@mzzsfy/dsh-session-manager 0.7.0 → 0.7.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
@@ -8,7 +8,7 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
8
8
  - **范围导航**:浮层内 `←/→` 切换范围,范围序为 **常用 → 当前会话(默认落点)→ 本工作区 → 全部工作区**,`→` 向更大范围,`←` 返回收藏,边界停住;范围显示为浮层左上角徽标。
9
9
  - **工作区持久缓存**:每工作区一个 JSON 文件(`~/.dsh/historyPrompt/<工作区>-<路径哈希>.json`,可辨认 + 防同名冲突),内容为该工作区聚合后的输入条目(带 `sid` 来源会话溯源)。浮层请求直接读缓存文件立即返回(毫秒级,响应 `{ inputs, aligned }`);**对齐**在后台进行:解压范围内最近会话产物(经官方 `sessionQuery.readSession`,仅 `source.kind === 'user'` 的人类输入,工具结果回填与插件注入排除)、与缓存合并去重后原子写回(临时文件 + 改名)。对齐时机:插件启动对齐最近会话覆盖的工作区、每次浮层请求触发(同工作区 30 秒节流)、**运行中会话参与对齐(输入实时追加)**。
10
10
  - **范围语义**:`prompts` 用户显式收藏的常用提示词(全局单文件 `prompts.json`,不按工作区,详见收藏交互);`session` 该会话输入(缓存按 `sid` 过滤);`workspace` 同 cwd 工作区缓存;`global` 全部工作区缓存并集。
11
- - **上限与首次对齐**:全部历史最多保留最新 200 条(`HISTORY_INPUT_LIMIT`)、单条截 2 万字符(`HISTORY_INPUT_MAX_CHARS`),聚合精确文本去重保留最新时间。首次(该工作区尚无缓存文件)`aligned=false`,client 显示「首次对齐历史中」提示并每 3 秒(`HISTORY_REPULL_MS`)静默重拉;单会话产物读取失败仅告警跳过;scope 非法或缺省回退 `session`。
11
+ - **上限与首次对齐**:全部历史最多保留最新 200 条(`HISTORY_INPUT_LIMIT`)、单条截 2 万字符(`HISTORY_INPUT_MAX_CHARS`),聚合精确文本去重保留最新时间;**每会话首条保护**:超出上限被挤出时,每个来源会话在去重后集合中的最早条目仍保留(会话开场提示词最旧也最有复用价值,时间倒序截断总先挤掉它们;保护在去重后集合上计算,首条文本已被更新的会话复用时不再按旧会话重复追加;无 sid 的收藏条目不触发)。首次(该工作区尚无缓存文件)`aligned=false`,client 显示「首次对齐历史中」提示并每 3 秒(`HISTORY_REPULL_MS`)静默重拉;单会话产物读取失败仅告警跳过;scope 非法或缺省回退 `session`。
12
12
  - 常用提示词收藏:`prompts` 以外的范围,历史行鼠标悬停时行尾出现收藏星标(已收藏实星、未收藏空心),点击即收藏/取消(经 `POST /api/session-manager/prompts/toggle`),toast 反馈并同步星标;`prompts` 范围右上角「编辑」按钮进入编辑态:行内文本框直接改(Enter/失焦保存,实现为移除旧文本 + 收藏新文本)、行尾 `×` 删除、底部输入框 Enter 新增,「完成」退出;上限 `HISTORY_PROMPTS_MAX` 条,超出裁掉最旧;收藏与自动对齐的历史缓存互不覆写(工作区缓存列举跳过 prompts.json);global 范围读取走 stat 指纹缓存(`listWorkspaceCachesCached`),产物未变的文件不重复读盘;client 与 core 的 `HISTORY_SCOPES` 镜像由 parity 测试锁定。
13
13
  - 历史浮层启停开关:低频功能,归档面板末行小尺寸开关(规约 switch 形态 `sm-histsw`,状态选择器锚定 `input[type=checkbox]`);值存宿主 settings(`historyEnabled`,默认启用,原生设置页与本面板同存储即时同值),读写经本插件 `GET/POST /api/session-manager/history-enabled`(client 写 settings 不经宿主 remotes,零版本脆弱依赖);浮层挂载拉取一次,停用即锚点/快捷键/浮层整体不渲染、键盘监听放行,变更刷新页面生效。
