agentfootprint 9.8.0 → 9.9.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.
Files changed (83) hide show
  1. package/dist/adapters/observability/audit.js +2 -28
  2. package/dist/adapters/observability/audit.js.map +1 -1
  3. package/dist/adapters/observability/githubBugReporter.js +461 -0
  4. package/dist/adapters/observability/githubBugReporter.js.map +1 -0
  5. package/dist/adapters/observability/githubDeviceSignIn.js +263 -0
  6. package/dist/adapters/observability/githubDeviceSignIn.js.map +1 -0
  7. package/dist/esm/adapters/observability/audit.js +1 -27
  8. package/dist/esm/adapters/observability/audit.js.map +1 -1
  9. package/dist/esm/adapters/observability/githubBugReporter.d.ts +154 -0
  10. package/dist/esm/adapters/observability/githubBugReporter.js +457 -0
  11. package/dist/esm/adapters/observability/githubBugReporter.js.map +1 -0
  12. package/dist/esm/adapters/observability/githubDeviceSignIn.d.ts +131 -0
  13. package/dist/esm/adapters/observability/githubDeviceSignIn.js +259 -0
  14. package/dist/esm/adapters/observability/githubDeviceSignIn.js.map +1 -0
  15. package/dist/esm/lib/bug-report/build.d.ts +103 -0
  16. package/dist/esm/lib/bug-report/build.js +648 -0
  17. package/dist/esm/lib/bug-report/build.js.map +1 -0
  18. package/dist/esm/lib/bug-report/index.d.ts +14 -0
  19. package/dist/esm/lib/bug-report/index.js +13 -0
  20. package/dist/esm/lib/bug-report/index.js.map +1 -0
  21. package/dist/esm/lib/bug-report/transcript.d.ts +61 -0
  22. package/dist/esm/lib/bug-report/transcript.js +124 -0
  23. package/dist/esm/lib/bug-report/transcript.js.map +1 -0
  24. package/dist/esm/lib/bug-report/types.d.ts +206 -0
  25. package/dist/esm/lib/bug-report/types.js +12 -0
  26. package/dist/esm/lib/bug-report/types.js.map +1 -0
  27. package/dist/esm/lib/bug-report/zip.d.ts +71 -0
  28. package/dist/esm/lib/bug-report/zip.js +202 -0
  29. package/dist/esm/lib/bug-report/zip.js.map +1 -0
  30. package/dist/esm/lib/libraryVersion.d.ts +23 -0
  31. package/dist/esm/lib/libraryVersion.js +46 -0
  32. package/dist/esm/lib/libraryVersion.js.map +1 -0
  33. package/dist/esm/lib/trace-toolpack/openRecording.d.ts +17 -0
  34. package/dist/esm/lib/trace-toolpack/openRecording.js +8 -2
  35. package/dist/esm/lib/trace-toolpack/openRecording.js.map +1 -1
  36. package/dist/esm/observability-providers.d.ts +2 -0
  37. package/dist/esm/observability-providers.js +9 -0
  38. package/dist/esm/observability-providers.js.map +1 -1
  39. package/dist/esm/observe.d.ts +1 -0
  40. package/dist/esm/observe.js +6 -0
  41. package/dist/esm/observe.js.map +1 -1
  42. package/dist/lib/bug-report/build.js +656 -0
  43. package/dist/lib/bug-report/build.js.map +1 -0
  44. package/dist/lib/bug-report/index.js +18 -0
  45. package/dist/lib/bug-report/index.js.map +1 -0
  46. package/dist/lib/bug-report/transcript.js +128 -0
  47. package/dist/lib/bug-report/transcript.js.map +1 -0
  48. package/dist/lib/bug-report/types.js +13 -0
  49. package/dist/lib/bug-report/types.js.map +1 -0
  50. package/dist/lib/bug-report/zip.js +207 -0
  51. package/dist/lib/bug-report/zip.js.map +1 -0
  52. package/dist/lib/libraryVersion.js +51 -0
  53. package/dist/lib/libraryVersion.js.map +1 -0
  54. package/dist/lib/trace-toolpack/openRecording.js +9 -2
  55. package/dist/lib/trace-toolpack/openRecording.js.map +1 -1
  56. package/dist/observability-providers.js +12 -1
  57. package/dist/observability-providers.js.map +1 -1
  58. package/dist/observe.js +14 -6
  59. package/dist/observe.js.map +1 -1
  60. package/dist/types/adapters/observability/audit.d.ts.map +1 -1
  61. package/dist/types/adapters/observability/githubBugReporter.d.ts +155 -0
  62. package/dist/types/adapters/observability/githubBugReporter.d.ts.map +1 -0
  63. package/dist/types/adapters/observability/githubDeviceSignIn.d.ts +132 -0
  64. package/dist/types/adapters/observability/githubDeviceSignIn.d.ts.map +1 -0
  65. package/dist/types/lib/bug-report/build.d.ts +104 -0
  66. package/dist/types/lib/bug-report/build.d.ts.map +1 -0
  67. package/dist/types/lib/bug-report/index.d.ts +15 -0
  68. package/dist/types/lib/bug-report/index.d.ts.map +1 -0
  69. package/dist/types/lib/bug-report/transcript.d.ts +62 -0
  70. package/dist/types/lib/bug-report/transcript.d.ts.map +1 -0
  71. package/dist/types/lib/bug-report/types.d.ts +207 -0
  72. package/dist/types/lib/bug-report/types.d.ts.map +1 -0
  73. package/dist/types/lib/bug-report/zip.d.ts +72 -0
  74. package/dist/types/lib/bug-report/zip.d.ts.map +1 -0
  75. package/dist/types/lib/libraryVersion.d.ts +24 -0
  76. package/dist/types/lib/libraryVersion.d.ts.map +1 -0
  77. package/dist/types/lib/trace-toolpack/openRecording.d.ts +17 -0
  78. package/dist/types/lib/trace-toolpack/openRecording.d.ts.map +1 -1
  79. package/dist/types/observability-providers.d.ts +2 -0
  80. package/dist/types/observability-providers.d.ts.map +1 -1
  81. package/dist/types/observe.d.ts +1 -0
  82. package/dist/types/observe.d.ts.map +1 -1
  83. package/package.json +1 -1
