@goodandready/dsh-cron 0.2.4 → 0.2.6

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/lib/llm-ask.js ADDED
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Small helper for asking the configured model a short question (#44, #43).
3
+ *
4
+ * The harness exposes the model through `ctx.llm.stream(...)`, which yields
5
+ * text deltas and a finish chunk. That is enough for a cheap side question such
6
+ * as "is this output worth alerting about?".
7
+ *
8
+ * Everything here fails open: any problem returns `{ ok: false }` and the caller
9
+ * falls back to its normal behaviour, so a broken model call can never silence a
10
+ * report or lose a diagnosis.
11
+ */
12
+
13
+ const DEFAULT_TIMEOUT_MS = 20000;
14
+ const DEFAULT_MAX_TOKENS = 400;
15
+
16
+ function getLlm(ctx) {
17
+ if (!ctx) return null;
18
+ if (ctx.llm && typeof ctx.llm.stream === 'function') return ctx.llm;
19
+ if (typeof ctx.get === 'function') {
20
+ const service = ctx.get('llm');
21
+ if (service && typeof service.stream === 'function') return service;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /** Provider and model for an auxiliary question: task first, then the default. */
27
+ export function resolveAskTarget(ctx, task = {}, preferredModel = '') {
28
+ let defaultSelection = null;
29
+ try {
30
+ defaultSelection = ctx && typeof ctx.get === 'function'
31
+ ? ctx.get('agentDefaultModel')?.currentSelection?.() || null
32
+ : null;
33
+ } catch {}
34
+ return {
35
+ provider: task.provider || defaultSelection?.provider || '',
36
+ model: preferredModel || task.model || defaultSelection?.model || '',
37
+ };
38
+ }
39
+
40
+ /** Collect the assistant text and how the stream ended. */
41
+ async function collectText(stream) {
42
+ let text = '';
43
+ let finishKind = '';
44
+ for await (const chunk of stream) {
45
+ if (!chunk) continue;
46
+ if (chunk.type === 'text-delta' && chunk.text) text += chunk.text;
47
+ if (chunk.type === 'finish') {
48
+ finishKind = (chunk.reason && chunk.reason.kind) || '';
49
+ break;
50
+ }
51
+ }
52
+ return { text, finishKind };
53
+ }
54
+
55
+ /** A stream that stopped for any other reason is not a usable answer. */
56
+ function isCleanFinish(kind) {
57
+ return kind === 'stop';
58
+ }
59
+
60
+ async function buildUserMessage(text) {
61
+ const content = [{ type: 'text', text }];
62
+ const source = { kind: 'plugin', plugin: 'dsh-cron', form: 'helper-call' };
63
+ try {
64
+ const mod = await import('@deepseek-ai/dsh-llm');
65
+ if (mod && typeof mod.createUserMessage === 'function') {
66
+ return mod.createUserMessage({ content, source });
67
+ }
68
+ } catch {}
69
+ return { role: 'user', content, source };
70
+ }
71
+
72
+ /**
73
+ * Ask the model one question and return its text.
74
+ * @returns {Promise<{ok: true, text: string} | {ok: false, error: string}>}
75
+ */
76
+ export async function askModel(ctx, options = {}) {
77
+ const { provider, model, prompt, system, maxTokens, timeoutMs, purpose } = options;
78
+ const llm = getLlm(ctx);
79
+ if (!llm) return { ok: false, error: 'the harness exposes no model stream' };
80
+ if (!provider || !model) return { ok: false, error: 'no provider/model is configured for this call' };
81
+ if (!prompt) return { ok: false, error: 'no prompt given' };
82
+
83
+ const limit = Number(timeoutMs) > 0 ? Number(timeoutMs) : DEFAULT_TIMEOUT_MS;
84
+ const controller = new AbortController();
85
+ let timer;
86
+ // The abort signal only helps a stream that honours it, so the whole call is
87
+ // raced against the same deadline: a model helper must never hold a run open.
88
+ const deadline = new Promise((_, reject) => {
89
+ timer = setTimeout(() => {
90
+ controller.abort();
91
+ reject(new Error(`the model call did not finish within ${limit} ms`));
92
+ }, limit);
93
+ });
94
+
95
+ try {
96
+ const work = (async () => {
97
+ const messages = [await buildUserMessage(prompt)];
98
+ const stream = llm.stream({
99
+ provider,
100
+ model,
101
+ messages,
102
+ ...(system ? { system } : {}),
103
+ maxTokens: Number(maxTokens) > 0 ? Number(maxTokens) : DEFAULT_MAX_TOKENS,
104
+ signal: controller.signal,
105
+ ...(purpose ? { purpose } : {}),
106
+ });
107
+ const { text, finishKind } = await collectText(stream);
108
+ return { text: String(text || '').trim(), finishKind };
109
+ })();
110
+ // The abandoned stream must not surface as an unhandled rejection.
111
+ work.catch(() => {});
112
+
113
+ const { text, finishKind } = await Promise.race([work, deadline]);
114
+ if (!isCleanFinish(finishKind)) {
115
+ return { ok: false, error: `the model call ended with ${finishKind || 'no finish reason'}` };
116
+ }
117
+ return { ok: true, text };
118
+ } catch (err) {
119
+ return { ok: false, error: (err && err.message) || String(err) };
120
+ } finally {
121
+ clearTimeout(timer);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Read the first JSON object out of a model answer.
127
+ * Models like to wrap JSON in prose or fences, so the text is searched rather
128
+ * than parsed directly. Returns null when nothing usable is found.
129
+ */
130
+ export function parseJsonAnswer(text) {
131
+ const raw = String(text || '');
132
+ const start = raw.indexOf('{');
133
+ const end = raw.lastIndexOf('}');
134
+ if (start === -1 || end <= start) return null;
135
+ try {
136
+ const parsed = JSON.parse(raw.slice(start, end + 1));
137
+ return parsed && typeof parsed === 'object' ? parsed : null;
138
+ } catch {
139
+ return null;
140
+ }
141
+ }
package/lib/recipes.js ADDED
@@ -0,0 +1,238 @@
1
+ /**
2
+ * Built-in recipe hub (#48): preconfigured monitoring jobs a user can create in
3
+ * one click instead of hand-writing shell commands.
4
+ *
5
+ * Two rules shape every entry:
6
+ * - read-only. No recipe deletes, prunes, restarts or installs anything; the
7
+ * worst a bad match can do is print output. Destructive maintenance stays a
8
+ * deliberate, hand-written task.
9
+ * - no secrets and no private endpoints. Everything here is a public command
10
+ * or a self-contained check.
11
+ *
12
+ * Recipes are ordinary task presets: the UI opens the normal form with the
13
+ * fields prefilled, so nothing is created without an explicit action.
14
+ */
15
+
16
+ export const RECIPE_CATEGORIES = [
17
+ { id: 'system', title: 'System health' },
18
+ { id: 'services', title: 'Services and containers' },
19
+ { id: 'storage', title: 'Storage' },
20
+ { id: 'security', title: 'Security' },
21
+ { id: 'maintenance', title: 'Maintenance windows' },
22
+ ];
23
+
24
+ /** Commands a recipe must never contain, checked by the test suite. */
25
+ /**
26
+ * Commands a recipe must never contain, checked by the test suite.
27
+ *
28
+ * Deliberately broad: this is a gate for the curated catalog, not a sandbox, so
29
+ * a false positive costs one recipe while a false negative ships a recipe that
30
+ * destroys something. An independent review of this block defeated the first
31
+ * version with `find -delete`, `shred`, `mv` and `apt purge`, so the list now
32
+ * covers deletion and wiping, raw device writes, service and container state,
33
+ * packages, users and permissions, scheduling and the network edge, power state
34
+ * and writes outside the working tree.
35
+ */
36
+ export const DESTRUCTIVE_PATTERNS = [
37
+ /\brm\b/i,
38
+ /\bunlink\b/i,
39
+ /\bshred\b/i,
40
+ /\bwipefs\b/i,
41
+ /\bmkfs\b/i,
42
+ /\btruncate\b/i,
43
+ /\bfind\b[^|;]*\s-delete\b/i,
44
+ /\bmv\s+[^|;]*\/etc\//i,
45
+ /\bdd\b/i,
46
+ /\bof=\/dev\//i,
47
+ /\bsystemctl\s+(stop|restart|disable|mask|kill)\b/i,
48
+ /\bkill(all)?\b/i,
49
+ /\bdocker\s+(?:[a-z-]+\s+)*(rm|rmi|prune|stop|kill|down)\b/i,
50
+ /\bdocker\s+volume\s+rm\b/i,
51
+ /\bapt(-get)?\s+(install|remove|purge|autoremove|upgrade|dist-upgrade|full-upgrade)\b/i,
52
+ /\bdpkg\s+(-r|-P|--purge)\b/i,
53
+ /\byum\s+(remove|erase|update|install)\b/i,
54
+ /\bpip3?\s+uninstall\b/i,
55
+ /\bnpm\s+(uninstall|rm)\b/i,
56
+ /\buserdel\b/i,
57
+ /\bgroupdel\b/i,
58
+ /\bchmod\s+(-R\s+)?0?777\b/i,
59
+ /\bchown\s+-R\b/i,
60
+ /\bcrontab\s+-r\b/i,
61
+ /\biptables\b/i,
62
+ /\bnft\s+(flush|delete)\b/i,
63
+ /\bufw\s+disable\b/i,
64
+ /\bshutdown\b/i,
65
+ /\breboot\b/i,
66
+ /\bhalt\b/i,
67
+ /\bpoweroff\b/i,
68
+ />\s*\/(etc|var|usr|boot)\//i,
69
+ /\btee\s+\/(etc|var|usr|boot)\//i,
70
+ /\bsed\s+-i\b/i,
71
+ ];
72
+
73
+ export const RECIPES = [
74
+ {
75
+ id: 'recipe_disk_pressure',
76
+ category: 'system',
77
+ title: 'Disk pressure',
78
+ description: 'Report partitions above 80% usage; stay quiet when everything is comfortable.',
79
+ type: 'script',
80
+ schedule: '0 8 * * *',
81
+ scheduleText: 'Every day at 08:00',
82
+ prompt: "df -h -x tmpfs -x devtmpfs | awk 'NR==1 || $5+0 > 80'",
83
+ channels: ['telegram'],
84
+ silentRule: 'Stay silent when no filesystem in the output is above 80% usage. Otherwise report the offending lines.',
85
+ },
86
+ {
87
+ id: 'recipe_memory_pressure',
88
+ category: 'system',
89
+ title: 'Memory and swap pressure',
90
+ description: 'Report memory and swap state; stay quiet while swap stays untouched.',
91
+ type: 'script',
92
+ schedule: '0 */6 * * *',
93
+ scheduleText: 'Every 6 hours',
94
+ prompt: 'free -m && echo "---" && uptime',
95
+ channels: ['telegram'],
96
+ silentRule: 'Stay silent when swap used is 0 MB and the load average is below the number of CPU cores. Otherwise report the numbers.',
97
+ },
98
+ {
99
+ id: 'recipe_failed_units',
100
+ category: 'services',
101
+ title: 'Failed systemd units',
102
+ description: 'List failed units; stay quiet when the list is empty.',
103
+ type: 'script',
104
+ schedule: '0 * * * *',
105
+ scheduleText: 'Every hour',
106
+ prompt: 'systemctl --failed --no-pager --no-legend',
107
+ channels: ['telegram'],
108
+ silentRule: 'Stay silent when the output lists no failed units. Otherwise report every failed unit.',
109
+ },
110
+ {
111
+ id: 'recipe_journal_errors',
112
+ category: 'services',
113
+ title: 'Errors in the journal',
114
+ description: 'Surface error-level journal entries from the last day.',
115
+ type: 'script',
116
+ schedule: '30 8 * * *',
117
+ scheduleText: 'Every day at 08:30',
118
+ prompt: "journalctl -p err --since '24 hours ago' --no-pager | tail -n 40",
119
+ channels: ['telegram'],
120
+ silentRule: 'Stay silent when the output contains no error lines. Otherwise summarise what is failing repeatedly.',
121
+ },
122
+ {
123
+ id: 'recipe_container_health',
124
+ category: 'services',
125
+ title: 'Container health',
126
+ description: 'List containers that are not Up; skip the task silently without Docker.',
127
+ type: 'script',
128
+ schedule: '*/30 * * * *',
129
+ scheduleText: 'Every 30 minutes',
130
+ prompt: "command -v docker >/dev/null 2>&1 || { echo 'docker is not installed'; exit 0; }; docker ps --format '{{.Names}}\\t{{.Status}}\\t{{.Image}}'",
131
+ channels: ['telegram'],
132
+ silentRule: 'Stay silent when every listed container is Up and no container is restarting. Otherwise report the unhealthy ones.',
133
+ },
134
+ {
135
+ id: 'recipe_log_growth',
136
+ category: 'storage',
137
+ title: 'Log directory growth',
138
+ description: 'Track the size of /var/log and the largest files inside it.',
139
+ type: 'script',
140
+ schedule: '0 9 * * 1',
141
+ scheduleText: 'Mondays at 09:00',
142
+ prompt: "du -sh /var/log 2>/dev/null; du -ah /var/log 2>/dev/null | sort -rh | head -n 10",
143
+ channels: ['telegram'],
144
+ silentRule: 'Stay silent when /var/log is smaller than 1 GB. Otherwise report the total and the largest files.',
145
+ },
146
+ {
147
+ id: 'recipe_backup_freshness',
148
+ category: 'storage',
149
+ title: 'Backup freshness',
150
+ description: 'Show the newest backup files so a stalled backup job becomes visible.',
151
+ type: 'script',
152
+ schedule: '0 7 * * *',
153
+ scheduleText: 'Every day at 07:00',
154
+ prompt: "ls -lt /var/backups 2>/dev/null | head -n 5 || echo 'no /var/backups directory'",
155
+ channels: ['telegram'],
156
+ silentRule: 'Stay silent when the newest backup file is less than 48 hours old. Report out loud when it is older or the directory is missing.',
157
+ },
158
+ {
159
+ id: 'recipe_certificate_expiry',
160
+ category: 'security',
161
+ title: 'Certificate expiry',
162
+ description: 'Report TLS certificates that expire within 30 days.',
163
+ type: 'script',
164
+ schedule: '0 8 * * 1',
165
+ scheduleText: 'Mondays at 08:00',
166
+ prompt: "find /etc/letsencrypt/live -name fullchain.pem 2>/dev/null | while read -r cert; do days=$(( ( $(date -d \"$(openssl x509 -enddate -noout -in \"$cert\" | cut -d= -f2)\" +%s) - $(date +%s) ) / 86400 )); echo \"$days days $cert\"; done",
167
+ channels: ['telegram'],
168
+ silentRule: 'Stay silent when every certificate has more than 30 days left. Report the ones that expire sooner.',
169
+ },
170
+ {
171
+ id: 'recipe_pending_updates',
172
+ category: 'security',
173
+ title: 'Pending package updates',
174
+ description: 'Count upgradable packages without installing anything.',
175
+ type: 'script',
176
+ schedule: '0 10 * * 5',
177
+ scheduleText: 'Fridays at 10:00',
178
+ prompt: "command -v apt >/dev/null 2>&1 || { echo 'apt is not available'; exit 0; }; apt list --upgradable 2>/dev/null | tail -n +2 | wc -l",
179
+ channels: ['telegram'],
180
+ silentRule: 'Stay silent when there are no pending updates. Otherwise report the count and mention that applying them is a manual step.',
181
+ },
182
+ {
183
+ id: 'recipe_uptime_review',
184
+ category: 'maintenance',
185
+ title: 'Weekly uptime review',
186
+ description: 'A short weekly note with uptime, load and the kernel in use.',
187
+ type: 'script',
188
+ schedule: '0 9 * * 1',
189
+ scheduleText: 'Mondays at 09:00',
190
+ prompt: 'uptime && echo "---" && uname -r && echo "---" && df -h / | tail -n 1',
191
+ channels: ['telegram'],
192
+ },
193
+ ];
194
+
195
+ /** Detached copy of the catalog, safe to hand to the UI. */
196
+ export function listRecipes() {
197
+ return RECIPES.map((recipe) => ({ ...recipe, channels: [...(recipe.channels || [])] }));
198
+ }
199
+
200
+ export function recipesByCategory() {
201
+ return RECIPE_CATEGORIES.map((category) => ({
202
+ ...category,
203
+ recipes: listRecipes().filter((recipe) => recipe.category === category.id),
204
+ }));
205
+ }
206
+
207
+ /**
208
+ * The panel's "recommended" list: the same catalog, flattened, so the hub is
209
+ * the single source instead of a second hand-written list (legacy shape kept).
210
+ */
211
+ export function recipeRecommendations(limit = 6) {
212
+ return listRecipes()
213
+ .slice(0, limit)
214
+ .map((recipe) => ({
215
+ id: recipe.id,
216
+ title: recipe.title,
217
+ schedule: recipe.schedule,
218
+ scheduleText: recipe.scheduleText,
219
+ prompt: recipe.prompt,
220
+ description: recipe.description,
221
+ type: recipe.type,
222
+ category: recipe.category,
223
+ channels: recipe.channels,
224
+ silentRule: recipe.silentRule || '',
225
+ }));
226
+ }
227
+
228
+ /** A recipe must never contain a destructive command. */
229
+ export function findDestructiveRecipe(recipes = RECIPES) {
230
+ for (const recipe of recipes) {
231
+ for (const pattern of DESTRUCTIVE_PATTERNS) {
232
+ if (pattern.test(String(recipe.prompt || ''))) {
233
+ return { id: recipe.id, pattern: String(pattern) };
234
+ }
235
+ }
236
+ }
237
+ return null;
238
+ }