Vai al contenuto principale
M

dsh-history-fold

mili1360/dsh-history-fold

Sostituisce la parte più vecchia della cronologia visibile di una sessione con un messaggio contenente un indice estrattivo e il percorso di un archivio locale senza perdita, riducendo i token inviati a ogni richiesta.

Installazione

dsh plugin --profile web add github:mili1360/dsh-history-fold

README

dsh-history-fold

dsh-plugin deepseek-harness license: MIT

中文说明:README.zh-CN.md — 完整对照,含 0.8.0 分类器的实测数据

Topics: dsh · dsh-plugin · deepseek-harness · persistent-memory · long-term-memory · memory-recall · history-fold · lossless-archive · extractive-index · recoverable · non-summary

Your assistant remembers yesterday — right up until the context fills up.

Every conversation that runs long enough hits the same wall: the history no longer fits, so something has to be dropped. The built-in compactor drops it by summarising, and a summary of eleven hours of work is not the parameter you needed, the path you saved, or the decision you actually made. Ask about it two days later and you get a confident paraphrase of a memory nobody kept.

This plugin keeps the whole thing instead. The oldest span of history becomes one message that carries an extractive index — real prefixes of the real messages, nothing paraphrased, no model called — and every folded message is written verbatim to a local archive first. The visible context gets short; the history does not disappear.

It is memory, not bookkeeping:

  • Nothing is ever dropped without a copy on disk. Archive first, fold second; if the write fails the fold is abandoned.
  • Nothing is summarised. The lines in the index are the messages' own prefixes, so what the model sees is quoted evidence, not a rewrite.
  • The notes are regenerable from your session logs — delete them entirely and the next refresh mines them back. Measured below.

⚠️ Read this first: precision is not the same as remembering

The engineering here is complete: lossless archive, recoverable index, semantic vectors, extractive notes — each one real, each one tested. None of that adds up to "it remembers what you talked about."

Measured against 659 human-labelled turns out of this project's own 4,906-turn history:

MetricMeasured
Turns that genuinely needed the archive, correctly let through51/87 = 59%
Turns that did not need it, wrongly triggered43/572 = 7.5%

Four times in ten, a turn that should have pulled up old context did not. That is not a bug — it is a deliberate trade:

ConfigurationInjectionsShare of turns
0.7 strict67019.5%
0.8, no classifier2,37769.3%
0.8 + classifier (current)2898.4%

Going from 69% to 8.4% is how the token cost was cut. The 41% of missed recalls is what paid for it. The saved tokens are real. So is what gets forgotten.

Read every "complete"/"lossless" claim below with that caveat. This plugin guarantees that folded content is never lost. It does not guarantee that the right content comes back at the right moment. The first is an engineering problem and it is solved. The second is a judgement problem.

2026-10-29: the three real causes found and fixed

The 59% above is the classifier layer. Digging below it showed the missed recalls were mostly not the classifier's fault. After fixing three things, measured end-to-end on 10 real questions:

BeforeAfter
End-to-end recall (10 questions)3/109/10
Chit-chat false positives (10 turns)—1/10 (only "好的")

The three causes:

  1. crossSession defaulted to false — only this session's notes were searched. Anything said in another session was outside the search radius entirely. This was the biggest single cause. Now on by default.
  2. gateThreshold defaulted to 0.50, which sat inside a cluster — "B65 到了怎么跑" scored 0.444, "显存怎么分配" 0.424, "二班下好了吗" 0.413: all rejected by a hair. Lowered to 0.40, which recovers all of them at the cost of admitting one extra 闲聊 turn.
  3. entity-map.json coordinates were mostly broken — bare filenames caused cross-session misalignment, line numbers ran past end-of-file or pointed at ## headings, and keys shorter than 3 characters were skipped outright by searchRare (桌宠/沙箱/肉鸽/双路/显存 had never fired). All 19 entries recomputed.

⚠️ The 9/10 comes from 10 hand-written questions; the 59% comes from 659 human-labelled turns. They are different datasets and must not be compared directly, and the 659-turn set has not been re-run. Treat 9/10 as "what the new configuration does on these 10", not as "recall is now 90%".


  • Inert by default. A fresh install reads nothing, writes nothing and logs nothing until you configure it.
  • Lossless. Every folded message is written to a local archive before the fold is committed, and the replacement tells the model exactly where to look.
  • No node_modules patching. It uses the same public surface-replacement mechanism as the shipped compactor, so a DSH upgrade cannot break it.
  • Also works on DSH 0.2.x (0.2.0-rc.2, 0.2.1-rc.2), which changed four things this plugin depends on — two of which fail silently. See DSH 0.2.x for what changed and how each was measured.
  • Declares what it supports. engines.dsh is set to >=0.1.0-rc.2 <0.3.0-0, so a loader that checks compatibility can tell you before mounting it rather than after it misbehaves.
  • The notes are regenerable. Everything this plugin remembers is derived from your DSH session logs, not hand-maintained. Delete the entire notes directory and the next refresh mines it back out of the logs, byte for byte. Measured: one 1.7 MB session log → 254 user messages, 1,948 replies, 4 note files; deleting the notes directory and re-running produced the identical 4 files.

