pi-firecode 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -41
- package/README.zh-CN.md +74 -0
- package/config.example.jsonc +21 -22
- package/dist/config.example.jsonc +90 -0
- package/dist/index.js +11464 -0
- package/dist/master/prompts/master.en.md +22 -0
- package/{master → dist/master}/prompts/master.zh.md +3 -3
- package/dist/master/prompts/worker.en.md +1 -0
- package/dist/watcher/prompts/watch.en.md +43 -0
- package/package.json +10 -27
- package/activity.ts +0 -92
- package/busy.ts +0 -188
- package/config.ts +0 -500
- package/deliver.ts +0 -119
- package/flame.ts +0 -149
- package/format.ts +0 -133
- package/header.ts +0 -218
- package/herdr-client.ts +0 -60
- package/index.ts +0 -68
- package/jsonc.ts +0 -34
- package/master/actions.ts +0 -293
- package/master/activity-list.ts +0 -281
- package/master/event-card.ts +0 -87
- package/master/event-format.ts +0 -83
- package/master/guard.ts +0 -46
- package/master/index.ts +0 -252
- package/master/list-view.ts +0 -129
- package/master/outbox.ts +0 -188
- package/master/prompt.ts +0 -26
- package/master/role.ts +0 -18
- package/master/run.ts +0 -315
- package/master/runtime.ts +0 -259
- package/master/spawn.ts +0 -234
- package/master/state.ts +0 -225
- package/master/worker-view.ts +0 -517
- package/provider/claude-sub.ts +0 -151
- package/provider/openai-native/index.ts +0 -8
- package/provider/openai-native/src/compact-client.ts +0 -350
- package/provider/openai-native/src/config.ts +0 -297
- package/provider/openai-native/src/extension.ts +0 -93
- package/provider/openai-native/src/native-compaction.ts +0 -151
- package/provider/openai-native/src/native-details.ts +0 -157
- package/provider/openai-native/src/native-replay.ts +0 -253
- package/provider/openai-native/src/native-runtime.ts +0 -144
- package/provider/openai-native/src/options.ts +0 -87
- package/provider/openai-native/src/request-pipeline.ts +0 -21
- package/provider/openai-native/src/responses-input.ts +0 -433
- package/review/advisor.ts +0 -110
- package/review/card.ts +0 -398
- package/review/checkpoint.ts +0 -378
- package/review/evidence.ts +0 -230
- package/review/index.ts +0 -992
- package/review/occupancy.ts +0 -25
- package/review/outcome.ts +0 -98
- package/review/prompt.ts +0 -209
- package/review/reviewer.ts +0 -353
- package/review/session.ts +0 -110
- package/review/state.ts +0 -877
- package/review/ui.ts +0 -89
- package/round-recorder.ts +0 -18
- package/session/herdr-display.ts +0 -87
- package/session/presets.ts +0 -300
- package/session/quota.ts +0 -123
- package/session/rename.ts +0 -46
- package/session/stats.ts +0 -313
- package/statusbar/index.ts +0 -256
- package/statusbar/render.ts +0 -113
- package/theme.ts +0 -45
- package/tools/actions.ts +0 -53
- package/tools/assistant-view.ts +0 -64
- package/tools/click-anchor.ts +0 -56
- package/tools/group-view.ts +0 -420
- package/tools/grouping.ts +0 -158
- package/tools/host.ts +0 -369
- package/tools/index.ts +0 -186
- package/tools/line.ts +0 -231
- package/tools/machine.ts +0 -78
- package/tools/parts.ts +0 -143
- package/tools/round.ts +0 -95
- package/tools/timing.ts +0 -22
- package/tools/turn-clock.ts +0 -41
- package/tools/turn-summary.ts +0 -109
- package/truncated-write.ts +0 -18
- package/watcher/card.ts +0 -91
- package/watcher/index.ts +0 -176
- package/watcher/observer.ts +0 -75
- package/watcher/transcript.ts +0 -56
- /package/{master → dist/master}/prompts/worker.zh.md +0 -0
- /package/{review → dist/review}/prompts/advisor.en.md +0 -0
- /package/{review → dist/review}/prompts/advisor.zh.md +0 -0
- /package/{review → dist/review}/prompts/review.en.md +0 -0
- /package/{review → dist/review}/prompts/review.zh.md +0 -0
- /package/{watcher → dist/watcher}/prompts/watch.zh.md +0 -0
package/tools/round.ts
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 轮记录:一段会话进行中(一次人类输入到歇下,含等子代理)的事后事实——整段时长与终态。
|
|
3
|
-
* 歇下边沿写成官方 CustomEntry 持久化,自己不占行;过程组摘要行落定时读它,重载后依然有数。
|
|
4
|
-
*/
|
|
5
|
-
import type { CustomEntry, EntryRenderer } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import type { Component } from "@earendil-works/pi-tui";
|
|
7
|
-
import type { SettledRound } from "../busy.js";
|
|
8
|
-
import { parseEnvelopes } from "../deliver.js";
|
|
9
|
-
import { textOf } from "../format.js";
|
|
10
|
-
|
|
11
|
-
export const ROUND_ENTRY = "firecode-round";
|
|
12
|
-
/** 轮记录写进会话之后在进程内总线上发布(无 payload):订阅方此刻读分支一定已含这条记录,不依赖歇下边沿的订阅顺序。 */
|
|
13
|
-
export const ROUND_RECORDED_CHANNEL = "firecode:round-recorded";
|
|
14
|
-
|
|
15
|
-
export interface Round extends SettledRound {
|
|
16
|
-
/** 歇下时刻(宿主记录的 entry 时间戳)。 */
|
|
17
|
-
at: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** 零行标记组件:只把记录带进聊天树,供投影按能力识别。 */
|
|
21
|
-
interface RoundMarker extends Component {
|
|
22
|
-
round: Round;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** 零行标记:投影按 roundOf 识别。会话里的轮记录经宿主渲染成它,没有轮记录的会话(子代理)也可按运行边界直接造。 */
|
|
26
|
-
export function roundMarker(round: Round): Component {
|
|
27
|
-
const marker: RoundMarker = { round, render: () => [], invalidate() {} };
|
|
28
|
-
return marker;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** 本模块写的 entry 一定带 data;类型上的 data? 只是宿主给所有 CustomEntry 的通用形状。 */
|
|
32
|
-
export const renderRound: EntryRenderer<SettledRound> = (entry: CustomEntry<SettledRound>) =>
|
|
33
|
-
roundMarker({ ...(entry.data as SettledRound), at: Date.parse(entry.timestamp) });
|
|
34
|
-
|
|
35
|
-
/** 一轮里更早的非完成终态的短标记。 */
|
|
36
|
-
const EARLIER_TEXT = { aborted: "中断过", error: "请求失败过" } as const;
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* 一轮可能有多条记录(中断后又跑了一段,如 /fire-review 或命令触发的再次进行)。合成规则不丢信息:
|
|
40
|
-
* 耗时累加(用户关心这一轮总共花了多久),终态与落定时刻取最后一条(这一轮最终怎样),更早的中断/请求失败
|
|
41
|
-
* 以“中断过 N 次”追加;多段的均速没有请求墙钟无法合成,按“不出半截的数”不给。
|
|
42
|
-
*/
|
|
43
|
-
export interface TurnRecord {
|
|
44
|
-
round: Round;
|
|
45
|
-
earlier: string[];
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function combineRounds(rounds: readonly Round[]): TurnRecord | undefined {
|
|
49
|
-
const last = rounds.at(-1);
|
|
50
|
-
if (!last) return undefined;
|
|
51
|
-
const elapsed = rounds.reduce((total, round) => total + round.elapsed, 0);
|
|
52
|
-
const round: Round = rounds.length === 1 ? last : { elapsed, outcome: last.outcome, at: last.at };
|
|
53
|
-
const earlier = (["aborted", "error"] as const).flatMap((outcome) => {
|
|
54
|
-
const count = rounds.slice(0, -1).filter((round) => round.outcome === outcome).length;
|
|
55
|
-
return count ? [`${EARLIER_TEXT[outcome]} ${count} 次`] : [];
|
|
56
|
-
});
|
|
57
|
-
return { round, earlier };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** 会话分支条目里本模块读到的部分(宿主 SessionEntry 的结构子集)。 */
|
|
61
|
-
export type BranchEntry =
|
|
62
|
-
| { type: "custom"; customType: string; data?: unknown; timestamp: string }
|
|
63
|
-
| { type: "message"; message: { role: string; content?: unknown } }
|
|
64
|
-
| { type: string };
|
|
65
|
-
|
|
66
|
-
const isHumanEntry = (entry: BranchEntry) => entry.type === "message" && "message" in entry
|
|
67
|
-
&& entry.message.role === "user" && !parseEnvelopes(textOf(entry.message.content));
|
|
68
|
-
|
|
69
|
-
/** 会话条目若是轮记录,给出它(带落定时刻);子代理全过程视图与 Master 的本次运行耗时都按它读子代理会话。 */
|
|
70
|
-
export function roundFromEntry(entry: unknown): Round | undefined {
|
|
71
|
-
const record = entry as { type?: unknown; customType?: unknown; data?: unknown; timestamp?: unknown };
|
|
72
|
-
if (record?.type !== "custom" || record.customType !== ROUND_ENTRY || typeof record.timestamp !== "string") return undefined;
|
|
73
|
-
return { ...(record.data as SettledRound), at: Date.parse(record.timestamp) };
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* 当前分支最近一轮的落定事实:最近一条人类消息之后的全部轮记录,按 combineRounds 合成。
|
|
78
|
-
* 输入框上边框落定态读它,与摘要行是同一份记录、同一条合成规则。
|
|
79
|
-
*/
|
|
80
|
-
export function latestTurnRecord(branch: readonly BranchEntry[]): TurnRecord | undefined {
|
|
81
|
-
const rounds: Round[] = [];
|
|
82
|
-
for (let index = branch.length - 1; index >= 0; index--) {
|
|
83
|
-
const entry = branch[index];
|
|
84
|
-
if (isHumanEntry(entry)) break;
|
|
85
|
-
const round = roundFromEntry(entry);
|
|
86
|
-
if (round) rounds.unshift(round);
|
|
87
|
-
}
|
|
88
|
-
return combineRounds(rounds);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** 宿主把 entry 包成 Container(Spacer + 渲染器组件);按能力找标记,不依赖类身份。 */
|
|
92
|
-
export function roundOf(component: Component): Round | undefined {
|
|
93
|
-
const children = (component as { children?: readonly Component[] }).children;
|
|
94
|
-
return children?.find((child): child is RoundMarker => "round" in child)?.round;
|
|
95
|
-
}
|
package/tools/timing.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/** 工具真实执行耗时:execute 侧写入,渲染侧按 toolCallId 取走。 */
|
|
2
|
-
const durations = new Map<string, number>();
|
|
3
|
-
|
|
4
|
-
export async function executeTimed<T>(id: string, execute: () => Promise<T>): Promise<T> {
|
|
5
|
-
const startedAt = performance.now();
|
|
6
|
-
try {
|
|
7
|
-
return await execute();
|
|
8
|
-
} finally {
|
|
9
|
-
durations.set(id, performance.now() - startedAt);
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** 取一次即释放,避免长会话里堆积。 */
|
|
14
|
-
export function takeDuration(id: string): number | undefined {
|
|
15
|
-
const duration = durations.get(id);
|
|
16
|
-
if (duration !== undefined) durations.delete(id);
|
|
17
|
-
return duration;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function clearDurations(): void {
|
|
21
|
-
durations.clear();
|
|
22
|
-
}
|
package/tools/turn-clock.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 轮次时钟:把 busy.ts 的会话进行中事实投影到各轮摘要行——哪一轮开着、机器消息是否刚到。
|
|
3
|
-
* 落定后的耗时与终态不在这里,来自聊天树里的轮记录(round.ts)。
|
|
4
|
-
*/
|
|
5
|
-
import { type BusyView, IDLE } from "../busy.js";
|
|
6
|
-
|
|
7
|
-
/** 子代理结果到达后摘要行高亮多久。 */
|
|
8
|
-
export const ARRIVAL_FLASH_MS = 2500;
|
|
9
|
-
|
|
10
|
-
export class TurnClock {
|
|
11
|
-
private busy: BusyView = IDLE;
|
|
12
|
-
private lastKey?: object;
|
|
13
|
-
private readonly arrivals = new WeakMap<object, number>();
|
|
14
|
-
|
|
15
|
-
constructor(readonly now: () => number = Date.now) {}
|
|
16
|
-
|
|
17
|
-
sync(view: BusyView): void {
|
|
18
|
-
this.busy = view;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** 指挥官自己的回合在跑;否则这一段只是在等子代理,摘要行没有当前动作可说。 */
|
|
22
|
-
get agentRunning(): boolean {
|
|
23
|
-
return this.busy.agentRunning;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/** 投影每次渲染声明当前最后一轮。 */
|
|
27
|
-
track(key: object): void {
|
|
28
|
-
this.lastKey = key;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** 会话正在进行且这是最后一轮。 */
|
|
32
|
-
live(key: object): boolean {
|
|
33
|
-
return this.busy.busy && key === this.lastKey;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** 机器消息距首次出现多久;只有运行中出现的才算“新到达”,恢复的历史永远是旧的。 */
|
|
37
|
-
arrivalAge(item: object): number {
|
|
38
|
-
if (!this.arrivals.has(item)) this.arrivals.set(item, this.busy.busy ? this.now() : -Infinity);
|
|
39
|
-
return this.now() - this.arrivals.get(item)!;
|
|
40
|
-
}
|
|
41
|
-
}
|
package/tools/turn-summary.ts
DELETED
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 一轮的摘要行:只承担运行状态、事后记录与异常提醒。运行中是火苗 + 当前动作(实时计时只在边框),落定后是 ✓ 整段耗时 · 均速,
|
|
3
|
-
* 中断/请求失败是 ✗ 加终态字样;这一轮有子代理失败追加“N 个子代理失败”,宿主的 ⚠ 提示原文也追加。
|
|
4
|
-
* 工具失败是模型的工作过程,不计数、不影响标记。纯渲染,不碰宿主组件。
|
|
5
|
-
*/
|
|
6
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
7
|
-
import { type Component, type TuiMouseEvent, visibleWidth } from "@earendil-works/pi-tui";
|
|
8
|
-
import { HEAT_COLORS, flame, mix, paint, settleMark } from "../flame.js";
|
|
9
|
-
import { OUTCOME_TEXT, roundTexts } from "../busy.js";
|
|
10
|
-
import { clip } from "../format.js";
|
|
11
|
-
import { CHAT_GUTTER } from "./line.js";
|
|
12
|
-
import type { Round } from "./round.js";
|
|
13
|
-
import { ARRIVAL_FLASH_MS } from "./turn-clock.js";
|
|
14
|
-
|
|
15
|
-
export interface SummaryView {
|
|
16
|
-
/** 折入段内的首条宿主提示原文(缓存、丢思考、压缩计费)。 */
|
|
17
|
-
notice?: string;
|
|
18
|
-
live: boolean;
|
|
19
|
-
/** 运行中的当前动作(动作词 + 简短目标);落定后或指挥官只在等子代理时没有,只画火苗。 */
|
|
20
|
-
action?: { word: string; target?: string };
|
|
21
|
-
/** 子代理结果刚到达:短暂替换当前动作。 */
|
|
22
|
-
arrival?: { text: string; failed: boolean; age: number };
|
|
23
|
-
/** 这一轮里失败的子代理数(按名字去重)。 */
|
|
24
|
-
failures?: number;
|
|
25
|
-
/** 落定后的轮记录:这一轮各段合成的耗时与终态(见 round.ts 的 combineRounds)。 */
|
|
26
|
-
round?: Round;
|
|
27
|
-
/** 这一轮更早几段的非完成终态短标记,如“中断过 1 次”。 */
|
|
28
|
-
earlier?: string[];
|
|
29
|
-
sinceEnd?: number;
|
|
30
|
-
/** 单轮展开/收起;全局展开时为空,点击无效。 */
|
|
31
|
-
toggle?: () => void;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** 提示原文被目标挤压时至少保留的列数。 */
|
|
35
|
-
const NOTICE_MIN = 12;
|
|
36
|
-
/** 目标少于这个宽度就不显示,只留动作词。 */
|
|
37
|
-
const MIN_TARGET = 4;
|
|
38
|
-
|
|
39
|
-
export class TurnSummary implements Component {
|
|
40
|
-
constructor(private readonly view: SummaryView, private readonly theme: Theme) {}
|
|
41
|
-
invalidate(): void {}
|
|
42
|
-
|
|
43
|
-
render(full: number): string[] {
|
|
44
|
-
const width = full - CHAT_GUTTER;
|
|
45
|
-
const { theme, view } = this;
|
|
46
|
-
const sep = theme.fg("dim", " · ");
|
|
47
|
-
const outcome = this.outcomeText();
|
|
48
|
-
const word = view.live ? this.actionText() : outcome;
|
|
49
|
-
const target = view.live && view.action?.target && !this.flashing() ? view.action.target : undefined;
|
|
50
|
-
const glyph = view.live
|
|
51
|
-
? flame(1, 0.05)
|
|
52
|
-
: settleMark(outcome ? "failed" : "done", view.sinceEnd ?? Infinity);
|
|
53
|
-
const head = word ? `${glyph} ${word}` : glyph;
|
|
54
|
-
const record = view.round === undefined ? [] : roundTexts(view.round).map((text) => theme.fg("muted", text));
|
|
55
|
-
// 失败数与更早的中断都是不能丢的固定标记,窄屏时与它们一起保留。
|
|
56
|
-
const failures = [
|
|
57
|
-
...(view.earlier ?? []).map((text) => theme.fg("warning", text)),
|
|
58
|
-
...(view.failures ? [theme.fg("error", `${view.failures} 个子代理失败`)] : []),
|
|
59
|
-
];
|
|
60
|
-
const join = (lead: string, parts: string[]) => `${lead}${parts.map((part, index) => (index === 0 && !word ? " " : sep) + part).join("")}`;
|
|
61
|
-
const noticeNeed = view.notice ? visibleWidth(sep) + 2 + Math.min(visibleWidth(view.notice), NOTICE_MIN) : 0;
|
|
62
|
-
// 窄屏先裁目标(给提示原文留出最小空间),再裁提示原文,仍放不下再丢记录,最后丢提示原文;动作词与失败数是固定标记。
|
|
63
|
-
for (const base of [[...record, ...failures], failures]) {
|
|
64
|
-
const targetRoom = width - visibleWidth(join(head, base)) - noticeNeed - 1;
|
|
65
|
-
const lead = target && targetRoom >= MIN_TARGET ? `${head} ${theme.fg("muted", clip(target, targetRoom))}` : head;
|
|
66
|
-
const used = visibleWidth(join(lead, base));
|
|
67
|
-
if (!view.notice && used <= width) return [join(lead, base)];
|
|
68
|
-
const noticeRoom = width - used - visibleWidth(sep) - 2;
|
|
69
|
-
if (view.notice && noticeRoom >= 4) return [join(lead, [...base, theme.fg("warning", `⚠ ${clip(view.notice, noticeRoom)}`)])];
|
|
70
|
-
}
|
|
71
|
-
const marked = join(head, failures);
|
|
72
|
-
return [visibleWidth(marked) <= width ? marked : clip(head, Math.max(1, width))];
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
private flashing(): boolean {
|
|
76
|
-
return !!this.view.arrival && this.view.arrival.age < ARRIVAL_FLASH_MS;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
private outcomeText(): string {
|
|
80
|
-
const text = OUTCOME_TEXT[this.view.round?.outcome ?? "complete"];
|
|
81
|
-
return text && this.theme.fg("error", text);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
private actionText(): string {
|
|
85
|
-
const { theme, view } = this;
|
|
86
|
-
const { arrival } = view;
|
|
87
|
-
if (arrival && arrival.age < ARRIVAL_FLASH_MS) {
|
|
88
|
-
const fade = Math.min(1, arrival.age / ARRIVAL_FLASH_MS);
|
|
89
|
-
const settled = arrival.failed ? HEAT_COLORS.fail : HEAT_COLORS.gold;
|
|
90
|
-
return paint(mix(HEAT_COLORS.white, settled, fade), theme.bold(arrival.text));
|
|
91
|
-
}
|
|
92
|
-
return view.action ? theme.fg("text", view.action.word) : "";
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
handleMouse(event: TuiMouseEvent) {
|
|
96
|
-
if (!this.view.toggle || event.type !== "click" || event.button !== "left") return undefined;
|
|
97
|
-
this.view.toggle();
|
|
98
|
-
return { handled: true };
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** 单行文本,截断不加背景相关的 reset。 */
|
|
103
|
-
export class Line implements Component {
|
|
104
|
-
constructor(private readonly text: string) {}
|
|
105
|
-
invalidate(): void {}
|
|
106
|
-
render(width: number): string[] {
|
|
107
|
-
return [clip(this.text, Math.max(1, width - CHAT_GUTTER))];
|
|
108
|
-
}
|
|
109
|
-
}
|
package/truncated-write.ts
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 拦截把 read 截断结果整体写回的 write:宿主 read 截断时在正文末尾追加“…Use offset=N to continue.]”,
|
|
3
|
-
* codemode 脚本把它原样写回会把文件清成半截(出过两次,测试文件被截断还被提交)。不属于任何可关的功能,每个会话都注册。
|
|
4
|
-
*/
|
|
5
|
-
import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
|
|
7
|
-
/** 与宿主 core/tools/read.ts 的截断提示同形。 */
|
|
8
|
-
const READ_TRUNCATION = /\n\[(?:Showing lines \d+-\d+ of \d+[^\]\n]*|\d+ more lines in file)\. Use offset=\d+ to continue\.\]/;
|
|
9
|
-
|
|
10
|
-
export function registerTruncatedWriteGuard(pi: ExtensionAPI): void {
|
|
11
|
-
pi.on("tool_call", (event) => {
|
|
12
|
-
if (!isToolCallEventType("write", event) || !READ_TRUNCATION.test(event.input.content)) return;
|
|
13
|
-
return {
|
|
14
|
-
block: true,
|
|
15
|
-
reason: "写入内容带 read 截断提示,说明拿的是截断后的半截文件:改文件用 edit,或分段读完整再写",
|
|
16
|
-
};
|
|
17
|
-
});
|
|
18
|
-
}
|
package/watcher/card.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 观察员建议的展示:单通道单一样式,从消息正文里的信封渲染(deliver.ts 拥有信封格式)。
|
|
3
|
-
* 渲染器永不抛异常;不是信封的消息降级纯文本。
|
|
4
|
-
*/
|
|
5
|
-
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { Text, type Component } from "@earendil-works/pi-tui";
|
|
7
|
-
|
|
8
|
-
import { parseEnvelopes, wrapEnvelope } from "../deliver.js";
|
|
9
|
-
import { clip, oneLine, textOf } from "../format.js";
|
|
10
|
-
import { RAIL, paintBgLine } from "../tools/line.js";
|
|
11
|
-
|
|
12
|
-
export const WATCHER_MESSAGE_TYPE = "firecode-watcher-note";
|
|
13
|
-
|
|
14
|
-
/** 继承 OMP 的 weigh don't blindly obey:投递给模型的正文自带权衡包装。 */
|
|
15
|
-
const WEIGH_NOTICE = "这是观察员供你权衡的第二意见,不是指令:与你掌握的上下文冲突时按你的判断继续。";
|
|
16
|
-
const LABEL = "👓 观察员";
|
|
17
|
-
|
|
18
|
-
export function adviceMessage(card: WatcherCard): string {
|
|
19
|
-
return wrapEnvelope("firecode_watcher", `${adviceHeadline(card)}\n${card.note}\n${WEIGH_NOTICE}`);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface WatcherCard {
|
|
23
|
-
note: string;
|
|
24
|
-
turnIndex: number;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function adviceHeadline(card: WatcherCard): string {
|
|
28
|
-
return `${LABEL}(${timeMark(card.turnIndex)})`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** 建议自带时点标记:投递时主会话可能已经走远,读的人要知道它看的是哪一刻。 */
|
|
32
|
-
function timeMark(turnIndex: number): string {
|
|
33
|
-
return `基于第 ${turnIndex} 回合前的观察`;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export function registerWatcherCardRenderer(pi: ExtensionAPI): void {
|
|
37
|
-
pi.registerMessageRenderer(WATCHER_MESSAGE_TYPE, (message, options, theme) => {
|
|
38
|
-
const advice = parseAdvice(message.content);
|
|
39
|
-
return advice ? new AdviceLine(advice, options.expanded, theme) : new Text(textOf(message.content), 0, 0);
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
interface Advice {
|
|
44
|
-
headline: string;
|
|
45
|
-
note: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** 信封正文 = 标题行 + 建议 + 权衡声明(末行),与 adviceMessage 同构。 */
|
|
49
|
-
function parseAdvice(content: unknown): Advice | undefined {
|
|
50
|
-
const body = parseEnvelopes(textOf(content))?.[0]?.body;
|
|
51
|
-
if (body === undefined) return undefined;
|
|
52
|
-
const lines = body.split("\n");
|
|
53
|
-
return { headline: lines[0] ?? "", note: lines.slice(1, -1).join("\n") };
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
class AdviceLine implements Component {
|
|
57
|
-
private readonly fallback: Component;
|
|
58
|
-
|
|
59
|
-
constructor(
|
|
60
|
-
private readonly card: Advice,
|
|
61
|
-
private readonly expanded: boolean,
|
|
62
|
-
private readonly theme: Theme,
|
|
63
|
-
) {
|
|
64
|
-
this.fallback = new Text(`${card.headline} ${card.note}`, 0, 0);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
render(width: number): string[] {
|
|
68
|
-
const columns = Math.max(1, width);
|
|
69
|
-
try {
|
|
70
|
-
const bgFn = (text: string) => this.theme.bg("toolPendingBg", text);
|
|
71
|
-
if (this.expanded) {
|
|
72
|
-
const headline = this.theme.fg("warning", clip(oneLine(this.card.headline), columns));
|
|
73
|
-
const body = new Text(this.theme.fg("dim", ` ${this.card.note}\n (供权衡,勿盲从)`), 0, 0);
|
|
74
|
-
return [paintBgLine(headline, columns, bgFn), ...body.render(columns)];
|
|
75
|
-
}
|
|
76
|
-
// 收起与工具行同构:单行 + 背景条;时点标记只在展开态显示。
|
|
77
|
-
const firstLine = oneLine(this.card.note.split(/\r?\n/u, 1)[0] ?? "");
|
|
78
|
-
const line = clip(
|
|
79
|
-
`${this.theme.fg("dim", RAIL)}${this.theme.fg("warning", LABEL)}${this.theme.fg("dim", ` — ${firstLine}`)}`,
|
|
80
|
-
columns,
|
|
81
|
-
);
|
|
82
|
-
return [paintBgLine(line, columns, bgFn)];
|
|
83
|
-
} catch {
|
|
84
|
-
return this.fallback.render(columns);
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
invalidate(): void {
|
|
89
|
-
this.fallback.invalidate?.();
|
|
90
|
-
}
|
|
91
|
-
}
|
package/watcher/index.ts
DELETED
|
@@ -1,176 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Watcher 观察员:每个 turn 结束后异步评估主会话增量,要么沉默,要么发一条建议。
|
|
3
|
-
* 与 Master、fire-review 各自独立注册;观察过程不落盘。
|
|
4
|
-
*/
|
|
5
|
-
import {
|
|
6
|
-
type ExtensionAPI,
|
|
7
|
-
type ExtensionContext,
|
|
8
|
-
} from "@earendil-works/pi-coding-agent";
|
|
9
|
-
import { loadConfig } from "../config.js";
|
|
10
|
-
import { deliver } from "../deliver.js";
|
|
11
|
-
import { InProcessSessionPool } from "../master/spawn.js";
|
|
12
|
-
import { OCCUPANCY_CHANNEL, type OccupancyPayload } from "../review/occupancy.js";
|
|
13
|
-
import {
|
|
14
|
-
adviceMessage,
|
|
15
|
-
registerWatcherCardRenderer,
|
|
16
|
-
WATCHER_MESSAGE_TYPE,
|
|
17
|
-
type WatcherCard,
|
|
18
|
-
} from "./card.js";
|
|
19
|
-
import { createObserver, type Advice, type Observer } from "./observer.js";
|
|
20
|
-
import { renderTurn } from "./transcript.js";
|
|
21
|
-
|
|
22
|
-
/** 观察会话自身上下文占比超过此值即重建。 */
|
|
23
|
-
const CONTEXT_RESET_PERCENT = 70;
|
|
24
|
-
|
|
25
|
-
interface WatcherDependencies {
|
|
26
|
-
pool?: InProcessSessionPool;
|
|
27
|
-
createObserver?: typeof createObserver;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
interface WatcherRuntime {
|
|
31
|
-
ctx: ExtensionContext;
|
|
32
|
-
pending: string[];
|
|
33
|
-
lastTurnIndex: number;
|
|
34
|
-
evaluating: boolean;
|
|
35
|
-
observer?: Observer;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function registerWatcher(
|
|
39
|
-
pi: ExtensionAPI,
|
|
40
|
-
dependencies: WatcherDependencies = {},
|
|
41
|
-
subsession = false,
|
|
42
|
-
): void {
|
|
43
|
-
// 子会话不带观察员:级联抑制是代码规则,不靠进程环境。
|
|
44
|
-
if (subsession) return;
|
|
45
|
-
registerWatcherCardRenderer(pi);
|
|
46
|
-
const loaded = loadConfig().watcher;
|
|
47
|
-
// 配置有问题时拒绝启动:静默回退会拿用户没配的模型真实发起观察。
|
|
48
|
-
if ("error" in loaded) {
|
|
49
|
-
pi.registerCommand("fire-watch", {
|
|
50
|
-
description: "翻转当前会话的观察员开关",
|
|
51
|
-
handler: async (args, ctx) => ctx.ui.notify(args.trim() ? "/fire-watch 不接受参数" : loaded.error, "error"),
|
|
52
|
-
});
|
|
53
|
-
return;
|
|
54
|
-
}
|
|
55
|
-
const config = loaded.config;
|
|
56
|
-
const pool = dependencies.pool ?? new InProcessSessionPool();
|
|
57
|
-
const spawnObserver = dependencies.createObserver ?? createObserver;
|
|
58
|
-
let runtime: WatcherRuntime | undefined;
|
|
59
|
-
let reviewActive = false;
|
|
60
|
-
|
|
61
|
-
const deactivate = (owner = runtime) => {
|
|
62
|
-
if (!owner || runtime !== owner) return;
|
|
63
|
-
runtime = undefined;
|
|
64
|
-
owner.observer?.dispose();
|
|
65
|
-
owner.observer = undefined;
|
|
66
|
-
owner.ctx.ui.setStatus("watcher", undefined);
|
|
67
|
-
};
|
|
68
|
-
const resetObserver = (owner = runtime) => {
|
|
69
|
-
if (!owner || runtime !== owner) return;
|
|
70
|
-
runtime = {
|
|
71
|
-
ctx: owner.ctx,
|
|
72
|
-
pending: [],
|
|
73
|
-
lastTurnIndex: owner.lastTurnIndex,
|
|
74
|
-
evaluating: false,
|
|
75
|
-
};
|
|
76
|
-
owner.observer?.dispose();
|
|
77
|
-
owner.observer = undefined;
|
|
78
|
-
};
|
|
79
|
-
const activate = (ctx: ExtensionContext): WatcherRuntime => {
|
|
80
|
-
const owner = { ctx, pending: [], lastTurnIndex: 0, evaluating: false };
|
|
81
|
-
runtime = owner;
|
|
82
|
-
ctx.ui.setStatus("watcher", ctx.ui.theme.fg("dim", "观察员"));
|
|
83
|
-
return owner;
|
|
84
|
-
};
|
|
85
|
-
// 与指挥官事件同构:忙时卡片经 steer 队列句缝追加,歇透时走前门唤起(见 deliver.ts)。
|
|
86
|
-
// 建议是当下的第二意见,过时重投没有价值:投递失败只丢弃这一条并提示,观察员照常工作(Master 事件则重试)。
|
|
87
|
-
const speak = async (owner: WatcherRuntime, advice: Advice, turnIndex: number) => {
|
|
88
|
-
const card: WatcherCard = { note: advice.note, turnIndex };
|
|
89
|
-
try {
|
|
90
|
-
await deliver(pi, owner.ctx, { customType: WATCHER_MESSAGE_TYPE, content: adviceMessage(card) });
|
|
91
|
-
} catch (error) {
|
|
92
|
-
if (runtime === owner) owner.ctx.ui.notify(`观察员这条建议投递失败,已丢弃:${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
const evaluate = async (owner: WatcherRuntime) => {
|
|
96
|
-
owner.evaluating = true;
|
|
97
|
-
try {
|
|
98
|
-
while (runtime === owner && owner.pending.length && !reviewActive) {
|
|
99
|
-
// 合并跳最新:评估期间到达的回合并进下一批,不排队补评估。
|
|
100
|
-
const increment = owner.pending.splice(0).join("\n");
|
|
101
|
-
const turnIndex = owner.lastTurnIndex;
|
|
102
|
-
let observer = owner.observer;
|
|
103
|
-
if (!observer) {
|
|
104
|
-
const cwd = owner.ctx.cwd;
|
|
105
|
-
const model = await pool.resolveModel(config.model);
|
|
106
|
-
if (runtime !== owner) return;
|
|
107
|
-
observer = await spawnObserver({ cwd, model, thinking: config.thinking, pool });
|
|
108
|
-
if (runtime !== owner) {
|
|
109
|
-
await observer.dispose();
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
owner.observer = observer;
|
|
113
|
-
}
|
|
114
|
-
const advice = await observer.evaluate(increment);
|
|
115
|
-
if (runtime !== owner) return;
|
|
116
|
-
if (advice) {
|
|
117
|
-
await speak(owner, advice, turnIndex);
|
|
118
|
-
if (runtime !== owner) return;
|
|
119
|
-
}
|
|
120
|
-
// 自身上下文快满时也重新入场:观察员只需要当下,不需要完整历史。
|
|
121
|
-
if ((observer.contextPercent() ?? 0) >= CONTEXT_RESET_PERCENT) resetObserver(owner);
|
|
122
|
-
}
|
|
123
|
-
} catch (error) {
|
|
124
|
-
// 被重新入场中断的评估不算故障:下一批增量会开一个新观察会话。
|
|
125
|
-
if (runtime !== owner) return;
|
|
126
|
-
owner.ctx.ui.notify(`观察员已停止:${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
127
|
-
deactivate(owner);
|
|
128
|
-
} finally {
|
|
129
|
-
owner.evaluating = false;
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
|
|
133
|
-
pi.registerCommand("fire-watch", {
|
|
134
|
-
description: "翻转当前会话的观察员开关",
|
|
135
|
-
handler: async (args, ctx) => {
|
|
136
|
-
if (args.trim()) {
|
|
137
|
-
ctx.ui.notify("/fire-watch 不接受参数", "error");
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
if (runtime) {
|
|
141
|
-
deactivate();
|
|
142
|
-
ctx.ui.notify("观察员已关闭", "info");
|
|
143
|
-
return;
|
|
144
|
-
}
|
|
145
|
-
activate(ctx);
|
|
146
|
-
ctx.ui.notify("观察员已开启", "info");
|
|
147
|
-
},
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
pi.on("session_start", (_event, ctx) => {
|
|
151
|
-
deactivate();
|
|
152
|
-
if (!config.enabled) return;
|
|
153
|
-
activate(ctx);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
pi.on("turn_end", (event) => {
|
|
157
|
-
const owner = runtime;
|
|
158
|
-
if (!owner) return;
|
|
159
|
-
owner.pending.push(renderTurn(event, config.context));
|
|
160
|
-
owner.lastTurnIndex = event.turnIndex;
|
|
161
|
-
if (!owner.evaluating && !reviewActive) void evaluate(owner);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// fire-review 活跃期静默:不与对抗审查的反馈打架,增量留着审查完合并评估。
|
|
165
|
-
pi.events.on(OCCUPANCY_CHANNEL, (data) => {
|
|
166
|
-
reviewActive = (data as OccupancyPayload).active;
|
|
167
|
-
const active = runtime;
|
|
168
|
-
if (reviewActive || !active || active.evaluating || !active.pending.length) return;
|
|
169
|
-
void evaluate(active);
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
// 主会话压缩:旧增量已不再对应主会话现场,观察员从当前尾部重新入场而不回放。
|
|
173
|
-
pi.on("session_compact", () => resetObserver());
|
|
174
|
-
pi.on("session_shutdown", () => deactivate());
|
|
175
|
-
}
|
|
176
|
-
|
package/watcher/observer.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 观察会话:进程内 memory 子会话 + 只读工具 + 唯一自定义工具 advise。
|
|
3
|
-
* 系统提示以 prompts/watch.zh.md 为唯一事实源(整体替换,不受项目文件改写)。
|
|
4
|
-
*/
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
6
|
-
import { dirname, join } from "node:path";
|
|
7
|
-
import { fileURLToPath } from "node:url";
|
|
8
|
-
import type { Model } from "@earendil-works/pi-ai";
|
|
9
|
-
import { Type } from "@earendil-works/pi-ai";
|
|
10
|
-
import type { InProcessSessionPool } from "../master/spawn.js";
|
|
11
|
-
import type { ThinkingLevelValue } from "../config.js";
|
|
12
|
-
|
|
13
|
-
const PROMPT_PATH = join(dirname(fileURLToPath(import.meta.url)), "prompts", "watch.zh.md");
|
|
14
|
-
const OBSERVER_TOOLS = ["read", "grep", "find", "ls", "advise"];
|
|
15
|
-
|
|
16
|
-
export interface Advice {
|
|
17
|
-
note: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface Observer {
|
|
21
|
-
/** 喂一段增量并等待评估;至多返回一条建议。 */
|
|
22
|
-
evaluate(increment: string): Promise<Advice | undefined>;
|
|
23
|
-
/** 观察会话自身上下文占比(百分数),未知时 undefined。 */
|
|
24
|
-
contextPercent(): number | undefined;
|
|
25
|
-
dispose(): Promise<void>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface ObserverOptions {
|
|
29
|
-
cwd: string;
|
|
30
|
-
model: Model<any>;
|
|
31
|
-
thinking: ThinkingLevelValue;
|
|
32
|
-
pool: InProcessSessionPool;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export async function createObserver(options: ObserverOptions): Promise<Observer> {
|
|
36
|
-
let advice: Advice | undefined;
|
|
37
|
-
const spawned = await options.pool.spawn({
|
|
38
|
-
cwd: options.cwd,
|
|
39
|
-
role: "observer",
|
|
40
|
-
model: options.model,
|
|
41
|
-
thinking: options.thinking,
|
|
42
|
-
tools: OBSERVER_TOOLS,
|
|
43
|
-
customTools: [adviseTool(() => advice, (next) => { advice = next; })],
|
|
44
|
-
systemPrompt: { mode: "replace", text: readFileSync(PROMPT_PATH, "utf8") },
|
|
45
|
-
contextFiles: true,
|
|
46
|
-
persistence: { type: "memory" },
|
|
47
|
-
});
|
|
48
|
-
return {
|
|
49
|
-
async evaluate(increment) {
|
|
50
|
-
advice = undefined;
|
|
51
|
-
await spawned.prompt(increment);
|
|
52
|
-
return advice;
|
|
53
|
-
},
|
|
54
|
-
contextPercent: () => spawned.session.getContextUsage?.()?.percent ?? undefined,
|
|
55
|
-
dispose: () => spawned.dispose(),
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function adviseTool(current: () => Advice | undefined, capture: (advice: Advice) => void) {
|
|
60
|
-
return {
|
|
61
|
-
name: "advise",
|
|
62
|
-
label: "建议",
|
|
63
|
-
description: "向主代理提交一条供权衡的观察建议;每次评估只接受一条,请只提最要紧的那一条。",
|
|
64
|
-
parameters: Type.Object({
|
|
65
|
-
note: Type.String({ description: "一句话说清问题与定位。" }),
|
|
66
|
-
}),
|
|
67
|
-
async execute(_id: string, params: Record<string, unknown>) {
|
|
68
|
-
if (current()) throw new Error("本次评估已提交过建议;余下的问题留到下一次评估");
|
|
69
|
-
const note = typeof params.note === "string" ? params.note.trim() : "";
|
|
70
|
-
if (!note) throw new Error("note 不能为空");
|
|
71
|
-
capture({ note });
|
|
72
|
-
return { content: [{ type: "text" as const, text: "已记录,本次评估结束。" }], details: undefined };
|
|
73
|
-
},
|
|
74
|
-
};
|
|
75
|
-
}
|