mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7542aca687 | ||
|
|
1e906bb577 | ||
|
|
a8c367ad0c | ||
|
|
2d35cab39e | ||
|
|
874d7df2f7 | ||
|
|
4d39744d99 | ||
|
|
74da4f2a92 | ||
|
|
36f6d245f9 | ||
|
|
a44c23c0a8 | ||
|
|
337ded3839 | ||
|
|
8ec6c630bd | ||
|
|
a787272ceb | ||
|
|
0ca16d6e2d | ||
|
|
ecf2ea372b | ||
|
|
335de43db5 | ||
|
|
af077ddb24 | ||
|
|
84ea648c84 | ||
|
|
2d95c5787d | ||
|
|
19a2b8d2be | ||
|
|
ca27ca50bf | ||
|
|
d4615d6e76 | ||
|
|
9eff2e2cfc | ||
|
|
c2f8e9b734 |
No files matched your search
@@ -1,3 +1,11 @@
|
||||
<p align="center">
|
||||
<img src="docs/logo.svg" alt="Reasonix — DeepSeek-native agent framework" width="640"/>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<em>Cache-first agent loop for DeepSeek V3 & R1 — Ink TUI, MCP first-class, no LangChain.</em>
|
||||
</p>
|
||||
|
||||
# Reasonix
|
||||
|
||||
[](https://www.npmjs.com/package/reasonix)
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,95 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="170" viewBox="0 0 760 170" role="img" aria-label="Reasonix — DeepSeek-native agent framework">
|
||||
<title>Reasonix</title>
|
||||
<defs>
|
||||
<!-- Signature cyan → violet → fuchsia gradient used across the TUI wordmark
|
||||
and the brand mark. Renders well on both GitHub dark and light bg. -->
|
||||
<linearGradient id="rx-grad" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" stop-color="#5eead4"/>
|
||||
<stop offset="15%" stop-color="#67e8f9"/>
|
||||
<stop offset="30%" stop-color="#7dd3fc"/>
|
||||
<stop offset="45%" stop-color="#93c5fd"/>
|
||||
<stop offset="60%" stop-color="#a5b4fc"/>
|
||||
<stop offset="75%" stop-color="#c4b5fd"/>
|
||||
<stop offset="90%" stop-color="#d8b4fe"/>
|
||||
<stop offset="100%" stop-color="#f0abfc"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="rx-grad-mark" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#5eead4"/>
|
||||
<stop offset="50%" stop-color="#93c5fd"/>
|
||||
<stop offset="100%" stop-color="#c4b5fd"/>
|
||||
</linearGradient>
|
||||
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- ◈ brand mark: concentric diamonds. Outer stroke pulses,
|
||||
inner fill breathes, whole group rotates slowly. The rotation
|
||||
is subtle (12s / full turn) so it feels like a heartbeat, not a
|
||||
spinner. -->
|
||||
<g transform="translate(90, 85)" filter="url(#glow)">
|
||||
<g>
|
||||
<animateTransform attributeName="transform" type="rotate"
|
||||
from="0" to="360" dur="18s" repeatCount="indefinite"/>
|
||||
<!-- Outer hollow diamond -->
|
||||
<path d="M 0,-46 L 46,0 L 0,46 L -46,0 Z"
|
||||
fill="none"
|
||||
stroke="url(#rx-grad-mark)"
|
||||
stroke-width="3.5"
|
||||
stroke-linejoin="round">
|
||||
<animate attributeName="stroke-width"
|
||||
values="3.5;5.5;3.5"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
<!-- Middle hollow diamond -->
|
||||
<path d="M 0,-26 L 26,0 L 0,26 L -26,0 Z"
|
||||
fill="none"
|
||||
stroke="url(#rx-grad-mark)"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
opacity="0.7">
|
||||
<animate attributeName="opacity"
|
||||
values="0.4;0.9;0.4"
|
||||
dur="2.4s"
|
||||
begin="0.8s"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
<!-- Inner solid diamond -->
|
||||
<path d="M 0,-11 L 11,0 L 0,11 L -11,0 Z"
|
||||
fill="url(#rx-grad-mark)">
|
||||
<animate attributeName="opacity"
|
||||
values="0.75;1;0.75"
|
||||
dur="2.4s"
|
||||
repeatCount="indefinite"/>
|
||||
</path>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- REASONIX wordmark with shifting gradient. The x1 offset animates
|
||||
so the gradient crawls left-to-right across the letters — a
|
||||
slow rainbow sweep that gives the logo life without being noisy. -->
|
||||
<text x="170" y="102"
|
||||
font-family="ui-monospace, SFMono-Regular, 'Cascadia Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
||||
font-size="68"
|
||||
font-weight="800"
|
||||
fill="url(#rx-grad)"
|
||||
letter-spacing="6">REASONIX
|
||||
<animate attributeName="opacity"
|
||||
values="0.9;1;0.9"
|
||||
dur="3.2s"
|
||||
repeatCount="indefinite"/>
|
||||
</text>
|
||||
|
||||
<!-- Tagline sits just below the wordmark. Slate-500 reads well on
|
||||
both GitHub dark and light themes (~4.5:1 contrast either way). -->
|
||||
<text x="172" y="136"
|
||||
font-family="ui-monospace, SFMono-Regular, 'Cascadia Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace"
|
||||
font-size="15"
|
||||
fill="#64748b"
|
||||
letter-spacing="0.5">DeepSeek-native agent framework · cache-first loop · Ink TUI</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.8 KiB |
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "reasonix",
|
||||
"version": "0.4.27",
|
||||
"version": "0.5.19",
|
||||
"description": "DeepSeek-native agent framework: cache-first loop, R1 thought harvesting, tool-call repair.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -17,6 +17,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"data/deepseek-tokenizer.json.gz",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regenerate `data/deepseek-tokenizer.json.gz` from an upstream
|
||||
* `tokenizer.json` (as shipped in DeepSeek's deepseek_v3_tokenizer.zip).
|
||||
*
|
||||
* Why slim+gz: the full tokenizer.json is 7.5MB; we only need four
|
||||
* fields (vocab, merges, pre_tokenizer, added_tokens). Strip + gzip
|
||||
* drops the committed artifact to ~1.7MB with no loss of fidelity
|
||||
* for encode-side token counting.
|
||||
*
|
||||
* Usage: `node --experimental-strip-types scripts/prepare-tokenizer.ts <path/to/tokenizer.json>`
|
||||
* (or via `tsx` / `ts-node`.)
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { gzipSync } from "node:zlib";
|
||||
|
||||
const src = process.argv[2];
|
||||
if (!src) {
|
||||
process.stderr.write("usage: prepare-tokenizer.ts <tokenizer.json>\n");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const raw = JSON.parse(readFileSync(src, "utf8"));
|
||||
const slim = {
|
||||
added_tokens: raw.added_tokens,
|
||||
pre_tokenizer: raw.pre_tokenizer,
|
||||
model: { type: raw.model.type, vocab: raw.model.vocab, merges: raw.model.merges },
|
||||
};
|
||||
const gz = gzipSync(Buffer.from(JSON.stringify(slim)), { level: 9 });
|
||||
const outPath = join(process.cwd(), "data", "deepseek-tokenizer.json.gz");
|
||||
writeFileSync(outPath, gz);
|
||||
process.stdout.write(`wrote ${outPath} (${(gz.length / 1024).toFixed(1)} KB)\n`);
|
||||
@@ -0,0 +1,397 @@
|
||||
/**
|
||||
* Expand `@path/to/file` mentions in a user prompt to inline file
|
||||
* content.
|
||||
*
|
||||
* Why: most interactive coding sessions start with "look at X, then
|
||||
* change Y". Typing `@src/loop.ts` reads faster and cheaper than
|
||||
* "look at src/loop.ts (and the model fires read_file, and we pay for
|
||||
* the round trip)" — the model sees the file content from turn 1
|
||||
* instead of round-tripping a tool call for it.
|
||||
*
|
||||
* Shape: the user's text is kept verbatim. Expanded file contents are
|
||||
* appended in a "Referenced files" block at the end, each wrapped in
|
||||
* `<file path="...">...</file>` so the model can cite them back
|
||||
* unambiguously.
|
||||
*
|
||||
* Safety: paths must resolve inside `rootDir` (no `..` escape, no
|
||||
* absolute paths), must exist as a regular file, and must be under
|
||||
* `maxBytes`. Missing / too-large / escaping paths get a short note
|
||||
* appended instead of content so the user sees why it was skipped.
|
||||
*/
|
||||
|
||||
import { type Dirent, existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
|
||||
/** Caps match tool-result dispatch truncation (0.5.2). */
|
||||
export const DEFAULT_AT_MENTION_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Default directory names skipped when listing files for the picker.
|
||||
* Matches what most repos gitignore AND keeps the picker off the
|
||||
* hottest bloat — `node_modules` alone can be 100k+ entries.
|
||||
*/
|
||||
export const DEFAULT_PICKER_IGNORE_DIRS: readonly string[] = [
|
||||
"node_modules",
|
||||
".git",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
"out",
|
||||
"coverage",
|
||||
".cache",
|
||||
".vscode",
|
||||
".idea",
|
||||
"target",
|
||||
".venv",
|
||||
"venv",
|
||||
"__pycache__",
|
||||
];
|
||||
|
||||
export interface ListFilesOptions {
|
||||
/** Cap the walk once we've collected this many entries. Default 500. */
|
||||
maxResults?: number;
|
||||
/** Directory names to skip entirely. Defaults to {@link DEFAULT_PICKER_IGNORE_DIRS}. */
|
||||
ignoreDirs?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk `root` recursively and return relative file paths (forward-slash
|
||||
* separator, regardless of platform) for the `@` picker.
|
||||
*
|
||||
* Synchronous on purpose: this runs once at App mount (and on each turn
|
||||
* so newly-created files show up) and blocks the render thread for a
|
||||
* predictable ~10-50ms on a moderate repo. An async variant would need
|
||||
* to coordinate with the Ink render loop; sync fits the rest of the
|
||||
* TUI's single-turn-per-tick model cleanly.
|
||||
*
|
||||
* Skips:
|
||||
* - directories in `ignoreDirs` (default: DEFAULT_PICKER_IGNORE_DIRS)
|
||||
* - any directory whose name starts with `.` (covers `.git`,
|
||||
* `.vscode`, dotfile vendors). Dotfile REGULAR FILES (`.env`,
|
||||
* `.gitignore`, `.prettierrc`) are kept — users reference them.
|
||||
* - entries the walker can't read (permission errors, broken links).
|
||||
*/
|
||||
export function listFilesSync(root: string, opts: ListFilesOptions = {}): string[] {
|
||||
return listFilesWithStatsSync(root, opts).map((e) => e.path);
|
||||
}
|
||||
|
||||
export interface FileWithStats {
|
||||
/** Relative path with forward-slash separator. */
|
||||
path: string;
|
||||
/** Modification time (Date.getTime() / ms since epoch). 0 when stat failed. */
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same walk as {@link listFilesSync} but also statS each file for
|
||||
* modification time. Used by the `@` picker to surface recently-
|
||||
* edited files first — matches VS Code Quick Open / similar UX.
|
||||
*
|
||||
* Stat failures don't throw: the entry is kept with `mtimeMs: 0` so
|
||||
* it still appears in the picker (just sinks to the bottom of the
|
||||
* recency sort).
|
||||
*/
|
||||
export function listFilesWithStatsSync(root: string, opts: ListFilesOptions = {}): FileWithStats[] {
|
||||
const maxResults = Math.max(1, opts.maxResults ?? 500);
|
||||
const ignore = new Set(opts.ignoreDirs ?? DEFAULT_PICKER_IGNORE_DIRS);
|
||||
const rootAbs = resolve(root);
|
||||
const out: FileWithStats[] = [];
|
||||
|
||||
const walk = (dirAbs: string, dirRel: string) => {
|
||||
if (out.length >= maxResults) return;
|
||||
let entries: Dirent[];
|
||||
try {
|
||||
entries = readdirSync(dirAbs, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const ent of entries) {
|
||||
if (out.length >= maxResults) return;
|
||||
const relPath = dirRel ? `${dirRel}/${ent.name}` : ent.name;
|
||||
if (ent.isDirectory()) {
|
||||
if (ent.name.startsWith(".") || ignore.has(ent.name)) continue;
|
||||
walk(join(dirAbs, ent.name), relPath);
|
||||
} else if (ent.isFile()) {
|
||||
let mtimeMs = 0;
|
||||
try {
|
||||
mtimeMs = statSync(join(dirAbs, ent.name)).mtimeMs;
|
||||
} catch {
|
||||
/* stat failed (permission / EAGAIN) — keep the entry with mtime=0 */
|
||||
}
|
||||
out.push({ path: relPath, mtimeMs });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(rootAbs, "");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefix pattern used by the `@` picker to detect an IN-PROGRESS
|
||||
* mention at the END of the input buffer. Captures the partial path
|
||||
* (which may be empty — just `@`) so the picker can use it as a
|
||||
* substring filter.
|
||||
*
|
||||
* Distinct from {@link AT_MENTION_PATTERN} (which finds completed
|
||||
* mentions anywhere in the text for expansion-at-submit). This one
|
||||
* fires on the trailing token only, anchored at end-of-input.
|
||||
*/
|
||||
export const AT_PICKER_PREFIX = /(?:^|\s)@([a-zA-Z0-9_./\\-]*)$/;
|
||||
|
||||
/**
|
||||
* Return the picker state for a given input buffer: the partial query
|
||||
* (may be empty string — just `@`) and the buffer offset of the `@`
|
||||
* character. `null` when the buffer doesn't end in a mention-in-
|
||||
* progress.
|
||||
*/
|
||||
export function detectAtPicker(input: string): { query: string; atOffset: number } | null {
|
||||
const m = AT_PICKER_PREFIX.exec(input);
|
||||
if (!m) return null;
|
||||
const query = m[1] ?? "";
|
||||
// `m.index` is the offset of the capture group's SURROUNDING match —
|
||||
// which starts at either ^ or the preceding whitespace. The `@`
|
||||
// itself is at `end-of-input - query.length - 1`.
|
||||
const atOffset = input.length - query.length - 1;
|
||||
return { query, atOffset };
|
||||
}
|
||||
|
||||
/** A candidate accepted by the picker ranker — either a bare path or a path with mtime. */
|
||||
export type PickerCandidate = string | FileWithStats;
|
||||
|
||||
export interface RankPickerOptions {
|
||||
/** Upper bound on returned entries. Default 40. */
|
||||
limit?: number;
|
||||
/**
|
||||
* Paths the user or model has touched recently (via tool calls like
|
||||
* `read_file` / `edit_file`). Matching paths get a recency boost so
|
||||
* the picker surfaces "stuff I just looked at" near the top.
|
||||
*/
|
||||
recentlyUsed?: readonly string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and rank candidate files against the picker's partial query.
|
||||
*
|
||||
* Empty query:
|
||||
* - Sort by "recently used" bucket first (if provided), then mtime
|
||||
* descending (newer first), then path alpha.
|
||||
* - Pure-string input (no mtime data) falls back to alpha since
|
||||
* recency info isn't available.
|
||||
*
|
||||
* Non-empty query:
|
||||
* - Case-insensitive substring match, with a basename-prefix boost
|
||||
* so `lo` floats `loop.ts`-shaped paths to the top.
|
||||
* - Ties broken first by recently-used membership, then mtime.
|
||||
*
|
||||
* Back-compat: passes `string[]` through the same logic (mtime = 0,
|
||||
* recently-used still honored).
|
||||
*/
|
||||
export function rankPickerCandidates(
|
||||
files: readonly PickerCandidate[],
|
||||
query: string,
|
||||
limitOrOpts?: number | RankPickerOptions,
|
||||
): string[] {
|
||||
const opts: RankPickerOptions =
|
||||
typeof limitOrOpts === "number" ? { limit: limitOrOpts } : (limitOrOpts ?? {});
|
||||
const limit = opts.limit ?? 40;
|
||||
const recent = new Set(opts.recentlyUsed ?? []);
|
||||
|
||||
const entries: FileWithStats[] = files.map((f) =>
|
||||
typeof f === "string" ? { path: f, mtimeMs: 0 } : f,
|
||||
);
|
||||
|
||||
if (!query) {
|
||||
// Only re-sort when we actually have signal to sort by. If input
|
||||
// is bare strings (mtime = 0 everywhere) AND there's no recent-
|
||||
// used list, preserve input order so callers keep their existing
|
||||
// layout. Passing FileWithStats or a non-empty recentlyUsed opts
|
||||
// you into mtime+recency ranking.
|
||||
const anyMtime = entries.some((e) => e.mtimeMs > 0);
|
||||
if (!anyMtime && recent.size === 0) {
|
||||
return entries.slice(0, limit).map((e) => e.path);
|
||||
}
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
const aRecent = recent.has(a.path) ? 1 : 0;
|
||||
const bRecent = recent.has(b.path) ? 1 : 0;
|
||||
if (aRecent !== bRecent) return bRecent - aRecent;
|
||||
if (a.mtimeMs !== b.mtimeMs) return b.mtimeMs - a.mtimeMs;
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
return sorted.slice(0, limit).map((e) => e.path);
|
||||
}
|
||||
|
||||
const needle = query.toLowerCase();
|
||||
const scored: Array<{ path: string; score: number; mtimeMs: number; recent: boolean }> = [];
|
||||
for (const e of entries) {
|
||||
const lower = e.path.toLowerCase();
|
||||
const hit = lower.indexOf(needle);
|
||||
if (hit < 0) continue;
|
||||
const slash = lower.lastIndexOf("/");
|
||||
const base = slash >= 0 ? lower.slice(slash + 1) : lower;
|
||||
let score = 2;
|
||||
if (base.startsWith(needle)) score = 0;
|
||||
else if (lower.startsWith(needle)) score = 1;
|
||||
scored.push({
|
||||
path: e.path,
|
||||
score: score * 10_000 + hit,
|
||||
mtimeMs: e.mtimeMs,
|
||||
recent: recent.has(e.path),
|
||||
});
|
||||
}
|
||||
scored.sort((a, b) => {
|
||||
if (a.score !== b.score) return a.score - b.score;
|
||||
// Tie-break: recently-used, then mtime (newer first).
|
||||
if (a.recent !== b.recent) return a.recent ? -1 : 1;
|
||||
return b.mtimeMs - a.mtimeMs;
|
||||
});
|
||||
return scored.slice(0, limit).map((s) => s.path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches `@` at a word boundary (start-of-string or preceded by
|
||||
* whitespace) followed by a path-like token. Deliberately rejects `@`
|
||||
* embedded in longer words (email addresses, mentions on social sites)
|
||||
* by requiring the word boundary.
|
||||
*
|
||||
* Path charset keeps it to the characters that appear in real repo
|
||||
* paths — letters, digits, `_` `-` `.` `/` `\`. Trailing `.` (e.g.
|
||||
* `@foo.ts.`) is stripped before lookup so a sentence-terminating
|
||||
* period doesn't break the mention.
|
||||
*/
|
||||
export const AT_MENTION_PATTERN = /(?<=^|\s)@([a-zA-Z0-9_./\\-]+)/g;
|
||||
|
||||
export interface AtMentionExpansion {
|
||||
/** The raw `@path` token as it appeared in the text. */
|
||||
token: string;
|
||||
/** The relative path, as resolved against rootDir. */
|
||||
path: string;
|
||||
/** True if the content was inlined. False = skipped (reason in `skip`). */
|
||||
ok: boolean;
|
||||
/** Bytes read (only for ok=true). */
|
||||
bytes?: number;
|
||||
/** Why the mention was skipped. Set when ok=false. */
|
||||
skip?: "missing" | "not-file" | "too-large" | "escape" | "read-error";
|
||||
}
|
||||
|
||||
export interface AtMentionOptions {
|
||||
/** Max file size in bytes before a mention is skipped. */
|
||||
maxBytes?: number;
|
||||
/**
|
||||
* Optional file-system overrides for tests. Real callers omit these;
|
||||
* the helper falls through to `node:fs`.
|
||||
*/
|
||||
fs?: {
|
||||
exists: (path: string) => boolean;
|
||||
isFile: (path: string) => boolean;
|
||||
size: (path: string) => number;
|
||||
read: (path: string) => string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand `@path` mentions in `text`. Returns the (possibly augmented)
|
||||
* text plus a per-mention report so the caller can surface expansions
|
||||
* in the UI.
|
||||
*/
|
||||
export function expandAtMentions(
|
||||
text: string,
|
||||
rootDir: string,
|
||||
opts: AtMentionOptions = {},
|
||||
): { text: string; expansions: AtMentionExpansion[] } {
|
||||
const maxBytes = opts.maxBytes ?? DEFAULT_AT_MENTION_MAX_BYTES;
|
||||
const fs = opts.fs ?? defaultFs;
|
||||
const root = resolve(rootDir);
|
||||
// De-dupe by token so `@file.ts` referenced twice inlines once.
|
||||
const seen = new Map<string, AtMentionExpansion>();
|
||||
const expansions: AtMentionExpansion[] = [];
|
||||
|
||||
for (const match of text.matchAll(AT_MENTION_PATTERN)) {
|
||||
const rawPath = match[1] ?? "";
|
||||
// Strip trailing dot (sentence terminator): `@foo.ts.` → `@foo.ts`.
|
||||
// Keep internal dots intact.
|
||||
const cleaned = rawPath.replace(/\.+$/, "");
|
||||
if (!cleaned) continue;
|
||||
const token = `@${cleaned}`;
|
||||
if (seen.has(token)) continue;
|
||||
|
||||
const expansion = resolveMention(cleaned, root, maxBytes, fs);
|
||||
seen.set(token, expansion);
|
||||
expansions.push(expansion);
|
||||
}
|
||||
|
||||
if (expansions.length === 0) return { text, expansions };
|
||||
|
||||
// Build the trailing "Referenced files" block. Keep successful
|
||||
// inlines and skipped ones (with their reason) so the model sees
|
||||
// both what's here and what's missing.
|
||||
const blocks: string[] = [];
|
||||
for (const ex of expansions) {
|
||||
if (ex.ok) {
|
||||
const content = readSafe(root, ex.path, fs);
|
||||
blocks.push(`<file path="${ex.path}">\n${content}\n</file>`);
|
||||
} else {
|
||||
blocks.push(`<file path="${ex.path}" skipped="${ex.skip}" />`);
|
||||
}
|
||||
}
|
||||
const augmented = `${text}\n\n[Referenced files]\n${blocks.join("\n\n")}`;
|
||||
return { text: augmented, expansions };
|
||||
}
|
||||
|
||||
function resolveMention(
|
||||
rawPath: string,
|
||||
root: string,
|
||||
maxBytes: number,
|
||||
fs: NonNullable<AtMentionOptions["fs"]>,
|
||||
): AtMentionExpansion {
|
||||
// Reject absolute paths — `@/etc/passwd` should not inline.
|
||||
if (isAbsolute(rawPath)) {
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "escape" };
|
||||
}
|
||||
const resolved = resolve(root, rawPath);
|
||||
// Sandbox escape: after resolution the path must still be inside root.
|
||||
const rel = relative(root, resolved);
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "escape" };
|
||||
}
|
||||
if (!fs.exists(resolved)) {
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "missing" };
|
||||
}
|
||||
if (!fs.isFile(resolved)) {
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "not-file" };
|
||||
}
|
||||
const size = fs.size(resolved);
|
||||
if (size > maxBytes) {
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: false, skip: "too-large", bytes: size };
|
||||
}
|
||||
return { token: `@${rawPath}`, path: rawPath, ok: true, bytes: size };
|
||||
}
|
||||
|
||||
function readSafe(root: string, rawPath: string, fs: NonNullable<AtMentionOptions["fs"]>): string {
|
||||
const resolved = resolve(root, rawPath);
|
||||
try {
|
||||
return fs.read(resolved);
|
||||
} catch {
|
||||
return "(read failed)";
|
||||
}
|
||||
}
|
||||
|
||||
const defaultFs: NonNullable<AtMentionOptions["fs"]> = {
|
||||
exists: (p) => existsSync(p),
|
||||
isFile: (p) => {
|
||||
try {
|
||||
return statSync(p).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
size: (p) => {
|
||||
try {
|
||||
return statSync(p).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
read: (p) => readFileSync(p, "utf8"),
|
||||
};
|
||||
@@ -211,6 +211,7 @@ export async function chatCommand(opts: ChatOptions): Promise<void> {
|
||||
spec: raw,
|
||||
toolCount: bridge.registeredNames.length,
|
||||
report,
|
||||
client: mcp,
|
||||
});
|
||||
} catch (err) {
|
||||
// Per-server failure is non-fatal: one broken server shouldn't
|
||||
|
||||
@@ -41,6 +41,15 @@ export interface CodeOptions {
|
||||
forceResume?: boolean;
|
||||
/** Skip the session picker — always wipe prior messages and start fresh. */
|
||||
forceNew?: boolean;
|
||||
/**
|
||||
* Opt into Pillar 2 harvesting (extracts a typed plan state from R1
|
||||
* reasoning via an extra V3 call). Default OFF in code mode — the
|
||||
* displayed subgoals/hypotheses/rejectedPaths have no programmatic
|
||||
* consumer, only `uncertainties.length` feeds branching, and the
|
||||
* extra V3 call adds ~10-15% per-turn cost. Users who want the
|
||||
* reasoning surfaced explicitly can pass `--harvest`.
|
||||
*/
|
||||
harvest?: boolean;
|
||||
}
|
||||
|
||||
export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
@@ -87,7 +96,7 @@ export async function codeCommand(opts: CodeOptions = {}): Promise<void> {
|
||||
|
||||
await chatCommand({
|
||||
model: opts.model ?? "deepseek-reasoner",
|
||||
harvest: true, // smart preset's harvest setting, always on for code
|
||||
harvest: opts.harvest ?? false,
|
||||
system: codeSystemPrompt(rootDir),
|
||||
transcript: opts.transcript,
|
||||
session,
|
||||
|
||||
@@ -125,6 +125,30 @@ export function renderDashboard(agg: UsageAggregate, logPath: string): string {
|
||||
if (agg.firstSeen) {
|
||||
lines.push(`tracked since: ${new Date(agg.firstSeen).toISOString().slice(0, 10)}`);
|
||||
}
|
||||
if (agg.subagents) {
|
||||
lines.push("");
|
||||
lines.push(renderSubagentSection(agg.subagents));
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderSubagentSection(sub: NonNullable<UsageAggregate["subagents"]>): string {
|
||||
const lines: string[] = [];
|
||||
const seconds = (sub.totalDurationMs / 1000).toFixed(1);
|
||||
lines.push(
|
||||
`subagent activity: ${sub.total} run(s) · $${sub.costUsd.toFixed(6)} · ${seconds}s total`,
|
||||
);
|
||||
// Show at most 5 skills so the section never dwarfs the main table.
|
||||
const top = sub.bySkill.slice(0, 5);
|
||||
for (const s of top) {
|
||||
const sec = (s.durationMs / 1000).toFixed(1);
|
||||
lines.push(
|
||||
` ${pad(s.skillName, 18)} ${pad(`${s.count}`, 4, "right")} $${s.costUsd.toFixed(6)} ${sec}s`,
|
||||
);
|
||||
}
|
||||
if (sub.bySkill.length > top.length) {
|
||||
lines.push(` (+${sub.bySkill.length - top.length} more)`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
|
||||
+6
-1
@@ -68,13 +68,17 @@ program
|
||||
program
|
||||
.command("code [dir]")
|
||||
.description(
|
||||
"Code-editing chat — filesystem MCP auto-bridged at <dir> (default: cwd), coding system prompt, smart preset. Model proposes SEARCH/REPLACE blocks; Reasonix applies them to disk.",
|
||||
"Code-editing chat — filesystem tools rooted at <dir> (default: cwd), coding system prompt, deepseek-reasoner. Model proposes SEARCH/REPLACE blocks; Reasonix applies them to disk.",
|
||||
)
|
||||
.option("-m, --model <id>", "Override default reasoner model")
|
||||
.option("--no-session", "Disable session persistence for this run")
|
||||
.option("-r, --resume", "Skip the session picker — always continue prior messages")
|
||||
.option("-n, --new", "Skip the session picker — always wipe prior messages and start fresh")
|
||||
.option("--transcript <path>", "Write a JSONL transcript to this path")
|
||||
.option(
|
||||
"--harvest",
|
||||
"Extract typed plan state from R1 reasoning (Pillar 2). Adds ~10-15% cost per turn. Off by default in code mode.",
|
||||
)
|
||||
.action(async (dir: string | undefined, opts) => {
|
||||
await codeCommand({
|
||||
dir,
|
||||
@@ -83,6 +87,7 @@ program
|
||||
transcript: opts.transcript,
|
||||
forceResume: !!opts.resume,
|
||||
forceNew: !!opts.new,
|
||||
harvest: !!opts.harvest,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+531
-29
@@ -1,6 +1,14 @@
|
||||
import type { WriteStream } from "node:fs";
|
||||
import { Box, Static, Text, useApp, useInput } from "ink";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type FileWithStats,
|
||||
detectAtPicker,
|
||||
expandAtMentions,
|
||||
listFilesWithStatsSync,
|
||||
rankPickerCandidates,
|
||||
} from "../../at-mentions.js";
|
||||
import { formatAllBlockDiffs } from "../../code/diff-preview.js";
|
||||
import {
|
||||
type ApplyResult,
|
||||
type EditBlock,
|
||||
@@ -10,6 +18,7 @@ import {
|
||||
restoreSnapshots,
|
||||
snapshotBeforeEdits,
|
||||
} from "../../code/edit-blocks.js";
|
||||
import { clearPendingEdits, loadPendingEdits, savePendingEdits } from "../../code/pending-edits.js";
|
||||
import { addProjectShellAllowed } from "../../config.js";
|
||||
import { type ResolvedHook, formatHookOutcomeMessage, loadHooks, runHooks } from "../../hooks.js";
|
||||
import { CacheFirstLoop, DeepSeekClient, ImmutablePrefix } from "../../index.js";
|
||||
@@ -27,14 +36,26 @@ import {
|
||||
import { openTranscriptFile, recordFromLoopEvent, writeRecord } from "../../transcript.js";
|
||||
import { appendUsage } from "../../usage.js";
|
||||
import { VERSION, compareVersions, getLatestVersion } from "../../version.js";
|
||||
import { AtMentionSuggestions } from "./AtMentionSuggestions.js";
|
||||
import { type DisplayEvent, EventRow } from "./EventLog.js";
|
||||
import { PlanConfirm, type PlanConfirmChoice } from "./PlanConfirm.js";
|
||||
import { PlanRefineInput } from "./PlanRefineInput.js";
|
||||
import { PromptInput } from "./PromptInput.js";
|
||||
import { ShellConfirm, type ShellConfirmChoice, derivePrefix } from "./ShellConfirm.js";
|
||||
import { SlashArgPicker } from "./SlashArgPicker.js";
|
||||
import { SlashSuggestions } from "./SlashSuggestions.js";
|
||||
import { StatsPanel } from "./StatsPanel.js";
|
||||
import { type McpServerSummary, handleSlash, parseSlash, suggestSlashCommands } from "./slash.js";
|
||||
import { detectBangCommand, formatBangUserMessage } from "./bang.js";
|
||||
import { handleMcpBrowseSlash } from "./mcp-browse.js";
|
||||
import { formatLongPaste } from "./paste-collapse.js";
|
||||
import {
|
||||
type McpServerSummary,
|
||||
type SlashArgContext,
|
||||
detectSlashArgContext,
|
||||
handleSlash,
|
||||
parseSlash,
|
||||
suggestSlashCommands,
|
||||
} from "./slash.js";
|
||||
import { TickerProvider, useElapsedSeconds, useTick } from "./ticker.js";
|
||||
|
||||
export interface AppProps {
|
||||
@@ -156,6 +177,11 @@ export function App({
|
||||
// tracks reality. `null` means either the endpoint failed or we
|
||||
// haven't fetched yet; the panel hides the cell in that case.
|
||||
const [balance, setBalance] = useState<{ currency: string; total: number } | null>(null);
|
||||
// Model catalog fetched from DeepSeek's /models endpoint once at
|
||||
// launch. `null` while the call is in flight or failed; `[]` means
|
||||
// the API answered with zero models (unlikely but possible). Powers
|
||||
// /models and validation in /model.
|
||||
const [models, setModels] = useState<string[] | null>(null);
|
||||
// Latest published version the npm registry returned, REGARDLESS
|
||||
// of whether it's newer than what we're running. `null` only while
|
||||
// the background check is in flight or when the network fails —
|
||||
@@ -287,6 +313,151 @@ export function App({
|
||||
});
|
||||
}, [slashMatches]);
|
||||
|
||||
// File picker for `@` mentions — only meaningful in code mode where
|
||||
// the model has filesystem tools and expandAtMentions is active.
|
||||
const [atSelected, setAtSelected] = useState(0);
|
||||
// Walk the code root ONCE on mount, collecting mtime alongside path
|
||||
// so the picker can surface recently-edited files first. Files
|
||||
// created mid-session via tool edits won't appear until restart —
|
||||
// rare, and the user can always type the full path.
|
||||
const atFiles = useMemo<readonly FileWithStats[]>(() => {
|
||||
if (!codeMode?.rootDir) return [];
|
||||
try {
|
||||
return listFilesWithStatsSync(codeMode.rootDir, { maxResults: 500 });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [codeMode?.rootDir]);
|
||||
// LRU of files touched by recent tool calls (read_file / edit_file /
|
||||
// write_file / search_content / directory_tree). Seeds the picker
|
||||
// with "stuff I just looked at" at the top, so a user typing `@` in
|
||||
// the same file they were just discussing gets it instantly.
|
||||
const recentFilesRef = useRef<string[]>([]);
|
||||
const recordRecentFile = useCallback((p: string) => {
|
||||
const list = recentFilesRef.current;
|
||||
const i = list.indexOf(p);
|
||||
if (i >= 0) list.splice(i, 1);
|
||||
list.unshift(p);
|
||||
if (list.length > 20) list.length = 20;
|
||||
}, []);
|
||||
// Detect the trailing `@…` prefix and the partial query. `null` when
|
||||
// we're not in picker mode (non-code mode, buffer doesn't end in a
|
||||
// mention-in-progress, or already in slash mode).
|
||||
const atPicker = useMemo(() => {
|
||||
if (!codeMode?.rootDir) return null;
|
||||
// Slash prefix wins — avoids the picker confusingly surfacing on
|
||||
// `/@wat`-style edge inputs.
|
||||
if (slashMatches !== null) return null;
|
||||
return detectAtPicker(input);
|
||||
}, [codeMode?.rootDir, input, slashMatches]);
|
||||
const atMatches = useMemo<readonly string[] | null>(() => {
|
||||
if (!atPicker) return null;
|
||||
return rankPickerCandidates(atFiles, atPicker.query, {
|
||||
limit: 40,
|
||||
recentlyUsed: recentFilesRef.current,
|
||||
});
|
||||
}, [atPicker, atFiles]);
|
||||
useEffect(() => {
|
||||
setAtSelected((prev) => {
|
||||
if (!atMatches || atMatches.length === 0) return 0;
|
||||
if (prev >= atMatches.length) return atMatches.length - 1;
|
||||
return prev;
|
||||
});
|
||||
}, [atMatches]);
|
||||
// Substitute the trailing `@partial` with `@chosenPath ` and keep
|
||||
// the rest of the buffer intact. The trailing space auto-closes
|
||||
// the picker (regex no longer matches) so Enter next time submits
|
||||
// cleanly without needing an explicit dismissal.
|
||||
const pickAtMention = useCallback(
|
||||
(chosenPath: string) => {
|
||||
if (!atPicker) return;
|
||||
const before = input.slice(0, atPicker.atOffset);
|
||||
setInput(`${before}@${chosenPath} `);
|
||||
},
|
||||
[atPicker, input],
|
||||
);
|
||||
|
||||
// Slash-argument picker. After `/<cmd> <partial>` we surface one of
|
||||
// three states:
|
||||
// - file picker → reuses `atFiles` + rankPickerCandidates
|
||||
// - enum picker → literal values from spec.argCompleter
|
||||
// - usage hint only → dim "what goes here?" line
|
||||
// Mutually exclusive with the slash-name picker (those conditions
|
||||
// don't overlap by regex) and with the atMention picker (we don't
|
||||
// run both — slash args take priority inside a slash buffer).
|
||||
const [slashArgSelected, setSlashArgSelected] = useState(0);
|
||||
const slashArgContext = useMemo<SlashArgContext | null>(() => {
|
||||
// Only active inside a slash buffer that's past the name boundary.
|
||||
if (!input.startsWith("/")) return null;
|
||||
if (slashMatches !== null) return null;
|
||||
return detectSlashArgContext(input, !!codeMode);
|
||||
}, [input, slashMatches, codeMode]);
|
||||
const slashArgMatches = useMemo<readonly string[] | null>(() => {
|
||||
if (!slashArgContext || slashArgContext.kind !== "picker") return null;
|
||||
const completer = slashArgContext.spec.argCompleter;
|
||||
const partial = slashArgContext.partial;
|
||||
const needle = partial.toLowerCase();
|
||||
// Once the partial is an EXACT match for a valid completion, hide
|
||||
// the picker so Enter submits the command instead of re-picking
|
||||
// the same value in an infinite loop. Case-insensitive — users
|
||||
// may type `/preset FAST` and still mean the same thing.
|
||||
if (Array.isArray(completer)) {
|
||||
if (partial && completer.some((v) => v.toLowerCase() === needle)) return null;
|
||||
if (!partial) return completer.slice();
|
||||
return completer.filter((v) => v.toLowerCase().startsWith(needle));
|
||||
}
|
||||
if (completer === "models") {
|
||||
const all = models ?? [];
|
||||
if (partial && all.some((m) => m.toLowerCase() === needle)) return null;
|
||||
if (!partial) return all.slice(0, 40);
|
||||
return all.filter((m) => m.toLowerCase().includes(needle)).slice(0, 40);
|
||||
}
|
||||
if (completer === "mcp-resources") {
|
||||
// Aggregate URIs across every server's cached inspection. Prefix
|
||||
// with the server label when >1 server so users can tell them
|
||||
// apart — the read handler strips the prefix before calling.
|
||||
const uris: string[] = [];
|
||||
const servers = mcpServers ?? [];
|
||||
for (const s of servers) {
|
||||
if (!s.report.resources.supported) continue;
|
||||
for (const r of s.report.resources.items) uris.push(r.uri);
|
||||
}
|
||||
if (partial && uris.some((u) => u.toLowerCase() === needle)) return null;
|
||||
if (!partial) return uris.slice(0, 40);
|
||||
return uris.filter((u) => u.toLowerCase().includes(needle)).slice(0, 40);
|
||||
}
|
||||
if (completer === "mcp-prompts") {
|
||||
const names: string[] = [];
|
||||
const servers = mcpServers ?? [];
|
||||
for (const s of servers) {
|
||||
if (!s.report.prompts.supported) continue;
|
||||
for (const p of s.report.prompts.items) names.push(p.name);
|
||||
}
|
||||
if (partial && names.some((n) => n.toLowerCase() === needle)) return null;
|
||||
if (!partial) return names.slice(0, 40);
|
||||
return names.filter((n) => n.toLowerCase().includes(needle)).slice(0, 40);
|
||||
}
|
||||
return null;
|
||||
}, [slashArgContext, models, mcpServers]);
|
||||
useEffect(() => {
|
||||
setSlashArgSelected((prev) => {
|
||||
if (!slashArgMatches || slashArgMatches.length === 0) return 0;
|
||||
if (prev >= slashArgMatches.length) return slashArgMatches.length - 1;
|
||||
return prev;
|
||||
});
|
||||
}, [slashArgMatches]);
|
||||
const pickSlashArg = useCallback(
|
||||
(chosen: string) => {
|
||||
if (!slashArgContext) return;
|
||||
const before = input.slice(0, slashArgContext.partialOffset);
|
||||
// No trailing space — enum picks (preset / model / plan / branch /
|
||||
// harvest) take no further args, so the user presses Enter once
|
||||
// more to run the command.
|
||||
setInput(`${before}${chosen}`);
|
||||
},
|
||||
[slashArgContext, input],
|
||||
);
|
||||
|
||||
const loopRef = useRef<CacheFirstLoop | null>(null);
|
||||
// Sink the subagent tool emits live events through (`start` →
|
||||
// `progress` → `end`). App attaches its updater on first loop
|
||||
@@ -327,6 +498,9 @@ export function App({
|
||||
// else falls through to spawnSubagent's default.
|
||||
model: skill.model,
|
||||
sink: subagentSinkRef.current,
|
||||
// Stamped onto every event so the TUI sink + usage log can
|
||||
// attribute the run to a skill without extra bookkeeping.
|
||||
skillName: skill.name,
|
||||
});
|
||||
return formatSubagentResult(result);
|
||||
},
|
||||
@@ -375,6 +549,21 @@ export function App({
|
||||
};
|
||||
}, [loop]);
|
||||
|
||||
// Fetch the model catalog from DeepSeek once. Same pattern as
|
||||
// balance: silent degrade on failure (stays null), so /models can
|
||||
// tell "still loading / offline" apart from "loaded, here's the list."
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const list = await loop.client.listModels().catch(() => null);
|
||||
if (cancelled || !list) return;
|
||||
setModels(list.data.map((m) => m.id));
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [loop]);
|
||||
|
||||
// Background registry check — 24h disk cache absorbs repeated
|
||||
// launches, timeout bounded so a flaky network doesn't delay the
|
||||
// notification. Set to `null` on failure (silent: no network, no
|
||||
@@ -437,9 +626,13 @@ export function App({
|
||||
// end
|
||||
setSubagentActivity(null);
|
||||
const seconds = ((ev.elapsedMs ?? 0) / 1000).toFixed(1);
|
||||
// Inline cost: the one number most users look at. Shown in the
|
||||
// Historical row so they don't have to run `/stats` to see it.
|
||||
const costTail =
|
||||
ev.costUsd !== undefined && ev.costUsd > 0 ? ` · $${ev.costUsd.toFixed(4)}` : "";
|
||||
const summary = ev.error
|
||||
? `🧬 subagent "${ev.task}" failed after ${seconds}s · ${ev.iter ?? 0} tool call(s) — ${ev.error}`
|
||||
: `🧬 subagent "${ev.task}" done in ${seconds}s · ${ev.iter ?? 0} tool call(s) · ${ev.turns ?? 0} turn(s)`;
|
||||
? `⌬ subagent "${ev.task}" failed after ${seconds}s · ${ev.iter ?? 0} tool call(s) — ${ev.error}`
|
||||
: `⌬ subagent "${ev.task}" done in ${seconds}s · ${ev.iter ?? 0} tool call(s) · ${ev.turns ?? 0} turn(s)${costTail}`;
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{
|
||||
@@ -448,11 +641,28 @@ export function App({
|
||||
text: summary,
|
||||
},
|
||||
]);
|
||||
// Persist a subagent summary row to ~/.reasonix/usage.jsonl so
|
||||
// `/stats` and `reasonix stats` surface it. Skipped on error — we
|
||||
// only record what actually cost money and did work.
|
||||
if (!ev.error && ev.usage && ev.model) {
|
||||
appendUsage({
|
||||
session: session ?? null,
|
||||
model: ev.model,
|
||||
usage: ev.usage,
|
||||
kind: "subagent",
|
||||
subagent: {
|
||||
skillName: ev.skillName,
|
||||
taskPreview: ev.task.slice(0, 60),
|
||||
toolIters: ev.iter ?? 0,
|
||||
durationMs: ev.elapsedMs ?? 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
return () => {
|
||||
subagentSinkRef.current.current = null;
|
||||
};
|
||||
}, []);
|
||||
}, [session]);
|
||||
|
||||
// Surface a one-time banner about session state on first mount.
|
||||
const sessionBannerShown = useRef(false);
|
||||
@@ -487,7 +697,25 @@ export function App({
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [session, loop]);
|
||||
// Restore any pending edit queue from a prior run that was
|
||||
// interrupted before /apply or /discard. The checkpoint file sits
|
||||
// next to the session log; if present, we re-populate pendingEdits
|
||||
// and post an info row so the user knows what's waiting.
|
||||
if (session && codeMode) {
|
||||
const restored = loadPendingEdits(session);
|
||||
if (restored && restored.length > 0) {
|
||||
pendingEdits.current = restored;
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `sys-pending-${Date.now()}`,
|
||||
role: "info",
|
||||
text: `▸ restored ${restored.length} pending edit block(s) from an interrupted prior run — /apply to commit or /discard to drop.`,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
}, [session, loop, codeMode]);
|
||||
|
||||
// Esc during busy → forward to the loop as an abort signal. The loop
|
||||
// finishes the tool call in flight (we can't kill subprocess stdio
|
||||
@@ -512,6 +740,48 @@ export function App({
|
||||
// (hidden) prompt buffer. Bail early.
|
||||
if (pendingShell) return;
|
||||
|
||||
// @-mention picker takes the same priority tier as slash. When
|
||||
// the user is typing `@…` in code mode and there are file matches,
|
||||
// ↑/↓ walk the list and Tab substitutes the selected path. Enter
|
||||
// is caught in handleSubmit. Must come BEFORE slash so the two
|
||||
// pickers don't fight over arrow keys (mutually exclusive by
|
||||
// construction — atPicker is null when slashMatches is set).
|
||||
if (atMatches && atMatches.length > 0) {
|
||||
if (key.upArrow) {
|
||||
setAtSelected((i) => Math.max(0, i - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setAtSelected((i) => Math.min(atMatches.length - 1, i + 1));
|
||||
return;
|
||||
}
|
||||
if (key.tab) {
|
||||
const sel = atMatches[atSelected] ?? atMatches[0];
|
||||
if (sel) pickAtMention(sel);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Slash-argument picker. Fires inside `/<cmd> <partial>` — either
|
||||
// a file picker (for /edit), enum picker (for /preset, /model,
|
||||
// /plan, /branch, /harvest), or hint-only row. Navigation + Tab
|
||||
// substitute the highlighted value at the arg's offset.
|
||||
if (slashArgMatches && slashArgMatches.length > 0) {
|
||||
if (key.upArrow) {
|
||||
setSlashArgSelected((i) => Math.max(0, i - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
setSlashArgSelected((i) => Math.min(slashArgMatches.length - 1, i + 1));
|
||||
return;
|
||||
}
|
||||
if (key.tab) {
|
||||
const sel = slashArgMatches[slashArgSelected] ?? slashArgMatches[0];
|
||||
if (sel) pickSlashArg(sel);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Slash-suggestion mode takes priority over history recall.
|
||||
// When the user is typing a `/…` prefix and there are matches,
|
||||
// ↑/↓ walk the suggestion list and Tab snaps the input to the
|
||||
@@ -589,8 +859,9 @@ export function App({
|
||||
const anyApplied = results.some((r) => r.status === "applied" || r.status === "created");
|
||||
if (anyApplied) lastEditSnapshots.current = snaps;
|
||||
pendingEdits.current = [];
|
||||
clearPendingEdits(session ?? null);
|
||||
return formatEditResults(results);
|
||||
}, [codeMode]);
|
||||
}, [codeMode, session]);
|
||||
|
||||
/**
|
||||
* /discard callback — forget the pending edits without touching
|
||||
@@ -601,8 +872,9 @@ export function App({
|
||||
const count = pendingEdits.current.length;
|
||||
if (count === 0) return "nothing pending to discard.";
|
||||
pendingEdits.current = [];
|
||||
clearPendingEdits(session ?? null);
|
||||
return `▸ discarded ${count} pending edit block(s). Nothing was written to disk.`;
|
||||
}, []);
|
||||
}, [session]);
|
||||
|
||||
const prefixHash = loop.prefix.fingerprint;
|
||||
|
||||
@@ -639,6 +911,34 @@ export function App({
|
||||
let text = raw.trim();
|
||||
if (!text || busy) return;
|
||||
|
||||
// @-mention picker intercept. When the picker is open (trailing
|
||||
// `@…` with file matches), Enter substitutes the highlighted
|
||||
// path INTO the buffer and does NOT submit — the user almost
|
||||
// always types more after a mention ("look at @file.ts and…").
|
||||
// Substituting adds a trailing space which dismisses the picker,
|
||||
// so the next Enter submits normally.
|
||||
if (atMatches && atMatches.length > 0 && atPicker) {
|
||||
const sel = atMatches[atSelected] ?? atMatches[0];
|
||||
if (sel) {
|
||||
pickAtMention(sel);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Slash-argument picker intercept — same shape as @-picker. For
|
||||
// file pickers (/edit) we splice + trailing space so the user
|
||||
// keeps typing the instruction. For enum pickers (/preset,
|
||||
// /model, /plan, …) we splice without trailing space; those
|
||||
// commands take no further args, so the user presses Enter a
|
||||
// second time to run.
|
||||
if (slashArgMatches && slashArgMatches.length > 0 && slashArgContext) {
|
||||
const sel = slashArgMatches[slashArgSelected] ?? slashArgMatches[0];
|
||||
if (sel) {
|
||||
pickSlashArg(sel);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Slash auto-complete on Enter. When the user typed a prefix
|
||||
// (e.g. "/he") and the suggestion list is visible, substitute
|
||||
// the highlighted match so Enter runs it — same effect as Tab
|
||||
@@ -670,6 +970,73 @@ export function App({
|
||||
return;
|
||||
}
|
||||
|
||||
// Bash mode — `!cmd` runs a shell command in the sandbox root
|
||||
// immediately (no allowlist gate: user-typed = explicit consent),
|
||||
// surfaces the formatted output in the Historical log, and
|
||||
// persists a user-role message so the next model turn sees what
|
||||
// happened AND the bang exchange survives session resume.
|
||||
const bangCmd = detectBangCommand(text);
|
||||
if (bangCmd !== null) {
|
||||
const bangRoot = codeMode?.rootDir ?? process.cwd();
|
||||
promptHistory.current.push(text);
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `bang-u-${Date.now()}`,
|
||||
role: "user",
|
||||
text,
|
||||
leadSeparator: prev.length > 0,
|
||||
},
|
||||
]);
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await runCommand(bangCmd, {
|
||||
cwd: bangRoot,
|
||||
timeoutSec: 60,
|
||||
maxOutputChars: 32_000,
|
||||
});
|
||||
const formatted = formatCommandResult(bangCmd, result);
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{ id: `bang-o-${Date.now()}`, role: "info", text: formatted },
|
||||
]);
|
||||
loop.appendAndPersist({
|
||||
role: "user",
|
||||
content: formatBangUserMessage(bangCmd, formatted),
|
||||
});
|
||||
} catch (err) {
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: `bang-e-${Date.now()}`,
|
||||
role: "warning",
|
||||
text: `! command failed: ${(err as Error).message}`,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// MCP resource / prompt browsers — async calls that don't fit the
|
||||
// synchronous handleSlash shape, so we intercept the exact command
|
||||
// forms here. The slash-command registry still lists them (for
|
||||
// /help + argument-level picker completion), but this branch is
|
||||
// what actually runs the read/fetch.
|
||||
const mcpBrowseMatch = /^\/(resource|prompt)(?:\s+([\s\S]*))?$/.exec(text);
|
||||
if (mcpBrowseMatch) {
|
||||
const kind = mcpBrowseMatch[1] as "resource" | "prompt";
|
||||
const arg = mcpBrowseMatch[2]?.trim() ?? "";
|
||||
promptHistory.current.push(text);
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{ id: `mcp-u-${Date.now()}`, role: "user", text, leadSeparator: prev.length > 0 },
|
||||
]);
|
||||
await handleMcpBrowseSlash(kind, arg, mcpServers ?? [], setHistorical);
|
||||
return;
|
||||
}
|
||||
|
||||
const slash = parseSlash(text);
|
||||
if (slash) {
|
||||
const result = handleSlash(slash.cmd, slash.args, loop, {
|
||||
@@ -697,6 +1064,13 @@ export function App({
|
||||
if (fresh) setLatestVersion(fresh);
|
||||
})();
|
||||
},
|
||||
models,
|
||||
refreshModels: () => {
|
||||
void (async () => {
|
||||
const list = await loop.client.listModels().catch(() => null);
|
||||
if (list) setModels(list.data.map((m) => m.id));
|
||||
})();
|
||||
},
|
||||
});
|
||||
if (result.exit) {
|
||||
transcriptRef.current?.end();
|
||||
@@ -715,10 +1089,21 @@ export function App({
|
||||
text: result.info,
|
||||
},
|
||||
]);
|
||||
// /new wipes conversation; any pending edits from the prior
|
||||
// assistant turn are stale (the user no longer sees the
|
||||
// preview). Drop them so a later /apply doesn't surprise.
|
||||
if (codeMode) {
|
||||
pendingEdits.current = [];
|
||||
clearPendingEdits(session ?? null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.clear) {
|
||||
setHistorical([]);
|
||||
if (codeMode) {
|
||||
pendingEdits.current = [];
|
||||
clearPendingEdits(session ?? null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (result.info) {
|
||||
@@ -769,8 +1154,24 @@ export function App({
|
||||
}
|
||||
|
||||
// User message is immutable — push to Static immediately.
|
||||
// Large pastes (stack traces, log dumps, file contents) get a
|
||||
// collapsed preview in Historical so scrollback stays navigable;
|
||||
// the MODEL still receives the full text below via modelInput.
|
||||
promptHistory.current.push(text);
|
||||
setHistorical((prev) => [...prev, { id: `u-${Date.now()}`, role: "user", text }]);
|
||||
const pasteDisplay = formatLongPaste(text);
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
// `leadSeparator`: thin rule above this user turn when history
|
||||
// isn't empty — visual pacing for multi-turn sessions. First
|
||||
// user message leaves it off so the UI doesn't open with a
|
||||
// dangling divider.
|
||||
{
|
||||
id: `u-${Date.now()}`,
|
||||
role: "user",
|
||||
text: pasteDisplay.displayText,
|
||||
leadSeparator: prev.length > 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const assistantId = `a-${Date.now()}`;
|
||||
// Refs are the source of truth for accumulated streaming text; the React
|
||||
@@ -779,7 +1180,14 @@ export function App({
|
||||
const contentBuf = { current: "" };
|
||||
const reasoningBuf = { current: "" };
|
||||
// Coalesces tool_call_delta events into one re-render per flush tick.
|
||||
const toolCallBuildBuf: { current: { name: string; chars: number } | null } = {
|
||||
const toolCallBuildBuf: {
|
||||
current: {
|
||||
name: string;
|
||||
chars: number;
|
||||
index?: number;
|
||||
readyCount?: number;
|
||||
} | null;
|
||||
} = {
|
||||
current: null,
|
||||
};
|
||||
|
||||
@@ -810,8 +1218,35 @@ export function App({
|
||||
// streamRef does no visible work — skip the interval entirely.
|
||||
const timer = PLAIN_UI ? null : setInterval(flush, FLUSH_INTERVAL_MS);
|
||||
|
||||
// Expand `@path/to/file.ts` mentions in code mode: the model
|
||||
// gets the inlined content appended under a "Referenced files"
|
||||
// block; the Historical row above keeps the user's verbatim text
|
||||
// so the display doesn't balloon.
|
||||
let modelInput = text;
|
||||
if (codeMode?.rootDir) {
|
||||
const expanded = expandAtMentions(text, codeMode.rootDir);
|
||||
if (expanded.expansions.length > 0) {
|
||||
modelInput = expanded.text;
|
||||
const inlined = expanded.expansions
|
||||
.filter((ex) => ex.ok)
|
||||
.map((ex) => `${ex.path} (${(ex.bytes ?? 0).toLocaleString()} bytes)`);
|
||||
const skipped = expanded.expansions
|
||||
.filter((ex) => !ex.ok)
|
||||
.map((ex) => `${ex.path} (${ex.skip})`);
|
||||
const parts: string[] = [];
|
||||
if (inlined.length > 0) parts.push(`inlined ${inlined.join(", ")}`);
|
||||
if (skipped.length > 0) parts.push(`skipped ${skipped.join(", ")}`);
|
||||
if (parts.length > 0) {
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{ id: `at-${Date.now()}`, role: "info", text: `▸ @mentions: ${parts.join("; ")}` },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
for await (const ev of loop.step(text)) {
|
||||
for await (const ev of loop.step(modelInput)) {
|
||||
writeTranscript(ev);
|
||||
// Status lines are transient — any primary event (streaming
|
||||
// starts, a tool fires, etc.) means whatever we were waiting
|
||||
@@ -831,6 +1266,8 @@ export function App({
|
||||
toolCallBuildBuf.current = {
|
||||
name: ev.toolName,
|
||||
chars: ev.toolCallArgsChars ?? 0,
|
||||
index: ev.toolCallIndex,
|
||||
readyCount: ev.toolCallReadyCount,
|
||||
};
|
||||
}
|
||||
} else if (ev.role === "branch_start") {
|
||||
@@ -912,6 +1349,11 @@ export function App({
|
||||
const blocks = parseEditBlocks(finalText);
|
||||
if (blocks.length > 0) {
|
||||
pendingEdits.current = blocks;
|
||||
// Checkpoint the queue so a crash / Ctrl+C between
|
||||
// "blocks parsed" and "user /apply" doesn't lose the
|
||||
// edits. On next launch App.tsx's restore effect reads
|
||||
// this file. /apply + /discard clear it explicitly.
|
||||
savePendingEdits(session ?? null, blocks);
|
||||
setHistorical((prev) => [
|
||||
...prev,
|
||||
{
|
||||
@@ -929,6 +1371,29 @@ export function App({
|
||||
// the new spinner starts clean.
|
||||
setOngoingTool({ name: ev.toolName ?? "?", args: ev.toolArgs });
|
||||
setToolProgress(null);
|
||||
// Feed the `@` picker's recency LRU from tool args — any
|
||||
// path-shaped field (`path`, `file_path`, `file`) under a
|
||||
// filesystem tool call means the user/model is actively
|
||||
// working on that file. Picker surfaces it next time `@`
|
||||
// is typed, even if the file's mtime is stale.
|
||||
if (codeMode && ev.toolArgs) {
|
||||
try {
|
||||
const parsed = JSON.parse(ev.toolArgs) as {
|
||||
path?: unknown;
|
||||
file_path?: unknown;
|
||||
file?: unknown;
|
||||
};
|
||||
for (const k of ["path", "file_path", "file"] as const) {
|
||||
const v = parsed[k];
|
||||
if (typeof v === "string" && v.trim()) {
|
||||
recordRecentFile(v.trim());
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* malformed args — skip recency tracking */
|
||||
}
|
||||
}
|
||||
} else if (ev.role === "tool") {
|
||||
flush();
|
||||
setOngoingTool(null);
|
||||
@@ -1055,9 +1520,19 @@ export function App({
|
||||
latestVersion,
|
||||
mcpSpecs,
|
||||
mcpServers,
|
||||
models,
|
||||
planMode,
|
||||
session,
|
||||
slashSelected,
|
||||
atMatches,
|
||||
atPicker,
|
||||
atSelected,
|
||||
pickAtMention,
|
||||
recordRecentFile,
|
||||
slashArgMatches,
|
||||
slashArgContext,
|
||||
slashArgSelected,
|
||||
pickSlashArg,
|
||||
togglePlanMode,
|
||||
writeTranscript,
|
||||
],
|
||||
@@ -1202,6 +1677,22 @@ export function App({
|
||||
[pendingPlan, togglePlanMode, busy, loop, handleSubmit],
|
||||
);
|
||||
|
||||
// Ref-wrapped stable alias. `handlePlanConfirm` has deps that churn
|
||||
// every turn (busy toggles while the model is still streaming its
|
||||
// wrap-up) — passing it directly to `React.memo(PlanConfirm)` breaks
|
||||
// the memo's shallow prop compare, so even without the ticker the
|
||||
// picker re-rendered on every parent state change. The ref keeps the
|
||||
// identity stable across the whole picker lifetime; the callback
|
||||
// itself always reads the latest closure via `.current`.
|
||||
const handlePlanConfirmRef = useRef(handlePlanConfirm);
|
||||
useEffect(() => {
|
||||
handlePlanConfirmRef.current = handlePlanConfirm;
|
||||
}, [handlePlanConfirm]);
|
||||
const stableHandlePlanConfirm = useCallback(
|
||||
async (choice: PlanConfirmChoice) => handlePlanConfirmRef.current(choice),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Fired when the user submits feedback from the inline input. The
|
||||
* staged `mode` decides whether this is a refine or approve: refine
|
||||
@@ -1262,7 +1753,7 @@ export function App({
|
||||
}, [stagedInput]);
|
||||
|
||||
return (
|
||||
<TickerProvider disabled={PLAIN_UI}>
|
||||
<TickerProvider disabled={PLAIN_UI || !!pendingPlan || !!pendingShell}>
|
||||
<Box flexDirection="column">
|
||||
<StatsPanel
|
||||
summary={summary}
|
||||
@@ -1272,6 +1763,7 @@ export function App({
|
||||
branchBudget={loop.branchOptions.budget}
|
||||
planMode={planMode}
|
||||
balance={balance}
|
||||
busy={busy}
|
||||
updateAvailable={updateAvailable}
|
||||
/>
|
||||
<Static items={historical}>
|
||||
@@ -1328,7 +1820,11 @@ export function App({
|
||||
onCancel={handleStagedInputCancel}
|
||||
/>
|
||||
) : pendingPlan ? (
|
||||
<PlanConfirm plan={pendingPlan} onChoose={handlePlanConfirm} projectRoot={hookCwd} />
|
||||
<PlanConfirm
|
||||
plan={pendingPlan}
|
||||
onChoose={stableHandlePlanConfirm}
|
||||
projectRoot={hookCwd}
|
||||
/>
|
||||
) : pendingShell ? (
|
||||
<ShellConfirm
|
||||
command={pendingShell}
|
||||
@@ -1344,6 +1840,20 @@ export function App({
|
||||
disabled={busy}
|
||||
/>
|
||||
<SlashSuggestions matches={slashMatches} selectedIndex={slashSelected} />
|
||||
<AtMentionSuggestions
|
||||
matches={atMatches}
|
||||
selectedIndex={atSelected}
|
||||
query={atPicker?.query ?? ""}
|
||||
/>
|
||||
{slashArgContext ? (
|
||||
<SlashArgPicker
|
||||
matches={slashArgMatches}
|
||||
selectedIndex={slashArgSelected}
|
||||
spec={slashArgContext.spec}
|
||||
kind={slashArgContext.kind}
|
||||
partial={slashArgContext.partial}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1392,7 +1902,7 @@ function StatusRow({ text }: { text: string }) {
|
||||
* Live one-line indicator for a running subagent. Sits below the
|
||||
* OngoingToolRow (the parent's tool dispatch row for `spawn_subagent`)
|
||||
* so the user sees both layers at once: outer "spawn_subagent
|
||||
* running…" + inner "🧬 subagent: <task> · iter N · 12.3s". Cleared
|
||||
* running…" + inner "⌬ subagent: <task> · iter N · 12.3s". Cleared
|
||||
* when the subagent ends; a one-line summary lands in historical.
|
||||
*/
|
||||
function SubagentRow({
|
||||
@@ -1405,7 +1915,7 @@ function SubagentRow({
|
||||
return (
|
||||
<Box paddingLeft={2}>
|
||||
<Text color="magenta">{SPINNER_FRAMES[tick % SPINNER_FRAMES.length]}</Text>
|
||||
<Text color="magenta">{` 🧬 subagent: ${activity.task}`}</Text>
|
||||
<Text color="magenta">{` ⌬ subagent: ${activity.task}`}</Text>
|
||||
<Text dimColor>{` · iter ${activity.iter} · ${seconds}s`}</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -1538,24 +2048,16 @@ function formatEditResults(results: ApplyResult[]): string {
|
||||
|
||||
/**
|
||||
* Pending-edits preview shown after each assistant turn that proposed
|
||||
* changes. We keep it deliberately thin — path + approximate
|
||||
* ±line-count — because a full diff would crowd the TUI and the user
|
||||
* can always run `git diff` after /apply.
|
||||
* changes. Per-block path header + ±line-count, then a unified-diff-
|
||||
* style preview (context trimmed to 2 lines each side, total capped
|
||||
* at 20 lines per block). Users can eyeball what's about to land
|
||||
* BEFORE pressing `y` — the old summary-only view was a common
|
||||
* mistake surface.
|
||||
*/
|
||||
function formatPendingPreview(blocks: EditBlock[]): string {
|
||||
const lines = blocks.map((b) => {
|
||||
const removed = b.search === "" ? 0 : countLines(b.search);
|
||||
const added = countLines(b.replace);
|
||||
const tag = b.search === "" ? "NEW " : " ";
|
||||
return ` ${tag}${b.path} (-${removed} +${added} lines)`;
|
||||
});
|
||||
const header = `▸ ${blocks.length} pending edit block(s) — /apply (or y) to commit · /discard (or n) to drop`;
|
||||
return [header, ...lines].join("\n");
|
||||
}
|
||||
|
||||
function countLines(s: string): number {
|
||||
if (s.length === 0) return 0;
|
||||
return (s.match(/\n/g)?.length ?? 0) + 1;
|
||||
const diffLines = formatAllBlockDiffs(blocks);
|
||||
return [header, ...diffLines].join("\n");
|
||||
}
|
||||
|
||||
function formatUndoResults(results: ApplyResult[]): string {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig.jsx = "react" needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
|
||||
export interface AtMentionSuggestionsProps {
|
||||
/**
|
||||
* Current matching file paths, ranked by the picker. `null` means
|
||||
* "not in @-prefix mode" — render nothing. Empty array means "in @
|
||||
* mode but no files match that partial" — render a hint.
|
||||
*/
|
||||
matches: readonly string[] | null;
|
||||
/** Index (within `matches`) of the currently highlighted row. */
|
||||
selectedIndex: number;
|
||||
/** The partial query the user typed after `@`. Shown in the hint row. */
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating `@`-mention picker. Rendered below the input box when the
|
||||
* user is typing an `@…` prefix in code mode. Navigation state lives
|
||||
* in the parent (App.tsx owns `atSelected`) — this component is pure
|
||||
* display. Mirrors {@link SlashSuggestions} shape so the keybindings
|
||||
* and layout feel identical across the two pickers.
|
||||
*/
|
||||
export function AtMentionSuggestions({
|
||||
matches,
|
||||
selectedIndex,
|
||||
query,
|
||||
}: AtMentionSuggestionsProps): React.ReactElement | null {
|
||||
if (matches === null) return null;
|
||||
if (matches.length === 0) {
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text color="yellow">no files match "@{query}"</Text>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
— keep typing, or Backspace to edit. Paths resolve from the code root.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const MAX = 8;
|
||||
const total = matches.length;
|
||||
const windowStart =
|
||||
total <= MAX ? 0 : Math.max(0, Math.min(selectedIndex - Math.floor(MAX / 2), total - MAX));
|
||||
const shown = matches.slice(windowStart, windowStart + MAX);
|
||||
const hiddenAbove = windowStart;
|
||||
const hiddenBelow = total - windowStart - shown.length;
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
{hiddenAbove > 0 ? <Text dimColor> ↑ {hiddenAbove} more above</Text> : null}
|
||||
{shown.map((path, i) => (
|
||||
<FileRow key={path} path={path} isSelected={windowStart + i === selectedIndex} />
|
||||
))}
|
||||
{hiddenBelow > 0 ? <Text dimColor> ↓ {hiddenBelow} more below</Text> : null}
|
||||
<Text dimColor> [↑↓] navigate · [Tab]/[Enter] pick · file content inlined on send</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function FileRow({ path, isSelected }: { path: string; isSelected: boolean }) {
|
||||
const marker = isSelected ? "▸" : " ";
|
||||
// Split the path so the basename visually pops — same dropdown
|
||||
// affordance as VS Code's Quick Open.
|
||||
const slash = path.lastIndexOf("/");
|
||||
const dir = slash >= 0 ? `${path.slice(0, slash)}/` : "";
|
||||
const base = slash >= 0 ? path.slice(slash + 1) : path;
|
||||
if (isSelected) {
|
||||
return (
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
{marker} {base}
|
||||
</Text>
|
||||
<Text color="cyan">{dir ? ` ${dir}` : ""}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{marker} {base}
|
||||
{dir ? ` ${dir}` : ""}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
+228
-74
@@ -1,4 +1,4 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import React from "react";
|
||||
import { type TypedPlanState, isPlanStateEmpty } from "../../harvest.js";
|
||||
import type { BranchProgress, BranchSummary } from "../../loop.js";
|
||||
@@ -21,7 +21,46 @@ export interface DisplayEvent {
|
||||
stats?: TurnStats;
|
||||
repair?: string;
|
||||
streaming?: boolean;
|
||||
toolCallBuild?: { name: string; chars: number };
|
||||
toolCallBuild?: { name: string; chars: number; index?: number; readyCount?: number };
|
||||
/**
|
||||
* Render a thin horizontal rule above this event. Used to mark
|
||||
* "start of a new user turn" so long scrollbacks get visual
|
||||
* pacing — without it, five-turn sessions read as one wall of
|
||||
* text. Only the *first* user message in a session should leave
|
||||
* this off; App.tsx sets it based on whether history is empty.
|
||||
*/
|
||||
leadSeparator?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasonix visual language: every event is anchored by a geometric
|
||||
* glyph in its role color. Together they form a consistent alphabet
|
||||
* (◇ ◆ ▣ ▥ ▲ ✦) the eye learns fast — no text labels needed after
|
||||
* the first couple of turns. `◈` is the brand mark, reserved for
|
||||
* the app header.
|
||||
*/
|
||||
const ROLE_GLYPH = {
|
||||
user: "◇",
|
||||
assistant: "◆",
|
||||
assistantPulse: "◇", // pulse alternate for streaming state
|
||||
toolOk: "▣",
|
||||
toolErr: "▥",
|
||||
warning: "▲",
|
||||
error: "✦",
|
||||
} as const;
|
||||
|
||||
function RoleGlyph({
|
||||
glyph,
|
||||
color,
|
||||
}: {
|
||||
glyph: string;
|
||||
color: "cyan" | "green" | "yellow" | "red" | "magenta" | "blue";
|
||||
}) {
|
||||
return (
|
||||
<Text color={color} bold>
|
||||
{glyph}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export const EventRow = React.memo(function EventRow({
|
||||
@@ -33,11 +72,12 @@ export const EventRow = React.memo(function EventRow({
|
||||
}) {
|
||||
if (event.role === "user") {
|
||||
return (
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
you ›{" "}
|
||||
<Box marginTop={event.leadSeparator ? 1 : 0}>
|
||||
<RoleGlyph glyph={ROLE_GLYPH.user} color="cyan" />
|
||||
<Text>
|
||||
{" "}
|
||||
{event.text}
|
||||
</Text>
|
||||
<Text>{event.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -46,33 +86,34 @@ export const EventRow = React.memo(function EventRow({
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text bold color="green">
|
||||
assistant
|
||||
</Text>
|
||||
<RoleGlyph glyph={ROLE_GLYPH.assistant} color="green" />
|
||||
{event.stats ? <Text dimColor>{` ${event.stats.model}`}</Text> : null}
|
||||
</Box>
|
||||
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
|
||||
{event.branch ? <BranchBlock branch={event.branch} /> : null}
|
||||
{event.reasoning ? <ReasoningBlock reasoning={event.reasoning} /> : null}
|
||||
{!isPlanStateEmpty(event.planState) ? (
|
||||
<PlanStateBlock planState={event.planState!} />
|
||||
) : null}
|
||||
{event.text ? (
|
||||
<Markdown text={event.text} projectRoot={projectRoot} />
|
||||
) : (
|
||||
<Text dimColor>(no content)</Text>
|
||||
)}
|
||||
{event.stats ? <StatsLine stats={event.stats} /> : null}
|
||||
{event.repair ? <Text color="magenta">{event.repair}</Text> : null}
|
||||
</Box>
|
||||
{event.branch ? <BranchBlock branch={event.branch} /> : null}
|
||||
{event.reasoning ? <ReasoningBlock reasoning={event.reasoning} /> : null}
|
||||
{!isPlanStateEmpty(event.planState) ? (
|
||||
<PlanStateBlock planState={event.planState!} />
|
||||
) : null}
|
||||
{event.text ? (
|
||||
<Markdown text={event.text} projectRoot={projectRoot} />
|
||||
) : (
|
||||
<Text dimColor>(no content)</Text>
|
||||
)}
|
||||
{event.stats ? <StatsLine stats={event.stats} /> : null}
|
||||
{event.repair ? <Text color="magenta">{event.repair}</Text> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (event.role === "tool") {
|
||||
// `flattenMcpResult` prefixes server-side errors with "ERROR: ".
|
||||
// Render those in red with a ✗ marker so they don't blend into
|
||||
// successful tool output (yellow) — the failure mode is what the
|
||||
// model most likely needs to act on next, and the user needs to
|
||||
// see at a glance.
|
||||
// Render the tool glyph in red with a ✗ direction marker so the
|
||||
// failure mode is unmissable — it's the signal the model needs
|
||||
// most for its next decision.
|
||||
const isError = event.text.startsWith("ERROR:");
|
||||
const color = isError ? "red" : "yellow";
|
||||
const glyph = isError ? ROLE_GLYPH.toolErr : ROLE_GLYPH.toolOk;
|
||||
const marker = isError ? "✗" : "→";
|
||||
// `edit_file` results get a dedicated diff renderer — colored
|
||||
// line-by-line so `-` removals show red, `+` additions show
|
||||
@@ -83,25 +124,31 @@ export const EventRow = React.memo(function EventRow({
|
||||
(event.toolName === "edit_file" || event.toolName?.endsWith("_edit_file")) && !isError;
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Text color={color}>{`tool<${event.toolName ?? "?"}> ${marker}`}</Text>
|
||||
{isEditFile ? (
|
||||
<EditFileDiff text={event.text} />
|
||||
) : (
|
||||
<Text color={isError ? "red" : undefined} dimColor={!isError}>
|
||||
{" "}
|
||||
{truncate(event.text, 400)}
|
||||
</Text>
|
||||
)}
|
||||
<Box>
|
||||
<RoleGlyph glyph={glyph} color={color} />
|
||||
<Text color={color} bold>{` ${event.toolName ?? "?"}`}</Text>
|
||||
<Text color={color} dimColor>{` ${marker}`}</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" paddingLeft={2} marginTop={1}>
|
||||
{isEditFile ? (
|
||||
<EditFileDiff text={event.text} />
|
||||
) : (
|
||||
<Text color={isError ? "red" : undefined} dimColor={!isError}>
|
||||
{truncate(event.text, 400)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (event.role === "error") {
|
||||
return (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red" bold>
|
||||
error{" "}
|
||||
<RoleGlyph glyph={ROLE_GLYPH.error} color="red" />
|
||||
<Text color="red">
|
||||
{" "}
|
||||
{event.text}
|
||||
</Text>
|
||||
<Text color="red">{event.text}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -115,8 +162,11 @@ export const EventRow = React.memo(function EventRow({
|
||||
if (event.role === "warning") {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="yellow">▸ </Text>
|
||||
<Text color="yellow">{event.text}</Text>
|
||||
<RoleGlyph glyph={ROLE_GLYPH.warning} color="yellow" />
|
||||
<Text color="yellow">
|
||||
{" "}
|
||||
{event.text}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -127,6 +177,32 @@ export const EventRow = React.memo(function EventRow({
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Thin horizontal rule between turns with a cyan ◆ centered as the
|
||||
* Reasonix mark. Past-turn separators live inside `<Static>` (Ink's
|
||||
* render-once optimization), so animation here would freeze at
|
||||
* tick=0 on every past turn — we keep it static by design. The
|
||||
* animated heartbeat lives in PulsingAssistantGlyph (current turn)
|
||||
* and the Wordmark (StatsPanel) where rerender is free.
|
||||
*/
|
||||
function TurnSeparator() {
|
||||
const { stdout } = useStdout();
|
||||
const cols = stdout?.columns ?? 80;
|
||||
const width = Math.max(16, cols - 2);
|
||||
const half = Math.floor((width - 3) / 2);
|
||||
const left = "─".repeat(half);
|
||||
const right = "─".repeat(width - half - 3);
|
||||
return (
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<Text dimColor>{left}</Text>
|
||||
<Text color="cyan" bold>
|
||||
{" ◆ "}
|
||||
</Text>
|
||||
<Text dimColor>{right}</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the payload of an `edit_file` tool result with proper
|
||||
* diff coloring: first line is the header ("edited X (A→B chars)"),
|
||||
@@ -193,7 +269,7 @@ function BranchBlock({ branch }: { branch: BranchSummary }) {
|
||||
return (
|
||||
<Box>
|
||||
<Text color="blue">
|
||||
{"🔀 branched "}
|
||||
{"⎇ branched "}
|
||||
<Text bold>{branch.budget}</Text>
|
||||
{` samples → picked #${branch.chosenIndex} `}
|
||||
<Text dimColor>{per}</Text>
|
||||
@@ -212,11 +288,15 @@ function ReasoningBlock({ reasoning }: { reasoning: string }) {
|
||||
// Users can dump the full reasoning with `/think` if needed.
|
||||
const preview =
|
||||
flat.length <= max ? flat : `… (+${flat.length - max} earlier chars) ${flat.slice(-max)}`;
|
||||
// ▏ (LEFT ONE EIGHTH BLOCK) renders as a thin vertical rule at the
|
||||
// cell edge — subtler than the role-level ▎ above, giving a
|
||||
// visual hierarchy: thick bar = role, thin bar = nested detail
|
||||
// under that role (thinking, stats, citations, etc).
|
||||
return (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>▏ </Text>
|
||||
<Text dimColor italic>
|
||||
{"↳ thinking: "}
|
||||
{preview}
|
||||
thinking {preview}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -234,6 +314,22 @@ function Elapsed() {
|
||||
return <Text dimColor>{`${mm}:${ss}`}</Text>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Animated role glyph for the actively-streaming assistant. Alternates
|
||||
* ◆ ↔ ◇ every ~480ms so the user can see the model is alive even
|
||||
* during R1's long pre-first-byte silence. Settles back to a static
|
||||
* ◆ once the turn ends (StreamingAssistant is unmounted).
|
||||
*/
|
||||
function PulsingAssistantGlyph() {
|
||||
const tick = useTick();
|
||||
const on = Math.floor(tick / 4) % 2 === 0;
|
||||
return (
|
||||
<Text color="green" bold>
|
||||
{on ? ROLE_GLYPH.assistant : ROLE_GLYPH.assistantPulse}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
if (event.branchProgress) {
|
||||
const p = event.branchProgress;
|
||||
@@ -242,15 +338,17 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text bold color="green">
|
||||
assistant{" "}
|
||||
</Text>
|
||||
<PulsingAssistantGlyph />
|
||||
<Text color="blue">
|
||||
🔀 launching {p.total} parallel samples (R1 thinking in parallel)…{" "}
|
||||
{" ⎇ launching "}
|
||||
{p.total}
|
||||
{" parallel samples (R1 thinking in parallel)… "}
|
||||
</Text>
|
||||
<Elapsed />
|
||||
</Box>
|
||||
<Text dimColor>{" "}spread across T=0.0/0.5/1.0 · typical wait 30-90s for reasoner</Text>
|
||||
<Text color="yellow">
|
||||
{" "}spread across T=0.0/0.5/1.0 · reasoner typically takes 30-90s — this is normal
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -258,11 +356,13 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text bold color="green">
|
||||
assistant{" "}
|
||||
</Text>
|
||||
<PulsingAssistantGlyph />
|
||||
<Text color="blue">
|
||||
🔀 branching {p.completed}/{p.total} ({pct}%){" "}
|
||||
{" ⎇ branching "}
|
||||
{p.completed}/{p.total}
|
||||
{" ("}
|
||||
{pct}
|
||||
{"%) "}
|
||||
</Text>
|
||||
<Elapsed />
|
||||
</Box>
|
||||
@@ -305,13 +405,15 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
label = `R1 reasoning · ${event.reasoning?.length ?? 0} chars of thought`;
|
||||
labelColor = "cyan";
|
||||
} else if (toolCallOnly) {
|
||||
label = `assembling tool call <${toolCallBuild.name}> · ${toolCallBuild.chars} chars of arguments`;
|
||||
label = `assembling tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars of arguments${formatReadyTail(toolCallBuild)}`;
|
||||
labelColor = "magenta";
|
||||
} else {
|
||||
const parts: string[] = [`writing response · ${event.text.length} chars`];
|
||||
if (event.reasoning) parts.push(`after ${event.reasoning.length} chars of reasoning`);
|
||||
if (toolCallBuild) {
|
||||
parts.push(`building tool call <${toolCallBuild.name}> · ${toolCallBuild.chars} chars`);
|
||||
parts.push(
|
||||
`building tool call${formatToolCallIndex(toolCallBuild)} <${toolCallBuild.name}> · ${toolCallBuild.chars} chars${formatReadyTail(toolCallBuild)}`,
|
||||
);
|
||||
}
|
||||
label = parts.join(" · ");
|
||||
labelColor = "green";
|
||||
@@ -319,9 +421,8 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text bold color="green">
|
||||
assistant{" "}
|
||||
</Text>
|
||||
<PulsingAssistantGlyph />
|
||||
<Text>{" "}</Text>
|
||||
<Pulse />
|
||||
<Text color={labelColor}>{` ${label} `}</Text>
|
||||
<Elapsed />
|
||||
@@ -334,22 +435,24 @@ function StreamingAssistant({ event }: { event: DisplayEvent }) {
|
||||
{tail ? (
|
||||
<Text dimColor>▸ {tail}</Text>
|
||||
) : preFirstByte ? (
|
||||
<Text dimColor italic>
|
||||
{" connection open, first byte typically in 5-60s depending on model + load"}
|
||||
// Non-dim yellow: first-time users misread the dim version as
|
||||
// "app frozen". The reassurance has to be VISIBLE to do its job.
|
||||
<Text color="yellow" italic>
|
||||
{" waiting for first byte — this is normal, typically 5-60s depending on model + load"}
|
||||
</Text>
|
||||
) : reasoningOnly ? (
|
||||
<Text color="yellow" dimColor>
|
||||
<Text color="yellow" italic>
|
||||
{
|
||||
" R1 is thinking before it speaks — body text starts when reasoning completes (typically 20-90s)."
|
||||
" R1 is thinking before it speaks — body text arrives when reasoning finishes (typically 20-90s, this is normal)"
|
||||
}
|
||||
</Text>
|
||||
) : toolCallOnly ? (
|
||||
<Text color="magenta" dimColor>
|
||||
<Text color="magenta" italic>
|
||||
{" 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 color="yellow" italic>
|
||||
{" R1 still reasoning — body text or tool call arrives when thinking finishes"}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
@@ -368,6 +471,29 @@ function Pulse() {
|
||||
return <Text color="cyan">{frames[Math.floor(tick / 4) % frames.length]}</Text>;
|
||||
}
|
||||
|
||||
/**
|
||||
* "(call 3)" suffix for the tool-call label when we have the index.
|
||||
* Hides when the turn only has one call so the common case stays tidy.
|
||||
*/
|
||||
function formatToolCallIndex(tb: { index?: number; readyCount?: number } | undefined): string {
|
||||
if (!tb || tb.index === undefined) return "";
|
||||
// Only show the index when this isn't obviously the first + only call.
|
||||
if (tb.index === 0 && (tb.readyCount ?? 0) === 0) return "";
|
||||
return ` (call ${tb.index + 1})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* "· 2 ready" tail that answers "how many tool calls have finished
|
||||
* streaming so far this turn?" — user feedback for multi-file turns
|
||||
* where the response takes 10–30s to stream. Hidden until readyCount
|
||||
* > 0 so single-call turns aren't cluttered.
|
||||
*/
|
||||
function formatReadyTail(tb: { readyCount?: number } | undefined): string {
|
||||
const n = tb?.readyCount ?? 0;
|
||||
if (n <= 0) return "";
|
||||
return ` · ${n} ready`;
|
||||
}
|
||||
|
||||
function lastLine(s: string, maxChars: number): string {
|
||||
const flat = s.replace(/\s+/g, " ").trim();
|
||||
if (!flat) return "";
|
||||
@@ -377,19 +503,47 @@ function lastLine(s: string, maxChars: number): string {
|
||||
function StatsLine({ stats }: { stats: TurnStats }) {
|
||||
const hit = (stats.cacheHitRatio * 100).toFixed(1);
|
||||
return (
|
||||
<Text dimColor>
|
||||
{" ↳ cache "}
|
||||
{hit}
|
||||
{"% · tokens "}
|
||||
{stats.usage.promptTokens}
|
||||
{"→"}
|
||||
{stats.usage.completionTokens}
|
||||
{" · $"}
|
||||
{stats.cost.toFixed(6)}
|
||||
</Text>
|
||||
<Box>
|
||||
<Text dimColor>▏ </Text>
|
||||
<Text dimColor>
|
||||
{"cache "}
|
||||
{hit}
|
||||
{"% · tokens "}
|
||||
{stats.usage.promptTokens}
|
||||
{" → "}
|
||||
{stats.usage.completionTokens}
|
||||
{" · $"}
|
||||
{stats.cost.toFixed(6)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the *tail* of a long tool result by default — the useful signal
|
||||
* (return value, summary, error line) usually lives at the end, not the
|
||||
* top-of-file boilerplate. For `ERROR:`-prefixed results, keep the
|
||||
* first line intact (that's the actual error message) AND include the
|
||||
* tail, so stack-trace endings stay visible too.
|
||||
*
|
||||
* Rendered like: `(+N earlier chars) …<tail>` so users can tell at a
|
||||
* glance that something was elided. `/tool N` still dumps the full
|
||||
* content for cases where the middle matters.
|
||||
*/
|
||||
function truncate(s: string, max: number): string {
|
||||
return s.length <= max ? s : `${s.slice(0, max)}… (+${s.length - max} chars)`;
|
||||
if (s.length <= max) return s;
|
||||
if (s.startsWith("ERROR:")) {
|
||||
const firstNl = s.indexOf("\n");
|
||||
const firstLine = firstNl === -1 ? s : s.slice(0, firstNl);
|
||||
// If the first line alone is bigger than max, just show it truncated.
|
||||
if (firstLine.length >= max) {
|
||||
return `${firstLine.slice(0, max)}… (+${s.length - max} chars — /tool N for full)`;
|
||||
}
|
||||
const budget = max - firstLine.length - 10; // reserve space for separator + ellipsis
|
||||
const tail = s.slice(-budget);
|
||||
const skipped = s.length - firstLine.length - tail.length;
|
||||
return `${firstLine}\n… (+${skipped} chars) …\n${tail}`;
|
||||
}
|
||||
const skipped = s.length - max;
|
||||
return `… (+${skipped} earlier chars — /tool N for full) …\n${s.slice(-max)}`;
|
||||
}
|
||||
@@ -34,16 +34,74 @@ export interface PlanConfirmProps {
|
||||
*/
|
||||
maxRenderedChars?: number;
|
||||
projectRoot?: string;
|
||||
/**
|
||||
* Override the terminal-row count used for vertical clamping. Tests
|
||||
* pass this to avoid depending on `process.stdout.rows`; runtime
|
||||
* callers leave it unset and we read the real TTY size.
|
||||
*/
|
||||
terminalRows?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_RENDERED = 2400;
|
||||
/**
|
||||
* Reserved terminal rows for the picker chrome (border + header +
|
||||
* divider + open-questions hint + SingleSelect with three options +
|
||||
* footer + the assistant-turn block Ink already printed above).
|
||||
* Empirically ~16; we round up to 18 for safety.
|
||||
*/
|
||||
const PICKER_CHROME_ROWS = 18;
|
||||
/**
|
||||
* Rough markdown expansion factor. One source line commonly renders
|
||||
* as 1–3 terminal rows: wrap on narrow terminals doubles long lines,
|
||||
* code fences add top/bottom rules, list items get bullet indent,
|
||||
* headings sometimes pick up blank rows. A factor of 2 is the
|
||||
* conservative bet that lets us keep `bodyRowBudget` > 0 on a 24-row
|
||||
* terminal while still guaranteeing the picker stays on-screen. If
|
||||
* this is wrong in practice it's wrong in the "trim too aggressively"
|
||||
* direction, which shows a truncation marker rather than flickering.
|
||||
*/
|
||||
const MARKDOWN_EXPANSION = 2;
|
||||
const MIN_BODY_ROWS = 4;
|
||||
|
||||
export function PlanConfirm({ plan, onChoose, maxRenderedChars, projectRoot }: PlanConfirmProps) {
|
||||
/**
|
||||
* Trim `text` to the first `maxLines` source lines, appending a
|
||||
* truncation marker when it was cut. Source lines, NOT rendered rows
|
||||
* — callers must divide their row budget by the expected markdown
|
||||
* expansion factor before passing it here.
|
||||
*/
|
||||
export function clampBodyByLines(text: string, maxLines: number): string {
|
||||
const lines = text.split("\n");
|
||||
if (lines.length <= maxLines) return text;
|
||||
const kept = lines.slice(0, maxLines).join("\n");
|
||||
const dropped = lines.length - maxLines;
|
||||
return `${kept}\n\n… (${dropped} more lines truncated — resize the terminal to see more, or /tool for the full proposal)`;
|
||||
}
|
||||
|
||||
function PlanConfirmInner({
|
||||
plan,
|
||||
onChoose,
|
||||
maxRenderedChars,
|
||||
projectRoot,
|
||||
terminalRows,
|
||||
}: PlanConfirmProps) {
|
||||
const cap = maxRenderedChars ?? DEFAULT_MAX_RENDERED;
|
||||
const tooLong = plan.length > cap;
|
||||
const visible = tooLong
|
||||
const charTrunc = plan.length > cap;
|
||||
const charCapped = charTrunc
|
||||
? `${plan.slice(0, cap)}\n\n… (${plan.length - cap} chars truncated — use /tool to view the full proposal)`
|
||||
: plan;
|
||||
// Vertical clamp. Flicker root-cause: when rendered content exceeds
|
||||
// the TTY row count Ink falls back to "clear + full redraw" each
|
||||
// frame, and every parent re-render redraws the big Markdown subtree.
|
||||
// Capping the body to (rows - picker chrome) keeps the whole modal
|
||||
// inside the terminal so Ink uses incremental diffs and the picker
|
||||
// stops thrashing. See Bug A in 0.5.14.
|
||||
const rows = terminalRows ?? process.stdout?.rows ?? 24;
|
||||
const renderedBudget = Math.max(MIN_BODY_ROWS * MARKDOWN_EXPANSION, rows - PICKER_CHROME_ROWS);
|
||||
// Divide by the expansion factor so source-line clamp produces a
|
||||
// rendered height that actually fits the terminal. Without this the
|
||||
// rendered body overflows → Ink clears + redraws each frame → flicker.
|
||||
const sourceLineBudget = Math.max(MIN_BODY_ROWS, Math.floor(renderedBudget / MARKDOWN_EXPANSION));
|
||||
const visible = clampBodyByLines(charCapped, sourceLineBudget);
|
||||
// Crude signal for "the model left questions or risks for me" — the
|
||||
// typical section headings. Triggers an extra hint toward the Refine
|
||||
// option so users know where to answer them.
|
||||
@@ -58,6 +116,11 @@ export function PlanConfirm({ plan, onChoose, maxRenderedChars, projectRoot }: P
|
||||
▸ plan submitted — awaiting your review
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="cyan" dimColor>
|
||||
{"──────────────────────────────────────────"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<Markdown text={visible} projectRoot={projectRoot} />
|
||||
</Box>
|
||||
@@ -91,8 +154,17 @@ export function PlanConfirm({ plan, onChoose, maxRenderedChars, projectRoot }: P
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as PlanConfirmChoice)}
|
||||
onCancel={() => onChoose("cancel")}
|
||||
footer="[↑↓] navigate · [Enter] select · [Esc] cancel"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// React.memo: parent App re-renders every 120ms while the global ticker
|
||||
// is running (even with the live status rows hidden — context changes
|
||||
// propagate). Unless props change, skip re-rendering the heavy Markdown
|
||||
// subtree. Default shallow prop compare is fine — `plan` + `onChoose`
|
||||
// identity + `projectRoot` are the only fields that change.
|
||||
export const PlanConfirm = React.memo(PlanConfirmInner);
|
||||
@@ -83,8 +83,8 @@ export function PromptInput({
|
||||
);
|
||||
|
||||
const effectivePlaceholder = disabled
|
||||
? (placeholder ?? "…waiting for response…")
|
||||
: (placeholder ?? "type a message, or /command · Ctrl+J for newline");
|
||||
? (placeholder ?? "…waiting for response… · [Esc] to stop")
|
||||
: (placeholder ?? "type a message, or /command · [Shift+Enter] / [Ctrl+J] newline");
|
||||
|
||||
const lines = value.length > 0 ? value.split("\n") : [""];
|
||||
const borderColor = disabled ? "gray" : "cyan";
|
||||
|
||||
@@ -28,6 +28,13 @@ export interface SingleSelectProps<V extends string> {
|
||||
initialValue?: V;
|
||||
onSubmit: (value: V) => void;
|
||||
onCancel?: () => void;
|
||||
/**
|
||||
* Optional footer rendered dim beneath the list, e.g.
|
||||
* `"↑/↓ navigate · Enter select · Esc cancel"`. Makes keyboard
|
||||
* affordances discoverable — otherwise new users hit `y`/`n` and
|
||||
* wonder why nothing happens.
|
||||
*/
|
||||
footer?: string;
|
||||
}
|
||||
|
||||
export function SingleSelect<V extends string>({
|
||||
@@ -35,6 +42,7 @@ export function SingleSelect<V extends string>({
|
||||
initialValue,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
footer,
|
||||
}: SingleSelectProps<V>) {
|
||||
const initialIndex = Math.max(
|
||||
0,
|
||||
@@ -65,6 +73,11 @@ export function SingleSelect<V extends string>({
|
||||
marker={i === index ? "▸" : " "}
|
||||
/>
|
||||
))}
|
||||
{footer ? (
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{footer}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,11 @@ export function ShellConfirm({ command, allowPrefix, onChoose }: ShellConfirmPro
|
||||
▸ model wants to run a shell command
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="yellow" dimColor>
|
||||
{"──────────────────────────────────────────"}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text>
|
||||
<Text dimColor>{"$ "}</Text>
|
||||
@@ -60,6 +65,8 @@ export function ShellConfirm({ command, allowPrefix, onChoose }: ShellConfirmPro
|
||||
},
|
||||
]}
|
||||
onSubmit={(v) => onChoose(v as ShellConfirmChoice)}
|
||||
onCancel={() => onChoose("deny")}
|
||||
footer="[↑↓] navigate · [Enter] select · [Esc] deny"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Box, Text } from "ink";
|
||||
// biome-ignore lint/style/useImportType: tsconfig.jsx = "react" needs React in value scope for JSX compilation
|
||||
import React from "react";
|
||||
import type { SlashCommandSpec } from "./slash.js";
|
||||
|
||||
export interface SlashArgPickerProps {
|
||||
/**
|
||||
* When set, render a picker with these matches (filter already
|
||||
* applied upstream). Null → not in picker mode; check `hintSpec`
|
||||
* for a usage hint instead.
|
||||
*/
|
||||
matches: readonly string[] | null;
|
||||
/** Highlighted row within `matches`. */
|
||||
selectedIndex: number;
|
||||
/**
|
||||
* Spec of the command the user is typing args for. Used to render
|
||||
* the header label ("/edit <file>") even when matches is empty or
|
||||
* the caller wants a hint instead of a picker.
|
||||
*/
|
||||
spec: SlashCommandSpec;
|
||||
/** What kind of arg guidance to render. */
|
||||
kind: "picker" | "hint";
|
||||
/** The user's partial input — shown in the "no matches" hint. */
|
||||
partial: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Argument-level picker for a slash command. Mirrors the visual
|
||||
* layout of SlashSuggestions / AtMentionSuggestions so the UI stays
|
||||
* consistent across all three picker surfaces.
|
||||
*/
|
||||
export function SlashArgPicker({
|
||||
matches,
|
||||
selectedIndex,
|
||||
spec,
|
||||
kind,
|
||||
partial,
|
||||
}: SlashArgPickerProps): React.ReactElement | null {
|
||||
// Hint mode: a single dim row explaining the argsHint + summary.
|
||||
if (kind === "hint") {
|
||||
return (
|
||||
<Box paddingX={1}>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
<Text bold>/{spec.cmd}</Text>
|
||||
{spec.argsHint ? ` ${spec.argsHint}` : ""} — {spec.summary}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (matches === null) return null;
|
||||
if (matches.length === 0) {
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
<Text bold>/{spec.cmd}</Text>
|
||||
{spec.argsHint ? ` ${spec.argsHint}` : ""} — {spec.summary}
|
||||
</Text>
|
||||
<Text color="yellow"> no match for "{partial}" — keep typing, or Backspace to edit</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const MAX = 8;
|
||||
const total = matches.length;
|
||||
const windowStart =
|
||||
total <= MAX ? 0 : Math.max(0, Math.min(selectedIndex - Math.floor(MAX / 2), total - MAX));
|
||||
const shown = matches.slice(windowStart, windowStart + MAX);
|
||||
const hiddenAbove = windowStart;
|
||||
const hiddenBelow = total - windowStart - shown.length;
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text dimColor>
|
||||
{" "}
|
||||
<Text bold>/{spec.cmd}</Text>
|
||||
{spec.argsHint ? ` ${spec.argsHint}` : ""} — {spec.summary}
|
||||
</Text>
|
||||
{hiddenAbove > 0 ? <Text dimColor> ↑ {hiddenAbove} more above</Text> : null}
|
||||
{shown.map((value, i) => (
|
||||
<ArgRow key={value} value={value} isSelected={windowStart + i === selectedIndex} />
|
||||
))}
|
||||
{hiddenBelow > 0 ? <Text dimColor> ↓ {hiddenBelow} more below</Text> : null}
|
||||
<Text dimColor> [↑↓] navigate · [Tab]/[Enter] pick</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ArgRow({ value, isSelected }: { value: string; isSelected: boolean }) {
|
||||
const marker = isSelected ? "▸" : " ";
|
||||
if (isSelected) {
|
||||
return (
|
||||
<Box>
|
||||
<Text bold color="cyan">
|
||||
{marker} {value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{marker} {value}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -51,7 +51,7 @@ export function SlashSuggestions({
|
||||
<SuggestionRow key={spec.cmd} spec={spec} isSelected={windowStart + i === selectedIndex} />
|
||||
))}
|
||||
{hiddenBelow > 0 ? <Text dimColor> ↓ {hiddenBelow} more below</Text> : null}
|
||||
<Text dimColor> ↑/↓ navigate · Tab or Enter to pick</Text>
|
||||
<Text dimColor> [↑↓] navigate · [Tab]/[Enter] pick</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
+369
-79
@@ -1,8 +1,54 @@
|
||||
import { Box, Text } from "ink";
|
||||
import { Box, Text, useStdout } from "ink";
|
||||
import React from "react";
|
||||
import { DEEPSEEK_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS } from "../../telemetry.js";
|
||||
import type { SessionSummary } from "../../telemetry.js";
|
||||
import { VERSION } from "../../version.js";
|
||||
import { useTick } from "./ticker.js";
|
||||
|
||||
/**
|
||||
* Cyan → blue → purple truecolor gradient for the REASONIX wordmark.
|
||||
* Each char gets its own hex color — terminals that support 24-bit
|
||||
* color (nearly all since 2016) render this as a smooth fade. Older
|
||||
* 256-color terminals snap to nearest; 8-color fall all the way back
|
||||
* to plain cyan via Ink's color pipeline, so the worst case is still
|
||||
* legible.
|
||||
*/
|
||||
const WORDMARK_STYLES: ReadonlyArray<{ ch: string; color: string; isLogo: boolean }> = [
|
||||
{ ch: "◈", color: "#5eead4", isLogo: true }, // teal — brand mark
|
||||
{ ch: " ", color: "#5eead4", isLogo: false },
|
||||
{ ch: "R", color: "#67e8f9", isLogo: false }, // cyan
|
||||
{ ch: "E", color: "#7dd3fc", isLogo: false }, // sky
|
||||
{ ch: "A", color: "#93c5fd", isLogo: false }, // blue
|
||||
{ ch: "S", color: "#a5b4fc", isLogo: false }, // indigo
|
||||
{ ch: "O", color: "#c4b5fd", isLogo: false }, // violet
|
||||
{ ch: "N", color: "#d8b4fe", isLogo: false }, // purple
|
||||
{ ch: "I", color: "#f0abfc", isLogo: false }, // fuchsia
|
||||
{ ch: "X", color: "#f0abfc", isLogo: false }, // fuchsia
|
||||
];
|
||||
|
||||
/**
|
||||
* Gradient-filled, animated app wordmark. The `◈` brand mark breathes
|
||||
* (bold on/off) once every ~1.6s when idle, ~600ms when the model is
|
||||
* working, so a glance at the top of the screen tells you immediately
|
||||
* whether the app is doing something. Letters' keys use the char+color
|
||||
* pair (not an index) so React state attaches to the glyph itself;
|
||||
* fine because WORDMARK_STYLES never reorders.
|
||||
*/
|
||||
function Wordmark({ busy }: { busy: boolean }) {
|
||||
const tick = useTick();
|
||||
// Slow pulse = 12 ticks (~1.4s). Busy pulse = 5 ticks (~600ms).
|
||||
const period = busy ? 5 : 12;
|
||||
const bright = Math.floor(tick / period) % 2 === 0;
|
||||
return (
|
||||
<Text>
|
||||
{WORDMARK_STYLES.map((c) => (
|
||||
<Text key={`${c.ch}-${c.color}`} color={c.color} bold={c.isLogo ? bright : true}>
|
||||
{c.ch}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
export interface StatsPanelProps {
|
||||
summary: SessionSummary;
|
||||
@@ -33,8 +79,31 @@ export interface StatsPanelProps {
|
||||
* still in flight).
|
||||
*/
|
||||
updateAvailable?: string | null;
|
||||
/**
|
||||
* True when the loop is currently mid-turn — drives the wordmark's
|
||||
* "busy breathing" animation. Visual signal that the app is thinking,
|
||||
* visible at a glance from anywhere on screen.
|
||||
*/
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal width under which we switch from one-line metrics to a
|
||||
* stacked layout. Below 120 cols the wide version wraps mid-number
|
||||
* and the parens ("in $X · out $Y") get split across lines — which
|
||||
* looks broken. Stacking costs one row but stays readable on every
|
||||
* SSH / split-pane case we've hit.
|
||||
*/
|
||||
const NARROW_BREAKPOINT = 120;
|
||||
|
||||
/**
|
||||
* Cache-hit ratio is always zero on turn 1 (there's literally no cache
|
||||
* to hit yet). Showing that as red "0.0%" is a false alarm — new users
|
||||
* read it as "something is broken." We suppress the gradient until a
|
||||
* few turns have landed; by turn 4 the cache has had time to warm up.
|
||||
*/
|
||||
const COLD_START_TURNS = 3;
|
||||
|
||||
export function StatsPanel({
|
||||
summary,
|
||||
model,
|
||||
@@ -44,99 +113,320 @@ export function StatsPanel({
|
||||
planMode,
|
||||
balance,
|
||||
updateAvailable,
|
||||
busy,
|
||||
}: StatsPanelProps) {
|
||||
const hitPct = (summary.cacheHitRatio * 100).toFixed(1);
|
||||
const hitColor =
|
||||
summary.cacheHitRatio >= 0.7 ? "green" : summary.cacheHitRatio >= 0.4 ? "yellow" : "red";
|
||||
const branchOn = (branchBudget ?? 1) > 1;
|
||||
|
||||
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[model] ?? DEFAULT_CONTEXT_TOKENS;
|
||||
const ctxRatio = summary.lastPromptTokens / ctxMax;
|
||||
const ctxColor = ctxRatio >= 0.8 ? "red" : ctxRatio >= 0.5 ? "yellow" : undefined;
|
||||
const { stdout } = useStdout();
|
||||
const columns = stdout?.columns ?? 80;
|
||||
const narrow = columns < NARROW_BREAKPOINT;
|
||||
|
||||
// Cold-start: first few turns render cache+cost muted so a 0.0% hit
|
||||
// rate on turn 1 doesn't look like an error. Once the cache has
|
||||
// actually had a chance to build, we flip to the live gradient.
|
||||
const coldStart = summary.turns <= COLD_START_TURNS;
|
||||
|
||||
return (
|
||||
<Box borderStyle="round" borderColor="cyan" flexDirection="column" paddingX={1}>
|
||||
<Box justifyContent="space-between">
|
||||
<Text>
|
||||
<Text color="cyan" bold>
|
||||
Reasonix
|
||||
</Text>
|
||||
<Text dimColor>{` v${VERSION}`}</Text>
|
||||
<Text dimColor> · model </Text>
|
||||
<Text color="yellow">{model}</Text>
|
||||
<Text dimColor> · prefix </Text>
|
||||
<Text dimColor>{prefixHash}</Text>
|
||||
{harvestOn ? <Text color="magenta"> · harvest</Text> : null}
|
||||
{branchOn ? <Text color="blue"> · branch{branchBudget}</Text> : null}
|
||||
{planMode ? (
|
||||
<Text color="red" bold>
|
||||
{" "}
|
||||
· PLAN
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
<Text>
|
||||
{updateAvailable ? (
|
||||
<Text color="yellow" bold>{`update: ${updateAvailable} · `}</Text>
|
||||
) : null}
|
||||
<Text dimColor>turns {summary.turns} · type /help</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box marginTop={1} gap={3}>
|
||||
<Text>
|
||||
<Text dimColor>cache hit </Text>
|
||||
<Text color={hitColor} bold>
|
||||
{hitPct}%
|
||||
</Text>
|
||||
</Text>
|
||||
<Text>
|
||||
<Text dimColor>cost </Text>
|
||||
<Text color="green" bold>
|
||||
${summary.totalCostUsd.toFixed(6)}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" (in "}${summary.totalInputCostUsd.toFixed(6)}
|
||||
{" · out "}${summary.totalOutputCostUsd.toFixed(6)}
|
||||
{")"}
|
||||
</Text>
|
||||
</Text>
|
||||
{summary.lastPromptTokens > 0 ? (
|
||||
<Text>
|
||||
<Text dimColor>ctx </Text>
|
||||
<Text color={ctxColor} bold={ctxColor !== undefined}>
|
||||
{formatTokens(summary.lastPromptTokens)}/{formatTokens(ctxMax)}
|
||||
</Text>
|
||||
<Text dimColor> ({(ctxRatio * 100).toFixed(0)}%)</Text>
|
||||
{ctxRatio >= 0.8 ? (
|
||||
<Text color="red" bold>
|
||||
{" "}
|
||||
· /compact
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
) : null}
|
||||
{balance ? (
|
||||
<Text>
|
||||
<Text dimColor>balance </Text>
|
||||
<Text color={balance.total < 1 ? "red" : balance.total < 5 ? "yellow" : "green"} bold>
|
||||
{balance.currency === "USD" ? "$" : ""}
|
||||
{balance.total.toFixed(2)}
|
||||
{balance.currency !== "USD" ? ` ${balance.currency}` : ""}
|
||||
</Text>
|
||||
<Header
|
||||
model={model}
|
||||
prefixHash={prefixHash}
|
||||
harvestOn={harvestOn}
|
||||
branchOn={branchOn}
|
||||
branchBudget={branchBudget ?? 1}
|
||||
planMode={planMode}
|
||||
turns={summary.turns}
|
||||
updateAvailable={updateAvailable}
|
||||
narrow={narrow}
|
||||
busy={busy ?? false}
|
||||
/>
|
||||
{narrow ? (
|
||||
<StackedMetrics
|
||||
summary={summary}
|
||||
ctxRatio={ctxRatio}
|
||||
ctxMax={ctxMax}
|
||||
balance={balance}
|
||||
coldStart={coldStart}
|
||||
/>
|
||||
) : (
|
||||
<InlineMetrics
|
||||
summary={summary}
|
||||
ctxRatio={ctxRatio}
|
||||
ctxMax={ctxMax}
|
||||
balance={balance}
|
||||
coldStart={coldStart}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function Header({
|
||||
model,
|
||||
prefixHash,
|
||||
harvestOn,
|
||||
branchOn,
|
||||
branchBudget,
|
||||
planMode,
|
||||
turns,
|
||||
updateAvailable,
|
||||
narrow,
|
||||
busy,
|
||||
}: {
|
||||
model: string;
|
||||
prefixHash: string;
|
||||
harvestOn?: boolean;
|
||||
branchOn: boolean;
|
||||
branchBudget: number;
|
||||
planMode?: boolean;
|
||||
turns: number;
|
||||
updateAvailable?: string | null;
|
||||
narrow: boolean;
|
||||
busy: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box justifyContent="space-between">
|
||||
<Box>
|
||||
<Wordmark busy={busy} />
|
||||
<Text dimColor>{` v${VERSION}`}</Text>
|
||||
<Text dimColor> · </Text>
|
||||
<Text color="yellow">{model}</Text>
|
||||
{narrow ? null : (
|
||||
<>
|
||||
<Text dimColor> · </Text>
|
||||
<Text dimColor>{prefixHash}</Text>
|
||||
</>
|
||||
)}
|
||||
{harvestOn ? <Text color="magenta"> · harvest</Text> : null}
|
||||
{branchOn ? <Text color="blue"> · branch{branchBudget}</Text> : null}
|
||||
{planMode ? (
|
||||
<Text color="red" bold>
|
||||
{" · PLAN"}
|
||||
</Text>
|
||||
) : null}
|
||||
</Box>
|
||||
<Text>
|
||||
{updateAvailable ? (
|
||||
<Text color="yellow" bold>{`update: ${updateAvailable} · `}</Text>
|
||||
) : null}
|
||||
<Text dimColor>{narrow ? `turn ${turns}` : `turn ${turns} · /help`}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact integer formatter: 1234 → "1.2k", 131072 → "131k". Keeps the
|
||||
* panel narrow enough to fit on 80-col terminals even when the context
|
||||
* is near full.
|
||||
* Wide-terminal layout (≥120 cols): single metrics row. Matches the
|
||||
* original panel, just with calmer spacing and the cold-start
|
||||
* treatment folded in.
|
||||
*/
|
||||
function InlineMetrics({
|
||||
summary,
|
||||
ctxRatio,
|
||||
ctxMax,
|
||||
balance,
|
||||
coldStart,
|
||||
}: {
|
||||
summary: SessionSummary;
|
||||
ctxRatio: number;
|
||||
ctxMax: number;
|
||||
balance?: { currency: string; total: number } | null;
|
||||
coldStart: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box marginTop={1} gap={3}>
|
||||
<ContextCell ratio={ctxRatio} promptTokens={summary.lastPromptTokens} ctxMax={ctxMax} />
|
||||
<CacheCell hitRatio={summary.cacheHitRatio} coldStart={coldStart} turns={summary.turns} />
|
||||
<CostCell summary={summary} coldStart={coldStart} />
|
||||
{balance ? <BalanceCell balance={balance} /> : null}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow-terminal layout (<120 cols): one metric per line, in priority
|
||||
* order (ctx → balance → cache → cost). Order matters because users
|
||||
* scanning a cramped terminal want "am I about to hit a wall?" answered
|
||||
* first; dollars spent comes later.
|
||||
*/
|
||||
function StackedMetrics({
|
||||
summary,
|
||||
ctxRatio,
|
||||
ctxMax,
|
||||
balance,
|
||||
coldStart,
|
||||
}: {
|
||||
summary: SessionSummary;
|
||||
ctxRatio: number;
|
||||
ctxMax: number;
|
||||
balance?: { currency: string; total: number } | null;
|
||||
coldStart: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
<ContextCell
|
||||
ratio={ctxRatio}
|
||||
promptTokens={summary.lastPromptTokens}
|
||||
ctxMax={ctxMax}
|
||||
showBar
|
||||
/>
|
||||
{balance ? <BalanceCell balance={balance} /> : null}
|
||||
<CacheCell hitRatio={summary.cacheHitRatio} coldStart={coldStart} turns={summary.turns} />
|
||||
<CostCell summary={summary} coldStart={coldStart} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextCell({
|
||||
ratio,
|
||||
promptTokens,
|
||||
ctxMax,
|
||||
showBar,
|
||||
}: {
|
||||
ratio: number;
|
||||
promptTokens: number;
|
||||
ctxMax: number;
|
||||
showBar?: boolean;
|
||||
}) {
|
||||
if (promptTokens === 0) {
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>ctx </Text>
|
||||
<Text dimColor>— (no turns yet)</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const color = ratio >= 0.8 ? "red" : ratio >= 0.6 ? "yellow" : "green";
|
||||
const pct = Math.round(ratio * 100);
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>ctx </Text>
|
||||
{showBar ? <Bar ratio={ratio} color={color} /> : null}
|
||||
{showBar ? <Text> </Text> : null}
|
||||
<Text color={color} bold>
|
||||
{formatTokens(promptTokens)}/{formatTokens(ctxMax)}
|
||||
</Text>
|
||||
<Text dimColor> ({pct}%)</Text>
|
||||
{ratio >= 0.8 ? (
|
||||
<Text color="red" bold>
|
||||
{" · /compact"}
|
||||
</Text>
|
||||
) : null}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function CacheCell({
|
||||
hitRatio,
|
||||
coldStart,
|
||||
turns,
|
||||
}: {
|
||||
hitRatio: number;
|
||||
coldStart: boolean;
|
||||
turns: number;
|
||||
}) {
|
||||
const pct = (hitRatio * 100).toFixed(1);
|
||||
if (turns === 0) {
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>cache </Text>
|
||||
<Text dimColor>—</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (coldStart) {
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>cache </Text>
|
||||
<Text dimColor>{pct}% </Text>
|
||||
<Text dimColor italic>
|
||||
(cold start)
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
const color = hitRatio >= 0.7 ? "green" : hitRatio >= 0.4 ? "yellow" : "red";
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>cache </Text>
|
||||
<Text color={color} bold>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function CostCell({
|
||||
summary,
|
||||
coldStart,
|
||||
}: {
|
||||
summary: SessionSummary;
|
||||
coldStart: boolean;
|
||||
}) {
|
||||
if (summary.turns === 0) {
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>cost </Text>
|
||||
<Text dimColor>—</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
// The first turn is unavoidably a cache-miss (nothing to hit),
|
||||
// so the dollar figure is front-loaded. Muting it during the
|
||||
// cold-start window keeps the "expensive first turn" from
|
||||
// reading as "something is wrong."
|
||||
const primaryColor = coldStart ? undefined : "green";
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>cost </Text>
|
||||
<Text color={primaryColor} bold={!coldStart} dimColor={coldStart}>
|
||||
${summary.totalCostUsd.toFixed(6)}
|
||||
</Text>
|
||||
<Text dimColor>
|
||||
{" (in "}${summary.totalInputCostUsd.toFixed(6)}
|
||||
{" · out "}${summary.totalOutputCostUsd.toFixed(6)}
|
||||
{")"}
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function BalanceCell({ balance }: { balance: { currency: string; total: number } }) {
|
||||
const color = balance.total < 1 ? "red" : balance.total < 5 ? "yellow" : "green";
|
||||
return (
|
||||
<Text>
|
||||
<Text dimColor>balance </Text>
|
||||
<Text color={color} bold>
|
||||
{balance.currency === "USD" ? "$" : ""}
|
||||
{balance.total.toFixed(2)}
|
||||
{balance.currency !== "USD" ? ` ${balance.currency}` : ""}
|
||||
</Text>
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unicode progress bar for the narrow layout's context cell. 10 cells
|
||||
* wide — narrow terminals are already cramped, and the bar is just a
|
||||
* visual cue for the percentage next to it, not the primary readout.
|
||||
*/
|
||||
function Bar({ ratio, color }: { ratio: number; color: "green" | "yellow" | "red" }) {
|
||||
const cells = 10;
|
||||
const filled = Math.max(0, Math.min(cells, Math.round(ratio * cells)));
|
||||
const bar = "█".repeat(filled) + "░".repeat(cells - filled);
|
||||
return <Text color={color}>{bar}</Text>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact token formatter using binary K (÷1024): 1234 → "1.2K",
|
||||
* 131072 → "128K". Matches DeepSeek's marketing label of "128K context
|
||||
* length" — showing "131k" confused users (the actual limit is
|
||||
* 128×1024 = 131,072 tokens, but the doc consistently says "128K").
|
||||
* Uppercase K signals binary-K to anyone who notices.
|
||||
*/
|
||||
function formatTokens(n: number): string {
|
||||
if (n < 1000) return String(n);
|
||||
const k = n / 1000;
|
||||
return k >= 100 ? `${k.toFixed(0)}k` : `${k.toFixed(1)}k`;
|
||||
if (n < 1024) return String(n);
|
||||
const k = n / 1024;
|
||||
return k >= 100 ? `${k.toFixed(0)}K` : `${k.toFixed(1)}K`;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Bash-mode (`!cmd`) prefix parsing.
|
||||
*
|
||||
* A `!` at the start of the user's input means "run this as a shell
|
||||
* command in the sandbox root, and put both the command and its output
|
||||
* into the conversation so the model sees it next turn." Same idea as
|
||||
* Claude Code's `!` prefix — faster than round-tripping through the
|
||||
* model just to cat a file or check git status.
|
||||
*
|
||||
* Design choice: user-typed `!` commands skip the allowlist. The
|
||||
* allowlist exists to bound what the MODEL can do with run_command;
|
||||
* the user typing `!git push` by hand is explicit consent. If a user
|
||||
* wants stricter gating they can set `REASONIX_BANG_CONFIRM=1` in a
|
||||
* future iteration.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Return the command portion of a `!`-prefixed input, or `null` if
|
||||
* the text isn't a bang invocation. Trims leading/trailing whitespace
|
||||
* from the command body. `!` alone (or `!` followed by only whitespace)
|
||||
* returns `null` — no command to run.
|
||||
*/
|
||||
export function detectBangCommand(text: string): string | null {
|
||||
if (!text.startsWith("!")) return null;
|
||||
const body = text.slice(1).trim();
|
||||
if (!body) return null;
|
||||
return body;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a bang command + its output into a single user-role message
|
||||
* body that the model will see on the next turn. The `[!cmd]` header
|
||||
* marks the run explicitly so the model can distinguish
|
||||
* user-typed shell output from its own `run_command` tool results
|
||||
* (which are `role: tool`, different message shape entirely).
|
||||
*/
|
||||
export function formatBangUserMessage(cmd: string, output: string): string {
|
||||
return `[!${cmd}]\n${output}`;
|
||||
}
|
||||
+11
-3
@@ -195,7 +195,15 @@ export function parseCitationUrl(url: string): CitationParts | null {
|
||||
export function validateCitation(url: string, projectRoot: string): CitationStatus {
|
||||
const parts = parseCitationUrl(url);
|
||||
if (!parts || !parts.path) return { ok: false, reason: "empty path" };
|
||||
const fullPath = isAbsolute(parts.path) ? parts.path : join(projectRoot, parts.path);
|
||||
// Strip a leading `/` or `\\`. Models habitually write `/foo.ts`
|
||||
// meaning "project-root relative" — Aider / Claude-Code convention —
|
||||
// rather than POSIX root-absolute. A literal absolute path is still
|
||||
// reachable as `C:\foo` on Windows or once the strip drops the slash
|
||||
// and Node's `isAbsolute` correctly rejects what's left. Real unix
|
||||
// absolute references like `/etc/hosts` in a code citation are vanishingly
|
||||
// rare — if anyone needs one we can revisit.
|
||||
const normalized = parts.path.replace(/^[/\\]+/, "");
|
||||
const fullPath = isAbsolute(normalized) ? normalized : join(projectRoot, normalized);
|
||||
let stat: ReturnType<typeof statSync>;
|
||||
try {
|
||||
stat = statSync(fullPath);
|
||||
@@ -295,7 +303,7 @@ function InlineMd({
|
||||
if (status && !status.ok) {
|
||||
parts.push(
|
||||
<Text key={`l${idx++}`} color="red" strikethrough>
|
||||
{`${linkText} ❌`}
|
||||
{`${linkText} ✗`}
|
||||
</Text>,
|
||||
);
|
||||
} else {
|
||||
@@ -902,7 +910,7 @@ function BrokenCitationsBlock({ items }: { items: Array<{ url: string; reason: s
|
||||
{items.map((b, i) => (
|
||||
// biome-ignore lint/suspicious/noArrayIndexKey: list is derived from a Map iteration order, stable per render
|
||||
<Text key={`bc-${i}`} color="red">
|
||||
{` ❌ ${b.url} → ${b.reason}`}
|
||||
{` ✗ ${b.url} → ${b.reason}`}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* `/resource` and `/prompt` slash handlers for the chat/code TUI.
|
||||
*
|
||||
* The slash-command registry (slash.ts) advertises both commands so the
|
||||
* argument-level picker and /help surface them, but the actual read/fetch
|
||||
* work is async — McpClient.readResource and getPrompt both round-trip to
|
||||
* the server — so it doesn't fit the synchronous `handleSlash` shape used
|
||||
* by every other slash command. App.tsx intercepts the commands directly
|
||||
* and calls into the helpers below.
|
||||
*
|
||||
* Split by effect boundary:
|
||||
* - formatters (resource list, contents, prompt list, messages) are
|
||||
* pure over already-fetched data and unit-tested
|
||||
* - find-server helpers are pure lookups against inspection reports
|
||||
* - `handleMcpBrowseSlash` is the async orchestrator App.tsx calls
|
||||
*
|
||||
* v1 intentionally does NOT inject the fetched resource/prompt into the
|
||||
* next model turn. It's a display-only browser so users can see what
|
||||
* their MCP servers expose. A future version may add a Tab-to-inject
|
||||
* hotkey once the UX is settled.
|
||||
*/
|
||||
|
||||
import type { McpClient } from "../../mcp/client.js";
|
||||
import type {
|
||||
GetPromptResult,
|
||||
McpPromptMessage,
|
||||
McpResourceContents,
|
||||
ReadResourceResult,
|
||||
} from "../../mcp/types.js";
|
||||
import type { DisplayEvent } from "./EventLog.js";
|
||||
import type { McpServerSummary } from "./slash.js";
|
||||
|
||||
/**
|
||||
* React setState for Historical — typed loose because the App state
|
||||
* shape is App-internal. We only ever append, so `(prev) => prev.concat(row)`
|
||||
* works against any array-of-DisplayEvent state.
|
||||
*/
|
||||
export type HistoricalSetter = (updater: (prev: DisplayEvent[]) => DisplayEvent[]) => void;
|
||||
|
||||
export function formatResourceList(servers: readonly McpServerSummary[]): string {
|
||||
const lines: string[] = [];
|
||||
let total = 0;
|
||||
for (const s of servers) {
|
||||
if (!s.report.resources.supported) continue;
|
||||
const items = s.report.resources.items;
|
||||
if (items.length === 0) continue;
|
||||
lines.push(`[${s.label}] ${items.length} resource(s):`);
|
||||
for (const r of items.slice(0, 20)) {
|
||||
const name = r.name && r.name !== r.uri ? ` ${r.name}` : "";
|
||||
const mime = r.mimeType ? ` · ${r.mimeType}` : "";
|
||||
lines.push(` · ${r.uri}${name}${mime}`);
|
||||
total++;
|
||||
}
|
||||
if (items.length > 20) lines.push(` (+${items.length - 20} more)`);
|
||||
lines.push("");
|
||||
}
|
||||
if (total === 0) {
|
||||
return "No resources on any connected MCP server (or no servers connected). `/mcp` shows the current set.";
|
||||
}
|
||||
lines.push("Read one: `/resource <uri>` — or use Tab in the picker.");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function formatPromptList(servers: readonly McpServerSummary[]): string {
|
||||
const lines: string[] = [];
|
||||
let total = 0;
|
||||
for (const s of servers) {
|
||||
if (!s.report.prompts.supported) continue;
|
||||
const items = s.report.prompts.items;
|
||||
if (items.length === 0) continue;
|
||||
lines.push(`[${s.label}] ${items.length} prompt(s):`);
|
||||
for (const p of items.slice(0, 20)) {
|
||||
const desc = p.description ? ` — ${p.description}` : "";
|
||||
const argHint =
|
||||
p.arguments && p.arguments.length > 0
|
||||
? ` (args: ${p.arguments.map((a) => a.name + (a.required ? "*" : "?")).join(", ")})`
|
||||
: "";
|
||||
lines.push(` · ${p.name}${argHint}${desc}`);
|
||||
total++;
|
||||
}
|
||||
if (items.length > 20) lines.push(` (+${items.length - 20} more)`);
|
||||
lines.push("");
|
||||
}
|
||||
if (total === 0) {
|
||||
return "No prompts on any connected MCP server (or no servers connected). `/mcp` shows the current set.";
|
||||
}
|
||||
lines.push(
|
||||
"Fetch one: `/prompt <name>` — args are not supported yet; prompts with required args will surface an error from the server.",
|
||||
);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function findServerForResource(
|
||||
servers: readonly McpServerSummary[],
|
||||
uri: string,
|
||||
): McpServerSummary | null {
|
||||
for (const s of servers) {
|
||||
if (!s.report.resources.supported) continue;
|
||||
if (s.report.resources.items.some((r) => r.uri === uri)) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function findServerForPrompt(
|
||||
servers: readonly McpServerSummary[],
|
||||
name: string,
|
||||
): McpServerSummary | null {
|
||||
for (const s of servers) {
|
||||
if (!s.report.prompts.supported) continue;
|
||||
if (s.report.prompts.items.some((p) => p.name === name)) return s;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatResourceContents(uri: string, result: ReadResourceResult): string {
|
||||
const lines: string[] = [`Resource ${uri} (${result.contents.length} content block(s)):`, ""];
|
||||
for (let i = 0; i < result.contents.length; i++) {
|
||||
const c = result.contents[i]!;
|
||||
const header = `— block ${i + 1}${c.mimeType ? ` · ${c.mimeType}` : ""}`;
|
||||
lines.push(header);
|
||||
lines.push(formatOneResourceContent(c));
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
function formatOneResourceContent(c: McpResourceContents): string {
|
||||
if ("text" in c) {
|
||||
const MAX = 8_000;
|
||||
if (c.text.length > MAX) {
|
||||
return `${c.text.slice(0, MAX)}\n\n[…truncated ${c.text.length - MAX} chars; full contents available via McpClient.readResource in library mode.]`;
|
||||
}
|
||||
return c.text;
|
||||
}
|
||||
// blob — we can't render arbitrary binary in the TUI; give the size.
|
||||
const bytes = typeof c.blob === "string" ? approximateBase64ByteSize(c.blob) : 0;
|
||||
return `[binary · ~${bytes.toLocaleString()} bytes · base64]`;
|
||||
}
|
||||
|
||||
function approximateBase64ByteSize(b64: string): number {
|
||||
// 4 base64 chars encode 3 bytes; padding `=` trims the output.
|
||||
const padding = b64.endsWith("==") ? 2 : b64.endsWith("=") ? 1 : 0;
|
||||
return Math.floor((b64.length * 3) / 4) - padding;
|
||||
}
|
||||
|
||||
export function formatPromptMessages(name: string, result: GetPromptResult): string {
|
||||
const lines: string[] = [
|
||||
`Prompt ${name}${result.description ? ` — ${result.description}` : ""}`,
|
||||
`(${result.messages.length} message(s))`,
|
||||
"",
|
||||
];
|
||||
for (let i = 0; i < result.messages.length; i++) {
|
||||
const m = result.messages[i]!;
|
||||
lines.push(`— ${i + 1}. ${m.role}`);
|
||||
lines.push(formatOnePromptMessage(m));
|
||||
lines.push("");
|
||||
}
|
||||
return lines.join("\n").trimEnd();
|
||||
}
|
||||
|
||||
function formatOnePromptMessage(m: McpPromptMessage): string {
|
||||
const block = m.content as { type?: string; text?: string; resource?: McpResourceContents };
|
||||
if (block.type === "text" && typeof block.text === "string") return block.text;
|
||||
if (block.type === "resource" && block.resource) {
|
||||
return `[resource: ${block.resource.uri}]\n${formatOneResourceContent(block.resource)}`;
|
||||
}
|
||||
return `[non-text content: ${block.type ?? "unknown"}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Orchestrator. Looks up the right server, calls readResource/getPrompt
|
||||
* via its `client`, and pushes one info row into `Historical`. Errors
|
||||
* (server missing, -32601, network) surface as a warning row; the user
|
||||
* can always fall back to `reasonix mcp inspect <spec>` for diagnostics.
|
||||
*/
|
||||
export async function handleMcpBrowseSlash(
|
||||
kind: "resource" | "prompt",
|
||||
arg: string,
|
||||
servers: readonly McpServerSummary[],
|
||||
setHistorical: HistoricalSetter,
|
||||
): Promise<void> {
|
||||
const ts = Date.now();
|
||||
const push = (role: DisplayEvent["role"], text: string) => {
|
||||
setHistorical((prev) => [...prev, { id: `mcp-${role}-${ts}-${prev.length}`, role, text }]);
|
||||
};
|
||||
|
||||
// No arg → list mode.
|
||||
if (!arg) {
|
||||
push("info", kind === "resource" ? formatResourceList(servers) : formatPromptList(servers));
|
||||
return;
|
||||
}
|
||||
|
||||
if (kind === "resource") {
|
||||
const server = findServerForResource(servers, arg);
|
||||
if (!server) {
|
||||
push(
|
||||
"warning",
|
||||
`no server exposes resource "${arg}". \`/resource\` with no arg lists what's available.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const client: McpClient | undefined = server.client;
|
||||
if (!client) {
|
||||
push(
|
||||
"warning",
|
||||
`server [${server.label}] is not connected (display-only). Resource read requires a live MCP client.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await client.readResource(arg);
|
||||
push("info", formatResourceContents(arg, result));
|
||||
} catch (err) {
|
||||
push("warning", `readResource failed: ${(err as Error).message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// prompt
|
||||
const server = findServerForPrompt(servers, arg);
|
||||
if (!server) {
|
||||
push(
|
||||
"warning",
|
||||
`no server exposes prompt "${arg}". \`/prompt\` with no arg lists what's available.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const client: McpClient | undefined = server.client;
|
||||
if (!client) {
|
||||
push(
|
||||
"warning",
|
||||
`server [${server.label}] is not connected (display-only). Prompt fetch requires a live MCP client.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await client.getPrompt(arg);
|
||||
push("info", formatPromptMessages(arg, result));
|
||||
} catch (err) {
|
||||
push("warning", `getPrompt failed: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Collapse long user-typed input for display purposes.
|
||||
*
|
||||
* Problem: pasting a stack trace, log dump, or sample file into the
|
||||
* prompt box floods the Historical scrollback with a huge block — the
|
||||
* user can't see their previous turns, the assistant's replies, or
|
||||
* where the cursor is. Terminal UIs have shipped this shape for
|
||||
* years (iTerm's "pasted text" notation, Claude Code's preview).
|
||||
*
|
||||
* Split of concerns: the MODEL still gets the full text. This helper
|
||||
* only rewrites what we render in the Historical row. `@` expansions
|
||||
* and `!` bang outputs have their own display paths (unaffected).
|
||||
*/
|
||||
|
||||
/** Defaults tuned to typical prose vs. typical code-paste shapes. */
|
||||
export const DEFAULT_PASTE_LINE_THRESHOLD = 40;
|
||||
export const DEFAULT_PASTE_CHAR_THRESHOLD = 2000;
|
||||
/** Lines kept visible at the head of a collapsed paste. */
|
||||
export const DEFAULT_PASTE_HEAD_LINES = 10;
|
||||
|
||||
export interface PasteCollapseOptions {
|
||||
lineThreshold?: number;
|
||||
charThreshold?: number;
|
||||
headLines?: number;
|
||||
}
|
||||
|
||||
export interface PasteCollapseResult {
|
||||
/** Text to render in the Historical row (possibly collapsed). */
|
||||
displayText: string;
|
||||
/** True when collapsing happened. False = input passed through verbatim. */
|
||||
collapsed: boolean;
|
||||
/** Original char length — exposed so callers can log/annotate. */
|
||||
originalChars: number;
|
||||
/** Original line count. */
|
||||
originalLines: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse `input` when it exceeds either the line OR char threshold.
|
||||
* Display shape:
|
||||
*
|
||||
* ▸ pasted 2.3 KB (84 lines) — first 10 shown, full text sent to model
|
||||
* line 1
|
||||
* line 2
|
||||
* …
|
||||
* line 10
|
||||
* … (74 more lines)
|
||||
*
|
||||
* Short input passes through unchanged — collapsed = false.
|
||||
*/
|
||||
export function formatLongPaste(
|
||||
input: string,
|
||||
opts: PasteCollapseOptions = {},
|
||||
): PasteCollapseResult {
|
||||
const lineCap = opts.lineThreshold ?? DEFAULT_PASTE_LINE_THRESHOLD;
|
||||
const charCap = opts.charThreshold ?? DEFAULT_PASTE_CHAR_THRESHOLD;
|
||||
const headN = Math.max(1, opts.headLines ?? DEFAULT_PASTE_HEAD_LINES);
|
||||
|
||||
const originalChars = input.length;
|
||||
const lines = input.split("\n");
|
||||
const originalLines = lines.length;
|
||||
|
||||
if (originalChars <= charCap && originalLines <= lineCap) {
|
||||
return { displayText: input, collapsed: false, originalChars, originalLines };
|
||||
}
|
||||
|
||||
const header = `▸ pasted ${formatBytes(originalChars)} (${originalLines} lines) — first ${Math.min(headN, originalLines)} shown, full text sent to model`;
|
||||
const head = lines.slice(0, headN).join("\n");
|
||||
const remaining = originalLines - headN;
|
||||
const footer = remaining > 0 ? `… (${remaining} more line${remaining === 1 ? "" : "s"})` : "";
|
||||
const displayText = footer ? `${header}\n${head}\n${footer}` : `${header}\n${head}`;
|
||||
return { displayText, collapsed: true, originalChars, originalLines };
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
const kb = n / 1024;
|
||||
if (kb < 1024) return `${kb.toFixed(kb >= 10 ? 0 : 1)} KB`;
|
||||
const mb = kb / 1024;
|
||||
return `${mb.toFixed(mb >= 10 ? 0 : 1)} MB`;
|
||||
}
|
||||
+348
-21
@@ -7,11 +7,13 @@ import {
|
||||
projectSettingsPath,
|
||||
} from "../../hooks.js";
|
||||
import type { CacheFirstLoop } from "../../loop.js";
|
||||
import type { McpClient } from "../../mcp/client.js";
|
||||
import type { InspectionReport } from "../../mcp/inspect.js";
|
||||
import { PROJECT_MEMORY_FILE, memoryEnabled, readProjectMemory } from "../../project-memory.js";
|
||||
import { deleteSession, listSessions } from "../../session.js";
|
||||
import { SkillStore } from "../../skills.js";
|
||||
import { DEEPSEEK_CONTEXT_TOKENS, DEFAULT_CONTEXT_TOKENS } from "../../telemetry.js";
|
||||
import { countTokens } from "../../tokenizer.js";
|
||||
import { aggregateUsage, defaultUsageLogPath, readUsageLog } from "../../usage.js";
|
||||
import { type MemoryScope, MemoryStore } from "../../user-memory.js";
|
||||
import { VERSION, compareVersions, isNpxInstall } from "../../version.js";
|
||||
@@ -142,6 +144,20 @@ export interface SlashContext {
|
||||
* with no retry path.
|
||||
*/
|
||||
refreshLatestVersion?: () => void;
|
||||
/**
|
||||
* Model catalog fetched from DeepSeek's `/models` endpoint at App
|
||||
* mount. `null` → still in flight or the call failed (auth / offline);
|
||||
* `[]` → the API answered with zero entries. `/models` uses this to
|
||||
* render the list, and `/model <id>` uses it for soft validation
|
||||
* (warn-only — we still switch even on unknown ids since the list
|
||||
* can lag a newly-released model).
|
||||
*/
|
||||
models?: string[] | null;
|
||||
/**
|
||||
* Fire-and-forget refresh for the models list. Lets `/models` retry
|
||||
* after a flaky first fetch without needing async slash support.
|
||||
*/
|
||||
refreshModels?: () => void;
|
||||
}
|
||||
|
||||
export interface McpServerSummary {
|
||||
@@ -153,6 +169,13 @@ export interface McpServerSummary {
|
||||
toolCount: number;
|
||||
/** Full inspection snapshot — used for the resources + prompts sections. */
|
||||
report: InspectionReport;
|
||||
/**
|
||||
* Live MCP client, kept so `/resource` and `/prompt` can call
|
||||
* `readResource` / `getPrompt` against this server. Omitted by
|
||||
* callers that only build the summary for display; those slashes
|
||||
* fall back to "not available" when the client is missing.
|
||||
*/
|
||||
client?: McpClient;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -171,6 +194,24 @@ export interface SlashCommandSpec {
|
||||
contextual?: "code";
|
||||
/** If the command takes args, hint text shown after the name. */
|
||||
argsHint?: string;
|
||||
/**
|
||||
* How the first argument position should autocomplete. Shapes the
|
||||
* picker that appears below the prompt once the user types `/<cmd>`
|
||||
* + space:
|
||||
* - `"models"` → DeepSeek model-id list fetched at startup.
|
||||
* - `"mcp-resources"` → live URIs aggregated across connected MCP servers.
|
||||
* - `"mcp-prompts"` → live prompt names aggregated across MCP servers.
|
||||
* - `string[]` → small enum of literal values (e.g. `["on", "off"]`).
|
||||
* - omitted → no picker; a persistent usage hint shows the
|
||||
* argsHint + summary so the user knows what to type.
|
||||
*
|
||||
* File-path completion is deliberately NOT offered here. Users who
|
||||
* want to reference a file in a prompt use `@path/to/file` (0.5.5);
|
||||
* the `@` picker already surfaces files with mtime + recently-used
|
||||
* ranking. Adding a second file-picker surface for slash commands
|
||||
* would split the UX without adding leverage.
|
||||
*/
|
||||
argCompleter?: "models" | "mcp-resources" | "mcp-prompts" | readonly string[];
|
||||
}
|
||||
|
||||
export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
@@ -180,11 +221,40 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
cmd: "preset",
|
||||
argsHint: "<fast|smart|max>",
|
||||
summary: "one-tap model + harvest + branch bundle",
|
||||
argCompleter: ["fast", "smart", "max"],
|
||||
},
|
||||
{
|
||||
cmd: "model",
|
||||
argsHint: "<id>",
|
||||
summary: "switch DeepSeek model id",
|
||||
argCompleter: "models",
|
||||
},
|
||||
{ cmd: "models", summary: "list available models fetched from DeepSeek /models" },
|
||||
{
|
||||
cmd: "harvest",
|
||||
argsHint: "[on|off]",
|
||||
summary: "toggle Pillar-2 plan-state extraction",
|
||||
argCompleter: ["on", "off"],
|
||||
},
|
||||
{
|
||||
cmd: "branch",
|
||||
argsHint: "<N|off>",
|
||||
summary: "run N parallel samples per turn (N>=2)",
|
||||
argCompleter: ["off", "2", "3", "4", "5"],
|
||||
},
|
||||
{ cmd: "model", argsHint: "<id>", summary: "switch DeepSeek model id" },
|
||||
{ cmd: "harvest", argsHint: "[on|off]", summary: "toggle Pillar-2 plan-state extraction" },
|
||||
{ cmd: "branch", argsHint: "<N|off>", summary: "run N parallel samples per turn (N>=2)" },
|
||||
{ cmd: "mcp", summary: "list MCP servers + tools attached to this session" },
|
||||
{
|
||||
cmd: "resource",
|
||||
argsHint: "[uri]",
|
||||
summary: "browse + read MCP resources (no arg → list URIs; <uri> → fetch contents)",
|
||||
argCompleter: "mcp-resources",
|
||||
},
|
||||
{
|
||||
cmd: "prompt",
|
||||
argsHint: "[name]",
|
||||
summary: "browse + fetch MCP prompts (no arg → list names; <name> → render prompt)",
|
||||
argCompleter: "mcp-prompts",
|
||||
},
|
||||
{ cmd: "tool", argsHint: "[N]", summary: "dump full output of the Nth tool call (1=latest)" },
|
||||
{
|
||||
cmd: "memory",
|
||||
@@ -211,8 +281,17 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
"cross-session cost dashboard (today / week / month / all-time · cache hit · vs Claude)",
|
||||
},
|
||||
{ cmd: "think", summary: "dump the last turn's full R1 reasoning (reasoner only)" },
|
||||
{
|
||||
cmd: "context",
|
||||
summary: "break down where context tokens are going: system / tools / per-turn log",
|
||||
},
|
||||
{ cmd: "retry", summary: "truncate & resend your last message (fresh sample)" },
|
||||
{ cmd: "compact", argsHint: "[cap]", summary: "shrink oversized tool results in the log" },
|
||||
{
|
||||
cmd: "compact",
|
||||
argsHint: "[tokens]",
|
||||
summary: "shrink oversized tool results in the log (cap in tokens, default 4000)",
|
||||
},
|
||||
{ cmd: "keys", summary: "show all keyboard shortcuts and prompt prefixes" },
|
||||
{ cmd: "sessions", summary: "list saved sessions (current marked with ▸)" },
|
||||
{ cmd: "forget", summary: "delete the current session from disk" },
|
||||
{ cmd: "setup", summary: "reminds you to exit and run `reasonix setup`" },
|
||||
@@ -234,6 +313,7 @@ export const SLASH_COMMANDS: readonly SlashCommandSpec[] = [
|
||||
argsHint: "[on|off]",
|
||||
summary: "toggle read-only plan mode (writes bounced until submit_plan + approval)",
|
||||
contextual: "code",
|
||||
argCompleter: ["on", "off"],
|
||||
},
|
||||
{
|
||||
cmd: "apply-plan",
|
||||
@@ -255,6 +335,72 @@ export function suggestSlashCommands(prefix: string, codeMode = false): SlashCom
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape describing what the prompt buffer is asking for AFTER the
|
||||
* user has committed to a slash command (`/<cmd> `) and started
|
||||
* typing its first argument. Consumed by the TUI to drive an
|
||||
* argument-level picker.
|
||||
*/
|
||||
export interface SlashArgContext {
|
||||
/** The command spec (looked up by name from SLASH_COMMANDS). */
|
||||
spec: SlashCommandSpec;
|
||||
/** The partial first-argument text, possibly empty. */
|
||||
partial: string;
|
||||
/**
|
||||
* Buffer offset where `partial` begins. Used by the TUI to splice
|
||||
* a picked completion back in at the right position.
|
||||
*/
|
||||
partialOffset: number;
|
||||
/**
|
||||
* Classification of what the caller should show:
|
||||
* - `"picker"` → an interactive picker (file / enum / models). The
|
||||
* caller uses `spec.argCompleter` to pick the data source and
|
||||
* filters against `partial`.
|
||||
* - `"hint"` → past the completable position (additional args or
|
||||
* no completer declared). Caller shows a dim usage hint only.
|
||||
*/
|
||||
kind: "picker" | "hint";
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify the prompt buffer for argument completion. Returns `null`
|
||||
* when the buffer isn't in a slash-with-args state.
|
||||
*
|
||||
* Firing shape: input must start with `/<cmd> ` (space after a known
|
||||
* command). The character right after the space through end-of-buffer
|
||||
* is the "arg tail"; if the tail has NO internal whitespace the
|
||||
* picker is live (tail is the partial). If the tail has whitespace,
|
||||
* the user has moved past the first argument position and we surface
|
||||
* the usage hint only.
|
||||
*/
|
||||
export function detectSlashArgContext(input: string, codeMode = false): SlashArgContext | null {
|
||||
// `/cmd <rest>` — one space, rest captured up to end-of-buffer.
|
||||
const m = /^\/(\S+) ([\s\S]*)$/.exec(input);
|
||||
if (!m) return null;
|
||||
const cmdName = m[1]!.toLowerCase();
|
||||
const tail = m[2] ?? "";
|
||||
const spec = SLASH_COMMANDS.find(
|
||||
(s) => s.cmd === cmdName && (s.contextual !== "code" || codeMode),
|
||||
);
|
||||
if (!spec) return null;
|
||||
const hasInternalSpace = /\s/.test(tail);
|
||||
const partialOffset = input.length - tail.length;
|
||||
if (hasInternalSpace) {
|
||||
// Past the first arg position (typing the second arg for e.g.
|
||||
// `/edit <file> <instruction>`, or mid-sentence for free-form).
|
||||
return { spec, partial: tail, partialOffset, kind: "hint" };
|
||||
}
|
||||
// No internal whitespace — we're still typing the first arg. Picker
|
||||
// is live if the spec declares a completer; otherwise show a hint
|
||||
// ("what goes here?") since the user's guessing.
|
||||
return {
|
||||
spec,
|
||||
partial: tail,
|
||||
partialOffset,
|
||||
kind: spec.argCompleter ? "picker" : "hint",
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSlash(text: string): { cmd: string; args: string[] } | null {
|
||||
if (!text.startsWith("/")) return null;
|
||||
const parts = text.slice(1).trim().split(/\s+/);
|
||||
@@ -292,20 +438,58 @@ export function handleSlash(
|
||||
};
|
||||
}
|
||||
|
||||
case "keys":
|
||||
return {
|
||||
info: [
|
||||
"Keyboard & prompt shortcuts:",
|
||||
"",
|
||||
" Enter submit the current prompt",
|
||||
" Shift+Enter / Ctrl+J insert a newline (multi-line prompt)",
|
||||
" \\<Enter> bash-style line continuation",
|
||||
" ← → ↑ ↓ move cursor / recall history when buffer empty",
|
||||
" Ctrl+A / Ctrl+E jump to start / end of the current line",
|
||||
" Backspace delete left; Delete delete under cursor",
|
||||
" Esc abort the in-flight turn",
|
||||
" y / n accept / reject pending edits (code mode)",
|
||||
"",
|
||||
"Prompt prefixes:",
|
||||
" /<name> slash command; Tab/Enter picks from the suggestion list",
|
||||
" @<path> inline a file under [Referenced files] (code mode).",
|
||||
" Trailing `@…` opens a file picker; ↑/↓ navigate, Tab/Enter pick.",
|
||||
" !<cmd> run <cmd> as shell in the sandbox root; output goes into context",
|
||||
" so the model sees it next turn. No allowlist gate.",
|
||||
"",
|
||||
"Pickers (slash + @-mention):",
|
||||
" ↑ / ↓ navigate the suggestion list",
|
||||
" Tab insert the highlighted item without submitting",
|
||||
" Enter insert and (slash) run it, (@) keep editing",
|
||||
"",
|
||||
"MCP exploration:",
|
||||
" /mcp servers + tool/resource/prompt counts",
|
||||
" /resource [uri] browse & read resources exposed by your MCP servers",
|
||||
" /prompt [name] browse & fetch prompts exposed by your MCP servers",
|
||||
"",
|
||||
"Useful slashes: /help · /context · /stats · /compact · /new · /exit",
|
||||
].join("\n"),
|
||||
};
|
||||
|
||||
case "help":
|
||||
case "?":
|
||||
return {
|
||||
info: [
|
||||
"Commands:",
|
||||
" /help this message",
|
||||
" /keys keyboard shortcuts + prompt prefixes (!, @, /)",
|
||||
" /status show current settings",
|
||||
" /preset <fast|smart|max> one-tap presets — see below",
|
||||
" /model <id> deepseek-chat or deepseek-reasoner",
|
||||
" /harvest [on|off] Pillar 2: structured plan-state extraction",
|
||||
" /branch <N|off> run N parallel samples (N>=2), pick most confident",
|
||||
" /mcp list MCP servers + tools attached to this session",
|
||||
" /resource [uri] browse + read MCP resources (no arg → list URIs; <uri> → fetch)",
|
||||
" /prompt [name] browse + fetch MCP prompts (no arg → list names; <name> → render)",
|
||||
" /setup (exit + reconfigure) → run `reasonix setup`",
|
||||
" /compact [cap] shrink large tool results in history (default 4k/result)",
|
||||
" /compact [tokens] shrink large tool results in history (default 4000 tokens/result)",
|
||||
" /think dump the most recent turn's full R1 reasoning (reasoner only)",
|
||||
" /tool [N] list tool calls (or dump full output of #N, 1=most recent)",
|
||||
" /memory [sub] show pinned memory (REASONIX.md + ~/.reasonix/memory).",
|
||||
@@ -325,6 +509,16 @@ export function handleSlash(
|
||||
" /clear clear displayed scrollback only (context kept — model still sees it)",
|
||||
" /exit quit",
|
||||
"",
|
||||
"Shell shortcut:",
|
||||
" !<cmd> run <cmd> in the sandbox root; output goes into",
|
||||
" the conversation so the model sees it next turn.",
|
||||
" No allowlist gate — user-typed = explicit consent.",
|
||||
" Example: !git status !ls src/ !npm test",
|
||||
"",
|
||||
"File references (code mode):",
|
||||
" @path/to/file inline file content under [Referenced files] on send.",
|
||||
" Type `@` to open the picker (↑↓ navigate, Tab/Enter pick).",
|
||||
"",
|
||||
"Presets:",
|
||||
" fast deepseek-chat no harvest no branch ~1¢/100turns ← default",
|
||||
" smart reasoner harvest ~10x cost, slower",
|
||||
@@ -351,6 +545,8 @@ export function handleSlash(
|
||||
// server with its tools / resources / prompts grouped together.
|
||||
if (servers.length > 0) {
|
||||
const lines: string[] = [];
|
||||
let anyResources = false;
|
||||
let anyPrompts = false;
|
||||
for (const s of servers) {
|
||||
const { report } = s;
|
||||
const serverName = report.serverInfo.name || "(unknown)";
|
||||
@@ -359,11 +555,20 @@ export function handleSlash(
|
||||
lines.push(` tools ${s.toolCount}`);
|
||||
appendSection(lines, "resources", report.resources);
|
||||
appendSection(lines, "prompts ", report.prompts);
|
||||
if (report.resources.supported && report.resources.items.length > 0) anyResources = true;
|
||||
if (report.prompts.supported && report.prompts.items.length > 0) anyPrompts = true;
|
||||
lines.push("");
|
||||
}
|
||||
lines.push(
|
||||
"Chat mode consumes tools today; resources+prompts are surfaced here for awareness.",
|
||||
);
|
||||
if (anyResources || anyPrompts) {
|
||||
const hints: string[] = [];
|
||||
if (anyResources) hints.push("`/resource` to browse+read");
|
||||
if (anyPrompts) hints.push("`/prompt` to browse+fetch");
|
||||
lines.push(hints.join(" · "));
|
||||
} else {
|
||||
lines.push(
|
||||
"Chat mode consumes tools today; resources+prompts are surfaced here for awareness.",
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
"Full catalog: `reasonix mcp list` · deeper diagnosis: `reasonix mcp inspect <spec>`.",
|
||||
);
|
||||
@@ -565,21 +770,22 @@ export function handleSlash(
|
||||
}
|
||||
|
||||
case "compact": {
|
||||
// Manual companion to the automatic heal-on-load. Re-applies
|
||||
// truncation with a tighter cap (4k chars per tool result) and
|
||||
// rewrites the session file so the shrink persists. Useful when
|
||||
// the ctx gauge in StatsPanel goes yellow/red mid-session and
|
||||
// the user wants to keep chatting without /forget'ing everything.
|
||||
// Manual companion to the automatic 60%/80% auto-compact. Re-
|
||||
// applies token-aware truncation with a tighter cap (default 4000
|
||||
// tokens per tool result) and rewrites the session file so the
|
||||
// shrink persists. Useful when the ctx gauge in StatsPanel goes
|
||||
// yellow/red mid-session and the user wants to keep chatting
|
||||
// without /forget'ing everything.
|
||||
const tight = Number.parseInt(args[0] ?? "", 10);
|
||||
const cap = Number.isFinite(tight) && tight >= 500 ? tight : 4000;
|
||||
const { healedCount, charsSaved } = loop.compact(cap);
|
||||
const cap = Number.isFinite(tight) && tight >= 100 ? tight : 4000;
|
||||
const { healedCount, tokensSaved, charsSaved } = loop.compact(cap);
|
||||
if (healedCount === 0) {
|
||||
return {
|
||||
info: `▸ nothing to compact — no tool result in history exceeds ${cap.toLocaleString()} chars.`,
|
||||
info: `▸ nothing to compact — no tool result in history exceeds ${cap.toLocaleString()} tokens.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
info: `▸ compacted ${healedCount} tool result(s), saved ${charsSaved.toLocaleString()} chars (~${Math.round(charsSaved / 4).toLocaleString()} tokens). Session file rewritten.`,
|
||||
info: `▸ compacted ${healedCount} tool result(s) to ${cap.toLocaleString()} tokens each, saved ${tokensSaved.toLocaleString()} tokens (${charsSaved.toLocaleString()} chars). Session file rewritten.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -617,6 +823,79 @@ export function handleSlash(
|
||||
};
|
||||
}
|
||||
|
||||
case "context": {
|
||||
// Measure each slice of the next request locally so the user can
|
||||
// see *where* context is being spent — a much more useful number
|
||||
// than the "last turn's total" the gauge shows. Tokenization is
|
||||
// lazy so the first /context call carries the data-file load
|
||||
// cost (~100ms); subsequent calls are pure compute.
|
||||
const systemTokens = countTokens(loop.prefix.system);
|
||||
const toolsTokens = countTokens(JSON.stringify(loop.prefix.toolSpecs));
|
||||
const entries = loop.log.toMessages();
|
||||
let userTokens = 0;
|
||||
let assistantTokens = 0;
|
||||
let toolResultTokens = 0;
|
||||
let toolCallTokens = 0;
|
||||
const toolBreakdown: Array<{ name: string; tokens: number; turn: number }> = [];
|
||||
let logTurn = 0;
|
||||
for (const e of entries) {
|
||||
const content = typeof e.content === "string" ? e.content : "";
|
||||
if (e.role === "user") {
|
||||
userTokens += countTokens(content);
|
||||
logTurn += 1;
|
||||
} else if (e.role === "assistant") {
|
||||
assistantTokens += countTokens(content);
|
||||
if (Array.isArray(e.tool_calls) && e.tool_calls.length > 0) {
|
||||
toolCallTokens += countTokens(JSON.stringify(e.tool_calls));
|
||||
}
|
||||
} else if (e.role === "tool") {
|
||||
const n = countTokens(content);
|
||||
toolResultTokens += n;
|
||||
toolBreakdown.push({ name: e.name ?? "?", tokens: n, turn: logTurn });
|
||||
}
|
||||
}
|
||||
const logTokens = userTokens + assistantTokens + toolResultTokens + toolCallTokens;
|
||||
const total = systemTokens + toolsTokens + logTokens;
|
||||
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS;
|
||||
const pct = (n: number) =>
|
||||
total > 0 ? `${Math.round((n / total) * 100)}%`.padStart(4) : " 0%";
|
||||
const row = (label: string, n: number, note = "") =>
|
||||
` ${label.padEnd(20)}${compactNum(n).padStart(8)} tokens ${pct(n)}${note ? ` ${note}` : ""}`;
|
||||
|
||||
const lines = [
|
||||
`Next-request estimate: ~${compactNum(total)} tokens of ${compactNum(ctxMax)} (${Math.round(
|
||||
(total / ctxMax) * 100,
|
||||
)}% of window)`,
|
||||
"",
|
||||
row("system prompt", systemTokens),
|
||||
row("tool specs", toolsTokens, `(${loop.prefix.toolSpecs.length} tools)`),
|
||||
row("log (all turns)", logTokens, `(${entries.length} messages)`),
|
||||
` user ${compactNum(userTokens).padStart(8)} tokens`,
|
||||
` assistant ${compactNum(assistantTokens).padStart(8)} tokens`,
|
||||
` tool-call args ${compactNum(toolCallTokens).padStart(8)} tokens`,
|
||||
` tool results ${compactNum(toolResultTokens).padStart(8)} tokens`,
|
||||
];
|
||||
|
||||
// Top 5 heaviest tool results — usually where unexpected bloat
|
||||
// lives (a big read_file, an unfiltered search_content).
|
||||
if (toolBreakdown.length > 0) {
|
||||
const top = [...toolBreakdown].sort((a, b) => b.tokens - a.tokens).slice(0, 5);
|
||||
lines.push("");
|
||||
lines.push(`Top tool results by cost (of ${toolBreakdown.length} total):`);
|
||||
for (const t of top) {
|
||||
lines.push(
|
||||
` turn ${String(t.turn).padStart(3)} ${t.name.padEnd(22)} ${compactNum(t.tokens).padStart(8)} tokens`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
"Count is a local estimate (DeepSeek V3 tokenizer, pure-TS port); server prompt_tokens may add ~3-6% for chat-template role markers.",
|
||||
);
|
||||
return { info: lines.join("\n") };
|
||||
}
|
||||
|
||||
case "status": {
|
||||
const branchBudget = loop.branchOptions.budget ?? 1;
|
||||
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[loop.model] ?? DEFAULT_CONTEXT_TOKENS;
|
||||
@@ -650,11 +929,53 @@ export function handleSlash(
|
||||
|
||||
case "model": {
|
||||
const id = args[0];
|
||||
if (!id) return { info: "usage: /model <id> (try deepseek-chat or deepseek-reasoner)" };
|
||||
const known = ctx.models ?? null;
|
||||
if (!id) {
|
||||
const hint =
|
||||
known && known.length > 0
|
||||
? known.join(" | ")
|
||||
: "try deepseek-chat or deepseek-reasoner — run /models to fetch the live list";
|
||||
return { info: `usage: /model <id> (${hint})` };
|
||||
}
|
||||
loop.configure({ model: id });
|
||||
// Soft validation: if we have the live list and the id isn't in
|
||||
// it, flag a warning but still switch — DeepSeek may have just
|
||||
// released something we haven't indexed yet, and refusing would
|
||||
// be worse than a bad API error on the next call.
|
||||
if (known && known.length > 0 && !known.includes(id)) {
|
||||
return {
|
||||
info: `model → ${id} (⚠ not in the fetched catalog: ${known.join(", ")}. If this is wrong the next call will 400 — run /models to refresh.)`,
|
||||
};
|
||||
}
|
||||
return { info: `model → ${id}` };
|
||||
}
|
||||
|
||||
case "models": {
|
||||
const list = ctx.models ?? null;
|
||||
if (list === null) {
|
||||
ctx.refreshModels?.();
|
||||
return {
|
||||
info: "fetching /models from DeepSeek… run /models again in a moment. If it stays empty, your API key may lack permission or the network is blocked.",
|
||||
};
|
||||
}
|
||||
if (list.length === 0) {
|
||||
return {
|
||||
info: "DeepSeek /models returned an empty list. Try /models again, or check your account status at api-docs.deepseek.com.",
|
||||
};
|
||||
}
|
||||
const current = loop.model;
|
||||
const lines = list.map((id) => (id === current ? `▸ ${id} (current)` : ` ${id}`));
|
||||
return {
|
||||
info: [
|
||||
`Available models (DeepSeek /models · ${list.length} total):`,
|
||||
"",
|
||||
...lines,
|
||||
"",
|
||||
"Switch with: /model <id>",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
case "harvest": {
|
||||
const arg = (args[0] ?? "").toLowerCase();
|
||||
const on = arg === "" ? !loop.harvestEnabled : arg === "on" || arg === "true" || arg === "1";
|
||||
@@ -1176,10 +1497,16 @@ function formatToolList(history: Array<{ toolName: string; text: string }>): str
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Binary-K token formatter: 1234 → "1.2K", 131072 → "128K". Matches
|
||||
* DeepSeek's doc ("128K context"). Every call site here is rendering
|
||||
* token counts — if a future caller wants decimal-K for dollars or
|
||||
* similar, add a separate formatter rather than reusing this one.
|
||||
*/
|
||||
function compactNum(n: number): string {
|
||||
if (n < 1000) return String(n);
|
||||
const k = n / 1000;
|
||||
return k >= 100 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
|
||||
if (n < 1024) return String(n);
|
||||
const k = n / 1024;
|
||||
return k >= 100 ? `${Math.round(k)}K` : `${k.toFixed(1)}K`;
|
||||
}
|
||||
|
||||
function stripOuterQuotes(s: string): string {
|
||||
|
||||
@@ -64,6 +64,23 @@ export interface UserBalance {
|
||||
balance_infos: BalanceInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Response shape for DeepSeek's `/models` endpoint. Mirrors the OpenAI
|
||||
* models list shape DeepSeek copied — `id` is the model name to pass to
|
||||
* `/chat/completions`, `owned_by` is the provider string (always
|
||||
* `"deepseek"` today).
|
||||
*/
|
||||
export interface ModelInfo {
|
||||
id: string;
|
||||
object: "model";
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
export interface ModelList {
|
||||
object: "list";
|
||||
data: ModelInfo[];
|
||||
}
|
||||
|
||||
export interface DeepSeekClientOptions {
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
@@ -133,6 +150,29 @@ export class DeepSeekClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the model catalog DeepSeek currently exposes. Today this is
|
||||
* `deepseek-chat` (V3) and `deepseek-reasoner` (R1), but querying is
|
||||
* the only way to learn about new ones without a Reasonix release.
|
||||
* Returns null on any network/auth failure so callers can degrade
|
||||
* gracefully — e.g. `/models` falls back to the hardcoded hint.
|
||||
*/
|
||||
async listModels(opts: { signal?: AbortSignal } = {}): Promise<ModelList | null> {
|
||||
try {
|
||||
const resp = await this._fetch(`${this.baseUrl}/models`, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${this.apiKey}` },
|
||||
signal: opts.signal,
|
||||
});
|
||||
if (!resp.ok) return null;
|
||||
const data = (await resp.json()) as ModelList;
|
||||
if (!data || !Array.isArray(data.data)) return null;
|
||||
return data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async chat(opts: ChatRequestOptions): Promise<ChatResponse> {
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), this.timeoutMs);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Render a SEARCH/REPLACE edit block as a compact unified-diff-style
|
||||
* preview for the code-mode confirm gate. Shows the user what's about
|
||||
* to land on disk BEFORE they press `y` — catches "wrong file" and
|
||||
* "wrong replacement" mistakes that the old path + line-count summary
|
||||
* couldn't.
|
||||
*
|
||||
* Not a full Myers diff: we trim shared leading/trailing lines as
|
||||
* context and render the middle divergence as `-old` / `+new`. That's
|
||||
* enough for the common SEARCH/REPLACE shape (small change inside a
|
||||
* function body, wrapped with a few lines of surrounding code for
|
||||
* uniqueness) and avoids pulling in a diff library.
|
||||
*/
|
||||
|
||||
import type { EditBlock } from "./edit-blocks.js";
|
||||
|
||||
export interface DiffPreviewOptions {
|
||||
/** How many lines of unchanged context to show at each end. Default 2. */
|
||||
contextLines?: number;
|
||||
/** Hard cap on total rendered lines. Default 20 — beyond this the preview collapses. */
|
||||
maxLines?: number;
|
||||
/** Indent applied to every output line. Default 8 spaces — matches the pending-preview nesting. */
|
||||
indent?: string;
|
||||
}
|
||||
|
||||
/** Render one edit block's diff. Returns an array of formatted lines. */
|
||||
export function formatEditBlockDiff(block: EditBlock, opts: DiffPreviewOptions = {}): string[] {
|
||||
const contextLines = Math.max(0, opts.contextLines ?? 2);
|
||||
const maxLines = Math.max(4, opts.maxLines ?? 20);
|
||||
const indent = opts.indent ?? " ";
|
||||
|
||||
const search = block.search === "" ? [] : block.search.split("\n");
|
||||
const replace = block.replace.split("\n");
|
||||
|
||||
// New-file case: no search to compare, show the full new content
|
||||
// (capped). Mark every line `+` so the user knows it's all additions.
|
||||
if (search.length === 0) {
|
||||
return renderAllPlus(replace, indent, maxLines);
|
||||
}
|
||||
|
||||
// Common leading / trailing lines — shared context we can collapse.
|
||||
let leading = 0;
|
||||
while (
|
||||
leading < search.length &&
|
||||
leading < replace.length &&
|
||||
search[leading] === replace[leading]
|
||||
) {
|
||||
leading++;
|
||||
}
|
||||
let trailing = 0;
|
||||
while (
|
||||
trailing < search.length - leading &&
|
||||
trailing < replace.length - leading &&
|
||||
search[search.length - 1 - trailing] === replace[replace.length - 1 - trailing]
|
||||
) {
|
||||
trailing++;
|
||||
}
|
||||
|
||||
const searchMiddle = search.slice(leading, search.length - trailing);
|
||||
const replaceMiddle = replace.slice(leading, replace.length - trailing);
|
||||
|
||||
// Trim context to `contextLines` on each side.
|
||||
const leadShown = search.slice(Math.max(0, leading - contextLines), leading);
|
||||
const leadHidden = leading - leadShown.length;
|
||||
const trailShown = search.slice(
|
||||
search.length - trailing,
|
||||
search.length - trailing + contextLines,
|
||||
);
|
||||
const trailHidden = trailing - trailShown.length;
|
||||
|
||||
const out: string[] = [];
|
||||
if (leadHidden > 0) {
|
||||
out.push(`${indent} … ${leadHidden} unchanged line${leadHidden === 1 ? "" : "s"} above`);
|
||||
}
|
||||
for (const l of leadShown) out.push(`${indent} ${l}`);
|
||||
for (const l of searchMiddle) out.push(`${indent}- ${l}`);
|
||||
for (const l of replaceMiddle) out.push(`${indent}+ ${l}`);
|
||||
for (const l of trailShown) out.push(`${indent} ${l}`);
|
||||
if (trailHidden > 0) {
|
||||
out.push(`${indent} … ${trailHidden} unchanged line${trailHidden === 1 ? "" : "s"} below`);
|
||||
}
|
||||
|
||||
return capLines(out, maxLines, indent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the full set of blocks back-to-back, each preceded by a
|
||||
* path-and-size header so the user can tell which file owns each
|
||||
* diff chunk. Blocks are separated by a blank line for breathing room.
|
||||
*/
|
||||
export function formatAllBlockDiffs(
|
||||
blocks: readonly EditBlock[],
|
||||
opts: DiffPreviewOptions = {},
|
||||
): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < blocks.length; i++) {
|
||||
const b = blocks[i]!;
|
||||
const removed = b.search === "" ? 0 : countLines(b.search);
|
||||
const added = countLines(b.replace);
|
||||
const tag = b.search === "" ? "NEW " : " ";
|
||||
if (i > 0) out.push("");
|
||||
out.push(` ${tag}${b.path} (-${removed} +${added} lines)`);
|
||||
out.push(...formatEditBlockDiff(b, opts));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function countLines(s: string): number {
|
||||
if (s.length === 0) return 0;
|
||||
return (s.match(/\n/g)?.length ?? 0) + 1;
|
||||
}
|
||||
|
||||
function renderAllPlus(lines: string[], indent: string, maxLines: number): string[] {
|
||||
const out = lines.map((l) => `${indent}+ ${l}`);
|
||||
return capLines(out, maxLines, indent);
|
||||
}
|
||||
|
||||
function capLines(lines: string[], maxLines: number, indent: string): string[] {
|
||||
if (lines.length <= maxLines) return lines;
|
||||
const head = lines.slice(0, maxLines - 1);
|
||||
const hidden = lines.length - head.length;
|
||||
head.push(`${indent}… (${hidden} more diff lines — full content applies on /apply)`);
|
||||
return head;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Durable checkpoint for the pending-edits queue.
|
||||
*
|
||||
* Code mode holds parsed SEARCH/REPLACE blocks in an in-memory
|
||||
* `useRef<EditBlock[]>` until the user runs `/apply` or `/discard`.
|
||||
* Pre-0.5.14 that was ephemeral: a crash or accidental Ctrl+C after
|
||||
* the model proposed seven edits discarded all seven — the session
|
||||
* log had the assistant text, but the "please review this batch"
|
||||
* state was gone.
|
||||
*
|
||||
* This module checkpoints the queue to
|
||||
* `~/.reasonix/sessions/<name>.pending.json`
|
||||
* after every mutation (parse → set / apply → clear / discard →
|
||||
* clear). On session resume the App reads the file and re-populates
|
||||
* the queue. If the file doesn't exist the session resumes as before.
|
||||
*
|
||||
* Format choices:
|
||||
* - JSON (not JSONL) — the queue is a single array written at once,
|
||||
* not a stream of records.
|
||||
* - Overwrite-on-write — the queue is small (kilobytes) and atomicity
|
||||
* is "last write wins"; partial-write on crash is fine since we'd
|
||||
* rather lose the checkpoint than have a torn file block resume.
|
||||
* - Best-effort I/O — disk full or permission denied must not break
|
||||
* the session. Silent skip matches the rest of the durable logs
|
||||
* (session.ts, usage.ts).
|
||||
*
|
||||
* Ephemeral sessions (sessionName === null) skip persistence entirely:
|
||||
* there's no stable name to key the checkpoint to.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { sanitizeName, sessionsDir } from "../session.js";
|
||||
import type { EditBlock } from "./edit-blocks.js";
|
||||
|
||||
/** Absolute path for the checkpoint file that belongs to this session. */
|
||||
export function pendingEditsPath(sessionName: string): string {
|
||||
return join(sessionsDir(), `${sanitizeName(sessionName)}.pending.json`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the queue to disk, or delete the checkpoint file when `blocks`
|
||||
* is empty. Callers pass the full current queue every time; we don't
|
||||
* attempt append-style deltas because the queue is tiny.
|
||||
*
|
||||
* No-op when `sessionName` is null (ephemeral session has nowhere to
|
||||
* write), so call sites can pass the session name directly without a
|
||||
* null guard.
|
||||
*/
|
||||
export function savePendingEdits(sessionName: string | null, blocks: EditBlock[]): void {
|
||||
if (!sessionName) return;
|
||||
const path = pendingEditsPath(sessionName);
|
||||
try {
|
||||
if (blocks.length === 0) {
|
||||
if (existsSync(path)) unlinkSync(path);
|
||||
return;
|
||||
}
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, JSON.stringify(blocks, null, 2), "utf8");
|
||||
} catch {
|
||||
/* best-effort — disk full / perms should not break the session */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the queue from disk, returns `null` when there's no checkpoint.
|
||||
* A malformed file (power-loss torn write, user hand-edit) also returns
|
||||
* null — silent recovery beats failing to open the session.
|
||||
*/
|
||||
export function loadPendingEdits(sessionName: string | null): EditBlock[] | null {
|
||||
if (!sessionName) return null;
|
||||
const path = pendingEditsPath(sessionName);
|
||||
if (!existsSync(path)) return null;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = readFileSync(path, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const out: EditBlock[] = [];
|
||||
for (const item of parsed) {
|
||||
if (
|
||||
item &&
|
||||
typeof item === "object" &&
|
||||
typeof item.path === "string" &&
|
||||
typeof item.search === "string" &&
|
||||
typeof item.replace === "string" &&
|
||||
typeof item.offset === "number"
|
||||
) {
|
||||
out.push(item as EditBlock);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete the checkpoint file unconditionally — called by /apply and /discard. */
|
||||
export function clearPendingEdits(sessionName: string | null): void {
|
||||
if (!sessionName) return;
|
||||
const path = pendingEditsPath(sessionName);
|
||||
try {
|
||||
if (existsSync(path)) unlinkSync(path);
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,30 @@ export type { ChatResponse, StreamChunk, DeepSeekClientOptions } from "./client.
|
||||
export {
|
||||
CacheFirstLoop,
|
||||
formatLoopError,
|
||||
fixToolCallPairing,
|
||||
healLoadedMessages,
|
||||
healLoadedMessagesByTokens,
|
||||
stripHallucinatedToolMarkup,
|
||||
} from "./loop.js";
|
||||
export {
|
||||
AT_MENTION_PATTERN,
|
||||
AT_PICKER_PREFIX,
|
||||
DEFAULT_AT_MENTION_MAX_BYTES,
|
||||
DEFAULT_PICKER_IGNORE_DIRS,
|
||||
detectAtPicker,
|
||||
expandAtMentions,
|
||||
listFilesSync,
|
||||
listFilesWithStatsSync,
|
||||
rankPickerCandidates,
|
||||
} from "./at-mentions.js";
|
||||
export type {
|
||||
AtMentionExpansion,
|
||||
AtMentionOptions,
|
||||
FileWithStats,
|
||||
ListFilesOptions,
|
||||
PickerCandidate,
|
||||
RankPickerOptions,
|
||||
} from "./at-mentions.js";
|
||||
export type {
|
||||
CacheFirstLoopOptions,
|
||||
LoopEvent,
|
||||
@@ -72,6 +93,7 @@ export type {
|
||||
} from "./tools/subagent.js";
|
||||
export {
|
||||
NeedsConfirmationError,
|
||||
detectShellOperator,
|
||||
formatCommandResult,
|
||||
injectPowerShellUtf8,
|
||||
isAllowed,
|
||||
@@ -173,9 +195,11 @@ export { SseTransport } from "./mcp/sse.js";
|
||||
export type { SseTransportOptions } from "./mcp/sse.js";
|
||||
export {
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
DEFAULT_MAX_RESULT_TOKENS,
|
||||
bridgeMcpTools,
|
||||
flattenMcpResult,
|
||||
truncateForModel,
|
||||
truncateForModelByTokens,
|
||||
} from "./mcp/registry.js";
|
||||
export type { BridgeOptions, BridgeResult, FlattenOptions } from "./mcp/registry.js";
|
||||
export { parseMcpSpec } from "./mcp/spec.js";
|
||||
|
||||
+306
-71
@@ -13,7 +13,12 @@ import {
|
||||
formatHookOutcomeMessage,
|
||||
runHooks,
|
||||
} from "./hooks.js";
|
||||
import { DEFAULT_MAX_RESULT_CHARS, truncateForModel } from "./mcp/registry.js";
|
||||
import {
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
DEFAULT_MAX_RESULT_TOKENS,
|
||||
truncateForModel,
|
||||
truncateForModelByTokens,
|
||||
} from "./mcp/registry.js";
|
||||
import { AppendOnlyLog, type ImmutablePrefix, VolatileScratch } from "./memory.js";
|
||||
import { type RepairReport, ToolCallRepair } from "./repair/index.js";
|
||||
import { appendSessionMessage, loadSessionMessages, rewriteSession } from "./session.js";
|
||||
@@ -23,6 +28,7 @@ import {
|
||||
SessionStats,
|
||||
type TurnStats,
|
||||
} from "./telemetry.js";
|
||||
import { countTokens, estimateRequestTokens } from "./tokenizer.js";
|
||||
import { ToolRegistry } from "./tools.js";
|
||||
import type { ChatMessage, ToolCall } from "./types.js";
|
||||
|
||||
@@ -90,6 +96,19 @@ export interface LoopEvent {
|
||||
toolArgs?: string;
|
||||
/** Cumulative arguments-string length for `role === "tool_call_delta"`. */
|
||||
toolCallArgsChars?: number;
|
||||
/**
|
||||
* Zero-based index of the tool call this delta belongs to. Surfaces
|
||||
* multi-tool turns: on a response emitting 4 write_file calls the UI
|
||||
* can show "building call 3/?" instead of a context-free spinner.
|
||||
*/
|
||||
toolCallIndex?: number;
|
||||
/**
|
||||
* Count of prior tool calls (this turn) whose arguments have finished
|
||||
* streaming into valid JSON. Not all ready calls have been dispatched
|
||||
* yet — dispatch still happens post-stream — but the user gets "2
|
||||
* ready" progress feedback while later calls keep streaming.
|
||||
*/
|
||||
toolCallReadyCount?: number;
|
||||
stats?: TurnStats;
|
||||
planState?: TypedPlanState;
|
||||
repair?: RepairReport;
|
||||
@@ -267,9 +286,9 @@ export class CacheFirstLoop {
|
||||
this.sessionName = opts.session ?? null;
|
||||
if (this.sessionName) {
|
||||
const prior = loadSessionMessages(this.sessionName);
|
||||
const { messages, healedCount, healedFrom } = healLoadedMessages(
|
||||
const { messages, healedCount, tokensSaved } = healLoadedMessagesByTokens(
|
||||
prior,
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
DEFAULT_MAX_RESULT_TOKENS,
|
||||
);
|
||||
for (const msg of messages) this.log.append(msg);
|
||||
this.resumedMessageCount = messages.length;
|
||||
@@ -285,7 +304,7 @@ export class CacheFirstLoop {
|
||||
/* disk full / perms — skip, in-memory heal still applies */
|
||||
}
|
||||
process.stderr.write(
|
||||
`▸ session "${this.sessionName}": healed ${healedCount} entr${healedCount === 1 ? "y" : "ies"}${healedFrom > 0 ? ` (was ${healedFrom.toLocaleString()} chars oversized)` : " (dropped dangling tool_calls tail)"}. Rewrote session file.\n`,
|
||||
`▸ session "${this.sessionName}": healed ${healedCount} entr${healedCount === 1 ? "y" : "ies"}${tokensSaved > 0 ? ` (shrunk ${tokensSaved.toLocaleString()} tokens of oversized tool output)` : " (dropped dangling tool_calls tail)"}. Rewrote session file.\n`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -295,28 +314,36 @@ export class CacheFirstLoop {
|
||||
|
||||
/**
|
||||
* Shrink the log by re-truncating oversized tool results to a tighter
|
||||
* cap, and persist the result back to disk so the next launch doesn't
|
||||
* re-inherit a fat session file. Returns a summary the TUI can
|
||||
* display.
|
||||
* token cap, and persist the result back to disk so the next launch
|
||||
* doesn't re-inherit a fat session file. Returns a summary the TUI
|
||||
* can display.
|
||||
*
|
||||
* The cap is in DeepSeek V3 tokens (not chars) — so CJK text gets
|
||||
* capped at the same effective context footprint as English instead
|
||||
* of slipping past a char cap at 2× the token cost. Default 4000
|
||||
* tokens, matching the token-aware dispatch cap from 0.5.2.
|
||||
*
|
||||
* Only tool-role messages are touched (same rationale as
|
||||
* {@link healLoadedMessages}). User and assistant messages carry
|
||||
* authored intent we can't mechanically shrink without losing
|
||||
* meaning.
|
||||
*/
|
||||
compact(tightCapChars = 4000): { healedCount: number; charsSaved: number } {
|
||||
compact(maxTokens = 4000): {
|
||||
healedCount: number;
|
||||
tokensSaved: number;
|
||||
charsSaved: number;
|
||||
} {
|
||||
const before = this.log.toMessages();
|
||||
// Use `shrinkOversizedToolResults` (not `healLoadedMessages`) — the
|
||||
// full heal would also strip a dangling `assistant.tool_calls` tail,
|
||||
// which during an active turn is legitimate state we still need
|
||||
// (tools haven't been dispatched yet). Structural healing is only
|
||||
// appropriate at session LOAD; mid-session `/compact` is strictly
|
||||
// about shrinking oversized tool payloads.
|
||||
const { messages, healedCount, healedFrom } = shrinkOversizedToolResults(before, tightCapChars);
|
||||
const afterBytes = messages
|
||||
.filter((m) => m.role === "tool")
|
||||
.reduce((s, m) => s + (typeof m.content === "string" ? m.content.length : 0), 0);
|
||||
const charsSaved = healedFrom - afterBytes;
|
||||
// Use `shrinkOversizedToolResultsByTokens` (not `healLoadedMessages`)
|
||||
// — the full heal would also strip a dangling `assistant.tool_calls`
|
||||
// tail, which during an active turn is legitimate state we still
|
||||
// need (tools haven't been dispatched yet). Structural healing is
|
||||
// only appropriate at session LOAD; mid-session `/compact` is
|
||||
// strictly about shrinking oversized tool payloads.
|
||||
const { messages, healedCount, tokensSaved, charsSaved } = shrinkOversizedToolResultsByTokens(
|
||||
before,
|
||||
maxTokens,
|
||||
);
|
||||
if (healedCount > 0) {
|
||||
this.log.compactInPlace(messages);
|
||||
if (this.sessionName) {
|
||||
@@ -327,10 +354,10 @@ export class CacheFirstLoop {
|
||||
}
|
||||
}
|
||||
}
|
||||
return { healedCount, charsSaved };
|
||||
return { healedCount, tokensSaved, charsSaved };
|
||||
}
|
||||
|
||||
private appendAndPersist(message: ChatMessage): void {
|
||||
appendAndPersist(message: ChatMessage): void {
|
||||
this.log.append(message);
|
||||
if (this.sessionName) {
|
||||
try {
|
||||
@@ -511,7 +538,12 @@ export class CacheFirstLoop {
|
||||
};
|
||||
const stoppedMsg =
|
||||
"[aborted by user (Esc) — no summary produced. Ask again or /retry when ready; prior tool output is still in the log.]";
|
||||
this.appendAndPersist({ role: "assistant", content: stoppedMsg });
|
||||
// Synthetic assistant turn — no real model output exists. For
|
||||
// reasoner sessions R1 still demands `reasoning_content` on
|
||||
// every assistant message, so we attach an empty-string
|
||||
// placeholder to satisfy the validator without inventing
|
||||
// reasoning we don't have. V3 gets a plain message as before.
|
||||
this.appendAndPersist(this.syntheticAssistantMessage(stoppedMsg));
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "assistant_final",
|
||||
@@ -548,7 +580,43 @@ export class CacheFirstLoop {
|
||||
content: `${iter}/${this.maxToolIters} tool calls used — approaching budget. Press Esc to force a summary now.`,
|
||||
};
|
||||
}
|
||||
const messages = this.buildMessages(pendingUser);
|
||||
let messages = this.buildMessages(pendingUser);
|
||||
|
||||
// Preflight context check. Reactive auto-compact at 60%/80%
|
||||
// keys off the PREVIOUS turn's server-reported prompt_tokens,
|
||||
// so a single new oversized tool result (or a fresh resumed
|
||||
// session) can push this turn's request straight past 131,072
|
||||
// tokens before we ever see a usage number — DeepSeek 400s with
|
||||
// "maximum context length". Here we estimate the outgoing
|
||||
// payload locally and compact preemptively when it's in the red
|
||||
// zone (>95% of the model's context window). One cheap
|
||||
// tokenize-pass per iter, only on our side.
|
||||
{
|
||||
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
|
||||
const estimate = estimateRequestTokens(messages, this.prefix.toolSpecs);
|
||||
if (estimate / ctxMax > 0.95) {
|
||||
const result = this.compact(1_000);
|
||||
if (result.healedCount > 0) {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `preflight: request ~${estimate.toLocaleString()}/${ctxMax.toLocaleString()} tokens (${Math.round(
|
||||
(estimate / ctxMax) * 100,
|
||||
)}%) — pre-compacted ${result.healedCount} tool result(s), saved ${result.tokensSaved.toLocaleString()} tokens. Sending.`,
|
||||
};
|
||||
// Rebuild with the compacted log so we send the smaller payload.
|
||||
messages = this.buildMessages(pendingUser);
|
||||
} else {
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `preflight: request ~${estimate.toLocaleString()}/${ctxMax.toLocaleString()} tokens (${Math.round(
|
||||
(estimate / ctxMax) * 100,
|
||||
)}%) and nothing to auto-compact — DeepSeek will likely 400. Run /forget or /clear to start fresh.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let assistantContent = "";
|
||||
let reasoningContent = "";
|
||||
@@ -650,6 +718,13 @@ export class CacheFirstLoop {
|
||||
};
|
||||
} else if (this.stream) {
|
||||
const callBuf: Map<number, ToolCall> = new Map();
|
||||
// Indices whose accumulated args have parsed as valid JSON at
|
||||
// least once. Purely informational — we don't dispatch until
|
||||
// the stream ends (that's the eager-dispatch feature we
|
||||
// intentionally punted) but the UI shows "N ready" so the
|
||||
// user sees progress on long multi-tool turns instead of a
|
||||
// stagnant "building tool call" spinner.
|
||||
const readyIndices = new Set<number>();
|
||||
for await (const chunk of this.client.stream({
|
||||
model: this.model,
|
||||
messages,
|
||||
@@ -685,6 +760,18 @@ export class CacheFirstLoop {
|
||||
if (d.argumentsDelta)
|
||||
cur.function.arguments = (cur.function.arguments ?? "") + d.argumentsDelta;
|
||||
callBuf.set(d.index, cur);
|
||||
|
||||
// Mark this index "ready" once its args first parse as
|
||||
// valid JSON. JSON.parse is sub-millisecond on typical
|
||||
// tool-call payloads; skip the check once already ready.
|
||||
if (
|
||||
!readyIndices.has(d.index) &&
|
||||
cur.function.name &&
|
||||
looksLikeCompleteJson(cur.function.arguments ?? "")
|
||||
) {
|
||||
readyIndices.add(d.index);
|
||||
}
|
||||
|
||||
// Skip the id-only opener: name is empty until the next chunk.
|
||||
if (cur.function.name) {
|
||||
yield {
|
||||
@@ -693,6 +780,8 @@ export class CacheFirstLoop {
|
||||
content: "",
|
||||
toolName: cur.function.name,
|
||||
toolCallArgsChars: (cur.function.arguments ?? "").length,
|
||||
toolCallIndex: d.index,
|
||||
toolCallReadyCount: readyIndices.size,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -771,7 +860,9 @@ export class CacheFirstLoop {
|
||||
assistantContent || null,
|
||||
);
|
||||
|
||||
this.appendAndPersist(this.assistantMessage(assistantContent, repairedCalls));
|
||||
this.appendAndPersist(
|
||||
this.assistantMessage(assistantContent, repairedCalls, reasoningContent),
|
||||
);
|
||||
|
||||
yield {
|
||||
turn: this._turn,
|
||||
@@ -826,19 +917,38 @@ export class CacheFirstLoop {
|
||||
// tool_calls"). The summary is about what was LEARNED so far,
|
||||
// not what we intended to do next.
|
||||
const ctxMax = DEEPSEEK_CONTEXT_TOKENS[this.model] ?? DEFAULT_CONTEXT_TOKENS;
|
||||
// Proactive tier: between 60% and 80%, pre-shrink oversized tool
|
||||
// results to a moderate cap (4k tokens) so the next iter doesn't
|
||||
// slam straight into the 80% reactive path — which shrinks far
|
||||
// more aggressively (1k tokens) and risks losing useful tail
|
||||
// info. This catches the slow-growth pattern (lots of medium
|
||||
// reads) before it compounds.
|
||||
if (usage) {
|
||||
const ratio = usage.promptTokens / ctxMax;
|
||||
if (ratio > 0.6 && ratio <= 0.8) {
|
||||
const before = usage.promptTokens;
|
||||
const soft = this.compact(4_000);
|
||||
if (soft.healedCount > 0) {
|
||||
const after = Math.max(0, before - soft.tokensSaved);
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} (${Math.round(
|
||||
ratio * 100,
|
||||
)}%) — proactively compacted ${soft.healedCount} tool result(s) to 4k tokens, saved ${soft.tokensSaved.toLocaleString()} tokens (now ~${after.toLocaleString()}). Staying ahead of the 80% guard.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (usage && usage.promptTokens / ctxMax > 0.8) {
|
||||
const before = usage.promptTokens;
|
||||
const compactResult = this.compact(4000);
|
||||
const compactResult = this.compact(1_000);
|
||||
if (compactResult.healedCount > 0) {
|
||||
// Rough estimate: 4 chars per token. Good enough to decide
|
||||
// whether compaction pushed us back under the threshold; the
|
||||
// exact number comes back from the NEXT API response's usage.
|
||||
const approxSaved = Math.round(compactResult.charsSaved / 4);
|
||||
const after = before - approxSaved;
|
||||
const after = Math.max(0, before - compactResult.tokensSaved);
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "warning",
|
||||
content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} — auto-compacted ${compactResult.healedCount} oversized tool result(s), saved ~${approxSaved.toLocaleString()} tokens (now ~${after.toLocaleString()}). Continuing.`,
|
||||
content: `context ${before.toLocaleString()}/${ctxMax.toLocaleString()} — auto-compacted ${compactResult.healedCount} oversized tool result(s), saved ${compactResult.tokensSaved.toLocaleString()} tokens (now ~${after.toLocaleString()}). Continuing.`,
|
||||
};
|
||||
// Intentionally don't re-check the threshold here: even if
|
||||
// compaction didn't fully clear us under 80%, one more tool
|
||||
@@ -921,7 +1031,10 @@ export class CacheFirstLoop {
|
||||
).trim();
|
||||
result = `[hook block] ${blocking?.hook.command ?? "<unknown>"}\n${reason}`;
|
||||
} else {
|
||||
result = await this.tools.dispatch(name, args, { signal });
|
||||
result = await this.tools.dispatch(name, args, {
|
||||
signal,
|
||||
maxResultTokens: DEFAULT_MAX_RESULT_TOKENS,
|
||||
});
|
||||
|
||||
// PostToolUse hooks — block is meaningless after the fact, so
|
||||
// every non-pass outcome is a warning. Hooks here are the
|
||||
@@ -1001,7 +1114,7 @@ export class CacheFirstLoop {
|
||||
const reasonPrefix = reasonPrefixFor(opts.reason, this.maxToolIters);
|
||||
const annotated = `${reasonPrefix}\n\n${summary}`;
|
||||
const summaryStats = this.stats.record(this._turn, this.model, resp.usage ?? new Usage());
|
||||
this.appendAndPersist({ role: "assistant", content: summary });
|
||||
this.appendAndPersist(this.assistantMessage(summary, [], resp.reasoningContent ?? undefined));
|
||||
yield {
|
||||
turn: this._turn,
|
||||
role: "assistant_final",
|
||||
@@ -1032,9 +1145,41 @@ export class CacheFirstLoop {
|
||||
return final;
|
||||
}
|
||||
|
||||
private assistantMessage(content: string, toolCalls: ToolCall[]): ChatMessage {
|
||||
private assistantMessage(
|
||||
content: string,
|
||||
toolCalls: ToolCall[],
|
||||
reasoningContent?: string,
|
||||
): ChatMessage {
|
||||
const msg: ChatMessage = { role: "assistant", content };
|
||||
if (toolCalls.length > 0) msg.tool_calls = toolCalls;
|
||||
// DeepSeek's "thinking mode must be passed back" rule applies to
|
||||
// ANY R1-generated assistant message, not just the ones with
|
||||
// tool_calls. 0.5.15 mis-scoped this: if the loop produced a
|
||||
// plain-text R1 turn (e.g. "plan submitted — awaiting approval")
|
||||
// between a tool-call turn and the next user turn, its reasoning
|
||||
// was stripped, and the following request 400'd with the same
|
||||
// error we already thought we'd fixed. deepseek-chat never emits
|
||||
// reasoning_content, so V3 requests stay untouched (the field
|
||||
// simply isn't present).
|
||||
if (reasoningContent && reasoningContent.length > 0) {
|
||||
msg.reasoning_content = reasoningContent;
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a synthetic assistant message we insert into the log without
|
||||
* a real API round trip (abort notices, future system injections).
|
||||
* Reasoner models reject follow-up requests whose assistant history
|
||||
* is missing `reasoning_content`, so we stamp an empty-string
|
||||
* placeholder on reasoner sessions to satisfy the validator. V3
|
||||
* doesn't care — field stays absent there.
|
||||
*/
|
||||
private syntheticAssistantMessage(content: string): ChatMessage {
|
||||
const msg: ChatMessage = { role: "assistant", content };
|
||||
if (this.model.includes("reasoner")) {
|
||||
msg.reasoning_content = "";
|
||||
}
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
@@ -1078,6 +1223,25 @@ function safeParseToolArgs(raw: string): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap "is this accumulated tool-call arguments blob now a complete
|
||||
* JSON value?" check. Used during streaming to mark a tool call as
|
||||
* ready for UI progress feedback — not for dispatch gating. Empty /
|
||||
* whitespace-only is not complete; anything that parses is.
|
||||
*
|
||||
* Exported so tests can lock down the precise shapes we consider
|
||||
* "ready" vs "still streaming."
|
||||
*/
|
||||
export function looksLikeCompleteJson(s: string): boolean {
|
||||
if (!s || !s.trim()) return false;
|
||||
try {
|
||||
JSON.parse(s);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Format non-pass hook outcomes as `LoopEvent`s of role `warning`. */
|
||||
function* hookWarnings(outcomes: HookOutcome[], turn: number): Generator<LoopEvent> {
|
||||
for (const o of outcomes) {
|
||||
@@ -1138,44 +1302,84 @@ export function shrinkOversizedToolResults(
|
||||
return { messages: out, healedCount, healedFrom };
|
||||
}
|
||||
|
||||
export function healLoadedMessages(
|
||||
/**
|
||||
* Token-aware variant of `shrinkOversizedToolResults`. Used by live
|
||||
* `/compact` and auto-compact so the shrink cap bounds the REAL
|
||||
* context footprint (CJK at 1 char/token would otherwise survive a
|
||||
* char cap at 2× the intended token cost). Session-load heal still
|
||||
* uses the char version for backward-compat on stored session files.
|
||||
*
|
||||
* Per-message token accounting: we tokenize each shrink candidate
|
||||
* twice (before + after) so `tokensSaved` is exact. At typical log
|
||||
* sizes (≤20 tool results) this is bounded; at pathological sizes
|
||||
* the `truncateForModelByTokens` call internally never tokenizes the
|
||||
* full input, so worst-case stays bounded too.
|
||||
*/
|
||||
export function shrinkOversizedToolResultsByTokens(
|
||||
messages: ChatMessage[],
|
||||
maxChars: number,
|
||||
): { messages: ChatMessage[]; healedCount: number; healedFrom: number } {
|
||||
// Pass 1: shrink oversized tool results (original heal purpose).
|
||||
const shrunk = shrinkOversizedToolResults(messages, maxChars);
|
||||
let healedCount = shrunk.healedCount;
|
||||
// Pass 2: enforce tool_calls ↔ tool pairing across the full log.
|
||||
//
|
||||
// DeepSeek rejects two shapes at the API boundary:
|
||||
// (a) assistant with tool_calls not followed by matching tool
|
||||
// responses ("insufficient tool messages following tool_calls")
|
||||
// (b) tool message without a preceding assistant.tool_calls with
|
||||
// the matching tool_call_id ("must be a response to a preceding
|
||||
// message with 'tool_calls'")
|
||||
//
|
||||
// Corrupted session files from earlier builds have hit both. Rebuild
|
||||
// the message stream so only well-formed (assistant.tool_calls + all
|
||||
// matching responses) groups survive. Plain user/assistant messages
|
||||
// (no tool_calls) always pass through.
|
||||
maxTokens: number,
|
||||
): {
|
||||
messages: ChatMessage[];
|
||||
healedCount: number;
|
||||
tokensSaved: number;
|
||||
charsSaved: number;
|
||||
} {
|
||||
let healedCount = 0;
|
||||
let tokensSaved = 0;
|
||||
let charsSaved = 0;
|
||||
const out = messages.map((msg) => {
|
||||
if (msg.role !== "tool") return msg;
|
||||
const content = typeof msg.content === "string" ? msg.content : "";
|
||||
// Fast path: length ≤ maxTokens ⇒ tokens ≤ maxTokens (every token
|
||||
// is ≥1 char). Skip the per-message tokenize for small results.
|
||||
if (content.length <= maxTokens) return msg;
|
||||
const beforeTokens = countTokens(content);
|
||||
if (beforeTokens <= maxTokens) return msg;
|
||||
const truncated = truncateForModelByTokens(content, maxTokens);
|
||||
const afterTokens = countTokens(truncated);
|
||||
healedCount += 1;
|
||||
tokensSaved += Math.max(0, beforeTokens - afterTokens);
|
||||
charsSaved += Math.max(0, content.length - truncated.length);
|
||||
return { ...msg, content: truncated };
|
||||
});
|
||||
return { messages: out, healedCount, tokensSaved, charsSaved };
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce tool_calls ↔ tool pairing across a message log. DeepSeek
|
||||
* rejects two shapes at the API boundary:
|
||||
* (a) assistant with tool_calls not followed by matching tool
|
||||
* responses ("insufficient tool messages following tool_calls")
|
||||
* (b) tool message without a preceding assistant.tool_calls with
|
||||
* the matching tool_call_id ("must be a response to a preceding
|
||||
* message with 'tool_calls'")
|
||||
*
|
||||
* Corrupted session files from earlier builds have hit both. This pass
|
||||
* rebuilds the message stream so only well-formed (assistant.tool_calls
|
||||
* + all matching responses) groups survive. Plain user/assistant/system
|
||||
* messages (no tool_calls) always pass through.
|
||||
*
|
||||
* Exported so both char-based and token-based heal can compose it.
|
||||
*/
|
||||
export function fixToolCallPairing(messages: ChatMessage[]): {
|
||||
messages: ChatMessage[];
|
||||
droppedAssistantCalls: number;
|
||||
droppedStrayTools: number;
|
||||
} {
|
||||
const out: ChatMessage[] = [];
|
||||
const openCallIds = new Set<string>();
|
||||
let droppedAssistantCalls = 0;
|
||||
let droppedStrayTools = 0;
|
||||
for (let i = 0; i < shrunk.messages.length; i++) {
|
||||
const msg = shrunk.messages[i]!;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]!;
|
||||
if (msg.role === "assistant" && Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0) {
|
||||
// Look ahead for tool responses matching every id in this
|
||||
// assistant's tool_calls. If all present (in any order, but
|
||||
// contiguous after this message), include the whole group.
|
||||
const needed = new Set<string>();
|
||||
for (const call of msg.tool_calls) {
|
||||
if (call?.id) needed.add(call.id);
|
||||
}
|
||||
const candidates: ChatMessage[] = [];
|
||||
let j = i + 1;
|
||||
while (j < shrunk.messages.length && needed.size > 0) {
|
||||
const nxt = shrunk.messages[j]!;
|
||||
while (j < messages.length && needed.size > 0) {
|
||||
const nxt = messages[j]!;
|
||||
if (nxt.role !== "tool") break;
|
||||
const id = nxt.tool_call_id ?? "";
|
||||
if (!needed.has(id)) break;
|
||||
@@ -1184,13 +1388,10 @@ export function healLoadedMessages(
|
||||
j++;
|
||||
}
|
||||
if (needed.size === 0) {
|
||||
// Every call has a response — emit the whole group.
|
||||
out.push(msg);
|
||||
for (const r of candidates) out.push(r);
|
||||
i = j - 1; // for-loop ++ will advance past the last response
|
||||
i = j - 1;
|
||||
} else {
|
||||
// Drop the assistant entry and anything that was speculatively
|
||||
// its responses — they'd become stray tool messages.
|
||||
droppedAssistantCalls += 1;
|
||||
droppedStrayTools += candidates.length;
|
||||
i = j - 1;
|
||||
@@ -1198,17 +1399,51 @@ export function healLoadedMessages(
|
||||
continue;
|
||||
}
|
||||
if (msg.role === "tool") {
|
||||
// Any tool message that reaches here did NOT get consumed by
|
||||
// the assistant-with-tool_calls branch above, so it's stray.
|
||||
// Drop it — surfacing it would 400 the next API call.
|
||||
droppedStrayTools += 1;
|
||||
continue;
|
||||
}
|
||||
// Plain user/assistant/system message — pass through.
|
||||
out.push(msg);
|
||||
}
|
||||
healedCount += droppedAssistantCalls + droppedStrayTools;
|
||||
return { messages: out, healedCount, healedFrom: shrunk.healedFrom };
|
||||
return { messages: out, droppedAssistantCalls, droppedStrayTools };
|
||||
}
|
||||
|
||||
export function healLoadedMessages(
|
||||
messages: ChatMessage[],
|
||||
maxChars: number,
|
||||
): { messages: ChatMessage[]; healedCount: number; healedFrom: number } {
|
||||
const shrunk = shrinkOversizedToolResults(messages, maxChars);
|
||||
const paired = fixToolCallPairing(shrunk.messages);
|
||||
const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
|
||||
return { messages: paired.messages, healedCount, healedFrom: shrunk.healedFrom };
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-aware counterpart of {@link healLoadedMessages}. Used at
|
||||
* session-load time so resumed sessions come back capped at the same
|
||||
* token budget (not char budget) as live tool results — CJK text no
|
||||
* longer slips past at 2× the intended token cost when re-hydrated.
|
||||
*
|
||||
* Still does the same structural pass for tool_calls ↔ tool pairing;
|
||||
* that logic is orthogonal to the truncation cap.
|
||||
*/
|
||||
export function healLoadedMessagesByTokens(
|
||||
messages: ChatMessage[],
|
||||
maxTokens: number,
|
||||
): {
|
||||
messages: ChatMessage[];
|
||||
healedCount: number;
|
||||
tokensSaved: number;
|
||||
charsSaved: number;
|
||||
} {
|
||||
const shrunk = shrinkOversizedToolResultsByTokens(messages, maxTokens);
|
||||
const paired = fixToolCallPairing(shrunk.messages);
|
||||
const healedCount = shrunk.healedCount + paired.droppedAssistantCalls + paired.droppedStrayTools;
|
||||
return {
|
||||
messages: paired.messages,
|
||||
healedCount,
|
||||
tokensSaved: shrunk.tokensSaved,
|
||||
charsSaved: shrunk.charsSaved,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* MCP ecosystem tool, wrapped in Reasonix's Pillar 1 + Pillar 3 benefits.
|
||||
*/
|
||||
|
||||
import { countTokens } from "../tokenizer.js";
|
||||
import { ToolRegistry } from "../tools.js";
|
||||
import type { JSONSchema } from "../types.js";
|
||||
import type { McpClient } from "./client.js";
|
||||
@@ -58,6 +59,18 @@ export interface BridgeOptions {
|
||||
*/
|
||||
export const DEFAULT_MAX_RESULT_CHARS = 32_000;
|
||||
|
||||
/**
|
||||
* Token-aware cap for tool results, in DeepSeek V3 tokens.
|
||||
*
|
||||
* 8,000 tokens ≈ 6% of DeepSeek V3's 131K context. One oversized tool
|
||||
* result can't eat more than that no matter what character density the
|
||||
* content has. The char cap (32K chars) only bounds tokens for English
|
||||
* — CJK text at 1 char/token blows past 16K tokens under the same
|
||||
* ceiling. With the tokenizer shipped in 0.5.0 we can cap the thing
|
||||
* that actually matters.
|
||||
*/
|
||||
export const DEFAULT_MAX_RESULT_TOKENS = 8_000;
|
||||
|
||||
export interface BridgeResult {
|
||||
registry: ToolRegistry;
|
||||
/** Names actually registered (may differ from MCP names when a prefix is applied). */
|
||||
@@ -159,6 +172,94 @@ export function truncateForModel(s: string, maxChars: number): string {
|
||||
return `${head}\n\n[…truncated ${dropped} chars — raise BridgeOptions.maxResultChars, or call the tool with a narrower scope (filter, head, pagination)…]\n\n${tail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token-aware truncation. Same head+tail policy as `truncateForModel`,
|
||||
* but sizes the slices against a DeepSeek V3 token budget instead of a
|
||||
* raw character count — so CJK text (which previously survived at 2×
|
||||
* the token cost per char) gets capped at the same effective context
|
||||
* footprint as English.
|
||||
*
|
||||
* Strategy: fast path when `s.length <= maxTokens` (every token is ≥1
|
||||
* char, so this bounds tokens ≤ maxTokens — skip tokenize entirely).
|
||||
* Short-ish strings are confirmed against the real token count.
|
||||
* Long strings go straight to char-sliced head+tail with one or two
|
||||
* tokenize-verify-and-shrink rounds per slice — we deliberately never
|
||||
* tokenize the full input, because pathological repetitive text
|
||||
* (megabytes of `AAAA…`) can cost 30s+ on the pure-TS BPE port.
|
||||
*/
|
||||
export function truncateForModelByTokens(s: string, maxTokens: number): string {
|
||||
if (maxTokens <= 0) return "";
|
||||
// Every token is ≥1 char — if length ≤ budget, tokens ≤ budget.
|
||||
if (s.length <= maxTokens) return s;
|
||||
// Small enough to tokenize-check without pathological cost: confirm
|
||||
// whether we're actually over budget. (Threshold is the char-bound
|
||||
// worst case for English/code — ~4 chars/token.)
|
||||
if (s.length <= maxTokens * 4) {
|
||||
const tokens = countTokens(s);
|
||||
if (tokens <= maxTokens) return s;
|
||||
}
|
||||
|
||||
const markerOverhead = 48; // rough token cost of the truncation marker
|
||||
const contentBudget = Math.max(0, maxTokens - markerOverhead);
|
||||
const tailBudget = Math.min(256, Math.floor(contentBudget * 0.1));
|
||||
const headBudget = Math.max(0, contentBudget - tailBudget);
|
||||
|
||||
const head = sizePrefixToTokens(s, headBudget);
|
||||
const tail = sizeSuffixToTokens(s, tailBudget);
|
||||
const droppedChars = s.length - head.length - tail.length;
|
||||
// Estimate dropped tokens from the per-slice char/token ratio we
|
||||
// already measured, rather than paying another full-string tokenize.
|
||||
// The marker says "~N tokens" so the ≤10% slop is visible to readers.
|
||||
const headTokens = head ? countTokens(head) : 0;
|
||||
const tailTokens = tail ? countTokens(tail) : 0;
|
||||
const sampleChars = head.length + tail.length;
|
||||
const sampleTokens = headTokens + tailTokens;
|
||||
const ratio = sampleChars > 0 ? sampleTokens / sampleChars : 0.3;
|
||||
const estTotalTokens = Math.ceil(s.length * ratio);
|
||||
const droppedTokens = Math.max(0, estTotalTokens - sampleTokens);
|
||||
return `${head}\n\n[…truncated ~${droppedTokens} tokens (${droppedChars} chars) — raise BridgeOptions.maxResultTokens, or call the tool with a narrower scope (filter, head, pagination)…]\n\n${tail}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice `s` from the start to the largest prefix that fits `budget`
|
||||
* tokens. Never tokenizes the full input: starts with a char-bound
|
||||
* estimate, then verifies and shrinks. Converges in 1–2 iterations.
|
||||
*/
|
||||
function sizePrefixToTokens(s: string, budget: number): string {
|
||||
if (budget <= 0 || s.length === 0) return "";
|
||||
// Optimistic starting size: assume ~4 chars/token (English/code
|
||||
// average). If the content is denser (CJK ~1 char/token), the first
|
||||
// tokenize will show we're over and we shrink.
|
||||
let size = Math.min(s.length, budget * 4);
|
||||
for (let iter = 0; iter < 6; iter++) {
|
||||
if (size <= 0) return "";
|
||||
const slice = s.slice(0, size);
|
||||
const count = countTokens(slice);
|
||||
if (count <= budget) return slice;
|
||||
// Shrink by the overshoot fraction plus a small safety margin.
|
||||
const next = Math.floor(size * (budget / count) * 0.95);
|
||||
if (next >= size) return s.slice(0, Math.max(0, size - 1));
|
||||
size = next;
|
||||
}
|
||||
return s.slice(0, Math.max(0, size));
|
||||
}
|
||||
|
||||
/** Slice `s` from the end to the largest suffix that fits `budget` tokens. */
|
||||
function sizeSuffixToTokens(s: string, budget: number): string {
|
||||
if (budget <= 0 || s.length === 0) return "";
|
||||
let size = Math.min(s.length, budget * 4);
|
||||
for (let iter = 0; iter < 6; iter++) {
|
||||
if (size <= 0) return "";
|
||||
const slice = s.slice(-size);
|
||||
const count = countTokens(slice);
|
||||
if (count <= budget) return slice;
|
||||
const next = Math.floor(size * (budget / count) * 0.95);
|
||||
if (next >= size) return s.slice(-Math.max(0, size - 1));
|
||||
size = next;
|
||||
}
|
||||
return s.slice(-Math.max(0, size));
|
||||
}
|
||||
|
||||
function blockToString(block: McpContentBlock): string {
|
||||
if (block.type === "text") return block.text;
|
||||
if (block.type === "image") return `[image ${block.mimeType}, ${block.data.length} chars base64]`;
|
||||
|
||||
@@ -107,6 +107,15 @@ export function deleteSession(name: string): boolean {
|
||||
const path = sessionPath(name);
|
||||
try {
|
||||
unlinkSync(path);
|
||||
// Best-effort cleanup of side-car files that belong to this session
|
||||
// so `/forget` doesn't leave orphans in `sessionsDir()`. Currently
|
||||
// just the pending-edits checkpoint (src/code/pending-edits.ts).
|
||||
const sidecar = path.replace(/\.jsonl$/, ".pending.json");
|
||||
try {
|
||||
unlinkSync(sidecar);
|
||||
} catch {
|
||||
/* no sidecar present — expected for sessions without pending edits */
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
|
||||
+15
-3
@@ -1,12 +1,24 @@
|
||||
import type { Usage } from "./client.js";
|
||||
|
||||
/** USD per 1M tokens. Update as DeepSeek pricing changes. */
|
||||
/**
|
||||
* USD per 1M tokens. Source of truth is DeepSeek's CNY price sheet at
|
||||
* https://api-docs.deepseek.com/zh-cn/quick_start/pricing (as of
|
||||
* 2026-04-23): chat and reasoner are unified at ¥0.2 / ¥2 / ¥3 per 1M
|
||||
* tokens (cache-hit input / cache-miss input / output). Converted at
|
||||
* a fixed 7.2 CNY/USD rate so stats stay stable across the daily FX
|
||||
* drift; revisit if the rate moves more than ±5%.
|
||||
*
|
||||
* Historical note: the pre-unification prices were chat $0.07/$0.27/$1.10
|
||||
* and reasoner $0.14/$0.55/$2.19. Sessions logged under those values
|
||||
* in `~/.reasonix/usage.jsonl` remain as-is (USD frozen at record time)
|
||||
* — we never retroactively rewrite billing history.
|
||||
*/
|
||||
export const DEEPSEEK_PRICING: Record<
|
||||
string,
|
||||
{ inputCacheHit: number; inputCacheMiss: number; output: number }
|
||||
> = {
|
||||
"deepseek-chat": { inputCacheHit: 0.07, inputCacheMiss: 0.27, output: 1.1 },
|
||||
"deepseek-reasoner": { inputCacheHit: 0.14, inputCacheMiss: 0.55, output: 2.19 },
|
||||
"deepseek-chat": { inputCacheHit: 0.028, inputCacheMiss: 0.28, output: 0.42 },
|
||||
"deepseek-reasoner": { inputCacheHit: 0.028, inputCacheMiss: 0.28, output: 0.42 },
|
||||
};
|
||||
|
||||
/** Reference Claude Sonnet 4.6 pricing (USD per 1M tokens). */
|
||||
|
||||
@@ -0,0 +1,375 @@
|
||||
/**
|
||||
* Pure-TS port of DeepSeek's V3 tokenizer. Reads the slimmed+gzipped
|
||||
* HuggingFace-format tokenizer data shipped at
|
||||
* `data/deepseek-tokenizer.json.gz` and implements enough of the HF
|
||||
* pipeline (Split pre-tokenizers → ByteLevel → BPE) to count tokens
|
||||
* offline without pulling the `@lenml/tokenizers` or native Rust deps.
|
||||
*
|
||||
* Accuracy target: within ~3% of the API-returned `usage.prompt_tokens`
|
||||
* for mixed CJK+English+code input. Exact-to-API match would also
|
||||
* require replaying the Jinja chat template (role markers / tool call
|
||||
* framing); we intentionally skip that — for a gauge/UI estimate the
|
||||
* raw-text count is the useful number.
|
||||
*
|
||||
* Scope: ENCODE-SIDE ONLY. We don't need decode for counting; adding
|
||||
* it later is trivial via the inverse byte-level table.
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
|
||||
interface AddedToken {
|
||||
id: number;
|
||||
content: string;
|
||||
special: boolean;
|
||||
normalized: boolean;
|
||||
}
|
||||
|
||||
interface SplitPretokenizer {
|
||||
type: "Split";
|
||||
pattern: { Regex: string };
|
||||
behavior: "Isolated" | "Removed" | string;
|
||||
invert: boolean;
|
||||
}
|
||||
|
||||
interface ByteLevelPretokenizer {
|
||||
type: "ByteLevel";
|
||||
add_prefix_space: boolean;
|
||||
trim_offsets: boolean;
|
||||
use_regex: boolean;
|
||||
}
|
||||
|
||||
type Pretokenizer = SplitPretokenizer | ByteLevelPretokenizer;
|
||||
|
||||
interface TokenizerData {
|
||||
added_tokens: AddedToken[];
|
||||
pre_tokenizer: {
|
||||
type: "Sequence";
|
||||
pretokenizers: Pretokenizer[];
|
||||
};
|
||||
model: {
|
||||
type: "BPE";
|
||||
vocab: Record<string, number>;
|
||||
merges: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface LoadedTokenizer {
|
||||
vocab: Record<string, number>;
|
||||
mergeRank: Map<string, number>;
|
||||
splitRegexes: RegExp[];
|
||||
byteToChar: string[];
|
||||
/**
|
||||
* Non-special added tokens (e.g. `<think>`, `<|fim▁hole|>`) that the
|
||||
* HF tokenizer recognizes as atomic units when they appear inline in
|
||||
* the text. Special tokens like `<|begin▁of▁sentence|>` are NOT in
|
||||
* this list — the model ignores them when they appear in user text
|
||||
* (the default HF `split_special_tokens=False` behavior), so we do
|
||||
* the same: they get tokenized byte-by-byte, not as the special ID.
|
||||
*/
|
||||
addedPattern: RegExp | null;
|
||||
addedMap: Map<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* GPT-2 byte-to-unicode mapping. Covers every byte 0..255 with a
|
||||
* deterministic visible-printable unicode char — this is what lets the
|
||||
* byte-level BPE show up in JSON vocabs as readable strings (`Ġ` for
|
||||
* space, `Ċ` for `\n`, etc). Identical across every byte-level BPE
|
||||
* tokenizer DeepSeek ships.
|
||||
*/
|
||||
function buildByteToChar(): string[] {
|
||||
const result: string[] = new Array(256);
|
||||
const bs: number[] = [];
|
||||
for (let b = 33; b <= 126; b++) bs.push(b);
|
||||
for (let b = 161; b <= 172; b++) bs.push(b);
|
||||
for (let b = 174; b <= 255; b++) bs.push(b);
|
||||
const cs = bs.slice();
|
||||
let n = 0;
|
||||
for (let b = 0; b < 256; b++) {
|
||||
if (!bs.includes(b)) {
|
||||
bs.push(b);
|
||||
cs.push(256 + n);
|
||||
n++;
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < bs.length; i++) {
|
||||
result[bs[i]!] = String.fromCodePoint(cs[i]!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
let cached: LoadedTokenizer | null = null;
|
||||
|
||||
/**
|
||||
* Find the bundled tokenizer data file. Resolution order (first hit wins):
|
||||
* 1. `REASONIX_TOKENIZER_PATH` env var (tests / custom builds).
|
||||
* 2. `../data/deepseek-tokenizer.json.gz` relative to this module —
|
||||
* covers dev (src/tokenizer.ts → data/) AND production bundled at
|
||||
* dist/index.js → dist/../data = data/.
|
||||
* 3. `../../data/deepseek-tokenizer.json.gz` — covers the CLI bundle
|
||||
* at dist/cli/index.js → dist/cli/../../data = data/. Without this
|
||||
* step 2 resolves to dist/data/ which doesn't exist (the data file
|
||||
* is shipped at package root per package.json `files` entry), and
|
||||
* every step() preflight crashes the loop with ENOENT.
|
||||
* 4. `require.resolve("reasonix/package.json")` — last-resort package
|
||||
* root lookup for unusual bundlers.
|
||||
*/
|
||||
function resolveDataPath(): string {
|
||||
if (process.env.REASONIX_TOKENIZER_PATH) return process.env.REASONIX_TOKENIZER_PATH;
|
||||
const candidates: string[] = [];
|
||||
try {
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
candidates.push(join(here, "..", "data", "deepseek-tokenizer.json.gz"));
|
||||
candidates.push(join(here, "..", "..", "data", "deepseek-tokenizer.json.gz"));
|
||||
} catch {
|
||||
/* import.meta.url unavailable — skip to the package resolution step. */
|
||||
}
|
||||
try {
|
||||
const req = createRequire(import.meta.url);
|
||||
candidates.push(
|
||||
join(dirname(req.resolve("reasonix/package.json")), "data", "deepseek-tokenizer.json.gz"),
|
||||
);
|
||||
} catch {
|
||||
/* Not installed as `reasonix/` — the earlier candidates still may hit. */
|
||||
}
|
||||
for (const p of candidates) {
|
||||
if (existsSync(p)) return p;
|
||||
}
|
||||
// Nothing exists — return the first candidate anyway so readFileSync
|
||||
// surfaces a concrete path in the ENOENT message (better than silent miss).
|
||||
return candidates[0] ?? join(process.cwd(), "data", "deepseek-tokenizer.json.gz");
|
||||
}
|
||||
|
||||
function loadTokenizer(): LoadedTokenizer {
|
||||
if (cached) return cached;
|
||||
const buf = readFileSync(resolveDataPath());
|
||||
const json = gunzipSync(buf).toString("utf8");
|
||||
const data = JSON.parse(json) as TokenizerData;
|
||||
|
||||
const mergeRank = new Map<string, number>();
|
||||
for (let i = 0; i < data.model.merges.length; i++) {
|
||||
mergeRank.set(data.model.merges[i]!, i);
|
||||
}
|
||||
|
||||
const splitRegexes: RegExp[] = [];
|
||||
for (const p of data.pre_tokenizer.pretokenizers) {
|
||||
if (p.type === "Split") {
|
||||
// All three Split rules use Isolated — matches become their own
|
||||
// pre-tokens and so do the in-between stretches. The ByteLevel
|
||||
// stage in the Sequence does no extra splitting here
|
||||
// (use_regex:false), so our 3 Split regexes are the whole story.
|
||||
splitRegexes.push(new RegExp(p.pattern.Regex, "gu"));
|
||||
}
|
||||
}
|
||||
|
||||
const addedMap = new Map<string, number>();
|
||||
const addedContents: string[] = [];
|
||||
for (const t of data.added_tokens) {
|
||||
if (!t.special) {
|
||||
addedMap.set(t.content, t.id);
|
||||
addedContents.push(t.content);
|
||||
}
|
||||
}
|
||||
// Longest-first ensures greedy matching doesn't lose a longer token
|
||||
// to a shorter prefix (e.g. `<think>` before `<`).
|
||||
addedContents.sort((a, b) => b.length - a.length);
|
||||
const addedPattern = addedContents.length
|
||||
? new RegExp(addedContents.map(escapeRegex).join("|"), "g")
|
||||
: null;
|
||||
|
||||
cached = {
|
||||
vocab: data.model.vocab,
|
||||
mergeRank,
|
||||
splitRegexes,
|
||||
byteToChar: buildByteToChar(),
|
||||
addedPattern,
|
||||
addedMap,
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one "Isolated" Split: the matches become their own pre-tokens,
|
||||
* stretches between matches pass through unchanged. Empty pre-tokens
|
||||
* are dropped so downstream stages don't see them.
|
||||
*/
|
||||
function applySplit(chunks: string[], re: RegExp): string[] {
|
||||
const out: string[] = [];
|
||||
for (const chunk of chunks) {
|
||||
if (!chunk) continue;
|
||||
// Reset lastIndex — reusing a /g regex across matchAll iterations
|
||||
// is safe (matchAll internally advances), but across different
|
||||
// input strings we want a clean start.
|
||||
re.lastIndex = 0;
|
||||
let last = 0;
|
||||
for (const m of chunk.matchAll(re)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) out.push(chunk.slice(last, idx));
|
||||
if (m[0].length > 0) out.push(m[0]);
|
||||
last = idx + m[0].length;
|
||||
}
|
||||
if (last < chunk.length) out.push(chunk.slice(last));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** UTF-8 bytes of `s`, each mapped to its byte-level visible char. */
|
||||
function byteLevelEncode(s: string, byteToChar: string[]): string {
|
||||
const bytes = new TextEncoder().encode(s);
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i++) out += byteToChar[bytes[i]!];
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard BPE merge loop. Starts from single byte-level chars and
|
||||
* repeatedly applies the lowest-rank merge until none remain. O(n²)
|
||||
* in chunk length, which is fine because chunks are ≤ a few hundred
|
||||
* byte-level chars after pre-tokenization (and most are <50).
|
||||
*/
|
||||
function bpeEncode(piece: string, mergeRank: Map<string, number>): string[] {
|
||||
if (piece.length <= 1) return piece ? [piece] : [];
|
||||
let word: string[] = Array.from(piece);
|
||||
while (true) {
|
||||
let bestIdx = -1;
|
||||
let bestRank = Number.POSITIVE_INFINITY;
|
||||
for (let i = 0; i < word.length - 1; i++) {
|
||||
const pair = `${word[i]} ${word[i + 1]}`;
|
||||
const rank = mergeRank.get(pair);
|
||||
if (rank !== undefined && rank < bestRank) {
|
||||
bestRank = rank;
|
||||
bestIdx = i;
|
||||
if (rank === 0) break; // 0 is already the best possible
|
||||
}
|
||||
}
|
||||
if (bestIdx < 0) break;
|
||||
word = [
|
||||
...word.slice(0, bestIdx),
|
||||
word[bestIdx]! + word[bestIdx + 1]!,
|
||||
...word.slice(bestIdx + 2),
|
||||
];
|
||||
if (word.length === 1) break;
|
||||
}
|
||||
return word;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenize a UTF-8 string into DeepSeek token IDs. Mirrors the HF
|
||||
* pipeline: (1) isolate non-special added tokens so they stay atomic,
|
||||
* (2) for each in-between segment run the three Split regexes, (3)
|
||||
* byte-level encode, (4) BPE merge, (5) look up vocab IDs.
|
||||
*
|
||||
* Not reentrancy-hazardous: loads a module-level singleton on first
|
||||
* call and reuses it. Subsequent calls are pure compute.
|
||||
*/
|
||||
export function encode(text: string): number[] {
|
||||
if (!text) return [];
|
||||
const t = loadTokenizer();
|
||||
const ids: number[] = [];
|
||||
|
||||
const process = (segment: string) => {
|
||||
if (!segment) return;
|
||||
let chunks: string[] = [segment];
|
||||
for (const re of t.splitRegexes) chunks = applySplit(chunks, re);
|
||||
for (const chunk of chunks) {
|
||||
if (!chunk) continue;
|
||||
const byteLevel = byteLevelEncode(chunk, t.byteToChar);
|
||||
const pieces = bpeEncode(byteLevel, t.mergeRank);
|
||||
for (const p of pieces) {
|
||||
const id = t.vocab[p];
|
||||
// If not in vocab we silently skip: shouldn't happen for
|
||||
// byte-level BPE (every single byte has its own vocab entry),
|
||||
// but if a future tokenizer update breaks that invariant we'd
|
||||
// rather under-count than throw from a UI gauge.
|
||||
if (id !== undefined) ids.push(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (t.addedPattern) {
|
||||
t.addedPattern.lastIndex = 0;
|
||||
let last = 0;
|
||||
for (const m of text.matchAll(t.addedPattern)) {
|
||||
const idx = m.index ?? 0;
|
||||
if (idx > last) process(text.slice(last, idx));
|
||||
const id = t.addedMap.get(m[0]);
|
||||
if (id !== undefined) ids.push(id);
|
||||
last = idx + m[0].length;
|
||||
}
|
||||
if (last < text.length) process(text.slice(last));
|
||||
} else {
|
||||
process(text);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fast path for UI: we only need the count, not the IDs. Saves a
|
||||
* per-call array allocation and lets callers batch large logs
|
||||
* without holding the full ID stream.
|
||||
*/
|
||||
export function countTokens(text: string): number {
|
||||
return encode(text).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the tokens a full conversation would cost. Sums raw-text
|
||||
* counts for every message's content; we do NOT add overhead for the
|
||||
* chat template's role markers (`<|User|>`, `<|Assistant|>`) because
|
||||
* the exact framing varies between the chat/reasoner templates and
|
||||
* DeepSeek applies them server-side anyway. Empirically adds ~3-6%
|
||||
* to the real `prompt_tokens` — callers who care can post-multiply.
|
||||
*/
|
||||
export function estimateConversationTokens(
|
||||
messages: Array<{ content?: string | null; tool_calls?: unknown }>,
|
||||
): number {
|
||||
let total = 0;
|
||||
for (const m of messages) {
|
||||
if (typeof m.content === "string" && m.content) {
|
||||
total += countTokens(m.content);
|
||||
}
|
||||
// Tool-call arguments are serialized as JSON in the prompt by the
|
||||
// chat template; their bytes WILL count upstream, so we count
|
||||
// them too. Stringify-once is cheap relative to the tokenize.
|
||||
if (m.tool_calls && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
|
||||
total += countTokens(JSON.stringify(m.tool_calls));
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate the tokens a full DeepSeek request would cost: the
|
||||
* conversation-side tokens (what `estimateConversationTokens` already
|
||||
* counts) PLUS the serialized tool-spec payload. Tool specs ride in
|
||||
* their own JSON blob in the request body, not folded into any
|
||||
* message's `content`, so they need a separate count to land an
|
||||
* accurate preflight estimate.
|
||||
*
|
||||
* Returned number matches what `/context` displays for "next request"
|
||||
* — reuse this helper anywhere (preflight guard, diagnostics, UI) to
|
||||
* keep the two values from drifting.
|
||||
*/
|
||||
export function estimateRequestTokens(
|
||||
messages: Array<{ content?: string | null; tool_calls?: unknown }>,
|
||||
toolSpecs?: ReadonlyArray<unknown> | null,
|
||||
): number {
|
||||
let total = estimateConversationTokens(messages);
|
||||
if (toolSpecs && toolSpecs.length > 0) {
|
||||
total += countTokens(JSON.stringify(toolSpecs));
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Exposed for tests — resets the lazy-load singleton. */
|
||||
export function _resetForTests(): void {
|
||||
cached = null;
|
||||
}
|
||||
+20
-2
@@ -1,3 +1,4 @@
|
||||
import { truncateForModel, truncateForModelByTokens } from "./mcp/registry.js";
|
||||
import { analyzeSchema, flattenSchema, nestArguments } from "./repair/flatten.js";
|
||||
import type { JSONSchema, ToolSpec } from "./types.js";
|
||||
|
||||
@@ -128,7 +129,7 @@ export class ToolRegistry {
|
||||
async dispatch(
|
||||
name: string,
|
||||
argumentsRaw: string | Record<string, unknown>,
|
||||
opts: { signal?: AbortSignal } = {},
|
||||
opts: { signal?: AbortSignal; maxResultChars?: number; maxResultTokens?: number } = {},
|
||||
): Promise<string> {
|
||||
const tool = this._tools.get(name);
|
||||
if (!tool) {
|
||||
@@ -168,7 +169,24 @@ export class ToolRegistry {
|
||||
|
||||
try {
|
||||
const result = await tool.fn(args, { signal: opts.signal });
|
||||
return typeof result === "string" ? result : JSON.stringify(result);
|
||||
const str = typeof result === "string" ? result : JSON.stringify(result);
|
||||
// Pre-clip at dispatch so a single fat result can't balloon the
|
||||
// log (and disk session file) on its way in. Healing at load time
|
||||
// still catches pre-existing oversize entries; this closes the
|
||||
// door on new ones.
|
||||
//
|
||||
// Two caps available: `maxResultTokens` (preferred — bounds the
|
||||
// real context footprint, so CJK doesn't slip past at 2× density)
|
||||
// and `maxResultChars` (legacy). If both are set, apply both and
|
||||
// the tighter one wins; char-only callers keep their old behavior.
|
||||
let clipped = str;
|
||||
if (opts.maxResultTokens !== undefined) {
|
||||
clipped = truncateForModelByTokens(clipped, opts.maxResultTokens);
|
||||
}
|
||||
if (opts.maxResultChars !== undefined) {
|
||||
clipped = truncateForModel(clipped, opts.maxResultChars);
|
||||
}
|
||||
return clipped;
|
||||
} catch (err) {
|
||||
const e = err as Error & { toToolResult?: () => unknown };
|
||||
// Errors may opt into a richer tool-result shape by implementing
|
||||
|
||||
+72
-1
@@ -171,6 +171,71 @@ export function tokenizeCommand(cmd: string): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan `cmd` for a shell operator (`|`, `||`, `>`, `>>`, `<`, `<<`,
|
||||
* `&`, `&&`, `2>`, `2>>`, `2>&1`, `&>`) that appears unquoted at a
|
||||
* token boundary. Returns the operator string, or null if none.
|
||||
*
|
||||
* Why this exists: `run_command` documents "no shell expansion, no
|
||||
* pipes, no redirects" (the tool spawns argv directly, not through a
|
||||
* shell), but when the model writes `dir | findstr foo` the `|`
|
||||
* survives tokenization as a standalone token and gets quoted as the
|
||||
* literal string `"|"` by `quoteForCmdExe` — cmd.exe sees it as an
|
||||
* argument, not an operator, so the pipe silently fails. Detecting
|
||||
* operators up front lets us throw a clear error ("split into separate
|
||||
* calls") instead of letting the command run with surprising results.
|
||||
*
|
||||
* Quoted operators (`grep "a|b"`) and operator characters embedded in
|
||||
* larger tokens (`--flag=1&2`) are NOT flagged — those are literal
|
||||
* argv bytes and are safe to pass through.
|
||||
*
|
||||
* Exported for testing.
|
||||
*/
|
||||
export function detectShellOperator(cmd: string): string | null {
|
||||
const opPrefix = /^(?:2>&1|&>|\|{1,2}|&{1,2}|2>{1,2}|>{1,2}|<{1,2})/;
|
||||
let cur = "";
|
||||
let curQuoted = false;
|
||||
let quote: '"' | "'" | null = null;
|
||||
const check = (): string | null => {
|
||||
if (cur.length === 0 && !curQuoted) return null;
|
||||
if (!curQuoted) {
|
||||
const m = opPrefix.exec(cur);
|
||||
if (m) return m[0] ?? null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (let i = 0; i < cmd.length; i++) {
|
||||
const ch = cmd[i]!;
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else if (ch === "\\" && quote === '"' && i + 1 < cmd.length) {
|
||||
cur += cmd[++i];
|
||||
curQuoted = true;
|
||||
} else {
|
||||
cur += ch;
|
||||
curQuoted = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
curQuoted = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === " " || ch === "\t") {
|
||||
const op = check();
|
||||
if (op) return op;
|
||||
cur = "";
|
||||
curQuoted = false;
|
||||
continue;
|
||||
}
|
||||
cur += ch;
|
||||
}
|
||||
if (quote) return null; // let tokenizeCommand throw the unclosed-quote error
|
||||
return check();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true when `cmd` matches an allowlisted prefix. Exported for
|
||||
* testing. Match is on the space-normalized leading tokens so
|
||||
@@ -205,6 +270,12 @@ export async function runCommand(
|
||||
): Promise<RunCommandResult> {
|
||||
const argv = tokenizeCommand(cmd);
|
||||
if (argv.length === 0) throw new Error("run_command: empty command");
|
||||
const operator = detectShellOperator(cmd);
|
||||
if (operator !== null) {
|
||||
throw new Error(
|
||||
`run_command: shell operator "${operator}" is not supported — this tool spawns one process, no shell expansion. Split into separate run_command calls and combine the output in your reasoning (e.g. instead of \`grep foo *.ts | wc -l\`, call \`grep -c foo *.ts\` or two separate commands). To pass "${operator}" as a literal argument, wrap it in quotes.`,
|
||||
);
|
||||
}
|
||||
const timeoutMs = (opts.timeoutSec ?? DEFAULT_TIMEOUT_SEC) * 1000;
|
||||
const maxChars = opts.maxOutputChars ?? DEFAULT_MAX_OUTPUT_CHARS;
|
||||
|
||||
@@ -538,7 +609,7 @@ export function registerShellTools(registry: ToolRegistry, opts: ShellToolsOptio
|
||||
command: {
|
||||
type: "string",
|
||||
description:
|
||||
"Full command line. Tokenized with POSIX-ish quoting; no shell expansion, no pipes, no redirects.",
|
||||
'Full command line. Tokenized with POSIX-ish quoting; no shell expansion. Pipes (`|`), redirects (`>`, `<`, `2>`), and `&&`/`||` chaining are rejected with an error — split into separate calls instead. To pass an operator character as a literal argument (e.g. a regex), wrap it in quotes: `grep "a|b" file.txt`.',
|
||||
},
|
||||
timeoutSec: {
|
||||
type: "integer",
|
||||
|
||||
+51
-1
@@ -41,7 +41,7 @@
|
||||
* Cancellation still works via the AbortSignal.
|
||||
*/
|
||||
|
||||
import type { DeepSeekClient } from "../client.js";
|
||||
import { type DeepSeekClient, Usage } from "../client.js";
|
||||
import { CacheFirstLoop } from "../loop.js";
|
||||
import { ImmutablePrefix } from "../memory.js";
|
||||
import { applyProjectMemory } from "../project-memory.js";
|
||||
@@ -58,6 +58,10 @@ export interface SubagentEvent {
|
||||
kind: "start" | "progress" | "end";
|
||||
/** First ~30 chars of the task prompt — used for the TUI status row. */
|
||||
task: string;
|
||||
/** Skill that spawned this subagent, when applicable. Stamped on every event so the TUI/logger can attribute without extra plumbing. */
|
||||
skillName?: string;
|
||||
/** Model id the child loop ran on. Stamped alongside skillName. */
|
||||
model?: string;
|
||||
/** Iteration count inside the child loop (number of tool results so far). */
|
||||
iter?: number;
|
||||
/** Wall-clock ms since the subagent started. */
|
||||
@@ -68,6 +72,10 @@ export interface SubagentEvent {
|
||||
error?: string;
|
||||
/** Total turns the subagent took. Set on `end`. */
|
||||
turns?: number;
|
||||
/** Total USD spent inside the child loop. Set on `end`. */
|
||||
costUsd?: number;
|
||||
/** Aggregated child-loop Usage (sum across turns). Set on `end`. */
|
||||
usage?: Usage;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +127,12 @@ export interface SpawnSubagentOptions {
|
||||
* that don't care about cancellation.
|
||||
*/
|
||||
parentSignal?: AbortSignal;
|
||||
/**
|
||||
* Skill name when this spawn is driven by a `runAs: subagent` skill.
|
||||
* Used purely for downstream attribution (TUI summary line, usage log).
|
||||
* Omit for raw `spawn_subagent` tool calls.
|
||||
*/
|
||||
skillName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,6 +154,12 @@ export interface SubagentResult {
|
||||
elapsedMs: number;
|
||||
/** USD spent in the child loop, summed across its turns. */
|
||||
costUsd: number;
|
||||
/** Model id the child loop ran on. */
|
||||
model: string;
|
||||
/** Skill name if the spawn was driven by a `runAs: subagent` skill. */
|
||||
skillName?: string;
|
||||
/** Aggregated Usage across the child loop's turns. Zero-filled when no API calls landed. */
|
||||
usage: Usage;
|
||||
}
|
||||
|
||||
export interface SubagentToolOptions {
|
||||
@@ -207,12 +227,15 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
const maxToolIters = opts.maxToolIters ?? DEFAULT_MAX_ITERS;
|
||||
const maxResultChars = opts.maxResultChars ?? DEFAULT_MAX_RESULT_CHARS;
|
||||
const sink = opts.sink;
|
||||
const skillName = opts.skillName;
|
||||
|
||||
const startedAt = Date.now();
|
||||
const taskPreview = opts.task.length > 30 ? `${opts.task.slice(0, 30)}…` : opts.task;
|
||||
sink?.current?.({
|
||||
kind: "start",
|
||||
task: taskPreview,
|
||||
skillName,
|
||||
model,
|
||||
iter: 0,
|
||||
elapsedMs: 0,
|
||||
});
|
||||
@@ -245,6 +268,8 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
sink?.current?.({
|
||||
kind: "progress",
|
||||
task: taskPreview,
|
||||
skillName,
|
||||
model,
|
||||
iter: toolIter,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
});
|
||||
@@ -276,6 +301,7 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const turns = childLoop.stats.turns.length;
|
||||
const costUsd = childLoop.stats.totalCost;
|
||||
const usage = aggregateChildUsage(childLoop);
|
||||
|
||||
const truncated =
|
||||
final.length > maxResultChars
|
||||
@@ -285,11 +311,15 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
sink?.current?.({
|
||||
kind: "end",
|
||||
task: taskPreview,
|
||||
skillName,
|
||||
model,
|
||||
iter: toolIter,
|
||||
elapsedMs,
|
||||
summary: errorMessage ? undefined : truncated.slice(0, 120),
|
||||
error: errorMessage,
|
||||
turns,
|
||||
costUsd,
|
||||
usage,
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -300,9 +330,29 @@ export async function spawnSubagent(opts: SpawnSubagentOptions): Promise<Subagen
|
||||
toolIters: toolIter,
|
||||
elapsedMs,
|
||||
costUsd,
|
||||
model,
|
||||
skillName,
|
||||
usage,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum the child loop's per-turn `Usage` into one aggregate record.
|
||||
* Zero-filled when the loop made no API calls (e.g. failed before the
|
||||
* first request) so downstream consumers always see a valid shape.
|
||||
*/
|
||||
function aggregateChildUsage(loop: CacheFirstLoop): Usage {
|
||||
const agg = new Usage();
|
||||
for (const t of loop.stats.turns) {
|
||||
agg.promptTokens += t.usage.promptTokens;
|
||||
agg.completionTokens += t.usage.completionTokens;
|
||||
agg.totalTokens += t.usage.totalTokens;
|
||||
agg.promptCacheHitTokens += t.usage.promptCacheHitTokens;
|
||||
agg.promptCacheMissTokens += t.usage.promptCacheMissTokens;
|
||||
}
|
||||
return agg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a {@link SubagentResult} into the JSON tool-result shape
|
||||
* the model consumes. Both the spawn_subagent tool and the run_skill
|
||||
|
||||
@@ -36,6 +36,14 @@ export interface ChatMessage {
|
||||
name?: string;
|
||||
tool_call_id?: string;
|
||||
tool_calls?: ToolCall[];
|
||||
/**
|
||||
* R1 `reasoning_content` captured from the assistant's thinking turn.
|
||||
* DeepSeek's thinking mode 400s with "reasoning_content in the
|
||||
* thinking mode must be passed back" when a tool-loop continuation
|
||||
* omits it from the preceding assistant message. Round-tripped for
|
||||
* deepseek-reasoner turns with tool_calls; absent for deepseek-chat.
|
||||
*/
|
||||
reasoning_content?: string | null;
|
||||
}
|
||||
|
||||
export interface RawUsage {
|
||||
|
||||
@@ -52,6 +52,22 @@ export interface UsageRecord {
|
||||
costUsd: number;
|
||||
/** What the same turn would have cost at Claude Sonnet 4.6 rates. */
|
||||
claudeEquivUsd: number;
|
||||
/**
|
||||
* Distinguishes ordinary parent-loop turns from subagent summary rows.
|
||||
* Absent on pre-0.5.14 records — treat as "turn" when missing.
|
||||
*/
|
||||
kind?: "turn" | "subagent";
|
||||
/** Present when `kind === "subagent"`. Attribution metadata for the /stats roll-up. */
|
||||
subagent?: {
|
||||
/** Skill that spawned it, when the spawn came from a `runAs: subagent` skill. */
|
||||
skillName?: string;
|
||||
/** First ~60 chars of the task prompt — enough context to recognize a run, never the full text. */
|
||||
taskPreview: string;
|
||||
/** Tool calls the child loop dispatched before returning. */
|
||||
toolIters: number;
|
||||
/** Wall-clock ms. */
|
||||
durationMs: number;
|
||||
};
|
||||
}
|
||||
|
||||
/** Where the log lives. Tests override via `opts.path`. */
|
||||
@@ -67,6 +83,9 @@ export interface AppendUsageInput {
|
||||
now?: number;
|
||||
/** Override the log path (tests). */
|
||||
path?: string;
|
||||
/** When appending a subagent summary row, set `kind: "subagent"` and populate `subagent`. */
|
||||
kind?: "turn" | "subagent";
|
||||
subagent?: UsageRecord["subagent"];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,6 +108,8 @@ export function appendUsage(input: AppendUsageInput): UsageRecord {
|
||||
costUsd: costUsd(input.model, input.usage),
|
||||
claudeEquivUsd: claudeEquivalentCost(input.usage),
|
||||
};
|
||||
if (input.kind === "subagent") record.kind = "subagent";
|
||||
if (input.subagent) record.subagent = input.subagent;
|
||||
|
||||
const path = input.path ?? defaultUsageLogPath();
|
||||
try {
|
||||
@@ -206,6 +227,21 @@ export interface UsageAggregate {
|
||||
firstSeen: number | null;
|
||||
/** Latest record's ts, or `null` when the log is empty. */
|
||||
lastSeen: number | null;
|
||||
/**
|
||||
* Subagent-specific rollup. Undefined when no subagent records exist
|
||||
* in the log so consumers can cheaply skip the section. Counts reflect
|
||||
* subagent SPAWNS (not internal child-loop turns) — one row per run.
|
||||
*/
|
||||
subagents?: SubagentAggregate;
|
||||
}
|
||||
|
||||
/** Rolled-up view of all `kind: "subagent"` records. */
|
||||
export interface SubagentAggregate {
|
||||
total: number;
|
||||
costUsd: number;
|
||||
totalDurationMs: number;
|
||||
/** Per-skill breakdown. Records without `skillName` (raw spawn_subagent calls) group under `"(adhoc)"`. */
|
||||
bySkill: Array<{ skillName: string; count: number; costUsd: number; durationMs: number }>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,6 +268,10 @@ export function aggregateUsage(
|
||||
const sessionCounts = new Map<string, number>();
|
||||
let firstSeen: number | null = null;
|
||||
let lastSeen: number | null = null;
|
||||
const skillCounts = new Map<string, { count: number; costUsd: number; durationMs: number }>();
|
||||
let subagentTotal = 0;
|
||||
let subagentCost = 0;
|
||||
let subagentDuration = 0;
|
||||
|
||||
for (const r of records) {
|
||||
addToBucket(all, r);
|
||||
@@ -245,6 +285,19 @@ export function aggregateUsage(
|
||||
|
||||
if (firstSeen === null || r.ts < firstSeen) firstSeen = r.ts;
|
||||
if (lastSeen === null || r.ts > lastSeen) lastSeen = r.ts;
|
||||
|
||||
if (r.kind === "subagent") {
|
||||
subagentTotal += 1;
|
||||
subagentCost += r.costUsd;
|
||||
const dur = r.subagent?.durationMs ?? 0;
|
||||
subagentDuration += dur;
|
||||
const key = r.subagent?.skillName?.trim() || "(adhoc)";
|
||||
const prev = skillCounts.get(key) ?? { count: 0, costUsd: 0, durationMs: 0 };
|
||||
prev.count += 1;
|
||||
prev.costUsd += r.costUsd;
|
||||
prev.durationMs += dur;
|
||||
skillCounts.set(key, prev);
|
||||
}
|
||||
}
|
||||
|
||||
const byModel = Array.from(modelCounts.entries())
|
||||
@@ -254,12 +307,25 @@ export function aggregateUsage(
|
||||
.map(([session, turns]) => ({ session, turns }))
|
||||
.sort((a, b) => b.turns - a.turns);
|
||||
|
||||
const subagents: SubagentAggregate | undefined =
|
||||
subagentTotal > 0
|
||||
? {
|
||||
total: subagentTotal,
|
||||
costUsd: subagentCost,
|
||||
totalDurationMs: subagentDuration,
|
||||
bySkill: Array.from(skillCounts.entries())
|
||||
.map(([skillName, v]) => ({ skillName, ...v }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
buckets: [today, week, month, all],
|
||||
byModel,
|
||||
bySession,
|
||||
firstSeen,
|
||||
lastSeen,
|
||||
subagents,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AT_MENTION_PATTERN,
|
||||
AT_PICKER_PREFIX,
|
||||
DEFAULT_AT_MENTION_MAX_BYTES,
|
||||
DEFAULT_PICKER_IGNORE_DIRS,
|
||||
detectAtPicker,
|
||||
expandAtMentions,
|
||||
listFilesSync,
|
||||
rankPickerCandidates,
|
||||
} from "../src/at-mentions.js";
|
||||
|
||||
describe("AT_MENTION_PATTERN", () => {
|
||||
it("matches @path at start of string", () => {
|
||||
const matches = [...".".matchAll(AT_MENTION_PATTERN)];
|
||||
expect(matches).toHaveLength(0);
|
||||
const m2 = [..."@src/loop.ts".matchAll(AT_MENTION_PATTERN)];
|
||||
expect(m2).toHaveLength(1);
|
||||
expect(m2[0]![1]).toBe("src/loop.ts");
|
||||
});
|
||||
|
||||
it("matches @path after whitespace", () => {
|
||||
const m = [..."look at @src/loop.ts please".matchAll(AT_MENTION_PATTERN)];
|
||||
expect(m).toHaveLength(1);
|
||||
expect(m[0]![1]).toBe("src/loop.ts");
|
||||
});
|
||||
|
||||
it("does NOT match @ embedded in a word (email, social handle)", () => {
|
||||
const m1 = [..."email [email protected]".matchAll(AT_MENTION_PATTERN)];
|
||||
expect(m1).toHaveLength(0);
|
||||
const m2 = [..."foo@bar".matchAll(AT_MENTION_PATTERN)];
|
||||
expect(m2).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("matches multiple @paths in one string", () => {
|
||||
const m = [..."compare @a.ts and @b.ts".matchAll(AT_MENTION_PATTERN)];
|
||||
expect(m).toHaveLength(2);
|
||||
expect(m[0]![1]).toBe("a.ts");
|
||||
expect(m[1]![1]).toBe("b.ts");
|
||||
});
|
||||
});
|
||||
|
||||
describe("expandAtMentions", () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "reasonix-at-mentions-"));
|
||||
mkdirSync(join(root, "src"), { recursive: true });
|
||||
writeFileSync(join(root, "src", "loop.ts"), "export const x = 1;\n");
|
||||
writeFileSync(join(root, "notes.md"), "# Notes\nhello\n");
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns text unchanged when there are no mentions", () => {
|
||||
const r = expandAtMentions("plain prompt with no mentions", root);
|
||||
expect(r.text).toBe("plain prompt with no mentions");
|
||||
expect(r.expansions).toEqual([]);
|
||||
});
|
||||
|
||||
it("inlines an existing file under a `Referenced files` block", () => {
|
||||
const r = expandAtMentions("look at @src/loop.ts", root);
|
||||
expect(r.expansions).toHaveLength(1);
|
||||
expect(r.expansions[0]!.ok).toBe(true);
|
||||
expect(r.expansions[0]!.path).toBe("src/loop.ts");
|
||||
expect(r.text).toContain("look at @src/loop.ts");
|
||||
expect(r.text).toContain("[Referenced files]");
|
||||
expect(r.text).toContain('<file path="src/loop.ts">');
|
||||
expect(r.text).toContain("export const x = 1;");
|
||||
expect(r.text).toContain("</file>");
|
||||
});
|
||||
|
||||
it("de-duplicates repeated mentions of the same file", () => {
|
||||
const r = expandAtMentions("compare @src/loop.ts with @src/loop.ts", root);
|
||||
expect(r.expansions).toHaveLength(1);
|
||||
// Only one file block in the output.
|
||||
const fileBlocks = r.text.match(/<file path="/g) ?? [];
|
||||
expect(fileBlocks).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("expands multiple different files in the same prompt", () => {
|
||||
const r = expandAtMentions("read @src/loop.ts and @notes.md", root);
|
||||
expect(r.expansions).toHaveLength(2);
|
||||
expect(r.expansions.every((ex) => ex.ok)).toBe(true);
|
||||
expect(r.text).toContain('<file path="src/loop.ts">');
|
||||
expect(r.text).toContain('<file path="notes.md">');
|
||||
});
|
||||
|
||||
it("marks missing files as skipped with a reason", () => {
|
||||
const r = expandAtMentions("look at @src/does-not-exist.ts", root);
|
||||
expect(r.expansions).toHaveLength(1);
|
||||
expect(r.expansions[0]!.ok).toBe(false);
|
||||
expect(r.expansions[0]!.skip).toBe("missing");
|
||||
expect(r.text).toContain('skipped="missing"');
|
||||
});
|
||||
|
||||
it("rejects paths that escape the root directory", () => {
|
||||
const r = expandAtMentions("peek at @../../../etc/passwd", root);
|
||||
expect(r.expansions).toHaveLength(1);
|
||||
expect(r.expansions[0]!.skip).toBe("escape");
|
||||
expect(r.text).not.toContain("passwd content");
|
||||
});
|
||||
|
||||
it("rejects absolute paths", () => {
|
||||
const r = expandAtMentions("look at @/etc/hosts", root);
|
||||
expect(r.expansions).toHaveLength(1);
|
||||
expect(r.expansions[0]!.skip).toBe("escape");
|
||||
});
|
||||
|
||||
it("skips files larger than maxBytes", () => {
|
||||
const big = join(root, "big.log");
|
||||
writeFileSync(big, "x".repeat(1000));
|
||||
const r = expandAtMentions("inspect @big.log", root, { maxBytes: 500 });
|
||||
expect(r.expansions).toHaveLength(1);
|
||||
expect(r.expansions[0]!.ok).toBe(false);
|
||||
expect(r.expansions[0]!.skip).toBe("too-large");
|
||||
expect(r.expansions[0]!.bytes).toBe(1000);
|
||||
expect(r.text).toContain('skipped="too-large"');
|
||||
});
|
||||
|
||||
it("strips a trailing sentence-terminator dot from the path", () => {
|
||||
// `@src/loop.ts.` — the trailing `.` is a sentence period, not
|
||||
// part of the filename. The mention should resolve src/loop.ts.
|
||||
const r = expandAtMentions("look at @src/loop.ts.", root);
|
||||
expect(r.expansions).toHaveLength(1);
|
||||
expect(r.expansions[0]!.ok).toBe(true);
|
||||
expect(r.expansions[0]!.path).toBe("src/loop.ts");
|
||||
});
|
||||
|
||||
it("default max bytes is 64KB", () => {
|
||||
expect(DEFAULT_AT_MENTION_MAX_BYTES).toBe(64 * 1024);
|
||||
});
|
||||
|
||||
it("marks a directory mention as not-file", () => {
|
||||
const r = expandAtMentions("look at @src", root);
|
||||
expect(r.expansions).toHaveLength(1);
|
||||
expect(r.expansions[0]!.ok).toBe(false);
|
||||
expect(r.expansions[0]!.skip).toBe("not-file");
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectAtPicker", () => {
|
||||
it("fires when the buffer ends with `@`", () => {
|
||||
const r = detectAtPicker("look at @");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.query).toBe("");
|
||||
// `@` is at offset 8 (after "look at ").
|
||||
expect(r!.atOffset).toBe(8);
|
||||
});
|
||||
|
||||
it("captures the partial query after `@`", () => {
|
||||
const r = detectAtPicker("edit @src/lo");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.query).toBe("src/lo");
|
||||
expect(r!.atOffset).toBe(5);
|
||||
});
|
||||
|
||||
it("does NOT fire when @ is embedded in a word", () => {
|
||||
expect(detectAtPicker("[email protected]")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT fire when the buffer ends with a space after the mention", () => {
|
||||
// Trailing space closes the picker — the user's done picking.
|
||||
expect(detectAtPicker("@src/loop.ts ")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT fire when there's no @ at all", () => {
|
||||
expect(detectAtPicker("just a normal message")).toBeNull();
|
||||
});
|
||||
|
||||
it("fires at start of string", () => {
|
||||
const r = detectAtPicker("@sr");
|
||||
expect(r).not.toBeNull();
|
||||
expect(r!.query).toBe("sr");
|
||||
expect(r!.atOffset).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AT_PICKER_PREFIX vs AT_MENTION_PATTERN (sanity)", () => {
|
||||
it("picker captures empty partial", () => {
|
||||
const m = AT_PICKER_PREFIX.exec("hi @");
|
||||
expect(m).not.toBeNull();
|
||||
expect(m![1]).toBe("");
|
||||
});
|
||||
|
||||
it("expansion pattern requires a non-empty path", () => {
|
||||
// Completed mentions for expandAtMentions need at least one char.
|
||||
const m = [..."hi @".matchAll(AT_MENTION_PATTERN)];
|
||||
expect(m).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rankPickerCandidates", () => {
|
||||
const files = [
|
||||
"src/loop.ts",
|
||||
"src/at-mentions.ts",
|
||||
"src/tokenizer.ts",
|
||||
"src/cli/ui/App.tsx",
|
||||
"src/cli/ui/PromptInput.tsx",
|
||||
"tests/loop.test.ts",
|
||||
"tests/at-mentions.test.ts",
|
||||
"README.md",
|
||||
];
|
||||
|
||||
it("returns the first `limit` entries when query is empty", () => {
|
||||
const r = rankPickerCandidates(files, "", 3);
|
||||
expect(r).toHaveLength(3);
|
||||
expect(r).toEqual(files.slice(0, 3));
|
||||
});
|
||||
|
||||
it("filters by substring match (case-insensitive)", () => {
|
||||
const r = rankPickerCandidates(files, "LOOP");
|
||||
expect(r).toContain("src/loop.ts");
|
||||
expect(r).toContain("tests/loop.test.ts");
|
||||
expect(r).not.toContain("README.md");
|
||||
});
|
||||
|
||||
it("ranks basename-prefix matches above substring matches", () => {
|
||||
// `ment` appears in "at-mentions" (both src and tests). Basenames
|
||||
// are "at-mentions.ts" and "at-mentions.test.ts" — both start
|
||||
// with `at-m` not `ment`. Not a basename-prefix hit; both should
|
||||
// score the same (substring).
|
||||
const r = rankPickerCandidates(files, "at-m");
|
||||
// `at-m` is a basename prefix for both at-mentions files:
|
||||
expect(r[0]).toMatch(/at-mentions/);
|
||||
expect(r[1]).toMatch(/at-mentions/);
|
||||
});
|
||||
|
||||
it("ranks path-prefix above substring when basename doesn't match", () => {
|
||||
// `tests/` is a path prefix (not basename). Both tests/* hit.
|
||||
const r = rankPickerCandidates(files, "tests/");
|
||||
expect(r[0]).toMatch(/^tests\//);
|
||||
});
|
||||
|
||||
it("returns empty array when nothing matches", () => {
|
||||
const r = rankPickerCandidates(files, "zzznomatch");
|
||||
expect(r).toEqual([]);
|
||||
});
|
||||
|
||||
it("respects the limit parameter", () => {
|
||||
const r = rankPickerCandidates(files, "s", 2);
|
||||
expect(r).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sorts by mtime descending when entries carry FileWithStats and query is empty", () => {
|
||||
const entries = [
|
||||
{ path: "a.ts", mtimeMs: 100 },
|
||||
{ path: "b.ts", mtimeMs: 300 },
|
||||
{ path: "c.ts", mtimeMs: 200 },
|
||||
];
|
||||
const r = rankPickerCandidates(entries, "", 5);
|
||||
// Newest (b, mtime 300) → middle (c, 200) → oldest (a, 100).
|
||||
expect(r).toEqual(["b.ts", "c.ts", "a.ts"]);
|
||||
});
|
||||
|
||||
it("recently-used paths float to the top on empty query regardless of mtime", () => {
|
||||
const entries = [
|
||||
{ path: "a.ts", mtimeMs: 300 },
|
||||
{ path: "b.ts", mtimeMs: 100 },
|
||||
{ path: "c.ts", mtimeMs: 200 },
|
||||
];
|
||||
const r = rankPickerCandidates(entries, "", {
|
||||
limit: 5,
|
||||
recentlyUsed: ["c.ts"],
|
||||
});
|
||||
// Recently-used c.ts comes first even though a.ts has a newer mtime.
|
||||
expect(r[0]).toBe("c.ts");
|
||||
// Remaining sorted by mtime descending.
|
||||
expect(r[1]).toBe("a.ts");
|
||||
expect(r[2]).toBe("b.ts");
|
||||
});
|
||||
|
||||
it("tie-breaks query matches by recently-used, then mtime", () => {
|
||||
const entries = [
|
||||
{ path: "src/alpha.ts", mtimeMs: 100 },
|
||||
{ path: "src/alpha2.ts", mtimeMs: 500 }, // newer
|
||||
];
|
||||
const r = rankPickerCandidates(entries, "alpha", { limit: 5 });
|
||||
// Both match with the same score (basename prefix, same hit
|
||||
// position) — mtime tiebreak puts alpha2 first.
|
||||
expect(r[0]).toBe("src/alpha2.ts");
|
||||
expect(r[1]).toBe("src/alpha.ts");
|
||||
|
||||
// Now with recency: older alpha.ts boosted over newer alpha2.ts.
|
||||
const r2 = rankPickerCandidates(entries, "alpha", {
|
||||
limit: 5,
|
||||
recentlyUsed: ["src/alpha.ts"],
|
||||
});
|
||||
expect(r2[0]).toBe("src/alpha.ts");
|
||||
expect(r2[1]).toBe("src/alpha2.ts");
|
||||
});
|
||||
|
||||
it("preserves input order on empty query when no mtime + no recency signal", () => {
|
||||
// Back-compat: bare string input behaves as before.
|
||||
const r = rankPickerCandidates(files, "", 3);
|
||||
expect(r).toEqual(files.slice(0, 3));
|
||||
});
|
||||
|
||||
it("accepts a number literal as the third arg for limit (back-compat)", () => {
|
||||
const r = rankPickerCandidates(files, "loop", 1);
|
||||
expect(r).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("listFilesSync", () => {
|
||||
let root: string;
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "reasonix-listfiles-"));
|
||||
mkdirSync(join(root, "src"), { recursive: true });
|
||||
mkdirSync(join(root, "src", "cli"), { recursive: true });
|
||||
mkdirSync(join(root, "node_modules", "foo"), { recursive: true });
|
||||
mkdirSync(join(root, ".git", "objects"), { recursive: true });
|
||||
writeFileSync(join(root, "package.json"), "{}");
|
||||
writeFileSync(join(root, "README.md"), "# hi");
|
||||
writeFileSync(join(root, ".gitignore"), "dist/");
|
||||
writeFileSync(join(root, "src", "index.ts"), "");
|
||||
writeFileSync(join(root, "src", "cli", "app.ts"), "");
|
||||
writeFileSync(join(root, "node_modules", "foo", "index.js"), "");
|
||||
writeFileSync(join(root, ".git", "objects", "abc"), "");
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns files recursively, with forward-slash separators", () => {
|
||||
const files = listFilesSync(root);
|
||||
expect(files).toContain("package.json");
|
||||
expect(files).toContain("README.md");
|
||||
expect(files).toContain("src/index.ts");
|
||||
expect(files).toContain("src/cli/app.ts");
|
||||
// All entries use forward slashes even on Windows.
|
||||
for (const f of files) {
|
||||
expect(f).not.toContain("\\");
|
||||
}
|
||||
});
|
||||
|
||||
it("skips ignored directories by default", () => {
|
||||
const files = listFilesSync(root);
|
||||
expect(files.every((f) => !f.includes("node_modules"))).toBe(true);
|
||||
expect(files.every((f) => !f.includes(".git/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("includes dotfiles at the top level (e.g. .gitignore)", () => {
|
||||
const files = listFilesSync(root);
|
||||
expect(files).toContain(".gitignore");
|
||||
});
|
||||
|
||||
it("respects custom ignoreDirs", () => {
|
||||
const files = listFilesSync(root, { ignoreDirs: ["src"] });
|
||||
expect(files.every((f) => !f.startsWith("src/"))).toBe(true);
|
||||
expect(files).toContain("package.json");
|
||||
});
|
||||
|
||||
it("caps the result count at maxResults", () => {
|
||||
const files = listFilesSync(root, { maxResults: 2 });
|
||||
expect(files.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("returns an empty list for an unreadable root (falls through)", () => {
|
||||
const files = listFilesSync(join(root, "does-not-exist"));
|
||||
expect(files).toEqual([]);
|
||||
});
|
||||
|
||||
it("exposes the default ignore list", () => {
|
||||
expect(DEFAULT_PICKER_IGNORE_DIRS).toContain("node_modules");
|
||||
expect(DEFAULT_PICKER_IGNORE_DIRS).toContain(".git");
|
||||
expect(DEFAULT_PICKER_IGNORE_DIRS).toContain("dist");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { detectBangCommand, formatBangUserMessage } from "../src/cli/ui/bang.js";
|
||||
|
||||
describe("detectBangCommand", () => {
|
||||
it("returns the command body for a `!`-prefixed input", () => {
|
||||
expect(detectBangCommand("!ls src/")).toBe("ls src/");
|
||||
expect(detectBangCommand("!git status")).toBe("git status");
|
||||
});
|
||||
|
||||
it("trims whitespace after the bang", () => {
|
||||
expect(detectBangCommand("! ls")).toBe("ls");
|
||||
expect(detectBangCommand("!ls ")).toBe("ls");
|
||||
});
|
||||
|
||||
it("returns null for non-bang input", () => {
|
||||
expect(detectBangCommand("ls src/")).toBeNull();
|
||||
expect(detectBangCommand("hello world")).toBeNull();
|
||||
expect(detectBangCommand("/help")).toBeNull();
|
||||
expect(detectBangCommand("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for `!` alone (no command body)", () => {
|
||||
expect(detectBangCommand("!")).toBeNull();
|
||||
expect(detectBangCommand("! ")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT trigger when `!` appears mid-string", () => {
|
||||
// Only leading `!` counts. Otherwise commands like `cat foo!bar`
|
||||
// would be incorrectly intercepted.
|
||||
expect(detectBangCommand("echo !hello")).toBeNull();
|
||||
expect(detectBangCommand("what is ! for?")).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a command with embedded `!` (not leading)", () => {
|
||||
// The bang is at position 0; the trailing ! in `echo hi!` is
|
||||
// part of the command body and passes through intact.
|
||||
expect(detectBangCommand("!echo hi!")).toBe("echo hi!");
|
||||
});
|
||||
|
||||
it("handles multi-word commands with flags and quotes", () => {
|
||||
expect(detectBangCommand('!grep -R "foo bar" src/')).toBe('grep -R "foo bar" src/');
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBangUserMessage", () => {
|
||||
it("prepends a `[!cmd]` header so the model can distinguish bang runs from its own tool output", () => {
|
||||
const out = formatBangUserMessage("ls", "$ ls\n[exit 0]\nfile1 file2");
|
||||
expect(out.startsWith("[!ls]\n")).toBe(true);
|
||||
expect(out).toContain("$ ls");
|
||||
expect(out).toContain("[exit 0]");
|
||||
expect(out).toContain("file1 file2");
|
||||
});
|
||||
|
||||
it("preserves the output verbatim after the header", () => {
|
||||
const rawOutput = "line1\nline2\n\n[…truncated…]";
|
||||
const formatted = formatBangUserMessage("cmd", rawOutput);
|
||||
expect(formatted).toBe(`[!cmd]\n${rawOutput}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Post-build smoke test for the bundled `dist/`.
|
||||
*
|
||||
* Unit tests load modules from `src/`, which means path-resolution
|
||||
* bugs that only appear AFTER tsup bundles everything into a different
|
||||
* directory layout never surface in the regular test suite. v0.5.4
|
||||
* shipped to npm with exactly such a bug — `src/tokenizer.ts` at
|
||||
* `dist/cli/index.js` tried to read the tokenizer data file relative
|
||||
* to the bundled entry, ended up at `dist/data/...` instead of the
|
||||
* package-root `data/...`, and every `reasonix run` crashed on first
|
||||
* turn with ENOENT.
|
||||
*
|
||||
* These tests spawn the bundled output and verify the two bundle
|
||||
* layouts (`dist/index.js` library + `dist/cli/index.js` CLI) can
|
||||
* both locate the tokenizer data file. They're skipped when the
|
||||
* build hasn't run yet (CI always runs `npm run build` first).
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const LIB_BUNDLE = resolve("dist/index.js");
|
||||
const CLI_BUNDLE = resolve("dist/cli/index.js");
|
||||
|
||||
describe("bundled dist — tokenizer path resolution", () => {
|
||||
const libExists = existsSync(LIB_BUNDLE);
|
||||
const cliExists = existsSync(CLI_BUNDLE);
|
||||
|
||||
(libExists ? it : it.skip)(
|
||||
"dist/index.js resolves the tokenizer data file at package-root data/",
|
||||
() => {
|
||||
// truncateForModelByTokens internally calls countTokens when the
|
||||
// input exceeds the fast-path threshold, which forces the
|
||||
// tokenizer's lazy data-file load. If resolveDataPath() lands on
|
||||
// a non-existent path (the 0.5.4 regression) this crashes with
|
||||
// ENOENT and the spawned process exits non-zero.
|
||||
// ESM dynamic imports on Windows require `file://` URLs, not bare
|
||||
// absolute paths (which Node's ESM loader rejects as an unknown
|
||||
// protocol). pathToFileURL handles the cross-platform form.
|
||||
const libUrl = pathToFileURL(LIB_BUNDLE).href;
|
||||
const result = spawnSync(
|
||||
"node",
|
||||
[
|
||||
"--input-type=module",
|
||||
"-e",
|
||||
`import { truncateForModelByTokens } from "${libUrl}";
|
||||
const s = "hello world ".repeat(500);
|
||||
const out = truncateForModelByTokens(s, 100);
|
||||
console.log(JSON.stringify({ ok: true, len: out.length }));`,
|
||||
],
|
||||
{ encoding: "utf8", timeout: 30_000 },
|
||||
);
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stderr).not.toMatch(/deepseek-tokenizer\.json\.gz/);
|
||||
expect(result.stderr).not.toMatch(/ENOENT/);
|
||||
expect(result.stdout).toMatch(/"ok":true/);
|
||||
},
|
||||
);
|
||||
|
||||
(cliExists ? it : it.skip)("dist/cli/index.js loads tokenizer before the first API fetch", () => {
|
||||
// Spawn the CLI pointed at a bogus local address that fails fetch
|
||||
// fast. In step(), preflight's estimateRequestTokens runs BEFORE
|
||||
// client.chat — so if the bundled layout can't find the
|
||||
// tokenizer data, we see ENOENT in stderr even though the fetch
|
||||
// never happens. If tokenizer loads fine, we see a connection
|
||||
// error instead (and that's OK — we're not testing the network
|
||||
// path, only that the tokenizer path resolution works from
|
||||
// dist/cli/).
|
||||
const result = spawnSync("node", [CLI_BUNDLE, "run", "--no-config", "hi"], {
|
||||
encoding: "utf8",
|
||||
timeout: 10_000,
|
||||
env: {
|
||||
...process.env,
|
||||
DEEPSEEK_API_KEY: "sk-smoke-test-bogus",
|
||||
// Fail-fast fetch target: the :1 port is almost never open,
|
||||
// so we get connection-refused within ~1ms instead of the
|
||||
// client's 120s timeout waiting on api.deepseek.com.
|
||||
DEEPSEEK_BASE_URL: "http://127.0.0.1:1",
|
||||
},
|
||||
});
|
||||
const combined = `${result.stdout}\n${result.stderr}`;
|
||||
// The crucial assertion: bundle must not crash on the tokenizer
|
||||
// path. Connection errors to 127.0.0.1:1 are expected and fine.
|
||||
expect(combined).not.toMatch(/deepseek-tokenizer\.json\.gz/);
|
||||
// Also not a missing-module style ENOENT (network errors are
|
||||
// ECONNREFUSED or fetch failure, never ENOENT).
|
||||
expect(combined).not.toMatch(/ENOENT.*tokenizer/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
|
||||
function makeFetch(status: number, body: unknown) {
|
||||
return vi.fn(
|
||||
async () =>
|
||||
new Response(typeof body === "string" ? body : JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
describe("DeepSeekClient.listModels", () => {
|
||||
it("parses the OpenAI-style model list", async () => {
|
||||
const client = new DeepSeekClient({
|
||||
apiKey: "sk-test",
|
||||
fetch: makeFetch(200, {
|
||||
object: "list",
|
||||
data: [
|
||||
{ id: "deepseek-chat", object: "model", owned_by: "deepseek" },
|
||||
{ id: "deepseek-reasoner", object: "model", owned_by: "deepseek" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
const list = await client.listModels();
|
||||
expect(list).not.toBeNull();
|
||||
expect(list!.data.map((m) => m.id)).toEqual(["deepseek-chat", "deepseek-reasoner"]);
|
||||
});
|
||||
|
||||
it("returns null on non-2xx (bad key / offline)", async () => {
|
||||
const client = new DeepSeekClient({
|
||||
apiKey: "sk-bad",
|
||||
fetch: makeFetch(401, { error: "unauthorized" }),
|
||||
});
|
||||
expect(await client.listModels()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on malformed payload", async () => {
|
||||
const client = new DeepSeekClient({
|
||||
apiKey: "sk-test",
|
||||
fetch: makeFetch(200, { whatever: "not a list" }),
|
||||
});
|
||||
expect(await client.listModels()).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when fetch throws (network error)", async () => {
|
||||
const client = new DeepSeekClient({
|
||||
apiKey: "sk-test",
|
||||
fetch: vi.fn(async () => {
|
||||
throw new Error("ECONNREFUSED");
|
||||
}) as unknown as typeof fetch,
|
||||
});
|
||||
expect(await client.listModels()).toBeNull();
|
||||
});
|
||||
|
||||
it("sends the bearer token header", async () => {
|
||||
const spy = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ object: "list", data: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
);
|
||||
const client = new DeepSeekClient({
|
||||
apiKey: "sk-xyz",
|
||||
fetch: spy as unknown as typeof fetch,
|
||||
});
|
||||
await client.listModels();
|
||||
const [, init] = spy.mock.calls[0]!;
|
||||
expect((init as RequestInit).method).toBe("GET");
|
||||
const headers = (init as RequestInit).headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe("Bearer sk-xyz");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { shrinkOversizedToolResultsByTokens } from "../src/loop.js";
|
||||
import { countTokens } from "../src/tokenizer.js";
|
||||
import type { ChatMessage } from "../src/types.js";
|
||||
|
||||
describe("shrinkOversizedToolResultsByTokens", () => {
|
||||
it("leaves small tool messages alone", () => {
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "tool", tool_call_id: "t1", content: "short" },
|
||||
];
|
||||
const r = shrinkOversizedToolResultsByTokens(msgs, 1000);
|
||||
expect(r.healedCount).toBe(0);
|
||||
expect(r.tokensSaved).toBe(0);
|
||||
expect(r.messages).toEqual(msgs);
|
||||
});
|
||||
|
||||
it("shrinks tool messages that exceed the token budget", () => {
|
||||
const huge = "some event detail line with words\n".repeat(1000);
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: "user", content: "do stuff" },
|
||||
{ role: "tool", tool_call_id: "t1", content: huge },
|
||||
];
|
||||
const r = shrinkOversizedToolResultsByTokens(msgs, 500);
|
||||
expect(r.healedCount).toBe(1);
|
||||
expect(r.tokensSaved).toBeGreaterThan(0);
|
||||
expect(r.charsSaved).toBeGreaterThan(0);
|
||||
const toolMsg = r.messages.find((m) => m.role === "tool");
|
||||
const shrunk = typeof toolMsg?.content === "string" ? toolMsg.content : "";
|
||||
// Final token count stays reasonably near the cap (plus marker
|
||||
// overhead from truncateForModelByTokens).
|
||||
expect(countTokens(shrunk)).toBeLessThanOrEqual(600);
|
||||
});
|
||||
|
||||
it("never mutates the input array", () => {
|
||||
const big = "line of text with content\n".repeat(800);
|
||||
const msgs: ChatMessage[] = [{ role: "tool", tool_call_id: "t1", content: big }];
|
||||
const original = msgs[0]!.content;
|
||||
shrinkOversizedToolResultsByTokens(msgs, 200);
|
||||
expect(msgs[0]!.content).toBe(original);
|
||||
});
|
||||
|
||||
it("does not touch user or assistant messages even when long", () => {
|
||||
const bigUser = "user-intent prose ".repeat(2000);
|
||||
const msgs: ChatMessage[] = [
|
||||
{ role: "user", content: bigUser },
|
||||
{ role: "assistant", content: "ok" },
|
||||
];
|
||||
const r = shrinkOversizedToolResultsByTokens(msgs, 100);
|
||||
expect(r.healedCount).toBe(0);
|
||||
expect(r.messages[0]!.content).toBe(bigUser);
|
||||
});
|
||||
|
||||
it("caps CJK tool results at the same token budget as English", () => {
|
||||
// Under the old char cap, CJK text slipped through at ~2× the
|
||||
// intended token cost. With a token cap, both must converge.
|
||||
const cjk = "错误:步骤执行失败需要复查\n".repeat(1000);
|
||||
const msgs: ChatMessage[] = [{ role: "tool", tool_call_id: "t1", content: cjk }];
|
||||
const r = shrinkOversizedToolResultsByTokens(msgs, 500);
|
||||
expect(r.healedCount).toBe(1);
|
||||
const shrunk = typeof r.messages[0]!.content === "string" ? r.messages[0]!.content : "";
|
||||
expect(countTokens(shrunk)).toBeLessThanOrEqual(600);
|
||||
});
|
||||
|
||||
it("fast-pathes tool messages whose content length is already below the budget", () => {
|
||||
// Every token is ≥1 char, so length <= maxTokens implies tokens
|
||||
// <= maxTokens — no tokenize call needed, message untouched.
|
||||
const content = "x".repeat(50);
|
||||
const msgs: ChatMessage[] = [{ role: "tool", tool_call_id: "t1", content }];
|
||||
const r = shrinkOversizedToolResultsByTokens(msgs, 100);
|
||||
expect(r.healedCount).toBe(0);
|
||||
expect(r.messages[0]!.content).toBe(content);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { formatAllBlockDiffs, formatEditBlockDiff } from "../src/code/diff-preview.js";
|
||||
import type { EditBlock } from "../src/code/edit-blocks.js";
|
||||
|
||||
function block(path: string, search: string, replace: string): EditBlock {
|
||||
return { path, search, replace, offset: 0 };
|
||||
}
|
||||
|
||||
describe("formatEditBlockDiff", () => {
|
||||
it("shows `+` lines only for a new-file edit (empty search)", () => {
|
||||
const b = block("src/new.ts", "", "export const x = 1;\nexport const y = 2;");
|
||||
const out = formatEditBlockDiff(b);
|
||||
expect(out.every((l) => /^\s+\+/.test(l))).toBe(true);
|
||||
expect(out.join("\n")).toContain("+ export const x = 1;");
|
||||
expect(out.join("\n")).toContain("+ export const y = 2;");
|
||||
});
|
||||
|
||||
it("collapses shared leading + trailing lines into context", () => {
|
||||
const b = block(
|
||||
"src/foo.ts",
|
||||
"function foo() {\n const x = 1;\n return x;\n}",
|
||||
"function foo() {\n const x = 2;\n return x;\n}",
|
||||
);
|
||||
const out = formatEditBlockDiff(b);
|
||||
const joined = out.join("\n");
|
||||
// Context lines (prefixed with two spaces) for unchanged parts.
|
||||
expect(joined).toContain(" function foo() {");
|
||||
expect(joined).toContain(" return x;");
|
||||
expect(joined).toContain(" }");
|
||||
// The diverging middle shows as `-`/`+`.
|
||||
expect(joined).toContain("- const x = 1;");
|
||||
expect(joined).toContain("+ const x = 2;");
|
||||
});
|
||||
|
||||
it("truncates diff to maxLines with a footer note", () => {
|
||||
// 30 different lines — no shared prefix/suffix so they all show.
|
||||
const search = Array.from({ length: 30 }, (_, i) => `old ${i}`).join("\n");
|
||||
const replace = Array.from({ length: 30 }, (_, i) => `new ${i}`).join("\n");
|
||||
const b = block("src/big.ts", search, replace);
|
||||
const out = formatEditBlockDiff(b, { maxLines: 10 });
|
||||
expect(out.length).toBeLessThanOrEqual(10);
|
||||
expect(out[out.length - 1]).toMatch(/more diff lines/);
|
||||
});
|
||||
|
||||
it("notes hidden leading context lines when prefix > contextLines", () => {
|
||||
const pre = Array.from({ length: 10 }, (_, i) => `pre ${i}`).join("\n");
|
||||
const b = block("src/a.ts", `${pre}\nOLD`, `${pre}\nNEW`);
|
||||
const out = formatEditBlockDiff(b, { contextLines: 2 });
|
||||
const joined = out.join("\n");
|
||||
// Leading context should be collapsed — we keep 2 visible and
|
||||
// note the rest as hidden.
|
||||
expect(joined).toMatch(/8 unchanged lines? above/);
|
||||
});
|
||||
|
||||
it("notes hidden trailing context lines when suffix > contextLines", () => {
|
||||
const post = Array.from({ length: 10 }, (_, i) => `post ${i}`).join("\n");
|
||||
const b = block("src/a.ts", `OLD\n${post}`, `NEW\n${post}`);
|
||||
const out = formatEditBlockDiff(b, { contextLines: 2 });
|
||||
const joined = out.join("\n");
|
||||
expect(joined).toMatch(/8 unchanged lines? below/);
|
||||
});
|
||||
|
||||
it("handles single-line edits cleanly", () => {
|
||||
const b = block("one.ts", "old", "new");
|
||||
const out = formatEditBlockDiff(b);
|
||||
expect(out).toContain(" - old");
|
||||
expect(out).toContain(" + new");
|
||||
});
|
||||
|
||||
it("respects custom indent", () => {
|
||||
const b = block("x.ts", "a", "b");
|
||||
const out = formatEditBlockDiff(b, { indent: ">> " });
|
||||
expect(out.some((l) => l.startsWith(">> - a"))).toBe(true);
|
||||
expect(out.some((l) => l.startsWith(">> + b"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatAllBlockDiffs", () => {
|
||||
it("emits a path+count header before each block's diff", () => {
|
||||
const blocks = [block("a.ts", "x", "y"), block("b.ts", "", "new content")];
|
||||
const out = formatAllBlockDiffs(blocks);
|
||||
const joined = out.join("\n");
|
||||
expect(joined).toMatch(/ {4}a\.ts {2}\(-1 \+1 lines\)/);
|
||||
expect(joined).toMatch(/NEW b\.ts {2}\(-0 \+1 lines\)/);
|
||||
});
|
||||
|
||||
it("separates blocks with a blank line", () => {
|
||||
const blocks = [block("a.ts", "x", "y"), block("b.ts", "p", "q")];
|
||||
const out = formatAllBlockDiffs(blocks);
|
||||
// Blank line between first block's diff and second block's header.
|
||||
const blankIdx = out.indexOf("");
|
||||
expect(blankIdx).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("returns an empty array for zero blocks", () => {
|
||||
expect(formatAllBlockDiffs([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* R1 thinking-mode contract: when a reasoner turn returns both
|
||||
* `reasoning_content` and `tool_calls`, the assistant message we
|
||||
* persist + send on the NEXT request (the tool-loop continuation)
|
||||
* must carry `reasoning_content` back. Otherwise DeepSeek 400s with
|
||||
* "The reasoning_content in the thinking mode must be passed back to
|
||||
* the API." Reproduces the bug that surfaced in 0.5.14 live usage.
|
||||
*/
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import { CacheFirstLoop } from "../src/loop.js";
|
||||
import { ImmutablePrefix } from "../src/memory.js";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
import type { ChatMessage } from "../src/types.js";
|
||||
|
||||
interface FakeResponseShape {
|
||||
content?: string;
|
||||
reasoning_content?: string;
|
||||
tool_calls?: any[];
|
||||
usage?: Record<string, number>;
|
||||
}
|
||||
|
||||
function capturingFetch(responses: FakeResponseShape[]): {
|
||||
fetch: typeof fetch;
|
||||
bodies: Array<{ messages: ChatMessage[] }>;
|
||||
} {
|
||||
const bodies: Array<{ messages: ChatMessage[] }> = [];
|
||||
let i = 0;
|
||||
const fn = vi.fn(async (_url: any, init: any) => {
|
||||
const body = init?.body ? JSON.parse(init.body) : {};
|
||||
bodies.push({ messages: body.messages });
|
||||
const resp = responses[i++] ?? responses[responses.length - 1]!;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: resp.content ?? "",
|
||||
reasoning_content: resp.reasoning_content ?? null,
|
||||
tool_calls: resp.tool_calls ?? undefined,
|
||||
},
|
||||
finish_reason: resp.tool_calls ? "tool_calls" : "stop",
|
||||
},
|
||||
],
|
||||
usage: resp.usage ?? {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 120,
|
||||
prompt_cache_hit_tokens: 0,
|
||||
prompt_cache_miss_tokens: 100,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
return { fetch: fn, bodies };
|
||||
}
|
||||
|
||||
describe("R1 reasoning_content round-trip", () => {
|
||||
it("preserves reasoning_content on the assistant message when the turn has tool_calls", async () => {
|
||||
const tools = new ToolRegistry();
|
||||
tools.register({
|
||||
name: "noop",
|
||||
readOnly: true,
|
||||
fn: () => "ok",
|
||||
});
|
||||
|
||||
const { fetch: fakeFetch, bodies } = capturingFetch([
|
||||
{
|
||||
// Turn 1: model emits reasoning + tool call.
|
||||
content: "",
|
||||
reasoning_content: "I should call noop to check something.",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "noop", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// Turn 2: plain text wrap-up after the tool result comes back.
|
||||
content: "done",
|
||||
},
|
||||
]);
|
||||
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: tools.specs() }),
|
||||
tools,
|
||||
model: "deepseek-reasoner",
|
||||
stream: false,
|
||||
});
|
||||
|
||||
for await (const _ev of loop.step("please noop")) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
expect(bodies.length).toBe(2);
|
||||
// Turn 2's request messages include the turn-1 assistant message;
|
||||
// find it and verify reasoning_content landed.
|
||||
const turn2Messages = bodies[1]!.messages;
|
||||
const assistantWithCalls = turn2Messages.find(
|
||||
(m) => m.role === "assistant" && (m.tool_calls?.length ?? 0) > 0,
|
||||
);
|
||||
expect(assistantWithCalls).toBeDefined();
|
||||
expect(assistantWithCalls?.reasoning_content).toBe("I should call noop to check something.");
|
||||
});
|
||||
|
||||
it("also preserves reasoning_content on plain-text reasoner turns", async () => {
|
||||
// 0.5.18 regression: R1 requires reasoning_content on ANY
|
||||
// assistant message the model produced in thinking mode, not just
|
||||
// ones with tool_calls. 0.5.15 scoped the fix too narrowly and a
|
||||
// plan-approval flow (submit_plan → "plan submitted" plain-text
|
||||
// turn → approval) kept 400ing on the follow-up request.
|
||||
const { fetch: fakeFetch, bodies } = capturingFetch([
|
||||
{
|
||||
content: "a plain answer",
|
||||
reasoning_content: "reasoning attached to a plain-text turn",
|
||||
},
|
||||
{ content: "follow-up" },
|
||||
]);
|
||||
const client = new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch });
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s" }),
|
||||
model: "deepseek-reasoner",
|
||||
stream: false,
|
||||
});
|
||||
|
||||
for await (const _ev of loop.step("hello")) {
|
||||
/* drain */
|
||||
}
|
||||
for await (const _ev of loop.step("next")) {
|
||||
/* drain */
|
||||
}
|
||||
|
||||
const turn2Messages = bodies[1]!.messages;
|
||||
const assistant = turn2Messages.find((m) => m.role === "assistant");
|
||||
expect(assistant).toBeDefined();
|
||||
expect(assistant?.reasoning_content).toBe("reasoning attached to a plain-text turn");
|
||||
});
|
||||
});
|
||||
+99
-1
@@ -470,11 +470,13 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
maxToolIters: 64,
|
||||
});
|
||||
// Put a big tool message into the log so compact has work to do.
|
||||
// Realistic log-line content (not "Z".repeat(N)) to avoid the
|
||||
// tokenizer's BPE O(n²) pathological path on pure-repeat inputs.
|
||||
loop.log.append({ role: "user", content: "do stuff" });
|
||||
loop.log.append({
|
||||
role: "tool",
|
||||
tool_call_id: "prior",
|
||||
content: "Z".repeat(60_000),
|
||||
content: "ERROR: something went wrong in module X at step Y\n".repeat(2000),
|
||||
});
|
||||
|
||||
const events: { role: string; forcedSummary?: boolean; content?: string }[] = [];
|
||||
@@ -494,6 +496,102 @@ describe("CacheFirstLoop (non-streaming)", () => {
|
||||
expect(last!.forcedSummary).toBeFalsy();
|
||||
});
|
||||
|
||||
it("proactively compacts oversized tool results between 60%-80% of the context window", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
reg.register({
|
||||
name: "probe",
|
||||
description: "no-op",
|
||||
parameters: { type: "object", properties: {} },
|
||||
fn: async () => "ok",
|
||||
});
|
||||
const responses: FakeResponseShape[] = [
|
||||
// 131k * 0.72 ≈ 94,320 tokens — squarely in the proactive band.
|
||||
{
|
||||
content: "",
|
||||
tool_calls: [{ id: "c1", type: "function", function: { name: "probe", arguments: "{}" } }],
|
||||
usage: {
|
||||
prompt_tokens: 95_000,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 95_010,
|
||||
prompt_cache_hit_tokens: 70_000,
|
||||
prompt_cache_miss_tokens: 25_000,
|
||||
},
|
||||
},
|
||||
{ content: "done." },
|
||||
];
|
||||
const client = makeClient(responses);
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: reg.specs() }),
|
||||
tools: reg,
|
||||
stream: false,
|
||||
maxToolIters: 64,
|
||||
});
|
||||
// Seed an oversized tool result (>4k tokens, the proactive cap)
|
||||
// so proactive compact has work to do. Using realistic log-line
|
||||
// content to avoid the tokenizer's BPE O(n²) pathological path.
|
||||
loop.log.append({ role: "user", content: "prior" });
|
||||
loop.log.append({
|
||||
role: "tool",
|
||||
tool_call_id: "prior",
|
||||
content: "INFO: step completed with some trailing detail\n".repeat(1500),
|
||||
});
|
||||
|
||||
const events: { role: string; content?: string; forcedSummary?: boolean }[] = [];
|
||||
for await (const ev of loop.step("continue")) {
|
||||
events.push({ role: ev.role, content: ev.content, forcedSummary: ev.forcedSummary });
|
||||
}
|
||||
|
||||
const proactive = events.find(
|
||||
(e) => e.role === "warning" && /proactively compacted/.test(e.content ?? ""),
|
||||
);
|
||||
expect(proactive).toBeDefined();
|
||||
expect(proactive!.content).toMatch(/4k tokens/);
|
||||
// 60%-80% band must NOT force a summary (that's the 80% reactive path).
|
||||
const finals = events.filter((e) => e.role === "assistant_final");
|
||||
expect(finals[finals.length - 1]!.forcedSummary).toBeFalsy();
|
||||
});
|
||||
|
||||
it("pre-clips new tool results at dispatch so they never enter the log oversized", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
// Tool returns ~50k chars of realistic-shape log text; the default
|
||||
// token budget (8k) bounds the resulting log entry to a small
|
||||
// fraction of the raw size. (Using "A".repeat(N) would hit the
|
||||
// tokenizer's BPE O(n²) path for repeated single-char inputs —
|
||||
// pathological enough to slow the suite by tens of seconds, and
|
||||
// not representative of real tool output.)
|
||||
const huge = "ERROR: repeated failure with some detail\n".repeat(1250);
|
||||
reg.register({
|
||||
name: "big",
|
||||
description: "returns a lot",
|
||||
parameters: { type: "object", properties: {} },
|
||||
fn: async () => huge,
|
||||
});
|
||||
const responses: FakeResponseShape[] = [
|
||||
{
|
||||
content: "",
|
||||
tool_calls: [{ id: "c1", type: "function", function: { name: "big", arguments: "{}" } }],
|
||||
},
|
||||
{ content: "summarized." },
|
||||
];
|
||||
const client = makeClient(responses);
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s", toolSpecs: reg.specs() }),
|
||||
tools: reg,
|
||||
stream: false,
|
||||
});
|
||||
for await (const _ev of loop.step("go")) {
|
||||
/* drain */
|
||||
}
|
||||
const toolEntry = loop.log.toMessages().find((m) => m.role === "tool");
|
||||
expect(toolEntry).toBeDefined();
|
||||
const content = typeof toolEntry!.content === "string" ? toolEntry!.content : "";
|
||||
// Well under the raw 50k — pre-clip fired before append.
|
||||
expect(content.length).toBeLessThan(40_000);
|
||||
expect(content).toMatch(/truncated/);
|
||||
});
|
||||
|
||||
it("buildMessages strips a dangling assistant-with-tool_calls tail — defensive against 'insufficient tool messages' 400", async () => {
|
||||
// Craft a log where the last entry is an assistant message with
|
||||
// tool_calls but no matching tool responses. This is the shape
|
||||
|
||||
@@ -504,6 +504,21 @@ describe("citation links — validateCitation + collectCitations", () => {
|
||||
if (!result.ok) expect(result.reason).toBe("file not found");
|
||||
});
|
||||
|
||||
it("leading `/` is treated as project-rooted, not filesystem-absolute", () => {
|
||||
// Models habitually write `/foo.ts` meaning "project root" (Aider /
|
||||
// Claude-Code style). Without the strip this would be resolved
|
||||
// against POSIX `/` or the Windows drive root and always fail —
|
||||
// that was the symptom in 0.5.16: a real file at the project root
|
||||
// rendered as "file not found" because the leading slash sent the
|
||||
// validator to the wrong place.
|
||||
expect(validateCitation("/real.ts", tmp)).toEqual({ ok: true });
|
||||
expect(validateCitation("/real.ts:2", tmp)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("leading `\\` is treated the same way (Windows-style)", () => {
|
||||
expect(validateCitation("\\real.ts", tmp)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("collectCitations validates every unique citation, skips externals", () => {
|
||||
const text = [
|
||||
"See [foo](real.ts:2) for details.",
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { DisplayEvent } from "../src/cli/ui/EventLog.js";
|
||||
import {
|
||||
findServerForPrompt,
|
||||
findServerForResource,
|
||||
formatPromptList,
|
||||
formatPromptMessages,
|
||||
formatResourceContents,
|
||||
formatResourceList,
|
||||
handleMcpBrowseSlash,
|
||||
} from "../src/cli/ui/mcp-browse.js";
|
||||
import type { McpServerSummary } from "../src/cli/ui/slash.js";
|
||||
import type { McpClient } from "../src/mcp/client.js";
|
||||
|
||||
function server(partial: Partial<McpServerSummary> & { label: string }): McpServerSummary {
|
||||
return {
|
||||
spec: partial.spec ?? `fake://${partial.label}`,
|
||||
toolCount: partial.toolCount ?? 0,
|
||||
report: partial.report ?? {
|
||||
protocolVersion: "2024-11-05",
|
||||
serverInfo: { name: partial.label, version: "1.0" },
|
||||
capabilities: {},
|
||||
tools: { supported: true, items: [] },
|
||||
resources: { supported: true, items: [] },
|
||||
prompts: { supported: true, items: [] },
|
||||
},
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("formatResourceList", () => {
|
||||
it("returns a polite message when no server exposes any resources", () => {
|
||||
expect(formatResourceList([])).toMatch(/No resources/);
|
||||
});
|
||||
|
||||
it("groups URIs by server label, shows name + mime when present", () => {
|
||||
const out = formatResourceList([
|
||||
server({
|
||||
label: "fs",
|
||||
report: {
|
||||
protocolVersion: "2024-11-05",
|
||||
serverInfo: { name: "fs", version: "1" },
|
||||
capabilities: {},
|
||||
tools: { supported: true, items: [] },
|
||||
prompts: { supported: false, reason: "" },
|
||||
resources: {
|
||||
supported: true,
|
||||
items: [{ uri: "file:///a.md", name: "a.md", mimeType: "text/markdown" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(out).toContain("[fs] 1 resource(s):");
|
||||
expect(out).toContain("file:///a.md");
|
||||
expect(out).toContain("a.md");
|
||||
expect(out).toContain("text/markdown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPromptList", () => {
|
||||
it("returns a polite message when no server exposes any prompts", () => {
|
||||
expect(formatPromptList([])).toMatch(/No prompts/);
|
||||
});
|
||||
|
||||
it("shows argument requirement markers (*/?)", () => {
|
||||
const out = formatPromptList([
|
||||
server({
|
||||
label: "p",
|
||||
report: {
|
||||
protocolVersion: "2024-11-05",
|
||||
serverInfo: { name: "p", version: "1" },
|
||||
capabilities: {},
|
||||
tools: { supported: true, items: [] },
|
||||
resources: { supported: false, reason: "" },
|
||||
prompts: {
|
||||
supported: true,
|
||||
items: [
|
||||
{
|
||||
name: "summarize",
|
||||
description: "one-line summary",
|
||||
arguments: [
|
||||
{ name: "topic", required: true },
|
||||
{ name: "length", required: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(out).toContain("summarize");
|
||||
expect(out).toContain("topic*");
|
||||
expect(out).toContain("length?");
|
||||
expect(out).toContain("one-line summary");
|
||||
});
|
||||
});
|
||||
|
||||
describe("findServerForResource", () => {
|
||||
it("returns the server that claims the URI", () => {
|
||||
const a = server({
|
||||
label: "a",
|
||||
report: {
|
||||
protocolVersion: "",
|
||||
serverInfo: { name: "a", version: "" },
|
||||
capabilities: {},
|
||||
tools: { supported: true, items: [] },
|
||||
prompts: { supported: false, reason: "" },
|
||||
resources: { supported: true, items: [{ uri: "x://1", name: "x1" }] },
|
||||
},
|
||||
});
|
||||
const b = server({
|
||||
label: "b",
|
||||
report: {
|
||||
protocolVersion: "",
|
||||
serverInfo: { name: "b", version: "" },
|
||||
capabilities: {},
|
||||
tools: { supported: true, items: [] },
|
||||
prompts: { supported: false, reason: "" },
|
||||
resources: { supported: true, items: [{ uri: "y://2", name: "y2" }] },
|
||||
},
|
||||
});
|
||||
expect(findServerForResource([a, b], "y://2")).toBe(b);
|
||||
expect(findServerForResource([a, b], "nope://")).toBeNull();
|
||||
});
|
||||
|
||||
it("skips servers that don't support resources", () => {
|
||||
const a = server({
|
||||
label: "a",
|
||||
report: {
|
||||
protocolVersion: "",
|
||||
serverInfo: { name: "a", version: "" },
|
||||
capabilities: {},
|
||||
tools: { supported: true, items: [] },
|
||||
prompts: { supported: false, reason: "" },
|
||||
resources: { supported: false, reason: "-32601" },
|
||||
},
|
||||
});
|
||||
expect(findServerForResource([a], "x://any")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findServerForPrompt", () => {
|
||||
it("returns the server that claims the prompt name", () => {
|
||||
const a = server({
|
||||
label: "a",
|
||||
report: {
|
||||
protocolVersion: "",
|
||||
serverInfo: { name: "a", version: "" },
|
||||
capabilities: {},
|
||||
tools: { supported: true, items: [] },
|
||||
resources: { supported: false, reason: "" },
|
||||
prompts: { supported: true, items: [{ name: "summarize" }] },
|
||||
},
|
||||
});
|
||||
expect(findServerForPrompt([a], "summarize")).toBe(a);
|
||||
expect(findServerForPrompt([a], "missing")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatResourceContents", () => {
|
||||
it("renders a text block verbatim", () => {
|
||||
const out = formatResourceContents("x://1", {
|
||||
contents: [{ uri: "x://1", mimeType: "text/plain", text: "hello world" }],
|
||||
});
|
||||
expect(out).toContain("hello world");
|
||||
expect(out).toContain("text/plain");
|
||||
});
|
||||
|
||||
it("truncates extremely large text blocks with a notice", () => {
|
||||
const big = "A".repeat(12_000);
|
||||
const out = formatResourceContents("x://1", {
|
||||
contents: [{ uri: "x://1", text: big }],
|
||||
});
|
||||
expect(out).toContain("truncated");
|
||||
expect(out.length).toBeLessThan(big.length);
|
||||
});
|
||||
|
||||
it("summarizes binary blobs rather than rendering them", () => {
|
||||
const out = formatResourceContents("x://1", {
|
||||
contents: [{ uri: "x://1", mimeType: "image/png", blob: "AAAA" }],
|
||||
});
|
||||
expect(out).toContain("binary");
|
||||
expect(out).toContain("bytes");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatPromptMessages", () => {
|
||||
it("renders role + text content for each message", () => {
|
||||
const out = formatPromptMessages("summarize", {
|
||||
description: "one-line summary",
|
||||
messages: [
|
||||
{ role: "user", content: { type: "text", text: "Summarize X." } as any },
|
||||
{ role: "assistant", content: { type: "text", text: "X is …" } as any },
|
||||
],
|
||||
});
|
||||
expect(out).toContain("summarize");
|
||||
expect(out).toContain("one-line summary");
|
||||
expect(out).toContain("user");
|
||||
expect(out).toContain("assistant");
|
||||
expect(out).toContain("Summarize X.");
|
||||
expect(out).toContain("X is …");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleMcpBrowseSlash", () => {
|
||||
function makeSetter() {
|
||||
const rows: DisplayEvent[] = [];
|
||||
const setter = (updater: (prev: DisplayEvent[]) => DisplayEvent[]) => {
|
||||
const next = updater(rows.slice());
|
||||
rows.splice(0, rows.length, ...next);
|
||||
};
|
||||
return { rows, setter };
|
||||
}
|
||||
|
||||
it("list mode: no arg writes a single info row with the list text", async () => {
|
||||
const { rows, setter } = makeSetter();
|
||||
await handleMcpBrowseSlash("resource", "", [], setter);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]?.role).toBe("info");
|
||||
expect(rows[0]?.text).toMatch(/No resources/);
|
||||
});
|
||||
|
||||
it("read mode: unknown URI emits a warning row", async () => {
|
||||
const { rows, setter } = makeSetter();
|
||||
await handleMcpBrowseSlash("resource", "mystery://1", [], setter);
|
||||
expect(rows[0]?.role).toBe("warning");
|
||||
expect(rows[0]?.text).toContain("no server exposes resource");
|
||||
});
|
||||
|
||||
it("read mode: calls client.readResource and emits the formatted contents", async () => {
|
||||
const { rows, setter } = makeSetter();
|
||||
const readResource = vi.fn(async () => ({
|
||||
contents: [{ uri: "x://1", text: "hello" }],
|
||||
}));
|
||||
const fake: McpServerSummary = server({
|
||||
label: "a",
|
||||
report: {
|
||||
protocolVersion: "",
|
||||
serverInfo: { name: "a", version: "" },
|
||||
capabilities: {},
|
||||
tools: { supported: true, items: [] },
|
||||
prompts: { supported: false, reason: "" },
|
||||
resources: { supported: true, items: [{ uri: "x://1", name: "x1" }] },
|
||||
},
|
||||
client: { readResource } as unknown as McpClient,
|
||||
});
|
||||
await handleMcpBrowseSlash("resource", "x://1", [fake], setter);
|
||||
expect(readResource).toHaveBeenCalledWith("x://1");
|
||||
expect(rows[0]?.role).toBe("info");
|
||||
expect(rows[0]?.text).toContain("hello");
|
||||
});
|
||||
|
||||
it("read mode: surfaces readResource rejection as a warning row", async () => {
|
||||
const { rows, setter } = makeSetter();
|
||||
const fake: McpServerSummary = server({
|
||||
label: "a",
|
||||
report: {
|
||||
protocolVersion: "",
|
||||
serverInfo: { name: "a", version: "" },
|
||||
capabilities: {},
|
||||
tools: { supported: true, items: [] },
|
||||
prompts: { supported: false, reason: "" },
|
||||
resources: { supported: true, items: [{ uri: "x://1", name: "x1" }] },
|
||||
},
|
||||
client: {
|
||||
readResource: vi.fn(async () => {
|
||||
throw new Error("server returned -32002 not-found");
|
||||
}),
|
||||
} as unknown as McpClient,
|
||||
});
|
||||
await handleMcpBrowseSlash("resource", "x://1", [fake], setter);
|
||||
expect(rows[0]?.role).toBe("warning");
|
||||
expect(rows[0]?.text).toContain("-32002");
|
||||
});
|
||||
|
||||
it("prompt mode: calls client.getPrompt and emits messages", async () => {
|
||||
const { rows, setter } = makeSetter();
|
||||
const getPrompt = vi.fn(async () => ({
|
||||
description: "hi",
|
||||
messages: [{ role: "user", content: { type: "text", text: "Hello." } as any }],
|
||||
}));
|
||||
const fake: McpServerSummary = server({
|
||||
label: "a",
|
||||
report: {
|
||||
protocolVersion: "",
|
||||
serverInfo: { name: "a", version: "" },
|
||||
capabilities: {},
|
||||
tools: { supported: true, items: [] },
|
||||
resources: { supported: false, reason: "" },
|
||||
prompts: { supported: true, items: [{ name: "greet" }] },
|
||||
},
|
||||
client: { getPrompt } as unknown as McpClient,
|
||||
});
|
||||
await handleMcpBrowseSlash("prompt", "greet", [fake], setter);
|
||||
expect(getPrompt).toHaveBeenCalledWith("greet");
|
||||
expect(rows[0]?.role).toBe("info");
|
||||
expect(rows[0]?.text).toContain("Hello.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
DEFAULT_PASTE_CHAR_THRESHOLD,
|
||||
DEFAULT_PASTE_HEAD_LINES,
|
||||
DEFAULT_PASTE_LINE_THRESHOLD,
|
||||
formatLongPaste,
|
||||
} from "../src/cli/ui/paste-collapse.js";
|
||||
|
||||
describe("formatLongPaste", () => {
|
||||
it("passes through short input verbatim", () => {
|
||||
const r = formatLongPaste("hello world");
|
||||
expect(r.collapsed).toBe(false);
|
||||
expect(r.displayText).toBe("hello world");
|
||||
expect(r.originalChars).toBe(11);
|
||||
expect(r.originalLines).toBe(1);
|
||||
});
|
||||
|
||||
it("passes through a multi-line but small-ish input", () => {
|
||||
const r = formatLongPaste("line 1\nline 2\nline 3");
|
||||
expect(r.collapsed).toBe(false);
|
||||
expect(r.displayText).toBe("line 1\nline 2\nline 3");
|
||||
expect(r.originalLines).toBe(3);
|
||||
});
|
||||
|
||||
it("collapses when line count exceeds threshold", () => {
|
||||
const input = Array.from({ length: 60 }, (_, i) => `line ${i + 1}`).join("\n");
|
||||
const r = formatLongPaste(input);
|
||||
expect(r.collapsed).toBe(true);
|
||||
expect(r.originalLines).toBe(60);
|
||||
expect(r.displayText).toMatch(/pasted/);
|
||||
expect(r.displayText).toMatch(/60 lines/);
|
||||
// Header + first 10 lines + "… (50 more lines)" footer.
|
||||
expect(r.displayText).toContain("line 1");
|
||||
expect(r.displayText).toContain("line 10");
|
||||
expect(r.displayText).not.toContain("line 11");
|
||||
expect(r.displayText).toMatch(/50 more lines/);
|
||||
});
|
||||
|
||||
it("collapses when char count exceeds threshold even with few lines", () => {
|
||||
const input = "x".repeat(3000); // 1 line, 3000 chars
|
||||
const r = formatLongPaste(input);
|
||||
expect(r.collapsed).toBe(true);
|
||||
expect(r.originalChars).toBe(3000);
|
||||
expect(r.displayText).toMatch(/pasted/);
|
||||
expect(r.displayText).toMatch(/2\.9 KB|3 KB/);
|
||||
});
|
||||
|
||||
it("renders bytes in KB when >= 1024", () => {
|
||||
const r = formatLongPaste(Array.from({ length: 100 }, () => "x".repeat(30)).join("\n"));
|
||||
expect(r.collapsed).toBe(true);
|
||||
// 100 * 31 = ~3.0 KB
|
||||
expect(r.displayText).toMatch(/(KB|MB)/);
|
||||
});
|
||||
|
||||
it("handles single-line remaining — 'more line' not 'more lines'", () => {
|
||||
const lines = Array.from({ length: DEFAULT_PASTE_HEAD_LINES + 1 }, (_, i) => `l${i}`);
|
||||
// Still below the line threshold by default (head+1), so trigger via chars.
|
||||
const big = `${lines.join("\n")}\n${"x".repeat(DEFAULT_PASTE_CHAR_THRESHOLD + 100)}`;
|
||||
const r = formatLongPaste(big);
|
||||
expect(r.collapsed).toBe(true);
|
||||
});
|
||||
|
||||
it("respects custom thresholds", () => {
|
||||
const r = formatLongPaste("line1\nline2\nline3", { lineThreshold: 2 });
|
||||
expect(r.collapsed).toBe(true);
|
||||
expect(r.displayText).toMatch(/3 lines/);
|
||||
});
|
||||
|
||||
it("respects custom headLines", () => {
|
||||
const lines = Array.from({ length: 100 }, (_, i) => `l${i}`);
|
||||
const r = formatLongPaste(lines.join("\n"), { headLines: 3 });
|
||||
expect(r.collapsed).toBe(true);
|
||||
expect(r.displayText).toContain("l0");
|
||||
expect(r.displayText).toContain("l2");
|
||||
expect(r.displayText).not.toContain("l3");
|
||||
expect(r.displayText).toMatch(/97 more lines/);
|
||||
});
|
||||
|
||||
it("reports 0 'more lines' when headLines covers all", () => {
|
||||
// Trigger collapse via chars, with very large headLines.
|
||||
const big = `line1\n${"x".repeat(DEFAULT_PASTE_CHAR_THRESHOLD + 100)}`;
|
||||
const r = formatLongPaste(big, { headLines: 999 });
|
||||
expect(r.collapsed).toBe(true);
|
||||
// When head covers everything, no footer is appended.
|
||||
expect(r.displayText).not.toMatch(/more line/);
|
||||
});
|
||||
|
||||
it("exposes default thresholds", () => {
|
||||
expect(DEFAULT_PASTE_LINE_THRESHOLD).toBe(40);
|
||||
expect(DEFAULT_PASTE_CHAR_THRESHOLD).toBe(2000);
|
||||
expect(DEFAULT_PASTE_HEAD_LINES).toBe(10);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { EditBlock } from "../src/code/edit-blocks.js";
|
||||
import {
|
||||
clearPendingEdits,
|
||||
loadPendingEdits,
|
||||
pendingEditsPath,
|
||||
savePendingEdits,
|
||||
} from "../src/code/pending-edits.js";
|
||||
import { appendSessionMessage, deleteSession, sessionPath } from "../src/session.js";
|
||||
|
||||
function block(overrides: Partial<EditBlock> = {}): EditBlock {
|
||||
return {
|
||||
path: "src/a.ts",
|
||||
search: "foo",
|
||||
replace: "bar",
|
||||
offset: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("pending-edits checkpoint", () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "reasonix-pending-"));
|
||||
vi.stubEnv("USERPROFILE", tmp);
|
||||
vi.stubEnv("HOME", tmp);
|
||||
vi.spyOn(require("node:os"), "homedir").mockReturnValue(tmp);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
if (existsSync(tmp)) rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("pendingEditsPath is a .pending.json sibling of the session file", () => {
|
||||
const p = pendingEditsPath("demo");
|
||||
expect(p).toContain("sessions");
|
||||
expect(p.endsWith("demo.pending.json")).toBe(true);
|
||||
});
|
||||
|
||||
it("save → load round-trips the queue", () => {
|
||||
const blocks = [block({ path: "a.ts" }), block({ path: "b.ts", search: "x", replace: "y" })];
|
||||
savePendingEdits("s1", blocks);
|
||||
const loaded = loadPendingEdits("s1");
|
||||
expect(loaded).toEqual(blocks);
|
||||
});
|
||||
|
||||
it("save with an empty array deletes the file", () => {
|
||||
savePendingEdits("s2", [block()]);
|
||||
expect(existsSync(pendingEditsPath("s2"))).toBe(true);
|
||||
savePendingEdits("s2", []);
|
||||
expect(existsSync(pendingEditsPath("s2"))).toBe(false);
|
||||
});
|
||||
|
||||
it("load returns null when the file doesn't exist", () => {
|
||||
expect(loadPendingEdits("ghost")).toBeNull();
|
||||
});
|
||||
|
||||
it("load returns null for a corrupt file rather than throwing", () => {
|
||||
// First create a real checkpoint, then trash its contents.
|
||||
savePendingEdits("corrupt", [block()]);
|
||||
const path = pendingEditsPath("corrupt");
|
||||
writeFileSync(path, "{ not json", "utf8");
|
||||
expect(loadPendingEdits("corrupt")).toBeNull();
|
||||
});
|
||||
|
||||
it("load drops malformed entries but keeps valid neighbors", () => {
|
||||
const path = pendingEditsPath("mixed");
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(
|
||||
path,
|
||||
JSON.stringify([
|
||||
block({ path: "good.ts" }),
|
||||
{ path: "bad", search: "", replace: "" }, // missing offset
|
||||
{ totally: "wrong" },
|
||||
block({ path: "also-good.ts" }),
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
const loaded = loadPendingEdits("mixed");
|
||||
expect(loaded?.map((b) => b.path)).toEqual(["good.ts", "also-good.ts"]);
|
||||
});
|
||||
|
||||
it("clearPendingEdits removes the checkpoint file", () => {
|
||||
savePendingEdits("clr", [block()]);
|
||||
expect(existsSync(pendingEditsPath("clr"))).toBe(true);
|
||||
clearPendingEdits("clr");
|
||||
expect(existsSync(pendingEditsPath("clr"))).toBe(false);
|
||||
});
|
||||
|
||||
it("clearPendingEdits is a no-op when the file is already missing", () => {
|
||||
expect(() => clearPendingEdits("ghost")).not.toThrow();
|
||||
});
|
||||
|
||||
it("save / load / clear are all no-ops when sessionName is null (ephemeral)", () => {
|
||||
expect(() => savePendingEdits(null, [block()])).not.toThrow();
|
||||
expect(loadPendingEdits(null)).toBeNull();
|
||||
expect(() => clearPendingEdits(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it("deleteSession removes the pending-edits sidecar too", () => {
|
||||
appendSessionMessage("combo", { role: "user", content: "hi" });
|
||||
savePendingEdits("combo", [block()]);
|
||||
expect(existsSync(pendingEditsPath("combo"))).toBe(true);
|
||||
expect(existsSync(sessionPath("combo"))).toBe(true);
|
||||
deleteSession("combo");
|
||||
expect(existsSync(sessionPath("combo"))).toBe(false);
|
||||
expect(existsSync(pendingEditsPath("combo"))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { clampBodyByLines } from "../src/cli/ui/PlanConfirm.js";
|
||||
|
||||
describe("clampBodyByLines", () => {
|
||||
it("returns text unchanged when already within the row budget", () => {
|
||||
const text = "line 1\nline 2\nline 3";
|
||||
expect(clampBodyByLines(text, 10)).toBe(text);
|
||||
});
|
||||
|
||||
it("trims to the first N lines and appends a truncation marker", () => {
|
||||
const lines = Array.from({ length: 40 }, (_, i) => `line ${i + 1}`);
|
||||
const out = clampBodyByLines(lines.join("\n"), 10);
|
||||
expect(out.split("\n").slice(0, 10)).toEqual(lines.slice(0, 10));
|
||||
expect(out).toContain("30 more lines truncated");
|
||||
});
|
||||
|
||||
it("shows the dropped-line count for the truncation hint", () => {
|
||||
const text = Array.from({ length: 25 }, (_, i) => `row${i}`).join("\n");
|
||||
expect(clampBodyByLines(text, 5)).toContain("20 more lines truncated");
|
||||
});
|
||||
|
||||
it("handles an empty body without throwing", () => {
|
||||
expect(clampBodyByLines("", 5)).toBe("");
|
||||
});
|
||||
|
||||
it("does not trim when exactly at the budget", () => {
|
||||
const text = "a\nb\nc";
|
||||
expect(clampBodyByLines(text, 3)).toBe(text);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* Preflight context-size check tests.
|
||||
*
|
||||
* Reactive auto-compact keys off the PREVIOUS turn's prompt_tokens —
|
||||
* too late to save a fresh request whose buildMessages already exceeds
|
||||
* the model's context window. The preflight estimates locally before
|
||||
* sending and compacts first.
|
||||
*
|
||||
* We set a tiny context budget on a synthetic model id so modestly-
|
||||
* sized test content can trip the 95% threshold without churning
|
||||
* through ~120k tokens of fake text.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import { CacheFirstLoop } from "../src/loop.js";
|
||||
import { ImmutablePrefix } from "../src/memory.js";
|
||||
import { DEEPSEEK_CONTEXT_TOKENS } from "../src/telemetry.js";
|
||||
import type { ChatMessage } from "../src/types.js";
|
||||
|
||||
interface FakeResponseShape {
|
||||
content?: string;
|
||||
usage?: Record<string, number>;
|
||||
}
|
||||
|
||||
function fakeFetch(responses: FakeResponseShape[]): typeof fetch {
|
||||
let i = 0;
|
||||
return vi.fn(async () => {
|
||||
const resp = responses[i++] ?? responses[responses.length - 1]!;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: resp.content ?? "", tool_calls: undefined },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: resp.usage ?? {
|
||||
prompt_tokens: 100,
|
||||
completion_tokens: 10,
|
||||
total_tokens: 110,
|
||||
prompt_cache_hit_tokens: 0,
|
||||
prompt_cache_miss_tokens: 100,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
}) as unknown as typeof fetch;
|
||||
}
|
||||
|
||||
function makeClient(responses: FakeResponseShape[]) {
|
||||
return new DeepSeekClient({ apiKey: "sk-test", fetch: fakeFetch(responses) });
|
||||
}
|
||||
|
||||
describe("preflight context-size check", () => {
|
||||
const TEST_MODEL = "test-tiny-ctx";
|
||||
afterEach(() => {
|
||||
delete DEEPSEEK_CONTEXT_TOKENS[TEST_MODEL];
|
||||
});
|
||||
|
||||
it("auto-compacts when the estimated request exceeds 95% of the context window", async () => {
|
||||
// Tiny 1000-token budget so modest content can overflow.
|
||||
DEEPSEEK_CONTEXT_TOKENS[TEST_MODEL] = 1000;
|
||||
|
||||
const client = makeClient([{ content: "ack" }]);
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "be brief" }),
|
||||
stream: false,
|
||||
model: TEST_MODEL,
|
||||
});
|
||||
|
||||
// Seed the log with a PROPERLY paired (assistant.tool_calls ↔
|
||||
// tool) turn so buildMessages doesn't strip the tool result as
|
||||
// an orphan. The tool result is oversized enough to push the
|
||||
// preflight estimate past 95% of the 1000-token budget. Realistic
|
||||
// log-line content to avoid the tokenizer's BPE O(n²) pathological
|
||||
// path on pure-repeat inputs.
|
||||
loop.log.append({ role: "user", content: "prior request" });
|
||||
loop.log.append({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [{ id: "prior", type: "function", function: { name: "probe", arguments: "{}" } }],
|
||||
});
|
||||
loop.log.append({
|
||||
role: "tool",
|
||||
tool_call_id: "prior",
|
||||
content: "ERROR: step failed with trailing detail\n".repeat(500),
|
||||
});
|
||||
|
||||
const events: { role: string; content?: string }[] = [];
|
||||
for await (const ev of loop.step("follow-up")) {
|
||||
events.push({ role: ev.role, content: ev.content });
|
||||
}
|
||||
|
||||
// Preflight fires BEFORE the request — expect a warning that names
|
||||
// the preflight path and reports tokens saved from compaction.
|
||||
const warn = events.find((e) => e.role === "warning" && /^preflight:/.test(e.content ?? ""));
|
||||
expect(warn).toBeDefined();
|
||||
expect(warn!.content).toMatch(/pre-compacted \d+ tool result/);
|
||||
expect(warn!.content).toMatch(/saved [\d,]+ tokens/);
|
||||
|
||||
// Loop still completed normally (no forced summary, no error).
|
||||
expect(events.find((e) => e.role === "error")).toBeUndefined();
|
||||
const finals = events.filter((e) => e.role === "assistant_final");
|
||||
expect(finals.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does NOT fire when the estimate is comfortably under 95%", async () => {
|
||||
// Keep the real 131k budget — a normal conversation won't trip.
|
||||
const client = makeClient([{ content: "ok" }]);
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "be brief" }),
|
||||
stream: false,
|
||||
});
|
||||
|
||||
const events: { role: string; content?: string }[] = [];
|
||||
for await (const ev of loop.step("hi")) {
|
||||
events.push({ role: ev.role, content: ev.content });
|
||||
}
|
||||
|
||||
const anyPreflight = events.find(
|
||||
(e) => e.role === "warning" && /^preflight:/.test(e.content ?? ""),
|
||||
);
|
||||
expect(anyPreflight).toBeUndefined();
|
||||
});
|
||||
|
||||
it("warns (but does not block) when over 95% with nothing to compact", async () => {
|
||||
// Tiny budget AND a system prompt that alone overwhelms it. No
|
||||
// tool messages exist, so compact has nothing to shrink — the
|
||||
// preflight should still surface a warning so the failure isn't
|
||||
// mysterious; the request goes out regardless and DeepSeek decides.
|
||||
DEEPSEEK_CONTEXT_TOKENS[TEST_MODEL] = 500;
|
||||
const bulkyPrompt = "You are a careful assistant. ".repeat(300);
|
||||
|
||||
const client = makeClient([{ content: "ack" }]);
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: bulkyPrompt }),
|
||||
stream: false,
|
||||
model: TEST_MODEL,
|
||||
});
|
||||
|
||||
const events: { role: string; content?: string }[] = [];
|
||||
for await (const ev of loop.step("hi")) {
|
||||
events.push({ role: ev.role, content: ev.content });
|
||||
}
|
||||
|
||||
const warn = events.find((e) => e.role === "warning" && /^preflight:/.test(e.content ?? ""));
|
||||
expect(warn).toBeDefined();
|
||||
expect(warn!.content).toMatch(/nothing to auto-compact/);
|
||||
// Run still reaches the final step — the user sees the warning
|
||||
// and can react, but we don't short-circuit on our own.
|
||||
const finals = events.filter((e) => e.role === "assistant_final");
|
||||
expect(finals.length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,9 @@ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "no
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DeepSeekClient } from "../src/client.js";
|
||||
import { CacheFirstLoop } from "../src/loop.js";
|
||||
import { ImmutablePrefix } from "../src/memory.js";
|
||||
import {
|
||||
appendSessionMessage,
|
||||
deleteSession,
|
||||
@@ -103,4 +106,22 @@ describe("session persistence", () => {
|
||||
expect(existsSync(sessionsDir())).toBe(true);
|
||||
expect(existsSync(dirname(sessionPath("s")))).toBe(true);
|
||||
});
|
||||
|
||||
it("loop.appendAndPersist writes bang-style messages to the session file", () => {
|
||||
// Regression: before 0.5.14 the bang handler called loop.log.append which
|
||||
// only touched memory, so `!cmd` output was lost on session resume.
|
||||
const client = new DeepSeekClient({
|
||||
apiKey: "sk-test",
|
||||
fetch: (async () => new Response()) as any,
|
||||
});
|
||||
const loop = new CacheFirstLoop({
|
||||
client,
|
||||
prefix: new ImmutablePrefix({ system: "s" }),
|
||||
stream: false,
|
||||
session: "bang-persist",
|
||||
});
|
||||
loop.appendAndPersist({ role: "user", content: "[!ls]\n$ ls\n[exit 0]\nfile1 file2" });
|
||||
const reloaded = loadSessionMessages("bang-persist");
|
||||
expect(reloaded).toEqual([{ role: "user", content: "[!ls]\n$ ls\n[exit 0]\nfile1 file2" }]);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ToolRegistry } from "../src/tools.js";
|
||||
import {
|
||||
NeedsConfirmationError,
|
||||
detectShellOperator,
|
||||
formatCommandResult,
|
||||
injectPowerShellUtf8,
|
||||
isAllowed,
|
||||
@@ -48,6 +49,97 @@ describe("tokenizeCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectShellOperator", () => {
|
||||
it("flags a standalone pipe", () => {
|
||||
expect(detectShellOperator("dir /b *.ts | findstr foo")).toBe("|");
|
||||
});
|
||||
|
||||
it("flags standalone redirects (>, >>, <, <<)", () => {
|
||||
expect(detectShellOperator("echo hi > out.txt")).toBe(">");
|
||||
expect(detectShellOperator("echo hi >> out.txt")).toBe(">>");
|
||||
expect(detectShellOperator("sort < in.txt")).toBe("<");
|
||||
expect(detectShellOperator("cat << EOF")).toBe("<<");
|
||||
});
|
||||
|
||||
it("flags &&, ||, and single &", () => {
|
||||
expect(detectShellOperator("a && b")).toBe("&&");
|
||||
expect(detectShellOperator("a || b")).toBe("||");
|
||||
expect(detectShellOperator("long_task &")).toBe("&");
|
||||
});
|
||||
|
||||
it("flags fd-prefixed redirects", () => {
|
||||
expect(detectShellOperator("cmd 2> err.log")).toBe("2>");
|
||||
expect(detectShellOperator("cmd 2>> err.log")).toBe("2>>");
|
||||
expect(detectShellOperator("cmd 2>&1")).toBe("2>&1");
|
||||
expect(detectShellOperator("cmd &> all.log")).toBe("&>");
|
||||
});
|
||||
|
||||
it("flags redirects stuck to the next token", () => {
|
||||
expect(detectShellOperator("echo hi >out.txt")).toBe(">");
|
||||
expect(detectShellOperator("sort <in.txt")).toBe("<");
|
||||
});
|
||||
|
||||
it("does NOT flag operators inside double quotes", () => {
|
||||
expect(detectShellOperator('grep "a|b" file.txt')).toBeNull();
|
||||
expect(detectShellOperator('echo "a > b"')).toBeNull();
|
||||
expect(detectShellOperator('git commit -m "feat & fix"')).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT flag operators inside single quotes", () => {
|
||||
expect(detectShellOperator("grep 'a|b' file.txt")).toBeNull();
|
||||
expect(detectShellOperator("awk '{print $1 > $2}' file")).toBeNull();
|
||||
});
|
||||
|
||||
it("does NOT flag operator characters embedded in larger unquoted tokens", () => {
|
||||
// `--flag=1&2` is a single token; the `&` is a literal byte, not a
|
||||
// shell operator. Same for regex-style args passed without quotes.
|
||||
expect(detectShellOperator("cargo run -- --flag=1&2")).toBeNull();
|
||||
expect(detectShellOperator("grep a|b file")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for plain commands", () => {
|
||||
expect(detectShellOperator("git status")).toBeNull();
|
||||
expect(detectShellOperator("ls -la")).toBeNull();
|
||||
expect(detectShellOperator("")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the FIRST operator when multiple are present", () => {
|
||||
expect(detectShellOperator("a | b > c")).toBe("|");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runCommand pipe/redirect rejection", () => {
|
||||
let tmp: string;
|
||||
beforeEach(() => {
|
||||
tmp = mkdtempSync(join(tmpdir(), "reasonix-shell-pipe-"));
|
||||
});
|
||||
afterEach(() => {
|
||||
rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("rejects a pipe command with a helpful error", async () => {
|
||||
await expect(runCommand("echo hi | cat", { cwd: tmp })).rejects.toThrow(
|
||||
/shell operator "\|" is not supported/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a redirect command with a helpful error", async () => {
|
||||
await expect(runCommand("echo hi > out.txt", { cwd: tmp })).rejects.toThrow(
|
||||
/shell operator ">" is not supported/,
|
||||
);
|
||||
});
|
||||
|
||||
it("error message points the model at splitting the call", async () => {
|
||||
try {
|
||||
await runCommand("a && b", { cwd: tmp });
|
||||
} catch (err) {
|
||||
expect((err as Error).message).toMatch(/Split into separate run_command calls/i);
|
||||
return;
|
||||
}
|
||||
throw new Error("expected runCommand to reject");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAllowed", () => {
|
||||
it("matches exact prefix and prefix+args", () => {
|
||||
expect(isAllowed("git status")).toBe(true);
|
||||
|
||||
+209
-11
@@ -4,6 +4,7 @@ import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
SLASH_COMMANDS,
|
||||
detectSlashArgContext,
|
||||
handleSlash,
|
||||
parseSlash,
|
||||
suggestSlashCommands,
|
||||
@@ -103,6 +104,43 @@ describe("handleSlash", () => {
|
||||
expect(loop.model).toBe("deepseek-reasoner");
|
||||
});
|
||||
|
||||
it("/model soft-warns when id is not in the fetched catalog but still switches", () => {
|
||||
const loop = makeLoop();
|
||||
const r = handleSlash("model", ["deepseek-made-up"], loop, {
|
||||
models: ["deepseek-chat", "deepseek-reasoner"],
|
||||
});
|
||||
expect(loop.model).toBe("deepseek-made-up");
|
||||
expect(r.info).toMatch(/not in the fetched catalog/);
|
||||
expect(r.info).toMatch(/\/models/);
|
||||
});
|
||||
|
||||
it("/model with no arg and loaded list hints at available ids", () => {
|
||||
const loop = makeLoop();
|
||||
const r = handleSlash("model", [], loop, {
|
||||
models: ["deepseek-chat", "deepseek-reasoner"],
|
||||
});
|
||||
expect(r.info).toMatch(/deepseek-chat \| deepseek-reasoner/);
|
||||
});
|
||||
|
||||
it("/models renders the fetched catalog and marks the current one", () => {
|
||||
const loop = makeLoop();
|
||||
loop.configure({ model: "deepseek-reasoner" });
|
||||
const r = handleSlash("models", [], loop, {
|
||||
models: ["deepseek-chat", "deepseek-reasoner"],
|
||||
});
|
||||
expect(r.info).toMatch(/deepseek-chat/);
|
||||
expect(r.info).toMatch(/▸ deepseek-reasoner\s+\(current\)/);
|
||||
expect(r.info).toMatch(/\/model <id>/);
|
||||
});
|
||||
|
||||
it("/models triggers a refresh and reports fetching when the list hasn't loaded yet", () => {
|
||||
const loop = makeLoop();
|
||||
const refresh = vi.fn();
|
||||
const r = handleSlash("models", [], loop, { models: null, refreshModels: refresh });
|
||||
expect(refresh).toHaveBeenCalledOnce();
|
||||
expect(r.info).toMatch(/fetching \/models/);
|
||||
});
|
||||
|
||||
it("/harvest on/off toggles", () => {
|
||||
const loop = makeLoop();
|
||||
handleSlash("harvest", ["on"], loop);
|
||||
@@ -189,29 +227,43 @@ describe("handleSlash", () => {
|
||||
loop.log.append({ role: "tool", tool_call_id: "t1", content: "short result" });
|
||||
const r = handleSlash("compact", [], loop);
|
||||
expect(r.info).toMatch(/nothing to compact/);
|
||||
expect(r.info).toMatch(/tokens/);
|
||||
});
|
||||
|
||||
it("/compact shrinks oversized tool results and reports chars saved", () => {
|
||||
it("/compact shrinks oversized tool results and reports tokens saved", () => {
|
||||
const loop = makeLoop();
|
||||
loop.log.append({ role: "user", content: "read a big file" });
|
||||
loop.log.append({ role: "tool", tool_call_id: "t1", content: "Z".repeat(20_000) });
|
||||
// Realistic log-shape content — avoids the BPE O(n²) pathological
|
||||
// path on pure-repeat inputs while still tokenizing well over the
|
||||
// default 4000-token cap.
|
||||
loop.log.append({
|
||||
role: "tool",
|
||||
tool_call_id: "t1",
|
||||
content: "ERROR: line failed with detail and context\n".repeat(2000),
|
||||
});
|
||||
const r = handleSlash("compact", [], loop);
|
||||
expect(r.info).toMatch(/compacted 1 tool result/);
|
||||
expect(r.info).toMatch(/saved/);
|
||||
// After compaction the tool message length should be below the default 4k cap + envelope.
|
||||
expect(r.info).toMatch(/saved [\d,]+ tokens/);
|
||||
// After compaction the tool content should be much smaller than
|
||||
// the original 84KB, comfortably under the cap's char worst-case.
|
||||
const toolEntry = loop.log.entries.find((m) => m.role === "tool");
|
||||
expect(typeof toolEntry?.content).toBe("string");
|
||||
expect((toolEntry?.content as string).length).toBeLessThan(5_000);
|
||||
expect((toolEntry?.content as string).length).toBeLessThan(20_000);
|
||||
});
|
||||
|
||||
it("/compact honors a custom cap argument", () => {
|
||||
it("/compact honors a custom token cap argument", () => {
|
||||
const loop = makeLoop();
|
||||
loop.log.append({ role: "tool", tool_call_id: "t1", content: "A".repeat(10_000) });
|
||||
// 2000-char cap should shrink the 10k message
|
||||
const r = handleSlash("compact", ["2000"], loop);
|
||||
loop.log.append({
|
||||
role: "tool",
|
||||
tool_call_id: "t1",
|
||||
content: "INFO: event ok\n".repeat(1500),
|
||||
});
|
||||
// 500-token cap should shrink the message hard.
|
||||
const r = handleSlash("compact", ["500"], loop);
|
||||
expect(r.info).toMatch(/compacted 1/);
|
||||
expect(r.info).toMatch(/500 tokens each/);
|
||||
const toolEntry = loop.log.entries.find((m) => m.role === "tool");
|
||||
expect((toolEntry?.content as string).length).toBeLessThan(2_500);
|
||||
expect((toolEntry?.content as string).length).toBeLessThan(3_000);
|
||||
});
|
||||
|
||||
it("/help mentions /compact", () => {
|
||||
@@ -449,6 +501,111 @@ describe("handleSlash", () => {
|
||||
expect(r.info).toMatch(/\/tool/);
|
||||
});
|
||||
|
||||
describe("/keys", () => {
|
||||
it("lists the major keyboard shortcuts", () => {
|
||||
const r = handleSlash("keys", [], makeLoop());
|
||||
expect(r.info).toMatch(/Enter\s+submit/);
|
||||
expect(r.info).toMatch(/Shift\+Enter/);
|
||||
expect(r.info).toMatch(/Ctrl\+J/);
|
||||
expect(r.info).toMatch(/Esc\s+abort/);
|
||||
});
|
||||
|
||||
it("documents the three prompt prefixes", () => {
|
||||
const r = handleSlash("keys", [], makeLoop());
|
||||
expect(r.info).toMatch(/\/<name>/);
|
||||
expect(r.info).toMatch(/@<path>/);
|
||||
expect(r.info).toMatch(/!<cmd>/);
|
||||
});
|
||||
|
||||
it("mentions the pickers", () => {
|
||||
const r = handleSlash("keys", [], makeLoop());
|
||||
expect(r.info).toMatch(/[Pp]icker/);
|
||||
expect(r.info).toMatch(/Tab/);
|
||||
});
|
||||
});
|
||||
|
||||
it("/help mentions /keys", () => {
|
||||
const r = handleSlash("help", [], makeLoop());
|
||||
expect(r.info).toMatch(/\/keys/);
|
||||
});
|
||||
|
||||
describe("detectSlashArgContext", () => {
|
||||
it("returns null before the user commits to a slash name", () => {
|
||||
expect(detectSlashArgContext("/pr")).toBeNull();
|
||||
expect(detectSlashArgContext("/preset")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the command doesn't exist", () => {
|
||||
expect(detectSlashArgContext("/nope foo")).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on plain prose (no slash at all)", () => {
|
||||
expect(detectSlashArgContext("just some text")).toBeNull();
|
||||
});
|
||||
|
||||
it("activates enum picker for /preset", () => {
|
||||
const ctx = detectSlashArgContext("/preset fa");
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.kind).toBe("picker");
|
||||
expect(ctx!.spec.argCompleter).toEqual(["fast", "smart", "max"]);
|
||||
expect(ctx!.partial).toBe("fa");
|
||||
// Offset is the char index where the partial starts in the buffer.
|
||||
expect(ctx!.partialOffset).toBe("/preset ".length);
|
||||
});
|
||||
|
||||
it("activates model picker for /model", () => {
|
||||
const ctx = detectSlashArgContext("/model deep");
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.kind).toBe("picker");
|
||||
expect(ctx!.spec.argCompleter).toBe("models");
|
||||
});
|
||||
|
||||
it("activates enum picker for /plan in code mode", () => {
|
||||
const ctx = detectSlashArgContext("/plan o", true);
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.kind).toBe("picker");
|
||||
expect(ctx!.spec.argCompleter).toEqual(["on", "off"]);
|
||||
});
|
||||
|
||||
it("hides /plan outside code mode (command is contextual)", () => {
|
||||
expect(detectSlashArgContext("/plan on", false)).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces a hint-only row once the user types a space inside the partial", () => {
|
||||
// "/preset fast foo" — typed past the one enum slot.
|
||||
const ctx = detectSlashArgContext("/preset fast foo");
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.kind).toBe("hint");
|
||||
});
|
||||
|
||||
it("returns picker with empty partial when the user just hit space", () => {
|
||||
const ctx = detectSlashArgContext("/preset ");
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.kind).toBe("picker");
|
||||
expect(ctx!.partial).toBe("");
|
||||
});
|
||||
|
||||
it("returns hint for commands without a completer", () => {
|
||||
// `/commit "msg"` — free-form argument, no picker data.
|
||||
const ctx = detectSlashArgContext('/commit "', true);
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.kind).toBe("hint");
|
||||
expect(ctx!.spec.cmd).toBe("commit");
|
||||
});
|
||||
|
||||
it("still surfaces picker kind when partial exactly matches an enum value", () => {
|
||||
// Detector itself is kind-only — it doesn't know whether the
|
||||
// partial is a complete match. The App's slashArgMatches memo
|
||||
// is responsible for hiding the picker on exact match so Enter
|
||||
// submits; this test documents that the detector's contract is
|
||||
// "we're in picker mode" regardless of match state.
|
||||
const ctx = detectSlashArgContext("/preset smart");
|
||||
expect(ctx).not.toBeNull();
|
||||
expect(ctx!.kind).toBe("picker");
|
||||
expect(ctx!.partial).toBe("smart");
|
||||
});
|
||||
});
|
||||
|
||||
it("SLASH_COMMANDS registry contains every handler switch case", () => {
|
||||
// Spot-check a handful so the registry doesn't silently drift
|
||||
// from `handleSlash`. If a new case lands in handleSlash, it
|
||||
@@ -476,6 +633,7 @@ describe("handleSlash", () => {
|
||||
"commit",
|
||||
"plan",
|
||||
"apply-plan",
|
||||
"keys",
|
||||
]) {
|
||||
expect(names, `registry missing /${required}`).toContain(required);
|
||||
}
|
||||
@@ -592,12 +750,52 @@ describe("handleSlash", () => {
|
||||
pendingEditCount: 3,
|
||||
});
|
||||
expect(r.info).toMatch(/model\s+deepseek-/);
|
||||
expect(r.info).toMatch(/ctx\s+\d+\.?\d*k?\/\d+k/);
|
||||
expect(r.info).toMatch(/ctx\s+\d+\.?\d*K?\/\d+K/);
|
||||
expect(r.info).toMatch(/mcp\s+2 server\(s\)/);
|
||||
expect(r.info).toMatch(/session.*\(ephemeral|session\s+"/);
|
||||
expect(r.info).toMatch(/edits\s+3 pending/);
|
||||
});
|
||||
|
||||
it("/context breaks down tokens across system / tools / log, and flags the heaviest tool results", () => {
|
||||
const loop = makeLoop();
|
||||
// Seed a realistic log: two turns, one with a large tool result.
|
||||
loop.log.append({ role: "user", content: "list me the files" });
|
||||
loop.log.append({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: "c1",
|
||||
type: "function",
|
||||
function: { name: "list_directory", arguments: '{"path":"."}' },
|
||||
},
|
||||
],
|
||||
});
|
||||
loop.log.append({
|
||||
role: "tool",
|
||||
tool_call_id: "c1",
|
||||
name: "list_directory",
|
||||
content: "README.md\npackage.json\nsrc/\n".repeat(200),
|
||||
});
|
||||
loop.log.append({ role: "assistant", content: "here are the files" });
|
||||
loop.log.append({ role: "user", content: "now read package.json" });
|
||||
|
||||
const r = handleSlash("context", [], loop);
|
||||
expect(r.info).toMatch(/Next-request estimate/);
|
||||
expect(r.info).toMatch(/system prompt/);
|
||||
expect(r.info).toMatch(/tool specs/);
|
||||
expect(r.info).toMatch(/log \(all turns\)/);
|
||||
// Heaviest-tool section must surface the list_directory result.
|
||||
expect(r.info).toMatch(/Top tool results/);
|
||||
expect(r.info).toMatch(/list_directory/);
|
||||
});
|
||||
|
||||
it("/context handles an empty log without crashing", () => {
|
||||
const r = handleSlash("context", [], makeLoop());
|
||||
expect(r.info).toMatch(/Next-request estimate/);
|
||||
expect(r.info).not.toMatch(/Top tool results/);
|
||||
});
|
||||
|
||||
it("/status with pendingEditCount=0 hides the edits line", () => {
|
||||
const r = handleSlash("status", [], makeLoop(), { pendingEditCount: 0 });
|
||||
expect(r.info).not.toMatch(/pending/);
|
||||
|
||||
@@ -126,6 +126,13 @@ describe("registerSubagentTool", () => {
|
||||
expect(end.summary).toBe("done");
|
||||
expect(end.turns).toBe(1);
|
||||
expect(end.error).toBeUndefined();
|
||||
// 0.5.14: end event also carries cost, model, and aggregate usage
|
||||
// so the sink can write a subagent row to the usage log without
|
||||
// recomputing anything.
|
||||
expect(end.model).toBeTruthy();
|
||||
expect(end.usage).toBeDefined();
|
||||
expect(end.usage?.promptTokens).toBeGreaterThan(0);
|
||||
expect(end.costUsd).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("emits a progress event for each tool result inside the child loop", async () => {
|
||||
|
||||
+38
-10
@@ -1,6 +1,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Usage } from "../src/client.js";
|
||||
import { SessionStats, costUsd, inputCostUsd, outputCostUsd } from "../src/telemetry.js";
|
||||
import {
|
||||
DEEPSEEK_PRICING,
|
||||
SessionStats,
|
||||
costUsd,
|
||||
inputCostUsd,
|
||||
outputCostUsd,
|
||||
} from "../src/telemetry.js";
|
||||
|
||||
// Derive expected figures from the pricing table so the tests don't
|
||||
// re-bake stale constants every time DeepSeek updates the price sheet.
|
||||
// The `costUsd` formula under test is:
|
||||
// (hitT * hit + missT * miss + outT * out) / 1e6
|
||||
const CHAT = DEEPSEEK_PRICING["deepseek-chat"]!;
|
||||
|
||||
describe("Usage.cacheHitRatio", () => {
|
||||
it("computes hit ratio", () => {
|
||||
@@ -16,8 +28,10 @@ describe("costUsd", () => {
|
||||
it("applies DeepSeek pricing tiers", () => {
|
||||
const u = new Usage(1000, 100, 0, 800, 200);
|
||||
const c = costUsd("deepseek-chat", u);
|
||||
// (800 * 0.07 + 200 * 0.27 + 100 * 1.10) / 1e6
|
||||
expect(c).toBeCloseTo((800 * 0.07 + 200 * 0.27 + 100 * 1.1) / 1_000_000, 10);
|
||||
expect(c).toBeCloseTo(
|
||||
(800 * CHAT.inputCacheHit + 200 * CHAT.inputCacheMiss + 100 * CHAT.output) / 1_000_000,
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 0 for unknown model", () => {
|
||||
@@ -56,12 +70,16 @@ describe("SessionStats", () => {
|
||||
const stats = new SessionStats();
|
||||
stats.record(1, "deepseek-chat", new Usage(1000, 100, 1100, 800, 200));
|
||||
const s = stats.summary();
|
||||
// input: (800 * 0.07 + 200 * 0.27) / 1e6 = 0.000056 + 0.000054 = 0.00011
|
||||
// output: 100 * 1.10 / 1e6 = 0.00011
|
||||
expect(s.totalInputCostUsd).toBeCloseTo((800 * 0.07 + 200 * 0.27) / 1_000_000, 10);
|
||||
expect(s.totalOutputCostUsd).toBeCloseTo((100 * 1.1) / 1_000_000, 10);
|
||||
// `summary()` rounds USD figures to 6 decimals, so we match at 6 —
|
||||
// the raw formula at higher precision is exercised by the
|
||||
// `inputCostUsd` / `outputCostUsd` tests below.
|
||||
expect(s.totalInputCostUsd).toBeCloseTo(
|
||||
(800 * CHAT.inputCacheHit + 200 * CHAT.inputCacheMiss) / 1_000_000,
|
||||
6,
|
||||
);
|
||||
expect(s.totalOutputCostUsd).toBeCloseTo((100 * CHAT.output) / 1_000_000, 6);
|
||||
// Sum of input+output equals total (within rounding).
|
||||
expect(s.totalInputCostUsd + s.totalOutputCostUsd).toBeCloseTo(s.totalCostUsd, 9);
|
||||
expect(s.totalInputCostUsd + s.totalOutputCostUsd).toBeCloseTo(s.totalCostUsd, 6);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,13 +87,23 @@ describe("inputCostUsd / outputCostUsd", () => {
|
||||
it("input cost covers cache-hit + cache-miss but NOT completion", () => {
|
||||
const u = new Usage(1000, 100, 1100, 800, 200);
|
||||
const i = inputCostUsd("deepseek-chat", u);
|
||||
expect(i).toBeCloseTo((800 * 0.07 + 200 * 0.27) / 1_000_000, 10);
|
||||
expect(i).toBeCloseTo((800 * CHAT.inputCacheHit + 200 * CHAT.inputCacheMiss) / 1_000_000, 10);
|
||||
});
|
||||
|
||||
it("output cost covers completion only", () => {
|
||||
const u = new Usage(1000, 100, 1100, 800, 200);
|
||||
const o = outputCostUsd("deepseek-chat", u);
|
||||
expect(o).toBeCloseTo((100 * 1.1) / 1_000_000, 10);
|
||||
expect(o).toBeCloseTo((100 * CHAT.output) / 1_000_000, 10);
|
||||
});
|
||||
|
||||
it("chat and reasoner are unified at the same price", () => {
|
||||
// Post-unification (2026-04): DeepSeek charges identically for both
|
||||
// models. If this test starts failing, either DeepSeek reintroduced
|
||||
// tiered pricing (update the constants) or someone accidentally
|
||||
// edited only one entry — catch that before shipping.
|
||||
const chat = DEEPSEEK_PRICING["deepseek-chat"]!;
|
||||
const reasoner = DEEPSEEK_PRICING["deepseek-reasoner"]!;
|
||||
expect(reasoner).toEqual(chat);
|
||||
});
|
||||
|
||||
it("both return 0 for an unknown model", () => {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { countTokens, encode, estimateConversationTokens } from "../src/tokenizer.js";
|
||||
|
||||
describe("DeepSeek V3 tokenizer — golden cases", () => {
|
||||
// These IDs were captured from the pure-TS port running against the
|
||||
// bundled `data/deepseek-tokenizer.json.gz`. They match what DeepSeek's
|
||||
// official Python tokenizer produces (HF LlamaTokenizerFast on the
|
||||
// same tokenizer.json). If a case regresses, check that the data
|
||||
// file wasn't accidentally truncated or the pre_tokenizer Sequence
|
||||
// wasn't reordered.
|
||||
it("empty string is zero tokens", () => {
|
||||
expect(encode("")).toEqual([]);
|
||||
expect(countTokens("")).toBe(0);
|
||||
});
|
||||
|
||||
it("ASCII words tokenize compactly", () => {
|
||||
expect(encode("Hello!")).toEqual([19923, 3]);
|
||||
expect(encode("Hello, world!")).toEqual([19923, 14, 2058, 3]);
|
||||
});
|
||||
|
||||
it("common CJK collocation is a single token", () => {
|
||||
expect(encode("你好")).toEqual([30594]);
|
||||
});
|
||||
|
||||
it("CJK sentence splits on punctuation", () => {
|
||||
expect(encode("你好,世界!")).toEqual([30594, 303, 3427, 1175]);
|
||||
});
|
||||
|
||||
it("digit run is isolated by the \\p{N}{1,3} pre-tokenizer rule", () => {
|
||||
// "1 + 1 = 2" → numbers get their own tokens; spaces/operators
|
||||
// fold into byte-level pieces.
|
||||
expect(encode("1 + 1 = 2")).toEqual([19, 940, 223, 19, 438, 223, 20]);
|
||||
});
|
||||
|
||||
it("recognizes <think>/</think> as atomic added tokens", () => {
|
||||
// 128798 = <think>, 128799 = </think> per tokenizer.json added_tokens.
|
||||
const ids = encode("<think>reasoning here</think>");
|
||||
expect(ids[0]).toBe(128798);
|
||||
expect(ids[ids.length - 1]).toBe(128799);
|
||||
expect(ids.length).toBe(5);
|
||||
});
|
||||
|
||||
it("mixed English+CJK follows the right pre-tokenizer branches", () => {
|
||||
const ids = encode("mixed 中文 and english 混合");
|
||||
expect(ids).toEqual([122545, 223, 21134, 305, 33010, 223, 14769]);
|
||||
});
|
||||
|
||||
it("round-trips a code snippet at a reasonable compression ratio", () => {
|
||||
const src = "function add(a, b) { return a + b; }";
|
||||
const n = countTokens(src);
|
||||
// 37 chars → expected ~12-14 tokens for a ByteLevel BPE trained on
|
||||
// code. Assert a loose band so a future tokenizer refresh (vocab
|
||||
// shift ±5%) doesn't break the test suite.
|
||||
expect(n).toBeGreaterThanOrEqual(10);
|
||||
expect(n).toBeLessThanOrEqual(16);
|
||||
});
|
||||
|
||||
it("Chinese prose gets the expected ~0.6 tokens/char rate", () => {
|
||||
const text = "深度求索是一家专注于人工智能基础技术研究的公司";
|
||||
const n = countTokens(text);
|
||||
// 22 CJK chars → DeepSeek's doc claims ~0.6 tokens/char ≈ 13, our
|
||||
// V3 tokenizer's CJK compression is tighter; allow 8-16 as the
|
||||
// sanity range.
|
||||
expect(n).toBeGreaterThanOrEqual(8);
|
||||
expect(n).toBeLessThanOrEqual(16);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateConversationTokens", () => {
|
||||
it("sums content across roles", () => {
|
||||
const n = estimateConversationTokens([
|
||||
{ content: "you are helpful" },
|
||||
{ content: "你好" },
|
||||
{ content: "Hello!" },
|
||||
]);
|
||||
const manual = countTokens("you are helpful") + countTokens("你好") + countTokens("Hello!");
|
||||
expect(n).toBe(manual);
|
||||
});
|
||||
|
||||
it("counts tool_calls JSON bytes when present", () => {
|
||||
const withCalls = estimateConversationTokens([
|
||||
{
|
||||
content: null,
|
||||
tool_calls: [{ id: "c1", function: { name: "read_file", arguments: '{"path":"a.ts"}' } }],
|
||||
},
|
||||
]);
|
||||
// The tool_calls serialization itself has weight; should be > 0.
|
||||
expect(withCalls).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("ignores missing / empty content without crashing", () => {
|
||||
expect(
|
||||
estimateConversationTokens([{ content: null }, { content: "" }, { content: undefined }]),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performance sanity", () => {
|
||||
it("tokenizes 10k chars of typical mixed content in under 200 ms", () => {
|
||||
const block = "Hello world! 你好 deepseek ".repeat(400); // ~9,600 chars
|
||||
const t0 = performance.now();
|
||||
const n = countTokens(block);
|
||||
const t1 = performance.now();
|
||||
expect(n).toBeGreaterThan(1000);
|
||||
expect(t1 - t0).toBeLessThan(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Tool-call "ready" progress signal during streaming.
|
||||
*
|
||||
* Matches the 0.5.19 UX fix: as each tool_call's arguments stream into
|
||||
* valid JSON we emit `tool_call_delta` with an incrementing
|
||||
* `toolCallReadyCount`, so the UI can render "N ready · building call
|
||||
* M" instead of a context-free spinner. Dispatch still happens after
|
||||
* the stream ends — this is purely a visibility improvement.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeCompleteJson } from "../src/loop.js";
|
||||
|
||||
describe("looksLikeCompleteJson", () => {
|
||||
it("empty / whitespace → false", () => {
|
||||
expect(looksLikeCompleteJson("")).toBe(false);
|
||||
expect(looksLikeCompleteJson(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("partial JSON → false (the common streaming case)", () => {
|
||||
expect(looksLikeCompleteJson("{")).toBe(false);
|
||||
expect(looksLikeCompleteJson('{"path"')).toBe(false);
|
||||
expect(looksLikeCompleteJson('{"path": "foo.md", "content": "hel')).toBe(false);
|
||||
});
|
||||
|
||||
it("complete JSON object → true", () => {
|
||||
expect(looksLikeCompleteJson("{}")).toBe(true);
|
||||
expect(looksLikeCompleteJson('{"path": "foo.md", "content": "hello"}')).toBe(true);
|
||||
});
|
||||
|
||||
it("complete JSON array → true", () => {
|
||||
expect(looksLikeCompleteJson("[]")).toBe(true);
|
||||
expect(looksLikeCompleteJson('[{"a": 1}]')).toBe(true);
|
||||
});
|
||||
|
||||
it("primitive JSON values → true", () => {
|
||||
expect(looksLikeCompleteJson("true")).toBe(true);
|
||||
expect(looksLikeCompleteJson("42")).toBe(true);
|
||||
expect(looksLikeCompleteJson('"text"')).toBe(true);
|
||||
});
|
||||
|
||||
it("valid JSON with trailing whitespace → true", () => {
|
||||
expect(looksLikeCompleteJson('{"a":1}\n')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -145,6 +145,31 @@ describe("ToolRegistry", () => {
|
||||
expect(received).toEqual({ a: { b: { c: "hi" } } });
|
||||
});
|
||||
|
||||
it("truncates oversized results when maxResultChars is set", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
const big = "x".repeat(50_000);
|
||||
reg.register({
|
||||
name: "bloat",
|
||||
parameters: { type: "object", properties: {} },
|
||||
fn: () => big,
|
||||
});
|
||||
const out = await reg.dispatch("bloat", "{}", { maxResultChars: 1000 });
|
||||
expect(out.length).toBeLessThan(2000);
|
||||
expect(out).toMatch(/truncated/);
|
||||
});
|
||||
|
||||
it("passes results through unchanged when maxResultChars is absent", async () => {
|
||||
const reg = new ToolRegistry();
|
||||
const big = "x".repeat(50_000);
|
||||
reg.register({
|
||||
name: "bloat",
|
||||
parameters: { type: "object", properties: {} },
|
||||
fn: () => big,
|
||||
});
|
||||
const out = await reg.dispatch("bloat", "{}");
|
||||
expect(out.length).toBe(50_000);
|
||||
});
|
||||
|
||||
it("autoFlatten:false opts out", () => {
|
||||
const reg = new ToolRegistry({ autoFlatten: false });
|
||||
reg.register({
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_MAX_RESULT_TOKENS, truncateForModelByTokens } from "../src/mcp/registry.js";
|
||||
import { countTokens } from "../src/tokenizer.js";
|
||||
|
||||
describe("truncateForModelByTokens", () => {
|
||||
it("returns the string unchanged when it fits the budget", () => {
|
||||
const s = "Hello, world! This is a short string.";
|
||||
expect(truncateForModelByTokens(s, 1000)).toBe(s);
|
||||
});
|
||||
|
||||
it("returns the string unchanged when length <= budget (fast path)", () => {
|
||||
// Fast path: every token is ≥ 1 char, so length ≤ budget implies
|
||||
// tokens ≤ budget. No tokenize call should be needed.
|
||||
const s = "a".repeat(50);
|
||||
expect(truncateForModelByTokens(s, 100)).toBe(s);
|
||||
});
|
||||
|
||||
it("truncates when tokens exceed the budget", () => {
|
||||
// 2000 "hello " tokens → well above 100-token budget
|
||||
const s = "hello ".repeat(2000);
|
||||
const out = truncateForModelByTokens(s, 100);
|
||||
expect(out).not.toBe(s);
|
||||
// Output includes the head, a truncation marker, and a short tail
|
||||
expect(out).toMatch(/…truncated ~?\d+ tokens \(\d+ chars\)/);
|
||||
// Final token count stays reasonably close to (at or below) budget
|
||||
// plus the marker's ~48-token overhead — we allow a small slack
|
||||
// because the slice refinement is char-based and can overshoot by
|
||||
// a few tokens before the retry loop settles.
|
||||
expect(countTokens(out)).toBeLessThanOrEqual(250);
|
||||
});
|
||||
|
||||
it("caps CJK text at the same token footprint as English", () => {
|
||||
// 8000 chars of Chinese — roughly 5000-8000 tokens depending on
|
||||
// which chars; old char-based cap at 32000 would let this through
|
||||
// at 2× the token cost. Token cap pulls it down.
|
||||
const s = "你好世界".repeat(2000); // 8000 chars
|
||||
const out = truncateForModelByTokens(s, 500);
|
||||
expect(countTokens(out)).toBeLessThanOrEqual(700);
|
||||
expect(out).toMatch(/…truncated/);
|
||||
});
|
||||
|
||||
it("preserves both the head and the tail of the content", () => {
|
||||
const head = "START-OF-RESULT\n";
|
||||
const middle = "filler line\n".repeat(5000);
|
||||
const tail = "\nEND-OF-RESULT";
|
||||
const s = head + middle + tail;
|
||||
const out = truncateForModelByTokens(s, 500);
|
||||
// Head leading sentinel is preserved at the start
|
||||
expect(out.startsWith("START-OF-RESULT")).toBe(true);
|
||||
// Tail trailing sentinel survives via the short tail window
|
||||
expect(out.endsWith("END-OF-RESULT")).toBe(true);
|
||||
expect(out).toMatch(/…truncated/);
|
||||
});
|
||||
|
||||
it("returns the empty string when budget is zero or negative", () => {
|
||||
expect(truncateForModelByTokens("anything", 0)).toBe("");
|
||||
expect(truncateForModelByTokens("anything", -5)).toBe("");
|
||||
});
|
||||
|
||||
it("handles the empty string", () => {
|
||||
expect(truncateForModelByTokens("", 100)).toBe("");
|
||||
expect(truncateForModelByTokens("", 0)).toBe("");
|
||||
});
|
||||
|
||||
it("default token budget is 8000", () => {
|
||||
expect(DEFAULT_MAX_RESULT_TOKENS).toBe(8_000);
|
||||
});
|
||||
|
||||
it("truncated marker reports a positive dropped-tokens count", () => {
|
||||
const s = "A".repeat(40_000);
|
||||
const out = truncateForModelByTokens(s, 200);
|
||||
const match = /truncated ~?(\d+) tokens \((\d+) chars\)/.exec(out);
|
||||
expect(match).not.toBeNull();
|
||||
const droppedTokens = Number(match![1]);
|
||||
const droppedChars = Number(match![2]);
|
||||
expect(droppedTokens).toBeGreaterThan(0);
|
||||
expect(droppedChars).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -262,6 +262,102 @@ describe("renderDashboard", () => {
|
||||
expect(out).toContain("tracked since:");
|
||||
});
|
||||
|
||||
it("surfaces a subagent activity section when subagent records are present", () => {
|
||||
const agg = aggregateUsage(
|
||||
[
|
||||
{
|
||||
ts: 1_700_000_000_000,
|
||||
session: "s",
|
||||
model: "deepseek-chat",
|
||||
promptTokens: 500,
|
||||
completionTokens: 60,
|
||||
cacheHitTokens: 400,
|
||||
cacheMissTokens: 100,
|
||||
costUsd: 0.002,
|
||||
claudeEquivUsd: 0.02,
|
||||
kind: "subagent",
|
||||
subagent: {
|
||||
skillName: "explore",
|
||||
taskPreview: "find foo",
|
||||
toolIters: 3,
|
||||
durationMs: 2_500,
|
||||
},
|
||||
},
|
||||
{
|
||||
ts: 1_700_000_000_000,
|
||||
session: "s",
|
||||
model: "deepseek-chat",
|
||||
promptTokens: 200,
|
||||
completionTokens: 30,
|
||||
cacheHitTokens: 100,
|
||||
cacheMissTokens: 100,
|
||||
costUsd: 0.0015,
|
||||
claudeEquivUsd: 0.015,
|
||||
kind: "subagent",
|
||||
subagent: {
|
||||
skillName: "explore",
|
||||
taskPreview: "find bar",
|
||||
toolIters: 2,
|
||||
durationMs: 1_500,
|
||||
},
|
||||
},
|
||||
],
|
||||
{ now: 1_700_000_000_000 },
|
||||
);
|
||||
expect(agg.subagents?.total).toBe(2);
|
||||
expect(agg.subagents?.bySkill[0]?.skillName).toBe("explore");
|
||||
expect(agg.subagents?.bySkill[0]?.count).toBe(2);
|
||||
const out = renderDashboard(agg, "/tmp/fake.jsonl");
|
||||
expect(out).toContain("subagent activity:");
|
||||
expect(out).toContain("explore");
|
||||
expect(out).toContain("2 run");
|
||||
});
|
||||
|
||||
it("groups subagent records without a skillName under (adhoc)", () => {
|
||||
const agg = aggregateUsage(
|
||||
[
|
||||
{
|
||||
ts: 1_700_000_000_000,
|
||||
session: null,
|
||||
model: "deepseek-chat",
|
||||
promptTokens: 10,
|
||||
completionTokens: 2,
|
||||
cacheHitTokens: 0,
|
||||
cacheMissTokens: 10,
|
||||
costUsd: 0.001,
|
||||
claudeEquivUsd: 0.01,
|
||||
kind: "subagent",
|
||||
subagent: { taskPreview: "raw call", toolIters: 1, durationMs: 500 },
|
||||
},
|
||||
],
|
||||
{ now: 1_700_000_000_000 },
|
||||
);
|
||||
expect(agg.subagents?.bySkill[0]?.skillName).toBe("(adhoc)");
|
||||
});
|
||||
|
||||
it("includes subagent cost in the main buckets (it's real spend)", () => {
|
||||
const agg = aggregateUsage(
|
||||
[
|
||||
{
|
||||
ts: 1_700_000_000_000 - 60_000,
|
||||
session: "s",
|
||||
model: "deepseek-chat",
|
||||
promptTokens: 0,
|
||||
completionTokens: 0,
|
||||
cacheHitTokens: 0,
|
||||
cacheMissTokens: 0,
|
||||
costUsd: 0.5,
|
||||
claudeEquivUsd: 5,
|
||||
kind: "subagent",
|
||||
subagent: { skillName: "explore", taskPreview: "x", toolIters: 1, durationMs: 100 },
|
||||
},
|
||||
],
|
||||
{ now: 1_700_000_000_000 },
|
||||
);
|
||||
expect(agg.buckets[0]?.costUsd).toBeCloseTo(0.5);
|
||||
expect(agg.buckets[0]?.turns).toBe(1);
|
||||
});
|
||||
|
||||
it("renders em-dashes for empty buckets rather than $0.000000", () => {
|
||||
const agg = aggregateUsage(
|
||||
[
|
||||
|
||||
Reference in new issue
Block a user