@stevederico/dotbot 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +136 -0
- package/README.md +380 -0
- package/bin/dotbot.js +461 -0
- package/core/agent.js +779 -0
- package/core/compaction.js +261 -0
- package/core/cron_handler.js +262 -0
- package/core/events.js +229 -0
- package/core/failover.js +193 -0
- package/core/gptoss_tool_parser.js +173 -0
- package/core/init.js +154 -0
- package/core/normalize.js +324 -0
- package/core/trigger_handler.js +148 -0
- package/docs/core.md +103 -0
- package/docs/protected-files.md +59 -0
- package/examples/sqlite-session-example.js +69 -0
- package/index.js +341 -0
- package/observer/index.js +164 -0
- package/package.json +42 -0
- package/storage/CronStore.js +145 -0
- package/storage/EventStore.js +71 -0
- package/storage/MemoryStore.js +175 -0
- package/storage/MongoAdapter.js +291 -0
- package/storage/MongoCronAdapter.js +347 -0
- package/storage/MongoTaskAdapter.js +242 -0
- package/storage/MongoTriggerAdapter.js +158 -0
- package/storage/SQLiteAdapter.js +382 -0
- package/storage/SQLiteCronAdapter.js +562 -0
- package/storage/SQLiteEventStore.js +300 -0
- package/storage/SQLiteMemoryAdapter.js +240 -0
- package/storage/SQLiteTaskAdapter.js +419 -0
- package/storage/SQLiteTriggerAdapter.js +262 -0
- package/storage/SessionStore.js +149 -0
- package/storage/TaskStore.js +100 -0
- package/storage/TriggerStore.js +90 -0
- package/storage/cron_constants.js +48 -0
- package/storage/index.js +21 -0
- package/tools/appgen.js +311 -0
- package/tools/browser.js +634 -0
- package/tools/code.js +101 -0
- package/tools/events.js +145 -0
- package/tools/files.js +201 -0
- package/tools/images.js +253 -0
- package/tools/index.js +97 -0
- package/tools/jobs.js +159 -0
- package/tools/memory.js +332 -0
- package/tools/messages.js +135 -0
- package/tools/notify.js +42 -0
- package/tools/tasks.js +404 -0
- package/tools/triggers.js +159 -0
- package/tools/weather.js +82 -0
- package/tools/web.js +283 -0
- package/utils/providers.js +136 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// core/compaction.js
|
|
2
|
+
// Session auto-compaction: summarizes old messages when context nears provider limits.
|
|
3
|
+
// Runs before trimMessages as an intelligent alternative to hard truncation.
|
|
4
|
+
// Operates on standard-format messages (see normalize.js).
|
|
5
|
+
|
|
6
|
+
import { AI_PROVIDERS } from "../utils/providers.js";
|
|
7
|
+
import { toStandardFormat, toProviderFormat } from "./normalize.js";
|
|
8
|
+
|
|
9
|
+
/** Max context window tokens per provider family. */
|
|
10
|
+
const CONTEXT_LIMITS = {
|
|
11
|
+
anthropic: 180000,
|
|
12
|
+
openai: 120000,
|
|
13
|
+
xai: 120000,
|
|
14
|
+
ollama: 6000,
|
|
15
|
+
dottie_desktop: 6000,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Number of recent messages to always preserve verbatim. */
|
|
19
|
+
const RECENT_TO_KEEP = 20;
|
|
20
|
+
|
|
21
|
+
/** Compaction triggers when estimated tokens exceed this fraction of the context limit. */
|
|
22
|
+
const COMPACT_THRESHOLD = 0.7;
|
|
23
|
+
|
|
24
|
+
/** Cheapest model per provider, used for summarization calls. */
|
|
25
|
+
const CHEAP_MODELS = {
|
|
26
|
+
xai: "grok-4-1-fast-non-reasoning",
|
|
27
|
+
anthropic: "claude-3-5-haiku-20241022",
|
|
28
|
+
openai: "gpt-5-nano",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Estimate token count for an array of standard-format messages.
|
|
33
|
+
*
|
|
34
|
+
* Uses a 4-chars-per-token heuristic. Counts content, toolCalls
|
|
35
|
+
* (name + input + result), and thinking fields.
|
|
36
|
+
*
|
|
37
|
+
* @param {Array} messages - Conversation messages in standard format.
|
|
38
|
+
* @returns {number} Estimated token count.
|
|
39
|
+
*/
|
|
40
|
+
export function estimateTokens(messages) {
|
|
41
|
+
let chars = 0;
|
|
42
|
+
|
|
43
|
+
for (const msg of messages) {
|
|
44
|
+
if (typeof msg.content === "string") {
|
|
45
|
+
chars += msg.content.length;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (msg.toolCalls) {
|
|
49
|
+
for (const tc of msg.toolCalls) {
|
|
50
|
+
chars += (tc.name || "").length;
|
|
51
|
+
chars += JSON.stringify(tc.input || {}).length;
|
|
52
|
+
if (tc.result) {
|
|
53
|
+
chars += (typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result)).length;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (msg.thinking) {
|
|
59
|
+
chars += msg.thinking.length;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return Math.ceil(chars / 4);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Naive fallback summary when no AI provider is available.
|
|
68
|
+
*
|
|
69
|
+
* Extracts the first 100 characters of each user message, tool call names,
|
|
70
|
+
* and tool results, producing a bullet-point summary.
|
|
71
|
+
*
|
|
72
|
+
* @param {Array} messages - Standard-format messages to summarize.
|
|
73
|
+
* @returns {string} Plain-text summary.
|
|
74
|
+
*/
|
|
75
|
+
function naiveSummary(messages) {
|
|
76
|
+
const lines = [];
|
|
77
|
+
|
|
78
|
+
for (const msg of messages) {
|
|
79
|
+
if (msg.role === "user" && msg.content) {
|
|
80
|
+
lines.push(`- User: ${msg.content.slice(0, 100)}`);
|
|
81
|
+
} else if (msg.role === "assistant") {
|
|
82
|
+
if (msg.toolCalls) {
|
|
83
|
+
for (const tc of msg.toolCalls) {
|
|
84
|
+
lines.push(`- Tool: ${tc.name}`);
|
|
85
|
+
if (tc.result) {
|
|
86
|
+
const resultStr = typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result);
|
|
87
|
+
lines.push(`- Result: ${resultStr.slice(0, 100)}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return lines.length > 0
|
|
95
|
+
? lines.join("\n")
|
|
96
|
+
: "Previous conversation context (details unavailable).";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Summarize old messages using the cheapest available AI provider.
|
|
101
|
+
*
|
|
102
|
+
* Checks provider keys in order: xAI, Anthropic, OpenAI.
|
|
103
|
+
* Falls back to naiveSummary() if no provider is configured.
|
|
104
|
+
* Messages are expected in standard format; converted to provider format for the API call.
|
|
105
|
+
*
|
|
106
|
+
* @param {Array} oldMessages - Standard-format messages to summarize.
|
|
107
|
+
* @param {Object} [options={}]
|
|
108
|
+
* @param {AbortSignal} [options.signal] - Optional abort signal.
|
|
109
|
+
* @param {Object} [options.providers={}] - Provider config with API keys.
|
|
110
|
+
* @returns {Promise<string>} Summary text.
|
|
111
|
+
*/
|
|
112
|
+
async function summarizeMessages(oldMessages, { signal, providers = {} } = {}) {
|
|
113
|
+
// Build plain-text transcript from standard-format messages
|
|
114
|
+
const transcript = oldMessages
|
|
115
|
+
.map((msg) => {
|
|
116
|
+
const role = msg.role || "unknown";
|
|
117
|
+
const parts = [];
|
|
118
|
+
|
|
119
|
+
if (msg.content) {
|
|
120
|
+
parts.push(msg.content);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (msg.toolCalls) {
|
|
124
|
+
for (const tc of msg.toolCalls) {
|
|
125
|
+
parts.push(`[tool: ${tc.name}]`);
|
|
126
|
+
if (tc.result) {
|
|
127
|
+
const resultStr = typeof tc.result === "string" ? tc.result : JSON.stringify(tc.result);
|
|
128
|
+
parts.push(`[result: ${resultStr.slice(0, 200)}]`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return `${role}: ${parts.join(" ")}`;
|
|
134
|
+
})
|
|
135
|
+
.join("\n");
|
|
136
|
+
|
|
137
|
+
// Find cheapest available provider
|
|
138
|
+
const providerOrder = ["xai", "anthropic", "openai"];
|
|
139
|
+
|
|
140
|
+
let selectedProvider = null;
|
|
141
|
+
let selectedModel = null;
|
|
142
|
+
let apiKey = null;
|
|
143
|
+
for (const id of providerOrder) {
|
|
144
|
+
if (providers[id]?.apiKey) {
|
|
145
|
+
selectedProvider = AI_PROVIDERS[id];
|
|
146
|
+
selectedModel = CHEAP_MODELS[id];
|
|
147
|
+
apiKey = providers[id].apiKey;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (!selectedProvider || !apiKey) {
|
|
153
|
+
return naiveSummary(oldMessages);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const summaryPrompt =
|
|
157
|
+
"Summarize this conversation concisely. Preserve: key decisions, user facts, tool results, and any important outcomes. Omit: greetings, thinking steps, redundant tool calls. Output only the summary, no preamble.";
|
|
158
|
+
|
|
159
|
+
// Build summarization request in standard format, then convert for the provider
|
|
160
|
+
const summaryMessages = [
|
|
161
|
+
{ role: "system", content: summaryPrompt },
|
|
162
|
+
{ role: "user", content: transcript },
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
const targetFormat = selectedProvider.id === "anthropic" ? "anthropic" : "openai";
|
|
166
|
+
const providerMessages = toProviderFormat(summaryMessages, targetFormat);
|
|
167
|
+
|
|
168
|
+
// Anthropic doesn't support system role in messages — use top-level system param
|
|
169
|
+
let requestBody;
|
|
170
|
+
if (targetFormat === "anthropic") {
|
|
171
|
+
requestBody = {
|
|
172
|
+
model: selectedModel,
|
|
173
|
+
max_tokens: 1024,
|
|
174
|
+
system: summaryPrompt,
|
|
175
|
+
messages: providerMessages.filter((m) => m.role !== "system"),
|
|
176
|
+
};
|
|
177
|
+
} else {
|
|
178
|
+
requestBody = {
|
|
179
|
+
model: selectedModel,
|
|
180
|
+
max_tokens: 1024,
|
|
181
|
+
messages: providerMessages,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const url = `${selectedProvider.apiUrl}${selectedProvider.endpoint}`;
|
|
187
|
+
const headers = selectedProvider.headers(apiKey);
|
|
188
|
+
|
|
189
|
+
const res = await fetch(url, {
|
|
190
|
+
method: "POST",
|
|
191
|
+
headers,
|
|
192
|
+
body: JSON.stringify(requestBody),
|
|
193
|
+
signal,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
if (!res.ok) {
|
|
197
|
+
console.error(`[compaction] summarization failed (${res.status}), using naive fallback`);
|
|
198
|
+
return naiveSummary(oldMessages);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const data = await res.json();
|
|
202
|
+
const text = selectedProvider.formatResponse(data);
|
|
203
|
+
return text || naiveSummary(oldMessages);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
if (err.name === "AbortError") throw err;
|
|
206
|
+
console.error("[compaction] summarization error, using naive fallback:", err.message);
|
|
207
|
+
return naiveSummary(oldMessages);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Compact a conversation's messages if approaching the provider's context limit.
|
|
213
|
+
*
|
|
214
|
+
* Splits messages into [system prompt] + [old messages] + [recent N messages],
|
|
215
|
+
* summarizes old messages via the cheapest AI provider, and returns a compacted
|
|
216
|
+
* array. If under the threshold, returns the original messages unchanged.
|
|
217
|
+
*
|
|
218
|
+
* @param {Array} messages - Full conversation history including system prompt.
|
|
219
|
+
* @param {Object} [options={}]
|
|
220
|
+
* @param {string} [options.providerId='ollama'] - Provider ID for context limit lookup.
|
|
221
|
+
* @param {AbortSignal} [options.signal] - Optional abort signal.
|
|
222
|
+
* @param {Object} [options.providers] - Provider configuration with API keys: { anthropic: { apiKey }, openai: { apiKey }, xai: { apiKey } }
|
|
223
|
+
* @returns {Promise<{messages: Array, compacted: boolean}>} Compacted result.
|
|
224
|
+
*/
|
|
225
|
+
export async function compactMessages(messages, { providerId = "ollama", signal, providers = {} } = {}) {
|
|
226
|
+
const tokens = estimateTokens(messages);
|
|
227
|
+
const limit = CONTEXT_LIMITS[providerId] || CONTEXT_LIMITS.ollama;
|
|
228
|
+
|
|
229
|
+
if (tokens < limit * COMPACT_THRESHOLD) {
|
|
230
|
+
return { messages, compacted: false };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Need at least system + RECENT_TO_KEEP + 1 old message to compact
|
|
234
|
+
if (messages.length <= RECENT_TO_KEEP + 2) {
|
|
235
|
+
return { messages, compacted: false };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const systemPrompt = messages[0];
|
|
239
|
+
const recent = messages.slice(-RECENT_TO_KEEP);
|
|
240
|
+
const old = messages.slice(1, messages.length - RECENT_TO_KEEP);
|
|
241
|
+
|
|
242
|
+
if (old.length === 0) {
|
|
243
|
+
return { messages, compacted: false };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
console.log(`[compaction] ${tokens} tokens (~${Math.round((tokens / limit) * 100)}% of ${providerId} limit), compacting ${old.length} old messages`);
|
|
247
|
+
|
|
248
|
+
const summary = await summarizeMessages(old, { signal, providers });
|
|
249
|
+
|
|
250
|
+
const summaryMessage = {
|
|
251
|
+
role: "user",
|
|
252
|
+
content: `[Context Summary]\n${summary}`,
|
|
253
|
+
_compaction: true,
|
|
254
|
+
_ts: Date.now(),
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
messages: [systemPrompt, summaryMessage, ...recent],
|
|
259
|
+
compacted: true,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cron task handler for dotbot.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from dottie-os server.js to provide a reusable cron task executor
|
|
5
|
+
* that handles session resolution, stale user gates, task injection, and
|
|
6
|
+
* notification hooks.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { compactMessages } from './compaction.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a cron task handler function.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} options
|
|
15
|
+
* @param {Object} options.sessionStore - Session store instance
|
|
16
|
+
* @param {Object} options.cronStore - Cron store instance
|
|
17
|
+
* @param {Object} options.taskStore - Task store instance (optional)
|
|
18
|
+
* @param {Object} options.memoryStore - Memory store instance (optional)
|
|
19
|
+
* @param {Object} options.providers - Provider API keys for compaction
|
|
20
|
+
* @param {number} [options.staleThresholdMs=86400000] - Skip heartbeat if user idle longer than this (default: 24h)
|
|
21
|
+
* @param {Object} [options.hooks] - Host-specific hooks
|
|
22
|
+
* @param {Function} [options.hooks.onNotification] - async (userId, { title, body, type }) => void
|
|
23
|
+
* @param {Function} [options.hooks.taskFetcher] - async (userId, taskId) => task object
|
|
24
|
+
* @param {Function} [options.hooks.tasksFinder] - async (userId, filter) => tasks array
|
|
25
|
+
* @returns {Function} Async handler for cron task execution
|
|
26
|
+
*/
|
|
27
|
+
export function createCronHandler({
|
|
28
|
+
sessionStore,
|
|
29
|
+
cronStore,
|
|
30
|
+
taskStore,
|
|
31
|
+
memoryStore,
|
|
32
|
+
providers = {},
|
|
33
|
+
staleThresholdMs = 24 * 60 * 60 * 1000,
|
|
34
|
+
hooks = {},
|
|
35
|
+
}) {
|
|
36
|
+
// Agent reference - will be set after init() creates the agent
|
|
37
|
+
let agent = null;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Set the agent instance (called by init() after agent creation).
|
|
41
|
+
* @param {Object} agentInstance
|
|
42
|
+
*/
|
|
43
|
+
function setAgent(agentInstance) {
|
|
44
|
+
agent = agentInstance;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Handle a cron task firing.
|
|
49
|
+
*
|
|
50
|
+
* @param {Object} task - Cron task object
|
|
51
|
+
* @param {string} task.name - Task name (e.g., "heartbeat", "task_step")
|
|
52
|
+
* @param {string} [task.userId] - User ID for user-level tasks
|
|
53
|
+
* @param {string} [task.sessionId] - Session ID for session-scoped tasks
|
|
54
|
+
* @param {string} [task.taskId] - Task ID for task_step cron jobs
|
|
55
|
+
* @param {string} [task.prompt] - Task prompt
|
|
56
|
+
*/
|
|
57
|
+
async function handleTaskFire(task) {
|
|
58
|
+
console.log(`[cron] processing task ${task.name} (userId=${task.userId || 'none'}, sessionId=${task.sessionId || 'none'})`);
|
|
59
|
+
|
|
60
|
+
// Skip if agent not initialized yet
|
|
61
|
+
if (!agent) {
|
|
62
|
+
console.warn('[cron] task skipped - agent not initialized');
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Resolve session: user-level heartbeats find the most recent session,
|
|
67
|
+
// session-scoped tasks use the explicit sessionId.
|
|
68
|
+
let session;
|
|
69
|
+
if (task.userId) {
|
|
70
|
+
session = await sessionStore.getOrCreateDefaultSession(task.userId);
|
|
71
|
+
} else if (task.sessionId) {
|
|
72
|
+
session = await sessionStore.getSessionInternal(task.sessionId);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!session) {
|
|
76
|
+
console.log(`[cron] no session found for task ${task.name}, skipping`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Stale user check: skip heartbeat if user hasn't interacted recently
|
|
81
|
+
if (task.name === 'heartbeat' && session.updatedAt) {
|
|
82
|
+
const idleMs = Date.now() - new Date(session.updatedAt).getTime();
|
|
83
|
+
if (idleMs > staleThresholdMs) {
|
|
84
|
+
console.log(`[cron] skipping heartbeat for stale user ${session.owner} (idle ${Math.round(idleMs / 3600000)}h)`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Build task content depending on task type
|
|
90
|
+
const taskContent = await buildTaskContent(task, session);
|
|
91
|
+
if (!taskContent) return;
|
|
92
|
+
|
|
93
|
+
// Add message to session
|
|
94
|
+
await sessionStore.addMessage(session.id, {
|
|
95
|
+
role: 'user',
|
|
96
|
+
content: taskContent,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Re-fetch session to pick up the added message
|
|
100
|
+
const updatedSession = await sessionStore.getSessionInternal(session.id);
|
|
101
|
+
const providerId = updatedSession.provider || 'ollama';
|
|
102
|
+
|
|
103
|
+
// Compact old messages before running agent loop
|
|
104
|
+
const compacted = await compactMessages(updatedSession.messages, {
|
|
105
|
+
providerId,
|
|
106
|
+
providers,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
if (compacted.compacted) {
|
|
110
|
+
updatedSession.messages = compacted.messages;
|
|
111
|
+
updatedSession.messages.push({
|
|
112
|
+
role: 'user',
|
|
113
|
+
content: '[Compaction] Compressed conversation history',
|
|
114
|
+
_ts: Date.now(),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Run the agent chat loop
|
|
119
|
+
let finalText = '';
|
|
120
|
+
for await (const event of agent.chat({
|
|
121
|
+
sessionId: updatedSession.id,
|
|
122
|
+
message: '', // Message already added to session
|
|
123
|
+
provider: providerId,
|
|
124
|
+
model: updatedSession.model,
|
|
125
|
+
context: {
|
|
126
|
+
userID: updatedSession.owner,
|
|
127
|
+
sessionId: updatedSession.id,
|
|
128
|
+
memoryStore,
|
|
129
|
+
taskStore,
|
|
130
|
+
},
|
|
131
|
+
})) {
|
|
132
|
+
if (event.type === 'text_delta' && event.text) {
|
|
133
|
+
finalText += event.text;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Create notification if the agent produced meaningful output
|
|
138
|
+
const trimmed = finalText.trim();
|
|
139
|
+
if (trimmed && trimmed.length > 10 && updatedSession.owner && hooks.onNotification) {
|
|
140
|
+
try {
|
|
141
|
+
await hooks.onNotification(updatedSession.owner, {
|
|
142
|
+
title: 'Dottie',
|
|
143
|
+
body: trimmed.slice(0, 500),
|
|
144
|
+
type: task.name === 'heartbeat' ? 'heartbeat' : 'cron',
|
|
145
|
+
});
|
|
146
|
+
console.log(`[cron] notification created for ${updatedSession.owner} (${trimmed.length} chars)`);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
console.error('[cron] failed to create notification:', err.message);
|
|
149
|
+
}
|
|
150
|
+
} else if (task.name === 'heartbeat') {
|
|
151
|
+
console.log(`[cron] heartbeat for ${updatedSession.owner} produced no meaningful output (${finalText.trim().length} chars)`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Build the content for a cron task based on its type.
|
|
157
|
+
*
|
|
158
|
+
* @param {Object} task - Cron task
|
|
159
|
+
* @param {Object} session - User session
|
|
160
|
+
* @returns {Promise<string|null>} Task content or null to skip
|
|
161
|
+
*/
|
|
162
|
+
async function buildTaskContent(task, session) {
|
|
163
|
+
if (task.name === 'task_step' && task.taskId) {
|
|
164
|
+
// Task step continuation — inject targeted prompt for the specific task
|
|
165
|
+
return await buildTaskStepContent(task, session);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (task.name === 'heartbeat' && session.owner) {
|
|
169
|
+
// Heartbeat — check for auto-mode tasks with pending steps
|
|
170
|
+
return await buildHeartbeatContent(task, session);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Default: use the task prompt
|
|
174
|
+
return `[Heartbeat] ${task.prompt}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Build content for a task_step cron job.
|
|
179
|
+
*/
|
|
180
|
+
async function buildTaskStepContent(task, session) {
|
|
181
|
+
try {
|
|
182
|
+
let taskDoc;
|
|
183
|
+
if (hooks.taskFetcher) {
|
|
184
|
+
taskDoc = await hooks.taskFetcher(session.owner, task.taskId);
|
|
185
|
+
} else if (taskStore) {
|
|
186
|
+
taskDoc = await taskStore.getTask(session.owner, task.taskId);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (!taskDoc) {
|
|
190
|
+
console.log(`[cron] task_step: task ${task.taskId} not found, skipping`);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (taskDoc.status === 'completed' || taskDoc.status === 'abandoned') {
|
|
195
|
+
console.log(`[cron] task_step: task ${task.taskId} already ${taskDoc.status}, skipping`);
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const nextStep = taskDoc.steps?.find(s => !s.done);
|
|
200
|
+
if (!nextStep) {
|
|
201
|
+
console.log(`[cron] task_step: all steps done for task ${task.taskId}, skipping`);
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const doneCount = taskDoc.steps.filter(s => s.done).length;
|
|
206
|
+
return `[Task Work] Continue auto-executing task "${taskDoc.description}" (${doneCount}/${taskDoc.steps.length} steps done). Call task_work with task_id "${task.taskId}" to execute the next step.`;
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.error('[cron] task_step error:', err.message);
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Build content for a heartbeat cron job.
|
|
215
|
+
*/
|
|
216
|
+
async function buildHeartbeatContent(task, session) {
|
|
217
|
+
let taskContent = `[Heartbeat] ${task.prompt}`;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
let tasks = [];
|
|
221
|
+
if (hooks.tasksFinder) {
|
|
222
|
+
tasks = await hooks.tasksFinder(session.owner, { status: ['pending', 'in_progress'] });
|
|
223
|
+
} else if (taskStore) {
|
|
224
|
+
tasks = await taskStore.findTasks(session.owner, { status: ['pending', 'in_progress'] });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (tasks.length > 0) {
|
|
228
|
+
// Check if any task is in auto mode with pending steps
|
|
229
|
+
const autoTask = tasks.find(t => t.mode === 'auto' && t.steps?.some(s => !s.done));
|
|
230
|
+
if (autoTask) {
|
|
231
|
+
const doneCount = autoTask.steps.filter(s => s.done).length;
|
|
232
|
+
const nextStep = autoTask.steps.find(s => !s.done);
|
|
233
|
+
taskContent = `[Heartbeat] Auto-mode task "${autoTask.description}" has pending steps (${doneCount}/${autoTask.steps.length} done). Call task_work with task_id "${autoTask._id || autoTask.id}" to execute: "${nextStep.text}"`;
|
|
234
|
+
} else {
|
|
235
|
+
// List all active tasks
|
|
236
|
+
const lines = tasks.map(t => {
|
|
237
|
+
let line = `• [${t.priority}] ${t.description}`;
|
|
238
|
+
if (t.mode) line += ` [${t.mode}]`;
|
|
239
|
+
if (t.deadline) line += ` (due: ${t.deadline})`;
|
|
240
|
+
if (t.steps && t.steps.length > 0) {
|
|
241
|
+
const done = t.steps.filter(s => s.done).length;
|
|
242
|
+
line += ` (${done}/${t.steps.length} steps)`;
|
|
243
|
+
for (const step of t.steps) {
|
|
244
|
+
line += `\n ${step.done ? '[x]' : '[ ]'} ${step.text}`;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return line;
|
|
248
|
+
});
|
|
249
|
+
taskContent += `\n\nActive tasks:\n${lines.join('\n')}`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.error('[cron] failed to fetch tasks for heartbeat:', err.message);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return taskContent;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Return handler with setAgent method attached
|
|
260
|
+
handleTaskFire.setAgent = setAgent;
|
|
261
|
+
return handleTaskFire;
|
|
262
|
+
}
|