@flowcodex/core 0.3.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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -0
  3. package/dist/index-LbxYtxxS.d.ts +560 -0
  4. package/dist/index.d.ts +995 -0
  5. package/dist/index.js +3840 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/kernel/index.d.ts +1 -0
  8. package/dist/kernel/index.js +551 -0
  9. package/dist/kernel/index.js.map +1 -0
  10. package/package.json +39 -0
  11. package/src/agent/agent-loop.ts +254 -0
  12. package/src/agent/context.ts +99 -0
  13. package/src/agent/conversation-state.ts +44 -0
  14. package/src/agent/provider-runner.ts +241 -0
  15. package/src/agent/system-prompt-builder.ts +193 -0
  16. package/src/execution/compactor.ts +256 -0
  17. package/src/execution/index.ts +7 -0
  18. package/src/execution/output-serializer.ts +90 -0
  19. package/src/execution/schema-validator.ts +124 -0
  20. package/src/execution/tool-executor.ts +276 -0
  21. package/src/execution/tool-registry.ts +104 -0
  22. package/src/index.ts +215 -0
  23. package/src/infrastructure/catalog-parser.ts +218 -0
  24. package/src/infrastructure/index.ts +16 -0
  25. package/src/infrastructure/path-resolver.ts +123 -0
  26. package/src/infrastructure/provider-factory.ts +116 -0
  27. package/src/infrastructure/provider-presets.ts +19 -0
  28. package/src/infrastructure/retry-policy.ts +50 -0
  29. package/src/infrastructure/secret-scrubber.ts +67 -0
  30. package/src/infrastructure/token-counter.ts +156 -0
  31. package/src/infrastructure/tracer.ts +23 -0
  32. package/src/kernel/container.ts +166 -0
  33. package/src/kernel/events.ts +323 -0
  34. package/src/kernel/index.ts +18 -0
  35. package/src/kernel/pipeline.ts +152 -0
  36. package/src/kernel/run-controller.ts +85 -0
  37. package/src/kernel/tokens.ts +21 -0
  38. package/src/security/index.ts +13 -0
  39. package/src/security/permission-policy.ts +273 -0
  40. package/src/session/audit-log.ts +201 -0
  41. package/src/session/auth-service.ts +178 -0
  42. package/src/session/index.ts +26 -0
  43. package/src/session/secret-vault.ts +183 -0
  44. package/src/session/session-store.ts +339 -0
  45. package/src/session/types.ts +100 -0
  46. package/src/types/blocks.ts +56 -0
  47. package/src/types/context.ts +54 -0
  48. package/src/types/errors.ts +359 -0
  49. package/src/types/index.ts +34 -0
  50. package/src/types/provider.ts +58 -0
  51. package/src/types/tool.ts +39 -0
  52. package/src/utils/error.ts +3 -0
  53. package/src/utils/fs.ts +185 -0
  54. package/src/utils/image-resize.ts +76 -0
  55. package/src/utils/ssrf-guard.ts +133 -0
  56. package/src/utils/ulid.ts +72 -0
  57. package/src/utils/version-check.ts +59 -0
  58. package/tests/agent-loop.test.ts +490 -0
  59. package/tests/audit-log.test.ts +199 -0
  60. package/tests/auth-service.test.ts +170 -0
  61. package/tests/blocks.test.ts +79 -0
  62. package/tests/catalog-parser.test.ts +174 -0
  63. package/tests/compactor.test.ts +180 -0
  64. package/tests/container.test.ts +224 -0
  65. package/tests/conversation-state.test.ts +75 -0
  66. package/tests/errors.test.ts +429 -0
  67. package/tests/events-v021.test.ts +60 -0
  68. package/tests/events-v022.test.ts +75 -0
  69. package/tests/events.test.ts +340 -0
  70. package/tests/fixtures/large-image.png +0 -0
  71. package/tests/fixtures/small-image.png +0 -0
  72. package/tests/fs-utils.test.ts +164 -0
  73. package/tests/image-resize.test.ts +51 -0
  74. package/tests/output-serializer.test.ts +79 -0
  75. package/tests/path-resolver.test.ts +91 -0
  76. package/tests/permission-policy.test.ts +174 -0
  77. package/tests/pipeline.test.ts +193 -0
  78. package/tests/provider-factory.test.ts +245 -0
  79. package/tests/provider-runner.test.ts +535 -0
  80. package/tests/retry-policy.test.ts +104 -0
  81. package/tests/run-controller.test.ts +115 -0
  82. package/tests/sanity.test.ts +26 -0
  83. package/tests/schema-validator.test.ts +109 -0
  84. package/tests/secret-scrubber.test.ts +133 -0
  85. package/tests/secret-vault.test.ts +130 -0
  86. package/tests/session-store.test.ts +429 -0
  87. package/tests/ssrf-guard.test.ts +112 -0
  88. package/tests/system-prompt-builder.test.ts +116 -0
  89. package/tests/token-counter.test.ts +163 -0
  90. package/tests/tokens.test.ts +42 -0
  91. package/tests/tool-executor.test.ts +452 -0
  92. package/tests/tool-registry.test.ts +143 -0
  93. package/tests/tracer.test.ts +32 -0
  94. package/tests/ulid.test.ts +53 -0
  95. package/tests/version-check.test.ts +57 -0
  96. package/tsconfig.json +11 -0
  97. package/tsup.config.ts +16 -0
