@chaoset/session-archive 0.1.0

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/README.md ADDED
@@ -0,0 +1,66 @@
1
+ # @chaoset/session-archive
2
+
3
+ DSH 插件:归档会话管理 —— 补上 DSH 缺失的"归档后半程"。
4
+
5
+ web 侧边栏底部新增 **归档** 面板(🗂):
6
+
7
+ - **查看归档**:列出全部归档会话(标题、目录、创建/最后修改时间、体积、
8
+ 运行状态),点击任意会话可展开**只读浏览**其完整聊天内容(用户/助手文本
9
+ 消息)。
10
+ - **一次多选批量操作**:
11
+ - **恢复归档**(unarchive):把勾选的会话移回会话树,恢复其在原工作区的
12
+ 位置(归档时保留的 slot 会计不被破坏)。
13
+ - **彻底删除**(delete):删除勾选会话的持久化文件与归档记录。删除为
14
+ 两段式确认(第一次点击进入确认态,4 秒内再次点击执行),避免误删。
15
+ - 工具条提供**全选**、已选计数;**运行中**(live)的会话显示黄色徽标且
16
+ 禁止勾选删除(请先停止会话);列表随会话树变化自动可刷新(⟳)。
17
+
18
+ ## 安装
19
+
20
+ ```bash
21
+ dsh plugin --profile web add @chaoset/session-archive
22
+ ```
23
+
24
+ 重启 web profile 后,侧边栏底部出现"归档"按钮。插件由两部分组成:host
25
+ 插件(归档读写/删除逻辑)与 web 客户端(面板 UI),随 `dsh.bundle.patch`
26
+ 自动激活。
27
+
28
+ ## 工作原理
29
+
30
+ - **列表**:`workspaceRegistry.archivedSessionIds` ∩ `sessionPersistence.list()`,
31
+ 标题从会话事件流折叠(最后一个 `session/title` 事件,与 dsh-session-title
32
+ 同规则);文件信息来自 `sessionPersistence.locate()` + `stat`。
33
+ - **查看**:`sessionPersistence.readFrom(id, 0)` 只读解析会话事件,提取
34
+ 文本消息(`user/message` / `assistant/message` 的 text 块),不做任何
35
+ 写入/修复。
36
+ - **删除**:live 会话拒绝;每个会话删除持久化文件与会话目录(`locate()`
37
+ 定位);最后从归档集合移除。归档集合没有官方移除 API,插件复用 registry
38
+ 自身的串行化写入通道(`enqueueOperation → requireState → setState`,
39
+ 与 `archiveSession` 同一路径);若内部形状变化会自动降级为"仅删文件",
40
+ 归档列表按文件存在性过滤,功能不受影响。
41
+ - **恢复**:仅从归档集合移除 id,会话数据不动,恢复后回到原工作区位置。
42
+
43
+ ## Remote API(`ctx.remote.sessionArchive`)
44
+
45
+ | 方法 | 参数 | 返回 |
46
+ | --- | --- | --- |
47
+ | `list()` | — | `{ items: ArchiveRow[] }` |
48
+ | `detail(sessionId)` | 会话 id | `{ sessionId, header, title, messageCount, messages, live }` |
49
+ | `delete(sessionIds[])` | id 数组 | `{ deleted, failed, removedFromArchive }` |
50
+ | `unarchive(sessionIds[])` | id 数组 | `{ restored, removedFromArchive }` |
51
+
52
+ `ArchiveRow`:`{ sessionId, title, cwd, createdAt, updatedAt, size, live }`。
53
+
54
+ ## 配置
55
+
56
+ config 字段(`cordis.patch.yml` 或 `~/.dsh/plugins/session-archive/config.json`):
57
+
58
+ - `detailMaxMessages`(默认 200):查看时返回的最大消息条数。
59
+ - `messagePreviewChars`(默认 2000):单条消息预览的最大字符数。
60
+ - `titleReadConcurrency`(默认 4):列表加载时并发读取标题的并行度。
61
+
62
+ ## 限制
63
+
64
+ - 运行中(live)的会话无法删除 —— 先停止会话再删除。
65
+ - 无官方 unarchive API,恢复归档通过 registry 写入通道实现;若未来 DSH
66
+ 提供官方 API,插件会切换过去(行为不变)。
@@ -0,0 +1,450 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@chaoset/session-archive",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ let react = require("react");
7
+
8
+ // ── 样式(注入 style 标签,复用 DSH 设计变量)────────────────────────
9
+ var css = [
10
+ ".sa_badge{width:100%;height:49px;color:var(--dsw-alias-label-primary);cursor:pointer;background:0 0;border:none;border-radius:12px;align-items:center;gap:8px;padding:0 8px 0 6px;font-family:inherit;font-size:14px;display:inline-flex;overflow:hidden}",
11
+ ".sa_badge:hover{background:var(--dsw-alias-interactive-bg-hover)}",
12
+ ".sa_badgeIcon{flex:none;font-size:15px;line-height:20px}",
13
+ ".sa_badgeLabel{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}",
14
+ ".sa_badgeCount{color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;flex:none;margin-left:auto;font-size:12px;line-height:16px}",
15
+ ".sa_panel{z-index:30;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-base);width:440px;max-width:calc(100vw - 24px);max-height:62vh;box-shadow:var(--dsw-shadow-lv2);--dsh-scrollbar-thumb:var(--dsw-alias-scrollbar-bg-l2);--dsh-scrollbar-thumb-hover:var(--dsw-alias-scrollbar-hover-l2);border-radius:12px;flex-direction:column;display:flex;position:fixed;bottom:128px;left:12px;overflow:hidden}",
16
+ ".sa_header{box-sizing:border-box;border-bottom:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);flex:none;justify-content:space-between;align-items:center;min-height:44px;padding:8px 12px;display:flex}",
17
+ ".sa_title{color:var(--dsw-alias-label-primary);font-size:13px;font-weight:500;line-height:20px}",
18
+ ".sa_iconBtn{font:inherit;cursor:pointer;border:0;border-radius:8px;width:36px;height:36px;color:var(--dsw-alias-label-secondary,#666);background:0 0;display:inline-flex;align-items:center;justify-content:center;font-size:18px}",
19
+ ".sa_refresh{font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;min-height:36px;padding:5px 14px;font-size:13px;line-height:20px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary);display:inline-flex;align-items:center;gap:5px}",
20
+ ".sa_refresh:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}",
21
+ ".sa_refresh:disabled{opacity:.4;cursor:default}",
22
+ ".sa_iconBtn:hover{background:var(--dsw-alias-interactive-bg-hover)}",
23
+ ".sa_iconBtn:disabled{opacity:.4;cursor:default}",
24
+ ".sa_toolbar{flex:none;border-bottom:1px solid var(--dsw-alias-border-l2);align-items:center;gap:8px;padding:8px 12px;display:flex;flex-wrap:wrap}",
25
+ ".sa_check{accent-color:var(--dsw-alias-label-primary);width:14px;height:14px;flex:none;cursor:pointer}",
26
+ ".sa_check:disabled{cursor:default;opacity:.45}",
27
+ ".sa_toolLabel{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;user-select:none;cursor:pointer;display:inline-flex;align-items:center;gap:6px}",
28
+ ".sa_count{color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;flex:1}",
29
+ ".sa_action{font:inherit;cursor:pointer;border:1px solid var(--dsw-alias-border-l1);border-radius:6px;padding:3px 10px;font-size:12px;line-height:18px;background:var(--dsw-alias-bg-base);color:var(--dsw-alias-label-primary)}",
30
+ ".sa_action:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}",
31
+ ".sa_action:disabled{opacity:.4;cursor:default}",
32
+ ".sa_actionDanger{border-color:transparent;background:var(--dsw-alias-state-error-primary);color:#fff}",
33
+ ".sa_actionDanger:hover:not(:disabled){background:var(--dsw-alias-state-error-primary)}",
34
+ ".sa_actionDanger:disabled{opacity:.4}",
35
+ ".sa_confirm{color:var(--dsw-alias-state-error-primary);border:1px solid var(--dsw-alias-state-error-primary);background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 10%,transparent)}",
36
+ ".sa_body{flex:1;min-height:0;padding:4px 12px 12px;overflow-y:auto}",
37
+ ".sa_empty{color:var(--dsw-alias-label-tertiary);margin:24px 0;text-align:center;font-size:12px;line-height:18px}",
38
+ ".sa_error{color:var(--dsw-alias-state-error-primary);margin:8px 0;font-size:12px;line-height:18px}",
39
+ ".sa_rows{flex-direction:column;gap:8px;margin:0;padding:0;list-style:none;display:flex}",
40
+ ".sa_row{border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-base);border-radius:12px;flex-direction:column;gap:6px;padding:8px 10px;display:flex}",
41
+ ".sa_rowHead{align-items:center;gap:8px;display:flex}",
42
+ ".sa_rowTitle{min-width:0;color:var(--dsw-alias-label-primary);text-overflow:ellipsis;white-space:nowrap;flex:1;font-size:13px;font-weight:500;line-height:20px;overflow:hidden;cursor:pointer}",
43
+ ".sa_rowTitle:hover{text-decoration:underline}",
44
+ ".sa_live{background:var(--dsw-alias-state-warn-tertiary);color:var(--dsw-alias-state-warn-label);height:18px;border-radius:9px;flex:none;align-items:center;padding:0 6px;font-size:11px;line-height:18px;display:inline-flex}",
45
+ ".sa_rowMeta{color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;overflow-wrap:anywhere}",
46
+ ".sa_rowMeta code{font-family:var(--dsh-font-mono,monospace)}",
47
+ ".sa_rowFoot{justify-content:space-between;align-items:center;gap:8px;display:flex}",
48
+ ".sa_rowActions{flex:none;align-items:center;gap:8px;display:flex}",
49
+ ".sa_detail{border-top:1px dashed var(--dsw-alias-border-l2);padding-top:8px;flex-direction:column;gap:6px;display:flex;max-height:260px;overflow-y:auto}",
50
+ ".sa_msg{flex-direction:column;gap:2px;display:flex}",
51
+ ".sa_msgRole{color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px;text-transform:uppercase;letter-spacing:.04em}",
52
+ ".sa_msgText{color:var(--dsw-alias-label-primary);white-space:pre-wrap;overflow-wrap:anywhere;font-size:12px;line-height:18px}",
53
+ ".sa_msgTextUser{color:var(--dsw-alias-label-secondary)}",
54
+ ".sa_busy{opacity:.55;pointer-events:none}"
55
+ ].join("");
56
+ var tagId = "@chaoset/session-archive/client.css";
57
+ if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=\"" + tagId + "\"]") === null) {
58
+ var tag = document.createElement("style");
59
+ tag.dataset.plugin = "@chaoset/session-archive";
60
+ tag.dataset.pluginCss = tagId;
61
+ tag.textContent = css;
62
+ document.head.appendChild(tag);
63
+ }
64
+
65
+ // ── 字典 ─────────────────────────────────────────────────────────────
66
+ const NS = "sidebar.sessionArchive";
67
+ const zh = {
68
+ badge: "归档",
69
+ panelTitle: "归档会话",
70
+ refresh: "刷新",
71
+ close: "关闭",
72
+ empty: "暂无归档会话。在会话列表的更多菜单中归档会话后会出现在这里。",
73
+ loadFailed: "加载归档列表失败",
74
+ selectAll: "全选",
75
+ selected: "已选 {n} 项",
76
+ restore: "恢复所选",
77
+ restoreDone: "已恢复 {n} 个会话",
78
+ restoreFailed: "恢复失败",
79
+ delete: "删除所选",
80
+ deleteConfirm: "再次点击确认删除",
81
+ deleteDone: "已删除 {n} 个会话",
82
+ deleteFailed: "部分删除失败:{n} 个",
83
+ confirmAll: "确认删除全部 {n} 个?",
84
+ noSelection: "请先勾选会话",
85
+ view: "查看",
86
+ collapse: "收起",
87
+ live: "运行中",
88
+ detailLoadFailed: "读取会话内容失败",
89
+ noneTitle: "(无标题)",
90
+ user: "用户",
91
+ assistant: "助手",
92
+ messages: "共 {n} 条消息",
93
+ noMessages: "(无文本消息)",
94
+ sizeBytes: "{n} B",
95
+ sizeKB: "{n} KB",
96
+ sizeMB: "{n} MB",
97
+ runningHint: "运行中的会话不能删除,请先停止"
98
+ };
99
+ const en = {
100
+ badge: "Archive",
101
+ panelTitle: "Archived sessions",
102
+ refresh: "Refresh",
103
+ close: "Close",
104
+ empty: "No archived sessions. Archive a session from its menu in the session list and it will show up here.",
105
+ loadFailed: "Failed to load archive list",
106
+ selectAll: "Select all",
107
+ selected: "{n} selected",
108
+ restore: "Restore",
109
+ restoreDone: "Restored {n} sessions",
110
+ restoreFailed: "Restore failed",
111
+ delete: "Delete",
112
+ deleteConfirm: "Click again to confirm delete",
113
+ deleteDone: "Deleted {n} sessions",
114
+ deleteFailed: "Some deletions failed: {n}",
115
+ confirmAll: "Delete all {n}?",
116
+ noSelection: "Select sessions first",
117
+ view: "View",
118
+ collapse: "Collapse",
119
+ live: "Running",
120
+ detailLoadFailed: "Failed to read session content",
121
+ noneTitle: "(no title)",
122
+ user: "User",
123
+ assistant: "Assistant",
124
+ messages: "{n} messages",
125
+ noMessages: "(no text messages)",
126
+ sizeBytes: "{n} B",
127
+ sizeKB: "{n} KB",
128
+ sizeMB: "{n} MB",
129
+ runningHint: "Running sessions cannot be deleted; stop them first"
130
+ };
131
+
132
+ // ── 工具函数 ─────────────────────────────────────────────────────────
133
+ function formatBytes(bytes, t) {
134
+ if (bytes < 1024) return t("sizeBytes").replace("{n}", String(bytes));
135
+ if (bytes < 1024 * 1024) return t("sizeKB").replace("{n}", (bytes / 1024).toFixed(1));
136
+ return t("sizeMB").replace("{n}", (bytes / (1024 * 1024)).toFixed(1));
137
+ }
138
+ function formatTime(ms) {
139
+ try { return new Date(ms).toLocaleString(); } catch { return String(ms); }
140
+ }
141
+ function shortId(id) {
142
+ return id.length > 12 ? id.slice(0, 12) + "…" : id;
143
+ }
144
+
145
+ // ── 归档面板 ─────────────────────────────────────────────────────────
146
+ function ArchivePanel(props) {
147
+ const t = props.t;
148
+ const call = props.call;
149
+ const rootRef = react.useRef(null);
150
+ const [open, setOpen] = react.useState(false);
151
+ const [items, setItems] = react.useState([]);
152
+ const [loading, setLoading] = react.useState(false);
153
+ const [error, setError] = react.useState(null);
154
+ const [selected, setSelected] = react.useState(new Set());
155
+ const [expanded, setExpanded] = react.useState(null);
156
+ const [details, setDetails] = react.useState(new Map());
157
+ const [detailLoading, setDetailLoading] = react.useState(new Set());
158
+ const [busy, setBusy] = react.useState(false);
159
+ const [confirmingDelete, setConfirmingDelete] = react.useState(false);
160
+ const [notice, setNotice] = react.useState(null);
161
+
162
+ const load = react.useCallback(async () => {
163
+ setLoading(true);
164
+ setError(null);
165
+ try {
166
+ const result = await call("list");
167
+ setItems(Array.isArray(result.items) ? result.items : []);
168
+ } catch (loadError) {
169
+ setError(t("loadFailed") + ": " + (loadError && loadError.message || loadError));
170
+ } finally {
171
+ setLoading(false);
172
+ }
173
+ }, [call, t]);
174
+
175
+ react.useEffect(() => {
176
+ if (open) load();
177
+ }, [open, load]);
178
+
179
+ react.useEffect(() => {
180
+ if (!open) return;
181
+ const onPointerDown = (event) => {
182
+ if (rootRef.current !== null && !rootRef.current.contains(event.target)) {
183
+ setOpen(false);
184
+ }
185
+ };
186
+ document.addEventListener("pointerdown", onPointerDown);
187
+ return () => document.removeEventListener("pointerdown", onPointerDown);
188
+ }, [open]);
189
+
190
+ const selectable = items.filter((item) => !item.live);
191
+ const allSelected = selectable.length > 0 && selectable.every((item) => selected.has(item.sessionId));
192
+
193
+ const toggleAll = (checked) => {
194
+ if (checked) {
195
+ setSelected(new Set(selectable.map((item) => item.sessionId)));
196
+ } else {
197
+ setSelected(new Set());
198
+ }
199
+ };
200
+ const toggleOne = (sessionId, checked) => {
201
+ setSelected((current) => {
202
+ const next = new Set(current);
203
+ if (checked) next.add(sessionId); else next.delete(sessionId);
204
+ return next;
205
+ });
206
+ };
207
+
208
+ const toggleDetail = (item) => {
209
+ if (expanded === item.sessionId) {
210
+ setExpanded(null);
211
+ return;
212
+ }
213
+ setExpanded(item.sessionId);
214
+ if (!details.has(item.sessionId)) {
215
+ setDetailLoading((current) => new Set(current).add(item.sessionId));
216
+ call("detail", item.sessionId).then((detail) => {
217
+ setDetails((current) => new Map(current).set(item.sessionId, detail));
218
+ }).catch((detailError) => {
219
+ setDetails((current) => new Map(current).set(item.sessionId, {
220
+ error: t("detailLoadFailed") + ": " + (detailError && detailError.message || detailError)
221
+ }));
222
+ }).finally(() => {
223
+ setDetailLoading((current) => {
224
+ const next = new Set(current);
225
+ next.delete(item.sessionId);
226
+ return next;
227
+ });
228
+ });
229
+ }
230
+ };
231
+
232
+ const runBatch = async (action, doneKey, failKey) => {
233
+ const ids = [...selected];
234
+ if (ids.length === 0) {
235
+ setNotice({ kind: "warn", text: t("noSelection") });
236
+ return;
237
+ }
238
+ setBusy(true);
239
+ setNotice(null);
240
+ try {
241
+ const result = await call(action, ids);
242
+ const doneIds = result.deleted || result.restored || [];
243
+ const n = doneIds.length;
244
+ if (result.failed && result.failed.length > 0) {
245
+ setNotice({ kind: "error", text: t(failKey).replace("{n}", String(result.failed.length)) });
246
+ } else {
247
+ setNotice({ kind: "ok", text: t(doneKey).replace("{n}", String(n)) });
248
+ }
249
+ if (doneIds.length > 0) {
250
+ const done = new Set(doneIds);
251
+ setItems((current) => current.filter((item) => !done.has(item.sessionId)));
252
+ }
253
+ setSelected(new Set());
254
+ setConfirmingDelete(false);
255
+ await load();
256
+ } catch (actionError) {
257
+ setNotice({ kind: "error", text: t(failKey).replace("{n}", "?") + ": " + (actionError && actionError.message || actionError) });
258
+ } finally {
259
+ setBusy(false);
260
+ }
261
+ };
262
+ const restoreSelected = () => runBatch("unarchive", "restoreDone", "restoreFailed");
263
+ const deleteSelected = () => {
264
+ if (!confirmingDelete) {
265
+ setConfirmingDelete(true);
266
+ window.setTimeout(() => setConfirmingDelete(false), 4000);
267
+ return;
268
+ }
269
+ runBatch("delete", "deleteDone", "deleteFailed");
270
+ };
271
+
272
+ const hasSelection = selected.size > 0;
273
+
274
+ return react.createElement(
275
+ "div",
276
+ { className: "sa_root", ref: rootRef },
277
+ react.createElement(
278
+ "button",
279
+ {
280
+ className: "sa_badge",
281
+ type: "button",
282
+ onClick: () => setOpen(!open),
283
+ "aria-expanded": open,
284
+ title: t("badge")
285
+ },
286
+ react.createElement("span", { className: "sa_badgeIcon", "aria-hidden": true }, "🗂"),
287
+ react.createElement("span", { className: "sa_badgeLabel" }, t("badge")),
288
+ react.createElement("span", { className: "sa_badgeCount" }, String(items.length))
289
+ ),
290
+ open ? react.createElement(
291
+ "div",
292
+ { className: "sa_panel" },
293
+ react.createElement(
294
+ "div",
295
+ { className: "sa_header" },
296
+ react.createElement("span", { className: "sa_title" }, t("panelTitle")),
297
+ react.createElement(
298
+ "span",
299
+ { style: { display: "inline-flex", gap: "6px", alignItems: "center" } },
300
+ react.createElement("button", { className: "sa_refresh", type: "button", title: t("refresh"), disabled: busy || loading, onClick: load }, t("refresh")),
301
+ react.createElement("button", { className: "sa_iconBtn", type: "button", title: t("close"), disabled: busy, onClick: () => setOpen(false) }, "✕")
302
+ )
303
+ ),
304
+ react.createElement(
305
+ "div",
306
+ { className: "sa_toolbar" },
307
+ react.createElement("label", { className: "sa_toolLabel" },
308
+ react.createElement("input", {
309
+ className: "sa_check",
310
+ type: "checkbox",
311
+ checked: allSelected,
312
+ disabled: busy || selectable.length === 0,
313
+ onChange: (e) => toggleAll(e.target.checked)
314
+ }),
315
+ t("selectAll")
316
+ ),
317
+ react.createElement("span", { className: "sa_count" }, t("selected").replace("{n}", String(selected.size))),
318
+ react.createElement("button", {
319
+ className: "sa_action",
320
+ type: "button",
321
+ disabled: busy || !hasSelection,
322
+ onClick: restoreSelected
323
+ }, t("restore")),
324
+ react.createElement("button", {
325
+ className: "sa_action " + (confirmingDelete ? "sa_actionDanger sa_confirm" : "sa_actionDanger"),
326
+ type: "button",
327
+ disabled: busy || !hasSelection,
328
+ onClick: deleteSelected
329
+ }, confirmingDelete ? t("deleteConfirm") : t("delete"))
330
+ ),
331
+ react.createElement(
332
+ "div",
333
+ { className: "sa_body" },
334
+ notice !== null ? react.createElement("p", { className: "sa_error" + (notice.kind === "ok" ? "" : ""), role: "status" }, notice.text) : null,
335
+ error !== null ? react.createElement("p", { className: "sa_error", role: "alert" }, error) : null,
336
+ loading && items.length === 0 ? react.createElement("p", { className: "sa_empty" }, "…") : null,
337
+ !loading && items.length === 0 && error === null ? react.createElement("p", { className: "sa_empty" }, t("empty")) : null,
338
+ items.length > 0 ? react.createElement(
339
+ "ul",
340
+ { className: "sa_rows" },
341
+ items.map((item) => {
342
+ const isExpanded = expanded === item.sessionId;
343
+ const detail = details.get(item.sessionId);
344
+ const detailPending = detailLoading.has(item.sessionId);
345
+ return react.createElement(
346
+ "li",
347
+ { className: "sa_row" + (busy ? " sa_busy" : ""), key: item.sessionId },
348
+ react.createElement(
349
+ "div",
350
+ { className: "sa_rowHead" },
351
+ react.createElement("input", {
352
+ className: "sa_check",
353
+ type: "checkbox",
354
+ checked: selected.has(item.sessionId),
355
+ disabled: busy || item.live,
356
+ title: item.live ? t("runningHint") : void 0,
357
+ onChange: (e) => toggleOne(item.sessionId, e.target.checked)
358
+ }),
359
+ react.createElement(
360
+ "span",
361
+ { className: "sa_rowTitle", onClick: () => toggleDetail(item), title: t("view") },
362
+ item.title !== null && item.title !== void 0 && item.title !== "" ? item.title : t("noneTitle")
363
+ ),
364
+ item.live ? react.createElement("span", { className: "sa_live" }, t("live")) : null
365
+ ),
366
+ react.createElement("div", { className: "sa_rowMeta" },
367
+ react.createElement("span", null, formatTime(item.updatedAt)),
368
+ item.cwd !== null && item.cwd !== void 0 ? react.createElement("span", null, " · ", react.createElement("code", null, item.cwd)) : null,
369
+ react.createElement("span", null, " · ", formatBytes(item.size, t))
370
+ ),
371
+ react.createElement(
372
+ "div",
373
+ { className: "sa_rowFoot" },
374
+ react.createElement("span", { className: "sa_rowMeta" },
375
+ react.createElement("code", null, shortId(item.sessionId)),
376
+ detail !== void 0 && !detail.error && detail.messageCount !== void 0
377
+ ? " · " + t("messages").replace("{n}", String(detail.messageCount))
378
+ : null
379
+ ),
380
+ react.createElement(
381
+ "div",
382
+ { className: "sa_rowActions" },
383
+ react.createElement("button", {
384
+ className: "sa_action",
385
+ type: "button",
386
+ disabled: busy || detailPending,
387
+ onClick: () => toggleDetail(item)
388
+ }, isExpanded ? t("collapse") : t("view"))
389
+ )
390
+ ),
391
+ isExpanded ? react.createElement(
392
+ "div",
393
+ { className: "sa_detail" },
394
+ detailPending ? react.createElement("p", { className: "sa_empty" }, "…") : null,
395
+ detail === void 0 ? null :
396
+ detail.error !== void 0 ? react.createElement("p", { className: "sa_error" }, detail.error) :
397
+ detail.messages.length === 0 ? react.createElement("p", { className: "sa_empty" }, t("noMessages")) :
398
+ detail.messages.map((message, index) => react.createElement(
399
+ "div",
400
+ { className: "sa_msg", key: index },
401
+ react.createElement("span", { className: "sa_msgRole" }, message.role === "user" ? t("user") : t("assistant") + " · " + formatTime(message.time)),
402
+ react.createElement("span", { className: "sa_msgText" + (message.role === "user" ? " sa_msgTextUser" : "") }, message.text)
403
+ ))
404
+ ) : null
405
+ );
406
+ })
407
+ ) : null
408
+ )
409
+ ) : null
410
+ );
411
+ }
412
+
413
+ // ── 插件 apply ───────────────────────────────────────────────────────
414
+ // DSH 客户端的 remote.<ns> 服务不会自动生成:必须由客户端代码用
415
+ // ctx.remote.$mount(contribution) 显式挂载(官方 dsh-api-remotes 即如此)。
416
+ const inject = ["slots", "locale", "remote"];
417
+ const passthroughSchema = { parse: (value) => value };
418
+ const REMOTE_CONTRIBUTION = {
419
+ package: "@chaoset/session-archive",
420
+ descriptors: [
421
+ { id: "@chaoset/session-archive#sessionArchive/list", service: "sessionArchive", namespace: "sessionArchive", method: "list", invocation: { kind: "direct" }, parameters: [], result: { mode: "strict", typeSymbol: "sessionArchive/list:result", schema: passthroughSchema } },
422
+ { id: "@chaoset/session-archive#sessionArchive/detail", service: "sessionArchive", namespace: "sessionArchive", method: "detail", invocation: { kind: "direct" }, parameters: [{ name: "sessionId", wire: "sessionId", source: "json", codec: { mode: "strict", typeSymbol: "sessionArchive/detail:sessionId", schema: passthroughSchema } }], result: { mode: "strict", typeSymbol: "sessionArchive/detail:result", schema: passthroughSchema } },
423
+ { id: "@chaoset/session-archive#sessionArchive/delete", service: "sessionArchive", namespace: "sessionArchive", method: "delete", invocation: { kind: "direct" }, parameters: [{ name: "sessionIds", wire: "sessionIds", source: "json", codec: { mode: "strict", typeSymbol: "sessionArchive/delete:sessionIds", schema: passthroughSchema } }], result: { mode: "strict", typeSymbol: "sessionArchive/delete:result", schema: passthroughSchema } },
424
+ { id: "@chaoset/session-archive#sessionArchive/unarchive", service: "sessionArchive", namespace: "sessionArchive", method: "unarchive", invocation: { kind: "direct" }, parameters: [{ name: "sessionIds", wire: "sessionIds", source: "json", codec: { mode: "strict", typeSymbol: "sessionArchive/unarchive:sessionIds", schema: passthroughSchema } }], result: { mode: "strict", typeSymbol: "sessionArchive/unarchive:result", schema: passthroughSchema } }
425
+ ]
426
+ };
427
+ async function apply(ctx) {
428
+ const t = ctx.locale.bind(NS);
429
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), "session-archive: dictionaries");
430
+ await ctx.remote.$mount(REMOTE_CONTRIBUTION);
431
+ const archiveService = ctx.get("remote.sessionArchive");
432
+ if (archiveService === void 0) throw new Error("session-archive: remote.sessionArchive did not materialize after mount");
433
+ const call = (method, ...args) => archiveService[method](...args).then((result) => {
434
+ if (!result.ok) throw new Error(method + " failed: " + result.error.code + ": " + result.error.message);
435
+ return result.value;
436
+ });
437
+ ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register({
438
+ name: "sidebar.footer.action",
439
+ id: "session-archive",
440
+ order: 20,
441
+ locale: NS,
442
+ inject: () => ({ call })
443
+ }, ArchivePanel));
444
+ }
445
+
446
+ exports.apply = apply;
447
+ exports.inject = inject;
448
+ return module.exports;
449
+ }
450
+ });
@@ -0,0 +1,14 @@
1
+ # @chaoset/session-archive bundle patch
2
+ #
3
+ # Installed automatically by:
4
+ # dsh plugin --profile <name> add @chaoset/session-archive
5
+ #
6
+ # This patch inserts the host plugin row. The package is also a normal npm
7
+ # dependency of the profile; the `dsh.bundle.patch` manifest field makes DSH
8
+ # treat it as a profile bundle layer. The web client part (sidebar archive
9
+ # panel) activates automatically once the bundle is installed.
10
+
11
+ - insert:
12
+ - id: session-archive
13
+ name: '@chaoset/session-archive'
14
+ config: {}
@@ -0,0 +1,97 @@
1
+ /**
2
+ * config-store — 插件配置持久化
3
+ *
4
+ * 配置文件位于 $DSH_HOME/plugins/<name>/config.json(默认 ~/.dsh,与
5
+ * file:// 部署模式共用)。生效顺序(后者覆盖前者):
6
+ * 1. 插件内置默认值(DEFAULT_CONFIG)
7
+ * 2. cordis.patch.yml 传入的 config(安装时生成的默认块)
8
+ * 3. config.json(设置页 UI 保存,权威)
9
+ *
10
+ * 保存后通过 onUpdate 回调立即热更新运行中的配置(包装闭包读共享 state)。
11
+ * 写入采用随机临时文件 + fsync + rename,权限 0600;损坏 JSON 只告警并回退。
12
+ */
13
+
14
+ import { randomUUID } from "node:crypto";
15
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
16
+ import { dirname, join, resolve } from "node:path";
17
+ import { homedir } from "node:os";
18
+
19
+ /**
20
+ * 创建配置存储。
21
+ * @param options - { name, defaults, patchConfig, onUpdate,
22
+ * validate?: (partial) => void, warn?: (message) => void }
23
+ */
24
+ export function createConfigStore(options) {
25
+ // 与 harness 的 DSH_HOME 约定保持一致:默认 ~/.dsh,可用 $DSH_HOME 覆盖。
26
+ const dshHome = process.env.DSH_HOME?.trim() ? resolve(process.env.DSH_HOME) : join(homedir(), ".dsh");
27
+ const file = join(dshHome, "plugins", options.name, "config.json");
28
+ const warn = options.warn ?? (() => {});
29
+ let readWarningShown = false;
30
+
31
+ function readJson() {
32
+ try {
33
+ if (!existsSync(file)) return {};
34
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
35
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
36
+ readWarningShown = false;
37
+ return parsed;
38
+ }
39
+ if (!readWarningShown) {
40
+ readWarningShown = true;
41
+ warn(`config file ${file} must contain a JSON object; using empty config`);
42
+ }
43
+ return {};
44
+ } catch (error) {
45
+ if (!readWarningShown) {
46
+ readWarningShown = true;
47
+ warn(`failed to read config file ${file}: ${error?.message || error}; using empty config`);
48
+ }
49
+ return {};
50
+ }
51
+ }
52
+
53
+ /** 当前生效配置(默认 + patch + json 合并)。 */
54
+ function effective() {
55
+ return { ...options.defaults, ...options.patchConfig, ...readJson() };
56
+ }
57
+
58
+ /** 保存部分配置到 config.json 并触发热更新,返回新的生效配置。 */
59
+ function set(partial) {
60
+ if (partial === null || typeof partial !== "object" || Array.isArray(partial)) {
61
+ throw new TypeError("set expects a plain config object");
62
+ }
63
+ options.validate?.(partial);
64
+ const merged = { ...readJson(), ...partial };
65
+ mkdirSync(dirname(file), { recursive: true });
66
+
67
+ const tmp = `${file}.${process.pid}.${randomUUID()}.tmp`;
68
+ let fd;
69
+ try {
70
+ fd = openSync(tmp, "w", 0o600);
71
+ writeFileSync(fd, JSON.stringify(merged, null, 2) + "\n", "utf8");
72
+ fsyncSync(fd);
73
+ closeSync(fd);
74
+ fd = void 0;
75
+ renameSync(tmp, file);
76
+ // POSIX 目录项持久化(Windows 上目录句柄不可打开时忽略)。
77
+ try {
78
+ const dir = openSync(dirname(file), "r");
79
+ try { fsyncSync(dir); } finally { closeSync(dir); }
80
+ } catch {}
81
+ } catch (error) {
82
+ if (fd !== void 0) try { closeSync(fd); } catch {}
83
+ try { unlinkSync(tmp); } catch {}
84
+ throw error;
85
+ }
86
+
87
+ const next = { ...options.defaults, ...options.patchConfig, ...merged };
88
+ try {
89
+ options.onUpdate?.(merged, next);
90
+ } catch (error) {
91
+ warn(`onUpdate failed after config save: ${error?.message || error}`);
92
+ }
93
+ return next;
94
+ }
95
+
96
+ return { file, effective, set };
97
+ }
package/lib/index.mjs ADDED
@@ -0,0 +1,267 @@
1
+ /**
2
+ * session-archive — 归档会话管理插件(@chaoset/session-archive)
3
+ *
4
+ * 补上 DSH 缺失的"归档"后半程:web 侧边栏新增"归档"面板,可查看归档
5
+ * 会话(列表 + 会话内容只读浏览)、一次多选批量恢复归档(unarchive,
6
+ * 会话回到会话树原位置)或彻底删除归档(删除持久化文件与归档记录)。
7
+ *
8
+ * host 端全部逻辑基于官方 service 接口(workspaceRegistry /
9
+ * sessionPersistence / sessions),不依赖 dsh 内部实现:
10
+ * 1. list() — archivedSessionIds ∩ sessionPersistence.list(),
11
+ * 每条附带标题(从事件流折叠 session/title)、目录、
12
+ * 创建时间、最后修改时间(文件 mtime)、体积与
13
+ * live 状态(会话仍在内存中运行时禁止删除)。
14
+ * 2. detail(id) — readFrom(id, 0) 只读取会话事件:标题 + 文本消息
15
+ * (user/assistant),供面板"查看"展开。
16
+ * 3. delete(ids) — 批量彻底删除:live 会话拒绝;逐个删除持久化文件
17
+ * (locate() 定位)+ 会话目录;最后从归档集合移除。
18
+ * 4. unarchive(ids) — 批量恢复:仅从归档集合移除(会话数据不动)。
19
+ *
20
+ * 归档集合(workspaceRegistry.archivedSessionIds)的移除没有官方 API,
21
+ * 这里复用 registry 自身的串行化写入通道(enqueueOperation → requireState
22
+ * → setState,与 archiveSession 相同的路径);若 registry 内部形状变化,
23
+ * 自动降级为"仅删文件",归档列表会以存在性过滤幽灵 id,功能仍正确。
24
+ *
25
+ * 本文件不依赖任何 dsh 内部包(纯 ESM + ctx.* service),可独立安装。
26
+ */
27
+
28
+ import { stat, rm } from 'node:fs/promises';
29
+ import { dirname } from 'node:path';
30
+ import { createConfigStore } from './config-store.mjs';
31
+
32
+ // remote 服务(侧边栏面板 UI 的读写)可选:typert-protocol 不可用时
33
+ // 动态 import 失败,仅面板不可用,host 逻辑不注册(无其他消费者)。
34
+ let SessionArchiveGateway = null;
35
+ try {
36
+ ({ SessionArchiveGateway } = await import('./remote.mjs'));
37
+ } catch (error) {
38
+ console.warn('session-archive: remote gateway unavailable: ' + (error && error.message || error));
39
+ }
40
+
41
+ export const name = 'session-archive';
42
+
43
+ /** sessions 参与 inject:删除前必须能查询 live 会话(存在即拒绝删除)。 */
44
+ export const inject = ['workspaceRegistry', 'sessionPersistence', 'sessions'];
45
+
46
+ /** 默认配置。apply 时与 YAML 传入的 config 合并(cordis 不合并小写 config 导出)。 */
47
+ const DEFAULT_CONFIG = {
48
+ /** detail() 返回的最大消息条数(超出仅计数)。 */
49
+ detailMaxMessages: 200,
50
+ /** 每条消息预览的最大字符数(超出截断加省略号)。 */
51
+ messagePreviewChars: 2000,
52
+ /** list() 时并发读取标题的最大并行数。 */
53
+ titleReadConcurrency: 4,
54
+ };
55
+
56
+ export const config = { ...DEFAULT_CONFIG };
57
+
58
+ /** 并发限制器:最多 N 个任务并行,其余排队。 */
59
+ function limitedConcurrency(limit, tasks) {
60
+ const results = new Array(tasks.length);
61
+ let cursor = 0;
62
+ const workers = Array.from({ length: Math.min(limit, tasks.length) }, async () => {
63
+ while (cursor < tasks.length) {
64
+ const at = cursor++;
65
+ results[at] = await tasks[at]();
66
+ }
67
+ });
68
+ return Promise.all(workers).then(() => results);
69
+ }
70
+
71
+ /** 从事件流折叠最新会话标题(与 dsh-session-title 相同的折叠规则)。 */
72
+ function foldTitle(events) {
73
+ for (let i = events.length - 1; i >= 0; i--) {
74
+ const event = events[i];
75
+ if (event && event.type === 'session/title') {
76
+ const title = event.data && typeof event.data.title === 'string' ? event.data.title.trim() : '';
77
+ return title.length > 0 ? title : null;
78
+ }
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /** 从一条消息(Message 结构)提取纯文本(text 块拼接,忽略图片/工具块)。 */
84
+ function messageText(message, maxChars) {
85
+ if (!message || !Array.isArray(message.content)) return '';
86
+ let text = '';
87
+ for (const block of message.content) {
88
+ if (block && block.type === 'text' && typeof block.text === 'string') {
89
+ text += block.text;
90
+ if (text.length > maxChars) break;
91
+ }
92
+ }
93
+ return text.length > maxChars ? text.slice(0, maxChars) + '…' : text;
94
+ }
95
+
96
+ /**
97
+ * 构造归档管理 host 逻辑(绑定 ctx 与配置)。
98
+ * 只读操作失败各自容错:单个会话的标题/详情读取失败不拖垮列表。
99
+ */
100
+ export function createArchiveHost(ctx, cfg) {
101
+ const registry = ctx.workspaceRegistry;
102
+ const persistence = ctx.sessionPersistence;
103
+
104
+ /** 会话是否仍在内存中运行(live)。 */
105
+ const isLive = (sessionId) => ctx.sessions.get(sessionId) !== undefined;
106
+
107
+ /** 从归档集合移除若干 id(恢复/删除共用),返回实际移除的 id。 */
108
+ async function removeFromArchiveSet(ids) {
109
+ const set = new Set(ids);
110
+ const canWrite =
111
+ registry !== undefined &&
112
+ typeof registry.enqueueOperation === 'function' &&
113
+ typeof registry.requireState === 'function' &&
114
+ typeof registry.setState === 'function';
115
+ if (!canWrite) return []; // 降级:仅删文件,列表按存在性过滤幽灵 id
116
+ let removedIds = [];
117
+ await registry.enqueueOperation(async () => {
118
+ const state = registry.requireState();
119
+ const current = Array.isArray(state.archivedSessionIds) ? state.archivedSessionIds : [];
120
+ removedIds = current.filter((id) => set.has(id));
121
+ const remaining = current.filter((id) => !set.has(id));
122
+ if (removedIds.length > 0) await registry.setState({ ...state, archivedSessionIds: remaining });
123
+ });
124
+ return removedIds;
125
+ }
126
+
127
+ /** 归档会话的文件信息(路径 + stat),会话文件缺失时返回 null。 */
128
+ async function fileInfo(header) {
129
+ try {
130
+ const location = persistence.locate(header);
131
+ if (location === undefined || typeof location.path !== 'string' || location.path.length === 0) return null;
132
+ const info = await stat(location.path);
133
+ return { path: location.path, size: info.size, mtimeMs: info.mtimeMs };
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+
139
+ /** 单个归档会话的展示行。标题读取失败回退 null(面板显示目录名)。 */
140
+ async function rowFor(sessionId, header) {
141
+ const file = await fileInfo(header);
142
+ let title = null;
143
+ try {
144
+ const { events } = await persistence.readFrom(sessionId, 0);
145
+ title = foldTitle(events);
146
+ } catch {}
147
+ return {
148
+ sessionId,
149
+ title,
150
+ cwd: header.cwd ?? null,
151
+ createdAt: header.createdAt,
152
+ updatedAt: file !== null ? file.mtimeMs : header.createdAt,
153
+ size: file !== null ? file.size : 0,
154
+ live: isLive(sessionId),
155
+ };
156
+ }
157
+
158
+ return {
159
+ /** 列出全部归档会话(存在性过滤:文件已删的幽灵归档记录不显示)。 */
160
+ async list() {
161
+ const rawArchived = typeof registry.archivedSessionIds === 'function' ? registry.archivedSessionIds() : registry.archivedSessionIds;
162
+ const archived = Array.isArray(rawArchived) ? rawArchived : [];
163
+ if (archived.length === 0) return { items: [] };
164
+ const headers = await persistence.list();
165
+ const byId = new Map(headers.map((header) => [header.id, header]));
166
+ const rows = [];
167
+ for (const sessionId of archived) {
168
+ const header = byId.get(sessionId);
169
+ if (header === undefined) continue; // 幽灵 id:会话文件已不存在
170
+ rows.push({ sessionId, header });
171
+ }
172
+ const items = await limitedConcurrency(cfg.titleReadConcurrency, rows.map(({ sessionId, header }) => () => rowFor(sessionId, header)));
173
+ return { items };
174
+ },
175
+
176
+ /** 读取一个归档会话的只读详情(标题 + 文本消息)。 */
177
+ async detail(sessionId) {
178
+ const { meta, events } = await persistence.readFrom(sessionId, 0);
179
+ const title = foldTitle(events);
180
+ const messages = [];
181
+ for (const event of events) {
182
+ if (event.type !== 'user/message' && event.type !== 'assistant/message') continue;
183
+ const text = messageText(event.data, cfg.messagePreviewChars);
184
+ if (text.length === 0) continue;
185
+ messages.push({
186
+ role: event.type === 'user/message' ? 'user' : 'assistant',
187
+ text,
188
+ time: event.time,
189
+ });
190
+ if (messages.length >= cfg.detailMaxMessages) break;
191
+ }
192
+ return {
193
+ sessionId,
194
+ header: {
195
+ cwd: meta.cwd ?? null,
196
+ createdAt: meta.createdAt,
197
+ parentSession: meta.parentSession ?? null,
198
+ agentPreset: meta.agentPreset ?? null,
199
+ },
200
+ title,
201
+ messageCount: messages.length,
202
+ messages,
203
+ live: isLive(sessionId),
204
+ };
205
+ },
206
+
207
+ /**
208
+ * 批量彻底删除归档会话。live 会话拒绝(先停止再删);每个会话删除
209
+ * 持久化文件与会话目录;最后从归档集合移除并返回结果明细。
210
+ */
211
+ async deleteArchived(sessionIds) {
212
+ const unique = [...new Set(sessionIds)];
213
+ const deleted = [];
214
+ const failed = [];
215
+ for (const sessionId of unique) {
216
+ if (isLive(sessionId)) {
217
+ failed.push({ sessionId, reason: 'live' });
218
+ continue;
219
+ }
220
+ const headers = await persistence.list();
221
+ const header = headers.find((item) => item.id === sessionId);
222
+ if (header === undefined) {
223
+ // 会话文件已不存在:仅清理归档记录(幂等删除)。
224
+ deleted.push(sessionId);
225
+ continue;
226
+ }
227
+ const file = await fileInfo(header);
228
+ if (file === null) {
229
+ deleted.push(sessionId);
230
+ continue;
231
+ }
232
+ try {
233
+ await rm(file.path, { force: true });
234
+ await rm(dirname(file.path), { recursive: true, force: true });
235
+ deleted.push(sessionId);
236
+ } catch (error) {
237
+ failed.push({ sessionId, reason: error && error.message ? String(error.message) : 'delete-failed' });
238
+ }
239
+ }
240
+ const removedIds = deleted.length > 0 ? await removeFromArchiveSet(deleted) : [];
241
+ return { deleted, failed, removedFromArchive: removedIds.length };
242
+ },
243
+
244
+ /** 批量恢复归档会话(仅从归档集合移除,会话数据不动)。 */
245
+ async unarchive(sessionIds) {
246
+ const unique = [...new Set(sessionIds)];
247
+ const restored = await removeFromArchiveSet(unique);
248
+ return { restored, removedFromArchive: restored.length };
249
+ },
250
+ };
251
+ }
252
+
253
+ /** 插件 apply:注册远程服务(面板 UI 读写)。 */
254
+ export function apply(ctx, config) {
255
+ const cfg = { ...DEFAULT_CONFIG, ...config };
256
+ const store = createConfigStore({
257
+ name,
258
+ defaults: DEFAULT_CONFIG,
259
+ patchConfig: config,
260
+ });
261
+ ctx.effect(() => store.dispose?.(), 'session-archive: store');
262
+
263
+ if (SessionArchiveGateway !== null) {
264
+ ctx.plugin(SessionArchiveGateway, { host: createArchiveHost(ctx, cfg), serviceKey: 'sessionArchive' });
265
+ }
266
+ return store;
267
+ }
package/lib/remote.mjs ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * remote.mjs — 归档管理插件的远程服务(侧边栏归档面板通过 ctx.remote.sessionArchive 调用)
3
+ *
4
+ * DSH 的 Remote 装饰器是 ECMAScript 标准装饰器语法(Node 默认未启用),
5
+ * 这里用"手动构造装饰器上下文"的方式等价调用:Remote(name)(method, context)
6
+ * 把标记初始化器收集起来,在模块加载时用 Object.create 模拟实例执行
7
+ * (mark 以实例原型为准,与真实实例化等价)。
8
+ *
9
+ * typert-protocol 惰性加载:npm 模式从包内 node_modules 解析;file:// 模式
10
+ * (~/.dsh/plugins/)从 harness 的 profile 依赖树解析。两者都不可用时
11
+ * 模块加载失败,由 index.mjs 的动态 import 捕获——核心 host 逻辑照常注册,
12
+ * 仅侧边栏面板的远程读写不可用。
13
+ */
14
+
15
+ import { createRequire } from "node:module";
16
+ import { homedir } from "node:os";
17
+ import { join, resolve } from "node:path";
18
+ import { pathToFileURL } from "node:url";
19
+
20
+ // @deepseek-ai/dsh-typert-protocol 是 ESM 包。Node 20 早期版本尚不支持
21
+ // require(ESM),因此统一用 import() 加载;fallback 时先用 createRequire
22
+ // 解析出实际文件,再以 file URL 导入。
23
+ async function loadTypert() {
24
+ try {
25
+ return await import("@deepseek-ai/dsh-typert-protocol");
26
+ } catch {}
27
+ const dshHome = process.env.DSH_HOME?.trim() ? resolve(process.env.DSH_HOME) : join(homedir(), ".dsh");
28
+ const candidates = [
29
+ join(dshHome, "profiles", "web", "package.json"),
30
+ join(dshHome, "profiles", "tui", "package.json"),
31
+ join(dshHome, "profiles", "headless", "package.json"),
32
+ ];
33
+ for (const base of candidates) {
34
+ try {
35
+ const resolved = createRequire(base).resolve("@deepseek-ai/dsh-typert-protocol");
36
+ return await import(pathToFileURL(resolved).href);
37
+ } catch {}
38
+ }
39
+ throw new Error("typert-protocol is unavailable (neither package deps nor harness profiles resolve it)");
40
+ }
41
+
42
+ const { Remote, TypertRemoteService } = await loadTypert();
43
+
44
+ let pending = [];
45
+
46
+ /** 手动标记一个类原型方法为 Remote 端点(等价 @Remote(exportName))。 */
47
+ function markRemoteMethod(proto, method, exportName) {
48
+ const initializers = [];
49
+ const context = {
50
+ kind: "method",
51
+ name: method,
52
+ private: false,
53
+ static: false,
54
+ addInitializer(fn) { initializers.push(fn); },
55
+ };
56
+ Remote(exportName ?? method)(proto[method], context);
57
+ pending.push({ initializers });
58
+ }
59
+
60
+ /** 执行收集到的标记(mark 以 Object.getPrototypeOf(this) 为原型)。 */
61
+ function runPendingMarks(instance) {
62
+ const batch = pending;
63
+ pending = [];
64
+ for (const { initializers } of batch) {
65
+ for (const init of initializers) init.call(instance);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * 归档管理远程服务:list 列出归档会话;detail 读取会话内容;
71
+ * delete 批量删除归档会话(文件 + 归档记录);unarchive 批量恢复归档。
72
+ * 所有逻辑委托给 host 模块(lib/index.mjs 传入的 archiveHost)。
73
+ * @param ctx - 插件上下文。
74
+ * @param config - { host: archiveHost, serviceKey: 远程服务名 }。
75
+ */
76
+ export class SessionArchiveGateway extends TypertRemoteService {
77
+ constructor(ctx, config) {
78
+ super(ctx, config.serviceKey);
79
+ runPendingMarks(this);
80
+ this.host = config.host;
81
+ }
82
+ list() {
83
+ return this.host.list();
84
+ }
85
+ detail(sessionId) {
86
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
87
+ throw new TypeError("detail expects a session id string");
88
+ }
89
+ return this.host.detail(sessionId);
90
+ }
91
+ delete(sessionIds) {
92
+ if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== "string")) {
93
+ throw new TypeError("delete expects an array of session id strings");
94
+ }
95
+ return this.host.deleteArchived(sessionIds);
96
+ }
97
+ unarchive(sessionIds) {
98
+ if (!Array.isArray(sessionIds) || sessionIds.some((id) => typeof id !== "string")) {
99
+ throw new TypeError("unarchive expects an array of session id strings");
100
+ }
101
+ return this.host.unarchive(sessionIds);
102
+ }
103
+ }
104
+ markRemoteMethod(SessionArchiveGateway.prototype, "list");
105
+ markRemoteMethod(SessionArchiveGateway.prototype, "detail");
106
+ markRemoteMethod(SessionArchiveGateway.prototype, "delete");
107
+ markRemoteMethod(SessionArchiveGateway.prototype, "unarchive");
108
+ // 模块加载时立即执行标记(Object.create 模拟实例;构造函数里的 runPendingMarks 幂等保留无害)。
109
+ runPendingMarks(Object.create(SessionArchiveGateway.prototype));
@@ -0,0 +1,91 @@
1
+ /**
2
+ * typert.host.js — 手写的 Typert host 工件(typert-loader 机制)。
3
+ *
4
+ * DSH 的 typert-loader 会为「导出 ./typert 的 loader 条目」把本工件注册进
5
+ * ctx.typert.local,于是 api-gateway 直接认领这些端点。这是官方扩展点:
6
+ * 不依赖 remote.mjs 的 Remote 装饰器 markers(markers 表是 typert-protocol
7
+ * 模块私有的 WeakMap,当插件从 profile 安装、typert-protocol 与 harness
8
+ * 各持一份模块实例时 markers 会丢失,SRC 认领为空,web 端报
9
+ * "transport failure ... HTTP 404")。
10
+ *
11
+ * codec 使用最小透传 schema({_zod, parse}):typert-loader 的
12
+ * requireStrictCodec 只校验这两个字段,网关 decode/encode 走 parse 透传,
13
+ * 与 client.cjs 里 $mount 的透传描述符一致。
14
+ */
15
+
16
+ const passthrough = (value) => value;
17
+ const codec = (typeSymbol) => ({
18
+ mode: 'strict',
19
+ typeSymbol,
20
+ schema: { _zod: true, parse: passthrough },
21
+ });
22
+
23
+ export const TYPERT = {
24
+ package: '@chaoset/session-archive',
25
+ face: 'host',
26
+ schemas: [],
27
+ invocations: [
28
+ {
29
+ id: '@chaoset/session-archive#sessionArchive/list',
30
+ service: 'sessionArchive',
31
+ namespace: 'sessionArchive',
32
+ method: 'list',
33
+ invocation: { kind: 'direct' },
34
+ parameters: [],
35
+ result: codec('@chaoset/session-archive/types#ArchiveListResult'),
36
+ sourceLocation: { file: 'packages/session-archive/lib/remote.mjs', line: 1, column: 1 },
37
+ },
38
+ {
39
+ id: '@chaoset/session-archive#sessionArchive/detail',
40
+ service: 'sessionArchive',
41
+ namespace: 'sessionArchive',
42
+ method: 'detail',
43
+ invocation: { kind: 'direct' },
44
+ parameters: [
45
+ {
46
+ name: 'sessionId',
47
+ wire: 'sessionId',
48
+ source: 'json',
49
+ codec: codec('@chaoset/session-archive/types#SessionId'),
50
+ },
51
+ ],
52
+ result: codec('@chaoset/session-archive/types#ArchiveDetailResult'),
53
+ sourceLocation: { file: 'packages/session-archive/lib/remote.mjs', line: 1, column: 1 },
54
+ },
55
+ {
56
+ id: '@chaoset/session-archive#sessionArchive/delete',
57
+ service: 'sessionArchive',
58
+ namespace: 'sessionArchive',
59
+ method: 'delete',
60
+ invocation: { kind: 'direct' },
61
+ parameters: [
62
+ {
63
+ name: 'sessionIds',
64
+ wire: 'sessionIds',
65
+ source: 'json',
66
+ codec: codec('@chaoset/session-archive/types#SessionIdArray'),
67
+ },
68
+ ],
69
+ result: codec('@chaoset/session-archive/types#DeleteResult'),
70
+ sourceLocation: { file: 'packages/session-archive/lib/remote.mjs', line: 1, column: 1 },
71
+ },
72
+ {
73
+ id: '@chaoset/session-archive#sessionArchive/unarchive',
74
+ service: 'sessionArchive',
75
+ namespace: 'sessionArchive',
76
+ method: 'unarchive',
77
+ invocation: { kind: 'direct' },
78
+ parameters: [
79
+ {
80
+ name: 'sessionIds',
81
+ wire: 'sessionIds',
82
+ source: 'json',
83
+ codec: codec('@chaoset/session-archive/types#SessionIdArray'),
84
+ },
85
+ ],
86
+ result: codec('@chaoset/session-archive/types#UnarchiveResult'),
87
+ sourceLocation: { file: 'packages/session-archive/lib/remote.mjs', line: 1, column: 1 },
88
+ },
89
+ ],
90
+ model: { services: [], events: [], objects: [] },
91
+ };
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@chaoset/session-archive",
3
+ "version": "0.1.0",
4
+ "description": "DSH plugin: browse archived sessions, inspect their content, batch-restore or permanently delete them with multi-select",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "files": [
8
+ "lib",
9
+ "client",
10
+ "cordis.patch.yml",
11
+ "README.md"
12
+ ],
13
+ "exports": {
14
+ ".": "./lib/index.mjs",
15
+ "./client": "./client/client.cjs",
16
+ "./typert": "./lib/typert.host.js",
17
+ "./cordis.patch.yml": "./cordis.patch.yml",
18
+ "./package.json": "./package.json"
19
+ },
20
+ "dsh": {
21
+ "bundle": {
22
+ "patch": "./cordis.patch.yml"
23
+ },
24
+ "client": {
25
+ "platform": "web",
26
+ "inject": [
27
+ "slots",
28
+ "locale",
29
+ "remote"
30
+ ]
31
+ }
32
+ },
33
+ "optionalDependencies": {
34
+ "@deepseek-ai/dsh-typert-protocol": "^0.1.0-rc.6"
35
+ },
36
+ "engines": {
37
+ "node": ">=22.19.0"
38
+ },
39
+ "scripts": {
40
+ "test": "node ../../scripts/test.mjs",
41
+ "prepublishOnly": "npm test"
42
+ },
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "https://github.com/winliyou/dsh-plugins.git",
46
+ "directory": "packages/session-archive"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }