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,24 +1,712 @@
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
  */
6
+ import _Ajv from 'ajv';
7
+ const Ajv = _Ajv.default || _Ajv;
5
8
  import { aggregateRisk, isV22Envelope } from '../types.js';
6
9
  // =============================================================================
10
+ // Schema Validation
11
+ // =============================================================================
12
+ const ajv = new Ajv({ allErrors: true, strict: false });
13
+ /**
14
+ * Validate data against JSON schema. Returns list of errors.
15
+ */
16
+ export function validateData(data, schema, label = 'Data') {
17
+ const errors = [];
18
+ if (!schema || Object.keys(schema).length === 0) {
19
+ return errors;
20
+ }
21
+ try {
22
+ const validate = ajv.compile(schema);
23
+ const valid = validate(data);
24
+ if (!valid && validate.errors) {
25
+ for (const err of validate.errors) {
26
+ const path = err.instancePath || '/';
27
+ errors.push(`${label} validation error: ${err.message} at ${path}`);
28
+ }
29
+ }
30
+ }
31
+ catch (e) {
32
+ errors.push(`Schema error: ${e.message}`);
33
+ }
34
+ return errors;
35
+ }
36
+ /** Tool categories for automatic policy mapping */
37
+ const TOOL_POLICY_MAPPING = {
38
+ // Network tools
39
+ 'fetch': ['network'],
40
+ 'http': ['network'],
41
+ 'request': ['network'],
42
+ 'curl': ['network'],
43
+ 'wget': ['network'],
44
+ 'api_call': ['network'],
45
+ // Filesystem tools
46
+ 'write_file': ['filesystem_write', 'side_effects'],
47
+ 'create_file': ['filesystem_write', 'side_effects'],
48
+ 'delete_file': ['filesystem_write', 'side_effects'],
49
+ 'rename_file': ['filesystem_write', 'side_effects'],
50
+ 'mkdir': ['filesystem_write', 'side_effects'],
51
+ 'rmdir': ['filesystem_write', 'side_effects'],
52
+ // Code execution tools
53
+ 'shell': ['code_execution', 'side_effects'],
54
+ 'exec': ['code_execution', 'side_effects'],
55
+ 'run_code': ['code_execution', 'side_effects'],
56
+ 'code_interpreter': ['code_execution', 'side_effects'],
57
+ 'eval': ['code_execution', 'side_effects'],
58
+ // Database tools
59
+ 'sql_query': ['side_effects'],
60
+ 'db_write': ['side_effects'],
61
+ };
62
+ /**
63
+ * Check if a tool is allowed by the module's tools policy.
64
+ *
65
+ * @param toolName The name of the tool to check
66
+ * @param module The cognitive module config
67
+ * @returns PolicyCheckResult indicating if the tool is allowed
68
+ *
69
+ * @example
70
+ * const result = checkToolPolicy('write_file', module);
71
+ * if (!result.allowed) {
72
+ * throw new Error(result.reason);
73
+ * }
74
+ */
75
+ export function checkToolPolicy(toolName, module) {
76
+ const toolsPolicy = module.tools;
77
+ // No policy = allow all
78
+ if (!toolsPolicy) {
79
+ return { allowed: true };
80
+ }
81
+ const normalizedName = toolName.toLowerCase().replace(/[-\s]/g, '_');
82
+ // Check explicit denied list first
83
+ if (toolsPolicy.denied?.some(d => d.toLowerCase().replace(/[-\s]/g, '_') === normalizedName)) {
84
+ return {
85
+ allowed: false,
86
+ reason: `Tool '${toolName}' is explicitly denied by module tools policy`,
87
+ policy: 'tools.denied'
88
+ };
89
+ }
90
+ // Check policy mode
91
+ if (toolsPolicy.policy === 'deny_by_default') {
92
+ // In deny_by_default mode, tool must be in allowed list
93
+ const isAllowed = toolsPolicy.allowed?.some(a => a.toLowerCase().replace(/[-\s]/g, '_') === normalizedName);
94
+ if (!isAllowed) {
95
+ return {
96
+ allowed: false,
97
+ reason: `Tool '${toolName}' not in allowed list (policy: deny_by_default)`,
98
+ policy: 'tools.policy'
99
+ };
100
+ }
101
+ }
102
+ return { allowed: true };
103
+ }
104
+ /**
105
+ * Check if an action is allowed by the module's policies.
106
+ *
107
+ * @param action The action to check (network, filesystem_write, etc.)
108
+ * @param module The cognitive module config
109
+ * @returns PolicyCheckResult indicating if the action is allowed
110
+ *
111
+ * @example
112
+ * const result = checkPolicy('network', module);
113
+ * if (!result.allowed) {
114
+ * throw new Error(result.reason);
115
+ * }
116
+ */
117
+ export function checkPolicy(action, module) {
118
+ const policies = module.policies;
119
+ // No policies = allow all
120
+ if (!policies) {
121
+ return { allowed: true };
122
+ }
123
+ // Check the specific policy
124
+ if (policies[action] === 'deny') {
125
+ return {
126
+ allowed: false,
127
+ reason: `Action '${action}' is denied by module policy`,
128
+ policy: `policies.${action}`
129
+ };
130
+ }
131
+ return { allowed: true };
132
+ }
133
+ /**
134
+ * Check if a tool is allowed considering both tools policy and general policies.
135
+ * This performs a comprehensive check that:
136
+ * 1. Checks the tools policy (allowed/denied lists)
137
+ * 2. Maps the tool to policy actions and checks those
138
+ *
139
+ * @param toolName The name of the tool to check
140
+ * @param module The cognitive module config
141
+ * @returns PolicyCheckResult with detailed information
142
+ *
143
+ * @example
144
+ * const result = checkToolAllowed('write_file', module);
145
+ * if (!result.allowed) {
146
+ * return makeErrorResponse({
147
+ * code: 'POLICY_VIOLATION',
148
+ * message: result.reason,
149
+ * });
150
+ * }
151
+ */
152
+ export function checkToolAllowed(toolName, module) {
153
+ // First check explicit tools policy
154
+ const toolCheck = checkToolPolicy(toolName, module);
155
+ if (!toolCheck.allowed) {
156
+ return toolCheck;
157
+ }
158
+ // Then check mapped policies
159
+ const normalizedName = toolName.toLowerCase().replace(/[-\s]/g, '_');
160
+ const mappedActions = TOOL_POLICY_MAPPING[normalizedName] || [];
161
+ for (const action of mappedActions) {
162
+ const policyCheck = checkPolicy(action, module);
163
+ if (!policyCheck.allowed) {
164
+ return {
165
+ allowed: false,
166
+ reason: `Tool '${toolName}' requires '${action}' which is denied by policy`,
167
+ policy: policyCheck.policy
168
+ };
169
+ }
170
+ }
171
+ return { allowed: true };
172
+ }
173
+ /**
174
+ * Validate that a list of tools are all allowed by the module's policies.
175
+ * Returns all violations found.
176
+ *
177
+ * @param toolNames List of tool names to check
178
+ * @param module The cognitive module config
179
+ * @returns Array of PolicyCheckResult for denied tools
180
+ */
181
+ export function validateToolsAllowed(toolNames, module) {
182
+ const violations = [];
183
+ for (const toolName of toolNames) {
184
+ const result = checkToolAllowed(toolName, module);
185
+ if (!result.allowed) {
186
+ violations.push(result);
187
+ }
188
+ }
189
+ return violations;
190
+ }
191
+ /**
192
+ * Get all denied actions for a module based on its policies.
193
+ * Useful for informing LLM about restrictions.
194
+ */
195
+ export function getDeniedActions(module) {
196
+ const denied = [];
197
+ const policies = module.policies;
198
+ if (!policies)
199
+ return denied;
200
+ const actions = ['network', 'filesystem_write', 'side_effects', 'code_execution'];
201
+ for (const action of actions) {
202
+ if (policies[action] === 'deny') {
203
+ denied.push(action);
204
+ }
205
+ }
206
+ return denied;
207
+ }
208
+ /**
209
+ * Get all denied tools for a module based on its tools policy.
210
+ */
211
+ export function getDeniedTools(module) {
212
+ return module.tools?.denied || [];
213
+ }
214
+ /**
215
+ * Get all allowed tools for a module (only meaningful in deny_by_default mode).
216
+ */
217
+ export function getAllowedTools(module) {
218
+ if (module.tools?.policy === 'deny_by_default') {
219
+ return module.tools.allowed || [];
220
+ }
221
+ return null; // null means "all allowed except denied list"
222
+ }
223
+ /**
224
+ * ToolCallInterceptor - Intercepts and validates tool calls against module policies.
225
+ *
226
+ * Use this class to wrap tool execution with policy enforcement:
227
+ *
228
+ * @example
229
+ * const interceptor = new ToolCallInterceptor(module);
230
+ *
231
+ * // Register tool executors
232
+ * interceptor.registerTool('read_file', async (args) => {
233
+ * return fs.readFile(args.path as string, 'utf-8');
234
+ * });
235
+ *
236
+ * // Execute tool with policy check
237
+ * const result = await interceptor.execute({
238
+ * name: 'write_file',
239
+ * arguments: { path: '/tmp/test.txt', content: 'hello' }
240
+ * });
241
+ *
242
+ * if (!result.success) {
243
+ * console.error('Tool blocked:', result.error);
244
+ * }
245
+ */
246
+ export class ToolCallInterceptor {
247
+ module;
248
+ tools = new Map();
249
+ callLog = [];
250
+ constructor(module) {
251
+ this.module = module;
252
+ }
253
+ /**
254
+ * Register a tool executor.
255
+ */
256
+ registerTool(name, executor) {
257
+ this.tools.set(name.toLowerCase(), executor);
258
+ }
259
+ /**
260
+ * Register multiple tools at once.
261
+ */
262
+ registerTools(tools) {
263
+ for (const [name, executor] of Object.entries(tools)) {
264
+ this.registerTool(name, executor);
265
+ }
266
+ }
267
+ /**
268
+ * Check if a tool call is allowed without executing it.
269
+ */
270
+ checkAllowed(toolName) {
271
+ return checkToolAllowed(toolName, this.module);
272
+ }
273
+ /**
274
+ * Execute a tool call with policy enforcement.
275
+ *
276
+ * @param request The tool call request
277
+ * @returns ToolCallResult with success/error
278
+ */
279
+ async execute(request) {
280
+ const { name, arguments: args } = request;
281
+ const timestamp = Date.now();
282
+ // Check policy
283
+ const policyResult = checkToolAllowed(name, this.module);
284
+ if (!policyResult.allowed) {
285
+ this.callLog.push({ tool: name, allowed: false, timestamp });
286
+ return {
287
+ success: false,
288
+ error: {
289
+ code: 'TOOL_NOT_ALLOWED',
290
+ message: policyResult.reason || `Tool '${name}' is not allowed`,
291
+ },
292
+ };
293
+ }
294
+ // Find executor
295
+ const executor = this.tools.get(name.toLowerCase());
296
+ if (!executor) {
297
+ return {
298
+ success: false,
299
+ error: {
300
+ code: 'TOOL_NOT_FOUND',
301
+ message: `Tool '${name}' is not registered`,
302
+ },
303
+ };
304
+ }
305
+ // Execute
306
+ try {
307
+ this.callLog.push({ tool: name, allowed: true, timestamp });
308
+ const result = await executor(args);
309
+ return { success: true, result };
310
+ }
311
+ catch (e) {
312
+ return {
313
+ success: false,
314
+ error: {
315
+ code: 'TOOL_EXECUTION_ERROR',
316
+ message: e.message,
317
+ },
318
+ };
319
+ }
320
+ }
321
+ /**
322
+ * Execute multiple tool calls in sequence.
323
+ * Stops on first policy violation.
324
+ */
325
+ async executeMany(requests) {
326
+ const results = [];
327
+ for (const request of requests) {
328
+ const result = await this.execute(request);
329
+ results.push(result);
330
+ // Stop on policy violation (not execution error)
331
+ if (!result.success && result.error?.code === 'TOOL_NOT_ALLOWED') {
332
+ break;
333
+ }
334
+ }
335
+ return results;
336
+ }
337
+ /**
338
+ * Get the call log for auditing.
339
+ */
340
+ getCallLog() {
341
+ return [...this.callLog];
342
+ }
343
+ /**
344
+ * Get summary of denied calls.
345
+ */
346
+ getDeniedCalls() {
347
+ return this.callLog
348
+ .filter(c => !c.allowed)
349
+ .map(({ tool, timestamp }) => ({ tool, timestamp }));
350
+ }
351
+ /**
352
+ * Clear the call log.
353
+ */
354
+ clearLog() {
355
+ this.callLog = [];
356
+ }
357
+ /**
358
+ * Get policy summary for this module.
359
+ */
360
+ getPolicySummary() {
361
+ return {
362
+ deniedActions: getDeniedActions(this.module),
363
+ deniedTools: getDeniedTools(this.module),
364
+ allowedTools: getAllowedTools(this.module),
365
+ toolsPolicy: this.module.tools?.policy,
366
+ };
367
+ }
368
+ }
369
+ /**
370
+ * Create a policy-aware tool executor wrapper.
371
+ *
372
+ * @example
373
+ * const safeExecutor = createPolicyAwareExecutor(module, 'write_file', async (args) => {
374
+ * return fs.writeFile(args.path, args.content);
375
+ * });
376
+ *
377
+ * // This will throw if write_file is denied
378
+ * await safeExecutor({ path: '/tmp/test.txt', content: 'hello' });
379
+ */
380
+ export function createPolicyAwareExecutor(module, toolName, executor) {
381
+ return async (args) => {
382
+ const policyResult = checkToolAllowed(toolName, module);
383
+ if (!policyResult.allowed) {
384
+ throw new Error(`Policy violation: ${policyResult.reason}`);
385
+ }
386
+ return executor(args);
387
+ };
388
+ }
389
+ // =============================================================================
390
+ // v2.2 Runtime Enforcement - Overflow & Enum
391
+ // =============================================================================
392
+ /**
393
+ * Validate overflow.insights against module's max_items config.
394
+ *
395
+ * @param data The response data object
396
+ * @param module The cognitive module config
397
+ * @returns Array of errors if insights exceed limit
398
+ */
399
+ export function validateOverflowLimits(data, module) {
400
+ const errors = [];
401
+ const overflowConfig = module.overflow;
402
+ if (!overflowConfig?.enabled) {
403
+ // If overflow disabled, insights should not exist
404
+ const extensions = data.extensions;
405
+ if (extensions?.insights && Array.isArray(extensions.insights) && extensions.insights.length > 0) {
406
+ errors.push('Overflow is disabled but extensions.insights contains data');
407
+ }
408
+ return errors;
409
+ }
410
+ const maxItems = overflowConfig.max_items ?? 5;
411
+ const extensions = data.extensions;
412
+ if (extensions?.insights && Array.isArray(extensions.insights)) {
413
+ const insights = extensions.insights;
414
+ if (insights.length > maxItems) {
415
+ errors.push(`overflow.max_items exceeded: ${insights.length} > ${maxItems}`);
416
+ }
417
+ // Check require_suggested_mapping
418
+ if (overflowConfig.require_suggested_mapping) {
419
+ for (let i = 0; i < insights.length; i++) {
420
+ const insight = insights[i];
421
+ if (!insight.suggested_mapping) {
422
+ errors.push(`insight[${i}] missing required suggested_mapping`);
423
+ }
424
+ }
425
+ }
426
+ }
427
+ return errors;
428
+ }
429
+ /**
430
+ * Validate enum values against module's enum strategy.
431
+ * For strict mode, custom enum objects are not allowed.
432
+ *
433
+ * @param data The response data object
434
+ * @param module The cognitive module config
435
+ * @returns Array of errors if enum violations found
436
+ */
437
+ export function validateEnumStrategy(data, module) {
438
+ const errors = [];
439
+ const enumStrategy = module.enums?.strategy ?? 'strict';
440
+ if (enumStrategy === 'strict') {
441
+ // In strict mode, custom enum objects (with 'custom' key) are not allowed
442
+ const checkForCustomEnums = (obj, path) => {
443
+ if (obj === null || obj === undefined)
444
+ return;
445
+ if (Array.isArray(obj)) {
446
+ obj.forEach((item, i) => checkForCustomEnums(item, `${path}[${i}]`));
447
+ }
448
+ else if (typeof obj === 'object') {
449
+ const record = obj;
450
+ // Check if this is a custom enum object
451
+ if ('custom' in record && 'reason' in record && Object.keys(record).length === 2) {
452
+ errors.push(`Custom enum not allowed in strict mode at ${path}: { custom: "${record.custom}" }`);
453
+ return;
454
+ }
455
+ // Recurse into nested objects
456
+ for (const [key, value] of Object.entries(record)) {
457
+ checkForCustomEnums(value, `${path}.${key}`);
458
+ }
459
+ }
460
+ };
461
+ checkForCustomEnums(data, 'data');
462
+ }
463
+ return errors;
464
+ }
465
+ // =============================================================================
466
+ // Constants
467
+ // =============================================================================
468
+ const ENVELOPE_VERSION = '2.2';
469
+ // =============================================================================
470
+ // Utility Functions
471
+ // =============================================================================
472
+ /**
473
+ * Deep clone an object to avoid mutation issues.
474
+ * Handles nested objects, arrays, and primitive values.
475
+ */
476
+ function deepClone(obj) {
477
+ if (obj === null || typeof obj !== 'object') {
478
+ return obj;
479
+ }
480
+ if (Array.isArray(obj)) {
481
+ return obj.map(item => deepClone(item));
482
+ }
483
+ const cloned = {};
484
+ for (const key in obj) {
485
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
486
+ cloned[key] = deepClone(obj[key]);
487
+ }
488
+ }
489
+ return cloned;
490
+ }
491
+ // Global hook registries
492
+ const _beforeCallHooks = [];
493
+ const _afterCallHooks = [];
494
+ const _errorHooks = [];
495
+ /**
496
+ * Decorator to register a before-call hook.
497
+ *
498
+ * @example
499
+ * onBeforeCall((moduleName, inputData, config) => {
500
+ * console.log(`Calling ${moduleName} with`, inputData);
501
+ * });
502
+ */
503
+ export function onBeforeCall(hook) {
504
+ _beforeCallHooks.push(hook);
505
+ return hook;
506
+ }
507
+ /**
508
+ * Decorator to register an after-call hook.
509
+ *
510
+ * @example
511
+ * onAfterCall((moduleName, result, latencyMs) => {
512
+ * console.log(`${moduleName} completed in ${latencyMs}ms`);
513
+ * });
514
+ */
515
+ export function onAfterCall(hook) {
516
+ _afterCallHooks.push(hook);
517
+ return hook;
518
+ }
519
+ /**
520
+ * Decorator to register an error hook.
521
+ *
522
+ * @example
523
+ * onError((moduleName, error, partialResult) => {
524
+ * console.error(`Error in ${moduleName}:`, error);
525
+ * });
526
+ */
527
+ export function onError(hook) {
528
+ _errorHooks.push(hook);
529
+ return hook;
530
+ }
531
+ /**
532
+ * Register a hook programmatically.
533
+ */
534
+ export function registerHook(hookType, hook) {
535
+ if (hookType === 'before_call') {
536
+ _beforeCallHooks.push(hook);
537
+ }
538
+ else if (hookType === 'after_call') {
539
+ _afterCallHooks.push(hook);
540
+ }
541
+ else if (hookType === 'error') {
542
+ _errorHooks.push(hook);
543
+ }
544
+ else {
545
+ throw new Error(`Unknown hook type: ${hookType}`);
546
+ }
547
+ }
548
+ /**
549
+ * Unregister a hook. Returns true if found and removed.
550
+ */
551
+ export function unregisterHook(hookType, hook) {
552
+ let hooks;
553
+ if (hookType === 'before_call') {
554
+ hooks = _beforeCallHooks;
555
+ }
556
+ else if (hookType === 'after_call') {
557
+ hooks = _afterCallHooks;
558
+ }
559
+ else if (hookType === 'error') {
560
+ hooks = _errorHooks;
561
+ }
562
+ else {
563
+ return false;
564
+ }
565
+ const index = hooks.indexOf(hook);
566
+ if (index !== -1) {
567
+ hooks.splice(index, 1);
568
+ return true;
569
+ }
570
+ return false;
571
+ }
572
+ /**
573
+ * Clear all registered hooks.
574
+ */
575
+ export function clearHooks() {
576
+ _beforeCallHooks.length = 0;
577
+ _afterCallHooks.length = 0;
578
+ _errorHooks.length = 0;
579
+ }
580
+ function _invokeBeforeHooks(moduleName, inputData, moduleConfig) {
581
+ for (const hook of _beforeCallHooks) {
582
+ try {
583
+ hook(moduleName, inputData, moduleConfig);
584
+ }
585
+ catch {
586
+ // Hooks should not break the main flow
587
+ }
588
+ }
589
+ }
590
+ function _invokeAfterHooks(moduleName, result, latencyMs) {
591
+ for (const hook of _afterCallHooks) {
592
+ try {
593
+ hook(moduleName, result, latencyMs);
594
+ }
595
+ catch {
596
+ // Hooks should not break the main flow
597
+ }
598
+ }
599
+ }
600
+ function _invokeErrorHooks(moduleName, error, partialResult) {
601
+ for (const hook of _errorHooks) {
602
+ try {
603
+ hook(moduleName, error, partialResult);
604
+ }
605
+ catch {
606
+ // Hooks should not break the main flow
607
+ }
608
+ }
609
+ }
610
+ // =============================================================================
611
+ // Error Response Builder
612
+ // =============================================================================
613
+ /** Error codes and their default properties */
614
+ export const ERROR_PROPERTIES = {
615
+ MODULE_NOT_FOUND: { recoverable: false, retry_after_ms: null },
616
+ INVALID_INPUT: { recoverable: false, retry_after_ms: null },
617
+ PARSE_ERROR: { recoverable: true, retry_after_ms: 1000 },
618
+ SCHEMA_VALIDATION_FAILED: { recoverable: true, retry_after_ms: 1000 },
619
+ META_VALIDATION_FAILED: { recoverable: true, retry_after_ms: 1000 },
620
+ POLICY_VIOLATION: { recoverable: false, retry_after_ms: null },
621
+ TOOL_NOT_ALLOWED: { recoverable: false, retry_after_ms: null },
622
+ LLM_ERROR: { recoverable: true, retry_after_ms: 5000 },
623
+ RATE_LIMITED: { recoverable: true, retry_after_ms: 10000 },
624
+ TIMEOUT: { recoverable: true, retry_after_ms: 5000 },
625
+ UNKNOWN: { recoverable: false, retry_after_ms: null },
626
+ };
627
+ /**
628
+ * Build a standardized error response with enhanced taxonomy.
629
+ */
630
+ export function makeErrorResponse(options) {
631
+ const { code, message, explain, partialData, details, recoverable, retryAfterMs, confidence = 0.0, risk = 'high', } = options;
632
+ // Get default properties from error code
633
+ const defaults = ERROR_PROPERTIES[code] || ERROR_PROPERTIES.UNKNOWN;
634
+ const errorObj = {
635
+ code,
636
+ message,
637
+ };
638
+ // Add recoverable flag
639
+ const isRecoverable = recoverable ?? defaults.recoverable;
640
+ if (isRecoverable !== undefined) {
641
+ errorObj.recoverable = isRecoverable;
642
+ }
643
+ // Add retry suggestion
644
+ const retryMs = retryAfterMs ?? defaults.retry_after_ms;
645
+ if (retryMs !== null) {
646
+ errorObj.retry_after_ms = retryMs;
647
+ }
648
+ // Add details if provided
649
+ if (details) {
650
+ errorObj.details = details;
651
+ }
652
+ return {
653
+ ok: false,
654
+ version: ENVELOPE_VERSION,
655
+ meta: {
656
+ confidence,
657
+ risk,
658
+ explain: (explain || message).slice(0, 280),
659
+ },
660
+ error: errorObj,
661
+ partial_data: partialData,
662
+ };
663
+ }
664
+ /**
665
+ * Build a standardized success response.
666
+ */
667
+ export function makeSuccessResponse(options) {
668
+ const { data, confidence, risk, explain, latencyMs, model, traceId } = options;
669
+ const meta = {
670
+ confidence: Math.max(0.0, Math.min(1.0, confidence)),
671
+ risk,
672
+ explain: explain ? explain.slice(0, 280) : 'No explanation provided',
673
+ };
674
+ if (latencyMs !== undefined) {
675
+ meta.latency_ms = latencyMs;
676
+ }
677
+ if (model) {
678
+ meta.model = model;
679
+ }
680
+ if (traceId) {
681
+ meta.trace_id = traceId;
682
+ }
683
+ return {
684
+ ok: true,
685
+ version: ENVELOPE_VERSION,
686
+ meta,
687
+ data,
688
+ };
689
+ }
690
+ // =============================================================================
7
691
  // Repair Pass (v2.2)
