@geravant/sinain 1.10.0 → 1.11.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/package.json +1 -1
- package/sinain-agent/CLAUDE.md +50 -0
- package/sinain-agent/run.sh +73 -10
- package/sinain-core/src/agent/analyzer.ts +4 -27
- package/sinain-core/src/agent/loop.ts +10 -40
- package/sinain-core/src/agent/situation-writer.ts +0 -16
- package/sinain-core/src/config.ts +1 -9
- package/sinain-core/src/escalation/escalator.ts +43 -16
- package/sinain-core/src/index.ts +316 -61
- package/sinain-core/src/learning/local-curation.ts +373 -0
- package/sinain-core/src/overlay/commands.ts +31 -11
- package/sinain-core/src/overlay/ws-handler.ts +10 -1
- package/sinain-core/src/server.ts +318 -0
- package/sinain-core/src/types.ts +22 -28
- package/sinain-mcp-server/index.ts +62 -4
- package/sinain-memory/eval/assertions.py +0 -21
- package/sinain-memory/eval/retrieval_benchmark.jsonl +12 -0
- package/sinain-memory/eval/retrieval_evaluator.py +186 -0
- package/sinain-memory/graph_query.py +34 -1
- package/sinain-memory/knowledge_integrator.py +54 -0
- package/sinain-memory/triplestore.py +76 -5
- package/sinain-core/src/agent/traits.ts +0 -520
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LocalCurationService — local-first knowledge pipeline for sinain-core.
|
|
3
|
+
*
|
|
4
|
+
* Runs the same Python scripts as the OpenClaw server-side pipeline,
|
|
5
|
+
* but triggered locally: on session end (SIGINT/SIGTERM) and periodically.
|
|
6
|
+
*
|
|
7
|
+
* This ensures knowledge persists between bare-agent sessions even when
|
|
8
|
+
* no OpenClaw gateway is available.
|
|
9
|
+
*
|
|
10
|
+
* Memory directory: SINAIN_MEMORY_DIR (default: ~/.sinain/memory)
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from "node:child_process";
|
|
14
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync, appendFileSync } from "node:fs";
|
|
15
|
+
import { resolve, dirname } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
17
|
+
import type { FeedItem } from "../types.js";
|
|
18
|
+
import { log, warn, error } from "../log.js";
|
|
19
|
+
|
|
20
|
+
const TAG = "local-curation";
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
22
|
+
|
|
23
|
+
/** Resolve the sinain-memory Python scripts directory. */
|
|
24
|
+
function resolveScriptsDir(): string {
|
|
25
|
+
// Look for sinain-memory scripts in known locations
|
|
26
|
+
const candidates = [
|
|
27
|
+
resolve(__dirname, "..", "..", "..", "sinain-hud-plugin", "sinain-memory"),
|
|
28
|
+
resolve(__dirname, "..", "..", "sinain-memory"),
|
|
29
|
+
resolve(process.env.HOME || "", ".sinain", "sinain-memory"),
|
|
30
|
+
];
|
|
31
|
+
for (const dir of candidates) {
|
|
32
|
+
if (existsSync(resolve(dir, "session_distiller.py"))) {
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return candidates[0]; // Fallback
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Resolve the local memory directory. */
|
|
40
|
+
function resolveMemoryDir(): string {
|
|
41
|
+
const raw = process.env.SINAIN_MEMORY_DIR
|
|
42
|
+
|| process.env.OPENCLAW_WORKSPACE_DIR
|
|
43
|
+
|| `${process.env.HOME}/.sinain/memory`;
|
|
44
|
+
const expanded = raw.startsWith("~") ? raw.replace("~", process.env.HOME || "") : raw;
|
|
45
|
+
|
|
46
|
+
// If pointing to workspace, use the memory subdirectory
|
|
47
|
+
if (expanded.endsWith("/workspace") || expanded.endsWith("/workspace/")) {
|
|
48
|
+
return resolve(expanded, "memory");
|
|
49
|
+
}
|
|
50
|
+
return expanded;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class LocalCurationService {
|
|
54
|
+
private memoryDir: string;
|
|
55
|
+
private scriptsDir: string;
|
|
56
|
+
private sessionStartTs: number;
|
|
57
|
+
private curationTimer: ReturnType<typeof setInterval> | null = null;
|
|
58
|
+
|
|
59
|
+
constructor() {
|
|
60
|
+
this.memoryDir = resolveMemoryDir();
|
|
61
|
+
this.scriptsDir = resolveScriptsDir();
|
|
62
|
+
this.sessionStartTs = Date.now();
|
|
63
|
+
|
|
64
|
+
// Ensure memory directory exists
|
|
65
|
+
for (const subdir of ["", "playbook-logs", "playbook-archive", "eval-logs", "eval-reports"]) {
|
|
66
|
+
const dir = resolve(this.memoryDir, subdir);
|
|
67
|
+
mkdirSync(dir, { recursive: true });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
log(TAG, `memory: ${this.memoryDir}`);
|
|
71
|
+
log(TAG, `scripts: ${this.scriptsDir}`);
|
|
72
|
+
log(TAG, `scripts available: ${existsSync(resolve(this.scriptsDir, "session_distiller.py"))}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Start periodic curation (30-minute timer). */
|
|
76
|
+
startPeriodicCuration(): void {
|
|
77
|
+
if (this.curationTimer) return;
|
|
78
|
+
const intervalMs = 30 * 60 * 1000; // 30 minutes
|
|
79
|
+
this.curationTimer = setInterval(() => {
|
|
80
|
+
this.runCurationPipeline();
|
|
81
|
+
}, intervalMs);
|
|
82
|
+
log(TAG, `periodic curation started (every 30 min)`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Stop periodic curation. */
|
|
86
|
+
stop(): void {
|
|
87
|
+
if (this.curationTimer) {
|
|
88
|
+
clearInterval(this.curationTimer);
|
|
89
|
+
this.curationTimer = null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Save feed items to disk for deferred distillation.
|
|
95
|
+
* Called during shutdown — instant (no LLM), survives tsx force-kill.
|
|
96
|
+
*/
|
|
97
|
+
savePendingSession(feedItems: FeedItem[]): void {
|
|
98
|
+
if (feedItems.length < 1) {
|
|
99
|
+
log(TAG, `skipping save — only ${feedItems.length} feed items`);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const pendingPath = resolve(this.memoryDir, "pending-session.json");
|
|
104
|
+
const data = {
|
|
105
|
+
ts: new Date().toISOString(),
|
|
106
|
+
sessionKey: "local-session",
|
|
107
|
+
durationMs: Date.now() - this.sessionStartTs,
|
|
108
|
+
items: feedItems.map(item => ({
|
|
109
|
+
text: item.text,
|
|
110
|
+
ts: item.ts,
|
|
111
|
+
source: item.source || "unknown",
|
|
112
|
+
channel: item.channel || "agent",
|
|
113
|
+
})),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
writeFileSync(pendingPath, JSON.stringify(data), "utf-8");
|
|
117
|
+
log(TAG, `saved ${feedItems.length} feed items to pending-session.json`);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Distill a previously saved pending session (from a prior shutdown).
|
|
122
|
+
* Called on startup — runs LLM distillation when there's no time pressure.
|
|
123
|
+
*/
|
|
124
|
+
distillPendingSession(): void {
|
|
125
|
+
const pendingPath = resolve(this.memoryDir, "pending-session.json");
|
|
126
|
+
if (!existsSync(pendingPath)) return;
|
|
127
|
+
|
|
128
|
+
let data: any;
|
|
129
|
+
try {
|
|
130
|
+
data = JSON.parse(readFileSync(pendingPath, "utf-8"));
|
|
131
|
+
} catch {
|
|
132
|
+
warn(TAG, "corrupt pending-session.json — removing");
|
|
133
|
+
unlinkSync(pendingPath);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const items: FeedItem[] = data.items || [];
|
|
138
|
+
if (items.length < 1) {
|
|
139
|
+
log(TAG, `pending session too small (${items.length} items) — removing`);
|
|
140
|
+
unlinkSync(pendingPath);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
log(TAG, `distilling pending session: ${items.length} items from ${data.ts}`);
|
|
145
|
+
|
|
146
|
+
// Remove pending file first so a crash here doesn't loop
|
|
147
|
+
unlinkSync(pendingPath);
|
|
148
|
+
|
|
149
|
+
this.runDistillation(items, {
|
|
150
|
+
ts: data.ts,
|
|
151
|
+
sessionKey: data.sessionKey || "local-session",
|
|
152
|
+
durationMs: data.durationMs || 0,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Distill the current session on shutdown.
|
|
158
|
+
* First saves feed items to disk (instant), then attempts LLM distillation.
|
|
159
|
+
* If tsx kills the process before LLM finishes, the saved file will be
|
|
160
|
+
* picked up on next startup via distillPendingSession().
|
|
161
|
+
*/
|
|
162
|
+
async distillSession(feedItems: FeedItem[]): Promise<void> {
|
|
163
|
+
if (feedItems.length < 1) {
|
|
164
|
+
log(TAG, `skipping distillation — only ${feedItems.length} feed items`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Step 0: Save to disk FIRST — survives force-kill
|
|
169
|
+
this.savePendingSession(feedItems);
|
|
170
|
+
|
|
171
|
+
const sessionMeta = {
|
|
172
|
+
ts: new Date().toISOString(),
|
|
173
|
+
sessionKey: "local-session",
|
|
174
|
+
durationMs: Date.now() - this.sessionStartTs,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const transcript = feedItems.map(item => ({
|
|
178
|
+
text: item.text,
|
|
179
|
+
ts: item.ts,
|
|
180
|
+
source: item.source || "unknown",
|
|
181
|
+
channel: item.channel || "agent",
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
// Step 1+2: Attempt LLM distillation (may be killed by tsx)
|
|
185
|
+
if (this.runDistillation(transcript, sessionMeta)) {
|
|
186
|
+
// Success — remove the pending file since we distilled in-place
|
|
187
|
+
const pendingPath = resolve(this.memoryDir, "pending-session.json");
|
|
188
|
+
try { unlinkSync(pendingPath); } catch { /* already gone */ }
|
|
189
|
+
}
|
|
190
|
+
// If killed here, pending-session.json remains for next startup
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Run the actual distillation pipeline (session_distiller + knowledge_integrator).
|
|
195
|
+
* Returns true if distillation succeeded.
|
|
196
|
+
*/
|
|
197
|
+
private runDistillation(transcript: any[], sessionMeta: { ts: string; sessionKey: string; durationMs: number }): boolean {
|
|
198
|
+
if (!existsSync(resolve(this.scriptsDir, "session_distiller.py"))) {
|
|
199
|
+
warn(TAG, "session_distiller.py not found — skipping distillation");
|
|
200
|
+
this.writeDailyNotesFallback(transcript as any);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
log(TAG, `distilling session: ${transcript.length} items, ${Math.round(sessionMeta.durationMs / 60000)} min`);
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
// Step 1: Distill session into a SessionDigest
|
|
208
|
+
const digestJson = execFileSync("python3", [
|
|
209
|
+
resolve(this.scriptsDir, "session_distiller.py"),
|
|
210
|
+
"--memory-dir", this.memoryDir,
|
|
211
|
+
"--transcript", JSON.stringify(transcript),
|
|
212
|
+
"--session-meta", JSON.stringify(sessionMeta),
|
|
213
|
+
], {
|
|
214
|
+
timeout: 30_000,
|
|
215
|
+
encoding: "utf-8",
|
|
216
|
+
env: { ...process.env, PYTHONPATH: this.scriptsDir },
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const digest = JSON.parse(digestJson);
|
|
220
|
+
|
|
221
|
+
if (digest.isEmpty || digest.error) {
|
|
222
|
+
log(TAG, `distillation skipped: ${digest.error || "empty session"}`);
|
|
223
|
+
this.writeDailyNotesFallback(transcript as any);
|
|
224
|
+
return false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
log(TAG, `distilled: "${(digest.whatHappened || "").slice(0, 80)}..."`);
|
|
228
|
+
|
|
229
|
+
// Write daily session notes
|
|
230
|
+
this.writeDailyNotes(digest, transcript as any);
|
|
231
|
+
|
|
232
|
+
// Step 2: Integrate into playbook + knowledge graph
|
|
233
|
+
try {
|
|
234
|
+
const integratorOutput = execFileSync("python3", [
|
|
235
|
+
resolve(this.scriptsDir, "knowledge_integrator.py"),
|
|
236
|
+
"--memory-dir", this.memoryDir,
|
|
237
|
+
"--digest", JSON.stringify(digest),
|
|
238
|
+
], {
|
|
239
|
+
timeout: 30_000,
|
|
240
|
+
encoding: "utf-8",
|
|
241
|
+
env: { ...process.env, PYTHONPATH: this.scriptsDir },
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const result = JSON.parse(integratorOutput);
|
|
245
|
+
log(TAG, `knowledge integrated: ${JSON.stringify(result.graphStats || result)}`);
|
|
246
|
+
} catch (err: any) {
|
|
247
|
+
warn(TAG, `knowledge integration failed: ${err.message?.slice(0, 200)}`);
|
|
248
|
+
}
|
|
249
|
+
return true;
|
|
250
|
+
} catch (err: any) {
|
|
251
|
+
warn(TAG, `distillation failed: ${err.message?.slice(0, 200)}`);
|
|
252
|
+
this.writeDailyNotesFallback(transcript as any);
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Run the periodic curation pipeline (feedback → mining → curation). */
|
|
258
|
+
private runCurationPipeline(): void {
|
|
259
|
+
log(TAG, "running periodic curation...");
|
|
260
|
+
|
|
261
|
+
const scripts = [
|
|
262
|
+
"feedback_analyzer.py",
|
|
263
|
+
"memory_miner.py",
|
|
264
|
+
"playbook_curator.py",
|
|
265
|
+
];
|
|
266
|
+
|
|
267
|
+
for (const script of scripts) {
|
|
268
|
+
const scriptPath = resolve(this.scriptsDir, script);
|
|
269
|
+
if (!existsSync(scriptPath)) {
|
|
270
|
+
warn(TAG, `${script} not found — skipping`);
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
try {
|
|
275
|
+
execFileSync("python3", [
|
|
276
|
+
scriptPath,
|
|
277
|
+
"--memory-dir", this.memoryDir,
|
|
278
|
+
], {
|
|
279
|
+
timeout: 60_000,
|
|
280
|
+
encoding: "utf-8",
|
|
281
|
+
env: { ...process.env, PYTHONPATH: this.scriptsDir },
|
|
282
|
+
});
|
|
283
|
+
log(TAG, ` ✓ ${script}`);
|
|
284
|
+
} catch (err: any) {
|
|
285
|
+
warn(TAG, ` ✗ ${script}: ${err.message?.slice(0, 100)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
log(TAG, "periodic curation complete");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Write distilled session notes to daily file. */
|
|
293
|
+
private writeDailyNotes(digest: any, feedItems: FeedItem[]): void {
|
|
294
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
295
|
+
const notesPath = resolve(this.memoryDir, `${date}.md`);
|
|
296
|
+
|
|
297
|
+
const sections = [
|
|
298
|
+
`## Session ${new Date().toISOString().slice(11, 16)} UTC`,
|
|
299
|
+
"",
|
|
300
|
+
`### Summary`,
|
|
301
|
+
digest.whatHappened || "(no summary)",
|
|
302
|
+
"",
|
|
303
|
+
];
|
|
304
|
+
|
|
305
|
+
if (digest.patterns?.length > 0) {
|
|
306
|
+
sections.push("### Patterns Observed");
|
|
307
|
+
for (const p of digest.patterns) {
|
|
308
|
+
sections.push(`- ${typeof p === "string" ? p : p.pattern || JSON.stringify(p)}`);
|
|
309
|
+
}
|
|
310
|
+
sections.push("");
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (digest.entities?.length > 0) {
|
|
314
|
+
sections.push("### Entities");
|
|
315
|
+
sections.push(digest.entities.map((e: string) => `\`${e}\``).join(", "));
|
|
316
|
+
sections.push("");
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
if (digest.preferences?.length > 0) {
|
|
320
|
+
sections.push("### User Preferences");
|
|
321
|
+
for (const p of digest.preferences) {
|
|
322
|
+
sections.push(`- ${typeof p === "string" ? p : JSON.stringify(p)}`);
|
|
323
|
+
}
|
|
324
|
+
sections.push("");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Append (don't overwrite — multiple sessions per day)
|
|
328
|
+
const content = sections.join("\n") + "\n---\n\n";
|
|
329
|
+
appendFileSync(notesPath, content, "utf-8");
|
|
330
|
+
log(TAG, `daily notes written: ${notesPath}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Fallback: write raw feed summary when distillation fails. */
|
|
334
|
+
private writeDailyNotesFallback(feedItems: FeedItem[]): void {
|
|
335
|
+
if (feedItems.length < 1) return;
|
|
336
|
+
|
|
337
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
338
|
+
const notesPath = resolve(this.memoryDir, `${date}.md`);
|
|
339
|
+
|
|
340
|
+
const agentItems = feedItems.filter(i => i.source === "openclaw" || i.text.startsWith("[🤖]") || i.text.startsWith("[🔧"));
|
|
341
|
+
const audioItems = feedItems.filter(i => i.text.startsWith("[🔊]"));
|
|
342
|
+
|
|
343
|
+
const sections = [
|
|
344
|
+
`## Session ${new Date().toISOString().slice(11, 16)} UTC (raw — distillation unavailable)`,
|
|
345
|
+
"",
|
|
346
|
+
`${feedItems.length} feed items, ${audioItems.length} audio, ${agentItems.length} agent responses`,
|
|
347
|
+
"",
|
|
348
|
+
];
|
|
349
|
+
|
|
350
|
+
// Include agent responses (most valuable)
|
|
351
|
+
if (agentItems.length > 0) {
|
|
352
|
+
sections.push("### Agent Responses");
|
|
353
|
+
for (const item of agentItems.slice(-10)) {
|
|
354
|
+
sections.push(`- ${item.text.slice(0, 200)}`);
|
|
355
|
+
}
|
|
356
|
+
sections.push("");
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Include audio highlights (first 5 non-trivial)
|
|
360
|
+
const meaningfulAudio = audioItems.filter(i => i.text.length > 20 && !i.text.includes("Thank you") && !i.text.includes("Okay"));
|
|
361
|
+
if (meaningfulAudio.length > 0) {
|
|
362
|
+
sections.push("### Audio Highlights");
|
|
363
|
+
for (const item of meaningfulAudio.slice(0, 10)) {
|
|
364
|
+
sections.push(`- ${item.text}`);
|
|
365
|
+
}
|
|
366
|
+
sections.push("");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const content = sections.join("\n") + "\n---\n\n";
|
|
370
|
+
appendFileSync(notesPath, content, "utf-8");
|
|
371
|
+
log(TAG, `daily notes (fallback) written: ${notesPath}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
@@ -21,8 +21,8 @@ export interface CommandDeps {
|
|
|
21
21
|
onSpawnCommand?: (text: string) => void;
|
|
22
22
|
/** Toggle screen capture — returns new state */
|
|
23
23
|
onToggleScreen: () => boolean;
|
|
24
|
-
/** Toggle
|
|
25
|
-
|
|
24
|
+
/** Toggle escalation pause/resume — returns true if now active */
|
|
25
|
+
onToggleEscalation: () => boolean;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
28
|
/**
|
|
@@ -79,6 +79,27 @@ export function setupCommands(deps: CommandDeps): void {
|
|
|
79
79
|
}
|
|
80
80
|
break;
|
|
81
81
|
}
|
|
82
|
+
case "spawn_reply": {
|
|
83
|
+
const { taskId, text } = msg as any;
|
|
84
|
+
log(TAG, `spawn reply for ${taskId}: "${(text || "").slice(0, 60)}"`);
|
|
85
|
+
// Forward to the /spawn/reply HTTP endpoint internally
|
|
86
|
+
fetch(`http://localhost:${deps.config.port}/spawn/reply`, {
|
|
87
|
+
method: "POST",
|
|
88
|
+
headers: { "Content-Type": "application/json" },
|
|
89
|
+
body: JSON.stringify({ taskId, text }),
|
|
90
|
+
}).catch(() => {});
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
case "spawn_permission_reply": {
|
|
94
|
+
const { taskId, decision } = msg as any;
|
|
95
|
+
log(TAG, `spawn permission reply for ${taskId}: ${decision}`);
|
|
96
|
+
fetch(`http://localhost:${deps.config.port}/spawn/permission-reply`, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: { "Content-Type": "application/json" },
|
|
99
|
+
body: JSON.stringify({ taskId, decision }),
|
|
100
|
+
}).catch(() => {});
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
82
103
|
case "command": {
|
|
83
104
|
handleCommand(msg.action, deps);
|
|
84
105
|
log(TAG, `command processed: ${msg.action}`);
|
|
@@ -142,15 +163,14 @@ function handleCommand(action: string, deps: CommandDeps): void {
|
|
|
142
163
|
log(TAG, `screen toggled ${nowActive ? "ON" : "OFF"}`);
|
|
143
164
|
break;
|
|
144
165
|
}
|
|
145
|
-
case "
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
log(TAG, `traits toggled ${nowEnabled ? "ON" : "OFF"}`);
|
|
166
|
+
case "toggle_escalation": {
|
|
167
|
+
const nowActive = deps.onToggleEscalation();
|
|
168
|
+
wsHandler.updateState({ escalation: nowActive ? "active" : "paused" });
|
|
169
|
+
wsHandler.broadcast(
|
|
170
|
+
nowActive ? "Escalations resumed" : "Escalations paused — context still accumulating",
|
|
171
|
+
"normal"
|
|
172
|
+
);
|
|
173
|
+
log(TAG, `escalation toggled ${nowActive ? "ON" : "OFF"}`);
|
|
154
174
|
break;
|
|
155
175
|
}
|
|
156
176
|
case "open_settings": {
|
|
@@ -37,6 +37,7 @@ export class WsHandler {
|
|
|
37
37
|
audio: "muted",
|
|
38
38
|
mic: "muted",
|
|
39
39
|
screen: "off",
|
|
40
|
+
escalation: "active",
|
|
40
41
|
connection: "disconnected",
|
|
41
42
|
};
|
|
42
43
|
private replayBuffer: FeedMessage[] = [];
|
|
@@ -72,6 +73,7 @@ export class WsHandler {
|
|
|
72
73
|
audio: this.state.audio,
|
|
73
74
|
mic: this.state.mic,
|
|
74
75
|
screen: this.state.screen,
|
|
76
|
+
escalation: this.state.escalation,
|
|
75
77
|
connection: this.state.connection,
|
|
76
78
|
});
|
|
77
79
|
|
|
@@ -149,11 +151,12 @@ export class WsHandler {
|
|
|
149
151
|
|
|
150
152
|
/** Send a status update to all connected overlays. */
|
|
151
153
|
broadcastStatus(): void {
|
|
152
|
-
const msg: StatusMessage & { envPath?: string } = {
|
|
154
|
+
const msg: StatusMessage & { envPath?: string; escalation?: string } = {
|
|
153
155
|
type: "status",
|
|
154
156
|
audio: this.state.audio,
|
|
155
157
|
mic: this.state.mic,
|
|
156
158
|
screen: this.state.screen,
|
|
159
|
+
escalation: this.state.escalation,
|
|
157
160
|
connection: this.state.connection,
|
|
158
161
|
};
|
|
159
162
|
if (loadedEnvPath) msg.envPath = loadedEnvPath;
|
|
@@ -229,6 +232,12 @@ export class WsHandler {
|
|
|
229
232
|
case "spawn_command":
|
|
230
233
|
log(TAG, `\u2190 spawn command: ${msg.text.slice(0, 100)}`);
|
|
231
234
|
break;
|
|
235
|
+
case "spawn_reply":
|
|
236
|
+
log(TAG, `\u2190 spawn reply: taskId=${(msg as any).taskId}`);
|
|
237
|
+
break;
|
|
238
|
+
case "spawn_permission_reply":
|
|
239
|
+
log(TAG, `\u2190 spawn permission reply: taskId=${(msg as any).taskId} decision=${(msg as any).decision}`);
|
|
240
|
+
break;
|
|
232
241
|
case "profiling":
|
|
233
242
|
if (this.onProfilingCb) this.onProfilingCb(msg);
|
|
234
243
|
return;
|