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.
- package/CHANGELOG.md +53 -0
- package/client.js +9 -0
- package/index.js +162 -197
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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
|
|
720
|
-
|
|
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
|
-
*
|
|
737
|
-
*
|
|
738
|
-
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
741
|
-
*
|
|
742
|
-
|
|
743
|
-
|
|
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
|
-
* @
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
-
|
|
810
|
-
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
-
*
|
|
908
|
-
* @param {readonly
|
|
909
|
-
* @
|
|
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
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
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.
|
|
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",
|