@goodandready/dsh-cron 0.3.3 → 0.3.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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Prompt Auto-Evolver & Versioning Engine (#331)
3
+ *
4
+ * Tracks prompt revisions, maintains versioned prompt history, computes
5
+ * empirical Success Rates across versions, and enables 1-click rollback.
6
+ */
7
+
8
+ const MAX_PROMPT_HISTORY = 20;
9
+
10
+ export function sanitizePromptHistory(history) {
11
+ if (!Array.isArray(history)) return [];
12
+ return history
13
+ .filter((h) => h && typeof h === 'object' && typeof h.prompt === 'string')
14
+ .slice(-MAX_PROMPT_HISTORY)
15
+ .map((h, idx) => ({
16
+ version: typeof h.version === 'number' && h.version > 0 ? h.version : (idx + 1),
17
+ prompt: String(h.prompt || ''),
18
+ note: typeof h.note === 'string' ? h.note.slice(0, 100) : 'Revision',
19
+ runs: typeof h.runs === 'number' && h.runs >= 0 ? h.runs : 0,
20
+ successes: typeof h.successes === 'number' && h.successes >= 0 ? h.successes : 0,
21
+ successRate: typeof h.successRate === 'number' && h.successRate >= 0 && h.successRate <= 1
22
+ ? Number(h.successRate.toFixed(3))
23
+ : (typeof h.runs === 'number' && h.runs > 0 ? Number(((h.successes || 0) / h.runs).toFixed(3)) : 1.0),
24
+ createdAt: typeof h.createdAt === 'number' ? h.createdAt : Date.now(),
25
+ lastRunAt: typeof h.lastRunAt === 'number' ? h.lastRunAt : null,
26
+ }));
27
+ }
28
+
29
+ export function trackPromptEdit(currentTask, newPrompt, note = 'Manual edit') {
30
+ if (!currentTask || typeof newPrompt !== 'string') {
31
+ return {
32
+ prompt: currentTask ? currentTask.prompt : newPrompt,
33
+ promptVersion: currentTask ? (currentTask.promptVersion || 1) : 1,
34
+ promptHistory: currentTask ? sanitizePromptHistory(currentTask.promptHistory) : [],
35
+ };
36
+ }
37
+
38
+ const trimmedNew = newPrompt.trim();
39
+ const trimmedOld = (currentTask.prompt || '').trim();
40
+
41
+ let history = sanitizePromptHistory(currentTask.promptHistory);
42
+
43
+ // If prompt has not changed, keep current version and baseline history if needed
44
+ if (trimmedNew === trimmedOld) {
45
+ if (history.length === 0 && trimmedOld) {
46
+ history.push({
47
+ version: currentTask.promptVersion || 1,
48
+ prompt: currentTask.prompt,
49
+ note: 'Initial baseline',
50
+ runs: typeof currentTask.runs === 'number' ? currentTask.runs : 0,
51
+ successes: typeof currentTask.successes === 'number' ? currentTask.successes : 0,
52
+ successRate: currentTask.runs > 0
53
+ ? Number(((currentTask.successes || 0) / currentTask.runs).toFixed(3))
54
+ : 1.0,
55
+ createdAt: currentTask.createdAt || Date.now(),
56
+ lastRunAt: currentTask.lastRun || null,
57
+ });
58
+ }
59
+ return {
60
+ prompt: currentTask.prompt,
61
+ promptVersion: currentTask.promptVersion || 1,
62
+ promptHistory: history,
63
+ };
64
+ }
65
+
66
+ // If no history exists yet, record baseline version 1 with existing task metrics before bumping
67
+ if (history.length === 0 && trimmedOld) {
68
+ history.push({
69
+ version: 1,
70
+ prompt: currentTask.prompt,
71
+ note: 'Initial baseline',
72
+ runs: typeof currentTask.runs === 'number' ? currentTask.runs : 0,
73
+ successes: typeof currentTask.successes === 'number' ? currentTask.successes : 0,
74
+ successRate: currentTask.runs > 0
75
+ ? Number(((currentTask.successes || 0) / currentTask.runs).toFixed(3))
76
+ : 1.0,
77
+ createdAt: currentTask.createdAt || Date.now(),
78
+ lastRunAt: currentTask.lastRun || null,
79
+ });
80
+ }
81
+
82
+ const maxVersion = history.reduce((max, h) => Math.max(max, h.version || 1), currentTask.promptVersion || 1);
83
+ const nextVersion = maxVersion + 1;
84
+
85
+ history.push({
86
+ version: nextVersion,
87
+ prompt: newPrompt,
88
+ note: String(note || 'Manual edit').slice(0, 100),
89
+ runs: 0,
90
+ successes: 0,
91
+ successRate: 1.0,
92
+ createdAt: Date.now(),
93
+ lastRunAt: null,
94
+ });
95
+
96
+ return {
97
+ prompt: newPrompt,
98
+ promptVersion: nextVersion,
99
+ promptHistory: sanitizePromptHistory(history),
100
+ };
101
+ }
102
+
103
+ export function recordPromptRunOutcome(task, isSuccess) {
104
+ if (!task || typeof task.prompt !== 'string') return task?.promptHistory || [];
105
+
106
+ let history = sanitizePromptHistory(task.promptHistory);
107
+ const activeVersion = task.promptVersion || 1;
108
+
109
+ let entry = history.find((h) => h.version === activeVersion);
110
+ if (!entry) {
111
+ entry = {
112
+ version: activeVersion,
113
+ prompt: task.prompt,
114
+ note: 'Active prompt',
115
+ runs: 0,
116
+ successes: 0,
117
+ successRate: 1.0,
118
+ createdAt: Date.now(),
119
+ lastRunAt: null,
120
+ };
121
+ history.push(entry);
122
+ }
123
+
124
+ entry.runs = (entry.runs || 0) + 1;
125
+ if (isSuccess) {
126
+ entry.successes = (entry.successes || 0) + 1;
127
+ }
128
+ entry.successRate = Number((entry.successes / entry.runs).toFixed(3));
129
+ entry.lastRunAt = Date.now();
130
+
131
+ task.promptHistory = sanitizePromptHistory(history);
132
+ return task.promptHistory;
133
+ }
134
+
135
+ export function rollbackPromptVersion(task, targetVersion) {
136
+ if (!task) return null;
137
+ const history = sanitizePromptHistory(task.promptHistory);
138
+ const target = history.find((h) => h.version === targetVersion);
139
+ if (!target) {
140
+ throw new Error(`Target prompt version ${targetVersion} not found in history`);
141
+ }
142
+
143
+ const edit = trackPromptEdit(task, target.prompt, `Rollback to v${targetVersion}`);
144
+ task.prompt = edit.prompt;
145
+ task.promptVersion = edit.promptVersion;
146
+ task.promptHistory = edit.promptHistory;
147
+ return task;
148
+ }
package/lib/routes.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { buildAnalyticsDigest, formatDigestMarkdown, sendAnalyticsDigest } from './analytics-digest.js';
1
2
  import { listVerifiedRecipes } from './recipes-catalog.js';
2
3
  import { randomUUID } from 'node:crypto';
3
4
  import { createCronApiHandler } from './api.js';
@@ -100,6 +101,84 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
100
101
  }
