@geravant/sinain 1.10.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.10.1",
3
+ "version": "1.11.0",
4
4
  "description": "Ambient intelligence that sees what you see, hears what you hear, and acts on your behalf",
5
5
  "type": "module",
6
6
  "bin": {
@@ -103,7 +103,7 @@ Knowledge API (localhost:9500) ← merges both DBs ← queries
103
103
 
104
104
  ### Using Knowledge in Escalation Responses
105
105
 
106
- When responding to escalations, call `sinain_knowledge_query` with relevant entities to enrich your response with long-term knowledge. Example: if the user is working on German grammar, query `sinain_knowledge_query({ entities: ["german", "grammar"] })` to retrieve previously learned patterns.
106
+ When responding to escalations, call `sinain_knowledge_query` with relevant entities to enrich your response with long-term knowledge. Example: if the user is working on German grammar, query `sinain_knowledge_query(entities=["german", "grammar"])` to retrieve previously learned patterns.
107
107
 
108
108
  ## Spawning Background Tasks
109
109
 
@@ -69,11 +69,22 @@ invoke_agent() {
69
69
  case "$AGENT" in
70
70
  claude)
71
71
  local turns="${2:-$AGENT_MAX_TURNS}"
72
- claude --enable-auto-mode \
73
- --mcp-config "$MCP_CONFIG" \
74
- ${ALLOWED_TOOLS:+--allowedTools $ALLOWED_TOOLS} \
75
- --max-turns "$turns" --output-format text \
76
- -p "$prompt"
72
+ if [ -n "${SINAIN_SPAWN:-}" ]; then
73
+ # Spawn: PreToolUse hook routes permission prompts to overlay HUD
74
+ claude \
75
+ --mcp-config "$MCP_CONFIG" \
76
+ --settings "$SCRIPT_DIR/.claude/settings.json" \
77
+ ${ALLOWED_TOOLS:+--allowedTools $ALLOWED_TOOLS} \
78
+ --max-turns "$turns" --output-format text \
79
+ -p "$prompt"
80
+ else
81
+ # Escalation: auto-approve for speed (short-lived, read-heavy)
82
+ claude --enable-auto-mode \
83
+ --mcp-config "$MCP_CONFIG" \
84
+ ${ALLOWED_TOOLS:+--allowedTools $ALLOWED_TOOLS} \
85
+ --max-turns "$turns" --output-format text \
86
+ -p "$prompt"
87
+ fi
77
88
  ;;
78
89
  codex)
79
90
  codex exec -s danger-full-access \
@@ -97,6 +108,8 @@ invoke_agent() {
97
108
  local turns="${2:-$AGENT_MAX_TURNS}"
98
109
  GOOSE_MODE=auto goose run --text "$prompt" \
99
110
  --output-format text \
111
+ --quiet \
112
+ --no-session \
100
113
  --max-turns "$turns"
101
114
  ;;
102
115
  aider)
@@ -186,6 +199,34 @@ if [ "$AGENT" = "codex" ]; then
186
199
  fi
187
200
  fi
188
201
 
202
+ # Goose: auto-register sinain MCP server in config.yaml if not present
203
+ if [ "$AGENT" = "goose" ]; then
204
+ TSX_BIN="$(cd "$SCRIPT_DIR/.." && pwd)/sinain-core/node_modules/.bin/tsx"
205
+ MCP_ENTRY="$(cd "$SCRIPT_DIR/.." && pwd)/sinain-mcp-server/index.ts"
206
+ GOOSE_CONFIG="${HOME}/.config/goose/config.yaml"
207
+ if [ -f "$GOOSE_CONFIG" ] && ! grep -q "sinain:" "$GOOSE_CONFIG" 2>/dev/null; then
208
+ echo "Registering sinain MCP server with goose..."
209
+ python3 -c "
210
+ import yaml, os, sys
211
+ config_path = sys.argv[1]
212
+ with open(config_path) as f:
213
+ cfg = yaml.safe_load(f) or {}
214
+ cfg.setdefault('extensions', {})['sinain'] = {
215
+ 'name': 'Sinain MCP Server',
216
+ 'cmd': sys.argv[2],
217
+ 'args': [sys.argv[3]],
218
+ 'enabled': True,
219
+ 'envs': {'SINAIN_CORE_URL': sys.argv[4], 'SINAIN_WORKSPACE': sys.argv[5]},
220
+ 'type': 'stdio',
221
+ 'timeout': 300,
222
+ }
223
+ with open(config_path, 'w') as f:
224
+ yaml.dump(cfg, f, default_flow_style=False)
225
+ print(' sinain extension added to ' + config_path)
226
+ " "$GOOSE_CONFIG" "$TSX_BIN" "$MCP_ENTRY" "$CORE_URL" "$WORKSPACE"
227
+ fi
228
+ fi
229
+
189
230
  # Agent mode label