What's new in 0.8.0 — it learned when not to interrupt you

Recall only helps if it fires at the right moment. The old gate was a regex: if the message contained a rare identifier or a word like "last time", go look something up. Measured against 659 hand-labelled turns from this project's own 4,906-message history, that regex fired on 22% of turns that did not need it — a wall of old notes arriving in the middle of ordinary conversation.

0.8.0 adds a 659-example classifier (character n-grams + logistic regression, lib/gate.js + model/gate-model.json) that reviews the rule's decision:

Measured on 659 hand-labelled real turnsRule aloneWith the classifier
Turns that genuinely need the archive, admitted49/87 (56%)51/87 (59%)
Turns that do not, falsely triggered126/572 (22.0%)43/572 (7.5%)

Both numbers move the right way — which is the only reason it ships. A filter that bought precision by giving up recall would have been useless here.

Then the whole decision chain was run over all 3,431 usable user messages in that history, three ways:

RouteFiresFire rateTokens injected
0.7 behaviour (strict)67019.5%≈709k
0.8 rules, no classifier2,37769.3%≈2.59M
0.8 + classifier2898.4%≈316k

Without the classifier the plugin was injecting old notes on one message out of three. With it, the fire rate lands in the 10–15% band where the annotated data says real demand is — and the injected volume drops 8×.

The properties that made it shippable rather than merely interesting:

  • 1.3 MB, zero dependencies. Plain JS over a JSON weight table: no ONNX, no runtime, no Python, no network call, no API cost. Inference is sub-millisecond; total recall overhead is 44–53 ms.
  • A filter, not an oracle. It sits on top of the existing rules and can only veto them when it is confident a turn is noise. Explicit recall intent ("what did we decide last time…") never reaches it.
  • Missing model = unchanged behaviour. If model/gate-model.json is absent or unreadable the layer is skipped and the rules decide exactly as in 0.7. Nothing is silently disabled, nothing silently enabled.
  • Trained on this project's own conversations, not a public benchmark. That is also its limit — see Limitations.

Also in 0.8.0: note scoring is TF-IDF weighted (top-3 on 20 rewritten questions went 7 → 12), recall.strict no longer silently abandons a question, and every injection logs the question plus the note files it hit — the raw material for training on real usage later.


Why a summary is the wrong answer

A long DSH session on one machine, for scale:

a long sessiona fresh one
history sitting in the window497,141 tokensa few thousand
what the built-in compactor leaves behinda summary, written by a model—
can you still quote the original line?noyes

Once that history is summarised, three things are gone permanently: the exact text, the ordering, and the ability to check. You cannot rg a paraphrase. Ask "what did we set the threshold to, and why" a week later and the honest answer is a plausible guess.

This plugin takes the other option — keep everything, shrink only the window. Folded messages are archived verbatim, so the answer to "what exactly did we say" is a file search away, and the message that replaced them quotes real prefixes instead of inventing a narrative.

That the request also gets shorter is a side effect, not the pitch: a shorter context is what keeps a long conversation usable at all, and it is why the fold has to happen. What the plugin refuses to do is make the fold lossy.

What it does

Before a turn's first step, if the visible surface is above the trigger — triggerRatio × contextWindow, or an explicit absolute triggerSurfaceTokens, see how the thresholds are resolved — the plugin:

  1. Picks the oldest run of nodes that ends on a tool-pairing balanced cut, leaves at least keepRecentNodes behind, and brings the surface down to about the target (targetRatio × contextWindow, default 0.14).

  2. Archives every folded node, verbatim, to $DSH_HOME/history-fold/archive/<session>.jsonl. If this write fails the fold is abandoned — history is never dropped without a copy on disk first.

  3. Replaces that range with one user/message, using the same mechanism as the shipped compactor:

    session.append('user/message', message, {
      surfaceOp: { op: 'replace', start, end },
      sourceEventSeqs: [/* every shadowed seq */],
    })
    

    dsh-session then splices the replacement into the old range's position, so chronology is preserved and the conversation still reads in order.

If that fold did not land, there is a second chance. When the next turn still finds the surface at or above emergencyTriggerRatio (0.8 of the window, 800k on a 1M window), the normal attempt clearly did not bring it down — keepRecentNodes covered the whole foldable span, the minFold* floors refused a small fold, or an archive write failed and the fold was abandoned. The plugin then retries with the recent window cut back to emergencyKeepRecentNodes (12) and the floors lowered to emergencyMinFoldNodes / emergencyMinFoldTokens. It is the same bounded fold: the same archival of every node, the same refusal to split a tool-call/tool-result pair. It just stops being polite about how much history it holds back — and every line of that fold carries emergency: true in the log, so a reader can tell the two paths apart afterwards.

