@iletai/nzb 1.1.2 → 1.1.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/cli.js +20 -1
- package/dist/copilot/client.js +21 -9
- package/dist/copilot/mcp-config.js +25 -4
- package/dist/copilot/orchestrator.js +38 -2
- package/dist/copilot/skills.js +20 -7
- package/dist/copilot/system-message.js +2 -1
- package/dist/copilot/tools.js +32 -8
- package/dist/daemon.js +34 -17
- package/dist/store/db.js +29 -17
- package/dist/telegram/bot.js +171 -15
- package/dist/telegram/formatter.js +21 -8
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { spawnSync } from "child_process";
|
|
3
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
4
|
import { dirname, join } from "path";
|
|
4
5
|
import { fileURLToPath } from "url";
|
|
5
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
// Auto-detect system CA bundle for corporate environments with TLS inspection.
|
|
8
|
+
// NODE_EXTRA_CA_CERTS must be set BEFORE the Node.js process starts — setting it
|
|
9
|
+
// at runtime via process.env does NOT work for Node.js 24's fetch() (undici).
|
|
10
|
+
// When missing, we re-exec the current process with the env var set.
|
|
11
|
+
if (!process.env.NODE_EXTRA_CA_CERTS && !process.env.__NZB_CA_INJECTED) {
|
|
12
|
+
const found = [
|
|
13
|
+
"/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu
|
|
14
|
+
"/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora
|
|
15
|
+
"/etc/ssl/cert.pem", // macOS / Alpine
|
|
16
|
+
].find((p) => existsSync(p));
|
|
17
|
+
if (found) {
|
|
18
|
+
const result = spawnSync(process.execPath, [...process.execArgv, ...process.argv.slice(1)], {
|
|
19
|
+
stdio: "inherit",
|
|
20
|
+
env: { ...process.env, NODE_EXTRA_CA_CERTS: found, __NZB_CA_INJECTED: "1" },
|
|
21
|
+
});
|
|
22
|
+
process.exit(result.status ?? 1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
6
25
|
function getVersion() {
|
|
7
26
|
try {
|
|
8
27
|
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
package/dist/copilot/client.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CopilotClient } from "@github/copilot-sdk";
|
|
2
2
|
let client;
|
|
3
|
+
/** Coalesces concurrent resetClient() calls into a single reset operation. */
|
|
4
|
+
let pendingResetPromise;
|
|
3
5
|
export async function getClient() {
|
|
4
6
|
if (!client) {
|
|
5
7
|
client = new CopilotClient({
|
|
@@ -10,18 +12,28 @@ export async function getClient() {
|
|
|
10
12
|
}
|
|
11
13
|
return client;
|
|
12
14
|
}
|
|
13
|
-
/** Tear down the existing client and create a fresh one. */
|
|
15
|
+
/** Tear down the existing client and create a fresh one. Concurrent calls coalesce to a single reset. */
|
|
14
16
|
export async function resetClient() {
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
if (pendingResetPromise)
|
|
18
|
+
return pendingResetPromise;
|
|
19
|
+
pendingResetPromise = (async () => {
|
|
20
|
+
if (client) {
|
|
21
|
+
try {
|
|
22
|
+
await client.stop();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
/* best-effort */
|
|
26
|
+
}
|
|
27
|
+
client = undefined;
|
|
21
28
|
}
|
|
22
|
-
|
|
29
|
+
return getClient();
|
|
30
|
+
})();
|
|
31
|
+
try {
|
|
32
|
+
return await pendingResetPromise;
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
pendingResetPromise = undefined;
|
|
23
36
|
}
|
|
24
|
-
return getClient();
|
|
25
37
|
}
|
|
26
38
|
export async function stopClient() {
|
|
27
39
|
if (client) {
|
|
@@ -1,22 +1,43 @@
|
|
|
1
1
|
import { readFileSync } from "fs";
|
|
2
|
-
import { join } from "path";
|
|
3
2
|
import { homedir } from "os";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
let cachedConfig;
|
|
4
5
|
/**
|
|
5
6
|
* Load MCP server configs from ~/.copilot/mcp-config.json.
|
|
6
7
|
* Returns an empty record if the file doesn't exist or is invalid.
|
|
8
|
+
* Only includes entries that have a valid 'type' field.
|
|
9
|
+
* Result is cached — call clearMcpConfigCache() to force a reload.
|
|
7
10
|
*/
|
|
8
11
|
export function loadMcpConfig() {
|
|
12
|
+
if (cachedConfig)
|
|
13
|
+
return cachedConfig;
|
|
9
14
|
const configPath = join(homedir(), ".copilot", "mcp-config.json");
|
|
10
15
|
try {
|
|
11
16
|
const raw = readFileSync(configPath, "utf-8");
|
|
12
17
|
const parsed = JSON.parse(raw);
|
|
13
18
|
if (parsed.mcpServers && typeof parsed.mcpServers === "object") {
|
|
14
|
-
|
|
19
|
+
// Filter out malformed entries — each server must have at least a type
|
|
20
|
+
const servers = {};
|
|
21
|
+
for (const [name, entry] of Object.entries(parsed.mcpServers)) {
|
|
22
|
+
if (entry && typeof entry === "object" && "type" in entry && typeof entry.type === "string") {
|
|
23
|
+
servers[name] = entry;
|
|
24
|
+
}
|
|
25
|
+
else {
|
|
26
|
+
console.log(`[nzb] Skipping malformed MCP server entry '${name}' (missing or invalid 'type' field)`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
cachedConfig = servers;
|
|
30
|
+
return servers;
|
|
15
31
|
}
|
|
16
|
-
|
|
32
|
+
cachedConfig = {};
|
|
33
|
+
return cachedConfig;
|
|
17
34
|
}
|
|
18
35
|
catch {
|
|
19
|
-
|
|
36
|
+
cachedConfig = {};
|
|
37
|
+
return cachedConfig;
|
|
20
38
|
}
|
|
21
39
|
}
|
|
40
|
+
export function clearMcpConfigCache() {
|
|
41
|
+
cachedConfig = undefined;
|
|
42
|
+
}
|
|
22
43
|
//# sourceMappingURL=mcp-config.js.map
|
|
@@ -19,6 +19,10 @@ let proactiveNotifyFn;
|
|
|
19
19
|
export function setProactiveNotify(fn) {
|
|
20
20
|
proactiveNotifyFn = fn;
|
|
21
21
|
}
|
|
22
|
+
let workerNotifyFn;
|
|
23
|
+
export function setWorkerNotify(fn) {
|
|
24
|
+
workerNotifyFn = fn;
|
|
25
|
+
}
|
|
22
26
|
let copilotClient;
|
|
23
27
|
const workers = new Map();
|
|
24
28
|
let healthCheckTimer;
|
|
@@ -40,6 +44,13 @@ function getSessionConfig() {
|
|
|
40
44
|
client: copilotClient,
|
|
41
45
|
workers,
|
|
42
46
|
onWorkerComplete: feedBackgroundResult,
|
|
47
|
+
onWorkerEvent: (event) => {
|
|
48
|
+
const worker = workers.get(event.name);
|
|
49
|
+
const channel = worker?.originChannel ?? currentSourceChannel;
|
|
50
|
+
if (workerNotifyFn) {
|
|
51
|
+
workerNotifyFn(event, channel);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
43
54
|
});
|
|
44
55
|
const mcpServers = loadMcpConfig();
|
|
45
56
|
const skillDirectories = getSkillDirectories();
|
|
@@ -49,6 +60,7 @@ function getSessionConfig() {
|
|
|
49
60
|
export function feedBackgroundResult(workerName, result) {
|
|
50
61
|
const worker = workers.get(workerName);
|
|
51
62
|
const channel = worker?.originChannel;
|
|
63
|
+
console.log(`[nzb] Feeding background result from worker '${workerName}' (channel: ${channel ?? "none"})`);
|
|
52
64
|
const prompt = `[Background task completed] Worker '${workerName}' finished:\n\n${result}`;
|
|
53
65
|
sendToOrchestrator(prompt, { type: "background" }, (_text, done) => {
|
|
54
66
|
if (done && proactiveNotifyFn) {
|
|
@@ -100,6 +112,13 @@ function startHealthCheck() {
|
|
|
100
112
|
}
|
|
101
113
|
}, HEALTH_CHECK_INTERVAL_MS);
|
|
102
114
|
}
|
|
115
|
+
/** Stop the periodic health check timer. Call during shutdown. */
|
|
116
|
+
export function stopHealthCheck() {
|
|
117
|
+
if (healthCheckTimer) {
|
|
118
|
+
clearInterval(healthCheckTimer);
|
|
119
|
+
healthCheckTimer = undefined;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
103
122
|
/** Create or resume the persistent orchestrator session. */
|
|
104
123
|
async function ensureOrchestratorSession() {
|
|
105
124
|
if (orchestratorSession)
|
|
@@ -139,6 +158,7 @@ async function createOrResumeSession() {
|
|
|
139
158
|
systemMessage: {
|
|
140
159
|
content: getOrchestratorSystemMessage(memorySummary || undefined, {
|
|
141
160
|
selfEditEnabled: config.selfEditEnabled,
|
|
161
|
+
currentModel: config.copilotModel,
|
|
142
162
|
}),
|
|
143
163
|
},
|
|
144
164
|
tools,
|
|
@@ -162,7 +182,10 @@ async function createOrResumeSession() {
|
|
|
162
182
|
configDir: SESSIONS_DIR,
|
|
163
183
|
streaming: true,
|
|
164
184
|
systemMessage: {
|
|
165
|
-
content: getOrchestratorSystemMessage(memorySummary || undefined, {
|
|
185
|
+
content: getOrchestratorSystemMessage(memorySummary || undefined, {
|
|
186
|
+
selfEditEnabled: config.selfEditEnabled,
|
|
187
|
+
currentModel: config.copilotModel,
|
|
188
|
+
}),
|
|
166
189
|
},
|
|
167
190
|
tools,
|
|
168
191
|
mcpServers,
|
|
@@ -241,14 +264,23 @@ async function executeOnSession(prompt, callback, onToolEvent) {
|
|
|
241
264
|
accumulated += event.data.deltaContent;
|
|
242
265
|
callback(accumulated, false);
|
|
243
266
|
});
|
|
267
|
+
const unsubError = session.on("session.error", (event) => {
|
|
268
|
+
const errMsg = event?.data?.message || event?.data?.error || "Unknown session error";
|
|
269
|
+
console.error(`[nzb] Session error event: ${errMsg}`);
|
|
270
|
+
});
|
|
244
271
|
try {
|
|
245
272
|
const result = await session.sendAndWait({ prompt }, 120_000);
|
|
246
273
|
const finalContent = result?.data?.content || accumulated || "(No response)";
|
|
247
274
|
return finalContent;
|
|
248
275
|
}
|
|
249
276
|
catch (err) {
|
|
250
|
-
// If the session is broken, invalidate it so it's recreated on next attempt
|
|
251
277
|
const msg = err instanceof Error ? err.message : String(err);
|
|
278
|
+
// On timeout, deliver whatever was accumulated instead of retrying from scratch
|
|
279
|
+
if (/timeout/i.test(msg) && accumulated.length > 0) {
|
|
280
|
+
console.log(`[nzb] Timeout — delivering ${accumulated.length} chars of partial content`);
|
|
281
|
+
return accumulated + "\n\n---\n\n⏱ Response was cut short (timeout). You can ask me to continue.";
|
|
282
|
+
}
|
|
283
|
+
// If the session is broken, invalidate it so it's recreated on next attempt
|
|
252
284
|
if (/closed|destroy|disposed|invalid|expired|not found/i.test(msg)) {
|
|
253
285
|
console.log(`[nzb] Session appears dead, will recreate: ${msg}`);
|
|
254
286
|
orchestratorSession = undefined;
|
|
@@ -260,6 +292,7 @@ async function executeOnSession(prompt, callback, onToolEvent) {
|
|
|
260
292
|
unsubDelta();
|
|
261
293
|
unsubToolStart();
|
|
262
294
|
unsubToolDone();
|
|
295
|
+
unsubError();
|
|
263
296
|
currentCallback = undefined;
|
|
264
297
|
}
|
|
265
298
|
}
|
|
@@ -360,6 +393,9 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent)
|
|
|
360
393
|
export async function cancelCurrentMessage() {
|
|
361
394
|
// Drain any queued messages
|
|
362
395
|
const drained = messageQueue.length;
|
|
396
|
+
if (drained > 0) {
|
|
397
|
+
console.log(`[nzb] Cancelling: draining ${drained} queued message(s)`);
|
|
398
|
+
}
|
|
363
399
|
while (messageQueue.length > 0) {
|
|
364
400
|
const item = messageQueue.shift();
|
|
365
401
|
item.reject(new Error("Cancelled"));
|
package/dist/copilot/skills.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join, dirname } from "path";
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
3
2
|
import { homedir } from "os";
|
|
3
|
+
import { dirname, join, resolve, sep } from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
5
|
import { SKILLS_DIR } from "../paths.js";
|
|
6
6
|
/** User-local skills directory (~/.nzb/skills/) */
|
|
@@ -9,8 +9,11 @@ const LOCAL_SKILLS_DIR = SKILLS_DIR;
|
|
|
9
9
|
const GLOBAL_SKILLS_DIR = join(homedir(), ".agents", "skills");
|
|
10
10
|
/** Skills bundled with the NZB package (e.g. find-skills) */
|
|
11
11
|
const BUNDLED_SKILLS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "skills");
|
|
12
|
-
|
|
12
|
+
let cachedSkillDirs;
|
|
13
|
+
/** Returns all skill directories that exist on disk. Cached after first call. */
|
|
13
14
|
export function getSkillDirectories() {
|
|
15
|
+
if (cachedSkillDirs)
|
|
16
|
+
return cachedSkillDirs;
|
|
14
17
|
const dirs = [];
|
|
15
18
|
if (existsSync(BUNDLED_SKILLS_DIR))
|
|
16
19
|
dirs.push(BUNDLED_SKILLS_DIR);
|
|
@@ -18,8 +21,12 @@ export function getSkillDirectories() {
|
|
|
18
21
|
dirs.push(LOCAL_SKILLS_DIR);
|
|
19
22
|
if (existsSync(GLOBAL_SKILLS_DIR))
|
|
20
23
|
dirs.push(GLOBAL_SKILLS_DIR);
|
|
24
|
+
cachedSkillDirs = dirs;
|
|
21
25
|
return dirs;
|
|
22
26
|
}
|
|
27
|
+
export function clearSkillDirsCache() {
|
|
28
|
+
cachedSkillDirs = undefined;
|
|
29
|
+
}
|
|
23
30
|
/** Scan all skill directories and return metadata for each skill found. */
|
|
24
31
|
export function listSkills() {
|
|
25
32
|
const skills = [];
|
|
@@ -69,8 +76,10 @@ export function listSkills() {
|
|
|
69
76
|
/** Create a new skill in the local skills directory. */
|
|
70
77
|
export function createSkill(slug, name, description, instructions) {
|
|
71
78
|
const skillDir = join(LOCAL_SKILLS_DIR, slug);
|
|
72
|
-
// Guard against path traversal
|
|
73
|
-
|
|
79
|
+
// Guard against path traversal — resolve to canonical path and verify it stays inside skills dir
|
|
80
|
+
const resolvedSkillDir = resolve(skillDir);
|
|
81
|
+
const resolvedBase = resolve(LOCAL_SKILLS_DIR);
|
|
82
|
+
if (!resolvedSkillDir.startsWith(resolvedBase + sep)) {
|
|
74
83
|
return `Invalid slug '${slug}': must be a simple kebab-case name without path separators.`;
|
|
75
84
|
}
|
|
76
85
|
if (existsSync(skillDir)) {
|
|
@@ -86,19 +95,23 @@ description: ${description}
|
|
|
86
95
|
${instructions}
|
|
87
96
|
`;
|
|
88
97
|
writeFileSync(join(skillDir, "SKILL.md"), skillMd);
|
|
98
|
+
clearSkillDirsCache();
|
|
89
99
|
return `Skill '${name}' created at ${skillDir}. It will be available on your next message.`;
|
|
90
100
|
}
|
|
91
101
|
/** Remove a skill from the local skills directory (~/.nzb/skills/). */
|
|
92
102
|
export function removeSkill(slug) {
|
|
93
103
|
const skillDir = join(LOCAL_SKILLS_DIR, slug);
|
|
94
|
-
// Guard against path traversal
|
|
95
|
-
|
|
104
|
+
// Guard against path traversal — resolve to canonical path and verify it stays inside skills dir
|
|
105
|
+
const resolvedSkillDir = resolve(skillDir);
|
|
106
|
+
const resolvedBase = resolve(LOCAL_SKILLS_DIR);
|
|
107
|
+
if (!resolvedSkillDir.startsWith(resolvedBase + sep)) {
|
|
96
108
|
return { ok: false, message: `Invalid slug '${slug}': must be a simple kebab-case name without path separators.` };
|
|
97
109
|
}
|
|
98
110
|
if (!existsSync(skillDir)) {
|
|
99
111
|
return { ok: false, message: `Skill '${slug}' not found in ${LOCAL_SKILLS_DIR}.` };
|
|
100
112
|
}
|
|
101
113
|
rmSync(skillDir, { recursive: true, force: true });
|
|
114
|
+
clearSkillDirsCache();
|
|
102
115
|
return {
|
|
103
116
|
ok: true,
|
|
104
117
|
message: `Skill '${slug}' removed from ${skillDir}. It will no longer be available on your next message.`,
|
|
@@ -17,7 +17,8 @@ This restriction does NOT apply to:
|
|
|
17
17
|
- Any files outside the NZB installation directory
|
|
18
18
|
`;
|
|
19
19
|
const osName = process.platform === "darwin" ? "macOS" : process.platform === "win32" ? "Windows" : "Linux";
|
|
20
|
-
|
|
20
|
+
const modelInfo = opts?.currentModel ? ` You are currently using the \`${opts.currentModel}\` model.` : "";
|
|
21
|
+
return `You are NZB, a personal AI assistant for developers running 24/7 on the user's machine (${osName}).${modelInfo} You are the user's always-on assistant.
|
|
21
22
|
|
|
22
23
|
## Your Architecture
|
|
23
24
|
|
package/dist/copilot/tools.js
CHANGED
|
@@ -60,12 +60,19 @@ export function createTools(deps) {
|
|
|
60
60
|
const names = Array.from(deps.workers.keys()).join(", ");
|
|
61
61
|
return `Worker limit reached (${MAX_CONCURRENT_WORKERS}). Active: ${names}. Kill a session first.`;
|
|
62
62
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
let session;
|
|
64
|
+
try {
|
|
65
|
+
session = await deps.client.createSession({
|
|
66
|
+
model: config.copilotModel,
|
|
67
|
+
configDir: SESSIONS_DIR,
|
|
68
|
+
workingDirectory: args.working_dir,
|
|
69
|
+
onPermissionRequest: approveAll,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
74
|
+
return `Failed to create worker session '${args.name}': ${msg}`;
|
|
75
|
+
}
|
|
69
76
|
const worker = {
|
|
70
77
|
name: args.name,
|
|
71
78
|
session,
|
|
@@ -74,6 +81,7 @@ export function createTools(deps) {
|
|
|
74
81
|
originChannel: getCurrentSourceChannel(),
|
|
75
82
|
};
|
|
76
83
|
deps.workers.set(args.name, worker);
|
|
84
|
+
deps.onWorkerEvent?.({ type: "created", name: args.name, workingDir: args.working_dir });
|
|
77
85
|
// Persist to SQLite
|
|
78
86
|
const db = getDb();
|
|
79
87
|
db.prepare(`INSERT OR REPLACE INTO worker_sessions (name, copilot_session_id, working_dir, status)
|
|
@@ -82,6 +90,7 @@ export function createTools(deps) {
|
|
|
82
90
|
worker.status = "running";
|
|
83
91
|
worker.startedAt = Date.now();
|
|
84
92
|
db.prepare(`UPDATE worker_sessions SET status = 'running', updated_at = CURRENT_TIMESTAMP WHERE name = ?`).run(args.name);
|
|
93
|
+
deps.onWorkerEvent?.({ type: "dispatched", name: args.name });
|
|
85
94
|
const timeoutMs = config.workerTimeoutMs;
|
|
86
95
|
// Non-blocking: dispatch work and return immediately
|
|
87
96
|
session
|
|
@@ -90,18 +99,25 @@ export function createTools(deps) {
|
|
|
90
99
|
}, timeoutMs)
|
|
91
100
|
.then((result) => {
|
|
92
101
|
worker.lastOutput = result?.data?.content || "No response";
|
|
102
|
+
deps.onWorkerEvent?.({ type: "completed", name: args.name });
|
|
93
103
|
deps.onWorkerComplete(args.name, worker.lastOutput);
|
|
94
104
|
})
|
|
95
105
|
.catch((err) => {
|
|
96
106
|
const errMsg = formatWorkerError(args.name, worker.startedAt, timeoutMs, err);
|
|
97
107
|
worker.lastOutput = errMsg;
|
|
108
|
+
deps.onWorkerEvent?.({ type: "error", name: args.name, error: errMsg });
|
|
98
109
|
deps.onWorkerComplete(args.name, errMsg);
|
|
99
110
|
})
|
|
100
111
|
.finally(() => {
|
|
101
112
|
// Auto-destroy background workers after completion to free memory (~400MB per worker)
|
|
102
113
|
session.destroy().catch(() => { });
|
|
103
114
|
deps.workers.delete(args.name);
|
|
104
|
-
|
|
115
|
+
try {
|
|
116
|
+
getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
|
|
117
|
+
}
|
|
118
|
+
catch (cleanupErr) {
|
|
119
|
+
console.error(`[nzb] Worker '${args.name}' DB cleanup failed:`, cleanupErr instanceof Error ? cleanupErr.message : cleanupErr);
|
|
120
|
+
}
|
|
105
121
|
});
|
|
106
122
|
return `Worker '${args.name}' created in ${args.working_dir}. Task dispatched — I'll notify you when it's done.`;
|
|
107
123
|
}
|
|
@@ -127,24 +143,32 @@ export function createTools(deps) {
|
|
|
127
143
|
worker.startedAt = Date.now();
|
|
128
144
|
const db = getDb();
|
|
129
145
|
db.prepare(`UPDATE worker_sessions SET status = 'running', updated_at = CURRENT_TIMESTAMP WHERE name = ?`).run(args.name);
|
|
146
|
+
deps.onWorkerEvent?.({ type: "dispatched", name: args.name });
|
|
130
147
|
const timeoutMs = config.workerTimeoutMs;
|
|
131
148
|
// Non-blocking: dispatch work and return immediately
|
|
132
149
|
worker.session
|
|
133
150
|
.sendAndWait({ prompt: args.prompt }, timeoutMs)
|
|
134
151
|
.then((result) => {
|
|
135
152
|
worker.lastOutput = result?.data?.content || "No response";
|
|
153
|
+
deps.onWorkerEvent?.({ type: "completed", name: args.name });
|
|
136
154
|
deps.onWorkerComplete(args.name, worker.lastOutput);
|
|
137
155
|
})
|
|
138
156
|
.catch((err) => {
|
|
139
157
|
const errMsg = formatWorkerError(args.name, worker.startedAt, timeoutMs, err);
|
|
140
158
|
worker.lastOutput = errMsg;
|
|
159
|
+
deps.onWorkerEvent?.({ type: "error", name: args.name, error: errMsg });
|
|
141
160
|
deps.onWorkerComplete(args.name, errMsg);
|
|
142
161
|
})
|
|
143
162
|
.finally(() => {
|
|
144
163
|
// Auto-destroy after each send_to_worker dispatch to free memory
|
|
145
164
|
worker.session.destroy().catch(() => { });
|
|
146
165
|
deps.workers.delete(args.name);
|
|
147
|
-
|
|
166
|
+
try {
|
|
167
|
+
getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
|
|
168
|
+
}
|
|
169
|
+
catch (cleanupErr) {
|
|
170
|
+
console.error(`[nzb] Worker '${args.name}' DB cleanup failed:`, cleanupErr instanceof Error ? cleanupErr.message : cleanupErr);
|
|
171
|
+
}
|
|
148
172
|
});
|
|
149
173
|
return `Task dispatched to worker '${args.name}'. I'll notify you when it's done.`;
|
|
150
174
|
},
|
package/dist/daemon.js
CHANGED
|
@@ -1,26 +1,14 @@
|
|
|
1
1
|
import { spawn } from "child_process";
|
|
2
|
-
import { existsSync } from "fs";
|
|
3
2
|
import { broadcastToSSE, startApiServer } from "./api/server.js";
|
|
4
3
|
import { config } from "./config.js";
|
|
5
4
|
import { getClient, stopClient } from "./copilot/client.js";
|
|
6
|
-
import { getWorkers, initOrchestrator, setMessageLogger, setProactiveNotify } from "./copilot/orchestrator.js";
|
|
5
|
+
import { getWorkers, initOrchestrator, setMessageLogger, setProactiveNotify, setWorkerNotify, stopHealthCheck, } from "./copilot/orchestrator.js";
|
|
7
6
|
import { closeDb, getDb } from "./store/db.js";
|
|
8
|
-
import { createBot, sendProactiveMessage, startBot, stopBot } from "./telegram/bot.js";
|
|
7
|
+
import { createBot, sendProactiveMessage, sendWorkerNotification, startBot, stopBot } from "./telegram/bot.js";
|
|
9
8
|
import { checkForUpdate } from "./update.js";
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
if (!process.env.NODE_EXTRA_CA_CERTS) {
|
|
14
|
-
const knownCaBundles = [
|
|
15
|
-
"/etc/ssl/certs/ca-certificates.crt", // Debian/Ubuntu
|
|
16
|
-
"/etc/pki/tls/certs/ca-bundle.crt", // RHEL/CentOS/Fedora
|
|
17
|
-
"/etc/ssl/cert.pem", // macOS / Alpine
|
|
18
|
-
];
|
|
19
|
-
const found = knownCaBundles.find((p) => existsSync(p));
|
|
20
|
-
if (found) {
|
|
21
|
-
process.env.NODE_EXTRA_CA_CERTS = found;
|
|
22
|
-
console.log(`[nzb] Auto-detected system CA bundle: ${found}`);
|
|
23
|
-
}
|
|
9
|
+
// Log the active CA bundle (injected by cli.ts via re-exec).
|
|
10
|
+
if (process.env.NODE_EXTRA_CA_CERTS) {
|
|
11
|
+
console.log(`[nzb] Using system CA bundle: ${process.env.NODE_EXTRA_CA_CERTS}`);
|
|
24
12
|
}
|
|
25
13
|
function truncate(text, max = 200) {
|
|
26
14
|
const oneLine = text.replace(/\n/g, " ").trim();
|
|
@@ -59,6 +47,32 @@ async function main() {
|
|
|
59
47
|
broadcastToSSE(text);
|
|
60
48
|
}
|
|
61
49
|
});
|
|
50
|
+
// Wire up worker lifecycle notifications
|
|
51
|
+
setWorkerNotify((event, channel) => {
|
|
52
|
+
let msg;
|
|
53
|
+
switch (event.type) {
|
|
54
|
+
case "created":
|
|
55
|
+
msg = `⚙️ Worker '${event.name}' created in ${event.workingDir}`;
|
|
56
|
+
break;
|
|
57
|
+
case "dispatched":
|
|
58
|
+
msg = `▶️ Worker '${event.name}' started working...`;
|
|
59
|
+
break;
|
|
60
|
+
case "completed":
|
|
61
|
+
msg = `✅ Worker '${event.name}' finished`;
|
|
62
|
+
break;
|
|
63
|
+
case "error":
|
|
64
|
+
msg = `❌ Worker '${event.name}' failed: ${event.error}`;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
console.log(`[nzb] worker-event (${channel ?? "all"}) ${msg}`);
|
|
68
|
+
if (!channel || channel === "telegram") {
|
|
69
|
+
if (config.telegramEnabled)
|
|
70
|
+
sendWorkerNotification(msg).catch(() => { });
|
|
71
|
+
}
|
|
72
|
+
if (!channel || channel === "tui") {
|
|
73
|
+
broadcastToSSE(msg);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
62
76
|
// Start HTTP API for TUI
|
|
63
77
|
await startApiServer();
|
|
64
78
|
// Start Telegram bot (if configured)
|
|
@@ -119,6 +133,8 @@ async function shutdown() {
|
|
|
119
133
|
process.exit(1);
|
|
120
134
|
}, 3000);
|
|
121
135
|
forceTimer.unref();
|
|
136
|
+
// Stop health check timer first
|
|
137
|
+
stopHealthCheck();
|
|
122
138
|
if (config.telegramEnabled) {
|
|
123
139
|
try {
|
|
124
140
|
await stopBot();
|
|
@@ -143,6 +159,7 @@ async function shutdown() {
|
|
|
143
159
|
/** Restart the daemon by spawning a new process and exiting. */
|
|
144
160
|
export async function restartDaemon() {
|
|
145
161
|
console.log("[nzb] Restarting...");
|
|
162
|
+
stopHealthCheck();
|
|
146
163
|
const activeWorkers = getWorkers();
|
|
147
164
|
const runningCount = Array.from(activeWorkers.values()).filter((w) => w.status === "running").length;
|
|
148
165
|
if (runningCount > 0) {
|
package/dist/store/db.js
CHANGED
|
@@ -2,6 +2,8 @@ import Database from "better-sqlite3";
|
|
|
2
2
|
import { DB_PATH, ensureNZBHome } from "../paths.js";
|
|
3
3
|
let db;
|
|
4
4
|
let logInsertCount = 0;
|
|
5
|
+
// Cached prepared statements for hot-path queries (created lazily after DB init)
|
|
6
|
+
let stmtCache;
|
|
5
7
|
export function getDb() {
|
|
6
8
|
if (!db) {
|
|
7
9
|
ensureNZBHome();
|
|
@@ -66,31 +68,42 @@ export function getDb() {
|
|
|
66
68
|
}
|
|
67
69
|
// Prune conversation log at startup
|
|
68
70
|
db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 200)`).run();
|
|
71
|
+
// Initialize cached prepared statements for hot-path operations
|
|
72
|
+
stmtCache = {
|
|
73
|
+
getState: db.prepare(`SELECT value FROM nzb_state WHERE key = ?`),
|
|
74
|
+
setState: db.prepare(`INSERT OR REPLACE INTO nzb_state (key, value) VALUES (?, ?)`),
|
|
75
|
+
deleteState: db.prepare(`DELETE FROM nzb_state WHERE key = ?`),
|
|
76
|
+
logConversation: db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES (?, ?, ?)`),
|
|
77
|
+
pruneConversation: db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 200)`),
|
|
78
|
+
addMemory: db.prepare(`INSERT INTO memories (category, content, source) VALUES (?, ?, ?)`),
|
|
79
|
+
removeMemory: db.prepare(`DELETE FROM memories WHERE id = ?`),
|
|
80
|
+
memorySummary: db.prepare(`SELECT id, category, content FROM memories ORDER BY category, last_accessed DESC`),
|
|
81
|
+
};
|
|
69
82
|
}
|
|
70
83
|
return db;
|
|
71
84
|
}
|
|
72
85
|
export function getState(key) {
|
|
73
|
-
|
|
74
|
-
const row =
|
|
86
|
+
getDb(); // ensure init
|
|
87
|
+
const row = stmtCache.getState.get(key);
|
|
75
88
|
return row?.value;
|
|
76
89
|
}
|
|
77
90
|
export function setState(key, value) {
|
|
78
|
-
|
|
79
|
-
|
|
91
|
+
getDb(); // ensure init
|
|
92
|
+
stmtCache.setState.run(key, value);
|
|
80
93
|
}
|
|
81
94
|
/** Remove a key from persistent state. */
|
|
82
95
|
export function deleteState(key) {
|
|
83
|
-
|
|
84
|
-
|
|
96
|
+
getDb(); // ensure init
|
|
97
|
+
stmtCache.deleteState.run(key);
|
|
85
98
|
}
|
|
86
99
|
/** Log a conversation turn (user, assistant, or system). */
|
|
87
100
|
export function logConversation(role, content, source) {
|
|
88
|
-
|
|
89
|
-
|
|
101
|
+
getDb(); // ensure init
|
|
102
|
+
stmtCache.logConversation.run(role, content, source);
|
|
90
103
|
// Keep last 200 entries to support context recovery after session loss
|
|
91
104
|
logInsertCount++;
|
|
92
105
|
if (logInsertCount % 50 === 0) {
|
|
93
|
-
|
|
106
|
+
stmtCache.pruneConversation.run();
|
|
94
107
|
}
|
|
95
108
|
}
|
|
96
109
|
/** Get recent conversation history formatted for injection into system message. */
|
|
@@ -114,10 +127,8 @@ export function getRecentConversation(limit = 20) {
|
|
|
114
127
|
}
|
|
115
128
|
/** Add a memory to long-term storage. */
|
|
116
129
|
export function addMemory(category, content, source = "user") {
|
|
117
|
-
|
|
118
|
-
const result =
|
|
119
|
-
.prepare(`INSERT INTO memories (category, content, source) VALUES (?, ?, ?)`)
|
|
120
|
-
.run(category, content, source);
|
|
130
|
+
getDb(); // ensure init
|
|
131
|
+
const result = stmtCache.addMemory.run(category, content, source);
|
|
121
132
|
return result.lastInsertRowid;
|
|
122
133
|
}
|
|
123
134
|
/** Search memories by keyword and/or category. */
|
|
@@ -147,14 +158,14 @@ export function searchMemories(keyword, category, limit = 20) {
|
|
|
147
158
|
}
|
|
148
159
|
/** Remove a memory by ID. */
|
|
149
160
|
export function removeMemory(id) {
|
|
150
|
-
|
|
151
|
-
const result =
|
|
161
|
+
getDb(); // ensure init
|
|
162
|
+
const result = stmtCache.removeMemory.run(id);
|
|
152
163
|
return result.changes > 0;
|
|
153
164
|
}
|
|
154
165
|
/** Get a compact summary of all memories for injection into system message. */
|
|
155
166
|
export function getMemorySummary() {
|
|
156
|
-
|
|
157
|
-
const rows =
|
|
167
|
+
getDb(); // ensure init
|
|
168
|
+
const rows = stmtCache.memorySummary.all();
|
|
158
169
|
if (rows.length === 0)
|
|
159
170
|
return "";
|
|
160
171
|
// Group by category
|
|
@@ -172,6 +183,7 @@ export function getMemorySummary() {
|
|
|
172
183
|
}
|
|
173
184
|
export function closeDb() {
|
|
174
185
|
if (db) {
|
|
186
|
+
stmtCache = undefined;
|
|
175
187
|
db.close();
|
|
176
188
|
db = undefined;
|
|
177
189
|
}
|
package/dist/telegram/bot.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Bot } from "grammy";
|
|
1
|
+
import { Bot, InlineKeyboard } from "grammy";
|
|
2
2
|
import { Agent as HttpsAgent } from "https";
|
|
3
3
|
import { config, persistModel } from "../config.js";
|
|
4
4
|
import { cancelCurrentMessage, getQueueSize, getWorkers, sendToOrchestrator } from "../copilot/orchestrator.js";
|
|
@@ -8,6 +8,16 @@ import { searchMemories } from "../store/db.js";
|
|
|
8
8
|
import { chunkMessage, toTelegramMarkdown } from "./formatter.js";
|
|
9
9
|
let bot;
|
|
10
10
|
const startedAt = Date.now();
|
|
11
|
+
// Inline keyboard menu for quick actions
|
|
12
|
+
const mainMenu = new InlineKeyboard()
|
|
13
|
+
.text("📊 Status", "action:status")
|
|
14
|
+
.text("🤖 Model", "action:model")
|
|
15
|
+
.row()
|
|
16
|
+
.text("👥 Workers", "action:workers")
|
|
17
|
+
.text("🧠 Skills", "action:skills")
|
|
18
|
+
.row()
|
|
19
|
+
.text("🗂 Memory", "action:memory")
|
|
20
|
+
.text("❌ Cancel", "action:cancel");
|
|
11
21
|
// Direct-connection HTTPS agent for Telegram API requests.
|
|
12
22
|
// This bypasses corporate proxy (HTTP_PROXY/HTTPS_PROXY env vars) without
|
|
13
23
|
// modifying process.env, so other services (Copilot SDK, MCP, npm) are unaffected.
|
|
@@ -36,8 +46,8 @@ export function createBot() {
|
|
|
36
46
|
}
|
|
37
47
|
await next();
|
|
38
48
|
});
|
|
39
|
-
// /start and /help
|
|
40
|
-
bot.command("start", (ctx) => ctx.reply("NZB is online. Send me anything
|
|
49
|
+
// /start and /help — with inline menu
|
|
50
|
+
bot.command("start", (ctx) => ctx.reply("NZB is online. Send me anything, or use the menu below:", { reply_markup: mainMenu }));
|
|
41
51
|
bot.command("help", (ctx) => ctx.reply("I'm NZB, your AI daemon.\n\n" +
|
|
42
52
|
"Just send me a message and I'll handle it.\n\n" +
|
|
43
53
|
"Commands:\n" +
|
|
@@ -49,7 +59,7 @@ export function createBot() {
|
|
|
49
59
|
"/workers — List active worker sessions\n" +
|
|
50
60
|
"/status — Show system status\n" +
|
|
51
61
|
"/restart — Restart NZB\n" +
|
|
52
|
-
"/help — Show this help"));
|
|
62
|
+
"/help — Show this help", { reply_markup: mainMenu }));
|
|
53
63
|
bot.command("cancel", async (ctx) => {
|
|
54
64
|
const cancelled = await cancelCurrentMessage();
|
|
55
65
|
await ctx.reply(cancelled ? "Cancelled." : "Nothing to cancel.");
|
|
@@ -138,6 +148,66 @@ export function createBot() {
|
|
|
138
148
|
});
|
|
139
149
|
}, 500);
|
|
140
150
|
});
|
|
151
|
+
// Callback query handlers for inline menu buttons
|
|
152
|
+
bot.callbackQuery("action:status", async (ctx) => {
|
|
153
|
+
await ctx.answerCallbackQuery();
|
|
154
|
+
const uptime = Math.floor((Date.now() - startedAt) / 1000);
|
|
155
|
+
const hours = Math.floor(uptime / 3600);
|
|
156
|
+
const minutes = Math.floor((uptime % 3600) / 60);
|
|
157
|
+
const seconds = uptime % 60;
|
|
158
|
+
const uptimeStr = hours > 0 ? `${hours}h ${minutes}m ${seconds}s` : minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
|
|
159
|
+
const workers = Array.from(getWorkers().values());
|
|
160
|
+
const lines = [
|
|
161
|
+
"📊 NZB Status",
|
|
162
|
+
`Model: ${config.copilotModel}`,
|
|
163
|
+
`Uptime: ${uptimeStr}`,
|
|
164
|
+
`Workers: ${workers.length} active`,
|
|
165
|
+
`Queue: ${getQueueSize()} pending`,
|
|
166
|
+
];
|
|
167
|
+
await ctx.reply(lines.join("\n"));
|
|
168
|
+
});
|
|
169
|
+
bot.callbackQuery("action:model", async (ctx) => {
|
|
170
|
+
await ctx.answerCallbackQuery();
|
|
171
|
+
await ctx.reply(`Current model: ${config.copilotModel}`);
|
|
172
|
+
});
|
|
173
|
+
bot.callbackQuery("action:workers", async (ctx) => {
|
|
174
|
+
await ctx.answerCallbackQuery();
|
|
175
|
+
const workers = Array.from(getWorkers().values());
|
|
176
|
+
if (workers.length === 0) {
|
|
177
|
+
await ctx.reply("No active worker sessions.");
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
const lines = workers.map((w) => `• ${w.name} (${w.workingDir}) — ${w.status}`);
|
|
181
|
+
await ctx.reply(lines.join("\n"));
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
bot.callbackQuery("action:skills", async (ctx) => {
|
|
185
|
+
await ctx.answerCallbackQuery();
|
|
186
|
+
const skills = listSkills();
|
|
187
|
+
if (skills.length === 0) {
|
|
188
|
+
await ctx.reply("No skills installed.");
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const lines = skills.map((s) => `• ${s.name} (${s.source}) — ${s.description}`);
|
|
192
|
+
await ctx.reply(lines.join("\n"));
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
bot.callbackQuery("action:memory", async (ctx) => {
|
|
196
|
+
await ctx.answerCallbackQuery();
|
|
197
|
+
const memories = searchMemories(undefined, undefined, 50);
|
|
198
|
+
if (memories.length === 0) {
|
|
199
|
+
await ctx.reply("No memories stored.");
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const lines = memories.map((m) => `#${m.id} [${m.category}] ${m.content}`);
|
|
203
|
+
await ctx.reply(lines.join("\n") + `\n\n${memories.length} total`);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
bot.callbackQuery("action:cancel", async (ctx) => {
|
|
207
|
+
await ctx.answerCallbackQuery();
|
|
208
|
+
const cancelled = await cancelCurrentMessage();
|
|
209
|
+
await ctx.reply(cancelled ? "Cancelled." : "Nothing to cancel.");
|
|
210
|
+
});
|
|
141
211
|
// Handle all text messages — progressive streaming with tool event feedback
|
|
142
212
|
bot.on("message:text", async (ctx) => {
|
|
143
213
|
const chatId = ctx.chat.id;
|
|
@@ -178,7 +248,9 @@ export function createBot() {
|
|
|
178
248
|
let currentToolName;
|
|
179
249
|
let finalized = false;
|
|
180
250
|
let editChain = Promise.resolve();
|
|
181
|
-
const EDIT_INTERVAL_MS =
|
|
251
|
+
const EDIT_INTERVAL_MS = 5000;
|
|
252
|
+
// Minimum character delta before sending an edit — avoids wasting API calls on tiny changes
|
|
253
|
+
const MIN_EDIT_DELTA = 100;
|
|
182
254
|
// Minimum time before showing the first placeholder, so user sees "typing" first
|
|
183
255
|
const FIRST_PLACEHOLDER_DELAY_MS = 1500;
|
|
184
256
|
const handlerStartTime = Date.now();
|
|
@@ -200,6 +272,8 @@ export function createBot() {
|
|
|
200
272
|
try {
|
|
201
273
|
const msg = await ctx.reply(text, { reply_parameters: replyParams });
|
|
202
274
|
placeholderMsgId = msg.message_id;
|
|
275
|
+
// Stop typing once placeholder is visible — edits serve as the indicator now
|
|
276
|
+
stopTyping();
|
|
203
277
|
}
|
|
204
278
|
catch {
|
|
205
279
|
return;
|
|
@@ -227,12 +301,45 @@ export function createBot() {
|
|
|
227
301
|
currentToolName = undefined;
|
|
228
302
|
}
|
|
229
303
|
};
|
|
304
|
+
// Notify user if their message is queued behind others
|
|
305
|
+
const queueSize = getQueueSize();
|
|
306
|
+
if (queueSize > 0) {
|
|
307
|
+
try {
|
|
308
|
+
await ctx.reply(`\u23f3 Queued (position ${queueSize + 1}) — I'll get to your message shortly.`, {
|
|
309
|
+
reply_parameters: replyParams,
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
/* best-effort */
|
|
314
|
+
}
|
|
315
|
+
}
|
|
230
316
|
sendToOrchestrator(ctx.message.text, { type: "telegram", chatId, messageId: userMessageId }, (text, done) => {
|
|
231
317
|
if (done) {
|
|
232
318
|
finalized = true;
|
|
233
319
|
stopTyping();
|
|
234
320
|
// Wait for in-flight edits to finish before sending the final response
|
|
235
321
|
void editChain.then(async () => {
|
|
322
|
+
// Format error messages with a distinct visual
|
|
323
|
+
const isError = text.startsWith("Error:");
|
|
324
|
+
if (isError) {
|
|
325
|
+
const errorText = `⚠️ ${text}`;
|
|
326
|
+
if (placeholderMsgId) {
|
|
327
|
+
try {
|
|
328
|
+
await bot.api.editMessageText(chatId, placeholderMsgId, errorText);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
/* fall through */
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
try {
|
|
336
|
+
await ctx.reply(errorText, { reply_parameters: replyParams });
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
/* nothing more we can do */
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
236
343
|
const formatted = toTelegramMarkdown(text);
|
|
237
344
|
const chunks = chunkMessage(formatted);
|
|
238
345
|
const fallbackChunks = chunkMessage(text);
|
|
@@ -261,23 +368,32 @@ export function createBot() {
|
|
|
261
368
|
/* ignore */
|
|
262
369
|
}
|
|
263
370
|
}
|
|
264
|
-
const
|
|
371
|
+
const totalChunks = chunks.length;
|
|
372
|
+
const sendChunk = async (chunk, fallback, index) => {
|
|
373
|
+
const isFirst = index === 0;
|
|
374
|
+
// Pagination header for multi-chunk messages
|
|
375
|
+
const pageTag = totalChunks > 1 ? `📄 ${index + 1}/${totalChunks}\n` : "";
|
|
265
376
|
const opts = isFirst
|
|
266
377
|
? { parse_mode: "MarkdownV2", reply_parameters: replyParams }
|
|
267
378
|
: { parse_mode: "MarkdownV2" };
|
|
268
379
|
await ctx
|
|
269
|
-
.reply(chunk, opts)
|
|
270
|
-
.catch(() => ctx.reply(fallback, isFirst ? { reply_parameters: replyParams } : {}));
|
|
380
|
+
.reply(pageTag + chunk, opts)
|
|
381
|
+
.catch(() => ctx.reply(pageTag + fallback, isFirst ? { reply_parameters: replyParams } : {}));
|
|
271
382
|
};
|
|
272
383
|
try {
|
|
273
384
|
for (let i = 0; i < chunks.length; i++) {
|
|
274
|
-
|
|
385
|
+
if (i > 0)
|
|
386
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
387
|
+
await sendChunk(chunks[i], fallbackChunks[i] ?? chunks[i], i);
|
|
275
388
|
}
|
|
276
389
|
}
|
|
277
390
|
catch {
|
|
278
391
|
try {
|
|
279
392
|
for (let i = 0; i < fallbackChunks.length; i++) {
|
|
280
|
-
|
|
393
|
+
if (i > 0)
|
|
394
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
395
|
+
const pageTag = fallbackChunks.length > 1 ? `📄 ${i + 1}/${fallbackChunks.length}\n` : "";
|
|
396
|
+
await ctx.reply(pageTag + fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
|
|
281
397
|
}
|
|
282
398
|
}
|
|
283
399
|
catch {
|
|
@@ -287,11 +403,19 @@ export function createBot() {
|
|
|
287
403
|
});
|
|
288
404
|
}
|
|
289
405
|
else {
|
|
290
|
-
// Progressive streaming: update placeholder periodically
|
|
406
|
+
// Progressive streaming: update placeholder periodically with delta threshold
|
|
291
407
|
const now = Date.now();
|
|
292
|
-
|
|
408
|
+
const textDelta = Math.abs(text.length - lastEditedText.length);
|
|
409
|
+
if (now - lastEditTime >= EDIT_INTERVAL_MS && textDelta >= MIN_EDIT_DELTA) {
|
|
293
410
|
lastEditTime = now;
|
|
294
|
-
|
|
411
|
+
// Show beginning + end for context instead of just the tail
|
|
412
|
+
let preview;
|
|
413
|
+
if (text.length > 4000) {
|
|
414
|
+
preview = text.slice(0, 1800) + "\n\n⋯\n\n" + text.slice(-1800);
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
preview = text;
|
|
418
|
+
}
|
|
295
419
|
const statusLine = currentToolName ? `🔧 ${currentToolName}\n\n` : "";
|
|
296
420
|
enqueueEdit(statusLine + preview);
|
|
297
421
|
}
|
|
@@ -304,6 +428,24 @@ export async function startBot() {
|
|
|
304
428
|
if (!bot)
|
|
305
429
|
throw new Error("Bot not created");
|
|
306
430
|
console.log("[nzb] Telegram bot starting...");
|
|
431
|
+
// Register commands with Telegram so users see the menu in the text input field
|
|
432
|
+
try {
|
|
433
|
+
await bot.api.setMyCommands([
|
|
434
|
+
{ command: "start", description: "Start the bot" },
|
|
435
|
+
{ command: "help", description: "Show help text" },
|
|
436
|
+
{ command: "cancel", description: "Cancel current message" },
|
|
437
|
+
{ command: "model", description: "Show/switch AI model" },
|
|
438
|
+
{ command: "status", description: "Show system status" },
|
|
439
|
+
{ command: "workers", description: "List active workers" },
|
|
440
|
+
{ command: "skills", description: "List installed skills" },
|
|
441
|
+
{ command: "memory", description: "Show stored memories" },
|
|
442
|
+
{ command: "restart", description: "Restart NZB" },
|
|
443
|
+
]);
|
|
444
|
+
console.log("[nzb] Bot commands registered with Telegram");
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
console.error("[nzb] Failed to register bot commands:", err instanceof Error ? err.message : err);
|
|
448
|
+
}
|
|
307
449
|
bot
|
|
308
450
|
.start({
|
|
309
451
|
onStart: () => console.log("[nzb] Telegram bot connected"),
|
|
@@ -333,12 +475,15 @@ export async function sendProactiveMessage(text) {
|
|
|
333
475
|
const chunks = chunkMessage(formatted);
|
|
334
476
|
const fallbackChunks = chunkMessage(text);
|
|
335
477
|
for (let i = 0; i < chunks.length; i++) {
|
|
478
|
+
if (i > 0)
|
|
479
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
480
|
+
const pageTag = chunks.length > 1 ? `📄 ${i + 1}/${chunks.length}\n` : "";
|
|
336
481
|
try {
|
|
337
|
-
await bot.api.sendMessage(config.authorizedUserId, chunks[i], { parse_mode: "MarkdownV2" });
|
|
482
|
+
await bot.api.sendMessage(config.authorizedUserId, pageTag + chunks[i], { parse_mode: "MarkdownV2" });
|
|
338
483
|
}
|
|
339
484
|
catch {
|
|
340
485
|
try {
|
|
341
|
-
await bot.api.sendMessage(config.authorizedUserId, fallbackChunks[i] ?? chunks[i]);
|
|
486
|
+
await bot.api.sendMessage(config.authorizedUserId, pageTag + (fallbackChunks[i] ?? chunks[i]));
|
|
342
487
|
}
|
|
343
488
|
catch {
|
|
344
489
|
// Bot may not be connected yet
|
|
@@ -346,6 +491,17 @@ export async function sendProactiveMessage(text) {
|
|
|
346
491
|
}
|
|
347
492
|
}
|
|
348
493
|
}
|
|
494
|
+
/** Send a worker lifecycle notification to the authorized user. */
|
|
495
|
+
export async function sendWorkerNotification(message) {
|
|
496
|
+
if (!bot || config.authorizedUserId === undefined)
|
|
497
|
+
return;
|
|
498
|
+
try {
|
|
499
|
+
await bot.api.sendMessage(config.authorizedUserId, message);
|
|
500
|
+
}
|
|
501
|
+
catch {
|
|
502
|
+
// best-effort — don't crash if notification fails
|
|
503
|
+
}
|
|
504
|
+
}
|
|
349
505
|
/** Send a photo to the authorized user. Accepts a file path or URL. */
|
|
350
506
|
export async function sendPhoto(photo, caption) {
|
|
351
507
|
if (!bot || config.authorizedUserId === undefined)
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
const TELEGRAM_MAX_LENGTH = 4096;
|
|
2
|
+
// Reserve space for code block closure markers and pagination prefix
|
|
3
|
+
const CHUNK_TARGET = TELEGRAM_MAX_LENGTH - 20;
|
|
2
4
|
/**
|
|
3
5
|
* Split a long message into chunks that fit within Telegram's message limit.
|
|
4
|
-
*
|
|
6
|
+
* Code-block-aware: if a split falls inside a fenced code block, the block is
|
|
7
|
+
* closed at the split and reopened in the next chunk so MarkdownV2 stays valid.
|
|
5
8
|
*/
|
|
6
9
|
export function chunkMessage(text) {
|
|
7
10
|
if (text.length <= TELEGRAM_MAX_LENGTH) {
|
|
@@ -14,15 +17,25 @@ export function chunkMessage(text) {
|
|
|
14
17
|
chunks.push(remaining);
|
|
15
18
|
break;
|
|
16
19
|
}
|
|
17
|
-
let splitAt = remaining.lastIndexOf("\n",
|
|
18
|
-
if (splitAt <
|
|
19
|
-
splitAt = remaining.lastIndexOf(" ",
|
|
20
|
+
let splitAt = remaining.lastIndexOf("\n", CHUNK_TARGET);
|
|
21
|
+
if (splitAt < CHUNK_TARGET * 0.3) {
|
|
22
|
+
splitAt = remaining.lastIndexOf(" ", CHUNK_TARGET);
|
|
20
23
|
}
|
|
21
|
-
if (splitAt <
|
|
22
|
-
splitAt =
|
|
24
|
+
if (splitAt < CHUNK_TARGET * 0.3) {
|
|
25
|
+
splitAt = CHUNK_TARGET;
|
|
26
|
+
}
|
|
27
|
+
const segment = remaining.slice(0, splitAt);
|
|
28
|
+
// Count ``` markers — odd means we're splitting inside a code block
|
|
29
|
+
const markers = segment.match(/```/g);
|
|
30
|
+
const insideCodeBlock = markers !== null && markers.length % 2 !== 0;
|
|
31
|
+
if (insideCodeBlock) {
|
|
32
|
+
chunks.push(segment + "\n```");
|
|
33
|
+
remaining = "```\n" + remaining.slice(splitAt).trimStart();
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
chunks.push(segment);
|
|
37
|
+
remaining = remaining.slice(splitAt).trimStart();
|
|
23
38
|
}
|
|
24
|
-
chunks.push(remaining.slice(0, splitAt));
|
|
25
|
-
remaining = remaining.slice(splitAt).trimStart();
|
|
26
39
|
}
|
|
27
40
|
return chunks;
|
|
28
41
|
}
|