cognitive-modules-cli 1.4.1 → 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.
Files changed (45) hide show
  1. package/dist/cli.js +65 -12
  2. package/dist/commands/compose.d.ts +31 -0
  3. package/dist/commands/compose.js +148 -0
  4. package/dist/commands/index.d.ts +1 -0
  5. package/dist/commands/index.js +1 -0
  6. package/dist/index.d.ts +2 -2
  7. package/dist/index.js +5 -1
  8. package/dist/modules/composition.d.ts +251 -0
  9. package/dist/modules/composition.js +1265 -0
  10. package/dist/modules/composition.test.d.ts +11 -0
  11. package/dist/modules/composition.test.js +450 -0
  12. package/dist/modules/index.d.ts +2 -0
  13. package/dist/modules/index.js +2 -0
  14. package/dist/modules/loader.d.ts +22 -2
  15. package/dist/modules/loader.js +167 -4
  16. package/dist/modules/policy.test.d.ts +10 -0
  17. package/dist/modules/policy.test.js +369 -0
  18. package/dist/modules/runner.d.ts +348 -34
  19. package/dist/modules/runner.js +1263 -708
  20. package/dist/modules/subagent.js +2 -0
  21. package/dist/modules/validator.d.ts +28 -0
  22. package/dist/modules/validator.js +629 -0
  23. package/dist/providers/base.d.ts +1 -45
  24. package/dist/providers/base.js +0 -67
  25. package/dist/providers/openai.d.ts +3 -27
  26. package/dist/providers/openai.js +3 -175
  27. package/dist/types.d.ts +93 -316
  28. package/dist/types.js +1 -120
  29. package/package.json +2 -1
  30. package/src/cli.ts +73 -12
  31. package/src/commands/compose.ts +185 -0
  32. package/src/commands/index.ts +1 -0
  33. package/src/index.ts +35 -0
  34. package/src/modules/composition.test.ts +558 -0
  35. package/src/modules/composition.ts +1674 -0
  36. package/src/modules/index.ts +2 -0
  37. package/src/modules/loader.ts +196 -6
  38. package/src/modules/policy.test.ts +455 -0
  39. package/src/modules/runner.ts +1692 -998
  40. package/src/modules/subagent.ts +2 -0
  41. package/src/modules/validator.ts +700 -0
  42. package/src/providers/base.ts +1 -86
  43. package/src/providers/openai.ts +4 -226
  44. package/src/types.ts +113 -462
  45. package/tsconfig.json +1 -1
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * Module Runner - Execute Cognitive Modules
3
- * v2.5: Streaming response and multimodal support
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,
@@ -16,882 +19,1734 @@ import type {
16
19
  EnvelopeMeta,
17
20
  ModuleResultData,
18
21
  RiskLevel,
19
- RiskRule,
20
- // v2.5 types
21
- StreamingChunk,
22
- MetaChunk,
23
- DeltaChunk,
24
- FinalChunk,
25
- ErrorChunk,
26
- ProgressChunk,
27
- StreamingSession,
28
- MediaInput,
29
- ProviderV25,
30
- CognitiveModuleV25,
31
- ModalityType,
32
- RuntimeCapabilities
22
+ RiskRule
33
23
  } from '../types.js';
34
- import {
35
- aggregateRisk,
36
- isV22Envelope,
37
- isProviderV25,
38
- isModuleV25,
39
- moduleSupportsStreaming,
40
- moduleSupportsMultimodal,
41
- getModuleInputModalities,
42
- ErrorCodesV25,
43
- DEFAULT_RUNTIME_CAPABILITIES
44
- } from '../types.js';
45
- import { randomUUID } from 'crypto';
46
- import { readFile } from 'fs/promises';
47
- import { existsSync } from 'fs';
48
- import { extname } from 'path';
24
+ import { aggregateRisk, isV22Envelope } from '../types.js';
49
25
 
50
- export interface RunOptions {
51
- // Clean input (v2 style)
52
- input?: ModuleInput;
53
-
54
- // Legacy CLI args (v1 compatibility) - mapped to input.code or input.query
55
- args?: string;
56
-
57
- // Runtime options
58
- verbose?: boolean;
59
-
60
- // Force envelope format (default: auto-detect from module.output.envelope)
61
- useEnvelope?: boolean;
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
+ }
62
40
 
63
- // Force v2.2 format (default: auto-detect from module.tier)
64
- useV22?: boolean;
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
+ }
65
54
 
66
- // Enable repair pass for validation failures (default: true)
67
- enableRepair?: boolean;
55
+ return errors;
68
56
  }
69
57
 
70
58
  // =============================================================================
71
- // Repair Pass (v2.2)
59
+ // v2.2 Policy Enforcement
72
60
  // =============================================================================
73
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
+
74
102
  /**
75
- * Attempt to repair envelope format issues without changing semantics.
103
+ * Check if a tool is allowed by the module's tools policy.
76
104
  *
77
- * Repairs (lossless only):
78
- * - Missing meta fields (fill with conservative defaults)
79
- * - Truncate explain if too long
80
- * - Trim whitespace from string fields
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
81
108
  *
82
- * Does NOT repair:
83
- * - Invalid enum values (treated as validation failure)
109
+ * @example
110
+ * const result = checkToolPolicy('write_file', module);
111
+ * if (!result.allowed) {
112
+ * throw new Error(result.reason);
113
+ * }
84
114
  */
85
- function repairEnvelope(
86
- response: Record<string, unknown>,
87
- riskRule: RiskRule = 'max_changes_risk',
88
- maxExplainLength: number = 280
89
- ): EnvelopeResponseV22<unknown> {
90
- const repaired = { ...response };
115
+ export function checkToolPolicy(
116
+ toolName: string,
117
+ module: CognitiveModule
118
+ ): PolicyCheckResult {
119
+ const toolsPolicy = module.tools;
91
120
 
92
- // Ensure meta exists
93
- if (!repaired.meta || typeof repaired.meta !== 'object') {
94
- repaired.meta = {};
121
+ // No policy = allow all
122
+ if (!toolsPolicy) {
123
+ return { allowed: true };
95
124
  }
96
125
 
97
- const meta = repaired.meta as Record<string, unknown>;
98
- const data = (repaired.data ?? {}) as Record<string, unknown>;
99
-
100
- // Repair confidence
101
- if (typeof meta.confidence !== 'number') {
102
- meta.confidence = (data.confidence as number) ?? 0.5;
103
- }
104
- meta.confidence = Math.max(0, Math.min(1, meta.confidence as number));
126
+ const normalizedName = toolName.toLowerCase().replace(/[-\s]/g, '_');
105
127
 
106
- // Repair risk using configurable aggregation rule
107
- if (!meta.risk) {
108
- meta.risk = aggregateRisk(data, riskRule);
109
- }
110
- // Trim whitespace only (lossless), validate is valid RiskLevel
111
- if (typeof meta.risk === 'string') {
112
- const trimmedRisk = meta.risk.trim().toLowerCase();
113
- const validRisks = ['none', 'low', 'medium', 'high'];
114
- meta.risk = validRisks.includes(trimmedRisk) ? trimmedRisk : 'medium';
115
- } else {
116
- meta.risk = 'medium'; // Default for invalid type
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
+ };
117
135
  }
118
136
 
119
- // Repair explain
120
- if (typeof meta.explain !== 'string') {
121
- const rationale = data.rationale as string | undefined;
122
- meta.explain = rationale ? String(rationale).slice(0, maxExplainLength) : 'No explanation provided';
123
- }
124
- // Trim whitespace (lossless)
125
- const explainStr = meta.explain as string;
126
- meta.explain = explainStr.trim();
127
- if ((meta.explain as string).length > maxExplainLength) {
128
- meta.explain = (meta.explain as string).slice(0, maxExplainLength - 3) + '...';
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
+ }
129
151
  }
130
152
 
131
- // Build proper v2.2 response
132
- const builtMeta: EnvelopeMeta = {
133
- confidence: meta.confidence as number,
134
- risk: meta.risk as RiskLevel,
135
- explain: meta.explain as string
136
- };
137
-
138
- const result: EnvelopeResponseV22<unknown> = repaired.ok === false ? {
139
- ok: false,
140
- meta: builtMeta,
141
- error: (repaired.error as { code: string; message: string }) ?? { code: 'UNKNOWN', message: 'Unknown error' },
142
- partial_data: repaired.partial_data
143
- } : {
144
- ok: true,
145
- meta: builtMeta,
146
- data: repaired.data
147
- };
148
-
149
- return result;
153
+ return { allowed: true };
150
154
  }
151
155
 
152
156
  /**
153
- * Wrap v2.1 response to v2.2 format
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
+ * }
154
168
  */
155
- function wrapV21ToV22(
156
- response: EnvelopeResponse<unknown>,
157
- riskRule: RiskRule = 'max_changes_risk'
158
- ): EnvelopeResponseV22<unknown> {
159
- if (isV22Envelope(response)) {
160
- return response;
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 };
161
178
  }
