@zhushanwen/pi-goal 0.14.9 → 0.14.10
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/package.json +5 -4
- package/src/__tests__/active-path.test.ts +226 -0
- package/src/index.ts +17 -1
- package/src/session.ts +28 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-goal",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.10",
|
|
4
4
|
"description": "Codex-style /goal command for Pi — persistent goal-driven autonomous loop with evidence-based completion, token budget, agent-reported blocking, and steering templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -29,11 +29,12 @@
|
|
|
29
29
|
"@earendil-works/pi-coding-agent": "^0.84.4",
|
|
30
30
|
"@earendil-works/pi-tui": "^0.84.4",
|
|
31
31
|
"typebox": "*",
|
|
32
|
-
"@zhushanwen/pi-pending-notifications": "0.8.
|
|
32
|
+
"@zhushanwen/pi-pending-notifications": "0.8.1"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@zhushanwen/extension-protocol": "0.
|
|
36
|
-
"@zhushanwen/pi-extension-logger": "0.6.1"
|
|
35
|
+
"@zhushanwen/extension-protocol": "0.17.0",
|
|
36
|
+
"@zhushanwen/pi-extension-logger": "0.6.1",
|
|
37
|
+
"@zhushanwen/pi-session-path": "0.1.0"
|
|
37
38
|
},
|
|
38
39
|
"peerDependenciesMeta": {
|
|
39
40
|
"@earendil-works/pi-tui": {
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* U6c:reconstructGoalState 重建接活跃路径裁剪 + session_tree handler 即时重建。
|
|
3
|
+
*
|
|
4
|
+
* 从 leafId 沿 parentId 回溯得活跃路径,被撤子树的 goal-state entry 不进重建态
|
|
5
|
+
* (不得经 before_agent_start 逐轮注入模型上下文——A14)。
|
|
6
|
+
*/
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { describe, expect, it, vi } from "vitest";
|
|
9
|
+
|
|
10
|
+
import { createGoalState } from "../engine/goal";
|
|
11
|
+
import { ENTRY_TYPE, serializeState } from "../persistence";
|
|
12
|
+
import type { SessionEntryLike } from "../ports";
|
|
13
|
+
import goalExtension from "../index";
|
|
14
|
+
import {
|
|
15
|
+
createGoalSession,
|
|
16
|
+
reconstructGoalState,
|
|
17
|
+
type ActivePathSessionView,
|
|
18
|
+
} from "../session";
|
|
19
|
+
|
|
20
|
+
// ── fixture ─────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
interface BranchEntry {
|
|
23
|
+
id: string;
|
|
24
|
+
parentId: string | null;
|
|
25
|
+
type: string;
|
|
26
|
+
customType?: string;
|
|
27
|
+
data?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** goal-state 快照 entry(append-only 全量快照,reconstruct 只读活跃路径内最新一条) */
|
|
31
|
+
function goalStateEntry(id: string, parentId: string | null, objective: string): BranchEntry {
|
|
32
|
+
return {
|
|
33
|
+
type: "custom",
|
|
34
|
+
id,
|
|
35
|
+
parentId,
|
|
36
|
+
customType: ENTRY_TYPE,
|
|
37
|
+
data: serializeState(createGoalState(objective)),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 撤回后真实形态:label entry 落文件尾(parentId = 回退后叶子),leafId 指向它 */
|
|
42
|
+
function labelEntry(id: string, parentId: string | null): BranchEntry {
|
|
43
|
+
return { type: "label", id, parentId, targetId: "u-x", label: "taiji:revoked" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function makeView(entries: SessionEntryLike[], leafId: string | null): ActivePathSessionView {
|
|
47
|
+
return {
|
|
48
|
+
// 模拟 Pi SDK filter-copy 语义:返回新数组
|
|
49
|
+
getEntries: () => entries.slice(),
|
|
50
|
+
getLeafId: () => leafId,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── reconstructGoalState 活跃路径裁剪(U6c)──────────
|
|
55
|
+
|
|
56
|
+
describe("reconstructGoalState 活跃路径裁剪(U6c)", () => {
|
|
57
|
+
it("有分支 fixture:被撤子树的 goal-state 不进重建态——取活跃路径内最新一条", () => {
|
|
58
|
+
// 树:e1(goal base) → e2(goal active-branch);e1 → e3(goal revoked-branch,
|
|
59
|
+
// 物理后写 = 被撤子树);撤回落 label 锚 L(parentId = 回退后叶子 e2),
|
|
60
|
+
// leafId = L → 活跃路径 {L, e2, e1}
|
|
61
|
+
const entries = [
|
|
62
|
+
goalStateEntry("e1", null, "base"),
|
|
63
|
+
goalStateEntry("e2", "e1", "active-branch"),
|
|
64
|
+
goalStateEntry("e3", "e1", "revoked-branch"),
|
|
65
|
+
labelEntry("L", "e2"),
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const session = createGoalSession();
|
|
69
|
+
reconstructGoalState(session, makeView(entries, "L"));
|
|
70
|
+
|
|
71
|
+
expect(session.state).not.toBeNull();
|
|
72
|
+
expect(session.state!.objective).toBe("active-branch");
|
|
73
|
+
// 反向断言:被撤子树快照不进重建态(物理位置在 e2 之后,无裁剪时会命中)
|
|
74
|
+
expect(session.state!.objective).not.toBe("revoked-branch");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("活跃路径内无 goal-state(撤回点早于 goal 创建)→ state=null,不回退到旧分支快照", () => {
|
|
78
|
+
const entries: BranchEntry[] = [
|
|
79
|
+
{ type: "custom", id: "e1", parentId: null, customType: "other-ext", data: {} },
|
|
80
|
+
goalStateEntry("e2", "e1", "only-on-revoked-branch"),
|
|
81
|
+
labelEntry("L", "e1"),
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
const session = createGoalSession();
|
|
85
|
+
reconstructGoalState(session, makeView(entries, "L"));
|
|
86
|
+
|
|
87
|
+
expect(session.state).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("无分支回归:leafId = 文件尾 → 恢复最新一条(现行为不变)", () => {
|
|
91
|
+
const entries = [goalStateEntry("e1", null, "old"), goalStateEntry("e2", "e1", "new")];
|
|
92
|
+
|
|
93
|
+
const session = createGoalSession();
|
|
94
|
+
reconstructGoalState(session, makeView(entries, "e2"));
|
|
95
|
+
|
|
96
|
+
expect(session.state!.objective).toBe("new");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("legacy fixture 回归:entry 无 id/parentId(duck-typed 最小形状)→ 线性全量,行为与裁剪前一致", () => {
|
|
100
|
+
const entries: SessionEntryLike[] = [
|
|
101
|
+
{ type: "custom", customType: ENTRY_TYPE, data: serializeState(createGoalState("legacy-old")) },
|
|
102
|
+
{ type: "custom", customType: ENTRY_TYPE, data: serializeState(createGoalState("legacy-new")) },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const session = createGoalSession();
|
|
106
|
+
reconstructGoalState(session, { getEntries: () => entries.slice() });
|
|
107
|
+
|
|
108
|
+
expect(session.state!.objective).toBe("legacy-new");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("leafId 防御:null / 指向不存在 entry → 回退文件尾(不静默清空重建态)", () => {
|
|
112
|
+
const entries = [goalStateEntry("e1", null, "first"), goalStateEntry("e2", "e1", "tail-snapshot")];
|
|
113
|
+
|
|
114
|
+
for (const leafId of [null, "missing-id"]) {
|
|
115
|
+
const session = createGoalSession();
|
|
116
|
+
reconstructGoalState(session, makeView(entries, leafId));
|
|
117
|
+
expect(session.state!.objective).toBe("tail-snapshot");
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── session_tree handler(U6c:即时重建,纯重建体)──
|
|
123
|
+
|
|
124
|
+
interface HandlerFixture {
|
|
125
|
+
handlers: Map<string, (event: unknown, ctx: ExtensionContext) => Promise<unknown>>;
|
|
126
|
+
sendMessage: ReturnType<typeof vi.fn>;
|
|
127
|
+
appendEntry: ReturnType<typeof vi.fn>;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** 实例化 goal 工厂并捕获事件 handler(最小 pi:注册面 vi.fn + 副作用面 vi.fn) */
|
|
131
|
+
function makeHandlerFixture(): HandlerFixture {
|
|
132
|
+
const handlers = new Map<string, (event: unknown, ctx: ExtensionContext) => Promise<unknown>>();
|
|
133
|
+
const pi = {
|
|
134
|
+
registerCommand: vi.fn(),
|
|
135
|
+
registerTool: vi.fn(),
|
|
136
|
+
on: vi.fn((event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise<unknown>) => {
|
|
137
|
+
// 真实 pi 的 on 是 handler 列表追加;此处每事件单 handler(goal 工厂每事件只注册一次)
|
|
138
|
+
handlers.set(event, handler);
|
|
139
|
+
}),
|
|
140
|
+
registerMessageRenderer: vi.fn(),
|
|
141
|
+
sendMessage: vi.fn(),
|
|
142
|
+
appendEntry: vi.fn(),
|
|
143
|
+
} as unknown as ExtensionAPI;
|
|
144
|
+
goalExtension(pi);
|
|
145
|
+
return { handlers, sendMessage: pi.sendMessage, appendEntry: pi.appendEntry };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 最小 ctx:sessionManager(分支树)+ ui 记录面 + getContextUsage(before_agent_start 用) */
|
|
149
|
+
function makeCtx(entries: SessionEntryLike[], leafId: string | null) {
|
|
150
|
+
const setWidget = vi.fn();
|
|
151
|
+
const setStatus = vi.fn();
|
|
152
|
+
const ctx = {
|
|
153
|
+
hasUI: true,
|
|
154
|
+
getContextUsage: () => null,
|
|
155
|
+
ui: {
|
|
156
|
+
notify: vi.fn(),
|
|
157
|
+
setStatus,
|
|
158
|
+
setWidget,
|
|
159
|
+
theme: { fg: (_c: string, t: string) => t, bold: (t: string) => t },
|
|
160
|
+
},
|
|
161
|
+
sessionManager: {
|
|
162
|
+
getEntries: () => entries.slice(),
|
|
163
|
+
getLeafId: () => leafId,
|
|
164
|
+
},
|
|
165
|
+
} as unknown as ExtensionContext;
|
|
166
|
+
return { ctx, setWidget, setStatus };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
describe("session_tree handler 即时重建(U6c)", () => {
|
|
170
|
+
it("触发 session_tree → 内存态重建为活跃路径态 + widget 刷新;handler 本身零注入零落盘", async () => {
|
|
171
|
+
const { handlers, sendMessage, appendEntry } = makeHandlerFixture();
|
|
172
|
+
const entries = [
|
|
173
|
+
goalStateEntry("e1", null, "base"),
|
|
174
|
+
goalStateEntry("e2", "e1", "active-branch"),
|
|
175
|
+
goalStateEntry("e3", "e1", "revoked-branch"),
|
|
176
|
+
labelEntry("L", "e2"),
|
|
177
|
+
];
|
|
178
|
+
const { ctx, setWidget } = makeCtx(entries, "L");
|
|
179
|
+
|
|
180
|
+
expect(handlers.has("session_tree")).toBe(true);
|
|
181
|
+
await handlers.get("session_tree")!({ type: "session_tree" }, ctx);
|
|
182
|
+
|
|
183
|
+
// 纯重建断言:handler 不注入消息(撤回编排自己不得发消息)、不落盘(不污染新分支文件尾)
|
|
184
|
+
expect(sendMessage).not.toHaveBeenCalled();
|
|
185
|
+
expect(appendEntry).not.toHaveBeenCalled();
|
|
186
|
+
// widget 反映活跃路径态(mock ctx 无 mode → setWidgetDual 走 TUI 臂,直接推 text 行)
|
|
187
|
+
const lines = setWidget.mock.calls.find(([name]) => name === "goal")?.[1] as string[] | undefined;
|
|
188
|
+
expect(lines?.join(" ")).toContain("active-branch");
|
|
189
|
+
expect(lines?.join(" ")).not.toContain("revoked-branch");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("撤回点早于 goal 创建 → 重建态 null,widget 清除(live 态无残影,A14)", async () => {
|
|
193
|
+
const { handlers } = makeHandlerFixture();
|
|
194
|
+
const entries: BranchEntry[] = [
|
|
195
|
+
{ type: "custom", id: "e1", parentId: null, customType: "other-ext", data: {} },
|
|
196
|
+
goalStateEntry("e2", "e1", "only-on-revoked-branch"),
|
|
197
|
+
labelEntry("L", "e1"),
|
|
198
|
+
];
|
|
199
|
+
const { ctx, setWidget } = makeCtx(entries, "L");
|
|
200
|
+
|
|
201
|
+
await handlers.get("session_tree")!({ type: "session_tree" }, ctx);
|
|
202
|
+
|
|
203
|
+
expect(setWidget).toHaveBeenCalledWith("goal", undefined);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("session_tree 重建后的内存态驱动 before_agent_start:下一轮注入不含被撤 goal", async () => {
|
|
207
|
+
const { handlers, appendEntry } = makeHandlerFixture();
|
|
208
|
+
const entries = [
|
|
209
|
+
goalStateEntry("e1", null, "active-branch"),
|
|
210
|
+
goalStateEntry("e2", "e1", "revoked-branch"),
|
|
211
|
+
labelEntry("L", "e1"),
|
|
212
|
+
];
|
|
213
|
+
const { ctx } = makeCtx(entries, "L");
|
|
214
|
+
|
|
215
|
+
await handlers.get("session_tree")!({ type: "session_tree" }, ctx);
|
|
216
|
+
// before_agent_start 读内存态不重算(现状契约)——新鲜度由 session_tree 重建保证
|
|
217
|
+
const result = (await handlers.get("before_agent_start")!({}, ctx)) as
|
|
218
|
+
| { message: { content: string } }
|
|
219
|
+
| undefined;
|
|
220
|
+
|
|
221
|
+
expect(result?.message?.content).toContain("active-branch");
|
|
222
|
+
expect(result?.message?.content).not.toContain("revoked-branch");
|
|
223
|
+
// before_agent_start 自身的 goal:log 落盘是该事件既有行为,不算 session_tree 副作用
|
|
224
|
+
expect(appendEntry).toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -29,8 +29,9 @@ import { handleSessionStart } from "./adapters/event-handlers/session-start";
|
|
|
29
29
|
import { handleTurnEnd } from "./adapters/event-handlers/turn-end";
|
|
30
30
|
import { registerGoalControlTool } from "./adapters/goal-control-adapter";
|
|
31
31
|
import { buildPorts } from "./adapters/ports";
|
|
32
|
+
import { updateWidget } from "./projection/widget";
|
|
32
33
|
import { createGoal } from "./service";
|
|
33
|
-
import { createGoalSession, type GoalSession } from "./session";
|
|
34
|
+
import { createGoalSession, reconstructGoalState, type GoalSession } from "./session";
|
|
34
35
|
|
|
35
36
|
// ── Extension Factory ─────────────────────────────────
|
|
36
37
|
|
|
@@ -88,6 +89,21 @@ export default function goalExtension(pi: ExtensionAPI) {
|
|
|
88
89
|
await handleSessionStart(pi, session, ctx);
|
|
89
90
|
});
|
|
90
91
|
|
|
92
|
+
// U6c:树回退(撤回 __taiji_nav__ → navigateTree / 用户树跳转)后即时重建 goal
|
|
93
|
+
// 内存态——before_agent_start 注入读内存态不重算(现状契约),新鲜度由本 handler
|
|
94
|
+
// 保证;缺席时运行中撤回后被撤 goal 逐轮注入模型上下文(A14)。
|
|
95
|
+
// 纯重建体(todo 的 session_tree handler 同款)——只做 reconstruct + widget 刷新,
|
|
96
|
+
// 绝不加 sendMessage / appendEntry 副作用:本事件由撤回编排触发,注入会让撤回编排
|
|
97
|
+
// 自己发出消息(违背「被撤内容从未发生」语义),落盘会向回退后的新分支追加 entry
|
|
98
|
+
// 污染文件尾。
|
|
99
|
+
// updateWidget 无条件调用(session_start 仅 state 非空时刷新——那是启动期无残留可清;
|
|
100
|
+
// 此处撤回点早于 goal 创建时重建态为 null,条件刷新会让被撤 goal 的 widget 残留)。
|
|
101
|
+
pi.on("session_tree", async (_event: unknown, ctx: ExtensionContext) => {
|
|
102
|
+
const ports = buildPorts(pi, ctx);
|
|
103
|
+
reconstructGoalState(session, ctx.sessionManager);
|
|
104
|
+
updateWidget(session, ports.ui);
|
|
105
|
+
});
|
|
106
|
+
|
|
91
107
|
// MF-R2-1 根修:session_shutdown 在 runner invalidate 之前触发(此刻 pi/ctx
|
|
92
108
|
// 仍可用),取消待发的退避 continuation timer——覆盖 reload / new / resume /
|
|
93
109
|
// fork / quit 全部失效路径,旧 timer 不再携 stale ctx 闭包存活到到期
|
package/src/session.ts
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Session 层 — 运行时句柄 + 状态重建(entry 不做 GC,只读最新一条)
|
|
3
3
|
*
|
|
4
4
|
* GoalSession 是进程内瞬态句柄(不持久化)。
|
|
5
|
-
* reconstructGoalState 从 entry 恢复状态(session_start
|
|
5
|
+
* reconstructGoalState 从 entry 恢复状态(session_start / session_tree 时调,
|
|
6
|
+
* U6c:重建输入接活跃路径裁剪——被撤子树的 goal-state 不进重建态)。
|
|
6
7
|
*
|
|
7
8
|
* FR-6.4: 删除 hasPendingInjection(僵尸字段)
|
|
8
9
|
* FR-6.7: 删除 pendingPause(ESC 改用 aborted 守卫)
|
|
@@ -10,9 +11,11 @@
|
|
|
10
11
|
* FR-3: 崩溃后保持原状态(active 重启计时;paused/blocked 保持;终态保持)
|
|
11
12
|
*/
|
|
12
13
|
|
|
14
|
+
import { filterActivePath } from "@zhushanwen/pi-session-path";
|
|
15
|
+
|
|
13
16
|
import type { GoalRuntimeState } from "./engine/types";
|
|
14
17
|
import { deserializeState, ENTRY_TYPE } from "./persistence";
|
|
15
|
-
import type { SessionEntryLike,
|
|
18
|
+
import type { SessionEntryLike, UiPort } from "./ports";
|
|
16
19
|
|
|
17
20
|
// ── 运行时句柄 ────────────────────────────────────────
|
|
18
21
|
|
|
@@ -47,10 +50,28 @@ export function createGoalSession(): GoalSession {
|
|
|
47
50
|
// 复用价值场景请参照 scheduler 的已验证方案:runtime.ts STALE_CTX_MARKER
|
|
48
51
|
// 'stale after session replacement'(子串匹配真实文案)+ 代际计数治本(G1)。
|
|
49
52
|
|
|
50
|
-
// ──
|
|
53
|
+
// ── 活跃路径裁剪(U6c)────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 活跃路径回溯所需的最小 session 视图(U6c)。getLeafId 可选:SessionPort
|
|
57
|
+
* (buildPorts 产物)不携带时按文件尾回退——session_start 时点 pi 树重放规则
|
|
58
|
+
* (叶子 = 文件最后一条 entry)使两锚等价;session_tree handler 传
|
|
59
|
+
* ctx.sessionManager(含 getLeafId),覆盖运行中树回退后 leaf ≠ 文件尾的形态。
|
|
60
|
+
*/
|
|
61
|
+
export interface ActivePathSessionView {
|
|
62
|
+
getEntries(): SessionEntryLike[];
|
|
63
|
+
getLeafId?(): string | null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 活跃路径裁剪(leafId 沿 parentId 回溯,撤回后被撤子树的 goal-state entry 不进
|
|
67
|
+
// 重建输入)收敛于 @zhushanwen/pi-session-path 单一实现(四包同构副本收编,防御
|
|
68
|
+
// 语义与回退口径见该包 filterActivePath 注释;runtime 侧 entry-tree-builder 保持
|
|
69
|
+
// 独立——extension 不能 import runtime 包)。
|
|
70
|
+
|
|
71
|
+
// ── reconstructGoalState(session_start / session_tree 时调)──────────
|
|
51
72
|
|
|
52
73
|
/**
|
|
53
|
-
* 从 session entries 恢复 goal state
|
|
74
|
+
* 从 session entries 恢复 goal state(U6c:重建输入 = 活跃路径投影,非全文件)。
|
|
54
75
|
*
|
|
55
76
|
* Pi SDK 的 session 是 append-only,getEntries() 返回 filter-copy——splice
|
|
56
77
|
* 无法修改真实 entries。Entry GC(goal-state 留 1、goal-history 留 20)在生产
|
|
@@ -60,11 +81,11 @@ export function createGoalSession(): GoalSession {
|
|
|
60
81
|
* paused/blocked 保持(用户/agent 主动叫停不被抹除),终态保持。
|
|
61
82
|
* FR-8.1 G-024: deserialize throw → state=null(部分损坏全丢)
|
|
62
83
|
*/
|
|
63
|
-
export function reconstructGoalState(session: GoalSession, sessionPort:
|
|
84
|
+
export function reconstructGoalState(session: GoalSession, sessionPort: ActivePathSessionView): void {
|
|
64
85
|
session.state = null;
|
|
65
|
-
const entries = sessionPort
|
|
86
|
+
const entries = filterActivePath(sessionPort);
|
|
66
87
|
|
|
67
|
-
// 找到最新的 goal-state entry
|
|
88
|
+
// 找到最新的 goal-state entry(活跃路径内从后往前)
|
|
68
89
|
let latestStateIdx = -1;
|
|
69
90
|
for (let i = entries.length - 1; i >= 0; i--) {
|
|
70
91
|
if (isGoalStateEntry(entries[i]!)) {
|