pi-workflow-engine 0.11.0 → 0.12.0

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 (73) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +255 -97
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +0 -4
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +3 -1
  4. package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +27 -4
  6. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
  7. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +49 -45
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +95 -26
  9. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +158 -203
  11. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +48 -42
  12. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +11 -3
  13. package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
  14. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
  15. package/.pi/extensions/pi-workflow-engine/src/budget.ts +3 -3
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +38 -0
  17. package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
  18. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +70 -4
  19. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +16 -130
  20. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +9 -5
  21. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +109 -60
  22. package/.pi/extensions/pi-workflow-engine/src/engine.ts +166 -18
  23. package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
  24. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +2 -2
  25. package/.pi/extensions/pi-workflow-engine/src/journal.ts +12 -6
  26. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
  27. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
  28. package/.pi/extensions/pi-workflow-engine/src/options.ts +88 -1
  29. package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
  30. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
  31. package/.pi/extensions/pi-workflow-engine/src/progress.ts +72 -117
  32. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
  33. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
  34. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
  35. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +74 -38
  36. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +1 -1
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +2 -1
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +11 -30
  40. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +5 -5
  41. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +3 -4
  42. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
  43. package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
  44. package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
  45. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
  46. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +1 -4
  47. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +1 -4
  48. package/.pi/extensions/pi-workflow-engine/src/types.ts +49 -15
  49. package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
  50. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
  51. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
  52. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +2 -1
  53. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
  54. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
  55. package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
  56. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
  57. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +1 -1
  58. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
  59. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
  60. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
  61. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
  62. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
  63. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
  64. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +2 -0
  65. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +40 -3
  66. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +18 -42
  67. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +10 -17
  68. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -15
  69. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -15
  70. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
  71. package/README.md +72 -141
  72. package/USAGE.md +211 -17
  73. package/package.json +1 -1
