dsh-archived-chats 1.1.0 → 1.1.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/README.md CHANGED
@@ -58,7 +58,7 @@ dsh plugin --profile web update dsh-archived-chats
58
58
  | **Read-only preview** | Native conversation layout for Markdown, reasoning, tool activity, JSON, code, and available stored images, with responsive turn navigation. |
59
59
  | **Local History** | Validated versions captured after archive, read-only snapshot preview, confirmed deletion, clear-history, and **Restore as copy** without overwriting the source. |
60
60
  | **Backup and restore** | JSON + Markdown ZIP export with preview-first, conflict-safe import. Existing session IDs are never overwritten. |
61
- | **Recoverable deletion** | Snapshot-protected Recycle Bin with immediate Undo, two-level restore, and separately confirmed permanent deletion. |
61
+ | **Recoverable deletion** | Snapshot-protected Recycle Bin with immediate Undo, two-level restore, separately confirmed permanent deletion, and an optional direct permanent-delete action for users who do not need recovery. |
62
62
  | **Storage and relationships** | Separate storage accounting, preview-first retention policies, and read-only Origins & Branches for forks and subagent trees. |
63
63
 
64
64
  ## Safety by design
@@ -128,6 +128,8 @@ Protection manifests use `dsh-archived-chats/snapshot` v1 and session payloads u
128
128
 
129
129
  Move ordering is: validate archive ownership → dispose or park a live session → capture and verify snapshot → recheck ownership → atomically commit `trashed` → invalidate caches. Ordinary move never removes the persistence artifact.
130
130
 
131
+ Archived sessions also expose an explicit direct permanent-delete action. It uses the same session-scoped physical-delete checks and crash-recovery bracket as Recycle Bin purge, but intentionally skips snapshot creation and recycle storage after a separate irreversible confirmation. Ordinary delete remains recoverable by default.
132
+
131
133
  Restore first rejects an existing-ID conflict. With an intact original it restores archive visibility and removes only the recycle record, without rewriting persistence; the snapshot remains history. With a missing original it completes validation and attachment-identity republishing before writing through public `create` / `append` / `saveImage` capabilities. A failure rolls back the new artifact and retains trash.
132
134
 
133
135
  Permanent purge persists `purge-pending` before physical writes, then removes every snapshot for that source, then the original session, and finally the recycle record. The session delete is deliberately last: a failure before it leaves the original intact and the record completable, rather than a `purge-pending` record whose session is already gone and which can therefore neither restore nor complete. The snapshot sweep attributes each published snapshot by manifest identity so a snapshot that fails validation is still removed when it belongs to this session, and an unrelated unverifiable snapshot is skipped instead of aborting the sweep — corruption elsewhere in the store must never make a purge impossible. The recycle record also names its own snapshot id, covering one damaged past attribution. Workspace, metadata, snapshot, or physical-delete failures retain `purge-pending`; snapshot deletion is rescanned before success can be returned. Physical deletion additionally requires the located artifact to sit in a directory named for the session itself, so a backend layout that shares one parent between sessions can never have that parent removed. Startup recovery retries only `purge-pending`, never plain `trashed`. Legacy `pending-deletions.json` is strict read-only migration input: each still-archived ID becomes recoverable trash and is never boot-deleted merely because of the old marker.
@@ -160,7 +160,7 @@ client.js 注册 order 30 的 `settings.section` 与 `shell.overlay`,并使用
160
160
  - 历史响应不包含工作区/快照/附件路径、原始事件、备注或确认 token;日志只记 ID 和稳定代码。
161
161
  - 工作区归档响应只公开安全的工作区 ID/标题、符合条件数量、已确认预览中的会话 ID/标题/时间和稳定的逐项结果;工作区路径、事件正文、备注、附件路径与确认凭据不会进入日志或渲染结果。
162
162
  - 导入限制 ZIP 大小、条目数量、路径格式、版本和 JSON 结构,拒绝遍历、重复和原型污染字段。
163
- - 普通删除从不调用物理清除;仅已提交回收记录可进入 purge。
163
+ - 普通删除从不调用物理清除;仅已提交回收记录可进入 purge。归档列表还提供单独确认的直接永久删除操作,跳过快照和回收站,但复用同一套会话目录边界、崩溃恢复和失败保护;普通删除仍默认可恢复。
164
164
  - 快照和回收文件使用 `0600`,目录使用 `0700`;快照文件在 sync 前以可写句柄重新打开,发布顺序为临时写入、sync、原子 rename,以保持 Windows、macOS 和 Linux 的持久化语义一致。
