@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 +14 -0
- package/package.json +12 -2
- package/src/core/ui-signal.js +26 -0
- package/src/engine/summarizer.js +75 -4
- package/web/client.js +4 -1
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.
|
|
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
|
},
|
package/src/core/ui-signal.js
CHANGED
|
@@ -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
|
package/src/engine/summarizer.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|