@iletai/nzb 1.7.4 → 1.8.1

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
@@ -45,6 +45,9 @@ Commands:
45
45
  tui Connect to the daemon via terminal UI
46
46
  setup Interactive first-run configuration
47
47
  update Check for updates and install the latest version
48
+ update check Check for updates without installing
49
+ update --force Force reinstall the latest version
50
+ cron Manage scheduled cron jobs
48
51
  help Show this help message
49
52
 
50
53
  Flags (start):
@@ -76,21 +79,48 @@ switch (command) {
76
79
  await import("./setup.js");
77
80
  break;
78
81
  case "update": {
79
- const { checkForUpdate, performUpdate } = await import("./update.js");
82
+ const { checkForUpdate, performUpdate, performForceUpdate } = await import("./update.js");
83
+ const updateArgs = args.slice(1);
84
+ const subCmd = updateArgs[0];
85
+ const force = updateArgs.includes("--force");
86
+ // `nzb update check` — check only, don't install
87
+ if (subCmd === "check") {
88
+ const check = await checkForUpdate();
89
+ if (!check.checkSucceeded) {
90
+ console.error("Warning: Could not reach the npm registry. Check your network and try again.");
91
+ process.exit(1);
92
+ }
93
+ if (check.updateAvailable) {
94
+ console.log(`Update available: v${check.current} → v${check.latest}`);
95
+ if (check.publishedAt) {
96
+ console.log(`Published: ${new Date(check.publishedAt).toLocaleDateString()}`);
97
+ }
98
+ }
99
+ else {
100
+ console.log(`nzb v${check.current} is already the latest version.`);
101
+ }
102
+ break;
103
+ }
104
+ // `nzb update` or `nzb update --force` — check and install
80
105
  const check = await checkForUpdate();
81
106
  if (!check.checkSucceeded) {
82
107
  console.error("Warning: Could not reach the npm registry. Check your network and try again.");
83
108
  process.exit(1);
84
109
  }
85
- if (!check.updateAvailable) {
110
+ if (!check.updateAvailable && !force) {
86
111
  console.log(`nzb v${check.current} is already the latest version.`);
87
112
  break;
88
113
  }
89
- console.log(`Update available: v${check.current} → v${check.latest}`);
114
+ if (check.updateAvailable) {
115
+ console.log(`Update available: v${check.current} → v${check.latest}`);
116
+ }
117
+ else if (force) {
118
+ console.log(`Force reinstalling nzb v${check.current}...`);
119
+ }
90
120
  console.log("Installing...");
91
- const result = await performUpdate();
121
+ const result = force ? await performForceUpdate() : await performUpdate();
92
122
  if (result.ok) {
93
- console.log(`Updated to v${check.latest}`);
123
+ console.log(check.updateAvailable ? `Updated to v${check.latest}` : `Reinstalled v${check.current}`);
94
124
  }
95
125
  else {
96
126
  console.error(`Update failed: ${result.output}`);
@@ -98,6 +128,101 @@ switch (command) {
98
128
  }
99
129
  break;
100
130
  }
131
+ case "cron": {
132
+ const subcommand = args[1] || "list";
133
+ const { listCronJobs, createCronJob, deleteCronJob, updateCronJob } = await import("./store/cron-store.js");
134
+ switch (subcommand) {
135
+ case "list": {
136
+ const jobs = listCronJobs();
137
+ if (jobs.length === 0) {
138
+ console.log("No cron jobs configured.");
139
+ }
140
+ else {
141
+ for (const job of jobs) {
142
+ const status = job.enabled ? "✅" : "⏸️";
143
+ console.log(`${status} ${job.id} — ${job.name} [${job.taskType}] ${job.cronExpression}`);
144
+ }
145
+ }
146
+ break;
147
+ }
148
+ case "add": {
149
+ const id = args[2];
150
+ const name = args[3];
151
+ const cronExpr = args[4];
152
+ const taskType = args[5];
153
+ if (!id || !name || !cronExpr || !taskType) {
154
+ console.error("Usage: nzb cron add <id> <name> <cron-expression> <task-type> [payload-json]");
155
+ console.error("Task types: prompt, health_check, backup, notification, webhook");
156
+ process.exit(1);
157
+ }
158
+ const validTypes = ["prompt", "health_check", "backup", "notification", "webhook"];
159
+ if (!validTypes.includes(taskType)) {
160
+ console.error(`Invalid task type: ${taskType}. Valid: ${validTypes.join(", ")}`);
161
+ process.exit(1);
162
+ }
163
+ const { Cron } = await import("croner");
164
+ try {
165
+ new Cron(cronExpr);
166
+ }
167
+ catch {
168
+ console.error(`Invalid cron expression: ${cronExpr}`);
169
+ process.exit(1);
170
+ }
171
+ const payload = args[6] || "{}";
172
+ try {
173
+ const job = createCronJob({
174
+ id,
175
+ name,
176
+ cronExpression: cronExpr,
177
+ taskType: taskType,
178
+ payload,
179
+ });
180
+ console.log(`Created cron job '${job.id}' (${job.name}): ${job.cronExpression}`);
181
+ console.log("Note: The job will be scheduled when the daemon starts.");
182
+ }
183
+ catch (err) {
184
+ console.error("Error:", err instanceof Error ? err.message : err);
185
+ process.exit(1);
186
+ }
187
+ break;
188
+ }
189
+ case "remove": {
190
+ const removeId = args[2];
191
+ if (!removeId) {
192
+ console.error("Usage: nzb cron remove <id>");
193
+ process.exit(1);
194
+ }
195
+ const deleted = deleteCronJob(removeId);
196
+ console.log(deleted ? `Deleted cron job '${removeId}'.` : `Job '${removeId}' not found.`);
197
+ break;
198
+ }
199
+ case "enable": {
200
+ const enableId = args[2];
201
+ if (!enableId) {
202
+ console.error("Usage: nzb cron enable <id>");
203
+ process.exit(1);
204
+ }
205
+ const enabled = updateCronJob(enableId, { enabled: true });
206
+ console.log(enabled ? `Enabled cron job '${enableId}'.` : `Job '${enableId}' not found.`);
207
+ break;
208
+ }
209
+ case "disable": {
210
+ const disableId = args[2];
211
+ if (!disableId) {
212
+ console.error("Usage: nzb cron disable <id>");
213
+ process.exit(1);
214
+ }
215
+ const disabled = updateCronJob(disableId, { enabled: false });
216
+ console.log(disabled ? `Disabled cron job '${disableId}'.` : `Job '${disableId}' not found.`);
217
+ break;
218
+ }
219
+ default:
220
+ console.error(`Unknown cron subcommand: ${subcommand}`);
221
+ console.error("Available: list, add, remove, enable, disable");
222
+ process.exit(1);
223
+ }
224
+ break;
225
+ }
101
226
  case "help":
102
227
  case "--help":
103
228
  case "-h":
package/dist/config.js CHANGED
@@ -82,6 +82,10 @@ export const config = {
82
82
  groupMentionOnly: process.env.GROUP_MENTION_ONLY !== "false",
83
83
  /** Reasoning effort: low | medium | high */
84
84
  reasoningEffort: validateEnum(process.env.REASONING_EFFORT, ["low", "medium", "high"], "medium", "REASONING_EFFORT"),
85
+ /** Model failover chain: comma-separated list of fallback models */
86
+ modelFailoverChain: process.env.MODEL_FAILOVER_CHAIN?.split(",").map((s) => s.trim()).filter(Boolean) ?? [],
87
+ /** Cooldown duration (ms) for a model after failure before retrying it */
88
+ modelCooldownMs: parseInt(process.env.MODEL_COOLDOWN_MS ?? "60000"),
85
89
  };
86
90
  /** Persist an env variable to ~/.nzb/.env */
87
91
  export function persistEnvVar(key, value) {
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Model Failover Manager — tracks model health and selects fallback models
3
+ * when the primary model encounters errors (rate limits, timeouts, etc.).
4
+ *
5
+ * When MODEL_FAILOVER_CHAIN is empty, this module is a no-op:
6
+ * selectModel() returns the configured primary, and getNextFallback() returns undefined.
7
+ */
8
+ /** Detect the provider from a model name string. */
9
+ export function detectProvider(model) {
10
+ const lower = model.toLowerCase();
11
+ if (lower.startsWith("claude-"))
12
+ return "anthropic";
13
+ if (lower.startsWith("gpt-") || lower.startsWith("o1-") || lower.startsWith("o3-") || lower.startsWith("o4-"))
14
+ return "openai";
15
+ if (lower.startsWith("gemini-"))
16
+ return "google";
17
+ return "unknown";
18
+ }
19
+ /** Number of consecutive failures before a model is considered "degraded". */
20
+ const DEGRADED_THRESHOLD = 3;
21
+ export class ModelFailoverManager {
22
+ chain;
23
+ cooldownMs;
24
+ health = new Map();
25
+ constructor(chain, cooldownMs) {
26
+ this.chain = Array.isArray(chain) ? chain : [];
27
+ this.cooldownMs = cooldownMs || 60_000;
28
+ // Initialise health entries for every model in the chain
29
+ for (const model of this.chain) {
30
+ this.health.set(model, {
31
+ failures: 0,
32
+ lastFailure: undefined,
33
+ cooldownUntil: 0,
34
+ successCount: 0,
35
+ });
36
+ }
37
+ }
38
+ /** True when at least one fallback model is configured. */
39
+ get enabled() {
40
+ return this.chain.length > 0;
41
+ }
42
+ /**
43
+ * Select the best model to use right now.
44
+ * Returns the first healthy model from the chain, or undefined when the
45
+ * chain is empty (caller should fall back to `config.copilotModel`).
46
+ */
47
+ selectModel() {
48
+ if (this.chain.length === 0)
49
+ return undefined;
50
+ const now = Date.now();
51
+ for (const model of this.chain) {
52
+ const h = this.getOrCreate(model);
53
+ if (now >= h.cooldownUntil)
54
+ return model;
55
+ }
56
+ // All models are on cooldown — pick the one whose cooldown expires soonest
57
+ let earliest;
58
+ let earliestTime = Infinity;
59
+ for (const model of this.chain) {
60
+ const h = this.getOrCreate(model);
61
+ if (h.cooldownUntil < earliestTime) {
62
+ earliestTime = h.cooldownUntil;
63
+ earliest = model;
64
+ }
65
+ }
66
+ return earliest;
67
+ }
68
+ /** Record a successful request for `model`. Resets its failure counter. */
69
+ recordSuccess(model) {
70
+ const h = this.getOrCreate(model);
71
+ h.failures = 0;
72
+ h.cooldownUntil = 0;
73
+ h.successCount++;
74
+ }
75
+ /** Record a failed request for `model`. Applies cooldown after threshold. */
76
+ recordFailure(model) {
77
+ const h = this.getOrCreate(model);
78
+ h.failures++;
79
+ h.lastFailure = Date.now();
80
+ // Apply cooldown immediately on failure so we try a different model next
81
+ h.cooldownUntil = Date.now() + this.cooldownMs;
82
+ }
83
+ /**
84
+ * Get the next fallback model after `currentModel`.
85
+ * Prefers a model from a DIFFERENT provider to maximise availability.
86
+ */
87
+ getNextFallback(currentModel) {
88
+ if (this.chain.length === 0)
89
+ return undefined;
90
+ const now = Date.now();
91
+ const currentProvider = detectProvider(currentModel);
92
+ // First pass: healthy model from a different provider
93
+ for (const model of this.chain) {
94
+ if (model === currentModel)
95
+ continue;
96
+ const h = this.getOrCreate(model);
97
+ if (now >= h.cooldownUntil && detectProvider(model) !== currentProvider) {
98
+ return model;
99
+ }
100
+ }
101
+ // Second pass: any healthy model (same provider is OK)
102
+ for (const model of this.chain) {
103
+ if (model === currentModel)
104
+ continue;
105
+ const h = this.getOrCreate(model);
106
+ if (now >= h.cooldownUntil) {
107
+ return model;
108
+ }
109
+ }
110
+ return undefined;
111
+ }
112
+ /**
113
+ * Detect whether an error is a model-level error that warrants failover
114
+ * (as opposed to a generic connectivity issue that warrants simple retry).
115
+ */
116
+ isModelError(err) {
117
+ const msg = err instanceof Error ? err.message : String(err);
118
+ return /429|rate.?limit|too many requests|quota|capacity|overloaded|model.*not.*available|model.*error|resource.*exhausted/i.test(msg);
119
+ }
120
+ /** Return a snapshot of health status for every model in the chain. */
121
+ getHealthStatus() {
122
+ return this.chain.map((model) => {
123
+ const h = this.getOrCreate(model);
124
+ const now = Date.now();
125
+ let status;
126
+ if (h.failures >= DEGRADED_THRESHOLD) {
127
+ status = "degraded";
128
+ }
129
+ else if (now < h.cooldownUntil) {
130
+ status = "cooldown";
131
+ }
132
+ else {
133
+ status = "healthy";
134
+ }
135
+ return {
136
+ model,
137
+ provider: detectProvider(model),
138
+ status,
139
+ failures: h.failures,
140
+ successCount: h.successCount,
141
+ lastFailure: h.lastFailure ? new Date(h.lastFailure).toISOString() : undefined,
142
+ };
143
+ });
144
+ }
145
+ getOrCreate(model) {
146
+ let h = this.health.get(model);
147
+ if (!h) {
148
+ h = { failures: 0, lastFailure: undefined, cooldownUntil: 0, successCount: 0 };
149
+ this.health.set(model, h);
150
+ }
151
+ return h;
152
+ }
153
+ }
154
+ //# sourceMappingURL=model-failover.js.map
@@ -8,6 +8,7 @@ import { completeTeam, updateTeamMemberResult } from "../store/team-store.js";
8
8
  import { formatAge, withTimeout } from "../utils.js";
9
9
  import { resetClient } from "./client.js";
10
10
  import { loadMcpConfig } from "./mcp-config.js";
11
+ import { ModelFailoverManager } from "./model-failover.js";
11
12
  import { getSkillDirectories } from "./skills.js";
12
13
  import { getOrchestratorSystemMessage } from "./system-message.js";
13
14
  import { createTools } from "./tools.js";
@@ -32,6 +33,8 @@ const workers = new Map();
32
33
  const teams = new Map();
33
34
  let healthCheckTimer;
34
35
  let workerReaperTimer;
36
+ // Model failover manager — initialised lazily in initOrchestrator
37
+ let failoverManager;
35
38
  // Persistent orchestrator session
36
39
  let orchestratorSession;
37
40
  // Coalesces concurrent ensureOrchestratorSession calls
@@ -336,6 +339,11 @@ async function createOrResumeSession() {
336
339
  export async function initOrchestrator(client) {
337
340
  copilotClient = client;
338
341
  const { mcpServers, skillDirectories } = getSessionConfig();
342
+ // Initialise failover manager from config
343
+ failoverManager = new ModelFailoverManager(config.modelFailoverChain, config.modelCooldownMs);
344
+ if (failoverManager.enabled) {
345
+ console.log(`[nzb] Model failover chain: ${config.modelFailoverChain.join(" → ")}`);
346
+ }
339
347
  // Validate configured model against available models (skip for default — saves 1-3s startup)
340
348
  if (config.copilotModel !== DEFAULT_MODEL) {
341
349
  try {
@@ -605,6 +613,10 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
605
613
  processQueue();
606
614
  });
607
615
  // Deliver response to user FIRST, then log best-effort
616
+ // Record success for failover tracking
617
+ if (failoverManager?.enabled) {
618
+ failoverManager.recordSuccess(config.copilotModel);
619
+ }
608
620
  try {
609
621
  logMessage("out", sourceLabel, finalContent);
610
622
  }
@@ -669,6 +681,20 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
669
681
  continue;
670
682
  }
671
683
  if (isRecoverableError(err) && attempt < MAX_RETRIES) {
684
+ // Model failover: if it's a model-level error and we have fallbacks, switch model
685
+ if (failoverManager?.enabled && failoverManager.isModelError(err)) {
686
+ const failedModel = config.copilotModel;
687
+ failoverManager.recordFailure(failedModel);
688
+ const fallback = failoverManager.getNextFallback(failedModel);
689
+ if (fallback) {
690
+ console.log(`[nzb] Model failover: ${failedModel} → ${fallback} (${msg})`);
691
+ config.copilotModel = fallback;
692
+ // Force session recreation with the new model
693
+ orchestratorSession = undefined;
694
+ sessionCreatedAt = undefined;
695
+ deleteState(ORCHESTRATOR_SESSION_KEY);
696
+ }
697
+ }
672
698
  const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
673
699
  console.error(`[nzb] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
674
700
  await sleep(delay);
@@ -681,6 +707,27 @@ export async function sendToOrchestrator(prompt, source, callback, onToolEvent,
681
707
  }
682
708
  continue;
683
709
  }
710
+ // Model-level error with failover available — try switching model even if not normally recoverable
711
+ if (failoverManager?.enabled && failoverManager.isModelError(err) && attempt < MAX_RETRIES) {
712
+ const failedModel = config.copilotModel;
713
+ failoverManager.recordFailure(failedModel);
714
+ const fallback = failoverManager.getNextFallback(failedModel);
715
+ if (fallback) {
716
+ console.log(`[nzb] Model failover: ${failedModel} → ${fallback} (${msg})`);
717
+ config.copilotModel = fallback;
718
+ orchestratorSession = undefined;
719
+ sessionCreatedAt = undefined;
720
+ deleteState(ORCHESTRATOR_SESSION_KEY);
721
+ await sleep(RECONNECT_DELAYS_MS[0]);
722
+ try {
723
+ await ensureClient();
724
+ }
725
+ catch {
726
+ /* will fail again on next attempt */
727
+ }
728
+ continue;
729
+ }
730
+ }
684
731
  console.error(`[nzb] Error processing message: ${msg}`);
685
732
  if (!globalTimedOut) {
686
733
  await callback(`Error: ${msg}`, true);
@@ -767,4 +814,8 @@ export async function compactSession() {
767
814
  return `Compaction failed: ${err instanceof Error ? err.message : String(err)}`;
768
815
  }
769
816
  }
817
+ /** Expose the failover manager so tools can query health status. */
818
+ export function getFailoverManager() {
819
+ return failoverManager;
820
+ }
770
821
  //# sourceMappingURL=orchestrator.js.map
@@ -4,10 +4,13 @@ import { homedir } from "os";
4
4
  import { join, resolve, sep } from "path";
5
5
  import { z } from "zod";
6
6
  import { config, persistModel } from "../config.js";
7
+ import { scheduleJob, triggerJob, unscheduleJob, getSchedulerStatus } from "../cron/scheduler.js";
7
8
  import { SESSIONS_DIR } from "../paths.js";
8
9
  import { getDb } from "../store/db.js";
10
+ import { createCronJob, deleteCronJob, getCronJob, getRecentRuns, updateCronJob, } from "../store/cron-store.js";
9
11
  import { withTimeout } from "../utils.js";
10
12
  import { addMemory, removeMemory, searchMemories } from "../store/memory.js";
13
+ import { getFailoverManager } from "./orchestrator.js";
11
14
  import { createSkill, listSkills, removeSkill } from "./skills.js";
12
15
  function isTimeoutError(err) {
13
16
  const msg = err instanceof Error ? err.message : String(err);
@@ -652,6 +655,27 @@ export function createTools(deps) {
652
655
  }
653
656
  },
654
657
  }),
658
+ defineTool("model_health", {
659
+ description: "Show health status of all models in the failover chain. " +
660
+ "Displays model name, provider, status (healthy/cooldown/degraded), failure count, and last failure time. " +
661
+ "Use when the user asks about model health, failover status, or which models are available in the chain.",
662
+ parameters: z.object({}),
663
+ handler: async () => {
664
+ const manager = getFailoverManager();
665
+ if (!manager || !manager.enabled) {
666
+ return (`Model failover is not configured. Current model: ${config.copilotModel}\n\n` +
667
+ `To enable failover, set MODEL_FAILOVER_CHAIN in ~/.nzb/.env with a comma-separated list of models.\n` +
668
+ `Example: MODEL_FAILOVER_CHAIN=claude-sonnet-4.6,gpt-4.1,gemini-2.0-flash`);
669
+ }
670
+ const statuses = manager.getHealthStatus();
671
+ const lines = statuses.map((s) => {
672
+ const icon = s.status === "healthy" ? "✅" : s.status === "cooldown" ? "⏳" : "⚠️";
673
+ const lastFail = s.lastFailure ? ` (last fail: ${s.lastFailure})` : "";
674
+ return `${icon} ${s.model} [${s.provider}] — ${s.status} | failures: ${s.failures} | successes: ${s.successCount}${lastFail}`;
675
+ });
676
+ return `Model Failover Health (active: ${config.copilotModel}):\n${lines.join("\n")}`;
677
+ },
678
+ }),
655
679
  defineTool("remember", {
656
680
  description: "Save something to NZB's long-term memory. Use when the user says 'remember that...', " +
657
681
  "states a preference, shares a fact about themselves, or mentions something important " +
@@ -706,6 +730,111 @@ export function createTools(deps) {
706
730
  : `Memory #${args.memory_id} not found — it may have already been removed.`;
707
731
  },
708
732
  }),
733
+ defineTool("manage_cron", {
734
+ description: "Manage scheduled cron jobs. Use to create, list, remove, enable, disable, or trigger " +
735
+ "recurring tasks like health checks, backups, notifications, webhook calls, or scheduled prompts.",
736
+ parameters: z.object({
737
+ action: z
738
+ .enum(["list", "add", "remove", "enable", "disable", "trigger", "history"])
739
+ .describe("Action to perform"),
740
+ job_id: z.string().optional().describe("Job ID (required for remove/enable/disable/trigger/history)"),
741
+ name: z.string().optional().describe("Job name (required for add)"),
742
+ cron_expression: z
743
+ .string()
744
+ .optional()
745
+ .describe("Cron expression, e.g. '0 */6 * * *' for every 6 hours (required for add)"),
746
+ task_type: z
747
+ .enum(["prompt", "health_check", "backup", "notification", "webhook"])
748
+ .optional()
749
+ .describe("Task type (required for add)"),
750
+ payload: z.string().optional().describe("JSON payload for the task (optional for add)"),
751
+ notify_telegram: z.boolean().optional().describe("Send result to Telegram (default: true)"),
752
+ }),
753
+ handler: async (args) => {
754
+ try {
755
+ switch (args.action) {
756
+ case "list": {
757
+ const status = getSchedulerStatus();
758
+ if (status.length === 0)
759
+ return "No cron jobs configured.";
760
+ const lines = status.map((j) => `• ${j.id} — ${j.name} [${j.taskType}] ${j.enabled ? "✅" : "⏸️"} ` +
761
+ `${j.active ? "active" : "inactive"} | ${j.cronExpression}` +
762
+ (j.nextRun ? ` | next: ${j.nextRun}` : ""));
763
+ return `${status.length} cron job(s):\n${lines.join("\n")}`;
764
+ }
765
+ case "add": {
766
+ if (!args.job_id || !args.name || !args.cron_expression || !args.task_type) {
767
+ return "Missing required fields: job_id, name, cron_expression, task_type";
768
+ }
769
+ const job = createCronJob({
770
+ id: args.job_id,
771
+ name: args.name,
772
+ cronExpression: args.cron_expression,
773
+ taskType: args.task_type,
774
+ payload: args.payload,
775
+ notifyTelegram: args.notify_telegram,
776
+ });
777
+ if (job.enabled)
778
+ scheduleJob(job);
779
+ return `Cron job '${job.id}' (${job.name}) created and scheduled: ${job.cronExpression}`;
780
+ }
781
+ case "remove": {
782
+ if (!args.job_id)
783
+ return "Missing job_id";
784
+ unscheduleJob(args.job_id);
785
+ const deleted = deleteCronJob(args.job_id);
786
+ return deleted
787
+ ? `Cron job '${args.job_id}' deleted.`
788
+ : `Job '${args.job_id}' not found.`;
789
+ }
790
+ case "enable": {
791
+ if (!args.job_id)
792
+ return "Missing job_id";
793
+ const enabled = updateCronJob(args.job_id, { enabled: true });
794
+ if (!enabled)
795
+ return `Job '${args.job_id}' not found.`;
796
+ scheduleJob(enabled);
797
+ return `Cron job '${args.job_id}' enabled and scheduled.`;
798
+ }
799
+ case "disable": {
800
+ if (!args.job_id)
801
+ return "Missing job_id";
802
+ const disabled = updateCronJob(args.job_id, { enabled: false });
803
+ if (!disabled)
804
+ return `Job '${args.job_id}' not found.`;
805
+ unscheduleJob(args.job_id);
806
+ return `Cron job '${args.job_id}' disabled.`;
807
+ }
808
+ case "trigger": {
809
+ if (!args.job_id)
810
+ return "Missing job_id";
811
+ const result = await triggerJob(args.job_id);
812
+ return `Triggered '${args.job_id}':\n${result}`;
813
+ }
814
+ case "history": {
815
+ if (!args.job_id)
816
+ return "Missing job_id";
817
+ const job = getCronJob(args.job_id);
818
+ if (!job)
819
+ return `Job '${args.job_id}' not found.`;
820
+ const runs = getRecentRuns(args.job_id);
821
+ if (runs.length === 0)
822
+ return `No runs recorded for '${args.job_id}'.`;
823
+ const lines = runs.map((r) => `• ${r.status} at ${r.startedAt}` +
824
+ (r.durationMs !== null ? ` (${r.durationMs}ms)` : "") +
825
+ (r.error ? ` — ${r.error}` : "") +
826
+ (r.result ? ` — ${r.result.slice(0, 100)}` : ""));
827
+ return `Recent runs for '${job.name}':\n${lines.join("\n")}`;
828
+ }
829
+ default:
830
+ return `Unknown action: ${args.action}`;
831
+ }
832
+ }
833
+ catch (err) {
834
+ return `Cron error: ${err instanceof Error ? err.message : String(err)}`;
835
+ }
836
+ },
837
+ }),
709
838
  defineTool("restart_nzb", {
710
839
  description: "Restart the NZB daemon process. Use when the user asks NZB to restart himself, " +
711
840
  "or when a restart is needed to pick up configuration changes. " +
@@ -726,6 +855,56 @@ export function createTools(deps) {
726
855
  return `Restarting NZB${reason}. I'll be back in a few seconds.`;
727
856
  },
728
857
  }),
858
+ defineTool("check_update", {
859
+ description: "Check for NZB updates and optionally apply them. " +
860
+ "Use 'check' to see if updates are available. " +
861
+ "Use 'update' to install the latest version and restart.",
862
+ parameters: z.object({
863
+ action: z.enum(["check", "update"]).describe("'check' to check for updates, 'update' to install and restart"),
864
+ }),
865
+ handler: async (args) => {
866
+ try {
867
+ const { checkForUpdate, performUpdate, getChangelog } = await import("../update.js");
868
+ if (args.action === "check") {
869
+ const result = await checkForUpdate();
870
+ if (!result.checkSucceeded) {
871
+ return "Could not reach the npm registry. Network may be unavailable.";
872
+ }
873
+ if (!result.updateAvailable) {
874
+ return `NZB v${result.current} is already the latest version.`;
875
+ }
876
+ const changelog = await getChangelog(5);
877
+ const changelogText = changelog.length > 0
878
+ ? "\n\nRecent versions:\n" + changelog.map((e) => `• v${e.version} (${e.date})`).join("\n")
879
+ : "";
880
+ return `Update available: v${result.current} → v${result.latest}${changelogText}`;
881
+ }
882
+ // action === "update"
883
+ const result = await checkForUpdate();
884
+ if (!result.checkSucceeded) {
885
+ return "Could not reach the npm registry. Network may be unavailable.";
886
+ }
887
+ if (!result.updateAvailable) {
888
+ return `NZB v${result.current} is already the latest version. No update needed.`;
889
+ }
890
+ const updateResult = await performUpdate();
891
+ if (!updateResult.ok) {
892
+ return `Update failed: ${updateResult.output}`;
893
+ }
894
+ // Schedule restart after returning the response
895
+ const { restartDaemon } = await import("../daemon.js");
896
+ setTimeout(() => {
897
+ restartDaemon().catch((err) => {
898
+ console.error("[nzb] Post-update restart failed:", err);
899
+ });
900
+ }, 1000);
901
+ return `Updated NZB to v${result.latest}. Restarting now...`;
902
+ }
903
+ catch (err) {
904
+ return `Update error: ${err instanceof Error ? err.message : String(err)}`;
905
+ }
906
+ },
907
+ }),
729
908
  ];
730
909
  }
731
910
  function formatAge(date) {