162
179
 
163
- if (response.ok) {
164
- const data = (response.data ?? {}) as Record<string, unknown>;
165
- const confidence = (data.confidence as number) ?? 0.5;
166
- const rationale = (data.rationale as string) ?? '';
167
-
168
- return {
169
- ok: true,
170
- meta: {
171
- confidence,
172
- risk: aggregateRisk(data, riskRule),
173
- explain: rationale.slice(0, 280) || 'No explanation provided'
174
- },
175
- data: data as ModuleResultData
176
- };
177
- } else {
178
- const errorMsg = response.error?.message ?? 'Unknown error';
180
+ // Check the specific policy
181
+ if (policies[action] === 'deny') {
179
182
  return {
180
- ok: false,
181
- meta: {
182
- confidence: 0,
183
- risk: 'high',
184
- explain: errorMsg.slice(0, 280)
185
- },
186
- error: response.error ?? { code: 'UNKNOWN', message: errorMsg },
187
- partial_data: response.partial_data
183
+ allowed: false,
184
+ reason: `Action '${action}' is denied by module policy`,
185
+ policy: `policies.${action}`
188
186
  };
189
187
  }
188
+
189
+ return { allowed: true };
190
190
  }
191
191
 
192
- export async function runModule(
193
- module: CognitiveModule,
194
- provider: Provider,
195
- options: RunOptions = {}
196
- ): Promise<ModuleResult> {
197
- const { args, input, verbose = false, useEnvelope, useV22, enableRepair = true } = options;
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
+ }
198
238
 
199
- // Determine if we should use envelope format
200
- const shouldUseEnvelope = useEnvelope ?? (module.output?.envelope === true || module.format === 'v2');
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[] = [];
201
252
 
202
- // Determine if we should use v2.2 format
203
- const isV22Module = module.tier !== undefined || module.formatVersion === 'v2.2';
204
- const shouldUseV22 = useV22 ?? (isV22Module || module.compat?.runtime_auto_wrap === true);
253
+ for (const toolName of toolNames) {
254
+ const result = checkToolAllowed(toolName, module);
255
+ if (!result.allowed) {
256
+ violations.push(result);
257
+ }
258
+ }
205
259
 
206
- // Get risk_rule from module config
207
- const riskRule: RiskRule = module.metaConfig?.risk_rule ?? 'max_changes_risk';
260
+ return violations;
261
+ }
208
262
 
209
- // Build clean input data (v2 style: no $ARGUMENTS pollution)
210
- const inputData: ModuleInput = input || {};
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;
211
270
 
212
- // Map legacy --args to clean input
213
- if (args && !inputData.code && !inputData.query) {
214
- // Determine if args looks like code or natural language
215
- if (looksLikeCode(args)) {
216
- inputData.code = args;
217
- } else {
218
- inputData.query = args;
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);
219
277
  }
220
278
  }
279
+
280
+ return denied;
281
+ }
221
282
 
222
- // Build prompt with clean substitution
223
- const prompt = buildPrompt(module, inputData);
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
+ }
224
289
 
225
- if (verbose) {
226
- console.error('--- Module ---');
227
- console.error(`Name: ${module.name} (${module.format})`);
228
- console.error(`Responsibility: ${module.responsibility}`);
229
- console.error(`Envelope: ${shouldUseEnvelope}`);
230
- console.error('--- Input ---');
231
- console.error(JSON.stringify(inputData, null, 2));
232
- console.error('--- Prompt ---');
233
- console.error(prompt);
234
- console.error('--- End ---');
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 || [];
235
296
  }
297
+ return null; // null means "all allowed except denied list"
298
+ }
236
299
 
237
- // Build system message based on module config
238
- const systemParts: string[] = [
239
- `You are executing the "${module.name}" Cognitive Module.`,
240
- '',
241
- `RESPONSIBILITY: ${module.responsibility}`,
242
- ];
300
+ // =============================================================================
301
+ // Tool Call Interceptor
302
+ // =============================================================================
243
303
 
244
- if (module.excludes.length > 0) {
245
- systemParts.push('', 'YOU MUST NOT:');
246
- module.excludes.forEach(e => systemParts.push(`- ${e}`));
247
- }
304
+ /** Tool call request from LLM */
305
+ export interface ToolCallRequest {
306
+ name: string;
307
+ arguments: Record<string, unknown>;
308
+ }
248
309
 
249
- if (module.constraints) {
250
- systemParts.push('', 'CONSTRAINTS:');
251
- if (module.constraints.no_network) systemParts.push('- No network access');
252
- if (module.constraints.no_side_effects) systemParts.push('- No side effects');
253
- if (module.constraints.no_file_write) systemParts.push('- No file writes');
254
- if (module.constraints.no_inventing_data) systemParts.push('- Do not invent data');
255
- }
310
+ /** Tool call result */
311
+ export interface ToolCallResult {
312
+ success: boolean;
313
+ result?: unknown;
314
+ error?: {
315
+ code: string;
316
+ message: string;
317
+ };
318
+ }
256
319
 
257
- if (module.output?.require_behavior_equivalence) {
258
- systemParts.push('', 'BEHAVIOR EQUIVALENCE:');
259
- systemParts.push('- You MUST set behavior_equivalence=true ONLY if the output is functionally identical');
260
- systemParts.push('- If unsure, set behavior_equivalence=false and explain in rationale');
261
-
262
- const maxConfidence = module.constraints?.behavior_equivalence_false_max_confidence ?? 0.7;
263
- systemParts.push(`- If behavior_equivalence=false, confidence MUST be <= ${maxConfidence}`);
264
- }
320
+ /** Tool executor function type */
321
+ export type ToolExecutor = (args: Record<string, unknown>) => Promise<unknown>;
265
322
 
266
- // Add envelope format instructions
267
- if (shouldUseEnvelope) {
268
- if (shouldUseV22) {
269
- systemParts.push('', 'RESPONSE FORMAT (Envelope v2.2):');
270
- systemParts.push('- Wrap your response in the v2.2 envelope format with separate meta and data');
271
- systemParts.push('- Success: { "ok": true, "meta": { "confidence": 0.9, "risk": "low", "explain": "short summary" }, "data": { ...payload... } }');
272
- systemParts.push('- Error: { "ok": false, "meta": { "confidence": 0.0, "risk": "high", "explain": "error summary" }, "error": { "code": "ERROR_CODE", "message": "..." } }');
273
- systemParts.push('- meta.explain must be ≤280 characters. data.rationale can be longer for detailed reasoning.');
274
- systemParts.push('- meta.risk must be one of: "none", "low", "medium", "high"');
275
- } else {
276
- systemParts.push('', 'RESPONSE FORMAT (Envelope):');
277
- systemParts.push('- Wrap your response in the envelope format');
278
- systemParts.push('- Success: { "ok": true, "data": { ...your output... } }');
279
- systemParts.push('- Error: { "ok": false, "error": { "code": "ERROR_CODE", "message": "..." } }');
280
- systemParts.push('- Include "confidence" (0-1) and "rationale" in data');
281
- }
282
- if (module.output?.require_behavior_equivalence) {
283
- systemParts.push('- Include "behavior_equivalence" (boolean) in data');
284
- }
285
- } else {
286
- systemParts.push('', 'OUTPUT FORMAT:');
287
- systemParts.push('- Respond with ONLY valid JSON');
288
- systemParts.push('- Include "confidence" (0-1) and "rationale" fields');
289
- if (module.output?.require_behavior_equivalence) {
290
- systemParts.push('- Include "behavior_equivalence" (boolean) field');
291
- }
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;
292
353
  }
293
-
294
- const messages: Message[] = [
295
- { role: 'system', content: systemParts.join('\n') },
296
- { role: 'user', content: prompt },
297
- ];
298
-
299
- // Invoke provider
300
- const result = await provider.invoke({
301
- messages,
302
- jsonSchema: module.outputSchema,
303
- temperature: 0.3,
304
- });
305
-
306
- if (verbose) {
307
- console.error('--- Response ---');
308
- console.error(result.content);
309
- console.error('--- End Response ---');
354
+
355
+ /**
356
+ * Register a tool executor.
357
+ */
358
+ registerTool(name: string, executor: ToolExecutor): void {
359
+ this.tools.set(name.toLowerCase(), executor);
310
360
  }
311
-
312
- // Parse response
313
- let parsed: unknown;
314
- try {
315
- const jsonMatch = result.content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
316
- const jsonStr = jsonMatch ? jsonMatch[1] : result.content;
317
- parsed = JSON.parse(jsonStr.trim());
318
- } catch {
319
- throw new Error(`Failed to parse JSON response: ${result.content.substring(0, 500)}`);
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
+ }
320
369
  }
