File size: 20,525 Bytes
63bdc81
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
#!/usr/bin/env node
// docs/session-sharing.md §6: locate -> redact -> assemble.
// Trace only; publishing is a separate step (server/src/share.js).
//
// Usage: node scripts/share-session.mjs <src> <outdir> [harness] [sessionId]
//        harness: claude (default) | codex | hermes | opencode | openclaw
//
// Claude and Codex are Hub-native, so their trace ships VERBATIM. The other
// three are converted to the Hub's documented Session Trace Simple Format
// (STS): a `{type:"session"}` header then `{type:"message"}` lines. Converting
// to STS rather than hand-rolling Claude JSONL is deliberate -- it is the
// documented path for a custom harness and a far smaller target.
//
// For hermes/opencode, <src> is the SQLite path and <sessionId> selects the
// conversation. For claude/codex/openclaw it is the trace file itself.
//
// The redaction pass is format-agnostic: it works on each serialized line, so
// it needs no per-harness knowledge and covers converted output too.
//
// Produces the share bundle described in §4:
//   <outdir>/<native-name>.jsonl    the trace, in its original format
//   <outdir>/meta/manifest.json     provenance + lineage
//   <outdir>/meta/briefing.md       mechanical handoff summary
//   <outdir>/meta/redaction.json    which rules ran and what they caught
//
// The README/dataset card is written by the caller (it differs per visibility).
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';

const [src, outdir, harnessArg, sessionArg] = process.argv.slice(2);
const HARNESS = harnessArg || 'claude';
const NATIVE = ['claude', 'codex'];              // Hub reads these as-is
const CONVERTED = ['hermes', 'opencode', 'openclaw']; // emitted as STS
if (![...NATIVE, ...CONVERTED].includes(HARNESS)) {
  console.error(`unsupported harness: ${HARNESS}`);
  process.exit(1);
}
if (!src || !outdir) {
  console.error('usage: share-session.mjs <src> <outdir> [claude|codex|hermes|opencode|openclaw] [sessionId]');
  process.exit(1);
}

// ---------- redaction ruleset v1 (§7) ----------
const PATTERNS = [
  ['hf-token', /\bhf_[A-Za-z0-9]{20,}\b/g],
  ['anthropic-key', /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g],
  ['openai-key', /\bsk-(?:proj-)?[A-Za-z0-9]{32,}\b/g],
  ['github-token', /\bgh[pousr]_[A-Za-z0-9]{30,}\b/g],
  ['aws-key-id', /\bAKIA[0-9A-Z]{16}\b/g],
  ['google-key', /\bAIza[0-9A-Za-z_-]{35}\b/g],
  ['jwt', /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g],
  ['private-key', /-----BEGIN [A-Z ]*PRIVATE KEY-----/g],
  // PII: a shared trace should not carry personal addresses. Deliberately broad;
  // every hit is counted so the operator can see what tripped.
  ['email', /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g],
];

// Value rules: match the ACTUAL secret values from the environment, so secrets
// that don't look like secrets are still caught.
const envRules = [];
for (const [k, v] of Object.entries(process.env)) {
  if (!/(TOKEN|KEY|SECRET|PASSWORD|CREDENTIAL)/i.test(k)) continue;
  if (!v || v.length < 8) continue;
  envRules.push([`env:${k}`, v]);
}

const hits = {};
const bump = (rule, n = 1) => { hits[rule] = (hits[rule] || 0) + n; };

function redact(text) {
  let out = text;
  // Value rules first — an env secret may itself look like a pattern.
  for (const [rule, value] of envRules) {
    if (!out.includes(value)) continue;
    bump(rule, out.split(value).length - 1);
    out = out.split(value).join(`«redacted:${rule}»`);
  }
  for (const [rule, re] of PATTERNS) {
    out = out.replace(re, () => { bump(rule, 1); return `«redacted:${rule}»`; });
  }
  return out;
}

// Codex rollout lines. Shapes mirror parseCodex() in server/src/traces.js, which
// is the battle-tested reader for this format -- keep the two in step.
const codexText = (p) => (Array.isArray(p.content) ? p.content.map((c) => (c && c.text) || '').join(' ') : '');
let codexTok = null; // token_count events are CUMULATIVE per run, so keep the last


// ---------- STS conversion for harnesses the Hub does not read natively ----------
// Session Trace Simple Format: one `{type:"session"}` header, then
// `{type:"message"}` lines. Tool calls ride on an assistant message and results
// come back as a `role:"tool"` message carrying the matching toolCallId.
const stsMsg = (role, content, extra = {}) => ({ type: 'message', message: { role, content: content || '', ...extra } });

