falling-ts/dsh-force-compact ↗★ 10

@falling-ts/dsh-force-compact

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. 适合在本地部署或小上下文模型下运行,需要激进压缩上下文以节省成本和内存的用户。

パッケージ
@falling-ts/dsh-force-compact
互換性
未検証
Harness ピア範囲
>=0.2.1-rc.1
Cordis ピア範囲
>=4.0.5
バージョン
0.9.0
ライセンス
MIT
最終更新
2026/10/19

インストール

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:falling-ts/dsh-force-compact

ドキュメント

README 全文を読む ↗

dsh-force-compact

Aggressive, local-first context compaction for DeepSeek Harness agents.

A DSH Cordis function plugin that keeps the agent's working context lean by design: serve Qwen3.8‑27B on a self-hosted llama.cpp with a modest context, and the plugin shrinks the conversation itself — a large-window feel with no API cost and no data egress.

中文


Why

  • Self-hosted inference — the agent talks to a local OpenAI-compatible llama.cpp server through the standard DeepSeek adapter; no separate adapter needed.
  • Low context, high signal — instead of fighting a small cap, the plugin shrinks the conversation, so the agent reasons over a tight prompt while keeping deep memory in the compressed head.
  • Think off for compactions, passthrough everywhere else — disableThinking: true (default) turns thinking off on this plugin's own compaction summarization call only; every other model request rides the machine's configuration unchanged.
  • Private & free — no per-token billing, no egress.

What it does

Two compaction engines coexist behind one facade (resolveCompaction), transparent to callers:

