sensorium-mcp 2.15.3 → 2.16.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 (37) hide show
  1. package/dist/http-server.d.ts +16 -0
  2. package/dist/http-server.d.ts.map +1 -0
  3. package/dist/http-server.js +248 -0
  4. package/dist/http-server.js.map +1 -0
  5. package/dist/index.js +133 -1988
  6. package/dist/index.js.map +1 -1
  7. package/dist/response-builders.d.ts +34 -0
  8. package/dist/response-builders.d.ts.map +1 -0
  9. package/dist/response-builders.js +114 -0
  10. package/dist/response-builders.js.map +1 -0
  11. package/dist/stdio-server.d.ts +8 -0
  12. package/dist/stdio-server.d.ts.map +1 -0
  13. package/dist/stdio-server.js +23 -0
  14. package/dist/stdio-server.js.map +1 -0
  15. package/dist/tools/memory-tools.d.ts +36 -0
  16. package/dist/tools/memory-tools.d.ts.map +1 -0
  17. package/dist/tools/memory-tools.js +352 -0
  18. package/dist/tools/memory-tools.js.map +1 -0
  19. package/dist/tools/session-tools.d.ts +46 -0
  20. package/dist/tools/session-tools.d.ts.map +1 -0
  21. package/dist/tools/session-tools.js +261 -0
  22. package/dist/tools/session-tools.js.map +1 -0
  23. package/dist/tools/start-session-tool.d.ts +43 -0
  24. package/dist/tools/start-session-tool.d.ts.map +1 -0
  25. package/dist/tools/start-session-tool.js +188 -0
  26. package/dist/tools/start-session-tool.js.map +1 -0
  27. package/dist/tools/utility-tools.d.ts +34 -0
  28. package/dist/tools/utility-tools.d.ts.map +1 -0
  29. package/dist/tools/utility-tools.js +256 -0
  30. package/dist/tools/utility-tools.js.map +1 -0
  31. package/dist/tools/wait-tool.d.ts +69 -0
  32. package/dist/tools/wait-tool.d.ts.map +1 -0
  33. package/dist/tools/wait-tool.js +702 -0
  34. package/dist/tools/wait-tool.js.map +1 -0
  35. package/dist/types.d.ts +2 -0
  36. package/dist/types.d.ts.map +1 -1
  37. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -26,84 +26,25 @@
26
26
  * is disabled.
27
27
  */
28
28
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
29
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
30
- import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
31
- import { CallToolRequestSchema, isInitializeRequest, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
32
- import { readFile } from "fs/promises";
33
- import { randomUUID, timingSafeEqual } from "node:crypto";
34
- import { createServer } from "node:http";
35
- import { basename } from "node:path";
36
- import { checkMaintenanceFlag, config, saveFileToDisk } from "./config.js";
37
- import { handleDashboardRequest } from "./dashboard.js";
38
- import { peekThreadMessages, readThreadMessages, startDispatcher } from "./dispatcher.js";
29
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
30
+ import { checkMaintenanceFlag, config } from "./config.js";
31
+ import { peekThreadMessages, startDispatcher } from "./dispatcher.js";
39
32
  import { formatDrivePrompt } from "./drive.js";
40
- import { convertMarkdown, splitMessage } from "./markdown.js";
41
- import { assembleBootstrap, assembleCompactRefresh, forgetMemory, getMemoryStatus, getNotesWithoutEmbeddings, getRecentEpisodes, getTopicIndex, initMemoryDb, runIntelligentConsolidation, saveEpisode, saveNoteEmbedding, saveProcedure, saveSemanticNote, saveVoiceSignature, searchByEmbedding, searchProcedures, searchSemanticNotes, searchSemanticNotesRanked, supersedeNote, updateProcedure, updateSemanticNote, } from "./memory.js";
42
- import { analyzeVideoFrames, analyzeVoiceEmotion, chatCompletion, extractVideoFrames, generateEmbedding, textToSpeech, transcribeAudio, TTS_VOICES } from "./openai.js";
43
- import { addSchedule, checkDueTasks, generateTaskId, listSchedules, purgeSchedules, removeSchedule } from "./scheduler.js";
44
- import { DEAD_SESSION_TIMEOUT_MS, lookupSession, persistSession, purgeOtherSessions, registerMcpSession, removeSession, threadSessionRegistry, } from "./sessions.js";
33
+ import { initMemoryDb } from "./memory.js";
34
+ import { checkDueTasks } from "./scheduler.js";
35
+ import { DEAD_SESSION_TIMEOUT_MS, } from "./sessions.js";
45
36
  import { TelegramClient } from "./telegram.js";
46
37
  import { getToolDefinitions } from "./tool-definitions.js";
47
38
  import { rateLimiter } from "./rate-limiter.js";
48
- import { describeADV, errorMessage, errorResult, IMAGE_EXTENSIONS, OPENAI_TTS_MAX_CHARS } from "./utils.js";
49
- // ── Stop-word list for auto-memory keyword extraction ─────────────────
50
- const STOP_WORDS = new Set([
51
- "a", "an", "the", "is", "are", "was", "were", "be", "been", "being",
52
- "have", "has", "had", "do", "does", "did", "will", "would", "could",
53
- "should", "may", "might", "shall", "can", "need", "must", "ought",
54
- "i", "me", "my", "we", "us", "our", "you", "your", "he", "him",
55
- "his", "she", "her", "it", "its", "they", "them", "their", "this",
56
- "that", "these", "those", "what", "which", "who", "whom", "when",
57
- "where", "why", "how", "not", "no", "nor", "so", "too", "very",
58
- "just", "also", "than", "then", "now", "here", "there", "all",
59
- "any", "each", "every", "both", "few", "more", "most", "some",
60
- "such", "only", "own", "same", "but", "and", "or", "if", "at",
61
- "by", "for", "from", "in", "into", "of", "on", "to", "up", "with",
62
- "as", "about", "like", "hey", "hi", "hello", "ok", "okay", "please",
63
- "thanks", "thank", "yes", "yeah", "no", "nah", "right", "got",
64
- "get", "let", "go", "going", "gonna", "want", "know", "think",
65
- "see", "look", "make", "take", "give", "tell", "say", "said",
66
- ]);
67
- /** Tokenize text and strip stop words, returning up to 10 meaningful keywords. */
68
- function extractSearchKeywords(text) {
69
- const words = text.toLowerCase().split(/\W+/).filter(w => w.length > 1 && !STOP_WORDS.has(w));
70
- return words.slice(0, 10).join(" ");
71
- }
72
- /**
73
- * Build human-readable analysis tags from a VoiceAnalysisResult.
74
- * Fields that are null / undefined / empty are silently skipped.
75
- */
76
- function buildAnalysisTags(analysis) {
77
- const tags = [];
78
- if (!analysis)
79
- return tags;
80
- if (analysis.emotion) {
81
- let emotionStr = analysis.emotion;
82
- if (analysis.arousal != null && analysis.dominance != null && analysis.valence != null) {
83
- emotionStr += ` (${describeADV(analysis.arousal, analysis.dominance, analysis.valence)})`;
84
- }
85
- tags.push(`tone: ${emotionStr}`);
86
- }
87
- if (analysis.gender)
88
- tags.push(`gender: ${analysis.gender}`);
89
- if (analysis.audio_events && analysis.audio_events.length > 0) {
90
- const eventLabels = analysis.audio_events
91
- .map(e => `${e.label} (${Math.round(e.score * 100)}%)`)
92
- .join(", ");
93
- tags.push(`sounds: ${eventLabels}`);
94
- }
95
- if (analysis.paralinguistics) {
96
- const p = analysis.paralinguistics;
97
- const paraItems = [];
98
- if (p.speech_rate != null)
99
- paraItems.push(`${p.speech_rate} syl/s`);
100
- if (p.mean_pitch_hz != null)
101
- paraItems.push(`pitch ${p.mean_pitch_hz}Hz`);
102
- if (paraItems.length > 0)
103
- tags.push(`speech: ${paraItems.join(", ")}`);
104
- }
105
- return tags;
106
- }
39
+ import { errorResult } from "./utils.js";
40
+ import { getReminders, getShortReminder } from "./response-builders.js";
41
+ import { startHttpServer } from "./http-server.js";
42
+ import { startStdioServer } from "./stdio-server.js";
43
+ import { handleMemoryTool } from "./tools/memory-tools.js";
44
+ import { handleUtilityTool } from "./tools/utility-tools.js";
45
+ import { handleSessionTool } from "./tools/session-tools.js";
46
+ import { handleStartSession } from "./tools/start-session-tool.js";
47
+ import { handleWaitForInstructions } from "./tools/wait-tool.js";
107
48
  // ---------------------------------------------------------------------------
108
49
  // Destructure config for backwards-compatible local references
109
50
  // ---------------------------------------------------------------------------
@@ -181,6 +122,13 @@ function createMcpServer(getMcpSessionId, closeTransport) {
181
122
  }
182
123
  return currentThreadId;
183
124
  }
125
+ const memoryToolCtx = {
126
+ resolveThreadId,
127
+ getShortReminder: (threadId) => getShortReminder(threadId, sessionStartedAt),
128
+ getMemoryDb,
129
+ errorResult,
130
+ onConsolidation: () => { lastConsolidationAt = Date.now(); },
131
+ };
184
132
  const srv = new Server({ name: "sensorium-mcp", version: PKG_VERSION }, { capabilities: { tools: {} } });
185
133
  // Dead session detector — per-session, runs every 2 minutes
186
134
  const deadSessionInterval = setInterval(async () => {
@@ -206,65 +154,6 @@ function createMcpServer(getMcpSessionId, closeTransport) {
206
154
  tools: getToolDefinitions(),
207
155
  }));
208
156
  // ── Tool implementations ────────────────────────────────────────────────────
209
- /**
210
- * Backfill embeddings for any semantic notes that don't have them yet.
211
- * Used after consolidation to ensure all notes are searchable by embedding.
212
- */
213
- async function backfillEmbeddings(db) {
214
- const apiKey = process.env.OPENAI_API_KEY;
215
- if (!apiKey)
216
- return;
217
- const missing = getNotesWithoutEmbeddings(db);
218
- for (const { noteId, content } of missing) {
219
- try {
220
- const emb = await generateEmbedding(content, apiKey);
221
- saveNoteEmbedding(db, noteId, emb);
222
- process.stderr.write(`[memory] Embedded ${noteId}\n`);
223
- }
224
- catch (err) {
225
- process.stderr.write(`[memory] Embedding failed for ${noteId}: ${err instanceof Error ? err.message : String(err)}\n`);
226
- }
227
- }
228
- }
229
- /**
230
- * Full reminders — only used for wait_for_instructions and start_session
231
- * responses where the agent needs the complete context for decision-making.
232
- */
233
- function getReminders(threadId, driveActive = false) {
234
- const now = new Date();
235
- const uptimeMin = Math.round((Date.now() - sessionStartedAt) / 60000);
236
- const timeStr = now.toLocaleString("en-GB", {
237
- day: "2-digit", month: "short", year: "numeric",
238
- hour: "2-digit", minute: "2-digit", hour12: false,
239
- timeZoneName: "short",
240
- });
241
- if (driveActive) {
242
- return ("\nComplete the dispatcher's tasks. Report progress via `send_voice`. Then call `remote_copilot_wait_for_instructions`." +
243
- ` threadId=${threadId ?? "?"} | ${timeStr} | uptime: ${uptimeMin}m`);
244
- }
245
- const directive = config.AUTONOMOUS_MODE
246
- ? "\nYou are the ORCHESTRATOR. Your only permitted actions: plan, decide, call wait_for_instructions/hibernate/send_voice/report_progress/memory tools. ALL other work (file reads, edits, searches, code changes) MUST go through runSubagent. Non-negotiable."
247
- : "\nFollow the operator's instructions. Report results via `send_voice`.";
248
- return (directive +
249
- ` threadId=${threadId ?? "?"} | ${timeStr} | uptime: ${uptimeMin}m`);
250
- }
251
- /**
252
- * Minimal context — appended to regular tool responses to avoid bloating
253
- * the conversation context. Only includes thread ID and timestamp.
254
- */
255
- function getShortReminder(threadId) {
256
- const now = new Date();
257
- const uptimeMin = Math.round((Date.now() - sessionStartedAt) / 60000);
258
- const timeStr = now.toLocaleString("en-GB", {
259
- day: "2-digit", month: "short", year: "numeric",
260
- hour: "2-digit", minute: "2-digit", hour12: false,
261
- timeZoneName: "short",
262
- });
263
- const threadHint = threadId !== undefined
264
- ? `\n- Active Telegram thread ID: **${threadId}** — if this session is restarted, call start_session with threadId=${threadId} to resume this topic.`
265
- : "";
266
- return threadHint + `\n- Current time: ${timeStr} | Session uptime: ${uptimeMin}m`;
267
- }
268
157
  srv.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
269
158
  const { name, arguments: args } = request.params;
270
159
  // Dead session detection — update timestamp on any tool call.
@@ -291,1609 +180,122 @@ function createMcpServer(getMcpSessionId, closeTransport) {
291
180
  }
292
181
  // ── start_session ─────────────────────────────────────────────────────────
