morpheus-cli 0.5.5 → 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.
@@ -0,0 +1,244 @@
1
+ import Database from 'better-sqlite3';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { homedir } from 'os';
5
+ import { randomUUID } from 'crypto';
6
+ import { ConfigManager } from '../../config/manager.js';
7
+ export class ChronosError extends Error {
8
+ constructor(message) {
9
+ super(message);
10
+ this.name = 'ChronosError';
11
+ }
12
+ }
13
+ export class ChronosRepository {
14
+ static instance = null;
15
+ db;
16
+ constructor() {
17
+ const dbPath = path.join(homedir(), '.morpheus', 'memory', 'short-memory.db');
18
+ fs.ensureDirSync(path.dirname(dbPath));
19
+ this.db = new Database(dbPath, { timeout: 5000 });
20
+ this.db.pragma('journal_mode = WAL');
21
+ this.db.pragma('foreign_keys = ON');
22
+ this.ensureTable();
23
+ }
24
+ static getInstance() {
25
+ if (!ChronosRepository.instance) {
26
+ ChronosRepository.instance = new ChronosRepository();
27
+ }
28
+ return ChronosRepository.instance;
29
+ }
30
+ ensureTable() {
31
+ this.db.exec(`
32
+ CREATE TABLE IF NOT EXISTS chronos_jobs (
33
+ id TEXT PRIMARY KEY
34
+ );
35
+ `);
36
+ this.db.exec(`
37
+ CREATE TABLE IF NOT EXISTS chronos_executions (
38
+ id TEXT PRIMARY KEY
39
+ );
40
+ `);
41
+ this.migrateTable();
42
+ this.ensureIndexes();
43
+ }
44
+ migrateTable() {
45
+ const jobInfo = this.db.pragma('table_info(chronos_jobs)');
46
+ const jobCols = new Set(jobInfo.map((c) => c.name));
47
+ const addJobCol = (sql, col) => {
48
+ if (jobCols.has(col))
49
+ return;
50
+ this.db.exec(sql);
51
+ jobCols.add(col);
52
+ };
53
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN prompt TEXT NOT NULL DEFAULT ''`, 'prompt');
54
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN schedule_type TEXT NOT NULL DEFAULT 'once'`, 'schedule_type');
55
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN schedule_expression TEXT NOT NULL DEFAULT ''`, 'schedule_expression');
56
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN cron_normalized TEXT`, 'cron_normalized');
57
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'`, 'timezone');
58
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN next_run_at INTEGER`, 'next_run_at');
59
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN last_run_at INTEGER`, 'last_run_at');
60
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1`, 'enabled');
61
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN created_at INTEGER NOT NULL DEFAULT 0`, 'created_at');
62
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN updated_at INTEGER NOT NULL DEFAULT 0`, 'updated_at');
63
+ addJobCol(`ALTER TABLE chronos_jobs ADD COLUMN created_by TEXT NOT NULL DEFAULT 'api'`, 'created_by');
64
+ const execInfo = this.db.pragma('table_info(chronos_executions)');
65
+ const execCols = new Set(execInfo.map((c) => c.name));
66
+ const addExecCol = (sql, col) => {
67
+ if (execCols.has(col))
68
+ return;
69
+ this.db.exec(sql);
70
+ execCols.add(col);
71
+ };
72
+ addExecCol(`ALTER TABLE chronos_executions ADD COLUMN job_id TEXT NOT NULL DEFAULT ''`, 'job_id');
73
+ addExecCol(`ALTER TABLE chronos_executions ADD COLUMN triggered_at INTEGER NOT NULL DEFAULT 0`, 'triggered_at');
74
+ addExecCol(`ALTER TABLE chronos_executions ADD COLUMN completed_at INTEGER`, 'completed_at');
75
+ addExecCol(`ALTER TABLE chronos_executions ADD COLUMN status TEXT NOT NULL DEFAULT 'running'`, 'status');
76
+ addExecCol(`ALTER TABLE chronos_executions ADD COLUMN error TEXT`, 'error');
77
+ addExecCol(`ALTER TABLE chronos_executions ADD COLUMN session_id TEXT NOT NULL DEFAULT ''`, 'session_id');
78
+ }
79
+ ensureIndexes() {
80
+ this.db.exec(`
81
+ CREATE INDEX IF NOT EXISTS idx_chronos_jobs_next_run
82
+ ON chronos_jobs (enabled, next_run_at);
83
+ CREATE INDEX IF NOT EXISTS idx_chronos_jobs_created_by
84
+ ON chronos_jobs (created_by);
85
+ CREATE INDEX IF NOT EXISTS idx_chronos_executions_job
86
+ ON chronos_executions (job_id, triggered_at DESC);
87
+ `);
88
+ }
89
+ deserializeJob(row) {
90
+ return {
91
+ id: row.id,
92
+ prompt: row.prompt,
93
+ schedule_type: row.schedule_type,
94
+ schedule_expression: row.schedule_expression,
95
+ cron_normalized: row.cron_normalized ?? null,
96
+ timezone: row.timezone,
97
+ next_run_at: row.next_run_at ?? null,
98
+ last_run_at: row.last_run_at ?? null,
99
+ enabled: row.enabled === 1,
100
+ created_at: row.created_at,
101
+ updated_at: row.updated_at,
102
+ created_by: row.created_by,
103
+ };
104
+ }
105
+ deserializeExecution(row) {
106
+ return {
107
+ id: row.id,
108
+ job_id: row.job_id,
109
+ triggered_at: row.triggered_at,
110
+ completed_at: row.completed_at ?? null,
111
+ status: row.status,
112
+ error: row.error ?? null,
113
+ session_id: row.session_id,
114
+ };
115
+ }
116
+ // ─── T036: max_active_jobs enforcement ────────────────────────────────────
117
+ createJob(input) {
118
+ const cfg = ConfigManager.getInstance().getChronosConfig();
119
+ const activeCount = this.db.prepare(`SELECT COUNT(*) as cnt FROM chronos_jobs WHERE enabled = 1`).get().cnt;
120
+ if (activeCount >= cfg.max_active_jobs) {
121
+ throw new ChronosError(`Maximum active jobs limit (${cfg.max_active_jobs}) reached. Disable or delete an existing job first.`);
122
+ }
123
+ const now = Date.now();
124
+ const id = randomUUID();
125
+ this.db.prepare(`
126
+ INSERT INTO chronos_jobs (
127
+ id, prompt, schedule_type, schedule_expression, cron_normalized,
128
+ timezone, next_run_at, last_run_at, enabled, created_at, updated_at, created_by
129
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, 1, ?, ?, ?)
130
+ `).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);
131
+ return this.getJob(id);
132
+ }
133
+ getJob(id) {
134
+ const row = this.db.prepare('SELECT * FROM chronos_jobs WHERE id = ?').get(id);
135
+ return row ? this.deserializeJob(row) : null;
136
+ }
137
+ listJobs(filters) {
138
+ const params = [];
139
+ let query = 'SELECT * FROM chronos_jobs WHERE 1=1';
140
+ if (filters?.enabled !== undefined) {
141
+ query += ' AND enabled = ?';
142
+ params.push(filters.enabled ? 1 : 0);
143
+ }
144
+ if (filters?.created_by) {
145
+ query += ' AND created_by = ?';
146
+ params.push(filters.created_by);
147
+ }
148
+ query += ' ORDER BY created_at DESC';
149
+ const rows = this.db.prepare(query).all(...params);
150
+ return rows.map((r) => this.deserializeJob(r));
151
+ }
152
+ updateJob(id, patch) {
153
+ const now = Date.now();
154
+ const sets = ['updated_at = ?'];
155
+ const params = [now];
156
+ if (patch.prompt !== undefined) {
157
+ sets.push('prompt = ?');
158
+ params.push(patch.prompt);
159
+ }
160
+ if (patch.schedule_expression !== undefined) {
161
+ sets.push('schedule_expression = ?');
162
+ params.push(patch.schedule_expression);
163
+ }
164
+ if ('cron_normalized' in patch) {
165
+ sets.push('cron_normalized = ?');
166
+ params.push(patch.cron_normalized ?? null);
167
+ }
168
+ if (patch.timezone !== undefined) {
169
+ sets.push('timezone = ?');
170
+ params.push(patch.timezone);
171
+ }
172
+ if ('next_run_at' in patch) {
173
+ sets.push('next_run_at = ?');
174
+ params.push(patch.next_run_at ?? null);
175
+ }
176
+ if ('last_run_at' in patch) {
177
+ sets.push('last_run_at = ?');
178
+ params.push(patch.last_run_at ?? null);
179
+ }
180
+ if (patch.enabled !== undefined) {
181
+ sets.push('enabled = ?');
182
+ params.push(patch.enabled ? 1 : 0);
183
+ }
184
+ params.push(id);
185
+ this.db.prepare(`UPDATE chronos_jobs SET ${sets.join(', ')} WHERE id = ?`).run(...params);
186
+ return this.getJob(id);
187
+ }
188
+ deleteJob(id) {
189
+ const result = this.db.prepare('DELETE FROM chronos_jobs WHERE id = ?').run(id);
190
+ return result.changes > 0;
191
+ }
192
+ getDueJobs(nowMs) {
193
+ const rows = this.db.prepare(`
194
+ SELECT * FROM chronos_jobs
195
+ WHERE enabled = 1 AND next_run_at IS NOT NULL AND next_run_at <= ?
196
+ ORDER BY next_run_at ASC
197
+ `).all(nowMs);
198
+ return rows.map((r) => this.deserializeJob(r));
199
+ }
200
+ enableJob(id) {
201
+ const now = Date.now();
202
+ this.db.prepare(`UPDATE chronos_jobs SET enabled = 1, updated_at = ? WHERE id = ?`).run(now, id);
203
+ return this.getJob(id);
204
+ }
205
+ disableJob(id) {
206
+ const now = Date.now();
207
+ this.db.prepare(`UPDATE chronos_jobs SET enabled = 0, next_run_at = NULL, updated_at = ? WHERE id = ?`).run(now, id);
208
+ return this.getJob(id);
209
+ }
210
+ // ─── Executions ───────────────────────────────────────────────────────────
211
+ insertExecution(record) {
212
+ this.db.prepare(`
213
+ INSERT INTO chronos_executions (id, job_id, triggered_at, completed_at, status, error, session_id)
214
+ VALUES (?, ?, ?, NULL, ?, NULL, ?)
215
+ `).run(record.id, record.job_id, record.triggered_at, record.status, record.session_id);
216
+ }
217
+ completeExecution(id, status, error) {
218
+ const now = Date.now();
219
+ this.db.prepare(`
220
+ UPDATE chronos_executions SET status = ?, completed_at = ?, error = ? WHERE id = ?
221
+ `).run(status, now, error ?? null, id);
222
+ }
223
+ listExecutions(jobId, limit = 50) {
224
+ const rows = this.db.prepare(`
225
+ SELECT * FROM chronos_executions WHERE job_id = ?
226
+ ORDER BY triggered_at DESC LIMIT ?
227
+ `).all(jobId, Math.min(limit, 100));
228
+ return rows.map((r) => this.deserializeExecution(r));
229
+ }
230
+ pruneExecutions(jobId, keepCount) {
231
+ this.db.prepare(`
232
+ DELETE FROM chronos_executions
233
+ WHERE job_id = ? AND id NOT IN (
234
+ SELECT id FROM chronos_executions
235
+ WHERE job_id = ?
236
+ ORDER BY triggered_at DESC
237
+ LIMIT ?
238
+ )
239
+ `).run(jobId, jobId, keepCount);
240
+ }
241
+ close() {
242
+ this.db.close();
243
+ }
244
+ }
@@ -0,0 +1,141 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { ConfigManager } from '../../config/manager.js';
3
+ import { DisplayManager } from '../display.js';
4
+ import { parseNextRun } from './parser.js';
5
+ export class ChronosWorker {
6
+ repo;
7
+ oracle;
8
+ static instance = null;
9
+ static notifyFn = null;
10
+ /**
11
+ * True while a Chronos job is being executed. Chronos management tools
12
+ * (chronos_cancel, chronos_schedule) check this flag and refuse to operate
13
+ * during execution to prevent the Oracle from self-deleting or re-scheduling
14
+ * the active job.
15
+ */
16
+ static isExecuting = false;
17
+ timer = null;
18
+ isRunning = false;
19
+ pollIntervalMs;
20
+ constructor(repo, oracle) {
21
+ this.repo = repo;
22
+ this.oracle = oracle;
23
+ this.pollIntervalMs = ConfigManager.getInstance().getChronosConfig().check_interval_ms;
24
+ }
25
+ static getInstance() {
26
+ return ChronosWorker.instance;
27
+ }
28
+ static setInstance(worker) {
29
+ ChronosWorker.instance = worker;
30
+ }
31
+ /** Register a function that will deliver Oracle responses to users (e.g. Telegram). */
32
+ static setNotifyFn(fn) {
33
+ ChronosWorker.notifyFn = fn;
34
+ }
35
+ start() {
36
+ if (this.timer)
37
+ return;
38
+ const display = DisplayManager.getInstance();
39
+ display.log(`Worker started (interval: ${this.pollIntervalMs}ms)`, { source: 'Chronos' });
40
+ this.timer = setInterval(() => void this.tick(), this.pollIntervalMs);
41
+ }
42
+ stop() {
43
+ if (this.timer) {
44
+ clearInterval(this.timer);
45
+ this.timer = null;
46
+ }
47
+ const display = DisplayManager.getInstance();
48
+ display.log('Worker stopped', { source: 'Chronos' });
49
+ }
50
+ /** Hot-reload poll interval without restarting the process */
51
+ updateInterval(newMs) {
52
+ if (newMs < 60000)
53
+ return;
54
+ this.pollIntervalMs = newMs;
55
+ if (this.timer) {
56
+ clearInterval(this.timer);
57
+ this.timer = setInterval(() => void this.tick(), this.pollIntervalMs);
58
+ }
59
+ const display = DisplayManager.getInstance();
60
+ display.log(`Worker interval updated to ${newMs}ms`, { source: 'Chronos' });
61
+ }
62
+ async tick() {
63
+ if (this.isRunning)
64
+ return;
65
+ this.isRunning = true;
66
+ try {
67
+ const dueJobs = this.repo.getDueJobs(Date.now());
68
+ for (const job of dueJobs) {
69
+ void this.executeJob(job);
70
+ }
71
+ }
72
+ finally {
73
+ this.isRunning = false;
74
+ }
75
+ }
76
+ async executeJob(job) {
77
+ const display = DisplayManager.getInstance();
78
+ const execId = randomUUID();
79
+ display.log(`Job ${job.id} triggered — "${job.prompt.slice(0, 60)}"`, { source: 'Chronos' });
80
+ // Use the currently active Oracle session so Chronos executes in the
81
+ // user's conversation context — no session switching or isolation needed.
82
+ const activeSessionId = this.oracle.getCurrentSessionId() ?? 'default';
83
+ this.repo.insertExecution({
84
+ id: execId,
85
+ job_id: job.id,
86
+ triggered_at: Date.now(),
87
+ status: 'running',
88
+ session_id: activeSessionId,
89
+ });
90
+ try {
91
+ // Inject execution context as an AI message so it appears naturally in the
92
+ // conversation history without triggering an extra LLM response.
93
+ const contextMessage = `[CHRONOS EXECUTION — job_id: ${job.id}]\n` +
94
+ `Executing scheduled job. Do NOT call chronos_cancel, chronos_schedule, ` +
95
+ `or any Chronos management tools during this execution.`;
96
+ await this.oracle.injectAIMessage(contextMessage);
97
+ // If a Telegram notify function is registered, tag delegated tasks with
98
+ // origin_channel: 'telegram' so the TaskDispatcher broadcasts their result.
99
+ const taskContext = ChronosWorker.notifyFn
100
+ ? { origin_channel: 'telegram', session_id: activeSessionId }
101
+ : undefined;
102
+ // Hard-block Chronos management tools during execution.
103
+ ChronosWorker.isExecuting = true;
104
+ const response = await this.oracle.chat(job.prompt, undefined, false, taskContext);
105
+ this.repo.completeExecution(execId, 'success');
106
+ display.log(`Job ${job.id} completed — status: success`, { source: 'Chronos' });
107
+ // Deliver Oracle response to notification channels.
108
+ await this.notify(job, response);
109
+ }
110
+ catch (err) {
111
+ const errMsg = err?.message ?? String(err);
112
+ this.repo.completeExecution(execId, 'failed', errMsg);
113
+ display.log(`Job ${job.id} failed — ${errMsg}`, { source: 'Chronos', level: 'error' });
114
+ }
115
+ finally {
116
+ ChronosWorker.isExecuting = false;
117
+ if (job.schedule_type === 'once') {
118
+ this.repo.disableJob(job.id);
119
+ display.log(`Job ${job.id} auto-disabled (once-type)`, { source: 'Chronos' });
120
+ }
121
+ else if (job.cron_normalized) {
122
+ const nextRunAt = parseNextRun(job.cron_normalized, job.timezone);
123
+ this.repo.updateJob(job.id, { next_run_at: nextRunAt, last_run_at: Date.now() });
124
+ display.log(`Job ${job.id} rescheduled — next_run_at: ${new Date(nextRunAt).toISOString()}`, { source: 'Chronos' });
125
+ }
126
+ this.repo.pruneExecutions(job.id, 100);
127
+ }
128
+ }
129
+ async notify(job, response) {
130
+ if (!ChronosWorker.notifyFn)
131
+ return;
132
+ const display = DisplayManager.getInstance();
133
+ const header = `⏰ *Chronos* — _${job.prompt.slice(0, 80)}${job.prompt.length > 80 ? '…' : ''}_\n\n`;
134
+ try {
135
+ await ChronosWorker.notifyFn(header + response);
136
+ }
137
+ catch (err) {
138
+ display.log(`Job ${job.id} notification failed — ${err.message}`, { source: 'Chronos', level: 'error' });
139
+ }
140
+ }
141
+ }
@@ -0,0 +1,120 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { ChronosWorker } from './worker.js';
3
+ // Mock ConfigManager and DisplayManager
4
+ vi.mock('../../config/manager.js', () => ({
5
+ ConfigManager: {
6
+ getInstance: () => ({
7
+ getChronosConfig: () => ({ timezone: 'UTC', check_interval_ms: 60000, max_active_jobs: 100 }),
8
+ }),
9
+ },
10
+ }));
11
+ vi.mock('../display.js', () => ({
12
+ DisplayManager: {
13
+ getInstance: () => ({
14
+ log: vi.fn(),
15
+ }),
16
+ },
17
+ }));
18
+ function makeJob(overrides = {}) {
19
+ return {
20
+ id: 'job-1',
21
+ prompt: 'Say hello',
22
+ schedule_type: 'once',
23
+ schedule_expression: 'in 1 hour',
24
+ cron_normalized: null,
25
+ timezone: 'UTC',
26
+ next_run_at: Date.now() - 1000,
27
+ last_run_at: null,
28
+ enabled: true,
29
+ created_at: Date.now() - 5000,
30
+ updated_at: Date.now() - 5000,
31
+ created_by: 'ui',
32
+ ...overrides,
33
+ };
34
+ }
35
+ function makeRepo(jobs = []) {
36
+ return {
37
+ getDueJobs: vi.fn().mockReturnValue(jobs),
38
+ insertExecution: vi.fn(),
39
+ completeExecution: vi.fn(),
40
+ disableJob: vi.fn(),
41
+ enableJob: vi.fn(),
42
+ updateJob: vi.fn(),
43
+ pruneExecutions: vi.fn(),
44
+ createJob: vi.fn(),
45
+ getJob: vi.fn(),
46
+ listJobs: vi.fn(),
47
+ deleteJob: vi.fn(),
48
+ listExecutions: vi.fn(),
49
+ close: vi.fn(),
50
+ };
51
+ }
52
+ function makeOracle() {
53
+ return {
54
+ chat: vi.fn().mockResolvedValue('Oracle response'),
55
+ setSessionId: vi.fn().mockResolvedValue(undefined),
56
+ getCurrentSessionId: vi.fn().mockReturnValue('user-session-1'),
57
+ initialize: vi.fn(),
58
+ getHistory: vi.fn(),
59
+ createNewSession: vi.fn(),
60
+ clearMemory: vi.fn(),
61
+ reloadTools: vi.fn(),
62
+ };
63
+ }
64
+ describe('ChronosWorker.tick()', () => {
65
+ it('isRunning guard prevents concurrent execution', async () => {
66
+ const repo = makeRepo([]);
67
+ const oracle = makeOracle();
68
+ const worker = new ChronosWorker(repo, oracle);
69
+ // Set isRunning to true via tick (accessing private via casting)
70
+ worker.isRunning = true;
71
+ await worker.tick();
72
+ expect(repo.getDueJobs).not.toHaveBeenCalled();
73
+ });
74
+ it('calls getDueJobs with current timestamp', async () => {
75
+ const repo = makeRepo([]);
76
+ const oracle = makeOracle();
77
+ const worker = new ChronosWorker(repo, oracle);
78
+ const before = Date.now();
79
+ await worker.tick();
80
+ const after = Date.now();
81
+ const [[ts]] = repo.getDueJobs.mock.calls;
82
+ expect(ts).toBeGreaterThanOrEqual(before);
83
+ expect(ts).toBeLessThanOrEqual(after);
84
+ });
85
+ it('triggers oracle.chat for a once-type due job then disables it', async () => {
86
+ const job = makeJob({ schedule_type: 'once' });
87
+ const repo = makeRepo([job]);
88
+ const oracle = makeOracle();
89
+ const worker = new ChronosWorker(repo, oracle);
90
+ await worker.tick();
91
+ // Wait for fire-and-forget
92
+ await new Promise((r) => setTimeout(r, 50));
93
+ expect(oracle.chat).toHaveBeenCalledWith(expect.stringContaining(job.prompt));
94
+ expect(repo.disableJob).toHaveBeenCalledWith(job.id);
95
+ expect(repo.updateJob).not.toHaveBeenCalled();
96
+ });
97
+ it('triggers oracle.chat for a recurring job then updates next_run_at', async () => {
98
+ const job = makeJob({ schedule_type: 'cron', cron_normalized: '0 9 * * *' });
99
+ const repo = makeRepo([job]);
100
+ const oracle = makeOracle();
101
+ const worker = new ChronosWorker(repo, oracle);
102
+ await worker.tick();
103
+ await new Promise((r) => setTimeout(r, 50));
104
+ expect(oracle.chat).toHaveBeenCalledWith(expect.stringContaining(job.prompt));
105
+ expect(repo.disableJob).not.toHaveBeenCalled();
106
+ expect(repo.updateJob).toHaveBeenCalledWith(job.id, expect.objectContaining({ next_run_at: expect.any(Number) }));
107
+ });
108
+ it('sets execution status to failed when oracle.chat rejects', async () => {
109
+ const job = makeJob({ schedule_type: 'once' });
110
+ const repo = makeRepo([job]);
111
+ const oracle = makeOracle();
112
+ oracle.chat.mockRejectedValue(new Error('LLM error'));
113
+ const worker = new ChronosWorker(repo, oracle);
114
+ await worker.tick();
115
+ await new Promise((r) => setTimeout(r, 50));
116
+ expect(repo.completeExecution).toHaveBeenCalledWith(expect.any(String), 'failed', 'LLM error');
117
+ // For once-type, still auto-disable even on failure
118
+ expect(repo.disableJob).toHaveBeenCalledWith(job.id);
119
+ });
120
+ });
@@ -98,6 +98,9 @@ export class DisplayManager {
98
98
  else if (options.source === 'Zaion') {
99
99
  color = chalk.hex('#00c3ff');
100
100
  }
101
+ else if (options.source === 'Chronos') {
102
+ color = chalk.hex('#a855f7');
103
+ }
101
104
  prefix = color(`[${options.source}] `);
102
105
  }
103
106
  let formattedMessage = message;
@@ -828,6 +828,16 @@ export class SQLiteChatMessageHistory extends BaseListChatMessageHistory {
828
828
  * Se já for active, não faz nada.
829
829
  * Transação: sessão atual active → paused, sessão alvo → active.
830
830
  */
831
+ /**
832
+ * Creates a session row with status 'paused' if it doesn't already exist.
833
+ * Safe to call multiple times — idempotent.
834
+ */
835
+ ensureSession(sessionId) {
836
+ const existing = this.db.prepare('SELECT id FROM sessions WHERE id = ?').get(sessionId);
837
+ if (!existing) {
838
+ this.db.prepare("INSERT INTO sessions (id, started_at, status) VALUES (?, ?, 'paused')").run(sessionId, Date.now());
839
+ }
840
+ }
831
841
  async switchSession(targetSessionId) {
832
842
  // Validar sessão alvo: existe e status ∈ (paused, active)
833
843
  const targetSession = this.db.prepare(`
