footprintjs 4.12.2 → 4.13.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 (68) hide show
  1. package/CLAUDE.md +111 -0
  2. package/README.md +29 -1
  3. package/dist/esm/index.js +16 -3
  4. package/dist/esm/lib/engine/graph/StageNode.js +2 -3
  5. package/dist/esm/lib/engine/handlers/SubflowExecutor.js +29 -1
  6. package/dist/esm/lib/engine/handlers/types.js +1 -1
  7. package/dist/esm/lib/engine/index.js +1 -1
  8. package/dist/esm/lib/engine/narrative/CombinedNarrativeRecorder.js +176 -41
  9. package/dist/esm/lib/engine/narrative/FlowRecorderDispatcher.js +8 -3
  10. package/dist/esm/lib/engine/narrative/index.js +1 -1
  11. package/dist/esm/lib/engine/narrative/narrativeTypes.js +1 -1
  12. package/dist/esm/lib/engine/narrative/types.js +1 -1
  13. package/dist/esm/lib/engine/traversal/FlowchartTraverser.js +60 -36
  14. package/dist/esm/lib/engine/types.js +1 -1
  15. package/dist/esm/lib/reactive/createTypedScope.js +6 -3
  16. package/dist/esm/lib/reactive/types.js +2 -1
  17. package/dist/esm/lib/recorder/CombinedRecorder.js +211 -0
  18. package/dist/esm/lib/recorder/EmitRecorder.js +62 -0
  19. package/dist/esm/lib/recorder/index.js +2 -1
  20. package/dist/esm/lib/runner/FlowChartExecutor.js +123 -1
  21. package/dist/esm/lib/scope/ScopeFacade.js +117 -1
  22. package/dist/esm/lib/scope/detectCircular.js +74 -5
  23. package/dist/esm/lib/scope/types.js +1 -1
  24. package/dist/esm/recorders.js +1 -1
  25. package/dist/index.js +50 -32
  26. package/dist/lib/engine/graph/StageNode.js +2 -3
  27. package/dist/lib/engine/handlers/SubflowExecutor.js +29 -1
  28. package/dist/lib/engine/handlers/types.js +1 -1
  29. package/dist/lib/engine/index.js +1 -1
  30. package/dist/lib/engine/narrative/CombinedNarrativeRecorder.js +176 -41
  31. package/dist/lib/engine/narrative/FlowRecorderDispatcher.js +8 -3
  32. package/dist/lib/engine/narrative/index.js +1 -1
  33. package/dist/lib/engine/narrative/narrativeTypes.js +1 -1
  34. package/dist/lib/engine/narrative/types.js +1 -1
  35. package/dist/lib/engine/traversal/FlowchartTraverser.js +60 -36
  36. package/dist/lib/engine/types.js +1 -1
  37. package/dist/lib/reactive/createTypedScope.js +6 -3
  38. package/dist/lib/reactive/types.js +2 -1
  39. package/dist/lib/recorder/CombinedRecorder.js +218 -0
  40. package/dist/lib/recorder/EmitRecorder.js +63 -0
  41. package/dist/lib/recorder/index.js +7 -2
  42. package/dist/lib/runner/FlowChartExecutor.js +123 -1
  43. package/dist/lib/scope/ScopeFacade.js +117 -1
  44. package/dist/lib/scope/detectCircular.js +74 -5
  45. package/dist/lib/scope/types.js +1 -1
  46. package/dist/recorders.js +1 -1
  47. package/dist/types/index.d.ts +36 -2
  48. package/dist/types/lib/engine/graph/StageNode.d.ts +3 -7
  49. package/dist/types/lib/engine/handlers/SubflowExecutor.d.ts +2 -3
  50. package/dist/types/lib/engine/handlers/types.d.ts +19 -3
  51. package/dist/types/lib/engine/index.d.ts +1 -1
  52. package/dist/types/lib/engine/narrative/CombinedNarrativeRecorder.d.ts +38 -17
  53. package/dist/types/lib/engine/narrative/FlowRecorderDispatcher.d.ts +1 -1
  54. package/dist/types/lib/engine/narrative/index.d.ts +1 -1
  55. package/dist/types/lib/engine/narrative/narrativeTypes.d.ts +70 -6
  56. package/dist/types/lib/engine/narrative/types.d.ts +24 -2
  57. package/dist/types/lib/engine/traversal/FlowchartTraverser.d.ts +18 -0
  58. package/dist/types/lib/engine/types.d.ts +66 -0
  59. package/dist/types/lib/reactive/types.d.ts +61 -3
  60. package/dist/types/lib/recorder/CombinedRecorder.d.ts +173 -0
  61. package/dist/types/lib/recorder/EmitRecorder.d.ts +135 -0
  62. package/dist/types/lib/recorder/index.d.ts +3 -0
  63. package/dist/types/lib/runner/FlowChartExecutor.d.ts +85 -0
  64. package/dist/types/lib/scope/ScopeFacade.d.ts +40 -0
  65. package/dist/types/lib/scope/detectCircular.d.ts +30 -3
  66. package/dist/types/lib/scope/types.d.ts +21 -0
  67. package/dist/types/recorders.d.ts +3 -2
  68. package/package.json +1 -2
