DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

mili1360 /

mili1360/dsh-history-fold

Verified

你的助手记得昨天——直到上下文撑不住。DSH 自带压实写摘要(有损),这个插件保留全文:折叠的消息原样归档到本地,替换它的是真实前缀构成的提取式索引,不加一句模型生成的话。笔记还能从会话日志重建。Persistent memory for DeepSeek Harness: fold old history into one extractive index backed by a verbatim local archive. Nothing summarised, nothing invented, and the notes can be rebuilt from the session logs.

★ 2 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@7fdcda78

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:

Metric Measured
Turns that genuinely needed the archive, correctly let through 51/87 = 59%
Turns that did not need it, wrongly triggered 43/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:

Configuration Injections Share of turns
0.7 strict 670 19.5%
0.8, no classifier 2,377 69.3%
0.8 + classifier (current) 289 8.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:

Before After
End-to-end recall (10 questions) 3/10 9/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 turns Rule alone With the classifier
Turns that genuinely need the archive, admitted 49/87 (56%) 51/87 (59%)
Turns that do not, falsely triggered 126/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:

Route Fires Fire rate Tokens injected
0.7 behaviour (strict) 670 19.5% ≈709k
0.8 rules, no classifier 2,377 69.3% ≈2.59M
0.8 + classifier 289 8.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 session a fresh one
history sitting in the window 497,141 tokens a few thousand
what the built-in compactor leaves behind a summary, written by a model —
can you still quote the original line? no yes

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):

