@guanlin4924/devflow-pi 0.1.10 → 0.1.12

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -1,6 +1,10 @@
1
- import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionUIContext } from "@oh-my-pi/pi-coding-agent";
2
+ import { mkdir, writeFile } from "node:fs/promises";
3
+ import { spawn } from "node:child_process";
4
+ import path from "node:path";
2
5
 
3
- import { aggregateUsageReport, renderUsageReport, type UsageRange } from "./usage-stats.ts";
6
+ import { devflowHome } from "./paths.ts";
7
+ import { aggregateUsageReport, renderUsageHtml, renderUsageReport, type UsageRange } from "./usage-stats.ts";
4
8
 
5
9
  export interface PlatformExtensionOptions {
6
10
  sessionRoot: string;
@@ -8,7 +12,8 @@ export interface PlatformExtensionOptions {
8
12
  projectId: string;
9
13
  }
10
14
 
11
- type UsageInput = UsageRange & { source?: "project" | "omp"; format?: "table" | "json" };
15
+ type UsageFormat = "table" | "json" | "html";
16
+ type UsageInput = UsageRange & { source?: "project" | "omp"; format?: UsageFormat };
12
17
 
13
18
  function commandUsageInput(args: string): UsageInput {
14
19
  const tokens = args.trim().split(/\s+/).filter(Boolean);
@@ -22,9 +27,30 @@ function commandUsageInput(args: string): UsageInput {
22
27
  session: read("--session"),
23
28
  from: read("--from"),
24
29
  to: read("--to"),
30
+ models: read("--model")?.split(",").map(value => value.trim()).filter(Boolean),
31
+ format: (read("--format") as UsageFormat | undefined) ?? "table",
25
32
  };
26
33
  }
27
34
 
35
+ function localDate(offset = "+08:00"): string {
36
+ const formatter = new Intl.DateTimeFormat("en-CA", { timeZone: offset === "+08:00" ? "Asia/Shanghai" : "UTC", year: "numeric", month: "2-digit", day: "2-digit" });
37
+ const parts = Object.fromEntries(formatter.formatToParts(new Date()).filter(part => part.type !== "literal").map(part => [part.type, part.value]));
38
+ return `${parts.year}-${parts.month}-${parts.day}`;
39
+ }
40
+
41
+ function daysAgo(days: number): string {
42
+ const date = new Date();
43
+ date.setDate(date.getDate() - days);
44
+ return date.toISOString().slice(0, 10);
45
+ }
46
+
47
+ function openHtmlReport(file: string): void {
48
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
49
+ const args = process.platform === "win32" ? ["/c", "start", "", file] : [file];
50
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
51
+ child.unref();
52
+ }
53
+
28
54
  export function createPlatformExtension(options: PlatformExtensionOptions) {
29
55
  return function devflowPlatform(pi: ExtensionAPI) {
30
56
  const { z } = pi.zod;
@@ -34,7 +60,8 @@ export function createPlatformExtension(options: PlatformExtensionOptions) {
34
60
  session: z.string().optional(),
35
61
  from: z.string().optional(),
36
62
  to: z.string().optional(),
37
- format: z.enum(["table", "json"]).default("table"),
63
+ models: z.array(z.string()).optional(),
64
+ format: z.enum(["table", "json", "html"]).default("table"),
38
65
  });
39
66
  const usageTool = {
40
67
  label: "Devflow Usage Stats",
@@ -48,13 +75,13 @@ export function createPlatformExtension(options: PlatformExtensionOptions) {
48
75
  const report = await aggregateUsageReport(
49
76
  source === "omp" ? options.ompSessionRoot : options.sessionRoot,
50
77
  source,
51
- { session: input.session, from: input.from, to: input.to },
78
+ { session: input.session, from: input.from, to: input.to, models: input.models },
52
79
  );
53
80
  return {
54
81
  content: [
55
82
  {
56
83
  type: "text" as const,
57
- text: input.format === "json" ? JSON.stringify({ projectId: options.projectId, ...report }, null, 2) : renderUsageReport(report),
84
+ text: input.format === "json" ? JSON.stringify({ projectId: options.projectId, ...report }, null, 2) : input.format === "html" ? renderUsageHtml(report) : renderUsageReport(report),
58
85
  },
59
86
  ],
60
87
  details: { projectId: options.projectId, ...report },
@@ -67,17 +94,65 @@ export function createPlatformExtension(options: PlatformExtensionOptions) {
67
94
  ...usageTool,
68
95
  });
69
96
  }
97
+ async function pickUsageInput(ctx: { ui: ExtensionUIContext; modelRegistry: { getAvailable(): Array<{ provider: string; id: string }> } }): Promise<UsageInput | undefined> {
98
+ const sourceChoice = await ctx.ui.select("使用统计:数据范围", [
99
+ { label: "当前 Devflow 项目", description: "只读取当前项目会话(推荐)" },
100
+ { label: "OMP 历史", description: "读取 ~/.omp/agent/sessions" },
101
+ ]);
102
+ if (!sourceChoice) return undefined;
103
+ const source = sourceChoice === "OMP 历史" ? "omp" : "project" as const;
104
+ const root = source === "omp" ? options.ompSessionRoot : options.sessionRoot;
105
+ const overview = await aggregateUsageReport(root, source);
106
+ const sessionChoice = await ctx.ui.select("使用统计:会话", ["全部会话", ...Object.keys(overview.perSession)]);
107
+ if (!sessionChoice) return undefined;
108
+ // The selector is for filtering a report, so it must list every model
109
+ // configured in OMP — not merely the model names found in old sessions.
110
+ const registeredModels = [...new Set(ctx.modelRegistry.getAvailable().map(model => `${model.provider}/${model.id}`))].sort();
111
+ const modelChoice = await ctx.ui.select("使用统计:模型", ["全部模型", ...registeredModels]);
112
+ if (!modelChoice) return undefined;
113
+ const period = await ctx.ui.select("使用统计:时间范围", ["全部时间", "今天", "最近 7 天", "最近 30 天", "自定义日期"]);
114
+ if (!period) return undefined;
115
+ let from: string | undefined;
116
+ let to: string | undefined;
117
+ if (period === "今天") from = to = localDate();
118
+ if (period === "最近 7 天") { from = daysAgo(6); to = localDate(); }
119
+ if (period === "最近 30 天") { from = daysAgo(29); to = localDate(); }
120
+ if (period === "自定义日期") {
121
+ from = await ctx.ui.input("使用统计:开始日期", "YYYY-MM-DD(留空表示不限)");
122
+ if (from === undefined) return undefined;
123
+ to = await ctx.ui.input("使用统计:结束日期", "YYYY-MM-DD(留空表示不限)");
124
+ if (to === undefined) return undefined;
125
+ }
126
+ const output = await ctx.ui.select("使用统计:输出方式", [
127
+ { label: "终端表格", description: "在当前终端显示" },
128
+ { label: "HTML 报告", description: "生成可在浏览器打开的单文件报告" },
129
+ { label: "JSON", description: "用于自动化处理" },
130
+ ]);
131
+ if (!output) return undefined;
132
+ return { source, session: sessionChoice === "全部会话" ? "all" : sessionChoice, models: modelChoice === "全部模型" ? undefined : [modelChoice], from: from || undefined, to: to || undefined, format: output === "HTML 报告" ? "html" : output === "JSON" ? "json" : "table" };
133
+ }
134
+
135
+ async function renderCommand(input: UsageInput, ctx: { ui: { notify(message: string, type?: "info" | "warning" | "error"): void } }) {
136
+ const source = input.source ?? "project";
137
+ const report = await aggregateUsageReport(source === "omp" ? options.ompSessionRoot : options.sessionRoot, source, input);
138
+ if (input.format === "html") {
139
+ const reportDir = path.join(devflowHome(), "reports", options.projectId);
140
+ const destination = path.join(reportDir, `usage-${new Date().toISOString().replaceAll(":", "-")}.html`);
141
+ await mkdir(reportDir, { recursive: true });
142
+ await writeFile(destination, renderUsageHtml(report), "utf8");
143
+ openHtmlReport(destination);
144
+ ctx.ui.notify(`已生成并在默认浏览器打开 HTML 使用报告:${destination}`, "info");
145
+ return;
146
+ }
147
+ ctx.ui.notify(input.format === "json" ? JSON.stringify({ projectId: options.projectId, ...report }, null, 2).slice(0, 12000) : renderUsageReport(report).slice(0, 12000), "info");
148
+ }
149
+
70
150
  pi.registerCommand("usage-stats", {
71
- description: "Show token/cost usage. Default is this project; pass `omp` or --source omp for explicit OMP history.",
151
+ description: "交互式查看 token/成本:范围、会话、模型、时间与 HTML 报告;也支持 --source/--session/--model/--format 参数。",
72
152
  handler: async (args, ctx) => {
73
- const input = commandUsageInput(args);
74
- const source = input.source ?? "project";
75
- const report = await aggregateUsageReport(
76
- source === "omp" ? options.ompSessionRoot : options.sessionRoot,
77
- source,
78
- { session: input.session, from: input.from, to: input.to },
79
- );
80
- ctx.ui.notify(renderUsageReport(report).slice(0, 12000), "info");
153
+ const input = args.trim() ? commandUsageInput(args) : await pickUsageInput(ctx);
154
+ if (!input) return;
155
+ await renderCommand(input, ctx);
81
156
  },
82
157
  });
83
158
  pi.registerCommand("devflow-usage", {
@@ -19,12 +19,19 @@ export interface UsageReport {
19
19
  totals: UsageTotals;
20
20
  perModel: Record<string, UsageTotals>;
21
21
  perSession: Record<string, UsageTotals>;
22
+ /**
23
+ * Message-linked attribution. A model reply that reads more than one Skill
24
+ * has its usage divided equally between those Skills, so this table always
25
+ * sums to the report total instead of double-counting a reply.
26
+ */
27
+ perSkill: Record<string, UsageTotals>;
22
28
  }
23
29
 
24
30
  export interface UsageRange {
25
31
  from?: string;
26
32
  to?: string;
27
33
  session?: string;
34
+ models?: string[];
28
35
  timezoneOffset?: string;
29
36
  }
30
37
 
@@ -61,6 +68,17 @@ function addUsage(target: UsageTotals, usage: Record<string, unknown>): void {
61
68
  target.assistantMessages += 1;
62
69
  }
63
70
 
71
+ function addUsageShare(target: UsageTotals, usage: Record<string, unknown>, divisor: number): void {
72
+ const share = Math.max(1, divisor);
73
+ target.input += finiteNumber(usage.input) / share;
74
+ target.output += finiteNumber(usage.output) / share;
75
+ target.cacheRead += finiteNumber(usage.cacheRead) / share;
76
+ target.cacheWrite += finiteNumber(usage.cacheWrite) / share;
77
+ target.totalTokens += finiteNumber(usage.totalTokens) / share;
78
+ target.cost += usageCost(usage.cost) / share;
79
+ target.assistantMessages += 1 / share;
80
+ }
81
+
64
82
  function nextDate(value: string): string {
65
83
  const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
66
84
  if (!match) return value;
@@ -107,6 +125,24 @@ function modelName(entry: Record<string, unknown>, current: string): string {
107
125
  return current;
108
126
  }
109
127
 
128
+ function messageSkills(message: Record<string, unknown>): string[] {
129
+ const content = message.content;
130
+ if (!Array.isArray(content)) return [];
131
+ const skills = new Set<string>();
132
+ for (const block of content) {
133
+ if (!block || typeof block !== "object") continue;
134
+ const value = block as Record<string, unknown>;
135
+ const argumentsValue = value.arguments;
136
+ const text = typeof value.text === "string" ? value.text : "";
137
+ const pathValue = argumentsValue && typeof argumentsValue === "object" ? (argumentsValue as Record<string, unknown>).path : undefined;
138
+ const candidates = [text, typeof pathValue === "string" ? pathValue : ""];
139
+ for (const candidate of candidates) {
140
+ for (const match of candidate.matchAll(/skill:\/\/([^/\s?#]+)/g)) skills.add(match[1]);
141
+ }
142
+ }
143
+ return [...skills].sort();
144
+ }
145
+
110
146
  export async function aggregateUsageReport(
111
147
  sessionRoot: string,
112
148
  source: "project" | "omp" = "project",
@@ -115,6 +151,7 @@ export async function aggregateUsageReport(
115
151
  const totals = emptyUsage();
116
152
  const perModel: Record<string, UsageTotals> = {};
117
153
  const perSession: Record<string, UsageTotals> = {};
154
+ const perSkill: Record<string, UsageTotals> = {};
118
155
  const files = await jsonlFiles(sessionRoot);
119
156
  totals.sessionFiles = files.length;
120
157
 
@@ -143,10 +180,14 @@ export async function aggregateUsageReport(
143
180
  const usage = message.usage as Record<string, unknown> | undefined;
144
181
  if (!usage) continue;
145
182
  const model = currentModel;
183
+ if (range.models?.length && !range.models.includes(model)) continue;
146
184
  const modelTotals = (perModel[model] ??= emptyUsage());
147
185
  addUsage(totals, usage);
148
186
  addUsage(sessionTotals, usage);
149
187
  addUsage(modelTotals, usage);
188
+ const skills = messageSkills(message);
189
+ const attribution = skills.length ? skills : ["未归因(此回复未直接读取 Skill)"];
190
+ for (const skill of attribution) addUsageShare((perSkill[skill] ??= emptyUsage()), usage, attribution.length);
150
191
  }
151
192
  }
152
193
 
@@ -162,6 +203,7 @@ export async function aggregateUsageReport(
162
203
  totals,
163
204
  perModel,
164
205
  perSession,
206
+ perSkill,
165
207
  };
166
208
  }
167
209
 
@@ -200,6 +242,23 @@ export function renderUsageReport(report: UsageReport): string {
200
242
  `sessions: ${totals.sessionFiles} | assistant requests: ${totals.assistantMessages} | tokens: ${totals.totalTokens.toLocaleString("en-US")} | cost: $${totals.cost.toFixed(6)}`,
201
243
  ...renderRows("By model", report.perModel),
202
244
  ...renderRows("By session", report.perSession),
203
- "Note: token/cost totals use assistant message usage directly. Tool-call cost allocation is intentionally not estimated.",
245
+ ...renderRows("By Skill(按读取 Skill 的回复归因;多 Skill 回复均分)", report.perSkill),
246
+ "Note: token/cost totals use assistant message usage directly. Skill attribution is message-linked, not provider-level per-Skill billing.",
204
247
  ].join("\n");
205
248
  }
249
+
250
+ function html(value: string): string {
251
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
252
+ }
253
+
254
+ function renderHtmlRows(rows: Record<string, UsageTotals>): string {
255
+ return Object.entries(rows)
256
+ .sort(([, left], [, right]) => right.totalTokens - left.totalTokens || right.cost - left.cost)
257
+ .map(([name, value]) => `<tr><td>${html(name)}</td><td>${value.assistantMessages.toFixed(2)}</td><td>${Math.round(value.totalTokens).toLocaleString("en-US")}</td><td>$${value.cost.toFixed(6)}</td></tr>`)
258
+ .join("") || "<tr><td colspan=\"4\">无数据</td></tr>";
259
+ }
260
+
261
+ export function renderUsageHtml(report: UsageReport): string {
262
+ const table = (title: string, rows: Record<string, UsageTotals>) => `<section><h2>${html(title)}</h2><table><thead><tr><th>名称</th><th>请求</th><th>Token</th><th>成本</th></tr></thead><tbody>${renderHtmlRows(rows)}</tbody></table></section>`;
263
+ return `<!doctype html><html lang="zh-CN"><meta charset="utf-8"><title>Devflow 使用统计</title><style>body{max-width:1100px;margin:32px auto;padding:0 20px;background:#111827;color:#e5e7eb;font:14px system-ui}h1{margin-bottom:4px}.muted{color:#9ca3af}section{margin-top:28px}table{width:100%;border-collapse:collapse;background:#182235}th,td{padding:10px;text-align:left;border-bottom:1px solid #334155}th{color:#93c5fd}td:nth-child(n+2){font-variant-numeric:tabular-nums}</style><h1>Devflow 使用统计</h1><p class="muted">${html(`来源:${report.source} | 会话:${report.filter.session} | ${report.filter.from ?? "全部时间"} 至 ${report.filter.to ?? "全部时间"}(${report.filter.timezoneOffset})`)}</p><section><h2>总计</h2><p>${report.totals.sessionFiles} 个会话 | ${report.totals.assistantMessages} 次请求 | ${report.totals.totalTokens.toLocaleString("en-US")} Token | $${report.totals.cost.toFixed(6)}</p></section>${table("按模型", report.perModel)}${table("按会话", report.perSession)}${table("按 Skill(多 Skill 回复均分)", report.perSkill)}<p class="muted">Skill 成本为回复级归因,不是供应商提供的逐 Skill 账单。</p></html>`;
264
+ }