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 CHANGED
@@ -1,10 +1,25 @@
1
- # smart-compaction
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
- A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) plugin that gives the
4
- model two tools: `compact_now`, so it can trigger compaction itself at a point *it* knows is safe
5
- — right after finishing a step, never mid-edit — instead of only ever being interrupted by dsh's
6
- automatic token-threshold trigger; and `context_status`, so it can check real, current token
7
- usage against its actual context window instead of guessing whether a conversation "feels long."
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, no config. Restart your dsh service after adding it — new bundles are only picked
75
- up on boot.
76
-
77
- **`compact_now` requires a `compaction` service on your profile.** Most profile templates ship
78
- one, but not all do (e.g. `@deepseek-ai/dsh-web-app`-based profiles don't by default). If yours
79
- doesn't, `compact_now` still installs cleanly (it won't break your profile's boot) but returns an
80
- error every time it's called: `"no compaction service is configured on this profile"`. Add a
81
- `compaction-basic` bundle to get one.
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). It's normally pulled in wherever `compaction-basic`
85
- is, so if `compact_now` works, `context_status` should too. If it isn't mounted, the tool still
86
- installs cleanly and just reports `available: false` instead of erroring.
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
- [`agents-snippet.md`](./agents-snippet.md) into your `AGENTS.md` (or whatever your profile injects
102
- as standing instructions).
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
- result = await compaction.compactRegion(range.start, range.end, exec.agent, exec.signal)
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.2.3",
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
- "cordis.patch.yml"
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()