@geravant/sinain 1.0.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 (53) hide show
  1. package/README.md +183 -0
  2. package/index.ts +2096 -0
  3. package/install.js +155 -0
  4. package/openclaw.plugin.json +59 -0
  5. package/package.json +21 -0
  6. package/sinain-memory/common.py +403 -0
  7. package/sinain-memory/demo_knowledge_transfer.sh +85 -0
  8. package/sinain-memory/embedder.py +268 -0
  9. package/sinain-memory/eval/__init__.py +0 -0
  10. package/sinain-memory/eval/assertions.py +288 -0
  11. package/sinain-memory/eval/judges/__init__.py +0 -0
  12. package/sinain-memory/eval/judges/base_judge.py +61 -0
  13. package/sinain-memory/eval/judges/curation_judge.py +46 -0
  14. package/sinain-memory/eval/judges/insight_judge.py +48 -0
  15. package/sinain-memory/eval/judges/mining_judge.py +42 -0
  16. package/sinain-memory/eval/judges/signal_judge.py +45 -0
  17. package/sinain-memory/eval/schemas.py +247 -0
  18. package/sinain-memory/eval_delta.py +109 -0
  19. package/sinain-memory/eval_reporter.py +642 -0
  20. package/sinain-memory/feedback_analyzer.py +221 -0
  21. package/sinain-memory/git_backup.sh +19 -0
  22. package/sinain-memory/insight_synthesizer.py +181 -0
  23. package/sinain-memory/memory/2026-03-01.md +11 -0
  24. package/sinain-memory/memory/playbook-archive/sinain-playbook-2026-03-01-1418.md +15 -0
  25. package/sinain-memory/memory/playbook-logs/2026-03-01.jsonl +1 -0
  26. package/sinain-memory/memory/sinain-playbook.md +21 -0
  27. package/sinain-memory/memory-config.json +39 -0
  28. package/sinain-memory/memory_miner.py +183 -0
  29. package/sinain-memory/module_manager.py +695 -0
  30. package/sinain-memory/playbook_curator.py +225 -0
  31. package/sinain-memory/requirements.txt +3 -0
  32. package/sinain-memory/signal_analyzer.py +141 -0
  33. package/sinain-memory/test_local.py +402 -0
  34. package/sinain-memory/tests/__init__.py +0 -0
  35. package/sinain-memory/tests/conftest.py +189 -0
  36. package/sinain-memory/tests/test_curator_helpers.py +94 -0
  37. package/sinain-memory/tests/test_embedder.py +210 -0
  38. package/sinain-memory/tests/test_extract_json.py +124 -0
  39. package/sinain-memory/tests/test_feedback_computation.py +121 -0
  40. package/sinain-memory/tests/test_miner_helpers.py +71 -0
  41. package/sinain-memory/tests/test_module_management.py +458 -0
  42. package/sinain-memory/tests/test_parsers.py +96 -0
  43. package/sinain-memory/tests/test_tick_evaluator.py +430 -0
  44. package/sinain-memory/tests/test_triple_extractor.py +255 -0
  45. package/sinain-memory/tests/test_triple_ingest.py +191 -0
  46. package/sinain-memory/tests/test_triple_migrate.py +138 -0
  47. package/sinain-memory/tests/test_triplestore.py +248 -0
  48. package/sinain-memory/tick_evaluator.py +392 -0
  49. package/sinain-memory/triple_extractor.py +402 -0
  50. package/sinain-memory/triple_ingest.py +290 -0
  51. package/sinain-memory/triple_migrate.py +275 -0
  52. package/sinain-memory/triple_query.py +184 -0
  53. package/sinain-memory/triplestore.py +498 -0
