virtualmatter 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,8 +2,9 @@
2
2
  /** virtualmatter - the CLI coding agents (and humans) install to build with Virtual Matter. */
3
3
  import fs from "node:fs";
4
4
  import path from "node:path";
5
+ import { registerLogCommands } from "./log-commands.js";
5
6
  import { Command } from "commander";
6
- import { createProject, defaultRegion, editUrl, fetchNativeClientCatalog, listProjects, playUrl, resolveSession, sessionBaseUrl, whoami, } from "./api.js";
7
+ import { createProject, createWebsiteBuild, getWebsiteBuild, getEmbed, defaultRegion, editUrl, fetchNativeClientCatalog, listProjects, playUrl, resolveSession, sessionBaseUrl, whoami, } from "./api.js";
7
8
  import { NotLoggedInError, clearCredentials, loadCredentials, pollForToken, saveCredentials, startDeviceFlow, } from "./auth.js";
8
9
  import { openInBrowser } from "./browser.js";
9
10
  import { detectPlatform, ensureClientInstalled, launchClient, seedNativeAuth } from "./client.js";
@@ -94,7 +95,10 @@ function reportPull(result, dest) {
94
95
  console.log(`Wrote ${files.written.join(", ")} so Claude Code, Codex, and Cursor find their way around.`);
95
96
  }
96
97
  const rel = path.relative(process.cwd(), dest) || ".";
97
- console.log(`Next: cd ${rel} && npx virtualmatter sync`);
98
+ // A relative path is friendlier only while it stays short; a folder far
99
+ // from the cwd reads better as the absolute path it is.
100
+ const shown = rel.startsWith("..") ? dest : rel;
101
+ console.log(`Next: cd ${JSON.stringify(shown).slice(1, -1).includes(" ") ? `"${shown}"` : shown} && npx virtualmatter sync`);
98
102
  console.log(` (or open the folder in your agent - the MCP server is registered in .mcp.json)`);
99
103
  }
100
104
  program
@@ -117,6 +121,21 @@ program
117
121
  const me = await whoami();
118
122
  console.log(JSON.stringify(me, null, 2));
119
123
  }));
124
+ program
125
+ .command("embed <target>")
126
+ .description("Get iframe markup and required hosting setup for a shared world; no sign-in")
127
+ .action((target) => run(async () => console.log(JSON.stringify(await getEmbed(target), null, 2))));
128
+ program
129
+ .command("build <name>")
130
+ .description("Create a private world and send a build prompt to VM's agent (uses VM credits)")
131
+ .requiredOption("--prompt <text>", "What VM should build")
132
+ .requiredOption("--request-id <id>", "Stable identifier for this request; reuse on retries")
133
+ .option("--region <region>", "NA, EU, or AS", defaultRegion())
134
+ .action((name, opts) => run(async () => console.log(JSON.stringify(await createWebsiteBuild({ name, prompt: opts.prompt, request_id: opts.requestId, region: opts.region }), null, 2))));
135
+ program
136
+ .command("build-status <build-id>")
137
+ .description("Read a previously requested VM build's progress")
138
+ .action((id) => run(async () => console.log(JSON.stringify(await getWebsiteBuild(id), null, 2))));
120
139
  program
121
140
  .command("list")
122
141
  .alias("ls")
@@ -234,14 +253,31 @@ program
234
253
  const { client } = await clientForDir(path.resolve(dir));
235
254
  console.log(JSON.stringify(await client.engineErrors(), null, 2));
236
255
  }));
256
+ /** Parse an "x,y,z" flag into a numeric triple. */
257
+ function triple(value, flag) {
258
+ const parts = value.split(",").map((p) => Number(p.trim()));
259
+ if (parts.length !== 3 || parts.some((n) => !Number.isFinite(n))) {
260
+ fail(`${flag} takes three numbers, e.g. ${flag} 0,20,20`);
261
+ }
262
+ return [parts[0], parts[1], parts[2]];
263
+ }
237
264
  program