@@ -0,0 +1,656 @@
1
+ "use strict";
2
+ /**
3
+ * exportBugReport / describeBugReport — a bug report IS the evidence.
4
+ *
5
+ * The usual bug report is a person's memory of a run: "it said the wrong
6
+ * thing, I think it called the search tool twice". The run itself — the
7
+ * timeline, the state, the chart, the narrative — is sitting right there in
8
+ * the process and never leaves it. This turns that around: the report is the
9
+ * run, packaged, with the prose attached.
10
+ *
11
+ * ## Two calls, because consent needs two
12
+ *
13
+ * 1. {@link describeBugReport} — "here is what would be sent." A manifest of
14
+ * SELECTABLE UNITS: each conversation with its event and turn counts and
15
+ * its size, each derived file, the redacted keys by name, the total, and
16
+ * a loud warning with trim hints if it is too big.
17
+ * 2. {@link exportBugReport} — "send exactly these." The reporter's ticked
18
+ * unit ids come back as `include`, and the bundle carries only those.
19
+ *
20
+ * One call does work for a server-side reporter that has no human in front of
21
+ * it; the two-call shape is what makes a browser consent dialog possible at
22
+ * all, because a dialog cannot ask about a blob it has not measured.
23
+ *
24
+ * ## What is in the bundle
25
+ *
26
+ * | file | what it is |
27
+ * |---|---|
28
+ * | `manifest.json` | this manifest — always present, never a selectable unit |
29
+ * | `recording.json` | the canon `{ snapshot, events, structure }` — drops straight into `observeRecording()` |
30
+ * | `conversations/<id>.json` | one file per conversation, when there is more than one run |
31
+ * | `conversation.json` | the readable transcript, derived from the events |
32
+ * | `narrative.txt` | the narrative recorder's lines, when one was attached |
33
+ * | `environment.json` | versions + the reporter's prose |
34
+ *
35
+ * `environment.json` is deliberately the whole environment: library version,
36
+ * engine version, Node version, platform and architecture. **No username, no
37
+ * hostname, no working directory, no environment variables, no file paths.**
38
+ * A bug report should not be the way an internal directory layout leaves a
39
+ * company.
40
+ *
41
+ * ## Redaction is already done, and the manifest proves it
42
+ *
43
+ * The recording arrives ALREADY redacted: footprintjs scrubs at commit time
44
+ * under the run's `RedactionPolicy`, so a redacted value was never in the
45
+ * snapshot this reads. Nothing here scrubs anything — it would be too late to
46
+ * matter and a second policy could only disagree with the first. What this
47
+ * does do is LIST the redacted keys by name, derived from the placeholders
48
+ * actually present in the evidence, so a human consenting to the bundle can
49
+ * see which secrets were protected. A key that is not on that list was not
50
+ * redacted, and the honest reading of an empty list is "no policy was set" —
51
+ * which the manifest says in a note.
52
+ *
53
+ * @example The consent flow
54
+ * ```ts
55
+ * const manifest = describeBugReport(recording);
56
+ * // …show manifest.units to the human; they tick some…
57
+ * const report = exportBugReport(recording, {
58
+ * include: ['conv-1', 'file-narrative', 'file-environment'],
59
+ * title: 'Agent answered with a stale price',
60
+ * stepsToReproduce: '1. ask for the price\n2. update it\n3. ask again',
61
+ * expected: 'the new price',
62
+ * actual: 'the old one',
63
+ * });
64
+ * fs.writeFileSync(report.filename, report.zip);
65
+ * ```
66
+ */
67
+ Object.defineProperty(exports, "__esModule", { value: true });
68
+ exports.slugify = exports.bundleFilename = exports.exportBugReport = exports.describeBugReport = exports.formatBytes = void 0;
69
+ const openRecording_js_1 = require("../trace-toolpack/openRecording.js");
70
+ const libraryVersion_js_1 = require("../libraryVersion.js");
71
+ const transcript_js_1 = require("./transcript.js");
72
+ const zip_js_1 = require("./zip.js");
73
+ /** 20 MB. Past this a bundle stops being something a person reviews. */
74
+ const DEFAULT_WARN_OVER_BYTES = 20 * 1024 * 1024;
75
+ const encoder = new TextEncoder();
76
+ const isFn = (value) => typeof value === 'function';
77
+ const asRecord = (value) => typeof value === 'object' && value !== null ? value : undefined;
78
+ /**
79
+ * Turn whatever the caller had into recordings.
80
+ *
81
+ * The interesting arm is the runner: a finished runner can give up its
82
+ * snapshot and its chart but NOT its events, because the dispatcher drops
83
+ * events nobody subscribed to. Rather than ship a recording with a silently
84
+ * empty timeline, that arm produces a note the manifest carries and the issue
85
+ * body prints. The fix is one line at the call site (`recordRun(agent)` before
86
+ * the run), and saying so is worth more than a blank panel.
87
+ */
88
+ function normalizeOne(source, index) {
89
+ const candidate = asRecord(source);
90
+ if (!candidate) {
91
+ throw new TypeError(`exportBugReport: source #${index + 1} is ${typeof source}, not a recording. Pass a ` +
92
+ `Recording ({ snapshot, events, structure }), the handle from recordRun(agent), or a ` +
93
+ `runner.`);
94
+ }
95
+ // A RunRecorder — the happy path, already wired.
96
+ if (isFn(candidate.toRecording)) {
97
+ const recording = candidate.toRecording();
98
+ return { ...withIds(recording), notes: [] };
99
+ }
100
+ // A Runner — two of the three pieces, honestly labelled.
101
+ if (isFn(candidate.getLastSnapshot) && isFn(candidate.getSpec)) {
102
+ const snapshot = candidate.getLastSnapshot();
103
+ if (snapshot === undefined) {
104
+ throw new TypeError(`exportBugReport: source #${index + 1} is a runner that has not run yet — there is ` +
105
+ `no snapshot to report. Run it, then export; or pass the recording from ` +
106
+ `recordRun(agent).`);
107
+ }
108
+ const spec = candidate.getSpec();
109
+ const recording = {
110
+ snapshot,
111
+ events: [],
112
+ structure: asRecord(spec)?.buildTimeStructure,
113
+ };
114
+ return {
115
+ ...withIds(recording),
116
+ notes: [
117
+ 'Exported from a runner AFTER its run, so this bundle has the state and the chart ' +
118
+ 'but NO event timeline and no transcript: events are delivered live and dropped ' +
119
+ 'when nothing is listening. For the complete three-part recording, call ' +
120
+ 'recordRun(agent) before run() and export the recording it returns.',
121
+ ],
122
+ };
123
+ }
124
+ // A Recording, live or parsed back from JSON.
125
+ if ('snapshot' in candidate || 'events' in candidate || 'structure' in candidate) {
126
+ const recording = {
127
+ snapshot: candidate.snapshot,
128
+ events: Array.isArray(candidate.events) ? candidate.events : [],
129
+ structure: candidate.structure,
130
+ };
131
+ const notes = Array.isArray(candidate.events) && candidate.events.length > 0
132
+ ? []
133
+ : [
134
+ 'This recording carries no events, so the bundle has no timeline and no ' +
135
+ 'transcript. Events are collected as a run happens — call recordRun(agent) ' +
136
+ 'BEFORE run(), then export what it gives you.',
137
+ ];
138
+ return { ...withIds(recording), notes };
139
+ }
140
+ throw new TypeError(`exportBugReport: source #${index + 1} is not a recording, a recordRun handle, or a ` +
141
+ `runner. A recording is { snapshot, events, structure } — the shape recordRun(agent)` +
142
+ `.toRecording() returns.`);
143
+ }
144
+ /** Read the run / session ids off the first event that carries them. */
145
+ function withIds(recording) {
146
+ for (const event of recording.events ?? []) {
147
+ const meta = asRecord(asRecord(event)?.meta);
148
+ if (!meta)
149
+ continue;
150
+ const runId = typeof meta.runId === 'string' ? meta.runId : undefined;
151
+ const sessionId = typeof meta.sessionId === 'string' ? meta.sessionId : undefined;
152
+ if (runId !== undefined || sessionId !== undefined) {
153
+ return { recording, ...(runId && { runId }), ...(sessionId && { sessionId }) };
154
+ }
155
+ }
156
+ // No events: the snapshot's own run id is the next best key.
157
+ const runId = asRecord(recording.snapshot)?.runId;
158
+ return { recording, ...(typeof runId === 'string' && { runId }) };
159
+ }
160
+ /**
161
+ * Group recordings into conversations.
162
+ *
163
+ * A `runId` is per `run()`; a session outlives it. So several runs of one
164
+ * session are ONE conversation — which is what a human means by "the chat that
165
+ * went wrong", and therefore the right unit to consent to. Runs with no session
166
+ * stand alone under their own run id.
167
+ */
168
+ function groupConversations(sources) {
169
+ const order = [];
170
+ const byKey = new Map();
171
+ sources.forEach((source, index) => {
172
+ const key = source.sessionId ?? source.runId ?? `run-${index + 1}`;
173
+ if (!byKey.has(key)) {
174
+ byKey.set(key, []);
175
+ order.push(key);
176
+ }
177
+ byKey.get(key).push(source);
178
+ });
179
+ return order.map((key, index) => {
180
+ const group = byKey.get(key);
181
+ const events = group.flatMap((entry) => [...(entry.recording.events ?? [])]);
182
+ const runIds = group.map((entry) => entry.runId).filter((id) => Boolean(id));
183
+ const sessionId = group[0]?.sessionId;
184
+ const transcript = (0, transcript_js_1.deriveTranscript)(events);
185
+ return {
186
+ id: `conv-${index + 1}`,
187
+ ...(sessionId !== undefined && { sessionId }),
188
+ runIds,
189
+ recordings: group.map((entry) => entry.recording),
190
+ events,
191
+ ...(transcript !== undefined && { transcript }),
192
+ };
193
+ });
194
+ }
195
+ // ─── Redaction: names only, read off the evidence itself ─────────────
196
+ /** What footprintjs writes in place of a value the run's policy covered. */
197
+ const REDACTION_PLACEHOLDERS = new Set(['[REDACTED]', 'REDACTED']);
198
+ /** A bundle is a tree of JSON; this bounds the walk rather than trusting it. */
199
+ const MAX_SCAN_DEPTH = 24;
200
+ /**
201
+ * Collect the KEY NAMES whose value is a redaction placeholder.
202
+ *
203
+ * Names only, never paths with values attached, and never the values (there
204
+ * are none — they were scrubbed upstream). This is what lets a human consent
205
+ * knowingly: "apiKey and customerSsn were protected; everything else in here
206
+ * is real."
207
+ */
208
+ function collectRedactedKeys(value, into, depth = 0) {
209
+ if (depth > MAX_SCAN_DEPTH || value === null || typeof value !== 'object')
210
+ return;
211
+ if (Array.isArray(value)) {
212
+ for (const item of value)
213
+ collectRedactedKeys(item, into, depth + 1);
214
+ return;
215
+ }
216
+ for (const [key, child] of Object.entries(value)) {
217
+ if (typeof child === 'string' && REDACTION_PLACEHOLDERS.has(child))
218
+ into.add(key);
219
+ else
220
+ collectRedactedKeys(child, into, depth + 1);
221
+ }
222
+ }
223
+ const json = (value) => `${JSON.stringify(value, null, 2)}\n`;
224
+ const byteLength = (text) => encoder.encode(text).length;
225
+ function environmentOf(appVersion) {
226
+ const proc = globalThis
227
+ .process;
228
+ return {
229
+ agentfootprint: (0, libraryVersion_js_1.libraryVersion)(),
230
+ footprintjs: (0, libraryVersion_js_1.engineVersion)(),
231
+ node: typeof proc?.version === 'string' ? proc.version : 'unknown',
232
+ platform: typeof proc?.platform === 'string' ? proc.platform : 'unknown',
233
+ arch: typeof proc?.arch === 'string' ? proc.arch : 'unknown',
234
+ ...(appVersion !== undefined && { appVersion }),
235
+ };
236
+ }
237
+ function plan(input, fields) {
238
+ const sources = (Array.isArray(input) ? input : [input]);
239
+ if (sources.length === 0) {
240
+ throw new TypeError('exportBugReport: no run to report. Pass a recording, a recordRun handle, a runner, or ' +
241
+ 'an array of them — a bug report with no evidence is the thing this exists to replace.');
242
+ }
243
+ const normalized = sources.map(normalizeOne);
244
+ const conversations = groupConversations(normalized);
245
+ const notes = [...new Set(normalized.flatMap((entry) => entry.notes))];
246
+ const redacted = new Set();
247
+ for (const conversation of conversations) {
248
+ for (const recording of conversation.recordings)
249
+ collectRedactedKeys(recording, redacted);
250
+ }
251
+ const redactedKeys = [...redacted].sort();
252
+ if (redactedKeys.length === 0) {
253
+ notes.push('No redaction placeholders are present, which means the run had no RedactionPolicy (or ' +
254
+ 'nothing it covered was written). Everything in this bundle is the real value.');
255
+ }
256
+ // ── Conversation units ───────────────────────────────────────────
257
+ const single = conversations.length === 1 && conversations[0].recordings.length === 1;
258
+ const conversationFiles = new Map();
259
+ const derivedFiles = new Map();
260
+ const units = [];
261
+ for (const conversation of conversations) {
262
+ // One run, one conversation → the canon shape under the canon name, so the
263
+ // file drops straight into `observeRecording()` with no unwrapping.
264
+ const name = single ? 'recording.json' : `conversations/${conversation.id}.json`;
265
+ const body = single
266
+ ? conversation.recordings[0]
267
+ : {
268
+ id: conversation.id,
269
+ ...(conversation.sessionId !== undefined && { sessionId: conversation.sessionId }),
270
+ runIds: conversation.runIds,
271
+ recordings: conversation.recordings,
272
+ };
273
+ const text = json(body);
274
+ const turnCount = conversation.transcript?.turns.length ?? 0;
275
+ const file = {
276
+ name,
277
+ text,
278
+ unitId: conversation.id,
279
+ eventCount: conversation.events.length,
280
+ turnCount,
281
+ };
282
+ conversationFiles.set(conversation.id, file);
283
+ units.push({
284
+ id: conversation.id,
285
+ kind: 'conversation',
286
+ label: conversationLabel(conversation, turnCount),
287
+ bytes: byteLength(text),
288
+ eventCount: conversation.events.length,
289
+ turnCount,
290
+ runCount: conversation.recordings.length,
291
+ ...(conversation.sessionId !== undefined && { sessionId: conversation.sessionId }),
292
+ files: [name],
293
+ });
294
+ }
295
+ // ── Derived file units ───────────────────────────────────────────
296
+ const buildTranscript = (selected) => {
297
+ const withTranscripts = selected.filter((conversation) => conversation.transcript);
298
+ if (withTranscripts.length === 0)
299
+ return undefined;
300
+ return {
301
+ name: 'conversation.json',
302
+ unitId: 'file-conversation',
303
+ text: json({
304
+ conversations: withTranscripts.map((conversation) => ({
305
+ id: conversation.id,
306
+ ...(conversation.sessionId !== undefined && { sessionId: conversation.sessionId }),
307
+ turns: conversation.transcript.turns,
308
+ })),
309
+ }),
310
+ };
311
+ };
312
+ const wholeTranscript = buildTranscript(conversations);
313
+ if (wholeTranscript) {
314
+ derivedFiles.set('file-conversation', buildTranscript);
315
+ units.push({
316
+ id: 'file-conversation',
317
+ kind: 'file',
318
+ label: 'conversation.json — the readable transcript (prompts, model replies, tool calls)',
319
+ bytes: byteLength(wholeTranscript.text),
320
+ files: ['conversation.json'],
321
+ });
322
+ }
323
+ else {
324
+ notes.push('No transcript: these events carry no turn, LLM or tool activity to read back, so ' +
325
+ 'conversation.json is not in the bundle.');
326
+ }
327
+ const buildNarrative = (selected) => {
328
+ const lines = selected.flatMap((conversation) => conversation.recordings.flatMap((recording) => {
329
+ const snapshot = (asRecord(recording.snapshot) ?? {});
330
+ const said = (0, openRecording_js_1.narrativeFrom)(snapshot);
331
+ return said ? [`── ${conversation.id} ──`, ...said] : [];
332
+ }));
333
+ if (lines.length === 0)
334
+ return undefined;
335
+ return { name: 'narrative.txt', unitId: 'file-narrative', text: `${lines.join('\n')}\n` };
336
+ };
337
+ const wholeNarrative = buildNarrative(conversations);
338
+ if (wholeNarrative) {
339
+ derivedFiles.set('file-narrative', buildNarrative);
340
+ units.push({
341
+ id: 'file-narrative',
342
+ kind: 'file',
343
+ label: 'narrative.txt — the run in sentences, from the attached narrative recorder',
344
+ bytes: byteLength(wholeNarrative.text),
345
+ files: ['narrative.txt'],
346
+ });
347
+ }
348
+ else {
349
+ notes.push('No narrative.txt: no narrative recorder was attached to this run. Attach ' +
350
+ "footprintjs's narrative() before running to get the run in sentences.");
351
+ }
352
+ // The environment is the same whichever conversations ride along.
353
+ const environmentFile = {
354
+ name: 'environment.json',
355
+ unitId: 'file-environment',
356
+ text: json({
357
+ ...environmentOf(fields?.appVersion),
358
+ ...(fields && {
359
+ report: {
360
+ title: fields.title,
361
+ stepsToReproduce: fields.stepsToReproduce,
362
+ expected: fields.expected,
363
+ actual: fields.actual,
364
+ ...(fields.appVersion !== undefined && { appVersion: fields.appVersion }),
365
+ },
366
+ }),
367
+ }),
368
+ };
369
+ derivedFiles.set('file-environment', () => environmentFile);
370
+ units.push({
371
+ id: 'file-environment',
372
+ kind: 'file',
373
+ label: 'environment.json — library, engine, Node and platform versions (no machine identity)',
374
+ bytes: byteLength(environmentFile.text),
375
+ files: ['environment.json'],
376
+ });
377
+ return { conversations, units, conversationFiles, derivedFiles, notes, redactedKeys };
378
+ }
379
+ function conversationLabel(conversation, turnCount) {
380
+ const who = conversation.sessionId
381
+ ? `session ${conversation.sessionId}`
382
+ : conversation.runIds[0] ?? 'one run';
383
+ const runs = conversation.recordings.length;
384
+ return (`${conversation.id} — ${who}: ${runs} run${runs === 1 ? '' : 's'}, ` +
385
+ `${turnCount} turn${turnCount === 1 ? '' : 's'}, ${conversation.events.length} events`);
386
+ }
387
+ /** Assemble the manifest for a given selection. */
388
+ function manifestFor(args) {
389
+ const { plan: planned, selected, files, createdAt, limitBytes, fields } = args;
390
+ const selectedSet = new Set(selected);
391
+ const excludedUnits = planned.units.filter((unit) => !selectedSet.has(unit.id));
392
+ const includedConversations = planned.conversations.filter((conversation) => selectedSet.has(conversation.id));
393
+ const totalBytes = files.reduce((sum, file) => sum + file.bytes, 0);
394
+ const warnings = [];
395
+ const notes = [...planned.notes];
396
+ const excludedConversationUnits = excludedUnits.filter((unit) => unit.kind === 'conversation');
397
+ if (excludedConversationUnits.length > 0) {
398
+ // Stated, loudly: a maintainer reading turn 4 must know turns 1-3 were not
399
+ // withheld by accident.
400
+ warnings.push(`The reporter deliberately excluded ${excludedConversationUnits.length} of ` +
401
+ `${planned.units.filter((unit) => unit.kind === 'conversation').length} ` +
402
+ `conversations from this bundle. What is here is a SUBSET of what the run produced.`);
403
+ }
404
+ const excludedFileUnits = excludedUnits.filter((unit) => unit.kind === 'file');
405
+ if (excludedFileUnits.length > 0) {
406
+ notes.push(`Excluded by the reporter: ${excludedFileUnits
407
+ .map((unit) => unit.files.join(', '))
408
+ .join(', ')}.`);
409
+ }
410
+ const oversize = totalBytes > limitBytes
411
+ ? {
412
+ totalBytes,
413
+ limitBytes,
414
+ trimHints: trimHints(planned.units, selectedSet, totalBytes, limitBytes),
415
+ }
416
+ : undefined;
417
+ if (oversize) {
418
+ warnings.push(`This bundle is ${formatBytes(totalBytes)}, over the ${formatBytes(limitBytes)} ceiling. ` +
419
+ `Trim it by leaving units out: ${oversize.trimHints.join(' ')}`);
420
+ }
421
+ return {
422
+ manifestVersion: 1,
423
+ createdAt: createdAt.toISOString(),
424
+ ...(fields && {
425
+ report: {
426
+ title: fields.title,
427
+ stepsToReproduce: fields.stepsToReproduce,
428
+ expected: fields.expected,
429
+ actual: fields.actual,
430
+ ...(fields.appVersion !== undefined && { appVersion: fields.appVersion }),
431
+ },
432
+ }),
433
+ units: planned.units,
434
+ selected,
435
+ excluded: {
436
+ conversations: excludedConversationUnits.length,
437
+ files: excludedFileUnits.length,
438
+ events: excludedConversationUnits.reduce((sum, unit) => sum + (unit.eventCount ?? 0), 0),
439
+ turns: excludedConversationUnits.reduce((sum, unit) => sum + (unit.turnCount ?? 0), 0),
440
+ unitIds: excludedUnits.map((unit) => unit.id),
441
+ },
442
+ files,
443
+ counts: {
444
+ conversations: includedConversations.length,
445
+ runs: includedConversations.reduce((sum, conversation) => sum + conversation.recordings.length, 0),
446
+ events: includedConversations.reduce((sum, conversation) => sum + conversation.events.length, 0),
447
+ turns: includedConversations.reduce((sum, conversation) => sum + (conversation.transcript?.turns.length ?? 0), 0),
448
+ files: files.length,
449
+ },
450
+ totalBytes,
451
+ redactedKeys: planned.redactedKeys,
452
+ warnings,
453
+ notes,
454
+ ...(oversize && { oversize }),
455
+ environment: environmentOf(fields?.appVersion),
456
+ };
457
+ }
458
+ /**
459
+ * A size a human reads. KB under a megabyte, MB above it — a ceiling reported
460
+ * as "0.0 MB" teaches nothing, and these strings are the whole content of a
461
+ * refusal.
462
+ */
463
+ function formatBytes(bytes) {
464
+ if (bytes < 1024)
465
+ return `${bytes} bytes`;
466
+ if (bytes < 1024 * 1024)
467
+ return `${(bytes / 1024).toFixed(1)} KB`;
468
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
469
+ }
470
+ exports.formatBytes = formatBytes;
471
+ /**
472
+ * Name the units worth dropping, biggest first, until the bundle would fit.
473
+ *
474
+ * A hint that says "make it smaller" is not a hint. Each of these names a unit
475
+ * id the caller can pass (or withhold) in `include`, and what dropping it saves.
476
+ */
477
+ function trimHints(units, selected, totalBytes, limitBytes) {
478
+ const droppable = units
479
+ .filter((unit) => selected.has(unit.id) && unit.kind === 'conversation')
480
+ .sort((left, right) => right.bytes - left.bytes);
481
+ const hints = [];
482
+ let remaining = totalBytes;
483
+ for (const unit of droppable) {
484
+ if (remaining <= limitBytes)
485
+ break;
486
+ // Never suggest dropping the last conversation: a bundle with no evidence
487
+ // is refused at export, so a hint that leads there is a dead end.
488
+ if (hints.length === droppable.length - 1)
489
+ break;
490
+ remaining -= unit.bytes;
491
+ hints.push(`Drop ${unit.id} (${formatBytes(unit.bytes)}) → ${formatBytes(remaining)}.`);
492
+ }
493
+ if (remaining > limitBytes) {
494
+ const others = units.filter((unit) => selected.has(unit.id) && unit.kind === 'file');
495
+ for (const unit of others) {
496
+ hints.push(`Drop ${unit.id} (${formatBytes(unit.bytes)}).`);
497
+ }
498
+ hints.push('Still over: record a shorter reproduction, or attach the bundle to the issue by hand.');
499
+ }
500
+ return hints;
501
+ }
502
+ // ─── The two entry points ────────────────────────────────────────────
503
+ /**
504
+ * Measure a bug report before anything leaves — the consent step.
505
+ *
506
+ * Every unit is "selected" in the manifest this returns, because nothing has
507
+ * been chosen yet: it is the offer, with sizes and counts attached, for a human
508
+ * (or a policy) to narrow. Show `units` to the reporter; pass the ids they keep
509
+ * to {@link exportBugReport} as `include`.
510
+ *
511
+ * Cheap enough to call on every dialog open: it serializes the files to measure
512
+ * them, and throws them away.
513
+ *
514
+ * @param input a recording, a `recordRun` handle, a runner, or an array.
515
+ * @param options size ceiling and a fixed timestamp.
516
+ */
517
+ function describeBugReport(input, options = {}) {
518
+ const planned = plan(input);
519
+ const createdAt = options.now ?? new Date();
520
+ const limitBytes = options.warnOverBytes ?? DEFAULT_WARN_OVER_BYTES;
521
+ const selected = planned.units.map((unit) => unit.id);
522
+ const files = filesFor(planned, new Set(selected)).map(summaryOf);
523
+ // The description does not count manifest.json: it is not written until the
524
+ // export, and its size depends on the selection it will describe. The offer's
525
+ // total is therefore a KB or two under the bundle's — stated here rather than
526
+ // guessed at with a placeholder.
527
+ return manifestFor({ plan: planned, selected, files, createdAt, limitBytes });
528
+ }
529
+ exports.describeBugReport = describeBugReport;
530
+ /**
531
+ * The files for one selection.
532
+ *
533
+ * Conversation units contribute their own recording file; file units are
534
+ * REBUILT over the selected conversations, which is what keeps a deselected
535
+ * conversation out of the transcript and the narrative as well as out of its
536
+ * own file.
537
+ */
538
+ function filesFor(planned, selected) {
539
+ const chosen = planned.conversations.filter((conversation) => selected.has(conversation.id));
540
+ const out = [];
541
+ for (const unit of planned.units) {
542
+ if (!selected.has(unit.id))
543
+ continue;
544
+ if (unit.kind === 'conversation') {
545
+ const file = planned.conversationFiles.get(unit.id);
546
+ if (file)
547
+ out.push(file);
548
+ continue;
549
+ }
550
+ const file = planned.derivedFiles.get(unit.id)?.(chosen);
551
+ if (file)
552
+ out.push(file);
553
+ }
554
+ return out;
555
+ }
556
+ function summaryOf(file) {
557
+ return {
558
+ name: file.name,
559
+ bytes: byteLength(file.text),
560
+ ...(file.unitId !== undefined && { unitId: file.unitId }),
561
+ ...(file.eventCount !== undefined && { eventCount: file.eventCount }),
562
+ ...(file.turnCount !== undefined && { turnCount: file.turnCount }),
563
+ };
564
+ }
565
+ /**
566
+ * Build the bundle: the manifest, the named files, and a real zip of them.
567
+ *
568
+ * @param input a recording, a `recordRun` handle, a runner, or an array.
569
+ * @param options the reporter's prose, plus `include` — the ids from
570
+ * {@link describeBugReport} that the reporter consented to.
571
+ *
572
+ * @throws TypeError naming the unknown id when `include` names a unit that
573
+ * does not exist, and naming the available conversations when the
574
+ * selection would carry no evidence at all.
575
+ */
576
+ function exportBugReport(input, options) {
577
+ if (!options || typeof options.title !== 'string' || options.title.trim() === '') {
578
+ throw new TypeError('exportBugReport: `title` is required — it becomes the issue title, and an untitled ' +
579
+ 'report is one nobody triages. `stepsToReproduce`, `expected` and `actual` are ' +
580
+ 'required with it.');
581
+ }
582
+ const planned = plan(input, options);
583
+ const known = new Set(planned.units.map((unit) => unit.id));
584
+ const selected = options.include ? [...new Set(options.include)] : [...known];
585
+ for (const id of selected) {
586
+ if (!known.has(id)) {
587
+ throw new TypeError(`exportBugReport: \`include\` names '${id}', which is not a unit of this report. ` +
588
+ `Available: ${[...known].join(', ')}. Take these ids from ` +
589
+ `describeBugReport(input).units — they are stable within one description, not ` +
590
+ `across runs.`);
591
+ }
592
+ }
593
+ const conversationIds = planned.units
594
+ .filter((unit) => unit.kind === 'conversation')
595
+ .map((unit) => unit.id);
596
+ if (!selected.some((id) => conversationIds.includes(id))) {
597
+ throw new TypeError('exportBugReport: the selection includes no conversation, so the bundle would carry ' +
598
+ 'the reporter’s prose and nothing to reproduce from — which is the ordinary bug ' +
599
+ `report this exists to replace. Include at least one of: ${conversationIds.join(', ')}.`);
600
+ }
601
+ const createdAt = options.now ?? new Date();
602
+ const limitBytes = options.warnOverBytes ?? DEFAULT_WARN_OVER_BYTES;
603
+ const selectedSet = new Set(selected);
604
+ const planFiles = filesFor(planned, selectedSet);
605
+ // The manifest counts itself: it is a file in the bundle, and a total that
606
+ // omits it would be a total that lies. Two passes — measure, then restate the
607
+ // total with the manifest's own size folded in.
608
+ const draft = manifestFor({
609
+ plan: planned,
610
+ selected,
611
+ files: planFiles.map(summaryOf),
612
+ createdAt,
613
+ limitBytes,
614
+ fields: options,
615
+ });
616
+ const draftText = json(draft);
617
+ const manifestSummary = {
618
+ name: 'manifest.json',
619
+ bytes: byteLength(draftText),
620
+ };
621
+ const manifest = manifestFor({
622
+ plan: planned,
623
+ selected,
624
+ files: [manifestSummary, ...planFiles.map(summaryOf)],
625
+ createdAt,
626
+ limitBytes,
627
+ fields: options,
628
+ });
629
+ const files = [
630
+ fileOf('manifest.json', json(manifest)),
631
+ ...planFiles.map((file) => fileOf(file.name, file.text)),
632
+ ];
633
+ const zip = (0, zip_js_1.zipStore)(files.map((file) => ({ name: file.name, data: file.bytes })), { modified: createdAt });
634
+ return { manifest, files, zip, filename: bundleFilename(options.title, createdAt) };
635
+ }
636
+ exports.exportBugReport = exportBugReport;
637
+ function fileOf(name, text) {
638
+ return { name, text, bytes: encoder.encode(text) };
639
+ }
640
+ /** `2026-08-11-agent-answered-with-a-stale-price.zip`. */
641
+ function bundleFilename(title, createdAt) {
642
+ return `${createdAt.toISOString().slice(0, 10)}-${slugify(title)}.zip`;
643
+ }
644
+ exports.bundleFilename = bundleFilename;
645
+ /** Lower-case, ASCII, hyphenated, bounded — a filename, not a sentence. */
646
+ function slugify(title) {
647
+ const slug = title
648
+ .toLowerCase()
649
+ .replace(/[^a-z0-9]+/g, '-')
650
+ .replace(/^-+|-+$/g, '')
651
+ .slice(0, 60)
652
+ .replace(/-+$/g, '');
653
+ return slug || 'bug-report';
654
+ }
655
+ exports.slugify = slugify;
656
+ //# sourceMappingURL=build.js.map