@@ -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 } 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, ...chronosTools]);
133
133
  if (!this.provider) {
134
134
  throw new Error("Provider factory returned undefined");
135
135
  }
@@ -180,7 +180,7 @@ Rules:
180
180
  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
181
  **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
182
  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.
183
+ 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
184
  4. Prefer delegation tools when execution should be asynchronous, and return the task acknowledgement clearly.
185
185
  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
186
  6. If the user asked for a single action, do not create additional delegated tasks.
@@ -422,6 +422,8 @@ Use it to inform your response and tool selection (if needed), but do not assume
422
422
  // `this.history = new SQLiteChatMessageHistory({ sessionId: sessionId, ... })`
423
423
  //
424
424
  // This is safe and clean.
425
+ // Ensure the target session exists before switching (creates as 'paused' if not found).
426
+ this.history.ensureSession(sessionId);
425
427
  await this.history.switchSession(sessionId);
426
428
  // Close previous connection before re-instantiating to avoid file handle leaks
427
429
  this.history.close();
@@ -436,6 +438,17 @@ Use it to inform your response and tool selection (if needed), but do not assume
436
438
  throw new Error("Current history provider does not support session switching.");
437
439
  }
438
440
  }
441
+ getCurrentSessionId() {
442
+ if (this.history instanceof SQLiteChatMessageHistory) {
443
+ return this.history.currentSessionId || null;
444
+ }
445
+ return null;
446
+ }
447
+ async injectAIMessage(content) {
448
+ if (!this.history)
449
+ throw new Error('Oracle not initialized.');
450
+ await this.history.addMessages([new AIMessage(content)]);
451
+ }
439
452
  async clearMemory() {
440
453
  if (!this.history) {
441
454
  throw new Error("Message history not initialized. Call initialize() first.");
@@ -448,7 +461,7 @@ Use it to inform your response and tool selection (if needed), but do not assume
448
461
  }
449
462
  await Neo.refreshDelegateCatalog().catch(() => { });
450
463
  await Trinity.refreshDelegateCatalog().catch(() => { });
451
- this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool]);
464
+ this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, ...chronosTools]);
452
465
  await Neo.getInstance().reload();