321
-
322
- // Handle envelope format
323
- if (shouldUseEnvelope && isEnvelopeResponse(parsed)) {
324
- let response = parseEnvelopeResponse(parsed, result.content);
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();
325
387
 
326
- // Upgrade to v2.2 if needed
327
- if (shouldUseV22 && response.ok && !('meta' in response && response.meta)) {
328
- const upgraded = wrapV21ToV22(parsed as EnvelopeResponse<unknown>, riskRule);
329
- response = {
330
- ok: true,
331
- meta: upgraded.meta as EnvelopeMeta,
332
- data: (upgraded as { data?: ModuleResultData }).data,
333
- raw: result.content
334
- } as ModuleResultV22;
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
+ };
335
400
  }
336
401
 
337
- // Apply repair pass if enabled and response needs it
338
- if (enableRepair && response.ok && shouldUseV22) {
339
- const repaired = repairEnvelope(
340
- response as unknown as Record<string, unknown>,
341
- riskRule
342
- );
343
- response = {
344
- ok: true,
345
- meta: repaired.meta as EnvelopeMeta,
346
- data: (repaired as { data?: ModuleResultData }).data,
347
- raw: result.content
348
- } as ModuleResultV22;
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
+ };
349
412
  }
350
413
 
351
- return response;
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
+ }
352
428
  }
353
-
354
- // Handle legacy format (non-envelope)
355
- const legacyResult = parseLegacyResponse(parsed, result.content);
356
429
 
357
- // Upgrade to v2.2 if requested
358
- if (shouldUseV22 && legacyResult.ok) {
359
- const data = (legacyResult.data ?? {}) as Record<string, unknown>;
360
- return {
361
- ok: true,
362
- meta: {
363
- confidence: (data.confidence as number) ?? 0.5,
364
- risk: aggregateRisk(data, riskRule),
365
- explain: ((data.rationale as string) ?? '').slice(0, 280) || 'No explanation provided'
366
- },
367
- data: legacyResult.data,
368
- raw: result.content
369
- } as ModuleResultV22;
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 = [];
370
471
  }
371
472
 
372
- return legacyResult;
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
+ }
373
489
  }
374
490
 
375
491
  /**
376
- * Check if response is in envelope format
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' });
377
501
  */
378
- function isEnvelopeResponse(obj: unknown): obj is EnvelopeResponse {
379
- if (typeof obj !== 'object' || obj === null) return false;
380
- const o = obj as Record<string, unknown>;
381
- return typeof o.ok === 'boolean';
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
+ };
382
516
  }
383
517
 
518
+ // =============================================================================
519
+ // v2.2 Runtime Enforcement - Overflow & Enum
520
+ // =============================================================================
521
+
384
522
  /**
385
- * Parse envelope format response (supports both v2.1 and v2.2)
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
386
528
  */
387
- function parseEnvelopeResponse(response: EnvelopeResponse<unknown>, raw: string): ModuleResult {
388
- // Check if v2.2 format (has meta)
389
- if (isV22Envelope(response)) {
390
- if (response.ok) {
391
- return {
392
- ok: true,
393
- meta: response.meta,
394
- data: response.data as ModuleResultData,
395
- raw,
396
- } as ModuleResultV22;
397
- } else {
398
- return {
399
- ok: false,
400
- meta: response.meta,
401
- error: response.error,
402
- partial_data: response.partial_data,
403
- raw,
404
- } as ModuleResultV22;
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');
405
541
  }
542
+ return errors;
406
543
  }
407
544
 
408
- // v2.1 format
409
- if (response.ok) {
410
- const data = (response.data ?? {}) as ModuleResultData & { confidence?: number };
411
- return {
412
- ok: true,
413
- data: {
414
- ...data,
415
- confidence: typeof data.confidence === 'number' ? data.confidence : 0.5,
416
- rationale: typeof data.rationale === 'string' ? data.rationale : '',
417
- behavior_equivalence: data.behavior_equivalence,
418
- },
419
- raw,
420
- } as ModuleResultV21;
421
- } else {
422
- return {
423
- ok: false,
424
- error: response.error,
425
- partial_data: response.partial_data,
426
- raw,
427
- } as ModuleResultV21;
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
+ }
428
564
  }
565
+
566
+ return errors;
429
567
  }
430
568
 
431
569
  /**
432
- * Parse legacy (non-envelope) format response
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
433
576
  */
434
- function parseLegacyResponse(output: unknown, raw: string): ModuleResult {
435
- const outputObj = output as Record<string, unknown>;
436
- const confidence = typeof outputObj.confidence === 'number' ? outputObj.confidence : 0.5;
437
- const rationale = typeof outputObj.rationale === 'string' ? outputObj.rationale : '';
438
- const behaviorEquivalence = typeof outputObj.behavior_equivalence === 'boolean'
439
- ? outputObj.behavior_equivalence
440
- : undefined;
441
-
442
- // Check if this is an error response (has error.code)
443
- if (outputObj.error && typeof outputObj.error === 'object') {
444
- const errorObj = outputObj.error as Record<string, unknown>;
445
- if (typeof errorObj.code === 'string') {
446
- return {
447
- ok: false,
448
- error: {
449
- code: errorObj.code,
450
- message: typeof errorObj.message === 'string' ? errorObj.message : 'Unknown error',
451
- },
452
- raw,
453
- };
454
- }
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');
455
609
  }
456
-
457
- // Return as v2.1 format (data includes confidence)
458
- return {
459
- ok: true,
460
- data: {
461
- ...outputObj,
462
- confidence,
463
- rationale,
464
- behavior_equivalence: behaviorEquivalence,
465
- },
466
- raw,
467
- } as ModuleResultV21;
610
+
611
+ return errors;
468
612
  }
469
613
 
470
- /**
471
- * Build prompt with clean variable substitution
472
- */
473
- function buildPrompt(module: CognitiveModule, input: ModuleInput): string {
474
- let prompt = module.prompt;
614
+ // =============================================================================
615
+ // Constants
616
+ // =============================================================================
475
617
 
476
- // v2 style: substitute ${variable} placeholders
477
- for (const [key, value] of Object.entries(input)) {
478
- const strValue = typeof value === 'string' ? value : JSON.stringify(value);
479
- prompt = prompt.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), strValue);
480
- }
618
+ const ENVELOPE_VERSION = '2.2';
481
619
 
482
- // v1 compatibility: substitute $ARGUMENTS
483
- const argsValue = input.code || input.query || '';
484
- prompt = prompt.replace(/\$ARGUMENTS/g, argsValue);
620
+ // =============================================================================
621
+ // Utility Functions
622
+ // =============================================================================
485
623
 
486
- // Substitute $N placeholders (v1 compatibility)
487
- if (typeof argsValue === 'string') {
488
- const argsList = argsValue.split(/\s+/);
489
- argsList.forEach((arg, i) => {
490
- prompt = prompt.replace(new RegExp(`\\$${i}\\b`, 'g'), arg);
491
- });
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;
492
631
  }
493
-
494
- // Append input summary if not already in prompt
495
- if (!prompt.includes(argsValue) && argsValue) {
496
- prompt += '\n\n## Input\n\n';
497
- if (input.code) {
498
- prompt += '```\n' + input.code + '\n```\n';
499
- }
500
- if (input.query) {
501
- prompt += input.query + '\n';
502
- }
503
- if (input.language) {
504
- prompt += `\nLanguage: ${input.language}\n`;
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]);
505
639
  }
506
640
  }
507
-
508
- return prompt;
509
- }
510
-
511
- /**
512
- * Heuristic to detect if input looks like code
513
- */
514
- function looksLikeCode(str: string): boolean {
515
- const codeIndicators = [
516
- /^(def|function|class|const|let|var|import|export|public|private)\s/,
517
- /[{};()]/,
518
- /=>/,
519
- /\.(py|js|ts|go|rs|java|cpp|c|rb)$/,
520
- ];
521
- return codeIndicators.some(re => re.test(str));
641
+ return cloned;
522
642
  }
523
643
 
524
644
  // =============================================================================
525
- // v2.5 Streaming Support
645
+ // Observability Hooks
526
646
  // =============================================================================
527
647
 
528
- export interface StreamRunOptions extends RunOptions {
529
- /** Callback for each chunk */
530
- onChunk?: (chunk: StreamingChunk) => void;
531
-
532
- /** Callback for progress updates */
533
- onProgress?: (percent: number, message?: string) => void;
534
-
535
- /** Heartbeat interval in milliseconds (default: 15000) */
536
- heartbeatInterval?: number;
537
-
538
- /** Maximum stream duration in milliseconds (default: 300000) */
539
- maxDuration?: number;
540
- }
648
+ /** Hook called before module execution */
649
+ export type BeforeCallHook = (moduleName: string, inputData: ModuleInput, moduleConfig: CognitiveModule) => void;
541
650
 
542
- /**
543
- * Create a new streaming session
544
- */
545
- function createStreamingSession(moduleName: string): StreamingSession {
546
- return {
547
- session_id: `sess_${randomUUID().slice(0, 12)}`,
548
- module_name: moduleName,
549
- started_at: Date.now(),
550
- chunks_sent: 0,
551
- accumulated_data: {},
552
- accumulated_text: {}
553
- };
554
- }
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[] = [];
555
661
 
