@falling-ts/dsh-force-compact 0.2.6 → 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/package.json +12 -2
- package/src/engine/summarizer.js +75 -4
- package/web/client.js +4 -1
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/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";
|