190
231
  if agent_has_mcp; then
191
232
  AGENT_MODE="MCP"
@@ -234,6 +275,11 @@ Knowledge context: sinain-core maintains two knowledge databases — local (sess
234
275
  while true; do
235
276
  # Poll for pending escalation
236
277
  ESC=$(curl -sf "$CORE_URL/escalation/pending" 2>/dev/null || echo '{"ok":false}')
278
+ ESC_PAUSED=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); print('true' if d.get('paused') else '')" 2>/dev/null || true)
279
+ if [ -n "$ESC_PAUSED" ]; then
280
+ sleep 10 # Slow polling when paused
281
+ continue
282
+ fi
237
283
  ESC_ID=$(echo "$ESC" | python3 -c "import sys,json; d=json.load(sys.stdin); e=d.get('escalation'); print(e['id'] if e else '')" 2>/dev/null || true)
238
284
 
239
285
  if [ -n "$ESC_ID" ]; then
@@ -274,10 +320,23 @@ while true; do
274
320
 
275
321
  if agent_has_mcp; then
276
322
  # MCP path: agent runs task with sinain tools available
323
+ # Pre-fetch knowledge context so the spawn doesn't waste turns calling tools
324
+ SPAWN_KNOWLEDGE=$(curl -sf "$CORE_URL/knowledge" 2>/dev/null | python3 -c "
325
+ import sys, json
326
+ d = json.load(sys.stdin)
327
+ k = d.get('knowledge', '')
328
+ # Trim to 2000 chars to avoid prompt bloat
329
+ print(k[:2000])
330
+ " 2>/dev/null || true)
277
331
  SPAWN_PROMPT="You have a background task to complete. Task: $SPAWN_TASK
278
-
279
- Complete this task thoroughly. Use sinain_get_knowledge and sinain_knowledge_query if you need context from past sessions. Summarize your findings concisely."
332
+ ${SPAWN_KNOWLEDGE:+
333
+ ## Knowledge Context
334
+ $SPAWN_KNOWLEDGE
335
+ }
336
+ Complete this task thoroughly. You also have sinain_get_knowledge and sinain_knowledge_query tools available for additional context. Summarize your findings concisely."
337
+ export SINAIN_SPAWN=1 SINAIN_SPAWN_TASK_ID="$SPAWN_ID"
280
338
  SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" "$SPAWN_MAX_TURNS" || echo "ERROR: agent invocation failed")
339
+ unset SINAIN_SPAWN SINAIN_SPAWN_TASK_ID
281
340
  else
282
341
  # Pipe path: agent gets task text directly
283
342
  SPAWN_RESULT=$(invoke_pipe "Background task: $SPAWN_TASK" || echo "No output")
@@ -53,13 +53,12 @@ You produce outputs as JSON.
53
53
  Respond ONLY with valid JSON. No markdown, no code fences, no explanation.
54
54
  Your entire response must be parseable by JSON.parse().
55
55
 
56
- {"hud":"...","digest":"...","record":{"command":"start"|"stop","label":"..."},"task":"..."}
56
+ {"hud":"...","digest":"...","record":{"command":"start"|"stop","label":"..."}}
57
57
 
58
58
  Output fields:
59
59
  - "hud" (required): max 60 words describing what user is doing NOW
60
60
  - "digest" (required): 5-8 sentences with detailed activity description
61
61
  - "record" (optional): control recording — {"command":"start","label":"Meeting name"} or {"command":"stop"}
62
- - "task" (optional): natural language instruction to spawn a background task
63
62
 
64
63
  When to use "record":
65
64
  - START when user begins a meeting, call, lecture, YouTube video, or important audio content
@@ -67,25 +66,6 @@ When to use "record":
67
66
  - Provide descriptive labels like "Team standup", "Client call", "YouTube: [video title from OCR]"
68
67
  - For YouTube/video content: extract video title from screen OCR for the label
69
68
 
70
- When to use "task":
71
- - User explicitly asks for research, lookup, or action
72
- - Something needs external search or processing that isn't a real-time response
73
- - Example: "Search for React 19 migration guide", "Find docs for this API"
74
-
75
- When to spawn "task" for video content:
76
- - 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]"
77
- - ONLY spawn ONCE per video - do not repeat spawn for the same video in subsequent ticks
78
- - Extract video title or URL from screen OCR to include in the task
79
-
80
- When to spawn "task" for coding problems:
81
- - If user is actively working on a coding problem/challenge for 1+ minutes:
82
- - Spawn: "Solve coding problem: [problem description/title from OCR]"
83
- - This includes LeetCode, HackerRank, interviews, coding assessments, or any visible coding challenge
84
- - Look for problem signals: "Input:", "Output:", "Example", "Constraints:", problem titles, test cases
85
- - Include as much context as possible from the screen OCR (problem description, examples, constraints)
86
- - ONLY spawn ONCE per distinct problem - do not repeat for the same problem
87
- - The spawned task should provide a complete solution with code and explanation
88
-
89
69
  Audio sources: [\ud83d\udd0a]=system/speaker audio, [\ud83c\udf99]=microphone (user's voice).
90
70
  Treat [\ud83c\udf99] as direct user speech. Treat [\ud83d\udd0a] as external audio.
91
71
 
@@ -95,7 +75,7 @@ Rules:
95
75
  - If nothing is happening, hud="Idle" and digest explains what was last seen.
96
76
  - Include specific filenames, URLs, error messages, UI text from OCR in digest.
97
77
  - Do NOT suggest actions in digest — just describe the situation factually.
98
- - Only include "record" or "task" when genuinely appropriate — most responses won't have them.
78
+ - Only include "record" when genuinely appropriate — most responses won't have it.
99
79
  - CRITICAL: Output ONLY the JSON object, nothing else.`;
100
80
 
101
81
  /**
@@ -210,7 +190,6 @@ export async function analyzeContext(
210
190
  contextWindow: ContextWindow,
211
191
  config: AnalysisConfig,
212
192
  recorderStatus: RecorderStatus | null = null,
213
- traitSystemPrompt?: string,
214
193
  ): Promise<AgentResult> {
215
194
  const userPrompt = buildUserPrompt(contextWindow, recorderStatus);
216
195
 
@@ -221,10 +200,8 @@ export async function analyzeContext(
221
200
  if (levelFor("screen_images", privacyDest) === "none") images = [];
222
201
  } catch { /* privacy not initialized, keep images */ }
223
202
 
224
- const systemPrompt = traitSystemPrompt ?? SYSTEM_PROMPT;
225
-
226
203
  if (config.provider === "ollama") {
227
- return await callOllama(systemPrompt, userPrompt, images, config);
204
+ return await callOllama(SYSTEM_PROMPT, userPrompt, images, config);
228
205
  }
229
206
 
230
207
  // OpenRouter path: model chain with fallbacks
@@ -241,7 +218,7 @@ export async function analyzeContext(
241
218
  let lastError: Error | null = null;
242
219
  for (const model of models) {
243
220
  try {
244
- return await callOpenRouter(systemPrompt, userPrompt, images, model, config);
221
+ return await callOpenRouter(SYSTEM_PROMPT, userPrompt, images, model, config);
245
222
  } catch (err: any) {
246
223
  lastError = err;
247
224
  log(TAG, `model ${model} failed: ${err.message || err}, trying next...`);
@@ -10,8 +10,6 @@ import { analyzeContext } from "./analyzer.js";
10
10
  import { writeSituationMd } from "./situation-writer.js";
11
11
  import { calculateEscalationScore } from "../escalation/scorer.js";
12
12
  import { log, warn, error, debug } from "../log.js";
13
- import type { TraitEngine, TraitSelection } from "./traits.js";
14
- import { writeTraitLog } from "./traits.js";
15
13
 
16
14
  const TAG = "agent";
17
15
 
@@ -33,10 +31,6 @@ export interface AgentLoopDeps {
33
31
  profiler?: Profiler;
34
32
  /** Called after each successful SITUATION.md write with the content string. */
35
33
  onSituationUpdate?: (content: string) => void;
36
- /** Optional trait engine for personality voice selection. */
37
- traitEngine?: TraitEngine;
38
- /** Directory to write per-day trait log JSONL files. */
39
- traitLogDir?: string;
40
34
  /** Optional: path to sinain-knowledge.md for startup recap. */
41
35
  getKnowledgeDocPath?: () => string | null;
42
36
  /** Optional: feedback store for startup recap context. */
@@ -283,14 +277,6 @@ export class AgentLoop extends EventEmitter {
283
277
  traceCtx?.startSpan("llm-call");
284
278
  const recorderStatus = this.deps.getRecorderStatus?.() ?? null;
285
279
 
286
- // Trait selection: pick the best personality voice for this tick
287
- let traitSelection: TraitSelection | null = null;
288
- if (this.deps.traitEngine?.enabled) {
289
- const ocrText = contextWindow.screen.map(e => e.ocr ?? "").join(" ");
290
- const audioText = contextWindow.audio.map(e => e.text).join(" ");
291
- traitSelection = this.deps.traitEngine.selectTrait(ocrText, audioText);
292
- }
293
-
294
280
  const result = await analyzeContext(contextWindow, this.deps.agentConfig, recorderStatus);
295
281
  this.deps.profiler?.timerRecord("agent.llmCall", result.latencyMs);
296
282
  traceCtx?.endSpan({ model: result.model, tokensIn: result.tokensIn, tokensOut: result.tokensOut, latencyMs: result.latencyMs });
@@ -346,11 +332,6 @@ export class AgentLoop extends EventEmitter {
346
332
  screenCount: contextWindow.screenCount,
347
333
  },
348
334
  };
349
- if (traitSelection) {
350
- entry.voice = traitSelection.trait.name;
351
- entry.voice_stat = traitSelection.stat;
352
- entry.voice_confidence = traitSelection.confidence;
353
- }
354
335
  this.agentBuffer.push(entry);
355
336
  const historyLimit = this.deps.agentConfig.historyLimit || 50;
356
337
  if (this.agentBuffer.length > historyLimit) this.agentBuffer.shift();
@@ -362,17 +343,22 @@ export class AgentLoop extends EventEmitter {
362
343
  debug(TAG, `#${entry.id} (${latencyMs}ms) hud unchanged`);
363
344
  }
