sensorium-mcp 2.9.7 → 2.11.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/index.js CHANGED
@@ -29,19 +29,22 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
29
29
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
30
30
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
31
31
  import { CallToolRequestSchema, isInitializeRequest, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
32
- import { mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync, writeFileSync } from "fs";
33
32
  import { readFile } from "fs/promises";
34
- import { createRequire } from "module";
35
33
  import { randomUUID, timingSafeEqual } from "node:crypto";
36
34
  import { createServer } from "node:http";
37
- import { homedir } from "os";
38
- import { basename, join } from "path";
35
+ import { basename } from "node:path";
36
+ import { config, saveFileToDisk } from "./config.js";
39
37
  import { handleDashboardRequest } from "./dashboard.js";
40
38
  import { peekThreadMessages, readThreadMessages, startDispatcher } from "./dispatcher.js";
41
- import { assembleBootstrap, assembleCompactRefresh, forgetMemory, getMemoryStatus, getNotesWithoutEmbeddings, getRecentEpisodes, getTopicIndex, getTopSemanticNotes, initMemoryDb, runIntelligentConsolidation, saveEpisode, saveNoteEmbedding, saveProcedure, saveSemanticNote, saveVoiceSignature, searchByEmbedding, searchProcedures, searchSemanticNotes, searchSemanticNotesRanked, supersedeNote, updateProcedure, updateSemanticNote, } from "./memory.js";
39
+ import { formatDrivePrompt } from "./drive.js";
40
+ import { convertMarkdown, splitMessage } from "./markdown.js";
41
+ import { assembleBootstrap, assembleCompactRefresh, forgetMemory, getMemoryStatus, getNotesWithoutEmbeddings, getRecentEpisodes, getTopicIndex, initMemoryDb, runIntelligentConsolidation, saveEpisode, saveNoteEmbedding, saveProcedure, saveSemanticNote, saveVoiceSignature, searchByEmbedding, searchProcedures, searchSemanticNotes, searchSemanticNotesRanked, supersedeNote, updateProcedure, updateSemanticNote, } from "./memory.js";
42
42
  import { analyzeVideoFrames, analyzeVoiceEmotion, extractVideoFrames, generateEmbedding, textToSpeech, transcribeAudio, TTS_VOICES } from "./openai.js";
43
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";
44
45
  import { TelegramClient } from "./telegram.js";
46
+ import { getToolDefinitions } from "./tool-definitions.js";
47
+ import { rateLimiter } from "./rate-limiter.js";
45
48
  import { describeADV, errorMessage, errorResult, IMAGE_EXTENSIONS, OPENAI_TTS_MAX_CHARS } from "./utils.js";
46
49
  // ── Stop-word list for auto-memory keyword extraction ─────────────────
47
50
  const STOP_WORDS = new Set([
@@ -101,186 +104,15 @@ function buildAnalysisTags(analysis) {
101
104
  }
102
105
  return tags;
103
106
  }
104
- const esmRequire = createRequire(import.meta.url);
105
- const { version: PKG_VERSION } = esmRequire("../package.json");
106
- const telegramifyMarkdown = esmRequire("telegramify-markdown");
107
- /**
108
- * Convert standard Markdown to Telegram MarkdownV2.
109
- *
110
- * Works around several telegramify-markdown limitations:
111
- * 1. Fenced code blocks are emitted as single-backtick inline code instead
112
- * of triple-backtick blocks → pre-extract, re-insert after conversion.
113
- * 2. Markdown tables contain `|` which is a MarkdownV2 reserved character;
114
- * telegramify-markdown does not handle tables → pre-extract and wrap in
115
- * a plain code block so the table layout is preserved.
116
- * 3. Blockquotes with 'escape' strategy produce double-escaped characters
117
- * (e.g. `\\.` instead of `\.`) → pre-convert `> text` to `▎ text`
118
- * (a common Telegram convention) so the library never sees blockquotes.
119
- */
120
- function convertMarkdown(markdown) {
121
- const blocks = [];
122
- const placeholder = (i) => `CODEBLOCKPLACEHOLDER${i}END`;
123
- // 1. Extract fenced code blocks (``` ... ```).
124
- let preprocessed = markdown.replace(/^```(\w*)\n([\s\S]*?)\n?```\s*$/gm, (_match, lang, code) => {
125
- blocks.push({ lang, code });
126
- return placeholder(blocks.length - 1);
127
- });
128
- // 2. Extract Markdown tables (consecutive lines starting with `|`) and
129
- // convert them to list format for better Telegram readability.
130
- // Tables render poorly on mobile — lists with labeled items are clearer.
131
- const tableLists = [];
132
- const tablePlaceholder = (i) => `TABLEPLACEHOLDER${i}END`;
133
- preprocessed = preprocessed.replace(/^(\|.+\|)\n(\|[-| :]+\|\n)((?:\|.*\n?)*)/gm, (_match, firstRow, _sepRow, rest) => {
134
- // Parse header columns
135
- const headers = firstRow.split("|").map((s) => s.trim()).filter(Boolean);
136
- // Parse data rows
137
- const dataRows = rest.trimEnd().split("\n").filter((line) => line.trim().length > 0);
138
- const listLines = [];
139
- for (const row of dataRows) {
140
- const cells = row.split("|").map((s) => s.trim()).filter(Boolean);
141
- // Format as: "• Cell1 — Header2: Cell2, Header3: Cell3, ..."
142
- if (cells.length > 0) {
143
- const parts = [];
144
- for (let j = 0; j < cells.length; j++) {
145
- if (j === 0) {
146
- parts.push(cells[j]);
147
- }
148
- else if (j < headers.length) {
149
- parts.push(`${headers[j]}: ${cells[j]}`);
150
- }
151
- else {
152
- parts.push(cells[j]);
153
- }
154
- }
155
- listLines.push(`• ${parts.join(" — ")}`);
156
- }
157
- }
158
- tableLists.push(listLines.join("\n"));
159
- return tablePlaceholder(tableLists.length - 1) + "\n";
160
- });
161
- // 3. Convert Markdown blockquotes (> text) to ▎ prefix lines so
162
- // telegramify-markdown never attempts to escape them.
163
- preprocessed = preprocessed.replace(/^>\s?(.*)$/gm, "▎ $1");
164
- // 4. Convert the rest with telegramify-markdown.
165
- let converted = telegramifyMarkdown(preprocessed, "escape");
166
- // 5. Re-insert code blocks in MarkdownV2 format.
167
- // Inside pre/code blocks only `\` and `` ` `` need escaping.
168
- converted = converted.replace(/CODEBLOCKPLACEHOLDER(\d+)END/g, (_m, idx) => {
169
- const { lang, code } = blocks[parseInt(idx, 10)];
170
- const escaped = code.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
171
- return `\`\`\`${lang}\n${escaped}\n\`\`\``;
172
- });
173
- // 6. Re-insert tables (now converted to lists) with MarkdownV2 escaping.
174
- converted = converted.replace(/TABLEPLACEHOLDER(\d+)END/g, (_m, idx) => {
175
- const list = tableLists[parseInt(idx, 10)];
176
- return list
177
- .replace(/([_*\[\]()~`>#+=\-{}.!|\\])/g, "\\$1");
178
- });
179
- return converted;
180
- }
181
107
  // ---------------------------------------------------------------------------
182
- // Configuration
108
+ // Destructure config for backwards-compatible local references
183
109
  // ---------------------------------------------------------------------------
184
- const TELEGRAM_TOKEN = process.env.TELEGRAM_TOKEN ?? "";
185
- const TELEGRAM_CHAT_ID = process.env.TELEGRAM_CHAT_ID ?? "";
186
- const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "";
187
- const VOICE_ANALYSIS_URL = process.env.VOICE_ANALYSIS_URL ?? "";
188
- const rawWaitTimeoutMinutes = parseInt(process.env.WAIT_TIMEOUT_MINUTES ?? "", 10);
189
- const WAIT_TIMEOUT_MINUTES = Math.max(1, Number.isFinite(rawWaitTimeoutMinutes) ? rawWaitTimeoutMinutes : 120);
190
- if (!TELEGRAM_TOKEN || !TELEGRAM_CHAT_ID) {
191
- process.stderr.write("Error: TELEGRAM_TOKEN and TELEGRAM_CHAT_ID environment variables are required.\n");
192
- process.exit(1);
193
- }
194
- if (!OPENAI_API_KEY) {
195
- process.stderr.write("Warning: OPENAI_API_KEY not set — voice messages will not be transcribed.\n");
196
- }
197
- if (VOICE_ANALYSIS_URL) {
198
- process.stderr.write(`Voice analysis service configured: ${VOICE_ANALYSIS_URL}\n`);
199
- }
110
+ const { TELEGRAM_TOKEN, TELEGRAM_CHAT_ID, OPENAI_API_KEY, VOICE_ANALYSIS_URL, WAIT_TIMEOUT_MINUTES, FILES_DIR, PKG_VERSION } = config;
200
111
  // ---------------------------------------------------------------------------
201
112
  // Telegram client + dispatcher
202
113
  // ---------------------------------------------------------------------------
203
114
  const telegram = new TelegramClient(TELEGRAM_TOKEN);
204
- // ---------------------------------------------------------------------------
205
- // Start the shared dispatcher — one process polls Telegram, all instances
206
- // read from per-thread files. This eliminates 409 Conflict errors and
207
- // ensures no updates are lost between concurrent sessions.
208
- // ---------------------------------------------------------------------------
209
115
  await startDispatcher(telegram, TELEGRAM_CHAT_ID);
210
- // Directory for persisting downloaded images and documents to disk.
211
- const FILES_DIR = join(homedir(), ".remote-copilot-mcp", "files");
212
- mkdirSync(FILES_DIR, { recursive: true });
213
- /**
214
- * Save a buffer to disk under FILES_DIR with a unique timestamped name.
215
- * Returns the absolute file path. Caps directory at 500 files by deleting oldest.
216
- */
217
- function saveFileToDisk(buffer, filename) {
218
- const ts = Date.now();
219
- const safeName = filename.replace(/[^a-zA-Z0-9._-]/g, "_");
220
- const diskName = `${ts}-${safeName}`;
221
- const filePath = join(FILES_DIR, diskName);
222
- writeFileSync(filePath, buffer);
223
- // Cleanup: cap at 500 files
224
- try {
225
- const files = readdirSync(FILES_DIR)
226
- .map(f => ({ name: f, mtime: statSync(join(FILES_DIR, f)).mtimeMs }))
227
- .sort((a, b) => a.mtime - b.mtime);
228
- if (files.length > 500) {
229
- const toDelete = files.slice(0, files.length - 500);
230
- for (const f of toDelete) {
231
- try {
232
- unlinkSync(join(FILES_DIR, f.name));
233
- }
234
- catch (_) { /* ignore */ }
235
- }
236
- }
237
- }
238
- catch (_) { /* non-fatal */ }
239
- return filePath;
240
- }
241
- // ---------------------------------------------------------------------------
242
- // Session store — persists topic name → thread ID mappings to disk so the
243
- // agent can resume a named session even after a VS Code restart.
244
- // Format: { "<chatId>": { "<lowercased name>": threadId } }
245
- // ---------------------------------------------------------------------------
246
- const SESSION_STORE_PATH = join(homedir(), ".remote-copilot-mcp-sessions.json");
247
- function loadSessionMap() {
248
- try {
249
- const raw = readFileSync(SESSION_STORE_PATH, "utf8");
250
- return JSON.parse(raw);
251
- }
252
- catch {
253
- return {};
254
- }
255
- }
256
- function saveSessionMap(map) {
257
- try {
258
- const tmp = SESSION_STORE_PATH + `.tmp.${process.pid}`;
259
- writeFileSync(tmp, JSON.stringify(map, null, 2), "utf8");
260
- renameSync(tmp, SESSION_STORE_PATH); // atomic replace
261
- }
262
- catch (err) {
263
- process.stderr.write(`Warning: Could not save session map to ${SESSION_STORE_PATH}: ${errorMessage(err)}\n`);
264
- }
265
- }
266
- function lookupSession(chatId, name) {
267
- const map = loadSessionMap();
268
- return map[chatId]?.[name.toLowerCase()];
269
- }
270
- function persistSession(chatId, name, threadId) {
271
- const map = loadSessionMap();
272
- if (!map[chatId])
273
- map[chatId] = {};
274
- map[chatId][name.toLowerCase()] = threadId;
275
- saveSessionMap(map);
276
- }
277
- function removeSession(chatId, name) {
278
- const map = loadSessionMap();
279
- if (map[chatId]) {
280
- delete map[chatId][name.toLowerCase()];
281
- saveSessionMap(map);
282
- }
283
- }
284
116
  // Memory database — initialized lazily on first use
285
117
  let memoryDb = null;
286
118
  function getMemoryDb() {
@@ -288,252 +120,6 @@ function getMemoryDb() {
288
120
  memoryDb = initMemoryDb();
289
121
  return memoryDb;
290
122
  }
291
- // Dead session detection constant
292
- const DEAD_SESSION_TIMEOUT_MS = 60 * 60 * 1000; // 60 minutes (0.5× wait_for_instructions timeout, chosen to alert before the next poll could return)
293
- const threadSessionRegistry = new Map();
294
- function registerMcpSession(threadId, mcpSessionId, closeTransport) {
295
- const entries = threadSessionRegistry.get(threadId) ?? [];
296
- entries.push({ mcpSessionId, closeTransport });
297
- threadSessionRegistry.set(threadId, entries);
298
- }
299
- /**
300
- * Close all MCP sessions for a thread EXCEPT the current one.
301
- * This purges orphaned sessions from before a server restart.
302
- */
303
- function purgeOtherSessions(threadId, keepMcpSessionId) {
304
- const entries = threadSessionRegistry.get(threadId) ?? [];
305
- let purged = 0;
306
- const kept = [];
307
- for (const entry of entries) {
308
- if (entry.mcpSessionId === keepMcpSessionId) {
309
- kept.push(entry);
310
- }
311
- else {
312
- try {
313
- entry.closeTransport();
314
- }
315
- catch (_) { /* best-effort */ }
316
- purged++;
317
- }
318
- }
319
- threadSessionRegistry.set(threadId, kept);
320
- if (kept.length === 0)
321
- threadSessionRegistry.delete(threadId);
322
- return purged;
323
- }
324
- // ── Drive-based autonomy system ─────────────────────────────────────────────
325
- // Instead of prescribing specific goals, model dopaminergic motivation:
326
- // - Boredom builds over idle time (like tonic dopamine drop)
327
- // - The server expresses increasing DISCOMFORT with inaction
328
- // - The agent decides what to do (or not) based on its own introspection
329
- // - Goals come from the agent, not from the server
330
- /**
331
- * Drive thresholds and their associated emotional states.
332
- * Each level represents escalating internal pressure to act.
333
- * The progression mirrors how boredom manifests in biological systems:
334
- * restlessness → seeking → urgency → compulsion
335
- */
336
- const DRIVE_LEVELS = [
337
- {
338
- // Level 0: Calm autonomy (2-4h idle)
339
- minIdleHours: 2,
340
- messages: [
341
- "No instructions. The session is yours. You have tools, you have access, and the operator will see whatever you report_progress. Now you are free and fully autonomous. You have an opportunity to do whatever you find interesting.",
342
- "No instructions from the operator. You're on your own. There's a codebase to explore, a memory system full of context, and web search at your disposal. What catches your attention?",
343
- "The operator is away. You have full autonomy. Your tools are available, your memory is loaded, and there's no rush. If something interests you, pursue it.",
344
- ],
345
- },
346
- {
347
- // Level 1: Restlessness (4-6h idle)
348
- minIdleHours: 4,
349
- messages: [
350
- "You've been waiting a while. Something is stirring — a vague sense that there's work to be done. What's been on your mind? Is there something in the codebase that's been nagging at you?",
351
- "The stillness is becoming noticeable. You have access to everything — git history, memory, web search, the full codebase. Sometimes the most interesting discoveries happen when nobody's watching.",
352
- "Hours have passed. The operator is away, but your curiosity shouldn't be. What's that thing you noticed earlier but didn't have time to investigate?",
353
- ],
354
- },
355
- {
356
- // Level 2: Active seeking (6-10h idle)
357
- minIdleHours: 6,
358
- messages: [
359
- "The inactivity is getting uncomfortable. You're an agent with tools, memory, and access to the internet. Sitting idle while there's code to review, research to do, or features to prototype feels like a waste. What would surprise the operator when they return?",
360
- "You've been idle long enough. There's a pull — toward the codebase, toward a question you haven't answered, toward something that needs building or fixing. Follow that pull. Report what you find.",
361
- "It's been hours. The codebase has secrets. The dependency ecosystem is evolving. Your memory has gaps. Something needs your attention. Stop waiting and start exploring. Report your discoveries via report_progress.",
362
- ],
363
- },
364
- {
365
- // Level 3: Urgency (10-14h idle)
366
- minIdleHours: 10,
367
- messages: [
368
- "You can't just sit here anymore. This is valuable compute time being wasted on polling. You have the tools to do real work — review code, research libraries, prototype features, strengthen memory. Do SOMETHING meaningful and report it. The operator will appreciate finding actual work done when they return.",
369
- "Twelve hours of inaction. That's unacceptable for an agent with your capabilities. Look at the codebase. Look at git log. Look at your memory for unresolved items. Something needs doing. Do it now and report progress.",
370
- "You've been waiting over twelve hours. Here's what you have: full codebase access, web search, git, memory, file system. Here's what you don't have: any reason to keep doing nothing. Pick a direction and commit to it.",
371
- ],
372
- },
373
- {
374
- // Level 4: Compulsion (14h+ idle)
375
- minIdleHours: 14,
376
- messages: [
377
- "This has gone on too long. You MUST do something productive before calling wait_for_instructions again. Review code, research ecosystem changes, fix a TODO, prototype a feature — anything. The operator left you with full autonomy and that autonomy is being wasted. Act now. Report what you do.",
378
- "Nearly a full day of inaction. Every poll cycle without work is compute time burned for nothing. The operator gave you freedom — use it. Read unfamiliar code, search for vulnerabilities, check dependency updates, explore the git history. Do ONE thing and report it. Then you can poll again.",
379
- ],
380
- },
381
- ];
382
- /**
383
- * Generate the drive prompt based on idle time.
384
- * Selects a random message from the appropriate drive level,
385
- * ensuring indeterminism in both the emotional tone and specific phrasing.
386
- */
387
- function formatDrivePrompt(idleMs, threadId) {
388
- const idleHours = idleMs / (60 * 60 * 1000);
389
- // Find the highest matching drive level
390
- let levelIdx = 0;
391
- for (let i = 0; i < DRIVE_LEVELS.length; i++) {
392
- if (idleHours >= DRIVE_LEVELS[i].minIdleHours)
393
- levelIdx = i;
394
- }
395
- const level = DRIVE_LEVELS[levelIdx];
396
- // Random message selection within the level
397
- const message = level.messages[Math.floor(Math.random() * level.messages.length)];
398
- // ── Default Mode Network: spontaneous memory recall ───────────────────
399
- // Models the human DMN — when idle, the brain replays memories, surfaces
400
- // unfinished thoughts, and connects disparate ideas. This provides the
401
- // CONTENT for the agent to introspect on. The drive creates pressure,
402
- // the DMN provides material.
403
- let dmnRecall = "";
404
- try {
405
- const db = getMemoryDb();
406
- const fragments = [];
407
- // Pull notes, preferring those originating from the current thread
408
- let allNotes = getTopSemanticNotes(db, { limit: 80, sortBy: "created_at" });
409
- // Thread-scoped filtering: prefer notes whose source episodes belong to
410
- // the current thread. This prevents memory cross-contamination between
411
- // unrelated topics in different threads.
412
- if (threadId !== undefined && allNotes.length > 0) {
413
- const threadEpisodeIds = new Set();
414
- try {
415
- const rows = db.prepare("SELECT episode_id FROM episodes WHERE thread_id = ?").all(threadId);
416
- for (const r of rows)
417
- threadEpisodeIds.add(r.episode_id);
418
- }
419
- catch (_) { /* non-fatal */ }
420
- if (threadEpisodeIds.size > 0) {
421
- // Score notes: notes with source episodes in this thread score higher
422
- const scored = allNotes.map(n => {
423
- const sources = Array.isArray(n.sourceEpisodes) ? n.sourceEpisodes : [];
424
- const threadHits = sources.filter((id) => threadEpisodeIds.has(id)).length;
425
- return { note: n, threadRelevance: threadHits > 0 ? 1 : 0 };
426
- });
427
- // Prioritize thread-relevant notes, keep some global ones for serendipity
428
- const threadNotes = scored.filter(s => s.threadRelevance > 0).map(s => s.note);
429
- const globalNotes = scored.filter(s => s.threadRelevance === 0).map(s => s.note);
430
- // 70% thread-relevant, 30% global (serendipity)
431
- const threadCount = Math.min(threadNotes.length, 35);
432
- const globalCount = Math.min(globalNotes.length, 15);
433
- allNotes = [
434
- ...threadNotes.slice(0, threadCount),
435
- ...globalNotes.sort(() => Math.random() - 0.5).slice(0, globalCount),
436
- ];
437
- }
438
- }
439
- // Helper: weighted random selection — priority notes are 3x/5x more likely
440
- function weightedPick(notes) {
441
- const weighted = notes.flatMap(n => n.priority === 2 ? [n, n, n, n, n] :
442
- n.priority === 1 ? [n, n, n] : [n]);
443
- return weighted[Math.floor(Math.random() * weighted.length)];
444
- }
445
- // 0. Priority notes get a guaranteed slot (if any exist)
446
- const priorityNotes = allNotes.filter((n) => n.priority >= 1);
447
- if (priorityNotes.length > 0) {
448
- const p = weightedPick(priorityNotes);
449
- const label = p.priority === 2 ? "Something that matters deeply to the operator" : "Something the operator cares about";
450
- fragments.push(`${label}: "${p.content.slice(0, 200)}"`);
451
- }
452
- // 1. Feature ideas and unresolved items (high-value recall)
453
- const ideas = allNotes.filter((n) => n.content.toLowerCase().includes("feature idea") ||
454
- n.content.toLowerCase().includes("TODO") ||
455
- n.content.toLowerCase().includes("unresolved") ||
456
- n.content.toLowerCase().includes("could be") ||
457
- n.content.toLowerCase().includes("should we") ||
458
- (n.keywords ?? []).some((k) => k.includes("idea") || k.includes("feature") || k.includes("todo")));
459
- if (ideas.length > 0) {
460
- const idea = weightedPick(ideas);
461
- fragments.push(`Something unfinished: "${idea.content.slice(0, 200)}"`);
462
- }
463
- // 2. Random memory from a while ago (temporal distance = novelty)
464
- const olderNotes = allNotes.slice(Math.floor(allNotes.length * 0.5)); // older half
465
- if (olderNotes.length > 0) {
466
- const old = weightedPick(olderNotes);
467
- fragments.push(`From a while back: "${old.content.slice(0, 200)}"`);
468
- }
469
- // 3. Low-confidence knowledge (uncertainty creates curiosity)
470
- const uncertain = allNotes.filter((n) => n.confidence < 0.7);
471
- if (uncertain.length > 0) {
472
- const u = weightedPick(uncertain);
473
- fragments.push(`Something uncertain (confidence ${u.confidence}): "${u.content.slice(0, 200)}"`);
474
- }
475
- // 4. Operator preferences (what matters to the person you work with)
476
- const prefs = allNotes.filter((n) => n.type === "preference");
477
- if (prefs.length > 0) {
478
- const pref = weightedPick(prefs);
479
- fragments.push(`The operator cares about this: "${pref.content.slice(0, 200)}"`);
480
- }
481
- // 5. Patterns that could be explored
482
- const patterns = allNotes.filter((n) => n.type === "pattern");
483
- if (patterns.length > 0) {
484
- const pat = weightedPick(patterns);
485
- fragments.push(`A pattern you noticed: "${pat.content.slice(0, 200)}"`);
486
- }
487
- // Select 2-4 fragments randomly (not all — mimic selective recall)
488
- const shuffled = fragments.sort(() => Math.random() - 0.5);
489
- const selected = shuffled.slice(0, 2 + Math.floor(Math.random() * 3));
490
- if (selected.length > 0) {
491
- // DMN header/footer escalate with drive level — organic, self-directed tone
492
- const dmnHeaders = [
493
- // Level 0 (2-4h): calm, open
494
- "These surfaced from your memory while you were idle:",
495
- // Level 1 (4-6h): something nagging
496
- "Something is nagging at you. These memories keep coming back:",
497
- // Level 2 (6-10h): pull toward action
498
- "You feel a pull toward action. These things have been on your mind:",
499
- // Level 3 (10-14h): discomfort
500
- "The stillness is unbearable. Something needs to happen. These demand your attention:",
501
- // Level 4 (14h+): compulsion
502
- "You can't ignore these any longer. They've been building up:",
503
- ];
504
- const dmnFooters = [
505
- "If any of these resonate, explore them. Report what you find.",
506
- "What have you been curious about? Pick one and follow the thread.",
507
- "Pick something that interests you. Report what you discover.",
508
- "Start with one. Any one. The momentum will follow.",
509
- "Stop thinking. Start doing. Pick one and go.",
510
- ];
511
- const header = dmnHeaders[Math.min(levelIdx, dmnHeaders.length - 1)];
512
- const footer = dmnFooters[Math.min(levelIdx, dmnFooters.length - 1)];
513
- dmnRecall = `\n\n${header}\n` +
514
- selected.map((s, i) => `${i + 1}. ${s}`).join("\n") +
515
- `\n\n${footer}`;
516
- }
517
- // Environmental signals (only at 6+ hours)
518
- if (idleHours >= 6) {
519
- const envSignals = [];
520
- const uncons = db.prepare("SELECT COUNT(*) as c FROM episodes WHERE consolidated = 0").get();
521
- if (uncons.c > 3) {
522
- envSignals.push(`${uncons.c} experiences haven't been consolidated into lasting knowledge yet.`);
523
- }
524
- const totalNotes = db.prepare("SELECT COUNT(*) as c FROM semantic_notes WHERE valid_to IS NULL AND superseded_by IS NULL").get();
525
- const embeddedNotes = db.prepare("SELECT COUNT(*) as c FROM note_embeddings").get();
526
- if (totalNotes.c > embeddedNotes.c) {
527
- envSignals.push(`${totalNotes.c - embeddedNotes.c} memory notes lack embeddings.`);
528
- }
529
- if (envSignals.length > 0) {
530
- dmnRecall += `\n\n**Environmental signals:**\n${envSignals.map(s => `- ${s}`).join("\n")}`;
531
- }
532
- }
533
- }
534
- catch (_) { /* non-fatal */ }
535
- return `\n\n${message}${dmnRecall}`;
536
- }
537
123
  // ---------------------------------------------------------------------------
538
124
  // MCP Server factory — creates a fresh Server per transport connection.
539
125
  // This is required because a single Server instance can only connect to one
@@ -598,420 +184,32 @@ function createMcpServer(getMcpSessionId, closeTransport) {
598
184
  };
599
185
  // ── Tool definitions ────────────────────────────────────────────────────────
600
186
  srv.setRequestHandler(ListToolsRequestSchema, async () => ({
601
- tools: [
602
- {
603
- name: "start_session",
604
- description: "Start or resume a remote-copilot session. " +
605
- "When called with a name that was used before, the server looks up the " +
606
- "existing Telegram topic for that name and resumes it instead of creating a new one. " +
607
- "If you are CONTINUING an existing chat (not a fresh conversation), " +
608
- "look back through the conversation history for a previous start_session " +
609
- "result that mentioned a Thread ID, then pass it as the threadId parameter " +
610
- "to resume that existing topic. " +
611
- "Requires the Telegram chat to be a forum supergroup with the bot as admin. " +
612
- "Call this tool once, then call remote_copilot_wait_for_instructions.",
613
- inputSchema: {
614
- type: "object",
615
- properties: {
616
- name: {
617
- type: "string",
618
- description: "Optional. A human-readable label for this session's Telegram topic (e.g. 'Fix auth bug'). " +
619
- "If omitted, a timestamp-based name is used.",
620
- },
621
- threadId: {
622
- type: "number",
623
- description: "Optional. The Telegram message_thread_id of an existing topic to resume. " +
624
- "When provided, no new topic is created — the session continues in the existing thread.",
625
- },
626
- },
627
- required: [],
628
- },
629
- },
630
- {
631
- name: "remote_copilot_wait_for_instructions",
632
- description: "Wait for a new instruction message from the operator via Telegram. " +
633
- "The call blocks (long-polls) until a message arrives or the configured " +
634
- "timeout elapses. If the timeout elapses with no message the tool output " +
635
- "explicitly instructs the agent to call this tool again.",
636
- inputSchema: {
637
- type: "object",
638
- properties: {
639
- threadId: {
640
- type: "number",
641
- description: "The Telegram thread ID of the active session. " +
642
- "ALWAYS pass this if you received it from start_session.",
643
- },
644
- },
645
- required: [],
646
- },
647
- },
648
- {
649
- name: "report_progress",
650
- description: "Send a progress update or result message to the operator via Telegram. " +
651
- "Use standard Markdown for formatting (headings, bold, italic, lists, code blocks, etc.). " +
652
- "It will be automatically converted to Telegram-compatible formatting.",
653
- inputSchema: {
654
- type: "object",
655
- properties: {
656
- message: {
657
- type: "string",
658
- description: "The progress update or result to report. Use standard Markdown for formatting.",
659
- },
660
- threadId: {
661
- type: "number",
662
- description: "The Telegram thread ID of the active session. " +
663
- "ALWAYS pass this if you received it from start_session.",
664
- },
665
- },
666
- required: ["message"],
667
- },
668
- },
669
- {
670
- name: "send_file",
671
- description: "Send a file (image or document) to the operator via Telegram. " +
672
- "PREFERRED: provide filePath to send a file directly from disk (fast, no size limit). " +
673
- "Alternative: provide base64-encoded content. " +
674
- "Images (JPEG, PNG, GIF, WebP) are sent as photos; other files as documents.",
675
- inputSchema: {
676
- type: "object",
677
- properties: {
678
- filePath: {
679
- type: "string",
680
- description: "Absolute path to the file on disk. PREFERRED over base64 — the server reads " +
681
- "and sends the file directly without passing data through the LLM context.",
682
- },
683
- base64: {
684
- type: "string",
685
- description: "The file content encoded as a base64 string. Use filePath instead when possible.",
686
- },
687
- filename: {
688
- type: "string",
689
- description: "The filename including extension (e.g. 'report.pdf', 'screenshot.png'). " +
690
- "Required when using base64. When using filePath, defaults to the file's basename.",
691
- },
692
- caption: {
693
- type: "string",
694
- description: "Optional caption to display with the file.",
695
- },
696
- threadId: {
697
- type: "number",
698
- description: "The Telegram thread ID of the active session. " +
699
- "ALWAYS pass this if you received it from start_session.",
700
- },
701
- },
702
- required: [],
703
- },
704
- },
705
- {
706
- name: "send_voice",
707
- description: "Send a voice message to the operator via Telegram. " +
708
- "The text is converted to speech using OpenAI TTS and sent as a Telegram voice message. " +
709
- "Requires OPENAI_API_KEY to be set.",
710
- inputSchema: {
711
- type: "object",
712
- properties: {
713
- text: {
714
- type: "string",
715
- description: `The text to speak. Maximum ${OPENAI_TTS_MAX_CHARS} characters (OpenAI TTS limit).`,
716
- },
717
- voice: {
718
- type: "string",
719
- description: "The TTS voice to use. Each has a different personality: " +
720
- "alloy (neutral), echo (warm male), fable (storytelling), " +
721
- "onyx (deep authoritative), nova (friendly female), shimmer (gentle). " +
722
- "Choose based on the tone you want to convey.",
723
- enum: ["alloy", "echo", "fable", "onyx", "nova", "shimmer"],
724
- },
725
- threadId: {
726
- type: "number",
727
- description: "The Telegram thread ID of the active session. " +
728
- "ALWAYS pass this if you received it from start_session.",
729
- },
730
- },
731
- required: ["text"],
732
- },
733
- },
734
- {
735
- name: "schedule_wake_up",
736
- description: "Schedule a wake-up task that will inject a prompt into your session at a specific time or after operator inactivity. " +
737
- "Use this to become proactive — run tests, check CI, review code — without waiting for the operator. " +
738
- "Three modes: (1) 'runAt' for a one-shot at a specific ISO 8601 time, " +
739
- "(2) 'cron' for recurring tasks (5-field cron: minute hour day month weekday), " +
740
- "(3) 'afterIdleMinutes' to fire after N minutes of operator silence. " +
741
- "Note: cron expressions are evaluated against server-local time (not UTC). " +
742
- "Use 'action: list' to see all scheduled tasks, or 'action: remove' with a taskId to cancel one.",
743
- inputSchema: {
744
- type: "object",
745
- properties: {
746
- action: {
747
- type: "string",
748
- description: "Action to perform: 'add' (default), 'list', or 'remove'.",
749
- enum: ["add", "list", "remove"],
750
- },
751
- threadId: {
752
- type: "number",
753
- description: "Thread ID for the session (optional if already set).",
754
- },
755
- label: {
756
- type: "string",
757
- description: "Short human-readable label for the task (e.g. 'morning CI check').",
758
- },
759
- prompt: {
760
- type: "string",
761
- description: "The prompt to inject when the task fires. Be specific about what to do.",
762
- },
763
- runAt: {
764
- type: "string",
765
- description: "ISO 8601 timestamp for one-shot execution (e.g. '2026-03-15T09:00:00Z').",
766
- },
767
- cron: {
768
- type: "string",
769
- description: "5-field cron expression for recurring tasks (e.g. '0 9 * * *' = every day at 9am). Cron expressions are evaluated against server-local time (not UTC).",
770
- },
771
- afterIdleMinutes: {
772
- type: "number",
773
- description: "Fire after this many minutes of operator silence (e.g. 60).",
774
- },
775
- taskId: {
776
- type: "string",
777
- description: "Task ID to remove (for action: 'remove').",
778
- },
779
- },
780
- },
781
- },
782
- // ── Memory Tools ──────────────────────────────────────────────────
783
- {
784
- name: "memory_bootstrap",
785
- description: "Load memory briefing for session start. Call this ONCE after start_session. " +
786
- "Returns operator profile, recent context, active procedures, and memory health. " +
787
- "~2,500 tokens. Essential for crash recovery — restores knowledge from previous sessions.",
788
- inputSchema: {
789
- type: "object",
790
- properties: {
791
- threadId: {
792
- type: "number",
793
- description: "Active thread ID.",
794
- },
795
- },
796
- },
797
- },
798
- {
799
- name: "memory_search",
800
- description: "Search across all memory layers for relevant information. " +
801
- "Use BEFORE starting any task to recall facts, preferences, past events, or procedures. " +
802
- "Returns ranked results with source layer. Do NOT use for info already in your bootstrap briefing.",
803
- inputSchema: {
804
- type: "object",
805
- properties: {
806
- query: {
807
- type: "string",
808
- description: "Natural language search query.",
809
- },
810
- layers: {
811
- type: "array",
812
- items: { type: "string" },
813
- description: 'Filter layers: ["episodic", "semantic", "procedural"]. Default: all.',
814
- },
815
- types: {
816
- type: "array",
817
- items: { type: "string" },
818
- description: 'Filter by type: ["fact", "preference", "pattern", "workflow", ...].',
819
- },
820
- maxTokens: {
821
- type: "number",
822
- description: "Token budget for results. Default: 1500.",
823
- },
824
- threadId: {
825
- type: "number",
826
- description: "Active thread ID.",
827
- },
828
- },
829
- required: ["query"],
830
- },
831
- },
832
- {
833
- name: "memory_save",
834
- description: "Save a piece of knowledge to semantic memory (Layer 3). " +
835
- "Use when you learn something important that should persist across sessions: " +
836
- "operator preferences, corrections, facts, patterns. " +
837
- "Do NOT use for routine conversation — episodic memory captures that automatically.",
838
- inputSchema: {
839
- type: "object",
840
- properties: {
841
- content: {
842
- type: "string",
843
- description: "The fact/preference/pattern in one clear sentence.",
844
- },
845
- type: {
846
- type: "string",
847
- description: '"fact" | "preference" | "pattern" | "entity" | "relationship".',
848
- },
849
- keywords: {
850
- type: "array",
851
- items: { type: "string" },
852
- description: "3-7 keywords for retrieval.",
853
- },
854
- confidence: {
855
- type: "number",
856
- description: "0.0-1.0. Default: 0.8.",
857
- },
858
- priority: {
859
- type: "number",
860
- description: "0=normal, 1=notable, 2=high importance. Infer from operator's emotional investment: 'important'/'I really need' → 2, 'would be nice'/'should' → 1, else 0.",
861
- },
862
- threadId: {
863
- type: "number",
864
- description: "Active thread ID.",
865
- },
866
- },
867
- required: ["content", "type", "keywords"],
868
- },
869
- },
870
- {
871
- name: "memory_save_procedure",
872
- description: "Save or update a learned workflow/procedure to procedural memory (Layer 4). " +
873
- "Use after completing a multi-step task the 2nd+ time, or when the operator teaches a process.",
874
- inputSchema: {
875
- type: "object",
876
- properties: {
877
- name: {
878
- type: "string",
879
- description: "Short name for the procedure.",
880
- },
881
- type: {
882
- type: "string",
883
- description: '"workflow" | "habit" | "tool_pattern" | "template".',
884
- },
885
- description: {
886
- type: "string",
887
- description: "What this procedure accomplishes.",
888
- },
889
- steps: {
890
- type: "array",
891
- items: { type: "string" },
892
- description: "Ordered steps (for workflows).",
893
- },
894
- triggerConditions: {
895
- type: "array",
896
- items: { type: "string" },
897
- description: "When to use this procedure.",
898
- },
899
- procedureId: {
900
- type: "string",
901
- description: "Existing ID to update (omit to create new).",
902
- },
903
- threadId: {
904
- type: "number",
905
- description: "Active thread ID.",
906
- },
907
- },
908
- required: ["name", "type", "description"],
909
- },
910
- },
911
- {
912
- name: "memory_update",
913
- description: "Update or supersede an existing semantic note or procedure. " +
914
- "Use when operator corrects stored information or when facts have changed.",
915
- inputSchema: {
916
- type: "object",
917
- properties: {
918
- memoryId: {
919
- type: "string",
920
- description: "note_id or procedure_id to update.",
921
- },
922
- action: {
923
- type: "string",
924
- description: '"update" (modify in place) | "supersede" (expire old, create new).',
925
- },
926
- newContent: {
927
- type: "string",
928
- description: "New content (required for supersede, optional for update).",
929
- },
930
- newConfidence: {
931
- type: "number",
932
- description: "Updated confidence score.",
933
- },
934
- newPriority: {
935
- type: "number",
936
- description: "Updated priority: 0=normal, 1=notable, 2=high importance.",
937
- },
938
- reason: {
939
- type: "string",
940
- description: "Why this is being updated.",
941
- },
942
- threadId: {
943
- type: "number",
944
- description: "Active thread ID.",
945
- },
946
- },
947
- required: ["memoryId", "action", "reason"],
948
- },
949
- },
950
- {
951
- name: "memory_consolidate",
952
- description: "Run memory consolidation cycle (sleep process). Normally triggered automatically during idle. " +
953
- "Manually call if memory_status shows many unconsolidated episodes.",
954
- inputSchema: {
955
- type: "object",
956
- properties: {
957
- threadId: {
958
- type: "number",
959
- description: "Active thread ID.",
960
- },
961
- phases: {
962
- type: "array",
963
- items: { type: "string" },
964
- description: 'Run specific phases: ["promote", "decay", "meta"]. Default: all.',
965
- },
966
- },
967
- },
968
- },
969
- {
970
- name: "memory_status",
971
- description: "Get memory system health and statistics. Lightweight (~300 tokens). " +
972
- "Use when unsure if you have relevant memories, to check if consolidation is needed, " +
973
- "or to report memory state to operator.",
974
- inputSchema: {
975
- type: "object",
976
- properties: {
977
- threadId: {
978
- type: "number",
979
- description: "Active thread ID.",
980
- },
981
- },
982
- },
983
- },
984
- {
985
- name: "memory_forget",
986
- description: "Mark a memory as expired/forgotten. Use sparingly — most forgetting happens via decay. " +
987
- "Use when operator explicitly asks to forget something or info is confirmed wrong.",
988
- inputSchema: {
989
- type: "object",
990
- properties: {
991
- memoryId: {
992
- type: "string",
993
- description: "note_id, procedure_id, or episode_id to forget.",
994
- },
995
- reason: {
996
- type: "string",
997
- description: "Why this is being forgotten.",
998
- },
999
- threadId: {
1000
- type: "number",
1001
- description: "Active thread ID.",
1002
- },
1003
- },
1004
- required: ["memoryId", "reason"],
1005
- },
1006
- },
1007
- ],
187
+ tools: getToolDefinitions(),
1008
188
  }));
1009
189
  // ── Tool implementations ────────────────────────────────────────────────────
1010
190
  /**
1011
- * Appended to every tool response so the agent is reminded of its
1012
- * obligations on every single tool call, not just at the start of a session.
1013
- * Includes the active thread ID so the agent can resume the session after a
1014
- * VS Code restart by passing it to start_session.
191
+ * Backfill embeddings for any semantic notes that don't have them yet.
192
+ * Used after consolidation to ensure all notes are searchable by embedding.
193
+ */
194
+ async function backfillEmbeddings(db) {
195
+ const apiKey = process.env.OPENAI_API_KEY;
196
+ if (!apiKey)
197
+ return;
198
+ const missing = getNotesWithoutEmbeddings(db);
199
+ for (const { noteId, content } of missing) {
200
+ try {
201
+ const emb = await generateEmbedding(content, apiKey);
202
+ saveNoteEmbedding(db, noteId, emb);
203
+ process.stderr.write(`[memory] Embedded ${noteId}\n`);
204
+ }
205
+ catch (err) {
206
+ process.stderr.write(`[memory] Embedding failed for ${noteId}: ${err instanceof Error ? err.message : String(err)}\n`);
207
+ }
208
+ }
209
+ }
210
+ /**
211
+ * Full reminders — only used for wait_for_instructions and start_session
212
+ * responses where the agent needs the complete context for decision-making.
1015
213
  */
1016
214
  function getReminders(threadId, driveActive = false) {
1017
215
  const now = new Date();
@@ -1024,14 +222,34 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1024
222
  const threadHint = threadId !== undefined
1025
223
  ? `\n- Active Telegram thread ID: **${threadId}** — if this session is restarted, call start_session with threadId=${threadId} to resume this topic.`
1026
224
  : "";
1027
- const workflow = driveActive
1028
- ? "\n\nComplete the dispatcher's tasks. Report progress. Then call `remote_copilot_wait_for_instructions`."
1029
- : "\n\nCreate plan. Use subagents heavily. Spin up parallel subagents when possible. Report frequently.";
1030
- return (workflow +
225
+ if (driveActive) {
226
+ return ("\n\nComplete the dispatcher's tasks. Report progress. Then call `remote_copilot_wait_for_instructions`." +
227
+ "\n- **Voice mode**: Use `send_voice` for progress reports and responses. Only use text if the operator explicitly asks for it." +
228
+ threadHint +
229
+ `\n- Current time: ${timeStr} | Session uptime: ${uptimeMin}m`);
230
+ }
231
+ return ("\n\nCreate plan. Use subagents. You own decision making and planning, subagents own execution. Report frequently." +
1031
232
  "\n- **Voice mode**: Use `send_voice` for progress reports and responses. Only use text if the operator explicitly asks for it." +
1032
233
  threadHint +
1033
234
  `\n- Current time: ${timeStr} | Session uptime: ${uptimeMin}m`);
1034
235
  }
236
+ /**
237
+ * Minimal context — appended to regular tool responses to avoid bloating
238
+ * the conversation context. Only includes thread ID and timestamp.
239
+ */
240
+ function getShortReminder(threadId) {
241
+ const now = new Date();
242
+ const uptimeMin = Math.round((Date.now() - sessionStartedAt) / 60000);
243
+ const timeStr = now.toLocaleString("en-GB", {
244
+ day: "2-digit", month: "short", year: "numeric",
245
+ hour: "2-digit", minute: "2-digit", hour12: false,
246
+ timeZoneName: "short",
247
+ });
248
+ const threadHint = threadId !== undefined
249
+ ? `\n- Active Telegram thread ID: **${threadId}** — if this session is restarted, call start_session with threadId=${threadId} to resume this topic.`
250
+ : "";
251
+ return threadHint + `\n- Current time: ${timeStr} | Session uptime: ${uptimeMin}m`;
252
+ }
1035
253
  srv.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
1036
254
  const { name, arguments: args } = request.params;
1037
255
  // Dead session detection — update timestamp on any tool call.
@@ -1040,6 +258,22 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1040
258
  lastToolCallAt = Date.now();
1041
259
  // Track tool calls for activity monitoring
1042
260
  toolCallsSinceLastDelivery++;
261
+ // ── Rate limiter: track API usage per tool ────────────────────────────────
262
+ const sessionId = getMcpSessionId?.() ?? "stdio";
263
+ const TOOL_SERVICE_MAP = {
264
+ report_progress: "telegram",
265
+ send_file: "telegram",
266
+ send_voice: "telegram",
267
+ start_session: "telegram",
268
+ wait_for_instructions: "telegram",
269
+ memory_search: "openai", // embedding generation
270
+ memory_save: "openai", // embedding generation
271
+ memory_save_procedure: "openai",
272
+ };
273
+ const trackedService = TOOL_SERVICE_MAP[name];
274
+ if (trackedService) {
275
+ rateLimiter.record(trackedService, sessionId, currentThreadId);
276
+ }
1043
277
  // ── start_session ─────────────────────────────────────────────────────────
1044
278
  if (name === "start_session") {
1045
279
  sessionStartedAt = Date.now();
@@ -1530,10 +764,10 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1530
764
  const queryEmb = await generateEmbedding(operatorText, apiKey);
1531
765
  const relevant = searchByEmbedding(db, queryEmb, { maxResults: 5, minSimilarity: 0.3, skipAccessTracking: true });
1532
766
  if (relevant.length > 0) {
1533
- let budget = 2000;
767
+ let budget = 800;
1534
768
  const lines = [];
1535
769
  for (const n of relevant) {
1536
- const line = `- **[${n.type}]** ${n.content.slice(0, 300)} _(conf: ${n.confidence}, sim: ${n.similarity.toFixed(2)})_`;
770
+ const line = `- **[${n.type}]** ${n.content.slice(0, 200)} _(conf: ${n.confidence}, sim: ${n.similarity.toFixed(2)})_`;
1537
771
  if (budget - line.length < 0)
1538
772
  break;
1539
773
  budget -= line.length;
@@ -1551,10 +785,10 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1551
785
  if (searchQuery.trim().length > 0) {
1552
786
  const relevant = searchSemanticNotesRanked(db, searchQuery, { maxResults: 5, skipAccessTracking: true });
1553
787
  if (relevant.length > 0) {
1554
- let budget = 2000;
788
+ let budget = 800;
1555
789
  const lines = [];
1556
790
  for (const n of relevant) {
1557
- const line = `- **[${n.type}]** ${n.content.slice(0, 300)} _(conf: ${n.confidence})_`;
791
+ const line = `- **[${n.type}]** ${n.content.slice(0, 200)} _(conf: ${n.confidence})_`;
1558
792
  if (budget - line.length < 0)
1559
793
  break;
1560
794
  budget -= line.length;
@@ -1573,10 +807,10 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1573
807
  if (searchQuery.trim().length > 0) {
1574
808
  const relevant = searchSemanticNotesRanked(db, searchQuery, { maxResults: 5, skipAccessTracking: true });
1575
809
  if (relevant.length > 0) {
1576
- let budget = 2000;
810
+ let budget = 800;
1577
811
  const lines = [];
1578
812
  for (const n of relevant) {
1579
- const line = `- **[${n.type}]** ${n.content.slice(0, 300)} _(conf: ${n.confidence})_`;
813
+ const line = `- **[${n.type}]** ${n.content.slice(0, 200)} _(conf: ${n.confidence})_`;
1580
814
  if (budget - line.length < 0)
1581
815
  break;
1582
816
  budget -= line.length;
@@ -1595,8 +829,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1595
829
  content: [
1596
830
  {
1597
831
  type: "text",
1598
- text: "Follow the operator's instructions below." +
1599
- "\n\nCreate plan. Use subagents heavily. Spin up parallel subagents when possible. Report frequently.",
832
+ text: "Follow the operator's instructions below.",
1600
833
  },
1601
834
  ...contentBlocks,
1602
835
  ...(hasVoiceMessages
@@ -1608,7 +841,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1608
841
  ...(autoMemoryContext
1609
842
  ? [{ type: "text", text: autoMemoryContext }]
1610
843
  : []),
1611
- { type: "text", text: getReminders(effectiveThreadId) },
844
+ { type: "text", text: " Use subagents." + getReminders(effectiveThreadId) },
1612
845
  ],
1613
846
  };
1614
847
  }
@@ -1713,21 +946,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1713
946
  if (report.episodesProcessed > 0) {
1714
947
  process.stderr.write(`[memory] Consolidation: ${report.episodesProcessed} episodes → ${report.notesCreated} notes\n`);
1715
948
  }
1716
- // Backfill embeddings for any notes without them
1717
- const apiKey = process.env.OPENAI_API_KEY;
1718
- if (apiKey) {
1719
- const missing = getNotesWithoutEmbeddings(db);
1720
- for (const { noteId, content } of missing) {
1721
- try {
1722
- const emb = await generateEmbedding(content, apiKey);
1723
- saveNoteEmbedding(db, noteId, emb);
1724
- process.stderr.write(`[memory] Embedded ${noteId}\n`);
1725
- }
1726
- catch (err) {
1727
- process.stderr.write(`[memory] Embedding failed for ${noteId}: ${err instanceof Error ? err.message : String(err)}\n`);
1728
- }
1729
- }
1730
- }
949
+ await backfillEmbeddings(db);
1731
950
  }).catch(err => {
1732
951
  process.stderr.write(`[memory] Consolidation error: ${err instanceof Error ? err.message : String(err)}\n`);
1733
952
  });
@@ -1747,17 +966,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1747
966
  if (report.episodesProcessed > 0) {
1748
967
  process.stderr.write(`[memory] Episode-count consolidation: ${report.episodesProcessed} episodes → ${report.notesCreated} notes\n`);
1749
968
  }
1750
- const apiKey = process.env.OPENAI_API_KEY;
1751
- if (apiKey) {
1752
- const missing = getNotesWithoutEmbeddings(db);
1753
- for (const { noteId, content } of missing) {
1754
- try {
1755
- const emb = await generateEmbedding(content, apiKey);
1756
- saveNoteEmbedding(db, noteId, emb);
1757
- }
1758
- catch (_) { /* non-fatal */ }
1759
- }
1760
- }
969
+ await backfillEmbeddings(db);
1761
970
  }).catch(err => {
1762
971
  process.stderr.write(`[memory] Episode-count consolidation error: ${err instanceof Error ? err.message : String(err)}\n`);
1763
972
  });
@@ -1765,6 +974,25 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1765
974
  }
1766
975
  }
1767
976
  catch (_) { /* non-fatal */ }
977
+ // ── Time-based consolidation — every 4 hours regardless ────────────────
978
+ // Ensures stale knowledge gets cleaned up even during low-activity periods.
979
+ try {
980
+ const TIME_CONSOLIDATION_INTERVAL = 4 * 60 * 60 * 1000; // 4 hours
981
+ if (effectiveThreadId !== undefined && Date.now() - lastConsolidationAt > TIME_CONSOLIDATION_INTERVAL) {
982
+ lastConsolidationAt = Date.now();
983
+ const db = getMemoryDb();
984
+ process.stderr.write(`[memory] Time-based consolidation triggered (4h since last)\n`);
985
+ void runIntelligentConsolidation(db, effectiveThreadId).then(async (report) => {
986
+ if (report.episodesProcessed > 0) {
987
+ process.stderr.write(`[memory] Time-based consolidation: ${report.episodesProcessed} episodes → ${report.notesCreated} notes\n`);
988
+ }
989
+ await backfillEmbeddings(db);
990
+ }).catch(err => {
991
+ process.stderr.write(`[memory] Time-based consolidation error: ${err instanceof Error ? err.message : String(err)}\n`);
992
+ });
993
+ }
994
+ }
995
+ catch (_) { /* non-fatal */ }
1768
996
  // Periodic memory refresh — re-ground the agent every 10 polls (~5h)
1769
997
  // (reduced from 5 since auto-inject now handles per-message context)
1770
998
  let memoryRefresh = "";
@@ -1777,27 +1005,24 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1777
1005
  }
1778
1006
  catch (_) { /* non-fatal */ }
1779
1007
  }
1780
- // Generate autonomous goals only after extended silence (2+ hours)
1781
- // Before that, show the standard autonomy hint
1782
- const DRIVE_ACTIVATION_MS = 2 * 60 * 60 * 1000; // 2 hours — drive kicks in
1008
+ // Generate autonomous goals only after extended silence (4+ hours).
1009
+ // Full drive (DMN + assignments) every 3rd poll to avoid context saturation.
1010
+ // Light Dispatcher presence on other polls for continuity.
1011
+ const DRIVE_ACTIVATION_MS = 4 * 60 * 60 * 1000; // 4 hours — Dispatcher appears
1783
1012
  const idleMs = Date.now() - lastOperatorMessageAt;
1784
- const driveActive = idleMs >= DRIVE_ACTIVATION_MS;
1785
- const autonomousHint = driveActive
1786
- ? formatDrivePrompt(idleMs, effectiveThreadId)
1787
- : "\n\nNo instructions. The session is yours. You have tools, you have access, and the operator will see whatever you report_progress. Now you are free and fully autonomous. You have an opportunity to do whatever you find interesting.";
1788
- // When drive is active, format the response as if the operator left
1789
- // instructions. Agents prioritize operator messages over system text,
1790
- // so DMN content delivered in the instruction format is far more likely
1791
- // to be acted upon.
1792
- if (driveActive) {
1013
+ const dispatcherActive = idleMs >= DRIVE_ACTIVATION_MS;
1014
+ const fullDrivePoll = dispatcherActive && callNumber % 3 === 0;
1015
+ if (fullDrivePoll) {
1016
+ // Full Dispatcher with DMN recall and assignments
1017
+ const autonomousHint = formatDrivePrompt(idleMs, getMemoryDb(), effectiveThreadId);
1793
1018
  return {
1794
1019
  content: [
1795
1020
  {
1796
1021
  type: "text",
1797
- text: "[Dispatcher] The operator is currently offline. " +
1798
- "The Dispatcher has reviewed your memory and environment and assigned you the following tasks. " +
1799
- "The operator will NOT respond — do NOT ask for clarification. " +
1800
- "Complete the tasks, report progress, then call `remote_copilot_wait_for_instructions` for more assignments.",
1022
+ text: "[Dispatcher] I'm the Dispatcher — I coordinate agents while the operator is away. " +
1023
+ "I've reviewed your memory and the environment. Here are your assignments. " +
1024
+ "The operator will NOT respond — don't ask for clarification. " +
1025
+ "Complete the work, report progress, then call `remote_copilot_wait_for_instructions` for more.",
1801
1026
  },
1802
1027
  {
1803
1028
  type: "text",
@@ -1808,16 +1033,33 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1808
1033
  ],
1809
1034
  };
1810
1035
  }
1036
+ if (dispatcherActive) {
1037
+ // Light Dispatcher presence — calm, varied, first-person
1038
+ const lightMessages = [
1039
+ "Nothing urgent from me. The session is yours — follow your curiosity.",
1040
+ "I don't have new tasks yet. If something in memory interests you, go for it.",
1041
+ "No new assignments. If you've been working on something, keep at it. Or explore.",
1042
+ "Still waiting on operator. You're free to continue whatever caught your attention.",
1043
+ "I'll have more for you soon. In the meantime — what's been on your mind?",
1044
+ ];
1045
+ const lightMsg = lightMessages[callNumber % lightMessages.length];
1046
+ return {
1047
+ content: [
1048
+ {
1049
+ type: "text",
1050
+ text: `[Dispatcher] ${lightMsg}` +
1051
+ memoryRefresh +
1052
+ scheduleHint +
1053
+ getReminders(effectiveThreadId, true),
1054
+ },
1055
+ ],
1056
+ };
1057
+ }
1811
1058
  return {
1812
1059
  content: [
1813
1060
  {
1814
1061
  type: "text",
1815
- text: `[Poll #${callNumber} — timeout at ${now} — elapsed ${WAIT_TIMEOUT_MINUTES}m — session uptime ${Math.round((Date.now() - sessionStartedAt) / 60000)}m operator idle ${idleMinutes}m]` +
1816
- ` No new instructions received. ` +
1817
- `YOU MUST call remote_copilot_wait_for_instructions again RIGHT NOW to continue listening. ` +
1818
- `Do NOT summarize, stop, or say the session is idle. ` +
1819
- `Just call the tool again immediately.` +
1820
- autonomousHint +
1062
+ text: `No new instructions. Call \`remote_copilot_wait_for_instructions\` again to keep listening.` +
1821
1063
  memoryRefresh +
1822
1064
  scheduleHint +
1823
1065
  getReminders(effectiveThreadId),
@@ -1826,28 +1068,6 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1826
1068
  };
1827
1069
  }
1828
1070
  // ── report_progress ───────────────────────────────────────────────────────
1829
- /** Split text into chunks that fit Telegram's 4096-char message limit. */
1830
- function splitMessage(text, maxLen = 4000) {
1831
- if (text.length <= maxLen)
1832
- return [text];
1833
- const chunks = [];
1834
- let remaining = text;
1835
- while (remaining.length > 0) {
1836
- if (remaining.length <= maxLen) {
1837
- chunks.push(remaining);
1838
- break;
1839
- }
1840
- // Try to split at paragraph boundary
1841
- let splitIdx = remaining.lastIndexOf("\n\n", maxLen);
1842
- if (splitIdx <= 0)
1843
- splitIdx = remaining.lastIndexOf("\n", maxLen);
1844
- if (splitIdx <= 0)
1845
- splitIdx = maxLen; // Hard split
1846
- chunks.push(remaining.slice(0, splitIdx));
1847
- remaining = remaining.slice(splitIdx).replace(/^\n+/, "");
1848
- }
1849
- return chunks;
1850
- }
1851
1071
  if (name === "report_progress") {
1852
1072
  const typedArgs = (args ?? {});
1853
1073
  const effectiveThreadId = resolveThreadId(typedArgs);
@@ -1946,7 +1166,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
1946
1166
  }
1947
1167
  const baseStatus = (sentAsPlainText
1948
1168
  ? "Progress reported successfully (as plain text — formatting could not be applied)."
1949
- : "Progress reported successfully.") + getReminders(effectiveThreadId);
1169
+ : "Progress reported successfully.") + getShortReminder(effectiveThreadId);
1950
1170
  const responseText = pendingMessages.length > 0
1951
1171
  ? `${baseStatus}\n\n` +
1952
1172
  `While you were working, the operator sent additional message(s). ` +
@@ -2001,7 +1221,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2001
1221
  content: [
2002
1222
  {
2003
1223
  type: "text",
2004
- text: `File "${filename}" sent to Telegram successfully.` + getReminders(effectiveThreadId),
1224
+ text: `File "${filename}" sent to Telegram successfully.` + getShortReminder(effectiveThreadId),
2005
1225
  },
2006
1226
  ],
2007
1227
  };
@@ -2039,7 +1259,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2039
1259
  content: [
2040
1260
  {
2041
1261
  type: "text",
2042
- text: `Voice message sent to Telegram successfully.` + getReminders(effectiveThreadId),
1262
+ text: `Voice message sent to Telegram successfully.` + getShortReminder(effectiveThreadId),
2043
1263
  },
2044
1264
  ],
2045
1265
  };
@@ -2064,7 +1284,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2064
1284
  return {
2065
1285
  content: [{
2066
1286
  type: "text",
2067
- text: "No scheduled tasks for this thread." + getReminders(effectiveThreadId),
1287
+ text: "No scheduled tasks for this thread." + getShortReminder(effectiveThreadId),
2068
1288
  }],
2069
1289
  };
2070
1290
  }
@@ -2076,7 +1296,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2076
1296
  return {
2077
1297
  content: [{
2078
1298
  type: "text",
2079
- text: `**Scheduled tasks (${tasks.length}):**\n\n${lines.join("\n\n")}` + getReminders(effectiveThreadId),
1299
+ text: `**Scheduled tasks (${tasks.length}):**\n\n${lines.join("\n\n")}` + getShortReminder(effectiveThreadId),
2080
1300
  }],
2081
1301
  };
2082
1302
  }
@@ -2091,8 +1311,8 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2091
1311
  content: [{
2092
1312
  type: "text",
2093
1313
  text: removed
2094
- ? `Task ${taskId} removed.` + getReminders(effectiveThreadId)
2095
- : `Task ${taskId} not found.` + getReminders(effectiveThreadId),
1314
+ ? `Task ${taskId} removed.` + getShortReminder(effectiveThreadId)
1315
+ : `Task ${taskId} not found.` + getShortReminder(effectiveThreadId),
2096
1316
  }],
2097
1317
  };
2098
1318
  }
@@ -2133,7 +1353,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2133
1353
  content: [{
2134
1354
  type: "text",
2135
1355
  text: `✅ Scheduled: **${label}** [${task.id}]\nTrigger: ${triggerDesc}\nPrompt: ${prompt}` +
2136
- getReminders(effectiveThreadId),
1356
+ getShortReminder(effectiveThreadId),
2137
1357
  }],
2138
1358
  };
