UNPKG

@jovan158/vantage

Version:

See and control what your coding agent does

125 lines (124 loc) • 4.56 kB
// Append-only session event log (JSONL). The shared data store that feeds the // meter now and, later, replay and the approval gate (CONCEPT.md §2, §4). import fs from "node:fs"; import path from "node:path"; import { plainDeep } from "./sanitize.js"; export class EventLog { filePath; constructor(filePath) { this.filePath = filePath; fs.mkdirSync(path.dirname(filePath), { recursive: true }); } append(event) { fs.appendFileSync(this.filePath, JSON.stringify(event) + "\n"); } // A damaged line — a torn write when vantage was killed mid-append, or two // processes appending at once — is skipped, so the rest of the session // stays readable. readAll() { if (!fs.existsSync(this.filePath)) return []; return parseEventLines(fs.readFileSync(this.filePath, "utf8")); } } // Follows a log that is still being written: each read() parses only what was // appended since the last one, so watching a long session stays cheap. A // line still being written is held back until it is complete; a log that // shrank (replaced or truncated) is read again from the start. export class EventTail { filePath; offset = 0; partial = Buffer.alloc(0); events = []; constructor(filePath) { this.filePath = filePath; } read() { let size; try { size = fs.statSync(this.filePath).size; } catch { return this.events; } if (size < this.offset) { this.offset = 0; this.partial = Buffer.alloc(0); this.events = []; } if (size === this.offset) return this.events; let fd = null; try { fd = fs.openSync(this.filePath, "r"); const chunk = Buffer.alloc(size - this.offset); const n = fs.readSync(fd, chunk, 0, chunk.length, this.offset); this.offset += n; const data = Buffer.concat([this.partial, chunk.subarray(0, n)]); // Split on the byte, not the string: a read can end inside a multi-byte // character, but never inside a newline. const end = data.lastIndexOf(0x0a); this.partial = end === -1 ? data : data.subarray(end + 1); if (end !== -1) this.events = this.events.concat(parseEventLines(data.subarray(0, end).toString("utf8"))); } catch { /* unreadable right now; the next read tries again */ } finally { if (fd !== null) fs.closeSync(fd); } return this.events; } } export function parseEventLines(text) { const out = []; for (const line of text.split("\n")) { if (!line.trim()) continue; try { const e = JSON.parse(line); // Logs hold text from outside (file names, prompts, tool output); it is // shown in the terminal, so control sequences go here (src/sanitize.ts). if (e && typeof e === "object" && typeof e.type === "string") out.push(plainDeep(e)); } catch { /* skip the damaged line */ } } return out; } // Sessions live under .vantage/sessions/<id>/ in the target repo. export function sessionDir(cwd, sessionId) { return path.join(cwd, ".vantage", "sessions", sessionId); } const VANTAGE_GITIGNORE = `# Written by Vantage. Session logs hold excerpts of prompts and replies, and # worktrees are separate checkouts: neither belongs in the repository. # Rules (policy.json) and project memory (memory/) stay versioned. sessions/ worktrees/ `; // Keeps session logs out of git without touching the project's own // .gitignore. An existing file is left alone, edited or not. Returns whether // it was written. export function ensureVantageGitignore(cwd) { const file = path.join(cwd, ".vantage", ".gitignore"); try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, VANTAGE_GITIGNORE, { flag: "wx" }); return true; } catch { return false; // already there, or not writable — the run goes on either way } } export function sessionEventsPath(cwd, sessionId) { return path.join(sessionDir(cwd, sessionId), "events.jsonl"); } export function newSessionId(now = new Date()) { const stamp = now.toISOString().replace(/[:.]/g, "-"); const rand = Math.random().toString(36).slice(2, 6); return `${stamp}_${rand}`; }