folded 614 messages / 184,709 tokens / 86.3%
marker that enters context 30,232 characters (~7.5k tokens) — about 24:1
archive written 638 KB
user-authored text preserved verbatim
assistant text preserved verbatim (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 changed 0.1.x 0.2.0 if you get it wrong
event accessor session.events, entries carry seq no 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 head no system message in the surface node 0 is the system prompt and is protected any 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 plugin the 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.

Key Default Meaning
enabled true Master switch. false ⇒ loaded but completely idle.
dryRun false true ⇒ observe and log only, never write.
profile true Write one size/profile line per turn.
triggerSurfaceTokens 250000 Absolute 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.
targetSurfaceTokens 180000 Absolute target, same fallback role. Must be reachable — see below.
triggerRatio 0.33 Fold 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.
targetRatio 0.14 Fold down to about this fraction of the window — below the compactor's own 0.16 retain ratio.
emergencyTriggerRatio 0.42 Second 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.
emergencyTriggerSurfaceTokens 320000 Absolute form of the second chance, used when the window cannot be resolved. Never allowed below the normal trigger.
emergencyKeepRecentNodes 12 How much recent history the retry is still allowed to protect (the smaller of this and keepRecentNodes wins).
emergencyMinFoldNodes 4 Retry floor on node count.
emergencyMinFoldTokens 2000 Retry floor on folded tokens.
keepRecentNodes 60 Never fold the most recent this-many nodes.
minFoldNodes 20 Refuse to fold fewer nodes than this.
minFoldTokens 20000 Refuse to fold fewer tokens than this.
archive true Write the lossless pre-fold archive. Leave this on.
archiveDir history-fold/archive Relative to $DSH_HOME.
digestMaxChars 30000 Cap on the in-context index. Over budget ⇒ sample, never truncate.
digestLineChars 160 Characters of each sampled message shown in the index.
indexTree true Write the tier index and name its path in context. Off ⇒ the old sample-only behaviour.
indexDir history-fold/index Tier index directory, relative to $DSH_HOME.
indexFull true Write the unsampled index to disk. Off ⇒ collect pointers but write no new file.
onlyAtTurnStart true Fold only before a turn's first step, never mid-turn.
logFile logs/history-fold.jsonl Relative to $DSH_HOME.
memoryFeed false Post the same digest to a memory service after each fold.
memoryFeedUrl — e.g. http://127.0.0.1:7999/memory-eternal/api.
notes.enabled true Mine the session log into notes.dir on a timer. Off ⇒ no mining at all.
notes.dir history-fold/notes Where the markdown lands. Relative to $DSH_HOME unless absolute.
notes.refreshMinutes 10 How often the log's mtime/size is checked. A pass runs only when it changed.
notes.events true Also write events-YYYY-MM.md (the durable-statement ledger).
notes.minEventScore 3 Strictness of the ledger. Raise to 4 for fewer, cleaner lines.
notes.startDelayMs 45000 First pass waits this long so boot is never slowed.
notes.maxLogMB 600 Safety valve: a bigger log is skipped rather than read into the heap.
recall.strict false true ⇒ 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.fallback true false ⇒ a strict-mode miss injects nothing (old behaviour).
recall.fallbackMinChars 12 A message shorter than this only falls back when it carries recall intent (see recall.gateThreshold).
recall.gateThreshold 0.45 Confidence 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.titleWeight 2 Multiplier when a matched term also appears in a ## heading.
recall.entityMapFile dsh-history-fold/entity-map.json JSON 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.rareMaxDf 2 A rare identifier must appear in at most this many note files to be used as a locator.
recall.entityMapAutoFile dsh-history-fold/entity-map.auto.json The auto-built table. Rebuilt from the notes, never hand-edited.
recall.autoMapMinutes 30 Rebuild the auto table when it is older than this.
recall.autoMapMaxDf 4 The auto table may be broader than the live scan, because a table hit is exact by construction.
recall.autoMapLimit 600 Cap 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 like what it meant
750000 "of a 1M window", i.e. 0.75 1.31M real tokens — above the window itself
trigger 750000 vs. compactor at 800000 unreachable: the compactor always fired first and rewrote the history into a model-written summary
the log idle, "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-basic this plugin UI-fold plugins
What replaces the history a model-written summary an extractive index + an archive pointer nothing — the request is untouched
Can you still quote the original no — the summary is all that survives yes, verbatim, in $DSH_HOME/history-fold/archive/ n/a
Is anything invented yes, the model writes new prose no, the index lines are the messages' own prefixes, and no model is called n/a
Recoverable no yes, one rg over the archive n/a
What it changes the surface (what the model is sent) the surface (what the model is sent) the display (long messages collapsed in the UI)
Trigger thresholdRatio × contextWindow, 800k by default triggerSurfaceTokens (or triggerRatio × contextWindow), calibrated against the real prompt size — 300000 on a Chinese-heavy 1M session; an absolute triggerSurfaceTokens still overrides the ratio n/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:

mark Meaning
profile Per-turn size/token breakdown, plus what each candidate keepRecent would save.
profile-error Profiling itself failed.
idle Below the trigger; nothing to do.
would-fold dryRun — this is what would have been folded.
folding / folded A real fold started / committed. Both carry surfaceTokens, foldTokens, startSeq, endSeq.
no-safe-cut No balanced cut reaches the target, or the minimums were not met.
no-meter The token meter was unreachable, so it refused to fold blind.
threshold-warning The resolved thresholds cannot fold anything usefully — see below. Written once per distinct resolved pair, never once per turn.
archive-failed The archive write failed, so the fold was abandoned.
error Anything else. The hook always fails open — a turn is never blocked.
fed The 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:

Field Meaning
trigger / target The two thresholds actually used this turn, in tokens.
windowTokens The resolved context window, or null when it could not be established.
triggerSource / targetSource absolute (explicit in config.json), ratio (floor(window × ratio)), or default (the shipped absolute fallback).
triggerRatioInvalid / targetRatioInvalid Present and true only when the configured ratio was unusable and rule 3 was applied instead.
warnings On 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.
via Which 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.

Suite Cases Covers
fold-tests 15 the fold itself: span, provenance, balance, archive-before-fold, the archive body fix, the fresh-install contract
profile-tests 8 the per-turn size profile
edge-tests 20 exact-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-tests 31 the 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.

Related plugins

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.


Models: what ships, and what is optional

Two different things called "model" here — do not confuse them.

what it is ships with the repo? needs a service?
model/gate-model.json 659-example classifier (logistic regression over char n-grams) that vetoes noisy recalls. 1.3 MB of plain JSON. Yes — committed in the repo No. Pure JS, zero deps, sub-ms inference.
bge-small-zh (semantic recall) Optional semantic search over notes/archive. Talks HTTP to an OpenAI-compatible /v1/embeddings endpoint. No Yes — you need an embedding server (e.g. llama.cpp with --embeddings, or any OpenAI-compatible one) on http://127.0.0.1:8021/v1/embeddings (configurable via recall.semantic.endpoint).

If you do not run an embedding server

Nothing breaks. Keyword recall (TF-IDF note scoring) keeps working; semantic recall and the rare-term route that depends on it are skipped. Missing model = unchanged behaviour is the rule everywhere.

Getting bge-small-zh

Any server that answers POST /v1/embeddings with {"model": "...", "input": [...]} and returns data[i].embedding works. A compact path on a CPU-only box:

`ash

llama.cpp server with an embedding-capable GGUF (e.g. bge-small-zh-v1.5)

llama-server -m bge-small-zh-v1.5-Q8_0.gguf --embeddings --port 8021 `

Then set the endpoint if it is not the default:

json { "recall": { "semantic": { "endpoint": "http://127.0.0.1:8021/v1/embeddings" } } }

The vector cache keys include the model name, so switching models never silently mixes vectors.

—/ 5

No ratings yet

Verified DSH bundle

Commit 7fdcda783250

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout