@iletai/nzb 1.6.4 → 1.7.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/dist/api/server.js +14 -5
- package/dist/cli.js +1 -0
- package/dist/config.js +14 -2
- package/dist/copilot/client.js +17 -17
- package/dist/copilot/mcp-config.js +2 -0
- package/dist/copilot/orchestrator.js +192 -55
- package/dist/copilot/skills.js +4 -2
- package/dist/copilot/tools.js +39 -16
- package/dist/copilot/types.js +2 -0
- package/dist/daemon.js +13 -12
- package/dist/setup.js +3 -2
- package/dist/store/conversation.js +96 -0
- package/dist/store/db.js +6 -206
- package/dist/store/memory.js +90 -0
- package/dist/store/team-store.js +51 -0
- package/dist/telegram/bot.js +77 -8
- package/dist/telegram/handlers/commands.js +1 -1
- package/dist/telegram/handlers/media.js +62 -10
- package/dist/telegram/handlers/streaming.js +252 -207
- package/dist/telegram/handlers/suggestions.js +22 -1
- package/dist/telegram/log-channel.js +2 -2
- package/dist/telegram/menus.js +247 -91
- package/dist/tui/ansi.js +19 -0
- package/dist/tui/api-client.js +158 -0
- package/dist/tui/debug.js +27 -0
- package/dist/tui/renderer.js +59 -0
- package/dist/tui/stream.js +163 -0
- package/dist/update.js +2 -0
- package/dist/utils.js +102 -0
- package/package.json +3 -3
package/dist/api/server.js
CHANGED
|
@@ -6,7 +6,7 @@ import { cancelCurrentMessage, getWorkers, sendToOrchestrator } from "../copilot
|
|
|
6
6
|
import { listSkills, removeSkill } from "../copilot/skills.js";
|
|
7
7
|
import { restartDaemon } from "../daemon.js";
|
|
8
8
|
import { API_TOKEN_PATH, ensureNZBHome } from "../paths.js";
|
|
9
|
-
import { searchMemories } from "../store/
|
|
9
|
+
import { searchMemories } from "../store/memory.js";
|
|
10
10
|
import { sendPhoto } from "../telegram/bot.js";
|
|
11
11
|
// Ensure token file exists (generate on first run)
|
|
12
12
|
let apiToken = null;
|
|
@@ -26,9 +26,9 @@ catch (err) {
|
|
|
26
26
|
}
|
|
27
27
|
const app = express();
|
|
28
28
|
app.use(express.json());
|
|
29
|
-
// Bearer token authentication middleware (skip /
|
|
29
|
+
// Bearer token authentication middleware (skip /ping health check only)
|
|
30
30
|
app.use((req, res, next) => {
|
|
31
|
-
if (!apiToken || req.path === "/
|
|
31
|
+
if (!apiToken || req.path === "/ping")
|
|
32
32
|
return next();
|
|
33
33
|
const auth = req.headers.authorization;
|
|
34
34
|
if (!auth || auth !== `Bearer ${apiToken}`) {
|
|
@@ -40,7 +40,11 @@ app.use((req, res, next) => {
|
|
|
40
40
|
// Active SSE connections
|
|
41
41
|
const sseClients = new Map();
|
|
42
42
|
let connectionCounter = 0;
|
|
43
|
-
//
|
|
43
|
+
// Minimal unauthenticated health check — no internal details
|
|
44
|
+
app.get("/ping", (_req, res) => {
|
|
45
|
+
res.json({ status: "ok" });
|
|
46
|
+
});
|
|
47
|
+
// Authenticated status with worker details
|
|
44
48
|
app.get("/status", (_req, res) => {
|
|
45
49
|
res.json({
|
|
46
50
|
status: "ok",
|
|
@@ -175,7 +179,12 @@ app.post("/restart", (_req, res) => {
|
|
|
175
179
|
app.post("/send-photo", async (req, res) => {
|
|
176
180
|
const { photo, caption } = req.body;
|
|
177
181
|
if (!photo || typeof photo !== "string") {
|
|
178
|
-
res.status(400).json({ error: "Missing 'photo' (file path or URL) in request body" });
|
|
182
|
+
res.status(400).json({ error: "Missing 'photo' (file path or HTTPS URL) in request body" });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
// Basic input validation before passing to sendPhoto
|
|
186
|
+
if (photo.startsWith("http://")) {
|
|
187
|
+
res.status(400).json({ error: "Only HTTPS URLs are allowed for photos" });
|
|
179
188
|
return;
|
|
180
189
|
}
|
|
181
190
|
try {
|
package/dist/cli.js
CHANGED
package/dist/config.js
CHANGED
|
@@ -15,6 +15,7 @@ const configSchema = z.object({
|
|
|
15
15
|
LOG_CHANNEL_ID: z.string().optional(),
|
|
16
16
|
NODE_EXTRA_CA_CERTS: z.string().optional(),
|
|
17
17
|
OPENAI_API_KEY: z.string().optional(),
|
|
18
|
+
REASONING_EFFORT: z.string().optional(),
|
|
18
19
|
});
|
|
19
20
|
const raw = configSchema.parse(process.env);
|
|
20
21
|
// Apply NODE_EXTRA_CA_CERTS from .env if not already set via environment.
|
|
@@ -37,6 +38,14 @@ if (!Number.isInteger(parsedWorkerTimeout) || parsedWorkerTimeout <= 0) {
|
|
|
37
38
|
}
|
|
38
39
|
const parsedLogChannelId = raw.LOG_CHANNEL_ID ? raw.LOG_CHANNEL_ID.trim() : undefined;
|
|
39
40
|
export const DEFAULT_MODEL = "claude-sonnet-4.6";
|
|
41
|
+
function validateEnum(value, validValues, defaultValue, name) {
|
|
42
|
+
if (!value)
|
|
43
|
+
return defaultValue;
|
|
44
|
+
if (validValues.includes(value))
|
|
45
|
+
return value;
|
|
46
|
+
console.log(`[nzb] Invalid ${name} value "${value}", using default "${defaultValue}"`);
|
|
47
|
+
return defaultValue;
|
|
48
|
+
}
|
|
40
49
|
let _copilotModel = raw.COPILOT_MODEL || DEFAULT_MODEL;
|
|
41
50
|
export const config = {
|
|
42
51
|
telegramBotToken: raw.TELEGRAM_BOT_TOKEN,
|
|
@@ -64,13 +73,15 @@ export const config = {
|
|
|
64
73
|
process.env.SHOW_REASONING = value ? "true" : "false";
|
|
65
74
|
},
|
|
66
75
|
/** Usage display mode: off | tokens | full */
|
|
67
|
-
usageMode: (process.env.USAGE_MODE
|
|
76
|
+
usageMode: validateEnum(process.env.USAGE_MODE, ["off", "tokens", "full"], "off", "USAGE_MODE"),
|
|
68
77
|
/** Verbose mode: when on, instructs the AI to be more detailed */
|
|
69
78
|
verboseMode: process.env.VERBOSE_MODE === "true",
|
|
70
79
|
/** Thinking level: off | low | medium | high */
|
|
71
|
-
thinkingLevel: (process.env.THINKING_LEVEL
|
|
80
|
+
thinkingLevel: validateEnum(process.env.THINKING_LEVEL, ["off", "low", "medium", "high"], "off", "THINKING_LEVEL"),
|
|
72
81
|
/** Group chat: when true, bot only responds when mentioned in groups */
|
|
73
82
|
groupMentionOnly: process.env.GROUP_MENTION_ONLY !== "false",
|
|
83
|
+
/** Reasoning effort: low | medium | high */
|
|
84
|
+
reasoningEffort: validateEnum(process.env.REASONING_EFFORT, ["low", "medium", "high"], "medium", "REASONING_EFFORT"),
|
|
74
85
|
};
|
|
75
86
|
/** Persist an env variable to ~/.nzb/.env */
|
|
76
87
|
export function persistEnvVar(key, value) {
|
|
@@ -91,6 +102,7 @@ export function persistEnvVar(key, value) {
|
|
|
91
102
|
writeFileSync(ENV_PATH, updated.join("\n"));
|
|
92
103
|
}
|
|
93
104
|
catch {
|
|
105
|
+
// Expected: .env file may not exist yet on first run
|
|
94
106
|
writeFileSync(ENV_PATH, `${key}=${value}\n`);
|
|
95
107
|
}
|
|
96
108
|
}
|
package/dist/copilot/client.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { CopilotClient } from "@github/copilot-sdk";
|
|
2
|
+
import { withTimeout } from "../utils.js";
|
|
2
3
|
let client;
|
|
3
4
|
/** Coalesces concurrent resetClient() calls into a single reset operation. */
|
|
4
5
|
let pendingResetPromise;
|
|
@@ -6,9 +7,8 @@ export async function getClient() {
|
|
|
6
7
|
if (!client) {
|
|
7
8
|
client = new CopilotClient({
|
|
8
9
|
autoStart: true,
|
|
9
|
-
autoRestart: true,
|
|
10
10
|
});
|
|
11
|
-
await client.start();
|
|
11
|
+
await withTimeout(client.start(), 30_000, "client.start()");
|
|
12
12
|
}
|
|
13
13
|
return client;
|
|
14
14
|
}
|
|
@@ -17,27 +17,27 @@ export async function resetClient() {
|
|
|
17
17
|
if (pendingResetPromise)
|
|
18
18
|
return pendingResetPromise;
|
|
19
19
|
pendingResetPromise = (async () => {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
try {
|
|
21
|
+
if (client) {
|
|
22
|
+
try {
|
|
23
|
+
await withTimeout(client.stop(), 10_000, "client.stop()");
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
console.error("[nzb] Error stopping client during reset:", err);
|
|
27
|
+
}
|
|
28
|
+
client = undefined;
|
|
23
29
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
return await getClient();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
pendingResetPromise = undefined;
|
|
28
34
|
}
|
|
29
|
-
return getClient();
|
|
30
35
|
})();
|
|
31
|
-
|
|
32
|
-
return await pendingResetPromise;
|
|
33
|
-
}
|
|
34
|
-
finally {
|
|
35
|
-
pendingResetPromise = undefined;
|
|
36
|
-
}
|
|
36
|
+
return pendingResetPromise;
|
|
37
37
|
}
|
|
38
38
|
export async function stopClient() {
|
|
39
39
|
if (client) {
|
|
40
|
-
await client.stop();
|
|
40
|
+
await withTimeout(client.stop(), 10_000, "client.stop()");
|
|
41
41
|
client = undefined;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
@@ -10,6 +10,7 @@ const isWSL = (() => {
|
|
|
10
10
|
return readFileSync("/proc/version", "utf-8").toLowerCase().includes("microsoft");
|
|
11
11
|
}
|
|
12
12
|
catch {
|
|
13
|
+
// Expected: /proc/version may not exist on non-Linux systems
|
|
13
14
|
return false;
|
|
14
15
|
}
|
|
15
16
|
})();
|
|
@@ -86,6 +87,7 @@ export function loadMcpConfig() {
|
|
|
86
87
|
return cachedConfig;
|
|
87
88
|
}
|
|
88
89
|
catch {
|
|
90
|
+
// Expected: config file may not exist or be malformed
|
|
89
91
|
cachedConfig = {};
|
|
90
92
|
return cachedConfig;
|
|
91
93
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { approveAll } from "@github/copilot-sdk";
|
|
2
2
|
import { config, DEFAULT_MODEL } from "../config.js";
|
|
3
3
|
import { SESSIONS_DIR } from "../paths.js";
|
|
4
|
-
import {
|
|
4
|
+
import { deleteState, getState, setState } from "../store/db.js";
|
|
5
|
+
import { getRecentConversation, logConversation } from "../store/conversation.js";
|
|
6
|
+
import { getMemorySummary } from "../store/memory.js";
|
|
7
|
+
import { completeTeam, updateTeamMemberResult } from "../store/team-store.js";
|
|
8
|
+
import { formatAge, withTimeout } from "../utils.js";
|
|
5
9
|
import { resetClient } from "./client.js";
|
|
6
10
|
import { loadMcpConfig } from "./mcp-config.js";
|
|
7
11
|
import { getSkillDirectories } from "./skills.js";
|
|
@@ -27,10 +31,13 @@ let copilotClient;
|
|
|
27
31
|
const workers = new Map();
|
|
28
32
|
const teams = new Map();
|
|
29
33
|
let healthCheckTimer;
|
|
34
|
+
let workerReaperTimer;
|
|
30
35
|
// Persistent orchestrator session
|
|
31
36
|
let orchestratorSession;
|
|
32
37
|
// Coalesces concurrent ensureOrchestratorSession calls
|
|
33
38
|
let sessionCreatePromise;
|
|
39
|
+
// Tracks when the orchestrator session was created for TTL enforcement
|
|
40
|
+
let sessionCreatedAt;
|
|
34
41
|
// Tracks in-flight context recovery injection so we don't race with real messages
|
|
35
42
|
let recoveryInjectionPromise;
|
|
36
43
|
const messageQueue = [];
|
|
@@ -60,6 +67,7 @@ function getSessionConfig() {
|
|
|
60
67
|
workerNotifyFn(event, channel);
|
|
61
68
|
}
|
|
62
69
|
},
|
|
70
|
+
getCurrentSourceChannel,
|
|
63
71
|
});
|
|
64
72
|
cachedToolsClientRef = copilotClient;
|
|
65
73
|
}
|
|
@@ -138,27 +146,35 @@ async function ensureClient() {
|
|
|
138
146
|
return resetPromise;
|
|
139
147
|
}
|
|
140
148
|
/** Start periodic health check that proactively reconnects the client. */
|
|
149
|
+
let healthCheckRunning = false;
|
|
141
150
|
function startHealthCheck() {
|
|
142
151
|
if (healthCheckTimer)
|
|
143
152
|
return;
|
|
144
153
|
healthCheckTimer = setInterval(async () => {
|
|
145
154
|
if (!copilotClient)
|
|
146
155
|
return;
|
|
156
|
+
if (healthCheckRunning)
|
|
157
|
+
return;
|
|
158
|
+
healthCheckRunning = true;
|
|
147
159
|
try {
|
|
148
160
|
const state = copilotClient.getState();
|
|
149
161
|
if (state !== "connected") {
|
|
150
162
|
console.log(`[nzb] Health check: client state is '${state}', resetting…`);
|
|
151
163
|
const previousClient = copilotClient;
|
|
152
|
-
await ensureClient();
|
|
164
|
+
await withTimeout(ensureClient(), 15_000, "health check");
|
|
153
165
|
// Only invalidate session if the underlying client actually changed
|
|
154
166
|
if (copilotClient !== previousClient) {
|
|
155
167
|
orchestratorSession = undefined;
|
|
168
|
+
sessionCreatedAt = undefined;
|
|
156
169
|
}
|
|
157
170
|
}
|
|
158
171
|
}
|
|
159
172
|
catch (err) {
|
|
160
173
|
console.error(`[nzb] Health check error:`, err instanceof Error ? err.message : err);
|
|
161
174
|
}
|
|
175
|
+
finally {
|
|
176
|
+
healthCheckRunning = false;
|
|
177
|
+
}
|
|
162
178
|
}, HEALTH_CHECK_INTERVAL_MS);
|
|
163
179
|
}
|
|
164
180
|
/** Stop the periodic health check timer. Call during shutdown. */
|
|
@@ -168,17 +184,64 @@ export function stopHealthCheck() {
|
|
|
168
184
|
healthCheckTimer = undefined;
|
|
169
185
|
}
|
|
170
186
|
}
|
|
187
|
+
/** Periodically kills workers that have exceeded 2× their configured timeout. */
|
|
188
|
+
function startWorkerReaper() {
|
|
189
|
+
if (workerReaperTimer)
|
|
190
|
+
return;
|
|
191
|
+
workerReaperTimer = setInterval(() => {
|
|
192
|
+
const maxAge = config.workerTimeoutMs * 2;
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
for (const [name, worker] of workers) {
|
|
195
|
+
if (worker.startedAt && now - worker.startedAt > maxAge) {
|
|
196
|
+
console.log(`[nzb] Reaping stuck worker '${name}' (age: ${formatAge(worker.startedAt)})`);
|
|
197
|
+
try {
|
|
198
|
+
worker.session.disconnect().catch(() => { });
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
// Session may already be destroyed
|
|
202
|
+
}
|
|
203
|
+
workers.delete(name);
|
|
204
|
+
feedBackgroundResult(name, `⚠ Worker '${name}' was automatically killed after exceeding timeout.`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}, 5 * 60 * 1000);
|
|
208
|
+
workerReaperTimer.unref();
|
|
209
|
+
}
|
|
210
|
+
const SESSION_MAX_AGE_MS = 4 * 60 * 60 * 1000; // 4 hours
|
|
171
211
|
/** Create or resume the persistent orchestrator session. */
|
|
172
212
|
async function ensureOrchestratorSession() {
|
|
173
|
-
if (orchestratorSession)
|
|
174
|
-
|
|
213
|
+
if (orchestratorSession) {
|
|
214
|
+
// Validate session is still usable — check client connectivity
|
|
215
|
+
try {
|
|
216
|
+
const clientState = copilotClient?.getState?.();
|
|
217
|
+
if (clientState && clientState !== "connected") {
|
|
218
|
+
console.log(`[nzb] Session stale (client state: ${clientState}), recreating…`);
|
|
219
|
+
orchestratorSession = undefined;
|
|
220
|
+
sessionCreatedAt = undefined;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
console.log("[nzb] Session validation failed, recreating…");
|
|
225
|
+
orchestratorSession = undefined;
|
|
226
|
+
sessionCreatedAt = undefined;
|
|
227
|
+
}
|
|
228
|
+
// Enforce session TTL
|
|
229
|
+
if (sessionCreatedAt && Date.now() - sessionCreatedAt > SESSION_MAX_AGE_MS) {
|
|
230
|
+
console.log("[nzb] Session TTL expired, recreating…");
|
|
231
|
+
orchestratorSession = undefined;
|
|
232
|
+
sessionCreatedAt = undefined;
|
|
233
|
+
}
|
|
234
|
+
if (orchestratorSession)
|
|
235
|
+
return orchestratorSession;
|
|
236
|
+
}
|
|
175
237
|
// Coalesce concurrent callers — wait for an in-flight creation
|
|
176
238
|
if (sessionCreatePromise)
|
|
177
239
|
return sessionCreatePromise;
|
|
178
|
-
sessionCreatePromise = createOrResumeSession();
|
|
240
|
+
sessionCreatePromise = withTimeout(createOrResumeSession(), 30_000, "session create/resume");
|
|
179
241
|
try {
|
|
180
242
|
const session = await sessionCreatePromise;
|
|
181
243
|
orchestratorSession = session;
|
|
244
|
+
sessionCreatedAt = Date.now();
|
|
182
245
|
return session;
|
|
183
246
|
}
|
|
184
247
|
finally {
|
|
@@ -204,6 +267,7 @@ async function createOrResumeSession() {
|
|
|
204
267
|
model: config.copilotModel,
|
|
205
268
|
configDir: SESSIONS_DIR,
|
|
206
269
|
streaming: true,
|
|
270
|
+
reasoningEffort: config.reasoningEffort,
|
|
207
271
|
systemMessage: {
|
|
208
272
|
content: getOrchestratorSystemMessage(memorySummary || undefined, {
|
|
209
273
|
selfEditEnabled: config.selfEditEnabled,
|
|
@@ -230,6 +294,7 @@ async function createOrResumeSession() {
|
|
|
230
294
|
model: config.copilotModel,
|
|
231
295
|
configDir: SESSIONS_DIR,
|
|
232
296
|
streaming: true,
|
|
297
|
+
reasoningEffort: config.reasoningEffort,
|
|
233
298
|
systemMessage: {
|
|
234
299
|
content: getOrchestratorSystemMessage(memorySummary || undefined, {
|
|
235
300
|
selfEditEnabled: config.selfEditEnabled,
|
|
@@ -248,7 +313,7 @@ async function createOrResumeSession() {
|
|
|
248
313
|
// Recover conversation context if available (session was lost, not first run)
|
|
249
314
|
// Runs concurrently but is awaited before any real message is sent on the session
|
|
250
315
|
const recentHistory = getRecentConversation(10);
|
|
251
|
-
if (recentHistory) {
|
|
316
|
+
if (!recoveryInjectionPromise && recentHistory) {
|
|
252
317
|
console.log(`[nzb] Injecting recent conversation context into new session`);
|
|
253
318
|
recoveryInjectionPromise = session
|
|
254
319
|
.sendAndWait({
|
|
@@ -288,6 +353,7 @@ export async function initOrchestrator(client) {
|
|
|
288
353
|
console.log(`[nzb] Skill directories: ${skillDirectories.join(", ") || "(none)"}`);
|
|
289
354
|
console.log(`[nzb] Persistent session mode — conversation history maintained by SDK`);
|
|
290
355
|
startHealthCheck();
|
|
356
|
+
startWorkerReaper();
|
|
291
357
|
// Eagerly create/resume the orchestrator session
|
|
292
358
|
try {
|
|
293
359
|
await ensureOrchestratorSession();
|
|
@@ -297,60 +363,96 @@ export async function initOrchestrator(client) {
|
|
|
297
363
|
}
|
|
298
364
|
}
|
|
299
365
|
/** Send a prompt on the persistent session, return the response. */
|
|
300
|
-
async function executeOnSession(prompt, callback, onToolEvent, onUsage) {
|
|
366
|
+
async function executeOnSession(prompt, callback, onToolEvent, onUsage, attachments) {
|
|
301
367
|
const session = await ensureOrchestratorSession();
|
|
302
368
|
// Wait for any in-flight context recovery injection to finish before sending
|
|
303
369
|
if (recoveryInjectionPromise) {
|
|
304
|
-
console.log(
|
|
305
|
-
await recoveryInjectionPromise
|
|
370
|
+
console.log("[nzb] Waiting for context recovery…");
|
|
371
|
+
await withTimeout(recoveryInjectionPromise, 25_000, "recovery injection wait").catch(() => {
|
|
372
|
+
console.log("[nzb] Recovery injection wait timed out, proceeding anyway");
|
|
373
|
+
});
|
|
306
374
|
}
|
|
307
375
|
currentCallback = callback;
|
|
308
376
|
let accumulated = "";
|
|
309
377
|
let toolCallExecuted = false;
|
|
310
378
|
const unsubToolStart = session.on("tool.execution_start", (event) => {
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
args?.
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
379
|
+
try {
|
|
380
|
+
const toolName = event?.data?.toolName || event?.data?.name || "tool";
|
|
381
|
+
const args = event?.data?.arguments;
|
|
382
|
+
const detail = args?.description ||
|
|
383
|
+
args?.command?.slice(0, 80) ||
|
|
384
|
+
args?.intent ||
|
|
385
|
+
args?.pattern ||
|
|
386
|
+
args?.prompt?.slice(0, 80) ||
|
|
387
|
+
undefined;
|
|
388
|
+
onToolEvent?.({ type: "tool_start", toolName, detail });
|
|
389
|
+
}
|
|
390
|
+
catch (err) {
|
|
391
|
+
console.error("[nzb] Error in tool.execution_start listener:", err instanceof Error ? err.message : err);
|
|
392
|
+
}
|
|
320
393
|
});
|
|
321
394
|
const unsubToolDone = session.on("tool.execution_complete", (event) => {
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
395
|
+
try {
|
|
396
|
+
toolCallExecuted = true;
|
|
397
|
+
const toolName = event?.data?.toolName || event?.data?.name || "tool";
|
|
398
|
+
onToolEvent?.({ type: "tool_complete", toolName });
|
|
399
|
+
}
|
|
400
|
+
catch (err) {
|
|
401
|
+
console.error("[nzb] Error in tool.execution_complete listener:", err instanceof Error ? err.message : err);
|
|
402
|
+
}
|
|
325
403
|
});
|
|
326
404
|
const unsubDelta = session.on("assistant.message_delta", (event) => {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
accumulated
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
405
|
+
try {
|
|
406
|
+
// After a tool call completes, ensure a line break separates the text blocks
|
|
407
|
+
// so they don't visually run together in the TUI.
|
|
408
|
+
if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
|
|
409
|
+
accumulated += "\n";
|
|
410
|
+
}
|
|
411
|
+
toolCallExecuted = false;
|
|
412
|
+
accumulated += event.data.deltaContent;
|
|
413
|
+
callback(accumulated, false);
|
|
414
|
+
}
|
|
415
|
+
catch (err) {
|
|
416
|
+
console.error("[nzb] Error in message_delta listener:", err instanceof Error ? err.message : err);
|
|
417
|
+
}
|
|
335
418
|
});
|
|
336
419
|
const unsubError = session.on("session.error", (event) => {
|
|
337
|
-
|
|
338
|
-
|
|
420
|
+
try {
|
|
421
|
+
const errMsg = event?.data?.message || event?.data?.error || "Unknown session error";
|
|
422
|
+
console.error(`[nzb] Session error event: ${errMsg}`);
|
|
423
|
+
}
|
|
424
|
+
catch (err) {
|
|
425
|
+
console.error("[nzb] Error in session.error listener:", err instanceof Error ? err.message : err);
|
|
426
|
+
}
|
|
339
427
|
});
|
|
340
428
|
const unsubPartialResult = session.on("tool.execution_partial_result", (event) => {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
429
|
+
try {
|
|
430
|
+
const toolName = event?.data?.toolName || event?.data?.name || "tool";
|
|
431
|
+
const partialOutput = event?.data?.partialOutput || "";
|
|
432
|
+
onToolEvent?.({ type: "tool_partial_result", toolName, detail: partialOutput });
|
|
433
|
+
}
|
|
434
|
+
catch (err) {
|
|
435
|
+
console.error("[nzb] Error in tool.execution_partial_result listener:", err instanceof Error ? err.message : err);
|
|
436
|
+
}
|
|
344
437
|
});
|
|
345
438
|
const unsubUsage = session.on("assistant.usage", (event) => {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
439
|
+
try {
|
|
440
|
+
const inputTokens = event?.data?.inputTokens || 0;
|
|
441
|
+
const outputTokens = event?.data?.outputTokens || 0;
|
|
442
|
+
const model = event?.data?.model || undefined;
|
|
443
|
+
const duration = event?.data?.duration || undefined;
|
|
444
|
+
onUsage?.({ inputTokens, outputTokens, model, duration });
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
console.error("[nzb] Error in assistant.usage listener:", err instanceof Error ? err.message : err);
|
|
448
|
+
}
|
|
351
449
|
});
|
|
352
450
|
try {
|
|
353
|
-
const
|
|
451
|
+
const sendPayload = { prompt };
|
|
452
|
+
if (attachments?.length) {
|
|
453
|
+
sendPayload.attachments = attachments;
|
|
454
|
+
}
|
|
455
|
+
const result = await session.sendAndWait(sendPayload, 60_000);
|
|
354
456
|
// Allow late-arriving events (e.g. assistant.usage) to be processed
|
|
355
457
|
await new Promise((r) => setTimeout(r, 150));
|
|
356
458
|
const finalContent = result?.data?.content || accumulated || "(No response)";
|
|
@@ -358,6 +460,16 @@ async function executeOnSession(prompt, callback, onToolEvent, onUsage) {
|
|
|
358
460
|
}
|
|
359
461
|
catch (err) {
|
|
360
462
|
const msg = err instanceof Error ? err.message : String(err);
|
|
463
|
+
// Vision not supported — the session is now tainted with image data in its history,
|
|
464
|
+
// so ALL subsequent messages would fail. Force-recreate the session to recover.
|
|
465
|
+
// The retry (with stripped attachments) is handled by sendToOrchestrator's retry loop.
|
|
466
|
+
if (/not supported for vision/i.test(msg)) {
|
|
467
|
+
console.log(`[nzb] Model '${config.copilotModel}' does not support vision — destroying tainted session`);
|
|
468
|
+
orchestratorSession = undefined;
|
|
469
|
+
sessionCreatedAt = undefined;
|
|
470
|
+
deleteState(ORCHESTRATOR_SESSION_KEY);
|
|
471
|
+
throw err;
|
|
472
|
+
}
|
|
361
473
|
// On timeout, deliver whatever was accumulated instead of retrying from scratch
|
|
362
474
|
if (/timeout/i.test(msg) && accumulated.length > 0) {
|
|
363
475
|
console.log(`[nzb] Timeout — delivering ${accumulated.length} chars of partial content`);
|
|
@@ -367,6 +479,7 @@ async function executeOnSession(prompt, callback, onToolEvent, onUsage) {
|
|
|
367
479
|
if (/closed|destroy|disposed|invalid|expired|not found/i.test(msg)) {
|
|
368
480
|
console.log(`[nzb] Session appears dead, will recreate: ${msg}`);
|
|
369
481
|
orchestratorSession = undefined;
|
|
482
|
+
sessionCreatedAt = undefined;
|
|
370
483
|
deleteState(ORCHESTRATOR_SESSION_KEY);
|
|
371
484
|
}
|
|
372
485
|
throw err;
|
|
@@ -390,26 +503,34 @@ async function processQueue() {
|
|
|
390
503
|
return;
|
|
391
504
|
}
|
|
392
505
|
processing = true;
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
506
|
+
try {
|
|
507
|
+
while (messageQueue.length > 0) {
|
|
508
|
+
const item = messageQueue.shift();
|
|
509
|
+
currentSourceChannel = item.sourceChannel;
|
|
510
|
+
try {
|
|
511
|
+
const result = await executeOnSession(item.prompt, item.callback, item.onToolEvent, item.onUsage, item.attachments);
|
|
512
|
+
item.resolve(result);
|
|
513
|
+
}
|
|
514
|
+
catch (err) {
|
|
515
|
+
item.reject(err);
|
|
516
|
+
}
|
|
517
|
+
currentSourceChannel = undefined;
|
|
402
518
|
}
|
|
403
|
-
currentSourceChannel = undefined;
|
|
404
519
|
}
|
|
405
|
-
|
|
520
|
+
finally {
|
|
521
|
+
processing = false;
|
|
522
|
+
}
|
|
523
|
+
// Re-check for messages that arrived during the last executeOnSession call
|
|
524
|
+
if (messageQueue.length > 0) {
|
|
525
|
+
void processQueue();
|
|
526
|
+
}
|
|
406
527
|
}
|
|
407
528
|
function isRecoverableError(err) {
|
|
408
529
|
const msg = err instanceof Error ? err.message : String(err);
|
|
409
530
|
return /timeout|disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
|
|
410
531
|
}
|
|
411
532
|
const MAX_AUTO_CONTINUE = 3;
|
|
412
|
-
export async function sendToOrchestrator(prompt, source, callback, onToolEvent, onUsage, _autoContinueCount = 0) {
|
|
533
|
+
export async function sendToOrchestrator(prompt, source, callback, onToolEvent, onUsage, _autoContinueCount = 0, attachments) {
|
|
413
534
|
const sourceLabel = source.type === "telegram" ? "telegram" : source.type === "tui" ? "tui" : "background";
|
|
414
535
|
logMessage("in", sourceLabel, prompt);
|
|
415
536
|
// Tag the prompt with its source channel
|
|
@@ -444,6 +565,7 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
|
|
|
444
565
|
const finalContent = await new Promise((resolve, reject) => {
|
|
445
566
|
const item = {
|
|
446
567
|
prompt: taggedPrompt,
|
|
568
|
+
attachments,
|
|
447
569
|
callback,
|
|
448
570
|
onToolEvent,
|
|
449
571
|
onUsage,
|
|
@@ -505,6 +627,19 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
|
|
|
505
627
|
if (/cancelled|abort/i.test(msg)) {
|
|
506
628
|
return;
|
|
507
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
|
+
}
|
|
508
643
|
if (isRecoverableError(err) && attempt < MAX_RETRIES) {
|
|
509
644
|
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
510
645
|
console.error(`[nzb] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
|
|
@@ -523,9 +658,10 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
|
|
|
523
658
|
return;
|
|
524
659
|
}
|
|
525
660
|
}
|
|
526
|
-
})()
|
|
661
|
+
})().catch((err) => {
|
|
662
|
+
console.error(`[nzb] Unhandled error in sendToOrchestrator: ${err instanceof Error ? err.message : String(err)}`);
|
|
663
|
+
});
|
|
527
664
|
}
|
|
528
|
-
/** Cancel the in-flight message and drain the queue. */
|
|
529
665
|
export async function cancelCurrentMessage() {
|
|
530
666
|
// Drain any queued messages
|
|
531
667
|
const drained = messageQueue.length;
|
|
@@ -572,10 +708,11 @@ export async function resetSession() {
|
|
|
572
708
|
// Destroy the existing session
|
|
573
709
|
if (orchestratorSession) {
|
|
574
710
|
try {
|
|
575
|
-
await orchestratorSession.
|
|
711
|
+
await orchestratorSession.disconnect();
|
|
576
712
|
}
|
|
577
713
|
catch { }
|
|
578
714
|
orchestratorSession = undefined;
|
|
715
|
+
sessionCreatedAt = undefined;
|
|
579
716
|
}
|
|
580
717
|
// Clear persisted session ID so a fresh session is created
|
|
581
718
|
deleteState(ORCHESTRATOR_SESSION_KEY);
|
package/dist/copilot/skills.js
CHANGED
|
@@ -41,7 +41,8 @@ export function listSkills() {
|
|
|
41
41
|
try {
|
|
42
42
|
entries = readdirSync(dir);
|
|
43
43
|
}
|
|
44
|
-
catch {
|
|
44
|
+
catch (err) {
|
|
45
|
+
console.error("[nzb] Failed to read skill directory", dir + ":", err instanceof Error ? err.message : err);
|
|
45
46
|
continue;
|
|
46
47
|
}
|
|
47
48
|
for (const entry of entries) {
|
|
@@ -60,7 +61,8 @@ export function listSkills() {
|
|
|
60
61
|
source,
|
|
61
62
|
});
|
|
62
63
|
}
|
|
63
|
-
catch {
|
|
64
|
+
catch (err) {
|
|
65
|
+
console.error("[nzb] Failed to parse SKILL.md for", entry + ":", err instanceof Error ? err.message : err);
|
|
64
66
|
skills.push({
|
|
65
67
|
slug: entry,
|
|
66
68
|
name: entry,
|