@guanlin4924/devflow-pi 0.1.10 → 0.1.11
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 +1 -1
- package/src/platform-extension.ts +78 -15
- package/src/usage-stats.ts +60 -1
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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 path from "node:path";
|
|
2
4
|
|
|
3
|
-
import {
|
|
5
|
+
import { devflowHome } from "./paths.ts";
|
|
6
|
+
import { aggregateUsageReport, renderUsageHtml, renderUsageReport, type UsageRange } from "./usage-stats.ts";
|
|
4
7
|
|
|
5
8
|
export interface PlatformExtensionOptions {
|
|
6
9
|
sessionRoot: string;
|
|
@@ -8,7 +11,8 @@ export interface PlatformExtensionOptions {
|
|
|
8
11
|
projectId: string;
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
type
|
|
14
|
+
type UsageFormat = "table" | "json" | "html";
|
|
15
|
+
type UsageInput = UsageRange & { source?: "project" | "omp"; format?: UsageFormat };
|
|
12
16
|
|
|
13
17
|
function commandUsageInput(args: string): UsageInput {
|
|
14
18
|
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
@@ -22,9 +26,23 @@ function commandUsageInput(args: string): UsageInput {
|
|
|
22
26
|
session: read("--session"),
|
|
23
27
|
from: read("--from"),
|
|
24
28
|
to: read("--to"),
|
|
29
|
+
models: read("--model")?.split(",").map(value => value.trim()).filter(Boolean),
|
|
30
|
+
format: (read("--format") as UsageFormat | undefined) ?? "table",
|
|
25
31
|
};
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
function localDate(offset = "+08:00"): string {
|
|
35
|
+
const formatter = new Intl.DateTimeFormat("en-CA", { timeZone: offset === "+08:00" ? "Asia/Shanghai" : "UTC", year: "numeric", month: "2-digit", day: "2-digit" });
|
|
36
|
+
const parts = Object.fromEntries(formatter.formatToParts(new Date()).filter(part => part.type !== "literal").map(part => [part.type, part.value]));
|
|
37
|
+
return `${parts.year}-${parts.month}-${parts.day}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function daysAgo(days: number): string {
|
|
41
|
+
const date = new Date();
|
|
42
|
+
date.setDate(date.getDate() - days);
|
|
43
|
+
return date.toISOString().slice(0, 10);
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
export function createPlatformExtension(options: PlatformExtensionOptions) {
|
|
29
47
|
return function devflowPlatform(pi: ExtensionAPI) {
|
|
30
48
|
const { z } = pi.zod;
|
|
@@ -34,7 +52,8 @@ export function createPlatformExtension(options: PlatformExtensionOptions) {
|
|
|
34
52
|
session: z.string().optional(),
|
|
35
53
|
from: z.string().optional(),
|
|
36
54
|
to: z.string().optional(),
|
|
37
|
-
|
|
55
|
+
models: z.array(z.string()).optional(),
|
|
56
|
+
format: z.enum(["table", "json", "html"]).default("table"),
|
|
38
57
|
});
|
|
39
58
|
const usageTool = {
|
|
40
59
|
label: "Devflow Usage Stats",
|
|
@@ -48,13 +67,13 @@ export function createPlatformExtension(options: PlatformExtensionOptions) {
|
|
|
48
67
|
const report = await aggregateUsageReport(
|
|
49
68
|
source === "omp" ? options.ompSessionRoot : options.sessionRoot,
|
|
50
69
|
source,
|
|
51
|
-
{ session: input.session, from: input.from, to: input.to },
|
|
70
|
+
{ session: input.session, from: input.from, to: input.to, models: input.models },
|
|
52
71
|
);
|
|
53
72
|
return {
|
|
54
73
|
content: [
|
|
55
74
|
{
|
|
56
75
|
type: "text" as const,
|
|
57
|
-
text: input.format === "json" ? JSON.stringify({ projectId: options.projectId, ...report }, null, 2) : renderUsageReport(report),
|
|
76
|
+
text: input.format === "json" ? JSON.stringify({ projectId: options.projectId, ...report }, null, 2) : input.format === "html" ? renderUsageHtml(report) : renderUsageReport(report),
|
|
58
77
|
},
|
|
59
78
|
],
|
|
60
79
|
details: { projectId: options.projectId, ...report },
|
|
@@ -67,17 +86,61 @@ export function createPlatformExtension(options: PlatformExtensionOptions) {
|
|
|
67
86
|
...usageTool,
|
|
68
87
|
});
|
|
69
88
|
}
|
|
89
|
+
async function pickUsageInput(ctx: { ui: ExtensionUIContext }): Promise<UsageInput | undefined> {
|
|
90
|
+
const sourceChoice = await ctx.ui.select("使用统计:数据范围", [
|
|
91
|
+
{ label: "当前 Devflow 项目", description: "只读取当前项目会话(推荐)" },
|
|
92
|
+
{ label: "OMP 历史", description: "读取 ~/.omp/agent/sessions" },
|
|
93
|
+
]);
|
|
94
|
+
if (!sourceChoice) return undefined;
|
|
95
|
+
const source = sourceChoice === "OMP 历史" ? "omp" : "project" as const;
|
|
96
|
+
const root = source === "omp" ? options.ompSessionRoot : options.sessionRoot;
|
|
97
|
+
const overview = await aggregateUsageReport(root, source);
|
|
98
|
+
const sessionChoice = await ctx.ui.select("使用统计:会话", ["全部会话", ...Object.keys(overview.perSession)]);
|
|
99
|
+
if (!sessionChoice) return undefined;
|
|
100
|
+
const modelChoice = await ctx.ui.select("使用统计:模型", ["全部模型", ...Object.keys(overview.perModel)]);
|
|
101
|
+
if (!modelChoice) return undefined;
|
|
102
|
+
const period = await ctx.ui.select("使用统计:时间范围", ["全部时间", "今天", "最近 7 天", "最近 30 天", "自定义日期"]);
|
|
103
|
+
if (!period) return undefined;
|
|
104
|
+
let from: string | undefined;
|
|
105
|
+
let to: string | undefined;
|
|
106
|
+
if (period === "今天") from = to = localDate();
|
|
107
|
+
if (period === "最近 7 天") { from = daysAgo(6); to = localDate(); }
|
|
108
|
+
if (period === "最近 30 天") { from = daysAgo(29); to = localDate(); }
|
|
109
|
+
if (period === "自定义日期") {
|
|
110
|
+
from = await ctx.ui.input("使用统计:开始日期", "YYYY-MM-DD(留空表示不限)");
|
|
111
|
+
if (from === undefined) return undefined;
|
|
112
|
+
to = await ctx.ui.input("使用统计:结束日期", "YYYY-MM-DD(留空表示不限)");
|
|
113
|
+
if (to === undefined) return undefined;
|
|
114
|
+
}
|
|
115
|
+
const output = await ctx.ui.select("使用统计:输出方式", [
|
|
116
|
+
{ label: "终端表格", description: "在当前终端显示" },
|
|
117
|
+
{ label: "HTML 报告", description: "生成可在浏览器打开的单文件报告" },
|
|
118
|
+
{ label: "JSON", description: "用于自动化处理" },
|
|
119
|
+
]);
|
|
120
|
+
if (!output) return undefined;
|
|
121
|
+
return { source, session: sessionChoice === "全部会话" ? "all" : sessionChoice, models: modelChoice === "全部模型" ? undefined : [modelChoice], from: from || undefined, to: to || undefined, format: output === "HTML 报告" ? "html" : output === "JSON" ? "json" : "table" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function renderCommand(input: UsageInput, ctx: { ui: { notify(message: string, type?: "info" | "warning" | "error"): void } }) {
|
|
125
|
+
const source = input.source ?? "project";
|
|
126
|
+
const report = await aggregateUsageReport(source === "omp" ? options.ompSessionRoot : options.sessionRoot, source, input);
|
|
127
|
+
if (input.format === "html") {
|
|
128
|
+
const reportDir = path.join(devflowHome(), "reports", options.projectId);
|
|
129
|
+
const destination = path.join(reportDir, `usage-${new Date().toISOString().replaceAll(":", "-")}.html`);
|
|
130
|
+
await mkdir(reportDir, { recursive: true });
|
|
131
|
+
await writeFile(destination, renderUsageHtml(report), "utf8");
|
|
132
|
+
ctx.ui.notify(`已生成 HTML 使用报告:${destination}`, "info");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
ctx.ui.notify(input.format === "json" ? JSON.stringify({ projectId: options.projectId, ...report }, null, 2).slice(0, 12000) : renderUsageReport(report).slice(0, 12000), "info");
|
|
136
|
+
}
|
|
137
|
+
|
|
70
138
|
pi.registerCommand("usage-stats", {
|
|
71
|
-
description: "
|
|
139
|
+
description: "交互式查看 token/成本:范围、会话、模型、时间与 HTML 报告;也支持 --source/--session/--model/--format 参数。",
|
|
72
140
|
handler: async (args, ctx) => {
|
|
73
|
-
const input = commandUsageInput(args);
|
|
74
|
-
|
|
75
|
-
|
|
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");
|
|
141
|
+
const input = args.trim() ? commandUsageInput(args) : await pickUsageInput(ctx);
|
|
142
|
+
if (!input) return;
|
|
143
|
+
await renderCommand(input, ctx);
|
|
81
144
|
},
|
|
82
145
|
});
|
|
83
146
|
pi.registerCommand("devflow-usage", {
|
package/src/usage-stats.ts
CHANGED
|
@@ -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
|
-
"
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
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
|
+
}
|