@iletai/nzb 1.7.3 → 1.7.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/dist/api/server.js +23 -1
- package/dist/copilot/orchestrator.js +126 -92
- package/dist/copilot/tools.js +16 -7
- package/dist/store/db.js +1 -0
- package/dist/telegram/bot.js +8 -0
- package/package.json +1 -1
package/dist/api/server.js
CHANGED
|
@@ -77,8 +77,23 @@ app.get("/stream", (req, res) => {
|
|
|
77
77
|
sseClients.set(connectionId, res);
|
|
78
78
|
// Heartbeat to keep connection alive
|
|
79
79
|
const heartbeat = setInterval(() => {
|
|
80
|
-
res.
|
|
80
|
+
if (res.writableEnded || res.closed) {
|
|
81
|
+
clearInterval(heartbeat);
|
|
82
|
+
sseClients.delete(connectionId);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
res.write(`:ping\n\n`);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
clearInterval(heartbeat);
|
|
90
|
+
sseClients.delete(connectionId);
|
|
91
|
+
}
|
|
81
92
|
}, 20_000);
|
|
93
|
+
res.on("error", () => {
|
|
94
|
+
clearInterval(heartbeat);
|
|
95
|
+
sseClients.delete(connectionId);
|
|
96
|
+
});
|
|
82
97
|
req.on("close", () => {
|
|
83
98
|
clearInterval(heartbeat);
|
|
84
99
|
sseClients.delete(connectionId);
|
|
@@ -196,6 +211,13 @@ app.post("/send-photo", async (req, res) => {
|
|
|
196
211
|
res.status(500).json({ error: msg });
|
|
197
212
|
}
|
|
198
213
|
});
|
|
214
|
+
// Global error handler — catch unhandled Express errors
|
|
215
|
+
app.use((err, _req, res, _next) => {
|
|
216
|
+
console.error("[nzb] Express error:", err.message);
|
|
217
|
+
if (!res.headersSent) {
|
|
218
|
+
res.status(500).json({ error: "Internal server error" });
|
|
219
|
+
}
|
|
220
|
+
});
|
|
199
221
|
export function startApiServer() {
|
|
200
222
|
return new Promise((resolve, reject) => {
|
|
201
223
|
const server = app.listen(config.apiPort, "127.0.0.1", () => {
|
|
@@ -155,6 +155,8 @@ function startHealthCheck() {
|
|
|
155
155
|
return;
|
|
156
156
|
if (healthCheckRunning)
|
|
157
157
|
return;
|
|
158
|
+
if (processing)
|
|
159
|
+
return; // Don't interfere while processing messages
|
|
158
160
|
healthCheckRunning = true;
|
|
159
161
|
try {
|
|
160
162
|
const state = copilotClient.getState();
|
|
@@ -188,17 +190,17 @@ export function stopHealthCheck() {
|
|
|
188
190
|
function startWorkerReaper() {
|
|
189
191
|
if (workerReaperTimer)
|
|
190
192
|
return;
|
|
191
|
-
workerReaperTimer = setInterval(() => {
|
|
193
|
+
workerReaperTimer = setInterval(async () => {
|
|
192
194
|
const maxAge = config.workerTimeoutMs * 2;
|
|
193
195
|
const now = Date.now();
|
|
194
196
|
for (const [name, worker] of workers) {
|
|
195
197
|
if (worker.startedAt && now - worker.startedAt > maxAge) {
|
|
196
198
|
console.log(`[nzb] Reaping stuck worker '${name}' (age: ${formatAge(worker.startedAt)})`);
|
|
197
199
|
try {
|
|
198
|
-
worker.session.disconnect()
|
|
200
|
+
await withTimeout(worker.session.disconnect(), 5_000, `reaper: worker '${name}'`);
|
|
199
201
|
}
|
|
200
|
-
catch {
|
|
201
|
-
|
|
202
|
+
catch (err) {
|
|
203
|
+
console.error(`[nzb] Reaper: worker '${name}' disconnect failed:`, err instanceof Error ? err.message : err);
|
|
202
204
|
}
|
|
203
205
|
workers.delete(name);
|
|
204
206
|
feedBackgroundResult(name, `⚠ Worker '${name}' was automatically killed after exceeding timeout.`);
|
|
@@ -522,7 +524,10 @@ async function processQueue() {
|
|
|
522
524
|
}
|
|
523
525
|
// Re-check for messages that arrived during the last executeOnSession call
|
|
524
526
|
if (messageQueue.length > 0) {
|
|
525
|
-
|
|
527
|
+
processQueue().catch((err) => {
|
|
528
|
+
console.error("[nzb] processQueue re-check failed:", err instanceof Error ? err.message : err);
|
|
529
|
+
processing = false;
|
|
530
|
+
});
|
|
526
531
|
}
|
|
527
532
|
}
|
|
528
533
|
function isRecoverableError(err) {
|
|
@@ -560,104 +565,133 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
|
|
|
560
565
|
const sourceChannel = source.type === "telegram" ? "telegram" : source.type === "tui" ? "tui" : undefined;
|
|
561
566
|
// Enqueue with priority — user messages go before background messages
|
|
562
567
|
void (async () => {
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
568
|
+
// Safety timeout for entire message processing chain.
|
|
569
|
+
// Uses a flag to prevent double-callback if timeout fires while processing completes.
|
|
570
|
+
const GLOBAL_MSG_TIMEOUT_MS = 300_000; // 5 minutes
|
|
571
|
+
let globalTimedOut = false;
|
|
572
|
+
const globalTimer = setTimeout(() => {
|
|
573
|
+
globalTimedOut = true;
|
|
574
|
+
console.error("[nzb] Global message processing timeout (5 min). Force-failing.");
|
|
575
|
+
Promise.resolve(callback("Error: Message processing timed out after 5 minutes. Please try again.", true)).catch(() => { });
|
|
576
|
+
}, GLOBAL_MSG_TIMEOUT_MS);
|
|
577
|
+
try {
|
|
578
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
579
|
+
try {
|
|
580
|
+
const finalContent = await new Promise((resolve, reject) => {
|
|
581
|
+
const item = {
|
|
582
|
+
prompt: taggedPrompt,
|
|
583
|
+
attachments,
|
|
584
|
+
callback,
|
|
585
|
+
onToolEvent,
|
|
586
|
+
onUsage,
|
|
587
|
+
sourceChannel,
|
|
588
|
+
resolve,
|
|
589
|
+
reject,
|
|
590
|
+
};
|
|
591
|
+
if (source.type === "background") {
|
|
592
|
+
// Background results go to the back of the queue
|
|
593
|
+
messageQueue.push(item);
|
|
585
594
|
}
|
|
586
595
|
else {
|
|
587
|
-
|
|
596
|
+
// User messages inserted before any background messages (priority)
|
|
597
|
+
const bgIndex = messageQueue.findIndex(isBackgroundMessage);
|
|
598
|
+
if (bgIndex >= 0) {
|
|
599
|
+
messageQueue.splice(bgIndex, 0, item);
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
messageQueue.push(item);
|
|
603
|
+
}
|
|
588
604
|
}
|
|
605
|
+
processQueue();
|
|
606
|
+
});
|
|
607
|
+
// Deliver response to user FIRST, then log best-effort
|
|
608
|
+
try {
|
|
609
|
+
logMessage("out", sourceLabel, finalContent);
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
/* best-effort */
|
|
613
|
+
}
|
|
614
|
+
// Log both sides of the conversation before delivery so we have the row ID
|
|
615
|
+
let assistantLogId;
|
|
616
|
+
try {
|
|
617
|
+
const telegramMsgId = source.type === "telegram" ? source.messageId : undefined;
|
|
618
|
+
logConversation(logRole, prompt, sourceLabel, telegramMsgId);
|
|
619
|
+
}
|
|
620
|
+
catch {
|
|
621
|
+
/* best-effort */
|
|
589
622
|
}
|
|
590
|
-
processQueue();
|
|
591
|
-
});
|
|
592
|
-
// Deliver response to user FIRST, then log best-effort
|
|
593
|
-
try {
|
|
594
|
-
logMessage("out", sourceLabel, finalContent);
|
|
595
|
-
}
|
|
596
|
-
catch {
|
|
597
|
-
/* best-effort */
|
|
598
|
-
}
|
|
599
|
-
// Log both sides of the conversation before delivery so we have the row ID
|
|
600
|
-
let assistantLogId;
|
|
601
|
-
try {
|
|
602
|
-
const telegramMsgId = source.type === "telegram" ? source.messageId : undefined;
|
|
603
|
-
logConversation(logRole, prompt, sourceLabel, telegramMsgId);
|
|
604
|
-
}
|
|
605
|
-
catch {
|
|
606
|
-
/* best-effort */
|
|
607
|
-
}
|
|
608
|
-
try {
|
|
609
|
-
assistantLogId = logConversation("assistant", finalContent, sourceLabel);
|
|
610
|
-
}
|
|
611
|
-
catch {
|
|
612
|
-
/* best-effort */
|
|
613
|
-
}
|
|
614
|
-
await callback(finalContent, true, { assistantLogId });
|
|
615
|
-
// Auto-continue: if the response was cut short by timeout, automatically
|
|
616
|
-
// send a follow-up "Continue" message so the user doesn't have to
|
|
617
|
-
if (finalContent.includes("⏱ Response was cut short (timeout)") && _autoContinueCount < MAX_AUTO_CONTINUE) {
|
|
618
|
-
console.log(`[nzb] Auto-continuing after timeout (${_autoContinueCount + 1}/${MAX_AUTO_CONTINUE})…`);
|
|
619
|
-
await sleep(1000);
|
|
620
|
-
void sendToOrchestrator("Continue from where you left off. Do not repeat what was already said.", source, callback, onToolEvent, onUsage, _autoContinueCount + 1);
|
|
621
|
-
}
|
|
622
|
-
return;
|
|
623
|
-
}
|
|
624
|
-
catch (err) {
|
|
625
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
626
|
-
// Don't retry cancelled messages
|
|
627
|
-
if (/cancelled|abort/i.test(msg)) {
|
|
628
|
-
return;
|
|
629
|
-
}
|
|
630
|
-
// Vision not supported — strip attachments and retry with text-only prompt.
|
|
631
|
-
// executeOnSession already destroyed the tainted session.
|
|
632
|
-
if (/not supported for vision/i.test(msg)) {
|
|
633
|
-
console.log(`[nzb] Vision not supported — retrying without attachments`);
|
|
634
|
-
attachments = undefined;
|
|
635
|
-
taggedPrompt =
|
|
636
|
-
`[System: The current model '${config.copilotModel}' does not support image/vision analysis. ` +
|
|
637
|
-
`The image path is already included in the user's message below. ` +
|
|
638
|
-
`Please inform the user that the current model doesn't support direct image analysis, ` +
|
|
639
|
-
`and suggest switching to a vision-capable model (e.g. gpt-4o, claude-sonnet-4, gemini-2.0-flash) ` +
|
|
640
|
-
`using the /model command.]\n\n${taggedPrompt}`;
|
|
641
|
-
continue;
|
|
642
|
-
}
|
|
643
|
-
if (isRecoverableError(err) && attempt < MAX_RETRIES) {
|
|
644
|
-
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
645
|
-
console.error(`[nzb] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
|
|
646
|
-
await sleep(delay);
|
|
647
|
-
// Reset client before retry in case the connection is stale
|
|
648
623
|
try {
|
|
649
|
-
|
|
624
|
+
assistantLogId = logConversation("assistant", finalContent, sourceLabel);
|
|
650
625
|
}
|
|
651
626
|
catch {
|
|
652
|
-
/*
|
|
627
|
+
/* best-effort */
|
|
653
628
|
}
|
|
654
|
-
|
|
629
|
+
try {
|
|
630
|
+
if (!globalTimedOut) {
|
|
631
|
+
await callback(finalContent, true, { assistantLogId });
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
catch (callbackErr) {
|
|
635
|
+
console.error("[nzb] Callback error after successful response:", callbackErr instanceof Error ? callbackErr.message : callbackErr);
|
|
636
|
+
}
|
|
637
|
+
// Auto-continue: if the response was cut short by timeout, automatically
|
|
638
|
+
// send a follow-up "Continue" message so the user doesn't have to
|
|
639
|
+
if (finalContent.includes("⏱ Response was cut short (timeout)") && _autoContinueCount < MAX_AUTO_CONTINUE) {
|
|
640
|
+
console.log(`[nzb] Auto-continuing after timeout (${_autoContinueCount + 1}/${MAX_AUTO_CONTINUE})…`);
|
|
641
|
+
await sleep(1000);
|
|
642
|
+
void sendToOrchestrator("Continue from where you left off. Do not repeat what was already said.", source, callback, onToolEvent, onUsage, _autoContinueCount + 1);
|
|
643
|
+
}
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
catch (err) {
|
|
647
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
648
|
+
// Don't retry cancelled messages
|
|
649
|
+
if (/cancelled|abort/i.test(msg)) {
|
|
650
|
+
if (!globalTimedOut) {
|
|
651
|
+
try {
|
|
652
|
+
await callback("Request was cancelled.", true);
|
|
653
|
+
}
|
|
654
|
+
catch { /* best-effort */ }
|
|
655
|
+
}
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
// Vision not supported — strip attachments and retry with text-only prompt.
|
|
659
|
+
// executeOnSession already destroyed the tainted session.
|
|
660
|
+
if (/not supported for vision/i.test(msg)) {
|
|
661
|
+
console.log(`[nzb] Vision not supported — retrying without attachments`);
|
|
662
|
+
attachments = undefined;
|
|
663
|
+
taggedPrompt =
|
|
664
|
+
`[System: The current model '${config.copilotModel}' does not support image/vision analysis. ` +
|
|
665
|
+
`The image path is already included in the user's message below. ` +
|
|
666
|
+
`Please inform the user that the current model doesn't support direct image analysis, ` +
|
|
667
|
+
`and suggest switching to a vision-capable model (e.g. gpt-4o, claude-sonnet-4, gemini-2.0-flash) ` +
|
|
668
|
+
`using the /model command.]\n\n${taggedPrompt}`;
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
if (isRecoverableError(err) && attempt < MAX_RETRIES) {
|
|
672
|
+
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
673
|
+
console.error(`[nzb] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
|
|
674
|
+
await sleep(delay);
|
|
675
|
+
// Reset client before retry in case the connection is stale
|
|
676
|
+
try {
|
|
677
|
+
await ensureClient();
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
/* will fail again on next attempt */
|
|
681
|
+
}
|
|
682
|
+
continue;
|
|
683
|
+
}
|
|
684
|
+
console.error(`[nzb] Error processing message: ${msg}`);
|
|
685
|
+
if (!globalTimedOut) {
|
|
686
|
+
await callback(`Error: ${msg}`, true);
|
|
687
|
+
}
|
|
688
|
+
return;
|
|
655
689
|
}
|
|
656
|
-
console.error(`[nzb] Error processing message: ${msg}`);
|
|
657
|
-
await callback(`Error: ${msg}`, true);
|
|
658
|
-
return;
|
|
659
690
|
}
|
|
660
691
|
}
|
|
692
|
+
finally {
|
|
693
|
+
clearTimeout(globalTimer);
|
|
694
|
+
}
|
|
661
695
|
})().catch((err) => {
|
|
662
696
|
console.error(`[nzb] Unhandled error in sendToOrchestrator: ${err instanceof Error ? err.message : String(err)}`);
|
|
663
697
|
});
|
package/dist/copilot/tools.js
CHANGED
|
@@ -6,6 +6,7 @@ import { z } from "zod";
|
|
|
6
6
|
import { config, persistModel } from "../config.js";
|
|
7
7
|
import { SESSIONS_DIR } from "../paths.js";
|
|
8
8
|
import { getDb } from "../store/db.js";
|
|
9
|
+
import { withTimeout } from "../utils.js";
|
|
9
10
|
import { addMemory, removeMemory, searchMemories } from "../store/memory.js";
|
|
10
11
|
import { createSkill, listSkills, removeSkill } from "./skills.js";
|
|
11
12
|
function isTimeoutError(err) {
|
|
@@ -111,7 +112,9 @@ export function createTools(deps) {
|
|
|
111
112
|
})
|
|
112
113
|
.finally(() => {
|
|
113
114
|
// Auto-destroy background workers after completion to free memory (~400MB per worker)
|
|
114
|
-
session.disconnect().catch(() => {
|
|
115
|
+
withTimeout(session.disconnect(), 5_000, `worker '${args.name}' disconnect`).catch((err) => {
|
|
116
|
+
console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
|
|
117
|
+
});
|
|
115
118
|
deps.workers.delete(args.name);
|
|
116
119
|
try {
|
|
117
120
|
getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
|
|
@@ -162,7 +165,9 @@ export function createTools(deps) {
|
|
|
162
165
|
})
|
|
163
166
|
.finally(() => {
|
|
164
167
|
// Auto-destroy after each send_to_worker dispatch to free memory
|
|
165
|
-
worker.session.disconnect().catch(() => {
|
|
168
|
+
withTimeout(worker.session.disconnect(), 5_000, `worker '${args.name}' disconnect`).catch((err) => {
|
|
169
|
+
console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
|
|
170
|
+
});
|
|
166
171
|
deps.workers.delete(args.name);
|
|
167
172
|
try {
|
|
168
173
|
getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
|
|
@@ -210,10 +215,10 @@ export function createTools(deps) {
|
|
|
210
215
|
return `No worker named '${args.name}'.`;
|
|
211
216
|
}
|
|
212
217
|
try {
|
|
213
|
-
await worker.session.disconnect();
|
|
218
|
+
await withTimeout(worker.session.disconnect(), 5_000, `worker '${args.name}' disconnect`);
|
|
214
219
|
}
|
|
215
|
-
catch {
|
|
216
|
-
|
|
220
|
+
catch (err) {
|
|
221
|
+
console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
|
|
217
222
|
}
|
|
218
223
|
deps.workers.delete(args.name);
|
|
219
224
|
const db = getDb();
|
|
@@ -232,7 +237,9 @@ export function createTools(deps) {
|
|
|
232
237
|
if (!worker)
|
|
233
238
|
return `No worker found with name '${args.name}'.`;
|
|
234
239
|
try {
|
|
235
|
-
worker.session.disconnect().catch(() => {
|
|
240
|
+
withTimeout(worker.session.disconnect(), 5_000, `worker '${args.name}' disconnect`).catch((err) => {
|
|
241
|
+
console.error(`[nzb] Worker '${args.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
|
|
242
|
+
});
|
|
236
243
|
}
|
|
237
244
|
catch {
|
|
238
245
|
// Session may already be destroyed
|
|
@@ -347,7 +354,9 @@ export function createTools(deps) {
|
|
|
347
354
|
deps.onWorkerComplete(member.name, errMsg);
|
|
348
355
|
})
|
|
349
356
|
.finally(() => {
|
|
350
|
-
session.disconnect().catch(() => {
|
|
357
|
+
withTimeout(session.disconnect(), 5_000, `worker '${member.name}' disconnect`).catch((err) => {
|
|
358
|
+
console.error(`[nzb] Worker '${member.name}' disconnect timeout/error:`, err instanceof Error ? err.message : err);
|
|
359
|
+
});
|
|
351
360
|
deps.workers.delete(member.name);
|
|
352
361
|
try {
|
|
353
362
|
getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(member.name);
|
package/dist/store/db.js
CHANGED
package/dist/telegram/bot.js
CHANGED
|
@@ -25,6 +25,8 @@ const startedAt = Date.now();
|
|
|
25
25
|
const INITIAL_POLL_RETRY_DELAY = 5000;
|
|
26
26
|
const MAX_POLL_RETRY_DELAY = 300_000; // 5 minutes
|
|
27
27
|
let pollRetryDelay = INITIAL_POLL_RETRY_DELAY;
|
|
28
|
+
let consecutivePollingFailures = 0;
|
|
29
|
+
const MAX_CONSECUTIVE_POLLING_FAILURES = 10;
|
|
28
30
|
// Direct-connection HTTPS agent for Telegram API requests.
|
|
29
31
|
// This bypasses corporate proxy (HTTP_PROXY/HTTPS_PROXY env vars) without
|
|
30
32
|
// modifying process.env, so other services (Copilot SDK, MCP, npm) are unaffected.
|
|
@@ -177,15 +179,21 @@ export async function startBot() {
|
|
|
177
179
|
...(savedOffset ? { offset: savedOffset + 1 } : {}),
|
|
178
180
|
onStart: () => {
|
|
179
181
|
console.log("[nzb] Telegram bot connected");
|
|
182
|
+
consecutivePollingFailures = 0;
|
|
180
183
|
pollRetryDelay = INITIAL_POLL_RETRY_DELAY;
|
|
181
184
|
void logInfo(`🚀 NZB v${process.env.npm_package_version || "?"} started (model: ${config.copilotModel})`);
|
|
182
185
|
},
|
|
183
186
|
})
|
|
184
187
|
.catch(async (err) => {
|
|
188
|
+
consecutivePollingFailures++;
|
|
185
189
|
if (err?.error_code === 401) {
|
|
186
190
|
console.error("[nzb] Warning: Telegram bot token is invalid or expired. Run 'nzb setup' and re-enter your bot token from @BotFather.");
|
|
187
191
|
return; // Unrecoverable — don't retry
|
|
188
192
|
}
|
|
193
|
+
if (consecutivePollingFailures >= MAX_CONSECUTIVE_POLLING_FAILURES) {
|
|
194
|
+
console.error(`[nzb] Telegram polling failed ${consecutivePollingFailures} consecutive times. Stopping retry attempts. Restart NZB to try again.`);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
189
197
|
if (err?.error_code === 409) {
|
|
190
198
|
console.error(`[nzb] Warning: Telegram polling conflict (409). Restarting polling in ${pollRetryDelay / 1000}s...`);
|
|
191
199
|
}
|