dsh-fast-mode 1.6.1 → 1.7.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,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,14 +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,
285
+ probeFirst: gear.directive && config.probeFirst === true,
286
+ actBeforePlan: gear.directive && config.actBeforePlan === true,
287
+ repeatNudge: gear.directive && config.repeatNudge === true,
288
288
  /**
289
289
  * The stall guard, the one *hard* bound this plugin owns: it cancels a single
290
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}.
package/src/usage.js CHANGED
@@ -122,3 +122,26 @@ export function findUsage(source) {
122
122
  export function readOutputTokens(source) {
123
123
  return readUsage(findUsage(source)).output
124
124
  }
125
+
126
+ /**
127
+ * Reasoning characters in one assistant message, summed over its blocks.
128
+ *
129
+ * This is the *observed* size of the model's thinking, read from the transcript;
130
+ * it is never written back into a request. Routes that do not persist reasoning
131
+ * blocks return 0, and a switch that reads this stays silent there — the correct
132
+ * failure mode for something that is off by default.
133
+ *
134
+ * @param {any} source an assistant message, or an event carrying one under `message`
135
+ * @returns {number}
136
+ */
137
+ export function readReasoningChars(source) {
138
+ const message = source && typeof source === 'object' ? (source.message ?? source) : undefined
139
+ const content = message && Array.isArray(message.content) ? message.content : []
140
+ let total = 0
141
+ for (const block of content) {
142
+ if (block && typeof block === 'object' && block.type === 'reasoning' && typeof block.text === 'string') {
143
+ total += block.text.length
144
+ }
145
+ }
146
+ return total
147
+ }
package/src/lazy.js DELETED
@@ -1,220 +0,0 @@
1
- /**
2
- * Lazy mode: the one switch in this plugin that is allowed to touch how much the
3
- * model deliberates.
4
- *
5
- * Everything else here buys speed while holding effort still — the promise is
6
- * "same depth, sooner". This module is the deliberate exception, and it is the
7
- * only place in the package that rewrites `reasoningEffort`. It exists because
8
- * a long turn loses time in a way parallelism cannot fix: the model keeps
9
- * expanding a problem it has already understood. The idea is borrowed from
10
- * `ponytail` — a lazy senior developer solves the whole problem with the least
11
- * new code, and is lazy about the solution but never about the reading.
12
- *
13
- * Three mechanisms, cheapest first:
14
- *
15
- * 1. **The ladder, in the prompt.** One short text asks for the first rung that
16
- * works and a one-line "what I skipped". Cost is input tokens once per turn;
17
- * it changes nothing about the harness.
18
- * 2. **The nudge.** When a turn's streamed reasoning passes a threshold without
19
- * a new tool result, one notice says so. It is observation, not policy: it
20
- * repeats what the transcript already proves.
21
- * 3. **The effort step-down.** Only when a step visibly over-thought (the
22
- * streamed reasoner block is large) does the next request ask for one rung
23
- * less. It never goes below `FLOOR` (the user's own floor, `low`), never
24
- * fires twice in a row after an error, and is capped per turn.
25
- *
26
- * What is never cut, in this module or its text: validation at a trust boundary,
27
- * error handling that protects data, security, accessibility, and the
28
- * deliverable itself. `DIRECTIVE` says so explicitly, because a model told
29
- * "write less" drops the wrong things first.
30
- *
31
- * ## Why the step-down keeps its own ledger
32
- *
33
- * The 0.9.x release lowered effort from step two of *every* turn. Measured
34
- * later, that bought no wall clock on this route and did real damage: the
35
- * rewrite reached the provider as a request header, so the composer showed an
36
- * effort the user never chose, and it stayed there across restarts. So this
37
- * module lowers effort only after evidence, restores the resolved value on the
38
- * next request, and is off unless `lazy: true`.
39
- */
40
-
41
- /** The prompt section. Short on purpose: it is paid on every request of a turn. */
42
- export const DIRECTIVE = `Work like a lazy senior developer: the best step is the one not taken.
43
-
44
- - Read before deciding. Trace the real flow of the code the change touches, then decide. Being lazy about the solution never means being lazy about the reading.
45
- - Once a step fully does the job, stop planning it. No option, abstraction, or "for later" code nobody asked for.
46
- - Take the first rung that works: what this repo already has, then the platform or a tool you already hold, then one line, then the minimum that works.
47
- - Never cut these to save code or time: validation where input crosses a trust boundary, error handling that protects data, security, accessibility, and the deliverable the user asked for.
48
- - End the turn with one line: what you skipped, and the risk that carries.`
49
-
50
- const DIRECTIVE_SUMMARY = 'lazy mode: first rung that works, no scope beyond the task'
51
-
52
- /** Reasoning rungs a route may name, weakest first. */
53
- export const EFFORT_RUNGS = Object.freeze(['minimal', 'low', 'medium', 'high', 'xhigh', 'max'])
54
-
55
- /**
56
- * The lowest rung this module will ask for. Effort is the user's contract; a
57
- * watchdog may spend a little of the headroom a high setting leaves, and stops
58
- * where the default would have been.
59
- */
60
- const FLOOR = 'low'
61
-
62
- /**
63
- * Streamed reasoning characters in one step that count as over-thinking. On the
64
- * measured route a normal step streams a few hundred characters and a step stuck
65
- * on scope streams thousands, so this is deliberately not a tight bound.
66
- */
67
- const HOT_CHARS = 1600
68
-
69
- /** Reasoning characters a turn may spend before the first nudge. */
70
- const NUDGE_CHARS = 4000
71
-
72
- /** How many notices one turn may receive. Two: one early, one late. */
73
- const MAX_NUDGES = 2
74
-
75
- /** How many effort reductions one turn may use. */
76
- const MAX_REDUCTIONS = 2
77
-
78
- /** Steps a reduction is off limits for after an error result (the step it fired on plus two). */
79
- const ERROR_COOLDOWN = 2
80
-
81
- /**
82
- * The next rung down, or `undefined` when the value is not a rung this module
83
- * understands (a route may use its own vocabulary — never guess at it) or is
84
- * already at `FLOOR`.
85
- *
86
- * @param {string} value
87
- * @returns {string | undefined}
88
- */
89
- export function cheaperEffort(value) {
90
- const at = EFFORT_RUNGS.indexOf(value)
91
- if (at <= 0) return undefined
92
- const floor = EFFORT_RUNGS.indexOf(FLOOR)
93
- if (at <= floor) return undefined
94
- return EFFORT_RUNGS[at - 1]
95
- }
96
-
97
- /** Per-session bookkeeping; one turn at a time is all a session runs. */
98
- const newTurn = () => ({
99
- step: 0,
100
- hot: false,
101
- stepChars: 0,
102
- turnChars: 0,
103
- nudges: 0,
104
- reductions: 0,
105
- coolUntil: 0,
106
- nudgedAt: 0,
107
- reducedStep: -1,
108
- })
109
-
110
- /**
111
- * Create the per-session ledger. Pure and dependency-free so the policy can be
112
- * tested without a harness.
113
- */
114
- export function createLazyWatch() {
115
- return { runs: new Map() }
116
- }
117
-
118
- /** @param {{runs: Map<string, any>}} watch @param {string} sid */
119
- const runOf = (watch, sid) => {
120
- let run = watch.runs.get(sid)
121
- if (run === undefined) {
122
- run = newTurn()
123
- watch.runs.set(sid, run)
124
- if (watch.runs.size > 64) watch.runs.delete(watch.runs.keys().next().value)
125
- }
126
- return run
127
- }
128
-
129
- /**
130
- * Count streamed reasoning characters. Called from the stream tap, i.e. before
131
- * the step ends, which is what makes the effort decision possible at all: the
132
- * decision has to be made for the *next* request.
133
- *
134
- * @param {{runs: Map<string, any>}} watch @param {string} sid @param {number} chars
135
- */
136
- export function noteReasoning(watch, sid, chars) {
137
- if (!Number.isFinite(chars) || chars <= 0) return
138
- const run = runOf(watch, sid)
139
- run.stepChars += chars
140
- run.turnChars += chars
141
- }
142
-
143
- /**
144
- * Open a step: close the previous one's verdict, and say whether this step is
145
- * the one that carries the ladder text.
146
- *
147
- * @param {{runs: Map<string, any>}} watch @param {string} sid
148
- * @param {{turn?: number, step?: number, firstStep?: boolean}} where
149
- * @returns {{directive?: string, nudge?: string} | undefined}
150
- */
151
- export function beginStep(watch, sid, where) {
152
- const run = runOf(watch, sid)
153
- const step = Number.isFinite(where?.step) ? Math.floor(where.step) : run.step + 1
154
- const newTurnStart = where?.firstStep === true || step <= 1
155
- if (newTurnStart) {
156
- const fresh = newTurn()
157
- fresh.step = step
158
- watch.runs.set(sid, fresh)
159
- return { directive: DIRECTIVE }
160
- }
161
- // The previous step's streamed volume is the evidence the next request acts on.
162
- run.hot = run.stepChars >= HOT_CHARS
163
- run.stepChars = 0
164
- run.step = step
165
- const plan = {}
166
- if (run.nudges < MAX_NUDGES && run.turnChars >= NUDGE_CHARS * (run.nudges + 1) && run.turnChars > run.nudgedAt) {
167
- run.nudges += 1
168
- run.nudgedAt = run.turnChars
169
- run.hot = true
170
- plan.nudge = `You have streamed about ${run.turnChars} characters of reasoning this turn. Use what you already have: decide, take the next rung, and say in one line what you skipped. Do not open a new line of investigation unless the last result contradicted an assumption.`
171
- }
172
- return Object.keys(plan).length === 0 ? undefined : plan
173
- }
174
-
175
- /**
176
- * A tool result came back. An error is the one signal that the model may need
177
- * *more* thinking, so it cancels any pending step-down for a couple of steps.
178
- *
179
- * @param {{runs: Map<string, any>}} watch @param {string} sid @param {{isError?: boolean}} observation
180
- */
181
- export function noteToolResult(watch, sid, observation) {
182
- if (observation?.isError !== true) return
183
- const run = runOf(watch, sid)
184
- run.hot = false
185
- run.coolUntil = run.step + ERROR_COOLDOWN + 1
186
- }
187
-
188
- /**
189
- * The effort this request should carry.
190
- *
191
- * Returns `base` untouched unless the ledger holds evidence: a hot previous
192
- * step, no pending cooldown, budget left, and a base that is a rung above
193
- * `FLOOR`. The caller must treat `effort === base` as "nothing happened".
194
- *
195
- * @param {{runs: Map<string, any>}} watch @param {string} sid
196
- * @param {string | undefined} base - the effort the harness resolved.
197
- * @returns {{effort: string | undefined, reduced: boolean}}
198
- */
199
- export function planEffort(watch, sid, base) {
200
- if (typeof base !== 'string') return { effort: base, reduced: false }
201
- const run = runOf(watch, sid)
202
- if (run.reducedStep === run.step) {
203
- // One step, one reduction: a retry inside the same step must not walk the
204
- // effort ladder down twice before anything has been measured again.
205
- return { effort: base, reduced: false }
206
- }
207
- if (!run.hot || run.step < run.coolUntil || run.reductions >= MAX_REDUCTIONS) {
208
- return { effort: base, reduced: false }
209
- }
210
- const cheaper = cheaperEffort(base)
211
- if (cheaper === undefined) return { effort: base, reduced: false }
212
- run.reductions += 1
213
- run.reducedStep = run.step
214
- return { effort: cheaper, reduced: true }
215
- }
216
-
217
- /** The notice body for the nudge, built where it is sent (see `index.js`). */
218
- export const NUDGE_SUMMARY = 'lazy mode: decide with what you have'
219
-
220
- export { DIRECTIVE_SUMMARY }