@geravant/sinain 1.8.0 → 1.10.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 +14 -13
- package/HEARTBEAT.md +1 -1
- package/README.md +4 -7
- package/cli.js +16 -2
- package/config-shared.js +469 -0
- package/config.js +152 -0
- package/index.ts +1 -3
- package/launcher.js +7 -1
- package/onboard.js +345 -0
- package/package.json +8 -2
- package/sense_client/__main__.py +8 -4
- package/sense_client/gate.py +1 -0
- package/sense_client/ocr.py +58 -25
- package/sense_client/sender.py +2 -0
- package/sense_client/vision.py +31 -11
- package/sinain-agent/CLAUDE.md +0 -1
- package/sinain-agent/run.sh +2 -1
- package/sinain-core/src/agent/analyzer.ts +56 -58
- package/sinain-core/src/agent/loop.ts +37 -11
- package/sinain-core/src/audio/transcription.ts +20 -5
- package/sinain-core/src/config.ts +20 -16
- package/sinain-core/src/cost/tracker.ts +64 -0
- package/sinain-core/src/escalation/escalator.ts +31 -59
- package/sinain-core/src/index.ts +41 -45
- package/sinain-core/src/overlay/commands.ts +12 -0
- package/sinain-core/src/overlay/ws-handler.ts +27 -0
- package/sinain-core/src/server.ts +41 -0
- package/sinain-core/src/types.ts +46 -11
- package/sinain-knowledge/curation/engine.ts +0 -17
- package/sinain-knowledge/protocol/heartbeat.md +1 -1
- package/sinain-mcp-server/index.ts +4 -20
- package/sinain-memory/git_backup.sh +0 -19
|
@@ -2,8 +2,9 @@ import { EventEmitter } from "node:events";
|
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import type { FeedBuffer } from "../buffers/feed-buffer.js";
|
|
4
4
|
import type { SenseBuffer } from "../buffers/sense-buffer.js";
|
|
5
|
-
import type {
|
|
5
|
+
import type { AnalysisConfig, AgentEntry, ContextWindow, EscalationMode, ContextRichness, RecorderStatus, SenseEvent, FeedbackRecord } from "../types.js";
|
|
6
6
|
import type { Profiler } from "../profiler.js";
|
|
7
|
+
import type { CostTracker } from "../cost/tracker.js";
|
|
7
8
|
import { buildContextWindow, RICHNESS_PRESETS } from "./context-window.js";
|
|
8
9
|
import { analyzeContext } from "./analyzer.js";
|
|
9
10
|
import { writeSituationMd } from "./situation-writer.js";
|
|
@@ -17,7 +18,7 @@ const TAG = "agent";
|
|
|
17
18
|
export interface AgentLoopDeps {
|
|
18
19
|
feedBuffer: FeedBuffer;
|
|
19
20
|
senseBuffer: SenseBuffer;
|
|
20
|
-
agentConfig:
|
|
21
|
+
agentConfig: AnalysisConfig;
|
|
21
22
|
escalationMode: EscalationMode;
|
|
22
23
|
situationMdPath: string;
|
|
23
24
|
/** Called after analysis with digest + context for escalation check. */
|
|
@@ -40,6 +41,8 @@ export interface AgentLoopDeps {
|
|
|
40
41
|
getKnowledgeDocPath?: () => string | null;
|
|
41
42
|
/** Optional: feedback store for startup recap context. */
|
|
42
43
|
feedbackStore?: { queryRecent(n: number): FeedbackRecord[] };
|
|
44
|
+
/** Optional: cost tracker for LLM cost accumulation. */
|
|
45
|
+
costTracker?: CostTracker;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export interface TraceContext {
|
|
@@ -103,9 +106,10 @@ export class AgentLoop extends EventEmitter {
|
|
|
103
106
|
/** Start the agent loop. */
|
|
104
107
|
start(): void {
|
|
105
108
|
if (this.started) return;
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
const ac = this.deps.agentConfig;
|
|
110
|
+
if (!ac.enabled || (ac.provider !== "ollama" && !ac.apiKey)) {
|
|
111
|
+
if (ac.enabled) {
|
|
112
|
+
warn(TAG, "AGENT_ENABLED=true but no API key and provider is not ollama \u2014 analysis disabled");
|
|
109
113
|
}
|
|
110
114
|
return;
|
|
111
115
|
}
|
|
@@ -174,8 +178,8 @@ export class AgentLoop extends EventEmitter {
|
|
|
174
178
|
|
|
175
179
|
/** Get config (safe — no API key). */
|
|
176
180
|
getConfig(): Record<string, unknown> {
|
|
177
|
-
const {
|
|
178
|
-
return { ...safe, hasApiKey: !!
|
|
181
|
+
const { apiKey, ...safe } = this.deps.agentConfig;
|
|
182
|
+
return { ...safe, hasApiKey: !!apiKey, escalationMode: this.deps.escalationMode };
|
|
179
183
|
}
|
|
180
184
|
|
|
181
185
|
/** Get stats for /health. */
|
|
@@ -216,10 +220,10 @@ export class AgentLoop extends EventEmitter {
|
|
|
216
220
|
if (updates.maxIntervalMs !== undefined) c.maxIntervalMs = Math.max(5000, parseInt(String(updates.maxIntervalMs)));
|
|
217
221
|
if (updates.cooldownMs !== undefined) c.cooldownMs = Math.max(3000, parseInt(String(updates.cooldownMs)));
|
|
218
222
|
if (updates.fallbackModels !== undefined) c.fallbackModels = Array.isArray(updates.fallbackModels) ? updates.fallbackModels : [];
|
|
219
|
-
if (updates.
|
|
223
|
+
if (updates.apiKey !== undefined) c.apiKey = String(updates.apiKey);
|
|
220
224
|
|
|
221
225
|
// Restart loop if needed
|
|
222
|
-
if (c.enabled && c.
|
|
226
|
+
if (c.enabled && (c.provider === "ollama" || c.apiKey)) {
|
|
223
227
|
if (!this.started) this.start();
|
|
224
228
|
else {
|
|
225
229
|
// Reset max interval timer with new config
|
|
@@ -235,7 +239,7 @@ export class AgentLoop extends EventEmitter {
|
|
|
235
239
|
|
|
236
240
|
private async run(): Promise<void> {
|
|
237
241
|
if (this.running) return;
|
|
238
|
-
if (!this.deps.agentConfig.
|
|
242
|
+
if (this.deps.agentConfig.provider !== "ollama" && !this.deps.agentConfig.apiKey) return;
|
|
239
243
|
|
|
240
244
|
// Cooldown: don't re-analyze within cooldownMs of last run (unless urgent)
|
|
241
245
|
const isUrgent = this.urgentPending;
|
|
@@ -317,6 +321,17 @@ export class AgentLoop extends EventEmitter {
|
|
|
317
321
|
this.deps.profiler?.gauge("agent.parseSuccesses", this.stats.parseSuccesses);
|
|
318
322
|
this.deps.profiler?.gauge("agent.parseFailures", this.stats.parseFailures);
|
|
319
323
|
|
|
324
|
+
if (typeof result.cost === "number" && result.cost > 0) {
|
|
325
|
+
this.deps.costTracker?.record({
|
|
326
|
+
source: "analyzer",
|
|
327
|
+
model: usedModel,
|
|
328
|
+
cost: result.cost,
|
|
329
|
+
tokensIn,
|
|
330
|
+
tokensOut,
|
|
331
|
+
ts: Date.now(),
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
320
335
|
// Build entry
|
|
321
336
|
const entry: AgentEntry = {
|
|
322
337
|
...result,
|
|
@@ -375,12 +390,13 @@ export class AgentLoop extends EventEmitter {
|
|
|
375
390
|
|
|
376
391
|
// Finish trace
|
|
377
392
|
const costPerToken = { in: 0.075 / 1_000_000, out: 0.3 / 1_000_000 };
|
|
393
|
+
const estimatedCost = tokensIn * costPerToken.in + tokensOut * costPerToken.out;
|
|
378
394
|
traceCtx?.finish({
|
|
379
395
|
totalLatencyMs: Date.now() - entry.ts + latencyMs,
|
|
380
396
|
llmLatencyMs: latencyMs,
|
|
381
397
|
llmInputTokens: tokensIn,
|
|
382
398
|
llmOutputTokens: tokensOut,
|
|
383
|
-
llmCost:
|
|
399
|
+
llmCost: result.cost ?? estimatedCost,
|
|
384
400
|
escalated: false, // Updated by escalator
|
|
385
401
|
escalationScore: 0,
|
|
386
402
|
contextScreenEvents: contextWindow.screenCount,
|
|
@@ -477,6 +493,16 @@ export class AgentLoop extends EventEmitter {
|
|
|
477
493
|
};
|
|
478
494
|
|
|
479
495
|
const result = await analyzeContext(recapWindow, this.deps.agentConfig, null);
|
|
496
|
+
if (typeof result.cost === "number" && result.cost > 0) {
|
|
497
|
+
this.deps.costTracker?.record({
|
|
498
|
+
source: "analyzer",
|
|
499
|
+
model: result.model,
|
|
500
|
+
cost: result.cost,
|
|
501
|
+
tokensIn: result.tokensIn,
|
|
502
|
+
tokensOut: result.tokensOut,
|
|
503
|
+
ts: Date.now(),
|
|
504
|
+
});
|
|
505
|
+
}
|
|
480
506
|
if (result?.hud && result.hud !== "—" && result.hud !== "Idle") {
|
|
481
507
|
this.deps.onHudUpdate(result.hud);
|
|
482
508
|
log(TAG, `recap tick (${Date.now() - startTs}ms, ${result.tokensIn}in+${result.tokensOut}out tok) hud="${result.hud}"`);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { EventEmitter } from "node:events";
|
|
2
2
|
import type { TranscriptionConfig, AudioChunk, TranscriptResult } from "../types.js";
|
|
3
3
|
import type { Profiler } from "../profiler.js";
|
|
4
|
+
import type { CostTracker } from "../cost/tracker.js";
|
|
4
5
|
import { LocalTranscriptionBackend } from "./transcription-local.js";
|
|
5
6
|
import { log, warn, error, debug } from "../log.js";
|
|
6
7
|
|
|
@@ -41,7 +42,10 @@ export class TranscriptionService extends EventEmitter {
|
|
|
41
42
|
private dropCount: number = 0;
|
|
42
43
|
private totalCalls: number = 0;
|
|
43
44
|
|
|
45
|
+
private costTracker: CostTracker | null = null;
|
|
46
|
+
|
|
44
47
|
setProfiler(p: Profiler): void { this.profiler = p; }
|
|
48
|
+
setCostTracker(ct: CostTracker): void { this.costTracker = ct; }
|
|
45
49
|
|
|
46
50
|
constructor(config: TranscriptionConfig) {
|
|
47
51
|
super();
|
|
@@ -219,7 +223,7 @@ export class TranscriptionService extends EventEmitter {
|
|
|
219
223
|
|
|
220
224
|
const data = await response.json() as {
|
|
221
225
|
choices?: Array<{ message?: { content?: string } }>;
|
|
222
|
-
usage?: { prompt_tokens?: number; completion_tokens?: number };
|
|
226
|
+
usage?: { prompt_tokens?: number; completion_tokens?: number; cost?: number };
|
|
223
227
|
};
|
|
224
228
|
|
|
225
229
|
const text = data.choices?.[0]?.message?.content?.trim();
|
|
@@ -231,6 +235,21 @@ export class TranscriptionService extends EventEmitter {
|
|
|
231
235
|
this.profiler?.timerRecord("transcription.call", elapsed);
|
|
232
236
|
this.totalAudioDurationMs += chunk.durationMs;
|
|
233
237
|
|
|
238
|
+
// Track tokens and cost before any early returns — the API call is already billed
|
|
239
|
+
if (data.usage) {
|
|
240
|
+
this.totalTokensConsumed += (data.usage.prompt_tokens || 0) + (data.usage.completion_tokens || 0);
|
|
241
|
+
}
|
|
242
|
+
if (typeof data.usage?.cost === "number" && data.usage.cost > 0) {
|
|
243
|
+
this.costTracker?.record({
|
|
244
|
+
source: "transcription",
|
|
245
|
+
model: this.config.geminiModel,
|
|
246
|
+
cost: data.usage.cost,
|
|
247
|
+
tokensIn: data.usage?.prompt_tokens || 0,
|
|
248
|
+
tokensOut: data.usage?.completion_tokens || 0,
|
|
249
|
+
ts: Date.now(),
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
234
253
|
if (!text) {
|
|
235
254
|
warn(TAG, `OpenRouter returned empty transcript (${elapsed}ms)`);
|
|
236
255
|
return;
|
|
@@ -248,10 +267,6 @@ export class TranscriptionService extends EventEmitter {
|
|
|
248
267
|
|
|
249
268
|
log(TAG, `transcript (${elapsed}ms): "${text.slice(0, 100)}${text.length > 100 ? "..." : ""}"`);
|
|
250
269
|
|
|
251
|
-
if (data.usage) {
|
|
252
|
-
this.totalTokensConsumed += (data.usage.prompt_tokens || 0) + (data.usage.completion_tokens || 0);
|
|
253
|
-
}
|
|
254
|
-
|
|
255
270
|
const result: TranscriptResult = {
|
|
256
271
|
text,
|
|
257
272
|
source: "openrouter",
|
|
@@ -2,7 +2,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
2
2
|
import { resolve, dirname } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
import os from "node:os";
|
|
5
|
-
import type { CoreConfig, AudioPipelineConfig, TranscriptionConfig,
|
|
5
|
+
import type { CoreConfig, AudioPipelineConfig, TranscriptionConfig, AnalysisConfig, EscalationConfig, OpenClawConfig, EscalationMode, EscalationTransport, LearningConfig, TraitConfig, PrivacyConfig, PrivacyMatrix, PrivacyLevel, PrivacyRow } from "./types.js";
|
|
6
6
|
import { PRESETS } from "./privacy/presets.js";
|
|
7
7
|
|
|
8
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
@@ -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 sinain-core/.env first, then project root .env
|
|
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;
|
|
@@ -178,25 +178,28 @@ export function loadConfig(): CoreConfig {
|
|
|
178
178
|
},
|
|
179
179
|
};
|
|
180
180
|
|
|
181
|
-
const
|
|
181
|
+
const analysisProvider = env("ANALYSIS_PROVIDER", "openrouter") as import("./types.js").AnalysisProvider;
|
|
182
|
+
const defaultEndpoint = analysisProvider === "ollama"
|
|
183
|
+
? "http://localhost:11434"
|
|
184
|
+
: "https://openrouter.ai/api/v1/chat/completions";
|
|
185
|
+
|
|
186
|
+
const agentConfig: import("./types.js").AnalysisConfig = {
|
|
182
187
|
enabled: boolEnv("AGENT_ENABLED", true),
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
188
|
+
provider: analysisProvider,
|
|
189
|
+
model: env("ANALYSIS_MODEL", "google/gemini-2.5-flash-lite"),
|
|
190
|
+
visionModel: env("ANALYSIS_VISION_MODEL", "google/gemini-2.5-flash"),
|
|
191
|
+
endpoint: env("ANALYSIS_ENDPOINT", defaultEndpoint),
|
|
192
|
+
apiKey: env("ANALYSIS_API_KEY", env("OPENROUTER_API_KEY", "")),
|
|
193
|
+
maxTokens: intEnv("ANALYSIS_MAX_TOKENS", 800),
|
|
194
|
+
temperature: floatEnv("ANALYSIS_TEMPERATURE", 0.3),
|
|
195
|
+
fallbackModels: env("ANALYSIS_FALLBACK_MODELS", "google/gemini-2.5-flash,anthropic/claude-3.5-haiku")
|
|
196
|
+
.split(",").map(s => s.trim()).filter(Boolean),
|
|
197
|
+
timeout: intEnv("ANALYSIS_TIMEOUT", 15000),
|
|
193
198
|
pushToFeed: boolEnv("AGENT_PUSH_TO_FEED", true),
|
|
194
199
|
debounceMs: intEnv("AGENT_DEBOUNCE_MS", 3000),
|
|
195
200
|
maxIntervalMs: intEnv("AGENT_MAX_INTERVAL_MS", 30000),
|
|
196
201
|
cooldownMs: intEnv("AGENT_COOLDOWN_MS", 10000),
|
|
197
202
|
maxAgeMs: intEnv("AGENT_MAX_AGE_MS", 120000),
|
|
198
|
-
fallbackModels: env("AGENT_FALLBACK_MODELS", "google/gemini-2.5-flash,anthropic/claude-3.5-haiku")
|
|
199
|
-
.split(",").map(s => s.trim()).filter(Boolean),
|
|
200
203
|
historyLimit: intEnv("AGENT_HISTORY_LIMIT", 50),
|
|
201
204
|
};
|
|
202
205
|
|
|
@@ -252,6 +255,7 @@ export function loadConfig(): CoreConfig {
|
|
|
252
255
|
situationMdPath,
|
|
253
256
|
traceEnabled: boolEnv("TRACE_ENABLED", true),
|
|
254
257
|
traceDir: resolvePath(env("TRACE_DIR", resolve(sinainDataDir(), "traces"))),
|
|
258
|
+
costDisplayEnabled: boolEnv("COST_DISPLAY_ENABLED", false),
|
|
255
259
|
learningConfig,
|
|
256
260
|
traitConfig,
|
|
257
261
|
privacyConfig,
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { CostEntry, CostSnapshot } from "../types.js";
|
|
2
|
+
import { log } from "../log.js";
|
|
3
|
+
|
|
4
|
+
const TAG = "cost";
|
|
5
|
+
|
|
6
|
+
export class CostTracker {
|
|
7
|
+
private totalCost = 0;
|
|
8
|
+
private costBySource = new Map<string, number>();
|
|
9
|
+
private costByModel = new Map<string, number>();
|
|
10
|
+
private callCount = 0;
|
|
11
|
+
private startedAt = Date.now();
|
|
12
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
13
|
+
private onCostUpdate: (snapshot: CostSnapshot) => void;
|
|
14
|
+
|
|
15
|
+
constructor(onCostUpdate: (snapshot: CostSnapshot) => void) {
|
|
16
|
+
this.onCostUpdate = onCostUpdate;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
record(entry: CostEntry): void {
|
|
20
|
+
if (entry.cost <= 0) return;
|
|
21
|
+
this.totalCost += entry.cost;
|
|
22
|
+
this.callCount++;
|
|
23
|
+
this.costBySource.set(
|
|
24
|
+
entry.source,
|
|
25
|
+
(this.costBySource.get(entry.source) || 0) + entry.cost,
|
|
26
|
+
);
|
|
27
|
+
this.costByModel.set(
|
|
28
|
+
entry.model,
|
|
29
|
+
(this.costByModel.get(entry.model) || 0) + entry.cost,
|
|
30
|
+
);
|
|
31
|
+
this.onCostUpdate(this.getSnapshot());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getSnapshot(): CostSnapshot {
|
|
35
|
+
return {
|
|
36
|
+
totalCost: this.totalCost,
|
|
37
|
+
costBySource: Object.fromEntries(this.costBySource),
|
|
38
|
+
costByModel: Object.fromEntries(this.costByModel),
|
|
39
|
+
callCount: this.callCount,
|
|
40
|
+
startedAt: this.startedAt,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
startPeriodicLog(intervalMs: number): void {
|
|
45
|
+
this.timer = setInterval(() => {
|
|
46
|
+
if (this.callCount === 0) return;
|
|
47
|
+
const elapsed = ((Date.now() - this.startedAt) / 60_000).toFixed(1);
|
|
48
|
+
const sources = [...this.costBySource.entries()]
|
|
49
|
+
.map(([k, v]) => `${k}=$${v.toFixed(6)}`)
|
|
50
|
+
.join(" ");
|
|
51
|
+
const models = [...this.costByModel.entries()]
|
|
52
|
+
.map(([k, v]) => `${k}=$${v.toFixed(6)}`)
|
|
53
|
+
.join(" ");
|
|
54
|
+
log(TAG, `$${this.totalCost.toFixed(6)} total (${this.callCount} calls, ${elapsed} min) | ${sources} | ${models}`);
|
|
55
|
+
}, intervalMs);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
stop(): void {
|
|
59
|
+
if (this.timer) {
|
|
60
|
+
clearInterval(this.timer);
|
|
61
|
+
this.timer = null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -13,14 +13,6 @@ 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
|
-
|
|
24
16
|
export interface HttpPendingEscalation {
|
|
25
17
|
id: string;
|
|
26
18
|
message: string;
|
|
@@ -473,7 +465,7 @@ ${recentLines.join("\n")}`;
|
|
|
473
465
|
* Creates a unique child session key and sends the task directly to the gateway
|
|
474
466
|
* agent RPC — bypassing the main session to avoid dedup/NO_REPLY issues.
|
|
475
467
|
*/
|
|
476
|
-
async dispatchSpawnTask(task: string, label?: string
|
|
468
|
+
async dispatchSpawnTask(task: string, label?: string): Promise<void> {
|
|
477
469
|
// Prevent sibling spawn RPCs from piling up (independent from escalation queue)
|
|
478
470
|
if (this.spawnInFlight) {
|
|
479
471
|
log(TAG, `spawn-task skipped — spawn RPC already in-flight`);
|
|
@@ -493,12 +485,9 @@ ${recentLines.join("\n")}`;
|
|
|
493
485
|
this.lastSpawnFingerprint = fingerprint;
|
|
494
486
|
this.lastSpawnTs = now;
|
|
495
487
|
|
|
496
|
-
// Truncate label to gateway's 64-char limit
|
|
497
|
-
const safeLabel = label?.slice(0, 64);
|
|
498
|
-
|
|
499
488
|
const taskId = `spawn-${Date.now()}`;
|
|
500
489
|
const startedAt = Date.now();
|
|
501
|
-
const labelStr =
|
|
490
|
+
const labelStr = label ? ` (label: "${label}")` : "";
|
|
502
491
|
const idemKey = `spawn-task-${Date.now()}`;
|
|
503
492
|
|
|
504
493
|
// Generate a unique child session key — bypasses the main agent entirely
|
|
@@ -509,11 +498,11 @@ ${recentLines.join("\n")}`;
|
|
|
509
498
|
log(TAG, `dispatching spawn-task${labelStr} → child=${childSessionKey}: "${task.slice(0, 80)}..."`);
|
|
510
499
|
|
|
511
500
|
// ★ Broadcast "spawned" BEFORE the RPC — TSK tab shows ··· immediately
|
|
512
|
-
this.broadcastTaskEvent(taskId, "spawned",
|
|
501
|
+
this.broadcastTaskEvent(taskId, "spawned", label, startedAt);
|
|
513
502
|
|
|
514
503
|
if (!this.wsClient.isConnected) {
|
|
515
504
|
// No OpenClaw gateway — queue for bare agent HTTP polling
|
|
516
|
-
this.spawnHttpPending = { id: taskId, task, label:
|
|
505
|
+
this.spawnHttpPending = { id: taskId, task, label: label || "background-task", ts: startedAt };
|
|
517
506
|
const preview = task.length > 60 ? task.slice(0, 60) + "…" : task;
|
|
518
507
|
this.deps.feedBuffer.push(`🔧 Task queued for agent: ${preview}`, "normal", "system", "stream");
|
|
519
508
|
this.deps.wsHandler.broadcast(`🔧 Task queued for agent: ${preview}`, "normal");
|
|
@@ -521,10 +510,6 @@ ${recentLines.join("\n")}`;
|
|
|
521
510
|
return;
|
|
522
511
|
}
|
|
523
512
|
|
|
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
|
-
|
|
528
513
|
// ★ Set spawnInFlight BEFORE first await — cleared in finally regardless of outcome.
|
|
529
514
|
// Dedicated lane flag: never touches the escalation queue so regular escalations
|
|
530
515
|
// continue unblocked while this spawn RPC is pending.
|
|
@@ -535,11 +520,11 @@ ${recentLines.join("\n")}`;
|
|
|
535
520
|
message: task,
|
|
536
521
|
sessionKey: childSessionKey,
|
|
537
522
|
lane: "subagent",
|
|
538
|
-
extraSystemPrompt: this.buildChildSystemPrompt(
|
|
523
|
+
extraSystemPrompt: this.buildChildSystemPrompt(task, label),
|
|
539
524
|
deliver: false,
|
|
540
525
|
idempotencyKey: idemKey,
|
|
541
|
-
label:
|
|
542
|
-
},
|
|
526
|
+
label: label || undefined,
|
|
527
|
+
}, 45_000, { expectFinal: true });
|
|
543
528
|
|
|
544
529
|
log(TAG, `spawn-task RPC response: ${JSON.stringify(result).slice(0, 500)}`);
|
|
545
530
|
this.stats.totalSpawnResponses++;
|
|
@@ -551,15 +536,15 @@ ${recentLines.join("\n")}`;
|
|
|
551
536
|
if (Array.isArray(payloads) && payloads.length > 0) {
|
|
552
537
|
const output = payloads.map((pl: any) => pl.text || "").join("\n").trim();
|
|
553
538
|
if (output) {
|
|
554
|
-
this.pushResponse(`${
|
|
555
|
-
this.broadcastTaskEvent(taskId, "completed",
|
|
539
|
+
this.pushResponse(`${label || "Background task"}:\n${output}`);
|
|
540
|
+
this.broadcastTaskEvent(taskId, "completed", label, startedAt, output);
|
|
556
541
|
} else {
|
|
557
542
|
log(TAG, `spawn-task: ${payloads.length} payloads but empty text, trying chat.history`);
|
|
558
543
|
const historyText = await this.fetchChildResult(childSessionKey);
|
|
559
|
-
this.broadcastTaskEvent(taskId, "completed",
|
|
544
|
+
this.broadcastTaskEvent(taskId, "completed", label, startedAt,
|
|
560
545
|
historyText || "task completed (no output)");
|
|
561
546
|
if (historyText) {
|
|
562
|
-
this.pushResponse(`${
|
|
547
|
+
this.pushResponse(`${label || "Background task"}:\n${historyText}`);
|
|
563
548
|
}
|
|
564
549
|
}
|
|
565
550
|
} else {
|
|
@@ -567,10 +552,10 @@ ${recentLines.join("\n")}`;
|
|
|
567
552
|
log(TAG, `spawn-task: no payloads, fetching chat.history for child=${childSessionKey}`);
|
|
568
553
|
const historyText = await this.fetchChildResult(childSessionKey);
|
|
569
554
|
if (historyText) {
|
|
570
|
-
this.pushResponse(`${
|
|
571
|
-
this.broadcastTaskEvent(taskId, "completed",
|
|
555
|
+
this.pushResponse(`${label || "Background task"}:\n${historyText}`);
|
|
556
|
+
this.broadcastTaskEvent(taskId, "completed", label, startedAt, historyText);
|
|
572
557
|
} else {
|
|
573
|
-
this.broadcastTaskEvent(taskId, "completed",
|
|
558
|
+
this.broadcastTaskEvent(taskId, "completed", label, startedAt,
|
|
574
559
|
"task completed (no output captured)");
|
|
575
560
|
}
|
|
576
561
|
}
|
|
@@ -579,7 +564,7 @@ ${recentLines.join("\n")}`;
|
|
|
579
564
|
this.pendingSpawnTasks.set(taskId, {
|
|
580
565
|
runId,
|
|
581
566
|
childSessionKey,
|
|
582
|
-
label
|
|
567
|
+
label,
|
|
583
568
|
startedAt,
|
|
584
569
|
pollingEmitted: false,
|
|
585
570
|
});
|
|
@@ -590,43 +575,30 @@ ${recentLines.join("\n")}`;
|
|
|
590
575
|
savePendingTasks(this.pendingSpawnTasks);
|
|
591
576
|
} catch (err: any) {
|
|
592
577
|
error(TAG, `spawn-task failed: ${err.message}`);
|
|
593
|
-
this.broadcastTaskEvent(taskId, "failed",
|
|
578
|
+
this.broadcastTaskEvent(taskId, "failed", label, startedAt);
|
|
594
579
|
} finally {
|
|
595
580
|
this.spawnInFlight = false;
|
|
596
581
|
}
|
|
597
582
|
}
|
|
598
583
|
|
|
599
|
-
/** Build a
|
|
600
|
-
private buildChildSystemPrompt(
|
|
601
|
-
|
|
602
|
-
"#
|
|
584
|
+
/** Build a focused system prompt for the child subagent. */
|
|
585
|
+
private buildChildSystemPrompt(task: string, label?: string): string {
|
|
586
|
+
return [
|
|
587
|
+
"# Subagent Context",
|
|
588
|
+
"",
|
|
589
|
+
"You are a **subagent** spawned for a specific task.",
|
|
603
590
|
"",
|
|
604
|
-
"
|
|
605
|
-
|
|
606
|
-
"
|
|
591
|
+
"## Your Role",
|
|
592
|
+
`- Task: ${task.replace(/\s+/g, " ").trim().slice(0, 500)}`,
|
|
593
|
+
"- Complete this task. That's your entire purpose.",
|
|
607
594
|
"",
|
|
608
595
|
"## Rules",
|
|
609
|
-
"1.
|
|
610
|
-
"2.
|
|
611
|
-
"3.
|
|
612
|
-
"
|
|
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");
|
|
596
|
+
"1. Stay focused — do your assigned task, nothing else",
|
|
597
|
+
"2. Your final message will be reported to the requester",
|
|
598
|
+
"3. Be concise but informative",
|
|
599
|
+
"",
|
|
600
|
+
label ? `Label: ${label}` : "",
|
|
601
|
+
].filter(Boolean).join("\n");
|
|
630
602
|
}
|
|
631
603
|
|
|
632
604
|
/** Fetch the latest assistant reply from a child session's chat history. */
|