@geravant/sinain 1.10.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geravant/sinain",
3
- "version": "1.10.0",
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": {
@@ -55,6 +55,56 @@ When responding to escalations:
55
55
  4. Optionally call `sinain_get_knowledge` to review the portable knowledge document
56
56
  5. Optionally call `sinain_get_feedback` to review recent escalation scores
57
57
 
58
+ ## Knowledge System
59
+
60
+ Knowledge is stored in a **dual-database** architecture with two SQLite triplestore databases:
61
+
62
+ | Database | Path | Written by |
63
+ |----------|------|------------|
64
+ | **Local** | `~/.sinain/memory/knowledge-graph.db` | `LocalCurationService` (session distillation on shutdown, periodic curation every 30 min) |
65
+ | **Workspace** | `~/.openclaw/workspace/memory/knowledge-graph.db` | Heartbeat curation scripts (`sinain_heartbeat_tick`) |
66
+
67
+ ### Knowledge Tools
68
+
69
+ | Tool | What it does |
70
+ |------|-------------|
71
+ | `sinain_get_knowledge` | Read the portable knowledge document (playbook + top facts) from workspace |
72
+ | `sinain_knowledge_query` | Query facts by entity/keyword — queries **both** DBs via sinain-core API |
73
+ | `sinain_distill_session` | Explicitly distill current session into knowledge updates |
74
+
75
+ ### HTTP Knowledge API (sinain-core, port 9500)
76
+
77
+ These endpoints query **both** databases and merge results:
78
+
79
+ | Endpoint | Purpose |
80
+ |----------|---------|
81
+ | `GET /knowledge` | Portable knowledge document |
82
+ | `GET /knowledge/facts?entities=X&max=N` | Query facts by keyword tags |
83
+ | `GET /knowledge/entities?max=N` | List all entities with attributes |
84
+ | `GET /knowledge/export?domain=X&max=N` | Export facts as portable JSON |
85
+ | `POST /knowledge/import` | Import facts (deduplicates automatically) |
86
+ | `GET /knowledge/ui` | Web UI for browsing/managing knowledge |
87
+
88
+ ### How Knowledge Flows
89
+
90
+ ```
91
+ Session (screen + audio) → LocalCurationService → Local DB
92
+ ↓ (queried together)
93
+ Heartbeat tick → curation scripts ──────────→ Workspace DB
94
+
95
+ Knowledge API (localhost:9500) ← merges both DBs ← queries
96
+ ```
97
+
98
+ - **Local DB** gets real-time session knowledge (audio transcripts, screen patterns, German lessons, etc.)
99
+ - **Workspace DB** gets heartbeat-curated knowledge (playbook patterns, feedback analysis)
100
+ - The Knowledge API merges both — use `sinain_knowledge_query` for combined results
101
+ - Facts have confidence decay (60-day half-life) — reinforcement resets the clock
102
+ - Export/import via `/knowledge/export` → `/knowledge/import` enables cross-instance transfer
103
+
104
+ ### Using Knowledge in Escalation Responses
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.
107
+
58
108
  ## Spawning Background Tasks
59
109
 
60
110
  When an escalation suggests deeper research would help:
@@ -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"
@@ -221,15 +262,24 @@ Call sinain_get_escalation to see the full context, then call sinain_respond wit
221
262
  Response guidelines: 5-10 sentences, address errors first, reference specific screen/audio context, never NO_REPLY. Max 4000 chars for coding context, 3000 otherwise.'
222
263
 
223
264
  HEARTBEAT_PROMPT='You are the sinain HUD agent. Run the heartbeat cycle:
224
- 1. Call sinain_heartbeat_tick with a brief session summary
225
- 2. If the result contains a suggestion, post it to HUD via sinain_post_feed
226
- 3. Call sinain_get_feedback to review recent scores'
265
+ 1. Call sinain_heartbeat_tick with a brief session summary (runs signal analysis, session distillation, knowledge integration, insight synthesis)
266
+ 2. If the result contains a suggestion or insight, post it to HUD via sinain_post_feed
267
+ 3. Call sinain_get_knowledge to review the merged knowledge document (draws from both local and workspace databases)
268
+ 4. Optionally call sinain_knowledge_query with relevant entities to check long-term knowledge state
269
+ 5. Call sinain_get_feedback to review recent escalation scores
270
+
271
+ Knowledge context: sinain-core maintains two knowledge databases — local (session distillation) and workspace (heartbeat curation). The knowledge tools query both via the sinain-core API. Facts have confidence decay (60-day half-life).'
227
272
 
228
273
  # --- Main loop ---
229
274
 
230
275
  while true; do
231
276
  # Poll for pending escalation
232
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
233
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)
234
284
 
235
285
  if [ -n "$ESC_ID" ]; then
@@ -270,10 +320,23 @@ while true; do
270
320
 
271
321
  if agent_has_mcp; then
272
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)
273
331
  SPAWN_PROMPT="You have a background task to complete. Task: $SPAWN_TASK
274
-
275
- 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"
276
338
  SPAWN_RESULT=$(invoke_agent "$SPAWN_PROMPT" "$SPAWN_MAX_TURNS" || echo "ERROR: agent invocation failed")
339
+ unset SINAIN_SPAWN SINAIN_SPAWN_TASK_ID
277
340
  else
278
341
  # Pipe path: agent gets task text directly
279
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