@chaoset/session-archive 0.1.0 → 0.1.3

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 CHANGED
@@ -10,8 +10,9 @@ web 侧边栏底部新增 **归档** 面板(🗂):
10
10
  - **一次多选批量操作**:
11
11
  - **恢复归档**(unarchive):把勾选的会话移回会话树,恢复其在原工作区的
12
12
  位置(归档时保留的 slot 会计不被破坏)。
13
- - **彻底删除**(delete):删除勾选会话的持久化文件与归档记录。删除为
13
+ - **彻底删除**(delete):删除勾选会话的持久化文件与会话目录。删除为
14
14
  两段式确认(第一次点击进入确认态,4 秒内再次点击执行),避免误删。
15
+ 删除后该会话在侧边栏对话列表与归档面板中都不会再出现。
15
16
  - 工具条提供**全选**、已选计数;**运行中**(live)的会话显示黄色徽标且
16
17
  禁止勾选删除(请先停止会话);列表随会话树变化自动可刷新(⟳)。
17
18
 
@@ -34,11 +35,16 @@ dsh plugin --profile web add @chaoset/session-archive
34
35
  文本消息(`user/message` / `assistant/message` 的 text 块),不做任何
35
36
  写入/修复。
36
37
  - **删除**:live 会话拒绝;每个会话删除持久化文件与会话目录(`locate()`
37
- 定位);最后从归档集合移除。归档集合没有官方移除 API,插件复用 registry
38
- 自身的串行化写入通道(`enqueueOperation requireState → setState`,
39
- 与 `archiveSession` 同一路径);若内部形状变化会自动降级为"仅删文件"
40
- 归档列表按文件存在性过滤,功能不受影响。
41
- - **恢复**:仅从归档集合移除 id,会话数据不动,恢复后回到原工作区位置。
38
+ 定位)。删除后**保留**该会话在归档集合中的 ghost id:宿主的
39
+ `archiveSession` 不停止内存会话,若把 id 移出归档集合,仍挂在内存中的
40
+ 会话会因"不再归档"而立刻重新出现在侧边栏对话列表(效果等同"恢复");
41
+ 保留 ghost id 后由 `list()` 的存在性过滤隐藏,面板与侧边栏都不再显示。
42
+ 归档集合没有官方写入 API,插件复用 registry 自身的串行化写入通道
43
+ (`enqueueOperation → requireState → setState`,与 `archiveSession` 同一
44
+ 路径);若内部形状变化会自动降级为"仅删文件",归档列表按文件存在性
45
+ 过滤,功能不受影响。
46
+ - **恢复**:仅从归档集合移除**仍存在持久化文件**的会话 id,会话数据不动,
47
+ 恢复后回到原工作区位置;文件已删的已删除会话拒绝恢复(防"复活")。
42
48
 
43
49
  ## Remote API(`ctx.remote.sessionArchive`)
44
50
 
@@ -47,6 +53,10 @@ dsh plugin --profile web add @chaoset/session-archive
47
53
  | `list()` | — | `{ items: ArchiveRow[] }` |
48
54
  | `detail(sessionId)` | 会话 id | `{ sessionId, header, title, messageCount, messages, live }` |
49
55
  | `delete(sessionIds[])` | id 数组 | `{ deleted, failed, removedFromArchive }` |
56
+
57
+ > `delete` 的 `removedFromArchive` 恒为 0:删除保留归档 ghost id(见上),
58
+ > 侧边栏不会重新显示已删除的会话;`unarchive` 的 `removedFromArchive` 为
59
+ > 实际从归档集合移除的 id 数。
50
60
  | `unarchive(sessionIds[])` | id 数组 | `{ restored, removedFromArchive }` |
51
61
 
52
62
  `ArchiveRow`:`{ sessionId, title, cwd, createdAt, updatedAt, size, live }`。
