stagent 0.9.2 → 0.9.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.
Files changed (50) hide show
  1. package/dist/cli.js +36 -1
  2. package/docs/superpowers/specs/2026-04-06-workflow-intelligence-stack-design.md +388 -0
  3. package/package.json +1 -1
  4. package/src/app/api/license/route.ts +3 -2
  5. package/src/app/api/workflows/[id]/debug/route.ts +18 -0
  6. package/src/app/api/workflows/[id]/execute/route.ts +39 -8
  7. package/src/app/api/workflows/optimize/route.ts +30 -0
  8. package/src/app/layout.tsx +4 -2
  9. package/src/components/chat/chat-message-markdown.tsx +78 -3
  10. package/src/components/chat/chat-message.tsx +12 -4
  11. package/src/components/settings/cloud-account-section.tsx +14 -12
  12. package/src/components/workflows/error-timeline.tsx +83 -0
  13. package/src/components/workflows/step-live-metrics.tsx +182 -0
  14. package/src/components/workflows/step-progress-bar.tsx +77 -0
  15. package/src/components/workflows/workflow-debug-panel.tsx +192 -0
  16. package/src/components/workflows/workflow-optimizer-panel.tsx +227 -0
  17. package/src/lib/agents/claude-agent.ts +4 -4
  18. package/src/lib/agents/runtime/anthropic-direct.ts +3 -3
  19. package/src/lib/agents/runtime/catalog.ts +30 -1
  20. package/src/lib/agents/runtime/openai-direct.ts +3 -3
  21. package/src/lib/billing/products.ts +6 -6
  22. package/src/lib/book/chapter-mapping.ts +6 -0
  23. package/src/lib/book/content.ts +10 -0
  24. package/src/lib/book/reading-paths.ts +1 -1
  25. package/src/lib/chat/__tests__/engine-stream-helpers.test.ts +57 -0
  26. package/src/lib/chat/engine.ts +68 -7
  27. package/src/lib/chat/stagent-tools.ts +2 -0
  28. package/src/lib/chat/tools/runtime-tools.ts +28 -0
  29. package/src/lib/chat/tools/schedule-tools.ts +44 -1
  30. package/src/lib/chat/tools/settings-tools.ts +40 -10
  31. package/src/lib/chat/tools/workflow-tools.ts +93 -4
  32. package/src/lib/chat/types.ts +21 -0
  33. package/src/lib/data/clear.ts +3 -0
  34. package/src/lib/db/bootstrap.ts +38 -0
  35. package/src/lib/db/migrations/0022_workflow_intelligence_phase1.sql +5 -0
  36. package/src/lib/db/migrations/0023_add_execution_stats.sql +15 -0
  37. package/src/lib/db/schema.ts +41 -1
  38. package/src/lib/license/__tests__/manager.test.ts +64 -0
  39. package/src/lib/license/manager.ts +80 -25
  40. package/src/lib/schedules/__tests__/interval-parser.test.ts +87 -0
  41. package/src/lib/schedules/__tests__/prompt-analyzer.test.ts +51 -0
  42. package/src/lib/schedules/interval-parser.ts +187 -0
  43. package/src/lib/schedules/prompt-analyzer.ts +87 -0
  44. package/src/lib/schedules/scheduler.ts +179 -9
  45. package/src/lib/workflows/cost-estimator.ts +141 -0
  46. package/src/lib/workflows/engine.ts +245 -45
  47. package/src/lib/workflows/error-analysis.ts +249 -0
  48. package/src/lib/workflows/execution-stats.ts +252 -0
  49. package/src/lib/workflows/optimizer.ts +193 -0
  50. package/src/lib/workflows/types.ts +6 -0
@@ -1,4 +1,4 @@
1
- import { sqliteTable, text, integer, index, uniqueIndex } from "drizzle-orm/sqlite-core";
1
+ import { sqliteTable, text, integer, real, index, uniqueIndex } from "drizzle-orm/sqlite-core";
2
2
  import type { InferSelectModel } from "drizzle-orm";
3
3
 
4
4
  export const projects = sqliteTable("projects", {
@@ -38,6 +38,8 @@ export const tasks = sqliteTable(
38
38
  enum: ["manual", "scheduled", "heartbeat", "workflow"],
39
39
  }),
40
40
  workflowRunNumber: integer("workflow_run_number"),
41
+ /** Resolved per-task budget cap in USD — set by workflow engine for child tasks */
42
+ maxBudgetUsd: real("max_budget_usd"),
41
43
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
42
44
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
43
45
  },
@@ -61,6 +63,8 @@ export const workflows = sqliteTable("workflows", {
61
63
  .default("draft")
62
64
  .notNull(),
63
65
  runNumber: integer("run_number").default(0).notNull(),
66
+ /** Runtime to use for all steps (nullable — falls back to system default) */
67
+ runtimeId: text("runtime_id"),
64
68
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
65
69
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
66
70
  });
@@ -198,6 +202,14 @@ export const schedules = sqliteTable(
198
202
  }),
199
203
  /** JSON array of channel config IDs for delivery after firing */
200
204
  deliveryChannels: text("delivery_channels"),
205
+ /** Exponential moving average of turns used per child task firing */
206
+ avgTurnsPerFiring: integer("avg_turns_per_firing"),
207
+ /** Turns used by the most recent firing */
208
+ lastTurnCount: integer("last_turn_count"),
209
+ /** Consecutive failed firings (reset to 0 on success). Auto-pause at 3. */
210
+ failureStreak: integer("failure_streak").default(0).notNull(),
211
+ /** Detected reason for the most recent failure (turn_limit_exceeded, timeout, etc.) */
212
+ lastFailureReason: text("last_failure_reason"),
201
213
  createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
202
214
  updatedAt: integer("updated_at", { mode: "timestamp" }).notNull(),
203
215
  },
@@ -1172,3 +1184,31 @@ export const license = sqliteTable("license", {
1172
1184
  });
1173
1185
 
1174
1186
  export type LicenseRow = InferSelectModel<typeof license>;
1187
+
1188
+ // ── Workflow Execution Stats ─────────────────────────────────────────
1189
+
1190
+ export const workflowExecutionStats = sqliteTable("workflow_execution_stats", {
1191
+ id: text("id").primaryKey(),
1192
+ /** Workflow pattern: sequence, parallel, swarm, etc. */
1193
+ pattern: text("pattern").notNull(),
1194
+ /** Number of steps in the workflow */
1195
+ stepCount: integer("step_count").notNull(),
1196
+ /** Average documents injected per step */
1197
+ avgDocsPerStep: real("avg_docs_per_step"),
1198
+ /** Average cost per step in microdollars */
1199
+ avgCostPerStepMicros: integer("avg_cost_per_step_micros"),
1200
+ /** Average duration per step in milliseconds */
1201
+ avgDurationPerStepMs: integer("avg_duration_per_step_ms"),
1202
+ /** Success rate (0.0 to 1.0) */
1203
+ successRate: real("success_rate"),
1204
+ /** JSON: common failure types and counts, e.g., {"budget_exceeded": 4, "timeout": 1} */
1205
+ commonFailures: text("common_failures"),
1206
+ /** JSON: per-runtime success rates, e.g., {"claude-code": 0.92, "openai-direct": 0.71} */
1207
+ runtimeBreakdown: text("runtime_breakdown"),
1208
+ /** Number of workflow runs included in these stats */
1209
+ sampleCount: integer("sample_count").notNull().default(0),
1210
+ lastUpdated: text("last_updated").notNull(),
1211
+ createdAt: text("created_at").notNull(),
1212
+ });
1213
+
1214
+ export type WorkflowExecutionStatsRow = InferSelectModel<typeof workflowExecutionStats>;
@@ -0,0 +1,64 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { eq } from "drizzle-orm";
3
+ import { db } from "@/lib/db";
4
+ import { license as licenseTable } from "@/lib/db/schema";
5
+ import { licenseManager } from "../manager";
6
+
7
+ const LICENSE_ROW_ID = "default";
8
+
9
+ /**
10
+ * Regression: under Turbopack module instance separation, the singleton's
11
+ * in-memory cache could disagree with the DB. If another module instance
12
+ * (or another process) updates the license row, getTier() must reflect
13
+ * the new value — otherwise gated limits silently fall back to community
14
+ * tier rules even on paid tiers.
15
+ *
16
+ * The fix in manager.ts makes getTier() / getStatus() read from the DB.
17
+ * This test simulates the cross-instance scenario by mutating the DB row
18
+ * directly without going through licenseManager.activate().
19
+ */
20
+ describe("LicenseManager — DB-direct tier reads", () => {
21
+ beforeEach(() => {
22
+ // Reset to a known community baseline.
23
+ db.delete(licenseTable).where(eq(licenseTable.id, LICENSE_ROW_ID)).run();
24
+ licenseManager.initialize();
25
+ });
26
+
27
+ afterEach(() => {
28
+ db.delete(licenseTable).where(eq(licenseTable.id, LICENSE_ROW_ID)).run();
29
+ });
30
+
31
+ it("getTier() reflects DB updates that bypassed activate()", () => {
32
+ expect(licenseManager.getTier()).toBe("community");
33
+
34
+ // Simulate "another module instance wrote scale to the DB".
35
+ db.update(licenseTable)
36
+ .set({ tier: "scale", status: "active", updatedAt: new Date() })
37
+ .where(eq(licenseTable.id, LICENSE_ROW_ID))
38
+ .run();
39
+
40
+ expect(licenseManager.getTier()).toBe("scale");
41
+ expect(licenseManager.getLimit("agentMemories")).toBe(Infinity);
42
+ expect(licenseManager.getLimit("activeSchedules")).toBe(Infinity);
43
+ expect(licenseManager.getLimit("parallelWorkflows")).toBe(Infinity);
44
+ expect(licenseManager.isPremium()).toBe(true);
45
+ });
46
+
47
+ it("getStatus() also reads from DB, not stale cache", () => {
48
+ expect(licenseManager.getStatus().tier).toBe("community");
49
+
50
+ db.update(licenseTable)
51
+ .set({
52
+ tier: "operator",
53
+ status: "active",
54
+ email: "ops@example.com",
55
+ updatedAt: new Date(),
56
+ })
57
+ .where(eq(licenseTable.id, LICENSE_ROW_ID))
58
+ .run();
59
+
60
+ const status = licenseManager.getStatus();
61
+ expect(status.tier).toBe("operator");
62
+ expect(status.email).toBe("ops@example.com");
63
+ });
64
+ });
@@ -93,9 +93,15 @@ class LicenseManager {
93
93
  }
