dsh-compact-arsenal 0.2.1 → 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 (3) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/index.js +116 -202
  3. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,42 @@ 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
+
7
43
  ## [0.2.1] — 2026-11-10
8
44
 
9
45
  ### Fixed
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'
@@ -197,14 +193,30 @@ export function apply(ctx, rawConfig) {
197
193
  const archive = { root: archiveRoot(config.archiveDir), lastPath: null }
198
194
 
199
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()
200
205
  ctx.on('agent/pre-step', async (payload, next) => {
201
206
  const decision = await next()
202
207
  if (decision?.kind !== 'enter') return decision
208
+ if (inFlight.has(payload.agent.id)) return decision
209
+ inFlight.add(payload.agent.id)
203
210
  try {
204
- rewrite(ctx, config, payload, lastProcessedTurn, stats, archive, pressure)
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)
205
215
  } catch (error) {
206
216
  // A rewrite failure must never break the step.
207
217
  ctx.logger?.warn?.(`[compact-arsenal] rewrite skipped: ${error?.message ?? error}`)
218
+ } finally {
219
+ inFlight.delete(payload.agent.id)
208
220
  }
209
221
  return decision
210
222
  })
@@ -504,8 +516,22 @@ async function rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, arch
504
516
  else if (event?.type === 'tool/result') toolSeqs.push(seq)
505
517
  }
506
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
+
507
533
  if (config.reasoning.enabled && underPressure(ctx, session, config.reasoning.pressureRatio)) {
508
- 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)
509
535
  stats.reasoning += saved.items
510
536
  stats.failed += saved.failed ?? 0
511
537
  stats.chars += saved.chars
@@ -513,7 +539,7 @@ async function rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, arch
513
539
  }
514
540
 
515
541
  if (config.tools.enabled && underPressure(ctx, session, config.tools.pressureRatio)) {
516
- 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)
517
543
  stats.tools += saved.items
518
544
  stats.failed += saved.failed ?? 0
519
545
  stats.chars += saved.chars
