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:
355 files changed
+47028
No files matched your search
@@ -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 } }
|
||||
},
|
||||
})
|
||||
@@ -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` }
|
||||
},
|
||||
})
|
||||
@@ -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 } }
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
@@ -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}` : ""}` }
|
||||
},
|
||||
})
|
||||
@@ -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]}` }
|
||||
},
|
||||
})
|
||||
@@ -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 } }
|
||||
},
|
||||
})
|
||||
@@ -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])))]
|
||||
}
|
||||
@@ -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
|
||||
@@ -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` }
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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) }
|
||||
},
|
||||
})
|
||||
@@ -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)" }
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -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("")
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 } }
|
||||
},
|
||||
})
|
||||
@@ -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"}`,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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),
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -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" }
|
||||
},
|
||||
})
|
||||
@@ -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` }
|
||||
},
|
||||
})
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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] }
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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 },
|
||||
}
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user