Files
omp-edit/lib/store.js
T

173 lines
6.3 KiB
JavaScript

// 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
}