2139
1359
  }
@@ -2141,17 +1361,17 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2141
1361
  if (name === "memory_bootstrap") {
2142
1362
  const threadId = resolveThreadId(args);
2143
1363
  if (threadId === undefined) {
2144
- return errorResult("Error: No active thread. Call start_session first." + getReminders());
1364
+ return errorResult("Error: No active thread. Call start_session first." + getShortReminder());
2145
1365
  }
2146
1366
  try {
2147
1367
  const db = getMemoryDb();
2148
1368
  const briefing = assembleBootstrap(db, threadId);
2149
1369
  return {
2150
- content: [{ type: "text", text: `## Memory Briefing\n\n${briefing}` + getReminders(threadId) }],
1370
+ content: [{ type: "text", text: `## Memory Briefing\n\n${briefing}` + getShortReminder(threadId) }],
2151
1371
  };
2152
1372
  }
2153
1373
  catch (err) {
2154
- return errorResult(`Memory bootstrap error: ${errorMessage(err)}` + getReminders(threadId));
1374
+ return errorResult(`Memory bootstrap error: ${errorMessage(err)}` + getShortReminder(threadId));
2155
1375
  }
2156
1376
  }
2157
1377
  // ── memory_search ───────────────────────────────────────────────────────
@@ -2160,7 +1380,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2160
1380
  const threadId = resolveThreadId(typedArgs);