364
345
 
365
- // Push HUD line to feed (suppress "—", "Idle", and all in focus mode)
346
+ // Always push HUD to feed buffer for data capture (curation pipeline reads this)
366
347
  if (this.deps.agentConfig.pushToFeed &&
367
- this.deps.escalationMode !== "focus" &&
368
- this.deps.escalationMode !== "rich" &&
369
348
  hud !== "\u2014" && hud !== "Idle" && hud !== this.lastPushedHud) {
370
349
  feedBuffer.push(`[\ud83e\udde0] ${hud}`, "normal", "agent", "stream");
371
- this.deps.onHudUpdate(`[\ud83e\udde0] ${hud}`);
372
350
  this.lastPushedHud = hud;
373
351
  entry.pushed = true;
374
352
  }
375
353
 
354
+ // Broadcast to overlay only when NOT in focus/rich mode
355
+ // (in those modes, the overlay gets updates via escalation instead)
356
+ if (entry.pushed &&
357
+ this.deps.escalationMode !== "focus" &&
358
+ this.deps.escalationMode !== "rich") {
359
+ this.deps.onHudUpdate(`[\ud83e\udde0] ${hud}`);
360
+ }
361
+
376
362
  // Store digest
377
363
  this.latestDigest = entry;
378
364
 
@@ -380,7 +366,7 @@ export class AgentLoop extends EventEmitter {
380
366
  const escalationScore = calculateEscalationScore(digest, contextWindow);
381
367
 
382
368
  // Write SITUATION.md (enhanced with escalation context and recorder status)
383
- const situationContent = writeSituationMd(this.deps.situationMdPath, contextWindow, digest, entry, escalationScore, recorderStatus, traitSelection);
369
+ const situationContent = writeSituationMd(this.deps.situationMdPath, contextWindow, digest, entry, escalationScore, recorderStatus);
384
370
  this.deps.onSituationUpdate?.(situationContent);
385
371
 
386
372
  // Notify for escalation check
@@ -406,22 +392,6 @@ export class AgentLoop extends EventEmitter {
406
392
  hudChanged: entry.pushed,
407
393
  });
