UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

143 lines (142 loc) • 6.11 kB
import { open } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { BridgeError } from "./protocol.js"; const BLOCK = 65_536; const CHECKPOINT_BYTES = 524_288; const MAX_ROW_BYTES = 67_108_864; /** A sparse byte/line index, shared by live readers, history pages, and images. * Indexing counts newlines without decoding JSON or retaining transcript text. * At the 4 GiB file limit, checkpoints occupy only a few hundred KiB. */ class TranscriptIndex { file; revision = randomUUID(); lines = 0; identity = ""; modified = 0; scanned = 0; complete = 0; checkpoints = [{ line: 0, offset: 0 }]; tail = Promise.resolve(); constructor(file) { this.file = file; } use(read, signal) { const task = this.tail.then(async () => { signal?.throwIfAborted(); const handle = await open(this.file, "r"); try { await this.refresh(handle, signal); return await read(handle, this); } finally { await handle.close(); } }); this.tail = task.catch(() => { }); return task; } async refresh(handle, signal) { const meta = await handle.stat(); if (!meta.isFile() || meta.size > 4_294_967_296) throw new BridgeError(413, "This conversation exceeds the transcript limit."); const identity = `${meta.dev}:${meta.ino}`; if (identity !== this.identity || meta.size < this.scanned || (meta.size === this.scanned && meta.mtimeMs !== this.modified)) { this.identity = identity; this.revision = randomUUID(); this.scanned = 0; this.complete = 0; this.lines = 0; this.checkpoints = [{ line: 0, offset: 0 }]; } this.modified = meta.mtimeMs; const buffer = Buffer.alloc(BLOCK); while (this.scanned < meta.size) { signal?.throwIfAborted(); const { bytesRead } = await handle.read(buffer, 0, Math.min(BLOCK, meta.size - this.scanned), this.scanned); if (!bytesRead) throw new BridgeError(409, "The transcript changed while loading. Refresh the conversation."); for (let newline = buffer.indexOf(10); newline >= 0 && newline < bytesRead; newline = buffer.indexOf(10, newline + 1)) { this.lines++; this.complete = this.scanned + newline + 1; if (this.complete - this.checkpoints.at(-1).offset >= CHECKPOINT_BYTES) { this.checkpoints.push({ line: this.lines, offset: this.complete }); } } this.scanned += bytesRead; } } async position(handle, line, signal) { if (line === this.lines) return this.complete; let low = 0, high = this.checkpoints.length; while (low + 1 < high) { const mid = Math.floor((low + high) / 2); if (this.checkpoints[mid].line <= line) low = mid; else high = mid; } let { offset, line: current } = this.checkpoints[low]; const buffer = Buffer.alloc(BLOCK); while (current < line) { signal?.throwIfAborted(); const { bytesRead } = await handle.read(buffer, 0, Math.min(BLOCK, this.complete - offset), offset); if (!bytesRead) throw new BridgeError(409, "The transcript changed while loading. Refresh the conversation."); for (let newline = buffer.indexOf(10); newline >= 0 && newline < bytesRead; newline = buffer.indexOf(10, newline + 1)) { if (++current === line) return offset + newline + 1; } offset += bytesRead; } return offset; } /** Read newest first and stop as soon as the caller has a page. Large rows * are skipped with bounded memory while keeping absolute line identities. */ async *rows(handle, before, after, signal) { const endLine = Math.min(before, this.lines); if (endLine <= after) return; let cursor = await this.position(handle, endLine, signal) - 1; const lower = await this.position(handle, after, signal); let line = endLine - 1, size = 0; let parts = []; const buffer = Buffer.alloc(BLOCK); while (cursor > lower) { signal?.throwIfAborted(); const start = Math.max(lower, cursor - BLOCK), length = cursor - start; const { bytesRead } = await handle.read(buffer, 0, length, start); if (bytesRead !== length) throw new BridgeError(409, "The transcript changed while loading. Refresh the conversation."); let end = bytesRead; while (end > 0) { const newline = buffer.lastIndexOf(10, end - 1), begin = newline + 1; size += end - begin; if (size <= MAX_ROW_BYTES) parts.push(Buffer.from(buffer.subarray(begin, end))); else parts = []; if (newline < 0) break; yield { line: line--, bytes: size <= MAX_ROW_BYTES ? Buffer.concat(parts.reverse(), size) : undefined }; parts = []; size = 0; end = newline; } cursor = start; } if (line >= after) yield { line, bytes: size <= MAX_ROW_BYTES ? Buffer.concat(parts.reverse(), size) : undefined }; } } // No on-disk transcript copies. Eviction affects speed only; an evicted reader // receives a fresh revision/backlog if it is subsequently reopened. const indexes = new Map(); export function withTranscriptIndex(file, read, signal) { const index = indexes.get(file) ?? new TranscriptIndex(file); indexes.delete(file); indexes.set(file, index); while (indexes.size > 32) indexes.delete(indexes.keys().next().value); return index.use(read, signal); }