94
94
  }
95
95
 
96
- /** Current tier — synchronous, zero-latency (reads from cache) */
96
+ /**
97
+ * Current tier — reads directly from DB to avoid stale singleton cache
98
+ * under Turbopack module instance separation. SQLite primary-key lookup
99
+ * is sub-millisecond, so the previous cache shortcut is not worth the
100
+ * staleness risk that caused gated limits to fall back to community
101
+ * even on paid tiers.
102
+ */
97
103
  getTier(): LicenseTier {
98
- return this.cache?.tier ?? "community";
104
+ return this.getTierFromDb();
99
105
  }
100
106
 
101
107
  /**
@@ -128,17 +134,40 @@ class LicenseManager {
128
134
  return TIER_LIMITS[this.getTier()][resource];
129
135
  }
130
136
 
131
- /** Get the full cached license state */
137
+ /**
138
+ * Get the full license state — reads directly from DB for the same
139
+ * reason as getTier(): the in-memory cache can be stale across
140
+ * Turbopack module instances, causing tier/email/expiry to disagree
141
+ * with what activate() just wrote.
142
+ */
132
143
  getStatus(): CachedLicense & { tier: LicenseTier } {
133
- return {
134
- tier: this.getTier(),
135
- status: this.cache?.status ?? "inactive",
136
- email: this.cache?.email ?? null,
137
- activatedAt: this.cache?.activatedAt ?? null,
138
- expiresAt: this.cache?.expiresAt ?? null,
139
- lastValidatedAt: this.cache?.lastValidatedAt ?? null,
140
- gracePeriodExpiresAt: this.cache?.gracePeriodExpiresAt ?? null,
141
- };
144
+ return this.getStatusFromDb();
145
+ }
146
+
147
+ /**
148
+ * Read full license state directly from DB — bypasses in-memory cache.
149
+ * Use in API routes to avoid stale Turbopack module singleton state.
150
+ */
151
+ getStatusFromDb(): CachedLicense & { tier: LicenseTier } {
152
+ const row = db
153
+ .select()
154
+ .from(licenseTable)
155
+ .where(eq(licenseTable.id, LICENSE_ROW_ID))
156
+ .get();
157
+
158
+ if (!row) {
159
+ return {
160
+ tier: "community",
161
+ status: "inactive",
162
+ email: null,
163
+ activatedAt: null,
164
+ expiresAt: null,
165
+ lastValidatedAt: null,
166
+ gracePeriodExpiresAt: null,
167
+ };
168
+ }
169
+
170
+ return this.rowToCache(row);
142
171
  }
