@inetafrica/open-claudia 2.0.2 → 2.0.4
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/bin/agent.js +60 -0
- package/bin/cli.js +46 -0
- package/bin/loopback-client.js +99 -0
- package/bin/schedule.js +118 -0
- package/bin/task.js +93 -0
- package/bin/transcript-window.js +202 -0
- package/bot.js +3 -2
- package/core/actions.js +18 -7
- package/core/adapter-registry.js +1 -1
- package/core/config.js +6 -2
- package/core/handlers.js +22 -13
- package/core/jobs.js +99 -0
- package/core/loopback.js +153 -27
- package/core/runner.js +45 -21
- package/core/scheduler.js +249 -0
- package/core/subagent.js +109 -0
- package/core/system-prompt.js +21 -0
- package/core/tasks.js +92 -0
- package/package.json +1 -1
- package/core/cron.js +0 -77
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Unified scheduler — owns every timer the bot has running. Replaces
|
|
2
|
+
// the old core/cron.js. Loads jobs.json on boot, schedules wakeups
|
|
3
|
+
// (setTimeout) and crons (node-cron), and fires each one in the right
|
|
4
|
+
// chat context with the right session pinned.
|
|
5
|
+
//
|
|
6
|
+
// When a wakeup fires the agent wakes up inside the SAME Claude session
|
|
7
|
+
// it was in when it scheduled the wakeup (per design choice a/i). If
|
|
8
|
+
// the channel is busy with a live turn the fire is deferred 30s and
|
|
9
|
+
// retried. One-shot wakeups self-delete; crons persist.
|
|
10
|
+
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const fs = require("fs");
|
|
13
|
+
const cron = require("node-cron");
|
|
14
|
+
|
|
15
|
+
const { CRONS_FILE, WORKSPACE, CHAT_ID } = require("./config");
|
|
16
|
+
const jobs = require("./jobs");
|
|
17
|
+
const { runInChat } = require("./context");
|
|
18
|
+
const { getUserState, userStates } = require("./state");
|
|
19
|
+
const { canonicalForTelegram, canonicalForChannel } = require("./identity");
|
|
20
|
+
const { send } = require("./io");
|
|
21
|
+
|
|
22
|
+
const MISSED_WAKEUP_GRACE_MS = 60 * 60 * 1000;
|
|
23
|
+
const DEFER_BUSY_MS = 30 * 1000;
|
|
24
|
+
|
|
25
|
+
const timers = new Map();
|
|
26
|
+
let adapters = [];
|
|
27
|
+
|
|
28
|
+
function setAdapters(list) {
|
|
29
|
+
adapters = Array.isArray(list) ? list.slice() : [];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveAdapter(job) {
|
|
33
|
+
if (job.adapter) {
|
|
34
|
+
const byId = adapters.find((a) => a.id === job.adapter);
|
|
35
|
+
if (byId) return byId;
|
|
36
|
+
}
|
|
37
|
+
if (job.adapterType) {
|
|
38
|
+
const byType = adapters.find((a) => a.type === job.adapterType);
|
|
39
|
+
if (byType) return byType;
|
|
40
|
+
}
|
|
41
|
+
return adapters[0] || null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function projectDir(job) {
|
|
45
|
+
if (!job.project) return null;
|
|
46
|
+
if (path.isAbsolute(job.project)) return job.project;
|
|
47
|
+
if (!WORKSPACE) return null;
|
|
48
|
+
return path.join(WORKSPACE, job.project);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function deriveCanonicalUserId(adapter, job) {
|
|
52
|
+
if (job.canonicalUserId) return job.canonicalUserId;
|
|
53
|
+
if (!adapter) return null;
|
|
54
|
+
return canonicalForChannel(adapter.type, job.channelId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function deriveUserId(adapter, job) {
|
|
58
|
+
if (job.userId) return String(job.userId);
|
|
59
|
+
if (adapter && adapter.type === "telegram") return String(job.channelId);
|
|
60
|
+
return String(job.canonicalUserId || job.channelId || "");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function fireJob(job, retry = 0) {
|
|
64
|
+
const adapter = resolveAdapter(job);
|
|
65
|
+
if (!adapter) {
|
|
66
|
+
console.error(`scheduler: no adapter for job ${job.id} (${job.label || job.kind})`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const canonicalUserId = deriveCanonicalUserId(adapter, job);
|
|
70
|
+
const userId = deriveUserId(adapter, job);
|
|
71
|
+
|
|
72
|
+
const ctx = {
|
|
73
|
+
adapter,
|
|
74
|
+
channelId: String(job.channelId),
|
|
75
|
+
canonicalUserId,
|
|
76
|
+
userId,
|
|
77
|
+
transport: adapter.type,
|
|
78
|
+
raw: null,
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
await runInChat(ctx, async () => {
|
|
82
|
+
const state = getUserState(canonicalUserId);
|
|
83
|
+
if (state.runningProcess) {
|
|
84
|
+
if (retry < 4) {
|
|
85
|
+
console.log(`scheduler: ${job.id} busy, deferring ${DEFER_BUSY_MS / 1000}s (retry ${retry + 1})`);
|
|
86
|
+
setTimeout(() => fireJob(job, retry + 1), DEFER_BUSY_MS);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.warn(`scheduler: ${job.id} still busy after retries, skipping`);
|
|
90
|
+
jobs.update(job.id, { lastFireAt: Date.now(), lastFireOk: false });
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const cwd = projectDir(job) || (state.currentSession && state.currentSession.dir) || WORKSPACE || process.cwd();
|
|
95
|
+
const label = job.label || (job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup");
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
await send(`Wakeup: ${label}`);
|
|
99
|
+
} catch (e) { /* non-fatal */ }
|
|
100
|
+
|
|
101
|
+
const wrappedPrompt = job.kind === "cron"
|
|
102
|
+
? `[Scheduled cron \"${label}\" fired at ${new Date().toISOString()}]\n${job.prompt}`
|
|
103
|
+
: `[Scheduled wakeup \"${label}\" fired at ${new Date().toISOString()}]\n${job.prompt}`;
|
|
104
|
+
|
|
105
|
+
const { runClaude } = require("./runner");
|
|
106
|
+
const runOpts = {};
|
|
107
|
+
if (job.sessionId) runOpts.resumeSessionId = job.sessionId;
|
|
108
|
+
try {
|
|
109
|
+
await runClaude(wrappedPrompt, cwd, null, runOpts);
|
|
110
|
+
jobs.update(job.id, { lastFireAt: Date.now(), lastFireOk: true });
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error(`scheduler: ${job.id} run error:`, e.message);
|
|
113
|
+
jobs.update(job.id, { lastFireAt: Date.now(), lastFireOk: false });
|
|
114
|
+
try { await send(`Wakeup "${label}" failed: ${e.message}`); } catch (_) {}
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (job.kind === "wakeup") {
|
|
119
|
+
jobs.remove(job.id);
|
|
120
|
+
timers.delete(job.id);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function scheduleJob(job) {
|
|
125
|
+
if (timers.has(job.id)) {
|
|
126
|
+
const prev = timers.get(job.id);
|
|
127
|
+
if (prev.stop) prev.stop();
|
|
128
|
+
if (prev.timeout) clearTimeout(prev.timeout);
|
|
129
|
+
timers.delete(job.id);
|
|
130
|
+
}
|
|
131
|
+
if (job.kind === "wakeup") {
|
|
132
|
+
const delay = Math.max(0, (job.fireAt || 0) - Date.now());
|
|
133
|
+
const timeout = setTimeout(() => fireJob(job), delay);
|
|
134
|
+
timers.set(job.id, { timeout });
|
|
135
|
+
} else if (job.kind === "cron") {
|
|
136
|
+
if (!cron.validate(job.schedule)) {
|
|
137
|
+
console.error(`scheduler: invalid cron schedule for ${job.id}: ${job.schedule}`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const task = cron.schedule(job.schedule, () => fireJob(job));
|
|
141
|
+
timers.set(job.id, { stop: () => task.stop() });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function unscheduleJob(id) {
|
|
146
|
+
const t = timers.get(id);
|
|
147
|
+
if (!t) return;
|
|
148
|
+
if (t.stop) t.stop();
|
|
149
|
+
if (t.timeout) clearTimeout(t.timeout);
|
|
150
|
+
timers.delete(id);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function migrateLegacyCrons() {
|
|
154
|
+
try {
|
|
155
|
+
if (!CRONS_FILE || !fs.existsSync(CRONS_FILE)) return 0;
|
|
156
|
+
const raw = JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8"));
|
|
157
|
+
if (!Array.isArray(raw) || raw.length === 0) return 0;
|
|
158
|
+
const existing = new Set(jobs.listAll().map((j) => j.id));
|
|
159
|
+
const tg = adapters.find((a) => a.type === "telegram");
|
|
160
|
+
const adapterId = tg ? tg.id : (adapters[0] && adapters[0].id);
|
|
161
|
+
const adapterType = tg ? "telegram" : (adapters[0] && adapters[0].type);
|
|
162
|
+
const channelId = tg ? (CHAT_ID || "") : "";
|
|
163
|
+
const canonicalUserId = adapterType === "telegram"
|
|
164
|
+
? canonicalForTelegram(CHAT_ID || "")
|
|
165
|
+
: canonicalForChannel(adapterType || "", channelId);
|
|
166
|
+
let migrated = 0;
|
|
167
|
+
for (const c of raw) {
|
|
168
|
+
if (!c || !c.schedule || !c.prompt) continue;
|
|
169
|
+
if (c.id && existing.has(c.id)) continue;
|
|
170
|
+
jobs.add({
|
|
171
|
+
id: c.id || jobs.nextId("cron"),
|
|
172
|
+
kind: "cron",
|
|
173
|
+
adapter: adapterId,
|
|
174
|
+
adapterType,
|
|
175
|
+
channelId: String(channelId),
|
|
176
|
+
canonicalUserId,
|
|
177
|
+
project: c.project || null,
|
|
178
|
+
prompt: c.prompt,
|
|
179
|
+
label: c.label || (c.prompt || "").slice(0, 50),
|
|
180
|
+
source: "user",
|
|
181
|
+
schedule: c.schedule,
|
|
182
|
+
sessionKey: null,
|
|
183
|
+
sessionId: null,
|
|
184
|
+
});
|
|
185
|
+
migrated++;
|
|
186
|
+
}
|
|
187
|
+
if (migrated > 0) {
|
|
188
|
+
const backup = `${CRONS_FILE}.migrated`;
|
|
189
|
+
try { fs.renameSync(CRONS_FILE, backup); } catch (e) {}
|
|
190
|
+
console.log(`scheduler: migrated ${migrated} legacy cron(s) to jobs.json (backup: ${backup})`);
|
|
191
|
+
}
|
|
192
|
+
return migrated;
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.error("scheduler: legacy migration failed:", e.message);
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function fireMissedWakeupsOnce() {
|
|
200
|
+
const now = Date.now();
|
|
201
|
+
for (const job of jobs.listAll()) {
|
|
202
|
+
if (job.kind !== "wakeup") continue;
|
|
203
|
+
if (!job.fireAt) continue;
|
|
204
|
+
if (job.fireAt >= now) continue;
|
|
205
|
+
if (now - job.fireAt > MISSED_WAKEUP_GRACE_MS) {
|
|
206
|
+
jobs.remove(job.id);
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
setTimeout(() => fireJob({ ...job, label: `${job.label || "wakeup"} (missed)` }), 1000);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function initScheduler(adapterList) {
|
|
214
|
+
setAdapters(adapterList);
|
|
215
|
+
migrateLegacyCrons();
|
|
216
|
+
for (const job of jobs.listAll()) {
|
|
217
|
+
try { scheduleJob(job); }
|
|
218
|
+
catch (e) { console.error(`scheduler: failed to schedule ${job.id}:`, e.message); }
|
|
219
|
+
}
|
|
220
|
+
fireMissedWakeupsOnce();
|
|
221
|
+
console.log(`scheduler: loaded ${jobs.listAll().length} job(s)`);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function addJob(spec) {
|
|
225
|
+
const job = jobs.add(spec);
|
|
226
|
+
try { scheduleJob(job); }
|
|
227
|
+
catch (e) { console.error(`scheduler: failed to schedule new ${job.id}:`, e.message); }
|
|
228
|
+
return job;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function removeJob(id) {
|
|
232
|
+
unscheduleJob(id);
|
|
233
|
+
return jobs.remove(id);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function stopAll() {
|
|
237
|
+
for (const [id] of timers) unscheduleJob(id);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
module.exports = {
|
|
241
|
+
setAdapters,
|
|
242
|
+
initScheduler,
|
|
243
|
+
scheduleJob,
|
|
244
|
+
unscheduleJob,
|
|
245
|
+
addJob,
|
|
246
|
+
removeJob,
|
|
247
|
+
stopAll,
|
|
248
|
+
fireJob,
|
|
249
|
+
};
|
package/core/subagent.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Sub-agents: spawn a fresh, throwaway Claude process for focused
|
|
2
|
+
// research or sub-tasks. No session resume, no shared state with the
|
|
3
|
+
// caller's turn — the output is captured as text and returned. The
|
|
4
|
+
// caller (a parent Claude turn invoking the CLI via Bash) decides what
|
|
5
|
+
// to do with the result.
|
|
6
|
+
|
|
7
|
+
const { spawn } = require("child_process");
|
|
8
|
+
const { CLAUDE_PATH, MAX_PROCESS_TIMEOUT, botSubprocessEnv } = require("./config");
|
|
9
|
+
const { redactSensitive } = require("./redact");
|
|
10
|
+
const { claudeSubprocessEnv } = require("./auth-flow");
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
13
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
14
|
+
|
|
15
|
+
const concurrentByChannel = new Map();
|
|
16
|
+
|
|
17
|
+
function buildSubagentSystemPrompt(role) {
|
|
18
|
+
return [
|
|
19
|
+
"You are a sub-agent spawned by another Claude assistant via Open Claudia.",
|
|
20
|
+
"Your reply is captured as raw text and returned to the parent agent — there is no user reading it directly.",
|
|
21
|
+
"Stay focused on the prompt. Be concise. Return findings; do not chat.",
|
|
22
|
+
"You do not have access to the parent's chat session, files in /tmp from the parent, or the loopback send tools.",
|
|
23
|
+
"You can use Read, Glob, Grep, Bash for read-only research. Do not start long-running processes.",
|
|
24
|
+
role ? `Role: ${role}` : "",
|
|
25
|
+
].filter(Boolean).join("\n");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function trackStart(channelId) {
|
|
29
|
+
if (!channelId) return 0;
|
|
30
|
+
const cur = concurrentByChannel.get(channelId) || 0;
|
|
31
|
+
concurrentByChannel.set(channelId, cur + 1);
|
|
32
|
+
return cur + 1;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function trackEnd(channelId) {
|
|
36
|
+
if (!channelId) return;
|
|
37
|
+
const cur = concurrentByChannel.get(channelId) || 0;
|
|
38
|
+
if (cur <= 1) concurrentByChannel.delete(channelId);
|
|
39
|
+
else concurrentByChannel.set(channelId, cur - 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function concurrentCount(channelId) {
|
|
43
|
+
return concurrentByChannel.get(channelId) || 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function spawnSubagent(prompt, opts = {}) {
|
|
47
|
+
const cwd = opts.cwd || process.cwd();
|
|
48
|
+
const role = opts.role || null;
|
|
49
|
+
const channelId = opts.channelId || null;
|
|
50
|
+
const timeoutMs = Math.min(opts.timeoutMs || DEFAULT_TIMEOUT_MS, MAX_PROCESS_TIMEOUT || DEFAULT_TIMEOUT_MS);
|
|
51
|
+
|
|
52
|
+
const count = trackStart(channelId);
|
|
53
|
+
if (count > 3) {
|
|
54
|
+
console.warn(`subagent: ${count} concurrent sub-agents on channel ${channelId}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const args = [
|
|
59
|
+
"-p",
|
|
60
|
+
"--output-format", opts.json ? "json" : "text",
|
|
61
|
+
"--verbose",
|
|
62
|
+
"--append-system-prompt", buildSubagentSystemPrompt(role),
|
|
63
|
+
"--dangerously-skip-permissions",
|
|
64
|
+
prompt,
|
|
65
|
+
];
|
|
66
|
+
const env = { ...botSubprocessEnv(), ...claudeSubprocessEnv() };
|
|
67
|
+
const proc = spawn(CLAUDE_PATH, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
|
|
68
|
+
|
|
69
|
+
let out = "";
|
|
70
|
+
let err = "";
|
|
71
|
+
let truncated = false;
|
|
72
|
+
|
|
73
|
+
proc.stdout.on("data", (d) => {
|
|
74
|
+
if (out.length + d.length > MAX_OUTPUT_BYTES) {
|
|
75
|
+
out += d.toString().slice(0, Math.max(0, MAX_OUTPUT_BYTES - out.length));
|
|
76
|
+
truncated = true;
|
|
77
|
+
} else {
|
|
78
|
+
out += d.toString();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
proc.stderr.on("data", (d) => {
|
|
82
|
+
if (err.length < MAX_OUTPUT_BYTES) err += d.toString();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const t = setTimeout(() => {
|
|
86
|
+
try { proc.kill("SIGTERM"); } catch (e) {}
|
|
87
|
+
reject(new Error(`subagent timed out after ${Math.round(timeoutMs / 1000)}s`));
|
|
88
|
+
}, timeoutMs);
|
|
89
|
+
|
|
90
|
+
proc.on("error", (e) => {
|
|
91
|
+
clearTimeout(t);
|
|
92
|
+
trackEnd(channelId);
|
|
93
|
+
reject(e);
|
|
94
|
+
});
|
|
95
|
+
proc.on("close", (code) => {
|
|
96
|
+
clearTimeout(t);
|
|
97
|
+
trackEnd(channelId);
|
|
98
|
+
const text = redactSensitive(out.trim());
|
|
99
|
+
const stderrText = redactSensitive(err.trim());
|
|
100
|
+
if (code !== 0 && !text) {
|
|
101
|
+
const msg = stderrText || `subagent exited with code ${code}`;
|
|
102
|
+
return reject(new Error(msg));
|
|
103
|
+
}
|
|
104
|
+
resolve({ text, truncated, stderr: stderrText, code });
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { spawnSubagent, concurrentCount };
|
package/core/system-prompt.js
CHANGED
|
@@ -51,6 +51,27 @@ Reply normally in your final answer. To send a file, image, or voice clip back t
|
|
|
51
51
|
- \`open-claudia send-voice <path>\` — ogg/opus voice note
|
|
52
52
|
Never print or embed bot tokens in prompts, commands, logs, or messages.
|
|
53
53
|
|
|
54
|
+
## Background work
|
|
55
|
+
You can persist work across turns and wake yourself up later. These survive bot restarts.
|
|
56
|
+
|
|
57
|
+
Wake-ups and crons (real schedulers, not hallucinations — use them instead of saying "I'll check back later"):
|
|
58
|
+
- \`open-claudia schedule-wakeup <when> "<prompt>"\` — one-shot. \`<when>\` is a duration like \`30s\`/\`5m\`/\`2h\`/\`1d\` or an ISO datetime. When it fires you wake up in this same conversation with the prompt as if the user typed it.
|
|
59
|
+
- \`open-claudia cron-add "<5-field cron>" "<prompt>"\` — recurring.
|
|
60
|
+
- \`open-claudia cron-list\` — list all wakeups + crons on this channel.
|
|
61
|
+
- \`open-claudia cron-remove <id>\` — cancel one.
|
|
62
|
+
|
|
63
|
+
Persistent todo list (per channel; use for multi-step work so progress survives compaction):
|
|
64
|
+
- \`open-claudia task add "<content>"\`
|
|
65
|
+
- \`open-claudia task list\`
|
|
66
|
+
- \`open-claudia task start <id>\` / \`task done <id>\` / \`task remove <id>\`
|
|
67
|
+
|
|
68
|
+
Sub-agents (spawn a fresh throwaway Claude for focused research — output comes back on stdout):
|
|
69
|
+
- \`open-claudia agent "<prompt>" [--role "<role>"]\`
|
|
70
|
+
- Use when a side question would pollute this conversation, or to fan out independent lookups.
|
|
71
|
+
- The sub-agent has Read/Glob/Grep/Bash but no access to your chat session or send-* tools.
|
|
72
|
+
|
|
73
|
+
If you tell the user "I'll check back in N minutes" or "I'll run this every morning", you MUST schedule it with one of the commands above in the same turn. Otherwise nothing happens.
|
|
74
|
+
|
|
54
75
|
## Guidelines
|
|
55
76
|
- Keep responses concise — many users are on mobile.
|
|
56
77
|
- Markdown: *bold*, _italic_, \`code\`, \`\`\`code blocks\`\`\` work on both Telegram and Kazee. Skip headers (#) and links [text](url).
|
package/core/tasks.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Per-channel persistent todo list. The agent uses this to track
|
|
2
|
+
// multi-step work that should survive a turn, a compaction, or a
|
|
3
|
+
// restart. Scoped by adapter+channel so Telegram and Kazee see
|
|
4
|
+
// separate lists.
|
|
5
|
+
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const path = require("path");
|
|
8
|
+
const { TASKS_DIR } = require("./config");
|
|
9
|
+
|
|
10
|
+
function safe(s) {
|
|
11
|
+
return String(s || "").replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function filePathFor(adapter, channelId) {
|
|
15
|
+
fs.mkdirSync(TASKS_DIR, { recursive: true });
|
|
16
|
+
return path.join(TASKS_DIR, `${safe(adapter)}-${safe(channelId)}.json`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function load(adapter, channelId) {
|
|
20
|
+
try {
|
|
21
|
+
const raw = JSON.parse(fs.readFileSync(filePathFor(adapter, channelId), "utf-8"));
|
|
22
|
+
return Array.isArray(raw) ? raw : [];
|
|
23
|
+
} catch (e) { return []; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function save(adapter, channelId, list) {
|
|
27
|
+
const f = filePathFor(adapter, channelId);
|
|
28
|
+
const tmp = `${f}.tmp`;
|
|
29
|
+
fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
|
|
30
|
+
fs.renameSync(tmp, f);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function nextId() {
|
|
34
|
+
return `task_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function add(adapter, channelId, content) {
|
|
38
|
+
const list = load(adapter, channelId);
|
|
39
|
+
const t = {
|
|
40
|
+
id: nextId(),
|
|
41
|
+
content: String(content || "").trim(),
|
|
42
|
+
status: "pending",
|
|
43
|
+
createdAt: Date.now(),
|
|
44
|
+
updatedAt: Date.now(),
|
|
45
|
+
};
|
|
46
|
+
list.push(t);
|
|
47
|
+
save(adapter, channelId, list);
|
|
48
|
+
return t;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function update(adapter, channelId, id, patch) {
|
|
52
|
+
const list = load(adapter, channelId);
|
|
53
|
+
const idx = list.findIndex((t) => t.id === id);
|
|
54
|
+
if (idx < 0) return null;
|
|
55
|
+
list[idx] = { ...list[idx], ...patch, updatedAt: Date.now() };
|
|
56
|
+
save(adapter, channelId, list);
|
|
57
|
+
return list[idx];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function remove(adapter, channelId, id) {
|
|
61
|
+
const list = load(adapter, channelId);
|
|
62
|
+
const idx = list.findIndex((t) => t.id === id);
|
|
63
|
+
if (idx < 0) return null;
|
|
64
|
+
const [removed] = list.splice(idx, 1);
|
|
65
|
+
save(adapter, channelId, list);
|
|
66
|
+
return removed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function clearCompleted(adapter, channelId) {
|
|
70
|
+
const list = load(adapter, channelId).filter((t) => t.status !== "completed");
|
|
71
|
+
save(adapter, channelId, list);
|
|
72
|
+
return list;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function list(adapter, channelId, opts = {}) {
|
|
76
|
+
const all = load(adapter, channelId);
|
|
77
|
+
if (opts.status) return all.filter((t) => t.status === opts.status);
|
|
78
|
+
return all;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function format(t, idx) {
|
|
82
|
+
const mark = t.status === "completed" ? "[x]" : t.status === "in_progress" ? "[~]" : "[ ]";
|
|
83
|
+
const num = typeof idx === "number" ? `${idx + 1}. ` : "";
|
|
84
|
+
return `${num}${mark} ${t.content}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
filePathFor,
|
|
89
|
+
load, save,
|
|
90
|
+
add, update, remove, list, clearCompleted,
|
|
91
|
+
format,
|
|
92
|
+
};
|
package/package.json
CHANGED
package/core/cron.js
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
// Cron loader and scheduler. Cron jobs fire from a timer (no inbound
|
|
2
|
-
// message, so no chatContext) — we resolve the owner's preferred
|
|
3
|
-
// channel from identities and bind the run to that adapter.
|
|
4
|
-
|
|
5
|
-
const fs = require("fs");
|
|
6
|
-
const path = require("path");
|
|
7
|
-
const cron = require("node-cron");
|
|
8
|
-
const { CRONS_FILE, WORKSPACE, CHAT_ID } = require("./config");
|
|
9
|
-
const { runInChat } = require("./context");
|
|
10
|
-
const { canonicalForTelegram, identities } = require("./identity");
|
|
11
|
-
const { runClaudeSilent } = require("./runner");
|
|
12
|
-
|
|
13
|
-
const activeCrons = new Map();
|
|
14
|
-
let adaptersById = new Map();
|
|
15
|
-
|
|
16
|
-
function setAdapters(adapters) {
|
|
17
|
-
adaptersById = new Map();
|
|
18
|
-
for (const a of adapters) adaptersById.set(a.type, a);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function loadCrons() {
|
|
22
|
-
try { return JSON.parse(fs.readFileSync(CRONS_FILE, "utf-8")); }
|
|
23
|
-
catch (e) { return []; }
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function saveCrons(list) {
|
|
27
|
-
fs.writeFileSync(CRONS_FILE, JSON.stringify(list, null, 2));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function ownerDispatchTarget() {
|
|
31
|
-
// Prefer an explicit owner mapping; fall back to legacy Telegram CHAT_ID.
|
|
32
|
-
const ownerId = canonicalForTelegram(CHAT_ID || "");
|
|
33
|
-
const preferred = identities.preferred[ownerId];
|
|
34
|
-
if (preferred && preferred.transport && adaptersById.get(preferred.transport)) {
|
|
35
|
-
return {
|
|
36
|
-
adapter: adaptersById.get(preferred.transport),
|
|
37
|
-
channelId: String(preferred.channelId),
|
|
38
|
-
canonicalUserId: ownerId,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
const tg = adaptersById.get("telegram");
|
|
42
|
-
if (tg && CHAT_ID) {
|
|
43
|
-
return { adapter: tg, channelId: String(CHAT_ID), canonicalUserId: ownerId };
|
|
44
|
-
}
|
|
45
|
-
return null;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function scheduleCron(c) {
|
|
49
|
-
const cwd = path.join(WORKSPACE, c.project);
|
|
50
|
-
if (activeCrons.has(c.id)) activeCrons.get(c.id).task.stop();
|
|
51
|
-
const task = cron.schedule(c.schedule, () => {
|
|
52
|
-
const target = ownerDispatchTarget();
|
|
53
|
-
if (!target) {
|
|
54
|
-
console.error(`Cron ${c.label}: no adapter available to deliver result`);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
runInChat(target, () => runClaudeSilent(c.prompt, cwd, c.label));
|
|
58
|
-
});
|
|
59
|
-
activeCrons.set(c.id, { task, config: c });
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function initCrons() {
|
|
63
|
-
for (const c of loadCrons()) {
|
|
64
|
-
try { scheduleCron(c); }
|
|
65
|
-
catch (e) { console.error("Cron error:", e.message); }
|
|
66
|
-
}
|
|
67
|
-
console.log(`Loaded ${loadCrons().length} cron(s)`);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
module.exports = {
|
|
71
|
-
activeCrons,
|
|
72
|
-
setAdapters,
|
|
73
|
-
loadCrons,
|
|
74
|
-
saveCrons,
|
|
75
|
-
scheduleCron,
|
|
76
|
-
initCrons,
|
|
77
|
-
};
|