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.
- package/CHANGELOG.md +54 -0
- package/client.js +40 -1
- package/index.js +161 -209
- 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('
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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
|
-
*
|
|
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.
|
|
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
|
-
* @
|
|
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
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
*
|
|
959
|
-
* @param {readonly
|
|
960
|
-
* @
|
|
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
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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.
|
|
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",
|