morpheus-cli 0.5.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,8 +9,9 @@ It runs as a daemon and orchestrates LLMs, MCP tools, DevKit tools, memory, and
9
9
 
10
10
  ## Why Morpheus
11
11
  - Local-first persistence (sessions, messages, usage, tasks).
12
- - Multi-agent architecture (Oracle, Neo, Apoc, Sati).
12
+ - Multi-agent architecture (Oracle, Neo, Apoc, Sati, Trinity).
13
13
  - Async task execution with queue + worker + notifier.
14
+ - Chronos temporal scheduler for recurring and one-time Oracle executions.
14
15
  - Rich operational visibility in UI (chat traces, tasks, usage, logs).
15
16
 
16
17
  ## Multi-Agent Roles
@@ -19,6 +20,7 @@ It runs as a daemon and orchestrates LLMs, MCP tools, DevKit tools, memory, and
19
20
  - `Apoc`: DevTools/browser execution (filesystem, shell, git, network, packages, processes, system, browser automation).
20
21
  - `Sati`: long-term memory retrieval/evaluation.
21
22
  - `Trinity`: database specialist. Executes queries, introspects schemas, and manages registered databases (PostgreSQL, MySQL, SQLite, MongoDB).
23
+ - `Chronos`: temporal scheduler. Runs Oracle prompts on a recurring or one-time schedule.
22
24
 
23
25
  ## Installation
24
26
 
@@ -156,6 +158,36 @@ Important behavior:
156
158
  - Duplicate/fabricated task acknowledgements are blocked by validation against DB.
157
159
  - Status follow-ups are handled by Oracle through `task_query` (no delegation required).
158
160
 
161
+ ## Chronos — Temporal Scheduler
162
+
163
+ Chronos lets you schedule any Oracle prompt to run at a fixed time or on a recurring schedule.
164
+
165
+ **Schedule types:**
166
+ - `once` — run once at a specific time: `"in 30 minutes"`, `"tomorrow at 9am"`, `"2026-03-01T09:00:00"`
167
+ - `interval` — recurring natural language: `"every day at 9am"`, `"every weekday"`, `"every monday and friday at 8am"`, `"every 30 minutes"`
168
+ - `cron` — raw 5-field cron: `"0 9 * * 1-5"`
169
+
170
+ **Execution model:**
171
+ - Jobs run inside the currently active Oracle session — no isolated sessions are created per job.
172
+ - Chronos injects context as an AI message before invoking `oracle.chat()`, keeping conversation history clean.
173
+ - Delegated tasks spawned during Chronos execution carry `origin_channel: 'telegram'` when a Telegram notify function is registered, so task results are delivered to you.
174
+
175
+ **Oracle tools:** `chronos_schedule`, `chronos_list`, `chronos_cancel`, `chronos_preview`
176
+
177
+ **Telegram commands:**
178
+ - `/chronos <prompt> @ <schedule>` — create a job
179
+ - `/chronos_list` — list all jobs (🟢 active / 🔴 disabled)
180
+ - `/chronos_view <id>` — view job details and last 5 executions
181
+ - `/chronos_enable <id>` / `/chronos_disable <id>` / `/chronos_delete <id>`
182
+
183
+ **API endpoints (protected):**
184
+ - `GET/POST /api/chronos` — list / create jobs
185
+ - `GET/PUT/DELETE /api/chronos/:id` — read / update / delete
186
+ - `PATCH /api/chronos/:id/enable` / `.../disable`
187
+ - `GET /api/chronos/:id/executions`
188
+ - `POST /api/chronos/preview` — preview next N run timestamps
189
+ - `GET/POST/DELETE /api/config/chronos`
190
+
159
191
  ## Telegram Experience
160
192
 
161
193
  Telegram responses use rich HTML formatting conversion with:
@@ -175,6 +207,7 @@ The dashboard includes:
175
207
  - Sati memories (search, bulk delete)
176
208
  - Usage stats and model pricing
177
209
  - Trinity databases (register/test/refresh schema)
210
+ - Chronos scheduler (create/edit/delete jobs, execution history)
178
211
  - Webhooks and notification inbox
179
212
  - Logs viewer
180
213
 
@@ -223,6 +256,10 @@ trinity:
223
256
  model: gpt-4o-mini
224
257
  temperature: 0.2
225
258
 
