跳过主要内容
A

dsh-compaction-sticky-window

a574676848/dsh-compaction-sticky-window

用模型自写的交接便签替代摘要式上下文压缩:换窗后只保留系统提示、便签与当前 turn,并提供预算感知与归档回查。

安装

dsh plugin --profile web add github:a574676848/dsh-compaction-sticky-window

README

dsh-compaction-sticky-window

English | 中文

A DeepSeek Harness profile bundle that replaces the summarizing context compactor with a model-written handoff note and a fresh window that keeps almost nothing else.

The shipped backend condenses an old span into a model-written summary: one extra model call per rollover, and a summary whose contents the model did not choose. This backend inverts the ownership. The model writes its own note with sticky_note, opens a new window with new_context, and that window starts with the system prompt, the note, and the work in flight — nothing else. One deterministic fallback remains for automatic pressure: if no note was written, the evicted messages are reduced to the facts the log can prove.

Install

From a checkout of this repository:

dsh plugin --profile <profile> add /absolute/path/to/dsh-compaction-sticky-window

Or through the Plugins page, which installs the bundle and enables it. The bundle patch retires the summarizing backend on every plane the profile exposes, so nothing else needs composing.

What installing adds, and what it replaces

Installing adds four tools and replaces the compaction backend. It takes no tool away, and it does not change which preset your sessions use.

Profile kindCompaction ownerWhat the bundle does
CLI, headless, SDKA host-plane row from the base bundleDisables compaction-basic and inserts this backend in its place; the four tools are added
Web, DesktopEach agent preset, inside a group that isolates compactionOverrides the shipped standard preset in place — same id, same order — swapping only its compaction group

On a preset-backed profile a host-plane row cannot reach a session: the preset isolates compaction, so its own backend serves its own agents. The supported way in is to override the preset declaration by its Loader row id, which replaces that preset's whole config. presets/sticky-window.patch.yml is exactly that: the shipped standard preset, restated, with one group changed. Because the preset keeps its id and order, nothing needs selecting — the summarizing backend is gone as soon as the bundle is.

This bundle still never touches agent-preset-registry. Forcing a different default preset would switch every open session onto an unfamiliar preset, which is a different operation from replacing a backend inside the preset those sessions already use.

The restated child list is the shipped standard list with the compaction group swapped, and npm run sync-preset refuses to write a snapshot whose plugin ids differ from the installation it read. Re-run it after a DSH upgrade.

The window lifecycle

work proceeds ──► context_status: tokens left?
                        │
        ┌───────────────┴────────────────┐
        │ room enough                    │ note is current, room tight
        ▼                                ▼
   keep working                 sticky_note update  ──►  new_context
                                        │                   │
                                        │           no note saved → refused
                                        │                   │
                                        ▼                   ▼
                              note saved          window ends: every message
                                                  before the current turn is
                                                  evicted, becomes archive
                                                            │
                                                            ▼
                              fresh window = system prompt + note + turn in flight
                                                            │
                                            detail missing? → history_query

Sequence: the model opens a window

sequenceDiagram
    autonumber
    actor U as User
    participant M as Model
    participant S as context_status
    participant N as sticky_note
    participant X as Compaction transaction
    participant H as history_query

    U->>M: task
    M->>S: how much room is left?
    S-->>M: used, rollover line, remaining, window ordinal

    Note over M: a push notice arrives in the warning band,<br/>so the model learns the budget without asking

    M->>N: update(the complete handoff)
    N-->>M: pinned lines, written to the session log
    M->>X: new_context

    alt no note saved in this window
        X-->>M: refused — write the note first
    else note saved
        X->>X: evict every message before the current turn
        X->>X: render the note and replace the span
        X-->>M: window N+1, M messages archived
    end

    Note over M,X: the fresh window is the system prompt,<br/>the note, and the turn in flight — nothing else

    M->>N: read
    N-->>M: the handoff a fresh window inherits
    M->>H: search("an exact literal")
    H-->>M: item ids and snippets
    M->>H: read(id)
    H-->>M: the evicted message
    M->>U: continue the work

Sequence: automatic pressure

sequenceDiagram
    autonumber
    participant T as Turn loop
    participant X as Compaction engine
    participant M as Model

    T->>X: pre-step
    X->>X: measure against the rollover line

    alt used < line − warnTokens
        X-->>T: nothing (no notice, no log scan)
    else inside the warning band
        X-->>M: warning notice (once per window)
        Note over X: still no rollover
    else past the line, no saved note
        X-->>M: exhausted, then blocked notice (each once)
        Note over X: postponed into the fallback band —<br/>history is never evicted unprepared
    else past the line, note saved
        X->>X: roll over now
        X-->>M: fresh window: prompt + note + turn in flight
    end

    Note over X: only a provider-confirmed overflow<br/>forces a window that has no note

Automatic pressure is a three-stage ramp, not a cliff:

StageAtWhat happens
warningthreshold − warnTokensA notice asks for the note while there is still room to write it.
exhaustedthresholdA second notice says the window's budget is spent and the next action is sticky_note then new_context. A rollover happens here only if a note is saved.
blockedceiling = min(threshold + fallbackTokens, capacity)With no saved note the window still does not end; the notice now says writing the note is the only action that matters.

Each notice fires at most once per window. The rollover itself is postponed — never forced — while the note is missing, because evicting history the model never wrote down is the failure this backend exists to prevent. The single exception is a provider-confirmed context overflow, where the window must end: the note then says plainly that none was written and carries only the recorded facts.

Because the retained window is empty by default (retainTokens: 0), the fresh window is genuinely fresh. Set retainTokens or retainRatio to keep a verbatim tail instead.

The note

Two sections, and only the first is the model's:

SectionSource
## HandoffThe model's sticky_note list, carried verbatim. Claims its byte budget first.
## Recorded factsDerived from the evicted messages: file paths and commands recovered from tool-call arguments, and tool results that reported an error. Suppressed by derivedAppendix: false.

The note is a normal checkpoint message on the surface, so the next rollover reads it back like any other evicted content — no side storage, and a replayed session rebuilds the identical note.

Pins are only read from a sticky_note result, so a file the agent read cannot inject or erase them. A pin list over budget is refused with an instruction to consolidate, not silently trimmed.

The tools

ToolAnswers
context_statusTokens used, the rollover line, tokens remaining, the current window ordinal, and whether the figure is provider usage or a local estimate.
sticky_noteread returns the handoff a fresh window inherits; update replaces it.
new_contextEnds the window now. Refused while this window has no saved note.
history_querywindows lists archived windows, search finds a case-sensitive literal and returns item ids, read returns one item by id.

All four are registered on the host plane; a preset realm inherits them through the tools registry it does not isolate.

context_status prices the same threshold the transaction triggers on, and reports the trigger's own refusals (available: false with the reason) rather than a plausible number that would never fire. src/budget.js mirrors that arithmetic and is tested against it.

new_context performs the rollover inside the tool call, bounded strictly before the current turn, so the turn's own tool results still have a surface to land on. A turn can outlive everything before it: when no older node is left on the surface, that boundary names no cut, so the rollover falls back to the current step — the last cut that still leaves the turn's own tool results a surface. The write-ahead gate is the reference behavior: no note, no new window.

history_query reads the archive the log already holds: each compaction/summary event records the seqs it replaced, and those events keep their seqs forever. Windows and items are named w<window>:<ordinal>.

The prompt protocol

The bundle registers one systemPrompt section (order 9000) that states what a window is, that everything not written into the note disappears from the live context, when to check the budget, what a handoff must contain, that new_context needs a saved note, and how to recover a detail afterwards. It is registered with interpolate: false so its braces stay literal.

The reference implementations make the same move — Axon keeps a <memory_window_protocol> block in its stable prefix.

Configuration

Every key is optional. Unknown keys fail at load instead of being ignored.

