agentfootprint 9.47.0 → 9.48.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 (103) hide show
  1. package/CLAUDE.md +11 -3
  2. package/dist/core/Agent.js +14 -1
  3. package/dist/core/Agent.js.map +1 -1
  4. package/dist/core/agent/AgentBuilder.js +147 -3
  5. package/dist/core/agent/AgentBuilder.js.map +1 -1
  6. package/dist/core/agent/runManifest.js +7 -0
  7. package/dist/core/agent/runManifest.js.map +1 -1
  8. package/dist/doors/recipes.js +55 -0
  9. package/dist/doors/recipes.js.map +1 -0
  10. package/dist/esm/core/Agent.d.ts +9 -1
  11. package/dist/esm/core/Agent.js +14 -1
  12. package/dist/esm/core/Agent.js.map +1 -1
  13. package/dist/esm/core/agent/AgentBuilder.d.ts +68 -0
  14. package/dist/esm/core/agent/AgentBuilder.js +147 -3
  15. package/dist/esm/core/agent/AgentBuilder.js.map +1 -1
  16. package/dist/esm/core/agent/runManifest.d.ts +18 -0
  17. package/dist/esm/core/agent/runManifest.js +7 -0
  18. package/dist/esm/core/agent/runManifest.js.map +1 -1
  19. package/dist/esm/doors/recipes.d.ts +38 -0
  20. package/dist/esm/doors/recipes.js +39 -0
  21. package/dist/esm/doors/recipes.js.map +1 -0
  22. package/dist/esm/events/payloads.d.ts +29 -0
  23. package/dist/esm/observe.d.ts +2 -0
  24. package/dist/esm/observe.js +12 -0
  25. package/dist/esm/observe.js.map +1 -1
  26. package/dist/esm/recipes/apply.d.ts +67 -0
  27. package/dist/esm/recipes/apply.js +117 -0
  28. package/dist/esm/recipes/apply.js.map +1 -0
  29. package/dist/esm/recipes/defineAgentRecipe.d.ts +68 -0
  30. package/dist/esm/recipes/defineAgentRecipe.js +112 -0
  31. package/dist/esm/recipes/defineAgentRecipe.js.map +1 -0
  32. package/dist/esm/recipes/identifier.d.ts +40 -0
  33. package/dist/esm/recipes/identifier.js +95 -0
  34. package/dist/esm/recipes/identifier.js.map +1 -0
  35. package/dist/esm/recipes/index.d.ts +19 -0
  36. package/dist/esm/recipes/index.js +19 -0
  37. package/dist/esm/recipes/index.js.map +1 -0
  38. package/dist/esm/recipes/provenance.d.ts +57 -0
  39. package/dist/esm/recipes/provenance.js +53 -0
  40. package/dist/esm/recipes/provenance.js.map +1 -0
  41. package/dist/esm/recipes/types.d.ts +134 -0
  42. package/dist/esm/recipes/types.js +63 -0
  43. package/dist/esm/recipes/types.js.map +1 -0
  44. package/dist/esm/recipes/version.d.ts +38 -0
  45. package/dist/esm/recipes/version.js +84 -0
  46. package/dist/esm/recipes/version.js.map +1 -0
  47. package/dist/esm/recorders/observability/fileRecordingSink.d.ts +104 -0
  48. package/dist/esm/recorders/observability/fileRecordingSink.js +195 -0
  49. package/dist/esm/recorders/observability/fileRecordingSink.js.map +1 -0
  50. package/dist/esm/recorders/observability/recordingEnvelope.d.ts +292 -0
  51. package/dist/esm/recorders/observability/recordingEnvelope.js +375 -0
  52. package/dist/esm/recorders/observability/recordingEnvelope.js.map +1 -0
  53. package/dist/observe.js +23 -1
  54. package/dist/observe.js.map +1 -1
  55. package/dist/recipes/apply.js +125 -0
  56. package/dist/recipes/apply.js.map +1 -0
  57. package/dist/recipes/defineAgentRecipe.js +118 -0
  58. package/dist/recipes/defineAgentRecipe.js.map +1 -0
  59. package/dist/recipes/identifier.js +100 -0
  60. package/dist/recipes/identifier.js.map +1 -0
  61. package/dist/recipes/index.js +24 -0
  62. package/dist/recipes/index.js.map +1 -0
  63. package/dist/recipes/provenance.js +57 -0
  64. package/dist/recipes/provenance.js.map +1 -0
  65. package/dist/recipes/types.js +64 -0
  66. package/dist/recipes/types.js.map +1 -0
  67. package/dist/recipes/version.js +89 -0
  68. package/dist/recipes/version.js.map +1 -0
  69. package/dist/recorders/observability/fileRecordingSink.js +201 -0
  70. package/dist/recorders/observability/fileRecordingSink.js.map +1 -0
  71. package/dist/recorders/observability/recordingEnvelope.js +382 -0
  72. package/dist/recorders/observability/recordingEnvelope.js.map +1 -0
  73. package/dist/types/core/Agent.d.ts +9 -1
  74. package/dist/types/core/Agent.d.ts.map +1 -1
  75. package/dist/types/core/agent/AgentBuilder.d.ts +68 -0
  76. package/dist/types/core/agent/AgentBuilder.d.ts.map +1 -1
  77. package/dist/types/core/agent/runManifest.d.ts +18 -0
  78. package/dist/types/core/agent/runManifest.d.ts.map +1 -1
  79. package/dist/types/doors/recipes.d.ts +39 -0
  80. package/dist/types/doors/recipes.d.ts.map +1 -0
  81. package/dist/types/events/payloads.d.ts +29 -0
  82. package/dist/types/events/payloads.d.ts.map +1 -1
  83. package/dist/types/observe.d.ts +2 -0
  84. package/dist/types/observe.d.ts.map +1 -1
  85. package/dist/types/recipes/apply.d.ts +68 -0
  86. package/dist/types/recipes/apply.d.ts.map +1 -0
  87. package/dist/types/recipes/defineAgentRecipe.d.ts +69 -0
  88. package/dist/types/recipes/defineAgentRecipe.d.ts.map +1 -0
  89. package/dist/types/recipes/identifier.d.ts +41 -0
  90. package/dist/types/recipes/identifier.d.ts.map +1 -0
  91. package/dist/types/recipes/index.d.ts +20 -0
  92. package/dist/types/recipes/index.d.ts.map +1 -0
  93. package/dist/types/recipes/provenance.d.ts +58 -0
  94. package/dist/types/recipes/provenance.d.ts.map +1 -0
  95. package/dist/types/recipes/types.d.ts +135 -0
  96. package/dist/types/recipes/types.d.ts.map +1 -0
  97. package/dist/types/recipes/version.d.ts +39 -0
  98. package/dist/types/recipes/version.d.ts.map +1 -0
  99. package/dist/types/recorders/observability/fileRecordingSink.d.ts +105 -0
  100. package/dist/types/recorders/observability/fileRecordingSink.d.ts.map +1 -0
  101. package/dist/types/recorders/observability/recordingEnvelope.d.ts +293 -0
  102. package/dist/types/recorders/observability/recordingEnvelope.d.ts.map +1 -0
  103. package/package.json +14 -1
