@curie-agent/daemon 0.2.5
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/LICENSE +179 -0
- package/dist/src/approval-tracker.d.ts +42 -0
- package/dist/src/approval-tracker.d.ts.map +1 -0
- package/dist/src/approval-tracker.js +78 -0
- package/dist/src/approval-tracker.js.map +1 -0
- package/dist/src/auth.d.ts +17 -0
- package/dist/src/auth.d.ts.map +1 -0
- package/dist/src/auth.js +94 -0
- package/dist/src/auth.js.map +1 -0
- package/dist/src/channel-manager.d.ts +50 -0
- package/dist/src/channel-manager.d.ts.map +1 -0
- package/dist/src/channel-manager.js +184 -0
- package/dist/src/channel-manager.js.map +1 -0
- package/dist/src/daemon-app.d.ts +88 -0
- package/dist/src/daemon-app.d.ts.map +1 -0
- package/dist/src/daemon-app.js +496 -0
- package/dist/src/daemon-app.js.map +1 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +26 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/jsonrpc-handler.d.ts +47 -0
- package/dist/src/jsonrpc-handler.d.ts.map +1 -0
- package/dist/src/jsonrpc-handler.js +2205 -0
- package/dist/src/jsonrpc-handler.js.map +1 -0
- package/dist/src/server.d.ts +49 -0
- package/dist/src/server.d.ts.map +1 -0
- package/dist/src/server.js +204 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/slash-cd.d.ts +38 -0
- package/dist/src/slash-cd.d.ts.map +1 -0
- package/dist/src/slash-cd.js +93 -0
- package/dist/src/slash-cd.js.map +1 -0
- package/dist/src/static-files.d.ts +7 -0
- package/dist/src/static-files.d.ts.map +1 -0
- package/dist/src/static-files.js +73 -0
- package/dist/src/static-files.js.map +1 -0
- package/dist/src/ws-handler.d.ts +21 -0
- package/dist/src/ws-handler.d.ts.map +1 -0
- package/dist/src/ws-handler.js +119 -0
- package/dist/src/ws-handler.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +37 -0
|
@@ -0,0 +1,2205 @@
|
|
|
1
|
+
import os, { homedir } from 'node:os';
|
|
2
|
+
import path, { join } from 'node:path';
|
|
3
|
+
import { readFileSync, existsSync, readdirSync, statSync, writeFileSync, mkdirSync, realpathSync } from 'node:fs';
|
|
4
|
+
import { Method } from '@curie-agent/protocol';
|
|
5
|
+
import { TurnLoop, parseReminderTime, listSnapshots, revertTo, createIdentityFiles } from '@curie-agent/core';
|
|
6
|
+
import { listSkills, discoverAllSkills } from '@curie-agent/tools';
|
|
7
|
+
import { executeCd } from './slash-cd.js';
|
|
8
|
+
export class JsonRpcHandler {
|
|
9
|
+
sessionStore;
|
|
10
|
+
settingsManager;
|
|
11
|
+
sharedEventBus;
|
|
12
|
+
createProvider;
|
|
13
|
+
tools;
|
|
14
|
+
daemonApp;
|
|
15
|
+
systemPrompt;
|
|
16
|
+
turnLoops = new Map();
|
|
17
|
+
constructor(sessionStore, settingsManager, sharedEventBus, createProvider, tools = [], daemonApp, systemPrompt) {
|
|
18
|
+
this.sessionStore = sessionStore;
|
|
19
|
+
this.settingsManager = settingsManager;
|
|
20
|
+
this.sharedEventBus = sharedEventBus;
|
|
21
|
+
this.createProvider = createProvider;
|
|
22
|
+
this.tools = tools;
|
|
23
|
+
this.daemonApp = daemonApp;
|
|
24
|
+
this.systemPrompt = systemPrompt;
|
|
25
|
+
// Load settings on init
|
|
26
|
+
this.settingsManager.load();
|
|
27
|
+
}
|
|
28
|
+
async handle(request) {
|
|
29
|
+
const { id, method, params } = request;
|
|
30
|
+
try {
|
|
31
|
+
let result;
|
|
32
|
+
switch (method) {
|
|
33
|
+
case Method.SESSION_LIST:
|
|
34
|
+
result = this.sessionStore.list();
|
|
35
|
+
break;
|
|
36
|
+
case Method.SESSION_GET: {
|
|
37
|
+
const idParam = this.getStringParam(params, 'id');
|
|
38
|
+
if (!idParam)
|
|
39
|
+
return this.paramError('id');
|
|
40
|
+
const info = this.sessionStore.load(idParam);
|
|
41
|
+
if (!info)
|
|
42
|
+
return { jsonrpc: '2.0', id, error: { code: -32602, message: `Session ${idParam} not found` } };
|
|
43
|
+
const events = this.sessionStore.loadEvents(idParam);
|
|
44
|
+
result = { info, events };
|
|
45
|
+
break;
|
|
46
|
+
}
|
|
47
|
+
case Method.SESSION_STATS: {
|
|
48
|
+
const sessions = this.sessionStore.list();
|
|
49
|
+
const todayStr = new Date().toDateString();
|
|
50
|
+
const todaySessions = sessions.filter(s => new Date(s.createdAt).toDateString() === todayStr);
|
|
51
|
+
const hourly = Array.from({ length: 24 }, (_, i) => ({
|
|
52
|
+
hour: i,
|
|
53
|
+
inputTokens: 0,
|
|
54
|
+
outputTokens: 0,
|
|
55
|
+
toolCalls: 0,
|
|
56
|
+
messages: 0,
|
|
57
|
+
}));
|
|
58
|
+
const entrypoints = {
|
|
59
|
+
webui: 0,
|
|
60
|
+
tui: 0,
|
|
61
|
+
telegram: 0,
|
|
62
|
+
heartbeat: 0,
|
|
63
|
+
};
|
|
64
|
+
const toolCallsCount = {};
|
|
65
|
+
let totalTokens = 0;
|
|
66
|
+
let totalInputTokens = 0;
|
|
67
|
+
let totalOutputTokens = 0;
|
|
68
|
+
let totalToolCalls = 0;
|
|
69
|
+
let totalMessages = 0;
|
|
70
|
+
let totalCost = 0;
|
|
71
|
+
// Estimate cost using the pricing model
|
|
72
|
+
const estimateCost = (model, inputTokens, outputTokens, customCost) => {
|
|
73
|
+
if (customCost) {
|
|
74
|
+
if (!customCost.includes('|')) {
|
|
75
|
+
const [inStr = '', outStr = ''] = customCost.split(';');
|
|
76
|
+
const inC = parseFloat(inStr);
|
|
77
|
+
const outC = parseFloat(outStr);
|
|
78
|
+
if (!isNaN(inC) && !isNaN(outC)) {
|
|
79
|
+
return (inputTokens * inC + outputTokens * outC) / 1_000_000;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
const rawTiers = customCost.split('|').map(s => s.trim());
|
|
84
|
+
const tiers = [];
|
|
85
|
+
const [inStr = '', outStr = ''] = rawTiers[0]?.split(';') ?? ['', ''];
|
|
86
|
+
const baseIn = parseFloat(inStr);
|
|
87
|
+
const baseOut = parseFloat(outStr);
|
|
88
|
+
if (!isNaN(baseIn) && !isNaN(baseOut)) {
|
|
89
|
+
tiers.push({ in: baseIn, out: baseOut });
|
|
90
|
+
for (let i = 1; i < rawTiers.length; i++) {
|
|
91
|
+
const tier = rawTiers[i];
|
|
92
|
+
const pipeIdx = tier.indexOf('<');
|
|
93
|
+
if (pipeIdx !== -1) {
|
|
94
|
+
const threshold = parseInt(tier.substring(0, pipeIdx).trim(), 10);
|
|
95
|
+
const rest = tier.substring(pipeIdx + 1).trim();
|
|
96
|
+
const [tierInStr = '', tierOutStr = ''] = rest.split(';');
|
|
97
|
+
const tierIn = parseFloat(tierInStr);
|
|
98
|
+
const tierOut = parseFloat(tierOutStr);
|
|
99
|
+
if (!isNaN(threshold) && !isNaN(tierIn) && !isNaN(tierOut)) {
|
|
100
|
+
tiers.push({ threshold, in: tierIn, out: tierOut });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (tiers.length > 0) {
|
|
106
|
+
let rate = [tiers[0].in, tiers[0].out];
|
|
107
|
+
const total = inputTokens + outputTokens;
|
|
108
|
+
for (const t of tiers) {
|
|
109
|
+
if (t.threshold !== undefined && total >= t.threshold) {
|
|
110
|
+
rate = [t.in, t.out];
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return (inputTokens * rate[0] + outputTokens * rate[1]) / 1_000_000;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
const pricing = {
|
|
118
|
+
'opus': { in: 15, out: 75 },
|
|
119
|
+
'sonnet': { in: 3, out: 15 },
|
|
120
|
+
'haiku': { in: 0.8, out: 4 },
|
|
121
|
+
'gpt-4o': { in: 2.5, out: 10 },
|
|
122
|
+
'gpt-4': { in: 5, out: 15 },
|
|
123
|
+
'qwen': { in: 0.112, out: 0.224 },
|
|
124
|
+
};
|
|
125
|
+
const key = Object.keys(pricing).find(k => model.toLowerCase().includes(k)) || 'sonnet';
|
|
126
|
+
const p = pricing[key];
|
|
127
|
+
return (inputTokens * p.in + outputTokens * p.out) / 1_000_000;
|
|
128
|
+
};
|
|
129
|
+
const providersConfig = this.settingsManager.get().providers;
|
|
130
|
+
for (const s of todaySessions) {
|
|
131
|
+
const typeKey = s.type || 'webui';
|
|
132
|
+
entrypoints[typeKey] = (entrypoints[typeKey] || 0) + 1;
|
|
133
|
+
const events = this.sessionStore.loadEvents(s.id);
|
|
134
|
+
const providerCfg = providersConfig[s.provider];
|
|
135
|
+
const customCost = providerCfg?.model_cost || undefined;
|
|
136
|
+
for (const e of events) {
|
|
137
|
+
const eventDate = new Date(e.timestamp);
|
|
138
|
+
if (eventDate.toDateString() !== todayStr)
|
|
139
|
+
continue;
|
|
140
|
+
const hour = eventDate.getHours();
|
|
141
|
+
if (hour < 0 || hour > 23)
|
|
142
|
+
continue;
|
|
143
|
+
const bucket = hourly[hour];
|
|
144
|
+
if (bucket) {
|
|
145
|
+
if (e.type === 'usage' && 'inputTokens' in e && 'outputTokens' in e) {
|
|
146
|
+
const inT = Number(e.inputTokens) || 0;
|
|
147
|
+
const outT = Number(e.outputTokens) || 0;
|
|
148
|
+
bucket.inputTokens += inT;
|
|
149
|
+
bucket.outputTokens += outT;
|
|
150
|
+
totalInputTokens += inT;
|
|
151
|
+
totalOutputTokens += outT;
|
|
152
|
+
totalTokens += inT + outT;
|
|
153
|
+
const eventCost = estimateCost(s.model, inT, outT, customCost);
|
|
154
|
+
totalCost += eventCost;
|
|
155
|
+
}
|
|
156
|
+
else if (e.type === 'tool-call') {
|
|
157
|
+
bucket.toolCalls += 1;
|
|
158
|
+
totalToolCalls += 1;
|
|
159
|
+
if ('name' in e && typeof e.name === 'string') {
|
|
160
|
+
toolCallsCount[e.name] = (toolCallsCount[e.name] || 0) + 1;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else if (e.type === 'user-prompt') {
|
|
164
|
+
bucket.messages += 1;
|
|
165
|
+
totalMessages += 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const topTools = Object.entries(toolCallsCount)
|
|
171
|
+
.map(([name, count]) => ({ name, count }))
|
|
172
|
+
.sort((a, b) => b.count - a.count)
|
|
173
|
+
.slice(0, 10);
|
|
174
|
+
result = {
|
|
175
|
+
summary: {
|
|
176
|
+
totalSessionsToday: todaySessions.length,
|
|
177
|
+
totalSessions: sessions.length,
|
|
178
|
+
totalTokens,
|
|
179
|
+
totalInputTokens,
|
|
180
|
+
totalOutputTokens,
|
|
181
|
+
totalToolCalls,
|
|
182
|
+
totalMessages,
|
|
183
|
+
totalCost,
|
|
184
|
+
},
|
|
185
|
+
hourly,
|
|
186
|
+
entrypoints,
|
|
187
|
+
topTools,
|
|
188
|
+
};
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
case Method.SESSION_SEND: {
|
|
192
|
+
const sessionId = this.getStringParam(params, 'id');
|
|
193
|
+
const text = this.getStringParam(params, 'text');
|
|
194
|
+
const type = this.getStringParam(params, 'type');
|
|
195
|
+
if (!text)
|
|
196
|
+
return this.paramError('text');
|
|
197
|
+
if (!this.createProvider) {
|
|
198
|
+
result = { status: 'error: no provider configured' };
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
if (text.startsWith('/')) {
|
|
202
|
+
const targetSessionId = sessionId || this.sessionStore.create(process.cwd(), this.settingsManager.get().model_override || this.settingsManager.get().model, this.settingsManager.get().current_provider || 'unknown', type || 'webui').id;
|
|
203
|
+
// Wait 150ms to guarantee that the client has completed the HTTP roundtrip,
|
|
204
|
+
// received the sessionId, and successfully subscribed to WebSocket events
|
|
205
|
+
// before any synchronous slash command output is emitted.
|
|
206
|
+
setTimeout(() => {
|
|
207
|
+
this.executeSlashCommand(targetSessionId, text).catch(err => {
|
|
208
|
+
console.error(`[jsonrpc] error executing slash command ${text}:`, err);
|
|
209
|
+
});
|
|
210
|
+
}, 150);
|
|
211
|
+
result = { sessionId: targetSessionId, status: 'started' };
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
// If no sessionId provided, create a new session and return it
|
|
215
|
+
// immediately so the client can subscribe before events start.
|
|
216
|
+
// The turn loop runs asynchronously in the background.
|
|
217
|
+
if (!sessionId) {
|
|
218
|
+
const settings = this.settingsManager.get();
|
|
219
|
+
const session = this.sessionStore.create(process.cwd(), settings.model_override || settings.model, settings.current_provider || 'unknown', type || 'webui');
|
|
220
|
+
// Start turn loop in background — events stream via WS
|
|
221
|
+
if (this.daemonApp) {
|
|
222
|
+
this.daemonApp.channelManager.send(session.id, text, undefined, type || 'webui').then(res => {
|
|
223
|
+
console.log(`[jsonrpc] session.send completed sessionId=${session.id} status=${res.status}`);
|
|
224
|
+
}).catch(err => {
|
|
225
|
+
console.error(`[jsonrpc] session.send error sessionId=${session.id}:`, err);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
else {
|
|
229
|
+
this.handleSend(session.id, text, type || 'webui').then(res => {
|
|
230
|
+
console.log(`[jsonrpc] session.send completed sessionId=${session.id} status=${res.status}`);
|
|
231
|
+
}).catch(err => {
|
|
232
|
+
console.error(`[jsonrpc] session.send error sessionId=${session.id}:`, err);
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
result = { sessionId: session.id, status: 'started' };
|
|
236
|
+
break;
|
|
237
|
+
}
|
|
238
|
+
console.log(`[jsonrpc] session.send id=${sessionId} text="${text.slice(0, 50)}"`);
|
|
239
|
+
// Start turn loop in background — events stream via WS
|
|
240
|
+
if (this.daemonApp) {
|
|
241
|
+
this.daemonApp.channelManager.send(sessionId, text, undefined, type || 'webui').then(res => {
|
|
242
|
+
console.log(`[jsonrpc] session.send completed id=${sessionId} status=${res.status}`);
|
|
243
|
+
}).catch(err => {
|
|
244
|
+
console.error(`[jsonrpc] session.send error id=${sessionId}:`, err);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
else {
|
|
248
|
+
this.handleSend(sessionId, text, type || 'webui').then(res => {
|
|
249
|
+
console.log(`[jsonrpc] session.send completed id=${sessionId} status=${res.status}`);
|
|
250
|
+
}).catch(err => {
|
|
251
|
+
console.error(`[jsonrpc] session.send error id=${sessionId}:`, err);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
result = { sessionId, status: 'started' };
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
case Method.SESSION_CANCEL: {
|
|
258
|
+
const sessionId = this.getStringParam(params, 'id');
|
|
259
|
+
if (!sessionId)
|
|
260
|
+
return this.paramError('id');
|
|
261
|
+
if (this.daemonApp) {
|
|
262
|
+
this.daemonApp.channelManager.cancel(sessionId);
|
|
263
|
+
}
|
|
264
|
+
else {
|
|
265
|
+
const loop = this.turnLoops.get(sessionId);
|
|
266
|
+
if (loop) {
|
|
267
|
+
loop.abort = true;
|
|
268
|
+
loop.abortController?.abort();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
result = { status: 'cancelled' };
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
case Method.SESSION_RESUME: {
|
|
275
|
+
const resumeId = this.getStringParam(params, 'id');
|
|
276
|
+
// Resume session: if no ID, get the latest session
|
|
277
|
+
const targetId = resumeId || this.sessionStore.list().pop()?.id;
|
|
278
|
+
if (!targetId)
|
|
279
|
+
return this.paramError('id');
|
|
280
|
+
result = { status: 'resumed', sessionId: targetId };
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
283
|
+
case Method.CONFIG_GET: {
|
|
284
|
+
const key = this.getStringParam(params, 'key');
|
|
285
|
+
if (!key)
|
|
286
|
+
return this.paramError('key');
|
|
287
|
+
const settings = this.settingsManager.get();
|
|
288
|
+
result = this.getNestedValue(settings, key);
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
case Method.CONFIG_SET: {
|
|
292
|
+
const key = this.getStringParam(params, 'key');
|
|
293
|
+
const value = params?.value;
|
|
294
|
+
if (!key)
|
|
295
|
+
return this.paramError('key');
|
|
296
|
+
if (value === undefined)
|
|
297
|
+
return this.paramError('value');
|
|
298
|
+
const settings = this.settingsManager.get();
|
|
299
|
+
if (key.includes('.')) {
|
|
300
|
+
const parts = key.split('.');
|
|
301
|
+
let current = settings;
|
|
302
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
303
|
+
const part = parts[i];
|
|
304
|
+
if (part) {
|
|
305
|
+
if (current[part] === undefined || typeof current[part] !== 'object') {
|
|
306
|
+
current[part] = {};
|
|
307
|
+
}
|
|
308
|
+
current = current[part];
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
const lastPart = parts[parts.length - 1];
|
|
312
|
+
if (lastPart) {
|
|
313
|
+
current[lastPart] = value;
|
|
314
|
+
}
|
|
315
|
+
this.settingsManager.update(settings);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
this.settingsManager.update({ [key]: value });
|
|
319
|
+
}
|
|
320
|
+
this.settingsManager.save();
|
|
321
|
+
this.sharedEventBus?.emit({
|
|
322
|
+
type: 'config-changed',
|
|
323
|
+
id: Math.random().toString(36).substring(7),
|
|
324
|
+
timestamp: Date.now(),
|
|
325
|
+
key,
|
|
326
|
+
value,
|
|
327
|
+
});
|
|
328
|
+
if (key.startsWith('heartbeat') && this.daemonApp) {
|
|
329
|
+
const updatedSettings = this.settingsManager.get();
|
|
330
|
+
if (updatedSettings.heartbeat?.schedule === 'on') {
|
|
331
|
+
this.daemonApp.taskManager.rescheduleFromSettings({
|
|
332
|
+
HEARTBEAT_INTRADAY: updatedSettings.heartbeat.intraday,
|
|
333
|
+
HEARTBEAT_DAILY: updatedSettings.heartbeat.daily,
|
|
334
|
+
HEARTBEAT_WEEKLY: updatedSettings.heartbeat.weekly,
|
|
335
|
+
HEARTBEAT_MONTHLY: updatedSettings.heartbeat.monthly,
|
|
336
|
+
HEARTBEAT_DREAMING: updatedSettings.heartbeat.dreaming,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
else if (updatedSettings.heartbeat?.schedule === 'off') {
|
|
340
|
+
this.daemonApp.taskManager.cancelAllHeartbeats();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
result = { status: 'ok', key, value };
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
case Method.TOOL_REGISTRY:
|
|
347
|
+
result = { tools: this.tools.map((t) => t.definition) };
|
|
348
|
+
break;
|
|
349
|
+
case Method.PROVIDER_LIST: {
|
|
350
|
+
const providers = this.settingsManager.get().providers;
|
|
351
|
+
result = Object.entries(providers).map(([name, cfg]) => ({
|
|
352
|
+
name,
|
|
353
|
+
model: cfg.model,
|
|
354
|
+
url: cfg.url,
|
|
355
|
+
configured: !!cfg.api_key,
|
|
356
|
+
model_cost: cfg.model_cost || '',
|
|
357
|
+
model_context_window: cfg.model_context_window || 131072,
|
|
358
|
+
}));
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
case Method.APPROVAL_PENDING: {
|
|
362
|
+
// Alias for approval.list — return real pending approvals
|
|
363
|
+
const sessionId = this.getStringParam(params, 'sessionId');
|
|
364
|
+
result = this.daemonApp?.approvalTracker.list(sessionId) ?? [];
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
case Method.APPROVAL_DECIDE: {
|
|
368
|
+
const toolCallId = this.getStringParam(params, 'toolCallId');
|
|
369
|
+
const decision = this.getStringParam(params, 'decision');
|
|
370
|
+
if (!toolCallId)
|
|
371
|
+
return this.paramError('toolCallId');
|
|
372
|
+
if (!decision || !['allow', 'deny'].includes(decision))
|
|
373
|
+
return this.paramError('decision');
|
|
374
|
+
if (this.daemonApp) {
|
|
375
|
+
const res = this.daemonApp.approvalTracker.decide(toolCallId, decision);
|
|
376
|
+
result = res;
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
result = { status: 'decided' };
|
|
380
|
+
}
|
|
381
|
+
break;
|
|
382
|
+
}
|
|
383
|
+
// Daemon lifecycle
|
|
384
|
+
case Method.DAEMON_STATUS:
|
|
385
|
+
result = {
|
|
386
|
+
status: 'ok',
|
|
387
|
+
version: '0.2.4',
|
|
388
|
+
clients: 0, // will be set by server
|
|
389
|
+
};
|
|
390
|
+
break;
|
|
391
|
+
case Method.DAEMON_SHUTDOWN:
|
|
392
|
+
result = { status: 'shutting-down' };
|
|
393
|
+
// Server will handle shutdown after response
|
|
394
|
+
break;
|
|
395
|
+
// Cron management (redirected to unified TaskManager)
|
|
396
|
+
case Method.CRON_LIST: {
|
|
397
|
+
const mode = this.getStringParam(params, 'mode');
|
|
398
|
+
if (this.daemonApp) {
|
|
399
|
+
this.daemonApp.taskManager.load();
|
|
400
|
+
const tasks = mode ? this.daemonApp.taskManager.list({ mode: mode }) : this.daemonApp.taskManager.list({ mode: 'notify' });
|
|
401
|
+
result = tasks;
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
result = [];
|
|
405
|
+
}
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
case Method.CRON_CREATE: {
|
|
409
|
+
const type = this.getStringParam(params, 'type');
|
|
410
|
+
const message = this.getStringParam(params, 'message');
|
|
411
|
+
const scheduledAt = params?.scheduledAt;
|
|
412
|
+
if (!message || !scheduledAt)
|
|
413
|
+
return this.paramError('message, scheduledAt');
|
|
414
|
+
if (this.daemonApp) {
|
|
415
|
+
const mode = type === 'task' ? 'auto' : 'notify';
|
|
416
|
+
const task = this.daemonApp.taskManager.create({ title: message, mode, scope: 'personal', scheduled_at: scheduledAt });
|
|
417
|
+
result = task;
|
|
418
|
+
}
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
case Method.CRON_CANCEL: {
|
|
422
|
+
const taskId = this.getStringParam(params, 'id');
|
|
423
|
+
if (!taskId)
|
|
424
|
+
return this.paramError('id');
|
|
425
|
+
if (this.daemonApp) {
|
|
426
|
+
result = { cancelled: this.daemonApp.taskManager.cancelTask(taskId) };
|
|
427
|
+
}
|
|
428
|
+
break;
|
|
429
|
+
}
|
|
430
|
+
case Method.CRON_CLEAR:
|
|
431
|
+
if (this.daemonApp) {
|
|
432
|
+
result = { removed: this.daemonApp.taskManager.clearCompleted() };
|
|
433
|
+
}
|
|
434
|
+
break;
|
|
435
|
+
// Heartbeat
|
|
436
|
+
case Method.HEARTBEAT_RUN: {
|
|
437
|
+
const scheduleType = this.getStringParam(params, 'scheduleType');
|
|
438
|
+
if (this.daemonApp) {
|
|
439
|
+
result = await this.daemonApp.runHeartbeat(scheduleType);
|
|
440
|
+
}
|
|
441
|
+
break;
|
|
442
|
+
}
|
|
443
|
+
case Method.HEARTBEAT_STATUS: {
|
|
444
|
+
const settings = this.settingsManager.get();
|
|
445
|
+
result = {
|
|
446
|
+
schedule: settings.heartbeat?.schedule || 'off',
|
|
447
|
+
intraday: settings.heartbeat?.intraday,
|
|
448
|
+
daily: settings.heartbeat?.daily,
|
|
449
|
+
weekly: settings.heartbeat?.weekly,
|
|
450
|
+
monthly: settings.heartbeat?.monthly,
|
|
451
|
+
dreaming: settings.heartbeat?.dreaming,
|
|
452
|
+
};
|
|
453
|
+
break;
|
|
454
|
+
}
|
|
455
|
+
// Channels
|
|
456
|
+
case Method.CHANNEL_LIST:
|
|
457
|
+
if (this.daemonApp) {
|
|
458
|
+
result = this.daemonApp.channelManager.listChannels();
|
|
459
|
+
}
|
|
460
|
+
else {
|
|
461
|
+
result = [];
|
|
462
|
+
}
|
|
463
|
+
break;
|
|
464
|
+
case Method.CHANNEL_GET: {
|
|
465
|
+
const channelId = this.getStringParam(params, 'channelId');
|
|
466
|
+
if (!channelId)
|
|
467
|
+
return this.paramError('channelId');
|
|
468
|
+
if (this.daemonApp) {
|
|
469
|
+
const channel = this.daemonApp.channelManager.getChannel(channelId);
|
|
470
|
+
if (!channel)
|
|
471
|
+
return { jsonrpc: '2.0', id, error: { code: -32602, message: `Channel ${channelId} not found` } };
|
|
472
|
+
result = channel;
|
|
473
|
+
}
|
|
474
|
+
break;
|
|
475
|
+
}
|
|
476
|
+
// MCP
|
|
477
|
+
case Method.MCP_LIST:
|
|
478
|
+
result = { servers: this.daemonApp?.mcpStatus ?? [] };
|
|
479
|
+
break;
|
|
480
|
+
// Identity setup
|
|
481
|
+
case Method.IDENTITY_SETUP: {
|
|
482
|
+
const p = params;
|
|
483
|
+
const provider = this.getStringParam(p, 'provider') || 'anthropic';
|
|
484
|
+
const apiKey = p?.apiKey || '';
|
|
485
|
+
const model = this.getStringParam(p, 'model') || 'custom';
|
|
486
|
+
const soulName = this.getStringParam(p, 'soulName') || 'Curie';
|
|
487
|
+
const soulVibe = this.getStringParam(p, 'soulVibe') || 'AI coding assistant';
|
|
488
|
+
const userName = this.getStringParam(p, 'userName') || 'User';
|
|
489
|
+
const userTimezone = this.getStringParam(p, 'userTimezone') || 'UTC';
|
|
490
|
+
const userLanguages = this.getStringParam(p, 'userLanguages') || 'TypeScript, Python';
|
|
491
|
+
createIdentityFiles({
|
|
492
|
+
provider: provider,
|
|
493
|
+
apiKey,
|
|
494
|
+
model,
|
|
495
|
+
soul: { name: soulName, vibe: soulVibe },
|
|
496
|
+
user: { name: userName, timezone: userTimezone, languages: userLanguages },
|
|
497
|
+
agentsAccepted: true,
|
|
498
|
+
});
|
|
499
|
+
const settings = this.settingsManager.get();
|
|
500
|
+
if (!settings.providers)
|
|
501
|
+
settings.providers = {};
|
|
502
|
+
if (!(provider in settings.providers)) {
|
|
503
|
+
settings.providers[provider] = {};
|
|
504
|
+
}
|
|
505
|
+
const providerConfig = settings.providers[provider];
|
|
506
|
+
if (apiKey)
|
|
507
|
+
providerConfig.api_key = apiKey;
|
|
508
|
+
providerConfig.model = model;
|
|
509
|
+
settings.current_provider = provider;
|
|
510
|
+
settings.model = model;
|
|
511
|
+
this.settingsManager.update(settings);
|
|
512
|
+
this.settingsManager.save();
|
|
513
|
+
this.sharedEventBus?.emit({
|
|
514
|
+
type: 'config-changed',
|
|
515
|
+
id: Math.random().toString(36).substring(7),
|
|
516
|
+
timestamp: Date.now(),
|
|
517
|
+
key: 'init',
|
|
518
|
+
value: true,
|
|
519
|
+
});
|
|
520
|
+
result = { status: 'complete', files: ['SOUL.md', 'USER.md', 'AGENTS.md', 'MEMORY.md', 'TOOLS.md', 'HEARTBEAT.md'] };
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
// Not yet implemented
|
|
524
|
+
case Method.ORCHESTRA_PANES:
|
|
525
|
+
case Method.ORCHESTRA_BROADCAST:
|
|
526
|
+
case Method.WIKI_QUERY:
|
|
527
|
+
case Method.WIKI_PAGE_GET:
|
|
528
|
+
result = { status: 'not-implemented', method };
|
|
529
|
+
break;
|
|
530
|
+
// Subagent management
|
|
531
|
+
case Method.SUBAGENT_SPAWN: {
|
|
532
|
+
const p = params;
|
|
533
|
+
const sessionId = this.getStringParam(p, 'sessionId');
|
|
534
|
+
const prompt = this.getStringParam(p, 'prompt');
|
|
535
|
+
if (!sessionId || !prompt)
|
|
536
|
+
return this.paramError('sessionId, prompt');
|
|
537
|
+
const providerName = p?.provider;
|
|
538
|
+
const mode = p?.mode;
|
|
539
|
+
const effort = p?.effort;
|
|
540
|
+
const model = p?.model;
|
|
541
|
+
const tools = p?.tools;
|
|
542
|
+
if (!this.daemonApp || !this.createProvider) {
|
|
543
|
+
return { jsonrpc: '2.0', id, error: { code: -32603, message: 'Daemon not fully initialized' } };
|
|
544
|
+
}
|
|
545
|
+
const settings = this.settingsManager.get();
|
|
546
|
+
// If a specific provider is requested, override current_provider in settings
|
|
547
|
+
const spawnSettings = providerName
|
|
548
|
+
? { ...settings, current_provider: providerName }
|
|
549
|
+
: settings;
|
|
550
|
+
const providerInstance = this.createProvider(spawnSettings);
|
|
551
|
+
// Resolve model for the subagent.
|
|
552
|
+
// - Explicit 'model' param from UI always wins.
|
|
553
|
+
// - If spawning on a different provider, use that provider's default config (ignore model_override).
|
|
554
|
+
// - If same provider as parent, inherit model_override if set.
|
|
555
|
+
const effectiveModel = (() => {
|
|
556
|
+
if (model)
|
|
557
|
+
return model;
|
|
558
|
+
if (providerName && providerName !== settings.current_provider) {
|
|
559
|
+
// Different provider — use target's default, not parent's model_override
|
|
560
|
+
return spawnSettings.providers?.[providerName]?.model || settings.model;
|
|
561
|
+
}
|
|
562
|
+
// Same provider — allow model_override to apply
|
|
563
|
+
return spawnSettings.model_override || spawnSettings.model;
|
|
564
|
+
})();
|
|
565
|
+
const handle = await this.daemonApp.subagentExecutor.spawn({
|
|
566
|
+
provider: providerInstance,
|
|
567
|
+
model: effectiveModel,
|
|
568
|
+
tools: this.tools,
|
|
569
|
+
cwd: join(homedir(), '.curie-agent'),
|
|
570
|
+
settings,
|
|
571
|
+
prompt,
|
|
572
|
+
system: this.systemPrompt,
|
|
573
|
+
providerName: providerName || undefined,
|
|
574
|
+
mode: mode || settings.mode || 'auto',
|
|
575
|
+
effort,
|
|
576
|
+
allowedTools: tools,
|
|
577
|
+
type: 'subagent',
|
|
578
|
+
});
|
|
579
|
+
result = {
|
|
580
|
+
agentId: handle.agentId,
|
|
581
|
+
sessionId: handle.sessionId,
|
|
582
|
+
prompt: handle.prompt,
|
|
583
|
+
provider: handle.provider,
|
|
584
|
+
status: handle.status,
|
|
585
|
+
startedAt: handle.startedAt,
|
|
586
|
+
};
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
case Method.SUBAGENT_LIST: {
|
|
590
|
+
const p = params;
|
|
591
|
+
const statusFilter = p?.status;
|
|
592
|
+
if (!this.daemonApp) {
|
|
593
|
+
return { jsonrpc: '2.0', id, error: { code: -32603, message: 'Daemon not initialized' } };
|
|
594
|
+
}
|
|
595
|
+
const agents = this.daemonApp.subagentExecutor.list(statusFilter);
|
|
596
|
+
result = agents.map((a) => ({
|
|
597
|
+
agentId: a.agentId,
|
|
598
|
+
sessionId: a.sessionId,
|
|
599
|
+
prompt: a.prompt,
|
|
600
|
+
provider: a.provider,
|
|
601
|
+
status: a.status,
|
|
602
|
+
text: a.text.slice(0, 200),
|
|
603
|
+
toolCalls: a.toolCalls,
|
|
604
|
+
inputTokens: a.inputTokens,
|
|
605
|
+
outputTokens: a.outputTokens,
|
|
606
|
+
startedAt: a.startedAt,
|
|
607
|
+
doneAt: a.doneAt,
|
|
608
|
+
}));
|
|
609
|
+
break;
|
|
610
|
+
}
|
|
611
|
+
case Method.SUBAGENT_CANCEL: {
|
|
612
|
+
const p = params;
|
|
613
|
+
const agentId = this.getStringParam(p, 'agentId');
|
|
614
|
+
if (!agentId)
|
|
615
|
+
return this.paramError('agentId');
|
|
616
|
+
if (!this.daemonApp) {
|
|
617
|
+
return { jsonrpc: '2.0', id, error: { code: -32603, message: 'Daemon not initialized' } };
|
|
618
|
+
}
|
|
619
|
+
// Look up the linked task before cancelling (maps are private)
|
|
620
|
+
const handle = this.daemonApp.subagentExecutor.stats(agentId);
|
|
621
|
+
const taskId = handle?.taskId;
|
|
622
|
+
const cancelled = this.daemonApp.subagentExecutor.cancel(agentId);
|
|
623
|
+
// Cancel linked auto-mode task if present
|
|
624
|
+
if (taskId && this.daemonApp.taskManager) {
|
|
625
|
+
this.daemonApp.taskManager.load();
|
|
626
|
+
this.daemonApp.taskManager.updateTaskStatus(taskId, 'canceled');
|
|
627
|
+
}
|
|
628
|
+
result = { cancelled };
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
case Method.SUBAGENT_STATS: {
|
|
632
|
+
const p = params;
|
|
633
|
+
const agentId = this.getStringParam(p, 'agentId');
|
|
634
|
+
if (!agentId)
|
|
635
|
+
return this.paramError('agentId');
|
|
636
|
+
if (!this.daemonApp) {
|
|
637
|
+
return { jsonrpc: '2.0', id, error: { code: -32603, message: 'Daemon not initialized' } };
|
|
638
|
+
}
|
|
639
|
+
const handle = this.daemonApp.subagentExecutor.stats(agentId);
|
|
640
|
+
result = handle ? {
|
|
641
|
+
agentId: handle.agentId,
|
|
642
|
+
sessionId: handle.sessionId,
|
|
643
|
+
prompt: handle.prompt,
|
|
644
|
+
status: handle.status,
|
|
645
|
+
text: handle.text.slice(0, 2000),
|
|
646
|
+
toolCalls: handle.toolCalls,
|
|
647
|
+
errors: handle.errors,
|
|
648
|
+
inputTokens: handle.inputTokens,
|
|
649
|
+
outputTokens: handle.outputTokens,
|
|
650
|
+
startedAt: handle.startedAt,
|
|
651
|
+
doneAt: handle.doneAt,
|
|
652
|
+
} : null;
|
|
653
|
+
break;
|
|
654
|
+
}
|
|
655
|
+
case Method.SUBAGENT_SEND: {
|
|
656
|
+
const p = params;
|
|
657
|
+
const agentId = this.getStringParam(p, 'agentId');
|
|
658
|
+
const message = this.getStringParam(p, 'message');
|
|
659
|
+
if (!agentId || !message)
|
|
660
|
+
return this.paramError('agentId, message');
|
|
661
|
+
if (!this.daemonApp) {
|
|
662
|
+
return { jsonrpc: '2.0', id, error: { code: -32603, message: 'Daemon not initialized' } };
|
|
663
|
+
}
|
|
664
|
+
const sent = this.daemonApp.subagentExecutor.sendMessage(agentId, message);
|
|
665
|
+
result = { sent };
|
|
666
|
+
break;
|
|
667
|
+
}
|
|
668
|
+
case Method.TASK_SCHEDULE: {
|
|
669
|
+
const p = params;
|
|
670
|
+
const instruction = this.getStringParam(p, 'instruction');
|
|
671
|
+
const scheduledAt = this.getStringParam(p, 'scheduled_at');
|
|
672
|
+
if (!instruction || !scheduledAt)
|
|
673
|
+
return this.paramError('instruction, scheduled_at');
|
|
674
|
+
if (!this.daemonApp) {
|
|
675
|
+
return { jsonrpc: '2.0', id, error: { code: -32603, message: 'Daemon not initialized' } };
|
|
676
|
+
}
|
|
677
|
+
const scheduledAtMs = new Date(scheduledAt).getTime();
|
|
678
|
+
if (isNaN(scheduledAtMs))
|
|
679
|
+
return this.paramError('scheduled_at must be a valid ISO datetime');
|
|
680
|
+
// Build metadata with optional overrides
|
|
681
|
+
const provider = p.provider;
|
|
682
|
+
const model = p.model;
|
|
683
|
+
const effort = p.effort;
|
|
684
|
+
const metadata = {};
|
|
685
|
+
if (provider)
|
|
686
|
+
metadata.provider = provider;
|
|
687
|
+
if (model)
|
|
688
|
+
metadata.model = model;
|
|
689
|
+
if (effort)
|
|
690
|
+
metadata.effort = effort;
|
|
691
|
+
this.daemonApp.taskManager.load();
|
|
692
|
+
const task = this.daemonApp.taskManager.create({
|
|
693
|
+
title: instruction,
|
|
694
|
+
mode: 'auto',
|
|
695
|
+
scope: 'personal',
|
|
696
|
+
scheduled_at: scheduledAtMs,
|
|
697
|
+
description: Object.keys(metadata).length > 0 ? JSON.stringify(metadata) : '',
|
|
698
|
+
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
|
699
|
+
});
|
|
700
|
+
const timeStr = new Date(task.scheduled_at).toLocaleString();
|
|
701
|
+
result = {
|
|
702
|
+
taskId: task.id,
|
|
703
|
+
scheduledAt: timeStr,
|
|
704
|
+
instruction: task.title,
|
|
705
|
+
};
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
default:
|
|
709
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } };
|
|
710
|
+
}
|
|
711
|
+
return { jsonrpc: '2.0', id, result };
|
|
712
|
+
}
|
|
713
|
+
catch (err) {
|
|
714
|
+
return {
|
|
715
|
+
jsonrpc: '2.0',
|
|
716
|
+
id,
|
|
717
|
+
error: {
|
|
718
|
+
code: -32603,
|
|
719
|
+
message: err instanceof Error ? err.message : 'Internal error',
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
async handleSend(sessionId, text, type) {
|
|
725
|
+
if (!this.createProvider) {
|
|
726
|
+
return { status: 'error: no provider configured' };
|
|
727
|
+
}
|
|
728
|
+
const settings = this.settingsManager.get();
|
|
729
|
+
const provider = this.createProvider(settings);
|
|
730
|
+
const sessionInfo = this.sessionStore.load(sessionId);
|
|
731
|
+
// Build the turn loop config
|
|
732
|
+
const loop = new TurnLoop({
|
|
733
|
+
provider,
|
|
734
|
+
model: settings.model_override || settings.model,
|
|
735
|
+
tools: this.tools,
|
|
736
|
+
cwd: sessionInfo?.cwd || join(homedir(), '.curie-agent'),
|
|
737
|
+
settings,
|
|
738
|
+
approvalMode: settings.mode || 'auto',
|
|
739
|
+
effort: settings.effort,
|
|
740
|
+
sessionId: sessionId,
|
|
741
|
+
resume: !!sessionId,
|
|
742
|
+
system: this.systemPrompt,
|
|
743
|
+
type,
|
|
744
|
+
}, this.sessionStore);
|
|
745
|
+
// Store the loop for potential cancellation
|
|
746
|
+
this.turnLoops.set(sessionId, loop);
|
|
747
|
+
// Bridge the turn loop's event bus to the shared daemon event bus
|
|
748
|
+
// so that WS clients receive real-time events.
|
|
749
|
+
const eventTypes = [
|
|
750
|
+
'user-prompt', 'assistant-delta', 'assistant-stop', 'tool-call',
|
|
751
|
+
'tool-result', 'approval-request', 'approval-decision', 'usage',
|
|
752
|
+
'error', 'session-start', 'session-stop', 'hook', 'status',
|
|
753
|
+
'session-resumed', 'context-warning', 'thinking-delta',
|
|
754
|
+
// Subagent events
|
|
755
|
+
'agent-start', 'agent-text-delta', 'agent-thinking-delta',
|
|
756
|
+
'agent-tool-call', 'agent-tool-result', 'agent-usage',
|
|
757
|
+
'agent-done', 'agent-error',
|
|
758
|
+
];
|
|
759
|
+
const unsubscribes = [];
|
|
760
|
+
for (const type of eventTypes) {
|
|
761
|
+
unsubscribes.push(loop.eventBus.subscribe(type, (event) => {
|
|
762
|
+
this.sharedEventBus?.emit({ ...event, sessionId });
|
|
763
|
+
}));
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
const result = await loop.run(text);
|
|
767
|
+
await this.checkContextThresholds(sessionId);
|
|
768
|
+
return { status: 'completed', sessionId: result.sessionId, events: result.events.length };
|
|
769
|
+
}
|
|
770
|
+
catch (err) {
|
|
771
|
+
return { status: 'error', error: err instanceof Error ? err.message : 'unknown' };
|
|
772
|
+
}
|
|
773
|
+
finally {
|
|
774
|
+
this.turnLoops.delete(sessionId);
|
|
775
|
+
for (const unsub of unsubscribes)
|
|
776
|
+
unsub();
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
getStringParam(params, key) {
|
|
780
|
+
return params?.[key];
|
|
781
|
+
}
|
|
782
|
+
paramError(key) {
|
|
783
|
+
return { jsonrpc: '2.0', id: 0, error: { code: -32602, message: `Missing required parameter: ${key}` } };
|
|
784
|
+
}
|
|
785
|
+
/** Get a nested value from an object using dot notation (e.g. "providers.anthropic.model"). */
|
|
786
|
+
getNestedValue(obj, path) {
|
|
787
|
+
const parts = path.split('.');
|
|
788
|
+
let current = obj;
|
|
789
|
+
for (const part of parts) {
|
|
790
|
+
if (current == null || typeof current !== 'object')
|
|
791
|
+
return undefined;
|
|
792
|
+
current = current[part];
|
|
793
|
+
}
|
|
794
|
+
return current;
|
|
795
|
+
}
|
|
796
|
+
/** Get the current working directory from session metadata, falling back to process.cwd(). */
|
|
797
|
+
getSessionCwd(sessionId) {
|
|
798
|
+
const metaPath = this.sessionStore.metadataPath(sessionId);
|
|
799
|
+
if (!existsSync(metaPath))
|
|
800
|
+
return undefined;
|
|
801
|
+
try {
|
|
802
|
+
const info = JSON.parse(readFileSync(metaPath, 'utf-8'));
|
|
803
|
+
return info.cwd;
|
|
804
|
+
}
|
|
805
|
+
catch {
|
|
806
|
+
return undefined;
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
async executeSlashCommand(sessionId, text) {
|
|
810
|
+
// 1. Emit user-prompt event so it appears in UI
|
|
811
|
+
const promptEvent = {
|
|
812
|
+
type: 'user-prompt',
|
|
813
|
+
id: crypto.randomUUID(),
|
|
814
|
+
timestamp: Date.now(),
|
|
815
|
+
text,
|
|
816
|
+
};
|
|
817
|
+
this.sharedEventBus?.emit({ ...promptEvent, sessionId });
|
|
818
|
+
this.sessionStore.appendEvent(sessionId, { ...promptEvent, sessionId });
|
|
819
|
+
// Helpers to emit response
|
|
820
|
+
const emitDelta = (chunk) => {
|
|
821
|
+
const deltaEvent = {
|
|
822
|
+
type: 'assistant-delta',
|
|
823
|
+
id: crypto.randomUUID(),
|
|
824
|
+
timestamp: Date.now(),
|
|
825
|
+
text: chunk,
|
|
826
|
+
};
|
|
827
|
+
this.sharedEventBus?.emit({ ...deltaEvent, sessionId });
|
|
828
|
+
this.sessionStore.appendEvent(sessionId, { ...deltaEvent, sessionId });
|
|
829
|
+
};
|
|
830
|
+
const emitStop = () => {
|
|
831
|
+
const stopEvent = {
|
|
832
|
+
type: 'assistant-stop',
|
|
833
|
+
id: crypto.randomUUID(),
|
|
834
|
+
timestamp: Date.now(),
|
|
835
|
+
};
|
|
836
|
+
this.sharedEventBus?.emit({ ...stopEvent, sessionId });
|
|
837
|
+
this.sessionStore.appendEvent(sessionId, { ...stopEvent, sessionId });
|
|
838
|
+
};
|
|
839
|
+
try {
|
|
840
|
+
const parts = text.slice(1).split(' ');
|
|
841
|
+
const command = parts[0]?.toLowerCase() || '';
|
|
842
|
+
const args = parts.slice(1).join(' ').trim();
|
|
843
|
+
switch (command) {
|
|
844
|
+
case 'help': {
|
|
845
|
+
const helpText = `### Available Slash Commands
|
|
846
|
+
|
|
847
|
+
**General & Status**
|
|
848
|
+
* \`/status\` — Show version, active model, provider, approval mode, CWD, active settings, and pricing.
|
|
849
|
+
* \`/help\` — List all available commands with usage details.
|
|
850
|
+
|
|
851
|
+
**Model & Config**
|
|
852
|
+
* \`/provider <name>\` — Switch AI provider (\`anthropic | openai | google | local | openrouter | ollama\`).
|
|
853
|
+
* \`/model <name>\` — Switch active model. Or use subcommands:
|
|
854
|
+
* \`/model pricing <in;out>\` — Customize pricing format per million tokens.
|
|
855
|
+
* \`/model window <tokens>\` — Adjust model context window capacity.
|
|
856
|
+
* \`/effort <low|medium|high|max|auto>\` — Set reasoning effort level.
|
|
857
|
+
* \`/mode <plan|edit|auto|yolo>\` — Set agent approval mode.
|
|
858
|
+
* \`/tools <max_tools> [max_websearch]\` — Configure dynamic tool limit per turn.
|
|
859
|
+
* \`/websearch <limit>\` — Configure maximum web search limits per turn.
|
|
860
|
+
|
|
861
|
+
**Skills & MCP**
|
|
862
|
+
* \`/skill [name]\` — List all globally and project-registered skills or view a specific skill's instructions.
|
|
863
|
+
* \`/mcp [list|reload]\` — List connected Model Context Protocol (MCP) servers and their tools, or reload configuration.
|
|
864
|
+
|
|
865
|
+
**Memory & Context**
|
|
866
|
+
* \`/memory [status|add <text>]\` — View active memory files or add new memories to be organized on next turn.
|
|
867
|
+
|
|
868
|
+
**System Info**
|
|
869
|
+
* \`/system\` — Show OS, platform, Node version, home dir, CWD, and PathGuard status.
|
|
870
|
+
* \`/context [auto [on|off|threshold N|warn N|pricing on/off]]\` — View visual token capacity fill percentage bar or configure auto-compaction.
|
|
871
|
+
|
|
872
|
+
**Automation & Scheduling**
|
|
873
|
+
* \`/remind <message at time>\` — Create a scheduled reminder (e.g., \`/remind review current pull request in 30 mins\`).
|
|
874
|
+
* \`/cron [list|delete <id>|clear]\` — View list of active reminders or manage completed ones.
|
|
875
|
+
* \`/heartbeat [status|enable|disable|now|daily <H:MM>|weekly <day@H:MM>...]\` — Control scheduled heartbeat cycles or run immediately.
|
|
876
|
+
* \`/task [create <instruction at time>|list [status]|delete <id>]\` — Schedule background autonomous agent tasks.
|
|
877
|
+
|
|
878
|
+
**Workspace Safety**
|
|
879
|
+
* \`/snapshots\` — List Git-backed state snapshots.
|
|
880
|
+
* \`/revert <index>\` — Revert workspace to a specific snapshot index.
|
|
881
|
+
* \`/cd <path>\` — Change working directory with safety checks.`;
|
|
882
|
+
emitDelta(helpText);
|
|
883
|
+
break;
|
|
884
|
+
}
|
|
885
|
+
case 'status': {
|
|
886
|
+
const settings = this.settingsManager.get();
|
|
887
|
+
const provider = settings.current_provider || 'anthropic';
|
|
888
|
+
const pConfig = settings.providers?.[provider];
|
|
889
|
+
const pricing = pConfig?.model_cost
|
|
890
|
+
? `\`${pConfig.model_cost}\` (per million)`
|
|
891
|
+
: 'Not configured';
|
|
892
|
+
const statusText = `### Curie Agent Status
|
|
893
|
+
* **Version:** \`0.2.4\`
|
|
894
|
+
* **Active Model:** \`${settings.model}\`
|
|
895
|
+
* **Active Provider:** \`${provider}\`
|
|
896
|
+
* **Approval Mode:** \`${settings.mode || 'auto'}\`
|
|
897
|
+
* **Reasoning Effort:** \`${settings.effort || 'auto'}\`
|
|
898
|
+
* **Workspace CWD:** \`${this.getSessionCwd(sessionId) || process.cwd()}\`
|
|
899
|
+
* **Tools per Turn Limit:** \`${settings.tools_per_call || 10}\`
|
|
900
|
+
* **Web Search per Turn Limit:** \`${settings.websearch_per_call || 5}\`
|
|
901
|
+
* **Model Context Window:** \`${(pConfig?.model_context_window || 200000).toLocaleString()} tokens\`
|
|
902
|
+
* **Model Pricing:** ${pricing}
|
|
903
|
+
* **Auto-Compaction:** \`${settings.auto_compact?.enabled || 'on'}\` (Threshold: \`${settings.auto_compact?.threshold ?? 80}%\`)`;
|
|
904
|
+
emitDelta(statusText);
|
|
905
|
+
break;
|
|
906
|
+
}
|
|
907
|
+
case 'model': {
|
|
908
|
+
const settings = this.settingsManager.get();
|
|
909
|
+
const provider = settings.current_provider || 'anthropic';
|
|
910
|
+
if (!settings.providers)
|
|
911
|
+
settings.providers = {};
|
|
912
|
+
if (!settings.providers[provider])
|
|
913
|
+
settings.providers[provider] = {};
|
|
914
|
+
const pConfig = settings.providers[provider];
|
|
915
|
+
const parts = args.trim().split(/\s+/);
|
|
916
|
+
const sub = parts[0]?.toLowerCase();
|
|
917
|
+
const rest = parts.slice(1).join(' ').trim();
|
|
918
|
+
if (!args) {
|
|
919
|
+
emitDelta(`Current model is: \`${settings.model}\`. Use \`/model <name>\` to switch models.
|
|
920
|
+
* Use \`/model pricing <in;out>\` to set custom token costs (e.g. \`/model pricing 3.0;15.0\`).
|
|
921
|
+
* Use \`/model window <tokens>\` to adjust the context capacity limit.`);
|
|
922
|
+
}
|
|
923
|
+
else if (sub === 'pricing') {
|
|
924
|
+
if (!rest) {
|
|
925
|
+
const currentPricing = pConfig.model_cost || 'none';
|
|
926
|
+
emitDelta(`Current model pricing for provider \`${provider}\`: \`${currentPricing}\` (per million tokens).
|
|
927
|
+
Usage: \`/model pricing <in;out>\` (e.g., \`/model pricing 2.50;10.00\`).`);
|
|
928
|
+
}
|
|
929
|
+
else if (!this.validatePricingString(rest)) {
|
|
930
|
+
emitDelta(`Invalid pricing format: "${rest}". Must be in the format: \`input_cost;output_cost\` (e.g., \`3.00;15.00\`).`);
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
pConfig.model_cost = rest;
|
|
934
|
+
this.settingsManager.update(settings);
|
|
935
|
+
emitDelta(`Pricing updated for provider \`${provider}\`! Cost: **${rest}**`);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
else if (sub === 'window') {
|
|
939
|
+
if (!rest) {
|
|
940
|
+
emitDelta(`Current context window size for provider \`${provider}\`: \`${(pConfig.model_context_window || 200000).toLocaleString()}\` tokens.
|
|
941
|
+
Usage: \`/model window <tokens>\` (e.g., \`/model window 120000\`).`);
|
|
942
|
+
}
|
|
943
|
+
else {
|
|
944
|
+
const val = parseInt(rest, 10);
|
|
945
|
+
if (isNaN(val) || val < 1000) {
|
|
946
|
+
emitDelta(`Invalid window value: "${rest}". Must be at least 1,000.`);
|
|
947
|
+
}
|
|
948
|
+
else {
|
|
949
|
+
pConfig.model_context_window = val;
|
|
950
|
+
this.settingsManager.update(settings);
|
|
951
|
+
emitDelta(`Model context window capacity set to **${val.toLocaleString()}** tokens.`);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
settings.model_override = args;
|
|
957
|
+
settings.model = args;
|
|
958
|
+
this.settingsManager.update(settings);
|
|
959
|
+
this.sharedEventBus?.emit({
|
|
960
|
+
type: 'config-changed',
|
|
961
|
+
id: Math.random().toString(36).substring(7),
|
|
962
|
+
timestamp: Date.now(),
|
|
963
|
+
key: 'model',
|
|
964
|
+
value: args,
|
|
965
|
+
});
|
|
966
|
+
emitDelta(`Successfully switched model to: **${args}**`);
|
|
967
|
+
}
|
|
968
|
+
break;
|
|
969
|
+
}
|
|
970
|
+
case 'provider': {
|
|
971
|
+
const settings = this.settingsManager.get();
|
|
972
|
+
const valid = ['anthropic', 'openai', 'google', 'local', 'ollama', 'openrouter'];
|
|
973
|
+
if (!args) {
|
|
974
|
+
emitDelta(`Current provider is: \`${settings.current_provider || 'anthropic'}\`. Use \`/provider <name>\` to switch.
|
|
975
|
+
* Valid providers: \`anthropic | openai | google | local | ollama | openrouter\``);
|
|
976
|
+
}
|
|
977
|
+
else {
|
|
978
|
+
const provider = args.toLowerCase().trim();
|
|
979
|
+
if (!valid.includes(provider)) {
|
|
980
|
+
emitDelta(`Unknown provider: "${args}". Valid options are: ${valid.join(', ')}`);
|
|
981
|
+
}
|
|
982
|
+
else {
|
|
983
|
+
settings.current_provider = provider;
|
|
984
|
+
settings.model_override = undefined;
|
|
985
|
+
this.settingsManager.update(settings);
|
|
986
|
+
this.sharedEventBus?.emit({
|
|
987
|
+
type: 'config-changed',
|
|
988
|
+
id: Math.random().toString(36).substring(7),
|
|
989
|
+
timestamp: Date.now(),
|
|
990
|
+
key: 'current_provider',
|
|
991
|
+
value: provider,
|
|
992
|
+
});
|
|
993
|
+
emitDelta(`Successfully switched provider to: **${provider}**`);
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
break;
|
|
997
|
+
}
|
|
998
|
+
case 'theme': {
|
|
999
|
+
const settings = this.settingsManager.get();
|
|
1000
|
+
const validThemes = ['tokyo-night', 'nord', 'dracula', 'solarized', 'gruvbox', 'black', 'white', 'grey'];
|
|
1001
|
+
const theme = args.toLowerCase().trim();
|
|
1002
|
+
if (!args) {
|
|
1003
|
+
emitDelta(`Current theme is: \`${settings.theme || 'nord'}\`. Use \`/theme <name>\` to switch.
|
|
1004
|
+
* Valid themes: \`tokyo-night | nord | dracula | solarized | gruvbox | black | white | grey\``);
|
|
1005
|
+
}
|
|
1006
|
+
else if (!validThemes.includes(theme)) {
|
|
1007
|
+
emitDelta(`Unknown theme: "${args}". Valid options are: ${validThemes.join(', ')}`);
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
settings.theme = theme;
|
|
1011
|
+
this.settingsManager.update(settings);
|
|
1012
|
+
this.sharedEventBus?.emit({
|
|
1013
|
+
type: 'config-changed',
|
|
1014
|
+
id: Math.random().toString(36).substring(7),
|
|
1015
|
+
timestamp: Date.now(),
|
|
1016
|
+
key: 'theme',
|
|
1017
|
+
value: theme,
|
|
1018
|
+
});
|
|
1019
|
+
emitDelta(`Successfully switched theme to: **${theme}**`);
|
|
1020
|
+
}
|
|
1021
|
+
break;
|
|
1022
|
+
}
|
|
1023
|
+
case 'mode': {
|
|
1024
|
+
const settings = this.settingsManager.get();
|
|
1025
|
+
const valid = ['plan', 'edit', 'auto', 'yolo'];
|
|
1026
|
+
if (!args) {
|
|
1027
|
+
emitDelta(`Current approval mode is: \`${settings.mode || 'auto'}\`. Use \`/mode <plan|edit|auto|yolo>\` to switch.`);
|
|
1028
|
+
}
|
|
1029
|
+
else if (!valid.includes(args.toLowerCase())) {
|
|
1030
|
+
emitDelta(`Invalid mode: \`${args}\`. Supported modes: plan, edit, auto, yolo.`);
|
|
1031
|
+
}
|
|
1032
|
+
else {
|
|
1033
|
+
settings.mode = args.toLowerCase();
|
|
1034
|
+
this.settingsManager.update(settings);
|
|
1035
|
+
emitDelta(`Successfully switched approval mode to: **${args.toLowerCase()}**`);
|
|
1036
|
+
}
|
|
1037
|
+
break;
|
|
1038
|
+
}
|
|
1039
|
+
case 'effort': {
|
|
1040
|
+
const settings = this.settingsManager.get();
|
|
1041
|
+
const valid = ['low', 'medium', 'high', 'max', 'auto'];
|
|
1042
|
+
if (!args) {
|
|
1043
|
+
emitDelta(`Current reasoning effort is: \`${settings.effort || 'auto'}\`. Use \`/effort <low|medium|high|max|auto>\` to switch.`);
|
|
1044
|
+
}
|
|
1045
|
+
else if (!valid.includes(args.toLowerCase())) {
|
|
1046
|
+
emitDelta(`Invalid effort: \`${args}\`. Supported effort levels: low, medium, high, max, auto.`);
|
|
1047
|
+
}
|
|
1048
|
+
else {
|
|
1049
|
+
settings.effort = args.toLowerCase();
|
|
1050
|
+
this.settingsManager.update(settings);
|
|
1051
|
+
emitDelta(`Successfully switched reasoning effort to: **${args.toLowerCase()}**`);
|
|
1052
|
+
}
|
|
1053
|
+
break;
|
|
1054
|
+
}
|
|
1055
|
+
case 'tools': {
|
|
1056
|
+
const settings = this.settingsManager.get();
|
|
1057
|
+
const parts = args.trim().split(/\s+/);
|
|
1058
|
+
if (!args.trim()) {
|
|
1059
|
+
emitDelta(`### Tool Limits (per turn):
|
|
1060
|
+
* **Max Tool Calls**: \`${settings.tools_per_call ?? 10}\`
|
|
1061
|
+
* **Max WebSearch/WebFetch**: \`${settings.websearch_per_call ?? 5}\`
|
|
1062
|
+
|
|
1063
|
+
**Usage**: \`/tools <max_tools> [max_websearch]\``);
|
|
1064
|
+
}
|
|
1065
|
+
else {
|
|
1066
|
+
const val = parseInt(parts[0], 10);
|
|
1067
|
+
if (isNaN(val) || val < 1) {
|
|
1068
|
+
emitDelta(`Invalid value: "${parts[0]}". Must be a positive integer.`);
|
|
1069
|
+
}
|
|
1070
|
+
else {
|
|
1071
|
+
settings.tools_per_call = val;
|
|
1072
|
+
if (parts[1]) {
|
|
1073
|
+
const wsVal = parseInt(parts[1], 10);
|
|
1074
|
+
if (!isNaN(wsVal) && wsVal >= 1) {
|
|
1075
|
+
settings.websearch_per_call = wsVal;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
this.settingsManager.save();
|
|
1079
|
+
emitDelta(`Tool limits updated! Tools per turn: **${settings.tools_per_call}**, WebSearch per turn: **${settings.websearch_per_call ?? 5}**`);
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
break;
|
|
1083
|
+
}
|
|
1084
|
+
case 'websearch': {
|
|
1085
|
+
const settings = this.settingsManager.get();
|
|
1086
|
+
if (!args.trim()) {
|
|
1087
|
+
emitDelta(`### WebSearch+WebFetch Limit (per turn):
|
|
1088
|
+
* **Limit**: \`${settings.websearch_per_call ?? 5}\`
|
|
1089
|
+
|
|
1090
|
+
**Usage**: \`/websearch <limit>\``);
|
|
1091
|
+
}
|
|
1092
|
+
else {
|
|
1093
|
+
const val = parseInt(args.trim(), 10);
|
|
1094
|
+
if (isNaN(val) || val < 1) {
|
|
1095
|
+
emitDelta(`Invalid value: "${args}". Must be a positive integer.`);
|
|
1096
|
+
}
|
|
1097
|
+
else {
|
|
1098
|
+
settings.websearch_per_call = val;
|
|
1099
|
+
this.settingsManager.save();
|
|
1100
|
+
emitDelta(`WebSearch/WebFetch limit per turn set to: **${val}**`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
break;
|
|
1104
|
+
}
|
|
1105
|
+
case 'mcp': {
|
|
1106
|
+
const settings = this.settingsManager.get();
|
|
1107
|
+
const sub = args.split(/\s+/)[0]?.toLowerCase();
|
|
1108
|
+
if (sub === 'list' || !sub) {
|
|
1109
|
+
const servers = settings.mcp_servers || {};
|
|
1110
|
+
const keys = Object.keys(servers);
|
|
1111
|
+
if (keys.length === 0) {
|
|
1112
|
+
emitDelta(`No Model Context Protocol (MCP) servers configured.`);
|
|
1113
|
+
}
|
|
1114
|
+
else {
|
|
1115
|
+
const lines = [`### Configured MCP Servers (${keys.length}):`];
|
|
1116
|
+
keys.forEach(k => {
|
|
1117
|
+
const cfg = servers[k];
|
|
1118
|
+
const status = this.daemonApp?.mcpStatus?.find(s => s.serverId === k);
|
|
1119
|
+
const connectedLabel = status?.connected ? '✅ Connected' : '❌ Disconnected';
|
|
1120
|
+
const toolsList = status?.tools?.join(', ') || 'none';
|
|
1121
|
+
lines.push(`* **${k}**: \`${cfg?.command}\` ${cfg?.args?.join(' ') ?? ''}`);
|
|
1122
|
+
lines.push(` └─ Status: ${connectedLabel}`);
|
|
1123
|
+
lines.push(` └─ Tools: _${toolsList}_`);
|
|
1124
|
+
});
|
|
1125
|
+
lines.push(`\nUse \`/mcp reload\` to apply configuration changes.`);
|
|
1126
|
+
emitDelta(lines.join('\n'));
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
else if (sub === 'reload') {
|
|
1130
|
+
emitDelta(`Reloading MCP servers...`);
|
|
1131
|
+
emitDelta(`To fully apply changes to MCP configurations, please restart the daemon process: \`curie-agent daemon stop && curie-agent daemon start\``);
|
|
1132
|
+
}
|
|
1133
|
+
else {
|
|
1134
|
+
emitDelta(`Usage: \`/mcp [list|reload]\``);
|
|
1135
|
+
}
|
|
1136
|
+
break;
|
|
1137
|
+
}
|
|
1138
|
+
case 'skill': {
|
|
1139
|
+
const workspaceDir = process.cwd();
|
|
1140
|
+
const parts = args.trim().split(/\s+/);
|
|
1141
|
+
const sub = parts[0]?.toLowerCase();
|
|
1142
|
+
if (!args.trim()) {
|
|
1143
|
+
const skills = listSkills(workspaceDir);
|
|
1144
|
+
if (skills.length === 0) {
|
|
1145
|
+
emitDelta(`No global or project-level skills discovered. Global path: \`~/.curie-agent/skills\`, project path: \`.curie-agent/skills\`.`);
|
|
1146
|
+
}
|
|
1147
|
+
else {
|
|
1148
|
+
const lines = [`### Discovered Skills (${skills.length}):`];
|
|
1149
|
+
skills.forEach(s => {
|
|
1150
|
+
const sourceLabel = s.source === 'project' ? '📁 Project' : '🌐 Global';
|
|
1151
|
+
lines.push(`* **${s.name}** [${sourceLabel}] — ${s.description || '_No description_'}`);
|
|
1152
|
+
});
|
|
1153
|
+
lines.push(`\nUse \`/skill <name>\` to read a skill's full instructions.`);
|
|
1154
|
+
emitDelta(lines.join('\n'));
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
else {
|
|
1158
|
+
const all = discoverAllSkills(workspaceDir);
|
|
1159
|
+
const target = args.trim().toLowerCase();
|
|
1160
|
+
const skill = all.find(s => s.name.toLowerCase() === target);
|
|
1161
|
+
if (!skill) {
|
|
1162
|
+
emitDelta(`Skill **"${args}"** not found. Run \`/skill\` without arguments to list available skills.`);
|
|
1163
|
+
}
|
|
1164
|
+
else {
|
|
1165
|
+
const fileContent = readFileSync(skill.filePath, 'utf-8');
|
|
1166
|
+
emitDelta(`### Skill: ${skill.name}\n**Source**: \`${skill.filePath}\`\n\n\`\`\`markdown\n${fileContent}\n\`\`\``);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
break;
|
|
1170
|
+
}
|
|
1171
|
+
case 'memory': {
|
|
1172
|
+
const memoryDir = join(homedir(), '.curie-agent', 'memory');
|
|
1173
|
+
const parts = args.trim().split(/\s+/);
|
|
1174
|
+
const sub = parts[0]?.toLowerCase();
|
|
1175
|
+
const rest = parts.slice(1).join(' ').trim();
|
|
1176
|
+
if (sub === 'status' || !args.trim()) {
|
|
1177
|
+
if (!existsSync(memoryDir)) {
|
|
1178
|
+
emitDelta(`Memory directory does not exist yet at \`${memoryDir}\`. Captured memories will create it automatically.`);
|
|
1179
|
+
}
|
|
1180
|
+
else {
|
|
1181
|
+
const files = readdirSync(memoryDir).filter(f => f.endsWith('.md') || f.endsWith('.txt'));
|
|
1182
|
+
if (files.length === 0) {
|
|
1183
|
+
emitDelta(`Memory directory \`${memoryDir}\` is empty.`);
|
|
1184
|
+
}
|
|
1185
|
+
else {
|
|
1186
|
+
const lines = [`### Memory Directory Files (\`${memoryDir}\`):`];
|
|
1187
|
+
files.forEach(f => {
|
|
1188
|
+
const stat = statSync(join(memoryDir, f));
|
|
1189
|
+
lines.push(`* **${f}** — \`${stat.size} bytes\``);
|
|
1190
|
+
});
|
|
1191
|
+
lines.push(`\nUse \`/memory add <text>\` to add a new memory entry.`);
|
|
1192
|
+
emitDelta(lines.join('\n'));
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
else if (sub === 'add') {
|
|
1197
|
+
if (!rest) {
|
|
1198
|
+
emitDelta(`Usage: \`/memory add <text>\` (e.g., \`/memory add User prefers TypeScript over JavaScript\`)`);
|
|
1199
|
+
}
|
|
1200
|
+
else {
|
|
1201
|
+
if (!existsSync(memoryDir)) {
|
|
1202
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
1203
|
+
}
|
|
1204
|
+
const capturedFile = join(memoryDir, 'captured.md');
|
|
1205
|
+
const entry = `- [${new Date().toISOString()}] ${rest}\n`;
|
|
1206
|
+
let existing = '';
|
|
1207
|
+
if (existsSync(capturedFile)) {
|
|
1208
|
+
existing = readFileSync(capturedFile, 'utf-8');
|
|
1209
|
+
}
|
|
1210
|
+
writeFileSync(capturedFile, existing + entry, 'utf-8');
|
|
1211
|
+
emitDelta(`Captured memory: **"${rest}"** successfully saved in \`captured.md\`!`);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
else {
|
|
1215
|
+
emitDelta(`Usage:\n* \`/memory status\` — Show files in the memory directory.\n* \`/memory add <text>\` — Log a specific memory.`);
|
|
1216
|
+
}
|
|
1217
|
+
break;
|
|
1218
|
+
}
|
|
1219
|
+
case 'context': {
|
|
1220
|
+
const settings = this.settingsManager.get();
|
|
1221
|
+
const parts = args.trim().split(/\s+/);
|
|
1222
|
+
const sub = parts[0]?.toLowerCase();
|
|
1223
|
+
const arg1 = parts[1]?.toLowerCase();
|
|
1224
|
+
const arg2 = parts[2]?.toLowerCase();
|
|
1225
|
+
if (sub === 'auto') {
|
|
1226
|
+
const s = settings;
|
|
1227
|
+
if (!s.auto_compact) {
|
|
1228
|
+
s.auto_compact = { enabled: 'on', threshold: 80, warn_threshold: 15, forced_threshold: 85 };
|
|
1229
|
+
}
|
|
1230
|
+
if (!arg1) {
|
|
1231
|
+
emitDelta(`### Auto-Compaction Settings:
|
|
1232
|
+
* **Auto-compact**: \`${s.auto_compact.enabled}\`
|
|
1233
|
+
* **Threshold**: \`${s.auto_compact.threshold ?? 80}%\`
|
|
1234
|
+
* **Warning Threshold**: \`${s.auto_compact.warn_threshold ?? 15}%\`
|
|
1235
|
+
* **Pricing Warn**: \`${s.pricing_tier_warn ?? 'off'}\`
|
|
1236
|
+
|
|
1237
|
+
**Usage**: \`/context auto [on|off|threshold N|warn N|pricing on/off]\``);
|
|
1238
|
+
}
|
|
1239
|
+
else if (arg1 === 'on') {
|
|
1240
|
+
s.auto_compact.enabled = 'on';
|
|
1241
|
+
this.settingsManager.save();
|
|
1242
|
+
emitDelta(`Autocompaction enabled.`);
|
|
1243
|
+
}
|
|
1244
|
+
else if (arg1 === 'off') {
|
|
1245
|
+
s.auto_compact.enabled = 'off';
|
|
1246
|
+
this.settingsManager.save();
|
|
1247
|
+
emitDelta(`Autocompaction disabled.`);
|
|
1248
|
+
}
|
|
1249
|
+
else if (arg1 === 'threshold' && arg2) {
|
|
1250
|
+
const pct = parseInt(arg2, 10);
|
|
1251
|
+
if (isNaN(pct) || pct < 10 || pct > 99) {
|
|
1252
|
+
emitDelta(`Invalid threshold. Use a value between 10 and 99.`);
|
|
1253
|
+
}
|
|
1254
|
+
else {
|
|
1255
|
+
s.auto_compact.threshold = pct;
|
|
1256
|
+
this.settingsManager.save();
|
|
1257
|
+
emitDelta(`Compaction threshold set to ${pct}%.`);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
else if (arg1 === 'warn' && arg2) {
|
|
1261
|
+
const pct = parseInt(arg2, 10);
|
|
1262
|
+
if (isNaN(pct) || pct < 5 || pct > 95) {
|
|
1263
|
+
emitDelta(`Invalid warning threshold. Use a value between 5 and 95.`);
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
s.auto_compact.warn_threshold = pct;
|
|
1267
|
+
this.settingsManager.save();
|
|
1268
|
+
emitDelta(`Warning threshold set to ${pct}%.`);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
else if (arg1 === 'pricing') {
|
|
1272
|
+
if (arg2 === 'on') {
|
|
1273
|
+
s.pricing_tier_warn = 'on';
|
|
1274
|
+
this.settingsManager.save();
|
|
1275
|
+
emitDelta(`Pricing tier warnings enabled.`);
|
|
1276
|
+
}
|
|
1277
|
+
else if (arg2 === 'off') {
|
|
1278
|
+
s.pricing_tier_warn = 'off';
|
|
1279
|
+
this.settingsManager.save();
|
|
1280
|
+
emitDelta(`Pricing tier warnings disabled.`);
|
|
1281
|
+
}
|
|
1282
|
+
else {
|
|
1283
|
+
emitDelta(`Usage: \`/context auto pricing on/off\``);
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
else {
|
|
1287
|
+
emitDelta(`Usage: \`/context auto [on|off|threshold N|warn N|pricing on/off]\``);
|
|
1288
|
+
}
|
|
1289
|
+
}
|
|
1290
|
+
else if (sub === 'compact') {
|
|
1291
|
+
emitDelta(`### ⚡ Manual Compaction Triggered\n\nAnalyzing conversation history and building summary...`);
|
|
1292
|
+
try {
|
|
1293
|
+
const summary = await this.runAutomaticCompaction(sessionId, 'detailed');
|
|
1294
|
+
emitDelta(`\n\n⚡ **Manual Compaction Executed Successfully!**\n\nConversation history has been summarized, reducing the active context usage to just ~500 tokens. The agent will continue seamlessly!\n\n**Restored Context Summary:**\n\n${summary}`);
|
|
1295
|
+
}
|
|
1296
|
+
catch (err) {
|
|
1297
|
+
emitDelta(`\n\n❌ **Compaction Failed**: ${err instanceof Error ? err.message : String(err)}`);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
else {
|
|
1301
|
+
const activeLoop = this.turnLoops.get(sessionId);
|
|
1302
|
+
const history = activeLoop
|
|
1303
|
+
? activeLoop.eventBus.history()
|
|
1304
|
+
: (this.sessionStore.loadEvents(sessionId) || []);
|
|
1305
|
+
const usageEvents = history.filter((e) => e.type === 'usage');
|
|
1306
|
+
let input = 0;
|
|
1307
|
+
let output = 0;
|
|
1308
|
+
for (const e of usageEvents) {
|
|
1309
|
+
input += e.inputTokens || 0;
|
|
1310
|
+
output += e.outputTokens || 0;
|
|
1311
|
+
}
|
|
1312
|
+
const model = settings.model || 'unknown';
|
|
1313
|
+
const windowSize = settings.providers?.[settings.current_provider]?.model_context_window ?? 200000;
|
|
1314
|
+
const pct = input > 0 ? Math.min(100, Math.round((input / windowSize) * 100)) : 0;
|
|
1315
|
+
const filled = Math.round((pct / 100) * 24);
|
|
1316
|
+
const bar = '█'.repeat(filled) + '░'.repeat(24 - filled);
|
|
1317
|
+
const fmt = (n) => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
|
|
1318
|
+
if (input === 0 && output === 0) {
|
|
1319
|
+
emitDelta(`No token data yet. Start a conversation to see context window usage.\n\nActive model: \`${model}\` (Max Context: \`${fmt(windowSize)}\` tokens).`);
|
|
1320
|
+
}
|
|
1321
|
+
else {
|
|
1322
|
+
const lines = [
|
|
1323
|
+
`### Context Window Usage (\`${model}\`):`,
|
|
1324
|
+
`\`${bar}\` **${pct}%** (${fmt(input)}/${fmt(windowSize)})`,
|
|
1325
|
+
`* **Tokens in**: \`${input.toLocaleString()}\``,
|
|
1326
|
+
`* **Tokens out**: \`${output.toLocaleString()}\``,
|
|
1327
|
+
];
|
|
1328
|
+
emitDelta(lines.join('\n'));
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
break;
|
|
1332
|
+
}
|
|
1333
|
+
case 'remind': {
|
|
1334
|
+
if (!this.daemonApp) {
|
|
1335
|
+
emitDelta(`Cron reminder system is not active on this daemon instance.`);
|
|
1336
|
+
break;
|
|
1337
|
+
}
|
|
1338
|
+
if (!args) {
|
|
1339
|
+
emitDelta(`Usage: \`/remind <message at time>\` (e.g. \`in 30 mins submit report\`)`);
|
|
1340
|
+
break;
|
|
1341
|
+
}
|
|
1342
|
+
const parsed = parseReminderTime(args);
|
|
1343
|
+
if (!parsed) {
|
|
1344
|
+
emitDelta(`Failed to parse reminder time. Please format like: \`in 2 hours call developer\` or \`tomorrow at 9:00 am meeting\`.`);
|
|
1345
|
+
break;
|
|
1346
|
+
}
|
|
1347
|
+
const task = this.daemonApp.taskManager.create({ title: parsed.message, mode: 'notify', scope: 'personal', scheduled_at: parsed.scheduledAt });
|
|
1348
|
+
emitDelta(`Reminder scheduled! **"${parsed.message}"** at ${new Date(parsed.scheduledAt).toLocaleString()}`);
|
|
1349
|
+
break;
|
|
1350
|
+
}
|
|
1351
|
+
case 'cron': {
|
|
1352
|
+
if (!this.daemonApp) {
|
|
1353
|
+
emitDelta(`Task service is not active on this daemon.`);
|
|
1354
|
+
break;
|
|
1355
|
+
}
|
|
1356
|
+
this.daemonApp.taskManager.load();
|
|
1357
|
+
const parts = args.split(/\s+/);
|
|
1358
|
+
const sub = parts[0]?.toLowerCase() || '';
|
|
1359
|
+
const rest = parts.slice(1).join(' ').trim();
|
|
1360
|
+
if (sub === 'list' || !sub) {
|
|
1361
|
+
const list = this.daemonApp.taskManager.list({ mode: 'notify' });
|
|
1362
|
+
if (list.length === 0) {
|
|
1363
|
+
emitDelta(`No reminders scheduled.`);
|
|
1364
|
+
}
|
|
1365
|
+
else {
|
|
1366
|
+
const items = list.map((t, i) => {
|
|
1367
|
+
const timeStr = t.scheduled_at ? new Date(t.scheduled_at).toLocaleString() : '—';
|
|
1368
|
+
return `${i + 1}. ${t.status} ${t.title} (Scheduled: ${timeStr}, ID: \`${t.id.slice(0, 8)}\`)`;
|
|
1369
|
+
}).join('\n');
|
|
1370
|
+
emitDelta(`### Active Reminders:\n${items}`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
else if (sub === 'delete') {
|
|
1374
|
+
if (!rest) {
|
|
1375
|
+
emitDelta(`Usage: \`/cron delete <id>\``);
|
|
1376
|
+
}
|
|
1377
|
+
else {
|
|
1378
|
+
const result = this.daemonApp.taskManager.cancelTask(rest);
|
|
1379
|
+
if (result) {
|
|
1380
|
+
emitDelta(`Reminder \`${rest}\` cancelled.`);
|
|
1381
|
+
}
|
|
1382
|
+
else {
|
|
1383
|
+
emitDelta(`No reminder found with ID: \`${rest}\``);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
else if (sub === 'clear') {
|
|
1388
|
+
const removed = this.daemonApp.taskManager.clearCompleted();
|
|
1389
|
+
emitDelta(`Successfully cleared ${removed} completed task(s).`);
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
emitDelta(`Unknown cron subcommand. Supported: \`/cron list\`, \`/cron delete <id>\`, \`/cron clear\`.`);
|
|
1393
|
+
}
|
|
1394
|
+
break;
|
|
1395
|
+
}
|
|
1396
|
+
case 'heartbeat': {
|
|
1397
|
+
if (!this.daemonApp) {
|
|
1398
|
+
emitDelta(`Heartbeat system is not active on this daemon.`);
|
|
1399
|
+
break;
|
|
1400
|
+
}
|
|
1401
|
+
const settings = this.settingsManager.get();
|
|
1402
|
+
if (!settings.heartbeat) {
|
|
1403
|
+
settings.heartbeat = { schedule: 'off', mode: 'yolo', daily: '6:00', weekly: 'monday@6:00', monthly: '1@6:00', dreaming: '2:00', intraday: '' };
|
|
1404
|
+
}
|
|
1405
|
+
const parts = args.trim().split(/\s+/);
|
|
1406
|
+
const sub = parts[0]?.toLowerCase();
|
|
1407
|
+
const rest = parts.slice(1).join(' ').trim();
|
|
1408
|
+
switch (sub) {
|
|
1409
|
+
case 'status':
|
|
1410
|
+
case '': {
|
|
1411
|
+
const active = settings.heartbeat.schedule === 'on';
|
|
1412
|
+
const intradayDisplay = settings.heartbeat.intraday || '(not set)';
|
|
1413
|
+
emitDelta(`### Heartbeat Cycle Status:
|
|
1414
|
+
* **Enabled**: \`${active ? 'yes' : 'no'}\`
|
|
1415
|
+
* **Intraday**: \`${intradayDisplay}\`
|
|
1416
|
+
* **Daily**: \`${settings.heartbeat.daily}\`
|
|
1417
|
+
* **Weekly**: \`${settings.heartbeat.weekly}\`
|
|
1418
|
+
* **Monthly**: \`${settings.heartbeat.monthly}\`
|
|
1419
|
+
* **Dreaming**: \`${settings.heartbeat.dreaming}\`
|
|
1420
|
+
|
|
1421
|
+
**Usage**:
|
|
1422
|
+
* \`/heartbeat enable\` / \`/heartbeat disable\`
|
|
1423
|
+
* \`/heartbeat daily <H:MM>\` (24h)
|
|
1424
|
+
* \`/heartbeat weekly <day@H:MM>\`
|
|
1425
|
+
* \`/heartbeat monthly <D@H:MM>\`
|
|
1426
|
+
* \`/heartbeat dreaming <H:MM>\`
|
|
1427
|
+
* \`/heartbeat intraday <H:MM,...>\` (e.g. \`8:10,14:20\`)
|
|
1428
|
+
* \`/heartbeat now\` — run a heartbeat immediately.`);
|
|
1429
|
+
break;
|
|
1430
|
+
}
|
|
1431
|
+
case 'enable': {
|
|
1432
|
+
settings.heartbeat.schedule = 'on';
|
|
1433
|
+
this.settingsManager.save();
|
|
1434
|
+
if (this.daemonApp) {
|
|
1435
|
+
this.daemonApp.taskManager.rescheduleFromSettings({
|
|
1436
|
+
HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
|
|
1437
|
+
HEARTBEAT_DAILY: settings.heartbeat.daily,
|
|
1438
|
+
HEARTBEAT_WEEKLY: settings.heartbeat.weekly,
|
|
1439
|
+
HEARTBEAT_MONTHLY: settings.heartbeat.monthly,
|
|
1440
|
+
HEARTBEAT_DREAMING: settings.heartbeat.dreaming,
|
|
1441
|
+
});
|
|
1442
|
+
}
|
|
1443
|
+
emitDelta(`Heartbeat cycle enabled.`);
|
|
1444
|
+
break;
|
|
1445
|
+
}
|
|
1446
|
+
case 'disable': {
|
|
1447
|
+
settings.heartbeat.schedule = 'off';
|
|
1448
|
+
this.settingsManager.save();
|
|
1449
|
+
if (this.daemonApp) {
|
|
1450
|
+
this.daemonApp.taskManager.cancelAllHeartbeats();
|
|
1451
|
+
}
|
|
1452
|
+
emitDelta(`Heartbeat cycle disabled.`);
|
|
1453
|
+
break;
|
|
1454
|
+
}
|
|
1455
|
+
case 'intraday': {
|
|
1456
|
+
if (!rest) {
|
|
1457
|
+
emitDelta(`Usage: \`/heartbeat intraday <H:MM,...>\` (e.g., \`8:10,14:20\`)`);
|
|
1458
|
+
}
|
|
1459
|
+
else {
|
|
1460
|
+
const tokens = rest.split(',').map(s => s.trim()).filter(Boolean);
|
|
1461
|
+
const invalid = tokens.filter(t => {
|
|
1462
|
+
if (!/^\d{1,2}:\d{2}$/.test(t))
|
|
1463
|
+
return true;
|
|
1464
|
+
const parts = t.split(':').map(Number);
|
|
1465
|
+
const h = parts[0];
|
|
1466
|
+
const m = parts[1];
|
|
1467
|
+
if (h === undefined || m === undefined)
|
|
1468
|
+
return true;
|
|
1469
|
+
return h < 0 || h > 23 || m < 0 || m > 59;
|
|
1470
|
+
});
|
|
1471
|
+
if (invalid.length > 0) {
|
|
1472
|
+
emitDelta(`Invalid times: ${invalid.join(', ')}. Hour 0-23, minute 0-59.`);
|
|
1473
|
+
}
|
|
1474
|
+
else {
|
|
1475
|
+
settings.heartbeat.intraday = tokens.join(',');
|
|
1476
|
+
this.settingsManager.save();
|
|
1477
|
+
if (this.daemonApp && settings.heartbeat.schedule === 'on') {
|
|
1478
|
+
this.daemonApp.taskManager.rescheduleFromSettings({
|
|
1479
|
+
HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
|
|
1480
|
+
HEARTBEAT_DAILY: settings.heartbeat.daily,
|
|
1481
|
+
HEARTBEAT_WEEKLY: settings.heartbeat.weekly,
|
|
1482
|
+
HEARTBEAT_MONTHLY: settings.heartbeat.monthly,
|
|
1483
|
+
HEARTBEAT_DREAMING: settings.heartbeat.dreaming,
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
emitDelta(`Intraday heartbeat times set to: \`${settings.heartbeat.intraday}\``);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
break;
|
|
1490
|
+
}
|
|
1491
|
+
case 'daily': {
|
|
1492
|
+
if (!rest || !/^\d{1,2}:\d{2}$/.test(rest)) {
|
|
1493
|
+
emitDelta(`Usage: \`/heartbeat daily <H:MM>\` (24h, e.g. \`6:00\`)`);
|
|
1494
|
+
}
|
|
1495
|
+
else {
|
|
1496
|
+
const parts = rest.split(':').map(Number);
|
|
1497
|
+
const h = parts[0];
|
|
1498
|
+
const m = parts[1];
|
|
1499
|
+
if (h === undefined || m === undefined || h < 0 || h > 23 || m < 0 || m > 59) {
|
|
1500
|
+
emitDelta(`Invalid time hour/minute limits.`);
|
|
1501
|
+
}
|
|
1502
|
+
else {
|
|
1503
|
+
settings.heartbeat.daily = rest;
|
|
1504
|
+
this.settingsManager.save();
|
|
1505
|
+
if (this.daemonApp && settings.heartbeat.schedule === 'on') {
|
|
1506
|
+
this.daemonApp.taskManager.rescheduleFromSettings({
|
|
1507
|
+
HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
|
|
1508
|
+
HEARTBEAT_DAILY: settings.heartbeat.daily,
|
|
1509
|
+
HEARTBEAT_WEEKLY: settings.heartbeat.weekly,
|
|
1510
|
+
HEARTBEAT_MONTHLY: settings.heartbeat.monthly,
|
|
1511
|
+
HEARTBEAT_DREAMING: settings.heartbeat.dreaming,
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
emitDelta(`Daily heartbeat time set to: \`${rest}\``);
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
break;
|
|
1518
|
+
}
|
|
1519
|
+
case 'weekly': {
|
|
1520
|
+
const atIdx = rest.indexOf('@');
|
|
1521
|
+
if (atIdx < 0) {
|
|
1522
|
+
emitDelta(`Usage: \`/heartbeat weekly <day@H:MM>\` (e.g. \`monday@6:00\`)`);
|
|
1523
|
+
}
|
|
1524
|
+
else {
|
|
1525
|
+
const day = rest.slice(0, atIdx).toLowerCase();
|
|
1526
|
+
const time = rest.slice(atIdx + 1);
|
|
1527
|
+
const validDays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
|
1528
|
+
if (!validDays.includes(day) || !/^\d{1,2}:\d{2}$/.test(time)) {
|
|
1529
|
+
emitDelta(`Invalid day or time format.`);
|
|
1530
|
+
}
|
|
1531
|
+
else {
|
|
1532
|
+
settings.heartbeat.weekly = rest;
|
|
1533
|
+
this.settingsManager.save();
|
|
1534
|
+
if (this.daemonApp && settings.heartbeat.schedule === 'on') {
|
|
1535
|
+
this.daemonApp.taskManager.rescheduleFromSettings({
|
|
1536
|
+
HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
|
|
1537
|
+
HEARTBEAT_DAILY: settings.heartbeat.daily,
|
|
1538
|
+
HEARTBEAT_WEEKLY: settings.heartbeat.weekly,
|
|
1539
|
+
HEARTBEAT_MONTHLY: settings.heartbeat.monthly,
|
|
1540
|
+
HEARTBEAT_DREAMING: settings.heartbeat.dreaming,
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
emitDelta(`Weekly heartbeat schedule set to: \`${rest}\``);
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
break;
|
|
1547
|
+
}
|
|
1548
|
+
case 'monthly': {
|
|
1549
|
+
const atIdx = rest.indexOf('@');
|
|
1550
|
+
if (atIdx < 0) {
|
|
1551
|
+
emitDelta(`Usage: \`/heartbeat monthly <D@H:MM>\` (e.g. \`1@6:00\`)`);
|
|
1552
|
+
}
|
|
1553
|
+
else {
|
|
1554
|
+
const day = parseInt(rest.slice(0, atIdx), 10);
|
|
1555
|
+
const time = rest.slice(atIdx + 1);
|
|
1556
|
+
if (isNaN(day) || day < 1 || day > 31 || !/^\d{1,2}:\d{2}$/.test(time)) {
|
|
1557
|
+
emitDelta(`Invalid day of month (1-31) or time format.`);
|
|
1558
|
+
}
|
|
1559
|
+
else {
|
|
1560
|
+
settings.heartbeat.monthly = rest;
|
|
1561
|
+
this.settingsManager.save();
|
|
1562
|
+
if (this.daemonApp && settings.heartbeat.schedule === 'on') {
|
|
1563
|
+
this.daemonApp.taskManager.rescheduleFromSettings({
|
|
1564
|
+
HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
|
|
1565
|
+
HEARTBEAT_DAILY: settings.heartbeat.daily,
|
|
1566
|
+
HEARTBEAT_WEEKLY: settings.heartbeat.weekly,
|
|
1567
|
+
HEARTBEAT_MONTHLY: settings.heartbeat.monthly,
|
|
1568
|
+
HEARTBEAT_DREAMING: settings.heartbeat.dreaming,
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
emitDelta(`Monthly heartbeat schedule set to: \`${rest}\``);
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
break;
|
|
1575
|
+
}
|
|
1576
|
+
case 'dreaming': {
|
|
1577
|
+
if (!rest || !/^\d{1,2}:\d{2}$/.test(rest)) {
|
|
1578
|
+
emitDelta(`Usage: \`/heartbeat dreaming <H:MM>\` (e.g. \`2:00\`)`);
|
|
1579
|
+
}
|
|
1580
|
+
else {
|
|
1581
|
+
const parts = rest.split(':').map(Number);
|
|
1582
|
+
const h = parts[0];
|
|
1583
|
+
const m = parts[1];
|
|
1584
|
+
if (h === undefined || m === undefined || h < 0 || h > 23 || m < 0 || m > 59) {
|
|
1585
|
+
emitDelta(`Invalid dreaming time hour/minute limits.`);
|
|
1586
|
+
}
|
|
1587
|
+
else {
|
|
1588
|
+
settings.heartbeat.dreaming = rest;
|
|
1589
|
+
this.settingsManager.save();
|
|
1590
|
+
if (this.daemonApp && settings.heartbeat.schedule === 'on') {
|
|
1591
|
+
this.daemonApp.taskManager.rescheduleFromSettings({
|
|
1592
|
+
HEARTBEAT_INTRADAY: settings.heartbeat.intraday,
|
|
1593
|
+
HEARTBEAT_DAILY: settings.heartbeat.daily,
|
|
1594
|
+
HEARTBEAT_WEEKLY: settings.heartbeat.weekly,
|
|
1595
|
+
HEARTBEAT_MONTHLY: settings.heartbeat.monthly,
|
|
1596
|
+
HEARTBEAT_DREAMING: settings.heartbeat.dreaming,
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
emitDelta(`Dreaming heartbeat time set to: \`${rest}\``);
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
break;
|
|
1603
|
+
}
|
|
1604
|
+
case 'now': {
|
|
1605
|
+
emitDelta(`Executing immediate heartbeat cycle...`);
|
|
1606
|
+
const res = await this.daemonApp.runHeartbeat();
|
|
1607
|
+
emitDelta(`### Heartbeat Finished!\n${res.text}\n* Tool calls: ${res.toolCalls}\n* Errors: ${res.errors.length > 0 ? res.errors.join(', ') : 'none'}`);
|
|
1608
|
+
break;
|
|
1609
|
+
}
|
|
1610
|
+
default:
|
|
1611
|
+
emitDelta(`Unknown heartbeat command. Supported subcommands: \`status\`, \`enable\`, \`disable\`, \`daily\`, \`weekly\`, \`monthly\`, \`dreaming\`, \`intraday\`, \`now\`.`);
|
|
1612
|
+
break;
|
|
1613
|
+
}
|
|
1614
|
+
break;
|
|
1615
|
+
}
|
|
1616
|
+
case 'task': {
|
|
1617
|
+
if (!this.daemonApp) {
|
|
1618
|
+
emitDelta(`Task system is not active on this daemon.`);
|
|
1619
|
+
break;
|
|
1620
|
+
}
|
|
1621
|
+
this.daemonApp.taskManager.load();
|
|
1622
|
+
const parts = args.trim().split(/\s+/);
|
|
1623
|
+
const sub = parts[0]?.toLowerCase();
|
|
1624
|
+
const rest = parts.slice(1).join(' ').trim();
|
|
1625
|
+
switch (sub) {
|
|
1626
|
+
case 'create': {
|
|
1627
|
+
if (!rest) {
|
|
1628
|
+
emitDelta(`Usage: \`/task create <instruction at time>\` (e.g., \`/task create at 7:55 make a report about AI models\`)`);
|
|
1629
|
+
}
|
|
1630
|
+
else {
|
|
1631
|
+
const parsed = parseReminderTime(rest);
|
|
1632
|
+
if (!parsed) {
|
|
1633
|
+
emitDelta(`Could not parse scheduled time from: "${rest}". Try: \`at 7:55 do something\` or \`tomorrow at 9am do something\`.`);
|
|
1634
|
+
}
|
|
1635
|
+
else {
|
|
1636
|
+
const task = this.daemonApp.taskManager.create({ title: parsed.message, mode: 'auto', scope: 'personal', scheduled_at: parsed.scheduledAt });
|
|
1637
|
+
const timeStr = new Date(task.scheduled_at).toLocaleString();
|
|
1638
|
+
emitDelta(`### Task Scheduled Successfully!
|
|
1639
|
+
* **Task ID**: \`${task.id}\`
|
|
1640
|
+
* **Scheduled Time**: \`${timeStr}\`
|
|
1641
|
+
* **Instruction**: "${parsed.message}"`);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
break;
|
|
1645
|
+
}
|
|
1646
|
+
case 'list': {
|
|
1647
|
+
const filter = ['pending', 'executing', 'completed', 'failed', 'cancelled'].includes(rest.toLowerCase())
|
|
1648
|
+
? rest.toLowerCase()
|
|
1649
|
+
: undefined;
|
|
1650
|
+
const tasks = this.daemonApp.taskManager.list({ mode: 'auto' });
|
|
1651
|
+
if (tasks.length === 0) {
|
|
1652
|
+
emitDelta(filter ? `No tasks found with status **${filter}**.` : `No tasks scheduled yet. Use \`/task create\` to schedule a task.`);
|
|
1653
|
+
}
|
|
1654
|
+
else {
|
|
1655
|
+
const lines = [`### Scheduled Tasks (${tasks.length}${filter ? ` — ${filter}` : ''}):`];
|
|
1656
|
+
tasks.forEach(t => {
|
|
1657
|
+
const statusEmoji = t.status === 'pending' ? '⏳ PENDING'
|
|
1658
|
+
: t.status === 'executing' ? '⚙️ RUNNING'
|
|
1659
|
+
: t.status === 'completed' ? '✅ COMPLETED'
|
|
1660
|
+
: t.status === 'failed' ? '❌ FAILED'
|
|
1661
|
+
: '🚫 CANCELLED';
|
|
1662
|
+
const timeStr = t.scheduled_at ? new Date(t.scheduled_at).toLocaleString() : '—';
|
|
1663
|
+
lines.push(`* **[${statusEmoji}]** "${t.title}"`);
|
|
1664
|
+
lines.push(` └─ Scheduled: \`${timeStr}\``);
|
|
1665
|
+
lines.push(` └─ ID: \`${t.id}\``);
|
|
1666
|
+
});
|
|
1667
|
+
emitDelta(lines.join('\n'));
|
|
1668
|
+
}
|
|
1669
|
+
break;
|
|
1670
|
+
}
|
|
1671
|
+
case 'delete': {
|
|
1672
|
+
if (!rest) {
|
|
1673
|
+
emitDelta(`Usage: \`/task delete <id>\``);
|
|
1674
|
+
}
|
|
1675
|
+
else {
|
|
1676
|
+
const res = this.daemonApp.taskManager.cancelTask(rest);
|
|
1677
|
+
if (res) {
|
|
1678
|
+
emitDelta(`Task \`${rest}\` cancelled successfully.`);
|
|
1679
|
+
}
|
|
1680
|
+
else {
|
|
1681
|
+
emitDelta(`No task found with ID \`${rest}\`.`);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
break;
|
|
1685
|
+
}
|
|
1686
|
+
default:
|
|
1687
|
+
emitDelta(`Usage:\n* \`/task create <instruction at time>\`\n* \`/task list [status]\`\n* \`/task delete <id>\``);
|
|
1688
|
+
break;
|
|
1689
|
+
}
|
|
1690
|
+
break;
|
|
1691
|
+
}
|
|
1692
|
+
case 'snapshots': {
|
|
1693
|
+
const snaps = listSnapshots(process.cwd());
|
|
1694
|
+
if (snaps.length === 0) {
|
|
1695
|
+
emitDelta(`No Git snapshots found for the current directory. Snapshots are created automatically in yolo mode.`);
|
|
1696
|
+
}
|
|
1697
|
+
else {
|
|
1698
|
+
const items = snaps.map((s, i) => {
|
|
1699
|
+
return `${i}) **${s.sha.slice(0, 7)}** — ${new Date(s.timestamp).toLocaleString()} (${s.changedFiles} files changed: _"${s.label}"_)`;
|
|
1700
|
+
}).join('\n');
|
|
1701
|
+
emitDelta(`### Recent Git Snapshots:\n${items}\n\nUse \`/revert <index>\` to restore files.`);
|
|
1702
|
+
}
|
|
1703
|
+
break;
|
|
1704
|
+
}
|
|
1705
|
+
case 'system': {
|
|
1706
|
+
const settings = this.settingsManager.get();
|
|
1707
|
+
const safety = settings.safety;
|
|
1708
|
+
const osName = os.platform() === 'win32' ? 'Windows' : os.platform() === 'darwin' ? 'macOS' : 'Linux';
|
|
1709
|
+
const curieDir = path.join(os.homedir(), '.curie-agent');
|
|
1710
|
+
let lines = [];
|
|
1711
|
+
lines.push(`**OS:** ${osName} (${os.arch()}, ${os.hostname()})`);
|
|
1712
|
+
lines.push(`**Platform:** \`${os.platform()}\``);
|
|
1713
|
+
lines.push(`**Node:** \`${process.version}\``);
|
|
1714
|
+
lines.push(`**Home:** \`${os.homedir()}\``);
|
|
1715
|
+
lines.push(`**Curie Agent Dir:** \`${curieDir}\``);
|
|
1716
|
+
lines.push(`**CWD:** \`${this.getSessionCwd(sessionId) || process.cwd()}\``);
|
|
1717
|
+
lines.push('');
|
|
1718
|
+
lines.push(`### PathGuard`);
|
|
1719
|
+
lines.push(`* **Status:** \`${safety?.path_guard || 'on'}\``);
|
|
1720
|
+
const raw = safety?.path_allowlist;
|
|
1721
|
+
const hasAllowlist = raw && (Array.isArray(raw) ? raw.length > 0 : typeof raw === 'string' && raw.trim().length > 0);
|
|
1722
|
+
const display = Array.isArray(raw) ? raw.join(', ') : raw;
|
|
1723
|
+
lines.push(`* **Allowlist:** ${hasAllowlist ? `\`${display}\`` : '(empty)'}`);
|
|
1724
|
+
lines.push(`**Blocked:** \`${curieDir}/settings.json\` (API keys)`);
|
|
1725
|
+
emitDelta(lines.join('\n'));
|
|
1726
|
+
break;
|
|
1727
|
+
}
|
|
1728
|
+
case 'revert': {
|
|
1729
|
+
if (!args) {
|
|
1730
|
+
emitDelta(`Usage: \`/revert <index>\`. Run \`/snapshots\` first to view available indices.`);
|
|
1731
|
+
break;
|
|
1732
|
+
}
|
|
1733
|
+
const idx = parseInt(args, 10);
|
|
1734
|
+
const snaps = listSnapshots(process.cwd());
|
|
1735
|
+
if (isNaN(idx) || idx < 0 || idx >= snaps.length) {
|
|
1736
|
+
emitDelta(`Invalid index: **${args}**. Please choose between 0 and ${snaps.length - 1}.`);
|
|
1737
|
+
break;
|
|
1738
|
+
}
|
|
1739
|
+
const snap = snaps[idx];
|
|
1740
|
+
if (!snap) {
|
|
1741
|
+
emitDelta(`Snapshot at index ${idx} not found.`);
|
|
1742
|
+
break;
|
|
1743
|
+
}
|
|
1744
|
+
const res = await revertTo(process.cwd(), snap.sha);
|
|
1745
|
+
if (res.success) {
|
|
1746
|
+
emitDelta(`Successfully reverted current workspace files to snapshot **${snap.sha.slice(0, 7)}** (_"${snap.label}"_)!`);
|
|
1747
|
+
}
|
|
1748
|
+
else {
|
|
1749
|
+
emitDelta(`Failed to revert: ${res.error}`);
|
|
1750
|
+
}
|
|
1751
|
+
break;
|
|
1752
|
+
}
|
|
1753
|
+
case 'cd': {
|
|
1754
|
+
if (!args) {
|
|
1755
|
+
emitDelta('Usage: `/cd <path>` — Change working directory.\n* `/cd add <path>` — Add path to PathGuard allowlist.');
|
|
1756
|
+
break;
|
|
1757
|
+
}
|
|
1758
|
+
const cdParts = args.trim().split(/\s+/);
|
|
1759
|
+
const subCmd = cdParts[0]?.toLowerCase();
|
|
1760
|
+
const pathArg = cdParts.slice(1).join(' ').trim();
|
|
1761
|
+
// /cd add <path> — add a directory to the PathGuard allowlist
|
|
1762
|
+
if (subCmd === 'add') {
|
|
1763
|
+
if (!pathArg) {
|
|
1764
|
+
emitDelta('Usage: `/cd add <path>` — Add a directory to the PathGuard allowlist.');
|
|
1765
|
+
break;
|
|
1766
|
+
}
|
|
1767
|
+
const settings = this.settingsManager.get();
|
|
1768
|
+
const safety = settings.safety || {};
|
|
1769
|
+
const rawAllowlist = safety.path_allowlist;
|
|
1770
|
+
const current = Array.isArray(rawAllowlist) ? [...rawAllowlist] : [];
|
|
1771
|
+
const existingStrings = current.map(s => s.trim()).filter(Boolean);
|
|
1772
|
+
// Normalize the requested path
|
|
1773
|
+
let normalized;
|
|
1774
|
+
try {
|
|
1775
|
+
const statResult = statSync(pathArg);
|
|
1776
|
+
if (!statResult.isDirectory()) {
|
|
1777
|
+
emitDelta(`"${pathArg}" is not a directory.`);
|
|
1778
|
+
break;
|
|
1779
|
+
}
|
|
1780
|
+
normalized = realpathSync(pathArg);
|
|
1781
|
+
}
|
|
1782
|
+
catch (err) {
|
|
1783
|
+
const msg = err instanceof Error ? err.message : 'unknown';
|
|
1784
|
+
emitDelta(`Cannot access "${pathArg}": ${msg}`);
|
|
1785
|
+
break;
|
|
1786
|
+
}
|
|
1787
|
+
if (existingStrings.includes(normalized)) {
|
|
1788
|
+
emitDelta(`"${normalized}" is already in the PathGuard allowlist.`);
|
|
1789
|
+
break;
|
|
1790
|
+
}
|
|
1791
|
+
const updated = [...current, normalized];
|
|
1792
|
+
safety.path_allowlist = updated;
|
|
1793
|
+
await this.settingsManager.update({ ...settings, safety });
|
|
1794
|
+
emitDelta(`Added "${normalized}" to PathGuard allowlist (${updated.length} path${updated.length === 1 ? '' : 's'} total).`);
|
|
1795
|
+
break;
|
|
1796
|
+
}
|
|
1797
|
+
if (!args || cdParts[0]?.startsWith('-')) {
|
|
1798
|
+
emitDelta('Usage: `/cd <path>` — Change working directory.\nExamples:\n* `/cd ../other-project` — relative to current\n* `/cd /home/user/docs` — absolute path\n* `/cd ~` — home directory');
|
|
1799
|
+
break;
|
|
1800
|
+
}
|
|
1801
|
+
const sessionInfo = this.sessionStore.load(sessionId);
|
|
1802
|
+
if (!sessionInfo) {
|
|
1803
|
+
emitDelta('No session found for the given session ID.');
|
|
1804
|
+
break;
|
|
1805
|
+
}
|
|
1806
|
+
const settings = this.settingsManager.get();
|
|
1807
|
+
const safety = settings.safety;
|
|
1808
|
+
const safetyPathAllowlist = safety?.path_allowlist;
|
|
1809
|
+
const snapshotsEnabled = safety?.snapshots !== 'off';
|
|
1810
|
+
const result = await executeCd(args, sessionInfo.cwd, safetyPathAllowlist, snapshotsEnabled, this.sessionStore.metadataPath(sessionId));
|
|
1811
|
+
// Emit config-changed event on success (notify clients of CWD change)
|
|
1812
|
+
if (result.kind === 'success') {
|
|
1813
|
+
const normalized = result.message.split('\n')[1]?.replace(/\*\*/g, '').trim() || '';
|
|
1814
|
+
this.sharedEventBus?.emit({
|
|
1815
|
+
type: 'config-changed',
|
|
1816
|
+
id: crypto.randomUUID(),
|
|
1817
|
+
timestamp: Date.now(),
|
|
1818
|
+
key: 'cwd',
|
|
1819
|
+
value: normalized,
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
emitDelta(result.message);
|
|
1823
|
+
break;
|
|
1824
|
+
}
|
|
1825
|
+
case 'agent': {
|
|
1826
|
+
if (!this.daemonApp || !this.createProvider) {
|
|
1827
|
+
emitDelta(`Agent system is not active on this daemon.`);
|
|
1828
|
+
break;
|
|
1829
|
+
}
|
|
1830
|
+
if (!args) {
|
|
1831
|
+
emitDelta(`Usage: \`/agent <prompt>\` to spawn a subagent. The subagent runs in parallel and streams output back.`);
|
|
1832
|
+
break;
|
|
1833
|
+
}
|
|
1834
|
+
try {
|
|
1835
|
+
const settings = this.settingsManager.get();
|
|
1836
|
+
const provider = this.createProvider(settings);
|
|
1837
|
+
const handle = await this.daemonApp.subagentExecutor.spawn({
|
|
1838
|
+
provider,
|
|
1839
|
+
model: settings.model_override || settings.model,
|
|
1840
|
+
tools: this.tools,
|
|
1841
|
+
cwd: join(homedir(), '.curie-agent'),
|
|
1842
|
+
settings,
|
|
1843
|
+
prompt: args,
|
|
1844
|
+
system: this.systemPrompt,
|
|
1845
|
+
mode: settings.mode || 'auto',
|
|
1846
|
+
type: 'subagent',
|
|
1847
|
+
});
|
|
1848
|
+
emitDelta(`**Agent started**: "${args}" (ID: \`${handle.agentId.slice(0, 8)}...\`). Monitor in the Agents tab.`);
|
|
1849
|
+
}
|
|
1850
|
+
catch (err) {
|
|
1851
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1852
|
+
emitDelta(`Failed to start agent: ${msg}`);
|
|
1853
|
+
}
|
|
1854
|
+
break;
|
|
1855
|
+
}
|
|
1856
|
+
case 'todo': {
|
|
1857
|
+
if (!this.daemonApp) {
|
|
1858
|
+
emitDelta(`Task system is not active on this daemon.`);
|
|
1859
|
+
break;
|
|
1860
|
+
}
|
|
1861
|
+
this.daemonApp.taskManager.load();
|
|
1862
|
+
const parts = args.trim().split(/\s+/);
|
|
1863
|
+
const sub = parts[0]?.toLowerCase() || '';
|
|
1864
|
+
const rest = parts.slice(1).join(' ').trim();
|
|
1865
|
+
// Detect mode keyword (auto/notify) from full args
|
|
1866
|
+
const fullArgsLower = args.toLowerCase();
|
|
1867
|
+
let mode = 'manual';
|
|
1868
|
+
if (/^auto\s/.test(fullArgsLower) || /^\bat\b/.test(fullArgsLower)) {
|
|
1869
|
+
mode = 'auto';
|
|
1870
|
+
}
|
|
1871
|
+
else if (/^notify\s/.test(fullArgsLower) || /remind\s/.test(fullArgsLower)) {
|
|
1872
|
+
mode = 'notify';
|
|
1873
|
+
}
|
|
1874
|
+
const scope = 'personal'; // Default to personal tasks for now
|
|
1875
|
+
switch (sub) {
|
|
1876
|
+
case 'list': {
|
|
1877
|
+
const allTasks = this.daemonApp.taskManager.list({ scope });
|
|
1878
|
+
if (allTasks.length === 0) {
|
|
1879
|
+
emitDelta(`No tasks in ${scope} scope.`);
|
|
1880
|
+
}
|
|
1881
|
+
else {
|
|
1882
|
+
const active = allTasks.filter((t) => !['done', 'canceled'].includes(t.status));
|
|
1883
|
+
const doneCount = allTasks.filter((t) => t.status === 'done').length;
|
|
1884
|
+
const lines = [`### Tasks (${scope}) — ${active.length} active, ${doneCount} done:`];
|
|
1885
|
+
for (const t of active.sort((a, b) => Number(a.order ?? 0) - Number(b.order ?? 0))) {
|
|
1886
|
+
const icon = String(t.status) === 'in_progress' ? '[*]' : '-';
|
|
1887
|
+
const prio = (t.priority && t.priority !== 'medium') ? ` [${t.priority}]` : '';
|
|
1888
|
+
const modeCol = t.mode ? `[${String(t.mode).toUpperCase()}]` : '[MANUAL]';
|
|
1889
|
+
const timeStr = t.scheduled_at ? ` (at ${new Date(t.scheduled_at).toLocaleString()})` : '';
|
|
1890
|
+
lines.push(` ${icon} ${String(t.id).slice(0, 8)} ${modeCol}${prio} ${t.title}${timeStr}`);
|
|
1891
|
+
}
|
|
1892
|
+
emitDelta(lines.join('\n'));
|
|
1893
|
+
}
|
|
1894
|
+
break;
|
|
1895
|
+
}
|
|
1896
|
+
case 'add': {
|
|
1897
|
+
if (!rest) {
|
|
1898
|
+
emitDelta(`Usage: \`/todo add <title>\` or \`/todo auto add "X at Y"\` for scheduled tasks\n /todo notify add "remind about X" — notification only`);
|
|
1899
|
+
break;
|
|
1900
|
+
}
|
|
1901
|
+
// Detect and strip mode keyword
|
|
1902
|
+
let instruction = rest.replace(/^(auto|notify)\s+/, '').trim();
|
|
1903
|
+
if (!instruction) {
|
|
1904
|
+
emitDelta(`Usage: \`/todo add <title>\` or \`/todo auto add "X at Y"\``);
|
|
1905
|
+
break;
|
|
1906
|
+
}
|
|
1907
|
+
// Check for natural language time (at/in/tomorrow etc.)
|
|
1908
|
+
const hasTimeRef = /\b(at|in|tomorrow|tonight)\b/i.test(instruction) || /remind/.test(instruction);
|
|
1909
|
+
if (hasTimeRef) {
|
|
1910
|
+
const parsed = parseReminderTime(instruction);
|
|
1911
|
+
if (parsed) {
|
|
1912
|
+
instruction = parsed.message;
|
|
1913
|
+
if (mode === 'auto') {
|
|
1914
|
+
const task = this.daemonApp.taskManager.create({
|
|
1915
|
+
title: instruction,
|
|
1916
|
+
mode: 'auto', scope, scheduled_at: parsed.scheduledAt,
|
|
1917
|
+
});
|
|
1918
|
+
emitDelta(`**Task scheduled**: "${instruction}" at ${new Date(parsed.scheduledAt).toLocaleString()} (ID: \`${task.id.slice(0, 8)}...\`)`);
|
|
1919
|
+
}
|
|
1920
|
+
else if (mode === 'notify') {
|
|
1921
|
+
const task = this.daemonApp.taskManager.create({
|
|
1922
|
+
title: instruction,
|
|
1923
|
+
mode: 'notify', scope, scheduled_at: parsed.scheduledAt,
|
|
1924
|
+
});
|
|
1925
|
+
emitDelta(`**Reminder set**: "${instruction}" at ${new Date(parsed.scheduledAt).toLocaleString()} (ID: \`${task.id.slice(0, 8)}...\`)`);
|
|
1926
|
+
}
|
|
1927
|
+
else {
|
|
1928
|
+
// manual with time — auto-escalate to notify
|
|
1929
|
+
const task = this.daemonApp.taskManager.create({
|
|
1930
|
+
title: instruction,
|
|
1931
|
+
mode: 'notify', scope, scheduled_at: parsed.scheduledAt,
|
|
1932
|
+
});
|
|
1933
|
+
emitDelta(`**Reminder set**: "${instruction}" at ${new Date(parsed.scheduledAt).toLocaleString()} (ID: \`${task.id.slice(0, 8)}...\`)`);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
else {
|
|
1937
|
+
// No time found — fall through to manual add
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
if (!hasTimeRef) {
|
|
1941
|
+
const task = this.daemonApp.taskManager.create({
|
|
1942
|
+
title: instruction,
|
|
1943
|
+
mode, scope,
|
|
1944
|
+
});
|
|
1945
|
+
emitDelta(`**Task added**: "${instruction}" (ID: \`${task.id.slice(0, 8)}...\`)`);
|
|
1946
|
+
}
|
|
1947
|
+
else if (!parseReminderTime(instruction)) {
|
|
1948
|
+
// Parse failed — add as manual task
|
|
1949
|
+
const task = this.daemonApp.taskManager.create({
|
|
1950
|
+
title: instruction, mode, scope,
|
|
1951
|
+
});
|
|
1952
|
+
emitDelta(`**Task added**: "${instruction}" (ID: \`${task.id.slice(0, 8)}...\`)`);
|
|
1953
|
+
}
|
|
1954
|
+
break;
|
|
1955
|
+
}
|
|
1956
|
+
case 'complete': {
|
|
1957
|
+
if (!rest) {
|
|
1958
|
+
emitDelta(`Usage: \`/todo complete <id>\``);
|
|
1959
|
+
break;
|
|
1960
|
+
}
|
|
1961
|
+
const task = this.daemonApp.taskManager.findTask(rest);
|
|
1962
|
+
if (!task) {
|
|
1963
|
+
emitDelta(`Task not found: \`${rest}\`.`);
|
|
1964
|
+
}
|
|
1965
|
+
else {
|
|
1966
|
+
this.daemonApp.taskManager.updateTaskStatus(task.id, 'done');
|
|
1967
|
+
emitDelta(`Completed: **${task.title}**`);
|
|
1968
|
+
}
|
|
1969
|
+
break;
|
|
1970
|
+
}
|
|
1971
|
+
case 'cancel': {
|
|
1972
|
+
if (!rest) {
|
|
1973
|
+
emitDelta(`Usage: \`/todo cancel <id>\``);
|
|
1974
|
+
break;
|
|
1975
|
+
}
|
|
1976
|
+
const task = this.daemonApp.taskManager.findTask(rest);
|
|
1977
|
+
if (!task) {
|
|
1978
|
+
emitDelta(`Task not found: \`${rest}\`.`);
|
|
1979
|
+
}
|
|
1980
|
+
else {
|
|
1981
|
+
this.daemonApp.taskManager.updateTaskStatus(task.id, 'canceled');
|
|
1982
|
+
emitDelta(`Canceled: **${task.title}**`);
|
|
1983
|
+
}
|
|
1984
|
+
break;
|
|
1985
|
+
}
|
|
1986
|
+
case 'start': {
|
|
1987
|
+
if (!rest) {
|
|
1988
|
+
emitDelta(`Usage: \`/todo start <id>\``);
|
|
1989
|
+
break;
|
|
1990
|
+
}
|
|
1991
|
+
const task = this.daemonApp.taskManager.findTask(rest);
|
|
1992
|
+
if (!task) {
|
|
1993
|
+
emitDelta(`Task not found: \`${rest}\`.`);
|
|
1994
|
+
}
|
|
1995
|
+
else {
|
|
1996
|
+
this.daemonApp.taskManager.updateTaskStatus(task.id, 'in_progress');
|
|
1997
|
+
emitDelta(`Started: **${task.title}**`);
|
|
1998
|
+
}
|
|
1999
|
+
break;
|
|
2000
|
+
}
|
|
2001
|
+
case 'remove': {
|
|
2002
|
+
if (!rest) {
|
|
2003
|
+
emitDelta(`Usage: \`/todo remove <id>\``);
|
|
2004
|
+
break;
|
|
2005
|
+
}
|
|
2006
|
+
const task = this.daemonApp.taskManager.findTask(rest);
|
|
2007
|
+
if (!task) {
|
|
2008
|
+
emitDelta(`Task not found: \`${rest}\`.`);
|
|
2009
|
+
}
|
|
2010
|
+
else {
|
|
2011
|
+
this.daemonApp.taskManager.removeTask(task.id);
|
|
2012
|
+
emitDelta(`Removed: \`${task.id.slice(0, 8)}\``);
|
|
2013
|
+
}
|
|
2014
|
+
break;
|
|
2015
|
+
}
|
|
2016
|
+
default: {
|
|
2017
|
+
emitDelta(`### Task Commands (Unified Todo System)
|
|
2018
|
+
|
|
2019
|
+
**Manual tasks:** \`/todo add "finish report"\` — add to task list
|
|
2020
|
+
**Auto tasks:** \`/todo auto add "build at 3pm"\` — agent executes it
|
|
2021
|
+
**Notify:** \`/todo notify add "remind about X at 5pm"\` — notification only
|
|
2022
|
+
|
|
2023
|
+
* \`/todo list [personal|project]\` — List tasks
|
|
2024
|
+
* \`/todo complete <id>\` — Mark done
|
|
2025
|
+
* \`/todo cancel <id>\` — Cancel task
|
|
2026
|
+
* \`/todo start <id>\` — Start working on it
|
|
2027
|
+
* \`/todo remove <id>\` — Delete permanently`);
|
|
2028
|
+
break;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
break;
|
|
2032
|
+
}
|
|
2033
|
+
default: {
|
|
2034
|
+
emitDelta(`Unknown slash command: **${text}**. Type \`/help\` to see all available commands.`);
|
|
2035
|
+
break;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
catch (err) {
|
|
2040
|
+
emitDelta(`Error executing command: ${err instanceof Error ? err.message : String(err)}`);
|
|
2041
|
+
}
|
|
2042
|
+
finally {
|
|
2043
|
+
emitStop();
|
|
2044
|
+
}
|
|
2045
|
+
}
|
|
2046
|
+
validatePricingString(cost) {
|
|
2047
|
+
if (!cost)
|
|
2048
|
+
return false;
|
|
2049
|
+
if (!cost.includes('|')) {
|
|
2050
|
+
const [inStr = '', outStr = ''] = cost.split(';');
|
|
2051
|
+
const inC = parseFloat(inStr);
|
|
2052
|
+
const outC = parseFloat(outStr);
|
|
2053
|
+
return !isNaN(inC) && !isNaN(outC) && inC >= 0 && outC >= 0;
|
|
2054
|
+
}
|
|
2055
|
+
const tiers = cost.split('|').map(s => s.trim());
|
|
2056
|
+
const firstPair = (tiers[0] ?? '').split(';');
|
|
2057
|
+
const inStr = firstPair[0] ?? '';
|
|
2058
|
+
const outStr = firstPair[1] ?? '';
|
|
2059
|
+
if (isNaN(parseFloat(inStr)) || isNaN(parseFloat(outStr)))
|
|
2060
|
+
return false;
|
|
2061
|
+
for (let i = 1; i < tiers.length; i++) {
|
|
2062
|
+
const tier = tiers[i];
|
|
2063
|
+
const idx = tier.indexOf('<');
|
|
2064
|
+
if (idx === -1)
|
|
2065
|
+
return false;
|
|
2066
|
+
const threshold = parseInt(tier.substring(0, idx).trim(), 10);
|
|
2067
|
+
const rest = tier.substring(idx + 1).trim();
|
|
2068
|
+
const [inStr2 = '', outStr2 = ''] = rest.split(';');
|
|
2069
|
+
const tierIn = parseFloat(inStr2);
|
|
2070
|
+
const tierOut = parseFloat(outStr2);
|
|
2071
|
+
if (isNaN(threshold) || isNaN(tierIn) || isNaN(tierOut) || threshold < 0 || tierIn < 0 || tierOut < 0)
|
|
2072
|
+
return false;
|
|
2073
|
+
}
|
|
2074
|
+
return true;
|
|
2075
|
+
}
|
|
2076
|
+
async runAutomaticCompaction(sessionId, depth) {
|
|
2077
|
+
if (!this.createProvider) {
|
|
2078
|
+
throw new Error('No provider configured');
|
|
2079
|
+
}
|
|
2080
|
+
const settings = this.settingsManager.get();
|
|
2081
|
+
const provider = this.createProvider(settings);
|
|
2082
|
+
const model = settings.model_override || settings.model;
|
|
2083
|
+
const events = this.sessionStore.loadEvents(sessionId) || [];
|
|
2084
|
+
if (events.length === 0) {
|
|
2085
|
+
throw new Error('No events to compact');
|
|
2086
|
+
}
|
|
2087
|
+
// Build human-readable transcript
|
|
2088
|
+
let transcriptParts = [];
|
|
2089
|
+
for (const e of events) {
|
|
2090
|
+
if (e.type === 'user-prompt' && e.text) {
|
|
2091
|
+
transcriptParts.push(`User: ${e.text}`);
|
|
2092
|
+
}
|
|
2093
|
+
else if (e.type === 'assistant-delta' && e.text) {
|
|
2094
|
+
const lastIdx = transcriptParts.length - 1;
|
|
2095
|
+
if (lastIdx >= 0 && transcriptParts[lastIdx]?.startsWith('Assistant:')) {
|
|
2096
|
+
transcriptParts[lastIdx] += e.text;
|
|
2097
|
+
}
|
|
2098
|
+
else {
|
|
2099
|
+
transcriptParts.push(`Assistant: ${e.text}`);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
const transcript = transcriptParts.join('\n\n');
|
|
2104
|
+
if (!transcript.trim()) {
|
|
2105
|
+
throw new Error('No conversational history found to compact');
|
|
2106
|
+
}
|
|
2107
|
+
const systemPrompt = `You are a conversation summarizer. Summarize the provided dialogue in a dense, detailed, high-fidelity paragraph or two. Focus on capturing the original goals, what was accomplished, any modified files or configurations, current settings, and what the pending next steps are. Ensure all key technical details (like file paths, specific code adjustments, command names) are preserved. Do not add any conversational intros or outros; output ONLY the raw summary text.`;
|
|
2108
|
+
const prompt = `Please summarize this conversation history:\n\n${transcript}`;
|
|
2109
|
+
const summary = await provider.check(prompt, { model, system: systemPrompt });
|
|
2110
|
+
const cleanSummary = summary.trim();
|
|
2111
|
+
// Overwrite events log
|
|
2112
|
+
const eventsPath = this.sessionStore.eventsPath(sessionId);
|
|
2113
|
+
const newEvents = [
|
|
2114
|
+
{
|
|
2115
|
+
type: 'session-start',
|
|
2116
|
+
id: crypto.randomUUID(),
|
|
2117
|
+
model,
|
|
2118
|
+
provider: settings.current_provider || 'unknown',
|
|
2119
|
+
cwd: process.cwd(),
|
|
2120
|
+
timestamp: Date.now(),
|
|
2121
|
+
},
|
|
2122
|
+
{
|
|
2123
|
+
type: 'user-prompt',
|
|
2124
|
+
id: crypto.randomUUID(),
|
|
2125
|
+
text: `This is a continuation of a compacted conversation. Here is the high-fidelity summary of our session so far:\n\n${cleanSummary}\n\nLet's continue!`,
|
|
2126
|
+
cwd: process.cwd(),
|
|
2127
|
+
timestamp: Date.now() + 1,
|
|
2128
|
+
},
|
|
2129
|
+
{
|
|
2130
|
+
type: 'assistant-delta',
|
|
2131
|
+
id: crypto.randomUUID(),
|
|
2132
|
+
text: `Got it! I have fully restored our conversation summary and details. Let let me know what you would like to do next!`,
|
|
2133
|
+
timestamp: Date.now() + 2,
|
|
2134
|
+
},
|
|
2135
|
+
{
|
|
2136
|
+
type: 'assistant-stop',
|
|
2137
|
+
id: crypto.randomUUID(),
|
|
2138
|
+
timestamp: Date.now() + 3,
|
|
2139
|
+
}
|
|
2140
|
+
];
|
|
2141
|
+
const data = newEvents.map((e) => JSON.stringify(e)).join('\n') + '\n';
|
|
2142
|
+
writeFileSync(eventsPath, data, 'utf-8');
|
|
2143
|
+
return cleanSummary;
|
|
2144
|
+
}
|
|
2145
|
+
async checkContextThresholds(sessionId) {
|
|
2146
|
+
const settings = this.settingsManager.get();
|
|
2147
|
+
const history = this.sessionStore.loadEvents(sessionId) || [];
|
|
2148
|
+
const usageEvents = history.filter((e) => e.type === 'usage');
|
|
2149
|
+
let input = 0;
|
|
2150
|
+
let output = 0;
|
|
2151
|
+
for (const e of usageEvents) {
|
|
2152
|
+
input += e.inputTokens || 0;
|
|
2153
|
+
output += e.outputTokens || 0;
|
|
2154
|
+
}
|
|
2155
|
+
if (input === 0)
|
|
2156
|
+
return; // No token data yet
|
|
2157
|
+
const windowSize = settings.providers?.[settings.current_provider]?.model_context_window ?? 200000;
|
|
2158
|
+
const pct = Math.min(100, Math.round((input / windowSize) * 100));
|
|
2159
|
+
const autoCompact = settings.auto_compact || { enabled: 'on', threshold: 80, warn_threshold: 60, forced_threshold: 85 };
|
|
2160
|
+
const warnThresh = autoCompact.warn_threshold ?? 60;
|
|
2161
|
+
const compactThresh = autoCompact.threshold ?? 80;
|
|
2162
|
+
const forcedThresh = autoCompact.forced_threshold ?? 85;
|
|
2163
|
+
const enabled = autoCompact.enabled ?? 'on';
|
|
2164
|
+
if (pct >= forcedThresh && enabled === 'on') {
|
|
2165
|
+
try {
|
|
2166
|
+
const summary = await this.runAutomaticCompaction(sessionId, 'detailed');
|
|
2167
|
+
const successMessage = `⚡ **Auto-Compaction Executed Successfully!**\n\nContext usage was at **${pct}%** (forced threshold: **${forcedThresh}%**).\nWe have summarized the conversation, reducing the history size down to just ~500 tokens. The agent will continue seamlessly!\n\n**Restored Context Summary:**\n\n${summary}`;
|
|
2168
|
+
const warningEvent = {
|
|
2169
|
+
type: 'context-warning',
|
|
2170
|
+
id: crypto.randomUUID(),
|
|
2171
|
+
message: successMessage,
|
|
2172
|
+
timestamp: Date.now(),
|
|
2173
|
+
};
|
|
2174
|
+
this.sharedEventBus?.emit({ ...warningEvent, sessionId });
|
|
2175
|
+
this.sessionStore.appendEvent(sessionId, { ...warningEvent, sessionId });
|
|
2176
|
+
}
|
|
2177
|
+
catch (err) {
|
|
2178
|
+
console.error('[compaction] Auto-compaction failed:', err);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
else if (pct >= compactThresh) {
|
|
2182
|
+
const suggestMessage = `⚠️ **Context Fill High (${pct}%)**\n\nYour context fill is at **${pct}%** (Threshold: **${compactThresh}%**). Suggesting conversation compaction.\n\nType \`/context compact\` to run compaction, summarize history, and free memory immediately!`;
|
|
2183
|
+
const warningEvent = {
|
|
2184
|
+
type: 'context-warning',
|
|
2185
|
+
id: crypto.randomUUID(),
|
|
2186
|
+
message: suggestMessage,
|
|
2187
|
+
timestamp: Date.now(),
|
|
2188
|
+
};
|
|
2189
|
+
this.sharedEventBus?.emit({ ...warningEvent, sessionId });
|
|
2190
|
+
this.sessionStore.appendEvent(sessionId, { ...warningEvent, sessionId });
|
|
2191
|
+
}
|
|
2192
|
+
else if (pct >= warnThresh) {
|
|
2193
|
+
const warnMessage = `⚠️ **Context Warning (${pct}%)**\n\nContext window is **${pct}%** full (Warning threshold: **${warnThresh}%**).`;
|
|
2194
|
+
const warningEvent = {
|
|
2195
|
+
type: 'context-warning',
|
|
2196
|
+
id: crypto.randomUUID(),
|
|
2197
|
+
message: warnMessage,
|
|
2198
|
+
timestamp: Date.now(),
|
|
2199
|
+
};
|
|
2200
|
+
this.sharedEventBus?.emit({ ...warningEvent, sessionId });
|
|
2201
|
+
this.sessionStore.appendEvent(sessionId, { ...warningEvent, sessionId });
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
//# sourceMappingURL=jsonrpc-handler.js.map
|