@aiwayds/dsh-dcp 0.3.2 → 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/README.en.md +28 -4
- package/README.md +22 -4
- package/lib/command.js +23 -2
- package/lib/config.js +15 -0
- package/lib/index.js +213 -4
- package/lib/summarizer.js +13 -0
- package/package.json +1 -2
- package/cordis.patch.example.yml +0 -19
package/README.en.md
CHANGED
|
@@ -79,7 +79,7 @@ A checkpoint produced on a real session (Chinese content kept verbatim):
|
|
|
79
79
|
`compaction-basic`
|
|
80
80
|
- **Things dsh already does, deliberately not re-implemented**:
|
|
81
81
|
- tool-result pruning (`compaction-tool-result-pruner`, deterministic by size)
|
|
82
|
-
- trigger policy, retained tail, overflow recovery (inherited from official)
|
|
82
|
+
- trigger policy, retained tail, overflow recovery (inherited from official; this plugin only adds the round-interval trigger, see below)
|
|
83
83
|
- `/compact` command, UI checkpoint cards (shipped with dsh)
|
|
84
84
|
|
|
85
85
|
## Install
|
|
@@ -112,7 +112,27 @@ npx dsh-dcp-setup # safe: date-stamped backup → append-only
|
|
|
112
112
|
| `/dcp set <k> <v>` | adjust a knob for this session, with a persist hint |
|
|
113
113
|
|
|
114
114
|
Settable: `dedup`, `purgeErrors`, `maxItems`, `maxItemChars`,
|
|
115
|
-
`maxSummaryTokens`, `language`, `tokenEstimate`, `thresholdRatio
|
|
115
|
+
`maxSummaryTokens`, `language`, `tokenEstimate`, `thresholdRatio`,
|
|
116
|
+
`roundInterval`, `notice`.
|
|
117
|
+
|
|
118
|
+
The `/dcp` status also lists every session that has compacted (subagents
|
|
119
|
+
included): `per-session: session-1 (2 compactions, ~444 tokens), child
|
|
120
|
+
(1 compaction, ~22 tokens)`. Compactions count per session; disposed
|
|
121
|
+
sessions (one-shot subagents included) fall out of the overview
|
|
122
|
+
automatically, and the list is capped at the first 10 sessions (`+N more`
|
|
123
|
+
for the rest) so the status stays one line.
|
|
124
|
+
|
|
125
|
+
## Triggers
|
|
126
|
+
|
|
127
|
+
| Trigger | When | Notes |
|
|
128
|
+
|---|---|---|
|
|
129
|
+
| Pressure | before every step | tokens ≥ `thresholdRatio` (inherited upstream default 0.8; this plugin's bundle mounts 0.7 — see config table) × context window |
|
|
130
|
+
| Overflow recovery | on a provider context-window error | inherited |
|
|
131
|
+
| **Round interval** | every `roundInterval` assistant messages | added by this plugin; one round = one LLM roundtrip (each tool-iteration response counts, so one-shot subagents trigger too). **Default 50**: first compaction after message 50, then every 50 more (100, 150, …); any compaction (pressure included) restarts the clock. Fires at the first idle boundary after the count is reached (below the pressure threshold too). `0` disables; requires the default `auto: true` |
|
|
132
|
+
| Manual | `/dcp compact`, `/compact` | anytime |
|
|
133
|
+
|
|
134
|
+
- **Subagents are covered**: in-process subagents (including continuable and one-shot children) dispatch through the same events, so pressure/overflow/round triggers count and fire per child session independently. The round trigger counts assistant messages, so a one-shot subagent whose whole run is a single turn (many tool iterations) triggers too.
|
|
135
|
+
- **Visibility**: after every trigger event a one-line notice row (`dcp: compacted N history items (~X tokens, trigger)`) is appended to the session; frontends render it as a collapsed row. Note the row also rides the model request context (~15–25 tokens per compaction), and it is **on by default since 0.4.0** — disable with `notice: false`. `/dcp` stats count every committed region (a pressure retry loop may commit several).
|
|
116
136
|
|
|
117
137
|
## Configuration
|
|
118
138
|
|
|
@@ -120,7 +140,9 @@ All optional, defaults work out of the box:
|
|
|
120
140
|
|
|
121
141
|
| Key | Default | Meaning |
|
|
122
142
|
|---|---|---|
|
|
123
|
-
| `thresholdRatio` | 0.
|
|
143
|
+
| `thresholdRatio` | 0.8 | pressure trigger (inherited upstream compaction-basic default 0.8; this plugin's bundle patch mounts 0.7, recommended for CJK-heavy sessions) |
|
|
144
|
+
| `roundInterval` | 50 | compact every N assistant messages (one LLM roundtrip) (0 disables). Default 50: 50, 100, 150… — the clock restarts after every compaction |
|
|
145
|
+
| `notice` | `true` | append the one-line compaction notice to the session |
|
|
124
146
|
| `language` | `zh` | summary language; `zh` also enables Chinese error/"待办:" detection |
|
|
125
147
|
| `tokenEstimate` | `cjk` | CJK (zh/ja/ko/full-width) at ~2 chars/token; `ascii` matches the host |
|
|
126
148
|
| `dedup` | `true` | annotate repeated tool calls |
|
|
@@ -128,6 +150,8 @@ All optional, defaults work out of the box:
|
|
|
128
150
|
| `maxItems` / `maxItemChars` | 10 / 200 | summary density |
|
|
129
151
|
| `maxSummaryTokens` | 2048 | summary token budget |
|
|
130
152
|
|
|
153
|
+
> **Upgrade note (0.5.0)**: the `roundInterval` counter switched from completed turns to assistant messages — the same value now triggers more often (a single turn usually contains several assistant messages).
|
|
154
|
+
|
|
131
155
|
## Design reference
|
|
132
156
|
|
|
133
157
|
- [Opencode-DCP/opencode-dynamic-context-pruning](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning)
|
|
@@ -136,7 +160,7 @@ All optional, defaults work out of the box:
|
|
|
136
160
|
## Development
|
|
137
161
|
|
|
138
162
|
```bash
|
|
139
|
-
npm install && npm test #
|
|
163
|
+
npm install && npm test # 65 tests: extractor/compaction/command/config/triggers/setup
|
|
140
164
|
```
|
|
141
165
|
|
|
142
166
|
## License
|
package/README.md
CHANGED
|
@@ -61,7 +61,7 @@ dsh 默认的压缩(`compaction-basic`)每次压缩都要让模型把旧对
|
|
|
61
61
|
- **不做语义归纳**:不"理解"代码,只保留"出现过的事实"。需要深度语义摘要的场景,请继续用官方 `compaction-basic`
|
|
62
62
|
- **dsh 已经有的我们不重复做**:
|
|
63
63
|
- 工具结果剪枝(`compaction-tool-result-pruner`,确定性按大小剪)
|
|
64
|
-
-
|
|
64
|
+
- 触发策略、保留尾巴、溢出恢复(直接继承官方;本插件仅新增轮数触发,见上)
|
|
65
65
|
- `/compact` 命令、UI 检查点卡片(dsh 自带)
|
|
66
66
|
|
|
67
67
|
## 安装
|
|
@@ -90,7 +90,21 @@ npx dsh-dcp-setup # 安全脚本:带日期备份 → 只追
|
|
|
90
90
|
| `/dcp compact` | 立即压缩(零 LLM) |
|
|
91
91
|
| `/dcp set <k> <v>` | 会话内调参,并提示如何持久化 |
|
|
92
92
|
|
|
93
|
-
可调键:`dedup`、`purgeErrors`、`maxItems`、`maxItemChars`、`maxSummaryTokens`、`language`、`tokenEstimate`、`thresholdRatio`。
|
|
93
|
+
可调键:`dedup`、`purgeErrors`、`maxItems`、`maxItemChars`、`maxSummaryTokens`、`language`、`tokenEstimate`、`thresholdRatio`、`roundInterval`、`notice`。
|
|
94
|
+
|
|
95
|
+
`/dcp` 状态还会列出每个发生过压缩的会话(per-session 概览,含子代理),例如 `per-session: session-1 (2 compactions, ~444 tokens), child (1 compaction, ~22 tokens)`。压缩按会话独立计数;已销毁的会话(含 one-shot 子代理)自动从概览消失;列表封顶一行(最多前 10 个会话,超出显示 `+N more`)。
|
|
96
|
+
|
|
97
|
+
## 触发条件
|
|
98
|
+
|
|
99
|
+
| 触发 | 时机 | 说明 |
|
|
100
|
+
|---|---|---|
|
|
101
|
+
| 压力触发 | 每步请求前 | token ≥ `thresholdRatio`(继承上游默认 0.8;本插件 bundle 挂载默认 0.7,见配置表)× 上下文窗口 |
|
|
102
|
+
| 溢出恢复 | 模型报 context 超限时 | 继承官方 |
|
|
103
|
+
| **轮数触发** | 会话每收到 `roundInterval` 条 assistant message | 本插件新增;一条 = 一次 LLM 往返(每轮工具迭代各算一条,one-shot 子代理也能触发)。**默认 50**:第 50 条后触发第一次,之后每 50 条一次(100、150……);任何一次压缩(含压力触发)都会重置轮数时钟。到达条数后的第一个空闲点触发(阈值之下也压)。`0` 关闭;需保持 `auto: true`(默认开) |
|
|
104
|
+
| 手动 | `/dcp compact`、`/compact` | 随时可用 |
|
|
105
|
+
|
|
106
|
+
- **subagent 同样生效**:进程内 subagent(含 continuable 与 one-shot 子代理)走同一套事件分发,压力/溢出/轮数触发对子会话独立计数、独立触发。轮数触发按 assistant message 计数,所以全程只有 1 个 turn 的 one-shot 子代理(多次工具迭代)也能触发。
|
|
107
|
+
- **压缩可见性**:每次压缩成功后,会话里追加一行 `dcp: 已压缩 N 条历史(约 X tokens,触发方式)` 通知行(前端渲染为折叠行)。注意该行也会作为上下文随请求发给模型(每次压缩约 15–25 tokens),且 **0.4.0 起默认开启**;`notice: false` 可关闭。`/dcp` 的 stats 持续累计(压力触发的多次 region 提交各计一次)。
|
|
94
108
|
|
|
95
109
|
## 配置
|
|
96
110
|
|
|
@@ -98,7 +112,9 @@ npx dsh-dcp-setup # 安全脚本:带日期备份 → 只追
|
|
|
98
112
|
|
|
99
113
|
| 键 | 默认 | 说明 |
|
|
100
114
|
|---|---|---|
|
|
101
|
-
| `thresholdRatio` | 0.
|
|
115
|
+
| `thresholdRatio` | 0.8 | 压力触发阈值(继承上游 compaction-basic 默认 0.8;本插件 bundle patch 挂载时默认 0.7,中文场景建议 0.7) |
|
|
116
|
+
| `roundInterval` | 50 | 每 N 条 assistant message(一次 LLM 往返)触发一次压缩(0 关闭)。默认 50:50、100、150……每次压缩后重数 |
|
|
117
|
+
| `notice` | `true` | 压缩后在会话中追加一行通知 |
|
|
102
118
|
| `language` | `zh` | 摘要语言;`zh` 额外识别中文报错和"待办:" |
|
|
103
119
|
| `tokenEstimate` | `cjk` | CJK(中/日/韩/全角)按 ~2 字符/token 计价;`ascii` 与宿主一致 |
|
|
104
120
|
| `dedup` | `true` | 标注重复工具调用 |
|
|
@@ -106,6 +122,8 @@ npx dsh-dcp-setup # 安全脚本:带日期备份 → 只追
|
|
|
106
122
|
| `maxItems` / `maxItemChars` | 10 / 200 | 摘要密度 |
|
|
107
123
|
| `maxSummaryTokens` | 2048 | 摘要 token 预算 |
|
|
108
124
|
|
|
125
|
+
> **升级提示(0.5.0)**:`roundInterval` 的计数单位由 completed turn 改为 assistant message——同值下触发会更频繁(一个 turn 内往往有多条 assistant message)。
|
|
126
|
+
|
|
109
127
|
## 设计参考
|
|
110
128
|
|
|
111
129
|
- [Opencode-DCP/opencode-dynamic-context-pruning](https://github.com/Opencode-DCP/opencode-dynamic-context-pruning)
|
|
@@ -114,7 +132,7 @@ npx dsh-dcp-setup # 安全脚本:带日期备份 → 只追
|
|
|
114
132
|
## 开发
|
|
115
133
|
|
|
116
134
|
```bash
|
|
117
|
-
npm install && npm test #
|
|
135
|
+
npm install && npm test # 65 个用例:抽取/压缩/命令/配置/触发/安装脚本
|
|
118
136
|
```
|
|
119
137
|
|
|
120
138
|
## License
|
package/lib/command.js
CHANGED
|
@@ -14,7 +14,7 @@ const USAGE = `Usage:
|
|
|
14
14
|
/dcp compact compact now (deterministic, no LLM call)
|
|
15
15
|
/dcp set <k> <v> adjust a knob for this session (dedup, purgeErrors,
|
|
16
16
|
maxItems, maxItemChars, maxSummaryTokens, language,
|
|
17
|
-
tokenEstimate, thresholdRatio)`
|
|
17
|
+
tokenEstimate, thresholdRatio, roundInterval, notice)`
|
|
18
18
|
|
|
19
19
|
const FAILURE_TEXT = Object.freeze({
|
|
20
20
|
busy: 'Compaction is unavailable because this process has an active compaction, or the agent is not idle.',
|
|
@@ -63,6 +63,11 @@ function applySet(engine, key, rawValue) {
|
|
|
63
63
|
engine.config = Object.freeze({ ...engine.config, thresholdRatio: numeric })
|
|
64
64
|
return `thresholdRatio = ${numeric} (this session)`
|
|
65
65
|
}
|
|
66
|
+
if (kind === 'nonnegative-integer') {
|
|
67
|
+
if (!Number.isInteger(numeric) || numeric < 0) return `${key} expects 0 or a positive integer`
|
|
68
|
+
engine.dcp[key] = numeric
|
|
69
|
+
return `${key} = ${numeric}${numeric === 0 ? ' (disabled)' : ''} (this session)`
|
|
70
|
+
}
|
|
66
71
|
if (!Number.isInteger(numeric) || numeric < 1) return `${key} expects a positive integer`
|
|
67
72
|
engine.dcp[key] = numeric
|
|
68
73
|
return `${key} = ${numeric} (this session)`
|
|
@@ -73,9 +78,25 @@ function statusText(engine, version) {
|
|
|
73
78
|
const stats = engine.dcpStats
|
|
74
79
|
const lines = [
|
|
75
80
|
`dsh-dcp ${version} — deterministic compaction backend (zero LLM summarization calls)`,
|
|
76
|
-
`config: dedup=${engine.dcp.dedup} purgeErrors=${engine.dcp.purgeErrors} maxItems=${engine.dcp.maxItems} maxItemChars=${engine.dcp.maxItemChars} maxSummaryTokens=${engine.dcp.maxSummaryTokens} language=${engine.dcp.language} tokenEstimate=${engine.dcp.tokenEstimate} protectedTools=[${engine.dcp.protectedTools.join(', ')}] thresholdRatio=${engine.config.thresholdRatio}`,
|
|
81
|
+
`config: dedup=${engine.dcp.dedup} purgeErrors=${engine.dcp.purgeErrors} maxItems=${engine.dcp.maxItems} maxItemChars=${engine.dcp.maxItemChars} maxSummaryTokens=${engine.dcp.maxSummaryTokens} language=${engine.dcp.language} tokenEstimate=${engine.dcp.tokenEstimate} protectedTools=[${engine.dcp.protectedTools.join(', ')}] thresholdRatio=${engine.config.thresholdRatio} roundInterval=${engine.dcp.roundInterval}${engine.dcp.roundInterval > 0 ? '' : ' (off)'} notice=${engine.dcp.notice}`,
|
|
77
82
|
`stats: ${stats.compactions} compaction${stats.compactions === 1 ? '' : 's'}, ~${stats.shadowedTokens} tokens shadowed, ${stats.compactions} LLM summary call${stats.compactions === 1 ? '' : 's'} avoided`,
|
|
78
83
|
]
|
|
84
|
+
// Per-session dimension: one compacted session per entry, in store
|
|
85
|
+
// (creation) order. Bounded to one line so a long-lived host with many
|
|
86
|
+
// compacted children never floods the status.
|
|
87
|
+
const perSession = engine.sessionStatsOverview()
|
|
88
|
+
if (perSession.length > 0) {
|
|
89
|
+
const MAX_SESSIONS = 10
|
|
90
|
+
const shown = perSession.slice(0, MAX_SESSIONS)
|
|
91
|
+
const rest = perSession.length - shown.length
|
|
92
|
+
const items = shown.map(({ id, compactions, shadowedTokens }) => {
|
|
93
|
+
const unit = compactions === 1 ? 'compaction' : 'compactions'
|
|
94
|
+
const tokens = shadowedTokens > 0 ? `, ~${shadowedTokens} tokens` : ''
|
|
95
|
+
return `${id} (${compactions} ${unit}${tokens})`
|
|
96
|
+
})
|
|
97
|
+
if (rest > 0) items.push(`+${rest} more`)
|
|
98
|
+
lines.push(`per-session: ${items.join(', ')}`)
|
|
99
|
+
}
|
|
79
100
|
if (stats.lastAt !== null) lines.push(`last compaction: ${new Date(stats.lastAt).toLocaleString()}`)
|
|
80
101
|
return lines.join('\n')
|
|
81
102
|
}
|
package/lib/config.js
CHANGED
|
@@ -16,6 +16,8 @@ export const DCP_CONFIG_KEYS = [
|
|
|
16
16
|
'language',
|
|
17
17
|
'tokenEstimate',
|
|
18
18
|
'protectedTools',
|
|
19
|
+
'roundInterval',
|
|
20
|
+
'notice',
|
|
19
21
|
]
|
|
20
22
|
|
|
21
23
|
/** compaction-basic policy keys forwarded to the parent engine verbatim. */
|
|
@@ -41,6 +43,8 @@ const DEFAULTS = Object.freeze({
|
|
|
41
43
|
language: 'en',
|
|
42
44
|
tokenEstimate: 'cjk',
|
|
43
45
|
protectedTools: Object.freeze(['write', 'edit', 'apply_patch']),
|
|
46
|
+
roundInterval: 50,
|
|
47
|
+
notice: true,
|
|
44
48
|
})
|
|
45
49
|
|
|
46
50
|
/**
|
|
@@ -97,6 +101,15 @@ export function resolveDcpConfig(raw = {}) {
|
|
|
97
101
|
throw new Error('DcpConfig: protectedTools must be an array of non-empty strings')
|
|
98
102
|
}
|
|
99
103
|
}
|
|
104
|
+
if (raw.roundInterval !== undefined) {
|
|
105
|
+
const value = raw.roundInterval
|
|
106
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
107
|
+
throw new Error('DcpConfig: roundInterval must be a non-negative integer (0 disables the round trigger)')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (raw.notice !== undefined && typeof raw.notice !== 'boolean') {
|
|
111
|
+
throw new Error('DcpConfig: notice must be a boolean')
|
|
112
|
+
}
|
|
100
113
|
return Object.freeze({ ...DEFAULTS, ...raw })
|
|
101
114
|
}
|
|
102
115
|
|
|
@@ -110,4 +123,6 @@ export const RUNTIME_SETTABLE = Object.freeze({
|
|
|
110
123
|
language: 'language',
|
|
111
124
|
tokenEstimate: 'token-estimate',
|
|
112
125
|
thresholdRatio: 'ratio',
|
|
126
|
+
roundInterval: 'nonnegative-integer',
|
|
127
|
+
notice: 'boolean',
|
|
113
128
|
})
|
package/lib/index.js
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
* - id: compaction-basic
|
|
20
20
|
* name: /absolute/path/to/dsh-dcp/lib/index.js
|
|
21
21
|
* config:
|
|
22
|
-
* thresholdRatio: 0.7 # optional; every key is optional
|
|
22
|
+
* thresholdRatio: 0.7 # optional; 0.7 = this bundle patch's mount value, package default is 0.8; every key is optional
|
|
23
|
+
* roundInterval: 100 # optional; also compact every N assistant messages, one per LLM roundtrip (default 50)
|
|
23
24
|
* ```
|
|
24
25
|
*
|
|
25
26
|
* @module dsh-dcp
|
|
@@ -29,8 +30,10 @@ import { createRequire } from 'node:module'
|
|
|
29
30
|
import { fileURLToPath } from 'node:url'
|
|
30
31
|
import z from '@deepseek-ai/schemastery'
|
|
31
32
|
import { BasicCompactionEngine } from '@deepseek-ai/dsh-compaction-basic'
|
|
33
|
+
import { boundContextSummary, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
34
|
+
import { ManualCompactionError } from '@deepseek-ai/dsh-compaction'
|
|
32
35
|
import { splitConfig, resolveDcpConfig } from './config.js'
|
|
33
|
-
import { summarizeDeterministically } from './summarizer.js'
|
|
36
|
+
import { summarizeDeterministically, noticeText } from './summarizer.js'
|
|
34
37
|
import { registerDcpCommand } from './command.js'
|
|
35
38
|
|
|
36
39
|
const require = createRequire(import.meta.url)
|
|
@@ -78,6 +81,8 @@ export class DcpEngine extends BasicCompactionEngine {
|
|
|
78
81
|
language: z.string(),
|
|
79
82
|
tokenEstimate: z.string(),
|
|
80
83
|
protectedTools: z.array(z.string()),
|
|
84
|
+
roundInterval: z.number().step(1).min(0),
|
|
85
|
+
notice: z.boolean(),
|
|
81
86
|
})
|
|
82
87
|
|
|
83
88
|
/** Resolved dcp knobs; mutable at runtime through `/dcp set`. */
|
|
@@ -89,6 +94,35 @@ export class DcpEngine extends BasicCompactionEngine {
|
|
|
89
94
|
/** Absolute module path, echoed by `/dcp set` for persistence snippets. */
|
|
90
95
|
pluginPath
|
|
91
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Completed-assistant-message counters since the last dsh-dcp compaction,
|
|
99
|
+
* per session — one "round" is one `assistant/message` (one LLM roundtrip).
|
|
100
|
+
* Weak keys: disposed sessions (including one-shot subagents) drop out with
|
|
101
|
+
* the object.
|
|
102
|
+
*/
|
|
103
|
+
#rounds = new WeakMap()
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Sessions whose next `compactNow` is a round-interval trigger rather than
|
|
107
|
+
* a manual command. Only {@link DcpEngine.#maybeRoundCompact} writes;
|
|
108
|
+
* `compactNow` reads and clears its own entry, so a stale marker can only
|
|
109
|
+
* turn a manual compaction into a labeled `'round'` one — never the reverse.
|
|
110
|
+
*/
|
|
111
|
+
#triggerLabels = new WeakMap()
|
|
112
|
+
|
|
113
|
+
/** Sessions with a round-triggered compaction still in flight. */
|
|
114
|
+
#roundInFlight = new WeakSet()
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Per-session compaction records: one entry per session that committed at
|
|
118
|
+
* least one compaction, counting the compactions and the shadowed tokens.
|
|
119
|
+
* Weak keys (mirroring `#rounds`): disposed sessions — including one-shot
|
|
120
|
+
* subagents — drop out with the object. Not enumerated directly, because a
|
|
121
|
+
* WeakMap leaks nothing and lists nothing; `/dcp` walks the sessions
|
|
122
|
+
* service and looks each live session up here.
|
|
123
|
+
*/
|
|
124
|
+
#sessionStats = new WeakMap()
|
|
125
|
+
|
|
92
126
|
constructor(ctx, config = {}) {
|
|
93
127
|
const { basic, dcp } = splitConfig(config)
|
|
94
128
|
super(ctx, basic)
|
|
@@ -99,6 +133,63 @@ export class DcpEngine extends BasicCompactionEngine {
|
|
|
99
133
|
ctx.effect(function* () {
|
|
100
134
|
yield registerDcpCommand(ctx, engine, VERSION)
|
|
101
135
|
}, 'dsh-dcp /dcp command lifecycle')
|
|
136
|
+
if (this.config.auto) this.#registerRoundTrigger()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Round-interval trigger: count assistant messages (one per LLM roundtrip)
|
|
141
|
+
* per session and, once the configured `roundInterval` is reached, compact
|
|
142
|
+
* at the agent's next idle boundary through the manual-compaction seam
|
|
143
|
+
* (`compactNow`). In-process subagents run through the same session/event
|
|
144
|
+
* and agent/status dispatch, so continuable children are covered exactly
|
|
145
|
+
* like the top-level session — and because one-shot subagents emit many
|
|
146
|
+
* assistant messages inside a single turn, they now trigger too.
|
|
147
|
+
*/
|
|
148
|
+
#registerRoundTrigger() {
|
|
149
|
+
const { ctx } = this
|
|
150
|
+
ctx.on('session/event', (session, event) => {
|
|
151
|
+
// Counting is skipped while disabled, but the listener stays registered
|
|
152
|
+
// so `/dcp set roundInterval N` can arm it again at runtime.
|
|
153
|
+
if (!this.dcp.roundInterval) return
|
|
154
|
+
// Every assembled assistant message is one completed LLM roundtrip;
|
|
155
|
+
// counting it (instead of completed turns) also covers one-shot
|
|
156
|
+
// subagents, whose whole run is a single turn with many model calls.
|
|
157
|
+
if (event.type !== 'assistant/message') return
|
|
158
|
+
this.#rounds.set(session, (this.#rounds.get(session) ?? 0) + 1)
|
|
159
|
+
})
|
|
160
|
+
ctx.on('agent/status', ({ agent, status }) => {
|
|
161
|
+
if (status === 'idle') this.#maybeRoundCompact(agent)
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Attempt one round-triggered compaction for an idle agent. The attempt is
|
|
167
|
+
* single-flight per session. `busy` (queued waking work won the race) and
|
|
168
|
+
* `cancelled` (the agent was interrupted mid-compaction) both keep the
|
|
169
|
+
* accumulated rounds for the next idle boundary — cancelling one attempt
|
|
170
|
+
* must not cancel the N assistant messages behind it. Any other failure warns
|
|
171
|
+
* and releases the boundary: the pressure trigger remains the safety net.
|
|
172
|
+
*/
|
|
173
|
+
#maybeRoundCompact(agent) {
|
|
174
|
+
const interval = this.dcp.roundInterval
|
|
175
|
+
if (!interval) return
|
|
176
|
+
const session = agent?.session
|
|
177
|
+
if (session === undefined || this.#roundInFlight.has(session)) return
|
|
178
|
+
if ((this.#rounds.get(session) ?? 0) < interval) return
|
|
179
|
+
this.#triggerLabels.set(session, 'round')
|
|
180
|
+
this.#roundInFlight.add(session)
|
|
181
|
+
const settle = () => this.#roundInFlight.delete(session)
|
|
182
|
+
const consume = () => {
|
|
183
|
+
this.#rounds.delete(session)
|
|
184
|
+
settle()
|
|
185
|
+
}
|
|
186
|
+
void this.compactNow(agent, new AbortController().signal).then(consume, (error) => {
|
|
187
|
+
if (error instanceof ManualCompactionError && (error.code === 'busy' || error.code === 'cancelled')) {
|
|
188
|
+
return settle()
|
|
189
|
+
}
|
|
190
|
+
this.ctx.logger.warn(`round-interval compaction failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
191
|
+
consume()
|
|
192
|
+
})
|
|
102
193
|
}
|
|
103
194
|
|
|
104
195
|
/**
|
|
@@ -116,13 +207,131 @@ export class DcpEngine extends BasicCompactionEngine {
|
|
|
116
207
|
}
|
|
117
208
|
}
|
|
118
209
|
|
|
119
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* Automatic triggers (pressure, overflow): every committed region is
|
|
212
|
+
* recorded through `compactRegion`, and ONE notice row is emitted per
|
|
213
|
+
* trigger event — the parent's retry loop may commit several regions
|
|
214
|
+
* before landing below the threshold, and stacking a near-duplicate row
|
|
215
|
+
* per region is noise, while the stats below count each real compaction.
|
|
216
|
+
*/
|
|
217
|
+
async compactIfNeeded(agent, trigger, signal) {
|
|
218
|
+
const label = trigger === 'context-overflow' ? 'overflow' : 'auto'
|
|
219
|
+
const result = await super.compactIfNeeded(agent, trigger, signal)
|
|
220
|
+
if (result !== null) this.#appendNotice(agent.session, result, label)
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Manual seam (`/dcp compact`, `/compact`) and this plugin's own
|
|
226
|
+
* round-interval trigger — the parent's `compactNow` bypasses
|
|
227
|
+
* `compactRegion` (it drives `compactSurfaceRegion` directly), so without
|
|
228
|
+
* this override the manual path would miss stats and the transcript notice.
|
|
229
|
+
*/
|
|
230
|
+
async compactNow(agent, signal, sourceCommandId) {
|
|
231
|
+
const session = agent.session
|
|
232
|
+
const trigger = this.#triggerLabels.get(session) === 'round' ? 'round' : 'manual'
|
|
233
|
+
try {
|
|
234
|
+
const result = await super.compactNow(agent, signal, sourceCommandId)
|
|
235
|
+
// `null` means no useful range existed: release the round counter so an
|
|
236
|
+
// early-session interval boundary cannot retry every idle boundary.
|
|
237
|
+
// Manual compactions share the release: a user-driven compact restarts
|
|
238
|
+
// interval counting whether or not it found anything to compact.
|
|
239
|
+
if (result === null) this.#rounds.delete(session)
|
|
240
|
+
else this.recordCompaction(session, result, trigger)
|
|
241
|
+
return result
|
|
242
|
+
} finally {
|
|
243
|
+
this.#triggerLabels.delete(session)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** Stats and round-counter restart for every committed region. */
|
|
120
248
|
async compactRegion(start, end, agent, signal) {
|
|
121
249
|
const result = await super.compactRegion(start, end, agent, signal)
|
|
250
|
+
this.#recordStats(agent.session, result)
|
|
251
|
+
return result
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Record one committed compaction: bump the `/dcp` counters and restart the
|
|
256
|
+
* round-interval counting.
|
|
257
|
+
*/
|
|
258
|
+
#recordStats(session, result) {
|
|
122
259
|
this.dcpStats.compactions += 1
|
|
123
260
|
this.dcpStats.shadowedTokens += result.shadowedTokenCount
|
|
124
261
|
this.dcpStats.lastAt = Date.now()
|
|
125
|
-
|
|
262
|
+
this.#rounds.delete(session)
|
|
263
|
+
this.#recordSessionStats(session, result.shadowedTokenCount)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Accumulate one committed compaction against its session. WeakMap keys
|
|
268
|
+
* must be objects, so a session-less recording (should not happen) is
|
|
269
|
+
* skipped rather than thrown on.
|
|
270
|
+
*/
|
|
271
|
+
#recordSessionStats(session, shadowedTokenCount) {
|
|
272
|
+
if (session === undefined || session === null || typeof session !== 'object') return
|
|
273
|
+
const entry = this.#sessionStats.get(session) ?? { compactions: 0, shadowedTokens: 0 }
|
|
274
|
+
entry.compactions += 1
|
|
275
|
+
entry.shadowedTokens += shadowedTokenCount
|
|
276
|
+
this.#sessionStats.set(session, entry)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Append the one-line notice row for one committed compaction. The notice
|
|
281
|
+
* is a `notice`-form plugin message, so every dsh frontend renders it as a
|
|
282
|
+
* collapsed transcript row, live and on replay.
|
|
283
|
+
*/
|
|
284
|
+
#appendNotice(session, result, trigger) {
|
|
285
|
+
if (!this.dcp.notice) return
|
|
286
|
+
const summary = boundContextSummary(noticeText(this.dcp.language, result.shadowedSeqs.length, result.shadowedTokenCount, trigger))
|
|
287
|
+
try {
|
|
288
|
+
session.append('user/message', createUserMessage({
|
|
289
|
+
content: [{ type: 'text', text: summary }],
|
|
290
|
+
source: { kind: 'plugin', plugin: 'dsh-dcp', form: 'notice', summary },
|
|
291
|
+
}))
|
|
292
|
+
} catch (error) {
|
|
293
|
+
// The compaction already committed; a display-row failure must never
|
|
294
|
+
// surface as a compaction failure.
|
|
295
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
296
|
+
this.ctx.logger.warn(`dsh-dcp notice append failed: ${message}`)
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Record stats and append the notice row for one `compactNow`-path
|
|
302
|
+
* compaction.
|
|
303
|
+
*
|
|
304
|
+
* Internal seam: `compactNow` calls it after its durable commit; tests
|
|
305
|
+
* drive it directly instead of mocking the upstream region machinery.
|
|
306
|
+
*/
|
|
307
|
+
recordCompaction(session, result, trigger) {
|
|
308
|
+
this.#recordStats(session, result)
|
|
309
|
+
this.#appendNotice(session, result, trigger)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Per-session compaction overview for `/dcp`: one entry per live session
|
|
314
|
+
* that has recorded at least one compaction. The WeakMap is not enumerable
|
|
315
|
+
* and must never retain sessions, so this walks the sessions service and
|
|
316
|
+
* looks each live session up. Disposed sessions (including one-shot
|
|
317
|
+
* subagents) fall out of the store, and their records with them.
|
|
318
|
+
*
|
|
319
|
+
* @returns {Array<{id: string, compactions: number, shadowedTokens: number}>}
|
|
320
|
+
*/
|
|
321
|
+
sessionStatsOverview() {
|
|
322
|
+
const overview = []
|
|
323
|
+
// Services live on ctx for a cordis plugin instance (`inject` only
|
|
324
|
+
// declares them); this.sessions is undefined in production.
|
|
325
|
+
for (const session of this.ctx.sessions?.list?.() ?? []) {
|
|
326
|
+
const entry = this.#sessionStats.get(session)
|
|
327
|
+
if (entry === undefined) continue
|
|
328
|
+
overview.push({
|
|
329
|
+
id: session.header?.id ?? '<unknown>',
|
|
330
|
+
compactions: entry.compactions,
|
|
331
|
+
shadowedTokens: entry.shadowedTokens,
|
|
332
|
+
})
|
|
333
|
+
}
|
|
334
|
+
return overview
|
|
126
335
|
}
|
|
127
336
|
}
|
|
128
337
|
|
package/lib/summarizer.js
CHANGED
|
@@ -132,6 +132,7 @@ const i18n = Object.freeze({
|
|
|
132
132
|
contextNote: '{messages} messages / {calls} tool calls compacted deterministically by dsh-dcp (no LLM summarization call)',
|
|
133
133
|
carried: 'carried from prior checkpoint',
|
|
134
134
|
terseHeader: '{messages} messages ({calls} tool calls) compacted deterministically by dsh-dcp.',
|
|
135
|
+
notice: 'dcp: compacted {items} history items (~{tokens} tokens, {trigger})',
|
|
135
136
|
}),
|
|
136
137
|
zh: Object.freeze({
|
|
137
138
|
none: '(无)',
|
|
@@ -141,9 +142,18 @@ const i18n = Object.freeze({
|
|
|
141
142
|
contextNote: 'dsh-dcp 确定性压缩了 {messages} 条消息 / {calls} 次工具调用(未调用 LLM 摘要)',
|
|
142
143
|
carried: '继承自上一次压缩检查点',
|
|
143
144
|
terseHeader: 'dsh-dcp 确定性压缩了 {messages} 条消息({calls} 次工具调用)。',
|
|
145
|
+
notice: 'dcp: 已压缩 {items} 条历史(约 {tokens} tokens,{trigger})',
|
|
144
146
|
}),
|
|
145
147
|
})
|
|
146
148
|
|
|
149
|
+
/** One-line compaction account for the transcript notice row. */
|
|
150
|
+
export function noticeText(language, items, tokens, trigger = 'auto') {
|
|
151
|
+
return i18n[language]?.notice
|
|
152
|
+
.replace('{items}', String(items))
|
|
153
|
+
.replace('{tokens}', String(tokens))
|
|
154
|
+
.replace('{trigger}', trigger) ?? ''
|
|
155
|
+
}
|
|
156
|
+
|
|
147
157
|
/** Collapse whitespace and hard-cap one item's length with an ellipsis. */
|
|
148
158
|
export function clip(text, maxChars) {
|
|
149
159
|
const flat = String(text).replace(/\s+/g, ' ').trim()
|
|
@@ -363,6 +373,9 @@ export function extractFacts(messages, language = 'en') {
|
|
|
363
373
|
facts.carried = parseCheckpointSections(text)
|
|
364
374
|
continue
|
|
365
375
|
}
|
|
376
|
+
// dsh-dcp's own compaction notices are transcript display rows: the model
|
|
377
|
+
// learns nothing from them the checkpoint below does not already carry.
|
|
378
|
+
if (message.source?.kind === 'plugin' && message.source.plugin === 'dsh-dcp') continue
|
|
366
379
|
if (isInjectedContext(message)) continue
|
|
367
380
|
const intentText = noticeSummary(message) ?? text
|
|
368
381
|
facts.lastUserText = intentText
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aiwayds/dsh-dcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Deterministic context-pruning compaction backend for dsh (DeepSeek Harness) — zero-LLM summaries, /dcp command, works out of the box. Design references Opencode-DCP/opencode-dynamic-context-pruning.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -20,7 +20,6 @@
|
|
|
20
20
|
"README.md",
|
|
21
21
|
"README.en.md",
|
|
22
22
|
"LICENSE",
|
|
23
|
-
"cordis.patch.example.yml",
|
|
24
23
|
"cordis.patch.yml"
|
|
25
24
|
],
|
|
26
25
|
"scripts": {
|
package/cordis.patch.example.yml
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# Example: mount dsh-dcp as the compaction backend.
|
|
2
|
-
#
|
|
3
|
-
# Append to ~/.dsh/cordis.patch.yml (home level, applies to every profile).
|
|
4
|
-
# The row id stays `compaction-basic` (patch overrides by id), so the line
|
|
5
|
-
# remains inside the standard preset's compaction isolate group and every
|
|
6
|
-
# consumer (/compact, UI checkpoint card, pressure triggers) keeps working.
|
|
7
|
-
#
|
|
8
|
-
# `name` MUST be an absolute path to this checkout's entry file; relative
|
|
9
|
-
# paths resolve against the loader's baseUrl and are easy to get wrong.
|
|
10
|
-
- id: compaction-basic
|
|
11
|
-
name: /Users/CHANGE/ME/dsh-dcp/lib/index.js
|
|
12
|
-
config:
|
|
13
|
-
thresholdRatio: 0.7 # optional: trigger earlier for CJK-heavy sessions
|
|
14
|
-
language: zh # optional: filler text language (content is verbatim)
|
|
15
|
-
# dedup: true # optional: every dsh-dcp key may be omitted
|
|
16
|
-
# purgeErrors: true
|
|
17
|
-
# maxItems: 10
|
|
18
|
-
# maxItemChars: 200
|
|
19
|
-
# maxSummaryTokens: 2048
|