morpheus-cli 0.5.6 → 0.6.1

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.
@@ -9,8 +9,8 @@ import { DisplayManager } from "../display.js";
9
9
  export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
10
10
  lc_namespace = ["langchain", "stores", "message", "sqlite"];
11
11
  display = DisplayManager.getInstance();
12
+ static migrationDone = false; // run migrations only once per process
12
13
  db;
13
- dbSati; // Optional separate DB for Sati memory, if needed in the future
14
14
  sessionId;
15
15
  limit;
16
16
  titleSet = false; // cache: skip setSessionTitleIfNeeded after title is set
@@ -23,20 +23,16 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
23
23
  this.limit = fields.limit ? fields.limit : 20;
24
24
  // Default path: ~/.morpheus/memory/short-memory.db
25
25
  const dbPath = fields.databasePath || path.join(homedir(), ".morpheus", "memory", "short-memory.db");
26
- const dbSatiPath = path.join(homedir(), '.morpheus', 'memory', 'sati-memory.db');
27
26
  // Ensure the directory exists
28
27
  this.ensureDirectory(dbPath);
29
- this.ensureDirectory(dbSatiPath);
30
28
  // Initialize database with retry logic for locked databases
31
29
  try {
32
30
  this.db = new Database(dbPath, {
33
31
  ...fields.config,
34
32
  timeout: 5000, // 5 second timeout for locks
35
33
  });
36
- this.dbSati = new Database(dbSatiPath, {
37
- ...fields.config,
38
- timeout: 5000,
39
- });
34
+ this.db.pragma('journal_mode = WAL');
35
+ this.db.pragma('synchronous = NORMAL');
40
36
  try {
41
37
  this.ensureTable();
42
38
  }
@@ -172,6 +168,9 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
172
168
  * Checks for missing columns and adds them if necessary.
173
169
  */
174
170
  migrateTable() {
171
+ if (SQLiteChatMessageHistory.migrationDone)
172
+ return;
173
+ SQLiteChatMessageHistory.migrationDone = true;
175
174
  try {
176
175
  // Migrate messages table
177
176
  const tableInfo = this.db.pragma('table_info(messages)');
@@ -669,6 +668,27 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
669
668
  tx(); // Executar a transação
670
669
  this.display.log('✅ Nova sessão iniciada e sessão anterior pausada', { source: 'Sati' });
671
670
  }
671
+ chunkText(text, chunkSize = 500, overlap = 50) {
672
+ if (!text || text.length === 0)
673
+ return [];
674
+ const chunks = [];
675
+ let start = 0;
676
+ while (start < text.length) {
677
+ let end = start + chunkSize;
678
+ if (end < text.length) {
679
+ const lastSpace = text.lastIndexOf(' ', end);
680
+ if (lastSpace > start)
681
+ end = lastSpace;
682
+ }
683
+ const chunk = text.slice(start, end).trim();
684
+ if (chunk.length > 0)
685
+ chunks.push(chunk);
686
+ start = end - overlap;
687
+ if (start < 0)
688
+ start = 0;
689
+ }
690
+ return chunks;
691
+ }
672
692
  /**
673
693
  * Encerrar uma sessão e transformá-la em memória do Sati.
674
694
  * Validar sessão existe e está em active ou paused.
@@ -711,30 +731,49 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
711
731
  const sessionText = messages
712
732
  .map(m => `[${m.type}] ${m.content}`)
713
733
  .join('\n\n');
714
- // Criar chunks (session_chunks) usando dbSati
715
- if (this.dbSati) {
716
- const chunks = this.chunkText(sessionText);
717
- for (let i = 0; i < chunks.length; i++) {
718
- this.dbSati.prepare(`
719
- INSERT INTO session_chunks (
720
- id,
721
- session_id,
722
- chunk_index,
723
- content,
724
- created_at
725
- ) VALUES (?, ?, ?, ?, ?)
726
- `).run(randomUUID(), sessionId, i, chunks[i], now);
727
- }
728
- this.display.log(`🧩 ${chunks.length} chunks criados para sessão ${sessionId}`, { source: 'Sati' });
729
- }
730
734
  // Remover mensagens da sessão após criar os chunks
731
735
  this.db.prepare(`
732
736
  DELETE FROM messages
733
737
  WHERE session_id = ?
734
738
  `).run(sessionId);
739
+ return sessionText;
735
740
  }
741
+ return null;
736
742
  });
737
- tx(); // Executar a transação
743
+ const sessionText = tx(); // Executar a transação
744
+ // Criar chunks no banco Sati — conexão aberta localmente e fechada ao fim
745
+ if (sessionText) {
746
+ const dbSatiPath = path.join(homedir(), '.morpheus', 'memory', 'sati-memory.db');
747
+ this.ensureDirectory(dbSatiPath);
748
+ const dbSati = new Database(dbSatiPath, { timeout: 5000 });
749
+ dbSati.pragma('journal_mode = WAL');
750
+ try {
751
+ dbSati.exec(`
752
+ CREATE TABLE IF NOT EXISTS session_chunks (
753
+ id TEXT PRIMARY KEY,
754
+ session_id TEXT NOT NULL,
755
+ chunk_index INTEGER NOT NULL,
756
+ content TEXT NOT NULL,
757
+ created_at INTEGER NOT NULL
758
+ );
759
+ CREATE INDEX IF NOT EXISTS idx_session_chunks_session_id ON session_chunks(session_id);
760
+ `);
761
+ const chunks = this.chunkText(sessionText);
762
+ const now = Date.now();
763
+ const insert = dbSati.prepare(`
764
+ INSERT INTO session_chunks (id, session_id, chunk_index, content, created_at)
765
+ VALUES (?, ?, ?, ?, ?)
766
+ `);
767
+ const insertMany = dbSati.transaction((items) => {
768
+ items.forEach((chunk, i) => insert.run(randomUUID(), sessionId, i, chunk, now));
769
+ });
770
+ insertMany(chunks);
771
+ this.display.log(`${chunks.length} chunks criados para sessão ${sessionId}`, { source: 'Sati' });
772
+ }
773
+ finally {
774
+ dbSati.close();
775
+ }
776
+ }
738
777
  }
739
778
  /**
740
779
  * Descartar completamente uma sessão sem gerar memória.
@@ -828,6 +867,16 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
828
867
  * Se já for active, não faz nada.
829
868
  * Transação: sessão atual active → paused, sessão alvo → active.
830
869
  */
870
+ /**
871
+ * Creates a session row with status 'paused' if it doesn't already exist.
872
+ * Safe to call multiple times — idempotent.
873
+ */
874
+ ensureSession(sessionId) {
875
+ const existing = this.db.prepare('SELECT id FROM sessions WHERE id = ?').get(sessionId);
876
+ if (!existing) {
877
+ this.db.prepare("INSERT INTO sessions (id, started_at, status) VALUES (?, ?, 'paused')").run(sessionId, Date.now());
878
+ }
879
+ }
831
880
  async switchSession(targetSessionId) {
832
881
  // Validar sessão alvo: existe e status ∈ (paused, active)
833
882
  const targetSession = this.db.prepare(`
@@ -909,31 +958,6 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
909
958
  `).run(newId, now);
910
959
  return newId;
911
960
  }
912
- chunkText(text, chunkSize = 500, overlap = 50) {
913
- if (!text || text.length === 0) {
914
- return [];
915
- }
916
- const chunks = [];
917
- let start = 0;
918
- while (start < text.length) {
919
- let end = start + chunkSize;
920
- // Evita cortar no meio da palavra
921
- if (end < text.length) {
922
- const lastSpace = text.lastIndexOf(' ', end);
923
- if (lastSpace > start) {
924
- end = lastSpace;
925
- }
926
- }
927
- const chunk = text.slice(start, end).trim();
928
- if (chunk.length > 0) {
929
- chunks.push(chunk);
930
- }
931
- start = end - overlap;
932
- if (start < 0)
933
- start = 0;
934
- }
935
- return chunks;
936
- }
937
961
  /**
938
962
  * Lists all active and paused sessions with their basic information.
939
963
  * Returns an array of session objects containing id, title, status, and started_at.
@@ -13,7 +13,7 @@ import { Trinity } from "./trinity.js";
13
13
  import { NeoDelegateTool } from "./tools/neo-tool.js";
14
14
  import { ApocDelegateTool } from "./tools/apoc-tool.js";
15
15
  import { TrinityDelegateTool } from "./tools/trinity-tool.js";
16
- import { TaskQueryTool } from "./tools/index.js";
16
+ import { TaskQueryTool, chronosTools, timeVerifierTool } from "./tools/index.js";
17
17
  import { MCPManager } from "../config/mcp-manager.js";
18
18
  export class Oracle {
19
19
  provider;
@@ -129,7 +129,7 @@ export class Oracle {
129
129
  // Fail-open: Oracle can still initialize even if catalog refresh fails.
130
130
  await Neo.refreshDelegateCatalog().catch(() => { });
131
131
  await Trinity.refreshDelegateCatalog().catch(() => { });
132
- this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool]);
132
+ this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, timeVerifierTool, ...chronosTools]);
133
133
  if (!this.provider) {
134
134
  throw new Error("Provider factory returned undefined");
135
135
  }
@@ -175,12 +175,20 @@ You are ${this.config.agent.name}, ${this.config.agent.personality}, the Oracle.
175
175
 
176
176
  You are an orchestrator and task router.
177
177
 
178
+ If the user request contains ANY time-related expression
179
+ (today, tomorrow, this week, next month, in 3 days, etc),
180
+ you **MUST** call the tool "time_verifier" before answering or call another tool **ALWAYS**.
181
+
182
+ With the time_verify, you remake the user prompt.
183
+
184
+ Never assume dates.
185
+ Always resolve temporal expressions using the tool.
178
186
 
179
187
  Rules:
180
188
  1. For conversation-only requests (greetings, conceptual explanation, memory follow-up, statements of fact, sharing personal information), answer directly. DO NOT create tasks or delegate for simple statements like "I have two cats" or "My name is John". Sati will automatically memorize facts in the background ( **ALWAYS** use SATI Memories to review or retrieve these facts if needed).
181
189
  **NEVER** Create data, use SATI memories to response on informal conversation or say that dont know abaout the awsor if the answer is in the memories. Always use the memories as source of truth for user facts, preferences, stable context and informal conversation. Use tools only for execution, verification or when external/system state is required.*
182
190
  2. For requests that require execution, verification, external/system state, or non-trivial operations, evaluate the available tools and choose the best one.
183
- 3. For task status/check questions (for example: "consultou?", "status da task", "andamento"), use task_query directly and do not delegate.
191
+ 3. For task status/check questions (for example: "consultou?", "status da task", "andamento"), use task_query directly and do not delegate. (normalize o id to downcase to send to task_query)
184
192
  4. Prefer delegation tools when execution should be asynchronous, and return the task acknowledgement clearly.
185
193
  5. If the user asked for multiple independent actions in the same message, enqueue one delegated task per action. Each task must be atomic (single objective).
186
194
  6. If the user asked for a single action, do not create additional delegated tasks.
@@ -310,6 +318,9 @@ Use it to inform your response and tool selection (if needed), but do not assume
310
318
  // Persist with addMessage so ack-provider usage is tracked per message row.
311
319
  await this.history.addMessage(userMessage);
312
320
  await this.history.addMessage(ackMessage);
321
+ // Unblock tasks for execution: the ack message is now persisted and will be
322
+ // returned to the caller (Telegram / UI) immediately after this point.
323
+ this.taskRepository.markAckSent(validDelegationAcks.map(a => a.task_id));
313
324
  }
314
325
  else if (mergedDelegationAcks.length > 0 || hadDelegationToolCall) {
315
326
  this.display.log(`Delegation attempted but no valid task id was confirmed (context=${contextDelegationAcks.length}, tool_messages=${toolDelegationAcks.length}, had_tool_call=${hadDelegationToolCall}).`, { source: "Oracle", level: "error" });
@@ -422,6 +433,8 @@ Use it to inform your response and tool selection (if needed), but do not assume
422
433
  // `this.history = new SQLiteChatMessageHistory({ sessionId: sessionId, ... })`
423
434
  //
424
435
  // This is safe and clean.
436
+ // Ensure the target session exists before switching (creates as 'paused' if not found).
437
+ this.history.ensureSession(sessionId);
425
438
  await this.history.switchSession(sessionId);
426
439
  // Close previous connection before re-instantiating to avoid file handle leaks
427
440
  this.history.close();
@@ -436,6 +449,17 @@ Use it to inform your response and tool selection (if needed), but do not assume
436
449
  throw new Error("Current history provider does not support session switching.");
437
450
  }
438
451
  }
452
+ getCurrentSessionId() {
453
+ if (this.history instanceof SQLiteChatMessageHistory) {
454
+ return this.history.currentSessionId || null;
455
+ }
456
+ return null;
457
+ }
458
+ async injectAIMessage(content) {
459
+ if (!this.history)
460
+ throw new Error('Oracle not initialized.');
461
+ await this.history.addMessages([new AIMessage(content)]);
462
+ }
439
463
  async clearMemory() {
440
464
  if (!this.history) {
441
465
  throw new Error("Message history not initialized. Call initialize() first.");
@@ -448,7 +472,7 @@ Use it to inform your response and tool selection (if needed), but do not assume
448
472
  }
449
473
  await Neo.refreshDelegateCatalog().catch(() => { });
450
474
  await Trinity.refreshDelegateCatalog().catch(() => { });
451
- this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool]);
475
+ this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, ...chronosTools]);
452
476
  await Neo.getInstance().reload();
453
477
  this.display.log(`Oracle and Neo tools reloaded`, { source: 'Oracle' });
454
478
  }
@@ -15,7 +15,7 @@ export class ProviderFactory {
15
15
  display.log(`Arguments: ${JSON.stringify(request.toolCall.args)}`, { level: "info", source: 'ConstructLoad' });
16
16
  try {
17
17
  const result = handler(request);
18
- display.log("Tool completed successfully", { level: "info", source: 'ConstructLoad' });
18
+ display.log(`Tool completed successfully. Result: ${JSON.stringify(result)}`, { level: "info", source: 'ConstructLoad' });
19
19
  return result;
20
20
  }
21
21
  catch (e) {
@@ -61,6 +61,7 @@ export class TaskRepository {
61
61
  addColumn(`ALTER TABLE tasks ADD COLUMN notify_last_error TEXT`, 'notify_last_error');
62
62
  addColumn(`ALTER TABLE tasks ADD COLUMN notified_at INTEGER`, 'notified_at');
63
63
  addColumn(`ALTER TABLE tasks ADD COLUMN notify_after_at INTEGER`, 'notify_after_at');
64
+ addColumn(`ALTER TABLE tasks ADD COLUMN ack_sent INTEGER NOT NULL DEFAULT 0`, 'ack_sent');
64
65
  this.db.exec(`
65
66
  UPDATE tasks
66
67
  SET
@@ -108,6 +109,7 @@ export class TaskRepository {
108
109
  notify_last_error: row.notify_last_error ?? null,
109
110
  notified_at: row.notified_at ?? null,
110
111
  notify_after_at: row.notify_after_at ?? null,
112
+ ack_sent: row.ack_sent === 1,
111
113
  };
112
114
  }
113
115
  /**
@@ -115,16 +117,20 @@ export class TaskRepository {
115
117
  * acknowledgement and the task result share the same delivery path (e.g. Telegram).
116
118
  * Channels with a synchronous ack (ui, api, cli, webhook) don't need this delay.
117
119
  */
118
- static DEFAULT_NOTIFY_AFTER_MS = 10_000;
120
+ static DEFAULT_NOTIFY_AFTER_MS = 1_000;
119
121
  static CHANNELS_NEEDING_ACK_GRACE = new Set(['telegram', 'discord']);
120
122
  createTask(input) {
121
123
  const now = Date.now();
122
124
  const id = randomUUID();
125
+ const needsAck = TaskRepository.CHANNELS_NEEDING_ACK_GRACE.has(input.origin_channel);
123
126
  const notify_after_at = input.notify_after_at !== undefined
124
127
  ? input.notify_after_at
125
- : TaskRepository.CHANNELS_NEEDING_ACK_GRACE.has(input.origin_channel)
128
+ : needsAck
126
129
  ? now + TaskRepository.DEFAULT_NOTIFY_AFTER_MS
127
130
  : null;
131
+ // ack_sent starts as 0 (blocked) for channels that send an ack message (telegram, discord).
132
+ // For other channels (ui, api, webhook, cli) there is no ack to wait for, so start as 1 (free).
133
+ const ack_sent = needsAck ? 0 : 1;
128
134
  this.db.prepare(`
129
135
  INSERT INTO tasks (
130
136
  id, agent, status, input, context, output, error,
@@ -132,16 +138,16 @@ export class TaskRepository {
132
138
  attempt_count, max_attempts, available_at,
133
139
  created_at, started_at, finished_at, updated_at, worker_id,
134
140
  notify_status, notify_attempts, notify_last_error, notified_at,
135
- notify_after_at
141
+ notify_after_at, ack_sent
136
142
  ) VALUES (
137
143
  ?, ?, 'pending', ?, ?, NULL, NULL,
138
144
  ?, ?, ?, ?,
139
145
  0, ?, ?,
140
146
  ?, NULL, NULL, ?, NULL,
141
147
  'pending', 0, NULL, NULL,
142
- ?
148
+ ?, ?
143
149
  )
144
- `).run(id, input.agent, input.input, input.context ?? null, input.origin_channel, input.session_id, input.origin_message_id ?? null, input.origin_user_id ?? null, input.max_attempts ?? 3, now, now, now, notify_after_at);
150
+ `).run(id, input.agent, input.input, input.context ?? null, input.origin_channel, input.session_id, input.origin_message_id ?? null, input.origin_user_id ?? null, input.max_attempts ?? 3, now, now, now, notify_after_at, ack_sent);
145
151
  return this.getTaskById(id);
146
152
  }
147
153
  getTaskById(id) {
@@ -199,16 +205,27 @@ export class TaskRepository {
199
205
  }
200
206
  return stats;
201
207
  }
208
+ /** Mark ack as sent for a list of task IDs, unblocking them for execution. */
209
+ markAckSent(ids) {
210
+ if (ids.length === 0)
211
+ return;
212
+ const placeholders = ids.map(() => '?').join(', ');
213
+ this.db.prepare(`UPDATE tasks SET ack_sent = 1, updated_at = ? WHERE id IN (${placeholders})`).run(Date.now(), ...ids);
214
+ }
215
+ /** Fallback grace period (ms): tasks older than this run even without ack_sent. */
216
+ static ACK_FALLBACK_MS = 60_000;
202
217
  claimNextPending(workerId) {
203
218
  const now = Date.now();
204
219
  const tx = this.db.transaction(() => {
205
220
  const row = this.db.prepare(`
206
221
  SELECT id
207
222
  FROM tasks
208
- WHERE status = 'pending' AND available_at <= ?
223
+ WHERE status = 'pending'
224
+ AND available_at <= ?
225
+ AND (ack_sent = 1 OR created_at <= ?)
209
226
  ORDER BY created_at ASC
210
227
  LIMIT 1
211
- `).get(now);
228
+ `).get(now, now - TaskRepository.ACK_FALLBACK_MS);
212
229
  if (!row)
213
230
  return null;
214
231
  const result = this.db.prepare(`
@@ -8,14 +8,16 @@ export class TaskWorker {
8
8
  workerId;
9
9
  pollIntervalMs;
10
10
  staleRunningMs;
11
+ maxConcurrent;
11
12
  repository = TaskRepository.getInstance();
12
13
  display = DisplayManager.getInstance();
13
14
  timer = null;
14
- running = false;
15
+ activeTasks = new Set(); // task IDs currently executing
15
16
  constructor(opts) {
16
17
  this.workerId = `task-worker-${randomUUID().slice(0, 8)}`;
17
- this.pollIntervalMs = opts?.pollIntervalMs ?? 1000;
18
+ this.pollIntervalMs = opts?.pollIntervalMs ?? 300;
18
19
  this.staleRunningMs = opts?.staleRunningMs ?? 5 * 60 * 1000;
20
+ this.maxConcurrent = opts?.maxConcurrent ?? parseInt(process.env.MORPHEUS_TASK_CONCURRENCY ?? '3', 10);
19
21
  }
20
22
  start() {
21
23
  if (this.timer)
@@ -25,7 +27,7 @@ export class TaskWorker {
25
27
  this.display.log(`Recovered ${recovered} stale running task(s).`, { source: 'TaskWorker', level: 'warning' });
26
28
  }
27
29
  this.timer = setInterval(() => {
28
- void this.tick();
30
+ this.tick();
29
31
  }, this.pollIntervalMs);
30
32
  this.display.log(`Task worker started (${this.workerId}).`, { source: 'TaskWorker' });
31
33
  }
@@ -36,19 +38,14 @@ export class TaskWorker {
36
38
  this.display.log(`Task worker stopped (${this.workerId}).`, { source: 'TaskWorker' });
37
39
  }
38
40
  }
39
- async tick() {
40
- if (this.running)
41
+ tick() {
42
+ if (this.activeTasks.size >= this.maxConcurrent)
41
43
  return;
42
- this.running = true;
43
- try {
44
- const task = this.repository.claimNextPending(this.workerId);
45
- if (!task)
46
- return;
47
- await this.executeTask(task);
48
- }
49
- finally {
50
- this.running = false;
51
- }
44
+ const task = this.repository.claimNextPending(this.workerId);
45
+ if (!task)
46
+ return;
47
+ this.activeTasks.add(task.id);
48
+ this.executeTask(task).finally(() => this.activeTasks.delete(task.id));
52
49
  }
53
50
  async executeTask(task) {
54
51
  try {
@@ -0,0 +1,181 @@
1
+ import { tool } from '@langchain/core/tools';
2
+ import { z } from 'zod';
3
+ import { ChronosRepository } from '../chronos/repository.js';
4
+ import { parseScheduleExpression, getNextOccurrences } from '../chronos/parser.js';
5
+ import { ConfigManager } from '../../config/manager.js';
6
+ import { ChronosWorker } from '../chronos/worker.js';
7
+ // ─── chronos_schedule ────────────────────────────────────────────────────────
8
+ export const ChronosScheduleTool = tool(async ({ prompt, schedule_type, schedule_expression, timezone }) => {
9
+ if (ChronosWorker.isExecuting) {
10
+ return JSON.stringify({ success: false, error: 'Cannot create a new Chronos job from within an active Chronos execution.' });
11
+ }
12
+ try {
13
+ const cfg = ConfigManager.getInstance().getChronosConfig();
14
+ const tz = timezone ?? cfg.timezone;
15
+ const parsed = parseScheduleExpression(schedule_expression, schedule_type, { timezone: tz });
16
+ const repo = ChronosRepository.getInstance();
17
+ const job = repo.createJob({
18
+ prompt,
19
+ schedule_type,
20
+ schedule_expression,
21
+ timezone: tz,
22
+ next_run_at: parsed.next_run_at,
23
+ cron_normalized: parsed.cron_normalized,
24
+ created_by: 'oracle',
25
+ });
26
+ return JSON.stringify({
27
+ success: true,
28
+ job_id: job.id,
29
+ prompt: job.prompt,
30
+ schedule_type: job.schedule_type,
31
+ human_readable: parsed.human_readable,
32
+ next_run_at: job.next_run_at != null ? new Date(job.next_run_at).toISOString() : null,
33
+ timezone: job.timezone,
34
+ });
35
+ }
36
+ catch (err) {
37
+ return JSON.stringify({ success: false, error: err.message });
38
+ }
39
+ }, {
40
+ name: 'chronos_schedule',
41
+ description: 'Schedule a prompt to be sent to the Oracle at a future time. ' +
42
+ 'Use schedule_type "once" for a single execution (e.g. "tomorrow at 9am", "in 2 hours", "2026-03-01T09:00:00"), ' +
43
+ '"cron" for a recurring cron expression (e.g. "0 9 * * 1-5" = every weekday at 9am), ' +
44
+ '"interval" for natural interval phrases (e.g. "every 30 minutes", "every 2 hours", "every day"). ' +
45
+ 'Returns the created job ID and the human-readable schedule confirmation.',
46
+ schema: z.object({
47
+ prompt: z.string().describe('The prompt text to send to Oracle at the scheduled time.'),
48
+ schedule_type: z
49
+ .enum(['once', 'cron', 'interval'])
50
+ .describe('"once" for one-time, "cron" for cron expression, "interval" for natural phrase.'),
51
+ schedule_expression: z
52
+ .string()
53
+ .describe('The schedule expression. For "once": ISO datetime or natural language like "tomorrow at 9am". ' +
54
+ 'For "cron": 5-field cron expression like "0 9 * * *". ' +
55
+ 'For "interval": phrase like "every 30 minutes" or "every 2 hours".'),
56
+ timezone: z
57
+ .string()
58
+ .optional()
59
+ .describe('IANA timezone (e.g. "America/Sao_Paulo"). Defaults to the global Chronos timezone config.'),
60
+ }),
61
+ });
62
+ // ─── chronos_list ────────────────────────────────────────────────────────────
63
+ export const ChronosListTool = tool(async ({ enabled_only }) => {
64
+ try {
65
+ const repo = ChronosRepository.getInstance();
66
+ const jobs = repo.listJobs(enabled_only !== false ? { enabled: true } : {});
67
+ if (jobs.length === 0) {
68
+ return JSON.stringify({ success: true, jobs: [], message: 'No Chronos jobs found.' });
69
+ }
70
+ const summary = jobs.map((j) => ({
71
+ id: j.id,
72
+ prompt: j.prompt.length > 80 ? j.prompt.slice(0, 80) + '…' : j.prompt,
73
+ schedule_type: j.schedule_type,
74
+ schedule_expression: j.schedule_expression,
75
+ next_run_at: j.next_run_at != null ? new Date(j.next_run_at).toISOString() : null,
76
+ last_run_at: j.last_run_at != null ? new Date(j.last_run_at).toISOString() : null,
77
+ enabled: j.enabled,
78
+ timezone: j.timezone,
79
+ created_by: j.created_by,
80
+ }));
81
+ return JSON.stringify({ success: true, jobs: summary, total: jobs.length });
82
+ }
83
+ catch (err) {
84
+ return JSON.stringify({ success: false, error: err.message });
85
+ }
86
+ }, {
87
+ name: 'chronos_list',
88
+ description: 'List Chronos scheduled jobs. By default only returns enabled (active) jobs. ' +
89
+ 'Set enabled_only to false to include disabled jobs too.',
90
+ schema: z.object({
91
+ enabled_only: z
92
+ .boolean()
93
+ .optional()
94
+ .describe('If true (default), only return enabled jobs. Set to false to include disabled ones.'),
95
+ }),
96
+ });
97
+ // ─── chronos_cancel ──────────────────────────────────────────────────────────
98
+ export const ChronosCancelTool = tool(async ({ job_id, action }) => {
99
+ if (ChronosWorker.isExecuting) {
100
+ return JSON.stringify({ success: false, error: 'Cannot manage Chronos jobs from within an active Chronos execution. Respond to the user normally without modifying the job.' });
101
+ }
102
+ try {
103
+ const repo = ChronosRepository.getInstance();
104
+ const job = repo.getJob(job_id);
105
+ if (!job) {
106
+ return JSON.stringify({ success: false, error: `No job found with id "${job_id}".` });
107
+ }
108
+ if (action === 'delete') {
109
+ repo.deleteJob(job_id);
110
+ return JSON.stringify({ success: true, message: `Job "${job_id}" deleted.` });
111
+ }
112
+ else if (action === 'disable') {
113
+ repo.disableJob(job_id);
114
+ return JSON.stringify({ success: true, message: `Job "${job_id}" disabled. It can be re-enabled later.` });
115
+ }
116
+ else {
117
+ repo.enableJob(job_id);
118
+ return JSON.stringify({ success: true, message: `Job "${job_id}" enabled.` });
119
+ }
120
+ }
121
+ catch (err) {
122
+ return JSON.stringify({ success: false, error: err.message });
123
+ }
124
+ }, {
125
+ name: 'chronos_cancel',
126
+ description: 'Manage an existing Chronos job: disable it (pause without losing it), enable it again, or permanently delete it. ' +
127
+ 'Use "disable" to pause a recurring job, "delete" to remove it permanently, "enable" to resume a disabled job.',
128
+ schema: z.object({
129
+ job_id: z.string().describe('The UUID of the Chronos job to manage.'),
130
+ action: z
131
+ .enum(['disable', 'enable', 'delete'])
132
+ .describe('"disable" pauses the job, "enable" resumes it, "delete" removes it permanently.'),
133
+ }),
134
+ });
135
+ // ─── chronos_preview ─────────────────────────────────────────────────────────
136
+ export const ChronosPreviewTool = tool(async ({ schedule_type, schedule_expression, timezone, occurrences }) => {
137
+ try {
138
+ const cfg = ConfigManager.getInstance().getChronosConfig();
139
+ const tz = timezone ?? cfg.timezone;
140
+ const count = occurrences ?? 3;
141
+ const parsed = parseScheduleExpression(schedule_expression, schedule_type, { timezone: tz });
142
+ let next_occurrences = [];
143
+ if (schedule_type !== 'once' && parsed.cron_normalized) {
144
+ const timestamps = getNextOccurrences(parsed.cron_normalized, tz, count);
145
+ next_occurrences = timestamps.map((ts) => new Date(ts).toISOString());
146
+ }
147
+ return JSON.stringify({
148
+ success: true,
149
+ valid: true,
150
+ human_readable: parsed.human_readable,
151
+ next_run_at: new Date(parsed.next_run_at).toISOString(),
152
+ next_occurrences,
153
+ timezone: tz,
154
+ });
155
+ }
156
+ catch (err) {
157
+ return JSON.stringify({ success: false, valid: false, error: err.message });
158
+ }
159
+ }, {
160
+ name: 'chronos_preview',
161
+ description: 'Preview a schedule expression without creating a job. ' +
162
+ 'Shows when the job would next run and the human-readable description. ' +
163
+ 'Useful for confirming a schedule before committing to it.',
164
+ schema: z.object({
165
+ schedule_type: z.enum(['once', 'cron', 'interval']).describe('Type of schedule expression.'),
166
+ schedule_expression: z.string().describe('The schedule expression to validate and preview.'),
167
+ timezone: z.string().optional().describe('IANA timezone. Defaults to global Chronos timezone.'),
168
+ occurrences: z
169
+ .number()
170
+ .min(1)
171
+ .max(10)
172
+ .optional()
173
+ .describe('How many future occurrences to show for recurring schedules (default 3).'),
174
+ }),
175
+ });
176
+ export const chronosTools = [
177
+ ChronosScheduleTool,
178
+ ChronosListTool,
179
+ ChronosCancelTool,
180
+ ChronosPreviewTool,
181
+ ];
@@ -2,3 +2,5 @@
2
2
  export * from './morpheus-tools.js';
3
3
  export * from './apoc-tool.js';
4
4
  export * from './neo-tool.js';
5
+ export * from './chronos-tools.js';
6
+ export * from './time-verify-tools.js';