@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 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 Clone and build the overlay app
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 Flutter overlay
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: ${authToken}`);
285
- console.log(" Next: run 'openclaw gateway' in a new terminal, then run ./setup-nemoclaw.sh on your Mac.\n");
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 overlayDir = findOverlayDir();
143
- const hasFlutter = commandExists("flutter");
144
- if (overlayDir && hasFlutter) {
145
- log("Starting overlay...");
146
- startProcess("overlay", "flutter", ["run", "-d", "macos"], {
147
- cwd: overlayDir,
148
- color: MAGENTA,
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 (!overlayDir) {
160
- warn("overlay not found — run: sinain setup-overlay");
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("flutter not found — overlay skipped");
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
- warn("flutter not found overlay will be skipped");
240
- skipOverlay = true;
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 findOverlayDir() {
465
- // 1. Sibling overlay/ (running from cloned repo)
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
- // 2. ~/.sinain/overlay/ (installed via setup-overlay)
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.2.0",
4
- "description": "sinain AI overlay system for macOS (npx @geravant/sinain start)",
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 — clone and build the Flutter overlay app
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) { console.log(`${BOLD}[setup-overlay]${RESET} ${msg}`); }
20
- function ok(msg) { console.log(`${BOLD}[setup-overlay]${RESET} ${GREEN}✓${RESET} ${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
- // 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");
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
- const flutterVer = execSync("flutter --version 2>&1", { encoding: "utf-8" }).split("\n")[0];
31
- ok(`flutter: ${flutterVer}`);
46
+ // ── Download pre-built .app ──────────────────────────────────────────────────
32
47
 
33
- fs.mkdirSync(SINAIN_DIR, { recursive: true });
48
+ async function downloadPrebuilt() {
49
+ fs.mkdirSync(APP_DIR, { recursive: true });
34
50
 
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 });
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
- // 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
- }
67
+ const tag = release.tag_name;
68
+ const version = tag.replace("overlay-v", "");
58
69
 
59
- log("Installing Flutter dependencies...");
60
- execSync("flutter pub get", { cwd: overlayDir, stdio: "inherit" });
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
- log("Building overlay (this may take a few minutes)...");
63
- execSync("flutter build macos", { cwd: overlayDir, stdio: "inherit" });
64
- ok("Overlay built successfully");
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
- // Symlink ~/.sinain/overlay → the overlay source dir
67
- try {
68
- if (fs.existsSync(OVERLAY_LINK)) {
69
- fs.unlinkSync(OVERLAY_LINK);
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
- 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}`);
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
- console.log(`
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
+ }
@@ -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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sinain-core",
3
3
  "version": "1.0.0",
4
- "description": "Unified HUD-sense-audio-bridge-relayreplaces relay + bridge with a single process",
4
+ "description": "SinainHUD coreaudio transcription, agent analysis loop, escalation orchestration, WebSocket feed",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "scripts": {
@@ -228,7 +228,6 @@ export function loadConfig(): CoreConfig {
228
228
  return {
229
229
  port: intEnv("PORT", 9500),
230
230
  audioConfig,
231
- audioAltDevice: env("AUDIO_ALT_DEVICE", "BlackHole 2ch"),
232
231
  micConfig,
233
232
  micEnabled,
234
233
  transcriptionConfig,
@@ -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 = score.reasons.join(", ");
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
 
@@ -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 (require("node:fs").existsSync(p)) return p; } catch {}
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, config } = deps;
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?.();
@@ -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. sinain_module_guidance
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 related to specified entities/domains."""
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
- # Find fact entity_ids that match the requested domains or entity names
33
- placeholders = ",".join(["?" for _ in entities])
34
- # Match by domain attribute OR by entity name substring
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 DISTINCT entity_id FROM triples
40
- WHERE NOT retracted AND (
41
- (attribute = 'domain' AND value IN ({placeholders}))
42
- OR ({like_clauses})
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
- (*entities, *entity_likes, max_facts * 3),
50
+ (*keywords, max_facts * 3),
46
51
  ).fetchall()
47
52
 
48
53
  fact_ids = [r["entity_id"] for r in rows]
49
54
 
50
- # Load full attributes for each fact, sorted by confidence
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 a in attrs:
58
- fact[a["attribute"]] = a["value"]
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 a in attrs:
97
- fact[a["attribute"]] = a["value"]
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
- # Entity-scoped query: find facts related to specified domains
103
- domain_clause = " OR ".join([f"value = ?" for _ in entities])
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 DISTINCT entity_id FROM triples
106
- WHERE attribute = 'domain' AND NOT retracted
107
- AND ({domain_clause})
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
- (*entities, limit),
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 a in attrs:
131
- fact[a["attribute"]] = a["value"]
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
- for a in attrs:
190
- if a["attribute"] == "confidence":
191
- try:
192
- cur_conf = float(a["value"])
193
- except ValueError:
194
- pass
195
- elif a["attribute"] == "reinforce_count":
196
- try:
197
- cur_count = int(a["value"])
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
- store.retract_triple(tx, entity_id, "last_reinforced", "") # retract any
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 a in attrs:
228
- store.retract_triple(tx, entity_id, a["attribute"], a["value"])
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