footprintjs 4.0.5 → 4.2.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 (61) hide show
  1. package/CLAUDE.md +7 -0
  2. package/dist/esm/index.js +6 -1
  3. package/dist/esm/lib/builder/FlowChartBuilder.js +13 -8
  4. package/dist/esm/lib/engine/graph/StageNode.js +1 -1
  5. package/dist/esm/lib/engine/handlers/DeciderHandler.js +8 -7
  6. package/dist/esm/lib/engine/handlers/SelectorHandler.js +8 -5
  7. package/dist/esm/lib/engine/handlers/SubflowExecutor.js +28 -246
  8. package/dist/esm/lib/engine/narrative/CombinedNarrativeRecorder.js +232 -139
  9. package/dist/esm/lib/engine/narrative/index.js +1 -1
  10. package/dist/esm/lib/engine/narrative/narrativeTypes.js +1 -1
  11. package/dist/esm/lib/engine/traversal/FlowchartTraverser.js +64 -22
  12. package/dist/esm/lib/engine/types.js +1 -1
  13. package/dist/esm/lib/memory/StageContext.js +36 -9
  14. package/dist/esm/lib/reactive/createTypedScope.js +9 -6
  15. package/dist/esm/lib/reactive/types.js +1 -1
  16. package/dist/esm/lib/recorder/CompositeRecorder.js +172 -0
  17. package/dist/esm/lib/recorder/index.js +2 -0
  18. package/dist/esm/lib/runner/FlowChartExecutor.js +33 -3
  19. package/dist/esm/lib/scope/ScopeFacade.js +52 -4
  20. package/dist/esm/lib/scope/recorders/DebugRecorder.js +18 -2
  21. package/dist/esm/lib/scope/recorders/MetricRecorder.js +51 -4
  22. package/dist/esm/recorders.js +6 -5
  23. package/dist/index.js +21 -15
  24. package/dist/lib/builder/FlowChartBuilder.js +13 -8
  25. package/dist/lib/engine/graph/StageNode.js +1 -1
  26. package/dist/lib/engine/handlers/DeciderHandler.js +8 -7
  27. package/dist/lib/engine/handlers/SelectorHandler.js +8 -5
  28. package/dist/lib/engine/handlers/SubflowExecutor.js +27 -245
  29. package/dist/lib/engine/narrative/CombinedNarrativeRecorder.js +232 -139
  30. package/dist/lib/engine/narrative/index.js +1 -1
  31. package/dist/lib/engine/narrative/narrativeTypes.js +1 -1
  32. package/dist/lib/engine/traversal/FlowchartTraverser.js +64 -22
  33. package/dist/lib/engine/types.js +1 -1
  34. package/dist/lib/memory/StageContext.js +36 -9
  35. package/dist/lib/reactive/createTypedScope.js +9 -6
  36. package/dist/lib/reactive/types.js +1 -1
  37. package/dist/lib/recorder/CompositeRecorder.js +176 -0
  38. package/dist/lib/recorder/index.js +6 -0
  39. package/dist/lib/runner/FlowChartExecutor.js +33 -3
  40. package/dist/lib/scope/ScopeFacade.js +52 -4
  41. package/dist/lib/scope/recorders/DebugRecorder.js +18 -2
  42. package/dist/lib/scope/recorders/MetricRecorder.js +51 -4
  43. package/dist/recorders.js +8 -6
  44. package/dist/types/index.d.ts +4 -0
  45. package/dist/types/lib/engine/graph/StageNode.d.ts +4 -0
  46. package/dist/types/lib/engine/handlers/SubflowExecutor.d.ts +10 -29
  47. package/dist/types/lib/engine/narrative/CombinedNarrativeRecorder.d.ts +18 -1
  48. package/dist/types/lib/engine/narrative/index.d.ts +1 -1
  49. package/dist/types/lib/engine/narrative/narrativeTypes.d.ts +92 -0
  50. package/dist/types/lib/engine/traversal/FlowchartTraverser.d.ts +20 -8
  51. package/dist/types/lib/engine/types.d.ts +51 -0
  52. package/dist/types/lib/memory/StageContext.d.ts +14 -3
  53. package/dist/types/lib/reactive/types.d.ts +2 -0
  54. package/dist/types/lib/recorder/CompositeRecorder.d.ts +95 -0
  55. package/dist/types/lib/recorder/index.d.ts +2 -0
  56. package/dist/types/lib/runner/FlowChartExecutor.d.ts +27 -1
  57. package/dist/types/lib/scope/ScopeFacade.d.ts +11 -0
  58. package/dist/types/lib/scope/recorders/DebugRecorder.d.ts +16 -0
  59. package/dist/types/lib/scope/recorders/MetricRecorder.d.ts +47 -2
  60. package/dist/types/recorders.d.ts +9 -4
  61. package/package.json +1 -1
