smart-compaction 0.2.3 → 0.3.1
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.md +101 -21
- package/index.js +92 -16
- package/package.json +12 -2
- package/safe-point.js +42 -0
- package/scripts/mount-into-presets.mjs +140 -0
package/README.md
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/JoblessJoe/smart-compaction/main/.github/assets/banner.svg" alt="smart-compaction: dsh auto-compacts mid-task; with smart-compaction the model compacts between tasks" width="100%">
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
<a href="https://www.npmjs.com/package/smart-compaction"><img src="https://img.shields.io/npm/v/smart-compaction?color=a78bfa&label=npm" alt="npm version"></a>
|
|
7
|
+
<a href="https://www.npmjs.com/package/smart-compaction"><img src="https://img.shields.io/npm/dm/smart-compaction?color=a78bfa" alt="npm downloads"></a>
|
|
8
|
+
<a href="LICENSE"><img src="https://img.shields.io/github/license/JoblessJoe/smart-compaction?color=a78bfa" alt="MIT license"></a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) plugin that lets the model decide **when** to compact its own context, instead of being cut off mid-task by a blind token threshold.
|
|
12
|
+
|
|
13
|
+
- **`compact_now`:** compacts at a point the model knows is safe, e.g. right after finishing a todo. Works mid-turn.
|
|
14
|
+
- **`context_status`:** real token usage against the session's actual context window, so the model stops guessing.
|
|
15
|
+
- **Automatic safe-point compaction:** local models rarely call a tool like this on their own, so the plugin also compacts by itself the moment a todo item is marked done and the context is over half full. No model decision needed.
|
|
16
|
+
- **No config, no core changes:** built only on dsh's public compaction APIs.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
dsh plugin --profile web add smart-compaction
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Restart dsh and you're done. Details and edge cases under [Install](#install).
|
|
8
23
|
|
|
9
24
|
## Why
|
|
10
25
|
|
|
@@ -28,11 +43,17 @@ Two tools, both no-argument.
|
|
|
28
43
|
tool-call-pairing-safe boundary logic dsh's own compaction already guarantees, reimplemented
|
|
29
44
|
here against dsh's *public* APIs only (no dsh core changes, no dependency on
|
|
30
45
|
`compaction-basic`'s internals).
|
|
46
|
+
- Posts a one-line "Compacting now" notice to the chat *before* calling `compactRegion()`, not
|
|
47
|
+
after — the summarization call is one extra model request and can take a while (minutes, on a
|
|
48
|
+
local model), and without this the chat just looks stuck between the tool call and its result.
|
|
49
|
+
Tagged with its own source kind and `form: 'notice'` (the same tagging dsh-compaction-basic's own
|
|
50
|
+
checkpoint messages and other host-generated asides use), so it renders as a collapsed system aside, not as
|
|
51
|
+
if the user typed it, and needs no model output of its own.
|
|
31
52
|
- Calls dsh's own `ctx.compaction.compactRegion()` to actually do the compaction — the same
|
|
32
53
|
summarizer, the same durable `compaction/start`/`compaction/end` log events, the same guarantees
|
|
33
54
|
as any other compaction in the session.
|
|
34
55
|
- No-ops harmlessly (`"Not enough compactable history yet."`) if there isn't enough history yet —
|
|
35
|
-
safe for the model to call speculatively.
|
|
56
|
+
safe for the model to call speculatively. No notice is posted for a no-op call.
|
|
36
57
|
|
|
37
58
|
**`context_status`**
|
|
38
59
|
|
|
@@ -49,6 +70,22 @@ Two tools, both no-argument.
|
|
|
49
70
|
only prior signal was a vague "if the conversation feels long" in `compact_now`'s own
|
|
50
71
|
description.
|
|
51
72
|
|
|
73
|
+
**Automatic safe-point compaction**
|
|
74
|
+
|
|
75
|
+
- Watches `todo_write`. When an item flips to `completed`, that's a finished step: a safe place
|
|
76
|
+
to cut.
|
|
77
|
+
- If usage is past `safePointRatio` (default **0.5**) at that moment, the next step starts on
|
|
78
|
+
compacted history. Same notice, same `compactRegion()` path as `compact_now`.
|
|
79
|
+
- dsh's own 80% trigger stays as the backstop; this just gets there first, at a clean boundary.
|
|
80
|
+
- A failure here never breaks the turn. It logs a warning and the backstop takes over.
|
|
81
|
+
- Tune or turn it off in your profile's `cordis.patch.yml`:
|
|
82
|
+
|
|
83
|
+
```yaml
|
|
84
|
+
- id: tool-compact-now
|
|
85
|
+
config:
|
|
86
|
+
safePointRatio: 0.6 # or false to disable
|
|
87
|
+
```
|
|
88
|
+
|
|
52
89
|
## How it works
|
|
53
90
|
|
|
54
91
|
Two entry points exist on dsh's compaction service: `compactNow()` (what the human `/compact`
|
|
@@ -71,19 +108,52 @@ Run the equivalent by hand from the profile directory, e.g. `~/.dsh/profiles/web
|
|
|
71
108
|
smart-compaction`, then add `"smart-compaction"` to that `package.json`'s `dsh.profile.bundles`
|
|
72
109
|
array yourself.)
|
|
73
110
|
|
|
74
|
-
No build step
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
111
|
+
No build step. Restart your dsh service after adding it — new bundles are only picked up on boot.
|
|
112
|
+
|
|
113
|
+
<details>
|
|
114
|
+
<summary><b>Requirements and preset-isolated profiles</b> (read this if <code>compact_now</code> says no compaction service is configured)</summary>
|
|
115
|
+
|
|
116
|
+
**`compact_now` requires a `compaction` service reachable from wherever this plugin itself is
|
|
117
|
+
mounted** — it reads `ctx.get('compaction')` at its own mount point, nothing fancier. Most profile
|
|
118
|
+
templates ship one at the host plane and the ordinary bundle install above is enough. But
|
|
119
|
+
`@deepseek-ai/dsh-web-app`-based profiles are different: they disable the host-plane
|
|
120
|
+
`compaction-basic` row and instead have each **preset** mount its own private instance inside an
|
|
121
|
+
isolated cordis realm (`isolate: { compaction: true }`). Isolation in cordis is strictly downward —
|
|
122
|
+
only a plugin mounted as a *descendant of that exact group* can see it (`vendor/cordis/src/service.ts`).
|
|
123
|
+
A plugin on the host plane, including this one installed the ordinary way, is that isolated
|
|
124
|
+
compaction's ancestor, not a descendant, so it can never see it no matter how cleverly it reads the
|
|
125
|
+
service at call time. The only fix is a second, scoped copy of this plugin mounted as a sibling row
|
|
126
|
+
inside that same isolated group — which safely coexists with the host-plane copy (dsh-tools' scoped
|
|
127
|
+
tool registrations shadow the global one in a separate layer, see `ScopedLayers.merge` in
|
|
128
|
+
`packages/core/scope/src/store.ts`).
|
|
129
|
+
|
|
130
|
+
This package's own `postinstall` script (`scripts/mount-into-presets.mjs`) does that automatically:
|
|
131
|
+
it scans `$DSH_HOME/.agent-presets/*/agent.cordis.yml` (your local, user-owned preset overrides —
|
|
132
|
+
never a shipped read-only preset under `node_modules`) for any preset that already isolates
|
|
133
|
+
`@deepseek-ai/dsh-compaction-basic`, and inserts a scoped `smart-compaction` row as its sibling if
|
|
134
|
+
one isn't already there. It's idempotent and a no-op (with a one-line log saying so) on profiles
|
|
135
|
+
that don't use presets at all. **Modern npm and pnpm block postinstall scripts by default** —
|
|
136
|
+
you'll need to approve it once: `npm install-scripts approve smart-compaction`, or for pnpm, add
|
|
137
|
+
`smart-compaction: true` under `allowBuilds` in the profile's `pnpm-workspace.yaml` (or run
|
|
138
|
+
`pnpm approve-builds` if your pnpm version offers it) — then reinstall. If you add a new preset
|
|
139
|
+
later, or skipped the approval, re-run it anytime with
|
|
140
|
+
`npx smart-compaction-mount-into-presets` (or `pnpm exec smart-compaction-mount-into-presets` from
|
|
141
|
+
the profile directory).
|
|
142
|
+
|
|
143
|
+
If `compact_now` still errors with `"no compaction service is configured on this profile"` after
|
|
144
|
+
that, your agent's preset genuinely has no `compaction-basic` anywhere in its scope chain — add one
|
|
145
|
+
(see [`docs/subsystems/compaction.md`](https://github.com/deepseek-ai/deepseek-harness/blob/main/docs/subsystems/compaction.md)
|
|
146
|
+
in the harness repo).
|
|
82
147
|
|
|
83
148
|
**`context_status` requires `@deepseek-ai/dsh-token-meter` mounted** (it registers the
|
|
84
|
-
`contextPressure` projection this tool reads).
|
|
85
|
-
|
|
86
|
-
|
|
149
|
+
`contextPressure` projection this tool reads). Unlike `compaction-basic`, the token meter is
|
|
150
|
+
deliberately kept on the **host plane** even on preset-isolated profiles (it owns a process-wide,
|
|
151
|
+
per-session projection table, not something that should come and go with which preset is mounted),
|
|
152
|
+
so the ordinary host-plane install always sees it — no preset patching needed for this one. If it
|
|
153
|
+
isn't mounted at all, the tool still installs cleanly and just reports `available: false` instead of
|
|
154
|
+
erroring.
|
|
155
|
+
|
|
156
|
+
</details>
|
|
87
157
|
|
|
88
158
|
### Tell the model when to use it
|
|
89
159
|
|
|
@@ -97,9 +167,19 @@ an early version of this plugin, which relied entirely on a hand-written `AGENTS
|
|
|
97
167
|
description alone wasn't a strong enough signal.
|
|
98
168
|
|
|
99
169
|
That said, standing instructions carry more weight than a tool description competing against
|
|
100
|
-
everything else in a long tool catalog. If you want to reinforce it further, copy
|
|
101
|
-
|
|
102
|
-
|
|
170
|
+
everything else in a long tool catalog. If you want to reinforce it further, copy this
|
|
171
|
+
into your `AGENTS.md` (or whatever your profile injects as standing instructions):
|
|
172
|
+
|
|
173
|
+
> After marking a todo item `completed` (never while one is `in_progress`), call `context_status`.
|
|
174
|
+
> Don't just compare the number to a fixed threshold — that's still reactive, and a step that
|
|
175
|
+
> turns out bigger than expected can blow past a comfortable-looking percentage mid-step. Instead,
|
|
176
|
+
> weigh what's left against what the *next* step will actually cost (a big file read, a long diff,
|
|
177
|
+
> a subagent dispatch); if it's not clearly going to fit with room to spare, call `compact_now`
|
|
178
|
+
> now, even while usage still looks moderate. Compacting a little early costs nothing; running out
|
|
179
|
+
> mid-step loses exactly the context that step needed. As a hard backstop, usage past roughly
|
|
180
|
+
> 70-80% of the context window is reason enough to compact regardless. Both tools are safe to call
|
|
181
|
+
> speculatively — `context_status` is read-only, and `compact_now` no-ops if there isn't enough
|
|
182
|
+
> history to compact yet.
|
|
103
183
|
|
|
104
184
|
Tying this to todo-completion matters: it's a real, already-tracked signal for "I just finished a
|
|
105
185
|
self-contained unit of work," instead of asking the model to estimate its own remaining work or
|
package/index.js
CHANGED
|
@@ -17,8 +17,18 @@
|
|
|
17
17
|
|
|
18
18
|
import { toolPairingBalancedBefore } from '@deepseek-ai/dsh-compaction'
|
|
19
19
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
20
21
|
import { summarizeContextUsage } from './context-status.js'
|
|
21
22
|
import { selectCompactableRange } from './select-range.js'
|
|
23
|
+
import { completedCount, DEFAULT_SAFE_POINT_RATIO, finishedStep, overSafePointRatio } from './safe-point.js'
|
|
24
|
+
|
|
25
|
+
// dsh 0.2 (session format v4) rejects the old generic `kind: 'plugin'` wrapper:
|
|
26
|
+
// every producer stamps its own kind, like repeat-tool-reminder does.
|
|
27
|
+
const PLUGIN_SOURCE = { kind: 'smart-compaction' }
|
|
28
|
+
const STARTED_NOTICE_TEXT =
|
|
29
|
+
'⏳ Compacting now — summarizing older history. This runs one extra model '
|
|
30
|
+
+ 'call and can take a while on a local model; the chat will look idle '
|
|
31
|
+
+ 'until it lands.'
|
|
22
32
|
|
|
23
33
|
export const name = 'tool-compact-now'
|
|
24
34
|
// `compaction` deliberately NOT in `inject`: that would make this plugin's
|
|
@@ -44,10 +54,48 @@ const DESCRIPTION =
|
|
|
44
54
|
+ 'Harmless to call speculatively either way — it no-ops if there is not enough history to '
|
|
45
55
|
+ 'compact yet.'
|
|
46
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Compact the largest safe span of `agent`'s history. Shared by the
|
|
59
|
+
* `compact_now` tool and the automatic safe-point trigger.
|
|
60
|
+
* @returns {Promise<{ compacted: false } | { compacted: true, shadowedCount: number, shadowedTokenCount: number }>}
|
|
61
|
+
*/
|
|
62
|
+
async function compactAgent(compaction, agent, signal, notice) {
|
|
63
|
+
const session = agent.session
|
|
64
|
+
const range = selectCompactableRange(session, toolPairingBalancedBefore)
|
|
65
|
+
if (range === null) return { compacted: false }
|
|
66
|
+
// Posted before the slow part (the summarization model call inside
|
|
67
|
+
// compactRegion, which can run minutes on a local model), not after:
|
|
68
|
+
// without this, the chat shows nothing until it lands and looks stuck.
|
|
69
|
+
// `session.append` publishes synchronously to live observers.
|
|
70
|
+
// An own `kind` + `form: 'notice'` is the same tagging
|
|
71
|
+
// dsh-compaction-basic and repeat-tool-reminder use for host-generated
|
|
72
|
+
// asides — collapsed by default, never rendered as if the user typed it.
|
|
73
|
+
session.append('user/message', createUserMessage({
|
|
74
|
+
content: [{ type: 'text', text: notice }],
|
|
75
|
+
source: { ...PLUGIN_SOURCE, form: 'notice', summary: 'Compacting now' },
|
|
76
|
+
}), { surfaceOp: 'append' })
|
|
77
|
+
const result = await compaction.compactRegion(range.start, range.end, agent, signal)
|
|
78
|
+
return {
|
|
79
|
+
compacted: true,
|
|
80
|
+
shadowedCount: result.shadowedSeqs.length,
|
|
81
|
+
shadowedTokenCount: result.shadowedTokenCount,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Current usage for `session`, via token-meter's contextPressure projection. */
|
|
86
|
+
function usageOf(ctx, session) {
|
|
87
|
+
const projections = ctx.get('sessionProjections')
|
|
88
|
+
if (!projections) return { available: false }
|
|
89
|
+
return summarizeContextUsage(projections.stateOf(session, 'contextPressure'))
|
|
90
|
+
}
|
|
91
|
+
|
|
47
92
|
/**
|
|
48
93
|
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
94
|
+
* @param {{ safePointRatio?: number | false }} [config] - `safePointRatio`:
|
|
95
|
+
* usage fraction (0-1) at which finishing a todo item compacts
|
|
96
|
+
* automatically; `false` turns the automatic trigger off. Default 0.5.
|
|
49
97
|
*/
|
|
50
|
-
export function apply(ctx) {
|
|
98
|
+
export function apply(ctx, config = {}) {
|
|
51
99
|
ctx.tools.register(defineTool({
|
|
52
100
|
name: 'compact_now',
|
|
53
101
|
description: DESCRIPTION,
|
|
@@ -77,25 +125,11 @@ export function apply(ctx) {
|
|
|
77
125
|
if (!exec.agent) {
|
|
78
126
|
throw new Error('compact_now requires an owning agent session')
|
|
79
127
|
}
|
|
80
|
-
const session = exec.agent.session
|
|
81
|
-
const range = selectCompactableRange(session, toolPairingBalancedBefore)
|
|
82
|
-
if (range === null) {
|
|
83
|
-
return { compacted: false }
|
|
84
|
-
}
|
|
85
|
-
let result
|
|
86
128
|
try {
|
|
87
|
-
|
|
129
|
+
return await compactAgent(compaction, exec.agent, exec.signal, STARTED_NOTICE_TEXT)
|
|
88
130
|
} catch (error) {
|
|
89
|
-
// The four errors compactRegion's own range validation can throw all
|
|
90
|
-
// indicate this file's range-selection logic disagrees with core's —
|
|
91
|
-
// a bug here, not an expected runtime outcome. Surface it plainly.
|
|
92
131
|
throw new Error(`compact_now: compaction failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
93
132
|
}
|
|
94
|
-
return {
|
|
95
|
-
compacted: true,
|
|
96
|
-
shadowedCount: result.shadowedSeqs.length,
|
|
97
|
-
shadowedTokenCount: result.shadowedTokenCount,
|
|
98
|
-
}
|
|
99
133
|
},
|
|
100
134
|
}))
|
|
101
135
|
|
|
@@ -127,6 +161,48 @@ export function apply(ctx) {
|
|
|
127
161
|
return summarizeContextUsage(state)
|
|
128
162
|
},
|
|
129
163
|
}))
|
|
164
|
+
|
|
165
|
+
// --- automatic safe-point compaction (see safe-point.js) ---------------
|
|
166
|
+
// Local models rarely call compact_now on their own, so the plugin also
|
|
167
|
+
// compacts by itself: a todo item flipping to completed marks a safe
|
|
168
|
+
// boundary, and the next step starts on compacted history if usage is past
|
|
169
|
+
// `safePointRatio`. Core's own 80% trigger stays as the backstop.
|
|
170
|
+
const ratio = config.safePointRatio ?? DEFAULT_SAFE_POINT_RATIO
|
|
171
|
+
if (ratio === false) return
|
|
172
|
+
if (typeof ratio !== 'number' || !(ratio > 0 && ratio < 1)) {
|
|
173
|
+
throw new Error(`smart-compaction: safePointRatio must be a number between 0 and 1, or false (got ${ratio})`)
|
|
174
|
+
}
|
|
175
|
+
const lastCompleted = new WeakMap()
|
|
176
|
+
const atSafePoint = new WeakSet()
|
|
177
|
+
|
|
178
|
+
ctx.on('tools/post-execute', async (exec, _result, next) => {
|
|
179
|
+
if (exec.agent && exec.name === 'todo_write') {
|
|
180
|
+
const done = completedCount(exec.arguments)
|
|
181
|
+
if (finishedStep(lastCompleted.get(exec.agent), done)) atSafePoint.add(exec.agent)
|
|
182
|
+
lastCompleted.set(exec.agent, done)
|
|
183
|
+
}
|
|
184
|
+
return next()
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
ctx.on('agent/pre-step', async ({ agent, signal }, next) => {
|
|
188
|
+
if (!atSafePoint.has(agent) || signal.aborted) return next()
|
|
189
|
+
atSafePoint.delete(agent)
|
|
190
|
+
// Not mounted next to a compaction service (e.g. the host-plane copy on a
|
|
191
|
+
// preset-isolated profile): the in-realm copy handles it.
|
|
192
|
+
const compaction = ctx.get('compaction')
|
|
193
|
+
if (!compaction) return next()
|
|
194
|
+
const usage = usageOf(ctx, agent.session)
|
|
195
|
+
if (!overSafePointRatio(usage, ratio)) return next()
|
|
196
|
+
try {
|
|
197
|
+
await compactAgent(compaction, agent, signal,
|
|
198
|
+
`⏳ Step finished with ${usage.percentUsed}% of the context used — compacting now, `
|
|
199
|
+
+ 'before the next step. This runs one extra model call and can take a while on a local model.')
|
|
200
|
+
} catch (error) {
|
|
201
|
+
// Never break the turn: core's own pressure trigger is still the backstop.
|
|
202
|
+
console.warn(`smart-compaction: safe-point compaction failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
203
|
+
}
|
|
204
|
+
return next()
|
|
205
|
+
})
|
|
130
206
|
}
|
|
131
207
|
|
|
132
208
|
const CONTEXT_STATUS_DESCRIPTION =
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "smart-compaction",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "dsh plugin: a compact_now tool letting the model trigger compaction itself at a safe point, plus a context_status tool so it can check real token usage against its context window, instead of only the automatic token-threshold trigger.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
@@ -55,15 +55,25 @@
|
|
|
55
55
|
"index.js",
|
|
56
56
|
"select-range.js",
|
|
57
57
|
"context-status.js",
|
|
58
|
-
"
|
|
58
|
+
"safe-point.js",
|
|
59
|
+
"cordis.patch.yml",
|
|
60
|
+
"scripts/mount-into-presets.mjs"
|
|
59
61
|
],
|
|
62
|
+
"bin": {
|
|
63
|
+
"smart-compaction-mount-into-presets": "scripts/mount-into-presets.mjs"
|
|
64
|
+
},
|
|
65
|
+
"scripts": {
|
|
66
|
+
"postinstall": "node scripts/mount-into-presets.mjs"
|
|
67
|
+
},
|
|
60
68
|
"peerDependencies": {
|
|
61
69
|
"@deepseek-ai/cordis": "*",
|
|
62
70
|
"@deepseek-ai/dsh-compaction": "*",
|
|
71
|
+
"@deepseek-ai/dsh-llm": "*",
|
|
63
72
|
"@deepseek-ai/dsh-tools": "*"
|
|
64
73
|
},
|
|
65
74
|
"devDependencies": {
|
|
66
75
|
"@deepseek-ai/dsh-compaction": "0.0.1-rc.5",
|
|
76
|
+
"@deepseek-ai/dsh-llm": "0.0.1-rc.1",
|
|
67
77
|
"@deepseek-ai/dsh-tools": "0.0.1-rc.1"
|
|
68
78
|
}
|
|
69
79
|
}
|
package/safe-point.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Automatic safe-point compaction: the plugin's answer to "local models
|
|
3
|
+
* rarely call compact_now on their own". Instead of waiting for the model to
|
|
4
|
+
* decide, the plugin treats "a todo item just flipped to completed" as the
|
|
5
|
+
* safe boundary and compacts before the next step whenever usage is already
|
|
6
|
+
* past `ratio` of the context window — well below core's own 80% trigger,
|
|
7
|
+
* which fires wherever the step happens to land.
|
|
8
|
+
*
|
|
9
|
+
* Pure helpers only; index.js wires them to `tools/post-execute` and
|
|
10
|
+
* `agent/pre-step`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Default usage fraction at which a finished todo item triggers compaction. */
|
|
14
|
+
export const DEFAULT_SAFE_POINT_RATIO = 0.5
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Number of completed items in a `todo_write` argument list (0 on bad input).
|
|
18
|
+
* @param {unknown} args
|
|
19
|
+
*/
|
|
20
|
+
export function completedCount(args) {
|
|
21
|
+
const todos = args !== null && typeof args === 'object' ? /** @type {any} */ (args).todos : undefined
|
|
22
|
+
return Array.isArray(todos) ? todos.filter(t => t?.status === 'completed').length : 0
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Whether a todo_write moved the completed count up (= a step just finished).
|
|
27
|
+
* A count going down (new turn, rewritten list) only resets the baseline.
|
|
28
|
+
* @param {number | undefined} before - completed count from this agent's previous todo_write.
|
|
29
|
+
* @param {number} after
|
|
30
|
+
*/
|
|
31
|
+
export function finishedStep(before, after) {
|
|
32
|
+
return after > (before ?? 0)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Whether usage is high enough to compact at a safe point.
|
|
37
|
+
* @param {{ available: boolean, percentUsed?: number }} usage - summarizeContextUsage() output.
|
|
38
|
+
* @param {number} ratio - 0..1
|
|
39
|
+
*/
|
|
40
|
+
export function overSafePointRatio(usage, ratio) {
|
|
41
|
+
return usage.available && usage.percentUsed !== undefined && usage.percentUsed >= ratio * 100
|
|
42
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Postinstall: mount smart-compaction inside every local preset that already
|
|
4
|
+
* has `@deepseek-ai/dsh-compaction-basic` isolated in its own cordis realm
|
|
5
|
+
* (the shape `@deepseek-ai/dsh-web-app`-based profiles use).
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: dsh-web-app disables the host-plane `compaction-basic`
|
|
8
|
+
* row and each preset mounts its own private instance inside an isolated
|
|
9
|
+
* cordis group (`isolate: { compaction: true }`). Cordis's isolation is
|
|
10
|
+
* strictly downward — only plugins mounted as descendants of that exact
|
|
11
|
+
* group see the private instance (vendor/cordis/src/service.ts's isolate-tag
|
|
12
|
+
* check). A plugin installed the normal way, as a profile bundle, mounts on
|
|
13
|
+
* the host plane — an ANCESTOR of every preset's realm, not a descendant —
|
|
14
|
+
* so it can never see that isolated compaction service, no matter how it
|
|
15
|
+
* reads it at call time.
|
|
16
|
+
*
|
|
17
|
+
* The only fix is mounting a second, scoped copy of this plugin as a sibling
|
|
18
|
+
* row inside that same isolated group. That row shadows the host-plane one
|
|
19
|
+
* for any agent running that preset (dsh-tools' scoped registrations shadow
|
|
20
|
+
* globals in a different layer — see packages/core/scope/src/store.ts's
|
|
21
|
+
* `ScopedLayers.merge`), so it's safe to coexist with the ordinary
|
|
22
|
+
* `dsh plugin add` bundle-list install used on profiles that DON'T isolate
|
|
23
|
+
* compaction (where the host-plane copy just works on its own).
|
|
24
|
+
*
|
|
25
|
+
* Patches with plain line surgery, not a parse/reserialize round-trip: a
|
|
26
|
+
* full YAML library round-trip (tried first) reflowed unrelated parts of the
|
|
27
|
+
* file (block-scalar indentation, flow-sequence spacing) even though only
|
|
28
|
+
* one line was meant to change — unacceptable for a script whose whole job
|
|
29
|
+
* is not surprising the user. This only ever inserts new lines; every byte
|
|
30
|
+
* of the rest of the file is untouched.
|
|
31
|
+
*
|
|
32
|
+
* Idempotent, best-effort, never fails the parent install: skipped entirely
|
|
33
|
+
* when `$DSH_HOME/.agent-presets` doesn't exist (simple/non-preset profile —
|
|
34
|
+
* nothing to do), and per-file errors are warned, not thrown. Only ever
|
|
35
|
+
* touches the user's own local preset overrides, never a shipped read-only
|
|
36
|
+
* preset under node_modules.
|
|
37
|
+
* @module scripts/mount-into-presets
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { readFile, writeFile, readdir } from 'node:fs/promises'
|
|
41
|
+
import { existsSync } from 'node:fs'
|
|
42
|
+
import { homedir } from 'node:os'
|
|
43
|
+
import { join } from 'node:path'
|
|
44
|
+
|
|
45
|
+
const COMPACTION_BASIC_NAME_RE = /name:\s*['"]?@deepseek-ai\/dsh-compaction-basic['"]?\s*$/
|
|
46
|
+
const OUR_NAME = 'smart-compaction'
|
|
47
|
+
const ITEM_START_RE = /^(\s*)-\s/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} text
|
|
51
|
+
* @returns {string | null} patched text, or null if nothing to do (already
|
|
52
|
+
* mounted, or this preset doesn't isolate compaction-basic at all)
|
|
53
|
+
*/
|
|
54
|
+
function patchPresetText(text) {
|
|
55
|
+
if (text.includes(`name: ${OUR_NAME}`)) return null // already mounted somewhere
|
|
56
|
+
|
|
57
|
+
const lines = text.split('\n')
|
|
58
|
+
const nameLineIdx = lines.findIndex(line => COMPACTION_BASIC_NAME_RE.test(line))
|
|
59
|
+
if (nameLineIdx === -1) return null // this preset doesn't isolate compaction-basic
|
|
60
|
+
|
|
61
|
+
// Walk back to the "- id: compaction-basic" (or however it's labeled) item start.
|
|
62
|
+
let itemStartIdx = -1
|
|
63
|
+
for (let i = nameLineIdx; i >= 0; i--) {
|
|
64
|
+
if (ITEM_START_RE.test(lines[i])) {
|
|
65
|
+
itemStartIdx = i
|
|
66
|
+
break
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (itemStartIdx === -1) return null // unexpected shape, don't guess
|
|
70
|
+
|
|
71
|
+
const indent = lines[itemStartIdx].match(ITEM_START_RE)[1].length
|
|
72
|
+
|
|
73
|
+
// Scan forward through this list's siblings (indent >= the item's own indent
|
|
74
|
+
// keeps nested `config:` sub-blocks in scope); a strictly shallower non-blank
|
|
75
|
+
// line means the list ended. Track the last non-blank line actually in it so
|
|
76
|
+
// trailing blank lines before the next section are preserved as-is.
|
|
77
|
+
let lastNonBlankIdx = itemStartIdx
|
|
78
|
+
let i = itemStartIdx + 1
|
|
79
|
+
for (; i < lines.length; i++) {
|
|
80
|
+
const line = lines[i]
|
|
81
|
+
if (line.trim() === '') continue
|
|
82
|
+
const lineIndent = line.length - line.trimStart().length
|
|
83
|
+
if (lineIndent < indent) break
|
|
84
|
+
lastNonBlankIdx = i
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const pad = ' '.repeat(indent)
|
|
88
|
+
const insertion = ['', `${pad}- id: ${OUR_NAME}`, `${pad} name: ${OUR_NAME}`]
|
|
89
|
+
lines.splice(lastNonBlankIdx + 1, 0, ...insertion)
|
|
90
|
+
return lines.join('\n')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {string} filePath
|
|
95
|
+
* @returns {Promise<'mounted' | 'already-mounted' | 'no-isolated-compaction'>}
|
|
96
|
+
*/
|
|
97
|
+
async function patchPresetFile(filePath) {
|
|
98
|
+
const text = await readFile(filePath, 'utf8')
|
|
99
|
+
if (text.includes(`name: ${OUR_NAME}`)) return 'already-mounted'
|
|
100
|
+
const patched = patchPresetText(text)
|
|
101
|
+
if (patched === null) return 'no-isolated-compaction'
|
|
102
|
+
await writeFile(filePath, patched)
|
|
103
|
+
return 'mounted'
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function main() {
|
|
107
|
+
const dshHome = process.env.DSH_HOME ?? join(homedir(), '.dsh')
|
|
108
|
+
const presetsDir = join(dshHome, '.agent-presets')
|
|
109
|
+
if (!existsSync(presetsDir)) {
|
|
110
|
+
console.log(`smart-compaction: no local presets at ${presetsDir} — nothing to mount (ordinary profile-bundle install covers this profile).`)
|
|
111
|
+
return
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let entries
|
|
115
|
+
try {
|
|
116
|
+
entries = await readdir(presetsDir, { withFileTypes: true })
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.warn(`smart-compaction: could not read ${presetsDir}: ${error instanceof Error ? error.message : String(error)}`)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
for (const entry of entries) {
|
|
123
|
+
if (!entry.isDirectory()) continue
|
|
124
|
+
const filePath = join(presetsDir, entry.name, 'agent.cordis.yml')
|
|
125
|
+
if (!existsSync(filePath)) continue
|
|
126
|
+
try {
|
|
127
|
+
const result = await patchPresetFile(filePath)
|
|
128
|
+
if (result === 'mounted') {
|
|
129
|
+
console.log(`smart-compaction: mounted inside preset "${entry.name}" (${filePath})`)
|
|
130
|
+
} else if (result === 'already-mounted') {
|
|
131
|
+
console.log(`smart-compaction: already mounted in preset "${entry.name}" — nothing to do.`)
|
|
132
|
+
}
|
|
133
|
+
// 'no-isolated-compaction': this preset doesn't isolate compaction-basic, nothing to do.
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.warn(`smart-compaction: could not patch ${filePath}: ${error instanceof Error ? error.message : String(error)}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await main()
|