pi-workflow-engine 0.6.1 → 0.8.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.
@@ -5,6 +5,7 @@ import type { PerfAggregate } from "../perf.ts";
5
5
  import { renderIssueDetails, renderIssuesTable } from "../review/review-format.ts";
6
6
  import { toReviewIssues, type ReviewContext } from "../review/review-issues.ts";
7
7
  import { formatCount } from "./workflow-format.ts";
8
+ import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "../usage.ts";
8
9
 
9
10
  export interface WorkflowPerfDetails {
10
11
  readonly enabled: boolean;
@@ -16,6 +17,7 @@ export interface WorkflowResultEnvelope {
16
17
  name: string;
17
18
  result: unknown;
18
19
  completedAt: number;
20
+ usage?: WorkflowUsageSnapshot;
19
21
  perf?: WorkflowPerfDetails;
20
22
  }
21
23
 
@@ -59,25 +61,27 @@ export function isAdvisoryReport(value: unknown): value is AdvisoryWorkflowResul
59
61
  return true;
60
62
  }
61
63
 
62
- export function renderWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme): Component {
64
+ export function renderWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme, usage?: unknown): Component {
63
65
  const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
64
- box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme), 0, 0));
66
+ box.addChild(new Text(renderWorkflowResultText(name, result, expanded, theme, usage), 0, 0));
65
67
  return box;
66
68
  }
67
69
 