8
692
  // =============================================================================
9
693
  /**
10
694
  * Attempt to repair envelope format issues without changing semantics.
11
695
  *
12
- * Repairs (lossless only):
696
+ * Repairs (mostly lossless, except explain truncation):
13
697
  * - Missing meta fields (fill with conservative defaults)
14
698
  * - Truncate explain if too long
15
699
  * - Trim whitespace from string fields
700
+ * - Clamp confidence to [0, 1] range
16
701
  *
17
702
  * Does NOT repair:
18
703
  * - Invalid enum values (treated as validation failure)
704
+ *
705
+ * Note: Returns a deep copy to avoid modifying the original data.
19
706
  */
20
707
  function repairEnvelope(response, riskRule = 'max_changes_risk', maxExplainLength = 280) {
21
- const repaired = { ...response };
708
+ // Deep clone to avoid mutation
709
+ const repaired = deepClone(response);
22
710
  // Ensure meta exists
23
711
  if (!repaired.meta || typeof repaired.meta !== 'object') {
24
712
  repaired.meta = {};
@@ -54,7 +742,7 @@ function repairEnvelope(response, riskRule = 'max_changes_risk', maxExplainLengt
54
742
  if (meta.explain.length > maxExplainLength) {
55
743
  meta.explain = meta.explain.slice(0, maxExplainLength - 3) + '...';
56
744
  }
57
- // Build proper v2.2 response
745
+ // Build proper v2.2 response with version
58
746
  const builtMeta = {
59
747
  confidence: meta.confidence,
60
748
  risk: meta.risk,
@@ -62,21 +750,63 @@ function repairEnvelope(response, riskRule = 'max_changes_risk', maxExplainLengt
62
750
  };
63
751
  const result = repaired.ok === false ? {
64
752
  ok: false,
753
+ version: ENVELOPE_VERSION,
65
754
  meta: builtMeta,
66
755
  error: repaired.error ?? { code: 'UNKNOWN', message: 'Unknown error' },
67
756
  partial_data: repaired.partial_data
68
757
  } : {
69
758
  ok: true,
759
+ version: ENVELOPE_VERSION,
70
760
  meta: builtMeta,
71
761
  data: repaired.data
72
762
  };
73
763
  return result;
74
764
  }
765
+ /**
766
+ * Repair error envelope format.
767
+ *
768
+ * Note: Returns a deep copy to avoid modifying the original data.
769
+ */
770
+ function repairErrorEnvelope(data, maxExplainLength = 280) {
771
+ // Deep clone to avoid mutation
772
+ const repaired = deepClone(data);
773
+ // Ensure meta exists for errors
774
+ if (!repaired.meta || typeof repaired.meta !== 'object') {
775
+ repaired.meta = {};
776
+ }
777
+ const meta = repaired.meta;
778
+ // Set default meta for errors
779
+ if (typeof meta.confidence !== 'number') {
780
+ meta.confidence = 0.0;
781
+ }
782
+ if (!meta.risk) {
783
+ meta.risk = 'high';
784
+ }
785
+ if (typeof meta.explain !== 'string') {
786
+ const error = (repaired.error ?? {});
787
+ meta.explain = (error.message ?? 'An error occurred').slice(0, maxExplainLength);
788
+ }
789
+ return {
790
+ ok: false,
791
+ version: ENVELOPE_VERSION,
792
+ meta: {
793
+ confidence: meta.confidence,
794
+ risk: meta.risk,
795
+ explain: meta.explain,
796
+ },
797
+ error: repaired.error ?? { code: 'UNKNOWN', message: 'Unknown error' },
798
+ partial_data: repaired.partial_data,
799
+ };
800
+ }
75
801
  /**
76
802
  * Wrap v2.1 response to v2.2 format
77
803
  */
78
804
  function wrapV21ToV22(response, riskRule = 'max_changes_risk') {
79
805
  if (isV22Envelope(response)) {
806
+ // Already v2.2, but ensure version field exists
807
+ if (!('version' in response) || !response.version) {
808
+ return { ...deepClone(response), version: ENVELOPE_VERSION };
809
+ }
80
810
  return response;
81
811
  }
82
812
  if (response.ok) {
@@ -85,6 +815,7 @@ function wrapV21ToV22(response, riskRule = 'max_changes_risk') {
85
815
  const rationale = data.rationale ?? '';
86
816
  return {
87
817
  ok: true,
818
+ version: ENVELOPE_VERSION,
88
819
  meta: {
89
820
  confidence,
90
821
  risk: aggregateRisk(data, riskRule),
@@ -97,6 +828,7 @@ function wrapV21ToV22(response, riskRule = 'max_changes_risk') {
97
828
  const errorMsg = response.error?.message ?? 'Unknown error';
98
829
  return {
99
830
  ok: false,
831
+ version: ENVELOPE_VERSION,
100
832
  meta: {
101
833
  confidence: 0,
102
834
  risk: 'high',
@@ -107,8 +839,51 @@ function wrapV21ToV22(response, riskRule = 'max_changes_risk') {
107
839
  };
108
840
  }
109
841
  }
842
+ /**
843
+ * Convert legacy format (no envelope) to v2.2 envelope.
844
+ */
845
+ function convertLegacyToEnvelope(data, isError = false) {
846
+ if (isError || 'error' in data) {
847
+ const error = (data.error ?? {});
848
+ const errorMsg = typeof error === 'object'
849
+ ? (error.message ?? String(error))
850
+ : String(error);
851
+ return {
852
+ ok: false,
853
+ version: ENVELOPE_VERSION,
854
+ meta: {
855
+ confidence: 0.0,
856
+ risk: 'high',
857
+ explain: errorMsg.slice(0, 280),
858
+ },
859
+ error: {
860
+ code: (typeof error === 'object' ? error.code : undefined) ?? 'UNKNOWN',
861
+ message: errorMsg,
862
+ },
863
+ partial_data: undefined,
864
+ };
865
+ }
866
+ else {
867
+ const confidence = data.confidence ?? 0.5;
868
+ const rationale = data.rationale ?? '';
869
+ return {
870
+ ok: true,
871
+ version: ENVELOPE_VERSION,
872
+ meta: {
873
+ confidence,
874
+ risk: aggregateRisk(data),
875
+ explain: rationale.slice(0, 280) || 'No explanation provided',
876
+ },
877
+ data,
878
+ };
879
+ }
880
+ }
881
+ // =============================================================================
882
+ // Main Runner
883
+ // =============================================================================
110
884
  export async function runModule(module, provider, options = {}) {
111
- const { args, input, verbose = false, useEnvelope, useV22, enableRepair = true } = options;
885
+ const { args, input, verbose = false, validateInput = true, validateOutput = true, useEnvelope, useV22, enableRepair = true, traceId, model: modelOverride } = options;
886
+ const startTime = Date.now();
112
887
  // Determine if we should use envelope format
113
888
  const shouldUseEnvelope = useEnvelope ?? (module.output?.envelope === true || module.format === 'v2');
114
889
  // Determine if we should use v2.2 format
@@ -128,6 +903,24 @@ export async function runModule(module, provider, options = {}) {
128
903
  inputData.query = args;
129
904
  }
130
905
  }
906
+ // Invoke before hooks
907
+ _invokeBeforeHooks(module.name, inputData, module);
908
+ // Validate input against schema
909
+ if (validateInput && module.inputSchema && Object.keys(module.inputSchema).length > 0) {
910
+ const inputErrors = validateData(inputData, module.inputSchema, 'Input');
911
+ if (inputErrors.length > 0) {
912
+ const errorResult = makeErrorResponse({
913
+ code: 'INVALID_INPUT',
914
+ message: inputErrors.join('; '),
915
+ explain: 'Input validation failed.',
916
+ confidence: 1.0,
917
+ risk: 'none',
918
+ details: { validation_errors: inputErrors },
919
+ });
920
+ _invokeErrorHooks(module.name, new Error(inputErrors.join('; ')), null);
921
+ return errorResult;
922
+ }
923
+ }
131
924
  // Build prompt with clean substitution
132
925
  const prompt = buildPrompt(module, inputData);
133
926
  if (verbose) {
@@ -202,70 +995,364 @@ export async function runModule(module, provider, options = {}) {
202
995
  { role: 'system', content: systemParts.join('\n') },
203
996
  { role: 'user', content: prompt },
204
997
  ];
205
- // Invoke provider
206
- const result = await provider.invoke({
207
- messages,
208
- jsonSchema: module.outputSchema,
209
- temperature: 0.3,
210
- });
211
- if (verbose) {
212
- console.error('--- Response ---');
213
- console.error(result.content);
214
- console.error('--- End Response ---');
215
- }
216
- // Parse response
217
- let parsed;
218
998
  try {
219
- const jsonMatch = result.content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
220
- const jsonStr = jsonMatch ? jsonMatch[1] : result.content;
221
- parsed = JSON.parse(jsonStr.trim());
222
- }
223
- catch {
224
- throw new Error(`Failed to parse JSON response: ${result.content.substring(0, 500)}`);
225
- }
226
- // Handle envelope format
227
- if (shouldUseEnvelope && isEnvelopeResponse(parsed)) {
228
- let response = parseEnvelopeResponse(parsed, result.content);
229
- // Upgrade to v2.2 if needed
230
- if (shouldUseV22 && response.ok && !('meta' in response && response.meta)) {
231
- const upgraded = wrapV21ToV22(parsed, riskRule);
232
- response = {
233
- ok: true,
234
- meta: upgraded.meta,
235
- data: upgraded.data,
236
- raw: result.content
237
- };
999
+ // Invoke provider
1000
+ const result = await provider.invoke({
1001
+ messages,
1002
+ jsonSchema: module.outputSchema,
1003
+ temperature: 0.3,
1004
+ });
1005
+ if (verbose) {
1006
+ console.error('--- Response ---');
1007
+ console.error(result.content);
1008
+ console.error('--- End Response ---');
238
1009
  }
239
- // Apply repair pass if enabled and response needs it
240
- if (enableRepair && response.ok && shouldUseV22) {
241
- const repaired = repairEnvelope(response, riskRule);
242
- response = {
243
- ok: true,
244
- meta: repaired.meta,
245
- data: repaired.data,
246
- raw: result.content
247
- };
1010
+ // Calculate latency
1011
+ const latencyMs = Date.now() - startTime;
1012
+ // Parse response
1013
+ let parsed;
1014
+ try {
1015
+ const jsonMatch = result.content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
1016
+ const jsonStr = jsonMatch ? jsonMatch[1] : result.content;
1017
+ parsed = JSON.parse(jsonStr.trim());
1018
+ }
1019
+ catch (e) {
1020
+ const errorResult = makeErrorResponse({
1021
+ code: 'PARSE_ERROR',
1022
+ message: `Failed to parse JSON response: ${e.message}`,
1023
+ explain: 'Failed to parse LLM response as JSON.',
1024
+ details: { raw_response: result.content.substring(0, 500) },
1025
+ });
1026
+ _invokeErrorHooks(module.name, e, null);
1027
+ return errorResult;
1028
+ }
1029
+ // Convert to v2.2 envelope
1030
+ let response;
1031
+ if (isV22Envelope(parsed)) {
1032
+ response = parsed;
1033
+ }
1034
+ else if (isEnvelopeResponse(parsed)) {
1035
+ response = wrapV21ToV22(parsed, riskRule);
1036
+ }
1037
+ else {
1038
+ response = convertLegacyToEnvelope(parsed);
248
1039
  }
1040
+ // Add version and meta fields
1041
+ response.version = ENVELOPE_VERSION;
1042
+ if (response.meta) {
1043
+ response.meta.latency_ms = latencyMs;
1044
+ if (traceId) {
1045
+ response.meta.trace_id = traceId;
1046
+ }
1047
+ if (modelOverride) {
1048
+ response.meta.model = modelOverride;
1049
+ }
1050
+ }
1051
+ // Validate and potentially repair output
1052
+ if (response.ok && validateOutput) {
1053
+ // Get data schema (support both "data" and "output" aliases)
1054
+ const dataSchema = module.dataSchema || module.outputSchema;
1055
+ const metaSchema = module.metaSchema;
1056
+ const dataToValidate = response.data ?? {};
1057
+ if (dataSchema && Object.keys(dataSchema).length > 0) {
1058
+ let dataErrors = validateData(dataToValidate, dataSchema, 'Data');
1059
+ if (dataErrors.length > 0 && enableRepair) {
1060
+ // Attempt repair pass
1061
+ response = repairEnvelope(response, riskRule);
1062
+ response.version = ENVELOPE_VERSION;
1063
+ // Re-validate after repair
1064
+ const repairedData = response.data ?? {};
1065
+ dataErrors = validateData(repairedData, dataSchema, 'Data');
1066
+ }
1067
+ if (dataErrors.length > 0) {
1068
+ const errorResult = makeErrorResponse({
1069
+ code: 'SCHEMA_VALIDATION_FAILED',
1070
+ message: dataErrors.join('; '),
1071
+ explain: 'Schema validation failed after repair attempt.',
1072
+ partialData: response.data,
1073
+ details: { validation_errors: dataErrors },
1074
+ });
1075
+ _invokeErrorHooks(module.name, new Error(dataErrors.join('; ')), response.data);
1076
+ return errorResult;
1077
+ }
1078
+ }
1079
+ // v2.2: Validate overflow limits
1080
+ const overflowErrors = validateOverflowLimits(dataToValidate, module);
1081
+ if (overflowErrors.length > 0) {
1082
+ const errorResult = makeErrorResponse({
1083
+ code: 'SCHEMA_VALIDATION_FAILED',
1084
+ message: overflowErrors.join('; '),
1085
+ explain: 'Overflow validation failed.',
1086
+ partialData: dataToValidate,
1087
+ details: { overflow_errors: overflowErrors },
1088
+ });
1089
+ _invokeErrorHooks(module.name, new Error(overflowErrors.join('; ')), dataToValidate);
1090
+ return errorResult;
1091
+ }
1092
+ // v2.2: Validate enum strategy
1093
+ const enumErrors = validateEnumStrategy(dataToValidate, module);
1094
+ if (enumErrors.length > 0) {
1095
+ const errorResult = makeErrorResponse({
1096
+ code: 'SCHEMA_VALIDATION_FAILED',
1097
+ message: enumErrors.join('; '),
1098
+ explain: 'Enum strategy validation failed.',
1099
+ partialData: dataToValidate,
1100
+ details: { enum_errors: enumErrors },
1101
+ });
1102
+ _invokeErrorHooks(module.name, new Error(enumErrors.join('; ')), dataToValidate);
1103
+ return errorResult;
1104
+ }
1105
+ // Validate meta if schema exists
1106
+ if (metaSchema && Object.keys(metaSchema).length > 0) {
1107
+ let metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
1108
+ if (metaErrors.length > 0 && enableRepair) {
1109
+ response = repairEnvelope(response, riskRule);
1110
+ response.version = ENVELOPE_VERSION;
1111
+ // Re-validate meta after repair
1112
+ metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
1113
+ if (metaErrors.length > 0) {
1114
+ const errorResult = makeErrorResponse({
1115
+ code: 'META_VALIDATION_FAILED',
1116
+ message: metaErrors.join('; '),
1117
+ explain: 'Meta schema validation failed after repair attempt.',
1118
+ partialData: response.data,
1119
+ details: { validation_errors: metaErrors },
1120
+ });
1121
+ _invokeErrorHooks(module.name, new Error(metaErrors.join('; ')), response.data);
1122
+ return errorResult;
1123
+ }
1124
+ }
1125
+ }
1126
+ }
1127
+ else if (enableRepair) {
1128
+ // Repair error envelopes to ensure they have proper meta fields
1129
+ response = repairErrorEnvelope(response);
1130
+ response.version = ENVELOPE_VERSION;
1131
+ }
1132
+ // Invoke after hooks
1133
+ const finalLatencyMs = Date.now() - startTime;
1134
+ _invokeAfterHooks(module.name, response, finalLatencyMs);
249
1135
  return response;
250
1136
  }
251
- // Handle legacy format (non-envelope)
252
- const legacyResult = parseLegacyResponse(parsed, result.content);
253
- // Upgrade to v2.2 if requested
254
- if (shouldUseV22 && legacyResult.ok) {
255
- const data = (legacyResult.data ?? {});
1137
+ catch (e) {
1138
+ const latencyMs = Date.now() - startTime;
1139
+ const errorResult = makeErrorResponse({
1140
+ code: 'UNKNOWN',
1141
+ message: e.message,
1142
+ explain: `Unexpected error: ${e.name}`,
1143
+ details: { exception_type: e.name },
1144
+ });
1145
+ if (errorResult.meta) {
1146
+ errorResult.meta.latency_ms = latencyMs;
1147
+ }
1148
+ _invokeErrorHooks(module.name, e, null);
1149
+ return errorResult;
1150
+ }
1151
+ }
1152
+ /**
1153
+ * Run a cognitive module with streaming output.
1154
+ *
1155
+ * Yields StreamEvent objects as the module executes:
1156
+ * - type="start": Module execution started
1157
+ * - type="chunk": Incremental data chunk (if LLM supports streaming)
1158
+ * - type="meta": Meta information available early
1159
+ * - type="complete": Final complete result
1160
+ * - type="error": Error occurred
1161
+ *
1162
+ * @example
1163
+ * for await (const event of runModuleStream(module, provider, options)) {
1164
+ * if (event.type === 'chunk') {
1165
+ * process.stdout.write(event.chunk);
1166
+ * } else if (event.type === 'complete') {
1167
+ * console.log('Result:', event.result);
1168
+ * }
1169
+ * }
1170
+ */
1171
+ export async function* runModuleStream(module, provider, options = {}) {
1172
+ const { input, args, validateInput = true, validateOutput = true, useV22 = true, enableRepair = true, traceId, model } = options;
1173
+ const startTime = Date.now();
1174
+ const moduleName = module.name;
1175
+ function makeEvent(type, extra = {}) {
256
1176
  return {
257
- ok: true,
258
- meta: {
259
- confidence: data.confidence ?? 0.5,
260
- risk: aggregateRisk(data, riskRule),
261
- explain: (data.rationale ?? '').slice(0, 280) || 'No explanation provided'
262
- },
263
- data: legacyResult.data,
264
- raw: result.content
1177
+ type,
1178
+ timestamp_ms: Date.now() - startTime,
1179
+ module_name: moduleName,
1180
+ ...extra,
265
1181
  };
266
1182
  }
267
- return legacyResult;
1183
+ try {
1184
+ // Emit start event
1185
+ yield makeEvent('start');
1186
+ // Build input data
1187
+ const inputData = input || {};
1188
+ if (args && !inputData.code && !inputData.query) {
1189
+ if (looksLikeCode(args)) {
1190
+ inputData.code = args;
1191
+ }
1192
+ else {
1193
+ inputData.query = args;
1194
+ }
1195
+ }
1196
+ // Validate input if enabled
1197
+ if (validateInput && module.inputSchema && Object.keys(module.inputSchema).length > 0) {
1198
+ const inputErrors = validateData(inputData, module.inputSchema, 'Input');
1199
+ if (inputErrors.length > 0) {
1200
+ const errorResult = makeErrorResponse({
1201
+ code: 'INVALID_INPUT',
1202
+ message: inputErrors.join('; '),
1203
+ confidence: 1.0,
1204
+ risk: 'none',
1205
+ });
1206
+ const errorObj = errorResult.error;
1207
+ yield makeEvent('error', { error: errorObj });
1208
+ yield makeEvent('complete', { result: errorResult });
1209
+ return;
1210
+ }
1211
+ }
1212
+ // Get risk_rule from module config
1213
+ const riskRule = module.metaConfig?.risk_rule ?? 'max_changes_risk';
1214
+ // Build prompt
1215
+ const prompt = buildPrompt(module, inputData);
1216
+ // Build messages
1217
+ const systemParts = [
1218
+ `You are executing the "${module.name}" Cognitive Module.`,
1219
+ '',
1220
+ `RESPONSIBILITY: ${module.responsibility}`,
1221
+ ];
1222
+ if (useV22) {
1223
+ systemParts.push('', 'RESPONSE FORMAT (Envelope v2.2):');
1224
+ systemParts.push('- Wrap your response in the v2.2 envelope format');
1225
+ systemParts.push('- Success: { "ok": true, "meta": { "confidence": 0.9, "risk": "low", "explain": "short summary" }, "data": { ...payload... } }');
1226
+ systemParts.push('- Return ONLY valid JSON.');
1227
+ }
1228
+ const messages = [
1229
+ { role: 'system', content: systemParts.join('\n') },
1230
+ { role: 'user', content: prompt },
1231
+ ];
1232
+ // Invoke provider (streaming not yet supported in provider interface, so we fallback)
1233
+ const result = await provider.invoke({
1234
+ messages,
1235
+ jsonSchema: module.outputSchema,
1236
+ temperature: 0.3,
1237
+ });
1238
+ // Emit chunk event with full response
1239
+ yield makeEvent('chunk', { chunk: result.content });
1240
+ // Parse response
1241
+ let parsed;
1242
+ try {
1243
+ const jsonMatch = result.content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
1244
+ const jsonStr = jsonMatch ? jsonMatch[1] : result.content;
1245
+ parsed = JSON.parse(jsonStr.trim());
1246
+ }
1247
+ catch (e) {
1248
+ const errorResult = makeErrorResponse({
1249
+ code: 'PARSE_ERROR',
1250
+ message: `Failed to parse JSON: ${e.message}`,
1251
+ });
1252
+ // errorResult is always an error response from makeErrorResponse
1253
+ const errorObj = errorResult.error;
1254
+ yield makeEvent('error', { error: errorObj });
1255
+ yield makeEvent('complete', { result: errorResult });
1256
+ return;
1257
+ }
1258
+ // Convert to v2.2 envelope
1259
+ let response;
1260
+ if (isV22Envelope(parsed)) {
1261
+ response = parsed;
1262
+ }
1263
+ else if (isEnvelopeResponse(parsed)) {
1264
+ response = wrapV21ToV22(parsed, riskRule);
1265
+ }
1266
+ else {
1267
+ response = convertLegacyToEnvelope(parsed);
1268
+ }
1269
+ // Add version and meta
1270
+ response.version = ENVELOPE_VERSION;
1271
+ const latencyMs = Date.now() - startTime;
1272
+ if (response.meta) {
1273
+ response.meta.latency_ms = latencyMs;
1274
+ if (traceId) {
1275
+ response.meta.trace_id = traceId;
1276
+ }
1277
+ if (model) {
1278
+ response.meta.model = model;
1279
+ }
1280
+ // Emit meta event early
1281
+ yield makeEvent('meta', { meta: response.meta });
1282
+ }
1283
+ // Validate and repair output
1284
+ if (response.ok && validateOutput) {
1285
+ const dataSchema = module.dataSchema || module.outputSchema;
1286
+ const metaSchema = module.metaSchema;
1287
+ if (dataSchema && Object.keys(dataSchema).length > 0) {
1288
+ const dataToValidate = response.data ?? {};
1289
+ let dataErrors = validateData(dataToValidate, dataSchema, 'Data');
1290
+ if (dataErrors.length > 0 && enableRepair) {
1291
+ response = repairEnvelope(response, riskRule);
1292
+ response.version = ENVELOPE_VERSION;
1293
+ // Re-validate after repair
1294
+ const repairedData = response.data ?? {};
1295
+ dataErrors = validateData(repairedData, dataSchema, 'Data');
1296
+ }
1297
+ if (dataErrors.length > 0) {
1298
+ const errorResult = makeErrorResponse({
1299
+ code: 'SCHEMA_VALIDATION_FAILED',
1300
+ message: dataErrors.join('; '),
1301
+ explain: 'Schema validation failed after repair attempt.',
1302
+ partialData: response.data,
1303
+ details: { validation_errors: dataErrors },
1304
+ });
1305
+ const errorObj = errorResult.error;
1306
+ yield makeEvent('error', { error: errorObj });
1307
+ yield makeEvent('complete', { result: errorResult });
1308
+ return;
1309
+ }
1310
+ }
1311
+ // Validate meta if schema exists
1312
+ if (metaSchema && Object.keys(metaSchema).length > 0) {
1313
+ let metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
1314
+ if (metaErrors.length > 0 && enableRepair) {
1315
+ response = repairEnvelope(response, riskRule);
1316
+ response.version = ENVELOPE_VERSION;
1317
+ metaErrors = validateData(response.meta ?? {}, metaSchema, 'Meta');
1318
+ if (metaErrors.length > 0) {
1319
+ const errorResult = makeErrorResponse({
1320
+ code: 'META_VALIDATION_FAILED',
1321
+ message: metaErrors.join('; '),
1322
+ explain: 'Meta validation failed after repair attempt.',
1323
+ partialData: response.data,
1324
+ details: { validation_errors: metaErrors },
1325
+ });
1326
+ const errorObj = errorResult.error;
1327
+ yield makeEvent('error', { error: errorObj });
1328
+ yield makeEvent('complete', { result: errorResult });
1329
+ return;
1330
+ }
1331
+ }
1332
+ }
1333
+ }
1334
+ else if (!response.ok && enableRepair) {
1335
+ response = repairErrorEnvelope(response);
1336
+ response.version = ENVELOPE_VERSION;
1337
+ }
1338
+ // Emit complete event
1339
+ yield makeEvent('complete', { result: response });
1340
+ }
1341
+ catch (e) {
1342
+ const errorResult = makeErrorResponse({
1343
+ code: 'UNKNOWN',
1344
+ message: e.message,
1345
+ explain: `Unexpected error: ${e.name}`,
1346
+ });
1347
+ // errorResult is always an error response from makeErrorResponse
1348
+ const errorObj = errorResult.error;
1349
+ yield makeEvent('error', { error: errorObj });
1350
+ yield makeEvent('complete', { result: errorResult });
1351
+ }
268
1352
  }
1353
+ // =============================================================================
1354
+ // Helper Functions
1355
+ // =============================================================================
269
1356
  /**
270
1357
  * Check if response is in envelope format
271
1358
  */
@@ -360,6 +1447,12 @@ function parseLegacyResponse(output, raw) {
360
1447
  }
361
1448
  /**
362
1449
  * Build prompt with clean variable substitution
1450
+ *
1451
+ * Substitution order (important to avoid partial replacements):
1452
+ * 1. ${variable} - v2 style placeholders
1453
+ * 2. $ARGUMENTS[N] - indexed access (descending order to avoid $1 matching $10)
1454
+ * 3. $N - shorthand indexed access (descending order)
1455
+ * 4. $ARGUMENTS - full argument string (LAST to avoid partial matches)
363
1456
  */
364
1457
  function buildPrompt(module, input) {
365
1458
  let prompt = module.prompt;
@@ -368,16 +1461,22 @@ function buildPrompt(module, input) {
368
1461
  const strValue = typeof value === 'string' ? value : JSON.stringify(value);
369
1462
  prompt = prompt.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), strValue);
370
1463
  }
371
- // v1 compatibility: substitute $ARGUMENTS
1464
+ // v1 compatibility: get args value
372
1465
  const argsValue = input.code || input.query || '';
373
- prompt = prompt.replace(/\$ARGUMENTS/g, argsValue);
374
- // Substitute $N placeholders (v1 compatibility)
1466
+ // Substitute $ARGUMENTS[N] and $N placeholders FIRST (v1 compatibility)
1467
+ // Process in descending order to avoid $1 replacing part of $10
375
1468
  if (typeof argsValue === 'string') {
376
1469
  const argsList = argsValue.split(/\s+/);
377
- argsList.forEach((arg, i) => {
1470
+ for (let i = argsList.length - 1; i >= 0; i--) {
1471
+ const arg = argsList[i];
1472
+ // Replace $ARGUMENTS[N] first
1473
+ prompt = prompt.replace(new RegExp(`\\$ARGUMENTS\\[${i}\\]`, 'g'), arg);
1474
+ // Replace $N shorthand
378
1475
  prompt = prompt.replace(new RegExp(`\\$${i}\\b`, 'g'), arg);
379
- });
1476
+ }
380
1477
  }
1478
+ // Replace $ARGUMENTS LAST (after indexed forms to avoid partial matches)
1479
+ prompt = prompt.replace(/\$ARGUMENTS/g, argsValue);
381
1480
  // Append input summary if not already in prompt
382
1481
  if (!prompt.includes(argsValue) && argsValue) {
383
1482
  prompt += '\n\n## Input\n\n';
@@ -405,3 +1504,61 @@ function looksLikeCode(str) {
405
1504
  ];
406
1505
  return codeIndicators.some(re => re.test(str));
407
1506
  }
1507
+ /**
1508
+ * Run a cognitive module (legacy API, returns raw output).
1509
+ * For backward compatibility. Throws on error instead of returning error envelope.
1510
+ */
1511
+ export async function runModuleLegacy(module, provider, input, options = {}) {
1512
+ const { validateInput = true, validateOutput = true, model } = options;
1513
+ const result = await runModule(module, provider, {
1514
+ input,
1515
+ validateInput,
1516
+ validateOutput,
1517
+ useEnvelope: false,
1518
+ useV22: false,
1519
+ model,
1520
+ });
1521
+ if (result.ok && 'data' in result) {
1522
+ return result.data;
1523
+ }
1524
+ else {
1525
+ const error = 'error' in result ? result.error : { code: 'UNKNOWN', message: 'Unknown error' };
1526
+ throw new Error(`${error?.code ?? 'UNKNOWN'}: ${error?.message ?? 'Unknown error'}`);
1527
+ }
1528
+ }
1529
+ // =============================================================================
1530
+ // Convenience Functions
1531
+ // =============================================================================
1532
+ /**
1533
+ * Extract meta from v2.2 envelope for routing/logging.
1534
+ */
1535
+ export function extractMeta(result) {
1536
+ return result.meta ?? {
1537
+ confidence: 0.5,
1538
+ risk: 'medium',
1539
+ explain: 'No meta available',
1540
+ };
1541
+ }
1542
+ // Alias for backward compatibility
1543
+ export const extractMetaV22 = extractMeta;
1544
+ /**
1545
+ * Determine if result should be escalated to human review based on meta.
1546
+ */
1547
+ export function shouldEscalate(result, confidenceThreshold = 0.7) {
1548
+ const meta = extractMeta(result);
1549
+ // Escalate if low confidence
1550
+ if (meta.confidence < confidenceThreshold) {
1551
+ return true;
1552
+ }
1553
+ // Escalate if high risk
1554
+ if (meta.risk === 'high') {
1555
+ return true;
1556
+ }
1557
+ // Escalate if error
1558
+ if (!result.ok) {
1559
+ return true;
1560
+ }
1561
+ return false;
1562
+ }
1563
+ // Alias for backward compatibility
1564
+ export const shouldEscalateV22 = shouldEscalate;