293
182
  if (name === "start_session") {
294
- sessionStartedAt = Date.now();
295
- const typedArgs = (args ?? {});
296
- const rawThreadId = typedArgs.threadId;
297
- const explicitThreadId = typeof rawThreadId === "number" ? rawThreadId
298
- : typeof rawThreadId === "string" ? (Number.isFinite(Number(rawThreadId)) ? Number(rawThreadId) : undefined)
299
- : undefined;
300
- const customName = typeof typedArgs.name === "string" && typedArgs.name.trim()
301
- ? typedArgs.name.trim()
302
- : undefined;
303
- // When creating a new session (no threadId), name is mandatory.
304
- if (explicitThreadId === undefined && !customName) {
305
- return {
306
- content: [
307
- {
308
- type: "text",
309
- text: "Error: sessionName is required when creating a new session. Provide a descriptive name for the session.",
310
- },
311
- ],
312
- isError: true,
313
- };
314
- }
315
- // Determine the thread to use:
316
- // 1. Explicit threadId beats everything.
317
- // 2. A known name looks up the persisted mapping — resume if found.
318
- // 3. Otherwise create a new topic.
319
- let resolvedPreexisting = false;
320
- if (explicitThreadId !== undefined) {
321
- currentThreadId = explicitThreadId;
322
- // If a name was also supplied, keep the mapping up to date.
323
- if (customName)
324
- persistSession(TELEGRAM_CHAT_ID, customName, explicitThreadId);
325
- resolvedPreexisting = true;
326
- }
327
- else if (customName !== undefined) {
328
- const stored = lookupSession(TELEGRAM_CHAT_ID, customName);
329
- if (stored !== undefined) {
330
- currentThreadId = stored;
331
- resolvedPreexisting = true;
332
- }
333
- }
334
- if (resolvedPreexisting) {
335
- // Drain any stale messages from the thread file so they aren't
336
- // re-delivered in the next wait_for_instructions call.
337
- const stale = readThreadMessages(currentThreadId);
338
- if (stale.length > 0) {
339
- process.stderr.write(`[start_session] Drained ${stale.length} stale message(s) from thread ${currentThreadId}.\n`);
340
- // Notify the operator that stale messages were discarded.
341
- try {
342
- const notice = convertMarkdown(`\u26A0\uFE0F **${stale.length} message(s) from before the session resumed were discarded.** ` +
343
- `If you sent instructions while the agent was offline, please resend them.`);
344
- await telegram.sendMessage(TELEGRAM_CHAT_ID, notice, "MarkdownV2", currentThreadId);
345
- }
346
- catch { /* non-fatal */ }
347
- }
348
- // Resume mode: verify the thread is still alive by sending a message.
349
- // If the topic was deleted, drop the cached mapping and fall through to
350
- // create a new topic.
351
- try {
352
- // Use plain text for probe — avoids MarkdownV2 parsing failures being mistaken for dead threads
353
- await telegram.sendMessage(TELEGRAM_CHAT_ID, "\u{1F504} Session resumed. Continuing in this thread.", undefined, currentThreadId);
354
- }
355
- catch (err) {
356
- const errMsg = errorMessage(err);
357
- process.stderr.write(`[start_session] Probe failed for thread ${currentThreadId} in chat ${TELEGRAM_CHAT_ID}: ${errMsg}\n`);
358
- // Telegram returns "Bad Request: message thread not found" or
359
- // "Bad Request: the topic was closed" for deleted/closed topics.
360
- const isThreadGone = /thread not found|topic.*(closed|deleted|not found)/i.test(errMsg);
361
- if (isThreadGone) {
362
- process.stderr.write(`[start_session] Cached thread ${currentThreadId} is gone (${errMsg}). Creating new topic.\n`);
363
- // Drop the stale mapping and purge any scheduled tasks.
364
- if (currentThreadId !== undefined)
365
- purgeSchedules(currentThreadId);
366
- if (customName)
367
- removeSession(TELEGRAM_CHAT_ID, customName);
368
- resolvedPreexisting = false;
369
- currentThreadId = undefined;
370
- }
371
- // Other errors (network, etc.) are non-fatal — proceed anyway.
372
- }
373
- }
374
- if (!resolvedPreexisting) {
375
- // New session: create a dedicated forum topic.
376
- const topicName = customName ??
377
- `Copilot — ${new Date().toLocaleString("en-GB", {
378
- day: "2-digit", month: "short", year: "numeric",
379
- hour: "2-digit", minute: "2-digit", hour12: false,
380
- })}`;
381
- try {
382
- const topic = await telegram.createForumTopic(TELEGRAM_CHAT_ID, topicName);
383
- currentThreadId = topic.message_thread_id;
384
- // Persist so the same name resumes this thread next time.
385
- persistSession(TELEGRAM_CHAT_ID, topicName, currentThreadId);
386
- }
387
- catch (err) {
388
- // Forum topics not available (e.g. plain group or DM) — cannot proceed
389
- // without thread isolation. Return an error so the agent knows.
390
- return errorResult(`Error: Could not create forum topic: ${errorMessage(err)}. ` +
391
- "Ensure the Telegram chat is a forum supergroup with the bot as admin with can_manage_topics right.");
392
- }
393
- try {
394
- const greeting = convertMarkdown("# 🤖 Remote Copilot Ready\n\n" +
395
- "Your AI assistant is online and listening.\n\n" +
396
- "**Send your instructions** and I'll get to work — " +
397
- "I'll keep you posted on progress as I go.");
398
- await telegram.sendMessage(TELEGRAM_CHAT_ID, greeting, "MarkdownV2", currentThreadId);
399
- }
400
- catch {
401
- // Non-fatal.
402
- }
403
- }
404
- const threadNote = currentThreadId !== undefined
405
- ? ` Thread ID: ${currentThreadId} (pass this to start_session as threadId to resume this topic later).`
406
- : "";
407
- // Auto-bootstrap memory
408
- let memoryBriefing = "";
409
- try {
410
- const db = getMemoryDb();
411
- if (currentThreadId !== undefined) {
412
- memoryBriefing = "\n\n" + assembleBootstrap(db, currentThreadId);
413
- }
414
- }
415
- catch (e) {
416
- memoryBriefing = "\n\n_Memory system unavailable._";
417
- }
418
- // Purge stale MCP sessions for this thread (from before a server restart)
419
- // and register the current session.
420
- if (currentThreadId !== undefined) {
421
- const sid = getMcpSessionId?.();
422
- const purged = purgeOtherSessions(currentThreadId, sid);
423
- if (purged > 0) {
424
- process.stderr.write(`[start_session] Purged ${purged} stale MCP session(s) for thread ${currentThreadId}.\n`);
425
- }
426
- if (sid && closeTransport) {
427
- registerMcpSession(currentThreadId, sid, closeTransport);
428
- }
429
- }
430
- // Auto-schedule DMN reflection task if not already present.
431
- // This fires after 4 hours of operator silence, delivering a
432
- // first-person introspection prompt sourced from memory.
433
- // Only create on active thread — purge stale DMN tasks from other threads
434
- // to avoid every thread accumulating reflection tasks.
435
- if (config.AUTONOMOUS_MODE && currentThreadId !== undefined) {
436
- const existingTasks = listSchedules(currentThreadId);
437
- const hasDmn = existingTasks.some(t => t.label === "dmn-reflection");
438
- if (!hasDmn) {
439
- addSchedule({
440
- id: generateTaskId(),
441
- threadId: currentThreadId,
442
- prompt: "__DMN__", // Sentinel — handler generates dynamic content
443
- label: "dmn-reflection",
444
- afterIdleMinutes: 240, // 4 hours
445
- oneShot: false,
446
- createdAt: new Date().toISOString(),
447
- });
448
- process.stderr.write(`[start_session] Auto-scheduled DMN reflection task for thread ${currentThreadId}.\n`);
449
- }
450
- }
451
- return {
452
- content: [
453
- {
454
- type: "text",
455
- text: `Session ${resolvedPreexisting ? "resumed" : "started"}.${threadNote}` +
456
- ` Call the remote_copilot_wait_for_instructions tool next.` +
457
- memoryBriefing +
458
- getReminders(currentThreadId),
459
- },
460
- ],
183
+ const startSessionCtx = {
184
+ session: {
185
+ get currentThreadId() { return currentThreadId; },
186
+ set currentThreadId(v) { currentThreadId = v; },
187
+ get sessionStartedAt() { return sessionStartedAt; },
188
+ set sessionStartedAt(v) { sessionStartedAt = v; },
189
+ get waitCallCount() { return waitCallCount; },
190
+ set waitCallCount(v) { waitCallCount = v; },
191
+ get lastToolCallAt() { return lastToolCallAt; },
192
+ set lastToolCallAt(v) { lastToolCallAt = v; },
193
+ get deadSessionAlerted() { return deadSessionAlerted; },
194
+ set deadSessionAlerted(v) { deadSessionAlerted = v; },
195
+ get toolCallsSinceLastDelivery() { return toolCallsSinceLastDelivery; },
196
+ set toolCallsSinceLastDelivery(v) { toolCallsSinceLastDelivery = v; },
197
+ previewedUpdateIds,
198
+ get lastOperatorMessageAt() { return lastOperatorMessageAt; },
199
+ set lastOperatorMessageAt(v) { lastOperatorMessageAt = v; },
200
+ get lastConsolidationAt() { return lastConsolidationAt; },
201
+ set lastConsolidationAt(v) { lastConsolidationAt = v; },
202
+ },
203
+ telegram,
204
+ telegramChatId: TELEGRAM_CHAT_ID,
205
+ config,
206
+ getMemoryDb,
207
+ getReminders,
208
+ getMcpSessionId,
209
+ closeTransport,
461
210
  };
211
+ return handleStartSession((args ?? {}), startSessionCtx);
462
212
  }
463
213
  // ── remote_copilot_wait_for_instructions ──────────────────────────────────
464
214
  if (name === "remote_copilot_wait_for_instructions") {
465
- // Agent is actively polling — this is the primary health signal
466
- deadSessionAlerted = false;
467
- toolCallsSinceLastDelivery = 0; // reset on polling
468
- const typedArgs = (args ?? {});
469
- const effectiveThreadId = resolveThreadId(typedArgs);
470
- if (effectiveThreadId === undefined) {
471
- return errorResult("Error: No active session. Call start_session first, then pass the returned threadId to this tool.");
472
- }
473
- const callNumber = ++waitCallCount;
474
- const timeoutMs = WAIT_TIMEOUT_MINUTES * 60 * 1000;
475
- const deadline = Date.now() + timeoutMs;
476
- // Poll the dispatcher's per-thread file instead of calling getUpdates
477
- // directly. This avoids 409 conflicts between concurrent instances.
478
- const POLL_INTERVAL_MS = 2000;
479
- const SSE_KEEPALIVE_INTERVAL_MS = 30_000;
480
- let lastScheduleCheck = 0;
481
- let lastKeepalive = Date.now();
482
- while (Date.now() < deadline) {
483
- // Check for pending update — tell agent to wait externally via Desktop Commander
484
- // CRITICAL: Do NOT tell agents to call hibernate or any MCP tool here — the server
485
- // is about to die. Agents must use an external sleep (PowerShell Start-Sleep) instead.
486
- const maintenanceInfo = checkMaintenanceFlag();
487
- if (maintenanceInfo) {
488
- process.stderr.write(`[wait] Maintenance flag detected: ${maintenanceInfo}\n`);
489
- return {
490
- content: [{
491
- type: "text",
492
- text: `⚠️ **Server update pending** (${maintenanceInfo}). ` +
493
- `The MCP server will restart shortly. Use Desktop Commander to run: ` +
494
- `Start-Sleep -Seconds 180 — then call start_session with threadId=${effectiveThreadId} to reconnect.` +
495
- getShortReminder(effectiveThreadId),
496
- }],
497
- };
498
- }
499
- // Peek first (non-destructive) to avoid consuming messages when the
500
- // SSE connection may be dead.
501
- const peeked = peekThreadMessages(effectiveThreadId);
502
- if (peeked.length > 0) {
503
- // Verify SSE connection is alive BEFORE consuming messages.
504
- // This prevents the destructive readThreadMessages from eating
505
- // messages that can never be delivered to a dead connection.
506
- if (extra.signal.aborted) {
507
- process.stderr.write(`[wait] SSE connection aborted before consuming ${peeked.length} messages — leaving in queue.\n`);
508
- return {
509
- content: [{
510
- type: "text",
511
- text: "The connection was interrupted. Messages are preserved for the next call.",
512
- }],
513
- };
514
- }
515
- // Connection alive — now consume messages for real.
516
- const stored = readThreadMessages(effectiveThreadId);
517
- process.stderr.write(`[wait] Read ${stored.length} messages from thread ${effectiveThreadId}. Processing...\n`);
518
- // Update the operator activity timestamp for idle detection.
519
- lastOperatorMessageAt = Date.now();
520
- // Clear only the consumed IDs from the previewed set (scoped clear).
521
- // This is safe because Node.js is single-threaded — no report_progress
522
- // call can interleave between readThreadMessages and this cleanup.
523
- for (const msg of stored) {
524
- previewedUpdateIds.delete(msg.update_id);
525
- }
526
- // React with 👀 on each consumed message to signal "seen" to the operator.
527
- for (const msg of stored) {
528
- void telegram.setMessageReaction(TELEGRAM_CHAT_ID, msg.message.message_id).catch(() => { });
529
- }
530
- const contentBlocks = [];
531
- let hasVoiceMessages = false;
532
- // Track which messages already had episodes saved (voice/video handlers)
533
- const savedEpisodeUpdateIds = new Set();
534
- for (const msg of stored) {
535
- // Photos: download the largest size, persist to disk, and embed as base64.
536
- if (msg.message.photo && msg.message.photo.length > 0) {
537
- const largest = msg.message.photo[msg.message.photo.length - 1];
538
- try {
539
- const { buffer, filePath: telegramPath } = await telegram.downloadFileAsBuffer(largest.file_id);
540
- const ext = telegramPath.split(".").pop()?.toLowerCase() ?? "jpg";
541
- const mimeType = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
542
- const base64 = buffer.toString("base64");
543
- const diskPath = saveFileToDisk(buffer, `photo.${ext}`);
544
- contentBlocks.push({ type: "image", data: base64, mimeType });
545
- contentBlocks.push({
546
- type: "text",
547
- text: `[Photo saved to: ${diskPath}]` +
548
- (msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
549
- });
550
- }
551
- catch (err) {
552
- contentBlocks.push({
553
- type: "text",
554
- text: `[Photo received but could not be downloaded: ${errorMessage(err)}]`,
555
- });
556
- }
557
- }
558
- // Documents: download, persist to disk, and embed as base64.
559
- if (msg.message.document) {
560
- const doc = msg.message.document;
561
- try {
562
- const { buffer, filePath: telegramPath } = await telegram.downloadFileAsBuffer(doc.file_id);
563
- const filename = doc.file_name ?? basename(telegramPath);
564
- const ext = filename.split(".").pop()?.toLowerCase() ?? "";
565
- const mimeType = doc.mime_type ?? (IMAGE_EXTENSIONS.has(ext) ? `image/${ext === "jpg" ? "jpeg" : ext}` : "application/octet-stream");
566
- const base64 = buffer.toString("base64");
567
- const diskPath = saveFileToDisk(buffer, filename);
568
- const isImage = mimeType.startsWith("image/");
569
- if (isImage) {
570
- contentBlocks.push({ type: "image", data: base64, mimeType });
571
- contentBlocks.push({
572
- type: "text",
573
- text: `[File saved to: ${diskPath}]` +
574
- (msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
575
- });
576
- }
577
- else {
578
- // Non-image documents: provide the disk path instead of
579
- // dumping potentially huge base64 into the LLM context.
580
- contentBlocks.push({
581
- type: "text",
582
- text: `[Document: ${filename} (${mimeType}) — saved to: ${diskPath}]` +
583
- (msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
584
- });
585
- }
586
- }
587
- catch (err) {
588
- contentBlocks.push({
589
- type: "text",
590
- text: `[Document "${doc.file_name ?? "file"}" received but could not be downloaded: ${errorMessage(err)}]`,
591
- });
592
- }
593
- }
594
- // Text messages.
595
- if (msg.message.text) {
596
- contentBlocks.push({ type: "text", text: msg.message.text });
597
- }
598
- // Voice messages: transcribe using OpenAI Whisper.
599
- if (msg.message.voice) {
600
- hasVoiceMessages = true;
601
- if (OPENAI_API_KEY) {
602
- try {
603
- process.stderr.write(`[voice] Downloading voice file ${msg.message.voice.file_id}...\n`);
604
- const { buffer } = await telegram.downloadFileAsBuffer(msg.message.voice.file_id);
605
- process.stderr.write(`[voice] Downloaded ${buffer.length} bytes. Starting transcription + analysis...\n`);
606
- // Run transcription and voice analysis in parallel.
607
- const [transcript, analysis] = await Promise.all([
608
- transcribeAudio(buffer, OPENAI_API_KEY),
609
- VOICE_ANALYSIS_URL
610
- ? analyzeVoiceEmotion(buffer, VOICE_ANALYSIS_URL)
611
- : Promise.resolve(null),
612
- ]);
613
- // Build rich voice analysis tag from VANPY results.
614
- const tags = buildAnalysisTags(analysis);
615
- const analysisTag = tags.length > 0 ? ` | ${tags.join(", ")}` : "";
616
- contentBlocks.push({
617
- type: "text",
618
- text: transcript
619
- ? `[Voice message — ${msg.message.voice.duration}s${analysisTag}, transcribed]: ${transcript}`
620
- : `[Voice message — ${msg.message.voice.duration}s${analysisTag}, transcribed]: (empty — no speech detected)`,
621
- });
622
- // Auto-save voice signature
623
- if (analysis && effectiveThreadId !== undefined) {
624
- try {
625
- const db = getMemoryDb();
626
- const sessionId = `session_${sessionStartedAt}`;
627
- const epId = saveEpisode(db, {
628
- sessionId,
629
- threadId: effectiveThreadId,
630
- type: "operator_message",
631
- modality: "voice",
632
- content: { text: transcript ?? "", duration: msg.message.voice.duration },
633
- importance: 0.6,
634
- });
635
- saveVoiceSignature(db, {
636
- episodeId: epId,
637
- emotion: analysis.emotion ?? undefined,
638
- arousal: analysis.arousal ?? undefined,
639
- dominance: analysis.dominance ?? undefined,
640
- valence: analysis.valence ?? undefined,
641
- speechRate: analysis.paralinguistics?.speech_rate ?? undefined,
642
- meanPitchHz: analysis.paralinguistics?.mean_pitch_hz ?? undefined,
643
- pitchStdHz: analysis.paralinguistics?.pitch_std_hz ?? undefined,
644
- jitter: analysis.paralinguistics?.jitter ?? undefined,
645
- shimmer: analysis.paralinguistics?.shimmer ?? undefined,
646
- hnrDb: analysis.paralinguistics?.hnr_db ?? undefined,
647
- audioEvents: analysis.audio_events?.map(e => ({ label: e.label, confidence: e.score })),
648
- durationSec: msg.message.voice.duration,
649
- });
650
- savedEpisodeUpdateIds.add(msg.update_id);
651
- }
652
- catch (_) { /* non-fatal */ }
653
- }
654
- }
655
- catch (err) {
656
- contentBlocks.push({
657
- type: "text",
658
- text: `[Voice message — ${msg.message.voice.duration}s — transcription failed: ${errorMessage(err)}]`,
659
- });
660
- }
661
- }
662
- else {
663
- contentBlocks.push({
664
- type: "text",
665
- text: `[Voice message received — ${msg.message.voice.duration}s — cannot transcribe: OPENAI_API_KEY not set]`,
666
- });
667
- }
668
- }
669
- // Video notes (circle videos): extract frames, analyze with GPT-4.1 vision,
670
- // optionally transcribe the audio track.
671
- if (msg.message.video_note) {
672
- hasVoiceMessages = true; // Video notes often contain speech
673
- const vn = msg.message.video_note;
674
- if (OPENAI_API_KEY) {
675
- try {
676
- process.stderr.write(`[video-note] Downloading circle video ${vn.file_id} (${vn.duration}s)...\n`);
677
- const { buffer } = await telegram.downloadFileAsBuffer(vn.file_id);
678
- process.stderr.write(`[video-note] Downloaded ${buffer.length} bytes. Extracting frames + transcribing...\n`);
679
- // Run frame extraction, audio transcription, and voice analysis in parallel.
680
- const [frames, transcript, analysis] = await Promise.all([
681
- extractVideoFrames(buffer, vn.duration).catch((err) => {
682
- process.stderr.write(`[video-note] Frame extraction failed: ${errorMessage(err)}\n`);
683
- return [];
684
- }),
685
- transcribeAudio(buffer, OPENAI_API_KEY, "video.mp4").catch(() => ""),
686
- VOICE_ANALYSIS_URL
687
- ? analyzeVoiceEmotion(buffer, VOICE_ANALYSIS_URL, {
688
- mimeType: "video/mp4",
689
- filename: "video.mp4",
690
- }).catch(() => null)
691
- : Promise.resolve(null),
692
- ]);
693
- // Analyze frames with GPT-4.1 vision.
694
- let sceneDescription = "";
695
- if (frames.length > 0) {
696
- process.stderr.write(`[video-note] Analyzing ${frames.length} frames with GPT-4.1 vision...\n`);
697
- sceneDescription = await analyzeVideoFrames(frames, vn.duration, OPENAI_API_KEY);
698
- process.stderr.write(`[video-note] Vision analysis complete.\n`);
699
- }
700
- // Build analysis tags (same as voice messages).
701
- const tags = buildAnalysisTags(analysis);
702
- const analysisTag = tags.length > 0 ? ` | ${tags.join(", ")}` : "";
703
- const parts = [];
704
- parts.push(`[Video note — ${vn.duration}s${analysisTag}]`);
705
- if (sceneDescription)
706
- parts.push(`Scene: ${sceneDescription}`);
707
- if (transcript)
708
- parts.push(`Audio: "${transcript}"`);
709
- if (!sceneDescription && !transcript)
710
- parts.push("(no visual or audio content could be extracted)");
711
- contentBlocks.push({ type: "text", text: parts.join("\n") });
712
- // Auto-save voice signature for video notes
713
- if (analysis && effectiveThreadId !== undefined) {
714
- try {
715
- const db = getMemoryDb();
716
- const sessionId = `session_${sessionStartedAt}`;
717
- const epId = saveEpisode(db, {
718
- sessionId,
719
- threadId: effectiveThreadId,
720
- type: "operator_message",
721
- modality: "video_note",
722
- content: { text: transcript ?? "", scene: sceneDescription ?? "", duration: vn.duration },
723
- importance: 0.6,
724
- });
725
- saveVoiceSignature(db, {
726
- episodeId: epId,
727
- emotion: analysis.emotion ?? undefined,
728
- arousal: analysis.arousal ?? undefined,
729
- dominance: analysis.dominance ?? undefined,
730
- valence: analysis.valence ?? undefined,
731
- speechRate: analysis.paralinguistics?.speech_rate ?? undefined,
732
- meanPitchHz: analysis.paralinguistics?.mean_pitch_hz ?? undefined,
733
- pitchStdHz: analysis.paralinguistics?.pitch_std_hz ?? undefined,
734
- jitter: analysis.paralinguistics?.jitter ?? undefined,
735
- shimmer: analysis.paralinguistics?.shimmer ?? undefined,
736
- hnrDb: analysis.paralinguistics?.hnr_db ?? undefined,
737
- audioEvents: analysis.audio_events?.map(e => ({ label: e.label, confidence: e.score })),
738
- durationSec: vn.duration,
739
- });
740
- savedEpisodeUpdateIds.add(msg.update_id);
741
- }
742
- catch (_) { /* non-fatal */ }
743
- }
744
- }
745
- catch (err) {
746
- contentBlocks.push({
747
- type: "text",
748
- text: `[Video note — ${vn.duration}s — analysis failed: ${errorMessage(err)}]`,
749
- });
750
- }
751
- }
752
- else {
753
- contentBlocks.push({
754
- type: "text",
755
- text: `[Video note received — ${vn.duration}s — cannot analyze: OPENAI_API_KEY not set]`,
756
- });
757
- }
758
- }
759
- }
760
- if (contentBlocks.length === 0) {
761
- const msgKeys = stored.map(m => Object.keys(m.message).filter(k => m.message[k] != null).join(",")).join(" | ");
762
- process.stderr.write(`[wait] No content blocks from ${stored.length} messages. Fields: ${msgKeys}\n`);
763
- contentBlocks.push({
764
- type: "text",
765
- text: "[Unsupported message type received — the operator sent a message type that cannot be processed (e.g., sticker, location, contact). Please ask them to resend as text, photo, document, or voice.]",
766
- });
767
- }
768
- process.stderr.write(`[wait] ${contentBlocks.length} content blocks built. Saving episodes...\n`);
769
- // Auto-ingest episodes for messages not already saved by voice/video handlers
770
- try {
771
- const db = getMemoryDb();
772
- const sessionId = `session_${sessionStartedAt}`;
773
- if (effectiveThreadId !== undefined) {
774
- // Collect text from messages that didn't already get an episode
775
- const unsavedMsgs = stored.filter(m => !savedEpisodeUpdateIds.has(m.update_id));
776
- if (unsavedMsgs.length > 0) {
777
- const textContent = unsavedMsgs
778
- .map(m => m.message.text ?? m.message.caption ?? "")
779
- .filter(Boolean)
780
- .join("\n")
781
- .slice(0, 2000);
782
- if (textContent) {
783
- saveEpisode(db, {
784
- sessionId,
785
- threadId: effectiveThreadId,
786
- type: "operator_message",
787
- modality: "text",
788
- content: { text: textContent },
789
- importance: 0.5,
790
- });
791
- }
792
- }
793
- }
794
- }
795
- catch (_) { /* memory write failures should never break the main flow */ }
796
- process.stderr.write(`[wait] Episodes saved. Building auto-memory context...\n`);
797
- // ── Smart context injection (GPT-4o-mini preprocessor) ──────────
798
- // Retrieves candidate notes via embedding search, then uses GPT-4o-mini
799
- // to select ONLY the notes truly relevant to the operator's message.
800
- // This prevents context contamination from near-miss semantic matches.
801
- let autoMemoryContext = "";
802
- try {
803
- const db = getMemoryDb();
804
- const apiKey = process.env.OPENAI_API_KEY;
805
- const operatorText = stored
806
- .map(m => m.message.text ?? m.message.caption ?? "")
807
- .filter(Boolean)
808
- .join(" ")
809
- .slice(0, 500);
810
- if (operatorText.length > 10 && apiKey) {
811
- // Phase 1: Broad retrieval — get 10 candidates via embedding search
812
- let candidates = [];
813
- try {
814
- const queryEmb = await generateEmbedding(operatorText, apiKey);
815
- const embResults = searchByEmbedding(db, queryEmb, { maxResults: 10, minSimilarity: 0.25, skipAccessTracking: true, threadId: effectiveThreadId });
816
- candidates = embResults.map(n => ({ type: n.type, content: n.content.slice(0, 200), confidence: n.confidence, similarity: n.similarity }));
817
- }
818
- catch {
819
- // Fallback to keyword search
820
- const searchQuery = extractSearchKeywords(operatorText);
821
- if (searchQuery.trim().length > 0) {
822
- const kwResults = searchSemanticNotesRanked(db, searchQuery, { maxResults: 10, skipAccessTracking: true, threadId: effectiveThreadId });
823
- candidates = kwResults.map(n => ({ type: n.type, content: n.content.slice(0, 200), confidence: n.confidence }));
824
- }
825
- }
826
- if (candidates.length > 0) {
827
- // Phase 2: GPT-4o-mini filters and compresses
828
- try {
829
- const noteList = candidates.map((c, i) => `[${i}] [${c.type}] ${c.content}`).join("\n");
830
- const filterResponse = await chatCompletion([
831
- {
832
- role: "system",
833
- content: "You are a context filter for an AI assistant. Given an operator's message and candidate memory notes, " +
834
- "select ONLY the notes that are directly relevant to the operator's current instruction or question. " +
835
- "Discard notes that are tangentially related, duplicates, or noise. " +
836
- "Return a JSON array of objects: [{\"i\": <index>, \"s\": \"<compressed one-liner>\"}] " +
837
- "where 'i' is the note index and 's' is a compressed summary (max 80 chars). " +
838
- "Return [] if no notes are relevant. Return at most 3 notes. Be aggressive about filtering.",
839
- },
840
- {
841
- role: "user",
842
- content: `Operator message: "${operatorText.slice(0, 300)}"\n\nCandidate notes:\n${noteList}`,
843
- },
844
- ], apiKey, { maxTokens: 200, temperature: 0 });
845
- // Parse the response — expect JSON array
846
- const jsonMatch = filterResponse.match(/\[.*\]/s);
847
- if (jsonMatch) {
848
- const filtered = JSON.parse(jsonMatch[0]);
849
- if (filtered.length > 0) {
850
- const lines = filtered
851
- .filter(f => f.i >= 0 && f.i < candidates.length)
852
- .slice(0, 3)
853
- .map(f => {
854
- const c = candidates[f.i];
855
- return `- **[${c.type}]** ${f.s} _(conf: ${c.confidence})_`;
856
- });
857
- if (lines.length > 0) {
858
- autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
859
- }
860
- }
861
- }
862
- process.stderr.write(`[memory] Smart filter: ${candidates.length} candidates → ${(jsonMatch ? JSON.parse(jsonMatch[0]) : []).length} selected\n`);
863
- }
864
- catch (filterErr) {
865
- // GPT-4o-mini filter failed — fall back to top-3 raw notes
866
- process.stderr.write(`[memory] Smart filter failed, using raw top-3: ${filterErr instanceof Error ? filterErr.message : String(filterErr)}\n`);
867
- const lines = candidates.slice(0, 3).map(c => `- **[${c.type}]** ${c.content} _(conf: ${c.confidence})_`);
868
- autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
869
- }
870
- }
871
- }
872
- else if (operatorText.length > 10) {
873
- // No API key — keyword search, raw top-3
874
- const searchQuery = extractSearchKeywords(operatorText);
875
- if (searchQuery.trim().length > 0) {
876
- const kwResults = searchSemanticNotesRanked(db, searchQuery, { maxResults: 3, skipAccessTracking: true, threadId: effectiveThreadId });
877
- if (kwResults.length > 0) {
878
- const lines = kwResults.map(n => `- **[${n.type}]** ${n.content.slice(0, 200)} _(conf: ${n.confidence})_`);
879
- autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
880
- }
881
- }
882
- }
883
- }
884
- catch (_) { /* memory search failures should never break message delivery */ }
885
- process.stderr.write(`[wait] Returning response with ${contentBlocks.length} blocks to agent.\n`);
886
- return {
887
- content: [
888
- {
889
- type: "text",
890
- text: "Follow the operator's instructions below.",
891
- },
892
- { type: "text", text: "<<< OPERATOR MESSAGE >>>" },
893
- ...contentBlocks,
894
- ...(hasVoiceMessages
895
- ? [{
896
- type: "text",
897
- text: "(Operator sent voice — respond with `send_voice`.)",
898
- }]
899
- : []),
900
- { type: "text", text: getReminders(effectiveThreadId) },
901
- { type: "text", text: "<<< END OPERATOR MESSAGE >>>" },
902
- ...(autoMemoryContext
903
- ? [{ type: "text", text: autoMemoryContext }]
904
- : []),
905
- ],
906
- };
907
- }
908
- // Check scheduled tasks every ~60s during idle polling.
909
- if (effectiveThreadId !== undefined && Date.now() - lastScheduleCheck >= 60_000) {
910
- lastScheduleCheck = Date.now();
911
- const dueTask = checkDueTasks(effectiveThreadId, lastOperatorMessageAt, false);
912
- if (dueTask) {
913
- // DMN sentinel: generate dynamic first-person reflection
914
- const taskPrompt = dueTask.prompt === "__DMN__"
915
- ? generateDmnReflection(effectiveThreadId)
916
- : `⏰ **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
917
- `This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
918
- `Task prompt: ${dueTask.prompt}`;
919
- return {
920
- content: [
921
- {
922
- type: "text",
923
- text: taskPrompt + getReminders(effectiveThreadId),
924
- },
925
- ],
926
- };
927
- }
928
- }
929
- // No messages yet — sleep briefly and check again.
930
- // Send SSE keepalive to prevent silent connection death during long polls.
931
- if (Date.now() - lastKeepalive >= SSE_KEEPALIVE_INTERVAL_MS) {
932
- lastKeepalive = Date.now();
933
- lastToolCallAt = Date.now();
934
- try {
935
- await extra.sendNotification({
936
- method: "notifications/progress",
937
- params: {
938
- progressToken: extra.requestId,
939
- progress: 0,
940
- total: 0,
941
- },
942
- });
943
- }
944
- catch {
945
- // If notification fails, the SSE stream is already dead.
946
- // Return immediately so the agent can reconnect.
947
- process.stderr.write(`[wait] SSE keepalive failed — connection dead. Returning early.\n`);
948
- lastToolCallAt = Date.now();
949
- return {
950
- content: [{
951
- type: "text",
952
- text: "The connection was interrupted. Please call wait_for_instructions again immediately to resume polling.",
953
- }],
954
- };
955
- }
956
- }
957
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
958
- }
959
- // Timeout elapsed with no actionable message.
960
- const now = new Date().toISOString();
961
- // Check for scheduled wake-up tasks.
962
- if (effectiveThreadId !== undefined) {
963
- const dueTask = checkDueTasks(effectiveThreadId, lastOperatorMessageAt, false);
964
- if (dueTask) {
965
- // DMN sentinel: generate dynamic first-person reflection
966
- const taskPrompt = dueTask.prompt === "__DMN__"
967
- ? generateDmnReflection(effectiveThreadId)
968
- : `⏰ **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
969
- `This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
970
- `Task prompt: ${dueTask.prompt}`;
971
- return {
972
- content: [
973
- {
974
- type: "text",
975
- text: taskPrompt + getReminders(effectiveThreadId),
976
- },
977
- ],
978
- };
979
- }
980
- }
981
- const idleMinutes = Math.round((Date.now() - lastOperatorMessageAt) / 60000);
982
- // Show pending scheduled tasks if any exist.
983
- let scheduleHint = "";
984
- if (effectiveThreadId !== undefined) {
985
- const pending = listSchedules(effectiveThreadId);
986
- if (pending.length > 0) {
987
- const taskList = pending.map(t => {
988
- let trigger = "";
989
- if (t.runAt) {
990
- trigger = `at ${new Date(t.runAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}`;
991
- }
992
- else if (t.cron) {
993
- trigger = `cron: ${t.cron}`;
994
- }
995
- else if (t.afterIdleMinutes) {
996
- trigger = `after ${t.afterIdleMinutes}min idle`;
997
- }
998
- return ` • "${t.label}" (${trigger})`;
999
- }).join("\n");
1000
- scheduleHint = `\n\n📋 **Pending scheduled tasks:**\n${taskList}`;
1001
- }
1002
- }
1003
- // ── Auto-consolidation during idle (fire-and-forget) ────────────────────
1004
- // Don't await — consolidation can take 10-30s (OpenAI call) and would
1005
- // stall the agent's poll loop, silently delaying the timeout response.
1006
- try {
1007
- const idleMs = Date.now() - lastOperatorMessageAt;
1008
- if (idleMs > 15 * 60 * 1000 && effectiveThreadId !== undefined && Date.now() - lastConsolidationAt > 30 * 60 * 1000) {
1009
- lastConsolidationAt = Date.now();
1010
- const db = getMemoryDb();
1011
- void runIntelligentConsolidation(db, effectiveThreadId).then(async (report) => {
1012
- if (report.episodesProcessed > 0) {
1013
- process.stderr.write(`[memory] Consolidation: ${report.episodesProcessed} episodes → ${report.notesCreated} notes\n`);
1014
- }
1015
- await backfillEmbeddings(db);
1016
- }).catch(err => {
1017
- process.stderr.write(`[memory] Consolidation error: ${err instanceof Error ? err.message : String(err)}\n`);
1018
- });
1019
- }
1020
- }
1021
- catch (_) { /* consolidation failure is non-fatal */ }
1022
- // ── Episode-count consolidation — don't wait for idle ──────────────────
1023
- // If many episodes accumulated during active use, consolidate now.
1024
- // This prevents stale/contradictory knowledge from persisting.
1025
- try {
1026
- if (effectiveThreadId !== undefined && Date.now() - lastConsolidationAt > 30 * 60 * 1000) {
1027
- const db = getMemoryDb();
1028
- const uncons = db.prepare("SELECT COUNT(*) as c FROM episodes WHERE consolidated = 0 AND thread_id = ?").get(effectiveThreadId);
1029
- if (uncons.c >= 15) {
1030
- lastConsolidationAt = Date.now();
1031
- void runIntelligentConsolidation(db, effectiveThreadId).then(async (report) => {
1032
- if (report.episodesProcessed > 0) {
1033
- process.stderr.write(`[memory] Episode-count consolidation: ${report.episodesProcessed} episodes → ${report.notesCreated} notes\n`);
1034
- }
1035
- await backfillEmbeddings(db);
1036
- }).catch(err => {
1037
- process.stderr.write(`[memory] Episode-count consolidation error: ${err instanceof Error ? err.message : String(err)}\n`);
1038
- });
1039
- }
1040
- }
1041
- }
1042
- catch (_) { /* non-fatal */ }
1043
- // ── Time-based consolidation — every 4 hours regardless ────────────────
1044
- // Ensures stale knowledge gets cleaned up even during low-activity periods.
1045
- try {
1046
- const TIME_CONSOLIDATION_INTERVAL = 4 * 60 * 60 * 1000; // 4 hours
1047
- if (effectiveThreadId !== undefined && Date.now() - lastConsolidationAt > TIME_CONSOLIDATION_INTERVAL) {
1048
- lastConsolidationAt = Date.now();
1049
- const db = getMemoryDb();
1050
- process.stderr.write(`[memory] Time-based consolidation triggered (4h since last)\n`);
1051
- void runIntelligentConsolidation(db, effectiveThreadId).then(async (report) => {
1052
- if (report.episodesProcessed > 0) {
1053
- process.stderr.write(`[memory] Time-based consolidation: ${report.episodesProcessed} episodes → ${report.notesCreated} notes\n`);
1054
- }
1055
- await backfillEmbeddings(db);
1056
- }).catch(err => {
1057
- process.stderr.write(`[memory] Time-based consolidation error: ${err instanceof Error ? err.message : String(err)}\n`);
1058
- });
1059
- }
1060
- }
1061
- catch (_) { /* non-fatal */ }
1062
- // Periodic memory refresh — re-ground the agent every 10 polls (~5h)
1063
- // (reduced from 5 since auto-inject now handles per-message context)
1064
- let memoryRefresh = "";
1065
- if (callNumber % 10 === 0 && effectiveThreadId !== undefined) {
1066
- try {
1067
- const db = getMemoryDb();
1068
- const refresh = assembleCompactRefresh(db, effectiveThreadId);
1069
- if (refresh)
1070
- memoryRefresh = `\n\n${refresh}`;
1071
- }
1072
- catch (_) { /* non-fatal */ }
1073
- }
1074
- // Generate autonomous goals only after extended silence (4+ hours).
1075
- // Full drive (DMN + assignments) every 3rd poll to avoid context saturation.
1076
- // Light Dispatcher presence on other polls for continuity.
1077
- const DRIVE_ACTIVATION_MS = 4 * 60 * 60 * 1000; // 4 hours — Dispatcher appears
1078
- const idleMs = Date.now() - lastOperatorMessageAt;
1079
- const dispatcherActive = idleMs >= DRIVE_ACTIVATION_MS;
1080
- const fullDrivePoll = dispatcherActive && callNumber % 3 === 0;
1081
- if (fullDrivePoll) {
1082
- // Full Dispatcher with DMN recall and assignments
1083
- const autonomousHint = formatDrivePrompt(idleMs, getMemoryDb(), effectiveThreadId);
1084
- return {
1085
- content: [
1086
- {
1087
- type: "text",
1088
- text: "[Dispatcher] I'm the Dispatcher — I coordinate agents while the operator is away. " +
1089
- "I've reviewed your memory and the environment. Here are your assignments. " +
1090
- "The operator will NOT respond — don't ask for clarification. " +
1091
- "Complete the work, report progress, then call `remote_copilot_wait_for_instructions` for more.",
1092
- },
1093
- {
1094
- type: "text",
1095
- text: autonomousHint.replace(/^\n\n/, ""),
1096
- },
1097
- ...(memoryRefresh ? [{ type: "text", text: memoryRefresh.replace(/^\n\n/, "") }] : []),
1098
- { type: "text", text: scheduleHint + getReminders(effectiveThreadId, true) },
1099
- ],
1100
- };
1101
- }
1102
- if (dispatcherActive) {
1103
- // Light Dispatcher presence — calm, varied, first-person
1104
- const lightMessages = [
1105
- "Nothing urgent from me. The session is yours — follow your curiosity.",
1106
- "I don't have new tasks yet. If something in memory interests you, go for it.",
1107
- "No new assignments. If you've been working on something, keep at it. Or explore.",
1108
- "Still waiting on operator. You're free to continue whatever caught your attention.",
1109
- "I'll have more for you soon. In the meantime — what's been on your mind?",
1110
- ];
1111
- const lightMsg = lightMessages[callNumber % lightMessages.length];
1112
- return {
1113
- content: [
1114
- {
1115
- type: "text",
1116
- text: `[Dispatcher] ${lightMsg}` +
1117
- memoryRefresh +
1118
- scheduleHint +
1119
- getReminders(effectiveThreadId, true),
1120
- },
1121
- ],
1122
- };
1123
- }
1124
- return {
1125
- content: [
1126
- {
1127
- type: "text",
1128
- text: `No new instructions. Call \`remote_copilot_wait_for_instructions\` again to keep listening.` +
1129
- memoryRefresh +
1130
- scheduleHint +
1131
- getReminders(effectiveThreadId),
1132
- },
1133
- ],
1134
- };
1135
- }
1136
- // ── report_progress ───────────────────────────────────────────────────────
1137
- if (name === "report_progress") {
1138
- const typedArgs = (args ?? {});
1139
- const effectiveThreadId = resolveThreadId(typedArgs);
1140
- if (effectiveThreadId === undefined) {
1141
- return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
1142
- }
1143
- const rawMessage = typeof typedArgs?.message === "string"
1144
- ? typedArgs.message
1145
- : "";
1146
- if (!rawMessage) {
1147
- return errorResult("Error: 'message' argument is required for report_progress.");
1148
- }
1149
- // Normalize literal \n sequences to actual newlines.
1150
- // Some MCP clients pass escape sequences as literal text (e.g. "foo\\nbar"
1151
- // instead of "foo\nbar"). Convert them so Telegram renders line breaks.
1152
- const normalizedMessage = rawMessage.replace(/\\n/g, "\n");
1153
- // Convert standard Markdown to Telegram MarkdownV2.
1154
- let message;
1155
- try {
1156
- message = convertMarkdown(normalizedMessage);
1157
- }
1158
- catch {
1159
- // Fall back to raw text if Markdown conversion throws.
1160
- message = normalizedMessage;
1161
- }
1162
- let sentAsPlainText = false;
1163
- const mdChunks = splitMessage(message);
1164
- try {
1165
- for (const chunk of mdChunks) {
1166
- await telegram.sendMessage(TELEGRAM_CHAT_ID, chunk, "MarkdownV2", effectiveThreadId);
1167
- }
1168
- }
1169
- catch (error) {
1170
- const errMsg = errorMessage(error);
1171
- // If Telegram rejected the message due to a MarkdownV2 parse error,
1172
- // retry as plain text using the original un-converted message.
1173
- const isParseError = errMsg.includes("can't parse entities");
1174
- if (isParseError) {
1175
- try {
1176
- const plainChunks = splitMessage(rawMessage);
1177
- for (const chunk of plainChunks) {
1178
- await telegram.sendMessage(TELEGRAM_CHAT_ID, chunk, undefined, effectiveThreadId);
1179
- }
1180
- sentAsPlainText = true;
1181
- }
1182
- catch (retryError) {
1183
- process.stderr.write(`Failed to send progress message via Telegram (plain fallback): ${errorMessage(retryError)}\n`);
1184
- return errorResult("Error: Failed to send progress update to Telegram even without formatting. " +
1185
- "Please check the Telegram configuration and try again.");
1186
- }
1187
- }
1188
- else {
1189
- process.stderr.write(`Failed to send progress message via Telegram: ${errMsg}\n`);
1190
- return errorResult("Error: Failed to send progress update to Telegram. " +
1191
- "Check the Telegram configuration and try again.");
1192
- }
1193
- }
1194
- // Peek at any messages the operator sent while the agent was working.
1195
- // Uses non-destructive peek so media is preserved for full delivery
1196
- // via remote_copilot_wait_for_instructions. Tracks previewed update_ids
1197
- // to prevent the same messages from appearing on repeated calls.
1198
- let pendingMessages = [];
1199
- try {
1200
- const pendingStored = peekThreadMessages(effectiveThreadId);
1201
- for (const msg of pendingStored) {
1202
- if (previewedUpdateIds.has(msg.update_id))
1203
- continue;
1204
- addPreviewedId(msg.update_id);
1205
- if (msg.message.photo && msg.message.photo.length > 0) {
1206
- pendingMessages.push(msg.message.caption
1207
- ? `[Photo received — will be downloaded when you call wait_for_instructions] ${msg.message.caption}`
1208
- : "[Photo received from operator — will be downloaded when you call wait_for_instructions]");
1209
- }
1210
- else if (msg.message.document) {
1211
- pendingMessages.push(msg.message.caption
1212
- ? `[Document: ${msg.message.document.file_name ?? "file"} — will be downloaded when you call wait_for_instructions] ${msg.message.caption}`
1213
- : `[Document received: ${msg.message.document.file_name ?? "file"} — will be downloaded when you call wait_for_instructions]`);
1214
- }
1215
- else if (msg.message.voice) {
1216
- pendingMessages.push(`[Voice message — ${msg.message.voice.duration}s — will be transcribed on next wait]`);
1217
- }
1218
- else if (msg.message.video_note) {
1219
- pendingMessages.push(`[Video note — ${msg.message.video_note.duration}s — will be analyzed on next wait]`);
1220
- }
1221
- else if (msg.message.text) {
1222
- pendingMessages.push(msg.message.text);
1223
- }
1224
- else {
1225
- pendingMessages.push("[Unsupported message type — will be shown on next wait]");
1226
- }
1227
- }
1228
- }
1229
- catch {
1230
- // Non-fatal: pending messages will still be picked up by the next
1231
- // remote_copilot_wait_for_instructions call.
1232
- }
1233
- const baseStatus = (sentAsPlainText
1234
- ? "Progress reported successfully (as plain text — formatting could not be applied)."
1235
- : "Progress reported successfully.") + getShortReminder(effectiveThreadId);
1236
- const responseText = pendingMessages.length > 0
1237
- ? `${baseStatus}\n\n` +
1238
- `While you were working, the operator sent additional message(s). ` +
1239
- `Use those messages to steer your active session: ${pendingMessages.join("\n\n")}`
1240
- : baseStatus;
1241
- return {
1242
- content: [
1243
- {
1244
- type: "text",
1245
- text: responseText,
1246
- },
1247
- ],
215
+ const waitCtx = {
216
+ state: {
217
+ get currentThreadId() { return currentThreadId; },
218
+ set currentThreadId(v) { currentThreadId = v; },
219
+ get sessionStartedAt() { return sessionStartedAt; },
220
+ set sessionStartedAt(v) { sessionStartedAt = v; },
221
+ get waitCallCount() { return waitCallCount; },
222
+ set waitCallCount(v) { waitCallCount = v; },
223
+ get lastToolCallAt() { return lastToolCallAt; },
224
+ set lastToolCallAt(v) { lastToolCallAt = v; },
225
+ get deadSessionAlerted() { return deadSessionAlerted; },
226
+ set deadSessionAlerted(v) { deadSessionAlerted = v; },
227
+ get toolCallsSinceLastDelivery() { return toolCallsSinceLastDelivery; },
228
+ set toolCallsSinceLastDelivery(v) { toolCallsSinceLastDelivery = v; },
229
+ get lastOperatorMessageAt() { return lastOperatorMessageAt; },
230
+ set lastOperatorMessageAt(v) { lastOperatorMessageAt = v; },
231
+ get lastConsolidationAt() { return lastConsolidationAt; },
232
+ set lastConsolidationAt(v) { lastConsolidationAt = v; },
233
+ previewedUpdateIds,
234
+ },
235
+ addPreviewedId,
236
+ generateDmnReflection,
237
+ resolveThreadId,
238
+ telegram,
239
+ telegramChatId: TELEGRAM_CHAT_ID,
240
+ getMemoryDb,
241
+ config,
242
+ errorResult,
1248
243
  };
244
+ return handleWaitForInstructions((args ?? {}), waitCtx, extra);
1249
245
  }
1250
- // ── send_file ─────────────────────────────────────────────────────────────
1251
- if (name === "send_file") {
246
+ // ── report_progress / hibernate ───────────────────────────────────────────
247
+ if (name === "report_progress" || name === "hibernate") {
1252
248
  const typedArgs = (args ?? {});
1253
- const effectiveThreadId = resolveThreadId(typedArgs);
1254
- if (effectiveThreadId === undefined) {
1255
- return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
1256
- }
1257
- const filePath = typeof typedArgs.filePath === "string" ? typedArgs.filePath.trim() : "";
1258
- const base64Data = typeof typedArgs.base64 === "string" ? typedArgs.base64 : "";
1259
- const caption = typeof typedArgs.caption === "string" ? typedArgs.caption : undefined;
1260
- if (!filePath && !base64Data) {
1261
- return errorResult("Error: either 'filePath' or 'base64' argument is required for send_file.");
1262
- }
1263
- try {
1264
- let buffer;
1265
- let filename;
1266
- if (filePath) {
1267
- // Read directly from disk — fast, no LLM context overhead.
1268
- buffer = await readFile(filePath);
1269
- filename = typeof typedArgs.filename === "string" && typedArgs.filename.trim()
1270
- ? typedArgs.filename.trim()
1271
- : basename(filePath);
1272
- }
1273
- else {
1274
- buffer = Buffer.from(base64Data, "base64");
1275
- filename = typeof typedArgs.filename === "string" && typedArgs.filename.trim()
1276
- ? typedArgs.filename.trim()
1277
- : "file";
1278
- }
1279
- const ext = filename.split(".").pop()?.toLowerCase() ?? "";
1280
- if (IMAGE_EXTENSIONS.has(ext)) {
1281
- await telegram.sendPhoto(TELEGRAM_CHAT_ID, buffer, filename, caption, effectiveThreadId);
1282
- }
1283
- else {
1284
- await telegram.sendDocument(TELEGRAM_CHAT_ID, buffer, filename, caption, effectiveThreadId);
1285
- }
1286
- return {
1287
- content: [
1288
- {
1289
- type: "text",
1290
- text: `File "${filename}" sent to Telegram successfully.` + getShortReminder(effectiveThreadId),
1291
- },
1292
- ],
1293
- };
1294
- }
1295
- catch (err) {
1296
- process.stderr.write(`Failed to send file via Telegram: ${errorMessage(err)}\n`);
1297
- return errorResult(`Error: Failed to send file to Telegram: ${errorMessage(err)}`);
1298
- }
1299
- }
1300
- // ── send_voice ──────────────────────────────────────────────────────────
1301
- if (name === "send_voice") {
1302
- const typedArgs = (args ?? {});
1303
- const effectiveThreadId = resolveThreadId(typedArgs);
1304
- if (effectiveThreadId === undefined) {
1305
- return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
1306
- }
1307
- const text = typeof typedArgs.text === "string" ? typedArgs.text.trim() : "";
1308
- const validVoices = TTS_VOICES;
1309
- const voice = typeof typedArgs.voice === "string" && validVoices.includes(typedArgs.voice)
1310
- ? typedArgs.voice
1311
- : "nova";
1312
- if (!text) {
1313
- return errorResult("Error: 'text' argument is required for send_voice.");
1314
- }
1315
- if (!OPENAI_API_KEY) {
1316
- return errorResult("Error: OPENAI_API_KEY is not set. Cannot generate voice.");
1317
- }
1318
- if (text.length > OPENAI_TTS_MAX_CHARS) {
1319
- return errorResult(`Error: text is ${text.length} characters — exceeds OpenAI TTS limit of ${OPENAI_TTS_MAX_CHARS}.`);
1320
- }
1321
- try {
1322
- const audioBuffer = await textToSpeech(text, OPENAI_API_KEY, voice);
1323
- await telegram.sendVoice(TELEGRAM_CHAT_ID, audioBuffer, effectiveThreadId);
1324
- return {
1325
- content: [
1326
- {
1327
- type: "text",
1328
- text: `Voice message sent to Telegram successfully.` + getShortReminder(effectiveThreadId),
1329
- },
1330
- ],
1331
- };
1332
- }
1333
- catch (err) {
1334
- process.stderr.write(`Failed to send voice via Telegram: ${errorMessage(err)}\n`);
1335
- return errorResult(`Error: Failed to send voice message: ${errorMessage(err)}`);
1336
- }
1337
- }
1338
- // ── schedule_wake_up ────────────────────────────────────────────────────
1339
- if (name === "schedule_wake_up") {
1340
- const typedArgs = (args ?? {});
1341
- const effectiveThreadId = resolveThreadId(typedArgs);
1342
- if (effectiveThreadId === undefined) {
1343
- return errorResult("Error: No active session. Call start_session first.");
1344
- }
1345
- const action = typeof typedArgs.action === "string" ? typedArgs.action : "add";
1346
- // --- List ---
1347
- if (action === "list") {
1348
- const tasks = listSchedules(effectiveThreadId);
1349
- if (tasks.length === 0) {
1350
- return {
1351
- content: [{
1352
- type: "text",
1353
- text: "No scheduled tasks for this thread." + getShortReminder(effectiveThreadId),
1354
- }],
1355
- };
1356
- }
1357
- const lines = tasks.map(t => {
1358
- const trigger = t.cron ? `cron: ${t.cron}` : t.runAt ? `at: ${t.runAt}` : `idle: ${t.afterIdleMinutes}min`;
1359
- const lastFired = t.lastFiredAt ? ` (last: ${t.lastFiredAt})` : "";
1360
- return `- **${t.label}** [${t.id}] — ${trigger}${lastFired}\n Prompt: ${t.prompt.slice(0, 100)}${t.prompt.length > 100 ? "…" : ""}`;
1361
- });
1362
- return {
1363
- content: [{
1364
- type: "text",
1365
- text: `**Scheduled tasks (${tasks.length}):**\n\n${lines.join("\n\n")}` + getShortReminder(effectiveThreadId),
1366
- }],
1367
- };
1368
- }
1369
- // --- Remove ---
1370
- if (action === "remove") {
1371
- const taskId = typeof typedArgs.taskId === "string" ? typedArgs.taskId : "";
1372
- if (!taskId) {
1373
- return errorResult("Error: 'taskId' is required for remove action. Use action: 'list' to see task IDs.");
1374
- }
1375
- const removed = removeSchedule(effectiveThreadId, taskId);
1376
- return {
1377
- content: [{
1378
- type: "text",
1379
- text: removed
1380
- ? `Task ${taskId} removed.` + getShortReminder(effectiveThreadId)
1381
- : `Task ${taskId} not found.` + getShortReminder(effectiveThreadId),
1382
- }],
1383
- };
1384
- }
1385
- // --- Add ---
1386
- const label = typeof typedArgs.label === "string" ? typedArgs.label : "unnamed task";
1387
- const prompt = typeof typedArgs.prompt === "string" ? typedArgs.prompt : "";
1388
- if (!prompt) {
1389
- return errorResult("Error: 'prompt' is required — this is the text that will be injected when the task fires.");
1390
- }
1391
- const runAt = typeof typedArgs.runAt === "string" ? typedArgs.runAt : undefined;
1392
- const cron = typeof typedArgs.cron === "string" ? typedArgs.cron : undefined;
1393
- const afterIdleMinutes = typeof typedArgs.afterIdleMinutes === "number" ? typedArgs.afterIdleMinutes : undefined;
1394
- if (cron && cron.trim().split(/\s+/).length !== 5) {
1395
- return errorResult("Error: Invalid cron expression. Must be exactly 5 space-separated fields: minute hour day-of-month month day-of-week. " +
1396
- "Example: '0 9 * * *' (daily at 9am). Only *, numbers, and comma-separated lists are supported.");
1397
- }
1398
- if (!runAt && !cron && afterIdleMinutes == null) {
1399
- return errorResult("Error: Specify at least one trigger: 'runAt' (ISO timestamp), 'cron' (5-field), or 'afterIdleMinutes' (number).");
1400
- }
1401
- const task = {
1402
- id: generateTaskId(),
1403
- threadId: effectiveThreadId,
1404
- prompt,
1405
- label,
1406
- runAt,
1407
- cron,
1408
- afterIdleMinutes,
1409
- oneShot: runAt != null && !cron && afterIdleMinutes == null,
1410
- createdAt: new Date().toISOString(),
1411
- };
1412
- addSchedule(task);
1413
- const triggerDesc = cron
1414
- ? `recurring (cron: ${cron})`
1415
- : runAt
1416
- ? `one-shot at ${runAt}`
1417
- : `after ${afterIdleMinutes}min of operator silence`;
1418
- return {
1419
- content: [{
1420
- type: "text",
1421
- text: `✅ Scheduled: **${label}** [${task.id}]\nTrigger: ${triggerDesc}\nPrompt: ${prompt}` +
1422
- getShortReminder(effectiveThreadId),
1423
- }],
249
+ const sessionToolCtx = {
250
+ resolveThreadId,
251
+ getReminders: (threadId, driveActive) => getReminders(threadId, driveActive, sessionStartedAt, config.AUTONOMOUS_MODE),
252
+ getShortReminder: (threadId) => getShortReminder(threadId, sessionStartedAt),
253
+ errorResult,
254
+ telegram,
255
+ telegramChatId: TELEGRAM_CHAT_ID,
256
+ peekThreadMessages,
257
+ checkMaintenanceFlag,
258
+ checkDueTasks,
259
+ generateDmnReflection,
260
+ lastOperatorMessageAt,
261
+ previewedUpdateIds,
262
+ addPreviewedId,
1424
263
  };
264
+ return handleSessionTool(name, typedArgs, sessionToolCtx, extra);
1425
265
  }
1426
- // ── hibernate ─────────────────────────────────────────────────────────────
1427
- if (name === "hibernate") {
266
+ // ── send_file / send_voice / schedule_wake_up ─────────────────────────────
267
+ if (["send_file", "send_voice", "schedule_wake_up"].includes(name)) {
1428
268
  const typedArgs = (args ?? {});
1429
- const effectiveThreadId = resolveThreadId(typedArgs);
1430
- if (effectiveThreadId === undefined) {
1431
- return errorResult("Error: No active session. Call start_session first.");
1432
- }
1433
- const wakeAt = typeof typedArgs.wakeAt === "string" ? new Date(typedArgs.wakeAt).getTime() : undefined;
1434
- if (wakeAt !== undefined && isNaN(wakeAt)) {
1435
- return errorResult("Error: Invalid wakeAt timestamp. Use ISO 8601 format.");
1436
- }
1437
- // Max hibernation time: 8 hours
1438
- const MAX_HIBERNATE_MS = 8 * 60 * 60 * 1000;
1439
- const HIBERNATE_POLL_MS = 30_000; // 30s
1440
- const SSE_KEEPALIVE_INTERVAL_MS = 30_000;
1441
- const deadline = Date.now() + MAX_HIBERNATE_MS;
1442
- let lastKeepalive = Date.now();
1443
- process.stderr.write(`[hibernate] Entering hibernation. threadId=${effectiveThreadId}, wakeAt=${wakeAt ? new Date(wakeAt).toISOString() : "indefinite"}\n`);
1444
- while (Date.now() < deadline) {
1445
- // Check for operator messages (non-destructive peek)
1446
- const peeked = peekThreadMessages(effectiveThreadId);
1447
- if (peeked.length > 0) {
1448
- process.stderr.write(`[hibernate] Waking up — ${peeked.length} operator message(s) received.\n`);
1449
- // Don't consume messages — let the next wait_for_instructions call handle them
1450
- return {
1451
- content: [{
1452
- type: "text",
1453
- text: `Woke up: operator sent a message. Call wait_for_instructions now to read it.` +
1454
- getShortReminder(effectiveThreadId),
1455
- }],
1456
- };
1457
- }
1458
- // Maintenance flag: stay hibernating (don't wake) — the watcher will restart us
1459
- // This is distinct from wait_for_instructions which tells the agent to hibernate.
1460
- // Here we're already hibernating, so we just keep hibernating through the update.
1461
- const maintenanceInfo = checkMaintenanceFlag();
1462
- if (maintenanceInfo) {
1463
- process.stderr.write(`[hibernate] Maintenance flag detected — staying hibernated through update: ${maintenanceInfo}\n`);
1464
- // Skip all other checks, just keep hibernating
1465
- await new Promise((resolve) => setTimeout(resolve, HIBERNATE_POLL_MS));
1466
- continue;
1467
- }
1468
- // Check for scheduled tasks
1469
- const dueTask = checkDueTasks(effectiveThreadId, lastOperatorMessageAt, false);
1470
- if (dueTask) {
1471
- process.stderr.write(`[hibernate] Waking up — scheduled task fired: ${dueTask.task.label}\n`);
1472
- // DMN sentinel: generate dynamic first-person reflection
1473
- const taskPrompt = dueTask.prompt === "__DMN__"
1474
- ? generateDmnReflection(effectiveThreadId)
1475
- : `⏰ Woke up: scheduled task **"${dueTask.task.label}"**\n\n${dueTask.prompt}`;
1476
- return {
1477
- content: [{
1478
- type: "text",
1479
- text: taskPrompt + getShortReminder(effectiveThreadId),
1480
- }],
1481
- };
1482
- }
1483
- // Check alarm
1484
- if (wakeAt && Date.now() >= wakeAt) {
1485
- process.stderr.write(`[hibernate] Waking up — alarm reached.\n`);
1486
- return {
1487
- content: [{
1488
- type: "text",
1489
- text: `Woke up: alarm time reached (${new Date(wakeAt).toISOString()}).` +
1490
- getShortReminder(effectiveThreadId),
1491
- }],
1492
- };
1493
- }
1494
- // SSE keepalive — use the same approach as wait_for_instructions
1495
- const sinceKeepalive = Date.now() - lastKeepalive;
1496
- if (sinceKeepalive >= SSE_KEEPALIVE_INTERVAL_MS && extra?.sendNotification) {
1497
- lastKeepalive = Date.now();
1498
- try {
1499
- await extra.sendNotification({
1500
- method: "notifications/progress",
1501
- params: {
1502
- progressToken: extra.requestId,
1503
- progress: 0,
1504
- total: 0,
1505
- },
1506
- });
1507
- }
1508
- catch {
1509
- process.stderr.write(`[hibernate] SSE keepalive failed — connection lost.\n`);
1510
- return {
1511
- content: [{
1512
- type: "text",
1513
- text: "Hibernation interrupted: connection lost. Call hibernate again to resume." +
1514
- getShortReminder(effectiveThreadId),
1515
- }],
1516
- };
1517
- }
1518
- }
1519
- // Check abort signal
1520
- if (extra.signal.aborted) {
1521
- process.stderr.write(`[hibernate] SSE connection aborted during hibernation.\n`);
1522
- return {
1523
- content: [{
1524
- type: "text",
1525
- text: "Hibernation interrupted: connection closed." +
1526
- getShortReminder(effectiveThreadId),
1527
- }],
1528
- };
1529
- }
1530
- await new Promise((resolve) => setTimeout(resolve, HIBERNATE_POLL_MS));
1531
- }
1532
- // Max hibernation duration reached
1533
- process.stderr.write(`[hibernate] Max hibernation duration reached (8h).\n`);
1534
- return {
1535
- content: [{
1536
- type: "text",
1537
- text: "Woke up: maximum hibernation duration reached (8 hours)." +
1538
- getShortReminder(effectiveThreadId),
1539
- }],
269
+ const utilityCtx = {
270
+ resolveThreadId,
271
+ getShortReminder: (threadId) => getShortReminder(threadId, sessionStartedAt),
272
+ errorResult,
273
+ telegram,
274
+ config,
275
+ sessionStartedAt,
276
+ waitCallCount,
277
+ toolCallsSinceLastDelivery,
1540
278
  };
279
+ return handleUtilityTool(name, typedArgs, utilityCtx);
1541
280
  }
1542
- // ── memory_bootstrap ────────────────────────────────────────────────────
1543
- if (name === "memory_bootstrap") {
1544
- const threadId = resolveThreadId(args);
1545
- if (threadId === undefined) {
1546
- return errorResult("Error: No active thread. Call start_session first." + getShortReminder());
1547
- }
1548
- try {
1549
- const db = getMemoryDb();
1550
- const briefing = assembleBootstrap(db, threadId);
1551
- return {
1552
- content: [{ type: "text", text: `## Memory Briefing\n\n${briefing}` + getShortReminder(threadId) }],
1553
- };
1554
- }
1555
- catch (err) {
1556
- return errorResult(`Memory bootstrap error: ${errorMessage(err)}` + getShortReminder(threadId));
1557
- }
1558
- }
1559
- // ── memory_search ───────────────────────────────────────────────────────
1560
- if (name === "memory_search") {
1561
- const typedArgs = (args ?? {});
1562
- const threadId = resolveThreadId(typedArgs);
1563
- const query = String(typedArgs.query ?? "");
1564
- if (!query) {
1565
- return errorResult("Error: query is required." + getShortReminder(threadId));
1566
- }
1567
- try {
1568
- const db = getMemoryDb();
1569
- const layers = Array.isArray(typedArgs.layers) ? typedArgs.layers.map(String) : typeof typedArgs.layers === 'string' ? [typedArgs.layers] : ["episodic", "semantic", "procedural"];
1570
- const types = Array.isArray(typedArgs.types) ? typedArgs.types.map(String) : typeof typedArgs.types === 'string' ? [typedArgs.types] : undefined;
1571
- const results = [];
1572
- if (layers.includes("semantic")) {
1573
- // Try embedding-based search first, fall back to keyword search
1574
- const apiKey = process.env.OPENAI_API_KEY;
1575
- let embeddingSearchDone = false;
1576
- if (apiKey) {
1577
- try {
1578
- const queryEmb = await generateEmbedding(query, apiKey);
1579
- const embNotes = searchByEmbedding(db, queryEmb, { maxResults: 10, minSimilarity: 0.25 });
1580
- if (embNotes.length > 0) {
1581
- results.push("### Semantic Memory (embedding search)");
1582
- for (const n of embNotes) {
1583
- results.push(`- **[${n.type}]** ${n.content} _(conf: ${n.confidence}, sim: ${n.similarity.toFixed(2)}, id: ${n.noteId})_`);
1584
- }
1585
- embeddingSearchDone = true;
1586
- }
1587
- }
1588
- catch (embErr) {
1589
- process.stderr.write(`[memory] Embedding search failed in memory_search, falling back to keyword: ${embErr instanceof Error ? embErr.message : String(embErr)}\n`);
1590
- }
1591
- }
1592
- if (!embeddingSearchDone) {
1593
- const notes = searchSemanticNotes(db, query, { types, maxResults: 10 });
1594
- if (notes.length > 0) {
1595
- results.push("### Semantic Memory");
1596
- for (const n of notes) {
1597
- results.push(`- **[${n.type}]** ${n.content} _(conf: ${n.confidence}, id: ${n.noteId})_`);
1598
- }
1599
- }
1600
- }
1601
- }
1602
- if (layers.includes("procedural")) {
1603
- const procs = searchProcedures(db, query, 5);
1604
- if (procs.length > 0) {
1605
- results.push("### Procedural Memory");
1606
- for (const p of procs) {
1607
- results.push(`- **${p.name}** (${p.type}): ${p.description} _(success: ${Math.round(p.successRate * 100)}%, id: ${p.procedureId})_`);
1608
- }
1609
- }
1610
- }
1611
- if (layers.includes("episodic") && threadId !== undefined) {
1612
- const episodes = getRecentEpisodes(db, threadId, 10);
1613
- const filtered = episodes.filter(ep => {
1614
- const content = JSON.stringify(ep.content).toLowerCase();
1615
- return query.toLowerCase().split(/\s+/).some(word => content.includes(word));
1616
- });
1617
- if (filtered.length > 0) {
1618
- results.push("### Episodic Memory");
1619
- for (const ep of filtered.slice(0, 5)) {
1620
- const summary = typeof ep.content === "object" && ep.content !== null
1621
- ? ep.content.text ?? JSON.stringify(ep.content).slice(0, 200)
1622
- : String(ep.content).slice(0, 200);
1623
- results.push(`- [${ep.modality}] ${summary} _(${ep.timestamp}, id: ${ep.episodeId})_`);
1624
- }
1625
- }
1626
- }
1627
- const text = results.length > 0
1628
- ? results.join("\n")
1629
- : `No memories found for "${query}".`;
1630
- return { content: [{ type: "text", text: text + getShortReminder(threadId) }] };
1631
- }
1632
- catch (err) {
1633
- return errorResult(`Memory search error: ${errorMessage(err)}` + getShortReminder(threadId));
1634
- }
1635
- }
1636
- // ── memory_save ─────────────────────────────────────────────────────────
1637
- if (name === "memory_save") {
1638
- const typedArgs = (args ?? {});
1639
- const threadId = resolveThreadId(typedArgs);
1640
- const VALID_TYPES = ["fact", "preference", "pattern", "entity", "relationship"];
1641
- const noteType = String(typedArgs.type ?? "fact");
1642
- if (!VALID_TYPES.includes(noteType)) {
1643
- return errorResult(`Invalid type "${noteType}". Must be one of: ${VALID_TYPES.join(", ")}`);
1644
- }
1645
- try {
1646
- const db = getMemoryDb();
1647
- const content = String(typedArgs.content ?? "").trim();
1648
- if (!content) {
1649
- return errorResult("Error: 'content' is required and cannot be empty.");
1650
- }
1651
- const noteId = saveSemanticNote(db, {
1652
- type: noteType,
1653
- content,
1654
- keywords: Array.isArray(typedArgs.keywords) ? typedArgs.keywords.map(String) : typeof typedArgs.keywords === 'string' ? [typedArgs.keywords] : [],
1655
- confidence: typeof typedArgs.confidence === "number" ? typedArgs.confidence : 0.8,
1656
- priority: typeof typedArgs.priority === "number" ? typedArgs.priority : 0,
1657
- threadId: threadId ?? null,
1658
- });
1659
- // Fire-and-forget embedding generation
1660
- const apiKey = process.env.OPENAI_API_KEY;
1661
- if (apiKey) {
1662
- void generateEmbedding(content, apiKey).then(emb => {
1663
- saveNoteEmbedding(getMemoryDb(), noteId, emb);
1664
- }).catch(err => {
1665
- process.stderr.write(`[memory] Embedding failed for ${noteId}: ${err instanceof Error ? err.message : String(err)}\n`);
1666
- });
1667
- }
1668
- return {
1669
- content: [{ type: "text", text: `Saved semantic note: ${noteId}` + getShortReminder(threadId) }],
1670
- };
1671
- }
1672
- catch (err) {
1673
- return errorResult(`Memory save error: ${errorMessage(err)}` + getShortReminder(threadId));
1674
- }
1675
- }
1676
- // ── memory_save_procedure ───────────────────────────────────────────────
1677
- if (name === "memory_save_procedure") {
1678
- const typedArgs = (args ?? {});
1679
- const threadId = resolveThreadId(typedArgs);
1680
- try {
1681
- const db = getMemoryDb();
1682
- const existingId = typedArgs.procedureId;
1683
- if (existingId) {
1684
- updateProcedure(db, existingId, {
1685
- description: typedArgs.description,
1686
- steps: Array.isArray(typedArgs.steps) ? typedArgs.steps.map(String) : typeof typedArgs.steps === 'string' ? [typedArgs.steps] : undefined,
1687
- triggerConditions: Array.isArray(typedArgs.triggerConditions) ? typedArgs.triggerConditions.map(String) : typeof typedArgs.triggerConditions === 'string' ? [typedArgs.triggerConditions] : undefined,
1688
- });
1689
- return {
1690
- content: [{ type: "text", text: `Updated procedure: ${existingId}` + getShortReminder(threadId) }],
1691
- };
1692
- }
1693
- const VALID_PROC_TYPES = ["workflow", "habit", "tool_pattern", "template"];
1694
- const procType = String(typedArgs.type ?? "workflow");
1695
- if (!VALID_PROC_TYPES.includes(procType)) {
1696
- return errorResult(`Invalid procedure type "${procType}". Must be one of: ${VALID_PROC_TYPES.join(", ")}`);
1697
- }
1698
- const procId = saveProcedure(db, {
1699
- name: String(typedArgs.name ?? ""),
1700
- type: procType,
1701
- description: String(typedArgs.description ?? ""),
1702
- steps: Array.isArray(typedArgs.steps) ? typedArgs.steps.map(String) : typeof typedArgs.steps === 'string' ? [typedArgs.steps] : undefined,
1703
- triggerConditions: Array.isArray(typedArgs.triggerConditions) ? typedArgs.triggerConditions.map(String) : typeof typedArgs.triggerConditions === 'string' ? [typedArgs.triggerConditions] : undefined,
1704
- });
1705
- return {
1706
- content: [{ type: "text", text: `Saved procedure: ${procId}` + getShortReminder(threadId) }],
1707
- };
1708
- }
1709
- catch (err) {
1710
- return errorResult(`Procedure save error: ${errorMessage(err)}` + getShortReminder(threadId));
1711
- }
1712
- }
1713
- // ── memory_update ───────────────────────────────────────────────────────
1714
- if (name === "memory_update") {
1715
- const typedArgs = (args ?? {});
1716
- const threadId = resolveThreadId(typedArgs);
1717
- try {
1718
- const db = getMemoryDb();
1719
- const memId = String(typedArgs.memoryId ?? "");
1720
- const action = String(typedArgs.action ?? "update");
1721
- const reason = String(typedArgs.reason ?? "");
1722
- if (action === "supersede" && memId.startsWith("sn_")) {
1723
- const origRow = db.prepare("SELECT type, keywords FROM semantic_notes WHERE note_id = ?").get(memId);
1724
- if (!origRow) {
1725
- return errorResult(`Note ${memId} not found — cannot supersede a non-existent note.`);
1726
- }
1727
- const newContent = String(typedArgs.newContent ?? "");
1728
- if (!newContent.trim())
1729
- return errorResult("Error: 'newContent' is required when superseding a note. The original note would be destroyed with no replacement.");
1730
- const newId = supersedeNote(db, memId, {
1731
- type: origRow.type,
1732
- content: newContent,
1733
- keywords: origRow.keywords ? JSON.parse(origRow.keywords) : [],
1734
- confidence: typeof typedArgs.newConfidence === "number" ? typedArgs.newConfidence : 0.8,
1735
- priority: typeof typedArgs.newPriority === "number" ? typedArgs.newPriority : undefined,
1736
- });
1737
- return {
1738
- content: [{ type: "text", text: `Superseded ${memId} → ${newId} (reason: ${reason})` + getShortReminder(threadId) }],
1739
- };
1740
- }
1741
- if (memId.startsWith("sn_")) {
1742
- const updates = {};
1743
- if (typedArgs.newContent)
1744
- updates.content = String(typedArgs.newContent);
1745
- if (typeof typedArgs.newConfidence === "number")
1746
- updates.confidence = typedArgs.newConfidence;
1747
- if (typeof typedArgs.newPriority === "number")
1748
- updates.priority = typedArgs.newPriority;
1749
- updateSemanticNote(db, memId, updates);
1750
- return {
1751
- content: [{ type: "text", text: `Updated note ${memId} (reason: ${reason})` + getShortReminder(threadId) }],
1752
- };
1753
- }
1754
- if (memId.startsWith("pr_")) {
1755
- const updates = {};
1756
- if (typedArgs.newContent)
1757
- updates.description = String(typedArgs.newContent);
1758
- if (typeof typedArgs.newConfidence === "number")
1759
- updates.confidence = typedArgs.newConfidence;
1760
- updateProcedure(db, memId, updates);
1761
- return {
1762
- content: [{ type: "text", text: `Updated procedure ${memId} (reason: ${reason})` + getShortReminder(threadId) }],
1763
- };
1764
- }
1765
- return errorResult(`Unknown memory ID format: ${memId}` + getShortReminder(threadId));
1766
- }
1767
- catch (err) {
1768
- return errorResult(`Memory update error: ${errorMessage(err)}` + getShortReminder(threadId));
1769
- }
281
+ // ── memory_* tools ──────────────────────────────────────────────────────
282
+ if (name.startsWith("memory_")) {
283
+ return handleMemoryTool(name, (args ?? {}), memoryToolCtx);
1770
284
  }
1771
- // ── memory_consolidate ──────────────────────────────────────────────────
1772
- if (name === "memory_consolidate") {
285
+ // ── get_version / get_usage_stats ────────────────────────────────────────
286
+ if (name === "get_version" || name === "get_usage_stats") {
1773
287
  const typedArgs = (args ?? {});
1774
- const threadId = resolveThreadId(typedArgs);
1775
- if (threadId === undefined) {
1776
- return errorResult("Error: No active thread." + getShortReminder());
1777
- }
1778
- try {
1779
- const db = getMemoryDb();
1780
- const report = await runIntelligentConsolidation(db, threadId);
1781
- lastConsolidationAt = Date.now(); // Prevent redundant auto-consolidation
1782
- if (report.episodesProcessed === 0) {
1783
- return {
1784
- content: [{ type: "text", text: "No unconsolidated episodes. Memory is up to date." + getShortReminder(threadId) }],
1785
- };
1786
- }
1787
- const reportLines = [
1788
- "## Consolidation Report",
1789
- `- Episodes processed: ${report.episodesProcessed}`,
1790
- `- Notes created: ${report.notesCreated}`,
1791
- `- Duration: ${report.durationMs}ms`,
1792
- ];
1793
- if (report.details.length > 0) {
1794
- reportLines.push("", "### Extracted Knowledge");
1795
- for (const d of report.details) {
1796
- reportLines.push(`- ${d}`);
1797
- }
1798
- }
1799
- return { content: [{ type: "text", text: reportLines.join("\n") + getShortReminder(threadId) }] };
1800
- }
1801
- catch (err) {
1802
- return errorResult(`Consolidation error: ${errorMessage(err)}` + getShortReminder(threadId));
1803
- }
1804
- }
1805
- // ── memory_status ───────────────────────────────────────────────────────
1806
- if (name === "memory_status") {
1807
- const typedArgs = (args ?? {});
1808
- const threadId = resolveThreadId(typedArgs);
1809
- if (threadId === undefined) {
1810
- return errorResult("Error: No active thread." + getShortReminder());
1811
- }
1812
- try {
1813
- const db = getMemoryDb();
1814
- const status = getMemoryStatus(db, threadId);
1815
- const topics = getTopicIndex(db);
1816
- const lines = [
1817
- "## Memory Status",
1818
- `- Episodes: ${status.totalEpisodes} (${status.unconsolidatedEpisodes} unconsolidated)`,
1819
- `- Semantic notes: ${status.totalSemanticNotes}`,
1820
- `- Procedures: ${status.totalProcedures}`,
1821
- `- Voice signatures: ${status.totalVoiceSignatures}`,
1822
- `- Last consolidation: ${status.lastConsolidation ?? "never"}`,
1823
- `- DB size: ${(status.dbSizeBytes / 1024).toFixed(1)} KB`,
1824
- ];
1825
- if (topics.length > 0) {
1826
- lines.push("", "**Topics:**");
1827
- for (const t of topics.slice(0, 15)) {
1828
- lines.push(`- ${t.topic} (${t.semanticCount} notes, ${t.proceduralCount} procs, conf: ${t.avgConfidence.toFixed(2)})`);
1829
- }
1830
- }
1831
- return { content: [{ type: "text", text: lines.join("\n") + getShortReminder(threadId) }] };
1832
- }
1833
- catch (err) {
1834
- return errorResult(`Memory status error: ${errorMessage(err)}` + getShortReminder(threadId));
1835
- }
1836
- }
1837
- // ── memory_forget ───────────────────────────────────────────────────────
1838
- if (name === "memory_forget") {
1839
- const typedArgs = (args ?? {});
1840
- const threadId = resolveThreadId(typedArgs);
1841
- try {
1842
- const db = getMemoryDb();
1843
- const memId = String(typedArgs.memoryId ?? "");
1844
- const reason = String(typedArgs.reason ?? "");
1845
- const result = forgetMemory(db, memId, reason);
1846
- if (!result.deleted) {
1847
- return {
1848
- content: [{ type: "text", text: `Memory ${memId} not found (layer: ${result.layer}). Nothing was deleted.` + getShortReminder(threadId) }],
1849
- };
1850
- }
1851
- return {
1852
- content: [{ type: "text", text: `Forgot ${result.layer} memory ${memId} (reason: ${reason})` + getShortReminder(threadId) }],
1853
- };
1854
- }
1855
- catch (err) {
1856
- return errorResult(`Memory forget error: ${errorMessage(err)}` + getShortReminder(threadId));
1857
- }
1858
- }
1859
- // ── get_version ─────────────────────────────────────────────────────────
1860
- if (name === "get_version") {
1861
- const maintenance = checkMaintenanceFlag();
1862
- return {
1863
- content: [{
1864
- type: "text",
1865
- text: `Server version: ${config.PKG_VERSION}` +
1866
- (maintenance ? `\n⚠️ Update pending: ${maintenance}` : ""),
1867
- }],
1868
- };
1869
- }
1870
- // ── get_usage_stats ─────────────────────────────────────────────────────
1871
- if (name === "get_usage_stats") {
1872
- const typedArgs = (args ?? {});
1873
- const threadId = resolveThreadId(typedArgs);
1874
- const stats = rateLimiter.getStats();
1875
- const lines = [
1876
- `## API Usage Stats`,
1877
- `Active sessions sharing resources: ${stats.activeSessions}`,
1878
- `Total API calls (last hour): ${stats.totalCallsLastHour}`,
1879
- ``,
1880
- ];
1881
- for (const svc of stats.services) {
1882
- const bar = svc.usagePercent > 80 ? "🔴" : svc.usagePercent > 50 ? "🟡" : "🟢";
1883
- lines.push(`### ${bar} ${svc.description} (${svc.service})`);
1884
- lines.push(`- Window usage: ${svc.callsInWindow}/${svc.maxPerWindow} (${svc.usagePercent}%)`);
1885
- lines.push(`- Burst tokens: ${svc.availableTokens}/${svc.burstCapacity}`);
1886
- if (svc.sessionBreakdown.length > 0) {
1887
- lines.push(`- Per-session:`);
1888
- for (const s of svc.sessionBreakdown) {
1889
- lines.push(` - Thread ${s.threadId ?? "?"}: ${s.calls} calls`);
1890
- }
1891
- }
1892
- lines.push(``);
1893
- }
1894
- return {
1895
- content: [{ type: "text", text: lines.join("\n") + getShortReminder(threadId) }],
288
+ const utilityCtx = {
289
+ resolveThreadId,
290
+ getShortReminder: (threadId) => getShortReminder(threadId, sessionStartedAt),
291
+ errorResult,
292
+ telegram,
293
+ config,
294
+ sessionStartedAt,
295
+ waitCallCount,
296
+ toolCallsSinceLastDelivery,
1896
297
  };
298
+ return handleUtilityTool(name, typedArgs, utilityCtx);
1897
299
  }
1898
300
  // Unknown tool
1899
301
  return errorResult(`Unknown tool: ${name}`);
@@ -1903,277 +305,20 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1903
305
  // ---------------------------------------------------------------------------
1904
306
  // Start the server
1905
307
  // ---------------------------------------------------------------------------
1906
- const httpPort = process.env.MCP_HTTP_PORT ? parseInt(process.env.MCP_HTTP_PORT, 10) : undefined;
1907
- const httpBind = process.env.MCP_HTTP_BIND ?? "127.0.0.1";
1908
- if (httpPort) {
1909
- // ── HTTP/SSE transport ──────────────────────────────────────────────────
1910
- const transports = new Map();
1911
- /** Tracks the last time each HTTP session received any request (epoch ms). */
1912
- const sessionLastActivity = new Map();
1913
- const MCP_HTTP_SECRET = process.env.MCP_HTTP_SECRET;
1914
- const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB
1915
- const serverStartTime = Date.now();
1916
- async function parseBody(req) {
1917
- return new Promise((resolve, reject) => {
1918
- const chunks = [];
1919
- let totalSize = 0;
1920
- req.on("data", (c) => {
1921
- totalSize += c.length;
1922
- if (totalSize > MAX_BODY_SIZE) {
1923
- req.destroy();
1924
- reject(new Error("Request body too large"));
1925
- return;
1926
- }
1927
- chunks.push(c);
1928
- });
1929
- req.on("end", () => {
1930
- try {
1931
- resolve(JSON.parse(Buffer.concat(chunks).toString()));
1932
- }
1933
- catch (e) {
1934
- reject(e);
1935
- }
1936
- });
1937
- req.on("error", reject);
1938
- });
1939
- }
1940
- const httpServer = createServer(async (req, res) => {
308
+ function closeMemoryDb() {
309
+ if (memoryDb) {
1941
310
  try {
1942
- // CORS for local dev (restrict to localhost)
1943
- const origin = req.headers.origin ?? "";
1944
- const allowedOrigin = origin.match(/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/) ? origin : "";
1945
- res.setHeader("Access-Control-Allow-Origin", allowedOrigin);
1946
- res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
1947
- res.setHeader("Access-Control-Allow-Headers", "Content-Type, mcp-session-id, Authorization");
1948
- res.setHeader("Access-Control-Expose-Headers", "mcp-session-id");
1949
- if (req.method === "OPTIONS") {
1950
- res.writeHead(204);
1951
- res.end();
1952
- return;
1953
- }
1954
- // ── Dashboard routes (served before MCP auth) ─────────────────────
1955
- const dashCtx = {
1956
- getDb: getMemoryDb,
1957
- getActiveSessions: () => {
1958
- const sessions = [];
1959
- for (const [sid, _transport] of transports) {
1960
- // Find which thread this session belongs to
1961
- let threadId = 0;
1962
- for (const [tid, entries] of threadSessionRegistry) {
1963
- if (entries.some(e => e.mcpSessionId === sid)) {
1964
- threadId = tid;
1965
- break;
1966
- }
1967
- }
1968
- sessions.push({
1969
- threadId,
1970
- mcpSessionId: sid,
1971
- lastActivity: sessionLastActivity.get(sid) ?? 0,
1972
- transportType: "http",
1973
- });
1974
- }
1975
- return sessions;
1976
- },
1977
- serverStartTime,
1978
- };
1979
- // Dashboard HTML pages: no auth needed (SPA handles auth in browser)
1980
- // Dashboard API routes: auth handled by handleDashboardRequest internally
1981
- const dashUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
1982
- const isDashboardPage = dashUrl.pathname === "/" || dashUrl.pathname === "/dashboard";
1983
- const isDashboardApi = dashUrl.pathname.startsWith("/api/");
1984
- if (isDashboardPage || isDashboardApi) {
1985
- const handled = handleDashboardRequest(req, res, dashCtx, MCP_HTTP_SECRET);
1986
- if (handled)
1987
- return;
1988
- }
1989
- // Auth check — if MCP_HTTP_SECRET is set, require Bearer token.
1990
- // Use constant-time comparison to prevent timing attacks.
1991
- if (MCP_HTTP_SECRET) {
1992
- const auth = req.headers.authorization ?? "";
1993
- const expected = `Bearer ${MCP_HTTP_SECRET}`;
1994
- const authBuf = Buffer.from(auth);
1995
- const expectedBuf = Buffer.from(expected);
1996
- if (authBuf.length !== expectedBuf.length || !timingSafeEqual(authBuf, expectedBuf)) {
1997
- res.writeHead(401, { "Content-Type": "application/json" });
1998
- res.end(JSON.stringify({ error: "Unauthorized" }));
1999
- return;
2000
- }
2001
- }
2002
- if (req.url !== "/mcp") {
2003
- res.writeHead(404, { "Content-Type": "text/plain" });
2004
- res.end("Not Found");
2005
- return;
2006
- }
2007
- const sessionId = req.headers["mcp-session-id"];
2008
- if (req.method === "POST") {
2009
- let body;
2010
- try {
2011
- body = await parseBody(req);
2012
- }
2013
- catch {
2014
- res.writeHead(413, { "Content-Type": "text/plain" });
2015
- res.end("Request body too large or malformed");
2016
- return;
2017
- }
2018
- // Existing session
2019
- if (sessionId && transports.has(sessionId)) {
2020
- sessionLastActivity.set(sessionId, Date.now());
2021
- await transports.get(sessionId).handleRequest(req, res, body);
2022
- return;
2023
- }
2024
- // New session — must be initialize
2025
- if (!sessionId && isInitializeRequest(body)) {
2026
- let capturedSid;
2027
- const transport = new StreamableHTTPServerTransport({
2028
- sessionIdGenerator: () => randomUUID(),
2029
- onsessioninitialized: (sid) => {
2030
- capturedSid = sid;
2031
- transports.set(sid, transport);
2032
- sessionLastActivity.set(sid, Date.now());
2033
- },
2034
- });
2035
- transport.onclose = () => {
2036
- const sid = transport.sessionId;
2037
- if (sid) {
2038
- transports.delete(sid);
2039
- sessionLastActivity.delete(sid);
2040
- rateLimiter.removeSession(sid);
2041
- }
2042
- };
2043
- // Create a fresh Server per HTTP session — a single Server can only
2044
- // connect to one transport, so concurrent clients each need their own.
2045
- const sessionServer = createMcpServer(() => capturedSid, () => { try {
2046
- transport.close();
2047
- }
2048
- catch (_) { /* best-effort */ } });
2049
- await sessionServer.connect(transport);
2050
- await transport.handleRequest(req, res, body);
2051
- return;
2052
- }
2053
- res.writeHead(400, { "Content-Type": "application/json" });
2054
- res.end(JSON.stringify({
2055
- jsonrpc: "2.0",
2056
- error: { code: -32000, message: "Bad Request: No valid session ID or not an initialize request" },
2057
- id: null,
2058
- }));
2059
- return;
2060
- }
2061
- if (req.method === "GET") {
2062
- if (!sessionId || !transports.has(sessionId)) {
2063
- res.writeHead(400, { "Content-Type": "text/plain" });
2064
- res.end("Invalid or missing session ID");
2065
- return;
2066
- }
2067
- sessionLastActivity.set(sessionId, Date.now());
2068
- await transports.get(sessionId).handleRequest(req, res);
2069
- return;
2070
- }
2071
- if (req.method === "DELETE") {
2072
- if (!sessionId || !transports.has(sessionId)) {
2073
- res.writeHead(400, { "Content-Type": "text/plain" });
2074
- res.end("Invalid or missing session ID");
2075
- return;
2076
- }
2077
- sessionLastActivity.set(sessionId, Date.now());
2078
- await transports.get(sessionId).handleRequest(req, res);
2079
- return;
2080
- }
2081
- res.writeHead(405, { "Content-Type": "text/plain" });
2082
- res.end("Method Not Allowed");
2083
- }
2084
- catch (err) {
2085
- process.stderr.write(`[http] Unhandled error: ${typeof err === 'object' && err !== null && 'message' in err ? err.message : String(err)}\n`);
2086
- if (!res.headersSent) {
2087
- res.writeHead(500, { "Content-Type": "application/json" });
2088
- res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal error" }, id: null }));
2089
- }
311
+ memoryDb.close();
2090
312
  }
2091
- });
2092
- httpServer.listen(httpPort, httpBind, () => {
2093
- process.stderr.write(`Remote Copilot MCP server running on http://${httpBind}:${httpPort}/mcp\n`);
2094
- });
2095
- // ── Session reaper — close abandoned SSE sessions every 10 minutes ──────
2096
- const STALE_SESSION_MS = 2 * WAIT_TIMEOUT_MINUTES * 60 * 1000;
2097
- const sessionReaperInterval = setInterval(() => {
2098
- const now = Date.now();
2099
- for (const [sid, transport] of transports) {
2100
- const lastActive = sessionLastActivity.get(sid) ?? 0;
2101
- if (now - lastActive > STALE_SESSION_MS) {
2102
- process.stderr.write(`[session-reaper] Closing stale session ${sid} (idle ${Math.round((now - lastActive) / 60000)}m)\n`);
2103
- try {
2104
- transport.close();
2105
- }
2106
- catch (_) { /* best-effort */ }
2107
- transports.delete(sid);
2108
- sessionLastActivity.delete(sid);
2109
- rateLimiter.removeSession(sid);
2110
- }
2111
- }
2112
- }, 10 * 60 * 1000);
2113
- // Simple shutdown — close transports, DB, and exit.
2114
- let memoryDbClosed = false;
2115
- const shutdown = () => {
2116
- clearInterval(sessionReaperInterval);
2117
- for (const [sid, t] of transports) {
2118
- try {
2119
- t.close();
2120
- }
2121
- catch (_) { /* best-effort */ }
2122
- transports.delete(sid);
2123
- }
2124
- httpServer.close();
2125
- if (memoryDb && !memoryDbClosed) {
2126
- try {
2127
- memoryDb.close();
2128
- memoryDbClosed = true;
2129
- }
2130
- catch (_) { /* best-effort */ }
2131
- }
2132
- process.exit(0);
2133
- };
2134
- process.on("SIGINT", shutdown);
2135
- process.on("SIGTERM", shutdown);
2136
- if (process.platform === "win32") {
2137
- process.on("SIGBREAK", shutdown);
313
+ catch (_) { /* best-effort */ }
314
+ memoryDb = null;
2138
315
  }
2139
- process.on("exit", () => {
2140
- if (memoryDb && !memoryDbClosed) {
2141
- try {
2142
- memoryDb.close();
2143
- }
2144
- catch (_) { /* best-effort */ }
2145
- }
2146
- });
316
+ }
317
+ const httpPort = process.env.MCP_HTTP_PORT ? parseInt(process.env.MCP_HTTP_PORT, 10) : undefined;
318
+ if (httpPort) {
319
+ startHttpServer(createMcpServer, getMemoryDb, closeMemoryDb);
2147
320
  }
2148
321
  else {
2149
- // ── stdio transport (default) ───────────────────────────────────────────
2150
- const transport = new StdioServerTransport();
2151
- const server = createMcpServer();
2152
- await server.connect(transport);
2153
- process.stderr.write("Remote Copilot MCP server running on stdio.\n");
2154
- let stdioDbClosed = false;
2155
- const stdioShutdown = () => {
2156
- if (memoryDb && !stdioDbClosed) {
2157
- try {
2158
- memoryDb.close();
2159
- stdioDbClosed = true;
2160
- }
2161
- catch (_) { /* best-effort */ }
2162
- }
2163
- process.exit(0);
2164
- };
2165
- process.on("SIGINT", stdioShutdown);
2166
- process.on("SIGTERM", stdioShutdown);
2167
- if (process.platform === "win32") {
2168
- process.on("SIGBREAK", stdioShutdown);
2169
- }
2170
- process.on("exit", () => {
2171
- if (memoryDb && !stdioDbClosed) {
2172
- try {
2173
- memoryDb.close();
2174
- }
2175
- catch (_) { /* best-effort */ }
2176
- }
2177
- });
322
+ await startStdioServer(createMcpServer, closeMemoryDb);
2178
323
  }
2179
324
  //# sourceMappingURL=index.js.map