dsh-compact-arsenal 0.2.1 → 0.4.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 +54 -0
  2. package/client.js +40 -1
  3. package/index.js +161 -209
  4. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,60 @@ 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.4.0] — 2026-11-11
8
+
9
+ ### Fixed
10
+
11
+ - **The pressure readout never appeared while all sections were disabled.**
12
+ `rewriteAsync()` short-circuited before measuring, so a plugin with every
13
+ switch off reported nothing. Measurement now happens first, and the panel
14
+ endpoint re-reads it from the live agent on every request instead of relying
15
+ on the once-per-turn hook value.
16
+
17
+ ### Added
18
+
19
+ - **Version badge in the panel header** (`v0.4.0`), sourced from the running
20
+ plugin rather than `package.json`, so a stale install is visible at a glance.
21
+ - A disabled **检查更新** button beside it, reserving the seat for a future
22
+ update check; the market owns package updates, so this stays inert until one
23
+ exists.
24
+
25
+ ## [0.3.0] — 2026-11-11
26
+
27
+ ### Fixed
28
+
29
+ - **Surface replacements could land outside an open turn and make the session
30
+ unloadable.** The `agent/pre-step` handler called `rewrite()` without
31
+ `await`, so a slow model batch kept appending after the handler returned and
32
+ after its turn had closed. Of 100 affected rows in one session, 5 sat in the
33
+ gap between turns and were rejected by the log loader on resume; the other 95
34
+ slipped through while claiming a turn they did not belong to.
35
+ - **Replacements carried the replaced node's turn.** `applyRewrittenText()` wrote
36
+ `turn: event.data.turn`, i.e. the turn the old node was created in — often
37
+ dozens of turns earlier. The row now carries the turn that owns the write, and
38
+ keeps the node's step only when both turns match.
39
+ - **A cancelled step still wrote to the log.** `compressSection()` now checks
40
+ `signal.throwIfAborted()` before each batch and each append.
41
+ - **Two turns of one session could rewrite concurrently.** `lastProcessedTurn`
42
+ is marked when a rewrite starts but appends land seconds later; a per-session
43
+ in-flight set now keeps a second pass from starting.
44
+ - **A turn closing mid-pass is detected.** Every append is preceded by a
45
+ turn-boundary check read from the log tail, so a write that would land after
46
+ `turn/end` is skipped instead of corrupting the log.
47
+
48
+ ### Removed
49
+
50
+ - The unused single-item rewrite path (`rewriteAssistant*`, `rewriteToolResult*`,
51
+ `rebuildWithModel`) — dead code that carried the same stale-turn bug and could
52
+ have been reintroduced by accident.
53
+
54
+ ### Added
55
+
56
+ - `test/turn-position.test.mjs`: pins the turn/step semantics of a replacement
57
+ and the "is the turn still open" decision.
58
+ - `test/verify-fix.cjs`: asserts all of the above against the shipped source, so
59
+ a regression fails a test rather than a user's session.
60
+
7
61
  ## [0.2.1] — 2026-11-10
8
62
 
9
63
  ### Fixed