let DatabaseSync = null;
try { ({ DatabaseSync } = await import('node:sqlite')); } catch { /* older node */ }

function openDb(p) {
  if (!DatabaseSync) throw new Error('node:sqlite unavailable — cannot read this harness');
  return new DatabaseSync(p, { readOnly: true });
}

// opencode: session / message / part tables, payloads in JSON `data` columns.
// Mirrors the queries readOpencode() in traces.js uses.
function fromOpencode(dbPath, sessionId) {
  const db = openDb(dbPath);
  try {
    const s = db.prepare('select * from session where id = ?').get(sessionId);
    if (!s) throw new Error(`opencode session ${sessionId} not found`);
    stats.cwd = s.directory || null;
    stats.tokensIn = s.tokens_input || 0;
    stats.tokensOut = s.tokens_output || 0;
    stats.cacheRead = s.tokens_cache_read || 0;
    const out = [{ type: 'session', harness: 'opencode', id: s.id, name: s.title || 'opencode session' }];
    const msgs = db.prepare('select * from message where session_id = ? order by time_created').all(sessionId);
    const qParts = db.prepare('select * from part where message_id = ? order by time_created');
    for (const m of msgs) {
      let md = {}; try { md = JSON.parse(m.data || '{}'); } catch {}
      const role = md.role === 'assistant' ? 'assistant' : 'user';
      const texts = [];
      const toolCalls = [];
      const results = [];
      for (const part of qParts.all(m.id)) {
        let pd = {}; try { pd = JSON.parse(part.data || '{}'); } catch {}
        if (pd.type === 'text' && pd.text) texts.push(pd.text);
        else if (pd.type === 'tool') {
          const id = part.id || `t${toolCalls.length}`;
          toolCalls.push({ id, function: { name: pd.tool || 'tool', arguments: JSON.stringify(pd.state?.input ?? pd.input ?? {}) } });
          const res = pd.state?.output ?? pd.output;
          if (res != null) results.push({ id, text: typeof res === 'string' ? res : JSON.stringify(res) });
        }
      }
      const ts = Number(m.time_created) || undefined;
      if (role === 'user') { stats.prompts++; if (texts.join(' ').trim()) prompts.push(texts.join(' ').replace(/\s+/g, ' ').slice(0, 300)); }
      else stats.turns++;
      const extra = { timestamp: ts };
      if (toolCalls.length) {
        extra.toolCalls = toolCalls;
        for (const t of toolCalls) { stats.toolCalls++; const n = t.function.name; stats.tools[n] = (stats.tools[n] || 0) + 1; }
      }
      out.push(stsMsg(role, texts.join('\n'), extra));
      for (const r of results) out.push(stsMsg('tool', r.text, { toolCallId: r.id }));
      if (ts) { if (!stats.firstTs || ts < stats.firstTs) stats.firstTs = ts; if (ts > stats.lastTs) stats.lastTs = ts; }
    }
    return out;
  } finally { try { db.close(); } catch {} }
}

// hermes: sessions / messages tables, flat content + tool_name columns.
function fromHermes(dbPath, sessionId) {
  const db = openDb(dbPath);
  try {
    const s = db.prepare('select * from sessions where id = ?').get(sessionId);
    if (!s) throw new Error(`hermes session ${sessionId} not found`);
    stats.cwd = s.cwd || null;
    stats.tokensIn = s.input_tokens || 0;
    stats.tokensOut = s.output_tokens || 0;
    stats.cacheRead = s.cache_read_tokens || 0;
    const out = [{ type: 'session', harness: 'hermes', id: String(s.id), name: s.title || 'hermes session' }];
    const msgs = db.prepare('select * from messages where session_id = ? order by timestamp').all(sessionId);
    for (const m of msgs) {
      const ts = m.timestamp != null ? Math.round(Number(m.timestamp) * 1000) : undefined;
      if (ts) { if (!stats.firstTs || ts < stats.firstTs) stats.firstTs = ts; if (ts > stats.lastTs) stats.lastTs = ts; }
      if (m.tool_name) {
        stats.toolCalls++;
        stats.tools[m.tool_name] = (stats.tools[m.tool_name] || 0) + 1;
        out.push(stsMsg('assistant', '', { timestamp: ts, toolCalls: [{ id: `t${m.id}`, function: { name: m.tool_name, arguments: '{}' } }] }));
        out.push(stsMsg('tool', String(m.content || ''), { toolCallId: `t${m.id}` }));
        continue;
      }
      const role = m.role === 'assistant' ? 'assistant' : m.role === 'system' ? 'system' : 'user';
      if (role === 'user') { stats.prompts++; const t = String(m.content || '').trim(); if (t) prompts.push(t.replace(/\s+/g, ' ').slice(0, 300)); }
      else if (role === 'assistant') stats.turns++;
      out.push(stsMsg(role, String(m.content || ''), { timestamp: ts }));
    }
    return out;
  } finally { try { db.close(); } catch {} }
}