259
+ chronos:
260
+ check_interval_ms: 60000 # polling interval in ms (minimum 60000)
261
+ default_timezone: UTC # IANA timezone used when none is specified
262
+
226
263
  runtime:
227
264
  async_tasks:
228
265
  enabled: true
@@ -352,10 +389,11 @@ Authenticated endpoints (`x-architect-pass`):
352
389
  - Sessions: `/api/sessions*`
353
390
  - Chat: `POST /api/chat`
354
391
  - Tasks: `GET /api/tasks`, `GET /api/tasks/stats`, `GET /api/tasks/:id`, `POST /api/tasks/:id/retry`
355
- - Config: `/api/config`, `/api/config/sati`, `/api/config/neo`, `/api/config/apoc`, `/api/config/trinity`
392
+ - Config: `/api/config`, `/api/config/sati`, `/api/config/neo`, `/api/config/apoc`, `/api/config/trinity`, `/api/config/chronos`
356
393
  - MCP: `/api/mcp/*` (servers CRUD + reload + status)
357
394
  - Sati memories: `/api/sati/memories*`
358
395
  - Trinity databases: `GET/POST/PUT/DELETE /api/trinity/databases`, `POST /api/trinity/databases/:id/test`, `POST /api/trinity/databases/:id/refresh-schema`