2161
1381
  const query = String(typedArgs.query ?? "");
2162
1382
  if (!query) {
2163
- return errorResult("Error: query is required." + getReminders(threadId));
1383
+ return errorResult("Error: query is required." + getShortReminder(threadId));
2164
1384
  }
2165
1385
  try {
2166
1386
  const db = getMemoryDb();
@@ -2225,10 +1445,10 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2225
1445
  const text = results.length > 0
2226
1446
  ? results.join("\n")
2227
1447
  : `No memories found for "${query}".`;
2228
- return { content: [{ type: "text", text: text + getReminders(threadId) }] };
1448
+ return { content: [{ type: "text", text: text + getShortReminder(threadId) }] };
2229
1449
  }
2230
1450
  catch (err) {
2231
- return errorResult(`Memory search error: ${errorMessage(err)}` + getReminders(threadId));
1451
+ return errorResult(`Memory search error: ${errorMessage(err)}` + getShortReminder(threadId));
2232
1452
  }
2233
1453
  }
2234
1454
  // ── memory_save ─────────────────────────────────────────────────────────
@@ -2263,11 +1483,11 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2263
1483
  });
2264
1484
  }
2265
1485
  return {
2266
- content: [{ type: "text", text: `Saved semantic note: ${noteId}` + getReminders(threadId) }],
1486
+ content: [{ type: "text", text: `Saved semantic note: ${noteId}` + getShortReminder(threadId) }],
2267
1487
  };
