@inetafrica/open-claudia 3.2.0 → 3.2.3
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 +29 -0
- package/bin/cli.js +94 -1
- package/bin/schedule.js +24 -4
- package/channels/kazee/adapter.js +37 -3
- package/channels/kazee/call/manager.js +128 -2
- package/channels/kazee/call/session.js +66 -25
- package/channels/kazee/socket.js +3 -0
- package/channels/spaces/socket.js +3 -0
- package/channels/telegram/adapter.js +9 -5
- package/core/handlers.js +6 -2
- package/core/loopback.js +55 -1
- package/core/media.js +58 -5
- package/core/providers/utility-policy.js +4 -1
- package/core/router.js +28 -4
- package/core/runner.js +14 -3
- package/core/scheduler.js +60 -21
- package/core/socket-reconnect.js +71 -0
- package/core/system-prompt.js +10 -1
- package/package.json +3 -3
- package/test-fixtures/fake-agent-cli.js +5 -1
- package/test-kazee-call-manager.js +44 -1
- package/test-kazee-call-session.js +45 -2
- package/test-provider-language.js +20 -1
- package/test-provider-scheduler.js +80 -0
- package/test-utility-provider-policy.js +22 -0
|
@@ -672,11 +672,15 @@ class TelegramAdapter {
|
|
|
672
672
|
if (!media || !media.fileId) return null;
|
|
673
673
|
const file = await this.bot.getFile(media.fileId);
|
|
674
674
|
const fileUrl = `https://api.telegram.org/file/bot${this.token}/${file.file_path}`;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
675
|
+
// Telegram's file_path carries the real extension even when the message
|
|
676
|
+
// has no fileName (e.g. videos), so prefer it over the .bin fallback.
|
|
677
|
+
const ext = path.extname(media.fileName || "")
|
|
678
|
+
|| path.extname(file.file_path || "")
|
|
679
|
+
|| (media.type === "voice" ? ".ogg"
|
|
680
|
+
: media.type === "audio" ? ".ogg"
|
|
681
|
+
: media.type === "photo" ? ".jpg"
|
|
682
|
+
: media.type === "video" ? ".mp4"
|
|
683
|
+
: ".bin");
|
|
680
684
|
const baseDir = media.type === "document" ? FILES_DIR : TEMP_DIR;
|
|
681
685
|
const localPath = media.type === "document" && media.fileName
|
|
682
686
|
? path.join(baseDir, media.fileName)
|
package/core/handlers.js
CHANGED
|
@@ -1611,14 +1611,18 @@ register({
|
|
|
1611
1611
|
// Stop pending preview flushes but keep statusMessageId/Channel: the
|
|
1612
1612
|
// killed run's delivery unwind edits the preview to drop its footer.
|
|
1613
1613
|
if (state.streamInterval) { clearTimeout(state.streamInterval); state.streamInterval = null; }
|
|
1614
|
-
|
|
1614
|
+
// Clearing the interval here orphans the run's own stopper (ownership
|
|
1615
|
+
// check makes it a no-op), so the explicit typing:stop must go out now.
|
|
1616
|
+
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; }
|
|
1617
|
+
try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
|
|
1615
1618
|
await sideCancellation;
|
|
1616
1619
|
await send(`Cancelled.${queuedNote()}`);
|
|
1617
1620
|
} else if (state.preparingRun) {
|
|
1618
1621
|
// Turn is mid-recall/prompt-build — no process to kill yet. Flag it so
|
|
1619
1622
|
// the runner's pre-spawn shouldAbort checkpoint bails before spawning.
|
|
1620
1623
|
state.cancelRequested = true;
|
|
1621
|
-
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; }
|
|
1624
|
+
if (state.typingHeartbeat) { clearInterval(state.typingHeartbeat); state.typingHeartbeat = null; state.thinkingPhase = false; }
|
|
1625
|
+
try { currentAdapter()?.typingStop?.(currentChannelId()); } catch (_) {}
|
|
1622
1626
|
await sideCancellation;
|
|
1623
1627
|
await send(`Cancelled.${queuedNote()}`);
|
|
1624
1628
|
} else if (await sideCancellation) await send("Cancelled side response.");
|
package/core/loopback.js
CHANGED
|
@@ -82,7 +82,7 @@ const JSON_KINDS = new Set([
|
|
|
82
82
|
"relay-send", "recent-fetch", "audit-tail",
|
|
83
83
|
"auth-list", "auth-revoke",
|
|
84
84
|
"approval-request",
|
|
85
|
-
"say",
|
|
85
|
+
"say", "call", "tts", "transcribe",
|
|
86
86
|
]);
|
|
87
87
|
|
|
88
88
|
function callerIsOwner(payload) {
|
|
@@ -200,6 +200,56 @@ async function handleJson(req, res, url, kind) {
|
|
|
200
200
|
return reply(res, ok ? 200 : 500, { ok });
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
if (kind === "tts") {
|
|
204
|
+
// Synthesis-to-FILE: no chat egress, so no guardrail — the artifact stays on
|
|
205
|
+
// local disk until some send verb (with its own guard) ships it. Runs here
|
|
206
|
+
// because only the bot process holds the speech token.
|
|
207
|
+
const text = String(payload.text || "").trim();
|
|
208
|
+
if (!text) return reply(res, 400, { error: "missing text" });
|
|
209
|
+
try {
|
|
210
|
+
const { textToVoiceFile } = require("./media");
|
|
211
|
+
const outPath = await textToVoiceFile(text, {
|
|
212
|
+
voice: payload.voice ? String(payload.voice) : undefined,
|
|
213
|
+
out: payload.out ? String(payload.out) : undefined,
|
|
214
|
+
});
|
|
215
|
+
if (!outPath) return reply(res, 503, { error: "no TTS route available" });
|
|
216
|
+
return reply(res, 200, { ok: true, path: outPath });
|
|
217
|
+
} catch (e) {
|
|
218
|
+
return reply(res, 500, { error: `synthesis failed: ${e.message}` });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (kind === "transcribe") {
|
|
223
|
+
const filePath = String(payload.path || "").trim();
|
|
224
|
+
if (!filePath) return reply(res, 400, { error: "missing path" });
|
|
225
|
+
if (!path.isAbsolute(filePath)) return reply(res, 400, { error: "path must be absolute (the bot process has its own cwd)" });
|
|
226
|
+
if (!fs.existsSync(filePath)) return reply(res, 400, { error: `file not found: ${filePath}` });
|
|
227
|
+
try {
|
|
228
|
+
const { transcribeMedia } = require("./media");
|
|
229
|
+
const text = await transcribeMedia(filePath);
|
|
230
|
+
if (text == null) return reply(res, 503, { error: "no STT route available" });
|
|
231
|
+
return reply(res, 200, { ok: true, text });
|
|
232
|
+
} catch (e) {
|
|
233
|
+
return reply(res, 500, { error: `transcription failed: ${e.message}` });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (kind === "call") {
|
|
238
|
+
// Placing a live voice call is owner-only: it rings a phone on the bot's
|
|
239
|
+
// behalf, so nothing guarded/external may trigger it.
|
|
240
|
+
if (!callerIsOwner(payload)) return reply(res, 403, { error: "only the owner can place a call" });
|
|
241
|
+
const manager = adapter._callManager;
|
|
242
|
+
if (!manager || typeof manager.outgoing !== "function") {
|
|
243
|
+
return reply(res, 501, { error: `adapter ${adapterId} does not support outgoing calls` });
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
const result = await manager.outgoing({ chatId: channelId, toUserId: payload.toUserId || undefined });
|
|
247
|
+
return reply(res, 200, { ok: true, ...result });
|
|
248
|
+
} catch (e) {
|
|
249
|
+
return reply(res, 500, { error: e.message });
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
203
253
|
if (kind === "schedule-wakeup") {
|
|
204
254
|
const fireAt = Number(payload.fireAt);
|
|
205
255
|
if (!Number.isFinite(fireAt) || fireAt <= 0) return reply(res, 400, { error: "missing/invalid fireAt" });
|
|
@@ -297,6 +347,10 @@ async function handleJson(req, res, url, kind) {
|
|
|
297
347
|
label: String(payload.label || payload.prompt).slice(0, 60),
|
|
298
348
|
schedule: payload.schedule ? String(payload.schedule) : undefined,
|
|
299
349
|
repeat: !!payload.repeat,
|
|
350
|
+
// Absent/invalid → the scheduler stamps its default watch timeout.
|
|
351
|
+
expiresAt: Number.isFinite(Number(payload.expiresAt)) && Number(payload.expiresAt) > 0
|
|
352
|
+
? Number(payload.expiresAt)
|
|
353
|
+
: undefined,
|
|
300
354
|
source: payload.source || "agent",
|
|
301
355
|
sessionId: payload.sessionId || null,
|
|
302
356
|
previousSessionId: payload.previousSessionId || null,
|
package/core/media.js
CHANGED
|
@@ -101,7 +101,7 @@ async function elevenLabsToVoice(clean) {
|
|
|
101
101
|
|
|
102
102
|
// Pocket TTS through the Agent Space speech gateway. The gateway returns WAV;
|
|
103
103
|
// Telegram and voice transports receive a compact Ogg/Opus file.
|
|
104
|
-
async function pocketToVoice(clean) {
|
|
104
|
+
async function pocketToVoice(clean, voiceOverride) {
|
|
105
105
|
const token = speechBearerToken();
|
|
106
106
|
if (TTS_PROVIDER === "elevenlabs" || !token || !SPEECH_SERVICE_URL || !FFMPEG) return null;
|
|
107
107
|
let wavPath = null;
|
|
@@ -109,7 +109,7 @@ async function pocketToVoice(clean) {
|
|
|
109
109
|
const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/audio/speech`, {
|
|
110
110
|
method: "POST",
|
|
111
111
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
112
|
-
body: JSON.stringify({ input: clean, voice: ttsVoice.getVoice(), response_format: "wav" }),
|
|
112
|
+
body: JSON.stringify({ input: clean, voice: voiceOverride || ttsVoice.getVoice(), response_format: "wav" }),
|
|
113
113
|
signal: AbortSignal.timeout(60000),
|
|
114
114
|
});
|
|
115
115
|
if (!res.ok) {
|
|
@@ -186,16 +186,69 @@ function canSpeak() {
|
|
|
186
186
|
}
|
|
187
187
|
|
|
188
188
|
// Natural voice via ElevenLabs, falling back to macOS `say` only on no-key/error.
|
|
189
|
-
|
|
189
|
+
// opts.voice overrides the gateway voice id (e.g. a cloned voice) for the pocket
|
|
190
|
+
// route only — ElevenLabs/say fallbacks keep their configured voices.
|
|
191
|
+
async function textToVoice(text, opts = {}) {
|
|
190
192
|
const clean = cleanForTTS(text);
|
|
191
193
|
if (!clean) return null;
|
|
192
|
-
const pocket = await pocketToVoice(clean);
|
|
194
|
+
const pocket = await pocketToVoice(clean, opts.voice);
|
|
193
195
|
if (pocket) return pocket;
|
|
194
196
|
const eleven = await elevenLabsToVoice(clean);
|
|
195
197
|
if (eleven) return eleven;
|
|
196
198
|
return sayToVoice(clean);
|
|
197
199
|
}
|
|
198
200
|
|
|
201
|
+
// Synthesize to a caller-chosen FILE (voice-overs, video narration) instead of a
|
|
202
|
+
// chat send. `out` picks the container by extension (.ogg is native; anything
|
|
203
|
+
// else transcodes). Returns the final path, or null when no TTS route exists.
|
|
204
|
+
async function textToVoiceFile(text, { voice, out } = {}) {
|
|
205
|
+
const oggPath = await textToVoice(text, { voice });
|
|
206
|
+
if (!oggPath) return null;
|
|
207
|
+
if (!out || path.resolve(out) === path.resolve(oggPath)) return oggPath;
|
|
208
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
209
|
+
if (path.extname(out).toLowerCase() === path.extname(oggPath).toLowerCase()) {
|
|
210
|
+
fs.copyFileSync(oggPath, out);
|
|
211
|
+
} else {
|
|
212
|
+
if (!FFMPEG) throw new Error(`ffmpeg unavailable to convert to ${path.extname(out) || "the requested format"}`);
|
|
213
|
+
execSync(`"${FFMPEG}" -i "${oggPath}" -y "${out}" 2>/dev/null`, { timeout: 60000 });
|
|
214
|
+
}
|
|
215
|
+
try { fs.unlinkSync(oggPath); } catch (e) {}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// True when at least one transcribeAudio route can produce text.
|
|
220
|
+
function canTranscribe() {
|
|
221
|
+
if (SPEECH_SERVICE_URL && speechBearerToken()) return true;
|
|
222
|
+
return Boolean(WHISPER_CLI && FFMPEG);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Transcribe ANY audio/video file (mp3, m4a, wav, ogg, mp4, mkv...): normalize
|
|
226
|
+
// to 16kHz mono wav first when ffmpeg is available — that extracts the audio
|
|
227
|
+
// track from video containers and shrinks the gateway upload. Returns the
|
|
228
|
+
// transcript string, or null when no STT route exists.
|
|
229
|
+
async function transcribeMedia(filePath) {
|
|
230
|
+
if (!canTranscribe()) return null;
|
|
231
|
+
let wavPath = null;
|
|
232
|
+
let sendPath = filePath;
|
|
233
|
+
if (FFMPEG && !/\.wav$/i.test(filePath)) {
|
|
234
|
+
try {
|
|
235
|
+
wavPath = path.join(TEMP_DIR, `stt-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.wav`);
|
|
236
|
+
execSync(`"${FFMPEG}" -i "${filePath}" -vn -ar 16000 -ac 1 -y "${wavPath}" 2>/dev/null`, { timeout: 300000 });
|
|
237
|
+
sendPath = wavPath;
|
|
238
|
+
} catch (e) {
|
|
239
|
+
console.error("STT transcode error:", e.message);
|
|
240
|
+
try { if (wavPath) fs.unlinkSync(wavPath); } catch (_) {}
|
|
241
|
+
wavPath = null;
|
|
242
|
+
sendPath = filePath;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
return await transcribeAudio(sendPath);
|
|
247
|
+
} finally {
|
|
248
|
+
if (wavPath) { try { fs.unlinkSync(wavPath); } catch (e) {} }
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
199
252
|
// Split a reply into sentence-sized chunks for streamed TTS. Tiny fragments are
|
|
200
253
|
// merged into the next chunk so we don't fire a TTS call per "Hi." and end up
|
|
201
254
|
// with choppy playback. Returns cleaned, non-empty chunks.
|
|
@@ -250,4 +303,4 @@ async function synthSentenceMp3(text) {
|
|
|
250
303
|
}
|
|
251
304
|
}
|
|
252
305
|
|
|
253
|
-
module.exports = { transcribeAudio, transcribeAudioLocal, textToVoice, splitSentences, synthSentenceMp3, pocketToVoice, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
|
|
306
|
+
module.exports = { transcribeAudio, transcribeAudioLocal, transcribeMedia, canTranscribe, textToVoice, textToVoiceFile, splitSentences, synthSentenceMp3, pocketToVoice, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
|
|
@@ -4,7 +4,7 @@ const config = require("../config");
|
|
|
4
4
|
|
|
5
5
|
const PROVIDER_IDS = Object.freeze(["claude", "codex"]);
|
|
6
6
|
const PROVIDER_ID_SET = new Set(PROVIDER_IDS);
|
|
7
|
-
const PURPOSES = Object.freeze(["subagent", "recall", "review", "dream", "introspection", "enforcer"]);
|
|
7
|
+
const PURPOSES = Object.freeze(["subagent", "recall", "review", "dedup", "dream", "introspection", "enforcer"]);
|
|
8
8
|
const PURPOSE_SET = new Set(PURPOSES);
|
|
9
9
|
const TIERS = Object.freeze(["low", "medium", "high"]);
|
|
10
10
|
const TIER_SET = new Set(TIERS);
|
|
@@ -12,6 +12,7 @@ const PURPOSE_PROVIDER_ENV = Object.freeze({
|
|
|
12
12
|
subagent: "SUBAGENT_PROVIDER",
|
|
13
13
|
recall: "RECALL_PROVIDER",
|
|
14
14
|
review: "MEMORY_PROVIDER",
|
|
15
|
+
dedup: "MEMORY_PROVIDER",
|
|
15
16
|
dream: "DREAM_PROVIDER",
|
|
16
17
|
introspection: "DREAM_PROVIDER",
|
|
17
18
|
enforcer: "ENFORCER_PROVIDER",
|
|
@@ -20,12 +21,14 @@ const PURPOSE_MODEL_PREFIX = Object.freeze({
|
|
|
20
21
|
subagent: "SUBAGENT_MODEL",
|
|
21
22
|
recall: "RECALL_MODEL",
|
|
22
23
|
review: "PACK_REVIEW_MODEL",
|
|
24
|
+
dedup: "PACK_DEDUP_MODEL",
|
|
23
25
|
dream: "DREAM_MODEL",
|
|
24
26
|
introspection: "DREAM_MODEL",
|
|
25
27
|
enforcer: "ENFORCER_MODEL",
|
|
26
28
|
});
|
|
27
29
|
const LEGACY_CLAUDE_MODEL_ENV = Object.freeze({
|
|
28
30
|
review: "PACK_REVIEW_MODEL",
|
|
31
|
+
dedup: "PACK_DEDUP_MODEL",
|
|
29
32
|
dream: "DREAM_MODEL",
|
|
30
33
|
introspection: "DREAM_MODEL",
|
|
31
34
|
enforcer: "ENFORCER_MODEL",
|
package/core/router.js
CHANGED
|
@@ -12,7 +12,7 @@ const { isChatAuthorized } = require("./access");
|
|
|
12
12
|
const introFlow = require("./intro-flow");
|
|
13
13
|
const { send, deleteMessage } = require("./io");
|
|
14
14
|
const { currentState, saveState, autoLinkOwnerChannel, getActiveProvider, getProjectKey } = require("./state");
|
|
15
|
-
const { admitRunContext, runAgent } = require("./runner");
|
|
15
|
+
const { admitRunContext, runAgent, startTypingHeartbeat } = require("./runner");
|
|
16
16
|
const { transcribeAudio, cloneVoiceFromClip } = require("./media");
|
|
17
17
|
const { vault } = require("./vault-store");
|
|
18
18
|
const { isOnboarded, handleOnboarding } = require("./onboarding");
|
|
@@ -203,9 +203,20 @@ async function admitGuardedTurn(askText, envelope, state = currentState()) {
|
|
|
203
203
|
return !(triage && triage.held);
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
// Chip-on-receipt: enforcer triage, media download, and STT all run before
|
|
207
|
+
// runAgent — many seconds with no feedback. Start the "thinking" heartbeat as
|
|
208
|
+
// soon as a turn-bound message arrives; the run's own heartbeat supersedes it
|
|
209
|
+
// at executeAdmittedRun, after which this stopper is a no-op. Skipped while a
|
|
210
|
+
// run is active — the chip is already owned by that turn.
|
|
211
|
+
function startPreTurnTyping(state) {
|
|
212
|
+
if (state.runningProcess || state.preparingRun) return () => {};
|
|
213
|
+
try { return startTypingHeartbeat(state); } catch (_) { return () => {}; }
|
|
214
|
+
}
|
|
215
|
+
|
|
206
216
|
async function handleVoice(envelope) {
|
|
207
217
|
if (isDuplicate(envelope)) return;
|
|
208
218
|
const state = currentState();
|
|
219
|
+
let stopPreTurnTyping = () => {};
|
|
209
220
|
try {
|
|
210
221
|
const media = envelope.media?.[0];
|
|
211
222
|
if (!media) return;
|
|
@@ -223,7 +234,7 @@ async function handleVoice(envelope) {
|
|
|
223
234
|
purpose: "foreground",
|
|
224
235
|
attachments: [{ type: "voice", mimeType: media.mimeType || "audio/ogg", fileName: media.fileName || null }],
|
|
225
236
|
});
|
|
226
|
-
|
|
237
|
+
stopPreTurnTyping = startPreTurnTyping(state);
|
|
227
238
|
const tStart = Date.now();
|
|
228
239
|
const oggPath = await envelope.adapter.downloadMedia(media);
|
|
229
240
|
const tAfterDownload = Date.now();
|
|
@@ -243,11 +254,13 @@ async function handleVoice(envelope) {
|
|
|
243
254
|
voiceStream: envelope.adapter?.type === "voice",
|
|
244
255
|
});
|
|
245
256
|
} catch (err) { await send(`Voice failed: ${err.message}`); }
|
|
257
|
+
finally { stopPreTurnTyping(); }
|
|
246
258
|
}
|
|
247
259
|
|
|
248
260
|
async function handleAudio(envelope) {
|
|
249
261
|
if (isDuplicate(envelope)) return;
|
|
250
262
|
const state = currentState();
|
|
263
|
+
let stopPreTurnTyping = () => {};
|
|
251
264
|
try {
|
|
252
265
|
const media = envelope.media?.[0];
|
|
253
266
|
if (!media) return;
|
|
@@ -260,7 +273,7 @@ async function handleAudio(envelope) {
|
|
|
260
273
|
purpose: "foreground",
|
|
261
274
|
attachments: [{ type: "audio", mimeType: media.mimeType || null, fileName: media.fileName || null }],
|
|
262
275
|
});
|
|
263
|
-
|
|
276
|
+
stopPreTurnTyping = startPreTurnTyping(state);
|
|
264
277
|
const p = await envelope.adapter.downloadMedia(media);
|
|
265
278
|
const t = await transcribeAudio(p);
|
|
266
279
|
try { fs.unlinkSync(p); } catch (e) {}
|
|
@@ -272,6 +285,7 @@ async function handleAudio(envelope) {
|
|
|
272
285
|
attachments: [{ type: "audio", path: p, mimeType: media.mimeType || null, fileName: media.fileName || null }],
|
|
273
286
|
});
|
|
274
287
|
} catch (err) { await send(`Audio failed: ${err.message}`); }
|
|
288
|
+
finally { stopPreTurnTyping(); }
|
|
275
289
|
}
|
|
276
290
|
|
|
277
291
|
// A clip the owner sent right after /voice clone. Upload it to the speech
|
|
@@ -297,6 +311,7 @@ async function captureVoiceClone(envelope, media) {
|
|
|
297
311
|
async function handlePhoto(envelope) {
|
|
298
312
|
if (isDuplicate(envelope)) return;
|
|
299
313
|
const state = currentState();
|
|
314
|
+
let stopPreTurnTyping = () => {};
|
|
300
315
|
try {
|
|
301
316
|
const items = envelope.media || [];
|
|
302
317
|
if (!items.length) return;
|
|
@@ -304,6 +319,7 @@ async function handlePhoto(envelope) {
|
|
|
304
319
|
purpose: "foreground",
|
|
305
320
|
attachments: items.map((item) => ({ type: "image", mimeType: item.mimeType || "image/*", fileName: item.fileName || null })),
|
|
306
321
|
});
|
|
322
|
+
stopPreTurnTyping = startPreTurnTyping(state);
|
|
307
323
|
const paths = [];
|
|
308
324
|
for (const m of items) paths.push(await envelope.adapter.downloadMedia(m));
|
|
309
325
|
const caption = envelope.caption || "Describe this image. If code/UI/error — explain and fix.";
|
|
@@ -327,11 +343,13 @@ async function handlePhoto(envelope) {
|
|
|
327
343
|
})),
|
|
328
344
|
});
|
|
329
345
|
} catch (err) { await send(`Image failed: ${err.message}`); }
|
|
346
|
+
finally { stopPreTurnTyping(); }
|
|
330
347
|
}
|
|
331
348
|
|
|
332
349
|
async function handleDocument(envelope) {
|
|
333
350
|
if (isDuplicate(envelope)) return;
|
|
334
351
|
const state = currentState();
|
|
352
|
+
let stopPreTurnTyping = () => {};
|
|
335
353
|
try {
|
|
336
354
|
const media = envelope.media?.[0];
|
|
337
355
|
if (!media) return;
|
|
@@ -342,6 +360,7 @@ async function handleDocument(envelope) {
|
|
|
342
360
|
purpose: "foreground",
|
|
343
361
|
attachments: [{ type: "document", mimeType: media.mimeType || null, fileName: media.fileName || null }],
|
|
344
362
|
});
|
|
363
|
+
stopPreTurnTyping = startPreTurnTyping(state);
|
|
345
364
|
const savePath = await envelope.adapter.downloadMedia(media);
|
|
346
365
|
const fileName = media.fileName || path.basename(savePath);
|
|
347
366
|
const mime = media.mimeType || "";
|
|
@@ -361,6 +380,7 @@ async function handleDocument(envelope) {
|
|
|
361
380
|
attachments: [{ type: isImage ? "image" : "document", path: savePath, mimeType: mime, fileName }],
|
|
362
381
|
});
|
|
363
382
|
} catch (err) { await send(`Failed: ${err.message}`); }
|
|
383
|
+
finally { stopPreTurnTyping(); }
|
|
364
384
|
}
|
|
365
385
|
|
|
366
386
|
// Provider-agnostic capture of a credential pasted into chat with no auth
|
|
@@ -605,7 +625,11 @@ async function handleText(envelope) {
|
|
|
605
625
|
// stop, instead of forwarding a secret to the model (Codex refuses those).
|
|
606
626
|
if (await captureLooseCredential(text.trim(), envelope)) return;
|
|
607
627
|
|
|
608
|
-
|
|
628
|
+
const stopPreTurnTyping = startPreTurnTyping(state);
|
|
629
|
+
let admitted;
|
|
630
|
+
try { admitted = await admitGuardedTurn(text, envelope, state); }
|
|
631
|
+
catch (e) { stopPreTurnTyping(); throw e; }
|
|
632
|
+
if (!admitted) { stopPreTurnTyping(); return; }
|
|
609
633
|
|
|
610
634
|
const runContext = admitRunContext(text, state.currentSession.dir, envelope.messageId, { purpose: "foreground" });
|
|
611
635
|
const attachments = [];
|
package/core/runner.js
CHANGED
|
@@ -1725,17 +1725,24 @@ function decorateForegroundInvocation(invocation, runContext, bundle) {
|
|
|
1725
1725
|
return { ...invocation, env };
|
|
1726
1726
|
}
|
|
1727
1727
|
|
|
1728
|
+
// Singleton per chat state: a new start supersedes any running heartbeat
|
|
1729
|
+
// (pre-turn "thinking" started at message receipt hands off to the run's own
|
|
1730
|
+
// heartbeat here). A stopper only acts while its own interval is current, so
|
|
1731
|
+
// a stale stopper from a superseded start can never kill a newer heartbeat.
|
|
1728
1732
|
function startTypingHeartbeat(state, store) {
|
|
1729
1733
|
const adapter = store?.adapter || currentAdapter();
|
|
1730
1734
|
const channelId = store?.channelId || currentChannelId();
|
|
1731
1735
|
if (!adapter || !channelId) return () => {};
|
|
1736
|
+
if (state.typingHeartbeat) clearInterval(state.typingHeartbeat);
|
|
1732
1737
|
const tick = () => adapter.typing(channelId, state.thinkingPhase ? "thinking" : undefined).catch(() => {});
|
|
1733
1738
|
state.thinkingPhase = true;
|
|
1734
1739
|
tick();
|
|
1735
|
-
|
|
1740
|
+
const interval = setInterval(tick, 4000);
|
|
1741
|
+
state.typingHeartbeat = interval;
|
|
1736
1742
|
return () => {
|
|
1743
|
+
if (state.typingHeartbeat !== interval) return;
|
|
1737
1744
|
state.thinkingPhase = false;
|
|
1738
|
-
|
|
1745
|
+
clearInterval(interval);
|
|
1739
1746
|
state.typingHeartbeat = null;
|
|
1740
1747
|
try { adapter.typingStop?.(channelId); } catch (_) {}
|
|
1741
1748
|
};
|
|
@@ -1880,7 +1887,10 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
|
|
|
1880
1887
|
observer.onRecallSurfaced(bundle.recallMetadata);
|
|
1881
1888
|
},
|
|
1882
1889
|
onEvent(event, runContext) {
|
|
1883
|
-
|
|
1890
|
+
// Streaming text reads as "typing"; tool work re-enters "thinking" so
|
|
1891
|
+
// the chip doesn't decay to a stale "typing" through long tool stretches.
|
|
1892
|
+
if (["text_delta", "text_final", "result", "error"].includes(event.type)) state.thinkingPhase = false;
|
|
1893
|
+
else if (event.type === "tool_start") state.thinkingPhase = true;
|
|
1884
1894
|
if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
|
|
1885
1895
|
const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
|
|
1886
1896
|
if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
|
|
@@ -2248,6 +2258,7 @@ module.exports = {
|
|
|
2248
2258
|
runAgent,
|
|
2249
2259
|
runAgentCapture,
|
|
2250
2260
|
runAgentSilent,
|
|
2261
|
+
startTypingHeartbeat,
|
|
2251
2262
|
shouldRetryRefusedRun,
|
|
2252
2263
|
quarantineRefusedRun,
|
|
2253
2264
|
refusalRetryContext,
|
package/core/scheduler.js
CHANGED
|
@@ -43,6 +43,10 @@ const RETRY_DELAY_MS = 30 * 1000;
|
|
|
43
43
|
const BUSY_DELAY_MS = 30 * 1000;
|
|
44
44
|
// A watch's `check` command is capped so a hung poll can't wedge the tick.
|
|
45
45
|
const WATCH_CHECK_TIMEOUT_MS = 30 * 1000;
|
|
46
|
+
// Every watch carries a hard expiry so none can idle-poll forever: when the
|
|
47
|
+
// deadline lapses with the check still failing, the next poll tick fires the
|
|
48
|
+
// wake anyway (marked expired) instead of skipping silently.
|
|
49
|
+
const DEFAULT_WATCH_TIMEOUT_MS = 2 * 60 * 60 * 1000;
|
|
46
50
|
const WATCH_LOG_FILE = process.env.OPEN_CLAUDIA_WATCH_LOG_FILE || path.join(CONFIG_DIR, "watch.log");
|
|
47
51
|
const WATCHES_ENABLED_FILE = process.env.OPEN_CLAUDIA_WATCHES_ENABLED_FILE || path.join(CONFIG_DIR, "watches-enabled");
|
|
48
52
|
|
|
@@ -107,6 +111,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
107
111
|
const retryDelayMs = Number.isFinite(dependencies.retryDelayMs) ? dependencies.retryDelayMs : RETRY_DELAY_MS;
|
|
108
112
|
const busyDelayMs = Number.isFinite(dependencies.busyDelayMs) ? dependencies.busyDelayMs : BUSY_DELAY_MS;
|
|
109
113
|
const missedGraceMs = Number.isFinite(dependencies.missedGraceMs) ? dependencies.missedGraceMs : MISSED_WAKEUP_GRACE_MS;
|
|
114
|
+
const watchTimeoutMs = Number.isFinite(dependencies.watchTimeoutMs) ? dependencies.watchTimeoutMs : DEFAULT_WATCH_TIMEOUT_MS;
|
|
110
115
|
const occurrenceIdFactory = dependencies.occurrenceIdFactory || (() => uniqueId("occ"));
|
|
111
116
|
const legacyCronsFile = dependencies.legacyCronsFile === undefined
|
|
112
117
|
? (dependencies.jobsStore ? null : CRONS_FILE)
|
|
@@ -273,22 +278,21 @@ function createSchedulerService(dependencies = {}) {
|
|
|
273
278
|
|
|
274
279
|
function wrappedPrompt(job, target) {
|
|
275
280
|
const firedAt = new Date(now()).toISOString();
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
281
|
+
const expired = !!(job.check && job.watchExpired);
|
|
282
|
+
const header = job.check
|
|
283
|
+
? (expired ? "Watch expired" : "Watch triggered")
|
|
284
|
+
: job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
|
|
285
|
+
const lines = [`[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`];
|
|
286
|
+
if (expired) {
|
|
287
|
+
lines.push("[This watch reached its timeout without its check ever passing — the watched condition did NOT happen. This is a deadline wake-up, not a trigger: investigate why the condition never became true, and arm a new watch if it is still worth waiting for.]");
|
|
283
288
|
}
|
|
284
|
-
if (target.
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
job.prompt,
|
|
289
|
-
].join("\n");
|
|
289
|
+
if (target.fallbackFrom) {
|
|
290
|
+
lines.push(`[Archived provider-neutral brief: the originating ${target.fallbackFrom} provider is unavailable. Its native session is not being resumed; this explicitly configured fallback starts fresh.]`);
|
|
291
|
+
} else if (target.switchedFrom) {
|
|
292
|
+
lines.push(`[Provider now tracks your current selection (${target.provider}); this was scheduled under ${target.switchedFrom}, whose native session cannot cross providers, so this run starts a fresh thread.]`);
|
|
290
293
|
}
|
|
291
|
-
|
|
294
|
+
lines.push(job.prompt);
|
|
295
|
+
return lines.join("\n");
|
|
292
296
|
}
|
|
293
297
|
|
|
294
298
|
function unscheduleJob(id) {
|
|
@@ -394,7 +398,7 @@ function createSchedulerService(dependencies = {}) {
|
|
|
394
398
|
|
|
395
399
|
async function fireJob(idOrJob, options = {}) {
|
|
396
400
|
const requested = typeof idOrJob === "string" ? jobsStore.get(idOrJob) : idOrJob;
|
|
397
|
-
|
|
401
|
+
let job = requested?.id && jobsStore.get(requested.id) ? jobsStore.get(requested.id) : requested;
|
|
398
402
|
if (!job) return { ok: false, status: "missing" };
|
|
399
403
|
if (job.disabled || job.status === "dead-letter" || job.status === "archived") {
|
|
400
404
|
return { ok: false, status: job.status, skipped: true };
|
|
@@ -424,10 +428,35 @@ function createSchedulerService(dependencies = {}) {
|
|
|
424
428
|
catch (_) { check = { code: 1, timedOut: false }; }
|
|
425
429
|
finally { checksInFlight.delete(job.id); }
|
|
426
430
|
if (!check || check.code !== 0) {
|
|
427
|
-
|
|
428
|
-
|
|
431
|
+
// Expiry backstop: a watch whose deadline has lapsed fires the wake
|
|
432
|
+
// anyway (marked expired) so no watch can ever end silently. Watches
|
|
433
|
+
// persisted before expiry existed (no expiresAt) keep polling forever.
|
|
434
|
+
const expiresAt = Number(job.expiresAt || 0);
|
|
435
|
+
if (!(expiresAt > 0 && now() >= expiresAt)) {
|
|
436
|
+
logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
|
|
437
|
+
return { ok: false, status: "watch-idle", skipped: true };
|
|
438
|
+
}
|
|
439
|
+
logWatch(job, `expired without firing (check exit ${check ? check.code : "?"}) — waking agent`);
|
|
440
|
+
// Expiry ends the watch's window: one-shot removal even for --repeat
|
|
441
|
+
// (the woken agent re-arms if still wanted). Persisted so a retry of a
|
|
442
|
+
// failed expiry fire keeps the same semantics across restarts.
|
|
443
|
+
try {
|
|
444
|
+
const marked = jobsStore.update(job.id, { repeat: false, watchExpired: true });
|
|
445
|
+
if (marked) job = marked;
|
|
446
|
+
else { job.repeat = false; job.watchExpired = true; }
|
|
447
|
+
} catch (_) { job.repeat = false; job.watchExpired = true; }
|
|
448
|
+
} else {
|
|
449
|
+
logWatch(job, "triggered (check passed) — waking agent");
|
|
450
|
+
if (job.watchExpired) {
|
|
451
|
+
// A prior expiry fire failed and the condition came true before the
|
|
452
|
+
// retry: report it as a real trigger, not an expiry.
|
|
453
|
+
try {
|
|
454
|
+
const cleared = jobsStore.update(job.id, { watchExpired: false });
|
|
455
|
+
if (cleared) job = cleared;
|
|
456
|
+
else job.watchExpired = false;
|
|
457
|
+
} catch (_) { job.watchExpired = false; }
|
|
458
|
+
}
|
|
429
459
|
}
|
|
430
|
-
logWatch(job, "triggered (check passed) — waking agent");
|
|
431
460
|
}
|
|
432
461
|
|
|
433
462
|
const adapter = resolveAdapter(job);
|
|
@@ -504,7 +533,10 @@ function createSchedulerService(dependencies = {}) {
|
|
|
504
533
|
return { ok: false, status: "failed", error: stableFailure(error) };
|
|
505
534
|
}
|
|
506
535
|
|
|
507
|
-
|
|
536
|
+
const advisory = job.check
|
|
537
|
+
? (job.watchExpired ? `⏰ Watch "${job.label}" timed out without triggering — waking anyway` : `🔔 Watch "${job.label}" triggered`)
|
|
538
|
+
: `Wakeup: ${job.label}`;
|
|
539
|
+
try { await send(advisory); } catch (_) { /* advisory only */ }
|
|
508
540
|
let result;
|
|
509
541
|
try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
|
|
510
542
|
catch (error) {
|
|
@@ -655,15 +687,21 @@ function createSchedulerService(dependencies = {}) {
|
|
|
655
687
|
}
|
|
656
688
|
|
|
657
689
|
// A watch is a cron carrying a shell `check`; it rides the migration-verified
|
|
658
|
-
// jobs store untouched (check/repeat survive normalizeRunnableJob
|
|
659
|
-
// fields). Default schedule polls every minute; default is one-shot.
|
|
690
|
+
// jobs store untouched (check/repeat/expiresAt survive normalizeRunnableJob
|
|
691
|
+
// as extra fields). Default schedule polls every minute; default is one-shot.
|
|
692
|
+
// Every new watch gets an expiry (caller's expiresAt or the default timeout)
|
|
693
|
+
// so it can never idle-poll silently forever.
|
|
660
694
|
function addWatch(spec) {
|
|
695
|
+
const requestedExpiry = Number(spec.expiresAt);
|
|
661
696
|
return addJob({
|
|
662
697
|
...spec,
|
|
663
698
|
kind: "cron",
|
|
664
699
|
schedule: spec.schedule || "* * * * *",
|
|
665
700
|
check: String(spec.check || "").trim(),
|
|
666
701
|
repeat: !!spec.repeat,
|
|
702
|
+
expiresAt: Number.isFinite(requestedExpiry) && requestedExpiry > 0
|
|
703
|
+
? requestedExpiry
|
|
704
|
+
: now() + watchTimeoutMs,
|
|
667
705
|
label: spec.label || String(spec.prompt || "watch").slice(0, 60),
|
|
668
706
|
});
|
|
669
707
|
}
|
|
@@ -710,6 +748,7 @@ module.exports = {
|
|
|
710
748
|
MISSED_WAKEUP_GRACE_MS,
|
|
711
749
|
RETRY_DELAY_MS,
|
|
712
750
|
BUSY_DELAY_MS,
|
|
751
|
+
DEFAULT_WATCH_TIMEOUT_MS,
|
|
713
752
|
createSchedulerService,
|
|
714
753
|
parseFallbacks,
|
|
715
754
|
terminalRunSucceeded,
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// socket.io-client permanently stops reconnecting when the server REJECTS a
|
|
4
|
+
// handshake (auth middleware next(new Error())): connect_error fires with
|
|
5
|
+
// socket.active === false and the manager never dials again. During the
|
|
6
|
+
// 2026-08-08 outage chat-central booted before Central's MongoDB and rejected
|
|
7
|
+
// every handshake for a few minutes, terminally stranding the whole bot fleet.
|
|
8
|
+
// This wires a manual connect() retry with capped backoff so a rejected
|
|
9
|
+
// handshake is never terminal. A deliberate client-side disconnect() cancels
|
|
10
|
+
// any pending retry and stays final until connect() is called again.
|
|
11
|
+
|
|
12
|
+
const DEFAULT_INITIAL_DELAY_MS = 2000;
|
|
13
|
+
const DEFAULT_MAX_DELAY_MS = 30000;
|
|
14
|
+
|
|
15
|
+
function wireTerminalReconnect(socket, opts = {}) {
|
|
16
|
+
const label = opts.label || "socket";
|
|
17
|
+
const log = opts.log || ((msg) => console.error(msg));
|
|
18
|
+
const initialDelay = opts.initialDelayMs || DEFAULT_INITIAL_DELAY_MS;
|
|
19
|
+
const maxDelay = opts.maxDelayMs || DEFAULT_MAX_DELAY_MS;
|
|
20
|
+
|
|
21
|
+
let attempts = 0;
|
|
22
|
+
let timer = null;
|
|
23
|
+
let stopped = false;
|
|
24
|
+
|
|
25
|
+
const cancel = () => {
|
|
26
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const schedule = (why) => {
|
|
30
|
+
if (stopped || timer) return;
|
|
31
|
+
const delay = Math.min(initialDelay * 2 ** attempts, maxDelay);
|
|
32
|
+
attempts += 1;
|
|
33
|
+
log(`${label}: ${why} — socket.io will not retry on its own; manual connect() in ${Math.ceil(delay / 1000)}s (attempt ${attempts})`);
|
|
34
|
+
timer = setTimeout(() => {
|
|
35
|
+
timer = null;
|
|
36
|
+
if (stopped || socket.connected || socket.active) return;
|
|
37
|
+
try { socket.connect(); }
|
|
38
|
+
catch (e) { schedule(`reconnect attempt threw (${e.message})`); }
|
|
39
|
+
}, delay);
|
|
40
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
socket.on("connect", () => { attempts = 0; cancel(); });
|
|
44
|
+
socket.on("connect_error", (err) => {
|
|
45
|
+
if (socket.active) return; // transport failure: the manager auto-retries
|
|
46
|
+
schedule(`handshake rejected (${err && err.message ? err.message : err})`);
|
|
47
|
+
});
|
|
48
|
+
socket.on("disconnect", (reason) => {
|
|
49
|
+
if (reason === "io client disconnect") { cancel(); return; }
|
|
50
|
+
if (socket.active) return; // the manager auto-reconnects
|
|
51
|
+
schedule(`disconnected (${reason})`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// disconnect() while wedged emits no event, so intercept the call itself;
|
|
55
|
+
// connect() re-arms retries for the adapter-restart path.
|
|
56
|
+
const origDisconnect = socket.disconnect;
|
|
57
|
+
socket.disconnect = function (...args) {
|
|
58
|
+
stopped = true;
|
|
59
|
+
cancel();
|
|
60
|
+
return origDisconnect.apply(this, args);
|
|
61
|
+
};
|
|
62
|
+
const origConnect = socket.connect;
|
|
63
|
+
socket.connect = function (...args) {
|
|
64
|
+
stopped = false;
|
|
65
|
+
return origConnect.apply(this, args);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return socket;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { wireTerminalReconnect, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS };
|