dsh-side-branch 0.1.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +103 -13
- package/README.md +92 -55
- package/docs/AGENTS.en.md +106 -14
- package/docs/CHANGELOG.md +82 -0
- package/docs/README.zh-CN.md +90 -53
- package/docs/TECHNICAL.md +105 -10
- package/docs/TECHNICAL.zh-CN.md +105 -10
- package/lib/client.js +618 -64
- package/lib/index.js +1041 -90
- package/package.json +10 -4
package/docs/TECHNICAL.md
CHANGED
|
@@ -10,7 +10,7 @@ Typical use: ask a follow-up question, look up a term, or explore an idea *along
|
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| **Package name** (implementation) | `dsh-side-branch` |
|
|
12
12
|
| **UI name** (what you see) | **Side Ask** · 中文「临时会话」 |
|
|
13
|
-
| **Verified DSH version** | `0.1.5-rc.2
|
|
13
|
+
| **Verified DSH version** | `0.1.5-rc.2`, `0.1.7-rc.2`, `0.2.0-rc.2` and `0.2.1-rc.3` (see "Archived sessions" below) |
|
|
14
14
|
| **Prerequisites** | Node.js ≥ 20 and `pnpm` on your `PATH` |
|
|
15
15
|
| **Runtime dependencies** | none — no build step, no third-party packages |
|
|
16
16
|
| **License** | MIT |
|
|
@@ -40,14 +40,14 @@ The closest alternatives each traded something away. For example:
|
|
|
40
40
|
- [`dsh-sidenote`](https://github.com/g-yixuan/dsh-sidenote) is a full-featured side chat with one-click "reflow into the main session"; by design its conclusions are meant to be written back into the main conversation.
|
|
41
41
|
- [`@lukeknow0/dsh-side-chat`](https://github.com/Lukeknow0/dsh-side-chat) has an excellent architecture and a comparable read-only model, but its README states it is an MVP.
|
|
42
42
|
|
|
43
|
-
The
|
|
43
|
+
The obvious way to get read-only is to work at the *prompt layer* — remove the tools from the prompt (`toolFilter`) or restrict them (`tools.restrict`). Functionally that does produce read-only. But it also makes the branch's prompt prefix diverge from the main session's, **which invalidates every prefix-cache entry the parent session has accumulated**; the whole context is then recomputed on every turn.
|
|
44
44
|
|
|
45
45
|
This plugin enforces read-only in the **execution layer** instead (`ctx.tools.guard`, the public `dsh-tools` contract). The tools stay in the prompt; the model may attempt to call them, but execution is denied — so the prompt is byte-identical to the main session and the cache keeps hitting (measured at 97%–98%).
|
|
46
46
|
|
|
47
47
|
## ✨ Core features
|
|
48
48
|
|
|
49
49
|
- **True branch, zero pollution.** The host half never writes anything back to the parent session. Whatever you ask — and whatever the branch answers — never appears in the main session's model context.
|
|
50
|
-
- **Prefix-cache hits stay intact.** The branch seeds itself from the main session's *completed-turn* prefix. Reusing the main session's model keeps hitting the existing prefix cache instead of rebuilding the prompt.
|
|
50
|
+
- **Prefix-cache hits stay intact.** The branch seeds itself from the main session's *completed-turn* prefix (re-taken on every follow-up — see "Re-fork" below). Reusing the main session's model keeps hitting the existing prefix cache instead of rebuilding the prompt.
|
|
51
51
|
- **Read-only at the execution layer.** The security boundary does not depend on the prompt. `ctx.tools.guard` allows exactly six side-effect-free query tools (`read`, `glob`, `grep`, `web_search`, `web_fetch`, `lsp`); everything else — writing files, running commands, spawning child sessions — is denied at execution time.
|
|
52
52
|
- **Streaming that survives the tab.** Body text and reasoning stream token by token (reasoning lives in a collapsible "Thinking" row). Closing the browser tab does not interrupt the answer in the background; reopen and the result is there.
|
|
53
53
|
- **Persistent panel state.** Panel state is kept locally per *session + panel pane*. Idle side sessions go to sleep, release their live instance, keep their record, and wake silently on the next question.
|
|
@@ -109,7 +109,7 @@ Install the DSH plugin dsh-side-branch into my web profile:
|
|
|
109
109
|
|
|
110
110
|
## 🚀 Usage
|
|
111
111
|
|
|
112
|
-
Select text in the main session's body: an ask entry appears above the selection. Click it, the right-hand panel opens, type your question and send. The selected text travels with the question as a quote.
|
|
112
|
+
Select text in the main session's body: an ask entry appears above the selection. It appears **only** there — not in the right-hand panels (file preview, settings, other plugins' panes) and not in an embedded sub-agent conversation hosted there, because those are not the main session's content. Click it, the right-hand panel opens, type your question and send. The selected text travels with the question as a quote.
|
|
113
113
|
|
|
114
114
|
There is a second entry point: a button on the right of the main session's composer opens the panel directly (it can be turned off in settings).
|
|
115
115
|
|
|
@@ -126,11 +126,11 @@ Closing the browser tab does not interrupt an in-flight answer; come back to the
|
|
|
126
126
|
|
|
127
127
|
## 🧭 Behaviour notes
|
|
128
128
|
|
|
129
|
-
**The branch is a real branch.** At birth it reads the main session's completed-turn prefix as a seed, and inherits the main session's agent preset, tools and persona. The system prompt this branch sees is byte-identical to the main session's.
|
|
129
|
+
**The branch is a real branch.** At birth it reads the main session's completed-turn prefix as a seed, and inherits the main session's agent preset, tools and persona. The system prompt this branch sees is byte-identical to the main session's. ★ **Every follow-up rebuilds it from the main session's *latest* settled prefix** while keeping the branch's own questions and answers (see "Re-fork" below).
|
|
130
130
|
|
|
131
131
|
**Answers never flow back.** The host half never writes anything to the parent session. Nothing you ask, and nothing it answers, appears in the main session's model context.
|
|
132
132
|
|
|
133
|
-
**Prefix-cache hits continue.** Because the prompt prefix matches the main session's word for word, reusing the main session's model keeps hitting the existing prefix cache instead of recomputing it every time. A branch on a different model is a
|
|
133
|
+
**Prefix-cache hits continue.** Because the prompt prefix matches the main session's word for word, reusing the main session's model keeps hitting the existing prefix cache instead of recomputing it every time. A branch on a **different** model is a different cache sequence on the provider side (however similar the prefix looks), so that segment is essentially billed at full price; the plugin marks that in the usage row.
|
|
134
134
|
|
|
135
135
|
**Streaming, body and reasoning.** Answers are pushed token by token; reasoning sits in a collapsible "Thinking" row. A disconnecting client does not abort the background answer.
|
|
136
136
|
|
|
@@ -150,6 +150,100 @@ The plugin's answer is a three-step cycle, all of it tolerant of failure:
|
|
|
150
150
|
|
|
151
151
|
`unarchiveSession()` only exists from DSH **`0.1.6-alpha.2`**; the call site is feature-detected, so on `0.1.5-rc.2` (no gate, no method) both extra steps are no-ops and behaviour is unchanged. This is what lets one published version serve both DSH lines.
|
|
152
152
|
|
|
153
|
+
## 🔁 Re-fork and multi-segment answers
|
|
154
|
+
|
|
155
|
+
### The rule in one line
|
|
156
|
+
|
|
157
|
+
Every follow-up sends the model:
|
|
158
|
+
|
|
159
|
+
```text
|
|
160
|
+
[ the main session's latest "settled prefix" ] ← re-taken on every rebuild
|
|
161
|
+
+ [ this branch's own questions and answers ] ← replayed in, verbatim
|
|
162
|
+
+ [ this turn's new question ]
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
The "settled prefix" is everything in the main session's event log **before the last `turn/end`** — the turns the main session has **finished and closed out**. A turn the main session is **still generating** does not enter the seed.
|
|
166
|
+
|
|
167
|
+
### When a rebuild happens (the triggers)
|
|
168
|
+
|
|
169
|
+
`shouldReFork` tests two conditions, and **either one** triggers a rebuild:
|
|
170
|
+
|
|
171
|
+
1. **the main session has settled a new turn** — the main session's current settled-prefix length is greater than the `parentCut` recorded when this segment was created;
|
|
172
|
+
2. **the model or the reasoning effort changed** — a `reasoningEffort`-only change counts too.
|
|
173
|
+
|
|
174
|
+
If neither holds (the main session has not grown and the model is unchanged) the **current segment is reused**: the seed would be byte-identical, so rebuilding would only add one more session record the platform cannot delete plus one deep copy, for exactly the same cache hits.
|
|
175
|
+
|
|
176
|
+
The rebuild is `reForkConversation`: create a fresh side session (seeded from the main session's **latest** prefix) → replay the previous segment's **own** events into it → release the previous segment only **after the replay succeeded**. If it fails, the turn is **refused explicitly** with `side-branch/refork-failed` and the **previous segment is left exactly as it was** (never a silent downgrade to "a new session with no history" — the model would think the branch had never been asked anything while the panel still shows the old answers).
|
|
177
|
+
|
|
178
|
+
Because a rebuild creates a new side session, **`conversationId` changes whenever a rebuild happens**; `POST /side-branch/start` always answers `handoff: true`, so the panel does **not** draw a "a new segment starts here" divider — the history really is continuous.
|
|
179
|
+
|
|
180
|
+
The cache trade-off: the newly added content is the part the main session **just requested itself**, so it is probably still in the provider's hot cache, and the rebuilt request prefix lines up with it exactly (a cache read); under "frozen prefix + append", that same content is sent by this branch in that shape for the **first** time (a cache write).
|
|
181
|
+
|
|
182
|
+
### The replay discipline (read this before touching the code)
|
|
183
|
+
|
|
184
|
+
Only the **5 event types that can enter the model-visible history** are `append`ed into the new session, verbatim:
|
|
185
|
+
|
|
186
|
+
```text
|
|
187
|
+
system/message developer/message user/message assistant/message tool/result
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
- **Only `event.data` is passed, verbatim.** An `assistant/message` embeds the **real provider stream** and `message.source.provider/model`; hand-building one is rejected by `dsh-session`'s shape checks.
|
|
191
|
+
- **Pure log events are never replayed** (`turn/start`, `step/start`, `tool/call`, `request/header`, …): `append`ing them throws.
|
|
192
|
+
- ⛔ **Never "helpfully" fill in `event.sourceEventSeqs`**: a `tool/result` carries `[the old session's callSeq]` at the **event level**, and copying it over trips `sourceEventSeqs must reference earlier events` (a foreign sequence number is very likely ≥ the new session's seq). Passing only `data` drops it naturally — and `append` does not need it anyway.
|
|
193
|
+
- **No truncation**: at the context limit the turn is refused; the earliest turns are never silently dropped.
|
|
194
|
+
|
|
195
|
+
### Multi-segment answers: every step of a turn is kept
|
|
196
|
+
|
|
197
|
+
A turn can contain several steps ("talk while working"), and **every step opens a new streaming attempt and emits its own `start` frame**. The branch keeps an **ordered segment array**:
|
|
198
|
+
|
|
199
|
+
```text
|
|
200
|
+
job.segments = [ { kind: 'reasoning'|'text'|'tool', text, turn, step }, … ]
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
- `start` frames are split by `turn`/`step`: **the same step with a new `attemptId`** is a **retry inside that step** (only that step's segments are dropped, and a `reset` is emitted); **a changed `turn`/`step`** means **a new segment** (a `segment` frame is emitted, and nothing old is ever deleted).
|
|
204
|
+
- `chunk` (`text-delta` / `reasoning-delta`) and `tool/call` append to the **current segment**; the terminal text from `assistant/message` **replaces only the current segment** (and only when it is non-empty, so a tool-only final step cannot leave a middle segment displayed as the final answer).
|
|
205
|
+
- The `job.text` / `job.reasoning` shortcuts are **pure functions** of the segment array (`syncSegmentShortcuts`) ⇒ they cannot drift from it, and there is no "second source of truth".
|
|
206
|
+
|
|
207
|
+
The complete SSE event and payload list:
|
|
208
|
+
|
|
209
|
+
| Event | When | Payload |
|
|
210
|
+
| --- | --- | --- |
|
|
211
|
+
| `snapshot` | once, when a client (re)connects | `{ status, text, reasoning, segments, conversationId, stats? }` |
|
|
212
|
+
| `segment` | entering the next step (a new segment) | `{ turn, step }` |
|
|
213
|
+
| `delta` | answer-text increment | `{ text, turn, step }` |
|
|
214
|
+
| `reasoning` | reasoning increment | `{ text, turn, step }` |
|
|
215
|
+
| `reset` | retry inside the same step (drops only that step) | `{ turn, step }` |
|
|
216
|
+
| `replace` | terminal text replacing **the current segment** | `{ text, turn, step }` |
|
|
217
|
+
| `tool` | an allow-listed tool was **executed** (tool name only, never the arguments) | `{ name, count, turn, step }` |
|
|
218
|
+
| `done` / `stopped` / `error` | terminal states | `{ status, text, reasoning, segments, conversationId, stats?, error? }` |
|
|
219
|
+
|
|
220
|
+
The client persists `segments` along with the round (`turn`/`step` included — they are tiny) and renders them in array order after a refresh; when **old data has no `segments`** they are synthesised from `answer`/`reasoning` (`normalizeSegments`) ⇒ an old panel does not come back empty after a refresh.
|
|
221
|
+
|
|
222
|
+
### What was inherited, and what was injected (layer 1 / layer 2)
|
|
223
|
+
|
|
224
|
+
- **Layer 1 (one row per segment)**: `/start` answers `synced: { turns, chars }` ⇒ the panel shows `Inherited N main-session turns · ~X chars`. If a branch preamble really was prepended on that turn, the response also carries `notice` (**the preamble text as injected into the model**, shown verbatim; only the first turn of a segment has it). That is the **only** text this plugin injects; the system prompt and the tool declarations are assembled by DSH and are **deliberately not shown**.
|
|
225
|
+
- **Layer 2 (expand on demand)**: `GET /side-branch/inherited?conversationId=&locale=` returns the turns before **this segment's cut point** (`conv.parentCut`) — **the last turn in full, one line per earlier turn**, all **truncated server-side with no model call** (so a "summary" is not "summarise it again"). The response carries `truncated` (whether anything was cut) and `parentHasNewer` (the main session has grown since ⇒ the next follow-up will include it).
|
|
226
|
+
- ⛔ **Layer 2's content never enters persisted panel state** (`rounds` / `normalizeRound` / `sessionStorage`): a main-session prefix can be hundreds of kilobytes, and panel state has a 5–10 MB `sessionStorage` quota ⇒ putting it in blows the quota instantly and breaks "survives a refresh". It lives only in component memory and is fetched once, on demand.
|
|
227
|
+
|
|
228
|
+
### The context limit: an estimate
|
|
229
|
+
|
|
230
|
+
After a re-fork the **new session has no usage yet** (the old `lastContextTokens` criterion is necessarily `undefined`), so the limit is now estimated:
|
|
231
|
+
|
|
232
|
+
```text
|
|
233
|
+
used ≈ the parent's last assistant/message input + cacheRead + cacheWrite (measured)
|
|
234
|
+
+ (branch-history characters + this turn's question characters) × safety factor
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Above `contextWindow × 0.8` the turn is **refused** with `contextFull: { used, limit, window }`; when the window cannot be read a fallback limit is used. ⚠️ **`used` is an estimate** (the parent-prefix half is measured, the other half is estimated from character counts), so it is normal for the number in the panel to disagree with the real usage once that turn settles. The history is **never truncated**.
|
|
238
|
+
|
|
239
|
+
### The startup orphan sweep and the instance lease
|
|
240
|
+
|
|
241
|
+
Every re-fork **adds one session record**, and the platform's session-persistence layer **has no API to delete a session** ⇒ records would pile up forever. So the plugin sweeps once in `apply()`: a `sessions/<project>/side-<uuid>/` directory on disk that is **not in this process's ledger** is an orphan left by an earlier instance.
|
|
242
|
+
|
|
243
|
+
That criterion is only safe together with the **instance lease**: the ledger **is necessarily empty at startup**, so if a **second DSH instance sharing the same `DSH_HOME`** is running at the same time, it would delete the first instance's **live** side-session directories as orphans (the first instance still has them in memory while their on-disk log is gone ⇒ that branch is simply broken). The mechanism: on startup each instance posts a lease in `<DSH_HOME>/side-branch-instances/` (`<pid>-<uuid>.json`) and refreshes its heartbeat, deleting it on unload; before sweeping, the plugin skips **the entire sweep** if it finds **any other live lease** (stale ones — expired heartbeat or dead process — are cleaned up in passing).
|
|
244
|
+
|
|
245
|
+
Three safety checks: ① the directory name must be **exactly** `side-<uuid>` (right prefix but wrong shape ⇒ **untouched**, only logged); ② it must not be in this process's ledger; ③ it must be strictly under the `sessions` root, with no path escape. Everything deleted is **logged item by item** (the platform cannot delete sessions, so the log is the only trace); a single failure is only logged and never affects startup. ⛔ The main sessions' (`session-*`) files are **never touched**.
|
|
246
|
+
|
|
153
247
|
## 🛡️ How read-only is guaranteed
|
|
154
248
|
|
|
155
249
|
Read-only is enforced by the **execution layer**, not by asking the model nicely in a prompt.
|
|
@@ -199,17 +293,18 @@ Measured: in a PTC parent session the PTC preamble applied from the first round,
|
|
|
199
293
|
|
|
200
294
|
## ⚠️ Known limitations
|
|
201
295
|
|
|
202
|
-
- **A
|
|
296
|
+
- **A turn the main session has not *settled* yet is invisible.** Every rebuild cuts at the main session's **last `turn/end`**, so a turn that is still generating (or was just sent and has not finished) is in neither the seed nor the replay. Turns the main session **has** finished do follow along automatically — that is what the re-fork does. To cite something not yet settled, use **manual selection quoting**: what is limited is "seeing it automatically", not "being able to quote it".
|
|
203
297
|
- **At most two side branches at once.** This comes from DSH's sidebar pane limit (one instance per tab type per pane, at most two panes), not from this plugin — but you will run into it.
|
|
204
298
|
- **The six allowed tools' own behaviour is not constrained by read-only.** The guard blocks *tools outside the list*; it does not restrict these six tools' network access, process spawning or file reading. See "Read-only is not harmless" above.
|
|
205
|
-
- **"Clear" is irreversible for the panel history.** After clearing, that branch is no longer visible in the panel and cannot be brought back. The archived side-session record is still on disk, but the official API cannot delete it and it never appears in any session list.
|
|
299
|
+
- **"Clear" is irreversible for the panel history.** After clearing, that branch is no longer visible in the panel and cannot be brought back. The archived side-session record is still on disk, but the official API cannot delete it and it never appears in any session list. Note also that a re-fork **adds one session record per rebuild**, and the platform **has no API to delete a session** ⇒ those records are reclaimed by the plugin's **startup orphan sweep** (see above); while **two instances share one `DSH_HOME`** and run at the same time, that sweep **skips entirely**.
|
|
206
300
|
- Settings live in browser `localStorage`: switching browsers or clearing site data resets them. Currently there is only one setting (the main-session quick-entry toggle).
|
|
207
301
|
- The tool allow-list is not configurable. Narrowing it means editing code; there is no per-tool switch.
|
|
208
302
|
- After a plugin reload, the table of "sleeping" sessions is gone with memory, and old session ids report that a new branch was started (the client creates one automatically, so nothing hangs). **Note the two paths**: going idle past the threshold only puts the side session to *sleep* — the record stays and the next question **wakes it silently**, without even a divider; only a **plugin reload** loses the record and starts a new branch. Your question is still answered normally and you do not have to retype it — that branch's history just no longer connects.
|
|
209
|
-
- Each branch has a context ceiling. On reaching it
|
|
303
|
+
- Each branch has a context ceiling. On reaching it the plugin **refuses to send** and returns `contextFull: { used, limit, window }`, instead of silently dropping the oldest turns. ⚠️ That `used` is an **estimate** (after a re-fork the new session has no usage yet), not a measured usage.
|
|
304
|
+
- **The cache hit rate is measured, not guaranteed.** The platform may **replace the system node in place** on the side session's first step (node 0) — the official wording is *"A prompt change that replaces a system node in place makes the request differ from that node's first token — in full when the node is node 0"* ⇒ if that happens, **everything from token 0 on is billed at full price**. A mismatched tool set also forces a new sequence. So there is no "always hits": 97%–98% is a measurement, not a promise. And **hitting the cache does not mean the tokens stop occupying the context window**: the `inputTokens + cacheRead + cacheWrite` total counts the cached tokens just the same.
|
|
210
305
|
- With a `ptc` main session the branch **cannot use a single tool** (the only visible entry, `run_code`, is always denied). A custom preset saved from the PTC template also defeats the opening-time detection, wasting the first round.
|
|
211
306
|
- After being denied by the guard, the model often **retries the same tool**, wasting a round. This is independent of PTC mode and was observed in both.
|
|
212
|
-
- Verified on DSH `0.1.5-rc.2` (before the archive gate) and `0.1.7-rc.2` (with it). `0.1.6-alpha.2` was checked by reading its published packages only — it already has `unarchiveSession` and does **not** yet have `ArchivedSessionGate`, so the feature-detected path keeps it working — but no end-to-end run was done there.
|
|
307
|
+
- Verified on DSH `0.1.5-rc.2` (before the archive gate) and `0.1.7-rc.2` (with it). `0.1.6-alpha.2` was checked by reading its published packages only — it already has `unarchiveSession` and does **not** yet have `ArchivedSessionGate`, so the feature-detected path keeps it working — but no end-to-end run was done there. Also verified end-to-end on `0.2.0-rc.2`: the plugin loads, the `/side-branch` routes register, and the panel was exercised by hand.
|
|
213
308
|
- The MCP tool conclusion comes from reading the code path, not from a live test (`run_code` was tested live).
|
|
214
309
|
- The effect of the branch preamble on model behaviour has not been quantified. It asks the model to say "this needs the main session" when it would have to read a file to answer; whether that makes the model overly conservative needs human judgement.
|
|
215
310
|
|
package/docs/TECHNICAL.zh-CN.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
| --- | --- |
|
|
11
11
|
| **包名**(实现) | `dsh-side-branch` |
|
|
12
12
|
| **界面显示名** | **临时会话**(英文界面 `Side Ask`) |
|
|
13
|
-
| **已验证的 DSH 版本** | `0.1.5-rc.2` 与 `0.1
|
|
13
|
+
| **已验证的 DSH 版本** | `0.1.5-rc.2`、`0.1.7-rc.2`、`0.2.0-rc.2` 与 `0.2.1-rc.3`(见下文「归档与解锁」) |
|
|
14
14
|
| **前置要求** | Node.js ≥ 20,且 `PATH` 上有 `pnpm` |
|
|
15
15
|
| **运行时依赖** | 无——零第三方依赖,无构建步骤 |
|
|
16
16
|
| **许可证** | MIT |
|
|
@@ -40,14 +40,14 @@
|
|
|
40
40
|
- [`dsh-sidenote`](https://github.com/g-yixuan/dsh-sidenote) 是功能完整的侧边聊天,带一键「回流主会话」;按设计它的结论就是要写回主会话的。
|
|
41
41
|
- [`@lukeknow0/dsh-side-chat`](https://github.com/Lukeknow0/dsh-side-chat) 架构优秀、只读模型与本插件相近,但其 README 自述仍是 MVP。
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
最直觉的只读做法是在**提示词层**动手:用 `toolFilter` 把工具从提示词里删掉,或用 `tools.restrict` 限制工具。功能上确实达到只读,但代价是侧会话的提示词前缀与主会话分叉,**父会话已经积累的前缀缓存全部失效**,每轮都要重算整个上下文。
|
|
44
44
|
|
|
45
45
|
本插件把只读拦在**执行层**(`ctx.tools.guard`,`dsh-tools` 的公开契约):工具仍留在提示词里,模型可以尝试调用,但执行被拒——于是提示词与主会话逐字一致,缓存照旧命中(实测 97%–98%)。
|
|
46
46
|
|
|
47
47
|
## ✨ 核心特性
|
|
48
48
|
|
|
49
49
|
- **真分支,零污染。** 宿主半绝不向父会话写入任何内容。你在分支里问过什么、得到什么答案,主会话的模型上下文里都不会出现。
|
|
50
|
-
- **继续命中前缀缓存。**
|
|
50
|
+
- **继续命中前缀缓存。** 分支出生时读取主会话**已完成回合**的前缀作为种子(每次追问都会重新取一遍——见下文「每轮重建(Re-Fork)」)。沿用主会话模型时,继续命中原有前缀缓存,无需重算提示词。
|
|
51
51
|
- **执行层只读隔离。** 安全边界不依赖提示词。由底层 `ctx.tools.guard` 精确放行六个无副作用的查询类工具(`read`、`glob`、`grep`、`web_search`、`web_fetch`、`lsp`),其余一切——写文件、执行命令、开孙子会话——都在执行层被拒。
|
|
52
52
|
- **关掉标签也不断的流式输出。** 正文与推理过程逐词推进(推理放在可折叠的「思考」行里)。关闭浏览器标签不会中断后台回答,重新打开结果还在。
|
|
53
53
|
- **面板状态会记住。** 按「会话 + 面板格」本地持久化。侧会话闲置后释放活动实例、保留记录,下次提问静默唤醒。
|
|
@@ -110,7 +110,7 @@ dsh plugin --profile web add C:\path\to\dsh-side-branch
|
|
|
110
110
|
|
|
111
111
|
## 🚀 使用
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
在主会话正文里选中文字,选区上方出现询问入口。它**只会**在这里出现——右栏(文件预览、设置、其它插件的面板)与右栏里的子代理嵌入会话里划字不会弹,因为那些不是主会话的内容。点它打开右栏面板,输入问题并发送——选中的原文会作为引用一起交给分支。
|
|
114
114
|
|
|
115
115
|
主会话输入框右侧另有一个入口按钮,点它可以直接打开面板(可在设置里关掉)。
|
|
116
116
|
|
|
@@ -127,11 +127,11 @@ dsh plugin --profile web add C:\path\to\dsh-side-branch
|
|
|
127
127
|
|
|
128
128
|
## 🧭 行为要点
|
|
129
129
|
|
|
130
|
-
**分支是真分支。**
|
|
130
|
+
**分支是真分支。** 出生时读取主会话**已完成的回合前缀**作为种子,并继承主会话的 agent preset、工具与 persona。因此这条分支看到的系统提示词与主会话逐字一致。★ **每次追问都会用主会话最新的已结算前缀重建一遍**,同时保留这条分支自己历来的问答(见下文「每轮重建(Re-Fork)」)。
|
|
131
131
|
|
|
132
132
|
**答案不回流。** 宿主半从不向父会话写入任何内容。你在分支里问过什么、得到什么答案,主会话的模型上下文里都不会出现。
|
|
133
133
|
|
|
134
|
-
**继续命中前缀缓存。**
|
|
134
|
+
**继续命中前缀缓存。** 因为提示词前缀与主会话逐字一致,沿用主会话模型时仍能命中原有的前缀缓存,而不是每次重算。换成**别的**模型时,供应商侧的缓存是**另一条序列**(前缀再像也算新序列),那一段基本按全价计费,插件会在用量行标注。
|
|
135
135
|
|
|
136
136
|
**流式输出正文与推理。** 回答逐词推送;推理过程放在可折叠的「思考」行里。客户端断开不会中止后台回答。
|
|
137
137
|
|
|
@@ -151,6 +151,100 @@ dsh plugin --profile web add C:\path\to\dsh-side-branch
|
|
|
151
151
|
|
|
152
152
|
`unarchiveSession()` 是 DSH **`0.1.6-alpha.2`** 才有的方法,调用点做了**特性探测**:在 `0.1.5-rc.2` 上(既没有这个方法、也没有那道闸)第 2、3 步都是空操作,行为与旧版完全一致。这正是同一份发布版本能同时服务两条 DSH 版本线的支点。
|
|
153
153
|
|
|
154
|
+
## 🔁 每轮重建(Re-Fork)与多段回答
|
|
155
|
+
|
|
156
|
+
### 一句话规则
|
|
157
|
+
|
|
158
|
+
每一次追问,发给模型的内容都是:
|
|
159
|
+
|
|
160
|
+
```text
|
|
161
|
+
[ 主会话最新的「已结算前缀」 ] ← 每次重建都重新取一遍
|
|
162
|
+
+ [ 这条分支自己历来的问答 ] ← 原样重放进去
|
|
163
|
+
+ [ 这一轮的新问题 ]
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
「已结算前缀」= 主会话事件里**最后一个 `turn/end` 之前**的全部内容,也就是主会话**答完并收口**的那些回合。主会话**正在生成**的那一轮不进种子。
|
|
167
|
+
|
|
168
|
+
### 什么时候重建(触发条件)
|
|
169
|
+
|
|
170
|
+
`shouldReFork` 判两条,**任一成立**就重建:
|
|
171
|
+
|
|
172
|
+
1. **主会话结算了新的回合** —— 主会话当下的已结算前缀长度 > 这段分支建段时记下的 `parentCut`;
|
|
173
|
+
2. **模型或推理等级变了** —— 只改 `reasoningEffort` 也算。
|
|
174
|
+
|
|
175
|
+
两条都不成立(主会话没长、模型也没换)就**沿用当前这一段**:种子会逐字节相同,重建只会多一条平台删不掉的会话记录和一次深拷贝,缓存命中完全一样。
|
|
176
|
+
|
|
177
|
+
重建由 `reForkConversation` 执行:新建侧会话(种子 = 主会话**最新**前缀)→ 把上一段分支**自己的**事件重放进去 → 只有**重放成功**才释放上一段。失败时本轮以 `side-branch/refork-failed` **显式拒绝**,**上一段原样保留**(绝不静默降级成"没有历史的新会话":那会让模型以为这条分支什么都没问过,而面板上还显示着旧答案)。
|
|
178
|
+
|
|
179
|
+
因为重建会新建侧会话,**`conversationId` 在重建时就会变**;`POST /side-branch/start` 一律回 `handoff: true`,所以面板**不会**画「以下是新的一段」分隔线——历史确实是连续的。
|
|
180
|
+
|
|
181
|
+
缓存方向的取舍:新增的内容是主会话**刚刚自己请求过**的那一段,很可能还在供应商的热缓存里,重建后的请求前缀正好和它对得上(走缓存读);而"前缀冻结 + 往后追加"的写法里,同样的内容是这条分支**第一次**以那种形态发出去(走缓存写)。
|
|
182
|
+
|
|
183
|
+
### 重放的纪律(改代码前先读)
|
|
184
|
+
|
|
185
|
+
只把**能进模型可见历史的那 5 种事件**原样 `append` 进新会话:
|
|
186
|
+
|
|
187
|
+
```text
|
|
188
|
+
system/message developer/message user/message assistant/message tool/result
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
- **只传 `event.data` 原样。** `assistant/message` 里嵌着**真实的 provider 流**与 `message.source.provider/model`,手搓一条必被 `dsh-session` 的形状校验拒掉。
|
|
192
|
+
- **纯日志事件不重放**(`turn/start`、`step/start`、`tool/call`、`request/header`…):`append` 它们会抛。
|
|
193
|
+
- ⛔ **绝不"顺手补全" `event.sourceEventSeqs`**:`tool/result` 在**事件层**带着 `[旧会话的 callSeq]`,抄过来会撞 `sourceEventSeqs must reference earlier events`(外来序号很可能 ≥ 新会话的 seq)。只传 `data` 天然丢掉它,而它对 `append` 本来就没用。
|
|
194
|
+
- **不截断**:到上下文上限就拒绝,不偷偷丢最早的轮次。
|
|
195
|
+
|
|
196
|
+
### 多段回答:一个 turn 里的多个 step 都要留住
|
|
197
|
+
|
|
198
|
+
一个 turn 可以含多个 step("边说边干"),**每个 step 都会开一次新的流式 attempt、各发一个 `start` 帧**。分支保有**有序的段落数组**:
|
|
199
|
+
|
|
200
|
+
```text
|
|
201
|
+
job.segments = [ { kind: 'reasoning'|'text'|'tool', text, turn, step }, … ]
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
- `start` 帧按 `turn`/`step` 分流:**同 step 换 `attemptId`** 才算**同一 step 内重试**(只丢那个 step 已经产出的段落,并发 `reset`);**`turn`/`step` 变了**才是**新的一段**(发 `segment`,绝不删旧的)。
|
|
205
|
+
- `chunk`(`text-delta` / `reasoning-delta`)与 `tool/call` 追加到**当前段**;`assistant/message` 的终局文本**只替换当前段**(而且非空才替换,纯工具收尾的那一步不会把中间输出顶成最终答案)。
|
|
206
|
+
- `job.text` / `job.reasoning` 这两个快捷引用是段落数组的**纯函数**(`syncSegmentShortcuts`)⇒ 不可能与段落数组漂移,也不存在"两处真相"。
|
|
207
|
+
|
|
208
|
+
SSE 事件与载荷完整清单:
|
|
209
|
+
|
|
210
|
+
| 事件 | 何时发 | 载荷 |
|
|
211
|
+
| --- | --- | --- |
|
|
212
|
+
| `snapshot` | 客户端(重)连上时先回放一次 | `{ status, text, reasoning, segments, conversationId, stats? }` |
|
|
213
|
+
| `segment` | 进入下一个 step(开新段) | `{ turn, step }` |
|
|
214
|
+
| `delta` | 正文增量 | `{ text, turn, step }` |
|
|
215
|
+
| `reasoning` | 推理增量 | `{ text, turn, step }` |
|
|
216
|
+
| `reset` | 同一 step 内重试(只丢该 step 的段落) | `{ turn, step }` |
|
|
217
|
+
| `replace` | 终局文本替换**当前段** | `{ text, turn, step }` |
|
|
218
|
+
| `tool` | 白名单工具被**执行**(只推工具名,不推参数) | `{ name, count, turn, step }` |
|
|
219
|
+
| `done` / `stopped` / `error` | 终态 | `{ status, text, reasoning, segments, conversationId, stats?, error? }` |
|
|
220
|
+
|
|
221
|
+
客户端把 `segments` 一起落盘(`turn`/`step` 也留着,很小),刷新后按数组顺序渲染;**老数据没有 `segments`** 时由 `answer`/`reasoning` 合成(`normalizeSegments`)⇒ 刷新后老面板不会变空。
|
|
222
|
+
|
|
223
|
+
### 继承与注入可见(层 1 / 层 2)
|
|
224
|
+
|
|
225
|
+
- **层 1(每段一行)**:`/start` 回 `synced: { turns, chars }` ⇒ 面板显示「已继承主会话 N 轮 · 约 X 字」。同一轮若真的拼了分支引导词,还会回 `notice`(**注入给模型的引导词全文**,逐字展示,只有每段第一轮才有)。这是本插件注入的**唯一**文本;系统提示词与工具声明是 DSH 自己组装的,**刻意不显示**。
|
|
226
|
+
- **层 2(按需展开)**:`GET /side-branch/inherited?conversationId=&locale=` 返回**这一段的切点**(`conv.parentCut`)之前的那些回合,**最后一轮给全文、更早的每轮只给一行摘要**,全在**服务端截断、不调用任何模型**(所以"摘要"不等于"再总结一遍")。返回里带 `truncated`(有没有被截断)与 `parentHasNewer`(主会话此后又长了 ⇒ 下一轮追问会带上它们)。
|
|
227
|
+
- ⛔ **层 2 的内容绝不进面板持久化状态**(`rounds` / `normalizeRound` / `sessionStorage`):一份主会话前缀可能几十万字符,而面板状态有 5–10 MB 的 `sessionStorage` 配额 ⇒ 塞进去会立刻爆配额、坏掉"刷新不丢"。它只放组件内存态、按需拉一次。
|
|
228
|
+
|
|
229
|
+
### 上下文上限:改成估算
|
|
230
|
+
|
|
231
|
+
re-fork 之后**新会话还没有 usage**(老判据 `lastContextTokens` 必然是 `undefined`),所以上限改成估算:
|
|
232
|
+
|
|
233
|
+
```text
|
|
234
|
+
used ≈ 父会话最后一条 assistant/message 的 input + cacheRead + cacheWrite(实测)
|
|
235
|
+
+ (分支历史字符数 + 本轮问题字符数) × 安全系数
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
超过 `contextWindow × 0.8` 就**拒绝**并回 `contextFull: { used, limit, window }`;拿不到窗口就按兜底上限。⚠️ **`used` 是估算值**(父前缀那半实测、后半按字符数估),面板上这个数字与该轮结束后的真实用量对不上是正常的。**绝不截断历史**。
|
|
239
|
+
|
|
240
|
+
### 启动时的孤儿清理与实例租约
|
|
241
|
+
|
|
242
|
+
re-fork 每次重建都**新增一条会话记录**,而平台的会话持久化层**没有删除会话的 API** ⇒ 记录会无限堆积。所以插件在 `apply()` 时清一次:磁盘上 `sessions/<项目>/side-<uuid>/` 里、**不在本进程账本里**的,就是以前的实例留下的孤儿。
|
|
243
|
+
|
|
244
|
+
判据必须配**实例租约**才安全:账本在启动时**必然是空的**,所以若同时跑着**第二个共用同一个 `DSH_HOME` 的 DSH 实例**,它启动时会把第一个实例**正在用的**侧枝目录当孤儿删掉(第一个实例内存里还记着它、磁盘日志却没了 ⇒ 那条侧枝直接坏掉)。做法:每个实例启动时在 `<DSH_HOME>/side-branch-instances/` 放一条租约(`<pid>-<uuid>.json`)并定期续心跳,卸载时删掉;清理前只要发现**任何一条还活着的外来租约**就**整个跳过**(心跳过期或进程已死的租约顺手清掉)。
|
|
245
|
+
|
|
246
|
+
安全校验三条:① 目录名必须**正好**是 `side-<uuid>`(前缀对但形状不对的**不动**、只留痕);② 不得在本进程的账本里;③ 严格限定在 `sessions` 根目录下、不许路径逃逸。删了什么**逐条记进日志**(平台删不掉,可追溯性只能靠日志);单条失败只记日志,绝不影响启动。⛔ **不碰**主会话(`session-*`)的任何文件。
|
|
247
|
+
|
|
154
248
|
## 🛡️ 只读是怎么保证的
|
|
155
249
|
|
|
156
250
|
只读由**执行层**保证,不依赖提示词请求模型配合。
|
|
@@ -200,18 +294,19 @@ read glob grep web_search web_fetch lsp
|
|
|
200
294
|
|
|
201
295
|
## ⚠️ 已知限制
|
|
202
296
|
|
|
203
|
-
-
|
|
297
|
+
- **主会话「尚未结算」的那一轮看不到。** 每次重建取的切点是主会话**最后一个 `turn/end`**,所以正在生成、或刚发出去还没答完的那一轮不在种子与重放里。已经在主会话**答完结算**的新回合则会**自动跟上**(这正是 re-fork 的作用)。想引用还没结算的内容,走**主动划选引用** —— 受限的是"自动看到",不是"能不能引用"。
|
|
204
298
|
- **同时最多两条侧枝会话。** 这来自 DSH 侧栏的分栏上限(同一 tab 类型每个分栏最多一颗、最多两个分栏),不是本插件的限制,但用起来会撞到。
|
|
205
299
|
- **`web_fetch`、`web_search`、`lsp`、`read`、`glob`、`grep` 本身的行为不受只读约束。** guard 拦的是「名单外的工具」,不是这六个工具的网络访问、起进程与读文件能力。详见上面「只读不等于无害」。
|
|
206
|
-
- **「清空」对界面历史不可逆。** 清空之后这一段在面板里再也看不到、也回不去。磁盘上的归档侧会话记录仍在,但官方 API
|
|
300
|
+
- **「清空」对界面历史不可逆。** 清空之后这一段在面板里再也看不到、也回不去。磁盘上的归档侧会话记录仍在,但官方 API 删不掉它,它也不会出现在任何会话列表里。另外,re-fork **每次重建都会新增一条会话记录**,而平台**没有删除会话的 API** ⇒ 这些记录靠插件**启动时的孤儿清理**回收(见上文);同一时刻有**两个共用同一个 `DSH_HOME` 的实例**在跑时,清理会**整个跳过**。
|
|
207
301
|
- 设置存在浏览器 `localStorage`:换浏览器或清站点数据会回到默认值。目前设置只有一项(主会话快速入口按钮的开关)。
|
|
208
302
|
- 工具放行名单不可配置。想收窄可用工具只能改代码,没有针对单个工具的开关。
|
|
209
303
|
- 插件重载后,「睡着」的会话表随内存消失,旧会话 id 会报告已另起一段(客户端会自动新建,不会卡住)。
|
|
210
304
|
**注意区分两种路径**:闲置到点只是让侧会话「睡觉」,记录还在,下次提问**静默唤醒**、连分隔线都不出现;只有**插件重载**才会丢记录、另起一段,此时问题照常得到回答、不用重新输入,只是那段历史接不上。
|
|
211
|
-
-
|
|
305
|
+
- 每段有上下文上限。到达上限时**拒绝发送**并回 `contextFull: { used, limit, window }`,不会静默丢弃最早的轮次。⚠️ 这个 `used` 是**估算值**(re-fork 之后新会话还没有 usage),不是实测用量。
|
|
306
|
+
- **缓存命中率是实测值,不是设计保证。** 平台可能在子会话第一步**原地替换 system 节点**(node 0)——官方原话是 *"A prompt change that replaces a system node in place makes the request differ from that node's first token — in full when the node is node 0"* ⇒ 一旦如此,**从第 0 个 token 起全废**;工具集不一致也会强制开一条新序列。所以不会有"必然命中"这回事,97%–98% 是实测,不是承诺。并且**命中缓存 ≠ 不占上下文窗口**:`inputTokens + cacheRead + cacheWrite` 里命中的那些 token **一样占窗口**。
|
|
212
307
|
- 主会话用 `ptc` preset 时,这条分支**一个工具都用不了**(唯一可见入口 `run_code` 永远被拒)。用 PTC 模板另存出来的自定义 preset 还会让开段判定漏判,第一轮白试一次。
|
|
213
308
|
- 模型被 guard 拒绝后往往会**再试一次**同一个工具,白花一轮。这与主会话是否 PTC 无关,实测两种模式下都会发生。
|
|
214
|
-
- 已在 DSH `0.1.5-rc.2`(尚无归档闸)与 `0.1.7-rc.2`(已有归档闸)上验证。`0.1.6-alpha.2` 只核对了它已发布的包:它已有 `unarchiveSession`、尚未有 `ArchivedSessionGate
|
|
309
|
+
- 已在 DSH `0.1.5-rc.2`(尚无归档闸)与 `0.1.7-rc.2`(已有归档闸)上验证。`0.1.6-alpha.2` 只核对了它已发布的包:它已有 `unarchiveSession`、尚未有 `ArchivedSessionGate`,特性探测的分支足以让它照常工作,但**没有在那里跑过端到端**。`0.2.0-rc.2` 上则做过完整实跑:插件加载、`/side-branch` 路由注册、面板人工验证通过。
|
|
215
310
|
- MCP 工具的拦截结论来自代码路径核对,没有实测(`run_code` 已实测)。
|
|
216
311
|
- 分支引导词对模型行为的影响没有量化。它要求模型在需要读文件才能得出结论时说明「这需要回主会话做」,是否会让模型过度保守需要人工评判。
|
|
217
312
|
|