UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

743 lines • 35.8 kB
"use strict"; /** * exportBugReport / describeBugReport — a bug report IS the evidence. * * The usual bug report is a person's memory of a run: "it said the wrong * thing, I think it called the search tool twice". The run itself — the * timeline, the state, the chart, the narrative — is sitting right there in * the process and never leaves it. This turns that around: the report is the * run, packaged, with the prose attached. * * ## Two calls, because consent needs two * * 1. {@link describeBugReport} — "here is what would be sent." A manifest of * SELECTABLE UNITS: each conversation with its event and turn counts and * its size, each derived file, the redacted keys by name, the total, and * a loud warning with trim hints if it is too big. * 2. {@link exportBugReport} — "send exactly these." The reporter's ticked * unit ids come back as `include`, and the bundle carries only those. * * One call does work for a server-side reporter that has no human in front of * it; the two-call shape is what makes a browser consent dialog possible at * all, because a dialog cannot ask about a blob it has not measured. * * ## What is in the bundle (layout 2 — `manifest.manifestVersion`) * * | file | what it is | * |---|---| * | `manifest.json` | this manifest — always present, never a selectable unit | * | `envelope.json` | the run as a `RecordingEnvelope`: the archive contract, with the canon `{ snapshot, events, structure }` under `recording` | * | `recording.json` | the bare recording — ONLY when the envelope's run facts were not available, with the manifest naming the missing one | * | `conversations/<id>.json` | one file per conversation, when there is more than one run | * | `conversation.json` | the readable transcript, derived from the events | * | `narrative.txt` | the narrative recorder's lines, when one was attached | * | `environment.json` | the HOST that ran it + the reporter's prose | * * The evidence file is named for what it is, and there is never more than one * of it: a bundle carrying both an envelope and a copy of the recording it * already contains would double the biggest file in the archive — and the * archive is store-only, so doubling is real bytes, against a ceiling a * reporter has to fit under. * * `environment.json` is the host and NOTHING that identifies a machine: Node * version, platform, architecture. **No username, no hostname, no working * directory, no environment variables, no file paths.** A bug report should not * be the way an internal directory layout leaves a company. The producer * versions it used to repeat now live where the archive contract stamps them, * in `envelope.json`'s `producer` — one fact, one place. (The manifest's own * `environment` block still prints both for the human reading the issue; it is * a summary, not a second archive.) * * ## Redaction is already done, and the manifest proves it * * The recording arrives ALREADY redacted when its run had a policy (an * agent's `Agent.create({ redact })`): footprintjs scrubs state at commit * time, the snapshot a recording carries is the redacted view * (`getLastSnapshot()`), and every event was served under the same rule * before anything recorded it — so a redacted value was never in what this * reads. Nothing here scrubs anything — it would be too late to matter and a * second policy could only disagree with the first. What this * does do is LIST the redacted keys by name, derived from the placeholders * actually present in the evidence, so a human consenting to the bundle can * see which secrets were protected. A key that is not on that list was not * redacted, and the honest reading of an empty list is "no policy was set" — * which the manifest says in a note. * * @example The consent flow * ```ts * const manifest = describeBugReport(recording); * // …show manifest.units to the human; they tick some… * const report = exportBugReport(recording, { * include: ['conv-1', 'file-narrative', 'file-environment'], * title: 'Agent answered with a stale price', * stepsToReproduce: '1. ask for the price\n2. update it\n3. ask again', * expected: 'the new price', * actual: 'the old one', * }); * fs.writeFileSync(report.filename, report.zip); * ``` */ Object.defineProperty(exports, "__esModule", { value: true }); exports.slugify = exports.bundleFilename = exports.exportBugReport = exports.describeBugReport = exports.formatBytes = void 0; const openRecording_js_1 = require("../trace-toolpack/openRecording.js"); const libraryVersion_js_1 = require("../lib/libraryVersion.js"); const wireJson_js_1 = require("../lib/wireJson.js"); const envelope_js_1 = require("./envelope.js"); const transcript_js_1 = require("./transcript.js"); const zip_js_1 = require("./zip.js"); const linearText_js_1 = require("../lib/linearText.js"); /** 20 MB. Past this a bundle stops being something a person reviews. */ const DEFAULT_WARN_OVER_BYTES = 20 * 1024 * 1024; const encoder = new TextEncoder(); const isFn = (value) => typeof value === 'function'; const asRecord = (value) => typeof value === 'object' && value !== null ? value : undefined; /** * Turn whatever the caller had into recordings. * * The interesting arm is the runner: a finished runner can give up its * snapshot and its chart but NOT its events, because the dispatcher drops * events nobody subscribed to. Rather than ship a recording with a silently * empty timeline, that arm produces a note the manifest carries and the issue * body prints. The fix is one line at the call site (`recordRun(agent)` before * the run), and saying so is worth more than a blank panel. */ function normalizeOne(source, index) { const candidate = asRecord(source); if (!candidate) { throw new TypeError(`exportBugReport: source #${index + 1} is ${typeof source}, not a recording. Pass a ` + `Recording ({ snapshot, events, structure }), the handle from recordRun(agent), or a ` + `runner.`); } // A RunRecorder — the happy path, already wired. The HANDLE rides on to the // envelope builder: it is the only source that can prove its drop count. if (isFn(candidate.toRecording)) { const recording = candidate.toRecording(); return { ...withIds(recording), notes: [], envelope: { source: source, countsDrops: true }, }; } // A Runner — two of the three pieces, honestly labelled. if (isFn(candidate.getLastSnapshot) && isFn(candidate.getSpec)) { const snapshot = candidate.getLastSnapshot(); if (snapshot === undefined) { throw new TypeError(`exportBugReport: source #${index + 1} is a runner that has not run yet — there is ` + `no snapshot to report. Run it, then export; or pass the recording from ` + `recordRun(agent).`); } const spec = candidate.getSpec(); const recording = { snapshot, events: [], structure: asRecord(spec)?.buildTimeStructure, }; return { ...withIds(recording), envelope: { source: recording, countsDrops: false }, notes: [ 'Exported from a runner AFTER its run, so this bundle has the state and the chart ' + 'but NO event timeline and no transcript: events are delivered live and dropped ' + 'when nothing is listening. For the complete three-part recording, call ' + 'recordRun(agent) before run() and export the recording it returns.', ], }; } // A Recording, live or parsed back from JSON. if ('snapshot' in candidate || 'events' in candidate || 'structure' in candidate) { const recording = { snapshot: candidate.snapshot, events: Array.isArray(candidate.events) ? candidate.events : [], structure: candidate.structure, }; const notes = Array.isArray(candidate.events) && candidate.events.length > 0 ? [] : [ 'This recording carries no events, so the bundle has no timeline and no ' + 'transcript. Events are collected as a run happens — call recordRun(agent) ' + 'BEFORE run(), then export what it gives you.', ]; return { ...withIds(recording), notes, envelope: { source: recording, countsDrops: false } }; } throw new TypeError(`exportBugReport: source #${index + 1} is not a recording, a recordRun handle, or a ` + `runner. A recording is { snapshot, events, structure } — the shape recordRun(agent)` + `.toRecording() returns.`); } /** Read the run / session ids off the first event that carries them. */ function withIds(recording) { for (const event of recording.events ?? []) { const meta = asRecord(asRecord(event)?.meta); if (!meta) continue; const runId = typeof meta.runId === 'string' ? meta.runId : undefined; const sessionId = typeof meta.sessionId === 'string' ? meta.sessionId : undefined; if (runId !== undefined || sessionId !== undefined) { return { recording, ...(runId && { runId }), ...(sessionId && { sessionId }) }; } } // No events: the snapshot's own run id is the next best key. const runId = asRecord(recording.snapshot)?.runId; return { recording, ...(typeof runId === 'string' && { runId }) }; } /** * Group recordings into conversations. * * A `runId` is per `run()`; a session outlives it. So several runs of one * session are ONE conversation — which is what a human means by "the chat that * went wrong", and therefore the right unit to consent to. Runs with no session * stand alone under their own run id. */ function groupConversations(sources) { const order = []; const byKey = new Map(); sources.forEach((source, index) => { const key = source.sessionId ?? source.runId ?? `run-${index + 1}`; if (!byKey.has(key)) { byKey.set(key, []); order.push(key); } byKey.get(key).push(source); }); return order.map((key, index) => { const group = byKey.get(key); const events = group.flatMap((entry) => [...(entry.recording.events ?? [])]); const runIds = group.map((entry) => entry.runId).filter((id) => Boolean(id)); const sessionId = group[0]?.sessionId; const transcript = (0, transcript_js_1.deriveTranscript)(events); return { id: `conv-${index + 1}`, ...(sessionId !== undefined && { sessionId }), runIds, recordings: group.map((entry) => entry.recording), sources: group.map((entry) => entry.envelope), events, ...(transcript !== undefined && { transcript }), }; }); } // ─── Redaction: names only, read off the evidence itself ───────────── /** What footprintjs writes in place of a value the run's policy covered. */ const REDACTION_PLACEHOLDERS = new Set(['[REDACTED]', 'REDACTED']); /** A bundle is a tree of JSON; this bounds the walk rather than trusting it. */ const MAX_SCAN_DEPTH = 24; /** * Collect the KEY NAMES whose value is a redaction placeholder. * * Names only, never paths with values attached, and never the values (there * are none — they were scrubbed upstream). This is what lets a human consent * knowingly: "apiKey and customerSsn were protected; everything else in here * is real." */ function collectRedactedKeys(value, into, depth = 0) { if (depth > MAX_SCAN_DEPTH || value === null || typeof value !== 'object') return; if (Array.isArray(value)) { for (const item of value) collectRedactedKeys(item, into, depth + 1); return; } for (const [key, child] of Object.entries(value)) { if (typeof child === 'string' && REDACTION_PLACEHOLDERS.has(child)) into.add(key); else collectRedactedKeys(child, into, depth + 1); } } /** Every file in the bundle — recordings included — by the wire rule (`lib/wireJson.ts`). */ const json = (value) => `${(0, wireJson_js_1.toWireJson)(value, 2)}\n`; const byteLength = (text) => encoder.encode(text).length; function hostOf(appVersion) { const proc = globalThis .process; return { node: typeof proc?.version === 'string' ? proc.version : 'unknown', platform: typeof proc?.platform === 'string' ? proc.platform : 'unknown', arch: typeof proc?.arch === 'string' ? proc.arch : 'unknown', ...(appVersion !== undefined && { appVersion }), }; } /** * The manifest's environment block — the host PLUS the producer versions. * * The versions are here and not in `environment.json` because this block is a * SUMMARY for a human: it is what a consent dialog shows and what the issue * body prints, and it is the only version statement a reader gets when a * conversation's run facts were missing and no envelope could be stamped. The * archive's copy of the same two facts is stamped once, by the envelope. */ function environmentOf(appVersion) { return { agentfootprint: (0, libraryVersion_js_1.libraryVersion)(), footprintjs: (0, libraryVersion_js_1.engineVersion)(), ...hostOf(appVersion), }; } function plan(input, fields, runFacts) { // `describeBugReport` has no `fields` to carry them on, so the facts are their // own parameter — but an export passes both, and reading only the parameter // would silently drop a caller's `run` if a future call site forgot to thread // it. Prefer the explicit one, fall back to the options object. const facts = runFacts ?? fields?.run; const sources = (Array.isArray(input) ? input : [input]); if (sources.length === 0) { throw new TypeError('exportBugReport: no run to report. Pass a recording, a recordRun handle, a runner, or ' + 'an array of them — a bug report with no evidence is the thing this exists to replace.'); } const normalized = sources.map(normalizeOne); const conversations = groupConversations(normalized); const notes = [...new Set(normalized.flatMap((entry) => entry.notes))]; const redacted = new Set(); for (const conversation of conversations) { for (const recording of conversation.recordings) collectRedactedKeys(recording, redacted); } const redactedKeys = [...redacted].sort(); if (redactedKeys.length === 0) { notes.push('No redaction placeholders are present, which means the run had no RedactionPolicy (or ' + 'nothing it covered was written). Everything in this bundle is the real value.'); } // ── Conversation units ─────────────────────────────────────────── const single = conversations.length === 1 && conversations[0].recordings.length === 1; const conversationFiles = new Map(); const derivedFiles = new Map(); const units = []; // Refusals are collected by REASON: three conversations that all lack the // same stated fact are one note naming three ids, not three notes. One reason // means one refused field, so the field rides along with the ids. const refusals = new Map(); for (const conversation of conversations) { const stamped = (0, envelope_js_1.stampConversation)(conversation.sources, facts); if (stamped.refusal !== undefined) { const named = refusals.get(stamped.refusal) ?? { ids: [], ...(stamped.field !== undefined && { field: stamped.field }), }; named.ids.push(conversation.id); refusals.set(stamped.refusal, named); } // One run, one conversation → one file at the root of the bundle, named for // what is actually in it: the archive envelope, or the bare recording when // the envelope's run facts were not available. const name = single ? stamped.envelopes ? 'envelope.json' : 'recording.json' : `conversations/${conversation.id}.json`; const body = single ? stamped.envelopes ? stamped.envelopes[0] : conversation.recordings[0] : { id: conversation.id, ...(conversation.sessionId !== undefined && { sessionId: conversation.sessionId }), runIds: conversation.runIds, // One key or the other, never both: the envelope already holds the // recording, and a store-only zip pays for every duplicated byte. ...(stamped.envelopes ? { envelopes: stamped.envelopes } : { recordings: conversation.recordings }), }; const text = json(body); const turnCount = conversation.transcript?.turns.length ?? 0; const file = { name, text, unitId: conversation.id, eventCount: conversation.events.length, turnCount, }; conversationFiles.set(conversation.id, file); units.push({ id: conversation.id, kind: 'conversation', label: conversationLabel(conversation, turnCount), bytes: byteLength(text), eventCount: conversation.events.length, turnCount, runCount: conversation.recordings.length, ...(conversation.sessionId !== undefined && { sessionId: conversation.sessionId }), enveloped: stamped.envelopes !== undefined, files: [name], }); } // A conversation that could not be enveloped is still in the bundle, whole — // what it lost is the archive contract around it, and losing that quietly is // exactly the drift this fold exists to end. So the reason is stated, by // conversation id, with the one line that supplies the missing fact. for (const [reason, { ids, field }] of refusals) { // Only two of the envelope's facts can be stated at THIS door. When the // refusal names any of the others, the builder's "state run.startedAt" // advice is addressed to persistRecording, not to a bug reporter — saying // so beats sending them looking for an option that is not there. const settableHere = field === 'complete' || field === 'droppedEvents'; notes.push(`${ids.join(', ')} ride${ids.length === 1 ? 's' : ''} as the bare recording rather than ` + `an archive envelope: ${reason}` + (settableHere ? '' : ' — of these, exportBugReport can state only run.complete and run.droppedEvents; ' + "every other envelope fact is read from the recording's own events, so this one is " + 'fixed at recording time, not at export time.')); } // ── Derived file units ─────────────────────────────────────────── const buildTranscript = (selected) => { const withTranscripts = selected.filter((conversation) => conversation.transcript); if (withTranscripts.length === 0) return undefined; return { name: 'conversation.json', unitId: 'file-conversation', text: json({ conversations: withTranscripts.map((conversation) => ({ id: conversation.id, ...(conversation.sessionId !== undefined && { sessionId: conversation.sessionId }), turns: conversation.transcript.turns, })), }), }; }; const wholeTranscript = buildTranscript(conversations); if (wholeTranscript) { derivedFiles.set('file-conversation', buildTranscript); units.push({ id: 'file-conversation', kind: 'file', label: 'conversation.json — the readable transcript (prompts, model replies, tool calls)', bytes: byteLength(wholeTranscript.text), files: ['conversation.json'], }); } else { notes.push('No transcript: these events carry no turn, LLM or tool activity to read back, so ' + 'conversation.json is not in the bundle.'); } const buildNarrative = (selected) => { const lines = selected.flatMap((conversation) => conversation.recordings.flatMap((recording) => { const snapshot = (asRecord(recording.snapshot) ?? {}); const said = (0, openRecording_js_1.narrativeFrom)(snapshot); return said ? [`── ${conversation.id} ──`, ...said] : []; })); if (lines.length === 0) return undefined; return { name: 'narrative.txt', unitId: 'file-narrative', text: `${lines.join('\n')}\n` }; }; const wholeNarrative = buildNarrative(conversations); if (wholeNarrative) { derivedFiles.set('file-narrative', buildNarrative); units.push({ id: 'file-narrative', kind: 'file', label: 'narrative.txt — the run in sentences, from the attached narrative recorder', bytes: byteLength(wholeNarrative.text), files: ['narrative.txt'], }); } else { notes.push('No narrative.txt: no narrative recorder was attached to this run. Attach ' + "footprintjs's narrative() before running to get the run in sentences."); } // The host is the same whichever conversations ride along. What is NOT here // any more: `agentfootprint` and `footprintjs`. Those are producer facts, and // the archive contract stamps them in `envelope.json` — a second copy in a // second file is a second thing to keep in step, which is how the two shapes // drifted in the first place. const environmentFile = { name: 'environment.json', unitId: 'file-environment', text: json({ ...hostOf(fields?.appVersion), ...(fields && { report: { title: fields.title, stepsToReproduce: fields.stepsToReproduce, expected: fields.expected, actual: fields.actual, ...(fields.appVersion !== undefined && { appVersion: fields.appVersion }), }, }), }), }; derivedFiles.set('file-environment', () => environmentFile); units.push({ id: 'file-environment', kind: 'file', label: 'environment.json — the host that ran it: Node, platform, architecture ' + '(no machine identity; the library versions are stamped in the envelope)', bytes: byteLength(environmentFile.text), files: ['environment.json'], }); return { conversations, units, conversationFiles, derivedFiles, notes, redactedKeys }; } function conversationLabel(conversation, turnCount) { const who = conversation.sessionId ? `session ${conversation.sessionId}` : conversation.runIds[0] ?? 'one run'; const runs = conversation.recordings.length; return (`${conversation.id} — ${who}: ${runs} run${runs === 1 ? '' : 's'}, ` + `${turnCount} turn${turnCount === 1 ? '' : 's'}, ${conversation.events.length} events`); } /** Assemble the manifest for a given selection. */ function manifestFor(args) { const { plan: planned, selected, files, createdAt, limitBytes, fields } = args; const selectedSet = new Set(selected); const excludedUnits = planned.units.filter((unit) => !selectedSet.has(unit.id)); const includedConversations = planned.conversations.filter((conversation) => selectedSet.has(conversation.id)); const totalBytes = files.reduce((sum, file) => sum + file.bytes, 0); const warnings = []; const notes = [...planned.notes]; const excludedConversationUnits = excludedUnits.filter((unit) => unit.kind === 'conversation'); if (excludedConversationUnits.length > 0) { // Stated, loudly: a maintainer reading turn 4 must know turns 1-3 were not // withheld by accident. warnings.push(`The reporter deliberately excluded ${excludedConversationUnits.length} of ` + `${planned.units.filter((unit) => unit.kind === 'conversation').length} ` + `conversations from this bundle. What is here is a SUBSET of what the run produced.`); } const excludedFileUnits = excludedUnits.filter((unit) => unit.kind === 'file'); if (excludedFileUnits.length > 0) { notes.push(`Excluded by the reporter: ${excludedFileUnits .map((unit) => unit.files.join(', ')) .join(', ')}.`); } const oversize = totalBytes > limitBytes ? { totalBytes, limitBytes, trimHints: trimHints(planned.units, selectedSet, totalBytes, limitBytes), } : undefined; if (oversize) { warnings.push(`This bundle is ${formatBytes(totalBytes)}, over the ${formatBytes(limitBytes)} ceiling. ` + `Trim it by leaving units out: ${oversize.trimHints.join(' ')}`); } return { manifestVersion: 2, createdAt: createdAt.toISOString(), ...(fields && { report: { title: fields.title, stepsToReproduce: fields.stepsToReproduce, expected: fields.expected, actual: fields.actual, ...(fields.appVersion !== undefined && { appVersion: fields.appVersion }), }, }), units: planned.units, selected, excluded: { conversations: excludedConversationUnits.length, files: excludedFileUnits.length, events: excludedConversationUnits.reduce((sum, unit) => sum + (unit.eventCount ?? 0), 0), turns: excludedConversationUnits.reduce((sum, unit) => sum + (unit.turnCount ?? 0), 0), unitIds: excludedUnits.map((unit) => unit.id), }, files, counts: { conversations: includedConversations.length, runs: includedConversations.reduce((sum, conversation) => sum + conversation.recordings.length, 0), events: includedConversations.reduce((sum, conversation) => sum + conversation.events.length, 0), turns: includedConversations.reduce((sum, conversation) => sum + (conversation.transcript?.turns.length ?? 0), 0), files: files.length, }, totalBytes, redactedKeys: planned.redactedKeys, warnings, notes, ...(oversize && { oversize }), environment: environmentOf(fields?.appVersion), }; } /** * A size a human reads. KB under a megabyte, MB above it — a ceiling reported * as "0.0 MB" teaches nothing, and these strings are the whole content of a * refusal. */ function formatBytes(bytes) { if (bytes < 1024) return `${bytes} bytes`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } exports.formatBytes = formatBytes; /** * Name the units worth dropping, biggest first, until the bundle would fit. * * A hint that says "make it smaller" is not a hint. Each of these names a unit * id the caller can pass (or withhold) in `include`, and what dropping it saves. */ function trimHints(units, selected, totalBytes, limitBytes) { const droppable = units .filter((unit) => selected.has(unit.id) && unit.kind === 'conversation') .sort((left, right) => right.bytes - left.bytes); const hints = []; let remaining = totalBytes; for (const unit of droppable) { if (remaining <= limitBytes) break; // Never suggest dropping the last conversation: a bundle with no evidence // is refused at export, so a hint that leads there is a dead end. if (hints.length === droppable.length - 1) break; remaining -= unit.bytes; hints.push(`Drop ${unit.id} (${formatBytes(unit.bytes)}) → ${formatBytes(remaining)}.`); } if (remaining > limitBytes) { const others = units.filter((unit) => selected.has(unit.id) && unit.kind === 'file'); for (const unit of others) { hints.push(`Drop ${unit.id} (${formatBytes(unit.bytes)}).`); } hints.push('Still over: record a shorter reproduction, or attach the bundle to the issue by hand.'); } return hints; } // ─── The two entry points ──────────────────────────────────────────── /** * Measure a bug report before anything leaves — the consent step. * * Every unit is "selected" in the manifest this returns, because nothing has * been chosen yet: it is the offer, with sizes and counts attached, for a human * (or a policy) to narrow. Show `units` to the reporter; pass the ids they keep * to {@link exportBugReport} as `include`. * * Cheap enough to call on every dialog open: it serializes the files to measure * them, and throws them away. * * @param input a recording, a `recordRun` handle, a runner, or an array. * @param options size ceiling and a fixed timestamp. */ function describeBugReport(input, options = {}) { const planned = plan(input, undefined, options.run); const createdAt = options.now ?? new Date(); const limitBytes = options.warnOverBytes ?? DEFAULT_WARN_OVER_BYTES; const selected = planned.units.map((unit) => unit.id); const files = filesFor(planned, new Set(selected)).map(summaryOf); // The description does not count manifest.json: it is not written until the // export, and its size depends on the selection it will describe. The offer's // total is therefore a KB or two under the bundle's — stated here rather than // guessed at with a placeholder. return manifestFor({ plan: planned, selected, files, createdAt, limitBytes }); } exports.describeBugReport = describeBugReport; /** * The files for one selection. * * Conversation units contribute their own recording file; file units are * REBUILT over the selected conversations, which is what keeps a deselected * conversation out of the transcript and the narrative as well as out of its * own file. */ function filesFor(planned, selected) { const chosen = planned.conversations.filter((conversation) => selected.has(conversation.id)); const out = []; for (const unit of planned.units) { if (!selected.has(unit.id)) continue; if (unit.kind === 'conversation') { const file = planned.conversationFiles.get(unit.id); if (file) out.push(file); continue; } const file = planned.derivedFiles.get(unit.id)?.(chosen); if (file) out.push(file); } return out; } function summaryOf(file) { return { name: file.name, bytes: byteLength(file.text), ...(file.unitId !== undefined && { unitId: file.unitId }), ...(file.eventCount !== undefined && { eventCount: file.eventCount }), ...(file.turnCount !== undefined && { turnCount: file.turnCount }), }; } /** * Build the bundle: the manifest, the named files, and a real zip of them. * * @param input a recording, a `recordRun` handle, a runner, or an array. * @param options the reporter's prose, plus `include` — the ids from * {@link describeBugReport} that the reporter consented to. * * @throws TypeError naming the unknown id when `include` names a unit that * does not exist, and naming the available conversations when the * selection would carry no evidence at all. */ function exportBugReport(input, options) { if (!options || typeof options.title !== 'string' || options.title.trim() === '') { throw new TypeError('exportBugReport: `title` is required — it becomes the issue title, and an untitled ' + 'report is one nobody triages. `stepsToReproduce`, `expected` and `actual` are ' + 'required with it.'); } const planned = plan(input, options, options.run); const known = new Set(planned.units.map((unit) => unit.id)); const selected = options.include ? [...new Set(options.include)] : [...known]; for (const id of selected) { if (!known.has(id)) { throw new TypeError(`exportBugReport: \`include\` names '${id}', which is not a unit of this report. ` + `Available: ${[...known].join(', ')}. Take these ids from ` + `describeBugReport(input).units — they are stable within one description, not ` + `across runs.`); } } const conversationIds = planned.units .filter((unit) => unit.kind === 'conversation') .map((unit) => unit.id); if (!selected.some((id) => conversationIds.includes(id))) { throw new TypeError('exportBugReport: the selection includes no conversation, so the bundle would carry ' + 'the reporter’s prose and nothing to reproduce from — which is the ordinary bug ' + `report this exists to replace. Include at least one of: ${conversationIds.join(', ')}.`); } const createdAt = options.now ?? new Date(); const limitBytes = options.warnOverBytes ?? DEFAULT_WARN_OVER_BYTES; const selectedSet = new Set(selected); const planFiles = filesFor(planned, selectedSet); // The manifest counts itself: it is a file in the bundle, and a total that // omits it would be a total that lies. Two passes — measure, then restate the // total with the manifest's own size folded in. const draft = manifestFor({ plan: planned, selected, files: planFiles.map(summaryOf), createdAt, limitBytes, fields: options, }); const draftText = json(draft); const manifestSummary = { name: 'manifest.json', bytes: byteLength(draftText), }; const manifest = manifestFor({ plan: planned, selected, files: [manifestSummary, ...planFiles.map(summaryOf)], createdAt, limitBytes, fields: options, }); const files = [ fileOf('manifest.json', json(manifest)), ...planFiles.map((file) => fileOf(file.name, file.text)), ]; const zip = (0, zip_js_1.zipStore)(files.map((file) => ({ name: file.name, data: file.bytes })), { modified: createdAt }); return { manifest, files, zip, filename: bundleFilename(options.title, createdAt) }; } exports.exportBugReport = exportBugReport; function fileOf(name, text) { return { name, text, bytes: encoder.encode(text) }; } /** `2026-08-11-agent-answered-with-a-stale-price.zip`. */ function bundleFilename(title, createdAt) { return `${createdAt.toISOString().slice(0, 10)}-${slugify(title)}.zip`; } exports.bundleFilename = bundleFilename; /** Lower-case, ASCII, hyphenated, bounded — a filename, not a sentence. */ function slugify(title) { const hyphenated = title.toLowerCase().replace(/[^a-z0-9]+/g, '-'); const slug = (0, linearText_js_1.trimTrailing)((0, linearText_js_1.trimBoth)(hyphenated, linearText_js_1.isHyphen).slice(0, 60), linearText_js_1.isHyphen); return slug || 'bug-report'; } exports.slugify = slugify; //# sourceMappingURL=build.js.map