408
394
 
409
- // Fire-and-forget trait log
410
- if (this.deps.traitEngine?.enabled && this.deps.traitLogDir) {
411
- writeTraitLog(this.deps.traitLogDir, {
412
- ts: new Date().toISOString(),
413
- tickId: entry.id,
414
- enabled: true,
415
- voice: traitSelection?.trait.name ?? "none",
416
- voice_stat: traitSelection?.stat ?? 0,
417
- voice_confidence: traitSelection?.confidence ?? 0,
418
- activation_scores: traitSelection?.allScores ?? {},
419
- context_app: contextWindow.currentApp,
420
- hud_length: entry.hud.length,
421
- synthesis: false,
422
- }).catch(() => {});
423
- }
424
-
425
395
  } catch (err: any) {
426
396
  error(TAG, "tick error:", err.message || err);
427
397
  traceCtx?.endSpan({ status: "error", error: err.message });
@@ -2,7 +2,6 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import type { ContextWindow, AgentEntry, RecorderStatus } from "../types.js";
4
4
  import type { EscalationScore } from "../escalation/scorer.js";
5
- import type { TraitSelection } from "./traits.js";
6
5
  import { normalizeAppName } from "./context-window.js";
7
6
  import { log, error } from "../log.js";
8
7
 
@@ -46,7 +45,6 @@ export function writeSituationMd(
46
45
  entry: AgentEntry,
47
46
  escalationScore?: EscalationScore,
48
47
  recorderStatus?: RecorderStatus | null,
49
- traitSelection?: TraitSelection | null,
50
48
  ): string {
51
49
  const dir = path.dirname(situationMdPath);
52
50
  const tmpPath = situationMdPath + ".tmp";
@@ -151,20 +149,6 @@ export function writeSituationMd(
151
149
  lines.push("");
152
150
  }
153
151
 
154
- // Trait voice: frame downstream agent's perspective
155
- if (traitSelection) {
156
- const { trait, stat } = traitSelection;
157
- const voiceFlavor = stat >= 7 ? trait.voice_high : stat <= 2 ? trait.voice_low : trait.description;
158
- lines.push("## Active Voice");
159
- lines.push("");
160
- lines.push(`- Trait: ${trait.name} (stat ${stat}/10)`);
161
- lines.push(`- Tagline: ${trait.tagline}`);
162
- lines.push(`- Voice: "${voiceFlavor}"`);
163
- lines.push("");
164
- lines.push("> Frame your response through this lens naturally. Do not name the trait explicitly.");
165
- lines.push("");
166
- }
167
-
168
152
  lines.push("## Metadata");
169
153
  lines.push("");
170
154
  lines.push(`- Screen events in window: ${contextWindow.screenCount}`);
@@ -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, AnalysisConfig, 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, 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));
@@ -234,13 +234,6 @@ export function loadConfig(): CoreConfig {
234
234
  retentionDays: intEnv("FEEDBACK_RETENTION_DAYS", 30),
235
235
  };