14
14
  - 自动归档:host 半区对超期会话走官方 `workspace.archiveSession` 通道,三个触发源共用同一评估与门闩:新会话创建(`session/created`,按该会话所属工作区限定)、settings 就绪后的启动补扫(全量,清掉停机期间积压)、每日周期轮(全量,经宿主 timer 服务固定短 tick + 到期判断)。幂等:已归档会话不参与评估。阈值 `autoArchiveDays` 默认 7,`0` 关闭;周期间隔 `autoArchiveIntervalHours` 默认 24,`0` 关闭周期轮,变更经周期 tick 对账(关闭即时暂停、重启用最迟下个 tick 生效、缩短经锚定钳制前移,最长等新间隔而非等满旧周期)。评估筛选:createdAt 纯内存预筛 + 已归档跳过 + stat 后活跃度判定,locate 缺失(第三方后端)与产物不可读的会话一律跳过,宁可漏归档不可误归档。经 settings 命名空间 `session-manager` 注册(schema 拒绝负数与非整数)。timer 为软依赖:宿主定时服务不可用时仅周期轮停用,面板顶部提示,插件其余能力不受影响。
@@ -71,12 +71,12 @@ DeepSeek Harness 会话生命周期管理插件:自动归档 + 归档面板 +
71
71
  - 对齐扫描各工作区最近 20 个会话(`HISTORY_SESSION_SCAN_LIMIT`,更早会话的输入不入缓存),解压发生在后台、永不阻塞浮层响应,代价是首次对齐期间列表为空、需等静默重拉。
72
72
  - **解压不重复执行**:每会话提取结果持久化在工作区缓存 `extracts` 段并附产物 stat 指纹(mtimeMs+size),对齐时先 stat 比对、产物未变零解压,判断完全基于磁盘、跨重启生效;运行中会话(当前会话)提取结果只保留内存不落盘——产物持续变化,落盘指纹立即失效。
73
73
  - 启动零解压(历史直接读磁盘缓存,重启前的输入本就在 entries 里),全量对齐延迟 30 秒(`HISTORY_STARTUP_DELAY_MS`)再跑且只回溯最近 100 个会话(`HISTORY_STARTUP_SCAN_LIMIT`)覆盖的工作区。
74
- - 对齐中连续解压占用超 200ms(`HISTORY_ALIGN_SLICE_MS`)即让出 100ms(`HISTORY_ALIGN_YIELD_MS`,zstd 解压为同步 CPU 操作,不设让出会让宿主启动阻塞数十秒);超 8MiB(`HISTORY_ALIGN_MAX_ARTIFACT_BYTES`)的巨产物跳过自动对齐(单次 readSession 内部同步解压不可让出,实时性让位于不卡主循环)。
74
+ - 对齐中连续解压占用超 200ms(`HISTORY_ALIGN_SLICE_MS`)即让出 100ms(`HISTORY_ALIGN_YIELD_MS`,解压为 CPU 操作,不设让出会长时间霸占主循环);巨产物跳过自动对齐,跳过线按宿主版本黑名单取值:0.1.1/0.1.2 系列(解压实现无周期让出)8MiB,其余(0.1.5 实测 `readZstdPrefix` 逐帧解压 + 周期让出,不阻塞主循环)16MiB(`HISTORY_ALIGN_LEGACY/MODERN_MAX_ARTIFACT_BYTES`,压缩字节数)——上限防的是解压耗时与事件对象内存峰值失控(压缩比约 1:3.4,超线产物解压后可达数百 MB);版本探测经 require 链解析宿主清单,失败保守按旧线。
75
75
  - header 查找走 ctx 级 TTL 缓存(inputs 路由不每次全量扫描 listSessions)。
76
76
  - 对齐为双车道:批量车道(窗口对齐)与焦点车道(单会话实时追加)各自串行互不等待,会话范围请求检测到产物指纹失配即触发焦点对齐并返回 aligned=false。