238
265
  .command("screenshot")
239
- .description("Capture a PNG of the engine's current view")
266
+ .description("Capture a PNG of the world - a default overview, or aimed with --at/--rot or --target")
240
267
  .argument("[dir]", "a synced folder", ".")
241
268
  .option("-o, --out <file>", "output file", "screenshot.png")
269
+ .option("--at <x,y,z>", "camera position (default: 0,20,20)")
270
+ .option("--rot <yaw,pitch,roll>", "camera rotation in degrees, yaw first (default: 0,-45,0)")
271
+ .option("--target <id-or-name>", "frame this object instead of using a camera pose")
272
+ .option("--distance <meters>", "framing distance for --target (default: from the object's bounds)")
242
273
  .action((dir, opts) => run(async () => {
243
274
  const { client } = await clientForDir(path.resolve(dir));
244
- const png = await client.screenshot();
275
+ const png = await client.screenshot({
276
+ at: opts.at ? triple(opts.at, "--at") : undefined,
277
+ rot: opts.rot ? triple(opts.rot, "--rot") : undefined,
278
+ target: opts.target,
279
+ distance: opts.distance === undefined ? undefined : Number(opts.distance),
280
+ });
245
281
  fs.writeFileSync(opts.out, png);
246
282
  console.log(`Wrote ${opts.out} (${png.length} bytes).`);
247
283
  }));
@@ -253,6 +289,9 @@ async function openNative(framingId, url, force = false) {
253
289
  console.log("Signed the native client in with your account.");
254
290
  launchClient(installed, url);
255
291
  console.log(`Opening ${url} in the Virtual Matter client (${installed.dir}).`);
292
+ if (installed.platform === "linux") {
293
+ console.log("Linux: the client starts through run.sh with its bundled libraries. If no window appears, read the newest file in ~/.local/share/Atomontage/Atomontage Studio/UserData/Logs/ - \"Failed to initialize graphics adapter\" means this build has no renderer for your setup yet; the world still runs in the browser at the URL above, and every CLI command works without the client.");
294
+ }
256
295
  }
257
296
  program
258
297
  .command("open")
@@ -293,6 +332,7 @@ program
293
332
  .action((dir, opts) => run(async () => {
294
333
  await runMcpServer(path.resolve(dir), opts.framing);
295
334
  }));
335
+ registerLogCommands(program);
296
336
  program.parseAsync(process.argv).catch((err) => {
297
337
  fail(err instanceof Error ? err.message : String(err));
298
338
  });