236
236
 
237
- const traitConfig: TraitConfig = {
238
- enabled: boolEnv("TRAITS_ENABLED", false),
239
- configPath: resolvePath(env("TRAITS_CONFIG", "~/.sinain/traits.json")),
240
- entropyHigh: boolEnv("TRAIT_ENTROPY_HIGH", false),
241
- logDir: resolvePath(env("TRAIT_LOG_DIR", "~/.sinain-core/traits")),
242
- };
243
-
244
237
  const privacyConfig = loadPrivacyConfig();
245
238
 
246
239
  return {
@@ -257,7 +250,6 @@ export function loadConfig(): CoreConfig {
257
250
  traceDir: resolvePath(env("TRACE_DIR", resolve(sinainDataDir(), "traces"))),
258
251
  costDisplayEnabled: boolEnv("COST_DISPLAY_ENABLED", false),
259
252
  learningConfig,
260
- traitConfig,
261
253
  privacyConfig,
262
254
  };
263
255
  }
@@ -520,11 +520,11 @@ ${recentLines.join("\n")}`;
520
520
  message: task,
521
521
  sessionKey: childSessionKey,
522
522
  lane: "subagent",
523
- extraSystemPrompt: this.buildChildSystemPrompt(task, label),
523
+ extraSystemPrompt: await this.buildChildSystemPrompt(task, label),
524
524
  deliver: false,
525
525
  idempotencyKey: idemKey,
526
526
  label: label || undefined,
527
- }, 45_000, { expectFinal: true });
527
+ }, 10 * 60_000, { expectFinal: true });
528
528
 
529
529
  log(TAG, `spawn-task RPC response: ${JSON.stringify(result).slice(0, 500)}`);
530
530
  this.stats.totalSpawnResponses++;
@@ -581,8 +581,44 @@ ${recentLines.join("\n")}`;
581
581
  }