77
77
  - 浮层打开期间固定节奏轮询当前范围(数据可能晚于首次响应到达,一次性重拉预算会让迟到数据永不出现);单会话首条数据可见预算 3s:会话范围路由触发焦点对齐后就地等待 2s(`HISTORY_FOCUS_WAIT_MS`),正常会话单次请求内拿到新数据,超时(巨产物)走轮询流式补齐。
78
78
  - 不解压搬进 worker:产物为多帧 zstd + 帧扫描 + 记录解码 + 版本协商,`readSession` 深度耦合宿主持持层,worker 内复刻等于维护第二份产物解析实现,宿主升级即漂移。
79
- - 缓存上限 200 条(`HISTORY_INPUT_LIMIT`),超出裁掉最旧(超长会话史会丢早期输入);新输入从发送到浮层可见有秒级延迟(宿主批量刷盘 200ms + 焦点对齐 + 轮询节奏),非严格实时。
79
+ - 缓存上限 200 条(`HISTORY_INPUT_LIMIT`),超出裁掉最旧,每会话最早条目例外保留(首条保护,见「上限与首次对齐」);巨产物(超线跳过对齐,见对齐参数)会话的条目只能来自既有缓存,首条从未入库则无法恢复;新输入从发送到浮层可见有秒级延迟(宿主批量刷盘 200ms + 焦点对齐 + 轮询节奏),非严格实时。
80
80
  - 缓存文件为明文 JSON(输入内容落盘 `~/.dsh/historyPrompt`,与产物本身同级敏感);单条超长文本(大段粘贴)在 host 侧截断,回填与展示均用截断后文本。
81
81
  - 交互收敛为快捷键唤起浮层(`Alt+↑`,无 mac 系统占用,与编辑器光标移动无冲突),无常驻界面元素。
82
82
  - 重挂载的并发窗口:attachSession 后、台账清除前进程中断,残留条目重挂载时因官方 attachSession 幂等(已在 sessionIds 中则跳过校验直写)无副作用,再次点按或「移除记录」即可收尾。台账域打开失败时已删除 / 重挂载 / forget 以 400 拒绝;删除路由走 partial 降级(其余步骤照常,提示「已移入回收站,但重挂载记录失败」),不做无台账中断——产物进回收站后中断删除只会造成状态不一致。
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@mzzsfy/dsh-session-manager",
3
3
  "description": "会话自动归档与管理:超期自动归档、归档面板、删除进系统回收站、还原后一键重挂载",