101
102
  }), 'dsh-cron: /chat/start');
102
103
 
104
+ // Analytics digest routes (#330)
105
+ ctx.effect(() => ctx.webServer.register({
106
+ kind: 'prefix',
107
+ path: '/dsh-cron/analytics',
108
+ handler: async (req, res) => {
109
+ try {
110
+ const url = new URL(req.url, 'http://127.0.0.1');
111
+ const path = url.pathname;
112
+
113
+ if (req.method === 'GET') {
114
+ const days = parseInt(url.searchParams.get('days') || '7', 10);
115
+ const tasks = store.list ? store.list() : Object.values(store.tasks || {});
116
+ let history = [];
117
+ if (store.history) {
118
+ if (typeof store.history.values === 'function') {
119
+ for (const runs of store.history.values()) {
120
+ if (Array.isArray(runs)) history.push(...runs);
121
+ }
122
+ } else if (typeof store.history === 'object') {
123
+ for (const runs of Object.values(store.history)) {
124
+ if (Array.isArray(runs)) history.push(...runs);
125
+ }
126
+ }
127
+ }
128
+ const costLedger = store.costLedger || [];
129
+ const digest = buildAnalyticsDigest({ tasks, history, costLedger, periodDays: days });
130
+ const markdown = formatDigestMarkdown(digest);
131
+ sendJson(res, 200, { ok: true, digest, markdown });
132
+ return;
133
+ }
134
+
135
+ if (req.method === 'POST') {
136
+ if (rejectCrossOrigin(req, res)) return;
137
+ const { body } = await readBody(req, res);
138
+ const days = parseInt(body?.days || url.searchParams.get('days') || '7', 10);
139
+ const tasks = store.list ? store.list() : Object.values(store.tasks || {});
140
+ let history = [];
141
+ if (store.history) {
142
+ if (typeof store.history.values === 'function') {
143
+ for (const runs of store.history.values()) {
144
+ if (Array.isArray(runs)) history.push(...runs);
145
+ }
146
+ } else if (typeof store.history === 'object') {
147
+ for (const runs of Object.values(store.history)) {
148
+ if (Array.isArray(runs)) history.push(...runs);
149
+ }
150
+ }
151
+ }
152
+ const costLedger = store.costLedger || [];
153
+ const digest = buildAnalyticsDigest({ tasks, history, costLedger, periodDays: days });
154
+ const settings = store.getSettings ? store.getSettings() : {};
155
+ const deliveryConfig = {
156
+ telegram: body?.telegram !== false && Boolean(settings.botToken && settings.chatId),
157
+ webhookUrl: body?.webhookUrl || settings.webhookUrl,
158
+ };
159
+ const channels = {
160
+ sendTelegram: async (text) => sendTelegramMessage({ botToken: settings.botToken, chatId: settings.chatId, text }),
161
+ sendWebhook: async (targetUrl, payload) => {
162
+ const r = await fetch(targetUrl, {
163
+ method: 'POST',
164
+ headers: { 'Content-Type': 'application/json' },
165
+ body: JSON.stringify(payload),
166
+ });
167
+ return { ok: r.ok, status: r.status };
168
+ },
169
+ };
170
+ const delivery = await sendAnalyticsDigest({ digest, channels, deliveryConfig });
171
+ sendJson(res, 200, { ok: true, digest, delivery });
172
+ return;
173
+ }
174
+
175
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
176
+ } catch (err) {
177
+ sendJson(res, 500, { ok: false, error: err.message });
178
+ }
179
+ }
180
+ }), 'dsh-cron: /analytics');
181
+
103
182
  // GET /dsh-cron/recipes (#48): the built-in recipe catalog.