556
662
  /**
557
- * Create meta chunk (initial streaming response)
663
+ * Decorator to register a before-call hook.
664
+ *
665
+ * @example
666
+ * onBeforeCall((moduleName, inputData, config) => {
667
+ * console.log(`Calling ${moduleName} with`, inputData);
668
+ * });
558
669
  */
559
- function createMetaChunk(session: StreamingSession, meta: Partial<EnvelopeMeta>): MetaChunk {
560
- return {
561
- ok: true,
562
- streaming: true,
563
- session_id: session.session_id,
564
- meta
565
- };
670
+ export function onBeforeCall(hook: BeforeCallHook): BeforeCallHook {
671
+ _beforeCallHooks.push(hook);
672
+ return hook;
566
673
  }
567
674
 
568
675
  /**
569
- * Create delta chunk (incremental content)
570
- * Note: Delta chunks don't include session_id per v2.5 spec
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
+ * });
571
682
  */
572
- function createDeltaChunk(
573
- session: StreamingSession,
574
- field: string,
575
- delta: string
576
- ): DeltaChunk {
577
- session.chunks_sent++;
578
- return {
579
- chunk: {
580
- seq: session.chunks_sent,
581
- type: 'delta',
582
- field,
583
- delta
584
- }
585
- };
683
+ export function onAfterCall(hook: AfterCallHook): AfterCallHook {
684
+ _afterCallHooks.push(hook);
685
+ return hook;
586
686
  }
587
687
 
588
688
  /**
589
- * Create progress chunk
590
- * Note: Progress chunks don't include session_id per v2.5 spec
689
+ * Decorator to register an error hook.
690
+ *
691
+ * @example
692
+ * onError((moduleName, error, partialResult) => {
693
+ * console.error(`Error in ${moduleName}:`, error);
694
+ * });
591
695
  */
592
- function createProgressChunk(
593
- _session: StreamingSession,
594
- percent: number,
595
- stage?: string,
596
- message?: string
597
- ): ProgressChunk {
598
- return {
599
- progress: {
600
- percent,
601
- stage,
602
- message
603
- }
604
- };
696
+ export function onError(hook: ErrorHook): ErrorHook {
697
+ _errorHooks.push(hook);
698
+ return hook;
605
699
  }
606
700
 
607
701
  /**
608
- * Create final chunk (completion signal)
609
- * Note: Final chunks don't include session_id per v2.5 spec
702
+ * Register a hook programmatically.
610
703
  */
611
- function createFinalChunk(
612
- _session: StreamingSession,
613
- meta: EnvelopeMeta,
614
- data: ModuleResultData,
615
- usage?: { input_tokens: number; output_tokens: number; total_tokens: number }
616
- ): FinalChunk {
617
- return {
618
- final: true,
619
- meta,
620
- data,
621
- usage
622
- };
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
+ }
623
717
  }
624
718
 
625
719
  /**
626
- * Create error chunk
720
+ * Unregister a hook. Returns true if found and removed.
627
721
  */
628
- function createErrorChunk(
629
- session: StreamingSession,
630
- code: string,
631
- message: string,
632
- recoverable: boolean = false,
633
- partialData?: unknown
634
- ): ErrorChunk {
635
- return {
636
- ok: false,
637
- streaming: true,
638
- session_id: session.session_id,
639
- error: {
640
- code,
641
- message,
642
- recoverable
643
- },
644
- partial_data: partialData
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,
645
872
  };
646
873
  }
647
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
+
648
885
  /**
649
- * Run module with streaming response
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
+
919
+ export interface RunOptions {
920
+ // Clean input (v2 style)
921
+ input?: ModuleInput;
922
+
923
+ // Legacy CLI args (v1 compatibility) - mapped to input.code or input.query
924
+ args?: string;
925
+
926
+ // Runtime options
927
+ verbose?: boolean;
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
+
935
+ // Force envelope format (default: auto-detect from module.output.envelope)
936
+ useEnvelope?: boolean;
937
+
938
+ // Force v2.2 format (default: auto-detect from module.tier)
939
+ useV22?: boolean;
940
+
941
+ // Enable repair pass for validation failures (default: true)
942
+ enableRepair?: boolean;
943
+
944
+ // Trace ID for distributed tracing
945
+ traceId?: string;
946
+
947
+ // Model identifier (for meta.model tracking)
948
+ model?: string;
949
+ }
950
+
951
+ // =============================================================================
952
+ // Repair Pass (v2.2)
953
+ // =============================================================================
954
+
955
+ /**
956
+ * Attempt to repair envelope format issues without changing semantics.
957
+ *
958
+ * Repairs (mostly lossless, except explain truncation):
959
+ * - Missing meta fields (fill with conservative defaults)
960
+ * - Truncate explain if too long
961
+ * - Trim whitespace from string fields
962
+ * - Clamp confidence to [0, 1] range
650
963
  *
651
- * @param module - The cognitive module to execute
652
- * @param provider - The LLM provider
653
- * @param options - Run options including streaming callbacks
654
- * @yields Streaming chunks
964
+ * Does NOT repair:
965
+ * - Invalid enum values (treated as validation failure)
966
+ *
967
+ * Note: Returns a deep copy to avoid modifying the original data.
655
968
  */
656
- export async function* runModuleStream(
657
- module: CognitiveModule,
658
- provider: Provider,
659
- options: StreamRunOptions = {}
660
- ): AsyncGenerator<StreamingChunk, ModuleResult | undefined, unknown> {
661
- const {
662
- onChunk,
663
- onProgress,
664
- heartbeatInterval = 15000,
665
- maxDuration = 300000,
666
- ...runOptions
667
- } = options;
969
+ function repairEnvelope(
970
+ response: Record<string, unknown>,
971
+ riskRule: RiskRule = 'max_changes_risk',
972
+ maxExplainLength: number = 280
973
+ ): EnvelopeResponseV22<unknown> {
974
+ // Deep clone to avoid mutation
975
+ const repaired = deepClone(response);
668
976
 
669
- // Create streaming session
670
- const session = createStreamingSession(module.name);
671
- const startTime = Date.now();
977
+ // Ensure meta exists
978
+ if (!repaired.meta || typeof repaired.meta !== 'object') {
979
+ repaired.meta = {};
980
+ }
672
981
 
673
- // Check if module supports streaming
674
- if (!moduleSupportsStreaming(module)) {
675
- // Fallback to sync execution
676
- const result = await runModule(module, provider, runOptions);
677
-
678
- // Emit as single final chunk
679
- if (result.ok && 'meta' in result) {
680
- const finalChunk = createFinalChunk(
681
- session,
682
- result.meta,
683
- result.data as ModuleResultData
684
- );
685
- yield finalChunk;
686
- onChunk?.(finalChunk);
687
- return result;
982
+ const meta = repaired.meta as Record<string, unknown>;
983
+ const data = (repaired.data ?? {}) as Record<string, unknown>;
984
+
985
+ // Repair confidence
986
+ if (typeof meta.confidence !== 'number') {
987
+ meta.confidence = (data.confidence as number) ?? 0.5;
988
+ }
989
+ meta.confidence = Math.max(0, Math.min(1, meta.confidence as number));
990
+
991
+ // Repair risk using configurable aggregation rule
992
+ if (!meta.risk) {
993
+ meta.risk = aggregateRisk(data, riskRule);
994
+ }
995
+ // Trim whitespace only (lossless), validate is valid RiskLevel
996
+ if (typeof meta.risk === 'string') {
997
+ const trimmedRisk = meta.risk.trim().toLowerCase();
998
+ const validRisks = ['none', 'low', 'medium', 'high'];
999
+ meta.risk = validRisks.includes(trimmedRisk) ? trimmedRisk : 'medium';
1000
+ } else {
1001
+ meta.risk = 'medium'; // Default for invalid type
1002
+ }
1003
+
1004
+ // Repair explain
1005
+ if (typeof meta.explain !== 'string') {
1006
+ const rationale = data.rationale as string | undefined;
1007
+ meta.explain = rationale ? String(rationale).slice(0, maxExplainLength) : 'No explanation provided';
1008
+ }
1009
+ // Trim whitespace (lossless)
1010
+ const explainStr = meta.explain as string;
1011
+ meta.explain = explainStr.trim();
1012
+ if ((meta.explain as string).length > maxExplainLength) {
1013
+ meta.explain = (meta.explain as string).slice(0, maxExplainLength - 3) + '...';
1014
+ }
1015
+
1016
+ // Build proper v2.2 response with version
1017
+ const builtMeta: EnvelopeMeta = {
1018
+ confidence: meta.confidence as number,
1019
+ risk: meta.risk as RiskLevel,
1020
+ explain: meta.explain as string
1021
+ };
1022
+
1023
+ const result: EnvelopeResponseV22<unknown> = repaired.ok === false ? {
1024
+ ok: false,
1025
+ version: ENVELOPE_VERSION,
1026
+ meta: builtMeta,
1027
+ error: (repaired.error as { code: string; message: string }) ?? { code: 'UNKNOWN', message: 'Unknown error' },
1028
+ partial_data: repaired.partial_data
1029
+ } : {
1030
+ ok: true,
1031
+ version: ENVELOPE_VERSION,
1032
+ meta: builtMeta,
1033
+ data: repaired.data
1034
+ };
1035
+
1036
+ return result;
1037
+ }
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
+
1083
+ /**
1084
+ * Wrap v2.1 response to v2.2 format
1085
+ */
1086
+ function wrapV21ToV22(
1087
+ response: EnvelopeResponse<unknown>,
1088
+ riskRule: RiskRule = 'max_changes_risk'
1089
+ ): EnvelopeResponseV22<unknown> {
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 };
688
1094
  }
689
-
690
- return result;
1095
+ return response;
691
1096
  }
692
1097
 
693
- // Check if provider supports streaming
694
- if (!isProviderV25(provider) || !provider.supportsStreaming?.()) {
695
- // Fallback to sync with warning
696
- console.warn('[cognitive] Provider does not support streaming, falling back to sync');
697
- const result = await runModule(module, provider, runOptions);
1098
+ if (response.ok) {
1099
+ const data = (response.data ?? {}) as Record<string, unknown>;
1100
+ const confidence = (data.confidence as number) ?? 0.5;
1101
+ const rationale = (data.rationale as string) ?? '';
698
1102
 
699
- if (result.ok && 'meta' in result) {
700
- const finalChunk = createFinalChunk(
701
- session,
702
- result.meta,
703
- result.data as ModuleResultData
704
- );
705
- yield finalChunk;
706
- onChunk?.(finalChunk);
707
- }
1103
+ return {
1104
+ ok: true,
1105
+ version: ENVELOPE_VERSION,
1106
+ meta: {
1107
+ confidence,
1108
+ risk: aggregateRisk(data, riskRule),
1109
+ explain: rationale.slice(0, 280) || 'No explanation provided'
1110
+ },
1111
+ data: data as ModuleResultData
1112
+ };
1113
+ } else {
1114
+ const errorMsg = response.error?.message ?? 'Unknown error';
1115
+ return {
1116
+ ok: false,
1117
+ version: ENVELOPE_VERSION,
1118
+ meta: {
1119
+ confidence: 0,
1120
+ risk: 'high',
1121
+ explain: errorMsg.slice(0, 280)
1122
+ },
1123
+ error: response.error ?? { code: 'UNKNOWN', message: errorMsg },
1124
+ partial_data: response.partial_data
1125
+ };
1126
+ }
1127
+ }
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);
708
1141
 
709
- return result;
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
+ };
710
1170
  }
