ci / check (push) Waiting to run
Levels (models/schema.json levels): provider/effort.ts levelsOf/levelFields/ readLevel — `off` only with thinking_switch and sends enable_thinking false and no effort, `default` for a model that takes no efforts and sends nothing, a level not offered is read as the start level. The engine's effort is a level; /effort shows nothing for one level, a Thinking switch for [off, X], a list otherwise; the status bar follows. /v1/models' thinking_switch, efforts [] and capacity.parallel are read (lembas/webui.ts); session/capacity.ts counts parallel slots. Reasoning (context.md §8): an assistant message of the current turn carries reasoning_content (provider/common.ts turnReasoning, openai-chat.ts); step-boundary deliveries, nudges and the wrap-up are marked turn: false and do not start a turn. Paths (context.md §11): the <env> block names the project and, started below the root, that directory relative to it (assemble.ts envLines); instruction files and @ attachments are named from the root or as ~/…; file tools and bash resolve relative paths against the project root. The team rules (team/team.ts) and conformance runners for levels, reasoning, paths and team; the team runtime follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
77 lines
4.1 KiB
TypeScript
77 lines
4.1 KiB
TypeScript
// show (harness spec 3.0.0): a file opened in the user's viewer beside the conversation. A terminal
|
|
// has no such viewer, so the tool is offered only in a session a client opened over the link and
|
|
// only when that client said it can show files (`show: true`, harness/acp.md `_lembas/show`): the
|
|
// device reads the file and sends it, since the client cannot reach this disk.
|
|
import { statSync } from "node:fs"
|
|
import { basename, extname, relative } from "node:path"
|
|
import { z } from "zod"
|
|
import { described, TOOL_SPECS } from "../harness.ts"
|
|
import { absPath, defineTool, readText, showPath, ToolError } from "./tool.ts"
|
|
|
|
/** The most a file may be to be sent (acp.md: at most 1 MB; a larger one is refused before sending). */
|
|
export const SHOW_MAX_BYTES = 1024 * 1024
|
|
|
|
/** The answer when nothing can be shown (spec `unavailable`), word for word. */
|
|
export const SHOW_UNAVAILABLE = TOOL_SPECS.show!.unavailable!
|
|
|
|
/** What `_lembas/show` carries for one file. */
|
|
export interface ShowRequest {
|
|
path: string
|
|
kind: "markdown" | "code" | "text" | "html"
|
|
language?: string
|
|
line?: number
|
|
text: string
|
|
}
|
|
|
|
export type ShowReply = { shown: true } | { shown: false; reason: string }
|
|
|
|
// A known source extension is code, with its language; the list is the viewer's highlighters.
|
|
const LANGUAGES: Record<string, string> = {
|
|
ts: "typescript", tsx: "tsx", js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", json: "json", py: "python", rs: "rust", go: "go",
|
|
rb: "ruby", php: "php", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", cs: "csharp",
|
|
sh: "bash", bash: "bash", zsh: "bash", fish: "fish", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", toml: "toml",
|
|
xml: "xml", svg: "xml", lua: "lua", zig: "zig", dart: "dart", vue: "vue", svelte: "svelte",
|
|
}
|
|
|
|
/** The viewer's kind for a file, by its name (acp.md): `.md` markdown, `.html`/`.htm` html, a known
|
|
* source extension code, anything else text. */
|
|
export function showKind(path: string): { kind: ShowRequest["kind"]; language?: string } {
|
|
const ext = extname(path).slice(1).toLowerCase()
|
|
if (ext === "md" || ext === "markdown") return { kind: "markdown" }
|
|
if (ext === "html" || ext === "htm") return { kind: "html" }
|
|
const language = LANGUAGES[ext] ?? (basename(path) === "Dockerfile" ? "dockerfile" : basename(path) === "Makefile" ? "makefile" : undefined)
|
|
return language ? { kind: "code", language } : { kind: "text" }
|
|
}
|
|
|
|
export const showTool = defineTool({
|
|
...described("show"),
|
|
access: "show",
|
|
requires: "display",
|
|
schema: z.object({ path: z.string(), line: z.number().int().min(1).optional() }),
|
|
permission: (a, ctx) => {
|
|
const abs = absPath(a.path, ctx)
|
|
return { permission: "show", class: "interact", patterns: [showPath(abs, ctx)], paths: [abs] }
|
|
},
|
|
async run(a, ctx) {
|
|
// Offered with a display; one that went away since answers the spec's sentence.
|
|
if (!ctx.show) return { output: SHOW_UNAVAILABLE, isError: true }
|
|
const abs = absPath(a.path, ctx)
|
|
let size: number
|
|
try {
|
|
const st = statSync(abs)
|
|
if (!st.isFile()) throw new ToolError(st.isDirectory() ? `Is a directory: ${abs}` : `${showPath(abs, ctx)} is not a file.`)
|
|
size = st.size
|
|
} catch (e) {
|
|
if (e instanceof ToolError) throw e
|
|
throw new ToolError(`No such file or directory: ${abs}`)
|
|
}
|
|
if (size > SHOW_MAX_BYTES) throw new ToolError(`${showPath(abs, ctx)} is ${Math.round(size / 1024)} KB; at most 1 MB can be shown. Give the user the path instead.`)
|
|
const text = await readText(abs)
|
|
if (text.includes("\u0000")) throw new ToolError(`${showPath(abs, ctx)} is not a text file; it cannot be shown. Give the user the path instead.`)
|
|
const rel = relative(ctx.root, abs) || basename(abs)
|
|
const reply = await ctx.show({ path: rel, ...showKind(abs), ...(a.line ? { line: a.line } : {}), text }, ctx.callId)
|
|
if (!reply.shown) return { output: `Not shown: ${reply.reason}`, isError: true }
|
|
return { output: `Shown to the user: ${showPath(abs, ctx)}${a.line ? ` at line ${a.line}` : ""}.`, title: showPath(abs, ctx) }
|
|
},
|
|
})
|