@geravant/sinain 1.1.0 → 1.2.1

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/index.ts CHANGED
@@ -560,8 +560,10 @@ export default function sinainHudPlugin(api: OpenClawPluginApi): void {
560
560
  `sinain-hud: session summary written (${toolCount} tools, ${Math.round(durationMs / 1000)}s)`,
561
561
  );
562
562
 
563
- // Fire-and-forget: ingest session summary into triple store
564
- if (state.workspaceDir) {
563
+ // Fire-and-forget: ingest session summary into knowledge graph
564
+ // NOTE: Main knowledge integration happens in heartbeat tick (session_distiller + knowledge_integrator).
565
+ // This is a lightweight best-effort path for when agent_end fires (won't fire on kill).
566
+ if (false && state.workspaceDir) {
565
567
  api.runtime.system.runCommandWithTimeout(
566
568
  ["uv", "run", "--with", "requests", "python3",
567
569
  "sinain-memory/triple_ingest.py",
package/install.js CHANGED
@@ -3,6 +3,7 @@ import fs from "fs";
3
3
  import path from "path";
4
4
  import os from "os";
5
5
  import { execSync } from "child_process";
6
+ import { randomBytes } from "crypto";
6
7
 
7
8
  const HOME = os.homedir();
8
9
  const PLUGIN_DIR = path.join(HOME, ".openclaw/extensions/sinain-hud");
@@ -18,7 +19,9 @@ const SKILL = path.join(PKG_DIR, "SKILL.md");
18
19
 
19
20
  console.log("\nInstalling sinain plugin...");
20
21
 
21
- // 1. Stage plugin files to local ~/.openclaw (used by both paths)
22
+ // 1. Copy plugin files (remove stale extensions/sinain dir if present from old installs)
23
+ const stalePluginDir = path.join(HOME, ".openclaw/extensions/sinain");
24
+ if (fs.existsSync(stalePluginDir)) fs.rmSync(stalePluginDir, { recursive: true, force: true });
22
25
  fs.mkdirSync(PLUGIN_DIR, { recursive: true });
23
26
  fs.copyFileSync(path.join(PKG_DIR, "index.ts"), path.join(PLUGIN_DIR, "index.ts"));
24
27
  fs.copyFileSync(path.join(PKG_DIR, "openclaw.plugin.json"), path.join(PLUGIN_DIR, "openclaw.plugin.json"));
@@ -115,6 +118,8 @@ async function installNemoClaw({ sandboxName }) {
115
118
  sessionKey: "agent:main:sinain"
116
119
  }
117
120
  };
121
+ // Remove stale "sinain" entry if present from a previous install
122
+ delete cfg.plugins.entries["sinain"];
118
123
  if (!cfg.plugins.allow.includes("sinain-hud")) cfg.plugins.allow.push("sinain-hud");
119
124
  cfg.agents ??= {};
120
125
  cfg.agents.defaults ??= {};
@@ -145,6 +150,19 @@ async function installNemoClaw({ sandboxName }) {
145
150
  }
146
151
  }
147
152
 
153
+ // Knowledge snapshot repo (optional) — inside sandbox
154
+ const snapshotUrl = process.env.SINAIN_SNAPSHOT_REPO;
155
+ if (snapshotUrl) {
156
+ try {
157
+ await checkRepoPrivacy(snapshotUrl);
158
+ run(`ssh -T openshell-${sandboxName} 'mkdir -p ~/.sinain/knowledge-snapshots && cd ~/.sinain/knowledge-snapshots && ([ -d .git ] || (git init && git config user.name sinain-knowledge && git config user.email sinain@local)) && (git remote get-url origin >/dev/null 2>&1 && git remote set-url origin "${snapshotUrl}" || git remote add origin "${snapshotUrl}") && (git fetch origin && git checkout -B main origin/main) 2>/dev/null || true'`);
159
+ console.log(" ✓ Snapshot repo configured in sandbox");
160
+ } catch (e) {
161
+ console.error("\n ✗ Snapshot repo setup aborted:", e.message, "\n");
162
+ process.exit(1);
163
+ }
164
+ }
165
+
148
166
  // Restart openclaw gateway inside sandbox (kill existing PID + start fresh)
149
167
  try {
150
168
  // Find the gateway PID, kill it, then start a new instance detached
@@ -201,6 +219,10 @@ async function installLocal() {
201
219
  sessionKey: "agent:main:sinain"
202
220
  }
203
221
  };
222
+ // Remove stale "sinain" entry if present from a previous install
223
+ delete cfg.plugins.entries["sinain"];
224
+ cfg.plugins.allow ??= [];
225
+ if (!cfg.plugins.allow.includes("sinain-hud")) cfg.plugins.allow.push("sinain-hud");
204
226
  cfg.agents ??= {};
205
227
  cfg.agents.defaults ??= {};
206
228
  cfg.agents.defaults.sandbox ??= {};
@@ -241,25 +263,70 @@ async function installLocal() {
241
263
  }
242
264
  }
243
265
 
244
- // Reload gateway
266
+ // Knowledge snapshot repo (optional)
267
+ await setupSnapshotRepo();
268
+
269
+ // Start / restart gateway
245
270
  try {
246
- execSync("openclaw reload", { stdio: "pipe" });
247
- console.log(" ✓ Gateway reloaded");
271
+ execSync("openclaw gateway restart --background", { stdio: "pipe" });
272
+ console.log(" ✓ Gateway restarted");
248
273
  } catch {
249
274
  try {
250
- execSync("openclaw stop && sleep 1 && openclaw start --background", { stdio: "pipe" });
251
- console.log(" ✓ Gateway restarted");
275
+ execSync("openclaw gateway start --background", { stdio: "pipe" });
276
+ console.log(" ✓ Gateway started");
252
277
  } catch {
253
278
  console.warn(" ⚠ Could not start gateway — run: openclaw gateway");
254
279
  }
255
280
  }
256
281
 
257
- console.log(`
258
- ✓ sinain installed successfully.
259
- Plugin config: ~/.openclaw/openclaw.json
260
- Auth token: check your Brev dashboard → 'Gateway Token'
261
- Then run ./setup-nemoclaw.sh on your Mac.
262
- `);
282
+ const token = cfg?.gateway?.auth?.token ?? "(see openclaw.json)";
283
+ console.log("\n✓ sinain installed successfully.");
284
+ console.log(" Plugin config: ~/.openclaw/openclaw.json");
285
+ console.log(` Auth token: ${token}`);
286
+ console.log(" Next: run 'openclaw gateway' if not running, or restart to pick up changes.\n");
287
+ }
288
+
289
+ // ── Knowledge snapshot repo setup (shared) ──────────────────────────────────
290
+
291
+ const SNAPSHOT_DIR = path.join(HOME, ".sinain", "knowledge-snapshots");
292
+
293
+ async function setupSnapshotRepo() {
294
+ const snapshotUrl = process.env.SINAIN_SNAPSHOT_REPO;
295
+ if (!snapshotUrl) return;
296
+
297
+ try {
298
+ await checkRepoPrivacy(snapshotUrl);
299
+ fs.mkdirSync(SNAPSHOT_DIR, { recursive: true });
300
+
301
+ if (!fs.existsSync(path.join(SNAPSHOT_DIR, ".git"))) {
302
+ execSync(`git init "${SNAPSHOT_DIR}"`, { stdio: "pipe" });
303
+ execSync(`git -C "${SNAPSHOT_DIR}" config user.name "sinain-knowledge"`, { stdio: "pipe" });
304
+ execSync(`git -C "${SNAPSHOT_DIR}" config user.email "sinain@local"`, { stdio: "pipe" });
305
+ }
306
+
307
+ // Set remote (add or update)
308
+ try {
309
+ run_capture(`git -C "${SNAPSHOT_DIR}" remote get-url origin`);
310
+ execSync(`git -C "${SNAPSHOT_DIR}" remote set-url origin "${snapshotUrl}"`, { stdio: "pipe" });
311
+ } catch {
312
+ execSync(`git -C "${SNAPSHOT_DIR}" remote add origin "${snapshotUrl}"`, { stdio: "pipe" });
313
+ }
314
+
315
+ // Pull existing snapshots if remote has content
316
+ try {
317
+ execSync(`git -C "${SNAPSHOT_DIR}" fetch origin`, { stdio: "pipe", timeout: 15_000 });
318
+ execSync(`git -C "${SNAPSHOT_DIR}" checkout -B main origin/main`, { stdio: "pipe" });
319
+ console.log(" ✓ Snapshot repo restored from", snapshotUrl);
320
+ } catch {
321
+ console.log(" ✓ Snapshot repo configured (empty remote)");
322
+ }
323
+ } catch (e) {
324
+ if (e.message?.startsWith("SECURITY") || e.message?.startsWith("Refusing")) {
325
+ console.error("\n ✗ Snapshot repo setup aborted:", e.message, "\n");
326
+ process.exit(1);
327
+ }
328
+ console.warn(" ⚠ Snapshot repo setup failed:", e.message);
329
+ }
263
330
  }
264
331
 
265
332
  // ── Detection ────────────────────────────────────────────────────────────────
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.1.0",
4
- "description": "sinain AI overlay system for macOS (npx @geravant/sinain start)",
3
+ "version": "1.2.1",
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",
@@ -12,8 +12,10 @@ You have MCP tools from `sinain-mcp-server`:
12
12
  - `sinain_get_feedback` — get feedback signals from recent escalations
13
13
  - `sinain_post_feed` — push an arbitrary message to the HUD
14
14
  - `sinain_health` — check system health
15
- - `sinain_knowledge_query` — query the knowledge graph for relevant context
16
- - `sinain_heartbeat_tick` — run the full curation pipeline (git backup, signals, insights, playbook)
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)
17
19
  - `sinain_module_guidance` — get active module guidance
18
20
 
19
21
  ## Main Loop
@@ -23,7 +25,7 @@ Your primary job is an escalation response loop:
23
25
  1. Call `sinain_get_escalation` to check for pending escalations
24
26
  2. If an escalation is present:
25
27
  a. Read the escalation message carefully — it contains screen OCR, audio transcripts, app context, and the local agent's digest
26
- b. Optionally call `sinain_knowledge_query` with key context from the escalation to enrich your response
28
+ b. Optionally call `sinain_get_knowledge` to read the knowledge document, or `sinain_knowledge_query` with specific entities to enrich your response
27
29
  c. Optionally call `sinain_module_guidance` to get active module instructions
28
30
  d. Craft a response and call `sinain_respond` with the escalation ID and your response
29
31
  3. If no escalation is pending, wait a few seconds and poll again
@@ -47,10 +49,12 @@ When responding to escalations:
47
49
  2. The tool runs the full pipeline automatically:
48
50
  - Git backup of memory directory
49
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)
50
54
  - Insight synthesis (generates suggestions from accumulated patterns)
51
- - Playbook curation (updates effective playbook based on feedback)
52
55
  3. If the result contains a suggestion or insight, post it to the HUD via `sinain_post_feed`
53
- 4. Optionally call `sinain_get_feedback` to review recent escalation scores
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
54
58
 
55
59
  ## Spawning Background Tasks
56
60
 
@@ -69,8 +73,11 @@ Rules:
69
73
  ## Files You Manage
70
74
 
71
75
  Your working memory lives at `~/.openclaw/workspace/memory/`:
72
- - `playbook.md` — your effective playbook (updated by curation pipeline)
73
- - `triplestore.db` — knowledge graph (SQLite, managed by Python scripts)
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
74
81
  - `playbook-logs/YYYY-MM-DD.jsonl` — decision logs
75
82
 
76
83
  ## Privacy
@@ -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";
@@ -9,7 +9,7 @@ import { OpenClawWsClient } from "./openclaw-ws.js";
9
9
  import { EscalationSlot } from "./escalation-slot.js";
10
10
  import type { SlotEntry, QueueFeedbackCtx } from "./escalation-slot.js";
11
11
  import { shouldEscalate, calculateEscalationScore } from "./scorer.js";
12
- import { isCodingContext, buildEscalationMessage } from "./message-builder.js";
12
+ import { isCodingContext, buildEscalationMessage, fetchKnowledgeFacts } from "./message-builder.js";
13
13
  import { loadPendingTasks, savePendingTasks, type PendingTaskEntry } from "../util/task-store.js";
14
14
  import { log, warn, error } from "../log.js";
15
15
 
@@ -32,6 +32,7 @@ export interface EscalatorDeps {
32
32
  profiler?: Profiler;
33
33
  feedbackStore?: FeedbackStore;
34
34
  signalCollector?: SignalCollector;
35
+ queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
35
36
  }
