@geravant/sinain 1.2.0 → 1.3.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/cli.js +7 -2
- package/install.js +3 -2
- package/launcher.js +53 -19
- package/package.json +2 -2
- package/setup-overlay.js +199 -47
- package/sinain-core/.env.example +0 -1
- package/sinain-core/package.json +1 -1
- package/sinain-core/src/config.ts +0 -1
- package/sinain-core/src/escalation/escalator.ts +37 -4
- package/sinain-core/src/escalation/message-builder.ts +7 -1
- package/sinain-core/src/index.ts +8 -1
- package/sinain-core/src/overlay/commands.ts +8 -10
- package/sinain-core/src/overlay/ws-handler.ts +3 -0
- package/sinain-core/src/server.ts +15 -0
- package/sinain-core/src/types.ts +14 -2
- package/sinain-mcp-server/index.ts +16 -1
- package/sinain-memory/graph_query.py +43 -18
- package/sinain-memory/knowledge_integrator.py +91 -22
package/cli.js
CHANGED
|
@@ -164,13 +164,18 @@ Usage:
|
|
|
164
164
|
sinain start [options] Launch sinain services
|
|
165
165
|
sinain stop Stop all sinain services
|
|
166
166
|
sinain status Check what's running
|
|
167
|
-
sinain setup-overlay
|
|
167
|
+
sinain setup-overlay Download pre-built overlay app
|
|
168
168
|
sinain install Install OpenClaw plugin (server-side)
|
|
169
169
|
|
|
170
170
|
Start options:
|
|
171
171
|
--no-sense Skip screen capture (sense_client)
|
|
172
|
-
--no-overlay Skip
|
|
172
|
+
--no-overlay Skip overlay
|
|
173
173
|
--no-agent Skip agent poll loop
|
|
174
174
|
--agent=<name> Agent to use: claude, codex, goose, aider (default: claude)
|
|
175
|
+
|
|
176
|
+
Setup-overlay options:
|
|
177
|
+
--from-source Build from Flutter source instead of downloading
|
|
178
|
+
--update Force re-download even if version matches
|
|
175
179
|
`);
|
|
180
|
+
|
|
176
181
|
}
|
package/install.js
CHANGED
|
@@ -279,10 +279,11 @@ async function installLocal() {
|
|
|
279
279
|
}
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
const token = cfg?.gateway?.auth?.token ?? "(see openclaw.json)";
|
|
282
283
|
console.log("\n✓ sinain installed successfully.");
|
|
283
284
|
console.log(" Plugin config: ~/.openclaw/openclaw.json");
|
|
284
|
-
console.log(` Auth token: ${
|
|
285
|
-
console.log(" Next: run 'openclaw gateway'
|
|
285
|
+
console.log(` Auth token: ${token}`);
|
|
286
|
+
console.log(" Next: run 'openclaw gateway' if not running, or restart to pick up changes.\n");
|
|
286
287
|
}
|
|
287
288
|
|
|
288
289
|
// ── Knowledge snapshot repo setup (shared) ──────────────────────────────────
|
package/launcher.js
CHANGED
|
@@ -139,14 +139,19 @@ async function main() {
|
|
|
139
139
|
// Start overlay
|
|
140
140
|
let overlayStatus = "skipped";
|
|
141
141
|
if (!skipOverlay) {
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
142
|
+
const overlay = findOverlay();
|
|
143
|
+
if (overlay?.type === "prebuilt") {
|
|
144
|
+
// Remove quarantine if present (ad-hoc signed app)
|
|
145
|
+
try {
|
|
146
|
+
const xattrs = execSync(`xattr "${overlay.path}"`, { encoding: "utf-8" });
|
|
147
|
+
if (xattrs.includes("com.apple.quarantine")) {
|
|
148
|
+
execSync(`xattr -dr com.apple.quarantine "${overlay.path}"`, { stdio: "pipe" });
|
|
149
|
+
}
|
|
150
|
+
} catch { /* no quarantine or xattr failed — try launching anyway */ }
|
|
151
|
+
|
|
152
|
+
log("Starting overlay (pre-built)...");
|
|
153
|
+
const binary = path.join(overlay.path, "Contents/MacOS/sinain_hud");
|
|
154
|
+
startProcess("overlay", binary, [], { color: MAGENTA });
|
|
150
155
|
await sleep(2000);
|
|
151
156
|
const overlayChild = children.find(c => c.name === "overlay");
|
|
152
157
|
if (overlayChild && !overlayChild.proc.killed && overlayChild.proc.exitCode === null) {
|
|
@@ -156,10 +161,28 @@ async function main() {
|
|
|
156
161
|
warn("overlay exited early — check logs above");
|
|
157
162
|
overlayStatus = "failed";
|
|
158
163
|
}
|
|
159
|
-
} else if (
|
|
160
|
-
|
|
164
|
+
} else if (overlay?.type === "source") {
|
|
165
|
+
const hasFlutter = commandExists("flutter");
|
|
166
|
+
if (hasFlutter) {
|
|
167
|
+
log("Starting overlay (flutter run)...");
|
|
168
|
+
startProcess("overlay", "flutter", ["run", "-d", "macos"], {
|
|
169
|
+
cwd: overlay.path,
|
|
170
|
+
color: MAGENTA,
|
|
171
|
+
});
|
|
172
|
+
await sleep(2000);
|
|
173
|
+
const overlayChild = children.find(c => c.name === "overlay");
|
|
174
|
+
if (overlayChild && !overlayChild.proc.killed && overlayChild.proc.exitCode === null) {
|
|
175
|
+
ok(`overlay running (pid:${overlayChild.pid})`);
|
|
176
|
+
overlayStatus = "running";
|
|
177
|
+
} else {
|
|
178
|
+
warn("overlay exited early — check logs above");
|
|
179
|
+
overlayStatus = "failed";
|
|
180
|
+
}
|
|
181
|
+
} else {
|
|
182
|
+
warn("flutter not found — overlay source found but can't build");
|
|
183
|
+
}
|
|
161
184
|
} else {
|
|
162
|
-
warn("
|
|
185
|
+
warn("overlay not found — run: sinain setup-overlay");
|
|
163
186
|
}
|
|
164
187
|
}
|
|
165
188
|
|
|
@@ -227,7 +250,7 @@ async function preflight() {
|
|
|
227
250
|
skipSense = true;
|
|
228
251
|
}
|
|
229
252
|
|
|
230
|
-
// Flutter (optional)
|
|
253
|
+
// Flutter (optional — only needed if no pre-built overlay)
|
|
231
254
|
if (commandExists("flutter")) {
|
|
232
255
|
try {
|
|
233
256
|
const flutterVer = execSync("flutter --version 2>&1", { encoding: "utf-8" }).split("\n")[0].split(" ")[1];
|
|
@@ -236,8 +259,13 @@ async function preflight() {
|
|
|
236
259
|
ok("flutter (version unknown)");
|
|
237
260
|
}
|
|
238
261
|
} else {
|
|
239
|
-
|
|
240
|
-
|
|
262
|
+
const prebuiltApp = path.join(SINAIN_DIR, "overlay-app", "sinain_hud.app");
|
|
263
|
+
if (fs.existsSync(prebuiltApp)) {
|
|
264
|
+
ok("overlay: pre-built app");
|
|
265
|
+
} else {
|
|
266
|
+
warn("no overlay available — run: sinain setup-overlay");
|
|
267
|
+
skipOverlay = true;
|
|
268
|
+
}
|
|
241
269
|
}
|
|
242
270
|
|
|
243
271
|
// Port 9500
|
|
@@ -461,17 +489,23 @@ function generateMcpConfig() {
|
|
|
461
489
|
|
|
462
490
|
// ── Overlay discovery ───────────────────────────────────────────────────────
|
|
463
491
|
|
|
464
|
-
function
|
|
465
|
-
// 1.
|
|
492
|
+
function findOverlay() {
|
|
493
|
+
// 1. Dev monorepo: sibling overlay/ with pubspec.yaml (Flutter source)
|
|
466
494
|
const siblingOverlay = path.join(PKG_DIR, "..", "overlay");
|
|
467
495
|
if (fs.existsSync(path.join(siblingOverlay, "pubspec.yaml"))) {
|
|
468
|
-
return siblingOverlay;
|
|
496
|
+
return { type: "source", path: siblingOverlay };
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// 2. Pre-built .app bundle (downloaded by setup-overlay)
|
|
500
|
+
const prebuiltApp = path.join(SINAIN_DIR, "overlay-app", "sinain_hud.app");
|
|
501
|
+
if (fs.existsSync(prebuiltApp)) {
|
|
502
|
+
return { type: "prebuilt", path: prebuiltApp };
|
|
469
503
|
}
|
|
470
504
|
|
|
471
|
-
//
|
|
505
|
+
// 3. Legacy: ~/.sinain/overlay/ source install (setup-overlay --from-source)
|
|
472
506
|
const installedOverlay = path.join(SINAIN_DIR, "overlay");
|
|
473
507
|
if (fs.existsSync(path.join(installedOverlay, "pubspec.yaml"))) {
|
|
474
|
-
return installedOverlay;
|
|
508
|
+
return { type: "source", path: installedOverlay };
|
|
475
509
|
}
|
|
476
510
|
|
|
477
511
|
return null;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geravant/sinain",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.3.0",
|
|
4
|
+
"description": "Ambient AI overlay invisible to screen capture — real-time insights from audio + screen context",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"sinain": "./cli.js",
|
package/setup-overlay.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// sinain setup-overlay —
|
|
2
|
+
// sinain setup-overlay — download pre-built overlay app (or build from source)
|
|
3
3
|
|
|
4
4
|
import { execSync } from "child_process";
|
|
5
5
|
import fs from "fs";
|
|
@@ -8,75 +8,227 @@ import os from "os";
|
|
|
8
8
|
|
|
9
9
|
const HOME = os.homedir();
|
|
10
10
|
const SINAIN_DIR = path.join(HOME, ".sinain");
|
|
11
|
+
const APP_DIR = path.join(SINAIN_DIR, "overlay-app");
|
|
12
|
+
const APP_PATH = path.join(APP_DIR, "sinain_hud.app");
|
|
13
|
+
const VERSION_FILE = path.join(APP_DIR, "version.json");
|
|
14
|
+
|
|
15
|
+
const REPO = "anthillnet/sinain-hud";
|
|
16
|
+
const RELEASES_API = `https://api.github.com/repos/${REPO}/releases`;
|
|
17
|
+
|
|
18
|
+
// Legacy source-build paths
|
|
11
19
|
const REPO_DIR = path.join(SINAIN_DIR, "overlay-repo");
|
|
12
20
|
const OVERLAY_LINK = path.join(SINAIN_DIR, "overlay");
|
|
13
21
|
|
|
14
22
|
const BOLD = "\x1b[1m";
|
|
15
23
|
const GREEN = "\x1b[32m";
|
|
24
|
+
const YELLOW = "\x1b[33m";
|
|
16
25
|
const RED = "\x1b[31m";
|
|
26
|
+
const DIM = "\x1b[2m";
|
|
17
27
|
const RESET = "\x1b[0m";
|
|
18
28
|
|
|
19
|
-
function log(msg)
|
|
20
|
-
function ok(msg)
|
|
29
|
+
function log(msg) { console.log(`${BOLD}[setup-overlay]${RESET} ${msg}`); }
|
|
30
|
+
function ok(msg) { console.log(`${BOLD}[setup-overlay]${RESET} ${GREEN}✓${RESET} ${msg}`); }
|
|
31
|
+
function warn(msg) { console.log(`${BOLD}[setup-overlay]${RESET} ${YELLOW}⚠${RESET} ${msg}`); }
|
|
21
32
|
function fail(msg) { console.error(`${BOLD}[setup-overlay]${RESET} ${RED}✗${RESET} ${msg}`); process.exit(1); }
|
|
22
33
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
34
|
+
// ── Parse flags ──────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
const args = process.argv.slice(2);
|
|
37
|
+
const fromSource = args.includes("--from-source");
|
|
38
|
+
const forceUpdate = args.includes("--update");
|
|
39
|
+
|
|
40
|
+
if (fromSource) {
|
|
41
|
+
await buildFromSource();
|
|
42
|
+
} else {
|
|
43
|
+
await downloadPrebuilt();
|
|
28
44
|
}
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
ok(`flutter: ${flutterVer}`);
|
|
46
|
+
// ── Download pre-built .app ──────────────────────────────────────────────────
|
|
32
47
|
|
|
33
|
-
|
|
48
|
+
async function downloadPrebuilt() {
|
|
49
|
+
fs.mkdirSync(APP_DIR, { recursive: true });
|
|
34
50
|
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
51
|
+
// Find latest overlay release
|
|
52
|
+
log("Checking for latest overlay release...");
|
|
53
|
+
let release;
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(`${RELEASES_API}?per_page=20`, {
|
|
56
|
+
signal: AbortSignal.timeout(10000),
|
|
57
|
+
headers: { "Accept": "application/vnd.github+json" },
|
|
58
|
+
});
|
|
59
|
+
if (!res.ok) throw new Error(`GitHub API returned ${res.status}`);
|
|
60
|
+
const releases = await res.json();
|
|
61
|
+
release = releases.find(r => r.tag_name?.startsWith("overlay-v"));
|
|
62
|
+
if (!release) throw new Error("No overlay release found");
|
|
63
|
+
} catch (e) {
|
|
64
|
+
fail(`Failed to fetch releases: ${e.message}\n Try: sinain setup-overlay --from-source`);
|
|
44
65
|
}
|
|
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
66
|
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
if (!fs.existsSync(path.join(overlayDir, "pubspec.yaml"))) {
|
|
56
|
-
fail("overlay/pubspec.yaml not found — sparse checkout may have failed");
|
|
57
|
-
}
|
|
67
|
+
const tag = release.tag_name;
|
|
68
|
+
const version = tag.replace("overlay-v", "");
|
|
58
69
|
|
|
59
|
-
|
|
60
|
-
|
|
70
|
+
// Check if already up-to-date
|
|
71
|
+
if (!forceUpdate && fs.existsSync(VERSION_FILE) && fs.existsSync(APP_PATH)) {
|
|
72
|
+
try {
|
|
73
|
+
const local = JSON.parse(fs.readFileSync(VERSION_FILE, "utf-8"));
|
|
74
|
+
if (local.tag === tag) {
|
|
75
|
+
ok(`Overlay already up-to-date (${version})`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
log(`Updating: ${local.tag} → ${tag}`);
|
|
79
|
+
} catch { /* corrupt version file — re-download */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Find the .zip asset
|
|
83
|
+
const zipAsset = release.assets?.find(a => a.name === "sinain_hud.app.zip");
|
|
84
|
+
if (!zipAsset) {
|
|
85
|
+
fail(`Release ${tag} has no sinain_hud.app.zip asset.\n Try: sinain setup-overlay --from-source`);
|
|
86
|
+
}
|
|
61
87
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
88
|
+
// Download with progress
|
|
89
|
+
log(`Downloading overlay ${version} (${formatBytes(zipAsset.size)})...`);
|
|
90
|
+
const zipPath = path.join(APP_DIR, "sinain_hud.app.zip");
|
|
65
91
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
92
|
+
try {
|
|
93
|
+
const res = await fetch(zipAsset.browser_download_url, {
|
|
94
|
+
signal: AbortSignal.timeout(120000),
|
|
95
|
+
redirect: "follow",
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) throw new Error(`Download failed: ${res.status}`);
|
|
98
|
+
|
|
99
|
+
const total = parseInt(res.headers.get("content-length") || "0");
|
|
100
|
+
const chunks = [];
|
|
101
|
+
let downloaded = 0;
|
|
102
|
+
|
|
103
|
+
const reader = res.body.getReader();
|
|
104
|
+
while (true) {
|
|
105
|
+
const { done, value } = await reader.read();
|
|
106
|
+
if (done) break;
|
|
107
|
+
chunks.push(value);
|
|
108
|
+
downloaded += value.length;
|
|
109
|
+
if (total > 0) {
|
|
110
|
+
const pct = Math.round((downloaded / total) * 100);
|
|
111
|
+
process.stdout.write(`\r${BOLD}[setup-overlay]${RESET} ${DIM}${pct}% (${formatBytes(downloaded)} / ${formatBytes(total)})${RESET}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
process.stdout.write("\n");
|
|
115
|
+
|
|
116
|
+
const buffer = Buffer.concat(chunks);
|
|
117
|
+
fs.writeFileSync(zipPath, buffer);
|
|
118
|
+
ok(`Downloaded ${formatBytes(buffer.length)}`);
|
|
119
|
+
} catch (e) {
|
|
120
|
+
fail(`Download failed: ${e.message}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Remove old app if present
|
|
124
|
+
if (fs.existsSync(APP_PATH)) {
|
|
125
|
+
fs.rmSync(APP_PATH, { recursive: true, force: true });
|
|
70
126
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
127
|
+
|
|
128
|
+
// Extract — ditto preserves macOS extended attributes (critical for code signing)
|
|
129
|
+
log("Extracting...");
|
|
130
|
+
try {
|
|
131
|
+
execSync(`ditto -x -k "${zipPath}" "${APP_DIR}"`, { stdio: "pipe" });
|
|
132
|
+
} catch {
|
|
133
|
+
// Fallback to unzip
|
|
134
|
+
try {
|
|
135
|
+
execSync(`unzip -o -q "${zipPath}" -d "${APP_DIR}"`, { stdio: "pipe" });
|
|
136
|
+
} catch (e) {
|
|
137
|
+
fail(`Extraction failed: ${e.message}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Remove quarantine attribute (ad-hoc signed app downloaded from internet)
|
|
142
|
+
try {
|
|
143
|
+
execSync(`xattr -cr "${APP_PATH}"`, { stdio: "pipe" });
|
|
144
|
+
} catch { /* xattr may not be needed */ }
|
|
145
|
+
|
|
146
|
+
// Write version marker
|
|
147
|
+
fs.writeFileSync(VERSION_FILE, JSON.stringify({
|
|
148
|
+
tag,
|
|
149
|
+
version,
|
|
150
|
+
installedAt: new Date().toISOString(),
|
|
151
|
+
}, null, 2));
|
|
152
|
+
|
|
153
|
+
// Clean up zip
|
|
154
|
+
fs.unlinkSync(zipPath);
|
|
155
|
+
|
|
156
|
+
ok(`Overlay ${version} installed`);
|
|
157
|
+
console.log(`
|
|
158
|
+
${GREEN}✓${RESET} Overlay ready!
|
|
159
|
+
Location: ${APP_PATH}
|
|
160
|
+
The overlay will auto-start with: ${BOLD}sinain start${RESET}
|
|
161
|
+
`);
|
|
76
162
|
}
|
|
77
163
|
|
|
78
|
-
|
|
164
|
+
// ── Build from source (legacy) ───────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
async function buildFromSource() {
|
|
167
|
+
// Check flutter
|
|
168
|
+
try {
|
|
169
|
+
execSync("which flutter", { stdio: "pipe" });
|
|
170
|
+
} catch {
|
|
171
|
+
fail("flutter not found. Install it: https://docs.flutter.dev/get-started/install");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const flutterVer = execSync("flutter --version 2>&1", { encoding: "utf-8" }).split("\n")[0];
|
|
175
|
+
ok(`flutter: ${flutterVer}`);
|
|
176
|
+
|
|
177
|
+
fs.mkdirSync(SINAIN_DIR, { recursive: true });
|
|
178
|
+
|
|
179
|
+
// Clone or update
|
|
180
|
+
if (fs.existsSync(path.join(REPO_DIR, ".git"))) {
|
|
181
|
+
log("Updating existing overlay repo...");
|
|
182
|
+
execSync("git pull --ff-only", { cwd: REPO_DIR, stdio: "inherit" });
|
|
183
|
+
ok("Repository updated");
|
|
184
|
+
} else {
|
|
185
|
+
log("Cloning overlay (sparse checkout — only overlay/ directory)...");
|
|
186
|
+
if (fs.existsSync(REPO_DIR)) {
|
|
187
|
+
fs.rmSync(REPO_DIR, { recursive: true, force: true });
|
|
188
|
+
}
|
|
189
|
+
execSync(
|
|
190
|
+
`git clone --depth 1 --filter=blob:none --sparse https://github.com/${REPO}.git "${REPO_DIR}"`,
|
|
191
|
+
{ stdio: "inherit" }
|
|
192
|
+
);
|
|
193
|
+
execSync("git sparse-checkout set overlay", { cwd: REPO_DIR, stdio: "inherit" });
|
|
194
|
+
ok("Repository cloned");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Build
|
|
198
|
+
const overlayDir = path.join(REPO_DIR, "overlay");
|
|
199
|
+
if (!fs.existsSync(path.join(overlayDir, "pubspec.yaml"))) {
|
|
200
|
+
fail("overlay/pubspec.yaml not found — sparse checkout may have failed");
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
log("Installing Flutter dependencies...");
|
|
204
|
+
execSync("flutter pub get", { cwd: overlayDir, stdio: "inherit" });
|
|
205
|
+
|
|
206
|
+
log("Building overlay (this may take a few minutes)...");
|
|
207
|
+
execSync("flutter build macos", { cwd: overlayDir, stdio: "inherit" });
|
|
208
|
+
ok("Overlay built successfully");
|
|
209
|
+
|
|
210
|
+
// Symlink ~/.sinain/overlay → the overlay source dir
|
|
211
|
+
try {
|
|
212
|
+
if (fs.existsSync(OVERLAY_LINK)) {
|
|
213
|
+
fs.unlinkSync(OVERLAY_LINK);
|
|
214
|
+
}
|
|
215
|
+
fs.symlinkSync(overlayDir, OVERLAY_LINK);
|
|
216
|
+
ok(`Symlinked: ${OVERLAY_LINK} → ${overlayDir}`);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
log(`Overlay built at: ${overlayDir}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log(`
|
|
79
222
|
${GREEN}✓${RESET} Overlay setup complete!
|
|
80
223
|
The overlay will auto-start with: ${BOLD}sinain start${RESET}
|
|
81
224
|
Or run manually: cd ${overlayDir} && flutter run -d macos
|
|
82
225
|
`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
function formatBytes(bytes) {
|
|
231
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
232
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
233
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
234
|
+
}
|
package/sinain-core/.env.example
CHANGED
|
@@ -14,7 +14,6 @@ AUDIO_VAD_ENABLED=true
|
|
|
14
14
|
AUDIO_VAD_THRESHOLD=0.003
|
|
15
15
|
AUDIO_AUTO_START=true
|
|
16
16
|
AUDIO_GAIN_DB=20
|
|
17
|
-
# AUDIO_ALT_DEVICE= # alternate device for switch_device command
|
|
18
17
|
|
|
19
18
|
# ── Microphone (opt-in for privacy) ──
|
|
20
19
|
MIC_ENABLED=false # set true to capture user's microphone
|
package/sinain-core/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sinain-core",
|
|
3
3
|
"version": "1.0.0",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "SinainHUD core — audio transcription, agent analysis loop, escalation orchestration, WebSocket feed",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"scripts": {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentEntry, ContextWindow, EscalationConfig, OpenClawConfig, FeedItem, SpawnTaskMessage, SpawnTaskStatus } from "../types.js";
|
|
1
|
+
import type { AgentEntry, ContextWindow, EscalationConfig, OpenClawConfig, FeedItem, SpawnTaskMessage, SpawnTaskStatus, UserCommand } from "../types.js";
|
|
2
2
|
import type { FeedBuffer } from "../buffers/feed-buffer.js";
|
|
3
3
|
import type { WsHandler } from "../overlay/ws-handler.js";
|
|
4
4
|
import type { Profiler } from "../profiler.js";
|
|
@@ -74,6 +74,10 @@ export class Escalator {
|
|
|
74
74
|
// Store context from last escalation for response handling
|
|
75
75
|
private lastEscalationContext: ContextWindow | null = null;
|
|
76
76
|
|
|
77
|
+
// User command to inject into the next escalation
|
|
78
|
+
private pendingUserCommand: UserCommand | null = null;
|
|
79
|
+
private static readonly USER_COMMAND_EXPIRY_MS = 120_000; // 2 minutes
|
|
80
|
+
|
|
77
81
|
private stats = {
|
|
78
82
|
totalEscalations: 0,
|
|
79
83
|
totalResponses: 0,
|
|
@@ -123,6 +127,15 @@ export class Escalator {
|
|
|
123
127
|
this.deps.signalCollector = sc;
|
|
124
128
|
}
|
|
125
129
|
|
|
130
|
+
/** Queue a user command to inject into the next escalation. */
|
|
131
|
+
setUserCommand(text: string, source: "text" | "voice" = "text"): void {
|
|
132
|
+
this.pendingUserCommand = { text, ts: Date.now(), source };
|
|
133
|
+
const preview = text.length > 60 ? text.slice(0, 60) + "…" : text;
|
|
134
|
+
this.deps.feedBuffer.push(`⌘ Command queued: ${preview}`, "normal", "system", "stream");
|
|
135
|
+
this.deps.wsHandler.broadcast(`⌘ Command queued: ${preview}`, "normal");
|
|
136
|
+
log(TAG, `user command set: "${preview}"`);
|
|
137
|
+
}
|
|
138
|
+
|
|
126
139
|
/** Start the WS connection to OpenClaw (skipped when transport=http). */
|
|
127
140
|
start(): void {
|
|
128
141
|
if (this.deps.escalationConfig.mode !== "off" && this.deps.escalationConfig.transport !== "http") {
|
|
@@ -161,6 +174,14 @@ export class Escalator {
|
|
|
161
174
|
* Decides whether to escalate and enqueues the message for delivery.
|
|
162
175
|
*/
|
|
163
176
|
async onAgentAnalysis(entry: AgentEntry, contextWindow: ContextWindow): Promise<void> {
|
|
177
|
+
// Expire stale user commands (safety net — 120s is generous)
|
|
178
|
+
if (this.pendingUserCommand && Date.now() - this.pendingUserCommand.ts > Escalator.USER_COMMAND_EXPIRY_MS) {
|
|
179
|
+
warn(TAG, `user command expired after ${Escalator.USER_COMMAND_EXPIRY_MS / 1000}s — no escalation occurred`);
|
|
180
|
+
this.deps.feedBuffer.push("⚠ Command expired — no escalation occurred", "normal", "system", "stream");
|
|
181
|
+
this.deps.wsHandler.broadcast("⚠ Command expired — no escalation occurred", "normal");
|
|
182
|
+
this.pendingUserCommand = null;
|
|
183
|
+
}
|
|
184
|
+
|
|
164
185
|
// Skip WS escalations when circuit is open (HTTP transport bypasses this)
|
|
165
186
|
const transport = this.deps.escalationConfig.transport;
|
|
166
187
|
if (this.wsClient.isCircuitOpen && transport !== "http") {
|
|
@@ -168,6 +189,9 @@ export class Escalator {
|
|
|
168
189
|
return;
|
|
169
190
|
}
|
|
170
191
|
|
|
192
|
+
// If user command is pending, force escalation (bypass score + cooldown)
|
|
193
|
+
const hasUserCommand = this.pendingUserCommand !== null;
|
|
194
|
+
|
|
171
195
|
const { escalate, score, stale } = shouldEscalate(
|
|
172
196
|
entry.digest,
|
|
173
197
|
entry.hud,
|
|
@@ -179,7 +203,7 @@ export class Escalator {
|
|
|
179
203
|
this.deps.escalationConfig.staleMs,
|
|
180
204
|
);
|
|
181
205
|
|
|
182
|
-
if (!escalate) {
|
|
206
|
+
if (!escalate && !hasUserCommand) {
|
|
183
207
|
log(TAG, `tick #${entry.id}: not escalating (mode=${this.deps.escalationConfig.mode}, score=${score.total}, hud="${entry.hud.slice(0, 40)}")`);
|
|
184
208
|
return;
|
|
185
209
|
}
|
|
@@ -192,21 +216,29 @@ export class Escalator {
|
|
|
192
216
|
this.lastEscalatedDigest = entry.digest;
|
|
193
217
|
|
|
194
218
|
const staleTag = stale ? ", STALE" : "";
|
|
219
|
+
const cmdTag = hasUserCommand ? ", USER_CMD" : "";
|
|
195
220
|
const wsState = this.wsClient.isConnected ? "ws=connected" : "ws=disconnected";
|
|
196
|
-
log(TAG, `escalating tick #${entry.id} (score=${score.total}, reasons=[${score.reasons.join(",")}]${staleTag}, ${wsState})`);
|
|
221
|
+
log(TAG, `escalating tick #${entry.id} (score=${score.total}, reasons=[${score.reasons.join(",")}]${staleTag}${cmdTag}, ${wsState})`);
|
|
197
222
|
|
|
198
223
|
// Store context for response handling (used in pushResponse for coding-context max-length)
|
|
199
224
|
this.lastEscalationContext = contextWindow;
|
|
200
225
|
|
|
201
|
-
const escalationReason =
|
|
226
|
+
const escalationReason = hasUserCommand
|
|
227
|
+
? `user_command: ${this.pendingUserCommand!.text.slice(0, 80)}`
|
|
228
|
+
: score.reasons.join(", ");
|
|
202
229
|
let message = buildEscalationMessage(
|
|
203
230
|
entry.digest,
|
|
204
231
|
contextWindow,
|
|
205
232
|
entry,
|
|
206
233
|
this.deps.escalationConfig.mode,
|
|
207
234
|
escalationReason,
|
|
235
|
+
undefined,
|
|
236
|
+
this.pendingUserCommand ?? undefined,
|
|
208
237
|
);
|
|
209
238
|
|
|
239
|
+
// Clear user command after building the message (consumed once)
|
|
240
|
+
this.pendingUserCommand = null;
|
|
241
|
+
|
|
210
242
|
// Enrich with long-term knowledge facts (best-effort, 5s max)
|
|
211
243
|
if (this.deps.queryKnowledgeFacts) {
|
|
212
244
|
try {
|
|
@@ -389,6 +421,7 @@ ${recentLines.join("\n")}`;
|
|
|
389
421
|
cooldownMs: this.deps.escalationConfig.cooldownMs,
|
|
390
422
|
staleMs: this.deps.escalationConfig.staleMs,
|
|
391
423
|
pendingSpawnTasks: this.pendingSpawnTasks.size,
|
|
424
|
+
pendingUserCommand: this.pendingUserCommand ? this.pendingUserCommand.text.slice(0, 80) : null,
|
|
392
425
|
...this.stats,
|
|
393
426
|
};
|
|
394
427
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ContextWindow, AgentEntry, EscalationMode, FeedbackRecord } from "../types.js";
|
|
1
|
+
import type { ContextWindow, AgentEntry, EscalationMode, FeedbackRecord, UserCommand } from "../types.js";
|
|
2
2
|
import { normalizeAppName } from "../agent/context-window.js";
|
|
3
3
|
import { levelFor, applyLevel } from "../privacy/index.js";
|
|
4
4
|
|
|
@@ -142,12 +142,18 @@ export function buildEscalationMessage(
|
|
|
142
142
|
mode: EscalationMode,
|
|
143
143
|
escalationReason?: string,
|
|
144
144
|
recentFeedback?: FeedbackRecord[],
|
|
145
|
+
userCommand?: UserCommand,
|
|
145
146
|
): string {
|
|
146
147
|
const sections: string[] = [];
|
|
147
148
|
|
|
148
149
|
// Header with tick metadata
|
|
149
150
|
sections.push(`[sinain-hud live context — tick #${entry.id}]`);
|
|
150
151
|
|
|
152
|
+
// User command — placed at the top so the agent sees it first
|
|
153
|
+
if (userCommand) {
|
|
154
|
+
sections.push(`## User Command\n> ${userCommand.text}\n\nThe user has explicitly asked you to address the above command. Prioritize it in your response.`);
|
|
155
|
+
}
|
|
156
|
+
|
|
151
157
|
// Digest (always full)
|
|
152
158
|
sections.push(`## Digest\n${digest}`);
|
|
153
159
|
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { loadConfig } from "./config.js";
|
|
2
3
|
import { FeedBuffer } from "./buffers/feed-buffer.js";
|
|
3
4
|
import { SenseBuffer } from "./buffers/sense-buffer.js";
|
|
@@ -390,6 +391,9 @@ async function main() {
|
|
|
390
391
|
getTraces: (after, limit) => tracer ? tracer.getTraces(after, limit) : [],
|
|
391
392
|
reconnectGateway: () => escalator.reconnectGateway(),
|
|
392
393
|
|
|
394
|
+
// User command injection (bare agent / HTTP)
|
|
395
|
+
setUserCommand: (text: string) => escalator.setUserCommand(text),
|
|
396
|
+
|
|
393
397
|
// Bare agent HTTP escalation bridge
|
|
394
398
|
getEscalationPending: () => escalator.getPendingHttp(),
|
|
395
399
|
respondEscalation: (id: string, response: string) => escalator.respondHttp(id, response),
|
|
@@ -398,7 +402,7 @@ async function main() {
|
|
|
398
402
|
getKnowledgeDocPath: () => {
|
|
399
403
|
const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
|
|
400
404
|
const p = `${workspace}/memory/sinain-knowledge.md`;
|
|
401
|
-
try { if (
|
|
405
|
+
try { if (existsSync(p)) return p; } catch {}
|
|
402
406
|
return null;
|
|
403
407
|
},
|
|
404
408
|
queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
|
|
@@ -428,6 +432,9 @@ async function main() {
|
|
|
428
432
|
onUserMessage: async (text) => {
|
|
429
433
|
await escalator.sendDirect(text);
|
|
430
434
|
},
|
|
435
|
+
onUserCommand: (text) => {
|
|
436
|
+
escalator.setUserCommand(text);
|
|
437
|
+
},
|
|
431
438
|
onToggleScreen: () => {
|
|
432
439
|
screenActive = !screenActive;
|
|
433
440
|
if (!screenActive) {
|
|
@@ -13,6 +13,8 @@ export interface CommandDeps {
|
|
|
13
13
|
micPipeline: AudioPipeline | null;
|
|
14
14
|
config: CoreConfig;
|
|
15
15
|
onUserMessage: (text: string) => Promise<void>;
|
|
16
|
+
/** Queue a user command to augment the next escalation */
|
|
17
|
+
onUserCommand: (text: string) => void;
|
|
16
18
|
/** Toggle screen capture — returns new state */
|
|
17
19
|
onToggleScreen: () => boolean;
|
|
18
20
|
/** Toggle trait voices — returns new enabled state */
|
|
@@ -37,6 +39,11 @@ export function setupCommands(deps: CommandDeps): void {
|
|
|
37
39
|
}
|
|
38
40
|
break;
|
|
39
41
|
}
|
|
42
|
+
case "user_command": {
|
|
43
|
+
log(TAG, `user command received: "${msg.text.slice(0, 60)}"`);
|
|
44
|
+
deps.onUserCommand(msg.text);
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
40
47
|
case "command": {
|
|
41
48
|
handleCommand(msg.action, deps);
|
|
42
49
|
log(TAG, `command processed: ${msg.action}`);
|
|
@@ -47,7 +54,7 @@ export function setupCommands(deps: CommandDeps): void {
|
|
|
47
54
|
}
|
|
48
55
|
|
|
49
56
|
function handleCommand(action: string, deps: CommandDeps): void {
|
|
50
|
-
const { wsHandler, systemAudioPipeline, micPipeline
|
|
57
|
+
const { wsHandler, systemAudioPipeline, micPipeline } = deps;
|
|
51
58
|
|
|
52
59
|
switch (action) {
|
|
53
60
|
case "toggle_audio": {
|
|
@@ -100,15 +107,6 @@ function handleCommand(action: string, deps: CommandDeps): void {
|
|
|
100
107
|
log(TAG, `screen toggled ${nowActive ? "ON" : "OFF"}`);
|
|
101
108
|
break;
|
|
102
109
|
}
|
|
103
|
-
case "switch_device": {
|
|
104
|
-
const current = systemAudioPipeline.getDevice();
|
|
105
|
-
const alt = config.audioAltDevice;
|
|
106
|
-
const next = current === config.audioConfig.device ? alt : config.audioConfig.device;
|
|
107
|
-
systemAudioPipeline.switchDevice(next);
|
|
108
|
-
wsHandler.broadcast(`Audio device \u2192 ${next}`, "normal");
|
|
109
|
-
log(TAG, `audio device switched: ${current} \u2192 ${next}`);
|
|
110
|
-
break;
|
|
111
|
-
}
|
|
112
110
|
case "toggle_traits": {
|
|
113
111
|
if (!deps.onToggleTraits) {
|
|
114
112
|
wsHandler.broadcast("Trait voices not configured", "normal");
|
|
@@ -194,6 +194,9 @@ export class WsHandler {
|
|
|
194
194
|
case "command":
|
|
195
195
|
log(TAG, `\u2190 command: ${msg.action}`);
|
|
196
196
|
break;
|
|
197
|
+
case "user_command":
|
|
198
|
+
log(TAG, `\u2190 user command: ${msg.text.slice(0, 100)}`);
|
|
199
|
+
break;
|
|
197
200
|
case "profiling":
|
|
198
201
|
if (this.onProfilingCb) this.onProfilingCb(msg);
|
|
199
202
|
return;
|
|
@@ -34,6 +34,7 @@ export interface ServerDeps {
|
|
|
34
34
|
getTraces: (after: number, limit: number) => unknown[];
|
|
35
35
|
reconnectGateway: () => void;
|
|
36
36
|
feedbackStore?: FeedbackStore;
|
|
37
|
+
setUserCommand?: (text: string) => void;
|
|
37
38
|
getEscalationPending?: () => any;
|
|
38
39
|
respondEscalation?: (id: string, response: string) => any;
|
|
39
40
|
getKnowledgeDocPath?: () => string | null;
|
|
@@ -305,6 +306,20 @@ export function createAppServer(deps: ServerDeps) {
|
|
|
305
306
|
return;
|
|
306
307
|
}
|
|
307
308
|
|
|
309
|
+
// ── /user/command ──
|
|
310
|
+
if (req.method === "POST" && url.pathname === "/user/command") {
|
|
311
|
+
const body = await readBody(req, 4096);
|
|
312
|
+
const { text } = JSON.parse(body);
|
|
313
|
+
if (!text) {
|
|
314
|
+
res.writeHead(400);
|
|
315
|
+
res.end(JSON.stringify({ ok: false, error: "missing text" }));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
deps.setUserCommand?.(text);
|
|
319
|
+
res.end(JSON.stringify({ ok: true, message: "Command queued for next escalation" }));
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
308
323
|
// ── /escalation/pending ──
|
|
309
324
|
if (req.method === "GET" && url.pathname === "/escalation/pending") {
|
|
310
325
|
const pending = deps.getEscalationPending?.();
|
package/sinain-core/src/types.ts
CHANGED
|
@@ -66,8 +66,21 @@ export interface ProfilingMessage {
|
|
|
66
66
|
ts: number;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** Overlay → sinain-core: user command to augment next escalation */
|
|
70
|
+
export interface UserCommandMessage {
|
|
71
|
+
type: "user_command";
|
|
72
|
+
text: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
69
75
|
export type OutboundMessage = FeedMessage | StatusMessage | PingMessage | SpawnTaskMessage;
|
|
70
|
-
export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage;
|
|
76
|
+
export type InboundMessage = UserMessage | CommandMessage | PongMessage | ProfilingMessage | UserCommandMessage;
|
|
77
|
+
|
|
78
|
+
/** Abstraction for user commands (text now, voice later). */
|
|
79
|
+
export interface UserCommand {
|
|
80
|
+
text: string;
|
|
81
|
+
ts: number;
|
|
82
|
+
source: "text" | "voice";
|
|
83
|
+
}
|
|
71
84
|
|
|
72
85
|
// ── Feed buffer types ──
|
|
73
86
|
|
|
@@ -411,7 +424,6 @@ export interface PrivacyConfig {
|
|
|
411
424
|
export interface CoreConfig {
|
|
412
425
|
port: number;
|
|
413
426
|
audioConfig: AudioPipelineConfig;
|
|
414
|
-
audioAltDevice: string;
|
|
415
427
|
micConfig: AudioPipelineConfig;
|
|
416
428
|
micEnabled: boolean;
|
|
417
429
|
transcriptionConfig: TranscriptionConfig;
|
|
@@ -367,7 +367,22 @@ server.tool(
|
|
|
367
367
|
},
|
|
368
368
|
);
|
|
369
369
|
|
|
370
|
-
// 10.
|
|
370
|
+
// 10. sinain_user_command
|
|
371
|
+
server.tool(
|
|
372
|
+
"sinain_user_command",
|
|
373
|
+
"Queue a user command to augment the next escalation context (forces escalation on next agent tick)",
|
|
374
|
+
{ text: z.string().describe("The command text to inject into the next escalation") },
|
|
375
|
+
async ({ text }) => {
|
|
376
|
+
try {
|
|
377
|
+
const data = await coreRequest("POST", "/user/command", { text });
|
|
378
|
+
return textResult(JSON.stringify(data, null, 2));
|
|
379
|
+
} catch (err: any) {
|
|
380
|
+
return textResult(`Error queuing user command: ${err.message}`);
|
|
381
|
+
}
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
|
|
385
|
+
// 11. sinain_module_guidance
|
|
371
386
|
server.tool(
|
|
372
387
|
"sinain_module_guidance",
|
|
373
388
|
"Read guidance from all active modules in the workspace",
|
|
@@ -21,7 +21,12 @@ def query_facts_by_entities(
|
|
|
21
21
|
entities: list[str],
|
|
22
22
|
max_facts: int = 5,
|
|
23
23
|
) -> list[dict]:
|
|
24
|
-
"""Query knowledge graph for facts
|
|
24
|
+
"""Query knowledge graph for facts matching keywords via tag index.
|
|
25
|
+
|
|
26
|
+
Uses auto-extracted 'tag' attributes for discovery. Results ranked by
|
|
27
|
+
number of matching tags (more matches = more relevant). Falls back to
|
|
28
|
+
domain/entity_id matching for untagged facts.
|
|
29
|
+
"""
|
|
25
30
|
if not Path(db_path).exists():
|
|
26
31
|
return []
|
|
27
32
|
|
|
@@ -29,36 +34,56 @@ def query_facts_by_entities(
|
|
|
29
34
|
from triplestore import TripleStore
|
|
30
35
|
store = TripleStore(db_path)
|
|
31
36
|
|
|
32
|
-
#
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
like_clauses = " OR ".join([f"entity_id LIKE ?" for _ in entities])
|
|
36
|
-
entity_likes = [f"fact:{e}%" for e in entities]
|
|
37
|
+
# Normalize keywords for tag matching
|
|
38
|
+
keywords = [e.lower().replace(" ", "-") for e in entities]
|
|
39
|
+
placeholders = ",".join(["?" for _ in keywords])
|
|
37
40
|
|
|
41
|
+
# Primary: tag-based ranked search (AVET index)
|
|
38
42
|
rows = store._conn.execute(
|
|
39
|
-
f"""SELECT
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
f"""SELECT entity_id, COUNT(*) as matches
|
|
44
|
+
FROM triples
|
|
45
|
+
WHERE attribute = 'tag' AND NOT retracted
|
|
46
|
+
AND value IN ({placeholders})
|
|
47
|
+
GROUP BY entity_id
|
|
48
|
+
ORDER BY matches DESC
|
|
44
49
|
LIMIT ?""",
|
|
45
|
-
(*
|
|
50
|
+
(*keywords, max_facts * 3),
|
|
46
51
|
).fetchall()
|
|
47
52
|
|
|
48
53
|
fact_ids = [r["entity_id"] for r in rows]
|
|
49
54
|
|
|
50
|
-
#
|
|
55
|
+
# Fallback: if tags found < max_facts, also search domain/entity_id (for untagged facts)
|
|
56
|
+
if len(fact_ids) < max_facts:
|
|
57
|
+
domain_placeholders = ",".join(["?" for _ in keywords])
|
|
58
|
+
like_clauses = " OR ".join([f"entity_id LIKE ?" for _ in keywords])
|
|
59
|
+
entity_likes = [f"fact:{kw}%" for kw in keywords]
|
|
60
|
+
|
|
61
|
+
fallback_rows = store._conn.execute(
|
|
62
|
+
f"""SELECT DISTINCT entity_id FROM triples
|
|
63
|
+
WHERE NOT retracted AND entity_id NOT IN ({','.join(['?' for _ in fact_ids]) or "''"})
|
|
64
|
+
AND (
|
|
65
|
+
(attribute = 'domain' AND value IN ({domain_placeholders}))
|
|
66
|
+
OR ({like_clauses})
|
|
67
|
+
)
|
|
68
|
+
LIMIT ?""",
|
|
69
|
+
(*fact_ids, *keywords, *entity_likes, max_facts - len(fact_ids)),
|
|
70
|
+
).fetchall()
|
|
71
|
+
fact_ids.extend(r["entity_id"] for r in fallback_rows)
|
|
72
|
+
|
|
73
|
+
# Load full attributes for each fact
|
|
51
74
|
facts = []
|
|
52
75
|
for fid in fact_ids:
|
|
53
76
|
attrs = store.entity(fid)
|
|
54
77
|
if not attrs:
|
|
55
78
|
continue
|
|
56
79
|
fact = {"entityId": fid}
|
|
57
|
-
for
|
|
58
|
-
|
|
80
|
+
for attr_name, values in attrs.items():
|
|
81
|
+
if attr_name == "tag":
|
|
82
|
+
continue # Don't include tags in output (noise)
|
|
83
|
+
fact[attr_name] = values[0] if len(values) == 1 else values
|
|
59
84
|
facts.append(fact)
|
|
60
85
|
|
|
61
|
-
# Sort by confidence descending
|
|
86
|
+
# Sort by confidence descending (tag ranking already done in SQL)
|
|
62
87
|
facts.sort(key=lambda f: float(f.get("confidence", "0")), reverse=True)
|
|
63
88
|
store.close()
|
|
64
89
|
return facts[:max_facts]
|
|
@@ -93,8 +118,8 @@ def query_top_facts(db_path: str, limit: int = 30) -> list[dict]:
|
|
|
93
118
|
if not attrs:
|
|
94
119
|
continue
|
|
95
120
|
fact = {"entityId": fid}
|
|
96
|
-
for
|
|
97
|
-
fact[
|
|
121
|
+
for attr_name, values in attrs.items():
|
|
122
|
+
fact[attr_name] = values[0] if len(values) == 1 else values
|
|
98
123
|
facts.append(fact)
|
|
99
124
|
|
|
100
125
|
store.close()
|
|
@@ -18,6 +18,7 @@ Usage:
|
|
|
18
18
|
import argparse
|
|
19
19
|
import hashlib
|
|
20
20
|
import json
|
|
21
|
+
import re
|
|
21
22
|
import shutil
|
|
22
23
|
import sys
|
|
23
24
|
from datetime import datetime, timezone
|
|
@@ -80,6 +81,34 @@ Respond with ONLY a JSON object:
|
|
|
80
81
|
}"""
|
|
81
82
|
|
|
82
83
|
|
|
84
|
+
_STOPWORDS = frozenset({
|
|
85
|
+
"the", "and", "for", "when", "with", "that", "this", "from", "into",
|
|
86
|
+
"after", "before", "during", "should", "would", "could", "been", "have",
|
|
87
|
+
"will", "also", "then", "than", "not", "but", "are", "was", "were",
|
|
88
|
+
"can", "may", "use", "run", "set", "get", "try", "all", "any", "new",
|
|
89
|
+
"score", "seen",
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _extract_tags(value: str) -> list[str]:
|
|
94
|
+
"""Extract searchable keyword tags from fact value text.
|
|
95
|
+
|
|
96
|
+
Returns up to 10 deduplicated lowercase tags suitable for AVET-indexed lookup.
|
|
97
|
+
"""
|
|
98
|
+
# Lowercase words (including hyphenated compounds like "react-native")
|
|
99
|
+
words = re.findall(r"[a-z][a-z0-9-]+", value.lower())
|
|
100
|
+
tags = [w for w in words if len(w) > 2 and w not in _STOPWORDS]
|
|
101
|
+
# Detect compound terms from CamelCase or "Title Case" patterns
|
|
102
|
+
compounds = re.findall(r"[A-Z][a-z]+ [A-Z][a-z]+", value)
|
|
103
|
+
for c in compounds:
|
|
104
|
+
tags.append(c.lower().replace(" ", "-"))
|
|
105
|
+
# Numeric tokens that look meaningful (error codes, port numbers)
|
|
106
|
+
nums = re.findall(r"\b\d{3,5}\b", value)
|
|
107
|
+
tags.extend(nums)
|
|
108
|
+
# Deduplicate preserving order, cap at 10
|
|
109
|
+
return list(dict.fromkeys(tags))[:10]
|
|
110
|
+
|
|
111
|
+
|
|
83
112
|
def _fact_id(entity: str, attribute: str, value: str) -> str:
|
|
84
113
|
"""Generate a deterministic fact entity ID from entity+attribute+value."""
|
|
85
114
|
content = f"{entity}:{attribute}:{value}"
|
|
@@ -99,14 +128,19 @@ def _load_graph_facts(db_path: str, entities: list[str] | None = None, limit: in
|
|
|
99
128
|
|
|
100
129
|
# Get all non-retracted fact entities with their attributes
|
|
101
130
|
if entities:
|
|
102
|
-
#
|
|
103
|
-
|
|
131
|
+
# Tag-based search: find facts whose tags match any of the keywords
|
|
132
|
+
# Normalize keywords to lowercase for tag matching
|
|
133
|
+
keywords = [e.lower().replace(" ", "-") for e in entities]
|
|
134
|
+
placeholders = ",".join(["?" for _ in keywords])
|
|
104
135
|
rows = store._conn.execute(
|
|
105
|
-
f"""SELECT
|
|
106
|
-
|
|
107
|
-
AND
|
|
136
|
+
f"""SELECT entity_id, COUNT(*) as matches
|
|
137
|
+
FROM triples
|
|
138
|
+
WHERE attribute = 'tag' AND NOT retracted
|
|
139
|
+
AND value IN ({placeholders})
|
|
140
|
+
GROUP BY entity_id
|
|
141
|
+
ORDER BY matches DESC
|
|
108
142
|
LIMIT ?""",
|
|
109
|
-
(*
|
|
143
|
+
(*keywords, limit),
|
|
110
144
|
).fetchall()
|
|
111
145
|
fact_ids = [r["entity_id"] for r in rows]
|
|
112
146
|
else:
|
|
@@ -127,8 +161,8 @@ def _load_graph_facts(db_path: str, entities: list[str] | None = None, limit: in
|
|
|
127
161
|
attrs = store.entity(fid)
|
|
128
162
|
if attrs:
|
|
129
163
|
fact = {"entityId": fid}
|
|
130
|
-
for
|
|
131
|
-
fact[
|
|
164
|
+
for attr_name, values in attrs.items():
|
|
165
|
+
fact[attr_name] = values[0] if len(values) == 1 else values
|
|
132
166
|
facts.append(fact)
|
|
133
167
|
|
|
134
168
|
store.close()
|
|
@@ -172,6 +206,9 @@ def _execute_graph_ops(db_path: str, ops: list[dict], digest_ts: str) -> dict:
|
|
|
172
206
|
store.assert_triple(tx, entity_id, "reinforce_count", "1")
|
|
173
207
|
if domain:
|
|
174
208
|
store.assert_triple(tx, entity_id, "domain", domain)
|
|
209
|
+
# Auto-tag for keyword-based discovery
|
|
210
|
+
for tag in _extract_tags(value):
|
|
211
|
+
store.assert_triple(tx, entity_id, "tag", tag)
|
|
175
212
|
stats["asserted"] += 1
|
|
176
213
|
|
|
177
214
|
elif op == "reinforce":
|
|
@@ -186,16 +223,15 @@ def _execute_graph_ops(db_path: str, ops: list[dict], digest_ts: str) -> dict:
|
|
|
186
223
|
|
|
187
224
|
cur_conf = 0.5
|
|
188
225
|
cur_count = 0
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
except ValueError:
|
|
226
|
+
if "confidence" in attrs:
|
|
227
|
+
try:
|
|
228
|
+
cur_conf = float(attrs["confidence"][0])
|
|
229
|
+
except (ValueError, IndexError):
|
|
230
|
+
pass
|
|
231
|
+
if "reinforce_count" in attrs:
|
|
232
|
+
try:
|
|
233
|
+
cur_count = int(attrs["reinforce_count"][0])
|
|
234
|
+
except (ValueError, IndexError):
|
|
199
235
|
pass
|
|
200
236
|
|
|
201
237
|
new_conf = min(1.0, cur_conf + 0.15)
|
|
@@ -209,7 +245,10 @@ def _execute_graph_ops(db_path: str, ops: list[dict], digest_ts: str) -> dict:
|
|
|
209
245
|
store.assert_triple(tx, entity_id, "confidence", str(round(new_conf, 2)))
|
|
210
246
|
store.retract_triple(tx, entity_id, "reinforce_count", str(cur_count))
|
|
211
247
|
store.assert_triple(tx, entity_id, "reinforce_count", str(new_count))
|
|
212
|
-
|
|
248
|
+
# Retract old last_reinforced if present
|
|
249
|
+
old_reinforced = attrs.get("last_reinforced", [])
|
|
250
|
+
for val in old_reinforced:
|
|
251
|
+
store.retract_triple(tx, entity_id, "last_reinforced", val)
|
|
213
252
|
store.assert_triple(tx, entity_id, "last_reinforced", digest_ts)
|
|
214
253
|
stats["reinforced"] += 1
|
|
215
254
|
|
|
@@ -224,8 +263,9 @@ def _execute_graph_ops(db_path: str, ops: list[dict], digest_ts: str) -> dict:
|
|
|
224
263
|
}))
|
|
225
264
|
# Retract all attributes of this entity
|
|
226
265
|
attrs = store.entity(entity_id)
|
|
227
|
-
for
|
|
228
|
-
|
|
266
|
+
for attr_name, values in attrs.items():
|
|
267
|
+
for val in values:
|
|
268
|
+
store.retract_triple(tx, entity_id, attr_name, val)
|
|
229
269
|
stats["retracted"] += 1
|
|
230
270
|
|
|
231
271
|
store.close()
|
|
@@ -335,6 +375,7 @@ def main() -> None:
|
|
|
335
375
|
parser.add_argument("--memory-dir", required=True, help="Path to memory/ directory")
|
|
336
376
|
parser.add_argument("--digest", default=None, help="SessionDigest JSON string")
|
|
337
377
|
parser.add_argument("--bootstrap", action="store_true", help="One-time: seed graph from playbook")
|
|
378
|
+
parser.add_argument("--retag", action="store_true", help="Re-extract tags for all existing facts")
|
|
338
379
|
args = parser.parse_args()
|
|
339
380
|
|
|
340
381
|
memory_dir = args.memory_dir
|
|
@@ -346,9 +387,37 @@ def main() -> None:
|
|
|
346
387
|
output_json(result)
|
|
347
388
|
return
|
|
348
389
|
|
|
390
|
+
# Retag mode: extract tags for all existing facts
|
|
391
|
+
if args.retag:
|
|
392
|
+
if not Path(db_path).exists():
|
|
393
|
+
output_json({"error": "knowledge-graph.db not found"})
|
|
394
|
+
return
|
|
395
|
+
from triplestore import TripleStore
|
|
396
|
+
store = TripleStore(db_path)
|
|
397
|
+
# Get all fact entities that have a 'value' attribute
|
|
398
|
+
rows = store._conn.execute(
|
|
399
|
+
"SELECT DISTINCT entity_id FROM triples WHERE attribute = 'value' AND NOT retracted AND entity_id LIKE 'fact:%'"
|
|
400
|
+
).fetchall()
|
|
401
|
+
tagged = 0
|
|
402
|
+
for row in rows:
|
|
403
|
+
fid = row["entity_id"]
|
|
404
|
+
attrs = store.entity(fid)
|
|
405
|
+
value_text = attrs.get("value", [""])[0] if attrs else ""
|
|
406
|
+
existing_tags = set(attrs.get("tag", [])) if attrs else set()
|
|
407
|
+
new_tags = _extract_tags(value_text)
|
|
408
|
+
missing = [t for t in new_tags if t not in existing_tags]
|
|
409
|
+
if missing:
|
|
410
|
+
tx = store.begin_tx("retag", metadata=json.dumps({"entity_id": fid}))
|
|
411
|
+
for tag in missing:
|
|
412
|
+
store.assert_triple(tx, fid, "tag", tag)
|
|
413
|
+
tagged += 1
|
|
414
|
+
store.close()
|
|
415
|
+
output_json({"retagged": tagged, "total_facts": len(rows)})
|
|
416
|
+
return
|
|
417
|
+
|
|
349
418
|
# Normal mode: integrate session digest
|
|
350
419
|
if not args.digest:
|
|
351
|
-
print("--digest is required (unless --bootstrap)", file=sys.stderr)
|
|
420
|
+
print("--digest is required (unless --bootstrap or --retag)", file=sys.stderr)
|
|
352
421
|
output_json({"error": "--digest required"})
|
|
353
422
|
return
|
|
354
423
|
|