@falling-ts/dsh-force-compact 0.2.3 → 0.2.5

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.
@@ -7,10 +7,11 @@
7
7
  * before a model request is made**:
8
8
  *
9
9
  * - **`agent/request`** (a Waterfall around the frozen call configuration) —
10
- * when the `disableThinking` setting is on, the returned `LlmCallConfig`
11
- * carries `reasoningEffort: 'off'`, which the LLM adapter maps to
12
- * `thinking: { type: 'disabled' }`. Every model request in this process is
13
- * therefore sent with thinking/reasoning disabled.
10
+ * a deliberate **pass-through** (2026-08 semantics revision): the returned
11
+ * `LlmCallConfig` rides UNCHANGED. `disableThinking` now scopes strictly to
12
+ * this plugin's own compaction summarization call (enforced inside
13
+ * `engine/builtin.js` `engine/summarizer.js`); all other model requests
14
+ * retain the machine's own reasoning-effort configuration.
14
15
  * - **`agent/pre-step`** (a Waterfall before each model step) — reads the
15
16
  * session's **projected context tokens** through the official
16
17
  * `contextPressure` projection (`projectedTokens` — the exact figure the
@@ -617,15 +618,29 @@ async function __forceCompactIfNeededBody(ctx, agent, signal, mode) {
617
618
  )
618
619
  }
619
620
 
