@aiwayds/dsh-dcp 0.5.0 → 0.6.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/lib/config.js CHANGED
@@ -60,11 +60,11 @@ export function splitConfig(config = {}) {
60
60
  for (const key of Object.keys(config)) {
61
61
  if (!known.has(key)) throw new Error(`DcpConfig: unknown key "${key}"`)
62
62
  }
63
- const basic = {}
63
+ const basic = /** @type {Record<string, unknown>} */ ({})
64
64
  for (const key of BASIC_CONFIG_KEYS) {
65
65
  if (config[key] !== undefined) basic[key] = config[key]
66
66
  }
67
- const dcp = {}
67
+ const dcp = /** @type {Record<string, unknown>} */ ({})
68
68
  for (const key of DCP_CONFIG_KEYS) {
69
69
  if (config[key] !== undefined) dcp[key] = config[key]
70
70
  }
@@ -75,7 +75,7 @@ export function splitConfig(config = {}) {
75
75
  * Validate and resolve dcp defaults.
76
76
  *
77
77
  * @param {Record<string, unknown>} raw - the dcp half of {@link splitConfig}.
78
- * @returns {Readonly<{dedup: boolean, purgeErrors: boolean, maxItems: number, maxItemChars: number, maxSummaryTokens: number, language: 'en'|'zh', tokenEstimate: 'cjk'|'ascii', protectedTools: readonly string[]}>}
78
+ * @returns {Readonly<{dedup: boolean, purgeErrors: boolean, maxItems: number, maxItemChars: number, maxSummaryTokens: number, language: 'en'|'zh', tokenEstimate: 'cjk'|'ascii', protectedTools: readonly string[], roundInterval: number, notice: boolean}>}
79
79
  */
80
80
  export function resolveDcpConfig(raw = {}) {
81
81
  if (raw.dedup !== undefined && typeof raw.dedup !== 'boolean') {
package/lib/index.js CHANGED
@@ -39,6 +39,33 @@ import { registerDcpCommand } from './command.js'
39
39
  const require = createRequire(import.meta.url)
40
40
  const { version: VERSION } = require('../package.json')
41
41
 
42
+ /**
43
+ * Per-engine state uses module-scoped symbol keys, never `#private` members.
44
+ * Cordis hands services to consuming fibers through derived receivers —
45
+ * `ctx.mixin` binds methods to a withProps proxy and `createTraceable` calls
46
+ * them on Object.create-derived shadows — so `/compact` and `/dcp compact`
47
+ * (both `ctx.compaction.compactNow(...)`) routinely execute engine methods
48
+ * with a `this` this class's constructor never initialized. Private members
49
+ * brand-check against exactly that and throw
50
+ * "Cannot read private member #triggerLabels from an object whose class did
51
+ * not declare it", while symbol lookups traverse those receivers to this
52
+ * instance's own state.
53
+ *
54
+ * Verified against dsh 0.1.2-alpha.3 (cordis 4.0.2): `withProps` /
55
+ * `createTraceable` / `applyTraceable` receivers and the `ctx.mixin` service
56
+ * forwarding are unchanged, and the base engine still brands no `#private`
57
+ * state — the symbol-keyed approach remains both necessary and valid.
58
+ */
59
+ const kRounds = Symbol('dsh-dcp.rounds')
60
+ const kTriggerLabels = Symbol('dsh-dcp.triggerLabels')
61
+ const kRoundInFlight = Symbol('dsh-dcp.roundInFlight')
62
+ const kSessionStats = Symbol('dsh-dcp.sessionStats')
63
+ const kRegisterRoundTrigger = Symbol('dsh-dcp.registerRoundTrigger')
64
+ const kMaybeRoundCompact = Symbol('dsh-dcp.maybeRoundCompact')
65
+ const kRecordStats = Symbol('dsh-dcp.recordStats')
66
+ const kRecordSessionStats = Symbol('dsh-dcp.recordSessionStats')
67
+ const kAppendNotice = Symbol('dsh-dcp.appendNotice')
68
+
42
69
  /**
43
70
  * Deterministic compaction engine: `summarize()` overridden, everything else
44
71
  * inherited. Registers the `/dcp` command beside the inherited `/compact`.
@@ -89,43 +116,26 @@ export class DcpEngine extends BasicCompactionEngine {
89
116
  dcp
90
117
 
91
118
  /** Compaction counters surfaced by `/dcp`. */
119
+ /** @type {{compactions: number, shadowedTokens: number, lastAt: number|null}} */
92
120
  dcpStats
93
121
 
94
122
  /** Absolute module path, echoed by `/dcp set` for persistence snippets. */
95
123
  pluginPath
96
124
 
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
-
126
125
  constructor(ctx, config = {}) {
127
126
  const { basic, dcp } = splitConfig(config)
128
127
  super(ctx, basic)
128
+ // Constructor assignments, not class fields: Node 26's V8 silently drops
129
+ // every symbol-keyed class field after the first one in a derived class
130
+ // (plain string-keyed and base-class fields are unaffected).
131
+ /** Per-session assistant-message counters since the last dsh-dcp compaction. */
132
+ this[kRounds] = new WeakMap()
133
+ /** Sessions whose next `compactNow` is a round-interval trigger, not manual. */
134
+ this[kTriggerLabels] = new WeakMap()
135
+ /** Sessions with a round-triggered compaction still in flight. */
136
+ this[kRoundInFlight] = new WeakSet()
137
+ /** Per-session compaction records for `/dcp` (weak so disposed sessions drop out). */
138
+ this[kSessionStats] = new WeakMap()
129
139
  this.dcp = { ...resolveDcpConfig(dcp) }
130
140
  this.dcpStats = { compactions: 0, shadowedTokens: 0, lastAt: null }
131
141
  this.pluginPath = fileURLToPath(import.meta.url)
@@ -133,7 +143,7 @@ export class DcpEngine extends BasicCompactionEngine {
133
143
  ctx.effect(function* () {
134
144
  yield registerDcpCommand(ctx, engine, VERSION)
135
145
  }, 'dsh-dcp /dcp command lifecycle')
136
- if (this.config.auto) this.#registerRoundTrigger()
146
+ if (this.config.auto) this[kRegisterRoundTrigger]()
137
147
  }
138
148
 
139
149
  /**
@@ -145,7 +155,7 @@ export class DcpEngine extends BasicCompactionEngine {
145
155
  * like the top-level session — and because one-shot subagents emit many
146
156
  * assistant messages inside a single turn, they now trigger too.
147
157
  */
148
- #registerRoundTrigger() {
158
+ [kRegisterRoundTrigger]() {
149
159
  const { ctx } = this
150
160
  ctx.on('session/event', (session, event) => {
151
161
  // Counting is skipped while disabled, but the listener stays registered
@@ -155,10 +165,10 @@ export class DcpEngine extends BasicCompactionEngine {
155
165
  // counting it (instead of completed turns) also covers one-shot
156
166
  // subagents, whose whole run is a single turn with many model calls.
157
167
  if (event.type !== 'assistant/message') return
158
- this.#rounds.set(session, (this.#rounds.get(session) ?? 0) + 1)
168
+ this[kRounds].set(session, (this[kRounds].get(session) ?? 0) + 1)
159
169
  })
160
170
  ctx.on('agent/status', ({ agent, status }) => {
161
- if (status === 'idle') this.#maybeRoundCompact(agent)
171
+ if (status === 'idle') this[kMaybeRoundCompact](agent)
162
172
  })
163
173
  }
164
174
 
@@ -170,17 +180,17 @@ export class DcpEngine extends BasicCompactionEngine {
170
180
  * must not cancel the N assistant messages behind it. Any other failure warns
171
181
  * and releases the boundary: the pressure trigger remains the safety net.
172
182
  */
173
- #maybeRoundCompact(agent) {
183
+ [kMaybeRoundCompact](agent) {
174
184
  const interval = this.dcp.roundInterval
175
185
  if (!interval) return
176
186
  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)
187
+ if (session === undefined || this[kRoundInFlight].has(session)) return
188
+ if ((this[kRounds].get(session) ?? 0) < interval) return
189
+ this[kTriggerLabels].set(session, 'round')
190
+ this[kRoundInFlight].add(session)
191
+ const settle = () => this[kRoundInFlight].delete(session)
182
192
  const consume = () => {
183
- this.#rounds.delete(session)
193
+ this[kRounds].delete(session)
184
194
  settle()
185
195
  }
186
196
  void this.compactNow(agent, new AbortController().signal).then(consume, (error) => {
@@ -217,7 +227,7 @@ export class DcpEngine extends BasicCompactionEngine {
217
227
  async compactIfNeeded(agent, trigger, signal) {
218
228
  const label = trigger === 'context-overflow' ? 'overflow' : 'auto'
219
229
  const result = await super.compactIfNeeded(agent, trigger, signal)
220
- if (result !== null) this.#appendNotice(agent.session, result, label)
230
+ if (result !== null) this[kAppendNotice](agent.session, result, label)
221
231
  return result
222
232
  }
223
233
 
@@ -226,28 +236,32 @@ export class DcpEngine extends BasicCompactionEngine {
226
236
  * round-interval trigger — the parent's `compactNow` bypasses
227
237
  * `compactRegion` (it drives `compactSurfaceRegion` directly), so without
228
238
  * this override the manual path would miss stats and the transcript notice.
239
+ *
240
+ * alpha.3 note: the base dereferences `signal` unguarded
241
+ * (`signal.throwIfAborted()`), so the signal is required — both this
242
+ * plugin's round trigger and the command invocation always pass one.
229
243
  */
230
244
  async compactNow(agent, signal, sourceCommandId) {
231
245
  const session = agent.session
232
- const trigger = this.#triggerLabels.get(session) === 'round' ? 'round' : 'manual'
246
+ const trigger = this[kTriggerLabels].get(session) === 'round' ? 'round' : 'manual'
233
247
  try {
234
248
  const result = await super.compactNow(agent, signal, sourceCommandId)
235
249
  // `null` means no useful range existed: release the round counter so an
236
250
  // early-session interval boundary cannot retry every idle boundary.
237
251
  // Manual compactions share the release: a user-driven compact restarts
238
252
  // interval counting whether or not it found anything to compact.
239
- if (result === null) this.#rounds.delete(session)
253
+ if (result === null) this[kRounds].delete(session)
240
254
  else this.recordCompaction(session, result, trigger)
241
255
  return result
242
256
  } finally {
243
- this.#triggerLabels.delete(session)
257
+ this[kTriggerLabels].delete(session)
244
258
  }
245
259
  }
246
260
 
247
261
  /** Stats and round-counter restart for every committed region. */
248
262
  async compactRegion(start, end, agent, signal) {
249
263
  const result = await super.compactRegion(start, end, agent, signal)
250
- this.#recordStats(agent.session, result)
264
+ this[kRecordStats](agent.session, result)
251
265
  return result
252
266
  }
253
267
 
@@ -255,12 +269,12 @@ export class DcpEngine extends BasicCompactionEngine {
255
269
  * Record one committed compaction: bump the `/dcp` counters and restart the
256
270
  * round-interval counting.
257
271
  */
258
- #recordStats(session, result) {
272
+ [kRecordStats](session, result) {
259
273
  this.dcpStats.compactions += 1
260
274
  this.dcpStats.shadowedTokens += result.shadowedTokenCount
261
275
  this.dcpStats.lastAt = Date.now()
262
- this.#rounds.delete(session)
263
- this.#recordSessionStats(session, result.shadowedTokenCount)
276
+ this[kRounds].delete(session)
277
+ this[kRecordSessionStats](session, result.shadowedTokenCount)
264
278
  }
265
279
 
266
280
  /**
@@ -268,12 +282,12 @@ export class DcpEngine extends BasicCompactionEngine {
268
282
  * must be objects, so a session-less recording (should not happen) is
269
283
  * skipped rather than thrown on.
270
284
  */
271
- #recordSessionStats(session, shadowedTokenCount) {
285
+ [kRecordSessionStats](session, shadowedTokenCount) {
272
286
  if (session === undefined || session === null || typeof session !== 'object') return
273
- const entry = this.#sessionStats.get(session) ?? { compactions: 0, shadowedTokens: 0 }
287
+ const entry = this[kSessionStats].get(session) ?? { compactions: 0, shadowedTokens: 0 }
274
288
  entry.compactions += 1
275
289
  entry.shadowedTokens += shadowedTokenCount
276
- this.#sessionStats.set(session, entry)
290
+ this[kSessionStats].set(session, entry)
277
291
  }
278
292
 
279
293
  /**
@@ -281,7 +295,7 @@ export class DcpEngine extends BasicCompactionEngine {
281
295
  * is a `notice`-form plugin message, so every dsh frontend renders it as a
282
296
  * collapsed transcript row, live and on replay.
283
297
  */
284
- #appendNotice(session, result, trigger) {
298
+ [kAppendNotice](session, result, trigger) {
285
299
  if (!this.dcp.notice) return
286
300
  const summary = boundContextSummary(noticeText(this.dcp.language, result.shadowedSeqs.length, result.shadowedTokenCount, trigger))
287
301
  try {
@@ -303,10 +317,11 @@ export class DcpEngine extends BasicCompactionEngine {
303
317
  *
304
318
  * Internal seam: `compactNow` calls it after its durable commit; tests
305
319
  * drive it directly instead of mocking the upstream region machinery.
320
+ * Public-named but not part of the dsh compaction contract.
306
321
  */
307
322
  recordCompaction(session, result, trigger) {
308
- this.#recordStats(session, result)
309
- this.#appendNotice(session, result, trigger)
323
+ this[kRecordStats](session, result)
324
+ this[kAppendNotice](session, result, trigger)
310
325
  }
311
326
 
312
327
  /**
@@ -323,7 +338,7 @@ export class DcpEngine extends BasicCompactionEngine {
323
338
  // Services live on ctx for a cordis plugin instance (`inject` only
324
339
  // declares them); this.sessions is undefined in production.
325
340
  for (const session of this.ctx.sessions?.list?.() ?? []) {
326
- const entry = this.#sessionStats.get(session)
341
+ const entry = this[kSessionStats].get(session)
327
342
  if (entry === undefined) continue
328
343
  overview.push({
329
344
  id: session.header?.id ?? '<unknown>',
package/lib/setup.js CHANGED
@@ -68,7 +68,7 @@ export function findBundledProfiles(profilesDir, pkg) {
68
68
  *
69
69
  * @param {string|undefined} text - existing file content, or undefined when the file is absent.
70
70
  * @param {{ name: string }} options - the resolvable dsh-dcp entry specifier to mount.
71
- * @returns {{ action: 'create'|'patch'|'skip', block?: string, note?: string }}
71
+ * @returns {{ action: 'create'|'patch', block: string, note?: string } | { action: 'skip' }}
72
72
  */
73
73
  export function planPatch(text, { name }) {
74
74
  if (text === undefined || text === null) {
package/lib/summarizer.js CHANGED
@@ -105,7 +105,7 @@ export function estimateTextTokens(text, mode = 'cjk') {
105
105
  * tool-result payloads (the same content the host meter prices, minus its
106
106
  * per-block overhead).
107
107
  *
108
- * @param {{ content?: Array<{ type: string }> }} message - any message-shaped object.
108
+ * @param {{ content?: Array<Record<string, any>> }} message - any message-shaped object.
109
109
  * @param {'cjk'|'ascii'} [mode] - pricing mode, see {@link estimateTextTokens}.
110
110
  * @returns {number} estimated tokens.
111
111
  */
@@ -275,7 +275,7 @@ function argDisplay(name, parsed) {
275
275
  * @returns {object} the extracted fact bundle.
276
276
  */
277
277
  export function extractFacts(messages, language = 'en') {
278
- const facts = {
278
+ const facts = /** @type {{messageCount: number, intents: string[], files: Map<string, {reads: number, writes: number}>, commands: string[], errors: string[], pendingTodos: string[], concepts: Set<string>, dupCounts: Map<string, {name: string, parsed: any, count: number}>, toolCallCount: number, lastUserText: string, lastAssistantText: string, carried: Map<string, string[]>}} */ ({
279
279
  messageCount: messages.length,
280
280
  intents: [],
281
281
  files: new Map(),
@@ -288,7 +288,7 @@ export function extractFacts(messages, language = 'en') {
288
288
  lastUserText: '',
289
289
  lastAssistantText: '',
290
290
  carried: new Map(),
291
- }
291
+ })
292
292
  const callsById = new Map()
293
293
  const errorRegexes = errorPatterns(language)
294
294
  const { checkbox, markers } = todoPatterns(language)
@@ -521,7 +521,7 @@ function composeTerse(facts, t, itemChars) {
521
521
  *
522
522
  * @param {{ messages: import('@deepseek-ai/dsh-llm').Message[] }} input - replayed region.
523
523
  * @param {object} dcp - resolved dcp config.
524
- * @param {(message: unknown) => number} [estimateMessageLike] - token estimator; defaults to {@link estimateMessageTokens}.
524
+ * @param {(message: any) => number} [estimateMessageLike] - token estimator; defaults to {@link estimateMessageTokens}.
525
525
  * @returns {{ summary: { type: 'text', text: string }[], provider: string, model: string }}
526
526
  */
527
527
  export function summarizeDeterministically(input, dcp, estimateMessageLike = (message) => estimateMessageTokens(message, dcp.tokenEstimate)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwayds/dsh-dcp",
3
- "version": "0.5.0",
3
+ "version": "0.6.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",
@@ -24,7 +24,10 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "test": "node --test",
27
- "setup": "node scripts/setup.mjs"
27
+ "setup": "node scripts/setup.mjs",
28
+ "link:closure": "node scripts/link-dsh-closure.mjs",
29
+ "check": "tsc --noEmit -p tsconfig.json",
30
+ "smoke": "node scripts/smoke-boot.mjs"
28
31
  },
29
32
  "keywords": [
30
33
  "dsh",
@@ -48,57 +51,83 @@
48
51
  },
49
52
  "dependencies": {},
50
53
  "peerDependencies": {
51
- "@deepseek-ai/cordis": "^4.0.1",
52
- "@deepseek-ai/dsh-agent": "0.1.0-rc.6",
53
- "@deepseek-ai/dsh-brand": "0.1.0-rc.6",
54
- "@deepseek-ai/dsh-commands": "0.1.0-rc.6",
55
- "@deepseek-ai/dsh-compaction": "0.1.0-rc.6",
56
- "@deepseek-ai/dsh-compaction-basic": "0.1.0-rc.6",
57
- "@deepseek-ai/dsh-compaction-tool-result-pruner": "0.1.0-rc.6",
58
- "@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
59
- "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
60
- "@deepseek-ai/dsh-session": "0.1.0-rc.6",
61
- "@deepseek-ai/dsh-token-meter": "0.1.0-rc.6",
62
- "@deepseek-ai/schemastery": "^3.18.1"
54
+ "@deepseek-ai/cordis": "^4.0.2",
55
+ "@deepseek-ai/dsh-agent": ">=0.1.2-alpha.3",
56
+ "@deepseek-ai/dsh-brand": ">=0.1.2-alpha.3",
57
+ "@deepseek-ai/dsh-commands": ">=0.1.2-alpha.3",
58
+ "@deepseek-ai/dsh-compaction": ">=0.1.2-alpha.3",
59
+ "@deepseek-ai/dsh-compaction-basic": ">=0.1.2-alpha.3",
60
+ "@deepseek-ai/dsh-compaction-tool-result-pruner": ">=0.1.2-alpha.3",
61
+ "@deepseek-ai/dsh-invariants": ">=0.1.2-alpha.3",
62
+ "@deepseek-ai/dsh-llm": ">=0.1.2-alpha.3",
63
+ "@deepseek-ai/dsh-session": ">=0.1.2-alpha.3",
64
+ "@deepseek-ai/dsh-token-meter": ">=0.1.2-alpha.3",
65
+ "@deepseek-ai/schemastery": "^3.18.2"
63
66
  },
64
67
  "peerDependenciesMeta": {
65
- "@deepseek-ai/cordis": { "optional": true },
66
- "@deepseek-ai/dsh-agent": { "optional": true },
67
- "@deepseek-ai/dsh-brand": { "optional": true },
68
- "@deepseek-ai/dsh-commands": { "optional": true },
69
- "@deepseek-ai/dsh-compaction": { "optional": true },
70
- "@deepseek-ai/dsh-compaction-basic": { "optional": true },
71
- "@deepseek-ai/dsh-compaction-tool-result-pruner": { "optional": true },
72
- "@deepseek-ai/dsh-invariants": { "optional": true },
73
- "@deepseek-ai/dsh-llm": { "optional": true },
74
- "@deepseek-ai/dsh-session": { "optional": true },
75
- "@deepseek-ai/dsh-token-meter": { "optional": true },
76
- "@deepseek-ai/schemastery": { "optional": true }
68
+ "@deepseek-ai/cordis": {
69
+ "optional": true
70
+ },
71
+ "@deepseek-ai/dsh-agent": {
72
+ "optional": true
73
+ },
74
+ "@deepseek-ai/dsh-brand": {
75
+ "optional": true
76
+ },
77
+ "@deepseek-ai/dsh-commands": {
78
+ "optional": true
79
+ },
80
+ "@deepseek-ai/dsh-compaction": {
81
+ "optional": true
82
+ },
83
+ "@deepseek-ai/dsh-compaction-basic": {
84
+ "optional": true
85
+ },
86
+ "@deepseek-ai/dsh-compaction-tool-result-pruner": {
87
+ "optional": true
88
+ },
89
+ "@deepseek-ai/dsh-invariants": {
90
+ "optional": true
91
+ },
92
+ "@deepseek-ai/dsh-llm": {
93
+ "optional": true
94
+ },
95
+ "@deepseek-ai/dsh-session": {
96
+ "optional": true
97
+ },
98
+ "@deepseek-ai/dsh-token-meter": {
99
+ "optional": true
100
+ },
101
+ "@deepseek-ai/schemastery": {
102
+ "optional": true
103
+ }
77
104
  },
78
105
  "devDependencies": {
79
- "@deepseek-ai/cordis": "^4.0.1",
80
- "@deepseek-ai/dsh-agent": "0.1.0-rc.6",
81
- "@deepseek-ai/dsh-brand": "0.1.0-rc.6",
82
- "@deepseek-ai/dsh-commands": "0.1.0-rc.6",
83
- "@deepseek-ai/dsh-compaction": "0.1.0-rc.6",
84
- "@deepseek-ai/dsh-compaction-basic": "0.1.0-rc.6",
85
- "@deepseek-ai/dsh-compaction-tool-result-pruner": "0.1.0-rc.6",
86
- "@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
87
- "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
88
- "@deepseek-ai/dsh-session": "0.1.0-rc.6",
89
- "@deepseek-ai/dsh-token-meter": "0.1.0-rc.6",
90
- "@deepseek-ai/schemastery": "^3.18.1"
106
+ "@types/node": "^24.0.0",
107
+ "@deepseek-ai/cordis": "4.0.2",
108
+ "@deepseek-ai/dsh-agent": "0.1.2-alpha.3",
109
+ "@deepseek-ai/dsh-brand": "0.1.2-alpha.3",
110
+ "@deepseek-ai/dsh-commands": "0.1.2-alpha.3",
111
+ "@deepseek-ai/dsh-compaction": "0.1.2-alpha.3",
112
+ "@deepseek-ai/dsh-compaction-basic": "0.1.2-alpha.3",
113
+ "@deepseek-ai/dsh-compaction-tool-result-pruner": "0.1.2-alpha.3",
114
+ "@deepseek-ai/dsh-invariants": "0.1.2-alpha.3",
115
+ "@deepseek-ai/dsh-llm": "0.1.2-alpha.3",
116
+ "@deepseek-ai/dsh-session": "0.1.2-alpha.3",
117
+ "@deepseek-ai/dsh-token-meter": "0.1.2-alpha.3",
118
+ "@deepseek-ai/schemastery": "3.18.2",
119
+ "typescript": "~5.9.0"
91
120
  },
92
121
  "overrides": {
93
- "@deepseek-ai/dsh-agent": "0.1.0-rc.6",
94
- "@deepseek-ai/dsh-brand": "0.1.0-rc.6",
95
- "@deepseek-ai/dsh-commands": "0.1.0-rc.6",
96
- "@deepseek-ai/dsh-compaction": "0.1.0-rc.6",
97
- "@deepseek-ai/dsh-compaction-tool-result-pruner": "0.1.0-rc.6",
98
- "@deepseek-ai/dsh-invariants": "0.1.0-rc.6",
99
- "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
100
- "@deepseek-ai/dsh-session": "0.1.0-rc.6",
101
- "@deepseek-ai/dsh-token-meter": "0.1.0-rc.6"
122
+ "@deepseek-ai/dsh-agent": "0.1.2-alpha.3",
123
+ "@deepseek-ai/dsh-brand": "0.1.2-alpha.3",
124
+ "@deepseek-ai/dsh-commands": "0.1.2-alpha.3",
125
+ "@deepseek-ai/dsh-compaction": "0.1.2-alpha.3",
126
+ "@deepseek-ai/dsh-compaction-tool-result-pruner": "0.1.2-alpha.3",
127
+ "@deepseek-ai/dsh-invariants": "0.1.2-alpha.3",
128
+ "@deepseek-ai/dsh-llm": "0.1.2-alpha.3",
129
+ "@deepseek-ai/dsh-session": "0.1.2-alpha.3",
130
+ "@deepseek-ai/dsh-token-meter": "0.1.2-alpha.3"
102
131
  },
103
132
  "dsh": {
104
133
  "bundle": {
@@ -175,7 +175,7 @@ function fmt(n) {
175
175
  */
176
176
  function simulate(events, mode, useDcp) {
177
177
  const surface = []
178
- const stats = { requests: 0, inputTokens: 0, hitTokens: 0, compactions: [] }
178
+ const stats = /** @type {{ requests: number, inputTokens: number, hitTokens: number, compactions: Array<{regionNodes: number, regionHost: number, regionCjk: number, summaryHost: number, summaryCjk: number, compression: string, totalAfter: number, preview: string}> }} */ ({ requests: 0, inputTokens: 0, hitTokens: 0, compactions: [] })
179
179
  let prevSurface = []
180
180
  let compactionId = 0
181
181
 
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Closure linker: point every `node_modules/@deepseek-ai/*` entry at one dsh
3
+ * closure (the installed dsh CLI's own `node_modules/@deepseek-ai` tree).
4
+ *
5
+ * Why this exists: dsh-dcp is a plugin that runs *inside* the installed dsh
6
+ * CLI, and its source imports the `@deepseek-ai/*` host packages (cordis,
7
+ * dsh-compaction-basic, dsh-llm, …). This repo is lib-as-source — there is no
8
+ * `src/` and no build step — so typecheck (`pnpm check`, tsc checkJs) and the
9
+ * node:test suite must resolve those imports against exactly ONE copy of the
10
+ * host graph: the closure the real runtime uses. Linking also guarantees a
11
+ * single `@deepseek-ai/cordis` instance, which the `declare module`
12
+ * augmentations (e.g. `ctx.commands`) require.
13
+ *
14
+ * NOTE on the guard below: unlike a TS repo this repo has no `src/` directory
15
+ * — `lib/` IS the source — so the "am I inside the repo?" check looks for
16
+ * `lib/` (plus `.git/`, which a published tarball never carries). The script
17
+ * is never wired as a lifecycle hook (no postinstall); it is run explicitly
18
+ * (CI step, `pnpm link:closure`), and the guard only protects stray manual
19
+ * runs from outside the repo.
20
+ *
21
+ * Resolution order for the closure:
22
+ * 0) `$DSH_CLOSURE_DIR` — explicit override, e.g. a scratch closure from
23
+ * `npm i --prefix ~/tmp/dsh-alpha-closure @deepseek-ai/dsh@alpha`:
24
+ * DSH_CLOSURE_DIR=~/tmp/dsh-alpha-closure/node_modules/@deepseek-ai node scripts/link-dsh-closure.mjs
25
+ * 1) the `dsh` bin on PATH → its package's node_modules/@deepseek-ai
26
+ * 2) `npm root -g` nested closure, then npm's flat global @deepseek-ai scope
27
+ *
28
+ * It is a no-op (exit 0) when no dsh closure is found.
29
+ */
30
+
31
+ import { execFileSync } from 'node:child_process'
32
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync, symlinkSync } from 'node:fs'
33
+ import { dirname, join } from 'node:path'
34
+ import { fileURLToPath } from 'node:url'
35
+
36
+ const repoRoot = dirname(dirname(fileURLToPath(import.meta.url)))
37
+ // Guard: dev convenience for THIS repo only. lib/ is this repo's source (there
38
+ // is no src/), and .git/ only exists in a working clone — never in a packed
39
+ // tarball — so both together prove "inside the repo, not a consumer install".
40
+ if (!existsSync(join(repoRoot, 'lib')) || !existsSync(join(repoRoot, '.git'))) {
41
+ process.exit(0)
42
+ }
43
+ const scopeDir = join(repoRoot, 'node_modules', '@deepseek-ai')
44
+
45
+ /** The dsh closure: a node_modules dir whose @deepseek-ai scope is complete. */
46
+ function findDshClosure() {
47
+ // 0) Explicit override for dev/typecheck against an unreleased dsh line.
48
+ const override = process.env.DSH_CLOSURE_DIR
49
+ if (override !== undefined && override !== '') {
50
+ const dir = realpathSync(override)
51
+ if (existsSync(join(dir, 'cordis'))) return dir
52
+ console.warn(`[link-dsh-closure] DSH_CLOSURE_DIR=${override} lacks @deepseek-ai/cordis — ignoring override`)
53
+ }
54
+ // 1) Follow the `dsh` bin — the most faithful pointer to the installed CLI
55
+ // (`/opt/homebrew/bin/dsh` → …/lib/bin.js → pkg dir → its node_modules).
56
+ try {
57
+ const bin = execFileSync('which', ['dsh'], { encoding: 'utf8' }).trim()
58
+ if (bin !== '') {
59
+ const real = realpathSync(bin)
60
+ const closure = join(dirname(dirname(real)), 'node_modules', '@deepseek-ai')
61
+ if (existsSync(join(closure, 'cordis'))) return closure
62
+ }
63
+ } catch { /* dsh not on PATH */ }
64
+ // 2) Fall back to the global node_modules root — the dsh package's own
65
+ // nested closure (what current npm produces for a global install).
66
+ try {
67
+ const root = execFileSync('npm', ['root', '-g'], { encoding: 'utf8' }).trim()
68
+ const nested = join(root, '@deepseek-ai', 'dsh', 'node_modules', '@deepseek-ai')
69
+ if (existsSync(join(nested, 'cordis'))) return nested
70
+ // 3) Last resort: npm's flat global layout, where dsh's @deepseek-ai/*
71
+ // deps are hoisted straight into <npm root -g>/@deepseek-ai next to
72
+ // the dsh package itself.
73
+ const flat = join(root, '@deepseek-ai')
74
+ if (existsSync(join(flat, 'cordis'))) return flat
75
+ } catch { /* npm unavailable */ }
76
+ return undefined
77
+ }
78
+
79
+ const closure = findDshClosure()
80
+ if (closure === undefined) {
81
+ console.warn('[link-dsh-closure] no dsh closure found — skipping @deepseek-ai links (dev without dsh)')
82
+ process.exit(0)
83
+ }
84
+
85
+ mkdirSync(scopeDir, { recursive: true })
86
+ let linked = 0
87
+ for (const name of readdirSync(closure)) {
88
+ const target = join(scopeDir, name)
89
+ const source = join(closure, name)
90
+ try {
91
+ // Replace any existing entry (stale symlink, or a local .pnpm copy a
92
+ // previous install created) with the closure link.
93
+ rmSync(target, { recursive: true, force: true })
94
+ symlinkSync(source, target, 'junction')
95
+ linked++
96
+ } catch (error) {
97
+ console.warn(`[link-dsh-closure] failed to link ${name}: ${(error instanceof Error ? error.message : String(error))}`)
98
+ }
99
+ }
100
+ console.log(`[link-dsh-closure] linked ${linked} @deepseek-ai/* packages from ${closure}`)
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // Boot-smoke: mount the freshly packed dsh-dcp into a scratch dsh profile and
3
+ // boot it with the real dsh CLI.
4
+ //
5
+ // Unlike dsh-cron (which mounts through the bundle patch), this exercises the
6
+ // cordis.patch.yml mechanism — the same layer scripts/setup.mjs writes for
7
+ // users: the profile patch disables the default `compaction-basic` backend and
8
+ // inserts dsh-dcp by absolute path.
9
+ //
10
+ // 1. npm pack the repo → tarball
11
+ // 2. scratch $DSH_HOME/profiles/smoke: the dsh-base bundle plus the tarball
12
+ // as a file: dependency, and a cordis.patch.yml carrying the
13
+ // disable-compaction-basic + insert-dsh-dcp mount block
14
+ // 3. pnpm install in the profile
15
+ // 4. `dsh --profile smoke --dump-config` must compose dsh-dcp into the tree
16
+ // (mount/patch-layer proof)
17
+ // 5. a real boot under a timeout must load the plugin tree without a loader
18
+ // error (a healthy boot is silent and survives to the kill signal; a
19
+ // broken plugin dies within ~1s with the loader error)
20
+ //
21
+ // The dsh CLI comes from $DSH_BIN if set (e.g. a scratch alpha closure:
22
+ // DSH_BIN=~/tmp/dsh-alpha-closure/node_modules/@deepseek-ai/dsh/lib/bin.js),
23
+ // otherwise `dsh` on PATH.
24
+ //
25
+ // Exit 0 = mounted and boots clean. Temp dir is kept and printed on failure,
26
+ // removed on success.
27
+
28
+ import { spawnSync } from 'node:child_process'
29
+ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
30
+ import { readFile } from 'node:fs/promises'
31
+ import { tmpdir } from 'node:os'
32
+ import path from 'node:path'
33
+ import { fileURLToPath } from 'node:url'
34
+
35
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
36
+ const pkg = JSON.parse(await readFile(path.join(repoRoot, 'package.json'), 'utf8'))
37
+ const ownName = pkg.name // @aiwayds/dsh-dcp
38
+ const entryId = 'dsh-dcp'
39
+
40
+ const work = mkdtempSync(path.join(tmpdir(), 'dsh-dcp-smoke-'))
41
+ const home = path.join(work, 'dsh-home')
42
+ const profile = path.join(home, 'profiles', 'smoke')
43
+ mkdirSync(profile, { recursive: true })
44
+
45
+ function fail(message, output = '') {
46
+ console.error(`smoke-boot: FAIL — ${message}`)
47
+ if (output) console.error(output.split('\n').slice(0, 30).join('\n'))
48
+ console.error(`smoke-boot: scratch kept at ${work}`)
49
+ process.exit(1)
50
+ }
51
+
52
+ const dshBin = process.env.DSH_BIN || 'dsh'
53
+ const dsh = (args, opts = {}) => spawnSync(dshBin, args, { cwd: profile, encoding: 'utf8', env: { ...process.env, DSH_HOME: home }, ...opts })
54
+
55
+ const pack = spawnSync('npm', ['pack', '--pack-destination', work], { cwd: repoRoot, encoding: 'utf8' })
56
+ if (pack.status !== 0 || pack.error) fail('npm pack failed', `${pack.stdout}\n${pack.stderr}`)
57
+ const tarball = path.join(work, pack.stdout.trim().split('\n').at(-1) ?? '')
58
+
59
+ writeFileSync(path.join(profile, 'cordis.yml'), '# dsh profile root — empty; the tree is composed from the bundle patches\n[]\n')
60
+ // The mount layer under test — isomorphic with scripts/setup.mjs's output:
61
+ // compaction-basic disabled, dsh-dcp inserted by absolute entry path.
62
+ writeFileSync(path.join(profile, 'cordis.patch.yml'), `# scratch smoke profile: dsh-dcp mounted the setup.mjs way
63
+ - id: compaction-basic
64
+ disabled: true
65
+ - insert:
66
+ - id: ${entryId}
67
+ name: ${path.join(profile, 'node_modules', '@aiwayds', 'dsh-dcp', 'lib', 'index.js')}
68
+ config:
69
+ thresholdRatio: 0.7
70
+ language: zh
71
+ `)
72
+ writeFileSync(path.join(profile, 'pnpm-workspace.yaml'), 'packages:\n - .\n\nnodeLinker: hoisted\nautoInstallPeers: false\n')
73
+ writeFileSync(path.join(profile, 'package.json'), JSON.stringify({
74
+ name: 'dsh-profile-smoke',
75
+ private: true,
76
+ dependencies: {
77
+ [ownName]: `file:${tarball}`,
78
+ },
79
+ dsh: {
80
+ profile: {
81
+ // dsh-base brings the standard tree (including compaction-basic) that
82
+ // the patch layer then re-wires; dsh-dcp itself mounts via the patch.
83
+ bundles: [
84
+ '@deepseek-ai/dsh-base',
85
+ ],
86
+ },
87
+ },
88
+ }, null, 2) + '\n')
89
+
90
+ const install = spawnSync('pnpm', ['install'], { cwd: profile, encoding: 'utf8' })
91
+ if (install.status !== 0 || install.error) fail('pnpm install in the scratch profile failed', `${install.stdout}\n${install.stderr}`)
92
+
93
+ // Phase 1 — mount proof: the composed tree must carry the dsh-dcp entry, with
94
+ // compaction-basic disabled rather than removed-by-default.
95
+ const dump = dsh(['--profile', 'smoke', '--dump-config'])
96
+ if (dump.status !== 0 || dump.error) fail('dsh --dump-config failed on the scratch profile', `${dump.stdout}\n${dump.stderr}`)
97
+ if (!dump.stdout.includes(entryId)) {
98
+ fail(`the composed profile tree does not contain the "${entryId}" entry — the patch mount is broken`, dump.stdout)
99
+ }
100
+
101
+ // Phase 2 — boot proof: the plugin tree must LOAD without a loader error.
102
+ const bootSeconds = 25
103
+ const boot = dsh(['--profile', 'smoke'], { timeout: bootSeconds * 1000, killSignal: 'SIGKILL' })
104
+ const output = `${boot.stdout ?? ''}\n${boot.stderr ?? ''}`
105
+ const loaderErrors = [
106
+ /plugin tree failed to load/,
107
+ /failed to apply loader entry/,
108
+ /cannot get property ".*" without inject/,
109
+ /cannot get required service/,
110
+ /Cannot find (package|module)/,
111
+ /unknown key/,
112
+ ]
113
+ const hit = loaderErrors.filter((re) => re.test(output))
114
+ if (hit.length > 0) {
115
+ fail('the real host failed to load the plugin tree:', output.split('\n').filter((line) => hit.some((re) => re.test(line)) || /Error/.test(line)).slice(0, 15).join('\n'))
116
+ }
117
+ if (boot.signal !== 'SIGKILL' && boot.status !== 0) {
118
+ fail(`dsh exited early with code ${boot.status} and no loader error — unexpected`, output)
119
+ }
120
+
121
+ console.log(`smoke-boot: PASS — ${ownName} mounted via cordis.patch.yml (compaction-basic disabled), composed into the scratch profile tree, and booted clean in real dsh (${boot.signal === 'SIGKILL' ? `survived ${bootSeconds}s boot window` : `exited ${boot.status}`})`)
122
+ rmSync(work, { recursive: true, force: true })