@iletai/nzb 1.7.4 → 1.8.0
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 +96 -0
- package/dist/config.js +4 -0
- package/dist/copilot/model-failover.js +154 -0
- package/dist/copilot/orchestrator.js +51 -0
- package/dist/copilot/tools.js +129 -0
- package/dist/cron/scheduler.js +159 -0
- package/dist/cron/task-runner.js +170 -0
- package/dist/daemon.js +5 -0
- package/dist/store/cron-store.js +142 -0
- package/dist/store/db.js +31 -0
- package/dist/telegram/bot.js +3 -0
- package/dist/telegram/handlers/commands.js +5 -0
- package/dist/telegram/handlers/cron.js +354 -0
- package/package.json +2 -1
package/dist/cli.js
CHANGED
|
@@ -45,6 +45,7 @@ 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
|
+
cron Manage scheduled cron jobs
|
|
48
49
|
help Show this help message
|
|
49
50
|
|
|
50
51
|
Flags (start):
|
|
@@ -98,6 +99,101 @@ switch (command) {
|
|
|
98
99
|
}
|
|
99
100
|
break;
|
|
100
101
|
}
|
|
102
|
+
case "cron": {
|
|
103
|
+
const subcommand = args[1] || "list";
|
|
104
|
+
const { listCronJobs, createCronJob, deleteCronJob, updateCronJob } = await import("./store/cron-store.js");
|
|
105
|
+
switch (subcommand) {
|
|
106
|
+
case "list": {
|
|
107
|
+
const jobs = listCronJobs();
|
|
108
|
+
if (jobs.length === 0) {
|
|
109
|
+
console.log("No cron jobs configured.");
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
for (const job of jobs) {
|
|
113
|
+
const status = job.enabled ? "✅" : "⏸️";
|
|
114
|
+
console.log(`${status} ${job.id} — ${job.name} [${job.taskType}] ${job.cronExpression}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "add": {
|
|
120
|
+
const id = args[2];
|
|
121
|
+
const name = args[3];
|
|
122
|
+
const cronExpr = args[4];
|
|
123
|
+
const taskType = args[5];
|
|
124
|
+
if (!id || !name || !cronExpr || !taskType) {
|
|
125
|
+
console.error("Usage: nzb cron add <id> <name> <cron-expression> <task-type> [payload-json]");
|
|
126
|
+
console.error("Task types: prompt, health_check, backup, notification, webhook");
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
const validTypes = ["prompt", "health_check", "backup", "notification", "webhook"];
|
|
130
|
+
if (!validTypes.includes(taskType)) {
|
|
131
|
+
console.error(`Invalid task type: ${taskType}. Valid: ${validTypes.join(", ")}`);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
const { Cron } = await import("croner");
|
|
135
|
+
try {
|
|
136
|
+
new Cron(cronExpr);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
console.error(`Invalid cron expression: ${cronExpr}`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
const payload = args[6] || "{}";
|
|
143
|
+
try {
|
|
144
|
+
const job = createCronJob({
|
|
145
|
+
id,
|
|
146
|
+
name,
|
|
147
|
+
cronExpression: cronExpr,
|
|
148
|
+
taskType: taskType,
|
|
149
|
+
payload,
|
|
150
|
+
});
|
|
151
|
+
console.log(`Created cron job '${job.id}' (${job.name}): ${job.cronExpression}`);
|
|
152
|
+
console.log("Note: The job will be scheduled when the daemon starts.");
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
156
|
+
process.exit(1);
|
|
157
|
+
}
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case "remove": {
|
|
161
|
+
const removeId = args[2];
|
|
162
|
+
if (!removeId) {
|
|
163
|
+
console.error("Usage: nzb cron remove <id>");
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
const deleted = deleteCronJob(removeId);
|
|
167
|
+
console.log(deleted ? `Deleted cron job '${removeId}'.` : `Job '${removeId}' not found.`);
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case "enable": {
|
|
171
|
+
const enableId = args[2];
|
|
172
|
+
if (!enableId) {
|
|
173
|
+
console.error("Usage: nzb cron enable <id>");
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
const enabled = updateCronJob(enableId, { enabled: true });
|
|
177
|
+
console.log(enabled ? `Enabled cron job '${enableId}'.` : `Job '${enableId}' not found.`);
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
case "disable": {
|
|
181
|
+
const disableId = args[2];
|
|
182
|
+
if (!disableId) {
|
|
183
|
+
console.error("Usage: nzb cron disable <id>");
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
186
|
+
const disabled = updateCronJob(disableId, { enabled: false });
|
|
187
|
+
console.log(disabled ? `Disabled cron job '${disableId}'.` : `Job '${disableId}' not found.`);
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
default:
|
|
191
|
+
console.error(`Unknown cron subcommand: ${subcommand}`);
|
|
192
|
+
console.error("Available: list, add, remove, enable, disable");
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
101
197
|
case "help":
|
|
102
198
|
case "--help":
|
|
103
199
|
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
|
package/dist/copilot/tools.js
CHANGED
|
@@ -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. " +
|