dsh-fast-mode 1.6.2 → 1.7.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/index.js CHANGED
@@ -82,10 +82,22 @@ import { classifyEntry } from './src/shape.js'
82
82
  import { detectPeerPlugins, opsToolsLive } from './src/peers.js'
83
83
  import { createStallGuard, stallBudgetFor } from './src/stallguard.js'
84
84
  import { createRecorder } from './src/metrics.js'
85
- import { readOutputTokens } from './src/usage.js'
85
+ import { readOutputTokens, readReasoningChars } from './src/usage.js'
86
86
  import { configOverridesFor } from './src/state.js'
87
87
  import { buildShellParallelTool } from './src/parallel.js'
88
- import { beginStep as lazyBeginStep, createLazyWatch, DIRECTIVE_SUMMARY as LAZY_SUMMARY, NUDGE_SUMMARY as LAZY_NUDGE_SUMMARY, noteReasoning as lazyNoteReasoning, noteToolResult as lazyNoteToolResult, planEffort as lazyPlanEffort } from './src/lazy.js'
88
+ import {
89
+ ACT_SUMMARY,
90
+ LONG_REASONING_SUMMARY,
91
+ PROBE_SUMMARY,
92
+ REPEAT_SUMMARY,
93
+ beginTurn as beginDeliberationTurn,
94
+ createProgressWatch,
95
+ deliberationNotice,
96
+ noteCall as noteDeliberationCall,
97
+ noteReasoning as noteTurnReasoning,
98
+ planReasoningNudge,
99
+ planRepeatNudge,
100
+ } from './src/deliberation.js'
89
101
 
90
102
  /**
91
103
  * The settings-page schema. Re-exported from here because the Loader reads
@@ -99,7 +111,7 @@ const TAG = '[fast-mode]'
99
111
  * Shipped identity of this build. Traced into the debug log so a stale install is
100
112
  * visible at a glance, and pinned by a test against `package.json`.
101
113
  */
102
- export const VERSION = '1.6.2'
114
+ export const VERSION = '1.7.2'
103
115
  const GLOBAL_AGENT = '__global__'
104
116
 