@@ -0,0 +1,695 @@
1
+ #!/usr/bin/env python3
2
+ """Module Manager — CLI for managing sinain knowledge modules.
3
+
4
+ Management subcommands (no LLM):
5
+ list, activate, suspend, priority, stack, info, guidance
6
+
7
+ Extraction subcommand (uses LLM):
8
+ extract — reads playbook + logs, uses LLM to extract domain patterns
9
+
10
+ Transfer subcommands (no LLM):
11
+ export — package a module as a portable .sinain-module.json bundle
12
+ import — import a module from a .sinain-module.json bundle
13
+
14
+ Usage:
15
+ python3 module_manager.py --modules-dir modules/ list
16
+ python3 module_manager.py --modules-dir modules/ activate react-native-dev --priority 85
17
+ python3 module_manager.py --modules-dir modules/ suspend react-native-dev
18
+ python3 module_manager.py --modules-dir modules/ priority react-native-dev 90
19
+ python3 module_manager.py --modules-dir modules/ stack
20
+ python3 module_manager.py --modules-dir modules/ info react-native-dev
21
+ python3 module_manager.py --modules-dir modules/ guidance react-native-dev
22
+ python3 module_manager.py --modules-dir modules/ guidance react-native-dev \\
23
+ --set "When user asks about hot reload, suggest Hermes bytecode caching"
24
+ python3 module_manager.py --modules-dir modules/ guidance react-native-dev --clear
25
+ python3 module_manager.py --modules-dir modules/ extract new-domain \\
26
+ --domain "description" --memory-dir memory/ [--min-score 0.3]
27
+ python3 module_manager.py --modules-dir modules/ export ocr-pipeline \\
28
+ [--output /tmp/ocr.sinain-module.json]
29
+ python3 module_manager.py --modules-dir modules/ import bundle.sinain-module.json \\
30
+ [--activate] [--force]
31
+ """
32
+
33
+ import argparse
34
+ import json
35
+ import sys
36
+ from datetime import datetime, timezone
37
+ from pathlib import Path
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Registry helpers
42
+ # ---------------------------------------------------------------------------
43
+
44
+ def _registry_path(modules_dir: Path) -> Path:
45
+ return modules_dir / "module-registry.json"
46
+
47
+
48
+ def _load_registry(modules_dir: Path) -> dict:
49
+ path = _registry_path(modules_dir)
50
+ if not path.exists():
51
+ return {"version": 1, "modules": {}}
52
+ return json.loads(path.read_text(encoding="utf-8"))
53
+
54
+
55
+ def _save_registry(modules_dir: Path, registry: dict) -> None:
56
+ path = _registry_path(modules_dir)
57
+ path.write_text(json.dumps(registry, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
58
+
59
+
60
+ def _load_manifest(modules_dir: Path, module_id: str) -> dict | None:
61
+ manifest_path = modules_dir / module_id / "manifest.json"
62
+ if not manifest_path.exists():
63
+ return None
64
+ return json.loads(manifest_path.read_text(encoding="utf-8"))
65
+
66
+
67
+ def _now_iso() -> str:
68
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
69
+
70
+
71
+ def _error(msg: str) -> None:
72
+ """Print error as JSON and exit."""
73
+ print(json.dumps({"error": msg}, ensure_ascii=False))
74
+ sys.exit(1)
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Subcommands: management (no LLM)
79
+ # ---------------------------------------------------------------------------
80
+
81
+ def cmd_list(modules_dir: Path, _args: argparse.Namespace) -> None:
82
+ """List all registered modules with their status."""
83
+ registry = _load_registry(modules_dir)
84
+ modules = []
85
+ for mid, entry in registry.get("modules", {}).items():
86
+ manifest = _load_manifest(modules_dir, mid)
87
+ modules.append({
88
+ "id": mid,
89
+ "name": manifest.get("name", mid) if manifest else mid,
90
+ "status": entry.get("status", "unknown"),
91
+ "priority": entry.get("priority", 0),
92
+ "locked": entry.get("locked", False),
93
+ "hasPatterns": (modules_dir / mid / "patterns.md").exists(),
94
+ })
95
+ # Also list unregistered module dirs
96
+ for child in sorted(modules_dir.iterdir()):
97
+ if child.is_dir() and child.name not in registry.get("modules", {}):
98
+ manifest = _load_manifest(modules_dir, child.name)
99
+ if manifest:
100
+ modules.append({
101
+ "id": child.name,
102
+ "name": manifest.get("name", child.name),
103
+ "status": "unregistered",
104
+ "priority": manifest.get("priority", {}).get("default", 0) if isinstance(manifest.get("priority"), dict) else 0,
105
+ "locked": False,
106
+ "hasPatterns": (child / "patterns.md").exists(),
107
+ })
108
+ print(json.dumps({"modules": modules}, ensure_ascii=False))
109
+
110
+
111
+ def cmd_activate(modules_dir: Path, args: argparse.Namespace) -> None:
112
+ """Activate a module (optionally set priority)."""
113
+ module_id = args.module_id
114
+ manifest = _load_manifest(modules_dir, module_id)
115
+ if not manifest:
116
+ _error(f"Module '{module_id}' not found (no manifest.json in {modules_dir / module_id})")
117
+
118
+ registry = _load_registry(modules_dir)
119
+ entry = registry.get("modules", {}).get(module_id, {})
120
+
121
+ # Determine priority
122
+ priority = args.priority
123
+ if priority is None:
124
+ priority = entry.get("priority") or (
125
+ manifest.get("priority", {}).get("default", 70)
126
+ if isinstance(manifest.get("priority"), dict)
127
+ else 70
128
+ )
129
+
130
+ # Validate priority against manifest range
131
+ prio_range = manifest.get("priority", {}).get("range") if isinstance(manifest.get("priority"), dict) else None
132
+ if prio_range and len(prio_range) == 2:
133
+ lo, hi = prio_range
134
+ if not (lo <= priority <= hi):
135
+ _error(f"Priority {priority} outside allowed range [{lo}, {hi}] for module '{module_id}'")
136
+
137
+ # Update registry
138
+ registry.setdefault("modules", {})[module_id] = {
139
+ "status": "active",
140
+ "priority": priority,
141
+ "activatedAt": _now_iso(),
142
+ "lastTriggered": entry.get("lastTriggered"),
143
+ "locked": entry.get("locked", manifest.get("locked", False)),
144
+ }
145
+ _save_registry(modules_dir, registry)
146
+
147
+ # Fire-and-forget: ingest module patterns into triple store
148
+ import subprocess
149
+ try:
150
+ subprocess.run(
151
+ ["python3", "triple_ingest.py", "--memory-dir",
152
+ str(modules_dir.parent / "memory"),
153
+ "--ingest-module", module_id, "--modules-dir", str(modules_dir),
154
+ "--embed"],
155
+ capture_output=True, timeout=15,
156
+ cwd=str(Path(__file__).parent),
157
+ )
158
+ except Exception:
159
+ pass
160
+
161
+ print(json.dumps({
162
+ "activated": module_id,
163
+ "priority": priority,
164
+ "status": "active",
165
+ }, ensure_ascii=False))
166
+
167
+
168
+ def cmd_suspend(modules_dir: Path, args: argparse.Namespace) -> None:
169
+ """Suspend a module (patterns excluded from effective playbook)."""
170
+ module_id = args.module_id
171
+ registry = _load_registry(modules_dir)
172
+ entry = registry.get("modules", {}).get(module_id)
173
+
174
+ if not entry:
175
+ _error(f"Module '{module_id}' not found in registry")
176
+ if entry.get("locked"):
177
+ _error(f"Module '{module_id}' is locked and cannot be suspended")
178
+
179
+ entry["status"] = "suspended"
180
+ _save_registry(modules_dir, registry)
181
+
182
+ # Fire-and-forget: retract module patterns from triple store
183
+ import subprocess
184
+ try:
185
+ subprocess.run(
186
+ ["python3", "triple_ingest.py", "--memory-dir",
187
+ str(modules_dir.parent / "memory"),
188
+ "--retract-module", module_id],
189
+ capture_output=True, timeout=15,
190
+ cwd=str(Path(__file__).parent),
191
+ )
192
+ except Exception:
193
+ pass
194
+
195
+ print(json.dumps({"suspended": module_id}, ensure_ascii=False))
196
+
197
+
198
+ def cmd_priority(modules_dir: Path, args: argparse.Namespace) -> None:
199
+ """Change a module's priority."""
200
+ module_id = args.module_id
201
+ new_priority = args.new_priority
202
+
203
+ manifest = _load_manifest(modules_dir, module_id)
204
+ if not manifest:
205
+ _error(f"Module '{module_id}' not found")
206
+
207
+ # Validate against manifest range
208
+ prio_range = manifest.get("priority", {}).get("range") if isinstance(manifest.get("priority"), dict) else None
209
+ if prio_range and len(prio_range) == 2:
210
+ lo, hi = prio_range
211
+ if not (lo <= new_priority <= hi):
212
+ _error(f"Priority {new_priority} outside allowed range [{lo}, {hi}]")
213
+
214
+ registry = _load_registry(modules_dir)
215
+ entry = registry.get("modules", {}).get(module_id)
216
+ if not entry:
217
+ _error(f"Module '{module_id}' not in registry (activate it first)")
218
+
219
+ entry["priority"] = new_priority
220
+ _save_registry(modules_dir, registry)
221
+ print(json.dumps({
222
+ "module": module_id,
223
+ "priority": new_priority,
224
+ }, ensure_ascii=False))
225
+
226
+
227
+ def cmd_stack(modules_dir: Path, _args: argparse.Namespace) -> None:
228
+ """Show the active module stack (sorted by priority desc)."""
229
+ registry = _load_registry(modules_dir)
230
+ active = []
231
+ suspended = []
232
+ for mid, entry in registry.get("modules", {}).items():
233
+ info = {
234
+ "id": mid,
235
+ "priority": entry.get("priority", 0),
236
+ "locked": entry.get("locked", False),
237
+ }
238
+ if entry.get("status") == "active":
239
+ active.append(info)
240
+ elif entry.get("status") == "suspended":
241
+ suspended.append(info)
242
+ active.sort(key=lambda m: m["priority"], reverse=True)
243
+ print(json.dumps({"active": active, "suspended": suspended}, ensure_ascii=False))
244
+
245
+
246
+ def cmd_info(modules_dir: Path, args: argparse.Namespace) -> None:
247
+ """Show detailed info about a module."""
248
+ module_id = args.module_id
249
+ manifest = _load_manifest(modules_dir, module_id)
250
+ if not manifest:
251
+ _error(f"Module '{module_id}' not found")
252
+
253
+ registry = _load_registry(modules_dir)
254
+ entry = registry.get("modules", {}).get(module_id, {})
255
+
256
+ patterns_path = modules_dir / module_id / "patterns.md"
257
+ patterns_lines = 0
258
+ if patterns_path.exists():
259
+ patterns_lines = len(patterns_path.read_text(encoding="utf-8").splitlines())
260
+
261
+ guidance_path = modules_dir / module_id / "guidance.md"
262
+ guidance_chars = 0
263
+ if guidance_path.exists():
264
+ guidance_chars = len(guidance_path.read_text(encoding="utf-8"))
265
+
266
+ print(json.dumps({
267
+ "id": module_id,
268
+ "manifest": manifest,
269
+ "registry": entry if entry else None,
270
+ "patternsLines": patterns_lines,
271
+ "patternsPath": str(patterns_path),
272
+ "guidanceChars": guidance_chars,
273
+ }, ensure_ascii=False))
274
+
275
+
276
+ def cmd_guidance(modules_dir: Path, args: argparse.Namespace) -> None:
277
+ """View, set, or clear per-module behavioral guidance."""
278
+ module_id = args.module_id
279
+ module_dir = modules_dir / module_id
280
+ manifest = _load_manifest(modules_dir, module_id)
281
+ if not manifest:
282
+ _error(f"Module '{module_id}' not found (no manifest.json in {module_dir})")
283
+
284
+ guidance_path = module_dir / "guidance.md"
285
+
286
+ if args.clear:
287
+ if guidance_path.exists():
288
+ guidance_path.unlink()
289
+ print(json.dumps({"module": module_id, "guidance": "", "cleared": True}, ensure_ascii=False))
290
+ return
291
+
292
+ if args.set is not None:
293
+ guidance_path.write_text(args.set, encoding="utf-8")
294
+ print(json.dumps({
295
+ "module": module_id,
296
+ "guidanceChars": len(args.set),
297
+ "written": True,
298
+ }, ensure_ascii=False))
299
+ return
300
+
301
+ # Default: view
302
+ guidance = guidance_path.read_text(encoding="utf-8") if guidance_path.exists() else ""
303
+ print(json.dumps({
304
+ "module": module_id,
305
+ "hasGuidance": bool(guidance),
306
+ "guidance": guidance,
307
+ }, ensure_ascii=False))
308
+
309
+
310
+ # ---------------------------------------------------------------------------
311
+ # Subcommand: extract (uses LLM)
312
+ # ---------------------------------------------------------------------------
313
+
314
+ EXTRACT_SYSTEM_PROMPT = """\
315
+ You are a knowledge extraction agent for sinain, a personal AI assistant.
316
+ Your job: given a playbook and recent log history, extract all patterns related to a specific domain.
317
+
318
+ Output format — respond with ONLY a JSON object:
319
+ {
320
+ "established": ["pattern 1 (score > 0.5)", ...],
321
+ "emerging": ["pattern that appeared recently", ...],
322
+ "vocabulary": ["domain-specific term: definition", ...]
323
+ }
324
+
325
+ Rules:
326
+ - Only include patterns genuinely related to the specified domain
327
+ - "established" = patterns with strong evidence (multiple occurrences, high scores)
328
+ - "emerging" = patterns seen once or twice, plausible but unconfirmed
329
+ - "vocabulary" = domain-specific terms, acronyms, tool names with brief definitions
330
+ - Be specific — cite concrete behaviors, not generic advice
331
+ - If no patterns found for the domain, return empty arrays"""
332
+
333
+
334
+ def cmd_extract(modules_dir: Path, args: argparse.Namespace) -> None:
335
+ """Extract domain patterns from playbook + logs using LLM."""
336
+ # Import LLM utilities (only needed for extract)
337
+ try:
338
+ from common import call_llm, extract_json, read_playbook, read_recent_logs, LLMError
339
+ except ImportError:
340
+ _error("Cannot import common.py — run from sinain-koog/ directory or ensure it's on PYTHONPATH")
341
+
342
+ module_id = args.module_id
343
+ domain = args.domain
344
+ memory_dir = args.memory_dir
345
+ min_score = args.min_score
346
+
347
+ # Read source data
348
+ playbook = read_playbook(memory_dir)
349
+ recent_logs = read_recent_logs(memory_dir, days=7)
350
+
351
+ if not playbook and not recent_logs:
352
+ _error("No playbook or logs found — nothing to extract from")
353
+
354
+ # Enrich with knowledge graph context (optional, degrades gracefully)
355
+ kg_context = ""
356
+ try:
357
+ from triple_query import get_related_context
358
+ kg_context = get_related_context(str(memory_dir), [domain], max_chars=1000)
359
+ except Exception:
360
+ pass # triple store unavailable — proceed without
361
+
362
+ # Build user prompt
363
+ parts = [f"## Domain: {domain}"]
364
+ if playbook:
365
+ parts.append(f"\n## Playbook Content\n{playbook}")
366
+ if recent_logs:
367
+ # Summarize logs (keep it compact)
368
+ log_entries = []
369
+ for entry in recent_logs[:20]:
370
+ log_entries.append(json.dumps({
371
+ "ts": entry.get("ts", "?"),
372
+ "signals": entry.get("signals", []),
373
+ "playbookChanges": entry.get("playbookChanges"),
374
+ "output": entry.get("output"),
375
+ }, ensure_ascii=False))
376
+ parts.append(f"\n## Recent Log Entries (last 7 days)\n" + "\n".join(log_entries))
377
+ if kg_context:
378
+ parts.append(f"\n## Knowledge Graph Context\n{kg_context}")
379
+ if min_score:
380
+ parts.append(f"\n## Minimum Score Filter: {min_score}")
381
+
382
+ user_prompt = "\n".join(parts)
383
+
384
+ try:
385
+ raw = call_llm(EXTRACT_SYSTEM_PROMPT, user_prompt, script="module_manager", json_mode=True)
386
+ result = extract_json(raw)
387
+ except (ValueError, LLMError) as e:
388
+ _error(f"LLM extraction failed: {e}")
389
+
390
+ established = result.get("established", [])
391
+ emerging = result.get("emerging", [])
392
+ vocabulary = result.get("vocabulary", [])
393
+
394
+ # Create module directory
395
+ module_dir = modules_dir / module_id
396
+ module_dir.mkdir(parents=True, exist_ok=True)
397
+
398
+ # Generate manifest
399
+ manifest = {
400
+ "id": module_id,
401
+ "name": domain,
402
+ "description": f"Auto-extracted patterns for: {domain}",
403
+ "version": "1.0.0",
404
+ "priority": {
405
+ "default": 70,
406
+ "range": [50, 100],
407
+ },
408
+ "triggers": {},
409
+ "locked": False,
410
+ "extractedAt": _now_iso(),
411
+ "source": "module_manager extract",
412
+ }
413
+ (module_dir / "manifest.json").write_text(
414
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
415
+ )
416
+
417
+ # Generate patterns.md
418
+ lines = [f"# {domain}\n"]
419
+ if established:
420
+ lines.append("## Established Patterns")
421
+ for p in established:
422
+ lines.append(f"- {p}")
423
+ lines.append("")
424
+ if emerging:
425
+ lines.append("## Emerging Patterns")
426
+ for p in emerging:
427
+ lines.append(f"- {p}")
428
+ lines.append("")
429
+ if vocabulary:
430
+ lines.append("## Domain Vocabulary")
431
+ for v in vocabulary:
432
+ lines.append(f"- {v}")
433
+ lines.append("")
434
+
435
+ (module_dir / "patterns.md").write_text("\n".join(lines), encoding="utf-8")
436
+
437
+ # Register as suspended (user must explicitly activate)
438
+ registry = _load_registry(modules_dir)
439
+ registry.setdefault("modules", {})[module_id] = {
440
+ "status": "suspended",
441
+ "priority": 70,
442
+ "activatedAt": None,
443
+ "lastTriggered": None,
444
+ "locked": False,
445
+ }
446
+ _save_registry(modules_dir, registry)
447
+
448
+ print(json.dumps({
449
+ "extracted": module_id,
450
+ "domain": domain,
451
+ "patternsEstablished": len(established),
452
+ "patternsEmerging": len(emerging),
453
+ "vocabularyTerms": len(vocabulary),
454
+ "modulePath": str(module_dir),
455
+ "status": "suspended",
456
+ "activateWith": f"python3 module_manager.py --modules-dir {modules_dir} activate {module_id}",
457
+ }, ensure_ascii=False))
458
+
459
+
460
+ # ---------------------------------------------------------------------------
461
+ # Subcommands: export / import (portable bundles)
462
+ # ---------------------------------------------------------------------------
463
+
464
+ def cmd_export(modules_dir: Path, args: argparse.Namespace) -> None:
465
+ """Export a module as a portable .sinain-module.json bundle."""
466
+ module_id = args.module_id
467
+ module_dir = modules_dir / module_id
468
+
469
+ manifest = _load_manifest(modules_dir, module_id)
470
+ if not manifest:
471
+ _error(f"Module '{module_id}' not found (no manifest.json)")
472
+
473
+ # Read patterns
474
+ patterns_path = module_dir / "patterns.md"
475
+ patterns = patterns_path.read_text(encoding="utf-8") if patterns_path.exists() else ""
476
+
477
+ # Read guidance
478
+ guidance_path = module_dir / "guidance.md"
479
+ guidance = guidance_path.read_text(encoding="utf-8") if guidance_path.exists() else ""
480
+
481
+ # Read context files
482
+ context = {}
483
+ context_dir = module_dir / "context"
484
+ if context_dir.is_dir():
485
+ for f in sorted(context_dir.iterdir()):
486
+ if f.is_file():
487
+ try:
488
+ context[f.name] = f.read_text(encoding="utf-8")
489
+ except UnicodeDecodeError:
490
+ pass # skip binary files
491
+
492
+ bundle = {
493
+ "format": "sinain-module-v1",
494
+ "moduleId": module_id,
495
+ "exportedAt": _now_iso(),
496
+ "manifest": manifest,
497
+ "patterns": patterns,
498
+ "guidance": guidance,
499
+ "context": context,
500
+ }
501
+
502
+ output_path = Path(args.output) if args.output else Path(f"{module_id}.sinain-module.json")
503
+ output_path.write_text(json.dumps(bundle, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
504
+
505
+ print(json.dumps({
506
+ "exported": module_id,
507
+ "outputPath": str(output_path),
508
+ "patternsChars": len(patterns),
509
+ "guidanceChars": len(guidance),
510
+ "contextFiles": len(context),
511
+ }, ensure_ascii=False))
512
+
513
+
514
+ def cmd_import(modules_dir: Path, args: argparse.Namespace) -> None:
515
+ """Import a module from a .sinain-module.json bundle."""
516
+ bundle_path = Path(args.bundle)
517
+ if not bundle_path.exists():
518
+ _error(f"Bundle file not found: {bundle_path}")
519
+
520
+ try:
521
+ bundle = json.loads(bundle_path.read_text(encoding="utf-8"))
522
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
523
+ _error(f"Invalid bundle file: {e}")
524
+
525
+ # Validate format
526
+ if bundle.get("format") != "sinain-module-v1":
527
+ _error(f"Unknown bundle format: {bundle.get('format')} (expected sinain-module-v1)")
528
+
529
+ module_id = bundle.get("moduleId")
530
+ if not module_id:
531
+ _error("Bundle missing moduleId")
532
+
533
+ module_dir = modules_dir / module_id
534
+
535
+ # Check for existing module
536
+ if module_dir.exists() and not args.force:
537
+ _error(f"Module '{module_id}' already exists. Use --force to overwrite.")
538
+
539
+ # Create module directory
540
+ module_dir.mkdir(parents=True, exist_ok=True)
541
+
542
+ # Write manifest (stamp with import metadata)
543
+ manifest = bundle.get("manifest", {})
544
+ manifest["importedAt"] = _now_iso()
545
+ manifest["source"] = "module_manager import"
546
+ manifest["importedFrom"] = str(bundle_path.name)
547
+ (module_dir / "manifest.json").write_text(
548
+ json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
549
+ )
550
+
551
+ # Write patterns
552
+ patterns = bundle.get("patterns", "")
553
+ if patterns:
554
+ (module_dir / "patterns.md").write_text(patterns, encoding="utf-8")
555
+
556
+ # Write guidance
557
+ guidance = bundle.get("guidance", "")
558
+ if guidance:
559
+ (module_dir / "guidance.md").write_text(guidance, encoding="utf-8")
560
+
561
+ # Write context files
562
+ context = bundle.get("context", {})
563
+ if context:
564
+ context_dir = module_dir / "context"
565
+ context_dir.mkdir(exist_ok=True)
566
+ for fname, content in context.items():
567
+ (context_dir / fname).write_text(content, encoding="utf-8")
568
+
569
+ # Register in registry
570
+ registry = _load_registry(modules_dir)
571
+ status = "active" if args.activate else "suspended"
572
+ priority = manifest.get("priority", {}).get("default", 70) if isinstance(manifest.get("priority"), dict) else 70
573
+ registry.setdefault("modules", {})[module_id] = {
574
+ "status": status,
575
+ "priority": priority,
576
+ "activatedAt": _now_iso() if args.activate else None,
577
+ "lastTriggered": None,
578
+ "locked": False,
579
+ }
580
+ _save_registry(modules_dir, registry)
581
+
582
+ # If activating, fire-and-forget KG ingestion (same as cmd_activate)
583
+ if args.activate:
584
+ import subprocess
585
+ try:
586
+ subprocess.run(
587
+ ["python3", "triple_ingest.py", "--memory-dir",
588
+ str(modules_dir.parent / "memory"),
589
+ "--ingest-module", module_id, "--modules-dir", str(modules_dir),
590
+ "--embed"],
591
+ capture_output=True, timeout=15,
592
+ cwd=str(Path(__file__).parent),
593
+ )
594
+ except Exception:
595
+ pass
596
+
597
+ print(json.dumps({
598
+ "imported": module_id,
599
+ "status": status,
600
+ "priority": priority,
601
+ "patternsChars": len(patterns),
602
+ "contextFiles": len(context),
603
+ "modulePath": str(module_dir),
604
+ }, ensure_ascii=False))
605
+
606
+
607
+ # ---------------------------------------------------------------------------
608
+ # CLI entry point
609
+ # ---------------------------------------------------------------------------
610
+
611
+ def main():
612
+ parser = argparse.ArgumentParser(
613
+ description="Sinain Knowledge Module Manager",
614
+ formatter_class=argparse.RawDescriptionHelpFormatter,
615
+ )
616
+ parser.add_argument(
617
+ "--modules-dir", required=True, type=Path,
618
+ help="Path to modules/ directory",
619
+ )
620
+
621
+ subparsers = parser.add_subparsers(dest="command", required=True)
622
+
623
+ # list
624
+ subparsers.add_parser("list", help="List all modules")
625
+
626
+ # activate
627
+ p_act = subparsers.add_parser("activate", help="Activate a module")
628
+ p_act.add_argument("module_id", help="Module ID")
629
+ p_act.add_argument("--priority", type=int, default=None, help="Set priority (default: from manifest)")
630
+
631
+ # suspend
632
+ p_sus = subparsers.add_parser("suspend", help="Suspend a module")
633
+ p_sus.add_argument("module_id", help="Module ID")
634
+
635
+ # priority
636
+ p_pri = subparsers.add_parser("priority", help="Change module priority")
637
+ p_pri.add_argument("module_id", help="Module ID")
638
+ p_pri.add_argument("new_priority", type=int, help="New priority value")
639
+
640
+ # stack
641
+ subparsers.add_parser("stack", help="Show active module stack")
642
+
643
+ # info
644
+ p_info = subparsers.add_parser("info", help="Show module details")
645
+ p_info.add_argument("module_id", help="Module ID")
646
+
647
+ # guidance
648
+ p_guid = subparsers.add_parser("guidance", help="View/set/clear per-module behavioral guidance")
649
+ p_guid.add_argument("module_id", help="Module ID")
650
+ p_guid.add_argument("--set", default=None, help="Set guidance text")
651
+ p_guid.add_argument("--clear", action="store_true", help="Clear guidance")
652
+
653
+ # extract
654
+ p_ext = subparsers.add_parser("extract", help="Extract domain patterns using LLM")
655
+ p_ext.add_argument("module_id", help="Module ID to create")
656
+ p_ext.add_argument("--domain", required=True, help="Domain description")
657
+ p_ext.add_argument("--memory-dir", required=True, help="Path to memory/ directory")
658
+ p_ext.add_argument("--min-score", type=float, default=0.3, help="Minimum pattern score (default: 0.3)")
659
+
660
+ # export
661
+ p_exp = subparsers.add_parser("export", help="Export module as portable bundle")
662
+ p_exp.add_argument("module_id", help="Module ID to export")
663
+ p_exp.add_argument("--output", default=None, help="Output file path (default: <id>.sinain-module.json)")
664
+
665
+ # import
666
+ p_imp = subparsers.add_parser("import", help="Import module from portable bundle")
667
+ p_imp.add_argument("bundle", help="Path to .sinain-module.json bundle file")
668
+ p_imp.add_argument("--activate", action="store_true", help="Activate module immediately after import")
669
+ p_imp.add_argument("--force", action="store_true", help="Overwrite existing module")
670
+
671
+ args = parser.parse_args()
672
+
673
+ commands = {
674
+ "list": cmd_list,
675
+ "activate": cmd_activate,
676
+ "suspend": cmd_suspend,
677
+ "priority": cmd_priority,
678
+ "stack": cmd_stack,
679
+ "info": cmd_info,
680
+ "guidance": cmd_guidance,
681
+ "extract": cmd_extract,
682
+ "export": cmd_export,
683
+ "import": cmd_import,
684
+ }
685
+
686
+ handler = commands.get(args.command)
687
+ if handler:
688
+ handler(args.modules_dir, args)
689
+ else:
690
+ parser.print_help()
691
+ sys.exit(1)
692
+
693
+
694
+ if __name__ == "__main__":
695
+ main()