@geravant/sinain 1.7.1 → 1.8.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/.env.example +6 -1
- package/package.json +1 -1
- package/sinain-agent/run.sh +13 -7
- package/sinain-core/src/agent/analyzer.ts +2 -20
- package/sinain-core/src/agent/loop.ts +9 -5
- package/sinain-core/src/config.ts +2 -2
- package/sinain-core/src/escalation/escalator.ts +59 -31
- package/sinain-core/src/index.ts +47 -31
- package/sinain-core/src/overlay/commands.ts +9 -0
- package/sinain-core/src/overlay/ws-handler.ts +3 -0
- package/sinain-agent/.env.example +0 -23
package/.env.example
CHANGED
|
@@ -20,6 +20,11 @@ SINAIN_CORE_URL=http://localhost:9500
|
|
|
20
20
|
SINAIN_POLL_INTERVAL=5 # seconds between escalation polls
|
|
21
21
|
SINAIN_HEARTBEAT_INTERVAL=900 # seconds between heartbeat ticks (15 min)
|
|
22
22
|
SINAIN_WORKSPACE=~/.openclaw/workspace # knowledge files, curation scripts, playbook
|
|
23
|
+
# SINAIN_ALLOWED_TOOLS=mcp__sinain # MCP tools auto-approved for bare agent
|
|
24
|
+
SINAIN_AGENT_MAX_TURNS=5 # max tool-use turns for escalation responses
|
|
25
|
+
SINAIN_SPAWN_MAX_TURNS=25 # max tool-use turns for spawn tasks (Shift+Enter)
|
|
26
|
+
# auto-derived from mcp-config.json if unset
|
|
27
|
+
# format: mcp__<server> (all) | mcp__<server>__<tool> (specific)
|
|
23
28
|
|
|
24
29
|
# ── Escalation ───────────────────────────────────────────────────────────────
|
|
25
30
|
ESCALATION_MODE=rich # off | selective | focus | rich
|
|
@@ -27,7 +32,7 @@ ESCALATION_MODE=rich # off | selective | focus | rich
|
|
|
27
32
|
# selective: score-based (errors, questions trigger it)
|
|
28
33
|
# focus: always escalate every tick
|
|
29
34
|
# rich: always escalate with maximum context
|
|
30
|
-
ESCALATION_COOLDOWN_MS=
|
|
35
|
+
# ESCALATION_COOLDOWN_MS=10000
|
|
31
36
|
# ESCALATION_TRANSPORT=auto # ws | http | auto
|
|
32
37
|
# auto = WS when gateway connected, HTTP fallback
|
|
33
38
|
# http = bare agent only (no gateway)
|
package/package.json
CHANGED
package/sinain-agent/run.sh
CHANGED
|
@@ -5,7 +5,9 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
5
5
|
|
|
6
6
|
# Load .env as fallback — does NOT override vars already in the environment
|
|
7
7
|
# (e.g. vars set by the launcher from ~/.sinain/.env)
|
|
8
|
-
|
|
8
|
+
# Load project root .env (single config for all subsystems)
|
|
9
|
+
ENV_FILE="$SCRIPT_DIR/../.env"
|
|
10
|
+
if [ -f "$ENV_FILE" ]; then
|
|
9
11
|
while IFS='=' read -r key val; do
|
|
10
12
|
# Skip comments and blank lines
|
|
11
13
|
[[ -z "$key" || "$key" =~ ^[[:space:]]*# ]] && continue
|
|
@@ -19,15 +21,17 @@ if [ -f "$SCRIPT_DIR/.env" ]; then
|
|
|
19
21
|
if [ -z "${!key+x}" ]; then
|
|
20
22
|
export "$key=$val"
|
|
21
23
|
fi
|
|
22
|
-
done < "$
|
|
24
|
+
done < "$ENV_FILE"
|
|
23
25
|
fi
|
|
24
26
|
|
|
25
27
|
MCP_CONFIG="${MCP_CONFIG:-$SCRIPT_DIR/mcp-config.json}"
|
|
26
28
|
CORE_URL="${SINAIN_CORE_URL:-http://localhost:9500}"
|
|
27
|
-
POLL_INTERVAL="${SINAIN_POLL_INTERVAL:-
|
|
29
|
+
POLL_INTERVAL="${SINAIN_POLL_INTERVAL:-2}"
|
|
28
30
|
HEARTBEAT_INTERVAL="${SINAIN_HEARTBEAT_INTERVAL:-900}" # 15 minutes
|
|
29
31
|
AGENT="${SINAIN_AGENT:-claude}"
|
|
30
32
|
WORKSPACE="${SINAIN_WORKSPACE:-$HOME/.openclaw/workspace}"
|
|
33
|
+
AGENT_MAX_TURNS="${SINAIN_AGENT_MAX_TURNS:-5}"
|
|
34
|
+
SPAWN_MAX_TURNS="${SINAIN_SPAWN_MAX_TURNS:-25}"
|
|
31
35
|
|
|
32
36
|
# Build allowed tools list for Claude's --allowedTools flag.
|
|
33
37
|
# SINAIN_ALLOWED_TOOLS in .env overrides; otherwise auto-derive from MCP config.
|
|
@@ -64,10 +68,11 @@ invoke_agent() {
|
|
|
64
68
|
local prompt="$1"
|
|
65
69
|
case "$AGENT" in
|
|
66
70
|
claude)
|
|
71
|
+
local turns="${2:-$AGENT_MAX_TURNS}"
|
|
67
72
|
claude --enable-auto-mode \
|
|
68
73
|
--mcp-config "$MCP_CONFIG" \
|
|
69
74
|
${ALLOWED_TOOLS:+--allowedTools $ALLOWED_TOOLS} \
|
|
70
|
-
--max-turns
|
|
75
|
+
--max-turns "$turns" --output-format text \
|
|
71
76
|
-p "$prompt"
|
|
72
77
|
;;
|
|
73
78
|
codex)
|
|
@@ -88,9 +93,10 @@ invoke_agent() {
|
|
|
88
93
|
fi
|
|
89
94
|
;;
|
|
90
95
|
goose)
|
|
96
|
+
local turns="${2:-$AGENT_MAX_TURNS}"
|
|
91
97
|
GOOSE_MODE=auto goose run --text "$prompt" \
|
|
92
98
|
--output-format text \
|
|
93
|
-
--max-turns
|
|
99
|
+
--max-turns "$turns"
|
|
94
100
|
;;
|
|
95
101
|
aider)
|
|
96
102
|
# No MCP support — signal pipe mode
|
|
@@ -265,8 +271,8 @@ while true; do
|
|
|
265
271
|
# MCP path: agent runs task with sinain tools available
|
|
266
272
|
SPAWN_PROMPT="You have a background task to complete. Task: $SPAWN_TASK
|
|
267
273
|
|
|
268
|
-
Complete this task thoroughly. Use sinain_get_knowledge and sinain_knowledge_query if you need context from past sessions. Summarize your findings concisely."
|
|
269
|
-
SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" || echo "ERROR: agent invocation failed")
|
|
274
|
+
Complete this task thoroughly. Use sinain_get_knowledge and sinain_knowledge_query if you need context from past sessions. Use web search, file operations, and code execution as needed. Create end-to-end artifacts. Summarize your findings concisely."
|
|
275
|
+
SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" "$SPAWN_MAX_TURNS" || echo "ERROR: agent invocation failed")
|
|
270
276
|
else
|
|
271
277
|
# Pipe path: agent gets task text directly
|
|
272
278
|
SPAWN_RESULT=$(invoke_pipe "Background task: $SPAWN_TASK" || echo "No output")
|
|
@@ -56,13 +56,12 @@ You produce outputs as JSON.
|
|
|
56
56
|
Respond ONLY with valid JSON. No markdown, no code fences, no explanation.
|
|
57
57
|
Your entire response must be parseable by JSON.parse().
|
|
58
58
|
|
|
59
|
-
{"hud":"...","digest":"...","record":{"command":"start"|"stop","label":"..."}
|
|
59
|
+
{"hud":"...","digest":"...","record":{"command":"start"|"stop","label":"..."}}
|
|
60
60
|
|
|
61
61
|
Output fields:
|
|
62
62
|
- "hud" (required): max 60 words describing what user is doing NOW
|
|
63
63
|
- "digest" (required): 5-8 sentences with detailed activity description
|
|
64
64
|
- "record" (optional): control recording — {"command":"start","label":"Meeting name"} or {"command":"stop"}
|
|
65
|
-
- "task" (optional): natural language instruction to spawn a background task
|
|
66
65
|
|
|
67
66
|
When to use "record":
|
|
68
67
|
- START when user begins a meeting, call, lecture, YouTube video, or important audio content
|
|
@@ -70,24 +69,7 @@ When to use "record":
|
|
|
70
69
|
- Provide descriptive labels like "Team standup", "Client call", "YouTube: [video title from OCR]"
|
|
71
70
|
- For YouTube/video content: extract video title from screen OCR for the label
|
|
72
71
|
|
|
73
|
-
|
|
74
|
-
- User explicitly asks for research, lookup, or action
|
|
75
|
-
- Something needs external search or processing that isn't a real-time response
|
|
76
|
-
- Example: "Search for React 19 migration guide", "Find docs for this API"
|
|
77
|
-
|
|
78
|
-
When to spawn "task" for video content:
|
|
79
|
-
- If user watches a YouTube video for 2+ minutes AND no task has been spawned for this video yet, spawn: "Summarize YouTube video: [title or URL from OCR]"
|
|
80
|
-
- ONLY spawn ONCE per video - do not repeat spawn for the same video in subsequent ticks
|
|
81
|
-
- Extract video title or URL from screen OCR to include in the task
|
|
82
|
-
|
|
83
|
-
When to spawn "task" for coding problems:
|
|
84
|
-
- If user is actively working on a coding problem/challenge for 1+ minutes:
|
|
85
|
-
- Spawn: "Solve coding problem: [problem description/title from OCR]"
|
|
86
|
-
- This includes LeetCode, HackerRank, interviews, coding assessments, or any visible coding challenge
|
|
87
|
-
- Look for problem signals: "Input:", "Output:", "Example", "Constraints:", problem titles, test cases
|
|
88
|
-
- Include as much context as possible from the screen OCR (problem description, examples, constraints)
|
|
89
|
-
- ONLY spawn ONCE per distinct problem - do not repeat for the same problem
|
|
90
|
-
- The spawned task should provide a complete solution with code and explanation
|
|
72
|
+
Do NOT set a "task" field — background tasks are spawned by user commands only.
|
|
91
73
|
|
|
92
74
|
Audio sources: [\ud83d\udd0a]=system/speaker audio, [\ud83c\udf99]=microphone (user's voice).
|
|
93
75
|
Treat [\ud83c\udf99] as direct user speech. Treat [\ud83d\udd0a] as external audio.
|
|
@@ -75,6 +75,7 @@ export class AgentLoop extends EventEmitter {
|
|
|
75
75
|
private running = false;
|
|
76
76
|
private started = false;
|
|
77
77
|
private firstTick = true;
|
|
78
|
+
private urgentPending = false;
|
|
78
79
|
|
|
79
80
|
private lastPushedHud = "";
|
|
80
81
|
private agentNextId = 1;
|
|
@@ -137,11 +138,12 @@ export class AgentLoop extends EventEmitter {
|
|
|
137
138
|
* Called by sense POST handler and transcription callback.
|
|
138
139
|
* Triggers debounced analysis.
|
|
139
140
|
*/
|
|
140
|
-
onNewContext(): void {
|
|
141
|
+
onNewContext(urgent = false): void {
|
|
141
142
|
if (!this.started) return;
|
|
142
143
|
|
|
143
|
-
//
|
|
144
|
-
const delay = this.firstTick ? 500 : this.deps.agentConfig.debounceMs;
|
|
144
|
+
// Urgent: user command — minimal debounce, bypass cooldown
|
|
145
|
+
const delay = urgent ? 200 : this.firstTick ? 500 : this.deps.agentConfig.debounceMs;
|
|
146
|
+
if (urgent) this.urgentPending = true;
|
|
145
147
|
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
146
148
|
this.debounceTimer = setTimeout(() => {
|
|
147
149
|
this.debounceTimer = null;
|
|
@@ -235,8 +237,10 @@ export class AgentLoop extends EventEmitter {
|
|
|
235
237
|
if (this.running) return;
|
|
236
238
|
if (!this.deps.agentConfig.openrouterApiKey) return;
|
|
237
239
|
|
|
238
|
-
// Cooldown: don't re-analyze within cooldownMs of last run
|
|
239
|
-
|
|
240
|
+
// Cooldown: don't re-analyze within cooldownMs of last run (unless urgent)
|
|
241
|
+
const isUrgent = this.urgentPending;
|
|
242
|
+
this.urgentPending = false;
|
|
243
|
+
if (!isUrgent && Date.now() - this.lastRunTs < this.deps.agentConfig.cooldownMs) return;
|
|
240
244
|
|
|
241
245
|
// Idle suppression: skip if no new events since last tick
|
|
242
246
|
const { feedBuffer, senseBuffer } = this.deps;
|
|
@@ -11,10 +11,10 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
11
11
|
export let loadedEnvPath: string | undefined;
|
|
12
12
|
|
|
13
13
|
function loadDotEnv(): void {
|
|
14
|
-
// Try
|
|
14
|
+
// Try project root .env first, then sinain-core/.env fallback
|
|
15
15
|
const candidates = [
|
|
16
|
-
resolve(__dirname, "..", ".env"),
|
|
17
16
|
resolve(__dirname, "..", "..", ".env"),
|
|
17
|
+
resolve(__dirname, "..", ".env"),
|
|
18
18
|
];
|
|
19
19
|
for (const envPath of candidates) {
|
|
20
20
|
if (!existsSync(envPath)) continue;
|
|
@@ -13,6 +13,14 @@ import { isCodingContext, buildEscalationMessage, fetchKnowledgeFacts } from "./
|
|
|
13
13
|
import { loadPendingTasks, savePendingTasks, type PendingTaskEntry } from "../util/task-store.js";
|
|
14
14
|
import { log, warn, error } from "../log.js";
|
|
15
15
|
|
|
16
|
+
/** Context passed to spawn subagents so they can act on the user's current situation. */
|
|
17
|
+
export interface SpawnContext {
|
|
18
|
+
currentApp?: string;
|
|
19
|
+
digest?: string;
|
|
20
|
+
recentAudio?: string;
|
|
21
|
+
recentScreen?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
16
24
|
export interface HttpPendingEscalation {
|
|
17
25
|
id: string;
|
|
18
26
|
message: string;
|
|
@@ -465,7 +473,7 @@ ${recentLines.join("\n")}`;
|
|
|
465
473
|
* Creates a unique child session key and sends the task directly to the gateway
|
|
466
474
|
* agent RPC — bypassing the main session to avoid dedup/NO_REPLY issues.
|
|
467
475
|
*/
|
|
468
|
-
async dispatchSpawnTask(task: string, label?: string): Promise<void> {
|
|
476
|
+
async dispatchSpawnTask(task: string, label?: string, context?: SpawnContext): Promise<void> {
|
|
469
477
|
// Prevent sibling spawn RPCs from piling up (independent from escalation queue)
|
|
470
478
|
if (this.spawnInFlight) {
|
|
471
479
|
log(TAG, `spawn-task skipped — spawn RPC already in-flight`);
|
|
@@ -485,9 +493,12 @@ ${recentLines.join("\n")}`;
|
|
|
485
493
|
this.lastSpawnFingerprint = fingerprint;
|
|
486
494
|
this.lastSpawnTs = now;
|
|
487
495
|
|
|
496
|
+
// Truncate label to gateway's 64-char limit
|
|
497
|
+
const safeLabel = label?.slice(0, 64);
|
|
498
|
+
|
|
488
499
|
const taskId = `spawn-${Date.now()}`;
|
|
489
500
|
const startedAt = Date.now();
|
|
490
|
-
const labelStr =
|
|
501
|
+
const labelStr = safeLabel ? ` (label: "${safeLabel}")` : "";
|
|
491
502
|
const idemKey = `spawn-task-${Date.now()}`;
|
|
492
503
|
|
|
493
504
|
// Generate a unique child session key — bypasses the main agent entirely
|
|
@@ -498,11 +509,11 @@ ${recentLines.join("\n")}`;
|
|
|
498
509
|
log(TAG, `dispatching spawn-task${labelStr} → child=${childSessionKey}: "${task.slice(0, 80)}..."`);
|
|
499
510
|
|
|
500
511
|
// ★ Broadcast "spawned" BEFORE the RPC — TSK tab shows ··· immediately
|
|
501
|
-
this.broadcastTaskEvent(taskId, "spawned",
|
|
512
|
+
this.broadcastTaskEvent(taskId, "spawned", safeLabel, startedAt);
|
|
502
513
|
|
|
503
514
|
if (!this.wsClient.isConnected) {
|
|
504
515
|
// No OpenClaw gateway — queue for bare agent HTTP polling
|
|
505
|
-
this.spawnHttpPending = { id: taskId, task, label:
|
|
516
|
+
this.spawnHttpPending = { id: taskId, task, label: safeLabel || "background-task", ts: startedAt };
|
|
506
517
|
const preview = task.length > 60 ? task.slice(0, 60) + "…" : task;
|
|
507
518
|
this.deps.feedBuffer.push(`🔧 Task queued for agent: ${preview}`, "normal", "system", "stream");
|
|
508
519
|
this.deps.wsHandler.broadcast(`🔧 Task queued for agent: ${preview}`, "normal");
|
|
@@ -510,6 +521,10 @@ ${recentLines.join("\n")}`;
|
|
|
510
521
|
return;
|
|
511
522
|
}
|
|
512
523
|
|
|
524
|
+
// Dynamic timeout: scale with task length (long transcripts need more time)
|
|
525
|
+
// Base 30s + 1s per 200 chars, min 45s, max 180s
|
|
526
|
+
const timeoutMs = Math.min(180_000, Math.max(45_000, Math.ceil(task.length / 200) * 1000 + 30_000));
|
|
527
|
+
|
|
513
528
|
// ★ Set spawnInFlight BEFORE first await — cleared in finally regardless of outcome.
|
|
514
529
|
// Dedicated lane flag: never touches the escalation queue so regular escalations
|
|
515
530
|
// continue unblocked while this spawn RPC is pending.
|
|
@@ -520,11 +535,11 @@ ${recentLines.join("\n")}`;
|
|
|
520
535
|
message: task,
|
|
521
536
|
sessionKey: childSessionKey,
|
|
522
537
|
lane: "subagent",
|
|
523
|
-
extraSystemPrompt: this.buildChildSystemPrompt(
|
|
538
|
+
extraSystemPrompt: this.buildChildSystemPrompt(context),
|
|
524
539
|
deliver: false,
|
|
525
540
|
idempotencyKey: idemKey,
|
|
526
|
-
label:
|
|
527
|
-
},
|
|
541
|
+
label: safeLabel || undefined,
|
|
542
|
+
}, timeoutMs, { expectFinal: true });
|
|
528
543
|
|
|
529
544
|
log(TAG, `spawn-task RPC response: ${JSON.stringify(result).slice(0, 500)}`);
|
|
530
545
|
this.stats.totalSpawnResponses++;
|
|
@@ -536,15 +551,15 @@ ${recentLines.join("\n")}`;
|
|
|
536
551
|
if (Array.isArray(payloads) && payloads.length > 0) {
|
|
537
552
|
const output = payloads.map((pl: any) => pl.text || "").join("\n").trim();
|
|
538
553
|
if (output) {
|
|
539
|
-
this.pushResponse(`${
|
|
540
|
-
this.broadcastTaskEvent(taskId, "completed",
|
|
554
|
+
this.pushResponse(`${safeLabel || "Background task"}:\n${output}`);
|
|
555
|
+
this.broadcastTaskEvent(taskId, "completed", safeLabel, startedAt, output);
|
|
541
556
|
} else {
|
|
542
557
|
log(TAG, `spawn-task: ${payloads.length} payloads but empty text, trying chat.history`);
|
|
543
558
|
const historyText = await this.fetchChildResult(childSessionKey);
|
|
544
|
-
this.broadcastTaskEvent(taskId, "completed",
|
|
559
|
+
this.broadcastTaskEvent(taskId, "completed", safeLabel, startedAt,
|
|
545
560
|
historyText || "task completed (no output)");
|
|
546
561
|
if (historyText) {
|
|
547
|
-
this.pushResponse(`${
|
|
562
|
+
this.pushResponse(`${safeLabel || "Background task"}:\n${historyText}`);
|
|
548
563
|
}
|
|
549
564
|
}
|
|
550
565
|
} else {
|
|
@@ -552,10 +567,10 @@ ${recentLines.join("\n")}`;
|
|
|
552
567
|
log(TAG, `spawn-task: no payloads, fetching chat.history for child=${childSessionKey}`);
|
|
553
568
|
const historyText = await this.fetchChildResult(childSessionKey);
|
|
554
569
|
if (historyText) {
|
|
555
|
-
this.pushResponse(`${
|
|
556
|
-
this.broadcastTaskEvent(taskId, "completed",
|
|
570
|
+
this.pushResponse(`${safeLabel || "Background task"}:\n${historyText}`);
|
|
571
|
+
this.broadcastTaskEvent(taskId, "completed", safeLabel, startedAt, historyText);
|
|
557
572
|
} else {
|
|
558
|
-
this.broadcastTaskEvent(taskId, "completed",
|
|
573
|
+
this.broadcastTaskEvent(taskId, "completed", safeLabel, startedAt,
|
|
559
574
|
"task completed (no output captured)");
|
|
560
575
|
}
|
|
561
576
|
}
|
|
@@ -564,7 +579,7 @@ ${recentLines.join("\n")}`;
|
|
|
564
579
|
this.pendingSpawnTasks.set(taskId, {
|
|
565
580
|
runId,
|
|
566
581
|
childSessionKey,
|
|
567
|
-
label,
|
|
582
|
+
label: safeLabel,
|
|
568
583
|
startedAt,
|
|
569
584
|
pollingEmitted: false,
|
|
570
585
|
});
|
|
@@ -575,30 +590,43 @@ ${recentLines.join("\n")}`;
|
|
|
575
590
|
savePendingTasks(this.pendingSpawnTasks);
|
|
576
591
|
} catch (err: any) {
|
|
577
592
|
error(TAG, `spawn-task failed: ${err.message}`);
|
|
578
|
-
this.broadcastTaskEvent(taskId, "failed",
|
|
593
|
+
this.broadcastTaskEvent(taskId, "failed", safeLabel, startedAt);
|
|
579
594
|
} finally {
|
|
580
595
|
this.spawnInFlight = false;
|
|
581
596
|
}
|
|
582
597
|
}
|
|
583
598
|
|
|
584
|
-
/** Build a
|
|
585
|
-
private buildChildSystemPrompt(
|
|
586
|
-
|
|
587
|
-
"#
|
|
588
|
-
"",
|
|
589
|
-
"You are a **subagent** spawned for a specific task.",
|
|
599
|
+
/** Build a context-rich system prompt for the child subagent. */
|
|
600
|
+
private buildChildSystemPrompt(context?: SpawnContext): string {
|
|
601
|
+
const parts = [
|
|
602
|
+
"# Background Agent",
|
|
590
603
|
"",
|
|
591
|
-
"
|
|
592
|
-
|
|
593
|
-
"-
|
|
604
|
+
"You are a background agent spawned by the user to complete a specific task.",
|
|
605
|
+
"You have full tool access: file operations, web search, code execution.",
|
|
606
|
+
"Create end-to-end valuable artifacts — summaries, code files, emails, analysis docs.",
|
|
594
607
|
"",
|
|
595
608
|
"## Rules",
|
|
596
|
-
"1.
|
|
597
|
-
"2.
|
|
598
|
-
"3.
|
|
599
|
-
"",
|
|
600
|
-
|
|
601
|
-
|
|
609
|
+
"1. Complete the task fully — actually do it, don't just describe what you'd do",
|
|
610
|
+
"2. Use your tools: search the web, write files, run code as needed",
|
|
611
|
+
"3. Your final message is shown in a small overlay — keep it concise (1-3 sentences + key links/paths)",
|
|
612
|
+
"4. For substantial output, write to a file and report the path",
|
|
613
|
+
];
|
|
614
|
+
|
|
615
|
+
if (context?.currentApp || context?.digest) {
|
|
616
|
+
parts.push("", "## User Context");
|
|
617
|
+
if (context.currentApp) parts.push(`- Current app: ${context.currentApp}`);
|
|
618
|
+
if (context.digest) parts.push(`- Situation: ${context.digest.slice(0, 500)}`);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (context?.recentScreen) {
|
|
622
|
+
parts.push("", "## Recent Screen (OCR, last ~60s)", context.recentScreen);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (context?.recentAudio) {
|
|
626
|
+
parts.push("", "## Recent Audio (last ~60s)", context.recentAudio);
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
return parts.join("\n");
|
|
602
630
|
}
|
|
603
631
|
|
|
604
632
|
/** Fetch the latest assistant reply from a child session's chat history. */
|
package/sinain-core/src/index.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { TranscriptionService } from "./audio/transcription.js";
|
|
|
10
10
|
import { AgentLoop } from "./agent/loop.js";
|
|
11
11
|
import { TraitEngine, loadTraitRoster } from "./agent/traits.js";
|
|
12
12
|
import { shortAppName } from "./agent/context-window.js";
|
|
13
|
-
import { Escalator } from "./escalation/escalator.js";
|
|
13
|
+
import { Escalator, type SpawnContext } from "./escalation/escalator.js";
|
|
14
14
|
import { Recorder } from "./recorder.js";
|
|
15
15
|
import { Tracer } from "./trace/tracer.js";
|
|
16
16
|
import { TraceStore } from "./trace/trace-store.js";
|
|
@@ -25,6 +25,41 @@ import { initPrivacy, levelFor, applyLevel } from "./privacy/index.js";
|
|
|
25
25
|
|
|
26
26
|
const TAG = "core";
|
|
27
27
|
|
|
28
|
+
/** Build context snapshot for user-initiated spawn tasks. */
|
|
29
|
+
function buildSpawnContext(
|
|
30
|
+
entry: { digest: string; context: { currentApp: string } },
|
|
31
|
+
feedBuffer: FeedBuffer,
|
|
32
|
+
senseBuffer: SenseBuffer,
|
|
33
|
+
): SpawnContext {
|
|
34
|
+
const cutoff = Date.now() - 60_000;
|
|
35
|
+
|
|
36
|
+
// Recent audio: last ~60s of transcripts
|
|
37
|
+
const recentAudio = feedBuffer.queryByTime(cutoff)
|
|
38
|
+
.filter(m => m.channel === "stream" && (m.text.startsWith("[🔊]") || m.text.startsWith("[🎙]")))
|
|
39
|
+
.map(m => m.text)
|
|
40
|
+
.join("\n")
|
|
41
|
+
.slice(0, 2000);
|
|
42
|
+
|
|
43
|
+
// Recent screen: last ~60s of deduped OCR text
|
|
44
|
+
const screenEvents = senseBuffer.queryByTime(cutoff);
|
|
45
|
+
const seenOcr = new Set<string>();
|
|
46
|
+
const screenLines: string[] = [];
|
|
47
|
+
for (const e of screenEvents) {
|
|
48
|
+
if (e.ocr && !seenOcr.has(e.ocr)) {
|
|
49
|
+
seenOcr.add(e.ocr);
|
|
50
|
+
screenLines.push(`[${e.meta.app}] ${e.ocr}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const recentScreen = screenLines.join("\n").slice(0, 3000);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
currentApp: entry.context.currentApp,
|
|
57
|
+
digest: entry.digest,
|
|
58
|
+
recentAudio: recentAudio || undefined,
|
|
59
|
+
recentScreen: recentScreen || undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
28
63
|
/** Resolve workspace path, expanding leading ~ to HOME. */
|
|
29
64
|
function resolveWorkspace(): string {
|
|
30
65
|
const raw = process.env.SINAIN_WORKSPACE || `${process.env.HOME}/.openclaw/workspace`;
|
|
@@ -65,6 +100,9 @@ async function main() {
|
|
|
65
100
|
// ── Initialize recorder ──
|
|
66
101
|
const recorder = new Recorder();
|
|
67
102
|
|
|
103
|
+
// ── Spawn context cache — updated every agent tick for user-initiated spawns ──
|
|
104
|
+
let lastSpawnContext: SpawnContext | null = null;
|
|
105
|
+
|
|
68
106
|
// ── Initialize profiler ──
|
|
69
107
|
const profiler = new Profiler();
|
|
70
108
|
|
|
@@ -108,35 +146,11 @@ async function main() {
|
|
|
108
146
|
getRecorderStatus: () => recorder.getStatus(),
|
|
109
147
|
profiler,
|
|
110
148
|
onAnalysis: (entry, contextWindow) => {
|
|
111
|
-
// Handle recorder commands
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
// Dispatch task via subagent spawn
|
|
115
|
-
if (entry.task || stopResult) {
|
|
116
|
-
let task: string;
|
|
117
|
-
let label: string | undefined;
|
|
118
|
-
|
|
119
|
-
if (stopResult && stopResult.segments > 0 && entry.task) {
|
|
120
|
-
// Recording stopped with explicit task instruction
|
|
121
|
-
task = `${entry.task}\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
|
|
122
|
-
label = stopResult.title;
|
|
123
|
-
} else if (stopResult && stopResult.segments > 0) {
|
|
124
|
-
// Recording stopped without explicit task — default to cleanup/summarize
|
|
125
|
-
task = `Clean up and summarize this recording transcript:\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
|
|
126
|
-
label = stopResult.title;
|
|
127
|
-
} else if (entry.task) {
|
|
128
|
-
// Standalone task without recording
|
|
129
|
-
task = entry.task;
|
|
130
|
-
} else {
|
|
131
|
-
task = "";
|
|
132
|
-
}
|
|
149
|
+
// Handle recorder commands (recording start/stop still works)
|
|
150
|
+
recorder.handleCommand(entry.record);
|
|
133
151
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
error(TAG, "spawn task dispatch error:", err);
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
}
|
|
152
|
+
// Cache context for user-initiated spawn commands (Shift+Enter)
|
|
153
|
+
lastSpawnContext = buildSpawnContext(entry, feedBuffer, senseBuffer);
|
|
140
154
|
|
|
141
155
|
// Escalation continues as normal
|
|
142
156
|
escalator.onAgentAnalysis(entry, contextWindow);
|
|
@@ -433,7 +447,7 @@ async function main() {
|
|
|
433
447
|
|
|
434
448
|
// Spawn background agent task (from HUD Shift+Enter or bare agent POST /spawn)
|
|
435
449
|
onSpawnCommand: (text: string) => {
|
|
436
|
-
escalator.dispatchSpawnTask(text,
|
|
450
|
+
escalator.dispatchSpawnTask(text, text.slice(0, 64), lastSpawnContext ?? undefined).catch((err) => {
|
|
437
451
|
log("srv", `spawn via HTTP failed: ${err}`);
|
|
438
452
|
});
|
|
439
453
|
},
|
|
@@ -457,9 +471,11 @@ async function main() {
|
|
|
457
471
|
},
|
|
458
472
|
onUserCommand: (text) => {
|
|
459
473
|
escalator.setUserCommand(text);
|
|
474
|
+
// Trigger agent loop immediately for user commands (bypass debounce + cooldown)
|
|
475
|
+
agentLoop.onNewContext(true);
|
|
460
476
|
},
|
|
461
477
|
onSpawnCommand: (text) => {
|
|
462
|
-
escalator.dispatchSpawnTask(text,
|
|
478
|
+
escalator.dispatchSpawnTask(text, text.slice(0, 64), lastSpawnContext ?? undefined).catch((err) => {
|
|
463
479
|
log("cmd", `spawn command failed: ${err}`);
|
|
464
480
|
wsHandler.broadcast(`\u26a0 Spawn failed: ${String(err).slice(0, 100)}`, "normal");
|
|
465
481
|
});
|
|
@@ -60,6 +60,15 @@ export function setupCommands(deps: CommandDeps): void {
|
|
|
60
60
|
case "spawn_command": {
|
|
61
61
|
const preview = msg.text.length > 60 ? msg.text.slice(0, 60) + "…" : msg.text;
|
|
62
62
|
log(TAG, `spawn command received: "${preview}"`);
|
|
63
|
+
// Echo spawn command to all overlay clients as a feed item (green in UI)
|
|
64
|
+
wsHandler.broadcastRaw({
|
|
65
|
+
type: "feed",
|
|
66
|
+
text: `⚡ ${msg.text}`,
|
|
67
|
+
priority: "normal",
|
|
68
|
+
ts: Date.now(),
|
|
69
|
+
channel: "agent",
|
|
70
|
+
sender: "spawn",
|
|
71
|
+
} as any);
|
|
63
72
|
if (deps.onSpawnCommand) {
|
|
64
73
|
deps.onSpawnCommand(msg.text);
|
|
65
74
|
} else {
|
|
@@ -199,6 +199,9 @@ export class WsHandler {
|
|
|
199
199
|
case "user_command":
|
|
200
200
|
log(TAG, `\u2190 user command: ${msg.text.slice(0, 100)}`);
|
|
201
201
|
break;
|
|
202
|
+
case "spawn_command":
|
|
203
|
+
log(TAG, `\u2190 spawn command: ${msg.text.slice(0, 100)}`);
|
|
204
|
+
break;
|
|
202
205
|
case "profiling":
|
|
203
206
|
if (this.onProfilingCb) this.onProfilingCb(msg);
|
|
204
207
|
return;
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# sinain-agent configuration
|
|
2
|
-
# Copy to .env and customize: cp .env.example .env
|
|
3
|
-
|
|
4
|
-
# ── Agent ──
|
|
5
|
-
SINAIN_AGENT=claude # claude | codex | junie | goose | aider | <custom command>
|
|
6
|
-
# MCP agents (claude, codex, junie, goose) call sinain tools directly
|
|
7
|
-
# Pipe agents (aider, custom) receive escalation text on stdin
|
|
8
|
-
|
|
9
|
-
# ── Core connection ──
|
|
10
|
-
SINAIN_CORE_URL=http://localhost:9500
|
|
11
|
-
|
|
12
|
-
# ── Timing ──
|
|
13
|
-
SINAIN_POLL_INTERVAL=5 # seconds between escalation polls
|
|
14
|
-
SINAIN_HEARTBEAT_INTERVAL=900 # seconds between heartbeat ticks (15 min)
|
|
15
|
-
|
|
16
|
-
# ── Workspace ──
|
|
17
|
-
SINAIN_WORKSPACE=~/.openclaw/workspace # knowledge files, curation scripts, playbook
|
|
18
|
-
|
|
19
|
-
# ── Tool permissions (Claude only) ──
|
|
20
|
-
# Tools auto-approved without prompting (space-separated).
|
|
21
|
-
# Default: auto-derived from MCP config server names (e.g. mcp__sinain).
|
|
22
|
-
# Format: mcp__<server> (all tools) | mcp__<server>__<tool> (specific) | Bash(pattern)
|
|
23
|
-
# SINAIN_ALLOWED_TOOLS=mcp__sinain mcp__github Bash(git *)
|