@@ -0,0 +1,173 @@
1
+ /**
2
+ * CombinedRecorder — a single observer that can hook into MULTIPLE event
3
+ * streams (currently: scope data-flow + control-flow). One object, one `id`,
4
+ * one consistent view of the execution.
5
+ *
6
+ * ## Why this exists
7
+ *
8
+ * Before `CombinedRecorder`, a consumer who wanted to observe both streams
9
+ * had to:
10
+ * 1. Implement both `Recorder` (8 methods) and `FlowRecorder` (12 methods)
11
+ * fully — stubbing every event they didn't care about.
12
+ * 2. Remember to call BOTH `attachRecorder(r)` AND `attachFlowRecorder(r)`.
13
+ * Forgetting the second call silently dropped half their events — no
14
+ * warning, no runtime error.
15
+ * 3. Re-implement coordination logic (buffering, ordering) themselves.
16
+ *
17
+ * `CombinedRecorder` collapses that into a single type and a single attach
18
+ * call (`executor.attachCombinedRecorder(r)`), with the library handling the
19
+ * routing internally. Consumers implement only the events they care about
20
+ * (`Partial<...>`) and the attach method duck-types at runtime to dispatch
21
+ * to the relevant channels.
22
+ *
23
+ * ## Forward compatibility
24
+ *
25
+ * When a third observer type is added (e.g. an `OperationRecorder`), the
26
+ * `CombinedRecorder` type gains an `& Partial<OperationRecorder>` clause and
27
+ * `attachCombinedRecorder` gains one more runtime branch in its dispatch.
28
+ * Consumers writing a `CombinedRecorder` today are NOT affected — their
29
+ * code keeps compiling and attaching correctly, because every new layer is
30
+ * optional.
31
+ *
32
+ * ## Example
33
+ *
34
+ * ```typescript
35
+ * import type { CombinedRecorder } from 'footprintjs';
36
+ *
37
+ * const audit: CombinedRecorder = {
38
+ * id: 'audit',
39
+ * onWrite: (e) => logWrite(e.key, e.value), // Recorder method
40
+ * onDecision: (e) => logDecision(e.chosen), // FlowRecorder method
41
+ * };
42
+ *
43
+ * executor.attachCombinedRecorder(audit);
44
+ * // ^ internally: detects both sets of methods, routes to both channels.
45
+ * ```
46
+ *
47
+ * ## Contract with existing APIs
48
+ *
49
+ * - `attachRecorder(r)` and `attachFlowRecorder(r)` remain unchanged.
50
+ * Consumers who want only ONE channel keep using them — explicit is good.
51
+ * - `attachCombinedRecorder(r)` is the ONLY way to guarantee an object is
52
+ * hooked into every stream it has methods for, without maintaining two
53
+ * attach calls at the call site.
54
+ * - Idempotency by `id` is preserved across channels: re-attaching with the
55
+ * same `id` replaces the previous instance on BOTH channels, not just one.
56
+ */
57
+ import type { FlowErrorEvent, FlowPauseEvent, FlowRecorder, FlowResumeEvent } from '../engine/narrative/types.js';
58
+ import type { ErrorEvent, PauseEvent, Recorder, ResumeEvent } from '../scope/types.js';
59
+ import type { EmitRecorder } from './EmitRecorder.js';
60
+ /**
61
+ * Method names that appear on BOTH `Recorder` and `FlowRecorder` but with
62
+ * different event payload types. For these, a `CombinedRecorder` declares
63
+ * ONE handler that receives the union of both payloads — consumers
64
+ * discriminate on `traversalContext`, which only control-flow events carry.
65
+ */
66
+ type SharedLifecycleOverlap = 'onError' | 'onPause' | 'onResume';
67
+ /** Lifecycle hooks (not event-specific) that both interfaces share identically. */
68
+ type SharedLifecycle = 'id' | 'clear' | 'toSnapshot';
69
+ /**
70
+ * A recorder that MAY observe any combination of supported event streams.
71
+ *
72
+ * Today's streams:
73
+ * - Scope data-flow (`Recorder`: onRead/onWrite/onCommit/onStageStart/…)
74
+ * - Control-flow (`FlowRecorder`: onDecision/onSubflowEntry/onLoop/…)
75
+ *
76
+ * All event handlers are optional — implement only what you care about.
77
+ * `id` is required so the library can deduplicate re-attaches.
78
+ *
79
+ * ## Shared method names (onError / onPause / onResume)
80
+ *
81
+ * Both `Recorder` and `FlowRecorder` declare these with DIFFERENT payload
82
+ * shapes. In a combined recorder, each such handler is called by BOTH
83
+ * channels with its own variant. The parameter type is a union — consumers
84
+ * can either handle both variants uniformly, or discriminate (control-flow
85
+ * variants carry a `traversalContext` field that data-flow variants lack).
86
+ *
87
+ * ## Forward compatibility
88
+ *
89
+ * When a third observer type ships (e.g. `OperationRecorder`), the type
90
+ * gains another `& Partial<…>` clause. Because every clause is `Partial`,
91
+ * existing `CombinedRecorder` implementations remain type-valid.
92
+ */
93
+ export type CombinedRecorder = Partial<Omit<Recorder, SharedLifecycleOverlap | SharedLifecycle>> & Partial<Omit<FlowRecorder, SharedLifecycleOverlap | SharedLifecycle>> & Partial<Omit<EmitRecorder, SharedLifecycle>> & {
94
+ readonly id: string;
95
+ clear?(): void;
96
+ toSnapshot?(): {
97
+ name: string;
98
+ description?: string;
99
+ preferredOperation?: 'translate' | 'accumulate' | 'aggregate';
100
+ data: unknown;
101
+ };
102
+ onError?(event: ErrorEvent | FlowErrorEvent): void;
103
+ onPause?(event: PauseEvent | FlowPauseEvent): void;
104
+ onResume?(event: ResumeEvent | FlowResumeEvent): void;
105
+ };
106
+ /**
107
+ * Discriminator for the union payload types on `CombinedRecorder`'s shared
108
+ * methods (`onError`, `onPause`, `onResume`). Returns `true` if the event
109
+ * was emitted from the control-flow channel (FlowRecorder).
110
+ *
111
+ * ## How it discriminates
112
+ *
113
+ * Scope-channel events extend `RecorderContext`, which carries `pipelineId`.
114
+ * Flow-channel events do not carry `pipelineId` (they carry an optional
115
+ * `traversalContext` instead, but that field is optional and cannot be
116
+ * relied on as a positive signal). We detect the flow variant by the
117
+ * ABSENCE of `pipelineId` — this is schema-stable as long as scope events
118
+ * continue to extend `RecorderContext`.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * onError: (e) => {
123
+ * if (isFlowEvent(e)) {
124
+ * // Narrowed to FlowErrorEvent: has stageName, message, structuredError
125
+ * } else {
126
+ * // Narrowed to ErrorEvent: has error, operation, key?
127
+ * }
128
+ * }
129
+ * ```
130
+ */
131
+ export declare function isFlowEvent<T>(event: T): event is Exclude<T, {
132
+ pipelineId: string;
133
+ }>;
134
+ /**
135
+ * True iff the recorder implements at least one data-flow event method.
136
+ * Used by `executor.attachCombinedRecorder` to decide whether to hook into
137
+ * the scope data-flow channel.
138
+ *
139
+ * ## Detection rule
140
+ *
141
+ * Accepts both object-literal recorders (own-property handlers) AND class
142
+ * instances (handlers declared on the class prototype). Rejects handlers
143
+ * inherited from `Object.prototype` — that's always accidental or
144
+ * malicious pollution, never a legitimate recorder.
145
+ *
146
+ * ## Lifecycle exclusions
147
+ *
148
+ * `clear` and `toSnapshot` are NOT counted as event methods — a recorder
149
+ * that only implements those has nothing to observe and would be a no-op
150
+ * on either channel.
151
+ *
152
+ * ## Return type
153
+ *
154
+ * Returns plain `boolean` (not a type predicate). The full `Recorder`
155
+ * interface has payload types that diverge from `CombinedRecorder`'s union
156
+ * variants for shared methods, so a narrowing predicate would be unsound.
157
+ * Callers that need to treat the recorder as a `Recorder` do so explicitly
158
+ * at the attach site — the cast is the contract that each channel passes
159
+ * its own payload variant.
160
+ */
161
+ export declare function hasRecorderMethods(r: CombinedRecorder): boolean;
162
+ /**
163
+ * True iff the recorder implements at least one control-flow event method.
164
+ * See `hasRecorderMethods`.
165
+ */
166
+ export declare function hasFlowRecorderMethods(r: CombinedRecorder): boolean;
167
+ /**
168
+ * True iff the recorder implements at least one emit-channel event method.
169
+ * See `hasRecorderMethods` for the ownership-detection rules (own or
170
+ * class-prototype methods count; `Object.prototype` pollution does not).
171
+ */
172
+ export declare function hasEmitRecorderMethods(r: CombinedRecorder): boolean;
173
+ export {};
@@ -0,0 +1,135 @@
1
+ /**
2
+ * EmitRecorder — the third observer channel, alongside `Recorder`
3
+ * (scope data-flow) and `FlowRecorder` (control-flow).
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * The first two channels capture what the LIBRARY knows about — scope
8
+ * reads/writes fired by `setValue`, and control-flow events fired by the
9
+ * traverser. But **user-emitted structured events** — things a stage
10
+ * function wants to surface for observability (LLM tokens, billing metrics,
11
+ * auth decisions, domain milestones) — have nowhere to go today. They land
12
+ * in unobserved `DiagnosticCollector` side bags that no recorder watches.
13
+ *
14
+ * This channel closes that gap. Consumer calls `scope.$emit(name, payload)`
15
+ * from inside a stage; the library enriches the event with stage context
16
+ * and dispatches it synchronously to every attached `EmitRecorder`.
17
+ *
18
+ * ## Design properties
19
+ *
20
+ * - **Pass-through, not buffered.** Zero allocation when no recorder is
21
+ * attached. Events delivered synchronously, in-order, at call time.
22
+ * Same semantics as `onRead`/`onWrite` already use.
23
+ * - **Library-agnostic vocabulary.** Event names are consumer-chosen strings.
24
+ * Convention: hierarchical dotted names, e.g. `'agentfootprint.llm.tokens'`,
25
+ * `'myapp.billing.spend'`. Library enforces no registry.
26
+ * - **Auto-enriched context.** Every event carries `stageName`,
27
+ * `runtimeStageId`, `subflowPath`, `pipelineId`, `timestamp`. Consumers
28
+ * never need to thread execution context through their emit payloads.
29
+ * - **Redaction-aware.** `RedactionPolicy.emitPatterns` matches event names
30
+ * before dispatch — matched events have their payload replaced with
31
+ * `'[REDACTED]'` so secrets can't leak via emit.
32
+ *
33
+ * ## Relationship to existing channels
34
+ *
35
+ * | Channel | Fires when | Built-in consumers |
36
+ * |-----------------|------------------------------------|------------------------|
37
+ * | `Recorder` | scope read/write/commit | DebugRecorder, MetricRecorder |
38
+ * | `FlowRecorder` | traversal transitions | NarrativeFlowRecorder, etc. |
39
+ * | `EmitRecorder` | consumer calls `scope.$emit(...)` | (none ships today; Phase 3.X adds MemoryEmitRecorder) |
40
+ *
41
+ * `CombinedRecorder` (from `./CombinedRecorder.ts`) intersects all three
42
+ * via `Partial<...>`. A consumer can write ONE object implementing any
43
+ * combination; `executor.attachCombinedRecorder(r)` duck-types and routes
44
+ * to the right channel(s).
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * const tokenMeter: EmitRecorder = {
49
+ * id: 'token-meter',
50
+ * onEmit(event) {
51
+ * if (event.name === 'agentfootprint.llm.tokens') {
52
+ * this.total += (event.payload as { input: number; output: number }).input;
53
+ * }
54
+ * },
55
+ * total: 0,
56
+ * } as any;
57
+ *
58
+ * executor.attachEmitRecorder(tokenMeter);
59
+ * ```
60
+ */
61
+ import type { RecorderOperation } from './RecorderOperation.js';
62
+ /**
63
+ * Event delivered to `EmitRecorder.onEmit`.
64
+ *
65
+ * Name + payload are consumer-supplied via `scope.$emit(name, payload)`.
66
+ * Everything else is library-enriched at dispatch time from the current
67
+ * stage's execution context.
68
+ */
69
+ export interface EmitEvent {
70
+ /**
71
+ * Consumer-supplied event name. Convention: hierarchical dotted namespace
72
+ * (e.g. `'agentfootprint.llm.tokens'`, `'myapp.billing.spend'`). Keeps
73
+ * vocabularies collision-free across libraries/apps without requiring a
74
+ * central registry.
75
+ */
76
+ readonly name: string;
77
+ /**
78
+ * Consumer-supplied payload. Shape is up to the consumer and their
79
+ * convention; library treats it as opaque and passes through unchanged
80
+ * (modulo redaction — see `RedactionPolicy.emitPatterns`).
81
+ *
82
+ * When redacted, replaced with the string `'[REDACTED]'`.
83
+ */
84
+ readonly payload: unknown;
85
+ /** Name of the stage that emitted this event. */
86
+ readonly stageName: string;
87
+ /**
88
+ * Unique per-execution-step identifier — the same value recorder events
89
+ * and commit-log entries carry. See `runtimeStageId.ts` for format.
90
+ */
91
+ readonly runtimeStageId: string;
92
+ /**
93
+ * Subflow path from the outermost parent down to the subflow that emitted
94
+ * this event. Empty array when the emit came from the root flowchart.
95
+ * Matches the convention used by `FlowPauseEvent.subflowPath`,
96
+ * `FlowchartCheckpoint.subflowPath`, etc.
97
+ */
98
+ readonly subflowPath: readonly string[];
99
+ /** Pipeline/run identifier (matches `RecorderContext.pipelineId`). */
100
+ readonly pipelineId: string;
101
+ /** Emission timestamp in milliseconds since epoch (`Date.now()`). */
102
+ readonly timestamp: number;
103
+ }
104
+ /**
105
+ * Pluggable observer for consumer-emitted structured events.
106
+ *
107
+ * All methods are optional; implement only what you care about. Recorders
108
+ * are invoked synchronously in attachment order. If a recorder throws, the
109
+ * error is caught and isolated — other recorders continue to receive the
110
+ * event and the emitting stage is unaffected.
111
+ */
112
+ export interface EmitRecorder {
113
+ /**
114
+ * Stable identifier for idempotent attach/detach. Re-attaching with the
115
+ * same id replaces the previous registration on the executor.
116
+ */
117
+ readonly id: string;
118
+ /** Called for every `scope.$emit(name, payload)` call in any stage. */
119
+ onEmit?(event: EmitEvent): void;
120
+ /**
121
+ * Optional: reset recorder-internal state between runs. Called by the
122
+ * executor before each `run()`.
123
+ */
124
+ clear?(): void;
125
+ /**
126
+ * Optional: expose collected data for inclusion in
127
+ * `executor.getSnapshot().recorders`.
128
+ */
129
+ toSnapshot?(): {
130
+ name: string;
131
+ description?: string;
132
+ preferredOperation?: RecorderOperation;
133
+ data: unknown;
134
+ };
135
+ }
@@ -1,5 +1,8 @@
1
+ export type { CombinedRecorder } from './CombinedRecorder.js';
2
+ export { hasEmitRecorderMethods, hasFlowRecorderMethods, hasRecorderMethods, isFlowEvent } from './CombinedRecorder.js';
1
3
  export type { CompositeSnapshot } from './CompositeRecorder.js';
