dsh-session-manager 0.4.10 → 0.5.1
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/CHANGELOG.md +218 -185
- package/README.md +158 -109
- package/README.zh.md +84 -29
- package/lib/annotation-store.js +159 -0
- package/lib/client.js +811 -54
- package/lib/clipboard-parser.js +208 -0
- package/lib/compat/dsh-adapter.js +17 -0
- package/lib/compat/zstd-frames.js +2 -2
- package/lib/index.js +1111 -1247
- package/lib/session-files.js +180 -0
- package/package.json +10 -3
package/README.md
CHANGED
|
@@ -1,109 +1,158 @@
|
|
|
1
|
-
# dsh-session-manager — session manager for DeepSeek Harness
|
|
2
|
-
|
|
3
|
-
English | [中文](README.zh.md)
|
|
4
|
-
|
|
5
|
-
[](https://www.npmjs.com/package/dsh-session-manager)
|
|
6
|
-
[](https://github.com/hkkz9522/dsh-session-manager)
|
|
7
|
-
[](https://github.com/hkkz9522/dsh-session-manager/actions/workflows/ci.yml)
|
|
8
|
-
[](https://awesome-dsh-plugin.com)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
## Features
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- **
|
|
17
|
-
- **
|
|
18
|
-
- **
|
|
19
|
-
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
```powershell
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
1
|
+
# dsh-session-manager — session manager for DeepSeek Harness
|
|
2
|
+
|
|
3
|
+
English | [中文](README.zh.md)
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/dsh-session-manager)
|
|
6
|
+
[](https://github.com/hkkz9522/dsh-session-manager)
|
|
7
|
+
[](https://github.com/hkkz9522/dsh-session-manager/actions/workflows/ci.yml)
|
|
8
|
+
[](https://awesome-dsh-plugin.com)
|
|
9
|
+
|
|
10
|
+
DSH Web session manager: delete, archive, move across workspaces, migrate preset; favorites, review-later, search, sort, priority, add tags and notes (manual / semi-automated). Suggestions are welcome on GitHub.
|
|
11
|
+
|
|
12
|
+
## Features
|
|
13
|
+
|
|
14
|
+
### Session lifecycle
|
|
15
|
+
|
|
16
|
+
- **Archive / unarchive** sessions.
|
|
17
|
+
- **Delete sessions** with an explicit irreversible-action confirmation. Deletion is rejected for subagent sessions and transient blank placeholders.
|
|
18
|
+
- **Move to workspace**: preserves history, title, archive state, and derived-session relationships, and rewrites the session's working directory (`cwd`) to the target workspace. The move updates the live writer's header in place so any pending tool calls keep landing on the new path.
|
|
19
|
+
- **Migrate Agent preset**: change the preset on demand. Typical use case: when the original preset was renamed or removed and the session can no longer resume, you can repair that session. The migration rewrites the latest `agent-preset/selected` event (or the session header if no such event exists) without altering message history.
|
|
20
|
+
|
|
21
|
+
### Session manager panel (sidebar)
|
|
22
|
+
|
|
23
|
+
- Browse active and archived sessions, switch workspaces, and filter, sort, search across the list.
|
|
24
|
+
- Open a session directly from a row, or click a tag chip to filter the list to that tag.
|
|
25
|
+
- Per-row actions: **Open**, **Archive / Unarchive**, **Move**, **Migrate preset**, **Delete**.
|
|
26
|
+
- Each popup dialog (Move / Migrate preset / Delete / the panel itself) toggles closed when its trigger is clicked a second time, matching the built-in title-area buttons.
|
|
27
|
+
|
|
28
|
+
### Search, filters, and sorting
|
|
29
|
+
|
|
30
|
+
- Case-insensitive title and session-ID search; whitespace is trimmed. Message history is never read.
|
|
31
|
+
- Combine a workspace selector (All / Ungrouped / specific) with the archive filter (All / Active / Archived).
|
|
32
|
+
- Combine favorite/review flags, tag and priority filters; sort by recently updated (default), least recently updated, newest created, oldest created, or **priority (1 → 5)**.
|
|
33
|
+
- See matching/total counts and reset all view controls together. These controls only affect the manager panel — workspace membership, archive state, and the native sidebar ordering are untouched.
|
|
34
|
+
- Failed workspace loads can be retried without losing search, sort state.
|
|
35
|
+
|
|
36
|
+
### Favorites, review flags, tags, notes and priority
|
|
37
|
+
|
|
38
|
+
- Favorite / Review / **Tags/Notes** / priority controls are reachable from both the **title bar** (current session) and the **manager panel** (every row).
|
|
39
|
+
- Favorites and review flags are manual — independent of archive / running state, never cleared automatically.
|
|
40
|
+
- Priority is a dropdown **1 Highest, 2 High, 3 Normal, 4 Low, 5 Lowest** with **3 (Normal) as the default**; the manager row and title bar always show a P1–P5 badge. Legacy `null` priorities are normalized to 3.
|
|
41
|
+
- Tags: up to 20 per session, 32 characters each. Both English `,` and Chinese `,` are separators, whitespace is trimmed, duplicates are merged case-insensitively.
|
|
42
|
+
- Notes: plain multiline text, up to 2000 characters.
|
|
43
|
+
- Tags, notes and the AI **paste** textarea all share the same `sm-noteInput` style and `rows: 3` height (60px min-height), so the three input boxes line up visually.
|
|
44
|
+
- The "Tags/Notes" editor also surfaces **Copy Prompt** / **Import** controls for AI-assisted tagging (see below).
|
|
45
|
+
- Annotations are stored as plain text in `dsh-session-manager/annotations.v1.json` under the DSH home, keyed by session ID. They do not rewrite history or enter model context automatically. Move / Migrate preserve them; Delete cleans them up (and reports cleanup failures separately).
|
|
46
|
+
- Both UI surfaces share live state. Same-origin browser tabs receive change notifications via `BroadcastChannel`; refocusing or reopening the manager refreshes data.
|
|
47
|
+
- Saves are atomic, use a cross-process lock, and never silently overwrite another editor: revision conflicts surface a "load latest" prompt. Unsaved drafts survive a save failure.
|
|
48
|
+
- A crash-left `annotations.v1.lock` is not forcibly removed; verify no writer is active before handling it.
|
|
49
|
+
|
|
50
|
+
### AI-assisted tagging (manual, opt-in)
|
|
51
|
+
|
|
52
|
+
The **Tags/Notes** editor has two extra buttons above the paste box. Neither calls a model automatically — both keep you in control:
|
|
53
|
+
|
|
54
|
+
- **复制 Prompt** / **Copy Prompt** copies a structured prompt (Chinese or English, matched to the active UI language) to the clipboard. Paste it into the current conversation to ask the model to generate tags / note / priority within the plugin's limits.
|
|
55
|
+
- **导入** / **Import** reads the clipboard, extracts the first JSON object (tolerating Markdown fences, conversational wrappers, smart quotes, stray backslashes and a leading BOM), validates it against the same limits, and populates the editor fields. Oversized notes are truncated; invalid tags / priority are dropped with reasons. Importing into a dirty draft asks for confirmation first. If parsing still fails, the error message includes the actual `JSON.parse` position from each recovery attempt so you can see exactly which character broke it.
|
|
56
|
+
|
|
57
|
+
The prompt templates and import parser live in `lib/clipboard-parser.js` and are bundled into the client; no build step or network call is required.
|
|
58
|
+
|
|
59
|
+
### Current session title bar
|
|
60
|
+
|
|
61
|
+
The right side of the title area offers:
|
|
62
|
+
|
|
63
|
+
- **Archive / Unarchive** the current session.
|
|
64
|
+
- **Move to workspace** with a workspace picker.
|
|
65
|
+
- A red **Delete session** button with confirmation.
|
|
66
|
+
|
|
67
|
+
The same buttons appear in the manager row.
|
|
68
|
+
## Agent preset migration
|
|
69
|
+
|
|
70
|
+
Use this when a session can no longer resume because its original preset no longer exists, for example after removing a custom preset such as `router-standard`.
|
|
71
|
+
|
|
72
|
+
1. Open **Session manager**.
|
|
73
|
+
2. Locate the session and select **Migrate preset**.
|
|
74
|
+
3. Choose one of the currently available target presets and confirm.
|
|
75
|
+
|
|
76
|
+
The plugin determines the session's effective preset from its latest `agent-preset/selected` event when present; otherwise it uses the session header. It then rewrites that event in place (or appends a fresh one if the session has never recorded a selection), so the migration is durable and the prior entry remains visible in the event log as history. For a live session, the new event is appended in memory via `Session.append()` and flushed to disk via `SessionStore.flush()`; the api-gateway's chat panel sees the new preset on the next event fold.
|
|
77
|
+
|
|
78
|
+
> A preset migration changes session metadata only. It does not alter message history, files, or the selected workspace.
|
|
79
|
+
|
|
80
|
+
## Install
|
|
81
|
+
|
|
82
|
+
The plugin is listed in [dsh-market](https://github.com/dsh-market/dsh-market) and [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin), and can be installed directly from the **Plugin Marketplace** inside DSH.
|
|
83
|
+
|
|
84
|
+
### From dsh-market
|
|
85
|
+
|
|
86
|
+
```powershell
|
|
87
|
+
dsh plugin --profile web add npm:dsh-session-manager
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### From GitHub
|
|
91
|
+
|
|
92
|
+
```powershell
|
|
93
|
+
dsh plugin --profile web add github:hkkz9522/dsh-session-manager
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Restart DSH Web after installation. If the browser still holds an older client bundle, force refresh with `Ctrl+Shift+R`.
|
|
97
|
+
|
|
98
|
+
### Local development / runtime injection
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
dev_inject_plugin {"dir": "<absolute path to this repository>"}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Safety and behavior
|
|
105
|
+
|
|
106
|
+
- **Deletion is permanent**, so the UI always asks for confirmation. The API checks the session ID, directory boundary and artifact header before deletion; traversal, symlinks and junctions are refused.
|
|
107
|
+
- Move and preset migration retain the live session/agent; deletion cancels and disposes it. Move updates the stored cwd and the existing live writer's header.
|
|
108
|
+
- The management list hides subagent sessions and the move API rejects them. Blank sessions without a persisted artifact cannot be moved.
|
|
109
|
+
- Cold rewrites preserve the artifact's stored format rather than forcing a v2 → v3 upgrade. Moves and rewrites refuse corrupt/truncated Zstd logs or JSONL logs with incomplete final lines instead of publishing partial history.
|
|
110
|
+
- Preset migration separates backup, publication and rollback. If rollback fails, recovery files are retained and their paths are included in the error; do not remove them.
|
|
111
|
+
- Incomplete startup scans skip workspace reconciliation. Complete scans preserve live sessions and membership added during the scan.
|
|
112
|
+
- Plugin mutations are serialized per session and request bodies are limited to 64 KiB. This queue supplements, rather than replaces, DSH's persistence coordination.
|
|
113
|
+
|
|
114
|
+
## Compatibility
|
|
115
|
+
|
|
116
|
+
| Plugin version | Verified DSH version |
|
|
117
|
+
| --- | --- |
|
|
118
|
+
| 0.5.1 | v0.1.6-alpha.2 |
|
|
119
|
+
| 0.4.11 | v0.1.5-rc.2 |
|
|
120
|
+
| 0.4.10 | v0.1.5-rc.1 |
|
|
121
|
+
| 0.4.9 | v0.1.5-rc.1 |
|
|
122
|
+
| 0.4.7 | v0.1.5-rc.1 |
|
|
123
|
+
| 0.4.4 | 0.1.3-alpha.2 |
|
|
124
|
+
| 0.4.1 | 0.1.3-alpha.2 |
|
|
125
|
+
| 0.4.0 | v0.1.2-rc.1 |
|
|
126
|
+
| 0.1.2 | v0.1.0-rc.7 |
|
|
127
|
+
| 0.1.1 | v0.1.0-rc.7 |
|
|
128
|
+
| 0.1.0 | v0.1.0-rc.7 |
|
|
129
|
+
|
|
130
|
+
Requires Node.js 22.15+ (22.x) or 24+ for built-in Zstd support.
|
|
131
|
+
|
|
132
|
+
The plugin is a Cordis plugin and declares `cordis: ">=4.0.0-rc <5"` as its peer dependency.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
- `lib/index.js` is the host-side ESM plugin; `lib/client.js` is the Web client bundle. No build step is required.
|
|
137
|
+
- Before submitting changes, run:
|
|
138
|
+
|
|
139
|
+
```powershell
|
|
140
|
+
npm run check
|
|
141
|
+
npm test
|
|
142
|
+
npm run check:package
|
|
143
|
+
git diff --check
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Tests use isolated temporary directories and the real plugin entry point, never real sessions. CI runs these checks on Windows/Linux with Node 22.15.0/24.
|
|
147
|
+
|
|
148
|
+
Optional integration check: run `node scripts/smoke-test.mjs` against a running test instance of DSH Web. This contacts a real service and is not part of the default unit test suite.
|
|
149
|
+
|
|
150
|
+
Release history is in [CHANGELOG.md](CHANGELOG.md).
|
|
151
|
+
|
|
152
|
+
## Acknowledgments
|
|
153
|
+
|
|
154
|
+
Thanks to everyone who installs and uses dsh-session-manager, and to the people who file issues and open pull requests to help improve it. This plugin is listed in [dsh-market](https://github.com/dsh-market/dsh-market) and [awesome-dsh-plugin](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin). Suggestions and feedback are welcome.
|
|
155
|
+
|
|
156
|
+
## License
|
|
157
|
+
|
|
158
|
+
[MIT](LICENSE)
|
package/README.zh.md
CHANGED
|
@@ -7,17 +7,64 @@
|
|
|
7
7
|
[](https://github.com/hkkz9522/dsh-session-manager/actions/workflows/ci.yml)
|
|
8
8
|
[](https://awesome-dsh-plugin.com)
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
DSH Web 会话管理:删除、归档、跨工作区移动、迁移预设;收藏、待看、搜索、排序、设置优先级、添加(手动/半自动)标签和备注。欢迎至 GitHub 提意见。
|
|
11
11
|
|
|
12
12
|
## 功能
|
|
13
13
|
|
|
14
|
+
### 会话生命周期
|
|
15
|
+
|
|
14
16
|
- **归档 / 移出归档**会话。
|
|
15
|
-
-
|
|
16
|
-
- **移动至工作区**:保留历史、标题、归档状态和派生会话关系,同时把会话的 `cwd`
|
|
17
|
-
- **迁移 Agent
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
- **删除会话**:不可逆操作的二次确认。subagent 会话和临时空白会话占位会被拒绝。
|
|
18
|
+
- **移动至工作区**:保留历史、标题、归档状态和派生会话关系,同时把会话的 `cwd` 更新为目标工作区。移动会就地更新 live writer 的 header,使挂起的工具调用继续落到新路径。
|
|
19
|
+
- **迁移 Agent 预设**:按需修改。典型工况:当原预设被改名或删除,导致会话无法恢复时,可修复该会话。迁移会就地重写最后一条 `agent-preset/selected` 事件(若从未记录选择事件则修改会话 header),不会改写历史消息。
|
|
20
|
+
|
|
21
|
+
### 会话管理窗口(侧边栏)
|
|
22
|
+
|
|
23
|
+
- 浏览未归档和已归档会话、切换工作区、在列表中筛选 / 排序 / 搜索。
|
|
24
|
+
- 直接从某一行打开会话,或点击标签直接按该标签筛选。
|
|
25
|
+
- 每行操作:**打开**、**归档 / 移出归档**、**移动**、**迁移预设**、**删除**。
|
|
26
|
+
- 各弹窗(移动 / 迁移预设 / 删除 / 面板本身)在触发按钮再次点击时会切换关闭,与标题栏原生按钮的行为一致。
|
|
27
|
+
|
|
28
|
+
### 搜索、筛选和排序
|
|
29
|
+
|
|
30
|
+
- 不区分大小写的标题和会话 ID 搜索,自动去除首尾空格;不读取聊天历史。
|
|
31
|
+
- 工作区下拉(全部 / 未分组 / 具体工作区)可与归档状态筛选(全部 / 未归档 / 已归档)叠加。
|
|
32
|
+
- 可叠加收藏 / 待回看、标签和优先级筛选;排序支持最近更新(默认)、最早更新、最新创建、最早创建以及**优先级(1 → 5)**。
|
|
33
|
+
- 显示匹配 / 总数,并提供"重置筛选"。这些控件只影响管理窗口,不改变会话归属、归档状态或原生侧边栏顺序。
|
|
34
|
+
- 工作区加载失败时可重试,标题 / ID 搜索和更新时间排序仍可使用。
|
|
35
|
+
|
|
36
|
+
### 收藏、待回看、标签、备注与优先级
|
|
37
|
+
|
|
38
|
+
- **标题栏**(当前会话)和 **管理窗口**(每一行)都提供收藏 / 待回看 / **标签 / 备注** / 优先级操作入口。
|
|
39
|
+
- 收藏是长期标记,待回看是手动提醒;不会随归档或会话结束自动清除。
|
|
40
|
+
- 优先级下拉:**1 最高、2 高、3 普通、4 低、5 最低**,**默认 3(普通)**;管理行 / 标题栏始终显示 P1–P5 徽标。旧数据中的 `null` 优先级归一化为 3。
|
|
41
|
+
- 标签:每个会话最多 20 个,每个最多 32 字符;英文 `,` 与中文 `,` 都是分隔符,首尾空白被去除,重复标签按大小写不敏感合并。
|
|
42
|
+
- 备注:最多 2000 字符的多行纯文本。
|
|
43
|
+
- 标签、备注、AI 粘贴三个输入框使用相同的 `sm-noteInput` 样式与 `rows: 3` 高度(60px min-height),三个字段在视觉上对齐。
|
|
44
|
+
- "标签 / 备注" 编辑窗口内还提供 **复制 Prompt** / **导入** 两个按钮,用于 AI 辅助整理(见下)。
|
|
45
|
+
- 标记明文保存在 DSH home 下的 `dsh-session-manager/annotations.v1.json`,按会话 ID 关联;不写入 JSONL/Zstd 历史,也不自动发送给模型。移动 / 迁移预设会保留标记;删除会话后会清理对应标记(清理失败会单独提示)。
|
|
46
|
+
- 标题栏和管理窗口实时共享状态;同源浏览器标签页通过 `BroadcastChannel` 通知同步,重新获得焦点或打开管理窗口也会刷新数据。
|
|
47
|
+
- 保存采用原子写入并使用跨进程锁;版本冲突时保留草稿,要求显式"载入最新内容"。保存失败不会关闭编辑窗口或丢弃草稿。
|
|
48
|
+
- 异常退出遗留的 `annotations.v1.lock` 不会被自动强行删除;应在确认没有进程写入后再处理。
|
|
49
|
+
|
|
50
|
+
### AI 整理(手动、可选)
|
|
51
|
+
|
|
52
|
+
**标签/备注** 编辑窗口在标准"取消 / 保存标记"按钮之外,还多了两个按钮,位于粘贴输入框上方。两者都不会自动调用模型,是否发送完全由你决定:
|
|
53
|
+
|
|
54
|
+
- **复制 Prompt** / **Copy Prompt**:把结构化 Prompt(中文或英文,跟随当前界面语言)复制到剪贴板。粘贴到当前对话中,要求模型按本插件的限制生成标签 / 备注 / 优先级(最多 20 个标签、每个 ≤ 32 字符、备注 ≤ 2000 字符、优先级 1–5 默认 3)。
|
|
55
|
+
- **导入** / **Import**:读取剪贴板,提取首个 JSON 对象(可识别 Markdown 代码块、对话包裹、智能引号、孤立反斜杠和开头 BOM),按相同规则校验后填入编辑窗口。如果当前有未保存的修改,会先询问是否覆盖再继续。超限的标签会被丢弃、超长的备注会被截断,所有调整都会在状态消息中列出,确认后再保存。如果仍然解析失败,错误信息会附带每一次修复尝试中 `JSON.parse` 给出的具体位置(原始 / 修复引号反斜杠 / 扫描对象 / 扫描对象+修复),方便定位坏掉的字符。
|
|
56
|
+
|
|
57
|
+
Prompt 模板和导入解析逻辑位于 `lib/clipboard-parser.js`,直接打包进客户端,无需额外构建步骤,也不会发起任何网络请求。
|
|
58
|
+
|
|
59
|
+
### 当前会话标题栏
|
|
60
|
+
|
|
61
|
+
标题栏右侧提供:
|
|
62
|
+
|
|
63
|
+
- **归档 / 移出归档** 当前会话。
|
|
64
|
+
- **移动至工作区**,弹窗选择目标工作区。
|
|
65
|
+
- 红色的 **删除会话** 按钮,带确认。
|
|
66
|
+
|
|
67
|
+
同样的按钮在管理窗口每一行也可用。
|
|
21
68
|
|
|
22
69
|
## UI 入口
|
|
23
70
|
|
|
@@ -32,7 +79,7 @@
|
|
|
32
79
|
|
|
33
80
|
例如:当会话无法恢复,报错表明原 Agent 预设不存在时(例如删掉了 `router-standard`),可以使用迁移功能。
|
|
34
81
|
|
|
35
|
-
插件会读取最后一条 `agent-preset/selected` 事件中的有效预设(若不存在则读取会话 header
|
|
82
|
+
插件会读取最后一条 `agent-preset/selected` 事件中的有效预设(若不存在则读取会话 header)。冷会话会重写最后一条选择事件;从未记录选择事件时修改 header。正常的 live session 通过 `Session.append()` 追加选择事件,再通过 `SessionStore.flush()` 刷到磁盘;api-gateway 的聊天面板在下次事件折叠时即可看到新预设。
|
|
36
83
|
|
|
37
84
|
> 迁移预设只会修改会话元数据,不会改写历史消息、文件或当前工作区。
|
|
38
85
|
|
|
@@ -62,25 +109,31 @@ dev_inject_plugin {"dir": "<本仓库的绝对路径>"}
|
|
|
62
109
|
|
|
63
110
|
## 安全与行为说明
|
|
64
111
|
|
|
65
|
-
-
|
|
66
|
-
-
|
|
67
|
-
-
|
|
68
|
-
-
|
|
69
|
-
-
|
|
112
|
+
- **删除不可恢复**,因此界面始终要求确认。删除前校验会话 ID、目录边界和工件 header;不允许通过路径穿越、符号链接或 junction 操作其他目录。
|
|
113
|
+
- 移动和迁移预设保留 live session / agent;删除才会取消运行并释放会话。移动会更新保存的 cwd 和 live writer 的 header。
|
|
114
|
+
- 会话管理列表隐藏 subagent 会话,移动接口也拒绝 subagent;尚未落盘的空白会话不能跨工作区移动。
|
|
115
|
+
- 冷会话重写保留原工件格式,不强制升级 v2 → v3。损坏或截断的 Zstd 日志、缺少完整尾行的 JSONL 会拒绝移动/重写,不会把部分历史当作完整日志保存。
|
|
116
|
+
- 迁移预设按“备份 → 发布 → 回滚”分阶段处理。如果回滚失败,会保留恢复文件并在错误中报告路径;不要删除这些文件。
|
|
117
|
+
- 启动扫描不完整时跳过工作区归属修复;完整扫描也不会清除仍在内存中或扫描期间新加入的会话。
|
|
118
|
+
- 同一会话的插件写操作按顺序执行;请求体限制为 64 KiB。该队列不替代 DSH 自身的持久化写入协调。
|
|
70
119
|
|
|
71
120
|
## 兼容性
|
|
72
121
|
|
|
73
|
-
| 插件版本
|
|
74
|
-
|
|
|
75
|
-
| 0.
|
|
76
|
-
| 0.4.
|
|
77
|
-
| 0.4.
|
|
78
|
-
| 0.4.
|
|
79
|
-
| 0.4.
|
|
80
|
-
| 0.4.
|
|
81
|
-
| 0.1
|
|
82
|
-
| 0.
|
|
83
|
-
| 0.1.
|
|
122
|
+
| 插件版本 | 已验证 DSH 版本 |
|
|
123
|
+
| ------ | ------------- |
|
|
124
|
+
| 0.5.1 | v0.1.6-alpha.2 |
|
|
125
|
+
| 0.4.11 | v0.1.5-rc.2 |
|
|
126
|
+
| 0.4.10 | v0.1.5-rc.1 |
|
|
127
|
+
| 0.4.9 | v0.1.5-rc.1 |
|
|
128
|
+
| 0.4.7 | v0.1.5-rc.1 |
|
|
129
|
+
| 0.4.4 | 0.1.3-alpha.2 |
|
|
130
|
+
| 0.4.1 | 0.1.3-alpha.2 |
|
|
131
|
+
| 0.4.0 | v0.1.2-rc.1 |
|
|
132
|
+
| 0.1.2 | v0.1.0-rc.7 |
|
|
133
|
+
| 0.1.1 | v0.1.0-rc.7 |
|
|
134
|
+
| 0.1.0 | v0.1.0-rc.7 |
|
|
135
|
+
|
|
136
|
+
运行时要求 Node.js 22.15+(22.x)或 24+,以提供内置 Zstd 支持。
|
|
84
137
|
|
|
85
138
|
本插件是 Cordis 插件,peer dependency 为 `cordis: ">=4.0.0-rc <5"`。
|
|
86
139
|
|
|
@@ -90,14 +143,16 @@ dev_inject_plugin {"dir": "<本仓库的绝对路径>"}
|
|
|
90
143
|
- 提交修改前请运行:
|
|
91
144
|
|
|
92
145
|
```powershell
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
node scripts/smoke-test.mjs
|
|
146
|
+
npm run check
|
|
147
|
+
npm test
|
|
148
|
+
npm run check:package
|
|
97
149
|
git diff --check
|
|
98
|
-
npm pack --dry-run
|
|
99
150
|
```
|
|
100
151
|
|
|
152
|
+
测试使用隔离临时目录和真实插件入口,不操作真实会话。CI 在 Windows/Linux、Node 22.15.0/24 上执行相同检查。
|
|
153
|
+
|
|
154
|
+
可选集成检查:在 DSH Web 已运行的测试环境中执行 `node scripts/smoke-test.mjs`;它会请求实际服务,不属于默认单元测试。
|
|
155
|
+
|
|
101
156
|
发布记录见 [CHANGELOG.md](CHANGELOG.md)。
|
|
102
157
|
|
|
103
158
|
## 致谢
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/** User annotations live outside session artifacts: no history rewriting, no
|
|
2
|
+
* workspace changes, and no model calls. Atomic publication + an advisory
|
|
3
|
+
* inter-process lock protect independent browser/profile updates. */
|
|
4
|
+
import * as fs from "node:fs/promises";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
7
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
8
|
+
import { assertSessionId, writeTempFile } from "./session-files.js";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_ANNOTATION = Object.freeze({ favorite: false, reviewLater: false, tags: Object.freeze([]), note: "", priority: null, revision: 0 });
|
|
11
|
+
export const ANNOTATION_LIMITS = Object.freeze({ tags: 20, tagLength: 32, noteLength: 2000 });
|
|
12
|
+
const FIELDS = new Set(["favorite", "reviewLater", "tags", "note", "priority"]);
|
|
13
|
+
const fail = (message, code = "bad-request") => Object.assign(new Error(message), { code });
|
|
14
|
+
const isRecord = value => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
15
|
+
|
|
16
|
+
export function normalizeAnnotationPatch(patch) {
|
|
17
|
+
if (!isRecord(patch) || Object.keys(patch).length === 0) throw fail("标记更新不能为空");
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const key of Object.keys(patch)) {
|
|
20
|
+
if (!FIELDS.has(key)) throw fail("未知的标记字段: " + key);
|
|
21
|
+
const value = patch[key];
|
|
22
|
+
if (key === "favorite" || key === "reviewLater") {
|
|
23
|
+
if (typeof value !== "boolean") throw fail("收藏和待回看必须是布尔值");
|
|
24
|
+
out[key] = value;
|
|
25
|
+
} else if (key === "priority") {
|
|
26
|
+
if (value !== null && (!Number.isInteger(value) || value < 1 || value > 5)) throw fail("优先级必须为 1–5 的整数,或未设置(null)");
|
|
27
|
+
out.priority = value;
|
|
28
|
+
} else if (key === "note") {
|
|
29
|
+
if (typeof value !== "string" || value.length > ANNOTATION_LIMITS.noteLength || value.includes("\0")) throw fail("备注最多 2000 字符,不能包含空字符");
|
|
30
|
+
out.note = value;
|
|
31
|
+
} else {
|
|
32
|
+
if (!Array.isArray(value)) throw fail("标签必须是数组");
|
|
33
|
+
const tags = []; const seen = new Set();
|
|
34
|
+
for (const item of value) {
|
|
35
|
+
if (typeof item !== "string") throw fail("标签必须是文本");
|
|
36
|
+
const tag = item.trim();
|
|
37
|
+
if (!tag) continue;
|
|
38
|
+
if (tag.length > ANNOTATION_LIMITS.tagLength || /[,,\x00-\x1f]/.test(tag)) throw fail("每个标签最多 32 字符,不能包含逗号或控制字符");
|
|
39
|
+
const key = tag.toLowerCase();
|
|
40
|
+
if (!seen.has(key)) { tags.push(tag); seen.add(key); }
|
|
41
|
+
}
|
|
42
|
+
if (tags.length > ANNOTATION_LIMITS.tags) throw fail("每个会话最多 20 个标签");
|
|
43
|
+
out.tags = tags;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function createAnnotationStore(directory, { io = fs, lockWaitMs = 1500 } = {}) {
|
|
50
|
+
if (typeof directory !== "string" || !directory) throw fail("无法定位会话标记目录", "annotations-unavailable");
|
|
51
|
+
const root = resolve(directory);
|
|
52
|
+
const path = join(root, "annotations.v1.json");
|
|
53
|
+
const lockPath = join(root, "annotations.v1.lock");
|
|
54
|
+
let queue = Promise.resolve();
|
|
55
|
+
const enqueue = operation => {
|
|
56
|
+
const result = queue.then(operation);
|
|
57
|
+
queue = result.catch(() => {});
|
|
58
|
+
return result;
|
|
59
|
+
};
|
|
60
|
+
const read = async () => {
|
|
61
|
+
let content;
|
|
62
|
+
try {
|
|
63
|
+
const parent = await io.lstat(root);
|
|
64
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) throw fail("会话标记目录不能是符号链接", "annotations-unavailable");
|
|
65
|
+
const info = await io.lstat(path);
|
|
66
|
+
if (!info.isFile() || info.isSymbolicLink()) throw fail("会话标记文件必须是普通文件", "annotations-unavailable");
|
|
67
|
+
content = await io.readFile(path, "utf8");
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (error.code === "ENOENT") return { version: 1, sessions: {} };
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const state = JSON.parse(content);
|
|
74
|
+
if (state?.version !== 1 || !isRecord(state.sessions)) throw new Error("schema");
|
|
75
|
+
for (const [id, item] of Object.entries(state.sessions)) {
|
|
76
|
+
assertSessionId(id);
|
|
77
|
+
if (!isRecord(item) || !Number.isSafeInteger(item.revision) || item.revision < 1) throw new Error("revision");
|
|
78
|
+
const { revision, updatedAt, ...fields } = item;
|
|
79
|
+
if (Object.keys(fields).length !== FIELDS.size || typeof updatedAt !== "number" || !Number.isFinite(updatedAt)) throw new Error("fields");
|
|
80
|
+
normalizeAnnotationPatch(fields);
|
|
81
|
+
}
|
|
82
|
+
return state;
|
|
83
|
+
} catch {
|
|
84
|
+
// Never include note text or JSON parser excerpts in HTTP errors/logs.
|
|
85
|
+
throw fail("会话标记文件损坏或版本不兼容,已停止写入;请先备份并检查 " + path, "annotations-corrupt");
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
const withLock = async operation => {
|
|
89
|
+
await io.mkdir(root, { recursive: true, mode: 0o700 });
|
|
90
|
+
const parent = await io.lstat(root);
|
|
91
|
+
if (!parent.isDirectory() || parent.isSymbolicLink()) throw fail("会话标记目录不能是符号链接", "annotations-unavailable");
|
|
92
|
+
const token = JSON.stringify({ pid: process.pid, token: randomBytes(12).toString("hex") });
|
|
93
|
+
const deadline = Date.now() + lockWaitMs;
|
|
94
|
+
let handle;
|
|
95
|
+
while (!handle) {
|
|
96
|
+
try { handle = await io.open(lockPath, "wx", 0o600); }
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code !== "EEXIST") throw error;
|
|
99
|
+
if (Date.now() >= deadline) throw fail("会话标记正由其他进程写入,请稍后重试;若进程异常退出,请确认无写入后检查锁文件 " + lockPath, "annotations-busy");
|
|
100
|
+
await delay(Math.min(25, Math.max(1, deadline - Date.now())));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
let stamped = false;
|
|
104
|
+
try {
|
|
105
|
+
await handle.writeFile(token);
|
|
106
|
+
stamped = true;
|
|
107
|
+
return await operation();
|
|
108
|
+
} finally {
|
|
109
|
+
await handle.close();
|
|
110
|
+
// Never unlink a lock that somebody replaced while we were working.
|
|
111
|
+
if (!stamped || await io.readFile(lockPath, "utf8").catch(() => undefined) === token) await io.rm(lockPath, { force: true });
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const publish = async state => {
|
|
115
|
+
const temp = await writeTempFile(path, JSON.stringify(state, null, 2) + "\n", io);
|
|
116
|
+
try {
|
|
117
|
+
// Replace directly: a failed rename leaves the old file intact; readers
|
|
118
|
+
// never see a backup/publication gap or a half-written JSON document.
|
|
119
|
+
await io.rename(temp, path);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
try { await io.rm(temp, { force: true }); } catch { /* uncommitted temp only */ }
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
return {
|
|
126
|
+
path,
|
|
127
|
+
async list() { return (await read()).sessions; },
|
|
128
|
+
update(id, patch, expectedRevision) {
|
|
129
|
+
assertSessionId(id);
|
|
130
|
+
const normalized = normalizeAnnotationPatch(patch);
|
|
131
|
+
if (expectedRevision !== undefined && (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0)) throw fail("标记版本号无效");
|
|
132
|
+
return enqueue(() => withLock(async () => {
|
|
133
|
+
const state = await read();
|
|
134
|
+
const previous = Object.hasOwn(state.sessions, id) ? state.sessions[id] : DEFAULT_ANNOTATION;
|
|
135
|
+
if (expectedRevision !== undefined && expectedRevision !== previous.revision) {
|
|
136
|
+
throw fail("标记已被其他窗口修改,请载入最新内容后再保存;当前输入尚未丢弃", "annotation-conflict");
|
|
137
|
+
}
|
|
138
|
+
if (Object.keys(normalized).every(key => JSON.stringify(previous[key]) === JSON.stringify(normalized[key]))) return { ...previous };
|
|
139
|
+
const next = { ...previous, ...normalized, revision: previous.revision + 1, updatedAt: Date.now() };
|
|
140
|
+
if (!Number.isSafeInteger(next.revision)) throw fail("标记版本号已超出范围", "annotations-unavailable");
|
|
141
|
+
await publish({ version: 1, sessions: { ...state.sessions, [id]: next } });
|
|
142
|
+
return next;
|
|
143
|
+
}));
|
|
144
|
+
},
|
|
145
|
+
async remove(id) {
|
|
146
|
+
assertSessionId(id);
|
|
147
|
+
// A plugin that has never used annotations should not create a directory
|
|
148
|
+
// just because an ordinary session was deleted.
|
|
149
|
+
if (!Object.hasOwn((await read()).sessions, id)) return false;
|
|
150
|
+
return enqueue(() => withLock(async () => {
|
|
151
|
+
const state = await read();
|
|
152
|
+
if (!Object.hasOwn(state.sessions, id)) return false;
|
|
153
|
+
const sessions = { ...state.sessions }; delete sessions[id];
|
|
154
|
+
await publish({ version: 1, sessions });
|
|
155
|
+
return true;
|
|
156
|
+
}));
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|