package/client/client.cjs CHANGED
@@ -33,6 +33,7 @@ window.__ModuleLoader__.load({
33
33
  ".sa_actionDanger:hover:not(:disabled){background:var(--dsw-alias-state-error-primary)}",
34
34
  ".sa_actionDanger:disabled{opacity:.4}",
35
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_confirm:hover:not(:disabled){background:var(--dsw-alias-state-error-primary);color:#fff}",
36
37
  ".sa_body{flex:1;min-height:0;padding:4px 12px 12px;overflow-y:auto}",
37
38
  ".sa_empty{color:var(--dsw-alias-label-tertiary);margin:24px 0;text-align:center;font-size:12px;line-height:18px}",
38
39
  ".sa_error{color:var(--dsw-alias-state-error-primary);margin:8px 0;font-size:12px;line-height:18px}",
package/lib/index.mjs CHANGED
@@ -14,10 +14,19 @@
14
14
  * 2. detail(id) — readFrom(id, 0) 只读取会话事件:标题 + 文本消息
15
15
  * (user/assistant),供面板"查看"展开。
16
16
  * 3. delete(ids) — 批量彻底删除:live 会话拒绝;逐个删除持久化文件
17
- * (locate() 定位)+ 会话目录;最后从归档集合移除。
18
- * 4. unarchive(ids) — 批量恢复:仅从归档集合移除(会话数据不动)。
17
+ * (locate() 定位)+ 会话目录。删除后**保留**该会话在
18
+ * 归档集合中的 ghost id(不调用移除):宿主的
19
+ * archiveSession 只改归档注册表、不停止内存会话,web
20
+ * 客户端重连还会把旧 tab 恢复进内存——删除时若把 id
21
+ * 移出归档集合,仍挂在内存里的会话会因"不再归档"而
22
+ * 立刻重新出现在侧边栏对话列表(效果等同"恢复")。
23
+ * 保留 ghost id 后由 list() 的存在性过滤隐藏,归档面板
24
+ * 与侧边栏都不会再显示该会话。
25
+ * 4. unarchive(ids) — 批量恢复:仅从归档集合移除**仍存在持久化文件**的
26
+ * 会话 id(文件已删的 ghost id 拒绝恢复,防止已彻底
27
+ * 删除的会话"复活"回侧边栏);会话数据不动。
19
28
  *
20
- * 归档集合(workspaceRegistry.archivedSessionIds)的移除没有官方 API,
29
+ * 归档集合(workspaceRegistry.archivedSessionIds)的写入没有官方 API,
21
30
  * 这里复用 registry 自身的串行化写入通道(enqueueOperation → requireState
22
31
  * → setState,与 archiveSession 相同的路径);若 registry 内部形状变化,
23
32
  * 自动降级为"仅删文件",归档列表会以存在性过滤幽灵 id,功能仍正确。
@@ -101,10 +110,29 @@ export function createArchiveHost(ctx, cfg) {
101
110
  const registry = ctx.workspaceRegistry;
102
111
  const persistence = ctx.sessionPersistence;
103
112
 
104
- /** 会话是否仍在内存中运行(live)。 */
105
- const isLive = (sessionId) => ctx.sessions.get(sessionId) !== undefined;
113
+ /** 当前归档集合快照。 */
114
+ const archivedSet = () => new Set(
115
+ typeof registry.archivedSessionIds === 'function'
116
+ ? registry.archivedSessionIds()
117
+ : (Array.isArray(registry.archivedSessionIds) ? registry.archivedSessionIds : []),
118
+ );
106
119
 
107
- /** 从归档集合移除若干 id(恢复/删除共用),返回实际移除的 id。 */
120
+ /** 会话是否"活跃"(不能安全删除)。
121
+ * 宿主的 archiveSession 只改归档注册表、不停止内存会话,web 客户端
122
+ * 重连还会把旧 tab 恢复进内存——归档会话因此长期"内存存在"。但归档
123
+ * 会话已从会话列表移除、无法继续对话,不会再写持久化文件,删除安全;
124
+ * 若把内存存在当作 live,归档面板会永远显示"运行中"且无法勾选删除。
125
+ * 因此 live 仅对"内存存在且**未归档**"的会话为真(防将来误用),
126
+ * 归档面板中恒为 false。 */
127
+ const isLive = (sessionId) =>
128
+ ctx.sessions.get(sessionId) !== undefined && !archivedSet().has(sessionId);
129
+
130
+ /** 归档瞬间的生成流兜底:会话仍在内存且文件最近有写入(60s 内)时
131
+ * 视为忙碌——删除文件后进行中的请求会把半截日志 append 回来。 */
132
+ const isBusy = (sessionId, file) =>
133
+ file !== null && ctx.sessions.get(sessionId) !== undefined && Date.now() - file.mtimeMs < 60_000;
134
+
135
+ /** 从归档集合移除若干 id(恢复用;删除不调用——见 deleteArchived),返回实际移除的 id。 */
108
136
  async function removeFromArchiveSet(ids) {
109
137
  const set = new Set(ids);
110
138
  const canWrite =
@@ -158,8 +186,7 @@ export function createArchiveHost(ctx, cfg) {
158
186
  return {
159
187
  /** 列出全部归档会话(存在性过滤:文件已删的幽灵归档记录不显示)。 */
160
188
  async list() {
161
- const rawArchived = typeof registry.archivedSessionIds === 'function' ? registry.archivedSessionIds() : registry.archivedSessionIds;
162
- const archived = Array.isArray(rawArchived) ? rawArchived : [];
189
+ const archived = [...archivedSet()];
163
190
  if (archived.length === 0) return { items: [] };
164
191
  const headers = await persistence.list();
165
192
  const byId = new Map(headers.map((header) => [header.id, header]));
@@ -206,7 +233,11 @@ export function createArchiveHost(ctx, cfg) {
206
233
 
207
234
  /**
208
235
  * 批量彻底删除归档会话。live 会话拒绝(先停止再删);每个会话删除
209
- * 持久化文件与会话目录;最后从归档集合移除并返回结果明细。
236
+ * 持久化文件与会话目录。删除后**不**从归档集合移除 id(保留 ghost id):
237
+ * 归档不停止内存会话,一旦把 id 移出归档集合,侧边栏(以"不在归档
238
+ * 集合中"作为显示条件)会立刻把仍在内存中的会话重新显示出来,等同
239
+ * "恢复"。ghost id 由 list() 的存在性过滤隐藏,面板与侧边栏均不再
240
+ * 显示该会话;`removedFromArchive` 因此恒为 0。
210
241
  */
211
242
  async deleteArchived(sessionIds) {
212
243
  const unique = [...new Set(sessionIds)];
@@ -220,7 +251,7 @@ export function createArchiveHost(ctx, cfg) {
220
251
  const headers = await persistence.list();
221
252
  const header = headers.find((item) => item.id === sessionId);
222
253
  if (header === undefined) {
223
- // 会话文件已不存在:仅清理归档记录(幂等删除)。
254
+ // 会话文件已不存在:幂等删除(ghost id 保留在归档集合中)。
224
255
  deleted.push(sessionId);
225
256
  continue;
226
257
  }
@@ -229,6 +260,10 @@ export function createArchiveHost(ctx, cfg) {
229
260
  deleted.push(sessionId);
230
261
  continue;
231
262
  }
263
+ if (isBusy(sessionId, file)) {
264
+ failed.push({ sessionId, reason: 'busy' });
265
+ continue;
266
+ }
232
267
  try {
233
268
  await rm(file.path, { force: true });
234
269
  await rm(dirname(file.path), { recursive: true, force: true });
@@ -237,14 +272,26 @@ export function createArchiveHost(ctx, cfg) {
237
272
  failed.push({ sessionId, reason: error && error.message ? String(error.message) : 'delete-failed' });
238
273
  }
239
274
  }
240
- const removedIds = deleted.length > 0 ? await removeFromArchiveSet(deleted) : [];
241
- return { deleted, failed, removedFromArchive: removedIds.length };
275
+ return { deleted, failed, removedFromArchive: 0 };
242
276
  },
243
277
 
244
- /** 批量恢复归档会话(仅从归档集合移除,会话数据不动)。 */
278
+ /**
279
+ * 批量恢复归档会话(仅从归档集合移除,会话数据不动)。只恢复仍存在
280
+ * 持久化文件的会话:文件已删的 ghost id(已彻底删除的会话)拒绝恢复,
281
+ * 避免删除后的会话再次出现在侧边栏对话列表。
282
+ */
245
283
  async unarchive(sessionIds) {
246
284
  const unique = [...new Set(sessionIds)];
247
- const restored = await removeFromArchiveSet(unique);
285
+ const headers = await persistence.list();
286
+ const restorable = [];
287
+ for (const sessionId of unique) {
288
+ const header = headers.find((item) => item.id === sessionId);
289
+ if (header === undefined) continue; // 持久化记录已删:拒绝恢复
290
+ const file = await fileInfo(header);
291
+ if (file === null) continue; // 有记录无文件:拒绝恢复
292
+ restorable.push(sessionId);
293
+ }
294
+ const restored = await removeFromArchiveSet(restorable);
248
295
  return { restored, removedFromArchive: restored.length };
249
296
  },
250
297
  };
package/lib/remote.mjs CHANGED
@@ -68,7 +68,8 @@ function runPendingMarks(instance) {
68
68
 
69
69
  /**
70
70
  * 归档管理远程服务:list 列出归档会话;detail 读取会话内容;
71
- * delete 批量删除归档会话(文件 + 归档记录);unarchive 批量恢复归档。
71
+ * delete 批量删除归档会话(文件删除,归档集合保留 ghost id 防止内存会话
72
+ * 重现侧边栏);unarchive 批量恢复归档(仅限仍存在文件的会话)。
72
73
  * 所有逻辑委托给 host 模块(lib/index.mjs 传入的 archiveHost)。
73
74
  * @param ctx - 插件上下文。
74
75
  * @param config - { host: archiveHost, serviceKey: 远程服务名 }。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chaoset/session-archive",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "DSH plugin: browse archived sessions, inspect their content, batch-restore or permanently delete them with multi-select",
5
5
  "type": "module",
6
6
  "license": "MIT",