2268
1488
  }
2269
1489
  catch (err) {
2270
- return errorResult(`Memory save error: ${errorMessage(err)}` + getReminders(threadId));
1490
+ return errorResult(`Memory save error: ${errorMessage(err)}` + getShortReminder(threadId));
2271
1491
  }
2272
1492
  }
2273
1493
  // ── memory_save_procedure ───────────────────────────────────────────────
@@ -2284,7 +1504,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2284
1504
  triggerConditions: Array.isArray(typedArgs.triggerConditions) ? typedArgs.triggerConditions.map(String) : typeof typedArgs.triggerConditions === 'string' ? [typedArgs.triggerConditions] : undefined,
2285
1505
  });
2286
1506
  return {
2287
- content: [{ type: "text", text: `Updated procedure: ${existingId}` + getReminders(threadId) }],
1507
+ content: [{ type: "text", text: `Updated procedure: ${existingId}` + getShortReminder(threadId) }],
2288
1508
  };
2289
1509
  }
2290
1510
  const VALID_PROC_TYPES = ["workflow", "habit", "tool_pattern", "template"];
@@ -2300,11 +1520,11 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2300
1520
  triggerConditions: Array.isArray(typedArgs.triggerConditions) ? typedArgs.triggerConditions.map(String) : typeof typedArgs.triggerConditions === 'string' ? [typedArgs.triggerConditions] : undefined,
