morpheus-cli 0.9.5 → 0.9.7

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.
Files changed (75) hide show
  1. package/README.md +63 -43
  2. package/dist/channels/discord.js +71 -21
  3. package/dist/channels/telegram.js +73 -19
  4. package/dist/cli/commands/restart.js +15 -0
  5. package/dist/cli/commands/start.js +18 -0
  6. package/dist/config/manager.js +61 -0
  7. package/dist/config/paths.js +1 -0
  8. package/dist/config/schemas.js +11 -3
  9. package/dist/http/api.js +3 -0
  10. package/dist/http/routers/link.js +239 -0
  11. package/dist/http/routers/skills.js +1 -8
  12. package/dist/runtime/apoc.js +1 -1
  13. package/dist/runtime/audit/repository.js +1 -1
  14. package/dist/runtime/link-chunker.js +214 -0
  15. package/dist/runtime/link-repository.js +301 -0
  16. package/dist/runtime/link-search.js +298 -0
  17. package/dist/runtime/link-worker.js +284 -0
  18. package/dist/runtime/link.js +295 -0
  19. package/dist/runtime/memory/sati/service.js +1 -1
  20. package/dist/runtime/memory/sqlite.js +52 -0
  21. package/dist/runtime/neo.js +1 -1
  22. package/dist/runtime/oracle.js +81 -44
  23. package/dist/runtime/scaffold.js +4 -17
  24. package/dist/runtime/skills/__tests__/loader.test.js +7 -10
  25. package/dist/runtime/skills/__tests__/registry.test.js +2 -18
  26. package/dist/runtime/skills/__tests__/tool.test.js +55 -224
  27. package/dist/runtime/skills/index.js +1 -2
  28. package/dist/runtime/skills/loader.js +0 -2
  29. package/dist/runtime/skills/registry.js +8 -20
  30. package/dist/runtime/skills/schema.js +0 -4
  31. package/dist/runtime/skills/tool.js +42 -209
  32. package/dist/runtime/smiths/delegator.js +1 -1
  33. package/dist/runtime/smiths/registry.js +1 -1
  34. package/dist/runtime/tasks/worker.js +12 -44
  35. package/dist/runtime/trinity.js +1 -1
  36. package/dist/types/config.js +14 -0
  37. package/dist/ui/assets/AuditDashboard-93LCGHG1.js +1 -0
  38. package/dist/ui/assets/{Chat-BNtutgja.js → Chat-CK5sNcQ1.js} +8 -8
  39. package/dist/ui/assets/{Chronos-3C8RPZcl.js → Chronos-m2h--GEe.js} +1 -1
  40. package/dist/ui/assets/{ConfirmationModal-ZQPBeJ2Z.js → ConfirmationModal-Dd5pUJme.js} +1 -1
  41. package/dist/ui/assets/{Dashboard-CqkHzr2F.js → Dashboard-ODwl7d-a.js} +1 -1
  42. package/dist/ui/assets/{DeleteConfirmationModal-CioxFWn_.js → DeleteConfirmationModal-CCcojDmr.js} +1 -1
  43. package/dist/ui/assets/Documents-dWnSoxFO.js +7 -0
  44. package/dist/ui/assets/{Logs-DBVanS0O.js → Logs-Dc9Z2LBj.js} +1 -1
  45. package/dist/ui/assets/{MCPManager-vXfL3P2U.js → MCPManager-CMkb8vMn.js} +1 -1
  46. package/dist/ui/assets/{ModelPricing-DyfdunLT.js → ModelPricing-DtHPPbEQ.js} +1 -1
  47. package/dist/ui/assets/{Notifications-VL-vep6d.js → Notifications-BPvo-DWP.js} +1 -1
  48. package/dist/ui/assets/{Pagination-oTGieBLM.js → Pagination-BHZKk42X.js} +1 -1
  49. package/dist/ui/assets/{SatiMemories-jaadkW0U.js → SatiMemories-BUPu1Lxr.js} +1 -1
  50. package/dist/ui/assets/SessionAudit-CFKF4DA8.js +9 -0
  51. package/dist/ui/assets/Settings-C4JrXfsR.js +47 -0
  52. package/dist/ui/assets/{Skills-DE3zziXL.js → Skills-BUlvJgJ4.js} +1 -1
  53. package/dist/ui/assets/{Smiths-pmogN1mU.js → Smiths-CDtJdY0I.js} +1 -1
  54. package/dist/ui/assets/{Tasks-Bs8s34Jc.js → Tasks-DK_cOsNK.js} +1 -1
  55. package/dist/ui/assets/{TrinityDatabases-D7uihcdp.js → TrinityDatabases-X07by-19.js} +1 -1
  56. package/dist/ui/assets/{UsageStats-B9gePLZ0.js → UsageStats-dYcgckLq.js} +1 -1
  57. package/dist/ui/assets/{WebhookManager-B2L3rCLM.js → WebhookManager-DDw5eX2R.js} +1 -1
  58. package/dist/ui/assets/{audit-Cggeu9mM.js → audit-DZ5WLUEm.js} +1 -1
  59. package/dist/ui/assets/{chronos-D3-sWhfU.js → chronos-B_HI4mlq.js} +1 -1
  60. package/dist/ui/assets/{config-CBqRUPgn.js → config-B-YxlVrc.js} +1 -1
  61. package/dist/ui/assets/index-DVjwJ8jT.css +1 -0
  62. package/dist/ui/assets/{index-zKplfrXZ.js → index-DfJwcKqG.js} +5 -5
  63. package/dist/ui/assets/{mcp-uL1R9hyA.js → mcp-k-_pwbqA.js} +1 -1
  64. package/dist/ui/assets/{skills-jmw8yTJs.js → skills-xMXangks.js} +1 -1
  65. package/dist/ui/assets/{stats-HOms6GnM.js → stats-C4QZIv5O.js} +1 -1
  66. package/dist/ui/assets/{vendor-icons-DMd9RGvJ.js → vendor-icons-NHF9HNeN.js} +1 -1
  67. package/dist/ui/index.html +3 -3
  68. package/dist/ui/sw.js +1 -1
  69. package/package.json +3 -1
  70. package/dist/runtime/__tests__/keymaker.test.js +0 -148
  71. package/dist/runtime/keymaker.js +0 -157
  72. package/dist/ui/assets/AuditDashboard-DliJ1CX0.js +0 -1
  73. package/dist/ui/assets/SessionAudit-BsXrWlwz.js +0 -9
  74. package/dist/ui/assets/Settings-B4eezRcg.js +0 -47
  75. package/dist/ui/assets/index-D4fzIKy1.css +0 -1
