@mzzsfy/dsh-session-manager 0.8.1 → 0.9.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/src/core.mjs CHANGED
@@ -1,344 +1,262 @@
1
- // 纯逻辑层:归档评估状态机、删除资格与失败矩阵、面板投影、归档集合差分。
2
- // 全部为无副作用纯函数,host 与测试共用。
3
-
4
- export const DAY_MS = 24 * 60 * 60 * 1000
5
- export const DEFAULT_AUTO_ARCHIVE_DAYS = 7
6
- export const DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS = 24
7
-
8
- /**
9
- * 自动归档配置的提交判定:输入框文本对照已提交值分类。
10
- * @param committedText - 该字段已提交值的字符串形态
11
- * @param text - 输入框当前文本
12
- * @returns noop 值未变 | invalid 非非负整数 | post 可提交(携带解析后的数值)
13
- */
14
- export function classifyAutoArchiveInput(committedText, text) {
15
- const trimmed = text.trim()
16
- if (trimmed === committedText) return { action: 'noop' }
17
- const value = Number(trimmed)
18
- if (trimmed === '' || !Number.isInteger(value) || value < 0) return { action: 'invalid' }
19
- return { action: 'post', value }
20
- }
21
-
22
- /** 更新时间 = max(createdAt, 最近活跃时间)。 */
23
- export function updatedAtOf(header, activityAtMs) {
24
- return Math.max(header.createdAt, activityAtMs ?? 0)
25
- }
26
-
27
- /**
28
- * 归档评估状态机:按阈值筛出待归档会话 id。
29
- * @param records - 候选行 {id, archived, running, blank, updatedAt}
30
- * @returns 超期且未归档、非运行中、非空白的会话 id
31
- */
32
- export function selectArchiveCandidates({ records, nowMs, thresholdDays }) {
33
- if (!Number.isFinite(thresholdDays) || thresholdDays <= 0) return []
34
- const cutoff = nowMs - thresholdDays * DAY_MS
35
- return records
36
- .filter((item) => !item.archived && !item.running && !item.blank && item.updatedAt < cutoff)
37
- .map((item) => item.id)
38
- }
39
-
40
- /**
41
- * 会话所属工作区标题解析(与 client 镜像同规):workspace 账本(sessionIds)
42
- * 一级映射与官方 dsh-client-ui-workspace 的 workspaceBySession 同构;回退链为本
43
- * 插件收紧决策——官方对账本缺失回退会话 cwd 的 basename(无需命中登记工作区),
44
- * 此处要求 cwd 命中某登记工作区路径才回退其标题,否则未分组(null),避免把未
45
- * 登记目录名冒充工作区。工作区 title 缺失时回退路径末段(官方 workspaceTitleOf
46
- * 同构);title 与 path 皆缺返回 null。
47
- */
48
- export function workspaceTitleOf(path) {
49
- const trimmed = String(path).replace(/[/\\]+$/, '')
50
- const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
51
- return trimmed.slice(separator + 1)
52
- }
53
-
54
- export function workspaceTitleForSession({ workspaces, sessionId, cwd }) {
55
- const items = Array.isArray(workspaces) ? workspaces : []
56
- for (const workspace of items) {
57
- if (workspace && Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) {
58
- return workspace.title || (workspace.path ? workspaceTitleOf(workspace.path) : null)
59
- }
60
- }
61
- const normalizedCwd = cwd ? String(cwd).replace(/[/\\]+$/, '') : ''
62
- if (!normalizedCwd) return null
63
- for (const workspace of items) {
64
- if (workspace && workspace.path && String(workspace.path).replace(/[/\\]+$/, '') === normalizedCwd) {
65
- return workspace.title || workspaceTitleOf(workspace.path)
66
- }
67
- }
68
- return null
69
- }
70
-
71
- /** 归档面板行:session.list 行与归档集合的交集,按更新时间倒序;标题缺失回退会话 id;workspace 为所属工作区标题或 null(与 client 镜像同规)。 */
72
- export function projectArchiveRows({ rows, archivedIds, workspaces }) {
73
- const archived = new Set(archivedIds)
74
- return rows
75
- .filter((row) => archived.has(row.id))
76
- .map((row) => ({
77
- id: row.id,
78
- title: row.title || row.id,
79
- updatedAt: row.updatedAt,
80
- workspace: workspaceTitleForSession({ workspaces, sessionId: row.id, cwd: row.cwd }),
81
- }))
82
- .sort((left, right) => right.updatedAt - left.updatedAt)
83
- }
84
-
85
- /** 归档集合差分:previous 缺省(基线首帧)不产生提示。 */
86
- export function diffArchived(previousIds, nextIds) {
87
- if (previousIds === undefined) return []
88
- const previous = new Set(previousIds)
89
- return nextIds.filter((id) => !previous.has(id))
90
- }
91
-
92
- /**
93
- * Toast 差分步进:连续两个 ready 快照才计新增。
94
- * 模型订阅即时发射 pending 空态,基线(存量归档)成为第二帧;基线是重连权威而非
95
- * 归档事件,任一单帧都无法与事件区分,故以「上一帧也是 ready」为差分启用条件,
96
- * 启动与重连首装不误报,重连基线携带的离期新增仍会提示。
97
- * client.js 有一份镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
98
- */
99
- export function archiveToastStep(previous, snapshot) {
100
- const ready = Boolean(snapshot && snapshot.phase === 'ready')
101
- const ids = (snapshot && snapshot.archivedSessionIds) || []
102
- const added = previous !== undefined && previous.ready && ready ? diffArchived(previous.ids, ids) : []
103
- return { state: { ready, ids }, added }
104
- }
105
-
106
- /** 归档通知标题列举上限:超过该数只列前段并以「 等」收尾。 */
107
- export const TOAST_MAX_TITLES = 3
108
-
109
- /**
110
- * 归档通知文案:单个报标题,多个为计数加列举标题,标题数超展示阈值只列前段并以
111
- * 「 等」收尾,计数恒为真实总数。标题按去除首尾空白取值,缺失、空白或行不存在
112
- * 回退会话 id,行数据非数组视为空。client.js 有镜像实现(单文件自包含无法跨文件
113
- * require),修改需两处同步。
114
- */
115
- export function archiveToastText(addedIds, rows) {
116
- const added = Array.isArray(addedIds) ? addedIds : []
117
- if (added.length === 0) return ''
118
- const safeRows = Array.isArray(rows) ? rows : []
119
- const titleById = new Map()
120
- for (const row of safeRows) {
121
- if (!row || row.id === undefined || row.id === null) continue
122
- const trimmed = String(row.title ?? '').trim()
123
- if (trimmed !== '') titleById.set(String(row.id), trimmed)
124
- }
125
- const names = added.map((id) => titleById.get(String(id)) || String(id))
126
- if (names.length === 1) return '会话「' + names[0] + '」已归档'
127
- const listed = names.slice(0, TOAST_MAX_TITLES)
128
- return '有 ' + names.length + ' 个会话已归档:' + listed.join('、') + (names.length > TOAST_MAX_TITLES ? ' 等' : '')
129
- }
130
-
131
- /** 删除资格:仅已归档会话,其余拒绝(竞态防护的 client 前置与 host 权威共用判定)。 */
132
- export function deleteEligibility({ archivedIds, sessionId }) {
133
- return archivedIds.includes(sessionId)
134
- ? { ok: true }
135
- : { ok: false, code: 'not-archived' }
136
- }
137
-
138
- /** 运行中判定:agent 注册表 status 为 running 即运行中,与归档评估同款判据。 */
139
- export function isSessionRunning({ agents, sessionId }) {
140
- const entry = agents ? agents.get(sessionId) : undefined
141
- return Boolean(entry && entry.status === 'running')
142
- }
143
-
144
- // 删除半失败聚合文案:trash 成功后各收尾失败点的主文案与后缀,host 响应逐键消费
145
- export const DELETE_MESSAGES = {
146
- partial: '已移入回收站,但移除列表记录失败',
147
- archiveCleanup: '已移入回收站,但移除归档记录失败',
148
- ledgerFailed: '已移入回收站,但重挂载记录失败',
149
- ledgerSuffix: ',且重挂载记录失败',
150
- runningDuringTrash: '警告:回收期间会话恢复运行,产物已移入回收站;建议检查会话状态',
151
- }
152
-
153
- /**
154
- * 删除收尾半失败聚合:trash 成功后的失败矩阵折叠为单一响应体。
155
- * 主文案优先级 detach → 归档清理 → 台账,台账失败以后缀并入前两者,运行中翻转
156
- * 以警告后缀并入一切形态。三形态逐键一致:{ok} / {ok,message} / {ok,partial,message}。
157
- */
158
- export function aggregateDeleteOutcome({ detachFailed = false, archiveCleanupFailed = false, ledgerFailed = false, runningDuringTrash = false }) {
159
- const warningSuffix = runningDuringTrash ? ';' + DELETE_MESSAGES.runningDuringTrash : ''
160
- const ledgerSuffix = ledgerFailed ? DELETE_MESSAGES.ledgerSuffix : ''
161
- if (detachFailed) return { ok: true, partial: true, message: DELETE_MESSAGES.partial + ledgerSuffix + warningSuffix }
162
- if (archiveCleanupFailed) return { ok: true, partial: true, message: DELETE_MESSAGES.archiveCleanup + ledgerSuffix + warningSuffix }
163
- if (ledgerFailed) return { ok: true, partial: true, message: DELETE_MESSAGES.ledgerFailed + warningSuffix }
164
- return runningDuringTrash
165
- ? { ok: true, message: DELETE_MESSAGES.runningDuringTrash }
166
- : { ok: true }
167
- }
168
-
169
- /** 空白产物判定:读到 EOF 且换行数不足两行,即仅 header(或空)视为空白。 */
170
- export function artifactLooksBlank(headText, hasMore) {
171
- let breaks = 0
172
- for (const char of headText) {
173
- if (char === '\n') breaks += 1
174
- if (breaks >= 2) return false
175
- }
176
- return !hasMore && breaks < 2
177
- }
178
-
179
- /** 台账合并:同 id 替换,新条目置顶;返回新数组,不改入参。client.js 有镜像,修改需两处同步。 */
180
- export function mergeDeletedEntry(deleted, entry) {
181
- return [entry, ...deleted.filter((item) => item.sessionId !== entry.sessionId)]
182
- }
183
-
184
- /** 台账移除:幂等;removed 标记是否发生变化,调用方据此决定是否写回。 */
185
- export function removeDeletedEntry(deleted, sessionId) {
186
- const next = deleted.filter((item) => item.sessionId !== sessionId)
187
- return { deleted: next, removed: next.length !== deleted.length }
188
- }
189
-
190
- /** 已删除面板行:标题取会话行展示标题,缺失回退会话 id,按删除时间倒序。client.js 有镜像,修改需两处同步。 */
191
- export function projectDeletedRows(deleted, sessionsById) {
192
- const byId = sessionsById || {}
193
- return [...deleted]
194
- .sort((left, right) => right.deletedAt - left.deletedAt)
195
- .map((item) => ({
196
- sessionId: item.sessionId,
197
- path: item.path,
198
- deletedAt: item.deletedAt,
199
- title: (byId[item.sessionId] && byId[item.sessionId].displayTitle) || item.sessionId,
200
- }))
201
- }
202
-
203
- // ── 归档库投影:主列表分页 / 工作区分组 / 标题搜索 ──
204
-
205
- /** 归档库主列表默认展开行数与手动展开步长。 */
206
- export const ARCHIVE_PAGE_SIZE = 100
207
-
208
- /**
209
- * 归档列表分页:取前 visibleCount 条并报告剩余量,非有限或非正可见数按 0 处理。
210
- * client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
211
- */
212
- export function pageArchiveRows(rows, visibleCount) {
213
- const list = Array.isArray(rows) ? rows : []
214
- const count = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0
215
- const visible = list.slice(0, count)
216
- return { visible, remaining: list.length - visible.length }
217
- }
218
-
219
- /**
220
- * 归档列表按工作区分组:workspace 假值并桶为未分组(null),组内保持输入序
221
- * (调用方输入恒为时间倒序投影),组间按组内最新 updatedAt 倒序。
222
- * client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
223
- */
224
- export function groupArchiveRowsByWorkspace(rows) {
225
- const list = Array.isArray(rows) ? rows : []
226
- const byKey = new Map()
227
- for (const row of list) {
228
- const title = row.workspace ? String(row.workspace) : ''
229
- if (!byKey.has(title)) byKey.set(title, { workspace: title === '' ? null : title, latest: row.updatedAt, rows: [] })
230
- const group = byKey.get(title)
231
- group.rows.push(row)
232
- if (row.updatedAt > group.latest) group.latest = row.updatedAt
233
- }
234
- return [...byKey.values()]
235
- .sort((left, right) => right.latest - left.latest)
236
- .map(({ workspace, rows: groupRows }) => ({ workspace, rows: groupRows }))
237
- }
238
-
239
- /**
240
- * 归档列表标题搜索:查询词去首尾空白后按不区分大小写子串匹配标题,
241
- * 空查询原样返回全量。client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
242
- */
243
- export function filterArchiveRows(rows, query) {
244
- const list = Array.isArray(rows) ? rows : []
245
- const needle = String(query ?? '').trim().toLowerCase()
246
- if (needle === '') return list
247
- return list.filter((row) => String(row.title ?? '').toLowerCase().includes(needle))
248
- }
249
-
250
- // 历史输入回溯参数:host 聚合路由使用(client 单文件自包含不经此,展示侧另行内联)。
251
- // 范围按 ←/→ 切换序排列:索引 0 为常用提示词(个人收藏),索引 1 为当前会话
252
- // (浮层默认落点),→ 向更大范围,← 返回收藏
253
- // 对齐 = 后台解压范围内会话产物与持久缓存合并;两次对齐最小间隔防持续解压。
254
- // 解压是 CPU 操作:启动零解压(历史直接读磁盘缓存),全量对齐延迟 STARTUP_DELAY
255
- // 再跑、只回溯最近 STARTUP_SCAN 个会话;超 MAX_ARTIFACT 的巨产物跳过——
256
- // 上限防的是解压耗时与事件对象内存峰值失控,非阻塞主循环;跳过线按宿主版本
257
- // 黑名单取值(见 isLegacyDecompressHost),对齐中连续解压占用超 SLICE 即让出 YIELD。
258
- // 运行中会话(当前会话)提取结果只保留内存不落盘——产物持续变化,落盘指纹立即失效
259
- export const HISTORY_SESSION_SCAN_LIMIT = 20
260
- export const HISTORY_INPUT_LIMIT = 200
261
- export const HISTORY_INPUT_MAX_CHARS = 20 * 1000
262
- export const HISTORY_SCOPES = ['prompts', 'session', 'workspace', 'global']
263
- export const HISTORY_PROMPTS_MAX = 100
264
- export const HISTORY_ALIGN_THROTTLE_MS = 30 * 1000
265
- export const HISTORY_STARTUP_DELAY_MS = 30 * 1000
266
- export const HISTORY_STARTUP_SCAN_LIMIT = 100
267
- export const HISTORY_ALIGN_SLICE_MS = 200
268
- export const HISTORY_ALIGN_YIELD_MS = 100
269
- // 巨产物跳过线(压缩字节数):现役最大产物约 3.5MiB。新解压线 16MiB 留 4 倍余量,
270
- // 超线产物解压后可达数百 MB(压缩比约 1:3.4,事件对象再放大),内存峰值失控;
271
- // 旧解压线 8MiB 面向 0.1.1/0.1.2 黑名单宿主——其解压实现尚无周期让出,从严
272
- export const HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES = 16 * 1024 * 1024
273
- export const HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES = 8 * 1024 * 1024
274
- // 与官方 semver 语义对齐的宽松校验:黑名单判定只取主/次/修订号,prerelease 无关
275
- const SEMVER_PATTERN = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)/
276
- // 旧解压实现黑名单:仅 0.1.1 与 0.1.2 系列(含各 prerelease);0.1.5 实测
277
- // readZstdPrefix 逐帧解压 + 周期让出。版本非法/缺失按黑名单保守回退旧线
278
- export function isLegacyDecompressHost(version) {
279
- const match = SEMVER_PATTERN.exec(typeof version === 'string' ? version.trim() : '')
280
- if (!match) return true
281
- const major = Number(match[1])
282
- const minor = Number(match[2])
283
- const patch = Number(match[3])
284
- return major === 0 && minor === 1 && (patch === 1 || patch === 2)
285
- }
286
- export function maxArtifactBytesForHost(version) {
287
- return isLegacyDecompressHost(version) ? HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES : HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES
288
- }
289
- // 会话范围路由触发焦点对齐后就地等待的时限:首条数据可见预算(3s)内
290
- // 留出解压+读盘份额,超时改走轮询流式补齐
291
- export const HISTORY_FOCUS_WAIT_MS = 2 * 1000
292
-
293
- /**
294
- * 从会话事件流提取人类输入:仅 user/message 且来源为用户本人,
295
- * 文本块按行拼接;工具结果回填、插件注入与空白输入(纯图/空/纯空白)均不产出。
296
- * @param events - readSession 返回的原始事件数组
297
- * @returns 条目 { text, at },时间取事件时间
298
- */
299
- export function extractUserInputs(events) {
300
- const entries = []
301
- for (const event of events || []) {
302
- if (!event || event.type !== 'user/message') continue
303
- const message = event.data
304
- if (!message || !message.source || message.source.kind !== 'user') continue
305
- const text = (message.content || [])
306
- .filter((block) => block && block.type === 'text' && block.text !== '')
307
- .map((block) => block.text)
308
- .join('\n')
309
- if (text.trim() === '') continue
310
- entries.push({ text, at: event.time })
311
- }
312
- return entries
313
- }
314
-
315
- /** 历史输入聚合:精确文本去重保留最新时间,时间倒序,截 limit 条,单条截 maxChars 字符。
316
- * 条目可携带 sid(来源会话)用于 session 范围过滤,聚合时原样保留。
317
- * 首条保护:被 limit 挤出时,每个来源会话在去重后集合中的最早条目仍保留——
318
- * 会话开场提示词是最有复用价值也最旧的一批,时间倒序截断总会先挤掉它们;
319
- * 保护在去重后集合上计算,文本已被更新的会话复用时不再按旧会话重复追加。 */
320
- export function aggregateInputs(entries, { limit, maxChars }) {
321
- const latestByText = new Map()
322
- for (const entry of entries || []) {
323
- const known = latestByText.get(entry.text)
324
- if (known === undefined || entry.at > known.at) latestByText.set(entry.text, entry)
325
- }
326
- const earliestBySid = new Map()
327
- for (const entry of latestByText.values()) {
328
- if (entry.sid === undefined) continue
329
- const known = earliestBySid.get(entry.sid)
330
- if (known === undefined || entry.at < known.at) earliestBySid.set(entry.sid, entry)
331
- }
332
- const kept = [...latestByText.values()].sort((left, right) => right.at - left.at).slice(0, limit)
333
- const keptTexts = new Set(kept.map((entry) => entry.text))
334
- for (const first of earliestBySid.values()) {
335
- if (!keptTexts.has(first.text)) kept.push(first)
336
- }
337
- return kept
338
- .sort((left, right) => right.at - left.at)
339
- .map((entry) => ({
340
- text: entry.text.slice(0, maxChars),
341
- at: entry.at,
342
- ...(entry.sid !== undefined ? { sid: entry.sid } : {}),
343
- }))
344
- }
1
+ // 纯逻辑层:归档评估状态机、删除资格与失败矩阵、面板投影、归档集合差分。
2
+ // 全部为无副作用纯函数,host 与测试共用。
3
+
4
+ export const DAY_MS = 24 * 60 * 60 * 1000
5
+ export const DEFAULT_AUTO_ARCHIVE_DAYS = 7
6
+ export const DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS = 24
7
+
8
+ /**
9
+ * 自动归档配置的提交判定:输入框文本对照已提交值分类。
10
+ * @param committedText - 该字段已提交值的字符串形态
11
+ * @param text - 输入框当前文本
12
+ * @returns noop 值未变 | invalid 非非负整数 | post 可提交(携带解析后的数值)
13
+ */
14
+ export function classifyAutoArchiveInput(committedText, text) {
15
+ const trimmed = text.trim()
16
+ if (trimmed === committedText) return { action: 'noop' }
17
+ const value = Number(trimmed)
18
+ if (trimmed === '' || !Number.isInteger(value) || value < 0) return { action: 'invalid' }
19
+ return { action: 'post', value }
20
+ }
21
+
22
+ /** 更新时间 = max(createdAt, 最近活跃时间)。 */
23
+ export function updatedAtOf(header, activityAtMs) {
24
+ return Math.max(header.createdAt, activityAtMs ?? 0)
25
+ }
26
+
27
+ /**
28
+ * 归档评估状态机:按阈值筛出待归档会话 id。
29
+ * @param records - 候选行 {id, archived, running, blank, updatedAt}
30
+ * @returns 超期且未归档、非运行中、非空白的会话 id
31
+ */
32
+ export function selectArchiveCandidates({ records, nowMs, thresholdDays }) {
33
+ if (!Number.isFinite(thresholdDays) || thresholdDays <= 0) return []
34
+ const cutoff = nowMs - thresholdDays * DAY_MS
35
+ return records
36
+ .filter((item) => !item.archived && !item.running && !item.blank && item.updatedAt < cutoff)
37
+ .map((item) => item.id)
38
+ }
39
+
40
+ /**
41
+ * 会话所属工作区标题解析(与 client 镜像同规):workspace 账本(sessionIds)
42
+ * 一级映射与官方 dsh-client-ui-workspace 的 workspaceBySession 同构;回退链为本
43
+ * 插件收紧决策——官方对账本缺失回退会话 cwd 的 basename(无需命中登记工作区),
44
+ * 此处要求 cwd 命中某登记工作区路径才回退其标题,否则未分组(null),避免把未
45
+ * 登记目录名冒充工作区。工作区 title 缺失时回退路径末段(官方 workspaceTitleOf
46
+ * 同构);title 与 path 皆缺返回 null。
47
+ */
48
+ export function workspaceTitleOf(path) {
49
+ const trimmed = String(path).replace(/[/\\]+$/, '')
50
+ const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
51
+ return trimmed.slice(separator + 1)
52
+ }
53
+
54
+ export function workspaceTitleForSession({ workspaces, sessionId, cwd }) {
55
+ const items = Array.isArray(workspaces) ? workspaces : []
56
+ for (const workspace of items) {
57
+ if (workspace && Array.isArray(workspace.sessionIds) && workspace.sessionIds.includes(sessionId)) {
58
+ return workspace.title || (workspace.path ? workspaceTitleOf(workspace.path) : null)
59
+ }
60
+ }
61
+ const normalizedCwd = cwd ? String(cwd).replace(/[/\\]+$/, '') : ''
62
+ if (!normalizedCwd) return null
63
+ for (const workspace of items) {
64
+ if (workspace && workspace.path && String(workspace.path).replace(/[/\\]+$/, '') === normalizedCwd) {
65
+ return workspace.title || workspaceTitleOf(workspace.path)
66
+ }
67
+ }
68
+ return null
69
+ }
70
+
71
+ /** 归档面板行:session.list 行与归档集合的交集,按更新时间倒序;标题缺失回退会话 id;workspace 为所属工作区标题或 null(与 client 镜像同规)。 */
72
+ export function projectArchiveRows({ rows, archivedIds, workspaces }) {
73
+ const archived = new Set(archivedIds)
74
+ return rows
75
+ .filter((row) => archived.has(row.id))
76
+ .map((row) => ({
77
+ id: row.id,
78
+ title: row.title || row.id,
79
+ updatedAt: row.updatedAt,
80
+ workspace: workspaceTitleForSession({ workspaces, sessionId: row.id, cwd: row.cwd }),
81
+ }))
82
+ .sort((left, right) => right.updatedAt - left.updatedAt)
83
+ }
84
+
85
+ /** 归档集合差分:previous 缺省(基线首帧)不产生提示。 */
86
+ export function diffArchived(previousIds, nextIds) {
87
+ if (previousIds === undefined) return []
88
+ const previous = new Set(previousIds)
89
+ return nextIds.filter((id) => !previous.has(id))
90
+ }
91
+
92
+ /**
93
+ * Toast 差分步进:连续两个 ready 快照才计新增。
94
+ * 模型订阅即时发射 pending 空态,基线(存量归档)成为第二帧;基线是重连权威而非
95
+ * 归档事件,任一单帧都无法与事件区分,故以「上一帧也是 ready」为差分启用条件,
96
+ * 启动与重连首装不误报,重连基线携带的离期新增仍会提示。
97
+ * client.js 有一份镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
98
+ */
99
+ export function archiveToastStep(previous, snapshot) {
100
+ const ready = Boolean(snapshot && snapshot.phase === 'ready')
101
+ const ids = (snapshot && snapshot.archivedSessionIds) || []
102
+ const added = previous !== undefined && previous.ready && ready ? diffArchived(previous.ids, ids) : []
103
+ return { state: { ready, ids }, added }
104
+ }
105
+
106
+ /** 归档通知标题列举上限:超过该数只列前段并以「 等」收尾。 */
107
+ export const TOAST_MAX_TITLES = 3
108
+
109
+ /**
110
+ * 归档通知文案:单个报标题,多个为计数加列举标题,标题数超展示阈值只列前段并以
111
+ * 「 等」收尾,计数恒为真实总数。标题按去除首尾空白取值,缺失、空白或行不存在
112
+ * 回退会话 id,行数据非数组视为空。client.js 有镜像实现(单文件自包含无法跨文件
113
+ * require),修改需两处同步。
114
+ */
115
+ export function archiveToastText(addedIds, rows) {
116
+ const added = Array.isArray(addedIds) ? addedIds : []
117
+ if (added.length === 0) return ''
118
+ const safeRows = Array.isArray(rows) ? rows : []
119
+ const titleById = new Map()
120
+ for (const row of safeRows) {
121
+ if (!row || row.id === undefined || row.id === null) continue
122
+ const trimmed = String(row.title ?? '').trim()
123
+ if (trimmed !== '') titleById.set(String(row.id), trimmed)
124
+ }
125
+ const names = added.map((id) => titleById.get(String(id)) || String(id))
126
+ if (names.length === 1) return '会话「' + names[0] + '」已归档'
127
+ const listed = names.slice(0, TOAST_MAX_TITLES)
128
+ return '有 ' + names.length + ' 个会话已归档:' + listed.join('、') + (names.length > TOAST_MAX_TITLES ? ' 等' : '')
129
+ }
130
+
131
+ /** 删除资格:仅已归档会话,其余拒绝(竞态防护的 client 前置与 host 权威共用判定)。 */
132
+ export function deleteEligibility({ archivedIds, sessionId }) {
133
+ return archivedIds.includes(sessionId)
134
+ ? { ok: true }
135
+ : { ok: false, code: 'not-archived' }
136
+ }
137
+
138
+ /** 运行中判定:agent 注册表 status 为 running 即运行中,与归档评估同款判据。 */
139
+ export function isSessionRunning({ agents, sessionId }) {
140
+ const entry = agents ? agents.get(sessionId) : undefined
141
+ return Boolean(entry && entry.status === 'running')
142
+ }
143
+
144
+ // 删除半失败聚合文案:trash 成功后各收尾失败点的主文案与后缀,host 响应逐键消费。
145
+ // 基础前缀与警告措辞按处置模式区分(系统回收站 / 插件回收区):两种模式找回路径不同
146
+ export function deleteOutcomeMessages(quarantined) {
147
+ const base = quarantined ? '已移入插件回收区' : '已移入回收站'
148
+ return {
149
+ partial: base + ',但移除列表记录失败',
150
+ archiveCleanup: base + ',但移除归档记录失败',
151
+ ledgerFailed: base + ',但重挂载记录失败',
152
+ runningDuringTrash: quarantined
153
+ ? '警告:回收期间会话恢复运行,产物已移入插件回收区;建议检查会话状态'
154
+ : '警告:回收期间会话恢复运行,产物已移入回收站;建议检查会话状态',
155
+ }
156
+ }
157
+
158
+ // OS 回收站模式文案:保留常量导出供既有测试与文案对照
159
+ export const DELETE_MESSAGES = {
160
+ ...deleteOutcomeMessages(false),
161
+ ledgerSuffix: ',且重挂载记录失败',
162
+ }
163
+
164
+ /**
165
+ * 删除收尾半失败聚合:trash 成功后的失败矩阵折叠为单一响应体。
166
+ * 主文案优先级 detach → 归档清理 → 台账,台账失败以后缀并入前两者,运行中翻转
167
+ * 以警告后缀并入一切形态。三形态逐键一致:{ok} / {ok,message} / {ok,partial,message}。
168
+ * quarantined 为处置模式:系统回收站失败降级插件回收区时置真,基础前缀与警告措辞随之切换。
169
+ */
170
+ export function aggregateDeleteOutcome({ detachFailed = false, archiveCleanupFailed = false, ledgerFailed = false, runningDuringTrash = false, quarantined = false }) {
171
+ const M = deleteOutcomeMessages(quarantined)
172
+ const warningSuffix = runningDuringTrash ? ';' + M.runningDuringTrash : ''
173
+ const ledgerSuffix = ledgerFailed ? DELETE_MESSAGES.ledgerSuffix : ''
174
+ if (detachFailed) return { ok: true, partial: true, message: M.partial + ledgerSuffix + warningSuffix }
175
+ if (archiveCleanupFailed) return { ok: true, partial: true, message: M.archiveCleanup + ledgerSuffix + warningSuffix }
176
+ if (ledgerFailed) return { ok: true, partial: true, message: M.ledgerFailed + warningSuffix }
177
+ return runningDuringTrash
178
+ ? { ok: true, message: M.runningDuringTrash }
179
+ : { ok: true }
180
+ }
181
+
182
+ /** 空白产物判定:读到 EOF 且换行数不足两行,即仅 header(或空)视为空白。 */
183
+ export function artifactLooksBlank(headText, hasMore) {
184
+ let breaks = 0
185
+ for (const char of headText) {
186
+ if (char === '\n') breaks += 1
187
+ if (breaks >= 2) return false
188
+ }
189
+ return !hasMore && breaks < 2
190
+ }
191
+
192
+ /** 台账合并:同 id 替换,新条目置顶;返回新数组,不改入参。client.js 有镜像,修改需两处同步。 */
193
+ export function mergeDeletedEntry(deleted, entry) {
194
+ return [entry, ...deleted.filter((item) => item.sessionId !== entry.sessionId)]
195
+ }
196
+
197
+ /** 台账移除:幂等;removed 标记是否发生变化,调用方据此决定是否写回。 */
198
+ export function removeDeletedEntry(deleted, sessionId) {
199
+ const next = deleted.filter((item) => item.sessionId !== sessionId)
200
+ return { deleted: next, removed: next.length !== deleted.length }
201
+ }
202
+
203
+ /** 已删除面板行:标题取会话行展示标题,缺失回退会话 id,按删除时间倒序。client.js 有镜像,修改需两处同步。 */
204
+ export function projectDeletedRows(deleted, sessionsById) {
205
+ const byId = sessionsById || {}
206
+ return [...deleted]
207
+ .sort((left, right) => right.deletedAt - left.deletedAt)
208
+ .map((item) => ({
209
+ sessionId: item.sessionId,
210
+ path: item.path,
211
+ deletedAt: item.deletedAt,
212
+ title: (byId[item.sessionId] && byId[item.sessionId].displayTitle) || item.sessionId,
213
+ }))
214
+ }
215
+
216
+ // ── 归档库投影:主列表分页 / 工作区分组 / 标题搜索 ──
217
+
218
+ /** 归档库主列表默认展开行数与手动展开步长。 */
219
+ export const ARCHIVE_PAGE_SIZE = 100
220
+
221
+ /**
222
+ * 归档列表分页:取前 visibleCount 条并报告剩余量,非有限或非正可见数按 0 处理。
223
+ * client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
224
+ */
225
+ export function pageArchiveRows(rows, visibleCount) {
226
+ const list = Array.isArray(rows) ? rows : []
227
+ const count = Number.isFinite(visibleCount) && visibleCount > 0 ? visibleCount : 0
228
+ const visible = list.slice(0, count)
229
+ return { visible, remaining: list.length - visible.length }
230
+ }
231
+
232
+ /**
233
+ * 归档列表按工作区分组:workspace 假值并桶为未分组(null),组内保持输入序
234
+ * (调用方输入恒为时间倒序投影),组间按组内最新 updatedAt 倒序。
235
+ * client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
236
+ */
237
+ export function groupArchiveRowsByWorkspace(rows) {
238
+ const list = Array.isArray(rows) ? rows : []
239
+ const byKey = new Map()
240
+ for (const row of list) {
241
+ const title = row.workspace ? String(row.workspace) : ''
242
+ if (!byKey.has(title)) byKey.set(title, { workspace: title === '' ? null : title, latest: row.updatedAt, rows: [] })
243
+ const group = byKey.get(title)
244
+ group.rows.push(row)
245
+ if (row.updatedAt > group.latest) group.latest = row.updatedAt
246
+ }
247
+ return [...byKey.values()]
248
+ .sort((left, right) => right.latest - left.latest)
249
+ .map(({ workspace, rows: groupRows }) => ({ workspace, rows: groupRows }))
250
+ }
251
+
252
+ /**
253
+ * 归档列表标题搜索:查询词去首尾空白后按不区分大小写子串匹配标题,
254
+ * 空查询原样返回全量。client.js 有镜像实现(单文件自包含无法跨文件 require),修改需两处同步。
255
+ */
256
+ export function filterArchiveRows(rows, query) {
257
+ const list = Array.isArray(rows) ? rows : []
258
+ const needle = String(query ?? '').trim().toLowerCase()
259
+ if (needle === '') return list
260
+ return list.filter((row) => String(row.title ?? '').toLowerCase().includes(needle))
261
+ }
262
+