dsh-compact-arsenal 0.3.0 → 0.5.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 +44 -0
- package/client.js +40 -1
- package/index.js +109 -23
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ 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.5.0] — 2026-11-11
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Reasoning compression could never land on any published DSH release.**
|
|
12
|
+
Reported in [#1](https://github.com/TacitSay/dsh-compact-arsenal/issues/1):
|
|
13
|
+
the log enforces two mutually exclusive rules on a surface replacement — every
|
|
14
|
+
shadowed node must be cited, and `assistant/message` may not cite anything.
|
|
15
|
+
Rewriting an assistant node in place is therefore impossible, and every
|
|
16
|
+
reasoning attempt failed with `missing <seq>` while its model call had already
|
|
17
|
+
been paid for. Reasoning now collapses into a `user/message` checkpoint citing
|
|
18
|
+
the node it replaces, the same shape the shipped compaction backends use.
|
|
19
|
+
- **`totalPass` cited only the nodes it fed the model.** Nodes filtered out
|
|
20
|
+
before summarization (system, developer, and turn-less nodes, including this
|
|
21
|
+
plugin's own earlier checkpoints) still fall inside `[startSeq, endSeq]` and
|
|
22
|
+
were reported as missing. The whole surface span is cited now.
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- Pre-flight shape check: an impossible replacement is detected before the model
|
|
27
|
+
call, so a deterministic rejection no longer spends a request.
|
|
28
|
+
- `test/surface-shape.test.mjs` runs against the packaged `dsh-session` and pins
|
|
29
|
+
the accepted replacement shape for both node types.
|
|
30
|
+
- Source assertions now cover the reasoning shape, the whole-span citation, and
|
|
31
|
+
the pre-flight check (17 checks).
|
|
32
|
+
|
|
33
|
+
## [0.4.0] — 2026-11-11
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- **The pressure readout never appeared while all sections were disabled.**
|
|
38
|
+
`rewriteAsync()` short-circuited before measuring, so a plugin with every
|
|
39
|
+
switch off reported nothing. Measurement now happens first, and the panel
|
|
40
|
+
endpoint re-reads it from the live agent on every request instead of relying
|
|
41
|
+
on the once-per-turn hook value.
|
|
42
|
+
|
|
43
|
+
### Added
|
|
44
|
+
|
|
45
|
+
- **Version badge in the panel header** (`v0.4.0`), sourced from the running
|
|
46
|
+
plugin rather than `package.json`, so a stale install is visible at a glance.
|
|
47
|
+
- A disabled **检查更新** button beside it, reserving the seat for a future
|
|
48
|
+
update check; the market owns package updates, so this stays inert until one
|
|
49
|
+
exists.
|
|
50
|
+
|
|
7
51
|
## [0.3.0] — 2026-11-11
|
|
8
52
|
|
|
9
53
|
### 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
|
@@ -27,6 +27,12 @@ import { archiveRewrite, archiveRoot } from './lib/archive.js'
|
|
|
27
27
|
|
|
28
28
|
export const name = 'compact-arsenal'
|
|
29
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.5.0'
|
|
35
|
+
|
|
30
36
|
/** Host services this plugin reads; all ship with dsh-base or dsh-web-app. */
|
|
31
37
|
export const inject = ['sessions', 'tokenMeter', 'webServer', 'configEditor']
|
|
32
38
|
|
|
@@ -174,6 +180,33 @@ function isLegacyModelPrompt(prompt) {
|
|
|
174
180
|
return typeof prompt === 'string' && prompt.includes('只输出要点') && prompt.includes('不超过 200 字')
|
|
175
181
|
}
|
|
176
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
|
+
|
|
177
210
|
/**
|
|
178
211
|
* Register the rewrite pass on the per-step hook.
|
|
179
212
|
* @param {any} ctx - plugin context.
|
|
@@ -250,7 +283,11 @@ function registerPanelRoutes(ctx, config, stats, archive, pressure) {
|
|
|
250
283
|
|
|
251
284
|
route(`${API_PREFIX}/state`, (req, res) => {
|
|
252
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)
|
|
253
289
|
send(res, 200, {
|
|
290
|
+
version: PACKAGE_VERSION,
|
|
254
291
|
config,
|
|
255
292
|
stats,
|
|
256
293
|
editable: configEditor !== undefined,
|
|
@@ -393,6 +430,19 @@ async function totalPass(ctx, session, total, turn, signal, stats, archive) {
|
|
|
393
430
|
if (picked.length < 4) return
|
|
394
431
|
|
|
395
432
|
const transcript = picked.map((node) => transcriptLine(node.event)).join('\n')
|
|
433
|
+
|
|
434
|
+
// The replacement must cite EVERY surface node inside the replaced span, not
|
|
435
|
+
// just the ones we fed the model: filters above skip system/developer nodes
|
|
436
|
+
// and nodes without a turn, and those still sit inside [startSeq, endSeq].
|
|
437
|
+
const startSeq = picked[0].seq
|
|
438
|
+
const endSeq = picked[picked.length - 1].seq
|
|
439
|
+
const surface = [...session.surface.nodes]
|
|
440
|
+
const startIdx = surface.indexOf(startSeq)
|
|
441
|
+
const endIdx = surface.indexOf(endSeq)
|
|
442
|
+
if (startIdx < 0 || endIdx < startIdx) return
|
|
443
|
+
if (startIdx === 0 && session.eventAt(surface[0])?.type === 'system/message') return
|
|
444
|
+
const shadowed = surface.slice(startIdx, endIdx + 1)
|
|
445
|
+
|
|
396
446
|
const routed = session.requestContext?.() ?? {}
|
|
397
447
|
const summaryCfg = {
|
|
398
448
|
...total,
|
|
@@ -409,8 +459,6 @@ async function totalPass(ctx, session, total, turn, signal, stats, archive) {
|
|
|
409
459
|
}
|
|
410
460
|
if (summary === '') return
|
|
411
461
|
|
|
412
|
-
const startSeq = picked[0].seq
|
|
413
|
-
const endSeq = picked[picked.length - 1].seq
|
|
414
462
|
try {
|
|
415
463
|
session.append(
|
|
416
464
|
'user/message',
|
|
@@ -422,7 +470,7 @@ async function totalPass(ctx, session, total, turn, signal, stats, archive) {
|
|
|
422
470
|
text: `This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it.\n\n<compacted-summary>\n${summary}\n</compacted-summary>`,
|
|
423
471
|
}],
|
|
424
472
|
},
|
|
425
|
-
{ surfaceOp: { op: 'replace', startSeq, endSeq }, sourceEventSeqs:
|
|
473
|
+
{ surfaceOp: { op: 'replace', startSeq, endSeq }, sourceEventSeqs: shadowed },
|
|
426
474
|
)
|
|
427
475
|
stats.total = (stats.total ?? 0) + 1
|
|
428
476
|
stats.chars += used
|
|
@@ -488,21 +536,22 @@ function rewrite(ctx, config, payload, lastProcessedTurn, stats, archive, pressu
|
|
|
488
536
|
* @returns {Promise<void>} resolves when every rewrite of this turn landed.
|
|
489
537
|
*/
|
|
490
538
|
async function rewriteAsync(ctx, config, payload, lastProcessedTurn, stats, archive, pressure) {
|
|
491
|
-
if (!config.total.enabled && !config.reasoning.enabled && !config.tools.enabled) return
|
|
492
|
-
|
|
493
539
|
const session = ctx.sessions.get(payload.agent.id)
|
|
494
540
|
if (session === undefined) return
|
|
495
541
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
lastProcessedTurn.set(session.id, payload.turn)
|
|
499
|
-
|
|
500
|
-
// Snapshot the inputs once per turn so the panel can explain a gate decision.
|
|
542
|
+
// Measure before any gate: the panel must be able to explain why nothing ran,
|
|
543
|
+
// so a fully disabled plugin still reports the current pressure.
|
|
501
544
|
const snapshot = pressureSnapshot(ctx, session)
|
|
502
545
|
pressure.reported = snapshot.reported ?? null
|
|
503
546
|
pressure.estimated = snapshot.estimated
|
|
504
547
|
pressure.window = session.requestContext?.()?.contextWindow ?? null
|
|
505
548
|
|
|
549
|
+
if (!config.total.enabled && !config.reasoning.enabled && !config.tools.enabled) return
|
|
550
|
+
|
|
551
|
+
if (lastProcessedTurn.get(session.id) === payload.turn) return
|
|
552
|
+
|
|
553
|
+
lastProcessedTurn.set(session.id, payload.turn)
|
|
554
|
+
|
|
506
555
|
if (config.total.enabled && underPressure(ctx, session, config.total.pressureRatio)) {
|
|
507
556
|
await totalPass(ctx, session, config.total, payload.turn, payload.signal, stats, archive)
|
|
508
557
|
}
|
|
@@ -602,7 +651,16 @@ function currentPosition(session, event, currentTurn) {
|
|
|
602
651
|
}
|
|
603
652
|
|
|
604
653
|
/**
|
|
605
|
-
*
|
|
654
|
+
* Rewrite one node's text, using the only shape the log accepts for each type.
|
|
655
|
+
*
|
|
656
|
+
* `tool/result` may be replaced in place because it can cite the node it
|
|
657
|
+
* shadows. `assistant/message` cannot: the log rejects any `sourceEventSeqs` on
|
|
658
|
+
* it ("assistant/message embeds its source stream"), while every replacement
|
|
659
|
+
* must cite all shadowed nodes — so an in-place assistant rewrite is impossible
|
|
660
|
+
* on every published version. Reasoning therefore collapses into a
|
|
661
|
+
* `user/message` checkpoint covering the whole replaced span, which is the same
|
|
662
|
+
* shape the shipped compaction backends use.
|
|
663
|
+
*
|
|
606
664
|
* @param {any} session - live session.
|
|
607
665
|
* @param {any} event - the surface event being replaced.
|
|
608
666
|
* @param {number} seq - its surface seq.
|
|
@@ -612,26 +670,46 @@ function currentPosition(session, event, currentTurn) {
|
|
|
612
670
|
* @returns {void}
|
|
613
671
|
*/
|
|
614
672
|
function applyRewrittenText(session, event, seq, kind, text, currentTurn) {
|
|
615
|
-
const blocks = event.data?.message?.content
|
|
616
|
-
const wanted = kind === 'tools' ? 'text' : 'reasoning'
|
|
617
|
-
const rich = blocks.filter((block) => block?.type !== wanted)
|
|
618
|
-
const message = { ...event.data.message, content: [{ type: wanted, text }, ...rich] }
|
|
619
|
-
const position = currentPosition(session, event, currentTurn)
|
|
620
673
|
if (kind === 'tools') {
|
|
674
|
+
const blocks = event.data?.message?.content ?? []
|
|
675
|
+
const rich = blocks.filter((block) => block?.type !== 'text')
|
|
676
|
+
const message = { ...event.data.message, content: [{ type: 'text', text }, ...rich] }
|
|
621
677
|
session.append('tool/result', {
|
|
622
|
-
...
|
|
678
|
+
...currentPosition(session, event, currentTurn),
|
|
623
679
|
message,
|
|
624
680
|
...(event.data.error === undefined ? {} : { error: event.data.error }),
|
|
625
681
|
...(event.data.meta === undefined ? {} : { meta: event.data.meta }),
|
|
626
682
|
}, { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] })
|
|
627
683
|
return
|
|
628
684
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
685
|
+
|
|
686
|
+
// Reasoning: replace the node with a user-role checkpoint that cites it.
|
|
687
|
+
session.append('user/message', {
|
|
688
|
+
role: 'user',
|
|
689
|
+
source: { kind: 'user' },
|
|
690
|
+
content: [{ type: 'text', text }],
|
|
691
|
+
}, { surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] })
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Whether one section can legally replace a node in this session right now.
|
|
696
|
+
*
|
|
697
|
+
* The check is deliberately cheap and local: it mirrors the log's own rules
|
|
698
|
+
* (a replacement must cite the node it shadows, and `assistant/message` may not
|
|
699
|
+
* cite anything) so an impossible write is detected before a model call.
|
|
700
|
+
*
|
|
701
|
+
* @param {any} session - live session.
|
|
702
|
+
* @param {'reasoning' | 'tools'} kind - section kind.
|
|
703
|
+
* @param {number | undefined} seq - the node that would be replaced.
|
|
704
|
+
* @returns {boolean} true when the replacement shape is allowed.
|
|
705
|
+
*/
|
|
706
|
+
function canReplace(session, kind, seq) {
|
|
707
|
+
if (seq === undefined) return false
|
|
708
|
+
const surface = session.surface?.nodes
|
|
709
|
+
if (!Array.isArray(surface) || !surface.includes(seq)) return false
|
|
710
|
+
if (kind === 'tools') return session.eventAt?.(seq)?.type === 'tool/result'
|
|
711
|
+
// Reasoning collapses into a user/message checkpoint, which may cite the node.
|
|
712
|
+
return kind === 'reasoning'
|
|
635
713
|
}
|
|
636
714
|
|
|
637
715
|
/**
|
|
@@ -674,6 +752,14 @@ async function compressSection(ctx, session, policy, kind, seqs, signal, archive
|
|
|
674
752
|
}
|
|
675
753
|
if (totalChars < minTotal) return { items: 0, chars: 0, failed: 0, archived: 0 }
|
|
676
754
|
|
|
755
|
+
// A rejection here is deterministic: the log will refuse the same shape every
|
|
756
|
+
// time. Probing with one throwaway append keeps a doomed batch from spending a
|
|
757
|
+
// model call, so `mode: model` cannot burn requests on writes that cannot land.
|
|
758
|
+
if (!canReplace(session, kind, candidates[0].seq)) {
|
|
759
|
+
lastFailure = `${kind}: surface shape rejected before any model call`
|
|
760
|
+
return { items: 0, chars: 0, failed: 1, archived: 0 }
|
|
761
|
+
}
|
|
762
|
+
|
|
677
763
|
const routed = session.requestContext?.() ?? {}
|
|
678
764
|
const model = policy.model ?? {}
|
|
679
765
|
const modelCfg = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-compact-arsenal",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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",
|