dsh-compact-arsenal 0.2.0 → 0.3.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.
Files changed (4) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/client.js +9 -0
  3. package/index.js +162 -197
  4. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,59 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
5
5
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.0] — 2026-11-11
8
+
9
+ ### Fixed
10
+
11
+ - **Surface replacements could land outside an open turn and make the session
12
+ unloadable.** The `agent/pre-step` handler called `rewrite()` without
13
+ `await`, so a slow model batch kept appending after the handler returned and
14
+ after its turn had closed. Of 100 affected rows in one session, 5 sat in the
15
+ gap between turns and were rejected by the log loader on resume; the other 95
16
+ slipped through while claiming a turn they did not belong to.
17
+ - **Replacements carried the replaced node's turn.** `applyRewrittenText()` wrote
18
+ `turn: event.data.turn`, i.e. the turn the old node was created in — often
19
+ dozens of turns earlier. The row now carries the turn that owns the write, and
20
+ keeps the node's step only when both turns match.
21
+ - **A cancelled step still wrote to the log.** `compressSection()` now checks
22
+ `signal.throwIfAborted()` before each batch and each append.
23
+ - **Two turns of one session could rewrite concurrently.** `lastProcessedTurn`
24
+ is marked when a rewrite starts but appends land seconds later; a per-session
25
+ in-flight set now keeps a second pass from starting.
26
+ - **A turn closing mid-pass is detected.** Every append is preceded by a
27
+ turn-boundary check read from the log tail, so a write that would land after
28
+ `turn/end` is skipped instead of corrupting the log.
29
+
30
+ ### Removed
31
+
32
+ - The unused single-item rewrite path (`rewriteAssistant*`, `rewriteToolResult*`,
33
+ `rebuildWithModel`) — dead code that carried the same stale-turn bug and could
34
+ have been reintroduced by accident.
35
+
36
+ ### Added
37
+
38
+ - `test/turn-position.test.mjs`: pins the turn/step semantics of a replacement
39
+ and the "is the turn still open" decision.
40
+ - `test/verify-fix.cjs`: asserts all of the above against the shipped source, so
41
+ a regression fails a test rather than a user's session.
42
+
43
+ ## [0.2.1] — 2026-11-10
44
+
45
+ ### Fixed
46
+
47
+ - The pressure gate now reads the provider-reported prompt size
48
+ (`ctx.tokenMeter.contextPressure()`), falling back to the heuristic estimate
49
+ only when no provider figure exists. The heuristic prices text at ~4
50
+ characters per token, which undercounts CJK and JSON by about 12%: a session
51
+ measured at 51.9% of its window was read as 45.4% and never tripped a 0.5
52
+ threshold.
53
+ - The panel shows both numbers (真实 / 估算) so a gate decision can be explained.
54
+
55
+ ### Added
56
+
57
+ - `docs/threshold-notes.md`: a field record of one session, the exact crossing
58
+ sample, every rewrite it produced, and the two measurement bases side by side.
59
+
7
60
  ## [0.2.0] — 2026-11-03
8
61
 
9
62
  ### Added
