@master.g/pi-wingman 0.0.1 → 0.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -4
- package/advisor.ts +15 -7
- package/audit.ts +21 -11
- package/config.ts +17 -7
- package/editor.ts +4 -5
- package/headsup.ts +12 -6
- package/hint.ts +2 -1
- package/index.ts +88 -41
- package/llm.ts +14 -5
- package/package.json +2 -1
- package/picker.ts +42 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
| **advisor** | oh-my-pi advisor | 指定一个模型当顾问,读主 agent 的每个 turn,以 steer 消息内联注入便签(aside / concern / blocker)让主 agent 纠偏 |
|
|
11
11
|
| **hint** | Claude Code prompt suggestions | 任务结束后按上下文与目标生成下一步 prompt,输入框 ghost text 呈现:Tab 填入、Enter 直接发送 |
|
|
12
12
|
|
|
13
|
-
>
|
|
13
|
+
> 状态:早期版本(v0.0.3)。hint、advisor、heads-up 三个功能均已实现,默认关闭,尚在试用打磨中。
|
|
14
14
|
|
|
15
15
|
## 安装
|
|
16
16
|
|
|
@@ -27,9 +27,11 @@ pi --extension ~/github/pi-wingman/index.ts
|
|
|
27
27
|
## 命令
|
|
28
28
|
|
|
29
29
|
- `/wingman` — 查看三个功能的状态与模型
|
|
30
|
-
- `/advisor
|
|
30
|
+
- `/wingman advisor|hint|headsup` — 不带值时弹出 pi 的模型选择器(同 `/model`,可搜索),选完模型再选思考强度(同 `/thinking`,Esc 跳过);也可直接写 `<provider/model> [effort]`(模型须存在,effort 须是该模型支持的档位)、`on` / `off`(开关,保留已设模型与 effort);参数支持补全
|
|
31
31
|
- `/ask-advisor <问题>` — 就当前上下文咨询顾问模型,回答注入对话
|
|
32
32
|
|
|
33
|
+
advisor 与 heads-up 审计通过(ok)时不打扰,只在底栏状态显示 `advisor ⋯`(审计中)、`advisor ✓ ok`(结果级别)或 `advisor ✗ 原因`(调用失败);自定义 footer 扩展需要自己渲染扩展状态才看得到。
|
|
34
|
+
|
|
33
35
|
## 配置
|
|
34
36
|
|
|
35
37
|
`<agentDir>/wingman.json`(默认 `~/.pi/agent/wingman.json`),由命令自动写入,也可手改:
|
|
@@ -37,12 +39,12 @@ pi --extension ~/github/pi-wingman/index.ts
|
|
|
37
39
|
```json
|
|
38
40
|
{
|
|
39
41
|
"headsup": { "enabled": false },
|
|
40
|
-
"advisor": { "enabled": true, "model": "openai-codex/gpt-5.5" },
|
|
42
|
+
"advisor": { "enabled": true, "model": "openai-codex/gpt-5.5", "effort": "low" },
|
|
41
43
|
"hint": { "enabled": false }
|
|
42
44
|
}
|
|
43
45
|
```
|
|
44
46
|
|
|
45
|
-
三个功能默认都关闭:每个功能都会额外调用模型,需要用户自己开启。模型形如 `provider/model-id`;hint 没配模型时用当前模型(`/hint on`),advisor/heads-up
|
|
47
|
+
三个功能默认都关闭:每个功能都会额外调用模型,需要用户自己开启。模型形如 `provider/model-id`;hint 没配模型时用当前模型(`/wingman hint on`),advisor/heads-up 必须配模型才会激活。`effort` 可选(off / minimal / low / medium / high / xhigh / max,按模型支持的档位校正),不设则不开推理。advisor/heads-up 在后台跑,单次调用超时 60 秒;hint 要求快,超时 10 秒,高 effort 多半超时,超时就不出 hint。
|
|
46
48
|
|
|
47
49
|
## License
|
|
48
50
|
|
package/advisor.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import { Auditor, buildDigest, type DigestMessage, type VerdictLevel } from "./audit.js";
|
|
16
|
+
import { auditStatus, Auditor, buildDigest, type DigestMessage, type VerdictLevel } from "./audit.js";
|
|
17
17
|
import type { WingmanConfig } from "./config.js";
|
|
18
18
|
import { completeText, messageText, resolveModel, type AnyModel, type ModelRegistry } from "./llm.js";
|
|
19
19
|
|
|
@@ -52,6 +52,8 @@ export class AdvisorState {
|
|
|
52
52
|
|
|
53
53
|
const ASK_SYSTEM_PROMPT = `你是资深编程顾问,在旁观察一个编程 agent 的工作。给你用户的目标和 agent 近期动作摘要,以及用户现在问你的问题。直接回答问题本身,简洁具体,≤300 字,用中文。`;
|
|
54
54
|
|
|
55
|
+
const STATUS_KEY = "wingman-advisor";
|
|
56
|
+
|
|
55
57
|
const NOTIFY_TYPE: Record<Exclude<VerdictLevel, "ok">, "info" | "warning" | "error"> = {
|
|
56
58
|
note: "info",
|
|
57
59
|
concern: "warning",
|
|
@@ -68,13 +70,19 @@ export function installAdvisor(pi: ExtensionAPI, getConfig: () => WingmanConfig,
|
|
|
68
70
|
let pendingDigests: string[] = [];
|
|
69
71
|
|
|
70
72
|
const audit = async (ctx: ExtensionContext, registry: ModelRegistry, model: AnyModel, digest: string) => {
|
|
71
|
-
|
|
73
|
+
ctx.ui.setStatus(STATUS_KEY, auditStatus("advisor"));
|
|
74
|
+
const result = await auditor.run(
|
|
72
75
|
registry,
|
|
73
76
|
model,
|
|
74
77
|
{ goal: getGoal(), digest },
|
|
75
|
-
|
|
78
|
+
// 后台审计不卡主流程,超时放宽给高 effort 留余地
|
|
79
|
+
{ timeoutMs: 60_000, signal: shutdown.signal, effort: getConfig().advisor.effort },
|
|
76
80
|
);
|
|
77
|
-
if (!
|
|
81
|
+
if (!result) return;
|
|
82
|
+
ctx.ui.setStatus(STATUS_KEY, auditStatus("advisor", result));
|
|
83
|
+
if (!result.ok) return;
|
|
84
|
+
const { verdict } = result;
|
|
85
|
+
if (verdict.level === "ok") return;
|
|
78
86
|
ctx.ui.notify(`advisor [${verdict.level}] ${verdict.summary}`, NOTIFY_TYPE[verdict.level]);
|
|
79
87
|
if (ctx.isIdle()) return; // 裁决迟到:agent 已停,只提示用户,不注入
|
|
80
88
|
pi.sendUserMessage(formatAdvisorNote(verdict), { deliverAs: "steer" });
|
|
@@ -86,7 +94,7 @@ export function installAdvisor(pi: ExtensionAPI, getConfig: () => WingmanConfig,
|
|
|
86
94
|
|
|
87
95
|
pi.on("turn_end", (event, ctx) => {
|
|
88
96
|
const config = getConfig().advisor;
|
|
89
|
-
// advisor 必须显式配置模型才激活(开启必经 /advisor <model>);
|
|
97
|
+
// advisor 必须显式配置模型才激活(开启必经 /wingman advisor <model>);
|
|
90
98
|
// 未设或拼错模型都不回退当前模型,避免双份成本。
|
|
91
99
|
if (!config.enabled || !config.model) return;
|
|
92
100
|
if (!state.shouldAuditTurn()) return;
|
|
@@ -108,7 +116,7 @@ export function installAdvisor(pi: ExtensionAPI, getConfig: () => WingmanConfig,
|
|
|
108
116
|
handler: async (args, ctx) => {
|
|
109
117
|
const config = getConfig().advisor;
|
|
110
118
|
if (!config.enabled || !config.model) {
|
|
111
|
-
ctx.ui.notify("advisor 未启用,先 /advisor <provider/model> 配置", "error");
|
|
119
|
+
ctx.ui.notify("advisor 未启用,先 /wingman advisor <provider/model> 配置", "error");
|
|
112
120
|
return;
|
|
113
121
|
}
|
|
114
122
|
const question = args.trim();
|
|
@@ -144,7 +152,7 @@ export function installAdvisor(pi: ExtensionAPI, getConfig: () => WingmanConfig,
|
|
|
144
152
|
},
|
|
145
153
|
],
|
|
146
154
|
},
|
|
147
|
-
{ timeoutMs:
|
|
155
|
+
{ timeoutMs: 60_000, signal: shutdown.signal, effort: config.effort },
|
|
148
156
|
);
|
|
149
157
|
if (!result.ok) {
|
|
150
158
|
ctx.ui.notify(`advisor 调用失败:${result.reason}`, "error");
|
package/audit.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* undefined);失败一律静默(返回 undefined),不抛进 pi 事件循环。
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { completeText, messageText, type AnyModel, type Message, type ModelRegistry } from "./llm.js";
|
|
12
|
+
import { completeText, messageText, type AnyModel, type CallOptions, type Message, type ModelRegistry } from "./llm.js";
|
|
13
13
|
|
|
14
14
|
export type VerdictLevel = "ok" | "note" | "concern" | "blocker";
|
|
15
15
|
|
|
@@ -21,6 +21,15 @@ export interface Verdict {
|
|
|
21
21
|
details: string[];
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/** 一次审计的结果:成功带裁决,失败带原因(给底栏状态看)。 */
|
|
25
|
+
export type AuditResult = { ok: true; verdict: Verdict } | { ok: false; reason: string };
|
|
26
|
+
|
|
27
|
+
/** 底栏状态文本:不传结果为审计中,否则 ✓ 级别 / ✗ 原因。 */
|
|
28
|
+
export function auditStatus(feature: string, result?: AuditResult): string {
|
|
29
|
+
if (!result) return `${feature} ⋯`;
|
|
30
|
+
return result.ok ? `${feature} ✓ ${result.verdict.level}` : `${feature} ✗ ${result.reason.slice(0, 60)}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
24
33
|
export type DigestMessage = Message;
|
|
25
34
|
|
|
26
35
|
export interface DigestOptions {
|
|
@@ -92,8 +101,8 @@ export async function runAudit(
|
|
|
92
101
|
registry: ModelRegistry,
|
|
93
102
|
model: AnyModel,
|
|
94
103
|
input: { goal: string; digest: string },
|
|
95
|
-
options:
|
|
96
|
-
): Promise<
|
|
104
|
+
options: CallOptions = {},
|
|
105
|
+
): Promise<AuditResult> {
|
|
97
106
|
const goal = input.goal ? `用户目标:${input.goal}\n\n` : "";
|
|
98
107
|
const result = await completeText(
|
|
99
108
|
registry,
|
|
@@ -108,10 +117,11 @@ export async function runAudit(
|
|
|
108
117
|
},
|
|
109
118
|
],
|
|
110
119
|
},
|
|
111
|
-
|
|
120
|
+
options,
|
|
112
121
|
);
|
|
113
|
-
if (!result.ok) return
|
|
114
|
-
|
|
122
|
+
if (!result.ok) return result;
|
|
123
|
+
const verdict = parseVerdict(result.text);
|
|
124
|
+
return verdict ? { ok: true, verdict } : { ok: false, reason: `无法解析裁决:${result.text.slice(0, 40)}` };
|
|
115
125
|
}
|
|
116
126
|
|
|
117
127
|
/** 冷却 + 互斥的审计器:advisor(turn_end)与 heads-up(去抖)共用一个实例各管各的。 */
|
|
@@ -135,15 +145,15 @@ export class Auditor {
|
|
|
135
145
|
registry: ModelRegistry,
|
|
136
146
|
model: AnyModel,
|
|
137
147
|
input: { goal: string; digest: string },
|
|
138
|
-
options:
|
|
139
|
-
): Promise<
|
|
140
|
-
if (!this.ready) return undefined;
|
|
148
|
+
options: CallOptions = {},
|
|
149
|
+
): Promise<AuditResult | undefined> {
|
|
150
|
+
if (!this.ready) return undefined; // 冷却或在途:跳过
|
|
141
151
|
this.inFlight = true;
|
|
142
152
|
this.lastRunAt = this.now();
|
|
143
153
|
try {
|
|
144
154
|
return await runAudit(registry, model, input, options);
|
|
145
|
-
} catch {
|
|
146
|
-
return
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
147
157
|
} finally {
|
|
148
158
|
this.inFlight = false;
|
|
149
159
|
}
|
package/config.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* config.ts — wingman 配置:读写 <agentDir>/wingman.json(默认 ~/.pi/agent)。
|
|
3
3
|
*
|
|
4
4
|
* 三个功能各自独立开关,默认全关(旁路调用都有成本,须用户显式开启);
|
|
5
|
-
* 模型形如 "provider/model-id"
|
|
6
|
-
*
|
|
5
|
+
* 模型形如 "provider/model-id",effort 是可选思考强度。未配置模型时各模块
|
|
6
|
+
* 自行回退:hint 用当前模型,advisor/heads-up 保持关闭。
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
9
10
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
11
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
12
|
import { dirname, join } from "node:path";
|
|
@@ -13,8 +14,14 @@ import { dirname, join } from "node:path";
|
|
|
13
14
|
export interface FeatureConfig {
|
|
14
15
|
enabled: boolean;
|
|
15
16
|
model?: string;
|
|
17
|
+
effort?: ModelThinkingLevel;
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
export const EFFORTS: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
|
21
|
+
|
|
22
|
+
export const isEffort = (value: unknown): value is ModelThinkingLevel =>
|
|
23
|
+
EFFORTS.includes(value as ModelThinkingLevel);
|
|
24
|
+
|
|
18
25
|
export interface WingmanConfig {
|
|
19
26
|
headsup: FeatureConfig;
|
|
20
27
|
advisor: FeatureConfig;
|
|
@@ -37,6 +44,7 @@ function mergeFeature(base: FeatureConfig, override: unknown): FeatureConfig {
|
|
|
37
44
|
return {
|
|
38
45
|
enabled: typeof o.enabled === "boolean" ? o.enabled : base.enabled,
|
|
39
46
|
model: typeof o.model === "string" ? o.model : base.model,
|
|
47
|
+
effort: isEffort(o.effort) ? o.effort : base.effort,
|
|
40
48
|
};
|
|
41
49
|
}
|
|
42
50
|
|
|
@@ -60,27 +68,29 @@ export function saveConfig(config: WingmanConfig, path: string = configPath()):
|
|
|
60
68
|
writeFileSync(path, JSON.stringify(config, null, "\t") + "\n");
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
/** 状态行:"hint: on (provider/model)" / "hint: on (current model)" / "hint: off"。 */
|
|
71
|
+
/** 状态行:"hint: on (provider/model · high)" / "hint: on (current model)" / "hint: off"。 */
|
|
64
72
|
export function featureLine(name: string, feature: FeatureConfig): string {
|
|
65
|
-
const
|
|
73
|
+
const model = feature.model ?? "current model";
|
|
74
|
+
const state = feature.enabled ? `on (${feature.effort ? `${model} · ${feature.effort}` : model})` : "off";
|
|
66
75
|
return `${name}: ${state}`;
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
/**
|
|
70
|
-
* 应用配置命令参数:"off" 关闭、"on" 重新开启(
|
|
71
|
-
* "provider/model"
|
|
79
|
+
* 应用配置命令参数:"off" 关闭、"on" 重新开启(都保留已设模型与 effort),
|
|
80
|
+
* "provider/model" 开启并设模型与 effort(档位随模型变,不带即清空),空参只读。返回状态行;模型校验、
|
|
72
81
|
* saveConfig 与后续副作用(如编辑器同步)由调用方负责。
|
|
73
82
|
*/
|
|
74
83
|
export function applyFeatureArg(
|
|
75
84
|
config: WingmanConfig,
|
|
76
85
|
feature: keyof WingmanConfig,
|
|
77
86
|
arg: string,
|
|
87
|
+
effort?: ModelThinkingLevel,
|
|
78
88
|
): string {
|
|
79
89
|
const value = arg.trim();
|
|
80
90
|
if (value === "off" || value === "on") {
|
|
81
91
|
config[feature] = { ...config[feature], enabled: value === "on" };
|
|
82
92
|
} else if (value) {
|
|
83
|
-
config[feature] = { enabled: true, model: value };
|
|
93
|
+
config[feature] = { enabled: true, model: value, effort };
|
|
84
94
|
}
|
|
85
95
|
return featureLine(feature, config[feature]);
|
|
86
96
|
}
|
package/editor.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* editor.ts — WingmanEditor 与其安装管理(EditorManager)。
|
|
3
3
|
*
|
|
4
4
|
* WingmanEditor 是全扩展唯一的 CustomEditor(setEditorComponent 全局只能装
|
|
5
|
-
* 一个):hint
|
|
5
|
+
* 一个):hint 用输入行内联的暗色 ghost 文本(M1),heads-up 用数字键捕获(M3)。安装条件是
|
|
6
6
|
* hint.enabled || headsup.enabled;session_start 与配置命令变更后各 sync
|
|
7
7
|
* 一次,条件不变不动(避免重建丢掉 pendingHint)。
|
|
8
8
|
*/
|
|
@@ -32,10 +32,9 @@ export class WingmanEditor extends CustomEditor {
|
|
|
32
32
|
|
|
33
33
|
render(width: number): string[] {
|
|
34
34
|
const lines = super.render(width);
|
|
35
|
-
if (!this.pendingHint || this.getText().length > 0 || lines.length <
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
lines.splice(lines.length - 1, 0, ghost);
|
|
35
|
+
if (!this.pendingHint || this.getText().length > 0 || lines.length < 3) return lines;
|
|
36
|
+
// 空编辑器的唯一内容行紧跟上边框:去掉行尾填充,把 hint 以暗色接在光标后
|
|
37
|
+
lines[1] = truncateToWidth(`${lines[1].replace(/ +$/, "")}\x1b[2m${this.pendingHint}\x1b[22m`, width);
|
|
39
38
|
return lines;
|
|
40
39
|
}
|
|
41
40
|
|
package/headsup.ts
CHANGED
|
@@ -20,7 +20,7 @@ import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
|
20
20
|
import { appendFileSync } from "node:fs";
|
|
21
21
|
import { tmpdir } from "node:os";
|
|
22
22
|
import { join } from "node:path";
|
|
23
|
-
import { Auditor, type Verdict, type VerdictLevel } from "./audit.js";
|
|
23
|
+
import { auditStatus, Auditor, type Verdict, type VerdictLevel } from "./audit.js";
|
|
24
24
|
import type { WingmanConfig } from "./config.js";
|
|
25
25
|
import type { EditorManager } from "./editor.js";
|
|
26
26
|
import { resolveModel } from "./llm.js";
|
|
@@ -31,6 +31,7 @@ const WIDGET_KEY = "wingman-headsup";
|
|
|
31
31
|
const LEVEL_COLOR: Partial<Record<VerdictLevel, ThemeColor>> = { concern: "warning", blocker: "error" };
|
|
32
32
|
/** 冷却期内最多积压的工具事件数,超出丢最旧的。 */
|
|
33
33
|
const MAX_PENDING = 30;
|
|
34
|
+
const STATUS_KEY = "wingman-headsup";
|
|
34
35
|
|
|
35
36
|
/** WINGMAN_DEBUG=1 时把管线决策追加到 <tmpdir>/wingman-debug.log;旁路默认静默,这是唯一的观测口。 */
|
|
36
37
|
function debug(message: string): void {
|
|
@@ -193,7 +194,7 @@ export function installHeadsUp(
|
|
|
193
194
|
clearTimer();
|
|
194
195
|
if (pending.length === 0) return;
|
|
195
196
|
const config = getConfig().headsup;
|
|
196
|
-
// heads-up 必须显式配置模型才激活(开启必经 /headsup <model>);拼错模型也不回退当前模型
|
|
197
|
+
// heads-up 必须显式配置模型才激活(开启必经 /wingman headsup <model>);拼错模型也不回退当前模型
|
|
197
198
|
if (!config.enabled || !config.model) {
|
|
198
199
|
debug(`flush 跳过:enabled=${config.enabled} model=${config.model}`);
|
|
199
200
|
pending = [];
|
|
@@ -213,14 +214,19 @@ export function installHeadsUp(
|
|
|
213
214
|
const events = pending;
|
|
214
215
|
pending = [];
|
|
215
216
|
debug(`flush: ${events.length} 个事件送审计`);
|
|
216
|
-
|
|
217
|
+
ctx.ui.setStatus(STATUS_KEY, auditStatus("headsup"));
|
|
218
|
+
const result = await auditor.run(
|
|
217
219
|
ctx.modelRegistry,
|
|
218
220
|
model,
|
|
219
221
|
{ goal: getGoal(), digest: formatToolEvents(events) },
|
|
220
|
-
{ timeoutMs:
|
|
222
|
+
{ timeoutMs: 60_000, signal: shutdown.signal, effort: config.effort },
|
|
221
223
|
);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
+
if (!result) return;
|
|
225
|
+
ctx.ui.setStatus(STATUS_KEY, auditStatus("headsup", result));
|
|
226
|
+
debug(`verdict: ${result.ok ? `${result.verdict.level} ${result.verdict.summary}` : `调用失败 ${result.reason}`}`);
|
|
227
|
+
if (!result.ok) return;
|
|
228
|
+
const { verdict } = result;
|
|
229
|
+
if (verdict.level !== "concern" && verdict.level !== "blocker") return;
|
|
224
230
|
if (!view.show(verdict)) {
|
|
225
231
|
debug("已在抑制集,不上屏");
|
|
226
232
|
return;
|
package/hint.ts
CHANGED
|
@@ -48,7 +48,8 @@ export function installHint(
|
|
|
48
48
|
},
|
|
49
49
|
],
|
|
50
50
|
},
|
|
51
|
-
|
|
51
|
+
// hint 要快:高 effort 多半超时,失败即无 hint
|
|
52
|
+
{ timeoutMs: 10_000, signal: shutdown.signal, effort: getConfig().hint.effort },
|
|
52
53
|
);
|
|
53
54
|
if (!result.ok || gen !== generation) return; // 静默降级 / 已过期
|
|
54
55
|
editors.current?.setHint(result.text.replace(/\s+/g, " ").trim().slice(0, MAX_HINT_CHARS));
|
package/index.ts
CHANGED
|
@@ -7,19 +7,22 @@
|
|
|
7
7
|
* headsup 任一开启即装);llm.ts 是共享旁路调用层,audit.ts 是 M2/M3
|
|
8
8
|
* 共享的审计管线。
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* /
|
|
10
|
+
* 配置集中在 /wingman:无参看总览;/wingman <feature> 接 <provider/model>
|
|
11
|
+
* [effort] 开启并设模型(须能在 modelRegistry 解析,effort 须是该模型支持
|
|
12
|
+
* 的档位)、on/off 开关(保留模型);不带值时 TUI 里弹 pi 的模型与思考
|
|
13
|
+
* 强度选择器,其他模式只看该功能状态。用户目标在此统一捕获,跳过 advisor 注入的旁注,供三个
|
|
13
14
|
* 功能共用。
|
|
14
15
|
*/
|
|
15
16
|
|
|
17
|
+
import { getSupportedThinkingLevels, type ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
16
18
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
19
|
import { installAdvisor, isAdvisorNote } from "./advisor.js";
|
|
18
|
-
import { applyFeatureArg, featureLine, loadConfig, saveConfig, type WingmanConfig } from "./config.js";
|
|
20
|
+
import { applyFeatureArg, featureLine, isEffort, loadConfig, saveConfig, type WingmanConfig } from "./config.js";
|
|
19
21
|
import { EditorManager } from "./editor.js";
|
|
20
22
|
import { installHeadsUp } from "./headsup.js";
|
|
21
23
|
import { installHint } from "./hint.js";
|
|
22
24
|
import { resolveModel, type ModelRegistry } from "./llm.js";
|
|
25
|
+
import { pickModel, type Picked } from "./picker.js";
|
|
23
26
|
|
|
24
27
|
export default function (pi: ExtensionAPI) {
|
|
25
28
|
let config: WingmanConfig = loadConfig();
|
|
@@ -30,50 +33,94 @@ export default function (pi: ExtensionAPI) {
|
|
|
30
33
|
const getConfig = () => config;
|
|
31
34
|
const getGoal = () => goal;
|
|
32
35
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
ctx.ui.notify(
|
|
37
|
-
[
|
|
38
|
-
"wingman",
|
|
39
|
-
featureLine("headsup", config.headsup),
|
|
40
|
-
featureLine("advisor", config.advisor),
|
|
41
|
-
featureLine("hint", config.hint),
|
|
42
|
-
].join(" · "),
|
|
43
|
-
"info",
|
|
44
|
-
);
|
|
45
|
-
},
|
|
46
|
-
});
|
|
36
|
+
const features = ["advisor", "hint", "headsup"] as const;
|
|
37
|
+
type Feature = (typeof features)[number];
|
|
38
|
+
const isFeature = (name: string | undefined): name is Feature => features.includes(name as Feature);
|
|
47
39
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
40
|
+
const usage = "/wingman <advisor|hint|headsup> [<provider/model> [effort] | on | off]";
|
|
41
|
+
|
|
42
|
+
pi.registerCommand("wingman", {
|
|
43
|
+
description: `Wingman status, or configure: ${usage}`,
|
|
44
|
+
getArgumentCompletions: (prefix) => {
|
|
45
|
+
const [feature, value, effort, ...rest] = prefix.trimStart().split(/\s+/);
|
|
46
|
+
if (rest.length > 0) return null;
|
|
47
|
+
if (value === undefined) {
|
|
48
|
+
const items = features
|
|
49
|
+
.filter((name) => name.startsWith(feature ?? ""))
|
|
50
|
+
.map((name) => ({ value: `${name} `, label: name }));
|
|
56
51
|
return items.length > 0 ? items : null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
52
|
+
}
|
|
53
|
+
if (!isFeature(feature)) return null;
|
|
54
|
+
if (effort !== undefined) {
|
|
55
|
+
const model = registry && resolveModel(registry, value, undefined);
|
|
56
|
+
if (!model) return null;
|
|
57
|
+
const items = getSupportedThinkingLevels(model)
|
|
58
|
+
.filter((level) => level.startsWith(effort))
|
|
59
|
+
.map((level) => ({ value: `${feature} ${value} ${level}`, label: level }));
|
|
60
|
+
return items.length > 0 ? items : null;
|
|
61
|
+
}
|
|
62
|
+
const models = registry?.getAvailable().map((m) => `${m.provider}/${m.id}`) ?? [];
|
|
63
|
+
const items = ["on", "off", ...models]
|
|
64
|
+
.filter((option) => option.startsWith(value))
|
|
65
|
+
.map((option) => ({ value: `${feature} ${option}`, label: option }));
|
|
66
|
+
return items.length > 0 ? items : null;
|
|
67
|
+
},
|
|
68
|
+
handler: async (args, ctx) => {
|
|
69
|
+
const [feature, arg = "", effortArg, ...extra] = args.trim().split(/\s+/).filter(Boolean);
|
|
70
|
+
if (feature === undefined) {
|
|
71
|
+
ctx.ui.notify(
|
|
72
|
+
["wingman", ...features.map((name) => featureLine(name, config[name]))].join(" · "),
|
|
73
|
+
"info",
|
|
74
|
+
);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const isSwitch = arg === "on" || arg === "off";
|
|
78
|
+
if (!isFeature(feature) || extra.length > 0 || (effortArg !== undefined && isSwitch)) {
|
|
79
|
+
ctx.ui.notify(`wingman · 用法 ${usage}`, "error");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
let value = arg;
|
|
83
|
+
let effort: ModelThinkingLevel | undefined;
|
|
84
|
+
if (!value && ctx.mode === "tui") {
|
|
85
|
+
let picked: Picked | undefined;
|
|
86
|
+
try {
|
|
87
|
+
picked = await pickModel(ctx, config[feature]);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
90
|
+
ctx.ui.notify(`wingman · 选择器不可用(${reason}),改用 /wingman ${feature} <provider/model> [effort]`, "error");
|
|
62
91
|
return;
|
|
63
92
|
}
|
|
64
|
-
if (
|
|
65
|
-
|
|
93
|
+
if (!picked) return;
|
|
94
|
+
value = `${picked.model.provider}/${picked.model.id}`;
|
|
95
|
+
effort = picked.effort;
|
|
96
|
+
} else if (value && !isSwitch) {
|
|
97
|
+
const model = resolveModel(ctx.modelRegistry, value, undefined);
|
|
98
|
+
if (!model) {
|
|
99
|
+
ctx.ui.notify(`wingman · 找不到模型 ${value}(格式 provider/model-id)`, "error");
|
|
66
100
|
return;
|
|
67
101
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
102
|
+
if (effortArg !== undefined) {
|
|
103
|
+
const levels = getSupportedThinkingLevels(model);
|
|
104
|
+
if (!isEffort(effortArg) || !levels.includes(effortArg)) {
|
|
105
|
+
ctx.ui.notify(`wingman · ${value} 支持的 effort:${levels.join(" / ")}`, "error");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
effort = effortArg;
|
|
72
109
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
110
|
+
}
|
|
111
|
+
if (value === "on" && feature !== "hint" && !config[feature].model) {
|
|
112
|
+
ctx.ui.notify(`wingman · ${feature} 需要模型,先 /wingman ${feature} <provider/model>`, "error");
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
const line = applyFeatureArg(config, feature, value, effort);
|
|
116
|
+
if (value) {
|
|
117
|
+
saveConfig(config);
|
|
118
|
+
editors.sync(ctx, config);
|
|
119
|
+
if (!config[feature].enabled) ctx.ui.setStatus(`wingman-${feature}`, undefined);
|
|
120
|
+
}
|
|
121
|
+
ctx.ui.notify(`wingman · ${line}`, "info");
|
|
122
|
+
},
|
|
123
|
+
});
|
|
77
124
|
|
|
78
125
|
pi.on("session_start", (_event, ctx) => {
|
|
79
126
|
config = loadConfig();
|
package/llm.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* llm.ts — 旁路模型调用封装。
|
|
3
3
|
*
|
|
4
|
-
* 经 ctx.modelRegistry.
|
|
4
|
+
* 经 ctx.modelRegistry.streamSimple() 调 provider 模型(provider 中立的选项里
|
|
5
|
+
* 才有 reasoning,complete() 没有):
|
|
5
6
|
* - resolveModel(registry, "provider/model-id") 解析配置串,未配置/找不到时
|
|
6
7
|
* 回退当前模型;
|
|
7
8
|
* - completeText() 统一超时与 AbortController(idle 时 ctx.signal 不存在,
|
|
@@ -11,14 +12,18 @@
|
|
|
11
12
|
* 失败一律返回 { ok: false, reason },不抛异常进 pi 事件循环。
|
|
12
13
|
*/
|
|
13
14
|
|
|
15
|
+
import { clampThinkingLevel, type ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
14
16
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
17
|
|
|
16
18
|
export type ModelRegistry = ExtensionContext["modelRegistry"];
|
|
17
19
|
export type AnyModel = NonNullable<ExtensionContext["model"]>;
|
|
18
|
-
export type LlmContext = Parameters<ModelRegistry["
|
|
20
|
+
export type LlmContext = Parameters<ModelRegistry["streamSimple"]>[1];
|
|
19
21
|
/** pi-ai 的 Message:按 role 可辨识的联合(system/user/assistant/toolResult)。 */
|
|
20
22
|
export type Message = LlmContext["messages"][number];
|
|
21
23
|
|
|
24
|
+
/** effort 是思考强度,按模型支持的档位校正;未设或 "off" 不开推理。 */
|
|
25
|
+
export type CallOptions = { timeoutMs?: number; signal?: AbortSignal; effort?: ModelThinkingLevel };
|
|
26
|
+
|
|
22
27
|
export type CompleteResult = { ok: true; text: string } | { ok: false; reason: string };
|
|
23
28
|
|
|
24
29
|
/** 拼接内容块里的文本块。 */
|
|
@@ -59,16 +64,20 @@ export async function completeText(
|
|
|
59
64
|
registry: ModelRegistry,
|
|
60
65
|
model: AnyModel,
|
|
61
66
|
context: LlmContext,
|
|
62
|
-
options:
|
|
67
|
+
options: CallOptions = {},
|
|
63
68
|
): Promise<CompleteResult> {
|
|
64
69
|
const controller = new AbortController();
|
|
65
70
|
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 15_000);
|
|
66
71
|
const onOuterAbort = () => controller.abort();
|
|
67
72
|
options.signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
68
73
|
try {
|
|
69
|
-
const
|
|
74
|
+
const effort = options.effort && clampThinkingLevel(model, options.effort);
|
|
75
|
+
const response = await registry
|
|
76
|
+
.streamSimple(model, context, { signal: controller.signal, reasoning: effort === "off" ? undefined : effort })
|
|
77
|
+
.result();
|
|
70
78
|
if (response.stopReason !== "stop") {
|
|
71
|
-
|
|
79
|
+
const detail = response.errorMessage ? `: ${response.errorMessage}` : "";
|
|
80
|
+
return { ok: false, reason: `stopReason=${response.stopReason}${detail}` };
|
|
72
81
|
}
|
|
73
82
|
const text = textOf(response.content).trim();
|
|
74
83
|
if (!text) return { ok: false, reason: "empty response" };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@master.g/pi-wingman",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.3",
|
|
4
4
|
"description": "Wingman for pi — heads-up audit while the agent works, a designated advisor model, and next-prompt hints (Tab/Enter to accept)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"typecheck": "tsc --noEmit"
|
|
39
39
|
},
|
|
40
40
|
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-ai": "*",
|
|
41
42
|
"@earendil-works/pi-coding-agent": "*",
|
|
42
43
|
"@earendil-works/pi-tui": "*"
|
|
43
44
|
},
|
package/picker.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* picker.ts — `/wingman <feature>` 不带值时的交互选择:依次复用 pi 的
|
|
3
|
+
* /model 与 /thinking 选择器选模型和 effort。仅 TUI 可用。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { clampThinkingLevel, getSupportedThinkingLevels, type ModelThinkingLevel } from "@earendil-works/pi-ai";
|
|
7
|
+
import {
|
|
8
|
+
ModelSelectorComponent,
|
|
9
|
+
ThinkingSelectorComponent,
|
|
10
|
+
type ExtensionContext,
|
|
11
|
+
type ModelRuntime,
|
|
12
|
+
} from "@earendil-works/pi-coding-agent";
|
|
13
|
+
import type { FeatureConfig } from "./config.js";
|
|
14
|
+
import { resolveModel, type AnyModel, type ModelRegistry } from "./llm.js";
|
|
15
|
+
|
|
16
|
+
export type Picked = { model: AnyModel; effort?: ModelThinkingLevel };
|
|
17
|
+
|
|
18
|
+
/** 选模型再选 effort;选模型时取消返回 undefined,选 effort 时取消则不设 effort。 */
|
|
19
|
+
export async function pickModel(ctx: ExtensionContext, current: FeatureConfig): Promise<Picked | undefined> {
|
|
20
|
+
const registry = ctx.modelRegistry;
|
|
21
|
+
// ponytail: 组件要 ModelRuntime,扩展只拿得到 ModelRegistry;组件只调这 4 个方法,
|
|
22
|
+
// registry 内部也是原样转发。pi 让组件多调方法时会抛错,调用方兜底回退手输。
|
|
23
|
+
const runtime = {
|
|
24
|
+
getAvailableSnapshot: () => registry.getAvailable(),
|
|
25
|
+
getModel: (provider: string, id: string) => registry.find(provider, id),
|
|
26
|
+
getError: () => registry.getError(),
|
|
27
|
+
refresh: (options: Parameters<ModelRegistry["refresh"]>[0]) => registry.refresh(options),
|
|
28
|
+
} as unknown as ModelRuntime;
|
|
29
|
+
const currentModel = resolveModel(registry, current.model, undefined);
|
|
30
|
+
const model = await ctx.ui.custom<AnyModel | undefined>(
|
|
31
|
+
(tui, _theme, _kb, done) => new ModelSelectorComponent(tui, currentModel, runtime, [], done, () => done(undefined)),
|
|
32
|
+
);
|
|
33
|
+
if (!model) return undefined;
|
|
34
|
+
|
|
35
|
+
const levels = getSupportedThinkingLevels(model);
|
|
36
|
+
if (levels.length <= 1) return { model };
|
|
37
|
+
const effort = await ctx.ui.custom<ModelThinkingLevel | undefined>(
|
|
38
|
+
(_tui, _theme, _kb, done) =>
|
|
39
|
+
new ThinkingSelectorComponent(clampThinkingLevel(model, current.effort ?? "off"), levels, done, () => done(undefined)),
|
|
40
|
+
);
|
|
41
|
+
return { model, effort };
|
|
42
|
+
}
|