The replacement is an index, not a summary

The message that enters the context window is a notice plus an extractive index — every sampled line is the real prefix of that message's own text. No model is called, and nothing is paraphrased:

[dsh-history-fold] 614 earlier messages (surface seqs 223995..387899) were folded out of the
visible history so this session stays fast.
NOTHING WAS DELETED. Their full text is archived at:
  /…/.dsh/history-fold/archive/session-….jsonl
To read any of it verbatim, search that file with the pwsh or grep tools, for example:
  rg -n "search words" "/…/.dsh/history-fold/archive/session-….jsonl"
The one-line index below is extractive, not a rewritten summary: read it to find what you
need, then search the archive for the exact text. Do not guess about folded content.
(sampled: every 3th of 614 folded messages)
[0] user/message (plugin) seq=223995: This is an automatically generated checkpoint …
[3] assistant/message (model) seq=224097: Now the fireWeapon for bolt does a spread burst …

If the index would exceed digestMaxChars it samples every n-th line rather than truncating, and says so.

Nested paths: a path inside a path

The in-context index is sampled once it overflows, and the sampling step is capped at 64 — so the more often a session folds, the coarser the index of its oldest spans becomes, until an old span is reduced to "somewhere in seqs A..B". That is the part that does not scale.

So every fold now does one more thing: it writes the unsampled index of the range to disk and leaves only its path in context.

the folded message in context
  |- path -> $DSH_HOME/history-fold/index/<session>/f100-4500.md  <- full index of that range
  |            \-- path -> f20-99.md                              <- an earlier fold's full index
  |                         \-- path -> f0-19.md
  \-- path -> $DSH_HOME/history-fold/archive/<session>.jsonl      <- the raw text of everything

Two rules make the chain hold:

  1. Each folded message carries a machine-readable marker: [fold-index] seqs=A..B messages=N file=<tier index path>. When that message is itself folded later, the marker is parsed back out — so the older span is represented by a pointer, not by a 160-character stub of an index.
  2. That line is never sampled away. Sampling drops ordinary message lines only. Dropping a fold-pointer would silently orphan everything older than it: nothing left in context could reach it.

