dsh-session-conductor 1.1.1 → 1.2.0

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
@@ -23,7 +23,7 @@
23
23
  | 自动重命名模型 | 指定便宜/专用模型做标题分析 | 设置 → 会话管理 |
24
24
  | 成员模型切换 | 给队友/子代理单独切模型(只动该成员会话,不影响其他成员):先用 `list_models` 拿确切模型 id,再 `set_member_model` 按成员名/sessionId 切换(写 `model/selection` 事件交给官方投影接管,跨轮持久;未挂载成员也能切,运行中会先等它暂停) | AI 工具 `list_models` / `set_member_model` |
25
25
  | 上下文用量自查 | AI 按需查自己会话的上下文占用(百分比、已用/容量 tokens)与构成(系统提示词/工具定义/对话消息),占用达 85% 时附收敛建议。数据与界面圆环同源(官方 `contextPressure` / `contextBreakdown` 投影),只读、无参数。**另:每轮把实时用量注入消息尾部**(AI 每轮都能直接看到数字;注入在尾部而非系统提示词前缀 ⇒ 前缀稳定、prompt cache 仍命中;按 turn 门控,一回合只注一次;配置 `injectContextUsage` 可关) | AI 工具 `context_usage` + 每轮注入 |
26
- | 跨会话消息 | AI 直接给另一个会话发消息(不经过人转达):先 `list_sessions` 挑目标,再 `send_to_session` 投递;目标空闲时**由本体自动唤醒**它跑一个回合,运行中则排到其下一回合边界(`mode:"steer"` 改为注入当前回合)。全部走本体官方 `prompt` API(幂等 `requestId`、不自己写会话日志)。**支持 `dryRun` 预览**(报告目标状态与限流余量,不投递、不消耗配额)。硬约束:禁自投、协作链跳数 ≥3 拒绝、同一对会话 60s 内最多 5 条 | AI 工具 `list_sessions` / `send_to_session` |
26
+ | 跨会话消息 | AI 直接给另一个会话发消息(不经过人转达):先 `list_sessions` 挑目标,再 `send_to_session` 投递;目标空闲时**由本体自动唤醒**它跑一个回合,运行中则排到其下一回合边界(`mode:"steer"` 改为注入当前回合)。全部走本体官方 `prompt` API(幂等 `requestId`、不自己写会话日志)。**支持 `dryRun` 预览**(报告目标状态与限流余量,不投递、不消耗配额)。`list_sessions` 支持 **`query` 子串过滤 + `limit`/`offset` 分页**(默认只给 20 条,避免整表塞进调用方上下文)。硬约束:禁自投、协作链跳数 ≥3 拒绝、同一对会话 60s 内最多 5 条 | AI 工具 `list_sessions` / `send_to_session` |
27
27
  | 指定模型新建会话 | AI 新建会话并可**直接指定 provider/model**(建完即切到该模型),可选带一条初始消息让它立刻开始干活。**缺省工作目录 = 调用方会话自己的 cwd**(不落在 DSH 安装目录里)。本体 `create` 本身不带模型,所以是 `create` → `selectModel` 两步;任一步失败会如实回执(如「会话已建,但模型未设置」),不谎报成功。**支持 `dryRun` 预览**(含模型是否存在于目录的核对),不建会话 | AI 工具 `create_session` |
28
28
 
29
29
  ## 界面预览
@@ -23,4 +23,6 @@ export {
23
23
  TEXT_LIMIT,
24
24
  HOPS_LIMIT,
25
25
  RATE_MAX,
26
+ LIST_DEFAULT_LIMIT,
27
+ LIST_MAX_LIMIT,
26
28
  } from "./logic.js";
@@ -23,6 +23,11 @@ export const RATE_MAX = 5;
23
23
  /** 本体 prompt 受理是本地操作,给足超时但别无限等。 */
24
24
  export const PROMPT_TIMEOUT_MS = 60_000;
25
25
 
26
+ // list_sessions 的分页默认值:会话多时整表输出会白烧调用方上下文,
27
+ // 所以默认只给一页(20 条),需要更多用 offset 翻页、或先用 query 收窄。
28
+ export const LIST_DEFAULT_LIMIT = 20;
29
+ export const LIST_MAX_LIMIT = 100;
30
+
26
31
  // ── 纯函数 ────────────────────────────────────────────────────────────────
27
32
 
