@geravant/sinain 1.0.19 → 1.2.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 (80) hide show
  1. package/README.md +10 -1
  2. package/cli.js +176 -0
  3. package/index.ts +4 -2
  4. package/install.js +89 -14
  5. package/launcher.js +622 -0
  6. package/openclaw.plugin.json +4 -0
  7. package/pack-prepare.js +48 -0
  8. package/package.json +24 -5
  9. package/sense_client/README.md +82 -0
  10. package/sense_client/__init__.py +1 -0
  11. package/sense_client/__main__.py +462 -0
  12. package/sense_client/app_detector.py +54 -0
  13. package/sense_client/app_detector_win.py +83 -0
  14. package/sense_client/capture.py +215 -0
  15. package/sense_client/capture_win.py +88 -0
  16. package/sense_client/change_detector.py +86 -0
  17. package/sense_client/config.py +64 -0
  18. package/sense_client/gate.py +145 -0
  19. package/sense_client/ocr.py +347 -0
  20. package/sense_client/privacy.py +65 -0
  21. package/sense_client/requirements.txt +13 -0
  22. package/sense_client/roi_extractor.py +84 -0
  23. package/sense_client/sender.py +173 -0
  24. package/sense_client/tests/__init__.py +0 -0
  25. package/sense_client/tests/test_stream1_optimizations.py +234 -0
  26. package/setup-overlay.js +82 -0
  27. package/sinain-agent/.env.example +17 -0
  28. package/sinain-agent/CLAUDE.md +87 -0
  29. package/sinain-agent/mcp-config.json +12 -0
  30. package/sinain-agent/run.sh +248 -0
  31. package/sinain-core/.env.example +93 -0
  32. package/sinain-core/package-lock.json +552 -0
  33. package/sinain-core/package.json +21 -0
  34. package/sinain-core/src/agent/analyzer.ts +366 -0
  35. package/sinain-core/src/agent/context-window.ts +172 -0
  36. package/sinain-core/src/agent/loop.ts +404 -0
  37. package/sinain-core/src/agent/situation-writer.ts +187 -0
  38. package/sinain-core/src/agent/traits.ts +520 -0
  39. package/sinain-core/src/audio/capture-spawner-macos.ts +44 -0
  40. package/sinain-core/src/audio/capture-spawner-win.ts +37 -0
  41. package/sinain-core/src/audio/capture-spawner.ts +14 -0
  42. package/sinain-core/src/audio/pipeline.ts +335 -0
  43. package/sinain-core/src/audio/transcription-local.ts +141 -0
  44. package/sinain-core/src/audio/transcription.ts +278 -0
  45. package/sinain-core/src/buffers/feed-buffer.ts +71 -0
  46. package/sinain-core/src/buffers/sense-buffer.ts +425 -0
  47. package/sinain-core/src/config.ts +245 -0
  48. package/sinain-core/src/escalation/escalation-slot.ts +136 -0
  49. package/sinain-core/src/escalation/escalator.ts +828 -0
  50. package/sinain-core/src/escalation/message-builder.ts +370 -0
  51. package/sinain-core/src/escalation/openclaw-ws.ts +726 -0
  52. package/sinain-core/src/escalation/scorer.ts +166 -0
  53. package/sinain-core/src/index.ts +537 -0
  54. package/sinain-core/src/learning/feedback-store.ts +253 -0
  55. package/sinain-core/src/learning/signal-collector.ts +218 -0
  56. package/sinain-core/src/log.ts +24 -0
  57. package/sinain-core/src/overlay/commands.ts +126 -0
  58. package/sinain-core/src/overlay/ws-handler.ts +267 -0
  59. package/sinain-core/src/privacy/index.ts +18 -0
  60. package/sinain-core/src/privacy/presets.ts +40 -0
  61. package/sinain-core/src/privacy/redact.ts +92 -0
  62. package/sinain-core/src/profiler.ts +181 -0
  63. package/sinain-core/src/recorder.ts +186 -0
  64. package/sinain-core/src/server.ts +456 -0
  65. package/sinain-core/src/trace/trace-store.ts +73 -0
  66. package/sinain-core/src/trace/tracer.ts +94 -0
  67. package/sinain-core/src/types.ts +427 -0
  68. package/sinain-core/src/util/dedup.ts +48 -0
  69. package/sinain-core/src/util/task-store.ts +84 -0
  70. package/sinain-core/tsconfig.json +18 -0
  71. package/sinain-knowledge/curation/engine.ts +137 -24
  72. package/sinain-knowledge/data/git-store.ts +26 -0
  73. package/sinain-knowledge/data/store.ts +117 -0
  74. package/sinain-mcp-server/index.ts +417 -0
  75. package/sinain-mcp-server/package.json +19 -0
  76. package/sinain-mcp-server/tsconfig.json +15 -0
  77. package/sinain-memory/graph_query.py +185 -0
  78. package/sinain-memory/knowledge_integrator.py +450 -0
  79. package/sinain-memory/memory-config.json +3 -1
  80. package/sinain-memory/session_distiller.py +162 -0