453
466
  this.display.log(`Oracle and Neo tools reloaded`, { source: 'Oracle' });
454
467
  }
@@ -43,10 +43,12 @@ export class TaskDispatcher {
43
43
  return;
44
44
  }
45
45
  if (task.origin_channel === 'ui') {
46
- const statusIcon = task.status === 'completed' ? '✅' : '❌';
46
+ const statusIcon = task.status === 'completed' ? '✅' : task.status === 'cancelled' ? '🚫' : '❌';
47
47
  const body = task.status === 'completed'
48
48
  ? (task.output && task.output.trim().length > 0 ? task.output : 'Task completed without output.')
49
- : (task.error && task.error.trim().length > 0 ? task.error : 'Task failed with unknown error.');
49
+ : task.status === 'cancelled'
50
+ ? 'Task was cancelled.'
51
+ : (task.error && task.error.trim().length > 0 ? task.error : 'Task failed with unknown error.');
50
52
  const content = `${statusIcon}\ Task \`${task.id.toUpperCase()}\`\n` +
51
53
  `Agent: \`${task.agent.toUpperCase()}\`\n` +
52
54
  `Status: \`${task.status.toUpperCase()}\`\n\n${body}`;
@@ -70,10 +72,12 @@ export class TaskDispatcher {
70
72
  if (!adapter) {
71
73
  throw new Error('Telegram adapter not connected');
72
74
  }
73
- const statusIcon = task.status === 'completed' ? '✅' : '❌';
75
+ const statusIcon = task.status === 'completed' ? '✅' : task.status === 'cancelled' ? '🚫' : '❌';
74
76
  const body = task.status === 'completed'
75
77
  ? (task.output && task.output.trim().length > 0 ? task.output : 'Task completed without output.')
76
- : (task.error && task.error.trim().length > 0 ? task.error : 'Task failed with unknown error.');
78
+ : task.status === 'cancelled'
79
+ ? 'Task was cancelled.'
80
+ : (task.error && task.error.trim().length > 0 ? task.error : 'Task failed with unknown error.');
77
81
  const header = `${statusIcon}\ Task \`${task.id.toUpperCase()}\`\n` +
78
82
  `Agent: \`${task.agent.toUpperCase()}\`\n` +
79
83
  `Status: \`${task.status.toUpperCase()}\``;
@@ -240,7 +240,7 @@ export class TaskRepository {
240
240
  notify_status = 'pending',
241
241
  notify_last_error = NULL,
242
242
  notified_at = NULL
243
- WHERE id = ?
243
+ WHERE id = ? AND status != 'cancelled'
244
244
  `).run(normalizedOutput.length > 0 ? normalizedOutput : 'Task completed without output.', now, now, id);
245
245
  }
246
246
  markFailed(id, error) {
@@ -253,9 +253,22 @@ export class TaskRepository {
253
253
  updated_at = ?,
254
254
  notify_status = 'pending',
255
255
  notified_at = NULL
256
- WHERE id = ?
256
+ WHERE id = ? AND status != 'cancelled'
257
257
  `).run(error, now, now, id);
258
258
  }
259
+ cancelTask(id) {
260
+ const now = Date.now();
261
+ const result = this.db.prepare(`
262
+ UPDATE tasks
263
+ SET status = 'cancelled',
264
+ finished_at = ?,
265
+ updated_at = ?,
266
+ notify_status = 'pending',
267
+ notified_at = NULL
268
+ WHERE id = ? AND status IN ('pending', 'running')
269
+ `).run(now, now, id);
270
+ return result.changes > 0;
271
+ }
259
272
  retryTask(id) {
260
273
  const now = Date.now();
261
274
  const result = this.db.prepare(`
@@ -306,7 +319,7 @@ export class TaskRepository {
306
319
  const row = this.db.prepare(`
307
320
  SELECT id
308
321
  FROM tasks
309
- WHERE status IN ('completed', 'failed')
322
+ WHERE status IN ('completed', 'failed', 'cancelled')
310
323
  AND notify_status = 'pending'
311
324
  AND finished_at IS NOT NULL
312
325
  AND finished_at <= ?
@@ -338,7 +351,7 @@ export class TaskRepository {
338
351
  SET notify_status = 'pending',
339
352
  notify_last_error = COALESCE(notify_last_error, 'Recovered notification queue state'),
340
353
  updated_at = ?
341
- WHERE status IN ('completed', 'failed')
354
+ WHERE status IN ('completed', 'failed', 'cancelled')
342
355
  AND (
343
356
  (notify_status = 'sending' AND updated_at <= ?)
344
357
  OR