cognitive-modules-cli 2.2.0 → 2.2.1

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.
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Module Runner - Execute Cognitive Modules
3
3
  * v2.2: Envelope format with meta/data separation, risk_rule, repair pass
4
+ * v2.2.1: Version field, enhanced error taxonomy, observability hooks, streaming
4
5
  */
5
6
 
7
+ import _Ajv from 'ajv';
8
+ const Ajv = _Ajv.default || _Ajv;
6
9
  import type {
7
10
  Provider,
8
11
  CognitiveModule,
@@ -20,6 +23,899 @@ import type {
20
23
  } from '../types.js';
21
24
  import { aggregateRisk, isV22Envelope } from '../types.js';
22
25
 
26
+ // =============================================================================
27
+ // Schema Validation
28
+ // =============================================================================
29
+
30
+ const ajv = new Ajv({ allErrors: true, strict: false });
31
+
32
+ /**
33
+ * Validate data against JSON schema. Returns list of errors.
34
+ */
35
+ export function validateData(data: unknown, schema: object, label: string = 'Data'): string[] {
36
+ const errors: string[] = [];
37
+ if (!schema || Object.keys(schema).length === 0) {
38
+ return errors;
39
+ }
40
+
41
+ try {
42
+ const validate = ajv.compile(schema);
43
+ const valid = validate(data);
44
+
45
+ if (!valid && validate.errors) {
46
+ for (const err of validate.errors) {
47
+ const path = err.instancePath || '/';
48
+ errors.push(`${label} validation error: ${err.message} at ${path}`);
49
+ }
50
+ }
51
+ } catch (e) {
52
+ errors.push(`Schema error: ${(e as Error).message}`);
53
+ }
54
+
55
+ return errors;
56
+ }
57
+
58
+ // =============================================================================
59
+ // v2.2 Policy Enforcement
60
+ // =============================================================================
61
+
62
+ /** Action types that can be checked against policies */
63
+ export type PolicyAction = 'network' | 'filesystem_write' | 'side_effects' | 'code_execution';
64
+
65
+ /** Tool categories for automatic policy mapping */
66
+ const TOOL_POLICY_MAPPING: Record<string, PolicyAction[]> = {
67
+ // Network tools
68
+ 'fetch': ['network'],
69
+ 'http': ['network'],
70
+ 'request': ['network'],
71
+ 'curl': ['network'],
72
+ 'wget': ['network'],
73
+ 'api_call': ['network'],
74
+
75
+ // Filesystem tools
76
+ 'write_file': ['filesystem_write', 'side_effects'],
77
+ 'create_file': ['filesystem_write', 'side_effects'],
78
+ 'delete_file': ['filesystem_write', 'side_effects'],
79
+ 'rename_file': ['filesystem_write', 'side_effects'],
80
+ 'mkdir': ['filesystem_write', 'side_effects'],
81
+ 'rmdir': ['filesystem_write', 'side_effects'],
82
+
83
+ // Code execution tools
84
+ 'shell': ['code_execution', 'side_effects'],
85
+ 'exec': ['code_execution', 'side_effects'],
86
+ 'run_code': ['code_execution', 'side_effects'],
87
+ 'code_interpreter': ['code_execution', 'side_effects'],
88
+ 'eval': ['code_execution', 'side_effects'],
89
+
90
+ // Database tools
91
+ 'sql_query': ['side_effects'],
92
+ 'db_write': ['side_effects'],
93
+ };
94
+
95
+ /** Result of a policy check */
96
+ export interface PolicyCheckResult {
97
+ allowed: boolean;
98
+ reason?: string;
99
+ policy?: string;
100
+ }
101
+
102
+ /**
103
+ * Check if a tool is allowed by the module's tools policy.
104
+ *
105
+ * @param toolName The name of the tool to check
106
+ * @param module The cognitive module config
107
+ * @returns PolicyCheckResult indicating if the tool is allowed
108
+ *
109
+ * @example
110
+ * const result = checkToolPolicy('write_file', module);
111
+ * if (!result.allowed) {
112
+ * throw new Error(result.reason);
113
+ * }
114
+ */
115
+ export function checkToolPolicy(
116
+ toolName: string,
117
+ module: CognitiveModule
118
+ ): PolicyCheckResult {
119
+ const toolsPolicy = module.tools;
120
+
121
+ // No policy = allow all
122
+ if (!toolsPolicy) {
123
+ return { allowed: true };
124
+ }
125
+
126
+ const normalizedName = toolName.toLowerCase().replace(/[-\s]/g, '_');
127
+
128
+ // Check explicit denied list first
129
+ if (toolsPolicy.denied?.some(d => d.toLowerCase().replace(/[-\s]/g, '_') === normalizedName)) {
130
+ return {
131
+ allowed: false,
132
+ reason: `Tool '${toolName}' is explicitly denied by module tools policy`,
133
+ policy: 'tools.denied'
134
+ };
135
+ }
136
+
137
+ // Check policy mode
138
+ if (toolsPolicy.policy === 'deny_by_default') {
139
+ // In deny_by_default mode, tool must be in allowed list
140
+ const isAllowed = toolsPolicy.allowed?.some(
141
+ a => a.toLowerCase().replace(/[-\s]/g, '_') === normalizedName
142
+ );
143
+
144
+ if (!isAllowed) {
145
+ return {
146
+ allowed: false,
147
+ reason: `Tool '${toolName}' not in allowed list (policy: deny_by_default)`,
148
+ policy: 'tools.policy'
149
+ };
150
+ }
151
+ }
152
+
153
+ return { allowed: true };
154
+ }
155
+
156
+ /**
157
+ * Check if an action is allowed by the module's policies.
158
+ *
159
+ * @param action The action to check (network, filesystem_write, etc.)
160
+ * @param module The cognitive module config
161
+ * @returns PolicyCheckResult indicating if the action is allowed
162
+ *
163
+ * @example
164
+ * const result = checkPolicy('network', module);
165
+ * if (!result.allowed) {
166
+ * throw new Error(result.reason);
167
+ * }
168
+ */
169
+ export function checkPolicy(
170
+ action: PolicyAction,
171
+ module: CognitiveModule
172
+ ): PolicyCheckResult {
173
+ const policies = module.policies;
174
+
175
+ // No policies = allow all
176
+ if (!policies) {
177
+ return { allowed: true };
178
+ }
179
+
180
+ // Check the specific policy
181
+ if (policies[action] === 'deny') {
182
+ return {
183
+ allowed: false,
184
+ reason: `Action '${action}' is denied by module policy`,
185
+ policy: `policies.${action}`
186
+ };
187
+ }
188
+
189
+ return { allowed: true };
190
+ }
191
+
192
+ /**
193
+ * Check if a tool is allowed considering both tools policy and general policies.
194
+ * This performs a comprehensive check that:
195
+ * 1. Checks the tools policy (allowed/denied lists)
196
+ * 2. Maps the tool to policy actions and checks those
197
+ *
198
+ * @param toolName The name of the tool to check
199
+ * @param module The cognitive module config
200
+ * @returns PolicyCheckResult with detailed information
201
+ *
202
+ * @example
203
+ * const result = checkToolAllowed('write_file', module);
204
+ * if (!result.allowed) {
205
+ * return makeErrorResponse({
206
+ * code: 'POLICY_VIOLATION',
207
+ * message: result.reason,
208
+ * });
209
+ * }
210
+ */
211
+ export function checkToolAllowed(
212
+ toolName: string,
213
+ module: CognitiveModule
214
+ ): PolicyCheckResult {
215
+ // First check explicit tools policy
216
+ const toolCheck = checkToolPolicy(toolName, module);
217
+ if (!toolCheck.allowed) {
218
+ return toolCheck;
219
+ }
220
+
221
+ // Then check mapped policies
222
+ const normalizedName = toolName.toLowerCase().replace(/[-\s]/g, '_');
223
+ const mappedActions = TOOL_POLICY_MAPPING[normalizedName] || [];
224
+
225
+ for (const action of mappedActions) {
226
+ const policyCheck = checkPolicy(action, module);
227
+ if (!policyCheck.allowed) {
228
+ return {
229
+ allowed: false,
230
+ reason: `Tool '${toolName}' requires '${action}' which is denied by policy`,
231
+ policy: policyCheck.policy
232
+ };
233
+ }
234
+ }
235
+
236
+ return { allowed: true };
237
+ }
238
+
239
+ /**
240
+ * Validate that a list of tools are all allowed by the module's policies.
241
+ * Returns all violations found.
242
+ *
243
+ * @param toolNames List of tool names to check
244
+ * @param module The cognitive module config
245
+ * @returns Array of PolicyCheckResult for denied tools
246
+ */
247
+ export function validateToolsAllowed(
248
+ toolNames: string[],
249
+ module: CognitiveModule
250
+ ): PolicyCheckResult[] {
251
+ const violations: PolicyCheckResult[] = [];
252
+
253
+ for (const toolName of toolNames) {
254
+ const result = checkToolAllowed(toolName, module);
255
+ if (!result.allowed) {
256
+ violations.push(result);
257
+ }
258
+ }
259
+
260
+ return violations;
261
+ }
262
+
263
+ /**
264
+ * Get all denied actions for a module based on its policies.
265
+ * Useful for informing LLM about restrictions.
266
+ */
267
+ export function getDeniedActions(module: CognitiveModule): PolicyAction[] {
268
+ const denied: PolicyAction[] = [];
269
+ const policies = module.policies;
270
+
271
+ if (!policies) return denied;
272
+
273
+ const actions: PolicyAction[] = ['network', 'filesystem_write', 'side_effects', 'code_execution'];
274
+ for (const action of actions) {
275
+ if (policies[action] === 'deny') {
276
+ denied.push(action);
277
+ }
278
+ }
279
+
280
+ return denied;
281
+ }
282
+
283
+ /**
284
+ * Get all denied tools for a module based on its tools policy.
285
+ */
286
+ export function getDeniedTools(module: CognitiveModule): string[] {
287
+ return module.tools?.denied || [];
288
+ }
289
+
290
+ /**
291
+ * Get all allowed tools for a module (only meaningful in deny_by_default mode).
292
+ */
293
+ export function getAllowedTools(module: CognitiveModule): string[] | null {
294
+ if (module.tools?.policy === 'deny_by_default') {
295
+ return module.tools.allowed || [];
296
+ }
297
+ return null; // null means "all allowed except denied list"
298
+ }
299
+
300
+ // =============================================================================
301
+ // Tool Call Interceptor
302
+ // =============================================================================
303
+
304
+ /** Tool call request from LLM */
305
+ export interface ToolCallRequest {
306
+ name: string;
307
+ arguments: Record<string, unknown>;
308
+ }
309
+
310
+ /** Tool call result */
311
+ export interface ToolCallResult {
312
+ success: boolean;
313
+ result?: unknown;
314
+ error?: {
315
+ code: string;
316
+ message: string;
317
+ };
318
+ }
319
+
320
+ /** Tool executor function type */
321
+ export type ToolExecutor = (args: Record<string, unknown>) => Promise<unknown>;
322
+
323
+ /**
324
+ * ToolCallInterceptor - Intercepts and validates tool calls against module policies.
325
+ *
326
+ * Use this class to wrap tool execution with policy enforcement:
327
+ *
328
+ * @example
329
+ * const interceptor = new ToolCallInterceptor(module);
330
+ *
331
+ * // Register tool executors
332
+ * interceptor.registerTool('read_file', async (args) => {
333
+ * return fs.readFile(args.path as string, 'utf-8');
334
+ * });
335
+ *
336
+ * // Execute tool with policy check
337
+ * const result = await interceptor.execute({
338
+ * name: 'write_file',
339
+ * arguments: { path: '/tmp/test.txt', content: 'hello' }
340
+ * });
341
+ *
342
+ * if (!result.success) {
343
+ * console.error('Tool blocked:', result.error);
344
+ * }
345
+ */
346
+ export class ToolCallInterceptor {
347
+ private module: CognitiveModule;
348
+ private tools: Map<string, ToolExecutor> = new Map();
349
+ private callLog: Array<{ tool: string; allowed: boolean; timestamp: number }> = [];
350
+
351
+ constructor(module: CognitiveModule) {
352
+ this.module = module;
353
+ }
354
+
355
+ /**
356
+ * Register a tool executor.
357
+ */
358
+ registerTool(name: string, executor: ToolExecutor): void {
359
+ this.tools.set(name.toLowerCase(), executor);
360
+ }
361
+
362
+ /**
363
+ * Register multiple tools at once.
364
+ */
365
+ registerTools(tools: Record<string, ToolExecutor>): void {
366
+ for (const [name, executor] of Object.entries(tools)) {
367
+ this.registerTool(name, executor);
368
+ }
369
+ }
370
+
371
+ /**
372
+ * Check if a tool call is allowed without executing it.
373
+ */
374
+ checkAllowed(toolName: string): PolicyCheckResult {
375
+ return checkToolAllowed(toolName, this.module);
376
+ }
377
+
378
+ /**
379
+ * Execute a tool call with policy enforcement.
380
+ *
381
+ * @param request The tool call request
382
+ * @returns ToolCallResult with success/error
383
+ */
384
+ async execute(request: ToolCallRequest): Promise<ToolCallResult> {
385
+ const { name, arguments: args } = request;
386
+ const timestamp = Date.now();
387
+
388
+ // Check policy
389
+ const policyResult = checkToolAllowed(name, this.module);
390
+
391
+ if (!policyResult.allowed) {
392
+ this.callLog.push({ tool: name, allowed: false, timestamp });
393
+ return {
394
+ success: false,
395
+ error: {
396
+ code: 'TOOL_NOT_ALLOWED',
397
+ message: policyResult.reason || `Tool '${name}' is not allowed`,
398
+ },
399
+ };
400
+ }
401
+
402
+ // Find executor
403
+ const executor = this.tools.get(name.toLowerCase());
404
+ if (!executor) {
405
+ return {
406
+ success: false,
407
+ error: {
408
+ code: 'TOOL_NOT_FOUND',
409
+ message: `Tool '${name}' is not registered`,
410
+ },
411
+ };
412
+ }
413
+
414
+ // Execute
415
+ try {
416
+ this.callLog.push({ tool: name, allowed: true, timestamp });
417
+ const result = await executor(args);
418
+ return { success: true, result };
419
+ } catch (e) {
420
+ return {
421
+ success: false,
422
+ error: {
423
+ code: 'TOOL_EXECUTION_ERROR',
424
+ message: (e as Error).message,
425
+ },
426
+ };
427
+ }
428
+ }
429
+
430
+ /**
431
+ * Execute multiple tool calls in sequence.
432
+ * Stops on first policy violation.
433
+ */
434
+ async executeMany(requests: ToolCallRequest[]): Promise<ToolCallResult[]> {
435
+ const results: ToolCallResult[] = [];
436
+
437
+ for (const request of requests) {
438
+ const result = await this.execute(request);
439
+ results.push(result);
440
+
441
+ // Stop on policy violation (not execution error)
442
+ if (!result.success && result.error?.code === 'TOOL_NOT_ALLOWED') {
443
+ break;
444
+ }
445
+ }
446
+
447
+ return results;
448
+ }
449
+
450
+ /**
451
+ * Get the call log for auditing.
452
+ */
453
+ getCallLog(): Array<{ tool: string; allowed: boolean; timestamp: number }> {
454
+ return [...this.callLog];
455
+ }
456
+
457
+ /**
458
+ * Get summary of denied calls.
459
+ */
460
+ getDeniedCalls(): Array<{ tool: string; timestamp: number }> {
461
+ return this.callLog
462
+ .filter(c => !c.allowed)
463
+ .map(({ tool, timestamp }) => ({ tool, timestamp }));
464
+ }
465
+
466
+ /**
467
+ * Clear the call log.
468
+ */
469
+ clearLog(): void {
470
+ this.callLog = [];
471
+ }
472
+
473
+ /**
474
+ * Get policy summary for this module.
475
+ */
476
+ getPolicySummary(): {
477
+ deniedActions: PolicyAction[];
478
+ deniedTools: string[];
479
+ allowedTools: string[] | null;
480
+ toolsPolicy: 'allow_by_default' | 'deny_by_default' | undefined;
481
+ } {
482
+ return {
483
+ deniedActions: getDeniedActions(this.module),
484
+ deniedTools: getDeniedTools(this.module),
485
+ allowedTools: getAllowedTools(this.module),
486
+ toolsPolicy: this.module.tools?.policy,
487
+ };
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Create a policy-aware tool executor wrapper.
493
+ *
494
+ * @example
495
+ * const safeExecutor = createPolicyAwareExecutor(module, 'write_file', async (args) => {
496
+ * return fs.writeFile(args.path, args.content);
497
+ * });
498
+ *
499
+ * // This will throw if write_file is denied
500
+ * await safeExecutor({ path: '/tmp/test.txt', content: 'hello' });
501
+ */
502
+ export function createPolicyAwareExecutor(
503
+ module: CognitiveModule,
504
+ toolName: string,
505
+ executor: ToolExecutor
506
+ ): ToolExecutor {
507
+ return async (args: Record<string, unknown>) => {
508
+ const policyResult = checkToolAllowed(toolName, module);
509
+
510
+ if (!policyResult.allowed) {
511
+ throw new Error(`Policy violation: ${policyResult.reason}`);
512
+ }
513
+
514
+ return executor(args);
515
+ };
516
+ }
517
+
518
+ // =============================================================================
519
+ // v2.2 Runtime Enforcement - Overflow & Enum
520
+ // =============================================================================
521
+
522
+ /**
523
+ * Validate overflow.insights against module's max_items config.
524
+ *
525
+ * @param data The response data object
526
+ * @param module The cognitive module config
527
+ * @returns Array of errors if insights exceed limit
528
+ */
529
+ export function validateOverflowLimits(
530
+ data: Record<string, unknown>,
531
+ module: CognitiveModule
532
+ ): string[] {
533
+ const errors: string[] = [];
534
+
535
+ const overflowConfig = module.overflow;
536
+ if (!overflowConfig?.enabled) {
537
+ // If overflow disabled, insights should not exist
538
+ const extensions = data.extensions as Record<string, unknown> | undefined;
539
+ if (extensions?.insights && Array.isArray(extensions.insights) && extensions.insights.length > 0) {
540
+ errors.push('Overflow is disabled but extensions.insights contains data');
541
+ }
542
+ return errors;
543
+ }
544
+
545
+ const maxItems = overflowConfig.max_items ?? 5;
546
+ const extensions = data.extensions as Record<string, unknown> | undefined;
547
+
548
+ if (extensions?.insights && Array.isArray(extensions.insights)) {
549
+ const insights = extensions.insights as unknown[];
550
+
551
+ if (insights.length > maxItems) {
552
+ errors.push(`overflow.max_items exceeded: ${insights.length} > ${maxItems}`);
553
+ }
554
+
555
+ // Check require_suggested_mapping
556
+ if (overflowConfig.require_suggested_mapping) {
557
+ for (let i = 0; i < insights.length; i++) {
558
+ const insight = insights[i] as Record<string, unknown>;
559
+ if (!insight.suggested_mapping) {
560
+ errors.push(`insight[${i}] missing required suggested_mapping`);
561
+ }
562
+ }
563
+ }
564
+ }
565
+
566
+ return errors;
567
+ }
568
+
569
+ /**
570
+ * Validate enum values against module's enum strategy.
571
+ * For strict mode, custom enum objects are not allowed.
572
+ *
573
+ * @param data The response data object
574
+ * @param module The cognitive module config
575
+ * @returns Array of errors if enum violations found
576
+ */
577
+ export function validateEnumStrategy(
578
+ data: Record<string, unknown>,
579
+ module: CognitiveModule
580
+ ): string[] {
581
+ const errors: string[] = [];
582
+
583
+ const enumStrategy = module.enums?.strategy ?? 'strict';
584
+
585
+ if (enumStrategy === 'strict') {
586
+ // In strict mode, custom enum objects (with 'custom' key) are not allowed
587
+ const checkForCustomEnums = (obj: unknown, path: string): void => {
588
+ if (obj === null || obj === undefined) return;
589
+
590
+ if (Array.isArray(obj)) {
591
+ obj.forEach((item, i) => checkForCustomEnums(item, `${path}[${i}]`));
592
+ } else if (typeof obj === 'object') {
593
+ const record = obj as Record<string, unknown>;
594
+
595
+ // Check if this is a custom enum object
596
+ if ('custom' in record && 'reason' in record && Object.keys(record).length === 2) {
597
+ errors.push(`Custom enum not allowed in strict mode at ${path}: { custom: "${record.custom}" }`);
598
+ return;
599
+ }
600
+
601
+ // Recurse into nested objects
602
+ for (const [key, value] of Object.entries(record)) {
603
+ checkForCustomEnums(value, `${path}.${key}`);
604
+ }
605
+ }
606
+ };
607
+
608
+ checkForCustomEnums(data, 'data');
609
+ }
610
+
611
+ return errors;
612
+ }
613
+
614
+ // =============================================================================
615
+ // Constants
616
+ // =============================================================================
617
+
618
+ const ENVELOPE_VERSION = '2.2';
619
+
620
+ // =============================================================================
621
+ // Utility Functions
622
+ // =============================================================================
623
+
624
+ /**
625
+ * Deep clone an object to avoid mutation issues.
626
+ * Handles nested objects, arrays, and primitive values.
627
+ */
628
+ function deepClone<T>(obj: T): T {
629
+ if (obj === null || typeof obj !== 'object') {
630
+ return obj;
631
+ }
632
+ if (Array.isArray(obj)) {
633
+ return obj.map(item => deepClone(item)) as T;
634
+ }
635
+ const cloned = {} as T;
636
+ for (const key in obj) {
637
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
638
+ cloned[key] = deepClone(obj[key]);
639
+ }
640
+ }
641
+ return cloned;
642
+ }
643
+
644
+ // =============================================================================
645
+ // Observability Hooks
646
+ // =============================================================================
647
+
648
+ /** Hook called before module execution */
649
+ export type BeforeCallHook = (moduleName: string, inputData: ModuleInput, moduleConfig: CognitiveModule) => void;
650
+
651
+ /** Hook called after successful module execution */
652
+ export type AfterCallHook = (moduleName: string, result: EnvelopeResponseV22<unknown>, latencyMs: number) => void;
653
+
654
+ /** Hook called when an error occurs */
655
+ export type ErrorHook = (moduleName: string, error: Error, partialResult: unknown | null) => void;
656
+
657
+ // Global hook registries
658
+ const _beforeCallHooks: BeforeCallHook[] = [];
659
+ const _afterCallHooks: AfterCallHook[] = [];
660
+ const _errorHooks: ErrorHook[] = [];
661
+
662
+ /**
663
+ * Decorator to register a before-call hook.
664
+ *
665
+ * @example
666
+ * onBeforeCall((moduleName, inputData, config) => {
667
+ * console.log(`Calling ${moduleName} with`, inputData);
668
+ * });
669
+ */
670
+ export function onBeforeCall(hook: BeforeCallHook): BeforeCallHook {
671
+ _beforeCallHooks.push(hook);
672
+ return hook;
673
+ }
674
+
675
+ /**
676
+ * Decorator to register an after-call hook.
677
+ *
678
+ * @example
679
+ * onAfterCall((moduleName, result, latencyMs) => {
680
+ * console.log(`${moduleName} completed in ${latencyMs}ms`);
681
+ * });
682
+ */
683
+ export function onAfterCall(hook: AfterCallHook): AfterCallHook {
684
+ _afterCallHooks.push(hook);
685
+ return hook;
686
+ }
687
+
688
+ /**
689
+ * Decorator to register an error hook.
690
+ *
691
+ * @example
692
+ * onError((moduleName, error, partialResult) => {
693
+ * console.error(`Error in ${moduleName}:`, error);
694
+ * });
695
+ */
696
+ export function onError(hook: ErrorHook): ErrorHook {
697
+ _errorHooks.push(hook);
698
+ return hook;
699
+ }
700
+
701
+ /**
702
+ * Register a hook programmatically.
703
+ */
704
+ export function registerHook(
705
+ hookType: 'before_call' | 'after_call' | 'error',
706
+ hook: BeforeCallHook | AfterCallHook | ErrorHook
707
+ ): void {
708
+ if (hookType === 'before_call') {
709
+ _beforeCallHooks.push(hook as BeforeCallHook);
710
+ } else if (hookType === 'after_call') {
711
+ _afterCallHooks.push(hook as AfterCallHook);
712
+ } else if (hookType === 'error') {
713
+ _errorHooks.push(hook as ErrorHook);
714
+ } else {
715
+ throw new Error(`Unknown hook type: ${hookType}`);
716
+ }
717
+ }
718
+
719
+ /**
720
+ * Unregister a hook. Returns true if found and removed.
721
+ */
722
+ export function unregisterHook(
723
+ hookType: 'before_call' | 'after_call' | 'error',
724
+ hook: BeforeCallHook | AfterCallHook | ErrorHook
725
+ ): boolean {
726
+ let hooks: unknown[];
727
+ if (hookType === 'before_call') {
728
+ hooks = _beforeCallHooks;
729
+ } else if (hookType === 'after_call') {
730
+ hooks = _afterCallHooks;
731
+ } else if (hookType === 'error') {
732
+ hooks = _errorHooks;
733
+ } else {
734
+ return false;
735
+ }
736
+
737
+ const index = hooks.indexOf(hook);
738
+ if (index !== -1) {
739
+ hooks.splice(index, 1);
740
+ return true;
741
+ }
742
+ return false;
743
+ }
744
+
745
+ /**
746
+ * Clear all registered hooks.
747
+ */
748
+ export function clearHooks(): void {
749
+ _beforeCallHooks.length = 0;
750
+ _afterCallHooks.length = 0;
751
+ _errorHooks.length = 0;
752
+ }
753
+
754
+ function _invokeBeforeHooks(moduleName: string, inputData: ModuleInput, moduleConfig: CognitiveModule): void {
755
+ for (const hook of _beforeCallHooks) {
756
+ try {
757
+ hook(moduleName, inputData, moduleConfig);
758
+ } catch {
759
+ // Hooks should not break the main flow
760
+ }
761
+ }
762
+ }
763
+
764
+ function _invokeAfterHooks(moduleName: string, result: EnvelopeResponseV22<unknown>, latencyMs: number): void {
765
+ for (const hook of _afterCallHooks) {
766
+ try {
767
+ hook(moduleName, result, latencyMs);
768
+ } catch {
769
+ // Hooks should not break the main flow
770
+ }
771
+ }
772
+ }
773
+
774
+ function _invokeErrorHooks(moduleName: string, error: Error, partialResult: unknown | null): void {
775
+ for (const hook of _errorHooks) {
776
+ try {
777
+ hook(moduleName, error, partialResult);
778
+ } catch {
779
+ // Hooks should not break the main flow
780
+ }
781
+ }
782
+ }
783
+
784
+ // =============================================================================
785
+ // Error Response Builder
786
+ // =============================================================================
787
+
788
+ /** Error codes and their default properties */
789
+ export const ERROR_PROPERTIES: Record<string, { recoverable: boolean; retry_after_ms: number | null }> = {
790
+ MODULE_NOT_FOUND: { recoverable: false, retry_after_ms: null },
791
+ INVALID_INPUT: { recoverable: false, retry_after_ms: null },
792
+ PARSE_ERROR: { recoverable: true, retry_after_ms: 1000 },
793
+ SCHEMA_VALIDATION_FAILED: { recoverable: true, retry_after_ms: 1000 },
794
+ META_VALIDATION_FAILED: { recoverable: true, retry_after_ms: 1000 },
795
+ POLICY_VIOLATION: { recoverable: false, retry_after_ms: null },
796
+ TOOL_NOT_ALLOWED: { recoverable: false, retry_after_ms: null },
797
+ LLM_ERROR: { recoverable: true, retry_after_ms: 5000 },
798
+ RATE_LIMITED: { recoverable: true, retry_after_ms: 10000 },
799
+ TIMEOUT: { recoverable: true, retry_after_ms: 5000 },
800
+ UNKNOWN: { recoverable: false, retry_after_ms: null },
801
+ };
802
+
803
+ export interface MakeErrorResponseOptions {
804
+ code: string;
805
+ message: string;
806
+ explain?: string;
807
+ partialData?: unknown;
808
+ details?: Record<string, unknown>;
809
+ recoverable?: boolean;
810
+ retryAfterMs?: number;
811
+ confidence?: number;
812
+ risk?: RiskLevel;
813
+ }
814
+
815
+ /**
816
+ * Build a standardized error response with enhanced taxonomy.
817
+ */
818
+ export function makeErrorResponse(options: MakeErrorResponseOptions): EnvelopeResponseV22<unknown> {
819
+ const {
820
+ code,
821
+ message,
822
+ explain,
823
+ partialData,
824
+ details,
825
+ recoverable,
826
+ retryAfterMs,
827
+ confidence = 0.0,
828
+ risk = 'high',
829
+ } = options;
830
+
831
+ // Get default properties from error code
832
+ const defaults = ERROR_PROPERTIES[code] || ERROR_PROPERTIES.UNKNOWN;
833
+
834
+ const errorObj: {
835
+ code: string;
836
+ message: string;
837
+ recoverable?: boolean;
838
+ retry_after_ms?: number;
839
+ details?: Record<string, unknown>;
840
+ } = {
841
+ code,
842
+ message,
843
+ };
844
+
845
+ // Add recoverable flag
846
+ const isRecoverable = recoverable ?? defaults.recoverable;
847
+ if (isRecoverable !== undefined) {
848
+ errorObj.recoverable = isRecoverable;
849
+ }
850
+
851
+ // Add retry suggestion
852
+ const retryMs = retryAfterMs ?? defaults.retry_after_ms;
853
+ if (retryMs !== null) {
854
+ errorObj.retry_after_ms = retryMs;
855
+ }
856
+
857
+ // Add details if provided
858
+ if (details) {
859
+ errorObj.details = details;
860
+ }
861
+
862
+ return {
863
+ ok: false,
864
+ version: ENVELOPE_VERSION,
865
+ meta: {
866
+ confidence,
867
+ risk,
868
+ explain: (explain || message).slice(0, 280),
869
+ },
870
+ error: errorObj,
871
+ partial_data: partialData,
872
+ };
873
+ }
874
+
875
+ export interface MakeSuccessResponseOptions {
876
+ data: unknown;
877
+ confidence: number;
878
+ risk: RiskLevel;
879
+ explain: string;
880
+ latencyMs?: number;
881
+ model?: string;
882
+ traceId?: string;
883
+ }
884
+
885
+ /**
886
+ * Build a standardized success response.
887
+ */
888
+ export function makeSuccessResponse(options: MakeSuccessResponseOptions): EnvelopeResponseV22<unknown> {
889
+ const { data, confidence, risk, explain, latencyMs, model, traceId } = options;
890
+
891
+ const meta: EnvelopeMeta = {
892
+ confidence: Math.max(0.0, Math.min(1.0, confidence)),
893
+ risk,
894
+ explain: explain ? explain.slice(0, 280) : 'No explanation provided',
895
+ };
896
+
897
+ if (latencyMs !== undefined) {
898
+ meta.latency_ms = latencyMs;
899
+ }
900
+ if (model) {
901
+ meta.model = model;
902
+ }
903
+ if (traceId) {
904
+ meta.trace_id = traceId;
905
+ }
906
+
907
+ return {
908
+ ok: true,
909
+ version: ENVELOPE_VERSION,
910
+ meta,
911
+ data,
912
+ };
913
+ }
914
+
915
+ // =============================================================================
916
+ // Run Options
917
+ // =============================================================================
918
+
23
919
  export interface RunOptions {
24
920
  // Clean input (v2 style)
25
921
  input?: ModuleInput;
@@ -30,6 +926,12 @@ export interface RunOptions {
30
926
  // Runtime options
31
927
  verbose?: boolean;
32
928
 
929
+ // Whether to validate input against schema (default: true)
930
+ validateInput?: boolean;
931
+
932
+ // Whether to validate output against schema (default: true)
933
+ validateOutput?: boolean;
934
+
33
935
  // Force envelope format (default: auto-detect from module.output.envelope)
34
936
  useEnvelope?: boolean;
35
937
 
@@ -38,6 +940,12 @@ export interface RunOptions {
38
940
 
39
941
  // Enable repair pass for validation failures (default: true)
40
942
  enableRepair?: boolean;
943
+
944
+ // Trace ID for distributed tracing
945
+ traceId?: string;
946
+
947
+ // Model identifier (for meta.model tracking)
948
+ model?: string;
41
949
  }
42
950
 
43
951
  // =============================================================================
@@ -47,20 +955,24 @@ export interface RunOptions {
47
955
  /**
48
956
  * Attempt to repair envelope format issues without changing semantics.
49
957
  *
50
- * Repairs (lossless only):
958
+ * Repairs (mostly lossless, except explain truncation):
51
959
  * - Missing meta fields (fill with conservative defaults)
52
960
  * - Truncate explain if too long
53
961
  * - Trim whitespace from string fields
962
+ * - Clamp confidence to [0, 1] range
54
963
  *
55
964
  * Does NOT repair:
56
965
  * - Invalid enum values (treated as validation failure)
966
+ *
967
+ * Note: Returns a deep copy to avoid modifying the original data.
57
968
  */
58
969
  function repairEnvelope(
59
970
  response: Record<string, unknown>,
60
971
  riskRule: RiskRule = 'max_changes_risk',
61
972
  maxExplainLength: number = 280
62
973
  ): EnvelopeResponseV22<unknown> {
63
- const repaired = { ...response };
974
+ // Deep clone to avoid mutation
975
+ const repaired = deepClone(response);
64
976
 
65
977
  // Ensure meta exists
66
978
  if (!repaired.meta || typeof repaired.meta !== 'object') {
@@ -101,7 +1013,7 @@ function repairEnvelope(
101
1013
  meta.explain = (meta.explain as string).slice(0, maxExplainLength - 3) + '...';
102
1014
  }
103
1015
 
104
- // Build proper v2.2 response
1016
+ // Build proper v2.2 response with version
105
1017
  const builtMeta: EnvelopeMeta = {
106
1018
  confidence: meta.confidence as number,
107
1019
  risk: meta.risk as RiskLevel,
@@ -110,11 +1022,13 @@ function repairEnvelope(
110
1022
 
111
1023
  const result: EnvelopeResponseV22<unknown> = repaired.ok === false ? {
112
1024
  ok: false,
1025
+ version: ENVELOPE_VERSION,
113
1026
  meta: builtMeta,
114
1027
  error: (repaired.error as { code: string; message: string }) ?? { code: 'UNKNOWN', message: 'Unknown error' },
115
1028
  partial_data: repaired.partial_data
116
1029
  } : {
117
1030
  ok: true,
1031
+ version: ENVELOPE_VERSION,
118
1032
  meta: builtMeta,
119
1033
  data: repaired.data
120
1034
  };
@@ -122,6 +1036,50 @@ function repairEnvelope(
122
1036
  return result;
123
1037
  }
124
1038
 
1039
+ /**
1040
+ * Repair error envelope format.
1041
+ *
1042
+ * Note: Returns a deep copy to avoid modifying the original data.
1043
+ */
1044
+ function repairErrorEnvelope(
1045
+ data: Record<string, unknown>,
1046
+ maxExplainLength: number = 280
1047
+ ): EnvelopeResponseV22<unknown> {
1048
+ // Deep clone to avoid mutation
1049
+ const repaired = deepClone(data);
1050
+
1051
+ // Ensure meta exists for errors
1052
+ if (!repaired.meta || typeof repaired.meta !== 'object') {
1053
+ repaired.meta = {};
1054
+ }
1055
+
1056
+ const meta = repaired.meta as Record<string, unknown>;
1057
+
1058
+ // Set default meta for errors
1059
+ if (typeof meta.confidence !== 'number') {
1060
+ meta.confidence = 0.0;
1061
+ }
1062
+ if (!meta.risk) {
1063
+ meta.risk = 'high';
1064
+ }
1065
+ if (typeof meta.explain !== 'string') {
1066
+ const error = (repaired.error ?? {}) as Record<string, unknown>;
1067
+ meta.explain = ((error.message as string) ?? 'An error occurred').slice(0, maxExplainLength);
1068
+ }
1069
+
1070
+ return {
1071
+ ok: false,
1072
+ version: ENVELOPE_VERSION,
1073
+ meta: {
1074
+ confidence: meta.confidence as number,
1075
+ risk: meta.risk as RiskLevel,
1076
+ explain: meta.explain as string,
1077
+ },
1078
+ error: (repaired.error as { code: string; message: string }) ?? { code: 'UNKNOWN', message: 'Unknown error' },
1079
+ partial_data: repaired.partial_data,
1080
+ };
1081
+ }
1082
+
125
1083
  /**
126
1084
  * Wrap v2.1 response to v2.2 format
127
1085
  */
@@ -130,6 +1088,10 @@ function wrapV21ToV22(
130
1088
  riskRule: RiskRule = 'max_changes_risk'
131
1089
  ): EnvelopeResponseV22<unknown> {
132
1090
  if (isV22Envelope(response)) {
1091
+ // Already v2.2, but ensure version field exists
1092
+ if (!('version' in response) || !response.version) {
1093
+ return { ...deepClone(response), version: ENVELOPE_VERSION };
1094
+ }
133
1095
  return response;
134
1096
  }
135
1097
 
@@ -140,6 +1102,7 @@ function wrapV21ToV22(
140
1102
 
141
1103
  return {
142
1104
  ok: true,
1105
+ version: ENVELOPE_VERSION,
143
1106
  meta: {
144
1107
  confidence,
145
1108
  risk: aggregateRisk(data, riskRule),
@@ -151,6 +1114,7 @@ function wrapV21ToV22(
151
1114
  const errorMsg = response.error?.message ?? 'Unknown error';
152
1115
  return {
153
1116
  ok: false,
1117
+ version: ENVELOPE_VERSION,
154
1118
  meta: {
155
1119
  confidence: 0,
156
1120
  risk: 'high',
@@ -162,12 +1126,61 @@ function wrapV21ToV22(
162
1126
  }
163
1127
  }
164
1128
 
1129
+ /**
1130
+ * Convert legacy format (no envelope) to v2.2 envelope.
1131
+ */
1132
+ function convertLegacyToEnvelope(
1133
+ data: Record<string, unknown>,
1134
+ isError: boolean = false
1135
+ ): EnvelopeResponseV22<unknown> {
1136
+ if (isError || 'error' in data) {
1137
+ const error = (data.error ?? {}) as Record<string, unknown>;
1138
+ const errorMsg = typeof error === 'object'
1139
+ ? ((error.message as string) ?? String(error))
1140
+ : String(error);
1141
+
1142
+ return {
1143
+ ok: false,
1144
+ version: ENVELOPE_VERSION,
1145
+ meta: {
1146
+ confidence: 0.0,
1147
+ risk: 'high',
1148
+ explain: errorMsg.slice(0, 280),
1149
+ },
1150
+ error: {
1151
+ code: (typeof error === 'object' ? (error.code as string) : undefined) ?? 'UNKNOWN',
1152
+ message: errorMsg,
1153
+ },
1154
+ partial_data: undefined,
1155
+ };
1156
+ } else {
1157
+ const confidence = (data.confidence as number) ?? 0.5;
1158
+ const rationale = (data.rationale as string) ?? '';
1159
+
1160
+ return {
1161
+ ok: true,
1162
+ version: ENVELOPE_VERSION,
1163
+ meta: {
1164
+ confidence,
1165
+ risk: aggregateRisk(data),
1166
+ explain: rationale.slice(0, 280) || 'No explanation provided',
1167
+ },
1168
+ data,
1169
+ };
1170
+ }
1171
+ }
1172
+
1173
+ // =============================================================================
1174
+ // Main Runner
1175
+ // =============================================================================
1176
+
165
1177
  export async function runModule(
166
1178
  module: CognitiveModule,
167
1179
  provider: Provider,
168
1180
  options: RunOptions = {}
169
1181
  ): Promise<ModuleResult> {
170
- const { args, input, verbose = false, useEnvelope, useV22, enableRepair = true } = options;
1182
+ const { args, input, verbose = false, validateInput = true, validateOutput = true, useEnvelope, useV22, enableRepair = true, traceId, model: modelOverride } = options;
1183
+ const startTime = Date.now();
171
1184
 
172
1185
  // Determine if we should use envelope format
173
1186
  const shouldUseEnvelope = useEnvelope ?? (module.output?.envelope === true || module.format === 'v2');
@@ -192,6 +1205,26 @@ export async function runModule(
192
1205
  }
193
1206
  }
194
1207
 
1208
+ // Invoke before hooks
1209
+ _invokeBeforeHooks(module.name, inputData, module);
1210
+
1211
+ // Validate input against schema
1212
+ if (validateInput && module.inputSchema && Object.keys(module.inputSchema).length > 0) {
1213
+ const inputErrors = validateData(inputData, module.inputSchema, 'Input');
1214
+ if (inputErrors.length > 0) {
1215
+ const errorResult = makeErrorResponse({
1216
+ code: 'INVALID_INPUT',
1217
+ message: inputErrors.join('; '),
1218
+ explain: 'Input validation failed.',
1219
+ confidence: 1.0,
1220
+ risk: 'none',
1221
+ details: { validation_errors: inputErrors },
1222
+ });
1223
+ _invokeErrorHooks(module.name, new Error(inputErrors.join('; ')), null);
1224
+ return errorResult as ModuleResult;
1225
+ }
1226
+ }
1227
+
195
1228
  // Build prompt with clean substitution
196
1229
  const prompt = buildPrompt(module, inputData);
197
1230
 
@@ -269,82 +1302,437 @@ export async function runModule(
269
1302
  { role: 'user', content: prompt },
270
1303
  ];
271
1304
 
272
- // Invoke provider
273
- const result = await provider.invoke({
274
- messages,
275
- jsonSchema: module.outputSchema,
276
- temperature: 0.3,
277
- });
1305
+ try {
1306
+ // Invoke provider
1307
+ const result = await provider.invoke({
1308
+ messages,
1309
+ jsonSchema: module.outputSchema,
1310
+ temperature: 0.3,
1311
+ });
278
1312
 
279
- if (verbose) {
280
- console.error('--- Response ---');
281
- console.error(result.content);
282
- console.error('--- End Response ---');
283
- }
1313
+ if (verbose) {
1314
+ console.error('--- Response ---');
1315
+ console.error(result.content);
1316
+ console.error('--- End Response ---');
1317
+ }
284
1318
 
285
- // Parse response
286
- let parsed: unknown;
287
- try {
288
- const jsonMatch = result.content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
289
- const jsonStr = jsonMatch ? jsonMatch[1] : result.content;
290
- parsed = JSON.parse(jsonStr.trim());
291
- } catch {
292
- throw new Error(`Failed to parse JSON response: ${result.content.substring(0, 500)}`);
293
- }
1319
+ // Calculate latency
1320
+ const latencyMs = Date.now() - startTime;
294
1321
 
295
- // Handle envelope format
296
- if (shouldUseEnvelope && isEnvelopeResponse(parsed)) {
297
- let response = parseEnvelopeResponse(parsed, result.content);
298
-
299
- // Upgrade to v2.2 if needed
300
- if (shouldUseV22 && response.ok && !('meta' in response && response.meta)) {
301
- const upgraded = wrapV21ToV22(parsed as EnvelopeResponse<unknown>, riskRule);
302
- response = {
303
- ok: true,
304
- meta: upgraded.meta as EnvelopeMeta,
305
- data: (upgraded as { data?: ModuleResultData }).data,
306
- raw: result.content
307
- } as ModuleResultV22;
1322
+ // Parse response
1323
+ let parsed: unknown;
1324
+ try {
1325
+ const jsonMatch = result.content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
1326
+ const jsonStr = jsonMatch ? jsonMatch[1] : result.content;
1327
+ parsed = JSON.parse(jsonStr.trim());
1328
+ } catch (e) {
1329
+ const errorResult = makeErrorResponse({
1330
+ code: 'PARSE_ERROR',
1331
+ message: `Failed to parse JSON response: ${(e as Error).message}`,
1332
+ explain: 'Failed to parse LLM response as JSON.',
1333
+ details: { raw_response: result.content.substring(0, 500) },
1334
+ });
1335
+ _invokeErrorHooks(module.name, e as Error, null);
1336
+ return errorResult as ModuleResult;
308
1337
  }
309
-
310
- // Apply repair pass if enabled and response needs it
311
- if (enableRepair && response.ok && shouldUseV22) {
312
- const repaired = repairEnvelope(
313
- response as unknown as Record<string, unknown>,
314
- riskRule
315
- );
316
- response = {
317
- ok: true,
318
- meta: repaired.meta as EnvelopeMeta,
319
- data: (repaired as { data?: ModuleResultData }).data,
320
- raw: result.content
321
- } as ModuleResultV22;
1338
+
1339
+ // Convert to v2.2 envelope
1340
+ let response: EnvelopeResponseV22<unknown>;
1341
+ if (isV22Envelope(parsed as EnvelopeResponse<unknown>)) {
1342
+ response = parsed as EnvelopeResponseV22<unknown>;
1343
+ } else if (isEnvelopeResponse(parsed)) {
1344
+ response = wrapV21ToV22(parsed as EnvelopeResponse<unknown>, riskRule);
1345
+ } else {
1346
+ response = convertLegacyToEnvelope(parsed as Record<string, unknown>);
322
1347
  }
323
-
324
- return response;
1348
+
1349
+ // Add version and meta fields
1350
+ response.version = ENVELOPE_VERSION;
1351
+ if (response.meta) {
1352
+ response.meta.latency_ms = latencyMs;
1353
+ if (traceId) {
1354
+ response.meta.trace_id = traceId;
1355
+ }
1356
+ if (modelOverride) {
1357
+ response.meta.model = modelOverride;
1358
+ }
1359
+ }
1360
+
1361
+ // Validate and potentially repair output
1362
+ if (response.ok && validateOutput) {
1363
+ // Get data schema (support both "data" and "output" aliases)
1364
+ const dataSchema = module.dataSchema || module.outputSchema;
1365
+ const metaSchema = module.metaSchema;
1366
+ const dataToValidate = (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data ?? {};
1367
+
1368
+ if (dataSchema && Object.keys(dataSchema).length > 0) {
1369
+ let dataErrors = validateData(dataToValidate, dataSchema, 'Data');
1370
+
1371
+ if (dataErrors.length > 0 && enableRepair) {
1372
+ // Attempt repair pass
1373
+ response = repairEnvelope(
1374
+ response as unknown as Record<string, unknown>,
1375
+ riskRule
1376
+ );
1377
+ response.version = ENVELOPE_VERSION;
1378
+
1379
+ // Re-validate after repair
1380
+ const repairedData = (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data ?? {};
1381
+ dataErrors = validateData(repairedData, dataSchema, 'Data');
1382
+ }
1383
+
1384
+ if (dataErrors.length > 0) {
1385
+ const errorResult = makeErrorResponse({
1386
+ code: 'SCHEMA_VALIDATION_FAILED',
1387
+ message: dataErrors.join('; '),
1388
+ explain: 'Schema validation failed after repair attempt.',
1389
+ partialData: (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data,
1390
+ details: { validation_errors: dataErrors },
1391
+ });
1392
+ _invokeErrorHooks(module.name, new Error(dataErrors.join('; ')), (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data);
1393
+ return errorResult as ModuleResult;
1394
+ }
1395
+ }
1396
+
1397
+ // v2.2: Validate overflow limits
1398
+ const overflowErrors = validateOverflowLimits(dataToValidate as Record<string, unknown>, module);
1399
+ if (overflowErrors.length > 0) {
1400
+ const errorResult = makeErrorResponse({
1401
+ code: 'SCHEMA_VALIDATION_FAILED',
1402
+ message: overflowErrors.join('; '),
1403
+ explain: 'Overflow validation failed.',
1404
+ partialData: dataToValidate,
1405
+ details: { overflow_errors: overflowErrors },
1406
+ });
1407
+ _invokeErrorHooks(module.name, new Error(overflowErrors.join('; ')), dataToValidate);
1408
+ return errorResult as ModuleResult;
1409
+ }
1410
+
1411
+ // v2.2: Validate enum strategy
1412
+ const enumErrors = validateEnumStrategy(dataToValidate as Record<string, unknown>, module);
1413
+ if (enumErrors.length > 0) {
1414
+ const errorResult = makeErrorResponse({
1415
+ code: 'SCHEMA_VALIDATION_FAILED',
1416
+ message: enumErrors.join('; '),
1417
+ explain: 'Enum strategy validation failed.',
1418
+ partialData: dataToValidate,
1419
+ details: { enum_errors: enumErrors },
1420
+ });
1421
+ _invokeErrorHooks(module.name, new Error(enumErrors.join('; ')), dataToValidate);
1422
+ return errorResult as ModuleResult;
1423
+ }
1424
+
1425
+ // Validate meta if schema exists
1426
+ if (metaSchema && Object.keys(metaSchema).length > 0) {
1427
+ let metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
1428
+
1429
+ if (metaErrors.length > 0 && enableRepair) {
1430
+ response = repairEnvelope(
1431
+ response as unknown as Record<string, unknown>,
1432
+ riskRule
1433
+ );
1434
+ response.version = ENVELOPE_VERSION;
1435
+
1436
+ // Re-validate meta after repair
1437
+ metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
1438
+
1439
+ if (metaErrors.length > 0) {
1440
+ const errorResult = makeErrorResponse({
1441
+ code: 'META_VALIDATION_FAILED',
1442
+ message: metaErrors.join('; '),
1443
+ explain: 'Meta schema validation failed after repair attempt.',
1444
+ partialData: (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data,
1445
+ details: { validation_errors: metaErrors },
1446
+ });
1447
+ _invokeErrorHooks(module.name, new Error(metaErrors.join('; ')), (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data);
1448
+ return errorResult as ModuleResult;
1449
+ }
1450
+ }
1451
+ }
1452
+ } else if (enableRepair) {
1453
+ // Repair error envelopes to ensure they have proper meta fields
1454
+ response = repairErrorEnvelope(response as unknown as Record<string, unknown>);
1455
+ response.version = ENVELOPE_VERSION;
1456
+ }
1457
+
1458
+ // Invoke after hooks
1459
+ const finalLatencyMs = Date.now() - startTime;
1460
+ _invokeAfterHooks(module.name, response, finalLatencyMs);
1461
+
1462
+ return response as ModuleResult;
1463
+
1464
+ } catch (e) {
1465
+ const latencyMs = Date.now() - startTime;
1466
+ const errorResult = makeErrorResponse({
1467
+ code: 'UNKNOWN',
1468
+ message: (e as Error).message,
1469
+ explain: `Unexpected error: ${(e as Error).name}`,
1470
+ details: { exception_type: (e as Error).name },
1471
+ });
1472
+ if (errorResult.meta) {
1473
+ errorResult.meta.latency_ms = latencyMs;
1474
+ }
1475
+ _invokeErrorHooks(module.name, e as Error, null);
1476
+ return errorResult as ModuleResult;
325
1477
  }
1478
+ }
326
1479
 
327
- // Handle legacy format (non-envelope)
328
- const legacyResult = parseLegacyResponse(parsed, result.content);
329
-
330
- // Upgrade to v2.2 if requested
331
- if (shouldUseV22 && legacyResult.ok) {
332
- const data = (legacyResult.data ?? {}) as Record<string, unknown>;
1480
+ // =============================================================================
1481
+ // Streaming Support
1482
+ // =============================================================================
1483
+
1484
+ /** Event types emitted during streaming execution */
1485
+ export type StreamEventType = 'start' | 'chunk' | 'meta' | 'complete' | 'error';
1486
+
1487
+ /** Event emitted during streaming execution */
1488
+ export interface StreamEvent {
1489
+ type: StreamEventType;
1490
+ timestamp_ms: number;
1491
+ module_name: string;
1492
+ chunk?: string;
1493
+ meta?: EnvelopeMeta;
1494
+ result?: EnvelopeResponseV22<unknown>;
1495
+ error?: { code: string; message: string };
1496
+ }
1497
+
1498
+ export interface StreamOptions {
1499
+ input?: ModuleInput;
1500
+ args?: string;
1501
+ validateInput?: boolean;
1502
+ validateOutput?: boolean;
1503
+ useV22?: boolean;
1504
+ enableRepair?: boolean;
1505
+ traceId?: string;
1506
+ model?: string; // Model identifier for meta.model
1507
+ }
1508
+
1509
+ /**
1510
+ * Run a cognitive module with streaming output.
1511
+ *
1512
+ * Yields StreamEvent objects as the module executes:
1513
+ * - type="start": Module execution started
1514
+ * - type="chunk": Incremental data chunk (if LLM supports streaming)
1515
+ * - type="meta": Meta information available early
1516
+ * - type="complete": Final complete result
1517
+ * - type="error": Error occurred
1518
+ *
1519
+ * @example
1520
+ * for await (const event of runModuleStream(module, provider, options)) {
1521
+ * if (event.type === 'chunk') {
1522
+ * process.stdout.write(event.chunk);
1523
+ * } else if (event.type === 'complete') {
1524
+ * console.log('Result:', event.result);
1525
+ * }
1526
+ * }
1527
+ */
1528
+ export async function* runModuleStream(
1529
+ module: CognitiveModule,
1530
+ provider: Provider,
1531
+ options: StreamOptions = {}
1532
+ ): AsyncGenerator<StreamEvent> {
1533
+ const { input, args, validateInput = true, validateOutput = true, useV22 = true, enableRepair = true, traceId, model } = options;
1534
+ const startTime = Date.now();
1535
+ const moduleName = module.name;
1536
+
1537
+ function makeEvent(type: StreamEventType, extra: Partial<StreamEvent> = {}): StreamEvent {
333
1538
  return {
334
- ok: true,
335
- meta: {
336
- confidence: (data.confidence as number) ?? 0.5,
337
- risk: aggregateRisk(data, riskRule),
338
- explain: ((data.rationale as string) ?? '').slice(0, 280) || 'No explanation provided'
339
- },
340
- data: legacyResult.data,
341
- raw: result.content
342
- } as ModuleResultV22;
1539
+ type,
1540
+ timestamp_ms: Date.now() - startTime,
1541
+ module_name: moduleName,
1542
+ ...extra,
1543
+ };
1544
+ }
1545
+
1546
+ try {
1547
+ // Emit start event
1548
+ yield makeEvent('start');
1549
+
1550
+ // Build input data
1551
+ const inputData: ModuleInput = input || {};
1552
+ if (args && !inputData.code && !inputData.query) {
1553
+ if (looksLikeCode(args)) {
1554
+ inputData.code = args;
1555
+ } else {
1556
+ inputData.query = args;
1557
+ }
1558
+ }
1559
+
1560
+ // Validate input if enabled
1561
+ if (validateInput && module.inputSchema && Object.keys(module.inputSchema).length > 0) {
1562
+ const inputErrors = validateData(inputData, module.inputSchema, 'Input');
1563
+ if (inputErrors.length > 0) {
1564
+ const errorResult = makeErrorResponse({
1565
+ code: 'INVALID_INPUT',
1566
+ message: inputErrors.join('; '),
1567
+ confidence: 1.0,
1568
+ risk: 'none',
1569
+ });
1570
+ const errorObj = (errorResult as { error: { code: string; message: string } }).error;
1571
+ yield makeEvent('error', { error: errorObj });
1572
+ yield makeEvent('complete', { result: errorResult });
1573
+ return;
1574
+ }
1575
+ }
1576
+
1577
+ // Get risk_rule from module config
1578
+ const riskRule: RiskRule = module.metaConfig?.risk_rule ?? 'max_changes_risk';
1579
+
1580
+ // Build prompt
1581
+ const prompt = buildPrompt(module, inputData);
1582
+
1583
+ // Build messages
1584
+ const systemParts: string[] = [
1585
+ `You are executing the "${module.name}" Cognitive Module.`,
1586
+ '',
1587
+ `RESPONSIBILITY: ${module.responsibility}`,
1588
+ ];
1589
+
1590
+ if (useV22) {
1591
+ systemParts.push('', 'RESPONSE FORMAT (Envelope v2.2):');
1592
+ systemParts.push('- Wrap your response in the v2.2 envelope format');
1593
+ systemParts.push('- Success: { "ok": true, "meta": { "confidence": 0.9, "risk": "low", "explain": "short summary" }, "data": { ...payload... } }');
1594
+ systemParts.push('- Return ONLY valid JSON.');
1595
+ }
1596
+
1597
+ const messages: Message[] = [
1598
+ { role: 'system', content: systemParts.join('\n') },
1599
+ { role: 'user', content: prompt },
1600
+ ];
1601
+
1602
+ // Invoke provider (streaming not yet supported in provider interface, so we fallback)
1603
+ const result = await provider.invoke({
1604
+ messages,
1605
+ jsonSchema: module.outputSchema,
1606
+ temperature: 0.3,
1607
+ });
1608
+
1609
+ // Emit chunk event with full response
1610
+ yield makeEvent('chunk', { chunk: result.content });
1611
+
1612
+ // Parse response
1613
+ let parsed: unknown;
1614
+ try {
1615
+ const jsonMatch = result.content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
1616
+ const jsonStr = jsonMatch ? jsonMatch[1] : result.content;
1617
+ parsed = JSON.parse(jsonStr.trim());
1618
+ } catch (e) {
1619
+ const errorResult = makeErrorResponse({
1620
+ code: 'PARSE_ERROR',
1621
+ message: `Failed to parse JSON: ${(e as Error).message}`,
1622
+ });
1623
+ // errorResult is always an error response from makeErrorResponse
1624
+ const errorObj = (errorResult as { error: { code: string; message: string } }).error;
1625
+ yield makeEvent('error', { error: errorObj });
1626
+ yield makeEvent('complete', { result: errorResult });
1627
+ return;
1628
+ }
1629
+
1630
+ // Convert to v2.2 envelope
1631
+ let response: EnvelopeResponseV22<unknown>;
1632
+ if (isV22Envelope(parsed as EnvelopeResponse<unknown>)) {
1633
+ response = parsed as EnvelopeResponseV22<unknown>;
1634
+ } else if (isEnvelopeResponse(parsed)) {
1635
+ response = wrapV21ToV22(parsed as EnvelopeResponse<unknown>, riskRule);
1636
+ } else {
1637
+ response = convertLegacyToEnvelope(parsed as Record<string, unknown>);
1638
+ }
1639
+
1640
+ // Add version and meta
1641
+ response.version = ENVELOPE_VERSION;
1642
+ const latencyMs = Date.now() - startTime;
1643
+ if (response.meta) {
1644
+ response.meta.latency_ms = latencyMs;
1645
+ if (traceId) {
1646
+ response.meta.trace_id = traceId;
1647
+ }
1648
+ if (model) {
1649
+ response.meta.model = model;
1650
+ }
1651
+ // Emit meta event early
1652
+ yield makeEvent('meta', { meta: response.meta });
1653
+ }
1654
+
1655
+ // Validate and repair output
1656
+ if (response.ok && validateOutput) {
1657
+ const dataSchema = module.dataSchema || module.outputSchema;
1658
+ const metaSchema = module.metaSchema;
1659
+
1660
+ if (dataSchema && Object.keys(dataSchema).length > 0) {
1661
+ const dataToValidate = (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data ?? {};
1662
+ let dataErrors = validateData(dataToValidate, dataSchema, 'Data');
1663
+
1664
+ if (dataErrors.length > 0 && enableRepair) {
1665
+ response = repairEnvelope(response as unknown as Record<string, unknown>, riskRule);
1666
+ response.version = ENVELOPE_VERSION;
1667
+ // Re-validate after repair
1668
+ const repairedData = (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data ?? {};
1669
+ dataErrors = validateData(repairedData, dataSchema, 'Data');
1670
+ }
1671
+
1672
+ if (dataErrors.length > 0) {
1673
+ const errorResult = makeErrorResponse({
1674
+ code: 'SCHEMA_VALIDATION_FAILED',
1675
+ message: dataErrors.join('; '),
1676
+ explain: 'Schema validation failed after repair attempt.',
1677
+ partialData: (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data,
1678
+ details: { validation_errors: dataErrors },
1679
+ });
1680
+ const errorObj = (errorResult as { error: { code: string; message: string } }).error;
1681
+ yield makeEvent('error', { error: errorObj });
1682
+ yield makeEvent('complete', { result: errorResult });
1683
+ return;
1684
+ }
1685
+ }
1686
+
1687
+ // Validate meta if schema exists
1688
+ if (metaSchema && Object.keys(metaSchema).length > 0) {
1689
+ let metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
1690
+
1691
+ if (metaErrors.length > 0 && enableRepair) {
1692
+ response = repairEnvelope(response as unknown as Record<string, unknown>, riskRule);
1693
+ response.version = ENVELOPE_VERSION;
1694
+ metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
1695
+
1696
+ if (metaErrors.length > 0) {
1697
+ const errorResult = makeErrorResponse({
1698
+ code: 'META_VALIDATION_FAILED',
1699
+ message: metaErrors.join('; '),
1700
+ explain: 'Meta validation failed after repair attempt.',
1701
+ partialData: (response as EnvelopeResponseV22<unknown> & { data?: unknown }).data,
1702
+ details: { validation_errors: metaErrors },
1703
+ });
1704
+ const errorObj = (errorResult as { error: { code: string; message: string } }).error;
1705
+ yield makeEvent('error', { error: errorObj });
1706
+ yield makeEvent('complete', { result: errorResult });
1707
+ return;
1708
+ }
1709
+ }
1710
+ }
1711
+ } else if (!response.ok && enableRepair) {
1712
+ response = repairErrorEnvelope(response as unknown as Record<string, unknown>);
1713
+ response.version = ENVELOPE_VERSION;
1714
+ }
1715
+
1716
+ // Emit complete event
1717
+ yield makeEvent('complete', { result: response });
1718
+
1719
+ } catch (e) {
1720
+ const errorResult = makeErrorResponse({
1721
+ code: 'UNKNOWN',
1722
+ message: (e as Error).message,
1723
+ explain: `Unexpected error: ${(e as Error).name}`,
1724
+ });
1725
+ // errorResult is always an error response from makeErrorResponse
1726
+ const errorObj = (errorResult as { error: { code: string; message: string } }).error;
1727
+ yield makeEvent('error', { error: errorObj });
1728
+ yield makeEvent('complete', { result: errorResult });
343
1729
  }
344
-
345
- return legacyResult;
346
1730
  }
347
1731
 
1732
+ // =============================================================================
1733
+ // Helper Functions
1734
+ // =============================================================================
1735
+
348
1736
  /**
349
1737
  * Check if response is in envelope format
350
1738
  */
@@ -442,6 +1830,12 @@ function parseLegacyResponse(output: unknown, raw: string): ModuleResult {
442
1830
 
443
1831
  /**
444
1832
  * Build prompt with clean variable substitution
1833
+ *
1834
+ * Substitution order (important to avoid partial replacements):
1835
+ * 1. ${variable} - v2 style placeholders
1836
+ * 2. $ARGUMENTS[N] - indexed access (descending order to avoid $1 matching $10)
1837
+ * 3. $N - shorthand indexed access (descending order)
1838
+ * 4. $ARGUMENTS - full argument string (LAST to avoid partial matches)
445
1839
  */
446
1840
  function buildPrompt(module: CognitiveModule, input: ModuleInput): string {
447
1841
  let prompt = module.prompt;
@@ -452,18 +1846,25 @@ function buildPrompt(module: CognitiveModule, input: ModuleInput): string {
452
1846
  prompt = prompt.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), strValue);
453
1847
  }
454
1848
 
455
- // v1 compatibility: substitute $ARGUMENTS
1849
+ // v1 compatibility: get args value
456
1850
  const argsValue = input.code || input.query || '';
457
- prompt = prompt.replace(/\$ARGUMENTS/g, argsValue);
458
1851
 
459
- // Substitute $N placeholders (v1 compatibility)
1852
+ // Substitute $ARGUMENTS[N] and $N placeholders FIRST (v1 compatibility)
1853
+ // Process in descending order to avoid $1 replacing part of $10
460
1854
  if (typeof argsValue === 'string') {
461
1855
  const argsList = argsValue.split(/\s+/);
462
- argsList.forEach((arg, i) => {
1856
+ for (let i = argsList.length - 1; i >= 0; i--) {
1857
+ const arg = argsList[i];
1858
+ // Replace $ARGUMENTS[N] first
1859
+ prompt = prompt.replace(new RegExp(`\\$ARGUMENTS\\[${i}\\]`, 'g'), arg);
1860
+ // Replace $N shorthand
463
1861
  prompt = prompt.replace(new RegExp(`\\$${i}\\b`, 'g'), arg);
464
- });
1862
+ }
465
1863
  }
466
1864
 
1865
+ // Replace $ARGUMENTS LAST (after indexed forms to avoid partial matches)
1866
+ prompt = prompt.replace(/\$ARGUMENTS/g, argsValue);
1867
+
467
1868
  // Append input summary if not already in prompt
468
1869
  if (!prompt.includes(argsValue) && argsValue) {
469
1870
  prompt += '\n\n## Input\n\n';
@@ -493,3 +1894,90 @@ function looksLikeCode(str: string): boolean {
493
1894
  ];
494
1895
  return codeIndicators.some(re => re.test(str));
495
1896
  }
1897
+
1898
+ // =============================================================================
1899
+ // Legacy API (for backward compatibility)
1900
+ // =============================================================================
1901
+
1902
+ export interface RunModuleLegacyOptions {
1903
+ validateInput?: boolean;
1904
+ validateOutput?: boolean;
1905
+ model?: string;
1906
+ }
1907
+
1908
+ /**
1909
+ * Run a cognitive module (legacy API, returns raw output).
1910
+ * For backward compatibility. Throws on error instead of returning error envelope.
1911
+ */
1912
+ export async function runModuleLegacy(
1913
+ module: CognitiveModule,
1914
+ provider: Provider,
1915
+ input: ModuleInput,
1916
+ options: RunModuleLegacyOptions = {}
1917
+ ): Promise<unknown> {
1918
+ const { validateInput = true, validateOutput = true, model } = options;
1919
+
1920
+ const result = await runModule(module, provider, {
1921
+ input,
1922
+ validateInput,
1923
+ validateOutput,
1924
+ useEnvelope: false,
1925
+ useV22: false,
1926
+ model,
1927
+ });
1928
+
1929
+ if (result.ok && 'data' in result) {
1930
+ return result.data;
1931
+ } else {
1932
+ const error = 'error' in result ? result.error : { code: 'UNKNOWN', message: 'Unknown error' };
1933
+ throw new Error(`${error?.code ?? 'UNKNOWN'}: ${error?.message ?? 'Unknown error'}`);
1934
+ }
1935
+ }
1936
+
1937
+ // =============================================================================
1938
+ // Convenience Functions
1939
+ // =============================================================================
1940
+
1941
+ /**
1942
+ * Extract meta from v2.2 envelope for routing/logging.
1943
+ */
1944
+ export function extractMeta(result: EnvelopeResponseV22<unknown>): EnvelopeMeta {
1945
+ return result.meta ?? {
1946
+ confidence: 0.5,
1947
+ risk: 'medium',
1948
+ explain: 'No meta available',
1949
+ };
1950
+ }
1951
+
1952
+ // Alias for backward compatibility
1953
+ export const extractMetaV22 = extractMeta;
1954
+
1955
+ /**
1956
+ * Determine if result should be escalated to human review based on meta.
1957
+ */
1958
+ export function shouldEscalate(
1959
+ result: EnvelopeResponseV22<unknown>,
1960
+ confidenceThreshold: number = 0.7
1961
+ ): boolean {
1962
+ const meta = extractMeta(result);
1963
+
1964
+ // Escalate if low confidence
1965
+ if (meta.confidence < confidenceThreshold) {
1966
+ return true;
1967
+ }
1968
+
1969
+ // Escalate if high risk
1970
+ if (meta.risk === 'high') {
1971
+ return true;
1972
+ }
1973
+
1974
+ // Escalate if error
1975
+ if (!result.ok) {
1976
+ return true;
1977
+ }
1978
+
1979
+ return false;
1980
+ }
1981
+
1982
+ // Alias for backward compatibility
1983
+ export const shouldEscalateV22 = shouldEscalate;