Initial commit: omp-edit DSH plugin (oh-my-pi port) with docs and tests
This commit is contained in:
commit
71953ffb8f
19 files changed
+2412
No files matched your search
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
*.bak
|
||||
*.orig
|
||||
.DS_Store
|
||||
@@ -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):
|
||||
|
||||
```
|
||||
<path>[src/app.py#1A2B]</path>
|
||||
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:` / `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]` / `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.
|
||||
@@ -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: {}
|
||||
@@ -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<object, SnapshotStore>} per session */
|
||||
const stores = new WeakMap()
|
||||
const storeFor = (exec) => {
|
||||
const owner = exec?.agent?.session ?? ctx
|
||||
let s = stores.get(owner)
|
||||
if (!s) { s = new SnapshotStore(); stores.set(owner, s) }
|
||||
return s
|
||||
}
|
||||
/** Latest provider/model seen per agent (from agent/request). */
|
||||
const models = new WeakMap()
|
||||
const modeFor = (agent) => resolveMode(cfg, agent ? (models.get(agent) ?? agent.options?.model ?? '') : '')
|
||||
|
||||
const cwdOf = (exec) => exec?.agent?.session?.header?.cwd
|
||||
const resolveTarget = (p, exec) => ctx.fs.resolve(String(p).replace(/^\[(.+?)#[0-9A-Fa-f]{4}\]$/, '$1'), { ...(cwdOf(exec) ? { cwd: cwdOf(exec) } : {}), signal: exec?.signal })
|
||||
const relPath = (target, exec) => {
|
||||
const cwd = cwdOf(exec)
|
||||
const p = target.displayPath
|
||||
if (cwd && path.isAbsolute(p)) { const r = path.relative(cwd, p); if (r && !r.startsWith('..') && !path.isAbsolute(r)) return r }
|
||||
return p
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- fs helpers (respect DSH policy)
|
||||
|
||||
const sandboxPolicyFor = (exec) => {
|
||||
try { return ctx.get('sandboxPolicy')?.resolve({ ...(exec?.agent ? { session: exec.agent.session } : {}) }) } catch { return undefined }
|
||||
}
|
||||
|
||||
async function readFile(target, exec) {
|
||||
const info = await ctx.fs.stat(target, exec?.signal)
|
||||
if (!info) throw new EditError(`${target.displayPath}: not found. Use \`write\` to create new files.`)
|
||||
if (info.type !== 'file') throw new EditError(`${target.displayPath}: not a regular file.`)
|
||||
const raw = await ctx.fs.readText(target, exec?.signal)
|
||||
return { raw, text: toLF(raw), version: info.version }
|
||||
}
|
||||
|
||||
/**
|
||||
* DSH's edit gate (fs-observation-policy): the file must have been read in this session.
|
||||
* `checkVersion` additionally keeps DSH's "changed since it was read" rule (replace/sloppy/
|
||||
* apply_patch); hashline uses its snapshot tag instead, which also allows recovery.
|
||||
* Afterwards the current version is reported as observed so the guarded write below
|
||||
* targets exactly the text this call read.
|
||||
*/
|
||||
async function editGate(target, exec, cur, { checkVersion }) {
|
||||
const intent = await ctx.waterfall('fs/edit-intent', target, exec, () => undefined)
|
||||
if (checkVersion && intent?.version !== undefined && intent.version !== cur.version) {
|
||||
throw new EditError(`cannot edit "${target.displayPath}": file changed since it was read — re-read the file, then retry`)
|
||||
}
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: cur.version }, exec)
|
||||
}
|
||||
|
||||
/** Guarded write through ctx.fs (stale-safe against our own read), then report the observation. */
|
||||
async function writeFile(target, content, exec, { create = false, unconditional = false } = {}) {
|
||||
const intent = unconditional ? undefined : create ? { kind: 'createIfAbsent' } : await ctx.waterfall('fs/write-intent', target, exec, () => undefined)
|
||||
const outcome = await ctx.fs.writeText(target, content, intent, exec?.signal, sandboxPolicyFor(exec))
|
||||
ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec)
|
||||
return outcome
|
||||
}
|
||||
|
||||
async function deleteFile(target, exec) {
|
||||
const p = ctx.fs.processPath(target)
|
||||
const policy = sandboxPolicyFor(exec)
|
||||
if (policy?.mode === 'read-only') throw new EditError(`[sandbox: deleting ${target.displayPath} is denied in read-only mode]`)
|
||||
if (policy?.mode === 'workspace-write' && policy.workspaceRoot) {
|
||||
const r = path.relative(policy.workspaceRoot, p)
|
||||
if (r.startsWith('..') || path.isAbsolute(r)) throw new EditError(`[sandbox: deleting ${target.displayPath} is outside the workspace]`)
|
||||
}
|
||||
const fsp = await import('node:fs/promises')
|
||||
await fsp.unlink(p)
|
||||
ctx.emit('fs/observed', target, { kind: 'absent' }, exec)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- shared mutation planner
|
||||
//
|
||||
// Every mode produces a plan: [{target, rel, before, after, op: 'update'|'create'|'delete'|'move', dest?}]
|
||||
// validated completely before anything is written; then committed in order.
|
||||
|
||||
async function commit(plan, exec) {
|
||||
const results = []
|
||||
for (const step of plan) {
|
||||
if (step.op === 'delete') { await deleteFile(step.target, exec); results.push({ path: step.rel, operation: 'delete' }); continue }
|
||||
const eol = step.eol ?? '\n'
|
||||
const content = restoreEol(step.after, eol)
|
||||
if (step.op === 'move') {
|
||||
await writeFile(step.destTarget, content, exec, { unconditional: true })
|
||||
await deleteFile(step.target, exec)
|
||||
results.push({ path: step.rel, movedTo: step.destRel, operation: 'move' })
|
||||
continue
|
||||
}
|
||||
await writeFile(step.target, content, exec, { create: step.op === 'create' })
|
||||
results.push({ path: step.rel, operation: step.op })
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
/** After a successful write: record the new snapshot, build the tagged preview. */
|
||||
function report(store, step, regions, exec) {
|
||||
if (step.op === 'delete') return `${step.rel}: deleted.`
|
||||
const key = String((step.destTarget ?? step.target).targetKey)
|
||||
const tag = store.record(key, step.after)
|
||||
const lines = splitLines(step.after).lines
|
||||
const shown = preview(lines, regions ?? [], { context: 2 })
|
||||
const head = formatHeader(step.destRel ?? step.rel, tag)
|
||||
const moved = step.op === 'move' ? ` (moved from ${step.rel})` : ''
|
||||
return [head + moved + (shown.length ? '' : ` — ${lines.length} lines`), ...shown].join('\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- hashline mode
|
||||
|
||||
async function runHashline(args, exec) {
|
||||
if (typeof args.input !== 'string' || !args.input.trim()) throw new EditError('input must be a non-empty hashline patch string.')
|
||||
const store = storeFor(exec)
|
||||
const { sections, warnings } = parseHashline(args.input)
|
||||
const plan = []
|
||||
const anon = { value: null }
|
||||
// Named registers publish only after the whole call lands.
|
||||
const registers = new Map(store.registers)
|
||||
const regionsByStep = new Map()
|
||||
const seen = new Map()
|
||||
for (const sec of sections) {
|
||||
const target = await resolveTarget(sec.path, exec)
|
||||
const key = String(target.targetKey)
|
||||
if (seen.has(key)) throw new EditError(`${sec.path}: appears in two sections; put all of a file's ops in one section.`)
|
||||
seen.set(key, true)
|
||||
const cur = await readFile(target, exec)
|
||||
const curTag = fileTag(cur.text)
|
||||
let ops = sec.ops
|
||||
let baseText = cur.text
|
||||
if (sec.tag !== curTag) {
|
||||
const snap = store.byTag(key, sec.tag)
|
||||
if (!snap) {
|
||||
const others = store.keysWithTag(sec.tag)
|
||||
const hint = others.length ? ` That tag belongs to another file (${others.map((k) => path.basename(k)).join(', ')}).` : ''
|
||||
throw new EditError(`${sec.path}: snapshot tag #${sec.tag} is not current (file is now #${curTag}) and was never issued for this file.${hint} Re-read the file and use its [PATH#TAG] header.`)
|
||||
}
|
||||
const rb = rebaseOps(snap.text, cur.text, ops)
|
||||
if (!rb) {
|
||||
throw new EditError(`${sec.path}: file changed since #${sec.tag} (now #${curTag}) and the anchored lines moved or changed, so the patch was not applied. Re-read the file and resend with the new tag.`)
|
||||
}
|
||||
await editGate(target, exec, cur, { checkVersion: false })
|
||||
ops = rb.ops
|
||||
const off = [...rb.offsets].filter((o) => o !== 0)
|
||||
warnings.push(`${sec.path}: recovered stale tag #${sec.tag} → #${curTag}${off.length ? ` (lines shifted by ${[...new Set(off)].join(', ')})` : ''}.`)
|
||||
} else {
|
||||
await editGate(target, exec, cur, { checkVersion: false })
|
||||
}
|
||||
const { lines, eol } = splitLines(baseText)
|
||||
const res = applySection({ ...sec, ops }, { path: sec.path, lines, registers, anon, warnings })
|
||||
const rel = relPath(target, exec)
|
||||
if (res.rem) { plan.push({ op: 'delete', target, rel, before: cur.text, after: '' }); continue }
|
||||
const after = joinLines(res.lines, eol || res.lines.length > 0)
|
||||
const step = { op: 'update', target, rel, before: cur.text, after, eol: detectEol(cur.raw) }
|
||||
if (res.mv) {
|
||||
const destTarget = await resolveTarget(res.mv, exec)
|
||||
step.op = 'move'; step.destTarget = destTarget; step.destRel = relPath(destTarget, exec)
|
||||
} else if (after === cur.text) {
|
||||
throw new EditError(`${sec.path}: the patch produced no change.`)
|
||||
}
|
||||
regionsByStep.set(step, res.regions)
|
||||
plan.push(step)
|
||||
}
|
||||
const results = await commit(plan, exec)
|
||||
for (const [k, v] of registers) store.registers.set(k, v)
|
||||
const out = plan.map((s) => report(store, s, regionsByStep.get(s), exec))
|
||||
return finish(plan, results, out, warnings)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- replace mode
|
||||
|
||||
async function runReplace(args, exec) {
|
||||
const store = storeFor(exec)
|
||||
const target = await resolveTarget(args.file_path, exec)
|
||||
const cur = await readFile(target, exec)
|
||||
await editGate(target, exec, cur, { checkVersion: true })
|
||||
const rel = relPath(target, exec)
|
||||
const list = Array.isArray(args.edits) && args.edits.length
|
||||
? args.edits
|
||||
: [{ old_string: args.old_string, new_string: args.new_string, replace_all: args.replace_all }]
|
||||
let text = cur.text
|
||||
const warnings = []
|
||||
for (const [i, e] of list.entries()) {
|
||||
if (!isObj(e) || typeof e.old_string !== 'string' || typeof e.new_string !== 'string') throw new EditError(`edits[${i}] needs old_string and new_string strings.`)
|
||||
const r = applyReplace(text, toLF(e.old_string), toLF(e.new_string), { all: !!e.replace_all, fuzzy: cfg.fuzzy, path: rel })
|
||||
if (r.kind !== 'exact') warnings.push(`edit ${i + 1}: matched with ${r.kind} whitespace normalization.`)
|
||||
text = r.text
|
||||
}
|
||||
const step = { op: 'update', target, rel, before: cur.text, after: text, eol: detectEol(cur.raw) }
|
||||
const results = await commit([step], exec)
|
||||
const regions = changedRegions(cur.text, text)
|
||||
return finish([step], results, [report(store, step, regions, exec)], warnings)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- sloppy mode
|
||||
|
||||
async function runSloppy(args, exec) {
|
||||
if (typeof args.input !== 'string' || !args.input.trim()) throw new EditError('input must be a non-empty patch string.')
|
||||
const store = storeFor(exec)
|
||||
const files = parseSloppy(args.input)
|
||||
const plan = []
|
||||
for (const f of files) {
|
||||
const target = await resolveTarget(f.path, exec)
|
||||
const cur = await readFile(target, exec)
|
||||
await editGate(target, exec, cur, { checkVersion: true })
|
||||
const after = applySloppy(cur.text, f)
|
||||
plan.push({ op: 'update', target, rel: relPath(target, exec), before: cur.text, after, eol: detectEol(cur.raw) })
|
||||
}
|
||||
const results = await commit(plan, exec)
|
||||
return finish(plan, results, plan.map((s) => report(store, s, changedRegions(s.before, s.after), exec)), [])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- apply_patch mode
|
||||
|
||||
async function runApplyPatch(args, exec) {
|
||||
if (typeof args.input !== 'string' || !args.input.trim()) throw new EditError('input must be a non-empty *** Begin Patch string.')
|
||||
const store = storeFor(exec)
|
||||
const ops = parseApplyPatch(args.input)
|
||||
const plan = []
|
||||
const regions = new Map()
|
||||
for (const op of ops) {
|
||||
const target = await resolveTarget(op.path, exec)
|
||||
const rel = relPath(target, exec)
|
||||
if (op.type === 'add') {
|
||||
if (await ctx.fs.stat(target, exec?.signal)) throw new EditError(`${op.path}: already exists; use *** Update File.`)
|
||||
const after = joinLines(op.lines, true)
|
||||
const step = { op: 'create', target, rel, before: '', after }
|
||||
plan.push(step); regions.set(step, [[1, op.lines.length]])
|
||||
continue
|
||||
}
|
||||
const cur = await readFile(target, exec)
|
||||
await editGate(target, exec, cur, { checkVersion: true })
|
||||
if (op.type === 'delete') { plan.push({ op: 'delete', target, rel, before: cur.text, after: '' }); continue }
|
||||
const { lines, eol } = splitLines(cur.text)
|
||||
const res = op.hunks.length ? applyUpdate(lines, op) : { lines, regions: [] }
|
||||
const step = { op: 'update', target, rel, before: cur.text, after: joinLines(res.lines, eol || res.lines.length > 0), eol: detectEol(cur.raw) }
|
||||
if (op.move) { const d = await resolveTarget(op.move, exec); step.op = 'move'; step.destTarget = d; step.destRel = relPath(d, exec) }
|
||||
plan.push(step); regions.set(step, res.regions)
|
||||
}
|
||||
const results = await commit(plan, exec)
|
||||
return finish(plan, results, plan.map((s) => report(store, s, regions.get(s), exec)), [])
|
||||
}
|
||||
|
||||
function changedRegions(before, after) {
|
||||
const d = hunkDiffs('', before, after, 0)
|
||||
const lines = splitLines(after).lines
|
||||
const regions = []
|
||||
// Locate each hunk's new text in the new file to get line numbers.
|
||||
let from = 0
|
||||
for (const h of d) {
|
||||
const first = h.newText.split('\n')[0]
|
||||
let idx = lines.findIndex((l, i) => i >= from && l === first)
|
||||
if (idx < 0) idx = from
|
||||
const len = Math.max(1, h.newText ? h.newText.split('\n').length : 1)
|
||||
regions.push([idx + 1, idx + len])
|
||||
from = idx + len
|
||||
}
|
||||
return regions
|
||||
}
|
||||
|
||||
function finish(plan, results, blocks, warnings) {
|
||||
const text = [...blocks, ...(warnings.length ? ['Warnings:', ...warnings.map((w) => `- ${w}`)] : [])].join('\n\n')
|
||||
const diffs = plan.flatMap((s) => (s.op === 'delete'
|
||||
? [{ path: s.rel, oldText: s.before || null, newText: '' }]
|
||||
: hunkDiffs(s.destRel ?? s.rel, s.before, s.after)))
|
||||
return { text, results, diffs }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- tool definitions
|
||||
|
||||
const textOutput = {
|
||||
schema: { type: 'object', additionalProperties: true, properties: { text: { type: 'string' } } },
|
||||
render: (_args, value) => [{ type: 'text', text: value.text }],
|
||||
presentationMeta: (_args, value) => ({ diffs: value.diffs ?? [] }),
|
||||
}
|
||||
|
||||
const editPresent = {
|
||||
presentCall(args) {
|
||||
return { card: 'diff', title: `Edit${args.file_path ? ` ${args.file_path}` : ''}`, diffs: [] }
|
||||
},
|
||||
presentResult(args, result) {
|
||||
if (result.isError) return undefined
|
||||
const diffs = isObj(result.meta) && Array.isArray(result.meta.diffs) ? result.meta.diffs : []
|
||||
if (!diffs.length) return undefined
|
||||
return { card: 'diff', title: `Edit ${[...new Set(diffs.map((d) => d.path))].join(', ')}`, diffs }
|
||||
},
|
||||
}
|
||||
|
||||
const wrap = (fn) => async (args, exec) => {
|
||||
try {
|
||||
return await fn(args ?? {}, exec)
|
||||
} catch (error) {
|
||||
// EditError / FsError messages are model-facing; rethrow so the runtime marks isError.
|
||||
if (error?.code === 'FS_STALE_VERSION') throw new EditError(`${error.message}. Re-read the file, then retry.`)
|
||||
if (error?.code === 'FS_NOT_OBSERVED') throw new EditError(`${error.message} (read it with \`read\` or \`grep\` first).`)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function editTool(mode) {
|
||||
const base = { name: 'edit', output: textOutput, timeoutMs: 120_000, ...editPresent }
|
||||
if (mode === 'hashline') {
|
||||
return {
|
||||
...base,
|
||||
description: P.HASHLINE,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['input'],
|
||||
properties: { input: { type: 'string', description: 'One or more [PATH#TAG] sections of hashline ops.' } },
|
||||
},
|
||||
execute: wrap(runHashline),
|
||||
}
|
||||
}
|
||||
if (mode === 'sloppy') {
|
||||
return {
|
||||
...base,
|
||||
description: P.SLOPPY,
|
||||
parameters: { type: 'object', additionalProperties: false, required: ['input'], properties: { input: { type: 'string', description: '*** Edit File / *** Find / *** Replace|Insert Before|Insert After payload.' } } },
|
||||
execute: wrap(runSloppy),
|
||||
}
|
||||
}
|
||||
if (mode === 'apply_patch') {
|
||||
return {
|
||||
...base,
|
||||
description: P.APPLY_PATCH,
|
||||
parameters: { type: 'object', additionalProperties: false, required: ['input'], properties: { input: { type: 'string', description: 'The full *** Begin Patch … *** End Patch text.' } } },
|
||||
execute: wrap(runApplyPatch),
|
||||
}
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
description: P.REPLACE,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['file_path'],
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Path of the file to edit.' },
|
||||
old_string: { type: 'string', description: 'Text to replace (fuzzy whitespace matching).' },
|
||||
new_string: { type: 'string', description: 'Replacement text. Empty string deletes.' },
|
||||
replace_all: { type: 'boolean', description: 'Replace every match. Default false (must match once).' },
|
||||
edits: {
|
||||
type: 'array',
|
||||
description: 'Batch: several replacements in this file, applied atomically in order. Use instead of old_string/new_string.',
|
||||
items: {
|
||||
type: 'object',
|
||||
additionalProperties: false,
|
||||
required: ['old_string', 'new_string'],
|
||||
properties: { old_string: { type: 'string' }, new_string: { type: 'string' }, replace_all: { type: 'boolean' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
execute: wrap(async (args, exec) => {
|
||||
if (!(Array.isArray(args.edits) && args.edits.length) && (typeof args.old_string !== 'string' || typeof args.new_string !== 'string')) {
|
||||
throw new EditError('Pass old_string + new_string, or a non-empty edits array.')
|
||||
}
|
||||
return runReplace(args, exec)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- read / grep wrappers (tag output)
|
||||
|
||||
/** Wrap the built-in read so its output carries a [PATH#TAG] header and LINE:TEXT rows. */
|
||||
function readTool(builtin) {
|
||||
return {
|
||||
...builtin,
|
||||
description: `${builtin.description}${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(/^<path>([^\n]*)<\/path>/, () => `<path>${formatHeader(displayPath, tag)}</path>`)
|
||||
.replace(/^(\d+): /gm, '$1:')
|
||||
}
|
||||
|
||||
/** Wrap the built-in grep: group matches under [PATH#TAG] headers. */
|
||||
function grepTool(builtin) {
|
||||
return {
|
||||
...builtin,
|
||||
description: `${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 }
|
||||
@@ -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 }
|
||||
}
|
||||
+151
@@ -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
|
||||
}
|
||||
+38
@@ -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') }
|
||||
})
|
||||
}
|
||||
+272
@@ -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: / 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 <N [@name] paste register at a gap (no body)
|
||||
// PUT N.=M @name paste named register over a range/block
|
||||
// REM delete the file
|
||||
// MV DEST move after edits
|
||||
//
|
||||
// All line numbers refer to the tagged snapshot, never shifted by earlier hunks.
|
||||
import { resolveBlock } from './block.js'
|
||||
import { EditError, HEADER_RE, jsonQuote } from './text.js'
|
||||
|
||||
const LINE = '([1-9]\\d*)'
|
||||
const REG = '(?:\\s+@([A-Za-z0-9_-]+))?'
|
||||
const RE = {
|
||||
putRange: new RegExp(`^PUT\\s+${LINE}\\s*\\.=\\s*${LINE}\\s*(:)?${REG}\\s*$`),
|
||||
putBlock: new RegExp(`^PUT\\s+${LINE}\\*\\s*(:)?${REG}\\s*$`),
|
||||
putGap: new RegExp(`^PUT\\s+([<>])\\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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
<ops>
|
||||
\`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:\` / \`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 <M:\` at its closer.
|
||||
\`CUT N.=M\` / \`CUT N*\` delete and capture, optionally into \`@name\`.
|
||||
\`PUT <N @name\` / \`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).
|
||||
</ops>
|
||||
|
||||
<rules>
|
||||
- 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.
|
||||
</rules>
|
||||
|
||||
<example>
|
||||
[greet.py#A1B2]
|
||||
PUT 1*:
|
||||
+@cache
|
||||
+def greet(name):
|
||||
+ print(name)
|
||||
[PLAN.md#3C4D]
|
||||
PUT >2:
|
||||
+- task
|
||||
</example>
|
||||
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.
|
||||
|
||||
<ops>
|
||||
- \`*** 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.
|
||||
</ops>
|
||||
|
||||
<example>
|
||||
*** Edit File: src/util.ts
|
||||
*** Find
|
||||
const helper = () => 1;
|
||||
*** Replace
|
||||
const helper = () => 2;
|
||||
*** Find
|
||||
run(target);
|
||||
*** Insert Before
|
||||
log(target);
|
||||
</example>`
|
||||
|
||||
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: <path>\` create a file; every following line starts with \`+\`.
|
||||
\`*** Delete File: <path>\` remove a file; nothing follows.
|
||||
\`*** Update File: <path>\` patch in place; optional \`*** Move to: <new path>\`; then one or more hunks. A hunk starts with \`@@\` (optionally \`@@ <class/function line>\` 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.'
|
||||
+138
@@ -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 }
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
// `sloppy` mode: anchored find / replace / insert-before / insert-after (oh-my-pi compatible).
|
||||
//
|
||||
// *** Edit File: path [all]
|
||||
// *** Find
|
||||
// <exact text> (`…` = capture of omitted text)
|
||||
// *** Replace | *** Insert Before | *** Insert After
|
||||
// <text> (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)
|
||||
}
|
||||
+172
@@ -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<string, Array<{tag:string,text:string,at:number}>>} key -> newest last */
|
||||
this.files = new Map()
|
||||
/** @type {Map<string,string[]>} 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<number>} | 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
|
||||
}
|
||||
+78
@@ -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))
|
||||
@@ -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." } }
|
||||
@@ -0,0 +1 @@
|
||||
{ "meta": { "title": "OMP 编辑", "description": "oh-my-pi 风格编辑:用带内容哈希标签的行锚点 hashline 补丁替换 edit 工具;read/grep 输出带标签。另有 replace(模糊匹配、批量)、sloppy 和 apply_patch 模式。" } }
|
||||
@@ -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" } }
|
||||
}
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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: `<path>${v.path}</path>\n<type>file</type>\n<content>\n${v.lines.map((l) => `${l.number}: ${l.text}`).join('\n')}\n\n(End of file - total ${v.totalLines} lines)\n</content>` }],
|
||||
},
|
||||
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 }
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
Reference in new issue
Block a user