@goodandready/dsh-cron 0.3.3 → 0.3.5

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,5 @@
1
+ import { calculateCostHeatmap } from './timeline.js';
2
+ import { buildAnalyticsDigest, formatDigestMarkdown, sendAnalyticsDigest } from './analytics-digest.js';
1
3
  import { listVerifiedRecipes } from './recipes-catalog.js';
2
4
  import { randomUUID } from 'node:crypto';
3
5
  import { createCronApiHandler } from './api.js';
@@ -100,6 +102,104 @@ export function registerRoutes(ctx, { store, scheduler, recommendations }) {
100
102
  }
101
103
  }), 'dsh-cron: /chat/start');
102
104
 
105
+ // Analytics digest routes (#330)
106
+ ctx.effect(() => ctx.webServer.register({
107
+ kind: 'prefix',
108
+ path: '/dsh-cron/analytics',
109
+ handler: async (req, res) => {
110
+ try {
111
+ const url = new URL(req.url, 'http://127.0.0.1');
112
+ const path = url.pathname;
113
+
114
+ if (req.method === 'GET') {
115
+ if (path === '/dsh-cron/analytics/heatmap') {
116
+ const modelFilter = url.searchParams.get('model') || null;
117
+ const providerFilter = url.searchParams.get('provider') || null;
118
+ const costLedger = store.costLedger || [];
119
+ let history = [];
120
+ if (store.history) {
121
+ if (typeof store.history.values === 'function') {
122
+ for (const runs of store.history.values()) {
123
+ if (Array.isArray(runs)) history.push(...runs);
124
+ }
125
+ } else if (typeof store.history === 'object') {
126
+ for (const runs of Object.values(store.history)) {
127
+ if (Array.isArray(runs)) history.push(...runs);
128
+ }
129
+ }
130
+ }
131
+ const heatmap = calculateCostHeatmap({ costLedger, history, modelFilter, providerFilter });
132
+ sendJson(res, 200, { ok: true, heatmap });
133
+ return;
134
+ }
135
+ const days = parseInt(url.searchParams.get('days') || '7', 10);
136
+ const tasks = store.list ? store.list() : Object.values(store.tasks || {});
137
+ let history = [];
138
+ if (store.history) {
139
+ if (typeof store.history.values === 'function') {
140
+ for (const runs of store.history.values()) {
141
+ if (Array.isArray(runs)) history.push(...runs);
142
+ }
143
+ } else if (typeof store.history === 'object') {
144
+ for (const runs of Object.values(store.history)) {
145
+ if (Array.isArray(runs)) history.push(...runs);
146
+ }
147
+ }
148
+ }
149
+ const costLedger = store.costLedger || [];
150
+ const digest = buildAnalyticsDigest({ tasks, history, costLedger, periodDays: days });
151
+ const markdown = formatDigestMarkdown(digest);
152
+ sendJson(res, 200, { ok: true, digest, markdown });
153
+ return;
154
+ }
155
+
156
+ if (req.method === 'POST') {
157
+ if (rejectCrossOrigin(req, res)) return;
158
+ const { body } = await readBody(req, res);
159
+ const days = parseInt(body?.days || url.searchParams.get('days') || '7', 10);
160
+ const tasks = store.list ? store.list() : Object.values(store.tasks || {});
161
+ let history = [];
162
+ if (store.history) {
163
+ if (typeof store.history.values === 'function') {
164
+ for (const runs of store.history.values()) {
165
+ if (Array.isArray(runs)) history.push(...runs);
166
+ }
167
+ } else if (typeof store.history === 'object') {
168
+ for (const runs of Object.values(store.history)) {
169
+ if (Array.isArray(runs)) history.push(...runs);
170
+ }
171
+ }
172
+ }
173
+ const costLedger = store.costLedger || [];
174
+ const digest = buildAnalyticsDigest({ tasks, history, costLedger, periodDays: days });
175
+ const settings = store.getSettings ? store.getSettings() : {};
176
+ const deliveryConfig = {
177
+ telegram: body?.telegram !== false && Boolean(settings.botToken && settings.chatId),
178
+ webhookUrl: body?.webhookUrl || settings.webhookUrl,
179
+ };
180
+ const channels = {
181
+ sendTelegram: async (text) => sendTelegramMessage({ botToken: settings.botToken, chatId: settings.chatId, text }),
182
+ sendWebhook: async (targetUrl, payload) => {
183
+ const r = await fetch(targetUrl, {
184
+ method: 'POST',
185
+ headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify(payload),
187
+ });
188
+ return { ok: r.ok, status: r.status };
189
+ },
190
+ };
191
+ const delivery = await sendAnalyticsDigest({ digest, channels, deliveryConfig });
192
+ sendJson(res, 200, { ok: true, digest, delivery });
193
+ return;
194
+ }
195
+
196
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
197
+ } catch (err) {
198
+ sendJson(res, 500, { ok: false, error: err.message });
199
+ }
200
+ }
201
+ }), 'dsh-cron: /analytics');
202
+
103
203
  // GET /dsh-cron/recipes (#48): the built-in recipe catalog.