2
4
  export { CompositeRecorder } from './CompositeRecorder.js';
5
+ export type { EmitEvent, EmitRecorder } from './EmitRecorder.js';
3
6
  export { KeyedRecorder } from './KeyedRecorder.js';
4
7
  export { RecorderOperation } from './RecorderOperation.js';
5
8
  export { SequenceRecorder } from './SequenceRecorder.js';
@@ -22,6 +22,8 @@ import type { ManifestEntry } from '../engine/narrative/recorders/ManifestFlowRe
22
22
  import type { FlowRecorder } from '../engine/narrative/types.js';
23
23
  import { type ExecutorResult, type ExtractorError, type FlowChart, type RunOptions, type ScopeFactory, type SerializedPipelineStructure, type StageNode, type StreamHandlers, type SubflowResult } from '../engine/types.js';
24
24
  import type { FlowchartCheckpoint } from '../pause/types.js';
25
+ import type { CombinedRecorder } from '../recorder/CombinedRecorder.js';
26
+ import type { EmitRecorder } from '../recorder/EmitRecorder.js';
25
27
  import type { ScopeProtectionMode } from '../scope/protection/types.js';
26
28
  import type { Recorder, RedactionPolicy, RedactionReport } from '../scope/types.js';
27
29
  import { type RuntimeSnapshot } from './ExecutionRuntime.js';
