agent-manager-template / scripts /share-session.mjs
Thomas Wolf
Session sharing: publish a session's trace as a Hub dataset, and read one in a panel (#10)
63bdc81 unverified
Raw
History Blame Contribute Delete
20.5 kB
#!/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));