@deepseek-ai/dsh-plan-mode 0.1.1-rc.2 → 0.1.2-alpha.2

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/lib/index.js CHANGED
@@ -12,12 +12,11 @@ import { UserQuestionError } from "@deepseek-ai/dsh-user-questions";
12
12
  * policy enforce restrictions independently and do not read or write plan
13
13
  * state.
14
14
  *
15
- * The state in force is folded from the session log (`plan/mode`, last one
16
- * wins), so resume and fork restore it without a live mirror. User selections
17
- * remain pending until the next accepted in-turn pre-step. The service includes
18
- * the selected state in the proposed step assembly, then appends `plan/mode`
19
- * from `agent/pre-step` only when the step is accepted. Same-step request
20
- * retries reuse their assembly.
15
+ * The `plan` projection folds the session log, so resume and fork restore the
16
+ * state. User selections remain pending until the next accepted in-turn
17
+ * pre-step. The service includes the selected state in the proposed step
18
+ * assembly, then appends `plan/mode` from `agent/pre-step` only when the step
19
+ * is accepted. Same-step request retries reuse their assembly.
21
20
  *
22
21
  * The exit tool remains registered while plan mode is inactive, so entering
23
22
  * or leaving plan mode changes only the prompt section, not the request tool
@@ -62,62 +61,81 @@ function resolveConfig(config) {
62
61
  if (unknown.length > 0) throw new Error(`PlanModeConfig has unknown key(s) ${unknown.join(", ")} — config is { section }`);
63
62
  return { section };
64
63
  }
