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.
- package/CHANGELOG.md +36 -0
- package/index.js +116 -202
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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
|
-
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
*
|
|
780
|
-
|
|
781
|
-
|
|
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
|
-
* @
|
|
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
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
*
|
|
959
|
-
* @param {readonly
|
|
960
|
-
* @
|
|
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
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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.
|
|
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",
|