143
172
 
144
173
  /**
@@ -151,20 +180,46 @@ class LicenseManager {
151
180
  encryptedToken?: string;
152
181
  }): void {
153
182
  const now = new Date();
154
- db.update(licenseTable)
155
- .set({
156
- tier: params.tier,
157
- status: "active",
158
- email: params.email,
159
- activatedAt: now,
160
- expiresAt: params.expiresAt ?? null,
161
- lastValidatedAt: now,
162
- gracePeriodExpiresAt: null,
163
- encryptedToken: params.encryptedToken ?? null,
164
- updatedAt: now,
165
- })
183
+
184
+ // Ensure the license row exists (may be missing if initialize() hasn't run)
185
+ const existing = db
186
+ .select({ id: licenseTable.id })
187
+ .from(licenseTable)
166
188
  .where(eq(licenseTable.id, LICENSE_ROW_ID))
167
- .run();
189
+ .get();
190
+
191
+ if (existing) {
192
+ db.update(licenseTable)
193
+ .set({
194
+ tier: params.tier,
195
+ status: "active",
196
+ email: params.email,
197
+ activatedAt: now,
198
+ expiresAt: params.expiresAt ?? null,
199
+ lastValidatedAt: now,
200
+ gracePeriodExpiresAt: null,
201
+ encryptedToken: params.encryptedToken ?? null,
202
+ updatedAt: now,
203
+ })
204
+ .where(eq(licenseTable.id, LICENSE_ROW_ID))
205
+ .run();
206
+ } else {
207
+ db.insert(licenseTable)
208
+ .values({
209
+ id: LICENSE_ROW_ID,
210
+ tier: params.tier,
211
+ status: "active",
212
+ email: params.email,
213
+ activatedAt: now,
214
+ expiresAt: params.expiresAt ?? null,
215
+ lastValidatedAt: now,
216
+ gracePeriodExpiresAt: null,
217
+ encryptedToken: params.encryptedToken ?? null,
218
+ createdAt: now,
219
+ updatedAt: now,
220
+ })
221
+ .run();
222
+ }
168
223
 
169
224
  this.refreshCache();
170
225
  }
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ expandCronMinutes,
4
+ computeStaggeredCron,
5
+ } from "../interval-parser";
6
+
7
+ describe("expandCronMinutes", () => {
8
+ it("expands */N step pattern", () => {
9
+ expect(expandCronMinutes("*/30 * * * *")).toEqual([0, 30]);
10
+ expect(expandCronMinutes("*/15 * * * *")).toEqual([0, 15, 30, 45]);
11
+ });
12
+
13
+ it("expands wildcard", () => {
14
+ const all = expandCronMinutes("* * * * *");
15
+ expect(all.length).toBe(60);
16
+ expect(all[0]).toBe(0);
17
+ expect(all[59]).toBe(59);
18
+ });
19
+
20
+ it("expands comma list", () => {
21
+ expect(expandCronMinutes("5,15,45 * * * *")).toEqual([5, 15, 45]);
22
+ });
23
+
24
+ it("expands ranges", () => {
25
+ expect(expandCronMinutes("10-13 * * * *")).toEqual([10, 11, 12, 13]);
26
+ });
27
+
28
+ it("expands stepped ranges", () => {
29
+ expect(expandCronMinutes("0-30/10 * * * *")).toEqual([0, 10, 20, 30]);
30
+ });
31
+
32
+ it("expands single value", () => {
33
+ expect(expandCronMinutes("7 * * * *")).toEqual([7]);
34
+ });
35
+
36
+ it("throws on invalid cron", () => {
37
+ expect(() => expandCronMinutes("not a cron")).toThrow();
38
+ });
39
+ });
40
+
41
+ describe("computeStaggeredCron", () => {
42
+ it("returns original cron when no existing schedules", () => {
43
+ const result = computeStaggeredCron("*/30 * * * *", []);
44
+ expect(result.collided).toBe(false);
45
+ expect(result.offsetApplied).toBe(0);
46
+ expect(result.cronExpression).toBe("*/30 * * * *");
47
+ });
48
+
49
+ it("returns original cron when no collision", () => {
50
+ const result = computeStaggeredCron("*/30 * * * *", ["7 * * * *"]);
51
+ expect(result.collided).toBe(false);
52
+ expect(result.cronExpression).toBe("*/30 * * * *");
53
+ });
54
+
55
+ it("staggers two */30 schedules", () => {
56
+ // First schedule fires at :00 and :30. Second should be offset by ≥5min.
57
+ const result = computeStaggeredCron("*/30 * * * *", ["*/30 * * * *"]);
58
+ expect(result.collided).toBe(true);
59
+ expect(result.offsetApplied).toBeGreaterThanOrEqual(5);
60
+ // Should produce a comma list reflecting the new fire times
61
+ const minutes = expandCronMinutes(result.cronExpression);
62
+ // Both fire minutes should be ≥5 away from {0,30}
63
+ for (const m of minutes) {
64
+ const distTo0 = Math.min(m, 60 - m);
65
+ const distTo30 = Math.abs(m - 30);
66
+ expect(distTo0).toBeGreaterThanOrEqual(5);
67
+ expect(distTo30).toBeGreaterThanOrEqual(5);
68
+ }
69
+ });
70
+
71
+ it("enforces 5-minute minimum gap", () => {
72
+ // Existing schedule at :00, new schedule wants :03 — should stagger.
73
+ const result = computeStaggeredCron("3 * * * *", ["0 * * * *"]);
74
+ expect(result.collided).toBe(true);
75
+ const minutes = expandCronMinutes(result.cronExpression);
76
+ expect(minutes.length).toBe(1);
77
+ const m = minutes[0];
78
+ const distTo0 = Math.min(m, 60 - m);
79
+ expect(distTo0).toBeGreaterThanOrEqual(5);
80
+ });
81
+
82
+ it("ignores unparseable existing crons gracefully", () => {
83
+ const result = computeStaggeredCron("*/30 * * * *", ["garbage"]);
84
+ expect(result.collided).toBe(false);
85
+ expect(result.cronExpression).toBe("*/30 * * * *");
86
+ });
87
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { analyzePromptEfficiency } from "../prompt-analyzer";
3
+
4
+ describe("analyzePromptEfficiency", () => {
5
+ it("returns no warnings for a clean batched prompt", () => {
6
+ const warnings = analyzePromptEfficiency(
7
+ "Search for stock prices AMZN GOOGL NVDA AAPL today and write a summary."
8
+ );
9
+ expect(warnings).toEqual([]);
10
+ });
11
+
12
+ it("flags 'for each' loop pattern", () => {
13
+ const warnings = analyzePromptEfficiency(
14
+ "For each stock in my portfolio, search the latest price."
15
+ );
16
+ expect(warnings.some((w) => w.type === "loop_pattern")).toBe(true);
17
+ });
18
+
19
+ it("flags 'for every' loop pattern", () => {
20
+ const warnings = analyzePromptEfficiency(
21
+ "For every market, fetch the current odds."
22
+ );
23
+ expect(warnings.some((w) => w.type === "loop_pattern")).toBe(true);
24
+ });
25
+
26
+ it("flags 'individually' loop pattern", () => {
27
+ const warnings = analyzePromptEfficiency(
28
+ "Look up each ticker individually and report results."
29
+ );
30
+ expect(warnings.some((w) => w.type === "loop_pattern")).toBe(true);
31
+ });
32
+
33
+ it("flags large list pattern", () => {
34
+ const warnings = analyzePromptEfficiency(
35
+ "Check all 32 markets and write a report."
36
+ );
37
+ expect(warnings.some((w) => w.type === "large_list")).toBe(true);
38
+ });
39
+
40
+ it("flags high turn estimate", () => {
41
+ // Many search verbs should trip the >30 estimate
42
+ const verbs = Array(35).fill("search").join(" ");
43
+ const warnings = analyzePromptEfficiency(verbs);
44
+ expect(warnings.some((w) => w.type === "high_turn_estimate")).toBe(true);
45
+ });
46
+
47
+ it("does not flag a single quick action", () => {
48
+ const warnings = analyzePromptEfficiency("Send me a daily news digest.");
49
+ expect(warnings).toEqual([]);
50
+ });
51
+ });
@@ -75,6 +75,193 @@ export function computeNextFireTime(cronExpression: string, from?: Date): Date {
75
75
  return expr.next().toDate();
76
76
  }
77
77
 
78
+ /**
79
+ * Expand a cron expression's minute field into the concrete set of minute
80
+ * values (0-59) it fires on.
81
+ *
82
+ * Used by the schedule auto-stagger logic to detect collisions between
83
+ * existing schedules and a newly requested cron. Only the minute field is
84
+ * expanded — collisions across hour/day/month boundaries are intentionally
85
+ * out of scope (a `*​/30 * * * *` schedule and a `*​/30 9 * * *` schedule are
86
+ * treated as overlapping at minute :00 even though they only collide once
87
+ * a day, because that single collision is still a starvation risk).
88
+ *
89
+ * Supported minute syntax: `*`, `*​/N`, comma lists (`5,15,45`), ranges
90
+ * (`10-30`), single values, and `step ranges` (`0-30/5`).
91
+ */
92
+ export function expandCronMinutes(cronExpression: string): number[] {
93
+ const fields = cronExpression.trim().split(/\s+/);
94
+ if (fields.length !== 5) {
95
+ throw new Error(`Invalid cron expression (need 5 fields): ${cronExpression}`);
96
+ }
97
+ return expandMinuteField(fields[0]);
98
+ }
99
+
100
+ function expandMinuteField(field: string): number[] {
101
+ const result = new Set<number>();
102
+ for (const part of field.split(",")) {
103
+ const trimmed = part.trim();
104
+ if (!trimmed) continue;
105
+
106
+ // Handle step syntax: */N, M/N, or A-B/N
107
+ let stepBase = trimmed;
108
+ let step = 1;
109
+ const stepIdx = trimmed.indexOf("/");
110
+ if (stepIdx >= 0) {
111
+ stepBase = trimmed.slice(0, stepIdx);
112
+ step = parseInt(trimmed.slice(stepIdx + 1), 10);
113
+ if (!Number.isFinite(step) || step <= 0) {
114
+ throw new Error(`Invalid step in cron minute field: ${trimmed}`);
115
+ }
116
+ }
117
+
118
+ let from: number;
119
+ let to: number;
120
+ if (stepBase === "*") {
121
+ from = 0;
122
+ to = 59;
123
+ } else if (stepBase.includes("-")) {
124
+ const [a, b] = stepBase.split("-").map((n) => parseInt(n, 10));
125
+ if (!Number.isFinite(a) || !Number.isFinite(b)) {
126
+ throw new Error(`Invalid range in cron minute field: ${trimmed}`);
127
+ }
128
+ from = a;
129
+ to = b;
130
+ } else {
131
+ const single = parseInt(stepBase, 10);
132
+ if (!Number.isFinite(single)) {
133
+ throw new Error(`Invalid value in cron minute field: ${trimmed}`);
134
+ }
135
+ // Bare integer with no step: just that one minute.
136
+ // Bare integer with /N: from value to 59 by step (cron semantics).
137
+ from = single;
138
+ to = stepIdx >= 0 ? 59 : single;
139
+ }
140
+
141
+ for (let m = from; m <= to && m < 60; m += step) {
142
+ if (m >= 0) result.add(m);
143
+ }
144
+ }
145
+ return [...result].sort((a, b) => a - b);
146
+ }
147
+
148
+ const MIN_GAP_MINUTES = 5;
149
+
150
+ export interface StaggerResult {
151
+ cronExpression: string;
152
+ offsetApplied: number;
153
+ collided: boolean;
154
+ }
155
+
156
+ /**
157
+ * Compute a non-colliding cron expression for a new schedule by offsetting
158
+ * its minute field if any of its fire minutes are within MIN_GAP_MINUTES of
159
+ * an already-occupied minute.
160
+ *
161
+ * Strategy:
162
+ * 1. Expand both the requested cron and all existing schedule crons into
163
+ * minute sets.
164
+ * 2. Detect the "interval period" of the request (e.g. 30 for `*​/30`),
165
+ * which bounds the offset search space.
166
+ * 3. Walk offsets [0..interval-1] looking for the smallest one that puts
167
+ * all requested fire minutes ≥ MIN_GAP_MINUTES away from every
168
+ * occupied minute.
169
+ * 4. Apply the offset by rewriting the minute field. For `*​/N` patterns
170
+ * we rewrite to a comma list `(0+off, N+off, 2N+off, ...)`. For lists
171
+ * we shift each element by the offset (mod 60).
172
+ *
173
+ * Returns the original cron when no offset is needed or when no
174
+ * collision-free offset exists in the search space (caller can decide
175
+ * whether to warn).
176
+ */
177
+ export function computeStaggeredCron(
178
+ requestedCron: string,
179
+ existingCrons: string[]
180
+ ): StaggerResult {
181
+ const requestedMinutes = expandCronMinutes(requestedCron);
182
+ if (requestedMinutes.length === 0) {
183
+ return { cronExpression: requestedCron, offsetApplied: 0, collided: false };
184
+ }
185
+
186
+ const occupied = new Set<number>();
187
+ for (const cron of existingCrons) {
188
+ try {
189
+ for (const m of expandCronMinutes(cron)) occupied.add(m);
190
+ } catch {
191
+ // Skip cron expressions we cannot parse — better to allow the user's
192
+ // schedule than to block creation on a bad neighbor.
193
+ }
194
+ }
195
+
196
+ if (!hasCollision(requestedMinutes, occupied)) {
197
+ return { cronExpression: requestedCron, offsetApplied: 0, collided: false };
198
+ }
199
+
200
+ // Determine the interval period bounds offset search.
201
+ // For `*​/N * * * *` the period is N. For arbitrary lists fall back to 60.
202
+ const period = detectMinutePeriod(requestedCron) ?? 60;
203
+
204
+ for (let offset = 1; offset < period; offset++) {
205
+ const shifted = requestedMinutes.map((m) => (m + offset) % 60);
206
+ if (!hasCollision(shifted, occupied)) {
207
+ return {
208
+ cronExpression: applyMinuteOffset(requestedCron, offset),
209
+ offsetApplied: offset,
210
+ collided: true,
211
+ };
212
+ }
213
+ }
214
+
215
+ // No collision-free offset found in search space — return original and let
216
+ // the caller log a warning. The queue drain still prevents starvation.
217
+ return { cronExpression: requestedCron, offsetApplied: 0, collided: true };
218
+ }
219
+
220
+ function hasCollision(minutes: number[], occupied: Set<number>): boolean {
221
+ if (occupied.size === 0) return false;
222
+ for (const m of minutes) {
223
+ for (let delta = -MIN_GAP_MINUTES + 1; delta < MIN_GAP_MINUTES; delta++) {
224
+ const candidate = (m + delta + 60) % 60;
225
+ if (occupied.has(candidate)) return true;
226
+ }
227
+ }
228
+ return false;
229
+ }
230
+
231
+ /**
232
+ * Detect the minute period of a cron expression. Returns the step value for
233
+ * `*​/N` patterns, the gap for evenly-spaced lists like `0,30`, or null when
234
+ * the pattern is irregular.
235
+ */
236
+ function detectMinutePeriod(cronExpression: string): number | null {
237
+ const minuteField = cronExpression.trim().split(/\s+/)[0];
238
+ const stepMatch = minuteField.match(/^\*\/(\d+)$/);
239
+ if (stepMatch) return parseInt(stepMatch[1], 10);
240
+
241
+ const minutes = expandCronMinutes(cronExpression);
242
+ if (minutes.length < 2) return null;
243
+ const gap = minutes[1] - minutes[0];
244
+ for (let i = 2; i < minutes.length; i++) {
245
+ if (minutes[i] - minutes[i - 1] !== gap) return null;
246
+ }
247
+ return gap;
248
+ }
249
+
250
+ /**
251
+ * Rewrite the minute field of a cron expression by shifting all minutes by
252
+ * the given offset. Replaces `*​/N` shorthand with an explicit comma list so
253
+ * the offset is visible to users inspecting the cron.
254
+ */
255
+ function applyMinuteOffset(cronExpression: string, offset: number): string {
256
+ const fields = cronExpression.trim().split(/\s+/);
257
+ const minutes = expandCronMinutes(cronExpression);
258
+ const shifted = minutes
259
+ .map((m) => (m + offset) % 60)
260
+ .sort((a, b) => a - b);
261
+ fields[0] = shifted.join(",");
262
+ return fields.join(" ");
263
+ }
264
+
78
265
  /**
79
266
  * Generate a human-readable description of a cron expression.
80
267
  */
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Heuristic prompt-efficiency analyzer for scheduled prompts.
3
+ *
4
+ * Field deployments showed that scheduled tasks frequently exhausted their
5
+ * `maxTurns` budget because authors wrote prompts using per-item language
6
+ * ("for each holding, search the price"), which causes agents to issue N
7
+ * sequential tool calls instead of one batched call. This analyzer flags
8
+ * those patterns at schedule-creation time so the user can rewrite the
9
+ * prompt before it ever fires.
10
+ *
11
+ * The analyzer is intentionally conservative: it returns *warnings*, not
12
+ * errors. The chat tool surfaces them to the user but still creates the
13
+ * schedule. The runtime turn limit remains the hard backstop.
14
+ */
15
+
16
+ export type WarningSeverity = "low" | "medium" | "high";
17
+
18
+ export interface PromptWarning {
19
+ type: string;
20
+ severity: WarningSeverity;
21
+ message: string;
22
+ }
23
+
24
+ const LOOP_PATTERNS: RegExp[] = [
25
+ /\bfor each\b/i,
26
+ /\bfor every\b/i,
27
+ /\bone by one\b/i,
28
+ /\bindividually\b/i,
29
+ /\bper[\s-]?(symbol|stock|item|market|ticker|holding|row)\b/i,
30
+ /\bsearch for [^.]+ then search for\b/i,
31
+ ];
32
+
33
+ const LARGE_LIST_PATTERNS: RegExp[] = [
34
+ /\ball \d{2,}\b/i, // "all 32 markets"
35
+ /\beach of the \d{2,}\b/i,
36
+ ];
37
+
38
+ /**
39
+ * Analyze a scheduled prompt for known turn-exhaustion anti-patterns.
40
+ *
41
+ * The estimate uses a deliberately rough heuristic: count occurrences of
42
+ * search/fetch/lookup verbs and table operations, add 3 for orchestration
43
+ * overhead, and warn if the result exceeds 30. Tuned against the failures
44
+ * observed in production (97 / 84 / 69 turn cases all triggered).
45
+ */
46
+ export function analyzePromptEfficiency(prompt: string): PromptWarning[] {
47
+ const warnings: PromptWarning[] = [];
48
+
49
+ for (const pattern of LOOP_PATTERNS) {
50
+ if (pattern.test(prompt)) {
51
+ warnings.push({
52
+ type: "loop_pattern",
53
+ severity: "high",
54
+ message:
55
+ "Prompt contains per-item processing language. Consider batching: instead of \"search for each stock price\", use \"search for all stock prices in one query: AMZN GOOGL NVDA...\".",
56
+ });
57
+ break;
58
+ }
59
+ }
60
+
61
+ for (const pattern of LARGE_LIST_PATTERNS) {
62
+ if (pattern.test(prompt)) {
63
+ warnings.push({
64
+ type: "large_list",
65
+ severity: "medium",
66
+ message:
67
+ "Prompt references a large number of items. Consider a bulk API call instead of iterating.",
68
+ });
69
+ break;
70
+ }
71
+ }
72
+
73
+ // Rough turn estimate. Each search-style verb ≈ 1 turn; each table op ≈ 1 turn.
74
+ const webSearchCount = (prompt.match(/\b(search|fetch|look up|check.*price)\b/gi) || []).length;
75
+ const tableOps = (prompt.match(/\b(read|query|update|insert|write).*(table|row)\b/gi) || []).length;
76
+ const estimatedTurns = webSearchCount + tableOps + 3;
77
+
78
+ if (estimatedTurns > 30) {
79
+ warnings.push({
80
+ type: "high_turn_estimate",
81
+ severity: "high",
82
+ message: `Estimated ${estimatedTurns}+ turns required. Consider splitting into a multi-step workflow or batching operations.`,
83
+ });
84
+ }
85
+
86
+ return warnings;
87
+ }