Bundle
@hjdd14/dsh-toolong-warning
DSH plugin: counts completed compactions per session and raises a floating reminder — with bilingual UI — only after repeated compactions plus heavy extra token spend.
- Source
- Hjdd14
- stars
- 1 stars
- License
- MIT
- Updated
- Updated 3 days ago
Readme
# dsh-toolong-warning
**English** | [中文](README.zh.md)
[](LICENSE)
[](#requirements)
[](https://nodejs.org)
A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (dsh) plugin that warns when a
conversation has become **too long to be worth continuing** — and stays quiet otherwise.
It counts how many times each conversation has been **compacted**, shows that count in a floating
window at all times, and adds a warning only when compaction has happened **repeatedly** *and* the
conversation has since burned **a lot of extra tokens**.
> **The point of this plugin is what it does *not* say.** A reminder that cries wolf gets ignored,
> so every rule below is written to prefer silence whenever the data to justify a warning is missing.
<p align="center">
<img src="docs/overlay.png" alt="The floating reminder window" width="480">
</p>
## Table of contents
- [What you see](#what-you-see)
- [When does it warn?](#when-does-it-warn)
- [Install](#install)
- [Settings](#settings)
- [How it works](#how-it-works)
- [Diagnostics](#diagnostics)
- [Requirements](#requirements)
- [License](#license)
## What you see
A self-owned floating window in the top-right corner. It uses the `shell.overlay` seat and falls back
to its own fixed container, so it never competes with another plugin for a slot:
```
┌──────────────────────────────┐
│ Long-conversation reminder – │
│ This conversation compacted │
│ 3 time(s) │
└──────────────────────────────┘
┌──────────────────────────────┐
│ ⚠ This conversation is │
│ getting long — consider │
│ starting a new one │
│ context occupancy has │
│ reached 62%; about 310k │
│ tokens were spent since the │
│ last compaction │
│ Compacted 3 time(s) so far │
│ (threshold 3); current │
│ context occupancy 62% │
│ [Got it] [Adjust thresholds]│
└──────────────────────────────┘
```
- The counter is **always** shown; the warning card appears only when the rule is met.
- **Got it** hides the current reminder only — the counter keeps updating.
- **Adjust thresholds** jumps to this plugin's section on the Settings page.
- The window is **draggable**: press anywhere on the card and move it, so it does not have to sit over
the page's own top-right controls. Where you leave it is remembered in this browser, it is kept
inside the viewport (and pulled back after a resize), and the Settings section offers a
**reset** row once it has been moved.
- Nothing is shown when the plugin is disabled or no conversation is open.
- The whole UI is **bilingual** (中文 / English), switchable from the plugin's own settings section —
see [Settings](#settings).
## When does it warn?
All three conditions must hold. If any input is missing, the plugin stays silent.
| # | Condition | Default |
|---|---|---|
| 1 | **Completed compactions** — only a `compaction/start` paired with a `compaction/end` that carries no `error`. Failed compactions, in-progress compactions, and pure pruning (`compaction/prune`) never count. | ≥ 3 |
| 2 | **Extra spend since the last compaction** — billed tokens (`uncachedInput` + `cacheRead` + `cacheWrite` + `output`), accumulated from the usage samples the session log carries. | ≥ 200,000 |
| 3 | **Either** context occupancy (the next request's expected prompt ÷ the model's context window) **or** double the spend threshold — the second is the fallback for routes that declare no context window, which is why it is twice as hard to reach. | ≥ 50% **or** ≥ 400,000 |
### Cases where it deliberately stays silent
| Situation | Result |
|---|---|
| A very long, very expensive conversation that has **never** been compacted | silent — only the counter shows (`0`) |
| Two compactions only (default threshold is 3) | silent |
| Three compactions, but the window is roomy and nothing extra was spent | silent |
| Three compactions, 250k extra spend, low occupancy — past the threshold but not past the doubled one | silent |
| No usage data at all (even at 95% occupancy) | silent |
| The route declares no context window and the extra spend is low | silent |
A documented example: a session with 2,100+ events, ~87M tokens spent and ~60% occupancy reported no
warning at all, because it had never been compacted (`gate: "compactions"`).
## Install
### Requirements
- dsh `>= 0.1.7-rc.1` (`@deepseek-ai/dsh`)
- Node `>= 22.19`
- A profile with a browser client: `web`, or `desktop` for the Electron app (this plugin
has a browser half; it is a no-op on surfaces without one)
### 1. From npm
```powershell
dsh plugin --profile web add @hjdd14/dsh-toolong-warning
```
### 2. From the Git repository
```powershell
dsh plugin --profile web add "git+https://github.com/Hjdd14/dsh-toolong-warning.git"
```
This plugin ships **hand-written JavaScript with no build step**, so pnpm has no
`prepare`/`postinstall` script to approve and the install does not stop for a build prompt. If a dsh
version reports a pending build script, add the key it prints under `allowBuilds` in
`<DSH_HOME>/profiles/web/pnpm-workspace.yaml` and re-run.
### 3. From a clone (development)
```powershell
git clone https://github.com/Hjdd14/dsh-toolong-warning.git
dsh plugin --profile web add "link:<absolute path to the clone>"
```
Editing `client.js` reloads the plugin in the browser within ~500 ms. Editing host-side files
(`index.js`, `src/*.js`) requires a new `dsh web` process — see [Diagnostics](#diagnostics).
### 4. By editing the profile by hand (when the CLI is unavailable)
In `<DSH_HOME>/profiles/web/package.json`:
```json
{
"dsh": {
"profile": {
"bundles": ["...", "@hjdd14/dsh-toolong-warning"]
}
},
"dependencies": {
"@hjdd14/dsh-toolong-warning": "^0.3.0"
}
}
```
Then run `pnpm install` inside `<DSH_HOME>/profiles/web` and restart `dsh web`.
### 5. Desktop (the Electron app)
The DSH Desktop app is another surface over the same installation, with its own profile
(`desktop`) and the same Web UI served from the app's own origin. It consumes client
plugins that declare `platform: "web"` — which is exactly what this package declares —
so **nothing about the manifest changes**; the package is simply installed into the
desktop profile:
1. Open the app and add `@hjdd14/dsh-toolong-warning` from its **Plugins** page.
2. Or by hand: add the package to `<DSH_HOME>/profiles/desktop/package.json` under both
`dsh.profile.bundles` and `dependencies`, run `pnpm install` in that directory, then
restart the app.
`dsh plugin --profile desktop add …` is refused on purpose: an application-owned profile
is written through the app's own in-process plugin manager rather than the CLI. The
floating window knows the shell owns the window chrome — see
[Desktop placement](#desktop-placement).
### Install a prebuilt tarball
```powershell
npm pack # produces hjdd14-dsh-toolong-warning-0.3.0.tgz
dsh plugin --profile web add "file:<path to the tgz>"
```
### Uninstall
```powershell
dsh plugin --profile web remove @hjdd14/dsh-toolong-warning
```
Or disable it from the dsh web sidebar's **Plugins** page.
## Settings
The plugin's section on the Settings page holds the language switch and every threshold. Values are
written into the profile's cordis patch and take effect immediately.
| Field | Default | Range | Meaning |
|---|---|---|---|
| `enabled` | `true` | toggle | When off, no state route is registered and no window appears |
| `compactCountMin` | `3` | 1–20 | Completed compactions required before a warning is possible |
| `occupancyPercentMin` | `50` | 10–95 | Context occupancy above which the conversation counts as expensive |
| `tokensSinceCompactionMin` | `200000` | 10000–5000000 | Billed tokens spent after the last compaction before it counts as heavy waste; at **twice** this value the warning fires even without an occupancy figure |
| `statsIntervalMs` | `15000` | 5000–120000 | How often the window refreshes (polling pauses while the page is hidden) |
| `dismissible` | `true` | toggle | Whether **Got it** may hide the current reminder |
| `historyReadTtlMs` | `30000` | 5000–600000 | How long a history read for a session the host has not loaded is reused |
| `historyReadTimeoutMs` | `5000` | 1000–30000 | Time limit for one history read; on timeout the window reports that the session cannot be read yet |
The lower part of the section shows the **current session's measured values** (compactions, billed
tokens, spend since the last compaction, occupancy) and *why* the reminder is or is not firing —
which is what calibrating the thresholds needs.
### Language
A `中文 / English` selector in the same section. The choice is stored in the browser, applies to the
floating window and the settings section immediately (no reload), and is also pushed to the harness'
shared locale service when that service is writable — so on most deployments the rest of the UI
follows too. The global language control DSH itself ships lives in **Settings → General**.
### Window position
Drag the floating window anywhere; the position is stored in this browser, next to the language
choice and the threshold override, and is deliberately **not** a profile setting — it describes one
person's screen, not the conversation. It stays inside the viewport (a moved window is pulled back
after a resize or when the warning card makes it taller), and a **Reset window position** row appears
in this section as soon as the window has been moved.
### Desktop placement
A desktop shell owns the window chrome, and the floating window is aware of it:
- **The shell's reserved band is never usable space.** The window's default top and the smallest top
it can be dragged to both come from the frame's own `--dsh-frame-overlay-top` variable, minus
whatever the shell has already reserved by moving its content viewport down. A shell that layers
its chrome over the content and one that moves the content below the chrome therefore each get the
right answer, and neither is compensated twice. On a plain browser page no such variable is
published, so every value stays what it was.
- **The space it is clamped against is measured, not assumed.** A shell whose content viewport starts
below its command bar would otherwise both mis-clamp the window and displace it a little on every
drag; an inert probe spanning the containing block measures it exactly. On a browser page the probe
measures the viewport itself, so the Web behaviour is unchanged.
- **It stays out of the shell's app-region computation**, so the window's own drag strips keep working
(macOS and Windows Electron both mark the document; a plain page never does).
One consequence worth knowing: browser storage is per origin, so the desktop app and a page at
`http://127.0.0.1:3080` keep **separate** language choices, window positions and browser-local
threshold overrides. That was already true before this adaptation and is not changed by it.
### Two scopes of edit — and why
DSH fixes settings writes to read-only on a **non-loopback** page
(`persistence = ctx.remote.$host.isLoopback ? 'host' : 'memory'`), and that applies to *every*
plugin's configuration form, not just this one. Rather than showing dead controls, this plugin
degrades honestly:
| Page opened at | The three decision thresholds | `enabled` / `dismissible` |
|---|---|---|
| `http://127.0.0.1:3080` or `http://localhost:3080` (loopback) | written to the **profile configuration** — applies to every session | editable, written to the profile |
| anything else (IP, hostname, tunnel) | still editable, stored in **this browser**, applied to this page only | read-only (they decide whether the host registers the route) |
Browser-local values ride each state request as query parameters, are re-validated and **clamped by
the host on every request**, and are never persisted. Out-of-range or non-integer values are ignored
and reported back. The decision always happens on the host, so a page cannot force a warning.
## How it works
```
browser (client.js) host (index.js + src/*)
──────────────────── ─────────────────────────
floating window ctx.on('session/event') ── incremental fold (live)
├─ session catalog → main-view session └─ first ask seeds that session's whole log
├─ poll /api/.../state every 15 s ctx.sessionProjections
└─ counter line + warning card └─ tokenUsage / contextPressure
settings section ctx.sessionQuery.readSession(id) ── history (cold)
├─ language switch └─ sessions the host has not loaded
└─ ctx.configForms('toolong-warning') two loopback-only, read-only routes
8 volatile fields /api/dsh-toolong-warning/state (per session)
/api/dsh-toolong-warning/health (probes)
```
### Three data sources, degrading in order
| # | Condition | Response | Notes |
|---|---|---|---|
| 1 | The session is **live** in this process | `source: "live"` | Full live metrics, including occupancy from the meter |
| 2 | Not loaded, but its log is readable | `source: "history"` | Count and spend folded from the log — this is why switching conversations shows a number without a page reload |
| 3 | Neither | `known: false` + `reason` | `session-not-loaded` or `history-unavailable`; **never warns** |
- The browser cannot see a session's event log (the client side only has the session catalog), so
every measurement happens host-side and is served over a same-origin route.
- Both sources share the same folding and decision functions, so they must agree; `?selftest=1` makes
the running process prove that on real sessions.
- The history source uses the supported read-only service and never reads private storage paths
(`$DSH_HOME/storages/**`, session log files). It never inserts a session into the live tracker, is
cached with a TTL, and is bounded by a timeout.
- The browser half is plain JavaScript registered through `dsh.client` + `exports["./client"]`; React
comes from the shell's module table. It is deliberately self-contained: DSH serves client bundles
from an exact combo URL out of an in-memory response map, so a sibling module can never be fetched
and the dictionaries are inlined.
- The host half imports no `@deepseek-ai/*` runtime package except `@deepseek-ai/schemastery`, which
the Settings form requires to be a real schema; it is loaded from an explicit candidate path and
degrades to a structurally equivalent descriptor when unavailable.
## Diagnostics
Both routes are **loopback-only** (socket address *and* `Host` header *and* same-origin markers) and
always send `cache-control: no-store`.
```powershell
# Is it loaded, which generation is running, what are the effective thresholds?
curl.exe -s "http://127.0.0.1:3080/api/dsh-toolong-warning/health"
# Drive the real routes in the running process and compare against expected values.
# Runs two self-checks: a synthetic session through the whole decision chain, and a
# live-vs-history count comparison on real sessions.
curl.exe -s "http://127.0.0.1:3080/api/dsh-toolong-warning/health?selftest=1"
# One session's decision and raw numbers. debug=1 adds event-type statistics and the
# compaction lifecycle — never message content.
curl.exe -s "http://127.0.0.1:3080/api/dsh-toolong-warning/state?sessionId=<session id>&debug=1"
```
`state` fields: `known`, `source` (`live` / `history`), `count`, `lastCompactionSeq`,
`tokensSinceCompaction`, `billedTokens`, `occupancyRatio` / `projectedTokens` / `contextWindow`,
`shouldWarn`, `reasons`, `gate`, `thresholds`, `thresholdsSource`, `configVersion`, `sampledEvents`,
`droppedEvents`, `coldReads`.
**`moduleGeneration` is load-bearing.** Host-side changes only load in a new `dsh web` process — dsh
hot-reloads client bundles only. The generation counter exists so the running process can report
which code it is serving, rather than that being a matter of inference.
## Requirements
| | |
|---|---|
| dsh | `>= 0.1.7-rc.1` |
| Surface | the `web` profile, and the Desktop app's `desktop` profile (the browser half is a no-op elsewhere) |
| Optional | `@deepseek-ai/dsh-session-query` — enables the history source. Without it, sessions the host has not loaded simply stay unmeasured |
| Host dependency | `@deepseek-ai/schemastery` `~3.18.4` for the Settings form. The bare `schemastery` reachable from a profile is 3.18.0 and has **no** `.volatile()`, which silently makes the form unavailable |
## License
[MIT](LICENSE) © 2026 Hjdd14
Install
dsh plugin --profile web add github:Hjdd14/dsh-toolong-warning
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install hjdd14-dsh-toolong-warning from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.