@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 +4 -2
- package/install.js +79 -12
- package/package.json +2 -2
- package/sinain-agent/CLAUDE.md +14 -7
- 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 +56 -7
- package/sinain-core/src/escalation/message-builder.ts +54 -1
- package/sinain-core/src/index.ts +37 -0
- 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 +54 -0
- package/sinain-core/src/types.ts +14 -2
- package/sinain-knowledge/curation/engine.ts +137 -24
- package/sinain-knowledge/data/git-store.ts +24 -0
- package/sinain-knowledge/data/store.ts +117 -0
- package/sinain-mcp-server/index.ts +109 -14
- package/sinain-memory/graph_query.py +210 -0
- package/sinain-memory/knowledge_integrator.py +519 -0
- package/sinain-memory/memory-config.json +3 -1
- package/sinain-memory/session_distiller.py +162 -0
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
|
|
564
|
-
|
|
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.
|
|
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
|
-
//
|
|
266
|
+
// Knowledge snapshot repo (optional)
|
|
267
|
+
await setupSnapshotRepo();
|
|
268
|
+
|
|
269
|
+
// Start / restart gateway
|
|
245
270
|
try {
|
|
246
|
-
execSync("openclaw
|
|
247
|
-
console.log(" ✓ Gateway
|
|
271
|
+
execSync("openclaw gateway restart --background", { stdio: "pipe" });
|
|
272
|
+
console.log(" ✓ Gateway restarted");
|
|
248
273
|
} catch {
|
|
249
274
|
try {
|
|
250
|
-
execSync("openclaw
|
|
251
|
-
console.log(" ✓ Gateway
|
|
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
|
-
|
|
258
|
-
✓ sinain installed successfully.
|
|
259
|
-
Plugin config: ~/.openclaw/openclaw.json
|
|
260
|
-
Auth token:
|
|
261
|
-
|
|
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
|
|
4
|
-
"description": "
|
|
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",
|
package/sinain-agent/CLAUDE.md
CHANGED
|
@@ -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
|
-
- `
|
|
16
|
-
- `
|
|
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 `
|
|
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 `
|
|
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
|
|
73
|
-
- `
|
|
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
|
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";
|
|
@@ -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 =
|
|
201
|
-
|
|
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.
|
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";
|
|
@@ -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
|
|
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?.();
|