@jovan158/vantage
Version:
See and control what your coding agent does
137 lines (136 loc) • 6.62 kB
JavaScript
// `vantage search <text>`: which session did what.
//
// vantage search fetch.ts which sessions read or changed it
// vantage search "npm publish" when it ran, or was blocked
// vantage search --commands push only shell commands
//
// Searches every session this machine recorded (plus those in the current
// directory): your messages, Claude's replies, the file or command of every
// tool call, files changed at the end, blocked or questioned calls, and
// secret warnings. Case-insensitive substring match — what you would type.
import { shortenPaths } from "./replay.js";
import { plain } from "./sanitize.js";
import { agentShort } from "./agents/index.js";
const FILE_TOOLS = {
Read: "read",
Write: "wrote",
Edit: "edited",
MultiEdit: "edited",
NotebookEdit: "edited",
NotebookRead: "read",
};
function callHit(ts, tool, target) {
if (tool in FILE_TOOLS && target)
return { ts, kind: "file", label: FILE_TOOLS[tool], text: target };
if (/^(Bash|BashOutput|PowerShell)$/.test(tool) && target)
return { ts, kind: "command", label: "ran", text: target };
return { ts, kind: "call", label: tool, text: target ?? "" };
}
function inScope(h, scope) {
if (scope === "files")
return h.kind === "file" || h.kind === "changed" || h.about === "file";
if (scope === "commands")
return h.kind === "command" || h.about === "command";
return true;
}
export function searchSession(ref, events, query, scope = "all") {
const start = events.find((e) => e.type === "session_start");
if (!start || start.type !== "session_start")
return null;
const project = start.project ?? ref.cwd;
const agent = agentShort(start.agent).toLowerCase();
const needle = query.toLowerCase();
const hits = [];
const add = (h) => {
if (!inScope(h, scope))
return;
// The text only: labels like "claude" or "ran" would match every hit.
if (!h.text.toLowerCase().includes(needle))
return;
hits.push({ ...h, text: shortenPaths(h.text, project) });
};
// The agent's tool loop resends your last message with every model call;
// it is one message, so it is one hit.
let lastPrompt;
for (const e of events) {
const ts = Date.parse(e.ts);
if (e.type === "usage" && !e.background) {
if (e.prompt && e.prompt !== lastPrompt)
add({ ts, kind: "message", label: "you", text: e.prompt });
lastPrompt = e.prompt ?? lastPrompt;
if (e.text)
add({ ts, kind: "reply", label: agent, text: e.text });
const calls = e.calls ?? (e.tools ?? []).map((tool) => ({ tool }));
for (const k of calls)
add(callHit(ts, k.tool, k.target));
}
else if (e.type === "decision") {
const call = callHit(ts, e.tool, e.target);
const about = call.kind === "file" || call.kind === "command" ? call.kind : undefined;
add({ ts, kind: e.decision === "deny" ? "blocked" : "asked", label: e.decision === "deny" ? "blocked" : "asked", text: `${e.tool} ${e.target ?? ""}`.trim(), ...(about ? { about } : {}) });
}
else if (e.type === "secret") {
add({ ts, kind: "secret", label: "secret sent", text: `${e.kind} from ${e.source}` });
}
else if (e.type === "session_end" && e.changes) {
for (const file of e.changes.files)
add({ ts, kind: "changed", label: "changed", text: file });
}
}
if (hits.length === 0)
return null;
return { ref, project, startedMs: Date.parse(start.ts), hits };
}
// ---------------------------------------------------------------------------
const C = { dim: "\x1b[2m", reset: "\x1b[0m", bold: "\x1b[1m", yellow: "\x1b[33m", red: "\x1b[31m" };
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function when(ms) {
const d = new Date(ms);
const p = (n) => String(n).padStart(2, "0");
return `${DAYS[d.getDay()]} ${p(d.getDate())}.${p(d.getMonth() + 1)}. ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function clockSecs(ms) {
const d = new Date(ms);
return [d.getHours(), d.getMinutes(), d.getSeconds()].map((n) => String(n).padStart(2, "0")).join(":");
}
// Shows the match in context: a window around it, highlighted.
function excerpt(text, query, c, max) {
const one = text.replace(/\s+/g, " ").trim();
const i = one.toLowerCase().indexOf(query.toLowerCase());
if (i < 0)
return one.length > max ? one.slice(0, max - 1) + "…" : one;
const room = Math.max(0, max - query.length);
let from = Math.max(0, i - Math.floor(room / 2));
const to = Math.min(one.length, from + max);
from = Math.max(0, to - max);
const pre = (from > 0 ? "…" : "") + one.slice(from, i);
const post = one.slice(i + query.length, to) + (to < one.length ? "…" : "");
return `${pre}${c.bold}${c.yellow}${one.slice(i, i + query.length)}${c.reset}${post}`;
}
export function renderSearch(results, opts) {
const c = opts.color === false ? new Proxy({}, { get: () => "" }) : C;
const width = Math.max(60, Math.min(opts.width ?? 100, 160));
const sorted = [...results].sort((a, z) => z.startedMs - a.startedMs);
const total = sorted.reduce((n, r) => n + r.hits.length, 0);
const lines = [];
if (sorted.length === 0) {
return `${c.dim}No session mentions "${opts.query}".${c.reset}`;
}
lines.push(`${c.bold}${total} match(es) in ${sorted.length} session(s)${c.reset} ${c.dim}for "${opts.query}", newest first${c.reset}`);
const limit = opts.limit ?? 10;
for (const r of sorted.slice(0, limit)) {
const name = plain(r.project.split(/[\\/]/).filter(Boolean).at(-1) ?? r.project);
lines.push("");
lines.push(`${c.bold}${when(r.startedMs)}${c.reset} ${name} ${c.dim}vantage replay ${r.ref.sessionId}${c.reset}`);
const shown = r.hits.slice(0, 8);
for (const h of shown) {
const color = h.kind === "blocked" || h.kind === "secret" ? c.red : "";
lines.push(` ${c.dim}${clockSecs(h.ts)}${c.reset} ${color}${h.label.padEnd(11)}${color ? c.reset : ""} ${excerpt(h.text, opts.query, c, width - 26)}`);
}
if (r.hits.length > shown.length)
lines.push(` ${c.dim}… ${r.hits.length - shown.length} more in this session${c.reset}`);
}
if (sorted.length > limit)
lines.push("", `${c.dim}… and ${sorted.length - limit} older session(s)${c.reset}`);
return lines.join("\n");
}