morpheus-cli 0.9.12 → 0.9.20

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 (106) hide show
  1. package/README.md +49 -18
  2. package/dist/channels/discord.js +93 -6
  3. package/dist/channels/telegram.js +112 -12
  4. package/dist/cli/commands/restart.js +2 -2
  5. package/dist/cli/commands/start.js +17 -2
  6. package/dist/config/manager.js +20 -1
  7. package/dist/config/paths.js +4 -0
  8. package/dist/config/schemas.js +15 -0
  9. package/dist/http/api.js +7 -3
  10. package/dist/http/routers/agents.js +9 -0
  11. package/dist/http/routers/danger.js +4 -5
  12. package/dist/http/routers/link.js +4 -4
  13. package/dist/runtime/__tests__/telephonist-tts.test.js +84 -0
  14. package/dist/runtime/adapters/AuditRepositoryAdapter.js +6 -0
  15. package/dist/runtime/adapters/ChannelNotifierAdapter.js +9 -0
  16. package/dist/runtime/adapters/LangChainProviderAdapter.js +9 -0
  17. package/dist/runtime/adapters/SQLiteChatHistoryAdapter.js +15 -0
  18. package/dist/runtime/adapters/SQLiteTaskEnqueuerAdapter.js +6 -0
  19. package/dist/runtime/adapters/index.js +5 -0
  20. package/dist/runtime/audit/repository.js +6 -2
  21. package/dist/runtime/chronos/repository.js +7 -5
  22. package/dist/runtime/chronos/worker.js +27 -4
  23. package/dist/runtime/chronos/worker.test.js +3 -2
  24. package/dist/runtime/container.js +50 -0
  25. package/dist/runtime/hot-reload.js +6 -9
  26. package/dist/runtime/memory/backfill-embeddings.js +2 -3
  27. package/dist/runtime/memory/sati/repository.js +3 -3
  28. package/dist/runtime/memory/sqlite.js +40 -14
  29. package/dist/runtime/memory/trinity-db.js +2 -2
  30. package/dist/runtime/oracle.js +78 -43
  31. package/dist/runtime/ports/IChatHistory.js +1 -0
  32. package/dist/runtime/ports/ILLMProviderFactory.js +1 -0
  33. package/dist/runtime/ports/INotifier.js +1 -0
  34. package/dist/runtime/ports/ITaskEnqueuer.js +1 -0
  35. package/dist/runtime/ports/index.js +1 -0
  36. package/dist/runtime/providers/factory.js +8 -52
  37. package/dist/runtime/providers/strategies.js +66 -0
  38. package/dist/runtime/setup/repository.js +2 -2
  39. package/dist/runtime/subagents/ISubagent.js +1 -0
  40. package/dist/runtime/{apoc.js → subagents/apoc.js} +20 -7
  41. package/dist/runtime/{devkit-instrument.js → subagents/devkit-instrument.js} +1 -1
  42. package/dist/runtime/subagents/index.js +12 -0
  43. package/dist/runtime/{link.js → subagents/link/link.js} +24 -10
  44. package/dist/runtime/{link-repository.js → subagents/link/repository.js} +4 -4
  45. package/dist/runtime/{link-search.js → subagents/link/search.js} +3 -3
  46. package/dist/runtime/{link-worker.js → subagents/link/worker.js} +9 -9
  47. package/dist/runtime/{neo.js → subagents/neo.js} +24 -10
  48. package/dist/runtime/subagents/registry.js +134 -0
  49. package/dist/runtime/{trinity.js → subagents/trinity/trinity.js} +23 -9
  50. package/dist/runtime/{subagent-utils.js → subagents/utils.js} +2 -2
  51. package/dist/runtime/tasks/repository.js +2 -2
  52. package/dist/runtime/tasks/worker.js +6 -70
  53. package/dist/runtime/telephonist.js +160 -0
  54. package/dist/runtime/tools/chronos-tools.js +1 -0
  55. package/dist/runtime/tools/delegation-utils.js +5 -7
  56. package/dist/runtime/tools/morpheus-tools.js +9 -10
  57. package/dist/runtime/tools/smith-tool.js +5 -7
  58. package/dist/runtime/webhooks/dispatcher.js +4 -0
  59. package/dist/runtime/webhooks/repository.js +2 -2
  60. package/dist/types/config.js +6 -0
  61. package/dist/ui/assets/AuditDashboard-Cu33zb_7.js +1 -0
  62. package/dist/ui/assets/Chat-mt1j5V55.js +41 -0
  63. package/dist/ui/assets/{Chronos-D1yAb4M5.js → Chronos-Bq_h41cw.js} +1 -1
  64. package/dist/ui/assets/{ConfirmationModal-DxUHZgTy.js → ConfirmationModal-CxLP8iC6.js} +1 -1
  65. package/dist/ui/assets/{Dashboard-BzxmcHaS.js → Dashboard-D0LAlHtG.js} +1 -1
  66. package/dist/ui/assets/{DeleteConfirmationModal-CqNXT_YQ.js → DeleteConfirmationModal-kZ_c3sFk.js} +1 -1
  67. package/dist/ui/assets/{Documents-DLFZdmim.js → Documents-nlQNoUcq.js} +1 -1
  68. package/dist/ui/assets/{Logs-B1Bpy9dB.js → Logs-C1tlg574.js} +1 -1
  69. package/dist/ui/assets/{MCPManager-BbUDMh5Q.js → MCPManager-Do7isizG.js} +1 -1
  70. package/dist/ui/assets/ModelPricing-BeJ7oXBA.js +1 -0
  71. package/dist/ui/assets/{Notifications-8Cqj-mNp.js → Notifications-Cg5CMlY0.js} +1 -1
  72. package/dist/ui/assets/{SatiMemories-CdHUe6di.js → SatiMemories-D9l6s8Pc.js} +1 -1
  73. package/dist/ui/assets/SessionAudit-Da1ySlYg.js +9 -0
  74. package/dist/ui/assets/Settings-DpXwpEhO.js +49 -0
  75. package/dist/ui/assets/{Skills-0k7A2T5_.js → Skills-DaqCY8QH.js} +1 -1
  76. package/dist/ui/assets/Smiths-DA-x4KFT.js +1 -0
  77. package/dist/ui/assets/Switch-CJTE4ZQm.js +1 -0
  78. package/dist/ui/assets/Tasks-DU49M9U-.js +1 -0
  79. package/dist/ui/assets/{TrinityDatabases-CGna6IMX.js → TrinityDatabases-CoKzKTL-.js} +1 -1
  80. package/dist/ui/assets/{UsageStats-B7EzZlZe.js → UsageStats-cds352Pj.js} +1 -1
  81. package/dist/ui/assets/{WebhookManager-Bb7KiucS.js → WebhookManager-DdAdHQUk.js} +1 -1
  82. package/dist/ui/assets/agents-B1z_dlQC.js +1 -0
  83. package/dist/ui/assets/{audit-CJ2Ms81U.js → audit-BAhaGrKY.js} +1 -1
  84. package/dist/ui/assets/{chronos-Bm68OSy4.js → chronos-DGD_Md9M.js} +1 -1
  85. package/dist/ui/assets/config-BwTXe5M2.js +1 -0
  86. package/dist/ui/assets/{index-BxN2w9sY.js → index-BcX5O7kY.js} +2 -2
  87. package/dist/ui/assets/index-Cjli-AD7.css +1 -0
  88. package/dist/ui/assets/{mcp-BE_OVkBe.js → mcp-BlkruPaA.js} +1 -1
  89. package/dist/ui/assets/{skills-Dt0qU4gH.js → skills-CtCb-52u.js} +1 -1
  90. package/dist/ui/assets/{stats-Bmdps1LR.js → stats-BiPI2kaw.js} +1 -1
  91. package/dist/ui/assets/useCurrency-BCdG-pHx.js +1 -0
  92. package/dist/ui/index.html +2 -2
  93. package/dist/ui/sw.js +1 -1
  94. package/package.json +1 -1
  95. package/dist/ui/assets/AuditDashboard-CM1YN1uk.js +0 -1
  96. package/dist/ui/assets/Chat-D4y-g6Tw.js +0 -41
  97. package/dist/ui/assets/ModelPricing-DCl-2_eJ.js +0 -1
  98. package/dist/ui/assets/SessionAudit-CtVHK_IH.js +0 -9
  99. package/dist/ui/assets/Settings-Clge45Z0.js +0 -49
  100. package/dist/ui/assets/Smiths-gjgBMN1F.js +0 -1
  101. package/dist/ui/assets/Tasks-AQ3MrrMp.js +0 -1
  102. package/dist/ui/assets/config-C88yQ_CP.js +0 -1
  103. package/dist/ui/assets/index-C3Ff736M.css +0 -1
  104. /package/dist/runtime/{ISubagent.js → ports/IAuditEmitter.js} +0 -0
  105. /package/dist/runtime/{link-chunker.js → subagents/link/chunker.js} +0 -0
  106. /package/dist/runtime/{trinity-connector.js → subagents/trinity/connector.js} +0 -0
