From 71953ffb8fdb7b5ee266bf1a3b47ae5489861be4 Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 10 Oct 2026 19:15:23 +0000 Subject: [PATCH] Initial commit: omp-edit DSH plugin (oh-my-pi port) with docs and tests --- .gitignore | 4 + README.md | 155 ++++++++++ cordis.patch.yml | 11 + index.js | 611 ++++++++++++++++++++++++++++++++++++++ lib/apply_patch.js | 106 +++++++ lib/block.js | 151 ++++++++++ lib/diff.js | 38 +++ lib/hashline.js | 272 +++++++++++++++++ lib/prompts.js | 82 +++++ lib/replace.js | 138 +++++++++ lib/sloppy.js | 146 +++++++++ lib/store.js | 172 +++++++++++ lib/text.js | 78 +++++ locale/en.json | 1 + locale/zh.json | 1 + package.json | 16 + test/core.test.mjs | 144 +++++++++ test/harness.mjs | 132 ++++++++ test/integration.test.mjs | 154 ++++++++++ 19 files changed, 2412 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 cordis.patch.yml create mode 100644 index.js create mode 100644 lib/apply_patch.js create mode 100644 lib/block.js create mode 100644 lib/diff.js create mode 100644 lib/hashline.js create mode 100644 lib/prompts.js create mode 100644 lib/replace.js create mode 100644 lib/sloppy.js create mode 100644 lib/store.js create mode 100644 lib/text.js create mode 100644 locale/en.json create mode 100644 locale/zh.json create mode 100644 package.json create mode 100644 test/core.test.mjs create mode 100644 test/harness.mjs create mode 100644 test/integration.test.mjs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cb38758 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +*.bak +*.orig +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..f80b6f3 --- /dev/null +++ b/README.md @@ -0,0 +1,155 @@ +# omp-edit + +[oh-my-pi](https://github.com/can1357/oh-my-pi)-style editing for the DeepSeek Harness (DSH). It **replaces DSH's built-in `edit` tool** with oh-my-pi's *hashline* patch language. It also wraps `read` and `grep` so their output carries the content-hash tags the patches anchor on. + +The model points at line numbers of a tagged snapshot instead of retyping the old text. That avoids whitespace-mismatch "string not found" loops and saves output tokens. A stale snapshot is detected before anything is written, and is rebased automatically when the anchored lines survived. + +## What the agent sees + +`read` output carries a `[PATH#TAG]` header and `LINE:TEXT` rows (the 4 hex characters are a hash of the whole file): + +``` +[src/app.py#1A2B] +1:def greet(name): +2: print("Hello, " + name) +``` + +`grep` groups matches under a tagged header per file. A grep hit counts as having read the file, so it can be edited straight away: + +``` +[src/app.py#1A2B] +2: print("Hello, " + name) +``` + +`edit` takes one `input` string with one or more file sections: + +``` +[src/app.py#1A2B] +PUT 2.=2: ++ print(f"Hi, {name}") +``` + +It returns the fresh tag plus a compact numbered preview of the changed region, so the agent can chain edits without re-reading. The Web UI shows the usual diff card. + +## Hashline operations + +| Form | Effect | +| --- | --- | +| `PUT N.=M:` + `+rows` | Replace original lines N..M (`N.=N` for one line) | +| `PUT N*:` + `+rows` | Replace the syntactic block opening at line N (function/class/object, Markdown heading section, Python/YAML indented block; leading decorators included) | +| `PUT N:` | Insert before / after line N (`<1` = file head, `>$` = file tail) | +| `PUT >N*:` | Insert after the block opening at N | +| `CUT N.=M [@name]` / `CUT N* [@name]` | Delete and capture (anonymous register, or a named one) | +| `PUT N [@name]` | Paste a register at a gap | +| `PUT N.=M @name` / `PUT N* @name` | Paste a named register over a range/block | +| `REM` | Delete the file | +| `MV DEST` | Move the file after the section's edits | + +The rules match oh-my-pi: +- Body rows are `+TEXT`; a lone `+` is a blank line; literal `-x` is written `+-x`. +- All numbers refer to the tagged snapshot, never shifted by earlier hunks. +- Overlapping ranges are rejected. +- Named registers persist for the session, and cross-file moves work (`CUT 1* @fn` in one section, `PUT <1 @fn` in another). +- The whole call is validated before anything is written. +- Tolerant parsing: an optional `*** Begin/End Patch` envelope and markdown fences are stripped, and `PUT 3-5:` / `PUT 3:` are accepted with a warning. + +### Stale tags + +Every tagged `read`/`grep`/`edit` records a snapshot. When the file changed since the tag (another tool, a formatter, the user): + +- if every anchored line still exists unchanged, with a neighbour that moved by the same offset, the patch is **remapped onto the current file** and applied. A warning says so. +- otherwise the edit is **rejected without writing**, with a message to re-read. + +A tag that was never issued for that file (e.g. copied from another file) is rejected with a hint. + +## Edit modes + +`mode` picks the wire format of the replacement `edit` tool: + +| Mode | Input | Notes | +| --- | --- | --- | +| `hashline` (default) | `input` | Described above | +| `replace` | `file_path`, `old_string`, `new_string`, `replace_all`, or `edits: [{old_string,new_string,replace_all}]` | Fuzzy whitespace matching: exact → indentation/trailing-space-insensitive → whitespace-collapsed → smart quotes/dashes → ≥95% similarity. The file's indentation is carried over. Batched edits are atomic | +| `sloppy` | `input` | `*** Edit File:` / `*** Find` / `*** Replace` · `*** Insert Before` · `*** Insert After`, `…` captures, ` all` suffix | +| `apply_patch` | `input` | Codex format: `*** Add/Delete/Update File`, `*** Move to`, `@@` scoped hunks, `*** End of File` | + +**Per-model selection**, like oh-my-pi: +1. `modelVariants` (substring of `provider/model` → mode), first match wins. +2. `mode`. +3. With `hashline` and `strict: false`, model families oh-my-pi considers unreliable at hashline fall back to `fallbackMode` (default `replace`). The list is `fallbackModels`, default `kimi, mimo, minimax, deepseek, stepfun, codex-spark, glm-5.3-flash`. + +The tool schema follows the model the agent actually uses and switches mid-session when you change models. + +## How it replaces the built-ins + +DSH registers `edit`/`read`/`grep` in each agent preset. Tools registered in an agent's own scope shadow those, so this plugin listens for `agent/created` and registers into every agent's scope: +- **`edit`** is replaced outright. Nothing of DSH's `edit` is used. +- **`read`** and **`grep`** delegate to DSH's implementations (sandbox, limits, ripgrep, spill files all unchanged) and only add tags to the output. + +DSH's safety rules are kept: +- **Read first:** you still need to `read` (or `grep`) a file before editing it. This is `fs-observation-policy`. +- **Guarded writes:** writes go through `ctx.fs` with the version guard. +- **Sandbox:** the session's sandbox policy applies to writes and `REM`. +- **Line endings:** CRLF files stay CRLF. + +To use DSH's original tools again, disable this plugin (Settings → Plugins → OMP Edit) or set `replaceEdit/replaceRead/replaceGrep: false`. + +## Installation + +```bash +dsh plugin --profile web add git+https://git.rizzy.dev/DSH-Plugins/omp-edit.git +``` + +Or in the Web UI, go to **Settings → Plugins → Install** and paste `git+https://git.rizzy.dev/DSH-Plugins/omp-edit.git`. You can also ask the agent to install it with `plugin_manager` → `install_bundle`. + +For development: + +```bash +git clone https://git.rizzy.dev/DSH-Plugins/omp-edit.git ~/dsh-plugins/omp-edit +dsh plugin --profile web add link:$HOME/dsh-plugins/omp-edit +``` + +Then restart DSH (`systemctl --user restart dsh` or re-run `dsh web`). New sessions get the new `edit` tool. Sessions that already exist pick it up once they're re-created, e.g. after the restart. + +No dependencies: everything is plain Node.js (≥ 20). + +## Configuration + +Set options in the `config` of the row in `~/.dsh/profiles/web/cordis.patch.yml`: + +```yaml +- id: omp-edit + config: + mode: hashline # hashline | replace | sloppy | apply_patch + strict: false # true: never fall back from hashline + fallbackMode: replace + fallbackModels: [kimi, mimo, minimax, deepseek, stepfun, codex-spark, glm-5.3-flash] + modelVariants: # substring of "provider/model" -> mode + gpt-5-codex: apply_patch + replaceEdit: true + replaceRead: true + replaceGrep: true + fuzzy: true # replace mode fuzzy matching +``` + +## Tests + +```bash +npm test # node --test test/*.test.mjs — 32 tests (engine + integration on the real DSH fs-local backend) +``` + +## Files + +| File | Role | +| --- | --- | +| `index.js` | DSH plugin: per-agent tool registration, mode selection, fs policy, diff cards | +| `lib/hashline.js` | Hashline parser and applier | +| `lib/block.js` | `N*` block resolution (brackets, indentation, Markdown, decorators) | +| `lib/store.js` | Session snapshot store, Myers line diff, stale-tag rebase | +| `lib/replace.js` | Fuzzy `replace` mode | +| `lib/sloppy.js` | `sloppy` mode | +| `lib/apply_patch.js` | `apply_patch` mode | +| `lib/diff.js` | Hunk diffs for the UI diff card | +| `lib/prompts.js` | Model-facing tool descriptions (adapted from oh-my-pi's `crates/pi-edit/prompts`) | + +Pairs well with [omp-lsp](https://git.rizzy.dev/DSH-Plugins/omp-lsp), which appends language-server diagnostics to every edit. diff --git a/cordis.patch.yml b/cordis.patch.yml new file mode 100644 index 0000000..ac49282 --- /dev/null +++ b/cordis.patch.yml @@ -0,0 +1,11 @@ +- insert: + - id: omp-edit + name: '@local/omp-edit' + config: + # hashline | replace | sloppy | apply_patch + mode: hashline + # true = never fall back from hashline for weak-at-hashline model families + strict: false + fallbackMode: replace + # substring of provider/model -> mode, first match wins + modelVariants: {} diff --git a/index.js b/index.js new file mode 100644 index 0000000..d176631 --- /dev/null +++ b/index.js @@ -0,0 +1,611 @@ +/** + * @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) + +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, + fuzzy: true, + ...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}${P.READ_HASHLINE_NOTE}`, + 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) { + const value = await builtin.execute(args, 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: `${builtin.description}${P.GREP_HASHLINE_NOTE}`, + 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) + } + const max = 250 + let shown = 0 + const out = [] + for (const [p, ms] of groups) { + if (shown >= max) break + out.push(tags[p] ? formatHeader(p, tags[p]) : p) + for (const m of ms) { + if (shown++ >= max) break + out.push(numbered(m.lineNumber, m.line.length > 400 ? `${m.line.slice(0, 400)}…` : m.line)) + } + } + if (rest.matches.length > max) out.push(`(${rest.matches.length - max} more matches not shown; narrow the pattern or path.)`) + if (!rest.matches.length) return builtin.output.render(args, rest) + 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 value = await builtin.execute(args, 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 } + }, + } + } + + // ---------------------------------------------------------------- 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 + 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.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 {} } }) + } + + 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 } diff --git a/lib/apply_patch.js b/lib/apply_patch.js new file mode 100644 index 0000000..85e4c86 --- /dev/null +++ b/lib/apply_patch.js @@ -0,0 +1,106 @@ +// `apply_patch` mode (Codex-compatible): +// +// *** Begin Patch +// *** Add File: path (+ lines) +// *** Delete File: path +// *** Update File: path +// *** Move to: new/path (optional) +// @@ [context header] (repeatable to narrow scope) +// context / -old / +new lines +// *** End of File (optional: hunk anchored at EOF) +// *** End Patch +import { EditError, jsonQuote } from './text.js' + +export function parseApplyPatch(input) { + let lines = String(input).replace(/\r\n?/g, '\n').split('\n') + // Accept missing envelope, and a heredoc wrapper like `apply_patch <<'EOF'`. + lines = lines.filter((l, i) => !(i === 0 && /^apply_patch\b/.test(l.trim())) && !/^EOF$/.test(l.trim())) + const begin = lines.findIndex((l) => l.trim() === '*** Begin Patch') + const end = lines.findLastIndex((l) => l.trim() === '*** End Patch') + if (begin >= 0) lines = lines.slice(begin + 1, end > begin ? end : undefined) + const ops = [] + let cur = null + let hunk = null + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] + const t = raw.trim() + let m + if ((m = /^\*\*\* Add File:\s*(.+)$/.exec(t))) { cur = { type: 'add', path: m[1].trim(), lines: [] }; ops.push(cur); hunk = null; continue } + if ((m = /^\*\*\* Delete File:\s*(.+)$/.exec(t))) { cur = { type: 'delete', path: m[1].trim() }; ops.push(cur); hunk = null; continue } + if ((m = /^\*\*\* Update File:\s*(.+)$/.exec(t))) { cur = { type: 'update', path: m[1].trim(), move: null, hunks: [] }; ops.push(cur); hunk = null; continue } + if (!cur) { if (t === '') continue; throw new EditError(`Line ${i + 1}: expected '*** Add/Delete/Update File:' before ${jsonQuote(raw)}.`) } + if ((m = /^\*\*\* Move to:\s*(.+)$/.exec(t))) { + if (cur.type !== 'update') throw new EditError(`Line ${i + 1}: '*** Move to:' only follows '*** Update File:'.`) + cur.move = m[1].trim(); continue + } + if (cur.type === 'add') { + if (raw.startsWith('+')) { cur.lines.push(raw.slice(1)); continue } + if (t === '') continue + throw new EditError(`Line ${i + 1}: every line of an added file must start with '+'.`) + } + if (cur.type === 'delete') { if (t === '') continue; throw new EditError(`Line ${i + 1}: '*** Delete File:' takes no body.`) } + // update + if (t === '*** End of File') { if (hunk) hunk.eof = true; continue } + if (raw.startsWith('@@')) { + const header = raw.slice(2).trim() + if (!hunk || hunk.lines.length) { hunk = { headers: [], lines: [], eof: false }; cur.hunks.push(hunk) } + if (header) hunk.headers.push(header) + continue + } + if (!hunk) { hunk = { headers: [], lines: [], eof: false }; cur.hunks.push(hunk) } + if (raw === '') { hunk.lines.push({ op: ' ', text: '' }); continue } + const c = raw[0] + if (c === ' ' || c === '-' || c === '+') { hunk.lines.push({ op: c, text: raw.slice(1) }); continue } + throw new EditError(`Line ${i + 1}: hunk lines must start with ' ', '-' or '+' (got ${jsonQuote(raw)}).`) + } + if (!ops.length) throw new EditError('apply_patch: no file operations found.') + for (const op of ops) if (op.type === 'update' && !op.hunks.length && !op.move) throw new EditError(`${op.path}: '*** Update File:' has no hunks.`) + return ops +} + +/** Locate seq in lines starting at from; tries exact, then trimEnd, then trim. */ +function seek(lines, seq, from, eof) { + if (!seq.length) return eof ? lines.length : from + const tries = [(s) => s, (s) => s.trimEnd(), (s) => s.trim()] + for (const f of tries) { + const want = seq.map(f) + const start = eof ? lines.length - seq.length : from + for (let i = Math.max(0, start); i + want.length <= lines.length; i++) { + let ok = true + for (let k = 0; k < want.length; k++) if (f(lines[i + k]) !== want[k]) { ok = false; break } + if (ok) return i + if (eof) break + } + } + return -1 +} + +/** Apply an Update op's hunks to a line array. */ +export function applyUpdate(lines, op) { + const out = [...lines] + let cursor = 0 + const repls = [] + for (const h of op.hunks) { + for (const hdr of h.headers) { + const idx = out.findIndex((l, i) => i >= cursor && l.includes(hdr.trim())) + if (idx === -1) throw new EditError(`${op.path}: @@ context ${jsonQuote(hdr)} not found.`) + cursor = idx + 1 + } + const oldSeq = h.lines.filter((l) => l.op !== '+').map((l) => l.text) + const newSeq = h.lines.filter((l) => l.op !== '-').map((l) => l.text) + let at = seek(out, oldSeq, cursor, h.eof) + if (at === -1 && cursor > 0) at = seek(out, oldSeq, 0, h.eof) + if (at === -1) { + throw new EditError(`${op.path}: hunk context not found:\n${oldSeq.slice(0, 6).join('\n')}${oldSeq.length > 6 ? '\n…' : ''}\nRe-read the file and copy context lines exactly.`) + } + repls.push({ at, len: oldSeq.length, rows: newSeq }) + cursor = at + oldSeq.length + } + repls.sort((a, b) => a.at - b.at) + for (let i = 1; i < repls.length; i++) if (repls[i].at < repls[i - 1].at + repls[i - 1].len) throw new EditError(`${op.path}: hunks overlap.`) + const regions = [] + let shift = 0 + for (const r of repls) { regions.push([r.at + 1 + shift, r.at + shift + Math.max(r.rows.length, 1)]); shift += r.rows.length - r.len } + for (const r of [...repls].reverse()) out.splice(r.at, r.len, ...r.rows) + return { lines: out, regions } +} diff --git a/lib/block.js b/lib/block.js new file mode 100644 index 0000000..618177d --- /dev/null +++ b/lib/block.js @@ -0,0 +1,151 @@ +// Syntactic block resolution for `N*` anchors. +// +// OMP resolves blocks with tree-sitter. This is a dependency-free approximation that covers +// the same shapes for the common languages: +// - Markdown: a heading runs through deeper headings until the next same/higher heading. +// - Indentation languages (Python, YAML, Nim, CoffeeScript, …) and any opener ending in ':' +// with no brackets: the opener plus every following line indented deeper (blank lines +// inside are kept, trailing blank lines are not). +// - Bracket languages (JS/TS/C/Go/Rust/Java/JSON/CSS/…): from the opener to the line that +// closes the first bracket left open, string- and comment-aware. +// Leading decorators/attributes (`@x`, `#[x]`, `[Attr]`) are grouped with the declaration +// that follows them. +import path from 'node:path' +import { EditError } from './text.js' + +const INDENT_EXT = new Set(['.py', '.pyi', '.yaml', '.yml', '.nim', '.coffee', '.sass', '.styl', '.pug', '.haml', '.slim', '.gd']) +const MARKDOWN_EXT = new Set(['.md', '.markdown', '.mdx', '.rst']) +const HASH_COMMENT_EXT = new Set(['.py', '.pyi', '.rb', '.sh', '.bash', '.zsh', '.yaml', '.yml', '.toml', '.pl', '.r', '.nim', '.coffee', '.cmake', '.mk', '.dockerfile', '.ex', '.exs', '.jl', '.tf', '.hcl']) + +const indentOf = (line) => (line.match(/^[ \t]*/)[0].replace(/\t/g, ' ').length) +const isBlank = (line) => line.trim() === '' +const isDecorator = (line) => /^\s*(@[\w.]|#\[|\[[A-Z]\w*(\(|\]))/.test(line) + +/** + * Resolve the block that starts at 1-based `line`. + * @returns {{start:number,end:number}} 1-based inclusive span + */ +export function resolveBlock(filePath, lines, line) { + if (line < 1 || line > lines.length) throw new EditError(`Block anchor ${line} is outside the file (1-${lines.length}).`) + const ext = path.extname(filePath).toLowerCase() + const base = path.basename(filePath).toLowerCase() + const opener = lines[line - 1] + if (isBlank(opener)) throw new EditError(`Block anchor ${line} is a blank line; anchor the construct opener (the first line of the function/class/heading).`) + + if (MARKDOWN_EXT.has(ext)) return markdownBlock(lines, line) + + // Decorators/attributes group with the declaration they precede. + let declLine = line + while (declLine <= lines.length && isDecorator(lines[declLine - 1]) && !/[{(:]\s*$/.test(lines[declLine - 1].replace(/\)\s*$/, ''))) { + if (declLine === lines.length) break + declLine++ + } + + const decl = lines[declLine - 1] + const useIndent = INDENT_EXT.has(ext) || (/:\s*(#.*)?$/.test(decl) && !/[{[(]/.test(stripStrings(decl))) + const span = useIndent + ? indentBlock(lines, declLine) + : bracketBlock(lines, declLine, HASH_COMMENT_EXT.has(ext) || base === 'dockerfile' || base === 'makefile') + if (!span || span.end <= line) { + throw new EditError( + `Line ${line} does not open a multi-line block (${JSON.stringify(opener.trim().slice(0, 60))}). ` + + 'Anchor the construct opener, never a closer, blank line or inner statement; for a single line use an explicit range (PUT N.=N:).', + ) + } + return { start: line, end: span.end } +} + +function markdownBlock(lines, line) { + const m = /^(#{1,6})\s/.exec(lines[line - 1]) + if (!m) { + // Paragraph / list block: until the next blank line. + let end = line + while (end < lines.length && !isBlank(lines[end])) end++ + if (end === line) throw new EditError(`Line ${line} is a single-line Markdown block; use an explicit range.`) + return { start: line, end } + } + const level = m[1].length + let end = lines.length + let fence = false + for (let i = line; i < lines.length; i++) { + if (/^\s*(```|~~~)/.test(lines[i])) fence = !fence + if (fence) continue + const h = /^(#{1,6})\s/.exec(lines[i]) + if (h && h[1].length <= level) { end = i; break } + } + while (end > line && isBlank(lines[end - 1])) end-- + return { start: line, end } +} + +function indentBlock(lines, line) { + const base = indentOf(lines[line - 1]) + let end = line + // A multi-line opener (e.g. a def whose parameters wrap) continues until the ':' line. + let i = line + if (!/:\s*(#.*)?$/.test(lines[line - 1])) { + let depth = bracketDelta(lines[line - 1]) + while (depth > 0 && i < lines.length) { depth += bracketDelta(lines[i]); i++ } + end = i + } + for (let k = i; k < lines.length; k++) { + const l = lines[k] + if (isBlank(l)) continue + if (indentOf(l) > base) end = k + 1 + else break + } + return { start: line, end } +} + +function stripStrings(s) { + return s.replace(/(["'`])(?:\\.|(?!\1).)*\1/g, '""') +} + +function bracketDelta(s) { + let d = 0 + for (const c of stripStrings(s)) { + if (c === '(' || c === '[' || c === '{') d++ + else if (c === ')' || c === ']' || c === '}') d-- + } + return d +} + +/** + * Scan from the opener with a small lexer (strings, template literals, line and block + * comments) and end at the line where depth returns to zero after first opening. + */ +function bracketBlock(lines, line, hashComments) { + let depth = 0 + let opened = false + let inBlock = false + let quote = null + for (let i = line - 1; i < lines.length; i++) { + const s = lines[i] + for (let j = 0; j < s.length; j++) { + const c = s[j] + const n = s[j + 1] + if (inBlock) { if (c === '*' && n === '/') { inBlock = false; j++ } continue } + if (quote) { + if (c === '\\') { j++; continue } + if (c === quote) quote = null + continue + } + if (c === '/' && n === '/') break + if (hashComments && c === '#') break + if (c === '/' && n === '*') { inBlock = true; j++; continue } + if (c === '"' || c === "'" || c === '`') { quote = c; continue } + if (c === '{' || c === '(' || c === '[') { depth++; opened = true } + else if (c === '}' || c === ')' || c === ']') { + depth-- + if (opened && depth <= 0) { + // Keep scanning the rest of this line for a chained opener: `} else {`, `}).then(() => {`. + const rest = stripStrings(s.slice(j + 1)) + if (bracketDelta(rest) > 0) { depth += 0; continue } + return { start: line, end: i + 1 } + } + } + } + if (quote && quote !== '`') quote = null // unterminated single-line string + if (!opened && i > line - 1 + 3) return null // opener must open within a few lines + } + return null +} diff --git a/lib/diff.js b/lib/diff.js new file mode 100644 index 0000000..424adf0 --- /dev/null +++ b/lib/diff.js @@ -0,0 +1,38 @@ +// Hunk diffs for the DSH diff card ({path, oldText, newText} per hunk, 3 lines of context). +import { lineMap } from './store.js' +import { splitLines } from './text.js' + +export function hunkDiffs(path, before, after, context = 3) { + const a = splitLines(before).lines + const b = splitLines(after).lines + const map = lineMap(a, b) + // Walk both sides, emitting ops: '=' common, '-' removed, '+' added. + const ops = [] + let i = 1; let j = 1 + const pairs = [...map.entries()].sort((x, y) => x[0] - y[0]) + for (const [ai, bj] of pairs) { + while (i < ai) ops.push(['-', a[i++ - 1]]) + while (j < bj) ops.push(['+', b[j++ - 1]]) + ops.push(['=', a[i - 1]]); i++; j++ + } + while (i <= a.length) ops.push(['-', a[i++ - 1]]) + while (j <= b.length) ops.push(['+', b[j++ - 1]]) + const changed = ops.map((o, k) => (o[0] !== '=' ? k : -1)).filter((k) => k >= 0) + if (!changed.length) return [] + const spans = [] + for (const k of changed) { + const s = Math.max(0, k - context); const e = Math.min(ops.length - 1, k + context) + const last = spans.at(-1) + if (last && s <= last[1] + 1) last[1] = Math.max(last[1], e) + else spans.push([s, e]) + } + return spans.map(([s, e]) => { + const oldL = []; const newL = [] + for (let k = s; k <= e; k++) { + const [op, text] = ops[k] + if (op !== '+') oldL.push(text) + if (op !== '-') newL.push(text) + } + return { path, oldText: oldL.length ? oldL.join('\n') : null, newText: newL.join('\n') } + }) +} diff --git a/lib/hashline.js b/lib/hashline.js new file mode 100644 index 0000000..92dee16 --- /dev/null +++ b/lib/hashline.js @@ -0,0 +1,272 @@ +// Hashline patch language (oh-my-pi compatible). +// +// [PATH#TAG] section header; TAG = 4-hex snapshot of the file from read/grep/edit +// PUT N.=M: +rows replace original lines N..M +// PUT N*: +rows replace the syntactic block opening at N +// PUT N: insert before / after line N (<1 = head, >$ = tail) +// PUT >N*: insert after the block opening at N +// CUT N.=M [@name] delete + capture (anonymous register when unnamed) +// CUT N* [@name] delete + capture a block +// PUT ])\\s*(${'[1-9]\\d*'}|\\$)(\\*)?\\s*(:)?${REG}\\s*$`), + cutRange: new RegExp(`^CUT\\s+${LINE}\\s*\\.=\\s*${LINE}${REG}\\s*$`), + cutBlock: new RegExp(`^CUT\\s+${LINE}\\*${REG}\\s*$`), + rem: /^REM\s*$/, + mv: /^MV\s+(.+?)\s*$/, + // Tolerated spellings the models often emit: `PUT N-M:`, `PUT N:` (single line), `PUT N..M:`. + putLoose: new RegExp(`^PUT\\s+${LINE}(?:\\s*(?:-|\\.\\.|\\.=|–)\\s*${LINE})?\\s*(:)${REG}\\s*$`), +} + +/** + * Parse a hashline payload into file sections. + * @returns {{sections: Array<{path:string, tag:string, ops:object[], line:number}>, warnings:string[]}} + */ +export function parseHashline(input) { + const warnings = [] + let lines = String(input).replace(/\r\n?/g, '\n').split('\n') + // Optional envelope. + lines = lines.filter((l) => !/^\*\*\*\s*(Begin|End) Patch\s*$/.test(l.trim())) + // Strip a surrounding markdown fence the model may have added. + if (lines.length && /^```/.test(lines[0].trim())) { + lines.shift() + const close = lines.findLastIndex((l) => /^```\s*$/.test(l.trim())) + if (close >= 0) lines.splice(close, 1) + } + const sections = [] + let cur = null + let op = null + const endOp = () => { op = null } + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] + const lineNo = i + 1 + const h = HEADER_RE.exec(raw.trim()) + if (h) { + endOp() + cur = { path: unquote(h[1].trim()), tag: h[2].toUpperCase(), ops: [], line: lineNo } + sections.push(cur) + continue + } + if (op && op.body && raw.startsWith('+')) { op.body.push(raw.slice(1)); continue } + const t = raw.trim() + if (t === '') { if (op && op.body) { /* blank between rows: treat as end */ endOp() } continue } + if (!cur) { + if (/^\[.+\]$/.test(t)) throw new EditError(`Line ${lineNo}: section header ${jsonQuote(t)} is missing the 4-hex snapshot tag. Copy the [PATH#TAG] header from the latest read/grep/edit output.`) + throw new EditError(`Line ${lineNo}: expected a [PATH#TAG] section header before ${jsonQuote(t)}.`) + } + if (op && op.body && (t.startsWith('-') || /^ /.test(raw))) { + throw new EditError(`Line ${lineNo}: body rows must start with '+' (got ${jsonQuote(raw)}). Hashline bodies are final content, never diff '-'/context lines; write literal '-x' as '+-x'.`) + } + op = parseOp(t, lineNo, warnings) + cur.ops.push(op) + if (!op.takesBody) op = null + } + for (const s of sections) { + for (const o of s.ops) { + if (o.takesBody && o.body.length === 0) { + throw new EditError(`${s.path}: ${o.src} has no '+' body rows. Use CUT to delete lines; a lone '+' writes a blank line.`) + } + } + if (!s.ops.length) throw new EditError(`${s.path}: section has no operations.`) + } + if (!sections.length) throw new EditError('No [PATH#TAG] sections found in the patch.') + return { sections, warnings } +} + +function unquote(p) { + if (/^".*"$/.test(p)) { try { return JSON.parse(p) } catch { return p.slice(1, -1) } } + return p +} + +function parseOp(t, lineNo, warnings) { + let m + if ((m = RE.putRange.exec(t))) { + const [, a, b, colon, reg] = m + if (!colon && !reg) throw new EditError(`Line ${lineNo}: ${jsonQuote(t)} needs ':' and '+' rows, or '@name' to paste a register.`) + if (!colon && reg) return { kind: 'paste', src: t, target: { span: { start: +a, end: +b } }, reg } + return { kind: 'replace', src: t, start: +a, end: +b, takesBody: true, body: [] } + } + if ((m = RE.putBlock.exec(t))) { + const [, a, colon, reg] = m + if (!colon && !reg) throw new EditError(`Line ${lineNo}: ${jsonQuote(t)} needs ':' and '+' rows, or '@name' to paste a register.`) + if (!colon && reg) return { kind: 'paste', src: t, target: { block: +a }, reg } + return { kind: 'replaceBlock', src: t, line: +a, takesBody: true, body: [] } + } + if ((m = RE.putGap.exec(t))) { + const [, side, at, star, colon, reg] = m + const anchor = at === '$' ? '$' : +at + if (side === '<' && anchor === '$') throw new EditError(`Line ${lineNo}: use 'PUT >$' to append at the end of the file.`) + if (colon) return { kind: 'insert', src: t, side, anchor, block: !!star, takesBody: true, body: [] } + if (star) throw new EditError(`Line ${lineNo}: register paste after a block is not supported; use PUT >M @name with the closer line.`) + return { kind: 'paste', src: t, target: { gap: { side, anchor } }, reg: reg ?? null } + } + if ((m = RE.cutRange.exec(t))) return { kind: 'cut', src: t, start: +m[1], end: +m[2], reg: m[3] ?? null } + if ((m = RE.cutBlock.exec(t))) return { kind: 'cutBlock', src: t, line: +m[1], reg: m[2] ?? null } + if (RE.rem.test(t)) return { kind: 'rem', src: t } + if ((m = RE.mv.exec(t))) return { kind: 'mv', src: t, dest: unquote(m[1]) } + if ((m = RE.putLoose.exec(t))) { + const a = +m[1]; const b = m[2] ? +m[2] : a + warnings.push(`Read ${jsonQuote(t)} as "PUT ${a}.=${b}:" (canonical range syntax is N.=M).`) + return { kind: 'replace', src: t, start: a, end: b, takesBody: true, body: [] } + } + if (/^[-+]/.test(t)) throw new EditError(`Line ${lineNo}: ${jsonQuote(t)} is a body row without a preceding 'PUT …:' header.`) + throw new EditError(`Line ${lineNo}: unrecognized operation ${jsonQuote(t)}. Expected PUT/CUT/REM/MV (see the edit tool description).`) +} + +/** Line numbers an op depends on (for stale-tag recovery and range validation). */ +export function opAnchors(op) { + switch (op.kind) { + case 'replace': case 'cut': { const a = []; for (let n = op.start; n <= op.end; n++) a.push(n); return a } + case 'replaceBlock': case 'cutBlock': return [op.line] + case 'insert': return op.anchor === '$' ? [] : [op.anchor] + case 'paste': + if (op.target.span) { const a = []; for (let n = op.target.span.start; n <= op.target.span.end; n++) a.push(n); return a } + if (op.target.block) return [op.target.block] + return op.target.gap.anchor === '$' ? [] : [op.target.gap.anchor] + default: return [] + } +} + +/** Return a copy of op with every line number passed through map(n). */ +export function remapOp(op, map) { + const o = structuredClone(op) + const m = (n) => (n === '$' ? '$' : map(n)) + switch (o.kind) { + case 'replace': case 'cut': o.start = m(o.start); o.end = m(o.end); break + case 'replaceBlock': case 'cutBlock': o.line = m(o.line); break + case 'insert': o.anchor = m(o.anchor); break + case 'paste': + if (o.target.span) { o.target.span.start = m(o.target.span.start); o.target.span.end = m(o.target.span.end) } + else if (o.target.block) o.target.block = m(o.target.block) + else o.target.gap.anchor = m(o.target.gap.anchor) + break + } + return o +} + +/** + * Apply one section's ops to `lines` (the snapshot the tag names). + * Edits are resolved against original numbering, then applied bottom-up. + * @param {object} ctx { path, lines, registers: Map, anon: {value}, warnings } + * @returns {{lines:string[], regions:Array<[number,number]>, rem:boolean, mv:string|null}} + */ +export function applySection(section, { path, lines, registers, anon, warnings }) { + const n = lines.length + const checkLine = (x, what) => { + if (x === '$') return + if (!Number.isInteger(x) || x < 1 || x > Math.max(n, 1)) { + throw new EditError(`${path}: ${what} line ${x} is outside the file (it has ${n} line${n === 1 ? '' : 's'}). Line numbers come from the tagged snapshot; re-read if unsure.`) + } + } + const block = (line) => resolveBlock(path, lines, line) + // Normalize every op to an edit over original numbering: + // { start, end, rows } replace [start..end] (end = start-1 for a pure insertion before start) + const edits = [] + let rem = false + let mv = null + for (const op of section.ops) { + switch (op.kind) { + case 'replace': { + checkLine(op.start, op.src); checkLine(op.end, op.src) + if (op.end < op.start) throw new EditError(`${path}: ${op.src} has end before start.`) + edits.push({ start: op.start, end: op.end, rows: op.body, src: op.src }) + break + } + case 'replaceBlock': { + checkLine(op.line, op.src) + const b = block(op.line) + edits.push({ start: b.start, end: b.end, rows: op.body, src: op.src }) + warnings.push(`${op.src} → resolved block ${b.start}-${b.end}.`) + break + } + case 'insert': { + const at = gapIndex(op.side, op.anchor, op.block, n, checkLine, block, op.src, warnings) + edits.push({ start: at, end: at - 1, rows: op.body, src: op.src }) + break + } + case 'cut': case 'cutBlock': { + let s, e + if (op.kind === 'cut') { checkLine(op.start, op.src); checkLine(op.end, op.src); s = op.start; e = op.end } + else { checkLine(op.line, op.src); const b = block(op.line); s = b.start; e = b.end; warnings.push(`${op.src} → resolved block ${b.start}-${b.end}.`) } + if (e < s) throw new EditError(`${path}: ${op.src} has end before start.`) + const captured = lines.slice(s - 1, e) + if (op.reg) registers.set(op.reg, captured) + else anon.value = captured + edits.push({ start: s, end: e, rows: [], src: op.src }) + break + } + case 'paste': { + const rows = op.reg ? registers.get(op.reg) : anon.value + if (!rows) { + throw new EditError(op.reg + ? `${path}: register @${op.reg} is empty. CUT into it first (earlier in this call or a previous call).` + : `${path}: the anonymous register is empty; CUT before pasting in the same call, or use a named register.`) + } + if (op.target.gap) { + const at = gapIndex(op.target.gap.side, op.target.gap.anchor, false, n, checkLine, block, op.src, warnings) + edits.push({ start: at, end: at - 1, rows: [...rows], src: op.src }) + } else if (op.target.span) { + const { start, end } = op.target.span + checkLine(start, op.src); checkLine(end, op.src) + edits.push({ start, end, rows: [...rows], src: op.src }) + } else { + checkLine(op.target.block, op.src) + const b = block(op.target.block) + edits.push({ start: b.start, end: b.end, rows: [...rows], src: op.src }) + } + break + } + case 'rem': rem = true; break + case 'mv': mv = op.dest; break + } + } + if (rem && edits.length) throw new EditError(`${path}: REM cannot be combined with other edits in the same section.`) + // Overlap check over original numbering (pure insertions may share a gap; they keep order). + const sorted = edits.map((e, i) => ({ ...e, i })).sort((a, b) => a.start - b.start || a.i - b.i) + for (let k = 1; k < sorted.length; k++) { + const p = sorted[k - 1]; const c = sorted[k] + const pIns = p.end < p.start; const cIns = c.end < c.start + if (!pIns && !cIns && c.start <= p.end) throw new EditError(`${path}: ${p.src} and ${c.src} overlap (lines ${c.start}-${Math.min(p.end, c.end)}). Merge them into one range.`) + if (!pIns && cIns && c.start > p.start && c.start <= p.end) throw new EditError(`${path}: ${c.src} inserts inside the range replaced by ${p.src}.`) + } + // Apply bottom-up so earlier numbering stays valid. + const out = [...lines] + const applied = [] + for (const e of [...sorted].sort((a, b) => b.start - a.start || b.i - a.i)) { + out.splice(e.start - 1, e.end - e.start + 1, ...e.rows) + applied.push(e) + } + // Regions in new numbering for the preview. + const regions = [] + for (const e of sorted) { + let shift = 0 + for (const o of sorted) if (o.start < e.start || (o.start === e.start && o.i < e.i)) shift += o.rows.length - (o.end - o.start + 1) + const s = e.start + shift + regions.push([Math.max(1, s), Math.max(1, s + Math.max(e.rows.length, 1) - 1)]) + } + return { lines: out, regions, rem, mv } +} + +function gapIndex(side, anchor, isBlock, n, checkLine, block, src, warnings) { + if (anchor === '$') return n + 1 + checkLine(anchor, src) + if (side === '<') return anchor + if (isBlock) { + const b = block(anchor) + warnings.push(`${src} → inserting after block ${b.start}-${b.end}.`) + return b.end + 1 + } + return anchor + 1 +} diff --git a/lib/prompts.js b/lib/prompts.js new file mode 100644 index 0000000..e18d07a --- /dev/null +++ b/lib/prompts.js @@ -0,0 +1,82 @@ +// Model-facing tool descriptions per edit mode (adapted from oh-my-pi's crates/pi-edit/prompts). + +export const HASHLINE = `Patch existing files with hashline anchors (new files: \`write\`). Each file section starts with \`[PATH#TAG]\` — TAG is the required 4-hex snapshot from the latest \`read\`/\`grep\`/\`edit\` output for that file. Line numbers are the original \`LINE:TEXT\` numbers of that snapshot, never shifted by earlier hunks in the same call. + + +\`PUT N.=M:\` replace inclusive lines N–M with the \`+\` body (\`N.=N\` for one line); \`PUT N*:\` replace the block opening on line N. +\`PUT N:\` insert before/after line N (\`<1\` = file head, \`>$\` = tail). \`PUT >N*:\` insert after the block opening at N (sibling depth); to insert inside a block use \`PUT N @name\` paste at a gap (omit the name for the anonymous CUT of this call); \`PUT N.=M @name\` / \`PUT N* @name\` paste over a range/block (name REQUIRED). Register pastes have NO body; named registers persist across calls. +\`REM\` delete the file; \`MV DEST\` rename it after the section's edits (quote paths with spaces). + + + +- Only \`:\` ops take a body: rows are \`+TEXT\` verbatim including indentation; a lone \`+\` is a blank line. Literal \`- item\`/\`+ item\` → \`+- item\`/\`++ item\`. NEVER \`-\` rows or bare context lines. Body length is independent of the range; delete with CUT, not an empty PUT. +- Touch only lines you have seen; truncated/omitted lines are UNSEEN — read them first. Keep ranges tight: split non-adjacent changes into separate ops; never include unchanged keepers or start/end mid-expression. Pure additions use a gap PUT. +- \`*\` needs a multi-line opener (function/class/heading/object line), NEVER a closer, blank line or inner statement; for one statement use a range. Anchor the first decorator/attribute/doc-comment to include it. +- Markdown: a heading block runs through deeper headings until the next same/higher heading; after a section \`PUT >N*:\` end the body with a blank line. +- NEVER restyle unrelated code. Tags and numbers change after every edit: use the \`[PATH#TAG]\` and preview in the edit response, or re-read. On a stale tag or surprise, STOP and re-read. + + + +[greet.py#A1B2] +PUT 1*: ++@cache ++def greet(name): ++ print(name) +[PLAN.md#3C4D] +PUT >2: ++- task + +Cross-file move: \`CUT 1* @fn\` in the source section, then \`PUT <1 @fn\` in the destination section.` + +export const REPLACE = `Edit an existing UTF-8 text file by string replacement, with fuzzy whitespace matching (indentation, trailing spaces, smart quotes). +- Use the smallest \`old_string\` that uniquely identifies the change; if it is not unique, add surrounding context or set \`replace_all\`. +- Several changes to the same file: pass \`edits\` (array of {old_string, new_string, replace_all?}); they apply atomically against the original text. +- You MUST read the file in this conversation before editing it. Use \`write\` for new files.` + +export const SLOPPY = `Anchored edit patch: Find quotes existing text; Replace replaces it; Insert Before/Insert After add lines without replacing it. + + +- \`*** Edit File: path\` opens a file; a bare \`*** Edit File:\` continues it. Repeat for more files; all edits apply atomically. Append \` all\` to the opener to change every match. +- \`*** Find\` MUST match once unless \`all\`. Copy exact text/indentation from the latest read. Use the smallest unique anchor; if ambiguous, add parent context. +- Follow each Find with exactly one of \`*** Replace\` (complete final text; empty deletes), \`*** Insert Before\`, \`*** Insert After\`. Inserts keep the match. +- Headers stand alone; bodies are raw lines until the next header — no \`+\`/\`-\` prefixes, no \`@@\` hunks, no closing line. Edits address the original file. +- In Find, \`…\` captures omitted text (mid-line gaps stay on the line; a gap at line end spans lines). Each \`…\` in Replace re-emits the next capture. +- Failure applies nothing: fix and resend the whole payload. + + + +*** Edit File: src/util.ts +*** Find +const helper = () => 1; +*** Replace +const helper = () => 2; +*** Find +run(target); +*** Insert Before +log(target); +` + +export const APPLY_PATCH = `Edit files with a stripped-down, file-oriented diff (apply_patch format). Envelope: +*** Begin Patch +[ one or more file sections ] +*** End Patch + +\`*** Add File: \` create a file; every following line starts with \`+\`. +\`*** Delete File: \` remove a file; nothing follows. +\`*** Update File: \` patch in place; optional \`*** Move to: \`; then one or more hunks. A hunk starts with \`@@\` (optionally \`@@ \` to scope it; repeat \`@@\` lines to narrow further) followed by lines prefixed with space (context), \`-\` (remove) or \`+\` (add). Use 3 lines of context before and after each change; don't repeat context between adjacent changes. \`*** End of File\` anchors a hunk at EOF. + +Example: +*** Begin Patch +*** Update File: src/app.py +@@ def main(): + args = parse() +- run(args) ++ run(args, verbose=True) +*** Add File: src/new.py ++print("hi") +*** End Patch` + +export const READ_HASHLINE_NOTE = ' Output is `[PATH#TAG]` followed by `LINE:TEXT` rows; copy that header and the line numbers into `edit`.' +export const GREP_HASHLINE_NOTE = ' Matches are grouped under `[PATH#TAG]` headers with `LINE:TEXT` rows usable as `edit` anchors.' diff --git a/lib/replace.js b/lib/replace.js new file mode 100644 index 0000000..41045f1 --- /dev/null +++ b/lib/replace.js @@ -0,0 +1,138 @@ +// `replace` mode: string replacement with oh-my-pi style fuzzy whitespace matching. +// +// Match order: exact → line-trimmed (indentation/trailing whitespace) → whitespace-collapsed +// → typographic-normalized (smart quotes, dashes, nbsp) → similarity ≥ 0.95 over a window of +// the same line count. A fuzzy match is reindented: the replacement keeps the file's actual +// indentation delta. +import { EditError, jsonQuote } from './text.js' + +const norms = [ + { name: 'trimmed', fn: (l) => l.trim() }, + { name: 'collapsed', fn: (l) => l.trim().replace(/\s+/g, ' ') }, + { + name: 'typography', + fn: (l) => l.trim().replace(/\s+/g, ' ') + .replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"') + .replace(/[\u2013\u2014\u2212]/g, '-').replace(/\u00A0/g, ' ').replace(/\u2026/g, '...'), + }, +] + +/** Count non-overlapping exact occurrences. */ +function countExact(hay, needle) { + let c = 0; let i = 0 + while ((i = hay.indexOf(needle, i)) !== -1) { c++; i += needle.length } + return c +} + +/** + * Find old in text. Returns a list of {start,end} char offsets (end exclusive) + kind. + */ +export function findMatches(text, old, { fuzzy = true, threshold = 0.95 } = {}) { + const exact = [] + let i = 0 + while ((i = text.indexOf(old, i)) !== -1) { exact.push({ start: i, end: i + old.length }); i += old.length } + if (exact.length || !fuzzy) return { kind: 'exact', matches: exact } + + const tLines = text.split('\n') + const oLines = old.replace(/\n$/, '').split('\n') + // Drop leading/trailing blank lines of the needle for line matching. + while (oLines.length > 1 && oLines[0].trim() === '') oLines.shift() + while (oLines.length > 1 && oLines.at(-1).trim() === '') oLines.pop() + const offsets = [] + let acc = 0 + for (const l of tLines) { offsets.push(acc); acc += l.length + 1 } + const spanOf = (s, len) => ({ start: offsets[s], end: offsets[s + len - 1] + tLines[s + len - 1].length, lineStart: s, lineCount: len }) + + for (const { name, fn } of norms) { + const want = oLines.map(fn) + const hits = [] + for (let s = 0; s + want.length <= tLines.length; s++) { + let ok = true + for (let k = 0; k < want.length; k++) if (fn(tLines[s + k]) !== want[k]) { ok = false; break } + if (ok) { hits.push(spanOf(s, want.length)); s += want.length - 1 } + } + if (hits.length) return { kind: name, matches: hits } + } + // Similarity window. + if (oLines.length >= 1) { + const want = oLines.map((l) => l.trim()).join('\n') + let best = null; let second = 0 + for (let s = 0; s + oLines.length <= tLines.length; s++) { + const got = tLines.slice(s, s + oLines.length).map((l) => l.trim()).join('\n') + const sim = similarity(want, got) + if (!best || sim > best.sim) { second = best?.sim ?? 0; best = { sim, s } } else if (sim > second) second = sim + } + if (best && best.sim >= threshold && best.sim - second > 0.01) { + return { kind: `similar(${best.sim.toFixed(3)})`, matches: [spanOf(best.s, oLines.length)] } + } + if (best && best.sim >= 0.6) return { kind: 'none', matches: [], closest: { line: best.s + 1, sim: best.sim, text: tLines.slice(best.s, best.s + oLines.length).join('\n') } } + } + return { kind: 'none', matches: [] } +} + +/** Normalized Levenshtein similarity (bounded for speed). */ +export function similarity(a, b) { + if (a === b) return 1 + const la = a.length; const lb = b.length + if (!la || !lb) return 0 + if (Math.abs(la - lb) / Math.max(la, lb) > 0.2) return 1 - Math.abs(la - lb) / Math.max(la, lb) + if (la * lb > 4_000_000) return a.slice(0, 2000) === b.slice(0, 2000) ? 0.9 : 0 + let prev = new Uint32Array(lb + 1); let cur = new Uint32Array(lb + 1) + for (let j = 0; j <= lb; j++) prev[j] = j + for (let i = 1; i <= la; i++) { + cur[0] = i + const ca = a.charCodeAt(i - 1) + for (let j = 1; j <= lb; j++) { + const cost = ca === b.charCodeAt(j - 1) ? 0 : 1 + cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost) + } + ;[prev, cur] = [cur, prev] + } + return 1 - prev[lb] / Math.max(la, lb) +} + +/** Shift the replacement's indentation by the delta between needle and actual text. */ +function reindent(oldText, actual, replacement) { + const ind = (s) => (s.split('\n').find((l) => l.trim() !== '') ?? '').match(/^[ \t]*/)[0] + const from = ind(oldText); const to = ind(actual) + if (from === to) return replacement + return replacement.split('\n').map((l) => { + if (l.trim() === '') return l + if (l.startsWith(from)) return to + l.slice(from.length) + return l + }).join('\n') +} + +/** + * Apply one replacement to text. + * @returns {{text:string, count:number, kind:string}} + */ +export function applyReplace(text, oldString, newString, { all = false, fuzzy = true, path = 'file' } = {}) { + if (!oldString) throw new EditError('old_string must be non-empty.') + if (oldString === newString) throw new EditError('old_string and new_string are identical; nothing to change.') + const res = findMatches(text, oldString, { fuzzy }) + if (!res.matches.length) { + let msg = `old_string was not found in ${path}.` + if (res.closest) msg += ` Closest text (${Math.round(res.closest.sim * 100)}% similar) at line ${res.closest.line}:\n${res.closest.text}\nCopy the exact text from a fresh read.` + throw new EditError(msg) + } + if (res.matches.length > 1 && !all) { + const lines = res.matches.map((m) => text.slice(0, m.start).split('\n').length) + throw new EditError(`old_string matched ${res.matches.length} times in ${path} (lines ${lines.join(', ')}); add surrounding context to make it unique or set replace_all.`) + } + let out = '' + let last = 0 + for (const m of res.matches) { + const actual = text.slice(m.start, m.end) + // Fuzzy spans cover whole lines without their final newline; drop the needle's trailing + // newline from the replacement to match, then carry the file's indentation over. + const body = oldString.endsWith('\n') ? newString.replace(/\n$/, '') : newString + const repl = res.kind === 'exact' ? newString : reindent(oldString, actual, body) + out += text.slice(last, m.start) + repl + last = m.end + } + out += text.slice(last) + return { text: out, count: res.matches.length, kind: res.kind } +} + +export { countExact, jsonQuote } diff --git a/lib/sloppy.js b/lib/sloppy.js new file mode 100644 index 0000000..f7d7695 --- /dev/null +++ b/lib/sloppy.js @@ -0,0 +1,146 @@ +// `sloppy` mode: anchored find / replace / insert-before / insert-after (oh-my-pi compatible). +// +// *** Edit File: path [all] +// *** Find +// (`…` = capture of omitted text) +// *** Replace | *** Insert Before | *** Insert After +// (Replace `…` re-emits the next capture) +// +// Edits address the original file; failure applies nothing. +import { EditError, jsonQuote } from './text.js' + +const HDR = /^\*\*\*\s*(Edit File:|Find|Replace|Insert Before|Insert After|End(?: Patch| of File)?|Begin Patch)\s*(.*)$/ +const ELLIPSIS = /…|\.\.\./ + +export function parseSloppy(input) { + const lines = String(input).replace(/\r\n?/g, '\n').split('\n') + const files = [] + let file = null + let edit = null + let section = null + const flush = () => { + if (section && edit) edit[section.kind] = trimBody(section.rows) + section = null + } + for (let i = 0; i < lines.length; i++) { + const raw = lines[i] + const m = HDR.exec(raw.trim()) + if (!m) { + if (section) { section.rows.push(raw); continue } + if (raw.trim() === '') continue + throw new EditError(`Line ${i + 1}: text outside a *** Find/Replace/Insert section: ${jsonQuote(raw)}`) + } + const [, kw, rest] = m + if (kw.startsWith('Begin') || kw.startsWith('End')) { flush(); continue } + if (kw === 'Edit File:') { + flush() + let p = rest.trim() + let all = false + if (/\s+all$/.test(p)) { all = true; p = p.replace(/\s+all$/, '') } + if (/^".*"$/.test(p)) { try { p = JSON.parse(p) } catch {} } + if (!p) { + if (!file) throw new EditError(`Line ${i + 1}: bare '*** Edit File:' with no previous file.`) + if (all) file.all = true + continue + } + file = files.find((f) => f.path === p) + if (!file) { file = { path: p, all, edits: [] }; files.push(file) } else if (all) file.all = true + edit = null + continue + } + if (!file) throw new EditError(`Line ${i + 1}: '*** ${kw}' before any '*** Edit File:' header.`) + if (kw === 'Find') { + flush() + edit = { find: null, replace: undefined, before: undefined, after: undefined, all: file.all } + file.edits.push(edit) + section = { kind: 'find', rows: [] } + continue + } + if (!edit) throw new EditError(`Line ${i + 1}: '*** ${kw}' must follow a '*** Find' section.`) + flush() + section = { kind: kw === 'Replace' ? 'replace' : kw === 'Insert Before' ? 'before' : 'after', rows: [] } + } + flush() + if (!files.length) throw new EditError("No '*** Edit File:' sections found.") + for (const f of files) { + if (!f.edits.length) throw new EditError(`${f.path}: no *** Find sections.`) + for (const e of f.edits) { + if (!e.find) throw new EditError(`${f.path}: empty *** Find section.`) + const acts = ['replace', 'before', 'after'].filter((k) => e[k] !== undefined) + if (acts.length !== 1) throw new EditError(`${f.path}: each *** Find needs exactly one of *** Replace / *** Insert Before / *** Insert After (Find: ${jsonQuote(e.find.split('\n')[0])}).`) + } + } + return files +} + +function trimBody(rows) { + const r = [...rows] + while (r.length && r.at(-1).trim() === '') r.pop() + while (r.length && r[0].trim() === '' && r.length > 1) r.shift() + return r.join('\n') +} + +/** Build a regex for a Find text with `…` captures. */ +function findRegex(find) { + const parts = find.split(ELLIPSIS) + if (parts.length === 1) return null + const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + let src = '' + parts.forEach((p, i) => { + src += esc(p) + if (i < parts.length - 1) { + // mid-line gap stays on the line; a line-end gap spans lines + const lineEnd = /\n/.test(parts[i + 1]?.slice(0, 1) ?? '') || parts[i + 1] === '' + src += lineEnd ? '([\\s\\S]*?)' : '([^\\n]*?)' + } + }) + return new RegExp(src, 'g') +} + +/** Apply a file's sloppy edits to text. */ +export function applySloppy(text, file) { + const spans = [] + for (const e of file.edits) { + const re = findRegex(e.find) + const hits = [] + if (re) { + let m + while ((m = re.exec(text))) { hits.push({ start: m.index, end: m.index + m[0].length, caps: m.slice(1) }); if (m[0].length === 0) re.lastIndex++ } + } else { + let i = 0 + while ((i = text.indexOf(e.find, i)) !== -1) { hits.push({ start: i, end: i + e.find.length, caps: [] }); i += Math.max(1, e.find.length) } + } + if (!hits.length) throw new EditError(`${file.path}: *** Find text not found:\n${e.find}\nCopy exact text and indentation from a fresh read.`) + if (hits.length > 1 && !e.all) { + const ln = hits.map((h) => text.slice(0, h.start).split('\n').length) + throw new EditError(`${file.path}: *** Find matched ${hits.length} times (lines ${ln.join(', ')}); add parent context to make it unique, or append ' all' to the Edit File header.`) + } + for (const h of hits) { + let replacement + if (e.replace !== undefined) { + if (e.replace === text.slice(h.start, h.end)) throw new EditError(`${file.path}: no change — the Replace text already equals the file text (Find: ${jsonQuote(e.find.split('\n')[0])}).`) + let k = 0 + replacement = e.replace.replace(new RegExp(ELLIPSIS.source, 'g'), () => { + if (k >= h.caps.length) throw new EditError(`${file.path}: Replace has more '…' than the Find captured.`) + return h.caps[k++] + }) + } else { + // Insert keeps the match; expand to whole lines. + const ls = text.lastIndexOf('\n', h.start - 1) + 1 + let le = text.indexOf('\n', h.end - (text[h.end - 1] === '\n' ? 1 : 0)) + if (le === -1) le = text.length + const matched = text.slice(ls, le) + const ins = e.before !== undefined ? e.before : e.after + if (!ins.trim()) throw new EditError(`${file.path}: Insert body is empty.`) + replacement = e.before !== undefined ? `${ins}\n${matched}` : `${matched}\n${ins}` + h.start = ls; h.end = le + } + spans.push({ start: h.start, end: h.end, replacement }) + } + } + spans.sort((a, b) => a.start - b.start) + for (let i = 1; i < spans.length; i++) if (spans[i].start < spans[i - 1].end) throw new EditError(`${file.path}: two Find anchors overlap; merge them into one edit.`) + let out = ''; let last = 0 + for (const s of spans) { out += text.slice(last, s.start) + s.replacement; last = s.end } + return out + text.slice(last) +} diff --git a/lib/store.js b/lib/store.js new file mode 100644 index 0000000..cdd5b88 --- /dev/null +++ b/lib/store.js @@ -0,0 +1,172 @@ +// Session snapshot store + stale-tag recovery. +// +// Every tagged read/grep/edit records the full LF text under (file, tag). An edit that names +// an older tag is rebased onto the current file when the anchored lines (and a neighbour on +// each side) survived unchanged — the same rule oh-my-pi uses — otherwise it is rejected +// with fresh context so the model can retry. +import { fileTag, splitLines } from './text.js' +import { opAnchors, remapOp } from './hashline.js' + +const MAX_SNAPSHOTS_PER_FILE = 12 +const MAX_FILES = 400 +const MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024 + +export class SnapshotStore { + constructor() { + /** @type {Map>} key -> newest last */ + this.files = new Map() + /** @type {Map} named registers persist for the session */ + this.registers = new Map() + } + + /** Record text for key and return its tag. */ + record(key, text) { + const tag = fileTag(text) + if (text.length > MAX_SNAPSHOT_BYTES) return tag + let list = this.files.get(key) + if (!list) { + if (this.files.size >= MAX_FILES) this.files.delete(this.files.keys().next().value) + list = [] + this.files.set(key, list) + } else { + // refresh LRU position + this.files.delete(key); this.files.set(key, list) + } + const i = list.findIndex((s) => s.tag === tag) + if (i >= 0) list.splice(i, 1) + list.push({ tag, text, at: Date.now() }) + while (list.length > MAX_SNAPSHOTS_PER_FILE) list.shift() + return tag + } + + byTag(key, tag) { + return this.files.get(key)?.find((s) => s.tag === tag) + } + + /** Paths (keys) that have a snapshot with this tag — helps explain a tag pasted onto the wrong file. */ + keysWithTag(tag) { + const out = [] + for (const [k, list] of this.files) if (list.some((s) => s.tag === tag)) out.push(k) + return out + } + + forget(key) { this.files.delete(key) } +} + +// ---------------------------------------------------------------- line diff (Myers, O(ND)) + +/** Map old 1-based line -> new 1-based line for lines common to both texts. */ +export function lineMap(prevLines, curLines) { + const map = new Map() + // Trim common prefix/suffix first (cheap and usually most of the file). + let a0 = 0 + while (a0 < prevLines.length && a0 < curLines.length && prevLines[a0] === curLines[a0]) { map.set(a0 + 1, a0 + 1); a0++ } + let ae = prevLines.length; let be = curLines.length + const tail = [] + while (ae > a0 && be > a0 && prevLines[ae - 1] === curLines[be - 1]) { tail.push([ae, be]); ae--; be-- } + const A = prevLines.slice(a0, ae); const B = curLines.slice(a0, be) + for (const [i, j] of lcsPairs(A, B)) map.set(a0 + i + 1, a0 + j + 1) + for (const [i, j] of tail) map.set(i, j) + return map +} + +function lcsPairs(A, B) { + const N = A.length; const M = B.length + if (!N || !M) return [] + if (N * M > 4_000_000) return greedyPairs(A, B) + const max = N + M + const V = new Int32Array(2 * max + 2) + const trace = [] + let found = false + for (let d = 0; d <= max && !found; d++) { + trace.push(V.slice()) + for (let k = -d; k <= d; k += 2) { + let x = k === -d || (k !== d && V[max + k - 1] < V[max + k + 1]) ? V[max + k + 1] : V[max + k - 1] + 1 + let y = x - k + while (x < N && y < M && A[x] === B[y]) { x++; y++ } + V[max + k] = x + if (x >= N && y >= M) { found = true; break } + } + } + // Backtrack. + const pairs = [] + let x = N; let y = M + for (let d = trace.length - 1; d >= 0 && (x > 0 || y > 0); d--) { + const v = trace[d] + const k = x - y + const prevK = k === -d || (k !== d && v[max + k - 1] < v[max + k + 1]) ? k + 1 : k - 1 + const prevX = v[max + prevK] + const prevY = prevX - prevK + while (x > prevX && y > prevY) { x--; y--; pairs.push([x, y]) } + if (d > 0) { x = prevX; y = prevY } + } + return pairs.reverse() +} + +function greedyPairs(A, B) { + // Fallback for huge diffs: match unique lines in order. + const pos = new Map() + B.forEach((l, j) => pos.set(l, pos.has(l) ? -1 : j)) + const pairs = [] + let last = -1 + A.forEach((l, i) => { const j = pos.get(l); if (j !== undefined && j > last) { pairs.push([i, j]); last = j } }) + return pairs +} + +// ---------------------------------------------------------------- recovery + +/** + * Try to rebase a section parsed against `prevText` onto `curText`. + * @returns {{ops:object[], offsets:Set} | null} + */ +export function rebaseOps(prevText, curText, ops) { + const prev = splitLines(prevText).lines + const cur = splitLines(curText).lines + const map = lineMap(prev, cur) + const anchors = new Set(ops.flatMap(opAnchors)) + const dupPrev = dupes(prev); const dupCur = dupes(cur) + // Validate: each anchor maps, and for unique lines at least one neighbour moved by the same + // offset; for duplicated lines every existing neighbour must agree. + const sorted = [...anchors].sort((a, b) => a - b) + const groups = [] + for (const a of sorted) { + const g = groups.at(-1) + if (g && a === g.end + 1) g.end = a + else groups.push({ start: a, end: a }) + } + for (const g of groups) { + const before = g.start > 1 ? g.start - 1 : null + const after = g.end < prev.length ? g.end + 1 : null + for (let line = g.start; line <= g.end; line++) { + const mapped = map.get(line) + if (mapped === undefined) return null + const off = mapped - line + const okB = before != null && map.get(before) === before + off + const okA = after != null && map.get(after) === after + off + const dup = dupPrev.has(prev[line - 1]) || dupCur.has(cur[mapped - 1]) + if (dup) { + if (before == null && after == null) return null + if (before != null && !okB) return null + if (after != null && !okA) return null + } else if (!okA && !okB && (before != null || after != null)) { + return null + } + } + } + const offsets = new Set() + const remapped = ops.map((op) => remapOp(op, (n) => { const m = map.get(n); offsets.add(m - n); return m })) + // Ranges must stay contiguous after the remap (nothing inserted inside them). + for (const op of remapped) { + if ((op.kind === 'replace' || op.kind === 'cut')) { + const orig = ops[remapped.indexOf(op)] + if (op.end - op.start !== orig.end - orig.start) return null + } + } + return { ops: remapped, offsets } +} + +function dupes(lines) { + const seen = new Set(); const d = new Set() + for (const l of lines) { if (seen.has(l)) d.add(l); else seen.add(l) } + return d +} diff --git a/lib/text.js b/lib/text.js new file mode 100644 index 0000000..6e9bea5 --- /dev/null +++ b/lib/text.js @@ -0,0 +1,78 @@ +// Text helpers shared by every edit mode: line splitting that remembers the +// trailing newline, snapshot tags, and compact numbered previews. +import crypto from 'node:crypto' + +/** Normalize CRLF/CR to LF. */ +export const toLF = (s) => String(s).replace(/\r\n?/g, '\n') + +/** Split LF text into lines plus whether it ended with a newline. Empty text has no lines. */ +export function splitLines(text) { + const t = toLF(text) + if (t === '') return { lines: [], eol: false } + const eol = t.endsWith('\n') + const lines = (eol ? t.slice(0, -1) : t).split('\n') + return { lines, eol } +} + +/** Inverse of splitLines. A file that gained content keeps (or adopts) a trailing newline. */ +export function joinLines(lines, eol) { + if (lines.length === 0) return '' + return lines.join('\n') + (eol ? '\n' : '') +} + +/** Dominant line ending of the original file, so writes preserve CRLF files. */ +export function detectEol(raw) { + const crlf = (raw.match(/\r\n/g) || []).length + const lf = (raw.match(/\n/g) || []).length - crlf + return crlf > lf ? '\r\n' : '\n' +} +export const restoreEol = (lfText, eol) => (eol === '\r\n' ? lfText.replace(/\n/g, '\r\n') : lfText) + +/** Four uppercase hex characters derived from the whole LF-normalized file. */ +export function fileTag(text) { + return crypto.createHash('sha256').update(toLF(text)).digest('hex').slice(0, 4).toUpperCase() +} + +export const HEADER_RE = /^\[(.+?)#([0-9A-Fa-f]{4})\]\s*$/ + +export const formatHeader = (path, tag) => `[${path}#${tag}]` +export const numbered = (n, text) => `${n}:${text}` + +/** + * Compact post-edit preview: changed regions of the new text (by line numbers) + * with `context` lines around them, separated by `…`. + * @param {string[]} lines new file lines + * @param {Array<[number, number]>} regions 1-based inclusive [start, end] in new numbering + */ +export function preview(lines, regions, { context = 2, maxLines = 80 } = {}) { + if (!regions.length || !lines.length) return [] + const spans = regions + .map(([a, b]) => [Math.max(1, a - context), Math.min(lines.length, Math.max(a, b) + context)]) + .sort((x, y) => x[0] - y[0]) + const merged = [] + for (const s of spans) { + const last = merged.at(-1) + if (last && s[0] <= last[1] + 1) last[1] = Math.max(last[1], s[1]) + else merged.push([...s]) + } + const out = [] + for (const [a, b] of merged) { + if (out.length) out.push('…') + for (let n = a; n <= b; n++) { + if (out.length >= maxLines) { out.push('… (preview truncated; read the file for more)'); return out } + out.push(numbered(n, lines[n - 1])) + } + } + return out +} + +/** Error type that carries a model-facing message. */ +export class EditError extends Error { + constructor(message, extra = {}) { + super(message) + this.name = 'EditError' + Object.assign(this, extra) + } +} + +export const jsonQuote = (s) => JSON.stringify(String(s)) diff --git a/locale/en.json b/locale/en.json new file mode 100644 index 0000000..5a6b860 --- /dev/null +++ b/locale/en.json @@ -0,0 +1 @@ +{ "meta": { "title": "OMP Edit", "description": "oh-my-pi style editing: hashline patches with content-hash tagged line anchors replace the edit tool; read/grep output is tagged. Also replace (fuzzy, batched), sloppy and apply_patch modes." } } diff --git a/locale/zh.json b/locale/zh.json new file mode 100644 index 0000000..f807f68 --- /dev/null +++ b/locale/zh.json @@ -0,0 +1 @@ +{ "meta": { "title": "OMP 编辑", "description": "oh-my-pi 风格编辑:用带内容哈希标签的行锚点 hashline 补丁替换 edit 工具;read/grep 输出带标签。另有 replace(模糊匹配、批量)、sloppy 和 apply_patch 模式。" } } diff --git a/package.json b/package.json new file mode 100644 index 0000000..e688bfb --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "@local/omp-edit", + "version": "1.0.0", + "description": "oh-my-pi style editing for DSH: replaces the edit tool with hashline patches (content-hash tagged line anchors, block ops, cut/paste registers, multi-file, stale-tag recovery), plus replace/sloppy/apply_patch modes; tags read and grep output.", + "private": true, + "type": "module", + "license": "MIT", + "exports": { + ".": "./index.js", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "files": ["index.js", "lib/*.js", "cordis.patch.yml", "locale/*.json"], + "scripts": { "test": "node --test test/*.test.mjs" }, + "dsh": { "bundle": { "patch": "./cordis.patch.yml" } } +} diff --git a/test/core.test.mjs b/test/core.test.mjs new file mode 100644 index 0000000..e53820d --- /dev/null +++ b/test/core.test.mjs @@ -0,0 +1,144 @@ +// node --test test/ +import test from 'node:test' +import assert from 'node:assert/strict' +import { parseHashline, applySection } from '../lib/hashline.js' +import { resolveBlock } from '../lib/block.js' +import { SnapshotStore, rebaseOps, lineMap } from '../lib/store.js' +import { applyReplace } from '../lib/replace.js' +import { parseSloppy, applySloppy } from '../lib/sloppy.js' +import { parseApplyPatch, applyUpdate } from '../lib/apply_patch.js' +import { fileTag, splitLines, joinLines } from '../lib/text.js' + +const run = (path, text, patch, registers = new Map()) => { + const { sections, warnings } = parseHashline(patch) + const { lines, eol } = splitLines(text) + const r = applySection(sections[0], { path, lines, registers, anon: { value: null }, warnings }) + return { text: joinLines(r.lines, eol), r, warnings } +} + +const py = '@cache\ndef greet(name):\n print("Hello, " + name)\n\ngreet("world")\n' + +test('PUT range replaces one line', () => { + const out = run('greet.py', py, `[greet.py#${fileTag(py)}]\nPUT 5.=5:\n+greet("team")`) + assert.equal(out.text, '@cache\ndef greet(name):\n print("Hello, " + name)\n\ngreet("team")\n') +}) + +test('PUT N* replaces a decorated python block', () => { + const out = run('greet.py', py, `[greet.py#ABCD]\nPUT 1*:\n+@cache\n+def greet(name):\n+ print(f"Hi, {name}")`) + assert.equal(out.text, '@cache\ndef greet(name):\n print(f"Hi, {name}")\n\ngreet("world")\n') +}) + +test('gap inserts and tail append', () => { + const t = 'a\nb\nc\n' + assert.equal(run('x.txt', t, '[x.txt#0000]\nPUT <1:\n+head').text, 'head\na\nb\nc\n') + assert.equal(run('x.txt', t, '[x.txt#0000]\nPUT >2:\n+mid').text, 'a\nb\nmid\nc\n') + assert.equal(run('x.txt', t, '[x.txt#0000]\nPUT >$:\n+tail').text, 'a\nb\nc\ntail\n') +}) + +test('multiple hunks use original numbering', () => { + const t = '1\n2\n3\n4\n5\n' + const out = run('n.txt', t, '[n.txt#0000]\nPUT 1.=1:\n+one\n+uno\nCUT 3.=3\nPUT 5.=5:\n+five') + assert.equal(out.text, 'one\nuno\n2\n4\nfive\n') +}) + +test('overlap is rejected', () => { + assert.throws(() => run('n.txt', '1\n2\n3\n', '[n.txt#0000]\nPUT 1.=2:\n+x\nPUT 2.=3:\n+y'), /overlap/) +}) + +test('CUT + paste via anon register in one section', () => { + const t = 'a\nb\nc\nd\n' + const out = run('m.txt', t, '[m.txt#0000]\nCUT 1.=2\nPUT >4') + assert.equal(out.text, 'c\nd\na\nb\n') +}) + +test('JS block resolution and >N* insert', () => { + const js = 'function a() {\n if (x) {\n y()\n } else {\n z()\n }\n}\nconst b = 1\n' + assert.deepEqual(resolveBlock('a.js', splitLines(js).lines, 1), { start: 1, end: 7 }) + assert.deepEqual(resolveBlock('a.js', splitLines(js).lines, 2), { start: 2, end: 6 }) + const out = run('a.js', js, '[a.js#0000]\nPUT >1*:\n+\n+function c() {}') + assert.equal(out.text, 'function a() {\n if (x) {\n y()\n } else {\n z()\n }\n}\n\nfunction c() {}\nconst b = 1\n') +}) + +test('markdown heading block', () => { + const md = '# A\ntext\n## A1\nmore\n# B\nb\n' + assert.deepEqual(resolveBlock('r.md', splitLines(md).lines, 1), { start: 1, end: 4 }) + assert.deepEqual(resolveBlock('r.md', splitLines(md).lines, 3), { start: 3, end: 4 }) +}) + +test('block on single line is rejected', () => { + assert.throws(() => resolveBlock('a.js', ['const x = 1', 'y'], 1), /does not open a multi-line block/) +}) + +test('body rows must be +', () => { + assert.throws(() => parseHashline('[a#0000]\nPUT 1.=1:\n-old\n+new'), /must start with '\+'/) +}) + +test('loose range spelling is accepted with a warning', () => { + const out = run('n.txt', '1\n2\n3\n', '[n.txt#0000]\nPUT 2-3:\n+x') + assert.equal(out.text, '1\nx\n') + assert.match(out.warnings.join(), /canonical range/) +}) + +test('stale tag recovery remaps lines shifted by an external insert', () => { + const v1 = 'alpha\nbeta\ngamma\ndelta\n' + const v2 = 'NEW\nalpha\nbeta\ngamma\ndelta\n' + const { sections } = parseHashline('[f#0000]\nPUT 3.=3:\n+GAMMA') + const rb = rebaseOps(v1, v2, sections[0].ops) + assert.ok(rb) + assert.equal(rb.ops[0].start, 4) +}) + +test('stale tag recovery refuses when the anchored line changed', () => { + const v1 = 'alpha\nbeta\ngamma\ndelta\n' + const v2 = 'alpha\nbeta\nGAMMA!\ndelta\n' + const { sections } = parseHashline('[f#0000]\nPUT 3.=3:\n+x') + assert.equal(rebaseOps(v1, v2, sections[0].ops), null) +}) + +test('lineMap via Myers', () => { + const m = lineMap(['a', 'b', 'c', 'd'], ['a', 'x', 'c', 'd', 'e']) + assert.equal(m.get(1), 1); assert.equal(m.get(2), undefined); assert.equal(m.get(3), 3); assert.equal(m.get(4), 4) +}) + +test('snapshot store records and finds tags', () => { + const s = new SnapshotStore() + const tag = s.record('k', 'hello\n') + assert.match(tag, /^[0-9A-F]{4}$/) + assert.equal(s.byTag('k', tag).text, 'hello\n') +}) + +test('replace: exact, ambiguous, fuzzy indentation', () => { + assert.equal(applyReplace('a = 1\nb = 2\n', 'b = 2', 'b = 3').text, 'a = 1\nb = 3\n') + assert.throws(() => applyReplace('x\nx\n', 'x', 'y'), /matched 2 times/) + assert.equal(applyReplace('x\nx\n', 'x', 'y', { all: true }).text, 'y\ny\n') + const src = 'def f():\n if a:\n return 1\n' + const r = applyReplace(src, 'if a:\n return 1', 'if a:\n return 2') + assert.equal(r.text, 'def f():\n if a:\n return 2\n') + assert.equal(r.kind, 'trimmed') + const q = applyReplace('say("hi")\n', 'say(\u201chi\u201d)', 'say("yo")') + assert.equal(q.text, 'say("yo")\n') +}) + +test('replace: not-found gives closest hint', () => { + assert.throws(() => applyReplace('function alpha(beta) {}\n', 'function alpha(betaa) {}', 'x', { fuzzy: false }), /not found/) +}) + +test('sloppy: replace, insert before/after, captures', () => { + const t = 'const helper = () => 1;\nfunction run(target) {\n run(target);\n}\n' + const [f] = parseSloppy('*** Edit File: a.js\n*** Find\nconst helper = () => 1;\n*** Replace\nconst helper = () => 2;\n*** Find\n run(target);\n*** Insert Before\n log(target);') + assert.equal(applySloppy(t, f), 'const helper = () => 2;\nfunction run(target) {\n log(target);\n run(target);\n}\n') + const [g] = parseSloppy('*** Edit File: a.js\n*** Find\nfunction run(…) {\n*** Replace\nfunction run(…, opts) {') + assert.equal(applySloppy(t, g), 'const helper = () => 1;\nfunction run(target, opts) {\n run(target);\n}\n') +}) + +test('apply_patch: update with context + add + move', () => { + const ops = parseApplyPatch('*** Begin Patch\n*** Update File: a.py\n@@ def f\n def f():\n- return 1\n+ return 2\n*** Add File: b.py\n+x = 1\n*** End Patch') + assert.equal(ops.length, 2) + const { lines } = applyUpdate(['import os', 'def f():', ' return 1', ''], ops[0]) + assert.deepEqual(lines, ['import os', 'def f():', ' return 2', '']) + assert.deepEqual(ops[1].lines, ['x = 1']) +}) + +test('header without tag gives actionable error', () => { + assert.throws(() => parseHashline('[a.js]\nPUT 1.=1:\n+x'), /missing the 4-hex snapshot tag/) +}) diff --git a/test/harness.mjs b/test/harness.mjs new file mode 100644 index 0000000..5ca6399 --- /dev/null +++ b/test/harness.mjs @@ -0,0 +1,132 @@ +// Minimal DSH-like context for integration tests: real @deepseek-ai/dsh-fs-local backend, +// the fs-observation-policy rules, a scoped tool registry, and fake agents. +import os from 'node:os' +import path from 'node:path' +import fs from 'node:fs' + +const DSH = '/usr/lib/node_modules/@deepseek-ai/dsh/node_modules/@deepseek-ai' + +export async function makeHarness() { + const { default: LocalFileSystem } = await import(`${DSH}/dsh-fs-local/lib/index.js`) + const { FsError } = await import(`${DSH}/dsh-fs/lib/index.js`) + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'omp-edit-')) + const listeners = new Map() + const on = (name, fn, opts) => { + const list = listeners.get(name) ?? [] + if (opts?.prepend) list.unshift(fn); else list.push(fn) + listeners.set(name, list) + return () => listeners.set(name, (listeners.get(name) ?? []).filter((f) => f !== fn)) + } + const fsCtx = { config: { cwd: dir }, effect() {}, on() {}, get() {}, provide() {}, emit() {} } + // Construct without a Cordis context: borrow the prototype and the instance fields. + const fsImpl = Object.create(LocalFileSystem.prototype) + Object.assign(fsImpl, { config: { cwd: dir, diffBasisMaxBytes: 10 * 1024 * 1024 }, internals: {}, locks: new Map() }) + void fsCtx + + // observation policy (copy of DSH's rules) + const observed = new WeakMap() + const own = (actor) => actor?.agent?.session + on('fs/write-intent', async (target, actor) => { + const p = own(actor) && observed.get(own(actor))?.get(target.targetKey) + return p?.kind === 'present' ? { kind: 'replaceIfVersion', version: p.version } : { kind: 'createIfAbsent' } + }) + on('fs/edit-intent', async (target, actor) => { + const o = own(actor); const p = o && observed.get(o)?.get(target.targetKey) + if (!o || p === undefined) throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED') + if (p.kind === 'absent') throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND') + return { version: p.version } + }) + on('fs/observed', (target, obs, actor) => { + const o = own(actor); if (!o) return + if (!observed.has(o)) observed.set(o, new Map()) + observed.get(o).set(target.targetKey, obs) + }) + + const global = new Map() + const scoped = new WeakMap() + const makeTools = (scope) => ({ + register(def) { + const m = scope ? (scoped.get(scope) ?? scoped.set(scope, new Map()).get(scope)) : global + if (m.has(def.name)) throw new Error(`duplicate tool ${def.name}`) + m.set(def.name, def) + return () => m.delete(def.name) + }, + get(name, s) { return (s && scoped.get(s)?.get(name)) ?? global.get(name) }, + }) + const agents = [] + const sp = { section: () => () => {}, getSectionOrder: () => 1300 } + const ctx = { + fs: fsImpl, + tools: makeTools(undefined), + agents: { list: () => agents }, + systemPrompt: sp, + on, + emit(name, ...args) { for (const f of listeners.get(name) ?? []) f(...args) }, + async waterfall(name, ...args) { + const inner = args.pop() + const cbs = [...(listeners.get(name) ?? [])] + const next = () => (cbs.shift() ?? inner)(...args, next) + return next() + }, + effect(fn) { const d = fn(); return d }, + get() { return undefined }, + } + function newAgent(model = 'anthropic/claude-sonnet') { + const agent = { options: { model }, session: { header: { cwd: dir } } } + agent.ctx = { tools: makeTools(agent), on, systemPrompt: sp } + agents.push(agent) + for (const f of listeners.get('agent/created') ?? []) f({ agent }) + return agent + } + const call = async (agent, name, args) => { + const t = ctx.tools.get(name, agent) + if (!t) throw new Error(`no tool ${name}`) + const exec = { agent, name, arguments: args, signal: new AbortController().signal } + const value = await t.execute(args, exec) + const content = t.output.render(args, value) + const meta = t.output.presentationMeta?.(args, value) + return { value, text: content.map((c) => c.text).join('\n'), meta } + } + const write = (rel, text) => fs.writeFileSync(path.join(dir, rel), text) + const readF = (rel) => fs.readFileSync(path.join(dir, rel), 'utf8') + return { ctx, dir, newAgent, call, write, read: readF, listeners, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) } +} + +/** Fake built-in read/grep with DSH's output shapes. */ +export function registerBuiltins(h) { + h.ctx.tools.register({ + name: 'read', + description: 'Read a UTF-8 text file and return line-numbered content.', + parameters: { type: 'object', properties: { file_path: { type: 'string' } }, required: ['file_path'] }, + output: { + schema: { type: 'object', additionalProperties: false, properties: { path: { type: 'string' }, offset: { type: 'integer' }, lines: { type: 'array' }, totalLines: { type: 'integer' } } }, + render: (_a, v) => [{ type: 'text', text: `${v.path}\nfile\n\n${v.lines.map((l) => `${l.number}: ${l.text}`).join('\n')}\n\n(End of file - total ${v.totalLines} lines)\n` }], + }, + async execute(args, exec) { + const t = await h.ctx.fs.resolve(args.file_path, { cwd: h.dir }) + const info = await h.ctx.fs.stat(t) + const text = await h.ctx.fs.readText(t) + const lines = text.replace(/\n$/, '').split('\n').map((x, i) => ({ number: i + 1, text: x })) + h.ctx.emit('fs/observed', t, { kind: 'present', version: info.version }, exec) + return { path: t.displayPath, offset: 1, lines, totalLines: lines.length } + }, + }) + h.ctx.tools.register({ + name: 'grep', + description: 'Search file contents.', + parameters: { type: 'object', properties: { pattern: { type: 'string' } }, required: ['pattern'] }, + output: { + schema: { type: 'object', additionalProperties: false, properties: { matches: { type: 'array' } } }, + render: (_a, v) => [{ type: 'text', text: v.matches.map((m) => `${m.path}:${m.lineNumber}: ${m.line}`).join('\n') || 'No matches' }], + }, + async execute(args) { + const out = [] + for (const f of fs.readdirSync(h.dir)) { + const p = path.join(h.dir, f) + if (!fs.statSync(p).isFile()) continue + fs.readFileSync(p, 'utf8').split('\n').forEach((l, i) => { if (new RegExp(args.pattern).test(l)) out.push({ path: f, lineNumber: i + 1, line: l }) }) + } + return { matches: out } + }, + }) +} diff --git a/test/integration.test.mjs b/test/integration.test.mjs new file mode 100644 index 0000000..64aa9b8 --- /dev/null +++ b/test/integration.test.mjs @@ -0,0 +1,154 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { makeHarness, registerBuiltins } from './harness.mjs' +import * as plugin from '../index.js' + +async function setup(config = {}, model) { + const h = await makeHarness() + registerBuiltins(h) + plugin.apply(h.ctx, config) + const agent = h.newAgent(model) + return { h, agent } +} + +const tagOf = (text, file) => new RegExp(`\\[${file.replace('.', '\\.')}#([0-9A-F]{4})\\]`).exec(text)?.[1] + +test('read is tagged; hashline edit applies, returns fresh tag + preview + diff', async () => { + const { h, agent } = await setup() + h.write('app.py', 'def f():\n return 1\n\nprint(f())\n') + const r = await h.call(agent, 'read', { file_path: 'app.py' }) + const tag = tagOf(r.text, 'app.py') + assert.ok(tag, r.text) + assert.match(r.text, /^2: return 1$/m) + const e = await h.call(agent, 'edit', { input: `[app.py#${tag}]\nPUT 2.=2:\n+ return 2` }) + assert.equal(h.read('app.py'), 'def f():\n return 2\n\nprint(f())\n') + const newTag = tagOf(e.text, 'app.py') + assert.ok(newTag && newTag !== tag, e.text) + assert.match(e.text, /2: return 2/) + assert.ok(e.meta.diffs.length >= 1) + h.cleanup() +}) + +test('edit without read is refused (DSH observation policy)', async () => { + const { h, agent } = await setup() + h.write('x.txt', 'a\n') + await assert.rejects(h.call(agent, 'edit', { input: '[x.txt#0000]\nPUT 1.=1:\n+b' }), /never issued|read/) + h.cleanup() +}) + +test('stale tag after external change is recovered when anchors survive', async () => { + const { h, agent } = await setup() + h.write('s.txt', 'alpha\nbeta\ngamma\n') + const tag = tagOf((await h.call(agent, 'read', { file_path: 's.txt' })).text, 's.txt') + h.write('s.txt', 'HEADER\nalpha\nbeta\ngamma\n') // external edit + const e = await h.call(agent, 'edit', { input: `[s.txt#${tag}]\nPUT 3.=3:\n+GAMMA` }) + assert.equal(h.read('s.txt'), 'HEADER\nalpha\nbeta\nGAMMA\n') + assert.match(e.text, /recovered stale tag/) + h.cleanup() +}) + +test('stale tag with changed anchor is rejected, nothing written', async () => { + const { h, agent } = await setup() + h.write('s.txt', 'alpha\nbeta\ngamma\n') + const tag = tagOf((await h.call(agent, 'read', { file_path: 's.txt' })).text, 's.txt') + h.write('s.txt', 'alpha\nBETA\ngamma\n') + await assert.rejects(h.call(agent, 'edit', { input: `[s.txt#${tag}]\nPUT 2.=2:\n+x` }), /anchored lines moved or changed/) + assert.equal(h.read('s.txt'), 'alpha\nBETA\ngamma\n') + h.cleanup() +}) + +test('multi-file move with named register, then chained edit using returned tag', async () => { + const { h, agent } = await setup() + h.write('a.js', 'function helper() {\n return 1\n}\n\nhelper()\n') + h.write('b.js', '// lib\n') + const ta = tagOf((await h.call(agent, 'read', { file_path: 'a.js' })).text, 'a.js') + const tb = tagOf((await h.call(agent, 'read', { file_path: 'b.js' })).text, 'b.js') + const e = await h.call(agent, 'edit', { input: `[a.js#${ta}]\nCUT 1* @fn\nCUT 4.=4\n[b.js#${tb}]\nPUT >$ @fn` }) + assert.equal(h.read('a.js'), 'helper()\n') + assert.equal(h.read('b.js'), '// lib\nfunction helper() {\n return 1\n}\n') + const tb2 = tagOf(e.text, 'b.js') + await h.call(agent, 'edit', { input: `[b.js#${tb2}]\nPUT 3.=3:\n+ return 2` }) + assert.match(h.read('b.js'), /return 2/) + h.cleanup() +}) + +test('grep output is tagged and counts as an observation', async () => { + const { h, agent } = await setup() + h.write('g.txt', 'one\nneedle here\nthree\n') + const g = await h.call(agent, 'grep', { pattern: 'needle' }) + const tag = tagOf(g.text, 'g.txt') + assert.ok(tag, g.text) + assert.match(g.text, /^2:needle here$/m) + await h.call(agent, 'edit', { input: `[g.txt#${tag}]\nPUT 2.=2:\n+found` }) + assert.equal(h.read('g.txt'), 'one\nfound\nthree\n') + h.cleanup() +}) + +test('REM and MV', async () => { + const { h, agent } = await setup() + h.write('old.txt', 'x\n') + h.write('gone.txt', 'y\n') + const t1 = tagOf((await h.call(agent, 'read', { file_path: 'old.txt' })).text, 'old.txt') + const t2 = tagOf((await h.call(agent, 'read', { file_path: 'gone.txt' })).text, 'gone.txt') + await h.call(agent, 'edit', { input: `[old.txt#${t1}]\nPUT 1.=1:\n+z\nMV new.txt\n[gone.txt#${t2}]\nREM` }) + assert.equal(h.read('new.txt'), 'z\n') + assert.throws(() => h.read('old.txt')) + assert.throws(() => h.read('gone.txt')) + h.cleanup() +}) + +test('CRLF files keep CRLF', async () => { + const { h, agent } = await setup() + h.write('w.txt', 'a\r\nb\r\n') + const tag = tagOf((await h.call(agent, 'read', { file_path: 'w.txt' })).text, 'w.txt') + await h.call(agent, 'edit', { input: `[w.txt#${tag}]\nPUT 2.=2:\n+c` }) + assert.equal(h.read('w.txt'), 'a\r\nc\r\n') + h.cleanup() +}) + +test('DeepSeek model falls back to replace mode; strict keeps hashline', async () => { + const { h, agent } = await setup({}, 'deepseek-v4') + const t = h.ctx.tools.get('edit', agent) + assert.ok(t.parameters.properties.old_string, 'replace schema') + h.write('r.txt', 'hello world\n') + await h.call(agent, 'read', { file_path: 'r.txt' }) + await h.call(agent, 'edit', { file_path: 'r.txt', edits: [{ old_string: 'hello', new_string: 'goodbye' }, { old_string: 'world', new_string: 'moon' }] }) + assert.equal(h.read('r.txt'), 'goodbye moon\n') + h.cleanup() + const s = await setup({ strict: true }, 'deepseek-v4') + assert.ok(s.h.ctx.tools.get('edit', s.agent).parameters.properties.input) + s.h.cleanup() +}) + +test('replace mode refuses when file changed since read', async () => { + const { h, agent } = await setup({ mode: 'replace' }) + h.write('c.txt', 'v1\n') + await h.call(agent, 'read', { file_path: 'c.txt' }) + h.write('c.txt', 'v2\n') + await assert.rejects(h.call(agent, 'edit', { file_path: 'c.txt', old_string: 'v2', new_string: 'v3' }), /changed since it was read/) + h.cleanup() +}) + +test('sloppy and apply_patch modes', async () => { + const s = await setup({ mode: 'sloppy' }) + s.h.write('u.ts', 'const a = 1;\nrun(a);\n') + await s.h.call(s.agent, 'read', { file_path: 'u.ts' }) + await s.h.call(s.agent, 'edit', { input: '*** Edit File: u.ts\n*** Find\nrun(a);\n*** Insert Before\nlog(a);' }) + assert.equal(s.h.read('u.ts'), 'const a = 1;\nlog(a);\nrun(a);\n') + s.h.cleanup() + const p = await setup({ mode: 'apply_patch' }) + p.h.write('m.py', 'def f():\n return 1\n') + await p.h.call(p.agent, 'read', { file_path: 'm.py' }) + await p.h.call(p.agent, 'edit', { input: '*** Begin Patch\n*** Update File: m.py\n def f():\n- return 1\n+ return 2\n*** Add File: n.py\n+x = 1\n*** End Patch' }) + assert.equal(p.h.read('m.py'), 'def f():\n return 2\n') + assert.equal(p.h.read('n.py'), 'x = 1\n') + p.h.cleanup() +}) + +test('model switch re-registers the edit tool in the agent scope', async () => { + const { h, agent } = await setup() + assert.ok(h.ctx.tools.get('edit', agent).parameters.properties.input) + await h.ctx.waterfall('agent/request', {}, async () => ({ provider: 'omni', model: 'deepseek-chat' })) + assert.ok(h.ctx.tools.get('edit', agent).parameters.properties.old_string) + h.cleanup() +})