396
+ - 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`
359
397
  - Usage/model pricing/logs/restart
360
398
  - Webhook management and webhook notifications
361
399
 
@@ -462,6 +500,10 @@ src/
462
500
  trinity.ts
463
501
  trinity-connector.ts # PostgreSQL/MySQL/SQLite/MongoDB drivers
464
502
  trinity-crypto.ts # AES-256-GCM encryption for DB passwords
503
+ chronos/
504
+ worker.ts # polling timer and job execution
505
+ repository.ts # SQLite-backed job and execution store
506
+ parser.ts # natural-language schedule parser
465
507
  memory/
466
508
  tasks/
467
509
  tools/
@@ -123,6 +123,14 @@ function escMd(value) {
123
123
  // The - must be at end of character class to avoid being treated as a range.
124
124
  return String(value).replace(/([.!?(){}#+~|=>$@\\-])/g, '\\$1');
125
125
  }
126
+ /**
127
+ * Full MarkdownV2 escape — escapes ALL special characters including * _ ` [ ].
128
+ * Use for untrusted/user-generated content (session titles, prompts, etc.)
129
+ * placed inside bold/italic markers or anywhere in a MarkdownV2 message.
130
+ */
131
+ function escMdRaw(value) {
132
+ return String(value).replace(/([_*[\]()~`>#+=|{}.!\\-])/g, '\\$1');
133
+ }
126
134
  export class TelegramAdapter {
127
135
  bot = null;
128
136
  isConnected = false;
@@ -135,6 +143,8 @@ export class TelegramAdapter {
135
143
  history = new SQLiteChatMessageHistory({ sessionId: '' });
136
144
  RATE_LIMIT_MS = 3000; // minimum ms between requests per user
137
145
  rateLimiter = new Map(); // userId -> last request timestamp
146
+ // Pending Chronos create confirmations (userId -> job data + expiry)
147
+ pendingChronosCreate = new Map();
138
148
  isRateLimited(userId) {
139
149
  const now = Date.now();
140
150
  const last = this.rateLimiter.get(userId);
@@ -155,7 +165,13 @@ export class TelegramAdapter {
155
165
  /sessions \\- List all sessions with titles and switch between them
156
166
  /restart \\- Restart the Morpheus agent
157
167
  /mcpreload \\- Reload MCP servers without restarting
158
- /mcp or /mcps \\- List registered MCP servers`;
168
+ /mcp or /mcps \\- List registered MCP servers
169
+ /chronos <prompt \\+ time\\> \\- Schedule a prompt for the Oracle
170
+ /chronos\\_list \\- List all active Chronos jobs
171
+ /chronos\\_view <id\\> \\- View a Chronos job and its last executions
172
+ /chronos\\_disable <id\\> \\- Disable a Chronos job
173
+ /chronos\\_enable <id\\> \\- Enable a Chronos job
174
+ /chronos\\_delete <id\\> \\- Delete a Chronos job`;
159
175
  constructor(oracle) {
160
176
  this.oracle = oracle;
161
177
  }
@@ -187,6 +203,22 @@ export class TelegramAdapter {
187
203
  await this.handleSystemCommand(ctx, text, user);
188
204
  return;
189
205
  }
206
+ // Check for pending Chronos create confirmation
207
+ const pendingChronos = this.pendingChronosCreate.get(userId);
208
+ if (pendingChronos) {
209
+ const lower = text.trim().toLowerCase();
210
+ if (lower === 'yes' || lower === 'confirm' || lower === 'y') {
211
+ clearTimeout(pendingChronos.timer);
212
+ this.pendingChronosCreate.delete(userId);
213
+ await this.confirmChronosCreate(ctx, userId, pendingChronos);
214
+ }
215
+ else {
216
+ clearTimeout(pendingChronos.timer);
217
+ this.pendingChronosCreate.delete(userId);
218
+ await ctx.reply('Cancelled.');
219
+ }
220
+ return;
221
+ }
190
222
  // Rate limit check
191
223
  if (this.isRateLimited(userId)) {
192
224
  await ctx.reply('Please wait a moment before sending another message.');
@@ -738,10 +770,234 @@ export class TelegramAdapter {
738
770
  case '/sessions':
739
771
  await this.handleSessionStatusCommand(ctx, user);
740
772
  break;
773
+ case '/chronos': {
774
+ const userId = ctx.from?.id?.toString() ?? user;
775
+ const fullText = text.slice('/chronos'.length).trim();
776
+ await this.handleChronosCreate(ctx, userId, fullText);
777
+ break;
778
+ }
779
+ case '/chronos_list':
780
+ await this.handleChronosList(ctx);
781
+ break;
782
+ case '/chronos_view': {
783
+ const id = args[0] ?? '';
784
+ await this.handleChronosView(ctx, id);
785
+ break;
786
+ }
787
+ case '/chronos_disable': {
788
+ const id = args[0] ?? '';
789
+ await this.handleChronosDisable(ctx, id);
790
+ break;
791
+ }
792
+ case '/chronos_enable': {
793
+ const id = args[0] ?? '';
794
+ await this.handleChronosEnable(ctx, id);
795
+ break;
796
+ }
797
+ case '/chronos_delete': {
798
+ const id = args[0] ?? '';
799
+ await this.handleChronosDelete(ctx, id);
800
+ break;
801
+ }
741
802
  default:
742
803
  await this.handleDefaultCommand(ctx, user, command);
743
804
  }
744
805
  }
806
+ // ─── Chronos Command Handlers ────────────────────────────────────────────────
807
+ async handleChronosCreate(ctx, userId, fullText) {
808
+ if (!fullText) {
809
+ await ctx.reply('Usage: /chronos <prompt + time expression>\nExample: /chronos Check disk space tomorrow at 9am');
810
+ return;
811
+ }
812
+ try {
813
+ const { parse: chronoParse } = await import('chrono-node');
814
+ const results = chronoParse(fullText);
815
+ if (!results.length) {
816
+ await ctx.reply('Could not detect a time expression. Try: `/chronos Check disk space tomorrow at 9am`', { parse_mode: 'Markdown' });
817
+ return;
818
+ }
819
+ // Extract the first matched datetime fragment and derive the prompt
820
+ const match = results[0];
821
+ const matchedText = fullText.slice(match.index, match.index + match.text.length);
822
+ const prompt = (fullText.slice(0, match.index) + fullText.slice(match.index + match.text.length)).replace(/\s+/g, ' ').trim() || fullText;
823
+ const globalTz = this.config.getChronosConfig().timezone;
824
+ const { parseScheduleExpression } = await import('../runtime/chronos/parser.js');
825
+ const schedule = parseScheduleExpression(matchedText, 'once', { timezone: globalTz });
826
+ const formatted = new Date(schedule.next_run_at).toLocaleString('en-US', {
827
+ timeZone: globalTz, year: 'numeric', month: 'short', day: 'numeric',
828
+ hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
829
+ });
830
+ const timer = setTimeout(() => {
831
+ this.pendingChronosCreate.delete(userId);
832
+ }, 5 * 60 * 1000);
833
+ this.pendingChronosCreate.set(userId, {
834
+ prompt,
835
+ schedule_expression: matchedText,
836
+ human_readable: schedule.human_readable,
837
+ timezone: globalTz,
838
+ expiresAt: Date.now() + 5 * 60 * 1000,
839
+ timer,
840
+ });
841
+ await ctx.reply(`📅 *${prompt}*\n${schedule.human_readable} (${formatted})\n\nConfirm? Reply \`yes\` or \`no\``, { parse_mode: 'Markdown' });
842
+ }
843
+ catch (err) {
844
+ await ctx.reply(`Error: ${err.message}`);
845
+ }
846
+ }
847
+ async confirmChronosCreate(ctx, _userId, pending) {
848
+ if (Date.now() > pending.expiresAt) {
849
+ await ctx.reply('Confirmation expired. Please run /chronos again.');
850
+ return;
851
+ }
852
+ try {
853
+ const { parseScheduleExpression } = await import('../runtime/chronos/parser.js');
854
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
855
+ const schedule = parseScheduleExpression(pending.schedule_expression, 'once', {
856
+ timezone: pending.timezone,
857
+ });
858
+ const repo = ChronosRepository.getInstance();
859
+ const job = repo.createJob({
860
+ prompt: pending.prompt,
861
+ schedule_type: 'once',
862
+ schedule_expression: pending.schedule_expression,
863
+ cron_normalized: schedule.cron_normalized,
864
+ timezone: pending.timezone,
865
+ next_run_at: schedule.next_run_at,
866
+ created_by: 'telegram',
867
+ });
868
+ const formatted = new Date(schedule.next_run_at).toLocaleString('en-US', {
869
+ timeZone: pending.timezone, year: 'numeric', month: 'short', day: 'numeric',
870
+ hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
871
+ });
872
+ await ctx.reply(`✅ Job created (ID: \`${job.id.slice(0, 8)}\`)\n${schedule.human_readable}\n${formatted}`, {
873
+ parse_mode: 'Markdown',
874
+ });
875
+ }
876
+ catch (err) {
877
+ await ctx.reply(`Failed to create job: ${err.message}`);
878
+ }
879
+ }
880
+ async handleChronosList(ctx) {
881
+ try {
882
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
883
+ const repo = ChronosRepository.getInstance();
884
+ const jobs = repo.listJobs();
885
+ if (!jobs.length) {
886
+ await ctx.reply('No Chronos jobs found.');
887
+ return;
888
+ }
889
+ const lines = jobs.map((j, i) => {
890
+ const status = j.enabled ? '🟢' : '🔴';
891
+ const next = j.enabled && j.next_run_at
892
+ ? new Date(j.next_run_at).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
893
+ : j.enabled ? 'N/A' : 'disabled';
894
+ const prompt = j.prompt.length > 35 ? j.prompt.slice(0, 35) + '…' : j.prompt;
895
+ return `${status} ${i + 1}. \`${j.id}\` \n${prompt}\n _${next}_`;
896
+ });
897
+ await ctx.reply(`*Chronos Jobs*\n\n${lines.join('\n')}`, { parse_mode: 'Markdown' });
898
+ }
899
+ catch (err) {
900
+ await ctx.reply(`Error: ${err.message}`);
901
+ }
902
+ }
903
+ async handleChronosView(ctx, id) {
904
+ if (!id) {
905
+ await ctx.reply('Usage: /chronos_view <job_id>');
906
+ return;
907
+ }
908
+ try {
909
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
910
+ const repo = ChronosRepository.getInstance();
911
+ const job = repo.getJob(id);
912
+ if (!job) {
913
+ await ctx.reply('Job not found.');
914
+ return;
915
+ }
916
+ const executions = repo.listExecutions(id, 3);
917
+ const next = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'N/A';
918
+ const last = job.last_run_at ? new Date(job.last_run_at).toLocaleString() : 'Never';
919
+ const execLines = executions.map(e => ` • ${e.status.toUpperCase()} — ${new Date(e.triggered_at).toLocaleString()}`).join('\n') || ' None yet';
920
+ const msg = `*Chronos Job* \`${id.slice(0, 8)}\`\n\n` +
921
+ `*Prompt:* ${job.prompt}\n` +
922
+ `*Schedule:* ${job.schedule_type} — \`${job.schedule_expression}\`\n` +
923
+ `*Timezone:* ${job.timezone}\n` +
924
+ `*Status:* ${job.enabled ? 'Enabled' : 'Disabled'}\n` +
925
+ `*Next Run:* ${next}\n` +
926
+ `*Last Run:* ${last}\n\n` +
927
+ `*Last 3 Executions:*\n${execLines}`;
928
+ await ctx.reply(msg, { parse_mode: 'Markdown' });
929
+ }
930
+ catch (err) {
931
+ await ctx.reply(`Error: ${err.message}`);
932
+ }
933
+ }
934
+ async handleChronosDisable(ctx, id) {
935
+ if (!id) {
936
+ await ctx.reply('Usage: /chronos_disable <job_id>');
937
+ return;
938
+ }
939
+ try {
940
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
941
+ const repo = ChronosRepository.getInstance();
942
+ const job = repo.disableJob(id);
943
+ if (!job) {
944
+ await ctx.reply('Job not found.');
945
+ return;
946
+ }
947
+ await ctx.reply(`Job \`${id.slice(0, 8)}\` disabled.`, { parse_mode: 'Markdown' });
948
+ }
949
+ catch (err) {
950
+ await ctx.reply(`Error: ${err.message}`);
951
+ }
952
+ }
953
+ async handleChronosEnable(ctx, id) {
954
+ if (!id) {
955
+ await ctx.reply('Usage: /chronos_enable <job_id>');
956
+ return;
957
+ }
958
+ try {
959
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
960
+ const { parseNextRun } = await import('../runtime/chronos/parser.js');
961
+ const repo = ChronosRepository.getInstance();
962
+ const existing = repo.getJob(id);
963
+ if (!existing) {
964
+ await ctx.reply('Job not found.');
965
+ return;
966
+ }
967
+ let nextRunAt;
968
+ if (existing.cron_normalized) {
969
+ // cron_normalized is always a 5-field cron string — use parseNextRun directly
970
+ nextRunAt = parseNextRun(existing.cron_normalized, existing.timezone);
971
+ }
972
+ repo.updateJob(id, { enabled: true, next_run_at: nextRunAt });
973
+ const job = repo.getJob(id);
974
+ const next = job.next_run_at ? new Date(job.next_run_at).toLocaleString() : 'N/A';
975
+ await ctx.reply(`Job \`${id.slice(0, 8)}\` enabled. Next run: ${next}`, { parse_mode: 'Markdown' });
976
+ }
977
+ catch (err) {
978
+ await ctx.reply(`Error: ${err.message}`);
979
+ }
980
+ }
981
+ async handleChronosDelete(ctx, id) {
982
+ if (!id) {
983
+ await ctx.reply('Usage: /chronos_delete <job_id>');
984
+ return;
985
+ }
986
+ try {
987
+ const { ChronosRepository } = await import('../runtime/chronos/repository.js');
988
+ const repo = ChronosRepository.getInstance();
989
+ const deleted = repo.deleteJob(id);
990
+ if (!deleted) {
991
+ await ctx.reply('Job not found.');
992
+ return;
993
+ }
994
+ await ctx.reply(`Job \`${id.slice(0, 8)}\` deleted.`, { parse_mode: 'Markdown' });
995
+ }
996
+ catch (err) {
997
+ await ctx.reply(`Error: ${err.message}`);
998
+ }
999
+ }
1000
+ // ─── End Chronos ──────────────────────────────────────────────────────────────
745
1001
  async handleNewSessionCommand(ctx, user) {
746
1002
  try {
747
1003
  await ctx.reply("Are you ready to start a new session\\? Please confirm\\.", {
@@ -767,9 +1023,10 @@ export class TelegramAdapter {
767
1023
  }
768
1024
  async handleSessionStatusCommand(ctx, user) {
769
1025
  try {
770
- // Obter todas as sessões ativas e pausadas usando a nova função
771
1026
  const history = new SQLiteChatMessageHistory({ sessionId: "" });
772
- const sessions = await history.listSessions();
1027
+ // Exclude automated Chronos sessions their IDs exceed Telegram's 64-byte
1028
+ // callback_data limit and they are not user-managed sessions.
1029
+ const sessions = (await history.listSessions()).filter((s) => !s.id.startsWith('chronos-job-') && !s.id.startsWith('sati-evaluation'));
773
1030
  if (sessions.length === 0) {
774
1031
  await ctx.reply('No active or paused sessions found\\.', { parse_mode: 'MarkdownV2' });
775
1032
  return;
@@ -779,10 +1036,10 @@ export class TelegramAdapter {
779
1036
  for (const session of sessions) {
780
1037
  const title = session.title || 'Untitled Session';
781
1038
  const statusEmoji = session.status === 'active' ? '🟢' : '🟡';
782
- response += `${statusEmoji} *${escMd(title)}*\n`;
783
- response += `\\- ID: ${escMd(session.id)}\n`;
784
- response += `\\- Status: ${escMd(session.status)}\n`;
785
- response += `\\- Started: ${escMd(new Date(session.started_at).toLocaleString())}\n\n`;
1039
+ response += `${statusEmoji} *${escMdRaw(title)}*\n`;
1040
+ response += `\\- ID: \`${escMdRaw(session.id)}\`\n`;
1041
+ response += `\\- Status: ${escMdRaw(session.status)}\n`;
1042
+ response += `\\- Started: ${escMdRaw(new Date(session.started_at).toLocaleString())}\n\n`;
786
1043
  // Adicionar botão inline para alternar para esta sessão
787
1044
  const sessionButtons = [];
788
1045
  if (session.status !== 'active') {
@@ -18,6 +18,8 @@ import { startSessionEmbeddingScheduler } from '../../runtime/session-embedding-
18
18
  import { TaskWorker } from '../../runtime/tasks/worker.js';
19
19
  import { TaskNotifier } from '../../runtime/tasks/notifier.js';
20
20
  import { TaskDispatcher } from '../../runtime/tasks/dispatcher.js';
21
+ import { ChronosWorker } from '../../runtime/chronos/worker.js';
22
+ import { ChronosRepository } from '../../runtime/chronos/repository.js';
21
23
  export const startCommand = new Command('start')
22
24
  .description('Start the Morpheus agent')
23
25
  .option('--ui', 'Enable web UI', true)
@@ -125,10 +127,13 @@ export const startCommand = new Command('start')
125
127
  const taskWorker = new TaskWorker();
126
128
  const taskNotifier = new TaskNotifier();
127
129
  const asyncTasksEnabled = config.runtime?.async_tasks?.enabled !== false;
130
+ const chronosRepo = ChronosRepository.getInstance();
131
+ const chronosWorker = new ChronosWorker(chronosRepo, oracle);
132
+ ChronosWorker.setInstance(chronosWorker);
128
133
  // Initialize Web UI
129
134
  if (options.ui && config.ui.enabled) {
130
135
  try {
131
- httpServer = new HttpServer(oracle);
136
+ httpServer = new HttpServer(oracle, chronosWorker);
132
137
  // Use CLI port if provided and valid, otherwise fallback to config or default
133
138
  const port = parseInt(options.port) || config.ui.port || 3333;
134
139
  httpServer.start(port);
@@ -146,6 +151,7 @@ export const startCommand = new Command('start')
146
151
  // Wire Telegram adapter to webhook dispatcher for proactive notifications
147
152
  WebhookDispatcher.setTelegramAdapter(telegram);
148
153
  TaskDispatcher.setTelegramAdapter(telegram);
154
+ ChronosWorker.setNotifyFn((text) => telegram.sendMessage(text));
149
155
  adapters.push(telegram);
150
156
  }
151
157
  catch (e) {
@@ -158,6 +164,7 @@ export const startCommand = new Command('start')
158
164
  }
159
165
  // Start Background Services
160
166
  startSessionEmbeddingScheduler();
167
+ chronosWorker.start();
161
168
  if (asyncTasksEnabled) {
162
169
  taskWorker.start();
163
170
  taskNotifier.start();
@@ -176,6 +183,7 @@ export const startCommand = new Command('start')
176
183
  for (const adapter of adapters) {
177
184
  await adapter.disconnect();
178
185
  }
186
+ chronosWorker.stop();
179
187
  if (asyncTasksEnabled) {
180
188
  taskWorker.stop();
181
189
  taskNotifier.stop();
@@ -186,6 +186,15 @@ export class ConfigManager {
186
186
  const memoryConfig = {
187
187
  limit: config.memory.limit // Not applying env var precedence to deprecated field
188
188
  };
189
+ // Apply precedence to Chronos config
190
+ let chronosConfig;
191
+ if (config.chronos) {
192
+ chronosConfig = {
193
+ timezone: resolveString('MORPHEUS_CHRONOS_TIMEZONE', config.chronos.timezone, 'UTC'),
194
+ check_interval_ms: resolveNumeric('MORPHEUS_CHRONOS_CHECK_INTERVAL_MS', config.chronos.check_interval_ms, 60000),
195
+ max_active_jobs: resolveNumeric('MORPHEUS_CHRONOS_MAX_ACTIVE_JOBS', config.chronos.max_active_jobs, 100),
196
+ };
197
+ }
189
198
  return {
190
199
  agent: agentConfig,
191
200
  llm: llmConfig,
@@ -197,7 +206,8 @@ export class ConfigManager {
197
206
  channels: channelsConfig,
198
207
  ui: uiConfig,
199
208
  logging: loggingConfig,
200
- memory: memoryConfig
209
+ memory: memoryConfig,
210
+ chronos: chronosConfig,
201
211
  };
202
212
  }
203
213
  get() {
@@ -267,4 +277,11 @@ export class ConfigManager {
267
277
  // Fallback to main LLM config
268
278
  return { ...this.config.llm };
269
279
  }
280
+ getChronosConfig() {
281
+ const defaults = { timezone: 'UTC', check_interval_ms: 60000, max_active_jobs: 100 };
282
+ if (this.config.chronos) {
283
+ return { ...defaults, ...this.config.chronos };
284
+ }
285
+ return defaults;
286
+ }
270
287
  }
@@ -31,6 +31,11 @@ export const TrinityConfigSchema = LLMConfigSchema;
31
31
  export const WebhookConfigSchema = z.object({
32
32
  telegram_notify_all: z.boolean().optional(),
33
33
  }).optional();
34
+ export const ChronosConfigSchema = z.object({
35
+ timezone: z.string().default('UTC'),
36
+ check_interval_ms: z.number().min(60000).default(60000),
37
+ max_active_jobs: z.number().min(1).max(1000).default(100),
38
+ });
34
39
  // Zod Schema matching MorpheusConfig interface
35
40
  export const ConfigSchema = z.object({
36
41
  agent: z.object({
@@ -52,6 +57,7 @@ export const ConfigSchema = z.object({
52
57
  enabled: z.boolean().default(DEFAULT_CONFIG.runtime?.async_tasks.enabled ?? true),
53
58
  }).default(DEFAULT_CONFIG.runtime?.async_tasks ?? { enabled: true }),
54
59
  }).optional(),
60
+ chronos: ChronosConfigSchema.optional(),
55
61
  channels: z.object({
56
62
  telegram: z.object({
57
63
  enabled: z.boolean().default(false),
package/dist/http/api.js CHANGED
@@ -15,6 +15,9 @@ import { TaskRepository } from '../runtime/tasks/repository.js';
15
15
  import { DatabaseRegistry } from '../runtime/memory/trinity-db.js';
16
16
  import { testConnection, introspectSchema } from '../runtime/trinity-connector.js';
17
17
  import { Trinity } from '../runtime/trinity.js';
18
+ import { ChronosRepository } from '../runtime/chronos/repository.js';
19
+ import { ChronosWorker } from '../runtime/chronos/worker.js';
20
+ import { createChronosJobRouter, createChronosConfigRouter } from './routers/chronos.js';
18
21
  async function readLastLines(filePath, n) {
19
22
  try {
20
23
  const content = await fs.readFile(filePath, 'utf8');
@@ -25,11 +28,18 @@ async function readLastLines(filePath, n) {
25
28
  return [];
26
29
  }
27
30
  }
28
- export function createApiRouter(oracle) {
31
+ export function createApiRouter(oracle, chronosWorker) {
29
32
  const router = Router();
30
33
  const configManager = ConfigManager.getInstance();
31
34
  const history = new SQLiteChatMessageHistory({ sessionId: 'api-reader' });
32
35
  const taskRepository = TaskRepository.getInstance();
36
+ const chronosRepo = ChronosRepository.getInstance();
37
+ const worker = chronosWorker ?? ChronosWorker.getInstance();
38
+ // Mount Chronos routers
39
+ if (worker) {
40
+ router.use('/chronos', createChronosJobRouter(chronosRepo, worker));
41
+ router.use('/config/chronos', createChronosConfigRouter(worker));
42
+ }
33
43
  // --- Session Management ---
34
44
  router.get('/sessions', async (req, res) => {
35
45
  try {
@@ -1011,7 +1021,7 @@ export function createApiRouter(oracle) {
1011
1021
  return res.status(404).json({ error: 'Log file not found' });
1012
1022
  }
1013
1023
  const lines = await readLastLines(filePath, limit);
1014
- res.json({ lines: lines.reverse() });
1024
+ res.json({ lines: lines });
1015
1025
  });
1016
1026
  return router;
1017
1027
  }