dsh-session-conductor 1.3.7 → 1.3.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,8 @@
10
10
 
11
11
  | 功能 | 说明 | 入口 |
12
12
  |---|---|---|
13
- | 会话管理面板 | 全部/已归档/活跃/说明四视图;归档、恢复、删除(批量/按条件)、置为不活跃、释放全部空闲 | 侧边栏「会话管理」按钮 |
13
+ | 会话管理面板 | 全部/已归档/活跃/**回收站**/说明五视图;归档、恢复、删除(批量/筛选)、置为不活跃、释放全部空闲 | 侧边栏「会话管理」按钮 |
14
+ | 回收站 | **删除只打标记、不动会话文件**:删掉的会话进回收站,可单条还原、多选还原、按批次撤销(还原最近一次删除),到期(保留天数可设,默认 7 天)或「一键清空」才真正删盘。真正删盘由每小时一次的到期清理执行 | 面板「回收站」标签页 |
14
15
  | 自动重命名 | 每个回合后 AI 分析会话内容,主题明显偏离时自动生成新标题(带运行状态后缀) | 面板逐会话开关 |
15
16
  | 自动续跑 | 识别非人为中断(崩溃/限流/超时/错误),自动继续原任务;失败后 30s 重试 | 面板逐会话开关(临时设置,重启即默认关) |
16
17
  | 会话分组 | 按工作区分组显示;「在分组下新建会话」 | 设置 → 插件 → 会话分组 |
@@ -122,8 +123,13 @@ allowBuilds:
122
123
  - **全部**:列出每个会话(标题/目录/时间/状态),支持归档、删除、撤回、自动重命名/续跑开关、搜索、价值分析
123
124
  - **已归档**:列出被归档的会话,可恢复(取消归档)、删除
124
125
  - **活跃**:当前活跃(live/running)的会话
126
+ - **回收站**:列出被删除(只打了标记)的会话,显示删除时间与剩余天数;五个操作:**设置保留天数**、**撤销上次删除**(还原最近一个批次)、**还原选中**、**全选**、**一键清空**(二次确认后立即真删)。单条也能点行内「还原」
125
127
  - **说明**:损坏修复(seq-gap / EIO / 双格式)+ 使用说明
126
128
 
129
+ > 删除语义:面板上的「删除」= 在索引里打标记 + 从列表隐藏,**会话日志文件原样保留**,
130
+ > 所以还原是秒级且零风险。真正删盘只发生在回收站到期(`trashRetentionDays`,默认 7 天)
131
+ > 或「一键清空」,由 `purgeExpiredTrash` 执行。
132
+
127
133
  ### 设置页(设置 → 会话管理)
128
134
 
129
135
  左侧栏「会话管理」独立页:
package/lib/apply.js CHANGED
@@ -50,6 +50,8 @@ import {
50
50
  collectSessionTitleMessages, resolveSessionTitle, stateSuffixOf, stripTitleStateSuffix, refreshTitleState,
51
51
  } from "./features/rename/title.js";
52
52
  import { SEARCH_MIN_QUERY, searchSessions } from './features/search.js';
53
+ // 价值判定索引:同一份会话内容在 TTL(默认 24 小时)内只算一次,避免每次筛选都重读日志
54
+ import { getValueVerdict, putValueVerdict, saveValueIndexNow } from './features/value-index.js';
53
55
  // 成员模型:本文件用到 findTargetAgent / switchAgentModel / getMemberModelOverride /
54
56
  // applyModelOverride / validateModelPair(拆出 apply.js 时漏了这一行 import,
55
57
  // 运行到相关代码路径会 ReferenceError: getMemberModelOverride is not defined)
@@ -58,6 +60,8 @@ import { findTargetAgent, switchAgentModel, getMemberModelOverride, applyModelOv
58
60
  registerModelSwitchCommand } from './features/member-model/index.js';
59
61
  // 会话操作的路由注册已抽到独立模块(handler 实现见该模块;此处只留注册入口)
60
62
  import { registerSessionOpsRoutes } from './features/session-ops-routes.js';
63
+ import { registerTrashRoutes } from './features/session-ops-routes.js';
64
+ import { purgeExpiredTrash } from './features/session-ops.js';
61
65
  import { installProcessGuards } from './guards.js';
62
66
  import { readJson, send } from './http.js';
63
67
  import { CONDUCTOR_SETTINGS_NS, PresenceSchema } from './shared/settings.js';