@@ -1,54 +1,35 @@
1
1
  /**
2
- * SubflowExecutor — Isolated recursive execution with I/O mapping.
2
+ * SubflowExecutor — Isolation boundary for subflow execution.
3
3
  *
4
4
  * Responsibilities:
5
- * - Execute subflows with isolated ExecutionRuntime contexts
5
+ * - Create isolated ExecutionRuntime for each subflow
6
6
  * - Apply input/output mapping via SubflowInputMapper
7
- * - Handle nested subflow detection and delegation
7
+ * - Delegate traversal to a factory-created FlowchartTraverser
8
8
  * - Track subflow results for debugging/visualization
9
9
  *
10
10
  * Each subflow gets its own GlobalStore for isolation.
11
- * The subflow's `next` chain after children is NOT executed inside —
12
- * the parent's executeNode continues with node.next after return.
11
+ * Traversal uses the SAME 7-phase algorithm as the top-level traverser
12
+ * (via SubflowTraverserFactory), so deciders, selectors, loops, lazy subflows,
13
+ * and abort signals all work inside subflows automatically.
13
14
  */
14
15
  import type { StageContext } from '../../memory/StageContext.js';
15
16
  import type { StageNode } from '../graph/StageNode.js';
16
17
  import type { TraversalContext } from '../narrative/types.js';
17
- import type { HandlerDeps, StageFunction, SubflowResult } from '../types.js';
18
- import type { NodeResolver } from './NodeResolver.js';
19
- import type { CallExtractorFn, RunStageFn } from './types.js';
20
- export type { CallExtractorFn, RunStageFn } from './types.js';
21
- /** Callback for getting a stage function from the stage map. */
22
- export type GetStageFnFn<TOut = any, TScope = any> = (node: StageNode<TOut, TScope>) => StageFunction<TOut, TScope> | undefined;
18
+ import type { HandlerDeps, SubflowResult, SubflowTraverserFactory } from '../types.js';
23
19
  export declare class SubflowExecutor<TOut = any, TScope = any> {
24
20
  private deps;
25
- private nodeResolver;
26
- private executeStage;
27
- private callExtractor;
28
- private getStageFn;
29
- private currentSubflowDeps?;
30
- private currentSubflowRoot?;
31
- private subflowResultsMap?;
32
- constructor(deps: HandlerDeps<TOut, TScope>, nodeResolver: NodeResolver<TOut, TScope>, executeStage: RunStageFn<TOut, TScope>, callExtractor: CallExtractorFn<TOut, TScope>, getStageFn: GetStageFnFn<TOut, TScope>);
21
+ private traverserFactory;
22
+ constructor(deps: HandlerDeps<TOut, TScope>, traverserFactory: SubflowTraverserFactory<TOut, TScope>);
33
23
  /**
34
24
  * Execute a subflow with isolated context.
35
25
  *
36
26
  * 1. Creates a fresh ExecutionRuntime for the subflow
37
27
  * 2. Applies input mapping to seed the subflow's GlobalStore
38
- * 3. Executes the subflow's internal structure
28
+ * 3. Delegates traversal to a factory-created FlowchartTraverser
39
29
  * 4. Applies output mapping to write results back to parent scope
40
30
  * 5. Stores execution data for debugging/visualization
41
31
  */
42
32
  executeSubflow(node: StageNode<TOut, TScope>, parentContext: StageContext, breakFlag: {
43
33
  shouldBreak: boolean;
44
34
  }, branchPath: string | undefined, subflowResultsMap: Map<string, SubflowResult>, parentTraversalContext?: TraversalContext): Promise<any>;
45
- /**
46
- * Internal execution within subflow context.
47
- * Mirrors the traverser's executeNode but within the subflow's isolated runtime.
48
- */
49
- private executeSubflowInternal;
50
- private computeContextDepth;
51
- private getStagePath;
52
- private executeNodeChildrenInternal;
53
- private executeSelectedChildrenInternal;
54
35
  }
@@ -13,12 +13,18 @@
13
13
  * emit the stage entry + flush the buffered ops in one pass.
14
14
  */
15
15
  import type { ReadEvent, Recorder, WriteEvent } from '../../scope/types.js';
16
- import type { CombinedNarrativeEntry } from './narrativeTypes.js';
16
+ import type { CombinedNarrativeEntry, NarrativeRenderer } from './narrativeTypes.js';
17
17
  import type { FlowBreakEvent, FlowDecisionEvent, FlowErrorEvent, FlowForkEvent, FlowLoopEvent, FlowRecorder, FlowSelectedEvent, FlowStageEvent, FlowSubflowEvent } from './types.js';
