dsh-jev-prune 0.1.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 +19 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/README_zh.md +175 -0
- package/assets/banner.png +0 -0
- package/assets/banner.svg +73 -0
- package/assets/demo-poster.png +0 -0
- package/assets/demo.cast +6 -0
- package/assets/demo.gif +0 -0
- package/assets/demo.json +51 -0
- package/assets/two-layers.png +0 -0
- package/assets/two-layers.svg +118 -0
- package/cordis.patch.yml +3 -0
- package/demo/README.md +23 -0
- package/demo/fixtures.mjs +301 -0
- package/demo/render.py +31 -0
- package/demo/run.mjs +85 -0
- package/docs/ARCHITECTURE.md +63 -0
- package/docs/CONTRIBUTING.md +48 -0
- package/docs/PORTING.md +60 -0
- package/docs/RELEASING.md +27 -0
- package/docs/implementation.md +310 -0
- package/docs/measurements.md +16 -0
- package/docs/review-2026-10-05.md +44 -0
- package/examples/README.md +7 -0
- package/examples/minimal.yml +10 -0
- package/index.js +2043 -0
- package/package.json +77 -0
- package/scripts/inspect_session.mjs +235 -0
- package/scripts/verify_layout.mjs +24 -0
- package/scripts/verify_real_shapes.mjs +99 -0
- package/scripts/wire_profile.mjs +173 -0
- package/src/jev.js +320 -0
- package/src/prune.js +383 -0
- package/src/receipt.js +800 -0
- package/src/state.js +532 -0
- package/test/check.js +1971 -0
- package/test/smoke_apply.mjs +1359 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="330" viewBox="0 0 1200 330" role="img" aria-label="The two layers: tool-result trimming keeps head/tail verbatim, receipt compaction replaces a spent read-only pair with a code-computed receipt">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="rule" x1="0" y1="0" x2="1" y2="0">
|
|
4
|
+
<stop offset="0" stop-color="#4CC9F0" stop-opacity="0.55"/>
|
|
5
|
+
<stop offset="1" stop-color="#F0A868" stop-opacity="0.55"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
|
|
9
|
+
<rect x="0" y="0" width="1200" height="330" rx="14" fill="#0B1220"/>
|
|
10
|
+
<rect x="0.5" y="0.5" width="1199" height="329" rx="13.5" fill="none" stroke="#1E2A38"/>
|
|
11
|
+
<rect x="28" y="0" width="300" height="3" rx="1.5" fill="url(#rule)"/>
|
|
12
|
+
|
|
13
|
+
<g font-family="ui-monospace, SFMono-Regular, Menlo, Consolas, 'DejaVu Sans Mono', monospace">
|
|
14
|
+
|
|
15
|
+
<!-- 行标题 -->
|
|
16
|
+
<g font-size="14" fill="#7D8B9A" text-anchor="end">
|
|
17
|
+
<text x="176" y="60">surface</text>
|
|
18
|
+
<text x="176" y="148">after layer 1</text>
|
|
19
|
+
<text x="176" y="236">after layer 2</text>
|
|
20
|
+
</g>
|
|
21
|
+
|
|
22
|
+
<!-- 受保护的写类调用 -->
|
|
23
|
+
<text x="641" y="22" font-size="12" fill="#F0BE8A">write-type → never compacted</text>
|
|
24
|
+
<line x1="625" y1="27" x2="848" y2="27" stroke="#F0A868" stroke-opacity="0.45" stroke-dasharray="4 4"/>
|
|
25
|
+
|
|
26
|
+
<!-- 第一行:完整 surface -->
|
|
27
|
+
<g>
|
|
28
|
+
<rect x="192" y="32" width="86" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
29
|
+
<text x="235" y="59" font-size="15" fill="#B6C2CF" text-anchor="middle">user</text>
|
|
30
|
+
|
|
31
|
+
<rect x="285" y="32" width="118" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
32
|
+
<text x="344" y="59" font-size="15" fill="#B6C2CF" text-anchor="middle">call · read</text>
|
|
33
|
+
|
|
34
|
+
<rect x="410" y="32" width="208" height="44" rx="8" fill="#4CC9F0" fill-opacity="0.08" stroke="#4CC9F0" stroke-opacity="0.38"/>
|
|
35
|
+
<text x="514" y="59" font-size="15" fill="#9FDCF2" text-anchor="middle">16489 chars out</text>
|
|
36
|
+
|
|
37
|
+
<rect x="625" y="32" width="112" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
38
|
+
<text x="681" y="59" font-size="15" fill="#B6C2CF" text-anchor="middle">call · edit</text>
|
|
39
|
+
|
|
40
|
+
<rect x="744" y="32" width="104" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
41
|
+
<text x="796" y="59" font-size="15" fill="#B6C2CF" text-anchor="middle">applied</text>
|
|
42
|
+
|
|
43
|
+
<rect x="855" y="32" width="118" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
44
|
+
<text x="914" y="59" font-size="15" fill="#B6C2CF" text-anchor="middle">call · read</text>
|
|
45
|
+
|
|
46
|
+
<rect x="980" y="32" width="196" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
47
|
+
<text x="1078" y="59" font-size="15" fill="#B6C2CF" text-anchor="middle">3200 chars out</text>
|
|
48
|
+
</g>
|
|
49
|
+
|
|
50
|
+
<!-- 箭头 1 -->
|
|
51
|
+
<g stroke="#4CC9F0" stroke-opacity="0.75" stroke-width="1.6" fill="none">
|
|
52
|
+
<path d="M514 82 L514 106"/>
|
|
53
|
+
<path d="M509 101 L514 107 L519 101"/>
|
|
54
|
+
</g>
|
|
55
|
+
<text x="536" y="100" font-size="13" fill="#7FD9F5">layer 1 · result trimming — middle cut; head 600 / tail 200 kept verbatim</text>
|
|
56
|
+
|
|
57
|
+
<!-- 第二行:裁中间之后 -->
|
|
58
|
+
<g>
|
|
59
|
+
<rect x="192" y="120" width="86" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
60
|
+
<text x="235" y="147" font-size="15" fill="#B6C2CF" text-anchor="middle">user</text>
|
|
61
|
+
|
|
62
|
+
<rect x="285" y="120" width="118" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
63
|
+
<text x="344" y="147" font-size="15" fill="#B6C2CF" text-anchor="middle">call · read</text>
|
|
64
|
+
|
|
65
|
+
<g>
|
|
66
|
+
<rect x="410" y="120" width="208" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
67
|
+
<rect x="472" y="124" width="84" height="36" rx="5" fill="#F0A868" fill-opacity="0.10" stroke="#F0A868" stroke-opacity="0.40" stroke-dasharray="4 3"/>
|
|
68
|
+
<line x1="472" y1="122" x2="472" y2="162" stroke="#5C6B7A" stroke-dasharray="3 3"/>
|
|
69
|
+
<line x1="556" y1="122" x2="556" y2="162" stroke="#5C6B7A" stroke-dasharray="3 3"/>
|
|
70
|
+
<text x="441" y="147" font-size="12" fill="#B6C2CF" text-anchor="middle">head</text>
|
|
71
|
+
<text x="514" y="147" font-size="12" fill="#F0BE8A" text-anchor="middle">trimmed</text>
|
|
72
|
+
<text x="587" y="147" font-size="12" fill="#B6C2CF" text-anchor="middle">tail</text>
|
|
73
|
+
</g>
|
|
74
|
+
|
|
75
|
+
<rect x="625" y="120" width="112" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
76
|
+
<text x="681" y="147" font-size="15" fill="#B6C2CF" text-anchor="middle">call · edit</text>
|
|
77
|
+
|
|
78
|
+
<rect x="744" y="120" width="104" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
79
|
+
<text x="796" y="147" font-size="15" fill="#B6C2CF" text-anchor="middle">applied</text>
|
|
80
|
+
|
|
81
|
+
<rect x="855" y="120" width="118" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
82
|
+
<text x="914" y="147" font-size="15" fill="#B6C2CF" text-anchor="middle">call · read</text>
|
|
83
|
+
|
|
84
|
+
<rect x="980" y="120" width="196" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
85
|
+
<text x="1078" y="147" font-size="15" fill="#B6C2CF" text-anchor="middle">3200 chars out</text>
|
|
86
|
+
</g>
|
|
87
|
+
|
|
88
|
+
<!-- 箭头 2 -->
|
|
89
|
+
<g stroke="#F0A868" stroke-opacity="0.75" stroke-width="1.6" fill="none">
|
|
90
|
+
<path d="M514 170 L514 194"/>
|
|
91
|
+
<path d="M509 189 L514 195 L519 189"/>
|
|
92
|
+
</g>
|
|
93
|
+
<text x="536" y="188" font-size="13" fill="#F0BE8A">layer 2 · receipt compaction — spent read-only pair replaced by a receipt</text>
|
|
94
|
+
|
|
95
|
+
<!-- 第三行:整对移出,换回执 -->
|
|
96
|
+
<g>
|
|
97
|
+
<rect x="192" y="208" width="86" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
98
|
+
<text x="235" y="235" font-size="15" fill="#B6C2CF" text-anchor="middle">user</text>
|
|
99
|
+
|
|
100
|
+
<rect x="285" y="208" width="233" height="44" rx="8" fill="#F0A868" fill-opacity="0.09" stroke="#F0A868" stroke-opacity="0.55" stroke-dasharray="6 4"/>
|
|
101
|
+
<text x="401" y="235" font-size="13" fill="#F0BE8A" text-anchor="middle">receipt · read → 16489 chars</text>
|
|
102
|
+
|
|
103
|
+
<rect x="525" y="208" width="112" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
104
|
+
<text x="581" y="235" font-size="15" fill="#B6C2CF" text-anchor="middle">call · edit</text>
|
|
105
|
+
|
|
106
|
+
<rect x="644" y="208" width="104" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
107
|
+
<text x="696" y="235" font-size="15" fill="#B6C2CF" text-anchor="middle">applied</text>
|
|
108
|
+
|
|
109
|
+
<rect x="755" y="208" width="118" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
110
|
+
<text x="814" y="235" font-size="15" fill="#B6C2CF" text-anchor="middle">call · read</text>
|
|
111
|
+
|
|
112
|
+
<rect x="880" y="208" width="196" height="44" rx="8" fill="#16212E" stroke="#24313F"/>
|
|
113
|
+
<text x="978" y="235" font-size="15" fill="#B6C2CF" text-anchor="middle">3200 chars out</text>
|
|
114
|
+
</g>
|
|
115
|
+
|
|
116
|
+
<text x="192" y="298" font-size="12" fill="#5C6B7A">every receipt line is computed by code — tool name, command, path, output characters, seq. No model inference.</text>
|
|
117
|
+
</g>
|
|
118
|
+
</svg>
|
package/cordis.patch.yml
ADDED
package/demo/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Deterministic behavior demonstration
|
|
2
|
+
|
|
3
|
+
The 24-second [GIF](../assets/demo.gif) presents captured outputs from the real plugin `apply()`, intercepted pruner, receipt injector, `jev_compact_now` and `jev_restore`. A simulated host and fixed judge scores make the run reproducible without credentials or remote inference. This is not a live DSH screen recording or a judge-quality benchmark.
|
|
4
|
+
|
|
5
|
+
The runner deliberately uses `keepMode: 'absolute'`, `compactMode: 'absolute'`, `compactThreshold: 0.2`, a lowered 1,000-character minimum and zero recent-node protection to isolate these behaviors. Automatic compaction is off until the manual tool is invoked. These are demonstration settings, not production defaults or configuration recommendations.
|
|
6
|
+
|
|
7
|
+
1. Inspect three long read-only tool outputs.
|
|
8
|
+
2. Trim two low-scored outputs; assert the high-scored output is unchanged.
|
|
9
|
+
3. Invoke receipt compaction; assert the protected output remains on the surface.
|
|
10
|
+
4. Retrieve a region checkpoint; assert archived text is returned without altering the surface.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm ci
|
|
14
|
+
node demo/run.mjs
|
|
15
|
+
python -m pip install Pillow==10.2.0
|
|
16
|
+
python demo/render.py
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The runner exits non-zero on an assertion failure, and generates [JSON evidence](../assets/demo.json) plus an [asciinema v2 recording](../assets/demo.cast). Replay the latter with `asciinema play assets/demo.cast`. GIF scene timing is six seconds per stage; runtime output is captured by the runner, not fabricated by the renderer. Long/Unicode output is retained in JSON and cast; the GIF uses a compact ASCII presentation.
|
|
20
|
+
|
|
21
|
+
Recovery here means read-only retrieval from a region summary checkpoint. Current retrieval caps each event at 4,000 UTF-16 units, does not reinsert events into the surface and cannot directly resolve layer-1 or mixed-batch replacement IDs. This demo does not exercise concurrent compaction, goal changes or live judge failures. See [review findings](../docs/review-2026-10-05.md).
|
|
22
|
+
|
|
23
|
+
`fixtures.mjs` is a snapshot of the smoke-test host helpers. When event shapes change, update both fixtures and verify against a live host before claiming new compatibility.
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// Simulated DSH fixtures derived from smoke_apply.mjs; no live API calls.
|
|
2
|
+
function makeSession(plan) {
|
|
3
|
+
const events = new Map()
|
|
4
|
+
let nextSeq = 1
|
|
5
|
+
const add = (type, data) => {
|
|
6
|
+
const seq = nextSeq++
|
|
7
|
+
events.set(seq, { seq, type, data })
|
|
8
|
+
return seq
|
|
9
|
+
}
|
|
10
|
+
const addStep = (tool, args, text) => {
|
|
11
|
+
const callId = `c${nextSeq + 1}`
|
|
12
|
+
const head = add('assistant/message', {
|
|
13
|
+
message: { content: [{ type: 'text', text: '看一下。' }, { type: 'tool-call', id: callId, name: tool, arguments: args }] },
|
|
14
|
+
})
|
|
15
|
+
const result = add('tool/result', {
|
|
16
|
+
message: { source: { callId }, content: [{ type: 'tool-result', content: [{ type: 'text', text }] }] },
|
|
17
|
+
})
|
|
18
|
+
return { head, result, tool }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
add('user/message', { content: [{ type: 'text', text: '排查河牌底池计算,先摸结构。' }], source: { kind: 'user' } })
|
|
22
|
+
// 三步都必须在默认只读白名单内(read/grep/glob)——默认配置下 shell 工具进不了候选
|
|
23
|
+
const effectivePlan = plan ?? [
|
|
24
|
+
{ tool: 'Read', args: { file_path: 'server/src/game/river.ts' }, chars: 6000 },
|
|
25
|
+
{ tool: 'Grep', args: { pattern: 'basePot', path: 'server/src' }, chars: 4000 },
|
|
26
|
+
{ tool: 'Glob', args: { pattern: '*.ts', path: 'server/src' }, chars: 6000 },
|
|
27
|
+
]
|
|
28
|
+
const steps = []
|
|
29
|
+
for (const item of effectivePlan) {
|
|
30
|
+
if (item.interleave) {
|
|
31
|
+
// 插一条不含 tool-call 的 assistant 文本,把前后两组只读步骤**切成两段**,
|
|
32
|
+
// 这样 maxCompactionsPerPass > 1 才有第二个可压区间
|
|
33
|
+
add('assistant/message', { message: { content: [{ type: 'text', text: item.interleave }] } })
|
|
34
|
+
}
|
|
35
|
+
const fill = item.fill ?? 'a'
|
|
36
|
+
steps.push(addStep(item.tool, item.args, fill.repeat(item.chars)))
|
|
37
|
+
}
|
|
38
|
+
const step1 = steps[0]
|
|
39
|
+
const step2 = steps[1]
|
|
40
|
+
const step3 = steps[2]
|
|
41
|
+
|
|
42
|
+
const tail = add('assistant/message', { message: { content: [{ type: 'text', text: '继续。' }] } })
|
|
43
|
+
|
|
44
|
+
const appended = []
|
|
45
|
+
const session = {
|
|
46
|
+
seqs: { s1: step1.result, s2: step2.result, s3: step3.result },
|
|
47
|
+
steps: [step1, step2, step3],
|
|
48
|
+
tail,
|
|
49
|
+
appended,
|
|
50
|
+
// ⚠️ 刻意**不**提供 `.events`:实测活的 DSH 会话对象上 `session.events` 是 undefined,
|
|
51
|
+
// 只有 `eventAt(seq)` 与 `surface.nodes` 可用。假对象要复刻这个现实,
|
|
52
|
+
// 否则会掩盖"依赖 .events 的 bug"(那个 bug 让工具名索引在活宿主里建出 0 条)。
|
|
53
|
+
surface: { nodes: [...events.keys()] },
|
|
54
|
+
eventAt: (seq) => events.get(seq) ?? null,
|
|
55
|
+
deriveEventMessage: (event) => event.data.message,
|
|
56
|
+
append(type, data, options) {
|
|
57
|
+
const seq = nextSeq++
|
|
58
|
+
events.set(seq, { seq, type, data, ...options })
|
|
59
|
+
appended.push({ seq, type, data, options })
|
|
60
|
+
const op = options?.surfaceOp
|
|
61
|
+
if (op === 'append') {
|
|
62
|
+
session.surface.nodes.push(seq)
|
|
63
|
+
} else if (op?.op === 'replace') {
|
|
64
|
+
const start = session.surface.nodes.indexOf(op.startSeq)
|
|
65
|
+
const end = session.surface.nodes.indexOf(op.endSeq)
|
|
66
|
+
if (start < 0 || end < start) throw new Error(`fake surface replace: 非法区间 ${op.startSeq}-${op.endSeq}`)
|
|
67
|
+
session.surface.nodes.splice(start, end - start + 1, seq)
|
|
68
|
+
}
|
|
69
|
+
return { seq }
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
return session
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 一个 assistant 消息并行发出 6 个 read,随后跟 6 个结果(issue #39 真实形态)。 */
|
|
76
|
+
function makeBatchSession() {
|
|
77
|
+
const events = new Map()
|
|
78
|
+
let nextSeq = 1
|
|
79
|
+
const appended = []
|
|
80
|
+
const add = (type, data, options = {}) => {
|
|
81
|
+
const seq = nextSeq++
|
|
82
|
+
events.set(seq, { seq, type, data, ...options })
|
|
83
|
+
return seq
|
|
84
|
+
}
|
|
85
|
+
add('user/message', { content: [{ type: 'text', text: '批量读六个文件。' }], source: { kind: 'user' } })
|
|
86
|
+
const calls = Array.from({ length: 6 }, (_, i) => ({
|
|
87
|
+
type: 'tool-call', id: `batch-${i + 1}`, name: 'Read',
|
|
88
|
+
arguments: { file_path: `src/file-${i + 1}.js` },
|
|
89
|
+
}))
|
|
90
|
+
const head = add('assistant/message', {
|
|
91
|
+
message: { content: [{ type: 'text', text: '批量读取。' }, ...calls] },
|
|
92
|
+
})
|
|
93
|
+
const results = calls.map((call, i) => add('tool/result', {
|
|
94
|
+
message: {
|
|
95
|
+
source: { callId: call.id },
|
|
96
|
+
content: [{ type: 'tool-result', content: [{ type: 'text', text: String(i + 1).repeat(3000) }] }],
|
|
97
|
+
},
|
|
98
|
+
}))
|
|
99
|
+
add('assistant/message', { message: { content: [{ type: 'text', text: '继续。' }] } })
|
|
100
|
+
const session = {
|
|
101
|
+
seqs: Object.fromEntries(results.map((seq, i) => [`s${i + 1}`, seq])),
|
|
102
|
+
steps: [{ head, results }],
|
|
103
|
+
appended,
|
|
104
|
+
surface: { nodes: [...events.keys()] },
|
|
105
|
+
eventAt: (seq) => events.get(seq) ?? null,
|
|
106
|
+
deriveEventMessage: (event) => event.data.message,
|
|
107
|
+
append(type, data, options) {
|
|
108
|
+
const seq = add(type, data, options)
|
|
109
|
+
appended.push({ seq, type, data, options })
|
|
110
|
+
const op = options?.surfaceOp
|
|
111
|
+
if (op === 'append') session.surface.nodes.push(seq)
|
|
112
|
+
else if (op?.op === 'replace') {
|
|
113
|
+
const start = session.surface.nodes.indexOf(op.startSeq)
|
|
114
|
+
const end = session.surface.nodes.indexOf(op.endSeq)
|
|
115
|
+
if (start < 0 || end < start) throw new Error(`fake surface replace: 非法区间 ${op.startSeq}-${op.endSeq}`)
|
|
116
|
+
session.surface.nodes.splice(start, end - start + 1, seq)
|
|
117
|
+
}
|
|
118
|
+
return { seq }
|
|
119
|
+
},
|
|
120
|
+
}
|
|
121
|
+
return session
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function makePruner() {
|
|
125
|
+
let pruneContentCalls = 0
|
|
126
|
+
return {
|
|
127
|
+
get pruneContentCalls() { return pruneContentCalls },
|
|
128
|
+
ctx: { tokenMeter: { estimateMessage: () => 123 } },
|
|
129
|
+
measureContent: (blocks) => blocks.reduce((n, b) => n + (b?.text ? Array.from(b.text).length : 0), 0),
|
|
130
|
+
// 与 DSH 同口径:超过阈值才裁;这里设成极高,模拟"体积启发式不动作"
|
|
131
|
+
pruneContent(blocks) {
|
|
132
|
+
pruneContentCalls += 1
|
|
133
|
+
const chars = blocks.reduce((n, b) => n + (b?.text ? Array.from(b.text).length : 0), 0)
|
|
134
|
+
if (chars <= 100000) return null
|
|
135
|
+
return blocks
|
|
136
|
+
},
|
|
137
|
+
/**
|
|
138
|
+
* 基线 pruneSession —— 忠实复刻 DSH 自带实现的循环与 append 协议。
|
|
139
|
+
* 插件会**接管**这个方法;这里的实现用来验证"接管前它是可用的"
|
|
140
|
+
* (插件的 guard 要求 pruner.pruneSession 是函数,否则拒绝接管)。
|
|
141
|
+
*/
|
|
142
|
+
pruneSession(session) {
|
|
143
|
+
const pruned = []
|
|
144
|
+
let charsRemoved = 0
|
|
145
|
+
for (const seq of [...session.surface.nodes]) {
|
|
146
|
+
const event = session.eventAt(seq)
|
|
147
|
+
if (event?.type !== 'tool/result') continue
|
|
148
|
+
const original = session.deriveEventMessage(event)
|
|
149
|
+
const result = original?.content?.[0]
|
|
150
|
+
if (result == null) continue
|
|
151
|
+
const content = this.pruneContent(result.content)
|
|
152
|
+
if (content == null) continue
|
|
153
|
+
const before = this.measureContent(result.content)
|
|
154
|
+
const after = this.measureContent(content)
|
|
155
|
+
session.append('compaction/prune', {
|
|
156
|
+
shadowedRange: { start: seq, end: seq },
|
|
157
|
+
shadowedSeqs: [seq],
|
|
158
|
+
shadowedTokenCount: this.ctx.tokenMeter.estimateMessage(original),
|
|
159
|
+
})
|
|
160
|
+
session.append('tool/result',
|
|
161
|
+
{ ...event.data, message: { ...original, content: [{ ...result, content }] } },
|
|
162
|
+
{ surfaceOp: { op: 'replace', startSeq: seq, endSeq: seq }, sourceEventSeqs: [seq] })
|
|
163
|
+
pruned.push({ originalSeq: seq, charsBefore: before, charsAfter: after })
|
|
164
|
+
charsRemoved += before - after
|
|
165
|
+
}
|
|
166
|
+
return { pruned, charsRemoved }
|
|
167
|
+
},
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 假 compaction 服务。行为对齐 DSH `BasicCompactionEngine` 的关键契约:
|
|
173
|
+
* · `compactRegion` 内部通过 `this.summarize(...)` **动态派发**(所以我们猴补丁实例方法才有效)
|
|
174
|
+
* · `summarize` 返回 `{ summary, provider, model }`,summary 是内容块数组
|
|
175
|
+
* · 回执必须比被压缩的区间更小,否则后端会抛错(这里也照做)
|
|
176
|
+
*/
|
|
177
|
+
function makeCompaction(session) {
|
|
178
|
+
const calls = []
|
|
179
|
+
const summaries = []
|
|
180
|
+
return {
|
|
181
|
+
calls,
|
|
182
|
+
summaries,
|
|
183
|
+
async summarize(input, agent, signal) {
|
|
184
|
+
summaries.push({ input, injected: false })
|
|
185
|
+
return { summary: [{ type: 'text', text: '(基线模型摘要)' }], provider: 'fake', model: 'fake-model' }
|
|
186
|
+
},
|
|
187
|
+
async compactRegion(start, end, agent, signal) {
|
|
188
|
+
const nodes = session.surface.nodes
|
|
189
|
+
const startIdx = nodes.indexOf(start)
|
|
190
|
+
const endIdx = nodes.indexOf(end)
|
|
191
|
+
if (startIdx < 0 || endIdx < 0 || startIdx > endIdx) throw new Error(`compactRegion: 非法区间 ${start}-${end}`)
|
|
192
|
+
// 关键:模拟后端内部这一次动态派发 —— 猴补丁必须在这里被调用到
|
|
193
|
+
const summarized = await this.summarize({ messages: [] }, agent, signal)
|
|
194
|
+
const text = summarized.summary.map((b) => b.text ?? '').join('')
|
|
195
|
+
calls.push({ start, end, nodes: nodes.slice(startIdx, endIdx + 1), summary: text, provider: summarized.provider })
|
|
196
|
+
return {
|
|
197
|
+
compactionId: 'fake-compaction',
|
|
198
|
+
shadowedRange: { start, end },
|
|
199
|
+
shadowedSeqs: nodes.slice(startIdx, endIdx + 1),
|
|
200
|
+
shadowedTokenCount: 1234,
|
|
201
|
+
summary: summarized.summary,
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function makeCtx({ pruner, session, compaction }) {
|
|
208
|
+
const handlers = new Map()
|
|
209
|
+
const registeredTools = []
|
|
210
|
+
const registeredCommands = []
|
|
211
|
+
const tokenMeter = {
|
|
212
|
+
measure: (s) => {
|
|
213
|
+
const nodes = (s?.surface?.nodes ?? []).map((seq) => {
|
|
214
|
+
const event = s.eventAt(seq)
|
|
215
|
+
const blocks = event?.data?.message?.content?.[0]?.content
|
|
216
|
+
const text = Array.isArray(blocks) ? blocks.map((b) => b.text ?? '').join('') : ''
|
|
217
|
+
return { seq, tokens: Math.ceil(text.length / 4), heuristicTokens: Math.ceil(text.length / 4) }
|
|
218
|
+
})
|
|
219
|
+
return { totalTokens: nodes.reduce((sum, n) => sum + n.tokens, 0), nodes }
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
const services = new Map([
|
|
223
|
+
['toolResultPruner', pruner],
|
|
224
|
+
['tokenMeter', tokenMeter],
|
|
225
|
+
['compaction', compaction],
|
|
226
|
+
['commands', { register: (c) => { registeredCommands.push(c); return c } }],
|
|
227
|
+
])
|
|
228
|
+
const toolsService = { register: (t) => { registeredTools.push(t); return t } }
|
|
229
|
+
services.set('tools', toolsService)
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
registeredTools,
|
|
233
|
+
registeredCommands,
|
|
234
|
+
handlers,
|
|
235
|
+
// Cordis 两种取法都要支持:ctx.get('x') 与 ctx.x
|
|
236
|
+
get: (n) => services.get(n) ?? null,
|
|
237
|
+
tools: toolsService,
|
|
238
|
+
commands: services.get('commands'),
|
|
239
|
+
tokenMeter,
|
|
240
|
+
compaction,
|
|
241
|
+
// 与 cordis 同构:同一事件可以有**多个**监听器,第三个参数(或 options.prepend)
|
|
242
|
+
// 决定插入队首还是队尾。此前这里只是 Map<event, fn> —— 第二个监听会**覆盖**第一个,
|
|
243
|
+
// 而且 prepend 被完全忽略。于是"判定钩子 prepend 到 compaction-basic 之前"这条
|
|
244
|
+
// 真实语义在测试里根本不存在(拆成两个监听后 30/72 项直接失败)。
|
|
245
|
+
// 假对象必须复刻宿主这个形状,否则测的是一套不存在的语义。
|
|
246
|
+
on: (evt, fn, options) => {
|
|
247
|
+
const prepend = typeof options === 'object' && options !== null
|
|
248
|
+
? options.prepend === true
|
|
249
|
+
: options === true
|
|
250
|
+
const list = handlers.get(evt) ?? []
|
|
251
|
+
if (prepend) list.unshift(fn)
|
|
252
|
+
else list.push(fn)
|
|
253
|
+
handlers.set(evt, list)
|
|
254
|
+
},
|
|
255
|
+
/**
|
|
256
|
+
* 忠实复刻 cordis 的 waterfall(`agent/pre-step` 正是这类事件):
|
|
257
|
+
* 最外层先跑,最后一个参数是内层 next;监听器**不调用 next() 即否决**后续链路
|
|
258
|
+
* (含宿主内建行为)。所以测试里不能"取一个 handler 直接调"——链上有几个监听、
|
|
259
|
+
* 谁先谁后,恰恰是这次要验证的东西。
|
|
260
|
+
*/
|
|
261
|
+
waterfall: (name, ...args) => {
|
|
262
|
+
const cbs = [...(handlers.get(name) ?? [])]
|
|
263
|
+
const inner = args.pop()
|
|
264
|
+
const next = () => (cbs.shift() ?? inner)(...args)
|
|
265
|
+
args.push(next)
|
|
266
|
+
return next()
|
|
267
|
+
},
|
|
268
|
+
effect: (fn) => fn(),
|
|
269
|
+
logger: { info: () => {}, debug: () => {}, warn: () => {}, error: () => {} },
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ---------------------------------------------------------------- 假 Jev
|
|
274
|
+
/**
|
|
275
|
+
* 只读概率、不看文本 —— 与真实现同语义。
|
|
276
|
+
* @param {Record<number, number>} resultPreset 结果可丢性 P(保留)
|
|
277
|
+
* @param {Record<number, number>} effectPreset 副作用 P(有副作用)
|
|
278
|
+
*/
|
|
279
|
+
function fakeJudge(resultPreset, effectPreset = {}) {
|
|
280
|
+
return {
|
|
281
|
+
ready: true,
|
|
282
|
+
requests: 0,
|
|
283
|
+
usage: { input_tokens: 0, output_tokens: 0 },
|
|
284
|
+
batch: (_state, questions) => [questions],
|
|
285
|
+
async ask(_state, questions) {
|
|
286
|
+
this.requests += 1
|
|
287
|
+
const out = {}
|
|
288
|
+
for (const id of Object.keys(questions)) {
|
|
289
|
+
const match = /^(result|effect)_s(\d+)$/.exec(id)
|
|
290
|
+
if (match == null) continue
|
|
291
|
+
const seq = Number(match[2])
|
|
292
|
+
const preset = match[1] === 'result' ? resultPreset : effectPreset
|
|
293
|
+
if (preset[seq] != null) out[id] = preset[seq]
|
|
294
|
+
}
|
|
295
|
+
return out
|
|
296
|
+
},
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
export { makeSession, makePruner, makeCtx, fakeJudge };
|
package/demo/render.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Render the captured demo output as an animated GIF. Requires Pillow."""
|
|
2
|
+
import json
|
|
3
|
+
import textwrap
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from PIL import Image, ImageDraw, ImageFont
|
|
6
|
+
|
|
7
|
+
root = Path(__file__).resolve().parent.parent
|
|
8
|
+
data = json.loads((root / 'assets/demo.json').read_text(encoding='utf-8'))
|
|
9
|
+
font = ImageFont.load_default(size=19)
|
|
10
|
+
title_font = ImageFont.load_default(size=26)
|
|
11
|
+
frames = []
|
|
12
|
+
for i, stage in enumerate(data['stages']):
|
|
13
|
+
image = Image.new('RGB', (1120, 640), '#0e1523')
|
|
14
|
+
draw = ImageDraw.Draw(image)
|
|
15
|
+
draw.rounded_rectangle((24, 24, 1096, 616), radius=18, fill='#182338', outline='#354764', width=2)
|
|
16
|
+
draw.text((48, 46), 'dsh-jev-prune | recorded deterministic run', font=title_font, fill='#f2f6ff')
|
|
17
|
+
draw.text((48, 90), 'REAL PLUGIN / SIMULATED HOST / FIXED JUDGE', font=font, fill='#8dc7ff')
|
|
18
|
+
draw.line((48, 130, 1070, 130), fill='#354764', width=2)
|
|
19
|
+
draw.text((48, 150), stage['title'], font=title_font, fill='#9eebc5')
|
|
20
|
+
y = 205
|
|
21
|
+
for line in stage['lines']:
|
|
22
|
+
# Render only ASCII in the GIF; full Unicode receipt stays in JSON/cast.
|
|
23
|
+
if not line.isascii():
|
|
24
|
+
line = '[Chinese receipt output captured verbatim in demo.json / demo.cast]'
|
|
25
|
+
for part in textwrap.wrap(line, width=88) or ['']:
|
|
26
|
+
draw.text((48, y), part, font=font, fill='#e4ebf6')
|
|
27
|
+
y += 29
|
|
28
|
+
draw.text((48, 576), f'{i+1}/4 | No live API calls | Reproduce: node demo/run.mjs', font=font, fill='#98a8bf')
|
|
29
|
+
frames.append(image)
|
|
30
|
+
frames[0].save(root / 'assets/demo.gif', save_all=True, append_images=frames[1:], duration=6000, loop=0, optimize=True)
|
|
31
|
+
frames[-1].save(root / 'assets/demo-poster.png')
|
package/demo/run.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {mkdirSync, writeFileSync} from 'node:fs';
|
|
3
|
+
import {fileURLToPath} from 'node:url';
|
|
4
|
+
import {apply} from '../index.js';
|
|
5
|
+
import {eventText} from '../src/state.js';
|
|
6
|
+
import {makeSession,makePruner,makeCtx,fakeJudge} from './fixtures.mjs';
|
|
7
|
+
|
|
8
|
+
const session=makeSession();
|
|
9
|
+
const events=[...session.surface.nodes].map(seq=>session.eventAt(seq));
|
|
10
|
+
session.snapshotEvents=()=>events;
|
|
11
|
+
const append=session.append.bind(session);
|
|
12
|
+
session.append=(...args)=>{const result=append(...args); events.push(session.eventAt(result.seq)); return result;};
|
|
13
|
+
const original=eventText(session.eventAt(session.seqs.s1));
|
|
14
|
+
const protectedOriginal=eventText(session.eventAt(session.seqs.s3));
|
|
15
|
+
const pruner=makePruner();
|
|
16
|
+
const compaction={
|
|
17
|
+
async summarize(){throw Error('Demo must inject a deterministic receipt');},
|
|
18
|
+
async compactRegion(start,end,agent,signal){
|
|
19
|
+
const nodes=session.surface.nodes;
|
|
20
|
+
const seqs=nodes.slice(nodes.indexOf(start),nodes.indexOf(end)+1);
|
|
21
|
+
const result=await this.summarize({messages:seqs.map(seq=>session.deriveEventMessage(session.eventAt(seq)))},agent,signal);
|
|
22
|
+
assert.equal(result.provider,'jev-receipt');
|
|
23
|
+
const compactionId='demo-region-1';
|
|
24
|
+
const record=session.append('compaction/summary',{compactionId,shadowedRange:{start,end},shadowedSeqs:seqs,summary:result.summary});
|
|
25
|
+
session.append('user/message',{source:{kind:'plugin',plugin:'compact',compactionId},content:result.summary},{surfaceOp:{op:'replace',startSeq:start,endSeq:end},sourceEventSeqs:seqs});
|
|
26
|
+
return {compactionId,shadowedSeqs:seqs,recordSeq:record.seq};
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
const ctx=makeCtx({session,pruner,compaction});
|
|
30
|
+
const judge=fakeJudge({3:0.05,5:0.06,7:0.95},{3:0.05,5:0.05,7:0.9});
|
|
31
|
+
apply(ctx,{judgeOn:'always',compactOn:'off',preserveRecent:0,compactPreserveRecent:0,compactMode:'absolute',compactThreshold:0.2,compactMinChars:1000,keepMode:'absolute',logLevel:'silent'},{judge});
|
|
32
|
+
const exec={agent:{session,options:{}}};
|
|
33
|
+
const stages=[];
|
|
34
|
+
const stage=(title,lines)=>{stages.push({title,lines}); console.log('\n'+title+'\n'+lines.join('\n'));};
|
|
35
|
+
stage('01 / Inspect long tool output',[
|
|
36
|
+
'REAL PLUGIN / SIMULATED DSH HOST / FIXED JUDGE SCORES',
|
|
37
|
+
'No live TypeSafe requests. No live-host compatibility claim.',
|
|
38
|
+
'Read s3: 6,000 characters; keep score 0.05',
|
|
39
|
+
'Read s7: 6,000 characters; keep score 0.95 (protected)',
|
|
40
|
+
]);
|
|
41
|
+
await ctx.waterfall('agent/pre-step',exec,()=>{});
|
|
42
|
+
const trimmed=pruner.pruneSession(session);
|
|
43
|
+
assert.equal(trimmed.pruned.length,2);
|
|
44
|
+
assert.equal(eventText(session.eventAt(7)),protectedOriginal);
|
|
45
|
+
const replacement=trimmed.pruned.find(row=>row.originalSeq===3);
|
|
46
|
+
assert.ok(replacement);
|
|
47
|
+
assert.ok(eventText(session.eventAt(replacement.replacementSeq)).length<original.length);
|
|
48
|
+
stage('02 / Trim spent output; preserve useful evidence',[
|
|
49
|
+
'Actual pruneSession result: '+trimmed.pruned.length+' outputs trimmed',
|
|
50
|
+
's3: '+replacement.charsBefore+' -> '+replacement.charsAfter+' characters',
|
|
51
|
+
's7: unchanged, byte-for-byte equality assertion passed',
|
|
52
|
+
'Original s3 remains in the session event log',
|
|
53
|
+
]);
|
|
54
|
+
const compact=ctx.registeredTools.find(tool=>tool.name==='jev_compact_now');
|
|
55
|
+
await compact.execute({},exec);
|
|
56
|
+
const summary=events.find(event=>event.type==='compaction/summary');
|
|
57
|
+
assert.ok(summary,'A real plugin receipt must be injected');
|
|
58
|
+
stage('03 / Inject a deterministic region receipt',[
|
|
59
|
+
'jev_compact_now invoked the real registered tool',
|
|
60
|
+
'Provider: jev-receipt / Model: deterministic',
|
|
61
|
+
'Original region: s'+summary.data.shadowedRange.start+'-s'+summary.data.shadowedRange.end,
|
|
62
|
+
'Protected s7 remains on the surface',
|
|
63
|
+
...eventText(summary).split('\n').slice(0,4),
|
|
64
|
+
]);
|
|
65
|
+
assert.ok(session.surface.nodes.includes(7));
|
|
66
|
+
const restore=ctx.registeredTools.find(tool=>tool.name==='jev_restore');
|
|
67
|
+
const before=[...session.surface.nodes];
|
|
68
|
+
const restored=await restore.execute({seq:summary.seq},exec);
|
|
69
|
+
assert.ok(restored.includes('a'.repeat(600)));
|
|
70
|
+
assert.deepEqual(session.surface.nodes,before);
|
|
71
|
+
stage('04 / Retrieve checkpoint text without changing the surface',[
|
|
72
|
+
'jev_restore({ seq: '+summary.seq+' }) returned hidden region text',
|
|
73
|
+
'Archived tool output excerpt found: assertion passed',
|
|
74
|
+
'Surface unchanged: assertion passed',
|
|
75
|
+
'Scope: REGION checkpoint only; each event is capped at 4,000 units',
|
|
76
|
+
'Layer-1 / partial-result IDs are not directly restorable yet',
|
|
77
|
+
'All demonstration assertions passed',
|
|
78
|
+
]);
|
|
79
|
+
const assets=fileURLToPath(new URL('../assets/',import.meta.url));
|
|
80
|
+
mkdirSync(assets,{recursive:true});
|
|
81
|
+
writeFileSync(assets+'demo.json',JSON.stringify({format:1,host:'simulated',judge:'fixed probabilities',liveApiCalls:0,stages,assertionsPassed:true},null,2)+'\n');
|
|
82
|
+
const cast=[JSON.stringify({version:2,width:120,height:24,title:'dsh-jev-prune deterministic demo (simulated host)',env:{TERM:'xterm-256color'}})];
|
|
83
|
+
stages.forEach((item,i)=>cast.push(JSON.stringify([i*6,'o','\u001b[2J\u001b[H'+item.title+'\r\n\r\n'+item.lines.join('\r\n')+'\r\n'])));
|
|
84
|
+
cast.push(JSON.stringify([24,'o','\r\nDemo complete.\r\n']));
|
|
85
|
+
writeFileSync(assets+'demo.cast',cast.join('\n')+'\n');
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Architecture
|
|
2
|
+
|
|
3
|
+
`dsh-jev-prune` adds two context-reduction layers to DeepSeek Harness while keeping DSH's event log as the source of truth.
|
|
4
|
+
|
|
5
|
+
```mermaid
|
|
6
|
+
flowchart LR
|
|
7
|
+
J[jev.js\njudge client + token estimate]
|
|
8
|
+
P[prune.js\nlayer 1 decisions]
|
|
9
|
+
S[state.js\nDSH event projection]
|
|
10
|
+
R[receipt.js\nlayer 2 range selection]
|
|
11
|
+
I[index.js\nhost adapter + lifecycle]
|
|
12
|
+
S --> J
|
|
13
|
+
S --> P
|
|
14
|
+
R --> P
|
|
15
|
+
I --> J
|
|
16
|
+
I --> P
|
|
17
|
+
I --> S
|
|
18
|
+
I --> R
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Modules
|
|
22
|
+
|
|
23
|
+
- `jev.js` owns the remote judge protocol, retry classification, batching, and local token estimation.
|
|
24
|
+
- `prune.js` contains layer-1 pure logic: cached-verdict lookup, pressure-budget planning, code-point-safe slicing, and DSH-compatible replacement events.
|
|
25
|
+
- `state.js` converts session events into the bounded state and questions sent to Jev. It also discovers tool names and selects candidates.
|
|
26
|
+
- `receipt.js` contains layer-2 pure logic: tool-pair balance checks, evidence guards, two-axis eligibility, contiguous range selection, and deterministic receipt rendering.
|
|
27
|
+
- `index.js` is the only host adapter. It resolves configuration, installs DSH hooks, owns per-session caches, invokes both layers, exposes tools/commands, and writes observability snapshots.
|
|
28
|
+
|
|
29
|
+
The dependency direction stays toward the pure modules. Host APIs must remain in `index.js`; moving them into `prune.js` or `receipt.js` would make the safety logic harder to test outside DSH.
|
|
30
|
+
|
|
31
|
+
## Runtime flow
|
|
32
|
+
|
|
33
|
+
1. A prepended `agent/pre-step` hook builds the current state and asks Jev only for missing verdict axes.
|
|
34
|
+
2. DSH's `compaction-basic` hook calls the synchronously overridden `toolResultPruner.pruneSession`. Layer 1 reads the verdict cache and replaces stale result bodies with head/marker/tail content.
|
|
35
|
+
3. The normal second plugin hook selects read-only call/result pairs whose result and effect verdicts are both low. Fully eligible steps become balanced contiguous ranges; mixed parallel batches become per-result replacements matched by `callId`.
|
|
36
|
+
4. Full ranges run through `compactRegion`. A one-use ownership token lets the temporary `summarize` override inject a deterministic receipt only for that transaction. Mixed batches use DSH's single-node shadow-price + `tool/result` replacement protocol, preserving every pair envelope and every rejected result.
|
|
37
|
+
5. Original events remain in the session log. The surface points to replacement or compaction events, and `jev_restore` can retrieve the shadowed text.
|
|
38
|
+
|
|
39
|
+
## State and identity
|
|
40
|
+
|
|
41
|
+
Verdicts are held in a `WeakMap<session, Map<seq, verdict>>`. A layer-1 replacement receives a new seq and records the old seq in `sourceEventSeqs`; cache lookup follows that metadata so both layers keep the same judgment. In-memory verdicts are rebuilt after a process restart.
|
|
42
|
+
|
|
43
|
+
The pressure ratio is also session-scoped. `judgePass` computes it asynchronously, and the synchronous layer-1 override consumes it later in the same pre-step chain.
|
|
44
|
+
|
|
45
|
+
## Safety invariants
|
|
46
|
+
|
|
47
|
+
- Never split an unbalanced tool-call/result range.
|
|
48
|
+
- Never compact write-like tools; layer 2 uses a read-only allow-list plus a deny-list.
|
|
49
|
+
- Preserve recent surface nodes independently for each layer.
|
|
50
|
+
- Scan replacement source chains for error/assert/failure evidence.
|
|
51
|
+
- Fall back conservatively when the context window, event shape, or judge result is unavailable.
|
|
52
|
+
- Inject a receipt only when the active transaction owns the pending token.
|
|
53
|
+
- Keep deterministic receipts factual: tool, arguments, seq, and output size; no model-generated conclusion.
|
|
54
|
+
|
|
55
|
+
## Parallel batch boundary
|
|
56
|
+
|
|
57
|
+
One assistant message may contain several parallel tool calls. They share one head event, and DSH 0.1.5's contiguous-range API cannot remove only a subset of those pairs. Mixed batches therefore compact eligible result bodies in place; only a fully eligible batch removes its assistant head and result nodes. This keeps the surface valid without fabricating assistant messages or depending on an unsupported multi-node insertion API.
|
|
58
|
+
|
|
59
|
+
## Implementation reference
|
|
60
|
+
|
|
61
|
+
Detailed configuration, gating, protocol history and troubleshooting moved to [the implementation reference](implementation.md). Measurement notes are in [measurements](measurements.md). The recorded [demo](../demo/README.md) runs real plugin code with a simulated host.
|
|
62
|
+
|
|
63
|
+
The current fencing token supports claim-once behavior, but does not prove which external compaction owns a summary call. See [the reviewed limitations](review-2026-10-05.md) before using concurrent compaction.
|