Initial commit: omp-edit DSH plugin (oh-my-pi port) with docs and tests

This commit is contained in:
Agent committed 2026-10-10 19:15:23 +00:00
commit 71953ffb8f
19 files changed
+2412

No files matched your search

+106
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+82
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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))