@goodandready/dsh-cron 0.2.9 → 0.2.11

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/index.js CHANGED
@@ -3,7 +3,7 @@ import { TaskScheduler, parseScheduleExpression } from './scheduler.js';
3
3
  import { SessionRunner } from './runner.js';
4
4
  import { getModelsHandler } from './models-handler.js';
5
5
  import { chatStartHandler } from './chat-start.js';
6
- import { sendTelegramMessage } from './telegram.js';
6
+ import { sendTelegramMessage, createTelegramTaskKeyboard } from './telegram.js';
7
7
  import { createKanbanCard } from './integrations.js';
8
8
  import { defineTool } from '@deepseek-ai/dsh-tools';
9
9
  import { randomUUID } from 'node:crypto';
@@ -393,6 +393,24 @@ export function apply(ctx, config) {
393
393
  handler: createCronApiHandler(store, scheduler, null)
394
394
  }), 'dsh-cron: /action/:id/:action');
395
395
 
396
+ ctx.effect(() => ctx.webServer.register({
397
+ kind: 'prefix',
398
+ path: '/dsh-cron/telegram',
399
+ handler: createCronApiHandler(store, scheduler, null)
400
+ }), 'dsh-cron: /telegram[...]');
401
+
402
+ ctx.effect(() => ctx.webServer.register({
403
+ kind: 'prefix',
404
+ path: '/dsh-cron/heartbeat-ping',
405
+ handler: createCronApiHandler(store, scheduler, null)
406
+ }), 'dsh-cron: /heartbeat-ping[...]');
407
+
408
+ ctx.effect(() => ctx.webServer.register({
409
+ kind: 'prefix',
410
+ path: '/dsh-cron/schedule',
411
+ handler: createCronApiHandler(store, scheduler, null)
412
+ }), 'dsh-cron: /schedule[...]');
413
+
396
414
  // GET /dsh-cron/metrics (#53) — Prometheus text exposition
