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,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))
|
||||
Reference in new issue
Block a user