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