返回 CodeWhale
trajectory.mjs
根目录 / crates / tui / plugins / computer-use / src / trajectory.mjs
1 // Action trajectories: a local, opt-in JSONL of the tool calls this session
2 // made — tool name, arguments, outcome — for review and replay. Files live in
3 // the recordings directory; nothing is uploaded anywhere, and recording stays
4 // off until a session explicitly starts it. Replay re-enters the normal tool
5 // pipeline, so every gate (permissions, grants, the kill switch) still applies.
6 import fs from "node:fs";
7 import path from "node:path";
8 import crypto from "node:crypto";
9 import { stateDir } from "./registry.mjs";
10
11 export const trajectoriesDir = () => path.join(process.env.CODEWHALE_CU_RECORDINGS_DIR || path.join(stateDir(), "recordings"), "trajectories");
12
13 /** Tools about the recorder itself are never recorded and never replayed. */
14 export const isTrajectoryTool = (name) => typeof name === "string" && (name === "trajectory" || name.startsWith("trajectory_"));
15
16 export function createRecorder() {
17 let file = null;
18 const turns = () => (file && fs.existsSync(file)) ? fs.readFileSync(file, "utf8").split("\n").filter((line) => line.includes('"call"')).length : 0;
19 return {
20 get active() { return file; },
21 start() {
22 fs.mkdirSync(trajectoriesDir(), { recursive: true });
23 const stamp = new Date().toISOString().replace(/[:.]/g, "-");
24 file = path.join(trajectoriesDir(), `traj-${stamp}-${crypto.randomBytes(3).toString("hex")}.jsonl`);
25 fs.writeFileSync(file, JSON.stringify({ type: "start", at: new Date().toISOString(), pid: process.pid }) + "\n");
26 return { recording: true, file };
27 },
28 stop() {
29 if (!file) return { recording: false, note: "no trajectory was recording" };
30 const stopped = file;
31 try { fs.appendFileSync(stopped, JSON.stringify({ type: "stop", at: new Date().toISOString() }) + "\n"); } catch {}
32 file = null;
33 return { recording: false, file: stopped, turns: countCalls(stopped) };
34 },
35 status() {
36 return { recording: !!file, file, turns: file ? countCalls(file) : 0, dir: trajectoriesDir(), note: "Local JSONL on this machine; arguments are stored verbatim so replay is faithful. Start it only when the person knows it runs." };
37 },
38 append(entry) {
39 if (!file) return;
40 try { fs.appendFileSync(file, JSON.stringify({ type: "call", at: new Date().toISOString(), ...entry }) + "\n"); } catch { /* a full disk must not break tool calls */ }
41 },
42 };
43 }
44
45 const countCalls = (file) => {
46 try { return fs.readFileSync(file, "utf8").split("\n").filter((line) => line.trim().endsWith("}") && line.includes('"type":"call"')).length; } catch { return 0; }
47 };
48
49 export function readTrajectory(file) {
50 const entries = [];
51 for (const line of fs.readFileSync(file, "utf8").split("\n")) {
52 if (!line.trim()) continue;
53 try { entries.push(JSON.parse(line)); } catch { /* skip a torn last line */ }
54 }
55 return entries;
56 }
57
58 export function listTrajectories(limit = 5) {
59 try {
60 return fs.readdirSync(trajectoriesDir())
61 .filter((name) => name.startsWith("traj-") && name.endsWith(".jsonl"))
62 .sort().reverse().slice(0, limit)
63 .map((name) => {
64 const full = path.join(trajectoriesDir(), name);
65 const stat = fs.statSync(full);
66 return { id: name, bytes: stat.size, modified: stat.mtime.toISOString(), turns: countCalls(full) };
67 });
68 } catch { return []; }
69 }
70
71 /**
72 * Resolve an id ("latest" or a traj-*.jsonl basename) to a file inside the
73 * trajectories dir. Anything that escapes the directory is refused, not read.
74 */
75 export function resolveTrajectory(id) {
76 const dir = trajectoriesDir();
77 const bad = (message) => Object.assign(new Error(message), { code: "bad_args" });
78 const missing = (message) => Object.assign(new Error(message), { code: "trajectory_not_found" });
79 let name = typeof id === "string" && id.trim() && id !== "latest" ? id.trim() : null;
80 if (!name) {
81 const recent = listTrajectories(1);
82 if (!recent.length) throw missing("no trajectories on this machine yet — start one with trajectory {action:\"start\"}");
83 name = recent[0].id;
84 }
85 if (name.includes("/") || name.includes("\\") || name.startsWith(".")) throw bad("trajectory id must be a traj-*.jsonl name from trajectory status");
86 const file = path.resolve(dir, name);
87 if (path.dirname(file) !== path.resolve(dir)) throw bad("trajectory id must stay inside the trajectories directory");
88 if (!fs.existsSync(file)) throw missing(`no trajectory named "${name}" (see trajectory {action:"status"})`);
89 return file;
90 }
91
91 lines Plain Text