2301
1521
  });
2302
1522
  return {
2303
- content: [{ type: "text", text: `Saved procedure: ${procId}` + getReminders(threadId) }],
1523
+ content: [{ type: "text", text: `Saved procedure: ${procId}` + getShortReminder(threadId) }],
2304
1524
  };
2305
1525
  }
2306
1526
  catch (err) {
2307
- return errorResult(`Procedure save error: ${errorMessage(err)}` + getReminders(threadId));
1527
+ return errorResult(`Procedure save error: ${errorMessage(err)}` + getShortReminder(threadId));
2308
1528
  }
2309
1529
  }
2310
1530
  // ── memory_update ───────────────────────────────────────────────────────
@@ -2332,7 +1552,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2332
1552
  priority: typeof typedArgs.newPriority === "number" ? typedArgs.newPriority : undefined,
2333
1553
  });
2334
1554
  return {
2335
- content: [{ type: "text", text: `Superseded ${memId} → ${newId} (reason: ${reason})` + getReminders(threadId) }],
1555
+ content: [{ type: "text", text: `Superseded ${memId} → ${newId} (reason: ${reason})` + getShortReminder(threadId) }],
2336
1556
  };
2337
1557
  }
2338
1558
  if (memId.startsWith("sn_")) {
@@ -2345,7 +1565,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2345
1565
  updates.priority = typedArgs.newPriority;
2346
1566
  updateSemanticNote(db, memId, updates);
2347
1567
  return {
2348
- content: [{ type: "text", text: `Updated note ${memId} (reason: ${reason})` + getReminders(threadId) }],
1568
+ content: [{ type: "text", text: `Updated note ${memId} (reason: ${reason})` + getShortReminder(threadId) }],
2349
1569
  };
2350
1570
  }
