pi-firecode 0.8.1 → 1.0.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.
- package/README.md +35 -15
- package/activity.ts +92 -0
- package/busy.ts +188 -0
- package/config.example.jsonc +38 -47
- package/config.ts +198 -172
- package/deliver.ts +100 -13
- package/flame.ts +149 -0
- package/format.ts +84 -31
- package/header.ts +208 -82
- package/index.ts +17 -11
- package/master/actions.ts +293 -0
- package/master/activity-list.ts +281 -0
- package/master/event-card.ts +18 -37
- package/master/event-format.ts +71 -40
- package/master/guard.ts +46 -0
- package/master/index.ts +88 -1036
- package/master/list-view.ts +129 -0
- package/master/outbox.ts +188 -0
- package/master/prompts/master.zh.md +17 -12
- package/master/prompts/worker.zh.md +1 -1
- package/master/role.ts +6 -1
- package/master/run.ts +315 -0
- package/master/runtime.ts +259 -0
- package/master/spawn.ts +79 -38
- package/master/state.ts +30 -6
- package/master/worker-view.ts +517 -0
- package/package.json +1 -1
- package/provider/claude-sub.ts +35 -13
- package/provider/openai-native/src/compact-client.ts +2 -14
- package/provider/openai-native/src/options.ts +4 -2
- package/review/advisor.ts +0 -2
- package/review/card.ts +36 -23
- package/review/checkpoint.ts +2 -8
- package/review/evidence.ts +42 -44
- package/review/index.ts +271 -525
- package/review/occupancy.ts +25 -0
- package/review/outcome.ts +31 -7
- package/review/prompt.ts +7 -8
- package/review/prompts/review.en.md +7 -6
- package/review/prompts/review.zh.md +7 -6
- package/review/reviewer.ts +2 -5
- package/review/session.ts +11 -30
- package/review/state.ts +32 -60
- package/review/ui.ts +30 -469
- package/round-recorder.ts +18 -0
- package/session/herdr-display.ts +1 -1
- package/session/presets.ts +130 -87
- package/session/quota.ts +123 -0
- package/session/stats.ts +4 -1
- package/statusbar/index.ts +242 -104
- package/statusbar/render.ts +95 -163
- package/theme.ts +2 -41
- package/tools/actions.ts +53 -0
- package/tools/assistant-view.ts +64 -0
- package/tools/click-anchor.ts +56 -0
- package/tools/group-view.ts +420 -0
- package/tools/grouping.ts +146 -221
- package/tools/host.ts +369 -0
- package/tools/index.ts +108 -105
- package/tools/line.ts +95 -58
- package/tools/machine.ts +78 -0
- package/tools/parts.ts +2 -2
- package/tools/round.ts +95 -0
- package/tools/turn-clock.ts +41 -0
- package/tools/turn-summary.ts +109 -0
- package/truncated-write.ts +18 -0
- package/watcher/card.ts +28 -22
- package/watcher/index.ts +16 -44
- package/watcher/observer.ts +3 -3
- package/watcher/prompts/watch.zh.md +1 -1
- package/watcher/transcript.ts +4 -12
- package/flame-frames.ts +0 -460
- package/review/progress.ts +0 -324
- package/session/bark.ts +0 -157
- package/session/working-flame.ts +0 -116
- package/statusbar/quota-cache.ts +0 -60
- package/statusbar/quota-parse.ts +0 -85
- package/statusbar/quota.ts +0 -203
- package/statusbar/tps.ts +0 -87
package/session/presets.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 预设:一键切换模型、思考等级、工具集与附加指令。
|
|
3
|
-
* 入口有 `--preset`、`/preset [名字]
|
|
3
|
+
* 入口有 `--preset`、`/preset [名字]` 与各预设的 key 快捷键。
|
|
4
4
|
* 预设定义见 firecode/config.jsonc 的 presets 节。
|
|
5
|
+
*
|
|
6
|
+
* 模型、思考档与工具集的事实源是宿主:宿主把它们记在会话里,并在 session_start 之前(切分支时同样)恢复。
|
|
7
|
+
* 这里只持有宿主不知道的两样——预设名与附加指令,且只在“当前模型仍是预设的模型”时成立:手动切走或重开会话时
|
|
8
|
+
* 宿主恢复的不是它,预设即失效(名字与指令一并清掉并记入会话),不另存一份模型去覆盖宿主,也不动宿主的工具集。
|
|
5
9
|
*/
|
|
6
10
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
7
11
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -10,10 +14,11 @@ import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi
|
|
|
10
14
|
import { type Preset, loadConfig } from "../config.js";
|
|
11
15
|
|
|
12
16
|
const CLEAR_ITEM = "(无)";
|
|
17
|
+
const STATE_ENTRY = "preset-state";
|
|
13
18
|
const INSTRUCTIONS_PREVIEW_CHARS = 30;
|
|
14
19
|
const SELECTOR_MAX_ROWS = 10;
|
|
15
20
|
|
|
16
|
-
/** 会话可用的等级比预设可配置的多(含 max
|
|
21
|
+
/** 会话可用的等级比预设可配置的多(含 max),快照要按前者存。重开会话后预设之前的模型已不可知,model 为空表示不动模型。 */
|
|
17
22
|
type OriginalState = {
|
|
18
23
|
model: Model<Api> | undefined;
|
|
19
24
|
thinkingLevel: ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
@@ -23,10 +28,20 @@ type OriginalState = {
|
|
|
23
28
|
const title = (text: string): string =>
|
|
24
29
|
text ? `${text[0].toUpperCase()}${text.slice(1)}` : text;
|
|
25
30
|
|
|
31
|
+
/** 模型原子在配置层已校验为 provider/model;调 Pi 接口时才拆成两段。 */
|
|
32
|
+
function splitModel(id: string): [provider: string, model: string] {
|
|
33
|
+
const slash = id.indexOf("/");
|
|
34
|
+
return [id.slice(0, slash), id.slice(slash + 1)];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 预设对模型的要求:当前模型就是预设的模型,或预设根本不管模型。 */
|
|
38
|
+
function holds(preset: Preset, model: Model<Api> | undefined): boolean {
|
|
39
|
+
return !preset.model || (!!model && `${model.provider}/${model.id}` === preset.model.model);
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
function describe(preset: Preset): string {
|
|
27
43
|
const parts: string[] = [];
|
|
28
|
-
if (preset.
|
|
29
|
-
if (preset.thinkingLevel) parts.push(`thinking:${preset.thinkingLevel}`);
|
|
44
|
+
if (preset.model) parts.push(preset.model.model, `thinking:${preset.model.thinking}`);
|
|
30
45
|
if (preset.tools) parts.push(`tools:${preset.tools.join(",")}`);
|
|
31
46
|
if (preset.instructions) {
|
|
32
47
|
const preview =
|
|
@@ -43,79 +58,100 @@ export function registerPresets(pi: ExtensionAPI): void {
|
|
|
43
58
|
let activeName: string | undefined;
|
|
44
59
|
let activePreset: Preset | undefined;
|
|
45
60
|
let originalState: OriginalState | undefined;
|
|
61
|
+
/** 本模块自己切模型期间的 model_select 不算用户切走。 */
|
|
62
|
+
let applying = false;
|
|
46
63
|
|
|
47
64
|
pi.registerFlag("preset", {
|
|
48
65
|
description: "要启用的预设名",
|
|
49
66
|
type: "string",
|
|
50
67
|
});
|
|
51
68
|
|
|
69
|
+
/**
|
|
70
|
+
* 预设名只在它带来边框看不见的改动(工具集、附加指令)时发布:模型与思考档已显示在输入框下边框,
|
|
71
|
+
* 只改这两样的预设再报名字就是同一信息说两遍。
|
|
72
|
+
*/
|
|
52
73
|
const updateStatus = (ctx: ExtensionContext) =>
|
|
53
74
|
ctx.ui.setStatus(
|
|
54
75
|
"preset",
|
|
55
|
-
activeName
|
|
76
|
+
activeName && (activePreset?.tools?.length || activePreset?.instructions)
|
|
77
|
+
? ctx.ui.theme.fg("accent", title(activeName))
|
|
78
|
+
: undefined,
|
|
56
79
|
);
|
|
57
80
|
|
|
58
81
|
const noPresetsHint = (ctx: ExtensionContext) =>
|
|
59
82
|
ctx.ui.notify("未定义任何预设。在 firecode/config.jsonc 的 presets 里添加。", "warning");
|
|
60
83
|
|
|
84
|
+
/** 预设状态写进当前分支(与宿主的模型记录同一棵树),null 表示没有预设;重开会话按它恢复。 */
|
|
85
|
+
function setActive(name: string | undefined, ctx: ExtensionContext): void {
|
|
86
|
+
activeName = name;
|
|
87
|
+
activePreset = name === undefined ? undefined : presets[name];
|
|
88
|
+
if (name === undefined) originalState = undefined;
|
|
89
|
+
pi.appendEntry(STATE_ENTRY, { name: name ?? null });
|
|
90
|
+
updateStatus(ctx);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function applyTools(name: string, preset: Preset, ctx: ExtensionContext): void {
|
|
94
|
+
if (!preset.tools?.length) return;
|
|
95
|
+
const known = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
96
|
+
const valid = preset.tools.filter((tool) => known.has(tool));
|
|
97
|
+
const unknown = preset.tools.filter((tool) => !known.has(tool));
|
|
98
|
+
if (unknown.length)
|
|
99
|
+
ctx.ui.notify(`预设「${name}」含未知工具:${unknown.join("、")}`, "warning");
|
|
100
|
+
if (valid.length) pi.setActiveTools(valid);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 先套模型;模型套用失败就整套不套(不动工具、不设预设名),只提示原因。 */
|
|
61
104
|
async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<void> {
|
|
62
105
|
// 首次应用前留一份快照,用于恢复默认。
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (preset.provider && preset.model) {
|
|
72
|
-
const model = ctx.modelRegistry.find(preset.provider, preset.model);
|
|
73
|
-
if (!model) {
|
|
74
|
-
ctx.ui.notify(
|
|
75
|
-
`Preset "${name}": Model ${preset.provider}/${preset.model} not found`,
|
|
76
|
-
"warning",
|
|
77
|
-
);
|
|
78
|
-
} else if (!(await pi.setModel(model))) {
|
|
79
|
-
ctx.ui.notify(
|
|
80
|
-
`Preset "${name}": No API key for ${preset.provider}/${preset.model}`,
|
|
81
|
-
"warning",
|
|
82
|
-
);
|
|
83
|
-
}
|
|
106
|
+
const snapshot = activeName === undefined
|
|
107
|
+
? { model: ctx.model, thinkingLevel: pi.getThinkingLevel(), tools: pi.getActiveTools() }
|
|
108
|
+
: originalState;
|
|
109
|
+
applying = true;
|
|
110
|
+
const failure = await applyModel(preset, ctx).finally(() => { applying = false; });
|
|
111
|
+
if (failure) {
|
|
112
|
+
ctx.ui.notify(`预设「${name}」未切换:${failure}`, "warning");
|
|
113
|
+
return;
|
|
84
114
|
}
|
|
115
|
+
originalState = snapshot;
|
|
116
|
+
applyTools(name, preset, ctx);
|
|
117
|
+
setActive(name, ctx);
|
|
118
|
+
ctx.ui.notify(`已切换预设「${name}」`, "info");
|
|
119
|
+
}
|
|
85
120
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (preset.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
121
|
+
/** 返回失败原因;成功或预设不管模型时为 undefined。 */
|
|
122
|
+
async function applyModel(preset: Preset, ctx: ExtensionContext): Promise<string | undefined> {
|
|
123
|
+
if (!preset.model) return undefined;
|
|
124
|
+
const [provider, id] = splitModel(preset.model.model);
|
|
125
|
+
const model = ctx.modelRegistry.find(provider, id);
|
|
126
|
+
if (!model) return `找不到模型 ${preset.model.model}`;
|
|
127
|
+
if (!(await pi.setModel(model))) return `模型 ${preset.model.model} 没有可用凭据`;
|
|
128
|
+
pi.setThinkingLevel(preset.model.thinking);
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
96
131
|
|
|
97
|
-
|
|
98
|
-
|
|
132
|
+
/** 当前模型已不是预设的:预设失效。 */
|
|
133
|
+
function dropIfDiverged(ctx: ExtensionContext): void {
|
|
134
|
+
if (!activeName || !activePreset || holds(activePreset, ctx.model)) return;
|
|
135
|
+
const name = activeName;
|
|
136
|
+
setActive(undefined, ctx);
|
|
137
|
+
ctx.ui.notify(`模型已不是预设「${name}」的,预设已失效`, "info");
|
|
99
138
|
}
|
|
100
139
|
|
|
101
140
|
async function activate(name: string, ctx: ExtensionContext): Promise<void> {
|
|
102
141
|
const preset = presets[name];
|
|
103
142
|
if (!preset) return;
|
|
104
143
|
await applyPreset(name, preset, ctx);
|
|
105
|
-
ctx.ui.notify(`已切换预设「${name}」`, "info");
|
|
106
|
-
updateStatus(ctx);
|
|
107
144
|
}
|
|
108
145
|
|
|
109
146
|
async function clearPreset(ctx: ExtensionContext): Promise<void> {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
if (
|
|
114
|
-
pi.setThinkingLevel(
|
|
115
|
-
pi.setActiveTools(
|
|
147
|
+
const original = originalState;
|
|
148
|
+
setActive(undefined, ctx);
|
|
149
|
+
if (original) {
|
|
150
|
+
if (original.model) await pi.setModel(original.model);
|
|
151
|
+
pi.setThinkingLevel(original.thinkingLevel);
|
|
152
|
+
pi.setActiveTools(original.tools);
|
|
116
153
|
}
|
|
117
154
|
ctx.ui.notify("预设已清除,恢复默认", "info");
|
|
118
|
-
updateStatus(ctx);
|
|
119
155
|
}
|
|
120
156
|
|
|
121
157
|
async function showSelector(ctx: ExtensionContext): Promise<void> {
|
|
@@ -169,25 +205,7 @@ export function registerPresets(pi: ExtensionAPI): void {
|
|
|
169
205
|
else await activate(choice, ctx);
|
|
170
206
|
}
|
|
171
207
|
|
|
172
|
-
|
|
173
|
-
const names = Object.keys(presets);
|
|
174
|
-
if (names.length === 0) {
|
|
175
|
-
noPresetsHint(ctx);
|
|
176
|
-
return;
|
|
177
|
-
}
|
|
178
|
-
const cycle = [CLEAR_ITEM, ...names];
|
|
179
|
-
const current = cycle.indexOf(activeName ?? CLEAR_ITEM);
|
|
180
|
-
const next = cycle[current === -1 ? 0 : (current + 1) % cycle.length];
|
|
181
|
-
if (next === CLEAR_ITEM) await clearPreset(ctx);
|
|
182
|
-
else await activate(next, ctx);
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
const { keys, presets: configured } = loadConfig().config;
|
|
186
|
-
|
|
187
|
-
pi.registerShortcut(keys.cyclePreset as never, {
|
|
188
|
-
description: "轮切预设",
|
|
189
|
-
handler: (ctx) => cyclePreset(ctx),
|
|
190
|
-
});
|
|
208
|
+
const { presets: configured } = loadConfig().config;
|
|
191
209
|
|
|
192
210
|
for (const [name, preset] of Object.entries(configured)) {
|
|
193
211
|
if (!preset.key) continue;
|
|
@@ -205,6 +223,11 @@ export function registerPresets(pi: ExtensionAPI): void {
|
|
|
205
223
|
await showSelector(ctx);
|
|
206
224
|
return;
|
|
207
225
|
}
|
|
226
|
+
// 与选择器同一个“无”项:清除当前预设。
|
|
227
|
+
if (name === CLEAR_ITEM) {
|
|
228
|
+
await clearPreset(ctx);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
208
231
|
if (!presets[name]) {
|
|
209
232
|
const available = Object.keys(presets).join(", ") || "(none defined)";
|
|
210
233
|
ctx.ui.notify(`未知预设「${name}」,可用:${available}`, "error");
|
|
@@ -214,12 +237,32 @@ export function registerPresets(pi: ExtensionAPI): void {
|
|
|
214
237
|
},
|
|
215
238
|
});
|
|
216
239
|
|
|
217
|
-
pi.on("
|
|
240
|
+
pi.on("model_select", (_event, ctx) => {
|
|
241
|
+
if (!applying) dropIfDiverged(ctx);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
pi.on("before_agent_start", async (event, ctx) => {
|
|
245
|
+
dropIfDiverged(ctx);
|
|
218
246
|
if (!activePreset?.instructions) return;
|
|
219
247
|
return { systemPrompt: `${event.systemPrompt}\n\n${activePreset.instructions}` };
|
|
220
248
|
});
|
|
221
249
|
|
|
250
|
+
/** 同进程内新开/切换会话或切分支复用本模块实例:上一处的预设不带进来。 */
|
|
251
|
+
const reset = () => {
|
|
252
|
+
activeName = undefined;
|
|
253
|
+
activePreset = undefined;
|
|
254
|
+
originalState = undefined;
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// 切到另一分支:宿主按该分支恢复模型,预设也按该分支的记录重新判定。
|
|
258
|
+
pi.on("session_tree", (_event, ctx) => {
|
|
259
|
+
reset();
|
|
260
|
+
restore(ctx);
|
|
261
|
+
updateStatus(ctx);
|
|
262
|
+
});
|
|
263
|
+
|
|
222
264
|
pi.on("session_start", async (_event, ctx) => {
|
|
265
|
+
reset();
|
|
223
266
|
// 配置问题由 index.ts 统一提示,这里只取预设。
|
|
224
267
|
presets = loadConfig().config.presets;
|
|
225
268
|
|
|
@@ -227,31 +270,31 @@ export function registerPresets(pi: ExtensionAPI): void {
|
|
|
227
270
|
if (typeof flag === "string" && flag) {
|
|
228
271
|
if (presets[flag]) {
|
|
229
272
|
await applyPreset(flag, presets[flag], ctx);
|
|
230
|
-
ctx.ui.notify(`已切换预设「${flag}」`, "info");
|
|
231
273
|
} else {
|
|
232
274
|
const available = Object.keys(presets).join(", ") || "(none defined)";
|
|
233
275
|
ctx.ui.notify(`未知预设「${flag}」,可用:${available}`, "warning");
|
|
234
276
|
}
|
|
235
|
-
} else
|
|
236
|
-
// 恢复上次会话的预设:只认名字,不重放模型与工具切换。
|
|
237
|
-
const restored = ctx.sessionManager
|
|
238
|
-
.getEntries()
|
|
239
|
-
.filter(
|
|
240
|
-
(entry: { type: string; customType?: string }) =>
|
|
241
|
-
entry.type === "custom" && entry.customType === "preset-state",
|
|
242
|
-
)
|
|
243
|
-
.pop() as { data?: { name: string } } | undefined;
|
|
244
|
-
const name = restored?.data?.name;
|
|
245
|
-
if (name && presets[name]) {
|
|
246
|
-
activeName = name;
|
|
247
|
-
activePreset = presets[name];
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
|
|
277
|
+
} else restore(ctx);
|
|
251
278
|
updateStatus(ctx);
|
|
252
279
|
});
|
|
253
280
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
281
|
+
/** 按当前分支最后一条预设记录恢复:宿主已恢复模型与工具集,模型仍是预设的才算生效。 */
|
|
282
|
+
function restore(ctx: ExtensionContext): void {
|
|
283
|
+
const record = ctx.sessionManager
|
|
284
|
+
.getBranch()
|
|
285
|
+
.filter((entry: { type: string; customType?: string }) => entry.type === "custom" && entry.customType === STATE_ENTRY)
|
|
286
|
+
.pop() as { data?: { name: string | null } } | undefined;
|
|
287
|
+
const name = record?.data?.name;
|
|
288
|
+
if (!name) return;
|
|
289
|
+
const preset = presets[name];
|
|
290
|
+
if (!preset || !holds(preset, ctx.model)) {
|
|
291
|
+
setActive(undefined, ctx);
|
|
292
|
+
ctx.ui.notify(preset ? `模型已不是预设「${name}」的,预设已失效` : `预设「${name}」已不在配置里,已清除`, "info");
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
activeName = name;
|
|
296
|
+
activePreset = preset;
|
|
297
|
+
// 应用预设之前的状态不在会话记录里:清除时不动模型,思考档与工具集维持宿主恢复的。
|
|
298
|
+
originalState = { model: undefined, thinkingLevel: pi.getThinkingLevel(), tools: pi.getActiveTools() };
|
|
299
|
+
}
|
|
257
300
|
}
|
package/session/quota.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/** /quota:按需查询 Pi OAuth 订阅额度,只投界面通知,不参与会话或模型调用。 */
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
|
|
4
|
+
const REQUEST_TIMEOUT_MS = 3_000;
|
|
5
|
+
const FABLE_WINDOW = "Fable 本周";
|
|
6
|
+
const HOUR_SECONDS = 3_600;
|
|
7
|
+
const DAY_SECONDS = 86_400;
|
|
8
|
+
const WEEK_SECONDS = 7 * DAY_SECONDS;
|
|
9
|
+
const PROVIDERS = [
|
|
10
|
+
{ id: "openai-codex", name: "Codex", url: "https://chatgpt.com/backend-api/wham/usage", parse: codexWindows },
|
|
11
|
+
{ id: "anthropic", name: "Claude", url: "https://api.anthropic.com/api/oauth/usage", parse: claudeWindows },
|
|
12
|
+
] as const;
|
|
13
|
+
type QuotaProvider = (typeof PROVIDERS)[number];
|
|
14
|
+
type QuotaWindow = { label: string; remaining: number };
|
|
15
|
+
const record = (value: unknown): Record<string, unknown> =>
|
|
16
|
+
value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
17
|
+
|
|
18
|
+
function changedFormat(): never {
|
|
19
|
+
throw new Error("额度响应格式已变化");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function quotaWindow(label: string, used: unknown): QuotaWindow {
|
|
23
|
+
if (typeof used !== "number" || !Number.isFinite(used) || used < 0) return changedFormat();
|
|
24
|
+
return { label, remaining: Math.max(0, Math.round(100 - used)) };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function codexWindows(value: unknown): QuotaWindow[] {
|
|
28
|
+
const rate = record(record(value).rate_limit);
|
|
29
|
+
const windows: QuotaWindow[] = [];
|
|
30
|
+
for (const value of [rate.primary_window, rate.secondary_window]) {
|
|
31
|
+
if (value == null) continue;
|
|
32
|
+
const entry = record(value);
|
|
33
|
+
const seconds = entry.limit_window_seconds;
|
|
34
|
+
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0) return changedFormat();
|
|
35
|
+
const label = seconds === WEEK_SECONDS ? "本周"
|
|
36
|
+
: seconds % DAY_SECONDS === 0 ? `${seconds / DAY_SECONDS}天` : `${seconds / HOUR_SECONDS}小时`;
|
|
37
|
+
windows.push(quotaWindow(label, entry.used_percent));
|
|
38
|
+
}
|
|
39
|
+
return windows;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function claudeWindows(value: unknown): QuotaWindow[] {
|
|
43
|
+
const limits = record(value).limits;
|
|
44
|
+
if (!Array.isArray(limits)) return changedFormat();
|
|
45
|
+
return limits.flatMap((value): QuotaWindow[] => {
|
|
46
|
+
const entry = record(value);
|
|
47
|
+
if (entry.kind === "session") return [quotaWindow("5小时", entry.percent)];
|
|
48
|
+
if (entry.kind === "weekly_all") return [quotaWindow("本周", entry.percent)];
|
|
49
|
+
const model = record(record(entry.scope).model).display_name;
|
|
50
|
+
if (entry.kind === "weekly_scoped" && model === "Fable") return [quotaWindow(FABLE_WINDOW, entry.percent)];
|
|
51
|
+
return [];
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function codexAccountId(token: string): string {
|
|
56
|
+
try {
|
|
57
|
+
const claims = record(JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8")));
|
|
58
|
+
const id = record(claims["https://api.openai.com/auth"]).chatgpt_account_id;
|
|
59
|
+
if (typeof id === "string" && id) return id;
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error("Codex 登录凭据无效");
|
|
62
|
+
}
|
|
63
|
+
throw new Error("Codex 登录凭据缺少账户信息");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function query(
|
|
67
|
+
provider: QuotaProvider,
|
|
68
|
+
registry: ExtensionContext["modelRegistry"],
|
|
69
|
+
signal: AbortSignal,
|
|
70
|
+
fetcher: typeof fetch,
|
|
71
|
+
): Promise<string> {
|
|
72
|
+
try {
|
|
73
|
+
const model = registry.getAll().find((model) => model.provider === provider.id);
|
|
74
|
+
if (!model || !registry.isUsingOAuth(model)) return `${provider.name}:未通过 Pi OAuth 登录`;
|
|
75
|
+
const resolution = await registry.getProviderAuth(provider.id);
|
|
76
|
+
const token = resolution?.auth.apiKey;
|
|
77
|
+
if (!token) throw new Error("未取得 OAuth 登录凭据");
|
|
78
|
+
const headers = new Headers({ Authorization: `Bearer ${token}`, Accept: "application/json" });
|
|
79
|
+
if (provider.id === "anthropic") headers.set("anthropic-beta", "oauth-2025-04-20");
|
|
80
|
+
else headers.set("ChatGPT-Account-Id", codexAccountId(token));
|
|
81
|
+
const response = await fetcher(provider.url, {
|
|
82
|
+
headers, signal: AbortSignal.any([signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
85
|
+
const windows = provider.parse(await response.json());
|
|
86
|
+
if (!windows.length) return changedFormat();
|
|
87
|
+
const parts = windows.map(({ label, remaining }) => `${label}剩余 ${remaining}%`);
|
|
88
|
+
if (provider.id === "anthropic" && !windows.some(({ label }) => label === FABLE_WINDOW))
|
|
89
|
+
parts.push(`${FABLE_WINDOW}:未提供独立额度`);
|
|
90
|
+
return `${provider.name}:${parts.join(" | ")}`;
|
|
91
|
+
} catch (error) {
|
|
92
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
93
|
+
return `${provider.name}:查询失败(${reason})`;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function registerQuota(pi: ExtensionAPI, fetcher: typeof fetch = fetch): void {
|
|
98
|
+
let inFlight: AbortController | undefined;
|
|
99
|
+
pi.registerCommand("quota", {
|
|
100
|
+
description: "查询 Codex、Claude 和 Fable 的订阅剩余额度,不打断当前任务",
|
|
101
|
+
handler: async (args, ctx) => {
|
|
102
|
+
if (args.trim()) return ctx.ui.notify("用法:/quota", "warning");
|
|
103
|
+
if (inFlight) return ctx.ui.notify("额度正在查询,请稍候", "info");
|
|
104
|
+
// 在首个 await 前占住唯一请求槽,重复命令不会叠加抓取。
|
|
105
|
+
const owner = new AbortController();
|
|
106
|
+
inFlight = owner;
|
|
107
|
+
const { ui, modelRegistry } = ctx;
|
|
108
|
+
ui.notify("正在查询订阅额度…", "info");
|
|
109
|
+
try {
|
|
110
|
+
const results = await Promise.all(PROVIDERS.map((provider) => query(provider, modelRegistry, owner.signal, fetcher)));
|
|
111
|
+
if (inFlight !== owner) return;
|
|
112
|
+
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
|
|
113
|
+
ui.notify(`订阅剩余额度 · 查询于 ${time}\n${results.join("\n")}`, "info");
|
|
114
|
+
} finally {
|
|
115
|
+
if (inFlight === owner) inFlight = undefined;
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
pi.on("session_shutdown", () => {
|
|
120
|
+
inFlight?.abort();
|
|
121
|
+
inFlight = undefined;
|
|
122
|
+
});
|
|
123
|
+
}
|
package/session/stats.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|
|
8
8
|
import { DynamicBorder, getAgentDir, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { type AutocompleteItem, Container, Markdown, Text, matchesKey } from "@earendil-works/pi-tui";
|
|
10
10
|
|
|
11
|
+
import { registerQuota } from "./quota.js";
|
|
12
|
+
|
|
11
13
|
const DEFAULT_DAYS = 30;
|
|
12
14
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
15
|
|
|
@@ -277,7 +279,7 @@ async function show(markdown: string, ctx: ExtensionCommandContext): Promise<voi
|
|
|
277
279
|
}
|
|
278
280
|
|
|
279
281
|
/** 支持 `/tokens 7`、`/tokens --days 7`、`/tokens 0`(全部)。 */
|
|
280
|
-
|
|
282
|
+
function parseDays(args: string | undefined): number {
|
|
281
283
|
const text = (args ?? "").trim();
|
|
282
284
|
if (!text) return DEFAULT_DAYS;
|
|
283
285
|
|
|
@@ -293,6 +295,7 @@ const dayCompletions: AutocompleteItem[] = [
|
|
|
293
295
|
];
|
|
294
296
|
|
|
295
297
|
export function registerStats(pi: ExtensionAPI): void {
|
|
298
|
+
registerQuota(pi);
|
|
296
299
|
pi.registerCommand("tokens", {
|
|
297
300
|
description: "统计会话 token 与成本,参数为天数:默认 30,0 表示全部",
|
|
298
301
|
getArgumentCompletions: (prefix: string) => {
|