@master.g/pi-wingman 0.0.2 → 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 +3 -1
- package/advisor.ts +10 -3
- package/audit.ts +17 -7
- package/editor.ts +4 -5
- package/headsup.ts +10 -4
- package/index.ts +1 -0
- package/llm.ts +2 -1
- package/package.json +1 -1
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
|
-
> 状态:早期版本(v0.0.
|
|
13
|
+
> 状态:早期版本(v0.0.3)。hint、advisor、heads-up 三个功能均已实现,默认关闭,尚在试用打磨中。
|
|
14
14
|
|
|
15
15
|
## 安装
|
|
16
16
|
|
|
@@ -30,6 +30,8 @@ pi --extension ~/github/pi-wingman/index.ts
|
|
|
30
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`),由命令自动写入,也可手改:
|
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,14 +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 留余地
|
|
76
79
|
{ timeoutMs: 60_000, signal: shutdown.signal, effort: getConfig().advisor.effort },
|
|
77
80
|
);
|
|
78
|
-
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;
|
|
79
86
|
ctx.ui.notify(`advisor [${verdict.level}] ${verdict.summary}`, NOTIFY_TYPE[verdict.level]);
|
|
80
87
|
if (ctx.isIdle()) return; // 裁决迟到:agent 已停,只提示用户,不注入
|
|
81
88
|
pi.sendUserMessage(formatAdvisorNote(verdict), { deliverAs: "steer" });
|
package/audit.ts
CHANGED
|
@@ -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 {
|
|
@@ -93,7 +102,7 @@ export async function runAudit(
|
|
|
93
102
|
model: AnyModel,
|
|
94
103
|
input: { goal: string; digest: string },
|
|
95
104
|
options: CallOptions = {},
|
|
96
|
-
): Promise<
|
|
105
|
+
): Promise<AuditResult> {
|
|
97
106
|
const goal = input.goal ? `用户目标:${input.goal}\n\n` : "";
|
|
98
107
|
const result = await completeText(
|
|
99
108
|
registry,
|
|
@@ -110,8 +119,9 @@ export async function runAudit(
|
|
|
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(去抖)共用一个实例各管各的。 */
|
|
@@ -136,14 +146,14 @@ export class Auditor {
|
|
|
136
146
|
model: AnyModel,
|
|
137
147
|
input: { goal: string; digest: string },
|
|
138
148
|
options: CallOptions = {},
|
|
139
|
-
): Promise<
|
|
140
|
-
if (!this.ready) return undefined;
|
|
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/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 {
|
|
@@ -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
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/index.ts
CHANGED
|
@@ -116,6 +116,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
116
116
|
if (value) {
|
|
117
117
|
saveConfig(config);
|
|
118
118
|
editors.sync(ctx, config);
|
|
119
|
+
if (!config[feature].enabled) ctx.ui.setStatus(`wingman-${feature}`, undefined);
|
|
119
120
|
}
|
|
120
121
|
ctx.ui.notify(`wingman · ${line}`, "info");
|
|
121
122
|
},
|
package/llm.ts
CHANGED
|
@@ -76,7 +76,8 @@ export async function completeText(
|
|
|
76
76
|
.streamSimple(model, context, { signal: controller.signal, reasoning: effort === "off" ? undefined : effort })
|
|
77
77
|
.result();
|
|
78
78
|
if (response.stopReason !== "stop") {
|
|
79
|
-
|
|
79
|
+
const detail = response.errorMessage ? `: ${response.errorMessage}` : "";
|
|
80
|
+
return { ok: false, reason: `stopReason=${response.stopReason}${detail}` };
|
|
80
81
|
}
|
|
81
82
|
const text = textOf(response.content).trim();
|
|
82
83
|
if (!text) return { ok: false, reason: "empty response" };
|
package/package.json
CHANGED