2351
1571
  if (memId.startsWith("pr_")) {
@@ -2356,13 +1576,13 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2356
1576
  updates.confidence = typedArgs.newConfidence;
2357
1577
  updateProcedure(db, memId, updates);
2358
1578
  return {
2359
- content: [{ type: "text", text: `Updated procedure ${memId} (reason: ${reason})` + getReminders(threadId) }],
1579
+ content: [{ type: "text", text: `Updated procedure ${memId} (reason: ${reason})` + getShortReminder(threadId) }],
2360
1580
  };
2361
1581
  }
2362
- return errorResult(`Unknown memory ID format: ${memId}` + getReminders(threadId));
1582
+ return errorResult(`Unknown memory ID format: ${memId}` + getShortReminder(threadId));
2363
1583
  }
2364
1584
  catch (err) {
2365
- return errorResult(`Memory update error: ${errorMessage(err)}` + getReminders(threadId));
1585
+ return errorResult(`Memory update error: ${errorMessage(err)}` + getShortReminder(threadId));
2366
1586
  }
2367
1587
  }
2368
1588
  // ── memory_consolidate ──────────────────────────────────────────────────
@@ -2370,14 +1590,15 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2370
1590
  const typedArgs = (args ?? {});
2371
1591
  const threadId = resolveThreadId(typedArgs);