@@ -0,0 +1,201 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawn } from "node:child_process";
4
+ import { fileURLToPath } from "node:url";
5
+ import { captureSource, collectLogs, enqueue, flushLogs, logConfig, logDir, projectRoot, readJson, registerSource, startLogSync, writeJson, } from "./agent-logs.js";
6
+ import { convertHook, convertTranscript, hash } from "./log-events.js";
7
+ import { setupLogs } from "./log-setup.js";
8
+ function root(dir = process.cwd()) {
9
+ const found = projectRoot(dir);
10
+ if (!found)
11
+ throw new Error("No Virtual Matter project here. Run virtualmatter pull first, or pass --dir.");
12
+ return found;
13
+ }
14
+ async function stdin() {
15
+ let text = "";
16
+ for await (const chunk of process.stdin) {
17
+ text += chunk;
18
+ if (Buffer.byteLength(text) > 8 * 1024 * 1024)
19
+ throw new Error("Hook input exceeds 8 MiB");
20
+ }
21
+ return text;
22
+ }
23
+ function kick(dir) {
24
+ const child = spawn(process.execPath, [
25
+ fileURLToPath(new URL("./index.js", import.meta.url)),
26
+ "agent-logs",
27
+ "flush",
28
+ "--dir",
29
+ dir,
30
+ "--quiet",
31
+ ], { detached: true, stdio: "ignore", windowsHide: true });
32
+ child.on("error", () => { });
33
+ child.unref();
34
+ }
35
+ export function registerLogCommands(program) {
36
+ const logs = program
37
+ .command("agent-logs")
38
+ .alias("agentlog")
39
+ .description("Upload and automatically sync conversations from local coding harnesses");
40
+ logs
41
+ .command("setup")
42
+ .option("--dir <dir>", "project folder")
43
+ .option("--harness <name>", "all, codex, claude-code, cursor, or hermes", "all")
44
+ .action((o) => {
45
+ for (const message of setupLogs(root(o.dir), o.harness))
46
+ console.log(message);
47
+ console.log("Capturing future entries. Keep virtualmatter sync or agent-logs watch running for offline retries and delayed transcript writes.");
48
+ });
49
+ logs
50
+ .command("disable")
51
+ .option("--dir <dir>")
52
+ .action((o) => {
53
+ const dir = root(o.dir), config = logConfig(dir);
54
+ if (config)
55
+ writeJson(path.join(logDir(dir), "config.json"), {
56
+ ...config,
57
+ enabled: false,
58
+ });
59
+ console.log("Automatic capture disabled. Existing logs and queued entries retained.");
60
+ });
61
+ logs
62
+ .command("status")
63
+ .option("--dir <dir>")
64
+ .action((o) => {
65
+ const dir = root(o.dir), pending = path.join(logDir(dir), "pending");
66
+ console.log(JSON.stringify({
67
+ project: dir,
68
+ config: logConfig(dir),
69
+ pending_batches: fs.existsSync(pending)
70
+ ? fs.readdirSync(pending).filter((f) => f.endsWith(".json"))
71
+ .length
72
+ : 0,
73
+ last_error: readJson(path.join(logDir(dir), "last-error.json")),
74
+ capture_errors: readJson(path.join(logDir(dir), "capture-errors.json")),
75
+ }, null, 2));
76
+ });
77
+ logs
78
+ .command("upload")
79
+ .argument("<file>", "JSONL file or - for stdin")
80
+ .requiredOption("--harness <name>")
81
+ .requiredOption("--session <id>")
82
+ .option("--format <format>", "events, codex, or claude-code", "events")
83
+ .option("--dir <dir>")
84
+ .option("--queue-only", "persist locally without uploading")
85
+ .action(async (file, o) => {
86
+ const dir = root(o.dir);
87
+ const events = convertTranscript(file === "-" ? await stdin() : fs.readFileSync(file, "utf8"), o.format);
88
+ const n = enqueue(dir, o.harness, o.session, events);
89
+ console.log(`Queued ${n} events.`);
90
+ if (!o.queueOnly)
91
+ console.log(`Acknowledged ${await flushLogs(dir)} events.`);
92
+ });
93
+ logs
94
+ .command("follow")
95
+ .argument("<file>", "Keep collecting new records from this transcript")
96
+ .requiredOption("--harness <name>")
97
+ .requiredOption("--session <id>")
98
+ .option("--format <format>", "events, codex, or claude-code", "events")
99
+ .option("--dir <dir>")
100
+ .action((file, o) => {
101
+ const dir = root(o.dir);
102
+ if (!logConfig(dir)?.enabled)
103
+ throw new Error("Run agent-logs setup first.");
104
+ const source = {
105
+ file: fs.realpathSync(file),
106
+ format: o.format,
107
+ harness: o.harness,
108
+ session: o.session,
109
+ };
110
+ // An explicit binding supports a task that began in another folder,
111
+ // without guessing that every transcript on this machine belongs here.
112
+ captureSource(dir, source, logConfig(dir).since);
113
+ registerSource(dir, source);
114
+ console.log("Transcript registered. New entries will be collected by sync/watch and flush.");
115
+ });
116
+ logs
117
+ .command("hook")
118
+ .requiredOption("--harness <name>")
119
+ .option("--dir <dir>")
120
+ .description("Receive harness JSON on stdin; silent, local-first, and safe outside enabled projects")
121
+ .action(async (o) => {
122
+ let dir = null;
123
+ try {
124
+ const p = JSON.parse(await stdin());
125
+ const cwd = o.dir ?? p.cwd ?? p.workspace_roots?.[0] ?? process.cwd();
126
+ dir = projectRoot(cwd);
127
+ if (!dir || !logConfig(dir)?.enabled)
128
+ return;
129
+ const session = p.session_id ?? p.conversation_id ?? p["thread-id"];
130
+ if (!session)
131
+ throw new Error("Hook did not provide a session ID");
132
+ if (["codex", "claude-code"].includes(o.harness)) {
133
+ if (p.transcript_path) {
134
+ const source = {
135
+ file: path.resolve(cwd, p.transcript_path),
136
+ format: o.harness,
137
+ harness: o.harness,
138
+ session,
139
+ };
140
+ registerSource(dir, source);
141
+ captureSource(dir, source, logConfig(dir).since);
142
+ }
143
+ else if (o.harness === "codex")
144
+ await collectLogs(dir);
145
+ else
146
+ throw new Error("Hook did not provide transcript_path");
147
+ }
148
+ else {
149
+ const stateFile = path.join(logDir(dir), "hook-state", hash(`${o.harness}:${session}`) + ".json");
150
+ const state = readJson(stateFile) ?? {};
151
+ enqueue(dir, o.harness, session, convertHook(o.harness, p, new Date().toISOString(), state));
152
+ writeJson(stateFile, state);
153
+ }
154
+ kick(dir);
155
+ }
156
+ catch (err) {
157
+ if (dir)
158
+ writeJson(path.join(logDir(dir), "last-error.json"), {
159
+ ts: new Date().toISOString(),
160
+ message: String(err),
161
+ });
162
+ // Logging must not change the harness response or interrupt the turn.
163
+ }
164
+ });
165
+ logs
166
+ .command("flush")
167
+ .option("--dir <dir>")
168
+ .option("--quiet")
169
+ .action(async (o) => {
170
+ const dir = root(o.dir);
171
+ try {
172
+ if (o.quiet && !logConfig(dir)?.enabled)
173
+ return;
174
+ await collectLogs(dir);
175
+ const n = await flushLogs(dir);
176
+ if (!o.quiet)
177
+ console.log(`Acknowledged ${n} events.`);
178
+ }
179
+ catch (err) {
180
+ if (!o.quiet)
181
+ throw err;
182
+ }
183
+ });
184
+ logs
185
+ .command("watch")
186
+ .option("--dir <dir>")
187
+ .action(async (o) => {
188
+ const dir = root(o.dir);
189
+ if (!logConfig(dir)?.enabled)
190
+ throw new Error("Run agent-logs setup first.");
191
+ console.log(`Watching agent logs for ${dir}. Ctrl-C to stop.`);
192
+ const stop = startLogSync(dir);
193
+ await new Promise((resolve) => {
194
+ const done = () => {
195
+ void stop().then(resolve);
196
+ };
197
+ process.once("SIGINT", done);
198
+ process.once("SIGTERM", done);
199
+ });
200
+ });
201
+ }
@@ -0,0 +1,267 @@
1
+ /** Harness adapters emit only public conversation/tool activity, never reasoning. */
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { z } from "zod";
4
+ export const eventSchema = z
5
+ .object({
6
+ event_id: z.string().regex(/^[A-Za-z0-9_:-]{1,128}$/),
7
+ ts: z.string().datetime({ offset: true }),
8
+ type: z.enum([
9
+ "user",
10
+ "assistant_text",
11
+ "tool_use",
12
+ "tool_result",
13
+ "result",
14
+ ]),
15
+ text: z.string().max(32768).optional(),
16
+ name: z.string().max(256).optional(),
17
+ input: z.union([z.record(z.unknown()), z.string()]).optional(),
18
+ tool_use_id: z.string().max(256).optional(),
19
+ result: z.string().max(32768).optional(),
20
+ })
21
+ .strict();
22
+ export const hash = (value) => createHash("sha256").update(value).digest("hex");
23
+ export const slug = (value) => {
24
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(value))
25
+ throw new Error("Invalid harness name");
26
+ return value;
27
+ };
28
+ export const sessionKey = (value) => {
29
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(value))
30
+ throw new Error("Invalid session ID");
31
+ return value;
32
+ };
33
+ export function scrub(value, depth = 0) {
34
+ if (depth > 15)
35
+ return "[nested content omitted]";
36
+ if (typeof value === "string") {
37
+ if (/^\s*[\[{]/.test(value)) {
38
+ try {
39
+ return JSON.stringify(scrub(JSON.parse(value), depth + 1)).slice(0, 32000);
40
+ }
41
+ catch {
42
+ /* ordinary text or truncated JSON */
43
+ }
44
+ }
45
+ return value
46
+ .replace(/data:[^\s;,]+;base64,[A-Za-z0-9+/=]+/g, "[media omitted]")
47
+ .replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted]")
48
+ .replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "[redacted]")
49
+ .slice(0, 32000);
50
+ }
51
+ if (Array.isArray(value))
52
+ return value.slice(0, 200).map((v) => scrub(v, depth + 1));
53
+ if (value && typeof value === "object") {
54
+ const obj = value;
55
+ if (/^(image|input_image|audio|input_audio|reasoning|thinking|redacted_thinking)$/.test(obj.type ?? ""))
56
+ return "[non-text content omitted]";
57
+ return Object.fromEntries(Object.entries(obj)
58
+ .slice(0, 200)
59
+ .map(([k, v]) => [
60
+ k,
61
+ /^(authorization|password|api[_-]?key|access_token|refresh_token|encrypted_content|reasoning|analysis|thinking)$/i.test(k)
62
+ ? "[redacted]"
63
+ : scrub(v, depth + 1),
64
+ ]));
65
+ }
66
+ return value;
67
+ }
68
+ function text(value) {
69
+ if (typeof value === "string")
70
+ return scrub(value);
71
+ if (Array.isArray(value))
72
+ return value
73
+ .map((b) => typeof b === "string"
74
+ ? b
75
+ : b && /^(text|input_text|output_text)$/.test(b.type)
76
+ ? (b.text ?? "")
77
+ : "[non-text content omitted]")
78
+ .join("\n")
79
+ .slice(0, 32000);
80
+ return value == null ? "" : JSON.stringify(scrub(value)).slice(0, 32000);
81
+ }
82
+ function input(value) {
83
+ if (typeof value === "string") {
84
+ try {
85
+ value = JSON.parse(value);
86
+ }
87
+ catch {
88
+ /* command text */
89
+ }
90
+ }
91
+ const clean = scrub(value ?? {});
92
+ return JSON.stringify(clean).length > 32000 ? text(clean) : clean;
93
+ }
94
+ function make(type, ts, seed, fields = {}) {
95
+ return eventSchema.parse({ event_id: hash(seed), ts, type, ...fields });
96
+ }
97
+ /** Complete JSONL records only when tailing. A malformed complete record fails visibly. */
98
+ export function parseJsonl(raw, tail = false) {
99
+ const lines = raw.split("\n");
100
+ if (tail)
101
+ lines.pop();
102
+ return lines.flatMap((line, i) => {
103
+ if (!line.trim())
104
+ return [];
105
+ try {
106
+ return [JSON.parse(line)];
107
+ }
108
+ catch {
109
+ throw new Error(`Invalid JSONL at line ${i + 1}`);
110
+ }
111
+ });
112
+ }
113
+ export function convertTranscript(raw, format, tail = false) {
114
+ const out = [];
115
+ const occurrences = new Map();
116
+ for (const row of parseJsonl(raw, tail)) {
117
+ const fingerprint = hash(JSON.stringify(row));
118
+ const n = occurrences.get(fingerprint) ?? 0;
119
+ occurrences.set(fingerprint, n + 1);
120
+ const seed = `${fingerprint}:${n}`;
121
+ const ts = row.timestamp ?? row.ts;
122
+ let count = 0;
123
+ const emit = (type, fields = {}) => out.push(make(type, ts, `${seed}:${count++}`, fields));
124
+ if (format === "events") {
125
+ // Accept the platform's flat logs as well as the portable event schema.
126
+ if (![
127
+ "user",
128
+ "assistant_text",
129
+ "tool_use",
130
+ "tool_result",
131
+ "result",
132
+ ].includes(row.type))
133
+ continue;
134
+ const fields = {};
135
+ for (const k of ["text", "name", "tool_use_id", "result"])
136
+ if (row[k] !== undefined)
137
+ fields[k] = text(row[k]);
138
+ if (row.input !== undefined)
139
+ fields.input = input(row.input);
140
+ out.push(eventSchema.parse({
141
+ event_id: row.event_id ?? hash(seed),
142
+ ts,
143
+ type: row.type,
144
+ ...fields,
145
+ }));
146
+ }
147
+ else if (format === "codex") {
148
+ const p = row.payload ?? {};
149
+ if (row.type === "event_msg" && p.type === "task_complete") {
150
+ emit("result");
151
+ continue;
152
+ }
153
+ if (row.type !== "response_item")
154
+ continue;
155
+ if (p.type === "message" &&
156
+ ["user", "assistant"].includes(p.role) &&
157
+ p.channel !== "analysis" &&
158
+ p.phase !== "analysis") {
159
+ const body = text(p.content);
160
+ if (!body ||
161
+ /^\s*<(environment_context|recommended_plugins)>/.test(body))
162
+ continue;
163
+ emit(p.role === "user" ? "user" : "assistant_text", { text: body });
164
+ }
165
+ else if (["function_call", "custom_tool_call"].includes(p.type)) {
166
+ emit("tool_use", {
167
+ name: p.name,
168
+ input: input(p.arguments ?? p.input),
169
+ tool_use_id: p.call_id,
170
+ });
171
+ }
172
+ else if (["function_call_output", "custom_tool_call_output"].includes(p.type)) {
173
+ emit("tool_result", { result: text(p.output), tool_use_id: p.call_id });
174
+ }
175
+ }
176
+ else if (format === "claude-code") {
177
+ if (row.type === "result") {
178
+ emit("result");
179
+ continue;
180
+ }
181
+ if (!["user", "assistant"].includes(row.type))
182
+ continue;
183
+ const msg = row.message ?? {};
184
+ if (typeof msg.content === "string")
185
+ emit(row.type === "user" ? "user" : "assistant_text", {
186
+ text: text(msg.content),
187
+ });
188
+ else
189
+ for (const b of msg.content ?? []) {
190
+ if (b.type === "text")
191
+ emit(row.type === "user" ? "user" : "assistant_text", {
192
+ text: text(b.text),
193
+ });
194
+ if (b.type === "tool_use")
195
+ emit("tool_use", {
196
+ name: b.name,
197
+ input: input(b.input),
198
+ tool_use_id: b.id,
199
+ });
200
+ if (b.type === "tool_result")
201
+ emit("tool_result", {
202
+ result: text(b.content),
203
+ tool_use_id: b.tool_use_id,
204
+ });
205
+ }
206
+ }
207
+ else
208
+ throw new Error(`Unsupported transcript format: ${format}. Use events for any other harness.`);
209
+ }
210
+ return out;
211
+ }
212
+ export function convertHook(harness, p, now = new Date().toISOString(), state = {}) {
213
+ const ts = p.timestamp ?? now;
214
+ const extra = p.extra ?? {};
215
+ const event = p.hook_event_name;
216
+ const seed = p.event_id ?? p.delivery_id ?? randomUUID();
217
+ const out = [];
218
+ const emit = (type, fields = {}) => out.push(make(type, ts, `${seed}:${out.length}`, fields));
219
+ if (harness === "cursor") {
220
+ if (event === "beforeSubmitPrompt")
221
+ emit("user", { text: text(p.prompt) });
222
+ if (event === "afterAgentResponse")
223
+ emit("assistant_text", { text: text(p.text) });
224
+ if (event === "postToolUse" || event === "postToolUseFailure") {
225
+ const id = p.tool_use_id ?? p.tool_call_id ?? seed;
226
+ emit("tool_use", {
227
+ name: p.tool_name ?? "tool",
228
+ input: input(p.tool_input),
229
+ tool_use_id: id,
230
+ });
231
+ emit("tool_result", {
232
+ result: text(p.tool_output ?? p.result ?? p.tool_response ?? p.error_message),
233
+ tool_use_id: id,
234
+ });
235
+ }
236
+ if (event === "stop")
237
+ emit("result");
238
+ }
239
+ else if (harness === "hermes") {
240
+ if (event === "pre_llm_call" && extra.user_message) {
241
+ const prompt = text(extra.user_message);
242
+ // The tool loop repeats pre_llm_call with the same prompt. Reset on
243
+ // completion so an identical prompt on the next turn is still captured.
244
+ if (state.prompt !== prompt)
245
+ emit("user", { text: prompt });
246
+ state.prompt = prompt;
247
+ }
248
+ if (event === "post_tool_call") {
249
+ const id = extra.tool_call_id ?? seed;
250
+ emit("tool_use", {
251
+ name: p.tool_name ?? "tool",
252
+ input: input(p.tool_input ?? extra.args),
253
+ tool_use_id: id,
254
+ });
255
+ emit("tool_result", { result: text(extra.result), tool_use_id: id });
256
+ }
257
+ if (event === "transform_llm_output") {
258
+ delete state.prompt;
259
+ emit("assistant_text", { text: text(extra.response_text) });
260
+ emit("result");
261
+ }
262
+ }
263
+ else if (Array.isArray(p.events)) {
264
+ return convertTranscript(p.events.map((e) => JSON.stringify(e)).join("\n"), "events");
265
+ }
266
+ return out;
267
+ }
@@ -0,0 +1,123 @@
1
+ /** Install additive, project-scoped integrations; keep other hooks and notify commands. */
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { parseDocument } from "yaml";
7
+ import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
8
+ import { logConfig, logDir, readJson, writeJson } from "./agent-logs.js";
9
+ import { requireState } from "./state.js";
10
+ export const HARNESSES = ["codex", "claude-code", "cursor", "hermes"];
11
+ const cli = fileURLToPath(new URL("./index.js", import.meta.url));
12
+ // JSON double quoting is safe for Windows command paths; POSIX needs literal single quotes.
13
+ export function quote(arg) {
14
+ return process.platform === "win32"
15
+ ? '"' + arg.replace(/"/g, '""') + '"'
16
+ : "'" + arg.replace(/'/g, "'\\''") + "'";
17
+ }
18
+ function backup(file) {
19
+ if (fs.existsSync(file))
20
+ fs.copyFileSync(file, `${file}.vm-backup-${Date.now()}`);
21
+ }
22
+ function command(harness) {
23
+ return [process.execPath, cli, "agent-logs", "hook", "--harness", harness]
24
+ .map(quote)
25
+ .join(" ");
26
+ }
27
+ function mergeJson(file, events, cmd, cursor = false) {
28
+ const config = readJson(file) ?? {};
29
+ if (typeof config !== "object" || Array.isArray(config))
30
+ throw new Error(`Invalid config: ${file}`);
31
+ config.hooks ??= {};
32
+ if (cursor)
33
+ config.version ??= 1;
34
+ for (const event of events) {
35
+ const groups = config.hooks[event] ?? [];
36
+ if (!Array.isArray(groups))
37
+ throw new Error(`Invalid hooks.${event} in ${file}`);
38
+ if (!JSON.stringify(groups).includes(cmd))
39
+ groups.push(cursor
40
+ ? { command: cmd }
41
+ : { hooks: [{ type: "command", command: cmd }] });
42
+ config.hooks[event] = groups;
43
+ }
44
+ if (JSON.stringify(readJson(file)) !== JSON.stringify(config)) {
45
+ backup(file);
46
+ writeJson(file, config);
47
+ }
48
+ }
49
+ export function setupLogs(dir, harness = "all") {
50
+ requireState(dir);
51
+ if (harness !== "all" && !HARNESSES.includes(harness))
52
+ throw new Error(`Choose all or ${HARNESSES.join(", ")}; custom harnesses use agent-logs hook/upload`);
53
+ const selected = harness === "all" ? HARNESSES : [harness];
54
+ const messages = [];
55
+ for (const h of selected) {
56
+ const cmd = command(h);
57
+ if (h === "codex") {
58
+ mergeJson(path.join(dir, ".codex/hooks.json"), ["SessionStart", "Stop", "Interrupt", "SessionEnd"], cmd);
59
+ const file = path.join(dir, ".codex/config.toml");
60
+ const config = fs.existsSync(file)
61
+ ? parseToml(fs.readFileSync(file, "utf8"))
62
+ : {};
63
+ const features = (config.features ?? {});
64
+ if (features.hooks !== true) {
65
+ features.hooks = true;
66
+ config.features = features;
67
+ backup(file);
68
+ fs.writeFileSync(file, stringifyToml(config), { mode: 0o600 });
69
+ }
70
+ messages.push("Codex: project hooks installed; review them with /hooks. The sync watcher also discovers this project's desktop/CLI transcripts.");
71
+ }
72
+ else if (h === "claude-code") {
73
+ mergeJson(path.join(dir, ".claude/settings.local.json"), ["SessionStart", "Stop", "SessionEnd"], cmd);
74
+ messages.push("Claude Code: project transcript hooks installed (restart the harness to load them).");
75
+ }
76
+ else if (h === "cursor") {
77
+ mergeJson(path.join(dir, ".cursor/hooks.json"), [
78
+ "beforeSubmitPrompt",
79
+ "afterAgentResponse",
80
+ "postToolUse",
81
+ "postToolUseFailure",
82
+ "stop",
83
+ ], cmd, true);
84
+ messages.push("Cursor: project event hooks installed (restart the harness to load them).");
85
+ }
86
+ else {
87
+ const home = process.env.HERMES_HOME ?? path.join(os.homedir(), ".hermes");
88
+ const file = path.join(home, "config.yaml");
89
+ if (harness === "all" && !fs.existsSync(file)) {
90
+ messages.push("Hermes is not configured here; run agent-logs setup --harness hermes after installing it.");
91
+ continue;
92
+ }
93
+ const doc = parseDocument(fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "{}");
94
+ if (doc.errors.length)
95
+ throw new Error(`Invalid YAML in ${file}; preserved`);
96
+ for (const event of [
97
+ "pre_llm_call",
98
+ "post_tool_call",
99
+ "transform_llm_output",
100
+ ]) {
101
+ const old = doc.getIn(["hooks", event]);
102
+ const list = old ? old.toJSON() : [];
103
+ if (!Array.isArray(list))
104
+ throw new Error(`Invalid Hermes hooks.${event}; preserved`);
105
+ if (!list.some((v) => v.command === cmd))
106
+ list.push({ command: cmd, timeout: 10 });
107
+ doc.setIn(["hooks", event], list);
108
+ }
109
+ backup(file);
110
+ fs.mkdirSync(home, { recursive: true });
111
+ fs.writeFileSync(file, doc.toString(), { mode: 0o600 });
112
+ messages.push("Hermes: cwd-gated profile hooks installed. Hermes will request its normal first-use hook approval.");
113
+ }
114
+ }
115
+ const old = logConfig(dir);
116
+ writeJson(path.join(logDir(dir), "config.json"), {
117
+ version: 1,
118
+ enabled: true,
119
+ since: old?.since ?? new Date().toISOString(),
120
+ codexDiscovery: old?.codexDiscovery || selected.includes("codex"),
121
+ });
122
+ return messages;
123
+ }