package/README.md CHANGED
@@ -22,8 +22,8 @@ It runs as a daemon and orchestrates LLMs, MCP tools, DevKit tools, memory, and
22
22
  - `Apoc`: DevTools/browser execution (filesystem, shell, git, network, packages, processes, system, browser automation).
23
23
  - `Sati`: long-term memory retrieval/evaluation.
24
24
  - `Trinity`: database specialist. Executes queries, introspects schemas, and manages registered databases (PostgreSQL, MySQL, SQLite, MongoDB).
25
+ - `Link`: documentation specialist. RAG over indexed user documents (PDF, Markdown, TXT, DOCX) with hybrid vector + keyword search.
25
26
  - `Chronos`: temporal scheduler. Runs Oracle prompts on a recurring or one-time schedule.
26
- - `Keymaker`: skill executor. Runs user-defined skills with full tool access (DevKit + MCP + internal tools).
27
27
  - `Smith`: remote DevKit executor. Runs DevKit operations on isolated machines (Docker, VMs, cloud) via WebSocket.
28
28
 
29
29
  ## Installation
@@ -510,14 +510,15 @@ Precedence order:
510
510
 
511
511
  ## Skills
512
512
 
513
- Skills are user-defined capabilities that extend Morpheus. Each skill is a folder in `~/.morpheus/skills/` containing a single `SKILL.md` file with YAML frontmatter for metadata.
513
+ Skills are user-defined instruction templates that extend Morpheus capabilities. Each skill is a folder in `~/.morpheus/skills/` containing a single `SKILL.md` file with YAML frontmatter for metadata.
514
514
 
515
- ### Execution Modes
515
+ ### How Skills Work
516
516
 
517
- Skills support two execution modes:
518
-
519
- - **`sync`** (default) - Executes immediately via `skill_execute`, returns result inline
520
- - **`async`** - Runs as background task via `skill_delegate`, notifies when complete
517
+ Skills are **instruction templates** loaded into Oracle's context on-demand:
518
+ 1. User request matches a skill's examples/description
519
+ 2. Oracle calls `load_skill` tool with the skill name
520
+ 3. Tool returns the skill's full content (SKILL.md)
521
+ 4. Oracle follows the instructions using its existing tools (delegation, MCP, etc.)
521
522
 
522
523
  ### Creating a Skill
523
524
 
@@ -533,7 +534,6 @@ description: Brief description of what this skill does
533
534
  version: 1.0.0
534
535
  author: your-name
535
536
  enabled: true
536
- execution_mode: sync
537
537
  tags:
538
538
  - automation
539
539
  - example
@@ -546,44 +546,17 @@ examples:
546
546
  You are an expert at [domain]. Follow these instructions...
547
547
 
548
548
  ## Your Task
549
- [Instructions for Keymaker to execute]
550
- ```
551
-
552
- ### Async Skill Example
553
-
554
- For long-running tasks like deployments or builds:
555
-
556
- ```markdown
557
- ---
558
- name: deploy-staging
559
- description: Deploy application to staging environment
560
- execution_mode: async
561
- tags:
562
- - deployment
563
- - devops
564
- ---
565
-
566
- # Deploy to Staging
567
-
568
- Execute a full deployment to the staging environment...
549
+ [Instructions for Oracle to execute]
569
550
  ```
570
551
 
571
552
  ### Using Skills
572
553
 
573
- Once a skill is loaded, Oracle will automatically suggest delegating matching tasks to Keymaker:
554
+ Once a skill is loaded, Oracle follows the instructions using its available tools:
574
555
 
575
- **Sync Skills (immediate result):**
576
556
  ```
