@eddyskywalker/dsh-chatgpt-subscription 0.47.0 → 0.48.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.
@@ -0,0 +1,783 @@
1
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
+ import { defineTool } from "@deepseek-ai/dsh-tools";
3
+ import { z } from "zod";
4
+ import { Service } from "@deepseek-ai/cordis";
5
+ //#region src/entry-plan-mode.ts
6
+ /**
7
+ * 「计划模式」服务 —— ctx.planMode 的 **fork**(本包在 dispatch preset 里替换
8
+ * harness 官方的 @deepseek-ai/dsh-plan-mode)。
9
+ *
10
+ * ## 为什么必须 fork
11
+ *
12
+ * 官方实现在**进入**与**离开**计划模式的那一步同时改两样东西,而这两样都是请求
13
+ * 前缀的一部分,于是每进一次计划模式就烧掉一整次前缀缓存重建:
14
+ *
15
+ * 1. 动态 plan:policy 段:systemPrompt.section(...) 的文本在 section 与 '' 之间翻转
16
+ * —— system 段在请求最前面,改一个字就是全前缀作废;
17
+ * 2. exit_plan_mode 用 { when } 逐步准入 —— 工具表一变,native tools 与 PTC
18
+ * tools:sdk 两边同时重建。
19
+ *
20
+ * Kimi 线路按内容前缀命中,这两次重建是**计划模式必经**的固定税。这个 fork 把税
21
+ * 取消掉,靠的是同一个观察:**末尾追加不破坏前缀**。
22
+ *
23
+ * ## 三个决定(每一个都对应上面一处重建)
24
+ *
25
+ * 1. **不注册任何动态 system 段。** system 段在整个会话里逐字节恒定。
26
+ * 2. **exit_plan_mode 无条件注册。** 没有 { when },它从会话第一步就在工具表里,
27
+ * 进出计划模式都不改工具表;「只在计划模式可用」由 execute 里的 loggedActive
28
+ * 判定执行(报错文案与官方一致)。代价是每一步多带一个工具声明,收益是
29
+ * native / PTC 两侧零重建。
30
+ * 3. **部署方的 section 走尾部用户消息**(agent/pre-step 的 decision.messages,
31
+ * 无回合时走 agent.inject),一次进入只投一次。手册性指引留在 enter_plan_mode
32
+ * 的工具返回里(见 entry-plan-mode-pipeline.ts)。
33
+ *
34
+ * ## 与官方实现的其余差别一律是「代次协商」,不是功能裁剪
35
+ *
36
+ * - plan 投影的 stateVersion 在 0.2.1-rc.5 及更早是 3(无 entered),0.2.2-alpha
37
+ * 起是 4(含 entered)。sessionProjections.register 对同一个 key 的版本不一致是
38
+ * **硬失败**,而同一个宿主里另一个 preset 可能已经注册过它,所以这里先按 4 注册,
39
+ * 失败时读错误里的 already registered at stateVersion N 用那个版本重试一次。
40
+ * - register() 在旧代是同步的、状态要经 ctx.sessionProjections.stateOf() 读;新代
41
+ * 返回可 await 的 ProjectionHandle。两种形状都在下面接住。
42
+ * - 没有 sessionProjections(老代宿主)时退回进程内 WeakMap:计划模式照常可用,
43
+ * 只是不随会话日志持久化。
44
+ *
45
+ * ## 不能 import 的东西
46
+ *
47
+ * @deepseek-ai/dsh-plan-mode / dsh-agent / dsh-session / dsh-commands /
48
+ * dsh-user-questions / dsh-session-projection 都不在本包 peerDependencies 里,所以
49
+ * 顶层**不 import 它们**,只按结构探测:ctx.get('sessionProjections')、
50
+ * ctx.get('userQuestions')、ctx.get('tools')、ctx.get('agents')、ctx.get('commands')。
51
+ *
52
+ * @module @eddyskywalker/dsh-chatgpt-subscription/plan-mode
53
+ */
54
+ /** 组件行名;preset 里的行 id 与模块说明符另由 preset 文件决定。 */
55
+ const name = "dsh-chatgpt-subscription-plan-mode";
56
+ /** 模型侧审批出口的工具名。冻结契约的一部分,不要改动。 */
57
+ const EXIT_PLAN_MODE = "exit_plan_mode";
58
+ /** 官方实现用来承载计划指引的 prompt 段名(这个 fork 刻意**不注册**它)。 */
59
+ const PLAN_POLICY_SECTION = "plan:policy";
60
+ /** plan 投影的 key。跨 preset 共享,不能改名。 */
61
+ const PLAN_PROJECTION_KEY = "plan";
62
+ /** 会话日志里记录计划模式开关的事件类型(与官方同名,投影可互换折叠)。 */
63
+ const PLAN_MODE_EVENT = "plan/mode";
64
+ /** 最新一代的投影状态版本(0.2.2-alpha 起:含 entered)。 */
65
+ const PLAN_STATE_VERSION = 4;
66
+ /** 旧一代(≤0.2.1-rc.5)的投影状态版本:没有 entered。 */
67
+ const LEGACY_PLAN_STATE_VERSION = 3;
68
+ /** 审批问题在答案里的 id。 */
69
+ const REVIEW_ID = "plan-review";
70
+ /** 审批问题「批准」选项的文案。 */
71
+ const APPROVE_LABEL = "Approve";
72
+ /** 审批问题「继续规划」选项的文案。 */
73
+ const KEEP_PLANNING_LABEL = "Keep planning";
74
+ /** 尾部通知消息的来源 kind(本包自有,见下方 MessageSourceMap 增补)。 */
75
+ const PLAN_NOTICE_SOURCE_KIND = "plan-mode-notice";
76
+ /**
77
+ * 校验部署方配置。缺失、空白、类型不符与未知字段都在**挂载时**失败,而不是被忽略。
78
+ * @param config - 原始插件配置。
79
+ * @returns 一份脱离入参的校验后配置。
80
+ * @throws {Error} 判定与文案与官方一致,便于既有部署配置与测试直接迁移。
81
+ */
82
+ function resolveConfig(config) {
83
+ const section = config?.section;
84
+ if (typeof section !== "string") throw new Error("PlanModeConfig needs a string `section`");
85
+ if (section.trim() === "") throw new Error("PlanModeConfig needs a non-empty `section`");
86
+ const unknown = Object.keys(config ?? {}).filter((key) => key !== "section");
87
+ if (unknown.length > 0) throw new Error("PlanModeConfig has unknown key(s) " + unknown.join(", ") + " — config is { section }");
88
+ return { section };
89
+ }
90
+ /**
91
+ * 投影状态 schema。
92
+ *
93
+ * 刻意**不用 strict()、并给 entered 一个默认值**:同一个 key 可能被按 v3 注册的
94
+ * 另一份定义(≤0.2.1-rc.5,没有 entered 字段)写进持久化缓存,严格的 schema 会让
95
+ * 那份缓存整行作废,进而把服务从「共享」变成「冲突」。
96
+ */
97
+ const planUnitStateSchema = z.object({
98
+ active: z.boolean(),
99
+ wanted: z.boolean().nullable(),
100
+ running: z.object({
101
+ commandId: z.string(),
102
+ wanted: z.boolean()
103
+ }).nullable(),
104
+ activeAtLastHeader: z.boolean().nullable(),
105
+ entered: z.boolean().default(false)
106
+ });
107
+ /** 线上视图 schema。 */
108
+ const planProjectionViewSchema = z.object({
109
+ active: z.boolean(),
110
+ pending: z.boolean()
111
+ });
112
+ /**
113
+ * 造一份 plan 投影定义。
114
+ *
115
+ * 折叠逻辑与官方逐句等价:/plan 命令的生命周期(command/run -> command/done)、
116
+ * plan/mode 整值记录、request/header 记下「上一次告诉模型的模式」。等价是硬要求:
117
+ * 同一个宿主里另一份定义可能已经按别的 stateVersion 注册了同一个 key,两边折叠出的
118
+ * 状态必须是同一个东西,共享才安全。
119
+ * @param stateVersion - 投影状态版本,默认最新一代(4)。冲突时用冲突方的版本重试。
120
+ * @returns 投影定义。
121
+ */
122
+ function createPlanProjectionDefinition(stateVersion = 4) {
123
+ return {
124
+ key: PLAN_PROJECTION_KEY,
125
+ stateVersion,
126
+ stateSchema: planUnitStateSchema,
127
+ init: () => ({
128
+ active: false,
129
+ wanted: null,
130
+ running: null,
131
+ activeAtLastHeader: null,
132
+ entered: false
133
+ }),
134
+ apply: (state, event) => {
135
+ const data = event.data ?? {};
136
+ if (event.type === "command/run" && data.name === "plan") {
137
+ if (data.args === void 0) return state;
138
+ const wanted = typeof data.args === "string" ? data.args.trim() !== "off" : false;
139
+ return {
140
+ ...state,
141
+ running: {
142
+ commandId: String(data.commandId ?? ""),
143
+ wanted
144
+ }
145
+ };
146
+ }
147
+ if (event.type === "command/done" && state.running !== null && data.commandId === state.running.commandId) {
148
+ const wanted = data.kind === "success" && state.running.wanted !== state.active ? state.running.wanted : null;
149
+ return {
150
+ ...state,
151
+ wanted,
152
+ running: null
153
+ };
154
+ }
155
+ if (event.type === "plan/mode") {
156
+ const active = data.active === true;
157
+ return {
158
+ ...state,
159
+ active,
160
+ wanted: null,
161
+ entered: state.entered || active
162
+ };
163
+ }
164
+ if (event.type === "request/header") return {
165
+ ...state,
166
+ activeAtLastHeader: state.active
167
+ };
168
+ return state;
169
+ },
170
+ wire: {
171
+ viewSchema: planProjectionViewSchema,
172
+ view: (state) => {
173
+ const wanted = state.running?.wanted ?? state.wanted;
174
+ return {
175
+ active: state.active,
176
+ pending: wanted !== null && wanted !== state.active
177
+ };
178
+ }
179
+ }
180
+ };
181
+ }
182
+ /** 最新一代(stateVersion 4)的 plan 投影定义。 */
183
+ const planProjectionDefinition = createPlanProjectionDefinition();
184
+ /** 什么读不到时的空状态。 */
185
+ const EMPTY_PLAN_STATE = Object.freeze({
186
+ active: false,
187
+ wanted: null,
188
+ running: null,
189
+ activeAtLastHeader: null,
190
+ entered: false
191
+ });
192
+ /**
193
+ * 调用时读一个可选服务。服务缺席或读取抛错一律 undefined —— 这一行不该因为
194
+ * 某个服务不在而挂不上。
195
+ * @param ctx - 宿主上下文(结构类型:老代的 ctx 也认)。
196
+ * @param service - 服务名。
197
+ * @returns 服务实例;不存在或读取抛错时 undefined。
198
+ */
199
+ function serviceOf(ctx, service) {
200
+ const read = ctx?.get;
201
+ if (typeof read !== "function") return void 0;
202
+ try {
203
+ return read.call(ctx, service);
204
+ } catch {
205
+ return;
206
+ }
207
+ }
208
+ /**
209
+ * 记一条日志。日志服务缺席时安静跳过。
210
+ * @param ctx - 宿主上下文。
211
+ * @param level - 级别。
212
+ * @param message - 正文。
213
+ */
214
+ function log(ctx, level, message) {
215
+ const logger = ctx?.logger;
216
+ try {
217
+ if (level === "warn") logger?.warn(message);
218
+ else logger?.info(message);
219
+ } catch {}
220
+ }
221
+ /**
222
+ * 会话对象作为 WeakMap 键。
223
+ * @param session - 任意值。
224
+ * @returns 是对象时原样返回,否则 undefined(没有会话就等于没有状态)。
225
+ */
226
+ function objectKey(session) {
227
+ return typeof session === "object" && session !== null ? session : void 0;
228
+ }
229
+ /**
230
+ * 从版本冲突错误里读冲突方的 stateVersion。
231
+ * @param error - register 抛出的错误。
232
+ * @returns 冲突方版本;不是版本冲突时 undefined。
233
+ */
234
+ function conflictingStateVersion(error) {
235
+ const message = error instanceof Error ? error.message : String(error);
236
+ const match = /already registered at stateVersion (\d+)/.exec(message);
237
+ if (match === null) return void 0;
238
+ const version = Number.parseInt(match[1], 10);
239
+ return Number.isSafeInteger(version) && version >= 0 ? version : void 0;
240
+ }
241
+ /**
242
+ * 把 register() 的返回值接成句柄。
243
+ *
244
+ * 三种形状:可 await 的 ProjectionHandle(新代)、同步 disposer 函数(旧代,状态要
245
+ * 经 ctx.sessionProjections.stateOf 读)、以及两者都不是(这一代没有投影注册面)。
246
+ * @param ready - register 的返回值。
247
+ * @param projections - 投影服务本身(旧代读状态的兜底)。
248
+ * @returns 句柄;接不住时 undefined。
249
+ */
250
+ async function handleOf(ready, projections) {
251
+ if (typeof ready?.then === "function") {
252
+ const handle = await ready;
253
+ return typeof handle?.stateOf === "function" ? handle : legacyHandle(projections);
254
+ }
255
+ return legacyHandle(projections);
256
+ }
257
+ /**
258
+ * 旧代的读法:按 key 读。
259
+ * @param projections - 投影服务。
260
+ * @returns 句柄;服务没有按 key 读的能力时 undefined。
261
+ */
262
+ function legacyHandle(projections) {
263
+ if (typeof projections.stateOf !== "function") return void 0;
264
+ return { stateOf: (session) => projections.stateOf?.(session, PLAN_PROJECTION_KEY) };
265
+ }
266
+ /**
267
+ * 订阅一个事件;这一代没有事件总线时安静跳过。
268
+ * @param ctx - 宿主上下文。
269
+ * @param event - 事件名。
270
+ * @param listener - 监听器。
271
+ * @returns 解绑函数;订不上时 undefined。
272
+ */
273
+ function subscribe(ctx, event, listener) {
274
+ const on = ctx?.on;
275
+ if (typeof on !== "function") return void 0;
276
+ try {
277
+ return on.call(ctx, event, listener);
278
+ } catch {
279
+ return;
280
+ }
281
+ }
282
+ /**
283
+ * ctx.planMode 的实现。
284
+ *
285
+ * 一个会话一份(agent plane),所以内部所有状态都按会话/agent 分桶:待投递选择按
286
+ * 会话 WeakMap,投影句柄一份(投影本身按会话折叠),内存兜底状态按会话 WeakMap。
287
+ */
288
+ var PlanModeController = class extends Service {
289
+ /**
290
+ * 依赖:tools 注册审批出口,sessionProjections 折叠状态,agents 读回合边界。
291
+ *
292
+ * **故意不含 systemPrompt** —— 这个 fork 一个 prompt 段都不注册,system 段在
293
+ * 整个会话里逐字节恒定,进出计划模式都不重建前缀缓存。
294
+ */
295
+ static inject = [
296
+ "tools",
297
+ "sessionProjections",
298
+ "agents"
299
+ ];
300
+ /** 校验后的部署指引,只经尾部消息投递。 */
301
+ section;
302
+ /** plan 投影的读面;没有投影服务时 undefined。 */
303
+ planProjectionHandle;
304
+ /** 尚未落到日志的选择,按会话。 */
305
+ pendingIntents = /* @__PURE__ */ new WeakMap();
306
+ /** 没有投影服务时的内存状态兜底(计划模式照常可用,只是不持久化)。 */
307
+ memoryStates = /* @__PURE__ */ new WeakMap();
308
+ /** 服务在自己脚下被卸载:此时批过的计划不能再被追加,必须让模型重新呈交。 */
309
+ disposed = false;
310
+ /**
311
+ * @param ctx - 宿主上下文。
312
+ * @param config - 部署配置(默认空 section,会在挂载时被 resolveConfig 拒绝)。
313
+ */
314
+ constructor(ctx, config = { section: "" }) {
315
+ super(ctx, "planMode");
316
+ this.section = resolveConfig(config).section;
317
+ }
318
+ /**
319
+ * 挂载主体:注册投影、订阅 pre-step、注册审批出口与 /plan 命令。
320
+ *
321
+ * 顺序有讲究:投影先就位,pre-step 与 exit_plan_mode 才能读到状态。
322
+ * @returns 挂载完成(投影注册可能是异步的)。
323
+ */
324
+ async [Service.init]() {
325
+ const ctx = this.ctx;
326
+ this.planProjectionHandle = await this.acquirePlanProjection();
327
+ const effect = ctx?.effect;
328
+ if (typeof effect === "function") try {
329
+ effect.call(ctx, () => () => {
330
+ this.disposed = true;
331
+ }, "dsh-chatgpt-subscription: close plan-mode service");
332
+ } catch {}
333
+ this.subscribePreStep();
334
+ this.registerExitTool();
335
+ this.registerPlanCommand();
336
+ }
337
+ /**
338
+ * 注册 plan 投影,并在版本冲突时按冲突方的 stateVersion 重试一次。
339
+ *
340
+ * 冲突本身是「同一个宿主里另一个 preset 已经注册了 plan」:那不是错误,而是
341
+ * 共享同一个折叠单元的前提。所以只读错误里的版本号,不按 harness 版本猜。
342
+ * @returns 投影读面;这一代没有投影注册面时 undefined。
343
+ */
344
+ async acquirePlanProjection() {
345
+ const projections = serviceOf(this.ctx, "sessionProjections");
346
+ if (projections === void 0 || typeof projections.register !== "function") {
347
+ log(this.ctx, "info", "[dsh-chatgpt-subscription] plan mode has no sessionProjections service: plan mode works but its state is not persisted in this host");
348
+ return;
349
+ }
350
+ let stateVersion = 4;
351
+ for (let attempt = 0; attempt < 2; attempt += 1) {
352
+ const definition = createPlanProjectionDefinition(stateVersion);
353
+ try {
354
+ return await handleOf(projections.register(definition), projections);
355
+ } catch (error) {
356
+ const reported = conflictingStateVersion(error);
357
+ if (reported === void 0 || reported === stateVersion) {
358
+ log(this.ctx, "warn", "[dsh-chatgpt-subscription] plan mode could not register the plan projection: " + (error instanceof Error ? error.message : String(error)));
359
+ return;
360
+ }
361
+ stateVersion = reported;
362
+ }
363
+ }
364
+ }
365
+ /**
366
+ * 已记录的计划状态。
367
+ * @param session - 会话。
368
+ * @returns 状态;读不到时是空状态(绝不抛错 —— 一个读失败不该打断整步)。
369
+ */
370
+ planState(session) {
371
+ const handle = this.planProjectionHandle;
372
+ if (handle !== void 0) try {
373
+ const state = handle.stateOf(session);
374
+ if (typeof state === "object" && state !== null && typeof state.active === "boolean") return state;
375
+ } catch {}
376
+ const key = objectKey(session);
377
+ return key === void 0 ? EMPTY_PLAN_STATE : this.memoryStates.get(key) ?? EMPTY_PLAN_STATE;
378
+ }
379
+ /**
380
+ * 已记录的计划模式开关。
381
+ * @param session - 会话。
382
+ * @returns 是否在计划模式。
383
+ */
384
+ loggedActive(session) {
385
+ return this.planState(session).active;
386
+ }
387
+ /**
388
+ * 本步会呈现给模型的状态:未决选择优先,否则是已记录状态。
389
+ * @param session - 会话。
390
+ * @returns 是否生效。
391
+ */
392
+ selectedActive(session) {
393
+ const key = objectKey(session);
394
+ return (key === void 0 ? void 0 : this.pendingIntents.get(key))?.active ?? this.loggedActive(session);
395
+ }
396
+ /**
397
+ * 上一次告诉模型的模式;还没发过请求时 undefined。
398
+ * @param session - 会话。
399
+ * @returns true / false / undefined。
400
+ */
401
+ loggedActiveAtLastHeader(session) {
402
+ return this.planState(session).activeAtLastHeader ?? void 0;
403
+ }
404
+ /**
405
+ * 会话是否处在打开的回合里(决定「立刻落日志」还是「等下一个被接受的 pre-step」)。
406
+ *
407
+ * 三级降级,与官方等价:agents.turnBoundaryOf(新代)> 投影里的 turnBoundary
408
+ * 状态(旧代)> 判定为没有打开回合(无从判定时按回合结束处理,与官方的
409
+ * 「回合之外立刻提交」一致)。
410
+ * @param session - 会话。
411
+ * @returns 是否有打开回合。
412
+ */
413
+ hasOpenTurn(session) {
414
+ const agents = serviceOf(this.ctx, "agents");
415
+ if (typeof agents?.turnBoundaryOf === "function") try {
416
+ const boundary = agents.turnBoundaryOf(session);
417
+ if (boundary !== void 0) return boundary?.openTurnStartSeq !== null;
418
+ } catch {}
419
+ const projections = serviceOf(this.ctx, "sessionProjections");
420
+ if (typeof projections?.stateOf === "function") try {
421
+ const boundary = projections.stateOf(session, "turnBoundary");
422
+ if (boundary !== void 0) return boundary?.openTurnStartSeq !== null;
423
+ } catch {}
424
+ return false;
425
+ }
426
+ /**
427
+ * 读当前计划模式(已记录 + 未决选择),供调用方展示。
428
+ * @param agent - 要读的 agent。
429
+ * @returns 已记录状态,以及在果有未决选择时的目标。
430
+ */
431
+ get(agent) {
432
+ const session = agent?.session;
433
+ const active = this.loggedActive(session);
434
+ const key = objectKey(session);
435
+ const pending = key === void 0 ? void 0 : this.pendingIntents.get(key);
436
+ return pending === void 0 ? { active } : {
437
+ active,
438
+ pending: pending.active
439
+ };
440
+ }
441
+ /**
442
+ * 选择计划模式是否生效。
443
+ *
444
+ * 回合之外立刻落日志并补一句说明;回合之内留作未决选择,等下一个被接受的
445
+ * pre-step(那时才写日志、才补说明)。重复选择当前值或已未决的同值是 no-op。
446
+ * @param agent - 要切换的 agent。
447
+ * @param active - 目标状态。
448
+ * @param options - 可选:narrate=false 表示调用方的工具结果已经把话说完了。
449
+ * @returns 发生了什么。
450
+ */
451
+ set(agent, active, options = {}) {
452
+ const session = agent?.session;
453
+ const key = objectKey(session);
454
+ if (key === void 0) return "noop";
455
+ const narrate = options.narrate !== false;
456
+ if (active === (this.pendingIntents.get(key)?.active ?? this.loggedActive(session))) return "noop";
457
+ if (this.hasOpenTurn(session)) {
458
+ this.pendingIntents.set(key, {
459
+ active,
460
+ narrate
461
+ });
462
+ return this.loggedActive(session) === active ? "cancelled" : "queued";
463
+ }
464
+ if (active === this.loggedActive(session)) {
465
+ this.pendingIntents.delete(key);
466
+ return "cancelled";
467
+ }
468
+ this.appendMode(session, active);
469
+ this.pendingIntents.delete(key);
470
+ const narration = this.narration(session, active);
471
+ if (narration !== void 0) this.injectMessage(agent, narration);
472
+ return "committed";
473
+ }
474
+ /**
475
+ * 把一个未决选择落进日志。
476
+ * @param session - 会话。
477
+ */
478
+ onBoundary(session) {
479
+ const key = objectKey(session);
480
+ if (key === void 0) return;
481
+ const pending = this.pendingIntents.get(key);
482
+ if (pending === void 0) return;
483
+ const target = pending.active;
484
+ if (target === this.loggedActive(session)) {
485
+ this.pendingIntents.delete(key);
486
+ return;
487
+ }
488
+ this.appendMode(session, target);
489
+ this.pendingIntents.delete(key);
490
+ }
491
+ /**
492
+ * 追加一条 plan/mode 事件,并同步内存兜底状态。
493
+ *
494
+ * 只在 append 成功后更新内存:写失败要留下可重试的选择,而不是一个假状态。
495
+ * @param session - 会话。
496
+ * @param active - 目标状态。
497
+ */
498
+ appendMode(session, active) {
499
+ const append = session?.append;
500
+ if (typeof append === "function") append.call(session, PLAN_MODE_EVENT, { active });
501
+ const key = objectKey(session);
502
+ if (key === void 0) return;
503
+ const current = this.memoryStates.get(key) ?? EMPTY_PLAN_STATE;
504
+ this.memoryStates.set(key, {
505
+ ...current,
506
+ active,
507
+ wanted: null,
508
+ entered: current.entered || active
509
+ });
510
+ }
511
+ /**
512
+ * 把一条尾部消息塞进当前会话。
513
+ * @param agent - 调用方 agent。
514
+ * @param message - 用户角色消息。
515
+ */
516
+ injectMessage(agent, message) {
517
+ const inject = agent?.inject;
518
+ if (typeof inject === "function") {
519
+ inject.call(agent, message);
520
+ return;
521
+ }
522
+ const steer = agent?.steer;
523
+ if (typeof steer === "function") steer.call(agent, message);
524
+ }
525
+ /**
526
+ * 造一条「用户切换了模式」的尾部通知。
527
+ *
528
+ * 与官方的差别只有一处:进入时的通知**携带部署指引**(section)。官方把指引放在
529
+ * 动态 system 段里,那正是前缀重建的来源;改成一次性尾部消息后,指引只付一次
530
+ * 输入费,而且之后每一步都还在历史里(末尾追加不破坏前缀)。
531
+ *
532
+ * 触发条件也放宽了一点:官方只在「上一次请求头告诉过模型另一种模式」时才讲;
533
+ * 这里在进入方向还接受 told === undefined(用户在第一发请求之前就 /plan)。
534
+ * @param session - 会话。
535
+ * @param target - 目标状态。
536
+ * @returns 通知消息;不需要讲时 undefined。
537
+ */
538
+ narration(session, target) {
539
+ const told = this.loggedActiveAtLastHeader(session);
540
+ if (target) {
541
+ if (told === true) return void 0;
542
+ return createPlanNotice("The user switched this session to plan mode.", this.section);
543
+ }
544
+ if (told !== true) return void 0;
545
+ return createPlanNotice("The user switched this session back to the default mode.");
546
+ }
547
+ /**
548
+ * 订阅 agent/pre-step:先 await next(),只对已经落定的决策动手。
549
+ *
550
+ * 三件事:把未决选择落进日志;在需要时把通知**追加到消息末尾**(末尾追加不改变
551
+ * 已发前缀,这是这个 fork 的全部意义所在)。
552
+ */
553
+ subscribePreStep() {
554
+ const listener = async (payload, next) => {
555
+ const decision = await next();
556
+ const session = (payload?.agent)?.session;
557
+ const key = objectKey(session);
558
+ if (decision === void 0 || decision.kind === "reject" || key === void 0) return decision;
559
+ if (payload?.signal?.aborted === true) return decision;
560
+ const pending = this.pendingIntents.get(key);
561
+ if (pending === void 0) return decision;
562
+ const narration = this.narration(session, pending.active);
563
+ try {
564
+ this.onBoundary(session);
565
+ } catch (error) {
566
+ log(this.ctx, "warn", "[dsh-chatgpt-subscription] failed to append the selected plan mode at step start: " + (error instanceof Error ? error.message : String(error)));
567
+ return decision;
568
+ }
569
+ if (pending.narrate === false || narration === void 0) return decision;
570
+ const messages = Array.isArray(decision.messages) ? decision.messages : [];
571
+ return {
572
+ ...decision,
573
+ messages: [...messages, narration]
574
+ };
575
+ };
576
+ subscribe(this.ctx, "agent/pre-step", listener);
577
+ }
578
+ /**
579
+ * 注册 exit_plan_mode —— **不带 { when }**。
580
+ *
581
+ * 这是与官方唯一的工具面差别,也是 tools / tools:sdk 两边零重建的全部原因:
582
+ * 条件准入会让工具表在进入那一步变大,PTC 的 run_code 绑定与 native 声明同时重发。
583
+ * 无条件注册后,「只在计划模式可用」由 execute 里的 loggedActive 判定执行,报错
584
+ * 文案与官方一致。
585
+ */
586
+ registerExitTool() {
587
+ const tools = serviceOf(this.ctx, "tools");
588
+ if (tools === void 0 || typeof tools.register !== "function") {
589
+ log(this.ctx, "info", "[dsh-chatgpt-subscription] plan mode has no tools service: exit_plan_mode was not registered, so the plan can only be left through /plan off");
590
+ return;
591
+ }
592
+ try {
593
+ tools.register(this.exitDefinition());
594
+ } catch (error) {
595
+ log(this.ctx, "warn", "[dsh-chatgpt-subscription] plan mode could not register exit_plan_mode: " + (error instanceof Error ? error.message : String(error)));
596
+ }
597
+ }
598
+ /**
599
+ * 造 exit_plan_mode 的定义。
600
+ * @returns 工具定义。
601
+ */
602
+ exitDefinition() {
603
+ const ctx = this.ctx;
604
+ const controller = this;
605
+ return defineTool({
606
+ name: EXIT_PLAN_MODE,
607
+ description: "Use only in plan mode. Present your plan for the user's review and, on approval, leave plan mode. The user may approve (carry out the plan from your next step) or keep planning — their feedback comes back in the tool result; revise and present again.",
608
+ parameters: { plan: {
609
+ type: "string",
610
+ required: true,
611
+ description: "The complete plan, as markdown, starting with a # heading that names it."
612
+ } },
613
+ output: {
614
+ schema: {
615
+ type: "object",
616
+ additionalProperties: false,
617
+ properties: { approved: {
618
+ type: "boolean",
619
+ required: true
620
+ } }
621
+ },
622
+ render: (_args, value) => {
623
+ return [{
624
+ type: "text",
625
+ text: value?.approved === true ? "Plan approved — plan mode exited; carry out the plan starting with your next step." : "The user dismissed the plan review to reply in their own words; plan mode remains active."
626
+ }];
627
+ }
628
+ },
629
+ async execute(args, exec) {
630
+ const execution = exec;
631
+ const agent = execution.agent;
632
+ if (agent === void 0 || agent === null || !controller.loggedActive(agent.session)) throw new Error("exit_plan_mode is only available in plan mode");
633
+ const plan = typeof args.plan === "string" ? args.plan : "";
634
+ if (!/^#\s+\S/.test(plan.trim())) throw new Error("exit_plan_mode requires a non-empty markdown plan starting with a # heading");
635
+ const interaction = serviceOf(ctx, "userQuestions");
636
+ if (interaction === void 0 || typeof interaction.ask !== "function") throw new Error("no user-questions channel is available to review the plan; ask the user to switch the session mode instead");
637
+ let answer;
638
+ try {
639
+ answer = await interaction.ask({
640
+ questions: [{
641
+ id: REVIEW_ID,
642
+ header: "Plan review",
643
+ question: "Approve this plan and leave plan mode?",
644
+ detail: plan,
645
+ options: [{
646
+ label: APPROVE_LABEL,
647
+ description: "Leave plan mode; the plan is carried out from the next step."
648
+ }, {
649
+ label: KEEP_PLANNING_LABEL,
650
+ description: "Stay in plan mode; feedback goes back to the model."
651
+ }],
652
+ intent: {
653
+ kind: "plan-review",
654
+ approve: APPROVE_LABEL,
655
+ callId: execution.callId
656
+ }
657
+ }],
658
+ agent,
659
+ signal: execution.signal
660
+ });
661
+ } catch (cause) {
662
+ if (cause?.code === "ASK_CANCELLED") {
663
+ if (typeof execution.concludeTurn === "function") {
664
+ execution.concludeTurn();
665
+ return { approved: false };
666
+ }
667
+ throw new Error("the user dismissed the plan review and this harness generation cannot conclude the turn from a tool; present the plan again or switch the session mode instead");
668
+ }
669
+ throw cause;
670
+ }
671
+ if (controller.disposed) throw new Error("the plan-mode service was reloaded while the plan was under review; present the plan again");
672
+ const items = (answer?.answers ?? []).filter((entry) => entry.id === REVIEW_ID);
673
+ const item = items.length === 1 ? items[0] : void 0;
674
+ if (item?.selected.length !== 1 || item.selected[0] !== "Approve" || item.custom !== void 0) {
675
+ const feedback = typeof item?.custom === "string" ? item.custom : "";
676
+ throw new Error(feedback === "" ? "The user chose to keep planning; revise the plan and present it again." : "The user chose to keep planning; their feedback: " + feedback);
677
+ }
678
+ const key = objectKey(agent.session);
679
+ if (key !== void 0) controller.pendingIntents.set(key, {
680
+ active: false,
681
+ narrate: false
682
+ });
683
+ return { approved: true };
684
+ }
685
+ });
686
+ }
687
+ /**
688
+ * 注册 /plan 命令(手动进入/离开计划模式的入口)。
689
+ *
690
+ * 命令面板在部分宿主里不存在,所以这里用运行时注入:有 commands 才注册,
691
+ * 没有就安静跳过 —— 缺一个可选服务不该让计划模式整体挂不上。
692
+ */
693
+ registerPlanCommand() {
694
+ const ctx = this.ctx;
695
+ const inject = ctx.inject;
696
+ if (typeof inject !== "function") return;
697
+ try {
698
+ inject.call(ctx, ["commands"], (scope) => {
699
+ const commands = serviceOf(scope, "commands");
700
+ if (commands === void 0 || typeof commands.register !== "function") return;
701
+ try {
702
+ commands.register({
703
+ definitionId: name,
704
+ name: "plan",
705
+ description: "Enter or leave plan mode",
706
+ input: {
707
+ hint: "[off|message]",
708
+ attachments: true
709
+ },
710
+ handler: (payload) => {
711
+ const raw = typeof payload?.rawInput === "string" ? payload.rawInput.trim() : "";
712
+ const attachments = Array.isArray(payload?.attachments) ? payload.attachments : [];
713
+ if (raw === "off" && attachments.length > 0) return {
714
+ kind: "error",
715
+ text: "Attachments cannot accompany /plan off."
716
+ };
717
+ if (raw === "off") switch (this.set(payload.agent, false)) {
718
+ case "committed": return {
719
+ kind: "success",
720
+ text: "Plan mode off."
721
+ };
722
+ case "queued": return {
723
+ kind: "success",
724
+ text: "Leaving plan mode (applies from the next step)."
725
+ };
726
+ case "cancelled": return {
727
+ kind: "success",
728
+ text: "Plan mode entry cancelled."
729
+ };
730
+ case "noop": return this.loggedActive(payload.agent?.session) ? {
731
+ kind: "success",
732
+ text: "Leaving plan mode (applies from the next step)."
733
+ } : {
734
+ kind: "success",
735
+ text: "Plan mode is already inactive."
736
+ };
737
+ }
738
+ const outcome = this.set(payload.agent, true);
739
+ if (raw !== "" || attachments.length > 0) {
740
+ const steer = payload.agent?.steer;
741
+ if (typeof steer === "function") steer.call(payload.agent, createUserMessage({
742
+ content: [...attachments, ...raw === "" ? [] : [{
743
+ type: "text",
744
+ text: raw
745
+ }]],
746
+ source: { kind: "user" }
747
+ }));
748
+ }
749
+ return {
750
+ kind: "success",
751
+ text: outcome === "committed" ? "Plan mode on. Use /plan off to leave." : "Entering plan mode (applies from the next step). Use /plan off to leave."
752
+ };
753
+ }
754
+ });
755
+ } catch {}
756
+ });
757
+ } catch {}
758
+ }
759
+ };
760
+ /**
761
+ * 造一条计划模式尾部通知。
762
+ *
763
+ * 用 createUserMessage 而不是手写消息字面量:DSH 对每条被接纳的消息都校验 id 与
764
+ * source,手写字面量会在第一次 steer 时同步抛错(见 SKILL 的陷阱清单)。
765
+ * @param text - 通知正文;指引会接在它后面。
766
+ * @param section - 计划模式指引正文,接在通知之后。
767
+ * @returns 用户角色消息。
768
+ */
769
+ function createPlanNotice(text, section, summary) {
770
+ return createUserMessage({
771
+ content: [{
772
+ type: "text",
773
+ text: section === void 0 || section.trim() === "" ? text : text + "\n\n" + section
774
+ }],
775
+ source: {
776
+ kind: PLAN_NOTICE_SOURCE_KIND,
777
+ form: "notice",
778
+ summary: summary ?? text
779
+ }
780
+ });
781
+ }
782
+ //#endregion
783
+ export { APPROVE_LABEL, EXIT_PLAN_MODE, KEEP_PLANNING_LABEL, LEGACY_PLAN_STATE_VERSION, PLAN_MODE_EVENT, PLAN_NOTICE_SOURCE_KIND, PLAN_POLICY_SECTION, PLAN_PROJECTION_KEY, PLAN_STATE_VERSION, PlanModeController, PlanModeController as default, REVIEW_ID, createPlanNotice, createPlanProjectionDefinition, name, planProjectionDefinition, resolveConfig };