package/client.js CHANGED
@@ -270,6 +270,7 @@ window.__ModuleLoader__.load({
270
270
  const [archiveRoot, setArchiveRoot] = useState(null)
271
271
  const [lastArchivePath, setLastArchivePath] = useState(null)
272
272
  const [pressure, setPressure] = useState(null)
273
+ const [runtimeVersion, setRuntimeVersion] = useState(null)
273
274
  const timer = useRef(null)
274
275
  const latest = useRef(emptyConfig)
275
276
 
@@ -287,6 +288,7 @@ window.__ModuleLoader__.load({
287
288
  setArchiveRoot(body.archiveRoot ?? null)
288
289
  setLastArchivePath(body.lastArchivePath ?? null)
289
290
  setPressure(body.pressure ?? null)
291
+ setRuntimeVersion(body.version ?? null)
290
292
  setStatus(body.editable === false ? 'readonly' : 'ready')
291
293
  })
292
294
  .catch((error) => {
@@ -317,6 +319,7 @@ window.__ModuleLoader__.load({
317
319
  if (body.archiveRoot) setArchiveRoot(body.archiveRoot)
318
320
  if (body.lastArchivePath !== undefined) setLastArchivePath(body.lastArchivePath)
319
321
  if (body.pressure !== undefined) setPressure(body.pressure)
322
+ if (body.version) setRuntimeVersion(body.version)
320
323
  setTotalApplied(body.totalApplied === undefined ? null : body.totalApplied)
321
324
  setStatus('ready')
322
325
  setMessage('已保存')
@@ -506,13 +509,49 @@ window.__ModuleLoader__.load({
506
509
  : status === 'readonly' ? '配置写入不可用(只读)'
507
510
  : (message || '已连接')
508
511
 
512
+ /**
513
+ * Version badge beside the title.
514
+ *
515
+ * The update affordance is deliberately inert for now: the market owns
516
+ * package updates, so this only reserves the seat and the label. When an
517
+ * update check exists, it flips `updateAvailable` and the same button
518
+ * becomes actionable without moving anything.
519
+ */
520
+ const versionBadge = h('div', {
521
+ key: 'version',
522
+ style: { display: 'flex', alignItems: 'center', gap: 6 },
523
+ }, [
524
+ h('span', {
525
+ key: 'v',
526
+ title: runtimeVersion ? `运行中:${runtimeVersion}` : '运行中版本未知',
527
+ style: {
528
+ fontSize: 11, padding: '1px 7px', borderRadius: 999, color: MUTED,
529
+ border: `1px solid ${LINE}`, fontVariantNumeric: 'tabular-nums',
530
+ },
531
+ }, runtimeVersion ? `v${runtimeVersion}` : 'v?'),
532
+ h('button', {
533
+ key: 'check',
534
+ type: 'button',
535
+ disabled: true,
536
+ title: '检查更新(预留:将由插件市场提供)',
537
+ style: {
538
+ fontSize: 11, padding: '1px 8px', borderRadius: 999,
539
+ border: `1px solid ${LINE}`, background: 'transparent',
540
+ color: MUTED, cursor: 'not-allowed', opacity: 0.55,
541
+ },
542
+ }, '检查更新'),
543
+ ])
544
+
509
545
  const header = h('header', {
510
546
  key: 'hdr',
511
547
  style: { display: 'flex', flexDirection: 'column', gap: 8 },
512
548
  }, [
513
549
  h('div', { key: 'row', style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 } }, [
514
550
  h('div', { key: 't', style: { display: 'flex', flexDirection: 'column', gap: 2 } }, [
515
- h('h1', { key: 'h1', style: { margin: 0, fontSize: 18, fontWeight: 600 } }, '上下文压缩'),
551
+ h('div', { key: 'title', style: { display: 'flex', alignItems: 'center', gap: 8 } }, [
552
+ h('h1', { key: 'h1', style: { margin: 0, fontSize: 18, fontWeight: 600 } }, '上下文压缩'),
553
+ versionBadge,
554
+ ]),
516
555
  h('p', { key: 'p', style: { margin: 0, fontSize: 12, color: MUTED, lineHeight: 1.6 } },
517
556
  '开关即生效,参数改完自动保存。每个板块有自己的触发阈值。'),
518
557
  ]),
package/index.js CHANGED
@@ -21,16 +21,18 @@ 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'
33
29
 
30
+ /**
31
+ * Bumped in lockstep with `package.json` so the panel can show which build is
32
+ * actually running — a stale install is otherwise invisible.
33
+ */
34
+ export const PACKAGE_VERSION = '0.4.0'
35
+
34
36
  /** Host services this plugin reads; all ship with dsh-base or dsh-web-app. */
35
37
  export const inject = ['sessions', 'tokenMeter', 'webServer', 'configEditor']
36
38
 
@@ -178,6 +180,33 @@ function isLegacyModelPrompt(prompt) {
178
180
  return typeof prompt === 'string' && prompt.includes('只输出要点') && prompt.includes('不超过 200 字')
179
181
  }
180
182
 
183
+ /**
184
+ * Recompute the pressure snapshot from whichever agent is live.
185
+ *
186
+ * The step hook updates the same object at most once per turn; a panel read
187
+ * should reflect the session as it stands now, including when every section is
188
+ * disabled and the hook short-circuits.
189
+ *
190
+ * @param {any} ctx - plugin context.
191
+ * @param {{ reported: number | null, estimated: number, window: number | null }} pressure - snapshot to update.
192
+ * @returns {void}
193
+ */
194
+ function refreshPressure(ctx, pressure) {
195
+ try {
196
+ const agents = ctx.get('agents')?.list?.() ?? []
197
+ const agent = agents[agents.length - 1]
198
+ if (agent === undefined) return
199
+ const session = ctx.sessions.get(agent.id)
200
+ if (session === undefined) return
201
+ const snapshot = pressureSnapshot(ctx, session)
202
+ pressure.reported = snapshot.reported ?? null
203
+ pressure.estimated = snapshot.estimated
204
+ pressure.window = session.requestContext?.()?.contextWindow ?? null
205
+ } catch {
206
+ // A missing agent registry only means "nothing live to measure".
207
+ }
208
+ }
209
+
181
210
  /**
182
211
  * Register the rewrite pass on the per-step hook.
183
212
  * @param {any} ctx - plugin context.
@@ -197,14 +226,30 @@ export function apply(ctx, rawConfig) {
197
226
  const archive = { root: archiveRoot(config.archiveDir), lastPath: null }
198
227
 
199
228
  if (config.enabled) {
229
+ /**
230
+ * Sessions with a rewrite in flight.
231
+ *
232
+ * `lastProcessedTurn` is marked synchronously when a rewrite starts, but the
233
+ * append lands seconds later (a model batch can take a while). Without this
234
+ * guard two turns of the same session can rewrite concurrently, which writes
235
+ * a replacement whose `turn` belongs to a turn that has already ended.
236
+ */
237
+ const inFlight = new Set()
200
238
  ctx.on('agent/pre-step', async (payload, next) => {
201
239
  const decision = await next()
202
240
  if (decision?.kind !== 'enter') return decision
241
+ if (inFlight.has(payload.agent.id)) return decision
242
+ inFlight.add(payload.agent.id)
203
243
  try {
204
- rewrite(ctx, config, payload, lastProcessedTurn, stats, archive, pressure)
244
+ // Awaiting matters: a rewrite left running after this handler returns
245
+ // can append while its turn is closing, and a rejection would escape
246
+ // this catch entirely.
247
+ await rewrite(ctx, config, payload, lastProcessedTurn, stats, archive, pressure)
205
248
  } catch (error) {
206
249
  // A rewrite failure must never break the step.
207
250
  ctx.logger?.warn?.(`[compact-arsenal] rewrite skipped: ${error?.message ?? error}`)
251
+ } finally {
252
+ inFlight.delete(payload.agent.id)
208
253
  }
209
254
  return decision
210
255
  })
@@ -238,7 +283,11 @@ function registerPanelRoutes(ctx, config, stats, archive, pressure) {
238
283
 
239
284
  route(`${API_PREFIX}/state`, (req, res) => {
240
285
  if (req.method !== 'GET') return send(res, 405, { error: 'method-not-allowed' })
286
+ // Fold any live agent into the snapshot at read time: the step hook only
287
+ // updates it once per turn, and the panel should not show a stale number.
288
+ refreshPressure(ctx, pressure)
241
289
  send(res, 200, {
290
+ version: PACKAGE_VERSION,
242
291
  config,
243
292
  stats,
244
293
  editable: configEditor !== undefined,
@@ -476,21 +525,22 @@ function rewrite(ctx, config, payload, lastProcessedTurn, stats, archive, pressu
476
525
  * @returns {Promise<void>} resolves when every rewrite of this turn landed.
477
526
  */
478
527
  async function rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, archive, pressure) {
479
- if (!config.total.enabled && !config.reasoning.enabled && !config.tools.enabled) return
480
-
481
528
  const session = ctx.sessions.get(payload.agent.id)
482
529
  if (session === undefined) return
483
530
 
484
- if (lastProcessedTurn.get(session.id) === payload.turn) return
485
-
486
- lastProcessedTurn.set(session.id, payload.turn)
487
-
488
- // Snapshot the inputs once per turn so the panel can explain a gate decision.
531
+ // Measure before any gate: the panel must be able to explain why nothing ran,
532
+ // so a fully disabled plugin still reports the current pressure.
489
533
  const snapshot = pressureSnapshot(ctx, session)
490
534
  pressure.reported = snapshot.reported ?? null
491
535
  pressure.estimated = snapshot.estimated
492
536
  pressure.window = session.requestContext?.()?.contextWindow ?? null
493
537
 
538
+ if (!config.total.enabled && !config.reasoning.enabled && !config.tools.enabled) return
539
+
540
+ if (lastProcessedTurn.get(session.id) === payload.turn) return
541
+
542
+ lastProcessedTurn.set(session.id, payload.turn)
543
+
494
544
  if (config.total.enabled && underPressure(ctx, session, config.total.pressureRatio)) {
495
545
  await totalPass(ctx, session, config.total, payload.turn, payload.signal, stats, archive)
496
546
  }
@@ -504,8 +554,22 @@ async function rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, arch
504
554
  else if (event?.type === 'tool/result') toolSeqs.push(seq)
505
555
  }
506
556
 
557
+ /**
558
+ * Whether the turn that started this pass is still the session's open turn.
559
+ *
560
+ * A surface replacement is only valid while its turn is open: writing after
561
+ * `turn/end` leaves a row in the gap between turns, which the log loader
562
+ * rejects on the next resume. Re-checking before every append keeps a slow
563
+ * model batch from writing into a turn that already closed.
564
+ */
565
+ const stillOpen = () => {
566
+ if (payload.signal?.aborted) return false
567
+ const open = openTurnOf(session)
568
+ return open === undefined || open === payload.turn
569
+ }
570
+
507
571
  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)
572
+ const saved = await compressSection(ctx, session, config.reasoning, 'reasoning', dropRecent(assistantSeqs, config.reasoning.keepLast), payload.signal, archive, payload.turn, stillOpen)
509
573
  stats.reasoning += saved.items
510
574
  stats.failed += saved.failed ?? 0
511
575
  stats.chars += saved.chars
@@ -513,7 +577,7 @@ async function rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, arch
513
577
  }
514
578
 
515
579
  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)
580
+ const saved = await compressSection(ctx, session, config.tools, 'tools', dropRecent(toolSeqs, config.tools.keepLast), payload.signal, archive, payload.turn, stillOpen)
517
581
  stats.tools += saved.items
518
582
  stats.failed += saved.failed ?? 0
519
583
  stats.chars += saved.chars
@@ -554,15 +618,46 @@ function sectionText(event, kind) {
554
618
  * @param {string} text - replacement text.
555
619
  * @returns {void}
556
620
  */
557
- function applyRewrittenText(session, event, seq, kind, text) {
621
+ /**
622
+ * Copy of `event.data.turn` replaced by the turn that is open right now.
623
+ *
624
+ * A surface replacement must carry the turn that owns the write, not the turn
625
+ * the replaced node was created in: the node may be dozens of turns old, and a
626
+ * row claiming a closed turn either fails the log's relationship check or slips
627
+ * through while claiming a turn that does not match its position.
628
+ *
629
+ * @param {any} session - live session.
630
+ * @param {any} event - the node being replaced.
631
+ * @param {number} currentTurn - turn owning this write.
632
+ * @returns {{ turn: number, step: number }} position fields for the new row.
633
+ */
634
+ function currentPosition(session, event, currentTurn) {
635
+ const turn = Number.isFinite(currentTurn) ? currentTurn : event.data.turn
636
+ // Keep the step only when the write belongs to the same turn as the node;
637
+ // otherwise the step number of an old turn is meaningless.
638
+ const step = turn === event.data.turn && Number.isFinite(event.data.step) ? event.data.step : 1
639
+ return { turn, step }
640
+ }
641
+
642
+ /**
643
+ * Replace one event's text with a rewritten version, keeping message identity.
644
+ * @param {any} session - live session.
645
+ * @param {any} event - the surface event being replaced.
646
+ * @param {number} seq - its surface seq.
647
+ * @param {'reasoning' | 'tools'} kind - section kind.
648
+ * @param {string} text - replacement text.
649
+ * @param {number} currentTurn - turn that owns this write.
650
+ * @returns {void}
651
+ */
652
+ function applyRewrittenText(session, event, seq, kind, text, currentTurn) {
558
653
  const blocks = event.data?.message?.content
559
654
  const wanted = kind === 'tools' ? 'text' : 'reasoning'
560
655
  const rich = blocks.filter((block) => block?.type !== wanted)
561
656
  const message = { ...event.data.message, content: [{ type: wanted, text }, ...rich] }
657
+ const position = currentPosition(session, event, currentTurn)
562
658
  if (kind === 'tools') {
563
659
  session.append('tool/result', {
564
- turn: event.data.turn,
565
- step: event.data.step,
660
+ ...position,
566
661
  message,
567
662
  ...(event.data.error === undefined ? {} : { error: event.data.error }),
568
663
  ...(event.data.meta === undefined ? {} : { meta: event.data.meta }),
@@ -570,8 +665,7 @@ function applyRewrittenText(session, event, seq, kind, text) {
570
665
  return
571
666
  }
572
667
  session.append('assistant/message', {
573
- turn: event.data.turn,
574
- step: event.data.step,
668
+ ...position,
575
669
  message,
576
670
  ...(event.data.usage === undefined ? {} : { usage: event.data.usage }),
577
671
  stream: event.data.stream ?? [],
@@ -591,9 +685,12 @@ function applyRewrittenText(session, event, seq, kind, text) {
591
685
  * @param {'reasoning' | 'tools'} kind - section kind.
592
686
  * @param {readonly number[]} seqs - candidate surface seqs, oldest first.
593
687
  * @param {AbortSignal | undefined} signal - step cancellation.
594
- * @returns {Promise<{ items: number, chars: number }>} rewrites landed and text dropped.
688
+ * @param {{ root: string, lastPath: string | null }} archive - archive target.
689
+ * @param {number} currentTurn - turn that owns these writes.
690
+ * @param {(() => boolean) | undefined} stillOpen - re-checked before every append.
691
+ * @returns {Promise<{ items: number, chars: number, failed: number, archived: number }>} what landed.
595
692
  */
596
- async function compressSection(ctx, session, policy, kind, seqs, signal, archive) {
693
+ async function compressSection(ctx, session, policy, kind, seqs, signal, archive, currentTurn, stillOpen) {
597
694
  const marker = sectionMarker(kind, policy.mode === 'model')
598
695
  const minTotal = Number.isFinite(policy.minTotalChars) ? policy.minTotalChars : 15000
599
696
  const perItemFloor = Number.isFinite(policy.minChars) ? policy.minChars : 0
@@ -630,6 +727,10 @@ async function compressSection(ctx, session, policy, kind, seqs, signal, archive
630
727
  let archived = 0
631
728
  let index = 0
632
729
  while (index < candidates.length && items < maxItems && chars < maxChars) {
730
+ // The step may have been cancelled while a model batch was in flight; never
731
+ // write into a session whose turn has been torn down.
732
+ signal?.throwIfAborted?.()
733
+ if (stillOpen !== undefined && !stillOpen()) return { items, chars, failed, archived }
633
734
  const batch = []
634
735
  let batchSize = 0
635
736
  while (index < candidates.length && batch.length < 8 && batchSize < batchChars) {
@@ -641,8 +742,11 @@ async function compressSection(ctx, session, policy, kind, seqs, signal, archive
641
742
  const summaries = policy.mode === 'model'
642
743
  ? await summarizeBatchSafe(ctx, modelCfg, batch.map((item) => item.text), signal)
643
744
  : null
745
+ signal?.throwIfAborted?.()
644
746
 
645
747
  for (let i = 0; i < batch.length; i += 1) {
748
+ signal?.throwIfAborted?.()
749
+ if (stillOpen !== undefined && !stillOpen()) return { items, chars, failed, archived }
646
750
  const item = batch[i]
647
751
  const summary = summaries === null ? null : summaries[i]
648
752
  const replacement = buildReplacement(item.text, policy, marker, summary)
@@ -650,7 +754,7 @@ async function compressSection(ctx, session, policy, kind, seqs, signal, archive
650
754
  try {
651
755
  // The append is the only proof that the rewrite landed, so the counters
652
756
  // move strictly after it returns.
653
- applyRewrittenText(session, item.event, item.seq, kind, replacement)
757
+ applyRewrittenText(session, item.event, item.seq, kind, replacement, currentTurn)
654
758
  } catch (error) {
655
759
  failed += 1
656
760
  lastFailure = `#${item.seq}: ${error?.message ?? error}`
@@ -773,197 +877,45 @@ function currentPressureTokens(ctx, session) {
773
877
  }
774
878
 
775
879
  /**
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.
880
+ * The turn that is currently open in a session, read from its own log.
881
+ *
882
+ * The last `turn/start` wins unless a matching `turn/end` closed it. Returned
883
+ * as `undefined` when the log carries no turn markers yet, so callers can treat
884
+ * "cannot tell" as "do not block".
885
+ *
943
886
  * @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.
887
+ * @returns {number | undefined} the open turn number.
946
888
  */
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
889
+ function openTurnOf(session) {
890
+ // Turn markers are log-only events, never surface nodes, so they are read from
891
+ // the log tail. Scanning backwards keeps this O(recent), not O(session).
892
+ const end = typeof session.seq === 'number' ? session.seq : 0
893
+ let seen = 0
894
+ let lastStart
895
+ for (let seq = end; seq >= 0; seq -= 1) {
896
+ const event = session.eventAt?.(seq)
897
+ if (event === undefined) continue
898
+ seen += 1
899
+ if (event.type === 'turn/end' && lastStart === undefined && event.data?.turn !== undefined) {
900
+ // The newest marker is an end: that turn is already closed.
901
+ return undefined
902
+ }
903
+ if (event.type === 'turn/start') {
904
+ lastStart = event.data?.turn
905
+ return lastStart
906
+ }
907
+ if (seen >= 400) break
953
908
  }
954
909
  return undefined
955
910
  }
956
911
 
957
912
  /**
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.
913
+ * All but the most recent `keep` entries.
914
+ * @param {readonly number[]} seqs - ordered surface seqs.
915
+ * @param {number} keep - how many trailing entries to leave untouched.
916
+ * @returns {number[]} seqs eligible for rewriting.
961
917
  */
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
- }
918
+ function dropRecent(seqs, keep) {
919
+ const n = Number.isFinite(keep) ? Math.max(0, Math.trunc(keep)) : 0
920
+ return n <= 0 ? [...seqs] : seqs.slice(0, Math.max(0, seqs.length - n))
921
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-compact-arsenal",
3
- "version": "0.2.1",
3
+ "version": "0.4.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",