68
- export function renderWorkflowResultText(name: string, result: unknown, expanded: boolean, theme: Theme): string {
70
+ export function renderWorkflowResultText(name: string, result: unknown, expanded: boolean, theme: Theme, usage?: unknown): string {
69
71
  if (isAdvisoryReport(result)) {
70
- return renderAdvisoryResult(name, result, expanded, theme);
72
+ return renderAdvisoryResult(name, result, expanded, theme, usage);
71
73
  }
72
- return renderGenericWorkflowResult(name, result, expanded, theme);
74
+ return renderGenericWorkflowResult(name, result, expanded, theme, usage);
73
75
  }
74
76
 
75
- function renderAdvisoryResult(name: string, result: AdvisoryWorkflowResult, expanded: boolean, theme: Theme): string {
77
+ function renderAdvisoryResult(name: string, result: AdvisoryWorkflowResult, expanded: boolean, theme: Theme, usage?: unknown): string {
76
78
  const icon = theme.fg("success", "✓");
77
79
  const title = theme.fg("accent", theme.bold(`Workflow: ${name}`));
78
80
  const lines = [`${icon} ${title}`, theme.fg("muted", result.summary)];
79
81
  const stats = statsLine(result.stats, theme);
80
82
  if (stats) lines.push(stats);
83
+ const usageLine = formatWorkflowUsageLine(usage);
84
+ if (usageLine) lines.push(theme.fg("dim", usageLine));
81
85
 
82
86
  if (result.findings.length === 0) {
83
87
  lines.push(theme.fg("success", "No findings."));
@@ -104,10 +108,12 @@ function renderNextSteps(nextSteps: string[], lines: string[], theme: Theme): vo
104
108
  }
105
109
  }
106
110
 
107
- function renderGenericWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme): string {
111
+ function renderGenericWorkflowResult(name: string, result: unknown, expanded: boolean, theme: Theme, usage?: unknown): string {
108
112
  const lines = [`${theme.fg("success", "✓")} ${theme.fg("accent", theme.bold(`Workflow: ${name}`))}`];
109
113
  const summary = extractSummary(result);
110
114
  if (summary) lines.push(theme.fg("muted", summary));
115
+ const usageLine = formatWorkflowUsageLine(usage);
116
+ if (usageLine) lines.push(theme.fg("dim", usageLine));
111
117
  if (expanded) lines.push(theme.fg("dim", safeJson(result)));
112
118
  else if (!summary) lines.push(theme.fg("dim", "Result available in expanded view."));
113
119
  return lines.join("\n");
@@ -0,0 +1,230 @@
1
+ export interface WorkflowUsageCost {
2
+ readonly input: number;
3
+ readonly output: number;
4
+ readonly cacheRead: number;
5
+ readonly cacheWrite: number;
6
+ readonly total: number;
7
+ }
8
+
9
+ export interface WorkflowUsageTotals {
10
+ readonly input: number;
11
+ readonly output: number;
12
+ readonly cacheRead: number;
13
+ readonly cacheWrite: number;
14
+ readonly totalTokens: number;
15
+ readonly cost: WorkflowUsageCost;
16
+ }
17
+
18
+ export interface WorkflowAgentUsage {
19
+ readonly label: string;
20
+ readonly phase?: string;
21
+ readonly provider?: string;
22
+ readonly model?: string;
23
+ readonly assistantMessages: number;
24
+ readonly usage: WorkflowUsageTotals;
25
+ }
26
+
27
+ export interface WorkflowUsageSnapshot {
28
+ readonly agents: readonly WorkflowAgentUsage[];
29
+ readonly totals: WorkflowUsageTotals;
30
+ readonly assistantMessages: number;
31
+ }
32
+
33
+ export interface WorkflowUsageSink {
34
+ recordAgentSession(input: { label: string; phase?: string; messages: readonly unknown[] }): void;
35
+ snapshot(): WorkflowUsageSnapshot;
36
+ }
37
+
38
+ const ZERO_COST: WorkflowUsageCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 };
39
+ const ZERO_TOTALS: WorkflowUsageTotals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: ZERO_COST };
40
+
41
+ interface AssistantUsageMessage {
42
+ readonly provider?: string;
43
+ readonly model?: string;
44
+ readonly usage: WorkflowUsageTotals;
45
+ }
46
+
47
+ export class WorkflowUsageRecorder implements WorkflowUsageSink {
48
+ private readonly agents: WorkflowAgentUsage[] = [];
49
+
50
+ recordAgentSession(input: { label: string; phase?: string; messages: readonly unknown[] }): void {
51
+ const assistantMessages = input.messages.flatMap((message) => {
52
+ const parsed = parseAssistantUsageMessage(message);
53
+ return parsed ? [parsed] : [];
54
+ });
55
+ if (assistantMessages.length === 0) return;
56
+
57
+ const usage = sumTotals(assistantMessages.map((message) => message.usage));
58
+ const latestMetadata = assistantMessages.findLast((message) => message.provider !== undefined || message.model !== undefined);
59
+ this.agents.push({
60
+ label: input.label,
61
+ phase: input.phase,
62
+ provider: latestMetadata?.provider,
63
+ model: latestMetadata?.model,
64
+ assistantMessages: assistantMessages.length,
65
+ usage,
66
+ });
67
+ }
68
+
69
+ snapshot(): WorkflowUsageSnapshot {
70
+ const agents = this.agents.map((agent) => ({
71
+ ...agent,
72
+ usage: cloneTotals(agent.usage),
73
+ }));
74
+ return {
75
+ agents,
76
+ totals: sumTotals(agents.map((agent) => agent.usage)),
77
+ assistantMessages: agents.reduce((sum, agent) => sum + agent.assistantMessages, 0),
78
+ };
79
+ }
80
+ }
81
+
82
+ export function createWorkflowUsageRecorder(): WorkflowUsageSink {
83
+ return new WorkflowUsageRecorder();
84
+ }
85
+
86
+ export function emptyWorkflowUsageTotals(): WorkflowUsageTotals {
87
+ return cloneTotals(ZERO_TOTALS);
88
+ }
89
+
90
+ export function isWorkflowUsageSnapshot(value: unknown): value is WorkflowUsageSnapshot {
91
+ if (!isRecord(value)) return false;
92
+ if (!Array.isArray(value.agents) || !value.agents.every(isWorkflowAgentUsage)) return false;
93
+ if (!isWorkflowUsageTotals(value.totals)) return false;
94
+ return finiteNumber(value.assistantMessages) !== undefined;
95
+ }
96
+
97
+ export function hasWorkflowUsage(snapshot: unknown): snapshot is WorkflowUsageSnapshot {
98
+ if (!isWorkflowUsageSnapshot(snapshot)) return false;
99
+ return snapshot.assistantMessages > 0 || snapshot.totals.totalTokens > 0 || snapshot.totals.cost.total > 0;
100
+ }
101
+
102
+ export function formatWorkflowUsageLine(snapshot: unknown): string | undefined {
103
+ if (!hasWorkflowUsage(snapshot)) return undefined;
104
+ const parts = [`↑${formatUsageCount(snapshot.totals.input)}`, `↓${formatUsageCount(snapshot.totals.output)}`];
105
+ if (snapshot.totals.cacheRead > 0) parts.push(`R${formatUsageCount(snapshot.totals.cacheRead)}`);
106
+ if (snapshot.totals.cacheWrite > 0) parts.push(`W${formatUsageCount(snapshot.totals.cacheWrite)}`);
107
+ parts.push(`cost $${snapshot.totals.cost.total.toFixed(3)}`);
108
+ parts.push(`agents ${snapshot.agents.length}`);
109
+ return `Usage: ${parts.join(" · ")}`;
110
+ }
111
+
112
+ function isWorkflowAgentUsage(value: unknown): value is WorkflowAgentUsage {
113
+ if (!isRecord(value)) return false;
114
+ if (typeof value.label !== "string") return false;
115
+ if (value.phase !== undefined && typeof value.phase !== "string") return false;
116
+ if (value.provider !== undefined && typeof value.provider !== "string") return false;
117
+ if (value.model !== undefined && typeof value.model !== "string") return false;
118
+ if (finiteNumber(value.assistantMessages) === undefined) return false;
119
+ return isWorkflowUsageTotals(value.usage);
120
+ }
121
+
122
+ function isWorkflowUsageTotals(value: unknown): value is WorkflowUsageTotals {
123
+ if (!isRecord(value)) return false;
124
+ if (finiteNumber(value.input) === undefined) return false;
125
+ if (finiteNumber(value.output) === undefined) return false;
126
+ if (finiteNumber(value.cacheRead) === undefined) return false;
127
+ if (finiteNumber(value.cacheWrite) === undefined) return false;
128
+ if (finiteNumber(value.totalTokens) === undefined) return false;
129
+ return isWorkflowUsageCost(value.cost);
130
+ }
131
+
132
+ function isWorkflowUsageCost(value: unknown): value is WorkflowUsageCost {
133
+ if (!isRecord(value)) return false;
134
+ return (
135
+ finiteNumber(value.input) !== undefined &&
136
+ finiteNumber(value.output) !== undefined &&
137
+ finiteNumber(value.cacheRead) !== undefined &&
138
+ finiteNumber(value.cacheWrite) !== undefined &&
139
+ finiteNumber(value.total) !== undefined
140
+ );
141
+ }
142
+
143
+ function parseAssistantUsageMessage(message: unknown): AssistantUsageMessage | undefined {
144
+ if (!isRecord(message)) return undefined;
145
+ if (message.role !== "assistant") return undefined;
146
+ const usage = parseUsageTotals(message.usage);
147
+ if (!usage) return undefined;
148
+ return {
149
+ provider: typeof message.provider === "string" ? message.provider : undefined,
150
+ model: typeof message.model === "string" ? message.model : undefined,
151
+ usage,
152
+ };
153
+ }
154
+
155
+ function parseUsageTotals(value: unknown): WorkflowUsageTotals | undefined {
156
+ if (!isRecord(value)) return undefined;
157
+ const input = finiteNumber(value.input);
158
+ const output = finiteNumber(value.output);
159
+ const cacheRead = finiteNumber(value.cacheRead);
160
+ const cacheWrite = finiteNumber(value.cacheWrite);
161
+ const cost = parseUsageCost(value.cost);
162
+ if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined || !cost) return undefined;
163
+ return {
164
+ input,
165
+ output,
166
+ cacheRead,
167
+ cacheWrite,
168
+ totalTokens: input + output + cacheRead + cacheWrite,
169
+ cost,
170
+ };
171
+ }
172
+
173
+ function parseUsageCost(value: unknown): WorkflowUsageCost | undefined {
174
+ if (!isRecord(value)) return undefined;
175
+ const input = finiteNumber(value.input);
176
+ const output = finiteNumber(value.output);
177
+ const cacheRead = finiteNumber(value.cacheRead);
178
+ const cacheWrite = finiteNumber(value.cacheWrite);
179
+ const total = finiteNumber(value.total);
180
+ if (input === undefined || output === undefined || cacheRead === undefined || cacheWrite === undefined || total === undefined) return undefined;
181
+ return { input, output, cacheRead, cacheWrite, total };
182
+ }
183
+
184
+ function sumTotals(values: readonly WorkflowUsageTotals[]): WorkflowUsageTotals {
185
+ return values.reduce((sum, value) => addTotals(sum, value), emptyWorkflowUsageTotals());
186
+ }
187
+
188
+ function addTotals(left: WorkflowUsageTotals, right: WorkflowUsageTotals): WorkflowUsageTotals {
189
+ return {
190
+ input: left.input + right.input,
191
+ output: left.output + right.output,
192
+ cacheRead: left.cacheRead + right.cacheRead,
193
+ cacheWrite: left.cacheWrite + right.cacheWrite,
194
+ totalTokens: left.totalTokens + right.totalTokens,
195
+ cost: {
196
+ input: left.cost.input + right.cost.input,
197
+ output: left.cost.output + right.cost.output,
198
+ cacheRead: left.cost.cacheRead + right.cost.cacheRead,
199
+ cacheWrite: left.cost.cacheWrite + right.cost.cacheWrite,
200
+ total: left.cost.total + right.cost.total,
201
+ },
202
+ };
203
+ }
204
+
205
+ function cloneTotals(value: WorkflowUsageTotals): WorkflowUsageTotals {
206
+ return {
207
+ input: value.input,
208
+ output: value.output,
209
+ cacheRead: value.cacheRead,
210
+ cacheWrite: value.cacheWrite,
211
+ totalTokens: value.totalTokens,
212
+ cost: { ...value.cost },
213
+ };
214
+ }
215
+
216
+ function formatUsageCount(count: number): string {
217
+ if (count < 1000) return count.toString();
218
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
219
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
220
+ if (count < 10000000) return `${(count / 1000000).toFixed(1)}M`;
221
+ return `${Math.round(count / 1000000)}M`;
222
+ }
223
+
224
+ function finiteNumber(value: unknown): number | undefined {
225
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
226
+ }
227
+
228
+ function isRecord(value: unknown): value is Record<string, unknown> {
229
+ return typeof value === "object" && value !== null;
230
+ }