@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
package/core/actions.js
CHANGED
|
@@ -12,7 +12,8 @@ const { listProjects, projectKeyboard, workspacePath } = require("./projects");
|
|
|
12
12
|
const { isChatOwner, approveAuthRequest, denyAuthRequest, authRequestLabel } = require("./access");
|
|
13
13
|
const { finishOnboarding } = require("./onboarding");
|
|
14
14
|
const { startSession } = require("./handlers");
|
|
15
|
-
const
|
|
15
|
+
const jobs = require("./jobs");
|
|
16
|
+
const scheduler = require("./scheduler");
|
|
16
17
|
|
|
17
18
|
async function handleAction(envelope) {
|
|
18
19
|
const adapter = envelope.adapter;
|
|
@@ -134,15 +135,25 @@ async function handleAction(envelope) {
|
|
|
134
135
|
};
|
|
135
136
|
const p = presets[d.slice(3)];
|
|
136
137
|
if (!p) return;
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
138
|
+
scheduler.addJob({
|
|
139
|
+
kind: "cron",
|
|
140
|
+
adapter: envelope.adapter.id,
|
|
141
|
+
adapterType: envelope.adapter.type,
|
|
142
|
+
channelId: String(envelope.channelId),
|
|
143
|
+
canonicalUserId: envelope.canonicalUserId,
|
|
144
|
+
project: state.currentSession.name,
|
|
145
|
+
prompt: p.prompt,
|
|
146
|
+
label: p.label,
|
|
147
|
+
source: "user",
|
|
148
|
+
schedule: p.schedule,
|
|
149
|
+
});
|
|
150
|
+
await send(`Added: ${p.label} for ${state.currentSession.name}`);
|
|
140
151
|
return;
|
|
141
152
|
}
|
|
142
153
|
if (d === "cp:clear") {
|
|
143
|
-
for (const
|
|
144
|
-
|
|
145
|
-
|
|
154
|
+
for (const j of jobs.listForChannel(envelope.adapter.id, envelope.channelId)) {
|
|
155
|
+
if (j.kind === "cron" && j.source === "user") scheduler.removeJob(j.id);
|
|
156
|
+
}
|
|
146
157
|
await send("All crons cleared.");
|
|
147
158
|
return;
|
|
148
159
|
}
|
package/core/adapter-registry.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// remember to re-wire everything.
|
|
9
9
|
|
|
10
10
|
const { loadChannels } = require("./config");
|
|
11
|
-
const { setAdapters } = require("./
|
|
11
|
+
const { setAdapters } = require("./scheduler");
|
|
12
12
|
|
|
13
13
|
const { TelegramAdapter } = require("../channels/telegram/adapter");
|
|
14
14
|
const { KazeeAdapter } = require("../channels/kazee/adapter");
|
package/core/config.js
CHANGED
|
@@ -47,7 +47,8 @@ const WORKSPACE = config.WORKSPACE;
|
|
|
47
47
|
const CLAUDE_PATH = config.CLAUDE_PATH;
|
|
48
48
|
const CURSOR_PATH = config.CURSOR_PATH || null;
|
|
49
49
|
const CODEX_PATH = config.CODEX_PATH || null;
|
|
50
|
-
const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "
|
|
50
|
+
const AUTO_COMPACT_TOKENS = parseInt(config.AUTO_COMPACT_TOKENS || process.env.AUTO_COMPACT_TOKENS || "380000", 10);
|
|
51
|
+
const MIN_COMPACT_INTERVAL_MS = parseInt(config.MIN_COMPACT_INTERVAL_MS || process.env.MIN_COMPACT_INTERVAL_MS || "1800000", 10);
|
|
51
52
|
const PROJECT_TRANSCRIPTS = configTruthy(config.PROJECT_TRANSCRIPTS || process.env.PROJECT_TRANSCRIPTS, true);
|
|
52
53
|
const TRANSCRIPT_MAX_ENTRY_CHARS = parseInt(config.TRANSCRIPT_MAX_ENTRY_CHARS || process.env.TRANSCRIPT_MAX_ENTRY_CHARS || "12000", 10);
|
|
53
54
|
const TRANSCRIPTS_DIR = config.TRANSCRIPTS_DIR || process.env.TRANSCRIPTS_DIR || path.join(CONFIG_DIR, "transcripts");
|
|
@@ -56,6 +57,8 @@ const WHISPER_MODEL = config.WHISPER_MODEL || "";
|
|
|
56
57
|
const FFMPEG = config.FFMPEG || "";
|
|
57
58
|
const SOUL_FILE = config.SOUL_FILE || path.join(CONFIG_DIR, "soul.md");
|
|
58
59
|
const CRONS_FILE = config.CRONS_FILE || path.join(CONFIG_DIR, "crons.json");
|
|
60
|
+
const JOBS_FILE = config.JOBS_FILE || path.join(CONFIG_DIR, "jobs.json");
|
|
61
|
+
const TASKS_DIR = config.TASKS_DIR || path.join(CONFIG_DIR, "tasks");
|
|
59
62
|
const VAULT_FILE = config.VAULT_FILE || path.join(CONFIG_DIR, "vault.enc");
|
|
60
63
|
const AUTH_FILE = config.AUTH_FILE || path.join(CONFIG_DIR, "auth.json");
|
|
61
64
|
const IDENTITIES_FILE = config.IDENTITIES_FILE || path.join(CONFIG_DIR, "identities.json");
|
|
@@ -185,11 +188,12 @@ module.exports = {
|
|
|
185
188
|
resolvedCursorPath,
|
|
186
189
|
resolvedCodexPath,
|
|
187
190
|
AUTO_COMPACT_TOKENS,
|
|
191
|
+
MIN_COMPACT_INTERVAL_MS,
|
|
188
192
|
PROJECT_TRANSCRIPTS,
|
|
189
193
|
TRANSCRIPT_MAX_ENTRY_CHARS,
|
|
190
194
|
TRANSCRIPTS_DIR,
|
|
191
195
|
WHISPER_CLI, WHISPER_MODEL, FFMPEG,
|
|
192
|
-
SOUL_FILE, CRONS_FILE, VAULT_FILE, AUTH_FILE, IDENTITIES_FILE,
|
|
196
|
+
SOUL_FILE, CRONS_FILE, JOBS_FILE, TASKS_DIR, VAULT_FILE, AUTH_FILE, IDENTITIES_FILE,
|
|
193
197
|
STATE_FILE, SESSIONS_FILE,
|
|
194
198
|
TEMP_DIR, FILES_DIR,
|
|
195
199
|
MAX_FILE_SIZE, MAX_VOICE_SIZE, MAX_PROCESS_TIMEOUT,
|
package/core/handlers.js
CHANGED
|
@@ -14,16 +14,15 @@ const {
|
|
|
14
14
|
const { register } = require("./commands");
|
|
15
15
|
const { send, deleteMessage } = require("./io");
|
|
16
16
|
const { currentState, resetSettings, resetSessionUsage, saveState, getProjectSessions, getLastProjectSession, recordSession, linkIdentity } = require("./state");
|
|
17
|
-
const { canonicalForChannel, normalizeCanonicalUserId, channelKey, identities } = require("./identity");
|
|
17
|
+
const { canonicalForChannel, canonicalForTelegram, normalizeCanonicalUserId, channelKey, identities } = require("./identity");
|
|
18
18
|
const { isChatAuthorized, isChatOwner, recordPendingAuthRequest, authRequestLabel, hasOwner, bootstrapOwner } = require("./access");
|
|
19
19
|
const { isOnboarded, startOnboarding } = require("./onboarding");
|
|
20
20
|
const { listProjects, findProject, projectKeyboard, workspacePath } = require("./projects");
|
|
21
21
|
const { vault } = require("./vault-store");
|
|
22
22
|
const { redactSensitive } = require("./redact");
|
|
23
23
|
const { runDoctorChecks, formatDoctorReport } = require("./doctor");
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
} = require("./cron");
|
|
24
|
+
const jobs = require("./jobs");
|
|
25
|
+
const scheduler = require("./scheduler");
|
|
27
26
|
const {
|
|
28
27
|
runClaude, compactActiveSession, getActiveSessionId,
|
|
29
28
|
} = require("./runner");
|
|
@@ -897,8 +896,9 @@ register({
|
|
|
897
896
|
name: "cron", description: "Manage scheduled tasks", args: "[add|remove ...]",
|
|
898
897
|
handler: async (env, { tail }) => {
|
|
899
898
|
if (!authorized(env)) return;
|
|
899
|
+
const channelJobs = () => jobs.listForChannel(env.adapter.id, env.channelId).filter((j) => j.kind === "cron");
|
|
900
900
|
if (!tail) {
|
|
901
|
-
const list =
|
|
901
|
+
const list = channelJobs();
|
|
902
902
|
if (list.length === 0) {
|
|
903
903
|
return send("No crons.\n\nAdd: /cron add \"<schedule>\" <project> \"<prompt>\"\n\nOr pick a preset:", {
|
|
904
904
|
keyboard: { inline_keyboard: [
|
|
@@ -907,7 +907,7 @@ register({
|
|
|
907
907
|
] },
|
|
908
908
|
});
|
|
909
909
|
}
|
|
910
|
-
return send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project}`).join("\n") + "\n\nRemove: /cron remove <#>");
|
|
910
|
+
return send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project || ""}`).join("\n") + "\n\nRemove: /cron remove <#>");
|
|
911
911
|
}
|
|
912
912
|
|
|
913
913
|
const addMatch = tail.match(/^add\s+"(.+)"\s+(\S+)\s+"(.+)"$/);
|
|
@@ -915,19 +915,28 @@ register({
|
|
|
915
915
|
if (!cronLib.validate(addMatch[1])) return send("Invalid cron schedule.");
|
|
916
916
|
const proj = findProject(addMatch[2]);
|
|
917
917
|
if (!proj || Array.isArray(proj)) return send("Project not found.");
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
918
|
+
scheduler.addJob({
|
|
919
|
+
kind: "cron",
|
|
920
|
+
adapter: env.adapter.id,
|
|
921
|
+
adapterType: env.adapter.type,
|
|
922
|
+
channelId: String(env.channelId),
|
|
923
|
+
canonicalUserId: env.canonicalUserId,
|
|
924
|
+
project: proj,
|
|
925
|
+
prompt: addMatch[3],
|
|
926
|
+
label: addMatch[3].slice(0, 50),
|
|
927
|
+
source: "user",
|
|
928
|
+
schedule: addMatch[1],
|
|
929
|
+
});
|
|
930
|
+
return send(`Added: ${addMatch[3].slice(0, 50)} (${addMatch[1]}) for ${proj}`);
|
|
921
931
|
}
|
|
922
932
|
|
|
923
933
|
const removeMatch = tail.match(/^remove\s+(\d+)$/);
|
|
924
934
|
if (removeMatch) {
|
|
925
|
-
const list =
|
|
935
|
+
const list = channelJobs();
|
|
926
936
|
const idx = parseInt(removeMatch[1], 10) - 1;
|
|
927
937
|
if (idx < 0 || idx >= list.length) return send("Invalid number.");
|
|
928
|
-
const removed =
|
|
929
|
-
|
|
930
|
-
return send(`Removed: ${removed.label}`);
|
|
938
|
+
const removed = scheduler.removeJob(list[idx].id);
|
|
939
|
+
return send(`Removed: ${removed ? removed.label : "(unknown)"}`);
|
|
931
940
|
}
|
|
932
941
|
|
|
933
942
|
send('Usage: /cron | /cron add "<schedule>" <project> "<prompt>" | /cron remove <#>');
|
package/core/jobs.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Unified persistent store for scheduled work — both one-shot wakeups
|
|
2
|
+
// the agent set for itself ("check back in 30 min") and recurring crons
|
|
3
|
+
// (user-defined or agent-defined). Survives bot restarts.
|
|
4
|
+
//
|
|
5
|
+
// Entry shape:
|
|
6
|
+
// {
|
|
7
|
+
// id: "job_<ts>_<rand>",
|
|
8
|
+
// kind: "wakeup" | "cron",
|
|
9
|
+
// adapter: "<adapter-id>", // e.g. "telegram-1" / "kazee-1"
|
|
10
|
+
// adapterType: "telegram"|"kazee",
|
|
11
|
+
// channelId: "<chat or channel id>",
|
|
12
|
+
// canonicalUserId: "telegram:<id>" or "<email>",
|
|
13
|
+
// project: "<project-name>" or null,
|
|
14
|
+
// prompt: "<text to send as if user>",
|
|
15
|
+
// label: "<short>",
|
|
16
|
+
// source: "agent" | "user",
|
|
17
|
+
// // wakeup-only
|
|
18
|
+
// fireAt: <ms epoch>,
|
|
19
|
+
// // cron-only
|
|
20
|
+
// schedule: "<5-field cron>",
|
|
21
|
+
// // session pinning (per choice a/i):
|
|
22
|
+
// sessionKey: "lastSessionId" | "cursorSessionId" | "codexSessionId" | null,
|
|
23
|
+
// sessionId: "<claude-session-id>" | null,
|
|
24
|
+
// createdAt: <ms>,
|
|
25
|
+
// lastFireAt: <ms> | null,
|
|
26
|
+
// lastFireOk: true|false|null,
|
|
27
|
+
// }
|
|
28
|
+
|
|
29
|
+
const fs = require("fs");
|
|
30
|
+
const path = require("path");
|
|
31
|
+
const { JOBS_FILE } = require("./config");
|
|
32
|
+
|
|
33
|
+
function load() {
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(fs.readFileSync(JOBS_FILE, "utf-8"));
|
|
36
|
+
return Array.isArray(raw) ? raw : [];
|
|
37
|
+
} catch (e) { return []; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function save(list) {
|
|
41
|
+
fs.mkdirSync(path.dirname(JOBS_FILE), { recursive: true });
|
|
42
|
+
const tmp = `${JOBS_FILE}.tmp`;
|
|
43
|
+
fs.writeFileSync(tmp, JSON.stringify(list, null, 2));
|
|
44
|
+
fs.renameSync(tmp, JOBS_FILE);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function nextId(kind) {
|
|
48
|
+
return `${kind}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function add(job) {
|
|
52
|
+
const list = load();
|
|
53
|
+
const entry = { createdAt: Date.now(), lastFireAt: null, lastFireOk: null, ...job };
|
|
54
|
+
if (!entry.id) entry.id = nextId(entry.kind || "job");
|
|
55
|
+
list.push(entry);
|
|
56
|
+
save(list);
|
|
57
|
+
return entry;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function remove(id) {
|
|
61
|
+
const list = load();
|
|
62
|
+
const idx = list.findIndex((j) => j.id === id);
|
|
63
|
+
if (idx < 0) return null;
|
|
64
|
+
const [removed] = list.splice(idx, 1);
|
|
65
|
+
save(list);
|
|
66
|
+
return removed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function update(id, patch) {
|
|
70
|
+
const list = load();
|
|
71
|
+
const idx = list.findIndex((j) => j.id === id);
|
|
72
|
+
if (idx < 0) return null;
|
|
73
|
+
list[idx] = { ...list[idx], ...patch };
|
|
74
|
+
save(list);
|
|
75
|
+
return list[idx];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function get(id) {
|
|
79
|
+
return load().find((j) => j.id === id) || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function listForChannel(adapter, channelId) {
|
|
83
|
+
return load().filter((j) =>
|
|
84
|
+
String(j.channelId) === String(channelId) &&
|
|
85
|
+
(j.adapter === adapter || j.adapterType === adapter),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function listAll() { return load(); }
|
|
90
|
+
|
|
91
|
+
function listByCanonicalUser(canonicalUserId) {
|
|
92
|
+
return load().filter((j) => j.canonicalUserId === canonicalUserId);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
module.exports = {
|
|
96
|
+
load, save, add, remove, update, get,
|
|
97
|
+
listAll, listForChannel, listByCanonicalUser,
|
|
98
|
+
nextId,
|
|
99
|
+
};
|
package/core/loopback.js
CHANGED
|
@@ -15,6 +15,9 @@ const http = require("http");
|
|
|
15
15
|
const os = require("os");
|
|
16
16
|
const crypto = require("crypto");
|
|
17
17
|
const { CONFIG_DIR } = require("./config");
|
|
18
|
+
const jobsStore = require("./jobs");
|
|
19
|
+
const tasksStore = require("./tasks");
|
|
20
|
+
const scheduler = require("./scheduler");
|
|
18
21
|
|
|
19
22
|
const INFO_DIR = path.join(CONFIG_DIR, "loopback");
|
|
20
23
|
const INFO_FILE = path.join(INFO_DIR, `${process.pid}.json`);
|
|
@@ -58,7 +61,153 @@ function readBodyToFile(req, dest) {
|
|
|
58
61
|
});
|
|
59
62
|
}
|
|
60
63
|
|
|
61
|
-
const
|
|
64
|
+
const SEND_KINDS = new Set(["send-file", "send-voice", "send-photo"]);
|
|
65
|
+
const JSON_KINDS = new Set([
|
|
66
|
+
"schedule-wakeup", "cron-add", "cron-remove", "job-list",
|
|
67
|
+
"task-add", "task-update", "task-remove", "task-list", "task-clear-completed",
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function readBodyAsString(req, max = 64 * 1024) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
let buf = "";
|
|
73
|
+
req.on("data", (d) => {
|
|
74
|
+
buf += d.toString();
|
|
75
|
+
if (buf.length > max) {
|
|
76
|
+
req.destroy();
|
|
77
|
+
reject(new Error("body too large"));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
req.on("end", () => resolve(buf));
|
|
81
|
+
req.on("error", reject);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function handleSend(req, res, url, kind) {
|
|
86
|
+
const channelId = url.searchParams.get("channelId");
|
|
87
|
+
const adapterId = url.searchParams.get("adapter");
|
|
88
|
+
const caption = url.searchParams.get("caption") || "";
|
|
89
|
+
const fileName = url.searchParams.get("fileName") || `file-${Date.now()}.bin`;
|
|
90
|
+
if (!channelId || !adapterId) return reply(res, 400, { error: "missing channelId/adapter" });
|
|
91
|
+
const adapter = registry.findAdapter(adapterId);
|
|
92
|
+
if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
|
|
93
|
+
|
|
94
|
+
const safeName = path.basename(fileName).replace(/[^A-Za-z0-9._-]/g, "_") || `file-${Date.now()}`;
|
|
95
|
+
const tmp = path.join(os.tmpdir(), `oc-loopback-${process.pid}-${Date.now()}-${safeName}`);
|
|
96
|
+
await readBodyToFile(req, tmp);
|
|
97
|
+
|
|
98
|
+
let ok = false;
|
|
99
|
+
try {
|
|
100
|
+
if (kind === "send-voice") {
|
|
101
|
+
ok = await adapter.sendVoice(channelId, tmp);
|
|
102
|
+
} else if (kind === "send-photo" && typeof adapter.sendPhoto === "function") {
|
|
103
|
+
ok = await adapter.sendPhoto(channelId, tmp, caption);
|
|
104
|
+
} else {
|
|
105
|
+
ok = await adapter.sendFile(channelId, tmp, caption);
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
if (kind !== "send-voice") { try { fs.unlinkSync(tmp); } catch (e) {} }
|
|
109
|
+
}
|
|
110
|
+
return reply(res, ok ? 200 : 500, { ok });
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function handleJson(req, res, url, kind) {
|
|
114
|
+
const body = await readBodyAsString(req);
|
|
115
|
+
let payload = {};
|
|
116
|
+
if (body.trim()) {
|
|
117
|
+
try { payload = JSON.parse(body); }
|
|
118
|
+
catch (e) { return reply(res, 400, { error: "invalid JSON body" }); }
|
|
119
|
+
}
|
|
120
|
+
const channelId = String(payload.channelId || url.searchParams.get("channelId") || "");
|
|
121
|
+
const adapterId = String(payload.adapter || url.searchParams.get("adapter") || "");
|
|
122
|
+
if (!channelId || !adapterId) return reply(res, 400, { error: "missing channelId/adapter" });
|
|
123
|
+
const adapter = registry.findAdapter(adapterId);
|
|
124
|
+
if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
|
|
125
|
+
|
|
126
|
+
if (kind === "schedule-wakeup") {
|
|
127
|
+
const fireAt = Number(payload.fireAt);
|
|
128
|
+
if (!Number.isFinite(fireAt) || fireAt <= 0) return reply(res, 400, { error: "missing/invalid fireAt" });
|
|
129
|
+
if (!payload.prompt) return reply(res, 400, { error: "missing prompt" });
|
|
130
|
+
const job = scheduler.addJob({
|
|
131
|
+
kind: "wakeup",
|
|
132
|
+
adapter: adapterId,
|
|
133
|
+
adapterType: adapter.type,
|
|
134
|
+
channelId,
|
|
135
|
+
canonicalUserId: payload.canonicalUserId || null,
|
|
136
|
+
project: payload.project || null,
|
|
137
|
+
prompt: String(payload.prompt),
|
|
138
|
+
label: String(payload.label || payload.prompt).slice(0, 60),
|
|
139
|
+
source: payload.source || "agent",
|
|
140
|
+
fireAt,
|
|
141
|
+
sessionKey: payload.sessionKey || null,
|
|
142
|
+
sessionId: payload.sessionId || null,
|
|
143
|
+
});
|
|
144
|
+
return reply(res, 200, { ok: true, job });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (kind === "cron-add") {
|
|
148
|
+
if (!payload.schedule || !payload.prompt) return reply(res, 400, { error: "missing schedule/prompt" });
|
|
149
|
+
const job = scheduler.addJob({
|
|
150
|
+
kind: "cron",
|
|
151
|
+
adapter: adapterId,
|
|
152
|
+
adapterType: adapter.type,
|
|
153
|
+
channelId,
|
|
154
|
+
canonicalUserId: payload.canonicalUserId || null,
|
|
155
|
+
project: payload.project || null,
|
|
156
|
+
prompt: String(payload.prompt),
|
|
157
|
+
label: String(payload.label || payload.prompt).slice(0, 60),
|
|
158
|
+
source: payload.source || "agent",
|
|
159
|
+
schedule: String(payload.schedule),
|
|
160
|
+
sessionKey: payload.sessionKey || null,
|
|
161
|
+
sessionId: payload.sessionId || null,
|
|
162
|
+
});
|
|
163
|
+
return reply(res, 200, { ok: true, job });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (kind === "cron-remove") {
|
|
167
|
+
const id = payload.id || url.searchParams.get("id");
|
|
168
|
+
if (!id) return reply(res, 400, { error: "missing id" });
|
|
169
|
+
const removed = scheduler.removeJob(id);
|
|
170
|
+
return reply(res, removed ? 200 : 404, { ok: !!removed, removed });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (kind === "job-list") {
|
|
174
|
+
const list = jobsStore.listForChannel(adapterId, channelId);
|
|
175
|
+
return reply(res, 200, { ok: true, jobs: list });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (kind === "task-add") {
|
|
179
|
+
if (!payload.content) return reply(res, 400, { error: "missing content" });
|
|
180
|
+
const t = tasksStore.add(adapterId, channelId, payload.content);
|
|
181
|
+
return reply(res, 200, { ok: true, task: t });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (kind === "task-update") {
|
|
185
|
+
if (!payload.id) return reply(res, 400, { error: "missing id" });
|
|
186
|
+
const patch = {};
|
|
187
|
+
if (payload.status) patch.status = payload.status;
|
|
188
|
+
if (typeof payload.content === "string") patch.content = payload.content;
|
|
189
|
+
const t = tasksStore.update(adapterId, channelId, payload.id, patch);
|
|
190
|
+
return reply(res, t ? 200 : 404, { ok: !!t, task: t });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (kind === "task-remove") {
|
|
194
|
+
if (!payload.id) return reply(res, 400, { error: "missing id" });
|
|
195
|
+
const removed = tasksStore.remove(adapterId, channelId, payload.id);
|
|
196
|
+
return reply(res, removed ? 200 : 404, { ok: !!removed, removed });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (kind === "task-list") {
|
|
200
|
+
const list = tasksStore.list(adapterId, channelId, payload.status ? { status: payload.status } : {});
|
|
201
|
+
return reply(res, 200, { ok: true, tasks: list });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (kind === "task-clear-completed") {
|
|
205
|
+
const remaining = tasksStore.clearCompleted(adapterId, channelId);
|
|
206
|
+
return reply(res, 200, { ok: true, tasks: remaining });
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return reply(res, 404, { error: "not found" });
|
|
210
|
+
}
|
|
62
211
|
|
|
63
212
|
async function handle(req, res) {
|
|
64
213
|
try {
|
|
@@ -66,32 +215,9 @@ async function handle(req, res) {
|
|
|
66
215
|
if (!authOk(req)) return reply(res, 401, { error: "unauthorized" });
|
|
67
216
|
const url = new URL(req.url, "http://127.0.0.1");
|
|
68
217
|
const kind = url.pathname.slice(1);
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const caption = url.searchParams.get("caption") || "";
|
|
73
|
-
const fileName = url.searchParams.get("fileName") || `file-${Date.now()}.bin`;
|
|
74
|
-
if (!channelId || !adapterId) return reply(res, 400, { error: "missing channelId/adapter" });
|
|
75
|
-
const adapter = registry.findAdapter(adapterId);
|
|
76
|
-
if (!adapter) return reply(res, 400, { error: `unknown adapter ${adapterId}` });
|
|
77
|
-
|
|
78
|
-
const safeName = path.basename(fileName).replace(/[^A-Za-z0-9._-]/g, "_") || `file-${Date.now()}`;
|
|
79
|
-
const tmp = path.join(os.tmpdir(), `oc-loopback-${process.pid}-${Date.now()}-${safeName}`);
|
|
80
|
-
await readBodyToFile(req, tmp);
|
|
81
|
-
|
|
82
|
-
let ok = false;
|
|
83
|
-
try {
|
|
84
|
-
if (kind === "send-voice") {
|
|
85
|
-
ok = await adapter.sendVoice(channelId, tmp); // adapter unlinks on success/failure
|
|
86
|
-
} else if (kind === "send-photo" && typeof adapter.sendPhoto === "function") {
|
|
87
|
-
ok = await adapter.sendPhoto(channelId, tmp, caption);
|
|
88
|
-
} else {
|
|
89
|
-
ok = await adapter.sendFile(channelId, tmp, caption);
|
|
90
|
-
}
|
|
91
|
-
} finally {
|
|
92
|
-
if (kind !== "send-voice") { try { fs.unlinkSync(tmp); } catch (e) {} }
|
|
93
|
-
}
|
|
94
|
-
return reply(res, ok ? 200 : 500, { ok });
|
|
218
|
+
if (SEND_KINDS.has(kind)) return handleSend(req, res, url, kind);
|
|
219
|
+
if (JSON_KINDS.has(kind)) return handleJson(req, res, url, kind);
|
|
220
|
+
return reply(res, 404, { error: "not found" });
|
|
95
221
|
} catch (e) {
|
|
96
222
|
console.error("loopback handle error:", e.message);
|
|
97
223
|
return reply(res, 500, { error: e.message });
|
package/core/runner.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const { spawn } = require("child_process");
|
|
7
7
|
const {
|
|
8
8
|
CLAUDE_PATH, resolvedCursorPath, resolvedCodexPath,
|
|
9
|
-
AUTO_COMPACT_TOKENS, MAX_PROCESS_TIMEOUT, botSubprocessEnv,
|
|
9
|
+
AUTO_COMPACT_TOKENS, MIN_COMPACT_INTERVAL_MS, MAX_PROCESS_TIMEOUT, botSubprocessEnv,
|
|
10
10
|
} = require("./config");
|
|
11
11
|
const { currentState, saveState, recordSession, userOwnsClaudeSession } = require("./state");
|
|
12
12
|
const { chatContext, currentChannelId, currentAdapter } = require("./context");
|
|
@@ -33,6 +33,20 @@ function chatEnvOverlay() {
|
|
|
33
33
|
overlay.OC_SEND_URL = lb.url;
|
|
34
34
|
overlay.OC_SEND_TOKEN = lb.token;
|
|
35
35
|
}
|
|
36
|
+
const tinfo = transcriptProjectInfo();
|
|
37
|
+
if (tinfo && tinfo.transcriptPath) overlay.OC_TRANSCRIPT_PATH = tinfo.transcriptPath;
|
|
38
|
+
try {
|
|
39
|
+
const state = currentState();
|
|
40
|
+
if (state) {
|
|
41
|
+
if (state.userId) overlay.OC_CANONICAL_USER_ID = String(state.userId);
|
|
42
|
+
const key = getActiveSessionKey(state);
|
|
43
|
+
const sid = state[key];
|
|
44
|
+
if (sid) {
|
|
45
|
+
overlay.OC_LAST_SESSION_ID = String(sid);
|
|
46
|
+
overlay.OC_LAST_SESSION_KEY = key;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
} catch (e) { /* no chat context (e.g. startup) — skip */ }
|
|
36
50
|
return overlay;
|
|
37
51
|
}
|
|
38
52
|
|
|
@@ -71,8 +85,11 @@ function getActiveSessionKey(state = currentState()) {
|
|
|
71
85
|
function shouldAutoCompact(state = currentState(), opts = {}) {
|
|
72
86
|
if (opts.skipAutoCompact || opts.fresh || opts.continueSession || state.isCompacting) return false;
|
|
73
87
|
if (!state[getActiveSessionKey(state)]) return false;
|
|
74
|
-
const threshold = Number.isFinite(AUTO_COMPACT_TOKENS) ? AUTO_COMPACT_TOKENS :
|
|
75
|
-
|
|
88
|
+
const threshold = Number.isFinite(AUTO_COMPACT_TOKENS) ? AUTO_COMPACT_TOKENS : 380000;
|
|
89
|
+
if ((state.sessionUsage?.lastInputTokens || 0) < threshold) return false;
|
|
90
|
+
const minInterval = Number.isFinite(MIN_COMPACT_INTERVAL_MS) ? MIN_COMPACT_INTERVAL_MS : 1800000;
|
|
91
|
+
if (state.lastCompactedAt && (Date.now() - state.lastCompactedAt) < minInterval) return false;
|
|
92
|
+
return true;
|
|
76
93
|
}
|
|
77
94
|
|
|
78
95
|
function buildClaudeArgs(prompt, opts = {}) {
|
|
@@ -181,13 +198,20 @@ function claudeEmptyFailureMessage(code, stderrText = "") {
|
|
|
181
198
|
function compactSummaryPrompt() {
|
|
182
199
|
return [
|
|
183
200
|
"Summarize this conversation for a fresh compacted continuation.",
|
|
184
|
-
"
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"-
|
|
188
|
-
"-
|
|
189
|
-
"-
|
|
190
|
-
"
|
|
201
|
+
"Write a brief that lets your future self resume work without re-asking the user.",
|
|
202
|
+
"",
|
|
203
|
+
"Include:",
|
|
204
|
+
"- Current user goal and any active side threads or sub-goals.",
|
|
205
|
+
"- Locked-in constraints, preferences, and decisions (with the WHY when non-obvious).",
|
|
206
|
+
"- Infrastructure / release facts: how things deploy, publish, or release; which CI handles what; required env or secrets; commands the user said NOT to run locally.",
|
|
207
|
+
"- Files and repos touched, current branch / commit / tag state, what is committed vs uncommitted, what is pushed vs local.",
|
|
208
|
+
"- Commands already run and their results — so you do not re-run completed setup and do not re-attempt failed steps.",
|
|
209
|
+
"- Open TODOs, blockers, and the exact next step.",
|
|
210
|
+
"- User pushback or corrections this session — your future self must not repeat the mistake. Quote the correction if short.",
|
|
211
|
+
"- Stable paths, IDs, PIDs, owner IDs, and reference URLs the work depends on.",
|
|
212
|
+
"",
|
|
213
|
+
"Do not include: secrets, raw tokens, full file dumps, or chat pleasantries.",
|
|
214
|
+
"Be concrete. Names of files, commands, flags, tags, and commits beat paraphrase. If a fact was load-bearing in this session, write it down verbatim.",
|
|
191
215
|
].join("\n");
|
|
192
216
|
}
|
|
193
217
|
|
|
@@ -197,6 +221,17 @@ function compactSeedPrompt(summary) {
|
|
|
197
221
|
"Treat the following summary as the prior conversation context. Do not repeat it back unless asked.",
|
|
198
222
|
"Continue from this state in future turns.",
|
|
199
223
|
"",
|
|
224
|
+
"Before telling the user you lack context on something they reference:",
|
|
225
|
+
"1. Check the summary below.",
|
|
226
|
+
"2. Search the project transcript with `open-claudia transcript-window <pattern>`.",
|
|
227
|
+
" It returns each hit with surrounding turns of context, capped per turn so it stays bounded.",
|
|
228
|
+
" Useful flags: --before N / --after N (default 2), --max-turns K (default 10), --regex.",
|
|
229
|
+
" Fall back to `grep -n -C 5 <pattern> <transcript-path>` (path in your system prompt under",
|
|
230
|
+
" 'Project Transcript Memory') only if the helper does not fit your search.",
|
|
231
|
+
"Only ask the user if both turn up nothing.",
|
|
232
|
+
"",
|
|
233
|
+
"If a fact in the summary contradicts current repo state (a file path, a command, a flag, a version), trust what you observe now and proceed without flagging it unless the user asks.",
|
|
234
|
+
"",
|
|
200
235
|
"Compacted summary:",
|
|
201
236
|
summary,
|
|
202
237
|
].join("\n");
|
|
@@ -366,17 +401,6 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
366
401
|
return;
|
|
367
402
|
}
|
|
368
403
|
|
|
369
|
-
if (shouldAutoCompact(state, opts)) {
|
|
370
|
-
try {
|
|
371
|
-
await compactActiveSession(cwd, {
|
|
372
|
-
notify: true,
|
|
373
|
-
message: "Context is getting large, compacting first so this stays fast…",
|
|
374
|
-
});
|
|
375
|
-
} catch (e) {
|
|
376
|
-
await send(`Compaction failed: ${redactSensitive(e.message)}\nContinuing in the existing session.`, { replyTo: replyToMsgId });
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
|
|
380
404
|
appendProjectTranscript("user", prompt, {
|
|
381
405
|
sourceMessageId: replyToMsgId || null,
|
|
382
406
|
fresh: !!opts.fresh,
|