LLeMbas CLI 1.0.0
ci / check (push) Canceled after 0s

The first public release of LLeMbas CLI: a terminal coding agent and project manager for any LLM
API, with permission modes, git snapshots, memory and skills, knowledge bases, MCP, voice, and a
link to a LLeMbas instance whose web UI can work its sessions too. Signed Linux binaries for x64
and arm64.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-10-09 21:59:03 +00:00
commit f9bad01ed7
355 files changed
+47028

No files matched your search

+175
View File
@@ -0,0 +1,175 @@
// apply_patch: the "*** Begin Patch" format (OpenCode tool/apply_patch.txt, parser in patch.ts),
// which GPT-family models are trained on, or a unified diff (unidiff.ts, from LLeMbas's file_edit),
// which every model has seen. Context lines locate each change, so unlike edit it does not demand
// the file was read first — a wrong context is refused instead.
import { mkdir, rm, stat } from "node:fs/promises"
import { dirname } from "node:path"
import { createTwoFilesPatch } from "diff"
import { z } from "zod"
import { derive, joinBom, parse, type Hunk } from "./patch.ts"
import { applyUnified, isUnified, parseUnified, UnidiffError, type UniFile } from "./unidiff.ts"
import { straySkillNote } from "../skill/index.ts"
import { absPath, defineTool, readText, showPath, ToolError, type ToolContext } from "./tool.ts"
import { diffStat } from "./write.ts"
import { described } from "../harness.ts"
function hunksOf(text: string): Hunk[] {
try {
return [...parse(text)]
} catch {
return []
}
}
function uniFiles(text: string): UniFile[] {
try {
return parseUnified(text)
} catch {
return []
}
}
/** The files a patch names, as given: for the permission request, before anything is read. */
function namedFiles(a: { patch: string; path?: string }): string[] {
if (isUnified(a.patch)) return [...new Set(uniFiles(a.patch).map((f) => f.path ?? a.path).filter((p): p is string => p !== undefined))]
return hunksOf(a.patch).flatMap((h) => (h.type === "update" && h.movePath ? [h.path, h.movePath] : [h.path]))
}
type Op = { kind: "write"; path: string; content: string; before: string } | { kind: "delete"; path: string; before: string }
function opsOf(original: Map<string, string | null>, current: Map<string, string | null>): Op[] {
const ops: Op[] = []
for (const [path, now] of current) {
const before = original.get(path) ?? null
if (now === before) continue
if (now === null) ops.push({ kind: "delete", path, before: before! })
else ops.push({ kind: "write", path, content: now, before: before ?? "" })
}
return ops
}
/** A unified diff: each file's hunks applied to it in memory, all or nothing. */
async function planUnified(a: { patch: string; path?: string }, ctx: ToolContext): Promise<Op[]> {
let files: UniFile[]
try {
files = parseUnified(a.patch)
} catch (e) {
throw new ToolError(`${(e as Error).message} Nothing was applied.`)
}
const original = new Map<string, string | null>()
const current = new Map<string, string | null>()
for (const f of files) {
const path = f.path ?? a.path
if (!path) throw new ToolError("The diff names no file: give `path`, or start it with --- a/<file> and +++ b/<file> lines. Nothing was applied.")
const abs = absPath(path, ctx)
if (!current.has(abs)) {
const text = await stat(abs).then(() => readText(abs), () => null)
original.set(abs, text)
current.set(abs, text)
}
const text = current.get(abs)!
if (f.remove) {
if (text === null) throw new ToolError(`${path} does not exist. Nothing was applied.`)
current.set(abs, null)
continue
}
if (text === null && !f.create) throw new ToolError(`${path} does not exist; a diff that creates it starts with --- /dev/null. Nothing was applied.`)
if (text !== null && f.create) throw new ToolError(`${path} already exists; diff it against its current content instead. Nothing was applied.`)
// A byte-order mark is not part of the first line the hunk's context names.
const bom = text?.startsWith("\uFEFF") ?? false
try {
current.set(abs, joinBom(applyUnified(bom ? text!.slice(1) : (text ?? ""), f.hunks), bom))
} catch (e) {
if (e instanceof UnidiffError) throw new ToolError(`${path}: ${e.message}`)
throw e
}
}
return opsOf(original, current)
}
async function planPatch(a: { patch: string; path?: string }, ctx: ToolContext): Promise<Op[]> {
if (isUnified(a.patch)) return planUnified(a, ctx)
let hunks: Hunk[]
try {
hunks = [...parse(a.patch)]
} catch (e) {
throw new ToolError(`The patch could not be parsed: ${(e as Error).message}`)
}
// Work everything out first; write nothing until every hunk is known to apply. Each file's
// content is followed through the patch, so a file named twice gets both changes, in order.
const original = new Map<string, string | null>() // on disk before; null: did not exist
const current = new Map<string, string | null>()
const load = async (abs: string) => {
if (!current.has(abs)) {
const text = await stat(abs).then(() => readText(abs), () => null)
original.set(abs, text)
current.set(abs, text)
}
return current.get(abs)!
}
for (const h of hunks) {
const abs = absPath(h.path, ctx)
const text = await load(abs)
if (h.type === "add") {
if (text !== null) throw new ToolError(`${h.path} already exists; use Update File to change it. Nothing was applied.`)
current.set(abs, h.contents.endsWith("\n") ? h.contents : h.contents + "\n")
} else if (h.type === "delete") {
if (text === null) throw new ToolError(`${h.path} does not exist. Nothing was applied.`)
current.set(abs, null)
} else {
if (text === null) throw new ToolError(`${h.path} does not exist. Nothing was applied.`)
let next
try {
next = derive(h.path, h.chunks, text)
} catch (e) {
throw new ToolError(`${(e as Error).message}\nRead the file again and write the hunk from what is there. Nothing was applied.`)
}
const to = h.movePath ? absPath(h.movePath, ctx) : abs
if (to !== abs) {
if ((await load(to)) !== null) throw new ToolError(`${h.movePath} already exists; a move does not overwrite a file. Nothing was applied.`)
current.set(abs, null)
}
current.set(to, joinBom(next.content, next.bom))
}
}
return opsOf(original, current)
}
export const applyPatchTool = defineTool({
...described("apply_patch"),
access: "edit",
schema: z.object({ patch: z.string().min(1), path: z.string().optional() }),
permission: (a, ctx) => {
const files = namedFiles(a).map((p) => absPath(p, ctx))
return { permission: "edit", class: "write", patterns: files.length ? files.map((f) => showPath(f, ctx)) : ["*"], paths: files }
},
async preview(a, ctx) {
return (await planPatch(a, ctx))
.map((op) => {
const shown = showPath(op.path, ctx)
return createTwoFilesPatch(shown, op.kind === "delete" ? "/dev/null" : shown, op.before, op.kind === "delete" ? "" : op.content, "", "", { context: 3 })
})
.join("\n")
},
async run(a, ctx) {
const ops = await planPatch(a, ctx)
const patches: string[] = []
for (const op of ops) {
const shown = showPath(op.path, ctx)
if (op.kind === "delete") {
await rm(op.path)
patches.push(createTwoFilesPatch(shown, "/dev/null", op.before, "", "", "", { context: 3 }))
continue
}
await mkdir(dirname(op.path), { recursive: true })
await Bun.write(op.path, op.content)
ctx.readFiles.add(op.path)
ctx.fileStamps.set(op.path, (await stat(op.path)).mtimeMs)
patches.push(createTwoFilesPatch(shown, shown, op.before, op.content, "", "", { context: 3 }))
}
const all = patches.join("\n")
const { added, removed } = diffStat(all)
const names = ops.map((o) => showPath(o.path, ctx)).join(", ")
return { output: `Applied to ${names} (+${added} −${removed}).${straySkillNote(ops.filter((o) => o.kind === "write").map((o) => o.path), ctx)}`, title: `${names} +${added} −${removed}`, meta: { diff: all } }
},
})
+147
View File
@@ -0,0 +1,147 @@
import { spawn } from "node:child_process"
import { z } from "zod"
import { getJob, killJob, listJobs, startJob } from "./jobs.ts"
import { absPath, defineTool, showPath, ToolError, truncate } from "./tool.ts"
import { duration } from "../duration.ts"
import { described } from "../harness.ts"
// git run by the model reads a repository's own config, which can name programs to run (an external
// diff, an fsmonitor, a pager) — and "git diff", "git show", "git status" are allowed unasked. These
// override that for every git started here; an embedded bare repository is not discovered either.
const GIT_SAFE: [string, string][] = [
["core.fsmonitor", "false"],
["diff.external", ""],
["core.pager", "cat"],
["safe.bareRepository", "explicit"],
]
const ENV = {
...process.env,
// A command can tell it runs under the agent.
LEMBAS: "1",
GIT_PAGER: "cat",
PAGER: "cat",
GIT_EDITOR: "true",
EDITOR: "true",
GIT_CONFIG_COUNT: String(GIT_SAFE.length),
...Object.fromEntries(GIT_SAFE.flatMap(([k, v], i) => [[`GIT_CONFIG_KEY_${i}`, k], [`GIT_CONFIG_VALUE_${i}`, v]])),
}
export const bashTool = defineTool({
...described("bash"),
purpose: "description",
access: "bash",
schema: z.object({
command: z.string(),
description: z.string().optional(),
cwd: z.string().optional(),
timeout: z.number().int().positive().max(600_000).optional(),
background: z.boolean().optional(),
}),
permission: (a, ctx) => {
const cwd = absPath(a.cwd ?? ".", ctx)
return { permission: "bash", class: "execute", patterns: [a.command], command: a.command, paths: [cwd] }
},
async run(a, ctx) {
const cwd = absPath(a.cwd ?? ".", ctx)
if (a.background) {
const job = startJob(a.command, cwd, ENV)
await Bun.sleep(300)
const early = job.output
job.read = early.length
return {
output: `Started in the background as ${job.id} (pid ${job.child.pid}).${job.exit !== undefined ? ` It has already exited with ${job.exit}.` : ""}${early ? `\nFirst output:\n${truncate(early, 4000)}` : ""}\nRead more with bash_output, stop it with bash_kill.`,
title: `${a.description ?? a.command.split("\n")[0]!.slice(0, 80)} · background ${job.id}`,
meta: { job: job.id },
}
}
const timeout = a.timeout ?? ctx.bashTimeoutMs
// Own process group, so a timeout or cancel takes the whole tree down.
const child = spawn("bash", ["-c", `exec 2>&1\n${a.command}`], {
cwd,
detached: true,
stdio: ["ignore", "pipe", "pipe"],
env: ENV,
})
let out = ""
child.stdout!.on("data", (d: Buffer) => {
if (out.length < 2_000_000) out += d.toString()
})
let killed = ""
const kill = (why: string) => {
killed = why
try {
process.kill(-child.pid!, "SIGTERM")
setTimeout(() => {
try {
process.kill(-child.pid!, "SIGKILL")
} catch {}
}, 2000)
} catch {}
}
const timer = setTimeout(() => kill(`timed out after ${duration(timeout, true)}`), timeout)
const onAbort = () => kill("cancelled")
ctx.signal.addEventListener("abort", onAbort, { once: true })
const code: number | null = await new Promise((res) => child.on("close", (c) => res(c)))
clearTimeout(timer)
ctx.signal.removeEventListener("abort", onAbort)
const status = killed ? killed : `exit ${code ?? "?"}`
return {
output: `${truncate(out.trimEnd()) || "(no output)"}\n\n[${status}]`,
title: `${a.description ?? a.command.split("\n")[0]!.slice(0, 80)} · ${status}`,
isError: killed !== "" || code !== 0,
meta: { exit: code, cwd: showPath(cwd, ctx) },
}
},
})
export const bashOutputTool = defineTool({
...described("bash_output"),
purpose: false,
access: "bash_output",
schema: z.object({ id: z.string(), wait: z.number().min(0).max(60).optional() }),
permission: () => ({ permission: "bash_output", class: "read", patterns: ["*"] }),
async run(a, ctx) {
const job = getJob(a.id)
if (!job) throw new ToolError(`No background job ${a.id}.`)
const until = Date.now() + (a.wait ?? 0) * 1000
while (Date.now() < until && job.exit === undefined && job.output.length === job.read && !ctx.signal.aborted) await Bun.sleep(200)
const fresh = job.output.slice(job.read)
job.read = job.output.length
const state = job.exit === undefined ? "still running" : `exited with ${job.exit}`
return { output: `${truncate(fresh.trimEnd()) || "(no new output)"}\n\n[${job.id} ${state}]`, title: `${job.id} · ${state}` }
},
})
export const bashKillTool = defineTool({
...described("bash_kill"),
purpose: false,
access: "bash_kill",
schema: z.object({ id: z.string() }),
permission: () => ({ permission: "bash_kill", class: "execute", patterns: ["*"] }),
async run(a) {
const job = getJob(a.id)
if (!job) throw new ToolError(`No background job ${a.id}.`)
if (job.exit !== undefined) return { output: `${job.id} had already exited with ${job.exit}.`, title: `${job.id} · already exited` }
killJob(job)
return { output: `Stopped ${job.id} (${job.command.split("\n")[0]}).`, title: `${job.id} · stopped` }
},
})
export const bashListTool = defineTool({
...described("bash_list"),
purpose: false,
access: "bash_output",
schema: z.object({}),
permission: () => ({ permission: "bash_output", class: "read", patterns: ["*"] }),
async run() {
const jobs = listJobs()
if (!jobs.length) return { output: "No background jobs.", title: "0 jobs" }
const now = Date.now()
const lines = jobs.map((j) => {
const state = j.exit === undefined ? "running" : `exited with ${j.exit}`
const unread = j.output.length > j.read ? " · new output" : ""
return `${j.id} · ${state} · ${duration(now - j.started, true)}${unread} · ${j.command.split("\n")[0]!.slice(0, 120)}`
})
return { output: lines.join("\n"), title: `${jobs.length} jobs` }
},
})
+48
View File
@@ -0,0 +1,48 @@
import { stat } from "node:fs/promises"
import { createTwoFilesPatch } from "diff"
import { z } from "zod"
import { replace } from "./replace-text.ts"
import { absPath, defineTool, readText, showPath, ToolError, type ToolContext } from "./tool.ts"
import { diffStat, guardExisting } from "./write.ts"
import { described } from "../harness.ts"
async function planEdit(a: { path: string; old: string; new: string; all?: boolean }, ctx: ToolContext) {
const abs = absPath(a.path, ctx)
const display = showPath(abs, ctx)
if (!(await guardExisting(abs, display, ctx))) throw new ToolError(`${display} does not exist. Use write to create it.`)
const raw = await readText(abs)
const crlf = raw.includes("\r\n")
const before = crlf ? raw.replaceAll("\r\n", "\n") : raw
try {
return { abs, display, before, crlf, after: replace(before, a.old.replaceAll("\r\n", "\n"), a.new.replaceAll("\r\n", "\n"), a.all ?? false) }
} catch (e) {
throw new ToolError(`${display}: ${(e as Error).message}`)
}
}
export const editTool = defineTool({
...described("edit"),
access: "edit",
schema: z.object({
path: z.string(),
old: z.string(),
new: z.string(),
all: z.boolean().optional(),
}),
permission: (a, ctx) => {
const abs = absPath(a.path, ctx)
return { permission: "edit", class: "write", patterns: [showPath(abs, ctx)], paths: [abs] }
},
async preview(a, ctx) {
const { display, before, after } = await planEdit(a, ctx)
return createTwoFilesPatch(display, display, before, after, "", "", { context: 3 })
},
async run(a, ctx) {
const { abs, display, before, after, crlf } = await planEdit(a, ctx)
await Bun.write(abs, crlf ? after.replaceAll("\n", "\r\n") : after)
ctx.fileStamps.set(abs, (await stat(abs)).mtimeMs)
const patch = createTwoFilesPatch(display, display, before, after, "", "", { context: 3 })
const { added, removed } = diffStat(patch)
return { output: `Edited ${display} (+${added} −${removed}).`, title: `${display} +${added} −${removed}`, meta: { diff: patch } }
},
})
+86
View File
@@ -0,0 +1,86 @@
// Background commands started by `bash` with background: true. Each runs in its own process group;
// all of them are killed when LLeMbas CLI exits, however it exits.
import { spawn, type ChildProcess } from "node:child_process"
export interface Job {
id: string
command: string
child: ChildProcess
output: string
/** How much of `output` the model has already been given. */
read: number
exit?: number | null
started: number
}
const MAX_BUFFER = 2_000_000
const exitListeners = new Set<(job: Job) => void>()
/** Be told when a background job ends — how a session is woken by one (after LLeMbas's
* jobs). Returns the way to stop listening. */
export function onJobExit(fn: (job: Job) => void): () => void {
exitListeners.add(fn)
return () => exitListeners.delete(fn)
}
/** What the model is told when a job it started ends: which, how, and the output it has not read. */
export function jobNote(job: Job, maxChars = 4000): string {
const unread = job.output.slice(job.read)
const tail = unread.length > maxChars ? `…${unread.slice(-maxChars)}` : unread
const how = job.exit === null ? "was killed" : `exited with ${job.exit}`
const secs = Math.round((Date.now() - job.started) / 1000)
return `Background job ${job.id} (${job.command.split("\n")[0]!.slice(0, 120)}) ${how} after ${secs}s.${tail.trim() ? `\nOutput you had not read:\n${tail}` : " It printed nothing you had not read."}`
}
const jobs = new Map<string, Job>()
let seq = 0
export function startJob(command: string, cwd: string, env: Record<string, string | undefined>): Job {
const child = spawn("bash", ["-c", `exec 2>&1\n${command}`], { cwd, detached: true, stdio: ["ignore", "pipe", "pipe"], env })
const job: Job = { id: `job${++seq}`, command, child, output: "", read: 0, started: Date.now() }
child.stdout!.on("data", (d: Buffer) => {
job.output += d.toString()
// Keep the tail when a long-running job talks a lot; the model only ever reads what is new.
if (job.output.length > MAX_BUFFER) {
const cut = job.output.length - MAX_BUFFER
job.output = job.output.slice(cut)
job.read = Math.max(0, job.read - cut)
}
})
child.on("close", (code) => {
job.exit = code
for (const fn of exitListeners) {
try {
fn(job)
} catch {}
}
})
jobs.set(job.id, job)
// Finished jobs beyond the last 50 are forgotten (each keeps up to MAX_BUFFER of output).
const done = [...jobs.values()].filter((j) => j.exit !== undefined)
for (const j of done.slice(0, Math.max(0, done.length - 50))) jobs.delete(j.id)
return job
}
export function getJob(id: string): Job | undefined {
return jobs.get(id)
}
export function killJob(job: Job) {
try {
process.kill(-job.child.pid!, "SIGTERM")
setTimeout(() => {
try {
process.kill(-job.child.pid!, "SIGKILL")
} catch {}
}, 2000).unref()
} catch {}
}
export function listJobs(): Job[] {
return [...jobs.values()]
}
function killAll() {
for (const j of jobs.values()) if (j.exit === undefined) killJob(j)
}
process.once("exit", killAll)
+142
View File
@@ -0,0 +1,142 @@
// The library's tools: notes the agent keeps, and the knowledge bases the user filled.
// Wording of knowledge_search after LLeMbas's tool.knowledge prompt (© Jaroslav Beneš,
// MIT): look there before the web, and read the document before answering from it.
import { z } from "zod"
import { SNIPPET_CHARS } from "../library/store.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
const MAX_GET = 40_000
const MAX_NOTE = 20_000
function lib(ctx: ToolContext) {
if (!ctx.library) throw new ToolError("The library is not available here.")
return ctx.library
}
// Notes are three tools (harness spec v1): reading and writing are never one tool, so a reviewer
// or a read-only helper is given the readers and nothing else.
function noteScopes(ctx: ToolContext) {
const L = lib(ctx)
return { L, scopes: ["global", ...(L.project ? [L.project] : [])] }
}
const scopeLabel = (scope: string) => (scope === "global" ? "global" : "project")
function ownNote(ctx: ToolContext, id: number) {
const { L, scopes } = noteScopes(ctx)
const n = L.lib.note(id)
if (!n || !scopes.includes(n.scope)) throw new ToolError(`There is no note ${id} here.`)
return { L, n }
}
export const notesSearchTool = defineTool({
...described("notes_search"),
access: "notes",
purpose: false,
schema: z.object({
query: z.string().optional(),
}),
permission: (a) => ({ permission: "notes", class: "read", patterns: [a.query || "*"] }),
async run(a, ctx) {
const { L, scopes } = noteScopes(ctx)
const found = L.lib.notes(scopes, a.query, 15)
if (!found.length) return { output: a.query ? `No note matches "${a.query}".` : "No notes yet.", title: "0 notes" }
return { output: found.map((n) => `[${n.id}] ${n.title} (${scopeLabel(n.scope)}, ${new Date(n.updated).toISOString().slice(0, 10)})`).join("\n") + "\n\nRead one with note_view.", title: `${found.length} notes` }
},
})
export const noteViewTool = defineTool({
...described("note_view"),
access: "notes",
purpose: false,
schema: z.object({
id: z.number().int(),
}),
permission: (a) => ({ permission: "notes", class: "read", patterns: [String(a.id)] }),
async run(a, ctx) {
const { n } = ownNote(ctx, a.id)
return { output: `# ${n.title}\n(${scopeLabel(n.scope)}, updated ${new Date(n.updated).toISOString().slice(0, 16).replace("T", " ")})\n\n${n.body}`, title: n.title }
},
})
export const noteManageTool = defineTool({
...described("note_manage"),
access: "notes",
purpose: false,
schema: z.object({
action: z.enum(["create", "edit", "delete"]),
id: z.number().int().optional(),
title: z.string().max(200).optional(),
body: z.string().optional(),
scope: z.enum(["project", "global"]).optional(),
}),
permission: (a) => ({ permission: "notes", class: "interact", patterns: [a.action] }),
async run(a, ctx) {
const { L } = noteScopes(ctx)
if (a.action === "create") {
if (!a.title?.trim() || !a.body?.trim()) throw new ToolError("create needs a title and a body.")
if (a.body.length > MAX_NOTE) throw new ToolError(`A note is at most ${MAX_NOTE} characters; this one is ${a.body.length}.`)
const scope = a.scope === "global" || !L.project ? "global" : L.project
const n = L.lib.addNote(scope, a.title.trim(), a.body)
return { output: `Saved note ${n.id} (${scopeLabel(scope)}).`, title: `+ ${n.title}` }
}
if (a.id === undefined) throw new ToolError(`${a.action} needs the note's id (notes_search finds it).`)
const { n } = ownNote(ctx, a.id)
if (a.action === "delete") {
L.lib.deleteNote(n.id)
return { output: `Deleted note ${n.id}.`, title: `− ${n.title}` }
}
if (a.body !== undefined && a.body.length > MAX_NOTE) throw new ToolError(`A note is at most ${MAX_NOTE} characters.`)
L.lib.editNote(n.id, { title: a.title?.trim() || undefined, body: a.body })
return { output: `Changed note ${n.id}.`, title: `~ ${a.title ?? n.title}` }
},
})
export const knowledgeSearchTool = defineTool({
...described("knowledge_search"),
access: "knowledge",
purpose: false,
requires: "knowledge",
schema: z.object({
query: z.string().min(1),
base: z.string().optional(),
}),
permission: (a) => ({ permission: "knowledge", class: "read", patterns: [a.base ?? "*"] }),
async run(a, ctx) {
const L = lib(ctx)
const bases = a.base ? [a.base] : L.bases
if (a.base && L.bases && !L.bases.includes(a.base)) throw new ToolError(`"${a.base}" is not one of this project's knowledge bases: ${L.bases.join(", ")}`)
let note = ""
let hits
try {
hits = await L.lib.search(a.query, { bases, embedder: L.embedder, signal: ctx.signal })
} catch (e) {
// The embedding model is away: words alone, and said.
note = `\n\n(searched by words only — the embedding model failed: ${(e as Error).message.split("\n")[0]})`
hits = await L.lib.search(a.query, { bases, signal: ctx.signal })
}
if (!hits.length) return { output: `Nothing in the knowledge bases matches "${a.query}".${note}`, title: "0 found" }
const out = hits.map((h) => `[${h.id}] ${h.title} (${h.base}${h.source !== h.title ? ` · ${h.source}` : ""})\n${h.snippet.slice(0, SNIPPET_CHARS)}`).join("\n\n---\n\n")
return { output: `${out}\n\nRead a document whole with knowledge_get(id).${note}`, title: `${hits.length} found` }
},
})
export const knowledgeGetTool = defineTool({
...described("knowledge_get"),
access: "knowledge",
purpose: false,
requires: "knowledge",
schema: z.object({ id: z.number().int(), offset: z.number().int().min(0).optional() }),
permission: () => ({ permission: "knowledge", class: "read", patterns: ["*"] }),
async run(a, ctx) {
const L = lib(ctx)
const d = L.lib.document(a.id)
if (!d || (L.bases && !L.bases.includes(d.base))) throw new ToolError(`There is no document ${a.id} in this project's knowledge bases.`)
if (d.error && !d.text) return { output: `[${d.id}] ${d.title}: no text — ${d.error}`, title: d.title, isError: true }
const from = a.offset ?? 0
const part = d.text.slice(from, from + MAX_GET)
const rest = d.text.length - from - part.length
const tail = rest > 0 ? `\n\n[…${rest.toLocaleString("en")} more characters: knowledge_get(id: ${d.id}, offset: ${from + part.length})]` : d.truncated ? "\n\n[the document was cut at 120,000 characters when it was added]" : ""
return { output: `# ${d.title}\n(${d.base} · ${d.source})\n\n${part}${tail}`, title: `${d.title}${from ? ` @${from}` : ""}` }
},
})
+41
View File
@@ -0,0 +1,41 @@
// The memory tool, after Hermes Agent's (tools/memory_tool.py, MIT, © Nous Research): one tool,
// a single change or a batch applied together, checked against the size limit as a whole.
import { z } from "zod"
import { MemoryStore, type Op } from "../memory/store.ts"
import { defineTool, ToolError } from "./tool.ts"
import { described } from "../harness.ts"
const OpSchema = z.object({
action: z.enum(["add", "replace", "remove"]),
content: z.string().optional(),
new_text: z.string().optional(),
old_text: z.string().optional(),
})
export const memoryTool = defineTool({
...described("memory"),
access: "memory",
schema: z.object({
target: z.enum(["memory", "user", "project"]),
action: z.enum(["add", "replace", "remove"]).optional(),
content: z.string().optional(),
new_text: z.string().optional(),
old_text: z.string().optional(),
operations: z.array(OpSchema).optional(),
}),
permission: (a) => ({ permission: "memory", class: "interact", patterns: [a.target] }),
async run(a, ctx) {
if (a.target === "project" && !ctx.projectMemory) throw new ToolError("This project has no project memory: it is not trusted, or has no .agent directory. Use 'memory' for facts that hold everywhere.")
const store = a.target === "project" ? ctx.projectMemory! : (ctx.memory ?? new MemoryStore())
const norm = (o: z.infer<typeof OpSchema>): Op => ({ action: o.action, content: o.content ?? o.new_text, old_text: o.old_text })
const ops: Op[] = a.operations?.length ? a.operations.map(norm) : a.action ? [norm({ action: a.action, content: a.content, new_text: a.new_text, old_text: a.old_text })] : []
if (!ops.length) throw new ToolError("Nothing to do: give action (with content and/or old_text), or operations.")
const r = store.apply(a.target === "project" ? "memory" : a.target, ops)
const label = a.target === "user" ? "user profile" : a.target === "project" ? "project memory" : "memory"
if (!r.ok) {
const list = r.entries ? `\n\nCurrent entries (${r.usage}):\n${r.entries.map((e, i) => `${i + 1}. ${e}`).join("\n") || "(none)"}` : `\n\n${label}: ${r.usage}`
return { output: r.message + list, title: `${label}: refused`, isError: true }
}
return { output: `${r.message} ${label}: ${r.usage}. Saved — this update is complete; do not repeat it.`, title: `${label} ${ops.map((o) => (o.action === "add" ? "+" : o.action === "remove" ? "−" : "~")).join("")} · ${r.usage.split(" — ")[0]}` }
},
})
+50
View File
@@ -0,0 +1,50 @@
import { stat } from "node:fs/promises"
import { createTwoFilesPatch } from "diff"
import { z } from "zod"
import { replace } from "./replace-text.ts"
import { absPath, defineTool, readText, showPath, ToolError, type ToolContext } from "./tool.ts"
import { diffStat, guardExisting } from "./write.ts"
import { described } from "../harness.ts"
async function planEdits(a: { path: string; edits: { old: string; new: string; all?: boolean }[] }, ctx: ToolContext) {
const abs = absPath(a.path, ctx)
const display = showPath(abs, ctx)
if (!(await guardExisting(abs, display, ctx))) throw new ToolError(`${display} does not exist. Use write to create it.`)
const raw = await readText(abs)
const crlf = raw.includes("\r\n")
const before = crlf ? raw.replaceAll("\r\n", "\n") : raw
let after = before
for (const [i, e] of a.edits.entries()) {
try {
after = replace(after, e.old.replaceAll("\r\n", "\n"), e.new.replaceAll("\r\n", "\n"), e.all ?? false)
} catch (err) {
throw new ToolError(`${display}: step ${i + 1} of ${a.edits.length} failed — ${(err as Error).message} Nothing was changed.`)
}
}
return { abs, display, before, after, crlf }
}
export const multieditTool = defineTool({
...described("multiedit"),
access: "edit",
schema: z.object({
path: z.string(),
edits: z.array(z.object({ old: z.string(), new: z.string(), all: z.boolean().optional() })).min(1).max(50),
}),
permission: (a, ctx) => {
const abs = absPath(a.path, ctx)
return { permission: "edit", class: "write", patterns: [showPath(abs, ctx)], paths: [abs] }
},
async preview(a, ctx) {
const { display, before, after } = await planEdits(a, ctx)
return createTwoFilesPatch(display, display, before, after, "", "", { context: 3 })
},
async run(a, ctx) {
const { abs, display, before, after, crlf } = await planEdits(a, ctx)
await Bun.write(abs, crlf ? after.replaceAll("\n", "\r\n") : after)
ctx.fileStamps.set(abs, (await stat(abs)).mtimeMs)
const patch = createTwoFilesPatch(display, display, before, after, "", "", { context: 3 })
const { added, removed } = diffStat(patch)
return { output: `Edited ${display}: ${a.edits.length} changes (+${added} −${removed}).`, title: `${display} +${added} −${removed}`, meta: { diff: patch } }
},
})
+39
View File
@@ -0,0 +1,39 @@
// Tool names the harness spec (harness/, shared with LLeMbas) renamed in v1, read from the spec's
// `aliases.cli` and `argument_aliases`. A call under a former name — a stored session, an
// agent file, a model trained on the old name — runs the tool it now is. Only the CLI's own
// former names: LLeMbas's differ in their arguments too, and are LLeMbas's to read.
import { TOOL_SPECS } from "../harness.ts"
const RENAMED: Record<string, string> = {}
const ARGUMENTS_RENAMED: Record<string, Record<string, string>> = {}
for (const t of Object.values(TOOL_SPECS)) {
// notes became three tools; which one depends on the call's action (below).
for (const old of t.aliases.cli) if (old !== "notes") RENAMED[old] = t.name
if (t.argument_aliases) ARGUMENTS_RENAMED[t.name] = t.argument_aliases
}
export function resolveCall(name: string, raw: unknown): { name: string; raw: unknown } {
let to = RENAMED[name] ?? name
let args = raw
const obj = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record<string, unknown>) : undefined
// notes was one tool with an action; it is three now, by what the action does.
if (name === "notes" && obj) {
const { action, ...rest } = obj
if (action === "search") to = "notes_search"
else if (action === "get") to = "note_view"
else to = "note_manage"
args = to === "note_manage" ? obj : rest
}
const renamed = ARGUMENTS_RENAMED[to]
if (renamed && args && typeof args === "object" && !Array.isArray(args)) {
const a = { ...(args as Record<string, unknown>) }
for (const [old, now] of Object.entries(renamed)) if (old in a) (a[now] ??= a[old], delete a[old])
args = a
}
return { name: to, raw: args }
}
/** A tool list as written by hand (an agent file's `tools:`), with former names read as the new. */
export function canonicalToolNames(names: string[]): string[] {
return [...new Set(names.flatMap((n) => (n === "notes" ? ["notes_search", "note_view", "note_manage"] : [RENAMED[n] ?? n])))]
}
+204
View File
@@ -0,0 +1,204 @@
// Lifted from OpenCode packages/core/src/patch.ts (MIT, © 2025 opencode): the parser for the
// "*** Begin Patch" format the apply_patch tool takes, and the lenient line matching that applies it.
// One change: an Update File's first chunk may come without its @@ line (parseUpdate).
export type Hunk =
| { readonly type: "add"; readonly path: string; readonly contents: string }
| { readonly type: "delete"; readonly path: string }
| {
readonly type: "update"
readonly path: string
readonly movePath?: string
readonly chunks: ReadonlyArray<UpdateFileChunk>
}
export interface UpdateFileChunk {
readonly oldLines: ReadonlyArray<string>
readonly newLines: ReadonlyArray<string>
readonly changeContext?: string
readonly endOfFile?: boolean
}
export interface FileUpdate {
readonly content: string
readonly bom: boolean
}
export function parse(patchText: string): ReadonlyArray<Hunk> {
const lines = stripHeredoc(patchText.trim()).split("\n")
const begin = lines.findIndex((line) => line.trim() === "*** Begin Patch")
const end = lines.findIndex((line) => line.trim() === "*** End Patch")
if (begin === -1 || end === -1 || begin >= end) throw new Error("Invalid patch format: missing Begin/End markers")
const hunks: Hunk[] = []
let index = begin + 1
while (index < end) {
const line = lines[index]!
if (line.startsWith("*** Add File:")) {
const path = line.slice("*** Add File:".length).trim()
if (!path) throw new Error("Invalid add file path")
const parsed = parseAdd(lines, index + 1)
hunks.push({ type: "add", path, contents: parsed.content })
index = parsed.next
continue
}
if (line.startsWith("*** Delete File:")) {
const path = line.slice("*** Delete File:".length).trim()
if (!path) throw new Error("Invalid delete file path")
hunks.push({ type: "delete", path })
index++
continue
}
if (line.startsWith("*** Update File:")) {
const path = line.slice("*** Update File:".length).trim()
if (!path) throw new Error("Invalid update file path")
let next = index + 1
let movePath: string | undefined
if (lines[next]?.startsWith("*** Move to:")) {
movePath = lines[next]!.slice("*** Move to:".length).trim()
if (!movePath) throw new Error("Invalid move file path")
next++
}
const parsed = parseUpdate(lines, next)
if (parsed.chunks.length === 0) throw new Error(`Invalid update hunk for ${path}: expected at least one @@ chunk`)
hunks.push({ type: "update", path, movePath, chunks: parsed.chunks })
index = parsed.next
continue
}
throw new Error(`Invalid patch line: ${line}`)
}
return hunks
}
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
const source = splitBom(original)
const lines = source.text.split("\n")
if (lines.at(-1) === "") lines.pop()
const replacements = computeReplacements(lines, path, chunks)
const updated = [...lines]
for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert)
if (updated.at(-1) !== "") updated.push("")
const next = splitBom(updated.join("\n"))
return { content: next.text, bom: source.bom || next.bom }
}
export function joinBom(text: string, bom: boolean) {
const stripped = splitBom(text).text
return bom ? `\uFEFF${stripped}` : stripped
}
function parseAdd(lines: ReadonlyArray<string>, start: number) {
const content: string[] = []
let index = start
while (index < lines.length && !lines[index]!.startsWith("***")) {
if (!lines[index]!.startsWith("+")) throw new Error(`Invalid add file line: ${lines[index]}`)
content.push(lines[index]!.slice(1))
index++
}
return { content: content.join("\n"), next: index }
}
function parseUpdate(lines: ReadonlyArray<string>, start: number) {
const chunks: UpdateFileChunk[] = []
let index = start
while (index < lines.length && !lines[index]!.startsWith("***")) {
// The first chunk may come without its @@ line, as the format's own grammar (Codex) allows and
// models often write it: its lines are the chunk, with nothing to find first. Later chunks
// need one — without it there is no telling where one chunk ends and the next begins.
const bare = !lines[index]!.startsWith("@@") && chunks.length === 0 && /^[ +-]/.test(lines[index]!)
if (!lines[index]!.startsWith("@@") && !bare) {
throw new Error(`Invalid update file line: ${lines[index]}`)
}
const changeContext = bare ? undefined : lines[index]!.slice(2).trim() || undefined
const oldLines: string[] = []
const newLines: string[] = []
let endOfFile = false
if (!bare) index++
while (index < lines.length && !lines[index]!.startsWith("@@")) {
const line = lines[index]!
if (line === "*** End of File") {
endOfFile = true
index++
break
}
if (line.startsWith("***")) break
if (line.startsWith(" ")) {
oldLines.push(line.slice(1))
newLines.push(line.slice(1))
} else if (line.startsWith("-")) oldLines.push(line.slice(1))
else if (line.startsWith("+")) newLines.push(line.slice(1))
else throw new Error(`Invalid update chunk line: ${line}`)
index++
}
chunks.push({ oldLines, newLines, changeContext, endOfFile: endOfFile || undefined })
}
return { chunks, next: index }
}
function computeReplacements(lines: ReadonlyArray<string>, path: string, chunks: ReadonlyArray<UpdateFileChunk>) {
const replacements: Array<readonly [start: number, remove: number, insert: ReadonlyArray<string>]> = []
let lineIndex = 0
for (const chunk of chunks) {
if (chunk.changeContext) {
const context = seek(lines, [chunk.changeContext], lineIndex)
if (context === -1) throw new Error(`Failed to find context '${chunk.changeContext}' in ${path}`)
lineIndex = context + 1
}
if (chunk.oldLines.length === 0) {
replacements.push([lines.length, 0, chunk.newLines])
continue
}
let oldLines = chunk.oldLines
let newLines = chunk.newLines
let found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
if (found === -1 && oldLines.at(-1) === "") {
oldLines = oldLines.slice(0, -1)
if (newLines.at(-1) === "") newLines = newLines.slice(0, -1)
found = seek(lines, oldLines, lineIndex, chunk.endOfFile)
}
if (found === -1) throw new Error(`Failed to find expected lines in ${path}:\n${chunk.oldLines.join("\n")}`)
replacements.push([found, oldLines.length, newLines])
lineIndex = found + oldLines.length
}
return replacements.toSorted((left, right) => left[0] - right[0])
}
function seek(lines: ReadonlyArray<string>, pattern: ReadonlyArray<string>, start: number, eof = false) {
if (pattern.length === 0) return -1
for (const compare of [exact, rstrip, trim, normalized]) {
if (eof) {
const offset = lines.length - pattern.length
if (offset >= start && matches(lines, pattern, offset, compare)) return offset
}
for (let offset = start; offset <= lines.length - pattern.length; offset++) {
if (matches(lines, pattern, offset, compare)) return offset
}
}
return -1
}
function matches(
lines: ReadonlyArray<string>,
pattern: ReadonlyArray<string>,
offset: number,
compare: (left: string, right: string) => boolean,
) {
return pattern.every((line, index) => compare(lines[offset + index]!, line))
}
const exact = (left: string, right: string) => left === right
const rstrip = (left: string, right: string) => left.trimEnd() === right.trimEnd()
const trim = (left: string, right: string) => left.trim() === right.trim()
const normalized = (left: string, right: string) => normalize(left.trim()) === normalize(right.trim())
const normalize = (value: string) =>
value
.replace(/[‘’‚‛]/g, "'")
.replace(/[“”„‟]/g, '"')
.replace(/[‐‑‒–—―]/g, "-")
.replace(/…/g, "...")
.replace(/ /g, " ")
const splitBom = (text: string) =>
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
const stripHeredoc = (input: string) =>
input.match(/^(?:cat\s+)?<<['"]?(\w+)['"]?\s*\n([\s\S]*?)\n\1\s*$/)?.[2] ?? input
+51
View File
@@ -0,0 +1,51 @@
import { existsSync, readFileSync, realpathSync } from "node:fs"
import { z } from "zod"
import type { Mode } from "../config/schema.ts"
import { absPath, defineTool, showPath, ToolError } from "./tool.ts"
import { described } from "../harness.ts"
export type PlanReply = { kind: "approve"; mode: Extract<Mode, "edit" | "manual"> } | { kind: "revise"; feedback: string } | { kind: "back"; text: string } | { kind: "dismissed" }
/** What the model is told when nobody can approve (also where the client turns out to have no plan
* card). */
export const nobodyToApprove = (path: string) => `Nobody is present to approve the plan. It is saved at ${path}. Stop here: say the plan is ready and where it is. Make no changes.`
export const planSubmitTool = defineTool({
...described("plan_submit"),
purpose: false,
access: "plan_submit",
requires: "plan",
exclusive: true,
schema: z.object({ path: z.string() }),
permission: () => ({ permission: "plan_submit", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
const abs = absPath(a.path, ctx)
const plans = ctx.planDir
if (plans && !abs.startsWith(plans + "/")) throw new ToolError(`The plan must be a file under ${showPath(plans, ctx)}; ${showPath(abs, ctx)} is not.`)
if (!existsSync(abs)) throw new ToolError(`${showPath(abs, ctx)} does not exist — write the plan first.`)
// By where it really is: a link under the plans directory could point anywhere.
if (plans && !realpathSync(abs).startsWith(realpathSync(plans) + "/")) throw new ToolError(`${showPath(abs, ctx)} leads out of the plans directory.`)
const text = readFileSync(abs, "utf8")
if (!ctx.plan)
return {
output: nobodyToApprove(showPath(abs, ctx)),
title: `${showPath(abs, ctx)} · saved, not approved`,
}
const r = await ctx.plan({ path: showPath(abs, ctx), text }, ctx.callId)
const shown = showPath(abs, ctx)
switch (r.kind) {
case "approve":
ctx.setMode?.(r.mode)
return {
output: `The user approved the plan in ${shown}. The permission mode is now ${r.mode}. Implement it now, step by step as written, keeping a todo list that follows its steps. If you find the plan wrong somewhere, say so and adjust rather than follow it into a mistake.`,
title: `${shown} · approved → ${r.mode}`,
}
case "revise":
return { output: `The user wants the plan revised: ${r.feedback}\nUpdate ${shown}, then call plan_submit again.`, title: `${shown} · revise` }
case "back":
return { output: `Before deciding, the user asks: "${r.text}"\nAnswer it in your reply, revise ${shown} if the answer changes it, then call plan_submit again.`, title: `${shown} · asked back` }
case "dismissed":
return { output: `The user closed the plan without deciding. Stay in plan mode; say the plan is at ${shown} and wait.`, title: `${shown} · not decided` }
}
},
})
+67
View File
@@ -0,0 +1,67 @@
import { z } from "zod"
import { addDecision, COLUMNS, readBoard, readDecisions, renderBoard, writeBoard } from "../project/board.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
function projectDir(ctx: ToolContext): string {
if (!ctx.projectDir) throw new ToolError("This project has no project directory yet (it is created when the directory is trusted).")
return ctx.projectDir
}
export const tasksTool = defineTool({
...described("tasks"),
access: "tasks",
requires: "project",
schema: z.object({
action: z.enum(["list", "add", "move", "remove"]),
text: z.string().optional(),
id: z.number().int().optional(),
to: z.enum(COLUMNS).optional(),
}),
permission: (a) => ({ permission: "tasks", class: a.action === "list" ? "read" : "interact", patterns: [a.action] }),
async run(a, ctx) {
const dir = projectDir(ctx)
const board = readBoard(dir)
if (a.action === "list") return { output: renderBoard(board), title: `${board.filter((t) => t.column !== "done").length} open` }
if (a.action === "add") {
if (!a.text?.trim()) throw new ToolError("add needs text")
const id = Math.max(0, ...board.map((t) => t.id)) + 1
board.push({ id, text: a.text.trim(), column: a.to ?? "todo" })
writeBoard(dir, board)
return { output: `Added #${id} to ${a.to ?? "todo"}.\n\n${renderBoard(board)}`, title: `+ #${id} ${a.text.trim().slice(0, 60)}` }
}
const t = board.find((x) => x.id === a.id)
if (!t) throw new ToolError(`There is no task #${a.id}. ${renderBoard(board)}`)
if (a.action === "remove") {
writeBoard(dir, board.filter((x) => x.id !== t.id))
return { output: `Removed #${t.id}.`, title: `− #${t.id}` }
}
if (!a.to) throw new ToolError("move needs `to`: todo, doing or done")
t.column = a.to
writeBoard(dir, board)
return { output: `#${t.id} → ${a.to}.`, title: `#${t.id} → ${a.to}` }
},
})
export const decisionsTool = defineTool({
...described("decisions"),
access: "decisions",
requires: "project",
schema: z.object({
action: z.enum(["list", "add"]),
title: z.string().optional(),
decision: z.string().optional(),
why: z.string().optional(),
}),
permission: (a) => ({ permission: "decisions", class: a.action === "list" ? "read" : "interact", patterns: [a.action] }),
async run(a, ctx) {
const dir = projectDir(ctx)
if (a.action === "list") {
const d = readDecisions(dir)
return { output: d.map((x) => `${x.date} · ${x.title}\n${x.text}`).join("\n\n") || "(no decisions recorded)", title: `${d.length} decisions` }
}
if (!a.title?.trim() || !a.decision?.trim()) throw new ToolError("add needs title and decision")
const d = addDecision(dir, a.title, a.decision, a.why)
return { output: `Recorded: ${d.date} · ${d.title}`, title: d.title.slice(0, 60) }
},
})
+147
View File
@@ -0,0 +1,147 @@
// The question tool. Its design merges three: LLeMbas' ask_user (options with explanations,
// everything in one call, an automatic free-text row, lenient with small models' shapes),
// OpenCode's question (a short header, `multiple`, the recommended option first) and Hermes'
// clarify — and adds what none of them has: the user may answer with a question of their own.
import { z } from "zod"
import { defineTool, ToolError } from "./tool.ts"
import { described } from "../harness.ts"
export interface QuestionOption {
label: string
description?: string
recommended?: boolean
}
export interface Question {
header: string
question: string
options: QuestionOption[]
multiple?: boolean
}
export interface QuestionRequest {
why?: string
questions: Question[]
}
export type Answer = { kind: "options"; labels: string[] } | { kind: "custom"; text: string } | { kind: "back"; text: string }
export type QuestionReply = { dismissed: true } | { dismissed?: false; answers: Answer[]; auto?: string }
/** What the model is told when nobody can answer (also where a client that cannot show the card
* holds the session). */
export const NOBODY_TO_ASK = "Nobody can be asked in this context. Choose the most reasonable option, carry on, and say what you assumed."
const MAX_Q = 4
const MAX_OPT = 6
// Small models send options as bare strings, a single question without the array, "choices"
// instead of "options"… all of it is accepted and normalised, as LLeMbas does.
const OptionIn = z.preprocess(
(o) => (typeof o === "string" ? { label: o } : o),
z.object({
label: z.string().min(1),
description: z.string().optional(),
recommended: z.boolean().optional(),
}),
)
const QuestionIn = z.preprocess(
(q) => {
if (!q || typeof q !== "object") return q
const x = { ...(q as Record<string, unknown>) }
x.options ??= x.choices
x.header ??= String(x.question ?? "").split(/[?.:]/)[0]!.slice(0, 30)
return x
},
z.object({
header: z.string(),
question: z.string().min(1),
options: z.array(OptionIn).max(MAX_OPT).default([]),
multiple: z.boolean().optional(),
}),
)
export const questionSchema = z.preprocess(
(a) => {
if (!a || typeof a !== "object") return a
const x = a as Record<string, unknown>
if (!x.questions && (x.question || x.options)) return { why: x.why, questions: [x] }
return x
},
z.object({
why: z.string().optional(),
questions: z.array(QuestionIn).min(1).max(MAX_Q),
}),
)
export const askUserTool = defineTool({
...described("ask_user"),
purpose: false,
access: "ask_user",
exclusive: true,
schema: questionSchema,
permission: () => ({ permission: "ask_user", class: "interact", patterns: ["*"] }),
async run(args, ctx) {
if (!ctx.question) throw new ToolError(NOBODY_TO_ASK)
const req = args as QuestionRequest
for (const q of req.questions) {
// Keep one recommendation at most, and put it first.
let seen = false
for (const o of q.options) if (o.recommended) (seen ? (o.recommended = false) : (seen = true))
q.options.sort((a, b) => Number(!!b.recommended) - Number(!!a.recommended))
}
const reply = await ctx.question(req, ctx.callId)
return { output: formatAnswers(req, reply), title: summary(req, reply) }
},
})
function summary(req: QuestionRequest, reply: QuestionReply): string {
if (reply.dismissed) return `${req.questions.length} question${req.questions.length > 1 ? "s" : ""} · dismissed`
return req.questions.map((q, i) => `${q.header}: ${short(reply.answers[i])}`).join(" · ")
}
function short(a: Answer | undefined): string {
if (!a) return "—"
if (a.kind === "options") return a.labels.join(", ")
if (a.kind === "custom") return `“${a.text.slice(0, 40)}”`
return `asked back`
}
export function formatAnswers(req: QuestionRequest, reply: QuestionReply): string {
if (reply.dismissed)
return "The user dismissed the questions without answering. Do not ask them again. Continue with your best judgement, and say what you assumed."
const lines: string[] = []
const back: number[] = []
req.questions.forEach((q, i) => {
lines.push(`Q${i + 1} [${q.header}] ${q.question}`)
const a = reply.answers[i]
if (!a) lines.push("→ (no answer)")
else if (a.kind === "options") {
const rec = q.options.find((o) => o.recommended)?.label
lines.push(`→ ${a.labels.map((l) => (l === rec ? `${l} (the one you recommended)` : l)).join(", ")}${reply.auto ? ` (${reply.auto})` : ""}`)
} else if (a.kind === "custom") lines.push(`→ custom: "${a.text}"`)
else {
lines.push(`↩ counter-question: "${a.text}"`)
back.push(i + 1)
}
})
if (back.length) {
const final = req.questions.map((_, i) => i + 1).filter((n) => !back.includes(n))
lines.push(
"",
`${final.length ? `Q${final.join(", Q")} ${final.length > 1 ? "are" : "is"} answered. ` : ""}Instead of answering Q${back.join(", Q")}, the user asked you a question. Answer it in your reply, then call question again with only Q${back.join(", Q")} — adjusted if your answer changes the options.`,
)
}
return lines.join("\n")
}
/** For headless runs with nobody present: config question.unattended. */
export function unattendedReply(req: QuestionRequest, policy: "recommended" | "first" | "fail"): QuestionReply {
if (policy === "fail") return { dismissed: true }
return {
auto: policy === "recommended" ? "chosen automatically: nobody is present, so the recommended option was taken" : "chosen automatically: nobody is present, so the first option was taken",
answers: req.questions.map((q) => {
const o = (policy === "recommended" ? q.options.find((x) => x.recommended) : undefined) ?? q.options[0]
return o ? { kind: "options", labels: [o.label] } : { kind: "custom", text: "(no answer: nobody is present)" }
}),
}
}
+80
View File
@@ -0,0 +1,80 @@
import { existsSync, readFileSync, realpathSync } from "node:fs"
import { unsafeProjectFile } from "../project/safe.ts"
import { readdir, stat } from "node:fs/promises"
import { dirname, join } from "node:path"
import { z } from "zod"
import { absPath, defineTool, showPath, ToolError } from "./tool.ts"
import { described } from "../harness.ts"
const MAX_LINES = 2000
const MAX_LINE = 2000
export const readTool = defineTool({
...described("read"),
access: "read",
schema: z.object({
path: z.string(),
offset: z.number().int().min(1).optional(),
limit: z.number().int().min(1).optional(),
}),
permission: (a, ctx) => {
const abs = absPath(a.path, ctx)
return { permission: "read", class: "read", patterns: [showPath(abs, ctx)], paths: [abs] }
},
async run(a, ctx) {
const abs = absPath(a.path, ctx)
const st = await stat(abs).catch(() => undefined)
if (!st) throw new ToolError(`${a.path} does not exist`)
if (st.isDirectory()) {
const entries = await readdir(abs, { withFileTypes: true })
const lines = entries
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
.sort((x, y) => Number(y.endsWith("/")) - Number(x.endsWith("/")) || x.localeCompare(y))
return { output: lines.join("\n") || "(empty directory)", title: `${showPath(abs, ctx)}/ · ${lines.length} entries` }
}
const file = Bun.file(abs)
const bytes = new Uint8Array(await file.slice(0, 8192).arrayBuffer())
if (bytes.includes(0)) throw new ToolError(`${a.path} is a binary file (${st.size} bytes)`)
// Read whole, so only up to a size that fits in memory comfortably; past it, grep or bash.
if (st.size > 50_000_000) throw new ToolError(`${a.path} is ${Math.round(st.size / 1e6)} MB — too large to read whole; search it with grep, or look at a part with bash (head, tail, sed -n)`)
const text = await file.text()
const all = text.split("\n")
if (all.length > 1 && all[all.length - 1] === "") all.pop()
const start = (a.offset ?? 1) - 1
const end = Math.min(all.length, start + (a.limit ?? MAX_LINES))
const body = all
.slice(start, end)
.map((l, i) => `${start + i + 1}: ${l.length > MAX_LINE ? l.slice(0, MAX_LINE) + " […cut]" : l}`)
.join("\n")
ctx.readFiles.add(abs)
ctx.fileStamps.set(abs, st.mtimeMs)
const more = end < all.length ? `\n\n(${all.length - end} more lines — read again with offset ${end + 1})` : ""
const empty = all.length === 0 ? "(empty file)" : ""
return { output: (empty || body) + more + nearbyInstructions(abs, ctx), title: `${showPath(abs, ctx)} · ${start + 1}–${end} of ${all.length}`, meta: { path: showPath(abs, ctx), from: start + 1, to: end, of: all.length } }
},
})
/** OpenCode's "nearby instructions": reading a file under a directory with its own AGENTS.md or
* CLAUDE.md hands the model that file, once per session. The root's is already in the prompt. */
function nearbyInstructions(file: string, ctx: import("./tool.ts").ToolContext): string {
if (!ctx.instructionsSeen || !file.startsWith(ctx.root + "/")) return ""
const out: string[] = []
for (let dir = dirname(file); dir.startsWith(ctx.root) && dir !== ctx.root; dir = dirname(dir)) {
const hit = ["AGENTS.md", "CLAUDE.md"].map((f) => join(dir, f)).find((f) => existsSync(f))
if (!hit || ctx.instructionsSeen.has(hit)) continue
ctx.instructionsSeen.add(hit)
// Only a file really inside the project: one committed as a link could point at anything.
if (unsafeProjectFile(hit, ctx.root)) continue
let text: string
try {
const real = realpathSync(hit)
const root = realpathSync(ctx.root)
if (!real.startsWith(`${root}/`)) continue
text = readFileSync(real, "utf8").trim().slice(0, 20_000)
} catch {
continue
}
out.push(`\n\n<instructions from="${showPath(hit, ctx)}">\nThis directory has its own instructions; they apply to work under it.\n${text}\n</instructions>`)
}
return out.reverse().join("")
}
+62
View File
@@ -0,0 +1,62 @@
import { applyPatchTool } from "./apply_patch.ts"
import { bashKillTool, bashListTool, bashOutputTool, bashTool } from "./bash.ts"
import { editTool } from "./edit.ts"
import { multieditTool } from "./multiedit.ts"
import { askUserTool } from "./question.ts"
import { readTool } from "./read.ts"
import { globTool, grepTool, listTool } from "./search.ts"
import { todoTool } from "./todo.ts"
import { viewImageTool } from "./view_image.ts"
import { planSubmitTool } from "./plan_exit.ts"
import { taskTool } from "./task.ts"
import { decisionsTool, tasksTool } from "./project_tools.ts"
import { webFetchTool, webSearchTool } from "./web.ts"
import { memoryTool } from "./memory.ts"
import { sessionSearchTool } from "./session_search.ts"
import { skillManageTool, skillsListTool, skillViewTool } from "./skills.ts"
import { settingsTool } from "./settings.ts"
import { knowledgeGetTool, knowledgeSearchTool, noteManageTool, notesSearchTool, noteViewTool } from "./library.ts"
import type { Tool } from "./tool.ts"
import { writeTool } from "./write.ts"
export const BUILTIN_TOOLS: Tool[] = [
readTool,
writeTool,
editTool,
multieditTool,
applyPatchTool,
globTool,
grepTool,
listTool,
bashTool,
bashOutputTool,
bashListTool,
bashKillTool,
webSearchTool,
webFetchTool,
taskTool,
todoTool,
tasksTool,
decisionsTool,
askUserTool,
planSubmitTool,
memoryTool,
sessionSearchTool,
notesSearchTool,
noteViewTool,
noteManageTool,
knowledgeSearchTool,
knowledgeGetTool,
skillsListTool,
skillViewTool,
skillManageTool,
settingsTool,
viewImageTool,
] as Tool[]
export function toolMap(tools: Tool[] = BUILTIN_TOOLS): Map<string, Tool> {
return new Map(tools.map((t) => [t.name, t]))
}
export { canonicalToolNames, resolveCall } from "./names.ts"
+9
View File
@@ -0,0 +1,9 @@
// OpenCode's replacer cascade (replace.ts, kept byte-for-byte with upstream) with one fix that has
// to live outside it: with replaceAll, upstream hands the new text to String.replaceAll, which
// reads $&, $$, $` and $' in it as patterns ("$$HOME" became "$HOME"). Escaping $ for that path
// makes it literal. Upstream has the same bug (packages/opencode/src/tool/edit.ts).
import { replace as cascade } from "./replace.ts"
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
return cascade(content, oldString, replaceAll && oldString !== newString ? newString.replaceAll("$", "$$$$") : newString, replaceAll)
}
+526
View File
@@ -0,0 +1,526 @@
// @ts-nocheck — kept byte-for-byte with upstream (written without noUncheckedIndexedAccess) so it can be re-synced; covered by tests/replace.test.ts.
// Lifted from OpenCode packages/opencode/src/tool/edit.ts (MIT, © 2025 opencode): the replacer
// cascade that lets an edit land when a model gets whitespace, indentation or escaping slightly wrong.
// OpenCode credits its approaches to cline (evals/diff-edits) and gemini-cli (editCorrector.ts).
export type Replacer = (content: string, find: string) => Generator<string, void, unknown>
// Similarity thresholds for block anchor fallback matching
const SINGLE_CANDIDATE_SIMILARITY_THRESHOLD = 0.65
const MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD = 0.65
/**
* Levenshtein distance algorithm implementation
*/
function levenshtein(a: string, b: string): number {
// Handle empty strings
if (a === "" || b === "") {
return Math.max(a.length, b.length)
}
const matrix = Array.from({ length: a.length + 1 }, (_, i) =>
Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
)
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1
matrix[i][j] = Math.min(matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + cost)
}
}
return matrix[a.length][b.length]
}
export const SimpleReplacer: Replacer = function* (_content, find) {
yield find
}
export const LineTrimmedReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
for (let i = 0; i <= originalLines.length - searchLines.length; i++) {
let matches = true
for (let j = 0; j < searchLines.length; j++) {
const originalTrimmed = originalLines[i + j].trim()
const searchTrimmed = searchLines[j].trim()
if (originalTrimmed !== searchTrimmed) {
matches = false
break
}
}
if (matches) {
let matchStartIndex = 0
for (let k = 0; k < i; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = 0; k < searchLines.length; k++) {
matchEndIndex += originalLines[i + k].length
if (k < searchLines.length - 1) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
}
}
}
export const BlockAnchorReplacer: Replacer = function* (content, find) {
const originalLines = content.split("\n")
const searchLines = find.split("\n")
if (searchLines.length < 3) {
return
}
if (searchLines[searchLines.length - 1] === "") {
searchLines.pop()
}
const firstLineSearch = searchLines[0].trim()
const lastLineSearch = searchLines[searchLines.length - 1].trim()
const searchBlockSize = searchLines.length
const maxLineDelta = Math.max(1, Math.floor(searchBlockSize * 0.25))
// Collect all candidate positions where both anchors match
const candidates: Array<{ startLine: number; endLine: number }> = []
for (let i = 0; i < originalLines.length; i++) {
if (originalLines[i].trim() !== firstLineSearch) {
continue
}
// Look for the matching last line after this first line
for (let j = i + 2; j < originalLines.length; j++) {
if (originalLines[j].trim() === lastLineSearch) {
const actualBlockSize = j - i + 1
if (Math.abs(actualBlockSize - searchBlockSize) <= maxLineDelta) {
candidates.push({ startLine: i, endLine: j })
}
break // Only match the first occurrence of the last line
}
}
}
// Return immediately if no candidates
if (candidates.length === 0) {
return
}
// Handle single candidate scenario (using relaxed threshold)
if (candidates.length === 1) {
const { startLine, endLine } = candidates[0]
const actualBlockSize = endLine - startLine + 1
let similarity = 0
const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
}
const distance = levenshtein(originalLine, searchLine)
similarity += (1 - distance / maxLen) / linesToCheck
// Exit early when threshold is reached
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
break
}
}
} else {
// No middle lines to compare, just accept based on anchors
similarity = 1.0
}
if (similarity >= SINGLE_CANDIDATE_SIMILARITY_THRESHOLD) {
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += originalLines[k].length
if (k < endLine) {
matchEndIndex += 1 // Add newline character except for the last line
}
}
yield content.substring(matchStartIndex, matchEndIndex)
}
return
}
// Calculate similarity for multiple candidates
let bestMatch: { startLine: number; endLine: number } | null = null
let maxSimilarity = -1
for (const candidate of candidates) {
const { startLine, endLine } = candidate
const actualBlockSize = endLine - startLine + 1
let similarity = 0
const linesToCheck = Math.min(searchBlockSize - 2, actualBlockSize - 2) // Middle lines only
if (linesToCheck > 0) {
for (let j = 1; j < searchBlockSize - 1 && j < actualBlockSize - 1; j++) {
const originalLine = originalLines[startLine + j].trim()
const searchLine = searchLines[j].trim()
const maxLen = Math.max(originalLine.length, searchLine.length)
if (maxLen === 0) {
continue
}
const distance = levenshtein(originalLine, searchLine)
similarity += 1 - distance / maxLen
}
similarity /= linesToCheck // Average similarity
} else {
// No middle lines to compare, just accept based on anchors
similarity = 1.0
}
if (similarity > maxSimilarity) {
maxSimilarity = similarity
bestMatch = candidate
}
}
// Threshold judgment
if (maxSimilarity >= MULTIPLE_CANDIDATES_SIMILARITY_THRESHOLD && bestMatch) {
const { startLine, endLine } = bestMatch
let matchStartIndex = 0
for (let k = 0; k < startLine; k++) {
matchStartIndex += originalLines[k].length + 1
}
let matchEndIndex = matchStartIndex
for (let k = startLine; k <= endLine; k++) {
matchEndIndex += originalLines[k].length
if (k < endLine) {
matchEndIndex += 1
}
}
yield content.substring(matchStartIndex, matchEndIndex)
}
}
export const WhitespaceNormalizedReplacer: Replacer = function* (content, find) {
const normalizeWhitespace = (text: string) => text.replace(/\s+/g, " ").trim()
const normalizedFind = normalizeWhitespace(find)
// Handle single line matches
const lines = content.split("\n")
for (let i = 0; i < lines.length; i++) {
const line = lines[i]
if (normalizeWhitespace(line) === normalizedFind) {
yield line
} else {
// Only check for substring matches if the full line doesn't match
const normalizedLine = normalizeWhitespace(line)
if (normalizedLine.includes(normalizedFind)) {
// Find the actual substring in the original line that matches
const words = find.trim().split(/\s+/)
if (words.length > 0) {
const pattern = words.map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("\\s+")
try {
const regex = new RegExp(pattern)
const match = line.match(regex)
if (match) {
yield match[0]
}
} catch {
// Invalid regex pattern, skip
}
}
}
}
}
// Handle multi-line matches
const findLines = find.split("\n")
if (findLines.length > 1) {
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length)
if (normalizeWhitespace(block.join("\n")) === normalizedFind) {
yield block.join("\n")
}
}
}
}
export const IndentationFlexibleReplacer: Replacer = function* (content, find) {
const removeIndentation = (text: string) => {
const lines = text.split("\n")
const nonEmptyLines = lines.filter((line) => line.trim().length > 0)
if (nonEmptyLines.length === 0) return text
const minIndent = Math.min(
...nonEmptyLines.map((line) => {
const match = line.match(/^(\s*)/)
return match ? match[1].length : 0
}),
)
return lines.map((line) => (line.trim().length === 0 ? line : line.slice(minIndent))).join("\n")
}
const normalizedFind = removeIndentation(find)
const contentLines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= contentLines.length - findLines.length; i++) {
const block = contentLines.slice(i, i + findLines.length).join("\n")
if (removeIndentation(block) === normalizedFind) {
yield block
}
}
}
export const EscapeNormalizedReplacer: Replacer = function* (content, find) {
const unescapeString = (str: string): string => {
return str.replace(/\\(n|t|r|'|"|`|\\|\n|\$)/g, (match, capturedChar) => {
switch (capturedChar) {
case "n":
return "\n"
case "t":
return "\t"
case "r":
return "\r"
case "'":
return "'"
case '"':
return '"'
case "`":
return "`"
case "\\":
return "\\"
case "\n":
return "\n"
case "$":
return "$"
default:
return match
}
})
}
const unescapedFind = unescapeString(find)
// Try direct match with unescaped find string
if (content.includes(unescapedFind)) {
yield unescapedFind
}
// Also try finding escaped versions in content that match unescaped find
const lines = content.split("\n")
const findLines = unescapedFind.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
const unescapedBlock = unescapeString(block)
if (unescapedBlock === unescapedFind) {
yield block
}
}
}
export const MultiOccurrenceReplacer: Replacer = function* (content, find) {
// This replacer yields all exact matches, allowing the replace function
// to handle multiple occurrences based on replaceAll parameter
let startIndex = 0
while (true) {
const index = content.indexOf(find, startIndex)
if (index === -1) break
yield find
startIndex = index + find.length
}
}
export const TrimmedBoundaryReplacer: Replacer = function* (content, find) {
const trimmedFind = find.trim()
if (trimmedFind === find) {
// Already trimmed, no point in trying
return
}
// Try to find the trimmed version
if (content.includes(trimmedFind)) {
yield trimmedFind
}
// Also try finding blocks where trimmed content matches
const lines = content.split("\n")
const findLines = find.split("\n")
for (let i = 0; i <= lines.length - findLines.length; i++) {
const block = lines.slice(i, i + findLines.length).join("\n")
if (block.trim() === trimmedFind) {
yield block
}
}
}
export const ContextAwareReplacer: Replacer = function* (content, find) {
const findLines = find.split("\n")
if (findLines.length < 3) {
// Need at least 3 lines to have meaningful context
return
}
// Remove trailing empty line if present
if (findLines[findLines.length - 1] === "") {
findLines.pop()
}
const contentLines = content.split("\n")
// Extract first and last lines as context anchors
const firstLine = findLines[0].trim()
const lastLine = findLines[findLines.length - 1].trim()
// Find blocks that start and end with the context anchors
for (let i = 0; i < contentLines.length; i++) {
if (contentLines[i].trim() !== firstLine) continue
// Look for the matching last line
for (let j = i + 2; j < contentLines.length; j++) {
if (contentLines[j].trim() === lastLine) {
// Found a potential context block
const blockLines = contentLines.slice(i, j + 1)
const block = blockLines.join("\n")
// Check if the middle content has reasonable similarity
// (simple heuristic: at least 50% of non-empty lines should match when trimmed)
if (blockLines.length === findLines.length) {
let matchingLines = 0
let totalNonEmptyLines = 0
for (let k = 1; k < blockLines.length - 1; k++) {
const blockLine = blockLines[k].trim()
const findLine = findLines[k].trim()
if (blockLine.length > 0 || findLine.length > 0) {
totalNonEmptyLines++
if (blockLine === findLine) {
matchingLines++
}
}
}
if (totalNonEmptyLines === 0 || matchingLines / totalNonEmptyLines >= 0.5) {
yield block
break // Only match the first occurrence
}
}
break
}
}
}
}
export function trimDiff(diff: string): string {
const lines = diff.split("\n")
const contentLines = lines.filter(
(line) =>
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++"),
)
if (contentLines.length === 0) return diff
let min = Infinity
for (const line of contentLines) {
const content = line.slice(1)
if (content.trim().length > 0) {
const match = content.match(/^(\s*)/)
if (match) min = Math.min(min, match[1].length)
}
}
if (min === Infinity || min === 0) return diff
const trimmedLines = lines.map((line) => {
if (
(line.startsWith("+") || line.startsWith("-") || line.startsWith(" ")) &&
!line.startsWith("---") &&
!line.startsWith("+++")
) {
const prefix = line[0]
const content = line.slice(1)
return prefix + content.slice(min)
}
return line
})
return trimmedLines.join("\n")
}
export function replace(content: string, oldString: string, newString: string, replaceAll = false): string {
if (oldString === newString) {
throw new Error("No changes to apply: oldString and newString are identical.")
}
if (oldString === "") {
throw new Error(
"oldString cannot be empty when editing an existing file. Provide the exact text to replace, or use write for an intentional full-file replacement.",
)
}
let notFound = true
for (const replacer of [
SimpleReplacer,
LineTrimmedReplacer,
BlockAnchorReplacer,
WhitespaceNormalizedReplacer,
IndentationFlexibleReplacer,
EscapeNormalizedReplacer,
TrimmedBoundaryReplacer,
ContextAwareReplacer,
MultiOccurrenceReplacer,
]) {
for (const search of replacer(content, oldString)) {
const index = content.indexOf(search)
if (index === -1) continue
notFound = false
if (isDisproportionateMatch(search, oldString)) {
throw new Error(
"Refusing replacement because the matched span is much larger than oldString. Re-read the file and provide the full exact oldString for the intended replacement.",
)
}
if (replaceAll) {
return content.replaceAll(search, newString)
}
const lastIndex = content.lastIndexOf(search)
if (index !== lastIndex) continue
return content.substring(0, index) + newString + content.substring(index + search.length)
}
}
if (notFound) {
throw new Error(
"Could not find oldString in the file. It must match exactly, including whitespace, indentation, and line endings.",
)
}
throw new Error("Found multiple matches for oldString. Provide more surrounding context to make the match unique.")
}
function isDisproportionateMatch(search: string, oldString: string) {
const oldLines = oldString.split("\n").length
const searchLines = search.split("\n").length
if (searchLines >= Math.max(oldLines + 3, oldLines * 2)) return true
if (oldLines === 1) return false
return search.trim().length > Math.max(oldString.trim().length + 500, oldString.trim().length * 4)
}
+149
View File
@@ -0,0 +1,149 @@
import { stat } from "node:fs/promises"
import { join, relative } from "node:path"
import { z } from "zod"
import { absPath, defineTool, showPath, ToolError, truncate, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
const IGNORED = ["node_modules", ".git", "dist", "build", ".venv", "venv", "__pycache__", "target", ".next", ".cache"]
const hasRg = Bun.which("rg") !== null
async function run(cmd: string[], cwd: string, signal: AbortSignal): Promise<{ code: number; out: string; err: string }> {
const p = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe", signal })
const [out, err, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited])
return { code, out, err }
}
/** Files under `dir`, honouring .gitignore when ripgrep is present. */
async function listFiles(dir: string, glob: string | undefined, ctx: ToolContext): Promise<string[]> {
// Spawning in a directory that does not exist fails as "posix_spawn 'rg' ENOENT", which reads
// as ripgrep missing.
const st = await stat(dir).catch(() => undefined)
if (!st) throw new ToolError(`${showPath(dir, ctx)} does not exist.`)
if (!st.isDirectory()) throw new ToolError(`${showPath(dir, ctx)} is a file, not a directory.`)
if (hasRg) {
const args = ["rg", "--files", "--hidden", "--glob", "!.git"]
if (glob) args.push("--glob", glob)
const r = await run(args, dir, ctx.signal)
if (r.code > 1) throw new ToolError(r.err.trim())
return r.out.split("\n").filter(Boolean)
}
const out: string[] = []
for await (const f of new Bun.Glob(glob ?? "**/*").scan({ cwd: dir, dot: true, onlyFiles: true })) {
if (f.split("/").some((seg) => IGNORED.includes(seg))) continue
out.push(f)
}
return out
}
export const globTool = defineTool({
...described("glob"),
access: "glob",
schema: z.object({
pattern: z.string(),
path: z.string().optional(),
}),
permission: (a, ctx) => {
const abs = absPath(a.path ?? ".", ctx)
return { permission: "glob", class: "read", patterns: [a.pattern], paths: [abs] }
},
async run(a, ctx) {
const dir = absPath(a.path ?? ".", ctx)
const files = await listFiles(dir, a.pattern, ctx)
const withTime = await Promise.all(files.map(async (f) => ({ f, t: (await stat(join(dir, f)).catch(() => undefined))?.mtimeMs ?? 0 })))
withTime.sort((x, y) => y.t - x.t)
const shown = withTime.slice(0, 200).map((x) => showPath(join(dir, x.f), ctx))
const more = files.length > 200 ? `\n(${files.length - 200} more — narrow the pattern)` : ""
return { output: (shown.join("\n") || "No files found.") + more, title: `${a.pattern} · ${files.length} files`, meta: { hits: files.length } }
},
})
export const grepTool = defineTool({
...described("grep"),
access: "grep",
schema: z.object({
pattern: z.string(),
path: z.string().optional(),
include: z.string().optional(),
ignore_case: z.boolean().optional(),
literal: z.boolean().optional(),
}),
permission: (a, ctx) => {
const abs = absPath(a.path ?? ".", ctx)
return { permission: "grep", class: "read", patterns: [a.pattern], paths: [abs] }
},
async run(a, ctx) {
const target = absPath(a.path ?? ".", ctx)
const lines: string[] = []
if (hasRg) {
const args = ["rg", "--line-number", "--no-heading", "--color", "never", "--hidden", "--glob", "!.git", "--max-columns", "400", "--max-columns-preview"]
if (a.ignore_case) args.push("-i")
if (a.literal) args.push("-F")
if (a.include) args.push("--glob", a.include)
args.push("-e", a.pattern, target)
const r = await run(args, ctx.cwd, ctx.signal)
if (r.code > 1) throw new ToolError(r.err.trim() || `rg exited ${r.code}`)
for (const l of r.out.split("\n")) if (l) lines.push(l.startsWith(target) ? showPath(target, ctx) + l.slice(target.length) : l)
} else {
let re: RegExp
try {
re = new RegExp(a.literal ? a.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") : a.pattern, a.ignore_case ? "i" : "")
} catch (e) {
throw new ToolError(`invalid regex: ${(e as Error).message}`)
}
const st = await stat(target).catch(() => undefined)
if (!st) throw new ToolError(`${a.path} does not exist`)
const files = st.isDirectory() ? (await listFiles(target, a.include, ctx)).map((f) => join(target, f)) : [target]
for (const f of files) {
const text = await Bun.file(f).text().catch(() => "")
if (text.includes("\0")) continue
text.split("\n").forEach((l, i) => {
if (re.test(l)) lines.push(`${showPath(f, ctx)}:${i + 1}:${l.slice(0, 400)}`)
})
if (lines.length > 1000) break
}
}
const more = lines.length > 200 ? `\n(${lines.length - 200} more matches — narrow the search)` : ""
// The commonest miss: searching for code text like `len(x)` as a regex, where ( ) are a group.
const hint = !lines.length && !a.literal && /[()[\]{}.*+?|\\^$]/.test(a.pattern) ? " The pattern is a regular expression; to search for the text as written, set literal: true." : ""
return { output: truncate(lines.slice(0, 200).join("\n") || "No matches." + hint) + more, title: `/${a.pattern}/ · ${lines.length} matches`, meta: { hits: lines.length } }
},
})
export const listTool = defineTool({
...described("list"),
access: "list",
schema: z.object({
path: z.string().optional(),
depth: z.number().int().min(1).max(6).optional(),
}),
permission: (a, ctx) => {
const abs = absPath(a.path ?? ".", ctx)
return { permission: "list", class: "read", patterns: [showPath(abs, ctx)], paths: [abs] }
},
async run(a, ctx) {
const dir = absPath(a.path ?? ".", ctx)
const depth = a.depth ?? 2
const files = await listFiles(dir, undefined, ctx)
const tree = new Map<string, Set<string>>()
for (const f of files) {
const segs = f.split("/")
for (let i = 0; i < Math.min(segs.length, depth); i++) {
const parent = segs.slice(0, i).join("/")
const name = segs[i]! + (i < segs.length - 1 ? "/" : "")
if (!tree.has(parent)) tree.set(parent, new Set())
tree.get(parent)!.add(name)
}
}
const out: string[] = []
const walk = (parent: string, indent: string) => {
const kids = [...(tree.get(parent) ?? [])].sort((x, y) => Number(y.endsWith("/")) - Number(x.endsWith("/")) || x.localeCompare(y))
for (const k of kids) {
if (out.length >= 400) return
out.push(indent + k)
if (k.endsWith("/")) walk(parent ? `${parent}/${k.slice(0, -1)}` : k.slice(0, -1), indent + " ")
}
}
walk("", "")
return { output: out.join("\n") || "(empty)", title: `${relative(ctx.root, dir) || "."}/ · ${files.length} files`, meta: { hits: files.length } }
},
})
+160
View File
@@ -0,0 +1,160 @@
// session_search, after Hermes Agent (tools/session_search_tool.py, hermes_state_search.py; MIT,
// © Nous Research): find and read past sessions in the local store. Four shapes, by arguments:
// query = discovery, session_id + around_message_id = scroll, session_id = read, nothing = browse.
// No model is involved: what comes back is the stored messages themselves.
import { z } from "zod"
import type { Message } from "../provider/types.ts"
import type { Row, SessionRow, Store } from "../session/store.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
/** A user's words made safe for FTS5 MATCH: quoted phrases kept, operators' punctuation dropped,
* terms with dots or hyphens quoted (FTS5 would read them as syntax). */
export function ftsQuery(q: string): string {
const phrases: string[] = []
let s = q.slice(0, 2048).replace(/"([^"]+)"/g, (_, p: string) => `\u0000${phrases.push(p.replace(/"/g, "")) - 1}\u0000`)
s = s.replace(/[+{}():"^@/#&|~[\]<>,;!?$=\\'%`]/g, " ").replace(/\*+/g, "*").replace(/(^|\s)\*/g, "$1")
let words = s.split(/\s+/).filter(Boolean)
// An operator right after another (a AND NOT b, a OR OR b) is FTS5 syntax error: keep the last
// of a run, except NOT after AND, which FTS5 spells NOT alone.
words = words.filter((w, i) => !(/^(AND|OR|NOT)$/.test(w) && /^(AND|OR|NOT)$/.test(words[i + 1] ?? "")))
while (words.length && /^(AND|OR|NOT)$/.test(words[0]!)) words.shift()
while (words.length && /^(AND|OR|NOT)$/.test(words[words.length - 1]!)) words.pop()
words = words.map((w) => (/^\u0000\d+\u0000$/.test(w) || /^(AND|OR|NOT)$/.test(w) ? w : /[.\-_]/.test(w.replace(/\*$/, "")) ? `"${w.replace(/\*$/, "")}"` : w))
return words.join(" ").replace(/\u0000(\d+)\u0000/g, (_, i: string) => `"${phrases[Number(i)]}"`)
}
/** The same terms, any of them (the retry when all of them found nothing). */
export const anyOf = (q: string) => {
const terms = q.match(/"[^"]*"|\S+/g) ?? []
return terms.length > 1 && !terms.some((t) => /^(AND|OR|NOT)$/.test(t)) ? terms.join(" OR ") : undefined
}
/** What a person would read of a stored message. */
export function readable(json: string): { role: string; text: string } {
const m = JSON.parse(json) as Message
if (m.role === "tool") return { role: "tool", text: `[${m.name} result, ${m.content.length} chars]` }
const text = m.parts
.map((p) => (p.type === "text" ? p.text : p.type === "tool_call" ? `[called ${p.name}]` : p.type === "image" ? "[image]" : ""))
.filter(Boolean)
.join("\n")
return { role: m.role, text }
}
const cap = (t: string, n: number) => (t.length > n ? `${t.slice(0, n)}… [${t.length - n} more chars]` : t)
const when = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " ")
const line = (r: Row, n: number) => {
const m = readable(r.json)
return `#${r.id} ${m.role}: ${cap(m.text.trim(), n)}`
}
const header = (s: SessionRow) => `${s.id} · ${when(s.created)} · ${s.root}${s.title ? ` · "${s.title}"` : ""} · ${s.model}`
function snippet(text: string, query: string): string {
const terms = (query.match(/"[^"]*"|\S+/g) ?? []).map((t) => t.replace(/^"|"$|\*$/g, "").toLowerCase()).filter((t) => t && !/^(and|or|not)$/.test(t))
const low = text.toLowerCase()
const at = Math.max(0, Math.min(...terms.map((t) => low.indexOf(t)).filter((i) => i >= 0), text.length))
const start = Math.max(0, at - 60)
return `${start ? "…" : ""}${text.slice(start, at + 140).replace(/\s+/g, " ").trim()}${at + 140 < text.length ? "…" : ""}`
}
function around(rows: Row[], id: number, window: number): Row[] {
const i = rows.findIndex((r) => r.id === id)
if (i < 0) throw new ToolError(`That session has no message #${id}.`)
return rows.slice(Math.max(0, i - window), i + window + 1)
}
export const sessionSearchTool = defineTool({
...described("session_search"),
purpose: false,
access: "session_search",
schema: z.object({
query: z.string().optional(),
session_id: z.string().optional(),
around_message_id: z.number().int().optional(),
window: z.number().int().optional(),
limit: z.number().int().optional(),
sort: z.enum(["newest", "oldest"]).optional(),
this_project: z.boolean().optional(),
}),
permission: (a) => ({ permission: "session_search", class: "read", patterns: [a.query ?? a.session_id ?? "recent"] }),
async run(a, ctx) {
const src = ctx.sessions
if (!src) throw new ToolError("There is no session store here (sessions are not being recorded).")
const store = src.store
const current = src.current()
const inScope = (s: SessionRow | undefined) => !!s && s.id !== current && (!a.this_project || s.root === ctx.root)
if (a.session_id) {
const s = store.session(a.session_id)
if (!s) throw new ToolError(`There is no session ${a.session_id}.`)
if (s.id === current) throw new ToolError("That is this session — it is already in your context.")
const rows = store.rows(s.id)
if (a.around_message_id !== undefined) {
const w = Math.min(20, Math.max(1, a.window ?? 5))
const win = around(rows, a.around_message_id, w)
const first = win[0]!.id
const last = win[win.length - 1]!.id
return {
output: `${header(s)}\n\n${win.map((r) => line(r, 4000)).join("\n\n")}\n\n${first === rows[0]!.id ? "(start of the session) " : ""}${last === rows[rows.length - 1]!.id ? "(end of the session) " : ""}Scroll on with around_message_id=${last} (forward) or ${first} (back).`,
title: `${s.id} #${first}–${last}`,
}
}
const shown = rows.length > 30 ? [...rows.slice(0, 20), ...rows.slice(-10)] : rows
const note = rows.length > 30 ? `\n\n(${rows.length} messages; showing the first 20 and the last 10 — pass around_message_id to read the middle.)` : ""
return { output: `${header(s)}\n\n${shown.map((r) => line(r, 2000)).join("\n\n")}${note}`, title: `${s.id} · ${rows.length} messages` }
}
if (!a.query?.trim()) {
const n = Math.min(10, Math.max(1, a.limit ?? 10))
const list = store.sessions(n + 1, a.this_project ? ctx.root : undefined, true).filter((s) => s.id !== current).slice(0, n)
if (!list.length) return { output: "No past sessions.", title: "0 sessions" }
const out = list.map((s) => {
const first = store.rows(s.id).find((r) => r.role === "user")
return `${header(s)} · updated ${when(s.updated)}${first ? `\n first: ${cap(readable(first.json).text.split("\n")[0]!, 120)}` : ""}`
})
return { output: out.join("\n"), title: `${list.length} recent sessions` }
}
const n = Math.min(10, Math.max(1, a.limit ?? 3))
let match = ftsQuery(a.query)
if (!match) throw new ToolError("The query has no searchable words.")
const find = (m: string) => {
try {
return store.hits(m, { exclude: current ? [current] : [], sort: a.sort, root: a.this_project ? ctx.root : undefined, perSession: true, limit: n })
} catch (e) {
throw new ToolError(`The search could not be run (${(e as Error).message}). Use plain words, "quoted phrases", OR and NOT.`)
}
}
let hits = find(match)
let relaxed = false
if (!hits.length && anyOf(match)) {
match = anyOf(match)!
hits = find(match)
relaxed = hits.length > 0
}
// The best hit per session, the sessions in rank order.
const seen = new Map<string, (typeof hits)[number]>()
const info = new Map<string, SessionRow | undefined>()
for (const h of hits) {
if (seen.has(h.session_id)) continue
if (!info.has(h.session_id)) info.set(h.session_id, store.session(h.session_id))
if (!inScope(info.get(h.session_id))) continue
seen.set(h.session_id, h)
if (seen.size >= n) break
}
if (!seen.size)
return { output: `No past session matches. All terms must match: try fewer words, OR between alternatives, "a quoted phrase", or a prefix like deploy*.`, title: "no match" }
const blocks = [...seen.values()].map((h, i) => {
const s = info.get(h.session_id)!
const m = readable(h.json)
const head = `${i + 1}. ${header(s)}\n matched ${m.role} message #${h.id}: ${snippet(m.text, match)}`
if (i > 0) return head
const win = around(store.rows(s.id), h.id, 5).filter((r) => r.role !== "tool" || r.id === h.id)
return `${head}\n\n${win.map((r) => ` ${line(r, 1500).replace(/\n/g, "\n ")}`).join("\n\n")}`
})
return {
output: `${relaxed ? "Nothing had all the terms; these have some of them.\n\n" : ""}${blocks.join("\n\n")}\n\nRead more with session_search(session_id, around_message_id).`,
title: `${seen.size} session${seen.size === 1 ? "" : "s"}`,
}
},
})
+74
View File
@@ -0,0 +1,74 @@
// The settings tool: the user can tell the agent "think harder", "switch to the big model", "plan
// first" — and it changes the setting, with the user's approval (settings_tool: ask, the default).
// Only what config/settings.ts marks for the agent; never permission rules, the hardline, MCP,
// connections, updates, or this tool's own gate. A less strict mode is asked about every time.
import { z } from "zod"
import { describe, findSetting, parseValue, show, type Entry } from "../config/settings.ts"
import type { Mode } from "../config/schema.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
const MODE_RANK: Record<Mode, number> = { auto: 0, edit: 1, manual: 2, plan: 3 }
const line = (e: Entry) =>
`${e.def.key} = ${show(e.value) || "(unset)"} [${e.source}]${e.def.values ? ` — ${e.def.values.join(" | ")}` : ""}${e.def.agent ? "" : " — user only"}${e.def.reload ? " — after a restart" : ""}`
function api(ctx: ToolContext) {
if (!ctx.settings) throw new ToolError("Settings cannot be changed here.")
return ctx.settings
}
export const settingsTool = defineTool({
...described("settings"),
access: "settings",
schema: z.object({
action: z.enum(["list", "get", "set"]),
key: z.string().optional(),
value: z.string().optional(),
scope: z.enum(["session", "global", "project"]).optional(),
}),
permission(a, ctx) {
if (a.action === "list") return { permission: "settings", class: "interact", patterns: ["list"] }
if (a.action === "get") return { permission: "settings", class: "interact", patterns: [`get ${a.key ?? ""}`] }
const scope = a.scope ?? "session"
const value = a.value ?? ""
let alwaysAsk: string | undefined
// Loosening the mode is the one change no rule or mode may wave through. Judged on the value as
// it will be applied (trimmed, checked); anything that does not read as a mode is asked too.
if (a.key?.trim() === "mode") {
let next: Mode | undefined
try {
next = parseValue(findSetting("mode")!, value) as Mode
} catch {}
const now = ctx.settings?.get("mode").value as Mode | undefined
if (!next || !now || MODE_RANK[next] < MODE_RANK[now]) alwaysAsk = `a less strict permission mode (${now ?? "?"} → ${next ?? value}) is always asked`
}
return { permission: "settings", class: "interact", patterns: [`${scope} ${a.key}=${value}`], always: [`${scope} ${a.key}=*`], alwaysAsk }
},
async run(a, ctx) {
const s = api(ctx)
if (a.action === "list") {
const entries = s.list()
return {
output: `${entries.map(line).join("\n")}\n\nConfigured models: ${s.models().join(", ") || "(none)"}`,
title: `${entries.length} settings`,
}
}
if (!a.key) throw new ToolError(`${a.action} needs a key.`)
const def = findSetting(a.key)
if (!def) throw new ToolError(`"${a.key}" is not a setting. List them with action "list".`)
if (a.action === "get") {
const e = s.get(a.key)
return { output: `${line(e)}\n${describe(def)}`, title: `${a.key} = ${show(e.value)}` }
}
if (!def.agent) throw new ToolError(`${a.key} is the user's to change, not yours: tell them to use /settings or edit config.yaml.`)
if (a.value === undefined) throw new ToolError("set needs a value.")
try {
const r = s.set(a.key, a.value, a.scope ?? "session")
const when = a.key === "model" || a.key === "effort" ? " It applies from your next step." : ""
return { output: `Done: ${r.message}.${when}`, title: `${a.key} = ${show(r.value)} (${r.scope})` }
} catch (e) {
throw new ToolError((e as Error).message)
}
},
})
+299
View File
@@ -0,0 +1,299 @@
// skills_list, skill_view and skill_manage, after Hermes Agent (tools/skills_tool.py,
// tools/skill_manager_tool.py; MIT, © Nous Research). See src/skill/index.ts.
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
import { basename, dirname, join, relative } from "node:path"
import { paths } from "../config/paths.ts"
import { z } from "zod"
import { threatMessage } from "../memory/threats.ts"
import { within, findSkill, globalSkillsDir, INDEX_DESCRIPTION, linkedFiles, loadSkills, MAX_DESCRIPTION, MAX_FILE, MAX_NAME, NAME, type Skill, skillFile, splitFrontmatter, SUPPORT_DIRS } from "../skill/index.ts"
import { replace } from "./replace-text.ts"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
const skillsOf = (ctx: ToolContext): Skill[] => ctx.skills?.list() ?? loadSkills()
export const skillsListTool = defineTool({
...described("skills_list"),
purpose: false,
access: "skills",
schema: z.object({ category: z.string().optional() }),
permission: () => ({ permission: "skills", class: "read", patterns: ["list"] }),
async run(a, ctx) {
const all = skillsOf(ctx)
const shown = a.category ? all.filter((s) => (s.category ?? "general") === a.category) : all
if (!shown.length) return { output: a.category ? `No skills in "${a.category}". Categories: ${[...new Set(all.map((s) => s.category ?? "general"))].join(", ") || "(none)"}` : "No skills yet.", title: "0 skills" }
const out = shown.map((s) => `${s.name}${s.category ? ` [${s.category}]` : ""}${s.source === "project" ? " (project)" : ""} — ${s.description.slice(0, MAX_DESCRIPTION)}`)
return { output: `${out.join("\n")}\n\nLoad one with skill_view(name).`, title: `${shown.length} skills` }
},
})
export const skillViewTool = defineTool({
...described("skill_view"),
purpose: false,
access: "skills",
schema: z.object({
name: z.string(),
file_path: z.string().optional(),
}),
permission: (a) => ({ permission: "skills", class: "read", patterns: [a.name] }),
async run(a, ctx) {
const skills = skillsOf(ctx)
const s = findSkill(skills, a.name)
if (!s) throw new ToolError(`There is no skill "${a.name}". Skills: ${skills.map((x) => x.name).join(", ") || "(none)"}`)
if (a.file_path) {
try {
return { output: skillFile(s, a.file_path).slice(0, MAX_FILE), title: `${s.name}/${a.file_path}` }
} catch (e) {
throw new ToolError((e as Error).message)
}
}
const body = splitFrontmatter(readFileSync(s.file, "utf8"))?.body.trim() ?? ""
const files = linkedFiles(s.dir)
const head = [`# Skill: ${s.name}`, s.description, `Directory: ${s.dir}`, ...(s.related.length ? [`Related skills: ${s.related.join(", ")}`] : [])].join("\n")
const tail = files.length ? `\n\nSupporting files (skill_view(name, file_path) reads one; run a script by its absolute path):\n${files.map((f) => `- ${f}`).join("\n")}` : ""
return { output: `${head}\n\n${body.slice(0, MAX_FILE)}${tail}`, title: s.name }
},
})
// Revisions (after LLeMbas's skill_revisions): every change keeps what it replaced, under the data
// directory — never beside the skills, where the loader would find the copies. `revert` puts the
// last one back, keeping the current state as a revision in its turn (so twice is no change).
const KEEP_REVISIONS = 20
const historyDir = (name: string) => join(paths.data, "skill-history", name)
function saveRevision(name: string, dir: string, files: Map<string, string>) {
if (!files.size) return
const at = join(historyDir(name), new Date().toISOString().replace(/[:.]/g, "-"))
for (const [f, text] of files) {
const to = join(at, "files", relative(dir, f))
mkdirSync(dirname(to), { recursive: true })
writeFileSync(to, text)
}
writeFileSync(join(at, "dir"), dir)
const all = readdirSync(historyDir(name)).sort()
for (const old of all.slice(0, Math.max(0, all.length - KEEP_REVISIONS))) rmSync(join(historyDir(name), old), { recursive: true, force: true })
}
/** Every file under a directory, by path — links are not followed, and not kept. */
function filesUnder(dir: string): Map<string, string> {
const out = new Map<string, string>()
if (!existsSync(dir) || lstatSync(dir).isSymbolicLink()) return out
for (const f of new Bun.Glob("**/*").scanSync({ cwd: dir, dot: false, followSymlinks: false })) {
const p = join(dir, f)
if (lstatSync(p).isFile()) out.set(p, readFileSync(p, "utf8"))
}
return out
}
export function revisions(name: string): string[] {
return existsSync(historyDir(name)) ? readdirSync(historyDir(name)).sort() : []
}
const Op = z.object({
action: z.enum(["create", "patch", "write_file", "remove_file", "delete", "revert"]),
name: z.string(),
description: z.string().optional(),
instructions: z.string().optional(),
content: z.string().optional(),
category: z.string().optional(),
where: z.enum(["global", "project"]).optional(),
old_string: z.string().optional(),
new_string: z.string().optional(),
replace_all: z.boolean().optional(),
file_path: z.string().optional(),
file_content: z.string().optional(),
})
type Op = z.infer<typeof Op>
const SHAPE = (name: string) => `It must look like this:\n---\nname: ${name}\ndescription: Use when …\n---\n\nThe instructions.\n(Or send description and instructions instead of content, and the file is written for you.)`
/** A SKILL.md from its parts; YAML-quoted so a colon in the description cannot break it. */
export const composeSkill = (name: string, description: string, instructions: string) =>
`---\nname: ${name}\ndescription: ${JSON.stringify(description.trim().replace(/\s+/g, " "))}\n---\n\n${instructions.trim()}\n`
/** What a SKILL.md must be: frontmatter with a valid name and a description, and a body. */
export function validateSkill(text: string, name: string): string | undefined {
if (text.length > MAX_FILE) return `SKILL.md is ${text.length} characters; the limit is ${MAX_FILE}. Move detail into references/.`
const fm = splitFrontmatter(text)
if (!fm) return `SKILL.md must start with frontmatter between two --- lines. ${SHAPE(name)}`
if (String(fm.meta.name ?? "") !== name) return `The frontmatter name must be "${name}".`
const d = String(fm.meta.description ?? "").trim()
if (!d) return `The frontmatter needs a description: one sentence saying when to use the skill. ${SHAPE(name)}`
if (d.length > MAX_DESCRIPTION) return `The description is ${d.length} characters; the limit is ${MAX_DESCRIPTION}.`
if (!fm.body.trim()) return `SKILL.md needs instructions after the closing --- line. ${SHAPE(name)}`
return undefined
}
function supportPath(s: { dir: string }, rel?: string): string {
if (!rel) throw new ToolError("file_path is needed")
const parts = rel.split(/[\\/]/)
if (rel.startsWith("/") || parts.includes("..") || !(SUPPORT_DIRS as readonly string[]).includes(parts[0]!) || parts.length < 2)
throw new ToolError(`"${rel}" must be a path inside the skill under ${SUPPORT_DIRS.join("/, ")}/`)
const p = join(s.dir, rel)
if (!within(s.dir, p)) throw new ToolError(`"${rel}" leads out of the skill's directory`)
return p
}
export const skillManageTool = defineTool({
...described("skill_manage"),
access: "skill_manage",
schema: z.object({ operations: z.array(Op).min(1).max(20) }),
permission: (a) => ({ permission: "skill_manage", class: "write", patterns: a.operations.map((o) => `${o.action} ${o.name}`) }),
async run(a, ctx) {
const ops = a.operations as Op[]
if (ops.some((o) => o.action === "delete" || o.action === "revert") && ops.length > 1) throw new ToolError(`${ops.find((o) => o.action === "delete" || o.action === "revert")!.action} must be the only operation in its call.`)
// Every name is checked before anything else: it becomes a directory name below.
for (const o of ops) if (!NAME.test(o.name) || o.name.length > MAX_NAME) throw new ToolError(`"${o.name}" is not a valid skill name: lowercase letters, digits, - _ . — starting with a letter or digit, up to ${MAX_NAME}.`)
const skills = skillsOf(ctx)
if (ops[0]!.action === "revert") {
const name = ops[0]!.name
const revs = revisions(name)
if (!revs.length) throw new ToolError(`"${name}" has no earlier version kept.`)
const at = join(historyDir(name), revs.at(-1)!)
const now = findSkill(skills, name)
// Where it goes back to: the skill as loaded now, else where it was kept from — and only ever
// a directory of that name inside a skills directory.
const dir = now?.dir ?? readFileSync(join(at, "dir"), "utf8").trim()
const roots = [globalSkillsDir(), ...(ctx.skills?.projectDir ? [join(ctx.skills.projectDir, "skills")] : [])]
if (basename(dir) !== name || !roots.some((r) => within(r, dir) && dir !== r)) throw new ToolError(`the kept version of "${name}" points outside the skills directories; not restored`)
const current = filesUnder(now?.dir ?? dir)
const back = filesUnder(join(at, "files"))
rmSync(at, { recursive: true, force: true })
saveRevision(name, now?.dir ?? dir, current)
// The skill as it was: those files, and nothing it gained since.
rmSync(now?.dir ?? dir, { recursive: true, force: true })
for (const [f, text] of back) {
const to = join(dir, relative(join(at, "files"), f))
mkdirSync(dirname(to), { recursive: true })
writeFileSync(to, text)
}
ctx.skills?.changed?.()
return { output: `Reverted ${name} to its version of ${revs.at(-1)!.slice(0, 19).replace("T", " ")}. The version it replaced is kept: revert again to undo this.`, title: `revert ${name}` }
}
// Files as they were, to put back if anything fails.
const backup = new Map<string, string | undefined>()
// Whose file each one is: what is kept as a revision once the call succeeds.
const owner = new Map<string, { name: string; dir: string }>()
let whole: { name: string; dir: string; files: Map<string, string> } | undefined
let current: { name: string; dir: string } | undefined
const keep = (f: string) => {
if (!backup.has(f)) backup.set(f, existsSync(f) ? readFileSync(f, "utf8") : undefined)
if (current && !owner.has(f)) owner.set(f, current)
}
const write = (f: string, text: string) => {
keep(f)
mkdirSync(dirname(f), { recursive: true })
writeFileSync(`${f}.tmp`, text)
renameSync(`${f}.tmp`, f)
written.push(f)
}
const created = new Map<string, { dir: string; file: string }>()
const notes: string[] = []
const written: string[] = []
try {
for (const [i, o] of ops.entries()) {
const at = ops.length > 1 ? `operation ${i + 1} (${o.action} ${o.name}): ` : ""
const fail = (m: string): never => {
throw new ToolError(at + m)
}
if (!NAME.test(o.name) || o.name.length > MAX_NAME) fail(`"${o.name}" is not a valid skill name: lowercase letters, digits, - _ . — starting with a letter or digit, up to ${MAX_NAME}.`)
const existing = created.get(o.name) ?? findSkill(skills, o.name)
if (o.action === "create") {
if (existing) fail(`a skill "${o.name}" already exists (${existing.dir}). Use patch.`)
if (o.category && (!NAME.test(o.category) || o.category.includes("/"))) fail(`"${o.category}" is not a valid category.`)
const text = o.content ?? (o.description?.trim() && o.instructions?.trim() ? composeSkill(o.name, o.description, o.instructions) : fail("create needs description and instructions (or content: the whole SKILL.md)."))
const bad = validateSkill(text, o.name) ?? threatMessage(text, "The skill")
if (bad) fail(bad)
const base = o.where === "project" ? (ctx.skills?.projectDir ? join(ctx.skills.projectDir, "skills") : fail("this project has no project directory, so the skill cannot go there.")) : globalSkillsDir()
const dir = join(base, ...(o.category ? [o.category] : []), o.name)
// A directory the loader skipped (disabled, another OS, bad frontmatter) is still somebody's.
if (existsSync(dir)) fail(`${dir} already exists, though it is not a loaded skill (disabled, for another system, or its SKILL.md is not valid). Choose another name, or ask the user.`)
const file = join(dir, "SKILL.md")
write(file, text.endsWith("\n") ? text : text + "\n")
created.set(o.name, { dir, file })
const d = String(splitFrontmatter(text)!.meta.description).trim()
if (d.length > INDEX_DESCRIPTION) notes.push(`${o.name}: the skills list shows only "${d.slice(0, INDEX_DESCRIPTION - 3)}..." — put the trigger first.`)
continue
}
if (!existing) fail(`there is no skill "${o.name}".`)
const s = existing!
// A skill made in this same call has no earlier version to keep.
current = created.has(o.name) ? undefined : { name: o.name, dir: s.dir }
if (o.action === "delete") {
whole = { name: o.name, dir: s.dir, files: filesUnder(s.dir) }
keep(s.file)
rmSync(s.dir, { recursive: true, force: true })
continue
}
if (o.action === "write_file") {
const f = supportPath(s, o.file_path)
const text = o.file_content ?? fail("write_file needs file_content.")
if (text.length > MAX_FILE) fail(`the file is ${text.length} characters; the limit is ${MAX_FILE}.`)
const bad = threatMessage(text, "The file")
if (bad) fail(bad)
write(f, text)
continue
}
if (o.action === "remove_file") {
const f = supportPath(s, o.file_path)
if (!existsSync(f)) fail(`${o.file_path} does not exist.`)
keep(f)
rmSync(f)
continue
}
// patch
const f = o.file_path ? supportPath(s, o.file_path) : s.file
if (!existsSync(f)) fail(`${o.file_path ?? "SKILL.md"} does not exist.`)
const before = readFileSync(f, "utf8")
let after: string
if (o.old_string !== undefined) {
try {
after = replace(before, o.old_string, o.new_string ?? "", o.replace_all ?? false)
} catch (e) {
fail(`${(e as Error).message} old_string must be the exact text now in the file — read it with skill_view first. The file begins:\n${before.slice(0, 500)}`)
}
} else after = o.content ?? fail("patch needs old_string and new_string (or content: a whole new file).")
if (f === s.file) {
const bad = validateSkill(after!, o.name)
if (bad) fail(`the patch would break SKILL.md: ${bad}`)
}
const threat = threatMessage(after!, "The skill")
if (threat) fail(threat)
write(f, after!)
}
} catch (e) {
// All or nothing: put back every file this call touched.
for (const [f, text] of backup) {
if (text === undefined) rmSync(f, { force: true })
else {
mkdirSync(dirname(f), { recursive: true })
writeFileSync(f, text)
}
}
for (const { dir } of created.values()) rmSync(dir, { recursive: true, force: true })
throw e
}
// Kept as a revision: every file this call changed or removed, as it was before.
if (whole) saveRevision(whole.name, whole.dir, whole.files)
else {
// The whole skill as it was: as it is now, with what this call changed put back.
const bySkill = new Map<string, { dir: string; files: Map<string, string> }>()
for (const [f, text] of backup) {
const o = owner.get(f)
if (!o) continue
const entry = bySkill.get(o.name) ?? { dir: o.dir, files: filesUnder(o.dir) }
if (text === undefined) entry.files.delete(f)
else entry.files.set(f, text)
bySkill.set(o.name, entry)
}
for (const [name, e] of bySkill) saveRevision(name, e.dir, e.files)
}
ctx.skills?.changed?.()
const summary = ops.map((o) => `${o.action} ${o.name}${o.file_path ? `/${o.file_path}` : ""}`).join(", ")
const files = [...new Set(written)]
return {
output: `Done: ${summary}.${files.length ? `\nWritten: ${files.join(", ")}` : ""}${notes.length ? `\n${notes.join("\n")}` : ""}\nskill_view shows a skill as it is now; the skills list in your instructions changes from the next session.`,
title: summary.slice(0, 80),
}
},
})
+36
View File
@@ -0,0 +1,36 @@
import { z } from "zod"
import { defineTool, ToolError } from "./tool.ts"
import { described } from "../harness.ts"
export interface SubagentResult {
text: string
steps: number
tools: number
reason: string
}
export const taskTool = defineTool({
...described("task"),
access: "task",
schema: z.object({
description: z.string(),
prompt: z.string().min(1),
agent: z.string().optional(),
worktree: z.boolean().optional(),
model: z.string().optional(),
}),
permission: (a) => ({ permission: "task", class: "interact", patterns: [a.model ? `${a.agent ?? "explore"} on ${a.model}` : (a.agent ?? "explore")] }),
async run(a, ctx) {
if (!ctx.spawn) throw new ToolError("Subagents cannot start subagents.")
const agent = a.agent ?? "explore"
let r: Awaited<ReturnType<NonNullable<typeof ctx.spawn>>>
try {
r = await ctx.spawn(agent, a.prompt, ctx.signal, ctx.callId, { worktree: a.worktree, description: a.description, model: a.model })
} catch (e) {
if (e instanceof ToolError) throw e
throw new ToolError((e as Error).message)
}
const tail = `\n\n[${agent} subagent · ${r.steps} step${r.steps === 1 ? "" : "s"} · ${r.tools} tool call${r.tools === 1 ? "" : "s"}${r.reason !== "stop" ? ` · ended: ${r.reason}` : ""}]`
return { output: (r.text.trim() || "(the subagent returned no report)") + tail, title: `${agent}${a.model ? ` on ${a.model}` : ""}: ${a.description} · ${r.steps} steps`, isError: r.reason === "error" }
},
})
+40
View File
@@ -0,0 +1,40 @@
// A working todo list for multi-step tasks. The whole list is sent each time (OpenCode's
// todowrite): simpler for small models than Hermes' merge-by-id, and nothing can drift.
import { z } from "zod"
import { defineTool } from "./tool.ts"
import { described } from "../harness.ts"
export const TODO_STATUSES = ["pending", "in_progress", "completed", "cancelled"] as const
export type Todo = { content: string; status: (typeof TODO_STATUSES)[number] }
const MARK: Record<Todo["status"], string> = { pending: "[ ]", in_progress: "[>]", completed: "[x]", cancelled: "[~]" }
export function renderTodos(items: Todo[]): string {
return items.map((t) => `${MARK[t.status]} ${t.content}`).join("\n") || "(empty)"
}
export const todoTool = defineTool({
...described("todo"),
purpose: false,
access: "todo",
schema: z.object({
todos: z
.array(
z.preprocess(
(t) => (typeof t === "string" ? { content: t, status: "pending" } : t),
z.object({ content: z.string().min(1), status: z.enum(TODO_STATUSES).default("pending") }),
),
)
.max(30),
}),
permission: () => ({ permission: "todo", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
const items = a.todos as Todo[]
if (ctx.todos) {
ctx.todos.items = items
ctx.todos.changed?.(items)
}
const done = items.filter((t) => t.status === "completed").length
return { output: renderTodos(items), title: `${done}/${items.length} done` }
},
})
+175
View File
@@ -0,0 +1,175 @@
import { existsSync } from "node:fs"
import { isAbsolute, relative, resolve } from "node:path"
import { z } from "zod"
import type { PermissionRequest } from "../permission/evaluate.ts"
import { expandHome } from "../config/paths.ts"
import type { ToolSpec } from "../provider/types.ts"
import { PURPOSE_DEF, TOOL_SPECS } from "../harness.ts"
export interface ToolContext {
/** Absolute project root. */
root: string
/** Absolute working directory for relative paths and shells. */
cwd: string
signal: AbortSignal
/** Absolute paths read in this session — `edit`/`write` of an existing file requires one. */
readFiles: Set<string>
/** mtime (ms) of each file when it was last read or written by a tool, to detect outside edits. */
fileStamps: Map<string, number>
bashTimeoutMs: number
/** Instruction files (AGENTS.md / CLAUDE.md) the model has already been given. */
instructionsSeen?: Set<string>
/** Ask the user questions (the ask_user tool). Absent where nobody can answer. */
question?: (req: import("./question.ts").QuestionRequest, callId?: string) => Promise<import("./question.ts").QuestionReply>
/** Run a subagent (the task tool). Absent inside a subagent: no recursion. */
spawn?: (agent: string, prompt: string, signal: AbortSignal, callId?: string, opts?: { worktree?: boolean; description?: string; model?: string }) => Promise<import("./task.ts").SubagentResult>
/** The id of the call being run (set by the engine per call). */
callId?: string
/** The project directory (.agent), when the project has one. */
projectDir?: string
/** The plans directory (plan mode writes only there). */
planDir?: string
/** Present a plan for approval (plan_submit). Absent where nobody can answer. */
plan?: (req: { path: string; text: string }, callId?: string) => Promise<import("./plan_exit.ts").PlanReply>
/** Change the permission mode (plan_submit, on approval). */
setMode?: (mode: import("../config/schema.ts").Mode) => void
/** Web search and fetch settings (config `search:`). */
search?: import("../search/index.ts").SearchConfig
/** Persistent memory (the memory tool); the default store when absent. */
memory?: import("../memory/store.ts").MemoryStore
/** This project's own memory (.agent/local/MEMORY.md), in a trusted project with a .agent. */
projectMemory?: import("../memory/store.ts").MemoryStore
/** Skills as they are on disk now, and where a new one may go. */
skills?: { list(): import("../skill/index.ts").Skill[]; projectDir?: string; changed?(): void }
/** Past sessions (session_search). */
sessions?: { store: import("../session/store.ts").Store; current(): string | undefined }
/** Settings (the settings tool): read and change them; the configured models. */
settings?: {
get(key: string): import("../config/settings.ts").Entry
list(): import("../config/settings.ts").Entry[]
set(key: string, value: unknown, scope?: import("../config/settings.ts").Scope): import("../settings.ts").SetResult
models(): string[]
}
/** The library: notes, and the knowledge bases this project searches (all, when
* `bases` is undefined); `project` is the scope of this project's notes. */
library?: {
lib: import("../library/store.ts").Library
project?: string
bases?: string[]
embedder?: import("../library/store.ts").Embedder
}
/** The session's todo list (the todo tool). */
todos?: { items: import("./todo.ts").Todo[]; changed?: (items: import("./todo.ts").Todo[]) => void }
}
export interface ToolResult {
output: string
/** One-line summary for the UI: `src/app.ts +12 −3`, `exit 0 · 0.4s`. */
title?: string
isError?: boolean
/** UI-only extras (a unified diff, exit code…). Never sent to the model. */
meta?: Record<string, unknown>
/** Images for the model (view_image): sent in a user turn after the tool results. */
images?: import("../provider/types.ts").ImagePart[]
}
export interface Tool<S extends z.ZodType = z.ZodType> {
name: string
/** The permission key the tool is checked under; several tools can share one (edit, write). */
access: string
/** Runs alone, never alongside other calls of the same step (it waits on the user). */
exclusive?: boolean
/** Offered only when this holds: a model with vision, plan mode, or a project directory. */
requires?: "vision" | "plan" | "project" | "knowledge"
description: string
schema: S
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
jsonSchema?: Record<string, unknown>
/** Where the model says what a call is for, shown when the user is asked to allow it: the
* `purpose` argument added to every tool (the default), a field of the tool's own (bash's
* `description`), or false for a tool that never asks. */
purpose?: string | false
permission(args: z.infer<S>, ctx: ToolContext): PermissionRequest
/** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */
preview?(args: z.infer<S>, ctx: ToolContext): Promise<string>
run(args: z.infer<S>, ctx: ToolContext): Promise<ToolResult>
}
export function defineTool<S extends z.ZodType>(t: Tool<S>): Tool<S> {
return t
}
export const PURPOSE = PURPOSE_DEF.name
// The argument's schema is the harness spec's (harness/purpose.json), as LLeMbas sends it too.
const PURPOSE_SPEC = PURPOSE_DEF.schema
/** The argument that carries a call's purpose for this tool, or undefined when it has none. A
* tool (an MCP server's) that already takes an argument named `purpose` keeps it as its own. */
export function purposeKey(t: Tool): string | undefined {
if (t.purpose === false) return undefined
if (typeof t.purpose === "string") return t.purpose
const props = t.jsonSchema?.properties as Record<string, unknown> | undefined
return props && PURPOSE in props ? undefined : PURPOSE
}
/** Take the injected `purpose` out of a call's arguments: the tool itself never sees it. */
export function splitPurpose(t: Tool, raw: unknown): { args: unknown; purpose?: string } {
const key = purposeKey(t)
if (!key || !raw || typeof raw !== "object" || Array.isArray(raw)) return { args: raw }
const v = (raw as Record<string, unknown>)[key]
const purpose = typeof v === "string" && v.trim() ? v.trim() : undefined
if (key !== PURPOSE) return { args: raw, purpose }
const { [PURPOSE]: _, ...rest } = raw as Record<string, unknown>
return { args: rest, purpose }
}
export function toSpec(t: Tool): ToolSpec {
let schema: Record<string, unknown>
if (t.jsonSchema) schema = t.jsonSchema
// A built-in tool's parameters are the harness spec's, as LLeMbas sends them too; its zod schema
// validates the call, and tests/harness.test.ts keeps the two the same shape.
else if (TOOL_SPECS[t.name]) schema = structuredClone(TOOL_SPECS[t.name]!.parameters)
else {
schema = z.toJSONSchema(t.schema, { target: "draft-7", io: "input" }) as Record<string, unknown>
delete schema.$schema
}
if (purposeKey(t) === PURPOSE) schema = { ...schema, properties: { ...((schema.properties as Record<string, unknown>) ?? {}), [PURPOSE]: PURPOSE_SPEC } }
return { name: t.name, description: t.description, parameters: schema }
}
/** A file's text exactly as it is, byte-order mark included. Bun's `.text()` drops a BOM, so a tool
* that read with it and wrote the result back took the mark off every file it changed. */
export async function readText(abs: string): Promise<string> {
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(await Bun.file(abs).bytes())
}
/** A tool's path argument, absolute. Models send both absolute and relative paths. */
export function absPath(p: string, ctx: ToolContext): string {
// A model writes ~/x as a shell would; without this it becomes a directory named "~".
if (p === "~" || p.startsWith("~/")) return resolve(expandHome(p))
if (isAbsolute(p)) return resolve(p)
const rel = resolve(ctx.cwd, p)
// Small models often drop the leading "/" of an absolute path ("home/me/proj/a.py"). When the
// relative reading does not exist and the absolute one does, inside the project, take that.
if (!existsSync(rel)) {
const abs = resolve("/", p)
if ((abs === ctx.root || abs.startsWith(ctx.root + "/")) && existsSync(abs)) return abs
}
return rel
}
/** For display and rule matching: relative to the root when inside it. */
export function showPath(abs: string, ctx: ToolContext): string {
const rel = relative(ctx.root, abs)
return rel && !rel.startsWith("..") && !isAbsolute(rel) ? rel : abs
}
export class ToolError extends Error {}
/** Cap tool output sent to the model; the head and tail are what matter. */
export function truncate(text: string, max = 30_000): string {
if (text.length <= max) return text
const head = Math.floor(max * 0.7)
const tail = max - head
return `${text.slice(0, head)}\n\n[… ${text.length - max} characters omitted …]\n\n${text.slice(-tail)}`
}
+165
View File
@@ -0,0 +1,165 @@
// Applying a unified diff — the second format apply_patch takes (harness spec v1), beside the
// "*** Begin Patch" envelope. Ported from LLeMbas services/agent/patch.py (© Jaroslav Beneš,
// MIT), where it is file_edit. Four behaviours carry it, each because of how models
// actually write patches rather than how the format is specified:
//
// - Fuzzy offset, exact content. `@@ -41,7 +41,8 @@` is a hint: the hinted position is tried
// first, then the file is scanned outward (MAX_DRIFT) for an exact match of the context. One
// match wins; more than one refuses — guessing between identical blocks silently corrupts a file.
// - Line endings are normalised in and restored out, or a CRLF file fails on every hunk.
// - A blank context line may have lost its leading space (trailing whitespace gets stripped).
// - Nothing is written unless every hunk applies; the caller writes the whole result or nothing.
export const MAX_HUNKS = 60
export const MAX_DRIFT = 200
const MISMATCH_WINDOW = 3
const HEADER = /^@@\s*-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s*@@/
const NO_NEWLINE = "\\ No newline at end of file"
export class UnidiffError extends Error {}
export interface UniHunk {
oldStart: number
oldCount: number
lines: string[]
/** `\ No newline at end of file` after a line this hunk adds: the result ends without one. */
endsWithoutNewline: boolean
}
/** One file's part of a unified diff. `path` is the +++ side (the --- side for a deletion); absent
* when the diff carries no file headers, and then the call's `path` names the file. */
export interface UniFile {
path?: string
create: boolean
remove: boolean
hunks: UniHunk[]
}
const before = (h: UniHunk) => h.lines.filter((l) => l[0] === " " || l[0] === "-").map((l) => l.slice(1))
const after = (h: UniHunk) => h.lines.filter((l) => l[0] === " " || l[0] === "+").map((l) => l.slice(1))
/** A unified diff looks like one: a hunk header somewhere in it. */
export function isUnified(text: string): boolean {
return /^@@\s*-\d+/m.test(text) && !/^\*\*\* Begin Patch/m.test(text)
}
function headerPath(raw: string): string | undefined {
// `--- a/src/x.ts\t2026-…` → src/x.ts; /dev/null → undefined.
const p = raw.slice(4).split("\t")[0]!.trim()
if (p === "/dev/null") return undefined
return p.replace(/^[ab]\//, "")
}
/** Read a unified diff into files and hunks. `diff --git` and `index` lines are tolerated and
* ignored; `---`/`+++` name the file when present. The `@@` header is required. */
export function parseUnified(patch: string): UniFile[] {
const files: UniFile[] = []
let file: UniFile | undefined
let hunk: UniHunk | undefined
const body = patch.replace(/\r\n?/g, "\n").split("\n")
// The patch's own final newline is not a blank context line.
if (body.at(-1) === "") body.pop()
const flush = () => {
if (hunk && file) file.hunks.push(hunk)
hunk = undefined
}
for (let i = 0; i < body.length; i++) {
const raw = body[i]!
// A file header is the pair `--- a/x` / `+++ b/x`; a lone `--- …` inside a hunk is a removal.
if (raw.startsWith("--- ") && body[i + 1]?.startsWith("+++ ")) {
flush()
const minus = headerPath(raw)
const plus = headerPath(body[++i]!)
file = { path: plus ?? minus, create: minus === undefined, remove: plus === undefined, hunks: [] }
files.push(file)
continue
}
const m = HEADER.exec(raw)
if (m) {
flush()
if (!file) files.push((file = { create: false, remove: false, hunks: [] }))
hunk = { oldStart: Number(m[1]), oldCount: m[2] === undefined ? 1 : Number(m[2]), lines: [], endsWithoutNewline: false }
continue
}
if (!hunk) continue // preamble: diff --git, index, anything before the first @@
if (raw.startsWith(NO_NEWLINE)) {
// Only the new side is an instruction; the old side describes the file, which is read anyway.
const last = hunk.lines.at(-1)
if (last && (last[0] === "+" || last[0] === " ")) hunk.endsWithoutNewline = true
continue
}
if (raw[0] === "+" || raw[0] === "-" || raw[0] === " ") hunk.lines.push(raw)
else if (raw === "") hunk.lines.push(" ")
else flush() // a stray line ends the hunk rather than corrupting it
}
flush()
const hunks = files.reduce((n, f) => n + f.hunks.length, 0)
if (!hunks)
throw new UnidiffError("That patch has no hunks. A unified diff needs at least one `@@ -old,count +new,count @@` header, followed by the lines: ' ' for context, '-' to remove, '+' to add.")
if (hunks > MAX_HUNKS) throw new UnidiffError(`That patch has ${hunks} hunks, and ${MAX_HUNKS} is the most applied at once. Rewrite the file with write instead, or send the change in pieces.`)
return files
}
function find(lines: string[], wanted: string[], hint: number, floor: number): number {
if (!wanted.length) return Math.max(floor, Math.min(hint, lines.length))
const span = wanted.length
const at = (i: number) => wanted.every((w, k) => lines[i + k] === w)
if (hint >= floor && hint + span <= lines.length && at(hint)) return hint
const matches: number[] = []
for (let i = Math.max(floor, hint - MAX_DRIFT); i <= Math.min(lines.length - span, hint + MAX_DRIFT); i++) if (at(i)) matches.push(i)
if (matches.length === 1) return matches[0]!
if (matches.length > 1)
throw new UnidiffError(
`Those context lines appear ${matches.length} times in the file, and the line numbers in the hunk header do not point at any of them, so there is no way to tell which was meant. Include more unchanged lines around the change.`,
)
throw new UnidiffError("")
}
function around(lines: string[], hint: number): string {
if (!lines.length) return " (the file is empty)"
const pad = (n: number) => String(n + 1).padStart(5)
if (hint >= lines.length) {
const start = Math.max(0, lines.length - MISMATCH_WINDOW)
const shown = []
for (let n = start; n < lines.length; n++) shown.push(` ${pad(n)} ${lines[n]}`)
return [...shown, ` (the file ends at line ${lines.length})`].join("\n")
}
const shown = []
for (let n = Math.max(0, hint - MISMATCH_WINDOW); n < Math.min(lines.length, hint + MISMATCH_WINDOW + 1); n++) shown.push(`${n === hint ? "->" : " "} ${pad(n)} ${lines[n]}`)
return shown.join("\n")
}
/** The file with every hunk applied, or an UnidiffError naming the first that would not. Hunks
* apply in order against a cursor, so a duplicated hunk cannot apply the same change twice. */
export function applyUnified(text: string, hunks: UniHunk[]): string {
const crlf = text.includes("\r\n")
const lines = text.replace(/\r\n?/g, "\n").split("\n")
const trailing = lines.at(-1) === ""
if (trailing) lines.pop()
const out: string[] = []
let cursor = 0
let reachedEnd = false
hunks.forEach((h, i) => {
const wanted = before(h)
// A pure insertion names the line it goes after, not the line it replaces.
const hint = h.oldCount === 0 ? h.oldStart : Math.max(h.oldStart - 1, 0)
let at: number
try {
at = find(lines, wanted, hint, cursor)
} catch (e) {
const why = (e as Error).message
if (why) throw new UnidiffError(`Hunk ${i + 1} did not apply. ${why} Nothing was written.`)
const expected = h.lines.find((l) => l[0] === " " || l[0] === "-")?.slice(1) ?? ""
throw new UnidiffError(
`Hunk ${i + 1} did not apply. It expects line ${hint + 1} to be\n ${expected}\nbut the file has\n${around(lines, hint)}\nand those lines are nowhere else nearby either. Nothing was written. Send a patch whose context matches what is printed above.`,
)
}
out.push(...lines.slice(cursor, at), ...after(h))
cursor = at + wanted.length
reachedEnd = h.endsWithoutNewline && cursor >= lines.length
})
out.push(...lines.slice(cursor))
let result = out.join("\n")
if (trailing && !reachedEnd) result += "\n"
return crlf ? result.replace(/\n/g, "\r\n") : result
}
+22
View File
@@ -0,0 +1,22 @@
import { z } from "zod"
import { loadImage } from "../project/image.ts"
import { absPath, defineTool, showPath, ToolError } from "./tool.ts"
import { described } from "../harness.ts"
export const viewImageTool = defineTool({
...described("view_image"),
access: "read",
requires: "vision",
schema: z.object({ path: z.string() }),
permission: (a, ctx) => {
const abs = absPath(a.path, ctx)
return { permission: "read", class: "read", patterns: [showPath(abs, ctx)], paths: [abs] }
},
async run(a, ctx) {
const abs = absPath(a.path, ctx)
const img = loadImage(abs)
if (typeof img === "string") throw new ToolError(img)
const kb = Math.round((img.data.length * 3) / 4 / 1024)
return { output: `Showing ${showPath(abs, ctx)} (${img.mime}, ${kb} KB) in the next message.`, title: `${showPath(abs, ctx)} · ${kb} KB`, images: [img] }
},
})
+83
View File
@@ -0,0 +1,83 @@
import { z } from "zod"
import { firecrawlScrape } from "../search/firecrawl.ts"
import { PrivateAddressError, fetchPage, isPrivateHost } from "../search/fetch.ts"
import { search, searchOrder } from "../search/index.ts"
import { webuiFetch } from "../search/webui.ts"
import { defineTool, ToolError, truncate } from "./tool.ts"
import { described } from "../harness.ts"
export const webSearchTool = defineTool({
...described("web_search"),
access: "web_search",
schema: z.object({
query: z.string().min(1),
max_results: z.number().int().min(1).max(20).optional(),
}),
permission: (a) => ({ permission: "web_search", class: "read", patterns: [a.query] }),
async run(a, ctx) {
const c = ctx.search ?? {}
const r = await search(a.query, c, ctx.signal, a.max_results ?? c.max_results ?? 8).catch((e) => {
throw new ToolError((e as Error).message)
})
const lines = r.results.map((x, i) => `${i + 1}. ${x.title}\n ${x.url}${x.snippet ? `\n ${x.snippet.slice(0, 300)}` : ""}`)
const note = r.failed.length ? `\n\n(${r.failed.join("; ")} — answered by ${r.provider})` : ""
return { output: (lines.join("\n") || "No results.") + note, title: `${a.query} · ${r.results.length} results · ${r.provider}` }
},
})
export const webFetchTool = defineTool({
...described("web_fetch"),
access: "web_fetch",
schema: z.object({
url: z.string().url(),
offset: z.number().int().min(0).optional(),
}),
permission: (a) => {
let host = ""
try {
host = new URL(a.url).hostname
} catch {}
// Pages on this machine or the local network are asked about: a page the model read
// earlier must not be able to steer it into internal services unseen.
return { permission: "web_fetch", class: "read", patterns: [isPrivateHost(host) ? `private:${host}` : host] }
},
async run(a, ctx) {
const u = new URL(a.url)
if (u.protocol !== "http:" && u.protocol !== "https:") throw new ToolError("only http and https URLs")
const c = ctx.search ?? {}
let title: string | undefined
let text: string
if (c.fetch === "firecrawl" && c.firecrawl) {
const r = await firecrawlScrape(a.url, c, ctx.signal)
title = r.title
text = r.markdown
} else if (c.fetch === "webui" && c.webui && !isPrivateHost(u.hostname)) {
// Through the instance — except a page on this network, which it cannot (and should not) reach.
const r = await webuiFetch(a.url, c, ctx.signal)
title = r.title
text = r.text
} else {
// A local host the user was asked about (and allowed) may be fetched; any other local address
// — a redirect, or a public name that resolves to one — is refused.
let r: Awaited<ReturnType<typeof fetchPage>>
try {
r = await fetchPage(a.url, ctx.signal, { allowPrivate: isPrivateHost(u.hostname) ? u.hostname : undefined })
} catch (e) {
if (e instanceof PrivateAddressError) throw new ToolError(e.message)
throw e
}
title = r.title
text = r.text
}
const from = a.offset ?? 0
const PAGE = 20_000
const part = text.slice(from, from + PAGE)
const more = from + PAGE < text.length ? `\n\n(${text.length - from - PAGE} more characters — call again with offset ${from + PAGE})` : ""
return {
output: `${title ? `# ${title}\n` : ""}${a.url}\n\n${truncate(part, PAGE + 1000)}${more}`,
title: `${u.host}${u.pathname.length > 1 ? u.pathname.slice(0, 40) : ""} · ${Math.round(text.length / 1000)}k chars`,
}
},
})
export { searchOrder }
+66
View File
@@ -0,0 +1,66 @@
import { mkdir, stat } from "node:fs/promises"
import { dirname } from "node:path"
import { createTwoFilesPatch } from "diff"
import { z } from "zod"
import { straySkillNote } from "../skill/index.ts"
import { absPath, defineTool, showPath, ToolError, type ToolContext } from "./tool.ts"
import { described } from "../harness.ts"
/** Refuse to overwrite a file the model has not read, or one changed on disk since it was read. */
export async function guardExisting(abs: string, display: string, ctx: ToolContext): Promise<boolean> {
const st = await stat(abs).catch(() => undefined)
if (!st) return false
if (!st.isFile()) throw new ToolError(`${display} is not a regular file`)
if (!ctx.readFiles.has(abs)) throw new ToolError(`${display} exists and has not been read in this session. Read it first.`)
const seen = ctx.fileStamps.get(abs)
if (seen !== undefined && st.mtimeMs > seen + 1)
throw new ToolError(`${display} changed on disk since you last read it. Read it again before changing it.`)
return true
}
export function diffStat(patch: string): { added: number; removed: number } {
let added = 0
let removed = 0
for (const l of patch.split("\n")) {
if (l.startsWith("+") && !l.startsWith("+++")) added++
else if (l.startsWith("-") && !l.startsWith("---")) removed++
}
return { added, removed }
}
export const writeTool = defineTool({
...described("write"),
access: "edit",
schema: z.object({
path: z.string(),
content: z.string(),
}),
permission: (a, ctx) => {
const abs = absPath(a.path, ctx)
return { permission: "edit", class: "write", patterns: [showPath(abs, ctx)], paths: [abs] }
},
async preview(a, ctx) {
const abs = absPath(a.path, ctx)
const display = showPath(abs, ctx)
const before = (await guardExisting(abs, display, ctx)) ? await Bun.file(abs).text() : ""
return createTwoFilesPatch(display, display, before, a.content, "", "", { context: 3 })
},
async run(a, ctx) {
const abs = absPath(a.path, ctx)
const display = showPath(abs, ctx)
const existed = await guardExisting(abs, display, ctx)
const before = existed ? await Bun.file(abs).text() : ""
await mkdir(dirname(abs), { recursive: true })
await Bun.write(abs, a.content)
const st = await stat(abs)
ctx.readFiles.add(abs)
ctx.fileStamps.set(abs, st.mtimeMs)
const patch = createTwoFilesPatch(display, display, before, a.content, "", "", { context: 3 })
const { added, removed } = diffStat(patch)
return {
output: (existed ? `Replaced ${display} (+${added} −${removed}).` : `Created ${display} (${a.content.split("\n").length} lines).`) + straySkillNote([abs], ctx),
title: `${display} +${added} −${removed}`,
meta: { diff: patch },
}
},
})