morpheus-cli 0.9.32 → 0.9.33

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.
@@ -12,6 +12,8 @@ const display = DisplayManager.getInstance();
12
12
  export class SatiService {
13
13
  repository;
14
14
  static instance;
15
+ cachedAgent = null;
16
+ cachedAgentConfigKey = null;
15
17
  constructor() {
16
18
  this.repository = SatiRepository.getInstance();
17
19
  }
@@ -59,9 +61,17 @@ export class SatiService {
59
61
  const satiConfig = ConfigManager.getInstance().getSatiConfig();
60
62
  if (!satiConfig)
61
63
  return;
62
- // Use the main provider factory to get an agent (Reusing Zion configuration)
63
- // We pass empty tools as Sati is a pure reasoning agent here
64
- const agent = await ProviderFactory.create(satiConfig, []);
64
+ // Reuse cached agent when config hasn't changed to avoid per-call overhead
65
+ const configKey = `${satiConfig.provider}:${satiConfig.model}`;
66
+ let agent;
67
+ if (this.cachedAgent && this.cachedAgentConfigKey === configKey) {
68
+ agent = this.cachedAgent;
69
+ }
70
+ else {
71
+ agent = await ProviderFactory.create(satiConfig, []);
72
+ this.cachedAgent = agent;
73
+ this.cachedAgentConfigKey = configKey;
74
+ }
65
75
  // Get existing memories for context (Simulated "Working Memory" or full list if small)
66
76
  const allMemories = this.repository.getAllMemories();
67
77
  // Map conversation to strict types and sanitize
@@ -10,6 +10,48 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
10
10
  lc_namespace = ["langchain", "stores", "message", "sqlite"];
11
11
  display = DisplayManager.getInstance();
12
12
  static migrationDone = false; // run migrations only once per process
13
+ // ---------------------------------------------------------------------------
14
+ // In-memory message cache — eliminates repeated SQLite reads for active sessions
15
+ // Key: sessionId Value: { messages (DESC order, newest first), touchedAt, limit }
16
+ // ---------------------------------------------------------------------------
17
+ static _cache = new Map();
18
+ static _CACHE_MAX_SESSIONS = 50;
19
+ static _CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes
20
+ /** Populate or replace a cache entry, evicting LRU if over capacity. */
21
+ static _setCacheEntry(sessionId, messages, limit) {
22
+ if (!sessionId)
23
+ return;
24
+ if (SQLiteChatMessageHistory._cache.size >= SQLiteChatMessageHistory._CACHE_MAX_SESSIONS) {
25
+ let oldestKey = '';
26
+ let oldestTime = Infinity;
27
+ for (const [k, v] of SQLiteChatMessageHistory._cache) {
28
+ if (v.touchedAt < oldestTime) {
29
+ oldestTime = v.touchedAt;
30
+ oldestKey = k;
31
+ }
32
+ }
33
+ if (oldestKey)
34
+ SQLiteChatMessageHistory._cache.delete(oldestKey);
35
+ }
36
+ SQLiteChatMessageHistory._cache.set(sessionId, { messages: [...messages], touchedAt: Date.now(), limit });
37
+ }
38
+ /** Remove a session from the cache (used on clear()). */
39
+ static invalidateCacheForSession(sessionId) {
40
+ SQLiteChatMessageHistory._cache.delete(sessionId);
41
+ }
42
+ /** Prepend new messages (newest-first order) to an existing cache entry. */
43
+ static _appendToCache(sessionId, newMessages) {
44
+ if (!sessionId || newMessages.length === 0)
45
+ return;
46
+ const entry = SQLiteChatMessageHistory._cache.get(sessionId);
47
+ if (!entry)
48
+ return; // session not cached — will be populated on next read
49
+ // newMessages is in chronological order (oldest first); reverse so newest goes first
50
+ const newestFirst = [...newMessages].reverse();
51
+ const merged = [...newestFirst, ...entry.messages];
52
+ entry.messages = merged.slice(0, entry.limit);
53
+ entry.touchedAt = Date.now();
54
+ }
13
55
  db;
14
56
  sessionId;
15
57
  limit;
@@ -281,6 +323,21 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
281
323
  */
282
324
  async getMessages() {
283
325
  try {
326
+ // -----------------------------------------------------------------------
327
+ // Cache fast-path: return cached messages if session is warm and not stale
328
+ // -----------------------------------------------------------------------
329
+ if (this.sessionId) {
330
+ const cached = SQLiteChatMessageHistory._cache.get(this.sessionId);
331
+ if (cached) {
332
+ const isStale = Date.now() - cached.touchedAt > SQLiteChatMessageHistory._CACHE_TTL_MS;
333
+ if (!isStale) {
334
+ cached.touchedAt = Date.now();
335
+ return [...cached.messages]; // defensive copy
336
+ }
337
+ // Stale — evict and fall through to DB
338
+ SQLiteChatMessageHistory._cache.delete(this.sessionId);
339
+ }
340
+ }
284
341
  // Fetch new columns
285
342
  const stmt = this.db.prepare(`SELECT type, content, input_tokens, output_tokens, total_tokens, cache_read_tokens, provider, model
286
343
  FROM messages
@@ -358,7 +415,10 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
358
415
  // Messages are in DESC order (newest first) here — orphans appear at the tail.
359
416
  // Remove trailing ToolMessages and AIMessages with tool_calls that have no
360
417
  // matching ToolMessage response (i.e. incomplete tool-call groups at the boundary).
361
- return this.sanitizeMessageWindow(mapped);
418
+ const result = this.sanitizeMessageWindow(mapped);
419
+ // Populate cache for subsequent reads in this session
420
+ SQLiteChatMessageHistory._setCacheEntry(this.sessionId, result, this.limit ?? 100);
421
+ return result;
362
422
  }
363
423
  catch (error) {
364
424
  // Check if it's a database lock error
@@ -459,6 +519,8 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
459
519
  }
460
520
  const stmt = this.db.prepare("INSERT INTO messages (session_id, type, content, created_at, input_tokens, output_tokens, total_tokens, cache_read_tokens, provider, model, audio_duration_seconds, agent, duration_ms, source) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
461
521
  stmt.run(this.sessionId, type, finalContent, Date.now(), inputTokens, outputTokens, totalTokens, cacheReadTokens, provider, model, audioDurationSeconds, agent, durationMs, source);
522
+ // Update in-memory cache so the next getMessages() call is a cache hit
523
+ SQLiteChatMessageHistory._appendToCache(this.sessionId, [message]);
462
524
  // Verificar se a sessão tem título e definir automaticamente se necessário
463
525
  await this.setSessionTitleIfNeeded();
464
526
  }
@@ -517,6 +579,8 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
517
579
  });
518
580
  try {
519
581
  insertAll(messages);
582
+ // Update in-memory cache so the next getMessages() call is a cache hit
583
+ SQLiteChatMessageHistory._appendToCache(this.sessionId, messages);
520
584
  await this.setSessionTitleIfNeeded();
521
585
  }
522
586
  catch (error) {
@@ -743,6 +807,7 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
743
807
  try {
744
808
  const stmt = this.db.prepare("DELETE FROM messages WHERE session_id = ?");
745
809
  stmt.run(this.sessionId);
810
+ SQLiteChatMessageHistory._cache.delete(this.sessionId);
746
811
  }
747
812
  catch (error) {
748
813
  // Check for database lock errors
@@ -19,7 +19,7 @@ import { SetupRepository } from './setup/repository.js';
19
19
  import { buildSetupTool } from './tools/setup-tool.js';
20
20
  import { SmithDelegator } from "./smiths/delegator.js";
21
21
  import { PATHS } from "../config/paths.js";
22
- import { writeFileSync } from "fs";
22
+ import { writeFile } from "fs/promises";
23
23
  export class Oracle {
24
24
  provider;
25
25
  config;
@@ -200,6 +200,13 @@ export class Oracle {
200
200
  this.registerSmithIfEnabled();
201
201
  // Refresh dynamic tool catalogs so delegate descriptions contain runtime info.
202
202
  await SubagentRegistry.refreshAllCatalogs();
203
+ // Eagerly initialize subagents so first delegation doesn't pay init cost.
204
+ await Promise.allSettled([
205
+ Apoc.getInstance().initialize(),
206
+ Neo.getInstance().initialize(),
207
+ Trinity.getInstance().initialize(),
208
+ Link.getInstance().initialize(),
209
+ ]);
203
210
  // Initialize setup repository (creates table if needed)
204
211
  SetupRepository.getInstance();
205
212
  const coreTools = [
@@ -459,10 +466,7 @@ ${SkillRegistry.getInstance().getSystemPromptSection()}
459
466
  ${SmithRegistry.getInstance().getSystemPromptSection()}
460
467
  `);
461
468
  //save the system prompt on ~/.morpheus/system_prompt.txt for debugging and prompt engineering purposes
462
- try {
463
- writeFileSync(`${PATHS.root}/system_prompt.txt`, String(systemMessage.content), 'utf-8');
464
- }
465
- catch { }
469
+ writeFile(`${PATHS.root}/system_prompt.txt`, String(systemMessage.content), 'utf-8').catch(() => { });
466
470
  // Resolve the authoritative session ID for this call.
467
471
  // Priority: explicit taskContext > current history instance > fallback.
468
472
  const currentSessionId = taskContext?.session_id
@@ -482,13 +486,20 @@ ${SmithRegistry.getInstance().getSystemPromptSection()}
482
486
  // Load existing history from database in reverse order (most recent first)
483
487
  let previousMessages = await callHistory.getMessages();
484
488
  previousMessages = previousMessages.reverse();
485
- // Sati Middleware: Retrieval
489
+ // Sati Middleware: Retrieval (with 3s timeout to avoid blocking the response)
486
490
  let memoryMessage = null;
487
491
  try {
488
- memoryMessage = await this.satiMiddleware.beforeAgent(message, previousMessages, currentSessionId);
492
+ const satiTimeout = new Promise((resolve) => setTimeout(() => resolve(null), 3000));
493
+ memoryMessage = await Promise.race([
494
+ this.satiMiddleware.beforeAgent(message, previousMessages, currentSessionId),
495
+ satiTimeout,
496
+ ]);
489
497
  if (memoryMessage) {
490
498
  this.display.log('Sati memory retrieved.', { source: 'Sati' });
491
499
  }
500
+ else if (memoryMessage === null) {
501
+ // Could be timeout or no memories found — either way, proceed
502
+ }
492
503
  }
493
504
  catch (e) {
494
505
  // Fail open - do not disrupt main flow
@@ -0,0 +1,11 @@
1
+ import { EventEmitter } from 'events';
2
+ /**
3
+ * Singleton event bus for task lifecycle events.
4
+ * Emitted by TaskWorker, consumed by TaskNotifier for immediate dispatch.
5
+ *
6
+ * Events:
7
+ * 'task:ready' (taskId: string) — task is completed/failed/cancelled and ready to notify
8
+ */
9
+ class TaskEventBus extends EventEmitter {
10
+ }
11
+ export const taskEventBus = new TaskEventBus();
@@ -1,20 +1,20 @@
1
1
  import { DisplayManager } from '../display.js';
2
2
  import { TaskDispatcher } from './dispatcher.js';
3
3
  import { TaskRepository } from './repository.js';
4
+ import { taskEventBus } from './event-bus.js';
4
5
  export class TaskNotifier {
5
- pollIntervalMs;
6
6
  maxAttempts;
7
7
  staleSendingMs;
8
- notificationGraceMs;
8
+ recoveryPollMs;
9
9
  repository = TaskRepository.getInstance();
10
10
  display = DisplayManager.getInstance();
11
11
  timer = null;
12
- running = false;
12
+ inFlight = new Set(); // task IDs currently being dispatched
13
13
  constructor(opts) {
14
- this.pollIntervalMs = opts?.pollIntervalMs ?? 1200;
15
14
  this.maxAttempts = opts?.maxAttempts ?? 5;
16
15
  this.staleSendingMs = opts?.staleSendingMs ?? 30_000;
17
- this.notificationGraceMs = opts?.notificationGraceMs ?? 2000;
16
+ // Slow poll only for orphan recovery (process restarts, crash scenarios)
17
+ this.recoveryPollMs = opts?.recoveryPollMs ?? 30_000;
18
18
  }
19
19
  start() {
20
20
  if (this.timer)
@@ -26,43 +26,69 @@ export class TaskNotifier {
26
26
  level: 'warning',
27
27
  });
28
28
  }
29
+ // Primary path: event-driven — fires immediately when TaskWorker completes a task
30
+ taskEventBus.on('task:ready', (taskId) => {
31
+ this.dispatchById(taskId);
32
+ });
33
+ // Fallback path: slow poll for orphaned tasks (e.g. completed before notifier started,
34
+ // or process restarted mid-notification)
29
35
  this.timer = setInterval(() => {
30
- void this.tick();
31
- }, this.pollIntervalMs);
32
- this.display.log('Task notifier started.', { source: 'TaskNotifier' });
36
+ void this.recoveryTick();
37
+ }, this.recoveryPollMs);
38
+ this.display.log('Task notifier started (event-driven + 30s recovery poll).', { source: 'TaskNotifier' });
33
39
  }
34
40
  stop() {
41
+ taskEventBus.removeAllListeners('task:ready');
35
42
  if (this.timer) {
36
43
  clearInterval(this.timer);
37
44
  this.timer = null;
38
- this.display.log('Task notifier stopped.', { source: 'TaskNotifier' });
39
45
  }
46
+ this.display.log('Task notifier stopped.', { source: 'TaskNotifier' });
40
47
  }
41
- async tick() {
42
- if (this.running)
48
+ /**
49
+ * Event-driven dispatch: called immediately when a task is marked complete/failed.
50
+ * Uses claimNotificationById to atomically claim — prevents double-dispatch with recovery poll.
51
+ */
52
+ dispatchById(taskId) {
53
+ if (this.inFlight.has(taskId))
43
54
  return;
44
- this.running = true;
45
- try {
46
- const task = this.repository.claimNextNotificationCandidate(this.notificationGraceMs);
55
+ this.inFlight.add(taskId);
56
+ const task = this.repository.claimNotificationById(taskId);
57
+ if (!task) {
58
+ // Already claimed by recovery poll or another path
59
+ this.inFlight.delete(taskId);
60
+ return;
61
+ }
62
+ void this.dispatch(task).finally(() => this.inFlight.delete(taskId));
63
+ }
64
+ /**
65
+ * Fallback recovery: picks up any orphaned completed tasks not yet notified.
66
+ */
67
+ async recoveryTick() {
68
+ // Drain all pending orphans in one recovery sweep
69
+ while (true) {
70
+ const task = this.repository.claimNextNotificationCandidate(0);
47
71
  if (!task)
48
- return;
49
- try {
50
- await TaskDispatcher.onTaskFinished(task);
51
- this.repository.markNotificationSent(task.id);
52
- }
53
- catch (err) {
54
- const latest = this.repository.getTaskById(task.id);
55
- const attempts = (latest?.notify_attempts ?? 0) + 1;
56
- const retry = attempts < this.maxAttempts;
57
- this.repository.markNotificationFailed(task.id, err?.message ?? String(err), retry);
58
- this.display.log(`Task notification failed (${task.id}): ${err?.message ?? err}`, {
59
- source: 'TaskNotifier',
60
- level: retry ? 'warning' : 'error',
61
- });
62
- }
72
+ break;
73
+ if (this.inFlight.has(task.id))
74
+ break; // already being dispatched via event
75
+ await this.dispatch(task);
63
76
  }
64
- finally {
65
- this.running = false;
77
+ }
78
+ async dispatch(task) {
79
+ try {
80
+ await TaskDispatcher.onTaskFinished(task);
81
+ this.repository.markNotificationSent(task.id);
82
+ }
83
+ catch (err) {
84
+ const latest = this.repository.getTaskById(task.id);
85
+ const attempts = (latest?.notify_attempts ?? 0) + 1;
86
+ const retry = attempts < this.maxAttempts;
87
+ this.repository.markNotificationFailed(task.id, err?.message ?? String(err), retry);
88
+ this.display.log(`Task notification failed (${task.id}): ${err?.message ?? err}`, {
89
+ source: 'TaskNotifier',
90
+ level: retry ? 'warning' : 'error',
91
+ });
66
92
  }
67
93
  }
68
94
  }
@@ -393,6 +393,27 @@ export class TaskRepository {
393
393
  `).run(now, now, now - staleMs);
394
394
  return result.changes;
395
395
  }
396
+ /**
397
+ * Atomically claim a specific task for notification by ID.
398
+ * Returns the task record if successfully claimed, null if already claimed or not eligible.
399
+ */
400
+ claimNotificationById(taskId) {
401
+ const tx = this.db.transaction(() => {
402
+ const changed = this.db.prepare(`
403
+ UPDATE tasks
404
+ SET notify_status = 'sending',
405
+ notify_last_error = NULL,
406
+ updated_at = ?
407
+ WHERE id = ?
408
+ AND status IN ('completed', 'failed', 'cancelled')
409
+ AND notify_status = 'pending'
410
+ `).run(Date.now(), taskId);
411
+ if (changed.changes === 0)
412
+ return null;
413
+ return this.getTaskById(taskId);
414
+ });
415
+ return tx();
416
+ }
396
417
  claimNextNotificationCandidate(minFinishedAgeMs = 0) {
397
418
  const now = Date.now();
398
419
  const tx = this.db.transaction(() => {
@@ -3,6 +3,7 @@ import { DisplayManager } from '../display.js';
3
3
  import { SubagentRegistry } from '../subagents/registry.js';
4
4
  import { TaskRepository } from './repository.js';
5
5
  import { AuditRepository } from '../audit/repository.js';
6
+ import { taskEventBus } from './event-bus.js';
6
7
  export class TaskWorker {
7
8
  workerId;
8
9
  pollIntervalMs;
@@ -38,13 +39,14 @@ export class TaskWorker {
38
39
  }
39
40
  }
40
41
  tick() {
41
- if (this.activeTasks.size >= this.maxConcurrent)
42
- return;
43
- const task = this.repository.claimNextPending(this.workerId);
44
- if (!task)
45
- return;
46
- this.activeTasks.add(task.id);
47
- this.executeTask(task).finally(() => this.activeTasks.delete(task.id));
42
+ // Claim as many tasks as concurrency allows per tick
43
+ while (this.activeTasks.size < this.maxConcurrent) {
44
+ const task = this.repository.claimNextPending(this.workerId);
45
+ if (!task)
46
+ break;
47
+ this.activeTasks.add(task.id);
48
+ this.executeTask(task).finally(() => this.activeTasks.delete(task.id));
49
+ }
48
50
  }
49
51
  async executeTask(task) {
50
52
  const audit = AuditRepository.getInstance();
@@ -94,6 +96,7 @@ export class TaskWorker {
94
96
  });
95
97
  }
96
98
  this.display.log(`Task completed: ${task.id}`, { source: 'TaskWorker', level: 'success' });
99
+ taskEventBus.emit('task:ready', task.id);
97
100
  }
98
101
  catch (err) {
99
102
  const latest = this.repository.getTaskById(task.id);
@@ -116,6 +119,7 @@ export class TaskWorker {
116
119
  metadata: { error: errorMessage },
117
120
  });
118
121
  this.display.log(`Task failed: ${task.id} (${errorMessage})`, { source: 'TaskWorker', level: 'error' });
122
+ taskEventBus.emit('task:ready', task.id);
119
123
  }
120
124
  }
121
125
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "morpheus-cli",
3
- "version": "0.9.32",
3
+ "version": "0.9.33",
4
4
  "description": "Morpheus is a local AI agent for developers, running as a CLI daemon that connects to LLMs, local tools, and MCPs, enabling interaction via Terminal, Telegram, and Discord. Inspired by the character Morpheus from *The Matrix*, the project acts as an intelligent orchestrator, bridging the gap between the developer and complex systems.",
5
5
  "bin": {
6
6
  "morpheus": "./bin/morpheus.js"