Every tier file is self-contained on disk, and it lists the tier files of any older folds that it covers (## older tier files). Context keeps the shortest layer; walking down the paths reaches the raw text. Reading a tier file costs nothing until it is read — and it is never read unless it is needed.

Measured on a real 1,210-turn session (surface 213,919 tokens):

folded614 messages / 184,709 tokens / 86.3%
marker that enters context30,232 characters (~7.5k tokens) — about 24:1
archive written638 KB
user-authored text preservedverbatim
assistant text preservedverbatim (the archive is a superset — it also keeps reasoning)

Because the archive holds the real text and the marker tells the model to search it, folded content stays recoverable. Asked about a specific change that lived in the folded range, the model read the archive and answered with the exact values; where content genuinely was not in context it answered "not in context" instead of guessing.

Install

One command, straight from the repository:

dsh plugin add --profile web github:mili1360/dsh-history-fold

Install from git, not npm. This package is not on the npm registry yet, so npm install dsh-history-fold will 404. The git form above is the supported path and is what the maintainer uses; it resolves the repo directly and needs no registry at all.

Prefer a pinned, immutable artifact? Every release ships its tarball, no registry involved:

dsh plugin add --profile web https://github.com/mili1360/dsh-history-fold/releases/download/v0.8.2/dsh-history-fold-0.8.2.tgz

That resolves the package over git, adds it to the profile's dependencies, and registers the bundle in dsh.profile.bundles on its own — verified on 0.2.0-rc.2. Then restart DSH: a registered bundle is only picked up at boot.

Works the same way from a local clone, if you would rather build from source:

git clone https://github.com/mili1360/dsh-history-fold.git
dsh plugin add --profile web ./dsh-history-fold
Doing it by hand instead

This is a plugin for a DSH profile. Add it to the profile — the same layer every other DSH plugin uses.

  1. Make the package resolvable by your profile, then add it to the profile manifest ~/.dsh/profiles/<profile>/package.json:

    {
      "dependencies": { "dsh-history-fold": "link:/path/to/dsh-history-fold" },
      "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "dsh-history-fold"] } }
    }
    

    Or, using the explicit patch layer, append to ~/.dsh/profiles/<profile>/cordis.patch.yml:

    - insert:
        - id: history-fold
          name: 'file:///path/to/dsh-history-fold/lib/index.js'
    
  2. Restart DSH. Plugin rows load at boot.

  3. It is already on. The shipped defaults fold for real — but only above triggerRatio (default 0.33 of the model's context window, deliberately early: that ratio multiplies DSH's meter, which under-reports the real prompt; see the calibration section below), and every folded node is archived verbatim before it leaves the visible history.

    To watch it without letting it write anything, create ~/.dsh/dsh-history-fold/config.json:

    { "dryRun": true }
    

    dryRun: true logs what it would fold without writing anything. Watch ~/.dsh/logs/history-fold.jsonl until the numbers look right, then set "dryRun": false again (or delete the file and the default takes over).

To remove it, delete the row you added and restart. Nothing else is left behind; the archive directory is the only thing this plugin creates.

Requirements / compatibility

After installing, confirm it actually works — a profile that boots is not proof. A plugin can load cleanly, serve its routes and still break the host; we have seen exactly that. So the acceptance test is: create a new session and send it one message, then check $DSH_HOME/logs/history-fold.jsonl for a profile line (observer mode) or an idle / would-fold / folded line. If new sessions cannot be created at all, remove the mount line above and restart.

Developed and tested against DSH 0.1.0-rc.7 — a customised build, not a stock upstream release. Another version may register its services or name its events differently: this plugin touches ctx.on, ctx.effect, ctx.tokenMeter, ctx.reflect, ctx.llm and the agent/pre-step event.

DSH 0.2.x (0.2.0-rc.2, 0.2.1-rc.2) — supported

Verified on 0.2.0 end to end: three consecutive folds, every turn completing, the folded message persisted to session.v4.jsonl.zstd, the session reloaded after a restart and kept folding, and the nested tier link present on disk.

On 0.2.1-rc.2 the plugin was mounted into a clean profile in its own DSH_HOME and came up (log line {"mark":"boot","plugin":"dsh-history-fold","version":"...","tools":true}), which also proves ctx.tools is available there. Its hook signatures are unchanged in 0.2.1-rc.2: tools/pre-execute (exec, next), tools/post-execute (exec, result, next), session/event (session, event). One thing to know: agent/pre-step does not exist in 0.1.0-rc.8 at all (the string appears only in comments there) and does exist in 0.2.1 — so a listener on it is inert on 0.1.x and becomes live after the upgrade.

Four of its API changes had to be handled, and three of them fail silently:

what changed0.1.x0.2.0if you get it wrong
event accessorsession.events, entries carry seqno such member; the log is positional (eventAt(seq) is log[seq]), plus snapshotEvents()every node reads as unknown / 0 bytes, no cut is ever balanced, no-safe-cut forever — no error
surface-replace range{op:'replace', start, end}{op:'replace', **startSeq, endSeq**}isReplaceOp throws: the fold is rejected
surface headno system message in the surfacenode 0 is the system prompt and is protectedany replace reaching node 0 throws: "may be rewritten only by a system/message over exactly that node"
message source{kind:'plugin', plugin:X}that wrapper is retired; a message needs a producer-owned kind — plugin:<name> for a third-party pluginthe append succeeds, then ROW ADMISSION throws when the log is written ⇒ the turn never ends, the session stops persisting, and every later turn hangs too. Nothing is logged

The plugin picks the replace shape and the source spelling by host, and starts the fold after the protected head; the head's tokens stay in the reach-the-target arithmetic. Log lines name what was used (surfaceOpKeys) and how many leading nodes were skipped (protectedCount).

If your build does not match, the plugin deactivates and says so — it does not stop the profile from booting. Nothing is registered, and one line lands in logs/history-fold.jsonl:

{"mark":"deactivated","reason":"registration-failed","step":"services","error":"..."}

If the profile will not start at all after installing, remove the mounted lines from <profile>/cordis.patch.yml (or drop "dsh-history-fold" from dsh.profile.bundles, if that is how you installed it) and restart:

- insert:
    - id: dsh-history-fold
      name: 'file:///path/to/dsh-history-fold/lib/index.js'

Configuration

$DSH_HOME/dsh-history-fold/config.json (falling back to ~/.dsh/…). It is re-read on every turn, so edits apply without a restart.

KeyDefaultMeaning
enabledtrueMaster switch. false ⇒ loaded but completely idle.
dryRunfalsetrue ⇒ observe and log only, never write.
profiletrueWrite one size/profile line per turn.
triggerSurfaceTokens250000Absolute trigger, used when the context window cannot be resolved. Explicit here ⇒ wins over the ratio. See the calibration section below before setting this on Chinese-heavy history.
targetSurfaceTokens180000Absolute target, same fallback role. Must be reachable — see below.
triggerRatio0.33Fold only above this fraction of the model's context window. Applied to DSH's meter, which under-reports real prompts (measured ~1.75x on Chinese), so this is deliberately below the compactor's 0.8 line — see the calibration section.
targetRatio0.14Fold down to about this fraction of the window — below the compactor's own 0.16 retain ratio.
emergencyTriggerRatio0.42Second chance. Still at or above this fraction of the window at the next turn ⇒ the normal attempt did not land, so retry with the relaxed limits below.
emergencyTriggerSurfaceTokens320000Absolute form of the second chance, used when the window cannot be resolved. Never allowed below the normal trigger.
emergencyKeepRecentNodes12How much recent history the retry is still allowed to protect (the smaller of this and keepRecentNodes wins).
emergencyMinFoldNodes4Retry floor on node count.
emergencyMinFoldTokens2000Retry floor on folded tokens.
keepRecentNodes60Never fold the most recent this-many nodes.
minFoldNodes20Refuse to fold fewer nodes than this.
minFoldTokens20000Refuse to fold fewer tokens than this.
archivetrueWrite the lossless pre-fold archive. Leave this on.
archiveDirhistory-fold/archiveRelative to $DSH_HOME.
digestMaxChars30000Cap on the in-context index. Over budget ⇒ sample, never truncate.
digestLineChars160Characters of each sampled message shown in the index.
indexTreetrueWrite the tier index and name its path in context. Off ⇒ the old sample-only behaviour.
indexDirhistory-fold/indexTier index directory, relative to $DSH_HOME.
indexFulltrueWrite the unsampled index to disk. Off ⇒ collect pointers but write no new file.
onlyAtTurnStarttrueFold only before a turn's first step, never mid-turn.
logFilelogs/history-fold.jsonlRelative to $DSH_HOME.
memoryFeedfalsePost the same digest to a memory service after each fold.
memoryFeedUrl—e.g. http://127.0.0.1:7999/memory-eternal/api.
notes.enabledtrueMine the session log into notes.dir on a timer. Off ⇒ no mining at all.
notes.dirhistory-fold/notesWhere the markdown lands. Relative to $DSH_HOME unless absolute.
notes.refreshMinutes10How often the log's mtime/size is checked. A pass runs only when it changed.
notes.eventstrueAlso write events-YYYY-MM.md (the durable-statement ledger).
notes.minEventScore3Strictness of the ledger. Raise to 4 for fewer, cleaner lines.
notes.startDelayMs45000First pass waits this long so boot is never slowed.
notes.maxLogMB600Safety valve: a bigger log is skipped rather than read into the heap.
recall.strictfalsetrue ⇒ the precise layer (entity map / rare identifiers) is tried first. Since 0.8.0 this no longer ends the turn when it finds nothing — it falls back to the broad path. Set recall.fallback: false for the pre-0.8 “inject nothing” behaviour.
recall.fallbacktruefalse ⇒ a strict-mode miss injects nothing (old behaviour).
recall.fallbackMinChars12A message shorter than this only falls back when it carries recall intent (see recall.gateThreshold).
recall.gateThreshold0.45Confidence the bundled classifier must reach for a plain question to proceed. Lower ⇒ more lookups and more noise; 0 disables the filter. The model is model/gate-model.json; if it is missing the layer is skipped and rules apply unchanged.
recall.gateModel(bundled)Override the classifier path.
recall.titleWeight2Multiplier when a matched term also appears in a ## heading.
recall.entityMapFiledsh-history-fold/entity-map.jsonJSON table of {key, file, line, title}. A key that appears in the user's message serves that exact location, no scoring. Edit the file to teach it a topic.
recall.rareMaxDf2A rare identifier must appear in at most this many note files to be used as a locator.
recall.entityMapAutoFiledsh-history-fold/entity-map.auto.jsonThe auto-built table. Rebuilt from the notes, never hand-edited.
recall.autoMapMinutes30Rebuild the auto table when it is older than this.
recall.autoMapMaxDf4The auto table may be broader than the live scan, because a table hit is exact by construction.
recall.autoMapLimit600Cap on auto-table rows (keeps the per-turn lookup cheap).

notes.* is read when the plugin loads, so changes to it need a restart; everything else on this table is re-read every turn.

How a recall hit is chosen

Three routes, tried in this order, and the first one that produces anything wins:

  1. Entity map — recall.entityMapFile is plain JSON: {"key":"tiger-maus","file":"day-20261024.md","line":1709,"title":"…"}. If key appears in the user's message (case-insensitive), that location is served. Deterministic, zero scoring. Extend it by editing the file; no restart needed.
  2. Rare identifiers — a term shaped like a name (digits, dots, dashes, or an inner capital, length ≥ 5) that appears in at most recall.rareMaxDf note files. The reasoning is measured: tiger-maus lives in 2 files while "how/previous/that" live in all of them, so ranking by occurrence buries the only term that could locate anything.
  3. Keyword/embedding fallback — the previous behaviour, and it is noisy by nature (2-gram cutting turns "有点牛逼啊" into 呲牛/牛逼/逼啊). Set recall.strict: true to turn it off entirely: then an injection only ever happens when route 1 or 2 found something.

Two rules apply to every route:

  • The current turn's words, not the previous turn's. The claimed payload.messages are read first; agent/pre-step runs before the message reaches session.events.
  • Today's own note file is never served. Reading back what was written minutes ago is self-reference, not memory.
The table builds itself

entity-map.auto.json is generated from notes.dir and rebuilt whenever it is older than recall.autoMapMinutes. The signal is narrow on purpose: a name-shaped term (digits, dots, dashes, inner capitals, length 4-24) that the human typed into a ## heading, occurring in at most recall.autoMapMaxDf note files. Scanning body text instead produced 3,032 candidates, almost all of them code symbols, file names and function names; headings are the user's own words, and that cuts it to a couple of hundred useful ones.

The hand-written table is still read first and is never overwritten. One guard applies to both: a heading containing a credential (密码 / 口令 / password / token / key) keeps its location but its wording is replaced with (含凭据,标题已隐藏), because titles are injected into the model's context.

Mining the session log into notes

Folding only ever covers what has been folded. lib/notes.js closes that gap: it decodes the newest $DSH_HOME/sessions/**/session.jsonl.zstd (concatenated zstd frames, decoded in pure Node) and writes two kinds of markdown into notes.dir, which recall searches alongside the archive:

  • day-YYYYMMDD.md — one section per episode: 用户 request, 结论 outcome, 路径 touched.
  • events-YYYY-MM.md — one line per durable statement (decision, rule, install, path, root cause), deduped, each carrying its date and seq so the day file can be opened.

Measured on a 27.4 MB log: 15,740 frames → 108 MB / 111k events in ~1.1 s, and a refresh pass writes ~50 files in 4-5 s. Secrets (sk-…, Bearer …, ghp_…) are redacted before anything is written, so a key that was pasted into the conversation never lands in a note.

targetSurfaceTokens / targetRatio must be reachable. At least keepRecentNodes nodes always stay, which is at least that many steps' worth of tokens. A target below that floor can never be met and the plugin will log no-safe-cut every turn. A target at or above the trigger is unreachable by construction (the fold stops at the target, and the surface only ever starts above the trigger), and exits through the same no-safe-cut line.

How the two thresholds are resolved

triggerSurfaceTokens / triggerRatio (and the same pair for the target) resolve in this order, per key, every turn:

  1. The key is present in config.json. triggerSurfaceTokens: 300000 means 300000 — an explicit absolute value beats the ratio, even when the context window is perfectly resolvable. "Present" means present in the raw file, not in the merged config object: DEFAULTS carries these keys too, so a merged-object check would always find one and the ratios would never apply at all.
  2. Otherwise, if the context window resolves: floor(window × ratio). The window comes from ctx.llm.resolveModelInfo(provider, model, signal).context.contextWindow — the same lookup dsh-compaction-basic uses for its own thresholdRatio × contextWindow.
  3. Otherwise: the shipped absolute default, and the log line says default.

The ratio is what scales. A fixed 250000 can never be reached on a 128k-window model, so the plugin silently does nothing there; on a 1M window it is only a quarter of capacity, so the compactor gets there first.

Verified on a real DSH instance (2026-10-12, web profile, a 1M-window model; the default ratio was 0.75 at the time, which is exactly the value 0.8.2 replaced after it failed to fold for three weeks): with a config that carries no absolute key, a turn logged windowTokens:1000000, trigger:750000, target:180000, triggerSource:"ratio" and via:"ctx.reflect.get|llm:ctx.reflect.get/agent.options" — so llm was reached through ctx.reflect.get and the provider/model came from agent.options. If none of the three lookup paths work in your build, the line says triggerSource: "default" and the behaviour is exactly what it was before this feature existed.

Calibrate the trigger against the real prompt, not against the number. surfaceTokens is DSH's own meter, and on Chinese-heavy history it under-reports what the provider actually bills by a measured factor: on one real session, surfaceTokens: 452,024 while the API request carried 790,915 tokens (cacheReadTokens + inputTokens), a ratio of 1.737–1.769 across 35 consecutive turns. A threshold that looks safely below the compactor's line in meter units can therefore be far above it in real tokens.

That is not hypothetical. This plugin ran triggerSurfaceTokens: 750000 against a 1,000,000-token window for three weeks and never folded once:

what the config looked likewhat it meant
750000 "of a 1M window", i.e. 0.751.31M real tokens — above the window itself
trigger 750000 vs. compactor at 800000unreachable: the compactor always fired first and rewrote the history into a model-written summary
the logidle, "surfaceTokens": 452024, "trigger": 750000 — reading, correctly, as "not needed yet"

So: measure your own ratio before choosing a number.

  1. Take one profile line's surfaceTokens from $DSH_HOME/logs/history-fold.jsonl.
  2. Take the same turn's cacheReadTokens + inputTokens from $DSH_HOME/logs/vitals/vitals-*.jsonl.
  3. Divide. Keep triggerSurfaceTokens low enough that trigger × ratio stays comfortably under 0.8 × contextWindow.

On the 1M-window, Chinese-heavy session this project is developed against, that ratio is ~1.75, so the working set is triggerSurfaceTokens: 300000 / targetSurfaceTokens: 130000 — ~528k real tokens (52.8% of the window) to fold, ~234k real tokens kept. That leaves ~272k real tokens of margin before the compactor's 800k line.

Since 0.8.2 the plugin checks this for you. A trigger at or above 0.8 × window, or at or above the window itself, or a target at or above the trigger, writes one threshold-warning line per distinct resolved pair — so the next misconfiguration is one grep away instead of three weeks away.

Every profile and fold line reports what happened: windowTokens (the resolved window, or null), and triggerSource / targetSource = absolute | ratio | default. A ratio outside (0, 1) — 0, 1, 1.5, "0.75", null — never throws and never becomes a broken threshold: it falls back to rule 3 and the line also carries triggerRatioInvalid / targetRatioInvalid so the reason is visible.

Relation to the built-in compactor, and to the other "fold"-named plugins

built-in dsh-compaction-basicthis pluginUI-fold plugins
What replaces the historya model-written summaryan extractive index + an archive pointernothing — the request is untouched
Can you still quote the originalno — the summary is all that survivesyes, verbatim, in $DSH_HOME/history-fold/archive/n/a
Is anything inventedyes, the model writes new proseno, the index lines are the messages' own prefixes, and no model is calledn/a
Recoverablenoyes, one rg over the archiven/a
What it changesthe surface (what the model is sent)the surface (what the model is sent)the display (long messages collapsed in the UI)
TriggerthresholdRatio × contextWindow, 800k by defaulttriggerSurfaceTokens (or triggerRatio × contextWindow), calibrated against the real prompt size — 300000 on a Chinese-heavy 1M session; an absolute triggerSurfaceTokens still overrides the ration/a

Keep the trigger below the compactor's line — in real tokens, not in meter units. dsh-compaction-basic fires at thresholdRatio × contextWindow — 0.8 × 1,000,000, so 800k — and keeps 0.16 × window (160k). Its replacement is a model-written summary, which is lossy. Folding first is what stops that summarizer from ever firing at all. The trap is that DSH's surfaceTokens can under-report the real request by ~1.75x on Chinese text, so a trigger of 0.75 × window in meter units is ~1.31 × window in real units and never fires. Measure the ratio (previous section), then pick the number.

A plugin with fold in its name is often a display fold: it collapses a long message in the UI so it is easier to read, and leaves the request payload exactly as it was. This plugin is the opposite — it does not touch the UI at all, and every byte it moves is aimed at the tokens sent on the next request. Decide which of the two you actually want before installing.

For the on-disk side of the same problem — session logs growing because finalised streaming fragments are never removed — see dsh-session-vacuum.

Log

$DSH_HOME/logs/history-fold.jsonl, one JSON object per line:

markMeaning
profilePer-turn size/token breakdown, plus what each candidate keepRecent would save.
profile-errorProfiling itself failed.
idleBelow the trigger; nothing to do.
would-folddryRun — this is what would have been folded.
folding / foldedA real fold started / committed. Both carry surfaceTokens, foldTokens, startSeq, endSeq.
no-safe-cutNo balanced cut reaches the target, or the minimums were not met.
no-meterThe token meter was unreachable, so it refused to fold blind.
threshold-warningThe resolved thresholds cannot fold anything usefully — see below. Written once per distinct resolved pair, never once per turn.
archive-failedThe archive write failed, so the fold was abandoned.
errorAnything else. The hook always fails open — a turn is never blocked.
fedThe digest was posted to the memory service (memoryFeed).

Every one of these lines also carries the resolved thresholds, so no reader has to guess which of the three paths ran:

FieldMeaning
trigger / targetThe two thresholds actually used this turn, in tokens.
windowTokensThe resolved context window, or null when it could not be established.
triggerSource / targetSourceabsolute (explicit in config.json), ratio (floor(window × ratio)), or default (the shipped absolute fallback).
triggerRatioInvalid / targetRatioInvalidPresent and true only when the configured ratio was unusable and rule 3 was applied instead.
warningsOn a profile line: which unreachable-threshold conditions hold. Values: trigger-behind-native-compaction (at or above 0.8 × window, so DSH's own compactor always gets there first), trigger-at-or-above-window (unreachable outright), target-not-below-trigger (nothing can ever be folded). An unresolved window produces no window-relative warning rather than a guessed one.
viaWhich path reached the token meter, and which reached llm (e.g. `ctx.tokenMeter

Safety model

  • Inert by default. With no config file, nothing is read, written or logged.
  • Archive before fold. A fold commits only after its full text is on disk.
  • Balanced cuts only. A tool call never crosses a fold boundary.
  • Balanced failures only. A missing token meter, an unreachable session or an archive error all skip the fold and log it, rather than guessing.
  • Turn boundary only. onlyAtTurnStart keeps a fold from ever landing mid-turn.
  • Nothing is paraphrased. The index is extraction; only the pointer message is synthetic, and it is labelled source.kind = 'plugin'.
  • A failed index write never blocks a fold. The archive is written first and holds every byte, so an index error is logged as index-failed and the fold proceeds; the message then names the raw archive instead of a tier file.

Tests

npm test              # all four suites
npm run test:fold     # fold policy and the archive
npm run test:profile  # the per-turn size profile
npm run test:edge     # boundaries: thresholds, floors, odd surfaces, hostile config
npm run test:ratio    # window-ratio thresholds: precedence, fallbacks, invalid ratios
npm run test:mutate   # reintroduce eleven real defects and require the suite to catch each
npm run test:all      # npm test + mutation testing

They run fully offline: no DSH, no network, no API key, and a throwaway DSH_HOME under os.tmpdir(). ratio-tests uses a three-line fake llm, never a real DSH service.

SuiteCasesCovers
fold-tests15the fold itself: span, provenance, balance, archive-before-fold, the archive body fix, the fresh-install contract
profile-tests8the per-turn size profile
edge-tests20exact-threshold behaviour, unreachable targets, both worth-folding floors, keepRecentNodes extremes, unclosed tool pairs, empty/degenerate surfaces, a tiny digest budget, an unreachable feed, hostile and corrupt config, a hostile ctx (missing or refusing services), a second fold in one session
ratio-tests31the ratio path, the absolute override, every way the window can fail to resolve, invalid ratios, and the unreachable-target path

Mutation testing is the reason to trust the green. A passing suite proves nothing on its own — this project shipped a suite that ran zero assertions for weeks while printing 13 passed, 0 failed. npm run test:mutate reintroduces eleven defects that have really existed or could plausibly exist (the dropped tool-result body, live-by-default settings, a removed mid-turn guard, an ignored worth-folding floor, a strict trigger comparison, a dropped tool-pairing balance requirement, an inverted absolute/ratio precedence, a merged-config key check that would let DEFAULTS always win, an accepted invalid ratio, an unvalidated context window, a removed registration guard that would let a hostile ctx throw out of apply()) and fails unless the suite goes red for every one. Current result: 11 / 11.

For contributors: test() must await fn(), in every suite. A synchronous harness reports ok before any async assertion runs and lets the script reach process.exit(0) first, which silently turns the whole suite into a no-op. See CHANGELOG.md for the full story and how it was detected.

Verified end to end

Beyond the unit suites, the fold has been run end to end against a real 1,210-turn session (surface 213,919 tokens): 15 consecutive folds, each verifying that every folded message body appears verbatim in the archive (456/456 bodies, 299 tool results, in a 608-message fold), plus a second fold in the same session, a reload after a DSH restart, and a recall test where the answer was a random string planted so that it existed nowhere on disk — the model found it by searching the archive, and said plainly that it was not in its visible context.

Two limits worth knowing:

  • The in-context index is not a summary you can rely on for detail. It was 29,937 characters in that test, and the model did not locate a specific string inside it. Actual recall comes from the archive plus the notice telling the model to search it. The index tells the model what exists, not what it said.
  • Anything that makes DSH's token meter throw disables folding silently. The plugin logs no-meter and skips the turn (fail open, nothing is lost), and the official compactor depends on the same meter. A session-sidecar pruner that emitted placeholder blocks was observed to cause exactly this; it is off by default and unrelated to the live path.

Limitations

  • Tuned and measured against DeepSeek Harness on Windows with a 1,000,000-token context window. The mechanism is adapter-agnostic; the shipped absolute fallbacks are not — which is what the ratios are for. When the window resolves, the thresholds scale with it.
  • Whether ctx.llm is really reachable from this plugin's context has not been confirmed against a live DSH. The lookup mirrors resolveMeter() (direct property, then ctx.reflect.get, then the agent's own composed context) and never throws, but if no path works in your build the plugin logs triggerSource: "default" and behaves exactly as it did before this feature existed.
  • Token counts come from DSH's tokenMeter. If it is unavailable the plugin does nothing — by design.
  • Folding reduces what the model sees. The archive keeps it recoverable, but reading it back costs a rg round-trip.
  • It does not (yet) coordinate with dsh-compaction-basic; it simply stays below its trigger.

Three small DSH plugins, each aimed at one part of the same problem — an agent that has to keep working past the point where a single context window runs out, without forgetting what happened:

  • dsh-session-vacuum — deletes the dead streaming fragments out of session logs at startup, so loading a long session stops expanding into gigabytes. (Different layer from this plugin: that one keeps the log loadable, this one keeps each request workable.)
  • dsh-proactive — wakes the agent when a long background task finishes after the turn has ended, so it reports back on its own.

License

MIT — see LICENSE.

中文说明见 README.zh-CN.md。


If this made a session usable again, a ★ is what lets the next person find it. Discovery here is ranked by stars — the plugin directories and GitHub's own search both sort by them — so a reader who was helped is the only thing that moves that number. Bug reports and "this did not work on my build" are just as valuable: they are the only reason the compatibility section above is worth anything.

Plugin correlati