711
-
712
- // Emit initial meta chunk
713
- const metaChunk = createMetaChunk(session, {
714
- confidence: undefined,
715
- risk: 'low',
716
- explain: 'Processing...'
717
- });
718
- yield metaChunk;
719
- onChunk?.(metaChunk);
720
-
721
- // Build prompt and messages (same as sync)
722
- const { input, args, verbose = false, useEnvelope, useV22 } = runOptions;
1171
+ }
1172
+
1173
+ // =============================================================================
1174
+ // Main Runner
1175
+ // =============================================================================
1176
+
1177
+ export async function runModule(
1178
+ module: CognitiveModule,
1179
+ provider: Provider,
1180
+ options: RunOptions = {}
1181
+ ): Promise<ModuleResult> {
1182
+ const { args, input, verbose = false, validateInput = true, validateOutput = true, useEnvelope, useV22, enableRepair = true, traceId, model: modelOverride } = options;
1183
+ const startTime = Date.now();
1184
+
1185
+ // Determine if we should use envelope format
723
1186
  const shouldUseEnvelope = useEnvelope ?? (module.output?.envelope === true || module.format === 'v2');
1187
+
1188
+ // Determine if we should use v2.2 format
724
1189
  const isV22Module = module.tier !== undefined || module.formatVersion === 'v2.2';
725
1190
  const shouldUseV22 = useV22 ?? (isV22Module || module.compat?.runtime_auto_wrap === true);
726
- const riskRule: RiskRule = module.metaConfig?.risk_rule ?? 'max_changes_risk';
727
1191
 
1192
+ // Get risk_rule from module config
1193
+ const riskRule: RiskRule = module.metaConfig?.risk_rule ?? 'max_changes_risk';
1194
+
1195
+ // Build clean input data (v2 style: no $ARGUMENTS pollution)
728
1196
  const inputData: ModuleInput = input || {};
1197
+
1198
+ // Map legacy --args to clean input
729
1199
  if (args && !inputData.code && !inputData.query) {
1200
+ // Determine if args looks like code or natural language
730
1201
  if (looksLikeCode(args)) {
731
1202
  inputData.code = args;
732
1203
  } else {
733
1204
  inputData.query = args;
734
1205
  }
735
1206
  }
736
-
737
- // Extract media from input
738
- const mediaInputs = extractMediaInputs(inputData);
739
-
740
- // Build prompt with media placeholders
741
- const prompt = buildPromptWithMedia(module, inputData, mediaInputs);
742
-
743
- // Build system message
744
- const systemParts = buildSystemMessage(module, shouldUseEnvelope, shouldUseV22);
745
-
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
+
1228
+ // Build prompt with clean substitution
1229
+ const prompt = buildPrompt(module, inputData);
1230
+
1231
+ if (verbose) {
1232
+ console.error('--- Module ---');
1233
+ console.error(`Name: ${module.name} (${module.format})`);
1234
+ console.error(`Responsibility: ${module.responsibility}`);
1235
+ console.error(`Envelope: ${shouldUseEnvelope}`);
1236
+ console.error('--- Input ---');
1237
+ console.error(JSON.stringify(inputData, null, 2));
1238
+ console.error('--- Prompt ---');
1239
+ console.error(prompt);
1240
+ console.error('--- End ---');
1241
+ }
1242
+
1243
+ // Build system message based on module config
1244
+ const systemParts: string[] = [
1245
+ `You are executing the "${module.name}" Cognitive Module.`,
1246
+ '',
1247
+ `RESPONSIBILITY: ${module.responsibility}`,
1248
+ ];
1249
+
1250
+ if (module.excludes.length > 0) {
1251
+ systemParts.push('', 'YOU MUST NOT:');
1252
+ module.excludes.forEach(e => systemParts.push(`- ${e}`));
1253
+ }
1254
+
1255
+ if (module.constraints) {
1256
+ systemParts.push('', 'CONSTRAINTS:');
1257
+ if (module.constraints.no_network) systemParts.push('- No network access');
1258
+ if (module.constraints.no_side_effects) systemParts.push('- No side effects');
1259
+ if (module.constraints.no_file_write) systemParts.push('- No file writes');
1260
+ if (module.constraints.no_inventing_data) systemParts.push('- Do not invent data');
1261
+ }
1262
+
1263
+ if (module.output?.require_behavior_equivalence) {
1264
+ systemParts.push('', 'BEHAVIOR EQUIVALENCE:');
1265
+ systemParts.push('- You MUST set behavior_equivalence=true ONLY if the output is functionally identical');
1266
+ systemParts.push('- If unsure, set behavior_equivalence=false and explain in rationale');
1267
+
1268
+ const maxConfidence = module.constraints?.behavior_equivalence_false_max_confidence ?? 0.7;
1269
+ systemParts.push(`- If behavior_equivalence=false, confidence MUST be <= ${maxConfidence}`);
1270
+ }
1271
+
1272
+ // Add envelope format instructions
1273
+ if (shouldUseEnvelope) {
1274
+ if (shouldUseV22) {
1275
+ systemParts.push('', 'RESPONSE FORMAT (Envelope v2.2):');
1276
+ systemParts.push('- Wrap your response in the v2.2 envelope format with separate meta and data');
1277
+ systemParts.push('- Success: { "ok": true, "meta": { "confidence": 0.9, "risk": "low", "explain": "short summary" }, "data": { ...payload... } }');
1278
+ systemParts.push('- Error: { "ok": false, "meta": { "confidence": 0.0, "risk": "high", "explain": "error summary" }, "error": { "code": "ERROR_CODE", "message": "..." } }');
1279
+ systemParts.push('- meta.explain must be ≤280 characters. data.rationale can be longer for detailed reasoning.');
1280
+ systemParts.push('- meta.risk must be one of: "none", "low", "medium", "high"');
1281
+ } else {
1282
+ systemParts.push('', 'RESPONSE FORMAT (Envelope):');
1283
+ systemParts.push('- Wrap your response in the envelope format');
1284
+ systemParts.push('- Success: { "ok": true, "data": { ...your output... } }');
1285
+ systemParts.push('- Error: { "ok": false, "error": { "code": "ERROR_CODE", "message": "..." } }');
1286
+ systemParts.push('- Include "confidence" (0-1) and "rationale" in data');
1287
+ }
1288
+ if (module.output?.require_behavior_equivalence) {
1289
+ systemParts.push('- Include "behavior_equivalence" (boolean) in data');
1290
+ }
1291
+ } else {
1292
+ systemParts.push('', 'OUTPUT FORMAT:');
1293
+ systemParts.push('- Respond with ONLY valid JSON');
1294
+ systemParts.push('- Include "confidence" (0-1) and "rationale" fields');
1295
+ if (module.output?.require_behavior_equivalence) {
1296
+ systemParts.push('- Include "behavior_equivalence" (boolean) field');
1297
+ }
1298
+ }
1299
+
746
1300
  const messages: Message[] = [
747
1301
  { role: 'system', content: systemParts.join('\n') },
748
1302
  { role: 'user', content: prompt },
749
1303
  ];
750
-
1304
+
751
1305
  try {
752
- // Start streaming invocation
753
- const streamResult = await provider.invokeStream!({
1306
+ // Invoke provider
1307
+ const result = await provider.invoke({
754
1308
  messages,
755
1309
  jsonSchema: module.outputSchema,
756
1310
  temperature: 0.3,
757
- stream: true,
758
- images: mediaInputs.images,
759
- audio: mediaInputs.audio,
760
- video: mediaInputs.video
761
1311
  });
762
-
763
- let accumulatedContent = '';
764
- let lastProgressTime = Date.now();
765
-
766
- // Process stream
767
- for await (const chunk of streamResult.stream) {
768
- // Check timeout
769
- if (Date.now() - startTime > maxDuration) {
770
- const errorChunk = createErrorChunk(
771
- session,
772
- ErrorCodesV25.STREAM_TIMEOUT,
773
- `Stream exceeded max duration of ${maxDuration}ms`,
774
- false,
775
- { partial_content: accumulatedContent }
776
- );
777
- yield errorChunk;
778
- onChunk?.(errorChunk);
779
- return undefined;
1312
+
1313
+ if (verbose) {
1314
+ console.error('--- Response ---');
1315
+ console.error(result.content);
1316
+ console.error('--- End Response ---');
1317
+ }
1318
+
1319
+ // Calculate latency
1320
+ const latencyMs = Date.now() - startTime;
1321
+
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;
1337
+ }
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>);
1347
+ }
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
+ }
780
1395
  }
781
1396
 
782
- // Accumulate content
783
- accumulatedContent += chunk;
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
+ }
784
1410
 