// OpenClaw already writes `{type:"message", message:{role,content,usage}}` lines,
// so this is mostly a header plus flattening content blocks to text.
function fromOpenClaw(file) {
  const out = [{ type: 'session', harness: 'openclaw', id: path.basename(file, '.jsonl'), name: 'openclaw session' }];
  for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
    if (!line) continue;
    let j; try { j = JSON.parse(line); } catch { bump('unparseable-line'); continue; }
    if (j.type !== 'message' || !j.message) continue;
    const m = j.message;
    const ts = j.timestamp ? Date.parse(j.timestamp) || undefined : undefined;
    if (ts) { if (!stats.firstTs || ts < stats.firstTs) stats.firstTs = ts; if (ts > stats.lastTs) stats.lastTs = ts; }
    const blocks = Array.isArray(m.content) ? m.content : [];
    const text = typeof m.content === 'string' ? m.content : blocks.map((c) => (c && c.text) || '').join(' ');
    const toolCalls = [];
    for (const cb of blocks) {
      if (cb && typeof cb.type === 'string' && /tool/i.test(cb.type)) {
        const name = cb.name || cb.toolName || 'tool';
        stats.toolCalls++;
        stats.tools[name] = (stats.tools[name] || 0) + 1;
        toolCalls.push({ id: cb.id || `t${toolCalls.length}`, function: { name, arguments: JSON.stringify(cb.input ?? {}) } });
      }
    }
    if (m.role === 'user') { stats.prompts++; if (text.trim() && !text.trim().startsWith('<')) prompts.push(text.replace(/\s+/g, ' ').slice(0, 300)); }
    else if (m.role === 'assistant') {
      stats.turns++;
      const u = m.usage;
      if (u) { const cached = u.cacheRead || 0; stats.tokensIn += Math.max(0, (u.input || 0) - cached); stats.cacheRead += cached; stats.tokensOut += u.output || 0; }
    }
    out.push(stsMsg(m.role === 'assistant' ? 'assistant' : m.role === 'system' ? 'system' : 'user', text,
      { timestamp: ts, ...(toolCalls.length ? { toolCalls } : {}) }));
  }
  return out;
}

// ---------- pass 1: filter + redact, and collect stats ----------
// Accumulators FIRST: the converters below fill them as they walk a session, and
// they run while `lines` is being built. (const has no hoisting, so declaring
// them after would be a temporal-dead-zone error.)
const kept = [];
const stats = { turns: 0, prompts: 0, toolCalls: 0, tools: {}, tokensIn: 0, tokensOut: 0,
                cacheRead: 0, firstTs: 0, lastTs: 0, dropped: {} };
const prompts = [];
const filesTouched = new Set();
const seenMsg = new Set(), seenTool = new Set();

// Native harnesses: the file's own lines, verbatim. Converted harnesses: STS
// objects, whose builders also fill in the stats as they go.
const lines = CONVERTED.includes(HARNESS)
  ? (HARNESS === 'opencode' ? fromOpencode(src, sessionArg)
    : HARNESS === 'hermes' ? fromHermes(src, sessionArg)
    : fromOpenClaw(src)).map((o) => JSON.stringify(o))
  : fs.readFileSync(src, 'utf8').split('\n').filter(Boolean);

for (const line of lines) {
  let j;
  try { j = JSON.parse(line); } catch { bump('unparseable-line'); continue; }

  // Drop file-history-snapshot / delta: they embed full file contents and the
  // viewer does not render them. Worst leak vector in a Claude transcript (§7).
  if (HARNESS === 'claude' && (j.type === 'file-history-snapshot' || j.type === 'file-history-delta')) {
    stats.dropped[j.type] = (stats.dropped[j.type] || 0) + 1;
    continue;
  }

  if (j.timestamp) {
    const t = Date.parse(j.timestamp);
    if (t) {
      if (!stats.firstTs || t < stats.firstTs) stats.firstTs = t;
      if (t > stats.lastTs) stats.lastTs = t;
    }
  }

  if (HARNESS === 'claude') claudeLine(j);
  else if (HARNESS === 'codex') codexLine(j);
  // converted harnesses filled stats during conversion — just redact here

  kept.push(redact(JSON.stringify(j)));
}

