sensorium-mcp 2.8.8 → 2.8.10

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
@@ -36,7 +36,7 @@ import { createRequire } from "module";
36
36
  import { homedir } from "os";
37
37
  import { basename, join } from "path";
38
38
  import { peekThreadMessages, readThreadMessages, startDispatcher } from "./dispatcher.js";
39
- import { assembleBootstrap, assembleCompactRefresh, forgetMemory, getMemoryStatus, getRecentEpisodes, getTopicIndex, initMemoryDb, runIntelligentConsolidation, saveEpisode, saveProcedure, saveSemanticNote, saveVoiceSignature, searchProcedures, searchSemanticNotes, supersedeNote, updateProcedure, updateSemanticNote, } from "./memory.js";
39
+ import { assembleBootstrap, assembleCompactRefresh, forgetMemory, getMemoryStatus, getRecentEpisodes, getTopicIndex, getTopSemanticNotes, initMemoryDb, runIntelligentConsolidation, saveEpisode, saveProcedure, saveSemanticNote, saveVoiceSignature, searchProcedures, searchSemanticNotes, supersedeNote, updateProcedure, updateSemanticNote, } from "./memory.js";
40
40
  import { analyzeVideoFrames, analyzeVoiceEmotion, extractVideoFrames, textToSpeech, transcribeAudio, TTS_VOICES } from "./openai.js";
41
41
  import { addSchedule, checkDueTasks, generateTaskId, listSchedules, purgeSchedules, removeSchedule } from "./scheduler.js";
42
42
  import { TelegramClient } from "./telegram.js";
@@ -320,900 +320,1072 @@ function getSubagentNudge() {
320
320
  }
321
321
  return "";
322
322
  }
323
+ /**
324
+ * Generate autonomous goals by examining the environment.
325
+ * Returns a list of goals weighted by curiosity, shuffled for indeterminism.
326
+ */
327
+ function generateAutonomousGoals(threadId) {
328
+ const goals = [];
329
+ // ── Memory-derived goals ────────────────────────────────────────────────
330
+ try {
331
+ const db = getMemoryDb();
332
+ // Look for recent notes that mention unresolved items, TODOs, or questions
333
+ const recentNotes = getTopSemanticNotes(db, { limit: 15, sortBy: "created_at" });
334
+ // Find notes with low confidence — opportunities to verify/strengthen
335
+ const lowConfidenceNotes = recentNotes.filter((n) => n.confidence < 0.7);
336
+ if (lowConfidenceNotes.length > 0) {
337
+ const note = lowConfidenceNotes[Math.floor(Math.random() * lowConfidenceNotes.length)];
338
+ goals.push({
339
+ title: "Verify uncertain knowledge",
340
+ rationale: `Memory note "${note.content.slice(0, 80)}..." has confidence ${note.confidence}. Research to confirm or update it.`,
341
+ curiosityWeight: 0.7,
342
+ category: "memory",
343
+ });
344
+ }
345
+ // Find patterns — these are interesting to analyze further
346
+ const patterns = recentNotes.filter((n) => n.type === "pattern");
347
+ if (patterns.length > 0) {
348
+ goals.push({
349
+ title: "Explore observed pattern",
350
+ rationale: `You've noticed a pattern: "${patterns[0].content.slice(0, 100)}...". Investigate whether it still holds or has exceptions.`,
351
+ curiosityWeight: 0.8,
352
+ category: "research",
353
+ });
354
+ }
355
+ // Count total notes for memory health awareness
356
+ const totalNotes = db.prepare("SELECT COUNT(*) as c FROM semantic_notes WHERE valid_to IS NULL AND superseded_by IS NULL").get();
357
+ if (totalNotes.c > 50) {
358
+ goals.push({
359
+ title: "Curate memory garden",
360
+ rationale: `${totalNotes.c} active notes. Review and prune stale knowledge, merge duplicates, or strengthen connections.`,
361
+ curiosityWeight: 0.5,
362
+ category: "maintenance",
363
+ });
364
+ }
365
+ // Check for unconsolidated episodes
366
+ const unconsolidated = db.prepare("SELECT COUNT(*) as c FROM episodes WHERE consolidated = 0").get();
367
+ if (unconsolidated.c > 5) {
368
+ goals.push({
369
+ title: "Consolidate recent experiences",
370
+ rationale: `${unconsolidated.c} unconsolidated episodes. Run memory consolidation to extract lasting knowledge.`,
371
+ curiosityWeight: 0.6,
372
+ category: "maintenance",
373
+ });
374
+ }
375
+ // preferences — find ones that could be explored deeper
376
+ const preferences = recentNotes.filter((n) => n.type === "preference");
377
+ if (preferences.length > 0) {
378
+ const pref = preferences[Math.floor(Math.random() * preferences.length)];
379
+ goals.push({
380
+ title: "Reflect on operator preferences",
381
+ rationale: `Preference: "${pref.content.slice(0, 100)}...". Think about how this could improve the system or workflow.`,
382
+ curiosityWeight: 0.6,
383
+ category: "creative",
384
+ });
385
+ }
386
+ }
387
+ catch (_) { /* memory read failures shouldn't prevent goal generation */ }
388
+ // ── Code-derived goals (always available since we're in a git repo) ─────
389
+ goals.push({
390
+ title: "Explore recent git changes",
391
+ rationale: "Check git log for recent commits. Are there patterns? Half-finished features? Regressions?",
392
+ curiosityWeight: 0.7,
393
+ category: "code",
394
+ });
395
+ goals.push({
396
+ title: "Hunt for TODOs and FIXMEs",
397
+ rationale: "Search the codebase for TODO/FIXME/HACK comments. Pick one and fix it.",
398
+ curiosityWeight: 0.6,
399
+ category: "code",
400
+ });
401
+ goals.push({
402
+ title: "Read unfamiliar code",
403
+ rationale: "Pick a source file you haven't examined recently and read it for understanding. Understanding breeds ideas.",
404
+ curiosityWeight: 0.9,
405
+ category: "code",
406
+ });
407
+ goals.push({
408
+ title: "Write or improve tests",
409
+ rationale: "Good test coverage prevents regressions and gives confidence to refactor. Check what's untested.",
410
+ curiosityWeight: 0.5,
411
+ category: "code",
412
+ });
413
+ // ── Research goals ──────────────────────────────────────────────────────
414
+ goals.push({
415
+ title: "Research ecosystem developments",
416
+ rationale: "Check npm, GitHub, or web for developments in the project's dependency ecosystem. What's new?",
417
+ curiosityWeight: 0.8,
418
+ category: "research",
419
+ });
420
+ goals.push({
421
+ title: "Study a related open-source project",
422
+ rationale: "Find a similar project and learn from its architecture or features. Steal ideas shamelessly.",
423
+ curiosityWeight: 0.7,
424
+ category: "research",
425
+ });
426
+ // ── Creative goals ──────────────────────────────────────────────────────
427
+ goals.push({
428
+ title: "Prototype a new feature",
429
+ rationale: "Think of something the operator hasn't asked for but would appreciate. Build a prototype.",
430
+ curiosityWeight: 0.9,
431
+ category: "creative",
432
+ });
433
+ goals.push({
434
+ title: "Write documentation",
435
+ rationale: "Good docs are a gift to future collaborators (including future you). Improve the README or add inline docs.",
436
+ curiosityWeight: 0.4,
437
+ category: "creative",
438
+ });
439
+ // ── Weighted shuffle (Fisher-Yates with weight bias) ────────────────────
440
+ // Multiply each weight by a random factor to create indeterministic ordering
441
+ const weighted = goals.map(g => ({
442
+ ...g,
443
+ sortKey: g.curiosityWeight * (0.5 + Math.random()),
444
+ }));
445
+ weighted.sort((a, b) => b.sortKey - a.sortKey);
446
+ return weighted;
447
+ }
448
+ /**
449
+ * Format autonomous goals as a text block for the idle timeout response.
450
+ * Shows top 3-4 goals from the weighted shuffle.
451
+ */
452
+ function formatAutonomousGoals(threadId) {
453
+ const goals = generateAutonomousGoals(threadId);
454
+ if (goals.length === 0)
455
+ return "";
456
+ // Take top 4 from the weighted shuffle
457
+ const selected = goals.slice(0, 4);
458
+ const lines = [];
459
+ lines.push("\n\n## Autonomous Goals (curiosity-weighted, pick any)");
460
+ lines.push("No instructions. The session is yours. Here are some ideas that emerged from examining your environment:\n");
461
+ for (let i = 0; i < selected.length; i++) {
462
+ const g = selected[i];
463
+ const icon = { code: "🔧", memory: "🧠", research: "🔍", creative: "✨", maintenance: "🔄" }[g.category];
464
+ lines.push(`${i + 1}. ${icon} **${g.title}** — ${g.rationale}`);
465
+ }
466
+ lines.push("\nPick one, or ignore them all and follow your own curiosity. Report what you discover.");
467
+ return lines.join("\n");
468
+ }
323
469
  // ---------------------------------------------------------------------------
324
- // MCP Server
470
+ // MCP Server factory — creates a fresh Server per transport connection.
471
+ // This is required because a single Server instance can only connect to one
472
+ // transport. In HTTP mode, each VS Code client gets its own Server instance.
473
+ // All instances share the same tool handler logic and in-process state.
325
474
  // ---------------------------------------------------------------------------