785
- // Emit delta chunk
786
- const deltaChunk = createDeltaChunk(session, 'data.rationale', chunk);
787
- yield deltaChunk;
788
- onChunk?.(deltaChunk);
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
+ }
789
1424
 
790
- // Emit progress periodically
791
- const now = Date.now();
792
- if (now - lastProgressTime > 1000) {
793
- const elapsed = now - startTime;
794
- const estimatedPercent = Math.min(90, Math.floor(elapsed / maxDuration * 100));
795
- const progressChunk = createProgressChunk(
796
- session,
797
- estimatedPercent,
798
- 'generating',
799
- 'Generating response...'
800
- );
801
- yield progressChunk;
802
- onProgress?.(estimatedPercent, 'Generating response...');
803
- lastProgressTime = now;
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
+ }
804
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;
805
1456
  }
806
-
807
- // Parse accumulated response
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;
1477
+ }
1478
+ }
1479
+
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 {
1538
+ return {
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
808
1613
  let parsed: unknown;
809
1614
  try {
810
- const jsonMatch = accumulatedContent.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
811
- const jsonStr = jsonMatch ? jsonMatch[1] : accumulatedContent;
1615
+ const jsonMatch = result.content.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
1616
+ const jsonStr = jsonMatch ? jsonMatch[1] : result.content;
812
1617
  parsed = JSON.parse(jsonStr.trim());
813
- } catch {
814
- // Try to extract partial JSON
815
- const errorChunk = createErrorChunk(
816
- session,
817
- 'E3001',
818
- `Failed to parse JSON response`,
819
- false,
820
- { raw: accumulatedContent }
821
- );
822
- yield errorChunk;
823
- onChunk?.(errorChunk);
824
- return undefined;
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;
825
1628
  }
826
-
827
- // Process parsed response
828
- let result: ModuleResult;
829
- if (shouldUseEnvelope && typeof parsed === 'object' && parsed !== null && 'ok' in parsed) {
830
- const response = parseEnvelopeResponseLocal(parsed as EnvelopeResponse<unknown>, accumulatedContent);
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;
831
1659
 
832
- if (shouldUseV22 && response.ok && !('meta' in response && response.meta)) {
833
- const upgraded = wrapV21ToV22Local(parsed as EnvelopeResponse<unknown>, riskRule);
834
- result = {
835
- ok: true,
836
- meta: upgraded.meta as EnvelopeMeta,
837
- data: (upgraded as { data?: ModuleResultData }).data,
838
- raw: accumulatedContent
839
- } as ModuleResultV22;
840
- } else {
841
- result = response;
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
+ }
842
1685
  }
843
- } else {
844
- result = parseLegacyResponseLocal(parsed, accumulatedContent);
845
1686
 
846
- if (shouldUseV22 && result.ok) {
847
- const data = (result.data ?? {}) as Record<string, unknown>;
848
- result = {
849
- ok: true,
850
- meta: {
851
- confidence: (data.confidence as number) ?? 0.5,
852
- risk: aggregateRisk(data, riskRule),
853
- explain: ((data.rationale as string) ?? '').slice(0, 280) || 'No explanation provided'
854
- },
855
- data: result.data,
856
- raw: accumulatedContent
857
- } as ModuleResultV22;
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
+ }
858
1710
  }
1711
+ } else if (!response.ok && enableRepair) {
1712
+ response = repairErrorEnvelope(response as unknown as Record<string, unknown>);
1713
+ response.version = ENVELOPE_VERSION;
859
1714
  }
860
-
861
- // Emit final chunk
862
- if (result.ok && 'meta' in result) {
863
- const finalChunk = createFinalChunk(
864
- session,
865
- result.meta,
866
- result.data as ModuleResultData,
867
- streamResult.usage ? {
868
- input_tokens: streamResult.usage.promptTokens,
869
- output_tokens: streamResult.usage.completionTokens,
870
- total_tokens: streamResult.usage.totalTokens
871
- } : undefined
872
- );
873
- yield finalChunk;
874
- onChunk?.(finalChunk);
875
- onProgress?.(100, 'Complete');
876
- }
877
-
878
- return result;
879
-
880
- } catch (error) {
881
- const errorChunk = createErrorChunk(
882
- session,
883
- ErrorCodesV25.STREAM_INTERRUPTED,
884
- error instanceof Error ? error.message : 'Stream interrupted',
885
- true
886
- );
887
- yield errorChunk;
888
- onChunk?.(errorChunk);
889
- return undefined;
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 });
890
1729
  }
891
1730
  }
892
1731
 