18
18
  export interface CombinedNarrativeRecorderOptions {
19
19
  includeStepNumbers?: boolean;
20
20
  includeValues?: boolean;
21
21
  maxValueLength?: number;
22
+ /** Custom value formatter. Called at render time (flushOps), not capture time.
23
+ * Receives the raw value and maxValueLength. Defaults to summarizeValue(). */
24
+ formatValue?: (value: unknown, maxLen: number) => string;
25
+ /** Pluggable renderer for customizing narrative output. Unimplemented methods
26
+ * fall back to the default English renderer. See NarrativeRenderer docs. */
27
+ renderer?: NarrativeRenderer;
22
28
  }
23
29
  export declare class CombinedNarrativeRecorder implements FlowRecorder, Recorder {
24
30
  readonly id: string;
@@ -39,6 +45,8 @@ export declare class CombinedNarrativeRecorder implements FlowRecorder, Recorder
39
45
  private includeStepNumbers;
40
46
  private includeValues;
41
47
  private maxValueLength;
48
+ private formatValue;
49
+ private renderer?;
42
50
  constructor(options?: CombinedNarrativeRecorderOptions & {
43
51
  id?: string;
44
52
  });
@@ -79,4 +87,13 @@ export declare class CombinedNarrativeRecorder implements FlowRecorder, Recorder
79
87
  private consumeFirstStageFlag;
80
88
  private bufferOp;
81
89
  private flushOps;
90
+ private defaultRenderStage;
91
+ private defaultRenderOp;
92
+ private defaultRenderDecision;
93
+ private defaultRenderFork;
94
+ private defaultRenderSelected;
95
+ private defaultRenderSubflow;
96
+ private defaultRenderLoop;
97
+ private defaultRenderBreak;
98
+ private defaultRenderError;
82
99
  }
@@ -1,6 +1,6 @@
1
1
  export type { CombinedNarrativeRecorderOptions } from './CombinedNarrativeRecorder.js';
2
2
  export { CombinedNarrativeRecorder } from './CombinedNarrativeRecorder.js';
3
- export type { CombinedNarrativeEntry, CombinedNarrativeOptions } from './narrativeTypes.js';
3
+ export type { BreakRenderContext, CombinedNarrativeEntry, CombinedNarrativeOptions, DecisionRenderContext, ErrorRenderContext, ForkRenderContext, LoopRenderContext, NarrativeRenderer, OpRenderContext, SelectedRenderContext, StageRenderContext, SubflowRenderContext, } from './narrativeTypes.js';
4
4
  export { NullControlFlowNarrativeGenerator } from './NullControlFlowNarrativeGenerator.js';
5
5
  export type { IControlFlowNarrative } from './types.js';
6
6
  export { FlowRecorderDispatcher } from './FlowRecorderDispatcher.js';
@@ -15,9 +15,101 @@ export interface CombinedNarrativeEntry {
15
15
  stepNumber?: number;
16
16
  /** Subflow ID when this entry was generated inside a subflow. Undefined for root-level. */
17
17
  subflowId?: string;
18
+ /** Raw value from the scope event — available for programmatic access and custom formatting.
19
+ * Only present on 'step' entries (read/write ops). This is a live reference, not a clone.
20
+ * When using ScopeFacade, redacted keys will have value '[REDACTED]' (sanitized upstream
21
+ * by ScopeFacade before dispatching events — the recorder does not enforce redaction). */
22
+ rawValue?: unknown;
18
23
  }
19
24
  export interface CombinedNarrativeOptions {
20
25
  includeStepNumbers?: boolean;
21
26
  includeValues?: boolean;
22
27
  indent?: string;
23
28
  }
