@zhushanwen/pi-goal 0.2.0 → 0.4.0
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 +13 -12
- package/package.json +8 -4
- package/src/__tests__/command-adapter.test.ts +404 -0
- package/src/__tests__/deserialize-state.test.ts +80 -0
- package/src/__tests__/event-adapter.test.ts +339 -0
- package/src/__tests__/goal-control-adapter.test.ts +303 -0
- package/src/__tests__/index.test.ts +119 -0
- package/src/__tests__/service.test.ts +317 -0
- package/src/__tests__/session.test.ts +220 -0
- package/src/__tests__/stale-checker.test.ts +78 -0
- package/src/__tests__/stubs/pi-sdk.ts +16 -0
- package/src/__tests__/stubs/typebox.ts +14 -0
- package/src/adapters/command-adapter.ts +360 -0
- package/src/adapters/event-adapter.ts +17 -0
- package/src/adapters/event-handlers/agent-end.ts +177 -0
- package/src/adapters/event-handlers/agent-start.ts +17 -0
- package/src/adapters/event-handlers/before-agent-start.ts +115 -0
- package/src/adapters/event-handlers/message-end.ts +37 -0
- package/src/adapters/event-handlers/session-start.ts +24 -0
- package/src/adapters/event-handlers/shared.ts +46 -0
- package/src/adapters/event-handlers/turn-end.ts +37 -0
- package/src/adapters/goal-control-adapter.ts +296 -0
- package/src/adapters/ports.ts +89 -0
- package/src/commands.ts +7 -19
- package/src/constants.ts +1 -3
- package/src/engine/__tests__/budget.test.ts +182 -0
- package/src/engine/__tests__/goal.test.ts +126 -0
- package/src/engine/budget.ts +146 -0
- package/src/engine/goal.ts +62 -0
- package/src/engine/types.ts +80 -0
- package/src/index.ts +110 -333
- package/src/persistence.ts +83 -0
- package/src/ports.ts +68 -0
- package/src/projection/__tests__/prompts.test.ts +245 -0
- package/src/projection/__tests__/widget.test.ts +255 -0
- package/src/projection/prompts.ts +250 -0
- package/src/projection/widget.ts +254 -0
- package/src/service.ts +276 -0
- package/src/session.ts +114 -0
- package/src/action-handlers.ts +0 -341
- package/src/agent-end-handler.ts +0 -304
- package/src/before-agent-start-handler.ts +0 -182
- package/src/budget.ts +0 -159
- package/src/command-handler.ts +0 -280
- package/src/state.ts +0 -250
- package/src/templates.ts +0 -232
- package/src/tool-handler.ts +0 -252
- package/src/widget.ts +0 -186
package/src/index.ts
CHANGED
|
@@ -1,423 +1,200 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pi /goal Extension —
|
|
2
|
+
* Pi /goal Extension — 工厂入口(重构后架构)
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - Evidence-based completion(完成任务必须提供具体证据)
|
|
7
|
-
* - Token 预算 + 时间预算(含 70%/90% 预警)
|
|
8
|
-
* - Blocked 状态检测(连续 stall 自动阻塞)
|
|
9
|
-
* - Steering 模板化(continuation / budget-limit / objective-updated)
|
|
10
|
-
* - 任务清单追踪
|
|
4
|
+
* 注册 command / events,全部委托 adapters 层。
|
|
5
|
+
* __goalInit 内部调 service.createGoal(FR-4.1)。
|
|
11
6
|
*
|
|
12
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
* -
|
|
15
|
-
* -
|
|
16
|
-
* -
|
|
17
|
-
* -
|
|
7
|
+
* 架构(D-21 双路径 + Ports/Adapters):
|
|
8
|
+
* - engine/:零 Pi 依赖的纯状态机(goal/budget/types)
|
|
9
|
+
* - ports.ts:机器可检查的能力边界
|
|
10
|
+
* - adapters/ports.ts:Pi → ServicePorts 桥接(单一构造点)
|
|
11
|
+
* - service.ts:事件入口协调器(applyEvent)
|
|
12
|
+
* - adapters/:Pi 桥接(command-adapter / event-adapter / ports)
|
|
13
|
+
* - projection/:渲染(widget / prompts)
|
|
14
|
+
*
|
|
15
|
+
* FR-4.2/D-16:ctx 必填,移除 lastCtx 模块级可变状态。
|
|
16
|
+
* FR-6.4:移除 hasPendingInjection。
|
|
17
|
+
* FR-6.7:移除 pendingPause(ESC 改用 ctx.signal.aborted 守卫,在 event-adapter)。
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type {
|
|
20
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext, Theme } from "@mariozechner/pi-coding-agent";
|
|
21
21
|
import { Text } from "@mariozechner/pi-tui";
|
|
22
|
-
import { type Static } from "typebox";
|
|
23
22
|
|
|
24
|
-
import {
|
|
25
|
-
import { handleBeforeAgentStart } from "./before-agent-start-handler";
|
|
26
|
-
import { handleGoalCommand } from "./command-handler";
|
|
27
|
-
import {
|
|
28
|
-
MAX_HISTORY_ENTRIES,
|
|
29
|
-
} from "./constants";
|
|
23
|
+
import { handleGoalCommand } from "./adapters/command-adapter";
|
|
30
24
|
import {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
} from "./
|
|
38
|
-
import {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
isGoalEntry,
|
|
45
|
-
isStaleContextError,
|
|
46
|
-
persistGoalState,
|
|
47
|
-
updateWidget,
|
|
48
|
-
} from "./tool-handler";
|
|
49
|
-
import { toSingleLine } from "./widget";
|
|
50
|
-
|
|
51
|
-
// ── Local Interfaces (avoid `any` on Pi callback/event signatures) ────
|
|
25
|
+
handleAgentEnd,
|
|
26
|
+
handleAgentStart,
|
|
27
|
+
handleBeforeAgentStart,
|
|
28
|
+
handleMessageEnd,
|
|
29
|
+
handleSessionStart,
|
|
30
|
+
handleTurnEnd,
|
|
31
|
+
} from "./adapters/event-adapter";
|
|
32
|
+
import { registerGoalControlTool } from "./adapters/goal-control-adapter";
|
|
33
|
+
import { buildPorts } from "./adapters/ports";
|
|
34
|
+
import { createGoal } from "./service";
|
|
35
|
+
import { createGoalSession, type GoalSession } from "./session";
|
|
36
|
+
|
|
37
|
+
// ── Local Interfaces (Like*Event — 避免 any on Pi callback/event signatures) ────
|
|
52
38
|
|
|
53
|
-
/** Fields accessed from BeforeAgentStartEvent */
|
|
54
39
|
interface BeforeAgentStartLikeEvent {
|
|
55
40
|
type: "before_agent_start";
|
|
56
41
|
prompt: string;
|
|
57
42
|
systemPrompt: string;
|
|
58
43
|
}
|
|
59
44
|
|
|
60
|
-
/** Fields accessed from TurnEndEvent */
|
|
61
45
|
interface TurnEndLikeEvent {
|
|
62
46
|
type: "turn_end";
|
|
63
47
|
turnIndex: number;
|
|
64
48
|
}
|
|
65
49
|
|
|
66
|
-
/** Subset of AgentMessage.usage accessed in message_end handler */
|
|
67
|
-
interface LikeUsage {
|
|
68
|
-
input?: number;
|
|
69
|
-
output?: number;
|
|
70
|
-
cacheRead?: number;
|
|
71
|
-
totalTokens?: number;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/** Fields accessed from MessageEndEvent */
|
|
75
50
|
interface MessageEndLikeEvent {
|
|
76
51
|
type: "message_end";
|
|
77
52
|
message: {
|
|
78
53
|
role: string;
|
|
79
|
-
usage?:
|
|
54
|
+
usage?: { input?: number; output?: number; cacheRead?: number; totalTokens?: number };
|
|
80
55
|
};
|
|
81
56
|
}
|
|
82
57
|
|
|
83
|
-
/** Fields accessed from AgentEndEvent */
|
|
84
58
|
interface AgentEndLikeEvent {
|
|
85
59
|
type: "agent_end";
|
|
86
60
|
messages: unknown[];
|
|
87
61
|
}
|
|
88
62
|
|
|
89
|
-
/** Fields accessed from SessionStartEvent */
|
|
90
63
|
interface SessionStartLikeEvent {
|
|
91
64
|
type: "session_start";
|
|
92
65
|
reason: string;
|
|
93
66
|
}
|
|
94
67
|
|
|
95
|
-
/** Shape of CustomMessage passed to registerMessageRenderer */
|
|
96
68
|
interface LikeCustomMessage {
|
|
97
69
|
customType: string;
|
|
98
70
|
content: string | unknown;
|
|
99
71
|
}
|
|
100
72
|
|
|
101
|
-
/** Options bag for registerMessageRenderer callback */
|
|
102
73
|
interface LikeMessageRenderOptions {
|
|
103
74
|
expanded: boolean;
|
|
104
75
|
}
|
|
105
76
|
|
|
106
|
-
/** Result shape accessed in renderResult */
|
|
107
|
-
interface LikeToolResult {
|
|
108
|
-
content: Array<{ type: string; text?: string }>;
|
|
109
|
-
details?: GoalManagerDetails;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Options bag for renderResult callback */
|
|
113
|
-
interface LikeToolRenderResultOptions {
|
|
114
|
-
expanded: boolean;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
// ── State Reconstruction ─────────────────────────────
|
|
118
|
-
|
|
119
|
-
function reconstructGoalState(pi: ExtensionAPI, session: GoalSession, ctx: ExtensionContext): void {
|
|
120
|
-
session.state = null;
|
|
121
|
-
const entries = ctx.sessionManager.getEntries();
|
|
122
|
-
|
|
123
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
124
|
-
const entry = entries[i]!;
|
|
125
|
-
if (isGoalEntry(entry)) {
|
|
126
|
-
const data = entry.data as Record<string, unknown> | undefined;
|
|
127
|
-
if (data) {
|
|
128
|
-
try {
|
|
129
|
-
session.state = deserializeState(data);
|
|
130
|
-
} catch {
|
|
131
|
-
session.state = null;
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
break;
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (!session.state) return;
|
|
139
|
-
|
|
140
|
-
// 非终态 → 恢复为 active
|
|
141
|
-
if (!isTerminalStatus(session.state.status) && session.state.status !== "paused") {
|
|
142
|
-
session.state.status = "active";
|
|
143
|
-
session.state.timeStartedAt = Date.now();
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// Entry GC — 标记旧的 goal-state entries
|
|
147
|
-
const goalEntryIndices: number[] = [];
|
|
148
|
-
let latestFound = false;
|
|
149
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
150
|
-
const entry = entries[i];
|
|
151
|
-
if (isGoalEntry(entry)) {
|
|
152
|
-
if (!latestFound) latestFound = true;
|
|
153
|
-
else goalEntryIndices.push(i);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
for (const idx of goalEntryIndices) {
|
|
157
|
-
entries.splice(idx, 1);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
// Goal-history entry GC
|
|
161
|
-
const historyIndices: number[] = [];
|
|
162
|
-
for (let i = 0; i < entries.length; i++) {
|
|
163
|
-
const entry = entries[i]!;
|
|
164
|
-
if (entry.type === "custom" && (entry as CustomEntry).customType === HISTORY_ENTRY_TYPE) {
|
|
165
|
-
historyIndices.push(i);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (historyIndices.length > MAX_HISTORY_ENTRIES) {
|
|
169
|
-
const toDelete = historyIndices.slice(0, historyIndices.length - MAX_HISTORY_ENTRIES);
|
|
170
|
-
for (let i = toDelete.length - 1; i >= 0; i--) {
|
|
171
|
-
entries.splice(toDelete[i]!, 1);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
|
|
176
77
|
// ── Extension Factory ─────────────────────────────────
|
|
177
78
|
|
|
178
79
|
export default function goalExtension(pi: ExtensionAPI) {
|
|
179
|
-
const session: GoalSession =
|
|
180
|
-
state: null,
|
|
181
|
-
tasksCompletedAtAgentStart: 0,
|
|
182
|
-
hasPendingInjection: false,
|
|
183
|
-
isProcessing: false, // P1-3: 防重入
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
// Capture latest ctx for external API (initializeGoalFromExternal needs it for persistGoalState)
|
|
187
|
-
let lastCtx: ExtensionContext | undefined;
|
|
188
|
-
|
|
189
|
-
pi.registerTool({
|
|
190
|
-
name: "goal_manager",
|
|
191
|
-
label: "Goal Manager",
|
|
192
|
-
description:
|
|
193
|
-
"Goal mode task manager. This tool is only available after starting a goal via the /goal command. AI cannot trigger it proactively. If Goal mode is not active, calling this tool will error." +
|
|
194
|
-
"\n\nAvailable actions:" +
|
|
195
|
-
"\n- create_tasks: Decompose the objective into a task list (call once at goal start). Each task description must be a one-line summary (max 60 chars), no newlines or markdown" +
|
|
196
|
-
"\n- add_tasks: Append new tasks to the existing list (when omissions are discovered). Each task description must be a one-line summary (max 60 chars), no newlines or markdown" +
|
|
197
|
-
"\n- update_tasks: Batch update task statuses (completed requires evidence, cancelled does not block goal completion)" +
|
|
198
|
-
"\n- list_tasks: View progress and remaining budget" +
|
|
199
|
-
"\n- complete_goal: Mark the objective as achieved (all tasks must be completed + evidence)" +
|
|
200
|
-
"\n- cancel_goal: Cancel the current goal (use when user wants to exit/stop)" +
|
|
201
|
-
"\n- report_blocked: Report being blocked (use when encountering unsolvable issues)" +
|
|
202
|
-
"\n- add_subtasks: Add subtasks to a specified task (params: taskId, texts[]). Use this instead of todo tool in Goal mode" +
|
|
203
|
-
"\n- update_subtasks: Batch update subtask statuses (params: taskId, subUpdates[])" +
|
|
204
|
-
"\n- delete_subtasks: Delete subtasks from a specified task (params: taskId, subIds[])",
|
|
205
|
-
promptSnippet: "Manage task list, completion status, and exit for /goal mode",
|
|
206
|
-
promptGuidelines: [
|
|
207
|
-
"[Workflow] After receiving the objective, the first step must be create_tasks to decompose. Do not re-call if task list already exists",
|
|
208
|
-
"[Format] Each task description must be a one-line summary, max 60 chars. No newlines, markdown, or detailed parameter lists — those go in execution phase. Example: 'Fix hook-registry dedup logic' not 'Fix hook-registry dedup + transport-execute enhancementConfig guard + failover-loop ...'",
|
|
209
|
-
"[Append] When discovering omissions during execution, use add_tasks to append — do not re-call create_tasks",
|
|
210
|
-
"[Completion] After completing a task, call update_tasks with status=completed and provide evidence (e.g. 'test X passed', 'file F created')",
|
|
211
|
-
"[Goal completion] Only call complete_goal when all tasks are completed with overall evidence",
|
|
212
|
-
"[Exit] When user says 'stop', 'exit', 'cancel', '不用了', '结束', etc. indicating they don't want to continue, immediately call cancel_goal — do not guide them through complete_goal",
|
|
213
|
-
"[Blocked] When encountering unsolvable technical issues, call report_blocked with the reason",
|
|
214
|
-
"[Progress] Use list_tasks anytime to check remaining tasks and budget",
|
|
215
|
-
"[Cancel] To cancel a task, use update_tasks with status=cancelled. Cancelled tasks do not block goal completion",
|
|
216
|
-
"[Forbidden] Do not mark tasks as completed without evidence, and do not call complete_goal without evidence",
|
|
217
|
-
"[Forbidden] Do not force task completion when the user explicitly wants to exit — call cancel_goal directly",
|
|
218
|
-
"[Forbidden] Do not re-call create_tasks to overwrite existing incomplete tasks — use add_tasks to append",
|
|
219
|
-
"[Subtask] For fine-grained step tracking in Goal mode, use add_subtasks — do not use the todo tool",
|
|
220
|
-
],
|
|
221
|
-
parameters: GoalManagerParams,
|
|
80
|
+
const session: GoalSession = createGoalSession();
|
|
222
81
|
|
|
223
|
-
|
|
224
|
-
lastCtx = ctx;
|
|
225
|
-
try {
|
|
226
|
-
// P1-4: 透传 signal
|
|
227
|
-
return await executeGoalAction(pi, session, params, ctx, signal);
|
|
228
|
-
} catch (err) {
|
|
229
|
-
// P1-2: Stale context 检测 — 静默吞掉
|
|
230
|
-
if (isStaleContextError(err)) {
|
|
231
|
-
return {
|
|
232
|
-
content: [{ type: "text" as const, text: "Goal context stale after compact or session replacement." }],
|
|
233
|
-
isError: true as const,
|
|
234
|
-
};
|
|
235
|
-
}
|
|
236
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
237
|
-
const inputSummary = JSON.stringify(params, null, 2); // eslint-disable-line no-magic-numbers -- JSON.stringify indent
|
|
238
|
-
return {
|
|
239
|
-
content: [{ type: "text", text: `${msg}\n\nInput: ${inputSummary}` }],
|
|
240
|
-
isError: true,
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
},
|
|
244
|
-
|
|
245
|
-
renderCall(args: Static<typeof GoalManagerParams>, theme: Theme) {
|
|
246
|
-
let text = theme.fg("toolTitle", theme.bold("goal_manager ")) + theme.fg("muted", args.action);
|
|
247
|
-
if (args.tasks) text += ` ${theme.fg("dim", `(${args.tasks.length} tasks)`)}`;
|
|
248
|
-
if (args.updates) text += ` ${theme.fg("dim", `(${args.updates.length} updates)`)}`;
|
|
249
|
-
if (args.taskId !== undefined) text += ` ${theme.fg("accent", `#${args.taskId}`)}`;
|
|
250
|
-
if (args.texts) text += ` ${theme.fg("dim", `(${args.texts.length} subtasks)`)}`;
|
|
251
|
-
if (args.subUpdates) text += ` ${theme.fg("dim", `(${args.subUpdates.length} subtask updates)`)}`;
|
|
252
|
-
if (args.subIds) text += ` ${theme.fg("dim", `del #${args.subIds.join(",")}`)}`;
|
|
253
|
-
return new Text(text, 0, 0);
|
|
254
|
-
},
|
|
255
|
-
|
|
256
|
-
renderResult(result: LikeToolResult, { expanded }: LikeToolRenderResultOptions, theme: Theme) {
|
|
257
|
-
const details = result.details as GoalManagerDetails | undefined;
|
|
258
|
-
if (!details || !Array.isArray(details.tasks)) {
|
|
259
|
-
const text = result.content[0];
|
|
260
|
-
return new Text(text?.type === "text" ? (text.text ?? "") : "", 0, 0);
|
|
261
|
-
}
|
|
262
|
-
const tasks = details.tasks;
|
|
263
|
-
const completed = tasks.filter((t) => t.status === "completed").length;
|
|
264
|
-
const summary = theme.fg("success", `✓ ${completed}/${tasks.length} completed`);
|
|
265
|
-
if (!expanded || tasks.length === 0) {
|
|
266
|
-
return new Text(summary, 0, 0);
|
|
267
|
-
}
|
|
268
|
-
const lines = [summary];
|
|
269
|
-
for (const t of tasks) {
|
|
270
|
-
const icon = t.status === "completed"
|
|
271
|
-
? theme.fg("success", "✓")
|
|
272
|
-
: t.status === "in_progress"
|
|
273
|
-
? theme.fg("warning", "●")
|
|
274
|
-
: t.status === "cancelled"
|
|
275
|
-
? theme.fg("dim", "✗")
|
|
276
|
-
: theme.fg("dim", "☐");
|
|
277
|
-
const descText = toSingleLine(t.description);
|
|
278
|
-
const desc = (t.status === "completed" || t.status === "cancelled")
|
|
279
|
-
? theme.fg("dim", descText)
|
|
280
|
-
: theme.fg("text", descText);
|
|
281
|
-
lines.push(` ${icon} ${theme.fg("accent", `#${t.id}`)} ${desc}`);
|
|
282
|
-
// Subtask items in expanded view
|
|
283
|
-
if (t.subtasks && t.subtasks.length > 0) {
|
|
284
|
-
for (const s of t.subtasks) {
|
|
285
|
-
const subIcon = s.status === "completed"
|
|
286
|
-
? theme.fg("success", "\u2713")
|
|
287
|
-
: s.status === "in_progress"
|
|
288
|
-
? theme.fg("warning", "\u25cf")
|
|
289
|
-
: theme.fg("dim", "\u25cb");
|
|
290
|
-
const subText = s.status === "completed" ? theme.fg("dim", s.text) : theme.fg("muted", s.text);
|
|
291
|
-
lines.push(` ${subIcon} ${theme.fg("dim", `${t.id}.${s.id}`)} ${subText}`);
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
return new Text(lines.join("\n"), 0, 0);
|
|
296
|
-
},
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
// ── Command: /goal ─────────────────────────────────
|
|
82
|
+
// ── Command: /goal ────────────────────────────────
|
|
300
83
|
|
|
301
84
|
pi.registerCommand("goal", {
|
|
302
85
|
description:
|
|
303
|
-
"Goal-driven mode: /goal <objective> [--tokens N] [--timeout N]
|
|
86
|
+
"Goal-driven mode: /goal <objective> [--tokens N] [--timeout N] | /goal resume | /goal clear | /goal update <new-objective> | /goal status | /goal history",
|
|
304
87
|
handler: async (args: string | undefined, ctx: ExtensionCommandContext) => {
|
|
305
88
|
await handleGoalCommand(pi, session, args, ctx);
|
|
306
89
|
},
|
|
307
90
|
});
|
|
308
91
|
|
|
309
|
-
// ──
|
|
92
|
+
// ── Tool: goal_control(complete / report_blocked,#3 替代已删 goal_manager)──
|
|
93
|
+
|
|
94
|
+
registerGoalControlTool(pi, session);
|
|
95
|
+
|
|
96
|
+
// ── Events(全部委托 adapters/event-adapter)────────
|
|
97
|
+
|
|
310
98
|
pi.on("before_agent_start", async (_event: BeforeAgentStartLikeEvent, ctx: ExtensionContext) => {
|
|
311
|
-
lastCtx = ctx;
|
|
312
99
|
return handleBeforeAgentStart(pi, session, ctx);
|
|
313
100
|
});
|
|
314
101
|
|
|
315
|
-
// ── Event: agent_start ─────────────────────────────
|
|
316
|
-
|
|
317
102
|
pi.on("agent_start", async () => {
|
|
318
|
-
|
|
319
|
-
session.tasksCompletedAtAgentStart = getCompletedCount(session.state.tasks);
|
|
103
|
+
await handleAgentStart(session);
|
|
320
104
|
});
|
|
321
105
|
|
|
322
|
-
// ── Event: turn_end ────────────────────────────────
|
|
323
106
|
pi.on("turn_end", async (_event: TurnEndLikeEvent, ctx: ExtensionContext) => {
|
|
324
|
-
|
|
325
|
-
session.state.currentTurnIndex++;
|
|
326
|
-
updateWidget(session, ctx);
|
|
107
|
+
await handleTurnEnd(pi, session, ctx);
|
|
327
108
|
});
|
|
328
109
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
if (!session.state || !isActiveStatus(session.state.status)) return;
|
|
332
|
-
if (event.message.role !== "assistant") return;
|
|
333
|
-
|
|
334
|
-
const usage = event.message.usage;
|
|
335
|
-
if (usage) {
|
|
336
|
-
const input = usage.input ?? 0;
|
|
337
|
-
const output = usage.output ?? 0;
|
|
338
|
-
const cacheRead = usage.cacheRead ?? 0;
|
|
339
|
-
if (input > 0 || output > 0) {
|
|
340
|
-
session.state.tokensUsed += Math.max(input - cacheRead, 0) + output;
|
|
341
|
-
} else if (usage.totalTokens) {
|
|
342
|
-
session.state.tokensUsed += usage.totalTokens;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
110
|
+
pi.on("message_end", async (event: MessageEndLikeEvent, ctx: ExtensionContext) => {
|
|
111
|
+
await handleMessageEnd(session, ctx, event);
|
|
345
112
|
});
|
|
346
113
|
|
|
347
|
-
// ── Event: agent_end ───────────────────────────────
|
|
348
114
|
pi.on("agent_end", async (_event: AgentEndLikeEvent, ctx: ExtensionContext) => {
|
|
349
115
|
await handleAgentEnd(pi, session, ctx);
|
|
350
116
|
});
|
|
351
117
|
|
|
352
|
-
// ── Event: session_start (state reconstruction) ───
|
|
353
118
|
pi.on("session_start", async (_event: SessionStartLikeEvent, ctx: ExtensionContext) => {
|
|
354
|
-
|
|
355
|
-
reconstructGoalState(pi, session, ctx);
|
|
356
|
-
if (session.state) {
|
|
357
|
-
session.tasksCompletedAtAgentStart = getCompletedCount(session.state.tasks);
|
|
358
|
-
updateWidget(session, ctx);
|
|
359
|
-
}
|
|
119
|
+
await handleSessionStart(pi, session, ctx);
|
|
360
120
|
});
|
|
361
121
|
|
|
362
122
|
// ── Message Renderers ──────────────────────────────
|
|
363
123
|
|
|
364
|
-
const goalMessageTypes = [
|
|
365
|
-
"goal-context",
|
|
366
|
-
"goal-context-exceeded",
|
|
367
|
-
"goal-staleness-reminder",
|
|
368
|
-
];
|
|
369
|
-
|
|
124
|
+
const goalMessageTypes = ["goal-context", "goal-context-exceeded"];
|
|
370
125
|
for (const customType of goalMessageTypes) {
|
|
371
|
-
pi.registerMessageRenderer(
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
? theme.fg("
|
|
126
|
+
pi.registerMessageRenderer(
|
|
127
|
+
customType,
|
|
128
|
+
(message: LikeCustomMessage, _options: LikeMessageRenderOptions, theme: Theme) => {
|
|
129
|
+
const prefix =
|
|
130
|
+
message.customType === "goal-context-exceeded"
|
|
131
|
+
? theme.fg("error", "[GOAL Budget] ")
|
|
377
132
|
: theme.fg("accent", "[GOAL] ");
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
133
|
+
const content =
|
|
134
|
+
typeof message.content === "string" ? message.content : JSON.stringify(message.content);
|
|
135
|
+
return new Text(prefix + theme.fg("dim", content), 0, 0);
|
|
136
|
+
},
|
|
137
|
+
);
|
|
381
138
|
}
|
|
382
139
|
|
|
383
|
-
// ── External API:
|
|
140
|
+
// ── External API: __goalInit(FR-4 双轨消除)──────────
|
|
384
141
|
|
|
385
142
|
/**
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
*
|
|
143
|
+
* 允许其他扩展(coding-workflow / plan)通过 pi.__goalInit 编程式初始化 goal。
|
|
144
|
+
* 内部调 service.createGoal(FR-4.1——与 goal_control create 走同一创建逻辑)。
|
|
145
|
+
*
|
|
146
|
+
* FR-4.2/D-16: ctx 必填(消除 lastCtx 模块级可变状态)。
|
|
147
|
+
* ports 构造复用 adapters/ports.buildPorts(DRY:单一 ports 构造点)。
|
|
148
|
+
*
|
|
149
|
+
* @param objective 目标描述
|
|
150
|
+
* @param budget 预算配置,传 undefined 用默认值
|
|
151
|
+
* @param ctx **必填**——调用方的 ExtensionContext。省略会返回 false(创建失败)。
|
|
152
|
+
* @param slug 可选短标识(仅 widget 标题 + history 用,不注入 prompt)
|
|
153
|
+
* @returns true 创建成功;false 已有 active goal 或 ctx 缺失
|
|
389
154
|
*/
|
|
390
|
-
|
|
155
|
+
const api = pi as unknown as Record<string, unknown>;
|
|
156
|
+
api.__goalInit = (
|
|
391
157
|
objective: string,
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
session.tasksCompletedAtAgentStart = 0;
|
|
401
|
-
|
|
402
|
-
// Create tasks (same logic as handleCreateTasks)
|
|
403
|
-
session.state.tasks = tasks.map((desc, i) => ({
|
|
404
|
-
id: i + 1,
|
|
405
|
-
description: desc.length > 60 ? desc.slice(0, 57) + "..." : desc,
|
|
406
|
-
status: "pending" as const,
|
|
407
|
-
lastUpdatedTurn: session.state!.currentTurnIndex,
|
|
408
|
-
}));
|
|
158
|
+
budget: GoalInitBudget | undefined,
|
|
159
|
+
ctx: ExtensionContext,
|
|
160
|
+
slug?: string,
|
|
161
|
+
): boolean => {
|
|
162
|
+
if (!ctx) return false;
|
|
163
|
+
return createGoal(session, objective, budget ?? {}, buildPorts(pi, ctx), true, slug);
|
|
164
|
+
};
|
|
165
|
+
}
|
|
409
166
|
|
|
410
|
-
|
|
411
|
-
// Note: ctx is captured from the last event handler invocation — acceptable
|
|
412
|
-
// because initializeGoalFromExternal is called synchronously during tool execution.
|
|
413
|
-
if (lastCtx) {
|
|
414
|
-
persistGoalState(pi, session, lastCtx);
|
|
415
|
-
}
|
|
167
|
+
// ── Cross-extension API 类型(单一 source of truth,API-1)──────────
|
|
416
168
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
169
|
+
/**
|
|
170
|
+
* `pi.__goalInit` 的预算配置形状。
|
|
171
|
+
*
|
|
172
|
+
* 跨扩展(coding-workflow / plan)通过 `pi.__goalInit` 编程式初始化 goal 时使用。
|
|
173
|
+
* 与 `BudgetConfig` 的差异:本类型只暴露外部可设的可选字段,且全部 optional。
|
|
174
|
+
*/
|
|
175
|
+
export interface GoalInitBudget {
|
|
176
|
+
tokenBudget?: number;
|
|
177
|
+
timeBudgetMinutes?: number;
|
|
423
178
|
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* `pi.__goalInit` 的规范函数签名(API-1:单一 source of truth)。
|
|
182
|
+
*
|
|
183
|
+
* 跨扩展消费者应 import 本类型而非重复声明 inline alias,避免签名 drift:
|
|
184
|
+
* ```ts
|
|
185
|
+
* import type { GoalInitFn } from "@zhushanwen/pi-goal";
|
|
186
|
+
* const goalInit = (pi as unknown as { __goalInit?: GoalInitFn }).__goalInit;
|
|
187
|
+
* ```
|
|
188
|
+
*
|
|
189
|
+
* @param objective 目标描述
|
|
190
|
+
* @param budget 预算配置,传 undefined 用默认值
|
|
191
|
+
* @param ctx **必填**——调用方的 ExtensionContext。省略会返回 false(创建失败)。
|
|
192
|
+
* @param slug 可选短标识(仅 widget 标题 + history 用,不注入 prompt)
|
|
193
|
+
* @returns true 创建成功;false 已有 active goal 或 ctx 缺失
|
|
194
|
+
*/
|
|
195
|
+
export type GoalInitFn = (
|
|
196
|
+
objective: string,
|
|
197
|
+
budget: GoalInitBudget | undefined,
|
|
198
|
+
ctx: ExtensionContext,
|
|
199
|
+
slug?: string,
|
|
200
|
+
) => boolean;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 持久化层 — serialize/deserialize + history entry 构造
|
|
3
|
+
*
|
|
4
|
+
* FR-5: 移除旧格式兼容,字段缺失直接 throw(tasks 字段例外——向后兼容忽略)。
|
|
5
|
+
* 零 Pi 依赖。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { GoalRuntimeState } from "./engine/types";
|
|
9
|
+
import type { GoalHistoryEntry } from "./ports";
|
|
10
|
+
|
|
11
|
+
// ── 常量 ──────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export const ENTRY_TYPE = "goal-state";
|
|
14
|
+
export const HISTORY_ENTRY_TYPE = "goal-history";
|
|
15
|
+
|
|
16
|
+
// ── serialize(深拷贝,纯函数)────────────────────────
|
|
17
|
+
|
|
18
|
+
export function serializeState(state: GoalRuntimeState): GoalRuntimeState {
|
|
19
|
+
return {
|
|
20
|
+
...state,
|
|
21
|
+
budget: { ...state.budget },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── deserialize(FR-5 严格解析,缺字段 throw)──────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 反序列化持久化 state。
|
|
29
|
+
*
|
|
30
|
+
* 向后兼容:旧 entry 可能含 `tasks` 字段(task CRUD 删除前的格式),此处忽略不 throw。
|
|
31
|
+
* 其余必填字段缺失仍 throw(FR-5)。
|
|
32
|
+
*/
|
|
33
|
+
export function deserializeState(data: Record<string, unknown>): GoalRuntimeState {
|
|
34
|
+
const req = <T>(key: string): T => {
|
|
35
|
+
if (!(key in data) || data[key] === undefined) {
|
|
36
|
+
throw new Error(`Missing required field: ${key}`);
|
|
37
|
+
}
|
|
38
|
+
return data[key] as T;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
goalId: req("goalId"),
|
|
43
|
+
objective: req("objective"),
|
|
44
|
+
// slug 用可选解析:旧持久化数据无此字段,不能误用 req()(否则旧数据 throw → state 全丢,GAP-4)
|
|
45
|
+
slug: data.slug as string | undefined,
|
|
46
|
+
status: req("status"),
|
|
47
|
+
tokensUsed: req("tokensUsed"),
|
|
48
|
+
timeStartedAt: req("timeStartedAt"),
|
|
49
|
+
timeUsedSeconds: req("timeUsedSeconds"),
|
|
50
|
+
budget: req("budget"),
|
|
51
|
+
lastProgressTurn: req("lastProgressTurn"),
|
|
52
|
+
budgetLimitSteeringSent: req("budgetLimitSteeringSent"),
|
|
53
|
+
objectiveUpdatedAt: req("objectiveUpdatedAt"),
|
|
54
|
+
lastBlockerReason: req("lastBlockerReason"),
|
|
55
|
+
tokenWarning70Sent: req("tokenWarning70Sent"),
|
|
56
|
+
tokenWarning90Sent: req("tokenWarning90Sent"),
|
|
57
|
+
timeWarning70Sent: req("timeWarning70Sent"),
|
|
58
|
+
timeWarning90Sent: req("timeWarning90Sent"),
|
|
59
|
+
lastTurnTokensUsed: req("lastTurnTokensUsed"),
|
|
60
|
+
currentTurnIndex: req("currentTurnIndex"),
|
|
61
|
+
completedAtTurnIndex: data.completedAtTurnIndex as number | undefined,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── makeHistoryEntry ─────────────────────────────────
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 从 state 构造 GoalHistoryEntry(纯函数)。
|
|
69
|
+
*
|
|
70
|
+
* totalTasks 暂置 0(task CRUD 已删除,#7 注入 todo 进度后可重填)。
|
|
71
|
+
*/
|
|
72
|
+
export function makeHistoryEntry(state: GoalRuntimeState, completedTasks: number): GoalHistoryEntry {
|
|
73
|
+
return {
|
|
74
|
+
goalId: state.goalId,
|
|
75
|
+
objective: state.objective,
|
|
76
|
+
slug: state.slug,
|
|
77
|
+
status: state.status,
|
|
78
|
+
completedTasks,
|
|
79
|
+
totalTasks: 0,
|
|
80
|
+
elapsedSeconds: Math.floor(state.timeUsedSeconds),
|
|
81
|
+
timestamp: Date.now(),
|
|
82
|
+
};
|
|
83
|
+
}
|