104
183
  ctx.effect(() => ctx.webServer.register({
105
184
  kind: 'exact',
@@ -1,3 +1,4 @@
1
+ import { recordPromptRunOutcome } from './prompt-evolver.js';
1
2
  import { evaluateAdaptiveRun, calculateNextAdaptiveState } from './adaptive.js';
2
3
  import { approvalManager } from './approval.js';
3
4
  import { globalBudgetManager } from './global-budgets.js';
@@ -513,6 +514,15 @@ export async function deliverNotifications(task, runInfo, { store, resolveSecret
513
514
  export function finishRun(scheduler, taskSnapshot, taskId, status, queuedCount, meta = {}) {
514
515
  scheduler.countRun(status);
515
516
 
517
+ // Track prompt version success rate outcome (#331)
518
+ if (taskSnapshot && taskSnapshot.prompt && (taskSnapshot.promptVersion || taskSnapshot.promptHistory)) {
519
+ const isSuccess = status === 'success';
520
+ recordPromptRunOutcome(taskSnapshot, isSuccess);
521
+ if (scheduler.store && typeof scheduler.store.set === 'function') {
522
+ scheduler.store.set(taskSnapshot);
523
+ }
524
+ }
525
+
516
526
  // Adaptive polling backoff (#329)
517
527
  if (taskSnapshot && taskSnapshot.adaptive && taskSnapshot.adaptive.enabled && !taskSnapshot.oneShot && taskSnapshot.status === 'active' && !scheduler.isStopped) {
518
528
  const history = scheduler.store.getHistory ? scheduler.store.getHistory(taskId, 2) : [];
@@ -1,3 +1,4 @@
1
+ import { sanitizePromptHistory } from './prompt-evolver.js';
1
2
  import { sanitizeAdaptiveConfig } from './adaptive.js';
2
3
  import { sanitizeValidators } from './validators.js';
3
4
  import { normalizeTaskType, EXTERNAL_RUNTIMES } from './runtimes.js';
@@ -33,6 +34,8 @@ export function executeCreateTask(store, scheduler, args) {
33
34
  schedule: parsed.cronPattern || args.schedule,
34
35
  scheduleText: parsed.humanText,
35
36
  prompt: args.prompt,
37
+ promptVersion: args.prompt ? 1 : undefined,
38
+ promptHistory: args.prompt ? [{ version: 1, prompt: args.prompt, note: 'Initial creation', runs: 0, successes: 0, successRate: 1.0, createdAt: Date.now(), lastRunAt: null }] : undefined,
36
39
  type: taskType,
37
40
  delivery: args.delivery || 'isolated',
38
41
  status: 'active',
package/lib/task-patch.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { trackPromptEdit, sanitizePromptHistory } from './prompt-evolver.js';
1
2
  import { sanitizeAdaptiveConfig } from './adaptive.js';
2
3
  import { sanitizeValidators } from './validators.js';
3
4
  import { sanitizeFileWatch } from './task-create.js';
@@ -108,6 +109,14 @@ export function buildTaskPatch(current, body) {
108
109
  if (patch.validators !== undefined) {
109
110
  patch.validators = Array.isArray(patch.validators) ? sanitizeValidators(patch.validators) : [];
110
111
  }
112
+ if (patch.prompt !== undefined && current && typeof current.prompt === 'string') {
113
+ const edit = trackPromptEdit(current, patch.prompt, body && body.promptNote ? body.promptNote : 'Manual edit');
114
+ patch.prompt = edit.prompt;
115
+ patch.promptVersion = edit.promptVersion;
116
+ patch.promptHistory = edit.promptHistory;
117
+ } else if (patch.promptHistory !== undefined) {
118
+ patch.promptHistory = sanitizePromptHistory(patch.promptHistory);
119
+ }
111
120
  if (patch.costLimitUsd !== undefined) {
112
121
  patch.costLimitUsd = patch.costLimitUsd === null ? null : (Number(patch.costLimitUsd) > 0 ? Number(patch.costLimitUsd) : null);
113
122
  }
@@ -81,6 +81,8 @@ export const PATCHABLE_TASK_FIELDS = [
81
81
  'joinPolicy',
82
82
  'adaptive',
83
83
  'adaptiveState',
84
+ 'promptVersion',
85
+ 'promptHistory',
84
86
  ];
85
87
 
86
88
  /** Fields a duplicate inherits; anything else is runtime state, not config. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "Background automation runner for DSH: isolated agent runs, script/HTTP/SSH/Docker runtimes, cost guard, notifications, heartbeats.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",