105
117
  /**
@@ -366,6 +378,10 @@ export function apply(ctx, rawConfig) {
366
378
  try {
367
379
  const out = readOutputTokens(event.data ?? event)
368
380
  if (out > 0) noteOutputTokens(auto, agentKey(session), out)
381
+ // Same event, second reading: how much reasoning this turn has written
382
+ // so far. Observed from the transcript; never written into a request.
383
+ const chars = readReasoningChars(event.data ?? event)
384
+ if (chars > 0) noteTurnReasoning(progressWatch, agentKey(session), chars)
369
385
  } catch {
370
386
  // A counter that can fail a turn is worse than no counter.
371
387
  }
@@ -429,11 +445,11 @@ export function apply(ctx, rawConfig) {
429
445
  // The scheduler's memory, one entry per conversation: what has been tried, how
430
446
  // long calls have been taking, and how many steps went by without a new result.
431
447
  const auto = createAutoState()
432
- const lazyWatch = createLazyWatch()
448
+ const progressWatch = createProgressWatch()
433
449
  // Which human turn each session is in. `agent/pre-step` carries no turn number,
434
450
  // so the count of claimed (non-plugin) user messages is the boundary: it moves
435
451
  // exactly when the user speaks again.
436
- const lazyTurnKey = new Map()
452
+ const deliberationTurnKey = new Map()
437
453
  /**
438
454
  * Live stall ceilings per conversation, as computed by the scheduler.
439
455
  * Bounded like `compacted`: a long-lived host process sees thousands of
@@ -525,19 +541,6 @@ export function apply(ctx, rawConfig) {
525
541
  if (cut <= 0) return
526
542
  const sid = frame.attemptId.slice(0, cut)
527
543
  try {
528
- // Lazy mode reads the same stream, and must keep reading when the journal
529
- // is off: the effort step-down is decided from how much the route actually
530
- // streamed, not from a usage field this route reports as zero.
531
- if (lazyWatch.runs.has(sid)) {
532
- const chunk = frame.chunk
533
- const kind = chunk && typeof chunk === 'object' ? chunk.type : undefined
534
- if (kind === 'reasoning-delta') {
535
- const text = typeof chunk.text === 'string'
536
- ? chunk.text
537
- : typeof chunk.delta === 'string' ? chunk.delta : ''
538
- lazyNoteReasoning(lazyWatch, sid, text.length)
539
- }
540
- }
541
544
  if (!metrics.enabled) return
542
545
  metrics.record({ id: sid }, {
543
546
  type: 'assistant/chunk',
@@ -620,23 +623,37 @@ export function apply(ctx, rawConfig) {
620
623
  }
621
624
  }
622
625
  }
623
- if (cfg.lazy === true) {
624
- // Lazy mode's two prompt-side mechanisms: the ladder text on the first
625
- // step of a turn, and at most two nudges when a turn's streamed reasoning
626
- // passes its threshold. The tail of a long turn is where over-thinking
627
- // costs the most and where a reminder changes the next step rather than
628
- // the one already paid for.
626
+ if (cfg.probeFirst === true || cfg.actBeforePlan === true || cfg.repeatNudge === true) {
627
+ // Three prompt-side mechanisms, all opt-in and all visible in the
628
+ // transcript: one notice at turn entry (probe-first / act-first), and an
629
+ // evidence nudge when a turn repeats a call or stops learning anything.
630
+ // Nothing here touches a request field: see `src/deliberation.js`.
629
631
  const key = `${sid}\u0000${claimedUserCount(payload?.messages)}`
630
- const fresh = lazyTurnKey.get(sid) !== key
632
+ const fresh = deliberationTurnKey.get(sid) !== key
631
633
  if (fresh) {
632
- lazyTurnKey.set(sid, key)
633
- trim(lazyTurnKey, 200)
634
+ deliberationTurnKey.set(sid, key)
635
+ trim(deliberationTurnKey, 200)
636
+ }
637
+ const turn = beginDeliberationTurn(progressWatch, sid, key)
638
+ let directive
639
+ if (fresh && (cfg.probeFirst === true || cfg.actBeforePlan === true)) {
640
+ directive = deliberationNotice({ probe: cfg.probeFirst === true, act: cfg.actBeforePlan === true })
641
+ if (directive) {
642
+ notices.push(createNoticeMessage(directive, cfg.probeFirst === true ? PROBE_SUMMARY : ACT_SUMMARY))
643
+ }
634
644
  }
635
- const plan = lazyBeginStep(lazyWatch, sid, { firstStep: fresh, variant: cfg.lazyVariant })
636
- if (plan && plan.directive) notices.push(createNoticeMessage(plan.directive, LAZY_SUMMARY))
637
- if (plan && plan.nudge) notices.push(createNoticeMessage(plan.nudge, LAZY_NUDGE_SUMMARY))
638
- if (plan && initial.debug) {
639
- appendDebug(initial.debugFile, `lazy v${VERSION}: turn=${key.split('\u0000').pop()} directive=${plan.directive ? 'yes' : 'no'} nudge=${plan.nudge ? 'yes' : 'no'}`)
645
+ let nudge
646
+ if (cfg.repeatNudge === true) nudge = planRepeatNudge(progressWatch, sid, payload?.step)
647
+ if (nudge) notices.push(createNoticeMessage(nudge, REPEAT_SUMMARY))
648
+ let lengthNudge
649
+ if (cfg.actBeforePlan === true) lengthNudge = planReasoningNudge(progressWatch, sid, payload?.step)
650
+ if (lengthNudge) notices.push(createNoticeMessage(lengthNudge, LONG_REASONING_SUMMARY))
651
+ if (initial.debug) {
652
+ appendDebug(initial.debugFile, `deliberation v${VERSION}: turn=${key.split('\u0000').pop()}`
653
+ + ` probe=${cfg.probeFirst === true ? 'on' : 'off'} act=${cfg.actBeforePlan === true ? 'on' : 'off'}`
654
+ + ` directive=${directive ? 'yes' : 'no'} nudge=${nudge ? 'yes' : 'no'}`
655
+ + ` reasoning=${turn.reasonChars} lengthNudge=${lengthNudge ? 'yes' : 'no'}`
656
+ + ` calls=${turn.calls.length} stale=${turn.stale}`)
640
657
  }
641
658
  }
642
659
  if (cfg.workspaceMap === true) {
@@ -785,9 +802,14 @@ export function apply(ctx, rawConfig) {
785
802
  // The scheduler reads the same observation: a call that returns the same
786
803
  // bytes for the same arguments is the raw material of the long tail.
787
804
  noteTool(auto, agent, observation)
788
- // An error result is the one signal that the model may need more room to
789
- // think, so it cancels any pending effort step-down for a couple of steps.
790
- lazyNoteToolResult(lazyWatch, agent, observation)
805
+ // The evidence nudge reads the same observation: same call again, or a
806
+ // run of results that carried nothing new, is what "over-thinking" is
807
+ // defined as here — not the length of the reasoning stream.
808
+ noteDeliberationCall(progressWatch, agent, {
809
+ signature: `${observation.name}\u0000${observation.argsKey}`,
810
+ digest: observation.digest,
811
+ isError: observation.isError,
812
+ })
791
813
  }
792
814
  } catch {
793
815
  // Observation is best-effort. A failure here must not affect the tool
@@ -806,12 +828,12 @@ export function apply(ctx, rawConfig) {
806
828
  const baseEffort = resolved?.reasoningEffort
807
829
  const route = routeOf(resolved)
808
830
 
809
- // The rule this plugin kept for five releases was "never touch the thinking
810
- // budget", and it is still the rule unless the user turns on the one switch
811
- // that says otherwise. See `src/lazy.js`: it lowers effort only after a step
812
- // visibly over-thought, never below the user's own floor, twice a turn at
813
- // most, and it restores the resolved value on the very next request — the
814
- // 0.9.x damage was that a rewrite outlived the turn that justified it.
831
+ // Released in 1.7.0: this hook no longer rewrites any request field. The
832
+ // 1.6.x effort step-down was measurable neither in wall clock (LZ1: 1.106x
833
+ // against the same text without it, CI crossing 1) nor in usage (the route
834
+ // reports `reasoning` as 0), and it was the one mechanism that could make the
835
+ // request differ from what the composer showed. Every mechanism kept in 1.7.0
836
+ // is prompt-side.
815
837
  if (!cfg.enabled) {
816
838
  metrics.label(sid, { mode: 'off', gear: 'off', effort: summaryEffort(baseEffort), effortBase: null, route })
817
839
  return resolved
@@ -819,20 +841,14 @@ export function apply(ctx, rawConfig) {
819
841
 
820
842
  const schedule = cfg.gear === AUTO ? autoSchedule(auto, sid, { emit: false }) : undefined
821
843
  checkParallelCap(cfg, schedule?.parallel)
822
- const planned = cfg.lazy === true && cfg.lazyEffort === true
823
- ? lazyPlanEffort(lazyWatch, sid, baseEffort)
824
- : undefined
825
- const applied = planned && planned.reduced ? planned.effort : baseEffort
826
- // The journal keeps both halves in one call: `effort` is what the request
827
- // carried, `effortBase` is what the user chose, so a comparison can tell a
828
- // lazy step-down from a session that was simply started at a lower rung.
829
- // `null`, not omitted, on a step that did not step down — otherwise the
830
- // previous reduction would leak into every following row.
844
+ // `effort` is recorded, `effortBase` stays null: nothing in this release
845
+ // changes it, and a reader comparing the two must be able to tell that the
846
+ // step-down is gone rather than merely not firing.
831
847
  metrics.label(sid, {
832
848
  mode: 'on',
833
849
  gear: cfg.gear,
834
- effort: summaryEffort(applied),
835
- effortBase: planned && planned.reduced ? summaryEffort(baseEffort) : null,
850
+ effort: summaryEffort(baseEffort),
851
+ effortBase: null,
836
852
  route,
837
853
  })
838
854
 
@@ -840,7 +856,7 @@ export function apply(ctx, rawConfig) {
840
856
  appendDebug(
841
857
  cfg.debugFile,
842
858
  `turn=${payload?.turn} step=${payload?.step} gear=${cfg.gear} effort=${String(summaryEffort(baseEffort))}`
843
- + (planned && planned.reduced ? `->${String(applied)} (lazy step-down)` : ' (unchanged)')
859
+ + ' (unchanged)'
844
860
  + ` route=${route}`
845
861
  + (schedule
846
862
  ? ` parallel=${schedule.parallel} budget=${schedule.stallBudgetMs}ms`
@@ -850,18 +866,20 @@ export function apply(ctx, rawConfig) {
850
866
  )
851
867
  }
852
868
 
853
- return planned && planned.reduced ? { ...resolved, reasoningEffort: applied } : resolved
869
+ return resolved
854
870
  })
855
871
 
856
872
  ctx.logger?.info?.(
857
- '%s v%s loaded: enabled=%s gear=%s parallel=%d adaptive=%s reasoningEffort=%s metrics=%s',
873
+ '%s v%s loaded: enabled=%s gear=%s parallel=%d adaptive=%s switches(probe/act/repeat)=%s metrics=%s',
858
874
  TAG,
859
875
  VERSION,
860
876
  initial.enabled,
861
877
  initial.gear,
862
878
  initial.parallel,
863
879
  initial.adaptive === true,
864
- initial.lazy === true ? (initial.lazyEffort === false ? 'lazy-text-only' : 'lazy') : 'untouched',
880
+ [initial.probeFirst, initial.actBeforePlan, initial.repeatNudge]
881
+ .map((on) => (on === true ? 'on' : 'off'))
882
+ .join('/'),
865
883
  metrics.enabled ? metrics.file : 'off',
866
884
  )
867
885
  if (initial.legacyGear) {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-fast-mode",
3
- "version": "1.6.2",
4
- "description": "DeepSeek Harness fast mode: fewer round trips at the same thinking effort. Adaptive parallel tool fan-out, output discipline that never trims the answer, a cancellable stall guard, and one off-by-default switch (lazy) that steps a stalled turn one rung down. By default it never changes reasoning effort, the model, or the request body. Provider-agnostic, one dependency (@deepseek-ai/schemastery).",
3
+ "version": "1.7.2",
4
+ "description": "DeepSeek Harness fast mode: fewer round trips at the same thinking effort. Adaptive parallel tool fan-out, output discipline that never trims the answer, a cancellable stall guard, and three off-by-default switches that trim wasted deliberation (read the fact you are unsure of, act after one line of plan, stop when the evidence stops arriving). It never changes reasoning effort, the model, or the request body. Provider-agnostic, one dependency (@deepseek-ai/schemastery).",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/regrevia/dsh-fast-mode.git"
package/src/config.js CHANGED
@@ -40,9 +40,10 @@
40
40
  *
41
41
  * `enabled` defaults to **false** on purpose: opt-in is what makes "off means
42
42
  * completely off" verifiable. The old `floor` / `ladder` / `aliases` knobs are
43
- * gone, along with the code that read them; the one field that can still move
44
- * effort is `lazy`, and it is off by default because it can change the answer,
45
- * not only the timing.
43
+ * gone, along with the code that read them, and so is the 1.6.x effort
44
+ * step-down: no field in this schema changes a request field any more. The three
45
+ * deliberation switches (`probeFirst`, `actBeforePlan`, `repeatNudge`) only add
46
+ * prompt text, and all three are off by default.
46
47
  *
47
48
  * @module dsh-fast-mode/config
48
49
  */
