titan-agent 5.3.2 → 5.4.1

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.
Files changed (48) hide show
  1. package/dist/agent/agent.js +11 -1
  2. package/dist/agent/agent.js.map +1 -1
  3. package/dist/agent/agentLoop.js +36 -1
  4. package/dist/agent/agentLoop.js.map +1 -1
  5. package/dist/agent/session.js +106 -5
  6. package/dist/agent/session.js.map +1 -1
  7. package/dist/agent/subAgent.js +62 -1
  8. package/dist/agent/subAgent.js.map +1 -1
  9. package/dist/config/config.js +30 -8
  10. package/dist/config/config.js.map +1 -1
  11. package/dist/config/schema.js +25 -2
  12. package/dist/config/schema.js.map +1 -1
  13. package/dist/gateway/server.js +32 -1
  14. package/dist/gateway/server.js.map +1 -1
  15. package/dist/memory/graph.js +49 -15
  16. package/dist/memory/graph.js.map +1 -1
  17. package/dist/memory/index.js +192 -0
  18. package/dist/memory/index.js.map +1 -0
  19. package/dist/memory/memory.js +1 -0
  20. package/dist/memory/memory.js.map +1 -1
  21. package/dist/mesh/transport.js +60 -8
  22. package/dist/mesh/transport.js.map +1 -1
  23. package/dist/providers/anthropic.js +3 -2
  24. package/dist/providers/anthropic.js.map +1 -1
  25. package/dist/providers/base.js.map +1 -1
  26. package/dist/providers/google.js +94 -20
  27. package/dist/providers/google.js.map +1 -1
  28. package/dist/providers/modelCapabilities.js +59 -0
  29. package/dist/providers/modelCapabilities.js.map +1 -0
  30. package/dist/providers/ollama.js +3 -2
  31. package/dist/providers/ollama.js.map +1 -1
  32. package/dist/providers/openai.js +4 -3
  33. package/dist/providers/openai.js.map +1 -1
  34. package/dist/providers/openai_compat.js +3 -2
  35. package/dist/providers/openai_compat.js.map +1 -1
  36. package/dist/providers/router.js +63 -21
  37. package/dist/providers/router.js.map +1 -1
  38. package/dist/safety/fabricationGuard.js +140 -0
  39. package/dist/safety/fabricationGuard.js.map +1 -0
  40. package/dist/skills/builtin/gepa.js +23 -1
  41. package/dist/skills/builtin/gepa.js.map +1 -1
  42. package/dist/skills/builtin/model_trainer.js +31 -4
  43. package/dist/skills/builtin/model_trainer.js.map +1 -1
  44. package/dist/skills/builtin/self_improve.js +50 -2
  45. package/dist/skills/builtin/self_improve.js.map +1 -1
  46. package/dist/utils/constants.js +2 -2
  47. package/dist/utils/constants.js.map +1 -1
  48. package/package.json +1 -1
@@ -1,12 +1,35 @@
1
1
  #!/usr/bin/env node
2
2
  import { v4 as uuid } from "uuid";
3
- import { getDb, getHistory, saveMessage, updateSessionMeta } from "../memory/memory.js";
3
+ import { getDb, getHistory, saveMessage, updateSessionMeta, debouncedSave } from "../memory/memory.js";
4
+ const SESSION_IDLE_PURGE_MS = 7 * 24 * 60 * 60 * 1e3;
4
5
  import { MAX_CONTEXT_MESSAGES, SESSION_TIMEOUT_MS } from "../utils/constants.js";
5
6
  import { generateKey } from "../security/encryption.js";
6
7
  import { resetLoopDetection } from "./loopDetection.js";
7
8
  import logger from "../utils/logger.js";
8
9
  const COMPONENT = "Session";
9
10
  const activeSessions = /* @__PURE__ */ new Map();
11
+ const PERSISTENT_CHANNELS_EXACT = /* @__PURE__ */ new Set([
12
+ "webchat",
13
+ "voice",
14
+ "discord",
15
+ "telegram",
16
+ "slack",
17
+ "whatsapp",
18
+ "matrix",
19
+ "irc",
20
+ "line",
21
+ "zulip",
22
+ "mattermost",
23
+ "rocketchat",
24
+ "twilio",
25
+ "sms",
26
+ "email"
27
+ ]);
28
+ function isEphemeralChannel(channel) {
29
+ return !PERSISTENT_CHANNELS_EXACT.has(channel);
30
+ }
31
+ const EPHEMERAL_TTL_MS = 5 * 60 * 1e3;
32
+ const EPHEMERAL_MAX_ACTIVE = 100;
10
33
  const UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
