@falling-ts/dsh-force-compact 0.2.5 → 0.2.7

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/index.js CHANGED
@@ -56,6 +56,7 @@ void thinkingDisabled
56
56
  import { registerCommand } from './src/hooks/command.js'
57
57
  import { handleAgentStatus } from './src/hooks/idle.js'
58
58
  import { registerLlmStreamHook } from './src/hooks/wire-rewrite.js'
59
+ import { publishWorkingOnStart } from './src/core/ui-signal.js'
59
60
  import { guardFn, installCrashNet } from './src/core/crashnet.js'
60
61
 
61
62
  /** @type {string} the function plugin's display name. */
@@ -373,6 +374,19 @@ const __applyInner = (ctx) => {
373
374
  maybeRegisterSettingsNamespace()
374
375
  maybeRegisterCommand()
375
376
  maybeInstallWireRewrite()
377
+ // CONVERSATION-START Live-UI FORCED OVERRIDE (self-healing): this seam is
378
+ // the FIRST model request of a conversation step, so force-clear any stale
379
+ // pinned bracket-form text ([强制压缩中>>>] / [压缩完成!] residue left by an
380
+ // interrupted or failed compaction) with a fresh random working pair
381
+ // (isImportant=true). Without this the non-important guard inside
382
+ // publishUiStatus would refuse to overwrite a `[`-prefixed text and the
383
+ // badge could stay frozen on a stale banner for the whole session. Fire-
384
+ // and-forget, like the llm/stream watermark: publishWorkingOnStart swallows
385
+ // its own rejections internally and must never block the request chain.
386
+ // Mid-request watermarking stays NON-important (publishRandomWorking at the
387
+ // llm/stream hook) so a live compressing/done banner during an ACTUAL
388
+ // compaction still survives; only the START of a conversation clears it.
389
+ void publishWorkingOnStart(ctx)
376
390
  return await __agentRequestListenerBody(ctx, payload, next)
377
391
  } catch (error) {
378
392
  const message = error instanceof Error ? error.message : String(error)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@falling-ts/dsh-force-compact",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "DSH Cordis plugin: hooks the core model-request seam (agent/pre-step + agent/request) to force-compact a session's context and disable thinking per the \"强制压缩配置\" settings namespace (disableThinking, autoThresholdTokens, retainLatestTokens, turnEndForceCompactionEnabled). When the threshold fires (or /force-compact queues while busy), the latest `retainLatestTokens` of the conversation's surface tokens are KEPT VERBATIM and everything before that cutoff is COMPACTED INTO A SINGLE SUMMARY NODE in one LLM call (original span entries become shadowed/skipped). Also compacts at each turn/end and at each session/flush durability checkpoint. Host half is a pure listener; a web client half registers a settings.section (强制压缩 / Force Compact) that reads and writes the same settings namespace.",
5
5
  "type": "module",
6
6
  "main": "./index.js",
@@ -17,6 +17,15 @@
17
17
  "cordis.patch.yml"
18
18
  ],
19
19
  "license": "MIT",
20
+ "peerDependencies": {
21
+ "@deepseek-ai/cordis": "^4.0.1",
22
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.7 || ^0.1.1-rc.2"
23
+ },
24
+ "peerDependenciesMeta": {
25
+ "@deepseek-ai/dsh-settings": {
26
+ "optional": true
27
+ }
28
+ },
20
29
  "publishConfig": {
21
30
  "access": "public"
22
31
  },
@@ -28,7 +37,8 @@
28
37
  "platform": "web",
29
38
  "inject": [
30
39
  "@deepseek-ai/dsh-client-ui-settings",
31
- "@deepseek-ai/dsh-client-locale"
40
+ "@deepseek-ai/dsh-client-locale",
41
+ "@deepseek-ai/dsh-client-store"
32
42
  ]
33
43
  }
34
44
  },
@@ -227,6 +227,32 @@ export async function publishRandomWorking(ctx) {
227
227
  await publishUiStatus(ctx, randomWorkingPair())
228
228
  }
229
229
 
230
+ /**
231
+ * The conversation-START forced override: publish a fresh random working pair
232
+ * with `isImportant=true`, so it UNCONDITIONALLY overwrites whatever is
233
+ * currently displayed — including a stale pinned bracket-form text such as
234
+ * `[强制压缩中>>>]` left behind by an interrupted/failed compaction (the
235
+ * non-important guard inside {@link publishUiStatus} refuses to overwrite a
236
+ * `[`-prefixed text, so such residue would otherwise stick on the badge for
237
+ * the whole session, forever showing a frozen compressing/done banner instead
238
+ * of a live working label).
239
+ *
240
+ * Call ONCE at the START of a conversation step (the `agent/request` seam —
241
+ * see index.js): the first model request of a new turn force-clears any
242
+ * bracket residue so the badge immediately returns to a normal working look.
243
+ * Only the START uses the forced form; the mid-stream watermark keeps
244
+ * publishing NON-importantly via {@link publishRandomWorking} (called from the
245
+ * `llm/stream` hook) so a live `[强制压缩中>>>]` during an ACTUAL compaction
246
+ * still survives (the guard stays effective while something is really
247
+ * pressing). Clean, self-healing: a stale `[` never outlives the next
248
+ * conversation start.
249
+ * @param {import('@deepseek-ai/cordis').Context} ctx
250
+ * @returns {Promise<void>}
251
+ */
252
+ export async function publishWorkingOnStart(ctx) {
253
+ await publishUiStatus(ctx, randomWorkingPair(), true)
254
+ }
255
+
230
256
  /**
231
257
  * Publish the pinned RED "[强制压缩中>>>]" status. Call BEFORE a `compactNow` /
232
258
  * `compactRegion` invocation. Passes `isImportant=true` so the pinned
@@ -26,6 +26,26 @@ export const SUMMARY_OPEN_TAG = '<compacted-summary>'
26
26
  * `compaction-basic` `frameSummary`, which emits both tags around the body). */
