Files

944 lines
47 KiB
JavaScript

/**
* @local/omp-edit — oh-my-pi style editing for DSH.
*
* Replaces DSH's built-in `edit` (and wraps `read` / `grep`) in every agent scope:
* - hashline (default): `[PATH#TAG]` + `PUT/CUT/REM/MV` patches anchored on line numbers of a
* content-hashed snapshot, block (`N*`) anchors, cut/paste registers, multi-file, stale-tag
* recovery;
* - replace: fuzzy-whitespace string replacement with batched `edits`;
* - sloppy: `*** Find / *** Replace / *** Insert Before|After` anchored patches;
* - apply_patch: Codex-style `*** Begin Patch` add/update/delete/move.
*
* Mode resolution (per agent, per call): model-specific `modelVariants` → `mode` → hashline,
* with oh-my-pi's fallback of weak-at-hashline model families to `replace` unless `strict`.
*
* Shadowing: DSH tools layers are per agent scope and a scoped registration shadows a
* global/preset one, so every agent gets this plugin's `edit`/`read`/`grep` registered into its
* own scope on `agent/created`. The built-in definitions keep working underneath (read/grep
* delegate to them; edit is replaced outright).
*/
import path from 'node:path'
import fs from 'node:fs'
import os from 'node:os'
import { randomUUID } from 'node:crypto'
import { SnapshotStore, rebaseOps } from './lib/store.js'
import { parseHashline, applySection } from './lib/hashline.js'
import { applyReplace } from './lib/replace.js'
import { parseSloppy, applySloppy } from './lib/sloppy.js'
import { parseApplyPatch, applyUpdate } from './lib/apply_patch.js'
import { hunkDiffs } from './lib/diff.js'
import {
EditError, detectEol, fileTag, formatHeader, joinLines, numbered, preview, restoreEol, splitLines, toLF,
} from './lib/text.js'
import * as P from './lib/prompts.js'
export const name = 'omp-edit'
export const inject = ['tools', 'fs', 'agents']
const MODES = ['hashline', 'replace', 'sloppy', 'apply_patch']
// oh-my-pi falls these families back from hashline to replace unless strict mode is on.
const DEFAULT_WEAK = ['kimi', 'mimo', 'minimax', 'deepseek', 'stepfun', 'codex-spark', 'glm-5.3-flash']
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v)
// Shell patterns the guard rejects. Each entry: [regex, message]. Matching runs per command
// segment (split on ; && || |) after quoted strings are blanked, so text inside quotes such as a
// commit message or an echo argument cannot trigger it.
//
// The guard only blocks WRITING through the shell. Reading (cat/head/tail/sed -n/grep/find/ls -R …)
// is allowed; the bash wrapper pages its output instead (see pageShellResult).
const SHELL_RULES = [
[/(^|\s)sed\s+(-[a-zA-Z]*i|--in-place)/, 'in-place sed edits are not allowed — use the edit tool (hashline patch).'],
[/(^|\s)perl\s+-[a-zA-Z]*i/, 'perl -i edits are not allowed — use the edit tool.'],
[/(^|\s)(ed|ex)\s/, 'ed/ex edits are not allowed — use the edit tool.'],
[/(^|\s)(tee)\s+(-a\s+)?[^-\s]/, 'writing files with tee is not allowed — use the edit tool (or write for new files).'],
[/(^|[^0-9&<>])>>?\s*(?!&|\/dev\/(null|stderr|stdout))[^\s|&;]/, 'shell redirection into files is not allowed — use the edit tool (or write for new files).'],
[/<<-?\s*['"]?\w+/, 'heredocs are not allowed for writing files — use the edit tool (or write for new files).'],
// Inline interpreter scripts (node -e/-p/--eval/--print, python -c, ruby/perl -e, deno eval…)
// are checked separately by inlineScriptViolation(): harmless computation passes, anything that
// can reach the filesystem or spawn a process is denied.
[/(^|\s)(truncate|dd)\s/, 'truncate/dd are not allowed — use the edit tool.'],
[/(^|\s)find\s.*\s-(delete|fprint\w*|fls|fprintf)\b/, 'find -delete/-fprint write or remove files — use the edit tool (read-only find is fine).'],
[/(^|\s)(awk|gawk)\s+.*(-i\s*inplace|--in-place)/, 'awk in-place edits are not allowed — use the edit tool.'],
]
/** awk programs live in quotes (blanked above), so writes from inside them are checked on the raw command. */
const AWK_WRITE = /(^|[\s;&|(])g?awk\b[\s\S]*(\bsystem\s*\(|\bprintf?\b[^;}\n]*>|\|\s*["'][^"']*["']\s*[;}]|\bclose\s*\()/
// ---------------------------------------------------------------- inline scripts
//
// Allowlist, not denylist: an inline script is allowed only when its body contains NO identifier
// that can reach the filesystem, a subprocess, the network, dynamic code or a module loader.
// Anything we cannot read (script piped via stdin, `-` as the program, a script file) is denied.
// Obfuscation is covered because the identifiers that *start* any I/O path (require, import,
// process, globalThis, eval, Function, open, os, subprocess, __import__, …) are themselves banned —
// `fs['write'+'File']` still needs `require`/`import` to obtain `fs`.
/** Interpreters and the flags that take an inline program as their next argument. */
const INTERPRETERS = [
{ re: /^(node|nodejs|bun)$/, flags: ['-e', '--eval', '-p', '--print', '-pe', '-ep'], stdin: true },
{ re: /^deno$/, flags: ['eval'], stdin: true },
{ re: /^(python|python[23](\.\d+)?|pypy3?)$/, flags: ['-c'], stdin: true },
{ re: /^ruby$/, flags: ['-e'], stdin: true },
{ re: /^perl$/, flags: ['-e', '-E'], stdin: true },
{ re: /^php$/, flags: ['-r'], stdin: true },
{ re: /^(lua|luajit)$/, flags: ['-e'], stdin: true },
]
const JS_BANNED = /\b(require|import|process|globalThis|global|window|self|module|exports|eval|Function|constructor|prototype|__proto__|Deno|Bun|fetch|XMLHttpRequest|WebSocket|Worker|setTimeout|setInterval|queueMicrotask|Reflect|Proxy|Symbol|WebAssembly|child_process|fs|net|http|https|dgram|vm|os)\b|\[\s*['"`]|\bthis\b/
const PY_BANNED = /\b(import|__import__|open|exec|eval|compile|globals|locals|vars|getattr|setattr|delattr|__builtins__|__class__|__subclasses__|__dict__|__globals__|builtins|os|sys|subprocess|shutil|pathlib|Path|io|socket|ctypes|input|breakpoint|help|memoryview)\b/
const RB_BANNED = /\b(File|Dir|IO|Kernel|Process|system|exec|spawn|fork|open|require|load|eval|send|public_send|instance_eval|class_eval|const_get|ObjectSpace|Socket|ENV|STDIN|ARGF|Open3|`)\b|`|%x/
const PL_BANNED = /\b(open|opendir|unlink|rename|mkdir|rmdir|system|exec|fork|qx|require|use|do|eval|chmod|chown|symlink|link|truncate|sysopen|readpipe|glob|socket)\b|`|<\s*\w*\s*>/
const GENERIC_BANNED = /\b(io|os|require|load|dofile|loadfile|loadstring|exec|system|popen|shell_exec|passthru|proc_open|fopen|file_get_contents|file_put_contents|unlink|include|eval)\b|`/
function bannedFor(name) {
if (/^(node|nodejs|bun|deno)$/.test(name)) return JS_BANNED
if (/^(python|python[23](\.\d+)?|pypy3?)$/.test(name)) return PY_BANNED
if (name === 'ruby') return RB_BANNED
if (name === 'perl') return PL_BANNED
return GENERIC_BANNED
}
/** Minimal shell tokenizer: words with quotes removed (single, double, $'…'), plus operators. */
function tokenize(cmd) {
const out = []
let cur = ''; let has = false; let i = 0
const s = String(cmd)
const push = () => { if (has) out.push(cur); cur = ''; has = false }
while (i < s.length) {
const c = s[i]
if (c === "'") { const j = s.indexOf("'", i + 1); cur += s.slice(i + 1, j < 0 ? s.length : j); has = true; i = j < 0 ? s.length : j + 1; continue }
if (c === '"') {
let j = i + 1
while (j < s.length && s[j] !== '"') { if (s[j] === '\\' && j + 1 < s.length) { cur += s[j + 1]; j += 2; continue } cur += s[j++] }
has = true; i = j + 1; continue
}
if (c === '\\' && i + 1 < s.length) { cur += s[i + 1]; has = true; i += 2; continue }
if (/\s/.test(c)) { push(); i++; continue }
if (c === ';' || c === '|' || c === '&' || c === '(' || c === ')' || c === '\n') {
push()
const two = s.slice(i, i + 2)
if (two === '&&' || two === '||') { out.push(two); i += 2 } else { out.push(c); i++ }
continue
}
cur += c; has = true; i++
}
push()
return out
}
const OPS = new Set([';', '|', '&', '&&', '||', '(', ')', '\n'])
/**
* Deny inline-interpreter scripts that could read, write, run or fetch anything.
* @returns a denial message, or null when every inline program is plain computation.
*/
export function inlineScriptViolation(cmd) {
const toks = tokenize(cmd)
for (let i = 0; i < toks.length; i++) {
// Command position: start, or right after an operator (skipping env-style prefixes).
if (i > 0 && !OPS.has(toks[i - 1])) continue
let k = i
while (k < toks.length && (/^(sudo|command|exec|time|nice|nohup|env)$/.test(toks[k]) || /^\w+=/.test(toks[k]) || /^-/.test(toks[k]) && /^(sudo|env|nice)$/.test(toks[k - 1] ?? ''))) k++
const prog = (toks[k] ?? '').split('/').pop()
const interp = INTERPRETERS.find((x) => x.re.test(prog))
if (!interp) continue
const deny = (why) => `Blocked by omp-edit guard: ${why} — use read/grep/edit for files (computation-only one-liners are fine). (command: ${String(cmd).slice(0, 120)})`
// Collect this command's arguments.
const args = []
for (let j = k + 1; j < toks.length && !OPS.has(toks[j]); j++) args.push(toks[j])
const fromPipe = i > 0 && toks[i - 1] === '|'
let program = null
for (let j = 0; j < args.length; j++) {
const a = args[j]
const eq = /^(--eval|--print)=(.*)$/s.exec(a)
if (eq) { program = eq[2]; break }
if (interp.flags.includes(a)) { program = args[j + 1] ?? ''; break }
// Combined short flags ending in the program flag (node -pe / python -Bc).
if (/^-[a-zA-Z]+$/.test(a) && interp.flags.some((f) => f.length === 2 && a.endsWith(f[1]) && f !== a)) { program = args[j + 1] ?? ''; break }
}
if (program === null) {
const firstOperand = args.find((a) => !a.startsWith('-'))
// A script read from stdin (`… | node`, `node -`, `node < f`) cannot be inspected.
if (fromPipe || args.includes('-') || firstOperand === undefined) {
if (args.some((a) => /^(--version|-v|-V|--help|-h)$/.test(a))) continue
return deny(`${prog} reading its program from stdin cannot be checked`)
}
continue // `node script.js`: running a file is a normal command, not an inline script
}
if (bannedFor(prog).test(program)) return deny(`this ${prog} one-liner can reach files, processes or the network`)
}
return null
}
/** Return a denial message when `cmd` mutates, reads or searches files through the shell. */
export function shellViolation(cmd) {
const inline = inlineScriptViolation(cmd)
if (inline) return inline
if (AWK_WRITE.test(String(cmd))) return `Blocked by omp-edit guard: this awk program writes files or runs commands — use the edit tool. (command: ${String(cmd).slice(0, 120)})`
const blanked = String(cmd)
.replace(/'[^']*'/g, "''")
.replace(/"(?:\\.|[^"\\])*"/g, '""')
// Heredocs and redirections are checked on the whole line (quotes already blanked).
// Split into commands; remember which ones read a pipe (their file-less form only filters output).
const parts = blanked.split(/(;|&&|\|\||\||\n)/)
let piped = false
for (let i = 0; i < parts.length; i += 2) {
const seg = parts[i].trim().replace(/^(sudo|env\s+\S+=\S+|command|exec|time|nice)\s+/, '')
const fromPipe = piped
piped = parts[i + 1] === '|'
if (!seg) continue
for (const [re, msg, pipeOk] of SHELL_RULES) {
if (!re.test(seg)) continue
if (fromPipe && pipeOk && pipeOk.test(seg)) continue
return `Blocked by omp-edit guard: ${msg} (command: ${String(cmd).slice(0, 120)})`
}
}
return null
}
// ---------------------------------------------------------------- shell output paging
//
// bash may read any file, but the model sees at most `pageLines` lines of a stream per call.
// The complete output (the executor's spill file when it truncated, else the text itself) is kept
// in a file under the OS temp dir and the footer tells the model how to read the next lines with
// the paged `read` tool.
const OUTPUT_DIR = path.join(os.tmpdir(), 'omp-edit-output')
const OUTPUT_TTL_MS = 24 * 3600 * 1000
const MAX_FULL_BYTES = 64 * 1024 * 1024
/** Write the full output of a stream to a private temp file, sweeping files older than a day. */
function saveFullOutput(text) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true, mode: 0o700 })
try {
const now = Date.now()
for (const f of fs.readdirSync(OUTPUT_DIR)) {
const p = path.join(OUTPUT_DIR, f)
try { if (now - fs.statSync(p).mtimeMs > OUTPUT_TTL_MS) fs.unlinkSync(p) } catch {}
}
} catch {}
const file = path.join(OUTPUT_DIR, `${randomUUID()}.txt`)
fs.writeFileSync(file, text, { mode: 0o600 })
return file
}
/**
* Cut `text` to its first `pageLines` lines plus a footer that names the lines still unseen and
* the read call that fetches them. Returns null when everything fits in one page.
* @param {string} text full stream text
* @param {number} pageLines page size
* @param {(text: string) => string} save persists the full text, returning its path
* @param {string} [label] stream name shown in the footer
*/
export function pageText(text, pageLines, save, label = 'output') {
const body = text.endsWith('\n') ? text.slice(0, -1) : text
const lines = body.split('\n')
if (lines.length <= pageLines) return null
const file = save(text)
const next = pageLines + 1
const nextEnd = Math.min(pageLines * 2, lines.length)
const head = lines.slice(0, pageLines).join('\n')
return {
file,
total: lines.length,
text: `${head}\n[${label} lines 1-${pageLines} of ${lines.length}. Read lines ${next}-${nextEnd}: read file_path="${file}" offset=${next} (up to ${pageLines} lines per call)]`,
}
}
/** Page the stdout/stderr of a finished foreground bash result; other result kinds pass through. */
export function pageShellResult(value, pageLines, save = saveFullOutput) {
if (!isObj(value) || value.kind !== 'foreground') return value
const out = { ...value }
for (const stream of ['stdout', 'stderr']) {
const s = value[stream]
if (!isObj(s) || typeof s.text !== 'string') continue
let full = s.text
if (s.truncated) {
// The executor kept only the tail; the complete stream is in its spill file.
try {
if (!s.spillPath || fs.statSync(s.spillPath).size > MAX_FULL_BYTES) continue
full = fs.readFileSync(s.spillPath, 'utf8')
} catch { continue }
}
const paged = pageText(full, pageLines, save, stream)
if (!paged) continue
out[stream] = { ...s, text: paged.text, truncated: false, spillPath: paged.file }
}
return out
}
/** Clone a parameters schema, documenting the page cap on `limit`. */
function capLimit(params, max) {
const p = structuredClone(params ?? { type: 'object', properties: {} })
if (p.properties?.limit) p.properties.limit = { ...p.properties.limit, description: `Maximum lines to return (default and max ${max}).` }
return p
}
/** Clone a parameters schema and add an integer `offset` property. */
function withOffset(params, description) {
const p = structuredClone(params ?? { type: 'object', properties: {} })
p.properties = { ...(p.properties ?? {}), offset: { type: 'integer', description } }
return p
}
export function resolveMode(config, model) {
const m = String(model || '').toLowerCase()
for (const [needle, mode] of Object.entries(config.modelVariants || {})) {
if (needle && m.includes(needle.toLowerCase()) && MODES.includes(mode)) return mode
}
const base = MODES.includes(config.mode) ? config.mode : 'hashline'
if (base === 'hashline' && !config.strict) {
const weak = Array.isArray(config.fallbackModels) ? config.fallbackModels : DEFAULT_WEAK
if (weak.some((w) => w && m.includes(String(w).toLowerCase()))) return config.fallbackMode || 'replace'
}
return base
}
export function apply(ctx, config = {}) {
const cfg = {
mode: 'hashline',
strict: false,
fallbackMode: 'replace',
fallbackModels: DEFAULT_WEAK,
modelVariants: {},
replaceRead: true,
replaceGrep: true,
replaceEdit: true,
replaceGlob: true,
// bash may read anything; its output is paged to pageLines per call (writes stay guarded).
replaceBash: true,
fuzzy: true,
// read/grep/glob return at most this many lines/matches/paths per call.
pageLines: 200,
// Guard: deny non-hashline mutations and shell-based reading/searching.
guard: true,
guardTools: ['write', 'str_replace_editor', 'apply_patch'],
...config,
}
/** @type {WeakMap<object, SnapshotStore>} per session */
const stores = new WeakMap()
const storeFor = (exec) => {
const owner = exec?.agent?.session ?? ctx
let s = stores.get(owner)
if (!s) { s = new SnapshotStore(); stores.set(owner, s) }
return s
}
/** Latest provider/model seen per agent (from agent/request). */
const models = new WeakMap()
const modeFor = (agent) => resolveMode(cfg, agent ? (models.get(agent) ?? agent.options?.model ?? '') : '')
const cwdOf = (exec) => exec?.agent?.session?.header?.cwd
const resolveTarget = (p, exec) => ctx.fs.resolve(String(p).replace(/^\[(.+?)#[0-9A-Fa-f]{4}\]$/, '$1'), { ...(cwdOf(exec) ? { cwd: cwdOf(exec) } : {}), signal: exec?.signal })
const relPath = (target, exec) => {
const cwd = cwdOf(exec)
const p = target.displayPath
if (cwd && path.isAbsolute(p)) { const r = path.relative(cwd, p); if (r && !r.startsWith('..') && !path.isAbsolute(r)) return r }
return p
}
// ---------------------------------------------------------------- fs helpers (respect DSH policy)
const sandboxPolicyFor = (exec) => {
try { return ctx.get('sandboxPolicy')?.resolve({ ...(exec?.agent ? { session: exec.agent.session } : {}) }) } catch { return undefined }
}
async function readFile(target, exec) {
const info = await ctx.fs.stat(target, exec?.signal)
if (!info) throw new EditError(`${target.displayPath}: not found. Use \`write\` to create new files.`)
if (info.type !== 'file') throw new EditError(`${target.displayPath}: not a regular file.`)
const raw = await ctx.fs.readText(target, exec?.signal)
return { raw, text: toLF(raw), version: info.version }
}
/**
* DSH's edit gate (fs-observation-policy): the file must have been read in this session.
* `checkVersion` additionally keeps DSH's "changed since it was read" rule (replace/sloppy/
* apply_patch); hashline uses its snapshot tag instead, which also allows recovery.
* Afterwards the current version is reported as observed so the guarded write below
* targets exactly the text this call read.
*/
async function editGate(target, exec, cur, { checkVersion }) {
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
if (checkVersion && intent?.version !== undefined && intent.version !== cur.version) {
throw new EditError(`cannot edit "${target.displayPath}": file changed since it was read — re-read the file, then retry`)
}
ctx.emit('fs/observed', target, { kind: 'present', version: cur.version }, exec)
}
/** Guarded write through ctx.fs (stale-safe against our own read), then report the observation. */
async function writeFile(target, content, exec, { create = false, unconditional = false } = {}) {
const intent = unconditional ? undefined : create ? { kind: 'createIfAbsent' } : await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
const outcome = await ctx.fs.writeText(target, content, intent, exec?.signal, sandboxPolicyFor(exec))
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
return outcome
}
async function deleteFile(target, exec) {
const p = ctx.fs.processPath(target)
const policy = sandboxPolicyFor(exec)
if (policy?.mode === 'read-only') throw new EditError(`[sandbox: deleting ${target.displayPath} is denied in read-only mode]`)
if (policy?.mode === 'workspace-write' && policy.workspaceRoot) {
const r = path.relative(policy.workspaceRoot, p)
if (r.startsWith('..') || path.isAbsolute(r)) throw new EditError(`[sandbox: deleting ${target.displayPath} is outside the workspace]`)
}
const fsp = await import('node:fs/promises')
await fsp.unlink(p)
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
}
// ---------------------------------------------------------------- shared mutation planner
//
// Every mode produces a plan: [{target, rel, before, after, op: 'update'|'create'|'delete'|'move', dest?}]
// validated completely before anything is written; then committed in order.
async function commit(plan, exec) {
const results = []
for (const step of plan) {
if (step.op === 'delete') { await deleteFile(step.target, exec); results.push({ path: step.rel, operation: 'delete' }); continue }
const eol = step.eol ?? '\n'
const content = restoreEol(step.after, eol)
if (step.op === 'move') {
await writeFile(step.destTarget, content, exec, { unconditional: true })
await deleteFile(step.target, exec)
results.push({ path: step.rel, movedTo: step.destRel, operation: 'move' })
continue
}
await writeFile(step.target, content, exec, { create: step.op === 'create' })
results.push({ path: step.rel, operation: step.op })
}
return results
}
/** After a successful write: record the new snapshot, build the tagged preview. */
function report(store, step, regions, exec) {
if (step.op === 'delete') return `${step.rel}: deleted.`
const key = String((step.destTarget ?? step.target).targetKey)
const tag = store.record(key, step.after)
const lines = splitLines(step.after).lines
const shown = preview(lines, regions ?? [], { context: 2 })
const head = formatHeader(step.destRel ?? step.rel, tag)
const moved = step.op === 'move' ? ` (moved from ${step.rel})` : ''
return [head + moved + (shown.length ? '' : ` — ${lines.length} lines`), ...shown].join('\n')
}
// ---------------------------------------------------------------- hashline mode
async function runHashline(args, exec) {
if (typeof args.input !== 'string' || !args.input.trim()) throw new EditError('input must be a non-empty hashline patch string.')
const store = storeFor(exec)
const { sections, warnings } = parseHashline(args.input)
const plan = []
const anon = { value: null }
// Named registers publish only after the whole call lands.
const registers = new Map(store.registers)
const regionsByStep = new Map()
const seen = new Map()
for (const sec of sections) {
const target = await resolveTarget(sec.path, exec)
const key = String(target.targetKey)
if (seen.has(key)) throw new EditError(`${sec.path}: appears in two sections; put all of a file's ops in one section.`)
seen.set(key, true)
const cur = await readFile(target, exec)
const curTag = fileTag(cur.text)
let ops = sec.ops
let baseText = cur.text
if (sec.tag !== curTag) {
const snap = store.byTag(key, sec.tag)
if (!snap) {
const others = store.keysWithTag(sec.tag)
const hint = others.length ? ` That tag belongs to another file (${others.map((k) => path.basename(k)).join(', ')}).` : ''
throw new EditError(`${sec.path}: snapshot tag #${sec.tag} is not current (file is now #${curTag}) and was never issued for this file.${hint} Re-read the file and use its [PATH#TAG] header.`)
}
const rb = rebaseOps(snap.text, cur.text, ops)
if (!rb) {
throw new EditError(`${sec.path}: file changed since #${sec.tag} (now #${curTag}) and the anchored lines moved or changed, so the patch was not applied. Re-read the file and resend with the new tag.`)
}
await editGate(target, exec, cur, { checkVersion: false })
ops = rb.ops
const off = [...rb.offsets].filter((o) => o !== 0)
warnings.push(`${sec.path}: recovered stale tag #${sec.tag} → #${curTag}${off.length ? ` (lines shifted by ${[...new Set(off)].join(', ')})` : ''}.`)
} else {
await editGate(target, exec, cur, { checkVersion: false })
}
const { lines, eol } = splitLines(baseText)
const res = applySection({ ...sec, ops }, { path: sec.path, lines, registers, anon, warnings })
const rel = relPath(target, exec)
if (res.rem) { plan.push({ op: 'delete', target, rel, before: cur.text, after: '' }); continue }
const after = joinLines(res.lines, eol || res.lines.length > 0)
const step = { op: 'update', target, rel, before: cur.text, after, eol: detectEol(cur.raw) }
if (res.mv) {
const destTarget = await resolveTarget(res.mv, exec)
step.op = 'move'; step.destTarget = destTarget; step.destRel = relPath(destTarget, exec)
} else if (after === cur.text) {
throw new EditError(`${sec.path}: the patch produced no change.`)
}
regionsByStep.set(step, res.regions)
plan.push(step)
}
const results = await commit(plan, exec)
for (const [k, v] of registers) store.registers.set(k, v)
const out = plan.map((s) => report(store, s, regionsByStep.get(s), exec))
return finish(plan, results, out, warnings)
}
// ---------------------------------------------------------------- replace mode
async function runReplace(args, exec) {
const store = storeFor(exec)
const target = await resolveTarget(args.file_path, exec)
const cur = await readFile(target, exec)
await editGate(target, exec, cur, { checkVersion: true })
const rel = relPath(target, exec)
const list = Array.isArray(args.edits) && args.edits.length
? args.edits
: [{ old_string: args.old_string, new_string: args.new_string, replace_all: args.replace_all }]
let text = cur.text
const warnings = []
for (const [i, e] of list.entries()) {
if (!isObj(e) || typeof e.old_string !== 'string' || typeof e.new_string !== 'string') throw new EditError(`edits[${i}] needs old_string and new_string strings.`)
const r = applyReplace(text, toLF(e.old_string), toLF(e.new_string), { all: !!e.replace_all, fuzzy: cfg.fuzzy, path: rel })
if (r.kind !== 'exact') warnings.push(`edit ${i + 1}: matched with ${r.kind} whitespace normalization.`)
text = r.text
}
const step = { op: 'update', target, rel, before: cur.text, after: text, eol: detectEol(cur.raw) }
const results = await commit([step], exec)
const regions = changedRegions(cur.text, text)
return finish([step], results, [report(store, step, regions, exec)], warnings)
}
// ---------------------------------------------------------------- sloppy mode
async function runSloppy(args, exec) {
if (typeof args.input !== 'string' || !args.input.trim()) throw new EditError('input must be a non-empty patch string.')
const store = storeFor(exec)
const files = parseSloppy(args.input)
const plan = []
for (const f of files) {
const target = await resolveTarget(f.path, exec)
const cur = await readFile(target, exec)
await editGate(target, exec, cur, { checkVersion: true })
const after = applySloppy(cur.text, f)
plan.push({ op: 'update', target, rel: relPath(target, exec), before: cur.text, after, eol: detectEol(cur.raw) })
}
const results = await commit(plan, exec)
return finish(plan, results, plan.map((s) => report(store, s, changedRegions(s.before, s.after), exec)), [])
}
// ---------------------------------------------------------------- apply_patch mode
async function runApplyPatch(args, exec) {
if (typeof args.input !== 'string' || !args.input.trim()) throw new EditError('input must be a non-empty *** Begin Patch string.')
const store = storeFor(exec)
const ops = parseApplyPatch(args.input)
const plan = []
const regions = new Map()
for (const op of ops) {
const target = await resolveTarget(op.path, exec)
const rel = relPath(target, exec)
if (op.type === 'add') {
if (await ctx.fs.stat(target, exec?.signal)) throw new EditError(`${op.path}: already exists; use *** Update File.`)
const after = joinLines(op.lines, true)
const step = { op: 'create', target, rel, before: '', after }
plan.push(step); regions.set(step, [[1, op.lines.length]])
continue
}
const cur = await readFile(target, exec)
await editGate(target, exec, cur, { checkVersion: true })
if (op.type === 'delete') { plan.push({ op: 'delete', target, rel, before: cur.text, after: '' }); continue }
const { lines, eol } = splitLines(cur.text)
const res = op.hunks.length ? applyUpdate(lines, op) : { lines, regions: [] }
const step = { op: 'update', target, rel, before: cur.text, after: joinLines(res.lines, eol || res.lines.length > 0), eol: detectEol(cur.raw) }
if (op.move) { const d = await resolveTarget(op.move, exec); step.op = 'move'; step.destTarget = d; step.destRel = relPath(d, exec) }
plan.push(step); regions.set(step, res.regions)
}
const results = await commit(plan, exec)
return finish(plan, results, plan.map((s) => report(store, s, regions.get(s), exec)), [])
}
function changedRegions(before, after) {
const d = hunkDiffs('', before, after, 0)
const lines = splitLines(after).lines
const regions = []
// Locate each hunk's new text in the new file to get line numbers.
let from = 0
for (const h of d) {
const first = h.newText.split('\n')[0]
let idx = lines.findIndex((l, i) => i >= from && l === first)
if (idx < 0) idx = from
const len = Math.max(1, h.newText ? h.newText.split('\n').length : 1)
regions.push([idx + 1, idx + len])
from = idx + len
}
return regions
}
function finish(plan, results, blocks, warnings) {
const text = [...blocks, ...(warnings.length ? ['Warnings:', ...warnings.map((w) => `- ${w}`)] : [])].join('\n\n')
const diffs = plan.flatMap((s) => (s.op === 'delete'
? [{ path: s.rel, oldText: s.before || null, newText: '' }]
: hunkDiffs(s.destRel ?? s.rel, s.before, s.after)))
return { text, results, diffs }
}
// ---------------------------------------------------------------- tool definitions
const textOutput = {
schema: { type: 'object', additionalProperties: true, properties: { text: { type: 'string' } } },
render: (_args, value) => [{ type: 'text', text: value.text }],
presentationMeta: (_args, value) => ({ diffs: value.diffs ?? [] }),
}
const editPresent = {
presentCall(args) {
return { card: 'diff', title: `Edit${args.file_path ? ` ${args.file_path}` : ''}`, diffs: [] }
},
presentResult(args, result) {
if (result.isError) return undefined
const diffs = isObj(result.meta) && Array.isArray(result.meta.diffs) ? result.meta.diffs : []
if (!diffs.length) return undefined
return { card: 'diff', title: `Edit ${[...new Set(diffs.map((d) => d.path))].join(', ')}`, diffs }
},
}
const wrap = (fn) => async (args, exec) => {
try {
return await fn(args ?? {}, exec)
} catch (error) {
// EditError / FsError messages are model-facing; rethrow so the runtime marks isError.
if (error?.code === 'FS_STALE_VERSION') throw new EditError(`${error.message}. Re-read the file, then retry.`)
if (error?.code === 'FS_NOT_OBSERVED') throw new EditError(`${error.message} (read it with \`read\` or \`grep\` first).`)
throw error
}
}
function editTool(mode) {
const base = { name: 'edit', output: textOutput, timeoutMs: 120_000, ...editPresent }
if (mode === 'hashline') {
return {
...base,
description: P.HASHLINE,
parameters: {
type: 'object',
additionalProperties: false,
required: ['input'],
properties: { input: { type: 'string', description: 'One or more [PATH#TAG] sections of hashline ops.' } },
},
execute: wrap(runHashline),
}
}
if (mode === 'sloppy') {
return {
...base,
description: P.SLOPPY,
parameters: { type: 'object', additionalProperties: false, required: ['input'], properties: { input: { type: 'string', description: '*** Edit File / *** Find / *** Replace|Insert Before|Insert After payload.' } } },
execute: wrap(runSloppy),
}
}
if (mode === 'apply_patch') {
return {
...base,
description: P.APPLY_PATCH,
parameters: { type: 'object', additionalProperties: false, required: ['input'], properties: { input: { type: 'string', description: 'The full *** Begin Patch … *** End Patch text.' } } },
execute: wrap(runApplyPatch),
}
}
return {
...base,
description: P.REPLACE,
parameters: {
type: 'object',
additionalProperties: false,
required: ['file_path'],
properties: {
file_path: { type: 'string', description: 'Path of the file to edit.' },
old_string: { type: 'string', description: 'Text to replace (fuzzy whitespace matching).' },
new_string: { type: 'string', description: 'Replacement text. Empty string deletes.' },
replace_all: { type: 'boolean', description: 'Replace every match. Default false (must match once).' },
edits: {
type: 'array',
description: 'Batch: several replacements in this file, applied atomically in order. Use instead of old_string/new_string.',
items: {
type: 'object',
additionalProperties: false,
required: ['old_string', 'new_string'],
properties: { old_string: { type: 'string' }, new_string: { type: 'string' }, replace_all: { type: 'boolean' } },
},
},
},
},
execute: wrap(async (args, exec) => {
if (!(Array.isArray(args.edits) && args.edits.length) && (typeof args.old_string !== 'string' || typeof args.new_string !== 'string')) {
throw new EditError('Pass old_string + new_string, or a non-empty edits array.')
}
return runReplace(args, exec)
}),
}
}
// ---------------------------------------------------------------- read / grep wrappers (tag output)
/** Wrap the built-in read so its output carries a [PATH#TAG] header and LINE:TEXT rows. */
function readTool(builtin) {
return {
...builtin,
description: `${builtin.description} Returns at most ${cfg.pageLines} lines per call; continue with offset from the footer.${cfg.mode === 'hashline' ? P.READ_HASHLINE_NOTE : ''}`,
parameters: capLimit(builtin.parameters, cfg.pageLines),
output: {
...builtin.output,
schema: { ...builtin.output.schema, properties: { ...builtin.output.schema.properties, tag: { type: 'string' }, rel: { type: 'string' } } },
render: (args, value) => {
if (!value.tag) return builtin.output.render(args, value)
const { tag, rel, ...rest } = value
const blocks = builtin.output.render(args, rest)
return blocks.map((b) => (b.type === 'text' ? { ...b, text: toHashlineRead(b.text, rel ?? value.path, tag) } : b))
},
...(builtin.output.presentationMeta ? { presentationMeta: (args, value) => { const { tag: _t, rel: _r, ...rest } = value; return builtin.output.presentationMeta(args, rest) } } : {}),
},
async execute(args, exec) {
// Page size: at most `pageLines` lines per call; the footer tells the model the next offset.
const limit = Math.min(Math.max(1, Math.floor(args.limit ?? cfg.pageLines)), cfg.pageLines)
const value = await builtin.execute({ ...args, limit }, exec)
try {
const target = await resolveTarget(args.file_path, exec)
const { text } = await readFile(target, exec)
const tag = storeFor(exec).record(String(target.targetKey), text)
// Tags only matter to hashline patches; other modes get the plain built-in output.
if (modeFor(exec?.agent) !== 'hashline') return value
return { ...value, tag, rel: relPath(target, exec) }
} catch {
return value
}
},
}
}
function toHashlineRead(text, displayPath, tag) {
return text
.replace(/^<path>([^\n]*)<\/path>/, () => `<path>${formatHeader(displayPath, tag)}</path>`)
.replace(/^(\d+): /gm, '$1:')
}
/** Wrap the built-in grep: group matches under [PATH#TAG] headers. */
function grepTool(builtin) {
return {
...builtin,
description: `Search file contents with a ripgrep regular expression. Returns at most ${cfg.pageLines} matching lines per call, grouped by file; pass offset to page through more.${cfg.mode === 'hashline' ? P.GREP_HASHLINE_NOTE : ' A match counts as having read the file, so it can be edited straight away.'}`,
parameters: withOffset(builtin.parameters, 'Skip this many matches (paging; the footer gives the next offset).'),
output: {
...builtin.output,
schema: {
...builtin.output.schema,
properties: {
...builtin.output.schema.properties,
tags: { type: 'array', items: { type: 'object', additionalProperties: false, required: ['path', 'tag'], properties: { path: { type: 'string' }, tag: { type: 'string' } } } },
},
},
render: (args, value) => {
const { tags: tagList, ...rest } = value
if (!tagList || !tagList.length) return builtin.output.render(args, rest)
const tags = Object.fromEntries(tagList.map((t) => [t.path, t.tag]))
const groups = new Map()
for (const m of rest.matches) {
if (!groups.has(m.path)) groups.set(m.path, [])
groups.get(m.path).push(m)
}
if (!rest.matches.length) return builtin.output.render(args, rest)
const start = Math.max(0, Math.floor(args.offset ?? 0))
const page = rest.matches.slice(start, start + cfg.pageLines)
const out = [`${rest.matches.length} match${rest.matches.length === 1 ? '' : 'es'} in ${groups.size} file${groups.size === 1 ? '' : 's'}${start || page.length < rest.matches.length ? ` — showing ${start + 1}-${start + page.length}` : ''}`]
let last = null
for (const m of page) {
if (m.path !== last) { out.push(tags[m.path] ? formatHeader(m.path, tags[m.path]) : m.path); last = m.path }
out.push(numbered(m.lineNumber, m.line.length > 400 ? `${m.line.slice(0, 400)}…` : m.line))
}
if (start + page.length < rest.matches.length) out.push(`(${rest.matches.length - start - page.length} more; call grep again with offset=${start + page.length})`)
return [{ type: 'text', text: out.join('\n') }]
},
...(builtin.output.presentationMeta ? { presentationMeta: (args, value) => { const { tags: _t, ...rest } = value; return builtin.output.presentationMeta(args, rest) } } : {}),
},
async execute(args, exec) {
const { offset: _o, ...rgArgs } = args
const value = await builtin.execute(rgArgs, exec)
const tags = []
try {
const store = storeFor(exec)
const files = [...new Set((value.matches || []).map((m) => m.path))].slice(0, 60)
for (const f of files) {
const target = await resolveTarget(f, exec)
const { text, version } = await readFile(target, exec)
tags.push({ path: f, tag: store.record(String(target.targetKey), text) })
// A grep hit counts as an observation of the file (so it can be edited by tag).
ctx.emit('fs/observed', target, { kind: 'present', version }, exec)
}
} catch { /* tagging is best-effort */ }
return { ...value, tags }
},
}
}
/** Wrap the built-in glob: page file lists at `pageLines` paths with an offset cursor. */
function globTool(builtin) {
return {
...builtin,
description: `Find files by glob pattern (e.g. "**/*.ts"). Returns at most ${cfg.pageLines} paths per call, newest first; pass offset to page.`,
parameters: withOffset(builtin.parameters, 'Skip this many paths (paging; the footer gives the next offset).'),
output: {
...builtin.output,
render: (args, value) => {
const list = Array.isArray(value?.paths) ? value.paths : Array.isArray(value?.files) ? value.files : null
if (!list) return builtin.output.render(args, value)
const start = Math.max(0, Math.floor(args.offset ?? 0))
const page = list.slice(start, start + cfg.pageLines)
const lines = [`${list.length} file${list.length === 1 ? '' : 's'}${start || page.length < list.length ? ` — showing ${start + 1}-${start + page.length}` : ''}`, ...page.map((x) => (typeof x === 'string' ? x : x.path ?? String(x)))]
if (start + page.length < list.length) lines.push(`(${list.length - start - page.length} more; call glob again with offset=${start + page.length})`)
return [{ type: 'text', text: lines.join('\n') }]
},
},
async execute(args, exec) {
const { offset: _o, ...rest } = args
return builtin.execute(rest, exec)
},
}
}
/** Wrap the built-in bash: the model sees at most `pageLines` lines per stream, the rest is readable with `read`. */
function bashTool(builtin) {
return {
...builtin,
description: `${builtin.description} Reading files and searching through the shell is fine; writing files through the shell is blocked (use edit/write). Output is capped at ${cfg.pageLines} lines per stream: the full output is saved to a file named in the footer, so read the next lines with read (file_path + offset).`,
async execute(args, exec) {
return pageShellResult(await builtin.execute(args, exec), cfg.pageLines)
},
}
}
// ---------------------------------------------------------------- per-agent installation
const installed = new Map()
function install(agent) {
if (installed.has(agent)) return
const scoped = agent.ctx
if (!scoped?.tools) return
const disposers = []
const reg = (d) => { if (typeof d === 'function') disposers.push(d) }
let ensure = () => {}
try {
// Prompt assembly collects the tool schemas for the next request. Wrap read/grep (their
// preset may mount after agent/created) before the schemas are built, then align the
// edit mode with the model the assembly selected and patch the schema in place.
reg(scoped.on('system-prompt/assemble', async (_assembly, _context, next) => {
try { ensure() } catch (e) { console.warn(`[omp-edit] wrap read/grep: ${e?.message ?? e}`) }
const assembled = await next()
const model = assembled?.variables?.model
if (model) {
const before = modeFor(agent)
models.set(agent, `${assembled.variables.provider ?? ''}/${model}`)
const after = modeFor(agent)
if (after !== before && cfg.replaceEdit) {
refreshEdit()
const t = editTool(after)
return { ...assembled, tools: assembled.tools.map((x) => (x.name === 'edit' ? { ...x, description: t.description, parameters: structuredClone(t.parameters) } : x)) }
}
}
return assembled
}, { prepend: true }))
reg(scoped.on('agent/request', async (_payload, next) => {
const resolved = await next()
if (resolved?.model) {
const before = modeFor(agent)
models.set(agent, `${resolved.provider ?? ''}/${resolved.model}`)
if (modeFor(agent) !== before) refreshEdit()
}
return resolved
}))
let editDispose = null
const refreshEdit = () => {
if (!cfg.replaceEdit) return
editDispose?.()
editDispose = scoped.tools.register(editTool(modeFor(agent)))
}
if (cfg.replaceEdit) refreshEdit()
reg(() => editDispose?.())
// read/grep live in the agent's preset scope; wrap them as soon as they are visible
// (at creation, or on the first request if the preset mounts later).
let wrappedRead = false
let wrappedGrep = false
let wrappedGlob = false
let wrappedBash = false
const ensureWrappers = () => {
if (cfg.replaceRead && !wrappedRead) {
const builtin = ctx.tools.get('read', agent)
if (builtin && !builtin.__ompWrapped) { const t = readTool(builtin); t.__ompWrapped = true; reg(scoped.tools.register(t)); wrappedRead = true }
}
if (cfg.replaceGlob && !wrappedGlob) {
const builtin = ctx.tools.get('glob', agent)
if (builtin && !builtin.__ompWrapped) { const t = globTool(builtin); t.__ompWrapped = true; reg(scoped.tools.register(t)); wrappedGlob = true }
}
if (cfg.replaceGrep && !wrappedGrep) {
const builtin = ctx.tools.get('grep', agent)
if (builtin && !builtin.__ompWrapped) { const t = grepTool(builtin); t.__ompWrapped = true; reg(scoped.tools.register(t)); wrappedGrep = true }
}
if (cfg.replaceBash && !wrappedBash) {
const builtin = ctx.tools.get('bash', agent)
if (builtin && !builtin.__ompWrapped) { const t = bashTool(builtin); t.__ompWrapped = true; reg(scoped.tools.register(t)); wrappedBash = true }
}
}
ensureWrappers()
ensure = ensureWrappers
reg(scoped.systemPrompt?.section?.({
name: 'omp-edit:hashline',
order: scoped.systemPrompt.getSectionOrder('TOOL_EDIT') + 1,
text: () => (modeFor(agent) === 'hashline'
? 'Edit with hashline: copy the [PATH#TAG] header and LINE numbers from the latest read/grep/edit output; after each edit use the returned header and preview (tags change on every edit).'
: ''),
}))
} catch (error) {
for (const d of disposers.splice(0).reverse()) { try { d() } catch {} }
console.warn(`[omp-edit] install failed for agent: ${error?.message ?? error}`)
return
}
installed.set(agent, () => { for (const d of disposers.splice(0).reverse()) { try { d() } catch {} } })
}
// ---------------------------------------------------------------- guard rule
// Edits go through `edit` (hashline), reading through `read`, searching through `grep`/`glob`.
// Shell commands that mutate files or read/search them are denied with a pointer to the tool.
if (cfg.guard) {
ctx.on('tools/pre-execute', async (exec, next) => {
const name = exec.name
if (cfg.guardTools.includes(name)) {
// `write` may still create brand-new files (hashline only patches existing ones).
if (name === 'write' && isObj(exec.arguments) && typeof exec.arguments.file_path === 'string') {
let exists
try {
const target = await resolveTarget(exec.arguments.file_path, exec)
exists = (await ctx.fs.stat(target, exec.signal)) != null // fs.stat resolves undefined when absent
} catch (e) {
if (e?.code === 'ENOENT' || e?.code === 'FS_NOT_FOUND') exists = false
else return { kind: 'deny', reason: `omp-edit guard could not check ${exec.arguments.file_path} (${e?.message ?? e}); write is only allowed for new files.` }
}
if (!exists) return next()
return { kind: 'deny', reason: `"write" may only create new files; ${exec.arguments.file_path} already exists. Change it with the edit tool: read it, then send a [PATH#TAG] hashline patch.` }
}
return { kind: 'deny', reason: `"${name}" is disabled by the omp-edit guard. Change files with the edit tool: read the file, then send a [PATH#TAG] hashline patch.` }
}
if ((name === 'bash' || name === 'pwsh' || name === 'bash_persistent') && isObj(exec.arguments)) {
const cmd = String(exec.arguments.command ?? exec.arguments.cmd ?? '')
const hit = shellViolation(cmd)
if (hit) return { kind: 'deny', reason: hit }
}
return next()
})
}
for (const agent of ctx.agents.list()) install(agent)
ctx.on('agent/created', ({ agent }) => { install(agent) })
ctx.on('agent/disposed', ({ agent }) => { installed.get(agent)?.(); installed.delete(agent) })
ctx.effect(() => () => { for (const d of installed.values()) d(); installed.clear() }, 'omp-edit: scoped tools')
}
// Exposed for tests.
export { DEFAULT_WEAK, MODES }