4
- "version": "0.7.0",
4
+ "version": "0.7.1",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
7
7
  "exports": {
package/src/core.mjs CHANGED
@@ -251,10 +251,10 @@ export function filterArchiveRows(rows, query) {
251
251
  // 范围按 ←/→ 切换序排列:索引 0 为常用提示词(个人收藏),索引 1 为当前会话
252
252
  // (浮层默认落点),→ 向更大范围,← 返回收藏
253
253
  // 对齐 = 后台解压范围内会话产物与持久缓存合并;两次对齐最小间隔防持续解压。
254
- // 解压是同步 CPU 操作会阻塞主循环:启动零解压(历史直接读磁盘缓存),
255
- // 全量对齐延迟 STARTUP_DELAY 再跑、只回溯最近 STARTUP_SCAN 个会话、
256
- // 跳过超 MAX_ARTIFACT 的巨产物(单次 readSession 内部不可让出,巨产物一解卡死主循环);
257
- // 对齐中连续解压占用超 SLICE 即让出 YIELD。
254
+ // 解压是 CPU 操作:启动零解压(历史直接读磁盘缓存),全量对齐延迟 STARTUP_DELAY
255
+ // 再跑、只回溯最近 STARTUP_SCAN 个会话;超 MAX_ARTIFACT 的巨产物跳过——
256
+ // 上限防的是解压耗时与事件对象内存峰值失控,非阻塞主循环;跳过线按宿主版本
257
+ // 黑名单取值(见 isLegacyDecompressHost),对齐中连续解压占用超 SLICE 即让出 YIELD。
258
258
  // 运行中会话(当前会话)提取结果只保留内存不落盘——产物持续变化,落盘指纹立即失效
259
259
  export const HISTORY_SESSION_SCAN_LIMIT = 20
260
260
  export const HISTORY_INPUT_LIMIT = 200
@@ -266,7 +266,26 @@ export const HISTORY_STARTUP_DELAY_MS = 30 * 1000
266
266
  export const HISTORY_STARTUP_SCAN_LIMIT = 100
267
267
  export const HISTORY_ALIGN_SLICE_MS = 200
268
268
  export const HISTORY_ALIGN_YIELD_MS = 100
269
- export const HISTORY_ALIGN_MAX_ARTIFACT_BYTES = 8 * 1024 * 1024
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
+ }
270
289
  // 会话范围路由触发焦点对齐后就地等待的时限:首条数据可见预算(3s)内
271
290
  // 留出解压+读盘份额,超时改走轮询流式补齐
272
291
  export const HISTORY_FOCUS_WAIT_MS = 2 * 1000
@@ -294,16 +313,29 @@ export function extractUserInputs(events) {
294
313
  }
295
314
 
296
315
  /** 历史输入聚合:精确文本去重保留最新时间,时间倒序,截 limit 条,单条截 maxChars 字符。
297
- * 条目可携带 sid(来源会话)用于 session 范围过滤,聚合时原样保留。 */
316
+ * 条目可携带 sid(来源会话)用于 session 范围过滤,聚合时原样保留。
317
+ * 首条保护:被 limit 挤出时,每个来源会话在去重后集合中的最早条目仍保留——
318
+ * 会话开场提示词是最有复用价值也最旧的一批,时间倒序截断总会先挤掉它们;
319
+ * 保护在去重后集合上计算,文本已被更新的会话复用时不再按旧会话重复追加。 */
298
320
  export function aggregateInputs(entries, { limit, maxChars }) {
299
321
  const latestByText = new Map()
300
322
  for (const entry of entries || []) {
301
323
  const known = latestByText.get(entry.text)
302
324
  if (known === undefined || entry.at > known.at) latestByText.set(entry.text, entry)
303
325
  }
304
- return [...latestByText.values()]
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
305
338
  .sort((left, right) => right.at - left.at)
306
- .slice(0, limit)
307
339
  .map((entry) => ({
308
340
  text: entry.text.slice(0, maxChars),
309
341
  at: entry.at,
package/src/index.js CHANGED
@@ -19,7 +19,6 @@ import {
19
19
  DAY_MS,
20
20
  DEFAULT_AUTO_ARCHIVE_DAYS,
21
21
  DEFAULT_AUTO_ARCHIVE_INTERVAL_HOURS,
22
- HISTORY_ALIGN_MAX_ARTIFACT_BYTES,
23
22
  HISTORY_ALIGN_SLICE_MS,
24
23
  HISTORY_ALIGN_THROTTLE_MS,
25
24
  HISTORY_ALIGN_YIELD_MS,
@@ -36,7 +35,9 @@ import {
36
35
  artifactLooksBlank,
37
36
  deleteEligibility,
38
37
  extractUserInputs,
38
+ isLegacyDecompressHost,
39
39
  isSessionRunning,
40
+ maxArtifactBytesForHost,
40
41
  mergeDeletedEntry,
41
42
  removeDeletedEntry,
42
43
  selectArchiveCandidates,
@@ -44,6 +45,7 @@ import {
44
45
  } from './core.mjs'
45
46
  import { ensureCacheDir, listWorkspaceCachesCached, readPrompts, readWorkspaceCache, writePrompts, writeWorkspaceCache } from './history-cache.mjs'
46
47
  import { trashPath } from './trash.mjs'
48
+ import { createRequire } from 'node:module'
47
49
 
48
50
  export const name = 'dsh-session-manager'
49
51
 
@@ -382,7 +384,7 @@ export function apply(ctx, config) {
382
384
  }
383
385
 
384
386
  // 历史输入:工作区粒度持久缓存(~/.dsh/historyPrompt/<工作区>-<hash>.json)。
385
- // 浮层请求直接读缓存文件立即返回(毫秒级);对齐在后台解压范围内最近会话产物,
387
+ // 浮层请求直接读缓存文件立即返回(毫秒级);对齐在后台解压范围内会话产物,
386
388
  // 与缓存合并去重后写回——运行中会话也参与(实时追加其新输入)。
387
389
  // 解压不重复执行:每会话提取结果持久化在工作区缓存 extracts 段,附产物 stat 指纹
388
390
  // (mtimeMs+size),对齐时先 stat 比对,产物没变零解压——判断全部基于磁盘,跨重启生效。
@@ -391,6 +393,20 @@ export function apply(ctx, config) {
391
393
  // STARTUP_DELAY 再跑,绝不阻塞宿主启动。测试经 env DSH_HISTORY_CACHE_DIR 注入临时目录
392
394
  const cacheDir = process.env.DSH_HISTORY_CACHE_DIR
393
395
  || join(homedir(), '.dsh', 'historyPrompt')
396
+ // 巨产物跳过线按宿主版本黑名单取值:0.1.1/0.1.2 解压实现无周期让出从严 8MiB,
397
+ // 其余 16MiB。运行宿主清单经 require 链解析(插件随宿主树部署时必命中),
398
+ // 探测失败按黑名单保守回退旧线。激活时求值一次即冻结,热路径只读值;
399
+ // 宿主热升级后重启生效
400
+ let maxArtifactBytes = resolveMaxArtifactBytes()
401
+ function resolveMaxArtifactBytes() {
402
+ let version = null
403
+ try {
404
+ version = createRequire(import.meta.url)('@deepseek-ai/dsh/package.json').version
405
+ } catch {
406
+ version = null
407
+ }
408
+ return maxArtifactBytesForHost(version)
409
+ }
394
410
  const alignThrottle = new Map()
395
411
  const runtimeExtracts = new Map()
396
412
  const headerCache = new Map()
@@ -447,8 +463,8 @@ export function apply(ctx, config) {
447
463
 
448
464
  // 批量对齐执行体:列出范围会话,逐个按指纹增量提取(磁盘 extracts 复用),
449
465
  // 与缓存合并后原子写回,extracts 裁剪到完整扫描窗口。经批量队列入队。
450
- // 超过 MAX_ARTIFACT 的巨产物直接跳过:单次 readSession 内部同步解压不可让出,
451
- // 巨会话一解卡死主循环;其历史来自缓存 entries 的既有贡献
466
+ // 超过 MAX_ARTIFACT 的巨产物直接跳过:解压耗时与内存峰值随产物规模失控
467
+ // (宿主内部逐帧解压带周期让出,不阻塞主循环);其历史来自缓存 entries 的既有贡献
452
468
  function alignWorkspaceNow(cwd) {
453
469
  return enqueueBulkAlign(async () => {
454
470
  const agents = ctx.get('agents')
@@ -465,7 +481,7 @@ export function apply(ctx, config) {
465
481
  const isRunning = isSessionRunning({ agents, sessionId: recordItem.header.id })
466
482
  const located = persistence ? persistence.locate(recordItem.header) : undefined
467
483
  const fingerprint = located ? await safeStat(located.path) : null
468
- if (fingerprint !== null && fingerprint.size > HISTORY_ALIGN_MAX_ARTIFACT_BYTES) continue
484
+ if (fingerprint !== null && fingerprint.size > resolveMaxArtifactBytes()) continue
469
485
  try {
470
486
  const entries = await extractSession(recordItem, fingerprint, extracts, isRunning)
471
487
  fresh.push(...entries.map((entry) => ({ ...entry, sid: recordItem.header.id })))
@@ -501,7 +517,7 @@ export function apply(ctx, config) {
501
517
  const persistence = ctx.get('sessionPersistence')
502
518
  const located = persistence ? persistence.locate(header) : undefined
503
519
  const fingerprint = located ? await safeStat(located.path) : null
504
- if (fingerprint === null || fingerprint.size > HISTORY_ALIGN_MAX_ARTIFACT_BYTES) return
520
+ if (fingerprint === null || fingerprint.size > resolveMaxArtifactBytes()) return
505
521
  const isRunning = isSessionRunning({ agents: ctx.get('agents'), sessionId })
506
522
  const cached = await readWorkspaceCache(cacheDir, cwd)
507
523
  const extracts = cached && cached.extracts ? { ...cached.extracts } : {}
@@ -576,7 +592,7 @@ export function apply(ctx, config) {
576
592
  // 实时追加:产物 stat 与已知指纹不一致 = 会话有新输入未入缓存——聚焦对齐
577
593
  // 只解该会话并写回。路由有界等待本次对齐(首条 <3s 预算):正常会话单次
578
594
  // 请求内直接拿到新数据;超时(巨产物等)返回既有数据 aligned=false,后续
579
- // 轮询流式补齐。巨产物跳过聚焦(单次解压不可让出),维持 aligned=true
595
+ // 轮询流式补齐。巨产物跳过聚焦(解压耗时与内存峰值失控),维持 aligned=true
580
596
  const check = async (cached) => {
581
597
  const agents = ctx.get('agents')
582
598
  const running = isSessionRunning({ agents, sessionId })
@@ -585,7 +601,7 @@ export function apply(ctx, config) {
585
601
  const fingerprint = located ? await safeStat(located.path) : null
586
602
  return {
587
603
  stale: fingerprint !== null && !(known && known.mtimeMs === fingerprint.mtimeMs && known.size === fingerprint.size),
588
- oversize: fingerprint !== null && fingerprint.size > HISTORY_ALIGN_MAX_ARTIFACT_BYTES,
604
+ oversize: fingerprint !== null && fingerprint.size > resolveMaxArtifactBytes(),
589
605
  }
590
606
  }
591
607
  let { cached, mine } = await read()
@@ -9,6 +9,8 @@ import {
9
9
  DAY_MS,
10
10
  DEFAULT_AUTO_ARCHIVE_DAYS,
11
11
  DELETE_MESSAGES,
12
+ HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES,
13
+ HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES,
12
14
  TOAST_MAX_TITLES,
13
15
  aggregateDeleteOutcome,
14
16
  aggregateInputs,
@@ -21,6 +23,7 @@ import {
21
23
  filterArchiveRows,
22
24
  groupArchiveRowsByWorkspace,
23
25
  isSessionRunning,
26
+ maxArtifactBytesForHost,
24
27
  mergeDeletedEntry,
25
28
  pageArchiveRows,
26
29
  projectArchiveRows,
@@ -515,3 +518,78 @@ test('历史输入聚合:limit 裁剪与单条截断', () => {
515
518
  ], { limit: 1, maxChars: 3 })
516
519
  assert.deepEqual(merged, [{ text: 'xy', at: 2 }])
517
520
  })
521
+
522
+ // ── 历史输入:每会话首条保护(挤出不做永久丢弃)──
523
+
524
+ test('历史输入聚合:超 limit 挤出时每个会话最早条目仍保留', () => {
525
+ const merged = aggregateInputs([
526
+ { text: '开场提示词', at: 1, sid: 's-old' },
527
+ { text: '继续', at: 2, sid: 's-old' },
528
+ { text: '新会话开场', at: 3, sid: 's-new' },
529
+ { text: '收尾', at: 4, sid: 's-new' },
530
+ ], { limit: 2, maxChars: 100 })
531
+ assert.deepEqual(merged, [
532
+ { text: '收尾', at: 4, sid: 's-new' },
533
+ { text: '新会话开场', at: 3, sid: 's-new' },
534
+ { text: '开场提示词', at: 1, sid: 's-old' },
535
+ ])
536
+ })
537
+
538
+ test('历史输入聚合:首条已在 limit 内不多余追加,结果仍为时间倒序', () => {
539
+ const merged = aggregateInputs([
540
+ { text: 'a', at: 1, sid: 's1' },
541
+ { text: 'b', at: 2, sid: 's2' },
542
+ ], { limit: 10, maxChars: 100 })
543
+ assert.deepEqual(merged, [{ text: 'b', at: 2, sid: 's2' }, { text: 'a', at: 1, sid: 's1' }])
544
+ })
545
+
546
+ test('历史输入聚合:首条保护在去重后集合上计算,与 limit 内文本重复不产生双条目', () => {
547
+ // s-old 的首条文本与 s-new 的某条相同:去重保留最新,归属 s-new,不再重复追加
548
+ const merged = aggregateInputs([
549
+ { text: '继续', at: 1, sid: 's-old' },
550
+ { text: '继续', at: 9, sid: 's-new' },
551
+ { text: '其他', at: 5, sid: 's-new' },
552
+ ], { limit: 1, maxChars: 100 })
553
+ assert.deepEqual(merged, [{ text: '继续', at: 9, sid: 's-new' }, { text: '其他', at: 5, sid: 's-new' }])
554
+ })
555
+
556
+ test('历史输入聚合:无 sid 条目不触发首条保护', () => {
557
+ const merged = aggregateInputs([
558
+ { text: 'old', at: 1 },
559
+ { text: 'new', at: 2 },
560
+ ], { limit: 1, maxChars: 100 })
561
+ assert.deepEqual(merged, [{ text: 'new', at: 2 }])
562
+ })
563
+
564
+ test('历史输入聚合:首条保护同样受 maxChars 截断', () => {
565
+ const merged = aggregateInputs([
566
+ { text: 'abcdef', at: 1, sid: 's1' },
567
+ { text: 'xy', at: 2, sid: 's2' },
568
+ ], { limit: 1, maxChars: 3 })
569
+ assert.deepEqual(merged, [{ text: 'xy', at: 2, sid: 's2' }, { text: 'abc', at: 1, sid: 's1' }])
570
+ })
571
+
572
+ // ── 巨产物跳过线:宿主版本黑名单 ──
573
+
574
+ test('巨产物跳过线:0.1.1 与 0.1.2 系列(含 prerelease)按旧线', () => {
575
+ for (const version of ['0.1.1', '0.1.1-rc.2', '0.1.2', '0.1.2-rc.1', '0.1.2-alpha.2']) {
576
+ assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES, version)
577
+ }
578
+ })
579
+
580
+ test('巨产物跳过线:0.1.3 起与未知未来版本按新线', () => {
581
+ for (const version of ['0.1.3', '0.1.5', '0.1.5-rc.1', '0.2.0', '1.0.0']) {
582
+ assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES, version)
583
+ }
584
+ })
585
+
586
+ test('巨产物跳过线:版本缺失或非法保守回退旧线', () => {
587
+ for (const version of [null, undefined, '', 'garbage', '0.1']) {
588
+ assert.equal(maxArtifactBytesForHost(version), HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES, String(version))
589
+ }
590
+ })
591
+
592
+ test('巨产物跳过线:新旧线常量与实测依据自洽', () => {
593
+ assert.equal(HISTORY_ALIGN_LEGACY_MAX_ARTIFACT_BYTES, 8 * 1024 * 1024)
594
+ assert.equal(HISTORY_ALIGN_MODERN_MAX_ARTIFACT_BYTES, 16 * 1024 * 1024)
595
+ })
@@ -1639,12 +1639,14 @@ test('历史输入路由:运行中会话参与对齐(实时追加),缓存上限
1639
1639
  },
1640
1640
  })
1641
1641
  const res = await requestUntil(handlers, '?sessionId=s1&scope=workspace',
1642
- (body) => body.aligned && body.inputs.length === HISTORY_INPUT_LIMIT)
1642
+ (body) => body.aligned && body.inputs.length === HISTORY_INPUT_LIMIT + 1)
1643
1643
  const texts = res.body.inputs.map((item) => item.text)
1644
- assert.equal(texts.length, HISTORY_INPUT_LIMIT)
1644
+ // 运行中会话输入量远超上限:limit 之外仅追加该会话去重后最早一条(s1 首条已在 limit 内)
1645
+ assert.equal(texts.length, HISTORY_INPUT_LIMIT + 1)
1645
1646
  assert.ok(texts.includes('历史输入'), '最新既有输入保留')
1646
1647
  assert.ok(texts.includes('运行输入' + (HISTORY_INPUT_LIMIT + 49)), '次新运行输入保留')
1647
- assert.ok(!texts.includes('运行输入0'), '最旧输入被上限裁剪')
1648
+ assert.ok(texts.includes('运行输入0'), '运行中会话最早输入受首条保护')
1649
+ assert.ok(!texts.includes('运行输入1'), '首条之外的旧输入仍被上限裁剪')
1648
1650
  assert.ok(readCounts.get('running1') >= 1, '运行中会话被对齐解压')
1649
1651
  const sessionRes = await requestUntilAligned(handlers, '?sessionId=running1&scope=session')
1650
1652
  assert.ok(sessionRes.body.inputs.length > 0, 'session 范围可取运行中会话自身输入')