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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Suge8
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# FireCode
|
|
2
|
+
|
|
3
|
+
FireCode 是一个模块化 Pi Package,提供终端状态与工具渲染、会话预设、`/fire-review` 对抗审查、`/fire-master` 进程内子代理委派和 `/fire-watch` 观察员。指挥官与观察员的新会话状态由配置决定,裸命令只翻转当前会话;各模块由功能开关独立注册,关闭任一模块不会改变其余模块。
|
|
4
|
+
|
|
5
|
+
## 安装
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install git:github.com/Suge8/firecode@v0.6.1
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
也可在本地仓库中直接试用:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pi -e .
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Pi Package 拥有与 Pi 相同的本机权限;安装前应审阅源码。
|
|
18
|
+
|
|
19
|
+
## 配置
|
|
20
|
+
|
|
21
|
+
运行配置不随包分发。将公开模板复制到 Pi Agent 目录后按需启用功能,再重启 Pi:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
agent_dir="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
|
25
|
+
mkdir -p "$agent_dir/extensions/firecode"
|
|
26
|
+
curl -fsSL https://raw.githubusercontent.com/Suge8/firecode/v0.6.1/config.example.jsonc \
|
|
27
|
+
-o "$agent_dir/extensions/firecode/config.jsonc"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
公开模板提供维护者当前的完整推荐配置:除 Bark 外功能全开,Master 与 Watcher 在新会话自动激活。Watcher 会在每个主会话回合后调用模型,OpenAI priority 会按供应商规则加价;复制前应确认列出的模型均已认证并接受额外费用。审查与观察员的模型必须显式写入运行配置,否则对应功能拒绝启动。缺少运行配置时,FireCode 会关闭可选功能并在会话启动时警告;配置模板本身不会被运行时读取。
|
|
31
|
+
|
|
32
|
+
## 开发
|
|
33
|
+
|
|
34
|
+
需要 [Bun](https://bun.sh/) 和一个 pi-mono checkout。开发版 `pi` 在 `PATH` 中时,测试会自动定位它;否则设置 `PI_PACKAGES_DIR` 为 pi-mono 的 `packages/` 目录。
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
bun test
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
模块边界、状态机约束和领域术语见 `AGENTS.md`、各模块的 `AGENTS.md` 与 `CONTEXT.md`。
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
{
|
|
2
|
+
// 公开推荐配置:除 Bark 外功能全开。复制后重启 Pi。
|
|
3
|
+
// Watcher 默认在每个主会话回合后调用模型;OpenAI priority 会按供应商规则加价。
|
|
4
|
+
"features": {
|
|
5
|
+
"header": true,
|
|
6
|
+
"statusbar": true,
|
|
7
|
+
"tools": true,
|
|
8
|
+
"presets": true,
|
|
9
|
+
"rename": true,
|
|
10
|
+
"stats": true,
|
|
11
|
+
"claudeSub": true,
|
|
12
|
+
"openaiNative": true,
|
|
13
|
+
"workingFlame": true,
|
|
14
|
+
"bark": false,
|
|
15
|
+
"review": true,
|
|
16
|
+
"master": true,
|
|
17
|
+
"watcher": true
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
"keys": {
|
|
21
|
+
"rename": "ctrl+r",
|
|
22
|
+
"cyclePreset": "ctrl+shift+u",
|
|
23
|
+
"fast": "ctrl+f"
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
// nativeCompaction 使用 OpenAI 原生上下文压缩;priority 加钱换更快出字。
|
|
27
|
+
"openai": {
|
|
28
|
+
"nativeCompaction": true,
|
|
29
|
+
"providers": {
|
|
30
|
+
"openai-codex": { "textVerbosity": "low", "priority": true },
|
|
31
|
+
"xai": { "priority": true }
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
// 每个主会话回合后评估新增内容,会产生额外模型调用;需要开口时当场插话。
|
|
36
|
+
"watcher": {
|
|
37
|
+
"enabled": true,
|
|
38
|
+
"model": "openai-codex/gpt-5.6-sol",
|
|
39
|
+
"thinking": "low",
|
|
40
|
+
"context": "minimal"
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
// 新会话默认激活指挥官;每个模型原子必须是 provider/model/thinking。
|
|
44
|
+
"master": {
|
|
45
|
+
"autoActivate": true,
|
|
46
|
+
"workerExcludeExtensions": [],
|
|
47
|
+
"roles": {
|
|
48
|
+
"调研员": {
|
|
49
|
+
"model": "openai-codex/gpt-5.6-luna/xhigh",
|
|
50
|
+
"use": "大规模并行调研,便宜,不承担实现"
|
|
51
|
+
},
|
|
52
|
+
"工程师": {
|
|
53
|
+
"model": "openai-codex/gpt-5.6-sol/medium",
|
|
54
|
+
"use": "常规实现与调试,速度快",
|
|
55
|
+
"fallback": ["anthropic/claude-opus-5/medium"]
|
|
56
|
+
},
|
|
57
|
+
"全栈": {
|
|
58
|
+
"model": "anthropic/claude-opus-5/medium",
|
|
59
|
+
"use": "前后端综合实现与调试",
|
|
60
|
+
"fallback": ["openai-codex/gpt-5.6-sol/medium"]
|
|
61
|
+
},
|
|
62
|
+
"架构师": {
|
|
63
|
+
"model": "anthropic/claude-fable-5/high",
|
|
64
|
+
"use": "高级架构顾问;费用高,启用前必须先征求用户确认"
|
|
65
|
+
},
|
|
66
|
+
"设计师": {
|
|
67
|
+
"model": "kimi-coding/k3-256k/high",
|
|
68
|
+
"use": "纯视觉设计;已有固定组件和设计方案时不适用"
|
|
69
|
+
},
|
|
70
|
+
"哨兵": {
|
|
71
|
+
"model": "openai-codex/gpt-5.6-luna/low",
|
|
72
|
+
"use": "盯守 CI、部署与长测试等等待过程"
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
"presets": {
|
|
78
|
+
"fable": { "provider": "anthropic", "model": "claude-fable-5", "thinkingLevel": "high", "key": "alt+1" },
|
|
79
|
+
"opus5": { "provider": "anthropic", "model": "claude-opus-5", "thinkingLevel": "medium", "key": "alt+2" },
|
|
80
|
+
"sol": { "provider": "openai-codex", "model": "gpt-5.6-sol", "thinkingLevel": "medium", "key": "alt+3" },
|
|
81
|
+
"gemini": { "provider": "antigravity", "model": "gemini-3.7-flash", "thinkingLevel": "high", "key": "alt+4" },
|
|
82
|
+
"ds": { "provider": "deepseek", "model": "deepseek-v4-flash", "thinkingLevel": "high", "key": "alt+5" },
|
|
83
|
+
"k3-256": { "provider": "kimi-coding", "model": "k3-256k", "thinkingLevel": "high", "key": "alt+6" },
|
|
84
|
+
"xai": { "provider": "xai", "model": "grok-4.6", "thinkingLevel": "high", "key": "alt+7" }
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// 审查只在用户或指挥官发起时调用模型。
|
|
88
|
+
"review": {
|
|
89
|
+
"advisor": { "model": "anthropic/claude-fable-5", "thinking": "high" },
|
|
90
|
+
"reviewers": [
|
|
91
|
+
{ "model": "anthropic/claude-sonnet-5", "thinking": "medium" },
|
|
92
|
+
{ "model": "anthropic/claude-opus-5", "thinking": "medium" }
|
|
93
|
+
],
|
|
94
|
+
"maxRounds": 5,
|
|
95
|
+
"advisorAfterFailures": 2,
|
|
96
|
+
"timeoutMinutes": 20,
|
|
97
|
+
"tools": ["read", "grep", "find", "ls", "bash"],
|
|
98
|
+
"language": "zh"
|
|
99
|
+
}
|
|
100
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
/** FireCode 配置:只读 Pi Agent 目录下的 `extensions/firecode/config.jsonc`。 */
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { parseJsonc } from "./jsonc.js";
|
|
6
|
+
|
|
7
|
+
export type Language = "zh" | "en";
|
|
8
|
+
export type ThinkingLevelValue =
|
|
9
|
+
| "off"
|
|
10
|
+
| "minimal"
|
|
11
|
+
| "low"
|
|
12
|
+
| "medium"
|
|
13
|
+
| "high"
|
|
14
|
+
| "xhigh"
|
|
15
|
+
| "max";
|
|
16
|
+
|
|
17
|
+
export interface Preset {
|
|
18
|
+
provider?: string;
|
|
19
|
+
model?: string;
|
|
20
|
+
thinkingLevel?: ThinkingLevelValue;
|
|
21
|
+
tools?: string[];
|
|
22
|
+
instructions?: string;
|
|
23
|
+
/** 一键切换,如 alt+1;不填则无快捷键 */
|
|
24
|
+
key?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ReviewModel {
|
|
28
|
+
model: string;
|
|
29
|
+
thinking: ThinkingLevelValue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** /fire-review 配置:审查者 / 顾问模型 + 循环限制。见 config.jsonc 的 review 节注释。 */
|
|
33
|
+
export interface ReviewConfig {
|
|
34
|
+
advisor: ReviewModel;
|
|
35
|
+
reviewers: ReviewModel[];
|
|
36
|
+
/** 审查轮数硬上限。 */
|
|
37
|
+
maxRounds: number;
|
|
38
|
+
/** 连续几轮失败触发顾问仲裁。 */
|
|
39
|
+
advisorAfterFailures: number;
|
|
40
|
+
/** 单个审查者 / 顾问会话超时(分钟)。 */
|
|
41
|
+
timeoutMinutes: number;
|
|
42
|
+
/** 审查者只读工具白名单。 */
|
|
43
|
+
tools: string[];
|
|
44
|
+
language: Language;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Master 角色表条目:原子模型已拆成运行时模型与思考档。 */
|
|
48
|
+
export interface MasterModelAtom {
|
|
49
|
+
model: string;
|
|
50
|
+
thinking: ThinkingLevelValue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const MASTER_ROLES = ["调研员", "工程师", "全栈", "架构师", "设计师", "哨兵"] as const;
|
|
54
|
+
export type MasterRoleName = (typeof MASTER_ROLES)[number];
|
|
55
|
+
|
|
56
|
+
export interface MasterRole extends MasterModelAtom {
|
|
57
|
+
role: MasterRoleName;
|
|
58
|
+
use: string;
|
|
59
|
+
fallback: MasterModelAtom[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface MasterConfig {
|
|
63
|
+
roles: MasterRole[];
|
|
64
|
+
workerExcludeExtensions: string[];
|
|
65
|
+
autoActivate: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 观察员喂给观察会话的增量粒度:minimal 省略 reasoning 与 diff 正文。 */
|
|
69
|
+
export type WatcherContext = "minimal" | "full";
|
|
70
|
+
|
|
71
|
+
/** Watcher 观察员配置:模型与 thinking 必须显式配置,绝不回退默认模型。 */
|
|
72
|
+
export interface WatcherConfig {
|
|
73
|
+
enabled: boolean;
|
|
74
|
+
model: string;
|
|
75
|
+
thinking: ThinkingLevelValue;
|
|
76
|
+
context: WatcherContext;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export const FEATURES = [
|
|
80
|
+
"header",
|
|
81
|
+
"statusbar",
|
|
82
|
+
"tools",
|
|
83
|
+
"presets",
|
|
84
|
+
"rename",
|
|
85
|
+
"stats",
|
|
86
|
+
"claudeSub",
|
|
87
|
+
"openaiNative",
|
|
88
|
+
"workingFlame",
|
|
89
|
+
"bark",
|
|
90
|
+
"review",
|
|
91
|
+
"master",
|
|
92
|
+
"watcher",
|
|
93
|
+
] as const;
|
|
94
|
+
|
|
95
|
+
export type Feature = (typeof FEATURES)[number];
|
|
96
|
+
|
|
97
|
+
export const DEFAULT_KEYS = {
|
|
98
|
+
rename: "ctrl+r",
|
|
99
|
+
cyclePreset: "ctrl+shift+u",
|
|
100
|
+
fast: "ctrl+f",
|
|
101
|
+
} as const;
|
|
102
|
+
|
|
103
|
+
export type FireCodeKeys = {
|
|
104
|
+
rename: string;
|
|
105
|
+
cyclePreset: string;
|
|
106
|
+
fast: string;
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
export interface FireCodeConfig {
|
|
110
|
+
features: Partial<Record<Feature, boolean>>;
|
|
111
|
+
keys: FireCodeKeys;
|
|
112
|
+
presets: Record<string, Preset>;
|
|
113
|
+
review: ReviewConfig;
|
|
114
|
+
master: MasterConfig;
|
|
115
|
+
watcher: WatcherConfig;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export type LoadedConfig = {
|
|
119
|
+
config: FireCodeConfig;
|
|
120
|
+
problems: string[];
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const CONFIG_PATH = join(getAgentDir(), "extensions", "firecode", "config.jsonc");
|
|
124
|
+
|
|
125
|
+
function readFile(problems: string[]): Record<string, unknown> {
|
|
126
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
127
|
+
problems.push("config.jsonc 不存在,已关闭可选功能");
|
|
128
|
+
return { features: Object.fromEntries(FEATURES.map((feature) => [feature, false])) };
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
const parsed: unknown = parseJsonc(readFileSync(CONFIG_PATH, "utf8"));
|
|
132
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
133
|
+
problems.push("config.jsonc 顶层必须是对象");
|
|
134
|
+
return {};
|
|
135
|
+
}
|
|
136
|
+
return parsed as Record<string, unknown>;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
// 统一前缀:文件级故障必须能被调用方识别并阻断功能,
|
|
139
|
+
// 不能因为消息文本不带节名就被当成无关问题过滤掉。
|
|
140
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
141
|
+
problems.push(`config.jsonc 解析失败:${message}`);
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
147
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function asRecord(value: unknown): Record<string, unknown> {
|
|
151
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
152
|
+
? (value as Record<string, unknown>)
|
|
153
|
+
: {};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function checkFeatures(features: Record<string, unknown>, problems: string[]): void {
|
|
157
|
+
for (const [key, value] of Object.entries(features)) {
|
|
158
|
+
if (!FEATURES.includes(key as Feature)) {
|
|
159
|
+
problems.push(`未知开关 features.${key},可用:${FEATURES.join(" / ")}`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// 开关只能是布尔:写成字符串 "false" 时因为 `!== false` 仍会启用,
|
|
163
|
+
// 而启用 review 意味着真实的模型调用,不能静默放行。
|
|
164
|
+
if (typeof value !== "boolean")
|
|
165
|
+
problems.push(`features.${key} 必须是 true 或 false`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function checkKeys(keys: FireCodeKeys, presets: Record<string, Preset>, problems: string[]): void {
|
|
170
|
+
const owners = new Map<string, string>([
|
|
171
|
+
[keys.rename, "keys.rename"],
|
|
172
|
+
[keys.cyclePreset, "keys.cyclePreset"],
|
|
173
|
+
[keys.fast, "keys.fast"],
|
|
174
|
+
]);
|
|
175
|
+
const declared = Object.entries(keys);
|
|
176
|
+
for (let index = 0; index < declared.length; index++) {
|
|
177
|
+
for (let other = index + 1; other < declared.length; other++) {
|
|
178
|
+
if (declared[index][1] === declared[other][1]) {
|
|
179
|
+
problems.push(
|
|
180
|
+
`快捷键 ${declared[index][1]} 被 keys.${declared[index][0]} 和 keys.${declared[other][0]} 重复占用`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const [name, preset] of Object.entries(presets)) {
|
|
186
|
+
if (!preset?.key) continue;
|
|
187
|
+
const owner = owners.get(preset.key);
|
|
188
|
+
if (owner) problems.push(`快捷键 ${preset.key} 被 ${owner} 和预设 ${name} 重复占用`);
|
|
189
|
+
else owners.set(preset.key, `预设 ${name}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let cached: LoadedConfig | undefined;
|
|
194
|
+
|
|
195
|
+
export function loadConfig(): LoadedConfig {
|
|
196
|
+
if (cached) return cached;
|
|
197
|
+
|
|
198
|
+
const problems: string[] = [];
|
|
199
|
+
const raw = readFile(problems);
|
|
200
|
+
// features 省略表示沿用默认全开;只要显式写了,就必须是对象。
|
|
201
|
+
// 非对象不能回退成 {},因为 {} 在入口语义里正是「全部启用」。
|
|
202
|
+
const invalidFeatures = raw.features !== undefined && !isPlainObject(raw.features);
|
|
203
|
+
if (invalidFeatures) problems.push("features 必须是对象");
|
|
204
|
+
const features: Partial<Record<Feature, boolean>> = invalidFeatures
|
|
205
|
+
? Object.fromEntries(FEATURES.map((feature) => [feature, false]))
|
|
206
|
+
: asRecord(raw.features);
|
|
207
|
+
const rawKeys = asRecord(raw.keys);
|
|
208
|
+
const presets = asRecord(raw.presets) as Record<string, Preset>;
|
|
209
|
+
const keys: FireCodeKeys = {
|
|
210
|
+
rename: typeof rawKeys.rename === "string" ? rawKeys.rename : DEFAULT_KEYS.rename,
|
|
211
|
+
cyclePreset:
|
|
212
|
+
typeof rawKeys.cyclePreset === "string" ? rawKeys.cyclePreset : DEFAULT_KEYS.cyclePreset,
|
|
213
|
+
fast: typeof rawKeys.fast === "string" ? rawKeys.fast : DEFAULT_KEYS.fast,
|
|
214
|
+
};
|
|
215
|
+
checkFeatures(features, problems);
|
|
216
|
+
checkKeys(keys, presets, problems);
|
|
217
|
+
// review 写成字符串/数组/null 或缺字段时不能静默补齐:会拿用户未选择的模型真实发起审查。
|
|
218
|
+
const reviewProblems: string[] = [];
|
|
219
|
+
if (raw.review !== undefined && !isPlainObject(raw.review))
|
|
220
|
+
reviewProblems.push("review 必须是对象");
|
|
221
|
+
const review = parseReviewConfig(asRecord(raw.review), reviewProblems);
|
|
222
|
+
if (raw.review !== undefined || features.review !== false) problems.push(...reviewProblems);
|
|
223
|
+
// master 同理:角色表错误会拿错模型真实发起 Worker,不能静默当空对象。
|
|
224
|
+
if (raw.master !== undefined && !isPlainObject(raw.master))
|
|
225
|
+
problems.push("master 必须是对象");
|
|
226
|
+
const master = parseMasterConfig(asRecord(raw.master), problems);
|
|
227
|
+
// watcher 同理:缺节或模型有误时功能拒绝启动,静默回退会拿用户没配的模型真实发起观察。
|
|
228
|
+
const watcherProblems: string[] = [];
|
|
229
|
+
if (raw.watcher !== undefined && !isPlainObject(raw.watcher))
|
|
230
|
+
watcherProblems.push("watcher 必须是对象");
|
|
231
|
+
const watcher = parseWatcherConfig(asRecord(raw.watcher), watcherProblems);
|
|
232
|
+
if (raw.watcher !== undefined || features.watcher !== false) problems.push(...watcherProblems);
|
|
233
|
+
|
|
234
|
+
cached = { config: { features, keys, presets, review, master, watcher }, problems };
|
|
235
|
+
return cached;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---- review 节 ----
|
|
239
|
+
|
|
240
|
+
const REVIEW_KEYS = new Set([
|
|
241
|
+
"advisor",
|
|
242
|
+
"reviewers",
|
|
243
|
+
"maxRounds",
|
|
244
|
+
"advisorAfterFailures",
|
|
245
|
+
"timeoutMinutes",
|
|
246
|
+
"tools",
|
|
247
|
+
"language",
|
|
248
|
+
]);
|
|
249
|
+
const DEFAULT_TOOLS = ["read", "grep", "find", "ls", "bash"];
|
|
250
|
+
const EMPTY_REVIEW_MODEL: ReviewModel = { model: "", thinking: "medium" };
|
|
251
|
+
const THINKING_LEVELS = new Set<ThinkingLevelValue>([
|
|
252
|
+
"off",
|
|
253
|
+
"minimal",
|
|
254
|
+
"low",
|
|
255
|
+
"medium",
|
|
256
|
+
"high",
|
|
257
|
+
"xhigh",
|
|
258
|
+
"max",
|
|
259
|
+
]);
|
|
260
|
+
const LANGUAGES = new Set<Language>(["zh", "en"]);
|
|
261
|
+
|
|
262
|
+
/** 导出供测试:严格拒绝未知字段(含嵌套),类型错误一律记录而非静默回退。 */
|
|
263
|
+
export function parseReviewConfig(raw: Record<string, unknown>, problems: string[]): ReviewConfig {
|
|
264
|
+
for (const key of Object.keys(raw)) {
|
|
265
|
+
if (REVIEW_KEYS.has(key)) continue;
|
|
266
|
+
problems.push(key === "background"
|
|
267
|
+
? "review.background 已随审查子进程层删除,请直接移除该键"
|
|
268
|
+
: `未知字段 review.${key}`);
|
|
269
|
+
}
|
|
270
|
+
for (const key of REVIEW_KEYS)
|
|
271
|
+
if (!(key in raw)) problems.push(`review.${key} 必须显式配置`);
|
|
272
|
+
const advisor = reviewModel(raw.advisor, "review.advisor", EMPTY_REVIEW_MODEL, problems);
|
|
273
|
+
const reviewers = reviewModels(raw.reviewers, problems);
|
|
274
|
+
return {
|
|
275
|
+
advisor,
|
|
276
|
+
reviewers,
|
|
277
|
+
maxRounds: reviewInt(raw.maxRounds, "review.maxRounds", 5, 1, 10, problems),
|
|
278
|
+
advisorAfterFailures: reviewInt(raw.advisorAfterFailures, "review.advisorAfterFailures", 2, 1, 5, problems),
|
|
279
|
+
timeoutMinutes: reviewInt(raw.timeoutMinutes, "review.timeoutMinutes", 20, 1, 60, problems),
|
|
280
|
+
tools: reviewTools(raw.tools, problems),
|
|
281
|
+
language: reviewLanguage(raw.language, problems),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 嵌套对象也做键白名单:拼写错误必须报出来,不能静默回退默认值。 */
|
|
286
|
+
function rejectUnknownKeys(
|
|
287
|
+
record: Record<string, unknown>,
|
|
288
|
+
allowed: readonly string[],
|
|
289
|
+
field: string,
|
|
290
|
+
problems: string[],
|
|
291
|
+
) {
|
|
292
|
+
for (const key of Object.keys(record))
|
|
293
|
+
if (!allowed.includes(key)) problems.push(`未知字段 ${field}.${key}`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function reviewModel(
|
|
297
|
+
value: unknown,
|
|
298
|
+
field: string,
|
|
299
|
+
fallback: ReviewModel,
|
|
300
|
+
problems: string[],
|
|
301
|
+
): ReviewModel {
|
|
302
|
+
if (value === undefined) return fallback;
|
|
303
|
+
const record = asRecord(value);
|
|
304
|
+
rejectUnknownKeys(record, ["model", "thinking"], field, problems);
|
|
305
|
+
if (!record.model || typeof record.model !== "string") {
|
|
306
|
+
problems.push(`${field}.model 必须是非空字符串`);
|
|
307
|
+
return fallback;
|
|
308
|
+
}
|
|
309
|
+
const thinking =
|
|
310
|
+
typeof record.thinking === "string" && THINKING_LEVELS.has(record.thinking as ThinkingLevelValue)
|
|
311
|
+
? (record.thinking as ThinkingLevelValue)
|
|
312
|
+
: fallback.thinking;
|
|
313
|
+
if (thinking !== record.thinking) problems.push(`${field}.thinking 值无效`);
|
|
314
|
+
return { model: record.model, thinking };
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function reviewModels(value: unknown, problems: string[]): ReviewModel[] {
|
|
318
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 5) {
|
|
319
|
+
if (value !== undefined) problems.push("review.reviewers 必须包含 1–5 个模型");
|
|
320
|
+
return [];
|
|
321
|
+
}
|
|
322
|
+
return value.map((item, index) =>
|
|
323
|
+
reviewModel(item, `review.reviewers[${index}]`, EMPTY_REVIEW_MODEL, problems),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function reviewInt(
|
|
328
|
+
value: unknown,
|
|
329
|
+
field: string,
|
|
330
|
+
fallback: number,
|
|
331
|
+
min: number,
|
|
332
|
+
max: number,
|
|
333
|
+
problems: string[],
|
|
334
|
+
): number {
|
|
335
|
+
if (value === undefined) return fallback;
|
|
336
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
|
|
337
|
+
problems.push(`${field} 必须是 ${min}–${max} 的整数`);
|
|
338
|
+
return fallback;
|
|
339
|
+
}
|
|
340
|
+
return value;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function reviewTools(value: unknown, problems: string[]): string[] {
|
|
344
|
+
if (value === undefined) return [...DEFAULT_TOOLS];
|
|
345
|
+
if (!Array.isArray(value)) {
|
|
346
|
+
problems.push("review.tools 必须是字符串数组");
|
|
347
|
+
return [...DEFAULT_TOOLS];
|
|
348
|
+
}
|
|
349
|
+
const tools = value.filter((item): item is string => typeof item === "string" && item.length > 0);
|
|
350
|
+
if (tools.length !== value.length || tools.length === 0)
|
|
351
|
+
problems.push("review.tools 必须是非空字符串数组");
|
|
352
|
+
return tools.length > 0 ? tools : [...DEFAULT_TOOLS];
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// ---- master 节 ----
|
|
356
|
+
|
|
357
|
+
/** 导出供测试:与 review 节同样严格拒绝未知字段,类型错误记录而非静默回退。 */
|
|
358
|
+
export function parseMasterConfig(raw: Record<string, unknown>, problems: string[]): MasterConfig {
|
|
359
|
+
for (const key of Object.keys(raw))
|
|
360
|
+
if (key !== "roles" && key !== "workerExcludeExtensions" && key !== "autoActivate")
|
|
361
|
+
problems.push(`未知字段 master.${key}`);
|
|
362
|
+
const exclusions = stringArray(raw.workerExcludeExtensions, "master.workerExcludeExtensions", problems);
|
|
363
|
+
const autoActivate = booleanValue(raw.autoActivate, "master.autoActivate", true, problems);
|
|
364
|
+
if (raw.roles === undefined)
|
|
365
|
+
return { roles: [], workerExcludeExtensions: exclusions, autoActivate };
|
|
366
|
+
if (!isPlainObject(raw.roles) || Object.keys(raw.roles).length === 0) {
|
|
367
|
+
problems.push("master.roles 必须是至少包含一个固定角色的对象");
|
|
368
|
+
return { roles: [], workerExcludeExtensions: exclusions, autoActivate };
|
|
369
|
+
}
|
|
370
|
+
const configured = raw.roles;
|
|
371
|
+
for (const role of Object.keys(configured))
|
|
372
|
+
if (!MASTER_ROLES.includes(role as MasterRoleName))
|
|
373
|
+
problems.push(`未知角色 master.roles.${role},可用:${MASTER_ROLES.join(" / ")}`);
|
|
374
|
+
const roles = MASTER_ROLES.flatMap((role) =>
|
|
375
|
+
Object.hasOwn(configured, role)
|
|
376
|
+
? [masterRole(configured[role], `master.roles.${role}`, role, problems)]
|
|
377
|
+
: []);
|
|
378
|
+
return { roles, workerExcludeExtensions: exclusions, autoActivate };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function booleanValue(value: unknown, field: string, fallback: boolean, problems: string[]): boolean {
|
|
382
|
+
if (value === undefined) return fallback;
|
|
383
|
+
if (typeof value === "boolean") return value;
|
|
384
|
+
problems.push(`${field} 必须是 true 或 false`);
|
|
385
|
+
return fallback;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function stringArray(value: unknown, field: string, problems: string[]): string[] {
|
|
389
|
+
if (value === undefined) return [];
|
|
390
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item)) {
|
|
391
|
+
problems.push(`${field} 必须是非空字符串数组`);
|
|
392
|
+
return [];
|
|
393
|
+
}
|
|
394
|
+
return [...new Set(value)];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function masterRole(
|
|
398
|
+
value: unknown,
|
|
399
|
+
field: string,
|
|
400
|
+
role: MasterRoleName,
|
|
401
|
+
problems: string[],
|
|
402
|
+
): MasterRole {
|
|
403
|
+
const record = asRecord(value);
|
|
404
|
+
rejectUnknownKeys(record, ["model", "use", "fallback"], field, problems);
|
|
405
|
+
const atom = masterModelAtom(record.model, `${field}.model`, problems);
|
|
406
|
+
const use = typeof record.use === "string" && record.use ? record.use : "";
|
|
407
|
+
if (!use) problems.push(`${field}.use 必须是非空字符串`);
|
|
408
|
+
const fallback = masterFallback(record.fallback, `${field}.fallback`, problems);
|
|
409
|
+
return { role, ...atom, use, fallback };
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function masterFallback(value: unknown, field: string, problems: string[]): MasterModelAtom[] {
|
|
413
|
+
if (value === undefined) return [];
|
|
414
|
+
if (!Array.isArray(value) || value.length > 2) {
|
|
415
|
+
problems.push(`${field} 必须是至多 2 项的数组`);
|
|
416
|
+
return [];
|
|
417
|
+
}
|
|
418
|
+
return value.map((item, index) => masterModelAtom(item, `${field}[${index}]`, problems));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function masterModelAtom(value: unknown, field: string, problems: string[]): MasterModelAtom {
|
|
422
|
+
if (typeof value !== "string" || !value) {
|
|
423
|
+
problems.push(`${field} 必须是“供应商/模型/思考档”字符串`);
|
|
424
|
+
return { model: "", thinking: "medium" };
|
|
425
|
+
}
|
|
426
|
+
const slash = value.lastIndexOf("/");
|
|
427
|
+
const model = slash > 0 ? value.slice(0, slash) : "";
|
|
428
|
+
const thinking = slash > 0 ? value.slice(slash + 1) : value;
|
|
429
|
+
if (!THINKING_LEVELS.has(thinking as ThinkingLevelValue))
|
|
430
|
+
problems.push(`${field} 思考档无效:${thinking}`);
|
|
431
|
+
const providerSlash = model.indexOf("/");
|
|
432
|
+
if (providerSlash <= 0 || providerSlash === model.length - 1)
|
|
433
|
+
problems.push(`${field} 模型无效:必须是 provider/model`);
|
|
434
|
+
return {
|
|
435
|
+
model,
|
|
436
|
+
thinking: THINKING_LEVELS.has(thinking as ThinkingLevelValue)
|
|
437
|
+
? thinking as ThinkingLevelValue
|
|
438
|
+
: "medium",
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ---- watcher 节 ----
|
|
443
|
+
|
|
444
|
+
const WATCHER_KEYS = ["enabled", "model", "thinking", "context"] as const;
|
|
445
|
+
const WATCHER_CONTEXTS = new Set<WatcherContext>(["minimal", "full"]);
|
|
446
|
+
|
|
447
|
+
/** 导出供测试:model/thinking 必填,enabled 默认 true、context 默认 minimal。 */
|
|
448
|
+
export function parseWatcherConfig(raw: Record<string, unknown>, problems: string[]): WatcherConfig {
|
|
449
|
+
rejectUnknownKeys(raw, WATCHER_KEYS, "watcher", problems);
|
|
450
|
+
const enabled = booleanValue(raw.enabled, "watcher.enabled", true, problems);
|
|
451
|
+
const model = typeof raw.model === "string" && raw.model ? raw.model : "";
|
|
452
|
+
if (!model) problems.push("watcher.model 必须显式配置为非空字符串");
|
|
453
|
+
let thinking: ThinkingLevelValue | undefined;
|
|
454
|
+
if (raw.thinking === undefined) problems.push("watcher.thinking 必须显式配置");
|
|
455
|
+
else if (typeof raw.thinking === "string" && THINKING_LEVELS.has(raw.thinking as ThinkingLevelValue))
|
|
456
|
+
thinking = raw.thinking as ThinkingLevelValue;
|
|
457
|
+
else problems.push("watcher.thinking 值无效");
|
|
458
|
+
let context: WatcherContext = "minimal";
|
|
459
|
+
if (raw.context !== undefined) {
|
|
460
|
+
if (typeof raw.context === "string" && WATCHER_CONTEXTS.has(raw.context as WatcherContext))
|
|
461
|
+
context = raw.context as WatcherContext;
|
|
462
|
+
else problems.push("watcher.context 必须是 minimal 或 full");
|
|
463
|
+
}
|
|
464
|
+
return { enabled, model, thinking: thinking ?? "low", context };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
function reviewLanguage(value: unknown, problems: string[]): Language {
|
|
468
|
+
if (value === undefined) return "zh";
|
|
469
|
+
if (typeof value !== "string" || !LANGUAGES.has(value as Language)) {
|
|
470
|
+
problems.push("review.language 必须是 zh 或 en");
|
|
471
|
+
return "zh";
|
|
472
|
+
}
|
|
473
|
+
return value as Language;
|
|
474
|
+
}
|
package/deliver.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一投递入口(Master 事件与观察员发言共用):宿主流式中投自定义卡片、经
|
|
3
|
+
* steer 队列在句缝送达;会话歇透时改走 sendUserMessage 前门唤起——宿主的
|
|
4
|
+
* triggerTurn 唤醒会跳过 before_agent_start(上游缺陷,#33),前门唤醒自带
|
|
5
|
+
* 完整开跑仪式,系统提示注入不随回合抖动。
|
|
6
|
+
*
|
|
7
|
+
* 忙闲判断与发送必须在同一事件循环节拍内完成,两者之间禁止 await:会话落定
|
|
8
|
+
* 是下一节拍的事件,同节拍读到的忙闲不会骑墙;宿主在回合结束前清空 steer
|
|
9
|
+
* 队列,忙时入队的消息以同回合续跑送达,不会沦为唤醒者。
|
|
10
|
+
*/
|
|
11
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
12
|
+
|
|
13
|
+
export interface Envelope<T> {
|
|
14
|
+
customType: string;
|
|
15
|
+
content: string;
|
|
16
|
+
details?: T;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function deliver<T>(
|
|
20
|
+
pi: ExtensionAPI,
|
|
21
|
+
ctx: ExtensionContext,
|
|
22
|
+
envelope: Envelope<T>,
|
|
23
|
+
): Promise<void> {
|
|
24
|
+
if (ctx.isIdle?.() === true) {
|
|
25
|
+
await pi.sendUserMessage(envelope.content);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
pi.sendMessage<T>(
|
|
29
|
+
{ customType: envelope.customType, content: envelope.content, display: true, details: envelope.details },
|
|
30
|
+
{ deliverAs: "steer" },
|
|
31
|
+
);
|
|
32
|
+
}
|