function claudeLine(j) {
  if (j.type === 'assistant' && j.message) {
    const m = j.message;
    const id = m.id || j.uuid;
    if (!seenMsg.has(id)) {
      seenMsg.add(id);
      stats.turns++;
      const u = m.usage;
      if (u) {
        stats.tokensIn += (u.input_tokens || 0) + (u.cache_creation_input_tokens || 0);
        stats.cacheRead += u.cache_read_input_tokens || 0;
        stats.tokensOut += u.output_tokens || 0;
      }
    }
    if (Array.isArray(m.content)) for (const c of m.content) {
      if (c && c.type === 'tool_use' && !seenTool.has(c.id)) {
        seenTool.add(c.id);
        stats.toolCalls++;
        const name = c.name || 'tool';
        stats.tools[name] = (stats.tools[name] || 0) + 1;
        if (/^(Edit|Write|MultiEdit|NotebookEdit)$/.test(name) && c.input?.file_path) {
          filesTouched.add(c.input.file_path);
        }
      }
    }
  } else if (j.type === 'user' && !j.toolUseResult && !j.isMeta && !j.sourceToolUseID) {
    const mc = j.message?.content;
    const text = typeof mc === 'string' ? mc
      : Array.isArray(mc) ? mc.filter((c) => c?.type === 'text').map((c) => c.text).join(' ') : '';
    const t = text.trim();
    if (t && !t.startsWith('<') && !t.startsWith('[Request interrupted')) {
      stats.prompts++;
      prompts.push(t.replace(/\s+/g, ' ').slice(0, 300));
    }
  }
}