165
165
  - 物理 purge 删除快照副本,但不承诺立即清理 Harness 全局附件库中仍被其他会话引用的字节。
166
166
  - 未知宿主能力必须降级或返回明确错误,不得猜测内部对象结构。
package/lib/client.js CHANGED
@@ -418,6 +418,7 @@ window.__ModuleLoader__.load({
418
418
  "chat.unarchive": "取消归档",
419
419
  "menu.unarchiveAll": "全部取消归档",
420
420
  "menu.deleteAll": "全部移至回收站",
421
+ "menu.deletePermanently": "全部永久删除",
421
422
  "group.collapse": "折叠",
422
423
  "group.expand": "展开",
423
424
  "confirm.deleteOne.title": "移至回收站?",
@@ -425,6 +426,9 @@ window.__ModuleLoader__.load({
425
426
  "confirm.deleteAll.title": "全部移至回收站?",
426
427
  "confirm.deleteGroup.title": "将该项目下的已归档聊天移至回收站?",
427
428
  "confirm.deleteSelected.title": "将选中的已归档聊天移至回收站?",
429
+ "confirm.permanent.title": "直接永久删除已归档聊天?",
430
+ "confirm.permanent.body": "这些聊天不会进入回收站,删除后无法恢复。",
431
+ "confirm.permanent.confirm": "永久删除",
428
432
  "confirm.cancel": "取消",
429
433
  "confirm.delete": "移至回收站",
430
434
  "trash.restore": "恢复",
@@ -718,6 +722,7 @@ window.__ModuleLoader__.load({
718
722
  "chat.unarchive": "Unarchive",
719
723
  "menu.unarchiveAll": "Unarchive all",
720
724
  "menu.deleteAll": "Move all to Recycle Bin",
725
+ "menu.deletePermanently": "Delete all permanently",
721
726
  "group.collapse": "Collapse",
722
727
  "group.expand": "Expand",
723
728
  "confirm.deleteOne.title": "Move to Recycle Bin?",
@@ -725,6 +730,9 @@ window.__ModuleLoader__.load({
725
730
  "confirm.deleteAll.title": "Move all to Recycle Bin?",
726
731
  "confirm.deleteGroup.title": "Move this project's archived chats to Recycle Bin?",
727
732
  "confirm.deleteSelected.title": "Move selected archived chats to Recycle Bin?",
733
+ "confirm.permanent.title": "Permanently delete archived chats directly?",
734
+ "confirm.permanent.body": "These chats will skip the Recycle Bin and cannot be recovered after deletion.",
735
+ "confirm.permanent.confirm": "Delete permanently",
728
736
  "confirm.cancel": "Cancel",
729
737
  "confirm.delete": "Move to Recycle Bin",
730
738
  "trash.restore": "Restore",
@@ -4037,6 +4045,38 @@ window.__ModuleLoader__.load({
4037
4045
  }
4038
4046
  };
4039
4047
 
4048
+ const runPermanentDelete = async (ids, finishSelectionAfter = false) => {
4049
+ if (ids.length === 0) return;
4050
+ markBusy(ids, true);
4051
+ setNotice(null);
4052
+ try {
4053
+ const result = await post("/delete-all", { sessionIds: ids, permanent: true });
4054
+ const deleted = uniqueSessionIds(result.deleted);
4055
+ const failed = result.failed ?? [];
4056
+ if (deleted.length > 0) {
4057
+ setSessions((prev) => (prev ?? []).filter((s) => !deleted.includes(s.id)));
4058
+ setSelected((current) => setVisibleSelection(current, deleted, false));
4059
+ setTrash((current) => ({ ...current, status: "idle" }));
4060
+ refreshSidebar?.();
4061
+ }
4062
+ if (failed.length > 0) setNotice({ kind: "error", text: failureText(t, failed) });
4063
+ else if (deleted.length > 0) setNotice({ kind: "ok", text: t("trash.purged") });
4064
+ if (finishSelectionAfter && failed.length === 0 && deleted.length === ids.length) finishSelectionMode();
4065
+ } catch (error) {
4066
+ const deleted = uniqueSessionIds(error.body?.deleted);
4067
+ const failed = error.body?.failed ?? [];
4068
+ if (deleted.length > 0) {
4069
+ setSessions((prev) => (prev ?? []).filter((s) => !deleted.includes(s.id)));
4070
+ setSelected((current) => setVisibleSelection(current, deleted, false));
4071
+ refreshSidebar?.();
4072
+ }
4073
+ setNotice({ kind: "error", text: failed.length > 0 ? failureText(t, failed) : String(error.message ?? error) });
4074
+ } finally {
4075
+ markBusy(ids, false);
4076
+ setConfirm(null);
4077
+ }
4078
+ };
4079
+
4040
4080
  const undoTrashMove = async (ids) => {
4041
4081
  const requested = uniqueSessionIds(ids);
4042
4082
  if (requested.length === 0) return;
@@ -4073,6 +4113,18 @@ window.__ModuleLoader__.load({
4073
4113
  }
4074
4114
  };
4075
4115
 
4116
+ const askPermanentDelete = (ids, finishSelectionAfter = false) => {
4117
+ if (ids.length === 0) return;
4118
+ deleteReturnFocusRef.current = document.activeElement;
4119
+ setConfirm({
4120
+ title: t("confirm.permanent.title"),
4121
+ body: `${t("confirm.permanent.body")} ${isZh(t) ? `共 ${ids.length} 个聊天。` : `${ids.length} chats selected.`}`,
4122
+ ids,
4123
+ permanent: true,
4124
+ finishSelectionAfter,
4125
+ });
4126
+ };
4127
+
4076
4128
  const openMetadataEditor = (session) => {
4077
4129
  metaReturnFocusRef.current = document.activeElement;
4078
4130
  setMetadataEdit(session);
@@ -4379,7 +4431,10 @@ window.__ModuleLoader__.load({
4379
4431
  onClick: () => setActionMenu((current) => current === "more" ? null : "more"),
4380
4432
  children: [(0, jsx.jsx)(IconDots, {}), (0, jsx.jsx)("span", { children: t("action.more") })]
4381
4433
  }),
4382
- actionMenu === "more" && (0, jsx.jsx)("div", { id: "dac-more-actions", className: "dac-action-menu", children: (0, jsx.jsx)("button", { type: "button", className: "dac-action-menu-item dac-danger", onClick: () => { setActionMenu(null); askDelete(allIds, void 0); }, children: t("delete.all") }) })
4434
+ actionMenu === "more" && (0, jsx.jsxs)("div", { id: "dac-more-actions", className: "dac-action-menu", children: [
4435
+ (0, jsx.jsx)("button", { type: "button", className: "dac-action-menu-item dac-danger", onClick: () => { setActionMenu(null); askDelete(allIds, void 0); }, children: t("delete.all") }),
4436
+ (0, jsx.jsx)("button", { type: "button", className: "dac-action-menu-item dac-danger", onClick: () => { setActionMenu(null); askPermanentDelete(allIds); }, children: t("menu.deletePermanently") })
4437
+ ] })
4383
4438
  ] })
4384
4439
  ]
4385
4440
  }),
@@ -4529,7 +4584,14 @@ window.__ModuleLoader__.load({
4529
4584
  className: "dac-bulk-btn dac-danger",
4530
4585
  disabled: selectedBusy,
4531
4586
  onClick: () => askDelete(selectedIds, null, true),
4532
- children: t("bulk.delete")
4587
+ children: t("bulk.delete")
4588
+ }),
4589
+ (0, jsx.jsx)("button", {
4590
+ type: "button",
4591
+ className: "dac-bulk-btn dac-danger",
4592
+ disabled: selectedBusy,
4593
+ onClick: () => askPermanentDelete(selectedIds, true),
4594
+ children: t("menu.deletePermanently")
4533
4595
  }),
4534
4596
  (0, jsx.jsx)("button", {
4535
4597
  type: "button",
@@ -4683,12 +4745,12 @@ window.__ModuleLoader__.load({
4683
4745
  confirm !== null && (0, jsx.jsx)(ConfirmDialog, {
4684
4746
  title: confirm.title,
4685
4747
  body: confirm.body,
4686
- confirmLabel: t("confirm.delete"),
4748
+ confirmLabel: t(confirm.permanent ? "confirm.permanent.confirm" : "confirm.delete"),
4687
4749
  cancelLabel: t("confirm.cancel"),
4688
4750
  busy: confirm.ids.some((id) => busy[id] === true),
4689
4751
  returnFocus: deleteReturnFocusRef.current,
4690
4752
  fallbackFocusRef: pageHeadingRef,
4691
- onConfirm: () => runDelete(confirm.ids, confirm.finishSelectionAfter === true),
4753
+ onConfirm: () => confirm.permanent ? runPermanentDelete(confirm.ids, confirm.finishSelectionAfter === true) : runDelete(confirm.ids, confirm.finishSelectionAfter === true),
4692
4754
  onCancel: () => setConfirm(null)
4693
4755
  }),
4694
4756
  trashConfirm !== null && (0, jsx.jsx)(ConfirmDialog, {
package/lib/index.js CHANGED
@@ -1729,6 +1729,24 @@ function registerRoutes(ctx, webServer, registry, persistence, titleCache, metad
1729
1729
  send(res, 400, { error: batch ? 'sessionIds-required' : 'sessionId-required' });
1730
1730
  return;
1731
1731
  }
1732
+ if (body.permanent === true) {
1733
+ const deleted = [];
1734
+ const pending = [];
1735
+ const failed = [];
1736
+ for (const id of [...new Set(ids)]) {
1737
+ try {
1738
+ const outcome = await deleteSession(ctx, registry, persistence, id, titleCache, statsService, metadataStore, pendingStore, lifecycle);
1739
+ if (outcome === 'deleted') deleted.push(id);
1740
+ else if (outcome === 'pending') pending.push(id);
1741
+ else failed.push({ id, reason: 'operation-cancelled' });
1742
+ } catch (error) {
1743
+ failed.push({ id, reason: error?.code ?? 'delete-failed' });
1744
+ }
1745
+ }
1746
+ const result = { deleted, pending, failed };
1747
+ send(res, failed.length > 0 && deleted.length === 0 ? 409 : 200, { ok: failed.length === 0, ...result });
1748
+ return;
1749
+ }
1732
1750
  const result = await recycleService.move(ids);
1733
1751
  send(res, result.failed.length > 0 && result.trashed.length === 0 ? 409 : 200, { ok: result.failed.length === 0, ...result });
1734
1752
  } catch (error) {
@@ -10,7 +10,7 @@ function objectLike(value) {
10
10
 
11
11
  function validatedHeader(value, expectedId) {
12
12
  if (!objectLike(value)
13
- || value.version !== 2
13
+ || (value.version !== 2 && value.version !== 3)
14
14
  || typeof value.id !== 'string'
15
15
  || value.id === ''
16
16
  || (expectedId !== undefined && value.id !== expectedId)
@@ -113,7 +113,8 @@ export function resolvePersistenceCompat(raw) {
113
113
  throw invalid('persistence read handle metadata is invalid');
114
114
  }
115
115
  if (handle.inheritedEventCount > 0) throw inspectionUnsupported();
116
- const events = await handle.read.call(handle, 0, undefined);
116
+ const slice = await handle.read.call(handle, 0, undefined);
117
+ const events = Array.isArray(slice) ? slice : slice?.events;
117
118
  if (!Array.isArray(events)) throw invalid('persistence read response must be an event array');
118
119
  return { meta: header, events };
119
120
  } finally {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-archived-chats",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "DeepSeek Harness 会话档案:从设置中安全批量归档工作区会话,按工作区浏览和全文搜索归档聊天,原生只读预览对话、工具活动和已存储图片,管理标签备注与 ZIP 备份恢复;保留历史版本并恢复为副本,提供可撤销回收站、空间分账、保留策略和来源与分支。所有数据留在本机。 Session Archive for DeepSeek Harness: safely bulk-archive workspace chats from Settings; browse and full-text search archived chats; natively preview conversations, tool activity, and stored images read-only; manage tags, notes, and ZIP backup restore; retain local History with restore-as-copy, an undoable Recycle Bin, storage accounting, retention policies, and Origins & Branches. All data stays local.",
5
5
  "license": "MIT",
6
6
  "author": "Ultronen",