pi-firecode 1.0.0 → 1.0.1

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.
Files changed (93) hide show
  1. package/README.md +55 -41
  2. package/README.zh-CN.md +74 -0
  3. package/config.example.jsonc +21 -22
  4. package/dist/config.example.jsonc +90 -0
  5. package/dist/index.js +11464 -0
  6. package/dist/master/prompts/master.en.md +22 -0
  7. package/{master → dist/master}/prompts/master.zh.md +3 -3
  8. package/dist/master/prompts/worker.en.md +1 -0
  9. package/dist/watcher/prompts/watch.en.md +43 -0
  10. package/package.json +10 -27
  11. package/activity.ts +0 -92
  12. package/busy.ts +0 -188
  13. package/config.ts +0 -500
  14. package/deliver.ts +0 -119
  15. package/flame.ts +0 -149
  16. package/format.ts +0 -133
  17. package/header.ts +0 -218
  18. package/herdr-client.ts +0 -60
  19. package/index.ts +0 -68
  20. package/jsonc.ts +0 -34
  21. package/master/actions.ts +0 -293
  22. package/master/activity-list.ts +0 -281
  23. package/master/event-card.ts +0 -87
  24. package/master/event-format.ts +0 -83
  25. package/master/guard.ts +0 -46
  26. package/master/index.ts +0 -252
  27. package/master/list-view.ts +0 -129
  28. package/master/outbox.ts +0 -188
  29. package/master/prompt.ts +0 -26
  30. package/master/role.ts +0 -18
  31. package/master/run.ts +0 -315
  32. package/master/runtime.ts +0 -259
  33. package/master/spawn.ts +0 -234
  34. package/master/state.ts +0 -225
  35. package/master/worker-view.ts +0 -517
  36. package/provider/claude-sub.ts +0 -151
  37. package/provider/openai-native/index.ts +0 -8
  38. package/provider/openai-native/src/compact-client.ts +0 -350
  39. package/provider/openai-native/src/config.ts +0 -297
  40. package/provider/openai-native/src/extension.ts +0 -93
  41. package/provider/openai-native/src/native-compaction.ts +0 -151
  42. package/provider/openai-native/src/native-details.ts +0 -157
  43. package/provider/openai-native/src/native-replay.ts +0 -253
  44. package/provider/openai-native/src/native-runtime.ts +0 -144
  45. package/provider/openai-native/src/options.ts +0 -87
  46. package/provider/openai-native/src/request-pipeline.ts +0 -21
  47. package/provider/openai-native/src/responses-input.ts +0 -433
  48. package/review/advisor.ts +0 -110
  49. package/review/card.ts +0 -398
  50. package/review/checkpoint.ts +0 -378
  51. package/review/evidence.ts +0 -230
  52. package/review/index.ts +0 -992
  53. package/review/occupancy.ts +0 -25
  54. package/review/outcome.ts +0 -98
  55. package/review/prompt.ts +0 -209
  56. package/review/reviewer.ts +0 -353
  57. package/review/session.ts +0 -110
  58. package/review/state.ts +0 -877
  59. package/review/ui.ts +0 -89
  60. package/round-recorder.ts +0 -18
  61. package/session/herdr-display.ts +0 -87
  62. package/session/presets.ts +0 -300
  63. package/session/quota.ts +0 -123
  64. package/session/rename.ts +0 -46
  65. package/session/stats.ts +0 -313
  66. package/statusbar/index.ts +0 -256
  67. package/statusbar/render.ts +0 -113
  68. package/theme.ts +0 -45
  69. package/tools/actions.ts +0 -53
  70. package/tools/assistant-view.ts +0 -64
  71. package/tools/click-anchor.ts +0 -56
  72. package/tools/group-view.ts +0 -420
  73. package/tools/grouping.ts +0 -158
  74. package/tools/host.ts +0 -369
  75. package/tools/index.ts +0 -186
  76. package/tools/line.ts +0 -231
  77. package/tools/machine.ts +0 -78
  78. package/tools/parts.ts +0 -143
  79. package/tools/round.ts +0 -95
  80. package/tools/timing.ts +0 -22
  81. package/tools/turn-clock.ts +0 -41
  82. package/tools/turn-summary.ts +0 -109
  83. package/truncated-write.ts +0 -18
  84. package/watcher/card.ts +0 -91
  85. package/watcher/index.ts +0 -176
  86. package/watcher/observer.ts +0 -75
  87. package/watcher/transcript.ts +0 -56
  88. /package/{master → dist/master}/prompts/worker.zh.md +0 -0
  89. /package/{review → dist/review}/prompts/advisor.en.md +0 -0
  90. /package/{review → dist/review}/prompts/advisor.zh.md +0 -0
  91. /package/{review → dist/review}/prompts/review.en.md +0 -0
  92. /package/{review → dist/review}/prompts/review.zh.md +0 -0
  93. /package/{watcher → dist/watcher}/prompts/watch.zh.md +0 -0
