@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.
@@ -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 { AgentConfig, AgentEntry, ContextWindow, EscalationMode, ContextRichness, RecorderStatus, SenseEvent, FeedbackRecord } from "../types.js";
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: 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
- if (!this.deps.agentConfig.enabled || !this.deps.agentConfig.openrouterApiKey) {
107
- if (this.deps.agentConfig.enabled) {
108
- warn(TAG, "AGENT_ENABLED=true but OPENROUTER_API_KEY not set \u2014 agent disabled");
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 { openrouterApiKey, ...safe } = this.deps.agentConfig;
178
- return { ...safe, hasApiKey: !!openrouterApiKey, escalationMode: this.deps.escalationMode };
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.openrouterApiKey !== undefined) c.openrouterApiKey = String(updates.openrouterApiKey);
223
+ if (updates.apiKey !== undefined) c.apiKey = String(updates.apiKey);
220
224
 
221
225
  // Restart loop if needed
222
- if (c.enabled && c.openrouterApiKey) {
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.openrouterApiKey) return;
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: tokensIn * costPerToken.in + tokensOut * costPerToken.out,
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, AgentConfig, EscalationConfig, OpenClawConfig, EscalationMode, EscalationTransport, LearningConfig, TraitConfig, PrivacyConfig, PrivacyMatrix, PrivacyLevel, PrivacyRow } from "./types.js";
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 project root .env first, then sinain-core/.env fallback
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 agentConfig: AgentConfig = {
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
- model: env("AGENT_MODEL", "google/gemini-2.5-flash-lite"),
184
- visionModel: env("AGENT_VISION_MODEL", "google/gemini-2.5-flash"),
185
- visionEnabled: boolEnv("AGENT_VISION_ENABLED", true),
186
- localVisionEnabled: boolEnv("LOCAL_VISION_ENABLED", false),
187
- localVisionModel: env("LOCAL_VISION_MODEL", "llava"),
188
- localVisionUrl: env("LOCAL_VISION_URL", "http://localhost:11434"),
189
- localVisionTimeout: intEnv("LOCAL_VISION_TIMEOUT", 10000),
190
- openrouterApiKey: env("OPENROUTER_API_KEY", ""),
191
- maxTokens: intEnv("AGENT_MAX_TOKENS", 800),
192
- temperature: floatEnv("AGENT_TEMPERATURE", 0.3),
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, context?: SpawnContext): Promise<void> {
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 = safeLabel ? ` (label: "${safeLabel}")` : "";
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", safeLabel, startedAt);
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: safeLabel || "background-task", ts: startedAt };
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(context),
523
+ extraSystemPrompt: this.buildChildSystemPrompt(task, label),
539
524
  deliver: false,
540
525
  idempotencyKey: idemKey,
541
- label: safeLabel || undefined,
542
- }, timeoutMs, { expectFinal: true });
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(`${safeLabel || "Background task"}:\n${output}`);
555
- this.broadcastTaskEvent(taskId, "completed", safeLabel, startedAt, output);
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", safeLabel, startedAt,
544
+ this.broadcastTaskEvent(taskId, "completed", label, startedAt,
560
545
  historyText || "task completed (no output)");
561
546
  if (historyText) {
562
- this.pushResponse(`${safeLabel || "Background task"}:\n${historyText}`);
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(`${safeLabel || "Background task"}:\n${historyText}`);
571
- this.broadcastTaskEvent(taskId, "completed", safeLabel, startedAt, historyText);
555
+ this.pushResponse(`${label || "Background task"}:\n${historyText}`);
556
+ this.broadcastTaskEvent(taskId, "completed", label, startedAt, historyText);
572
557
  } else {
573
- this.broadcastTaskEvent(taskId, "completed", safeLabel, startedAt,
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: safeLabel,
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", safeLabel, startedAt);
578
+ this.broadcastTaskEvent(taskId, "failed", label, startedAt);
594
579
  } finally {
595
580
  this.spawnInFlight = false;
596
581
  }
597
582
  }
598
583
 
599
- /** Build a context-rich system prompt for the child subagent. */
600
- private buildChildSystemPrompt(context?: SpawnContext): string {
601
- const parts = [
602
- "# Background Agent",
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
- "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.",
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. 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");
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. */