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.
@@ -0,0 +1,206 @@
1
+ // 会话回收站:删除只打标记,文件原样保留;到期或手动清空才真正删盘。
2
+ //
3
+ // 【为什么这么设计】删除是不可逆操作,而会话日志动辄几十 MB。把「删除」降级为
4
+ // 「在索引里打一个标记」,就同时得到三件事:
5
+ // ① 还原是秒级且零风险(文件从没动过);
6
+ // ② 误删可撤销(按批次整批还原);
7
+ // ③ 真正删盘集中在两个可控时机 —— 到期(保留天数可配,默认 7 天)或一键清空。
8
+ //
9
+ // 【落盘】<DSH_HOME>/storages/dsh-session-conductor/trash.json,原子写(tmp + rename)。
10
+ // 条目:{ [sessionId]: { deletedAt, batchId, title, cwd, updatedAt, archived } }
11
+ // 不存在/损坏都按「空回收站」处理,绝不影响列表与删除本身。
12
+
13
+ import { cfg } from "../core/config.js";
14
+ import { getSection, setSection, saveStoreNow } from "../core/index-store.js";
15
+
16
+ const TRASH_VERSION = 1;
17
+ const DEFAULT_RETENTION_DAYS = 7;
18
+ const MAX_RETENTION_DAYS = 365;
19
+ const DAY_MS = 24 * 3600 * 1000;
20
+
21
+ let trashCache = null;
22
+
23
+ /** 读回收站(进程内只读一次;索引不可用按空处理)。
24
+ * 落盘统一走 <DSH_HOME>/storages/dsh-session-conductor/index.json 的 trash section。 */
25
+ export function loadTrash(ctx) {
26
+ if (trashCache !== null) return trashCache;
27
+ trashCache = { version: TRASH_VERSION, entries: {} };
28
+ try {
29
+ const section = getSection(ctx, "trash");
30
+ if (section?.entries && typeof section.entries === "object") {
31
+ trashCache = { version: TRASH_VERSION, entries: section.entries };
32
+ if (Number.isFinite(section.retentionDays)) trashCache.retentionDays = section.retentionDays;
33
+ }
34
+ } catch {
35
+ // 索引不可用:按空回收站继续
36
+ }
37
+ return trashCache;
38
+ }
39
+
40
+ /** 立即落盘(交给统一索引存储做原子写)。失败只丢内存态,不抛。 */
41
+ export function saveTrashNow(ctx) {
42
+ if (trashCache === null) return;
43
+ try {
44
+ setSection(ctx, "trash", {
45
+ entries: trashCache.entries,
46
+ ...(Number.isFinite(trashCache.retentionDays) ? { retentionDays: trashCache.retentionDays } : {}),
47
+ });
48
+ saveStoreNow(ctx);
49
+ } catch {
50
+ // 写盘失败:下次再试,不影响「已标记」的内存态
51
+ }
52
+ }
53
+
54
+ /**
55
+ * 保留天数:**优先取回收站文件里存的值**(面板上改的就是它),
56
+ * 没设置过才回落到配置 trashRetentionDays / 默认 7 天。范围 1~365。
57
+ */
58
+ export function retentionDays(ctx) {
59
+ const stored = ctx ? Number(loadTrash(ctx).retentionDays) : NaN;
60
+ if (Number.isFinite(stored) && stored >= 1) return Math.min(MAX_RETENTION_DAYS, Math.round(stored));
61
+ const days = Number(cfg?.trashRetentionDays);
62
+ if (!Number.isFinite(days) || days < 1) return DEFAULT_RETENTION_DAYS;
63
+ return Math.min(MAX_RETENTION_DAYS, Math.round(days));
64
+ }
65
+
66
+ /** 设置保留天数(写回收站文件,立即生效)。 */
67
+ export function setRetentionDays(ctx, days) {
68
+ const value = Math.min(MAX_RETENTION_DAYS, Math.max(1, Math.round(Number(days) || DEFAULT_RETENTION_DAYS)));
69
+ const trash = loadTrash(ctx);
70
+ trash.retentionDays = value;
71
+ saveTrashNow(ctx);
72
+ return value;
73
+ }
74
+
75
+ /** 当前被标记删除的会话 id 集合(列表/搜索据此隐藏)。 */
76
+ export function trashedIds(ctx) {
77
+ return new Set(Object.keys(loadTrash(ctx).entries));
78
+ }
79
+
80
+ /** 单个会话是否在回收站里。 */
81
+ export function isTrashed(ctx, sessionId) {
82
+ return typeof sessionId === "string" && sessionId !== "" && sessionId in loadTrash(ctx).entries;
83
+ }
84
+
85
+ /**
86
+ * 回收站列表(按删除时间倒序),附带到期时间与剩余天数。
87
+ * @returns {Array<{sessionId,title,cwd,updatedAt,archived,deletedAt,batchId,expiresAt,remainingDays}>}
88
+ */
89
+ export function listTrash(ctx) {
90
+ const ttl = retentionDays(ctx) * DAY_MS;
91
+ const now = Date.now();
92
+ return Object.entries(loadTrash(ctx).entries)
93
+ .map(([sessionId, entry]) => {
94
+ const deletedAt = typeof entry?.deletedAt === "number" ? entry.deletedAt : 0;
95
+ const expiresAt = deletedAt + ttl;
96
+ return {
97
+ sessionId,
98
+ title: entry?.title ?? null,
99
+ cwd: entry?.cwd ?? null,
100
+ updatedAt: entry?.updatedAt ?? null,
101
+ archived: entry?.archived === true,
102
+ deletedAt,
103
+ batchId: entry?.batchId ?? "",
104
+ expiresAt,
105
+ remainingDays: Math.max(0, Math.ceil((expiresAt - now) / DAY_MS)),
106
+ };
107
+ })
108
+ .sort((a, b) => b.deletedAt - a.deletedAt);
109
+ }
110
+
111
+ /** 生成一个批次号(一次删除操作 = 一个批次,撤销按批次整批还原)。 */
112
+ export function newBatchId() {
113
+ return `b${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
114
+ }
115
+
116
+ /**
117
+ * 标记删除(不碰文件)。
118
+ * @param {object} ctx
119
+ * @param {Array<string>} sessionIds
120
+ * @param {object} [metaById] 会话快照(title/cwd/updatedAt/archived),用于回收站列表展示
121
+ * @param {string} [batchId] 指定批次(批量删除传同一个)
122
+ * @returns {{batchId:string, marked:string[]}}
123
+ */
124
+ export function markDeleted(ctx, sessionIds, metaById = {}, batchId = newBatchId()) {
125
+ const trash = loadTrash(ctx);
126
+ const marked = [];
127
+ const now = Date.now();
128
+ for (const id of Array.isArray(sessionIds) ? sessionIds : []) {
129
+ if (typeof id !== "string" || id === "") continue;
130
+ const meta = metaById?.[id] ?? {};
131
+ trash.entries[id] = {
132
+ deletedAt: now,
133
+ batchId,
134
+ title: meta.title ?? null,
135
+ cwd: meta.cwd ?? null,
136
+ updatedAt: typeof meta.updatedAt === "number" ? meta.updatedAt : null,
137
+ archived: meta.archived === true,
138
+ };
139
+ marked.push(id);
140
+ }
141
+ if (marked.length > 0) saveTrashNow(ctx);
142
+ return { batchId, marked };
143
+ }
144
+
145
+ /**
146
+ * 还原指定会话(删标记,文件本来就在)。
147
+ * @returns {{restored:string[], missing:string[]}}
148
+ */
149
+ export function restoreSessions(ctx, sessionIds) {
150
+ const trash = loadTrash(ctx);
151
+ const restored = [];
152
+ const missing = [];
153
+ for (const id of Array.isArray(sessionIds) ? sessionIds : []) {
154
+ if (typeof id !== "string" || id === "") continue;
155
+ if (id in trash.entries) {
156
+ delete trash.entries[id];
157
+ restored.push(id);
158
+ } else {
159
+ missing.push(id);
160
+ }
161
+ }
162
+ if (restored.length > 0) saveTrashNow(ctx);
163
+ return { restored, missing };
164
+ }
165
+
166
+ /** 最近一个批次(撤销用)。空回收站返回 null。 */
167
+ export function latestBatch(ctx) {
168
+ const entries = Object.entries(loadTrash(ctx).entries);
169
+ if (entries.length === 0) return null;
170
+ let best = null;
171
+ for (const [sessionId, entry] of entries) {
172
+ const at = typeof entry?.deletedAt === "number" ? entry.deletedAt : 0;
173
+ if (best === null || at > best.deletedAt) {
174
+ best = { sessionId, deletedAt: at, batchId: entry?.batchId ?? "" };
175
+ }
176
+ }
177
+ if (best === null) return null;
178
+ const ids = entries.filter(([, e]) => (e?.batchId ?? "") === best.batchId).map(([id]) => id);
179
+ return { batchId: best.batchId, deletedAt: best.deletedAt, ids };
180
+ }
181
+
182
+ /** 已到期(超过保留天数)的会话 id。 */
183
+ export function expiredIds(ctx, now = Date.now()) {
184
+ const ttl = retentionDays(ctx) * DAY_MS;
185
+ return Object.entries(loadTrash(ctx).entries)
186
+ .filter(([, entry]) => {
187
+ const at = typeof entry?.deletedAt === "number" ? entry.deletedAt : 0;
188
+ return at > 0 && now - at >= ttl;
189
+ })
190
+ .map(([id]) => id);
191
+ }
192
+
193
+ /** 从回收站移除条目(真删完成后调用;不落盘,由调用方统一保存)。 */
194
+ export function forgetTrashed(ctx, sessionIds) {
195
+ const trash = loadTrash(ctx);
196
+ let removed = 0;
197
+ for (const id of Array.isArray(sessionIds) ? sessionIds : []) {
198
+ if (id in trash.entries) { delete trash.entries[id]; removed += 1; }
199
+ }
200
+ return removed;
201
+ }
202
+
203
+ /** 测试钩子:清空内存态。 */
204
+ export function resetTrashForTest() {
205
+ trashCache = null;
206
+ }
@@ -0,0 +1,120 @@
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 { cfg } from "../core/config.js";
17
+ import { getSection, setSection, saveStoreNow } from "../core/index-store.js";
18
+
19
+ const VALUE_INDEX_VERSION = 1;
20
+ const DEFAULT_TTL_HOURS = 24;
21
+ const SAVE_DEBOUNCE_MS = 2000;
22
+
23
+ /** { version, entries: { [sessionId]: { at, updatedAt, value, score, reason } } } */
24
+ let indexCache = null;
25
+ let indexPath = null;
26
+ let indexDirty = false;
27
+ let indexTimer = null;
28
+
29
+ /** 读索引(进程内只读一次;索引不可用都按空处理,不向上抛)。
30
+ * 落盘统一走 <DSH_HOME>/storages/dsh-session-conductor/index.json 的 value section。 */
31
+ export function loadValueIndex(ctx) {
32
+ if (indexCache !== null) return indexCache;
33
+ indexCache = { version: VALUE_INDEX_VERSION, entries: {} };
34
+ try {
35
+ const section = getSection(ctx, "value");
36
+ if (section?.entries && typeof section.entries === "object") {
37
+ indexCache = { version: VALUE_INDEX_VERSION, entries: section.entries };
38
+ }
39
+ } catch {
40
+ // 索引不可用:按空索引继续
41
+ }
42
+ return indexCache;
43
+ }
44
+
45
+ /** 有效期(毫秒):默认 24 小时,可由配置 valueIndexTtlHours 覆盖。 */
46
+ export function valueIndexTtlMs() {
47
+ const hours = Number(cfg?.valueIndexTtlHours);
48
+ const effective = Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_TTL_HOURS;
49
+ return effective * 3600_000;
50
+ }
51
+
52
+ /**
53
+ * 取可复用的判定结果;没有 / 会话变过 / 超期都返回 null(调用方据此决定重算)。
54
+ * @param {object} ctx 插件上下文
55
+ * @param {string} sessionId 会话 id
56
+ * @param {number|null} updatedAt 列表项上的最后活动时间(变更令牌)
57
+ */
58
+ export function getValueVerdict(ctx, sessionId, updatedAt) {
59
+ const entry = loadValueIndex(ctx).entries[sessionId];
60
+ if (!entry) return null;
61
+ if (typeof updatedAt === "number" && typeof entry.updatedAt === "number" && entry.updatedAt !== updatedAt) return null;
62
+ if (Date.now() - (entry.at ?? 0) > valueIndexTtlMs()) return null;
63
+ return entry;
64
+ }
65
+
66
+ /** 写入一条判定结果(标记脏,节流落盘)。 */
67
+ export function putValueVerdict(ctx, sessionId, updatedAt, verdict) {
68
+ const index = loadValueIndex(ctx);
69
+ index.entries[sessionId] = {
70
+ at: Date.now(),
71
+ updatedAt: typeof updatedAt === "number" ? updatedAt : null,
72
+ value: verdict.value,
73
+ score: verdict.score ?? 0,
74
+ reason: verdict.reason ?? "",
75
+ };
76
+ indexDirty = true;
77
+ if (indexTimer !== null) return;
78
+ indexTimer = setTimeout(() => {
79
+ indexTimer = null;
80
+ if (indexDirty) saveValueIndexNow(ctx);
81
+ }, SAVE_DEBOUNCE_MS);
82
+ if (typeof indexTimer?.unref === "function") indexTimer.unref();
83
+ }
84
+
85
+ /**
86
+ * 忘掉某个会话的价值判定(删除会话后必须调用)。
87
+ * 否则删掉的会话还留着一条「低价值」结论,下次筛选若按索引命中就会把它勾出来 ——
88
+ * 而它其实已经不存在了。
89
+ * @returns {boolean} true = 确实删掉了条目
90
+ */
91
+ export function forgetValueVerdict(ctx, sessionId) {
92
+ if (!sessionId) return false;
93
+ const index = loadValueIndex(ctx);
94
+ if (!index?.entries || !(sessionId in index.entries)) return false;
95
+ delete index.entries[sessionId];
96
+ indexDirty = true;
97
+ saveValueIndexNow(ctx);
98
+ return true;
99
+ }
100
+
101
+ /** 立即落盘(原子写:tmp + rename)。失败只丢缓存,不抛。 */
102
+ export function saveValueIndexNow(ctx) {
103
+ if (indexCache === null) return;
104
+ try {
105
+ setSection(ctx, "value", { entries: indexCache.entries });
106
+ saveStoreNow(ctx);
107
+ indexDirty = false;
108
+ } catch {
109
+ // 写盘失败(只读卷/磁盘满):本次不落盘,下次继续尝试
110
+ }
111
+ }
112
+
113
+ /** 测试钩子:清空内存态并允许重新指定路径。 */
114
+ export function resetValueIndexForTest() {
115
+ indexCache = null;
116
+ indexPath = null;
117
+ indexDirty = false;
118
+ if (indexTimer !== null) clearTimeout(indexTimer);
119
+ indexTimer = null;
120
+ }
package/lib/i18n/en.json CHANGED
@@ -13,6 +13,21 @@
13
13
  "tab.archived": "Archived",
14
14
  "tab.active": "Active",
15
15
  "tab.help": "Help",
16
+ "tab.trash": "Trash",
17
+ "trash.retention.label": "Keep",
18
+ "trash.retention.unit": "days",
19
+ "trash.retention.save": "Save",
20
+ "trash.undo": "Undo last delete",
21
+ "trash.undo.title": "Restore the latest batch ({n})",
22
+ "trash.restoreSelected": "Restore selected ({n})",
23
+ "trash.selectAll": "Select all",
24
+ "trash.empty": "Empty trash",
25
+ "trash.emptyConfirm": "Emptying the trash permanently deletes {n} sessions (irreversible). Continue?",
26
+ "trash.restore": "Restore",
27
+ "trash.emptyList": "Trash is empty",
28
+ "trash.hint": "Deleting only marks the session; files stay until the retention period ends or you empty the trash",
29
+ "trash.remaining": "{n} day(s) left",
30
+ "trash.nothingToUndo": "Nothing to undo",
16
31
  "list.empty": "No sessions yet",
17
32
  "list.archivedEmpty": "Nothing archived",
18
33
  "list.activeEmpty": "No active sessions (live/running)",
@@ -62,8 +77,8 @@
62
77
  "action.analyze": "Analyze",
63
78
  "action.busy": "Working…",
64
79
  "action.retry": "Retry",
65
- "confirm.delete.title": "Permanently delete session \"{title}\"?",
66
- "confirm.delete.body": "This cannot be undone (the session log will be removed too).",
80
+ "confirm.delete.title": "Delete session \"{title}\"?",
81
+ "confirm.delete.body": "The session moves to the Trash and can be restored from the Trash tab at any time. Files are only removed when the retention period (default 7 days) expires or you empty the trash.",
67
82
  "error.action": "Action failed: {message}",
68
83
  "meta.time.now": "just now",
69
84
  "meta.time.minutes": "{n}m ago",
@@ -71,8 +86,8 @@
71
86
  "meta.time.days": "{n}d ago",
72
87
  "meta.time.months": "{n}mo ago",
73
88
  "meta.time.years": "{n}y ago",
74
- "help.delete.title": "Delete session (added by this plugin)",
75
- "help.delete.desc": "The built-in UI has no way to delete a session. This plugin adds it: refuses to delete a running session, double-checks before deleting, removes the log for good.",
89
+ "help.delete.title": "Delete sessions (added by this plugin)",
90
+ "help.delete.desc": "The built-in UI has no way to delete a session; this plugin adds it. Deleting only marks the session - files stay untouched: it goes to the Trash tab, where you can restore one, restore several, or undo the latest batch. Running sessions are refused. Files are really removed only when the retention period expires (configurable, default 7 days) or you empty the trash.",
76
91
  "help.autoRename.title": "Auto-rename (added by this plugin)",
77
92
  "help.autoRename.desc": "Turn on \"Auto-rename\" for a session and after each finished turn the plugin asks an AI to compare recent session content against the current title; when the topic has clearly drifted it generates a new title and renames automatically. Titles carry a live state suffix: \"(运行中)\" = turn in progress, \"(已中断)\" = crashed/errored and waiting to resume; updated at turn boundaries. Rate-limited (5 min interval, needs a few new messages) to keep token costs low.",
78
93
  "help.autoContinue.title": "Auto-resume (added by this plugin)",
@@ -99,23 +114,23 @@
99
114
  "search.hits": "{n} sessions matched",
100
115
  "search.close": "Collapse results",
101
116
  "deleteBatch.action": "Delete selected ({n})",
102
- "deleteBatch.confirm.title": "Permanently delete the {n} selected sessions?",
103
- "deleteBatch.confirm.body": "This cannot be undone (logs are removed too); running sessions are skipped automatically.",
104
- "deleteBatch.done": "Deleted {deleted}{skippedText}",
105
- "deleteBatch.skipped": ", skipped {n} (running/undeletable)",
106
- "deleteByRule.action": "Delete by rule",
117
+ "deleteBatch.confirm.title": "Delete the {n} selected sessions?",
118
+ "deleteBatch.confirm.body": "They move to the Trash and can be restored anytime. Running sessions are skipped automatically.",
119
+ "deleteBatch.done": "Moved {deleted} to the Trash{skippedText}",
120
+ "deleteBatch.skipped": ", skipped {n} (running / not deletable)",
121
+ "deleteByRule.action": "Filter",
107
122
  "deleteByRule.label.archivedOnly": "Archived only",
108
123
  "deleteByRule.label.inactiveDays": "Inactive for more than {n} days (0 = any)",
109
124
  "deleteByRule.label.cwdPrefix": "cwd prefix (optional, group scope)",
110
- "deleteByRule.previewBtn": "Preview",
111
- "deleteByRule.preview": "Rule matches {n} sessions. Delete? (running ones are skipped)",
125
+ "deleteByRule.previewBtn": "Select matching sessions",
126
+ "deleteByRule.filtering": "Filtering… (low-value check reads every session)",
112
127
  "deleteByRule.noMatch": "No sessions match the rule",
113
- "deleteByRule.run": "Delete",
114
128
  "deleteByRule.cancel": "Cancel",
115
129
  "deleteByRule.label.lowValue": "Low-value only (value analysis; dust/duplicates)",
116
130
  "deleteByRule.label.inactiveDays.pre": "Inactive for more than ",
117
131
  "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"
132
+ "deleteByRule.analyzing": "Analyzing low value {done}/{total} (selects as it goes)",
133
+ "deleteByRule.andHint": "All four conditions are AND-ed: a session is selected only if every enabled condition matches (unchecked = that condition is ignored)"
119
134
  },
120
135
  "sessionGroup": {
121
136
  "title": "Session Groups",
@@ -202,4 +217,4 @@
202
217
  "armCurNone": "Current: not selected",
203
218
  "renderFail": "Settings render failed: "
204
219
  }
205
- }
220
+ }
package/lib/i18n/zh.json CHANGED
@@ -13,6 +13,21 @@
13
13
  "tab.archived": "已归档",
14
14
  "tab.active": "活跃",
15
15
  "tab.help": "说明",
16
+ "tab.trash": "回收站",
17
+ "trash.retention.label": "保留",
18
+ "trash.retention.unit": "天",
19
+ "trash.retention.save": "保存",
20
+ "trash.undo": "撤销上次删除",
21
+ "trash.undo.title": "还原最近一批({n} 个)",
22
+ "trash.restoreSelected": "还原选中 ({n})",
23
+ "trash.selectAll": "全选",
24
+ "trash.empty": "一键清空",
25
+ "trash.emptyConfirm": "清空回收站会立即真删 {n} 个会话(不可恢复),确定吗?",
26
+ "trash.restore": "还原",
27
+ "trash.emptyList": "回收站是空的",
28
+ "trash.hint": "删除只是打标记、文件还在;到期(保留天数)或一键清空才真正删盘",
29
+ "trash.remaining": "剩 {n} 天",
30
+ "trash.nothingToUndo": "没有可撤销的删除",
16
31
  "list.empty": "还没有会话",
17
32
  "list.archivedEmpty": "没有已归档的会话",
18
33
  "list.activeEmpty": "当前没有活跃会话(live/running)",
@@ -55,8 +70,8 @@
55
70
  "action.analyze": "分析",
56
71
  "action.busy": "处理中…",
57
72
  "action.retry": "重试",
58
- "confirm.delete.title": "确定要永久删除会话「{title}」吗?",
59
- "confirm.delete.body": "删除后无法恢复(会话日志与记录将一并删除)。",
73
+ "confirm.delete.title": "确定要删除会话「{title}」吗?",
74
+ "confirm.delete.body": "会话会移入回收站,可在「回收站」页随时还原;只有到期(保留天数,默认 7 天)或一键清空才真正删盘。",
60
75
  "action.undo": "撤回消息",
61
76
  "undo.preview.empty": "(无文本消息)",
62
77
  "confirm.undo.title": "撤回最后一条消息?",
@@ -72,7 +87,7 @@
72
87
  "meta.time.months": "{n}个月前",
73
88
  "meta.time.years": "{n}年前",
74
89
  "help.delete.title": "删除会话(本插件新增)",
75
- "help.delete.desc": "内置界面没有任何删除会话的入口。本插件补上了:拒绝删除运行中的会话,删除前二次确认,删除后日志一并清除、不可恢复。",
90
+ "help.delete.desc": "内置界面没有任何删除会话的入口,本插件补上了。删除**只打标记、不动会话文件**:会话会进「回收站」标签页,可单条还原、多选还原,或按批次撤销最近一次删除;运行中的会话拒绝删除。真正删盘只发生在回收站到期(保留天数可设,默认 7 天)或「一键清空」。",
76
91
  "help.autoRename.title": "自动重命名(本插件新增)",
77
92
  "help.autoRename.desc": "给会话勾选「自动重命名」后,每个回合结束,插件会用 AI 分析最近会话内容;发现主题/方向已明显偏离当前标题时,自动生成新标题并改掉。标题会带上实时状态后缀:「(运行中)」= 正在执行回合、「(已中断)」= 崩溃/错误中断待续跑;回合结束自动更新。分析带 5 分钟最小间隔和新增消息数门槛,避免频繁调用浪费额度。",
78
93
  "help.autoContinue.title": "自动续跑(本插件新增)",
@@ -99,23 +114,23 @@
99
114
  "search.hits": "命中 {n} 个会话",
100
115
  "search.close": "收起结果",
101
116
  "deleteBatch.action": "删除选中 ({n})",
102
- "deleteBatch.confirm.title": "确定要永久删除选中的 {n} 个会话吗?",
103
- "deleteBatch.confirm.body": "删除后无法恢复(日志一并清除);运行中的会话会被自动跳过。",
104
- "deleteBatch.done": "已删除 {deleted} 个{skippedText}",
117
+ "deleteBatch.confirm.title": "确定要删除选中的 {n} 个会话吗?",
118
+ "deleteBatch.confirm.body": "会移入回收站,可随时还原;运行中的会话会被自动跳过。",
119
+ "deleteBatch.done": "已移入回收站 {deleted} 个{skippedText}",
105
120
  "deleteBatch.skipped": ",跳过 {n} 个(运行中/不可删)",
106
- "deleteByRule.action": "按条件删除",
121
+ "deleteByRule.action": "筛选",
107
122
  "deleteByRule.label.archivedOnly": "仅已归档会话",
108
123
  "deleteByRule.label.inactiveDays": "超过 {n} 天未活跃(0=不限)",
109
124
  "deleteByRule.label.cwdPrefix": "目录前缀(可选,限定分组)",
110
- "deleteByRule.previewBtn": "预览命中",
111
- "deleteByRule.preview": "按条件命中 {n} 个会话,确定删除吗?(运行中的会被跳过)",
125
+ "deleteByRule.previewBtn": "勾选符合条件的会话",
126
+ "deleteByRule.filtering": "筛选中…(低价值需逐个会话读事件,请稍候)",
112
127
  "deleteByRule.noMatch": "没有符合条件的会话",
113
- "deleteByRule.run": "执行删除",
114
128
  "deleteByRule.cancel": "取消",
115
129
  "deleteByRule.label.lowValue": "仅低价值会话(价值分析判定,灰尘/重复可删)",
116
130
  "deleteByRule.label.inactiveDays.pre": "超过 ",
117
131
  "deleteByRule.label.inactiveDays.post": " 天未活跃(0=不限)",
118
- "deleteByRule.offlineHint": "预览页为离线快照,不执行真实删除——请到 DSH GUI 操作"
132
+ "deleteByRule.analyzing": "低价值分析中 {done}/{total}(分析出一个勾一个)",
133
+ "deleteByRule.andHint": "四个条件为「与」关系:只有同时满足的条件才会被勾选(勾选框不选=该条件不参与)"
119
134
  },
120
135
  "sessionGroup": {
121
136
  "title": "会话分组",
@@ -202,4 +217,4 @@
202
217
  "armCurNone": "当前:未选择",
203
218
  "renderFail": "设置页渲染失败:"
204
219
  }
205
- }
220
+ }
@@ -7,45 +7,32 @@
7
7
  // ③ 列表 TTL 缓存与失效(写操作后 invalidateSessionListCache 立即失效)。
8
8
  // 列表的构建流程(buildSessionList)在 list.js。
9
9
 
10
- import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
11
- import path from "node:path";
10
+ import { getSection, setSection, saveStoreNow } from "../core/index-store.js";
12
11
  import { log } from "../shared/log.js";
13
- import { cfg, resolveDshHome, autoRenameEnabled, effectiveAutoContinue } from "../core/config.js";
12
+ import { cfg, autoRenameEnabled, effectiveAutoContinue } from "../core/config.js";
14
13
  import { continueJobs } from "../core/state.js";
15
14
  import { stripArchiveWsPrefix } from "../features/rename/title.js";
16
15
 
17
16
  /** 缓存文件版本(不符视为空缓存)。 */
18
17
  const LIST_CACHE_VERSION = 1;
19
- /** 缓存文件所在子目录。 */
20
- const LIST_CACHE_SUBDIR = ["storages", "dsh-session-conductor"];
21
18
 
22
- /** 落盘缓存内容:{ version, entries: { [id]: { rev, title, updatedAt, interruption, inspectError, cwd, createdAt, parentSession } } } */
19
+ /** 内存缓存:{ version, entries: { [id]: { rev, title, updatedAt, interruption, inspectError, cwd, createdAt, parentSession } } }
20
+ * 落盘统一走 <DSH_HOME>/storages/dsh-session-conductor/index.json 的 list section。 */
23
21
  let listDiskCache = null;
24
- let listDiskCachePath = null;
25
22
  let listDiskCacheDirty = false;
26
23
  let listDiskCacheTimer = null;
27
24
 
28
- /** 缓存文件绝对路径(首次调用时解析并缓存)。 */
29
- export function listCacheFilePath(ctx) {
30
- if (listDiskCachePath !== null) return listDiskCachePath;
31
- const home = resolveDshHome(ctx, cfg);
32
- listDiskCachePath = home ? path.join(home, ...LIST_CACHE_SUBDIR, "list-cache.json") : "";
33
- return listDiskCachePath;
34
- }
35
-
36
- /** 读落盘缓存(进程内只读一次)。任何异常都退化成空缓存,不向上抛。 */
25
+ /** 读缓存(进程内只读一次)。任何异常都退化成空缓存,不向上抛。 */
37
26
  export function loadListDiskCache(ctx) {
38
27
  if (listDiskCache !== null) return listDiskCache;
39
28
  listDiskCache = { version: LIST_CACHE_VERSION, entries: {} };
40
- const file = listCacheFilePath(ctx);
41
- if (!file) return listDiskCache;
42
29
  try {
43
- const parsed = JSON.parse(readFileSync(file, "utf8"));
44
- if (parsed?.version === LIST_CACHE_VERSION && parsed.entries !== null && typeof parsed.entries === "object") {
45
- listDiskCache = { version: LIST_CACHE_VERSION, entries: parsed.entries };
30
+ const section = getSection(ctx, "list");
31
+ if (section?.entries && typeof section.entries === "object") {
32
+ listDiskCache = { version: LIST_CACHE_VERSION, entries: section.entries };
46
33
  }
47
34
  } catch {
48
- // 首次运行(文件不存在)/ 文件损坏 / JSON 非法:按空缓存继续,本次解析完会覆盖写入
35
+ // 索引不可用:按空缓存继续,本次解析完会覆盖写入
49
36
  }
50
37
  return listDiskCache;
51
38
  }
@@ -67,25 +54,51 @@ export function scheduleSaveListDiskCache(ctx) {
67
54
  /** 立即原子落盘(写临时文件再 rename,避免下次启动读到半个文件)。 */
68
55
  export function saveListDiskCacheNow(ctx) {
69
56
  if (!listDiskCacheDirty || listDiskCache === null) return;
70
- const file = listCacheFilePath(ctx);
71
- if (!file) return;
72
57
  listDiskCacheDirty = false;
73
58
  try {
74
- mkdirSync(path.dirname(file), { recursive: true });
75
- const tmpPath = `${file}.tmp`;
76
- writeFileSync(tmpPath, JSON.stringify(listDiskCache), "utf8");
77
- renameSync(tmpPath, file);
59
+ setSection(ctx, "list", { entries: listDiskCache.entries });
60
+ saveStoreNow(ctx);
78
61
  } catch (error) {
79
62
  // 落盘失败只是让下次重新解析,不影响列表功能;记录下来便于诊断
80
63
  log(ctx, `会话列表缓存落盘失败(不影响功能): ${String(error?.message ?? error)}`);
81
64
  }
82
65
  }
83
66
 
67
+ /**
68
+ * 「这个会话的列表信息过期了」的统一入口:内存 TTL 缓存 + 落盘索引条目一起作废。
69
+ *
70
+ * 凡是改了会话**可见属性**(标题、归档态、状态后缀、内容)的写操作都必须调它一次 ——
71
+ * 只失效内存缓存不够:冷会话的标题/归档态来自落盘索引,revision 没变就不会重算,
72
+ * 于是表现为「后端改好了、面板还是旧的」。删除走 forgetSessionInListCache(条目直接删掉)。
73
+ */
74
+ export function markSessionListStale(ctx, sessionId) {
75
+ if (sessionId) forgetSessionInListCache(ctx, sessionId);
76
+ invalidateSessionListCache();
77
+ }
78
+
84
79
  /** 立即标记为脏(用于「清理完缓存条目后强制落盘」)。 */
85
80
  export function markListDiskCacheDirty() {
86
81
  listDiskCacheDirty = true;
87
82
  }
88
83
 
84
+ /**
85
+ * 忘掉某个会话的落盘缓存条目(删除/移走后必须调用)。
86
+ *
87
+ * 为什么必须有:面板现在**先读落盘索引**出列表(冷缓存时否则要等 41~226 秒),
88
+ * 删掉的会话若只从内存与磁盘上消失、索引里还留着,刷新页面就又被索引"复活"显示出来。
89
+ * 这里把条目删掉并立刻落盘,保证「删了就是删了」。
90
+ * @returns {boolean} true = 确实删掉了条目
91
+ */
92
+ export function forgetSessionInListCache(ctx, sessionId) {
93
+ if (!sessionId) return false;
94
+ const cache = loadListDiskCache(ctx);
95
+ if (!cache?.entries || !(sessionId in cache.entries)) return false;
96
+ delete cache.entries[sessionId];
97
+ listDiskCacheDirty = true;
98
+ saveListDiskCacheNow(ctx);
99
+ return true;
100
+ }
101
+
89
102
  /** 当前是否处于脏状态(构建段据此决定收尾是否落盘)。 */
90
103
  export function isListDiskCacheDirty() {
91
104
  return listDiskCacheDirty;
@@ -165,7 +178,6 @@ export function settleListBuild(promise, items) {
165
178
  /** 测试钩子:清空列表缓存状态(单测在场景之间调用)。 */
166
179
  export function resetListCacheForTest() {
167
180
  listDiskCache = null;
168
- listDiskCachePath = null;
169
181
  listDiskCacheDirty = false;
170
182
  if (listDiskCacheTimer !== null) clearTimeout(listDiskCacheTimer);
171
183
  listDiskCacheTimer = null;
@@ -221,6 +221,17 @@ export async function buildSessionList(ctx, opts = {}) { // dsh-skip-func-length
221
221
  }
222
222
  }
223
223
 
224
+ // 回收站里的会话不进列表:删除只是打标记,文件仍在磁盘上,只是对外隐藏。
225
+ try {
226
+ const { trashedIds } = await import("../features/trash.js");
227
+ const hidden = trashedIds(ctx);
228
+ if (hidden.size > 0) {
229
+ for (let i = items.length - 1; i >= 0; i -= 1) {
230
+ if (hidden.has(items[i].id)) items.splice(i, 1);
231
+ }
232
+ }
233
+ } catch { /* 回收站不可用不影响列表 */ }
234
+
224
235
  items.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
225
236
  return items;
226
237
  }