sensorium-mcp 2.11.1 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -39,7 +39,7 @@ import { peekThreadMessages, readThreadMessages, startDispatcher } from "./dispa
39
39
  import { formatDrivePrompt } from "./drive.js";
40
40
  import { convertMarkdown, splitMessage } from "./markdown.js";
41
41
  import { assembleBootstrap, assembleCompactRefresh, forgetMemory, getMemoryStatus, getNotesWithoutEmbeddings, getRecentEpisodes, getTopicIndex, initMemoryDb, runIntelligentConsolidation, saveEpisode, saveNoteEmbedding, saveProcedure, saveSemanticNote, saveVoiceSignature, searchByEmbedding, searchProcedures, searchSemanticNotes, searchSemanticNotesRanked, supersedeNote, updateProcedure, updateSemanticNote, } from "./memory.js";
42
- import { analyzeVideoFrames, analyzeVoiceEmotion, extractVideoFrames, generateEmbedding, textToSpeech, transcribeAudio, TTS_VOICES } from "./openai.js";
42
+ import { analyzeVideoFrames, analyzeVoiceEmotion, chatCompletion, extractVideoFrames, generateEmbedding, textToSpeech, transcribeAudio, TTS_VOICES } from "./openai.js";
43
43
  import { addSchedule, checkDueTasks, generateTaskId, listSchedules, purgeSchedules, removeSchedule } from "./scheduler.js";
44
44
  import { DEAD_SESSION_TIMEOUT_MS, lookupSession, persistSession, purgeOtherSessions, registerMcpSession, removeSession, threadSessionRegistry, } from "./sessions.js";
45
45
  import { TelegramClient } from "./telegram.js";
@@ -151,6 +151,25 @@ function createMcpServer(getMcpSessionId, closeTransport) {
151
151
  }
152
152
  previewedUpdateIds.add(id);
153
153
  }
154
+ /**
155
+ * Generate a first-person DMN (Default Mode Network) reflection prompt.
156
+ * Called when the __DMN__ sentinel fires as a scheduled task.
157
+ */
158
+ function generateDmnReflection(threadId) {
159
+ try {
160
+ const db = getMemoryDb();
161
+ const idleMs = Date.now() - lastOperatorMessageAt;
162
+ const driveContent = formatDrivePrompt(idleMs, db, threadId);
163
+ // Reframe in first person
164
+ return (`I've been thinking while the operator is away.\n\n` +
165
+ `${driveContent}\n\n` +
166
+ `If something here resonates, I should explore it — use subagents, search the codebase, review memory. ` +
167
+ `Report what I find, then go back to sleep or continue waiting.`);
168
+ }
169
+ catch {
170
+ return "I should review memory and the codebase for anything interesting while the operator is away.";
171
+ }
172
+ }
154
173
  function resolveThreadId(args) {
155
174
  const raw = args?.threadId;
156
175
  const explicit = typeof raw === "number" ? raw
@@ -400,6 +419,25 @@ function createMcpServer(getMcpSessionId, closeTransport) {
400
419
  registerMcpSession(currentThreadId, sid, closeTransport);
401
420
  }
402
421
  }
