dsh-session-conductor 1.3.7 → 1.3.8

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.
@@ -111,7 +111,6 @@ export async function deleteSession(ctx, sessionId) {
111
111
  return withDeleteLock(sessionId, async () => {
112
112
  const sessions = ctx.get("sessions");
113
113
  const workspaceRegistry = ctx.get("workspaceRegistry");
114
- const persistence = ctx.get("sessionPersistence");
115
114
 
116
115
  // 0. 运行中 / 续跑中的会话拒绝删除
117
116
  const live = sessions?.get(sessionId);
@@ -140,11 +139,16 @@ export async function deleteSession(ctx, sessionId) {
140
139
  // 4. 删除磁盘 artifact(session 目录)。sqlite 查询索引会对账自动清理。
141
140
  // 先 detach 再删文件:持久化协调器在 session/disposed 后退休该会话,
142
141
  // 不会再把它写回磁盘。
143
- if (persistence?.listArtifacts) {
144
- const artifacts = await persistence.listArtifacts();
145
- const artifact = artifacts.find((entry) => entry.header?.id === sessionId);
146
- if (artifact?.path) {
147
- const sessionDir = path.dirname(artifact.path);
142
+ //
143
+ // ⚠️ 0.2.x 已移除 `persistence.listArtifacts`:原实现整段是死代码 ——
144
+ // 只做了 detach、**文件一个没删**,重启后会话原样回来(表现为「删除成功但没删掉」)。
145
+ // 现在直接按 id 定位日志文件(findSessionLogById 扫 <DSH_HOME>/sessions/<project>/<id>/),
146
+ // 取其所在目录删除。
147
+ try {
148
+ const { findSessionLogById, sessionsRootOf } = await import("../sessions/events.js");
149
+ const logPath = findSessionLogById(sessionId, sessionsRootOf());
150
+ if (logPath) {
151
+ const sessionDir = path.dirname(logPath);
148
152
  await rm(sessionDir, { recursive: true, force: true });
149
153
  // 尽力清理空的 project 目录(会话删光后不留空壳)
150
154
  const projectDir = path.dirname(sessionDir);
@@ -154,6 +158,20 @@ export async function deleteSession(ctx, sessionId) {
154
158
  // 目录非空或不可读,保留即可
155
159
  }
156
160
  }
161
+ } catch {
162
+ // 定位/删除失败不阻断(内存侧已 detach),文件会留下 —— 下次删除会再试
163
+ }
164
+
165
+ // 4.5 同步清理两份**落盘索引**:删掉的会话不能在索引里继续存在。
166
+ // 面板现在先读落盘索引出列表(否则冷缓存要等 41~226 秒),索引不清就会出现
167
+ // 「删掉了、刷新又回来」;价值索引同理(下次筛选还会把它当低价值勾出来)。
168
+ try {
169
+ const { forgetSessionInListCache } = await import("../sessions/list-cache.js");
170
+ forgetSessionInListCache(ctx, sessionId);
171
+ const { forgetValueVerdict } = await import("./value-index.js");
172
+ forgetValueVerdict(ctx, sessionId);
173
+ } catch {
174
+ // 索引清理失败不影响删除本身(下次全量重建会自然 prune 掉)
157
175
  }
158
176
 
159
177
  // 5. 清理自动重命名 / 自动续跑开关(config.json 两组同步清,防同名会话重建后误判)
@@ -320,13 +338,18 @@ export async function deleteBatchSessions(ctx, sessionIds) {
320
338
  const persistIds = new Set();
321
339
  let canCheckPersist = false;
322
340
  try {
323
- // 持久化会话权威来源 = persistence.list()(mock 与真实实现都提供);
324
- // listArtifacts 仅作补充(可能未实现/返回空,不代表会话不存在)
341
+ // 持久化会话权威来源 = persistence.list()。
342
+ // ⚠️ 宿主返回的是 **SessionPersistenceSnapshot**(`{ header, revision, ... }`),
343
+ // 不是裸 header —— 只认 `m.id` 会得到空集合,于是每个 id 都被判成 not-found,
344
+ // 批量删除「全部跳过」正是这么来的(列表侧早就按 `entry.header ?? entry` 兼容了)。
325
345
  if (persistence?.list) {
326
346
  const metas = await persistence.list();
327
347
  if (Array.isArray(metas)) {
328
348
  canCheckPersist = true;
329
- for (const m of metas) if (m?.id) persistIds.add(m.id);
349
+ for (const m of metas) {
350
+ const id = m?.id ?? m?.header?.id;
351
+ if (typeof id === "string" && id !== "") persistIds.add(id);
352
+ }
330
353
  }
331
354
  }
332
355
  } catch { canCheckPersist = false; }
@@ -0,0 +1,135 @@
1
+ // 会话价值索引:把「这条会话值不值得留」的判定结果按会话落盘复用。
2
+ //
3
+ // 【为什么需要】价值判定必须读会话日志(逐帧解压 + 逐行解析),实测本机样本:
4
+ // 0.2 MB ≈ 85 ms、1.6 MB ≈ 311 ms、7 MB ≈ 1.1 s、30 MB ≈ 7 s、86 MB ≈ 16 s(解压吞吐约 5 MB/s)。
5
+ // 上百条会话全量重算就是几十秒起步 —— 而判定结果在「会话没变」时是稳定的,
6
+ // 每次筛选都重算纯属浪费。
7
+ //
8
+ // 【失效判据(两条,任一不满足即重算)】
9
+ // ① 变更令牌:列表项 updatedAt 变了(说明又追加了事件)→ 旧结论作废;
10
+ // ② 时间上限:超过 valueIndexTtlHours(默认 24 小时,配置可覆盖)→ 重算一次。
11
+ // 两条合起来的语义是「同一份内容 24 小时内只算一次」。
12
+ //
13
+ // 【落盘】<DSH_HOME>/storages/dsh-session-conductor/value-index.json,原子写(tmp + rename),
14
+ // 节流 2 秒合并多次写入;任何异常都退化为「没有索引」,绝不影响筛选本身。
15
+
16
+ import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
17
+ import path from "node:path";
18
+
19
+ import { cfg, resolveDshHome } from "../core/config.js";
20
+
21
+ const VALUE_INDEX_VERSION = 1;
22
+ const VALUE_INDEX_SUBDIR = ["storages", "dsh-session-conductor"];
23
+ const DEFAULT_TTL_HOURS = 24;
24
+ const SAVE_DEBOUNCE_MS = 2000;
25
+
26
+ /** { version, entries: { [sessionId]: { at, updatedAt, value, score, reason } } } */
27
+ let indexCache = null;
28
+ let indexPath = null;
29
+ let indexDirty = false;
30
+ let indexTimer = null;
31
+
32
+ /** 索引文件绝对路径(首次解析后缓存)。 */
33
+ export function valueIndexFilePath(ctx) {
34
+ if (indexPath !== null) return indexPath;
35
+ const home = resolveDshHome(ctx, cfg);
36
+ indexPath = home ? path.join(home, ...VALUE_INDEX_SUBDIR, "value-index.json") : "";
37
+ return indexPath;
38
+ }
39
+
40
+ /** 读索引(进程内只读一次;文件不存在/损坏都按空索引处理,不向上抛)。 */
41
+ export function loadValueIndex(ctx) {
42
+ if (indexCache !== null) return indexCache;
43
+ indexCache = { version: VALUE_INDEX_VERSION, entries: {} };
44
+ const file = valueIndexFilePath(ctx);
45
+ if (!file) return indexCache;
46
+ try {
47
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
48
+ if (parsed?.version === VALUE_INDEX_VERSION && parsed.entries && typeof parsed.entries === "object") {
49
+ indexCache = { version: VALUE_INDEX_VERSION, entries: parsed.entries };
50
+ }
51
+ } catch {
52
+ // 首次运行 / 文件损坏:按空索引继续,本次算完会覆盖写入
53
+ }
54
+ return indexCache;
55
+ }
56
+
57
+ /** 有效期(毫秒):默认 24 小时,可由配置 valueIndexTtlHours 覆盖。 */
58
+ export function valueIndexTtlMs() {
59
+ const hours = Number(cfg?.valueIndexTtlHours);
60
+ const effective = Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_TTL_HOURS;
61
+ return effective * 3600_000;
62
+ }
63
+
64
+ /**
65
+ * 取可复用的判定结果;没有 / 会话变过 / 超期都返回 null(调用方据此决定重算)。
66
+ * @param {object} ctx 插件上下文
67
+ * @param {string} sessionId 会话 id
68
+ * @param {number|null} updatedAt 列表项上的最后活动时间(变更令牌)
69
+ */
70
+ export function getValueVerdict(ctx, sessionId, updatedAt) {
71
+ const entry = loadValueIndex(ctx).entries[sessionId];
72
+ if (!entry) return null;
73
+ if (typeof updatedAt === "number" && typeof entry.updatedAt === "number" && entry.updatedAt !== updatedAt) return null;
74
+ if (Date.now() - (entry.at ?? 0) > valueIndexTtlMs()) return null;
75
+ return entry;
76
+ }
77
+
78
+ /** 写入一条判定结果(标记脏,节流落盘)。 */
79
+ export function putValueVerdict(ctx, sessionId, updatedAt, verdict) {
80
+ const index = loadValueIndex(ctx);
81
+ index.entries[sessionId] = {
82
+ at: Date.now(),
83
+ updatedAt: typeof updatedAt === "number" ? updatedAt : null,
84
+ value: verdict.value,
85
+ score: verdict.score ?? 0,
86
+ reason: verdict.reason ?? "",
87
+ };
88
+ indexDirty = true;
89
+ if (indexTimer !== null) return;
90
+ indexTimer = setTimeout(() => {
91
+ indexTimer = null;
92
+ if (indexDirty) saveValueIndexNow(ctx);
93
+ }, SAVE_DEBOUNCE_MS);
94
+ if (typeof indexTimer?.unref === "function") indexTimer.unref();
95
+ }
96
+
97
+ /**
98
+ * 忘掉某个会话的价值判定(删除会话后必须调用)。
99
+ * 否则删掉的会话还留着一条「低价值」结论,下次筛选若按索引命中就会把它勾出来 ——
100
+ * 而它其实已经不存在了。
101
+ * @returns {boolean} true = 确实删掉了条目
102
+ */
103
+ export function forgetValueVerdict(ctx, sessionId) {
104
+ if (!sessionId) return false;
105
+ const index = loadValueIndex(ctx);
106
+ if (!index?.entries || !(sessionId in index.entries)) return false;
107
+ delete index.entries[sessionId];
108
+ indexDirty = true;
109
+ saveValueIndexNow(ctx);
110
+ return true;
111
+ }
112
+
113
+ /** 立即落盘(原子写:tmp + rename)。失败只丢缓存,不抛。 */
114
+ export function saveValueIndexNow(ctx) {
115
+ const file = valueIndexFilePath(ctx);
116
+ if (!file || indexCache === null) return;
117
+ try {
118
+ mkdirSync(path.dirname(file), { recursive: true });
119
+ const tmpPath = `${file}.tmp-${process.pid}`;
120
+ writeFileSync(tmpPath, JSON.stringify(indexCache), "utf8");
121
+ renameSync(tmpPath, file);
122
+ indexDirty = false;
123
+ } catch {
124
+ // 写盘失败(只读卷/磁盘满):本次不落盘,下次继续尝试
125
+ }
126
+ }
127
+
128
+ /** 测试钩子:清空内存态并允许重新指定路径。 */
129
+ export function resetValueIndexForTest() {
130
+ indexCache = null;
131
+ indexPath = null;
132
+ indexDirty = false;
133
+ if (indexTimer !== null) clearTimeout(indexTimer);
134
+ indexTimer = null;
135
+ }
package/lib/i18n/en.json CHANGED
@@ -103,19 +103,19 @@
103
103
  "deleteBatch.confirm.body": "This cannot be undone (logs are removed too); running sessions are skipped automatically.",
104
104
  "deleteBatch.done": "Deleted {deleted}{skippedText}",
105
105
  "deleteBatch.skipped": ", skipped {n} (running/undeletable)",
106
- "deleteByRule.action": "Delete by rule",
106
+ "deleteByRule.action": "Filter",
107
107
  "deleteByRule.label.archivedOnly": "Archived only",
108
108
  "deleteByRule.label.inactiveDays": "Inactive for more than {n} days (0 = any)",
109
109
  "deleteByRule.label.cwdPrefix": "cwd prefix (optional, group scope)",
110
- "deleteByRule.previewBtn": "Preview",
111
- "deleteByRule.preview": "Rule matches {n} sessions. Delete? (running ones are skipped)",
110
+ "deleteByRule.previewBtn": "Select matching sessions",
111
+ "deleteByRule.filtering": "Filtering… (low-value check reads every session)",
112
112
  "deleteByRule.noMatch": "No sessions match the rule",
113
- "deleteByRule.run": "Delete",
114
113
  "deleteByRule.cancel": "Cancel",
115
114
  "deleteByRule.label.lowValue": "Low-value only (value analysis; dust/duplicates)",
116
115
  "deleteByRule.label.inactiveDays.pre": "Inactive for more than ",
117
116
  "deleteByRule.label.inactiveDays.post": " days (0 = any)",
118
- "deleteByRule.offlineHint": "Preview page is an offline snapshot; deletion is not executed here — use the DSH GUI"
117
+ "deleteByRule.analyzing": "Analyzing low value {done}/{total} (selects as it goes)",
118
+ "deleteByRule.andHint": "All four conditions are AND-ed: a session is selected only if every enabled condition matches (unchecked = that condition is ignored)"
119
119
  },
120
120
  "sessionGroup": {
121
121
  "title": "Session Groups",
package/lib/i18n/zh.json CHANGED
@@ -103,19 +103,19 @@
103
103
  "deleteBatch.confirm.body": "删除后无法恢复(日志一并清除);运行中的会话会被自动跳过。",
104
104
  "deleteBatch.done": "已删除 {deleted} 个{skippedText}",
105
105
  "deleteBatch.skipped": ",跳过 {n} 个(运行中/不可删)",
106
- "deleteByRule.action": "按条件删除",
106
+ "deleteByRule.action": "筛选",
107
107
  "deleteByRule.label.archivedOnly": "仅已归档会话",
108
108
  "deleteByRule.label.inactiveDays": "超过 {n} 天未活跃(0=不限)",
109
109
  "deleteByRule.label.cwdPrefix": "目录前缀(可选,限定分组)",
110
- "deleteByRule.previewBtn": "预览命中",
111
- "deleteByRule.preview": "按条件命中 {n} 个会话,确定删除吗?(运行中的会被跳过)",
110
+ "deleteByRule.previewBtn": "勾选符合条件的会话",
111
+ "deleteByRule.filtering": "筛选中…(低价值需逐个会话读事件,请稍候)",
112
112
  "deleteByRule.noMatch": "没有符合条件的会话",
113
- "deleteByRule.run": "执行删除",
114
113
  "deleteByRule.cancel": "取消",
115
114
  "deleteByRule.label.lowValue": "仅低价值会话(价值分析判定,灰尘/重复可删)",
116
115
  "deleteByRule.label.inactiveDays.pre": "超过 ",
117
116
  "deleteByRule.label.inactiveDays.post": " 天未活跃(0=不限)",
118
- "deleteByRule.offlineHint": "预览页为离线快照,不执行真实删除——请到 DSH GUI 操作"
117
+ "deleteByRule.analyzing": "低价值分析中 {done}/{total}(分析出一个勾一个)",
118
+ "deleteByRule.andHint": "四个条件为「与」关系:只有同时满足的条件才会被勾选(勾选框不选=该条件不参与)"
119
119
  },
120
120
  "sessionGroup": {
121
121
  "title": "会话分组",
@@ -81,11 +81,41 @@ export function saveListDiskCacheNow(ctx) {
81
81
  }
82
82
  }
83
83
 
84
+ /**
85
+ * 「这个会话的列表信息过期了」的统一入口:内存 TTL 缓存 + 落盘索引条目一起作废。
86
+ *
87
+ * 凡是改了会话**可见属性**(标题、归档态、状态后缀、内容)的写操作都必须调它一次 ——
88
+ * 只失效内存缓存不够:冷会话的标题/归档态来自落盘索引,revision 没变就不会重算,
89
+ * 于是表现为「后端改好了、面板还是旧的」。删除走 forgetSessionInListCache(条目直接删掉)。
90
+ */
91
+ export function markSessionListStale(ctx, sessionId) {
92
+ if (sessionId) forgetSessionInListCache(ctx, sessionId);
93
+ invalidateSessionListCache();
94
+ }
95
+
84
96
  /** 立即标记为脏(用于「清理完缓存条目后强制落盘」)。 */
85
97
  export function markListDiskCacheDirty() {
86
98
  listDiskCacheDirty = true;
87
99
  }
88
100
 
101
+ /**
102
+ * 忘掉某个会话的落盘缓存条目(删除/移走后必须调用)。
103
+ *
104
+ * 为什么必须有:面板现在**先读落盘索引**出列表(冷缓存时否则要等 41~226 秒),
105
+ * 删掉的会话若只从内存与磁盘上消失、索引里还留着,刷新页面就又被索引"复活"显示出来。
106
+ * 这里把条目删掉并立刻落盘,保证「删了就是删了」。
107
+ * @returns {boolean} true = 确实删掉了条目
108
+ */
109
+ export function forgetSessionInListCache(ctx, sessionId) {
110
+ if (!sessionId) return false;
111
+ const cache = loadListDiskCache(ctx);
112
+ if (!cache?.entries || !(sessionId in cache.entries)) return false;
113
+ delete cache.entries[sessionId];
114
+ listDiskCacheDirty = true;
115
+ saveListDiskCacheNow(ctx);
116
+ return true;
117
+ }
118
+
89
119
  /** 当前是否处于脏状态(构建段据此决定收尾是否落盘)。 */
90
120
  export function isListDiskCacheDirty() {
91
121
  return listDiskCacheDirty;
package/lib/value.js CHANGED
@@ -346,6 +346,37 @@ export function keywordMatch(title, lastUserText, keywords) {
346
346
  return { hit: matched.length > 0, matched };
347
347
  }
348
348
 
349
+ /**
350
+ * 单会话价值判定(流式用):与 analyzeValuesWithKeywords 里「无关键词 / 无 LLM」那条路径
351
+ * **同一套规则**,只是按会话逐个算 —— 便于边分析边把结果推给前端(分析出一个勾一个),
352
+ * 不必等全量跑完才给第一个结果。
353
+ *
354
+ * 规则顺序:assessValue 特征评分 → 事件不可读时保守判 high(孤儿/未登记会话不误删)。
355
+ * 关键词与 LLM 分支不在流式路径里(面板筛选不用它们)。
356
+ * @param {object} session 会话对象
357
+ * @param {Array} events 该会话事件流(读不到传空数组)
358
+ * @param {Date} now
359
+ * @param {number} staleDays
360
+ * @returns {{id:string, value:"high"|"low", score:number, reason:string, source:string, status:string}}
361
+ */
362
+ export function classifySessionLevel(session, events, now = new Date(), staleDays = 3) {
363
+ const evs = Array.isArray(events) ? events : [];
364
+ const feats = buildValueFeatures(evs, session, now);
365
+ const { status } = classifySessionValue(session, lastAssistantText(evs), now, staleDays);
366
+ let scored = assessValue(feats);
367
+ if (feats && feats.eventCount === 0 && !(feats.activeHours > LONG_STALE_HOURS)) {
368
+ scored = { value: "high", score: VALUE_SCORE.artifactSafe, reason: "事件不可读(孤儿/未登记),保守保留", source: "artifact-safe" };
369
+ }
370
+ return {
371
+ id: session.id,
372
+ value: scored.value,
373
+ score: scored.score ?? 0,
374
+ reason: scored.reason || "",
375
+ source: scored.source || "rule",
376
+ status,
377
+ };
378
+ }
379
+
349
380
  /**
350
381
  * 关键词命中 → 高/低价值聚合(关键词命中即无条件 high,其余走 assessValue 特征评分)。
351
382
  * @param {Array} sessions 会话对象数组
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.3.7",
3
+ "version": "1.3.8",
4
4
  "description": "DSH session management enhancement: archive/restore sessions, batch & conditional delete, AI auto-rename, interrupted-session auto-resume, release idle sessions, workspace grouping, undo last message, repair damaged session logs, value analysis, full-text search, per-member model switching (list_models / set_member_model), and plan/closing template injection (system-prompt + context).",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",