virtualmatter 0.3.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/README.md CHANGED
@@ -157,3 +157,31 @@ server with OAuth. Sign in or create a VM account, approve the connection,
157
157
  and return to Lovable. Then ask “Embed my Lava Arena world” or “Create a
158
158
  Virtual Matter lava survival arena and embed it here.” This remote endpoint
159
159
  runs on the platform; users do not install or run this CLI for that flow.
160
+
161
+ ## Automatic agent logs
162
+
163
+ Run `virtualmatter agent-logs setup` once in a project mirror to capture future
164
+ conversations from Codex, Claude Code, Cursor, and an installed Hermes profile.
165
+ Keep `virtualmatter sync` (or `virtualmatter agent-logs watch`) running for
166
+ retry and transcript catch-up. Existing hooks are retained; follow the
167
+ harness's normal hook trust/restart flow. `agent-logs status` shows the queue;
168
+ `agent-logs disable` stops automatic capture.
169
+
170
+ Any harness can upload public event JSONL with:
171
+
172
+ ```sh
173
+ virtualmatter agent-logs upload conversation.jsonl --harness my-agent --session session-123
174
+ ```
175
+
176
+ Each event needs a stable `event_id`, timezone-bearing `ts`, and `type` (`user`,
177
+ `assistant_text`, `tool_use`, `tool_result`, or `result`). Text events carry
178
+ `text`; tools carry `name`, `input`, `tool_use_id`; results carry `tool_use_id`
179
+ and `result`. Retries deduplicate, existing entries cannot be overwritten, and
180
+ failed batches remain in the project's local `.virtualmatter/agent-logs/`
181
+ outbox. `--format codex` and `--format claude-code` import explicit transcript
182
+ files. MCP exposes the same portable contract as `upload_agent_logs`.
183
+
184
+ Setup records only this project's future entries. Hidden reasoning, system
185
+ messages, and binary media are excluded. Logs become part of the project's
186
+ history and saved content. The server must have the agent-log API update;
187
+ older servers return 404 and the CLI retains pending entries.
@@ -0,0 +1,329 @@
1
+ /** Durable project-scoped outbox. No credentials or private transcripts enter the world tree. */
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+ import { randomUUID } from "node:crypto";
6
+ import { authorizedFetch, resolveSession, sessionBaseUrl } from "./api.js";
7
+ import { apiBase } from "./config.js";
8
+ import { loadState, requireState } from "./state.js";
9
+ import { convertTranscript, eventSchema, hash, sessionKey, slug, } from "./log-events.js";
10
+ export function projectRoot(start) {
11
+ let dir = path.resolve(start);
12
+ for (;;) {
13
+ if (loadState(dir))
14
+ return fs.realpathSync(dir);
15
+ const parent = path.dirname(dir);
16
+ if (parent === dir)
17
+ return null;
18
+ dir = parent;
19
+ }
20
+ }
21
+ export const logDir = (dir) => path.join(dir, ".virtualmatter", "agent-logs");
22
+ export function writeJson(file, obj) {
23
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
24
+ const temp = `${file}.${randomUUID()}.tmp`;
25
+ fs.writeFileSync(temp, JSON.stringify(obj, null, 2) + "\n", { mode: 0o600 });
26
+ fs.renameSync(temp, file);
27
+ }
28
+ export function readJson(file) {
29
+ try {
30
+ return JSON.parse(fs.readFileSync(file, "utf8"));
31
+ }
32
+ catch (err) {
33
+ if (err.code === "ENOENT")
34
+ return null;
35
+ throw err;
36
+ }
37
+ }
38
+ export function logConfig(dir) {
39
+ return readJson(path.join(logDir(dir), "config.json"));
40
+ }
41
+ export function enqueue(dir, harness, session, events) {
42
+ slug(harness);
43
+ sessionKey(session);
44
+ const framing = requireState(dir).framing_id;
45
+ const indexFile = path.join(logDir(dir), "captured", hash(JSON.stringify([apiBase(), framing, harness, session])) + ".json");
46
+ const known = Object.assign(Object.create(null), readJson(indexFile) ?? {});
47
+ const fresh = [];
48
+ // Validate the entire capture before queueing. Checkpoints contain hashes,
49
+ // never transcript content. Re-reading a growing transcript while offline
50
+ // must queue only its new events, not every prefix of the same conversation.
51
+ for (const raw of events) {
52
+ const event = eventSchema.parse(raw);
53
+ const encoded = JSON.stringify(event);
54
+ if (Buffer.byteLength(encoded) > 256 * 1024)
55
+ throw new Error("Event exceeds 256 KiB");
56
+ const digest = hash(encoded);
57
+ if (Object.hasOwn(known, event.event_id)) {
58
+ if (known[event.event_id] !== digest)
59
+ throw new Error("Event ID already captured with different content");
60
+ }
61
+ else {
62
+ fresh.push(event);
63
+ known[event.event_id] = digest;
64
+ }
65
+ }
66
+ let queued = 0;
67
+ let batch = [];
68
+ let size = 0;
69
+ const commit = () => {
70
+ if (!batch.length)
71
+ return;
72
+ const envelope = {
73
+ version: 1,
74
+ api: apiBase(),
75
+ framing,
76
+ harness,
77
+ session,
78
+ events: batch,
79
+ };
80
+ const id = hash(JSON.stringify(envelope));
81
+ const root = logDir(dir);
82
+ if (!fs.existsSync(path.join(root, "receipts", id)) &&
83
+ !fs.existsSync(path.join(root, "pending", id + ".json"))) {
84
+ writeJson(path.join(root, "pending", id + ".json"), envelope);
85
+ queued += batch.length;
86
+ }
87
+ batch = [];
88
+ size = 0;
89
+ };
90
+ for (const event of fresh) {
91
+ const bytes = Buffer.byteLength(JSON.stringify(event));
92
+ if (bytes > 256 * 1024)
93
+ throw new Error("Event exceeds 256 KiB");
94
+ if (batch.length >= 250 || size + bytes > 800_000)
95
+ commit();
96
+ batch.push(event);
97
+ size += bytes + 1;
98
+ }
99
+ commit();
100
+ // Queue first, checkpoint second. A crash can cause a safe duplicate retry,
101
+ // never a checkpoint that skips events which were not durably queued.
102
+ if (fresh.length)
103
+ writeJson(indexFile, { ...(readJson(indexFile) ?? {}), ...known });
104
+ return queued;
105
+ }
106
+ export async function uploadBatch(base, framing, harness, session, events) {
107
+ const response = await authorizedFetch(`${base}/api/montage/${encodeURIComponent(framing)}/agent-logs/${slug(harness)}/${sessionKey(session)}`, {
108
+ method: "POST",
109
+ headers: { "Content-Type": "application/json" },
110
+ body: JSON.stringify({ events }),
111
+ signal: AbortSignal.timeout(30_000),
112
+ });
113
+ if (!response.ok)
114
+ throw new Error(`Agent-log upload failed: HTTP ${response.status}${response.status === 404 ? " (server may need the agent-log API update)" : ""}`);
115
+ const result = (await response.json());
116
+ if (result.accepted + result.duplicates !== events.length)
117
+ throw new Error("Invalid upload acknowledgement; batch retained");
118
+ return result;
119
+ }
120
+ export async function flushLogs(dir, send) {
121
+ const lock = path.join(logDir(dir), "flush.lock");
122
+ fs.mkdirSync(path.dirname(lock), { recursive: true, mode: 0o700 });
123
+ // Concurrent hooks share one uploader. A crashed process leaves recoverable
124
+ // queue files; never discard batches just because a lock was abandoned.
125
+ for (let attempt = 0; attempt < 2; attempt++) {
126
+ try {
127
+ const fd = fs.openSync(lock, "wx", 0o600);
128
+ fs.writeFileSync(fd, String(process.pid));
129
+ fs.closeSync(fd);
130
+ try {
131
+ return await flushUnlocked(dir, send);
132
+ }
133
+ finally {
134
+ fs.rmSync(lock, { force: true });
135
+ }
136
+ }
137
+ catch (err) {
138
+ if (err.code !== "EEXIST")
139
+ throw err;
140
+ let pid;
141
+ try {
142
+ pid = Number(fs.readFileSync(lock, "utf8"));
143
+ }
144
+ catch {
145
+ continue;
146
+ }
147
+ // A just-created, not-yet-written lock also belongs to a live writer.
148
+ if (!Number.isSafeInteger(pid) || pid <= 0)
149
+ return 0;
150
+ try {
151
+ process.kill(pid, 0);
152
+ return 0;
153
+ }
154
+ catch (error) {
155
+ if (error.code !== "ESRCH")
156
+ return 0;
157
+ fs.rmSync(lock, { force: true });
158
+ }
159
+ }
160
+ }
161
+ return 0;
162
+ }
163
+ async function flushUnlocked(dir, send) {
164
+ const root = logDir(dir), pending = path.join(root, "pending");
165
+ if (!fs.existsSync(pending))
166
+ return 0;
167
+ const files = fs
168
+ .readdirSync(pending)
169
+ .filter((f) => f.endsWith(".json"))
170
+ .sort((a, b) => fs.statSync(path.join(pending, a)).mtimeMs -
171
+ fs.statSync(path.join(pending, b)).mtimeMs);
172
+ if (!files.length)
173
+ return 0;
174
+ let base;
175
+ let sent = 0;
176
+ for (const name of files) {
177
+ const file = path.join(pending, name);
178
+ const e = readJson(file);
179
+ if (!e)
180
+ continue; // another flushing process already acknowledged it
181
+ if (e.version !== 1 ||
182
+ e.api !== apiBase() ||
183
+ e.framing !== requireState(dir).framing_id)
184
+ throw new Error("Outbox belongs to a different project/environment; preserved");
185
+ try {
186
+ if (send)
187
+ await send(e);
188
+ else {
189
+ base ??= sessionBaseUrl(await resolveSession(e.framing, { timeoutMs: 30_000 }));
190
+ await uploadBatch(base, e.framing, e.harness, e.session, e.events);
191
+ }
192
+ const receipt = path.join(root, "receipts", name.slice(0, -5));
193
+ fs.mkdirSync(path.dirname(receipt), { recursive: true, mode: 0o700 });
194
+ fs.writeFileSync(receipt, "", { mode: 0o600 });
195
+ fs.rmSync(file, { force: true });
196
+ sent += e.events.length;
197
+ }
198
+ catch (err) {
199
+ writeJson(path.join(root, "last-error.json"), {
200
+ ts: new Date().toISOString(),
201
+ message: String(err),
202
+ });
203
+ throw err;
204
+ }
205
+ }
206
+ fs.rmSync(path.join(root, "last-error.json"), { force: true });
207
+ return sent;
208
+ }
209
+ export function registerSource(dir, source) {
210
+ slug(source.harness);
211
+ sessionKey(source.session);
212
+ const target = path.join(logDir(dir), "sources", hash(source.file) + ".json");
213
+ if (JSON.stringify(readJson(target)) !== JSON.stringify(source))
214
+ writeJson(target, source);
215
+ }
216
+ export function captureSource(dir, source, since) {
217
+ if (!fs.existsSync(source.file))
218
+ return 0;
219
+ if (fs.statSync(source.file).size > 128 * 1024 * 1024)
220
+ throw new Error("Transcript exceeds 128 MiB; split it for explicit upload");
221
+ const events = convertTranscript(fs.readFileSync(source.file, "utf8"), source.format, true).filter((e) => !since || Date.parse(e.ts) >= Date.parse(since));
222
+ return enqueue(dir, source.harness, source.session, events);
223
+ }
224
+ /** Discover Codex desktop/CLI sessions by their initial cwd metadata, never by text search. */
225
+ export function discoverCodex(dir, since, home = process.env.CODEX_HOME ?? path.join(os.homedir(), ".codex")) {
226
+ const root = path.join(home, "sessions"), found = [];
227
+ const walk = (folder, depth) => {
228
+ if (depth > 4 || !fs.existsSync(folder))
229
+ return;
230
+ for (const entry of fs.readdirSync(folder, { withFileTypes: true })) {
231
+ const file = path.join(folder, entry.name);
232
+ if (entry.isDirectory())
233
+ walk(file, depth + 1);
234
+ else if (entry.isFile() &&
235
+ entry.name.endsWith(".jsonl") &&
236
+ fs.statSync(file).mtimeMs >= Date.parse(since)) {
237
+ const fd = fs.openSync(file, "r");
238
+ let first;
239
+ try {
240
+ const buffer = Buffer.alloc(16384);
241
+ first = buffer
242
+ .subarray(0, fs.readSync(fd, buffer, 0, buffer.length, 0))
243
+ .toString()
244
+ .split("\n")[0];
245
+ }
246
+ finally {
247
+ fs.closeSync(fd);
248
+ }
249
+ try {
250
+ const row = JSON.parse(first), p = row.payload;
251
+ if (row.type === "session_meta" &&
252
+ typeof p?.cwd === "string" &&
253
+ projectRoot(p.cwd) === fs.realpathSync(dir)) {
254
+ found.push({
255
+ file,
256
+ format: "codex",
257
+ harness: "codex",
258
+ session: sessionKey(p.id),
259
+ });
260
+ }
261
+ }
262
+ catch {
263
+ /* unrelated/unflushed metadata */
264
+ }
265
+ }
266
+ }
267
+ };
268
+ walk(root, 0);
269
+ return found;
270
+ }
271
+ export async function collectLogs(dir) {
272
+ const config = logConfig(dir);
273
+ if (!config?.enabled)
274
+ return 0;
275
+ if (config.codexDiscovery)
276
+ for (const source of discoverCodex(dir, config.since))
277
+ registerSource(dir, source);
278
+ const sources = path.join(logDir(dir), "sources");
279
+ let n = 0;
280
+ const errors = [];
281
+ if (fs.existsSync(sources))
282
+ for (const file of fs
283
+ .readdirSync(sources)
284
+ .filter((f) => f.endsWith(".json"))) {
285
+ try {
286
+ n += captureSource(dir, readJson(path.join(sources, file)), config.since);
287
+ }
288
+ catch (err) {
289
+ errors.push({ source: file, message: String(err) });
290
+ }
291
+ }
292
+ if (errors.length)
293
+ writeJson(path.join(logDir(dir), "capture-errors.json"), errors);
294
+ else
295
+ fs.rmSync(path.join(logDir(dir), "capture-errors.json"), { force: true });
296
+ return n;
297
+ }
298
+ /** One non-overlapping retry loop; discovery also catches delayed final transcript writes. */
299
+ export function startLogSync(dir, warn = (s) => console.warn(s)) {
300
+ let running;
301
+ let lastError = "";
302
+ const tick = () => {
303
+ if (running)
304
+ return;
305
+ running = (async () => {
306
+ try {
307
+ if (!logConfig(dir)?.enabled)
308
+ return;
309
+ await collectLogs(dir);
310
+ await flushLogs(dir);
311
+ lastError = "";
312
+ }
313
+ catch (err) {
314
+ const message = String(err);
315
+ if (message !== lastError)
316
+ warn(`Agent logs queued: ${message}`);
317
+ lastError = message;
318
+ }
319
+ })().finally(() => {
320
+ running = undefined;
321
+ });
322
+ };
323
+ tick();
324
+ const interval = setInterval(tick, 5000);
325
+ return async () => {
326
+ clearInterval(interval);
327
+ await running;
328
+ };
329
+ }
@@ -14,6 +14,7 @@ export const AGENTS_MD_STUB = `# Working with Virtual Matter
14
14
  This folder is a live mirror of a Virtual Matter world's Montage files.
15
15
 
16
16
  - \`npx virtualmatter sync\` keeps it in sync with the running session.
17
+ - \`npx virtualmatter agent-logs setup\` enables automatic local conversation logs.
17
18
  - \`npx virtualmatter run-lua --code "..."\` executes Lua in the engine.
18
19
  - \`npx virtualmatter errors\` shows recent engine errors.
19
20
  - \`npx virtualmatter screenshot -o shot.png\` captures the current view.
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
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
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";
@@ -331,6 +332,7 @@ program
331
332
  .action((dir, opts) => run(async () => {
332
333
  await runMcpServer(path.resolve(dir), opts.framing);
333
334
  }));
335
+ registerLogCommands(program);
334
336
  program.parseAsync(process.argv).catch((err) => {
335
337
  fail(err instanceof Error ? err.message : String(err));
336
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
+ }
package/dist/mcp.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { uploadBatch } from "./agent-logs.js";
2
+ import { eventSchema } from "./log-events.js";
1
3
  /**
2
4
  * `virtualmatter mcp` - a stdio MCP server that gives a coding agent direct
3
5
  * hands on Virtual Matter: the account's projects, creating one, a live
@@ -97,6 +99,15 @@ export async function writeWithRetry(client, filePath, body) {
97
99
  export async function runMcpServer(dir, framingFlag) {
98
100
  const ctx = makeContext(resolveFramingId(dir, framingFlag));
99
101
  const server = new McpServer({ name: "virtualmatter", version: packageVersion() });
102
+ server.registerTool("upload_agent_logs", {
103
+ description: "Append public conversation and tool events from any local harness to the selected project's Agent Logs. Supply stable event IDs for retry deduplication. Never include private reasoning, system prompts, credentials, or binary media. Use the CLI hook integrations for automatic full conversation capture.",
104
+ inputSchema: { harness: z.string().regex(/^[a-z0-9][a-z0-9_-]{0,63}$/), session_id: z.string().regex(/^[A-Za-z0-9_-]{1,128}$/), events: z.array(eventSchema).min(1).max(500) },
105
+ }, async ({ harness, session_id, events }) => {
106
+ const framing = ctx.requireFraming();
107
+ // Resolve afresh so a restarted session never strands retries on an old host.
108
+ const session = await resolveSession(framing);
109
+ return textResult(JSON.stringify(await uploadBatch(sessionBaseUrl(session), framing, harness, session_id, events)));
110
+ });
100
111
  server.registerTool("get_embed", {
101
112
  description: "Get canonical iframe markup and hosting instructions for a shared Virtual Matter world URL. No sign-in needed. Follow the instructions and verify the published page.",
102
113
  inputSchema: { target: z.string() },
package/dist/sync.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { startLogSync } from "./agent-logs.js";
1
2
  /**
2
3
  * The sync hot loop: initial pull of remote changes, watch the local tree
3
4
  * and push edits with stored etags, poll the remote listing and pull files
@@ -213,6 +214,7 @@ export async function runSyncLoop(engine, dir) {
213
214
  const { default: chokidar } = await import("chokidar");
214
215
  const { pulled, pushed } = await engine.initialSync();
215
216
  console.log(`initial sync: ${pulled} pulled, ${pushed} pushed. Watching ${dir} ...`);
217
+ const stopLogs = startLogSync(dir);
216
218
  const watcher = chokidar.watch(dir, {
217
219
  ignoreInitial: true,
218
220
  ignored: (p) => {
@@ -241,6 +243,7 @@ export async function runSyncLoop(engine, dir) {
241
243
  const stop = async () => {
242
244
  clearInterval(interval);
243
245
  await watcher.close();
246
+ await stopLogs();
244
247
  console.log("\nsync stopped.");
245
248
  resolve();
246
249
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "virtualmatter",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "CLI + MCP server for building with Virtual Matter - list and create worlds, sync their files, run Lua, capture screenshots, open the native client, and wire coding agents into a live session.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,6 +25,8 @@
25
25
  "@modelcontextprotocol/sdk": "^1.12.0",
26
26
  "chokidar": "^4.0.3",
27
27
  "commander": "^14.0.0",
28
+ "smol-toml": "^1.8.0",
29
+ "yaml": "^2.9.0",
28
30
  "zod": "^3.25.0"
29
31
  },
30
32
  "devDependencies": {