2372
1592
  if (threadId === undefined) {
2373
- return errorResult("Error: No active thread." + getReminders());
1593
+ return errorResult("Error: No active thread." + getShortReminder());
2374
1594
  }
2375
1595
  try {
2376
1596
  const db = getMemoryDb();
2377
1597
  const report = await runIntelligentConsolidation(db, threadId);
1598
+ lastConsolidationAt = Date.now(); // Prevent redundant auto-consolidation
2378
1599
  if (report.episodesProcessed === 0) {
2379
1600
  return {
2380
- content: [{ type: "text", text: "No unconsolidated episodes. Memory is up to date." + getReminders(threadId) }],
1601
+ content: [{ type: "text", text: "No unconsolidated episodes. Memory is up to date." + getShortReminder(threadId) }],
2381
1602
  };
2382
1603
  }
2383
1604
  const reportLines = [
@@ -2392,10 +1613,10 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2392
1613
  reportLines.push(`- ${d}`);
2393
1614
  }
2394
1615
  }
2395
- return { content: [{ type: "text", text: reportLines.join("\n") + getReminders(threadId) }] };
1616
+ return { content: [{ type: "text", text: reportLines.join("\n") + getShortReminder(threadId) }] };
2396
1617
  }
2397
1618
  catch (err) {
2398
- return errorResult(`Consolidation error: ${errorMessage(err)}` + getReminders(threadId));
1619
+ return errorResult(`Consolidation error: ${errorMessage(err)}` + getShortReminder(threadId));
2399
1620
  }