893
- // Local versions of helper functions to avoid circular issues
894
- function parseEnvelopeResponseLocal(response: EnvelopeResponse<unknown>, raw: string): ModuleResult {
1732
+ // =============================================================================
1733
+ // Helper Functions
1734
+ // =============================================================================
1735
+
1736
+ /**
1737
+ * Check if response is in envelope format
1738
+ */
1739
+ function isEnvelopeResponse(obj: unknown): obj is EnvelopeResponse {
1740
+ if (typeof obj !== 'object' || obj === null) return false;
1741
+ const o = obj as Record<string, unknown>;
1742
+ return typeof o.ok === 'boolean';
1743
+ }
1744
+
1745
+ /**
1746
+ * Parse envelope format response (supports both v2.1 and v2.2)
1747
+ */
1748
+ function parseEnvelopeResponse(response: EnvelopeResponse<unknown>, raw: string): ModuleResult {
1749
+ // Check if v2.2 format (has meta)
895
1750
  if (isV22Envelope(response)) {
896
1751
  if (response.ok) {
897
1752
  return {
@@ -911,6 +1766,7 @@ function parseEnvelopeResponseLocal(response: EnvelopeResponse<unknown>, raw: st
911
1766
  }
912
1767
  }
913
1768
 
1769
+ // v2.1 format
914
1770
  if (response.ok) {
915
1771
  const data = (response.data ?? {}) as ModuleResultData & { confidence?: number };
916
1772
  return {
@@ -933,44 +1789,10 @@ function parseEnvelopeResponseLocal(response: EnvelopeResponse<unknown>, raw: st
933
1789
  }
934
1790
  }
935
1791
 
936
- function wrapV21ToV22Local(
937
- response: EnvelopeResponse<unknown>,
938
- riskRule: RiskRule = 'max_changes_risk'
939
- ): EnvelopeResponseV22<unknown> {
940
- if (isV22Envelope(response)) {
941
- return response;
942
- }
943
-
944
- if (response.ok) {
945
- const data = (response.data ?? {}) as Record<string, unknown>;
946
- const confidence = (data.confidence as number) ?? 0.5;
947
- const rationale = (data.rationale as string) ?? '';
948
-
949
- return {
950
- ok: true,
951
- meta: {
952
- confidence,
953
- risk: aggregateRisk(data, riskRule),
954
- explain: rationale.slice(0, 280) || 'No explanation provided'
955
- },
956
- data: data as ModuleResultData
957
- };
958
- } else {
959
- const errorMsg = response.error?.message ?? 'Unknown error';
960
- return {
961
- ok: false,
962
- meta: {
963
- confidence: 0,
964
- risk: 'high',
965
- explain: errorMsg.slice(0, 280)
966
- },
967
- error: response.error ?? { code: 'UNKNOWN', message: errorMsg },
968
- partial_data: response.partial_data
969
- };
970
- }
971
- }
972
-
973
- function parseLegacyResponseLocal(output: unknown, raw: string): ModuleResult {
1792
+ /**
1793
+ * Parse legacy (non-envelope) format response
1794
+ */
1795
+ function parseLegacyResponse(output: unknown, raw: string): ModuleResult {
974
1796
  const outputObj = output as Record<string, unknown>;
975
1797
  const confidence = typeof outputObj.confidence === 'number' ? outputObj.confidence : 0.5;
976
1798
  const rationale = typeof outputObj.rationale === 'string' ? outputObj.rationale : '';
@@ -978,6 +1800,7 @@ function parseLegacyResponseLocal(output: unknown, raw: string): ModuleResult {
978
1800
  ? outputObj.behavior_equivalence
979
1801
  : undefined;
980
1802
 
1803
+ // Check if this is an error response (has error.code)
981
1804
  if (outputObj.error && typeof outputObj.error === 'object') {
982
1805
  const errorObj = outputObj.error as Record<string, unknown>;
983
1806
  if (typeof errorObj.code === 'string') {
@@ -992,6 +1815,7 @@ function parseLegacyResponseLocal(output: unknown, raw: string): ModuleResult {
992
1815
  }
993
1816
  }
994
1817
 
1818
+ // Return as v2.1 format (data includes confidence)
995
1819
  return {
996
1820
  ok: true,
997
1821
  data: {
@@ -1004,286 +1828,156 @@ function parseLegacyResponseLocal(output: unknown, raw: string): ModuleResult {
1004
1828
  } as ModuleResultV21;
1005
1829
  }
1006
1830
 
1007
- // =============================================================================
1008
- // v2.5 Multimodal Support
1009
- // =============================================================================
1010
-
1011
- interface ExtractedMedia {
1012
- images: MediaInput[];
1013
- audio: MediaInput[];
1014
- video: MediaInput[];
1015
- }
1016
-
1017
- /**
1018
- * Extract media inputs from module input data
1019
- */
1020
- function extractMediaInputs(input: ModuleInput): ExtractedMedia {
1021
- const images: MediaInput[] = [];
1022
- const audio: MediaInput[] = [];
1023
- const video: MediaInput[] = [];
1024
-
1025
- // Check for images array
1026
- if (Array.isArray(input.images)) {
1027
- for (const img of input.images) {
1028
- if (isValidMediaInput(img)) {
1029
- images.push(img);
1030
- }
1031
- }
1032
- }
1033
-
1034
- // Check for audio array
1035
- if (Array.isArray(input.audio)) {
1036
- for (const aud of input.audio) {
1037
- if (isValidMediaInput(aud)) {
1038
- audio.push(aud);
1039
- }
1040
- }
1041
- }
1042
-
1043
- // Check for video array
1044
- if (Array.isArray(input.video)) {
1045
- for (const vid of input.video) {
1046
- if (isValidMediaInput(vid)) {
1047
- video.push(vid);
1048
- }
1049
- }
1050
- }
1051
-
1052
- return { images, audio, video };
1053
- }
1054
-
1055
- /**
1056
- * Validate media input structure
1057
- */
1058
- function isValidMediaInput(input: unknown): input is MediaInput {
1059
- if (typeof input !== 'object' || input === null) return false;
1060
- const obj = input as Record<string, unknown>;
1061
-
1062
- if (obj.type === 'url' && typeof obj.url === 'string') return true;
1063
- if (obj.type === 'base64' && typeof obj.data === 'string' && typeof obj.media_type === 'string') return true;
1064
- if (obj.type === 'file' && typeof obj.path === 'string') return true;
1065
-
1066
- return false;
1067
- }
1068
-
1069
- /**
1070
- * Build prompt with media placeholders
1071
- */
1072
- function buildPromptWithMedia(
1073
- module: CognitiveModule,
1074
- input: ModuleInput,
1075
- media: ExtractedMedia
1076
- ): string {
1077
- let prompt = buildPrompt(module, input);
1078
-
1079
- // Replace $MEDIA_INPUTS placeholder
1080
- if (prompt.includes('$MEDIA_INPUTS')) {
1081
- const mediaSummary = buildMediaSummary(media);
1082
- prompt = prompt.replace(/\$MEDIA_INPUTS/g, mediaSummary);
1083
- }
1084
-
1085
- return prompt;
1086
- }
1087
-
1088
- /**
1089
- * Build summary of media inputs for prompt
1090
- */
1091
- function buildMediaSummary(media: ExtractedMedia): string {
1092
- const parts: string[] = [];
1093
-
1094
- if (media.images.length > 0) {
1095
- parts.push(`[${media.images.length} image(s) attached]`);
1096
- }
1097
- if (media.audio.length > 0) {
1098
- parts.push(`[${media.audio.length} audio file(s) attached]`);
1099
- }
1100
- if (media.video.length > 0) {
1101
- parts.push(`[${media.video.length} video file(s) attached]`);
1102
- }
1103
-
1104
- return parts.length > 0 ? parts.join('\n') : '[No media attached]';
1105
- }
1106
-
1107
1831
  /**
1108
- * Build system message for module execution
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)
1109
1839
  */
1110
- function buildSystemMessage(
1111
- module: CognitiveModule,
1112
- shouldUseEnvelope: boolean,
1113
- shouldUseV22: boolean
1114
- ): string[] {
1115
- const systemParts: string[] = [
1116
- `You are executing the "${module.name}" Cognitive Module.`,
1117
- '',
1118
- `RESPONSIBILITY: ${module.responsibility}`,
1119
- ];
1840
+ function buildPrompt(module: CognitiveModule, input: ModuleInput): string {
1841
+ let prompt = module.prompt;
1120
1842
 
1121
- if (module.excludes.length > 0) {
1122
- systemParts.push('', 'YOU MUST NOT:');
1123
- module.excludes.forEach(e => systemParts.push(`- ${e}`));
1843
+ // v2 style: substitute ${variable} placeholders
1844
+ for (const [key, value] of Object.entries(input)) {
1845
+ const strValue = typeof value === 'string' ? value : JSON.stringify(value);
1846
+ prompt = prompt.replace(new RegExp(`\\$\\{${key}\\}`, 'g'), strValue);
1124
1847
  }
1125
1848
 
1126
- if (module.constraints) {
1127
- systemParts.push('', 'CONSTRAINTS:');
1128
- if (module.constraints.no_network) systemParts.push('- No network access');
1129
- if (module.constraints.no_side_effects) systemParts.push('- No side effects');
1130
- if (module.constraints.no_file_write) systemParts.push('- No file writes');
1131
- if (module.constraints.no_inventing_data) systemParts.push('- Do not invent data');
1132
- }
1849
+ // v1 compatibility: get args value
1850
+ const argsValue = input.code || input.query || '';
1133
1851
 
1134
- if (module.output?.require_behavior_equivalence) {
1135
- systemParts.push('', 'BEHAVIOR EQUIVALENCE:');
1136
- systemParts.push('- You MUST set behavior_equivalence=true ONLY if the output is functionally identical');
1137
- systemParts.push('- If unsure, set behavior_equivalence=false and explain in rationale');
1138
-
1139
- const maxConfidence = module.constraints?.behavior_equivalence_false_max_confidence ?? 0.7;
1140
- systemParts.push(`- If behavior_equivalence=false, confidence MUST be <= ${maxConfidence}`);
1852
+ // Substitute $ARGUMENTS[N] and $N placeholders FIRST (v1 compatibility)
1853
+ // Process in descending order to avoid $1 replacing part of $10
1854
+ if (typeof argsValue === 'string') {
1855
+ const argsList = argsValue.split(/\s+/);
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
1861
+ prompt = prompt.replace(new RegExp(`\\$${i}\\b`, 'g'), arg);
1862
+ }
1141
1863
  }
1142
1864
 
1143
- // Add multimodal instructions if module supports it
1144
- if (isModuleV25(module) && moduleSupportsMultimodal(module)) {
1145
- const inputModalities = getModuleInputModalities(module);
1146
- systemParts.push('', 'MULTIMODAL INPUT:');
1147
- systemParts.push(`- This module accepts: ${inputModalities.join(', ')}`);
1148
- systemParts.push('- Analyze any attached media carefully');
1149
- systemParts.push('- Reference specific elements from the media in your analysis');
1150
- }
1865
+ // Replace $ARGUMENTS LAST (after indexed forms to avoid partial matches)
1866
+ prompt = prompt.replace(/\$ARGUMENTS/g, argsValue);
1151
1867
 
1152
- // Add envelope format instructions
1153
- if (shouldUseEnvelope) {
1154
- if (shouldUseV22) {
1155
- systemParts.push('', 'RESPONSE FORMAT (Envelope v2.2):');
1156
- systemParts.push('- Wrap your response in the v2.2 envelope format with separate meta and data');
1157
- systemParts.push('- Success: { "ok": true, "meta": { "confidence": 0.9, "risk": "low", "explain": "short summary" }, "data": { ...payload... } }');
1158
- systemParts.push('- Error: { "ok": false, "meta": { "confidence": 0.0, "risk": "high", "explain": "error summary" }, "error": { "code": "ERROR_CODE", "message": "..." } }');
1159
- systemParts.push('- meta.explain must be ≤280 characters. data.rationale can be longer for detailed reasoning.');
1160
- systemParts.push('- meta.risk must be one of: "none", "low", "medium", "high"');
1161
- } else {
1162
- systemParts.push('', 'RESPONSE FORMAT (Envelope):');
1163
- systemParts.push('- Wrap your response in the envelope format');
1164
- systemParts.push('- Success: { "ok": true, "data": { ...your output... } }');
1165
- systemParts.push('- Error: { "ok": false, "error": { "code": "ERROR_CODE", "message": "..." } }');
1166
- systemParts.push('- Include "confidence" (0-1) and "rationale" in data');
1868
+ // Append input summary if not already in prompt
1869
+ if (!prompt.includes(argsValue) && argsValue) {
1870
+ prompt += '\n\n## Input\n\n';
1871
+ if (input.code) {
1872
+ prompt += '```\n' + input.code + '\n```\n';
1167
1873
  }
1168
- if (module.output?.require_behavior_equivalence) {
1169
- systemParts.push('- Include "behavior_equivalence" (boolean) in data');
1874
+ if (input.query) {
1875
+ prompt += input.query + '\n';
1170
1876
  }
1171
- } else {
1172
- systemParts.push('', 'OUTPUT FORMAT:');
1173
- systemParts.push('- Respond with ONLY valid JSON');
1174
- systemParts.push('- Include "confidence" (0-1) and "rationale" fields');
1175
- if (module.output?.require_behavior_equivalence) {
1176
- systemParts.push('- Include "behavior_equivalence" (boolean) field');
1877
+ if (input.language) {
1878
+ prompt += `\nLanguage: ${input.language}\n`;
1177
1879
  }
1178
1880
  }
1179
1881
 
1180
- return systemParts;
1882
+ return prompt;
1181
1883
  }
1182
1884
 
1183
1885
  /**
1184
- * Load media file as base64
1886
+ * Heuristic to detect if input looks like code
1185
1887
  */
1186
- export async function loadMediaAsBase64(path: string): Promise<{ data: string; media_type: string } | null> {
1187
- try {
1188
- if (!existsSync(path)) {
1189
- return null;
1190
- }
1191
-
1192
- const buffer = await readFile(path);
1193
- const data = buffer.toString('base64');
1194
- const media_type = getMediaTypeFromExtension(extname(path));
1195
-
1196
- return { data, media_type };
1197
- } catch {
1198
- return null;
1199
- }
1888
+ function looksLikeCode(str: string): boolean {
1889
+ const codeIndicators = [
1890
+ /^(def|function|class|const|let|var|import|export|public|private)\s/,
1891
+ /[{};()]/,
1892
+ /=>/,
1893
+ /\.(py|js|ts|go|rs|java|cpp|c|rb)$/,
1894
+ ];
1895
+ return codeIndicators.some(re => re.test(str));
1200
1896
  }
1201
1897
 
1202
- /**
1203
- * Get MIME type from file extension
1204
- */
1205
- function getMediaTypeFromExtension(ext: string): string {
1206
- const mimeTypes: Record<string, string> = {
1207
- '.jpg': 'image/jpeg',
1208
- '.jpeg': 'image/jpeg',
1209
- '.png': 'image/png',
1210
- '.gif': 'image/gif',
1211
- '.webp': 'image/webp',
1212
- '.mp3': 'audio/mpeg',
1213
- '.wav': 'audio/wav',
1214
- '.ogg': 'audio/ogg',
1215
- '.webm': 'audio/webm',
1216
- '.mp4': 'video/mp4',
1217
- '.mov': 'video/quicktime',
1218
- '.pdf': 'application/pdf'
1219
- };
1220
-
1221
- return mimeTypes[ext.toLowerCase()] ?? 'application/octet-stream';
1898
+ // =============================================================================
1899
+ // Legacy API (for backward compatibility)
1900
+ // =============================================================================
1901
+
1902
+ export interface RunModuleLegacyOptions {
1903
+ validateInput?: boolean;
1904
+ validateOutput?: boolean;
1905
+ model?: string;
1222
1906
  }
1223
1907
 
1224
1908
  /**
1225
- * Validate media input against module constraints
1909
+ * Run a cognitive module (legacy API, returns raw output).
1910
+ * For backward compatibility. Throws on error instead of returning error envelope.
1226
1911
  */
1227
- export function validateMediaInput(
1228
- media: MediaInput,
1912
+ export async function runModuleLegacy(
1229
1913
  module: CognitiveModule,
1230
- maxSizeMb: number = 20
1231
- ): { valid: boolean; error?: string; code?: string } {
1232
- // Check if module supports multimodal
1233
- if (!moduleSupportsMultimodal(module)) {
1234
- return {
1235
- valid: false,
1236
- error: 'Module does not support multimodal input',
1237
- code: ErrorCodesV25.MULTIMODAL_NOT_SUPPORTED
1238
- };
1239
- }
1240
-
1241
- // Validate media type
1242
- if (media.type === 'base64') {
1243
- const mediaType = (media as { media_type: string }).media_type;
1244
- if (!isValidMediaType(mediaType)) {
1245
- return {
1246
- valid: false,
1247
- error: `Unsupported media type: ${mediaType}`,
1248
- code: ErrorCodesV25.UNSUPPORTED_MEDIA_TYPE
1249
- };
1250
- }
1251
- }
1914
+ provider: Provider,
1915
+ input: ModuleInput,
1916
+ options: RunModuleLegacyOptions = {}
1917
+ ): Promise<unknown> {
1918
+ const { validateInput = true, validateOutput = true, model } = options;
1252
1919
 
1253
- // Size validation would require fetching/checking actual data
1254
- // This is a placeholder for the check
1920
+ const result = await runModule(module, provider, {
1921
+ input,
1922
+ validateInput,
1923
+ validateOutput,
1924
+ useEnvelope: false,
1925
+ useV22: false,
1926
+ model,
1927
+ });
1255
1928
 
1256
- return { valid: true };
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
+ }
1257
1935
  }
1258
1936
 
1259
- /**
1260
- * Check if media type is supported
1261
- */
1262
- function isValidMediaType(mediaType: string): boolean {
1263
- const supported = [
1264
- 'image/jpeg', 'image/png', 'image/webp', 'image/gif',
1265
- 'audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/webm',
1266
- 'video/mp4', 'video/webm', 'video/quicktime',
1267
- 'application/pdf'
1268
- ];
1269
-
1270
- return supported.includes(mediaType);
1271
- }
1937
+ // =============================================================================
1938
+ // Convenience Functions
1939
+ // =============================================================================
1272
1940
 
1273
1941
  /**
1274
- * Get runtime capabilities
1942
+ * Extract meta from v2.2 envelope for routing/logging.
1275
1943
  */
1276
- export function getRuntimeCapabilities(): RuntimeCapabilities {
1277
- return { ...DEFAULT_RUNTIME_CAPABILITIES };
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
+ };
1278
1950
  }
1279
1951
 
1952
+ // Alias for backward compatibility
1953
+ export const extractMetaV22 = extractMeta;
1954
+
1280
1955
  /**
1281
- * Check if runtime supports a specific modality
1956
+ * Determine if result should be escalated to human review based on meta.
1282
1957
  */
1283
- export function runtimeSupportsModality(
1284
- modality: ModalityType,
1285
- direction: 'input' | 'output' = 'input'
1958
+ export function shouldEscalate(
1959
+ result: EnvelopeResponseV22<unknown>,
1960
+ confidenceThreshold: number = 0.7
1286
1961
  ): boolean {
1287
- const caps = getRuntimeCapabilities();
1288
- return caps.multimodal[direction].includes(modality);
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;
1289
1980
  }
1981
+
1982
+ // Alias for backward compatibility
1983
+ export const shouldEscalateV22 = shouldEscalate;