65
- /**
66
- * Whether plan mode is active after the first `end` events. The last
67
- * `plan/mode` wins; a prefix with none is inactive.
68
- *
69
- * @param events The session log or any prefix of it.
70
- * @param end Fold `events[0, end)`; defaults to the whole log.
71
- * @returns Whether plan mode is active.
72
- */
73
- function foldPlanMode(events, end = events.length) {
74
- let active = false;
75
- let index = 0;
76
- for (const event of events) {
77
- if (index >= end) break;
78
- index++;
79
- if (event.type === "plan/mode") active = event.data.active;
80
- }
81
- return active;
82
- }
83
- const planUnitStateSchema = z.object({
84
- active: z.boolean(),
85
- wanted: z.boolean().nullable(),
86
- running: z.object({
87
- commandId: z.string(),
88
- wanted: z.boolean()
89
- }).strict().nullable()
90
- }).strict();
91
- /** Wire payload schema of the `plan` projection. */
92
- const planProjectionSchema = z.object({
93
- active: z.boolean(),
94
- pending: z.boolean()
95
- });
96
- /** Whether the log holds an opened turn without its closing `turn/end`. */
97
- function hasOpenTurn(events) {
98
- let open = false;
99
- for (const event of events) if (event.type === "turn/start") open = true;
100
- else if (event.type === "turn/end") open = false;
101
- return open;
102
- }
103
- /** Plan state at the last logged request header, or `undefined` before the first header. */
104
- function planModeAtLastHeader(events) {
105
- let lastHeader = -1;
106
- let index = 0;
107
- for (const event of events) {
108
- if (event.type === "request/header") lastHeader = index;
109
- index++;
64
+ /** Projection of logged plan selections and committed mode. */
65
+ const planProjectionDefinition = {
66
+ key: "plan",
67
+ stateVersion: 3,
68
+ stateSchema: z.object({
69
+ active: z.boolean(),
70
+ wanted: z.boolean().nullable(),
71
+ running: z.object({
72
+ commandId: z.string(),
73
+ wanted: z.boolean()
74
+ }).strict().nullable(),
75
+ activeAtLastHeader: z.boolean().nullable()
76
+ }).strict(),
77
+ init: () => ({
78
+ active: false,
79
+ wanted: null,
80
+ running: null,
81
+ activeAtLastHeader: null
82
+ }),
83
+ apply: (state, event) => {
84
+ if (event.type === "command/run" && event.data.name === "plan") {
85
+ if (event.data.args === void 0) return state;
86
+ const wanted = event.data.args.trim() !== "off";
87
+ return {
88
+ ...state,
89
+ running: {
90
+ commandId: event.data.commandId,
91
+ wanted
92
+ }
93
+ };
94
+ }
95
+ if (event.type === "command/done" && event.data.commandId === state.running?.commandId) {
96
+ const wanted = event.data.kind === "success" && state.running.wanted !== state.active ? state.running.wanted : null;
97
+ return {
98
+ ...state,
99
+ wanted,
100
+ running: null
101
+ };
102
+ }
103
+ if (event.type === "plan/mode") return {
104
+ ...state,
105
+ active: event.data.active,
106
+ wanted: null
107
+ };
108
+ if (event.type === "request/header") return {
109
+ ...state,
110
+ activeAtLastHeader: state.active
111
+ };
112
+ return state;
113
+ },
114
+ wire: {
115
+ viewSchema: z.object({
116
+ active: z.boolean(),
117
+ pending: z.boolean()
118
+ }),
119
+ view: (state) => {
120
+ const wanted = state.running?.wanted ?? state.wanted;
121
+ return {
122
+ active: state.active,
123
+ pending: wanted !== null && wanted !== state.active
124
+ };
125
+ }
110
126
  }
111
- if (lastHeader < 0) return void 0;
112
- return foldPlanMode(events, lastHeader + 1);
113
- }
127
+ };
114
128
  /**
115
129
  * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start,
116
130
  * the `plan:policy` section, the `/plan` command, and the stable exit tool.
117
- * UIs observe committed flips through `session/event`; there is no live mirror.
131
+ * Client carriers expose the projection's cropped `{ active, pending }` view.
118
132
  */
119
133
  var PlanModeController = class extends Service {
120
- static inject = ["tools", "systemPrompt"];
134
+ static inject = [
135
+ "tools",
136
+ "systemPrompt",
137
+ "sessionProjections"
138
+ ];
121
139
  /** Validated deployment-owned guidance. */
122
140
  section;
123
141
  /**
@@ -151,61 +169,13 @@ var PlanModeController = class extends Service {
151
169
  }, "dsh-plan-mode: close service lifetime");
152
170
  ctx.systemPrompt.section({
153
171
  name: "plan:policy",
154
- order: 50,
172
+ order: ctx.systemPrompt.getSectionOrder("PLAN_POLICY"),
155
173
  text: (context) => {
156
174
  if (context.agent === void 0) return "";
157
- return this.pendingIntents.get(context.agent.session)?.active ?? foldPlanMode(context.agent.session.events) ? this.section : "";
175
+ return this.pendingIntents.get(context.agent.session)?.active ?? this.loggedActive(context.agent.session) ? this.section : "";
158
176
  }
159
177
  });
160
- ctx.inject(["sessionProjections"], (projectionCtx) => {
161
- projectionCtx.sessionProjections.register({
162
- key: "plan",
163
- stateSchema: planUnitStateSchema,
164
- init: () => ({
165
- active: false,
166
- wanted: null,
167
- running: null
168
- }),
169
- apply: (state, event) => {
170
- if (event.type === "command/run" && event.data.name === "plan") {
171
- if (event.data.args === void 0) return state;
172
- const wanted = event.data.args.trim() !== "off";
173
- return {
174
- ...state,
175
- running: {
176
- commandId: event.data.commandId,
177
- wanted
178
- }
179
- };
180
- }
181
- if (event.type === "command/done" && event.data.commandId === state.running?.commandId) {
182
- const wanted = event.data.kind === "success" && state.running.wanted !== state.active ? state.running.wanted : null;
183
- return {
184
- ...state,
185
- wanted,
186
- running: null
187
- };
188
- }
189
- if (event.type === "plan/mode") return {
190
- ...state,
191
- active: event.data.active,
192
- wanted: null
193
- };
194
- return state;
195
- },
196
- wire: {
197
- viewSchema: planProjectionSchema,
198
- view: (state) => {
199
- const wanted = state.running?.wanted ?? state.wanted;
200
- return {
201
- active: state.active,
202
- pending: wanted !== null && wanted !== state.active
203
- };
204
- }
205
- },
206
- stateVersion: 2
207
- });
208
- });
178
+ ctx.sessionProjections.register(planProjectionDefinition);
209
179
  ctx.inject(["commands"], (commandCtx) => {
210
180
  commandCtx.commands.register({
211
181
  name: "plan",
@@ -233,7 +203,7 @@ var PlanModeController = class extends Service {
233
203
  kind: "success",
234
204
  text: "Plan mode entry cancelled."
235
205
  };
236
- case "noop": return foldPlanMode(agent.session.events) ? {
206
+ case "noop": return this.loggedActive(agent.session) ? {
237
207
  kind: "success",
238
208
  text: "Leaving plan mode (applies from the next step)."
239
209
  } : {
@@ -282,7 +252,7 @@ var PlanModeController = class extends Service {
282
252
  execute: async (args, exec) => {
283
253
  const agent = exec.agent;
284
254
  if (agent === void 0) throw new Error(`${EXIT_PLAN_MODE} requires a calling agent (no session to switch)`);
285
- if (!foldPlanMode(agent.session.events)) throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`);
255
+ if (!this.loggedActive(agent.session)) throw new Error(`${EXIT_PLAN_MODE} is only available in plan mode`);
286
256
  if (!/^#\s+\S/.test(args.plan.trim())) throw new Error(`${EXIT_PLAN_MODE} requires a non-empty markdown plan starting with a # heading`);
287
257
  const interaction = ctx.get("userQuestions");
288
258
  if (interaction === void 0) throw new Error("no user-questions channel is available to review the plan; ask the user to switch the session mode instead");
@@ -339,6 +309,23 @@ var PlanModeController = class extends Service {
339
309
  })
340
310
  }));
341
311
  }
312
+ loggedActive(session) {
313
+ return this.planState(session).active;
314
+ }
315
+ hasOpenTurn(session) {
316
+ const state = this.ctx.sessionProjections.stateOf(session, "turnBoundary");
317
+ if (state === void 0) throw new Error("plan-mode requires the turnBoundary session projection");
318
+ return state.openTurnStartSeq !== null;
319
+ }
320
+ loggedActiveAtLastHeader(session) {
321
+ return this.planState(session).activeAtLastHeader ?? void 0;
322
+ }
323
+ /** Read the required plan projection state or fail at the first service access. */
324
+ planState(session) {
325
+ const state = this.ctx.sessionProjections.stateOf(session, "plan");
326
+ if (state === void 0) throw new Error("plan-mode requires the plan session projection");
327
+ return state;
328
+ }
342
329
  /**
343
330
  * Read the logged plan state and any selected state awaiting the next
344
331
  * accepted in-turn pre-step.
@@ -347,7 +334,7 @@ var PlanModeController = class extends Service {
347
334
  * @returns Current logged state plus a pending selection, when present.
348
335
  */
349
336
  get(agent) {
350
- const active = foldPlanMode(agent.session.events);
337
+ const active = this.loggedActive(agent.session);
351
338
  const pending = this.pendingIntents.get(agent.session);
352
339
  return pending === void 0 ? { active } : {
353
340
  active,
@@ -372,15 +359,15 @@ var PlanModeController = class extends Service {
372
359
  */
373
360
  set(agent, active) {
374
361
  const session = agent.session;
375
- if (active === (this.pendingIntents.get(session)?.active ?? foldPlanMode(session.events))) return "noop";
376
- if (hasOpenTurn(session.events)) {
362
+ if (active === (this.pendingIntents.get(session)?.active ?? this.loggedActive(session))) return "noop";
363
+ if (this.hasOpenTurn(session)) {
377
364
  this.pendingIntents.set(session, {
378
365
  active,
379
366
  narrate: true
380
367
  });
381
- return foldPlanMode(session.events) === active ? "cancelled" : "queued";
368
+ return this.loggedActive(session) === active ? "cancelled" : "queued";
382
369
  }
383
- if (active === foldPlanMode(session.events)) {
370
+ if (active === this.loggedActive(session)) {
384
371
  this.pendingIntents.delete(session);
385
372
  return "cancelled";
386
373
  }
@@ -395,7 +382,7 @@ var PlanModeController = class extends Service {
395
382
  const pending = this.pendingIntents.get(session);
396
383
  if (pending === void 0) return;
397
384
  const target = pending.active;
398
- if (target === foldPlanMode(session.events)) {
385
+ if (target === this.loggedActive(session)) {
399
386
  this.pendingIntents.delete(session);
400
387
  return;
401
388
  }
@@ -404,7 +391,7 @@ var PlanModeController = class extends Service {
404
391
  }
405
392
  /** Build a user-switch notice when the last logged header described the other mode. */
406
393
  narration(session, target) {
407
- const told = planModeAtLastHeader(session.events);
394
+ const told = this.loggedActiveAtLastHeader(session);
408
395
  if (told === void 0 || told === target) return;
409
396
  const text = target ? "The user switched this session to plan mode." : "The user switched this session back to the default mode.";
410
397
  return createUserMessage({
@@ -422,4 +409,4 @@ var PlanModeController = class extends Service {
422
409
  }
423
410
  };
424
411
  //#endregion
425
- export { EXIT_PLAN_MODE, PlanModeController, PlanModeController as default, foldPlanMode, resolveConfig };
412
+ export { EXIT_PLAN_MODE, PlanModeController, PlanModeController as default, planProjectionDefinition, resolveConfig };
@@ -6,12 +6,11 @@
6
6
  * policy enforce restrictions independently and do not read or write plan
7
7
  * state.
8
8
  *
9
- * The state in force is folded from the session log (`plan/mode`, last one
10
- * wins), so resume and fork restore it without a live mirror. User selections
11
- * remain pending until the next accepted in-turn pre-step. The service includes
12
- * the selected state in the proposed step assembly, then appends `plan/mode`
13
- * from `agent/pre-step` only when the step is accepted. Same-step request
14
- * retries reuse their assembly.
9
+ * The `plan` projection folds the session log, so resume and fork restore the
10
+ * state. User selections remain pending until the next accepted in-turn
11
+ * pre-step. The service includes the selected state in the proposed step
12
+ * assembly, then appends `plan/mode` from `agent/pre-step` only when the step
13
+ * is accepted. Same-step request retries reuse their assembly.
15
14
  *
16
15
  * The exit tool remains registered while plan mode is inactive, so entering
17
16
  * or leaving plan mode changes only the prompt section, not the request tool
@@ -23,16 +22,16 @@
23
22
  * @module @deepseek-ai/dsh-plan-mode
24
23
  */
25
24
  import { Context, Service } from '@deepseek-ai/cordis';
25
+ import { z as zod } from 'zod';
26
26
  import type { Agent } from '@deepseek-ai/dsh-agent';
27
- import type { SessionEvent } from '@deepseek-ai/dsh-session';
28
- import type { CommandId } from '@deepseek-ai/dsh-commands';
27
+ import type { PlanProjection, PlanUnitState } from './types.ts';
29
28
  export type * from './types.ts';
30
29
  declare module '@deepseek-ai/dsh-session/types' {
31
30
  interface SessionEventMap {
32
31
  /**
33
32
  * Whether plan mode is in force from this point on: log-only, non-surface,
34
33
  * whole-value replace. The last `plan/mode` wins; a log with none folds to
35
- * inactive through {@link foldPlanMode}.
34
+ * inactive through the projection unit's fold.
36
35
  */
37
36
  'plan/mode': {
38
37
  active: boolean;
@@ -62,40 +61,30 @@ export interface PlanModeConfig {
62
61
  * @returns A detached validated config.
63
62
  */
64
63
  export declare function resolveConfig(config: PlanModeConfig): PlanModeConfig;
65
- /**
66
- * Whether plan mode is active after the first `end` events. The last
67
- * `plan/mode` wins; a prefix with none is inactive.
68
- *
69
- * @param events The session log or any prefix of it.
70
- * @param end Fold `events[0, end)`; defaults to the whole log.
71
- * @returns Whether plan mode is active.
72
- */
73
- export declare function foldPlanMode(events: readonly SessionEvent[], end?: number): boolean;
74
- /**
75
- * Projection unit state: the logged mode, the latest successful `/plan`
76
- * selection not yet resolved by a `plan/mode` commit, and an execution whose
77
- * paired `command/done` has not settled. Plain JSON (persisted-cache
78
- * precondition).
79
- */
80
- interface PlanUnitState {
81
- active: boolean;
82
- /** The selection's target mode; null when no selection is outstanding. */
83
- wanted: boolean | null;
84
- /** The latest plan command awaiting its paired settlement. */
85
- running: {
86
- commandId: CommandId;
87
- wanted: boolean;
88
- } | null;
89
- }
90
- declare module '@deepseek-ai/dsh-session-projection/types' {
91
- interface SessionProjectionStateMap {
92
- plan: PlanUnitState;
93
- }
94
- }
64
+ /** Projection of logged plan selections and committed mode. */
65
+ export declare const planProjectionDefinition: {
66
+ key: "plan";
67
+ stateVersion: number;
68
+ stateSchema: zod.ZodType<PlanUnitState, unknown, zod.core.$ZodTypeInternals<PlanUnitState, unknown>>;
69
+ init: () => {
70
+ active: false;
71
+ wanted: null;
72
+ running: null;
73
+ activeAtLastHeader: null;
74
+ };
75
+ apply: (state: NoInfer<PlanUnitState>, event: import("@deepseek-ai/dsh-session").SessionEvent) => PlanUnitState;
76
+ wire: {
77
+ viewSchema: zod.ZodType<PlanProjection, unknown, zod.core.$ZodTypeInternals<PlanProjection, unknown>>;
78
+ view: (state: NoInfer<PlanUnitState>) => {
79
+ active: boolean;
80
+ pending: boolean;
81
+ };
82
+ };
83
+ };
95
84
  /**
96
85
  * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start,
97
86
  * the `plan:policy` section, the `/plan` command, and the stable exit tool.
98
- * UIs observe committed flips through `session/event`; there is no live mirror.
87
+ * Client carriers expose the projection's cropped `{ active, pending }` view.
99
88
  */
100
89
  export declare class PlanModeController extends Service {
101
90
  static inject: string[];
@@ -108,6 +97,11 @@ export declare class PlanModeController extends Service {
108
97
  */
109
98
  private readonly pendingIntents;
110
99
  constructor(ctx: Context, config?: PlanModeConfig);
100
+ private loggedActive;
101
+ private hasOpenTurn;
102
+ private loggedActiveAtLastHeader;
103
+ /** Read the required plan projection state or fail at the first service access. */
104
+ private planState;
111
105
  /**
112
106
  * Read the logged plan state and any selected state awaiting the next
113
107
  * accepted in-turn pre-step.