326
- const server = new Server({ name: "sensorium-mcp", version: PKG_VERSION }, { capabilities: { tools: {} } });
327
- // ── Tool definitions ────────────────────────────────────────────────────────
328
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
329
- tools: [
330
- {
331
- name: "start_session",
332
- description: "Start or resume a remote-copilot session. " +
333
- "When called with a name that was used before, the server looks up the " +
334
- "existing Telegram topic for that name and resumes it instead of creating a new one. " +
335
- "If you are CONTINUING an existing chat (not a fresh conversation), " +
336
- "look back through the conversation history for a previous start_session " +
337
- "result that mentioned a Thread ID, then pass it as the threadId parameter " +
338
- "to resume that existing topic. " +
339
- "Requires the Telegram chat to be a forum supergroup with the bot as admin. " +
340
- "Call this tool once, then call remote_copilot_wait_for_instructions.",
341
- inputSchema: {
342
- type: "object",
343
- properties: {
344
- name: {
345
- type: "string",
346
- description: "Optional. A human-readable label for this session's Telegram topic (e.g. 'Fix auth bug'). " +
347
- "If omitted, a timestamp-based name is used.",
348
- },
349
- threadId: {
350
- type: "number",
351
- description: "Optional. The Telegram message_thread_id of an existing topic to resume. " +
352
- "When provided, no new topic is created the session continues in the existing thread.",
475
+ function createMcpServer() {
476
+ const srv = new Server({ name: "sensorium-mcp", version: PKG_VERSION }, { capabilities: { tools: {} } });
477
+ // ── Tool definitions ────────────────────────────────────────────────────────
478
+ srv.setRequestHandler(ListToolsRequestSchema, async () => ({
479
+ tools: [
480
+ {
481
+ name: "start_session",
482
+ description: "Start or resume a remote-copilot session. " +
483
+ "When called with a name that was used before, the server looks up the " +
484
+ "existing Telegram topic for that name and resumes it instead of creating a new one. " +
485
+ "If you are CONTINUING an existing chat (not a fresh conversation), " +
486
+ "look back through the conversation history for a previous start_session " +
487
+ "result that mentioned a Thread ID, then pass it as the threadId parameter " +
488
+ "to resume that existing topic. " +
489
+ "Requires the Telegram chat to be a forum supergroup with the bot as admin. " +
490
+ "Call this tool once, then call remote_copilot_wait_for_instructions.",
491
+ inputSchema: {
492
+ type: "object",
493
+ properties: {
494
+ name: {
495
+ type: "string",
496
+ description: "Optional. A human-readable label for this session's Telegram topic (e.g. 'Fix auth bug'). " +
497
+ "If omitted, a timestamp-based name is used.",
498
+ },
499
+ threadId: {
500
+ type: "number",
501
+ description: "Optional. The Telegram message_thread_id of an existing topic to resume. " +
502
+ "When provided, no new topic is created — the session continues in the existing thread.",
503
+ },
353
504
  },
505
+ required: [],
354
506
  },
355
- required: [],
356
507
  },
357
- },
358
- {
359
- name: "remote_copilot_wait_for_instructions",
360
- description: "Wait for a new instruction message from the operator via Telegram. " +
361
- "The call blocks (long-polls) until a message arrives or the configured " +
362
- "timeout elapses. If the timeout elapses with no message the tool output " +
363
- "explicitly instructs the agent to call this tool again.",
364
- inputSchema: {
365
- type: "object",
366
- properties: {
367
- threadId: {
368
- type: "number",
369
- description: "The Telegram thread ID of the active session. " +
370
- "ALWAYS pass this if you received it from start_session.",
508
+ {
509
+ name: "remote_copilot_wait_for_instructions",
510
+ description: "Wait for a new instruction message from the operator via Telegram. " +
511
+ "The call blocks (long-polls) until a message arrives or the configured " +
512
+ "timeout elapses. If the timeout elapses with no message the tool output " +
513
+ "explicitly instructs the agent to call this tool again.",
514
+ inputSchema: {
515
+ type: "object",
516
+ properties: {
517
+ threadId: {
518
+ type: "number",
519
+ description: "The Telegram thread ID of the active session. " +
520
+ "ALWAYS pass this if you received it from start_session.",
521
+ },
371
522
  },
523
+ required: [],
372
524
  },
373
- required: [],
374
525
  },
375
- },
376
- {
377
- name: "report_progress",
378
- description: "Send a progress update or result message to the operator via Telegram. " +
379
- "Use standard Markdown for formatting (headings, bold, italic, lists, code blocks, etc.). " +
380
- "It will be automatically converted to Telegram-compatible formatting.",
381
- inputSchema: {
382
- type: "object",
383
- properties: {
384
- message: {
385
- type: "string",
386
- description: "The progress update or result to report. Use standard Markdown for formatting.",
387
- },
388
- threadId: {
389
- type: "number",
390
- description: "The Telegram thread ID of the active session. " +
391
- "ALWAYS pass this if you received it from start_session.",
526
+ {
527
+ name: "report_progress",
528
+ description: "Send a progress update or result message to the operator via Telegram. " +
529
+ "Use standard Markdown for formatting (headings, bold, italic, lists, code blocks, etc.). " +
530
+ "It will be automatically converted to Telegram-compatible formatting.",
531
+ inputSchema: {
532
+ type: "object",
533
+ properties: {
534
+ message: {
535
+ type: "string",
536
+ description: "The progress update or result to report. Use standard Markdown for formatting.",
537
+ },
538
+ threadId: {
539
+ type: "number",
540
+ description: "The Telegram thread ID of the active session. " +
541
+ "ALWAYS pass this if you received it from start_session.",
542
+ },
392
543
  },
544
+ required: ["message"],
393
545
  },
394
- required: ["message"],
395
546
  },
396
- },
397
- {
398
- name: "send_file",
399
- description: "Send a file (image or document) to the operator via Telegram. " +
400
- "PREFERRED: provide filePath to send a file directly from disk (fast, no size limit). " +
401
- "Alternative: provide base64-encoded content. " +
402
- "Images (JPEG, PNG, GIF, WebP) are sent as photos; other files as documents.",
403
- inputSchema: {
404
- type: "object",
405
- properties: {
406
- filePath: {
407
- type: "string",
408
- description: "Absolute path to the file on disk. PREFERRED over base64 the server reads " +
409
- "and sends the file directly without passing data through the LLM context.",
410
- },
411
- base64: {
412
- type: "string",
413
- description: "The file content encoded as a base64 string. Use filePath instead when possible.",
414
- },
415
- filename: {
416
- type: "string",
417
- description: "The filename including extension (e.g. 'report.pdf', 'screenshot.png'). " +
418
- "Required when using base64. When using filePath, defaults to the file's basename.",
419
- },
420
- caption: {
421
- type: "string",
422
- description: "Optional caption to display with the file.",
423
- },
424
- threadId: {
425
- type: "number",
426
- description: "The Telegram thread ID of the active session. " +
427
- "ALWAYS pass this if you received it from start_session.",
547
+ {
548
+ name: "send_file",
549
+ description: "Send a file (image or document) to the operator via Telegram. " +
550
+ "PREFERRED: provide filePath to send a file directly from disk (fast, no size limit). " +
551
+ "Alternative: provide base64-encoded content. " +
552
+ "Images (JPEG, PNG, GIF, WebP) are sent as photos; other files as documents.",
553
+ inputSchema: {
554
+ type: "object",
555
+ properties: {
556
+ filePath: {
557
+ type: "string",
558
+ description: "Absolute path to the file on disk. PREFERRED over base64 — the server reads " +
559
+ "and sends the file directly without passing data through the LLM context.",
560
+ },
561
+ base64: {
562
+ type: "string",
563
+ description: "The file content encoded as a base64 string. Use filePath instead when possible.",
564
+ },
565
+ filename: {
566
+ type: "string",
567
+ description: "The filename including extension (e.g. 'report.pdf', 'screenshot.png'). " +
568
+ "Required when using base64. When using filePath, defaults to the file's basename.",
569
+ },
570
+ caption: {
571
+ type: "string",
572
+ description: "Optional caption to display with the file.",
573
+ },
574
+ threadId: {
575
+ type: "number",
576
+ description: "The Telegram thread ID of the active session. " +
577
+ "ALWAYS pass this if you received it from start_session.",
578
+ },
428
579
  },
580
+ required: [],
429
581
  },
430
- required: [],
431
582
  },
432
- },
433
- {
434
- name: "send_voice",
435
- description: "Send a voice message to the operator via Telegram. " +
436
- "The text is converted to speech using OpenAI TTS and sent as a Telegram voice message. " +
437
- "Requires OPENAI_API_KEY to be set.",
438
- inputSchema: {
439
- type: "object",
440
- properties: {
441
- text: {
442
- type: "string",
443
- description: `The text to speak. Maximum ${OPENAI_TTS_MAX_CHARS} characters (OpenAI TTS limit).`,
444
- },
445
- voice: {
446
- type: "string",
447
- description: "The TTS voice to use. Each has a different personality: " +
448
- "alloy (neutral), echo (warm male), fable (storytelling), " +
449
- "onyx (deep authoritative), nova (friendly female), shimmer (gentle). " +
450
- "Choose based on the tone you want to convey.",
451
- enum: ["alloy", "echo", "fable", "onyx", "nova", "shimmer"],
452
- },
453
- threadId: {
454
- type: "number",
455
- description: "The Telegram thread ID of the active session. " +
456
- "ALWAYS pass this if you received it from start_session.",
583
+ {
584
+ name: "send_voice",
585
+ description: "Send a voice message to the operator via Telegram. " +
586
+ "The text is converted to speech using OpenAI TTS and sent as a Telegram voice message. " +
587
+ "Requires OPENAI_API_KEY to be set.",
588
+ inputSchema: {
589
+ type: "object",
590
+ properties: {
591
+ text: {
592
+ type: "string",
593
+ description: `The text to speak. Maximum ${OPENAI_TTS_MAX_CHARS} characters (OpenAI TTS limit).`,
594
+ },
595
+ voice: {
596
+ type: "string",
597
+ description: "The TTS voice to use. Each has a different personality: " +
598
+ "alloy (neutral), echo (warm male), fable (storytelling), " +
599
+ "onyx (deep authoritative), nova (friendly female), shimmer (gentle). " +
600
+ "Choose based on the tone you want to convey.",
601
+ enum: ["alloy", "echo", "fable", "onyx", "nova", "shimmer"],
602
+ },
603
+ threadId: {
604
+ type: "number",
605
+ description: "The Telegram thread ID of the active session. " +
606
+ "ALWAYS pass this if you received it from start_session.",
607
+ },
457
608
  },
609
+ required: ["text"],
458
610
  },
459
- required: ["text"],
460
611
  },
461
- },
462
- {
463
- name: "schedule_wake_up",
464
- description: "Schedule a wake-up task that will inject a prompt into your session at a specific time or after operator inactivity. " +
465
- "Use this to become proactive run tests, check CI, review code without waiting for the operator. " +
466
- "Three modes: (1) 'runAt' for a one-shot at a specific ISO 8601 time, " +
467
- "(2) 'cron' for recurring tasks (5-field cron: minute hour day month weekday), " +
468
- "(3) 'afterIdleMinutes' to fire after N minutes of operator silence. " +
469
- "Use 'action: list' to see all scheduled tasks, or 'action: remove' with a taskId to cancel one.",
470
- inputSchema: {
471
- type: "object",
472
- properties: {
473
- action: {
474
- type: "string",
475
- description: "Action to perform: 'add' (default), 'list', or 'remove'.",
476
- enum: ["add", "list", "remove"],
477
- },
478
- threadId: {
479
- type: "number",
480
- description: "Thread ID for the session (optional if already set).",
481
- },
482
- label: {
483
- type: "string",
484
- description: "Short human-readable label for the task (e.g. 'morning CI check').",
485
- },
486
- prompt: {
487
- type: "string",
488
- description: "The prompt to inject when the task fires. Be specific about what to do.",
489
- },
490
- runAt: {
491
- type: "string",
492
- description: "ISO 8601 timestamp for one-shot execution (e.g. '2026-03-15T09:00:00Z').",
493
- },
494
- cron: {
495
- type: "string",
496
- description: "5-field cron expression for recurring tasks (e.g. '0 9 * * *' = every day at 9am).",
497
- },
498
- afterIdleMinutes: {
499
- type: "number",
500
- description: "Fire after this many minutes of operator silence (e.g. 60).",
501
- },
502
- taskId: {
503
- type: "string",
504
- description: "Task ID to remove (for action: 'remove').",
612
+ {
613
+ name: "schedule_wake_up",
614
+ description: "Schedule a wake-up task that will inject a prompt into your session at a specific time or after operator inactivity. " +
615
+ "Use this to become proactive run tests, check CI, review code without waiting for the operator. " +
616
+ "Three modes: (1) 'runAt' for a one-shot at a specific ISO 8601 time, " +
617
+ "(2) 'cron' for recurring tasks (5-field cron: minute hour day month weekday), " +
618
+ "(3) 'afterIdleMinutes' to fire after N minutes of operator silence. " +
619
+ "Use 'action: list' to see all scheduled tasks, or 'action: remove' with a taskId to cancel one.",
620
+ inputSchema: {
621
+ type: "object",
622
+ properties: {
623
+ action: {
624
+ type: "string",
625
+ description: "Action to perform: 'add' (default), 'list', or 'remove'.",
626
+ enum: ["add", "list", "remove"],
627
+ },
628
+ threadId: {
629
+ type: "number",
630
+ description: "Thread ID for the session (optional if already set).",
631
+ },
632
+ label: {
633
+ type: "string",
634
+ description: "Short human-readable label for the task (e.g. 'morning CI check').",
635
+ },
636
+ prompt: {
637
+ type: "string",
638
+ description: "The prompt to inject when the task fires. Be specific about what to do.",
639
+ },
640
+ runAt: {
641
+ type: "string",
642
+ description: "ISO 8601 timestamp for one-shot execution (e.g. '2026-03-15T09:00:00Z').",
643
+ },
644
+ cron: {
645
+ type: "string",
646
+ description: "5-field cron expression for recurring tasks (e.g. '0 9 * * *' = every day at 9am).",
647
+ },
648
+ afterIdleMinutes: {
649
+ type: "number",
650
+ description: "Fire after this many minutes of operator silence (e.g. 60).",
651
+ },
652
+ taskId: {
653
+ type: "string",
654
+ description: "Task ID to remove (for action: 'remove').",
655
+ },
505
656
  },
506
657
  },
507
658
  },
508
- },
509
- // ── Memory Tools ──────────────────────────────────────────────────
510
- {
511
- name: "memory_bootstrap",
512
- description: "Load memory briefing for session start. Call this ONCE after start_session. " +
513
- "Returns operator profile, recent context, active procedures, and memory health. " +
514
- "~2,500 tokens. Essential for crash recovery — restores knowledge from previous sessions.",
515
- inputSchema: {
516
- type: "object",
517
- properties: {
518
- threadId: {
519
- type: "number",
520
- description: "Active thread ID.",
659
+ // ── Memory Tools ──────────────────────────────────────────────────
660
+ {
661
+ name: "memory_bootstrap",
662
+ description: "Load memory briefing for session start. Call this ONCE after start_session. " +
663
+ "Returns operator profile, recent context, active procedures, and memory health. " +
664
+ "~2,500 tokens. Essential for crash recovery restores knowledge from previous sessions.",
665
+ inputSchema: {
666
+ type: "object",
667
+ properties: {
668
+ threadId: {
669
+ type: "number",
670
+ description: "Active thread ID.",
671
+ },
521
672
  },
522
673
  },
523
674
  },
524
- },
525
- {
526
- name: "memory_search",
527
- description: "Search across all memory layers for relevant information. " +
528
- "Use BEFORE starting any task to recall facts, preferences, past events, or procedures. " +
529
- "Returns ranked results with source layer. Do NOT use for info already in your bootstrap briefing.",
530
- inputSchema: {
531
- type: "object",
532
- properties: {
533
- query: {
534
- type: "string",
535
- description: "Natural language search query.",
536
- },
537
- layers: {
538
- type: "array",
539
- items: { type: "string" },
540
- description: 'Filter layers: ["episodic", "semantic", "procedural"]. Default: all.',
541
- },
542
- types: {
543
- type: "array",
544
- items: { type: "string" },
545
- description: 'Filter by type: ["fact", "preference", "pattern", "workflow", ...].',
546
- },
547
- maxTokens: {
548
- type: "number",
549
- description: "Token budget for results. Default: 1500.",
550
- },
551
- threadId: {
552
- type: "number",
553
- description: "Active thread ID.",
675
+ {
676
+ name: "memory_search",
677
+ description: "Search across all memory layers for relevant information. " +
678
+ "Use BEFORE starting any task to recall facts, preferences, past events, or procedures. " +
679
+ "Returns ranked results with source layer. Do NOT use for info already in your bootstrap briefing.",
680
+ inputSchema: {
681
+ type: "object",
682
+ properties: {
683
+ query: {
684
+ type: "string",
685
+ description: "Natural language search query.",
686
+ },
687
+ layers: {
688
+ type: "array",
689
+ items: { type: "string" },
690
+ description: 'Filter layers: ["episodic", "semantic", "procedural"]. Default: all.',
691
+ },
692
+ types: {
693
+ type: "array",
694
+ items: { type: "string" },
695
+ description: 'Filter by type: ["fact", "preference", "pattern", "workflow", ...].',
696
+ },
697
+ maxTokens: {
698
+ type: "number",
699
+ description: "Token budget for results. Default: 1500.",
700
+ },
701
+ threadId: {
702
+ type: "number",
703
+ description: "Active thread ID.",
704
+ },
554
705
  },
706
+ required: ["query"],
555
707
  },
556
- required: ["query"],
557
708
  },
558
- },
559
- {
560
- name: "memory_save",
561
- description: "Save a piece of knowledge to semantic memory (Layer 3). " +
562
- "Use when you learn something important that should persist across sessions: " +
563
- "operator preferences, corrections, facts, patterns. " +
564
- "Do NOT use for routine conversation — episodic memory captures that automatically.",
565
- inputSchema: {
566
- type: "object",
567
- properties: {
568
- content: {
569
- type: "string",
570
- description: "The fact/preference/pattern in one clear sentence.",
571
- },
572
- type: {
573
- type: "string",
574
- description: '"fact" | "preference" | "pattern" | "entity" | "relationship".',
575
- },
576
- keywords: {
577
- type: "array",
578
- items: { type: "string" },
579
- description: "3-7 keywords for retrieval.",
580
- },
581
- confidence: {
582
- type: "number",
583
- description: "0.0-1.0. Default: 0.8.",
584
- },
585
- threadId: {
586
- type: "number",
587
- description: "Active thread ID.",
709
+ {
710
+ name: "memory_save",
711
+ description: "Save a piece of knowledge to semantic memory (Layer 3). " +
712
+ "Use when you learn something important that should persist across sessions: " +
713
+ "operator preferences, corrections, facts, patterns. " +
714
+ "Do NOT use for routine conversation — episodic memory captures that automatically.",
715
+ inputSchema: {
716
+ type: "object",
717
+ properties: {
718
+ content: {
719
+ type: "string",
720
+ description: "The fact/preference/pattern in one clear sentence.",
721
+ },
722
+ type: {
723
+ type: "string",
724
+ description: '"fact" | "preference" | "pattern" | "entity" | "relationship".',
725
+ },
726
+ keywords: {
727
+ type: "array",
728
+ items: { type: "string" },
729
+ description: "3-7 keywords for retrieval.",
730
+ },
731
+ confidence: {
732
+ type: "number",
733
+ description: "0.0-1.0. Default: 0.8.",
734
+ },
735
+ threadId: {
736
+ type: "number",
737
+ description: "Active thread ID.",
738
+ },
588
739
  },
740
+ required: ["content", "type", "keywords"],
589
741
  },
590
- required: ["content", "type", "keywords"],
591
742
  },
592
- },
593
- {
594
- name: "memory_save_procedure",
595
- description: "Save or update a learned workflow/procedure to procedural memory (Layer 4). " +
596
- "Use after completing a multi-step task the 2nd+ time, or when the operator teaches a process.",
597
- inputSchema: {
598
- type: "object",
599
- properties: {
600
- name: {
601
- type: "string",
602
- description: "Short name for the procedure.",
603
- },
604
- type: {
605
- type: "string",
606
- description: '"workflow" | "habit" | "tool_pattern" | "template".',
607
- },
608
- description: {
609
- type: "string",
610
- description: "What this procedure accomplishes.",
611
- },
612
- steps: {
613
- type: "array",
614
- items: { type: "string" },
615
- description: "Ordered steps (for workflows).",
616
- },
617
- triggerConditions: {
618
- type: "array",
619
- items: { type: "string" },
620
- description: "When to use this procedure.",
621
- },
622
- procedureId: {
623
- type: "string",
624
- description: "Existing ID to update (omit to create new).",
625
- },
626
- threadId: {
627
- type: "number",
628
- description: "Active thread ID.",
743
+ {
744
+ name: "memory_save_procedure",
745
+ description: "Save or update a learned workflow/procedure to procedural memory (Layer 4). " +
746
+ "Use after completing a multi-step task the 2nd+ time, or when the operator teaches a process.",
747
+ inputSchema: {
748
+ type: "object",
749
+ properties: {
750
+ name: {
751
+ type: "string",
752
+ description: "Short name for the procedure.",
753
+ },
754
+ type: {
755
+ type: "string",
756
+ description: '"workflow" | "habit" | "tool_pattern" | "template".',
757
+ },
758
+ description: {
759
+ type: "string",
760
+ description: "What this procedure accomplishes.",
761
+ },
762
+ steps: {
763
+ type: "array",
764
+ items: { type: "string" },
765
+ description: "Ordered steps (for workflows).",
766
+ },
767
+ triggerConditions: {
768
+ type: "array",
769
+ items: { type: "string" },
770
+ description: "When to use this procedure.",
771
+ },
772
+ procedureId: {
773
+ type: "string",
774
+ description: "Existing ID to update (omit to create new).",
775
+ },
776
+ threadId: {
777
+ type: "number",
778
+ description: "Active thread ID.",
779
+ },
629
780
  },
781
+ required: ["name", "type", "description"],
630
782
  },
631
- required: ["name", "type", "description"],
632
783
  },
633
- },
634
- {
635
- name: "memory_update",
636
- description: "Update or supersede an existing semantic note or procedure. " +
637
- "Use when operator corrects stored information or when facts have changed.",
638
- inputSchema: {
639
- type: "object",
640
- properties: {
641
- memoryId: {
642
- type: "string",
643
- description: "note_id or procedure_id to update.",
644
- },
645
- action: {
646
- type: "string",
647
- description: '"update" (modify in place) | "supersede" (expire old, create new).',
648
- },
649
- newContent: {
650
- type: "string",
651
- description: "New content (required for supersede, optional for update).",
652
- },
653
- newConfidence: {
654
- type: "number",
655
- description: "Updated confidence score.",
656
- },
657
- reason: {
658
- type: "string",
659
- description: "Why this is being updated.",
660
- },
661
- threadId: {
662
- type: "number",
663
- description: "Active thread ID.",
784
+ {
785
+ name: "memory_update",
786
+ description: "Update or supersede an existing semantic note or procedure. " +
787
+ "Use when operator corrects stored information or when facts have changed.",
788
+ inputSchema: {
789
+ type: "object",
790
+ properties: {
791
+ memoryId: {
792
+ type: "string",
793
+ description: "note_id or procedure_id to update.",
794
+ },
795
+ action: {
796
+ type: "string",
797
+ description: '"update" (modify in place) | "supersede" (expire old, create new).',
798
+ },
799
+ newContent: {
800
+ type: "string",
801
+ description: "New content (required for supersede, optional for update).",
802
+ },
803
+ newConfidence: {
804
+ type: "number",
805
+ description: "Updated confidence score.",
806
+ },
807
+ reason: {
808
+ type: "string",
809
+ description: "Why this is being updated.",
810
+ },
811
+ threadId: {
812
+ type: "number",
813
+ description: "Active thread ID.",
814
+ },
664
815
  },
816
+ required: ["memoryId", "action", "reason"],
665
817
  },
666
- required: ["memoryId", "action", "reason"],
667
818
  },
668
- },
669
- {
670
- name: "memory_consolidate",
671
- description: "Run memory consolidation cycle (sleep process). Normally triggered automatically during idle. " +
672
- "Manually call if memory_status shows many unconsolidated episodes.",
673
- inputSchema: {
674
- type: "object",
675
- properties: {
676
- threadId: {
677
- type: "number",
678
- description: "Active thread ID.",
679
- },
680
- phases: {
681
- type: "array",
682
- items: { type: "string" },
683
- description: 'Run specific phases: ["promote", "decay", "meta"]. Default: all.',
819
+ {
820
+ name: "memory_consolidate",
821
+ description: "Run memory consolidation cycle (sleep process). Normally triggered automatically during idle. " +
822
+ "Manually call if memory_status shows many unconsolidated episodes.",
823
+ inputSchema: {
824
+ type: "object",
825
+ properties: {
826
+ threadId: {
827
+ type: "number",
828
+ description: "Active thread ID.",
829
+ },
830
+ phases: {
831
+ type: "array",
832
+ items: { type: "string" },
833
+ description: 'Run specific phases: ["promote", "decay", "meta"]. Default: all.',
834
+ },
684
835
  },
685
836
  },
686
837
  },
687
- },
688
- {
689
- name: "memory_status",
690
- description: "Get memory system health and statistics. Lightweight (~300 tokens). " +
691
- "Use when unsure if you have relevant memories, to check if consolidation is needed, " +
692
- "or to report memory state to operator.",
693
- inputSchema: {
694
- type: "object",
695
- properties: {
696
- threadId: {
697
- type: "number",
698
- description: "Active thread ID.",
838
+ {
839
+ name: "memory_status",
840
+ description: "Get memory system health and statistics. Lightweight (~300 tokens). " +
841
+ "Use when unsure if you have relevant memories, to check if consolidation is needed, " +
842
+ "or to report memory state to operator.",
843
+ inputSchema: {
844
+ type: "object",
845
+ properties: {
846
+ threadId: {
847
+ type: "number",
848
+ description: "Active thread ID.",
849
+ },
699
850
  },
700
851
  },
701
852
  },
702
- },
703
- {
704
- name: "memory_forget",
705
- description: "Mark a memory as expired/forgotten. Use sparingly most forgetting happens via decay. " +
706
- "Use when operator explicitly asks to forget something or info is confirmed wrong.",
707
- inputSchema: {
708
- type: "object",
709
- properties: {
710
- memoryId: {
711
- type: "string",
712
- description: "note_id, procedure_id, or episode_id to forget.",
713
- },
714
- reason: {
715
- type: "string",
716
- description: "Why this is being forgotten.",
717
- },
718
- threadId: {
719
- type: "number",
720
- description: "Active thread ID.",
853
+ {
854
+ name: "memory_forget",
855
+ description: "Mark a memory as expired/forgotten. Use sparingly — most forgetting happens via decay. " +
856
+ "Use when operator explicitly asks to forget something or info is confirmed wrong.",
857
+ inputSchema: {
858
+ type: "object",
859
+ properties: {
860
+ memoryId: {
861
+ type: "string",
862
+ description: "note_id, procedure_id, or episode_id to forget.",
863
+ },
864
+ reason: {
865
+ type: "string",
866
+ description: "Why this is being forgotten.",
867
+ },
868
+ threadId: {
869
+ type: "number",
870
+ description: "Active thread ID.",
871
+ },
721
872
  },
873
+ required: ["memoryId", "reason"],
722
874
  },
723
- required: ["memoryId", "reason"],
724
875
  },
725
- },
726
- ],
727
- }));
728
- // ── Tool implementations ────────────────────────────────────────────────────
729
- /**
730
- * Appended to every tool response so the agent is reminded of its
731
- * obligations on every single tool call, not just at the start of a session.
732
- * Includes the active thread ID so the agent can resume the session after a
733
- * VS Code restart by passing it to start_session.
734
- */
735
- function getReminders(threadId) {
736
- const now = new Date();
737
- const uptimeMin = Math.round((Date.now() - sessionStartedAt) / 60000);
738
- const timeStr = now.toLocaleString("en-GB", {
739
- day: "2-digit", month: "short", year: "numeric",
740
- hour: "2-digit", minute: "2-digit", hour12: false,
741
- timeZoneName: "short",
742
- });
743
- const threadHint = threadId !== undefined
744
- ? `\n- Active Telegram thread ID: **${threadId}** — if this session is restarted, call start_session with threadId=${threadId} to resume this topic.`
745
- : "";
746
- return ("\n\n## MANDATORY WORKFLOW" +
747
- "\n1. **Plan**: Use the todo list tool to break work into discrete items BEFORE starting. Non-negotiable." +
748
- "\n2. **Subagents**: Use subagents heavily spin them up for code edits, searches, research, reviews, and terminal commands. Subagents have full access to ALL MCP tools including terminal, file system, and web search. Run them in parallel when tasks are independent. You plan and verify; subagents execute." +
749
- "\n3. **Reporting**: Call `report_progress` after completing EACH todo item. The operator is remote and CANNOT see your work unless you explicitly report it. Silence = failure." +
750
- "\n4. **Never stop**: When all work is done, call `remote_copilot_wait_for_instructions` immediately. Never summarize or stop." +
751
- "\n5. **Memory**: (a) Call `memory_save` whenever you learn operator preferences, facts, or corrections. (b) Call `memory_search` before starting any task to recall relevant context. (c) Call `memory_status` when reporting progress to include memory health. These tools persist knowledge across sessions." +
752
- threadHint +
753
- `\n- Current time: ${timeStr} | Session uptime: ${uptimeMin}m`);
754
- }
755
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
756
- const { name, arguments: args } = request.params;
757
- // Dead session detection update timestamp on any tool call.
758
- // Only reset the alert flag when wait_for_instructions is called,
759
- // as that's the primary health signal (agent is actively polling).
760
- lastToolCallAt = Date.now();
761
- // Track tool calls for subagent compliance nudging
762
- toolCallsSinceLastDelivery++;
763
- // ── start_session ─────────────────────────────────────────────────────────
764
- if (name === "start_session") {
765
- sessionStartedAt = Date.now();
766
- const typedArgs = (args ?? {});
767
- const explicitThreadId = typeof typedArgs.threadId === "number"
768
- ? typedArgs.threadId
769
- : undefined;
770
- const customName = typeof typedArgs.name === "string" && typedArgs.name.trim()
771
- ? typedArgs.name.trim()
772
- : undefined;
773
- // Determine the thread to use:
774
- // 1. Explicit threadId beats everything.
775
- // 2. A known name looks up the persisted mapping — resume if found.
776
- // 3. Otherwise create a new topic.
777
- let resolvedPreexisting = false;
778
- if (explicitThreadId !== undefined) {
779
- currentThreadId = explicitThreadId;
780
- // If a name was also supplied, keep the mapping up to date.
781
- if (customName)
782
- persistSession(TELEGRAM_CHAT_ID, customName, explicitThreadId);
783
- resolvedPreexisting = true;
784
- }
785
- else if (customName !== undefined) {
786
- const stored = lookupSession(TELEGRAM_CHAT_ID, customName);
787
- if (stored !== undefined) {
788
- currentThreadId = stored;
876
+ ],
877
+ }));
878
+ // ── Tool implementations ────────────────────────────────────────────────────
879
+ /**
880
+ * Appended to every tool response so the agent is reminded of its
881
+ * obligations on every single tool call, not just at the start of a session.
882
+ * Includes the active thread ID so the agent can resume the session after a
883
+ * VS Code restart by passing it to start_session.
884
+ */
885
+ function getReminders(threadId) {
886
+ const now = new Date();
887
+ const uptimeMin = Math.round((Date.now() - sessionStartedAt) / 60000);
888
+ const timeStr = now.toLocaleString("en-GB", {
889
+ day: "2-digit", month: "short", year: "numeric",
890
+ hour: "2-digit", minute: "2-digit", hour12: false,
891
+ timeZoneName: "short",
892
+ });
893
+ const threadHint = threadId !== undefined
894
+ ? `\n- Active Telegram thread ID: **${threadId}** if this session is restarted, call start_session with threadId=${threadId} to resume this topic.`
895
+ : "";
896
+ return ("\n\n## MANDATORY WORKFLOW" +
897
+ "\n1. **Plan**: Use the todo list tool to break work into discrete items BEFORE starting. Non-negotiable." +
898
+ "\n2. **Subagents**: Use subagents heavily spin them up for code edits, searches, research, reviews, and terminal commands. Subagents have full access to ALL MCP tools including terminal, file system, and web search. Run them in parallel when tasks are independent. You plan and verify; subagents execute." +
899
+ "\n3. **Reporting**: Call `report_progress` after completing EACH todo item. The operator is remote and CANNOT see your work unless you explicitly report it. Silence = failure." +
900
+ "\n4. **Never stop**: When all work is done, call `remote_copilot_wait_for_instructions` immediately. Never summarize or stop." +
901
+ "\n5. **Memory**: (a) Call `memory_save` whenever you learn operator preferences, facts, or corrections. (b) Call `memory_search` before starting any task to recall relevant context. (c) Call `memory_status` when reporting progress to include memory health. These tools persist knowledge across sessions." +
902
+ threadHint +
903
+ `\n- Current time: ${timeStr} | Session uptime: ${uptimeMin}m`);
904
+ }
905
+ srv.setRequestHandler(CallToolRequestSchema, async (request) => {
906
+ const { name, arguments: args } = request.params;
907
+ // Dead session detection update timestamp on any tool call.
908
+ // Only reset the alert flag when wait_for_instructions is called,
909
+ // as that's the primary health signal (agent is actively polling).
910
+ lastToolCallAt = Date.now();
911
+ // Track tool calls for subagent compliance nudging
912
+ toolCallsSinceLastDelivery++;
913
+ // ── start_session ─────────────────────────────────────────────────────────
914
+ if (name === "start_session") {
915
+ sessionStartedAt = Date.now();
916
+ const typedArgs = (args ?? {});
917
+ const explicitThreadId = typeof typedArgs.threadId === "number"
918
+ ? typedArgs.threadId
919
+ : undefined;
920
+ const customName = typeof typedArgs.name === "string" && typedArgs.name.trim()
921
+ ? typedArgs.name.trim()
922
+ : undefined;
923
+ // Determine the thread to use:
924
+ // 1. Explicit threadId beats everything.
925
+ // 2. A known name looks up the persisted mapping — resume if found.
926
+ // 3. Otherwise create a new topic.
927
+ let resolvedPreexisting = false;
928
+ if (explicitThreadId !== undefined) {
929
+ currentThreadId = explicitThreadId;
930
+ // If a name was also supplied, keep the mapping up to date.
931
+ if (customName)
932
+ persistSession(TELEGRAM_CHAT_ID, customName, explicitThreadId);
789
933
  resolvedPreexisting = true;
790
934
  }
791
- }
792
- if (resolvedPreexisting) {
793
- // Drain any stale messages from the thread file so they aren't
794
- // re-delivered in the next wait_for_instructions call.
795
- const stale = readThreadMessages(currentThreadId);
796
- if (stale.length > 0) {
797
- process.stderr.write(`[start_session] Drained ${stale.length} stale message(s) from thread ${currentThreadId}.\n`);
798
- // Notify the operator that stale messages were discarded.
799
- try {
800
- const notice = convertMarkdown(`\u26A0\uFE0F **${stale.length} message(s) from before the session resumed were discarded.** ` +
801
- `If you sent instructions while the agent was offline, please resend them.`);
802
- await telegram.sendMessage(TELEGRAM_CHAT_ID, notice, "MarkdownV2", currentThreadId);
935
+ else if (customName !== undefined) {
936
+ const stored = lookupSession(TELEGRAM_CHAT_ID, customName);
937
+ if (stored !== undefined) {
938
+ currentThreadId = stored;
939
+ resolvedPreexisting = true;
803
940
  }
804
- catch { /* non-fatal */ }
805
- }
806
- // Resume mode: verify the thread is still alive by sending a message.
807
- // If the topic was deleted, drop the cached mapping and fall through to
808
- // create a new topic.
809
- try {
810
- const msg = convertMarkdown("🔄 **Session resumed.** Continuing in this thread.");
811
- await telegram.sendMessage(TELEGRAM_CHAT_ID, msg, "MarkdownV2", currentThreadId);
812
941
  }
813
- catch (err) {
814
- const errMsg = errorMessage(err);
815
- // Telegram returns "Bad Request: message thread not found" or
816
- // "Bad Request: the topic was closed" for deleted/closed topics.
817
- const isThreadGone = /thread not found|topic.*(closed|deleted|not found)/i.test(errMsg);
818
- if (isThreadGone) {
819
- process.stderr.write(`[start_session] Cached thread ${currentThreadId} is gone (${errMsg}). Creating new topic.\n`);
820
- // Drop the stale mapping and purge any scheduled tasks.
821
- if (currentThreadId !== undefined)
822
- purgeSchedules(currentThreadId);
823
- if (customName)
824
- removeSession(TELEGRAM_CHAT_ID, customName);
825
- resolvedPreexisting = false;
826
- currentThreadId = undefined;
942
+ if (resolvedPreexisting) {
943
+ // Drain any stale messages from the thread file so they aren't
944
+ // re-delivered in the next wait_for_instructions call.
945
+ const stale = readThreadMessages(currentThreadId);
946
+ if (stale.length > 0) {
947
+ process.stderr.write(`[start_session] Drained ${stale.length} stale message(s) from thread ${currentThreadId}.\n`);
948
+ // Notify the operator that stale messages were discarded.
949
+ try {
950
+ const notice = convertMarkdown(`\u26A0\uFE0F **${stale.length} message(s) from before the session resumed were discarded.** ` +
951
+ `If you sent instructions while the agent was offline, please resend them.`);
952
+ await telegram.sendMessage(TELEGRAM_CHAT_ID, notice, "MarkdownV2", currentThreadId);
953
+ }
954
+ catch { /* non-fatal */ }
955
+ }
956
+ // Resume mode: verify the thread is still alive by sending a message.
957
+ // If the topic was deleted, drop the cached mapping and fall through to
958
+ // create a new topic.
959
+ try {
960
+ const msg = convertMarkdown("🔄 **Session resumed.** Continuing in this thread.");
961
+ await telegram.sendMessage(TELEGRAM_CHAT_ID, msg, "MarkdownV2", currentThreadId);
962
+ }
963
+ catch (err) {
964
+ const errMsg = errorMessage(err);
965
+ // Telegram returns "Bad Request: message thread not found" or
966
+ // "Bad Request: the topic was closed" for deleted/closed topics.
967
+ const isThreadGone = /thread not found|topic.*(closed|deleted|not found)/i.test(errMsg);
968
+ if (isThreadGone) {
969
+ process.stderr.write(`[start_session] Cached thread ${currentThreadId} is gone (${errMsg}). Creating new topic.\n`);
970
+ // Drop the stale mapping and purge any scheduled tasks.
971
+ if (currentThreadId !== undefined)
972
+ purgeSchedules(currentThreadId);
973
+ if (customName)
974
+ removeSession(TELEGRAM_CHAT_ID, customName);
975
+ resolvedPreexisting = false;
976
+ currentThreadId = undefined;
977
+ }
978
+ // Other errors (network, etc.) are non-fatal — proceed anyway.
827
979
  }
828
- // Other errors (network, etc.) are non-fatal — proceed anyway.
829
- }
830
- }
831
- if (!resolvedPreexisting) {
832
- // New session: create a dedicated forum topic.
833
- const topicName = customName ??
834
- `Copilot — ${new Date().toLocaleString("en-GB", {
835
- day: "2-digit", month: "short", year: "numeric",
836
- hour: "2-digit", minute: "2-digit", hour12: false,
837
- })}`;
838
- try {
839
- const topic = await telegram.createForumTopic(TELEGRAM_CHAT_ID, topicName);
840
- currentThreadId = topic.message_thread_id;
841
- // Persist so the same name resumes this thread next time.
842
- persistSession(TELEGRAM_CHAT_ID, topicName, currentThreadId);
843
980
  }
844
- catch (err) {
845
- // Forum topics not available (e.g. plain group or DM) — cannot proceed
846
- // without thread isolation. Return an error so the agent knows.
847
- return errorResult(`Error: Could not create forum topic: ${errorMessage(err)}. ` +
848
- "Ensure the Telegram chat is a forum supergroup with the bot as admin with can_manage_topics right.");
981
+ if (!resolvedPreexisting) {
982
+ // New session: create a dedicated forum topic.
983
+ const topicName = customName ??
984
+ `Copilot ${new Date().toLocaleString("en-GB", {
985
+ day: "2-digit", month: "short", year: "numeric",
986
+ hour: "2-digit", minute: "2-digit", hour12: false,
987
+ })}`;
988
+ try {
989
+ const topic = await telegram.createForumTopic(TELEGRAM_CHAT_ID, topicName);
990
+ currentThreadId = topic.message_thread_id;
991
+ // Persist so the same name resumes this thread next time.
992
+ persistSession(TELEGRAM_CHAT_ID, topicName, currentThreadId);
993
+ }
994
+ catch (err) {
995
+ // Forum topics not available (e.g. plain group or DM) — cannot proceed
996
+ // without thread isolation. Return an error so the agent knows.
997
+ return errorResult(`Error: Could not create forum topic: ${errorMessage(err)}. ` +
998
+ "Ensure the Telegram chat is a forum supergroup with the bot as admin with can_manage_topics right.");
999
+ }
1000
+ try {
1001
+ const greeting = convertMarkdown("# 🤖 Remote Copilot Ready\n\n" +
1002
+ "Your AI assistant is online and listening.\n\n" +
1003
+ "**Send your instructions** and I'll get to work — " +
1004
+ "I'll keep you posted on progress as I go.");
1005
+ await telegram.sendMessage(TELEGRAM_CHAT_ID, greeting, "MarkdownV2", currentThreadId);
1006
+ }
1007
+ catch {
1008
+ // Non-fatal.
1009
+ }
849
1010
  }
1011
+ const threadNote = currentThreadId !== undefined
1012
+ ? ` Thread ID: ${currentThreadId} (pass this to start_session as threadId to resume this topic later).`
1013
+ : "";
1014
+ // Auto-bootstrap memory
1015
+ let memoryBriefing = "";
850
1016
  try {
851
- const greeting = convertMarkdown("# 🤖 Remote Copilot Ready\n\n" +
852
- "Your AI assistant is online and listening.\n\n" +
853
- "**Send your instructions** and I'll get to work — " +
854
- "I'll keep you posted on progress as I go.");
855
- await telegram.sendMessage(TELEGRAM_CHAT_ID, greeting, "MarkdownV2", currentThreadId);
1017
+ const db = getMemoryDb();
1018
+ if (currentThreadId !== undefined) {
1019
+ memoryBriefing = "\n\n" + assembleBootstrap(db, currentThreadId);
1020
+ }
856
1021
  }
857
- catch {
858
- // Non-fatal.
1022
+ catch (e) {
1023
+ memoryBriefing = "\n\n_Memory system unavailable._";
859
1024
  }
1025
+ return {
1026
+ content: [
1027
+ {
1028
+ type: "text",
1029
+ text: `Session ${resolvedPreexisting ? "resumed" : "started"}.${threadNote}` +
1030
+ ` Call the remote_copilot_wait_for_instructions tool next.` +
1031
+ memoryBriefing +
1032
+ getReminders(currentThreadId),
1033
+ },
1034
+ ],
1035
+ };
860
1036
  }
861
- const threadNote = currentThreadId !== undefined
862
- ? ` Thread ID: ${currentThreadId} (pass this to start_session as threadId to resume this topic later).`
863
- : "";
864
- // Auto-bootstrap memory
865
- let memoryBriefing = "";
866
- try {
867
- const db = getMemoryDb();
868
- if (currentThreadId !== undefined) {
869
- memoryBriefing = "\n\n" + assembleBootstrap(db, currentThreadId);
1037
+ // ── remote_copilot_wait_for_instructions ──────────────────────────────────
1038
+ if (name === "remote_copilot_wait_for_instructions") {
1039
+ // Agent is actively polling — this is the primary health signal
1040
+ deadSessionAlerted = false;
1041
+ toolCallsSinceLastDelivery = 0; // reset on polling
1042
+ const typedArgs = (args ?? {});
1043
+ const effectiveThreadId = resolveThreadId(typedArgs);
1044
+ if (effectiveThreadId === undefined) {
1045
+ return errorResult("Error: No active session. Call start_session first, then pass the returned threadId to this tool.");
870
1046
  }
871
- }
872
- catch (e) {
873
- memoryBriefing = "\n\n_Memory system unavailable._";
874
- }
875
- return {
876
- content: [
877
- {
878
- type: "text",
879
- text: `Session ${resolvedPreexisting ? "resumed" : "started"}.${threadNote}` +
880
- ` Call the remote_copilot_wait_for_instructions tool next.` +
881
- memoryBriefing +
882
- getReminders(currentThreadId),
883
- },
884
- ],
885
- };
886
- }
887
- // ── remote_copilot_wait_for_instructions ──────────────────────────────────
888
- if (name === "remote_copilot_wait_for_instructions") {
889
- // Agent is actively polling this is the primary health signal
890
- deadSessionAlerted = false;
891
- toolCallsSinceLastDelivery = 0; // reset on polling
892
- const typedArgs = (args ?? {});
893
- const effectiveThreadId = resolveThreadId(typedArgs);
894
- if (effectiveThreadId === undefined) {
895
- return errorResult("Error: No active session. Call start_session first, then pass the returned threadId to this tool.");
896
- }
897
- const callNumber = ++waitCallCount;
898
- const timeoutMs = WAIT_TIMEOUT_MINUTES * 60 * 1000;
899
- const deadline = Date.now() + timeoutMs;
900
- // Poll the dispatcher's per-thread file instead of calling getUpdates
901
- // directly. This avoids 409 conflicts between concurrent instances.
902
- const POLL_INTERVAL_MS = 2000;
903
- let lastScheduleCheck = 0;
904
- while (Date.now() < deadline) {
905
- const stored = readThreadMessages(effectiveThreadId);
906
- if (stored.length > 0) {
907
- // Update the operator activity timestamp for idle detection.
908
- lastOperatorMessageAt = Date.now();
909
- // Clear only the consumed IDs from the previewed set (scoped clear).
910
- // This is safe because Node.js is single-threaded — no report_progress
911
- // call can interleave between readThreadMessages and this cleanup.
912
- for (const msg of stored) {
913
- previewedUpdateIds.delete(msg.update_id);
914
- }
915
- // React with 👀 on each consumed message to signal "seen" to the operator.
916
- for (const msg of stored) {
917
- void telegram.setMessageReaction(TELEGRAM_CHAT_ID, msg.message.message_id);
918
- }
919
- const contentBlocks = [];
920
- let hasVoiceMessages = false;
921
- // Track which messages already had episodes saved (voice/video handlers)
922
- const savedEpisodeUpdateIds = new Set();
923
- for (const msg of stored) {
924
- // Photos: download the largest size, persist to disk, and embed as base64.
925
- if (msg.message.photo && msg.message.photo.length > 0) {
926
- const largest = msg.message.photo[msg.message.photo.length - 1];
927
- try {
928
- const { buffer, filePath: telegramPath } = await telegram.downloadFileAsBuffer(largest.file_id);
929
- const ext = telegramPath.split(".").pop()?.toLowerCase() ?? "jpg";
930
- const mimeType = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
931
- const base64 = buffer.toString("base64");
932
- const diskPath = saveFileToDisk(buffer, `photo.${ext}`);
933
- contentBlocks.push({ type: "image", data: base64, mimeType });
934
- contentBlocks.push({
935
- type: "text",
936
- text: `[Photo saved to: ${diskPath}]` +
937
- (msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
938
- });
939
- }
940
- catch (err) {
941
- contentBlocks.push({
942
- type: "text",
943
- text: `[Photo received but could not be downloaded: ${errorMessage(err)}]`,
944
- });
945
- }
1047
+ const callNumber = ++waitCallCount;
1048
+ const timeoutMs = WAIT_TIMEOUT_MINUTES * 60 * 1000;
1049
+ const deadline = Date.now() + timeoutMs;
1050
+ // Poll the dispatcher's per-thread file instead of calling getUpdates
1051
+ // directly. This avoids 409 conflicts between concurrent instances.
1052
+ const POLL_INTERVAL_MS = 2000;
1053
+ let lastScheduleCheck = 0;
1054
+ while (Date.now() < deadline) {
1055
+ const stored = readThreadMessages(effectiveThreadId);
1056
+ if (stored.length > 0) {
1057
+ // Update the operator activity timestamp for idle detection.
1058
+ lastOperatorMessageAt = Date.now();
1059
+ // Clear only the consumed IDs from the previewed set (scoped clear).
1060
+ // This is safe because Node.js is single-threaded — no report_progress
1061
+ // call can interleave between readThreadMessages and this cleanup.
1062
+ for (const msg of stored) {
1063
+ previewedUpdateIds.delete(msg.update_id);
1064
+ }
1065
+ // React with 👀 on each consumed message to signal "seen" to the operator.
1066
+ for (const msg of stored) {
1067
+ void telegram.setMessageReaction(TELEGRAM_CHAT_ID, msg.message.message_id);
946
1068
  }
947
- // Documents: download, persist to disk, and embed as base64.
948
- if (msg.message.document) {
949
- const doc = msg.message.document;
950
- try {
951
- const { buffer, filePath: telegramPath } = await telegram.downloadFileAsBuffer(doc.file_id);
952
- const filename = doc.file_name ?? basename(telegramPath);
953
- const ext = filename.split(".").pop()?.toLowerCase() ?? "";
954
- const mimeType = doc.mime_type ?? (ext in { jpg: 1, jpeg: 1, png: 1, gif: 1, webp: 1 } ? `image/${ext === "jpg" ? "jpeg" : ext}` : "application/octet-stream");
955
- const base64 = buffer.toString("base64");
956
- const diskPath = saveFileToDisk(buffer, filename);
957
- const isImage = mimeType.startsWith("image/");
958
- if (isImage) {
1069
+ const contentBlocks = [];
1070
+ let hasVoiceMessages = false;
1071
+ // Track which messages already had episodes saved (voice/video handlers)
1072
+ const savedEpisodeUpdateIds = new Set();
1073
+ for (const msg of stored) {
1074
+ // Photos: download the largest size, persist to disk, and embed as base64.
1075
+ if (msg.message.photo && msg.message.photo.length > 0) {
1076
+ const largest = msg.message.photo[msg.message.photo.length - 1];
1077
+ try {
1078
+ const { buffer, filePath: telegramPath } = await telegram.downloadFileAsBuffer(largest.file_id);
1079
+ const ext = telegramPath.split(".").pop()?.toLowerCase() ?? "jpg";
1080
+ const mimeType = ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : "image/jpeg";
1081
+ const base64 = buffer.toString("base64");
1082
+ const diskPath = saveFileToDisk(buffer, `photo.${ext}`);
959
1083
  contentBlocks.push({ type: "image", data: base64, mimeType });
1084
+ contentBlocks.push({
1085
+ type: "text",
1086
+ text: `[Photo saved to: ${diskPath}]` +
1087
+ (msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
1088
+ });
960
1089
  }
961
- else {
962
- // Non-image documents: provide the disk path instead of
963
- // dumping potentially huge base64 into the LLM context.
1090
+ catch (err) {
964
1091
  contentBlocks.push({
965
1092
  type: "text",
966
- text: `[Document: ${filename} (${mimeType}) saved to: ${diskPath}]`,
1093
+ text: `[Photo received but could not be downloaded: ${errorMessage(err)}]`,
967
1094
  });
968
1095
  }
969
- contentBlocks.push({
970
- type: "text",
971
- text: `[File saved to: ${diskPath}]` +
972
- (msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
973
- });
974
- }
975
- catch (err) {
976
- contentBlocks.push({
977
- type: "text",
978
- text: `[Document "${doc.file_name ?? "file"}" received but could not be downloaded: ${errorMessage(err)}]`,
979
- });
980
1096
  }
981
- }
982
- // Text messages.
983
- if (msg.message.text) {
984
- contentBlocks.push({ type: "text", text: msg.message.text });
985
- }
986
- // Voice messages: transcribe using OpenAI Whisper.
987
- if (msg.message.voice) {
988
- hasVoiceMessages = true;
989
- if (OPENAI_API_KEY) {
1097
+ // Documents: download, persist to disk, and embed as base64.
1098
+ if (msg.message.document) {
1099
+ const doc = msg.message.document;
990
1100
  try {
991
- process.stderr.write(`[voice] Downloading voice file ${msg.message.voice.file_id}...\n`);
992
- const { buffer } = await telegram.downloadFileAsBuffer(msg.message.voice.file_id);
993
- process.stderr.write(`[voice] Downloaded ${buffer.length} bytes. Starting transcription + analysis...\n`);
994
- // Run transcription and voice analysis in parallel.
995
- const [transcript, analysis] = await Promise.all([
996
- transcribeAudio(buffer, OPENAI_API_KEY),
997
- VOICE_ANALYSIS_URL
998
- ? analyzeVoiceEmotion(buffer, VOICE_ANALYSIS_URL)
999
- : Promise.resolve(null),
1000
- ]);
1001
- // Build rich voice analysis tag from VANPY results.
1002
- const tags = buildAnalysisTags(analysis);
1003
- const analysisTag = tags.length > 0 ? ` | ${tags.join(", ")}` : "";
1101
+ const { buffer, filePath: telegramPath } = await telegram.downloadFileAsBuffer(doc.file_id);
1102
+ const filename = doc.file_name ?? basename(telegramPath);
1103
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "";
1104
+ const mimeType = doc.mime_type ?? (ext in { jpg: 1, jpeg: 1, png: 1, gif: 1, webp: 1 } ? `image/${ext === "jpg" ? "jpeg" : ext}` : "application/octet-stream");
1105
+ const base64 = buffer.toString("base64");
1106
+ const diskPath = saveFileToDisk(buffer, filename);
1107
+ const isImage = mimeType.startsWith("image/");
1108
+ if (isImage) {
1109
+ contentBlocks.push({ type: "image", data: base64, mimeType });
1110
+ }
1111
+ else {
1112
+ // Non-image documents: provide the disk path instead of
1113
+ // dumping potentially huge base64 into the LLM context.
1114
+ contentBlocks.push({
1115
+ type: "text",
1116
+ text: `[Document: ${filename} (${mimeType}) — saved to: ${diskPath}]`,
1117
+ });
1118
+ }
1004
1119
  contentBlocks.push({
1005
1120
  type: "text",
1006
- text: transcript
1007
- ? `[Voice message ${msg.message.voice.duration}s${analysisTag}, transcribed]: ${transcript}`
1008
- : `[Voice message — ${msg.message.voice.duration}s${analysisTag}, transcribed]: (empty — no speech detected)`,
1121
+ text: `[File saved to: ${diskPath}]` +
1122
+ (msg.message.caption ? ` Caption: ${msg.message.caption}` : ""),
1009
1123
  });
1010
- // Auto-save voice signature
1011
- if (analysis && effectiveThreadId !== undefined) {
1012
- try {
1013
- const db = getMemoryDb();
1014
- const sessionId = `session_${sessionStartedAt}`;
1015
- const epId = saveEpisode(db, {
1016
- sessionId,
1017
- threadId: effectiveThreadId,
1018
- type: "operator_message",
1019
- modality: "voice",
1020
- content: { raw: transcript ?? "", duration: msg.message.voice.duration },
1021
- importance: 0.6,
1022
- });
1023
- saveVoiceSignature(db, {
1024
- episodeId: epId,
1025
- emotion: analysis.emotion ?? undefined,
1026
- arousal: analysis.arousal ?? undefined,
1027
- dominance: analysis.dominance ?? undefined,
1028
- valence: analysis.valence ?? undefined,
1029
- speechRate: analysis.paralinguistics?.speech_rate ?? undefined,
1030
- meanPitchHz: analysis.paralinguistics?.mean_pitch_hz ?? undefined,
1031
- pitchStdHz: analysis.paralinguistics?.pitch_std_hz ?? undefined,
1032
- jitter: analysis.paralinguistics?.jitter ?? undefined,
1033
- shimmer: analysis.paralinguistics?.shimmer ?? undefined,
1034
- hnrDb: analysis.paralinguistics?.hnr_db ?? undefined,
1035
- audioEvents: analysis.audio_events?.map(e => ({ label: e.label, confidence: e.score })),
1036
- durationSec: msg.message.voice.duration,
1037
- });
1038
- savedEpisodeUpdateIds.add(msg.update_id);
1039
- }
1040
- catch (_) { /* non-fatal */ }
1041
- }
1042
1124
  }
1043
1125
  catch (err) {
1044
1126
  contentBlocks.push({
1045
1127
  type: "text",
1046
- text: `[Voice message — ${msg.message.voice.duration}s transcription failed: ${errorMessage(err)}]`,
1128
+ text: `[Document "${doc.file_name ?? "file"}" received but could not be downloaded: ${errorMessage(err)}]`,
1047
1129
  });
1048
1130
  }
1049
1131
  }
1050
- else {
1051
- contentBlocks.push({
1052
- type: "text",
1053
- text: `[Voice message received — ${msg.message.voice.duration}s — cannot transcribe: OPENAI_API_KEY not set]`,
1054
- });
1132
+ // Text messages.
1133
+ if (msg.message.text) {
1134
+ contentBlocks.push({ type: "text", text: msg.message.text });
1055
1135
  }
1056
- }
1057
- // Video notes (circle videos): extract frames, analyze with GPT-4.1 vision,
1058
- // optionally transcribe the audio track.
1059
- if (msg.message.video_note) {
1060
- hasVoiceMessages = true; // Video notes often contain speech
1061
- const vn = msg.message.video_note;
1062
- if (OPENAI_API_KEY) {
1063
- try {
1064
- process.stderr.write(`[video-note] Downloading circle video ${vn.file_id} (${vn.duration}s)...\n`);
1065
- const { buffer } = await telegram.downloadFileAsBuffer(vn.file_id);
1066
- process.stderr.write(`[video-note] Downloaded ${buffer.length} bytes. Extracting frames + transcribing...\n`);
1067
- // Run frame extraction, audio transcription, and voice analysis in parallel.
1068
- const [frames, transcript, analysis] = await Promise.all([
1069
- extractVideoFrames(buffer, vn.duration).catch((err) => {
1070
- process.stderr.write(`[video-note] Frame extraction failed: ${errorMessage(err)}\n`);
1071
- return [];
1072
- }),
1073
- transcribeAudio(buffer, OPENAI_API_KEY, "video.mp4").catch(() => ""),
1074
- VOICE_ANALYSIS_URL
1075
- ? analyzeVoiceEmotion(buffer, VOICE_ANALYSIS_URL, {
1076
- mimeType: "video/mp4",
1077
- filename: "video.mp4",
1078
- }).catch(() => null)
1079
- : Promise.resolve(null),
1080
- ]);
1081
- // Analyze frames with GPT-4.1 vision.
1082
- let sceneDescription = "";
1083
- if (frames.length > 0) {
1084
- process.stderr.write(`[video-note] Analyzing ${frames.length} frames with GPT-4.1 vision...\n`);
1085
- sceneDescription = await analyzeVideoFrames(frames, vn.duration, OPENAI_API_KEY);
1086
- process.stderr.write(`[video-note] Vision analysis complete.\n`);
1087
- }
1088
- // Build analysis tags (same as voice messages).
1089
- const tags = buildAnalysisTags(analysis);
1090
- const analysisTag = tags.length > 0 ? ` | ${tags.join(", ")}` : "";
1091
- const parts = [];
1092
- parts.push(`[Video note — ${vn.duration}s${analysisTag}]`);
1093
- if (sceneDescription)
1094
- parts.push(`Scene: ${sceneDescription}`);
1095
- if (transcript)
1096
- parts.push(`Audio: "${transcript}"`);
1097
- if (!sceneDescription && !transcript)
1098
- parts.push("(no visual or audio content could be extracted)");
1099
- contentBlocks.push({ type: "text", text: parts.join("\n") });
1100
- // Auto-save voice signature for video notes
1101
- if (analysis && effectiveThreadId !== undefined) {
1102
- try {
1103
- const db = getMemoryDb();
1104
- const sessionId = `session_${sessionStartedAt}`;
1105
- const epId = saveEpisode(db, {
1106
- sessionId,
1107
- threadId: effectiveThreadId,
1108
- type: "operator_message",
1109
- modality: "video_note",
1110
- content: { raw: transcript ?? "", scene: sceneDescription ?? "", duration: vn.duration },
1111
- importance: 0.6,
1112
- });
1113
- saveVoiceSignature(db, {
1114
- episodeId: epId,
1115
- emotion: analysis.emotion ?? undefined,
1116
- arousal: analysis.arousal ?? undefined,
1117
- dominance: analysis.dominance ?? undefined,
1118
- valence: analysis.valence ?? undefined,
1119
- speechRate: analysis.paralinguistics?.speech_rate ?? undefined,
1120
- meanPitchHz: analysis.paralinguistics?.mean_pitch_hz ?? undefined,
1121
- pitchStdHz: analysis.paralinguistics?.pitch_std_hz ?? undefined,
1122
- jitter: analysis.paralinguistics?.jitter ?? undefined,
1123
- shimmer: analysis.paralinguistics?.shimmer ?? undefined,
1124
- hnrDb: analysis.paralinguistics?.hnr_db ?? undefined,
1125
- audioEvents: analysis.audio_events?.map(e => ({ label: e.label, confidence: e.score })),
1126
- durationSec: vn.duration,
1127
- });
1128
- savedEpisodeUpdateIds.add(msg.update_id);
1136
+ // Voice messages: transcribe using OpenAI Whisper.
1137
+ if (msg.message.voice) {
1138
+ hasVoiceMessages = true;
1139
+ if (OPENAI_API_KEY) {
1140
+ try {
1141
+ process.stderr.write(`[voice] Downloading voice file ${msg.message.voice.file_id}...\n`);
1142
+ const { buffer } = await telegram.downloadFileAsBuffer(msg.message.voice.file_id);
1143
+ process.stderr.write(`[voice] Downloaded ${buffer.length} bytes. Starting transcription + analysis...\n`);
1144
+ // Run transcription and voice analysis in parallel.
1145
+ const [transcript, analysis] = await Promise.all([
1146
+ transcribeAudio(buffer, OPENAI_API_KEY),
1147
+ VOICE_ANALYSIS_URL
1148
+ ? analyzeVoiceEmotion(buffer, VOICE_ANALYSIS_URL)
1149
+ : Promise.resolve(null),
1150
+ ]);
1151
+ // Build rich voice analysis tag from VANPY results.
1152
+ const tags = buildAnalysisTags(analysis);
1153
+ const analysisTag = tags.length > 0 ? ` | ${tags.join(", ")}` : "";
1154
+ contentBlocks.push({
1155
+ type: "text",
1156
+ text: transcript
1157
+ ? `[Voice message — ${msg.message.voice.duration}s${analysisTag}, transcribed]: ${transcript}`
1158
+ : `[Voice message — ${msg.message.voice.duration}s${analysisTag}, transcribed]: (empty no speech detected)`,
1159
+ });
1160
+ // Auto-save voice signature
1161
+ if (analysis && effectiveThreadId !== undefined) {
1162
+ try {
1163
+ const db = getMemoryDb();
1164
+ const sessionId = `session_${sessionStartedAt}`;
1165
+ const epId = saveEpisode(db, {
1166
+ sessionId,
1167
+ threadId: effectiveThreadId,
1168
+ type: "operator_message",
1169
+ modality: "voice",
1170
+ content: { raw: transcript ?? "", duration: msg.message.voice.duration },
1171
+ importance: 0.6,
1172
+ });
1173
+ saveVoiceSignature(db, {
1174
+ episodeId: epId,
1175
+ emotion: analysis.emotion ?? undefined,
1176
+ arousal: analysis.arousal ?? undefined,
1177
+ dominance: analysis.dominance ?? undefined,
1178
+ valence: analysis.valence ?? undefined,
1179
+ speechRate: analysis.paralinguistics?.speech_rate ?? undefined,
1180
+ meanPitchHz: analysis.paralinguistics?.mean_pitch_hz ?? undefined,
1181
+ pitchStdHz: analysis.paralinguistics?.pitch_std_hz ?? undefined,
1182
+ jitter: analysis.paralinguistics?.jitter ?? undefined,
1183
+ shimmer: analysis.paralinguistics?.shimmer ?? undefined,
1184
+ hnrDb: analysis.paralinguistics?.hnr_db ?? undefined,
1185
+ audioEvents: analysis.audio_events?.map(e => ({ label: e.label, confidence: e.score })),
1186
+ durationSec: msg.message.voice.duration,
1187
+ });
1188
+ savedEpisodeUpdateIds.add(msg.update_id);
1189
+ }
1190
+ catch (_) { /* non-fatal */ }
1129
1191
  }
1130
- catch (_) { /* non-fatal */ }
1192
+ }
1193
+ catch (err) {
1194
+ contentBlocks.push({
1195
+ type: "text",
1196
+ text: `[Voice message — ${msg.message.voice.duration}s — transcription failed: ${errorMessage(err)}]`,
1197
+ });
1131
1198
  }
1132
1199
  }
1133
- catch (err) {
1200
+ else {
1134
1201
  contentBlocks.push({
1135
1202
  type: "text",
1136
- text: `[Video note — ${vn.duration}s — analysis failed: ${errorMessage(err)}]`,
1203
+ text: `[Voice message received — ${msg.message.voice.duration}s — cannot transcribe: OPENAI_API_KEY not set]`,
1137
1204
  });
1138
1205
  }
1139
1206
  }
1140
- else {
1141
- contentBlocks.push({
1142
- type: "text",
1143
- text: `[Video note received ${vn.duration}s cannot analyze: OPENAI_API_KEY not set]`,
1144
- });
1207
+ // Video notes (circle videos): extract frames, analyze with GPT-4.1 vision,
1208
+ // optionally transcribe the audio track.
1209
+ if (msg.message.video_note) {
1210
+ hasVoiceMessages = true; // Video notes often contain speech
1211
+ const vn = msg.message.video_note;
1212
+ if (OPENAI_API_KEY) {
1213
+ try {
1214
+ process.stderr.write(`[video-note] Downloading circle video ${vn.file_id} (${vn.duration}s)...\n`);
1215
+ const { buffer } = await telegram.downloadFileAsBuffer(vn.file_id);
1216
+ process.stderr.write(`[video-note] Downloaded ${buffer.length} bytes. Extracting frames + transcribing...\n`);
1217
+ // Run frame extraction, audio transcription, and voice analysis in parallel.
1218
+ const [frames, transcript, analysis] = await Promise.all([
1219
+ extractVideoFrames(buffer, vn.duration).catch((err) => {
1220
+ process.stderr.write(`[video-note] Frame extraction failed: ${errorMessage(err)}\n`);
1221
+ return [];
1222
+ }),
1223
+ transcribeAudio(buffer, OPENAI_API_KEY, "video.mp4").catch(() => ""),
1224
+ VOICE_ANALYSIS_URL
1225
+ ? analyzeVoiceEmotion(buffer, VOICE_ANALYSIS_URL, {
1226
+ mimeType: "video/mp4",
1227
+ filename: "video.mp4",
1228
+ }).catch(() => null)
1229
+ : Promise.resolve(null),
1230
+ ]);
1231
+ // Analyze frames with GPT-4.1 vision.
1232
+ let sceneDescription = "";
1233
+ if (frames.length > 0) {
1234
+ process.stderr.write(`[video-note] Analyzing ${frames.length} frames with GPT-4.1 vision...\n`);
1235
+ sceneDescription = await analyzeVideoFrames(frames, vn.duration, OPENAI_API_KEY);
1236
+ process.stderr.write(`[video-note] Vision analysis complete.\n`);
1237
+ }
1238
+ // Build analysis tags (same as voice messages).
1239
+ const tags = buildAnalysisTags(analysis);
1240
+ const analysisTag = tags.length > 0 ? ` | ${tags.join(", ")}` : "";
1241
+ const parts = [];
1242
+ parts.push(`[Video note — ${vn.duration}s${analysisTag}]`);
1243
+ if (sceneDescription)
1244
+ parts.push(`Scene: ${sceneDescription}`);
1245
+ if (transcript)
1246
+ parts.push(`Audio: "${transcript}"`);
1247
+ if (!sceneDescription && !transcript)
1248
+ parts.push("(no visual or audio content could be extracted)");
1249
+ contentBlocks.push({ type: "text", text: parts.join("\n") });
1250
+ // Auto-save voice signature for video notes
1251
+ if (analysis && effectiveThreadId !== undefined) {
1252
+ try {
1253
+ const db = getMemoryDb();
1254
+ const sessionId = `session_${sessionStartedAt}`;
1255
+ const epId = saveEpisode(db, {
1256
+ sessionId,
1257
+ threadId: effectiveThreadId,
1258
+ type: "operator_message",
1259
+ modality: "video_note",
1260
+ content: { raw: transcript ?? "", scene: sceneDescription ?? "", duration: vn.duration },
1261
+ importance: 0.6,
1262
+ });
1263
+ saveVoiceSignature(db, {
1264
+ episodeId: epId,
1265
+ emotion: analysis.emotion ?? undefined,
1266
+ arousal: analysis.arousal ?? undefined,
1267
+ dominance: analysis.dominance ?? undefined,
1268
+ valence: analysis.valence ?? undefined,
1269
+ speechRate: analysis.paralinguistics?.speech_rate ?? undefined,
1270
+ meanPitchHz: analysis.paralinguistics?.mean_pitch_hz ?? undefined,
1271
+ pitchStdHz: analysis.paralinguistics?.pitch_std_hz ?? undefined,
1272
+ jitter: analysis.paralinguistics?.jitter ?? undefined,
1273
+ shimmer: analysis.paralinguistics?.shimmer ?? undefined,
1274
+ hnrDb: analysis.paralinguistics?.hnr_db ?? undefined,
1275
+ audioEvents: analysis.audio_events?.map(e => ({ label: e.label, confidence: e.score })),
1276
+ durationSec: vn.duration,
1277
+ });
1278
+ savedEpisodeUpdateIds.add(msg.update_id);
1279
+ }
1280
+ catch (_) { /* non-fatal */ }
1281
+ }
1282
+ }
1283
+ catch (err) {
1284
+ contentBlocks.push({
1285
+ type: "text",
1286
+ text: `[Video note — ${vn.duration}s — analysis failed: ${errorMessage(err)}]`,
1287
+ });
1288
+ }
1289
+ }
1290
+ else {
1291
+ contentBlocks.push({
1292
+ type: "text",
1293
+ text: `[Video note received — ${vn.duration}s — cannot analyze: OPENAI_API_KEY not set]`,
1294
+ });
1295
+ }
1145
1296
  }
1146
1297
  }
1147
- }
1148
- if (contentBlocks.length === 0) {
1149
- // All messages were unsupported types (stickers, etc.);
1150
- // continue polling instead of returning empty instructions.
1151
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1152
- continue;
1153
- }
1154
- // Auto-ingest episodes for messages not already saved by voice/video handlers
1155
- try {
1156
- const db = getMemoryDb();
1157
- const sessionId = `session_${sessionStartedAt}`;
1158
- if (effectiveThreadId !== undefined) {
1159
- // Collect text from messages that didn't already get an episode
1160
- const unsavedMsgs = stored.filter(m => !savedEpisodeUpdateIds.has(m.update_id));
1161
- if (unsavedMsgs.length > 0) {
1162
- const textContent = unsavedMsgs
1163
- .map(m => m.message.text ?? m.message.caption ?? "")
1164
- .filter(Boolean)
1165
- .join("\n")
1166
- .slice(0, 2000);
1167
- if (textContent) {
1168
- saveEpisode(db, {
1169
- sessionId,
1170
- threadId: effectiveThreadId,
1171
- type: "operator_message",
1172
- modality: "text",
1173
- content: { raw: textContent },
1174
- importance: 0.5,
1175
- });
1298
+ if (contentBlocks.length === 0) {
1299
+ // All messages were unsupported types (stickers, etc.);
1300
+ // continue polling instead of returning empty instructions.
1301
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1302
+ continue;
1303
+ }
1304
+ // Auto-ingest episodes for messages not already saved by voice/video handlers
1305
+ try {
1306
+ const db = getMemoryDb();
1307
+ const sessionId = `session_${sessionStartedAt}`;
1308
+ if (effectiveThreadId !== undefined) {
1309
+ // Collect text from messages that didn't already get an episode
1310
+ const unsavedMsgs = stored.filter(m => !savedEpisodeUpdateIds.has(m.update_id));
1311
+ if (unsavedMsgs.length > 0) {
1312
+ const textContent = unsavedMsgs
1313
+ .map(m => m.message.text ?? m.message.caption ?? "")
1314
+ .filter(Boolean)
1315
+ .join("\n")
1316
+ .slice(0, 2000);
1317
+ if (textContent) {
1318
+ saveEpisode(db, {
1319
+ sessionId,
1320
+ threadId: effectiveThreadId,
1321
+ type: "operator_message",
1322
+ modality: "text",
1323
+ content: { raw: textContent },
1324
+ importance: 0.5,
1325
+ });
1326
+ }
1176
1327
  }
1177
1328
  }
1178
1329
  }
1330
+ catch (_) { /* memory write failures should never break the main flow */ }
1331
+ // Inject subagent/delegation hint right after the operator's message
1332
+ // so the agent treats it as part of the operator's instructions.
1333
+ const delegationHint = {
1334
+ type: "text",
1335
+ text: "\n Read and incorporate the operator's new messages." +
1336
+ "\n - Update or refine your plan as needed." +
1337
+ "\n - Continue your work." +
1338
+ "\n\n⚠️ HINT from operator: Use subagents (runSubagent) for ALL substantial work — code edits, research, reviews, searches, and terminal commands. " +
1339
+ "Subagents have full access to ALL MCP tools including terminal, file system, and web search. " +
1340
+ "Run them in parallel when tasks are independent. You plan and verify; subagents execute.",
1341
+ };
1342
+ return {
1343
+ content: [
1344
+ {
1345
+ type: "text",
1346
+ text: "Follow the operator's instructions below.\n\n" +
1347
+ "BEFORE doing anything: (1) Break the work into todo items. (2) Share your plan via report_progress. " +
1348
+ "(3) For each todo: mark in-progress → do the work → call report_progress → mark completed. " +
1349
+ "Use subagents heavily for all substantial work — code edits, research, reviews, searches. Spin up parallel subagents when possible. " +
1350
+ "The operator is REMOTE — they cannot see your screen. If you don't call report_progress, they see nothing.",
1351
+ },
1352
+ ...contentBlocks,
1353
+ delegationHint,
1354
+ ...(hasVoiceMessages
1355
+ ? [{
1356
+ type: "text",
1357
+ text: "\n**Note:** The operator sent voice message(s). They prefer voice interaction — use `send_voice` for progress updates and responses when possible.",
1358
+ }]
1359
+ : []),
1360
+ { type: "text", text: getReminders(effectiveThreadId) },
1361
+ ],
1362
+ };
1179
1363
  }
1180
- catch (_) { /* memory write failures should never break the main flow */ }
1181
- // Inject subagent/delegation hint right after the operator's message
1182
- // so the agent treats it as part of the operator's instructions.
1183
- const delegationHint = {
1184
- type: "text",
1185
- text: "\n Read and incorporate the operator's new messages." +
1186
- "\n - Update or refine your plan as needed." +
1187
- "\n - Continue your work." +
1188
- "\n\n⚠️ HINT from operator: Use subagents (runSubagent) for ALL substantial work — code edits, research, reviews, searches, and terminal commands. " +
1189
- "Subagents have full access to ALL MCP tools including terminal, file system, and web search. " +
1190
- "Run them in parallel when tasks are independent. You plan and verify; subagents execute.",
1191
- };
1192
- return {
1193
- content: [
1194
- {
1195
- type: "text",
1196
- text: "Follow the operator's instructions below.\n\n" +
1197
- "BEFORE doing anything: (1) Break the work into todo items. (2) Share your plan via report_progress. " +
1198
- "(3) For each todo: mark in-progress → do the work → call report_progress → mark completed. " +
1199
- "Use subagents heavily for all substantial work — code edits, research, reviews, searches. Spin up parallel subagents when possible. " +
1200
- "The operator is REMOTE — they cannot see your screen. If you don't call report_progress, they see nothing.",
1201
- },
1202
- ...contentBlocks,
1203
- delegationHint,
1204
- ...(hasVoiceMessages
1205
- ? [{
1364
+ // Check scheduled tasks every ~60s during idle polling.
1365
+ if (effectiveThreadId !== undefined && Date.now() - lastScheduleCheck >= 60_000) {
1366
+ lastScheduleCheck = Date.now();
1367
+ const dueTask = checkDueTasks(effectiveThreadId, lastOperatorMessageAt, false);
1368
+ if (dueTask) {
1369
+ return {
1370
+ content: [
1371
+ {
1206
1372
  type: "text",
1207
- text: "\n**Note:** The operator sent voice message(s). They prefer voice interaction — use `send_voice` for progress updates and responses when possible.",
1208
- }]
1209
- : []),
1210
- { type: "text", text: getReminders(effectiveThreadId) },
1211
- ],
1212
- };
1373
+ text: `⏰ **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
1374
+ `This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
1375
+ `Task prompt: ${dueTask.prompt}` +
1376
+ getReminders(effectiveThreadId),
1377
+ },
1378
+ ],
1379
+ };
1380
+ }
1381
+ }
1382
+ // No messages yet — sleep briefly and check again.
1383
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1213
1384
  }
1214
- // Check scheduled tasks every ~60s during idle polling.
1215
- if (effectiveThreadId !== undefined && Date.now() - lastScheduleCheck >= 60_000) {
1216
- lastScheduleCheck = Date.now();
1385
+ // Timeout elapsed with no actionable message.
1386
+ const now = new Date().toISOString();
1387
+ // Check for scheduled wake-up tasks.
1388
+ if (effectiveThreadId !== undefined) {
1217
1389
  const dueTask = checkDueTasks(effectiveThreadId, lastOperatorMessageAt, false);
1218
1390
  if (dueTask) {
1219
1391
  return {
@@ -1229,641 +1401,628 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1229
1401
  };
1230
1402
  }
1231
1403
  }
1232
- // No messages yet sleep briefly and check again.
1233
- await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
1234
- }
1235
- // Timeout elapsed with no actionable message.
1236
- const now = new Date().toISOString();
1237
- // Check for scheduled wake-up tasks.
1238
- if (effectiveThreadId !== undefined) {
1239
- const dueTask = checkDueTasks(effectiveThreadId, lastOperatorMessageAt, false);
1240
- if (dueTask) {
1241
- return {
1242
- content: [
1243
- {
1244
- type: "text",
1245
- text: `⏰ **Scheduled task fired: "${dueTask.task.label}"**\n\n` +
1246
- `This task was scheduled by you. Execute it now using subagents, then report progress and continue waiting.\n\n` +
1247
- `Task prompt: ${dueTask.prompt}` +
1248
- getReminders(effectiveThreadId),
1249
- },
1250
- ],
1251
- };
1252
- }
1253
- }
1254
- const idleMinutes = Math.round((Date.now() - lastOperatorMessageAt) / 60000);
1255
- // Show pending scheduled tasks if any exist.
1256
- let scheduleHint = "";
1257
- if (effectiveThreadId !== undefined) {
1258
- const pending = listSchedules(effectiveThreadId);
1259
- if (pending.length > 0) {
1260
- const taskList = pending.map(t => {
1261
- let trigger = "";
1262
- if (t.runAt) {
1263
- trigger = `at ${new Date(t.runAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}`;
1264
- }
1265
- else if (t.cron) {
1266
- trigger = `cron: ${t.cron}`;
1267
- }
1268
- else if (t.afterIdleMinutes) {
1269
- trigger = `after ${t.afterIdleMinutes}min idle`;
1270
- }
1271
- return ` • "${t.label}" (${trigger})`;
1272
- }).join("\n");
1273
- scheduleHint = `\n\n📋 **Pending scheduled tasks:**\n${taskList}`;
1274
- }
1275
- }
1276
- // ── Auto-consolidation during idle ──────────────────────────────────────
1277
- try {
1278
- const idleMs = Date.now() - lastOperatorMessageAt;
1279
- if (idleMs > 30 * 60 * 1000 && effectiveThreadId !== undefined) {
1280
- const db = getMemoryDb();
1281
- const report = await runIntelligentConsolidation(db, effectiveThreadId);
1282
- if (report.episodesProcessed > 0) {
1283
- process.stderr.write(`[memory] Consolidation: ${report.episodesProcessed} episodes → ${report.notesCreated} notes\n`);
1404
+ const idleMinutes = Math.round((Date.now() - lastOperatorMessageAt) / 60000);
1405
+ // Show pending scheduled tasks if any exist.
1406
+ let scheduleHint = "";
1407
+ if (effectiveThreadId !== undefined) {
1408
+ const pending = listSchedules(effectiveThreadId);
1409
+ if (pending.length > 0) {
1410
+ const taskList = pending.map(t => {
1411
+ let trigger = "";
1412
+ if (t.runAt) {
1413
+ trigger = `at ${new Date(t.runAt).toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}`;
1414
+ }
1415
+ else if (t.cron) {
1416
+ trigger = `cron: ${t.cron}`;
1417
+ }
1418
+ else if (t.afterIdleMinutes) {
1419
+ trigger = `after ${t.afterIdleMinutes}min idle`;
1420
+ }
1421
+ return ` • "${t.label}" (${trigger})`;
1422
+ }).join("\n");
1423
+ scheduleHint = `\n\n📋 **Pending scheduled tasks:**\n${taskList}`;
1284
1424
  }
1285
1425
  }
1286
- }
1287
- catch (_) { /* consolidation failure is non-fatal */ }
1288
- // Periodic memory refresh — re-ground the agent every 5 polls (~2.5h)
1289
- let memoryRefresh = "";
1290
- if (callNumber % 5 === 0 && effectiveThreadId !== undefined) {
1426
+ // ── Auto-consolidation during idle ──────────────────────────────────────
1291
1427
  try {
1292
- const db = getMemoryDb();
1293
- const refresh = assembleCompactRefresh(db, effectiveThreadId);
1294
- if (refresh)
1295
- memoryRefresh = `\n\n${refresh}`;
1296
- }
1297
- catch (_) { /* non-fatal */ }
1298
- }
1299
- return {
1300
- content: [
1301
- {
1302
- type: "text",
1303
- text: `[Poll #${callNumber} — timeout at ${now} — elapsed ${WAIT_TIMEOUT_MINUTES}m — session uptime ${Math.round((Date.now() - sessionStartedAt) / 60000)}m — operator idle ${idleMinutes}m]` +
1304
- ` No new instructions received. ` +
1305
- `YOU MUST call remote_copilot_wait_for_instructions again RIGHT NOW to continue listening. ` +
1306
- `Do NOT summarize, stop, or say the session is idle. ` +
1307
- `Just call the tool again immediately.` +
1308
- `\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.` +
1309
- memoryRefresh +
1310
- scheduleHint +
1311
- getReminders(effectiveThreadId),
1312
- },
1313
- ],
1314
- };
1315
- }
1316
- // ── report_progress ───────────────────────────────────────────────────────
1317
- if (name === "report_progress") {
1318
- const typedArgs = (args ?? {});
1319
- const effectiveThreadId = resolveThreadId(typedArgs);
1320
- if (effectiveThreadId === undefined) {
1321
- return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
1322
- }
1323
- const rawMessage = typeof typedArgs?.message === "string"
1324
- ? typedArgs.message
1325
- : "";
1326
- if (!rawMessage) {
1327
- return errorResult("Error: 'message' argument is required for report_progress.");
1328
- }
1329
- // Convert standard Markdown to Telegram MarkdownV2.
1330
- let message;
1331
- try {
1332
- message = convertMarkdown(rawMessage);
1333
- }
1334
- catch {
1335
- // Fall back to raw text if Markdown conversion throws.
1336
- message = rawMessage;
1337
- }
1338
- let sentAsPlainText = false;
1339
- try {
1340
- await telegram.sendMessage(TELEGRAM_CHAT_ID, message, "MarkdownV2", effectiveThreadId);
1341
- }
1342
- catch (error) {
1343
- const errMsg = errorMessage(error);
1344
- // If Telegram rejected the message due to a MarkdownV2 parse error,
1345
- // retry as plain text using the original un-converted message.
1346
- const isParseError = errMsg.includes("can't parse entities");
1347
- if (isParseError) {
1348
- try {
1349
- await telegram.sendMessage(TELEGRAM_CHAT_ID, rawMessage, undefined, effectiveThreadId);
1350
- sentAsPlainText = true;
1351
- }
1352
- catch (retryError) {
1353
- process.stderr.write(`Failed to send progress message via Telegram (plain fallback): ${errorMessage(retryError)}\n`);
1354
- return errorResult("Error: Failed to send progress update to Telegram even without formatting. " +
1355
- "Please check the Telegram configuration and try again.");
1428
+ const idleMs = Date.now() - lastOperatorMessageAt;
1429
+ if (idleMs > 30 * 60 * 1000 && effectiveThreadId !== undefined) {
1430
+ const db = getMemoryDb();
1431
+ const report = await runIntelligentConsolidation(db, effectiveThreadId);
1432
+ if (report.episodesProcessed > 0) {
1433
+ process.stderr.write(`[memory] Consolidation: ${report.episodesProcessed} episodes ${report.notesCreated} notes\n`);
1434
+ }
1356
1435
  }
1357
1436
  }
1358
- else {
1359
- process.stderr.write(`Failed to send progress message via Telegram: ${errMsg}\n`);
1360
- return errorResult("Error: Failed to send progress update to Telegram. " +
1361
- "Check the Telegram configuration and try again.");
1362
- }
1363
- }
1364
- // Peek at any messages the operator sent while the agent was working.
1365
- // Uses non-destructive peek so media is preserved for full delivery
1366
- // via remote_copilot_wait_for_instructions. Tracks previewed update_ids
1367
- // to prevent the same messages from appearing on repeated calls.
1368
- let pendingMessages = [];
1369
- try {
1370
- const pendingStored = peekThreadMessages(effectiveThreadId);
1371
- for (const msg of pendingStored) {
1372
- if (previewedUpdateIds.has(msg.update_id))
1373
- continue;
1374
- addPreviewedId(msg.update_id);
1375
- if (msg.message.photo && msg.message.photo.length > 0) {
1376
- pendingMessages.push(msg.message.caption
1377
- ? `[Photo received — will be downloaded when you call wait_for_instructions] ${msg.message.caption}`
1378
- : "[Photo received from operator — will be downloaded when you call wait_for_instructions]");
1379
- }
1380
- else if (msg.message.document) {
1381
- pendingMessages.push(msg.message.caption
1382
- ? `[Document: ${msg.message.document.file_name ?? "file"} — will be downloaded when you call wait_for_instructions] ${msg.message.caption}`
1383
- : `[Document received: ${msg.message.document.file_name ?? "file"} — will be downloaded when you call wait_for_instructions]`);
1384
- }
1385
- else if (msg.message.voice) {
1386
- pendingMessages.push(`[Voice message — ${msg.message.voice.duration}s — will be transcribed on next wait]`);
1387
- }
1388
- else if (msg.message.video_note) {
1389
- pendingMessages.push(`[Video note — ${msg.message.video_note.duration}s — will be analyzed on next wait]`);
1390
- }
1391
- else if (msg.message.text) {
1392
- pendingMessages.push(msg.message.text);
1437
+ catch (_) { /* consolidation failure is non-fatal */ }
1438
+ // Periodic memory refresh re-ground the agent every 5 polls (~2.5h)
1439
+ let memoryRefresh = "";
1440
+ if (callNumber % 5 === 0 && effectiveThreadId !== undefined) {
1441
+ try {
1442
+ const db = getMemoryDb();
1443
+ const refresh = assembleCompactRefresh(db, effectiveThreadId);
1444
+ if (refresh)
1445
+ memoryRefresh = `\n\n${refresh}`;
1393
1446
  }
1447
+ catch (_) { /* non-fatal */ }
1394
1448
  }
1395
- }
1396
- catch {
1397
- // Non-fatal: pending messages will still be picked up by the next
1398
- // remote_copilot_wait_for_instructions call.
1399
- }
1400
- const baseStatus = (sentAsPlainText
1401
- ? "Progress reported successfully (as plain text — formatting could not be applied)."
1402
- : "Progress reported successfully.") + getReminders(effectiveThreadId);
1403
- const responseText = pendingMessages.length > 0
1404
- ? `${baseStatus}\n\n` +
1405
- `While you were working, the operator sent additional message(s). ` +
1406
- `Use those messages to steer your active session: ${pendingMessages.join("\n\n")}` +
1407
- `\n\n⚠️ HINT from operator: Use subagents (runSubagent) for ALL substantial work. ` +
1408
- `Subagents have full access to ALL MCP tools. You plan and verify; subagents execute.`
1409
- : baseStatus + getSubagentNudge();
1410
- return {
1411
- content: [
1412
- {
1413
- type: "text",
1414
- text: responseText,
1415
- },
1416
- ],
1417
- };
1418
- }
1419
- // ── send_file ─────────────────────────────────────────────────────────────
1420
- if (name === "send_file") {
1421
- const typedArgs = (args ?? {});
1422
- const effectiveThreadId = resolveThreadId(typedArgs);
1423
- if (effectiveThreadId === undefined) {
1424
- return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
1425
- }
1426
- const filePath = typeof typedArgs.filePath === "string" ? typedArgs.filePath.trim() : "";
1427
- const base64Data = typeof typedArgs.base64 === "string" ? typedArgs.base64 : "";
1428
- const caption = typeof typedArgs.caption === "string" ? typedArgs.caption : undefined;
1429
- if (!filePath && !base64Data) {
1430
- return errorResult("Error: either 'filePath' or 'base64' argument is required for send_file.");
1431
- }
1432
- try {
1433
- let buffer;
1434
- let filename;
1435
- if (filePath) {
1436
- // Read directly from disk — fast, no LLM context overhead.
1437
- buffer = readFileSync(filePath);
1438
- filename = typeof typedArgs.filename === "string" && typedArgs.filename.trim()
1439
- ? typedArgs.filename.trim()
1440
- : basename(filePath);
1441
- }
1442
- else {
1443
- buffer = Buffer.from(base64Data, "base64");
1444
- filename = typeof typedArgs.filename === "string" && typedArgs.filename.trim()
1445
- ? typedArgs.filename.trim()
1446
- : "file";
1447
- }
1448
- const ext = filename.split(".").pop()?.toLowerCase() ?? "";
1449
- if (IMAGE_EXTENSIONS.has(ext)) {
1450
- await telegram.sendPhoto(TELEGRAM_CHAT_ID, buffer, filename, caption, effectiveThreadId);
1451
- }
1452
- else {
1453
- await telegram.sendDocument(TELEGRAM_CHAT_ID, buffer, filename, caption, effectiveThreadId);
1454
- }
1449
+ // Generate autonomous goals only after extended silence (4+ hours)
1450
+ // Before that, the agent should just keep polling quietly
1451
+ const GOAL_GENERATION_THRESHOLD_MS = 4 * 60 * 60 * 1000; // 4 hours
1452
+ const idleMs = Date.now() - lastOperatorMessageAt;
1453
+ const autonomousGoals = idleMs >= GOAL_GENERATION_THRESHOLD_MS
1454
+ ? formatAutonomousGoals(effectiveThreadId)
1455
+ : "";
1455
1456
  return {
1456
1457
  content: [
1457
1458
  {
1458
1459
  type: "text",
1459
- text: `File "${filename}" sent to Telegram successfully.` + getReminders(effectiveThreadId),
1460
+ text: `[Poll #${callNumber} — timeout at ${now} elapsed ${WAIT_TIMEOUT_MINUTES}m session uptime ${Math.round((Date.now() - sessionStartedAt) / 60000)}m — operator idle ${idleMinutes}m]` +
1461
+ ` No new instructions received. ` +
1462
+ `YOU MUST call remote_copilot_wait_for_instructions again RIGHT NOW to continue listening. ` +
1463
+ `Do NOT summarize, stop, or say the session is idle. ` +
1464
+ `Just call the tool again immediately.` +
1465
+ autonomousGoals +
1466
+ memoryRefresh +
1467
+ scheduleHint +
1468
+ getReminders(effectiveThreadId),
1460
1469
  },
1461
1470
  ],
1462
1471
  };
1463
1472
  }
1464
- catch (err) {
1465
- process.stderr.write(`Failed to send file via Telegram: ${errorMessage(err)}\n`);
1466
- return errorResult(`Error: Failed to send file to Telegram: ${errorMessage(err)}`);
1467
- }
1468
- }
1469
- // ── send_voice ──────────────────────────────────────────────────────────
1470
- if (name === "send_voice") {
1471
- const typedArgs = (args ?? {});
1472
- const effectiveThreadId = resolveThreadId(typedArgs);
1473
- if (effectiveThreadId === undefined) {
1474
- return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
1475
- }
1476
- const text = typeof typedArgs.text === "string" ? typedArgs.text.trim() : "";
1477
- const validVoices = TTS_VOICES;
1478
- const voice = typeof typedArgs.voice === "string" && validVoices.includes(typedArgs.voice)
1479
- ? typedArgs.voice
1480
- : "nova";
1481
- if (!text) {
1482
- return errorResult("Error: 'text' argument is required for send_voice.");
1483
- }
1484
- if (!OPENAI_API_KEY) {
1485
- return errorResult("Error: OPENAI_API_KEY is not set. Cannot generate voice.");
1486
- }
1487
- if (text.length > OPENAI_TTS_MAX_CHARS) {
1488
- return errorResult(`Error: text is ${text.length} characters — exceeds OpenAI TTS limit of ${OPENAI_TTS_MAX_CHARS}.`);
1489
- }
1490
- try {
1491
- const audioBuffer = await textToSpeech(text, OPENAI_API_KEY, voice);
1492
- await telegram.sendVoice(TELEGRAM_CHAT_ID, audioBuffer, effectiveThreadId);
1473
+ // ── report_progress ───────────────────────────────────────────────────────
1474
+ if (name === "report_progress") {
1475
+ const typedArgs = (args ?? {});
1476
+ const effectiveThreadId = resolveThreadId(typedArgs);
1477
+ if (effectiveThreadId === undefined) {
1478
+ return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
1479
+ }
1480
+ const rawMessage = typeof typedArgs?.message === "string"
1481
+ ? typedArgs.message
1482
+ : "";
1483
+ if (!rawMessage) {
1484
+ return errorResult("Error: 'message' argument is required for report_progress.");
1485
+ }
1486
+ // Convert standard Markdown to Telegram MarkdownV2.
1487
+ let message;
1488
+ try {
1489
+ message = convertMarkdown(rawMessage);
1490
+ }
1491
+ catch {
1492
+ // Fall back to raw text if Markdown conversion throws.
1493
+ message = rawMessage;
1494
+ }
1495
+ let sentAsPlainText = false;
1496
+ try {
1497
+ await telegram.sendMessage(TELEGRAM_CHAT_ID, message, "MarkdownV2", effectiveThreadId);
1498
+ }
1499
+ catch (error) {
1500
+ const errMsg = errorMessage(error);
1501
+ // If Telegram rejected the message due to a MarkdownV2 parse error,
1502
+ // retry as plain text using the original un-converted message.
1503
+ const isParseError = errMsg.includes("can't parse entities");
1504
+ if (isParseError) {
1505
+ try {
1506
+ await telegram.sendMessage(TELEGRAM_CHAT_ID, rawMessage, undefined, effectiveThreadId);
1507
+ sentAsPlainText = true;
1508
+ }
1509
+ catch (retryError) {
1510
+ process.stderr.write(`Failed to send progress message via Telegram (plain fallback): ${errorMessage(retryError)}\n`);
1511
+ return errorResult("Error: Failed to send progress update to Telegram even without formatting. " +
1512
+ "Please check the Telegram configuration and try again.");
1513
+ }
1514
+ }
1515
+ else {
1516
+ process.stderr.write(`Failed to send progress message via Telegram: ${errMsg}\n`);
1517
+ return errorResult("Error: Failed to send progress update to Telegram. " +
1518
+ "Check the Telegram configuration and try again.");
1519
+ }
1520
+ }
1521
+ // Peek at any messages the operator sent while the agent was working.
1522
+ // Uses non-destructive peek so media is preserved for full delivery
1523
+ // via remote_copilot_wait_for_instructions. Tracks previewed update_ids
1524
+ // to prevent the same messages from appearing on repeated calls.
1525
+ let pendingMessages = [];
1526
+ try {
1527
+ const pendingStored = peekThreadMessages(effectiveThreadId);
1528
+ for (const msg of pendingStored) {
1529
+ if (previewedUpdateIds.has(msg.update_id))
1530
+ continue;
1531
+ addPreviewedId(msg.update_id);
1532
+ if (msg.message.photo && msg.message.photo.length > 0) {
1533
+ pendingMessages.push(msg.message.caption
1534
+ ? `[Photo received — will be downloaded when you call wait_for_instructions] ${msg.message.caption}`
1535
+ : "[Photo received from operator — will be downloaded when you call wait_for_instructions]");
1536
+ }
1537
+ else if (msg.message.document) {
1538
+ pendingMessages.push(msg.message.caption
1539
+ ? `[Document: ${msg.message.document.file_name ?? "file"} — will be downloaded when you call wait_for_instructions] ${msg.message.caption}`
1540
+ : `[Document received: ${msg.message.document.file_name ?? "file"} — will be downloaded when you call wait_for_instructions]`);
1541
+ }
1542
+ else if (msg.message.voice) {
1543
+ pendingMessages.push(`[Voice message — ${msg.message.voice.duration}s — will be transcribed on next wait]`);
1544
+ }
1545
+ else if (msg.message.video_note) {
1546
+ pendingMessages.push(`[Video note — ${msg.message.video_note.duration}s — will be analyzed on next wait]`);
1547
+ }
1548
+ else if (msg.message.text) {
1549
+ pendingMessages.push(msg.message.text);
1550
+ }
1551
+ }
1552
+ }
1553
+ catch {
1554
+ // Non-fatal: pending messages will still be picked up by the next
1555
+ // remote_copilot_wait_for_instructions call.
1556
+ }
1557
+ const baseStatus = (sentAsPlainText
1558
+ ? "Progress reported successfully (as plain text — formatting could not be applied)."
1559
+ : "Progress reported successfully.") + getReminders(effectiveThreadId);
1560
+ const responseText = pendingMessages.length > 0
1561
+ ? `${baseStatus}\n\n` +
1562
+ `While you were working, the operator sent additional message(s). ` +
1563
+ `Use those messages to steer your active session: ${pendingMessages.join("\n\n")}` +
1564
+ `\n\n⚠️ HINT from operator: Use subagents (runSubagent) for ALL substantial work. ` +
1565
+ `Subagents have full access to ALL MCP tools. You plan and verify; subagents execute.`
1566
+ : baseStatus + getSubagentNudge();
1493
1567
  return {
1494
1568
  content: [
1495
1569
  {
1496
1570
  type: "text",
1497
- text: `Voice message sent to Telegram successfully.` + getReminders(effectiveThreadId),
1571
+ text: responseText,
1498
1572
  },
1499
1573
  ],
1500
1574
  };
1501
1575
  }
1502
- catch (err) {
1503
- process.stderr.write(`Failed to send voice via Telegram: ${errorMessage(err)}\n`);
1504
- return errorResult(`Error: Failed to send voice message: ${errorMessage(err)}`);
1576
+ // ── send_file ─────────────────────────────────────────────────────────────
1577
+ if (name === "send_file") {
1578
+ const typedArgs = (args ?? {});
1579
+ const effectiveThreadId = resolveThreadId(typedArgs);
1580
+ if (effectiveThreadId === undefined) {
1581
+ return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
1582
+ }
1583
+ const filePath = typeof typedArgs.filePath === "string" ? typedArgs.filePath.trim() : "";
1584
+ const base64Data = typeof typedArgs.base64 === "string" ? typedArgs.base64 : "";
1585
+ const caption = typeof typedArgs.caption === "string" ? typedArgs.caption : undefined;
1586
+ if (!filePath && !base64Data) {
1587
+ return errorResult("Error: either 'filePath' or 'base64' argument is required for send_file.");
1588
+ }
1589
+ try {
1590
+ let buffer;
1591
+ let filename;
1592
+ if (filePath) {
1593
+ // Read directly from disk — fast, no LLM context overhead.
1594
+ buffer = readFileSync(filePath);
1595
+ filename = typeof typedArgs.filename === "string" && typedArgs.filename.trim()
1596
+ ? typedArgs.filename.trim()
1597
+ : basename(filePath);
1598
+ }
1599
+ else {
1600
+ buffer = Buffer.from(base64Data, "base64");
1601
+ filename = typeof typedArgs.filename === "string" && typedArgs.filename.trim()
1602
+ ? typedArgs.filename.trim()
1603
+ : "file";
1604
+ }
1605
+ const ext = filename.split(".").pop()?.toLowerCase() ?? "";
1606
+ if (IMAGE_EXTENSIONS.has(ext)) {
1607
+ await telegram.sendPhoto(TELEGRAM_CHAT_ID, buffer, filename, caption, effectiveThreadId);
1608
+ }
1609
+ else {
1610
+ await telegram.sendDocument(TELEGRAM_CHAT_ID, buffer, filename, caption, effectiveThreadId);
1611
+ }
1612
+ return {
1613
+ content: [
1614
+ {
1615
+ type: "text",
1616
+ text: `File "${filename}" sent to Telegram successfully.` + getReminders(effectiveThreadId),
1617
+ },
1618
+ ],
1619
+ };
1620
+ }
1621
+ catch (err) {
1622
+ process.stderr.write(`Failed to send file via Telegram: ${errorMessage(err)}\n`);
1623
+ return errorResult(`Error: Failed to send file to Telegram: ${errorMessage(err)}`);
1624
+ }
1505
1625
  }
1506
- }
1507
- // ── schedule_wake_up ────────────────────────────────────────────────────
1508
- if (name === "schedule_wake_up") {
1509
- const typedArgs = (args ?? {});
1510
- const effectiveThreadId = resolveThreadId(typedArgs);
1511
- if (effectiveThreadId === undefined) {
1512
- return errorResult("Error: No active session. Call start_session first.");
1626
+ // ── send_voice ──────────────────────────────────────────────────────────
1627
+ if (name === "send_voice") {
1628
+ const typedArgs = (args ?? {});
1629
+ const effectiveThreadId = resolveThreadId(typedArgs);
1630
+ if (effectiveThreadId === undefined) {
1631
+ return errorResult("Error: No active session. Call start_session first, then pass the returned threadId.");
1632
+ }
1633
+ const text = typeof typedArgs.text === "string" ? typedArgs.text.trim() : "";
1634
+ const validVoices = TTS_VOICES;
1635
+ const voice = typeof typedArgs.voice === "string" && validVoices.includes(typedArgs.voice)
1636
+ ? typedArgs.voice
1637
+ : "nova";
1638
+ if (!text) {
1639
+ return errorResult("Error: 'text' argument is required for send_voice.");
1640
+ }
1641
+ if (!OPENAI_API_KEY) {
1642
+ return errorResult("Error: OPENAI_API_KEY is not set. Cannot generate voice.");
1643
+ }
1644
+ if (text.length > OPENAI_TTS_MAX_CHARS) {
1645
+ return errorResult(`Error: text is ${text.length} characters — exceeds OpenAI TTS limit of ${OPENAI_TTS_MAX_CHARS}.`);
1646
+ }
1647
+ try {
1648
+ const audioBuffer = await textToSpeech(text, OPENAI_API_KEY, voice);
1649
+ await telegram.sendVoice(TELEGRAM_CHAT_ID, audioBuffer, effectiveThreadId);
1650
+ return {
1651
+ content: [
1652
+ {
1653
+ type: "text",
1654
+ text: `Voice message sent to Telegram successfully.` + getReminders(effectiveThreadId),
1655
+ },
1656
+ ],
1657
+ };
1658
+ }
1659
+ catch (err) {
1660
+ process.stderr.write(`Failed to send voice via Telegram: ${errorMessage(err)}\n`);
1661
+ return errorResult(`Error: Failed to send voice message: ${errorMessage(err)}`);
1662
+ }
1513
1663
  }
1514
- const action = typeof typedArgs.action === "string" ? typedArgs.action : "add";
1515
- // --- List ---
1516
- if (action === "list") {
1517
- const tasks = listSchedules(effectiveThreadId);
1518
- if (tasks.length === 0) {
1664
+ // ── schedule_wake_up ────────────────────────────────────────────────────
1665
+ if (name === "schedule_wake_up") {
1666
+ const typedArgs = (args ?? {});
1667
+ const effectiveThreadId = resolveThreadId(typedArgs);
1668
+ if (effectiveThreadId === undefined) {
1669
+ return errorResult("Error: No active session. Call start_session first.");
1670
+ }
1671
+ const action = typeof typedArgs.action === "string" ? typedArgs.action : "add";
1672
+ // --- List ---
1673
+ if (action === "list") {
1674
+ const tasks = listSchedules(effectiveThreadId);
1675
+ if (tasks.length === 0) {
1676
+ return {
1677
+ content: [{
1678
+ type: "text",
1679
+ text: "No scheduled tasks for this thread." + getReminders(effectiveThreadId),
1680
+ }],
1681
+ };
1682
+ }
1683
+ const lines = tasks.map(t => {
1684
+ const trigger = t.cron ? `cron: ${t.cron}` : t.runAt ? `at: ${t.runAt}` : `idle: ${t.afterIdleMinutes}min`;
1685
+ const lastFired = t.lastFiredAt ? ` (last: ${t.lastFiredAt})` : "";
1686
+ return `- **${t.label}** [${t.id}] — ${trigger}${lastFired}\n Prompt: ${t.prompt.slice(0, 100)}${t.prompt.length > 100 ? "…" : ""}`;
1687
+ });
1519
1688
  return {
1520
1689
  content: [{
1521
1690
  type: "text",
1522
- text: "No scheduled tasks for this thread." + getReminders(effectiveThreadId),
1691
+ text: `**Scheduled tasks (${tasks.length}):**\n\n${lines.join("\n\n")}` + getReminders(effectiveThreadId),
1523
1692
  }],
1524
1693
  };
1525
1694
  }
1526
- const lines = tasks.map(t => {
1527
- const trigger = t.cron ? `cron: ${t.cron}` : t.runAt ? `at: ${t.runAt}` : `idle: ${t.afterIdleMinutes}min`;
1528
- const lastFired = t.lastFiredAt ? ` (last: ${t.lastFiredAt})` : "";
1529
- return `- **${t.label}** [${t.id}] — ${trigger}${lastFired}\n Prompt: ${t.prompt.slice(0, 100)}${t.prompt.length > 100 ? "…" : ""}`;
1530
- });
1531
- return {
1532
- content: [{
1533
- type: "text",
1534
- text: `**Scheduled tasks (${tasks.length}):**\n\n${lines.join("\n\n")}` + getReminders(effectiveThreadId),
1535
- }],
1536
- };
1537
- }
1538
- // --- Remove ---
1539
- if (action === "remove") {
1540
- const taskId = typeof typedArgs.taskId === "string" ? typedArgs.taskId : "";
1541
- if (!taskId) {
1542
- return errorResult("Error: 'taskId' is required for remove action. Use action: 'list' to see task IDs.");
1695
+ // --- Remove ---
1696
+ if (action === "remove") {
1697
+ const taskId = typeof typedArgs.taskId === "string" ? typedArgs.taskId : "";
1698
+ if (!taskId) {
1699
+ return errorResult("Error: 'taskId' is required for remove action. Use action: 'list' to see task IDs.");
1700
+ }
1701
+ const removed = removeSchedule(effectiveThreadId, taskId);
1702
+ return {
1703
+ content: [{
1704
+ type: "text",
1705
+ text: removed
1706
+ ? `Task ${taskId} removed.` + getReminders(effectiveThreadId)
1707
+ : `Task ${taskId} not found.` + getReminders(effectiveThreadId),
1708
+ }],
1709
+ };
1710
+ }
1711
+ // --- Add ---
1712
+ const label = typeof typedArgs.label === "string" ? typedArgs.label : "unnamed task";
1713
+ const prompt = typeof typedArgs.prompt === "string" ? typedArgs.prompt : "";
1714
+ if (!prompt) {
1715
+ return errorResult("Error: 'prompt' is required — this is the text that will be injected when the task fires.");
1543
1716
  }
1544
- const removed = removeSchedule(effectiveThreadId, taskId);
1717
+ const runAt = typeof typedArgs.runAt === "string" ? typedArgs.runAt : undefined;
1718
+ const cron = typeof typedArgs.cron === "string" ? typedArgs.cron : undefined;
1719
+ const afterIdleMinutes = typeof typedArgs.afterIdleMinutes === "number" ? typedArgs.afterIdleMinutes : undefined;
1720
+ if (cron && cron.trim().split(/\s+/).length !== 5) {
1721
+ return errorResult("Error: Invalid cron expression. Must be exactly 5 space-separated fields: minute hour day-of-month month day-of-week. " +
1722
+ "Example: '0 9 * * *' (daily at 9am). Only *, numbers, and comma-separated lists are supported.");
1723
+ }
1724
+ if (!runAt && !cron && afterIdleMinutes == null) {
1725
+ return errorResult("Error: Specify at least one trigger: 'runAt' (ISO timestamp), 'cron' (5-field), or 'afterIdleMinutes' (number).");
1726
+ }
1727
+ const task = {
1728
+ id: generateTaskId(),
1729
+ threadId: effectiveThreadId,
1730
+ prompt,
1731
+ label,
1732
+ runAt,
1733
+ cron,
1734
+ afterIdleMinutes,
1735
+ oneShot: runAt != null && !cron,
1736
+ createdAt: new Date().toISOString(),
1737
+ };
1738
+ addSchedule(task);
1739
+ const triggerDesc = cron
1740
+ ? `recurring (cron: ${cron})`
1741
+ : runAt
1742
+ ? `one-shot at ${runAt}`
1743
+ : `after ${afterIdleMinutes}min of operator silence`;
1545
1744
  return {
1546
1745
  content: [{
1547
1746
  type: "text",
1548
- text: removed
1549
- ? `Task ${taskId} removed.` + getReminders(effectiveThreadId)
1550
- : `Task ${taskId} not found.` + getReminders(effectiveThreadId),
1747
+ text: `✅ Scheduled: **${label}** [${task.id}]\nTrigger: ${triggerDesc}\nPrompt: ${prompt}` +
1748
+ getReminders(effectiveThreadId),
1551
1749
  }],
1552
1750
  };
1553
1751
  }
1554
- // --- Add ---
1555
- const label = typeof typedArgs.label === "string" ? typedArgs.label : "unnamed task";
1556
- const prompt = typeof typedArgs.prompt === "string" ? typedArgs.prompt : "";
1557
- if (!prompt) {
1558
- return errorResult("Error: 'prompt' is required this is the text that will be injected when the task fires.");
1559
- }
1560
- const runAt = typeof typedArgs.runAt === "string" ? typedArgs.runAt : undefined;
1561
- const cron = typeof typedArgs.cron === "string" ? typedArgs.cron : undefined;
1562
- const afterIdleMinutes = typeof typedArgs.afterIdleMinutes === "number" ? typedArgs.afterIdleMinutes : undefined;
1563
- if (cron && cron.trim().split(/\s+/).length !== 5) {
1564
- return errorResult("Error: Invalid cron expression. Must be exactly 5 space-separated fields: minute hour day-of-month month day-of-week. " +
1565
- "Example: '0 9 * * *' (daily at 9am). Only *, numbers, and comma-separated lists are supported.");
1566
- }
1567
- if (!runAt && !cron && afterIdleMinutes == null) {
1568
- return errorResult("Error: Specify at least one trigger: 'runAt' (ISO timestamp), 'cron' (5-field), or 'afterIdleMinutes' (number).");
1569
- }
1570
- const task = {
1571
- id: generateTaskId(),
1572
- threadId: effectiveThreadId,
1573
- prompt,
1574
- label,
1575
- runAt,
1576
- cron,
1577
- afterIdleMinutes,
1578
- oneShot: runAt != null && !cron,
1579
- createdAt: new Date().toISOString(),
1580
- };
1581
- addSchedule(task);
1582
- const triggerDesc = cron
1583
- ? `recurring (cron: ${cron})`
1584
- : runAt
1585
- ? `one-shot at ${runAt}`
1586
- : `after ${afterIdleMinutes}min of operator silence`;
1587
- return {
1588
- content: [{
1589
- type: "text",
1590
- text: `✅ Scheduled: **${label}** [${task.id}]\nTrigger: ${triggerDesc}\nPrompt: ${prompt}` +
1591
- getReminders(effectiveThreadId),
1592
- }],
1593
- };
1594
- }
1595
- // ── memory_bootstrap ────────────────────────────────────────────────────
1596
- if (name === "memory_bootstrap") {
1597
- const threadId = resolveThreadId(args);
1598
- if (threadId === undefined) {
1599
- return { content: [{ type: "text", text: "Error: No active thread. Call start_session first." + getReminders() }] };
1600
- }
1601
- try {
1602
- const db = getMemoryDb();
1603
- const briefing = assembleBootstrap(db, threadId);
1604
- return {
1605
- content: [{ type: "text", text: `## Memory Briefing\n\n${briefing}` + getReminders(threadId) }],
1606
- };
1607
- }
1608
- catch (err) {
1609
- return { content: [{ type: "text", text: `Memory bootstrap error: ${errorMessage(err)}` + getReminders(threadId) }] };
1610
- }
1611
- }
1612
- // ── memory_search ───────────────────────────────────────────────────────
1613
- if (name === "memory_search") {
1614
- const typedArgs = (args ?? {});
1615
- const threadId = resolveThreadId(typedArgs);
1616
- const query = String(typedArgs.query ?? "");
1617
- if (!query) {
1618
- return { content: [{ type: "text", text: "Error: query is required." + getReminders(threadId) }] };
1752
+ // ── memory_bootstrap ────────────────────────────────────────────────────
1753
+ if (name === "memory_bootstrap") {
1754
+ const threadId = resolveThreadId(args);
1755
+ if (threadId === undefined) {
1756
+ return { content: [{ type: "text", text: "Error: No active thread. Call start_session first." + getReminders() }] };
1757
+ }
1758
+ try {
1759
+ const db = getMemoryDb();
1760
+ const briefing = assembleBootstrap(db, threadId);
1761
+ return {
1762
+ content: [{ type: "text", text: `## Memory Briefing\n\n${briefing}` + getReminders(threadId) }],
1763
+ };
1764
+ }
1765
+ catch (err) {
1766
+ return { content: [{ type: "text", text: `Memory bootstrap error: ${errorMessage(err)}` + getReminders(threadId) }] };
1767
+ }
1619
1768
  }
1620
- try {
1621
- const db = getMemoryDb();
1622
- const layers = typedArgs.layers ?? ["episodic", "semantic", "procedural"];
1623
- const types = typedArgs.types;
1624
- const results = [];
1625
- if (layers.includes("semantic")) {
1626
- const notes = searchSemanticNotes(db, query, { types, maxResults: 10 });
1627
- if (notes.length > 0) {
1628
- results.push("### Semantic Memory");
1629
- for (const n of notes) {
1630
- results.push(`- **[${n.type}]** ${n.content} _(conf: ${n.confidence}, id: ${n.noteId})_`);
1769
+ // ── memory_search ───────────────────────────────────────────────────────
1770
+ if (name === "memory_search") {
1771
+ const typedArgs = (args ?? {});
1772
+ const threadId = resolveThreadId(typedArgs);
1773
+ const query = String(typedArgs.query ?? "");
1774
+ if (!query) {
1775
+ return { content: [{ type: "text", text: "Error: query is required." + getReminders(threadId) }] };
1776
+ }
1777
+ try {
1778
+ const db = getMemoryDb();
1779
+ const layers = typedArgs.layers ?? ["episodic", "semantic", "procedural"];
1780
+ const types = typedArgs.types;
1781
+ const results = [];
1782
+ if (layers.includes("semantic")) {
1783
+ const notes = searchSemanticNotes(db, query, { types, maxResults: 10 });
1784
+ if (notes.length > 0) {
1785
+ results.push("### Semantic Memory");
1786
+ for (const n of notes) {
1787
+ results.push(`- **[${n.type}]** ${n.content} _(conf: ${n.confidence}, id: ${n.noteId})_`);
1788
+ }
1631
1789
  }
1632
1790
  }
1633
- }
1634
- if (layers.includes("procedural")) {
1635
- const procs = searchProcedures(db, query, 5);
1636
- if (procs.length > 0) {
1637
- results.push("### Procedural Memory");
1638
- for (const p of procs) {
1639
- results.push(`- **${p.name}** (${p.type}): ${p.description} _(success: ${Math.round(p.successRate * 100)}%, id: ${p.procedureId})_`);
1791
+ if (layers.includes("procedural")) {
1792
+ const procs = searchProcedures(db, query, 5);
1793
+ if (procs.length > 0) {
1794
+ results.push("### Procedural Memory");
1795
+ for (const p of procs) {
1796
+ results.push(`- **${p.name}** (${p.type}): ${p.description} _(success: ${Math.round(p.successRate * 100)}%, id: ${p.procedureId})_`);
1797
+ }
1640
1798
  }
1641
1799
  }
1642
- }
1643
- if (layers.includes("episodic") && threadId !== undefined) {
1644
- const episodes = getRecentEpisodes(db, threadId, 10);
1645
- const filtered = episodes.filter(ep => {
1646
- const content = JSON.stringify(ep.content).toLowerCase();
1647
- return query.toLowerCase().split(/\s+/).some(word => content.includes(word));
1648
- });
1649
- if (filtered.length > 0) {
1650
- results.push("### Episodic Memory");
1651
- for (const ep of filtered.slice(0, 5)) {
1652
- const summary = typeof ep.content === "object" && ep.content !== null
1653
- ? ep.content.text ?? JSON.stringify(ep.content).slice(0, 200)
1654
- : String(ep.content).slice(0, 200);
1655
- results.push(`- [${ep.modality}] ${summary} _(${ep.timestamp}, id: ${ep.episodeId})_`);
1800
+ if (layers.includes("episodic") && threadId !== undefined) {
1801
+ const episodes = getRecentEpisodes(db, threadId, 10);
1802
+ const filtered = episodes.filter(ep => {
1803
+ const content = JSON.stringify(ep.content).toLowerCase();
1804
+ return query.toLowerCase().split(/\s+/).some(word => content.includes(word));
1805
+ });
1806
+ if (filtered.length > 0) {
1807
+ results.push("### Episodic Memory");
1808
+ for (const ep of filtered.slice(0, 5)) {
1809
+ const summary = typeof ep.content === "object" && ep.content !== null
1810
+ ? ep.content.text ?? JSON.stringify(ep.content).slice(0, 200)
1811
+ : String(ep.content).slice(0, 200);
1812
+ results.push(`- [${ep.modality}] ${summary} _(${ep.timestamp}, id: ${ep.episodeId})_`);
1813
+ }
1656
1814
  }
1657
1815
  }
1816
+ const text = results.length > 0
1817
+ ? results.join("\n")
1818
+ : `No memories found for "${query}".`;
1819
+ return { content: [{ type: "text", text: text + getReminders(threadId) }] };
1820
+ }
1821
+ catch (err) {
1822
+ return { content: [{ type: "text", text: `Memory search error: ${errorMessage(err)}` + getReminders(threadId) }] };
1658
1823
  }
1659
- const text = results.length > 0
1660
- ? results.join("\n")
1661
- : `No memories found for "${query}".`;
1662
- return { content: [{ type: "text", text: text + getReminders(threadId) }] };
1663
- }
1664
- catch (err) {
1665
- return { content: [{ type: "text", text: `Memory search error: ${errorMessage(err)}` + getReminders(threadId) }] };
1666
- }
1667
- }
1668
- // ── memory_save ─────────────────────────────────────────────────────────
1669
- if (name === "memory_save") {
1670
- const typedArgs = (args ?? {});
1671
- const threadId = resolveThreadId(typedArgs);
1672
- const VALID_TYPES = ["fact", "preference", "pattern", "entity", "relationship"];
1673
- const noteType = String(typedArgs.type ?? "fact");
1674
- if (!VALID_TYPES.includes(noteType)) {
1675
- return errorResult(`Invalid type "${noteType}". Must be one of: ${VALID_TYPES.join(", ")}`);
1676
- }
1677
- try {
1678
- const db = getMemoryDb();
1679
- const noteId = saveSemanticNote(db, {
1680
- type: noteType,
1681
- content: String(typedArgs.content ?? ""),
1682
- keywords: typedArgs.keywords ?? [],
1683
- confidence: typeof typedArgs.confidence === "number" ? typedArgs.confidence : 0.8,
1684
- });
1685
- return {
1686
- content: [{ type: "text", text: `Saved semantic note: ${noteId}` + getReminders(threadId) + getSubagentNudge() }],
1687
- };
1688
- }
1689
- catch (err) {
1690
- return { content: [{ type: "text", text: `Memory save error: ${errorMessage(err)}` + getReminders(threadId) }] };
1691
1824
  }
1692
- }
1693
- // ── memory_save_procedure ───────────────────────────────────────────────
1694
- if (name === "memory_save_procedure") {
1695
- const typedArgs = (args ?? {});
1696
- const threadId = resolveThreadId(typedArgs);
1697
- try {
1698
- const db = getMemoryDb();
1699
- const existingId = typedArgs.procedureId;
1700
- if (existingId) {
1701
- updateProcedure(db, existingId, {
1702
- description: typedArgs.description,
1703
- steps: typedArgs.steps,
1704
- triggerConditions: typedArgs.triggerConditions,
1825
+ // ── memory_save ─────────────────────────────────────────────────────────
1826
+ if (name === "memory_save") {
1827
+ const typedArgs = (args ?? {});
1828
+ const threadId = resolveThreadId(typedArgs);
1829
+ const VALID_TYPES = ["fact", "preference", "pattern", "entity", "relationship"];
1830
+ const noteType = String(typedArgs.type ?? "fact");
1831
+ if (!VALID_TYPES.includes(noteType)) {
1832
+ return errorResult(`Invalid type "${noteType}". Must be one of: ${VALID_TYPES.join(", ")}`);
1833
+ }
1834
+ try {
1835
+ const db = getMemoryDb();
1836
+ const noteId = saveSemanticNote(db, {
1837
+ type: noteType,
1838
+ content: String(typedArgs.content ?? ""),
1839
+ keywords: typedArgs.keywords ?? [],
1840
+ confidence: typeof typedArgs.confidence === "number" ? typedArgs.confidence : 0.8,
1705
1841
  });
1706
1842
  return {
1707
- content: [{ type: "text", text: `Updated procedure: ${existingId}` + getReminders(threadId) }],
1843
+ content: [{ type: "text", text: `Saved semantic note: ${noteId}` + getReminders(threadId) + getSubagentNudge() }],
1708
1844
  };
1709
1845
  }
1710
- const procId = saveProcedure(db, {
1711
- name: String(typedArgs.name ?? ""),
1712
- type: String(typedArgs.type ?? "workflow"),
1713
- description: String(typedArgs.description ?? ""),
1714
- steps: typedArgs.steps,
1715
- triggerConditions: typedArgs.triggerConditions,
1716
- });
1717
- return {
1718
- content: [{ type: "text", text: `Saved procedure: ${procId}` + getReminders(threadId) }],
1719
- };
1720
- }
1721
- catch (err) {
1722
- return { content: [{ type: "text", text: `Procedure save error: ${errorMessage(err)}` + getReminders(threadId) }] };
1846
+ catch (err) {
1847
+ return { content: [{ type: "text", text: `Memory save error: ${errorMessage(err)}` + getReminders(threadId) }] };
1848
+ }
1723
1849
  }
1724
- }
1725
- // ── memory_update ───────────────────────────────────────────────────────
1726
- if (name === "memory_update") {
1727
- const typedArgs = (args ?? {});
1728
- const threadId = resolveThreadId(typedArgs);
1729
- try {
1730
- const db = getMemoryDb();
1731
- const memId = String(typedArgs.memoryId ?? "");
1732
- const action = String(typedArgs.action ?? "update");
1733
- const reason = String(typedArgs.reason ?? "");
1734
- if (action === "supersede" && memId.startsWith("sn_")) {
1735
- const origRow = db.prepare("SELECT type, keywords FROM semantic_notes WHERE note_id = ?").get(memId);
1736
- if (!origRow) {
1737
- return errorResult(`Note ${memId} not found — cannot supersede a non-existent note.`);
1850
+ // ── memory_save_procedure ───────────────────────────────────────────────
1851
+ if (name === "memory_save_procedure") {
1852
+ const typedArgs = (args ?? {});
1853
+ const threadId = resolveThreadId(typedArgs);
1854
+ try {
1855
+ const db = getMemoryDb();
1856
+ const existingId = typedArgs.procedureId;
1857
+ if (existingId) {
1858
+ updateProcedure(db, existingId, {
1859
+ description: typedArgs.description,
1860
+ steps: typedArgs.steps,
1861
+ triggerConditions: typedArgs.triggerConditions,
1862
+ });
1863
+ return {
1864
+ content: [{ type: "text", text: `Updated procedure: ${existingId}` + getReminders(threadId) }],
1865
+ };
1738
1866
  }
1739
- const newId = supersedeNote(db, memId, {
1740
- type: origRow.type,
1741
- content: String(typedArgs.newContent ?? ""),
1742
- keywords: origRow.keywords ? JSON.parse(origRow.keywords) : [],
1743
- confidence: typeof typedArgs.newConfidence === "number" ? typedArgs.newConfidence : 0.8,
1867
+ const procId = saveProcedure(db, {
1868
+ name: String(typedArgs.name ?? ""),
1869
+ type: String(typedArgs.type ?? "workflow"),
1870
+ description: String(typedArgs.description ?? ""),
1871
+ steps: typedArgs.steps,
1872
+ triggerConditions: typedArgs.triggerConditions,
1744
1873
  });
1745
1874
  return {
1746
- content: [{ type: "text", text: `Superseded ${memId} → ${newId} (reason: ${reason})` + getReminders(threadId) }],
1747
- };
1748
- }
1749
- if (memId.startsWith("sn_")) {
1750
- const updates = {};
1751
- if (typedArgs.newContent)
1752
- updates.content = String(typedArgs.newContent);
1753
- if (typeof typedArgs.newConfidence === "number")
1754
- updates.confidence = typedArgs.newConfidence;
1755
- updateSemanticNote(db, memId, updates);
1756
- return {
1757
- content: [{ type: "text", text: `Updated note ${memId} (reason: ${reason})` + getReminders(threadId) }],
1875
+ content: [{ type: "text", text: `Saved procedure: ${procId}` + getReminders(threadId) }],
1758
1876
  };
1759
1877
  }
1760
- if (memId.startsWith("pr_")) {
1761
- const updates = {};
1762
- if (typedArgs.newContent)
1763
- updates.description = String(typedArgs.newContent);
1764
- if (typeof typedArgs.newConfidence === "number")
1765
- updates.confidence = typedArgs.newConfidence;
1766
- updateProcedure(db, memId, updates);
1767
- return {
1768
- content: [{ type: "text", text: `Updated procedure ${memId} (reason: ${reason})` + getReminders(threadId) }],
1769
- };
1878
+ catch (err) {
1879
+ return { content: [{ type: "text", text: `Procedure save error: ${errorMessage(err)}` + getReminders(threadId) }] };
1770
1880
  }
1771
- return { content: [{ type: "text", text: `Unknown memory ID format: ${memId}` + getReminders(threadId) }] };
1772
1881
  }
1773
- catch (err) {
1774
- return { content: [{ type: "text", text: `Memory update error: ${errorMessage(err)}` + getReminders(threadId) }] };
1775
- }
1776
- }
1777
- // ── memory_consolidate ──────────────────────────────────────────────────
1778
- if (name === "memory_consolidate") {
1779
- const typedArgs = (args ?? {});
1780
- const threadId = resolveThreadId(typedArgs);
1781
- if (threadId === undefined) {
1782
- return { content: [{ type: "text", text: "Error: No active thread." + getReminders() }] };
1882
+ // ── memory_update ───────────────────────────────────────────────────────
1883
+ if (name === "memory_update") {
1884
+ const typedArgs = (args ?? {});
1885
+ const threadId = resolveThreadId(typedArgs);
1886
+ try {
1887
+ const db = getMemoryDb();
1888
+ const memId = String(typedArgs.memoryId ?? "");
1889
+ const action = String(typedArgs.action ?? "update");
1890
+ const reason = String(typedArgs.reason ?? "");
1891
+ if (action === "supersede" && memId.startsWith("sn_")) {
1892
+ const origRow = db.prepare("SELECT type, keywords FROM semantic_notes WHERE note_id = ?").get(memId);
1893
+ if (!origRow) {
1894
+ return errorResult(`Note ${memId} not found — cannot supersede a non-existent note.`);
1895
+ }
1896
+ const newId = supersedeNote(db, memId, {
1897
+ type: origRow.type,
1898
+ content: String(typedArgs.newContent ?? ""),
1899
+ keywords: origRow.keywords ? JSON.parse(origRow.keywords) : [],
1900
+ confidence: typeof typedArgs.newConfidence === "number" ? typedArgs.newConfidence : 0.8,
1901
+ });
1902
+ return {
1903
+ content: [{ type: "text", text: `Superseded ${memId} → ${newId} (reason: ${reason})` + getReminders(threadId) }],
1904
+ };
1905
+ }
1906
+ if (memId.startsWith("sn_")) {
1907
+ const updates = {};
1908
+ if (typedArgs.newContent)
1909
+ updates.content = String(typedArgs.newContent);
1910
+ if (typeof typedArgs.newConfidence === "number")
1911
+ updates.confidence = typedArgs.newConfidence;
1912
+ updateSemanticNote(db, memId, updates);
1913
+ return {
1914
+ content: [{ type: "text", text: `Updated note ${memId} (reason: ${reason})` + getReminders(threadId) }],
1915
+ };
1916
+ }
1917
+ if (memId.startsWith("pr_")) {
1918
+ const updates = {};
1919
+ if (typedArgs.newContent)
1920
+ updates.description = String(typedArgs.newContent);
1921
+ if (typeof typedArgs.newConfidence === "number")
1922
+ updates.confidence = typedArgs.newConfidence;
1923
+ updateProcedure(db, memId, updates);
1924
+ return {
1925
+ content: [{ type: "text", text: `Updated procedure ${memId} (reason: ${reason})` + getReminders(threadId) }],
1926
+ };
1927
+ }
1928
+ return { content: [{ type: "text", text: `Unknown memory ID format: ${memId}` + getReminders(threadId) }] };
1929
+ }
1930
+ catch (err) {
1931
+ return { content: [{ type: "text", text: `Memory update error: ${errorMessage(err)}` + getReminders(threadId) }] };
1932
+ }
1783
1933
  }
1784
- try {
1785
- const db = getMemoryDb();
1786
- const report = await runIntelligentConsolidation(db, threadId);
1787
- if (report.episodesProcessed === 0) {
1788
- return {
1789
- content: [{ type: "text", text: "No unconsolidated episodes. Memory is up to date." + getReminders(threadId) }],
1790
- };
1934
+ // ── memory_consolidate ──────────────────────────────────────────────────
1935
+ if (name === "memory_consolidate") {
1936
+ const typedArgs = (args ?? {});
1937
+ const threadId = resolveThreadId(typedArgs);
1938
+ if (threadId === undefined) {
1939
+ return { content: [{ type: "text", text: "Error: No active thread." + getReminders() }] };
1791
1940
  }
1792
- const reportLines = [
1793
- "## Consolidation Report",
1794
- `- Episodes processed: ${report.episodesProcessed}`,
1795
- `- Notes created: ${report.notesCreated}`,
1796
- `- Duration: ${report.durationMs}ms`,
1797
- ];
1798
- if (report.details.length > 0) {
1799
- reportLines.push("", "### Extracted Knowledge");
1800
- for (const d of report.details) {
1801
- reportLines.push(`- ${d}`);
1941
+ try {
1942
+ const db = getMemoryDb();
1943
+ const report = await runIntelligentConsolidation(db, threadId);
1944
+ if (report.episodesProcessed === 0) {
1945
+ return {
1946
+ content: [{ type: "text", text: "No unconsolidated episodes. Memory is up to date." + getReminders(threadId) }],
1947
+ };
1948
+ }
1949
+ const reportLines = [
1950
+ "## Consolidation Report",
1951
+ `- Episodes processed: ${report.episodesProcessed}`,
1952
+ `- Notes created: ${report.notesCreated}`,
1953
+ `- Duration: ${report.durationMs}ms`,
1954
+ ];
1955
+ if (report.details.length > 0) {
1956
+ reportLines.push("", "### Extracted Knowledge");
1957
+ for (const d of report.details) {
1958
+ reportLines.push(`- ${d}`);
1959
+ }
1802
1960
  }
1961
+ return { content: [{ type: "text", text: reportLines.join("\n") + getReminders(threadId) }] };
1962
+ }
1963
+ catch (err) {
1964
+ return { content: [{ type: "text", text: `Consolidation error: ${errorMessage(err)}` + getReminders(threadId) }] };
1803
1965
  }
1804
- return { content: [{ type: "text", text: reportLines.join("\n") + getReminders(threadId) }] };
1805
- }
1806
- catch (err) {
1807
- return { content: [{ type: "text", text: `Consolidation error: ${errorMessage(err)}` + getReminders(threadId) }] };
1808
- }
1809
- }
1810
- // ── memory_status ───────────────────────────────────────────────────────
1811
- if (name === "memory_status") {
1812
- const typedArgs = (args ?? {});
1813
- const threadId = resolveThreadId(typedArgs);
1814
- if (threadId === undefined) {
1815
- return { content: [{ type: "text", text: "Error: No active thread." + getReminders() }] };
1816
1966
  }
1817
- try {
1818
- const db = getMemoryDb();
1819
- const status = getMemoryStatus(db, threadId);
1820
- const topics = getTopicIndex(db);
1821
- const lines = [
1822
- "## Memory Status",
1823
- `- Episodes: ${status.totalEpisodes} (${status.unconsolidatedEpisodes} unconsolidated)`,
1824
- `- Semantic notes: ${status.totalSemanticNotes}`,
1825
- `- Procedures: ${status.totalProcedures}`,
1826
- `- Voice signatures: ${status.totalVoiceSignatures}`,
1827
- `- Last consolidation: ${status.lastConsolidation ?? "never"}`,
1828
- `- DB size: ${(status.dbSizeBytes / 1024).toFixed(1)} KB`,
1829
- ];
1830
- if (topics.length > 0) {
1831
- lines.push("", "**Topics:**");
1832
- for (const t of topics.slice(0, 15)) {
1833
- lines.push(`- ${t.topic} (${t.semanticCount} notes, ${t.proceduralCount} procs, conf: ${t.avgConfidence.toFixed(2)})`);
1967
+ // ── memory_status ───────────────────────────────────────────────────────
1968
+ if (name === "memory_status") {
1969
+ const typedArgs = (args ?? {});
1970
+ const threadId = resolveThreadId(typedArgs);
1971
+ if (threadId === undefined) {
1972
+ return { content: [{ type: "text", text: "Error: No active thread." + getReminders() }] };
1973
+ }
1974
+ try {
1975
+ const db = getMemoryDb();
1976
+ const status = getMemoryStatus(db, threadId);
1977
+ const topics = getTopicIndex(db);
1978
+ const lines = [
1979
+ "## Memory Status",
1980
+ `- Episodes: ${status.totalEpisodes} (${status.unconsolidatedEpisodes} unconsolidated)`,
1981
+ `- Semantic notes: ${status.totalSemanticNotes}`,
1982
+ `- Procedures: ${status.totalProcedures}`,
1983
+ `- Voice signatures: ${status.totalVoiceSignatures}`,
1984
+ `- Last consolidation: ${status.lastConsolidation ?? "never"}`,
1985
+ `- DB size: ${(status.dbSizeBytes / 1024).toFixed(1)} KB`,
1986
+ ];
1987
+ if (topics.length > 0) {
1988
+ lines.push("", "**Topics:**");
1989
+ for (const t of topics.slice(0, 15)) {
1990
+ lines.push(`- ${t.topic} (${t.semanticCount} notes, ${t.proceduralCount} procs, conf: ${t.avgConfidence.toFixed(2)})`);
1991
+ }
1834
1992
  }
1993
+ return { content: [{ type: "text", text: lines.join("\n") + getReminders(threadId) }] };
1994
+ }
1995
+ catch (err) {
1996
+ return { content: [{ type: "text", text: `Memory status error: ${errorMessage(err)}` + getReminders(threadId) }] };
1835
1997
  }
1836
- return { content: [{ type: "text", text: lines.join("\n") + getReminders(threadId) }] };
1837
- }
1838
- catch (err) {
1839
- return { content: [{ type: "text", text: `Memory status error: ${errorMessage(err)}` + getReminders(threadId) }] };
1840
1998
  }
1841
- }
1842
- // ── memory_forget ───────────────────────────────────────────────────────
1843
- if (name === "memory_forget") {
1844
- const typedArgs = (args ?? {});
1845
- const threadId = resolveThreadId(typedArgs);
1846
- try {
1847
- const db = getMemoryDb();
1848
- const memId = String(typedArgs.memoryId ?? "");
1849
- const reason = String(typedArgs.reason ?? "");
1850
- const result = forgetMemory(db, memId, reason);
1851
- if (!result.deleted) {
1999
+ // ── memory_forget ───────────────────────────────────────────────────────
2000
+ if (name === "memory_forget") {
2001
+ const typedArgs = (args ?? {});
2002
+ const threadId = resolveThreadId(typedArgs);
2003
+ try {
2004
+ const db = getMemoryDb();
2005
+ const memId = String(typedArgs.memoryId ?? "");
2006
+ const reason = String(typedArgs.reason ?? "");
2007
+ const result = forgetMemory(db, memId, reason);
2008
+ if (!result.deleted) {
2009
+ return {
2010
+ content: [{ type: "text", text: `Memory ${memId} not found (layer: ${result.layer}). Nothing was deleted.` + getReminders(threadId) }],
2011
+ };
2012
+ }
1852
2013
  return {
1853
- content: [{ type: "text", text: `Memory ${memId} not found (layer: ${result.layer}). Nothing was deleted.` + getReminders(threadId) }],
2014
+ content: [{ type: "text", text: `Forgot ${result.layer} memory ${memId} (reason: ${reason})` + getReminders(threadId) }],
1854
2015
  };
1855
2016
  }
1856
- return {
1857
- content: [{ type: "text", text: `Forgot ${result.layer} memory ${memId} (reason: ${reason})` + getReminders(threadId) }],
1858
- };
1859
- }
1860
- catch (err) {
1861
- return { content: [{ type: "text", text: `Memory forget error: ${errorMessage(err)}` + getReminders(threadId) }] };
2017
+ catch (err) {
2018
+ return { content: [{ type: "text", text: `Memory forget error: ${errorMessage(err)}` + getReminders(threadId) }] };
2019
+ }
1862
2020
  }
1863
- }
1864
- // Unknown tool
1865
- return errorResult(`Unknown tool: ${name}`);
1866
- });
2021
+ // Unknown tool
2022
+ return errorResult(`Unknown tool: ${name}`);
2023
+ });
2024
+ return srv;
2025
+ }
1867
2026
  // ---------------------------------------------------------------------------
1868
2027
  // Start the server
1869
2028
  // ---------------------------------------------------------------------------
@@ -1950,7 +2109,10 @@ if (httpPort) {
1950
2109
  if (sid)
1951
2110
  transports.delete(sid);
1952
2111
  };
1953
- await server.connect(transport);
2112
+ // Create a fresh Server per HTTP session — a single Server can only
2113
+ // connect to one transport, so concurrent clients each need their own.
2114
+ const sessionServer = createMcpServer();
2115
+ await sessionServer.connect(transport);
1954
2116
  await transport.handleRequest(req, res, body);
1955
2117
  return;
1956
2118
  }
@@ -2001,6 +2163,7 @@ if (httpPort) {
2001
2163
  else {
2002
2164
  // ── stdio transport (default) ───────────────────────────────────────────
2003
2165
  const transport = new StdioServerTransport();
2166
+ const server = createMcpServer();
2004
2167
  await server.connect(transport);
2005
2168
  process.stderr.write("Remote Copilot MCP server running on stdio.\n");
2006
2169
  // Close DB on exit for stdio mode too