/** * @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 { 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. 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).'], [/(^|\s)(python3?|node|ruby|perl)\s+-[ce]\s/, 'inline interpreter scripts are not allowed for file reads/writes — use read/grep/edit.'], [/(^|\s)(truncate|dd)\s/, 'truncate/dd are not allowed — use the edit tool.'], // A pipe consumer (`… | head`) only trims command output and stays allowed: segments start after the pipe. [/^(cat|head|tail|less|more|bat|nl)\s+(?:-\S+\s+)*[^-\s]/, 'reading files through the shell is not allowed — use the read tool (paged, 200 lines per call).', /^(head|tail)\s+(?:-[a-zA-Z]*\s*\d*\s*|-\d+\s*|--?\w+(=\S+)?\s*)*$/], [/(^|\s)(grep|egrep|fgrep|rg|ag|ack)\s/, 'searching through the shell is not allowed — use the grep tool (paged, hashline-tagged).', /^(grep|egrep|fgrep|rg)\s+(?:-\S+\s+)*(?:''|""|\S+)\s*$/], [/(^|\s)(find|fd|locate)\s/, 'finding files through the shell is not allowed — use the glob tool.'], [/(^|\s)ls\s+(-[a-zA-Z]*R|--recursive)/, 'recursive listings are not allowed — use the glob tool.'], [/(^|\s)(awk|gawk)\s/, 'awk is not allowed for reading or editing files — use read/grep/edit.'], [/(^|\s)sed\s+(-n|--quiet)/, 'sed -n is not allowed for reading files — use the read tool with offset.'], ] /** Return a denial message when `cmd` mutates, reads or searches files through the shell. */ export function shellViolation(cmd) { 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 } /** 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, 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} 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.${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) return { ...value, tag, rel: relPath(target, exec) } } catch { return value } }, } } function toHashlineRead(text, displayPath, tag) { return text .replace(/^([^\n]*)<\/path>/, () => `${formatHeader(displayPath, tag)}`) .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.${P.GREP_HASHLINE_NOTE}`, 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) }, } } // ---------------------------------------------------------------- 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 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 } } } 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 }