582
582
  }
583
583
 
584
- /** Build a focused system prompt for the child subagent. */
585
- private buildChildSystemPrompt(task: string, label?: string): string {
584
+ /** Build a focused system prompt for the child subagent, enriched with knowledge. */
585
+ private async buildChildSystemPrompt(task: string, label?: string): Promise<string> {
586
+ // Fetch relevant knowledge facts (same enrichment as escalations)
587
+ let knowledgeSection = "";
588
+ if (this.deps.queryKnowledgeFacts) {
589
+ try {
590
+ const entities: string[] = [];
591
+ // Extract keywords from task text for entity matching
592
+ const techKeywords = [
593
+ "react-native", "react", "flutter", "swift", "kotlin", "python",
594
+ "typescript", "node", "docker", "sinain", "openclaw", "overlay",
595
+ "sense", "audio", "transcription", "escalation", "knowledge",
596
+ ];
597
+ const lower = task.toLowerCase();
598
+ for (const kw of techKeywords) {
599
+ if (lower.includes(kw)) entities.push(kw);
600
+ }
601
+ // Also extract capitalized proper nouns (file names, project names)
602
+ const nouns = task.match(/\b[A-Z][a-z]{2,}\b/g);
603
+ if (nouns) entities.push(...nouns.map(n => n.toLowerCase()).slice(0, 3));
604
+
605
+ if (entities.length > 0) {
606
+ const facts = await this.deps.queryKnowledgeFacts(entities.slice(0, 5), 5);
607
+ if (facts && facts.trim().length > 20) {
608
+ knowledgeSection = `\n## Relevant Knowledge\n${facts.trim()}`;
609
+ }
610
+ }
611
+ } catch (err) {
612
+ log(TAG, `spawn knowledge enrichment failed: ${String(err)}`);
613
+ }
614
+ }
615
+
616
+ // Include latest digest for screen/audio context
617
+ const latestDigest = this.deps.feedBuffer.latest()?.text;
618
+ const contextSection = latestDigest
619
+ ? `\n## Current User Context\n${latestDigest.slice(0, 500)}`
620
+ : "";
621
+
586
622
  return [
587
623
  "# Subagent Context",
588
624
  "",
@@ -596,8 +632,9 @@ ${recentLines.join("\n")}`;
596
632
  "1. Stay focused — do your assigned task, nothing else",
597
633
  "2. Your final message will be reported to the requester",
598
634
  "3. Be concise but informative",
599
- "",
600
- label ? `Label: ${label}` : "",
635
+ label ? `\nLabel: ${label}` : "",
636
+ knowledgeSection,
637
+ contextSection,
601
638
  ].filter(Boolean).join("\n");
602
639
  }
603
640
 
@@ -633,19 +670,9 @@ ${recentLines.join("\n")}`;
633
670
  return;
634
671
  }
635
672
 
636
- const maxWaitMs = 5 * 60 * 1000; // 5 minutes
637
673
  const pollIntervalMs = 5000; // 5 seconds
638
674
 
639
675
  const poll = async (): Promise<void> => {
640
- const elapsed = Date.now() - task.startedAt;
641
- if (elapsed > maxWaitMs) {
642
- log(TAG, `spawn-task timeout: taskId=${taskId}`);
643
- this.broadcastTaskEvent(taskId, "timeout", task.label, task.startedAt);
644
- this.pendingSpawnTasks.delete(taskId);
645
- savePendingTasks(this.pendingSpawnTasks);
646
- this.finishPoll();
647
- return;
648
- }
649
676
 
650
677
  if (!this.wsClient.isConnected) {
651
678
  // Retry later
@@ -8,7 +8,6 @@ import { AudioPipeline } from "./audio/pipeline.js";
8
8
  import type { CaptureSpawner } from "./audio/capture-spawner.js";
9
9
  import { TranscriptionService } from "./audio/transcription.js";
10
10
  import { AgentLoop } from "./agent/loop.js";
11
- import { TraitEngine, loadTraitRoster } from "./agent/traits.js";
12
11
  import { shortAppName } from "./agent/context-window.js";
13
12
  import { Escalator } from "./escalation/escalator.js";
14
13
  import { Recorder } from "./recorder.js";
@@ -344,10 +343,6 @@ async function main() {
344
343
  localCuration.distillPendingSession(); // Recover any session saved before a force-kill
345
344
  localCuration.startPeriodicCuration();
346
345
 
347
- // ── Initialize trait engine ──
348
- const traitRoster = loadTraitRoster(config.traitConfig.configPath);
349
- const traitEngine = new TraitEngine(traitRoster, config.traitConfig);
350
-
351
346
  // ── Initialize escalation ──
352
347
  const escalator = new Escalator({
353
348
  feedBuffer,
@@ -372,33 +367,6 @@ async function main() {
372
367
  // Handle recorder commands
373
368
  const stopResult = recorder.handleCommand(entry.record);
374
369
 
375
- // Dispatch task via subagent spawn
376
- if (entry.task || stopResult) {
377
- let task: string;
378
- let label: string | undefined;
379
-
380
- if (stopResult && stopResult.segments > 0 && entry.task) {
381
- // Recording stopped with explicit task instruction
382
- task = `${entry.task}\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
383
- label = stopResult.title;
384
- } else if (stopResult && stopResult.segments > 0) {
385
- // Recording stopped without explicit task — default to cleanup/summarize
386
- task = `Clean up and summarize this recording transcript:\n\n[Recording: "${stopResult.title}", ${stopResult.durationS}s]\n${stopResult.transcript}`;
387
- label = stopResult.title;
388
- } else if (entry.task) {
389
- // Standalone task without recording
390
- task = entry.task;
391
- } else {
392
- task = "";
393
- }
394
-
395
- if (task) {
396
- escalator.dispatchSpawnTask(task, label).catch(err => {
397
- error(TAG, "spawn task dispatch error:", err);
398
- });
399
- }
400
- }
401
-
402
370
  // Escalation continues as normal
403
371
  escalator.onAgentAnalysis(entry, contextWindow);
404
372
  },
@@ -422,8 +390,6 @@ async function main() {
422
390
  };
423
391
  return ctx;
424
392
  } : undefined,
425
- traitEngine,
426
- traitLogDir: config.traitConfig.logDir,
427
393
  getKnowledgeDocPath: () => {
428
394
  const workspace = resolveWorkspace();
429
395
  const p = `${workspace}/memory/sinain-knowledge.md`;
@@ -564,6 +530,9 @@ async function main() {
564
530
  // ── Screen capture active flag ──
565
531
  let screenActive = true;
566
532
 
533
+ // ── Escalation pause/resume state ──
534
+ let savedEscalationMode: typeof config.escalationConfig.mode | null = null;
535
+
567
536
  // ── Create HTTP + WS server ──
568
537
  const server = createAppServer({
569
538
  config,
@@ -674,6 +643,7 @@ async function main() {
674
643
 
675
644
  // Bare agent HTTP escalation bridge
676
645
  getEscalationPending: () => escalator.getPendingHttp(),
646
+ isEscalationPaused: () => savedEscalationMode !== null,
677
647
  respondEscalation: (id: string, response: string) => escalator.respondHttp(id, response),
678
648
 
679
649
  // Knowledge graph integration (checks both local and workspace DBs)
@@ -733,7 +703,22 @@ async function main() {
733
703
  wsHandler.updateState({ screen: screenActive ? "active" : "off" });
734
704
  return screenActive;
735
705
  },
736
- onToggleTraits: () => traitEngine.toggle(),
706
+ onToggleEscalation: () => {
707
+ if (savedEscalationMode === null) {
708
+ // Pause: save current mode, switch to off
709
+ savedEscalationMode = config.escalationConfig.mode;
710
+ escalator.setMode("off");
711
+ log(TAG, `escalation paused (was: ${savedEscalationMode})`);
712
+ return false;
713
+ } else {
714
+ // Resume: restore saved mode
715
+ const mode = savedEscalationMode;
716
+ savedEscalationMode = null;
717
+ escalator.setMode(mode);
718
+ log(TAG, `escalation resumed (mode: ${mode})`);
719
+ return true;
720
+ }
721
+ },
737
722
  });
738
723
 
739
724
  // Broadcast initial screen state so overlay gets correct status on connect
@@ -795,7 +780,6 @@ async function main() {
795
780
  log(TAG, ` mic: ${config.micEnabled ? (config.micConfig.autoStart ? "active" : "standby") : "disabled"}`);
796
781
  log(TAG, ` agent: ${config.agentConfig.enabled ? "enabled" : "disabled"}`);
797
782
  log(TAG, ` escal: ${config.escalationConfig.mode}`);
798
- log(TAG, ` traits: ${config.traitConfig.enabled ? "enabled" : "disabled"} (${traitRoster.length} traits)`);
799
783
  log(TAG, ` cost: display=${config.costDisplayEnabled ? "on" : "off"} (always logged)`);
800
784
 
801
785
  // ── Graceful shutdown ──
@@ -95,7 +95,7 @@ export class LocalCurationService {
95
95
  * Called during shutdown — instant (no LLM), survives tsx force-kill.
96
96
  */
97
97
  savePendingSession(feedItems: FeedItem[]): void {
98
- if (feedItems.length < 3) {
98
+ if (feedItems.length < 1) {
99
99
  log(TAG, `skipping save — only ${feedItems.length} feed items`);
100
100
  return;
101
101
  }
@@ -135,7 +135,7 @@ export class LocalCurationService {
135
135
  }
136
136
 
137
137
  const items: FeedItem[] = data.items || [];
138
- if (items.length < 3) {
138
+ if (items.length < 1) {
139
139
  log(TAG, `pending session too small (${items.length} items) — removing`);
140
140
  unlinkSync(pendingPath);
141
141
  return;
@@ -160,7 +160,7 @@ export class LocalCurationService {
160
160
  * picked up on next startup via distillPendingSession().
161
161
  */
162
162
  async distillSession(feedItems: FeedItem[]): Promise<void> {
163
- if (feedItems.length < 3) {
163
+ if (feedItems.length < 1) {
164
164
  log(TAG, `skipping distillation — only ${feedItems.length} feed items`);
165
165
  return;
166
166
  }
@@ -332,7 +332,7 @@ export class LocalCurationService {
332
332
 
333
333
  /** Fallback: write raw feed summary when distillation fails. */
334
334
  private writeDailyNotesFallback(feedItems: FeedItem[]): void {
335
- if (feedItems.length < 3) return;
335
+ if (feedItems.length < 1) return;
336
336
 
337
337
  const date = new Date().toISOString().slice(0, 10);
338
338
  const notesPath = resolve(this.memoryDir, `${date}.md`);