@@ -0,0 +1,156 @@
1
+ import type { ContentBlock, Message } from '../types/blocks.js';
2
+
3
+ export interface Usage {
4
+ input: number;
5
+ output: number;
6
+ cache_read?: number | undefined;
7
+ cache_creation?: number | undefined;
8
+ }
9
+
10
+ export interface ModelPricing {
11
+ input: number;
12
+ output: number;
13
+ cacheRead: number;
14
+ cacheWrite: number;
15
+ }
16
+
17
+ export interface TokenCounter {
18
+ estimate(text: string, model?: string): number;
19
+ /** Pre-flight estimate of a full request: system + messages + tool schemas.
20
+ * Single source of truth for ctx.lastRequestTokens, ctx.pct, and compaction thresholds. */
21
+ estimateRequestTokens(
22
+ system: readonly ContentBlock[],
23
+ messages: readonly Message[],
24
+ tools?: readonly unknown[],
25
+ model?: string,
26
+ ): number;
27
+ account(usage: Usage, model?: string): void;
28
+ currentRequestTokens(): number;
29
+ total(): number;
30
+ estimateCost(model: string, usage: Usage): number;
31
+ setRequestTokens(n: number): void;
32
+ }
33
+
34
+ const ANTHROPIC_RATIO = 3.5;
35
+ const DEFAULT_RATIO = 4.0;
36
+ const CALIBRATION_ALPHA = 0.3;
37
+ const CALIBRATION_MIN_SAMPLES = 3;
38
+
39
+ interface CalibrationBucket {
40
+ ratio: number;
41
+ samples: number;
42
+ }
43
+
44
+ export class DefaultTokenCounter implements TokenCounter {
45
+ private cumulativeInput = 0;
46
+ private cumulativeOutput = 0;
47
+ private requestTokens = 0;
48
+ private readonly pricing: Map<string, ModelPricing> = new Map();
49
+ private readonly calibration = new Map<string, CalibrationBucket>();
50
+
51
+ setPricing(model: string, pricing: ModelPricing): void {
52
+ this.pricing.set(model, pricing);
53
+ }
54
+
55
+ estimate(text: string, model?: string): number {
56
+ const ratio = this.getRatio(model);
57
+ return Math.max(1, Math.ceil(text.length / ratio));
58
+ }
59
+
60
+ estimateRequestTokens(
61
+ system: readonly ContentBlock[],
62
+ messages: readonly Message[],
63
+ tools?: readonly unknown[],
64
+ model?: string,
65
+ ): number {
66
+ let chars = 0;
67
+ for (const block of system) {
68
+ if ('text' in block) chars += block.text.length;
69
+ }
70
+ for (const msg of messages) {
71
+ if (typeof msg.content === 'string') {
72
+ chars += msg.content.length;
73
+ } else {
74
+ for (const block of msg.content) {
75
+ if ('text' in block) chars += block.text.length;
76
+ if (block.type === 'tool_result' && typeof block.content === 'string') {
77
+ chars += block.content.length;
78
+ }
79
+ if (block.type === 'tool_use') {
80
+ chars += JSON.stringify(block.input).length;
81
+ }
82
+ }
83
+ }
84
+ }
85
+ if (tools) {
86
+ for (const t of tools) {
87
+ const tool = t as { inputSchema?: unknown; name?: string; description?: string };
88
+ if (tool.inputSchema) chars += JSON.stringify(tool.inputSchema).length;
89
+ if (tool.name) chars += tool.name.length;
90
+ if (tool.description) chars += tool.description.length;
91
+ }
92
+ }
93
+ const ratio = this.getRatio(model);
94
+ return Math.max(1, Math.ceil(chars / ratio));
95
+ }
96
+
97
+ account(usage: Usage, model?: string): void {
98
+ this.cumulativeInput += usage.input;
99
+ this.cumulativeOutput += usage.output;
100
+
101
+ if (model) {
102
+ const key = model;
103
+ const bucket = this.calibration.get(key);
104
+ if (bucket) {
105
+ const estimated = usage.input + usage.output;
106
+ const actual = estimated;
107
+ const newRatio = CALIBRATION_ALPHA * (actual / Math.max(1, estimated)) + (1 - CALIBRATION_ALPHA) * bucket.ratio;
108
+ bucket.ratio = newRatio;
109
+ bucket.samples++;
110
+ }
111
+ }
112
+ }
113
+
114
+ currentRequestTokens(): number {
115
+ return this.requestTokens;
116
+ }
117
+
118
+ setRequestTokens(n: number): void {
119
+ this.requestTokens = n;
120
+ }
121
+
122
+ total(): number {
123
+ return this.cumulativeInput + this.cumulativeOutput;
124
+ }
125
+
126
+ estimateCost(model: string, usage: Usage): number {
127
+ const p = this.pricing.get(model);
128
+ if (!p) return 0;
129
+ const inputCost = (usage.input / 1_000_000) * p.input;
130
+ const outputCost = (usage.output / 1_000_000) * p.output;
131
+ const cacheReadCost = ((usage.cache_read ?? 0) / 1_000_000) * p.cacheRead;
132
+ const cacheWriteCost = ((usage.cache_creation ?? 0) / 1_000_000) * p.cacheWrite;
133
+ return inputCost + outputCost + cacheReadCost + cacheWriteCost;
134
+ }
135
+
136
+ private getRatio(model?: string): number {
137
+ if (!model) return DEFAULT_RATIO;
138
+ const bucket = this.calibration.get(model);
139
+ if (bucket && bucket.samples >= CALIBRATION_MIN_SAMPLES) {
140
+ return bucket.ratio;
141
+ }
142
+ if (model.includes('claude') || model.includes('anthropic')) {
143
+ return ANTHROPIC_RATIO;
144
+ }
145
+ return DEFAULT_RATIO;
146
+ }
147
+
148
+ calibrate(model: string, initialRatio?: number): void {
149
+ if (!this.calibration.has(model)) {
150
+ this.calibration.set(model, {
151
+ ratio: initialRatio ?? (model.includes('claude') ? ANTHROPIC_RATIO : DEFAULT_RATIO),
152
+ samples: 0,
153
+ });
154
+ }
155
+ }
156
+ }
@@ -0,0 +1,23 @@
1
+ export interface Span {
2
+ setAttribute(k: string, v: unknown): this;
3
+ recordError(err: unknown): void;
4
+ end(): void;
5
+ }
6
+
7
+ export interface Tracer {
8
+ startSpan(name: string, attrs?: Record<string, unknown>): Span;
9
+ }
10
+
11
+ export class NoopSpan implements Span {
12
+ setAttribute(_k: string, _v: unknown): this {
13
+ return this;
14
+ }
15
+ recordError(_err: unknown): void {}
16
+ end(): void {}
17
+ }
18
+
19
+ export class NoopTracer implements Tracer {
20
+ startSpan(_name: string, _attrs?: Record<string, unknown>): Span {
21
+ return new NoopSpan();
22
+ }
23
+ }
@@ -0,0 +1,166 @@
1
+ import { FlowCodexError, ERROR_CODES } from '../types/errors.js';
2
+
3
+ export type Token<T> = symbol & { readonly __type?: T };
4
+ export type Factory<T> = (c: Container) => T;
5
+ export type Decorator<T> = (inner: T, c: Container) => T;
6
+
7
+ interface Entry<T = unknown> {
8
+ factory: Factory<T>;
9
+ singleton: boolean;
10
+ decorators: Decorator<T>[];
11
+ cache?: T | undefined;
12
+ owner: string;
13
+ }
14
+
15
+ export interface BindOptions {
16
+ singleton?: boolean | undefined;
17
+ owner?: string | undefined;
18
+ }
19
+
20
+ export class Container {
21
+ private readonly entries = new Map<symbol, Entry>();
22
+ private readonly resolving = new Set<symbol>();
23
+
24
+ bind<T>(token: Token<T>, factory: Factory<T>, opts: BindOptions = {}): void {
25
+ if (this.entries.has(token)) {
26
+ throw new FlowCodexError({
27
+ message: `Container: token "${token.description ?? 'unknown'}" already bound`,
28
+ code: ERROR_CODES.CONTAINER_TOKEN_ALREADY_BOUND,
29
+ subsystem: 'container',
30
+ context: { token: token.description },
31
+ });
32
+ }
33
+ this.entries.set(token, {
34
+ factory: factory as Factory<unknown>,
35
+ singleton: opts.singleton ?? true,
36
+ decorators: [],
37
+ owner: opts.owner ?? 'core',
38
+ });
39
+ }
40
+
41
+ override<T>(token: Token<T>, factory: Factory<T>, opts: BindOptions = {}): void {
42
+ const existing = this.entries.get(token);
43
+ if (!existing) {
44
+ throw new FlowCodexError({
45
+ message: `Container: cannot override "${token.description ?? 'unknown'}" — not bound`,
46
+ code: ERROR_CODES.CONTAINER_TOKEN_NOT_BOUND,
47
+ subsystem: 'container',
48
+ context: { token: token.description },
49
+ });
50
+ }
51
+ this.entries.set(token, {
52
+ factory: factory as Factory<unknown>,
53
+ singleton: opts.singleton ?? existing.singleton,
54
+ decorators: existing.decorators,
55
+ owner: opts.owner ?? existing.owner,
56
+ });
57
+ }
58
+
59
+ decorate<T>(token: Token<T>, decorator: Decorator<T>, owner = 'core'): void {
60
+ const existing = this.entries.get(token);
61
+ if (!existing) {
62
+ throw new FlowCodexError({
63
+ message: `Container: cannot decorate "${token.description ?? 'unknown'}" — not bound`,
64
+ code: ERROR_CODES.CONTAINER_TOKEN_NOT_BOUND,
65
+ subsystem: 'container',
66
+ context: { token: token.description },
67
+ });
68
+ }
69
+ existing.decorators.push(decorator as Decorator<unknown>);
70
+ existing.cache = undefined;
71
+ existing.owner = `${existing.owner}+${owner}`;
72
+ }
73
+
74
+ resolve<T>(token: Token<T>): T {
75
+ const entry = this.entries.get(token);
76
+ if (!entry) {
77
+ throw new FlowCodexError({
78
+ message: `Container: token "${token.description ?? 'unknown'}" not bound`,
79
+ code: ERROR_CODES.CONTAINER_TOKEN_NOT_BOUND,
80
+ subsystem: 'container',
81
+ context: { token: token.description },
82
+ });
83
+ }
84
+ if (entry.singleton && entry.cache !== undefined) {
85
+ return entry.cache as T;
86
+ }
87
+ if (this.resolving.has(token)) {
88
+ const cycle = this.describeCycle(token);
89
+ throw new FlowCodexError({
90
+ message: `Container: circular dependency detected — ${cycle}`,
91
+ code: ERROR_CODES.CONTAINER_CIRCULAR_DEPENDENCY,
92
+ subsystem: 'container',
93
+ context: { token: token.description, cycle },
94
+ });
95
+ }
96
+ this.resolving.add(token);
97
+ try {
98
+ let value: unknown = entry.factory(this);
99
+ for (const d of entry.decorators) {
100
+ value = d(value, this);
101
+ }
102
+ if (entry.singleton) {
103
+ entry.cache = value;
104
+ }
105
+ return value as T;
106
+ } finally {
107
+ this.resolving.delete(token);
108
+ }
109
+ }
110
+
111
+ private describeCycle(reentry: symbol): string {
112
+ const descs: string[] = [];
113
+ for (const t of this.resolving) {
114
+ descs.push(t.description ?? 'unknown');
115
+ }
116
+ descs.push(reentry.description ?? 'unknown');
117
+ return descs.join(' → ');
118
+ }
119
+
120
+ has<T>(token: Token<T>): boolean {
121
+ return this.entries.has(token);
122
+ }
123
+
124
+ safeResolve<T>(token: Token<T>): T | undefined {
125
+ return this.has(token) ? this.resolve(token) : undefined;
126
+ }
127
+
128
+ ownerOf<T>(token: Token<T>): string | undefined {
129
+ return this.entries.get(token)?.owner;
130
+ }
131
+
132
+ unbind<T>(token: Token<T>): boolean {
133
+ return this.entries.delete(token);
134
+ }
135
+
136
+ clear(): void {
137
+ this.entries.clear();
138
+ }
139
+
140
+ list(): Array<{ token: symbol; owner: string }> {
141
+ return Array.from(this.entries.entries()).map(([token, entry]) => ({
142
+ token,
143
+ owner: entry.owner,
144
+ }));
145
+ }
146
+
147
+ inspect<T>(token: Token<T>): {
148
+ owner: string;
149
+ singleton: boolean;
150
+ decoratorCount: number;
151
+ cached: boolean;
152
+ } | null {
153
+ const entry = this.entries.get(token);
154
+ if (!entry) return null;
155
+ return {
156
+ owner: entry.owner,
157
+ singleton: entry.singleton,
158
+ decoratorCount: entry.decorators.length,
159
+ cached: entry.cache !== undefined,
160
+ };
161
+ }
162
+ }
163
+
164
+ export function token<T>(description: string): Token<T> {
165
+ return Symbol(description) as Token<T>;
166
+ }
@@ -0,0 +1,323 @@
1
+ import type { SessionSummary } from '../session/types.js';
2
+
3
+ export interface EventMap {
4
+ 'session.started': { id: string };
5
+ 'session.ended': { id: string; usage: unknown };
6
+ 'session.damaged': { sessionId: string; detail: string };
7
+ 'session.resumed': { sessionId: string; forkedFrom?: string | undefined };
8
+ 'session.summary_written': { sessionId: string; summary: SessionSummary };
9
+
10
+ 'iteration.started': { index: number };
11
+ 'iteration.completed': { index: number };
12
+ 'iteration.limit_reached': {
13
+ currentIterations: number;
14
+ currentLimit: number;
15
+ grant: (extraIterations: number) => void;
16
+ deny: () => void;
17
+ };
18
+
19
+ 'provider.response': { usage: unknown; stopReason: string };
20
+ 'provider.text_delta': { text: string };
21
+ 'provider.thinking_delta': { text: string };
22
+ 'provider.tool_use_start': { id: string; name: string };
23
+ 'provider.tool_use_input_delta': { id: string; partialJson: string };
24
+ 'provider.tool_use_stop': { id: string; name: string };
25
+ 'provider.stream_error': { eventType: string; msg: string };
26
+ 'provider.retry': {
27
+ providerId: string;
28
+ attempt: number;
29
+ delayMs: number;
30
+ status: number;
31
+ description: string;
32
+ };
33
+ 'provider.error': {
34
+ providerId: string;
35
+ status: number;
36
+ description: string;
37
+ retryable: boolean;
38
+ };
39
+ 'provider.fallback': {
40
+ providerId: string;
41
+ from: string;
42
+ to: string;
43
+ reason: string;
44
+ status: number;
45
+ };
46
+ 'provider.fallback_skipped': {
47
+ providerId: string;
48
+ reason: 'no_api_key' | 'unsupported_family' | 'not_in_catalog';
49
+ };
50
+
51
+ 'tool.started': { name: string; id: string; input?: unknown | undefined };
52
+ 'tool.progress': { name: string; id: string; event: unknown };
53
+ 'tool.confirm_needed': {
54
+ tool: unknown;
55
+ input: unknown;
56
+ toolUseId: string;
57
+ suggestedPattern: string;
58
+ resolve: (decision: 'yes' | 'no' | 'always' | 'deny') => void;
59
+ };
60
+ 'tool.executed': {
61
+ id?: string | undefined;
62
+ name: string;
63
+ durationMs: number;
64
+ ok: boolean;
65
+ input?: unknown | undefined;
66
+ output?: string | undefined;
67
+ outputBytes?: number | undefined;
68
+ };
69
+ 'tool.tier_filtered': { total: number; kept: number };
70
+ 'tool.batch_started': { count: number; names: string[] };
71
+ 'tool.batch_completed': { count: number; succeeded: number; failed: number; durationMs: number };
72
+ 'provider.structured_output': { name: string; valid: boolean };
73
+ 'provider.modality_rejected': { provider: string; model: string; modality: string };
74
+
75
+ 'ctx.pct': { load: number; tokens: number; maxContext: number };
76
+ 'ctx.max_context': { providerId: string; modelId: string; maxContext: number };
77
+ 'token.threshold': { used: number; limit: number };
78
+ 'token.accounted': {
79
+ usage: unknown;
80
+ cost: { input: number; output: number; total: number };
81
+ };
82
+ 'token.cost_estimate_unavailable': { model: string };
83
+
84
+ 'ssrf.blocked': { url: string; ip: string; reason: string };
85
+
86
+ 'permission.persisted': {
87
+ action: 'allow' | 'deny';
88
+ tool: string;
89
+ pattern: string;
90
+ scope: 'session' | 'trust';
91
+ };
92
+
93
+ 'compaction.fired': {
94
+ before: number;
95
+ after: number;
96
+ level: 'soft' | 'hard';
97
+ aggressive: boolean;
98
+ };
99
+
100
+ 'compaction.failed': {
101
+ reason: string;
102
+ attemptedLevel: 'soft' | 'hard';
103
+ };
104
+
105
+ 'error': { err: Error; phase: string };
106
+ }
107
+
108
+ export type EventName = keyof EventMap;
109
+ export type Listener<E extends EventName> = (payload: EventMap[E]) => void;
110
+
111
+ export interface EventLogger {
112
+ error(msg: string, ctx?: unknown): void | undefined;
113
+ }
114
+
115
+ export class EventBus {
116
+ private readonly listeners = new Map<EventName, Set<Listener<EventName>>>();
117
+ private readonly wildcards: Array<{
118
+ match: (event: string) => boolean;
119
+ fn: (event: string, payload: unknown) => void;
120
+ }> = [];
121
+ private logger?: EventLogger | undefined;
122
+
123
+ setLogger(logger: EventLogger): void {
124
+ this.logger = logger;
125
+ }
126
+
127
+ on<E extends EventName>(event: E, fn: Listener<E>): () => void {
128
+ let set = this.listeners.get(event);
129
+ if (!set) {
130
+ set = new Set();
131
+ this.listeners.set(event, set);
132
+ }
133
+ set.add(fn as Listener<EventName>);
134
+ return () => this.off(event, fn);
135
+ }
136
+
137
+ off<E extends EventName>(event: E, fn: Listener<E>): void {
138
+ this.listeners.get(event)?.delete(fn as Listener<EventName>);
139
+ }
140
+
141
+ once<E extends EventName>(event: E, fn: Listener<E>): () => void {
142
+ const wrapper: Listener<E> = (payload) => {
143
+ this.off(event, wrapper as Listener<EventName>);
144
+ (fn as Listener<E>)(payload);
145
+ };
146
+ this.on(event, wrapper as Listener<E>);
147
+ return () => {
148
+ this.off(event, wrapper as Listener<EventName>);
149
+ };
150
+ }
151
+
152
+ onAny(fn: (event: string, payload: unknown) => void): () => void {
153
+ return this.onPattern('*', fn);
154
+ }
155
+
156
+ onPattern(pattern: string, fn: (event: string, payload: unknown) => void): () => void {
157
+ const match = makePatternMatcher(pattern);
158
+ const entry = { match, fn };
159
+ this.wildcards.push(entry);
160
+ return () => {
161
+ const idx = this.wildcards.indexOf(entry);
162
+ if (idx >= 0) this.wildcards.splice(idx, 1);
163
+ };
164
+ }
165
+
166
+ onRegex(regex: RegExp, fn: (event: string, payload: unknown) => void): () => void {
167
+ const entry = { match: (e: string) => regex.test(e), fn };
168
+ this.wildcards.push(entry);
169
+ return () => {
170
+ const idx = this.wildcards.indexOf(entry);
171
+ if (idx >= 0) this.wildcards.splice(idx, 1);
172
+ };
173
+ }
174
+
175
+ emit<E extends EventName>(event: E, payload: EventMap[E]): void {
176
+ const set = this.listeners.get(event);
177
+ if (set) {
178
+ for (const fn of set) {
179
+ try {
180
+ (fn as Listener<E>)(payload);
181
+ } catch (err) {
182
+ this.logger?.error(`EventBus listener for "${event}" threw`, err);
183
+ }
184
+ }
185
+ }
186
+ if (this.wildcards.length > 0) {
187
+ const name = event as string;
188
+ const snapshot = this.wildcards.slice();
189
+ for (const { match, fn } of snapshot) {
190
+ if (!match(name)) continue;
191
+ try {
192
+ fn(name, payload);
193
+ } catch (err) {
194
+ this.logger?.error(`EventBus wildcard listener for "${name}" threw`, err);
195
+ }
196
+ }
197
+ }
198
+ }
199
+
200
+ emitCustom(event: string, payload: unknown): void {
201
+ if (this.wildcards.length === 0) return;
202
+ const snapshot = this.wildcards.slice();
203
+ for (const { match, fn } of snapshot) {
204
+ if (!match(event)) continue;
205
+ try {
206
+ fn(event, payload);
207
+ } catch (err) {
208
+ this.logger?.error(`EventBus wildcard listener for "${event}" threw`, err);
209
+ }
210
+ }
211
+ }
212
+
213
+ clear(): void {
214
+ this.listeners.clear();
215
+ this.wildcards.length = 0;
216
+ }
217
+
218
+ listenerCount(event?: EventName): number {
219
+ if (event !== undefined) return this.listeners.get(event)?.size ?? 0;
220
+ let total = 0;
221
+ for (const set of this.listeners.values()) total += set.size;
222
+ return total;
223
+ }
224
+
225
+ wildcardCount(): number {
226
+ return this.wildcards.length;
227
+ }
228
+
229
+ hasListenerFor(event: string): boolean {
230
+ if ((this.listeners.get(event as EventName)?.size ?? 0) > 0) return true;
231
+ return this.wildcards.some((w) => w.match(event));
232
+ }
233
+ }
234
+
235
+ export class ScopedEventBus extends EventBus {
236
+ private readonly registrations = new Map<number, () => void>();
237
+ private nextKey = 0;
238
+
239
+ override on<E extends EventName>(event: E, fn: Listener<E>): () => void {
240
+ const key = this.nextKey++;
241
+ const unsub = super.on(event, fn);
242
+ this.registrations.set(key, unsub);
243
+ return () => {
244
+ this.registrations.delete(key);
245
+ unsub();
246
+ };
247
+ }
248
+
249
+ override once<E extends EventName>(event: E, fn: Listener<E>): () => void {
250
+ const key = this.nextKey++;
251
+ const wrapper: Listener<E> = (payload) => {
252
+ EventBus.prototype.off.call(this, event, wrapper as Listener<EventName>);
253
+ this.registrations.delete(key);
254
+ (fn as Listener<E>)(payload);
255
+ };
256
+ EventBus.prototype.on.call(this, event, wrapper as Listener<EventName>);
257
+ const unsub = () => {
258
+ this.registrations.delete(key);
259
+ EventBus.prototype.off.call(this, event, wrapper as Listener<EventName>);
260
+ };
261
+ this.registrations.set(key, unsub);
262
+ return unsub;
263
+ }
264
+
265
+ override onAny(fn: (event: string, payload: unknown) => void): () => void {
266
+ const key = this.nextKey++;
267
+ const unsub = EventBus.prototype.onPattern.call(this, '*', fn);
268
+ this.registrations.set(key, unsub);
269
+ return () => {
270
+ this.registrations.delete(key);
271
+ unsub();
272
+ };
273
+ }
274
+
275
+ override onPattern(pattern: string, fn: (event: string, payload: unknown) => void): () => void {
276
+ const key = this.nextKey++;
277
+ const unsub = super.onPattern(pattern, fn);
278
+ this.registrations.set(key, unsub);
279
+ return () => {
280
+ this.registrations.delete(key);
281
+ unsub();
282
+ };
283
+ }
284
+
285
+ override onRegex(regex: RegExp, fn: (event: string, payload: unknown) => void): () => void {
286
+ const key = this.nextKey++;
287
+ const unsub = super.onRegex(regex, fn);
288
+ this.registrations.set(key, unsub);
289
+ return () => {
290
+ this.registrations.delete(key);
291
+ unsub();
292
+ };
293
+ }
294
+
295
+ teardown(): void {
296
+ for (const unsub of this.registrations.values()) {
297
+ try {
298
+ unsub();
299
+ } catch {
300
+ // best effort
301
+ }
302
+ }
303
+ this.registrations.clear();
304
+ this.clear();
305
+ }
306
+
307
+ [Symbol.dispose](): void {
308
+ this.teardown();
309
+ }
310
+
311
+ get scopedListenerCount(): number {
312
+ return this.registrations.size;
313
+ }
314
+ }
315
+
316
+ function makePatternMatcher(pattern: string): (event: string) => boolean {
317
+ if (pattern === '*') return () => true;
318
+ if (pattern.endsWith('.*')) {
319
+ const prefix = pattern.slice(0, -2);
320
+ return (e: string) => e.startsWith(`${prefix}.`);
321
+ }
322
+ return (e: string) => e === pattern;
323
+ }
@@ -0,0 +1,18 @@
1
+ export { Container, token } from './container.js';
2
+ export type { Token, Factory, Decorator, BindOptions } from './container.js';
3
+ export { Pipeline } from './pipeline.js';
4
+ export type {
5
+ NextFn,
6
+ MiddlewareHandler,
7
+ Middleware,
8
+ ReadonlyPipeline,
9
+ PipelineErrorHandler,
10
+ PipelineErrorEvent,
11
+ PipelineErrorPolicy,
12
+ PipelineOptions,
13
+ } from './pipeline.js';
14
+ export { RunController } from './run-controller.js';
15
+ export type { RunControllerOptions } from './run-controller.js';
16
+ export { TOKENS } from './tokens.js';
17
+ export { EventBus, ScopedEventBus } from './events.js';
18
+ export type { EventMap, EventName, Listener, EventLogger } from './events.js';