// Session snapshot store + stale-tag recovery. // // Every tagged read/grep/edit records the full LF text under (file, tag). An edit that names // an older tag is rebased onto the current file when the anchored lines (and a neighbour on // each side) survived unchanged — the same rule oh-my-pi uses — otherwise it is rejected // with fresh context so the model can retry. import { fileTag, splitLines } from './text.js' import { opAnchors, remapOp } from './hashline.js' const MAX_SNAPSHOTS_PER_FILE = 12 const MAX_FILES = 400 const MAX_SNAPSHOT_BYTES = 4 * 1024 * 1024 export class SnapshotStore { constructor() { /** @type {Map>} key -> newest last */ this.files = new Map() /** @type {Map} named registers persist for the session */ this.registers = new Map() } /** Record text for key and return its tag. */ record(key, text) { const tag = fileTag(text) if (text.length > MAX_SNAPSHOT_BYTES) return tag let list = this.files.get(key) if (!list) { if (this.files.size >= MAX_FILES) this.files.delete(this.files.keys().next().value) list = [] this.files.set(key, list) } else { // refresh LRU position this.files.delete(key); this.files.set(key, list) } const i = list.findIndex((s) => s.tag === tag) if (i >= 0) list.splice(i, 1) list.push({ tag, text, at: Date.now() }) while (list.length > MAX_SNAPSHOTS_PER_FILE) list.shift() return tag } byTag(key, tag) { return this.files.get(key)?.find((s) => s.tag === tag) } /** Paths (keys) that have a snapshot with this tag — helps explain a tag pasted onto the wrong file. */ keysWithTag(tag) { const out = [] for (const [k, list] of this.files) if (list.some((s) => s.tag === tag)) out.push(k) return out } forget(key) { this.files.delete(key) } } // ---------------------------------------------------------------- line diff (Myers, O(ND)) /** Map old 1-based line -> new 1-based line for lines common to both texts. */ export function lineMap(prevLines, curLines) { const map = new Map() // Trim common prefix/suffix first (cheap and usually most of the file). let a0 = 0 while (a0 < prevLines.length && a0 < curLines.length && prevLines[a0] === curLines[a0]) { map.set(a0 + 1, a0 + 1); a0++ } let ae = prevLines.length; let be = curLines.length const tail = [] while (ae > a0 && be > a0 && prevLines[ae - 1] === curLines[be - 1]) { tail.push([ae, be]); ae--; be-- } const A = prevLines.slice(a0, ae); const B = curLines.slice(a0, be) for (const [i, j] of lcsPairs(A, B)) map.set(a0 + i + 1, a0 + j + 1) for (const [i, j] of tail) map.set(i, j) return map } function lcsPairs(A, B) { const N = A.length; const M = B.length if (!N || !M) return [] if (N * M > 4_000_000) return greedyPairs(A, B) const max = N + M const V = new Int32Array(2 * max + 2) const trace = [] let found = false for (let d = 0; d <= max && !found; d++) { trace.push(V.slice()) for (let k = -d; k <= d; k += 2) { let x = k === -d || (k !== d && V[max + k - 1] < V[max + k + 1]) ? V[max + k + 1] : V[max + k - 1] + 1 let y = x - k while (x < N && y < M && A[x] === B[y]) { x++; y++ } V[max + k] = x if (x >= N && y >= M) { found = true; break } } } // Backtrack. const pairs = [] let x = N; let y = M for (let d = trace.length - 1; d >= 0 && (x > 0 || y > 0); d--) { const v = trace[d] const k = x - y const prevK = k === -d || (k !== d && v[max + k - 1] < v[max + k + 1]) ? k + 1 : k - 1 const prevX = v[max + prevK] const prevY = prevX - prevK while (x > prevX && y > prevY) { x--; y--; pairs.push([x, y]) } if (d > 0) { x = prevX; y = prevY } } return pairs.reverse() } function greedyPairs(A, B) { // Fallback for huge diffs: match unique lines in order. const pos = new Map() B.forEach((l, j) => pos.set(l, pos.has(l) ? -1 : j)) const pairs = [] let last = -1 A.forEach((l, i) => { const j = pos.get(l); if (j !== undefined && j > last) { pairs.push([i, j]); last = j } }) return pairs } // ---------------------------------------------------------------- recovery /** * Try to rebase a section parsed against `prevText` onto `curText`. * @returns {{ops:object[], offsets:Set} | null} */ export function rebaseOps(prevText, curText, ops) { const prev = splitLines(prevText).lines const cur = splitLines(curText).lines const map = lineMap(prev, cur) const anchors = new Set(ops.flatMap(opAnchors)) const dupPrev = dupes(prev); const dupCur = dupes(cur) // Validate: each anchor maps, and for unique lines at least one neighbour moved by the same // offset; for duplicated lines every existing neighbour must agree. const sorted = [...anchors].sort((a, b) => a - b) const groups = [] for (const a of sorted) { const g = groups.at(-1) if (g && a === g.end + 1) g.end = a else groups.push({ start: a, end: a }) } for (const g of groups) { const before = g.start > 1 ? g.start - 1 : null const after = g.end < prev.length ? g.end + 1 : null for (let line = g.start; line <= g.end; line++) { const mapped = map.get(line) if (mapped === undefined) return null const off = mapped - line const okB = before != null && map.get(before) === before + off const okA = after != null && map.get(after) === after + off const dup = dupPrev.has(prev[line - 1]) || dupCur.has(cur[mapped - 1]) if (dup) { if (before == null && after == null) return null if (before != null && !okB) return null if (after != null && !okA) return null } else if (!okA && !okB && (before != null || after != null)) { return null } } } const offsets = new Set() const remapped = ops.map((op) => remapOp(op, (n) => { const m = map.get(n); offsets.add(m - n); return m })) // Ranges must stay contiguous after the remap (nothing inserted inside them). for (const op of remapped) { if ((op.kind === 'replace' || op.kind === 'cut')) { const orig = ops[remapped.indexOf(op)] if (op.end - op.start !== orig.end - orig.start) return null } } return { ops: remapped, offsets } } function dupes(lines) { const seen = new Set(); const d = new Set() for (const l of lines) { if (seen.has(l)) d.add(l); else seen.add(l) } return d }