@geravant/sinain 1.13.0 → 1.15.0

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 (78) hide show
  1. package/.env.example +33 -27
  2. package/cli.js +30 -14
  3. package/config-shared.js +173 -30
  4. package/launcher.js +38 -21
  5. package/onboard.js +36 -20
  6. package/package.json +4 -1
  7. package/sinain-agent/run.sh +600 -127
  8. package/sinain-core/src/agents-loader.ts +254 -0
  9. package/sinain-core/src/buffers/feed-buffer.ts +6 -4
  10. package/sinain-core/src/config.ts +77 -15
  11. package/sinain-core/src/escalation/escalator.ts +178 -18
  12. package/sinain-core/src/index.ts +218 -31
  13. package/sinain-core/src/learning/local-curation.ts +81 -27
  14. package/sinain-core/src/overlay/commands.ts +25 -0
  15. package/sinain-core/src/overlay/ws-handler.ts +3 -0
  16. package/sinain-core/src/server.ts +101 -10
  17. package/sinain-core/src/types.ts +29 -3
  18. package/sinain-memory/graph_query.py +12 -3
  19. package/sinain-memory/knowledge_integrator.py +194 -10
  20. package/sinain-memory/__pycache__/common.cpython-312.pyc +0 -0
  21. package/sinain-memory/__pycache__/embed_client.cpython-312.pyc +0 -0
  22. package/sinain-memory/__pycache__/graph_query.cpython-312.pyc +0 -0
  23. package/sinain-memory/__pycache__/knowledge_integrator.cpython-312.pyc +0 -0
  24. package/sinain-memory/__pycache__/session_distiller.cpython-312.pyc +0 -0
  25. package/sinain-memory/__pycache__/triplestore.cpython-312.pyc +0 -0
  26. package/sinain-memory/eval/__init__.py +0 -0
  27. package/sinain-memory/eval/__pycache__/__init__.cpython-312.pyc +0 -0
  28. package/sinain-memory/eval/assertions.py +0 -267
  29. package/sinain-memory/eval/benchmarks/__init__.py +0 -0
  30. package/sinain-memory/eval/benchmarks/__pycache__/__init__.cpython-312.pyc +0 -0
  31. package/sinain-memory/eval/benchmarks/__pycache__/base_adapter.cpython-312.pyc +0 -0
  32. package/sinain-memory/eval/benchmarks/__pycache__/config.cpython-312.pyc +0 -0
  33. package/sinain-memory/eval/benchmarks/__pycache__/evaluate.cpython-312.pyc +0 -0
  34. package/sinain-memory/eval/benchmarks/__pycache__/ingest.cpython-312.pyc +0 -0
  35. package/sinain-memory/eval/benchmarks/__pycache__/longmemeval_adapter.cpython-312.pyc +0 -0
  36. package/sinain-memory/eval/benchmarks/__pycache__/meeting_adapter.cpython-312.pyc +0 -0
  37. package/sinain-memory/eval/benchmarks/__pycache__/meeting_runner.cpython-312.pyc +0 -0
  38. package/sinain-memory/eval/benchmarks/__pycache__/query.cpython-312.pyc +0 -0
  39. package/sinain-memory/eval/benchmarks/__pycache__/report.cpython-312.pyc +0 -0
  40. package/sinain-memory/eval/benchmarks/__pycache__/runner.cpython-312.pyc +0 -0
  41. package/sinain-memory/eval/benchmarks/base_adapter.py +0 -43
  42. package/sinain-memory/eval/benchmarks/config.py +0 -23
  43. package/sinain-memory/eval/benchmarks/evaluate.py +0 -146
  44. package/sinain-memory/eval/benchmarks/ingest.py +0 -152
  45. package/sinain-memory/eval/benchmarks/judges/__init__.py +0 -0
  46. package/sinain-memory/eval/benchmarks/judges/__pycache__/__init__.cpython-312.pyc +0 -0
  47. package/sinain-memory/eval/benchmarks/judges/__pycache__/qa_judge.cpython-312.pyc +0 -0
  48. package/sinain-memory/eval/benchmarks/judges/qa_judge.py +0 -81
  49. package/sinain-memory/eval/benchmarks/longmemeval_adapter.py +0 -177
  50. package/sinain-memory/eval/benchmarks/meeting_adapter.py +0 -81
  51. package/sinain-memory/eval/benchmarks/meeting_runner.py +0 -230
  52. package/sinain-memory/eval/benchmarks/query.py +0 -193
  53. package/sinain-memory/eval/benchmarks/report.py +0 -87
  54. package/sinain-memory/eval/benchmarks/run_meeting_bench.sh +0 -318
  55. package/sinain-memory/eval/benchmarks/runner.py +0 -283
  56. package/sinain-memory/eval/judges/__init__.py +0 -0
  57. package/sinain-memory/eval/judges/base_judge.py +0 -61
  58. package/sinain-memory/eval/judges/curation_judge.py +0 -46
  59. package/sinain-memory/eval/judges/insight_judge.py +0 -48
  60. package/sinain-memory/eval/judges/mining_judge.py +0 -42
  61. package/sinain-memory/eval/judges/signal_judge.py +0 -45
  62. package/sinain-memory/eval/retrieval_benchmark.jsonl +0 -12
  63. package/sinain-memory/eval/retrieval_evaluator.py +0 -186
  64. package/sinain-memory/eval/schemas.py +0 -247
  65. package/sinain-memory/tests/__init__.py +0 -0
  66. package/sinain-memory/tests/conftest.py +0 -189
  67. package/sinain-memory/tests/test_curator_helpers.py +0 -94
  68. package/sinain-memory/tests/test_embedder.py +0 -210
  69. package/sinain-memory/tests/test_extract_json.py +0 -124
  70. package/sinain-memory/tests/test_feedback_computation.py +0 -121
  71. package/sinain-memory/tests/test_miner_helpers.py +0 -71
  72. package/sinain-memory/tests/test_module_management.py +0 -458
  73. package/sinain-memory/tests/test_parsers.py +0 -96
  74. package/sinain-memory/tests/test_tick_evaluator.py +0 -430
  75. package/sinain-memory/tests/test_triple_extractor.py +0 -255
  76. package/sinain-memory/tests/test_triple_ingest.py +0 -191
  77. package/sinain-memory/tests/test_triple_migrate.py +0 -138
  78. package/sinain-memory/tests/test_triplestore.py +0 -248