620
- // At or above the threshold: do NOT request the model. Retain the latest
621
- // `retainLatestTokens` of the surface VERBATIM, and send everything before
622
- // that cutoff (the head) as ONE BATCH to the LLM summarizer. The loop retries
623
- // the step against the shrunken context (retained tail unchanged).
624
- ctx.logger.info(
625
- `[force-compact] ${session.id}: context ~${total} tokens >= threshold ${settings.autoThresholdTokens}; `
626
- + `rejecting the model request and compacting the head while retaining the latest ~${settings.retainLatestTokens} tokens`,
621
+ // SHORT-CIRCUIT: when the whole current surface window does not exceed the
622
+ // retention budget, there is no head to compact (the tail-retaining selector
623
+ // would walk the entire window and return null). The threshold was tripped
624
+ // by the provider-usage baseline rather than by real surface growth, so an
625
+ // attempted compaction is a guaranteed no-op — skip it and let the request
626
+ // proceed without pretending to compact.
627
+ if (windowSumObserved > 0 && Number.isFinite(windowSumObserved) && windowSumObserved <= settings.retainLatestTokens) {
628
+ ctx.logger.debug(
629
+ `[force-compact] ${session.id}: threshold ${settings.autoThresholdTokens} tripped on a surface window (~${Math.round(windowSumObserved)} tokens) that does not exceed retainLatestTokens (~${settings.retainLatestTokens}) — nothing above the retention floor to compact; letting the request proceed`,
630
+ )
631
+ return false
632
+ }
633
+
634
+ // At or above the threshold: attempt a retained-tail compaction (the request
635
+ // itself always proceeds; the plugin never rejects the model call).
636
+ ctx.logger.debug(
637
+ `[force-compact] ${session.id}: context ~${total} tokens >= threshold ${settings.autoThresholdTokens} — attempting a retained-tail compaction (success/failure is reported by the backend below; the request itself proceeds regardless)`,
627
638
  )
628
- const committed = await compactRetainingLatest(ctx, agent, signal, mode, measurement)
639
+ // NOTE: threshold path has NO originating slash-command, so the 5th argument
640
+ // (sourceCommandId) must be omitted — passing something else here (e.g. the
641
+ // `measurement` snapshot, as an earlier revision did) would leak a non-string
642
+ // into the compaction/* events' sourceCommandId field.
643
+ const committed = await compactRetainingLatest(ctx, agent, signal, mode)
629
644
  if (!committed) {
630
645
  // BLANK OUTCOME — nothing shrank, so the NEXT step re-attempts at the same
631
646
  // total. That is intentional ("先压缩再说"): a blank result never wedges the
@@ -640,11 +655,21 @@ async function __forceCompactIfNeededBody(ctx, agent, signal, mode) {
640
655
  }
641
656
 
642
657
  /**
643
- * Whether a model request should be sent with thinking/reasoning disabled.
658
+ * Whether a model request SHOULD be sent with thinking/reasoning disabled.
659
+ *
660
+ * LEGACY PREDICATE (2026-08 semantics revision): the active `agent/request`
661
+ * hot path no longer calls this helper. `disableThinking` now scopes STRICTLY
662
+ * to THIS PLUGIN'S OWN compaction summarization call — enforced inside
663
+ * `src/engine/builtin.js` (which sources `extra.reasoningEffort` from
664
+ * `settings.disableThinking` and passes it to `src/engine/summarizer.js`,
665
+ * whose `options.reasoningEffort` stamps the `ctx.llm.stream` request). All
666
+ * OTHER model requests (business conversation, sub-agents, tool-driven,
667
+ * other plugins) ride the machine's config UNCHANGED.
644
668
  *
645
- * Called from the `agent/request` Waterfall. When the `disableThinking`
646
- * setting is on (default), the caller sets `reasoningEffort: 'off'` on the
647
- * returned `LlmCallConfig`.
669
+ * Kept exported so a FUTURE caller who genuinely wants the blanket
670
+ * "off-everywhere" semantics (or the legacy dual-layer insurance described
671
+ * in the stale docs) can consume the same setting through the same
672
+ * containment envelope without duplicating the read-settings logic.
648
673
  *
649
674
  * @param {import('@deepseek-ai/cordis').Context} ctx
650
675
  * @returns {Promise<boolean>}
@@ -1,117 +1,127 @@
1
1
  /**
2
- * dsh-force-compact's wire-layer second safety net for disabling thinking —
3
- * appends the OPENAI-COMPATIBLE wire field `reasoning_effort: "none"` to
4
- * EVERY outgoing LLM call whenever the `disableThinking` setting is on.
2
+ * dsh-force-compact's Live-UI watermark side-channel on the `llm/stream`
3
+ * waterfall seam KICKS OFF a fresh random "working" one-liner on every LLM
4
+ * call START so the Live UI (browser) repaints the `TurnStatus` node with a
5
+ * new `liveUi.working` pair. Purely a PRESENTATION-LAYER concern (a settings-
6
+ * write on the `liveUi` field, mirrored live to the browser via the existing
7
+ * settings-sync channel). Performs NO wire modification whatsoever.
5
8
  *
6
- * The dual-layer insurance rationale
7
- * ---------------------------------
8
- * The `disableThinking` setting is ALSO honored upstream at the request-seam:
9
- * the `agent/request` waterfall sets `LlmCallConfig.reasoningEffort = 'off'`,
10
- * which the DeepSeek adapter serializes to the wire field
11
- * `thinking: { type: 'disabled' }`. That is the RIGHT field for the real
12
- * DeepSeek API. But when the SAME wire shape lands on a llama.cpp
13
- * OpenAI-compatible endpoint (:8080, `Qwen3.8-27B-NVFP4-MTP-LOW.gguf`), the
14
- * top-level `thinking` key is NOT in the llama.cpp request schema — it is
15
- * forwarded opaquely into `llama_params` and IGNORED
16
- * (verified against `D:\AI\llama.cpp\tools\server`: `thinking` appears
17
- * nowhere in `server-schema.cpp`, and the OAI parsing path never reads a
18
- * top-level `thinking` key). Net effect of the request-seam path ALONE:
19
- * `disableThinking:true` SILENTLY FAILS to turn off thinking on llama.cpp —
20
- * the model thinks anyway, with no error surfacing.
9
+ * Why NOT at the `llm/stream` seam for wire-fields (historical note, 2026-08)
10
+ * ----------------------------------------------------------------------------
11
+ * An EARLIER draft of this module attempted to APPEND the llama.cpp-native
12
+ * wire field `reasoning_effort: "none"` to outgoing LLM calls at this same
13
+ * `llm/stream` seam. That approach is PROVABLY INEFFECTIVE HERE for two
14
+ * independent structural reasons (both verified empirically against the
15
+ * harness dispatch code and Cordis waterfall semantics):
21
16
  *
22
- * The llama.cpp-native (and OpenAI-generic) spelling for "disable reasoning"
23
- * is a TOP-LEVEL wire field `reasoning_effort: "none"`
24
- * (`server-common.cpp:1295-1304`), which the OAI parser special-cases into
25
- * `inputs.enable_thinking = false` UNCONDITIONALLY independent of jinja
26
- * template capability. A weaker alternative,
27
- * `chat_template_kwargs: { enable_thinking: false }`
28
- * (`server-common.cpp:1286-1291`), depends on the template honoring the
29
- * `enable_thinking` placeholder; `reasoning_effort:"none"` is more robust.
17
+ * 1. WATERFALL IS A LINEAR CHAIN THAT DISCARDS INTERMEDIATE RETURNS.
18
+ * `ctx.waterfall(...)` (vendor/cordis/src/events.ts:234-243) walks its
19
+ * listener array sequentially, always passing the SAME frozen seed
20
+ * `args` to each successive layer, and returns the OUTERMOST layer's
21
+ * value as the final stream. Intermediate layers' return values are
22
+ * DROPPED they influence nothing downstream. Our plugin registers
23
+ * LAST (lazy-install, default `push` order), so our return reaches
24
+ * nobody; the innermost thunk receives the ORIGINAL seed reference
25
+ * regardless of what we return.
30
26
  *
31
- * Current state: NEUTERED to a pure passthrough
32
- * ---------------------------------------------
33
- * The original intent was "Layer 2" append a SECOND wire field
34
- * `reasoning_effort: "none"` at the `llm/stream` seam on top of the
35
- * adapter's `thinking:{type:'disabled'}` (Layer 1) so that a llama.cpp /
36
- * OpenAI-compatible endpoint (which ignores the top-level `thinking` key)
37
- * would ALSO receive a field it honors. Live testing PROVED that this cannot
38
- * be achieved at the `llm/stream` waterfall seam without modifying vendor
39
- * code, because of two hard walls discovered empirically:
27
+ * 2. IN-PLACE ASSIGNMENT TO THE FROZEN SEED CRASHES. The seed is a deep-
28
+ * frozen (non-extensible) `GenerateOptions`; `seed.reasoning_effort =
29
+ * 'none'` throws `Cannot add property …, object is not extensible` at
30
+ * the instant a real LLM call fires, propagating OUT OF the listener
31
+ * into the host process and taking the entire `dsh web` instance down.
32
+ *
33
+ * Net effect: any injection-at-the-waterfall design either CRASHES (wall 1)
34
+ * or SILENTLY NO-OPS (wall 2). Both were observed in live testing on a
35
+ * running 3180 dev instance; that is the documented reason the module was
36
+ * reduced to a pure passthrough.
37
+ *
38
+ * Where the wire-append ACTUALLY lives now (since 2026-08)
39
+ * ----------------------------------------------------------------------
40
+ * The correct single-line fix landed in `src/engine/summarizer.js` IMMEDIATELY
41
+ * BEFORE the `llm.stream(options)` call (search for the comment block titled
42
+ * "LLAMA.CPP COMPATIBILITY WIRE FIELD"): when `extra.reasoningEffort === 'off'`
43
+ * (which `engine/builtin.js` stamps whenever `settings.disableThinking` is
44
+ * true), the summarizer sets `options.reasoning_effort = 'none'` ALONGSIDE the
45
+ * existing camelCase `options.reasoningEffort = 'off'` field (which the
46
+ * DeepSeek adapter serializes to `thinking:{type:'disabled'}`). Emitting BOTH
47
+ * fields covers BOTH endpoints simultaneously:
40
48
  *
41
- * 1. FROZEN SEED. The waterfall passes EVERY listener the SAME deep-frozen
42
- * `GenerateOptions` seed (`payload`; `ctx.waterfall` re-invokes inner
43
- * layers with the identical args see `vendor/cordis/src/events.ts#
44
- * waterfall`). In-place assignment `options.reasoning_effort = …` throws
45
- * `Cannot add property …, object is not extensible` at the exact moment
46
- * a real LLM call fires, which PROPAGATES OUT OF the listener and TAKES
47
- * DOWN THE ENTIRE `dsh web` PROCESS.
48
- * 2. GENERATOR BINDS THE SEED. The base `adapterStream` is an AsyncGenerator
49
- * that BOUND the seed object at construction; building a fresh mutable
50
- * clone and returning it (or re-dispatching `this.stream(clone)`) never
51
- * reaches the serializer, and re-dispatch additionally fails because the
52
- * listener's `this` is not reliably the LlmRuntime here (`Cannot read
53
- * properties of undefined (reading 'stream')`).
49
+ * Real DeepSeek endpoint: reads `reasoningEffort` emits
50
+ * `thinking: { type: 'disabled' }`; ignores the unknown snake_case
51
+ * `reasoning_effort` top-level key (silent no-op, no 400).
52
+ * • llama.cpp / OpenAI-compatible endpoint: reads the top-level
53
+ * `reasoning_effort: "none"` parses natively into
54
+ * `inputs.enable_thinking = false` UNCONDITIONALLY
55
+ * (`D:\AI\llama.cpp\tools\server\server-common.cpp:1295-1304`); the
56
+ * adapter's `thinking` field (still present in the body) is tolerated-
57
+ * but-ignored there.
54
58
  *
55
- * Therefore injecting a NEW top-level wire field at this seam requires either
56
- * a vendor change (out of scope) or a dedicated `registerAdapter` for
57
- * llama.cpp (would collide with / be shadowed by the existing DeepSeek-route
58
- * adapter). Rather than ship a hook that either silently no-ops or CRASHES the
59
- * host, this file is deliberately reduced to a GUARANTEED pure passthrough:
60
- * it forwards `next()`'s result (the real stream) untouched and performs NO
61
- * mutation — incapable of breaking any business-path model call.
59
+ * Because `builtin.js` gates `extra.reasoningEffort` on
60
+ * `settings.disableThinking`, the wire-field rides the EXACT same scoping
61
+ * rule as the primary one: only emitted on COMPRACTION calls where the user
62
+ * has turned thinking OFF. Business-conversation requests and every other LLM
63
+ * call never reach `summarize()` at all, so they are UNAFFECTED.
62
64
  *
63
- * The AUTHORITATIVE disable-thinking mechanism remains Layer 1 (unchanged):
64
- * the `agent/request` waterfall sets `reasoningEffort:'off'`, which the
65
- * DeepSeek adapter serializes to the native `thinking:{type:'disabled'}`
66
- * honored by the real DeepSeek API. On a llama.cpp / OpenAI-compatible
67
- * endpoint that top-level field is tolerated-but-ignored, so thinking stays
68
- * on there IDENTICAL to having no plugin at all (a documented limitation,
69
- * never a crash). Restoring the wire-append requires a future dedicated
70
- * llama.cpp adapter or a vendor-supported options-extension seam.
65
+ * What THIS FILE STILL DOES (Live-UI watermark)
66
+ * ---------------------------------------------
67
+ * Independent of the wire question above, the listener KICKS OFF a fire-
68
+ * and-forget `publishRandomWorking(ctx)` call BEFORE the synchronization
69
+ * point so the Live UI paints a fresh random "working" one-liner on every LLM
70
+ * call start. This is purely a presentation-layer concern (a settings-write
71
+ * on the `liveUi` field, mirrored live to the browser so it can repaint the
72
+ * `TurnStatus` node) and MUST NOT BLOCK the return path. `publishRandomWorking`
73
+ * swallows ALL of its own rejections internally, so the fire-and-forget form
74
+ * leaks no unhandled rejection and never disturbs the stream.
75
+ *
76
+ * Contract guarantees
77
+ * --------------------
78
+ * • ALWAYS calls `next()` (skipping it would stall the waterfall chain).
79
+ * • NEVER mutates the (deep-frozen) seed, so it cannot raise
80
+ * `object is not extensible`.
81
+ * • NEVER reshapes / spreads the (stream) return value, so it cannot break
82
+ * the consumer's `for await`.
83
+ * • Is DECLARED NON-ASYNC: an `async` listener would wrap `next()`'s stream
84
+ * return in a Promise, and the waterfall dispatcher's downstream
85
+ * `yield* <promise>` would throw
86
+ * `yield* (intermediate value)… is not async iterable` on every call.
71
87
  *
72
88
  * @module @falling-ts/dsh-force-compact/wire-rewrite
73
89
  */
74
90
 
75
91
  /**
76
- * Register the `llm/stream` Waterfall listener. As of the neutering described
77
- * in the module header, the listener is a DELIBERATE pure passthrough: it
78
- * forwards `next()`'s result (the async-iterable chunk stream) untouched and
79
- * performs NO mutation. See the module header for why the intended
80
- * `reasoning_effort:"none"` wire-append is NOT possible at this seam without a
81
- * vendor change or a dedicated llama.cpp adapter.
92
+ * Register the `llm/stream` Waterfall listener. As of the historical-note
93
+ * section above, the listener is a DELIBERATE pure passthrough w.r.t. WIRE
94
+ * SEMANTICS: it forwards `next()`'s result (the async-iterable chunk stream)
95
+ * untouched and performs NO mutation. Its sole remaining duty is the Live-UI
96
+ * watermark side-channel (see the module header). See
97
+ * `src/engine/summarizer.js` for where the actual `reasoning_effort` wire
98
+ * field is now injected (at the options-construction site, NOT at this
99
+ * waterfall seam).
82
100
  *
83
101
  * Idempotent within one plugin lifetime (a process-local latch prevents
84
102
  * double-registration across multiple `apply` invocations in tests or HMR).
85
103
  *
86
104
  * Contract guarantees:
87
- * Contract guarantees:
88
105
  * • ALWAYS calls `next()` (skipping it would stall the waterfall chain).
89
106
  * • NEVER mutates the (deep-frozen) seed, so it cannot raise
90
107
  * `object is not extensible`.
91
- * • NEVER reshapes/spreads the (stream) return value, so it cannot break the
92
- * consumer's `for await`.
93
- * • Emits at most ONE debug line (the first-of-lifetime ui-signal marker;
94
- * silent thereafter).
108
+ * • NEVER reshapes / spreads the (stream) return value, so it cannot break
109
+ * the consumer's `for await`.
95
110
  * • FIRES THE LIVE UI STATUS SIDE-CHANNEL (`core/ui-signal.js`) on every
96
111
  * invocation — the "each LLM call start = fresh random working pair"
97
- * watermark. Publication is KICKED OFF FIRE-AND-FORGET (a plain
98
- * non-awaited `publishRandomWorking(ctx)` call placed BEFORE the
99
- * synchronous `return next()`) so the listener itself STAYS SYNC and can
100
- * directly hand back the real stream; `publishRandomWorking` swallows all
101
- * of its own rejections internally, so the fire-and-forget form leaks no
102
- * unhandled rejection. It never touches `payload` (the deep-frozen seed —
103
- * untouched per the two-hard-walls note above), so it can never stall or
104
- * corrupt the stream.
105
- * • The listener is DECLARED NON-ASYNC: an `async` listener would wrap
106
- * `next()`'s stream return in a Promise, and the waterfall dispatcher's
107
- * downstream `yield* <promise>` would throw
108
- * `yield* (intermediate value)… is not async iterable` on every call.
112
+ * watermark. Publication is KICKED OFF FIRE-AND-FORGET (a plain non-
113
+ * awaited `publishRandomWorking(ctx)` call placed BEFORE the synchronous
114
+ * `return next()`) so the listener itself STAYS SYNC and can directly hand
115
+ * back the real stream; `publishRandomWorking` swallows all of its own
116
+ * rejections internally, so the fire-and-forget form leaks no unhandled
117
+ * rejection. It never touches `payload` (the deep-frozen seed — left
118
+ * untouched per the historical-note section above), so it can never stall
119
+ * or corrupt the stream.
109
120
  *
110
121
  * @param {import('@deepseek-ai/cordis').Context} ctx
111
122
  * @returns {boolean} whether this call actually performed the (once-only)
112
- * registration. `false` indicates it was a no-op re-entry (already
113
- * installed) or a registration failure (not installed; a later re-entry
114
- * will retry).
123
+ * registration. `false` indicates it was a no-op re-entry (already installed)
124
+ * or a registration failure (not installed; a later re-entry will retry).
115
125
  */
116
126
 
117
127
  import { publishRandomWorking } from '../core/ui-signal.js'
@@ -131,10 +141,10 @@ export function registerLlmStreamHook(ctx) {
131
141
  // internally (guaranteed side-effect-free w.r.t. the waterfall), so
132
142
  // kicking it off without awaiting cannot leak an unhandled rejection.
133
143
  // `payload` is the deep-frozen GenerateOptions seed — NEVER mutated (see
134
- // the two-hard-walls note in the module header); the publication touches
135
- // none of that (pure settings-write on the `liveUi` field, mirrored live
136
- // to the browser so it can repaint the `TurnStatus` node). `void payload`
137
- // documents the deliberate non-use.
144
+ // the historical-note section in the module header); the publication
145
+ // touches none of that (pure settings-write on the `liveUi` field,
146
+ // mirrored live to the browser so it can repaint the `TurnStatus` node).
147
+ // `void payload` documents the deliberate non-use.
138
148
  ctx.on('llm/stream', (payload, next) => {
139
149
  void payload
140
150
  publishRandomWorking(ctx) // fire-and-forget: sync kick-off, async settle
@@ -142,10 +152,8 @@ export function registerLlmStreamHook(ctx) {
142
152
  })
143
153
  installed = true
144
154
  return true
145
- } catch {
146
- // Registration failure (rare: e.g. a context without a usable `on`) is
147
- // fatal to NOTHING — the plugin simply never installs this hook. We DO
148
- // NOT mark `installed`, so a later re-entry retries.
155
+ } catch (err) {
156
+ ctx.logger?.warn('[force-compact] wire-rewrite install failed:', err?.message ?? err)
149
157
  return false
150
158
  }
151
159
  }