@falling-ts/dsh-force-compact 0.2.0

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,188 @@
1
+ /**
2
+ * Tool-pairing balance over a session surface — ported verbatim (plain JS, no
3
+ * type layer) from the official `@deepseek-ai/dsh-compaction` tool-pairing
4
+ * ledger (`deepseek-harness/packages/compaction/compaction/src/tool-pairing.ts`).
5
+ *
6
+ * Compaction changes surface positions, so safe cuts are derived from
7
+ * tool-call/result content in current surface order rather than step markers.
8
+ *
9
+ * Balance definition (official semantics):
10
+ * • an `assistant/message` event contributes +N where N is the number of
11
+ * `tool-call` content blocks in `data.message.content`;
12
+ * • a `tool/result` event contributes -1;
13
+ * • every other event contributes 0.
14
+ * A surface CUT (between two consecutive surface positions, or before the
15
+ * first / after the last) is BALANCED when the running in-progress tool-call
16
+ * count at that cut is 0 — i.e. no unanswered tool call straddles the cut.
17
+ * A surface of N nodes therefore has N+1 cuts; the leading cut (before the
18
+ * first node) is trivially balanced.
19
+ *
20
+ * The balance table is computed incrementally and cached PER SESSION behind a
21
+ * `WeakMap` keyed on the session object (mirrors the official
22
+ * `balanceCacheBySession`). The cache advances only over the NEW suffix of the
23
+ * surface (appends), so repeated reads over a growing surface stay near-O(new
24
+ * nodes). A surface REPLACEMENT (compactation checkpoint landing) bumps the
25
+ * session's `replaceGeneration`, which forces a full rebuild from scratch —
26
+ * exactly the official rebuild path ("the same fold started from the
27
+ * empty-surface state").
28
+ *
29
+ * Corrupt-surface handling matches the official module: a surface sequence
30
+ * whose log slot holds a different event (`eventForSeq` mismatch) or a
31
+ * `tool/result` with no preceding open call THROWS, so a corrupted log can
32
+ * never leave a partially-advanced cache state behind (the tail is validated
33
+ * BEFORE the live cache mutates). Callers in this plugin wrap the public
34
+ * predicates in safe envelopes that degrade to "assume balanced" rather than
35
+ * throwing into the compaction path — see `safe*` variants below.
36
+ *
37
+ * @module @falling-ts/dsh-force-compact/pairing
38
+ */
39
+
40
+ /**
41
+ * Incremental balance state for one session surface generation.
42
+ * @typedef {Object} BalanceCache
43
+ * @property {number} generation Surface rewrite generation this state
44
+ * describes (`session.surface.replaceGeneration`).
45
+ * @property {ReadonlyArray<boolean>} cutBalanced Balance of every surface cut
46
+ * in current order: a surface of N sequences has N + 1 cuts, entry `i`
47
+ * being the cut before sequence `i` and the final entry the cut after the
48
+ * surface tail.
49
+ * @property {Map<number, number>} indexBySeq Current surface position of each
50
+ * event seq, indexing {@link BalanceCache#cutBalanced}.
51
+ * @property {number} inProgressToolCalls In-progress tool-call count after
52
+ * the processed surface tail.
53
+ */
54
+
55
+ const balanceCacheBySession = new WeakMap()
56
+
57
+ /** How one surface event changes the in-progress tool-call count (official `eventDelta`). */
58
+ export function eventDelta(event) {
59
+ const type = event && event.type
60
+ if (type === 'assistant/message') {
61
+ const message = event.data && event.data.message
62
+ const content = (message && Array.isArray(message.content)) ? message.content : []
63
+ return content.filter(block => block && block.type === 'tool-call').length
64
+ }
65
+ if (type === 'tool/result') return -1
66
+ return 0
67
+ }
68
+
69
+ /** Read and validate the event named by a surface sequence (official `eventForSeq`). */
70
+ function eventForSeq(events, seq) {
71
+ const event = events[seq]
72
+ if (event === undefined || event.seq !== seq) {
73
+ throw new Error(`tool-pairing balance: surface seq ${seq} has no matching session event (corrupt surface)`)
74
+ }
75
+ return event
76
+ }
77
+
78
+ /** Fold surface sequences not yet in the cache into its balance state (official `extendCache`). */
79
+ function extendCache(session, cache, seqs) {
80
+ const processed = cache.cutBalanced.length - 1
81
+ const tail = seqs.slice(processed)
82
+ // Validate the unseen tail BEFORE mutating the live cache, so a corrupt
83
+ // append cannot leave a partially advanced state behind.
84
+ const events = session.events
85
+ const pendingCuts = []
86
+ let inProgressToolCalls = cache.inProgressToolCalls
87
+ for (const seq of tail) {
88
+ inProgressToolCalls += eventDelta(eventForSeq(events, seq))
89
+ if (inProgressToolCalls < 0) {
90
+ throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`)
91
+ }
92
+ pendingCuts.push(inProgressToolCalls === 0)
93
+ }
94
+
95
+ tail.forEach((seq, offset) => cache.indexBySeq.set(seq, processed + offset))
96
+ cache.cutBalanced = cache.cutBalanced.concat(pendingCuts)
97
+ cache.inProgressToolCalls = inProgressToolCalls
98
+ return cache
99
+ }
100
+
101
+ /** Balance state synchronized with the current session surface (official `balanceCache`). */
102
+ function balanceCache(session) {
103
+ const surface = session.surface
104
+ const seqs = surface.nodes
105
+ const generation = (surface.replaceGeneration === undefined) ? 0 : surface.replaceGeneration
106
+ const cached = balanceCacheBySession.get(session)
107
+
108
+ if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > seqs.length) {
109
+ // A rebuild is the same fold started from the empty-surface state, whose
110
+ // single leading cut is trivially balanced.
111
+ const rebuilt = extendCache(session, {
112
+ generation,
113
+ cutBalanced: [true],
114
+ indexBySeq: new Map(),
115
+ inProgressToolCalls: 0,
116
+ }, seqs)
117
+ balanceCacheBySession.set(session, rebuilt)
118
+ return rebuilt
119
+ }
120
+ if (cached.cutBalanced.length - 1 < seqs.length) return extendCache(session, cached, seqs)
121
+ return cached
122
+ }
123
+
124
+ /** Balance of the cut at a sequence's position plus offset; rejects foreign seqs (official `cutBalance`). */
125
+ function cutBalance(cache, seq, offset) {
126
+ const index = cache.indexBySeq.get(seq)
127
+ const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset]
128
+ if (balanced === undefined) {
129
+ throw new Error(`tool-pairing balance: surface seq ${seq} not found`)
130
+ }
131
+ return balanced
132
+ }
133
+
134
+ /**
135
+ * Whether the cut immediately BEFORE a current surface sequence is tool-pairing
136
+ * balanced (official `toolPairingBalancedBefore`). THROWS on a corrupt surface
137
+ * or a seq absent from the current surface — use {@link toolPairingBalancedBeforeSafe}
138
+ * on plugin hot paths.
139
+ * @param {import('@deepseek-ai/dsh-session').Session} session
140
+ * @param {number} seq
141
+ * @returns {boolean}
142
+ */
143
+ export function toolPairingBalancedBefore(session, seq) {
144
+ return cutBalance(balanceCache(session), seq, 0)
145
+ }
146
+
147
+ /**
148
+ * Whether the cut immediately AFTER a current surface sequence is tool-pairing
149
+ * balanced (official `toolPairingBalancedAfter`). THROWING variant — see the
150
+ * `Before` twin.
151
+ * @param {import('@deepseek-ai/dsh-session').Session} session
152
+ * @param {number} seq
153
+ * @returns {boolean}
154
+ */
155
+ export function toolPairingBalancedAfter(session, seq) {
156
+ return cutBalance(balanceCache(session), seq, 1)
157
+ }
158
+
159
+ /**
160
+ * Safe variant for plugin hot paths: identical math, but a corrupt-surface
161
+ * throw DETERMINES "this cut is balanced" (returns `true`) so a damaged log
162
+ * degrades to "attempt the compaction" (where the session core's own replace
163
+ * validation is the last line of defense) instead of wedging the whole
164
+ * compaction path in a perpetual selection-failure loop.
165
+ * @param {import('@deepseek-ai/dsh-session').Session} session
166
+ * @param {number} seq
167
+ * @returns {boolean}
168
+ */
169
+ export function toolPairingBalancedBeforeSafe(session, seq) {
170
+ try {
171
+ return toolPairingBalancedBefore(session, seq)
172
+ } catch (error) {
173
+ const message = (error instanceof Error) ? error.message : String(error)
174
+ try { console.warn(`[force-compact] pairing-ledger degraded (assuming balanced before seq ${seq}): ${message}`) } catch { /* never throw out */ }
175
+ return true
176
+ }
177
+ }
178
+
179
+ /** Safe trailing-cut twin of {@link toolPairingBalancedBeforeSafe}. */
180
+ export function toolPairingBalancedAfterSafe(session, seq) {
181
+ try {
182
+ return toolPairingBalancedAfter(session, seq)
183
+ } catch (error) {
184
+ const message = (error instanceof Error) ? error.message : String(error)
185
+ try { console.warn(`[force-compact] pairing-ledger degraded (assuming balanced after seq ${seq}): ${message}`) } catch { /* never throw out */ }
186
+ return true
187
+ }
188
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * dsh-force-compact tunables.
3
+ *
4
+ * These are the plugin's own compaction policy knobs. They are deliberately
5
+ * fixed constants (not cordis `Config` fields): a standalone Host listener has
6
+ * no `Config` schema, and a deployment that wants a different policy points its
7
+ * composition at the `compaction` service's own `retainRatio` / `summarizationModel`
8
+ * instead — see AGENTS.md for why.
9
+ * @module @falling-ts/dsh-force-compact/config
10
+ */
11
+
12
+ /** Minimum surface nodes before any compaction is considered. */
13
+ export const MIN_NODES = 6
14
+
15
+ /** Fraction of the recent tail (by surface-node count) retained verbatim and never compacted. */
16
+ export const RETAIN_RATIO = 0.25
17
+
18
+ /** Compact only when the compactable prefix (all but the retained tail) spans at least this many surface nodes. */
19
+ export const MIN_COMPACTABLE_NODES = 4
20
+
21
+ /** Generation cap for the summarization call. */
22
+ export const MAX_SUMMARY_TOKENS = 16384
23
+
24
+ /**
25
+ * Resolve the full config object for a compaction round.
26
+ * @returns {Readonly<{minNodes: number, retainRatio: number, minCompactableNodes: number, maxSummaryTokens: number}>}
27
+ */
28
+ export function resolveConfig() {
29
+ return Object.freeze({
30
+ minNodes: MIN_NODES,
31
+ retainRatio: RETAIN_RATIO,
32
+ minCompactableNodes: MIN_COMPACTABLE_NODES,
33
+ maxSummaryTokens: MAX_SUMMARY_TOKENS,
34
+ })
35
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * dsh-force-compact shared reader for the official `projectedTokens` reading.
3
+ *
4
+ * WHY THIS MODULE
5
+ * ---------------
6
+ * Every threshold gate, region-selection budget basis, and diagnostic line in
7
+ * the plugin must key off the SAME number the harness renders in the bottom-right
8
+ * corner (`上下文已用 xx% / ~xxK / xxK`). That rendered figure IS the
9
+ * `projectedTokens` member of the host `contextPressure` projection — the
10
+ * provider-anchored sample plus the surface movement since the sample. Reading
11
+ * it through the official registry keeps a single definition of the pressure
12
+ * basis alive inside the plugin instead of a parallel heuristic drifting from
13
+ * upstream revisions.
14
+ *
15
+ * WHERE IT COMES FROM
16
+ * --------------------
17
+ * `ctx.get('sessionProjections')` is the host registry that DRIVES every
18
+ * registered projection unit forward eagerly over committed session events
19
+ * (framework-owned watermark cache). `snapshot(session).values.contextPressure`
20
+ * hands back the SAME wire object the host broadcasts to the browser as
21
+ * `session/projection` frames — already validated against the unit's own
22
+ * `viewSchema`, so the plugin never re-implements the anchor math. Fields are
23
+ * individually optional (`pressureTokens` / `projectedTokens` /
24
+ * `contextWindow`): absent until a provider reports usage.
25
+ *
26
+ * SEMANTICS (mirrors the official JSDoc — read before building gates on this)
27
+ * ----------------------------------------------------------------------------
28
+ * The three members are INDEPENDENT last-wins records, not one atomic request
29
+ * observation: switching models can pair a fresh capacity with the previous
30
+ * route's pressure until the next request reports usage. Official stance —
31
+ * this is a USER-FACING REFERENCE, not a billing or gating input. The plugin
32
+ * therefore treats `projectedTokens` as the authoritative pressure basis but
33
+ * FAILS OPEN (returns `undefined`) when it is absent, so a session that has
34
+ * not reported usage yet still degrades gracefully to the char-based
35
+ * estimator at each call site instead of blocking.
36
+ *
37
+ * CALIBER NOTES FOR CALLERS
38
+ * -------------------------
39
+ * `projectedTokens` = `max(0, pressureTokens + surfaceTokens −
40
+ * sampledSurfaceTokens)`. The delta term is estimated at the meter's fixed
41
+ * density (CHARS_PER_TOKEN=4), so the figure UNDERCOUNTS heavy-CJK / tool-JSON
42
+ * content relative to the pure `surfaceTokens` sum — a deliberate provider
43
+ * anchor the meter prefers over the unanchored sum. Gate thresholds calibrated
44
+ * against the old `surfaceTokens` basis sit slightly higher (fewer triggers)
45
+ * after this swap; that is intended behaviour, not a bug.
46
+ *
47
+ * COST CONTRACT
48
+ * -------------
49
+ * Sync, pure read. First touch of a freshly-restarted long session replays the
50
+ * whole log once (lazy fold seeding) — O(events); every later call in the same
51
+ * process hits the eager-fold watermark cache — effectively O(1). Call sites
52
+ * are the pre-step gate (once per model step) and the retained-tail path (rare
53
+ * compaction rounds), so the hot-path cost is negligible.
54
+ *
55
+ * FAILURE MODES — ALL RESOLVED TO `undefined` (never throw)
56
+ * ---------------------------------------------------------
57
+ * • `ctx` / `ctx.get` absent or not a function → `undefined`
58
+ * • `sessionProjections` registry not mounted (trimmed compositions) → `undefined`
59
+ * • `snapshot` throws on a transient backend fault → caught, `undefined`
60
+ * • `session` unusable → `undefined`
61
+ * • `contextPressure` unit not folded for this session → `undefined`
62
+ * • unit present but `projectedTokens` still absent (no usage sample yet) → `undefined`
63
+ *
64
+ * Callers pair a `=== undefined` result with their existing char-estimator
65
+ * fallback (see `estimateSessionTokens` / `estimateSurfaceTokensLocal`), so a
66
+ * degraded registry never blocks a model request or a compaction commit.
67
+ *
68
+ * @module @falling-ts/dsh-force-compact/projected
69
+ */
70
+
71
+ /**
72
+ * Read the official `projectedTokens` for one session — the SAME number the
73
+ * harness renders in the bottom-right corner.
74
+ *
75
+ * Fail-open by design (see module doc): every absence/degradation resolves to
76
+ * `undefined` rather than propagating. Pair the result with a char-based
77
+ * estimator fallback at each call site.
78
+ *
79
+ * @param {object} ctx cordis context (Host listener `this` / apply ctx).
80
+ * @param {object|undefined} session live session handle.
81
+ * @returns {number|undefined} the official `projectedTokens` reading, or
82
+ * `undefined` when the registry is unavailable, the snapshot failed, the
83
+ * unit is not folded for this session, or no usage sample has been captured
84
+ * yet (all fail-open — caller decides the fallback).
85
+ */
86
+ export function getProjectedTokens(ctx, session) {
87
+ try {
88
+ if (typeof ctx?.get !== 'function') return undefined
89
+ const registry = ctx.get('sessionProjections')
90
+ if (registry === undefined || registry === null) return undefined
91
+ if (typeof registry.snapshot !== 'function') return undefined
92
+ if (session === undefined || session === null) return undefined
93
+ const snap = registry.snapshot(session)
94
+ const cp = snap && snap.values && snap.values.contextPressure
95
+ if (cp === undefined || cp === null) return undefined
96
+ const projected = cp.projectedTokens
97
+ return (typeof projected === 'number' && Number.isFinite(projected)) ? projected : undefined
98
+ } catch {
99
+ return undefined
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Classify WHY {@link getProjectedTokens} resolved to `undefined` for one
105
+ * session — a DIAGNOSTIC aid that reproduces the same read but labels the exact
106
+ * failure tier, so an operator can tell apart "registry/service not reachable
107
+ * from this context" from "reachable but the session simply has no usage sample
108
+ * yet". Pure, sync, never throws, returns a short stable reason string (or
109
+ * `'available'` with the numeric reading attached when the read succeeds).
110
+ *
111
+ * @param {object} ctx cordis context.
112
+ * @param {object|undefined} session live session handle.
113
+ * @returns {string} one of: `available:<n>` / `no-ctx.get` / `registry-absent` /
114
+ * `registry-no-snapshot` / `session-unusable` / `snap-threw` /
115
+ * `unit-not-folded` / `no-usage-sample-yet`.
116
+ */
117
+ export function diagnoseProjectedTokensAbsence(ctx, session) {
118
+ try {
119
+ if (typeof ctx?.get !== 'function') return 'no-ctx.get'
120
+ const registry = ctx.get('sessionProjections')
121
+ if (registry === undefined || registry === null) return 'registry-absent'
122
+ if (typeof registry.snapshot !== 'function') return 'registry-no-snapshot'
123
+ if (session === undefined || session === null) return 'session-unusable'
124
+ let snap
125
+ try {
126
+ snap = registry.snapshot(session)
127
+ } catch {
128
+ return 'snap-threw'
129
+ }
130
+ const cp = snap && snap.values && snap.values.contextPressure
131
+ if (cp === undefined || cp === null) return 'unit-not-folded'
132
+ const projected = cp.projectedTokens
133
+ return (typeof projected === 'number' && Number.isFinite(projected))
134
+ ? `available:${projected}`
135
+ : 'no-usage-sample-yet'
136
+ } catch {
137
+ return 'unexpected-throw'
138
+ }
139
+ }