@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/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 { activeCrons, loadCrons, saveCrons, scheduleCron } = require("./cron");
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
- const c = { id: `cron_${Date.now()}`, ...p, project: state.currentSession.name };
138
- const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
139
- await send(`Added: ${c.label} for ${state.currentSession.name}`);
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 [, v] of activeCrons) v.task.stop();
144
- activeCrons.clear();
145
- saveCrons([]);
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
  }
@@ -8,7 +8,7 @@
8
8
  // remember to re-wire everything.
9
9
 
10
10
  const { loadChannels } = require("./config");
11
- const { setAdapters } = require("./cron");
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 || "280000", 10);
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
- loadCrons, saveCrons, scheduleCron, activeCrons,
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 = loadCrons();
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
- const c = { id: `cron_${Date.now()}`, schedule: addMatch[1], project: proj, prompt: addMatch[3], label: addMatch[3].slice(0, 50) };
919
- const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
920
- return send(`Added: ${c.label} (${c.schedule}) for ${proj}`);
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 = loadCrons();
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 = list.splice(idx, 1)[0]; saveCrons(list);
929
- if (activeCrons.has(removed.id)) { activeCrons.get(removed.id).task.stop(); activeCrons.delete(removed.id); }
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 KINDS = new Set(["send-file", "send-voice", "send-photo"]);
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 (!KINDS.has(kind)) return reply(res, 404, { error: "not found" });
70
- const channelId = url.searchParams.get("channelId");
71
- const adapterId = url.searchParams.get("adapter");
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 : 280000;
75
- return (state.sessionUsage?.lastInputTokens || 0) >= threshold;
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
- "Include only durable context needed to continue effectively:",
185
- "- current user goal and constraints",
186
- "- important decisions and preferences",
187
- "- files/repos touched and current code state",
188
- "- commands/tests already run and results",
189
- "- open TODOs, blockers, and exact next step",
190
- "Do not include secrets, raw tokens, or irrelevant chat transcript.",
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,