577
557
  User: "Review the code in src/auth.ts"
578
- Oracle: [executes code-reviewer skill via skill_execute]
579
- Keymaker: [returns detailed review immediately]
580
- ```
581
-
582
- **Async Skills (background task):**
583
- ```
584
- User: "Deploy to staging"
585
- Oracle: [delegates via skill_delegate, task queued]
586
- Morpheus: [notifies via Telegram/Discord when complete]
558
+ Oracle: [loads code-reviewer skill via load_skill]
559
+ Oracle: [executes review using its tools and returns result]
587
560
  ```
588
561
 
589
562
  ### Managing Skills
@@ -617,15 +590,61 @@ POST /api/skills/:name/disable - Disable skill
617
590
  ### Sample Skills
618
591
 
619
592
  Example skills are available in `examples/skills/`:
620
- - `code-reviewer` - Reviews code for issues and best practices (sync)
621
- - `git-helper` - Assists with Git operations (sync)
622
- - `deploy-staging` - Deploy to staging environment (async)
593
+ - `code-reviewer` - Reviews code for issues and best practices
594
+ - `git-helper` - Assists with Git operations
623
595
 
624
596
  Copy them to your skills directory:
625
597
  ```bash
626
598
  cp -r examples/skills/* ~/.morpheus/skills/
627
599
  ```
628
600
 
601
+ ## Link — Document RAG
602
+
603
+ Link is a documentation specialist subagent that provides RAG (Retrieval-Augmented Generation) over user documents.
604
+
605
+ **Supported formats:** PDF, Markdown, TXT, DOCX
606
+
607
+ **Document storage:** `~/.morpheus/docs/`
608
+
609
+ **Embedding database:** `~/.morpheus/memory/link.db` (SQLite with sqlite-vec)
610
+
611
+ ### Using Link
612
+
613
+ Oracle automatically delegates to Link when users ask about their documents:
614
+
615
+ ```
616
+ User: "What does my contract say about termination?"
617
+ Oracle: [delegates to Link via link_delegate]
618
+ Link: [searches indexed documents and returns answer with citations]
619
+ ```
620
+
621
+ ### Managing Documents
622
+
623
+ **Web UI:** Navigate to `/documents` to upload, delete, and reindex documents.
624
+
625
+ **API Endpoints:**
626
+ ```
627
+ GET /api/link/documents - List all documents
628
+ POST /api/link/upload - Upload a document
629
+ DELETE /api/link/documents/:id - Delete a document
630
+ POST /api/link/documents/:id/reindex - Reindex a document
631
+ ```
632
+
633
+ ### Configuration
634
+
635
+ ```yaml
636
+ link:
637
+ provider: openai
638
+ model: gpt-4o-mini
639
+ temperature: 0.2
640
+ personality: documentation_specialist
641
+ execution_mode: async # 'sync' = inline, 'async' = background task
642
+ max_results: 10 # max search results per query
643
+ score_threshold: 0.5 # minimum similarity score (0-1)
644
+ chunk_size: 1000 # characters per chunk
645
+ chunk_overlap: 200 # overlap between chunks
646
+ ```
647
+
629
648
  ## MCP Configuration
630
649
 
631
650
  Configure MCP servers in `~/.morpheus/mcps.json`.
@@ -655,9 +674,10 @@ Authenticated endpoints (`x-architect-pass`):
655
674
  - Sessions: `/api/sessions*`
656
675
  - Chat: `POST /api/chat`
657
676
  - Tasks: `GET /api/tasks`, `GET /api/tasks/stats`, `GET /api/tasks/:id`, `POST /api/tasks/:id/retry`
658
- - Config: `/api/config`, `/api/config/sati`, `/api/config/neo`, `/api/config/apoc`, `/api/config/trinity`, `/api/config/chronos`, `/api/config/smiths`
677
+ - Config: `/api/config`, `/api/config/sati`, `/api/config/neo`, `/api/config/apoc`, `/api/config/trinity`, `/api/config/link`, `/api/config/chronos`, `/api/config/smiths`
659
678
  - MCP: `/api/mcp/*` (servers CRUD + reload + status)
660
679
  - Sati memories: `/api/sati/memories*`
680
+ - Link documents: `/api/link/documents`, `/api/link/upload`, `/api/link/documents/:id/reindex`
661
681
  - Trinity databases: `GET/POST/PUT/DELETE /api/trinity/databases`, `POST /api/trinity/databases/:id/test`, `POST /api/trinity/databases/:id/refresh-schema`
662
682
  - Chronos: `GET/POST /api/chronos`, `GET/PUT/DELETE /api/chronos/:id`, `PATCH /api/chronos/:id/enable`, `PATCH /api/chronos/:id/disable`, `GET /api/chronos/:id/executions`, `POST /api/chronos/preview`
663
683
  - Smiths: `GET /api/smiths`, `GET/PUT /api/smiths/config`, `GET/DELETE /api/smiths/:name`, `POST /api/smiths/:name/ping`
@@ -112,8 +112,8 @@ export class DiscordAdapter {
112
112
  display = DisplayManager.getInstance();
113
113
  config = ConfigManager.getInstance();
114
114
  history = new SQLiteChatMessageHistory({ sessionId: '' });
115
- /** Per-channel session tracking — which session this Discord adapter is currently using */
116
- currentSessionId = null;
115
+ /** Per-user session tracking — maps userId to sessionId */
116
+ userSessions = new Map();
117
117
  telephonist = null;