11
34
  function isDefaultSession(s) {
12
35
  if (s.is_named === true) return false;
@@ -221,16 +244,89 @@ function cleanupStaleSessions() {
221
244
  let cleaned = 0;
222
245
  for (const s of store.sessions) {
223
246
  if (s.status === "active") {
247
+ const ttl = isEphemeralChannel(s.channel) ? EPHEMERAL_TTL_MS : SESSION_TIMEOUT_MS;
224
248
  const lastActive = new Date(s.last_active || s.created_at).getTime();
225
- if (now - lastActive > SESSION_TIMEOUT_MS) {
249
+ if (now - lastActive > ttl) {
226
250
  s.status = "idle";
227
251
  cleaned++;
228
252
  }
229
253
  }
230
254
  }
231
- if (cleaned > 0) {
232
- logger.info(COMPONENT, `Cleaned up ${cleaned} stale session(s)`);
255
+ const keysToDelete = [];
256
+ for (const [key, session] of activeSessions.entries()) {
257
+ const ttl = isEphemeralChannel(session.channel) ? EPHEMERAL_TTL_MS : SESSION_TIMEOUT_MS;
258
+ const lastActive = new Date(session.lastActive || session.createdAt).getTime();
259
+ if (now - lastActive > ttl) {
260
+ keysToDelete.push(key);
261
+ }
262
+ }
263
+ for (const key of keysToDelete) {
264
+ activeSessions.delete(key);
265
+ }
266
+ const ephemeralEntries = [];
267
+ const seenSessionIds = /* @__PURE__ */ new Set();
268
+ for (const [key, session] of activeSessions.entries()) {
269
+ if (!isEphemeralChannel(session.channel)) continue;
270
+ if (seenSessionIds.has(session.id)) continue;
271
+ seenSessionIds.add(session.id);
272
+ ephemeralEntries.push({
273
+ key,
274
+ session,
275
+ lastActive: new Date(session.lastActive || session.createdAt).getTime()
276
+ });
277
+ }
278
+ let lruEvicted = 0;
279
+ if (ephemeralEntries.length > EPHEMERAL_MAX_ACTIVE) {
280
+ ephemeralEntries.sort((a, b) => a.lastActive - b.lastActive);
281
+ const toEvict = ephemeralEntries.slice(0, ephemeralEntries.length - EPHEMERAL_MAX_ACTIVE);
282
+ for (const { session } of toEvict) {
283
+ const allKeys = [];
284
+ for (const [k, v] of activeSessions.entries()) {
285
+ if (v.id === session.id) allKeys.push(k);
286
+ }
287
+ for (const k of allKeys) activeSessions.delete(k);
288
+ lruEvicted++;
289
+ }
290
+ }
291
+ const beforePurge = store.sessions.length;
292
+ store.sessions = store.sessions.filter((s) => {
293
+ if (s.status !== "idle") return true;
294
+ const lastActive = new Date(s.last_active || s.created_at).getTime();
295
+ return now - lastActive < SESSION_IDLE_PURGE_MS;
296
+ });
297
+ const purged = beforePurge - store.sessions.length;
298
+ if (cleaned > 0 || keysToDelete.length > 0 || lruEvicted > 0 || purged > 0) {
299
+ logger.info(
300
+ COMPONENT,
301
+ `Cleaned up ${cleaned} stale session(s), evicted ${keysToDelete.length} from cache, LRU-evicted ${lruEvicted} ephemeral over cap, purged ${purged} old idle session(s)`
302
+ );
303
+ }
304
+ if (cleaned > 0 || purged > 0) {
305
+ debouncedSave();
306
+ }
307
+ }
308
+ function sweepSessions(opts = {}) {
309
+ const now = Date.now();
310
+ const idleThreshold = opts.idleMs ?? 0;
311
+ const sessionIdsToClose = /* @__PURE__ */ new Set();
312
+ for (const session of activeSessions.values()) {
313
+ if (sessionIdsToClose.has(session.id)) continue;
314
+ if (!opts.force && !isEphemeralChannel(session.channel)) continue;
315
+ if (opts.channel && session.channel !== opts.channel) continue;
316
+ if (opts.channelPrefix && !session.channel.startsWith(opts.channelPrefix)) continue;
317
+ const lastActive = new Date(session.lastActive || session.createdAt).getTime();
318
+ if (now - lastActive < idleThreshold) continue;
319
+ sessionIdsToClose.add(session.id);
320
+ }
321
+ let closed = 0;
322
+ for (const id of sessionIdsToClose) {
323
+ closeSession(id);
324
+ closed++;
325
+ }
326
+ if (closed > 0) {
327
+ logger.info(COMPONENT, `Sweep closed ${closed} session(s) \u2014 channel=${opts.channel ?? opts.channelPrefix ?? "*"} idleMs=${idleThreshold} force=${!!opts.force}`);
233
328
  }
329
+ return { closed };
234
330
  }
235
331
  function renameSession(sessionId, name) {
236
332
  const store = getDb();
@@ -375,6 +471,9 @@ function pruneGuestSessions() {
375
471
  }
376
472
  }
377
473
  export {
474
+ EPHEMERAL_MAX_ACTIVE,
475
+ EPHEMERAL_TTL_MS,
476
+ activeSessions,
378
477
  addMessage,
379
478
  cleanupStaleSessions,
380
479
  closeSession,
@@ -384,6 +483,7 @@ export {
384
483
  getOrCreateSession,
385
484
  getOrCreateSessionById,
386
485
  getSessionById,
486
+ isEphemeralChannel,
387
487
  isGuestSession,
388
488
  listSessions,
389
489
  pruneGuestSessions,
@@ -391,6 +491,7 @@ export {
391
491
  replaceSessionContext,
392
492
  setSessionModelOverride,
393
493
  setSessionThinkingOverride,
394
- setSessionVerbose
494
+ setSessionVerbose,
495
+ sweepSessions
395
496
  };
396
497
  //# sourceMappingURL=session.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/agent/session.ts"],"sourcesContent":["/**\n * TITAN — Session Manager\n * Manages per-user/per-channel isolated sessions with history and context.\n */\nimport { v4 as uuid } from 'uuid';\nimport { getDb, getHistory, saveMessage, updateSessionMeta } from '../memory/memory.js';\nimport type { ChatMessage } from '../providers/base.js';\nimport { MAX_CONTEXT_MESSAGES, SESSION_TIMEOUT_MS } from '../utils/constants.js';\nimport { generateKey } from '../security/encryption.js';\nimport { resetLoopDetection } from './loopDetection.js';\nimport logger from '../utils/logger.js';\n// chat imported dynamically to avoid circular dependency\n\nconst COMPONENT = 'Session';\n\nexport interface Session {\n id: string;\n channel: string;\n userId: string;\n agentId: string;\n status: 'active' | 'idle' | 'closed';\n messageCount: number;\n createdAt: string;\n lastActive: string;\n name?: string;\n lastMessage?: string;\n e2eKey?: string; // Stored only in memory for active sessions\n /** Team ID if this session belongs to a team (for RBAC) */\n teamId?: string;\n // Per-session overrides (in-memory only, reset when session closes/times out)\n modelOverride?: string;\n thinkingOverride?: 'off' | 'low' | 'medium' | 'high';\n verboseMode?: boolean;\n}\n\n/** Active sessions cache */\nconst activeSessions: Map<string, Session> = new Map();\n\n/** Create or retrieve a session */\n// Hunt Finding #19 (2026-04-14): UUID v4 pattern used to distinguish\n// auto-generated default sessions from caller-supplied named sessions.\n// Needed for backward compatibility with sessions created BEFORE the\n// is_named flag was added — those don't have the flag but can still be\n// identified by ID shape (non-UUID = named by caller).\nconst UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\n\nfunction isDefaultSession(s: { id: string } & { is_named?: boolean }): boolean {\n // Explicitly marked as named via the flag → not a default session.\n if (s.is_named === true) return false;\n // Pre-#19 sessions don't have the flag. Fall back to ID-shape detection:\n // auto-generated defaults use uuid(), named sessions use caller-supplied\n // strings that rarely match UUID v4.\n return UUID_V4_PATTERN.test(s.id);\n}\n\nexport function getOrCreateSession(channel: string, userId: string, agentId: string = 'default', isEncrypted: boolean = false): Session {\n const sessionKey = `${channel}:${userId}:${agentId}`;\n\n // Check cache\n const cached = activeSessions.get(sessionKey);\n if (cached && cached.status === 'active') {\n return cached;\n }\n\n // Check data store. Hunt Finding #19 (2026-04-14): exclude named sessions\n // (those created via getOrCreateSessionById with an explicit ID). A named\n // session belongs to whoever holds its ID — it must NOT be returned as the\n // default for the channel+user+agent slot, or a subsequent no-sessionId\n // request inherits the previous named caller's conversation history.\n const store = getDb();\n const existing = store.sessions.find(\n (s) => s.channel === channel\n && s.user_id === userId\n && s.agent_id === agentId\n && s.status === 'active'\n && isDefaultSession(s as unknown as { id: string; is_named?: boolean }),\n );\n\n if (existing) {\n const lastActive = new Date(existing.last_active || existing.created_at).getTime();\n if (Date.now() - lastActive > SESSION_TIMEOUT_MS) {\n existing.status = 'idle';\n logger.debug(COMPONENT, `Session ${existing.id} timed out, creating new one`);\n // Fall through to create a new session\n } else {\n const session: Session = {\n id: existing.id,\n channel: existing.channel,\n userId: existing.user_id,\n agentId: existing.agent_id,\n status: existing.status as 'active',\n messageCount: existing.message_count,\n createdAt: existing.created_at,\n lastActive: existing.last_active,\n name: existing.name,\n lastMessage: existing.last_message,\n // D3: Restore persisted overrides on session recovery\n modelOverride: (existing as unknown as Record<string, unknown>).model_override as string | undefined,\n thinkingOverride: (existing as unknown as Record<string, unknown>).thinking_override as Session['thinkingOverride'],\n // Note: If a session was encrypted but dropped from memory, we cannot recover the key\n // A robust implementation would involve key exchange, but for now we warn:\n e2eKey: undefined\n };\n if (isEncrypted) {\n logger.warn(COMPONENT, `Recovered session ${existing.id}, but E2E key was lost from memory.`);\n }\n activeSessions.set(sessionKey, session);\n return session;\n }\n }\n\n // Create new session\n const session: Session = {\n id: uuid(),\n channel,\n userId,\n agentId,\n status: 'active',\n messageCount: 0,\n createdAt: new Date().toISOString(),\n lastActive: new Date().toISOString(),\n };\n\n if (isEncrypted) {\n try {\n session.e2eKey = generateKey().toString('base64');\n logger.info(COMPONENT, `Generated E2E key for session ${session.id}`);\n } catch (err) {\n logger.error(COMPONENT, `Failed to generate E2E key: ${err} — session will proceed without encryption`);\n // e2eKey remains undefined; addMessage/getContextMessages handle undefined gracefully\n }\n }\n\n store.sessions.push({\n id: session.id,\n channel,\n user_id: userId,\n agent_id: agentId,\n status: 'active',\n message_count: 0,\n created_at: session.createdAt,\n last_active: session.lastActive,\n });\n\n activeSessions.set(sessionKey, session);\n logger.info(COMPONENT, `Created new session: ${session.id} (${channel}/${userId})`);\n return session;\n}\n\n/** Create a new session (always fresh — never reuses existing) */\nexport function createNewSession(channel: string, userId: string, agentId: string = 'default'): Session {\n const session: Session = {\n id: uuid(),\n channel,\n userId,\n agentId,\n status: 'active',\n messageCount: 0,\n createdAt: new Date().toISOString(),\n lastActive: new Date().toISOString(),\n };\n\n const store = getDb();\n store.sessions.push({\n id: session.id,\n channel,\n user_id: userId,\n agent_id: agentId,\n status: 'active',\n message_count: 0,\n created_at: session.createdAt,\n last_active: session.lastActive,\n });\n\n // Cache by ID so getSessionById can find it\n activeSessions.set(`id:${session.id}`, session);\n // Also set as the active session for this channel/user combo\n activeSessions.set(`${channel}:${userId}:${agentId}`, session);\n\n logger.info(COMPONENT, `Created new session (explicit): ${session.id} (${channel}/${userId})`);\n return session;\n}\n\n/**\n * Get a session by ID, or create a new one with that exact ID if it doesn't exist.\n *\n * Hunt Finding #06 (2026-04-14): clients that pass an explicit sessionId to\n * /api/message previously had their ID silently ignored when the session\n * didn't exist — processMessage fell through to getOrCreateSession(channel,\n * userId, agentId), which returned the default session for that channel+user\n * combo. The client's intent to start a fresh isolated session was dropped\n * and old context polluted the new request.\n *\n * This helper preserves the requested ID: if the session exists, return it;\n * if not, create a brand-new session and register it under the requested ID.\n */\nexport function getOrCreateSessionById(\n sessionId: string,\n channel: string,\n userId: string,\n agentId: string = 'default',\n): Session {\n const existing = getSessionById(sessionId);\n if (existing) return existing;\n\n const session: Session = {\n id: sessionId,\n channel,\n userId,\n agentId,\n status: 'active',\n messageCount: 0,\n createdAt: new Date().toISOString(),\n lastActive: new Date().toISOString(),\n };\n\n const store = getDb();\n store.sessions.push({\n id: session.id,\n channel,\n user_id: userId,\n agent_id: agentId,\n status: 'active',\n message_count: 0,\n created_at: session.createdAt,\n last_active: session.lastActive,\n // Hunt Finding #19 (2026-04-14): mark as named so the default-slot\n // lookup in getOrCreateSession doesn't return this to an unrelated\n // no-sessionId caller.\n is_named: true,\n } as Parameters<typeof store.sessions.push>[0]);\n\n // Hunt Finding #19 (2026-04-14): register ONLY under the id: key. Do NOT\n // overwrite the default channel+user+agent slot — that's what was causing\n // no-sessionId requests to inherit the most recent named session. The\n // previous behavior claimed to \"avoid creating another session for the\n // same user\" but that convenience cost was a privacy leak between API\n // callers sharing the api-user:default fallback.\n activeSessions.set(`id:${session.id}`, session);\n\n logger.info(COMPONENT, `Created new session with explicit ID: ${session.id} (${channel}/${userId})`);\n return session;\n}\n\n/** Get a session by its ID (for session switching) */\nexport function getSessionById(sessionId: string): Session | null {\n // Check cache first\n const cached = activeSessions.get(`id:${sessionId}`);\n if (cached) return cached;\n\n // Check data store\n const store = getDb();\n const existing = store.sessions.find(s => s.id === sessionId);\n if (!existing) return null;\n\n const session: Session = {\n id: existing.id,\n channel: existing.channel,\n userId: existing.user_id,\n agentId: existing.agent_id,\n status: existing.status as 'active' | 'idle' | 'closed',\n messageCount: existing.message_count,\n createdAt: existing.created_at,\n lastActive: existing.last_active,\n name: existing.name,\n lastMessage: existing.last_message,\n };\n\n // Cache for future lookups\n activeSessions.set(`id:${sessionId}`, session);\n\n return session;\n}\n\n/** Add a message to a session */\nexport function addMessage(\n session: Session,\n role: 'user' | 'assistant' | 'system' | 'tool',\n content: string,\n extra?: { toolCalls?: string; toolCallId?: string; model?: string; tokenCount?: number }\n): void {\n const messageId = uuid();\n saveMessage({\n id: messageId,\n sessionId: session.id,\n role,\n content,\n toolCalls: extra?.toolCalls,\n toolCallId: extra?.toolCallId,\n model: extra?.model,\n tokenCount: extra?.tokenCount || 0,\n }, session.e2eKey);\n\n // Update session\n session.messageCount++;\n session.lastActive = new Date().toISOString();\n\n const store = getDb();\n const sessionRec = store.sessions.find((s) => s.id === session.id);\n if (sessionRec) {\n sessionRec.message_count = session.messageCount;\n sessionRec.last_active = session.lastActive;\n }\n\n // Auto-name session from first user message; track last user message snippet\n if (role === 'user') {\n const snippet = content.slice(0, 60) + (content.length > 60 ? '…' : '');\n const meta: { name?: string; last_message?: string } = { last_message: snippet };\n if (!session.name) {\n // Generate a concise title via LLM (fire-and-forget, fallback to truncation)\n const cleaned = content.replace(/^\\[voice\\/voice-user\\]\\s*/i, '').replace(/^\\[api\\/api-user\\]\\s*/i, '');\n const fallbackTitle = cleaned.charAt(0).toUpperCase() + cleaned.slice(1, 47) + (cleaned.length > 47 ? '…' : '');\n session.name = fallbackTitle;\n meta.name = fallbackTitle;\n // Async LLM title generation — updates session name when ready\n import('../providers/router.js').then(({ chat: chatFn }) => chatFn({ model: 'fast', messages: [{ role: 'user', content: `Generate a concise 5-word title for this conversation. Only output the title, nothing else. Message: ${cleaned.slice(0, 200)}` }], maxTokens: 30, temperature: 0.7 }).then(res => {\n if (res.content && res.content.length > 0 && res.content.length < 60) {\n session.name = res.content.trim();\n updateSessionMeta(session.id, { name: session.name });\n logger.info('Session', `LLM title for ${session.id.slice(0, 8)}: \"${session.name}\"`);\n }\n })).catch(() => { /* fallback title already set */ });\n }\n session.lastMessage = snippet;\n updateSessionMeta(session.id, meta);\n }\n}\n\n/** Get the context messages for a session (for sending to LLM) */\nexport function getContextMessages(session: Session, maxMessages: number = MAX_CONTEXT_MESSAGES): ChatMessage[] {\n const history = getHistory(session.id, maxMessages, session.e2eKey);\n return history.map((msg) => ({\n role: msg.role as ChatMessage['role'],\n content: msg.content,\n toolCallId: msg.toolCallId || undefined,\n toolCalls: msg.toolCalls ? JSON.parse(msg.toolCalls) : undefined,\n }));\n}\n\n/** Mark sessions that have been inactive > SESSION_TIMEOUT_MS as idle */\nexport function cleanupStaleSessions(): void {\n const store = getDb();\n const now = Date.now();\n let cleaned = 0;\n for (const s of store.sessions) {\n if (s.status === 'active') {\n const lastActive = new Date(s.last_active || s.created_at).getTime();\n if (now - lastActive > SESSION_TIMEOUT_MS) {\n s.status = 'idle';\n cleaned++;\n }\n }\n }\n if (cleaned > 0) {\n logger.info(COMPONENT, `Cleaned up ${cleaned} stale session(s)`);\n }\n}\n\n/** Rename a session */\nexport function renameSession(sessionId: string, name: string): boolean {\n const store = getDb();\n const s = store.sessions.find((s) => s.id === sessionId);\n if (!s) return false;\n s.name = name.trim().slice(0, 100);\n updateSessionMeta(sessionId, { name: s.name });\n // Update in-memory cache too\n for (const session of activeSessions.values()) {\n if (session.id === sessionId) {\n session.name = s.name;\n break;\n }\n }\n logger.info(COMPONENT, `Renamed session ${sessionId.slice(0, 8)} → \"${s.name}\"`);\n return true;\n}\n\n/** List all active sessions */\nexport function listSessions(): Session[] {\n cleanupStaleSessions();\n const store = getDb();\n return store.sessions\n .filter((s) => s.status === 'active' || s.status === 'idle')\n .sort((a, b) => b.last_active.localeCompare(a.last_active))\n .map((s) => {\n // Backfill name/lastMessage from conversation history for sessions created before this feature\n if (!s.name) {\n const msgs = store.conversations.filter(m => m.sessionId === s.id && m.role === 'user');\n if (msgs.length > 0) {\n const first = msgs[0].content.slice(0, 60) + (msgs[0].content.length > 60 ? '…' : '');\n const last = msgs[msgs.length - 1].content.slice(0, 60) + (msgs[msgs.length - 1].content.length > 60 ? '…' : '');\n s.name = first;\n s.last_message = last;\n }\n }\n return {\n id: s.id,\n channel: s.channel,\n userId: s.user_id,\n agentId: s.agent_id,\n status: s.status as 'active',\n messageCount: s.message_count,\n createdAt: s.created_at,\n lastActive: s.last_active,\n name: s.name,\n lastMessage: s.last_message,\n };\n });\n}\n\n/** Set a model override for the current session */\nexport function setSessionModelOverride(channel: string, userId: string, model: string): void {\n const session = getOrCreateSession(channel, userId, 'default');\n session.modelOverride = model;\n // D3: Persist to database so override survives session recovery\n updateSessionMeta(session.id, { model_override: model });\n logger.info(COMPONENT, `Session ${session.id.slice(0, 8)}: model override → ${model}`);\n}\n\n/** Set a thinking mode override for the current session */\nexport function setSessionThinkingOverride(channel: string, userId: string, level: 'off' | 'low' | 'medium' | 'high'): void {\n const session = getOrCreateSession(channel, userId, 'default');\n session.thinkingOverride = level;\n // D3: Persist to database so override survives session recovery\n updateSessionMeta(session.id, { thinking_override: level });\n logger.info(COMPONENT, `Session ${session.id.slice(0, 8)}: thinking override → ${level}`);\n}\n\n/** Set verbose mode for the current session */\nexport function setSessionVerbose(channel: string, userId: string, on: boolean): void {\n const session = getOrCreateSession(channel, userId, 'default');\n session.verboseMode = on;\n logger.info(COMPONENT, `Session ${session.id.slice(0, 8)}: verbose → ${on}`);\n}\n\n/** Replace session message context with compacted messages (used by /compact) */\nexport function replaceSessionContext(session: Session, messages: ChatMessage[]): void {\n // Clear existing history from the DB for this session\n const store = getDb();\n store.conversations = store.conversations.filter((m) => m.sessionId !== session.id);\n\n // Re-insert compacted messages\n for (const msg of messages) {\n if (msg.role === 'system') continue; // system prompts are rebuilt each turn\n saveMessage({\n id: uuid(),\n sessionId: session.id,\n role: msg.role,\n content: msg.content || '',\n toolCalls: msg.toolCalls ? JSON.stringify(msg.toolCalls) : undefined,\n toolCallId: msg.toolCallId,\n tokenCount: 0,\n }, session.e2eKey);\n }\n\n // Update session message count\n session.messageCount = messages.filter((m) => m.role !== 'system').length;\n const sessionRec = store.sessions.find((s) => s.id === session.id);\n if (sessionRec) sessionRec.message_count = session.messageCount;\n\n logger.info(COMPONENT, `Session ${session.id.slice(0, 8)}: context replaced (${session.messageCount} messages)`);\n}\n\n/** Close a session */\nexport function closeSession(sessionId: string): void {\n const store = getDb();\n const sessionRec = store.sessions.find((s) => s.id === sessionId);\n if (sessionRec) {\n sessionRec.status = 'closed';\n }\n\n // Delete ALL cache entries for this session (both id: and channel:user:agent keys)\n const keysToDelete: string[] = [];\n for (const [key, session] of activeSessions) {\n if (session.id === sessionId) {\n keysToDelete.push(key);\n }\n }\n for (const key of keysToDelete) {\n activeSessions.delete(key);\n }\n\n // D1: Clean up loop detection state to prevent unbounded memory growth\n resetLoopDetection(sessionId);\n\n // Flush accumulated tool analytics for this session (fire-and-forget)\n import('../analytics/featureTracker.js')\n .then(({ endToolSession }) => endToolSession(sessionId))\n .catch(() => {});\n\n logger.info(COMPONENT, `Closed session: ${sessionId}`);\n}\n\n// v5.0: Guest session support (Space Agent parity)\nconst GUEST_PREFIX = 'guest_';\nconst GUEST_MAX_AGE_MS = 72 * 60 * 60 * 1000; // 72h\nconst GUEST_MAX_FILES = 1000;\n\nexport function createGuestSession(): Session {\n const id = `${GUEST_PREFIX}${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;\n const session: Session = {\n id,\n channel: 'webchat',\n userId: id,\n agentId: 'default',\n status: 'active',\n messageCount: 0,\n createdAt: new Date().toISOString(),\n lastActive: new Date().toISOString(),\n };\n activeSessions.set(`id:${id}`, session);\n logger.info(COMPONENT, `Created guest session: ${id}`);\n return session;\n}\n\nexport function isGuestSession(sessionId: string): boolean {\n return sessionId.startsWith(GUEST_PREFIX);\n}\n\n/** Prune old guest sessions */\nexport function pruneGuestSessions(): void {\n const now = Date.now();\n const keysToDelete: string[] = [];\n for (const [key, session] of activeSessions) {\n if (isGuestSession(session.id)) {\n const age = now - new Date(session.lastActive).getTime();\n if (age > GUEST_MAX_AGE_MS) {\n keysToDelete.push(key);\n }\n }\n }\n for (const key of keysToDelete) {\n const session = activeSessions.get(key);\n if (session) {\n closeSession(session.id);\n }\n }\n if (keysToDelete.length > 0) {\n logger.info(COMPONENT, `Pruned ${keysToDelete.length} inactive guest session(s)`);\n }\n}\n"],"mappings":";AAIA,SAAS,MAAM,YAAY;AAC3B,SAAS,OAAO,YAAY,aAAa,yBAAyB;AAElE,SAAS,sBAAsB,0BAA0B;AACzD,SAAS,mBAAmB;AAC5B,SAAS,0BAA0B;AACnC,OAAO,YAAY;AAGnB,MAAM,YAAY;AAuBlB,MAAM,iBAAuC,oBAAI,IAAI;AAQrD,MAAM,kBAAkB;AAExB,SAAS,iBAAiB,GAAqD;AAE3E,MAAI,EAAE,aAAa,KAAM,QAAO;AAIhC,SAAO,gBAAgB,KAAK,EAAE,EAAE;AACpC;AAEO,SAAS,mBAAmB,SAAiB,QAAgB,UAAkB,WAAW,cAAuB,OAAgB;AACpI,QAAM,aAAa,GAAG,OAAO,IAAI,MAAM,IAAI,OAAO;AAGlD,QAAM,SAAS,eAAe,IAAI,UAAU;AAC5C,MAAI,UAAU,OAAO,WAAW,UAAU;AACtC,WAAO;AAAA,EACX;AAOA,QAAM,QAAQ,MAAM;AACpB,QAAM,WAAW,MAAM,SAAS;AAAA,IAC5B,CAAC,MAAM,EAAE,YAAY,WACd,EAAE,YAAY,UACd,EAAE,aAAa,WACf,EAAE,WAAW,YACb,iBAAiB,CAAkD;AAAA,EAC9E;AAEA,MAAI,UAAU;AACV,UAAM,aAAa,IAAI,KAAK,SAAS,eAAe,SAAS,UAAU,EAAE,QAAQ;AACjF,QAAI,KAAK,IAAI,IAAI,aAAa,oBAAoB;AAC9C,eAAS,SAAS;AAClB,aAAO,MAAM,WAAW,WAAW,SAAS,EAAE,8BAA8B;AAAA,IAEhF,OAAO;AACH,YAAMA,WAAmB;AAAA,QACrB,IAAI,SAAS;AAAA,QACb,SAAS,SAAS;AAAA,QAClB,QAAQ,SAAS;AAAA,QACjB,SAAS,SAAS;AAAA,QAClB,QAAQ,SAAS;AAAA,QACjB,cAAc,SAAS;AAAA,QACvB,WAAW,SAAS;AAAA,QACpB,YAAY,SAAS;AAAA,QACrB,MAAM,SAAS;AAAA,QACf,aAAa,SAAS;AAAA;AAAA,QAEtB,eAAgB,SAAgD;AAAA,QAChE,kBAAmB,SAAgD;AAAA;AAAA;AAAA,QAGnE,QAAQ;AAAA,MACZ;AACA,UAAI,aAAa;AACb,eAAO,KAAK,WAAW,qBAAqB,SAAS,EAAE,qCAAqC;AAAA,MAChG;AACA,qBAAe,IAAI,YAAYA,QAAO;AACtC,aAAOA;AAAA,IACX;AAAA,EACJ;AAGA,QAAM,UAAmB;AAAA,IACrB,IAAI,KAAK;AAAA,IACT;AAAA,IACA;AAAA,IACA;AAAA,IACA,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,IAClC,aAAY,oBAAI,KAAK,GAAE,YAAY;AAAA,EACvC;AAEA,MAAI,aAAa;AACb,QAAI;AACA,cAAQ,SAAS,YAAY,EAAE,SAAS,QAAQ;AAChD,aAAO,KAAK,WAAW,iCAAiC,QAAQ,EAAE,EAAE;AAAA,IACxE,SAAS,KAAK;AACV,aAAO,MAAM,WAAW,+BAA+B,GAAG,iDAA4C;AAAA,IAE1G;AAAA,EACJ;AAEA,QAAM,SAAS,KAAK;AAAA,IAChB,IAAI,QAAQ;AAAA,IACZ;AAAA,IACA,SAAS;AAAA,IACT,UAAU;AAAA,IACV,QAAQ;AAAA,IACR,eAAe;AAAA,IACf,YAAY,QAAQ;AAAA,IACpB,aAAa,QAAQ;AAAA,EACzB,CAAC;AAED,iBAAe,IAAI,YAAY,OAAO;AACtC,SAAO,KAAK,WAAW,wBAAwB,QAAQ,EAAE,KAAK,OAAO,IAAI,MAAM,GAAG;AAClF,SAAO;AACX;AAGO,SAAS,iBAAiB,SAAiB,QAAgB,UAAkB,WAAoB;AACpG,QAAM,UAAmB;AAAA,IACrB,IAAI,KAAK;AAAA,IACT;AAAA,IACA;AAAA,IACA;AAAA,IACA,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,IAClC,aAAY,oBAAI,KAAK,GAAE,YAAY;AAAA,EACvC;AAEA,QAAM,QAAQ,MAAM;AACpB,QAAM,SAAS,KAAK;AAAA,IAChB,IAAI,QAAQ;AAAA,IACZ;AAAA,IACA,SAAS;AAAA,IACT,UAAU;AAAA,IACV,QAAQ;AAAA,IACR,eAAe;AAAA,IACf,YAAY,QAAQ;AAAA,IACpB,aAAa,QAAQ;AAAA,EACzB,CAAC;AAGD,iBAAe,IAAI,MAAM,QAAQ,EAAE,IAAI,OAAO;AAE9C,iBAAe,IAAI,GAAG,OAAO,IAAI,MAAM,IAAI,OAAO,IAAI,OAAO;AAE7D,SAAO,KAAK,WAAW,mCAAmC,QAAQ,EAAE,KAAK,OAAO,IAAI,MAAM,GAAG;AAC7F,SAAO;AACX;AAeO,SAAS,uBACZ,WACA,SACA,QACA,UAAkB,WACX;AACP,QAAM,WAAW,eAAe,SAAS;AACzC,MAAI,SAAU,QAAO;AAErB,QAAM,UAAmB;AAAA,IACrB,IAAI;AAAA,IACJ;AAAA,IACA;AAAA,IACA;AAAA,IACA,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,IAClC,aAAY,oBAAI,KAAK,GAAE,YAAY;AAAA,EACvC;AAEA,QAAM,QAAQ,MAAM;AACpB,QAAM,SAAS,KAAK;AAAA,IAChB,IAAI,QAAQ;AAAA,IACZ;AAAA,IACA,SAAS;AAAA,IACT,UAAU;AAAA,IACV,QAAQ;AAAA,IACR,eAAe;AAAA,IACf,YAAY,QAAQ;AAAA,IACpB,aAAa,QAAQ;AAAA;AAAA;AAAA;AAAA,IAIrB,UAAU;AAAA,EACd,CAA8C;AAQ9C,iBAAe,IAAI,MAAM,QAAQ,EAAE,IAAI,OAAO;AAE9C,SAAO,KAAK,WAAW,yCAAyC,QAAQ,EAAE,KAAK,OAAO,IAAI,MAAM,GAAG;AACnG,SAAO;AACX;AAGO,SAAS,eAAe,WAAmC;AAE9D,QAAM,SAAS,eAAe,IAAI,MAAM,SAAS,EAAE;AACnD,MAAI,OAAQ,QAAO;AAGnB,QAAM,QAAQ,MAAM;AACpB,QAAM,WAAW,MAAM,SAAS,KAAK,OAAK,EAAE,OAAO,SAAS;AAC5D,MAAI,CAAC,SAAU,QAAO;AAEtB,QAAM,UAAmB;AAAA,IACrB,IAAI,SAAS;AAAA,IACb,SAAS,SAAS;AAAA,IAClB,QAAQ,SAAS;AAAA,IACjB,SAAS,SAAS;AAAA,IAClB,QAAQ,SAAS;AAAA,IACjB,cAAc,SAAS;AAAA,IACvB,WAAW,SAAS;AAAA,IACpB,YAAY,SAAS;AAAA,IACrB,MAAM,SAAS;AAAA,IACf,aAAa,SAAS;AAAA,EAC1B;AAGA,iBAAe,IAAI,MAAM,SAAS,IAAI,OAAO;AAE7C,SAAO;AACX;AAGO,SAAS,WACZ,SACA,MACA,SACA,OACI;AACJ,QAAM,YAAY,KAAK;AACvB,cAAY;AAAA,IACR,IAAI;AAAA,IACJ,WAAW,QAAQ;AAAA,IACnB;AAAA,IACA;AAAA,IACA,WAAW,OAAO;AAAA,IAClB,YAAY,OAAO;AAAA,IACnB,OAAO,OAAO;AAAA,IACd,YAAY,OAAO,cAAc;AAAA,EACrC,GAAG,QAAQ,MAAM;AAGjB,UAAQ;AACR,UAAQ,cAAa,oBAAI,KAAK,GAAE,YAAY;AAE5C,QAAM,QAAQ,MAAM;AACpB,QAAM,aAAa,MAAM,SAAS,KAAK,CAAC,MAAM,EAAE,OAAO,QAAQ,EAAE;AACjE,MAAI,YAAY;AACZ,eAAW,gBAAgB,QAAQ;AACnC,eAAW,cAAc,QAAQ;AAAA,EACrC;AAGA,MAAI,SAAS,QAAQ;AACjB,UAAM,UAAU,QAAQ,MAAM,GAAG,EAAE,KAAK,QAAQ,SAAS,KAAK,WAAM;AACpE,UAAM,OAAiD,EAAE,cAAc,QAAQ;AAC/E,QAAI,CAAC,QAAQ,MAAM;AAEf,YAAM,UAAU,QAAQ,QAAQ,8BAA8B,EAAE,EAAE,QAAQ,0BAA0B,EAAE;AACtG,YAAM,gBAAgB,QAAQ,OAAO,CAAC,EAAE,YAAY,IAAI,QAAQ,MAAM,GAAG,EAAE,KAAK,QAAQ,SAAS,KAAK,WAAM;AAC5G,cAAQ,OAAO;AACf,WAAK,OAAO;AAEZ,aAAO,wBAAwB,EAAE,KAAK,CAAC,EAAE,MAAM,OAAO,MAAM,OAAO,EAAE,OAAO,QAAQ,UAAU,CAAC,EAAE,MAAM,QAAQ,SAAS,wGAAwG,QAAQ,MAAM,GAAG,GAAG,CAAC,GAAG,CAAC,GAAG,WAAW,IAAI,aAAa,IAAI,CAAC,EAAE,KAAK,SAAO;AACvS,YAAI,IAAI,WAAW,IAAI,QAAQ,SAAS,KAAK,IAAI,QAAQ,SAAS,IAAI;AAClE,kBAAQ,OAAO,IAAI,QAAQ,KAAK;AAChC,4BAAkB,QAAQ,IAAI,EAAE,MAAM,QAAQ,KAAK,CAAC;AACpD,iBAAO,KAAK,WAAW,iBAAiB,QAAQ,GAAG,MAAM,GAAG,CAAC,CAAC,MAAM,QAAQ,IAAI,GAAG;AAAA,QACvF;AAAA,MACJ,CAAC,CAAC,EAAE,MAAM,MAAM;AAAA,MAAmC,CAAC;AAAA,IACxD;AACA,YAAQ,cAAc;AACtB,sBAAkB,QAAQ,IAAI,IAAI;AAAA,EACtC;AACJ;AAGO,SAAS,mBAAmB,SAAkB,cAAsB,sBAAqC;AAC5G,QAAM,UAAU,WAAW,QAAQ,IAAI,aAAa,QAAQ,MAAM;AAClE,SAAO,QAAQ,IAAI,CAAC,SAAS;AAAA,IACzB,MAAM,IAAI;AAAA,IACV,SAAS,IAAI;AAAA,IACb,YAAY,IAAI,cAAc;AAAA,IAC9B,WAAW,IAAI,YAAY,KAAK,MAAM,IAAI,SAAS,IAAI;AAAA,EAC3D,EAAE;AACN;AAGO,SAAS,uBAA6B;AACzC,QAAM,QAAQ,MAAM;AACpB,QAAM,MAAM,KAAK,IAAI;AACrB,MAAI,UAAU;AACd,aAAW,KAAK,MAAM,UAAU;AAC5B,QAAI,EAAE,WAAW,UAAU;AACvB,YAAM,aAAa,IAAI,KAAK,EAAE,eAAe,EAAE,UAAU,EAAE,QAAQ;AACnE,UAAI,MAAM,aAAa,oBAAoB;AACvC,UAAE,SAAS;AACX;AAAA,MACJ;AAAA,IACJ;AAAA,EACJ;AACA,MAAI,UAAU,GAAG;AACb,WAAO,KAAK,WAAW,cAAc,OAAO,mBAAmB;AAAA,EACnE;AACJ;AAGO,SAAS,cAAc,WAAmB,MAAuB;AACpE,QAAM,QAAQ,MAAM;AACpB,QAAM,IAAI,MAAM,SAAS,KAAK,CAACC,OAAMA,GAAE,OAAO,SAAS;AACvD,MAAI,CAAC,EAAG,QAAO;AACf,IAAE,OAAO,KAAK,KAAK,EAAE,MAAM,GAAG,GAAG;AACjC,oBAAkB,WAAW,EAAE,MAAM,EAAE,KAAK,CAAC;AAE7C,aAAW,WAAW,eAAe,OAAO,GAAG;AAC3C,QAAI,QAAQ,OAAO,WAAW;AAC1B,cAAQ,OAAO,EAAE;AACjB;AAAA,IACJ;AAAA,EACJ;AACA,SAAO,KAAK,WAAW,mBAAmB,UAAU,MAAM,GAAG,CAAC,CAAC,YAAO,EAAE,IAAI,GAAG;AAC/E,SAAO;AACX;AAGO,SAAS,eAA0B;AACtC,uBAAqB;AACrB,QAAM,QAAQ,MAAM;AACpB,SAAO,MAAM,SACR,OAAO,CAAC,MAAM,EAAE,WAAW,YAAY,EAAE,WAAW,MAAM,EAC1D,KAAK,CAAC,GAAG,MAAM,EAAE,YAAY,cAAc,EAAE,WAAW,CAAC,EACzD,IAAI,CAAC,MAAM;AAER,QAAI,CAAC,EAAE,MAAM;AACT,YAAM,OAAO,MAAM,cAAc,OAAO,OAAK,EAAE,cAAc,EAAE,MAAM,EAAE,SAAS,MAAM;AACtF,UAAI,KAAK,SAAS,GAAG;AACjB,cAAM,QAAQ,KAAK,CAAC,EAAE,QAAQ,MAAM,GAAG,EAAE,KAAK,KAAK,CAAC,EAAE,QAAQ,SAAS,KAAK,WAAM;AAClF,cAAM,OAAO,KAAK,KAAK,SAAS,CAAC,EAAE,QAAQ,MAAM,GAAG,EAAE,KAAK,KAAK,KAAK,SAAS,CAAC,EAAE,QAAQ,SAAS,KAAK,WAAM;AAC7G,UAAE,OAAO;AACT,UAAE,eAAe;AAAA,MACrB;AAAA,IACJ;AACA,WAAO;AAAA,MACH,IAAI,EAAE;AAAA,MACN,SAAS,EAAE;AAAA,MACX,QAAQ,EAAE;AAAA,MACV,SAAS,EAAE;AAAA,MACX,QAAQ,EAAE;AAAA,MACV,cAAc,EAAE;AAAA,MAChB,WAAW,EAAE;AAAA,MACb,YAAY,EAAE;AAAA,MACd,MAAM,EAAE;AAAA,MACR,aAAa,EAAE;AAAA,IACnB;AAAA,EACJ,CAAC;AACT;AAGO,SAAS,wBAAwB,SAAiB,QAAgB,OAAqB;AAC1F,QAAM,UAAU,mBAAmB,SAAS,QAAQ,SAAS;AAC7D,UAAQ,gBAAgB;AAExB,oBAAkB,QAAQ,IAAI,EAAE,gBAAgB,MAAM,CAAC;AACvD,SAAO,KAAK,WAAW,WAAW,QAAQ,GAAG,MAAM,GAAG,CAAC,CAAC,2BAAsB,KAAK,EAAE;AACzF;AAGO,SAAS,2BAA2B,SAAiB,QAAgB,OAAgD;AACxH,QAAM,UAAU,mBAAmB,SAAS,QAAQ,SAAS;AAC7D,UAAQ,mBAAmB;AAE3B,oBAAkB,QAAQ,IAAI,EAAE,mBAAmB,MAAM,CAAC;AAC1D,SAAO,KAAK,WAAW,WAAW,QAAQ,GAAG,MAAM,GAAG,CAAC,CAAC,8BAAyB,KAAK,EAAE;AAC5F;AAGO,SAAS,kBAAkB,SAAiB,QAAgB,IAAmB;AAClF,QAAM,UAAU,mBAAmB,SAAS,QAAQ,SAAS;AAC7D,UAAQ,cAAc;AACtB,SAAO,KAAK,WAAW,WAAW,QAAQ,GAAG,MAAM,GAAG,CAAC,CAAC,oBAAe,EAAE,EAAE;AAC/E;AAGO,SAAS,sBAAsB,SAAkB,UAA+B;AAEnF,QAAM,QAAQ,MAAM;AACpB,QAAM,gBAAgB,MAAM,cAAc,OAAO,CAAC,MAAM,EAAE,cAAc,QAAQ,EAAE;AAGlF,aAAW,OAAO,UAAU;AACxB,QAAI,IAAI,SAAS,SAAU;AAC3B,gBAAY;AAAA,MACR,IAAI,KAAK;AAAA,MACT,WAAW,QAAQ;AAAA,MACnB,MAAM,IAAI;AAAA,MACV,SAAS,IAAI,WAAW;AAAA,MACxB,WAAW,IAAI,YAAY,KAAK,UAAU,IAAI,SAAS,IAAI;AAAA,MAC3D,YAAY,IAAI;AAAA,MAChB,YAAY;AAAA,IAChB,GAAG,QAAQ,MAAM;AAAA,EACrB;AAGA,UAAQ,eAAe,SAAS,OAAO,CAAC,MAAM,EAAE,SAAS,QAAQ,EAAE;AACnE,QAAM,aAAa,MAAM,SAAS,KAAK,CAAC,MAAM,EAAE,OAAO,QAAQ,EAAE;AACjE,MAAI,WAAY,YAAW,gBAAgB,QAAQ;AAEnD,SAAO,KAAK,WAAW,WAAW,QAAQ,GAAG,MAAM,GAAG,CAAC,CAAC,uBAAuB,QAAQ,YAAY,YAAY;AACnH;AAGO,SAAS,aAAa,WAAyB;AAClD,QAAM,QAAQ,MAAM;AACpB,QAAM,aAAa,MAAM,SAAS,KAAK,CAAC,MAAM,EAAE,OAAO,SAAS;AAChE,MAAI,YAAY;AACZ,eAAW,SAAS;AAAA,EACxB;AAGA,QAAM,eAAyB,CAAC;AAChC,aAAW,CAAC,KAAK,OAAO,KAAK,gBAAgB;AACzC,QAAI,QAAQ,OAAO,WAAW;AAC1B,mBAAa,KAAK,GAAG;AAAA,IACzB;AAAA,EACJ;AACA,aAAW,OAAO,cAAc;AAC5B,mBAAe,OAAO,GAAG;AAAA,EAC7B;AAGA,qBAAmB,SAAS;AAG5B,SAAO,gCAAgC,EAClC,KAAK,CAAC,EAAE,eAAe,MAAM,eAAe,SAAS,CAAC,EACtD,MAAM,MAAM;AAAA,EAAC,CAAC;AAEnB,SAAO,KAAK,WAAW,mBAAmB,SAAS,EAAE;AACzD;AAGA,MAAM,eAAe;AACrB,MAAM,mBAAmB,KAAK,KAAK,KAAK;AACxC,MAAM,kBAAkB;AAEjB,SAAS,qBAA8B;AAC1C,QAAM,KAAK,GAAG,YAAY,GAAG,KAAK,IAAI,EAAE,SAAS,EAAE,CAAC,IAAI,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,GAAG,CAAC,CAAC;AAC9F,QAAM,UAAmB;AAAA,IACrB;AAAA,IACA,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,SAAS;AAAA,IACT,QAAQ;AAAA,IACR,cAAc;AAAA,IACd,YAAW,oBAAI,KAAK,GAAE,YAAY;AAAA,IAClC,aAAY,oBAAI,KAAK,GAAE,YAAY;AAAA,EACvC;AACA,iBAAe,IAAI,MAAM,EAAE,IAAI,OAAO;AACtC,SAAO,KAAK,WAAW,0BAA0B,EAAE,EAAE;AACrD,SAAO;AACX;AAEO,SAAS,eAAe,WAA4B;AACvD,SAAO,UAAU,WAAW,YAAY;AAC5C;AAGO,SAAS,qBAA2B;AACvC,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,eAAyB,CAAC;AAChC,aAAW,CAAC,KAAK,OAAO,KAAK,gBAAgB;AACzC,QAAI,eAAe,QAAQ,EAAE,GAAG;AAC5B,YAAM,MAAM,MAAM,IAAI,KAAK,QAAQ,UAAU,EAAE,QAAQ;AACvD,UAAI,MAAM,kBAAkB;AACxB,qBAAa,KAAK,GAAG;AAAA,MACzB;AAAA,IACJ;AAAA,EACJ;AACA,aAAW,OAAO,cAAc;AAC5B,UAAM,UAAU,eAAe,IAAI,GAAG;AACtC,QAAI,SAAS;AACT,mBAAa,QAAQ,EAAE;AAAA,IAC3B;AAAA,EACJ;AACA,MAAI,aAAa,SAAS,GAAG;AACzB,WAAO,KAAK,WAAW,UAAU,aAAa,MAAM,4BAA4B;AAAA,EACpF;AACJ;","names":["session","s"]}
1
+ {"version":3,"sources":["../../src/agent/session.ts"],"sourcesContent":["/**\n * TITAN — Session Manager\n * Manages per-user/per-channel isolated sessions with history and context.\n */\nimport { v4 as uuid } from 'uuid';\nimport { getDb, getHistory, saveMessage, updateSessionMeta, debouncedSave } from '../memory/memory.js';\n\n/** Idle sessions older than this are purged from the store entirely */\nconst SESSION_IDLE_PURGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days\nimport type { ChatMessage } from '../providers/base.js';\nimport { MAX_CONTEXT_MESSAGES, SESSION_TIMEOUT_MS } from '../utils/constants.js';\nimport { generateKey } from '../security/encryption.js';\nimport { resetLoopDetection } from './loopDetection.js';\nimport logger from '../utils/logger.js';\n// chat imported dynamically to avoid circular dependency\n\nconst COMPONENT = 'Session';\n\nexport interface Session {\n id: string;\n channel: string;\n userId: string;\n agentId: string;\n status: 'active' | 'idle' | 'closed';\n messageCount: number;\n createdAt: string;\n lastActive: string;\n name?: string;\n lastMessage?: string;\n e2eKey?: string; // Stored only in memory for active sessions\n /** Team ID if this session belongs to a team (for RBAC) */\n teamId?: string;\n // Per-session overrides (in-memory only, reset when session closes/times out)\n modelOverride?: string;\n thinkingOverride?: 'off' | 'low' | 'medium' | 'high';\n verboseMode?: boolean;\n}\n\n/** Active sessions cache */\nexport const activeSessions: Map<string, Session> = new Map();\n\n// ─── Ephemeral channel cleanup (Phase 9 / TITAN PC leak fix) ────────────\n//\n// Background: TITAN PC v5.3.2 accumulated 755 in-memory sessions in 29min\n// because every endpoint that internally calls processMessage with a\n// templated channel name (autoresearch-trigger-${type}, twilio-call-${sid},\n// initiative-fix, etc.) creates a unique cache key under\n// `${channel}:${userId}:${agentId}` — and all sessions previously shared\n// the same SESSION_TIMEOUT_MS (30min) idle TTL. At ~26 sessions/min creation\n// rate, that 30min window buffered 750+ entries before the first expired.\n//\n// Fix: classify channels as ephemeral (one-shot agent invocations from\n// internal triggers) vs persistent (webchat, voice, discord, telegram,\n// slack — where the user expects to resume mid-conversation). Ephemerals\n// get a 5-minute idle TTL and an LRU cap; persistents keep the full 30min.\n//\n// Persistent channels are an EXPLICIT allowlist — any new channel added\n// in the future defaults to ephemeral by accident, which is the safer\n// failure mode (a few extra closeSession calls vs a slow OOM).\nconst PERSISTENT_CHANNELS_EXACT = new Set([\n 'webchat', 'voice', 'discord', 'telegram', 'slack',\n 'whatsapp', 'matrix', 'irc', 'line', 'zulip',\n 'mattermost', 'rocketchat', 'twilio', 'sms', 'email',\n]);\n\n/** True if the channel is an ephemeral one-shot — short TTL + LRU cap. */\nexport function isEphemeralChannel(channel: string): boolean {\n return !PERSISTENT_CHANNELS_EXACT.has(channel);\n}\n\n/** Idle TTL for ephemeral sessions. Far shorter than SESSION_TIMEOUT_MS. */\nexport const EPHEMERAL_TTL_MS = 5 * 60 * 1000;\n/** Max ephemeral sessions retained in the in-memory cache; LRU evicts beyond. */\nexport const EPHEMERAL_MAX_ACTIVE = 100;\n\n/** Create or retrieve a session */\n// Hunt Finding #19 (2026-04-14): UUID v4 pattern used to distinguish\n// auto-generated default sessions from caller-supplied named sessions.\n// Needed for backward compatibility with sessions created BEFORE the\n// is_named flag was added — those don't have the flag but can still be\n// identified by ID shape (non-UUID = named by caller).\nconst UUID_V4_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;\n\nfunction isDefaultSession(s: { id: string } & { is_named?: boolean }): boolean {\n // Explicitly marked as named via the flag → not a default session.\n if (s.is_named === true) return false;\n // Pre-#19 sessions don't have the flag. Fall back to ID-shape detection:\n // auto-generated defaults use uuid(), named sessions use caller-supplied\n // strings that rarely match UUID v4.\n return UUID_V4_PATTERN.test(s.id);\n}\n\nexport function getOrCreateSession(channel: string, userId: string, agentId: string = 'default', isEncrypted: boolean = false): Session {\n const sessionKey = `${channel}:${userId}:${agentId}`;\n\n // Check cache\n const cached = activeSessions.get(sessionKey);\n if (cached && cached.status === 'active') {\n return cached;\n }\n\n // Check data store. Hunt Finding #19 (2026-04-14): exclude named sessions\n // (those created via getOrCreateSessionById with an explicit ID). A named\n // session belongs to whoever holds its ID — it must NOT be returned as the\n // default for the channel+user+agent slot, or a subsequent no-sessionId\n // request inherits the previous named caller's conversation history.\n const store = getDb();\n const existing = store.sessions.find(\n (s) => s.channel === channel\n && s.user_id === userId\n && s.agent_id === agentId\n && s.status === 'active'\n && isDefaultSession(s as unknown as { id: string; is_named?: boolean }),\n );\n\n if (existing) {\n const lastActive = new Date(existing.last_active || existing.created_at).getTime();\n if (Date.now() - lastActive > SESSION_TIMEOUT_MS) {\n existing.status = 'idle';\n logger.debug(COMPONENT, `Session ${existing.id} timed out, creating new one`);\n // Fall through to create a new session\n } else {\n const session: Session = {\n id: existing.id,\n channel: existing.channel,\n userId: existing.user_id,\n agentId: existing.agent_id,\n status: existing.status as 'active',\n messageCount: existing.message_count,\n createdAt: existing.created_at,\n lastActive: existing.last_active,\n name: existing.name,\n lastMessage: existing.last_message,\n // D3: Restore persisted overrides on session recovery\n modelOverride: (existing as unknown as Record<string, unknown>).model_override as string | undefined,\n thinkingOverride: (existing as unknown as Record<string, unknown>).thinking_override as Session['thinkingOverride'],\n // Note: If a session was encrypted but dropped from memory, we cannot recover the key\n // A robust implementation would involve key exchange, but for now we warn:\n e2eKey: undefined\n };\n if (isEncrypted) {\n logger.warn(COMPONENT, `Recovered session ${existing.id}, but E2E key was lost from memory.`);\n }\n activeSessions.set(sessionKey, session);\n return session;\n }\n }\n\n // Create new session\n const session: Session = {\n id: uuid(),\n channel,\n userId,\n agentId,\n status: 'active',\n messageCount: 0,\n createdAt: new Date().toISOString(),\n lastActive: new Date().toISOString(),\n };\n\n if (isEncrypted) {\n try {\n session.e2eKey = generateKey().toString('base64');\n logger.info(COMPONENT, `Generated E2E key for session ${session.id}`);\n } catch (err) {\n logger.error(COMPONENT, `Failed to generate E2E key: ${err} — session will proceed without encryption`);\n // e2eKey remains undefined; addMessage/getContextMessages handle undefined gracefully\n }\n }\n\n store.sessions.push({\n id: session.id,\n channel,\n user_id: userId,\n agent_id: agentId,\n status: 'active',\n message_count: 0,\n created_at: session.createdAt,\n last_active: session.lastActive,\n });\n\n activeSessions.set(sessionKey, session);\n logger.info(COMPONENT, `Created new session: ${session.id} (${channel}/${userId})`);\n return session;\n}\n\n/** Create a new session (always fresh — never reuses existing) */\nexport function createNewSession(channel: string, userId: string, agentId: string = 'default'): Session {\n const session: Session = {\n id: uuid(),\n channel,\n userId,\n agentId,\n status: 'active',\n messageCount: 0,\n createdAt: new Date().toISOString(),\n lastActive: new Date().toISOString(),\n };\n\n const store = getDb();\n store.sessions.push({\n id: session.id,\n channel,\n user_id: userId,\n agent_id: agentId,\n status: 'active',\n message_count: 0,\n created_at: session.createdAt,\n last_active: session.lastActive,\n });\n\n // Cache by ID so getSessionById can find it\n activeSessions.set(`id:${session.id}`, session);\n // Also set as the active session for this channel/user combo\n activeSessions.set(`${channel}:${userId}:${agentId}`, session);\n\n logger.info(COMPONENT, `Created new session (explicit): ${session.id} (${channel}/${userId})`);\n return session;\n}\n\n/**\n * Get a session by ID, or create a new one with that exact ID if it doesn't exist.\n *\n * Hunt Finding #06 (2026-04-14): clients that pass an explicit sessionId to\n * /api/message previously had their ID silently ignored when the session\n * didn't exist — processMessage fell through to getOrCreateSession(channel,\n * userId, agentId), which returned the default session for that channel+user\n * combo. The client's intent to start a fresh isolated session was dropped\n * and old context polluted the new request.\n *\n * This helper preserves the requested ID: if the session exists, return it;\n * if not, create a brand-new session and register it under the requested ID.\n */\nexport function getOrCreateSessionById(\n sessionId: string,\n channel: string,\n userId: string,\n agentId: string = 'default',\n): Session {\n const existing = getSessionById(sessionId);\n if (existing) return existing;\n\n const session: Session = {\n id: sessionId,\n channel,\n userId,\n agentId,\n status: 'active',\n messageCount: 0,\n createdAt: new Date().toISOString(),\n lastActive: new Date().toISOString(),\n };\n\n const store = getDb();\n store.sessions.push({\n id: session.id,\n channel,\n user_id: userId,\n agent_id: agentId,\n status: 'active',\n message_count: 0,\n created_at: session.createdAt,\n last_active: session.lastActive,\n // Hunt Finding #19 (2026-04-14): mark as named so the default-slot\n // lookup in getOrCreateSession doesn't return this to an unrelated\n // no-sessionId caller.\n is_named: true,\n } as Parameters<typeof store.sessions.push>[0]);\n\n // Hunt Finding #19 (2026-04-14): register ONLY under the id: key. Do NOT\n // overwrite the default channel+user+agent slot — that's what was causing\n // no-sessionId requests to inherit the most recent named session. The\n // previous behavior claimed to \"avoid creating another session for the\n // same user\" but that convenience cost was a privacy leak between API\n // callers sharing the api-user:default fallback.\n activeSessions.set(`id:${session.id}`, session);\n\n logger.info(COMPONENT, `Created new session with explicit ID: ${session.id} (${channel}/${userId})`);\n return session;\n}\n\n/** Get a session by its ID (for session switching) */\nexport function getSessionById(sessionId: string): Session | null {\n // Check cache first\n const cached = activeSessions.get(`id:${sessionId}`);\n if (cached) return cached;\n\n // Check data store\n const store = getDb();\n const existing = store.sessions.find(s => s.id === sessionId);\n if (!existing) return null;\n\n const session: Session = {\n id: existing.id,\n channel: existing.channel,\n userId: existing.user_id,\n agentId: existing.agent_id,\n status: existing.status as 'active' | 'idle' | 'closed',\n messageCount: existing.message_count,\n createdAt: existing.created_at,\n lastActive: existing.last_active,\n name: existing.name,\n lastMessage: existing.last_message,\n };\n\n // Cache for future lookups\n activeSessions.set(`id:${sessionId}`, session);\n\n return session;\n}\n\n/** Add a message to a session */\nexport function addMessage(\n session: Session,\n role: 'user' | 'assistant' | 'system' | 'tool',\n content: string,\n extra?: { toolCalls?: string; toolCallId?: string; model?: string; tokenCount?: number }\n): void {\n const messageId = uuid();\n saveMessage({\n id: messageId,\n sessionId: session.id,\n role,\n content,\n toolCalls: extra?.toolCalls,\n toolCallId: extra?.toolCallId,\n model: extra?.model,\n tokenCount: extra?.tokenCount || 0,\n }, session.e2eKey);\n\n // Update session\n session.messageCount++;\n session.lastActive = new Date().toISOString();\n\n const store = getDb();\n const sessionRec = store.sessions.find((s) => s.id === session.id);\n if (sessionRec) {\n sessionRec.message_count = session.messageCount;\n sessionRec.last_active = session.lastActive;\n }\n\n // Auto-name session from first user message; track last user message snippet\n if (role === 'user') {\n const snippet = content.slice(0, 60) + (content.length > 60 ? '…' : '');\n const meta: { name?: string; last_message?: string } = { last_message: snippet };\n if (!session.name) {\n // Generate a concise title via LLM (fire-and-forget, fallback to truncation)\n const cleaned = content.replace(/^\\[voice\\/voice-user\\]\\s*/i, '').replace(/^\\[api\\/api-user\\]\\s*/i, '');\n const fallbackTitle = cleaned.charAt(0).toUpperCase() + cleaned.slice(1, 47) + (cleaned.length > 47 ? '…' : '');\n session.name = fallbackTitle;\n meta.name = fallbackTitle;\n // Async LLM title generation — updates session name when ready\n import('../providers/router.js').then(({ chat: chatFn }) => chatFn({ model: 'fast', messages: [{ role: 'user', content: `Generate a concise 5-word title for this conversation. Only output the title, nothing else. Message: ${cleaned.slice(0, 200)}` }], maxTokens: 30, temperature: 0.7 }).then(res => {\n if (res.content && res.content.length > 0 && res.content.length < 60) {\n session.name = res.content.trim();\n updateSessionMeta(session.id, { name: session.name });\n logger.info('Session', `LLM title for ${session.id.slice(0, 8)}: \"${session.name}\"`);\n }\n })).catch(() => { /* fallback title already set */ });\n }\n session.lastMessage = snippet;\n updateSessionMeta(session.id, meta);\n }\n}\n\n/** Get the context messages for a session (for sending to LLM) */\nexport function getContextMessages(session: Session, maxMessages: number = MAX_CONTEXT_MESSAGES): ChatMessage[] {\n const history = getHistory(session.id, maxMessages, session.e2eKey);\n return history.map((msg) => ({\n role: msg.role as ChatMessage['role'],\n content: msg.content,\n toolCallId: msg.toolCallId || undefined,\n toolCalls: msg.toolCalls ? JSON.parse(msg.toolCalls) : undefined,\n }));\n}\n\n/**\n * Mark sessions inactive past the per-channel idle TTL as idle, evict from\n * the in-memory cache, enforce the ephemeral LRU cap, and purge ancient\n * idle records from the store.\n *\n * TTL is per-channel:\n * - Persistent channels (webchat, voice, discord, telegram, slack, ...)\n * keep SESSION_TIMEOUT_MS (30min) so user conversations can resume.\n * - Ephemeral channels (api, eval, autoresearch-*, initiative-*, twilio-*,\n * monitor, mesh, deliberation, ...) get EPHEMERAL_TTL_MS (5min) — these\n * are internal one-shot agent invocations that don't need to linger.\n *\n * After idle eviction, the ephemeral entries in the cache are capped at\n * EPHEMERAL_MAX_ACTIVE; oldest-by-lastActive get dropped beyond that.\n */\nexport function cleanupStaleSessions(): void {\n const store = getDb();\n const now = Date.now();\n let cleaned = 0;\n for (const s of store.sessions) {\n if (s.status === 'active') {\n const ttl = isEphemeralChannel(s.channel) ? EPHEMERAL_TTL_MS : SESSION_TIMEOUT_MS;\n const lastActive = new Date(s.last_active || s.created_at).getTime();\n if (now - lastActive > ttl) {\n s.status = 'idle';\n cleaned++;\n }\n }\n }\n\n // Evict timed-out entries from the in-memory cache too — otherwise\n // getOrCreateSessionById registrations (keyed by id:*) leak forever.\n const keysToDelete: string[] = [];\n for (const [key, session] of activeSessions.entries()) {\n const ttl = isEphemeralChannel(session.channel) ? EPHEMERAL_TTL_MS : SESSION_TIMEOUT_MS;\n const lastActive = new Date(session.lastActive || session.createdAt).getTime();\n if (now - lastActive > ttl) {\n keysToDelete.push(key);\n }\n }\n for (const key of keysToDelete) {\n activeSessions.delete(key);\n }\n\n // LRU cap on ephemeral cache entries — even within the 5min window, a\n // burst of 200+ one-shot agent calls would still buffer up. Drop the\n // oldest-by-lastActive past EPHEMERAL_MAX_ACTIVE. We dedupe by session\n // ID first because `id:` and `channel:user:agent` keys often share an\n // underlying Session object.\n const ephemeralEntries: Array<{ key: string; session: Session; lastActive: number }> = [];\n const seenSessionIds = new Set<string>();\n for (const [key, session] of activeSessions.entries()) {\n if (!isEphemeralChannel(session.channel)) continue;\n if (seenSessionIds.has(session.id)) continue;\n seenSessionIds.add(session.id);\n ephemeralEntries.push({\n key,\n session,\n lastActive: new Date(session.lastActive || session.createdAt).getTime(),\n });\n }\n let lruEvicted = 0;\n if (ephemeralEntries.length > EPHEMERAL_MAX_ACTIVE) {\n ephemeralEntries.sort((a, b) => a.lastActive - b.lastActive); // oldest first\n const toEvict = ephemeralEntries.slice(0, ephemeralEntries.length - EPHEMERAL_MAX_ACTIVE);\n for (const { session } of toEvict) {\n // Remove BOTH key patterns for this session id.\n const allKeys: string[] = [];\n for (const [k, v] of activeSessions.entries()) {\n if (v.id === session.id) allKeys.push(k);\n }\n for (const k of allKeys) activeSessions.delete(k);\n lruEvicted++;\n }\n }\n\n // Purge idle sessions older than 7 days from the store entirely —\n // otherwise the sessions array grows forever (755+ sessions observed).\n const beforePurge = store.sessions.length;\n store.sessions = store.sessions.filter((s) => {\n if (s.status !== 'idle') return true;\n const lastActive = new Date(s.last_active || s.created_at).getTime();\n return now - lastActive < SESSION_IDLE_PURGE_MS;\n });\n const purged = beforePurge - store.sessions.length;\n\n if (cleaned > 0 || keysToDelete.length > 0 || lruEvicted > 0 || purged > 0) {\n logger.info(\n COMPONENT,\n `Cleaned up ${cleaned} stale session(s), evicted ${keysToDelete.length} from cache, ` +\n `LRU-evicted ${lruEvicted} ephemeral over cap, purged ${purged} old idle session(s)`,\n );\n }\n\n if (cleaned > 0 || purged > 0) {\n debouncedSave();\n }\n}\n\n/**\n * Bulk close sessions matching a filter — used by POST /api/sessions/sweep\n * for live operational drain (no service restart needed) when the cache\n * unexpectedly grows.\n *\n * @param opts.channel If set, only close sessions on this exact channel.\n * @param opts.channelPrefix If set, only close sessions whose channel\n * starts with this prefix (matches templated channels like\n * \"autoresearch-trigger-tool_router\").\n * @param opts.idleMs Minimum idle time in ms; only close sessions whose\n * lastActive is older than now - idleMs. Defaults to 0 (any age).\n * @param opts.force If true, also close persistent channels. Off by\n * default to keep webchat/voice conversations alive.\n *\n * Returns the count of sessions closed (cache + DB record).\n */\nexport function sweepSessions(opts: {\n channel?: string;\n channelPrefix?: string;\n idleMs?: number;\n force?: boolean;\n} = {}): { closed: number } {\n const now = Date.now();\n const idleThreshold = opts.idleMs ?? 0;\n\n const sessionIdsToClose = new Set<string>();\n for (const session of activeSessions.values()) {\n if (sessionIdsToClose.has(session.id)) continue;\n if (!opts.force && !isEphemeralChannel(session.channel)) continue;\n if (opts.channel && session.channel !== opts.channel) continue;\n if (opts.channelPrefix && !session.channel.startsWith(opts.channelPrefix)) continue;\n const lastActive = new Date(session.lastActive || session.createdAt).getTime();\n if (now - lastActive < idleThreshold) continue;\n sessionIdsToClose.add(session.id);\n }\n\n let closed = 0;\n for (const id of sessionIdsToClose) {\n closeSession(id);\n closed++;\n }\n if (closed > 0) {\n logger.info(COMPONENT, `Sweep closed ${closed} session(s) — channel=${opts.channel ?? opts.channelPrefix ?? '*'} idleMs=${idleThreshold} force=${!!opts.force}`);\n }\n return { closed };\n}\n\n/** Rename a session */\nexport function renameSession(sessionId: string, name: string): boolean {\n const store = getDb();\n const s = store.sessions.find((s) => s.id === sessionId);\n if (!s) return false;\n s.name = name.trim().slice(0, 100);\n updateSessionMeta(sessionId, { name: s.name });\n // Update in-memory cache too\n for (const session of activeSessions.values()) {\n if (session.id === sessionId) {\n session.name = s.name;\n break;\n }\n }\n logger.info(COMPONENT, `Renamed session ${sessionId.slice(0, 8)} → \"${s.name}\"`);\n return true;\n}\n\n/** List all active sessions */\nexport function listSessions(): Session[] {\n cleanupStaleSessions();\n const store = getDb();\n return store.sessions\n .filter((s) => s.status === 'active' || s.status === 'idle')\n .sort((a, b) => b.last_active.localeCompare(a.last_active))\n .map((s) => {\n // Backfill name/lastMessage from conversation history for sessions created before this feature\n if (!s.name) {\n const msgs = store.conversations.filter(m => m.sessionId === s.id && m.role === 'user');\n if (msgs.length > 0) {\n const first = msgs[0].content.slice(0, 60) + (msgs[0].content.length > 60 ? '…' : '');\n const last = msgs[msgs.length - 1].content.slice(0, 60) + (msgs[msgs.length - 1].content.length > 60 ? '…' : '');\n s.name = first;\n s.last_message = last;\n }\n }\n return {\n id: s.id,\n channel: s.channel,\n userId: s.user_id,\n agentId: s.agent_id,\n status: s.status as 'active',\n messageCount: s.message_count,\n createdAt: s.created_at,\n lastActive: s.last_active,\n name: s.name,\n lastMessage: s.last_message,\n };\n });\n}\n\n/** Set a model override for the current session */\nexport function setSessionModelOverride(channel: string, userId: string, model: string): void {\n const session = getOrCreateSession(channel, userId, 'default');\n session.modelOverride = model;\n // D3: Persist to database so override survives session recovery\n updateSessionMeta(session.id, { model_override: model });\n logger.info(COMPONENT, `Session ${session.id.slice(0, 8)}: model override → ${model}`);\n}\n\n/** Set a thinking mode override for the current session */\nexport function setSessionThinkingOverride(channel: string, userId: string, level: 'off' | 'low' | 'medium' | 'high'): void {\n const session = getOrCreateSession(channel, userId, 'default');\n session.thinkingOverride = level;\n // D3: Persist to database so override survives session recovery\n updateSessionMeta(session.id, { thinking_override: level });\n logger.info(COMPONENT, `Session ${session.id.slice(0, 8)}: thinking override → ${level}`);\n}\n\n/** Set verbose mode for the current session */\nexport function setSessionVerbose(channel: string, userId: string, on: boolean): void {\n const session = getOrCreateSession(channel, userId, 'default');\n session.verboseMode = on;\n logger.info(COMPONENT, `Session ${session.id.slice(0, 8)}: verbose → ${on}`);\n}\n\n/** Replace session message context with compacted messages (used by /compact) */\nexport function replaceSessionContext(session: Session, messages: ChatMessage[]): void {\n // Clear existing history from the DB for this session\n const store = getDb();\n store.conversations = store.conversations.filter((m) => m.sessionId !== session.id);\n\n // Re-insert compacted messages\n for (const msg of messages) {\n if (msg.role === 'system') continue; // system prompts are rebuilt each turn\n saveMessage({\n id: uuid(),\n sessionId: session.id,\n role: msg.role,\n content: msg.content || '',\n toolCalls: msg.toolCalls ? JSON.stringify(msg.toolCalls) : undefined,\n toolCallId: msg.toolCallId,\n tokenCount: 0,\n }, session.e2eKey);\n }\n\n // Update session message count\n session.messageCount = messages.filter((m) => m.role !== 'system').length;\n const sessionRec = store.sessions.find((s) => s.id === session.id);\n if (sessionRec) sessionRec.message_count = session.messageCount;\n\n logger.info(COMPONENT, `Session ${session.id.slice(0, 8)}: context replaced (${session.messageCount} messages)`);\n}\n\n/** Close a session */\nexport function closeSession(sessionId: string): void {\n const store = getDb();\n const sessionRec = store.sessions.find((s) => s.id === sessionId);\n if (sessionRec) {\n sessionRec.status = 'closed';\n }\n\n // Delete ALL cache entries for this session (both id: and channel:user:agent keys)\n const keysToDelete: string[] = [];\n for (const [key, session] of activeSessions) {\n if (session.id === sessionId) {\n keysToDelete.push(key);\n }\n }\n for (const key of keysToDelete) {\n activeSessions.delete(key);\n }\n\n // D1: Clean up loop detection state to prevent unbounded memory growth\n resetLoopDetection(sessionId);\n\n // Flush accumulated tool analytics for this session (fire-and-forget)\n import('../analytics/featureTracker.js')\n .then(({ endToolSession }) => endToolSession(sessionId))\n .catch(() => {});\n\n logger.info(COMPONENT, `Closed session: ${sessionId}`);\n}\n\n// v5.0: Guest session support (Space Agent parity)\nconst GUEST_PREFIX = 'guest_';\nconst GUEST_MAX_AGE_MS = 72 * 60 * 60 * 1000; // 72h\nconst GUEST_MAX_FILES = 1000;\n\nexport function createGuestSession(): Session {\n const id = `${GUEST_PREFIX}${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;\n const session: Session = {\n id,\n channel: 'webchat',\n userId: id,\n agentId: 'default',\n status: 'active',\n messageCount: 0,\n createdAt: new Date().toISOString(),\n lastActive: new Date().toISOString(),\n };\n activeSessions.set(`id:${id}`, session);\n logger.info(COMPONENT, `Created guest session: ${id}`);\n return session;\n}\n\nexport function isGuestSession(sessionId: string): boolean {\n return sessionId.startsWith(GUEST_PREFIX);\n}\n\n/** Prune old guest sessions */\nexport function pruneGuestSessions(): void {\n const now = Date.now();\n const keysToDelete: string[] = [];\n for (const [key, session] of activeSessions) {\n if (isGuestSession(session.id)) {\n const age = now - new Date(session.lastActive).getTime();\n if (age > GUEST_MAX_AGE_MS) {\n keysToDelete.push(key);\n }\n }\n }\n for (const key of keysToDelete) {\n const session = activeSessions.get(key);\n if (session) {\n closeSession(session.id);\n }\n }\n if (keysToDelete.length > 0) {\n logger.info(COMPONENT, `Pruned ${keysToDelete.length} inactive guest session(s)`);\n }\n}\n"],"mappings":"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","names":["session","s"]}
@@ -397,6 +397,11 @@ ${personaContent}`;
397
397
  const toolsUsed = [];
398
398
  let finalContent = "";
399
399
  let rounds = 0;
400
+ const toolHistory = [];
401
+ let lastContent = "";
402
+ let stallCount = 0;
403
+ const STALL_THRESHOLD = 3;
404
+ const LOOP_THRESHOLD = 2;
400
405
  try {
401
406
  for (let round = 0; round < maxRounds; round++) {
402
407
  rounds = round + 1;
@@ -434,7 +439,41 @@ ${personaContent}`;
434
439
  config.streamCallbacks.onToolCall(tc.function.name, JSON.parse(tc.function.arguments || "{}"));
435
440
  }
436
441
  }
437
- const toolResults = await executeTools(response.toolCalls);
442
+ const toolResults = [];
443
+ let allToolsFailed = true;
444
+ for (const tc of response.toolCalls) {
445
+ let result;
446
+ try {
447
+ const singleResult = await executeTools([tc]);
448
+ result = singleResult[0];
449
+ if (result.success !== false) allToolsFailed = false;
450
+ } catch (toolErr) {
451
+ result = {
452
+ toolCallId: tc.id,
453
+ name: tc.function.name,
454
+ content: `Error executing ${tc.function.name}: ${toolErr.message}`,
455
+ success: false,
456
+ durationMs: 0
457
+ };
458
+ logger.warn(COMPONENT, `[${agentName}] Tool ${tc.function.name} failed: ${toolErr.message}`);
459
+ }
460
+ const MAX_TOOL_OUTPUT = 1e4;
461
+ if (result.content && result.content.length > MAX_TOOL_OUTPUT) {
462
+ const originalLen = result.content.length;
463
+ const marker = `
464
+
465
+ [\u2026output truncated from ${originalLen} to ${MAX_TOOL_OUTPUT} chars \u2014 full result available via tool re-execution with narrower scope]`;
466
+ result.content = result.content.slice(0, MAX_TOOL_OUTPUT - marker.length) + marker;
467
+ }
468
+ toolResults.push(result);
469
+ toolHistory.push({ name: result.name, args: tc.function.arguments || "{}", round });
470
+ }
471
+ if (allToolsFailed && toolResults.length > 0) {
472
+ const failures = toolResults.map((r) => `${r.name}: ${r.content.slice(0, 120)}`).join("; ");
473
+ finalContent = `Error: All ${toolResults.length} tool(s) failed in round ${rounds}. ${failures}`;
474
+ logger.warn(COMPONENT, `[${agentName}] All tools failed \u2014 aborting after ${rounds} rounds`);
475
+ break;
476
+ }
438
477
  if (config.streamCallbacks?.onToolResult) {
439
478
  for (const tr of toolResults) {
440
479
  config.streamCallbacks.onToolResult(tr.name, tr.content, tr.durationMs || 0, tr.success !== false);
@@ -449,6 +488,28 @@ ${personaContent}`;
449
488
  name: result.name
450
489
  });
451
490
  }
491
+ const currentContent = response.content || "";
492
+ if (currentContent && currentContent === lastContent) {
493
+ stallCount++;
494
+ logger.warn(COMPONENT, `[${agentName}] Stall detected (${stallCount}/${STALL_THRESHOLD}) \u2014 identical content in round ${rounds}`);
495
+ if (stallCount >= STALL_THRESHOLD) {
496
+ finalContent = `Task stalled after ${rounds} rounds \u2014 the agent repeated the same reasoning without progress.`;
497
+ logger.warn(COMPONENT, `[${agentName}] Aborting due to stall`);
498
+ break;
499
+ }
500
+ } else {
501
+ stallCount = 0;
502
+ lastContent = currentContent;
503
+ }
504
+ if (toolHistory.length >= 2) {
505
+ const last = toolHistory[toolHistory.length - 1];
506
+ const prev = toolHistory[toolHistory.length - 2];
507
+ if (last.name === prev.name && last.args === prev.args) {
508
+ logger.warn(COMPONENT, `[${agentName}] Loop detected \u2014 ${last.name} called with identical args in consecutive rounds`);
509
+ finalContent = `Task looped after ${rounds} rounds \u2014 the agent called ${last.name} repeatedly with the same arguments.`;
510
+ break;
511
+ }
512
+ }
452
513
  if (round === maxRounds - 1) {
453
514
  finalContent = response.content || "Max rounds reached. Partial results returned.";
454
515
  }