dsh-archived-chats 1.0.2 → 1.0.4
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 +1 -1
- package/README.zh-CN.md +1 -1
- package/docs/ARCHITECTURE.en.md +7 -7
- package/docs/ARCHITECTURE.md +7 -7
- package/lib/client.js +63 -18
- package/lib/durable.js +99 -0
- package/lib/export.js +22 -6
- package/lib/history-restore.js +23 -15
- package/lib/history.js +41 -4
- package/lib/import.js +66 -7
- package/lib/index.js +146 -102
- package/lib/metadata.js +39 -17
- package/lib/recycle.js +104 -33
- package/lib/restore.js +115 -42
- package/lib/retention.js +5 -14
- package/lib/search.js +153 -52
- package/lib/snapshot.js +103 -31
- package/lib/stats.js +5 -2
- package/lib/trash.js +5 -35
- package/lib/types/index.d.ts +11 -4
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -76,7 +76,7 @@ Features activate from the public capabilities exposed by the DeepSeek Harness H
|
|
|
76
76
|
| --- | --- |
|
|
77
77
|
| Archive and session reads | Browsing, search, preview, History inventory, storage accounting, and lineage. |
|
|
78
78
|
| Attachment reads | Stored images appear in conversation and snapshot previews; without it, text remains readable. |
|
|
79
|
-
| Public
|
|
79
|
+
| Public session writer | ZIP import, **Restore as copy**, and snapshot fallback when an original is missing all write through the Host's public `create` / `append` / `locate` capability, or a dedicated restore entry point where one exists. |
|
|
80
80
|
| Missing write capability | The operation returns `restore-unsupported` without writing or overwriting data. |
|
|
81
81
|
|
|
82
82
|
Back up `$DSH_HOME/plugin-data/archived-chats/` before downgrading to a release that does not display History or understand recycle snapshots.
|
package/README.zh-CN.md
CHANGED
|
@@ -76,7 +76,7 @@ dsh plugin --profile web update dsh-archived-chats
|
|
|
76
76
|
| --- | --- |
|
|
77
77
|
| 归档与会话读取 | 浏览、搜索、预览、历史清单、空间分账和会话血缘。 |
|
|
78
78
|
| 附件读取 | 对话和快照预览可显示已存储图片;缺少时文本内容仍可阅读。 |
|
|
79
|
-
|
|
|
79
|
+
| 公开会话 writer | ZIP 导入、**恢复为副本** 和原件丢失时的快照回退,都通过 Host 公开的 `create` / `append` / `locate` 能力写入;Host 提供专用恢复入口时优先使用。 |
|
|
80
80
|
| 缺少写入能力 | 操作返回 `restore-unsupported`,不会写入或覆盖数据。 |
|
|
81
81
|
|
|
82
82
|
降级到不显示历史版本或不识别回收快照的版本前,请备份 `$DSH_HOME/plugin-data/archived-chats/`。
|
package/docs/ARCHITECTURE.en.md
CHANGED
|
@@ -76,7 +76,7 @@ Preview accepts visible archived IDs by default and only recycle-catalog IDs wit
|
|
|
76
76
|
|
|
77
77
|
The preview/image authorization sequence is fixed: first require POST and `x-dsh-archived-chats: 1`, then bounded-parse `sessionId` and `attachmentId`; next confirm that the session is still in the currently visible archive set, find an exact image-descriptor match in that session's canonical projection, and only then read bytes through the optional `attachments.readImage` service. Both preview and preview/image recheck visible archive state after asynchronous reads and immediately before sending a response, preventing an overlapping unarchive or delete from exposing stale content. Image bytes use `no-store` and `nosniff`; cross-session, non-archived, and unprojected references are rejected, and error responses never echo filesystem paths. A host without attachment-read capability returns `preview-image-unsupported`; this degrades images only and does not block text, Markdown, reasoning, tool, JSON, or code preview.
|
|
78
78
|
|
|
79
|
-
Cross-session persistence inspection is limited to four concurrent reads. A broken session is reported in `skipped` while other hits still succeed. Canonical
|
|
79
|
+
Cross-session persistence inspection is limited to four concurrent reads, stops scheduling batches once the hit limit is satisfied, and aborts an older browser request when a newer search starts. A broken session is reported in `skipped` while other hits still succeed. Canonical projection limits each segment to 256 Ki code points, each message to 1 Mi code points and 1,000 segments, and each session to 10,000 projected messages; unknown structured values are bounded by depth, node, and character budgets before stringify. A 30-second TTL, 64-session LRU, and per-session cache cap keep bounded projections resident. Unarchive, delete, and restore invalidate affected cache entries.
|
|
80
80
|
|
|
81
81
|
## History versions and restore-as-copy
|
|
82
82
|
|
|
@@ -86,7 +86,7 @@ Cross-session persistence inspection is limited to four concurrent reads. A brok
|
|
|
86
86
|
|
|
87
87
|
`history-restore.js` fully validates the snapshot, asks the Host for a new session ID, and issues a five-minute single-use token/nonce. Confirmation consumes the credential before writes and rechecks the manifest, then creates persistence, rewrites session/attachment identities, appends events, restores workspace and metadata, and commits archive registry state last. Failures reverse plugin-controlled steps. The source session and snapshot never change, and the plugin makes no claim that Host-global attachment objects were deleted.
|
|
88
88
|
|
|
89
|
-
Single-version deletion and **Clear history versions** both enter the shared lifecycle queue and bypass the ordinary 30-second cache/in-flight list so current snapshot and recycle-protection state is recomputed.
|
|
89
|
+
Single-version deletion and **Clear history versions** both enter the shared lifecycle queue and bypass the ordinary 30-second cache/in-flight list so current snapshot and recycle-protection state is recomputed. Both reject a snapshot a recycle record still names, degraded or not — that record is its last claim. Both otherwise accept a degraded snapshot: it can no longer be previewed or restored from, but its bytes are still on disk and no other surface can reclaim them (retention plans only healthy snapshots), so refusing here would leak the store permanently. Deletion physically removes the plugin snapshot and its attachment copies, while the original chat and other versions remain unchanged.
|
|
90
90
|
|
|
91
91
|
## Export flow
|
|
92
92
|
|
|
@@ -104,7 +104,7 @@ ZIP paths are sanitized and collision-safe. Batch export inspects and writes ses
|
|
|
104
104
|
|
|
105
105
|
## Import and restore flow
|
|
106
106
|
|
|
107
|
-
import/inspect accepts only version-one ZIPs produced by this plugin. The Host streams bounded compressed chunks, preflights declared entry sizes, counts actual output, and caps entry count, per-entry bytes, manifest bytes, and total expansion. Iterative JSON validation then caps depth, node count, and total Unicode code points before
|
|
107
|
+
import/inspect accepts only version-one ZIPs produced by this plugin. The Host streams bounded compressed chunks, preflights declared entry sizes, counts actual output, and caps entry count, per-entry bytes, manifest bytes, and total expansion. Iterative JSON validation then caps depth, node count, and total Unicode code points before checking paths, versions, generator, workspace, storage descriptors, timestamps, `source.meta.id`, the event array, and cross-file consistency:
|
|
108
108
|
|
|
109
109
|
1. The browser uploads the ZIP and receives session summaries, versions, size, and warnings.
|
|
110
110
|
2. Existing session IDs are marked as conflicts and deselected by default.
|
|
@@ -113,19 +113,19 @@ import/inspect accepts only version-one ZIPs produced by this plugin. The Host s
|
|
|
113
113
|
5. restore.js uses a feature-detected adapter to write sessions, metadata, and archive state.
|
|
114
114
|
6. Any failure rolls back staged data and never overwrites an existing session.
|
|
115
115
|
|
|
116
|
-
The confirmation token expires quickly
|
|
116
|
+
The confirmation token expires quickly, can be used once, and is bounded to eight retained plans and 128 MiB total per process. Confirmation-time conflict revalidation, staging, and commit all run inside the shared lifecycle queue. Import resolves a session writer by capability: a dedicated Host restore entry point when one exists, otherwise the ordinary `create` / `append` / `locate` surface — the same capability History restore-as-copy writes through, so import works wherever that works. The append writer carries its own session-scoped rollback (it confirms the located directory is the session's own before creating anything), so a separate removal capability is required only for a dedicated restore entry point. Archive and metadata write capabilities are still required. The staged id does not exist yet, so a session reader that fails closed on unknown ids is the expected answer to the capability probe and never aborts the restore. Workspace attach is used only with a matching detach, otherwise the item restores ungrouped with a warning. A boundary that throws after changing state is compensated in reverse order; failed compensation is reported explicitly rather than returning false success.
|
|
117
117
|
|
|
118
118
|
## Recycle and protection-snapshot lifecycle
|
|
119
119
|
|
|
120
120
|
`trash.json` permits only `trashed`, `purge-pending`, and `degraded`. Legal transitions are `missing -> trashed`, `trashed/degraded -> purge-pending`, and removal of an existing state after a committed transaction. A `purge-pending` record cannot restore.
|
|
121
121
|
|
|
122
|
-
Protection manifests use `dsh-archived-chats/snapshot` v1 and session payloads use `dsh-archived-chats/snapshot-session` v1. Each recycle record names one active snapshot; older valid snapshots from restore/recycle cycles remain history until explicit retention application or permanent purge. Exact limits
|
|
122
|
+
Protection manifests use `dsh-archived-chats/snapshot` v1 and session payloads use `dsh-archived-chats/snapshot-session` v1. Each recycle record names one active snapshot; older valid snapshots from restore/recycle cycles remain history until explicit retention application or permanent purge. Exact limits are 4 MiB manifest, 64 MiB session JSON, 1,000 attachments, 32 MiB each, and 512 MiB total. Restore validation streams attachment digests first and rereads one attachment at a time immediately before Host writes, never retaining all attachment bytes together. Snapshot publication/deletion and state-file renames sync file and parent-directory durability, with a safe fallback on Windows filesystems that do not expose directory fsync. Windows cannot atomically replace a file or remove a directory entry while another handle is open on it — an indexer or antivirus scan is enough — so replaces and recursive removals retry the transient `EPERM` / `EACCES` / `EBUSY` codes there, bounded, and only there: on POSIX the same codes are permanent conditions and retrying would only delay the same failure. Snapshot publication also treats a rename refused onto an existing directory as a conflict after probing the destination, because Windows reports that as `EPERM` rather than `EEXIST`. Path containment is tested with the platform separator and rejects an absolute answer, so a Windows `..\` escape or a different drive letter cannot read as inside the root.
|
|
123
123
|
|
|
124
124
|
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.
|
|
125
125
|
|
|
126
126
|
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.
|
|
127
127
|
|
|
128
|
-
Permanent purge persists `purge-pending` before physical writes, then removes the original,
|
|
128
|
+
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.
|
|
129
129
|
|
|
130
130
|
## Browser client
|
|
131
131
|
|
|
@@ -158,7 +158,7 @@ The browser never mutates files directly. After an operation, the Host response
|
|
|
158
158
|
|
|
159
159
|
## Compatibility and testing
|
|
160
160
|
|
|
161
|
-
The plugin adapts through Host capability detection: archive reads, attachment reads, persistence writes, and live-session lifecycle support are evaluated independently, and missing capabilities must degrade safely or return explicit errors. Import, History restore-as-copy, and snapshot fallback when the original is missing
|
|
161
|
+
The plugin adapts through Host capability detection: archive reads, attachment reads, persistence writes, and live-session lifecycle support are evaluated independently, and missing capabilities must degrade safely or return explicit errors. Import, History restore-as-copy, and snapshot fallback when the original is missing all write through the public `create` / `append` / `locate` capability, or a dedicated restore entry point where the Host offers one; only a Host exposing neither returns `restore-unsupported` without mutation. A capability set that no shipped Host satisfies is not an acceptable guard — it makes the feature permanently dead rather than gracefully degraded. Back up the complete plugin-data directory before downgrading to a release that does not display History or understand recycle snapshots.
|
|
162
162
|
|
|
163
163
|
Coverage includes:
|
|
164
164
|
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -76,7 +76,7 @@ preview 默认只接受当前可见归档 ID;显式 `scope: "trash"` 时仅接
|
|
|
76
76
|
|
|
77
77
|
preview/image 的授权顺序固定为:先验证 POST 和 `x-dsh-archived-chats: 1`,再有界解析 `sessionId` 与 `attachmentId`;随后确认会话仍在当前可见归档集合中,从该会话的规范投影中查找完全匹配的图片描述符,最后才通过可选的 `attachments.readImage` 服务读取。preview 和 preview/image 都会在异步读取完成后、响应发送前再次检查可见归档状态,避免并发取消归档或删除泄露旧内容。图片字节以 `no-store`、`nosniff` 返回;跨会话、非归档或不在投影中的引用均会被拒绝,错误响应不回显文件路径。宿主没有附件读取能力时返回 `preview-image-unsupported`;这只降级图片,不阻塞文本、Markdown、思考、工具、JSON 或代码预览。
|
|
78
78
|
|
|
79
|
-
跨会话搜索的持久层读取并发上限为 4
|
|
79
|
+
跨会话搜索的持久层读取并发上限为 4,达到命中上限后不再调度后续批次,浏览器的新搜索会中止旧请求;单个会话失败会记入 skipped,其他命中仍正常返回。规范投影使用 30 秒 TTL、64 会话 LRU,并限制单段 256 Ki Unicode 码点、单消息 1 Mi 码点/1,000 段、单会话 10,000 条投影消息;结构化未知值在 stringify 前即受深度、节点和字符预算约束。超出投影边界的内容会安全截断且不会常驻缓存。取消归档、删除和恢复会使相关缓存失效。
|
|
80
80
|
|
|
81
81
|
## 历史版本与恢复为副本
|
|
82
82
|
|
|
@@ -86,7 +86,7 @@ preview/image 的授权顺序固定为:先验证 POST 和 `x-dsh-archived-chat
|
|
|
86
86
|
|
|
87
87
|
`history-restore.js` 先完整验证快照,用 Host 生成新会话 ID,再签发五分钟、单次使用的 token/nonce。确认时先消费凭据并重验 manifest;然后依次创建持久会话、重写会话/附件身份、附加事件、恢复工作区和元数据,最后才写入归档注册表。任一插件控制的边界失败都按逆序回滚;来源会话与快照始终不变,也不声称删除了 Host 全局附件对象。
|
|
88
88
|
|
|
89
|
-
历史页的单条删除与「清空历史版本」都进入共用生命周期队列,并绕过普通 30
|
|
89
|
+
历史页的单条删除与「清空历史版本」都进入共用生命周期队列,并绕过普通 30 秒缓存/进行中请求,重新计算当前快照与回收保护关系。两者都拒绝仍被回收记录引用的快照(无论是否降级)——那条记录是它最后的归属。除此之外两者都接受降级快照:它已无法预览或恢复,但字节仍在磁盘上,而其他任何入口都无法回收它们(保留策略只规划健康快照),在这里拒绝就意味着永久泄漏。删除会物理移除插件快照及其附件副本,但不修改原聊天或其他版本。
|
|
90
90
|
|
|
91
91
|
## 导出流程
|
|
92
92
|
|
|
@@ -104,7 +104,7 @@ ZIP 路径会清理遍历字符并处理重名。批量导出按会话顺序逐
|
|
|
104
104
|
|
|
105
105
|
## 导入和恢复流程
|
|
106
106
|
|
|
107
|
-
import/inspect 只接受本插件版本一导出的 ZIP。Host 以有界压缩块流式解压,先核对条目声明大小,再累计实际输出,并限制条目数、单条目、manifest 与总解压量。JSON 校验使用迭代遍历限制深度、节点数和 Unicode
|
|
107
|
+
import/inspect 只接受本插件版本一导出的 ZIP。Host 以有界压缩块流式解压,先核对条目声明大小,再累计实际输出,并限制条目数、单条目、manifest 与总解压量。JSON 校验使用迭代遍历限制深度、节点数和 Unicode 字符总量,然后继续校验路径、版本、生成器、工作区、存储描述符、时间戳、`source.meta.id`、事件数组及跨文件一致性并生成预览:
|
|
108
108
|
|
|
109
109
|
1. 浏览器上传 ZIP,Host 返回会话摘要、版本、大小和警告。
|
|
110
110
|
2. 已存在的会话 ID 标记为冲突并默认取消选择。
|
|
@@ -113,19 +113,19 @@ import/inspect 只接受本插件版本一导出的 ZIP。Host 以有界压缩
|
|
|
113
113
|
5. restore.js 通过能力探测的适配器写入会话、元数据和归档状态。
|
|
114
114
|
6. 任一步骤失败都回滚暂存数据,不覆盖已有会话。
|
|
115
115
|
|
|
116
|
-
|
|
116
|
+
确认令牌短期有效且只能使用一次,并受 8 条、总计 128 MiB 的进程内保留上限约束。确认后的冲突重检、暂存和提交全部进入共享生命周期队列。导入按能力解析会话 writer:有专用恢复入口时优先使用,否则使用普通的 `create` / `append` / `locate` 面——也就是历史版本恢复为副本写入所用的同一能力,因此后者能工作的地方导入就能工作。append writer 自带会话作用域回滚(创建任何东西之前先确认定位到的目录属于该会话本身),所以只有专用恢复入口才需要额外的删除能力;归档与元数据写入能力仍然必需。待恢复 id 尚不存在,因此对未知会话 fail-closed 的读取器正是能力探测的预期回答,不会中断恢复。工作区 attach 必须有对应 detach,否则按未分组警告处理。任一边界即使在抛错前已经改变状态,也会按逆序补偿;无法完成补偿时明确返回 rollback failed,而不会报告恢复成功。
|
|
117
117
|
|
|
118
118
|
## 回收与保护快照生命周期
|
|
119
119
|
|
|
120
120
|
`trash.json` 的合法状态只有 `trashed`、`purge-pending`、`degraded`。合法转换为 `missing -> trashed`、`trashed/degraded -> purge-pending`,以及任一现有状态在事务成功后移除。`purge-pending` 不得恢复。
|
|
121
121
|
|
|
122
|
-
保护快照格式是 `dsh-archived-chats/snapshot` v1,会话载荷是 `dsh-archived-chats/snapshot-session` v1。每个回收记录只引用一个活跃快照,重复恢复/回收产生的旧有效快照保留为历史,直到明确应用保留策略或永久删除。精确上限为:manifest 4 MiB、session JSON
|
|
122
|
+
保护快照格式是 `dsh-archived-chats/snapshot` v1,会话载荷是 `dsh-archived-chats/snapshot-session` v1。每个回收记录只引用一个活跃快照,重复恢复/回收产生的旧有效快照保留为历史,直到明确应用保留策略或永久删除。精确上限为:manifest 4 MiB、session JSON 64 MiB、1,000 个附件、单附件 32 MiB、总计 512 MiB;恢复验证先流式校验附件摘要,再在写入 Host 前逐件复读,绝不同时保留全部附件字节。发布、删除与状态文件的 rename 会同步文件和父目录;不支持目录 fsync 的 Windows 文件系统安全降级。Windows 在目标仍被其他句柄打开时无法原子替换文件或移除目录项(索引服务或杀毒扫描即可导致),因此替换与递归删除会在该平台上、且仅在该平台上,对瞬时的 `EPERM` / `EACCES` / `EBUSY` 做有界重试:POSIX 上这些码表示永久条件,重试只会延后同一个失败。快照发布还会在 rename 被拒时探测目标目录再判定为冲突,因为 Windows 把「rename 到已存在目录」报成 `EPERM` 而不是 `EEXIST`。路径包含判定使用平台分隔符并拒绝绝对结果,因此 Windows 的 `..\` 逃逸或不同盘符都不会被误判为在根目录内。
|
|
123
123
|
|
|
124
124
|
移入顺序为:校验归档所有权 → 处置/停放运行中会话 → 捕获并验证快照 → 再次校验所有权 → 原子写入 `trashed` 记录 → 使缓存失效。普通移入不删除持久层文件。
|
|
125
125
|
|
|
126
126
|
恢复先检查同 ID 冲突。原会话完好时只恢复归档可见性并移除回收记录,不重写持久层,保护快照保留为历史;原件丢失时先完成所有校验和附件身份重发,然后仅通过公开 `create` / `append` / `saveImage` 能力写入。失败会回滚新建件并保留回收记录。
|
|
127
127
|
|
|
128
|
-
永久删除在任何物理写入前持久化 `purge-pending
|
|
128
|
+
永久删除在任何物理写入前持久化 `purge-pending`,接着删除该来源的全部快照,然后删除原会话,最后移除回收记录。会话删除刻意放在最后:在它之前失败会保留完好的原件和可继续完成的记录,而不是留下一条会话已消失、既不能恢复也不能完成的 `purge-pending`。快照清扫按 manifest 身份归属每个已发布快照,因此校验失败但确属该会话的快照仍会被删除,而无关的无法校验快照会被跳过而不是中断整个清扫——快照库别处的损坏绝不能让永久删除变成不可能。回收记录还会点名自己的快照 id,覆盖损坏到无法归属的那一个。工作区、元数据、快照或物理删除任一步骤失败时都保留 `purge-pending`;快照删除后还会重新扫描确认,不会虚假报告成功。物理删除另外要求定位到的文件位于以该会话自身命名的目录中,因此当后端布局让多个会话共享同一父目录时,那个父目录永远不会被删除。启动恢复仅重试 `purge-pending`,从不删除普通 `trashed`。旧 `pending-deletions.json` 是严格、只读的迁移输入:每个仍归档的 ID 都转成可恢复回收记录,绝不因旧标记在启动时直接删除。
|
|
129
129
|
|
|
130
130
|
## 浏览器客户端
|
|
131
131
|
|
|
@@ -158,7 +158,7 @@ client.js 注册 order 30 的 settings.section,并使用 Harness 公开的浮
|
|
|
158
158
|
|
|
159
159
|
## 兼容性和测试
|
|
160
160
|
|
|
161
|
-
插件通过能力检测适配 Host
|
|
161
|
+
插件通过能力检测适配 Host:归档读取、附件读取、持久层写入和运行中会话生命周期能力分别判断,缺失能力必须安全降级或返回明确错误。导入、历史版本恢复为副本和原件丢失时的快照回退都通过公开的 `create` / `append` / `locate` 能力写入,Host 提供专用恢复入口时优先使用;只有两者都不存在才返回 `restore-unsupported` 且不写入数据。要求一组没有任何已发布 Host 能满足的能力不是合格的守卫——那会让功能永久失效,而不是优雅降级。旧版若不显示历史页或不识别回收快照,降级前应备份整个插件数据目录。
|
|
162
162
|
|
|
163
163
|
测试覆盖:
|
|
164
164
|
|
package/lib/client.js
CHANGED
|
@@ -261,6 +261,9 @@ window.__ModuleLoader__.load({
|
|
|
261
261
|
"history.scope.history-only": "仅历史保留",
|
|
262
262
|
"history.degraded": "无法读取的历史版本",
|
|
263
263
|
"history.degradedItem": "无法验证",
|
|
264
|
+
"history.degradedDelete": "清除",
|
|
265
|
+
"history.degradedDeleteTitle": "清除这个无法读取的历史版本?",
|
|
266
|
+
"trash.listUnverified": "回收站目录无法读取:下面的列表可能包含已删除的聊天,归档修改已暂停。",
|
|
264
267
|
"insights.loading": "正在分析空间…",
|
|
265
268
|
"insights.error": "空间分析暂不可用",
|
|
266
269
|
"insights.scopeNote": "这里只统计已归档、回收站会话,以及本插件为它们创建并继续保留的恢复快照。恢复聊天后快照仍可保留,所以归档列表为空时这里仍可能有数据。",
|
|
@@ -522,6 +525,9 @@ window.__ModuleLoader__.load({
|
|
|
522
525
|
"history.scope.history-only": "History only",
|
|
523
526
|
"history.degraded": "Unreadable history versions",
|
|
524
527
|
"history.degradedItem": "Could not verify",
|
|
528
|
+
"history.degradedDelete": "Reclaim",
|
|
529
|
+
"history.degradedDeleteTitle": "Reclaim this unreadable history version?",
|
|
530
|
+
"trash.listUnverified": "The Recycle Bin catalog is unreadable: this list may include deleted chats, and archive changes are paused.",
|
|
525
531
|
"insights.loading": "Analyzing storage…",
|
|
526
532
|
"insights.error": "Storage insights are unavailable",
|
|
527
533
|
"insights.scopeNote": "This page measures archived and recycled chats plus recovery snapshots this plugin created and retained for them. Restoring a chat may retain its snapshot, so storage can remain when the archive list is empty.",
|
|
@@ -805,6 +811,11 @@ window.__ModuleLoader__.load({
|
|
|
805
811
|
return new Date(ms).toLocaleString();
|
|
806
812
|
}
|
|
807
813
|
}
|
|
814
|
+
function isoTimestamp(ms) {
|
|
815
|
+
if (typeof ms !== "number" || !Number.isFinite(ms)) return undefined;
|
|
816
|
+
const date = new Date(ms);
|
|
817
|
+
return Number.isFinite(date.valueOf()) ? date.toISOString() : undefined;
|
|
818
|
+
}
|
|
808
819
|
|
|
809
820
|
function formatBytes(value) {
|
|
810
821
|
if (!Number.isFinite(value) || value < 0) return "—";
|
|
@@ -1403,6 +1414,10 @@ window.__ModuleLoader__.load({
|
|
|
1403
1414
|
return {
|
|
1404
1415
|
sessions: Array.isArray(body.sessions) ? body.sessions : [],
|
|
1405
1416
|
metadataStatus: body.metadataStatus === "unavailable" ? "unavailable" : "ready",
|
|
1417
|
+
// An unreadable recycle catalog cannot be subtracted from this list, so
|
|
1418
|
+
// already-deleted chats can reappear here. Surface it instead of
|
|
1419
|
+
// silently showing them as ordinary archived chats.
|
|
1420
|
+
trashStatus: body.trashStatus === "unavailable" ? "unavailable" : "ready",
|
|
1406
1421
|
};
|
|
1407
1422
|
}
|
|
1408
1423
|
|
|
@@ -1524,8 +1539,8 @@ window.__ModuleLoader__.load({
|
|
|
1524
1539
|
return res.blob();
|
|
1525
1540
|
}
|
|
1526
1541
|
|
|
1527
|
-
async function fetchArchiveSearch(query, limit = 50) {
|
|
1528
|
-
return post("/search", { query, limit });
|
|
1542
|
+
async function fetchArchiveSearch(query, limit = 50, signal) {
|
|
1543
|
+
return post("/search", { query, limit }, signal);
|
|
1529
1544
|
}
|
|
1530
1545
|
|
|
1531
1546
|
async function saveMetadata(sessionId, tags, note) {
|
|
@@ -2240,7 +2255,7 @@ window.__ModuleLoader__.load({
|
|
|
2240
2255
|
setCopied(true);
|
|
2241
2256
|
};
|
|
2242
2257
|
return (0, jsx.jsxs)("div", { className: "dac-preview-actions", children: [
|
|
2243
|
-
(0, jsx.jsx)("time", { dateTime:
|
|
2258
|
+
(0, jsx.jsx)("time", { dateTime: isoTimestamp(node.time), children: formatDate(t, node.time) }),
|
|
2244
2259
|
(0, jsx.jsx)("button", { type: "button", onClick: copy, "aria-label": copied ? t("preview.copied") : t("preview.copy"), children: copied ? t("preview.copied") : t("preview.copy") }),
|
|
2245
2260
|
] });
|
|
2246
2261
|
}
|
|
@@ -3077,6 +3092,14 @@ window.__ModuleLoader__.load({
|
|
|
3077
3092
|
setDeleteDialog({ kind: "one", session, version });
|
|
3078
3093
|
setMessage(null);
|
|
3079
3094
|
};
|
|
3095
|
+
// Unreadable versions still occupy the snapshot store, so they need their
|
|
3096
|
+
// own reclaim path — they never appear as a restorable version row.
|
|
3097
|
+
const askDeleteDegraded = (item) => {
|
|
3098
|
+
if (deleteBusy || restoreDialog !== null) return;
|
|
3099
|
+
deleteReturnFocusRef.current = document.activeElement;
|
|
3100
|
+
setDeleteDialog({ kind: "degraded", item });
|
|
3101
|
+
setMessage(null);
|
|
3102
|
+
};
|
|
3080
3103
|
const askClearHistory = () => {
|
|
3081
3104
|
if (clearSummary.versionCount === 0 || deleteBusy || restoreDialog !== null) return;
|
|
3082
3105
|
deleteReturnFocusRef.current = document.activeElement;
|
|
@@ -3088,9 +3111,9 @@ window.__ModuleLoader__.load({
|
|
|
3088
3111
|
const current = deleteDialog;
|
|
3089
3112
|
setDeleteBusy(true);
|
|
3090
3113
|
try {
|
|
3091
|
-
const result = current.kind === "
|
|
3092
|
-
? await
|
|
3093
|
-
: await
|
|
3114
|
+
const result = current.kind === "all"
|
|
3115
|
+
? await clearHistoryVersions()
|
|
3116
|
+
: await deleteHistoryVersion(current.kind === "degraded" ? current.item.snapshotId : current.version.snapshotId);
|
|
3094
3117
|
setDeleteDialog(null);
|
|
3095
3118
|
await load();
|
|
3096
3119
|
const deletedCount = Array.isArray(result?.deleted) ? result.deleted.length : 0;
|
|
@@ -3111,7 +3134,11 @@ window.__ModuleLoader__.load({
|
|
|
3111
3134
|
setDeleteBusy(false);
|
|
3112
3135
|
}
|
|
3113
3136
|
};
|
|
3114
|
-
const deleteBody = deleteDialog === null ? "" : deleteDialog.kind === "
|
|
3137
|
+
const deleteBody = deleteDialog === null ? "" : deleteDialog.kind === "degraded"
|
|
3138
|
+
? (isZh(t)
|
|
3139
|
+
? `该历史版本已无法读取或校验,不能再用于恢复。清除后将释放它占用的磁盘空间;原聊天不会被删除。`
|
|
3140
|
+
: `This history version can no longer be read or verified, so it cannot be restored from. Reclaiming it frees the disk space it still occupies. The original chat is not deleted.`)
|
|
3141
|
+
: deleteDialog.kind === "one"
|
|
3115
3142
|
? (() => {
|
|
3116
3143
|
const title = deleteDialog.session.title || t("chat.untitled");
|
|
3117
3144
|
const createdMs = Date.parse(deleteDialog.version.createdAt);
|
|
@@ -3205,6 +3232,13 @@ window.__ModuleLoader__.load({
|
|
|
3205
3232
|
...state.degraded.map((item) => (0, jsx.jsxs)("div", { className: "dac-history-degraded-row", children: [
|
|
3206
3233
|
(0, jsx.jsx)("code", { children: item.snapshotId }),
|
|
3207
3234
|
(0, jsx.jsx)("span", { children: t("history.degradedItem") }),
|
|
3235
|
+
(0, jsx.jsx)("button", {
|
|
3236
|
+
type: "button",
|
|
3237
|
+
className: "dac-btn dac-history-delete",
|
|
3238
|
+
disabled: deleteBusy || deleteDialog !== null || restoreDialog !== null,
|
|
3239
|
+
onClick: () => askDeleteDegraded(item),
|
|
3240
|
+
children: t("history.degradedDelete"),
|
|
3241
|
+
}),
|
|
3208
3242
|
] }, item.snapshotId)),
|
|
3209
3243
|
] }),
|
|
3210
3244
|
restoreDialog !== null && (0, jsx.jsx)(ConfirmDialog, {
|
|
@@ -3220,9 +3254,9 @@ window.__ModuleLoader__.load({
|
|
|
3220
3254
|
onCancel: () => setRestoreDialog(null),
|
|
3221
3255
|
}),
|
|
3222
3256
|
deleteDialog !== null && (0, jsx.jsx)(ConfirmDialog, {
|
|
3223
|
-
title: t(deleteDialog.kind === "one" ? "history.deleteTitle" : "history.clearTitle"),
|
|
3257
|
+
title: t(deleteDialog.kind === "one" ? "history.deleteTitle" : deleteDialog.kind === "degraded" ? "history.degradedDeleteTitle" : "history.clearTitle"),
|
|
3224
3258
|
body: deleteBody,
|
|
3225
|
-
confirmLabel: t(deleteDialog.kind === "
|
|
3259
|
+
confirmLabel: t(deleteDialog.kind === "all" ? "history.clear" : "history.deleteConfirm"),
|
|
3226
3260
|
cancelLabel: t("confirm.cancel"),
|
|
3227
3261
|
busy: deleteBusy,
|
|
3228
3262
|
returnFocus: deleteReturnFocusRef.current,
|
|
@@ -3254,6 +3288,7 @@ window.__ModuleLoader__.load({
|
|
|
3254
3288
|
const [selected, setSelected] = _react.useState(() => new Set());
|
|
3255
3289
|
const [tagFilter, setTagFilter] = _react.useState("");
|
|
3256
3290
|
const [metadataStatus, setMetadataStatus] = _react.useState("ready");
|
|
3291
|
+
const [archiveTrashStatus, setArchiveTrashStatus] = _react.useState("ready");
|
|
3257
3292
|
const [stats, setStats] = _react.useState({ status: "idle", summary: null, sessions: {} });
|
|
3258
3293
|
const [metadataEdit, setMetadataEdit] = _react.useState(null);
|
|
3259
3294
|
const [metaBusy, setMetaBusy] = _react.useState(false);
|
|
@@ -3273,7 +3308,7 @@ window.__ModuleLoader__.load({
|
|
|
3273
3308
|
const trashReturnFocusRef = _react.useRef(null);
|
|
3274
3309
|
const metaReturnFocusRef = _react.useRef(null);
|
|
3275
3310
|
const importInputRef = _react.useRef(null);
|
|
3276
|
-
const searchRequestRef = _react.useRef(0);
|
|
3311
|
+
const searchRequestRef = _react.useRef({ sequence: 0, controller: null });
|
|
3277
3312
|
const previewReturnFocusRef = _react.useRef(null);
|
|
3278
3313
|
const previewRequestRef = _react.useRef({ sequence: 0, controller: null });
|
|
3279
3314
|
|
|
@@ -3341,6 +3376,7 @@ window.__ModuleLoader__.load({
|
|
|
3341
3376
|
const loaded = await fetchState();
|
|
3342
3377
|
setSessions(loaded.sessions);
|
|
3343
3378
|
setMetadataStatus(loaded.metadataStatus);
|
|
3379
|
+
setArchiveTrashStatus(loaded.trashStatus);
|
|
3344
3380
|
setStats({ status: "idle", summary: null, sessions: {} });
|
|
3345
3381
|
} catch (error) {
|
|
3346
3382
|
setLoadError(error);
|
|
@@ -3406,16 +3442,18 @@ window.__ModuleLoader__.load({
|
|
|
3406
3442
|
}, [sessions, t]);
|
|
3407
3443
|
_react.useEffect(() => {
|
|
3408
3444
|
const trimmed = query.trim();
|
|
3409
|
-
const
|
|
3410
|
-
|
|
3445
|
+
const current = searchRequestRef.current;
|
|
3446
|
+
current.controller?.abort();
|
|
3447
|
+
const request = { sequence: current.sequence + 1, controller: new AbortController() };
|
|
3448
|
+
searchRequestRef.current = request;
|
|
3411
3449
|
if (Array.from(trimmed).length < 2) {
|
|
3412
3450
|
setContentSearch({ status: "idle", query: trimmed, hits: [], skipped: [] });
|
|
3413
|
-
return
|
|
3451
|
+
return () => request.controller.abort();
|
|
3414
3452
|
}
|
|
3415
3453
|
setContentSearch({ status: "loading", query: trimmed, hits: [], skipped: [] });
|
|
3416
3454
|
const timer = setTimeout(() => {
|
|
3417
|
-
fetchArchiveSearch(trimmed, 100).then((result) => {
|
|
3418
|
-
if (searchRequestRef.current !==
|
|
3455
|
+
fetchArchiveSearch(trimmed, 100, request.controller.signal).then((result) => {
|
|
3456
|
+
if (searchRequestRef.current !== request || request.controller.signal.aborted) return;
|
|
3419
3457
|
setContentSearch({
|
|
3420
3458
|
status: "ready",
|
|
3421
3459
|
query: trimmed,
|
|
@@ -3423,11 +3461,11 @@ window.__ModuleLoader__.load({
|
|
|
3423
3461
|
skipped: Array.isArray(result?.skipped) ? result.skipped : [],
|
|
3424
3462
|
});
|
|
3425
3463
|
}).catch(() => {
|
|
3426
|
-
if (searchRequestRef.current !==
|
|
3464
|
+
if (searchRequestRef.current !== request || request.controller.signal.aborted) return;
|
|
3427
3465
|
setContentSearch({ status: "error", query: trimmed, hits: [], skipped: [] });
|
|
3428
3466
|
});
|
|
3429
3467
|
}, 300);
|
|
3430
|
-
return () => clearTimeout(timer);
|
|
3468
|
+
return () => { clearTimeout(timer); request.controller.abort(); };
|
|
3431
3469
|
}, [query]);
|
|
3432
3470
|
_react.useEffect(() => {
|
|
3433
3471
|
if (sessions === null) return;
|
|
@@ -3878,7 +3916,9 @@ window.__ModuleLoader__.load({
|
|
|
3878
3916
|
}, [sessions, query, projectFilter, typeFilter, tagFilter, sortMode, contentHits, t]);
|
|
3879
3917
|
|
|
3880
3918
|
if (sessions === null && loadError === null) {
|
|
3881
|
-
|
|
3919
|
+
// jsxs is the runtime for a static children array; jsx would make React
|
|
3920
|
+
// treat it as a dynamic list and warn about missing keys on every open.
|
|
3921
|
+
return (0, jsx.jsxs)("div", { className: "dac-center", children: [(0, jsx.jsx)("span", { className: "dac-spin" }), (0, jsx.jsx)("span", { children: t("state.loading") })] });
|
|
3882
3922
|
}
|
|
3883
3923
|
if (loadError !== null && sessions === null) {
|
|
3884
3924
|
return (0, jsx.jsxs)("div", {
|
|
@@ -3986,6 +4026,11 @@ window.__ModuleLoader__.load({
|
|
|
3986
4026
|
role: "status",
|
|
3987
4027
|
children: t("meta.unavailable")
|
|
3988
4028
|
}),
|
|
4029
|
+
archiveTrashStatus === "unavailable" && (0, jsx.jsx)("div", {
|
|
4030
|
+
className: "dac-warn",
|
|
4031
|
+
role: "status",
|
|
4032
|
+
children: t("trash.listUnverified")
|
|
4033
|
+
}),
|
|
3989
4034
|
(0, jsx.jsxs)("div", {
|
|
3990
4035
|
className: "dac-search",
|
|
3991
4036
|
children: [
|
package/lib/durable.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, open, rename, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const DIRECTORY_SYNC_UNSUPPORTED = new Set(['EINVAL', 'ENOTSUP']);
|
|
6
|
+
const WINDOWS_DIRECTORY_SYNC_UNSUPPORTED = new Set(['EACCES', 'EISDIR', 'EPERM']);
|
|
7
|
+
/**
|
|
8
|
+
* Windows has no atomic-replace guarantee while another handle is open on the
|
|
9
|
+
* destination — an indexer or antivirus scan is enough to fail the rename with
|
|
10
|
+
* EPERM/EACCES/EBUSY. These are transient, so the replace is retried briefly.
|
|
11
|
+
* POSIX rename never reports them for this operation, so the loop is a no-op.
|
|
12
|
+
*/
|
|
13
|
+
const RENAME_RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
|
14
|
+
const RENAME_ATTEMPTS = 10;
|
|
15
|
+
const RENAME_DELAY_MS = 20;
|
|
16
|
+
|
|
17
|
+
function directorySyncUnsupported(error) {
|
|
18
|
+
return DIRECTORY_SYNC_UNSUPPORTED.has(error?.code)
|
|
19
|
+
|| (process.platform === 'win32' && WINDOWS_DIRECTORY_SYNC_UNSUPPORTED.has(error?.code));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Flush a file after it has been written. `r+` is supported on Windows. */
|
|
23
|
+
export async function syncFile(path, openFile = open) {
|
|
24
|
+
const handle = await openFile(path, 'r+');
|
|
25
|
+
try { await handle.sync(); } finally { await handle.close(); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Flush directory-entry changes where the platform exposes directory fsync. */
|
|
29
|
+
export async function syncDirectory(path, openFile = open) {
|
|
30
|
+
let handle;
|
|
31
|
+
try { handle = await openFile(path, 'r'); }
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (directorySyncUnsupported(error)) return false;
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
await handle.sync();
|
|
38
|
+
return true;
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (directorySyncUnsupported(error)) return false;
|
|
41
|
+
throw error;
|
|
42
|
+
} finally {
|
|
43
|
+
await handle.close();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Replace one file by rename, retrying only the transient Windows codes. On
|
|
49
|
+
* POSIX these codes are permanent conditions, so retrying there would just add
|
|
50
|
+
* latency before the same failure.
|
|
51
|
+
*/
|
|
52
|
+
export async function replaceFile(tempPath, filePath, {
|
|
53
|
+
renameFile = rename,
|
|
54
|
+
platform = process.platform,
|
|
55
|
+
delayMs = RENAME_DELAY_MS,
|
|
56
|
+
attempts = RENAME_ATTEMPTS,
|
|
57
|
+
} = {}) {
|
|
58
|
+
for (let attempt = 1; ; attempt += 1) {
|
|
59
|
+
try {
|
|
60
|
+
await renameFile(tempPath, filePath);
|
|
61
|
+
return attempt;
|
|
62
|
+
} catch (error) {
|
|
63
|
+
if (attempt >= attempts || platform !== 'win32' || !RENAME_RETRY_CODES.has(error?.code)) throw error;
|
|
64
|
+
await new Promise((resolve) => { setTimeout(resolve, delayMs * attempt); });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Atomically replace one private file and make the rename crash-durable. */
|
|
70
|
+
export async function atomicWriteFile(filePath, data, { encoding = undefined } = {}) {
|
|
71
|
+
const directory = dirname(filePath);
|
|
72
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
73
|
+
await chmod(directory, 0o700);
|
|
74
|
+
let tempPath = null;
|
|
75
|
+
try {
|
|
76
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
77
|
+
const candidate = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
78
|
+
try {
|
|
79
|
+
await writeFile(candidate, data, {
|
|
80
|
+
...(encoding === undefined ? {} : { encoding }),
|
|
81
|
+
mode: 0o600,
|
|
82
|
+
flag: 'wx',
|
|
83
|
+
});
|
|
84
|
+
tempPath = candidate;
|
|
85
|
+
break;
|
|
86
|
+
} catch (error) {
|
|
87
|
+
await rm(candidate, { force: true }).catch(() => undefined);
|
|
88
|
+
if (error?.code !== 'EEXIST' || attempt === 7) throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
await chmod(tempPath, 0o600);
|
|
92
|
+
await syncFile(tempPath);
|
|
93
|
+
await replaceFile(tempPath, filePath);
|
|
94
|
+
tempPath = null;
|
|
95
|
+
await syncDirectory(directory);
|
|
96
|
+
} finally {
|
|
97
|
+
if (tempPath !== null) await rm(tempPath, { force: true }).catch(() => undefined);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/lib/export.js
CHANGED
|
@@ -14,6 +14,17 @@ function codePoints(value) {
|
|
|
14
14
|
return [...value];
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* Neutralize a Windows device name. The reservation covers every extension too
|
|
19
|
+
* (`NUL.txt` is the device), so the marker has to change the base name instead
|
|
20
|
+
* of trailing the whole segment.
|
|
21
|
+
*/
|
|
22
|
+
function deviceSafe(segment) {
|
|
23
|
+
if (!RESERVED_BASENAME.test(segment)) return segment;
|
|
24
|
+
const dot = segment.indexOf('.');
|
|
25
|
+
return dot === -1 ? `${segment}-file` : `${segment.slice(0, dot)}-file${segment.slice(dot)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
17
28
|
/** Normalize untrusted text into one cross-platform archive path segment. */
|
|
18
29
|
export function safeSegment(value, fallback = 'untitled', maxLength = 80) {
|
|
19
30
|
const limit = Number.isInteger(maxLength) && maxLength > 0 ? maxLength : 80;
|
|
@@ -25,13 +36,13 @@ export function safeSegment(value, fallback = 'untitled', maxLength = 80) {
|
|
|
25
36
|
.replace(/^[. -]+|[. -]+$/g, '');
|
|
26
37
|
|
|
27
38
|
if (segment === '') segment = String(fallback).normalize('NFKC');
|
|
28
|
-
if (RESERVED_BASENAME.test(segment)) segment = `${segment}-file`;
|
|
29
39
|
|
|
40
|
+
// Truncate first: shortening can itself expose a reserved base name, and the
|
|
41
|
+
// marker is applied exactly once afterwards so it never stacks.
|
|
30
42
|
segment = codePoints(segment).slice(0, limit).join('')
|
|
31
43
|
.replace(/^[. -]+|[. -]+$/g, '');
|
|
32
44
|
if (segment === '') return 'untitled';
|
|
33
|
-
|
|
34
|
-
return segment;
|
|
45
|
+
return deviceSafe(segment);
|
|
35
46
|
}
|
|
36
47
|
|
|
37
48
|
function nullableString(value) {
|
|
@@ -237,6 +248,12 @@ function messageLabel(message) {
|
|
|
237
248
|
return 'System';
|
|
238
249
|
}
|
|
239
250
|
|
|
251
|
+
function isoTimestamp(value) {
|
|
252
|
+
if (!Number.isFinite(value)) return null;
|
|
253
|
+
const date = new Date(value);
|
|
254
|
+
return Number.isFinite(date.valueOf()) ? date.toISOString() : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
240
257
|
/** Render the durable human transcript from append-origin Harness messages. */
|
|
241
258
|
export function renderTranscript(item, events, exportedAt) {
|
|
242
259
|
const lines = [
|
|
@@ -262,9 +279,8 @@ export function renderTranscript(item, events, exportedAt) {
|
|
|
262
279
|
if (!isAppendSurfaceEvent(event)) continue;
|
|
263
280
|
const message = deriveEventMessage(event);
|
|
264
281
|
if (message === null) continue;
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
: '';
|
|
282
|
+
const isoTime = isoTimestamp(event.time);
|
|
283
|
+
const timestamp = isoTime === null ? '' : ` - ${isoTime}`;
|
|
268
284
|
const content = Array.isArray(message.content)
|
|
269
285
|
? message.content.map(renderContentBlock).filter(Boolean).join('\n\n')
|
|
270
286
|
: fenced('json', jsonText(message.content));
|