29
+ /** Context passed to renderStage. */
30
+ export interface StageRenderContext {
31
+ stageName: string;
32
+ stageNumber: number;
33
+ isFirst: boolean;
34
+ description?: string;
35
+ }
36
+ /** Context passed to renderOp. Return null to exclude the entry. */
37
+ export interface OpRenderContext {
38
+ type: 'read' | 'write';
39
+ key: string;
40
+ rawValue: unknown;
41
+ valueSummary: string;
42
+ operation?: 'set' | 'update' | 'delete';
43
+ stepNumber: number;
44
+ }
45
+ /** Context passed to renderDecision. */
46
+ export interface DecisionRenderContext {
47
+ decider: string;
48
+ chosen: string;
49
+ description?: string;
50
+ rationale?: string;
51
+ /** Raw evidence from decide()/select() — available for custom rendering. */
52
+ evidence?: unknown;
53
+ }
54
+ /** Context passed to renderFork. */
55
+ export interface ForkRenderContext {
56
+ children: string[];
57
+ }
58
+ /** Context passed to renderSubflow. */
59
+ export interface SubflowRenderContext {
60
+ name: string;
61
+ direction: 'entry' | 'exit';
62
+ description?: string;
63
+ }
64
+ /** Context passed to renderLoop. */
65
+ export interface LoopRenderContext {
66
+ target: string;
67
+ iteration: number;
68
+ description?: string;
69
+ }
70
+ /** Context passed to renderBreak. */
71
+ export interface BreakRenderContext {
72
+ stageName: string;
73
+ }
74
+ /** Context passed to renderSelected. */
75
+ export interface SelectedRenderContext {
76
+ selected: string[];
77
+ total: number;
78
+ /** Raw evidence from select() — available for custom rendering. */
79
+ evidence?: unknown;
80
+ }
81
+ /** Context passed to renderError. */
82
+ export interface ErrorRenderContext {
83
+ stageName: string;
84
+ message: string;
85
+ validationIssues?: string;
86
+ }
87
+ /**
88
+ * Pluggable renderer for customizing narrative output.
89
+ *
90
+ * Each method is optional — unimplemented methods fall back to the default
91
+ * English renderer. Return null from renderOp to exclude an entry entirely.
92
+ *
93
+ * ```typescript
94
+ * const rec = narrative({
95
+ * renderer: {
96
+ * renderOp(ctx) {
97
+ * if (ctx.key.startsWith('_internal')) return null; // filter out internal keys
98
+ * return `${ctx.type === 'read' ? 'Read' : 'Wrote'} ${ctx.key}: ${ctx.valueSummary}`;
99
+ * },
100
+ * },
101
+ * });
102
+ * ```
103
+ */
104
+ export interface NarrativeRenderer {
105
+ renderStage?(ctx: StageRenderContext): string;
106
+ /** Return null to exclude the op from the narrative. */
107
+ renderOp?(ctx: OpRenderContext): string | null;
108
+ renderDecision?(ctx: DecisionRenderContext): string;
109
+ renderFork?(ctx: ForkRenderContext): string;
110
+ renderSelected?(ctx: SelectedRenderContext): string;
111
+ renderSubflow?(ctx: SubflowRenderContext): string;
112
+ renderLoop?(ctx: LoopRenderContext): string;
113
+ renderBreak?(ctx: BreakRenderContext): string;
114
+ renderError?(ctx: ErrorRenderContext): string;
115
+ }
@@ -21,7 +21,7 @@
21
21
  /// <reference types="node" />
22
22
  import type { ScopeProtectionMode } from '../../scope/protection/types.js';
23
23
  import { FlowRecorderDispatcher } from '../narrative/FlowRecorderDispatcher.js';
24
- import type { FlowRecorder } from '../narrative/types.js';
24
+ import type { FlowRecorder, IControlFlowNarrative } from '../narrative/types.js';
25
25
  import type { ExtractorError, IExecutionRuntime, ILogger, ScopeFactory, SerializedPipelineStructure, StageFunction, StageNode, StreamHandlers, SubflowResult, TraversalExtractor, TraversalResult } from '../types.js';