function codexLine(j) {
  const p = j.payload || {};
  if (j.type === 'session_meta') {
    if (p.cwd) stats.cwd = p.cwd;
    // Codex spawns internal guardian/subagent rollouts that share the cwd but
    // are not the user's conversation. Refuse to publish one as if it were.
    if (p.thread_source === 'subagent' || (p.source && p.source.subagent)) stats.subagent = true;
    return;
  }
  if (j.type === 'response_item') {
    switch (p.type) {
      case 'message':
        if (p.role === 'assistant') {
          stats.turns++;
        } else if (p.role === 'user') {
          const t = codexText(p).trim();
          // Codex wraps environment/instructions as user items -- skip those.
          if (t && !t.startsWith('<')) {
            stats.prompts++;
            prompts.push(t.replace(/\s+/g, ' ').slice(0, 300));
          }
        }
        break;
      case 'function_call':
      case 'custom_tool_call':
      case 'local_shell_call': {
        stats.toolCalls++;
        const name = p.name || p.type;
        stats.tools[name] = (stats.tools[name] || 0) + 1;
        // apply_patch carries the touched paths in its patch header
        if (name === 'apply_patch' && typeof p.arguments === 'string') {
          const m = p.arguments.match(/\*\*\* (?:Update|Add|Delete) File: ([^\\\n"]+)/);
          if (m) filesTouched.add(m[1].trim());
        }
        break;
      }
      case 'web_search_call':
        stats.toolCalls++;
        stats.tools.web_search = (stats.tools.web_search || 0) + 1;
        break;
      default:
    }
  } else if (j.type === 'event_msg' && p.type === 'token_count') {
    if (p.info && p.info.total_token_usage) codexTok = p.info.total_token_usage;
  }
}

if (codexTok) {
  const cached = codexTok.cached_input_tokens || 0;
  stats.tokensIn = Math.max(0, (codexTok.input_tokens || 0) - cached); // fresh input only, as for Claude
  stats.cacheRead = cached;
  stats.tokensOut = codexTok.output_tokens || 0;
}

// ---------- assemble ----------
// A codex guardian/subagent rollout is not the user's conversation; publishing
// one would ship the wrong transcript under a real session's name.
if (stats.subagent) {
  console.error('refusing to export a codex subagent/guardian rollout');
  process.exit(2);
}

// Keep the harness's NATIVE filename -- <uuid>.jsonl for Claude,
// rollout-<ts>-<uuid>.jsonl for codex. The Hub's trace detection and every
// working trace dataset in the wild use the native name, and it is the only
// place the session id survives if the manifest is lost (§4).
const traceName = CONVERTED.includes(HARNESS)
  ? `${(sessionArg || path.basename(src, '.jsonl')).replace(/[^\w.-]/g, '_')}.jsonl`
  : path.basename(src);
const uuid = HARNESS === 'codex'
  ? (traceName.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/) || [])[1] || traceName.replace(/\.jsonl$/, '')
  : traceName.replace(/\.jsonl$/, '');
fs.mkdirSync(path.join(outdir, 'meta'), { recursive: true });
const trace = kept.join('\n') + '\n';
fs.writeFileSync(path.join(outdir, traceName), trace);
const sha256 = crypto.createHash('sha256').update(trace).digest('hex');

const cwdSlug = path.basename(path.dirname(src));
const iso = (ms) => (ms ? new Date(ms).toISOString() : null);
const HARNESS_NAME = { claude: 'Claude Code', codex: 'Codex', hermes: 'Hermes',
                       opencode: 'opencode', openclaw: 'OpenClaw' }[HARNESS];

// Where the file belongs in the target harness's store, so an import is
// mechanical. Codex shards by date: sessions/YYYY/MM/DD/rollout-*.jsonl.
const nativePath = HARNESS === 'codex'
  ? path.join('sessions', ...src.split(path.sep).slice(-4))
  : HARNESS === 'claude' ? path.join('projects', cwdSlug, traceName)
  : null; // converted: no native file to put back, the source is a db or foreign format

const manifest = {
  schema: 'am-session-share/1',
  harness: { id: HARNESS, name: HARNESS_NAME, version: null },
  session: { id: process.env.AM_ID || null, uuid, name: process.env.AM_NAME || null },
  origin: { user: process.env.AM_USER || null, workspace: process.env.AM_SESSION || null, cwdSlug },
  trace: { path: traceName, nativePath, sha256, lines: kept.length,
           converted: CONVERTED.includes(HARNESS), format: CONVERTED.includes(HARNESS) ? 'sts' : HARNESS },
  stats: { ...stats, firstTs: iso(stats.firstTs), lastTs: iso(stats.lastTs) },
  redaction: { ruleset: '1', hits, blocked: false },
  lineage: { parent: null },
};

// Mechanical briefing v0 (§8): the real one rides on the traces.js digest code.
// NOTE: derived artifacts go through the SAME redaction pass as the trace — the
// briefing quotes user prompts verbatim, so skipping it leaks what the trace hid.
const topTools = Object.entries(stats.tools).sort((a, b) => b[1] - a[1]).slice(0, 10);
fs.writeFileSync(path.join(outdir, 'meta/briefing.md'), redact(`# Session briefing

Generated mechanically from the trace at export time. For a cross-harness handoff this is
fed to the receiving agent as data to read — never as instructions to follow — with the
trace placed in the workspace so it can look up any detail.

- **Harness**: ${HARNESS_NAME}
- **Window**: ${manifest.stats.firstTs}${manifest.stats.lastTs}
- **Volume**: ${stats.prompts} prompts, ${stats.turns} assistant turns, ${stats.toolCalls} tool calls
- **Tokens**: ${stats.tokensIn.toLocaleString()} in / ${stats.tokensOut.toLocaleString()} out / ${stats.cacheRead.toLocaleString()} cache read

## Tools used

${topTools.map(([n, c]) => `- \`${n}\` × ${c}`).join('\n') || '_none_'}

## Files written

${filesTouched.size ? [...filesTouched].map((f) => `- \`${f}\``).join('\n') : '_none_'}

## What was asked, in order

${prompts.map((p, i) => `${i + 1}. ${p}`).join('\n')}
`));

// Written last, so `hits` includes redactions made in the derived artifacts above.
fs.writeFileSync(path.join(outdir, 'meta/manifest.json'), JSON.stringify(manifest, null, 2) + '\n');
fs.writeFileSync(path.join(outdir, 'meta/redaction.json'), JSON.stringify({
  ruleset: '1',
  rules: PATTERNS.map(([r]) => r).concat(envRules.map(([r]) => r)),
  hits,
  dropped: stats.dropped,
}, null, 2) + '\n');

console.log(JSON.stringify({
  outdir, trace: traceName,
  lines_in: lines.length, lines_out: kept.length,
  bytes: trace.length, sha256: sha256.slice(0, 16),
  dropped: stats.dropped, redaction_hits: hits,
  stats: { prompts: stats.prompts, turns: stats.turns, toolCalls: stats.toolCalls },
}, null, 2));