27
27
  export const SUMMARY_CLOSE_TAG = '</compacted-summary>'
28
28
 
29
+ /**
30
+ * Hard wall-clock cap for ONE summarization stream, in milliseconds.
31
+ *
32
+ * Why a timeout exists at all: `llm.stream` is a composed async iterable, and
33
+ * a silent provider (or a poisoned composition listener) can yield ZERO chunks
34
+ * and never terminate. If summarization never resolves, the caller
35
+ * (`runTransaction`) stays parked at its `await summarize(...)` and the
36
+ * `compaction/start` lock it opened is never closed by a matching
37
+ * `compaction/end` — every later compaction (idle auto-run, `/force-compact`)
38
+ * is then rejected with "a prior compaction transaction is still open" until
39
+ * the process restarts. Observed live 2026-08-30 on opencode-go/
40
+ * deepseek-v4-flash: the wire-fields audit line fired, then nothing — the
41
+ * stream simply hung. Fail-closed is cheaper than a permanently leaked lock:
42
+ * when the cap fires, the caller closes the bracket with an `error`, arming
43
+ * the existing failure cooldown instead of livelocking. The cap is enforced
44
+ * with `AbortSignal.timeout` racing the collection (`Promise.race`), because
45
+ * an abort alone cannot interrupt an iterator stuck inside its own `await`.
46
+ */
47
+ export const SUMMARIZATION_TIMEOUT_MS = 90_000
48
+
29
49
  /**
30
50
  * The compaction directive, delivered as the FINAL user message after the
31
51
  * replayed conversation rather than as a distinct summarizer system prompt.
@@ -155,7 +175,8 @@ export function frameSummary(textBlocks) {
155
175
  * • `{ status: '<failure>', reason: string }` — the call was made but no
156
176
  * usable summary resulted. Failure labels: `not-iterable`, `no-finish`,
157
177
  * `provider-error`, `aborted`, `truncated-empty`, `image-content`,
158
- * `empty-text`. Caller arms the per-session cooldown and closes the
178
+ * `empty-text`, `timeout` (hard wall-clock cap hit: stream aborted,
179
+ * presumed hung). Caller arms the per-session cooldown and closes the
159
180
  * transaction with `error`.
160
181
  * No throw path exists: a malformed chunk/finish/object degrades to a
161
182
  * labeled failure, so a bad provider response can never surface a TypeError
@@ -180,7 +201,8 @@ async function __summarizeBody(ctx, config, agent, input, signal, extra) {
180
201
  // 'aborted' (terminal finish kind:'aborted'),
181
202
  // 'truncated-empty' (kind:'max-tokens' with no text),
182
203
  // 'image-content' (image blocks present — unsafe as a checkpoint),
183
- // 'empty-text' (terminated successfully but emitted no text).
204
+ // 'empty-text' (terminated successfully but emitted no text),
205
+ // 'timeout' (hard wall-clock cap hit; stream aborted, presumed hung).
184
206
  // The caller (builtin.js runTransaction) maps 'ok' → commit; 'no-target'/
185
207
  // 'no-llm' → silent skip (nothing to cool down); any other status → arm the
186
208
  // per-session failure cooldown + close the transaction with `error`. No throw
@@ -232,7 +254,19 @@ async function __summarizeBody(ctx, config, agent, input, signal, extra) {
232
254
  if (extra !== undefined && Number.isFinite(extra.maxTokens) && extra.maxTokens > 0) {
233
255
  options.maxTokens = extra.maxTokens
234
256
  }
235
- if (signal !== undefined) options.signal = signal
257
+ // HUNG-STREAM GUARD: pin the summarization stream to a hard wall-clock
258
+ // timeout ON TOP OF the caller's signal. `AbortSignal.timeout` + `AbortSignal.any`
259
+ // (Node >= 20.3) fire the abort on THEIR OWN schedule, so a silent provider
260
+ // stream (zero chunks, never a terminal finish) cannot leave the caller's
261
+ // `await summarize(...)` pending forever and leak the `compaction/start`
262
+ // lock. The caller's own signal keeps working normally (an external abort
263
+ // still cancels earlier); only when the TIMEOUT fires does the caller
264
+ // receive a labeled `timeout` failure instead of an open-ended hang.
265
+ const timeoutSignal = AbortSignal.timeout(SUMMARIZATION_TIMEOUT_MS)
266
+ const mergedSignal = (signal !== undefined && signal !== null && typeof signal.aborted === 'boolean')
267
+ ? AbortSignal.any([signal, timeoutSignal])
268
+ : timeoutSignal
269
+ options.signal = mergedSignal
236
270
  const session = agent.session
237
271
  if (session !== undefined && session !== null && typeof session.id === 'string') {
238
272
  options.sessionId = session.id
@@ -315,7 +349,24 @@ async function __summarizeBody(ctx, config, agent, input, signal, extra) {
315
349
  // error degrades to a labeled failure instead of escaping `summarize`.
316
350
  let collected
317
351
  try {
318
- collected = await collectChunks(stream, signal)
352
+ // HUNG-STREAM RACE: an `AbortSignal.timeout` abort alone cannot interrupt a
353
+ // stream whose async iterator is stuck INSIDE an `await` (the `for await`
354
+ // loop only re-checks `signal.aborted` when the NEXT chunk arrives — a
355
+ // stream parked on `await new Promise(() => {})` never notices). So race
356
+ // the collection against the timeout signal's own settlement: whichever
357
+ // fires first wins. When the timeout wins, the labeled `timeout` failure
358
+ // below lets `runTransaction` close the `compaction/start` lock instead of
359
+ // leaking it forever; the abort is ALSO fired at the transport, so a
360
+ // fetch/undici-backed stream tears down instead of burning provider time.
361
+ // The abandoned collection loop (if any) keeps running in the background
362
+ // but its result is discarded — releasing the lock is the contract.
363
+ const hangRace = new Promise((resolve) => {
364
+ timeoutSignal.addEventListener('abort', () => resolve({ _hungByTimeout: true }), { once: true })
365
+ })
366
+ collected = await Promise.race([
367
+ collectChunks(stream, mergedSignal),
368
+ hangRace,
369
+ ])
319
370
  } catch (err) {
320
371
  // `for await` threw mid-iteration (generator fault, network reset, a
321
372
  // poisoned composed stream, …). Record it and fall through to the shared
@@ -326,9 +377,29 @@ async function __summarizeBody(ctx, config, agent, input, signal, extra) {
326
377
  _rejectReason: (err && err.message) ? err.message : String(err),
327
378
  }
328
379
  }
380
+ if (collected && typeof collected === 'object' && collected._hungByTimeout === true) {
381
+ // The hang race won: the stream never delivered a terminal fact within the
382
+ // cap. Labeled timeout failure — the caller closes the lock with an error.
383
+ return {
384
+ status: 'timeout',
385
+ reason: `summarization stream exceeded ${SUMMARIZATION_TIMEOUT_MS}ms without a terminal finish (race won; stream presumed hung: ${describeStream(stream)})`,
386
+ }
387
+ }
329
388
  if (!collected || typeof collected !== 'object') {
330
389
  collected = { blocks: [], text: '', hasImage: false, finish: undefined, usage: undefined, _chunkCount: 0, _rejected: true, _rejectReason: 'collectChunks returned a non-object' }
331
390
  }
391
+ // HUNG-STREAM TERMINATION (checked BEFORE the `_rejected` branch): the hard
392
+ // timeout — not the caller's own signal — aborted the stream. The provider
393
+ // (or a composed listener) delivered no terminal chunk within the cap. Report
394
+ // a labeled `timeout` failure so `runTransaction` closes the `compaction/start`
395
+ // lock with an error instead of leaving it open forever. An external abort
396
+ // (caller signal) is NOT this case and keeps its existing classification.
397
+ if (mergedSignal.aborted && !(signal !== undefined && signal !== null && signal.aborted)) {
398
+ return {
399
+ status: 'timeout',
400
+ reason: `summarization stream exceeded ${SUMMARIZATION_TIMEOUT_MS}ms without a terminal finish and was aborted (${describeStream(stream)})`,
401
+ }
402
+ }
332
403
  if (collected._rejected) {
333
404
  // The stream value was not a usable async iterable (see collectChunks).
334
405
  return { status: 'not-iterable', reason: 'llm.stream() did not return an async iterable: ' + (collected._rejectReason || describeStream(stream)) }
package/web/client.js CHANGED
@@ -21,7 +21,10 @@ window.__ModuleLoader__.load({
21
21
  const React = require("react");
22
22
  const h = React.createElement;
23
23
  // 基线外部(web 平台预载):把 settingsScope 镜像成 uSES 安全的 SnapshotStore。
24
- const { createSnapshotStore } = require("@deepseek-ai/dsh-client-runtime/client");
24
+ // `createSnapshotStore` 的正确来源是 PLATFORM_MODULES seed 表内的静态包
25
+ // `@deepseek-ai/dsh-client-store`;`@deepseek-ai/dsh-client-runtime` 不在共享模块表
26
+ // 里,require 它会命中 client-modules 的 "missed the module table" 落空错误。
27
+ const { createSnapshotStore } = require("@deepseek-ai/dsh-client-store");
25
28
 
26
29
  /** 该分区拥有的文案命名空间。 */
27
30
  const NS = "settings.forceCompact";