@@ -97,7 +98,7 @@ export const Config = z.object({
97
98
  .default('late')
98
99
  .volatile()
99
100
  .description(
100
- '输出纪律 / Output discipline(面板「少写过程」,也是没有自己条目的会话的默认值): 让模型少写过程、只在结论处落笔。`late`(默认)不往系统提示里放文风规则,第 4 步之后由调度器提醒一次——短任务零成本,长任务实测 6 对:墙钟中位 2.68×、步数 10.5→7.5、输出 token −52%、正确性 12/12 不变。`true` 全程生效;`false` 关闭。',
101
+ '输出纪律 / Output discipline(面板「精简输出」,也是没有自己条目的会话的默认值): 让模型少写过程、只在结论处落笔。`late`(默认)不往系统提示里放文风规则,第 4 步之后由调度器提醒一次——短任务零成本,长任务实测 6 对:墙钟中位 2.68×、步数 10.5→7.5、输出 token −52%、正确性 12/12 不变。`true` 全程生效;`false` 关闭。',
101
102
  ),
102
103
 
103
104
  shellParallel: z
@@ -115,7 +116,7 @@ export const Config = z.object({
115
116
  .default(0)
116
117
  .volatile()
117
118
  .description(
118
- '单轮步数预算 / Steps per turn(面板「最多几步」,0 显示为「不限」): 用户自设的硬上限,到达后调度器提醒一次按现状汇报;0 = 不限(默认)。步数只说明成本,不说明任务是否完成——默认不再按步数催停,避免把仍在推进的长任务截断。需要成本上限时自己设。',
119
+ '单轮步数预算 / Steps per turn(面板「步数上限」,0 显示为「不限」): 用户自设的硬上限,到达后调度器提醒一次按现状汇报;0 = 不限(默认)。步数只说明成本,不说明任务是否完成——默认不再按步数催停,避免把仍在推进的长任务截断。需要成本上限时自己设。',
119
120
  ),
120
121
 
121
122
  finishLineSteps: z
@@ -158,29 +159,31 @@ export const Config = z.object({
158
159
  .boolean()
159
160
  .default(false)
160
161
  .description(
161
- '开局批量 / Opening batch(面板名「一次读多个文件」): 每轮第一步提醒一次「把已知要做的独立调用放进同一块、shell 命令用 && 串起来」。短任务的钱几乎全花在每步固定成本(首字延迟 ~0.5 s/次)上,少一次往返就省一次;只发一次,不改后续调度。面板可逐会话开;和 `workspaceMap` 一起开时实测 0.742×(11 对),单独开测不到显著,所以默认仍是 false。',
162
+ '开局批量 / Opening batch(面板名「批量读文件」): 每轮第一步提醒一次「把已知要做的独立调用放进同一块、shell 命令用 && 串起来」。短任务的钱几乎全花在每步固定成本(首字延迟 ~0.5 s/次)上,少一次往返就省一次;只发一次,不改后续调度。面板可逐会话开;和 `workspaceMap` 一起开时实测 0.742×(11 对),单独开测不到显著,所以默认仍是 false。',
162
163
  ),
163
164
 
164
- lazy: z
165
+ probeFirst: z
165
166
  .boolean()
166
167
  .default(false)
167
168
  .volatile()
168
169
  .description(
169
- '过度思考时降档 / Cap over-thinking(面板「过度思考时降档」): 每轮第一步给一次「先找最省的一级、不扩范围、不砍信任边界校验/数据丢失处理/安全/可访问性」的提示;当上一步流出的思考字符 ≥ 1600、或本轮累计超过 4000/8000 时,把下一个请求的思考强度降一档(最低 low、从不升档、每轮最多两次、工具报错后两步内不降)。每个请求都从用户当下的选择重新读基准,所以下一次请求就恢复。默认关闭:它是唯一会改变答案而不只是时延的开关。',
170
+ '不确定先查证 / Verify before guessing(面板「不确定先查证」): 每轮第一步的提示里加一句「拿不准的仓库事实就读一次:一次调用,选最便宜的那个(grep 先于 read,读范围先于整文件);结果出乎意料时先重读,再解释为什么」。只加提示文字,不改任何请求字段、不改调度、不改完成判定。依据是本插件唯一测出大收益的机制就是提示文字(LZ1 0.389×),而参数降档测不出效果;这条专门对着「用推理代替读取」这种过思考。默认关闭。',
170
171
  ),
171
172
 
172
- lazyVariant: z
173
- .union(['v1', 'v2', 'v3'].map(name => z.const(name)))
174
- .default('v1')
173
+ actBeforePlan: z
174
+ .boolean()
175
+ .default(false)
176
+ .volatile()
175
177
  .description(
176
- '实验开关,不进面板:lazy 的梯子文本用哪一版。v1 是 1.6.x 已发布的文本,v2 在它后面加「不重述任务、行动已经清楚就直接做」,v3 是同样规则的精简重写。只用于同一块里对照三版文本,默认 v1;选它不会改变回答以外的任何东西,普通用户不需要动。',
178
+ '先执行后解释 / Act before explaining(面板「先执行后解释」): 每轮第一步加一句「不复述任务、不重讲计划;第一个工具调用之前最多一行计划,动作已经清楚就直接做」。省的是每一步的输出字符(实测输出中位一万字符以上),对推理量不一定有效,所以它和另外两条是不同机制、分开开关。默认关闭。',
177
179
  ),
178
180
 
179
- lazyEffort: z
181
+ repeatNudge: z
180
182
  .boolean()
181
- .default(true)
183
+ .default(false)
184
+ .volatile()
182
185
  .description(
183
- 'lazy 的思考强度半边 / Lazy effort step-down: 设为 false 时 lazy 只发提示文字,一个请求字段都不写。对照臂,用来分辨收益来自提示词还是 provider 真的更低档;默认 true。',
186
+ '重复时换方法 / Evidence nudge(面板「重复时换方法」): 轮内检测两种「没有新信息还在想」的证据——同一个调用(工具名+参数)本轮已经出现 ≥2 次,或连续 ≥3 次工具结果的内容完全相同;命中时在下一步之前发一句带计数的提醒,每轮最多两次、同一证据不重复。它只读本轮已经发生的调用,不发请求、不写请求字段。按推理字符数触发只是「过度思考」的代理指标,短任务上会误触发,这条改用它本身的定义。默认关闭。',
184
187
  ),
185
188
 
186
189
  preferPatch: z
@@ -232,7 +235,7 @@ export const Config = z.object({
232
235
  .default(false)
233
236
  .volatile()
234
237
  .description(
235
- '工作区清单 / Workspace inventory(面板「先列文件清单」): 会话开始时不调用任何工具,harness 自己走一遍 cwd(深度 2、最多 60 条、跳过 node_modules 等)把路径、大小和现有测试入口作为一条提示交给模型。实测 `def15` 会话里 35.7% 的工具调用是 ls/find/cat 这类探路,其中 40% 出现在前两次调用里——这一步就是把那次往返拿掉。只发一次,不隐藏任何文件,读文件仍由模型自己决定。',
238
+ '工作区清单 / Workspace inventory(面板「开局附文件清单」): 会话开始时不调用任何工具,harness 自己走一遍 cwd(深度 2、最多 60 条、跳过 node_modules 等)把路径、大小和现有测试入口作为一条提示交给模型。实测 `def15` 会话里 35.7% 的工具调用是 ls/find/cat 这类探路,其中 40% 出现在前两次调用里——这一步就是把那次往返拿掉。只发一次,不隐藏任何文件,读文件仍由模型自己决定。',
236
239
  ),
237
240
 
238
241
  mapEntries: z
@@ -0,0 +1,217 @@
1
+ /**
2
+ * Deliberation controls: the three opt-in mechanisms that replaced the retired
3
+ * effort step-down in 1.7.0.
4
+ *
5
+ * Why the old half went away: rewriting `reasoningEffort` for one request turned
6
+ * out to be unmeasurable on the route this plugin runs on (the provider reports
7
+ * `reasoning` usage as 0 on every row, so there is no evidence the parameter is
8
+ * honoured) and it was the one mechanism that changed answers rather than only
9
+ * latency. See BENCHMARK.md, LZ1: text-only 0.389x vs text+step-down 0.430x.
10
+ *
11
+ * What is left is three switches, all visible in the transcript instead:
12
+ * 1. `probeFirst` — one line at turn entry: look up the fact you are unsure
13
+ * of instead of reasoning about it.
14
+ * 2. `actBeforePlan`— one line at turn entry: at most one line of plan before
15
+ * the first call, and act when the next action is clear.
16
+ * 3. `repeatNudge` — an evidence nudge mid-turn: the same call already ran, or
17
+ * the last few results carried nothing new. This targets
18
+ * the definition of over-thinking ("no new information, and
19
+ * still thinking") instead of its proxy ("long reasoning").
20
+ *
21
+ * `actBeforePlan` also carries a length nudge. Repetition turned out to be blind
22
+ * on tasks that read one new file per step, so the second signal is the one the
23
+ * transcript already records: how many characters of reasoning this turn has
24
+ * written. It is counted from `assistant/message` events and never written back,
25
+ * so like everything else here it can only add text to the prompt.
26
+ *
27
+ * All three are prompt-side only. No config field is written, no request field
28
+ * changes, so nothing here can make the request differ from what the composer
29
+ * shows.
30
+ */
31
+
32
+ /** Never-cut guard: the point of thinking less is not to skip the expensive checks. */
33
+ export const GUARD =
34
+ 'None of this cuts validation at trust boundaries, data-loss handling, security, or accessibility.'
35
+
36
+ export const PROBE_TEXT =
37
+ 'When you are unsure of a fact about the codebase — a file\'s contents, a signature, a test\'s expectation — read it once instead of reasoning about it. One call, the cheapest that answers the question: `grep` before `read`, a range before the whole file. Guessing costs a step and is usually wrong; looking costs the same step and settles it. Read again when a result surprises you, before theorising about why it surprised you.'
38
+
39
+ export const ACT_TEXT =
40
+ 'Do not restate the task and do not re-explain the plan: the reader already has it. Before your first tool call, one line of plan is enough; when the next action is already clear, take it now and explain afterwards only if the result is not self-evident.'
41
+
42
+ export const PROBE_SUMMARY = 'probe-first: read the fact, do not reason about it'
43
+ export const ACT_SUMMARY = 'act-first: one line of plan, then work'
44
+ export const REPEAT_SUMMARY = 'repeat nudge: same call again, or no new information'
45
+ export const LONG_REASONING_SUMMARY = 'over-thinking: think with what you have'
46
+
47
+ /** Cumulative reasoning characters in one turn that earn the first length nudge. */
48
+ export const REASONING_CHARS = 4000
49
+ /** At most this many length nudges per turn. */
50
+ export const MAX_REASONING_NUDGES = 2
51
+
52
+ /** At most this many evidence nudges per turn. */
53
+ export const MAX_REPEAT_NUDGES = 2
54
+ /** The same call (name + arguments) seen this many times in one turn is a loop. */
55
+ export const REPEAT_CALLS = 2
56
+ /** Consecutive unchanged results beyond the first: three identical results in a row. */
57
+ export const STALE_RESULTS = 2
58
+ /** Bounded history per conversation; the watch is a WeakMap-free Map trimmed by age. */
59
+ const HISTORY_MAX = 8
60
+ const RUN_MAX = 64
61
+
62
+ /**
63
+ * The turn-entry notice. Both text switches share one message and one guard line
64
+ * so that turning both on does not cost two notices.
65
+ * @param {{probe?: boolean, act?: boolean}} switches
66
+ * @returns {string|undefined}
67
+ */
68
+ export function deliberationNotice(switches) {
69
+ const parts = []
70
+ if (switches?.probe === true) parts.push(PROBE_TEXT)
71
+ if (switches?.act === true) parts.push(ACT_TEXT)
72
+ if (parts.length === 0) return undefined
73
+ return `${parts.join('\n\n')}\n\n${GUARD}`
74
+ }
75
+
76
+ /** @returns {{runs: Map<string, object>}} */
77
+ export function createProgressWatch() {
78
+ return { runs: new Map() }
79
+ }
80
+
81
+ function runOf(watch, sid) {
82
+ let run = watch.runs.get(sid)
83
+ if (!run) {
84
+ run = { key: '', calls: [], stale: 0, nudges: 0, nudgedFor: '', reasonChars: 0, reasonNudges: 0 }
85
+ watch.runs.set(sid, run)
86
+ if (watch.runs.size > RUN_MAX) {
87
+ const oldest = watch.runs.keys().next().value
88
+ if (oldest !== undefined && oldest !== sid) watch.runs.delete(oldest)
89
+ }
90
+ }
91
+ return run
92
+ }
93
+
94
+ /**
95
+ * Start (or continue) a conversation's turn. A new user message resets the
96
+ * evidence: the previous turn's loop is not this turn's.
97
+ * @param {{runs: Map<string, object>}} watch
98
+ * @param {string} sid
99
+ * @param {string} key turn identity
100
+ */
101
+ export function beginTurn(watch, sid, key) {
102
+ const run = runOf(watch, sid)
103
+ if (run.key !== key) {
104
+ run.key = key
105
+ run.calls = []
106
+ run.stale = 0
107
+ run.nudges = 0
108
+ run.nudgedFor = ''
109
+ run.reasonChars = 0
110
+ run.reasonNudges = 0
111
+ }
112
+ return run
113
+ }
114
+
115
+ /**
116
+ * Record one finished tool call. `signature` must be stable across repeats of the
117
+ * same call (tool name + arguments), `digest` the hash of the result content.
118
+ * Error results are counted as "something happened", never as a loop signal: an
119
+ * error is information, and it is the one case where more thought is warranted.
120
+ * @param {{runs: Map<string, object>}} watch
121
+ * @param {string} sid
122
+ * @param {{signature?: string, digest?: string, isError?: boolean}} call
123
+ */
124
+ export function noteCall(watch, sid, call) {
125
+ const run = runOf(watch, sid)
126
+ const signature = typeof call?.signature === 'string' ? call.signature : ''
127
+ const digest = typeof call?.digest === 'string' ? call.digest : ''
128
+ const last = run.calls[run.calls.length - 1]
129
+ if (call?.isError !== true && last !== undefined && digest !== '' && digest === last.digest) {
130
+ run.stale += 1
131
+ } else {
132
+ run.stale = 0
133
+ }
134
+ // An error is information: it resets the "nothing new" run, and it never counts
135
+ // as a repeated call, because the next step after a failure is exactly where
136
+ // more thought is warranted rather than less.
137
+ const isError = call?.isError === true
138
+ run.calls.push({ signature: isError ? '' : signature, digest, isError })
139
+ if (run.calls.length > HISTORY_MAX) run.calls.shift()
140
+ }
141
+
142
+ /**
143
+ * The evidence nudge for the next step, or undefined when the turn shows no loop.
144
+ * Every nudge names the count it saw, because "you are looping" without a number
145
+ * reads as an opinion and this one has to read as a finding.
146
+ * @param {{runs: Map<string, object>}} watch
147
+ * @param {string} sid
148
+ * @param {number} step
149
+ * @returns {string|undefined}
150
+ */
151
+ export function planRepeatNudge(watch, sid, step) {
152
+ const run = runOf(watch, sid)
153
+ if (run.nudges >= MAX_REPEAT_NUDGES) return undefined
154
+
155
+ let condition = ''
156
+ let text = ''
157
+
158
+ const last = run.calls[run.calls.length - 1]
159
+ if (last !== undefined && last.signature !== '') {
160
+ const same = run.calls.filter((call) => call.signature === last.signature).length
161
+ if (same >= REPEAT_CALLS) {
162
+ // The digest is part of the condition: the same call returning new content
163
+ // is new evidence, and earns its own sentence once.
164
+ condition = `repeat:${last.signature}:${last.digest}`
165
+ text = `The call you are about to reason about has already run ${same} times in this turn and returned what you have. If the answer did not change, a different call is what changes it — not more thinking about this one.`
166
+ }
167
+ }
168
+
169
+ if (text === '' && run.stale >= STALE_RESULTS) {
170
+ condition = `stale:${last?.digest ?? ''}`
171
+ text = `The last ${run.stale + 1} calls returned the same content. Nothing new is arriving from that direction: decide with what you have, or ask a different question.`
172
+ }
173
+
174
+ if (text === '' || condition === run.nudgedFor) return undefined
175
+ run.nudges += 1
176
+ run.nudgedFor = condition
177
+ run.debugWhy = `${condition} step=${step}`
178
+ return text
179
+ }
180
+
181
+ /**
182
+ * Add one assistant message's reasoning size to this turn's running total.
183
+ *
184
+ * Per turn, not per session: the length of *this* attempt is what the next step
185
+ * needs to know, and the next user message starts the measurement over.
186
+ *
187
+ * @param {{runs: Map<string, object>}} watch
188
+ * @param {string} sid
189
+ * @param {number} chars
190
+ */
191
+ export function noteReasoning(watch, sid, chars) {
192
+ if (!Number.isFinite(chars) || chars <= 0) return
193
+ const run = runOf(watch, sid)
194
+ run.reasonChars += chars
195
+ }
196
+
197
+ /**
198
+ * The length nudge for the next step, or undefined when this turn has not yet
199
+ * thought longer than a decision needs. Fires at most `MAX_REASONING_NUDGES`
200
+ * times per turn and names the size it saw, so the message reads as a finding
201
+ * rather than an opinion.
202
+ *
203
+ * @param {{runs: Map<string, object>}} watch
204
+ * @param {string} sid
205
+ * @param {number} step
206
+ * @returns {string|undefined}
207
+ */
208
+ export function planReasoningNudge(watch, sid, step) {
209
+ const run = runOf(watch, sid)
210
+ if (run.reasonNudges >= MAX_REASONING_NUDGES) return undefined
211
+ const threshold = REASONING_CHARS * (run.reasonNudges + 1)
212
+ if (run.reasonChars < threshold) return undefined
213
+ run.reasonNudges += 1
214
+ run.debugWhy = `reasoning:${run.reasonChars} step=${step}`
215
+ const seen = Math.round(run.reasonChars / 100) * 100
216
+ return `You have written about ${seen} characters of reasoning in this turn. Past that length, more thinking about facts you already have rarely changes the next action: take the action the evidence supports, or read the one fact that is still missing.`
217
+ }
package/src/effort.js CHANGED
@@ -277,15 +277,14 @@ export function resolveConfig(raw, overrides) {
277
277
  ? Math.max(0, Math.floor(number(config.finishLineSteps, 0)))
278
278
  : 0,
279
279
  /**
280
- * Lazy mode: the one switch that may cut deliberation instead of only round
281
- * trips. Off by default, because it changes what the model thinks about and
282
- * every other switch keeps the "same depth, sooner" promise. `lazyEffort:
283
- * false` keeps the ladder text and the nudge but never rewrites effort, so
284
- * the two halves can be measured apart.
280
+ * The three deliberation switches of 1.7.0, all prompt-side and all opt-in.
281
+ * They replaced the 1.6.x effort step-down, which could change an answer
282
+ * without being measurable on this route. `probeFirst` and `actBeforePlan`
283
+ * cost one notice at turn entry; `repeatNudge` reads the turn's own calls.
285
284
  */
286
- lazy: gear.directive && config.lazy === true,
287
- lazyEffort: gear.directive && config.lazy === true && config.lazyEffort !== false,
288
- lazyVariant: String(config.lazyVariant || 'v1'),
285
+ probeFirst: gear.directive && config.probeFirst === true,
286
+ actBeforePlan: gear.directive && config.actBeforePlan === true,
287
+ repeatNudge: gear.directive && config.repeatNudge === true,
289
288
  /**
290
289
  * The stall guard, the one *hard* bound this plugin owns: it cancels a single
291
290
  * tool call at its deadline. On by default; `false` or a negative
package/src/state.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * has an entry of its own. Only an edit in `cordis.patch.yml` changes them;
15
15
  * the chip and the panel never write them.
16
16
  * - The `sessions` field carries every per-session choice: `{ sessionId: {
17
- * enabled, gear, discipline, stepBudget, map, batch, lazy, ts } }` as a compact JSON
17
+ * enabled, gear, discipline, stepBudget, map, batch, probeFirst, actBeforePlan, repeatNudge, ts } }` as a compact JSON
18
18
  * string. It is volatile, so a write lands in the running plugin without a
19
19
  * remount.
20
20
  *
@@ -107,7 +107,9 @@ function sanitise(input) {
107
107
  if (Number.isFinite(entry.stepBudget) && entry.stepBudget >= 0) clean.stepBudget = Math.floor(Number(entry.stepBudget))
108
108
  if (typeof entry.map === 'boolean') clean.map = entry.map
109
109
  if (typeof entry.batch === 'boolean') clean.batch = entry.batch
110
- if (typeof entry.lazy === 'boolean') clean.lazy = entry.lazy
110
+ if (typeof entry.probeFirst === 'boolean') clean.probeFirst = entry.probeFirst
111
+ if (typeof entry.actBeforePlan === 'boolean') clean.actBeforePlan = entry.actBeforePlan
112
+ if (typeof entry.repeatNudge === 'boolean') clean.repeatNudge = entry.repeatNudge
111
113
  if (Number.isFinite(entry.ts)) clean.ts = Number(entry.ts)
112
114
  if (
113
115
  clean.enabled === undefined
@@ -116,7 +118,9 @@ function sanitise(input) {
116
118
  && clean.stepBudget === undefined
117
119
  && clean.map === undefined
118
120
  && clean.batch === undefined
119
- && clean.lazy === undefined
121
+ && clean.probeFirst === undefined
122
+ && clean.actBeforePlan === undefined
123
+ && clean.repeatNudge === undefined
120
124
  ) continue
121
125
  out[sessionId] = clean
122
126
  }
@@ -142,7 +146,9 @@ export function choiceFor(raw, sessionId) {
142
146
  if (entry.stepBudget !== undefined) choice.stepBudget = entry.stepBudget
143
147
  if (entry.map !== undefined) choice.map = entry.map
144
148
  if (entry.batch !== undefined) choice.batch = entry.batch
145
- if (entry.lazy !== undefined) choice.lazy = entry.lazy
149
+ if (entry.probeFirst !== undefined) choice.probeFirst = entry.probeFirst
150
+ if (entry.actBeforePlan !== undefined) choice.actBeforePlan = entry.actBeforePlan
151
+ if (entry.repeatNudge !== undefined) choice.repeatNudge = entry.repeatNudge
146
152
  return choice
147
153
  }
148
154
 
@@ -168,7 +174,9 @@ export function configOverridesFor(raw, sessionId) {
168
174
  if (choice.stepBudget !== undefined) overrides.stepBudget = choice.stepBudget
169
175
  if (choice.map !== undefined) overrides.workspaceMap = choice.map
170
176
  if (choice.batch !== undefined) overrides.openBatch = choice.batch
171
- if (choice.lazy !== undefined) overrides.lazy = choice.lazy
177
+ if (choice.probeFirst !== undefined) overrides.probeFirst = choice.probeFirst
178
+ if (choice.actBeforePlan !== undefined) overrides.actBeforePlan = choice.actBeforePlan
179
+ if (choice.repeatNudge !== undefined) overrides.repeatNudge = choice.repeatNudge
172
180
  return Object.keys(overrides).length === 0 ? undefined : overrides
173
181
  }
174
182
 
@@ -220,12 +228,14 @@ export function writeChoice(raw, sessionId, choice, now) {
220
228
  if (Number.isFinite(choice.stepBudget) && choice.stepBudget >= 0) next.stepBudget = Math.floor(Number(choice.stepBudget))
221
229
  if (typeof choice.map === 'boolean') next.map = choice.map
222
230
  if (typeof choice.batch === 'boolean') next.batch = choice.batch
223
- if (typeof choice.lazy === 'boolean') next.lazy = choice.lazy
231
+ if (typeof choice.probeFirst === 'boolean') next.probeFirst = choice.probeFirst
232
+ if (typeof choice.actBeforePlan === 'boolean') next.actBeforePlan = choice.actBeforePlan
233
+ if (typeof choice.repeatNudge === 'boolean') next.repeatNudge = choice.repeatNudge
224
234
  map[sessionId] = next
225
235
  return { sessions: encodeChoices(map), map }
226
236
  }
227
237
 
228
- /** @typedef {{enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean, batch?: boolean, lazy?: boolean, ts?: number}} SessionEntry */
238
+ /** @typedef {{enabled?: boolean, gear?: string, discipline?: boolean|'late', stepBudget?: number, map?: boolean, batch?: boolean, probeFirst?: boolean, actBeforePlan?: boolean, repeatNudge?: boolean, ts?: number}} SessionEntry */
229
239
 
230
240
  /**
231
241
  * Serialise the map, dropping the oldest entries beyond {@link SESSIONS_LIMIT}.