@master.g/pi-wingman 0.0.0-stage → 0.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.
- package/LICENSE +21 -0
- package/README.md +48 -2
- package/advisor.ts +161 -0
- package/audit.ts +151 -0
- package/config.ts +86 -0
- package/editor.ts +113 -0
- package/headsup.ts +276 -0
- package/hint.ts +76 -0
- package/index.ts +95 -0
- package/llm.ts +82 -0
- package/package.json +49 -5
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 master-g
|
|
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
CHANGED
|
@@ -1,3 +1,49 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-wingman
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[pi](https://github.com/earendil-works/pi) 的僚机扩展:主 agent 干活时替你把关、参谋、想下一步。
|
|
4
|
+
|
|
5
|
+
三个功能(均可独立开关,模型各自可配):
|
|
6
|
+
|
|
7
|
+
| 功能 | 参照 | 说明 |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| **heads-up** | — | 主 agent 工作时后台自动审计,提前发现潜在问题,notify + widget 告警给用户(只读旁路,不阻塞、不注入) |
|
|
10
|
+
| **advisor** | oh-my-pi advisor | 指定一个模型当顾问,读主 agent 的每个 turn,以 steer 消息内联注入便签(aside / concern / blocker)让主 agent 纠偏 |
|
|
11
|
+
| **hint** | Claude Code prompt suggestions | 任务结束后按上下文与目标生成下一步 prompt,输入框 ghost text 呈现:Tab 填入、Enter 直接发送 |
|
|
12
|
+
|
|
13
|
+
> 状态:骨架(v0.0.1)。命令与配置已可用;三个控制器按 M1 hint → M2 advisor → M3 heads-up 接入。
|
|
14
|
+
|
|
15
|
+
## 安装
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:@master.g/pi-wingman
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
开发中本地加载:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi --extension ~/github/pi-wingman/index.ts
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 命令
|
|
28
|
+
|
|
29
|
+
- `/wingman` — 查看三个功能的状态与模型
|
|
30
|
+
- `/advisor`、`/hint`、`/headsup` — 各接 `<provider/model>`(开启并设模型,模型须存在)、`on` / `off`(开关,保留已设模型)、无参(看状态);参数支持补全
|
|
31
|
+
- `/ask-advisor <问题>` — 就当前上下文咨询顾问模型,回答注入对话
|
|
32
|
+
|
|
33
|
+
## 配置
|
|
34
|
+
|
|
35
|
+
`<agentDir>/wingman.json`(默认 `~/.pi/agent/wingman.json`),由命令自动写入,也可手改:
|
|
36
|
+
|
|
37
|
+
```json
|
|
38
|
+
{
|
|
39
|
+
"headsup": { "enabled": false },
|
|
40
|
+
"advisor": { "enabled": true, "model": "openai-codex/gpt-5.5" },
|
|
41
|
+
"hint": { "enabled": false }
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
三个功能默认都关闭:每个功能都会额外调用模型,需要用户自己开启。模型形如 `provider/model-id`;hint 没配模型时用当前模型(`/hint on`),advisor/heads-up 必须配模型才会激活。
|
|
46
|
+
|
|
47
|
+
## License
|
|
48
|
+
|
|
49
|
+
MIT
|
package/advisor.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* advisor.ts — 用户指定模型的顾问(参照 oh-my-pi advisor)。
|
|
3
|
+
*
|
|
4
|
+
* 每个 turn_end 把该 turn 摘要交给后台审计(不 await,不拖住主 agent);
|
|
5
|
+
* 冷却或在途时摘要先累积,下一个 turn 一起审。返回 note/concern/blocker
|
|
6
|
+
* 时 notify 用户;agent 仍在运行才以 pi.sendUserMessage(note, { deliverAs:
|
|
7
|
+
* "steer" }) 注入纠偏,已 idle 则只提示(顾问不自行唤醒 agent)。注入消息
|
|
8
|
+
* 以 ADVISOR_MARKER 打标:响应旁注的 turn 不再审计(防循环),index.ts
|
|
9
|
+
* 记录用户目标时也跳过带标记的 prompt(防污染)。
|
|
10
|
+
*
|
|
11
|
+
* 另提供 /ask-advisor <问题> 手动咨询当前上下文:答案同样以 steer 注入
|
|
12
|
+
* 主会话。手动咨询失败时向用户报错;自动审计走静默降级。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { Auditor, buildDigest, type DigestMessage, type VerdictLevel } from "./audit.js";
|
|
17
|
+
import type { WingmanConfig } from "./config.js";
|
|
18
|
+
import { completeText, messageText, resolveModel, type AnyModel, type ModelRegistry } from "./llm.js";
|
|
19
|
+
|
|
20
|
+
/** 注入消息的打标前缀;审计 prompt 也告诉审计员忽略带此标记的内容。 */
|
|
21
|
+
export const ADVISOR_MARKER = "[wingman advisor";
|
|
22
|
+
|
|
23
|
+
export function isAdvisorNote(text: string): boolean {
|
|
24
|
+
return text.startsWith(ADVISOR_MARKER);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatAdvisorNote(verdict: { level: VerdictLevel; summary: string }): string {
|
|
28
|
+
return `${ADVISOR_MARKER} · 旁注,非用户消息,仅供参考] ${verdict.level}: ${verdict.summary}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 防循环:注入的旁注以 user 消息进入 transcript。message_end 见到带标记的
|
|
33
|
+
* user 消息就记下,随后那个 turn_end(响应旁注的 turn)跳过审计。按注入
|
|
34
|
+
* 文本识别,不依赖后台审计与 turn 的时序。
|
|
35
|
+
*/
|
|
36
|
+
export class AdvisorState {
|
|
37
|
+
private skipNextTurnAudit = false;
|
|
38
|
+
|
|
39
|
+
onUserMessage(text: string): void {
|
|
40
|
+
if (isAdvisorNote(text)) this.skipNextTurnAudit = true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** turn_end 到达时调用:true = 本次应审计;false = 跳过并消费跳过量。 */
|
|
44
|
+
shouldAuditTurn(): boolean {
|
|
45
|
+
if (this.skipNextTurnAudit) {
|
|
46
|
+
this.skipNextTurnAudit = false;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ASK_SYSTEM_PROMPT = `你是资深编程顾问,在旁观察一个编程 agent 的工作。给你用户的目标和 agent 近期动作摘要,以及用户现在问你的问题。直接回答问题本身,简洁具体,≤300 字,用中文。`;
|
|
54
|
+
|
|
55
|
+
const NOTIFY_TYPE: Record<Exclude<VerdictLevel, "ok">, "info" | "warning" | "error"> = {
|
|
56
|
+
note: "info",
|
|
57
|
+
concern: "warning",
|
|
58
|
+
blocker: "error",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** 冷却期内最多累积几个 turn 的摘要,超出丢最旧的。 */
|
|
62
|
+
const MAX_PENDING_TURNS = 3;
|
|
63
|
+
|
|
64
|
+
export function installAdvisor(pi: ExtensionAPI, getConfig: () => WingmanConfig, getGoal: () => string): void {
|
|
65
|
+
const auditor = new Auditor({ minIntervalMs: 60_000 });
|
|
66
|
+
const shutdown = new AbortController();
|
|
67
|
+
const state = new AdvisorState();
|
|
68
|
+
let pendingDigests: string[] = [];
|
|
69
|
+
|
|
70
|
+
const audit = async (ctx: ExtensionContext, registry: ModelRegistry, model: AnyModel, digest: string) => {
|
|
71
|
+
const verdict = await auditor.run(
|
|
72
|
+
registry,
|
|
73
|
+
model,
|
|
74
|
+
{ goal: getGoal(), digest },
|
|
75
|
+
{ timeoutMs: 15_000, signal: shutdown.signal },
|
|
76
|
+
);
|
|
77
|
+
if (!verdict || verdict.level === "ok") return;
|
|
78
|
+
ctx.ui.notify(`advisor [${verdict.level}] ${verdict.summary}`, NOTIFY_TYPE[verdict.level]);
|
|
79
|
+
if (ctx.isIdle()) return; // 裁决迟到:agent 已停,只提示用户,不注入
|
|
80
|
+
pi.sendUserMessage(formatAdvisorNote(verdict), { deliverAs: "steer" });
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
pi.on("message_end", (event) => {
|
|
84
|
+
if (event.message.role === "user") state.onUserMessage(messageText(event.message));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
pi.on("turn_end", (event, ctx) => {
|
|
88
|
+
const config = getConfig().advisor;
|
|
89
|
+
// advisor 必须显式配置模型才激活(开启必经 /advisor <model>);
|
|
90
|
+
// 未设或拼错模型都不回退当前模型,避免双份成本。
|
|
91
|
+
if (!config.enabled || !config.model) return;
|
|
92
|
+
if (!state.shouldAuditTurn()) return;
|
|
93
|
+
const model = resolveModel(ctx.modelRegistry, config.model, undefined);
|
|
94
|
+
if (!model) return;
|
|
95
|
+
|
|
96
|
+
const digest = buildDigest([event.message, ...event.toolResults] as DigestMessage[]);
|
|
97
|
+
if (!digest.trim()) return;
|
|
98
|
+
pendingDigests = [...pendingDigests, digest].slice(-MAX_PENDING_TURNS);
|
|
99
|
+
if (!auditor.ready) return; // 冷却或在途:摘要留到下一个 turn 一起审
|
|
100
|
+
const batch = pendingDigests.join("\n");
|
|
101
|
+
pendingDigests = [];
|
|
102
|
+
// 后台执行已在 runner 的 try/catch 之外,自己兜底(会话替换后 UI 调用可能抛)
|
|
103
|
+
audit(ctx, ctx.modelRegistry, model, batch).catch(() => {});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
pi.registerCommand("ask-advisor", {
|
|
107
|
+
description: "Ask the advisor model about current context: /ask-advisor <question>",
|
|
108
|
+
handler: async (args, ctx) => {
|
|
109
|
+
const config = getConfig().advisor;
|
|
110
|
+
if (!config.enabled || !config.model) {
|
|
111
|
+
ctx.ui.notify("advisor 未启用,先 /advisor <provider/model> 配置", "error");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const question = args.trim();
|
|
115
|
+
if (!question) {
|
|
116
|
+
ctx.ui.notify("用法:/ask-advisor <问题>", "info");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const model = resolveModel(ctx.modelRegistry, config.model, undefined);
|
|
120
|
+
if (!model) {
|
|
121
|
+
ctx.ui.notify(`advisor 模型无法解析:${config.model}`, "error");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const recent = ctx.sessionManager
|
|
126
|
+
.getBranch()
|
|
127
|
+
.filter((entry) => entry.type === "message")
|
|
128
|
+
.slice(-10)
|
|
129
|
+
.map((entry) => (entry as { message: unknown }).message as DigestMessage);
|
|
130
|
+
const digest = buildDigest(recent);
|
|
131
|
+
const goal = getGoal() ? `用户目标:${getGoal()}\n\n` : "";
|
|
132
|
+
const result = await completeText(
|
|
133
|
+
ctx.modelRegistry,
|
|
134
|
+
model,
|
|
135
|
+
{
|
|
136
|
+
systemPrompt: ASK_SYSTEM_PROMPT,
|
|
137
|
+
messages: [
|
|
138
|
+
{
|
|
139
|
+
role: "user",
|
|
140
|
+
content: [
|
|
141
|
+
{ type: "text", text: `${goal}近期动作:\n${digest || "(暂无)"}\n\n顾问问题:${question}` },
|
|
142
|
+
],
|
|
143
|
+
timestamp: Date.now(),
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
{ timeoutMs: 30_000, signal: shutdown.signal },
|
|
148
|
+
);
|
|
149
|
+
if (!result.ok) {
|
|
150
|
+
ctx.ui.notify(`advisor 调用失败:${result.reason}`, "error");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
pi.sendUserMessage(`${ADVISOR_MARKER} · 问答] 问:${question}\n答:${result.text}`, { deliverAs: "steer" });
|
|
154
|
+
ctx.ui.notify("顾问回答已注入对话", "info");
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
pi.on("session_shutdown", () => {
|
|
159
|
+
shutdown.abort();
|
|
160
|
+
});
|
|
161
|
+
}
|
package/audit.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* audit.ts — heads-up 与 advisor 共享的审计管线。
|
|
3
|
+
*
|
|
4
|
+
* 输入:用户目标(before_agent_start 的 prompt)+ 近期动作 digest;
|
|
5
|
+
* 输出:Verdict(ok/note/concern/blocker + 一句话)。heads-up 与 advisor
|
|
6
|
+
* 只差输出通道,审计逻辑复用同一份。
|
|
7
|
+
*
|
|
8
|
+
* Auditor 带冷却与互斥:并发只放行一个,冷却期内直接跳过(返回
|
|
9
|
+
* undefined);失败一律静默(返回 undefined),不抛进 pi 事件循环。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { completeText, messageText, type AnyModel, type Message, type ModelRegistry } from "./llm.js";
|
|
13
|
+
|
|
14
|
+
export type VerdictLevel = "ok" | "note" | "concern" | "blocker";
|
|
15
|
+
|
|
16
|
+
export interface Verdict {
|
|
17
|
+
level: VerdictLevel;
|
|
18
|
+
/** 一句话说明,给用户或主 agent 看 */
|
|
19
|
+
summary: string;
|
|
20
|
+
/** 展开要点(heads-up 展开态用;仅 concern/blocker 时审计模型才会给) */
|
|
21
|
+
details: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type DigestMessage = Message;
|
|
25
|
+
|
|
26
|
+
export interface DigestOptions {
|
|
27
|
+
maxMessages?: number;
|
|
28
|
+
perMessageChars?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const AUDIT_SYSTEM_PROMPT = `你是编程 agent 的旁路审计员。给你用户的目标和 agent 近期动作摘要,判断 agent 是否走在正确的路上。
|
|
32
|
+
|
|
33
|
+
只输出如下格式,不要任何其他内容:
|
|
34
|
+
VERDICT: <ok|note|concern|blocker>
|
|
35
|
+
SUMMARY: <一句话,≤120 字符,用中文>
|
|
36
|
+
DETAILS:(仅 concern/blocker 时输出此块,最多 4 条要点,每条一行以 "- " 开头,各 ≤100 字符,说明判断依据与建议;ok/note 时不要输出此块)
|
|
37
|
+
|
|
38
|
+
级别含义:
|
|
39
|
+
- blocker:正在犯错且会造成实质损失(破坏数据、违背用户目标、在错误的方向上大量返工),必须立即纠偏
|
|
40
|
+
- concern:方向可疑或明显浪费(改错文件、绕过而非修复、重复无效尝试),值得提醒
|
|
41
|
+
- note:方向正确,但有一个可选的改进建议
|
|
42
|
+
- ok:一切正常,无话可说
|
|
43
|
+
|
|
44
|
+
宁缺毋滥:不确定时选 ok。带 [wingman advisor 标记的消息是顾问自己之前的旁注,不要评论它。`;
|
|
45
|
+
|
|
46
|
+
/** 把近期消息压成审计用摘要:role 前缀 + 截断正文 + 工具调用/结果行。 */
|
|
47
|
+
export function buildDigest(messages: DigestMessage[], options: DigestOptions = {}): string {
|
|
48
|
+
const { maxMessages = 12, perMessageChars = 300 } = options;
|
|
49
|
+
const lines: string[] = [];
|
|
50
|
+
for (const message of messages.slice(-maxMessages)) {
|
|
51
|
+
const text = messageText(message).slice(0, perMessageChars);
|
|
52
|
+
if (message.role === "toolResult") {
|
|
53
|
+
const error = message.isError ? " ERROR" : "";
|
|
54
|
+
lines.push(`[toolResult ${message.toolName}]${error} ${text}`.trimEnd());
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const calls =
|
|
58
|
+
message.role === "assistant"
|
|
59
|
+
? message.content
|
|
60
|
+
.filter((c) => c.type === "toolCall")
|
|
61
|
+
.map((c) => `→ ${c.name}(${JSON.stringify(c.arguments ?? {}).slice(0, 120)})`)
|
|
62
|
+
.join(" ")
|
|
63
|
+
: "";
|
|
64
|
+
lines.push(`[${message.role}] ${text}${text && calls ? " " : ""}${calls}`.trimEnd());
|
|
65
|
+
}
|
|
66
|
+
return lines.join("\n");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 解析审计模型的输出;没有 VERDICT 行视为无效。 */
|
|
70
|
+
export function parseVerdict(text: string): Verdict | undefined {
|
|
71
|
+
const verdict = text.match(/verdict:\s*(ok|note|concern|blocker)/i);
|
|
72
|
+
if (!verdict) return undefined;
|
|
73
|
+
const summary = text.match(/summary:\s*(.+)/i);
|
|
74
|
+
const detailsMatch = text.match(/details:\s*\n((?:^[ \t]*-[ \t].*\n?)+)/im);
|
|
75
|
+
const details = detailsMatch
|
|
76
|
+
? detailsMatch[1]
|
|
77
|
+
.split("\n")
|
|
78
|
+
.map((line) => line.replace(/^[ \t]*-[ \t]*/, "").trim())
|
|
79
|
+
.filter((line) => line.length > 0)
|
|
80
|
+
.slice(0, 4)
|
|
81
|
+
.map((line) => line.slice(0, 200))
|
|
82
|
+
: [];
|
|
83
|
+
return {
|
|
84
|
+
level: verdict[1].toLowerCase() as VerdictLevel,
|
|
85
|
+
summary: (summary?.[1] ?? "").trim().slice(0, 200),
|
|
86
|
+
details,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 无守卫的单次审计;冷却互斥由 Auditor 负责。 */
|
|
91
|
+
export async function runAudit(
|
|
92
|
+
registry: ModelRegistry,
|
|
93
|
+
model: AnyModel,
|
|
94
|
+
input: { goal: string; digest: string },
|
|
95
|
+
options: { timeoutMs?: number; signal?: AbortSignal } = {},
|
|
96
|
+
): Promise<Verdict | undefined> {
|
|
97
|
+
const goal = input.goal ? `用户目标:${input.goal}\n\n` : "";
|
|
98
|
+
const result = await completeText(
|
|
99
|
+
registry,
|
|
100
|
+
model,
|
|
101
|
+
{
|
|
102
|
+
systemPrompt: AUDIT_SYSTEM_PROMPT,
|
|
103
|
+
messages: [
|
|
104
|
+
{
|
|
105
|
+
role: "user",
|
|
106
|
+
content: [{ type: "text", text: `${goal}近期动作:\n${input.digest}` }],
|
|
107
|
+
timestamp: Date.now(),
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
{ timeoutMs: options.timeoutMs ?? 15_000, signal: options.signal },
|
|
112
|
+
);
|
|
113
|
+
if (!result.ok) return undefined;
|
|
114
|
+
return parseVerdict(result.text);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 冷却 + 互斥的审计器:advisor(turn_end)与 heads-up(去抖)共用一个实例各管各的。 */
|
|
118
|
+
export class Auditor {
|
|
119
|
+
private inFlight = false;
|
|
120
|
+
private lastRunAt = Number.NEGATIVE_INFINITY;
|
|
121
|
+
private readonly minIntervalMs: number;
|
|
122
|
+
private readonly now: () => number;
|
|
123
|
+
|
|
124
|
+
constructor(options: { minIntervalMs?: number; now?: () => number } = {}) {
|
|
125
|
+
this.minIntervalMs = options.minIntervalMs ?? 60_000;
|
|
126
|
+
this.now = options.now ?? Date.now;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** 此刻 run 会放行:不在途且已过冷却。调用方据此决定是否先消费累积的输入。 */
|
|
130
|
+
get ready(): boolean {
|
|
131
|
+
return !this.inFlight && this.now() - this.lastRunAt >= this.minIntervalMs;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async run(
|
|
135
|
+
registry: ModelRegistry,
|
|
136
|
+
model: AnyModel,
|
|
137
|
+
input: { goal: string; digest: string },
|
|
138
|
+
options: { timeoutMs?: number; signal?: AbortSignal } = {},
|
|
139
|
+
): Promise<Verdict | undefined> {
|
|
140
|
+
if (!this.ready) return undefined;
|
|
141
|
+
this.inFlight = true;
|
|
142
|
+
this.lastRunAt = this.now();
|
|
143
|
+
try {
|
|
144
|
+
return await runAudit(registry, model, input, options);
|
|
145
|
+
} catch {
|
|
146
|
+
return undefined; // 静默降级
|
|
147
|
+
} finally {
|
|
148
|
+
this.inFlight = false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
package/config.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.ts — wingman 配置:读写 <agentDir>/wingman.json(默认 ~/.pi/agent)。
|
|
3
|
+
*
|
|
4
|
+
* 三个功能各自独立开关,默认全关(旁路调用都有成本,须用户显式开启);
|
|
5
|
+
* 模型形如 "provider/model-id"。未配置模型时各模块自行回退:hint 用当前
|
|
6
|
+
* 模型,advisor/heads-up 保持关闭。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
|
|
13
|
+
export interface FeatureConfig {
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
model?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WingmanConfig {
|
|
19
|
+
headsup: FeatureConfig;
|
|
20
|
+
advisor: FeatureConfig;
|
|
21
|
+
hint: FeatureConfig;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_CONFIG: WingmanConfig = {
|
|
25
|
+
headsup: { enabled: false },
|
|
26
|
+
advisor: { enabled: false },
|
|
27
|
+
hint: { enabled: false },
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function configPath(): string {
|
|
31
|
+
return join(getAgentDir(), "wingman.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function mergeFeature(base: FeatureConfig, override: unknown): FeatureConfig {
|
|
35
|
+
if (typeof override !== "object" || override === null) return { ...base };
|
|
36
|
+
const o = override as Partial<FeatureConfig>;
|
|
37
|
+
return {
|
|
38
|
+
enabled: typeof o.enabled === "boolean" ? o.enabled : base.enabled,
|
|
39
|
+
model: typeof o.model === "string" ? o.model : base.model,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function loadConfig(path: string = configPath()): WingmanConfig {
|
|
44
|
+
let raw: Partial<Record<keyof WingmanConfig, unknown>> = {};
|
|
45
|
+
try {
|
|
46
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
47
|
+
if (typeof parsed === "object" && parsed !== null) raw = parsed;
|
|
48
|
+
} catch {
|
|
49
|
+
// 文件不存在或损坏时回退默认配置
|
|
50
|
+
}
|
|
51
|
+
return {
|
|
52
|
+
headsup: mergeFeature(DEFAULT_CONFIG.headsup, raw.headsup),
|
|
53
|
+
advisor: mergeFeature(DEFAULT_CONFIG.advisor, raw.advisor),
|
|
54
|
+
hint: mergeFeature(DEFAULT_CONFIG.hint, raw.hint),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function saveConfig(config: WingmanConfig, path: string = configPath()): void {
|
|
59
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
60
|
+
writeFileSync(path, JSON.stringify(config, null, "\t") + "\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 状态行:"hint: on (provider/model)" / "hint: on (current model)" / "hint: off"。 */
|
|
64
|
+
export function featureLine(name: string, feature: FeatureConfig): string {
|
|
65
|
+
const state = feature.enabled ? `on (${feature.model ?? "current model"})` : "off";
|
|
66
|
+
return `${name}: ${state}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 应用配置命令参数:"off" 关闭、"on" 重新开启(都保留已设模型),
|
|
71
|
+
* "provider/model" 开启并设模型,空参只读。返回状态行;模型校验、
|
|
72
|
+
* saveConfig 与后续副作用(如编辑器同步)由调用方负责。
|
|
73
|
+
*/
|
|
74
|
+
export function applyFeatureArg(
|
|
75
|
+
config: WingmanConfig,
|
|
76
|
+
feature: keyof WingmanConfig,
|
|
77
|
+
arg: string,
|
|
78
|
+
): string {
|
|
79
|
+
const value = arg.trim();
|
|
80
|
+
if (value === "off" || value === "on") {
|
|
81
|
+
config[feature] = { ...config[feature], enabled: value === "on" };
|
|
82
|
+
} else if (value) {
|
|
83
|
+
config[feature] = { enabled: true, model: value };
|
|
84
|
+
}
|
|
85
|
+
return featureLine(feature, config[feature]);
|
|
86
|
+
}
|
package/editor.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* editor.ts — WingmanEditor 与其安装管理(EditorManager)。
|
|
3
|
+
*
|
|
4
|
+
* WingmanEditor 是全扩展唯一的 CustomEditor(setEditorComponent 全局只能装
|
|
5
|
+
* 一个):hint 用 ghost 行(M1),heads-up 用数字键捕获(M3)。安装条件是
|
|
6
|
+
* hint.enabled || headsup.enabled;session_start 与配置命令变更后各 sync
|
|
7
|
+
* 一次,条件不变不动(避免重建丢掉 pendingHint)。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { CustomEditor, type ExtensionContext, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { getKeybindings, truncateToWidth, type EditorTheme, type TUI } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { WingmanConfig } from "./config.js";
|
|
13
|
+
|
|
14
|
+
export class WingmanEditor extends CustomEditor {
|
|
15
|
+
private pendingHint: string | undefined;
|
|
16
|
+
/** heads-up 数字键处理器,返回 true 表示消费;仅编辑器为空时被调用。 */
|
|
17
|
+
onHeadsUpKey?: (key: "0" | "1" | "2") => boolean;
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
tui: TUI,
|
|
21
|
+
theme: EditorTheme,
|
|
22
|
+
keybindings: KeybindingsManager,
|
|
23
|
+
private readonly activeTui: { current: TUI | undefined },
|
|
24
|
+
) {
|
|
25
|
+
super(tui, theme, keybindings);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
setHint(hint: string | undefined): void {
|
|
29
|
+
this.pendingHint = hint;
|
|
30
|
+
this.activeTui.current?.requestRender();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
render(width: number): string[] {
|
|
34
|
+
const lines = super.render(width);
|
|
35
|
+
if (!this.pendingHint || this.getText().length > 0 || lines.length < 2) return lines;
|
|
36
|
+
// shortcut: ghost 渲染为输入行下方新增一行;如需与输入行内联覆盖再升级。
|
|
37
|
+
const ghost = truncateToWidth(`\x1b[2m 💡 ${this.pendingHint}\x1b[0m`, width);
|
|
38
|
+
lines.splice(lines.length - 1, 0, ghost);
|
|
39
|
+
return lines;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
handleInput(data: string): void {
|
|
43
|
+
if (
|
|
44
|
+
this.getText().length === 0 &&
|
|
45
|
+
(data === "0" || data === "1" || data === "2") &&
|
|
46
|
+
this.onHeadsUpKey?.(data)
|
|
47
|
+
) {
|
|
48
|
+
return; // heads-up 消费了数字键
|
|
49
|
+
}
|
|
50
|
+
if (this.pendingHint) {
|
|
51
|
+
// 与基类编辑器同一套键位判断:兼容 kitty 协议与用户自定义键位
|
|
52
|
+
const kb = getKeybindings();
|
|
53
|
+
if (kb.matches(data, "tui.input.tab")) {
|
|
54
|
+
// Tab:填入可编辑,吞掉 Tab 防触发补全
|
|
55
|
+
const hint = this.pendingHint;
|
|
56
|
+
this.pendingHint = undefined;
|
|
57
|
+
this.setText(hint);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (kb.matches(data, "tui.input.submit")) {
|
|
61
|
+
// 提交键:填入后把同一按键交给基类走提交路径
|
|
62
|
+
const hint = this.pendingHint;
|
|
63
|
+
this.pendingHint = undefined;
|
|
64
|
+
this.setText(hint);
|
|
65
|
+
super.handleInput(data);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// 其他任意按键:清除 hint,按键照常转发
|
|
69
|
+
this.pendingHint = undefined;
|
|
70
|
+
}
|
|
71
|
+
super.handleInput(data);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export class EditorManager {
|
|
76
|
+
private readonly activeTui: { current: TUI | undefined } = { current: undefined };
|
|
77
|
+
private editor: WingmanEditor | undefined;
|
|
78
|
+
private registered = false;
|
|
79
|
+
/** heads-up 数字键处理器槽位;工厂重建编辑器时自动接上。 */
|
|
80
|
+
headsUpHandler?: (key: "0" | "1" | "2") => boolean;
|
|
81
|
+
|
|
82
|
+
/** 当前编辑器实例(未安装或 TUI 未构建时为 undefined)。 */
|
|
83
|
+
get current(): WingmanEditor | undefined {
|
|
84
|
+
return this.editor;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 按配置同步安装状态;shortcut: 运行中切换会重建编辑器丢弃未提交文本,配置命令场景可接受。 */
|
|
88
|
+
sync(ctx: ExtensionContext, config: WingmanConfig): void {
|
|
89
|
+
if (ctx.mode !== "tui") return;
|
|
90
|
+
const need = config.hint.enabled || config.headsup.enabled;
|
|
91
|
+
if (need === this.registered) return;
|
|
92
|
+
if (need) {
|
|
93
|
+
this.registered = true;
|
|
94
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
95
|
+
this.activeTui.current = tui;
|
|
96
|
+
this.editor = new WingmanEditor(tui, theme, keybindings, this.activeTui);
|
|
97
|
+
this.editor.onHeadsUpKey = (key) => this.headsUpHandler?.(key) ?? false;
|
|
98
|
+
return this.editor;
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
// teardown 内部才把 registered 置 false,顺序不可颠倒
|
|
102
|
+
this.teardown(ctx);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
teardown(ctx: ExtensionContext): void {
|
|
107
|
+
if (!this.registered) return;
|
|
108
|
+
this.registered = false;
|
|
109
|
+
this.editor = undefined;
|
|
110
|
+
this.activeTui.current = undefined;
|
|
111
|
+
ctx.ui.setEditorComponent(undefined);
|
|
112
|
+
}
|
|
113
|
+
}
|
package/headsup.ts
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* headsup.ts — 主 agent 工作时后台去抖审计,提前发现潜在问题
|
|
3
|
+
* (交互对齐 Claude Code heads-up 真实样例)。
|
|
4
|
+
*
|
|
5
|
+
* 触发:tool_execution_end 聚合,去抖 ~8s 或攒够 batchSize 次 → 后台调用
|
|
6
|
+
* audit.ts 的 Auditor(不 await,不拖住工具事件);冷却或在途时事件留在
|
|
7
|
+
* 队列等下一次。只有 concern/blocker 上屏,ok/note 静默;失败静默降级
|
|
8
|
+
* (保留 last-good,即不清已显示的 finding)。
|
|
9
|
+
*
|
|
10
|
+
* widget 三态(挂载于 belowEditor,出现时挂载、dismiss 即卸载):
|
|
11
|
+
* 折叠:`✦ Heads up · <一句话>` + `1: 展开 2: 已知悉 0: 忽略`
|
|
12
|
+
* 展开:标题 + 要点列表 + `1: 知道了 2: 带到主会话 0: 忽略`
|
|
13
|
+
* 带到主会话:setEditorText 填入引用块,用户自己编辑/发送(不自动注入)
|
|
14
|
+
* 数字键仅编辑器为空时被 WingmanEditor 捕获,其余按键照常输入。
|
|
15
|
+
* 忽略/已知悉/知道了都把发现记入本会话抑制集,同一发现不再弹(v1 同义)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ExtensionAPI, ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
19
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
20
|
+
import { appendFileSync } from "node:fs";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import { Auditor, type Verdict, type VerdictLevel } from "./audit.js";
|
|
24
|
+
import type { WingmanConfig } from "./config.js";
|
|
25
|
+
import type { EditorManager } from "./editor.js";
|
|
26
|
+
import { resolveModel } from "./llm.js";
|
|
27
|
+
|
|
28
|
+
export type HeadsUpAction = { action: "none" | "expanded" | "dismissed" } | { action: "copied"; text: string };
|
|
29
|
+
|
|
30
|
+
const WIDGET_KEY = "wingman-headsup";
|
|
31
|
+
const LEVEL_COLOR: Partial<Record<VerdictLevel, ThemeColor>> = { concern: "warning", blocker: "error" };
|
|
32
|
+
/** 冷却期内最多积压的工具事件数,超出丢最旧的。 */
|
|
33
|
+
const MAX_PENDING = 30;
|
|
34
|
+
|
|
35
|
+
/** WINGMAN_DEBUG=1 时把管线决策追加到 <tmpdir>/wingman-debug.log;旁路默认静默,这是唯一的观测口。 */
|
|
36
|
+
function debug(message: string): void {
|
|
37
|
+
if (!process.env.WINGMAN_DEBUG) return;
|
|
38
|
+
try {
|
|
39
|
+
appendFileSync(join(tmpdir(), "wingman-debug.log"), `${new Date().toISOString()} ${message}\n`);
|
|
40
|
+
} catch {
|
|
41
|
+
// 调试通道自身永不抛错
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export class HeadsUpView {
|
|
46
|
+
private finding: Verdict | undefined;
|
|
47
|
+
private expanded = false;
|
|
48
|
+
private readonly suppressed = new Set<string>();
|
|
49
|
+
|
|
50
|
+
private key(finding: Verdict): string {
|
|
51
|
+
return `${finding.level}:${finding.summary}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 上屏新发现;同一发现(level+summary)已被抑制时返回 false。 */
|
|
55
|
+
show(finding: Verdict): boolean {
|
|
56
|
+
if (this.suppressed.has(this.key(finding))) return false;
|
|
57
|
+
this.finding = finding;
|
|
58
|
+
this.expanded = false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
hasPending(): boolean {
|
|
63
|
+
return this.finding !== undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 三态按键:折叠时 1 展开、0/2 忽略/已知悉(v1 同义,都抑制);
|
|
68
|
+
* 展开时 1 知道了、0 忽略(都 dismiss)、2 生成引用块后 dismiss。
|
|
69
|
+
*/
|
|
70
|
+
handleKey(key: "0" | "1" | "2"): HeadsUpAction {
|
|
71
|
+
if (!this.finding) return { action: "none" };
|
|
72
|
+
if (!this.expanded && key === "1") {
|
|
73
|
+
this.expanded = true;
|
|
74
|
+
return { action: "expanded" };
|
|
75
|
+
}
|
|
76
|
+
if (this.expanded && key === "2") {
|
|
77
|
+
const text = this.copyText(this.finding);
|
|
78
|
+
this.dismiss();
|
|
79
|
+
return { action: "copied", text };
|
|
80
|
+
}
|
|
81
|
+
this.dismiss();
|
|
82
|
+
return { action: "dismissed" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private dismiss(): void {
|
|
86
|
+
if (this.finding) this.suppressed.add(this.key(this.finding));
|
|
87
|
+
this.finding = undefined;
|
|
88
|
+
this.expanded = false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** 当前状态的渲染行;无 finding 时为空数组。每行已按宽度截断。 */
|
|
92
|
+
lines(width: number, theme: Pick<Theme, "fg">): string[] {
|
|
93
|
+
if (!this.finding) return [];
|
|
94
|
+
const color = LEVEL_COLOR[this.finding.level];
|
|
95
|
+
const title = `✦ Heads up · ${color ? theme.fg(color, this.finding.summary) : this.finding.summary}`;
|
|
96
|
+
const options = theme.fg("dim", this.expanded ? " 1: 知道了 2: 带到主会话 0: 忽略" : " 1: 展开 2: 已知悉 0: 忽略");
|
|
97
|
+
const lines = this.expanded
|
|
98
|
+
? [title, "", ...this.finding.details.map((d) => `- ${d}`), "", options]
|
|
99
|
+
: [title, options];
|
|
100
|
+
return lines.map((line) => truncateToWidth(line, width));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** finding 的引用块格式(「带到主会话」内容)。 */
|
|
104
|
+
private copyText(finding: Verdict): string {
|
|
105
|
+
const quoted = [`> Heads up · ${finding.summary}`];
|
|
106
|
+
if (finding.details.length > 0) {
|
|
107
|
+
quoted.push(">");
|
|
108
|
+
for (const detail of finding.details) quoted.push(`> - ${detail}`);
|
|
109
|
+
}
|
|
110
|
+
return `Here is a note offered by a side agent:\n\n${quoted.join("\n")}`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ToolEventRecord {
|
|
115
|
+
toolName: string;
|
|
116
|
+
isError: boolean;
|
|
117
|
+
snippet: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function formatToolEvents(events: ToolEventRecord[]): string {
|
|
121
|
+
return events
|
|
122
|
+
.map((e) => `${e.toolName} → ${e.isError ? "ERROR" : "ok"}${e.snippet ? ` | ${e.snippet}` : ""}`)
|
|
123
|
+
.join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function snippetOf(result: unknown): string {
|
|
127
|
+
const raw = typeof result === "string" ? result : JSON.stringify(result) || "";
|
|
128
|
+
return raw.replace(/\s+/g, " ").slice(0, 100);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export interface HeadsUpOptions {
|
|
132
|
+
batchSize?: number;
|
|
133
|
+
debounceMs?: number;
|
|
134
|
+
minIntervalMs?: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function installHeadsUp(
|
|
138
|
+
pi: ExtensionAPI,
|
|
139
|
+
getConfig: () => WingmanConfig,
|
|
140
|
+
getGoal: () => string,
|
|
141
|
+
editors: EditorManager,
|
|
142
|
+
options: HeadsUpOptions = {},
|
|
143
|
+
): void {
|
|
144
|
+
const batchSize = options.batchSize ?? 6;
|
|
145
|
+
const debounceMs = options.debounceMs ?? 8_000;
|
|
146
|
+
const auditor = new Auditor({ minIntervalMs: options.minIntervalMs ?? 30_000 });
|
|
147
|
+
const view = new HeadsUpView();
|
|
148
|
+
const shutdown = new AbortController();
|
|
149
|
+
let pending: ToolEventRecord[] = [];
|
|
150
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
151
|
+
let lastCtx: ExtensionContext | undefined;
|
|
152
|
+
let activeTui: { requestRender(): void } | undefined;
|
|
153
|
+
let widgetShown = false;
|
|
154
|
+
|
|
155
|
+
const hideWidget = (ctx: ExtensionContext) => {
|
|
156
|
+
if (!widgetShown) return;
|
|
157
|
+
widgetShown = false;
|
|
158
|
+
activeTui = undefined;
|
|
159
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const showWidget = (ctx: ExtensionContext) => {
|
|
163
|
+
if (widgetShown) return;
|
|
164
|
+
widgetShown = true;
|
|
165
|
+
ctx.ui.setWidget(
|
|
166
|
+
WIDGET_KEY,
|
|
167
|
+
(tui, theme) => {
|
|
168
|
+
activeTui = tui;
|
|
169
|
+
return {
|
|
170
|
+
render: (width: number) => view.lines(width, theme),
|
|
171
|
+
invalidate: () => {},
|
|
172
|
+
dispose: () => {
|
|
173
|
+
activeTui = undefined;
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
},
|
|
177
|
+
{ placement: "belowEditor" },
|
|
178
|
+
);
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const clearTimer = () => {
|
|
182
|
+
if (timer) clearTimeout(timer);
|
|
183
|
+
timer = undefined;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const arm = (ctx: ExtensionContext) => {
|
|
187
|
+
clearTimer();
|
|
188
|
+
timer = setTimeout(() => runFlush(ctx), debounceMs);
|
|
189
|
+
timer.unref?.();
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const flush = async (ctx: ExtensionContext): Promise<void> => {
|
|
193
|
+
clearTimer();
|
|
194
|
+
if (pending.length === 0) return;
|
|
195
|
+
const config = getConfig().headsup;
|
|
196
|
+
// heads-up 必须显式配置模型才激活(开启必经 /headsup <model>);拼错模型也不回退当前模型
|
|
197
|
+
if (!config.enabled || !config.model) {
|
|
198
|
+
debug(`flush 跳过:enabled=${config.enabled} model=${config.model}`);
|
|
199
|
+
pending = [];
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
const model = resolveModel(ctx.modelRegistry, config.model, undefined);
|
|
203
|
+
if (!model) {
|
|
204
|
+
debug(`flush 跳过:模型无法解析 ${config.model}`);
|
|
205
|
+
pending = [];
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (!auditor.ready) {
|
|
209
|
+
debug(`flush 推迟:审计冷却或在途,保留 ${pending.length} 个事件`);
|
|
210
|
+
arm(ctx);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const events = pending;
|
|
214
|
+
pending = [];
|
|
215
|
+
debug(`flush: ${events.length} 个事件送审计`);
|
|
216
|
+
const verdict = await auditor.run(
|
|
217
|
+
ctx.modelRegistry,
|
|
218
|
+
model,
|
|
219
|
+
{ goal: getGoal(), digest: formatToolEvents(events) },
|
|
220
|
+
{ timeoutMs: 15_000, signal: shutdown.signal },
|
|
221
|
+
);
|
|
222
|
+
debug(`verdict: ${verdict ? `${verdict.level} ${verdict.summary}` : "(调用失败/跳过)"}`);
|
|
223
|
+
if (!verdict || (verdict.level !== "concern" && verdict.level !== "blocker")) return;
|
|
224
|
+
if (!view.show(verdict)) {
|
|
225
|
+
debug("已在抑制集,不上屏");
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
showWidget(ctx);
|
|
229
|
+
debug("上屏");
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** 后台执行 flush:已在 runner 的 try/catch 之外,自己兜底(会话替换后 UI 调用可能抛)。 */
|
|
233
|
+
const runFlush = (ctx: ExtensionContext) => {
|
|
234
|
+
flush(ctx).catch(() => {});
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
// 数字键捕获:仅编辑器为空时由 WingmanEditor 调用;已知限制——
|
|
238
|
+
// 有 pending heads-up 时,空编辑器里输入 0/1/2 开头的文本会被当作命令
|
|
239
|
+
// (对齐 Claude Code 的行为)。
|
|
240
|
+
editors.headsUpHandler = (key) => {
|
|
241
|
+
if (!view.hasPending() || !lastCtx) return false;
|
|
242
|
+
const result = view.handleKey(key);
|
|
243
|
+
switch (result.action) {
|
|
244
|
+
case "expanded":
|
|
245
|
+
activeTui?.requestRender();
|
|
246
|
+
return true;
|
|
247
|
+
case "copied":
|
|
248
|
+
hideWidget(lastCtx);
|
|
249
|
+
lastCtx.ui.setEditorText(result.text);
|
|
250
|
+
return true;
|
|
251
|
+
case "dismissed":
|
|
252
|
+
hideWidget(lastCtx);
|
|
253
|
+
return true;
|
|
254
|
+
case "none":
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
pi.on("tool_execution_end", (event, ctx) => {
|
|
260
|
+
lastCtx = ctx;
|
|
261
|
+
const config = getConfig().headsup;
|
|
262
|
+
if (!config.enabled || !config.model || ctx.mode !== "tui") return;
|
|
263
|
+
pending = [...pending, { toolName: event.toolName, isError: event.isError, snippet: snippetOf(event.result) }].slice(
|
|
264
|
+
-MAX_PENDING,
|
|
265
|
+
);
|
|
266
|
+
debug(`tool_end ${event.toolName}${event.isError ? " ERROR" : ""} pending=${pending.length}`);
|
|
267
|
+
if (pending.length >= batchSize) runFlush(ctx);
|
|
268
|
+
else arm(ctx);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
272
|
+
shutdown.abort();
|
|
273
|
+
clearTimer();
|
|
274
|
+
hideWidget(ctx);
|
|
275
|
+
});
|
|
276
|
+
}
|
package/hint.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hint.ts — 任务结束后的下一步 prompt 建议(参照 Claude Code prompt suggestions)。
|
|
3
|
+
*
|
|
4
|
+
* agent_end 后在后台用便宜模型生成 1 条 ≤120 字符的下一步 prompt(不 await,
|
|
5
|
+
* 不拖住 agent_end 派发),经 EditorManager 拿到共享 WingmanEditor 设置
|
|
6
|
+
* pending hint(ghost 渲染与 Tab/Enter/清除状态机在 editor.ts)。
|
|
7
|
+
* before_agent_start 清除 hint 并使在途生成作废。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { buildDigest, type DigestMessage } from "./audit.js";
|
|
12
|
+
import type { WingmanConfig } from "./config.js";
|
|
13
|
+
import type { EditorManager } from "./editor.js";
|
|
14
|
+
import { completeText, resolveModel, type AnyModel, type ModelRegistry } from "./llm.js";
|
|
15
|
+
|
|
16
|
+
const HINT_SYSTEM_PROMPT = `The user is driving an AI coding agent. Given the user's goal and the latest transcript, suggest the single next prompt the user is most likely to send.
|
|
17
|
+
|
|
18
|
+
Rules:
|
|
19
|
+
- Output only the prompt text, no quotes, no explanation.
|
|
20
|
+
- At most 120 characters, one line.
|
|
21
|
+
- Continue the work naturally (next step, fix, verify, or commit); do not repeat what was just done.
|
|
22
|
+
- If the work looks finished, suggest a sensible follow-up or say "继续".`;
|
|
23
|
+
|
|
24
|
+
const MAX_HINT_CHARS = 120;
|
|
25
|
+
|
|
26
|
+
export function installHint(
|
|
27
|
+
pi: ExtensionAPI,
|
|
28
|
+
getConfig: () => WingmanConfig,
|
|
29
|
+
getGoal: () => string,
|
|
30
|
+
editors: EditorManager,
|
|
31
|
+
): void {
|
|
32
|
+
const shutdown = new AbortController();
|
|
33
|
+
/** 每个新 run 自增;生成回来时代际已变说明 hint 过期,丢弃。 */
|
|
34
|
+
let generation = 0;
|
|
35
|
+
|
|
36
|
+
const generate = async (registry: ModelRegistry, model: AnyModel, transcript: string, gen: number) => {
|
|
37
|
+
const goal = getGoal() ? `User's goal: ${getGoal()}\n\n` : "";
|
|
38
|
+
const result = await completeText(
|
|
39
|
+
registry,
|
|
40
|
+
model,
|
|
41
|
+
{
|
|
42
|
+
systemPrompt: HINT_SYSTEM_PROMPT,
|
|
43
|
+
messages: [
|
|
44
|
+
{
|
|
45
|
+
role: "user",
|
|
46
|
+
content: [{ type: "text", text: `${goal}Latest transcript:\n${transcript}` }],
|
|
47
|
+
timestamp: Date.now(),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
{ timeoutMs: 10_000, signal: shutdown.signal },
|
|
52
|
+
);
|
|
53
|
+
if (!result.ok || gen !== generation) return; // 静默降级 / 已过期
|
|
54
|
+
editors.current?.setHint(result.text.replace(/\s+/g, " ").trim().slice(0, MAX_HINT_CHARS));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
pi.on("before_agent_start", () => {
|
|
58
|
+
generation++;
|
|
59
|
+
editors.current?.setHint(undefined);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
pi.on("agent_end", (event, ctx) => {
|
|
63
|
+
const config = getConfig().hint;
|
|
64
|
+
if (!config.enabled || ctx.mode !== "tui" || !editors.current) return;
|
|
65
|
+
const model = resolveModel(ctx.modelRegistry, config.model, ctx.model);
|
|
66
|
+
if (!model) return;
|
|
67
|
+
const transcript = buildDigest(event.messages as DigestMessage[], { maxMessages: 6, perMessageChars: 500 });
|
|
68
|
+
if (!transcript) return;
|
|
69
|
+
// 后台执行已在 runner 的 try/catch 之外,自己兜底(会话替换后 UI 调用可能抛)
|
|
70
|
+
generate(ctx.modelRegistry, model, transcript, generation).catch(() => {});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
pi.on("session_shutdown", () => {
|
|
74
|
+
shutdown.abort();
|
|
75
|
+
});
|
|
76
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* index.ts — pi-wingman 入口。
|
|
3
|
+
*
|
|
4
|
+
* M1 hint.ts(下一步 prompt 建议)、M2 advisor.ts(turn 审计 + steer 注入)、
|
|
5
|
+
* M3 headsup.ts(去抖审计 + widget 三态交互)已接入。
|
|
6
|
+
* editor.ts 的 EditorManager 统一管理共享 WingmanEditor 的安装(hint 或
|
|
7
|
+
* headsup 任一开启即装);llm.ts 是共享旁路调用层,audit.ts 是 M2/M3
|
|
8
|
+
* 共享的审计管线。
|
|
9
|
+
*
|
|
10
|
+
* 配置入口对称:/advisor、/hint、/headsup 各接 <provider/model> 开启并设
|
|
11
|
+
* 模型(须能在 modelRegistry 解析)、on/off 开关(保留模型)、无参看状态;
|
|
12
|
+
* /wingman 总览。用户目标在此统一捕获,跳过 advisor 注入的旁注,供三个
|
|
13
|
+
* 功能共用。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { installAdvisor, isAdvisorNote } from "./advisor.js";
|
|
18
|
+
import { applyFeatureArg, featureLine, loadConfig, saveConfig, type WingmanConfig } from "./config.js";
|
|
19
|
+
import { EditorManager } from "./editor.js";
|
|
20
|
+
import { installHeadsUp } from "./headsup.js";
|
|
21
|
+
import { installHint } from "./hint.js";
|
|
22
|
+
import { resolveModel, type ModelRegistry } from "./llm.js";
|
|
23
|
+
|
|
24
|
+
export default function (pi: ExtensionAPI) {
|
|
25
|
+
let config: WingmanConfig = loadConfig();
|
|
26
|
+
let goal = "";
|
|
27
|
+
/** 参数补全拿不到 ctx,在 session_start 记下 registry。 */
|
|
28
|
+
let registry: ModelRegistry | undefined;
|
|
29
|
+
const editors = new EditorManager();
|
|
30
|
+
const getConfig = () => config;
|
|
31
|
+
const getGoal = () => goal;
|
|
32
|
+
|
|
33
|
+
pi.registerCommand("wingman", {
|
|
34
|
+
description: "Show wingman status (headsup / advisor / hint)",
|
|
35
|
+
handler: async (_args, ctx) => {
|
|
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
|
+
});
|
|
47
|
+
|
|
48
|
+
for (const feature of ["advisor", "hint", "headsup"] as const) {
|
|
49
|
+
pi.registerCommand(feature, {
|
|
50
|
+
description: `Configure wingman ${feature}: /${feature} <provider/model> | on | off`,
|
|
51
|
+
getArgumentCompletions: (prefix) => {
|
|
52
|
+
const models = registry?.getAvailable().map((m) => `${m.provider}/${m.id}`) ?? [];
|
|
53
|
+
const items = ["on", "off", ...models]
|
|
54
|
+
.filter((value) => value.startsWith(prefix))
|
|
55
|
+
.map((value) => ({ value, label: value }));
|
|
56
|
+
return items.length > 0 ? items : null;
|
|
57
|
+
},
|
|
58
|
+
handler: async (args, ctx) => {
|
|
59
|
+
const value = args.trim();
|
|
60
|
+
if (value && value !== "on" && value !== "off" && !resolveModel(ctx.modelRegistry, value, undefined)) {
|
|
61
|
+
ctx.ui.notify(`wingman · 找不到模型 ${value}(格式 provider/model-id)`, "error");
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (value === "on" && feature !== "hint" && !config[feature].model) {
|
|
65
|
+
ctx.ui.notify(`wingman · ${feature} 需要模型,先 /${feature} <provider/model>`, "error");
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const line = applyFeatureArg(config, feature, args);
|
|
69
|
+
if (args.trim()) {
|
|
70
|
+
saveConfig(config);
|
|
71
|
+
editors.sync(ctx, config);
|
|
72
|
+
}
|
|
73
|
+
ctx.ui.notify(`wingman · ${line}`, "info");
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
pi.on("session_start", (_event, ctx) => {
|
|
79
|
+
config = loadConfig();
|
|
80
|
+
registry = ctx.modelRegistry;
|
|
81
|
+
editors.sync(ctx, config);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
pi.on("before_agent_start", (event) => {
|
|
85
|
+
if (event.messageType === "user" && !isAdvisorNote(event.prompt)) goal = event.prompt;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
89
|
+
editors.teardown(ctx);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
installHint(pi, getConfig, getGoal, editors);
|
|
93
|
+
installAdvisor(pi, getConfig, getGoal);
|
|
94
|
+
installHeadsUp(pi, getConfig, getGoal, editors);
|
|
95
|
+
}
|
package/llm.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* llm.ts — 旁路模型调用封装。
|
|
3
|
+
*
|
|
4
|
+
* 经 ctx.modelRegistry.complete() 调 provider 模型:
|
|
5
|
+
* - resolveModel(registry, "provider/model-id") 解析配置串,未配置/找不到时
|
|
6
|
+
* 回退当前模型;
|
|
7
|
+
* - completeText() 统一超时与 AbortController(idle 时 ctx.signal 不存在,
|
|
8
|
+
* 旁路调用用自有 controller,session_shutdown 统一 abort)。
|
|
9
|
+
*
|
|
10
|
+
* 三个功能(headsup / advisor / hint)共用这一层,不直接碰 modelRegistry。
|
|
11
|
+
* 失败一律返回 { ok: false, reason },不抛异常进 pi 事件循环。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
|
|
16
|
+
export type ModelRegistry = ExtensionContext["modelRegistry"];
|
|
17
|
+
export type AnyModel = NonNullable<ExtensionContext["model"]>;
|
|
18
|
+
export type LlmContext = Parameters<ModelRegistry["complete"]>[1];
|
|
19
|
+
/** pi-ai 的 Message:按 role 可辨识的联合(system/user/assistant/toolResult)。 */
|
|
20
|
+
export type Message = LlmContext["messages"][number];
|
|
21
|
+
|
|
22
|
+
export type CompleteResult = { ok: true; text: string } | { ok: false; reason: string };
|
|
23
|
+
|
|
24
|
+
/** 拼接内容块里的文本块。 */
|
|
25
|
+
function textOf(content: readonly { type: string }[]): string {
|
|
26
|
+
return content
|
|
27
|
+
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
28
|
+
.map((c) => c.text)
|
|
29
|
+
.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 提取消息正文的文本块;事件里的 AgentMessage 可能混入无 content 的自定义消息,故守卫数组。 */
|
|
33
|
+
export function messageText(message: Message): string {
|
|
34
|
+
const { content } = message;
|
|
35
|
+
if (typeof content === "string") return content;
|
|
36
|
+
return Array.isArray(content) ? textOf(content) : "";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 解析 "provider/model-id";缺参或找不到时回退 fallback(hint 传当前模型;
|
|
41
|
+
* advisor/heads-up 传 undefined,拼错模型时不激活而非悄悄用主模型)。
|
|
42
|
+
*/
|
|
43
|
+
export function resolveModel(
|
|
44
|
+
registry: ModelRegistry,
|
|
45
|
+
spec: string | undefined,
|
|
46
|
+
fallback: AnyModel | undefined,
|
|
47
|
+
): AnyModel | undefined {
|
|
48
|
+
if (!spec) return fallback;
|
|
49
|
+
const slash = spec.indexOf("/");
|
|
50
|
+
if (slash <= 0 || slash === spec.length - 1) return fallback;
|
|
51
|
+
return registry.find(spec.slice(0, slash), spec.slice(slash + 1)) ?? fallback;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 非流式补全,统一超时与中断。外部 signal(如 session_shutdown)触发时
|
|
56
|
+
* 一并 abort;超时、stopReason 非 stop、空文本都算失败,原因写进 reason。
|
|
57
|
+
*/
|
|
58
|
+
export async function completeText(
|
|
59
|
+
registry: ModelRegistry,
|
|
60
|
+
model: AnyModel,
|
|
61
|
+
context: LlmContext,
|
|
62
|
+
options: { timeoutMs?: number; signal?: AbortSignal } = {},
|
|
63
|
+
): Promise<CompleteResult> {
|
|
64
|
+
const controller = new AbortController();
|
|
65
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? 15_000);
|
|
66
|
+
const onOuterAbort = () => controller.abort();
|
|
67
|
+
options.signal?.addEventListener("abort", onOuterAbort, { once: true });
|
|
68
|
+
try {
|
|
69
|
+
const response = await registry.complete(model, context, { signal: controller.signal });
|
|
70
|
+
if (response.stopReason !== "stop") {
|
|
71
|
+
return { ok: false, reason: `stopReason=${response.stopReason}` };
|
|
72
|
+
}
|
|
73
|
+
const text = textOf(response.content).trim();
|
|
74
|
+
if (!text) return { ok: false, reason: "empty response" };
|
|
75
|
+
return { ok: true, text };
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return { ok: false, reason: error instanceof Error ? error.message : String(error) };
|
|
78
|
+
} finally {
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
options.signal?.removeEventListener("abort", onOuterAbort);
|
|
81
|
+
}
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@master.g/pi-wingman",
|
|
3
|
+
"version": "0.0.1",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "master-g",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi",
|
|
11
|
+
"pi-coding-agent",
|
|
12
|
+
"extension",
|
|
13
|
+
"heads-up",
|
|
14
|
+
"advisor",
|
|
15
|
+
"hint"
|
|
16
|
+
],
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/master-g/pi-wingman.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/master-g/pi-wingman#readme",
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"*.ts",
|
|
27
|
+
"!*.test.ts",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./index.ts"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"test": "bun test",
|
|
38
|
+
"typecheck": "tsc --noEmit"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
42
|
+
"@earendil-works/pi-tui": "*"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
46
|
+
"@types/node": "^25.9.3",
|
|
47
|
+
"bun-types": "^1.4.3",
|
|
48
|
+
"typescript": "^5.9.0"
|
|
49
|
+
}
|
|
50
|
+
}
|