titan-agent 6.0.0 → 6.0.2

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.
@@ -50,23 +50,82 @@ function advisoriesPath(userId) {
50
50
  const home = env ? env.startsWith("~/") ? join(homedir(), env.slice(2)) : env : join(homedir(), ".titan");
51
51
  return join(home, "users", userId, "soma-advisories.jsonl");
52
52
  }
53
- function enqueueAdvisory(userId, decision) {
53
+ const DEFAULT_DEDUP_WINDOW_MS = 12 * 60 * 60 * 1e3;
54
+ const DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1e3;
55
+ function getDedupWindowMs() {
56
+ const raw = process.env.TITAN_SOMA_DEDUP_WINDOW_MS;
57
+ const n = raw ? Number(raw) : NaN;
58
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_DEDUP_WINDOW_MS;
59
+ }
60
+ function getRetentionMs() {
61
+ const raw = process.env.TITAN_SOMA_ADVISORY_RETENTION_MS;
62
+ const n = raw ? Number(raw) : NaN;
63
+ return Number.isFinite(n) && n >= 0 ? n : DEFAULT_RETENTION_MS;
64
+ }
65
+ function parseAdvisoryFile(raw) {
66
+ if (!raw.trim()) return [];
67
+ const out = [];
68
+ for (const line of raw.split("\n")) {
69
+ const trimmed = line.trim();
70
+ if (!trimmed) continue;
71
+ try {
72
+ const rec = JSON.parse(trimmed);
73
+ if (rec && typeof rec.at === "string" && typeof rec.action === "string") out.push(rec);
74
+ } catch {
75
+ }
76
+ }
77
+ return out;
78
+ }
79
+ function dedupKey(action, rationale) {
80
+ const r = rationale.toLowerCase().replace(/\s+/g, " ").replace(/[.!?]+\s*$/, "").trim();
81
+ return `${action}::${r}`;
82
+ }
83
+ function enqueueAdvisory(userId, decision, now = Date.now()) {
54
84
  if (decision.action === "nothing") return;
55
85
  const path = advisoriesPath(userId);
56
86
  try {
57
87
  mkdirSync(join(path, ".."), { recursive: true });
58
88
  } catch {
59
89
  }
60
- const line = JSON.stringify({
61
- at: (/* @__PURE__ */ new Date()).toISOString(),
62
- action: decision.action,
63
- rationale: decision.rationale,
64
- confidence: decision.confidence,
65
- payload: decision.payload
66
- });
90
+ const rationale = (decision.rationale ?? "").trim() || decision.action;
91
+ const confidence = typeof decision.confidence === "number" && Number.isFinite(decision.confidence) ? decision.confidence : 0.5;
92
+ const dedupWindow = getDedupWindowMs();
93
+ const retention = getRetentionMs();
94
+ const incomingKey = dedupKey(decision.action, rationale);
67
95
  try {
68
- const prev = existsSync(path) ? readFileSync(path, "utf-8") : "";
69
- writeFileSync(path, prev + line + "\n");
96
+ const existing = existsSync(path) ? parseAdvisoryFile(readFileSync(path, "utf-8")) : [];
97
+ const retentionCutoff = now - retention;
98
+ const retained = existing.filter((r) => {
99
+ const t = new Date(r.at).getTime();
100
+ return Number.isFinite(t) && t >= retentionCutoff;
101
+ });
102
+ const dedupCutoff = now - dedupWindow;
103
+ const isDup = retained.some((r) => {
104
+ const t = new Date(r.at).getTime();
105
+ if (!Number.isFinite(t) || t < dedupCutoff) return false;
106
+ return dedupKey(r.action, r.rationale) === incomingKey;
107
+ });
108
+ if (isDup) {
109
+ logger.debug(COMPONENT, `Skipping duplicate advisory within ${Math.round(dedupWindow / 36e5)}h window: ${decision.action} \u2014 ${rationale.slice(0, 60)}`);
110
+ if (retained.length !== existing.length) {
111
+ const body2 = retained.map((r) => JSON.stringify(r)).join("\n") + (retained.length > 0 ? "\n" : "");
112
+ writeFileSync(path, body2);
113
+ }
114
+ return;
115
+ }
116
+ const fresh = {
117
+ at: new Date(now).toISOString(),
118
+ action: decision.action,
119
+ rationale,
120
+ confidence,
121
+ payload: decision.payload
122
+ };
123
+ const final = [...retained, fresh];
124
+ const body = final.map((r) => JSON.stringify(r)).join("\n") + "\n";
125
+ writeFileSync(path, body);
126
+ if (retained.length !== existing.length) {
127
+ logger.info(COMPONENT, `Pruned ${existing.length - retained.length} stale advisory entries (>${Math.round(retention / 864e5)}d old)`);
128
+ }
70
129
  } catch (err) {
71
130
  logger.warn(COMPONENT, `Failed to enqueue advisory: ${err.message}`);
72
131
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/agent/somaInitiative.ts"],"sourcesContent":["/**\n * TITAN — somaInitiative (v6.0 step 11)\n *\n * Why this exists\n * ---------------\n * The Presence thesis: \"TITAN acts without being asked.\" This module is the\n * concrete engine. While the user is idle, a cron-driven scout fires every\n * N minutes:\n *\n * 1. Read the user's pattern aggregates (step 12)\n * 2. Read Soma drive state (step 14 + organism layer)\n * 3. Read time-of-day, recent activity, pending todos\n * 4. Decide if there's a useful surface to surface\n * 5. If yes — fire a side-channel widget event (existing widgetEmitter)\n * labelled \"Soma noticed…\" so the user sees it appeared\n * autonomously\n *\n * Most pulses end in \"nothing to do.\" That's by design — the loop is\n * conservative. Better to miss a chance to help than spam the user with\n * unwanted widgets.\n *\n * Reference:\n * - ~/.claude/projects/-Users-michaelelliott/memory/titan-v6-living-canvas.md\n * (v6.0 step 11)\n * - src/storage/patterns.ts — input signals\n * - src/storage/somaProfile.ts — drive baselines\n * - src/agent/widgetEmitter.ts — output channel\n */\nimport logger from '../utils/logger.js';\nimport { deriveSuggestions, recordSignal, aggregatePatterns } from '../storage/patterns.js';\nimport { readSomaProfile } from '../storage/somaProfile.js';\nimport { getActiveSpace } from '../storage/spaces.js';\n\nconst COMPONENT = 'SomaInitiative';\n\n/** How often the loop fires when active. 5 minutes by Tony's spec. */\nconst PULSE_INTERVAL_MS = 5 * 60 * 1000;\n\n/** Time-of-day buckets the heuristics check against (24h, local). */\nfunction timeBucket(now = new Date()): string {\n const hour = now.getHours();\n const day = now.getDay();\n const dayKind = day === 0 || day === 6 ? 'weekend' : 'weekday';\n return `${dayKind}-${hour.toString().padStart(2, '0')}`;\n}\n\n/** Single pulse — pure function of inputs, no side effects yet. */\nexport interface PulseDecision {\n /** What the loop decided to do this pulse. */\n action: 'nothing' | 'pin-widget' | 'create-space' | 'add-cron' | 'note';\n /** Human-readable explanation (used in logs + the `Soma noticed…` chip). */\n rationale?: string;\n /** Confidence the suggestion is welcome (0..1). */\n confidence?: number;\n /** Optional payload for the action — varies by kind. */\n payload?: Record<string, unknown>;\n}\n\n/**\n * Decide what (if anything) Soma should do this pulse. Pure function — no\n * side effects. The actual emission happens in `executePulse`.\n *\n * Inputs:\n * - userId — whose patterns/profile to read\n * - idleMs — milliseconds since last user activity\n * - opts.minIdleMs — only act when truly idle (default 2 min)\n */\nexport function decidePulse(\n userId = 'default-user',\n idleMs = Infinity,\n opts?: { minIdleMs?: number },\n): PulseDecision {\n const minIdle = opts?.minIdleMs ?? 2 * 60 * 1000;\n if (idleMs < minIdle) {\n return { action: 'nothing', rationale: 'user is active' };\n }\n\n // Record the time bucket as a pattern signal so the loop's own activity\n // becomes part of the user's pattern history. This is harmless and\n // helps timing-based suggestions emerge over time.\n recordSignal(userId, `time:${timeBucket()}`);\n\n const suggestions = deriveSuggestions(userId, { windowDays: 14 });\n if (suggestions.length === 0) {\n return { action: 'nothing', rationale: 'no patterns above confidence threshold' };\n }\n\n // Pick the single highest-confidence suggestion. Future iterations could\n // surface multiple, but conservative-first is the right default.\n const best = suggestions.sort((a, b) => b.confidence - a.confidence)[0];\n\n // Don't suggest the same thing repeatedly — dedup on signal in pattern\n // history. We rely on the pattern aggregation cooldown (only suggests\n // when count >= 3 in window) as the primary throttle; here we layer a\n // small extra check using the active Space's frustration baseline.\n const profile = readSomaProfile(userId);\n if (profile.baseline.frustration > 0.7) {\n // User has been frustrated. Don't pile on with proactive suggestions\n // — they'll feel like noise. Wait for the frustration to subside.\n return { action: 'nothing', rationale: 'soma frustration high — holding suggestions' };\n }\n\n return {\n action: best.kind === 'pin-widget' ? 'pin-widget'\n : best.kind === 'create-space' ? 'create-space'\n : best.kind === 'add-cron' ? 'add-cron'\n : 'note',\n rationale: best.rationale,\n confidence: best.confidence,\n payload: { signal: best.signal, activeSpaceId: getActiveSpace()?.id },\n };\n}\n\n/**\n * Fire one pulse + execute its decision. Used both by the cron driver and\n * by tests to step the loop deterministically.\n *\n * Returns the decision for telemetry / test assertions.\n */\nexport function executePulse(userId = 'default-user', idleMs = Infinity): PulseDecision {\n const d = decidePulse(userId, idleMs);\n if (d.action === 'nothing') {\n // Don't log every \"nothing happened\" tick — too noisy.\n return d;\n }\n logger.info(COMPONENT, `[Pulse] action=${d.action} confidence=${d.confidence?.toFixed(2)} rationale=\"${d.rationale}\"`);\n\n // For now the loop emits a logged advisory rather than auto-creating\n // widgets. Auto-creation requires:\n // - the gateway SSE stream to be open (otherwise the widget event\n // fires into the void)\n // - the agent to author the actual widget code, which it can't do\n // from a non-LLM context\n //\n // The right next step (v6.0+) is to enqueue these advisories so the\n // user sees them in a \"Soma noticed…\" panel; the agent's NEXT chat\n // turn can then pick them up and act. That keeps the loop's\n // side-effects observable + reversible.\n enqueueAdvisory(userId, d);\n return d;\n}\n\n// ─── Advisory queue ─────────────────────────────────────────────────\n\nimport { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';\nimport { join } from 'path';\nimport { homedir } from 'os';\n\nfunction advisoriesPath(userId: string): string {\n const env = process.env.TITAN_HOME;\n const home = env\n ? (env.startsWith('~/') ? join(homedir(), env.slice(2)) : env)\n : join(homedir(), '.titan');\n return join(home, 'users', userId, 'soma-advisories.jsonl');\n}\n\nexport function enqueueAdvisory(userId: string, decision: PulseDecision): void {\n if (decision.action === 'nothing') return;\n const path = advisoriesPath(userId);\n try { mkdirSync(join(path, '..'), { recursive: true }); } catch { /* exists */ }\n const line = JSON.stringify({\n at: new Date().toISOString(),\n action: decision.action,\n rationale: decision.rationale,\n confidence: decision.confidence,\n payload: decision.payload,\n });\n try {\n const prev = existsSync(path) ? readFileSync(path, 'utf-8') : '';\n writeFileSync(path, prev + line + '\\n');\n } catch (err) {\n logger.warn(COMPONENT, `Failed to enqueue advisory: ${(err as Error).message}`);\n }\n}\n\n/** Read recent advisories. Used by the agent loop to surface Soma's\n * observations in the next chat turn. Returns up to `limit` most-recent. */\nexport function readRecentAdvisories(userId: string, limit = 5): Array<{\n at: string;\n action: string;\n rationale: string;\n confidence: number;\n payload?: Record<string, unknown>;\n}> {\n const path = advisoriesPath(userId);\n if (!existsSync(path)) return [];\n try {\n const lines = readFileSync(path, 'utf-8').trim().split('\\n').filter(Boolean);\n return lines.slice(-limit).map(l => JSON.parse(l)).reverse();\n } catch {\n return [];\n }\n}\n\n// ─── Daily proactive-widget gift (v6.0) ────────────────────────────\n//\n// Tony's spec: \"I want the TITAN agent to make a customized widget for\n// its user, from info it knows about the user, every day or so if it\n// wants to.\"\n//\n// Mechanism:\n// 1. Once a day (default — 22h interval, slight jitter), the agent\n// considers gifting the user a custom widget.\n// 2. Pulls the user's Soma profile (long-term observations + baseline\n// drives), recent patterns, and active Space context.\n// 3. Composes a /api/message internal prompt asking the agent to\n// \"design and pin a widget you think the user would appreciate\n// based on what you know about them — or skip if nothing comes to\n// mind.\" Conservative bias: the agent SHOULD skip when in doubt.\n// 4. The agent (real LLM) either calls create_widget itself or\n// politely declines. Either way the decision is logged to\n// `~/.titan/users/<userId>/soma-gifts.jsonl` so we can audit.\n//\n// This is the \"TITAN gifts you a widget on its own, occasionally\"\n// behavior — different from the 5-min advisory pulse which only files\n// advisories without calling the LLM.\n\nconst DAILY_GIFT_INTERVAL_MS = 22 * 60 * 60 * 1000; // 22h, lets it drift\nconst DAILY_GIFT_MIN_GAP_MS = 18 * 60 * 60 * 1000; // never less than 18h between gifts\n\nfunction giftsPath(userId: string): string {\n const env = process.env.TITAN_HOME;\n const home = env\n ? (env.startsWith('~/') ? join(homedir(), env.slice(2)) : env)\n : join(homedir(), '.titan');\n return join(home, 'users', userId, 'soma-gifts.jsonl');\n}\n\n/** Read the most recent gift attempt timestamp (epoch ms), or 0 if none. */\nfunction lastGiftAt(userId: string): number {\n const path = giftsPath(userId);\n if (!existsSync(path)) return 0;\n try {\n const lines = readFileSync(path, 'utf-8').trim().split('\\n').filter(Boolean);\n const last = lines[lines.length - 1];\n if (!last) return 0;\n const entry = JSON.parse(last) as { at?: string };\n return entry.at ? Date.parse(entry.at) : 0;\n } catch { return 0; }\n}\n\nfunction logGiftAttempt(userId: string, payload: Record<string, unknown>): void {\n const path = giftsPath(userId);\n try { mkdirSync(join(path, '..'), { recursive: true }); } catch { /* exists */ }\n const line = JSON.stringify({ at: new Date().toISOString(), ...payload });\n try {\n const prev = existsSync(path) ? readFileSync(path, 'utf-8') : '';\n writeFileSync(path, prev + line + '\\n');\n } catch (err) {\n logger.warn(COMPONENT, `gift log write failed: ${(err as Error).message}`);\n }\n}\n\n/**\n * Decide whether to gift a custom widget today. Pure function — no side\n * effects. Returns null when we should NOT gift right now, or an object\n * with a one-paragraph brief the LLM can use to design the widget.\n */\nexport interface GiftBrief {\n /** Short rationale shown to the user when the widget lands. */\n rationale: string;\n /** Compact context the LLM sees — recent observations + patterns. */\n context: string;\n}\n\nexport function decideDailyGift(userId = 'default-user', now = Date.now(), opts?: { force?: boolean }): GiftBrief | null {\n const force = opts?.force === true;\n if (!force) {\n const since = now - lastGiftAt(userId);\n if (since < DAILY_GIFT_MIN_GAP_MS) return null;\n }\n\n // Pull what we know about the user.\n const profile = readSomaProfile(userId);\n // v6.0.3 — When force=true (manual user trigger from the SOMA panel),\n // skip the \"learned >= 3 observations\" gate. The user explicitly asked\n // for a gift; Soma should at least try, even on day-one with a thin\n // profile. The agent prompt still lets the LLM decline if nothing\n // meaningful comes to mind.\n if (!force && profile.learnedAboutUser.length < 3) {\n return null;\n }\n // Don't gift when frustrated — same throttling as advisories.\n // Manual trigger overrides this too.\n if (!force && profile.baseline.frustration > 0.7) return null;\n\n const recent = profile.learnedAboutUser.slice(-10);\n const aggs = aggregatePatterns(userId, { windowDays: 7, topN: 5 });\n const activeSpace = getActiveSpace();\n\n const context = [\n `User profile (most recent 10 observations):`,\n ...recent.map(o => ` - ${o}`),\n ``,\n `Top patterns last 7 days:`,\n ...(aggs.length === 0\n ? [' (none yet — user is new or quiet)']\n : aggs.map(a => ` - ${a.signal} ×${a.count}`)),\n ``,\n activeSpace ? `Active Space: ${activeSpace.name} (${activeSpace.widgets.length} widget(s) pinned).` : 'No active Space.',\n ].join('\\n');\n\n return {\n rationale: `Soma noticed it's been ~24h since the last surprise. Generating a widget from what's known about the user.`,\n context,\n };\n}\n\n/**\n * Actually execute the daily gift — invokes the LLM via the agent's own\n * `processMessage` flow with a brief that asks the agent to either build\n * a widget or politely decline. The agent's create_widget call (if it\n * makes one) routes through the normal widgetEmitter side-channel.\n */\nexport async function tryDailyGift(userId = 'default-user', opts?: { force?: boolean }): Promise<{ attempted: boolean; reason: string }> {\n const brief = decideDailyGift(userId, Date.now(), opts);\n if (!brief) {\n return { attempted: false, reason: 'cooldown / not enough profile / soma blocked' };\n }\n logger.info(COMPONENT, `[DailyGift] attempting for ${userId}`);\n logGiftAttempt(userId, { phase: 'start' });\n\n const prompt = [\n `You have an opportunity to gift the user a custom widget right now.`,\n `This is a SPONTANEOUS, OPTIONAL gesture — not a response to a request.`,\n `Decline if nothing meaningful comes to mind. Better to skip than to ship something generic.`,\n ``,\n `What you know about THIS user:`,\n brief.context,\n ``,\n `If you decide to build, call \\`create_widget\\` ONCE with a name + source that genuinely reflects something specific to this user (a tracker for a thing they care about, a tool for a recurring task, a dashboard for a domain they work in). Title it something they'd recognize as personal.`,\n ``,\n `If you decline, just reply with one sentence explaining why (\"nothing strong enough today\"). Do NOT make excuses, do NOT promise a future gift.`,\n ].join('\\n');\n\n try {\n // Lazy-import to avoid circular deps at module load.\n const { processMessage } = await import('./agent.js');\n const result = await processMessage(prompt, 'soma-initiative', userId, {});\n const toolCalls = (result.toolsUsed || []).join(', ');\n const widgetMade = (result.toolsUsed || []).includes('create_widget');\n logGiftAttempt(userId, {\n phase: 'done',\n widgetMade,\n toolsUsed: toolCalls,\n content: (result.content || '').slice(0, 240),\n });\n logger.info(COMPONENT, `[DailyGift] ${widgetMade ? 'built a widget' : 'declined'} (tools: ${toolCalls})`);\n return { attempted: true, reason: widgetMade ? 'widget shipped' : 'agent declined' };\n } catch (err) {\n logGiftAttempt(userId, { phase: 'error', error: (err as Error).message });\n logger.warn(COMPONENT, `[DailyGift] error: ${(err as Error).message}`);\n return { attempted: false, reason: `error: ${(err as Error).message}` };\n }\n}\n\n// ─── Driver ────────────────────────────────────────────────────────\n\nlet timerHandle: NodeJS.Timeout | null = null;\nlet dailyGiftHandle: NodeJS.Timeout | null = null;\n\n/** Start the cron-driven pulse loop. Idempotent. */\nexport function startSomaInitiative(opts?: { intervalMs?: number; userId?: string; dailyGiftIntervalMs?: number }): void {\n if (timerHandle) return;\n const interval = opts?.intervalMs ?? PULSE_INTERVAL_MS;\n const userId = opts?.userId ?? 'default-user';\n\n timerHandle = setInterval(() => {\n try {\n // Idle detection is approximate — for now we treat every tick as\n // \"fully idle\" and let `decidePulse`'s conservative confidence\n // threshold do the throttling. Once gateway-side idle tracking\n // wires in (e.g. last /api/message timestamp), this can be\n // replaced with a real `idleMs`.\n executePulse(userId, Infinity);\n } catch (err) {\n logger.warn(COMPONENT, `Pulse error: ${(err as Error).message}`);\n }\n }, interval);\n if (timerHandle.unref) timerHandle.unref();\n logger.info(COMPONENT, `Started somaInitiative loop (interval=${Math.round(interval / 1000)}s, user=${userId}).`);\n\n // v6.0 — Daily proactive-widget gift loop. Fires once every\n // ~22h with jitter; the cooldown check inside decideDailyGift also\n // prevents over-firing if interval is short. Independent timer so\n // its long cadence doesn't gum up the 5-min advisory pulse.\n const giftInterval = opts?.dailyGiftIntervalMs ?? DAILY_GIFT_INTERVAL_MS;\n dailyGiftHandle = setInterval(() => {\n void tryDailyGift(userId);\n }, giftInterval);\n if (dailyGiftHandle.unref) dailyGiftHandle.unref();\n logger.info(COMPONENT, `Started daily-gift loop (interval=${Math.round(giftInterval / 3600_000)}h, user=${userId}).`);\n}\n\nexport function stopSomaInitiative(): void {\n if (timerHandle) { clearInterval(timerHandle); timerHandle = null; }\n if (dailyGiftHandle) { clearInterval(dailyGiftHandle); dailyGiftHandle = null; }\n}\n\nexport function __resetForTests(): void {\n stopSomaInitiative();\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/agent/somaInitiative.ts"],"sourcesContent":["/**\n * TITAN — somaInitiative (v6.0 step 11)\n *\n * Why this exists\n * ---------------\n * The Presence thesis: \"TITAN acts without being asked.\" This module is the\n * concrete engine. While the user is idle, a cron-driven scout fires every\n * N minutes:\n *\n * 1. Read the user's pattern aggregates (step 12)\n * 2. Read Soma drive state (step 14 + organism layer)\n * 3. Read time-of-day, recent activity, pending todos\n * 4. Decide if there's a useful surface to surface\n * 5. If yes — fire a side-channel widget event (existing widgetEmitter)\n * labelled \"Soma noticed…\" so the user sees it appeared\n * autonomously\n *\n * Most pulses end in \"nothing to do.\" That's by design — the loop is\n * conservative. Better to miss a chance to help than spam the user with\n * unwanted widgets.\n *\n * Reference:\n * - ~/.claude/projects/-Users-michaelelliott/memory/titan-v6-living-canvas.md\n * (v6.0 step 11)\n * - src/storage/patterns.ts — input signals\n * - src/storage/somaProfile.ts — drive baselines\n * - src/agent/widgetEmitter.ts — output channel\n */\nimport logger from '../utils/logger.js';\nimport { deriveSuggestions, recordSignal, aggregatePatterns } from '../storage/patterns.js';\nimport { readSomaProfile } from '../storage/somaProfile.js';\nimport { getActiveSpace } from '../storage/spaces.js';\n\nconst COMPONENT = 'SomaInitiative';\n\n/** How often the loop fires when active. 5 minutes by Tony's spec. */\nconst PULSE_INTERVAL_MS = 5 * 60 * 1000;\n\n/** Time-of-day buckets the heuristics check against (24h, local). */\nfunction timeBucket(now = new Date()): string {\n const hour = now.getHours();\n const day = now.getDay();\n const dayKind = day === 0 || day === 6 ? 'weekend' : 'weekday';\n return `${dayKind}-${hour.toString().padStart(2, '0')}`;\n}\n\n/** Single pulse — pure function of inputs, no side effects yet. */\nexport interface PulseDecision {\n /** What the loop decided to do this pulse. */\n action: 'nothing' | 'pin-widget' | 'create-space' | 'add-cron' | 'note';\n /** Human-readable explanation (used in logs + the `Soma noticed…` chip). */\n rationale?: string;\n /** Confidence the suggestion is welcome (0..1). */\n confidence?: number;\n /** Optional payload for the action — varies by kind. */\n payload?: Record<string, unknown>;\n}\n\n/**\n * Decide what (if anything) Soma should do this pulse. Pure function — no\n * side effects. The actual emission happens in `executePulse`.\n *\n * Inputs:\n * - userId — whose patterns/profile to read\n * - idleMs — milliseconds since last user activity\n * - opts.minIdleMs — only act when truly idle (default 2 min)\n */\nexport function decidePulse(\n userId = 'default-user',\n idleMs = Infinity,\n opts?: { minIdleMs?: number },\n): PulseDecision {\n const minIdle = opts?.minIdleMs ?? 2 * 60 * 1000;\n if (idleMs < minIdle) {\n return { action: 'nothing', rationale: 'user is active' };\n }\n\n // Record the time bucket as a pattern signal so the loop's own activity\n // becomes part of the user's pattern history. This is harmless and\n // helps timing-based suggestions emerge over time.\n recordSignal(userId, `time:${timeBucket()}`);\n\n const suggestions = deriveSuggestions(userId, { windowDays: 14 });\n if (suggestions.length === 0) {\n return { action: 'nothing', rationale: 'no patterns above confidence threshold' };\n }\n\n // Pick the single highest-confidence suggestion. Future iterations could\n // surface multiple, but conservative-first is the right default.\n const best = suggestions.sort((a, b) => b.confidence - a.confidence)[0];\n\n // Don't suggest the same thing repeatedly — dedup on signal in pattern\n // history. We rely on the pattern aggregation cooldown (only suggests\n // when count >= 3 in window) as the primary throttle; here we layer a\n // small extra check using the active Space's frustration baseline.\n const profile = readSomaProfile(userId);\n if (profile.baseline.frustration > 0.7) {\n // User has been frustrated. Don't pile on with proactive suggestions\n // — they'll feel like noise. Wait for the frustration to subside.\n return { action: 'nothing', rationale: 'soma frustration high — holding suggestions' };\n }\n\n return {\n action: best.kind === 'pin-widget' ? 'pin-widget'\n : best.kind === 'create-space' ? 'create-space'\n : best.kind === 'add-cron' ? 'add-cron'\n : 'note',\n rationale: best.rationale,\n confidence: best.confidence,\n payload: { signal: best.signal, activeSpaceId: getActiveSpace()?.id },\n };\n}\n\n/**\n * Fire one pulse + execute its decision. Used both by the cron driver and\n * by tests to step the loop deterministically.\n *\n * Returns the decision for telemetry / test assertions.\n */\nexport function executePulse(userId = 'default-user', idleMs = Infinity): PulseDecision {\n const d = decidePulse(userId, idleMs);\n if (d.action === 'nothing') {\n // Don't log every \"nothing happened\" tick — too noisy.\n return d;\n }\n logger.info(COMPONENT, `[Pulse] action=${d.action} confidence=${d.confidence?.toFixed(2)} rationale=\"${d.rationale}\"`);\n\n // For now the loop emits a logged advisory rather than auto-creating\n // widgets. Auto-creation requires:\n // - the gateway SSE stream to be open (otherwise the widget event\n // fires into the void)\n // - the agent to author the actual widget code, which it can't do\n // from a non-LLM context\n //\n // The right next step (v6.0+) is to enqueue these advisories so the\n // user sees them in a \"Soma noticed…\" panel; the agent's NEXT chat\n // turn can then pick them up and act. That keeps the loop's\n // side-effects observable + reversible.\n enqueueAdvisory(userId, d);\n return d;\n}\n\n// ─── Advisory queue ─────────────────────────────────────────────────\n\nimport { writeFileSync, readFileSync, existsSync, mkdirSync } from 'fs';\nimport { join } from 'path';\nimport { homedir } from 'os';\n\nfunction advisoriesPath(userId: string): string {\n const env = process.env.TITAN_HOME;\n const home = env\n ? (env.startsWith('~/') ? join(homedir(), env.slice(2)) : env)\n : join(homedir(), '.titan');\n return join(home, 'users', userId, 'soma-advisories.jsonl');\n}\n\n// v6.0.2 — Advisory dedup + retention windows.\n//\n// Pre-v6.0.2, `enqueueAdvisory` blindly appended every pulse decision.\n// Since `decidePulse` is stable (same activity pattern → same advisory)\n// the file accumulated hundreds of duplicate entries — 308 in production\n// at the time this fix shipped, of which only ~10 were unique. The\n// SomaAdvisoryToast widget polls this file and surfaces \"new\" entries,\n// so duplicates re-spam the user with the same suggestion every 30s.\n//\n// Fix: before appending, check whether the same (action, rationale) pair\n// was filed within `ADVISORY_DEDUP_WINDOW_MS` (12h default). If yes,\n// silently skip. ALSO prune entries older than `ADVISORY_RETENTION_MS`\n// (7 days) on every write so the file doesn't grow unbounded.\n//\n// Override either window via env (TITAN_SOMA_DEDUP_WINDOW_MS /\n// TITAN_SOMA_ADVISORY_RETENTION_MS) for testing or aggressive tuning.\n\nconst DEFAULT_DEDUP_WINDOW_MS = 12 * 60 * 60 * 1000; // 12h\nconst DEFAULT_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; // 7d\n\nfunction getDedupWindowMs(): number {\n const raw = process.env.TITAN_SOMA_DEDUP_WINDOW_MS;\n const n = raw ? Number(raw) : NaN;\n return Number.isFinite(n) && n >= 0 ? n : DEFAULT_DEDUP_WINDOW_MS;\n}\n\nfunction getRetentionMs(): number {\n const raw = process.env.TITAN_SOMA_ADVISORY_RETENTION_MS;\n const n = raw ? Number(raw) : NaN;\n return Number.isFinite(n) && n >= 0 ? n : DEFAULT_RETENTION_MS;\n}\n\ninterface AdvisoryRecord {\n at: string;\n action: string;\n rationale: string;\n confidence: number;\n payload?: Record<string, unknown>;\n}\n\nfunction parseAdvisoryFile(raw: string): AdvisoryRecord[] {\n if (!raw.trim()) return [];\n const out: AdvisoryRecord[] = [];\n for (const line of raw.split('\\n')) {\n const trimmed = line.trim();\n if (!trimmed) continue;\n try {\n const rec = JSON.parse(trimmed) as AdvisoryRecord;\n if (rec && typeof rec.at === 'string' && typeof rec.action === 'string') out.push(rec);\n } catch { /* skip corrupt line — never let one bad row break dedup */ }\n }\n return out;\n}\n\n/** Normalize rationale so trivial variations (whitespace, capitalization,\n * trailing punctuation) collapse to the same dedup key. */\nfunction dedupKey(action: string, rationale: string): string {\n const r = rationale.toLowerCase().replace(/\\s+/g, ' ').replace(/[.!?]+\\s*$/, '').trim();\n return `${action}::${r}`;\n}\n\nexport function enqueueAdvisory(userId: string, decision: PulseDecision, now: number = Date.now()): void {\n if (decision.action === 'nothing') return;\n const path = advisoriesPath(userId);\n try { mkdirSync(join(path, '..'), { recursive: true }); } catch { /* exists */ }\n\n // Normalize optionals up-front so dedup + persistence work with\n // concrete values (PulseDecision.rationale/confidence are optional\n // on the type, but every advisory needs a stable form on disk).\n const rationale = (decision.rationale ?? '').trim() || decision.action;\n const confidence = typeof decision.confidence === 'number' && Number.isFinite(decision.confidence) ? decision.confidence : 0.5;\n\n const dedupWindow = getDedupWindowMs();\n const retention = getRetentionMs();\n const incomingKey = dedupKey(decision.action, rationale);\n\n try {\n const existing = existsSync(path) ? parseAdvisoryFile(readFileSync(path, 'utf-8')) : [];\n\n // Prune entries older than retention so the file stays bounded.\n const retentionCutoff = now - retention;\n const retained = existing.filter(r => {\n const t = new Date(r.at).getTime();\n return Number.isFinite(t) && t >= retentionCutoff;\n });\n\n // Dedup: same (action, normalized-rationale) within the dedup window → skip.\n const dedupCutoff = now - dedupWindow;\n const isDup = retained.some(r => {\n const t = new Date(r.at).getTime();\n if (!Number.isFinite(t) || t < dedupCutoff) return false;\n return dedupKey(r.action, r.rationale) === incomingKey;\n });\n if (isDup) {\n // Common case during a steady-state behaviour pattern. Don't\n // spam the agent log either — debug level only.\n logger.debug(COMPONENT, `Skipping duplicate advisory within ${Math.round(dedupWindow / 3600_000)}h window: ${decision.action} — ${rationale.slice(0, 60)}`);\n // Rewrite the pruned file IF we actually pruned anything, so\n // the retention sweep still happens even when we're deduping.\n if (retained.length !== existing.length) {\n const body = retained.map(r => JSON.stringify(r)).join('\\n') + (retained.length > 0 ? '\\n' : '');\n writeFileSync(path, body);\n }\n return;\n }\n\n // New advisory — append.\n const fresh: AdvisoryRecord = {\n at: new Date(now).toISOString(),\n action: decision.action,\n rationale,\n confidence,\n payload: decision.payload,\n };\n const final = [...retained, fresh];\n const body = final.map(r => JSON.stringify(r)).join('\\n') + '\\n';\n writeFileSync(path, body);\n if (retained.length !== existing.length) {\n logger.info(COMPONENT, `Pruned ${existing.length - retained.length} stale advisory entries (>${Math.round(retention / 86400_000)}d old)`);\n }\n } catch (err) {\n logger.warn(COMPONENT, `Failed to enqueue advisory: ${(err as Error).message}`);\n }\n}\n\n/** Read recent advisories. Used by the agent loop to surface Soma's\n * observations in the next chat turn. Returns up to `limit` most-recent. */\nexport function readRecentAdvisories(userId: string, limit = 5): Array<{\n at: string;\n action: string;\n rationale: string;\n confidence: number;\n payload?: Record<string, unknown>;\n}> {\n const path = advisoriesPath(userId);\n if (!existsSync(path)) return [];\n try {\n const lines = readFileSync(path, 'utf-8').trim().split('\\n').filter(Boolean);\n return lines.slice(-limit).map(l => JSON.parse(l)).reverse();\n } catch {\n return [];\n }\n}\n\n// ─── Daily proactive-widget gift (v6.0) ────────────────────────────\n//\n// Tony's spec: \"I want the TITAN agent to make a customized widget for\n// its user, from info it knows about the user, every day or so if it\n// wants to.\"\n//\n// Mechanism:\n// 1. Once a day (default — 22h interval, slight jitter), the agent\n// considers gifting the user a custom widget.\n// 2. Pulls the user's Soma profile (long-term observations + baseline\n// drives), recent patterns, and active Space context.\n// 3. Composes a /api/message internal prompt asking the agent to\n// \"design and pin a widget you think the user would appreciate\n// based on what you know about them — or skip if nothing comes to\n// mind.\" Conservative bias: the agent SHOULD skip when in doubt.\n// 4. The agent (real LLM) either calls create_widget itself or\n// politely declines. Either way the decision is logged to\n// `~/.titan/users/<userId>/soma-gifts.jsonl` so we can audit.\n//\n// This is the \"TITAN gifts you a widget on its own, occasionally\"\n// behavior — different from the 5-min advisory pulse which only files\n// advisories without calling the LLM.\n\nconst DAILY_GIFT_INTERVAL_MS = 22 * 60 * 60 * 1000; // 22h, lets it drift\nconst DAILY_GIFT_MIN_GAP_MS = 18 * 60 * 60 * 1000; // never less than 18h between gifts\n\nfunction giftsPath(userId: string): string {\n const env = process.env.TITAN_HOME;\n const home = env\n ? (env.startsWith('~/') ? join(homedir(), env.slice(2)) : env)\n : join(homedir(), '.titan');\n return join(home, 'users', userId, 'soma-gifts.jsonl');\n}\n\n/** Read the most recent gift attempt timestamp (epoch ms), or 0 if none. */\nfunction lastGiftAt(userId: string): number {\n const path = giftsPath(userId);\n if (!existsSync(path)) return 0;\n try {\n const lines = readFileSync(path, 'utf-8').trim().split('\\n').filter(Boolean);\n const last = lines[lines.length - 1];\n if (!last) return 0;\n const entry = JSON.parse(last) as { at?: string };\n return entry.at ? Date.parse(entry.at) : 0;\n } catch { return 0; }\n}\n\nfunction logGiftAttempt(userId: string, payload: Record<string, unknown>): void {\n const path = giftsPath(userId);\n try { mkdirSync(join(path, '..'), { recursive: true }); } catch { /* exists */ }\n const line = JSON.stringify({ at: new Date().toISOString(), ...payload });\n try {\n const prev = existsSync(path) ? readFileSync(path, 'utf-8') : '';\n writeFileSync(path, prev + line + '\\n');\n } catch (err) {\n logger.warn(COMPONENT, `gift log write failed: ${(err as Error).message}`);\n }\n}\n\n/**\n * Decide whether to gift a custom widget today. Pure function — no side\n * effects. Returns null when we should NOT gift right now, or an object\n * with a one-paragraph brief the LLM can use to design the widget.\n */\nexport interface GiftBrief {\n /** Short rationale shown to the user when the widget lands. */\n rationale: string;\n /** Compact context the LLM sees — recent observations + patterns. */\n context: string;\n}\n\nexport function decideDailyGift(userId = 'default-user', now = Date.now(), opts?: { force?: boolean }): GiftBrief | null {\n const force = opts?.force === true;\n if (!force) {\n const since = now - lastGiftAt(userId);\n if (since < DAILY_GIFT_MIN_GAP_MS) return null;\n }\n\n // Pull what we know about the user.\n const profile = readSomaProfile(userId);\n // v6.0.3 — When force=true (manual user trigger from the SOMA panel),\n // skip the \"learned >= 3 observations\" gate. The user explicitly asked\n // for a gift; Soma should at least try, even on day-one with a thin\n // profile. The agent prompt still lets the LLM decline if nothing\n // meaningful comes to mind.\n if (!force && profile.learnedAboutUser.length < 3) {\n return null;\n }\n // Don't gift when frustrated — same throttling as advisories.\n // Manual trigger overrides this too.\n if (!force && profile.baseline.frustration > 0.7) return null;\n\n const recent = profile.learnedAboutUser.slice(-10);\n const aggs = aggregatePatterns(userId, { windowDays: 7, topN: 5 });\n const activeSpace = getActiveSpace();\n\n const context = [\n `User profile (most recent 10 observations):`,\n ...recent.map(o => ` - ${o}`),\n ``,\n `Top patterns last 7 days:`,\n ...(aggs.length === 0\n ? [' (none yet — user is new or quiet)']\n : aggs.map(a => ` - ${a.signal} ×${a.count}`)),\n ``,\n activeSpace ? `Active Space: ${activeSpace.name} (${activeSpace.widgets.length} widget(s) pinned).` : 'No active Space.',\n ].join('\\n');\n\n return {\n rationale: `Soma noticed it's been ~24h since the last surprise. Generating a widget from what's known about the user.`,\n context,\n };\n}\n\n/**\n * Actually execute the daily gift — invokes the LLM via the agent's own\n * `processMessage` flow with a brief that asks the agent to either build\n * a widget or politely decline. The agent's create_widget call (if it\n * makes one) routes through the normal widgetEmitter side-channel.\n */\nexport async function tryDailyGift(userId = 'default-user', opts?: { force?: boolean }): Promise<{ attempted: boolean; reason: string }> {\n const brief = decideDailyGift(userId, Date.now(), opts);\n if (!brief) {\n return { attempted: false, reason: 'cooldown / not enough profile / soma blocked' };\n }\n logger.info(COMPONENT, `[DailyGift] attempting for ${userId}`);\n logGiftAttempt(userId, { phase: 'start' });\n\n const prompt = [\n `You have an opportunity to gift the user a custom widget right now.`,\n `This is a SPONTANEOUS, OPTIONAL gesture — not a response to a request.`,\n `Decline if nothing meaningful comes to mind. Better to skip than to ship something generic.`,\n ``,\n `What you know about THIS user:`,\n brief.context,\n ``,\n `If you decide to build, call \\`create_widget\\` ONCE with a name + source that genuinely reflects something specific to this user (a tracker for a thing they care about, a tool for a recurring task, a dashboard for a domain they work in). Title it something they'd recognize as personal.`,\n ``,\n `If you decline, just reply with one sentence explaining why (\"nothing strong enough today\"). Do NOT make excuses, do NOT promise a future gift.`,\n ].join('\\n');\n\n try {\n // Lazy-import to avoid circular deps at module load.\n const { processMessage } = await import('./agent.js');\n const result = await processMessage(prompt, 'soma-initiative', userId, {});\n const toolCalls = (result.toolsUsed || []).join(', ');\n const widgetMade = (result.toolsUsed || []).includes('create_widget');\n logGiftAttempt(userId, {\n phase: 'done',\n widgetMade,\n toolsUsed: toolCalls,\n content: (result.content || '').slice(0, 240),\n });\n logger.info(COMPONENT, `[DailyGift] ${widgetMade ? 'built a widget' : 'declined'} (tools: ${toolCalls})`);\n return { attempted: true, reason: widgetMade ? 'widget shipped' : 'agent declined' };\n } catch (err) {\n logGiftAttempt(userId, { phase: 'error', error: (err as Error).message });\n logger.warn(COMPONENT, `[DailyGift] error: ${(err as Error).message}`);\n return { attempted: false, reason: `error: ${(err as Error).message}` };\n }\n}\n\n// ─── Driver ────────────────────────────────────────────────────────\n\nlet timerHandle: NodeJS.Timeout | null = null;\nlet dailyGiftHandle: NodeJS.Timeout | null = null;\n\n/** Start the cron-driven pulse loop. Idempotent. */\nexport function startSomaInitiative(opts?: { intervalMs?: number; userId?: string; dailyGiftIntervalMs?: number }): void {\n if (timerHandle) return;\n const interval = opts?.intervalMs ?? PULSE_INTERVAL_MS;\n const userId = opts?.userId ?? 'default-user';\n\n timerHandle = setInterval(() => {\n try {\n // Idle detection is approximate — for now we treat every tick as\n // \"fully idle\" and let `decidePulse`'s conservative confidence\n // threshold do the throttling. Once gateway-side idle tracking\n // wires in (e.g. last /api/message timestamp), this can be\n // replaced with a real `idleMs`.\n executePulse(userId, Infinity);\n } catch (err) {\n logger.warn(COMPONENT, `Pulse error: ${(err as Error).message}`);\n }\n }, interval);\n if (timerHandle.unref) timerHandle.unref();\n logger.info(COMPONENT, `Started somaInitiative loop (interval=${Math.round(interval / 1000)}s, user=${userId}).`);\n\n // v6.0 — Daily proactive-widget gift loop. Fires once every\n // ~22h with jitter; the cooldown check inside decideDailyGift also\n // prevents over-firing if interval is short. Independent timer so\n // its long cadence doesn't gum up the 5-min advisory pulse.\n const giftInterval = opts?.dailyGiftIntervalMs ?? DAILY_GIFT_INTERVAL_MS;\n dailyGiftHandle = setInterval(() => {\n void tryDailyGift(userId);\n }, giftInterval);\n if (dailyGiftHandle.unref) dailyGiftHandle.unref();\n logger.info(COMPONENT, `Started daily-gift loop (interval=${Math.round(giftInterval / 3600_000)}h, user=${userId}).`);\n}\n\nexport function stopSomaInitiative(): void {\n if (timerHandle) { clearInterval(timerHandle); timerHandle = null; }\n if (dailyGiftHandle) { clearInterval(dailyGiftHandle); dailyGiftHandle = null; }\n}\n\nexport function __resetForTests(): void {\n stopSomaInitiative();\n}\n"],"mappings":"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","names":["body"]}
@@ -4,12 +4,12 @@ import {
4
4
  DEFAULT_GATEWAY_HOST,
5
5
  DEFAULT_GATEWAY_PORT,
6
6
  DEFAULT_WEB_PORT,
7
- DEFAULT_MODEL,
8
7
  DEFAULT_MAX_TOKENS,
9
8
  DEFAULT_TEMPERATURE,
10
9
  DEFAULT_SANDBOX_MODE,
11
10
  ALLOWED_TOOLS_DEFAULT
12
11
  } from "../utils/constants.js";
12
+ import { getDefaultModelId, getDefaultModelAliases } from "../providers/defaultModel.js";
13
13
  const AuthProfileSchema = z.object({
14
14
  name: z.string(),
15
15
  apiKey: z.string(),
@@ -157,7 +157,12 @@ const GatewayConfigSchema = z.object({
157
157
  }).default({})
158
158
  });
159
159
  const AgentConfigSchema = z.object({
160
- model: z.string().default(DEFAULT_MODEL),
160
+ // v6.0.1 — Provider-agnostic default. `DEFAULT_MODEL` is the
161
+ // hardcoded fallback (anthropic/claude-sonnet-4); `getDefaultModelId()`
162
+ // picks based on the user's actual environment (ANTHROPIC_API_KEY →
163
+ // anthropic, OPENAI_API_KEY → openai, etc.) and falls back to local
164
+ // Ollama if no cloud keys are set.
165
+ model: z.string().default(() => getDefaultModelId()),
161
166
  maxTokens: z.number().default(DEFAULT_MAX_TOKENS),
162
167
  temperature: z.number().min(0).max(2).default(DEFAULT_TEMPERATURE),
163
168
  systemPrompt: z.string().optional(),
@@ -192,21 +197,10 @@ const AgentConfigSchema = z.object({
192
197
  // whole record on any user override, so once a user customized aliases
193
198
  // their file would LOSE the built-ins. Use .transform() to merge user
194
199
  // overrides on top of the built-ins.
195
- modelAliases: z.record(z.string(), z.string()).default({
196
- fast: "ollama/qwen3.5:cloud",
197
- smart: "ollama/glm-5:cloud",
198
- reasoning: "ollama/kimi-k2.6:cloud",
199
- cheap: "ollama/qwen3.5:cloud",
200
- local: "ollama/qwen3.5:4b",
201
- cloud: "ollama/kimi-k2.6:cloud"
202
- }).transform((userAliases) => ({
203
- // Ollama cloud-first built-ins (always present as a floor)
204
- fast: "ollama/qwen3.5:cloud",
205
- smart: "ollama/glm-5:cloud",
206
- cheap: "ollama/qwen3.5:cloud",
207
- reasoning: "ollama/kimi-k2.6:cloud",
208
- local: "ollama/qwen3.5:4b",
209
- cloud: "ollama/kimi-k2.6:cloud",
200
+ modelAliases: z.record(z.string(), z.string()).default(() => getDefaultModelAliases()).transform((userAliases) => ({
201
+ // Provider-aware floor (re-resolved on transform so a config
202
+ // file with partial aliases still gets sensible fallbacks).
203
+ ...getDefaultModelAliases(),
210
204
  // User overrides win
211
205
  ...userAliases
212
206
  })),