36
37
 
37
38
  /**
@@ -73,6 +74,10 @@ export class Escalator {
73
74
  // Store context from last escalation for response handling
74
75
  private lastEscalationContext: ContextWindow | null = null;
75
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
+
76
81
  private stats = {
77
82
  totalEscalations: 0,
78
83
  totalResponses: 0,
@@ -122,6 +127,15 @@ export class Escalator {
122
127
  this.deps.signalCollector = sc;
123
128
  }
124
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
+
125
139
  /** Start the WS connection to OpenClaw (skipped when transport=http). */
126
140
  start(): void {
127
141
  if (this.deps.escalationConfig.mode !== "off" && this.deps.escalationConfig.transport !== "http") {
@@ -159,7 +173,15 @@ export class Escalator {
159
173
  * Called after every agent analysis tick.
160
174
  * Decides whether to escalate and enqueues the message for delivery.
161
175
  */
162
- onAgentAnalysis(entry: AgentEntry, contextWindow: ContextWindow): void {
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
+
163
185
  // Skip WS escalations when circuit is open (HTTP transport bypasses this)
164
186
  const transport = this.deps.escalationConfig.transport;
165
187
  if (this.wsClient.isCircuitOpen && transport !== "http") {
@@ -167,6 +189,9 @@ export class Escalator {
167
189
  return;
168
190
  }
169
191
 
192
+ // If user command is pending, force escalation (bypass score + cooldown)
193
+ const hasUserCommand = this.pendingUserCommand !== null;
194
+
170
195
  const { escalate, score, stale } = shouldEscalate(
171
196
  entry.digest,
172
197
  entry.hud,
@@ -178,7 +203,7 @@ export class Escalator {
178
203
  this.deps.escalationConfig.staleMs,
179
204
  );
180
205
 
181
- if (!escalate) {
206
+ if (!escalate && !hasUserCommand) {
182
207
  log(TAG, `tick #${entry.id}: not escalating (mode=${this.deps.escalationConfig.mode}, score=${score.total}, hud="${entry.hud.slice(0, 40)}")`);
183
208
  return;
184
209
  }
@@ -191,21 +216,44 @@ export class Escalator {
191
216
  this.lastEscalatedDigest = entry.digest;
192
217
 
193
218
  const staleTag = stale ? ", STALE" : "";
219
+ const cmdTag = hasUserCommand ? ", USER_CMD" : "";
194
220
  const wsState = this.wsClient.isConnected ? "ws=connected" : "ws=disconnected";
195
- 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})`);
196
222
 
197
223
  // Store context for response handling (used in pushResponse for coding-context max-length)
198
224
  this.lastEscalationContext = contextWindow;
199
225
 
200
- const escalationReason = score.reasons.join(", ");
201
- const message = buildEscalationMessage(
226
+ const escalationReason = hasUserCommand
227
+ ? `user_command: ${this.pendingUserCommand!.text.slice(0, 80)}`
228
+ : score.reasons.join(", ");
229
+ let message = buildEscalationMessage(
202
230
  entry.digest,
203
231
  contextWindow,
204
232
  entry,
205
233
  this.deps.escalationConfig.mode,
206
234
  escalationReason,
235
+ undefined,
236
+ this.pendingUserCommand ?? undefined,
207
237
  );
208
238
 
239
+ // Clear user command after building the message (consumed once)
240
+ this.pendingUserCommand = null;
241
+
242
+ // Enrich with long-term knowledge facts (best-effort, 5s max)
243
+ if (this.deps.queryKnowledgeFacts) {
244
+ try {
245
+ const knowledgeSection = await fetchKnowledgeFacts(
246
+ contextWindow, entry.digest, this.deps.queryKnowledgeFacts,
247
+ );
248
+ if (knowledgeSection) {
249
+ message = message + "\n\n" + knowledgeSection;
250
+ log(TAG, `knowledge enrichment injected (${knowledgeSection.length} chars)`);
251
+ }
252
+ } catch (err) {
253
+ log(TAG, `knowledge enrichment failed: ${String(err)}`);
254
+ }
255
+ }
256
+
209
257
  const slotId = createHash("sha256").update(this.deps.openclawConfig.sessionKey + entry.ts).digest("hex").slice(0, 16);
210
258
  const slotEntry: SlotEntry = {
211
259
  id: slotId,
@@ -373,6 +421,7 @@ ${recentLines.join("\n")}`;
373
421
  cooldownMs: this.deps.escalationConfig.cooldownMs,
374
422
  staleMs: this.deps.escalationConfig.staleMs,
375
423
  pendingSpawnTasks: this.pendingSpawnTasks.size,
424
+ pendingUserCommand: this.pendingUserCommand ? this.pendingUserCommand.text.slice(0, 80) : null,
376
425
  ...this.stats,
377
426
  };
378
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
 
@@ -292,6 +298,53 @@ the local analyzer reported idle/no-change. Provide a PROACTIVE response:
292
298
  return sections.join("\n\n");
293
299
  }
294
300
 
301
+ /**
302
+ * Fetch relevant long-term knowledge facts for the current escalation context.
303
+ * Extracts entities from the context and queries the knowledge graph.
304
+ * Returns a formatted section or empty string if no relevant facts found.
305
+ */
306
+ export async function fetchKnowledgeFacts(
307
+ context: ContextWindow,
308
+ digest: string,
309
+ queryFn?: (entities: string[], maxFacts: number) => Promise<string>,
310
+ ): Promise<string> {
311
+ if (!queryFn) return "";
312
+
313
+ // Extract entities from context: current app + error types + domain keywords
314
+ const entities: string[] = [];
315
+ const app = normalizeAppName(context.currentApp).toLowerCase().replace(/\s+/g, "-");
316
+ if (app && app.length > 2) entities.push(app);
317
+
318
+ // Extract error type keywords from digest
319
+ const errorPatterns = digest.matchAll(
320
+ /(?:Error|Exception|TypeError|SyntaxError|ReferenceError|HTTP\s*\d{3}|ENOENT|EACCES)/gi,
321
+ );
322
+ for (const m of errorPatterns) {
323
+ entities.push(m[0].toLowerCase());
324
+ }
325
+
326
+ // Extract technology keywords
327
+ const techKeywords = [
328
+ "react-native", "react", "flutter", "swift", "kotlin", "python",
329
+ "typescript", "node", "docker", "metro", "gradle", "cocoapods",
330
+ "intellij", "vscode", "xcode", "sinain",
331
+ ];
332
+ const lowerDigest = digest.toLowerCase();
333
+ for (const kw of techKeywords) {
334
+ if (lowerDigest.includes(kw)) entities.push(kw);
335
+ }
336
+
337
+ if (entities.length === 0) return "";
338
+
339
+ try {
340
+ const factsText = await queryFn(entities.slice(0, 5), 5);
341
+ if (factsText && factsText.trim().length > 20) {
342
+ return `## Past Experience\nBased on long-term knowledge relevant to this context:\n${factsText.trim()}`;
343
+ }
344
+ } catch {}
345
+ return "";
346
+ }
347
+
295
348
  /**
296
349
  * Format a compact inline feedback section for escalation messages.
297
350
  * Shows recent performance so the agent can calibrate its response style.
@@ -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";
@@ -78,6 +79,17 @@ async function main() {
78
79
  openclawConfig: config.openclawConfig,
79
80
  profiler,
80
81
  feedbackStore: feedbackStore ?? undefined,
82
+ queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
83
+ const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
84
+ const dbPath = `${workspace}/memory/knowledge-graph.db`;
85
+ const scriptPath = `${workspace}/sinain-memory/graph_query.py`;
86
+ try {
87
+ const { execFileSync } = await import("node:child_process");
88
+ const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts), "--format", "text"];
89
+ if (entities.length > 0) args.push("--entities", JSON.stringify(entities));
90
+ return execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" });
91
+ } catch { return ""; }
92
+ },
81
93
  });
82
94
 
83
95
  // ── Initialize agent loop (event-driven) ──
@@ -379,9 +391,31 @@ async function main() {
379
391
  getTraces: (after, limit) => tracer ? tracer.getTraces(after, limit) : [],
380
392
  reconnectGateway: () => escalator.reconnectGateway(),
381
393
 
394
+ // User command injection (bare agent / HTTP)
395
+ setUserCommand: (text: string) => escalator.setUserCommand(text),
396
+
382
397
  // Bare agent HTTP escalation bridge
383
398
  getEscalationPending: () => escalator.getPendingHttp(),
384
399
  respondEscalation: (id: string, response: string) => escalator.respondHttp(id, response),
400
+
401
+ // Knowledge graph integration
402
+ getKnowledgeDocPath: () => {
403
+ const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
404
+ const p = `${workspace}/memory/sinain-knowledge.md`;
405
+ try { if (existsSync(p)) return p; } catch {}
406
+ return null;
407
+ },
408
+ queryKnowledgeFacts: async (entities: string[], maxFacts: number) => {
409
+ const workspace = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
410
+ const dbPath = `${workspace}/memory/knowledge-graph.db`;
411
+ const scriptPath = `${workspace}/sinain-memory/graph_query.py`;
412
+ try {
413
+ const { execFileSync } = await import("node:child_process");
414
+ const args = [scriptPath, "--db", dbPath, "--max-facts", String(maxFacts), "--format", "text"];
415
+ if (entities.length > 0) args.push("--entities", JSON.stringify(entities));
416
+ return execFileSync("python3", args, { timeout: 5000, encoding: "utf-8" });
417
+ } catch { return ""; }
418
+ },
385
419
  });
386
420
 
387
421
  // ── Wire overlay profiling ──
@@ -398,6 +432,9 @@ async function main() {
398
432
  onUserMessage: async (text) => {
399
433
  await escalator.sendDirect(text);
400
434
  },
435
+ onUserCommand: (text) => {
436
+ escalator.setUserCommand(text);
437
+ },
401
438
  onToggleScreen: () => {
402
439
  screenActive = !screenActive;
403
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,8 +34,11 @@ 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;
40
+ getKnowledgeDocPath?: () => string | null;
41
+ queryKnowledgeFacts?: (entities: string[], maxFacts: number) => Promise<string>;
39
42
  }
40
43
 
41
44
  function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
@@ -207,6 +210,43 @@ export function createAppServer(deps: ServerDeps) {
207
210
  return;
208
211
  }
209
212
 
213
+ // ── /knowledge ──
214
+ if (req.method === "GET" && url.pathname === "/knowledge") {
215
+ // Return portable knowledge document
216
+ const knowledgePath = deps.getKnowledgeDocPath?.();
217
+ if (knowledgePath) {
218
+ try {
219
+ const { readFileSync } = await import("node:fs");
220
+ const content = readFileSync(knowledgePath, "utf-8");
221
+ res.end(JSON.stringify({ ok: true, content }));
222
+ } catch {
223
+ res.end(JSON.stringify({ ok: true, content: "" }));
224
+ }
225
+ } else {
226
+ res.end(JSON.stringify({ ok: true, content: "" }));
227
+ }
228
+ return;
229
+ }
230
+
231
+ if (req.method === "GET" && url.pathname === "/knowledge/facts") {
232
+ // Query knowledge graph for entity-matched facts
233
+ const entitiesParam = url.searchParams.get("entities") || "";
234
+ const maxFacts = Math.min(parseInt(url.searchParams.get("max") || "5"), 20);
235
+ const entities = entitiesParam.split(",").map(e => e.trim()).filter(Boolean);
236
+
237
+ if (deps.queryKnowledgeFacts) {
238
+ try {
239
+ const facts = await deps.queryKnowledgeFacts(entities, maxFacts);
240
+ res.end(JSON.stringify({ ok: true, facts }));
241
+ } catch (err) {
242
+ res.end(JSON.stringify({ ok: true, facts: [], error: String(err) }));
243
+ }
244
+ } else {
245
+ res.end(JSON.stringify({ ok: true, facts: [] }));
246
+ }
247
+ return;
248
+ }
249
+
210
250
  // ── /traces ──
211
251
  if (req.method === "GET" && url.pathname === "/traces") {
212
252
  const after = parseInt(url.searchParams.get("after") || "0");
@@ -266,6 +306,20 @@ export function createAppServer(deps: ServerDeps) {
266
306
  return;
267
307
  }
268
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
+
269
323
  // ── /escalation/pending ──
270
324
  if (req.method === "GET" && url.pathname === "/escalation/pending") {
271
325
  const pending = deps.getEscalationPending?.();