@@ -88,6 +92,10 @@ export async function apply(ctx, config = {}) {
88
92
  maxContinuesPerSession: num(config?.maxContinuesPerSession, 1, MAX_CONTINUES_PER_SESSION_LIMIT, DEFAULTS.maxContinuesPerSession),
89
93
  turnTimeoutMs: num(config?.turnTimeoutMs, 60 * 1000, 6 * 3600 * 1000, DEFAULTS.turnTimeoutMs),
90
94
  scanIntervalMs: num(config?.scanIntervalMs, 30 * 1000, 24 * 3600 * 1000, DEFAULTS.scanIntervalMs),
95
+ // 价值判定索引有效期(小时):同一份会话内容在该时长内只算一次(默认 24 小时,可配置覆盖)
96
+ valueIndexTtlHours: num(config?.valueIndexTtlHours, 1, 24 * 30, DEFAULTS.valueIndexTtlHours),
97
+ // 回收站保留天数(面板上设置的值优先;这里是兜底默认)
98
+ trashRetentionDays: num(config?.trashRetentionDays, 1, 365, DEFAULTS.trashRetentionDays),
91
99
  // 自动重命名模型路由(成对配置才生效;缺省 = 继承会话 request/header 的对话模型)
92
100
  autoRenameProvider: typeof config?.autoRenameProvider === "string" && config.autoRenameProvider !== "" ? config.autoRenameProvider : undefined,
93
101
  autoRenameModel: typeof config?.autoRenameModel === "string" && config.autoRenameModel !== "" ? config.autoRenameModel : undefined,
@@ -653,6 +661,20 @@ export async function apply(ctx, config = {}) {
653
661
  // 具体 handler 实现见该模块(行为逐字不变,依赖以 deps 传入)。
654
662
  registerSessionOpsRoutes({ webServer, ctx, send, readJson, invalidateSessionListCache });
655
663
 
664
+ // ---------- 回收站路由 ----------
665
+ // 删除只是打标记(文件不动),所以还原零成本;真删只发生在到期与一键清空。
666
+ registerTrashRoutes({ webServer, ctx, send, readJson, invalidateSessionListCache });
667
+
668
+ // 到期清理:每小时扫一次(回收站保留天数可配,默认 7 天)。
669
+ // 用 unref 的定时器 + 单次 try/catch:清理失败绝不能影响宿主进程。
670
+ try {
671
+ const purgeTimer = setInterval(() => {
672
+ purgeExpiredTrash(ctx).catch(() => { /* 单次清理失败,下一小时再试 */ });
673
+ }, 3600_000);
674
+ if (typeof purgeTimer?.unref === "function") purgeTimer.unref();
675
+ ctx.effect?.(() => () => { try { clearInterval(purgeTimer); } catch { /* 已清理 */ } }, "session-conductor: trash purge timer");
676
+ } catch { /* 定时器建不起来也不影响手动清理 */ }
677
+
656
678
  // ---------- POST /api/session-conductor/search ----------
657
679
  // 全文搜索:跨会话搜消息内容,返回命中会话 + 上下文片段
658
680
  webServer.register({
@@ -1071,6 +1093,57 @@ export async function apply(ctx, config = {}) {
1071
1093
  : 3;
1072
1094
  const useLlm = body?.llm === true;
1073
1095
  const sessions = await buildSessionListCached(ctx); // 价值分析走缓存(容忍 5s 陈旧,避免与面板刷新叠加扫描)
1096
+ // 流式(`?stream=1` 或 Accept: application/x-ndjson):**每分析完一个会话就推一行**。
1097
+ // 前端拿到 value==="low" 立刻勾上(分析出一个勾一个),不必等全量跑完 ——
1098
+ // 全量要逐个读上百份日志,慢的是读取本身,而读取是天然可增量的。
1099
+ // 判定规则与批量路径同源(value.js 的 classifySessionLevel),不在这里另写一套。
1100
+ const wantValueStream = /application\/x-ndjson/i.test(req.headers?.accept ?? "")
1101
+ || String(req.url ?? "").includes("stream=1");
1102
+ if (wantValueStream) {
1103
+ // body.ids:只分析这些会话(面板已先用归档/时间/目录在本地筛过一轮,
1104
+ // 没必要把全量会话都读一遍事件)。不传 = 全量。
1105
+ const wantedIds = Array.isArray(body?.ids)
1106
+ ? new Set(body.ids.filter((id) => typeof id === "string" && id !== ""))
1107
+ : null;
1108
+ const streamTargets = wantedIds ? sessions.filter((s) => wantedIds.has(s.id)) : sessions;
1109
+ res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8");
1110
+ res.setHeader("Cache-Control", "no-store");
1111
+ if (res.socket?.setNoDelay) res.socket.setNoDelay(true);
1112
+ res.write(JSON.stringify({ ok: true, stream: true, total: streamTargets.length }) + "\n");
1113
+ try {
1114
+ if (typeof res.flushHeaders === "function") res.flushHeaders();
1115
+ } catch { /* 老宿主不支持 flush,不影响主流程 */ }
1116
+ const { classifySessionLevel } = await lazyValue();
1117
+ let done = 0;
1118
+ for (const s of streamTargets) {
1119
+ done += 1;
1120
+ // ① 索引命中(内容没变 + 未超 TTL)→ 零 I/O 直接给结论
1121
+ const cachedVerdict = getValueVerdict(ctx, s.id, s.updatedAt);
1122
+ if (cachedVerdict) {
1123
+ try {
1124
+ res.write(JSON.stringify({
1125
+ id: s.id, value: cachedVerdict.value, score: cachedVerdict.score,
1126
+ reason: cachedVerdict.reason, cached: true, done, total: streamTargets.length,
1127
+ }) + "\n");
1128
+ } catch { break; }
1129
+ continue;
1130
+ }
1131
+ // ② 没索引 / 会话变过 / 超 TTL → 真读一次日志
1132
+ let events = [];
1133
+ try {
1134
+ const found = await sessionEventsOf(ctx, s.id);
1135
+ events = found?.events ?? [];
1136
+ } catch { /* 读不到就当空事件:会走 artifact-safe 保守判 high,不误删 */ }
1137
+ const verdict = classifySessionLevel(s, events, new Date(), staleDays);
1138
+ putValueVerdict(ctx, s.id, s.updatedAt, verdict);
1139
+ try {
1140
+ res.write(JSON.stringify({ ...verdict, cached: false, done, total: streamTargets.length }) + "\n");
1141
+ } catch { break; }
1142
+ }
1143
+ saveValueIndexNow(ctx); // 本次算出来的结论立刻落盘(下次筛选直接命中索引)
1144
+ try { res.end(); } catch { /* 连接已断 */ }
1145
+ return;
1146
+ }
1074
1147
  // 一次性读取各会话事件(复用,避免大会话重复解压导致超时/失败)
1075
1148
  const eventsById = {};
1076
1149
  // 逐个会话读取最后 assistant 文本 + 最后用户消息(损坏/不可读会话跳过,用空串)
@@ -17,20 +17,22 @@
17
17
  locale: NS,
18
18
  inject: () => ({
19
19
  onOpenSession: (sessionId) => {
20
- // 打开会话的真实入口随宿主版本不同,按优先级尝试(**不要只认一个**):
21
- // · workspaceNavigation.openSession(id) —— dsh-client-ui-conversation 的用法
22
- // · ctx.uiWorkspace.openSession(id) —— dsh-client-ui-chat 的用法
23
- // · sessions.open(id) —— 早期路径,保留兜底
24
- // 以前只写了第三个,而宿主客户端并没有 `sessions` 这个服务 ⇒ 可选链把失败吞成
25
- // 「点了没反应」,且不报错、难查。所以这里:多个入口都试;全失败要留下痕迹。
20
+ // 打开会话的入口,按「宿主真实存在的」优先级逐个试:
21
+ // · ctx.get("uiWorkspace").openSession(id) —— 宿主自己的会话导航服务
22
+ // (ui-conversation 内部就是 `const nav = ctx.get('uiWorkspace')` 再 nav.openSession(id);
23
+ // 实测宿主包里根本没有 workspaceNavigation / openSession 这类名字,早期写法是猜的)
24
+ // · ctx.uiWorkspace.openSession(id) —— 同一服务,仅在本插件声明过依赖时才作为属性可见
25
+ // · ctx.get("sessions").open(id) —— 老版本兜底
26
+ // 必须用 ctx.get(...):cordis 里未在 inject 声明的服务**没有** ctx.<name> 属性,
27
+ // 但 ctx.get(name) 仍能取到 —— 只写 ctx.uiWorkspace 会永远 undefined,点了没反应。
26
28
  try {
27
- const nav = ctx.get?.("workspaceNavigation");
29
+ const nav = ctx.get?.("uiWorkspace");
28
30
  if (typeof nav?.openSession === "function") return nav.openSession(sessionId);
29
31
  if (typeof ctx.uiWorkspace?.openSession === "function") return ctx.uiWorkspace.openSession(sessionId);
30
32
  const legacy = ctx.get?.("sessions");
31
33
  if (typeof legacy?.open === "function") return legacy.open(sessionId);
32
34
  // eslint-disable-next-line no-console
33
- console.warn("[session-conductor] 打不开会话:宿主客户端未暴露 workspaceNavigation/uiWorkspace/sessions 任一入口", sessionId);
35
+ console.warn("[session-conductor] 打不开会话:宿主未暴露 uiWorkspace.openSession / sessions.open", sessionId);
34
36
  } catch (error) {
35
37
  // 打开失败不阻塞面板,但不再静默
36
38
  // eslint-disable-next-line no-console