@@ -1,121 +0,0 @@
1
- """Tests for feedback_analyzer.py: compute_effectiveness() and determine_directive()."""
2
-
3
- from feedback_analyzer import compute_effectiveness, determine_directive, extract_feedback_scores
4
-
5
-
6
- class TestComputeEffectiveness:
7
- def test_no_logs(self):
8
- result = compute_effectiveness([])
9
- assert result == {"outputs": 0, "positive": 0, "negative": 0, "neutral": 0, "rate": 0.0}
10
-
11
- def test_all_skipped(self):
12
- logs = [{"ts": "2026-02-28T10:00:00Z", "skipped": True}] * 5
13
- result = compute_effectiveness(logs)
14
- assert result["outputs"] == 0
15
-
16
- def test_positive_outcomes(self):
17
- logs = [
18
- {"ts": "2026-02-28T10:00:00Z", "skipped": False},
19
- {"ts": "2026-02-28T10:30:00Z", "skipped": True, "feedbackScores": {"avg": 0.5}},
20
- {"ts": "2026-02-28T11:00:00Z", "skipped": False},
21
- {"ts": "2026-02-28T11:30:00Z", "skipped": True, "feedbackScores": {"avg": 0.6}},
22
- ]
23
- result = compute_effectiveness(logs)
24
- assert result["outputs"] == 2
25
- assert result["positive"] == 2
26
- assert result["rate"] == 1.0
27
-
28
- def test_negative_outcomes(self):
29
- logs = [
30
- {"ts": "2026-02-28T10:00:00Z", "skipped": False},
31
- {"ts": "2026-02-28T10:30:00Z", "skipped": True, "feedbackScores": {"avg": -0.5}},
32
- ]
33
- result = compute_effectiveness(logs)
34
- assert result["negative"] == 1
35
-
36
- def test_neutral_for_last_tick(self):
37
- """Last tick with output has no next tick for feedback — counted as neutral."""
38
- logs = [{"ts": "2026-02-28T10:00:00Z", "skipped": False}]
39
- result = compute_effectiveness(logs)
40
- assert result["neutral"] == 1
41
-
42
- def test_mixed_outcomes(self):
43
- logs = [
44
- {"ts": "2026-02-28T10:00:00Z", "skipped": False},
45
- {"ts": "2026-02-28T10:30:00Z", "feedbackScores": {"avg": 0.5}}, # positive
46
- {"ts": "2026-02-28T11:00:00Z", "skipped": False},
47
- {"ts": "2026-02-28T11:30:00Z", "feedbackScores": {"avg": -0.3}}, # negative
48
- {"ts": "2026-02-28T12:00:00Z", "skipped": False},
49
- {"ts": "2026-02-28T12:30:00Z", "feedbackScores": {"avg": 0.05}}, # neutral
50
- ]
51
- result = compute_effectiveness(logs)
52
- assert result["outputs"] == 3
53
- assert result["positive"] == 1
54
- assert result["negative"] == 1
55
- assert result["neutral"] == 1
56
- assert result["rate"] == round(1 / 3, 2)
57
-
58
- def test_rate_is_rounded(self):
59
- logs = [
60
- {"ts": f"2026-02-28T1{i}:00:00Z", "skipped": False}
61
- for i in range(3)
62
- ] + [
63
- {"ts": f"2026-02-28T1{i}:30:00Z", "feedbackScores": {"avg": 0.5}}
64
- for i in range(3)
65
- ]
66
- result = compute_effectiveness(logs)
67
- assert isinstance(result["rate"], float)
68
-
69
-
70
- class TestDetermineDirective:
71
- def test_insufficient_data(self):
72
- assert determine_directive({"outputs": 3, "rate": 0.8}) == "insufficient_data"
73
- assert determine_directive({"outputs": 0, "rate": 0.0}) == "insufficient_data"
74
- assert determine_directive({"outputs": 4, "rate": 0.5}) == "insufficient_data"
75
-
76
- def test_aggressive_prune(self):
77
- assert determine_directive({"outputs": 10, "rate": 0.2}) == "aggressive_prune"
78
- assert determine_directive({"outputs": 5, "rate": 0.39}) == "aggressive_prune"
79
-
80
- def test_stability(self):
81
- assert determine_directive({"outputs": 10, "rate": 0.8}) == "stability"
82
- assert determine_directive({"outputs": 5, "rate": 0.71}) == "stability"
83
-
84
- def test_normal(self):
85
- assert determine_directive({"outputs": 10, "rate": 0.5}) == "normal"
86
- assert determine_directive({"outputs": 5, "rate": 0.4}) == "normal"
87
- assert determine_directive({"outputs": 5, "rate": 0.7}) == "normal"
88
-
89
- def test_boundary_values(self):
90
- # Exactly 5 outputs is enough data
91
- assert determine_directive({"outputs": 5, "rate": 0.5}) != "insufficient_data"
92
- # rate=0.4 is normal (not aggressive_prune)
93
- assert determine_directive({"outputs": 5, "rate": 0.4}) == "normal"
94
- # rate=0.7 is normal (not stability)
95
- assert determine_directive({"outputs": 5, "rate": 0.7}) == "normal"
96
-
97
-
98
- class TestExtractFeedbackScores:
99
- def test_empty_logs(self):
100
- result = extract_feedback_scores([])
101
- assert result["avg"] == 0
102
- assert result["high"] == []
103
- assert result["low"] == []
104
-
105
- def test_with_scores(self):
106
- logs = [
107
- {"feedbackScores": {"avg": 0.5, "high": ["good A"], "low": ["bad A"]}},
108
- {"feedbackScores": {"avg": 0.3, "high": ["good B"], "low": []}},
109
- ]
110
- result = extract_feedback_scores(logs)
111
- assert result["avg"] == 0.4
112
- assert "good A" in result["high"]
113
- assert "good B" in result["high"]
114
-
115
- def test_limits_to_5_patterns(self):
116
- logs = [
117
- {"feedbackScores": {"avg": 0.1, "high": [f"pattern {i}"], "low": []}}
118
- for i in range(10)
119
- ]
120
- result = extract_feedback_scores(logs)
121
- assert len(result["high"]) <= 5
@@ -1,71 +0,0 @@
1
- """Tests for memory_miner.py: get_unmined_files() and update_mining_index()."""
2
-
3
- from pathlib import Path
4
- from memory_miner import get_unmined_files, update_mining_index
5
- from common import parse_mining_index
6
-
7
-
8
- class TestGetUnminedFiles:
9
- def test_all_mined(self, tmp_memory_dir):
10
- memory_dir = str(tmp_memory_dir)
11
- mined = ["2026-02-21", "2026-02-20", "2026-02-19"]
12
- result = get_unmined_files(memory_dir, mined)
13
- assert result == []
14
-
15
- def test_some_unmined(self, tmp_memory_dir):
16
- memory_dir = str(tmp_memory_dir)
17
- mined = ["2026-02-21"]
18
- result = get_unmined_files(memory_dir, mined)
19
- basenames = [Path(f).stem for f in result]
20
- assert "2026-02-20" in basenames
21
- assert "2026-02-19" in basenames
22
- assert "2026-02-21" not in basenames
23
-
24
- def test_none_mined(self, tmp_memory_dir):
25
- memory_dir = str(tmp_memory_dir)
26
- result = get_unmined_files(memory_dir, [])
27
- assert len(result) == 3 # all 3 daily files
28
-
29
- def test_no_daily_files(self, tmp_path):
30
- empty_memory = tmp_path / "empty-memory"
31
- empty_memory.mkdir()
32
- result = get_unmined_files(str(empty_memory), [])
33
- assert result == []
34
-
35
-
36
- class TestUpdateMiningIndex:
37
- def test_adds_new_dates(self, tmp_memory_dir):
38
- memory_dir = str(tmp_memory_dir)
39
- playbook = (tmp_memory_dir / "sinain-playbook.md").read_text()
40
- update_mining_index(memory_dir, playbook, ["2026-02-22"])
41
-
42
- updated = (tmp_memory_dir / "sinain-playbook.md").read_text()
43
- index = parse_mining_index(updated)
44
- assert "2026-02-22" in index
45
-
46
- def test_deduplicates(self, tmp_memory_dir):
47
- memory_dir = str(tmp_memory_dir)
48
- playbook = (tmp_memory_dir / "sinain-playbook.md").read_text()
49
- # 2026-02-21 is already in index
50
- update_mining_index(memory_dir, playbook, ["2026-02-21"])
51
-
52
- updated = (tmp_memory_dir / "sinain-playbook.md").read_text()
53
- index = parse_mining_index(updated)
54
- assert index.count("2026-02-21") == 1
55
-
56
- def test_creates_playbook_if_missing(self, tmp_path):
57
- memory_dir = str(tmp_path)
58
- update_mining_index(memory_dir, "", ["2026-02-25"])
59
-
60
- playbook = (tmp_path / "sinain-playbook.md").read_text()
61
- assert "mining-index:" in playbook
62
- assert "2026-02-25" in playbook
63
-
64
- def test_sorted_descending(self, tmp_memory_dir):
65
- memory_dir = str(tmp_memory_dir)
66
- playbook = (tmp_memory_dir / "sinain-playbook.md").read_text()
67
- update_mining_index(memory_dir, playbook, ["2026-02-22", "2026-02-23"])
68
-
69
- updated = (tmp_memory_dir / "sinain-playbook.md").read_text()
70
- index = parse_mining_index(updated)
71
- assert index == sorted(index, reverse=True)
@@ -1,458 +0,0 @@
1
- """Tests for module_manager.py: management commands (no LLM)."""
2
-
3
- import json
4
- from pathlib import Path
5
- from io import StringIO
6
- from unittest.mock import patch
7
- import argparse
8
- import pytest
9
-
10
- from module_manager import (
11
- cmd_list,
12
- cmd_activate,
13
- cmd_suspend,
14
- cmd_priority,
15
- cmd_stack,
16
- cmd_info,
17
- cmd_guidance,
18
- cmd_export,
19
- cmd_import,
20
- _load_registry,
21
- _save_registry,
22
- )
23
-
24
-
25
- def _capture_stdout(func, modules_dir, args_dict=None):
26
- """Call a command function and capture its stdout JSON."""
27
- args = argparse.Namespace(**(args_dict or {}))
28
- with patch("sys.stdout", new_callable=StringIO) as mock_out:
29
- func(modules_dir, args)
30
- return json.loads(mock_out.getvalue())
31
-
32
-
33
- class TestCmdList:
34
- def test_lists_all_modules(self, tmp_modules_dir):
35
- result = _capture_stdout(cmd_list, tmp_modules_dir)
36
- modules = result["modules"]
37
- ids = [m["id"] for m in modules]
38
- assert "react-native-dev" in ids
39
- assert "ocr-pipeline" in ids
40
-
41
- def test_shows_status(self, tmp_modules_dir):
42
- result = _capture_stdout(cmd_list, tmp_modules_dir)
43
- modules = {m["id"]: m for m in result["modules"]}
44
- assert modules["react-native-dev"]["status"] == "active"
45
- assert modules["ocr-pipeline"]["status"] == "suspended"
46
-
47
- def test_empty_registry(self, tmp_path):
48
- modules = tmp_path / "modules"
49
- modules.mkdir()
50
- result = _capture_stdout(cmd_list, modules)
51
- assert result["modules"] == []
52
-
53
-
54
- class TestCmdActivate:
55
- def test_activate_suspended_module(self, tmp_modules_dir):
56
- # Create manifest for ocr-pipeline
57
- ocr_dir = tmp_modules_dir / "ocr-pipeline"
58
- ocr_dir.mkdir(exist_ok=True)
59
- (ocr_dir / "manifest.json").write_text(json.dumps({
60
- "id": "ocr-pipeline", "name": "OCR Pipeline",
61
- "priority": {"default": 70, "range": [50, 100]},
62
- }))
63
-
64
- result = _capture_stdout(cmd_activate, tmp_modules_dir, {"module_id": "ocr-pipeline", "priority": None})
65
- assert result["activated"] == "ocr-pipeline"
66
- assert result["status"] == "active"
67
-
68
- reg = _load_registry(tmp_modules_dir)
69
- assert reg["modules"]["ocr-pipeline"]["status"] == "active"
70
-
71
- def test_activate_with_custom_priority(self, tmp_modules_dir):
72
- ocr_dir = tmp_modules_dir / "ocr-pipeline"
73
- ocr_dir.mkdir(exist_ok=True)
74
- (ocr_dir / "manifest.json").write_text(json.dumps({
75
- "id": "ocr-pipeline", "name": "OCR Pipeline",
76
- "priority": {"default": 70, "range": [50, 100]},
77
- }))
78
-
79
- result = _capture_stdout(cmd_activate, tmp_modules_dir, {"module_id": "ocr-pipeline", "priority": 90})
80
- assert result["priority"] == 90
81
-
82
- def test_activate_out_of_range(self, tmp_modules_dir):
83
- ocr_dir = tmp_modules_dir / "ocr-pipeline"
84
- ocr_dir.mkdir(exist_ok=True)
85
- (ocr_dir / "manifest.json").write_text(json.dumps({
86
- "id": "ocr-pipeline", "name": "OCR Pipeline",
87
- "priority": {"default": 70, "range": [50, 100]},
88
- }))
89
-
90
- with pytest.raises(SystemExit):
91
- _capture_stdout(cmd_activate, tmp_modules_dir, {"module_id": "ocr-pipeline", "priority": 200})
92
-
93
-
94
- class TestCmdSuspend:
95
- def test_suspend_active_module(self, tmp_modules_dir):
96
- result = _capture_stdout(cmd_suspend, tmp_modules_dir, {"module_id": "react-native-dev"})
97
- assert result["suspended"] == "react-native-dev"
98
-
99
- reg = _load_registry(tmp_modules_dir)
100
- assert reg["modules"]["react-native-dev"]["status"] == "suspended"
101
-
102
- def test_suspend_locked_module(self, tmp_modules_dir):
103
- reg = _load_registry(tmp_modules_dir)
104
- reg["modules"]["react-native-dev"]["locked"] = True
105
- _save_registry(tmp_modules_dir, reg)
106
-
107
- with pytest.raises(SystemExit):
108
- _capture_stdout(cmd_suspend, tmp_modules_dir, {"module_id": "react-native-dev"})
109
-
110
- def test_suspend_nonexistent(self, tmp_modules_dir):
111
- with pytest.raises(SystemExit):
112
- _capture_stdout(cmd_suspend, tmp_modules_dir, {"module_id": "nonexistent"})
113
-
114
-
115
- class TestCmdPriority:
116
- def test_change_priority(self, tmp_modules_dir):
117
- result = _capture_stdout(cmd_priority, tmp_modules_dir,
118
- {"module_id": "react-native-dev", "new_priority": 95})
119
- assert result["priority"] == 95
120
-
121
- reg = _load_registry(tmp_modules_dir)
122
- assert reg["modules"]["react-native-dev"]["priority"] == 95
123
-
124
-
125
- class TestCmdStack:
126
- def test_shows_active_and_suspended(self, tmp_modules_dir):
127
- result = _capture_stdout(cmd_stack, tmp_modules_dir)
128
- assert len(result["active"]) == 1
129
- assert result["active"][0]["id"] == "react-native-dev"
130
- assert len(result["suspended"]) == 1
131
-
132
- def test_sorted_by_priority_desc(self, tmp_modules_dir):
133
- # Add another active module
134
- reg = _load_registry(tmp_modules_dir)
135
- reg["modules"]["other-module"] = {
136
- "status": "active", "priority": 95, "locked": False,
137
- "activatedAt": None, "lastTriggered": None,
138
- }
139
- _save_registry(tmp_modules_dir, reg)
140
-
141
- result = _capture_stdout(cmd_stack, tmp_modules_dir)
142
- priorities = [m["priority"] for m in result["active"]]
143
- assert priorities == sorted(priorities, reverse=True)
144
-
145
-
146
- class TestCmdInfo:
147
- def test_shows_module_info(self, tmp_modules_dir):
148
- result = _capture_stdout(cmd_info, tmp_modules_dir, {"module_id": "react-native-dev"})
149
- assert result["id"] == "react-native-dev"
150
- assert result["manifest"]["name"] == "React Native Development"
151
- assert result["registry"]["status"] == "active"
152
- assert result["patternsLines"] > 0
153
- assert result["guidanceChars"] == 0
154
-
155
- def test_info_with_guidance(self, tmp_modules_dir):
156
- guidance = "When user asks about hot reload, suggest Hermes bytecode caching"
157
- (tmp_modules_dir / "react-native-dev" / "guidance.md").write_text(guidance, encoding="utf-8")
158
- result = _capture_stdout(cmd_info, tmp_modules_dir, {"module_id": "react-native-dev"})
159
- assert result["guidanceChars"] == len(guidance)
160
-
161
- def test_nonexistent_module(self, tmp_modules_dir):
162
- with pytest.raises(SystemExit):
163
- _capture_stdout(cmd_info, tmp_modules_dir, {"module_id": "nonexistent"})
164
-
165
-
166
- class TestCmdGuidance:
167
- def test_view_empty_guidance(self, tmp_modules_dir):
168
- result = _capture_stdout(cmd_guidance, tmp_modules_dir, {
169
- "module_id": "react-native-dev", "set": None, "clear": False,
170
- })
171
- assert result["module"] == "react-native-dev"
172
- assert result["hasGuidance"] is False
173
- assert result["guidance"] == ""
174
-
175
- def test_set_guidance(self, tmp_modules_dir):
176
- text = "When user asks about hot reload, suggest Hermes bytecode caching"
177
- result = _capture_stdout(cmd_guidance, tmp_modules_dir, {
178
- "module_id": "react-native-dev", "set": text, "clear": False,
179
- })
180
- assert result["written"] is True
181
- assert result["guidanceChars"] == len(text)
182
- # Verify file on disk
183
- guidance_path = tmp_modules_dir / "react-native-dev" / "guidance.md"
184
- assert guidance_path.read_text(encoding="utf-8") == text
185
-
186
- def test_view_existing_guidance(self, tmp_modules_dir):
187
- text = "Proactively suggest frame batching for OCR"
188
- (tmp_modules_dir / "react-native-dev" / "guidance.md").write_text(text, encoding="utf-8")
189
- result = _capture_stdout(cmd_guidance, tmp_modules_dir, {
190
- "module_id": "react-native-dev", "set": None, "clear": False,
191
- })
192
- assert result["hasGuidance"] is True
193
- assert result["guidance"] == text
194
-
195
- def test_clear_guidance(self, tmp_modules_dir):
196
- (tmp_modules_dir / "react-native-dev" / "guidance.md").write_text("some guidance", encoding="utf-8")
197
- result = _capture_stdout(cmd_guidance, tmp_modules_dir, {
198
- "module_id": "react-native-dev", "set": None, "clear": True,
199
- })
200
- assert result["cleared"] is True
201
- assert not (tmp_modules_dir / "react-native-dev" / "guidance.md").exists()
202
-
203
- def test_nonexistent_module(self, tmp_modules_dir):
204
- with pytest.raises(SystemExit):
205
- _capture_stdout(cmd_guidance, tmp_modules_dir, {
206
- "module_id": "nonexistent", "set": None, "clear": False,
207
- })
208
-
209
-
210
- class TestCmdExport:
211
- def test_export_produces_valid_bundle(self, tmp_modules_dir, tmp_path):
212
- output = tmp_path / "export.sinain-module.json"
213
- result = _capture_stdout(cmd_export, tmp_modules_dir, {
214
- "module_id": "react-native-dev",
215
- "output": str(output),
216
- })
217
- assert result["exported"] == "react-native-dev"
218
- assert output.exists()
219
-
220
- bundle = json.loads(output.read_text(encoding="utf-8"))
221
- assert bundle["format"] == "sinain-module-v1"
222
- assert bundle["moduleId"] == "react-native-dev"
223
- assert "exportedAt" in bundle
224
- assert bundle["manifest"]["name"] == "React Native Development"
225
- assert "Hermes" in bundle["patterns"]
226
-
227
- def test_export_includes_context_files(self, tmp_modules_dir, tmp_path):
228
- # Add a context file
229
- ctx_dir = tmp_modules_dir / "react-native-dev" / "context"
230
- ctx_dir.mkdir()
231
- (ctx_dir / "notes.json").write_text('{"key": "value"}', encoding="utf-8")
232
-
233
- output = tmp_path / "export.sinain-module.json"
234
- _capture_stdout(cmd_export, tmp_modules_dir, {
235
- "module_id": "react-native-dev",
236
- "output": str(output),
237
- })
238
- bundle = json.loads(output.read_text(encoding="utf-8"))
239
- assert "notes.json" in bundle["context"]
240
- assert bundle["context"]["notes.json"] == '{"key": "value"}'
241
-
242
- def test_export_default_output_path(self, tmp_modules_dir):
243
- result = _capture_stdout(cmd_export, tmp_modules_dir, {
244
- "module_id": "react-native-dev",
245
- "output": None,
246
- })
247
- default_path = Path("react-native-dev.sinain-module.json")
248
- assert result["outputPath"] == str(default_path)
249
- # Clean up
250
- if default_path.exists():
251
- default_path.unlink()
252
-
253
- def test_export_includes_guidance(self, tmp_modules_dir, tmp_path):
254
- guidance = "Suggest Hermes bytecode caching for hot reload"
255
- (tmp_modules_dir / "react-native-dev" / "guidance.md").write_text(guidance, encoding="utf-8")
256
- output = tmp_path / "export.sinain-module.json"
257
- result = _capture_stdout(cmd_export, tmp_modules_dir, {
258
- "module_id": "react-native-dev",
259
- "output": str(output),
260
- })
261
- assert result["guidanceChars"] == len(guidance)
262
- bundle = json.loads(output.read_text(encoding="utf-8"))
263
- assert bundle["guidance"] == guidance
264
-
265
- def test_export_no_guidance(self, tmp_modules_dir, tmp_path):
266
- output = tmp_path / "export.sinain-module.json"
267
- _capture_stdout(cmd_export, tmp_modules_dir, {
268
- "module_id": "react-native-dev",
269
- "output": str(output),
270
- })
271
- bundle = json.loads(output.read_text(encoding="utf-8"))
272
- assert bundle["guidance"] == ""
273
-
274
- def test_export_nonexistent_module(self, tmp_modules_dir):
275
- with pytest.raises(SystemExit):
276
- _capture_stdout(cmd_export, tmp_modules_dir, {
277
- "module_id": "nonexistent",
278
- "output": None,
279
- })
280
-
281
-
282
- class TestCmdImport:
283
- def _make_bundle(self, tmp_path, module_id="test-module", patterns="# Test\n- pattern 1\n",
284
- guidance="", context=None, manifest_extra=None):
285
- """Helper to create a valid bundle file."""
286
- manifest = {
287
- "id": module_id,
288
- "name": "Test Module",
289
- "version": "1.0.0",
290
- "priority": {"default": 75, "range": [50, 100]},
291
- "locked": False,
292
- }
293
- if manifest_extra:
294
- manifest.update(manifest_extra)
295
- bundle = {
296
- "format": "sinain-module-v1",
297
- "moduleId": module_id,
298
- "exportedAt": "2026-03-05T12:00:00Z",
299
- "manifest": manifest,
300
- "patterns": patterns,
301
- "guidance": guidance,
302
- "context": context or {},
303
- }
304
- path = tmp_path / f"{module_id}.sinain-module.json"
305
- path.write_text(json.dumps(bundle, indent=2), encoding="utf-8")
306
- return path
307
-
308
- def test_import_creates_module(self, tmp_modules_dir, tmp_path):
309
- bundle_path = self._make_bundle(tmp_path)
310
- result = _capture_stdout(cmd_import, tmp_modules_dir, {
311
- "bundle": str(bundle_path),
312
- "activate": False,
313
- "force": False,
314
- })
315
- assert result["imported"] == "test-module"
316
- assert result["status"] == "suspended"
317
-
318
- # Check files created
319
- module_dir = tmp_modules_dir / "test-module"
320
- assert (module_dir / "manifest.json").exists()
321
- assert (module_dir / "patterns.md").exists()
322
-
323
- manifest = json.loads((module_dir / "manifest.json").read_text(encoding="utf-8"))
324
- assert "importedAt" in manifest
325
- assert manifest["source"] == "module_manager import"
326
-
327
- def test_import_registers_as_suspended(self, tmp_modules_dir, tmp_path):
328
- bundle_path = self._make_bundle(tmp_path)
329
- _capture_stdout(cmd_import, tmp_modules_dir, {
330
- "bundle": str(bundle_path),
331
- "activate": False,
332
- "force": False,
333
- })
334
- reg = _load_registry(tmp_modules_dir)
335
- assert reg["modules"]["test-module"]["status"] == "suspended"
336
- assert reg["modules"]["test-module"]["activatedAt"] is None
337
-
338
- def test_import_with_activate(self, tmp_modules_dir, tmp_path):
339
- bundle_path = self._make_bundle(tmp_path)
340
- result = _capture_stdout(cmd_import, tmp_modules_dir, {
341
- "bundle": str(bundle_path),
342
- "activate": True,
343
- "force": False,
344
- })
345
- assert result["status"] == "active"
346
-
347
- reg = _load_registry(tmp_modules_dir)
348
- assert reg["modules"]["test-module"]["status"] == "active"
349
- assert reg["modules"]["test-module"]["activatedAt"] is not None
350
-
351
- def test_import_with_context_files(self, tmp_modules_dir, tmp_path):
352
- bundle_path = self._make_bundle(tmp_path, context={"config.json": '{"x": 1}'})
353
- _capture_stdout(cmd_import, tmp_modules_dir, {
354
- "bundle": str(bundle_path),
355
- "activate": False,
356
- "force": False,
357
- })
358
- ctx_file = tmp_modules_dir / "test-module" / "context" / "config.json"
359
- assert ctx_file.exists()
360
- assert ctx_file.read_text(encoding="utf-8") == '{"x": 1}'
361
-
362
- def test_import_with_guidance(self, tmp_modules_dir, tmp_path):
363
- guidance = "- Suggest frame batching for OCR\n- Prefer concise responses"
364
- bundle_path = self._make_bundle(tmp_path, guidance=guidance)
365
- _capture_stdout(cmd_import, tmp_modules_dir, {
366
- "bundle": str(bundle_path),
367
- "activate": False,
368
- "force": False,
369
- })
370
- guidance_file = tmp_modules_dir / "test-module" / "guidance.md"
371
- assert guidance_file.exists()
372
- assert guidance_file.read_text(encoding="utf-8") == guidance
373
-
374
- def test_import_without_guidance(self, tmp_modules_dir, tmp_path):
375
- bundle_path = self._make_bundle(tmp_path, guidance="")
376
- _capture_stdout(cmd_import, tmp_modules_dir, {
377
- "bundle": str(bundle_path),
378
- "activate": False,
379
- "force": False,
380
- })
381
- assert not (tmp_modules_dir / "test-module" / "guidance.md").exists()
382
-
383
- def test_import_existing_module_fails_without_force(self, tmp_modules_dir, tmp_path):
384
- # react-native-dev already exists in tmp_modules_dir
385
- bundle_path = self._make_bundle(tmp_path, module_id="react-native-dev")
386
- with pytest.raises(SystemExit):
387
- _capture_stdout(cmd_import, tmp_modules_dir, {
388
- "bundle": str(bundle_path),
389
- "activate": False,
390
- "force": False,
391
- })
392
-
393
- def test_import_with_force_overwrites(self, tmp_modules_dir, tmp_path):
394
- bundle_path = self._make_bundle(
395
- tmp_path, module_id="react-native-dev",
396
- patterns="# Overwritten\n- new pattern\n",
397
- )
398
- result = _capture_stdout(cmd_import, tmp_modules_dir, {
399
- "bundle": str(bundle_path),
400
- "activate": False,
401
- "force": True,
402
- })
403
- assert result["imported"] == "react-native-dev"
404
-
405
- patterns = (tmp_modules_dir / "react-native-dev" / "patterns.md").read_text(encoding="utf-8")
406
- assert "Overwritten" in patterns
407
-
408
- def test_import_invalid_format_fails(self, tmp_modules_dir, tmp_path):
409
- bad_bundle = tmp_path / "bad.sinain-module.json"
410
- bad_bundle.write_text(json.dumps({"format": "unknown-v99"}), encoding="utf-8")
411
- with pytest.raises(SystemExit):
412
- _capture_stdout(cmd_import, tmp_modules_dir, {
413
- "bundle": str(bad_bundle),
414
- "activate": False,
415
- "force": False,
416
- })
417
-
418
- def test_roundtrip_export_import(self, tmp_modules_dir, tmp_path):
419
- """Export a module, import into same instance under new name, verify match."""
420
- # Add guidance before export
421
- guidance = "- Suggest Hermes bytecode caching\n- Recommend flipper for debugging"
422
- (tmp_modules_dir / "react-native-dev" / "guidance.md").write_text(guidance, encoding="utf-8")
423
-
424
- # Export existing module
425
- export_path = tmp_path / "exported.sinain-module.json"
426
- _capture_stdout(cmd_export, tmp_modules_dir, {
427
- "module_id": "react-native-dev",
428
- "output": str(export_path),
429
- })
430
-
431
- # Modify bundle to use different module ID (simulates transfer)
432
- bundle = json.loads(export_path.read_text(encoding="utf-8"))
433
- bundle["moduleId"] = "rn-dev-copy"
434
- bundle["manifest"]["id"] = "rn-dev-copy"
435
- modified_path = tmp_path / "modified.sinain-module.json"
436
- modified_path.write_text(json.dumps(bundle), encoding="utf-8")
437
-
438
- # Import
439
- result = _capture_stdout(cmd_import, tmp_modules_dir, {
440
- "bundle": str(modified_path),
441
- "activate": True,
442
- "force": False,
443
- })
444
- assert result["imported"] == "rn-dev-copy"
445
-
446
- # Verify patterns match original
447
- original = (tmp_modules_dir / "react-native-dev" / "patterns.md").read_text(encoding="utf-8")
448
- copied = (tmp_modules_dir / "rn-dev-copy" / "patterns.md").read_text(encoding="utf-8")
449
- assert original == copied
450
-
451
- # Verify guidance survives roundtrip
452
- copied_guidance = (tmp_modules_dir / "rn-dev-copy" / "guidance.md").read_text(encoding="utf-8")
453
- assert copied_guidance == guidance
454
-
455
- # Verify manifest has import metadata
456
- manifest = json.loads((tmp_modules_dir / "rn-dev-copy" / "manifest.json").read_text(encoding="utf-8"))
457
- assert manifest["importedAt"] is not None
458
- assert manifest["name"] == "React Native Development"