28
33
  /** 会话条目 → 状态词(running=正在跑回合 / idle=已挂载空闲 / inactive=冷会话)。 */
@@ -48,7 +53,19 @@ export function formatTime(value) {
48
53
  * @param selfId 当前会话 id(用于标出「当前会话」,可为 null)
49
54
  * @returns {string} 多行文本;空列表时返回固定提示
50
55
  */
51
- export function formatSessionList(items, selfId) {
56
+ /**
57
+ * 把会话列表格式化为工具输出(支持子串过滤 + 分页)。
58
+ *
59
+ * 【为什么要分页/过滤】会话多了以后整表输出会白烧 AI 上下文(本机实测 80+ 会话一次输出
60
+ * 上百行);调用方通常只需要「找目标」这一件事,因此默认由工具侧给一个小 limit,
61
+ * 需要更多再用 offset 翻页,或先用 query 收窄。
62
+ *
63
+ * @param {Array} items buildSessionListCached 的产出
64
+ * @param {string} selfId 当前会话 id(用于标注「← 当前会话」)
65
+ * @param {object} [opts] limit(本页条数;不传=全部,保持既有调用方语义)/ offset / query(子串,不区分大小写)
66
+ * @returns {string} 多行文本
67
+ */
68
+ export function formatSessionList(items, selfId, opts = {}) {
52
69
  const all = Array.isArray(items) ? items : [];
53
70
  // 防御:会话列表构建偶尔会混入缺 id 的脏条目(实测见过一条),直接渲染会输出
54
71
  // 「undefined | (无标题) …」误导调用方 —— 这里按 id 过滤,并如实报告跳过了几条。
@@ -57,15 +74,35 @@ export function formatSessionList(items, selfId) {
57
74
  if (usable.length === 0) {
58
75
  return skipped > 0 ? `没有可用会话(${skipped} 条缺 id 的脏数据已跳过)。` : "没有可见会话。";
59
76
  }
60
- const lines = usable.map((item) => {
77
+ // 子串过滤:id / 标题 / 工作区 任一命中即可(会话一多,先收窄再翻页比整表更省)
78
+ const rawQuery = typeof opts.query === "string" ? opts.query.trim() : "";
79
+ const matched = rawQuery
80
+ ? usable.filter((item) => `${item.id} ${item.title ?? ""} ${item.cwd ?? ""}`.toLowerCase().includes(rawQuery.toLowerCase()))
81
+ : usable;
82
+ if (matched.length === 0) {
83
+ return `没有匹配「${rawQuery}」的会话(共 ${usable.length} 个)。`;
84
+ }
85
+ // 分页:不传 limit = 全部(保持既有调用方语义);offset 越界时给空页提示而不是报错
86
+ const total = matched.length;
87
+ const limit = Number.isSafeInteger(opts.limit) && opts.limit > 0 ? opts.limit : total;
88
+ const offset = Number.isSafeInteger(opts.offset) && opts.offset > 0 ? opts.offset : 0;
89
+ const page = matched.slice(offset, offset + limit);
90
+ if (page.length === 0) {
91
+ return `offset ${offset} 超出范围(共 ${total} 个会话,本页 0 条)。`;
92
+ }
93
+ const lines = page.map((item) => {
61
94
  const flags = [statusOf(item)];
62
95
  if (item?.archived) flags.push("已归档");
63
96
  if (selfId && item?.id === selfId) flags.push("← 当前会话");
64
97
  return `- ${item.id} | ${String(item?.title ?? "(无标题)")} | ${String(item?.cwd ?? "(无工作区)")}`
65
98
  + ` | ${flags.join(" / ")} | 最后活动 ${formatTime(item?.updatedAt)}`;
66
99
  });
100
+ const scope = rawQuery ? `(匹配「${rawQuery}」)` : "";
101
+ const head = `共 ${total} 个会话${scope},本页 ${page.length} 条(offset ${offset}):`;
102
+ const remaining = total - offset - page.length;
103
+ const more = remaining > 0 ? `\n还有 ${remaining} 条,用 offset=${offset + page.length} 取下一页。` : "";
67
104
  const tail = skipped > 0 ? `\n(另有 ${skipped} 条缺 id 的脏数据已跳过)` : "";
68
- return `共 ${usable.length} 个会话:\n${lines.join("\n")}${tail}`;
105
+ return `${head}\n${lines.join("\n")}${more}${tail}`;
69
106
  }
70
107
 
71
108
  /** 把远端错误压成「短码: 原文」一行,便于回执如实说明失败原因。 */
@@ -9,7 +9,7 @@
9
9
  // ReferenceError ⇒ 同批工具全部注册不上(详见 lib/apply.js 的注释)。
10
10
 
11
11
  import {
12
- TEXT_LIMIT, HOPS_LIMIT, RATE_WINDOW_MS, RATE_MAX,
12
+ TEXT_LIMIT, HOPS_LIMIT, RATE_WINDOW_MS, RATE_MAX, LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT,
13
13
  currentSessionId, callerCwd, sessionControllerOf, shortError, mapPromptError,
14
14
  formatSessionList, listSessions, describeTarget, checkModelPair,
15
15
  peekRateLimit, hitRateLimit, isInFlight, markInFlight, clearInFlight,
@@ -31,16 +31,26 @@ function registerListSessions(ctx, defineTool, tools) {
31
31
  name: "list_sessions",
32
32
  description:
33
33
  "列出本机 DSH 的会话(id / 标题 / 工作区 / 状态 / 最后活动 / 是否当前会话),供 send_to_session 挑目标。"
34
- + "只读、无参数、无副作用。"
34
+ + "只读、无副作用。"
35
+ + `**默认只返回前 ${LIST_DEFAULT_LIMIT} 条**(会话多时整表输出会白烧调用方上下文):`
36
+ + "先用 query 按 id/标题/工作区子串收窄,再按回执提示的 offset 翻页。"
35
37
  + "状态含义:running = 正在跑回合;idle = 已挂载但空闲;inactive = 冷会话(未挂载,被投递时会被唤醒)。"
36
38
  + "用法:先调它拿目标 id,再把 id 原样传给 send_to_session —— 不要凭记忆或标题猜 id。"
37
39
  + "缺 id 的脏数据会被自动跳过并如实标注。",
38
- parameters: {},
40
+ parameters: {
41
+ query: { type: "string", description: "按 id / 标题 / 工作区子串过滤(不区分大小写),会话多时先用它收窄" },
42
+ limit: { type: "number", description: `本页最多返回多少条(默认 ${LIST_DEFAULT_LIMIT},上限 ${LIST_MAX_LIMIT})` },
43
+ offset: { type: "number", description: "跳过前多少条(默认 0;回执里会给出下一页该用的 offset)" },
44
+ },
39
45
  output: TEXT_OUTPUT,
40
- async execute(_args, exec) {
46
+ async execute(args, exec) {
41
47
  try {
42
48
  const items = await listSessions(ctx);
43
- return formatSessionList(items, currentSessionId(exec?.agent));
49
+ const limit = Number.isSafeInteger(args?.limit) && args.limit > 0
50
+ ? Math.min(args.limit, LIST_MAX_LIMIT)
51
+ : LIST_DEFAULT_LIMIT;
52
+ const offset = Number.isSafeInteger(args?.offset) && args.offset > 0 ? args.offset : 0;
53
+ return formatSessionList(items, currentSessionId(exec?.agent), { limit, offset, query: args?.query });
44
54
  } catch (error) {
45
55
  return `列出会话失败:${shortError(error)}`;
46
56
  }
@@ -3,8 +3,38 @@
3
3
  // 【为什么单独成文件】「拿到一个会话的事件流」是列表、续跑、重命名、搜索、撤回的共同前置:
4
4
  // live 会话走内存快照,冷会话走官方 persistence.open(id,"read") 读法。
5
5
  // 集中在此避免各域各写一份(历史上冷会话读法变更过多次:inspect 失效 → open/read)。
6
+ //
7
+ // 【已知限制·宿主 API 不能流式读】官方 `handle.read(offset, length)` 的 offset/length 只是
8
+ // **内存数组切片**:后端每次调用都会 `readStoredLog(path)` 把**整份日志读进来解压**
9
+ // (见 packages/session/session-persistence-jsonl/src/storage.ts 的 readCurrent)。因此:
10
+ // · 分块调用**不会**降低内存峰值,反而变成「N 次全量解压」(O(n²) 耗时)——**不要分块读**;
11
+ // · 想真正把单会话内存压到 KB 级,只能绕过 handle:直接按 zstd 帧解码日志文件
12
+ // (多帧格式每帧可独立解压,本仓库 lib/zstd-frames.js 已具备该能力)。
13
+ // 当前策略是「**读一次就别再读**」:靠列表磁盘缓存(revision 命中即跳过解析)把
14
+ // 重复解析降到 0,从而把 OOM 风险压掉。
15
+
16
+ // 宿主包**懒加载**:顶层 `import` 会让本文件在「工作区自测」环境直接解析失败
17
+ // (仓库里踩过同类坑),而 events.js 是最需要被单测覆盖的模块之一(冷会话读法)。
18
+ // 这里改成顶层 await import + 兜底:没有宿主依赖时退化为「不加闭合器」,其余逻辑照常可测。
19
+ let interruptedTurnClosers = null;
20
+ try {
21
+ const hostSession = await import("@deepseek-ai/dsh-session");
22
+ if (typeof hostSession?.interruptedTurnClosers === "function") {
23
+ interruptedTurnClosers = hostSession.interruptedTurnClosers;
24
+ }
25
+ } catch {
26
+ // 无宿主依赖(工作区自测):保持 null,调用点会跳过「补闭合器」这一步
27
+ }
6
28
 
7
- import { interruptedTurnClosers } from "@deepseek-ai/dsh-session";
29
+ /** 需要时补官方中断闭合器;宿主包不可用时原样返回(不阻断列表)。 */
30
+ function withInterruptedClosers(events) {
31
+ if (typeof interruptedTurnClosers !== "function" || events.length === 0) return events;
32
+ try {
33
+ return [...events, ...interruptedTurnClosers(events)];
34
+ } catch {
35
+ return events;
36
+ }
37
+ }
8
38
 
9
39
  /**
10
40
  * 该子会话是否归属 API remote(不该被插件当普通会话处理)。
@@ -50,12 +80,17 @@ export async function sessionEventsOf(ctx, sessionId) {
50
80
  }
51
81
 
52
82
  /**
53
- * 读一个冷(已持久化)会话的完整事件流(官方 0.1.6 标准读法):
54
- * persistence.open(id, "read") → handle.read(0) → close。
55
- * ⚠️ 0.1.6-alpha.1 起 persistence.inspect 已失效(返回空),冷会话标题/搜索内容
56
- * 全靠本函数;与 session-query 的 readColdSessionLog 同一姿势,并补中断闭合器。
83
+ * 读一个冷(已持久化)会话的完整事件流(官方标准读法):
84
+ * `persistence.open(id,"read")` → `handle.read(0)` → `close`,并补官方中断闭合器。
85
+ *
86
+ * ⚠️ **这是全量读**,也是宿主 API 目前唯一能给的读法(见文件头「宿主 API 不能流式读」):
87
+ * 整份日志会变成 events 数组驻留内存 —— 本机实测最大会话 49.2 MB 压缩 → 173.3 MB 解压
88
+ * (另一份 39.9 → 197.9 MB,膨胀 3.2~5.0×)。因此**必须避免重复解析**:
89
+ * 列表侧靠磁盘缓存(revision 命中即跳过);不要为了「省内存」改成多次 read
90
+ * (那只会变成 N 次全量解压,内存峰值不变、耗时变 O(n²))。
91
+ *
57
92
  * 读取失败**向上抛**(buildSessionList 用它带出 inspectError);仅「无 open 能力」返回 null。
58
- * @returns {{events:Array, meta:object|null}}
93
+ * @returns {Promise<{events:Array, meta:object|null}|null>}
59
94
  */
60
95
  export async function readColdSessionEvents(ctx, sessionId) {
61
96
  const persistence = ctx.get("sessionPersistence");
@@ -69,5 +104,5 @@ export async function readColdSessionEvents(ctx, sessionId) {
69
104
  try { await handle.close(); } catch { /* 读失败时 close 失败无妨 */ }
70
105
  }
71
106
  // 崩溃残留的开放回合补合成闭合器(与官方 cold-read 一致,列表中断判定需要)
72
- return { events: [...events, ...interruptedTurnClosers(events)], meta: handle?.header ?? null };
107
+ return { events: withInterruptedClosers(events), meta: handle?.header ?? null };
73
108
  }
@@ -17,6 +17,12 @@ import {
17
17
  peekListCache, peekListBuildInflight, registerListBuild, settleListBuild,
18
18
  markListDiskCacheDirty, isListDiskCacheDirty,
19
19
  } from "./list-cache.js";
20
+ import { isValidSessionId } from "./session-id.js";
21
+
22
+ /**
23
+ * 会话列表项 id 的唯一判据在 `./session-id.js`(零依赖叶子模块,便于单测直接覆盖)。
24
+ * 本文件四处使用它:归档集合净化 / 补全守卫 / persistence.list 回退校验 / emit 兜底。
25
+ */
20
26
 
21
27
  /**
22
28
  * 组装全部会话(live + 已持久化),按最后活动时间倒序。
@@ -30,9 +36,25 @@ export async function buildSessionList(ctx, opts = {}) { // dsh-skip-func-length
30
36
  const sessionTitle = ctx.get("sessionTitle");
31
37
  const storeState = await pluginState(ctx);
32
38
 
33
- const archived = new Set(workspaceRegistry?.archivedSessionIds ?? []);
39
+ // 归档 id 集合是「权威隐藏名单」,但它来自宿主持久化,可能混入空值/非字符串:
40
+ // 脏值会让下面的补全循环造出 `{ id: undefined }` 的 meta,最终产出一条没有 id 的列表项,
41
+ // 并在读该会话事件时抛 "Cannot read properties of undefined (reading 'length')"。
42
+ // 这里先净化,只保留非空字符串。
43
+ const archived = new Set(
44
+ (workspaceRegistry?.archivedSessionIds ?? []).filter(isValidSessionId),
45
+ );
34
46
  const items = [];
35
- const emit = (item) => { items.push(item); if (opts?.onItem) opts.onItem(item); };
47
+ // 列表项的唯一不变量:必须有非空字符串 id(前端与工具都按 id 索引/跳转)。
48
+ // 在 emit 这一个咽喉点兜底 —— 任何来源(活跃/冷会话/归档补全/缓存命中)产出的缺 id 项都进不来,
49
+ // 且留一行日志便于定位上游;比在每个产出点各写一遍更可靠。
50
+ const emit = (item) => {
51
+ if (!item || !isValidSessionId(item.id)) {
52
+ log(ctx, `会话列表项缺 id,已跳过:${JSON.stringify({ title: item?.title ?? null, live: item?.live ?? null })}`);
53
+ return;
54
+ }
55
+ items.push(item);
56
+ if (opts?.onItem) opts.onItem(item);
57
+ };
36
58
 
37
59
  // live 会话
38
60
  for (const session of sessions?.list() ?? []) {
@@ -65,17 +87,22 @@ export async function buildSessionList(ctx, opts = {}) { // dsh-skip-func-length
65
87
  try {
66
88
  // 优先 listSnapshots()——只读 header 行 + 一次 stat,**不解析日志**,
67
89
  // 顺带给出 revision(日志变更令牌),这是「懒加载」的判据。
68
- if (typeof persistence.listSnapshots === "function") {
69
- for (const snapshot of await persistence.listSnapshots()) {
70
- const header = snapshot?.header;
71
- if (header?.id && !liveIds.has(header.id)) {
72
- coldById.set(header.id, { meta: header, revision: snapshot.revision == null ? null : String(snapshot.revision) });
73
- }
74
- }
75
- } else {
76
- for (const meta of await persistence.list()) {
77
- if (!liveIds.has(meta.id)) coldById.set(meta.id, { meta, revision: null });
78
- }
90
+ // 宿主 `list()` 返回的是 **SessionPersistenceSnapshot**(`{ header, revision, eventCount?, sizeBytes? }`),
91
+ // 不是裸 header。早期写法先探测 `listSnapshots()`(宿主已无此方法,全仓搜不到),探测失败就退回
92
+ // 「把 list() 的返回值当 header 用、revision 置 null」——而 revision 为 null 的条目**永不进缓存**,
93
+ // 于是每次列表构建都要全量重解析所有日志(实测 list 接口 30s 不响应、进程 RSS 2.3GB)。
94
+ // 现在统一按快照解析:`entry.header ?? entry` 同时兼容新快照与旧裸 header 两种形态。
95
+ for (const entry of await persistence.list()) {
96
+ const header = entry?.header ?? entry;
97
+ // 缺 id 的条目不能进冷会话表(否则后续按 undefined 读日志,产出无 id 的列表项)
98
+ if (!isValidSessionId(header?.id) || liveIds.has(header.id)) continue;
99
+ const revision = entry?.revision;
100
+ coldById.set(header.id, {
101
+ meta: header,
102
+ revision: revision == null ? null : String(revision),
103
+ // 事件数:宿主能从元数据廉价给出时带上,供冷会话「只读尾部」直接算偏移(省掉全量扫描)
104
+ eventCount: typeof entry?.eventCount === "number" ? entry.eventCount : null,
105
+ });
79
106
  }
80
107
  } catch (error) {
81
108
  log(ctx, `sessionPersistence.list 失败(改走归档补全): ${String(error?.message ?? error)}`);
@@ -83,6 +110,8 @@ export async function buildSessionList(ctx, opts = {}) { // dsh-skip-func-length
83
110
  }
84
111
  // 归档集合是权威隐藏名单:即使 persistence.list 漏扫,也按 id inspect 补进列表。
85
112
  for (const id of archived) {
113
+ // 双保险:即使上游过滤失效,这里也不拿空 id 建 meta(meta.id 就是列表项 id 的来源)
114
+ if (!isValidSessionId(id)) continue;
86
115
  if (!liveIds.has(id) && !coldById.has(id)) coldById.set(id, { meta: { id }, revision: null });
87
116
  }
88
117
  if (coldById.size > 0) {
@@ -110,8 +139,10 @@ export async function buildSessionList(ctx, opts = {}) { // dsh-skip-func-length
110
139
  let inspectError = null;
111
140
  let inspected = entry.meta;
112
141
  try {
113
- // ⚠️ 0.1.6-alpha.1 起 persistence.inspect 已失效(返回空)→ 改用官方 handle 读法
114
- // (open('read') → read(0) → close),与 session-query 一致。
142
+ // ⚠️ 0.1.6-alpha.1 起 persistence.inspect 已失效(返回空)→ 改用官方 handle 读法。
143
+ // ⚠️ 这是**全量读**(宿主 API 的 read(offset,length) 只是内存切片,分块读不会省内存、
144
+ // 反而 N 次全量解压 ⇒ O(n²))。所以内存控制的关键是**别重复解析**:
145
+ // 上面的 revision 命中判定 + 下面的落盘缓存就是为此存在。
115
146
  const loaded = persistence?.open ? await readColdSessionEvents(ctx, entry.meta.id) : null;
116
147
  events = loaded?.events ?? [];
117
148
  if (loaded?.meta) inspected = loaded.meta;
@@ -0,0 +1,24 @@
1
+ // dsh-session-conductor — 会话 id 判据(零依赖叶子模块)
2
+ //
3
+ // 【为什么单独一个文件】这条判据是「会话列表项必须有 id」这条不变量的**唯一定义处**,
4
+ // 上游净化(归档集合)、补全守卫、回退校验(persistence.list)、emit 兜底四处共用。
5
+ // 放在 list.js 里会因依赖链(core/domain.js → 宿主包 @deepseek-ai/dsh-storage-domain)
6
+ // 而**无法被单测直接导入**;抽成零依赖叶子模块后,单测可功能级覆盖它本身,
7
+ // 再用结构断言钉住「list.js 的四处都走它」。
8
+ //
9
+ // 【背景】宿主持久化里出现过脏值:归档 id 集合混入 undefined 时,补全循环会造出
10
+ // `{ id: undefined }` 的 meta,最终产出一条没有 id 的列表项,读该会话事件时抛
11
+ // "Cannot read properties of undefined (reading 'length')"。
12
+
13
+ /**
14
+ * 该值能否作为会话 id:**非空字符串**。
15
+ *
16
+ * 注意只认字符串 —— 数字/对象/空串/null/undefined 一律不算(列表项 id 会被前端用作
17
+ * DOM key 与路由参数,也会被工具拿去查会话,任何非字符串都会在别处炸得更隐蔽)。
18
+ *
19
+ * @param {unknown} value 待判定的值
20
+ * @returns {boolean} 是否可作会话 id
21
+ */
22
+ export function isValidSessionId(value) {
23
+ return typeof value === "string" && value !== "";
24
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-session-conductor",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
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",
@@ -175,7 +175,7 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
175
175
 
176
176
  | 工具 | 用途 | 关键参数 |
177
177
  |---|---|---|
178
- | `list_sessions` | 列出本机会话(id / 标题 / 工作区 / 状态 / 最后活动 / 是否当前会话) | 无 |
178
+ | `list_sessions` | 列出本机会话(id / 标题 / 工作区 / 状态 / 最后活动 / 是否当前会话);支持 `query` 子串过滤 + `limit`/`offset` 分页(**默认只给 20 条**,回执会给出下一页的 offset) | `query` / `limit` / `offset` |
179
179
  | `send_to_session` | 给另一个会话投递消息(不经过人转达) | `sessionId`、`text`、`mode`(queue/steer)、`requestId`、`hops`、`dryRun` |
180
180
  | `create_session` | 新建会话并可指定模型,可选带初始消息 | `provider`+`model`、`cwd`/`workspaceId`、`sessionId`、`text`、`dryRun` |
181
181
 
@@ -188,6 +188,10 @@ whenToUse: 需要了解 dsh-session-conductor 某个功能怎么用/参数是什
188
188
  - **`create_session` 缺省工作目录 = 调用方会话自己的 cwd**(发起它的那个 AI 在哪干活,新会话就落在哪)。**不要**依赖本体默认:那是 DSH 安装目录(实测 `/volume1/@appstore/DeepSeekHarness-NAS`),新会话落在程序目录里对干活毫无意义。显式给 `cwd` 或 `workspaceId` 可覆盖(二者互斥)。
189
189
  - **dryRun(两个写操作工具都支持)**:只做校验与预览、**不产生任何副作用**。`create_session` 的 dryRun 还会**核对 provider/model 是否真在模型目录里**;`send_to_session` 的 dryRun 会**报告目标状态与限流余量**,且**不消耗限流配额**(可以先 dryRun 多次再真投)。dryRun 同样执行全部本地校验(自投/超长/跳数等照常被拒)。
190
190
  - **状态含义**(`list_sessions` 输出):`running` = 正在跑回合;`idle` = 已挂载但空闲;`inactive` = 冷会话(未挂载,投递时会被唤醒)。缺 id 的脏条目会被跳过并如实标注(不会渲染成 `undefined`)。
191
+ - **会话列表的内存边界(实测,别再踩)**:冷会话读日志走官方 `handle.read(0, undefined)`,**宿主 API 不能流式读** —— `read(offset, length)` 的 offset/length 只是**内存数组切片**,后端每次调用都会把整份日志读进来解压(`session-persistence-jsonl/src/storage.ts` 的 `readCurrent`)。实测单份日志 49.2 MB 压缩 → **173.3 MB 解压**(3.2~5.0×)。
192
+ - ⛔ **不要为了省内存改成分块读**:内存峰值不变(每次都全量物化),耗时变 O(n²)(曾导致 `/list` >120s 超时,已撤回)。
193
+ - ✅ 正确做法是**别重复解析**:靠列表磁盘缓存的 `revision` 命中跳过(1.2.0 修掉了「`list()` 返回快照却被当裸 header 用、`revision` 恒 null ⇒ 缓存永不生效」这个 bug)。
194
+ - 想把单会话内存压到 KB 级,只能绕过 handle:按 zstd 帧解码日志文件(多帧格式每帧可独立解压,`lib/zstd-frames.js` 已具备该能力)。
191
195
  - **投递语义**:默认 `mode:"queue"` —— 目标空闲时**由本体自动唤醒**它跑一个回合;目标运行中则排到其下一回合边界。`mode:"steer"` 改为注入目标**正在跑**的那个回合。
192
196
  - **实现口径(重要)**:全部走本体官方 `sessionController` 的 `@Remote` 方法 —— `create` / `selectModel` / `prompt`。**不自己写会话日志**:inbox 家族只有 `agent/inbox/spliced` 落盘,其 fold 对非法 splice(id 重复、start 越界)**直接抛错**,自己写等于拿对方会话的完整性冒险。`prompt` 的受理流程会先 resume 目标会话,唤醒语义由本体保证;`requestId` 是幂等键(本体持久化在那条被接受的消息上),重试传同键不会重复投递。
193
197
  - **`create_session` 为什么是两步**:本体 `SessionCreateRequest` 只有 `workspaceId? / cwd? / sessionId? / agentPreset?`,**没有 model 字段**,所以必须 `create` → `selectModel`。任一步失败都如实回执(如「⚠️ 会话已建,但模型未设置」),**不谎报成功**。