EngineUsed whenNotes
Officialthe compaction service resolves in the agent realmPreferred; delegates to compaction/basic.
Builtinautomatic fallback (typical standard preset isolates the service)Self-contained persistent transaction on ctx.sessions / ctx.llm.stream / ctx.tokenMeter; reuses the official compaction/* event vocabulary, so it replays safely across builds.

No toggling — official wins when reachable, builtin takes over otherwise.

Trigger points

  • Per-request guard (agent/pre-step) — reads the session's projected context tokens (the exact number the harness renders bottom-right). At autoThresholdTokens it rejects the outgoing request and compacts the head instead, retaining the latest retainLatestTokens verbatim. Below the threshold the request proceeds.
  • Turn end / idle (agent/status → idle) — when the agent quiesces, optionally compacts via compactNow (gate: turnEndForceCompactionEnabled).
  • Manual /force-compact — immediate compactNow when idle; when busy it queues a process-local flag consumed at the next model step. Loaded lazily — see "Command availability" under Install. In the / picker the row carries the same face as the first-party commands: an icon, a localized name and a localized description (the official presentation table only covers first-party commands, so the client half attaches the face itself — see AGENTS.md, "指令行的官方外观").
  • session/flush — the awaited durability checkpoint.

Every path funnels into the single "compaction result landed in the session" boundary — the same point where the LiveUI signal fires.

Decision key is projectedTokens (provider-anchored, same figure as the UI corner), so the plugin never drifts from what you see; the threshold-aware shrink gate skips summarizer calls that provably cannot pull the session below the threshold (kills the low-threshold dead loop).

The builtin transaction bills shadowedTokenCount from the same tokenMeter.measure per-node prices the official engine uses, so the meter's collapse protocol settles the drop correctly — the bottom-right counter goes down after compaction.

Thinking control: scoped to compactions

Since the 2026-08 semantics revision, disableThinking controls one thing: whether this plugin's own summarization call (engine/builtin.js → engine/summarizer.js → ctx.llm.stream) carries reasoningEffort:'off'. Everything else is untouched:

Call siteWith disableThinking: true
Builtin-engine summarization callCarries reasoningEffort:'off'
Every other model request (business, sub-agents, tools, other plugins)Machine's LlmCallConfig unchanged
Official compaction service callsNot routed through any plugin seam — unaffected

A route that cannot express off degrades instead of failing. Not every catalog model declares an off level — pi-ai's thinkingLevelMap may map it to null, as opencode-go/deepseek-v4.1-flash does (it accepts only low / high / max, while deepseek/deepseek-flash accepts off too). The summarizer probes the route's capability before the call (llm.resolveCallConfig, a detached lookup with no provider I/O) and, when off is unavailable, uses the cheapest level the route does accept; a model with no reasoning support at all gets no effort field. Because a thinking route bills its reasoning trace against the same maxTokens cap, a fixed reasoning allowance is added on top of maxSummaryTokens (documented as the cap on the summary text) so the summary still fits. Both are logged once per route:

summarization route opencode-go/deepseek-v4.1-flash does not support reasoning effort 'off'
  — using the cheapest supported level 'low' instead; compaction continues

Before this guard, such a route failed every compaction outright (the harness rejects the unsupported effort before any provider I/O).

When the target is a llama.cpp / OpenAI-compatible endpoint, the thinking: { type: 'disabled' } field the adapter emits is silently ignored there — so the summarizer ALSO stamps the llama.cpp-native top-level reasoning_effort: "none", gated on the exact same condition. One options object carries BOTH fields:

Endpoint familyReadsResult
Real DeepSeek APIreasoningEffort:'off' → thinking:{type:'disabled'}Thinking off ✅
llama.cpp / OAI-compatiblereasoning_effort:"none" (top-level)enable_thinking=false ✅

Each family tolerates-and-ignores the foreign key, so emitting both is harmless. The field is stamped in src/engine/summarizer.js (immediately before llm.stream(options)), NOT in the llm/stream waterfall — a prior draft injected there but proved ineffective structurally (middle-layer returns are discarded; in-place seed mutation crashes the host); see the src/hooks/wire-rewrite.js module header for the write-up (that module is now retired and keeps only this historical record).

Need thinking off on business calls too? Set your provider's reasoningEffort at the request-header level — the plugin deliberately stays out of that decision.

Observability: per-attempt audit lines

Every summarization attempt logs two lines (visible at the default debug: true) — the durable proof of the scoping decision and its wire fields, without capturing traffic:

[force-compact] : compaction thinking-policy — settings.disableThinking=true → extra.reasoningEffort='off' (this summarization call carries thinking-OFF)
[force-compact] : summarization wire-fields → 
/: reasoningEffort='off' + reasoning_effort="none" (llama.cpp-native wire field)
  • Line 1 (engine/builtin.js) records where disableThinking is read and routed into the call options; with the setting off it records machine default.
  • Line 2 (engine/summarizer.js) records both wire fields exactly as they leave the options object, plus resolved provider/model; unstamped fields are labeled (absent…).

Empirically grounded: probed against a local llama.cpp endpoint, a baseline request returned populated reasoning_content (the model thinks by default), while the same request with top-level reasoning_effort:"none" returned none at all — the field genuinely disables thinking there, and business calls (which omit it) keep thinking.

LiveUI status

A tiny host→client messenger (the liveUi settings field mirrored live to the browser) replaces the official running label's leading phrase beside the turn. Only the text changes: the harness's own elapsed-time text, font, and colour are left exactly as shipped.

The status text reports which kind of time-consuming agent action is running, one phase per kind. Three phases, each driven by one agent seam:

StatusMeaningInsertion point
模型请求中 (Requesting model)the agent issued a model request and is waiting for the first tokenthe agent/request waterfall
模型响应中 (Model responding)the first token arrived; lasts to the end of the responsethe first chunk of agent/assistant-stream (index === 0)
工具执行中 (Running tool)a non-model, agent-internal operation is runningthe tools/pre-execute waterfall

responding hangs on the first chunk, not on the attempt's start frame: start is emitted before any token arrives (right after the llm.stream(request) call), and that moment still belongs to requesting's span.

A compaction summarization request never publishes any of the three (the user asked to exclude compaction-time requests). That is a structural exclusion, not a filter: the plugin's (and the official engine's) summarizer calls ctx.llm.stream directly, so it traverses no agent/request waterfall, produces no agent/assistant-stream frame, and dispatches no tools.

The compaction banners are kept:

  • [强制压缩中>>>] — just before a compaction commits;
  • [压缩完成!] — the instant a compaction lands; the next agent action (normally the continuing loop's 模型请求中) then overwrites it. There is no 3 s fallback timer any more;
  • Restored at conversation end — when the agent goes idle (the turn is fully done), an empty text is pushed: the client puts the official label back and drops its replacement prefix.

Harness 0.2.0 moved that line into its own RunningStatus component — the old button[data-turn-process] > span now renders only settled turns — anchored on the container attribute data-chat-running:


  深度求索中        ← visually-hidden announcement
  
  
    …whale animation (APNG mask + SVG fallback)…
    深度求索中,用时1分14秒 ···
  

So the client half substitutes just the leading phrase: it uses the announcement node's text as an anchor, splits the text at their common prefix, and keeps the remainder — the harness clock tail, the official trailing ··· included — verbatim. The whale animation icon and the divider are left exactly as they are: no colours, no font changes, no layout. The announcement node is never touched, so screen readers keep the official text.

A MutationObserver, connected only while a phase is active, writes both copies of the sentence: TextShimmer renders it twice (a real text node plus an aria-hidden animated highlight copy whose glyphs come from CSS ::after { content: attr(data-shimmer-text) }), so patching only the text node would let the sweep reveal the official wording. The observer re-applies in the same microtask in which React rewrites the label, so the clock keeps ticking and nothing polls.

Label text follows the app language: the host writes a locale-independent textId (the phase name) plus the canonical Chinese text, and the client half resolves it through its own ctx.locale zh/en/ja/ko dictionaries — an English UI shows Requesting model / Model responding / Running tool, a Chinese UI keeps the originals.

Badge text follows the app language: the host writes a locale-independent textId (phase name or working.N) alongside the canonical text, and the client half maps it to zh/en via its ctx.locale dictionaries — English UI shows English one-liners, Chinese UI shows the original Chinese.

Publishers are fail-safe: a messenger glitch can never disturb the actual compaction.


How it works

agent/request(payload, next)              # every model request
    return await next()                  # pure pass-through (thinking-off scopes
                                          # ONLY to the plugin's own summarizer)

agent/pre-step(payload, next)             # before each model step
    projectedTokens >= autoThresholdTokens?
        no  -> next()                     # let the request proceed
        yes -> compactRegion(head-before-retainLatestTokens, signal)
               return { kind: "reject" }  # no model request this step

agent/status({ agent, status })          # lifecycle transition
    status === "idle" && turnEndForceCompactionEnabled?
        -> compactNow(agent, freshSignal) # turn-end compaction

session/flush(session)                   # durability checkpoint
    select region -> project messages -> preview + shrink gate
    -> compaction.compactRegion(start, end, agent, signal)

Supporting modules:

  • src/hooks/guard.js — agent/request pure pass-through + pre-step threshold gate + process-local force flag (thinkingDisabled survives only as a legacy predicate).
  • src/hooks/command.js — the /force-compact command (lazily registered).
  • src/hooks/idle.js — turn-end forced compaction.
  • src/hooks/wire-rewrite.js — retired; keeps only the historical note on why the llm/stream seam cannot carry wire fields. No exports.
  • src/engine/region.js — head/tail-anchored region selection (with the official pairing ledger).
  • src/engine/summarizer.js — the one-shot LLM summarizer, aligned with official compaction-basic in structure (target resolution, prefix-cache alignment, purpose:'compaction' tag, fail-closed finish classification, usage capture); the summarization instruction text is localized to Chinese (structure identical, copy localized, with both `` tags kept verbatim). The official engine still uses the English instruction — see the "摘要指令中文化" section in AGENTS.md.
  • src/engine/builtin.js — the builtin persistent transaction (official compaction/* vocab).
  • src/engine/checkpoint.js — preview + shrink gate + delegation to the compaction service.
  • src/core/projected.js — provider-anchored projectedTokens.
  • src/core/ui-signal.js — the LiveUI messenger.

Install

As an installable package (recommended):

# from npm (published):
npm install @falling-ts/dsh-force-compact
# from git:
dsh plugin --profile web add github:falling-ts/dsh-force-compact
# from a local checkout:
dsh plugin --profile web add ./dsh-force-compact

Or, from a local checkout, as a --patch overlay without installing:

dsh web --patch dsh-force-compact/cordis.patch.yml

The plugin is loaded iff ~/.dsh/logs/dsh-force-compact.log gains:

[force-compact] debug logging enabled — writing [force-compact] lines to 

Command availability — /force-compact loads lazily

The commands service arrives with the agent-presets plane, after the plugin's boot-time apply, so registration happens at the first guarded-listener activation (agent/request / agent/pre-step / agent/status / session/flush), settling permanently on the first success. Practical effect: after (re)starting the instance, a fresh session's / picker does NOT show /force-compact until that session makes its first model request — send any one message, then the command is registered process-wide.

  • Success: [force-compact] /force-compact command registered (deferred)
  • commands permanently absent: one … still UNREGISTERED 10 min … warn explains the empty picker. Until registered, the rest of the plugin works — degradation, not an install failure.

Verify a compaction happened:

idle compaction (builtin) shadowed N nodes (~M tokens)
builtin compaction OK — replaced span seq[A..B] (N nodes, ~K tokens) with a P-char checkpoint
compaction thinking-policy — settings.disableThinking=true → extra.reasoningEffort='off' (…)
summarization wire-fields → 
/: reasoningEffort='off' + reasoning_effort="none" (…)

(The last two line