422
+ // Auto-schedule DMN reflection task if not already present.
423
+ // This fires after 4 hours of operator silence, delivering a
424
+ // first-person introspection prompt sourced from memory.
425
+ if (currentThreadId !== undefined) {
426
+ const existingTasks = listSchedules(currentThreadId);
427
+ const hasDmn = existingTasks.some(t => t.label === "dmn-reflection");
428
+ if (!hasDmn) {
429
+ addSchedule({
430
+ id: generateTaskId(),
431
+ threadId: currentThreadId,
432
+ prompt: "__DMN__", // Sentinel — handler generates dynamic content
433
+ label: "dmn-reflection",
434
+ afterIdleMinutes: 240, // 4 hours
435
+ oneShot: false,
436
+ createdAt: new Date().toISOString(),
437
+ });
438
+ process.stderr.write(`[start_session] Auto-scheduled DMN reflection task for thread ${currentThreadId}.\n`);
439
+ }
440
+ }
403
441
  return {
404
442
  content: [
405
443
  {
@@ -744,81 +782,89 @@ function createMcpServer(getMcpSessionId, closeTransport) {
744
782
  contentBlocks.push({ type: "text", text: subagentInstruction });
745
783
  }
746
784
  }
747
- // ── Auto-inject relevant memory context ───────────────────────────
748
- // Architecture-enforced: the agent should NOT need to manually call
749
- // memory_search. The server automatically searches memory for notes
750
- // relevant to the operator's message and injects them.
785
+ // ── Smart context injection (GPT-4o-mini preprocessor) ──────────
786
+ // Retrieves candidate notes via embedding search, then uses GPT-4o-mini
787
+ // to select ONLY the notes truly relevant to the operator's message.
788
+ // This prevents context contamination from near-miss semantic matches.
751
789
  let autoMemoryContext = "";
752
790
  try {
753
791
  const db = getMemoryDb();
754
792
  const apiKey = process.env.OPENAI_API_KEY;
755
- // Extract the operator's text to use as a memory search query
756
793
  const operatorText = stored
757
794
  .map(m => m.message.text ?? m.message.caption ?? "")
758
795
  .filter(Boolean)
759
796
  .join(" ")
760
797
  .slice(0, 500);
761
798
  if (operatorText.length > 10 && apiKey) {
762
- // Try embedding-based search first, fall back to keyword search
799
+ // Phase 1: Broad retrieval get 10 candidates via embedding search
800
+ let candidates = [];
763
801
  try {
764
802
  const queryEmb = await generateEmbedding(operatorText, apiKey);
765
- const relevant = searchByEmbedding(db, queryEmb, { maxResults: 5, minSimilarity: 0.3, skipAccessTracking: true });
766
- if (relevant.length > 0) {
767
- let budget = 800;
768
- const lines = [];
769
- for (const n of relevant) {
770
- const line = `- **[${n.type}]** ${n.content.slice(0, 200)} _(conf: ${n.confidence}, sim: ${n.similarity.toFixed(2)})_`;
771
- if (budget - line.length < 0)
772
- break;
773
- budget -= line.length;
774
- lines.push(line);
775
- }
776
- if (lines.length > 0) {
777
- autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
778
- }
779
- }
803
+ const embResults = searchByEmbedding(db, queryEmb, { maxResults: 10, minSimilarity: 0.25, skipAccessTracking: true, threadId: effectiveThreadId });
804
+ candidates = embResults.map(n => ({ type: n.type, content: n.content.slice(0, 200), confidence: n.confidence, similarity: n.similarity }));
780
805
  }
781
- catch (embErr) {
782
- // Fallback to keyword search if embedding fails
783
- process.stderr.write(`[memory] Embedding search failed, falling back to keyword: ${embErr instanceof Error ? embErr.message : String(embErr)}\n`);
806
+ catch {
807
+ // Fallback to keyword search
784
808
  const searchQuery = extractSearchKeywords(operatorText);
785
809
  if (searchQuery.trim().length > 0) {
786
- const relevant = searchSemanticNotesRanked(db, searchQuery, { maxResults: 5, skipAccessTracking: true });
787
- if (relevant.length > 0) {
788
- let budget = 800;
789
- const lines = [];
790
- for (const n of relevant) {
791
- const line = `- **[${n.type}]** ${n.content.slice(0, 200)} _(conf: ${n.confidence})_`;
792
- if (budget - line.length < 0)
793
- break;
794
- budget -= line.length;
795
- lines.push(line);
796
- }
797
- if (lines.length > 0) {
798
- autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
810
+ const kwResults = searchSemanticNotesRanked(db, searchQuery, { maxResults: 10, skipAccessTracking: true, threadId: effectiveThreadId });
811
+ candidates = kwResults.map(n => ({ type: n.type, content: n.content.slice(0, 200), confidence: n.confidence }));
812
+ }
813
+ }
814
+ if (candidates.length > 0) {
815
+ // Phase 2: GPT-4o-mini filters and compresses
816
+ try {
817
+ const noteList = candidates.map((c, i) => `[${i}] [${c.type}] ${c.content}`).join("\n");
818
+ const filterResponse = await chatCompletion([
819
+ {
820
+ role: "system",
821
+ content: "You are a context filter for an AI assistant. Given an operator's message and candidate memory notes, " +
822
+ "select ONLY the notes that are directly relevant to the operator's current instruction or question. " +
823
+ "Discard notes that are tangentially related, duplicates, or noise. " +
824
+ "Return a JSON array of objects: [{\"i\": <index>, \"s\": \"<compressed one-liner>\"}] " +
825
+ "where 'i' is the note index and 's' is a compressed summary (max 80 chars). " +
826
+ "Return [] if no notes are relevant. Return at most 3 notes. Be aggressive about filtering.",
827
+ },
828
+ {
829
+ role: "user",
830
+ content: `Operator message: "${operatorText.slice(0, 300)}"\n\nCandidate notes:\n${noteList}`,
831
+ },
832
+ ], apiKey, { maxTokens: 200, temperature: 0 });
833
+ // Parse the response — expect JSON array
834
+ const jsonMatch = filterResponse.match(/\[.*\]/s);
835
+ if (jsonMatch) {
836
+ const filtered = JSON.parse(jsonMatch[0]);
837
+ if (filtered.length > 0) {
838
+ const lines = filtered
839
+ .filter(f => f.i >= 0 && f.i < candidates.length)
840
+ .slice(0, 3)
841
+ .map(f => {
842
+ const c = candidates[f.i];
843
+ return `- **[${c.type}]** ${f.s} _(conf: ${c.confidence})_`;
844
+ });
845
+ if (lines.length > 0) {
846
+ autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
847
+ }
799
848
  }
800
849
  }
850
+ process.stderr.write(`[memory] Smart filter: ${candidates.length} candidates → ${(jsonMatch ? JSON.parse(jsonMatch[0]) : []).length} selected\n`);
851
+ }
852
+ catch (filterErr) {
853
+ // GPT-4o-mini filter failed — fall back to top-3 raw notes
854
+ process.stderr.write(`[memory] Smart filter failed, using raw top-3: ${filterErr instanceof Error ? filterErr.message : String(filterErr)}\n`);
855
+ const lines = candidates.slice(0, 3).map(c => `- **[${c.type}]** ${c.content} _(conf: ${c.confidence})_`);
856
+ autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
801
857
  }
802
858
  }
803
859
  }
804
- else {
805
- // No API key or text too short use keyword search
860
+ else if (operatorText.length > 10) {
861
+ // No API key — keyword search, raw top-3
806
862
  const searchQuery = extractSearchKeywords(operatorText);
807
863
  if (searchQuery.trim().length > 0) {
808
- const relevant = searchSemanticNotesRanked(db, searchQuery, { maxResults: 5, skipAccessTracking: true });
809
- if (relevant.length > 0) {
810
- let budget = 800;
811
- const lines = [];
812
- for (const n of relevant) {
813
- const line = `- **[${n.type}]** ${n.content.slice(0, 200)} _(conf: ${n.confidence})_`;
814
- if (budget - line.length < 0)
815
- break;
816
- budget -= line.length;
817
- lines.push(line);
818
- }
819
- if (lines.length > 0) {
820
- autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
821
- }
864
+ const kwResults = searchSemanticNotesRanked(db, searchQuery, { maxResults: 3, skipAccessTracking: true, threadId: effectiveThreadId });
865
+ if (kwResults.length > 0) {
866
+ const lines = kwResults.map(n => `- **[${n.type}]** ${n.content.slice(0, 200)} _(conf: ${n.confidence})_`);
867
+ autoMemoryContext = `\n\n## Relevant Memory (auto-injected)\n${lines.join("\n")}`;
822
868
  }
823
869
  }
824
870
  }
@@ -850,14 +896,17 @@ function createMcpServer(getMcpSessionId, closeTransport) {
850
896
  lastScheduleCheck = Date.now();
851
897
  const dueTask = checkDueTasks(effectiveThreadId, lastOperatorMessageAt, false);
852
898
  if (dueTask) {
899
+ // DMN sentinel: generate dynamic first-person reflection
900
+ const taskPrompt = dueTask.prompt === "__DMN__"
901
+ ? generateDmnReflection(effectiveThreadId)
902
+ : `⏰ **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
903
+ `This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
904
+ `Task prompt: ${dueTask.prompt}`;
853
905
  return {
854
906
  content: [
855
907
  {
856
908
  type: "text",
857
- text: `⏰ **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
858
- `This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
859
- `Task prompt: ${dueTask.prompt}` +
860
- getReminders(effectiveThreadId),
909
+ text: taskPrompt + getReminders(effectiveThreadId),
861
910
  },
862
911
  ],
863
912
  };
@@ -899,14 +948,17 @@ function createMcpServer(getMcpSessionId, closeTransport) {
899
948
  if (effectiveThreadId !== undefined) {
900
949
  const dueTask = checkDueTasks(effectiveThreadId, lastOperatorMessageAt, false);
901
950
  if (dueTask) {
951
+ // DMN sentinel: generate dynamic first-person reflection
952
+ const taskPrompt = dueTask.prompt === "__DMN__"
953
+ ? generateDmnReflection(effectiveThreadId)
954
+ : `⏰ **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
955
+ `This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
956
+ `Task prompt: ${dueTask.prompt}`;
902
957
  return {
903
958
  content: [
904
959
  {
905
960
  type: "text",
906
- text: `⏰ **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
907
- `This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
908
- `Task prompt: ${dueTask.prompt}` +
909
- getReminders(effectiveThreadId),
961
+ text: taskPrompt + getReminders(effectiveThreadId),
910
962
  },
911
963
  ],
912
964
  };
@@ -1357,6 +1409,108 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1357
1409
  }],
1358
1410
  };
1359
1411
  }
1412
+ // ── sleep ────────────────────────────────────────────────────────────────
1413
+ if (name === "sleep") {
1414
+ const typedArgs = (args ?? {});
1415
+ const effectiveThreadId = resolveThreadId(typedArgs);
1416
+ if (effectiveThreadId === undefined) {
1417
+ return errorResult("Error: No active session. Call start_session first.");
1418
+ }
1419
+ const wakeAt = typeof typedArgs.wakeAt === "string" ? new Date(typedArgs.wakeAt).getTime() : undefined;
1420
+ if (wakeAt !== undefined && isNaN(wakeAt)) {
1421
+ return errorResult("Error: Invalid wakeAt timestamp. Use ISO 8601 format.");
1422
+ }
1423
+ // Max sleep: 8 hours
1424
+ const MAX_SLEEP_MS = 8 * 60 * 60 * 1000;
1425
+ const SLEEP_POLL_INTERVAL_MS = 30_000; // 30s
1426
+ const SSE_KEEPALIVE_INTERVAL_MS = 30_000;
1427
+ const deadline = Date.now() + MAX_SLEEP_MS;
1428
+ let lastKeepalive = Date.now();
1429
+ process.stderr.write(`[sleep] Entering sleep mode. threadId=${effectiveThreadId}, wakeAt=${wakeAt ? new Date(wakeAt).toISOString() : "indefinite"}\n`);
1430
+ while (Date.now() < deadline) {
1431
+ // Check for operator messages (non-destructive peek)
1432
+ const peeked = peekThreadMessages(effectiveThreadId);
1433
+ if (peeked.length > 0) {
1434
+ process.stderr.write(`[sleep] Waking up — ${peeked.length} operator message(s) received.\n`);
1435
+ // Don't consume messages — let the next wait_for_instructions call handle them
1436
+ return {
1437
+ content: [{
1438
+ type: "text",
1439
+ text: `Woke up: operator sent a message. Call wait_for_instructions now to read it.` +
1440
+ getShortReminder(effectiveThreadId),
1441
+ }],
1442
+ };
1443
+ }
1444
+ // Check for scheduled tasks
1445
+ const dueTask = checkDueTasks(effectiveThreadId, lastOperatorMessageAt, false);
1446
+ if (dueTask) {
1447
+ process.stderr.write(`[sleep] Waking up — scheduled task fired: ${dueTask.task.label}\n`);
1448
+ // DMN sentinel: generate dynamic first-person reflection
1449
+ const taskPrompt = dueTask.prompt === "__DMN__"
1450
+ ? generateDmnReflection(effectiveThreadId)
1451
+ : `⏰ Woke up: scheduled task **"${dueTask.task.label}"**\n\n${dueTask.prompt}`;
1452
+ return {
1453
+ content: [{
1454
+ type: "text",
1455
+ text: taskPrompt + getShortReminder(effectiveThreadId),
1456
+ }],
1457
+ };
1458
+ }
1459
+ // Check alarm
1460
+ if (wakeAt && Date.now() >= wakeAt) {
1461
+ process.stderr.write(`[sleep] Waking up — alarm reached.\n`);
1462
+ return {
1463
+ content: [{
1464
+ type: "text",
1465
+ text: `Woke up: alarm time reached (${new Date(wakeAt).toISOString()}).` +
1466
+ getShortReminder(effectiveThreadId),
1467
+ }],
1468
+ };
1469
+ }
1470
+ // SSE keepalive
1471
+ const sinceKeepalive = Date.now() - lastKeepalive;
1472
+ if (sinceKeepalive >= SSE_KEEPALIVE_INTERVAL_MS && extra?.sendNotification) {
1473
+ try {
1474
+ await extra.sendNotification({
1475
+ method: "notifications/message",
1476
+ params: { level: "debug", data: "sleeping", logger: "sensorium" },
1477
+ });
1478
+ lastKeepalive = Date.now();
1479
+ }
1480
+ catch {
1481
+ process.stderr.write(`[sleep] SSE keepalive failed — connection lost.\n`);
1482
+ return {
1483
+ content: [{
1484
+ type: "text",
1485
+ text: "Sleep interrupted: connection lost. Call sleep again to resume." +
1486
+ getShortReminder(effectiveThreadId),
1487
+ }],
1488
+ };
1489
+ }
1490
+ }
1491
+ // Check abort signal
1492
+ if (extra.signal.aborted) {
1493
+ process.stderr.write(`[sleep] SSE connection aborted during sleep.\n`);
1494
+ return {
1495
+ content: [{
1496
+ type: "text",
1497
+ text: "Sleep interrupted: connection closed." +
1498
+ getShortReminder(effectiveThreadId),
1499
+ }],
1500
+ };
1501
+ }
1502
+ await new Promise((resolve) => setTimeout(resolve, SLEEP_POLL_INTERVAL_MS));
1503
+ }
1504
+ // Max sleep duration reached
1505
+ process.stderr.write(`[sleep] Max sleep duration reached (8h).\n`);
1506
+ return {
1507
+ content: [{
1508
+ type: "text",
1509
+ text: "Woke up: maximum sleep duration reached (8 hours)." +
1510
+ getShortReminder(effectiveThreadId),
1511
+ }],
1512
+ };
1513
+ }
1360
1514
  // ── memory_bootstrap ────────────────────────────────────────────────────
1361
1515
  if (name === "memory_bootstrap") {
1362
1516
  const threadId = resolveThreadId(args);
@@ -1472,6 +1626,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1472
1626
  keywords: Array.isArray(typedArgs.keywords) ? typedArgs.keywords.map(String) : typeof typedArgs.keywords === 'string' ? [typedArgs.keywords] : [],
1473
1627
  confidence: typeof typedArgs.confidence === "number" ? typedArgs.confidence : 0.8,
1474
1628
  priority: typeof typedArgs.priority === "number" ? typedArgs.priority : 0,
1629
+ threadId: threadId ?? null,
1475
1630
  });
1476
1631
  // Fire-and-forget embedding generation
1477
1632
  const apiKey = process.env.OPENAI_API_KEY;