397
415
  ctx.effect(() => ctx.webServer.register({
398
416
  kind: 'exact',
@@ -527,7 +545,8 @@ export function apply(ctx, config) {
527
545
  }
528
546
 
529
547
  const text = `🔔 *dsh-cron test notification*\n\nConnection between DSH Cron and Telegram is working! Checked at: \`${new Date().toISOString()}\``;
530
- await sendTelegramMessage({ botToken, chatId, text });
548
+ const replyMarkup = createTelegramTaskKeyboard(task, runInfo);
549
+ await sendTelegramMessage({ botToken, chatId, text, replyMarkup });
531
550
  sendJson(res, 200, { ok: true, message: 'Test message sent successfully' });
532
551
  } catch (err) {
533
552
  sendJson(res, 500, { ok: false, error: err.message });
@@ -0,0 +1,141 @@
1
+ import fs from 'node:fs';
2
+
3
+ /**
4
+ * Extract structured action directives from LLM output text (#137).
5
+ * Supports JSON code fences ```json ... ``` and inline { "dsh_action": ... } blocks.
6
+ */
7
+ export function parseLlmActionDirectives(text) {
8
+ if (!text || typeof text !== 'string') return [];
9
+ const directives = [];
10
+
11
+ // 1. Check for ```json ... ``` or ``` ... ``` blocks
12
+ const blockRegex = /```(?:json)?\s*([\s\S]*?)\s*```/gi;
13
+ let match;
14
+ while ((match = blockRegex.exec(text)) !== null) {
15
+ const raw = match[1].trim();
16
+ if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
17
+ try {
18
+ const parsed = JSON.parse(raw);
19
+ collectDirectives(parsed, directives);
20
+ } catch {}
21
+ }
22
+ }
23
+
24
+ // 2. Check for inline JSON object with dsh_action or dsh_actions if nothing found in fences
25
+ if (directives.length === 0) {
26
+ const inlineMatch = /\{(?:[^{}]|(\{(?:[^{}]|\{[^{}]*\})*\}))*"dsh_action[s]?"(?:[^{}]|(\{(?:[^{}]|\{[^{}]*\})*\}))*\}/gi;
27
+ let im;
28
+ while ((im = inlineMatch.exec(text)) !== null) {
29
+ try {
30
+ const parsed = JSON.parse(im[0]);
31
+ collectDirectives(parsed, directives);
32
+ } catch {}
33
+ }
34
+ }
35
+
36
+ return directives;
37
+ }
38
+
39
+ function collectDirectives(obj, list) {
40
+ if (!obj || typeof obj !== 'object') return;
41
+ if (Array.isArray(obj)) {
42
+ for (const item of obj) collectDirectives(item, list);
43
+ return;
44
+ }
45
+ if (Array.isArray(obj.dsh_actions)) {
46
+ for (const item of obj.dsh_actions) collectDirectives(item, list);
47
+ return;
48
+ }
49
+ if (Array.isArray(obj.actions)) {
50
+ for (const item of obj.actions) collectDirectives(item, list);
51
+ return;
52
+ }
53
+ const type = obj.dsh_action || obj.action || obj.type;
54
+ if (typeof type === 'string' && type.trim()) {
55
+ list.push({
56
+ type: type.trim(),
57
+ payload: obj.payload || {},
58
+ ...obj,
59
+ });
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Execute parsed LLM action directives if enabled in plugin settings (#137).
65
+ */
66
+ export async function executeLlmActionDirectives({ directives, task, runInfo, scheduler, store, settings = {}, fetchFn = globalThis.fetch }) {
67
+ if (!Array.isArray(directives) || directives.length === 0) return [];
68
+ if (!settings.llmActionsEnabled) {
69
+ return [];
70
+ }
71
+
72
+ const results = [];
73
+ for (const dir of directives) {
74
+ const type = dir.type || dir.action;
75
+ try {
76
+ if (type === 'trigger_task' || type === 'run_task') {
77
+ const targetId = dir.taskId || dir.payload?.taskId;
78
+ if (targetId && typeof scheduler.runNow === 'function') {
79
+ await scheduler.runNow(targetId, {
80
+ prevOutput: runInfo?.output || '',
81
+ prevTaskId: task?.id || '',
82
+ });
83
+ results.push({ type, targetId, status: 'triggered' });
84
+ }
85
+ } else if (type === 'notify') {
86
+ const msg = dir.message || dir.text || dir.payload?.message;
87
+ const botToken = settings.botToken;
88
+ const chatId = settings.chatId;
89
+ if (msg && botToken && chatId) {
90
+ const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
91
+ await fetchFn(url, {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ chat_id: chatId, text: String(msg) }),
95
+ });
96
+ results.push({ type, status: 'sent' });
97
+ }
98
+ } else if (type === 'create_issue') {
99
+ const title = dir.title || dir.payload?.title || `Issue from task ${task?.title || 'Cron'}`;
100
+ const body = dir.body || dir.payload?.body || String(runInfo?.output || '');
101
+ const giteaRes = await tryCreateGiteaIssue({ title, body, fetchFn });
102
+ results.push({ type, status: giteaRes ? 'created' : 'skipped', details: giteaRes });
103
+ }
104
+ } catch (err) {
105
+ console.warn(`[dsh-cron] action directive ${type} execution failed:`, err.message);
106
+ results.push({ type, status: 'error', error: err.message });
107
+ }
108
+ }
109
+
110
+ return results;
111
+ }
112
+
113
+ /**
114
+ * Best-effort helper to post an issue to local Gitea if credentials are present.
115
+ */
116
+ async function tryCreateGiteaIssue({ title, body, repo = 'goodandready/dsh-cron', fetchFn }) {
117
+ try {
118
+ let token = process.env.GITEA_TOKEN;
119
+ const credPath = '/mnt/external/Project/DEV/.gitea-agent-credentials.json';
120
+ if (!token && fs.existsSync(credPath)) {
121
+ const data = JSON.parse(fs.readFileSync(credPath, 'utf-8'));
122
+ token = data?.agents?.antigravity?.token || data?.token;
123
+ }
124
+ if (!token) return null;
125
+
126
+ const url = `http://127.0.0.1:3005/api/v1/repos/${repo}/issues`;
127
+ const res = await fetchFn(url, {
128
+ method: 'POST',
129
+ headers: {
130
+ 'Authorization': `token ${token}`,
131
+ 'Content-Type': 'application/json',
132
+ },
133
+ body: JSON.stringify({ title, body }),
134
+ });
135
+ if (res.ok) {
136
+ const json = await res.json().catch(() => ({}));
137
+ return { number: json.number, url: json.html_url };
138
+ }
139
+ } catch {}
140
+ return null;
141
+ }
package/lib/metrics.js CHANGED
@@ -36,7 +36,7 @@ function sample(name, labels, value) {
36
36
  * Render the current scheduler state as Prometheus text exposition.
37
37
  * Pure function over plain data so it is testable without a store.
38
38
  */
39
- export function renderMetrics({ tasks = [], stats = {}, runCounters = {} } = {}) {
39
+ export function renderMetrics({ tasks = [], stats = {}, runCounters = {}, scheduler } = {}) {
40
40
  const lines = [];
41
41
  const byStatus = {};
42
42
  for (const task of tasks) {
@@ -72,6 +72,28 @@ export function renderMetrics({ tasks = [], stats = {}, runCounters = {} } = {})
72
72
  lines.push('# TYPE dsh_cron_run_records gauge');
73
73
  lines.push('dsh_cron_run_records ' + (Number(stats.totalRuns) || 0));
74
74
 
75
+ if (scheduler && typeof scheduler.runningCount === 'function') {
76
+ lines.push('# HELP dsh_cron_concurrent_running Number of currently executing tasks.');
77
+ lines.push('# TYPE dsh_cron_concurrent_running gauge');
78
+ lines.push('dsh_cron_concurrent_running ' + (Number(scheduler.runningCount()) || 0));
79
+ }
80
+
81
+ lines.push('# HELP dsh_cron_task_tokens_total Cumulative tokens consumed by task.');
82
+ lines.push('# TYPE dsh_cron_task_tokens_total counter');
83
+ for (const task of tasks) {
84
+ if (Number(task.totalTokens) > 0) {
85
+ lines.push(sample('dsh_cron_task_tokens_total', { task: task.id }, task.totalTokens));
86
+ }
87
+ }
88
+
89
+ lines.push('# HELP dsh_cron_task_cost_usd_total Estimated cumulative LLM cost in USD.');
90
+ lines.push('# TYPE dsh_cron_task_cost_usd_total counter');
91
+ for (const task of tasks) {
92
+ if (Number(task.totalCostUsd) > 0) {
93
+ lines.push(sample('dsh_cron_task_cost_usd_total', { task: task.id }, task.totalCostUsd));
94
+ }
95
+ }
96
+
75
97
  return lines.join(NL) + NL;
76
98
  }
77
99
 
@@ -90,6 +112,7 @@ export function createMetricsHandler({ store, scheduler }) {
90
112
  tasks: store.list({ status: 'all' }),
91
113
  stats: store.getAggregatedStats(),
92
114
  runCounters: typeof scheduler.getRunCounters === 'function' ? scheduler.getRunCounters() : {},
115
+ scheduler,
93
116
  });
94
117
  res.writeHead(200, { 'Content-Type': METRICS_CONTENT_TYPE, 'Cache-Control': 'no-store' });
95
118
  res.end(req.method === 'HEAD' ? undefined : body);
package/lib/runner.js CHANGED
@@ -54,7 +54,7 @@ export class SessionRunner {
54
54
  }, timeoutMs);
55
55
  });
56
56
 
57
- const executionPromise = this._run(task, controller.signal);
57
+ const executionPromise = this._run(task, controller.signal, options);
58
58
 
59
59
  try {
60
60
  return await Promise.race([executionPromise, timeoutPromise]);
@@ -195,7 +195,7 @@ export class SessionRunner {
195
195
  }
196
196
 
197
197
  /** Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9). */
198
- async _runExternal(task, runtime, signal) {
198
+ async _runExternal(task, runtime, signal, execOptions = {}) {
199
199
  if (runtime === 'ssh' && task.sshProfileId) {
200
200
  const viaService = await this._runViaRemoteWorkspace(task, signal);
201
201
  if (viaService) return viaService;
@@ -239,9 +239,13 @@ export class SessionRunner {
239
239
  signal.addEventListener('abort', abortHandler, { once: true });
240
240
  }
241
241
 
242
+ const runEnv = { ...invocation.env };
243
+ if (execOptions?.prevOutput !== undefined) {
244
+ runEnv.DSH_PREV_OUTPUT = String(execOptions.prevOutput);
245
+ }
242
246
  const options = {
243
247
  cwd,
244
- env: invocation.env,
248
+ env: runEnv,
245
249
  detached: isPosix,
246
250
  stdio: ['ignore', 'pipe', 'pipe'],
247
251
  };
@@ -293,27 +297,27 @@ export class SessionRunner {
293
297
  });
294
298
  }
295
299
 
296
- async _run(task, signal) {
300
+ async _run(task, signal, options = {}) {
297
301
  console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
298
302
 
299
303
  // 1. Non-LLM runtimes: shell, Node.js, Python, HTTP, SSH, Docker (#4 #5 #7 #8 #9)
300
304
  const runtime = normalizeTaskType(task.type);
301
305
  if (EXTERNAL_RUNTIMES.includes(runtime) || task.mode === 'no-llm') {
302
- return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal);
306
+ return this._runExternal(task, runtime === 'llm' ? 'script' : runtime, signal, options);
303
307
  }
304
308
 
305
309
  // 2. Agent session, when the harness exposes one
306
310
  if (this.ctx && this.ctx.agents && typeof this.ctx.agents.create === 'function') {
307
- return this._runAgentSession(task, runtime, signal);
311
+ return this._runAgentSession(task, runtime, signal, options);
308
312
  }
309
313
 
310
314
  // 3. Legacy session service
311
315
  if (this.ctx && this.ctx.session && typeof this.ctx.session.create === 'function') {
312
- return this._runLegacySession(task);
316
+ return this._runLegacySession(task, options);
313
317
  }
314
318
 
315
319
  // 4. Nothing to run through: report the prompt as triggered
316
- return this._runBare(task);
320
+ return this._runBare(task, options);
317
321
  }
318
322
 
319
323
  /** Legacy session service path; falls back to the bare report. */
@@ -336,7 +340,7 @@ export class SessionRunner {
336
340
  return this._runBare(task);
337
341
  }
338
342
 
339
- _runBare(task) {
343
+ _runBare(task, execOptions = {}) {
340
344
  return {
341
345
  output: `[dsh-cron] Prompt triggered: "${task.prompt.slice(0, 100)}..."`,
342
346
  usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
@@ -344,9 +348,18 @@ export class SessionRunner {
344
348
  };
345
349
  }
346
350
 
347
- async _runAgentSession(task, runtime, signal) {
351
+ async _runAgentSession(task, runtime, signal, execOptions = {}) {
348
352
  try {
349
- const prep = await this._prepareAgentRun(task, runtime, signal);
353
+ let effectiveTask = task;
354
+ if (execOptions?.prevOutput) {
355
+ const prev = String(execOptions.prevOutput);
356
+ const prompt = task.prompt || '';
357
+ const newPrompt = prompt.includes('{{prevOutput}}')
358
+ ? prompt.replaceAll('{{prevOutput}}', prev)
359
+ : `[Previous Output]:\n${prev}\n\n${prompt}`;
360
+ effectiveTask = { ...task, prompt: newPrompt };
361
+ }
362
+ const prep = await this._prepareAgentRun(effectiveTask, runtime, signal);
350
363
  return await this._executeAgentTurn(task, prep);
351
364
  } catch (err) {
352
365
  console.error(`[dsh-cron] agent execution failed:`, err.message);