@@ -0,0 +1,292 @@
1
+ /**
2
+ * recordingEnvelope — the versioned, serialized contract around a recording.
3
+ *
4
+ * `recordRun` already freezes a run into `{ events, snapshot, structure }`, and
5
+ * that shape is exactly right for handing to a viewer in the same process. What
6
+ * it is NOT is a thing you can put on disk and read back next quarter: it
7
+ * carries no format marker, no producer version, no statement of which run it
8
+ * is, and no statement of whether it is the WHOLE run. So every consumer that
9
+ * wanted to archive one, attach one to a bug report, or feed one to an analysis
10
+ * tool invented its own wrapper — and each wrapper made a different guess about
11
+ * the same missing facts.
12
+ *
13
+ * This is the producer-owned answer: one envelope, one format string, and every
14
+ * field either a fact the library can prove or a fact the caller stated.
15
+ *
16
+ * import { persistRecording, fileRecordingSink } from 'agentfootprint/observe';
17
+ *
18
+ * const recorder = recordRun(agent);
19
+ * await agent.run({ message });
20
+ * await persistRecording(recorder, {
21
+ * sink: fileRecordingSink({ directory: './run-archive' }),
22
+ * run: { complete: true },
23
+ * });
24
+ *
25
+ * ## The rule this file exists to keep: never stamp a fact you had to guess
26
+ *
27
+ * An envelope is read by people and tools that were not there when the run
28
+ * happened. That makes every field a claim, and a claim that turns out to be a
29
+ * guess is worse than a missing field — a missing field sends the reader to
30
+ * look, a wrong field stops them looking. So each one has a stated source:
31
+ *
32
+ * runId, sessionId, derived from the EVENT META, which is the only
33
+ * principal, tenant place these are recorded, or stated by the caller.
34
+ * Never synthesized. See "identity" below.
35
+ * startedAt / endedAt derived from event wall clocks, but only where the
36
+ * stream can honestly supply them (see below).
37
+ * complete CALLER INPUT, always. Nothing in a finished
38
+ * recording says whether the run reached its end.
39
+ * droppedEvents read off the live recorder, which counts them; a
40
+ * bare recording carries no count, so it is asked for
41
+ * rather than assumed to be 0.
42
+ * configuration lifted from the run's own `run_configured` event —
43
+ * the manifest, which is names-and-ids only by law.
44
+ * producer read from the package manifests at runtime.
45
+ *
46
+ * Where a fact is neither derivable nor supplied, this REFUSES
47
+ * ({@link IndeterminateRunFactError}) instead of filling in a plausible value.
48
+ *
49
+ * ## Identity is never invented
50
+ *
51
+ * `principal` and `tenant` come from `EventMeta`, and `EventMeta`'s own law
52
+ * (see src/events/types.ts) is that they are stamped ONLY from an explicit
53
+ * `run(input, { identity })` — never from the run's internal identity, and
54
+ * never from a session id, because "a conversation id is not an actor". By
55
+ * sourcing them from the meta and nowhere else, this envelope inherits that
56
+ * guarantee whole: an anonymous run produces an envelope with no `principal`
57
+ * key at all, not a placeholder and not a session id wearing an actor's name.
58
+ *
59
+ * ## `runId` has two namespaces, and this one is deliberate
60
+ *
61
+ * A footprintjs snapshot carries its OWN engine-level `runId`
62
+ * (`<timestamp>-<padded counter>`, minted per `executor.run()`), while the
63
+ * agentfootprint events carry `run-<timestamp>-<seq>` from `makeRunId()`. They
64
+ * are different ids for different layers and they do not match. This envelope
65
+ * uses the EVENT-meta one exclusively, because that is the id `sessionId`,
66
+ * `principal` and `tenant` ride beside and the one every typed-event consumer
67
+ * correlates on. When the events cannot supply it, this refuses rather than
68
+ * falling back to the snapshot's — quietly substituting an id from another
69
+ * namespace would make two envelopes look comparable when they are not.
70
+ */
71
+ import type { RunManifestLike } from '../../lib/context-bisect/arms/types.js';
72
+ import type { Recording, RunRecorder } from './recordRun.js';
73
+ /**
74
+ * The format marker. Bumped only for a change an older reader could not
75
+ * survive — a reader that does not recognise the string must refuse the file,
76
+ * never half-read it.
77
+ */
78
+ export declare const RECORDING_ENVELOPE_FORMAT = "agentfootprint.recording.v1";
79
+ /**
80
+ * The privacy policy id a `'full'` envelope carries when the caller names none.
81
+ * A stable string so a retention rule can match on it rather than on prose.
82
+ */
83
+ export declare const FULL_PRIVACY_POLICY_ID = "agentfootprint.privacy.full.v1";
84
+ /**
85
+ * What a caller may ASK for. Only `'full'` is implemented in v1; the other two
86
+ * are named here so the refusal can name them back, and so a consumer writing
87
+ * against a future version gets a compile-time hint rather than a typo.
88
+ */
89
+ export type RecordingPrivacyMode = 'full' | 'structure-only' | 'redacted';
90
+ /** Versions of the two libraries that produced the bytes. */
91
+ export interface RecordingProducer {
92
+ /** This library's version, or `'unknown'` when the manifest is unreadable. */
93
+ readonly agentfootprintVersion: string;
94
+ /** The engine underneath it, or `'unknown'`. */
95
+ readonly footprintjsVersion: string;
96
+ }
97
+ /**
98
+ * WHICH run this is, and how much of it this is.
99
+ *
100
+ * Every field is either derived from the recording's own events or stated by
101
+ * the caller — see the module header for the per-field source.
102
+ */
103
+ export interface RecordingRun {
104
+ /** From `EventMeta.runId` — the agentfootprint run id, not the engine's. */
105
+ readonly runId: string;
106
+ /** Present only when the run was session-bound. Never invented. */
107
+ readonly sessionId?: string;
108
+ /** The actor the caller NAMED. Absent for an anonymous run. */
109
+ readonly principal?: string;
110
+ /** The tenant boundary the caller NAMED. Absent for a single-tenant run. */
111
+ readonly tenant?: string;
112
+ /** ISO 8601. The first recorded event's wall clock, unless the caller said. */
113
+ readonly startedAt: string;
114
+ /**
115
+ * ISO 8601. Absent when the recording is not `complete` and the caller named
116
+ * no end — a run that had not finished has no end time to report.
117
+ */
118
+ readonly endedAt?: string;
119
+ /**
120
+ * Did this recording capture the run through to its end?
121
+ *
122
+ * ALWAYS the caller's statement. A frozen recording looks identical whether
123
+ * it was taken after the run or from a crash handler mid-run, and there is no
124
+ * run-terminal event in the registry to check, so the library cannot know.
125
+ * Defaulting it to `true` would make every crash dump claim to be whole.
126
+ */
127
+ readonly complete: boolean;
128
+ /**
129
+ * Events discarded to stay under the recorder's `maxEvents` cap. `0` means
130
+ * "none were dropped", proven — never "we did not look".
131
+ */
132
+ readonly droppedEvents: number;
133
+ }
134
+ /** What the agent was configured as — names and ids only, no values. */
135
+ export interface RecordingConfiguration {
136
+ readonly agentId?: string;
137
+ /**
138
+ * The run manifest, as the run itself declared it in
139
+ * `agentfootprint.agent.run_configured`.
140
+ *
141
+ * Safe to archive by construction: the manifest's own law is NAMES AND IDS
142
+ * ONLY — no endpoints, directories, connection strings or keys — precisely
143
+ * because it was built to ride into recordings and shared traces.
144
+ */
145
+ readonly manifest?: RunManifestLike;
146
+ }
147
+ /** What was done to the bytes before they were stored. */
148
+ export interface RecordingPrivacy {
149
+ /**
150
+ * `'full'` — the recording is exactly what `recordRun` captured, unredacted.
151
+ *
152
+ * A v1 envelope can say nothing else: the redacting modes are not built, and
153
+ * this refuses to produce a label it cannot honour.
154
+ */
155
+ readonly mode: 'full';
156
+ /** Names the policy the producer applied. See {@link FULL_PRIVACY_POLICY_ID}. */
157
+ readonly policyId: string;
158
+ }
159
+ /**
160
+ * One archived run: the recording, plus everything a reader needs to know what
161
+ * they are holding.
162
+ *
163
+ * Plain JSON by construction — no Dates, no Maps, no live handles — so
164
+ * `JSON.parse(JSON.stringify(envelope))` is the same envelope. Absent optional
165
+ * fields are absent KEYS rather than keys with `undefined`, which is what makes
166
+ * that round trip exact.
167
+ */
168
+ export interface RecordingEnvelope {
169
+ readonly format: typeof RECORDING_ENVELOPE_FORMAT;
170
+ readonly producer: RecordingProducer;
171
+ readonly run: RecordingRun;
172
+ readonly configuration?: RecordingConfiguration;
173
+ readonly privacy: RecordingPrivacy;
174
+ /** The recording itself, unmodified — `{ snapshot, events, structure }`. */
175
+ readonly recording: Recording;
176
+ }
177
+ /** A timestamp a caller may state, in any of the three obvious spellings. */
178
+ export type RecordingTimestamp = string | number | Date;
179
+ /** The run facts a caller states — the half the library cannot derive. */
180
+ export interface RecordingRunFacts {
181
+ /**
182
+ * Did the recording capture the run through to its end?
183
+ *
184
+ * Required, and deliberately so: this is the one field with no derivation and
185
+ * no safe default, so the API asks rather than guesses. Say `false` for a
186
+ * recording frozen from a crash handler, a timeout, or mid-stream.
187
+ */
188
+ readonly complete: boolean;
189
+ /** Override the event-derived run id, or supply it when events cannot. */
190
+ readonly runId?: string;
191
+ readonly sessionId?: string;
192
+ readonly principal?: string;
193
+ readonly tenant?: string;
194
+ readonly startedAt?: RecordingTimestamp;
195
+ readonly endedAt?: RecordingTimestamp;
196
+ /**
197
+ * Events lost to the recorder's cap. Read off a live `recordRun` handle
198
+ * automatically; state it when persisting a bare `Recording`, whose shape
199
+ * carries no count.
200
+ */
201
+ readonly droppedEvents?: number;
202
+ }
203
+ /**
204
+ * Where an envelope goes. One method, so a destination is a few lines: a
205
+ * directory, an object store, a table, an HTTP endpoint.
206
+ */
207
+ export interface RecordingSink {
208
+ /**
209
+ * Store one envelope.
210
+ *
211
+ * @returns `id` the sink's own handle for what it just stored.
212
+ * `uri` where it landed, when the sink has a meaningful address.
213
+ */
214
+ write(envelope: RecordingEnvelope): Promise<{
215
+ id: string;
216
+ uri?: string;
217
+ }>;
218
+ }
219
+ /** Options for {@link persistRecording}. */
220
+ export interface PersistRecordingOptions {
221
+ readonly sink: RecordingSink;
222
+ /**
223
+ * The run facts. Required because {@link RecordingRunFacts.complete} is:
224
+ * an envelope that did not state whether it holds a whole run is an envelope
225
+ * whose reader has to guess.
226
+ */
227
+ readonly run: RecordingRunFacts;
228
+ /** Override the manifest lifted from the run's own `run_configured` event. */
229
+ readonly configuration?: RecordingConfiguration;
230
+ readonly privacy?: {
231
+ readonly mode?: RecordingPrivacyMode;
232
+ readonly policyId?: string;
233
+ };
234
+ }
235
+ /** Options for {@link buildRecordingEnvelope}. */
236
+ export type BuildRecordingEnvelopeOptions = Omit<PersistRecordingOptions, 'sink'>;
237
+ /** A recording to envelope, or the live handle that is still collecting one. */
238
+ export type RecordingSource = Recording | RunRecorder;
239
+ /**
240
+ * Raised when a privacy mode is asked for that this version cannot honour.
241
+ *
242
+ * Its own class because the answer is never "retry": a caller catching this has
243
+ * to change what they store or how they store it.
244
+ */
245
+ export declare class UnsupportedPrivacyModeError extends Error {
246
+ readonly code: "ERR_UNSUPPORTED_PRIVACY_MODE";
247
+ readonly mode: string;
248
+ constructor(mode: string);
249
+ }
250
+ /**
251
+ * Raised when a required run fact is neither derivable from the recording nor
252
+ * stated by the caller.
253
+ *
254
+ * Carries the `field` so a caller can catch it and supply exactly that one.
255
+ */
256
+ export declare class IndeterminateRunFactError extends Error {
257
+ readonly code: "ERR_INDETERMINATE_RUN_FACT";
258
+ readonly field: string;
259
+ constructor(field: string, detail: string);
260
+ }
261
+ /**
262
+ * Freeze a recording into an archivable envelope, without storing it.
263
+ *
264
+ * The half of {@link persistRecording} that has no destination — useful when
265
+ * the envelope goes somewhere this library should not know about (a request
266
+ * body, a queue), and the unit under test for the contract itself.
267
+ */
268
+ export declare function buildRecordingEnvelope(source: RecordingSource, options: BuildRecordingEnvelopeOptions): RecordingEnvelope;
269
+ /**
270
+ * Build the envelope and hand it to a sink.
271
+ *
272
+ * @param source the handle from `recordRun(agent)`, or the recording it made.
273
+ * Prefer the handle: it is the only thing that knows how many
274
+ * events the cap discarded.
275
+ * @param options the destination, the run facts, and the privacy statement.
276
+ *
277
+ * @example
278
+ * ```ts
279
+ * const recorder = recordRun(agent);
280
+ * await agent.run({ message: 'Weather in San Francisco?' });
281
+ *
282
+ * const { uri } = await persistRecording(recorder, {
283
+ * sink: fileRecordingSink({ directory: './run-archive' }),
284
+ * run: { complete: true },
285
+ * });
286
+ * recorder.stop();
287
+ * ```
288
+ */
289
+ export declare function persistRecording(source: RecordingSource, options: PersistRecordingOptions): Promise<{
290
+ id: string;
291
+ uri?: string;
292
+ }>;
@@ -0,0 +1,375 @@
1
+ /**
2
+ * recordingEnvelope — the versioned, serialized contract around a recording.
3
+ *
4
+ * `recordRun` already freezes a run into `{ events, snapshot, structure }`, and
5
+ * that shape is exactly right for handing to a viewer in the same process. What
6
+ * it is NOT is a thing you can put on disk and read back next quarter: it
7
+ * carries no format marker, no producer version, no statement of which run it
8
+ * is, and no statement of whether it is the WHOLE run. So every consumer that
9
+ * wanted to archive one, attach one to a bug report, or feed one to an analysis
10
+ * tool invented its own wrapper — and each wrapper made a different guess about
11
+ * the same missing facts.
12
+ *
13
+ * This is the producer-owned answer: one envelope, one format string, and every
14
+ * field either a fact the library can prove or a fact the caller stated.
15
+ *
16
+ * import { persistRecording, fileRecordingSink } from 'agentfootprint/observe';
17
+ *
18
+ * const recorder = recordRun(agent);
19
+ * await agent.run({ message });
20
+ * await persistRecording(recorder, {
21
+ * sink: fileRecordingSink({ directory: './run-archive' }),
22
+ * run: { complete: true },
23
+ * });
24
+ *
25
+ * ## The rule this file exists to keep: never stamp a fact you had to guess
26
+ *
27
+ * An envelope is read by people and tools that were not there when the run
28
+ * happened. That makes every field a claim, and a claim that turns out to be a
29
+ * guess is worse than a missing field — a missing field sends the reader to
30
+ * look, a wrong field stops them looking. So each one has a stated source:
31
+ *
32
+ * runId, sessionId, derived from the EVENT META, which is the only
33
+ * principal, tenant place these are recorded, or stated by the caller.
34
+ * Never synthesized. See "identity" below.
35
+ * startedAt / endedAt derived from event wall clocks, but only where the
36
+ * stream can honestly supply them (see below).
37
+ * complete CALLER INPUT, always. Nothing in a finished
38
+ * recording says whether the run reached its end.
39
+ * droppedEvents read off the live recorder, which counts them; a
40
+ * bare recording carries no count, so it is asked for
41
+ * rather than assumed to be 0.
42
+ * configuration lifted from the run's own `run_configured` event —
43
+ * the manifest, which is names-and-ids only by law.
44
+ * producer read from the package manifests at runtime.
45
+ *
46
+ * Where a fact is neither derivable nor supplied, this REFUSES
47
+ * ({@link IndeterminateRunFactError}) instead of filling in a plausible value.
48
+ *
49
+ * ## Identity is never invented
50
+ *
51
+ * `principal` and `tenant` come from `EventMeta`, and `EventMeta`'s own law
52
+ * (see src/events/types.ts) is that they are stamped ONLY from an explicit
53
+ * `run(input, { identity })` — never from the run's internal identity, and
54
+ * never from a session id, because "a conversation id is not an actor". By
55
+ * sourcing them from the meta and nowhere else, this envelope inherits that
56
+ * guarantee whole: an anonymous run produces an envelope with no `principal`
57
+ * key at all, not a placeholder and not a session id wearing an actor's name.
58
+ *
59
+ * ## `runId` has two namespaces, and this one is deliberate
60
+ *
61
+ * A footprintjs snapshot carries its OWN engine-level `runId`
62
+ * (`<timestamp>-<padded counter>`, minted per `executor.run()`), while the
63
+ * agentfootprint events carry `run-<timestamp>-<seq>` from `makeRunId()`. They
64
+ * are different ids for different layers and they do not match. This envelope
65
+ * uses the EVENT-meta one exclusively, because that is the id `sessionId`,
66
+ * `principal` and `tenant` ride beside and the one every typed-event consumer
67
+ * correlates on. When the events cannot supply it, this refuses rather than
68
+ * falling back to the snapshot's — quietly substituting an id from another
69
+ * namespace would make two envelopes look comparable when they are not.
70
+ */
71
+ import { manifestFromEvents } from '../../lib/context-bisect/arms/manifest.js';
72
+ import { engineVersion, libraryVersion } from '../../lib/libraryVersion.js';
73
+ /**
74
+ * The format marker. Bumped only for a change an older reader could not
75
+ * survive — a reader that does not recognise the string must refuse the file,
76
+ * never half-read it.
77
+ */
78
+ export const RECORDING_ENVELOPE_FORMAT = 'agentfootprint.recording.v1';
79
+ /**
80
+ * The privacy policy id a `'full'` envelope carries when the caller names none.
81
+ * A stable string so a retention rule can match on it rather than on prose.
82
+ */
83
+ export const FULL_PRIVACY_POLICY_ID = 'agentfootprint.privacy.full.v1';
84
+ // ─── Refusals ────────────────────────────────────────────────────────
85
+ /**
86
+ * Raised when a privacy mode is asked for that this version cannot honour.
87
+ *
88
+ * Its own class because the answer is never "retry": a caller catching this has
89
+ * to change what they store or how they store it.
90
+ */
91
+ export class UnsupportedPrivacyModeError extends Error {
92
+ code = 'ERR_UNSUPPORTED_PRIVACY_MODE';
93
+ mode;
94
+ constructor(mode) {
95
+ super(`[recording-envelope] privacy mode '${mode}' is not implemented. ` +
96
+ `${RECORDING_ENVELOPE_FORMAT} can produce 'full' envelopes only — the bytes exactly ` +
97
+ `as recordRun captured them.\n\n` +
98
+ `This refuses rather than approximating because the label is what downstream readers ` +
99
+ `act on: an archive browser decides what to show, a retention rule decides how long to ` +
100
+ `keep it, and a triage tool decides who may open it — all from this field. An envelope ` +
101
+ `stamped '${mode}' over un-redacted bytes would be handled with LESS care than one ` +
102
+ `that admits it is raw, so silently storing the raw bytes under that label is the ` +
103
+ `worse outcome, not the convenient one.\n\n` +
104
+ `Either persist with privacy: { mode: 'full' } and hold the result under the rules raw ` +
105
+ `run data needs, or redact BEFORE persisting — recordRun(agent, { boundaryDetail: ` +
106
+ `'lean' }) captures no payloads in the first place, and serializeTrace/redactContent ` +
107
+ `redact at the serialize boundary.`);
108
+ this.name = 'UnsupportedPrivacyModeError';
109
+ this.mode = mode;
110
+ }
111
+ }
112
+ /**
113
+ * Raised when a required run fact is neither derivable from the recording nor
114
+ * stated by the caller.
115
+ *
116
+ * Carries the `field` so a caller can catch it and supply exactly that one.
117
+ */
118
+ export class IndeterminateRunFactError extends Error {
119
+ code = 'ERR_INDETERMINATE_RUN_FACT';
120
+ field;
121
+ constructor(field, detail) {
122
+ super(`[recording-envelope] cannot determine run.${field}: ${detail}`);
123
+ this.name = 'IndeterminateRunFactError';
124
+ this.field = field;
125
+ }
126
+ }
127
+ // ─── Reading the recording ───────────────────────────────────────────
128
+ const isRecord = (value) => typeof value === 'object' && value !== null;
129
+ const isFn = (value) => typeof value === 'function';
130
+ /** A live `recordRun` handle is the one that can also report its drop count. */
131
+ function asRunRecorder(source) {
132
+ return isRecord(source) && isFn(source.toRecording)
133
+ ? source
134
+ : undefined;
135
+ }
136
+ /**
137
+ * Every DISTINCT defined value of one `meta` field across the stream.
138
+ *
139
+ * Distinct rather than first-wins on purpose. One recording is meant to be one
140
+ * run, but a recorder left attached across `run()` and then `resume()` sees two
141
+ * run ids, and a stream carrying two principals is two actors. Collapsing
142
+ * either to "the first one" is how an archive ends up labelled with the wrong
143
+ * actor — so the caller is made to say which, rather than being told a guess.
144
+ */
145
+ function distinctMetaValues(events, field) {
146
+ const seen = new Set();
147
+ for (const event of events) {
148
+ const meta = isRecord(event) ? event.meta : undefined;
149
+ if (!isRecord(meta))
150
+ continue;
151
+ const value = meta[field];
152
+ if (typeof value === 'string' && value !== '')
153
+ seen.add(value);
154
+ }
155
+ return [...seen];
156
+ }
157
+ /** The wall clock of the first / last event that carries one, in epoch ms. */
158
+ function edgeWallClock(events, edge) {
159
+ const ordered = edge === 'first' ? events : [...events].reverse();
160
+ for (const event of ordered) {
161
+ const meta = isRecord(event) ? event.meta : undefined;
162
+ if (!isRecord(meta))
163
+ continue;
164
+ const ms = meta.wallClockMs;
165
+ if (typeof ms === 'number' && Number.isFinite(ms))
166
+ return ms;
167
+ }
168
+ return undefined;
169
+ }
170
+ /**
171
+ * One optional identity field, derived under the never-invent rule.
172
+ *
173
+ * @returns the single value, or `undefined` meaning ABSENT — which is a real
174
+ * answer here ("this run named no tenant"), not a missing one.
175
+ */
176
+ function soleIdentity(events, field, stated) {
177
+ if (stated !== undefined)
178
+ return stated;
179
+ const found = distinctMetaValues(events, field);
180
+ if (found.length === 0)
181
+ return undefined;
182
+ if (found.length === 1)
183
+ return found[0];
184
+ throw new IndeterminateRunFactError(field, `the recording's events carry ${String(found.length)} different values ` +
185
+ `(${found.map((v) => JSON.stringify(v)).join(', ')}), so there is no single one to ` +
186
+ `stamp. Naming one of them here would put a value in the envelope that is wrong for ` +
187
+ `some of the events inside it. Split the recording per ${field}, or state the intended ` +
188
+ `one explicitly as run.${field}.`);
189
+ }
190
+ /** Normalize a caller timestamp to ISO 8601, refusing anything unreadable. */
191
+ function toIso(value, field) {
192
+ const date = value instanceof Date ? value : typeof value === 'number' ? new Date(value) : new Date(value);
193
+ const ms = date.getTime();
194
+ if (Number.isNaN(ms)) {
195
+ throw new IndeterminateRunFactError(field, `${JSON.stringify(String(value))} is not a readable date. Pass an ISO 8601 string, epoch ` +
196
+ `milliseconds, or a Date.`);
197
+ }
198
+ return date.toISOString();
199
+ }
200
+ // ─── Building ────────────────────────────────────────────────────────
201
+ /** `'full'`, or a named refusal. Never a silent downgrade. */
202
+ function resolvePrivacy(privacy) {
203
+ const mode = privacy?.mode ?? 'full';
204
+ if (mode !== 'full')
205
+ throw new UnsupportedPrivacyModeError(String(mode));
206
+ return { mode: 'full', policyId: privacy?.policyId ?? FULL_PRIVACY_POLICY_ID };
207
+ }
208
+ /** How many events the cap discarded — proven, or asked for. */
209
+ function resolveDroppedEvents(source, stated) {
210
+ if (stated !== undefined) {
211
+ if (!Number.isInteger(stated) || stated < 0) {
212
+ throw new IndeterminateRunFactError('droppedEvents', `${JSON.stringify(stated)} is not a count. Pass a non-negative integer.`);
213
+ }
214
+ return stated;
215
+ }
216
+ const recorder = asRunRecorder(source);
217
+ if (recorder !== undefined)
218
+ return recorder.droppedEvents;
219
+ throw new IndeterminateRunFactError('droppedEvents', `a bare Recording carries no drop count — only the live recordRun handle counts what the ` +
220
+ `maxEvents cap discarded, and that handle is gone by the time a recording is a plain ` +
221
+ `object. Reporting 0 here would turn "we did not look" into "none were dropped", which ` +
222
+ `is the difference between a timeline that starts at the run's beginning and one that ` +
223
+ `merely appears to. Pass the handle from recordRun(agent) instead of its recording, or ` +
224
+ `state run.droppedEvents.`);
225
+ }
226
+ /**
227
+ * Freeze a recording into an archivable envelope, without storing it.
228
+ *
229
+ * The half of {@link persistRecording} that has no destination — useful when
230
+ * the envelope goes somewhere this library should not know about (a request
231
+ * body, a queue), and the unit under test for the contract itself.
232
+ */
233
+ export function buildRecordingEnvelope(source, options) {
234
+ if (!isRecord(source)) {
235
+ throw new TypeError(`[recording-envelope] expected a Recording ({ snapshot, events, structure }) or the ` +
236
+ `handle from recordRun(agent); received ${typeof source}.`);
237
+ }
238
+ const facts = options.run;
239
+ if (!isRecord(facts) || typeof facts.complete !== 'boolean') {
240
+ throw new TypeError(`[recording-envelope] run.complete must be stated as true or false. Nothing in a frozen ` +
241
+ `recording says whether it reached the run's end — a crash-handler snapshot and a ` +
242
+ `finished run look identical — so this is asked for rather than guessed. Defaulting it ` +
243
+ `would make every partial recording claim to be whole.`);
244
+ }
245
+ // Resolve privacy FIRST: an unsupported mode must refuse before any work is
246
+ // done that a caller could mistake for progress toward a stored file.
247
+ const privacy = resolvePrivacy(options.privacy);
248
+ const recorder = asRunRecorder(source);
249
+ const recording = recorder ? recorder.toRecording() : source;
250
+ const events = Array.isArray(recording.events)
251
+ ? recording.events
252
+ : [];
253
+ const droppedEvents = resolveDroppedEvents(source, facts.droppedEvents);
254
+ // ── runId ──
255
+ let runId;
256
+ if (facts.runId !== undefined) {
257
+ runId = facts.runId;
258
+ }
259
+ else {
260
+ const found = distinctMetaValues(events, 'runId');
261
+ if (found.length === 1) {
262
+ runId = found[0];
263
+ }
264
+ else if (found.length === 0) {
265
+ throw new IndeterminateRunFactError('runId', `no event in this recording carries meta.runId${events.length === 0 ? ' (the recording holds no events at all)' : ''}. The snapshot's own runId is deliberately NOT used as a fallback: it is the ` +
266
+ `footprintjs engine's id for the run, a different namespace from the ` +
267
+ `agentfootprint run id the events carry, so substituting it would make two ` +
268
+ `envelopes look comparable when they are not. Record with recordRun(agent) BEFORE ` +
269
+ `run(), or state run.runId.`);
270
+ }
271
+ else {
272
+ throw new IndeterminateRunFactError('runId', `this recording spans ${String(found.length)} runs (${found
273
+ .map((v) => JSON.stringify(v))
274
+ .join(', ')}). A fresh run id is minted per run() AND per resume(), so a recorder ` +
275
+ `left attached across both sees two. An envelope names ONE run — split the ` +
276
+ `recording, or state which run.runId this archive is filed under.`);
277
+ }
278
+ }
279
+ const sessionId = soleIdentity(events, 'sessionId', facts.sessionId);
280
+ const principal = soleIdentity(events, 'principal', facts.principal);
281
+ const tenant = soleIdentity(events, 'tenant', facts.tenant);
282
+ // ── startedAt ──
283
+ let startedAt;
284
+ if (facts.startedAt !== undefined) {
285
+ startedAt = toIso(facts.startedAt, 'startedAt');
286
+ }
287
+ else if (droppedEvents > 0) {
288
+ // The cap drops the OLDEST events, so the earliest event still held is not
289
+ // the one the run started with. Reading a start time off it would be off by
290
+ // however much of the run was discarded, silently.
291
+ throw new IndeterminateRunFactError('startedAt', `${String(droppedEvents)} event(s) were dropped from the head of this stream, so the ` +
292
+ `earliest retained event is NOT the run's first — deriving a start time from it would ` +
293
+ `report the moment recording overflowed, not the moment the run began. State ` +
294
+ `run.startedAt, or record with a larger maxEvents.`);
295
+ }
296
+ else {
297
+ const first = edgeWallClock(events, 'first');
298
+ if (first === undefined) {
299
+ throw new IndeterminateRunFactError('startedAt', `no event in this recording carries a wall clock to read a start time from. State ` +
300
+ `run.startedAt.`);
301
+ }
302
+ startedAt = new Date(first).toISOString();
303
+ }
304
+ // ── endedAt ──
305
+ // Only a recording the caller calls COMPLETE gets an end time derived for it.
306
+ // In an incomplete recording the last retained event is just where watching
307
+ // stopped; the run has no end yet, and absent is the honest answer.
308
+ let endedAt;
309
+ if (facts.endedAt !== undefined) {
310
+ endedAt = toIso(facts.endedAt, 'endedAt');
311
+ }
312
+ else if (facts.complete) {
313
+ const last = edgeWallClock(events, 'last');
314
+ if (last !== undefined)
315
+ endedAt = new Date(last).toISOString();
316
+ }
317
+ // ── configuration ──
318
+ const manifest = options.configuration?.manifest ??
319
+ manifestFromEvents(events);
320
+ const agentId = options.configuration?.agentId ?? manifest?.agentId;
321
+ const configuration = agentId === undefined && manifest === undefined
322
+ ? undefined
323
+ : { ...(agentId !== undefined && { agentId }), ...(manifest !== undefined && { manifest }) };
324
+ // Conditional spreads, not `undefined` values: an absent optional must be an
325
+ // absent KEY, or JSON.stringify drops it and the round trip stops being exact.
326
+ return {
327
+ format: RECORDING_ENVELOPE_FORMAT,
328
+ producer: {
329
+ agentfootprintVersion: libraryVersion(),
330
+ footprintjsVersion: engineVersion(),
331
+ },
332
+ run: {
333
+ runId,
334
+ ...(sessionId !== undefined && { sessionId }),
335
+ ...(principal !== undefined && { principal }),
336
+ ...(tenant !== undefined && { tenant }),
337
+ startedAt,
338
+ ...(endedAt !== undefined && { endedAt }),
339
+ complete: facts.complete,
340
+ droppedEvents,
341
+ },
342
+ ...(configuration !== undefined && { configuration }),
343
+ privacy,
344
+ recording,
345
+ };
346
+ }
347
+ /**
348
+ * Build the envelope and hand it to a sink.
349
+ *
350
+ * @param source the handle from `recordRun(agent)`, or the recording it made.
351
+ * Prefer the handle: it is the only thing that knows how many
352
+ * events the cap discarded.
353
+ * @param options the destination, the run facts, and the privacy statement.
354
+ *
355
+ * @example
356
+ * ```ts
357
+ * const recorder = recordRun(agent);
358
+ * await agent.run({ message: 'Weather in San Francisco?' });
359
+ *
360
+ * const { uri } = await persistRecording(recorder, {
361
+ * sink: fileRecordingSink({ directory: './run-archive' }),
362
+ * run: { complete: true },
363
+ * });
364
+ * recorder.stop();
365
+ * ```
366
+ */
367
+ export async function persistRecording(source, options) {
368
+ const sink = options.sink;
369
+ if (!isRecord(sink) || !isFn(sink.write)) {
370
+ throw new TypeError(`[recording-envelope] persistRecording needs a sink with a write(envelope) method — ` +
371
+ `e.g. fileRecordingSink({ directory: './run-archive' }).`);
372
+ }
373
+ return sink.write(buildRecordingEnvelope(source, options));
374
+ }
375
+ //# sourceMappingURL=recordingEnvelope.js.map