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
+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
|
||||
}
|
||||
Reference in new issue
Block a user