@@ -2,11 +2,11 @@ import { Router } from 'express';
2
2
  import multer from 'multer';
3
3
  import path from 'path';
4
4
  import fs from 'fs-extra';
5
- import { homedir } from 'os';
6
- import { LinkRepository } from '../../runtime/link-repository.js';
7
- import { LinkWorker } from '../../runtime/link-worker.js';
5
+ import { LinkRepository } from '../../runtime/subagents/link/repository.js';
6
+ import { LinkWorker } from '../../runtime/subagents/link/worker.js';
8
7
  import { ConfigManager } from '../../config/manager.js';
9
- const DOCS_PATH = path.join(homedir(), '.morpheus', 'docs');
8
+ import { PATHS } from '../../config/paths.js';
9
+ const DOCS_PATH = PATHS.docs;
10
10
  // Configure multer for file uploads
11
11
  const storage = multer.diskStorage({
12
12
  destination: async (req, file, cb) => {
@@ -0,0 +1,84 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createTtsTelephonist, TTS_MAX_CHARS } from '../telephonist.js';
3
+ // ─── createTtsTelephonist factory ─────────────────────────────────────────────
4
+ describe('createTtsTelephonist', () => {
5
+ it('returns an instance with synthesize() for openai provider', () => {
6
+ const telephonist = createTtsTelephonist({
7
+ enabled: true,
8
+ provider: 'openai',
9
+ model: 'tts-1',
10
+ voice: 'alloy',
11
+ });
12
+ expect(telephonist).toBeDefined();
13
+ expect(typeof telephonist.synthesize).toBe('function');
14
+ });
15
+ it('returns an instance with synthesize() for google provider', () => {
16
+ const telephonist = createTtsTelephonist({
17
+ enabled: true,
18
+ provider: 'google',
19
+ model: 'gemini-2.5-flash',
20
+ voice: 'Kore',
21
+ });
22
+ expect(telephonist).toBeDefined();
23
+ expect(typeof telephonist.synthesize).toBe('function');
24
+ });
25
+ it('throws for unsupported provider', () => {
26
+ expect(() => createTtsTelephonist({
27
+ enabled: true,
28
+ provider: 'ollama',
29
+ model: 'some-model',
30
+ voice: 'default',
31
+ })).toThrow(/Unsupported TTS provider/);
32
+ });
33
+ it('does not expose transcribe() meaningfully (throws)', async () => {
34
+ const telephonist = createTtsTelephonist({
35
+ enabled: true,
36
+ provider: 'openai',
37
+ model: 'tts-1',
38
+ voice: 'alloy',
39
+ });
40
+ await expect(telephonist.transcribe('', '', '')).rejects.toThrow();
41
+ });
42
+ });
43
+ // ─── Text truncation ──────────────────────────────────────────────────────────
44
+ describe('TTS text truncation', () => {
45
+ it('TTS_MAX_CHARS constant is 4096', () => {
46
+ expect(TTS_MAX_CHARS).toBe(4096);
47
+ });
48
+ it('short text (under 4096 chars) passes through unchanged in OpenAI synthesize', async () => {
49
+ // We test the truncation logic indirectly by checking that the SDK call
50
+ // receives the correct (non-truncated) text. We mock the OpenAI client.
51
+ const mockCreate = vi.fn().mockResolvedValue({
52
+ arrayBuffer: async () => new ArrayBuffer(8),
53
+ });
54
+ vi.mock('openai', () => ({
55
+ default: vi.fn().mockImplementation(() => ({
56
+ audio: {
57
+ speech: { create: mockCreate },
58
+ },
59
+ })),
60
+ }));
61
+ vi.mock('fs-extra', () => ({
62
+ default: { writeFile: vi.fn().mockResolvedValue(undefined) },
63
+ }));
64
+ const telephonist = createTtsTelephonist({
65
+ enabled: true,
66
+ provider: 'openai',
67
+ model: 'tts-1',
68
+ voice: 'alloy',
69
+ });
70
+ const shortText = 'Hello world';
71
+ await telephonist.synthesize(shortText, 'fake-key').catch(() => { });
72
+ // Verify the mock was called with the short text (not truncated)
73
+ if (mockCreate.mock.calls.length > 0) {
74
+ expect(mockCreate.mock.calls[0][0].input).toBe(shortText);
75
+ }
76
+ });
77
+ it('long text (over 4096 chars) is truncated to 4096', () => {
78
+ // Test the truncation logic directly by re-creating what truncateForTts does
79
+ const longText = 'a'.repeat(5000);
80
+ const truncated = longText.slice(0, 4096);
81
+ expect(truncated.length).toBe(4096);
82
+ expect(longText.length).toBeGreaterThan(4096);
83
+ });
84
+ });
@@ -0,0 +1,6 @@
1
+ import { AuditRepository } from '../audit/repository.js';
2
+ export class AuditRepositoryAdapter {
3
+ emit(event) {
4
+ AuditRepository.getInstance().insert(event);
5
+ }
6
+ }
@@ -0,0 +1,9 @@
1
+ import { ChannelRegistry } from '../../channels/registry.js';
2
+ export class ChannelNotifierAdapter {
3
+ async sendToUser(channel, userId, text) {
4
+ await ChannelRegistry.sendToUser(channel, userId, text);
5
+ }
6
+ async broadcast(text) {
7
+ await ChannelRegistry.broadcast(text);
8
+ }
9
+ }
@@ -0,0 +1,9 @@
1
+ import { ProviderFactory } from '../providers/factory.js';
2
+ export class LangChainProviderAdapter {
3
+ async createBare(config, tools = []) {
4
+ return ProviderFactory.createBare(config, tools);
5
+ }
6
+ async create(config, tools = []) {
7
+ return ProviderFactory.createBare(config, tools);
8
+ }
9
+ }
@@ -0,0 +1,15 @@
1
+ import { SQLiteChatMessageHistory } from '../memory/sqlite.js';
2
+ export class SQLiteChatHistoryAdapter {
3
+ async getMessages(sessionId) {
4
+ const history = new SQLiteChatMessageHistory({ sessionId });
5
+ return history.getMessages();
6
+ }
7
+ async addMessage(sessionId, message) {
8
+ const history = new SQLiteChatMessageHistory({ sessionId });
9
+ await history.addMessage(message);
10
+ }
11
+ async clear(sessionId) {
12
+ const history = new SQLiteChatMessageHistory({ sessionId });
13
+ await history.clear();
14
+ }
15
+ }
@@ -0,0 +1,6 @@
1
+ import { TaskRepository } from '../tasks/repository.js';
2
+ export class SQLiteTaskEnqueuerAdapter {
3
+ enqueue(input) {
4
+ return TaskRepository.getInstance().createTask(input);
5
+ }
6
+ }
@@ -0,0 +1,5 @@
1
+ export { ChannelNotifierAdapter } from './ChannelNotifierAdapter.js';
2
+ export { SQLiteTaskEnqueuerAdapter } from './SQLiteTaskEnqueuerAdapter.js';
3
+ export { SQLiteChatHistoryAdapter } from './SQLiteChatHistoryAdapter.js';
4
+ export { LangChainProviderAdapter } from './LangChainProviderAdapter.js';
5
+ export { AuditRepositoryAdapter } from './AuditRepositoryAdapter.js';
@@ -1,14 +1,14 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
- import { homedir } from 'os';
5
4
  import { randomUUID } from 'crypto';
6
5
  import { DisplayManager } from '../display.js';
6
+ import { PATHS } from '../../config/paths.js';
7
7
  export class AuditRepository {
8
8
  static instance = null;
9
9
  db;
10
10
  constructor() {
11
- const dbPath = path.join(homedir(), '.morpheus', 'memory', 'short-memory.db');
11
+ const dbPath = PATHS.shortMemoryDb;
12
12
  fs.ensureDirSync(path.dirname(dbPath));
13
13
  this.db = new Database(dbPath, { timeout: 5000 });
14
14
  this.db.pragma('journal_mode = WAL');
@@ -59,6 +59,10 @@ export class AuditRepository {
59
59
  DisplayManager.getInstance().log(`AuditRepository.insert failed: ${err?.message ?? String(err)}`, { source: 'Audit', level: 'error' });
60
60
  }
61
61
  }
62
+ countBySession(sessionId) {
63
+ const row = this.db.prepare(`SELECT COUNT(*) as n FROM audit_events WHERE session_id = ?`).get(sessionId);
64
+ return row?.n ?? 0;
65
+ }
62
66
  getBySession(sessionId, opts) {
63
67
  const limit = opts?.limit ?? 500;
64
68
  const offset = opts?.offset ?? 0;
@@ -1,9 +1,9 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
- import { homedir } from 'os';
5
4
  import { randomUUID } from 'crypto';
6
5
  import { ConfigManager } from '../../config/manager.js';
6
+ import { PATHS } from '../../config/paths.js';
7
7
  export class ChronosError extends Error {
8
8
  constructor(message) {
9
9
  super(message);
@@ -14,7 +14,7 @@ export class ChronosRepository {
14
14
  static instance = null;
15
15
  db;
16
16
  constructor() {
17
- const dbPath = path.join(homedir(), '.morpheus', 'memory', 'short-memory.db');
17
+ const dbPath = PATHS.shortMemoryDb;
18
18
  fs.ensureDirSync(path.dirname(dbPath));
19
19
  this.db = new Database(dbPath, { timeout: 5000 });
20
20
  this.db.pragma('journal_mode = WAL');
@@ -62,6 +62,7 @@ export class ChronosRepository {
62
62
  addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0`, 'updated_at');
63
63
  addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN created_by TEXT NOT NULL DEFAULT 'api'`, 'created_by');
64
64
  addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN notify_channels TEXT NOT NULL DEFAULT '[]'`, 'notify_channels');
65
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN origin_session_id TEXT`, 'origin_session_id');
65
66
  const execInfo = this.db.pragma('table_info(chronos_executions)');
66
67
  const execCols = new Set(execInfo.map((c) => c.name));
67
68
  const addExecCol = (sql, col) => {
@@ -107,6 +108,7 @@ export class ChronosRepository {
107
108
  updated_at: row.updated_at,
108
109
  created_by: row.created_by,
109
110
  notify_channels,
111
+ origin_session_id: row.origin_session_id ?? null,
110
112
  };
111
113
  }
112
114
  deserializeExecution(row) {
@@ -132,9 +134,9 @@ export class ChronosRepository {
132
134
  this.db.prepare(`
133
135
  INSERT INTO chronos_jobs (
134
136
  id, prompt, schedule_type, schedule_expression, cron_normalized,
135
- timezone, next_run_at, last_run_at, enabled, created_at, updated_at, created_by, notify_channels
136
- ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?, ?)
137
- `).run(id, input.prompt, input.schedule_type, input.schedule_expression, input.cron_normalized ?? null, input.timezone, input.next_run_at, now, now, input.created_by, JSON.stringify(input.notify_channels ?? []));
137
+ timezone, next_run_at, last_run_at, enabled, created_at, updated_at, created_by, notify_channels, origin_session_id
138
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?, ?, ?)
139
+ `).run(id, input.prompt, input.schedule_type, input.schedule_expression, input.cron_normalized ?? null, input.timezone, input.next_run_at, now, now, input.created_by, JSON.stringify(input.notify_channels ?? []), input.origin_session_id ?? null);
138
140
  return this.getJob(id);
139
141
  }
140
142
  getJob(id) {
@@ -4,6 +4,7 @@ import { DisplayManager } from '../display.js';
4
4
  import { parseNextRun } from './parser.js';
5
5
  import { ChannelRegistry } from '../../channels/registry.js';
6
6
  import { AuditRepository } from '../audit/repository.js';
7
+ import { SQLiteChatMessageHistory } from '../memory/sqlite.js';
7
8
  export class ChronosWorker {
8
9
  repo;
9
10
  oracle;
@@ -74,9 +75,27 @@ export class ChronosWorker {
74
75
  const display = DisplayManager.getInstance();
75
76
  const execId = randomUUID();
76
77
  display.log(`Job ${job.id} triggered — "${job.prompt.slice(0, 60)}"`, { source: 'Chronos' });
77
- // Use the currently active Oracle session so Chronos executes in the
78
- // user's conversation context no session switching or isolation needed.
79
- const activeSessionId = this.oracle.getCurrentSessionId() ?? 'default';
78
+ // Resolve session: prefer the session where the job was originally created,
79
+ // fall back to Oracle's current session, then to most recent active session.
80
+ const tmpHistory = new SQLiteChatMessageHistory({ sessionId: 'tmp' });
81
+ let activeSessionId;
82
+ try {
83
+ if (job.origin_session_id && tmpHistory.isSessionUsable(job.origin_session_id)) {
84
+ activeSessionId = job.origin_session_id;
85
+ }
86
+ else {
87
+ const oracleSession = this.oracle.getCurrentSessionId();
88
+ if (oracleSession && tmpHistory.isSessionUsable(oracleSession)) {
89
+ activeSessionId = oracleSession;
90
+ }
91
+ else {
92
+ activeSessionId = tmpHistory.getMostRecentSession() ?? 'default';
93
+ }
94
+ }
95
+ }
96
+ finally {
97
+ tmpHistory.close();
98
+ }
80
99
  this.repo.insertExecution({
81
100
  id: execId,
82
101
  job_id: job.id,
@@ -96,7 +115,7 @@ export class ChronosWorker {
96
115
  const taskOriginChannel = job.notify_channels.length === 1
97
116
  ? job.notify_channels[0]
98
117
  : 'chronos';
99
- const taskContext = { origin_channel: taskOriginChannel, session_id: activeSessionId };
118
+ const taskContext = { origin_channel: taskOriginChannel, session_id: activeSessionId, source: 'chronos' };
100
119
  // Hard-block Chronos management tools during execution.
101
120
  ChronosWorker.isExecuting = true;
102
121
  const chronosStartMs = Date.now();
@@ -154,6 +173,10 @@ export class ChronosWorker {
154
173
  }
155
174
  else {
156
175
  for (const ch of job.notify_channels) {
176
+ // 'ui' has no adapter — it works by polling session messages from the DB.
177
+ // The response is already persisted via oracle.chat(), so skip silently.
178
+ if (ch === 'ui')
179
+ continue;
157
180
  const adapter = ChannelRegistry.get(ch);
158
181
  if (adapter) {
159
182
  await adapter.sendMessage(text).catch((err) => {
@@ -30,6 +30,7 @@ function makeJob(overrides = {}) {
30
30
  updated_at: Date.now() - 5000,
31
31
  created_by: 'ui',
32
32
  notify_channels: [],
33
+ origin_session_id: null,
33
34
  ...overrides,
34
35
  };
35
36
  }
@@ -91,7 +92,7 @@ describe('ChronosWorker.tick()', () => {
91
92
  await worker.tick();
92
93
  // Wait for fire-and-forget
93
94
  await new Promise((r) => setTimeout(r, 50));
94
- expect(oracle.chat).toHaveBeenCalledWith(expect.stringContaining(job.prompt));
95
+ expect(oracle.chat).toHaveBeenCalledWith(expect.stringContaining(job.prompt), undefined, false, expect.objectContaining({ source: 'chronos' }));
95
96
  expect(repo.disableJob).toHaveBeenCalledWith(job.id);
96
97
  expect(repo.updateJob).not.toHaveBeenCalled();
97
98
  });
@@ -102,7 +103,7 @@ describe('ChronosWorker.tick()', () => {
102
103
  const worker = new ChronosWorker(repo, oracle);
103
104
  await worker.tick();
104
105
  await new Promise((r) => setTimeout(r, 50));
105
- expect(oracle.chat).toHaveBeenCalledWith(expect.stringContaining(job.prompt));
106
+ expect(oracle.chat).toHaveBeenCalledWith(expect.stringContaining(job.prompt), undefined, false, expect.objectContaining({ source: 'chronos' }));
106
107
  expect(repo.disableJob).not.toHaveBeenCalled();
107
108
  expect(repo.updateJob).toHaveBeenCalledWith(job.id, expect.objectContaining({ next_run_at: expect.any(Number) }));
108
109
  });
@@ -0,0 +1,50 @@
1
+ /**
2
+ * ServiceContainer — Composition Root
3
+ *
4
+ * Simple typed registry for application services (ports).
5
+ * Configured once at startup in start.ts. No DI framework magic —
6
+ * just a Map with type-safe get/register.
7
+ *
8
+ * Usage:
9
+ * // Registration (start.ts):
10
+ * ServiceContainer.register('notifier', new ChannelNotifierAdapter());
11
+ *
12
+ * // Consumption:
13
+ * const notifier = ServiceContainer.get<INotifier>('notifier');
14
+ */
15
+ export class ServiceContainer {
16
+ static services = new Map();
17
+ /** Register a service under a given key. Overwrites if already registered. */
18
+ static register(key, service) {
19
+ ServiceContainer.services.set(key, service);
20
+ }
21
+ /**
22
+ * Retrieve a registered service.
23
+ * @throws if the key is not registered.
24
+ */
25
+ static get(key) {
26
+ const service = ServiceContainer.services.get(key);
27
+ if (service === undefined) {
28
+ throw new Error(`ServiceContainer: "${key}" is not registered. Did you call register() in start.ts?`);
29
+ }
30
+ return service;
31
+ }
32
+ /** Returns true if a service is registered under the given key. */
33
+ static has(key) {
34
+ return ServiceContainer.services.has(key);
35
+ }
36
+ /** Remove all registered services (useful in tests). */
37
+ static reset() {
38
+ ServiceContainer.services.clear();
39
+ }
40
+ }
41
+ /**
42
+ * Well-known service keys — use these constants to avoid typo bugs.
43
+ */
44
+ export const SERVICE_KEYS = {
45
+ notifier: 'notifier',
46
+ taskEnqueuer: 'taskEnqueuer',
47
+ chatHistory: 'chatHistory',
48
+ providerFactory: 'providerFactory',
49
+ auditEmitter: 'auditEmitter',
50
+ };
@@ -7,9 +7,7 @@
7
7
  */
8
8
  import { ConfigManager } from '../config/manager.js';
9
9
  import { DisplayManager } from './display.js';
10
- import { Apoc } from './apoc.js';
11
- import { Neo } from './neo.js';
12
- import { Trinity } from './trinity.js';
10
+ import { SubagentRegistry } from './subagents/registry.js';
13
11
  let currentOracle = null;
14
12
  /**
15
13
  * Register the current Oracle instance for hot-reload.
@@ -44,12 +42,11 @@ export async function hotReloadConfig() {
44
42
  reinitialized.push('Oracle');
45
43
  display.log('Oracle reinitialized with new config', { source: 'HotReload', level: 'info' });
46
44
  }
47
- // 3. Reset subagent singletons - they will reinitialize with new config on next use
48
- Apoc.resetInstance();
49
- Neo.resetInstance();
50
- Trinity.resetInstance();
51
- reinitialized.push('Apoc', 'Neo', 'Trinity');
52
- display.log('Subagent singletons reset (will reinitialize on next use)', { source: 'HotReload', level: 'info' });
45
+ // 3. Reload all registered subagents via SubagentRegistry
46
+ await SubagentRegistry.reloadAll();
47
+ const agentNames = SubagentRegistry.getAll().map(r => r.label);
48
+ reinitialized.push(...agentNames);
49
+ display.log(`Subagents reloaded: ${agentNames.join(', ')}`, { source: 'HotReload', level: 'info' });
53
50
  return {
54
51
  success: true,
55
52
  reinitialized,
@@ -1,9 +1,8 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import { EmbeddingService } from './embedding.service.js';
3
- import path from 'path';
4
- import { homedir } from 'os';
5
3
  import loadVecExtension from './sqlite-vec.js';
6
- const db = new Database(path.join(homedir(), '.morpheus', 'memory', 'sati-memory.db'));
4
+ import { PATHS } from '../../config/paths.js';
5
+ const db = new Database(PATHS.satiDb);
7
6
  db.pragma('journal_mode = WAL');
8
7
  // 🔥 ISSO AQUI É O QUE ESTÁ FALTANDO
9
8
  loadVecExtension(db);
@@ -1,11 +1,11 @@
1
1
  import Database from 'better-sqlite3';
2
- import path from 'path';
3
- import { homedir } from 'os';
4
2
  import fs from 'fs-extra';
3
+ import path from 'path';
5
4
  import { randomUUID } from 'crypto';
6
5
  import loadVecExtension from '../sqlite-vec.js';
7
6
  import { DisplayManager } from '../../display.js';
8
7
  import { ConfigManager } from '../../../config/manager.js';
8
+ import { PATHS } from '../../../config/paths.js';
9
9
  const EMBEDDING_DIM = 384;
10
10
  export class SatiRepository {
11
11
  db = null;
@@ -14,7 +14,7 @@ export class SatiRepository {
14
14
  display = DisplayManager.getInstance();
15
15
  constructor(dbPath) {
16
16
  this.dbPath =
17
- dbPath || path.join(homedir(), '.morpheus', 'memory', 'sati-memory.db');
17
+ dbPath || PATHS.satiDb;
18
18
  }
19
19
  static getInstance(dbPath) {
20
20
  if (!SatiRepository.instance) {
@@ -3,7 +3,7 @@ import { HumanMessage, AIMessage, SystemMessage, ToolMessage } from "@langchain/
3
3
  import Database from "better-sqlite3";
4
4
  import fs from "fs-extra";
5
5
  import * as path from "path";
6
- import { homedir } from "os";
6
+ import { PATHS } from "../../config/paths.js";
7
7
  import { randomUUID } from 'crypto';
8
8
  import { DisplayManager } from "../display.js";
9
9
  export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
@@ -22,7 +22,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
22
22
  this.sessionId = fields.sessionId && fields.sessionId !== '' ? fields.sessionId : '';
23
23
  this.limit = fields.limit ? fields.limit : 20;
24
24
  // Default path: ~/.morpheus/memory/short-memory.db
25
- const dbPath = fields.databasePath || path.join(homedir(), ".morpheus", "memory", "short-memory.db");
25
+ const dbPath = fields.databasePath || PATHS.shortMemoryDb;
26
26
  // Ensure the directory exists
27
27
  this.ensureDirectory(dbPath);
28
28
  // Initialize database with retry logic for locked databases
@@ -847,6 +847,10 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
847
847
  embedding_status = 'pending'
848
848
  WHERE id = ?
849
849
  `).run(now, now, sessionId);
850
+ // Remove channel/user bindings so channels don't route to an archived session
851
+ this.db.prepare(`
852
+ DELETE FROM user_channel_sessions WHERE session_id = ?
853
+ `).run(sessionId);
850
854
  // Exportar mensagens → texto
851
855
  const messages = this.db.prepare(`
852
856
  SELECT type, content
@@ -870,7 +874,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
870
874
  const sessionText = tx(); // Executar a transação
871
875
  // Criar chunks no banco Sati — conexão aberta localmente e fechada ao fim
872
876
  if (sessionText) {
873
- const dbSatiPath = path.join(homedir(), '.morpheus', 'memory', 'sati-memory.db');
877
+ const dbSatiPath = PATHS.satiDb;
874
878
  this.ensureDirectory(dbSatiPath);
875
879
  const dbSati = new Database(dbSatiPath, { timeout: 5000 });
876
880
  dbSati.pragma('journal_mode = WAL');
@@ -936,6 +940,10 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
936
940
  deleted_at = ?
937
941
  WHERE id = ?
938
942
  `).run(now, sessionId);
943
+ // Remove channel/user bindings so channels don't route to a deleted session
944
+ this.db.prepare(`
945
+ DELETE FROM user_channel_sessions WHERE session_id = ?
946
+ `).run(sessionId);
939
947
  });
940
948
  tx(); // Executar a transação
941
949
  }
@@ -977,6 +985,14 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
977
985
  this.db.prepare("INSERT INTO sessions (id, started_at, status) VALUES (?, ?, 'active')").run(sessionId, Date.now());
978
986
  }
979
987
  }
988
+ /**
989
+ * Checks whether a session exists and is usable (active or paused).
990
+ * Returns false for deleted, archived, or non-existent sessions.
991
+ */
992
+ isSessionUsable(sessionId) {
993
+ const row = this.db.prepare("SELECT status FROM sessions WHERE id = ?").get(sessionId);
994
+ return !!row && (row.status === 'active' || row.status === 'paused');
995
+ }
980
996
  /**
981
997
  * Validates that the target session exists and is usable (not archived/deleted).
982
998
  * No longer swaps active↔paused — sessions are independently usable from any channel.
@@ -1015,10 +1031,11 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
1015
1031
  */
1016
1032
  async listSessions() {
1017
1033
  const sessions = this.db.prepare(`
1018
- SELECT id, title, status, started_at
1019
- FROM sessions
1020
- WHERE status IN ('active', 'paused')
1021
- ORDER BY started_at DESC
1034
+ SELECT s.id, s.title, s.status, s.started_at,
1035
+ (SELECT MAX(m.created_at) FROM messages m WHERE m.session_id = s.id) AS last_message_at
1036
+ FROM sessions s
1037
+ WHERE s.status IN ('active', 'paused')
1038
+ ORDER BY COALESCE(last_message_at, s.started_at) DESC
1022
1039
  `).all();
1023
1040
  return sessions;
1024
1041
  }
@@ -1028,8 +1045,11 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
1028
1045
  */
1029
1046
  async getUserChannelSession(channel, userId) {
1030
1047
  const result = this.db.prepare(`
1031
- SELECT session_id FROM user_channel_sessions
1032
- WHERE channel = ? AND user_id = ?
1048
+ SELECT ucs.session_id
1049
+ FROM user_channel_sessions ucs
1050
+ JOIN sessions s ON s.id = ucs.session_id
1051
+ WHERE ucs.channel = ? AND ucs.user_id = ?
1052
+ AND s.status IN ('active', 'paused')
1033
1053
  `).get(channel, userId);
1034
1054
  return result ? result.session_id : null;
1035
1055
  }
@@ -1052,8 +1072,11 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
1052
1072
  */
1053
1073
  async listUserChannelSessions(channel) {
1054
1074
  const rows = this.db.prepare(`
1055
- SELECT user_id, session_id FROM user_channel_sessions
1056
- WHERE channel = ?
1075
+ SELECT ucs.user_id, ucs.session_id
1076
+ FROM user_channel_sessions ucs
1077
+ JOIN sessions s ON s.id = ucs.session_id
1078
+ WHERE ucs.channel = ?
1079
+ AND s.status IN ('active', 'paused')
1057
1080
  `).all(channel);
1058
1081
  return rows.map(row => ({ userId: row.user_id, sessionId: row.session_id }));
1059
1082
  }
@@ -1075,9 +1098,12 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
1075
1098
  return null;
1076
1099
  const placeholders = channels.map(() => '?').join(', ');
1077
1100
  const result = this.db.prepare(`
1078
- SELECT session_id FROM user_channel_sessions
1079
- WHERE channel IN (${placeholders})
1080
- ORDER BY updated_at DESC LIMIT 1
1101
+ SELECT ucs.session_id
1102
+ FROM user_channel_sessions ucs
1103
+ JOIN sessions s ON s.id = ucs.session_id
1104
+ WHERE ucs.channel IN (${placeholders})
1105
+ AND s.status IN ('active', 'paused')
1106
+ ORDER BY ucs.updated_at DESC LIMIT 1
1081
1107
  `).get(...channels);
1082
1108
  return result ? result.session_id : null;
1083
1109
  }
@@ -1,8 +1,8 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
- import { homedir } from 'os';
5
4
  import { encrypt, decrypt, canEncrypt } from '../trinity-crypto.js';
5
+ import { PATHS } from '../../config/paths.js';
6
6
  function safeDecrypt(value) {
7
7
  if (!value)
8
8
  return null;
@@ -39,7 +39,7 @@ export class DatabaseRegistry {
39
39
  static instance = null;
40
40
  db;
41
41
  constructor() {
42
- const dbPath = path.join(homedir(), '.morpheus', 'memory', 'trinity.db');
42
+ const dbPath = PATHS.trinityDb;
43
43
  fs.ensureDirSync(path.dirname(dbPath));
44
44
  this.db = new Database(dbPath, { timeout: 5000 });
45
45
  this.db.pragma('journal_mode = WAL');