118
118
  telephonistProvider = null;
119
119
  telephonistModel = null;
@@ -189,8 +189,7 @@ export class DiscordAdapter {
189
189
  return;
190
190
  this.display.log(`${message.author.tag}: ${text}`, { source: 'Discord' });
191
191
  try {
192
- const sessionId = this.currentSessionId ?? await this.history.getCurrentSessionOrCreate();
193
- this.currentSessionId = sessionId;
192
+ const sessionId = await this.getSessionForUser(userId);
194
193
  const response = await this.oracle.chat(text, undefined, false, {
195
194
  origin_channel: 'discord',
196
195
  session_id: sessionId,
@@ -303,8 +302,7 @@ export class DiscordAdapter {
303
302
  // Show transcription
304
303
  await channel.send(`🎤 "${text}"`);
305
304
  // Process with Oracle
306
- const sessionId = this.currentSessionId ?? await this.history.getCurrentSessionOrCreate();
307
- this.currentSessionId = sessionId;
305
+ const sessionId = await this.getSessionForUser(userId);
308
306
  const response = await this.oracle.chat(text, usage, true, {
309
307
  origin_channel: 'discord',
310
308
  session_id: sessionId,
@@ -495,8 +493,10 @@ export class DiscordAdapter {
495
493
  const history = new SQLiteChatMessageHistory({ sessionId: '' });
496
494
  const newSessionId = await history.createNewSession();
497
495
  history.close();
498
- // Track the new session as the current one for this Discord channel
499
- this.currentSessionId = newSessionId;
496
+ // Track the new session as the current one for this user
497
+ const userId = interaction.user.id;
498
+ await this.history.setUserChannelSession(this.channel, userId, newSessionId);
499
+ this.userSessions.set(userId, newSessionId);
500
500
  await interaction.reply({ content: '✅ New session started.' });
501
501
  }
502
502
  catch (err) {
@@ -506,6 +506,13 @@ export class DiscordAdapter {
506
506
  async cmdSessions(interaction) {
507
507
  try {
508
508
  const history = new SQLiteChatMessageHistory({ sessionId: '' });
509
+ const userId = interaction.user.id;
510
+ // Get user's current session (string | undefined)
511
+ let userCurrentSession = this.userSessions.get(userId) || undefined;
512
+ if (userCurrentSession === undefined) {
513
+ const fromDb = await this.history.getUserChannelSession(this.channel, userId);
514
+ userCurrentSession = fromDb ?? undefined;
515
+ }
509
516
  const sessions = (await history.listSessions()).filter((s) => !s.id.startsWith('chronos-job-') && !s.id.startsWith('sati-evaluation'));
510
517
  history.close();
511
518
  if (sessions.length === 0) {
@@ -516,7 +523,7 @@ export class DiscordAdapter {
516
523
  const rows = [];
517
524
  for (const session of sessions) {
518
525
  const title = session.title || 'Untitled Session';
519
- const isCurrent = session.id === this.currentSessionId;
526
+ const isCurrent = session.id === userCurrentSession;
520
527
  const icon = isCurrent ? '🟢' : '⚪';
521
528
  const started = new Date(session.started_at).toLocaleString();
522
529
  lines.push(`${icon} **${title}**`);
@@ -554,12 +561,14 @@ export class DiscordAdapter {
554
561
  });
555
562
  collector.on('collect', async (btn) => {
556
563
  try {
564
+ const uid = btn.user.id;
557
565
  if (btn.customId.startsWith('session_switch_')) {
558
566
  const sid = btn.customId.replace('session_switch_', '');
559
567
  const h = new SQLiteChatMessageHistory({ sessionId: '' });
560
568
  await h.switchSession(sid);
561
569
  h.close();
562
- this.currentSessionId = sid;
570
+ await this.history.setUserChannelSession(this.channel, uid, sid);
571
+ this.userSessions.set(uid, sid);
563
572
  await btn.reply({ content: `✅ Switched to session \`${sid}\`.`, ephemeral: true });
564
573
  }
565
574
  else if (btn.customId.startsWith('session_archive_')) {
@@ -567,8 +576,12 @@ export class DiscordAdapter {
567
576
  const h = new SQLiteChatMessageHistory({ sessionId: '' });
568
577
  await h.archiveSession(sid);
569
578
  h.close();
570
- if (this.currentSessionId === sid)
571
- this.currentSessionId = null;
579
+ // Remove user-channel mapping if this was their current session
580
+ const current = this.userSessions.get(uid);
581
+ if (current === sid) {
582
+ await this.history.deleteUserChannelSession(this.channel, uid);
583
+ this.userSessions.delete(uid);
584
+ }
572
585
  await btn.reply({ content: `📂 Session \`${sid}\` archived.`, ephemeral: true });
573
586
  }
574
587
  else if (btn.customId.startsWith('session_delete_')) {
@@ -576,8 +589,12 @@ export class DiscordAdapter {
576
589
  const h = new SQLiteChatMessageHistory({ sessionId: '' });
577
590
  await h.deleteSession(sid);
578
591
  h.close();
579
- if (this.currentSessionId === sid)
580
- this.currentSessionId = null;
592
+ // Remove user-channel mapping if this was their current session
593
+ const current = this.userSessions.get(uid);
594
+ if (current === sid) {
595
+ await this.history.deleteUserChannelSession(this.channel, uid);
596
+ this.userSessions.delete(uid);
597
+ }
581
598
  await btn.reply({ content: `🗑️ Session \`${sid}\` deleted.`, ephemeral: true });
582
599
  }
583
600
  }
@@ -602,7 +619,9 @@ export class DiscordAdapter {
602
619
  const history = new SQLiteChatMessageHistory({ sessionId: '' });
603
620
  await history.switchSession(sessionId);
604
621
  history.close();
605
- this.currentSessionId = sessionId;
622
+ const userId = interaction.user.id;
623
+ await this.history.setUserChannelSession(this.channel, userId, sessionId);
624
+ this.userSessions.set(userId, sessionId);
606
625
  await interaction.reply({ content: `✅ Switched to session \`${sessionId}\`.` });
607
626
  }
608
627
  catch (err) {
@@ -773,10 +792,9 @@ export class DiscordAdapter {
773
792
  }
774
793
  async cmdSkillReload(interaction) {
775
794
  try {
776
- const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
795
+ const { SkillRegistry } = await import('../runtime/skills/index.js');
777
796
  const registry = SkillRegistry.getInstance();
778
797
  const result = await registry.reload();
779
- updateSkillDelegateDescription();
780
798
  const msg = result.errors.length > 0
781
799
  ? `Reloaded ${result.skills.length} skills with ${result.errors.length} error(s).`
782
800
  : `Reloaded ${result.skills.length} skill(s).`;
@@ -789,14 +807,13 @@ export class DiscordAdapter {
789
807
  async cmdSkillEnable(interaction) {
790
808
  const name = interaction.options.getString('name', true);
791
809
  try {
792
- const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
810
+ const { SkillRegistry } = await import('../runtime/skills/index.js');
793
811
  const registry = SkillRegistry.getInstance();
794
812
  const success = registry.enable(name);
795
813
  if (!success) {
796
814
  await interaction.reply({ content: `Skill "${name}" not found.` });
797
815
  return;
798
816
  }
799
- updateSkillDelegateDescription();
800
817
  await interaction.reply({ content: `Skill \`${name}\` enabled.` });
801
818
  }
802
819
  catch (err) {
@@ -806,14 +823,13 @@ export class DiscordAdapter {
806
823
  async cmdSkillDisable(interaction) {
807
824
  const name = interaction.options.getString('name', true);
808
825
  try {
809
- const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
826
+ const { SkillRegistry } = await import('../runtime/skills/index.js');
810
827
  const registry = SkillRegistry.getInstance();
811
828
  const success = registry.disable(name);
812
829
  if (!success) {
813
830
  await interaction.reply({ content: `Skill "${name}" not found.` });
814
831
  return;
815
832
  }
816
- updateSkillDelegateDescription();
817
833
  await interaction.reply({ content: `Skill \`${name}\` disabled.` });
818
834
  }
819
835
  catch (err) {
@@ -898,6 +914,40 @@ export class DiscordAdapter {
898
914
  isAuthorized(userId) {
899
915
  return this.allowedUsers.includes(userId);
900
916
  }
917
+ /**
918
+ * Gets or creates a session for a specific user.
919
+ * Uses triple fallback: memory → DB → global session.
920
+ */
921
+ async getSessionForUser(userId) {
922
+ // 1. Try memory
923
+ const fromMemory = this.userSessions.get(userId);
924
+ if (fromMemory)
925
+ return fromMemory;
926
+ // 2. Try DB
927
+ const fromDb = await this.history.getUserChannelSession(this.channel, userId);
928
+ if (fromDb !== null) {
929
+ this.userSessions.set(userId, fromDb);
930
+ return fromDb;
931
+ }
932
+ // 3. Create/use global session
933
+ const newSessionId = await this.history.getCurrentSessionOrCreate();
934
+ await this.history.setUserChannelSession(this.channel, userId, newSessionId);
935
+ this.userSessions.set(userId, newSessionId);
936
+ return newSessionId;
937
+ }
938
+ /**
939
+ * Restores user sessions from DB on startup.
940
+ * Reads user_channel_sessions table and populates userSessions Map.
941
+ */
942
+ async restoreUserSessions() {
943
+ const rows = await this.history.listUserChannelSessions(this.channel);
944
+ for (const row of rows) {
945
+ this.userSessions.set(row.userId, row.sessionId);
946
+ }
947
+ if (rows.length > 0) {
948
+ this.display.log(`✓ Restored ${rows.length} user session(s) from database`, { source: 'Discord', level: 'info' });
949
+ }
950
+ }
901
951
  isRateLimited(userId) {
902
952
  const now = Date.now();
903
953
  const last = this.rateLimitMap.get(userId);
@@ -144,8 +144,8 @@ export class TelegramAdapter {
144
144
  telephonistProvider = null;
145
145
  telephonistModel = null;
146
146
  history = new SQLiteChatMessageHistory({ sessionId: '' });
147
- /** Per-channel session tracking — which session this Telegram adapter is currently using */
148
- currentSessionId = null;
147
+ /** Per-user session tracking — maps userId to sessionId */
148
+ userSessions = new Map();
149
149
  RATE_LIMIT_MS = 3000; // minimum ms between requests per user
150
150
  rateLimiter = new Map(); // userId -> last request timestamp
151
151
  // Pending Chronos create confirmations (userId -> job data + expiry)
@@ -232,8 +232,7 @@ export class TelegramAdapter {
232
232
  try {
233
233
  // Send "typing" status
234
234
  await ctx.sendChatAction('typing');
235
- const sessionId = this.currentSessionId ?? await this.history.getCurrentSessionOrCreate();
236
- this.currentSessionId = sessionId;
235
+ const sessionId = await this.getSessionForUser(userId);
237
236
  // Process with Agent
238
237
  const response = await this.oracle.chat(text, undefined, false, {
239
238
  origin_channel: 'telegram',
@@ -319,7 +318,7 @@ export class TelegramAdapter {
319
318
  this.display.log(`Transcription success for @${user}: "${text}"`, { source: 'Telephonist', level: 'success' });
320
319
  // Audit: record telephonist execution
321
320
  try {
322
- const auditSessionId = this.currentSessionId ?? await this.history.getCurrentSessionOrCreate();
321
+ const auditSessionId = await this.getSessionForUser(userId);
323
322
  AuditRepository.getInstance().insert({
324
323
  session_id: auditSessionId,
325
324
  event_type: 'telephonist',
@@ -347,8 +346,7 @@ export class TelegramAdapter {
347
346
  // So I should treat 'text' as if it was a text message.
348
347
  await ctx.reply(`🎤 Transcription: "${text}"`);
349
348
  await ctx.sendChatAction('typing');
350
- const sessionId = this.currentSessionId ?? await this.history.getCurrentSessionOrCreate();
351
- this.currentSessionId = sessionId;
349
+ const sessionId = await this.getSessionForUser(userId);
352
350
  // Process with Agent
353
351
  const response = await this.oracle.chat(text, usage, true, {
354
352
  origin_channel: 'telegram',
@@ -382,7 +380,7 @@ export class TelegramAdapter {
382
380
  const detail = error?.cause?.message || error?.response?.data?.error?.message || error.message;
383
381
  this.display.log(`Audio processing error for @${user}: ${detail}`, { source: 'Telephonist', level: 'error' });
384
382
  try {
385
- const auditSessionId = this.currentSessionId ?? 'default';
383
+ const auditSessionId = await this.getSessionForUser(userId);
386
384
  AuditRepository.getInstance().insert({
387
385
  session_id: auditSessionId,
388
386
  event_type: 'telephonist',
@@ -425,6 +423,7 @@ export class TelegramAdapter {
425
423
  const callbackQuery = ctx.callbackQuery;
426
424
  const data = callbackQuery && 'data' in callbackQuery ? callbackQuery.data : undefined;
427
425
  const sessionId = typeof data === 'string' ? data.replace('switch_session_', '') : '';
426
+ const userId = ctx.from.id.toString();
428
427
  if (!sessionId || sessionId === '') {
429
428
  await ctx.answerCbQuery('Invalid session ID');
430
429
  return;
@@ -434,8 +433,9 @@ export class TelegramAdapter {
434
433
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
435
434
  await history.switchSession(sessionId);
436
435
  history.close();
437
- // Track this session as the current one for this Telegram channel
438
- this.currentSessionId = sessionId;
436
+ // Update user-channel session mapping
437
+ await this.history.setUserChannelSession(this.channel, userId, sessionId);
438
+ this.userSessions.set(userId, sessionId);
439
439
  await ctx.answerCbQuery();
440
440
  // Remove the previous message and send confirmation
441
441
  if (ctx.updateType === 'callback_query') {
@@ -468,9 +468,16 @@ export class TelegramAdapter {
468
468
  this.bot.action(/^confirm_archive_session_/, async (ctx) => {
469
469
  const data = ctx.callbackQuery.data;
470
470
  const sessionId = data.replace('confirm_archive_session_', '');
471
+ const userId = ctx.from.id.toString();
471
472
  try {
472
473
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
473
474
  await history.archiveSession(sessionId);
475
+ // Remove user-channel session mapping if this was their current session
476
+ const currentSession = this.userSessions.get(userId);
477
+ if (currentSession === sessionId) {
478
+ await this.history.deleteUserChannelSession(this.channel, userId);
479
+ this.userSessions.delete(userId);
480
+ }
474
481
  await ctx.answerCbQuery('Session archived successfully');
475
482
  if (ctx.updateType === 'callback_query') {
476
483
  ctx.deleteMessage().catch(() => { });
@@ -501,9 +508,16 @@ export class TelegramAdapter {
501
508
  this.bot.action(/^confirm_delete_session_/, async (ctx) => {
502
509
  const data = ctx.callbackQuery.data;
503
510
  const sessionId = data.replace('confirm_delete_session_', '');
511
+ const userId = ctx.from.id.toString();
504
512
  try {
505
513
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
506
514
  await history.deleteSession(sessionId);
515
+ // Remove user-channel session mapping if this was their current session
516
+ const currentSession = this.userSessions.get(userId);
517
+ if (currentSession === sessionId) {
518
+ await this.history.deleteUserChannelSession(this.channel, userId);
519
+ this.userSessions.delete(userId);
520
+ }
507
521
  await ctx.answerCbQuery('Session deleted successfully');
508
522
  if (ctx.updateType === 'callback_query') {
509
523
  ctx.deleteMessage().catch(() => { });
@@ -690,6 +704,40 @@ export class TelegramAdapter {
690
704
  isAuthorized(userId, allowedUsers) {
691
705
  return allowedUsers.includes(userId);
692
706
  }
707
+ /**
708
+ * Gets or creates a session for a specific user.
709
+ * Uses triple fallback: memory → DB → global session.
710
+ */
711
+ async getSessionForUser(userId) {
712
+ // 1. Try memory
713
+ const fromMemory = this.userSessions.get(userId);
714
+ if (fromMemory)
715
+ return fromMemory;
716
+ // 2. Try DB
717
+ const fromDb = await this.history.getUserChannelSession(this.channel, userId);
718
+ if (fromDb !== null) {
719
+ this.userSessions.set(userId, fromDb);
720
+ return fromDb;
721
+ }
722
+ // 3. Create/use global session
723
+ const newSessionId = await this.history.getCurrentSessionOrCreate();
724
+ await this.history.setUserChannelSession(this.channel, userId, newSessionId);
725
+ this.userSessions.set(userId, newSessionId);
726
+ return newSessionId;
727
+ }
728
+ /**
729
+ * Restores user sessions from DB on startup.
730
+ * Reads user_channel_sessions table and populates userSessions Map.
731
+ */
732
+ async restoreUserSessions() {
733
+ const rows = await this.history.listUserChannelSessions(this.channel);
734
+ for (const row of rows) {
735
+ this.userSessions.set(row.userId, row.sessionId);
736
+ }
737
+ if (rows.length > 0) {
738
+ this.display.log(`✓ Restored ${rows.length} user session(s) from database`, { source: 'Telegram', level: 'info' });
739
+ }
740
+ }
693
741
  async downloadToTemp(url, extension = '.ogg') {
694
742
  const response = await fetch(url);
695
743
  if (!response.ok)
@@ -1087,10 +1135,9 @@ export class TelegramAdapter {
1087
1135
  }
1088
1136
  async handleSkillsReload(ctx) {
1089
1137
  try {
1090
- const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
1138
+ const { SkillRegistry } = await import('../runtime/skills/index.js');
1091
1139
  const registry = SkillRegistry.getInstance();
1092
1140
  const result = await registry.reload();
1093
- updateSkillDelegateDescription();
1094
1141
  const msg = result.errors.length > 0
1095
1142
  ? `Reloaded ${result.skills.length} skills with ${result.errors.length} error(s).`
1096
1143
  : `Reloaded ${result.skills.length} skill(s).`;
@@ -1106,14 +1153,13 @@ export class TelegramAdapter {
1106
1153
  return;
1107
1154
  }
1108
1155
  try {
1109
- const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
1156
+ const { SkillRegistry } = await import('../runtime/skills/index.js');
1110
1157
  const registry = SkillRegistry.getInstance();
1111
1158
  const success = registry.enable(name);
1112
1159
  if (!success) {
1113
1160
  await ctx.reply(`Skill "${name}" not found.`);
1114
1161
  return;
1115
1162
  }
1116
- updateSkillDelegateDescription();
1117
1163
  await ctx.reply(`Skill \`${name}\` enabled.`, { parse_mode: 'Markdown' });
1118
1164
  }
1119
1165
  catch (err) {
@@ -1126,14 +1172,13 @@ export class TelegramAdapter {
1126
1172
  return;
1127
1173
  }
1128
1174
  try {
1129
- const { SkillRegistry, updateSkillDelegateDescription } = await import('../runtime/skills/index.js');
1175
+ const { SkillRegistry } = await import('../runtime/skills/index.js');
1130
1176
  const registry = SkillRegistry.getInstance();
1131
1177
  const success = registry.disable(name);
1132
1178
  if (!success) {
1133
1179
  await ctx.reply(`Skill "${name}" not found.`);
1134
1180
  return;
1135
1181
  }
1136
- updateSkillDelegateDescription();
1137
1182
  await ctx.reply(`Skill \`${name}\` disabled.`, { parse_mode: 'Markdown' });
1138
1183
  }
1139
1184
  catch (err) {
@@ -1160,8 +1205,10 @@ export class TelegramAdapter {
1160
1205
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
1161
1206
  const newSessionId = await history.createNewSession();
1162
1207
  history.close();
1163
- // Track the new session as the current one for this Telegram channel
1164
- this.currentSessionId = newSessionId;
1208
+ // Track the new session as the current one for this user
1209
+ const userId = ctx.from.id.toString();
1210
+ await this.history.setUserChannelSession(this.channel, userId, newSessionId);
1211
+ this.userSessions.set(userId, newSessionId);
1165
1212
  }
1166
1213
  catch (e) {
1167
1214
  await ctx.reply(`Error creating new session: ${e.message}`);
@@ -1170,6 +1217,13 @@ export class TelegramAdapter {
1170
1217
  async handleSessionStatusCommand(ctx, user) {
1171
1218
  try {
1172
1219
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
1220
+ const userId = ctx.from.id.toString();
1221
+ // Get user's current session (string | undefined)
1222
+ let userCurrentSession = this.userSessions.get(userId) || undefined;
1223
+ if (userCurrentSession === undefined) {
1224
+ const fromDb = await this.history.getUserChannelSession(this.channel, userId);
1225
+ userCurrentSession = fromDb ?? undefined;
1226
+ }
1173
1227
  // Exclude automated Chronos sessions — their IDs exceed Telegram's 64-byte
1174
1228
  // callback_data limit and they are not user-managed sessions.
1175
1229
  const sessions = (await history.listSessions()).filter((s) => !s.id.startsWith('chronos-job-') && !s.id.startsWith('sati-evaluation'));
@@ -1181,7 +1235,7 @@ export class TelegramAdapter {
1181
1235
  const keyboard = [];
1182
1236
  for (const session of sessions) {
1183
1237
  const title = session.title || 'Untitled Session';
1184
- const isCurrent = session.id === this.currentSessionId;
1238
+ const isCurrent = session.id === userCurrentSession;
1185
1239
  const statusEmoji = isCurrent ? '🟢' : '⚪';
1186
1240
  response += `${statusEmoji} *${escMdRaw(title)}*\n`;
1187
1241
  response += `\\- ID: \`${escMdRaw(session.id)}\`\n`;
@@ -15,6 +15,8 @@ import { HttpServer } from '../../http/server.js';
15
15
  import { getVersion } from '../utils/version.js';
16
16
  import { TaskWorker } from '../../runtime/tasks/worker.js';
17
17
  import { TaskNotifier } from '../../runtime/tasks/notifier.js';
18
+ import { Link } from '../../runtime/link.js';
19
+ import { LinkWorker } from '../../runtime/link-worker.js';
18
20
  export const restartCommand = new Command('restart')
19
21
  .description('Restart the Morpheus agent')
20
22
  .option('--ui', 'Enable web UI', true)
@@ -97,6 +99,15 @@ export const restartCommand = new Command('restart')
97
99
  await clearPid();
98
100
  process.exit(1);
99
101
  }
102
+ // Initialize Link (Documentation Specialist)
103
+ try {
104
+ const link = Link.getInstance(config);
105
+ await link.initialize();
106
+ display.log(chalk.green('✓ Link initialized'), { source: 'Link' });
107
+ }
108
+ catch (err) {
109
+ display.log(chalk.yellow(`Link initialization warning: ${err.message}`), { source: 'Link' });
110
+ }
100
111
  const adapters = [];
101
112
  let httpServer;
102
113
  const taskWorker = new TaskWorker();
@@ -135,6 +146,9 @@ export const restartCommand = new Command('restart')
135
146
  taskWorker.start();
136
147
  taskNotifier.start();
137
148
  }
149
+ // Start LinkWorker for document indexing
150
+ const linkWorker = LinkWorker.getInstance();
151
+ linkWorker.start();
138
152
  // Handle graceful shutdown
139
153
  const shutdown = async (signal) => {
140
154
  display.stopSpinner();
@@ -145,6 +159,7 @@ export const restartCommand = new Command('restart')
145
159
  for (const adapter of adapters) {
146
160
  await adapter.disconnect();
147
161
  }
162
+ linkWorker.stop();
148
163
  if (asyncTasksEnabled) {
149
164
  taskWorker.stop();
150
165
  taskNotifier.stop();