@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 CHANGED
@@ -1,8 +1,27 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from "fs";
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"));
@@ -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 (client) {
16
- try {
17
- await client.stop();
18
- }
19
- catch {
20
- /* best-effort */
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
- client = undefined;
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
- return parsed.mcpServers;
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
- return {};
32
+ cachedConfig = {};
33
+ return cachedConfig;
17
34
  }
18
35
  catch {
19
- return {};
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, { selfEditEnabled: config.selfEditEnabled }),
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"));
@@ -1,6 +1,6 @@
1
- import { readdirSync, readFileSync, mkdirSync, writeFileSync, existsSync, rmSync } from "fs";
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
- /** Returns all skill directories that exist on disk. */
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
- if (!skillDir.startsWith(LOCAL_SKILLS_DIR + "/")) {
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
- if (!skillDir.startsWith(LOCAL_SKILLS_DIR + "/")) {
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
- return `You are NZB, a personal AI assistant for developers running 24/7 on the user's machine (${osName}). You are the user's always-on assistant.
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
 
@@ -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
- const session = await deps.client.createSession({
64
- model: config.copilotModel,
65
- configDir: SESSIONS_DIR,
66
- workingDirectory: args.working_dir,
67
- onPermissionRequest: approveAll,
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
- getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
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
- getDb().prepare(`DELETE FROM worker_sessions WHERE name = ?`).run(args.name);
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
- // Auto-detect system CA bundle for corporate environments with TLS inspection.
11
- // NODE_EXTRA_CA_CERTS must be set before any TLS connection is made.
12
- // Users can override via NODE_EXTRA_CA_CERTS env var or NODE_EXTRA_CA_CERTS in ~/.nzb/.env.
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
- const db = getDb();
74
- const row = db.prepare(`SELECT value FROM nzb_state WHERE key = ?`).get(key);
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
- const db = getDb();
79
- db.prepare(`INSERT OR REPLACE INTO nzb_state (key, value) VALUES (?, ?)`).run(key, value);
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
- const db = getDb();
84
- db.prepare(`DELETE FROM nzb_state WHERE key = ?`).run(key);
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
- const db = getDb();
89
- db.prepare(`INSERT INTO conversation_log (role, content, source) VALUES (?, ?, ?)`).run(role, content, source);
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
- db.prepare(`DELETE FROM conversation_log WHERE id NOT IN (SELECT id FROM conversation_log ORDER BY id DESC LIMIT 200)`).run();
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
- const db = getDb();
118
- const result = db
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
- const db = getDb();
151
- const result = db.prepare(`DELETE FROM memories WHERE id = ?`).run(id);
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
- const db = getDb();
157
- const rows = db.prepare(`SELECT id, category, content FROM memories ORDER BY category, last_accessed DESC`).all();
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
  }
@@ -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 = 3000;
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 sendChunk = async (chunk, fallback, isFirst) => {
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
- await sendChunk(chunks[i], fallbackChunks[i] ?? chunks[i], i === 0);
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
- await ctx.reply(fallbackChunks[i], i === 0 ? { reply_parameters: replyParams } : {});
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
- if (now - lastEditTime >= EDIT_INTERVAL_MS) {
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
- const preview = text.length > 4000 ? "…" + text.slice(-4000) : text;
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
- * Tries to split at newlines, then spaces, falling back to hard cuts.
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", TELEGRAM_MAX_LENGTH);
18
- if (splitAt < TELEGRAM_MAX_LENGTH * 0.3) {
19
- splitAt = remaining.lastIndexOf(" ", TELEGRAM_MAX_LENGTH);
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 < TELEGRAM_MAX_LENGTH * 0.3) {
22
- splitAt = TELEGRAM_MAX_LENGTH;
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iletai/nzb",
3
- "version": "1.1.2",
3
+ "version": "1.1.4",
4
4
  "description": "NZB — a personal AI assistant for developers, built on the GitHub Copilot SDK",
5
5
  "bin": {
6
6
  "nzb": "dist/cli.js"