@@ -216,6 +218,89 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
216
218
  detachFlowRecorder(id: string): void;
217
219
  /** Returns a defensive copy of attached FlowRecorders. */
218
220
  getFlowRecorders(): FlowRecorder[];
221
+ /**
222
+ * Attach a recorder that may observe multiple event streams (scope
223
+ * data-flow, control-flow, or both). Detects at runtime which streams the
224
+ * recorder has methods for and routes it to the correct internal channels.
225
+ *
226
+ * Preferred over calling `attachRecorder` and `attachFlowRecorder`
227
+ * separately, because forgetting one of the two is a silent foot-gun —
228
+ * half your events never fire and there is no runtime warning. With
229
+ * `attachCombinedRecorder` the library guarantees the recorder's declared
230
+ * methods all fire, and adds no overhead versus two explicit calls.
231
+ *
232
+ * ## Idempotency
233
+ *
234
+ * Idempotent by `id` across ALL channels — re-attaching with the same `id`
235
+ * replaces the previous instance everywhere it was registered. Mixing
236
+ * `attachCombinedRecorder(x)` with a prior `attachRecorder(y)` or
237
+ * `attachFlowRecorder(y)` that share `x.id === y.id` is also safe: the
238
+ * combined attach replaces the single-channel registration on whichever
239
+ * channel(s) `x` has methods for. No duplicate firings occur.
240
+ *
241
+ * ## Narrative activation
242
+ *
243
+ * If the recorder has any control-flow methods, `enableNarrative()` is
244
+ * called as a side effect (the narrative subsystem is required to emit
245
+ * control-flow events). Data-flow-only recorders do NOT activate the
246
+ * narrative.
247
+ *
248
+ * ## Detection rule
249
+ *
250
+ * Only **own** event methods count (see `hasRecorderMethods`). Methods
251
+ * inherited via the prototype chain are ignored — this protects against
252
+ * accidental `Object.prototype` pollution attaching handlers you never
253
+ * declared. A recorder that provides only `clear`/`toSnapshot` is a
254
+ * no-op and emits a dev-mode warning to surface the likely mistake.
255
+ *
256
+ * @example
257
+ * ```typescript
258
+ * const audit: CombinedRecorder = {
259
+ * id: 'audit',
260
+ * onWrite: (e) => log('scope write', e.key),
261
+ * onDecision: (e) => log('routed to', e.chosen),
262
+ * };
263
+ * executor.attachCombinedRecorder(audit);
264
+ * ```
265
+ */
266
+ attachCombinedRecorder(recorder: CombinedRecorder): void;
267
+ /**
268
+ * Detach a combined recorder from all channels it was attached to.
269
+ * Safe to call if the recorder was only on one channel or never attached.
270
+ */
271
+ detachCombinedRecorder(id: string): void;
272
+ /**
273
+ * Attach an `EmitRecorder` — an observer for consumer-emitted structured
274
+ * events fired via `scope.$emit(name, payload)`.
275
+ *
276
+ * Internally, emit recorders share the scope-recorder channel because
277
+ * emit events fire from inside `ScopeFacade` during stage execution,
278
+ * same timing as `onRead`/`onWrite`. This method is a convenience that
279
+ * delegates to `attachRecorder` — consumers can also use
280
+ * `attachRecorder` directly for a recorder that implements BOTH
281
+ * `onWrite` and `onEmit`. Either approach places the recorder on the
282
+ * same underlying list, so `onEmit` fires exactly once per event.
283
+ *
284
+ * **Idempotent by `id`:** replaces existing recorder with same `id`.
285
+ *
286
+ * @example
287
+ * ```typescript
288
+ * executor.attachEmitRecorder({
289
+ * id: 'token-meter',
290
+ * onEmit: (e) => {
291
+ * if (e.name === 'agentfootprint.llm.tokens') trackTokens(e.payload);
292
+ * },
293
+ * });
294
+ * ```
295
+ */
296
+ attachEmitRecorder(recorder: EmitRecorder): void;
297
+ /** Detach an `EmitRecorder` by id. Safe to call if never attached. */
298
+ detachEmitRecorder(id: string): void;
299
+ /**
300
+ * Returns a defensive copy of attached recorders filtered to those that
301
+ * implement `onEmit`. Useful for inspection during testing.
302
+ */
303
+ getEmitRecorders(): EmitRecorder[];
219
304
  /**
220
305
  * Returns the execution narrative.
221
306
  *
@@ -18,6 +18,12 @@ import { StageContext } from '../memory/StageContext.js';
18
18
  import type { CommitEvent, Recorder, RedactionPolicy, RedactionReport } from './types.js';
19
19
  export declare class ScopeFacade {
20
20
  static readonly BRAND: unique symbol;
21
+ /**
22
+ * Shared sentinel returned by `_getSubflowPath()` for root-level stages
23
+ * (no subflow nesting). Avoids per-call allocation of a fresh
24
+ * `Object.freeze([])` on every `emitEvent` in the common no-subflow case.
25
+ */
26
+ private static readonly _EMPTY_SUBFLOW_PATH;
21
27
  protected _stageContext: StageContext;