104
204
  ctx.effect(() => ctx.webServer.register({
105
205
  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. */
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Visual Schedule Timeline & Cost Heatmap Engine (#332, #333)
3
+ *
4
+ * 1. 24-hour schedule projection with collision and peak concurrency detection.
5
+ * 2. 7x24 weekly hour-of-day cost and token burn heatmap matrix.
6
+ */
7
+
8
+ import { Cron } from 'croner';
9
+
10
+ const DAY_LABELS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
11
+
12
+ export function calculate24hTimeline({
13
+ tasks = [],
14
+ maxConcurrent = 3,
15
+ fromTimestamp = Date.now(),
16
+ hoursCount = 24,
17
+ }) {
18
+ const limit = Math.max(1, Number(maxConcurrent) || 3);
19
+ const activeTasks = (Array.isArray(tasks) ? tasks : Object.values(tasks || {})).filter(
20
+ (t) => t && t.status === 'active'
21
+ );
22
+
23
+ const startMs = typeof fromTimestamp === 'number' ? fromTimestamp : Date.now();
24
+ const endMs = startMs + hoursCount * 3600000;
25
+
26
+ const buckets = Array.from({ length: hoursCount }, (_, i) => {
27
+ const bucketStart = startMs + i * 3600000;
28
+ const d = new Date(bucketStart);
29
+ const hourPad = String(d.getHours()).padStart(2, '0');
30
+ return {
31
+ index: i,
32
+ label: `${hourPad}:00`,
33
+ startMs: bucketStart,
34
+ endMs: bucketStart + 3600000,
35
+ runs: [],
36
+ count: 0,
37
+ isPeak: false,
38
+ };
39
+ });
40
+
41
+ let totalRuns = 0;
42
+
43
+ for (const task of activeTasks) {
44
+ if (task.oneShot) {
45
+ const runAt = Number(task.nextRunAt || task.targetTimestamp);
46
+ if (runAt >= startMs && runAt < endMs) {
47
+ const bIdx = Math.floor((runAt - startMs) / 3600000);
48
+ if (bIdx >= 0 && bIdx < hoursCount) {
49
+ buckets[bIdx].runs.push({
50
+ taskId: task.id,
51
+ title: task.title || task.id,
52
+ runAt,
53
+ durationEstMs: task.avgDuration || 2000,
54
+ type: task.type || 'llm',
55
+ });
56
+ totalRuns += 1;
57
+ }
58
+ }
59
+ continue;
60
+ }
61
+
62
+ if (task.schedule) {
63
+ try {
64
+ const c = new Cron(task.schedule, { timezone: task.timezone });
65
+ let cursor = new Date(startMs - 1);
66
+ // Bound to max 96 occurrences per task in 24h window
67
+ for (let step = 0; step < 96; step++) {
68
+ const nextDate = c.nextRun(cursor);
69
+ if (!nextDate) break;
70
+ const runMs = nextDate.getTime();
71
+ if (runMs >= endMs) break;
72
+
73
+ const bIdx = Math.floor((runMs - startMs) / 3600000);
74
+ if (bIdx >= 0 && bIdx < hoursCount) {
75
+ buckets[bIdx].runs.push({
76
+ taskId: task.id,
77
+ title: task.title || task.id,
78
+ runAt: runMs,
79
+ durationEstMs: task.avgDuration || 2000,
80
+ type: task.type || 'llm',
81
+ });
82
+ totalRuns += 1;
83
+ }
84
+ cursor = new Date(runMs + 1000);
85
+ }
86
+ } catch (err) {
87
+ // Fallback for custom or invalid cron schedules
88
+ const runAt = Number(task.nextRunAt);
89
+ if (runAt >= startMs && runAt < endMs) {
90
+ const bIdx = Math.floor((runAt - startMs) / 3600000);
91
+ if (bIdx >= 0 && bIdx < hoursCount) {
92
+ buckets[bIdx].runs.push({
93
+ taskId: task.id,
94
+ title: task.title || task.id,
95
+ runAt,
96
+ durationEstMs: task.avgDuration || 2000,
97
+ type: task.type || 'llm',
98
+ });
99
+ totalRuns += 1;
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+
106
+ const peakHours = [];
107
+
108
+ for (const b of buckets) {
109
+ b.count = b.runs.length;
110
+ b.isPeak = b.count > limit;
111
+ if (b.isPeak) {
112
+ peakHours.push(b.label);
113
+ }
114
+ }
115
+
116
+ return {
117
+ fromTimestamp: startMs,
118
+ toTimestamp: endMs,
119
+ maxConcurrent: limit,
120
+ totalRuns,
121
+ peakHoursCount: peakHours.length,
122
+ peakHours,
123
+ buckets,
124
+ };
125
+ }
126
+
127
+ export function calculateCostHeatmap({
128
+ costLedger = [],
129
+ history = [],
130
+ modelFilter = null,
131
+ providerFilter = null,
132
+ }) {
133
+ const ledger = Array.isArray(costLedger) ? costLedger : [];
134
+ const historyList = Array.isArray(history) ? history : [];
135
+
136
+ // Matrix: 7 rows (Mon..Sun) x 24 columns (00..23h)
137
+ const matrix = Array.from({ length: 7 }, (_, dIdx) =>
138
+ Array.from({ length: 24 }, (_, hIdx) => ({
139
+ day: dIdx,
140
+ dayLabel: DAY_LABELS[dIdx],
141
+ hour: hIdx,
142
+ hourLabel: `${String(hIdx).padStart(2, '0')}:00`,
143
+ tokens: 0,
144
+ costUsd: 0,
145
+ runs: 0,
146
+ costIntensity: 0,
147
+ tokenIntensity: 0,
148
+ }))
149
+ );
150
+
151
+ let totalCostUsd = 0;
152
+ let totalTokens = 0;
153
+ let totalRuns = 0;
154
+
155
+ // Aggregate costLedger
156
+ for (const entry of ledger) {
157
+ if (!entry || typeof entry.timestamp !== 'number') continue;
158
+ if (modelFilter && entry.model && entry.model !== modelFilter) continue;
159
+ if (providerFilter && entry.provider && entry.provider !== providerFilter) continue;
160
+
161
+ const d = new Date(entry.timestamp);
162
+ const dayOfWeek = (d.getUTCDay() + 6) % 7; // Convert Sun(0) to 6, Mon(1) to 0
163
+ const hour = d.getUTCHours();
164
+
165
+ if (dayOfWeek >= 0 && dayOfWeek < 7 && hour >= 0 && hour < 24) {
166
+ const cell = matrix[dayOfWeek][hour];
167
+ const cost = typeof entry.costUsd === 'number' ? entry.costUsd : 0;
168
+ const tokens = typeof entry.tokens === 'number' ? entry.tokens : 0;
169
+
170
+ cell.costUsd += cost;
171
+ cell.tokens += tokens;
172
+ totalCostUsd += cost;
173
+ totalTokens += tokens;
174
+ }
175
+ }
176
+
177
+ // Aggregate run counts from history
178
+ for (const r of historyList) {
179
+ if (!r) continue;
180
+ const t = r.startedAt || r.completedAt || r.timestamp;
181
+ if (typeof t !== 'number') continue;
182
+ if (modelFilter && r.model && r.model !== modelFilter) continue;
183
+ if (providerFilter && r.provider && r.provider !== providerFilter) continue;
184
+
185
+ const d = new Date(t);
186
+ const dayOfWeek = (d.getUTCDay() + 6) % 7;
187
+ const hour = d.getUTCHours();
188
+
189
+ if (dayOfWeek >= 0 && dayOfWeek < 7 && hour >= 0 && hour < 24) {
190
+ matrix[dayOfWeek][hour].runs += 1;
191
+ totalRuns += 1;
192
+ }
193
+ }
194
+
195
+ // Calculate maximums for intensity normalisation
196
+ let maxCostHour = 0;
197
+ let maxTokensHour = 0;
198
+
199
+ for (let d = 0; d < 7; d++) {
200
+ for (let h = 0; h < 24; h++) {
201
+ const cell = matrix[d][h];
202
+ cell.costUsd = Number(cell.costUsd.toFixed(4));
203
+ if (cell.costUsd > maxCostHour) maxCostHour = cell.costUsd;
204
+ if (cell.tokens > maxTokensHour) maxTokensHour = cell.tokens;
205
+ }
206
+ }
207
+
208
+ // Normalise intensity (0.0 to 1.0)
209
+ const costDenom = maxCostHour > 0 ? maxCostHour : 1;
210
+ const tokenDenom = maxTokensHour > 0 ? maxTokensHour : 1;
211
+
212
+ for (let d = 0; d < 7; d++) {
213
+ for (let h = 0; h < 24; h++) {
214
+ const cell = matrix[d][h];
215
+ cell.costIntensity = Number((cell.costUsd / costDenom).toFixed(3));
216
+ cell.tokenIntensity = Number((cell.tokens / tokenDenom).toFixed(3));
217
+ }
218
+ }
219
+
220
+ return {
221
+ dayLabels: DAY_LABELS,
222
+ hourLabels: Array.from({ length: 24 }, (_, i) => `${String(i).padStart(2, '0')}:00`),
223
+ matrix,
224
+ maxCostHour,
225
+ maxTokensHour,
226
+ totalCostUsd: Number(totalCostUsd.toFixed(4)),
227
+ totalTokens,
228
+ totalRuns,
229
+ };
230
+ }
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.5",
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",