26
26
  export interface TraverserOptions<TOut = any, TScope = any> {
27
27
  root: StageNode<TOut, TScope>;
@@ -45,11 +45,22 @@ export interface TraverserOptions<TOut = any, TScope = any> {
45
45
  signal?: AbortSignal;
46
46
  /** Pre-configured FlowRecorders to attach when narrative is enabled. */
47
47
  flowRecorders?: FlowRecorder[];
48
+ /**
49
+ * Pre-configured narrative generator. If provided, takes precedence over
50
+ * flowRecorders and narrativeEnabled. Used by the subflow traverser factory
51
+ * to share the parent's narrative generator with subflow traversers.
52
+ */
53
+ narrativeGenerator?: IControlFlowNarrative;
48
54
  /**
49
55
  * Maximum recursive executeNode depth. Defaults to FlowchartTraverser.MAX_EXECUTE_DEPTH (500).
50
56
  * Override in tests or unusually deep pipelines.
51
57
  */
52
58
  maxDepth?: number;
59
+ /**
60
+ * When this traverser runs inside a subflow, set this to the subflow's ID.
61
+ * Propagated to TraversalContext so narrative entries carry the correct subflowId.
62
+ */
63
+ parentSubflowId?: string;
53
64
  }
54
65
  export declare class FlowchartTraverser<TOut = any, TScope = any> {
55
66
  private readonly root;
@@ -58,6 +69,7 @@ export declare class FlowchartTraverser<TOut = any, TScope = any> {
58
69
  private subflows;
59
70
  private readonly logger;
60
71
  private readonly signal?;
72
+ private readonly parentSubflowId?;
61
73
  private readonly nodeResolver;
62
74
  private readonly childrenExecutor;
63
75
  private readonly subflowExecutor;
@@ -107,8 +119,14 @@ export declare class FlowchartTraverser<TOut = any, TScope = any> {
107
119
  */
108
120
  static readonly MAX_EXECUTE_DEPTH = 500;
109
121
  constructor(opts: TraverserOptions<TOut, TScope>);
122
+ /**
123
+ * Create a factory that produces FlowchartTraverser instances for subflow execution.
124
+ * Captures parent config in closure — SubflowExecutor provides subflow-specific overrides.
125
+ * Each subflow gets a full traverser with all 7 phases (deciders, selectors, loops, etc.).
126
+ */
127
+ private createSubflowTraverserFactory;
110
128
  private createDeps;
111
- execute(): Promise<TraversalResult>;
129
+ execute(branchPath?: string): Promise<TraversalResult>;
112
130
  getRuntimeStructure(): SerializedPipelineStructure | undefined;
113
131
  getSnapshot(): {
114
132
  sharedState: Record<string, unknown>;
@@ -117,12 +135,6 @@ export declare class FlowchartTraverser<TOut = any, TScope = any> {
117
135
  subflowResults?: Record<string, unknown> | undefined;
118
136
  recorders?: {
119
137
  id: string;
120
- /**
121
- * Per-traverser set of lazy subflow IDs that have been resolved by THIS run.
122
- * Used instead of writing `node.subflowResolver = undefined` back to the shared
123
- * StageNode graph — avoids a race where a concurrent traverser clears the shared
124
- * resolver before another traverser has finished using it.
125
- */
126
138
  name: string;
127
139
  data: unknown;
128
140
  }[] | undefined;
@@ -41,6 +41,26 @@ export interface StreamHandlers {
41
41
  }
42
42
  export interface SubflowMountOptions<TParentScope = any, TSubflowInput = any, TSubflowOutput = any> {
43
43
  inputMapper?: (parentScope: TParentScope) => TSubflowInput;
44
+ /**
45
+ * Maps subflow output back into parent scope.
46
+ *
47
+ * **Array values are concatenated, not replaced.** `applyOutputMapping` uses
48
+ * `[...existing, ...value]` for arrays. Return only the **delta** (new items)
49
+ * for array keys, or the parent's existing array items will be duplicated.
50
+ * Scalar values are replaced normally.
51
+ *
52
+ * @example
53
+ * ```typescript
54
+ * // WRONG — returns full array, parent concats → duplicates
55
+ * outputMapper: (sf) => ({ messages: sf.allMessages })
56
+ *
57
+ * // RIGHT — returns only new items (delta), concat appends correctly
58
+ * outputMapper: (sf) => ({ messages: sf.newMessages })
59
+ *
60
+ * // Scalars are fine — replaced, not concatenated
61
+ * outputMapper: (sf) => ({ status: sf.result, count: sf.total })
62
+ * ```
63
+ */
44
64
  outputMapper?: (subflowOutput: TSubflowOutput, parentScope: TParentScope) => Record<string, unknown>;
45
65
  }
46
66
  export interface SubflowResult {
@@ -54,6 +74,37 @@ export interface SubflowResult {
54
74
  parentStageId: string;
55
75
  pipelineStructure?: unknown;
56
76
  }
77
+ /**
78
+ * SubflowTraverserFactory — Creates a FlowchartTraverser for subflow execution.
79
+ *
80
+ * Injected into SubflowExecutor to break the circular dependency:
81
+ * FlowchartTraverser → SubflowExecutor → FlowchartTraverser.
82
+ *
83
+ * The factory captures parent traverser config (stageMap, scopeFactory, narrative, etc.)
84
+ * in a closure. SubflowExecutor calls it with subflow-specific overrides (root, runtime, input).
85
+ * The returned traverser uses the SAME 7-phase algorithm as the top-level traverser,
86
+ * so deciders, selectors, loops, lazy subflows, and abort signals all work inside subflows.
87
+ */
88
+ export type SubflowTraverserFactory<TOut = any, TScope = any> = (options: {
89
+ /** Root node of the subflow (with isSubflowRoot stripped). */
90
+ root: StageNode<TOut, TScope>;
91
+ /** Isolated execution runtime for the subflow. */
92
+ executionRuntime: IExecutionRuntime;
93
+ /** Mapped input from parent scope (becomes readOnlyContext for stages). */
94
+ readOnlyContext?: unknown;
95
+ /** Subflow identifier — used as branchPath for narrative context. */
96
+ subflowId?: string;
97
+ }) => SubflowTraverserHandle<TOut, TScope>;
98
+ /**
99
+ * Handle returned by SubflowTraverserFactory.
100
+ * Provides execute() + access to nested subflow results.
101
+ */
102
+ export interface SubflowTraverserHandle<TOut = any, TScope = any> {
103
+ /** Execute the subflow's graph using the full 7-phase traversal algorithm. */
104
+ execute(): Promise<TraversalResult>;
105
+ /** Collect nested subflow results (from subflows mounted inside this subflow). */
106
+ getSubflowResults(): Map<string, SubflowResult>;
107
+ }
57
108
  /**
58
109
  * IExecutionRuntime — Interface for the runtime environment.
59
110
  *
@@ -31,10 +31,12 @@ export declare class StageContext {
31
31
  next?: StageContext;
32
32
  children?: StageContext[];
33
33
  debug: DiagnosticCollector;
34
- /** Tracks user-level writes (pre-namespace) for the memory view. */
34
+ /** Tracks user-level writes (pre-namespace) for the memory view and onCommit. */
35
35
  private _stageWrites;
36
36
  /** Tracks user-level reads (pre-namespace) for the memory view. */
37
37
  private _stageReads;
38
+ /** Observer called after commit() — used by ScopeFacade to fire Recorder.onCommit. */
39
+ private _commitObserver?;
38
40
  constructor(runId: string, name: string, stageId: string, sharedMemory: SharedMemory, branchId?: string, eventLog?: EventLog, isDecider?: boolean);
39
41
  /** Returns the SharedMemory instance (needed by scope layer). */
40
42
  getSharedMemory(): SharedMemory;
@@ -45,18 +47,27 @@ export declare class StageContext {
45
47
  patch(path: string[], key: string, value: unknown, shouldRedact?: boolean): void;
46
48
  set(path: string[], key: string, value: unknown): void;
47
49
  merge(path: string[], key: string, value: unknown): void;
48
- setObject(path: string[], key: string, value: unknown, shouldRedact?: boolean, description?: string): void;
49
- updateObject(path: string[], key: string, value: unknown, description?: string): void;
50
+ setObject(path: string[], key: string, value: unknown, shouldRedact?: boolean, description?: string, operationOverride?: 'set' | 'delete'): void;
51
+ updateObject(path: string[], key: string, value: unknown, description?: string, shouldRedact?: boolean): void;
50
52
  setRoot(key: string, value: unknown): void;
51
53
  setGlobal(key: string, value: unknown, description?: string): void;
52
54
  updateGlobalContext(key: string, value: unknown): void;
53
55
  appendToArray(path: string[], key: string, items: unknown[], description?: string): void;
54
56
  mergeObject(path: string[], key: string, obj: Record<string, unknown>, description?: string): void;
55
57
  getValue(path: string[], key?: string, description?: string): any;
58
+ /** Read state without tracking in _stageReads or paying structuredClone cost.
59
+ * Used by ScopeFacade.getValueSilent() for array proxy internal operations. */
60
+ getValueDirect(path: string[], key?: string): unknown;
56
61
  getRoot(key: string): any;
57
62
  getGlobal(key: string): any;
58
63
  getScope(): Record<string, unknown>;
59
64
  getRunId(): string;
65
+ /** Register an observer that fires after commit() applies patches.
66
+ * Used by ScopeFacade to dispatch Recorder.onCommit events. */
67
+ setCommitObserver(observer: (mutations: Record<string, {
68
+ value: unknown;
69
+ operation: 'set' | 'update' | 'delete';
70
+ }>) => void): void;
60
71
  commit(): void;
61
72
  createNext(path: string, stageName: string, stageId: string, isDecider?: boolean): StageContext;
62
73
  createChild(runId: string, branchId: string, stageName: string, stageId: string, isDecider?: boolean): StageContext;
@@ -18,6 +18,8 @@ export interface ReactiveTarget {
18
18
  getStateKeys?(): string[];
19
19
  /** Check key existence without firing onRead. Used by has trap. */
20
20
  hasKey?(key: string): boolean;
21
+ /** Read state without firing onRead. Used by array proxy getCurrent(). */
22
+ getValueSilent?(key?: string): unknown;
21
23
  getArgs<T = Record<string, unknown>>(): T;
22
24
  getEnv(): Readonly<ExecutionEnv>;
23
25
  attachRecorder(recorder: Recorder): void;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * CompositeRecorder — fan-out a single recorder attachment to multiple child recorders.
3
+ *
4
+ * Implements both Recorder (scope data ops) and FlowRecorder (control flow events)
5
+ * so it works with both `executor.attachRecorder()` and `executor.attachFlowRecorder()`.
6
+ *
7
+ * The composite has a single ID for idempotent attach/detach. Child recorders
8
+ * keep their own IDs internally but are not individually visible to the executor.
9
+ *
10
+ * Domain libraries (e.g., agentfootprint) use this to bundle multiple recorders
11
+ * into a single preset — the consumer calls one function, gets full observability.
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { CompositeRecorder, MetricRecorder, DebugRecorder } from 'footprintjs';
16
+ *
17
+ * // Bundle metrics + debug into a single recorder
18
+ * const observability = new CompositeRecorder('observability', [
19
+ * new MetricRecorder({ stageFilter: (name) => name === 'CallLLM' }),
20
+ * new DebugRecorder({ verbosity: 'minimal' }),
21
+ * ]);
22
+ *
23
+ * executor.attachRecorder(observability);
24
+ *
25
+ * // Access child recorders by type
26
+ * const metrics = observability.get(MetricRecorder);
27
+ * metrics?.getMetrics(); // timing data
28
+ * ```
29
+ *
30
+ * @example
31
+ * ```typescript
32
+ * // Domain library preset (e.g., agentfootprint)
33
+ * export function agentObservability(options?: AgentObservabilityOptions) {
34
+ * return new CompositeRecorder('agent-observability', [
35
+ * new MetricRecorder(options?.stageFilter ? { stageFilter: options.stageFilter } : undefined),
36
+ * new TokenRecorder(),
37
+ * new ToolUsageRecorder(),
38
+ * ]);
39
+ * }
40
+ *
41
+ * // Consumer
42
+ * executor.attachRecorder(agentObservability());
43
+ * ```
44
+ */
45
+ import type { FlowBreakEvent, FlowDecisionEvent, FlowErrorEvent, FlowForkEvent, FlowLoopEvent, FlowNextEvent, FlowRecorder, FlowSelectedEvent, FlowStageEvent, FlowSubflowEvent, FlowSubflowRegisteredEvent } from '../engine/narrative/types.js';
46
+ import type { CommitEvent, ErrorEvent, ReadEvent, Recorder, StageEvent, WriteEvent } from '../scope/types.js';
47
+ /** Snapshot format for composite recorders — wraps child snapshots. */
48
+ export interface CompositeSnapshot {
49
+ name: string;
50
+ data: {
51
+ children: Array<{
52
+ id: string;
53
+ name: string;
54
+ data: unknown;
55
+ }>;
56
+ };
57
+ }
58
+ export declare class CompositeRecorder implements Recorder, FlowRecorder {
59
+ readonly id: string;
60
+ private readonly children;
61
+ constructor(id: string, children: Array<Recorder | FlowRecorder>);
62
+ /**
63
+ * Get a child recorder by class type.
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * const metrics = composite.get(MetricRecorder);
68
+ * ```
69
+ */
70
+ get<T>(type: new (...args: any[]) => T): T | undefined;
71
+ /** Get all child recorders. */
72
+ getChildren(): ReadonlyArray<Recorder | FlowRecorder>;
73
+ onRead(event: ReadEvent): void;
74
+ onWrite(event: WriteEvent): void;
75
+ onCommit(event: CommitEvent): void;
76
+ onError(event: ErrorEvent | FlowErrorEvent): void;
77
+ onStageStart(event: StageEvent): void;
78
+ onStageEnd(event: StageEvent): void;
79
+ onStageExecuted(event: FlowStageEvent): void;
80
+ onNext(event: FlowNextEvent): void;
81
+ onDecision(event: FlowDecisionEvent): void;
82
+ onFork(event: FlowForkEvent): void;
83
+ onSelected(event: FlowSelectedEvent): void;
84
+ onSubflowEntry(event: FlowSubflowEvent): void;
85
+ onSubflowExit(event: FlowSubflowEvent): void;
86
+ onSubflowRegistered(event: FlowSubflowRegisteredEvent): void;
87
+ onLoop(event: FlowLoopEvent): void;
88
+ onBreak(event: FlowBreakEvent): void;
89
+ clear(): void;
90
+ /**
91
+ * Snapshot merges all child snapshots into a single composite entry.
92
+ * Each child's snapshot is preserved with its own id/name/data.
93
+ */
94
+ toSnapshot(): CompositeSnapshot;
95
+ }
@@ -0,0 +1,2 @@
1
+ export type { CompositeSnapshot } from './CompositeRecorder.js';
2
+ export { CompositeRecorder } from './CompositeRecorder.js';
@@ -16,6 +16,7 @@
16
16
  *
17
17
  * const result = await executor.run({ input: data, env: { traceId: 'req-123' } });
18
18
  */
19
+ import type { CombinedNarrativeRecorderOptions } from '../engine/narrative/CombinedNarrativeRecorder.js';
19
20
  import type { CombinedNarrativeEntry } from '../engine/narrative/narrativeTypes.js';
20
21
  import type { ManifestEntry } from '../engine/narrative/recorders/ManifestFlowRecorder.js';
21
22
  import type { FlowRecorder } from '../engine/narrative/types.js';
@@ -80,6 +81,7 @@ export interface FlowChartExecutorOptions<TScope = any> {
80
81
  export declare class FlowChartExecutor<TOut = any, TScope = any> {
81
82
  private traverser;
82
83
  private narrativeEnabled;
84
+ private narrativeOptions?;
83
85
  private combinedRecorder;
84
86
  private flowRecorders;
85
87
  private scopeRecorders;
@@ -105,7 +107,7 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
105
107
  */
106
108
  constructor(flowChart: FlowChart<TOut, TScope>, factoryOrOptions?: ScopeFactory<TScope> | FlowChartExecutorOptions<TScope>);
107
109
  private createTraverser;
108
- enableNarrative(): void;
110
+ enableNarrative(options?: CombinedNarrativeRecorderOptions): void;
109
111
  /**
110
112
  * Set a declarative redaction policy that applies to all stages.
111
113
  * Must be called before run().
@@ -120,6 +122,28 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
120
122
  * Attach a scope Recorder to observe data operations (reads, writes, commits).
121
123
  * Automatically attached to every ScopeFacade created during traversal.
122
124
  * Must be called before run().
125
+ *
126
+ * **Idempotent by ID:** If a recorder with the same `id` is already attached,
127
+ * it is replaced (not duplicated). This prevents double-counting when both
128
+ * a framework and the user attach the same recorder type.
129
+ *
130
+ * Built-in recorders use auto-increment IDs (`metrics-1`, `debug-1`, ...) by
131
+ * default, so multiple instances with different configs coexist. To override
132
+ * a framework-attached recorder, pass the same well-known ID.
133
+ *
134
+ * @example
135
+ * ```typescript
136
+ * // Multiple recorders with different configs — each gets a unique ID
137
+ * executor.attachRecorder(new MetricRecorder());
138
+ * executor.attachRecorder(new DebugRecorder({ verbosity: 'minimal' }));
139
+ *
140
+ * // Override a framework-attached recorder by passing its well-known ID
141
+ * executor.attachRecorder(new MetricRecorder('metrics'));
142
+ *
143
+ * // Attaching twice with same ID replaces (no double-counting)
144
+ * executor.attachRecorder(new MetricRecorder('my-metrics'));
145
+ * executor.attachRecorder(new MetricRecorder('my-metrics')); // replaces previous
146
+ * ```
123
147
  */
124
148
  attachRecorder(recorder: Recorder): void;
125
149
  /** Detach all scope Recorders with the given ID. */
@@ -130,6 +154,8 @@ export declare class FlowChartExecutor<TOut = any, TScope = any> {
130
154
  * Attach a FlowRecorder to observe control flow events.
131
155
  * Automatically enables narrative if not already enabled.
132
156
  * Must be called before run() — recorders are passed to the traverser at creation time.
157
+ *
158
+ * **Idempotent by ID:** replaces existing recorder with same `id`.
133
159
  */
134
160
  attachFlowRecorder(recorder: FlowRecorder): void;
135
161
  /** Detach all FlowRecorders with the given ID. */
@@ -63,6 +63,9 @@ export declare class ScopeFacade {
63
63
  notifyStageEnd(duration?: number): void;
64
64
  /** @internal */
65
65
  notifyCommit(mutations: CommitEvent['mutations']): void;
66
+ /** Called by StageContext.commit() observer. Converts tracked writes to CommitEvent format.
67
+ * Errors are caught to prevent recorder issues from aborting the traversal. */
68
+ private _onCommitFired;
66
69
  addDebugInfo(key: string, value: unknown): void;
67
70
  addDebugMessage(value: unknown): void;
68
71
  addErrorInfo(key: string, value: unknown): void;
@@ -74,6 +77,14 @@ export declare class ScopeFacade {
74
77
  * Contract: returns false for keys never set OR keys set to undefined.
75
78
  * This matches deleteValue() semantics (sets to undefined = deleted). */
76
79
  hasKey(key: string): boolean;
80
+ /** Read state without firing onRead. Used by array proxy getCurrent() to avoid
81
+ * phantom reads on internal array operations (.length, .has, iteration, etc.).
82
+ * The initial property access fires one tracked onRead via getValue(); subsequent
83
+ * internal array operations use this method to stay silent.
84
+ * NOTE: Like getValue(), returns the raw value to the caller. Redaction applies
85
+ * only to recorder dispatch — it does not filter the returned value. This matches
86
+ * the existing getValue() contract where user code always receives raw data. */
87
+ getValueSilent(key?: string): unknown;
77
88
  getInitialValueFor(key: string): any;
78
89
  getValue(key?: string): any;
79
90
  setValue(key: string, value: unknown, shouldRedact?: boolean, description?: string): void;
@@ -16,7 +16,23 @@ export interface DebugRecorderOptions {
16
16
  id?: string;
17
17
  verbosity?: DebugVerbosity;
18
18
  }
19
+ /**
20
+ * Each instance gets a unique auto-increment ID (`debug-1`, `debug-2`, ...),
21
+ * so multiple recorders with different verbosity coexist.
22
+ *
23
+ * @example
24
+ * ```typescript
25
+ * // Verbose debug for development
26
+ * executor.attachRecorder(new DebugRecorder({ verbosity: 'verbose' }));
27
+ *
28
+ * // Minimal debug for production (errors only)
29
+ * executor.attachRecorder(new DebugRecorder({ verbosity: 'minimal' }));
30
+ *
31
+ * // Both coexist — different auto IDs
32
+ * ```
33
+ */
19
34
  export declare class DebugRecorder implements Recorder {
35
+ private static _counter;
20
36
  readonly id: string;
21
37
  private entries;
22
38
  private verbosity;