@@ -554,15 +580,46 @@ function sectionText(event, kind) {
554
580
  * @param {string} text - replacement text.
555
581
  * @returns {void}
556
582
  */
557
- 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) {
558
615
  const blocks = event.data?.message?.content
559
616
  const wanted = kind === 'tools' ? 'text' : 'reasoning'
560
617
  const rich = blocks.filter((block) => block?.type !== wanted)
561
618
  const message = { ...event.data.message, content: [{ type: wanted, text }, ...rich] }
619
+ const position = currentPosition(session, event, currentTurn)
562
620
  if (kind === 'tools') {
563
621
  session.append('tool/result', {
564
- turn: event.data.turn,
565
- step: event.data.step,
622
+ ...position,
566
623
  message,
567
624
  ...(event.data.error === undefined ? {} : { error: event.data.error }),
568
625
  ...(event.data.meta === undefined ? {} : { meta: event.data.meta }),
@@ -570,8 +627,7 @@ function applyRewrittenText(session, event, seq, kind, text) {
570
627
  return
571
628
  }
572
629
  session.append('assistant/message', {
573
- turn: event.data.turn,
574
- step: event.data.step,
630
+ ...position,
575
631
  message,
576
632
  ...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
577
633
  stream: event.data.stream ?? [],
@@ -591,9 +647,12 @@ function applyRewrittenText(session, event, seq, kind, text) {
591
647
  * @param {'reasoning' | 'tools'} kind - section kind.
592
648
  * @param {readonly number[]} seqs - candidate surface seqs, oldest first.
593
649
  * @param {AbortSignal | undefined} signal - step cancellation.
594
- * @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.
595
654
  */
596
- async function compressSection(ctx, session, policy, kind, seqs, signal, archive) {
655
+ async function compressSection(ctx, session, policy, kind, seqs, signal, archive, currentTurn, stillOpen) {
597
656
  const marker = sectionMarker(kind, policy.mode === 'model')
598
657
  const minTotal = Number.isFinite(policy.minTotalChars) ? policy.minTotalChars : 15000
599
658
  const perItemFloor = Number.isFinite(policy.minChars) ? policy.minChars : 0
@@ -630,6 +689,10 @@ async function compressSection(ctx, session, policy, kind, seqs, signal, archive
630
689
  let archived = 0
631
690
  let index = 0
632
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 }
633
696
  const batch = []
634
697
  let batchSize = 0
635
698
  while (index < candidates.length && batch.length < 8 && batchSize < batchChars) {
@@ -641,8 +704,11 @@ async function compressSection(ctx, session, policy, kind, seqs, signal, archive
641
704
  const summaries = policy.mode === 'model'
642
705
  ? await summarizeBatchSafe(ctx, modelCfg, batch.map((item) => item.text), signal)
643
706
  : null
707
+ signal?.throwIfAborted?.()
644
708
 
645
709
  for (let i = 0; i < batch.length; i += 1) {
710
+ signal?.throwIfAborted?.()
711
+ if (stillOpen !== undefined && !stillOpen()) return { items, chars, failed, archived }
646
712
  const item = batch[i]
647
713
  const summary = summaries === null ? null : summaries[i]
648
714
  const replacement = buildReplacement(item.text, policy, marker, summary)
@@ -650,7 +716,7 @@ async function compressSection(ctx, session, policy, kind, seqs, signal, archive
650
716
  try {
651
717
  // The append is the only proof that the rewrite landed, so the counters
652
718
  // move strictly after it returns.
653
- applyRewrittenText(session, item.event, item.seq, kind, replacement)
719
+ applyRewrittenText(session, item.event, item.seq, kind, replacement, currentTurn)
654
720
  } catch (error) {
655
721
  failed += 1
656
722
  lastFailure = `#${item.seq}: ${error?.message ?? error}`
@@ -773,197 +839,45 @@ function currentPressureTokens(ctx, session) {
773
839
  }
774
840
 
775
841
  /**
776
- * All but the most recent `keep` entries.
777
- * @param {readonly number[]} seqs - ordered surface seqs.
778
- * @param {number} keep - how many trailing entries to leave untouched.
779
- * @returns {number[]} seqs eligible for rewriting.
780
- */
781
- function dropRecent(seqs, keep) {
782
- const n = Number.isFinite(keep) ? Math.max(0, Math.trunc(keep)) : 0
783
- return n <= 0 ? [...seqs] : seqs.slice(0, Math.max(0, seqs.length - n))
784
- }
785
-
786
- /**
787
- * Replace one assistant message with the same message minus (or with trimmed) reasoning.
788
- * @param {any} ctx - plugin context.
789
- * @param {any} session - live session.
790
- * @param {number} seq - surface seq of the assistant message.
791
- * @param {any} policy - reasoning section config.
792
- * @returns {number} characters removed, or 0 when nothing was rewritten.
793
- */
794
- function rewriteAssistant(ctx, session, seq, policy, signal) {
795
- return rewriteAssistantAsync(ctx, session, seq, policy, signal)
796
- }
797
-
798
- /**
799
- * Async body of {@link rewriteAssistant}: `model` mode summarizes the elided middle.
800
- * @param {any} ctx - plugin context.
801
- * @param {any} session - live session.
802
- * @param {number} seq - surface seq of the assistant message.
803
- * @param {any} policy - reasoning section config.
804
- * @param {AbortSignal | undefined} signal - step cancellation.
805
- * @returns {Promise<number>} characters removed, or 0 when nothing was rewritten.
806
- */
807
- async function rewriteAssistantAsync(ctx, session, seq, policy, signal) {
808
- const event = session.eventAt(seq)
809
- if (event === undefined) return 0
810
- const message = event.data?.message
811
- if (message?.role !== 'assistant' || !Array.isArray(message.content)) return 0
812
-
813
- let next = policy.mode === 'clear'
814
- ? clearReasoning(message.content)
815
- : trimReasoning(message.content, policy)
816
-
817
- if (policy.mode === 'model') {
818
- next = await rebuildWithModel(ctx, message.content, policy, signal, 'reasoning', session.requestContext?.() ?? {})
819
- }
820
- if (next === null) return 0
821
-
822
- session.append(
823
- 'assistant/message',
824
- {
825
- turn: event.data.turn,
826
- step: event.data.step,
827
- message: { ...message, content: next },
828
- ...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
829
- stream: event.data.stream ?? [],
830
- },
831
- { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq } },
832
- )
833
- const before = measureBlocks(message.content)
834
- const after = measureBlocks(next)
835
- ctx.logger?.debug?.(describeChange('reasoning', before, after))
836
- return Math.max(0, before - after)
837
- }
838
-
839
- /**
840
- * Replace one oversized tool result with its head and tail.
841
- * @param {any} ctx - plugin context.
842
- * @param {any} session - live session.
843
- * @param {number} seq - surface seq of the tool result.
844
- * @param {any} policy - tools section config.
845
- * @returns {number} characters removed, or 0 when nothing was rewritten.
846
- */
847
- function rewriteToolResult(ctx, session, seq, policy, signal) {
848
- return rewriteToolResultAsync(ctx, session, seq, policy, signal)
849
- }
850
-
851
- /**
852
- * Async body of {@link rewriteToolResult}: `model` mode summarizes the elided middle.
853
- * @param {any} ctx - plugin context.
854
- * @param {any} session - live session.
855
- * @param {number} seq - surface seq of the tool result.
856
- * @param {any} policy - tools section config.
857
- * @param {AbortSignal | undefined} signal - step cancellation.
858
- * @returns {Promise<number>} characters removed, or 0 when nothing was rewritten.
859
- */
860
- async function rewriteToolResultAsync(ctx, session, seq, policy, signal) {
861
- const event = session.eventAt(seq)
862
- if (event === undefined) return 0
863
- const message = event.data?.message
864
- if (message?.role !== 'tool' || !Array.isArray(message.content)) return 0
865
- if (Array.isArray(policy.onlyTools) && policy.onlyTools.length > 0) {
866
- const toolName = resolveToolName(session, event)
867
- if (toolName === undefined || !policy.onlyTools.includes(toolName)) return 0
868
- }
869
-
870
- let next = pruneToolContent(message.content, policy)
871
- if (policy.mode === 'model') {
872
- next = await rebuildWithModel(ctx, message.content, policy, signal, 'tools', session.requestContext?.() ?? {})
873
- }
874
- if (next === null) return 0
875
-
876
- session.append(
877
- 'tool/result',
878
- {
879
- turn: event.data.turn,
880
- step: event.data.step,
881
- message: { ...message, content: next },
882
- ...(event.data.error === undefined ? {} : { error: event.data.error }),
883
- ...(event.data.meta === undefined ? {} : { meta: event.data.meta }),
884
- },
885
- { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] },
886
- )
887
- const before = measureBlocks(message.content)
888
- const after = measureBlocks(next)
889
- ctx.logger?.debug?.(describeChange('tools', before, after))
890
- return Math.max(0, before - after)
891
- }
892
-
893
- /**
894
- * Rebuild one content array with a model-written summary in place of the elided middle.
895
- * Falls back to the mechanical rewrite whenever the model path is unavailable,
896
- * so an unconfigured or failing route never loses the saving entirely.
897
- * @param {any} ctx - plugin context.
898
- * @param {readonly any[]} blocks - original content blocks.
899
- * @param {any} policy - section config carrying `headChars`, `tailChars` and `model`.
900
- * @param {AbortSignal | undefined} signal - step cancellation.
901
- * @param {'reasoning' | 'tools'} kind - which section asked.
902
- * @returns {Promise<any[] | null>} replacement blocks, or `null` when within budget.
903
- */
904
- async function rebuildWithModel(ctx, blocks, policy, signal, kind, routed) {
905
- const kindOfBlock = kind === 'tools' ? 'text' : 'reasoning'
906
- const text = blocks.filter((block) => block?.type === kindOfBlock)
907
- .map((block) => (typeof block.text === 'string' ? block.text : ''))
908
- .join('')
909
- const limit = kind === 'tools' ? policy.thresholdChars : policy.minChars
910
- if ([...text].length <= limit) return null
911
-
912
- // Already summarized once: never summarize a summary (it would compound).
913
- const marker = kind === 'tools' ? TOOL_MARKER : REASONING_MARKER
914
- if (text.includes(marker.trim())) return null
915
-
916
- const mechanical = () => (kind === 'tools' ? pruneToolContent(blocks, policy) : trimReasoning(blocks, policy))
917
- const model = policy.model ?? {}
918
- const summaryCfg = {
919
- ...model,
920
- provider: model.provider || (routed && routed.provider) || '',
921
- model: model.model || (routed && routed.model) || '',
922
- }
923
-
924
- try {
925
- const replaced = await summarizeMiddle(
926
- ctx,
927
- summaryCfg,
928
- text,
929
- { headChars: policy.headChars, tailChars: policy.tailChars, marker },
930
- signal,
931
- )
932
- if (replaced === null) throw new Error('empty-summary')
933
- const rich = blocks.filter((block) => block?.type !== kindOfBlock)
934
- return [{ type: kindOfBlock, text: replaced }, ...rich]
935
- } catch (error) {
936
- ctx.logger?.warn?.(`[compact-arsenal] model ${kind} unavailable, using mechanical: ${error?.message ?? error}`)
937
- return mechanical()
938
- }
939
- }
940
-
941
- /**
942
- * 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
+ *
943
848
  * @param {any} session - live session.
944
- * @param {any} event - tool/result event.
945
- * @returns {string | undefined} tool name when the call is still in the surface.
849
+ * @returns {number | undefined} the open turn number.
946
850
  */
947
- function resolveToolName(session, event) {
948
- const callId = event.data?.message?.toolCallId ?? event.data?.message?.source?.callId
949
- if (callId === undefined) return undefined
950
- for (const seq of session.surface.nodes) {
951
- const candidate = session.eventAt(seq)
952
- 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
953
870
  }
954
871
  return undefined
955
872
  }
956
873
 
957
874
  /**
958
- * Rough size of a content array for logging.
959
- * @param {readonly any[]} blocks - content blocks.
960
- * @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.
961
879
  */
962
- function measureBlocks(blocks) {
963
- let total = 0
964
- for (const block of blocks) {
965
- if (typeof block?.text === 'string') total += block.text.length
966
- if (typeof block?.arguments === 'string') total += block.arguments.length
967
- }
968
- return total
969
- }
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.1",
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",