package/client.js CHANGED
@@ -269,6 +269,7 @@ window.__ModuleLoader__.load({
269
269
  const [lastFailure, setLastFailure] = useState(null)
270
270
  const [archiveRoot, setArchiveRoot] = useState(null)
271
271
  const [lastArchivePath, setLastArchivePath] = useState(null)
272
+ const [pressure, setPressure] = useState(null)
272
273
  const timer = useRef(null)
273
274
  const latest = useRef(emptyConfig)
274
275
 
@@ -285,6 +286,7 @@ window.__ModuleLoader__.load({
285
286
  setLastFailure(body.lastFailure ?? null)
286
287
  setArchiveRoot(body.archiveRoot ?? null)
287
288
  setLastArchivePath(body.lastArchivePath ?? null)
289
+ setPressure(body.pressure ?? null)
288
290
  setStatus(body.editable === false ? 'readonly' : 'ready')
289
291
  })
290
292
  .catch((error) => {
@@ -314,6 +316,7 @@ window.__ModuleLoader__.load({
314
316
  setLastFailure(body.lastFailure ?? null)
315
317
  if (body.archiveRoot) setArchiveRoot(body.archiveRoot)
316
318
  if (body.lastArchivePath !== undefined) setLastArchivePath(body.lastArchivePath)
319
+ if (body.pressure !== undefined) setPressure(body.pressure)
317
320
  setTotalApplied(body.totalApplied === undefined ? null : body.totalApplied)
318
321
  setStatus('ready')
319
322
  setMessage('已保存')
@@ -485,6 +488,12 @@ window.__ModuleLoader__.load({
485
488
  ? h('p', { key: 'fail', style: { margin: 0, fontSize: 11, color: WARN, lineHeight: 1.6 } },
486
489
  `失败 ${stats.failed} 次(未落盘,不计入节省):${lastFailure || '原因未记录'}`)
487
490
  : null,
491
+ pressure && typeof pressure.estimated === 'number' && pressure.estimated > 0
492
+ ? h('p', { key: 'pressure', style: { margin: 0, fontSize: 11, color: MUTED, lineHeight: 1.6 } },
493
+ pressure.window
494
+ ? `上下文压力:真实 ${(pressure.reported ?? pressure.estimated).toLocaleString()}(${((pressure.reported ?? pressure.estimated) / pressure.window * 100).toFixed(1)}%)· 估算 ${pressure.estimated.toLocaleString()}(${(pressure.estimated / pressure.window * 100).toFixed(1)}%)· 窗口 ${pressure.window.toLocaleString()}`
495
+ : `上下文压力:真实 ${(pressure.reported ?? pressure.estimated).toLocaleString()} · 估算 ${pressure.estimated.toLocaleString()}`)
496
+ : null,
488
497
  archiveRoot
489
498
  ? h('p', { key: 'archive', style: { margin: 0, fontSize: 11, color: MUTED, lineHeight: 1.6, wordBreak: 'break-all' } },
490
499
  `归档目录:${archiveRoot}${lastArchivePath ? `(最新:${lastArchivePath})` : ''}(共 ${stats.archived ?? 0} 份)`)
package/index.js CHANGED
@@ -21,12 +21,8 @@ import {
21
21
  REASONING_MARKER,
22
22
  TOOL_MARKER,
23
23
  alreadyRewritten,
24
- clearReasoning,
25
- describeChange,
26
- pruneToolContent,
27
- trimReasoning,
28
24
  } from './lib/policy.js'
29
- import { summarize, summarizeBatch, summarizeMiddle } from './lib/summarize.js'
25
+ import { summarize, summarizeBatch } from './lib/summarize.js'
30
26
  import { archiveRewrite, archiveRoot } from './lib/archive.js'
31
27
 
32
28
  export const name = 'compact-arsenal'
@@ -189,17 +185,38 @@ export function apply(ctx, rawConfig) {
189
185
  /** Last turn already processed, per session, so one turn is rewritten at most once. */
190
186
  const lastProcessedTurn = new Map()
191
187
  const stats = { reasoning: 0, tools: 0, chars: 0, failed: 0, archived: 0 }
188
+ /**
189
+ * Last observed pressure inputs, so the panel can show both the provider's
190
+ * number and the heuristic estimate that the gate falls back to.
191
+ */
192
+ const pressure = { reported: null, estimated: 0, window: null }
192
193
  const archive = { root: archiveRoot(config.archiveDir), lastPath: null }
193
194
 
194
195
  if (config.enabled) {
196
+ /**
197
+ * Sessions with a rewrite in flight.
198
+ *
199
+ * `lastProcessedTurn` is marked synchronously when a rewrite starts, but the
200
+ * append lands seconds later (a model batch can take a while). Without this
201
+ * guard two turns of the same session can rewrite concurrently, which writes
202
+ * a replacement whose `turn` belongs to a turn that has already ended.
203
+ */
204
+ const inFlight = new Set()
195
205
  ctx.on('agent/pre-step', async (payload, next) => {
196
206
  const decision = await next()
197
207
  if (decision?.kind !== 'enter') return decision
208
+ if (inFlight.has(payload.agent.id)) return decision
209
+ inFlight.add(payload.agent.id)
198
210
  try {
199
- rewrite(ctx, config, payload, lastProcessedTurn, stats, archive)
211
+ // Awaiting matters: a rewrite left running after this handler returns
212
+ // can append while its turn is closing, and a rejection would escape
213
+ // this catch entirely.
214
+ await rewrite(ctx, config, payload, lastProcessedTurn, stats, archive, pressure)
200
215
  } catch (error) {
201
216
  // A rewrite failure must never break the step.
202
217
  ctx.logger?.warn?.(`[compact-arsenal] rewrite skipped: ${error?.message ?? error}`)
218
+ } finally {
219
+ inFlight.delete(payload.agent.id)
203
220
  }
204
221
  return decision
205
222
  })
@@ -207,7 +224,7 @@ export function apply(ctx, rawConfig) {
207
224
 
208
225
  // The panel needs a read/write channel, so these routes exist even while the
209
226
  // plugin is switched off: that is how it gets switched back on.
210
- registerPanelRoutes(ctx, config, stats, archive)
227
+ registerPanelRoutes(ctx, config, stats, archive, pressure)
211
228
  }
212
229
 
213
230
  /** Route prefix owned by this plugin. */
@@ -219,7 +236,7 @@ const API_PREFIX = '/compact-arsenal/api'
219
236
  * @param {any} config - the config this instance mounted with.
220
237
  * @param {{ reasoning: number, tools: number, chars: number }} stats - live counters.
221
238
  */
222
- function registerPanelRoutes(ctx, config, stats, archive) {
239
+ function registerPanelRoutes(ctx, config, stats, archive, pressure) {
223
240
  const webServer = ctx.get('webServer')
224
241
  const configEditor = ctx.get('configEditor')
225
242
  if (webServer === undefined) return
@@ -240,6 +257,7 @@ function registerPanelRoutes(ctx, config, stats, archive) {
240
257
  lastFailure,
241
258
  archiveRoot: archive.root,
242
259
  lastArchivePath: archive.lastPath,
260
+ pressure,
243
261
  })
244
262
  })
245
263
 
@@ -274,6 +292,7 @@ function registerPanelRoutes(ctx, config, stats, archive) {
274
292
  lastFailure,
275
293
  archiveRoot: archive.root,
276
294
  lastArchivePath: archive.lastPath,
295
+ pressure,
277
296
  })
278
297
  })
279
298
  }
@@ -455,8 +474,8 @@ function transcriptLine(event) {
455
474
  * @param {{ agent: { id: string }, turn: number }} payload - pre-step payload.
456
475
  * @param {Map<string, number>} lastProcessedTurn - per-session guard.
457
476
  */
458
- function rewrite(ctx, config, payload, lastProcessedTurn, stats, archive) {
459
- return rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, archive)
477
+ function rewrite(ctx, config, payload, lastProcessedTurn, stats, archive, pressure) {
478
+ return rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, archive, pressure)
460
479
  }
461
480
 
462
481
  /**
@@ -468,7 +487,7 @@ function rewrite(ctx, config, payload, lastProcessedTurn, stats, archive) {
468
487
  * @param {{ reasoning: number, tools: number, chars: number }} stats - live counters.
469
488
  * @returns {Promise<void>} resolves when every rewrite of this turn landed.
470
489
  */
471
- async function rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, archive) {
490
+ async function rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, archive, pressure) {
472
491
  if (!config.total.enabled && !config.reasoning.enabled && !config.tools.enabled) return
473
492
 
474
493
  const session = ctx.sessions.get(payload.agent.id)
@@ -478,6 +497,12 @@ async function rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, arch
478
497
 
479
498
  lastProcessedTurn.set(session.id, payload.turn)
480
499
 
500
+ // Snapshot the inputs once per turn so the panel can explain a gate decision.
501
+ const snapshot = pressureSnapshot(ctx, session)
502
+ pressure.reported = snapshot.reported ?? null
503
+ pressure.estimated = snapshot.estimated
504
+ pressure.window = session.requestContext?.()?.contextWindow ?? null
505
+
481
506
  if (config.total.enabled && underPressure(ctx, session, config.total.pressureRatio)) {
482
507
  await totalPass(ctx, session, config.total, payload.turn, payload.signal, stats, archive)
483
508
  }
@@ -491,8 +516,22 @@ async function rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, arch
491
516
  else if (event?.type === 'tool/result') toolSeqs.push(seq)
492
517
  }
493
518
 
519
+ /**
520
+ * Whether the turn that started this pass is still the session's open turn.
521
+ *
522
+ * A surface replacement is only valid while its turn is open: writing after
523
+ * `turn/end` leaves a row in the gap between turns, which the log loader
524
+ * rejects on the next resume. Re-checking before every append keeps a slow
525
+ * model batch from writing into a turn that already closed.
526
+ */
527
+ const stillOpen = () => {
528
+ if (payload.signal?.aborted) return false
529
+ const open = openTurnOf(session)
530
+ return open === undefined || open === payload.turn
531
+ }
532
+
494
533
  if (config.reasoning.enabled && underPressure(ctx, session, config.reasoning.pressureRatio)) {
495
- const saved = await compressSection(ctx, session, config.reasoning, 'reasoning', dropRecent(assistantSeqs, config.reasoning.keepLast), payload.signal, archive)
534
+ const saved = await compressSection(ctx, session, config.reasoning, 'reasoning', dropRecent(assistantSeqs, config.reasoning.keepLast), payload.signal, archive, payload.turn, stillOpen)
496
535
  stats.reasoning += saved.items
497
536
  stats.failed += saved.failed ?? 0
498
537
  stats.chars += saved.chars
@@ -500,7 +539,7 @@ async function rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, arch
500
539
  }
501
540
 
502
541
  if (config.tools.enabled && underPressure(ctx, session, config.tools.pressureRatio)) {
503
- const saved = await compressSection(ctx, session, config.tools, 'tools', dropRecent(toolSeqs, config.tools.keepLast), payload.signal, archive)
542
+ const saved = await compressSection(ctx, session, config.tools, 'tools', dropRecent(toolSeqs, config.tools.keepLast), payload.signal, archive, payload.turn, stillOpen)
504
543
  stats.tools += saved.items
505
544
  stats.failed += saved.failed ?? 0
506
545
  stats.chars += saved.chars
@@ -541,15 +580,46 @@ function sectionText(event, kind) {
541
580
  * @param {string} text - replacement text.
542
581
  * @returns {void}
543
582
  */
544
- function applyRewrittenText(session, event, seq, kind, text) {
583
+ /**
584
+ * Copy of `event.data.turn` replaced by the turn that is open right now.
585
+ *
586
+ * A surface replacement must carry the turn that owns the write, not the turn
587
+ * the replaced node was created in: the node may be dozens of turns old, and a
588
+ * row claiming a closed turn either fails the log's relationship check or slips
589
+ * through while claiming a turn that does not match its position.
590
+ *
591
+ * @param {any} session - live session.
592
+ * @param {any} event - the node being replaced.
593
+ * @param {number} currentTurn - turn owning this write.
594
+ * @returns {{ turn: number, step: number }} position fields for the new row.
595
+ */
596
+ function currentPosition(session, event, currentTurn) {
597
+ const turn = Number.isFinite(currentTurn) ? currentTurn : event.data.turn
598
+ // Keep the step only when the write belongs to the same turn as the node;
599
+ // otherwise the step number of an old turn is meaningless.
600
+ const step = turn === event.data.turn && Number.isFinite(event.data.step) ? event.data.step : 1
601
+ return { turn, step }
602
+ }
603
+
604
+ /**
605
+ * Replace one event's text with a rewritten version, keeping message identity.
606
+ * @param {any} session - live session.
607
+ * @param {any} event - the surface event being replaced.
608
+ * @param {number} seq - its surface seq.
609
+ * @param {'reasoning' | 'tools'} kind - section kind.
610
+ * @param {string} text - replacement text.
611
+ * @param {number} currentTurn - turn that owns this write.
612
+ * @returns {void}
613
+ */
614
+ function applyRewrittenText(session, event, seq, kind, text, currentTurn) {
545
615
  const blocks = event.data?.message?.content
546
616
  const wanted = kind === 'tools' ? 'text' : 'reasoning'
547
617
  const rich = blocks.filter((block) => block?.type !== wanted)
548
618
  const message = { ...event.data.message, content: [{ type: wanted, text }, ...rich] }
619
+ const position = currentPosition(session, event, currentTurn)
549
620
  if (kind === 'tools') {
550
621
  session.append('tool/result', {
551
- turn: event.data.turn,
552
- step: event.data.step,
622
+ ...position,
553
623
  message,
554
624
  ...(event.data.error === undefined ? {} : { error: event.data.error }),
555
625
  ...(event.data.meta === undefined ? {} : { meta: event.data.meta }),
@@ -557,8 +627,7 @@ function applyRewrittenText(session, event, seq, kind, text) {
557
627
  return
558
628
  }
559
629
  session.append('assistant/message', {
560
- turn: event.data.turn,
561
- step: event.data.step,
630
+ ...position,
562
631
  message,
563
632
  ...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
564
633
  stream: event.data.stream ?? [],
@@ -578,9 +647,12 @@ function applyRewrittenText(session, event, seq, kind, text) {
578
647
  * @param {'reasoning' | 'tools'} kind - section kind.
579
648
  * @param {readonly number[]} seqs - candidate surface seqs, oldest first.
580
649
  * @param {AbortSignal | undefined} signal - step cancellation.
581
- * @returns {Promise<{ items: number, chars: number }>} rewrites landed and text dropped.
650
+ * @param {{ root: string, lastPath: string | null }} archive - archive target.
651
+ * @param {number} currentTurn - turn that owns these writes.
652
+ * @param {(() => boolean) | undefined} stillOpen - re-checked before every append.
653
+ * @returns {Promise<{ items: number, chars: number, failed: number, archived: number }>} what landed.
582
654
  */
583
- async function compressSection(ctx, session, policy, kind, seqs, signal, archive) {
655
+ async function compressSection(ctx, session, policy, kind, seqs, signal, archive, currentTurn, stillOpen) {
584
656
  const marker = sectionMarker(kind, policy.mode === 'model')
585
657
  const minTotal = Number.isFinite(policy.minTotalChars) ? policy.minTotalChars : 15000
586
658
  const perItemFloor = Number.isFinite(policy.minChars) ? policy.minChars : 0
@@ -617,6 +689,10 @@ async function compressSection(ctx, session, policy, kind, seqs, signal, archive
617
689
  let archived = 0
618
690
  let index = 0
619
691
  while (index < candidates.length && items < maxItems && chars < maxChars) {
692
+ // The step may have been cancelled while a model batch was in flight; never
693
+ // write into a session whose turn has been torn down.
694
+ signal?.throwIfAborted?.()
695
+ if (stillOpen !== undefined && !stillOpen()) return { items, chars, failed, archived }
620
696
  const batch = []
621
697
  let batchSize = 0
622
698
  while (index < candidates.length && batch.length < 8 && batchSize < batchChars) {
@@ -628,8 +704,11 @@ async function compressSection(ctx, session, policy, kind, seqs, signal, archive
628
704
  const summaries = policy.mode === 'model'
629
705
  ? await summarizeBatchSafe(ctx, modelCfg, batch.map((item) => item.text), signal)
630
706
  : null
707
+ signal?.throwIfAborted?.()
631
708
 
632
709
  for (let i = 0; i < batch.length; i += 1) {
710
+ signal?.throwIfAborted?.()
711
+ if (stillOpen !== undefined && !stillOpen()) return { items, chars, failed, archived }
633
712
  const item = batch[i]
634
713
  const summary = summaries === null ? null : summaries[i]
635
714
  const replacement = buildReplacement(item.text, policy, marker, summary)
@@ -637,7 +716,7 @@ async function compressSection(ctx, session, policy, kind, seqs, signal, archive
637
716
  try {
638
717
  // The append is the only proof that the rewrite landed, so the counters
639
718
  // move strictly after it returns.
640
- applyRewrittenText(session, item.event, item.seq, kind, replacement)
719
+ applyRewrittenText(session, item.event, item.seq, kind, replacement, currentTurn)
641
720
  } catch (error) {
642
721
  failed += 1
643
722
  lastFailure = `#${item.seq}: ${error?.message ?? error}`
@@ -716,203 +795,89 @@ function underPressure(ctx, session, ratio) {
716
795
  if (typeof window !== 'number' || window <= 0) return false
717
796
  const r = Number.isFinite(ratio) ? Math.min(1, Math.max(0, ratio)) : 0.75
718
797
  if (r <= 0) return true
719
- const measured = ctx.tokenMeter?.measure?.(session)
720
- const total = measured?.totalTokens ?? 0
721
- return total >= window * r
722
- }
723
-
724
- /**
725
- * All but the most recent `keep` entries.
726
- * @param {readonly number[]} seqs - ordered surface seqs.
727
- * @param {number} keep - how many trailing entries to leave untouched.
728
- * @returns {number[]} seqs eligible for rewriting.
729
- */
730
- function dropRecent(seqs, keep) {
731
- const n = Number.isFinite(keep) ? Math.max(0, Math.trunc(keep)) : 0
732
- return n <= 0 ? [...seqs] : seqs.slice(0, Math.max(0, seqs.length - n))
798
+ const used = currentPressureTokens(ctx, session)
799
+ return used >= window * r
733
800
  }
734
801
 
735
802
  /**
736
- * Replace one assistant message with the same message minus (or with trimmed) reasoning.
737
- * @param {any} ctx - plugin context.
738
- * @param {any} session - live session.
739
- * @param {number} seq - surface seq of the assistant message.
740
- * @param {any} policy - reasoning section config.
741
- * @returns {number} characters removed, or 0 when nothing was rewritten.
742
- */
743
- function rewriteAssistant(ctx, session, seq, policy, signal) {
744
- return rewriteAssistantAsync(ctx, session, seq, policy, signal)
745
- }
746
-
747
- /**
748
- * Async body of {@link rewriteAssistant}: `model` mode summarizes the elided middle.
803
+ * Tokens the next request is expected to cost, preferring the provider's own
804
+ * count over the heuristic estimate.
805
+ *
806
+ * The heuristic prices text at ~4 characters per token, which undercounts CJK
807
+ * and JSON by roughly 12%: a session measured at 0.519 of its window can look
808
+ * like 0.454 and never trip the gate. `contextPressure()` carries the prompt
809
+ * size the provider last reported, so that figure wins whenever it exists.
810
+ *
749
811
  * @param {any} ctx - plugin context.
750
812
  * @param {any} session - live session.
751
- * @param {number} seq - surface seq of the assistant message.
752
- * @param {any} policy - reasoning section config.
753
- * @param {AbortSignal | undefined} signal - step cancellation.
754
- * @returns {Promise<number>} characters removed, or 0 when nothing was rewritten.
813
+ * @returns {{ used: number, reported: number | undefined, estimated: number }} the pressure inputs.
755
814
  */
756
- async function rewriteAssistantAsync(ctx, session, seq, policy, signal) {
757
- const event = session.eventAt(seq)
758
- if (event === undefined) return 0
759
- const message = event.data?.message
760
- if (message?.role !== 'assistant' || !Array.isArray(message.content)) return 0
761
-
762
- let next = policy.mode === 'clear'
763
- ? clearReasoning(message.content)
764
- : trimReasoning(message.content, policy)
765
-
766
- if (policy.mode === 'model') {
767
- next = await rebuildWithModel(ctx, message.content, policy, signal, 'reasoning', session.requestContext?.() ?? {})
815
+ function pressureSnapshot(ctx, session) {
816
+ let reported
817
+ try {
818
+ const pressure = ctx.tokenMeter?.contextPressure?.(session)
819
+ if (pressure && typeof pressure === 'object') {
820
+ const value = pressure.projectedTokens ?? pressure.pressureTokens
821
+ if (typeof value === 'number' && Number.isFinite(value) && value > 0) reported = value
822
+ }
823
+ } catch {
824
+ // A missing projection only means "fall back to the estimate".
768
825
  }
769
- if (next === null) return 0
770
-
771
- session.append(
772
- 'assistant/message',
773
- {
774
- turn: event.data.turn,
775
- step: event.data.step,
776
- message: { ...message, content: next },
777
- ...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
778
- stream: event.data.stream ?? [],
779
- },
780
- { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq } },
781
- )
782
- const before = measureBlocks(message.content)
783
- const after = measureBlocks(next)
784
- ctx.logger?.debug?.(describeChange('reasoning', before, after))
785
- return Math.max(0, before - after)
786
- }
787
-
788
- /**
789
- * Replace one oversized tool result with its head and tail.
790
- * @param {any} ctx - plugin context.
791
- * @param {any} session - live session.
792
- * @param {number} seq - surface seq of the tool result.
793
- * @param {any} policy - tools section config.
794
- * @returns {number} characters removed, or 0 when nothing was rewritten.
795
- */
796
- function rewriteToolResult(ctx, session, seq, policy, signal) {
797
- return rewriteToolResultAsync(ctx, session, seq, policy, signal)
826
+ const measured = ctx.tokenMeter?.measure?.(session)
827
+ const estimated = typeof measured?.totalTokens === 'number' ? measured.totalTokens : 0
828
+ return { used: reported ?? estimated, reported, estimated }
798
829
  }
799
830
 
800
831
  /**
801
- * Async body of {@link rewriteToolResult}: `model` mode summarizes the elided middle.
832
+ * Just the token count the gate compares against the window.
802
833
  * @param {any} ctx - plugin context.
803
834
  * @param {any} session - live session.
804
- * @param {number} seq - surface seq of the tool result.
805
- * @param {any} policy - tools section config.
806
- * @param {AbortSignal | undefined} signal - step cancellation.
807
- * @returns {Promise<number>} characters removed, or 0 when nothing was rewritten.
835
+ * @returns {number} pressure tokens.
808
836
  */
809
- async function rewriteToolResultAsync(ctx, session, seq, policy, signal) {
810
- const event = session.eventAt(seq)
811
- if (event === undefined) return 0
812
- const message = event.data?.message
813
- if (message?.role !== 'tool' || !Array.isArray(message.content)) return 0
814
- if (Array.isArray(policy.onlyTools) && policy.onlyTools.length > 0) {
815
- const toolName = resolveToolName(session, event)
816
- if (toolName === undefined || !policy.onlyTools.includes(toolName)) return 0
817
- }
818
-
819
- let next = pruneToolContent(message.content, policy)
820
- if (policy.mode === 'model') {
821
- next = await rebuildWithModel(ctx, message.content, policy, signal, 'tools', session.requestContext?.() ?? {})
822
- }
823
- if (next === null) return 0
824
-
825
- session.append(
826
- 'tool/result',
827
- {
828
- turn: event.data.turn,
829
- step: event.data.step,
830
- message: { ...message, content: next },
831
- ...(event.data.error === undefined ? {} : { error: event.data.error }),
832
- ...(event.data.meta === undefined ? {} : { meta: event.data.meta }),
833
- },
834
- { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] },
835
- )
836
- const before = measureBlocks(message.content)
837
- const after = measureBlocks(next)
838
- ctx.logger?.debug?.(describeChange('tools', before, after))
839
- return Math.max(0, before - after)
840
- }
841
-
842
- /**
843
- * Rebuild one content array with a model-written summary in place of the elided middle.
844
- * Falls back to the mechanical rewrite whenever the model path is unavailable,
845
- * so an unconfigured or failing route never loses the saving entirely.
846
- * @param {any} ctx - plugin context.
847
- * @param {readonly any[]} blocks - original content blocks.
848
- * @param {any} policy - section config carrying `headChars`, `tailChars` and `model`.
849
- * @param {AbortSignal | undefined} signal - step cancellation.
850
- * @param {'reasoning' | 'tools'} kind - which section asked.
851
- * @returns {Promise<any[] | null>} replacement blocks, or `null` when within budget.
852
- */
853
- async function rebuildWithModel(ctx, blocks, policy, signal, kind, routed) {
854
- const kindOfBlock = kind === 'tools' ? 'text' : 'reasoning'
855
- const text = blocks.filter((block) => block?.type === kindOfBlock)
856
- .map((block) => (typeof block.text === 'string' ? block.text : ''))
857
- .join('')
858
- const limit = kind === 'tools' ? policy.thresholdChars : policy.minChars
859
- if ([...text].length <= limit) return null
860
-
861
- // Already summarized once: never summarize a summary (it would compound).
862
- const marker = kind === 'tools' ? TOOL_MARKER : REASONING_MARKER
863
- if (text.includes(marker.trim())) return null
864
-
865
- const mechanical = () => (kind === 'tools' ? pruneToolContent(blocks, policy) : trimReasoning(blocks, policy))
866
- const model = policy.model ?? {}
867
- const summaryCfg = {
868
- ...model,
869
- provider: model.provider || (routed && routed.provider) || '',
870
- model: model.model || (routed && routed.model) || '',
871
- }
872
-
873
- try {
874
- const replaced = await summarizeMiddle(
875
- ctx,
876
- summaryCfg,
877
- text,
878
- { headChars: policy.headChars, tailChars: policy.tailChars, marker },
879
- signal,
880
- )
881
- if (replaced === null) throw new Error('empty-summary')
882
- const rich = blocks.filter((block) => block?.type !== kindOfBlock)
883
- return [{ type: kindOfBlock, text: replaced }, ...rich]
884
- } catch (error) {
885
- ctx.logger?.warn?.(`[compact-arsenal] model ${kind} unavailable, using mechanical: ${error?.message ?? error}`)
886
- return mechanical()
887
- }
837
+ function currentPressureTokens(ctx, session) {
838
+ return pressureSnapshot(ctx, session).used
888
839
  }
889
840
 
890
841
  /**
891
- * Name of the tool that produced one result, from the matching tool call.
842
+ * The turn that is currently open in a session, read from its own log.
843
+ *
844
+ * The last `turn/start` wins unless a matching `turn/end` closed it. Returned
845
+ * as `undefined` when the log carries no turn markers yet, so callers can treat
846
+ * "cannot tell" as "do not block".
847
+ *
892
848
  * @param {any} session - live session.
893
- * @param {any} event - tool/result event.
894
- * @returns {string | undefined} tool name when the call is still in the surface.
849
+ * @returns {number | undefined} the open turn number.
895
850
  */
896
- function resolveToolName(session, event) {
897
- const callId = event.data?.message?.toolCallId ?? event.data?.message?.source?.callId
898
- if (callId === undefined) return undefined
899
- for (const seq of session.surface.nodes) {
900
- const candidate = session.eventAt(seq)
901
- if (candidate?.type === 'tool/call' && candidate.data?.callId === callId) return candidate.data.name
851
+ function openTurnOf(session) {
852
+ // Turn markers are log-only events, never surface nodes, so they are read from
853
+ // the log tail. Scanning backwards keeps this O(recent), not O(session).
854
+ const end = typeof session.seq === 'number' ? session.seq : 0
855
+ let seen = 0
856
+ let lastStart
857
+ for (let seq = end; seq >= 0; seq -= 1) {
858
+ const event = session.eventAt?.(seq)
859
+ if (event === undefined) continue
860
+ seen += 1
861
+ if (event.type === 'turn/end' && lastStart === undefined && event.data?.turn !== undefined) {
862
+ // The newest marker is an end: that turn is already closed.
863
+ return undefined
864
+ }
865
+ if (event.type === 'turn/start') {
866
+ lastStart = event.data?.turn
867
+ return lastStart
868
+ }
869
+ if (seen >= 400) break
902
870
  }
903
871
  return undefined
904
872
  }
905
873
 
906
874
  /**
907
- * Rough size of a content array for logging.
908
- * @param {readonly any[]} blocks - content blocks.
909
- * @returns {number} total characters across text-like blocks.
875
+ * All but the most recent `keep` entries.
876
+ * @param {readonly number[]} seqs - ordered surface seqs.
877
+ * @param {number} keep - how many trailing entries to leave untouched.
878
+ * @returns {number[]} seqs eligible for rewriting.
910
879
  */
911
- function measureBlocks(blocks) {
912
- let total = 0
913
- for (const block of blocks) {
914
- if (typeof block?.text === 'string') total += block.text.length
915
- if (typeof block?.arguments === 'string') total += block.arguments.length
916
- }
917
- return total
918
- }
880
+ function dropRecent(seqs, keep) {
881
+ const n = Number.isFinite(keep) ? Math.max(0, Math.trunc(keep)) : 0
882
+ return n <= 0 ? [...seqs] : seqs.slice(0, Math.max(0, seqs.length - n))
883
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-compact-arsenal",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Configurable context compaction for DeepSeek Harness: three independent sections — whole-context checkpoint, reasoning compression and tool-output pruning, each with its own switch, threshold and method, plus a left-sidebar panel.",
5
5
  "icon": "icon.svg",
6
6
  "author": "TacitSay",
@@ -38,7 +38,7 @@
38
38
  "dsh": ">=0.2.0-rc.2 || >=0.2.1-rc.2"
39
39
  },
40
40
  "scripts": {
41
- "test": "node --test test/*.test.mjs"
41
+ "test": "node --test test/*.test.mjs && node test/verify-fix.cjs index.js"
42
42
  },
43
43
  "keywords": [
44
44
  "deepseek-harness",