cognitive-modules 0.4.0__py3-none-any.whl → 0.5.0__py3-none-any.whl

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.
cognitive/migrate.py ADDED
@@ -0,0 +1,624 @@
1
+ """
2
+ Module Migration Tool - Migrate v1/v2.1 modules to v2.2 format.
3
+
4
+ Migration includes:
5
+ - Creating module.yaml with tier, overflow, enums, compat
6
+ - Updating schema.json with meta schema
7
+ - Creating/updating prompt.md with v2.2 envelope instructions
8
+ - Preserving MODULE.md for backward compatibility
9
+ """
10
+
11
+ import json
12
+ import shutil
13
+ from pathlib import Path
14
+ from typing import Optional
15
+ from datetime import datetime
16
+
17
+ import yaml
18
+
19
+ from .registry import find_module, list_modules
20
+ from .loader import detect_format, parse_frontmatter
21
+
22
+
23
+ # =============================================================================
24
+ # Migration Entry Points
25
+ # =============================================================================
26
+
27
+ def migrate_module(
28
+ name_or_path: str,
29
+ dry_run: bool = False,
30
+ backup: bool = True
31
+ ) -> tuple[bool, list[str], list[str]]:
32
+ """
33
+ Migrate a single module to v2.2 format.
34
+
35
+ Args:
36
+ name_or_path: Module name or path
37
+ dry_run: If True, only show what would be done
38
+ backup: If True, create backup before migration
39
+
40
+ Returns:
41
+ Tuple of (success, changes, warnings)
42
+ """
43
+ changes = []
44
+ warnings = []
45
+
46
+ # Find module
47
+ path = Path(name_or_path)
48
+ if path.exists() and path.is_dir():
49
+ module_path = path
50
+ else:
51
+ module_path = find_module(name_or_path)
52
+ if not module_path:
53
+ return False, [], [f"Module not found: {name_or_path}"]
54
+
55
+ # Detect current format
56
+ try:
57
+ fmt = detect_format(module_path)
58
+ except FileNotFoundError as e:
59
+ return False, [], [str(e)]
60
+
61
+ # Check if already v2.2
62
+ if fmt == "v2":
63
+ module_yaml_path = module_path / "module.yaml"
64
+ if module_yaml_path.exists():
65
+ with open(module_yaml_path, 'r', encoding='utf-8') as f:
66
+ manifest = yaml.safe_load(f)
67
+ if manifest.get('tier') is not None:
68
+ warnings.append("Module appears to already be v2.2 format")
69
+ return True, [], warnings
70
+
71
+ # Create backup
72
+ if backup and not dry_run:
73
+ backup_path = _create_backup(module_path)
74
+ changes.append(f"Created backup: {backup_path}")
75
+
76
+ # Perform migration based on format
77
+ if fmt == "v0":
78
+ return _migrate_from_v0(module_path, dry_run, changes, warnings)
79
+ elif fmt == "v1":
80
+ return _migrate_from_v1(module_path, dry_run, changes, warnings)
81
+ elif fmt == "v2":
82
+ return _migrate_from_v2(module_path, dry_run, changes, warnings)
83
+ else:
84
+ return False, [], [f"Unknown format: {fmt}"]
85
+
86
+
87
+ def migrate_all_modules(
88
+ dry_run: bool = False,
89
+ backup: bool = True
90
+ ) -> list[tuple[str, bool, list[str], list[str]]]:
91
+ """
92
+ Migrate all installed modules to v2.2 format.
93
+
94
+ Returns:
95
+ List of (module_name, success, changes, warnings) tuples
96
+ """
97
+ results = []
98
+ modules = list_modules()
99
+
100
+ for module in modules:
101
+ name = module.get('name', 'unknown')
102
+ path = module.get('path')
103
+
104
+ if path:
105
+ success, changes, warnings = migrate_module(
106
+ str(path),
107
+ dry_run=dry_run,
108
+ backup=backup
109
+ )
110
+ results.append((name, success, changes, warnings))
111
+
112
+ return results
113
+
114
+
115
+ # =============================================================================
116
+ # Format-Specific Migration
117
+ # =============================================================================
118
+
119
+ def _migrate_from_v0(
120
+ module_path: Path,
121
+ dry_run: bool,
122
+ changes: list[str],
123
+ warnings: list[str]
124
+ ) -> tuple[bool, list[str], list[str]]:
125
+ """Migrate from v0 (6-file) format."""
126
+ warnings.append("v0 format migration requires manual review")
127
+
128
+ # Load existing data
129
+ module_md_path = module_path / "module.md"
130
+ with open(module_md_path, 'r', encoding='utf-8') as f:
131
+ content = f.read()
132
+
133
+ frontmatter, _ = parse_frontmatter(content)
134
+
135
+ # Load prompt
136
+ prompt_txt_path = module_path / "prompt.txt"
137
+ with open(prompt_txt_path, 'r', encoding='utf-8') as f:
138
+ prompt = f.read()
139
+
140
+ # Load schemas
141
+ with open(module_path / "input.schema.json", 'r', encoding='utf-8') as f:
142
+ input_schema = json.load(f)
143
+ with open(module_path / "output.schema.json", 'r', encoding='utf-8') as f:
144
+ output_schema = json.load(f)
145
+
146
+ # Create module.yaml
147
+ manifest = _create_v22_manifest(frontmatter)
148
+
149
+ # Create combined schema.json
150
+ schema = _create_v22_schema(input_schema, output_schema)
151
+
152
+ # Create prompt.md
153
+ prompt_md = _create_v22_prompt(frontmatter, prompt)
154
+
155
+ if dry_run:
156
+ changes.append("[DRY RUN] Would create module.yaml")
157
+ changes.append("[DRY RUN] Would create schema.json (combined)")
158
+ changes.append("[DRY RUN] Would create prompt.md")
159
+ else:
160
+ # Write files
161
+ _write_yaml(module_path / "module.yaml", manifest)
162
+ changes.append("Created module.yaml")
163
+
164
+ _write_json(module_path / "schema.json", schema)
165
+ changes.append("Created schema.json (combined)")
166
+
167
+ _write_text(module_path / "prompt.md", prompt_md)
168
+ changes.append("Created prompt.md")
169
+
170
+ return True, changes, warnings
171
+
172
+
173
+ def _migrate_from_v1(
174
+ module_path: Path,
175
+ dry_run: bool,
176
+ changes: list[str],
177
+ warnings: list[str]
178
+ ) -> tuple[bool, list[str], list[str]]:
179
+ """Migrate from v1 (MODULE.md + schema.json) format."""
180
+ # Load MODULE.md
181
+ module_md_path = module_path / "MODULE.md"
182
+ with open(module_md_path, 'r', encoding='utf-8') as f:
183
+ content = f.read()
184
+
185
+ frontmatter, prompt_body = parse_frontmatter(content)
186
+
187
+ # Load schema.json if exists
188
+ schema_path = module_path / "schema.json"
189
+ if schema_path.exists():
190
+ with open(schema_path, 'r', encoding='utf-8') as f:
191
+ schema = json.load(f)
192
+ input_schema = schema.get('input', {})
193
+ output_schema = schema.get('output', {})
194
+ else:
195
+ input_schema = {}
196
+ output_schema = {}
197
+
198
+ # Create module.yaml
199
+ manifest = _create_v22_manifest(frontmatter)
200
+
201
+ # Create/update schema.json with meta
202
+ new_schema = _create_v22_schema(input_schema, output_schema)
203
+
204
+ # Create prompt.md
205
+ prompt_md = _create_v22_prompt(frontmatter, prompt_body)
206
+
207
+ if dry_run:
208
+ changes.append("[DRY RUN] Would create module.yaml")
209
+ changes.append("[DRY RUN] Would update schema.json (add meta)")
210
+ changes.append("[DRY RUN] Would create prompt.md")
211
+ else:
212
+ # Write files
213
+ _write_yaml(module_path / "module.yaml", manifest)
214
+ changes.append("Created module.yaml")
215
+
216
+ _write_json(module_path / "schema.json", new_schema)
217
+ changes.append("Updated schema.json (added meta)")
218
+
219
+ _write_text(module_path / "prompt.md", prompt_md)
220
+ changes.append("Created prompt.md")
221
+
222
+ # Keep MODULE.md for compatibility
223
+ changes.append("Preserved MODULE.md (backward compatibility)")
224
+
225
+ return True, changes, warnings
226
+
227
+
228
+ def _migrate_from_v2(
229
+ module_path: Path,
230
+ dry_run: bool,
231
+ changes: list[str],
232
+ warnings: list[str]
233
+ ) -> tuple[bool, list[str], list[str]]:
234
+ """Migrate from v2.0/v2.1 to v2.2 format."""
235
+ # Load module.yaml
236
+ module_yaml_path = module_path / "module.yaml"
237
+ with open(module_yaml_path, 'r', encoding='utf-8') as f:
238
+ manifest = yaml.safe_load(f)
239
+
240
+ # Load schema.json
241
+ schema_path = module_path / "schema.json"
242
+ if schema_path.exists():
243
+ with open(schema_path, 'r', encoding='utf-8') as f:
244
+ schema = json.load(f)
245
+ else:
246
+ schema = {}
247
+
248
+ # Load prompt.md
249
+ prompt_path = module_path / "prompt.md"
250
+ if prompt_path.exists():
251
+ with open(prompt_path, 'r', encoding='utf-8') as f:
252
+ prompt = f.read()
253
+ else:
254
+ prompt = ""
255
+
256
+ # Upgrade manifest to v2.2
257
+ manifest_changes = []
258
+
259
+ if 'tier' not in manifest:
260
+ manifest['tier'] = 'decision' # Safe default
261
+ manifest_changes.append("Added tier: decision")
262
+
263
+ if 'schema_strictness' not in manifest:
264
+ manifest['schema_strictness'] = 'medium'
265
+ manifest_changes.append("Added schema_strictness: medium")
266
+
267
+ if 'overflow' not in manifest:
268
+ manifest['overflow'] = {
269
+ 'enabled': True,
270
+ 'recoverable': True,
271
+ 'max_items': 5,
272
+ 'require_suggested_mapping': True
273
+ }
274
+ manifest_changes.append("Added overflow config")
275
+
276
+ if 'enums' not in manifest:
277
+ manifest['enums'] = {
278
+ 'strategy': 'extensible'
279
+ }
280
+ manifest_changes.append("Added enums config")
281
+
282
+ if 'compat' not in manifest:
283
+ manifest['compat'] = {
284
+ 'accepts_v21_payload': True,
285
+ 'runtime_auto_wrap': True,
286
+ 'schema_output_alias': 'data'
287
+ }
288
+ manifest_changes.append("Added compat config")
289
+
290
+ # Update IO references
291
+ if 'io' not in manifest:
292
+ manifest['io'] = {
293
+ 'input': './schema.json#/input',
294
+ 'data': './schema.json#/data',
295
+ 'meta': './schema.json#/meta',
296
+ 'error': './schema.json#/error'
297
+ }
298
+ manifest_changes.append("Added io references")
299
+
300
+ # Upgrade schema to v2.2
301
+ schema_changes = []
302
+
303
+ if 'meta' not in schema:
304
+ schema['meta'] = _create_meta_schema()
305
+ schema_changes.append("Added meta schema")
306
+
307
+ # Rename output to data if needed
308
+ if 'output' in schema and 'data' not in schema:
309
+ schema['data'] = schema.pop('output')
310
+ schema_changes.append("Renamed output to data")
311
+
312
+ # Add rationale requirement if missing
313
+ if 'data' in schema:
314
+ data_required = schema['data'].get('required', [])
315
+ if 'rationale' not in data_required:
316
+ data_required.append('rationale')
317
+ schema['data']['required'] = data_required
318
+ schema_changes.append("Added rationale to data.required")
319
+
320
+ # Add extensions if overflow enabled
321
+ if manifest.get('overflow', {}).get('enabled') and '$defs' not in schema:
322
+ schema['$defs'] = {
323
+ 'extensions': _create_extensions_schema()
324
+ }
325
+ schema_changes.append("Added $defs.extensions")
326
+
327
+ # Update prompt if needed
328
+ prompt_changes = []
329
+ if 'meta' not in prompt.lower() or 'envelope' not in prompt.lower():
330
+ prompt = _add_v22_instructions_to_prompt(prompt, manifest)
331
+ prompt_changes.append("Added v2.2 envelope instructions")
332
+
333
+ if dry_run:
334
+ if manifest_changes:
335
+ changes.append(f"[DRY RUN] Would update module.yaml: {', '.join(manifest_changes)}")
336
+ if schema_changes:
337
+ changes.append(f"[DRY RUN] Would update schema.json: {', '.join(schema_changes)}")
338
+ if prompt_changes:
339
+ changes.append(f"[DRY RUN] Would update prompt.md: {', '.join(prompt_changes)}")
340
+ else:
341
+ if manifest_changes:
342
+ _write_yaml(module_yaml_path, manifest)
343
+ changes.append(f"Updated module.yaml: {', '.join(manifest_changes)}")
344
+
345
+ if schema_changes:
346
+ _write_json(schema_path, schema)
347
+ changes.append(f"Updated schema.json: {', '.join(schema_changes)}")
348
+
349
+ if prompt_changes:
350
+ _write_text(prompt_path, prompt)
351
+ changes.append(f"Updated prompt.md: {', '.join(prompt_changes)}")
352
+
353
+ return True, changes, warnings
354
+
355
+
356
+ # =============================================================================
357
+ # Helper Functions
358
+ # =============================================================================
359
+
360
+ def _create_backup(module_path: Path) -> Path:
361
+ """Create a backup of the module directory."""
362
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
363
+ backup_path = module_path.parent / f"{module_path.name}_backup_{timestamp}"
364
+ shutil.copytree(module_path, backup_path)
365
+ return backup_path
366
+
367
+
368
+ def _create_v22_manifest(frontmatter: dict) -> dict:
369
+ """Create v2.2 module.yaml from frontmatter."""
370
+ manifest = {
371
+ 'name': frontmatter.get('name', 'unknown'),
372
+ 'version': frontmatter.get('version', '2.2.0'),
373
+ 'responsibility': frontmatter.get('responsibility', ''),
374
+ 'tier': 'decision',
375
+ 'schema_strictness': 'medium',
376
+ 'excludes': frontmatter.get('excludes', []),
377
+ 'policies': {
378
+ 'network': 'deny',
379
+ 'filesystem_write': 'deny',
380
+ 'side_effects': 'deny',
381
+ 'code_execution': 'deny'
382
+ },
383
+ 'tools': {
384
+ 'policy': 'deny_by_default',
385
+ 'allowed': [],
386
+ 'denied': ['write_file', 'shell', 'network']
387
+ },
388
+ 'overflow': {
389
+ 'enabled': True,
390
+ 'recoverable': True,
391
+ 'max_items': 5,
392
+ 'require_suggested_mapping': True
393
+ },
394
+ 'enums': {
395
+ 'strategy': 'extensible'
396
+ },
397
+ 'failure': {
398
+ 'contract': 'error_union',
399
+ 'partial_allowed': True,
400
+ 'must_return_error_schema': True
401
+ },
402
+ 'runtime_requirements': {
403
+ 'structured_output': True,
404
+ 'max_input_tokens': 8000,
405
+ 'preferred_capabilities': ['json_mode']
406
+ },
407
+ 'io': {
408
+ 'input': './schema.json#/input',
409
+ 'data': './schema.json#/data',
410
+ 'meta': './schema.json#/meta',
411
+ 'error': './schema.json#/error'
412
+ },
413
+ 'compat': {
414
+ 'accepts_v21_payload': True,
415
+ 'runtime_auto_wrap': True,
416
+ 'schema_output_alias': 'data'
417
+ }
418
+ }
419
+
420
+ # Preserve constraints if present
421
+ constraints = frontmatter.get('constraints', {})
422
+ if constraints:
423
+ manifest['constraints'] = constraints
424
+
425
+ # Preserve context if present
426
+ if 'context' in frontmatter:
427
+ manifest['context'] = frontmatter['context']
428
+
429
+ return manifest
430
+
431
+
432
+ def _create_v22_schema(input_schema: dict, output_schema: dict) -> dict:
433
+ """Create v2.2 schema.json from input and output schemas."""
434
+ return {
435
+ '$schema': 'https://ziel-io.github.io/cognitive-modules/schema/v2.2.json',
436
+ 'meta': _create_meta_schema(),
437
+ 'input': input_schema,
438
+ 'data': _add_rationale_to_output(output_schema),
439
+ 'error': {
440
+ 'type': 'object',
441
+ 'required': ['code', 'message'],
442
+ 'properties': {
443
+ 'code': {'type': 'string'},
444
+ 'message': {'type': 'string'}
445
+ }
446
+ },
447
+ '$defs': {
448
+ 'extensions': _create_extensions_schema()
449
+ }
450
+ }
451
+
452
+
453
+ def _create_meta_schema() -> dict:
454
+ """Create the standard v2.2 meta schema."""
455
+ return {
456
+ 'type': 'object',
457
+ 'required': ['confidence', 'risk', 'explain'],
458
+ 'properties': {
459
+ 'confidence': {
460
+ 'type': 'number',
461
+ 'minimum': 0,
462
+ 'maximum': 1,
463
+ 'description': 'Confidence score, unified across all modules'
464
+ },
465
+ 'risk': {
466
+ 'type': 'string',
467
+ 'enum': ['none', 'low', 'medium', 'high'],
468
+ 'description': 'Aggregated risk level'
469
+ },
470
+ 'explain': {
471
+ 'type': 'string',
472
+ 'maxLength': 280,
473
+ 'description': 'Short explanation for control plane'
474
+ },
475
+ 'trace_id': {'type': 'string'},
476
+ 'model': {'type': 'string'},
477
+ 'latency_ms': {'type': 'number', 'minimum': 0}
478
+ }
479
+ }
480
+
481
+
482
+ def _create_extensions_schema() -> dict:
483
+ """Create the standard extensions schema for overflow."""
484
+ return {
485
+ 'type': 'object',
486
+ 'properties': {
487
+ 'insights': {
488
+ 'type': 'array',
489
+ 'maxItems': 5,
490
+ 'items': {
491
+ 'type': 'object',
492
+ 'required': ['text', 'suggested_mapping'],
493
+ 'properties': {
494
+ 'text': {'type': 'string'},
495
+ 'suggested_mapping': {'type': 'string'},
496
+ 'evidence': {'type': 'string'}
497
+ }
498
+ }
499
+ }
500
+ }
501
+ }
502
+
503
+
504
+ def _add_rationale_to_output(output_schema: dict) -> dict:
505
+ """Ensure output schema has rationale field."""
506
+ schema = dict(output_schema)
507
+
508
+ # Ensure required includes rationale
509
+ required = schema.get('required', [])
510
+ if 'rationale' not in required:
511
+ required.append('rationale')
512
+ schema['required'] = required
513
+
514
+ # Ensure properties includes rationale
515
+ properties = schema.get('properties', {})
516
+ if 'rationale' not in properties:
517
+ properties['rationale'] = {
518
+ 'type': 'string',
519
+ 'description': 'Detailed explanation for audit and human review'
520
+ }
521
+
522
+ # Add extensions reference
523
+ if 'extensions' not in properties:
524
+ properties['extensions'] = {'$ref': '#/$defs/extensions'}
525
+
526
+ schema['properties'] = properties
527
+
528
+ # Remove confidence from data (moved to meta)
529
+ # But keep for backward compat if exists
530
+
531
+ return schema
532
+
533
+
534
+ def _create_v22_prompt(frontmatter: dict, prompt_body: str) -> str:
535
+ """Create v2.2 prompt.md with envelope instructions."""
536
+ name = frontmatter.get('name', 'Module')
537
+
538
+ return f"""# {name.replace('-', ' ').title()}
539
+
540
+ {prompt_body}
541
+
542
+ ## Response Format (Envelope v2.2)
543
+
544
+ You MUST wrap your response in the v2.2 envelope format with separate meta and data sections.
545
+
546
+ ### Success Response
547
+
548
+ ```json
549
+ {{
550
+ "ok": true,
551
+ "meta": {{
552
+ "confidence": 0.9,
553
+ "risk": "low",
554
+ "explain": "Short summary (max 280 chars) for routing and UI display."
555
+ }},
556
+ "data": {{
557
+ "...your output fields...",
558
+ "rationale": "Detailed explanation for audit and human review..."
559
+ }}
560
+ }}
561
+ ```
562
+
563
+ ### Error Response
564
+
565
+ ```json
566
+ {{
567
+ "ok": false,
568
+ "meta": {{
569
+ "confidence": 0.0,
570
+ "risk": "high",
571
+ "explain": "Brief error summary."
572
+ }},
573
+ "error": {{
574
+ "code": "ERROR_CODE",
575
+ "message": "Detailed error description"
576
+ }}
577
+ }}
578
+ ```
579
+
580
+ ## Important
581
+
582
+ - `meta.explain` is for **quick decisions** (≤280 chars)
583
+ - `data.rationale` is for **audit and review** (no limit)
584
+ - Both must be present in successful responses
585
+ """
586
+
587
+
588
+ def _add_v22_instructions_to_prompt(prompt: str, manifest: dict) -> str:
589
+ """Add v2.2 envelope instructions to existing prompt."""
590
+ v22_section = """
591
+
592
+ ## Response Format (Envelope v2.2)
593
+
594
+ You MUST wrap your response in the v2.2 envelope format with separate meta and data sections:
595
+
596
+ - Success: `{ "ok": true, "meta": { "confidence": 0.9, "risk": "low", "explain": "≤280 chars" }, "data": { ...payload... } }`
597
+ - Error: `{ "ok": false, "meta": { ... }, "error": { "code": "...", "message": "..." } }`
598
+
599
+ Important:
600
+ - `meta.explain` is for quick routing (≤280 chars)
601
+ - `data.rationale` is for detailed audit (no limit)
602
+ """
603
+ return prompt + v22_section
604
+
605
+
606
+ def _write_yaml(path: Path, data: dict) -> None:
607
+ """Write YAML file with nice formatting."""
608
+ with open(path, 'w', encoding='utf-8') as f:
609
+ # Add header comment
610
+ f.write("# Cognitive Module Manifest v2.2\n")
611
+ yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
612
+
613
+
614
+ def _write_json(path: Path, data: dict) -> None:
615
+ """Write JSON file with nice formatting."""
616
+ with open(path, 'w', encoding='utf-8') as f:
617
+ json.dump(data, f, indent=2, ensure_ascii=False)
618
+ f.write('\n')
619
+
620
+
621
+ def _write_text(path: Path, content: str) -> None:
622
+ """Write text file."""
623
+ with open(path, 'w', encoding='utf-8') as f:
624
+ f.write(content)