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.
- package/dist/http-server.d.ts +16 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +248 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.js +133 -1988
- package/dist/index.js.map +1 -1
- package/dist/response-builders.d.ts +34 -0
- package/dist/response-builders.d.ts.map +1 -0
- package/dist/response-builders.js +114 -0
- package/dist/response-builders.js.map +1 -0
- package/dist/stdio-server.d.ts +8 -0
- package/dist/stdio-server.d.ts.map +1 -0
- package/dist/stdio-server.js +23 -0
- package/dist/stdio-server.js.map +1 -0
- package/dist/tools/memory-tools.d.ts +36 -0
- package/dist/tools/memory-tools.d.ts.map +1 -0
- package/dist/tools/memory-tools.js +352 -0
- package/dist/tools/memory-tools.js.map +1 -0
- package/dist/tools/session-tools.d.ts +46 -0
- package/dist/tools/session-tools.d.ts.map +1 -0
- package/dist/tools/session-tools.js +261 -0
- package/dist/tools/session-tools.js.map +1 -0
- package/dist/tools/start-session-tool.d.ts +43 -0
- package/dist/tools/start-session-tool.d.ts.map +1 -0
- package/dist/tools/start-session-tool.js +188 -0
- package/dist/tools/start-session-tool.js.map +1 -0
- package/dist/tools/utility-tools.d.ts +34 -0
- package/dist/tools/utility-tools.d.ts.map +1 -0
- package/dist/tools/utility-tools.js +256 -0
- package/dist/tools/utility-tools.js.map +1 -0
- package/dist/tools/wait-tool.d.ts +69 -0
- package/dist/tools/wait-tool.d.ts.map +1 -0
- package/dist/tools/wait-tool.js +702 -0
- package/dist/tools/wait-tool.js.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.d.ts.map +1 -1
- 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 {
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
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 {
|
|
41
|
-
import {
|
|
42
|
-
import {
|
|
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 {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
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
|
-
// ──
|
|
1251
|
-
if (name === "
|
|
246
|
+
// ── report_progress / hibernate ───────────────────────────────────────────
|
|
247
|
+
if (name === "report_progress" || name === "hibernate") {
|
|
1252
248
|
const typedArgs = (args ?? {});
|
|
1253
|
-
const
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
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
|
-
// ──
|
|
1427
|
-
if (
|
|
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
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
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
|
-
// ──
|
|
1543
|
-
if (name
|
|
1544
|
-
|
|
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
|
-
// ──
|
|
1772
|
-
if (name === "
|
|
285
|
+
// ── get_version / get_usage_stats ────────────────────────────────────────
|
|
286
|
+
if (name === "get_version" || name === "get_usage_stats") {
|
|
1773
287
|
const typedArgs = (args ?? {});
|
|
1774
|
-
const
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
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
|
-
|
|
1907
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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
|
-
|
|
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
|