@@ -1,55 +1,20 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import type { TUI } from "@earendil-works/pi-tui";
3
- import type { WorkflowLaneItemStatus, WorkflowProgressEvent } from "./types.ts";
2
+ import type { WorkflowProgressEvent } from "./types.ts";
3
+ import type { AgentRowStatus, WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
4
+ import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "./usage.ts";
4
5
  import { unknownErrorMessage } from "./unknown-error.ts";
5
6
  import { statusTextFromCounts, type WorkflowStatusCounts } from "./ui/workflow-format.ts";
6
- import { createWorkflowWidget, type WorkflowWidget } from "./ui/workflow-widget.ts";
7
-
8
- export type AgentRowStatus = "queued" | "running" | "done" | "failed";
9
-
10
- export interface AgentRowSnapshot {
11
- readonly id: number;
12
- readonly label: string;
13
- readonly status: AgentRowStatus;
14
- readonly startedAt?: number;
15
- readonly doneAt?: number;
16
- readonly toolUses: number;
17
- readonly lastTool?: string;
18
- readonly error?: string;
19
- }
20
-
21
- export interface PhaseSnapshot {
22
- readonly title: string;
23
- readonly agents: readonly AgentRowSnapshot[];
24
- }
25
-
26
- export interface WorkflowCounterSnapshot {
27
- readonly key: string;
28
- readonly label: string;
29
- readonly value: number;
30
- }
31
-
32
- export interface WorkflowLaneItemSnapshot {
33
- readonly lane: string;
34
- readonly title: string;
35
- readonly subtitle?: string;
36
- readonly status: WorkflowLaneItemStatus;
37
- readonly details?: string;
38
- readonly createdAt: number;
39
- }
40
-
41
- export interface WorkflowProgressSnapshot {
42
- readonly title: string;
43
- readonly startedAt: number;
44
- readonly doneAt?: number;
45
- readonly currentPhase: string;
46
- readonly phases: readonly PhaseSnapshot[];
47
- readonly counters: readonly WorkflowCounterSnapshot[];
48
- readonly summary: readonly [string, string | number][];
49
- readonly lanes: readonly [string, readonly WorkflowLaneItemSnapshot[]][];
50
- readonly laneOverflow: readonly [string, number][];
51
- readonly logs: readonly string[];
52
- }
7
+ import { renderWorkflowWidgetLines } from "./ui/workflow-widget.ts";
8
+
9
+ export type {
10
+ AgentRowSnapshot,
11
+ AgentRowStatus,
12
+ PhaseSnapshot,
13
+ WorkflowCounterSnapshot,
14
+ WorkflowLaneItemSnapshot,
15
+ WorkflowLaneItemStatus,
16
+ WorkflowProgressSnapshot,
17
+ } from "./progress-types.ts";
53
18
 
54
19
  interface AgentRow {
55
20
  id: number;
@@ -83,6 +48,7 @@ interface WorkflowLaneItem {
83
48
  }
84
49
 
85
50
  const LOG_LIMIT = 24;
51
+ const WIDGET_REFRESH_INTERVAL_MS = 1_000;
86
52
  export const DEFAULT_LANE_ITEM_LIMIT = 200;
87
53
 
88
54
  /**
@@ -103,17 +69,18 @@ export class ProgressTracker {
103
69
  private doneAt: number | undefined;
104
70
  private currentPhase = "Workflow";
105
71
  private nextAgentId = 1;
106
- private widget: WorkflowWidget | undefined;
107
- private widgetRegistered = false;
108
- private tui: Pick<TUI, "requestRender"> | undefined;
109
- private widgetInterval: ReturnType<typeof setInterval> | undefined;
110
72
  private lastStatusText: string | undefined;
111
- private renderQueued = false;
73
+ private usageSnapshot: WorkflowUsageSnapshot | undefined;
74
+ private widgetRefreshInterval: ReturnType<typeof setInterval> | undefined;
75
+ private readonly surfaceKey: string;
112
76
 
113
77
  constructor(
114
78
  private readonly ctx: ExtensionContext,
115
79
  private readonly title: string,
80
+ private readonly runId: string,
81
+ private readonly onSnapshot?: (snapshot: WorkflowProgressSnapshot) => void,
116
82
  ) {
83
+ this.surfaceKey = `workflow:${runId}`;
117
84
  this.ensurePhase(this.currentPhase);
118
85
  }
119
86
 
@@ -130,14 +97,14 @@ export class ProgressTracker {
130
97
  this.currentPhase = title;
131
98
  this.ensurePhase(title);
132
99
  if (!this.ctx.hasUI) process.stderr.write(`[${this.title}] ${title}\n`);
133
- this.render();
100
+ this.publish();
134
101
  }
135
102
 
136
103
  log(message: string): void {
137
104
  this.logs.push(message);
138
105
  while (this.logs.length > LOG_LIMIT) this.logs.shift();
139
106
  if (!this.ctx.hasUI) process.stderr.write(`[${this.title}] ${message}\n`);
140
- this.render();
107
+ this.publish();
141
108
  }
142
109
 
143
110
  event(event: WorkflowProgressEvent): void {
@@ -172,7 +139,7 @@ export class ProgressTracker {
172
139
  this.summary.set(event.key, event.value);
173
140
  break;
174
141
  }
175
- this.render();
142
+ this.publish();
176
143
  }
177
144
 
178
145
  agentQueued(phase: string | undefined, label: string): number {
@@ -181,7 +148,7 @@ export class ProgressTracker {
181
148
  this.ensurePhase(phase ?? this.currentPhase).agents.push(row);
182
149
  this.rowsById.set(id, row);
183
150
  this.agentCounts.queued++;
184
- this.render();
151
+ this.publish();
185
152
  return id;
186
153
  }
187
154
 
@@ -203,7 +170,7 @@ export class ProgressTracker {
203
170
  this.rowsById.set(nextRow.id, nextRow);
204
171
  this.agentCounts.running++;
205
172
  }
206
- this.render();
173
+ this.publish();
207
174
  }
208
175
 
209
176
  agentTool(label: string, tool: string, id?: number): void {
@@ -212,7 +179,7 @@ export class ProgressTracker {
212
179
  row.lastTool = tool;
213
180
  row.toolUses += 1;
214
181
  }
215
- this.render();
182
+ this.publish();
216
183
  }
217
184
 
218
185
  agentDone(label: string, id?: number): void {
@@ -221,7 +188,7 @@ export class ProgressTracker {
221
188
  this.transitionAgentStatus(row, "done");
222
189
  row.doneAt = Date.now();
223
190
  }
224
- this.render();
191
+ this.publish();
225
192
  }
226
193
 
227
194
  agentFailed(label: string, error: unknown, id?: number): void {
@@ -231,11 +198,17 @@ export class ProgressTracker {
231
198
  row.doneAt = Date.now();
232
199
  row.error = unknownErrorMessage(error);
233
200
  }
234
- this.render();
201
+ this.publish();
202
+ }
203
+
204
+ updateUsage(snapshot: WorkflowUsageSnapshot): void {
205
+ this.usageSnapshot = snapshot;
206
+ this.publish();
235
207
  }
236
208
 
237
209
  snapshot(): WorkflowProgressSnapshot {
238
210
  return {
211
+ runId: this.runId,
239
212
  title: this.title,
240
213
  startedAt: this.startedAt,
241
214
  doneAt: this.doneAt,
@@ -249,6 +222,7 @@ export class ProgressTracker {
249
222
  lanes: [...this.lanes.entries()].map(([lane, items]) => [lane, items.map((item) => ({ ...item }))]),
250
223
  laneOverflow: [...this.laneOverflow.entries()],
251
224
  logs: [...this.logs],
225
+ usage: this.usageSnapshot,
252
226
  };
253
227
  }
254
228
 
@@ -300,25 +274,35 @@ export class ProgressTracker {
300
274
  return this.rowsById.get(id);
301
275
  }
302
276
 
303
- private render(): void {
277
+ private publish(): void {
278
+ this.publishSnapshot();
304
279
  if (!this.ctx.hasUI) return;
305
- this.ensureWidget();
306
- this.widget?.invalidate();
307
- this.requestRenderSoon();
280
+ this.publishWidget();
281
+ this.startWidgetRefresh();
308
282
  this.publishStatus();
309
283
  }
310
284
 
311
- private requestRenderSoon(): void {
312
- if (this.renderQueued) return;
313
- this.renderQueued = true;
314
- queueMicrotask(() => {
315
- this.renderQueued = false;
316
- this.tui?.requestRender();
317
- });
285
+ private publishWidget(): void {
286
+ this.ctx.ui.setWidget(
287
+ this.surfaceKey,
288
+ renderWorkflowWidgetLines(this.snapshot(), this.ctx.ui.theme),
289
+ { placement: "aboveEditor" },
290
+ );
291
+ }
292
+
293
+ private startWidgetRefresh(): void {
294
+ if (this.widgetRefreshInterval !== undefined) return;
295
+ this.widgetRefreshInterval = setInterval(() => this.publishWidget(), WIDGET_REFRESH_INTERVAL_MS);
296
+ }
297
+
298
+ private stopWidgetRefresh(): void {
299
+ if (this.widgetRefreshInterval === undefined) return;
300
+ clearInterval(this.widgetRefreshInterval);
301
+ this.widgetRefreshInterval = undefined;
318
302
  }
319
303
 
320
304
  private publishStatus(): void {
321
- const next = statusTextFromCounts(
305
+ const status = statusTextFromCounts(
322
306
  {
323
307
  title: this.title,
324
308
  doneAt: this.doneAt,
@@ -328,55 +312,26 @@ export class ProgressTracker {
328
312
  this.statusCountsSnapshot(),
329
313
  this.ctx.ui.theme,
330
314
  );
315
+ const usage = formatWorkflowUsageLine(this.usageSnapshot);
316
+ const next = usage ? `${status} · ${usage}` : status;
331
317
  if (next === this.lastStatusText) return;
332
- this.ctx.ui.setStatus("workflow", next);
318
+ this.ctx.ui.setStatus(this.surfaceKey, next);
333
319
  this.lastStatusText = next;
334
320
  }
335
321
 
336
- private ensureWidget(): void {
337
- if (this.widgetRegistered) return;
338
- this.widget = createWorkflowWidget(() => this.snapshot());
339
- this.ctx.ui.setWidget(
340
- "workflow",
341
- (tui, theme) => {
342
- this.tui = tui;
343
- return {
344
- render: (width?: number) => this.widget?.render(width ?? tui.terminal.columns, theme) ?? [],
345
- invalidate: () => this.widget?.invalidate(),
346
- };
347
- },
348
- { placement: "aboveEditor" },
349
- );
350
- this.widgetRegistered = true;
351
- this.startWidgetTimer();
352
- }
353
-
354
- private startWidgetTimer(): void {
355
- if (this.widgetInterval !== undefined) return;
356
- this.widgetInterval = setInterval(() => {
357
- this.widget?.nextFrame();
358
- this.tui?.requestRender();
359
- }, 100);
360
- }
361
-
362
- private stopWidgetTimer(): void {
363
- if (this.widgetInterval === undefined) return;
364
- clearInterval(this.widgetInterval);
365
- this.widgetInterval = undefined;
366
- }
367
-
368
- /** Clear live workflow surfaces; optionally leave a one-line final status. */
369
- done(status?: string): void {
322
+ /** Clear this run's live workflow surfaces. Final feedback is delivered by the result surface. */
323
+ done(): void {
370
324
  this.doneAt = Date.now();
371
- this.stopWidgetTimer();
325
+ this.stopWidgetRefresh();
326
+ this.publishSnapshot();
372
327
  if (!this.ctx.hasUI) return;
373
- this.ctx.ui.setWidget("workflow", undefined);
374
- this.ctx.ui.setStatus("workflow", status);
375
- this.lastStatusText = status;
376
- this.renderQueued = false;
377
- this.widgetRegistered = false;
378
- this.widget = undefined;
379
- this.tui = undefined;
328
+ this.ctx.ui.setWidget(this.surfaceKey, undefined);
329
+ this.ctx.ui.setStatus(this.surfaceKey, undefined);
330
+ this.lastStatusText = undefined;
331
+ }
332
+
333
+ private publishSnapshot(): void {
334
+ this.onSnapshot?.(this.snapshot());
380
335
  }
381
336
  }
382
337
 
@@ -0,0 +1,189 @@
1
+ import { WorkflowPauseError } from "./cancellation.ts";
2
+ import {
3
+ WORKFLOW_USAGE_LIMIT_DELAY_MIN_MS,
4
+ type ResolvedWorkflowRunOptions,
5
+ } from "./options.ts";
6
+ import type { WorkflowProviderUsageLimitPause } from "./workflow-run-record.ts";
7
+
8
+ export const WORKFLOW_PROVIDER_USAGE_LIMIT_CODE = "WORKFLOW_PROVIDER_USAGE_LIMIT";
9
+ export const WORKFLOW_USAGE_LIMIT_FALLBACK_DELAY_MS = 60_000;
10
+ const MAX_RESET_HINT_LENGTH = 512;
11
+
12
+ export interface ProviderUsageLimitDetails {
13
+ readonly stopReason: "error";
14
+ readonly providerMessage: string;
15
+ readonly provider?: string;
16
+ readonly model?: string;
17
+ readonly api?: string;
18
+ readonly resetHint?: string;
19
+ readonly resetAt?: number;
20
+ }
21
+
22
+ export interface ProviderUsageLimitPauseRecord {
23
+ readonly message: string;
24
+ readonly pause: WorkflowProviderUsageLimitPause;
25
+ }
26
+
27
+ /** Fatal-to-the-current-run provider limit that remains resumable from its journal boundary. */
28
+ export class WorkflowProviderUsageLimitError extends WorkflowPauseError {
29
+ override readonly name = "WorkflowProviderUsageLimitError";
30
+ readonly code = WORKFLOW_PROVIDER_USAGE_LIMIT_CODE;
31
+
32
+ constructor(readonly details: ProviderUsageLimitDetails) {
33
+ super(details.providerMessage);
34
+ }
35
+
36
+ toJSON(): {
37
+ readonly name: string;
38
+ readonly message: string;
39
+ readonly code: string;
40
+ readonly details: ProviderUsageLimitDetails;
41
+ } {
42
+ return { name: this.name, message: this.message, code: this.code, details: this.details };
43
+ }
44
+ }
45
+
46
+ export function providerUsageLimitFromMessages(
47
+ messages: readonly unknown[],
48
+ now = Date.now(),
49
+ ): WorkflowProviderUsageLimitError | undefined {
50
+ const message = messages.findLast(isAssistantMessage);
51
+ if (!message || message.stopReason !== "error") return undefined;
52
+ const providerMessage = typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
53
+ if (!providerMessage || !isUsageLimitMessage(providerMessage)) return undefined;
54
+ const reset = parseProviderResetHint(providerMessage, now);
55
+ const provider = stringDetail(message.provider);
56
+ const model = stringDetail(message.model);
57
+ const api = stringDetail(message.api);
58
+ return new WorkflowProviderUsageLimitError({
59
+ stopReason: "error",
60
+ providerMessage,
61
+ ...(provider ? { provider } : {}),
62
+ ...(model ? { model } : {}),
63
+ ...(api ? { api } : {}),
64
+ ...reset,
65
+ });
66
+ }
67
+
68
+ export function createProviderUsageLimitPauseRecord(
69
+ error: WorkflowProviderUsageLimitError,
70
+ options: ResolvedWorkflowRunOptions,
71
+ replayableInvocation: boolean,
72
+ now = Date.now(),
73
+ ): ProviderUsageLimitPauseRecord {
74
+ const attempt = options.usageLimitAttempt + 1;
75
+ const hintedDelay = error.details.resetAt === undefined
76
+ ? WORKFLOW_USAGE_LIMIT_FALLBACK_DELAY_MS
77
+ : error.details.resetAt - now;
78
+ const delayMs = Math.min(
79
+ options.usageLimitMaxDelayMs,
80
+ Math.max(
81
+ WORKFLOW_USAGE_LIMIT_DELAY_MIN_MS,
82
+ hintedDelay > 0 ? hintedDelay : WORKFLOW_USAGE_LIMIT_FALLBACK_DELAY_MS,
83
+ ),
84
+ );
85
+ const autoResume = options.autoResumeOnUsageLimit
86
+ && attempt < options.usageLimitMaxAttempts
87
+ && replayableInvocation;
88
+ return {
89
+ message: autoResume
90
+ ? `Provider usage limit paused this run; automatic attempt ${attempt + 1}/${options.usageLimitMaxAttempts} is scheduled.`
91
+ : `Provider usage limit paused this run at attempt ${attempt}/${options.usageLimitMaxAttempts}.`,
92
+ pause: {
93
+ kind: "provider_usage_limit",
94
+ reason: "provider_usage_limit",
95
+ providerMessage: error.details.providerMessage,
96
+ provider: error.details.provider,
97
+ model: error.details.model,
98
+ api: error.details.api,
99
+ resetHint: error.details.resetHint,
100
+ attempt,
101
+ nextEligibleAt: now + delayMs,
102
+ autoResume,
103
+ maxAttempts: options.usageLimitMaxAttempts,
104
+ },
105
+ };
106
+ }
107
+
108
+ export function parseProviderResetHint(
109
+ message: string,
110
+ now = Date.now(),
111
+ ): { readonly resetHint?: string; readonly resetAt?: number } {
112
+ const iso = message.match(
113
+ /(?:[a-z-]*reset[a-z-]*|resets?\s+at|available\s+again\s+at)\s*[:=]?\s*(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))/i,
114
+ )?.[1];
115
+ if (iso) {
116
+ const parsed = Date.parse(iso);
117
+ return Number.isFinite(parsed) && parsed > now
118
+ ? { resetHint: boundedHint(iso), resetAt: parsed }
119
+ : { resetHint: boundedHint(iso) };
120
+ }
121
+
122
+ const labelled = message.match(
123
+ /(?:retry[- ]after|try again in|resets? in|available again in|x-ratelimit-reset(?:-[a-z-]+)?)\s*[:=]?\s*((?:\d+(?:\.\d+)?\s*(?:milliseconds?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)\s*)+)/i,
124
+ );
125
+ if (labelled?.[1]) {
126
+ const delayMs = parseDurationMs(labelled[1]);
127
+ return delayMs === undefined
128
+ ? { resetHint: boundedHint(labelled[0]) }
129
+ : { resetHint: boundedHint(labelled[0]), resetAt: now + delayMs };
130
+ }
131
+
132
+ const retryAfter = message.match(/retry[- ]after\s*[:=]\s*([^\s,;]+)/i);
133
+ if (retryAfter?.[1]) {
134
+ const seconds = Number(retryAfter[1]);
135
+ return Number.isFinite(seconds) && seconds >= 0
136
+ ? { resetHint: boundedHint(retryAfter[0]), resetAt: now + seconds * 1_000 }
137
+ : { resetHint: boundedHint(retryAfter[0]) };
138
+ }
139
+ return {};
140
+ }
141
+
142
+ function isUsageLimitMessage(message: string): boolean {
143
+ return /(?:GoUsageLimitError|FreeUsageLimitError|insufficient_quota|rate_limit_error|ResourceExhausted)/i.test(message)
144
+ || /\btoo many requests\b/i.test(message)
145
+ || /\b(?:http|status(?: code)?|error)\s*[:=]?\s*429\b/i.test(message)
146
+ || /\b429\b[^\n]*(?:rate|requests?|quota|limit)/i.test(message)
147
+ || /\b(?:usage|quota|monthly|weekly|daily)\s+(?:window\s+)?limit\b/i.test(message)
148
+ || /\bquota\s+(?:has\s+been\s+)?exceeded\b/i.test(message)
149
+ || /\bexceeded\b[^\n]*\bquota\b/i.test(message)
150
+ || /\b(?:usage|rate)\s+limit\s+(?:(?:has\s+been|was)\s+)?(?:reached|exceeded)\b/i.test(message);
151
+ }
152
+
153
+ function parseDurationMs(value: string): number | undefined {
154
+ const unitPattern = /(\d+(?:\.\d+)?)\s*(milliseconds?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d)/gi;
155
+ let total = 0;
156
+ let matched = "";
157
+ for (const part of value.matchAll(unitPattern)) {
158
+ const amount = Number(part[1]);
159
+ const unit = part[2]?.toLowerCase();
160
+ if (!Number.isFinite(amount) || !unit) return undefined;
161
+ matched += part[0];
162
+ if (unit === "ms" || unit.startsWith("millisecond")) total += amount;
163
+ else if (unit === "s" || unit.startsWith("sec")) total += amount * 1_000;
164
+ else if (unit === "m" || unit.startsWith("min")) total += amount * 60_000;
165
+ else if (unit === "h" || unit.startsWith("hr") || unit.startsWith("hour")) total += amount * 3_600_000;
166
+ else total += amount * 86_400_000;
167
+ }
168
+ if (!matched || value.replace(/\s+/g, "") !== matched.replace(/\s+/g, "")) return undefined;
169
+ return Number.isFinite(total) && total >= 0 ? total : undefined;
170
+ }
171
+
172
+ function boundedHint(value: string): string {
173
+ return value.length <= MAX_RESET_HINT_LENGTH ? value : `${value.slice(0, MAX_RESET_HINT_LENGTH - 1)}…`;
174
+ }
175
+
176
+ function isAssistantMessage(value: unknown): value is {
177
+ readonly role: "assistant";
178
+ readonly stopReason?: unknown;
179
+ readonly errorMessage?: unknown;
180
+ readonly provider?: unknown;
181
+ readonly model?: unknown;
182
+ readonly api?: unknown;
183
+ } {
184
+ return typeof value === "object" && value !== null && "role" in value && value.role === "assistant";
185
+ }
186
+
187
+ function stringDetail(value: unknown): string | undefined {
188
+ return typeof value === "string" && value.length > 0 ? value : undefined;
189
+ }
@@ -0,0 +1,89 @@
1
+ import { Type, type Static } from "typebox";
2
+
3
+ export const MAX_RESEARCH_LANES = 4;
4
+ export const MAX_LANE_EVIDENCE = 6;
5
+ export const MAX_VERIFICATION_CLAIMS = 12;
6
+
7
+ export const ResearchSourceSchema = Type.Object({
8
+ title: Type.String({ minLength: 1, description: "Human-readable title of the supporting page." }),
9
+ url: Type.String({ minLength: 1, description: "Direct HTTP(S) URL of the supporting page, never a search-results URL." }),
10
+ publishedAt: Type.Optional(Type.String({ description: "Publication or update date when the page states one." })),
11
+ });
12
+
13
+ export type ResearchSource = Static<typeof ResearchSourceSchema>;
14
+
15
+ export const ResearchPlanSchema = Type.Object({
16
+ scopeConstraints: Type.Array(Type.String()),
17
+ lanes: Type.Array(
18
+ Type.Object({
19
+ id: Type.String({ minLength: 1 }),
20
+ title: Type.String({ minLength: 1 }),
21
+ objective: Type.String({ minLength: 1 }),
22
+ queries: Type.Array(Type.String({ minLength: 1 }), { minItems: 1, maxItems: 4 }),
23
+ }),
24
+ { minItems: 1, maxItems: MAX_RESEARCH_LANES },
25
+ ),
26
+ });
27
+
28
+ export type ResearchPlan = Static<typeof ResearchPlanSchema>;
29
+ export type ResearchLane = ResearchPlan["lanes"][number];
30
+
31
+ export const ResearchEvidenceSchema = Type.Object({
32
+ claim: Type.String({ minLength: 1 }),
33
+ importance: Type.Union([Type.Literal("high"), Type.Literal("medium"), Type.Literal("low")]),
34
+ stance: Type.Union([Type.Literal("supports"), Type.Literal("conflicts")]),
35
+ evidence: Type.String({ minLength: 1, description: "A short supporting passage or precise paraphrase from the page." }),
36
+ source: ResearchSourceSchema,
37
+ });
38
+
39
+ export type ResearchEvidence = Static<typeof ResearchEvidenceSchema>;
40
+
41
+ export const ResearchLaneResultSchema = Type.Object({
42
+ laneId: Type.String({ minLength: 1 }),
43
+ evidence: Type.Array(ResearchEvidenceSchema, { maxItems: MAX_LANE_EVIDENCE }),
44
+ gaps: Type.Array(Type.String()),
45
+ });
46
+
47
+ export type ResearchLaneResult = Static<typeof ResearchLaneResultSchema>;
48
+
49
+ export interface ResearchClaimCandidate {
50
+ readonly claim: string;
51
+ readonly importance: ResearchEvidence["importance"];
52
+ readonly evidence: readonly ResearchEvidence[];
53
+ }
54
+
55
+ export const ResearchVerificationSchema = Type.Object({
56
+ claim: Type.String({ minLength: 1 }),
57
+ verdict: Type.Union([
58
+ Type.Literal("SUPPORTED"),
59
+ Type.Literal("CONFLICTED"),
60
+ Type.Literal("UNCERTAIN"),
61
+ Type.Literal("INFERENCE"),
62
+ Type.Literal("REJECTED"),
63
+ ]),
64
+ explanation: Type.String({ minLength: 1 }),
65
+ sources: Type.Array(ResearchSourceSchema, { maxItems: 8 }),
66
+ });
67
+
68
+ export type ResearchVerification = Static<typeof ResearchVerificationSchema>;
69
+
70
+ export const ResearchReportEntrySchema = Type.Object({
71
+ claim: Type.String({ minLength: 1 }),
72
+ explanation: Type.String({ minLength: 1 }),
73
+ citations: Type.Array(ResearchSourceSchema, { maxItems: 8 }),
74
+ });
75
+
76
+ export type ResearchReportEntry = Static<typeof ResearchReportEntrySchema>;
77
+
78
+ export const ResearchReportSchema = Type.Object({
79
+ answer: Type.String(),
80
+ supportedClaims: Type.Array(ResearchReportEntrySchema),
81
+ conflictingEvidence: Type.Array(ResearchReportEntrySchema),
82
+ uncertainties: Type.Array(ResearchReportEntrySchema),
83
+ inferences: Type.Array(ResearchReportEntrySchema),
84
+ sources: Type.Array(ResearchSourceSchema),
85
+ limitations: Type.Array(Type.String()),
86
+ nextSteps: Type.Array(Type.String()),
87
+ });
88
+
89
+ export type ResearchReport = Static<typeof ResearchReportSchema>;