pi-firecode 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.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/config.example.jsonc +100 -0
- package/config.ts +474 -0
- package/deliver.ts +32 -0
- package/flame-frames.ts +460 -0
- package/format.ts +80 -0
- package/header.ts +92 -0
- package/herdr-client.ts +60 -0
- package/index.ts +62 -0
- package/jsonc.ts +34 -0
- package/master/event-card.ts +106 -0
- package/master/event-format.ts +52 -0
- package/master/index.ts +1200 -0
- package/master/prompt.ts +26 -0
- package/master/prompts/master.zh.md +17 -0
- package/master/prompts/worker.zh.md +1 -0
- package/master/role.ts +13 -0
- package/master/spawn.ts +193 -0
- package/master/state.ts +201 -0
- package/package.json +59 -0
- package/provider/claude-sub.ts +129 -0
- package/provider/openai-native/index.ts +8 -0
- package/provider/openai-native/src/compact-client.ts +362 -0
- package/provider/openai-native/src/config.ts +297 -0
- package/provider/openai-native/src/extension.ts +93 -0
- package/provider/openai-native/src/native-compaction.ts +151 -0
- package/provider/openai-native/src/native-details.ts +157 -0
- package/provider/openai-native/src/native-replay.ts +253 -0
- package/provider/openai-native/src/native-runtime.ts +144 -0
- package/provider/openai-native/src/options.ts +85 -0
- package/provider/openai-native/src/request-pipeline.ts +21 -0
- package/provider/openai-native/src/responses-input.ts +433 -0
- package/review/advisor.ts +112 -0
- package/review/card.ts +385 -0
- package/review/checkpoint.ts +384 -0
- package/review/evidence.ts +232 -0
- package/review/index.ts +1246 -0
- package/review/outcome.ts +74 -0
- package/review/progress.ts +324 -0
- package/review/prompt.ts +210 -0
- package/review/prompts/advisor.en.md +41 -0
- package/review/prompts/advisor.zh.md +42 -0
- package/review/prompts/review.en.md +75 -0
- package/review/prompts/review.zh.md +75 -0
- package/review/reviewer.ts +356 -0
- package/review/session.ts +129 -0
- package/review/state.ts +905 -0
- package/review/ui.ts +528 -0
- package/session/bark.ts +157 -0
- package/session/herdr-display.ts +87 -0
- package/session/presets.ts +257 -0
- package/session/rename.ts +46 -0
- package/session/stats.ts +310 -0
- package/session/working-flame.ts +116 -0
- package/statusbar/index.ts +118 -0
- package/statusbar/quota-cache.ts +60 -0
- package/statusbar/quota-parse.ts +85 -0
- package/statusbar/quota.ts +203 -0
- package/statusbar/render.ts +181 -0
- package/statusbar/tps.ts +87 -0
- package/theme.ts +84 -0
- package/tools/grouping.ts +233 -0
- package/tools/index.ts +183 -0
- package/tools/line.ts +194 -0
- package/tools/parts.ts +143 -0
- package/tools/timing.ts +22 -0
- package/watcher/card.ts +85 -0
- package/watcher/index.ts +204 -0
- package/watcher/observer.ts +75 -0
- package/watcher/prompts/watch.zh.md +46 -0
- package/watcher/transcript.ts +64 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 把 pi 的会话身份投影到 herdr agent 副标题:`pi·模型/思考等级` + 会话名。
|
|
3
|
+
* 会话名同时以 `session` 自定义 token 上报,供 herdr 侧边栏 `$session` 行布局显示;
|
|
4
|
+
* 只写带 source 的 pane 显示元数据,不碰持久 pane/tab 名;失败静默,不影响会话。
|
|
5
|
+
* herdr 之外、非 TUI 模式或 Master Worker 内自我禁用。
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { formatModelName } from "../format.js";
|
|
9
|
+
import { herdrPaneEnv, herdrRequest } from "../herdr-client.js";
|
|
10
|
+
|
|
11
|
+
const SOURCE = "firecode";
|
|
12
|
+
|
|
13
|
+
/** herdr 按 seq 丢弃过期上报,同一 pane 的元数据必须单调递增。 */
|
|
14
|
+
let seq = Date.now() * 1000;
|
|
15
|
+
const nextSeq = () => (seq += 1);
|
|
16
|
+
|
|
17
|
+
type Identity = { title: string; agent: string };
|
|
18
|
+
|
|
19
|
+
export function projectIdentity(
|
|
20
|
+
sessionName: string | undefined,
|
|
21
|
+
modelId: string | undefined,
|
|
22
|
+
thinking: string | undefined,
|
|
23
|
+
): Identity {
|
|
24
|
+
const level = thinking && thinking !== "off" ? `/${thinking}` : "";
|
|
25
|
+
return {
|
|
26
|
+
title: sessionName ?? "",
|
|
27
|
+
agent: `pi·${formatModelName(modelId)}${level}`,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function registerHerdrDisplay(pi: ExtensionAPI, subsession = false): void {
|
|
32
|
+
const env = herdrPaneEnv();
|
|
33
|
+
if (!env || subsession) return;
|
|
34
|
+
const paneId = env.paneId;
|
|
35
|
+
|
|
36
|
+
let chain = Promise.resolve();
|
|
37
|
+
let published: string | undefined;
|
|
38
|
+
/** 链尾意图:最近一次入队的身份。去重必须以它为准——拿已确认身份提前返回
|
|
39
|
+
* 会让 A→B→A 快速切回把过时的 B 留在 pane 上;同时它挡住同身份密集事件的重发。
|
|
40
|
+
* 失败且仍是链尾时清空,保持「失败静默并由下一事件重试」的既有语义。 */
|
|
41
|
+
let enqueued: string | undefined;
|
|
42
|
+
|
|
43
|
+
const publish = (identity: Identity): Promise<void> => {
|
|
44
|
+
const key = `${identity.title}\u0000${identity.agent}`;
|
|
45
|
+
if (key === (enqueued ?? published)) return chain;
|
|
46
|
+
enqueued = key;
|
|
47
|
+
chain = chain.then(async () => {
|
|
48
|
+
const delivered = await herdrRequest(SOURCE, "pane.report_metadata", {
|
|
49
|
+
pane_id: paneId,
|
|
50
|
+
source: SOURCE,
|
|
51
|
+
display_agent: identity.agent || null,
|
|
52
|
+
clear_display_agent: !identity.agent,
|
|
53
|
+
title: identity.title || null,
|
|
54
|
+
clear_title: !identity.title,
|
|
55
|
+
// 侧边栏行布局只能消费自定义 token(title 不在 token 集里);null 即清除。
|
|
56
|
+
tokens: { session: identity.title || null },
|
|
57
|
+
seq: nextSeq(),
|
|
58
|
+
});
|
|
59
|
+
published = delivered ? key : undefined;
|
|
60
|
+
if (!delivered && enqueued === key) enqueued = undefined;
|
|
61
|
+
});
|
|
62
|
+
return chain;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const sync = (ctx: ExtensionContext): Promise<void> =>
|
|
66
|
+
ctx.mode === "tui"
|
|
67
|
+
? publish(
|
|
68
|
+
projectIdentity(
|
|
69
|
+
ctx.sessionManager.getSessionName(),
|
|
70
|
+
ctx.model?.id,
|
|
71
|
+
ctx.model?.reasoning ? pi.getThinkingLevel() : undefined,
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
: Promise.resolve();
|
|
75
|
+
|
|
76
|
+
pi.on("session_start", (_event, ctx) => sync(ctx));
|
|
77
|
+
// 覆盖 /rename、快捷键与 pi 自动命名:宿主已把改名收口到这一个事件。
|
|
78
|
+
pi.on("session_info_changed", (_event, ctx) => sync(ctx));
|
|
79
|
+
pi.on("model_select", (_event, ctx) => sync(ctx));
|
|
80
|
+
pi.on("thinking_level_select", (_event, ctx) => sync(ctx));
|
|
81
|
+
// quit 后 pane 退回 shell;其它 session 切换会立刻由新 session_start 覆盖。
|
|
82
|
+
pi.on("session_shutdown", (event, ctx) =>
|
|
83
|
+
event.reason === "quit" && ctx.mode === "tui"
|
|
84
|
+
? publish({ title: "", agent: "" })
|
|
85
|
+
: undefined,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 预设:一键切换模型、思考等级、工具集与附加指令。
|
|
3
|
+
* 入口有 `--preset`、`/preset [名字]`、Option+1-9、Ctrl+Shift+U 循环。
|
|
4
|
+
* 预设定义见 firecode/config.jsonc 的 presets 节。
|
|
5
|
+
*/
|
|
6
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
|
|
10
|
+
import { type Preset, loadConfig } from "../config.js";
|
|
11
|
+
|
|
12
|
+
const CLEAR_ITEM = "(无)";
|
|
13
|
+
const INSTRUCTIONS_PREVIEW_CHARS = 30;
|
|
14
|
+
const SELECTOR_MAX_ROWS = 10;
|
|
15
|
+
|
|
16
|
+
/** 会话可用的等级比预设可配置的多(含 max),快照要按前者存。 */
|
|
17
|
+
type OriginalState = {
|
|
18
|
+
model: Model<Api> | undefined;
|
|
19
|
+
thinkingLevel: ReturnType<ExtensionAPI["getThinkingLevel"]>;
|
|
20
|
+
tools: string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const title = (text: string): string =>
|
|
24
|
+
text ? `${text[0].toUpperCase()}${text.slice(1)}` : text;
|
|
25
|
+
|
|
26
|
+
function describe(preset: Preset): string {
|
|
27
|
+
const parts: string[] = [];
|
|
28
|
+
if (preset.provider && preset.model) parts.push(`${preset.provider}/${preset.model}`);
|
|
29
|
+
if (preset.thinkingLevel) parts.push(`thinking:${preset.thinkingLevel}`);
|
|
30
|
+
if (preset.tools) parts.push(`tools:${preset.tools.join(",")}`);
|
|
31
|
+
if (preset.instructions) {
|
|
32
|
+
const preview =
|
|
33
|
+
preset.instructions.length > INSTRUCTIONS_PREVIEW_CHARS
|
|
34
|
+
? `${preset.instructions.slice(0, INSTRUCTIONS_PREVIEW_CHARS - 3)}...`
|
|
35
|
+
: preset.instructions;
|
|
36
|
+
parts.push(`"${preview}"`);
|
|
37
|
+
}
|
|
38
|
+
return parts.join(" | ");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function registerPresets(pi: ExtensionAPI): void {
|
|
42
|
+
let presets: Record<string, Preset> = {};
|
|
43
|
+
let activeName: string | undefined;
|
|
44
|
+
let activePreset: Preset | undefined;
|
|
45
|
+
let originalState: OriginalState | undefined;
|
|
46
|
+
|
|
47
|
+
pi.registerFlag("preset", {
|
|
48
|
+
description: "要启用的预设名",
|
|
49
|
+
type: "string",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const updateStatus = (ctx: ExtensionContext) =>
|
|
53
|
+
ctx.ui.setStatus(
|
|
54
|
+
"preset",
|
|
55
|
+
activeName ? ctx.ui.theme.fg("accent", `🧩 ${title(activeName)}`) : undefined,
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const noPresetsHint = (ctx: ExtensionContext) =>
|
|
59
|
+
ctx.ui.notify("未定义任何预设。在 firecode/config.jsonc 的 presets 里添加。", "warning");
|
|
60
|
+
|
|
61
|
+
async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<void> {
|
|
62
|
+
// 首次应用前留一份快照,用于恢复默认。
|
|
63
|
+
if (activeName === undefined) {
|
|
64
|
+
originalState = {
|
|
65
|
+
model: ctx.model,
|
|
66
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
67
|
+
tools: pi.getActiveTools(),
|
|
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
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (preset.thinkingLevel) pi.setThinkingLevel(preset.thinkingLevel);
|
|
87
|
+
|
|
88
|
+
if (preset.tools?.length) {
|
|
89
|
+
const known = new Set(pi.getAllTools().map((tool) => tool.name));
|
|
90
|
+
const valid = preset.tools.filter((tool) => known.has(tool));
|
|
91
|
+
const unknown = preset.tools.filter((tool) => !known.has(tool));
|
|
92
|
+
if (unknown.length)
|
|
93
|
+
ctx.ui.notify(`预设「${name}」含未知工具:${unknown.join("、")}`, "warning");
|
|
94
|
+
if (valid.length) pi.setActiveTools(valid);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
activeName = name;
|
|
98
|
+
activePreset = preset;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function activate(name: string, ctx: ExtensionContext): Promise<void> {
|
|
102
|
+
const preset = presets[name];
|
|
103
|
+
if (!preset) return;
|
|
104
|
+
await applyPreset(name, preset, ctx);
|
|
105
|
+
ctx.ui.notify(`已切换预设「${name}」`, "info");
|
|
106
|
+
updateStatus(ctx);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function clearPreset(ctx: ExtensionContext): Promise<void> {
|
|
110
|
+
activeName = undefined;
|
|
111
|
+
activePreset = undefined;
|
|
112
|
+
if (originalState) {
|
|
113
|
+
if (originalState.model) await pi.setModel(originalState.model);
|
|
114
|
+
pi.setThinkingLevel(originalState.thinkingLevel);
|
|
115
|
+
pi.setActiveTools(originalState.tools);
|
|
116
|
+
}
|
|
117
|
+
ctx.ui.notify("预设已清除,恢复默认", "info");
|
|
118
|
+
updateStatus(ctx);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function showSelector(ctx: ExtensionContext): Promise<void> {
|
|
122
|
+
const names = Object.keys(presets);
|
|
123
|
+
if (names.length === 0) {
|
|
124
|
+
noPresetsHint(ctx);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const items: SelectItem[] = names.map((name) => ({
|
|
129
|
+
value: name,
|
|
130
|
+
label: name === activeName ? `${name}(当前)` : name,
|
|
131
|
+
description: describe(presets[name]),
|
|
132
|
+
}));
|
|
133
|
+
items.push({
|
|
134
|
+
value: CLEAR_ITEM,
|
|
135
|
+
label: CLEAR_ITEM,
|
|
136
|
+
description: "清除当前预设,恢复默认",
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const choice = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
140
|
+
const container = new Container();
|
|
141
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
142
|
+
container.addChild(new Text(theme.fg("accent", theme.bold("选择预设"))));
|
|
143
|
+
|
|
144
|
+
const selectList = new SelectList(items, Math.min(items.length, SELECTOR_MAX_ROWS), {
|
|
145
|
+
selectedPrefix: (text) => theme.fg("accent", text),
|
|
146
|
+
selectedText: (text) => theme.fg("accent", text),
|
|
147
|
+
description: (text) => theme.fg("muted", text),
|
|
148
|
+
scrollInfo: (text) => theme.fg("dim", text),
|
|
149
|
+
noMatch: (text) => theme.fg("warning", text),
|
|
150
|
+
});
|
|
151
|
+
selectList.onSelect = (item) => done(item.value);
|
|
152
|
+
selectList.onCancel = () => done(null);
|
|
153
|
+
container.addChild(selectList);
|
|
154
|
+
container.addChild(new Text(theme.fg("dim", "↑↓ 选择 • enter 确认 • esc 取消")));
|
|
155
|
+
container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
render: (width: number) => container.render(width),
|
|
159
|
+
invalidate: () => container.invalidate(),
|
|
160
|
+
handleInput(data: string) {
|
|
161
|
+
selectList.handleInput(data);
|
|
162
|
+
tui.requestRender();
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
if (!choice) return;
|
|
168
|
+
if (choice === CLEAR_ITEM) await clearPreset(ctx);
|
|
169
|
+
else await activate(choice, ctx);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function cyclePreset(ctx: ExtensionContext): Promise<void> {
|
|
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
|
+
});
|
|
191
|
+
|
|
192
|
+
for (const [name, preset] of Object.entries(configured)) {
|
|
193
|
+
if (!preset.key) continue;
|
|
194
|
+
pi.registerShortcut(preset.key as never, {
|
|
195
|
+
description: `启用预设「${name}」`,
|
|
196
|
+
handler: (ctx) => activate(name, ctx),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
pi.registerCommand("preset", {
|
|
201
|
+
description: "切换预设",
|
|
202
|
+
handler: async (args, ctx) => {
|
|
203
|
+
const name = args?.trim();
|
|
204
|
+
if (!name) {
|
|
205
|
+
await showSelector(ctx);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!presets[name]) {
|
|
209
|
+
const available = Object.keys(presets).join(", ") || "(none defined)";
|
|
210
|
+
ctx.ui.notify(`未知预设「${name}」,可用:${available}`, "error");
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
await activate(name, ctx);
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
pi.on("before_agent_start", async (event) => {
|
|
218
|
+
if (!activePreset?.instructions) return;
|
|
219
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${activePreset.instructions}` };
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
223
|
+
// 配置问题由 index.ts 统一提示,这里只取预设。
|
|
224
|
+
presets = loadConfig().config.presets;
|
|
225
|
+
|
|
226
|
+
const flag = pi.getFlag("preset");
|
|
227
|
+
if (typeof flag === "string" && flag) {
|
|
228
|
+
if (presets[flag]) {
|
|
229
|
+
await applyPreset(flag, presets[flag], ctx);
|
|
230
|
+
ctx.ui.notify(`已切换预设「${flag}」`, "info");
|
|
231
|
+
} else {
|
|
232
|
+
const available = Object.keys(presets).join(", ") || "(none defined)";
|
|
233
|
+
ctx.ui.notify(`未知预设「${flag}」,可用:${available}`, "warning");
|
|
234
|
+
}
|
|
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
|
+
|
|
251
|
+
updateStatus(ctx);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
pi.on("turn_start", async () => {
|
|
255
|
+
if (activeName) pi.appendEntry("preset-state", { name: activeName });
|
|
256
|
+
});
|
|
257
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** 会话重命名:`/rename <name>` 与 config.keys.rename,只改 pi 会话名。 */
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { loadConfig } from "../config.js";
|
|
4
|
+
|
|
5
|
+
const MAX_TITLE_CHARS = 160;
|
|
6
|
+
const CONTROL_CHARS = /[\x00-\x1f\x7f-\x9f]/g;
|
|
7
|
+
const INVISIBLE_CHARS = /[\u200b-\u200f\u202a-\u202e\u2060-\u206f]/g;
|
|
8
|
+
|
|
9
|
+
function cleanTitle(raw: string): string {
|
|
10
|
+
const title = raw
|
|
11
|
+
.replace(CONTROL_CHARS, " ")
|
|
12
|
+
.replace(INVISIBLE_CHARS, "")
|
|
13
|
+
.replace(/\s+/g, " ")
|
|
14
|
+
.trim();
|
|
15
|
+
return Array.from(title).slice(0, MAX_TITLE_CHARS).join("");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function registerSessionName(pi: ExtensionAPI): void {
|
|
19
|
+
const rename = (ctx: ExtensionContext, name: string) => {
|
|
20
|
+
pi.setSessionName(name);
|
|
21
|
+
ctx.ui.notify(`会话已改名:${name}`, "info");
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
pi.registerShortcut(loadConfig().config.keys.rename as never, {
|
|
25
|
+
description: "重命名当前会话",
|
|
26
|
+
handler: async (ctx) => {
|
|
27
|
+
if (!ctx.hasUI) return;
|
|
28
|
+
const current = pi.getSessionName() ?? "";
|
|
29
|
+
const next = await ctx.ui.input("重命名会话", current || "新名字");
|
|
30
|
+
const name = cleanTitle(next ?? "");
|
|
31
|
+
if (name) rename(ctx, name);
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
pi.registerCommand("rename", {
|
|
36
|
+
description: "重命名当前会话",
|
|
37
|
+
handler: async (args, ctx) => {
|
|
38
|
+
const name = cleanTitle(args);
|
|
39
|
+
if (!name) {
|
|
40
|
+
ctx.ui.notify("用法:/rename <新名字>", "error");
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
rename(ctx, name);
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
}
|
package/session/stats.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话 token / 成本统计:扫描 sessions 下的 jsonl,按模型汇总后用 Markdown 展示。
|
|
3
|
+
* 源自 pi-token-stats (MIT, https://github.com/reaishijie/pi-token-stats),已精简。
|
|
4
|
+
*/
|
|
5
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { DynamicBorder, getAgentDir, getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { type AutocompleteItem, Container, Markdown, Text, matchesKey } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
const DEFAULT_DAYS = 30;
|
|
12
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
type Usage = {
|
|
15
|
+
input?: number;
|
|
16
|
+
output?: number;
|
|
17
|
+
cacheRead?: number;
|
|
18
|
+
cacheWrite?: number;
|
|
19
|
+
totalTokens?: number;
|
|
20
|
+
cost?: { total?: number };
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type SessionLine = {
|
|
24
|
+
type?: string;
|
|
25
|
+
timestamp?: string;
|
|
26
|
+
usage?: Usage;
|
|
27
|
+
message?: { role?: string; provider?: string; model?: string; usage?: Usage; timestamp?: number };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type UsageAttribution = {
|
|
31
|
+
provider: string;
|
|
32
|
+
model: string;
|
|
33
|
+
usage: Usage;
|
|
34
|
+
timestamp?: number;
|
|
35
|
+
countRequest: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** 与 pi 的 usage-totals 对齐:assistant 归模型,工具与压缩归 tools/summaries。 */
|
|
39
|
+
export function usageAttribution(entry: SessionLine): UsageAttribution | undefined {
|
|
40
|
+
const at = (fallback?: number) => fallback ?? (entry.timestamp ? Date.parse(entry.timestamp) : undefined);
|
|
41
|
+
|
|
42
|
+
if (entry.type === "message" && entry.message?.usage) {
|
|
43
|
+
const { role, provider, model, usage, timestamp } = entry.message;
|
|
44
|
+
if (role === "assistant") {
|
|
45
|
+
return {
|
|
46
|
+
provider: provider ?? "unknown",
|
|
47
|
+
model: model ?? "unknown",
|
|
48
|
+
usage,
|
|
49
|
+
timestamp: at(timestamp),
|
|
50
|
+
countRequest: true,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
if (role === "toolResult") {
|
|
54
|
+
return { provider: "tools", model: "summaries", usage, timestamp: at(timestamp), countRequest: false };
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if ((entry.type === "compaction" || entry.type === "branch_summary") && entry.usage) {
|
|
60
|
+
return { provider: "tools", model: "summaries", usage: entry.usage, timestamp: at(), countRequest: false };
|
|
61
|
+
}
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type Totals = {
|
|
66
|
+
requests: number;
|
|
67
|
+
input: number;
|
|
68
|
+
output: number;
|
|
69
|
+
cacheRead: number;
|
|
70
|
+
cacheWrite: number;
|
|
71
|
+
tokens: number;
|
|
72
|
+
cost: number;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const emptyTotals = (): Totals => ({
|
|
76
|
+
requests: 0,
|
|
77
|
+
input: 0,
|
|
78
|
+
output: 0,
|
|
79
|
+
cacheRead: 0,
|
|
80
|
+
cacheWrite: 0,
|
|
81
|
+
tokens: 0,
|
|
82
|
+
cost: 0,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
function add(target: Totals, usage: Usage, countRequest: boolean): void {
|
|
86
|
+
const input = usage.input ?? 0;
|
|
87
|
+
const output = usage.output ?? 0;
|
|
88
|
+
const cacheRead = usage.cacheRead ?? 0;
|
|
89
|
+
const cacheWrite = usage.cacheWrite ?? 0;
|
|
90
|
+
|
|
91
|
+
if (countRequest) target.requests += 1;
|
|
92
|
+
target.input += input;
|
|
93
|
+
target.output += output;
|
|
94
|
+
target.cacheRead += cacheRead;
|
|
95
|
+
target.cacheWrite += cacheWrite;
|
|
96
|
+
target.tokens += usage.totalTokens ?? input + output + cacheRead + cacheWrite;
|
|
97
|
+
target.cost += usage.cost?.total ?? 0;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function sessionFiles(dir: string): string[] {
|
|
101
|
+
if (!existsSync(dir)) return [];
|
|
102
|
+
|
|
103
|
+
const found: string[] = [];
|
|
104
|
+
const pending = [dir];
|
|
105
|
+
while (pending.length > 0) {
|
|
106
|
+
const current = pending.pop() as string;
|
|
107
|
+
let entries: string[];
|
|
108
|
+
try {
|
|
109
|
+
entries = readdirSync(current);
|
|
110
|
+
} catch {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
for (const entry of entries) {
|
|
114
|
+
const path = join(current, entry);
|
|
115
|
+
try {
|
|
116
|
+
if (statSync(path).isDirectory()) pending.push(path);
|
|
117
|
+
else if (entry.endsWith(".jsonl")) found.push(path);
|
|
118
|
+
} catch {
|
|
119
|
+
// 会话文件可能在扫描过程中被轮转掉。
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return found;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type Report = {
|
|
127
|
+
days: number;
|
|
128
|
+
from?: number;
|
|
129
|
+
to: number;
|
|
130
|
+
dir: string;
|
|
131
|
+
scanned: number;
|
|
132
|
+
matched: number;
|
|
133
|
+
overall: Totals;
|
|
134
|
+
byModel: Map<string, Totals>;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
function collect(days: number): Report {
|
|
138
|
+
const to = Date.now();
|
|
139
|
+
const from = days === 0 ? undefined : to - days * DAY_MS;
|
|
140
|
+
const dir = join(getAgentDir(), "sessions");
|
|
141
|
+
const files = sessionFiles(dir);
|
|
142
|
+
const report: Report = {
|
|
143
|
+
days,
|
|
144
|
+
from,
|
|
145
|
+
to,
|
|
146
|
+
dir,
|
|
147
|
+
scanned: files.length,
|
|
148
|
+
matched: 0,
|
|
149
|
+
overall: emptyTotals(),
|
|
150
|
+
byModel: new Map(),
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
for (const file of files) {
|
|
154
|
+
let content: string;
|
|
155
|
+
try {
|
|
156
|
+
content = readFileSync(file, "utf8");
|
|
157
|
+
} catch {
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
let matched = false;
|
|
162
|
+
for (const line of content.split("\n")) {
|
|
163
|
+
if (!line.trim()) continue;
|
|
164
|
+
|
|
165
|
+
let attributed: UsageAttribution | undefined;
|
|
166
|
+
try {
|
|
167
|
+
attributed = usageAttribution(JSON.parse(line) as SessionLine);
|
|
168
|
+
} catch {
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (!attributed) continue;
|
|
172
|
+
if (from !== undefined && attributed.timestamp !== undefined && attributed.timestamp < from) continue;
|
|
173
|
+
|
|
174
|
+
const key = `${attributed.provider}/${attributed.model}`;
|
|
175
|
+
let model = report.byModel.get(key);
|
|
176
|
+
if (!model) {
|
|
177
|
+
model = emptyTotals();
|
|
178
|
+
report.byModel.set(key, model);
|
|
179
|
+
}
|
|
180
|
+
add(report.overall, attributed.usage, attributed.countRequest);
|
|
181
|
+
add(model, attributed.usage, attributed.countRequest);
|
|
182
|
+
matched = true;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (matched) report.matched += 1;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return report;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const number = (value: number): string => new Intl.NumberFormat("en-US").format(Math.round(value));
|
|
192
|
+
|
|
193
|
+
const compactTokens = (value: number): string => {
|
|
194
|
+
const rounded = Math.round(value);
|
|
195
|
+
if (rounded >= 100_000_000) return `${(rounded / 100_000_000).toFixed(1)}亿`;
|
|
196
|
+
if (rounded >= 10_000) return `${(rounded / 10_000).toFixed(1)}万`;
|
|
197
|
+
return number(rounded);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const cost = (value: number): string => `$${value.toFixed(value >= 1 ? 2 : 4)}`;
|
|
201
|
+
|
|
202
|
+
const dateTime = (value: number): string => {
|
|
203
|
+
const date = new Date(value);
|
|
204
|
+
const pad = (part: number) => part.toString().padStart(2, "0");
|
|
205
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const row = (cells: Array<string | number>): string => `| ${cells.join(" | ")} |`;
|
|
209
|
+
|
|
210
|
+
const totalsCells = (totals: Totals): string[] => [
|
|
211
|
+
number(totals.input),
|
|
212
|
+
number(totals.output),
|
|
213
|
+
number(totals.cacheRead),
|
|
214
|
+
number(totals.cacheWrite),
|
|
215
|
+
compactTokens(totals.tokens),
|
|
216
|
+
cost(totals.cost),
|
|
217
|
+
];
|
|
218
|
+
|
|
219
|
+
function buildMarkdown(report: Report): string {
|
|
220
|
+
const range =
|
|
221
|
+
report.days === 0
|
|
222
|
+
? "全部"
|
|
223
|
+
: `最近 ${report.days} 天(${dateTime(report.from as number)} ~ ${dateTime(report.to)})`;
|
|
224
|
+
const headers = ["输入", "输出", "缓存读", "缓存写", "总 Token", "成本"];
|
|
225
|
+
const aligns = headers.map(() => "---:");
|
|
226
|
+
const lines = [
|
|
227
|
+
"# Token 用量",
|
|
228
|
+
"",
|
|
229
|
+
`- 范围:${range}`,
|
|
230
|
+
`- 会话:命中 ${number(report.matched)} / 扫描 ${number(report.scanned)} 个文件`,
|
|
231
|
+
`- 请求:${number(report.overall.requests)} 次,合计 ${cost(report.overall.cost)}`,
|
|
232
|
+
"",
|
|
233
|
+
row(headers),
|
|
234
|
+
row(aligns),
|
|
235
|
+
row(totalsCells(report.overall)),
|
|
236
|
+
"",
|
|
237
|
+
"## 按模型",
|
|
238
|
+
"",
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
const models = [...report.byModel.entries()].sort((left, right) => right[1].tokens - left[1].tokens);
|
|
242
|
+
if (models.length === 0) {
|
|
243
|
+
lines.push("该范围内没有用量记录。");
|
|
244
|
+
return lines.join("\n");
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
lines.push(row(["模型", "请求", ...headers]));
|
|
248
|
+
lines.push(row(["---", "---:", ...aligns]));
|
|
249
|
+
for (const [key, totals] of models) {
|
|
250
|
+
lines.push(row([`\`${key}\``, number(totals.requests), ...totalsCells(totals)]));
|
|
251
|
+
}
|
|
252
|
+
return lines.join("\n");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function show(markdown: string, ctx: ExtensionCommandContext): Promise<void> {
|
|
256
|
+
if (ctx.mode !== "tui") {
|
|
257
|
+
console.log(markdown);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
await ctx.ui.custom((_tui, theme, _keybindings, done) => {
|
|
262
|
+
const container = new Container();
|
|
263
|
+
const border = new DynamicBorder((text: string) => theme.fg("accent", text));
|
|
264
|
+
container.addChild(border);
|
|
265
|
+
container.addChild(new Markdown(markdown, 1, 1, getMarkdownTheme()));
|
|
266
|
+
container.addChild(new Text(theme.fg("dim", "Enter / Esc 关闭"), 1, 0));
|
|
267
|
+
container.addChild(border);
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
render: (width: number) => container.render(width),
|
|
271
|
+
invalidate: () => container.invalidate(),
|
|
272
|
+
handleInput: (data: string) => {
|
|
273
|
+
if (matchesKey(data, "enter") || matchesKey(data, "escape")) done(undefined);
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 支持 `/tokens 7`、`/tokens --days 7`、`/tokens 0`(全部)。 */
|
|
280
|
+
export function parseDays(args: string | undefined): number {
|
|
281
|
+
const text = (args ?? "").trim();
|
|
282
|
+
if (!text) return DEFAULT_DAYS;
|
|
283
|
+
|
|
284
|
+
const matched = text.match(/(?:^|\s)(?:--days|-d)\s+(\d+)(?:\s|$)/i) ?? text.match(/^(\d+)\s*d?$/i);
|
|
285
|
+
const value = Number.parseInt(matched?.[1] ?? "", 10);
|
|
286
|
+
return Number.isFinite(value) ? value : DEFAULT_DAYS;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const dayCompletions: AutocompleteItem[] = [
|
|
290
|
+
{ value: "7", label: "7", description: "最近 7 天" },
|
|
291
|
+
{ value: "30", label: "30", description: "最近 30 天(默认)" },
|
|
292
|
+
{ value: "0", label: "0", description: "全部历史" },
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
export function registerStats(pi: ExtensionAPI): void {
|
|
296
|
+
pi.registerCommand("tokens", {
|
|
297
|
+
description: "统计会话 token 与成本,参数为天数:默认 30,0 表示全部",
|
|
298
|
+
getArgumentCompletions: (prefix: string) => {
|
|
299
|
+
const matches = dayCompletions.filter((item) => item.value.startsWith(prefix.trim()));
|
|
300
|
+
return matches.length > 0 ? matches : null;
|
|
301
|
+
},
|
|
302
|
+
handler: async (args: string | undefined, ctx: ExtensionCommandContext) => {
|
|
303
|
+
const days = parseDays(args);
|
|
304
|
+
if (ctx.hasUI) {
|
|
305
|
+
ctx.ui.notify(`统计中(${days === 0 ? "全部历史" : `最近 ${days} 天`})…`, "info");
|
|
306
|
+
}
|
|
307
|
+
await show(buildMarkdown(collect(days)), ctx);
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|