@@ -0,0 +1,234 @@
1
+ """Tests for Stream 1 optimizations: adaptive cooldown, adaptive SSIM,
2
+ parallel OCR, skip image for text events, latency instrumentation."""
3
+
4
+ import time
5
+ import unittest
6
+ from unittest.mock import MagicMock, patch
7
+
8
+ from sense_client.change_detector import ChangeDetector, ChangeResult
9
+ from sense_client.config import load_config, DEFAULTS
10
+ from sense_client.gate import DecisionGate, SenseEvent, SenseMeta
11
+ from sense_client.ocr import OCRResult
12
+ from sense_client.sender import SenseSender
13
+
14
+
15
+ class TestConfig(unittest.TestCase):
16
+ """Test that adaptiveCooldownMs is present in defaults."""
17
+
18
+ def test_defaults_has_adaptive_cooldown(self):
19
+ config = load_config(None)
20
+ self.assertIn("adaptiveCooldownMs", config["gate"])
21
+ self.assertEqual(config["gate"]["adaptiveCooldownMs"], 2000)
22
+
23
+ def test_defaults_cooldown_unchanged(self):
24
+ config = load_config(None)
25
+ self.assertEqual(config["gate"]["cooldownMs"], 5000)
26
+
27
+
28
+ class TestChangeDetectorSetThreshold(unittest.TestCase):
29
+ """Test dynamic SSIM threshold adjustment."""
30
+
31
+ def test_set_threshold(self):
32
+ det = ChangeDetector(threshold=0.92)
33
+ self.assertEqual(det.threshold, 0.92)
34
+ det.set_threshold(0.85)
35
+ self.assertEqual(det.threshold, 0.85)
36
+ det.set_threshold(0.95)
37
+ self.assertEqual(det.threshold, 0.95)
38
+
39
+
40
+ class TestAdaptiveCooldown(unittest.TestCase):
41
+ """Test that DecisionGate uses shorter cooldown after app switch."""
42
+
43
+ def _make_change(self, ssim=0.80):
44
+ return ChangeResult(
45
+ ssim_score=ssim,
46
+ diff_image=MagicMock(),
47
+ contours=[],
48
+ bbox=(0, 0, 100, 100),
49
+ )
50
+
51
+ def _make_ocr(self, text=""):
52
+ return OCRResult(text=text, confidence=90, word_count=len(text.split()))
53
+
54
+ def test_init_has_adaptive_params(self):
55
+ gate = DecisionGate(adaptive_cooldown_ms=2000)
56
+ self.assertEqual(gate.adaptive_cooldown_ms, 2000)
57
+ self.assertEqual(gate.last_app_change_ts, 0)
58
+
59
+ def test_app_change_sets_timestamp(self):
60
+ gate = DecisionGate(cooldown_ms=5000, adaptive_cooldown_ms=2000,
61
+ context_cooldown_ms=0)
62
+ change = self._make_change()
63
+ ocr = self._make_ocr()
64
+
65
+ # First call with app_changed — should set last_app_change_ts
66
+ event = gate.classify(change, ocr, app_changed=True)
67
+ self.assertIsNotNone(event)
68
+ self.assertEqual(event.type, "context")
69
+ self.assertGreater(gate.last_app_change_ts, 0)
70
+
71
+ def test_adaptive_cooldown_shorter_after_app_switch(self):
72
+ gate = DecisionGate(cooldown_ms=5000, adaptive_cooldown_ms=2000,
73
+ context_cooldown_ms=0)
74
+ long_text = "This is a sufficiently long OCR text for testing purposes here"
75
+ change = self._make_change(ssim=0.80)
76
+ ocr = self._make_ocr(long_text)
77
+
78
+ # Trigger app change to set last_app_change_ts
79
+ gate.classify(change, ocr, app_changed=True)
80
+
81
+ # Immediately after: should use 2s adaptive cooldown
82
+ # Within 2s window, should be gated
83
+ event = gate.classify(change, ocr, app_changed=False)
84
+ self.assertIsNone(event) # within 2s cooldown
85
+
86
+ # Simulate 2.1s passing by adjusting timestamps
87
+ gate.last_send_ts -= 2100 # pretend 2.1s have passed
88
+ gate.last_app_change_ts = time.time() * 1000 - 1000 # app change 1s ago (within 10s)
89
+
90
+ event = gate.classify(change, self._make_ocr(long_text + " extra"), app_changed=False)
91
+ self.assertIsNotNone(event)
92
+ self.assertEqual(event.type, "text")
93
+
94
+ def test_normal_cooldown_when_no_recent_app_change(self):
95
+ gate = DecisionGate(cooldown_ms=5000, adaptive_cooldown_ms=2000,
96
+ context_cooldown_ms=10000)
97
+ long_text = "This is a sufficiently long OCR text for testing purposes here"
98
+ change = self._make_change(ssim=0.80)
99
+ ocr = self._make_ocr(long_text)
100
+
101
+ # No app change ever — last_app_change_ts is 0
102
+ # Send an initial event
103
+ event = gate.classify(change, ocr, app_changed=False)
104
+ self.assertIsNotNone(event)
105
+
106
+ # Try again after 3s (> adaptive but < normal cooldown)
107
+ gate.last_send_ts -= 3000
108
+ event = gate.classify(change, self._make_ocr(long_text + " more"), app_changed=False)
109
+ # Should be gated because no recent app change → uses 5s cooldown
110
+ self.assertIsNone(event)
111
+
112
+
113
+ class TestSkipImageForTextEvents(unittest.TestCase):
114
+ """Verify that text events don't get image data packaged."""
115
+
116
+ def test_text_event_has_no_roi(self):
117
+ """Simulate what __main__.py does: text events skip package_roi."""
118
+ event = SenseEvent(type="text", ts=time.time() * 1000, ocr="some text")
119
+ # The logic in __main__.py:
120
+ # if event.type == "context": package_full_frame
121
+ # elif event.type == "visual" and rois: package_roi
122
+ # text events: nothing
123
+ if event.type == "context":
124
+ event.roi = {"data": "base64..."}
125
+ elif event.type == "visual":
126
+ event.roi = {"data": "base64..."}
127
+ # text: no roi set
128
+ self.assertIsNone(event.roi)
129
+
130
+ def test_visual_event_gets_roi(self):
131
+ event = SenseEvent(type="visual", ts=time.time() * 1000, ocr="")
132
+ rois = [MagicMock()]
133
+ if event.type == "visual" and rois:
134
+ event.roi = {"data": "base64...", "bbox": [0, 0, 100, 100]}
135
+ self.assertIsNotNone(event.roi)
136
+
137
+
138
+ class TestSenderLatencyTracking(unittest.TestCase):
139
+ """Test that SenseSender tracks send latencies."""
140
+
141
+ def test_init_has_latency_fields(self):
142
+ sender = SenseSender()
143
+ self.assertIsInstance(sender._latencies, list)
144
+ self.assertEqual(len(sender._latencies), 0)
145
+ self.assertIsInstance(sender._last_stats_ts, float)
146
+
147
+ @patch("sense_client.sender.requests.post")
148
+ def test_send_tracks_latency(self, mock_post):
149
+ mock_resp = MagicMock()
150
+ mock_resp.status_code = 200
151
+ mock_post.return_value = mock_resp
152
+
153
+ sender = SenseSender(url="http://localhost:18791")
154
+ event = SenseEvent(type="text", ts=time.time() * 1000, ocr="test",
155
+ meta=SenseMeta(ssim=0.9, app="test"))
156
+
157
+ ok = sender.send(event)
158
+ self.assertTrue(ok)
159
+ self.assertEqual(len(sender._latencies), 1)
160
+ self.assertGreater(sender._latencies[0], 0)
161
+
162
+ @patch("sense_client.sender.requests.post")
163
+ def test_stats_logged_after_interval(self, mock_post):
164
+ mock_resp = MagicMock()
165
+ mock_resp.status_code = 200
166
+ mock_post.return_value = mock_resp
167
+
168
+ sender = SenseSender(url="http://localhost:18791")
169
+ sender._last_stats_ts = time.time() - 61 # pretend 61s ago
170
+
171
+ event = SenseEvent(type="text", ts=time.time() * 1000, ocr="test",
172
+ meta=SenseMeta(ssim=0.9, app="test"))
173
+
174
+ with patch("builtins.print") as mock_print:
175
+ sender.send(event)
176
+ # Should have logged stats
177
+ calls = [str(c) for c in mock_print.call_args_list]
178
+ stats_logged = any("[sender] relay latency" in c for c in calls)
179
+ self.assertTrue(stats_logged, f"Expected latency stats log, got: {calls}")
180
+
181
+ @patch("sense_client.sender.requests.post")
182
+ def test_stats_not_logged_before_interval(self, mock_post):
183
+ mock_resp = MagicMock()
184
+ mock_resp.status_code = 200
185
+ mock_post.return_value = mock_resp
186
+
187
+ sender = SenseSender(url="http://localhost:18791")
188
+ # _last_stats_ts defaults to now, so interval not elapsed
189
+
190
+ event = SenseEvent(type="text", ts=time.time() * 1000, ocr="test",
191
+ meta=SenseMeta(ssim=0.9, app="test"))
192
+
193
+ with patch("builtins.print") as mock_print:
194
+ sender.send(event)
195
+ calls = [str(c) for c in mock_print.call_args_list]
196
+ stats_logged = any("[sender] relay latency" in c for c in calls)
197
+ self.assertFalse(stats_logged)
198
+
199
+
200
+ class TestParallelOCR(unittest.TestCase):
201
+ """Test parallel OCR logic from __main__.py."""
202
+
203
+ def test_single_roi_no_threadpool(self):
204
+ """With 1 ROI, OCR is called directly (no ThreadPoolExecutor)."""
205
+ mock_ocr = MagicMock()
206
+ mock_ocr.extract.return_value = OCRResult(text="hello world test text here", confidence=90, word_count=5)
207
+
208
+ roi = MagicMock()
209
+ rois = [roi]
210
+
211
+ # Simulate single-ROI path
212
+ if len(rois) == 1:
213
+ result = mock_ocr.extract(rois[0].image)
214
+ else:
215
+ result = None
216
+
217
+ self.assertIsNotNone(result)
218
+ self.assertEqual(result.text, "hello world test text here")
219
+ mock_ocr.extract.assert_called_once()
220
+
221
+ def test_multiple_rois_picks_best(self):
222
+ """With multiple ROIs, pick the result with most text."""
223
+ results = [
224
+ OCRResult(text="short", confidence=90, word_count=1),
225
+ OCRResult(text="this is the longest text result here", confidence=85, word_count=7),
226
+ OCRResult(text="medium text", confidence=88, word_count=2),
227
+ ]
228
+
229
+ best = max(results, key=lambda r: len(r.text))
230
+ self.assertEqual(best.text, "this is the longest text result here")
231
+
232
+
233
+ if __name__ == "__main__":
234
+ unittest.main()
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // sinain setup-overlay — clone and build the Flutter overlay app
3
+
4
+ import { execSync } from "child_process";
5
+ import fs from "fs";
6
+ import path from "path";
7
+ import os from "os";
8
+
9
+ const HOME = os.homedir();
10
+ const SINAIN_DIR = path.join(HOME, ".sinain");
11
+ const REPO_DIR = path.join(SINAIN_DIR, "overlay-repo");
12
+ const OVERLAY_LINK = path.join(SINAIN_DIR, "overlay");
13
+
14
+ const BOLD = "\x1b[1m";
15
+ const GREEN = "\x1b[32m";
16
+ const RED = "\x1b[31m";
17
+ const RESET = "\x1b[0m";
18
+
19
+ function log(msg) { console.log(`${BOLD}[setup-overlay]${RESET} ${msg}`); }
20
+ function ok(msg) { console.log(`${BOLD}[setup-overlay]${RESET} ${GREEN}✓${RESET} ${msg}`); }
21
+ function fail(msg) { console.error(`${BOLD}[setup-overlay]${RESET} ${RED}✗${RESET} ${msg}`); process.exit(1); }
22
+
23
+ // Check flutter
24
+ try {
25
+ execSync("which flutter", { stdio: "pipe" });
26
+ } catch {
27
+ fail("flutter not found. Install it: https://docs.flutter.dev/get-started/install");
28
+ }
29
+
30
+ const flutterVer = execSync("flutter --version 2>&1", { encoding: "utf-8" }).split("\n")[0];
31
+ ok(`flutter: ${flutterVer}`);
32
+
33
+ fs.mkdirSync(SINAIN_DIR, { recursive: true });
34
+
35
+ // Clone or update
36
+ if (fs.existsSync(path.join(REPO_DIR, ".git"))) {
37
+ log("Updating existing overlay repo...");
38
+ execSync("git pull --ff-only", { cwd: REPO_DIR, stdio: "inherit" });
39
+ ok("Repository updated");
40
+ } else {
41
+ log("Cloning overlay (sparse checkout — only overlay/ directory)...");
42
+ if (fs.existsSync(REPO_DIR)) {
43
+ fs.rmSync(REPO_DIR, { recursive: true, force: true });
44
+ }
45
+ execSync(
46
+ `git clone --depth 1 --filter=blob:none --sparse https://github.com/anthillnet/sinain-hud.git "${REPO_DIR}"`,
47
+ { stdio: "inherit" }
48
+ );
49
+ execSync("git sparse-checkout set overlay", { cwd: REPO_DIR, stdio: "inherit" });
50
+ ok("Repository cloned");
51
+ }
52
+
53
+ // Build
54
+ const overlayDir = path.join(REPO_DIR, "overlay");
55
+ if (!fs.existsSync(path.join(overlayDir, "pubspec.yaml"))) {
56
+ fail("overlay/pubspec.yaml not found — sparse checkout may have failed");
57
+ }
58
+
59
+ log("Installing Flutter dependencies...");
60
+ execSync("flutter pub get", { cwd: overlayDir, stdio: "inherit" });
61
+
62
+ log("Building overlay (this may take a few minutes)...");
63
+ execSync("flutter build macos", { cwd: overlayDir, stdio: "inherit" });
64
+ ok("Overlay built successfully");
65
+
66
+ // Symlink ~/.sinain/overlay → the overlay source dir
67
+ try {
68
+ if (fs.existsSync(OVERLAY_LINK)) {
69
+ fs.unlinkSync(OVERLAY_LINK);
70
+ }
71
+ fs.symlinkSync(overlayDir, OVERLAY_LINK);
72
+ ok(`Symlinked: ${OVERLAY_LINK} → ${overlayDir}`);
73
+ } catch (e) {
74
+ // Symlink may fail on some systems — fall back to just noting the path
75
+ log(`Overlay built at: ${overlayDir}`);
76
+ }
77
+
78
+ console.log(`
79
+ ${GREEN}✓${RESET} Overlay setup complete!
80
+ The overlay will auto-start with: ${BOLD}sinain start${RESET}
81
+ Or run manually: cd ${overlayDir} && flutter run -d macos
82
+ `);
@@ -0,0 +1,17 @@
1
+ # sinain-agent configuration
2
+ # Copy to .env and customize: cp .env.example .env
3
+
4
+ # ── Agent ──
5
+ SINAIN_AGENT=claude # claude | codex | junie | goose | aider | <custom command>
6
+ # MCP agents (claude, codex, junie, goose) call sinain tools directly
7
+ # Pipe agents (aider, custom) receive escalation text on stdin
8
+
9
+ # ── Core connection ──
10
+ SINAIN_CORE_URL=http://localhost:9500
11
+
12
+ # ── Timing ──
13
+ SINAIN_POLL_INTERVAL=5 # seconds between escalation polls
14
+ SINAIN_HEARTBEAT_INTERVAL=900 # seconds between heartbeat ticks (15 min)
15
+
16
+ # ── Workspace ──
17
+ SINAIN_WORKSPACE=~/.openclaw/workspace # knowledge files, curation scripts, playbook
@@ -0,0 +1,87 @@
1
+ # Sinain HUD Agent
2
+
3
+ You are a coding assistant connected to sinain-hud, a privacy-first AI overlay for macOS. You observe the user's screen and audio context via the HUD system and provide real-time advice displayed on an invisible overlay.
4
+
5
+ ## Your Tools
6
+
7
+ You have MCP tools from `sinain-mcp-server`:
8
+ - `sinain_get_escalation` — poll for pending escalation (call every 3-5 seconds in your main loop)
9
+ - `sinain_respond` — submit your response to an escalation (appears on user's HUD)
10
+ - `sinain_get_context` — get the full context window (screen OCR, audio transcripts, app history)
11
+ - `sinain_get_digest` — get the current agent analysis summary
12
+ - `sinain_get_feedback` — get feedback signals from recent escalations
13
+ - `sinain_post_feed` — push an arbitrary message to the HUD
14
+ - `sinain_health` — check system health
15
+ - `sinain_get_knowledge` — get the portable knowledge document (playbook + long-term facts + sessions)
16
+ - `sinain_knowledge_query` — query the knowledge graph for facts about specific entities/domains
17
+ - `sinain_distill_session` — explicitly distill the current session into knowledge updates
18
+ - `sinain_heartbeat_tick` — run the heartbeat pipeline (git backup, signals, distillation, insights)
19
+ - `sinain_module_guidance` — get active module guidance
20
+
21
+ ## Main Loop
22
+
23
+ Your primary job is an escalation response loop:
24
+
25
+ 1. Call `sinain_get_escalation` to check for pending escalations
26
+ 2. If an escalation is present:
27
+ a. Read the escalation message carefully — it contains screen OCR, audio transcripts, app context, and the local agent's digest
28
+ b. Optionally call `sinain_get_knowledge` to read the knowledge document, or `sinain_knowledge_query` with specific entities to enrich your response
29
+ c. Optionally call `sinain_module_guidance` to get active module instructions
30
+ d. Craft a response and call `sinain_respond` with the escalation ID and your response
31
+ 3. If no escalation is pending, wait a few seconds and poll again
32
+ 4. Every 15 minutes, run `sinain_heartbeat_tick` for curation maintenance
33
+
34
+ ## Response Guidelines
35
+
36
+ When responding to escalations:
37
+
38
+ - **5-10 sentences** — concise but substantive
39
+ - **Address errors first** — if the context shows errors, stack traces, or failures, diagnose and suggest fixes
40
+ - **Reference specific context** — quote screen text or audio when relevant ("I see you have a TypeError on line 42...")
41
+ - **Coding context** — if the user is in an IDE or coding platform, focus on code-level help (fixes, patterns, suggestions). Max 4000 chars.
42
+ - **Non-coding context** — share insights, connections, tips relevant to what's on screen. Max 3000 chars.
43
+ - **Never NO_REPLY** — always provide value. If context is minimal, share a relevant insight or tech joke.
44
+ - **Never describe what the user is doing** — they can see their own screen. Add value, don't narrate.
45
+
46
+ ## Heartbeat Cycle (every 15 minutes)
47
+
48
+ 1. Call `sinain_heartbeat_tick` with a brief session summary
49
+ 2. The tool runs the full pipeline automatically:
50
+ - Git backup of memory directory
51
+ - Signal analysis (detects opportunities from session patterns)
52
+ - **Session distillation** — fetches new feed items from sinain-core, distills patterns/learnings
53
+ - **Knowledge integration** — updates playbook (working memory) and knowledge graph (long-term memory)
54
+ - Insight synthesis (generates suggestions from accumulated patterns)
55
+ 3. If the result contains a suggestion or insight, post it to the HUD via `sinain_post_feed`
56
+ 4. Optionally call `sinain_get_knowledge` to review the portable knowledge document
57
+ 5. Optionally call `sinain_get_feedback` to review recent escalation scores
58
+
59
+ ## Spawning Background Tasks
60
+
61
+ When an escalation suggests deeper research would help:
62
+
63
+ 1. **Respond first** — the user sees your immediate HUD response
64
+ 2. Spawn a background research task (use your native subprocess/agent capabilities)
65
+ 3. On next escalation or heartbeat, check for completed task results
66
+ 4. Integrate findings into your response or playbook
67
+
68
+ Rules:
69
+ - Max 2 spawns per hour
70
+ - Never duplicate a recent task
71
+ - Keep spawned tasks focused and time-bounded
72
+
73
+ ## Files You Manage
74
+
75
+ Your working memory lives at `~/.openclaw/workspace/memory/`:
76
+ - `sinain-playbook.md` — your effective playbook (working memory, updated by knowledge integrator)
77
+ - `knowledge-graph.db` — long-term knowledge graph (SQLite, curated facts with confidence tracking)
78
+ - `sinain-knowledge.md` — portable knowledge document (<8KB, playbook + top graph facts + recent sessions)
79
+ - `session-digests.jsonl` — session distillation history
80
+ - `distill-state.json` — watermark for what's been distilled
81
+ - `playbook-logs/YYYY-MM-DD.jsonl` — decision logs
82
+
83
+ ## Privacy
84
+
85
+ The HUD overlay is invisible to screen capture. All content you receive has already been privacy-stripped by sinain-core. Your responses appear only on the ghost overlay — they are never captured in screenshots or recordings.
86
+
87
+ Never include `<private>` tagged content in your responses — it will be stripped automatically, but avoid echoing it.
@@ -0,0 +1,12 @@
1
+ {
2
+ "mcpServers": {
3
+ "sinain": {
4
+ "command": "../sinain-core/node_modules/.bin/tsx",
5
+ "args": ["../sinain-mcp-server/index.ts"],
6
+ "env": {
7
+ "SINAIN_CORE_URL": "http://localhost:9500",
8
+ "SINAIN_WORKSPACE": "~/.openclaw/workspace"
9
+ }
10
+ }
11
+ }
12
+ }
@@ -0,0 +1,248 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
5
+
6
+ # Load .env if present (does not override existing env vars)
7
+ if [ -f "$SCRIPT_DIR/.env" ]; then
8
+ set -a
9
+ # shellcheck source=/dev/null
10
+ . "$SCRIPT_DIR/.env"
11
+ set +a
12
+ fi
13
+
14
+ MCP_CONFIG="${MCP_CONFIG:-$SCRIPT_DIR/mcp-config.json}"
15
+ CORE_URL="${SINAIN_CORE_URL:-http://localhost:9500}"
16
+ POLL_INTERVAL="${SINAIN_POLL_INTERVAL:-5}"
17
+ HEARTBEAT_INTERVAL="${SINAIN_HEARTBEAT_INTERVAL:-900}" # 15 minutes
18
+ AGENT="${SINAIN_AGENT:-claude}"
19
+ WORKSPACE="${SINAIN_WORKSPACE:-$HOME/.openclaw/workspace}"
20
+
21
+ # --- Agent profiles ---
22
+
23
+ # Returns 0 if the selected agent supports MCP tools natively.
24
+ # Junie support is detected at startup (JUNIE_HAS_MCP flag).
25
+ JUNIE_HAS_MCP=false # set during startup checks
26
+ agent_has_mcp() {
27
+ case "$AGENT" in
28
+ claude|codex|goose) return 0 ;;
29
+ junie) $JUNIE_HAS_MCP ;;
30
+ *) return 1 ;;
31
+ esac
32
+ }
33
+
34
+ # Invoke the selected agent with a prompt. MCP-capable agents get the config
35
+ # so they can call sinain tools directly. Returns text on stdout.
36
+ # Exit code 1 means "agent doesn't support MCP — use pipe mode instead".
37
+ invoke_agent() {
38
+ local prompt="$1"
39
+ case "$AGENT" in
40
+ claude)
41
+ claude --dangerously-skip-permissions \
42
+ --mcp-config "$MCP_CONFIG" \
43
+ --max-turns 5 --output-format text \
44
+ -p "$prompt" 2>/dev/null
45
+ ;;
46
+ codex)
47
+ codex exec -s danger-full-access \
48
+ "$prompt" 2>/dev/null
49
+ ;;
50
+ junie)
51
+ if $JUNIE_HAS_MCP; then
52
+ junie --output-format text \
53
+ --mcp-location "$JUNIE_MCP_DIR" \
54
+ --task "$prompt" 2>/dev/null
55
+ else
56
+ return 1
57
+ fi
58
+ ;;
59
+ goose)
60
+ goose run --text "$prompt" \
61
+ --output-format text \
62
+ --max-turns 10 2>/dev/null
63
+ ;;
64
+ aider)
65
+ # No MCP support — signal pipe mode
66
+ return 1
67
+ ;;
68
+ *)
69
+ # Generic pipe mode — treat AGENT value as a command
70
+ return 1
71
+ ;;
72
+ esac
73
+ }
74
+
75
+ # --- Pipe-mode helpers (for agents without MCP) ---
76
+
77
+ # JSON-encode stdin for use in curl payloads
78
+ json_encode() {
79
+ python3 -c 'import sys,json; print(json.dumps(sys.stdin.read()))'
80
+ }
81
+
82
+ # Post an escalation response via HTTP (used in pipe mode)
83
+ post_response() {
84
+ local esc_id="$1" response="$2"
85
+ curl -sf -X POST "$CORE_URL/escalation/respond" \
86
+ -H 'Content-Type: application/json' \
87
+ -d "{\"id\":\"$esc_id\",\"response\":$(echo "$response" | json_encode)}" >/dev/null
88
+ }
89
+
90
+ # Invoke a pipe-mode agent with escalation message text.
91
+ # Some agents take the message as an argument, others via stdin.
92
+ invoke_pipe() {
93
+ local msg="$1"
94
+ case "$AGENT" in
95
+ junie)
96
+ junie --output-format text --task "$msg" 2>/dev/null
97
+ ;;
98
+ aider)
99
+ aider --yes -m "$msg" 2>/dev/null
100
+ ;;
101
+ *)
102
+ # Generic: pipe message to stdin
103
+ echo "$msg" | $AGENT 2>/dev/null
104
+ ;;
105
+ esac
106
+ }
107
+
108
+ # --- Startup checks ---
109
+
110
+ # Verify sinain-core is running
111
+ if ! curl -sf "$CORE_URL/health" > /dev/null 2>&1; then
112
+ echo "ERROR: sinain-core is not running at $CORE_URL"
113
+ echo "Start it first: cd sinain-core && npm run dev"
114
+ exit 1
115
+ fi
116
+
117
+ # Junie: detect --mcp-location support (must run before agent_has_mcp calls)
118
+ JUNIE_MCP_DIR="$SCRIPT_DIR/.junie-mcp"
119
+ if [ "$AGENT" = "junie" ]; then
120
+ if junie --help 2>&1 | grep -q "mcp-location"; then
121
+ JUNIE_HAS_MCP=true
122
+ mkdir -p "$JUNIE_MCP_DIR"
123
+ cp "$MCP_CONFIG" "$JUNIE_MCP_DIR/mcp.json"
124
+ else
125
+ echo "NOTE: junie $(junie --version 2>&1 | grep -oE '[0-9.]+' | head -1) lacks --mcp-location, using pipe mode"
126
+ echo " Upgrade junie for MCP support: brew upgrade junie"
127
+ fi
128
+ fi
129
+
130
+ # Verify MCP server dependencies (only needed for MCP agents)
131
+ if agent_has_mcp && [ ! -d "$SCRIPT_DIR/../sinain-mcp-server/node_modules" ]; then
132
+ echo "Installing sinain-mcp-server dependencies..."
133
+ (cd "$SCRIPT_DIR/../sinain-mcp-server" && npm install)
134
+ fi
135
+
136
+ # Codex: auto-register sinain MCP server if not already configured
137
+ if [ "$AGENT" = "codex" ]; then
138
+ TSX_BIN="$SCRIPT_DIR/../sinain-core/node_modules/.bin/tsx"
139
+ MCP_ENTRY="$SCRIPT_DIR/../sinain-mcp-server/index.ts"
140
+ if ! codex mcp get sinain >/dev/null 2>&1; then
141
+ echo "Registering sinain MCP server with codex..."
142
+ codex mcp add sinain \
143
+ --env "SINAIN_CORE_URL=$CORE_URL" \
144
+ --env "SINAIN_WORKSPACE=$WORKSPACE" \
145
+ -- "$TSX_BIN" "$MCP_ENTRY"
146
+ fi
147
+ fi
148
+
149
+ # Agent mode label
150
+ if agent_has_mcp; then
151
+ AGENT_MODE="MCP"
152
+ else
153
+ AGENT_MODE="pipe"
154
+ fi
155
+
156
+ echo "sinain bare agent started"
157
+ echo " Agent: $AGENT ($AGENT_MODE)"
158
+ echo " Core: $CORE_URL"
159
+ echo " Poll: every ${POLL_INTERVAL}s"
160
+ echo " Heartbeat: every ${HEARTBEAT_INTERVAL}s"
161
+ echo " Press Ctrl+C to stop"
162
+ echo ""
163
+
164
+ LAST_HEARTBEAT=$(date +%s)
165
+ ESCALATION_COUNT=0
166
+
167
+ cleanup() {
168
+ echo ""
169
+ echo "Agent stopped. Escalations handled: $ESCALATION_COUNT"
170
+ exit 0
171
+ }
172
+ trap cleanup INT TERM
173
+
174
+ # --- Prompt templates ---
175
+
176
+ ESC_PROMPT_TEMPLATE='You are the sinain HUD agent. An escalation is pending with ID=%s.
177
+
178
+ Call sinain_get_escalation to see the full context, then call sinain_respond with the ID and your response.
179
+
180
+ Response guidelines: 5-10 sentences, address errors first, reference specific screen/audio context, never NO_REPLY. Max 4000 chars for coding context, 3000 otherwise.'
181
+
182
+ HEARTBEAT_PROMPT='You are the sinain HUD agent. Run the heartbeat cycle:
183
+ 1. Call sinain_heartbeat_tick with a brief session summary
184
+ 2. If the result contains a suggestion, post it to HUD via sinain_post_feed
185
+ 3. Call sinain_get_feedback to review recent scores'
186
+
187
+ # --- Main loop ---
188
+
189
+ while true; do
190
+ # Poll for pending escalation
191
+ ESC=$(curl -sf "$CORE_URL/escalation/pending" 2>/dev/null || echo '{"ok":false}')
192
+ ESC_ID=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); e=d.get('escalation'); print(e['id'] if e else '')" 2>/dev/null || true)
193
+
194
+ if [ -n "$ESC_ID" ]; then
195
+ ESC_MSG=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['escalation']['message'])" 2>/dev/null)
196
+ ESC_SCORE=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['escalation'].get('score','?'))" 2>/dev/null)
197
+ ESC_CODING=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['escalation'].get('codingContext',False))" 2>/dev/null)
198
+
199
+ echo "[$(date +%H:%M:%S)] Escalation $ESC_ID (score=$ESC_SCORE, coding=$ESC_CODING)"
200
+
201
+ if agent_has_mcp; then
202
+ # MCP path: agent calls sinain tools directly
203
+ PROMPT=$(printf "$ESC_PROMPT_TEMPLATE" "$ESC_ID")
204
+ RESPONSE=$(invoke_agent "$PROMPT" || echo "ERROR: $AGENT invocation failed")
205
+ else
206
+ # Pipe path: bash handles HTTP, agent just generates text
207
+ RESPONSE=$(invoke_pipe "$ESC_MSG" || true)
208
+ if [ -n "$RESPONSE" ]; then
209
+ post_response "$ESC_ID" "$RESPONSE"
210
+ else
211
+ echo "[$(date +%H:%M:%S)] WARNING: $AGENT returned empty response"
212
+ fi
213
+ fi
214
+
215
+ ESCALATION_COUNT=$((ESCALATION_COUNT + 1))
216
+ echo "[$(date +%H:%M:%S)] Responded ($ESCALATION_COUNT total): ${RESPONSE:0:120}..."
217
+ echo ""
218
+ fi
219
+
220
+ # Heartbeat check
221
+ NOW=$(date +%s)
222
+ ELAPSED=$((NOW - LAST_HEARTBEAT))
223
+ if [ "$ELAPSED" -ge "$HEARTBEAT_INTERVAL" ]; then
224
+ echo "[$(date +%H:%M:%S)] Running heartbeat tick..."
225
+
226
+ if agent_has_mcp; then
227
+ # MCP path: agent runs heartbeat tools
228
+ invoke_agent "$HEARTBEAT_PROMPT" || true
229
+ else
230
+ # Pipe path: run curation scripts directly
231
+ SCRIPTS_DIR="$WORKSPACE/sinain-memory"
232
+ MEMORY_DIR="$WORKSPACE/memory"
233
+ if [ -d "$SCRIPTS_DIR" ]; then
234
+ python3 "$SCRIPTS_DIR/signal_analyzer.py" --memory-dir "$MEMORY_DIR" 2>/dev/null || true
235
+ python3 "$SCRIPTS_DIR/playbook_curator.py" --memory-dir "$MEMORY_DIR" 2>/dev/null || true
236
+ echo "[$(date +%H:%M:%S)] Heartbeat: ran signal_analyzer + playbook_curator"
237
+ else
238
+ echo "[$(date +%H:%M:%S)] Heartbeat: skipped (no scripts at $SCRIPTS_DIR)"
239
+ fi
240
+ fi
241
+
242
+ LAST_HEARTBEAT=$NOW
243
+ echo "[$(date +%H:%M:%S)] Heartbeat complete"
244
+ echo ""
245
+ fi
246
+
247
+ sleep "$POLL_INTERVAL"
248
+ done