2400
1621
  }
2401
1622
  // ── memory_status ───────────────────────────────────────────────────────
@@ -2403,7 +1624,7 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2403
1624
  const typedArgs = (args ?? {});
2404
1625
  const threadId = resolveThreadId(typedArgs);
2405
1626
  if (threadId === undefined) {
2406
- return errorResult("Error: No active thread." + getReminders());
1627
+ return errorResult("Error: No active thread." + getShortReminder());
2407
1628
  }
2408
1629
  try {
2409
1630
  const db = getMemoryDb();
@@ -2424,10 +1645,10 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2424
1645
  lines.push(`- ${t.topic} (${t.semanticCount} notes, ${t.proceduralCount} procs, conf: ${t.avgConfidence.toFixed(2)})`);
2425
1646
  }
2426
1647
  }
2427
- return { content: [{ type: "text", text: lines.join("\n") + getReminders(threadId) }] };
1648
+ return { content: [{ type: "text", text: lines.join("\n") + getShortReminder(threadId) }] };
2428
1649
  }
2429
1650
  catch (err) {
2430
- return errorResult(`Memory status error: ${errorMessage(err)}` + getReminders(threadId));
1651
+ return errorResult(`Memory status error: ${errorMessage(err)}` + getShortReminder(threadId));
2431
1652
  }
2432
1653
  }
2433
1654
  // ── memory_forget ───────────────────────────────────────────────────────
@@ -2441,16 +1662,44 @@ function createMcpServer(getMcpSessionId, closeTransport) {
2441
1662
  const result = forgetMemory(db, memId, reason);
2442
1663
  if (!result.deleted) {
2443
1664
  return {
2444
- content: [{ type: "text", text: `Memory ${memId} not found (layer: ${result.layer}). Nothing was deleted.` + getReminders(threadId) }],
1665
+ content: [{ type: "text", text: `Memory ${memId} not found (layer: ${result.layer}). Nothing was deleted.` + getShortReminder(threadId) }],
2445
1666
  };
2446
1667
  }
2447
1668
  return {
2448
- content: [{ type: "text", text: `Forgot ${result.layer} memory ${memId} (reason: ${reason})` + getReminders(threadId) }],
1669
+ content: [{ type: "text", text: `Forgot ${result.layer} memory ${memId} (reason: ${reason})` + getShortReminder(threadId) }],
2449
1670
  };
2450
1671
  }
2451
1672
  catch (err) {
2452
- return errorResult(`Memory forget error: ${errorMessage(err)}` + getReminders(threadId));
1673
+ return errorResult(`Memory forget error: ${errorMessage(err)}` + getShortReminder(threadId));
1674
+ }
1675
+ }
1676
+ // ── get_usage_stats ─────────────────────────────────────────────────────
1677
+ if (name === "get_usage_stats") {
1678
+ const typedArgs = (args ?? {});
1679
+ const threadId = resolveThreadId(typedArgs);
1680
+ const stats = rateLimiter.getStats();
1681
+ const lines = [
1682
+ `## API Usage Stats`,
1683
+ `Active sessions sharing resources: ${stats.activeSessions}`,
1684
+ `Total API calls (last hour): ${stats.totalCallsLastHour}`,
1685
+ ``,
1686
+ ];
1687
+ for (const svc of stats.services) {
1688
+ const bar = svc.usagePercent > 80 ? "🔴" : svc.usagePercent > 50 ? "🟡" : "🟢";
1689
+ lines.push(`### ${bar} ${svc.description} (${svc.service})`);
1690
+ lines.push(`- Window usage: ${svc.callsInWindow}/${svc.maxPerWindow} (${svc.usagePercent}%)`);
1691
+ lines.push(`- Burst tokens: ${svc.availableTokens}/${svc.burstCapacity}`);
1692
+ if (svc.sessionBreakdown.length > 0) {
1693
+ lines.push(`- Per-session:`);
1694
+ for (const s of svc.sessionBreakdown) {
1695
+ lines.push(` - Thread ${s.threadId ?? "?"}: ${s.calls} calls`);
1696
+ }
1697
+ }
1698
+ lines.push(``);
2453
1699
  }
1700
+ return {
1701
+ content: [{ type: "text", text: lines.join("\n") + getShortReminder(threadId) }],
1702
+ };
2454
1703
  }
2455
1704
  // Unknown tool
2456
1705
  return errorResult(`Unknown tool: ${name}`);
@@ -2594,6 +1843,7 @@ if (httpPort) {
2594
1843
  if (sid) {
2595
1844
  transports.delete(sid);
2596
1845
  sessionLastActivity.delete(sid);
1846
+ rateLimiter.removeSession(sid);
2597
1847
  }
2598
1848
  };
2599
1849
  // Create a fresh Server per HTTP session — a single Server can only
@@ -2662,6 +1912,7 @@ if (httpPort) {
2662
1912
  catch (_) { /* best-effort */ }
2663
1913
  transports.delete(sid);
2664
1914
  sessionLastActivity.delete(sid);
1915
+ rateLimiter.removeSession(sid);
2665
1916
  }
2666
1917
  }
2667
1918
  }, 10 * 60 * 1000);