KeyDefaultMeaning
thresholdRatio0.8Share of the message budget that triggers an automatic rollover.
retainTokens0Verbatim tail kept after a rollover. 0 keeps only the turn in flight.
retainRatio—Fractional form of retainTokens; mutually exclusive with it.
headroomTokensshipped defaultReserve subtracted from the context window before pressure is measured.
maxTokensshipped defaultGeneration cap used when pricing the request.
compactionRetriesshipped defaultRetries when one rollover does not fall below the threshold.
maxOverflowRetriesshipped defaultRetries after a provider-confirmed context overflow.
modelPolicies[]Per-provider/model overrides of the fields above.
autotrueWhether automatic pressure and overflow listeners are registered.
noteMaxBytes6144UTF-8 budget for the whole note.
handoffMaxBytes4096Budget for ## Handoff; claimed before anything else.
recordedMaxBytes2048Budget for ## Recorded facts.
derivedAppendixtrueInclude the recorded-facts section.
fileListMax24Distinct file paths in the appendix.
commandListMax12Distinct commands in the appendix.
pinnedLineMaxChars400Per-line character cap for a pinned fact.
warningTokens6144Remaining budget at which context_status advises writing the note now.
warnTokens6144Distance before the rollover line at which the session pushes its first notice.
fallbackTokens16384Slack past the line during which a rollover waits for the note.
minEvictTokens1024Smallest span worth replacing; below it a rollover is refused, because the note's fixed framing would be larger than what it replaces.
budgetRecheckMs60000How long a failed or negative budget resolution is trusted before the model's capacity is asked again.
noticestruePush budget notices into the model's context.
historyReadMaxChars4000Characters returned by one archive read.
historySearchLimit5Hits returned by one archive search.
historySnippetChars400Context characters shown around a hit.
noteTooltrueRegister sticky_note.
sensingTooltrueRegister context_status.
historyTooltrueRegister history_query.
newContextTooltrueRegister new_context.
guidancetrueRegister the prompt protocol section.

The note budget is additionally trimmed to half of the span it replaces, so a small span shrinks the note instead of failing the transaction's shrink check. A span too small to hold even the note's framing still fails loudly, exactly as the shipped backend does with a summary that cannot fit.

Why this shape

  • The model owns the handoff. A summary the model did not write is a guess about what matters. The reference implementations hand that decision to the model and give it a gate that makes writing it obligatory before a rollover it requests.
  • No side storage. The note, the pins, and the archive index are ordinary surface content or existing log events, so fork, resume, and replay need no extra persistence and no format version.
  • No new session events. The mechanism rides the existing compaction/* vocabulary.
  • No dependence on internal APIs. The backend extends the published BasicCompactionEngine and overrides the documented summarize() hook; everything else is the shipped transaction.

Development

node --test test/note.test.mjs test/engine.integration.mjs
node scripts/sync-preset.mjs

test/engine.integration.mjs drives real rollovers through the shipped compaction transaction with a hand-built Cordis Context, and asserts the fresh window really is the note plus the current turn — including one long turn that has to roll over at its last step because it outlived everything before it.

node_modules/. holds a local junction to a DSH installation for module resolution in development and is not published.

Known limitations

  • The model can still under-write. When it does, the note carries only the recorded facts and says no handoff was written; it never fabricates one.
  • If the model never writes a note, the window holds out until a provider-confirmed overflow forces it. That is deliberate — an unprepared rollover loses state — but it means a model that ignores every notice reaches the hard failure instead of a quiet one.
  • The note's handoff is read from the whole session, so a note written in the turn that triggers a rollover is still carried.
  • A single indivisible message larger than the window cannot be repaired by a rollover; the optional tool-result pruner still runs before selection.
  • The override is a snapshot of standard at the targeted DSH release. A DSH upgrade can change that preset without this package noticing; run sync-preset and publish a new version. Verified at the targeted release: the restated list has the same 19 top-level plugin ids as the installation.
  • The override applies to sessions started after the bundle is enabled. A session already running keeps the preset it mounted at start, so restart (or open a new session) to pick up the new backend; the tools it already has do not change either way.
  • Push notices are appended as ordinary user/message events with the sticky-window-notice source kind. The session format admits extension source kinds, so a log stays readable after the plugin is removed, but an older reader will not know that kind by name.
  • On a DSH release whose base backend compacts on a model switch through a path that bypasses compactIfNeeded, that switch can still roll over without the note gate. The targeted 0.2.0 line has no such path; a later line would need this backend to gate it too.

相关插件