@jovan158/vantage
Version:
See and control what your coding agent does
125 lines (124 loc) • 4.56 kB
JavaScript
// 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}`;
}