sensorium-mcp 2.8.9 → 2.8.11

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