package/review/ui.ts DELETED
@@ -1,89 +0,0 @@
1
- /**
2
- * /fire-review 的界面接管:esc 取消的只读编辑器与终端标题。
3
- *
4
- * 审查进度不在这里画:它经占用频道(occupancy.ts)发布,由输入框外壳嵌进上边框,界面只此一处。
5
- * Working 指示的可见性归输入框外壳(statusbar)统一管理,这里不碰。
6
- */
7
- import { basename } from "node:path";
8
- import {
9
- CustomEditor,
10
- type ExtensionContext,
11
- type KeybindingsManager,
12
- } from "@earendil-works/pi-coding-agent";
13
- import type { EditorComponent, EditorTheme, TUI } from "@earendil-works/pi-tui";
14
- import type { Language } from "../config.js";
15
- import { clip } from "../format.js";
16
-
17
- let reviewTitleActive = false;
18
-
19
- /**
20
- * 审查等模型结论时(排队/审查中/顾问仲裁)接管编辑器:禁止输入,esc/Ctrl+C 随时取消。
21
- * 返回解锁函数,还原成锁定前的编辑器工厂(可能是别的扩展设置的自定义编辑器)。
22
- *
23
- * 不能用全局输入钩子比对裸 \x1b:终端开启增强键盘协议后 esc 是带修饰的序列,
24
- * 字面量比较会漏。这里统一走 keybindings 匹配。
25
- * awaiting_fix 相不接管——那时是执行模型在改代码,用户应能正常输入与中断。
26
- */
27
- export function lockEditor(ctx: ExtensionContext, cancel: () => void): () => void {
28
- if (ctx.hasUI === false || typeof ctx.ui.setEditorComponent !== "function") return () => {};
29
- const previous = ctx.ui.getEditorComponent();
30
- ctx.ui.setEditorComponent((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) => {
31
- const frame = previous?.(tui, theme, keybindings) ?? new CustomEditor(tui, theme, keybindings);
32
- return new ReviewEditor(tui, theme, keybindings, cancel, frame);
33
- });
34
- return () => ctx.ui.setEditorComponent(previous);
35
- }
36
-
37
- /**
38
- * 审查期间的只读编辑器:输入区收起成一行暗色提示,上下边框取自 frame(锁定前的编辑器),
39
- * 外壳状态(审查进度、会话标题、模型)因此保持可见。
40
- */
41
- class ReviewEditor extends CustomEditor {
42
- constructor(
43
- tui: TUI,
44
- theme: EditorTheme,
45
- private readonly keys: KeybindingsManager,
46
- private readonly cancel: () => void,
47
- private readonly frame: EditorComponent,
48
- ) {
49
- super(tui, theme, keys);
50
- }
51
-
52
- override handleInput(data: string): void {
53
- if (this.keys.matches(data, "app.interrupt") || this.keys.matches(data, "app.clear")) this.cancel();
54
- // 审查期间不接受任何其他输入:插话会污染本轮审查的会话证据。
55
- }
56
-
57
- override render(width: number): string[] {
58
- const lines = this.frame.render(width);
59
- if (lines.length < 2) return lines;
60
- const keys = this.keys.getKeys("app.interrupt").join("/").replaceAll("escape", "esc") || "esc";
61
- const hint = clip(` 审查进行中 · ${keys} 取消`, width, "end", "");
62
- return [lines[0], `\x1b[2m${hint}\x1b[22m`, lines[lines.length - 1]];
63
- }
64
- }
65
-
66
- /** 审查期间终端标题写明“审查中 R轮次 · 会话名”,结束后还原。 */
67
- export function showReviewTitle(ctx: ExtensionContext, round: number, language: Language) {
68
- if (!ctx.hasUI || typeof ctx.ui.setTitle !== "function") return;
69
- const manager = ctx.sessionManager as { getSessionName?: () => unknown; getCwd?: () => unknown };
70
- const rawName = manager.getSessionName?.();
71
- const rawCwd = manager.getCwd?.();
72
- const who = typeof rawName === "string" && rawName
73
- ? rawName
74
- : typeof rawCwd === "string" ? basename(rawCwd) : "";
75
- const label = language === "en" ? "Reviewing" : "审查中";
76
- reviewTitleActive = true;
77
- ctx.ui.setTitle(`${label}${round > 0 ? ` R${round}` : ""}${who ? ` · ${who}` : ""}`);
78
- }
79
-
80
- export function hideReviewTitle(ctx: ExtensionContext) {
81
- if (!reviewTitleActive || !ctx.hasUI || typeof ctx.ui.setTitle !== "function") return;
82
- reviewTitleActive = false;
83
- const manager = ctx.sessionManager as { getSessionName?: () => unknown; getCwd?: () => unknown };
84
- const rawName = manager.getSessionName?.();
85
- const rawCwd = manager.getCwd?.();
86
- const name = typeof rawName === "string" && rawName ? rawName : undefined;
87
- const dir = typeof rawCwd === "string" ? basename(rawCwd) : "";
88
- ctx.ui.setTitle(name ? `π - ${name} - ${dir}` : `π - ${dir}`);
89
- }
package/round-recorder.ts DELETED
@@ -1,18 +0,0 @@
1
- /**
2
- * 轮记录器:会话歇下时把这一段的整段时长、终态与均速写成轮记录(CustomEntry),写入后在进程内总线上通知。
3
- * 每个会话都注册——主会话与每个子代理会话跑的是同一段代码、同一个歇下判定(busy.ts),与界面无关:
4
- * 主会话的摘要行、输入框外壳、子代理全过程视图都只读记录;Master 结果事件的“本次运行”耗时也读子代理会话里的
5
- * 这份记录,不再在指挥官这边另外计时——同一段时长只有一个事实源,视图与事件不会对不上。
6
- */
7
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
8
- import { watchBusy } from "./busy.js";
9
- import { ROUND_ENTRY, ROUND_RECORDED_CHANNEL } from "./tools/round.js";
10
-
11
- export function registerRoundRecorder(pi: ExtensionAPI): void {
12
- watchBusy(pi, {
13
- onSettled: (_ctx, round) => {
14
- pi.appendEntry(ROUND_ENTRY, round);
15
- pi.events.emit(ROUND_RECORDED_CHANNEL, undefined);
16
- },
17
- });
18
- }
@@ -1,87 +0,0 @@
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
- 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
- }
@@ -1,300 +0,0 @@
1
- /**
2
- * 预设:一键切换模型、思考等级、工具集与附加指令。
3
- * 入口有 `--preset`、`/preset [名字]` 与各预设的 key 快捷键。
4
- * 预设定义见 firecode/config.jsonc 的 presets 节。
5
- *
6
- * 模型、思考档与工具集的事实源是宿主:宿主把它们记在会话里,并在 session_start 之前(切分支时同样)恢复。
7
- * 这里只持有宿主不知道的两样——预设名与附加指令,且只在“当前模型仍是预设的模型”时成立:手动切走或重开会话时
8
- * 宿主恢复的不是它,预设即失效(名字与指令一并清掉并记入会话),不另存一份模型去覆盖宿主,也不动宿主的工具集。
9
- */
10
- import type { Api, Model } from "@earendil-works/pi-ai";
11
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
- import { DynamicBorder } from "@earendil-works/pi-coding-agent";
13
- import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
14
- import { type Preset, loadConfig } from "../config.js";
15
-
16
- const CLEAR_ITEM = "(无)";
17
- const STATE_ENTRY = "preset-state";
18
- const INSTRUCTIONS_PREVIEW_CHARS = 30;
19
- const SELECTOR_MAX_ROWS = 10;
20
-
21
- /** 会话可用的等级比预设可配置的多(含 max),快照要按前者存。重开会话后预设之前的模型已不可知,model 为空表示不动模型。 */
22
- type OriginalState = {
23
- model: Model<Api> | undefined;
24
- thinkingLevel: ReturnType<ExtensionAPI["getThinkingLevel"]>;
25
- tools: string[];
26
- };
27
-
28
- const title = (text: string): string =>
29
- text ? `${text[0].toUpperCase()}${text.slice(1)}` : text;
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
-
42
- function describe(preset: Preset): string {
43
- const parts: string[] = [];
44
- if (preset.model) parts.push(preset.model.model, `thinking:${preset.model.thinking}`);
45
- if (preset.tools) parts.push(`tools:${preset.tools.join(",")}`);
46
- if (preset.instructions) {
47
- const preview =
48
- preset.instructions.length > INSTRUCTIONS_PREVIEW_CHARS
49
- ? `${preset.instructions.slice(0, INSTRUCTIONS_PREVIEW_CHARS - 3)}...`
50
- : preset.instructions;
51
- parts.push(`"${preview}"`);
52
- }
53
- return parts.join(" | ");
54
- }
55
-
56
- export function registerPresets(pi: ExtensionAPI): void {
57
- let presets: Record<string, Preset> = {};
58
- let activeName: string | undefined;
59
- let activePreset: Preset | undefined;
60
- let originalState: OriginalState | undefined;
61
- /** 本模块自己切模型期间的 model_select 不算用户切走。 */
62
- let applying = false;
63
-
64
- pi.registerFlag("preset", {
65
- description: "要启用的预设名",
66
- type: "string",
67
- });
68
-
69
- /**
70
- * 预设名只在它带来边框看不见的改动(工具集、附加指令)时发布:模型与思考档已显示在输入框下边框,
71
- * 只改这两样的预设再报名字就是同一信息说两遍。
72
- */
73
- const updateStatus = (ctx: ExtensionContext) =>
74
- ctx.ui.setStatus(
75
- "preset",
76
- activeName && (activePreset?.tools?.length || activePreset?.instructions)
77
- ? ctx.ui.theme.fg("accent", title(activeName))
78
- : undefined,
79
- );
80
-
81
- const noPresetsHint = (ctx: ExtensionContext) =>
82
- ctx.ui.notify("未定义任何预设。在 firecode/config.jsonc 的 presets 里添加。", "warning");
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
- /** 先套模型;模型套用失败就整套不套(不动工具、不设预设名),只提示原因。 */
104
- async function applyPreset(name: string, preset: Preset, ctx: ExtensionContext): Promise<void> {
105
- // 首次应用前留一份快照,用于恢复默认。
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;
114
- }
115
- originalState = snapshot;
116
- applyTools(name, preset, ctx);
117
- setActive(name, ctx);
118
- ctx.ui.notify(`已切换预设「${name}」`, "info");
119
- }
120
-
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
- }
131
-
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");
138
- }
139
-
140
- async function activate(name: string, ctx: ExtensionContext): Promise<void> {
141
- const preset = presets[name];
142
- if (!preset) return;
143
- await applyPreset(name, preset, ctx);
144
- }
145
-
146
- async function clearPreset(ctx: ExtensionContext): Promise<void> {
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);
153
- }
154
- ctx.ui.notify("预设已清除,恢复默认", "info");
155
- }
156
-
157
- async function showSelector(ctx: ExtensionContext): Promise<void> {
158
- const names = Object.keys(presets);
159
- if (names.length === 0) {
160
- noPresetsHint(ctx);
161
- return;
162
- }
163
-
164
- const items: SelectItem[] = names.map((name) => ({
165
- value: name,
166
- label: name === activeName ? `${name}(当前)` : name,
167
- description: describe(presets[name]),
168
- }));
169
- items.push({
170
- value: CLEAR_ITEM,
171
- label: CLEAR_ITEM,
172
- description: "清除当前预设,恢复默认",
173
- });
174
-
175
- const choice = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
176
- const container = new Container();
177
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
178
- container.addChild(new Text(theme.fg("accent", theme.bold("选择预设"))));
179
-
180
- const selectList = new SelectList(items, Math.min(items.length, SELECTOR_MAX_ROWS), {
181
- selectedPrefix: (text) => theme.fg("accent", text),
182
- selectedText: (text) => theme.fg("accent", text),
183
- description: (text) => theme.fg("muted", text),
184
- scrollInfo: (text) => theme.fg("dim", text),
185
- noMatch: (text) => theme.fg("warning", text),
186
- });
187
- selectList.onSelect = (item) => done(item.value);
188
- selectList.onCancel = () => done(null);
189
- container.addChild(selectList);
190
- container.addChild(new Text(theme.fg("dim", "↑↓ 选择 • enter 确认 • esc 取消")));
191
- container.addChild(new DynamicBorder((str) => theme.fg("accent", str)));
192
-
193
- return {
194
- render: (width: number) => container.render(width),
195
- invalidate: () => container.invalidate(),
196
- handleInput(data: string) {
197
- selectList.handleInput(data);
198
- tui.requestRender();
199
- },
200
- };
201
- });
202
-
203
- if (!choice) return;
204
- if (choice === CLEAR_ITEM) await clearPreset(ctx);
205
- else await activate(choice, ctx);
206
- }
207
-
208
- const { presets: configured } = loadConfig().config;
209
-
210
- for (const [name, preset] of Object.entries(configured)) {
211
- if (!preset.key) continue;
212
- pi.registerShortcut(preset.key as never, {
213
- description: `启用预设「${name}」`,
214
- handler: (ctx) => activate(name, ctx),
215
- });
216
- }
217
-
218
- pi.registerCommand("preset", {
219
- description: "切换预设",
220
- handler: async (args, ctx) => {
221
- const name = args?.trim();
222
- if (!name) {
223
- await showSelector(ctx);
224
- return;
225
- }
226
- // 与选择器同一个“无”项:清除当前预设。
227
- if (name === CLEAR_ITEM) {
228
- await clearPreset(ctx);
229
- return;
230
- }
231
- if (!presets[name]) {
232
- const available = Object.keys(presets).join(", ") || "(none defined)";
233
- ctx.ui.notify(`未知预设「${name}」,可用:${available}`, "error");
234
- return;
235
- }
236
- await activate(name, ctx);
237
- },
238
- });
239
-
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);
246
- if (!activePreset?.instructions) return;
247
- return { systemPrompt: `${event.systemPrompt}\n\n${activePreset.instructions}` };
248
- });
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
-
264
- pi.on("session_start", async (_event, ctx) => {
265
- reset();
266
- // 配置问题由 index.ts 统一提示,这里只取预设。
267
- presets = loadConfig().config.presets;
268
-
269
- const flag = pi.getFlag("preset");
270
- if (typeof flag === "string" && flag) {
271
- if (presets[flag]) {
272
- await applyPreset(flag, presets[flag], ctx);
273
- } else {
274
- const available = Object.keys(presets).join(", ") || "(none defined)";
275
- ctx.ui.notify(`未知预设「${flag}」,可用:${available}`, "warning");
276
- }
277
- } else restore(ctx);
278
- updateStatus(ctx);
279
- });
280
-
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
- }
300
- }
package/session/quota.ts DELETED
@@ -1,123 +0,0 @@
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/rename.ts DELETED
@@ -1,46 +0,0 @@
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
- }