22
28
  protected _stageName: string;
23
29
  protected readonly _readOnlyValues?: unknown;
@@ -75,6 +81,40 @@ export declare class ScopeFacade {
75
81
  addErrorInfo(key: string, value: unknown): void;
76
82
  addMetric(metricName: string, value: unknown): void;
77
83
  addEval(metricName: string, value: unknown): void;
84
+ /**
85
+ * Fire a structured event to every attached recorder implementing
86
+ * `onEmit`. Synchronous, in-order, pass-through — no buffering.
87
+ *
88
+ * - **Fast-path**: zero allocation + zero cost when no recorders are
89
+ * attached (early return on empty list).
90
+ * - **Enrichment**: library auto-adds `stageName`, `runtimeStageId`,
91
+ * `subflowPath`, `pipelineId`, `timestamp` to the event.
92
+ * - **Redaction**: `RedactionPolicy.emitPatterns` regexes are matched
93
+ * against `name` — matched events have their payload replaced with
94
+ * `'[REDACTED]'` before dispatch.
95
+ * - **Error isolation**: a throwing `onEmit` does not propagate — it is
96
+ * caught and routed to `onError` on remaining recorders, matching the
97
+ * pattern used by other scope events.
98
+ *
99
+ * Consumers call this via the `scope.$emit(name, payload)` scope method;
100
+ * the method routes here via `createTypedScope`.
101
+ */
102
+ emitEvent(name: string, payload: unknown): void;
103
+ /**
104
+ * Build the subflowPath (outer → inner) for event enrichment.
105
+ *
106
+ * Parses from `runtimeStageId` which has the format
107
+ * `[subflowPath/]stageId#executionIndex` (see `lib/engine/runtimeStageId.ts`).
108
+ * Subflow isolation prevents walking the parent-chain across boundaries,
109
+ * so the runtimeStageId — globally unique, includes full path — is the
110
+ * canonical source of truth for the subflow hierarchy at emit time.
111
+ *
112
+ * Examples:
113
+ * 'seed#0' → [] (root)
114
+ * 'sf-inner/inner#5' → ['sf-inner']
115
+ * 'sf-a/sf-b/stage#3' → ['sf-a', 'sf-b'] (nested)
116
+ */
117
+ private _getSubflowPath;
78
118
  /** Returns all state keys without firing onRead. Used by TypedScope ownKeys/has traps. */
79
119
  getStateKeys(): string[];
80
120
  /** Check key existence without firing onRead. Used by TypedScope has trap.
@@ -12,9 +12,36 @@
12
12
  * Only checks plain objects and arrays — class instances, Date, Map, etc. are skipped.
13
13
  */
14
14
  export declare function hasCircularReference(value: unknown, ancestors?: WeakSet<object>): boolean;
15
- /** Enable dev-mode circular reference detection. Call once at app startup. */
15
+ /**
16
+ * Enable dev-mode diagnostics across the whole library.
17
+ *
18
+ * When on, the library performs developer-only checks (circular references,
19
+ * empty recorder detection, suspicious predicate shapes, etc.) and emits
20
+ * `console.warn` messages to help you catch mistakes early.
21
+ *
22
+ * Call once at application startup. See the module header for the full
23
+ * list of what dev-mode gates.
24
+ *
25
+ * @example
26
+ * ```ts
27
+ * import { enableDevMode } from 'footprintjs';
28
+ * if (process.env.NODE_ENV !== 'production') enableDevMode();
29
+ * ```
30
+ */
16
31
  export declare function enableDevMode(): void;
17
- /** Disable dev-mode detection (default). */
32
+ /**
33
+ * Disable dev-mode diagnostics across the whole library (default state).
34
+ *
35
+ * All dev-only checks become no-ops. Safe to call in production paths —
36
+ * typically the default never needs to be re-asserted, but this is the
37
+ * documented way to turn the flag off if your code enabled it earlier.
38
+ */
18
39
  export declare function disableDevMode(): void;
19
- /** Returns whether dev-mode detection is enabled. */
40
+ /**
41
+ * Returns whether dev-mode diagnostics are currently enabled.
42
+ *
43
+ * Library internals call this before running any dev-only check. Consumers
44
+ * rarely need to call it directly — prefer `enableDevMode()` at startup and
45
+ * let the library gate its own diagnostics internally.
46
+ */
20
47
  export declare function isDevMode(): boolean;
@@ -68,6 +68,18 @@ export interface RedactionPolicy {
68
68
  /** Field-level redaction within objects — key → array of fields to scrub.
69
69
  * Supports dot-notation for nested paths (e.g. 'address.zip'). */
70
70
  fields?: Record<string, string[]>;
71
+ /**
72
+ * Regex patterns matched against `EmitEvent.name` for `scope.$emit(...)`
73
+ * calls. Any emit event whose name matches has its payload replaced with
74
+ * the string `'[REDACTED]'` before dispatch to recorders.
75
+ *
76
+ * Example:
77
+ * ```ts
78
+ * { emitPatterns: [/\.auth\./, /\.billing\./] }
79
+ * // Hides payloads of events like 'myapp.auth.check' and 'myapp.billing.spend'
80
+ * ```
81
+ */
82
+ emitPatterns?: RegExp[];
71
83
  }
72
84
  /**
73
85
  * Compliance-friendly report of what was redacted. Never includes values.
@@ -98,6 +110,15 @@ export interface Recorder {
98
110
  onStageEnd?(event: StageEvent): void;
99
111
  onPause?(event: PauseEvent): void;
100
112
  onResume?(event: ResumeEvent): void;
113
+ /**
114
+ * Fires for every `scope.$emit(name, payload)` call during a stage.
115
+ * Optional — implement only if you want to observe consumer-emitted
116
+ * structured events. See `EmitRecorder` for the focused interface
117
+ * (structurally compatible; this field is the same shape).
118
+ *
119
+ * @see EmitRecorder in `src/lib/recorder/EmitRecorder.ts`
120
+ */
121
+ onEmit?(event: import('../recorder/EmitRecorder.js').EmitEvent): void;
101
122
  /** Reset state before each executor.run() — prevents cross-run accumulation. */
102
123
  clear?(): void;
103
124
  /** Expose collected data for inclusion in executor.getSnapshot().recorders. */
@@ -25,12 +25,13 @@
25
25
  */
26
26
  import type { CombinedNarrativeRecorderOptions } from './lib/engine/narrative/CombinedNarrativeRecorder.js';
27
27
  import { CombinedNarrativeRecorder } from './lib/engine/narrative/CombinedNarrativeRecorder.js';
28
- import type { BreakRenderContext, CombinedNarrativeEntry, DecisionRenderContext, ErrorRenderContext, ForkRenderContext, LoopRenderContext, NarrativeRenderer, OpRenderContext, SelectedRenderContext, StageRenderContext, SubflowRenderContext } from './lib/engine/narrative/narrativeTypes.js';
28
+ import type { BreakRenderContext, CombinedNarrativeEntry, DecisionRenderContext, EmitRenderContext, ErrorRenderContext, ForkRenderContext, LoopRenderContext, NarrativeFormatter, NarrativeRenderer, OpRenderContext, SelectedRenderContext, StageRenderContext, SubflowRenderContext } from './lib/engine/narrative/narrativeTypes.js';
29
29
  import { AdaptiveNarrativeFlowRecorder } from './lib/engine/narrative/recorders/AdaptiveNarrativeFlowRecorder.js';
30
30
  import type { ManifestEntry } from './lib/engine/narrative/recorders/ManifestFlowRecorder.js';
31
31
  import { ManifestFlowRecorder } from './lib/engine/narrative/recorders/ManifestFlowRecorder.js';
32
32
  import { MilestoneNarrativeFlowRecorder } from './lib/engine/narrative/recorders/MilestoneNarrativeFlowRecorder.js';
33
33
  import { WindowedNarrativeFlowRecorder } from './lib/engine/narrative/recorders/WindowedNarrativeFlowRecorder.js';
34
+ import type { EmitEvent, EmitRecorder } from './lib/recorder/EmitRecorder.js';
34
35
  import type { DebugEntry, DebugRecorderOptions } from './lib/scope/recorders/DebugRecorder.js';
35
36
  import { DebugRecorder } from './lib/scope/recorders/DebugRecorder.js';
36
37
  import type { AggregatedMetrics, MetricRecorderOptions, StageMetrics } from './lib/scope/recorders/MetricRecorder.js';
@@ -59,7 +60,7 @@ export declare function manifest(): ManifestInstance;
59
60
  export declare function adaptive(): AdaptiveNarrativeFlowRecorder;
60
61
  export declare function milestone(): MilestoneNarrativeFlowRecorder;
61
62
  export declare function windowed(maxEntries?: number): WindowedNarrativeFlowRecorder;
62
- export type { BreakRenderContext, CombinedNarrativeRecorderOptions, DecisionRenderContext, ErrorRenderContext, ForkRenderContext, LoopRenderContext, NarrativeRenderer, OpRenderContext, SelectedRenderContext, StageRenderContext, SubflowRenderContext, };
63
+ export type { BreakRenderContext, CombinedNarrativeRecorderOptions, DecisionRenderContext, EmitEvent, EmitRecorder, EmitRenderContext, ErrorRenderContext, ForkRenderContext, LoopRenderContext, NarrativeFormatter, NarrativeRenderer, OpRenderContext, SelectedRenderContext, StageRenderContext, SubflowRenderContext, };
63
64
  export type { AggregatedMetrics, MetricRecorderOptions, StageMetrics };
64
65
  export type { CompositeSnapshot } from './lib/recorder/index.js';
65
66
  export { CompositeRecorder } from './lib/recorder/index.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "footprintjs",
3
- "version": "4.12.2",
3
+ "version": "4.13.0",
4
4
  "description": "Explainable backend flows — automatic causal traces, decision evidence, and MCP tool generation for AI agents",
5
5
  "license": "MIT",
6
6
  "author": "Sanjay Krishna Anbalagan",
@@ -97,7 +97,6 @@
97
97
  ]
98
98
  },
99
99
  "sideEffects": false,
100
- "dependencies": {},
101
100
  "peerDependencies": {
102
101
  "zod": "^3.0.0 || ^4.0.0"
103
102
  },