dsh-session-conductor 1.3.19 → 1.4.5

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,384 @@
1
+ // dsh-session-conductor — 从「会话导出包」恢复/覆盖会话
2
+ //
3
+ // 【为什么需要】DSH 的会话导出包是**明文 jsonl** 目录结构(`session.v4.jsonl` +
4
+ // `subagents/<id>/session.v4.jsonl` + `media/`),而运行时读的是
5
+ // `session.v4.jsonl.zstd` —— 带校验和的**多帧** zstd 容器,首帧必须恰好一行 header。
6
+ // 因此「把备份覆盖回去」不是简单拷贝:必须按官方容器格式重新编码,否则宿主会报
7
+ // 「会话文件 总和检查码不匹配」或 `corrupt session log`(本机两种都踩过)。
8
+ //
9
+ // 【三种坑与本模块的对应做法】
10
+ // ① 无校验和帧 ⇒ 宿主拒读:压缩时固定带 ZSTD_c_checksumFlag(复用 zstd-frames 的 CHECKSUM_OPTIONS)。
11
+ // ② 单帧塞进整份日志 ⇒ 读取端一次性解压整帧、内存尖峰:按 eventsPerFrame 分帧(宿主自己也是分批 append)。
12
+ // ③ 覆盖 seeded 会话时打乱 inherited 标记 ⇒ 宿主断言 "seeded session requires an inherited event count":
13
+ // 只覆盖日志文件本身,不动 projcache 与目录内其它文件;报告里回显 inherited 标记位置供核对。
14
+ //
15
+ // 【安全】默认 dryRun:只扫描并报告「会覆盖哪些会话、事件多少、帧多少」,不写盘。
16
+ // 真正写盘时:先校验 header.id 与目标一致、事件 seq 严格连续,再写 tmp 后 rename 原子替换;
17
+ // 写完重新逐帧解码复核事件数与末事件,复核不通过即记为错误。
18
+
19
+ import { readFileSync, writeFileSync, renameSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import zlib from 'node:zlib';
22
+ import { createHash } from 'node:crypto';
23
+ import { scanZstdFrames, validateHeaderFrame, decodeAllFrames, CHECKSUM_OPTIONS } from '../zstd-frames.js';
24
+ import { sessionsRootOf, findSessionLogById } from './events.js';
25
+ import { log } from '../shared/log.js';
26
+
27
+ /** 每个 zstd 事件帧里装多少条事件(宿主自己也是分批 append,太大会让读取端内存尖峰)。 */
28
+ const DEFAULT_EVENTS_PER_FRAME = 1000;
29
+
30
+ /** 导出包里明文的固定文件名。 */
31
+ const PLAIN_NAME = 'session.v4.jsonl';
32
+
33
+ /**
34
+ * 把一个明文 jsonl 编码成 DSH 官方 zstd 多帧容器(首帧恰好一行 header,每帧带校验和)。
35
+ *
36
+ * @param {string} plainText 明文 jsonl 全文
37
+ * @param {{eventsPerFrame?: number}} [opts]
38
+ * @returns {{ok: true, buffer: Buffer, header: object, eventCount: number, frames: number, inheritedAt: number|null}
39
+ * | {ok: false, error: string}}
40
+ */
41
+ export function encodePlainToZstd(plainText, opts = {}) {
42
+ const eventsPerFrame = Math.max(1, opts.eventsPerFrame ?? DEFAULT_EVENTS_PER_FRAME);
43
+ const lines = String(plainText).split('\n').filter((l) => l.trim() !== '');
44
+ if (lines.length < 2) return { ok: false, error: '明文事件不足(至少需要 header + 1 条事件)' };
45
+
46
+ let header;
47
+ try {
48
+ header = JSON.parse(lines[0]);
49
+ } catch (error) {
50
+ return { ok: false, error: `首行不是合法 JSON:${String(error?.message ?? error)}` };
51
+ }
52
+ if (header?.type !== 'session' || !header?.id) return { ok: false, error: '首行不是 session header' };
53
+
54
+ // 事件必须 seq 严格连续:不连续的日志写出去宿主也会判 corrupt,这里直接拒绝
55
+ const events = [];
56
+ let prev = -1;
57
+ let gaps = 0;
58
+ let bad = 0;
59
+ let inheritedAt = null;
60
+ for (let i = 1; i < lines.length; i += 1) {
61
+ let e;
62
+ try {
63
+ e = JSON.parse(lines[i]);
64
+ } catch {
65
+ bad += 1;
66
+ continue;
67
+ }
68
+ if (typeof e?.seq === 'number') {
69
+ if (prev >= 0 && e.seq !== prev + 1) gaps += 1;
70
+ prev = e.seq;
71
+ }
72
+ if (e?.type === 'session/end-seed' && e?.data?.inherited === true) inheritedAt = e.seq;
73
+ events.push(e);
74
+ }
75
+ if (bad > 0) return { ok: false, error: `明文里有 ${bad} 行坏 JSON` };
76
+ if (gaps > 0) return { ok: false, error: `明文事件 seq 不连续(${gaps} 处),拒绝编码` };
77
+
78
+ const frames = [zlib.zstdCompressSync(Buffer.from(lines[0] + '\n', 'utf8'), CHECKSUM_OPTIONS)];
79
+ for (let i = 0; i < events.length; i += eventsPerFrame) {
80
+ const chunk = events.slice(i, i + eventsPerFrame).map((e) => JSON.stringify(e)).join('\n') + '\n';
81
+ frames.push(zlib.zstdCompressSync(Buffer.from(chunk, 'utf8'), CHECKSUM_OPTIONS));
82
+ }
83
+ return { ok: true, buffer: Buffer.concat(frames), header, eventCount: events.length, frames: frames.length, inheritedAt };
84
+ }
85
+
86
+ /**
87
+ * 扫描导出包目录,列出「里面有哪些会话、各自对应哪个目标目录」。
88
+ *
89
+ * 目标目录解析顺序:① 按会话 id 找现有会话目录(覆盖已有会话)② 找不到就用同族目录
90
+ * (导出包里父会话所在的项目目录)新建 —— 同族会话必然同 cwd,避免自己实现路径编码。
91
+ *
92
+ * @param {string} exportDir 解压后的导出包目录
93
+ * @param {{home?: string}} [opts]
94
+ * @returns {{entries: Array<object>, error?: string}}
95
+ */
96
+ export function scanExportDir(exportDir, opts = {}) {
97
+ if (!existsSync(exportDir)) return { entries: [], error: `导出目录不存在:${exportDir}` };
98
+ // 注意:sessionsRootOf() 不接受参数(它只读 process.env.DSH_HOME),
99
+ // 所以要支持自定义 home 必须自己拼 <home>/sessions,不能把 home 当参数传进去。
100
+ const root = opts.home ? join(opts.home, 'sessions') : sessionsRootOf();
101
+ const entries = [];
102
+
103
+ const readHeaderOf = (plainPath) => {
104
+ try {
105
+ const firstLine = readFileSync(plainPath, 'utf8').split('\n', 1)[0];
106
+ return JSON.parse(firstLine);
107
+ } catch {
108
+ return null;
109
+ }
110
+ };
111
+
112
+ // 父会话(导出包根下的 session.v4.jsonl)
113
+ const parentPlain = join(exportDir, PLAIN_NAME);
114
+ let familyDir = null;
115
+ if (existsSync(parentPlain)) {
116
+ const header = readHeaderOf(parentPlain);
117
+ if (header?.id) {
118
+ const existing = findSessionLogById(header.id, root);
119
+ const targetDir = existing ? join(existing, '..') : null;
120
+ if (targetDir) familyDir = join(targetDir, '..');
121
+ entries.push({
122
+ role: 'parent',
123
+ id: header.id,
124
+ plainPath: parentPlain,
125
+ targetDir,
126
+ action: targetDir ? 'overwrite' : 'skip',
127
+ reason: targetDir ? '' : '找不到该会话的现有目录',
128
+ });
129
+ }
130
+ }
131
+
132
+ // 子代理(subagents/<id>/session.v4.jsonl)
133
+ const subsDir = join(exportDir, 'subagents');
134
+ if (existsSync(subsDir)) {
135
+ for (const name of readdirSync(subsDir)) {
136
+ const plainPath = join(subsDir, name, PLAIN_NAME);
137
+ if (!existsSync(plainPath)) continue;
138
+ const header = readHeaderOf(plainPath);
139
+ const id = header?.id;
140
+ if (!id) {
141
+ entries.push({ role: 'subagent', id: name, plainPath, targetDir: null, action: 'skip', reason: '明文 header 无法解析' });
142
+ continue;
143
+ }
144
+ const existing = findSessionLogById(id, root);
145
+ if (existing) {
146
+ entries.push({ role: 'subagent', id, plainPath, targetDir: join(existing, '..'), action: 'overwrite' });
147
+ } else if (familyDir) {
148
+ entries.push({ role: 'subagent', id, plainPath, targetDir: join(familyDir, id), action: 'create' });
149
+ } else {
150
+ entries.push({ role: 'subagent', id, plainPath, targetDir: null, action: 'skip', reason: '既无现有目录、又无同族目录可落' });
151
+ }
152
+ if (id !== name) {
153
+ const last = entries[entries.length - 1];
154
+ last.reason = `${last.reason ? last.reason + ';' : ''}目录名(${name})与 header.id 不一致`;
155
+ }
156
+ }
157
+ }
158
+ return { entries };
159
+ }
160
+
161
+ /**
162
+ * 从导出包的 `media/` 恢复附件对象到内容寻址对象库。
163
+ *
164
+ * 【为什么必须做】会话消息里引用的是 `sha256:<64hex>` 附件对象,宿主按
165
+ * `<DSH_HOME>/attachments/v1/objects/<前两位>/<64hex>` 取文件。只覆盖会话日志而不恢复
166
+ * 附件,会话就会在下一轮报「Attachment object is missing」(本机实测:恢复 4 个会话后
167
+ * 168 个引用里缺 27 个)。
168
+ *
169
+ * 【安全】导出包里的文件名形如 `sha256:<64hex>.<ext>`,这里**逐个重算 sha256 与文件名比对**,
170
+ * 不一致直接拒绝(不把坏文件写进对象库);已存在的对象跳过(内容寻址天然幂等)。
171
+ *
172
+ * @param {object} o
173
+ * @param {string} o.exportDir 解压后的导出包目录
174
+ * @param {string} o.home DSH_HOME
175
+ * @param {boolean} [o.dryRun] true 只统计不写盘(默认 true)
176
+ * @returns {{placed:number, existed:number, rejected:number, dryRun:boolean, errors:string[]}}
177
+ */
178
+ export async function restoreExportMedia(o) {
179
+ const dryRun = o?.dryRun !== false;
180
+ const mediaDir = join(o?.exportDir ?? '', 'media');
181
+ const out = { placed: 0, existed: 0, rejected: 0, dryRun, errors: [] };
182
+ if (!existsSync(mediaDir)) return out;
183
+ const objRoot = join(o?.home ?? '', 'attachments', 'v1', 'objects');
184
+
185
+ for (const name of readdirSync(mediaDir)) {
186
+ const m = /^sha256:([0-9a-f]{64})(?:\.[A-Za-z0-9]+)?$/.exec(name);
187
+ if (!m) {
188
+ out.rejected += 1;
189
+ out.errors.push(`跳过(命名不合规范):${name}`);
190
+ continue;
191
+ }
192
+ const hash = m[1];
193
+ const target = join(objRoot, hash.slice(0, 2), hash);
194
+ if (existsSync(target)) {
195
+ out.existed += 1;
196
+ continue;
197
+ }
198
+ let buf;
199
+ try {
200
+ buf = readFileSync(join(mediaDir, name));
201
+ } catch (e) {
202
+ out.rejected += 1;
203
+ out.errors.push(`${name}:读取失败 ${String(e?.message ?? e)}`);
204
+ continue;
205
+ }
206
+ // 内容自证:重算 sha256 必须等于文件名里的 hash
207
+ const actual = createHash('sha256').update(buf).digest('hex');
208
+ if (actual !== hash) {
209
+ out.rejected += 1;
210
+ out.errors.push(`${name}:内容校验失败(实际 sha256=${actual.slice(0, 12)}…),拒绝写入`);
211
+ continue;
212
+ }
213
+ if (dryRun) {
214
+ out.placed += 1;
215
+ continue;
216
+ }
217
+ try {
218
+ mkdirSync(join(objRoot, hash.slice(0, 2)), { recursive: true, mode: 0o700 });
219
+ writeFileSync(target, buf, { mode: 0o600 });
220
+ out.placed += 1;
221
+ } catch (e) {
222
+ out.rejected += 1;
223
+ out.errors.push(`${name}:写入失败 ${String(e?.message ?? e)}`);
224
+ }
225
+ }
226
+ return out;
227
+ }
228
+
229
+ /**
230
+ * 恢复后让「新日志」真正生效:清投影缓存 + 尝试把 live 会话卸载回冷状态。
231
+ *
232
+ * 【为什么必须做】DSH 有**三层状态**:会话日志(磁盘)/ projcache(投影缓存)/ 进程内存里的 agent。
233
+ * 只覆盖磁盘日志时,界面仍显示旧内容——本机实测:日志 11:20 已覆盖,projcache 还停在 11:06,
234
+ * 打开会话看到的就是替换前的内容。因此恢复后必须:
235
+ * ① 删掉该会话的 projcache 条目(否则冷读仍会拿旧投影);
236
+ * ② 若会话还是 live 的(内存里有 agent),把它 detach 回冷状态,下次打开才会重新从磁盘读。
237
+ *
238
+ * detach 需要插件 ctx(要 agents/sessions 服务);没有 ctx 时只清缓存并如实标注,
239
+ * 由调用方提示「需重载/重启宿主」。
240
+ *
241
+ * @param {object} o
242
+ * @param {string} o.home DSH_HOME
243
+ * @param {string} o.sessionId 会话 id
244
+ * @param {object} [o.ctx] 插件 ctx(有则尝试 detach)
245
+ * @returns {Promise<{projcacheCleared: boolean, detached: boolean, note?: string}>}
246
+ */
247
+ async function invalidateAfterRestore({ home, sessionId, ctx }) {
248
+ const out = { projcacheCleared: false, detached: false };
249
+ // ① 清投影缓存
250
+ try {
251
+ const cacheFile = join(home, 'storages', 'session_projcache', 'sessions', `${sessionId}.json`);
252
+ if (existsSync(cacheFile)) {
253
+ unlinkSync(cacheFile);
254
+ out.projcacheCleared = true;
255
+ }
256
+ } catch (error) {
257
+ out.note = `清投影缓存失败:${String(error?.message ?? error)}`;
258
+ }
259
+ // ② 尝试 detach(让内存态丢弃;非 live 幂等返回 ok)
260
+ if (ctx) {
261
+ try {
262
+ const { detachSessionAgent } = await import('./detach.js');
263
+ const r = await detachSessionAgent(ctx, sessionId);
264
+ out.detached = r?.ok === true;
265
+ if (!r?.ok && r?.error?.message && !out.note) out.note = `detach 未执行:${r.error.message}`;
266
+ } catch (error) {
267
+ if (!out.note) out.note = `detach 失败:${String(error?.message ?? error)}`;
268
+ }
269
+ } else if (!out.note) {
270
+ out.note = '未传 ctx,未尝试 detach(若会话仍打开着,需重载/重启宿主才能看到新内容)';
271
+ }
272
+ return out;
273
+ }
274
+
275
+ /**
276
+ * 按导出包恢复/覆盖会话。
277
+ *
278
+ * @param {object} o
279
+ * @param {string} o.exportDir 解压后的导出包目录
280
+ * @param {string} [o.home] DSH_HOME(缺省按宿主约定推导)
281
+ * @param {string[]} [o.sessionIds] 只处理这些会话(缺省全部)
282
+ * @param {boolean} [o.dryRun] true 只扫描报告(默认 true)
283
+ * @param {number} [o.eventsPerFrame]
284
+ * @param {object} [o.ctx] 可选:传插件 ctx 时顺带写日志,并尝试 detach(把 live 会话卸载回冷状态)
285
+ * @param {boolean} [o.invalidate] 默认 true:恢复后清该会话 projcache,避免「覆盖了但界面还是旧内容」
286
+ * @param {boolean} [o.media] 默认 true:同时把导出包 media/ 里的附件恢复到对象库
287
+ * (只覆盖日志不恢复附件,会话下一轮会报 Attachment object is missing)
288
+ * @returns {Promise<{ok:boolean, dryRun:boolean, results:Array<object>, errors:Array<string>}>}
289
+ */
290
+ export async function restoreSessionsFromExport(o) {
291
+ const dryRun = o?.dryRun !== false;
292
+ const { entries, error } = scanExportDir(o?.exportDir, { home: o?.home });
293
+ if (error) return { ok: false, dryRun, results: [], errors: [error] };
294
+ const only = Array.isArray(o?.sessionIds) && o.sessionIds.length > 0 ? new Set(o.sessionIds) : null;
295
+ const results = [];
296
+ const errors = [];
297
+
298
+ for (const entry of entries) {
299
+ if (only && !only.has(entry.id)) continue;
300
+ if (entry.action === 'skip') {
301
+ results.push({ ...entry, status: 'skipped' });
302
+ continue;
303
+ }
304
+ let plain;
305
+ try {
306
+ plain = readFileSync(entry.plainPath, 'utf8');
307
+ } catch (e) {
308
+ errors.push(`${entry.id}:读明文失败 ${String(e?.message ?? e)}`);
309
+ results.push({ ...entry, status: 'error' });
310
+ continue;
311
+ }
312
+ const encoded = encodePlainToZstd(plain, { eventsPerFrame: o?.eventsPerFrame });
313
+ if (!encoded.ok) {
314
+ errors.push(`${entry.id}:${encoded.error}`);
315
+ results.push({ ...entry, status: 'error' });
316
+ continue;
317
+ }
318
+ if (encoded.header.id !== entry.id) {
319
+ errors.push(`${entry.id}:明文 header.id=${encoded.header.id} 与目录不一致`);
320
+ results.push({ ...entry, status: 'error' });
321
+ continue;
322
+ }
323
+ const summary = {
324
+ ...entry,
325
+ status: dryRun ? 'would-restore' : 'restored',
326
+ eventCount: encoded.eventCount,
327
+ frames: encoded.frames,
328
+ inheritedAt: encoded.inheritedAt,
329
+ bytes: encoded.buffer.length,
330
+ };
331
+ if (dryRun) {
332
+ results.push(summary);
333
+ continue;
334
+ }
335
+ // 真正写盘:目录缺则建,写 tmp 后 rename 原子替换,再逐帧复核
336
+ try {
337
+ if (!existsSync(entry.targetDir)) {
338
+ mkdirSync(entry.targetDir, { recursive: true, mode: 0o700 });
339
+ summary.created = true;
340
+ }
341
+ const target = join(entry.targetDir, 'session.v4.jsonl.zstd');
342
+ const tmp = join(entry.targetDir, '.session.restore.tmp');
343
+ writeFileSync(tmp, encoded.buffer, { mode: 0o600 });
344
+ renameSync(tmp, target);
345
+ const after = readFileSync(target);
346
+ const headerCheck = await validateHeaderFrame(after);
347
+ const { frames } = scanZstdFrames(after);
348
+ const checksums = frames.map((fr) => (after.readUInt8(fr.start + 4) & 0x04) !== 0);
349
+ const backLines = (await decodeAllFrames(after)).split('\n').filter((l) => l.trim() !== '');
350
+ const okVerify = headerCheck.ok && checksums.every(Boolean) && backLines.length - 1 === encoded.eventCount;
351
+ summary.verified = okVerify;
352
+ summary.sizeMB = Math.round((statSync(target).size / 1048576) * 10) / 10;
353
+ if (!okVerify) {
354
+ errors.push(
355
+ `${entry.id}:写后复核未通过(首帧 ${headerCheck.ok}、校验和 ${checksums.every(Boolean)}、事件 ${backLines.length - 1}/${encoded.eventCount})`,
356
+ );
357
+ } else if (o?.invalidate !== false) {
358
+ // 复核通过才失效:顺序很重要——先确保磁盘是对的,再丢缓存/卸载
359
+ const inv = await invalidateAfterRestore({ home: o?.home ?? '', sessionId: entry.id, ctx: o?.ctx });
360
+ summary.projcacheCleared = inv.projcacheCleared;
361
+ summary.detached = inv.detached;
362
+ if (inv.note) summary.note = inv.note;
363
+ }
364
+ results.push(summary);
365
+ } catch (e) {
366
+ errors.push(`${entry.id}:写盘失败 ${String(e?.message ?? e)}`);
367
+ results.push({ ...summary, status: 'error' });
368
+ }
369
+ }
370
+ // 附件:只恢复会话日志、不恢复 media 会让会话下一轮报 Attachment object is missing
371
+ let media = null;
372
+ if (o?.media !== false) {
373
+ try {
374
+ media = await restoreExportMedia({ exportDir: o?.exportDir, home: o?.home ?? '', dryRun });
375
+ if (media.rejected > 0) errors.push(...media.errors.slice(0, 5));
376
+ } catch (e) {
377
+ errors.push(`恢复附件失败:${String(e?.message ?? e)}`);
378
+ }
379
+ }
380
+ if (o?.ctx) {
381
+ log(o.ctx, `导出包恢复:${dryRun ? '预览' : '执行'} ${results.length} 项,附件 ${media ? `新增 ${media.placed}/已有 ${media.existed}/拒绝 ${media.rejected}` : '未处理'},错误 ${errors.length} 条`);
382
+ }
383
+ return { ok: errors.length === 0, dryRun, results, errors, media };
384
+ }
@@ -16,6 +16,8 @@ import { readFile } from "node:fs/promises";
16
16
  import { findSessionLogById, sessionsRootOf } from "./events.js";
17
17
  // 流式逐事件读取的公共底座(与记忆取回共用;本模块只负责「按 turn/step 怎么组织」)
18
18
  import { streamSessionEvents } from "./log-stream.js";
19
+ // 目标解析的公共底座(id / 前缀 / 标题 / 成员名;与记忆取回共用)
20
+ import { resolveTargetSession } from "./resolve.js";
19
21
 
20
22
  /** 单条文本默认截断长度(summary 模式下更短,见 SUMMARY_CLIP)。 */
21
23
  const DEFAULT_MAX_ITEM = 800;
@@ -292,9 +294,10 @@ export function registerMemberTraceTool(ctx, defineTool, tools, deps = {}) {
292
294
  const selfId = exec?.agent?.session?.id;
293
295
  if (!selfId) return "拿不到当前会话 id,无法读取轨迹。";
294
296
 
295
- // 目标解析:与记忆取回共用同一套(成员名 → id / 前缀 / 标题;命中多个给候选,不猜)
297
+ // 目标解析走公共底座(sessions/resolve.js):id / id 前缀 / 标题 / 成员名,命中多个给候选不猜。
298
+ // 两个工具(本工具与记忆取回)共用同一套,避免各自一套解析各缺一块。
296
299
  const want = typeof args?.session === "string" ? args.session.trim() : "";
297
- const resolveFn = resolve ?? (await import("../features/memory-recall/index.js")).resolveSessionTarget;
300
+ const resolveFn = resolve ?? resolveTargetSession;
298
301
  const rounds = Math.min(
299
302
  Number.isSafeInteger(args?.rounds) && args.rounds > 0 ? args.rounds : 1,
300
303
  maxRounds,
@@ -308,12 +311,14 @@ export function registerMemberTraceTool(ctx, defineTool, tools, deps = {}) {
308
311
 
309
312
  let targetId = selfId;
310
313
  if (want && want !== selfId) {
311
- const hit = await resolveFn(ctx, want, selfId);
314
+ const hit = await resolveFn(ctx, want, selfId, { agent: exec?.agent });
312
315
  if (hit?.candidates) {
313
316
  return `「${want}」匹配到多个目标,请用更完整的 id 重试:\n`
314
317
  + hit.candidates.slice(0, 10).map((c) => `- ${c.id}${c.title ? ` | ${c.title}` : ""}`).join("\n");
315
318
  }
316
- if (!hit?.id) return `找不到目标会话「${want}」:没有匹配的成员名 / 会话 id / id 前缀 / 标题。`;
319
+ if (!hit?.id) {
320
+ return `找不到目标会话「${want}」:没有匹配的成员名 / 会话 id / id 前缀 / 标题。`;
321
+ }
317
322
  targetId = hit.id;
318
323
  }
319
324
 
@@ -0,0 +1,120 @@
1
+ // dsh-session-conductor — 会话目标解析(公共底座)
2
+ //
3
+ // 【为什么抽出来】「用户/AI 给一个模糊的目标(id / id 前缀 / 标题 / 成员名),解析成确切 sessionId」
4
+ // 这件事原先只写在记忆取回里(`resolveSessionTarget`),成员轨迹又自己接了一套,两边各有缺口。
5
+ //
6
+ // 【原先的缺陷】旧实现判断「这是不是一个会话目录」用的是 `dir.startsWith("session-")`。
7
+ // 主会话的目录名确实是 `session-<uuid>` 形式,**但子代理/成员会话的目录名是裸 UUID**
8
+ // (例如 `c9a6a72c-908e-4040-b88c-2e6ce5014378`)。于是成员会话永远进不了候选集,
9
+ // 完整 id 和 id 前缀都解析不出来,工具只好报「找不到目标会话」。
10
+ //
11
+ // 【现在的判据】不看目录名叫什么,只看**里面有没有会话日志文件**(`findSessionLog`)——
12
+ // 这是宿主自己也用的判据,且与目录命名规则解耦(v0/v3/v4 各格式都能识别)。
13
+ //
14
+ // 【为什么异步】会话目录可能上千个;同步 stat/readdir 会把事件循环钉住,拖慢整个宿主。
15
+ import { readdir, stat } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ import { findSessionLog } from "../session-log.js";
18
+ import { sessionsRootOf } from "./events.js";
19
+
20
+ /**
21
+ * 列出全部会话 id(按「目录里有会话日志」判断,不看目录名前缀)。
22
+ *
23
+ * @param {string} root 会话根目录
24
+ * @returns {Promise<string[]>}
25
+ */
26
+ async function listSessionIds(root) {
27
+ const ids = [];
28
+ const isDir = async (p) => {
29
+ try { return (await stat(p)).isDirectory(); } catch { return false; }
30
+ };
31
+ if (!root || !(await isDir(root))) return ids;
32
+ for (const group of await readdir(root)) {
33
+ const groupPath = join(root, group);
34
+ if (!(await isDir(groupPath))) continue;
35
+ for (const dir of await readdir(groupPath)) {
36
+ const dirPath = join(groupPath, dir);
37
+ if (!(await isDir(dirPath))) continue;
38
+ // 关键:用「有没有日志文件」而不是「目录名像不像 session-xxx」
39
+ if (findSessionLog(dirPath)) ids.push(dir);
40
+ }
41
+ }
42
+ return ids;
43
+ }
44
+
45
+ /**
46
+ * 把一个模糊目标解析成会话 id。支持:完整 id → id 前缀 → 标题子串。
47
+ *
48
+ * 多个命中时**不猜**,返回候选清单交给调用方展示。
49
+ *
50
+ * @param {object} ctx 插件 context(用于落盘索引里的标题)
51
+ * @param {string} want 用户给的目标
52
+ * @param {string} [selfId] 当前会话 id(等于它时直接返回)
53
+ * @param {{root?: string}} [opts]
54
+ * @returns {Promise<{id: string, title?: string|null} | {candidates: Array<{id: string, title?: string|null}>} | null>}
55
+ * null = 三段都没命中
56
+ */
57
+ export async function resolveSessionId(ctx, want, selfId, opts = {}) {
58
+ const raw = String(want ?? "").trim();
59
+ if (!raw) return null;
60
+ if (selfId && raw === selfId) return { id: selfId };
61
+
62
+ const root = opts.root ?? sessionsRootOf();
63
+ const ids = await listSessionIds(root);
64
+
65
+ if (ids.includes(raw)) return { id: raw };
66
+ const byPrefix = ids.filter((id) => id.startsWith(raw));
67
+ if (byPrefix.length === 1) return { id: byPrefix[0] };
68
+ if (byPrefix.length > 1) return { candidates: byPrefix.map((id) => ({ id })) };
69
+
70
+ // 标题子串:只查落盘索引(避免为了解析一个参数去逐份读日志)
71
+ try {
72
+ const { loadListDiskCache } = await import("./list-cache.js");
73
+ let entries = {};
74
+ try { entries = loadListDiskCache(ctx)?.entries ?? {}; } catch { entries = {}; }
75
+ const lower = raw.toLowerCase();
76
+ const byTitle = Object.entries(entries)
77
+ .filter(([, e]) => String(e?.title ?? "").toLowerCase().includes(lower))
78
+ .map(([id, e]) => ({ id, title: e?.title ?? null }));
79
+ if (byTitle.length === 1) return byTitle[0];
80
+ if (byTitle.length > 1) return { candidates: byTitle };
81
+ } catch {
82
+ // 索引不可用:退化为只按 id 解析
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * 解析目标会话,**额外支持 Team 成员名**。
89
+ *
90
+ * 成员名是日常最顺手的写法(`read_member_trace({ session: "writer" })`),但成员名既不是会话 id
91
+ * 也不是会话标题,通用解析认不了。这里在通用解析未命中时补一层成员解析。
92
+ *
93
+ * 用动态 `import()` 引成员定位:`sessions/` 是本插件底层,`features/member-model/` 是上层,
94
+ * 静态引用会形成层级倒置;且宿主没装 agent-team 时成员解析本就不可用,动态导入让那种情况自然降级。
95
+ *
96
+ * @param {object} ctx 插件 context
97
+ * @param {string} want 用户给的目标
98
+ * @param {string} [selfId] 当前会话 id
99
+ * @param {{root?: string, agent?: object}} [opts] agent 用于成员名册的作用域(缺省按全局名册)
100
+ * @returns {Promise<{id: string, title?: string|null, matched?: string} | {candidates: Array<{id: string, title?: string|null}>} | null>}
101
+ */
102
+ export async function resolveTargetSession(ctx, want, selfId, opts = {}) {
103
+ const direct = await resolveSessionId(ctx, want, selfId, opts);
104
+ if (direct) return direct;
105
+
106
+ try {
107
+ const { findTargetAgent } = await import("../features/member-model/locate.js");
108
+ const found = findTargetAgent(ctx, want, { caller: opts.agent ?? null });
109
+ if (found?.sessionId) return { id: found.sessionId, matched: found.matched ?? "成员名" };
110
+ if (found?.error && /匹配到 \d+ 个/.test(String(found.error))) {
111
+ // 成员名模糊命中多个:把它转成候选清单,保持「不猜」的一致行为
112
+ const ids = [...String(found.error).matchAll(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/g)]
113
+ .map((m) => ({ id: m[1] }));
114
+ if (ids.length > 1) return { candidates: ids };
115
+ }
116
+ } catch {
117
+ // 成员解析不可用:按未命中处理,由调用方给出统一报错
118
+ }
119
+ return null;
120
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.3.19",
3
+ "version": "1.4.5",
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",