@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.
- package/README.md +10 -1
- package/cli.js +176 -0
- package/index.ts +4 -2
- package/install.js +89 -14
- package/launcher.js +622 -0
- package/openclaw.plugin.json +4 -0
- package/pack-prepare.js +48 -0
- package/package.json +24 -5
- package/sense_client/README.md +82 -0
- package/sense_client/__init__.py +1 -0
- package/sense_client/__main__.py +462 -0
- package/sense_client/app_detector.py +54 -0
- package/sense_client/app_detector_win.py +83 -0
- package/sense_client/capture.py +215 -0
- package/sense_client/capture_win.py +88 -0
- package/sense_client/change_detector.py +86 -0
- package/sense_client/config.py +64 -0
- package/sense_client/gate.py +145 -0
- package/sense_client/ocr.py +347 -0
- package/sense_client/privacy.py +65 -0
- package/sense_client/requirements.txt +13 -0
- package/sense_client/roi_extractor.py +84 -0
- package/sense_client/sender.py +173 -0
- package/sense_client/tests/__init__.py +0 -0
- package/sense_client/tests/test_stream1_optimizations.py +234 -0
- package/setup-overlay.js +82 -0
- package/sinain-agent/.env.example +17 -0
- package/sinain-agent/CLAUDE.md +87 -0
- package/sinain-agent/mcp-config.json +12 -0
- package/sinain-agent/run.sh +248 -0
- package/sinain-core/.env.example +93 -0
- package/sinain-core/package-lock.json +552 -0
- package/sinain-core/package.json +21 -0
- package/sinain-core/src/agent/analyzer.ts +366 -0
- package/sinain-core/src/agent/context-window.ts +172 -0
- package/sinain-core/src/agent/loop.ts +404 -0
- package/sinain-core/src/agent/situation-writer.ts +187 -0
- package/sinain-core/src/agent/traits.ts +520 -0
- package/sinain-core/src/audio/capture-spawner-macos.ts +44 -0
- package/sinain-core/src/audio/capture-spawner-win.ts +37 -0
- package/sinain-core/src/audio/capture-spawner.ts +14 -0
- package/sinain-core/src/audio/pipeline.ts +335 -0
- package/sinain-core/src/audio/transcription-local.ts +141 -0
- package/sinain-core/src/audio/transcription.ts +278 -0
- package/sinain-core/src/buffers/feed-buffer.ts +71 -0
- package/sinain-core/src/buffers/sense-buffer.ts +425 -0
- package/sinain-core/src/config.ts +245 -0
- package/sinain-core/src/escalation/escalation-slot.ts +136 -0
- package/sinain-core/src/escalation/escalator.ts +828 -0
- package/sinain-core/src/escalation/message-builder.ts +370 -0
- package/sinain-core/src/escalation/openclaw-ws.ts +726 -0
- package/sinain-core/src/escalation/scorer.ts +166 -0
- package/sinain-core/src/index.ts +537 -0
- package/sinain-core/src/learning/feedback-store.ts +253 -0
- package/sinain-core/src/learning/signal-collector.ts +218 -0
- package/sinain-core/src/log.ts +24 -0
- package/sinain-core/src/overlay/commands.ts +126 -0
- package/sinain-core/src/overlay/ws-handler.ts +267 -0
- package/sinain-core/src/privacy/index.ts +18 -0
- package/sinain-core/src/privacy/presets.ts +40 -0
- package/sinain-core/src/privacy/redact.ts +92 -0
- package/sinain-core/src/profiler.ts +181 -0
- package/sinain-core/src/recorder.ts +186 -0
- package/sinain-core/src/server.ts +456 -0
- package/sinain-core/src/trace/trace-store.ts +73 -0
- package/sinain-core/src/trace/tracer.ts +94 -0
- package/sinain-core/src/types.ts +427 -0
- package/sinain-core/src/util/dedup.ts +48 -0
- package/sinain-core/src/util/task-store.ts +84 -0
- package/sinain-core/tsconfig.json +18 -0
- package/sinain-knowledge/curation/engine.ts +137 -24
- package/sinain-knowledge/data/git-store.ts +26 -0
- package/sinain-knowledge/data/store.ts +117 -0
- package/sinain-mcp-server/index.ts +417 -0
- package/sinain-mcp-server/package.json +19 -0
- package/sinain-mcp-server/tsconfig.json +15 -0
- package/sinain-memory/graph_query.py +185 -0
- package/sinain-memory/knowledge_integrator.py +450 -0
- package/sinain-memory/memory-config.json +3 -1
- 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()
|
package/setup-overlay.js
ADDED
|
@@ -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,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
|