dsh-session-conductor 1.1.1 → 1.2.1
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 +20 -1
- package/lib/features/session-messaging/index.js +2 -0
- package/lib/features/session-messaging/logic.js +40 -3
- package/lib/features/session-messaging/tools.js +15 -5
- package/lib/features/session-ops.js +6 -3
- package/lib/group.js +12 -14
- package/lib/sessions/events.js +42 -7
- package/lib/sessions/list.js +46 -15
- package/lib/sessions/session-id.js +24 -0
- package/package.json +1 -1
- package/skills/dsh-session-conductor-functions.md +5 -1
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`
|
|
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
|
## 界面预览
|
|
@@ -36,6 +36,25 @@
|
|
|
36
36
|
|
|
37
37
|

|
|
38
38
|
|
|
39
|
+
## 环境要求(支持的 DSH 版本)
|
|
40
|
+
|
|
41
|
+
| 项 | 要求 |
|
|
42
|
+
|---|---|
|
|
43
|
+
| **DSH 版本** | **0.2.1 及以上(推荐)**;0.2.x 早期版本功能可用,但**列表缓存不生效**(见下) |
|
|
44
|
+
| Node | 与宿主 DSH 自带的 Node 一致(无需额外安装) |
|
|
45
|
+
| 平台 | Linux(群晖 DSM / fnOS 等);纯 Node 实现,无原生依赖 |
|
|
46
|
+
|
|
47
|
+
**为什么 0.2.1 是推荐下限**:本插件的会话列表缓存依赖 `sessionPersistence.list()` 返回的
|
|
48
|
+
**`SessionPersistenceSnapshot`**(`{ header, revision, eventCount?, sizeBytes? }`)里的 `revision`
|
|
49
|
+
作为「日志变过没有」的变更令牌。0.2.1 起该 API 稳定提供 `revision`;更早的版本里
|
|
50
|
+
`list()` 只给裸 header(没有 `revision`),插件会**优雅降级**:功能全部正常,
|
|
51
|
+
只是每次列表构建都要重新解析会话日志(会话多、日志大时会明显变慢、内存占用高)。
|
|
52
|
+
|
|
53
|
+
> 早期代码曾探测 `persistence.listSnapshots()` 并期望它返回 `{ header, revision }` ——
|
|
54
|
+
> 该 session 级方法**在任何 DSH 版本里都不存在**(0.1.6 里同名的 `listSnapshots(profileDir)`
|
|
55
|
+
> 是 profile 快照,另一套东西),所以那个探测永远失败、缓存从未生效过。
|
|
56
|
+
> 1.2.0 起统一改用 `list()` 并按快照解析。
|
|
57
|
+
|
|
39
58
|
## 安装
|
|
40
59
|
|
|
41
60
|
装到哪个 profile 由 `--profile` 指定。`dsh plugin` 会把后面的参数**转发给该 profile 目录下的 pnpm**,
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
46
|
+
async execute(args, exec) {
|
|
41
47
|
try {
|
|
42
48
|
const items = await listSessions(ctx);
|
|
43
|
-
|
|
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
|
}
|
|
@@ -72,8 +72,11 @@ import { buildSessionListCached } from "../sessions/list.js";
|
|
|
72
72
|
* 逼近 --max-old-space-size=2048 上限,即 修的那个堆 OOM)。
|
|
73
73
|
*
|
|
74
74
|
* 做法(持久性保存 + 懒加载,内存优先于速度):
|
|
75
|
-
* ① 用 persistence.
|
|
76
|
-
*
|
|
75
|
+
* ① 用 persistence.list() 拿每个会话的 **SessionPersistenceSnapshot**(`{ header, revision, ... }`;
|
|
76
|
+
* list() 只读 header 行 + 一次 stat,**不解析日志**),revision 是「该会话日志内容是否变过」的变更令牌;
|
|
77
|
+
* ⚠️ 不要探测 `listSnapshots()` —— 该 session 级方法在任何 DSH 版本里都不存在
|
|
78
|
+
* (0.1.6 里同名的 `listSnapshots(profileDir)` 是 profile 快照,另一套 API),
|
|
79
|
+
* 探测永远失败会让缓存静默失效(1.2.0 修掉的正是这个)。
|
|
77
80
|
* ② 把上面三个字段连同 revision 落盘到 <DSH_HOME>/storages/dsh-session-conductor/list-cache.json;
|
|
78
81
|
* ③ 下次列列表时 **revision 没变就直接用落盘结果,一次 inspect 都不做**(懒加载核心);
|
|
79
82
|
* 只有新会话、或日志真的追加过(revision 变了)才重新解析那一条;
|
|
@@ -254,7 +257,7 @@ export async function undoLastMessage(ctx, sessionId, { dryRun = false } = {}) {
|
|
|
254
257
|
if (targetLineIdx >= 0) break;
|
|
255
258
|
}
|
|
256
259
|
if (targetLineIdx < 0) {
|
|
257
|
-
return { ok: false, error: { code: "no-user-message", message: "
|
|
260
|
+
return { ok: false, error: { code: "no-user-message", message: "该会话没有可撤回的用户消息" } };
|
|
258
261
|
}
|
|
259
262
|
|
|
260
263
|
// 删除范围 [targetLineIdx, 行尾]:这条消息 + 它触发的整轮回复(文件尾部)
|
package/lib/group.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* GET /api/session-conductor/group/status 状态(分组数/groupRoot/profile)
|
|
8
8
|
* GET /api/session-conductor/group/list 全部分组(含 sessionIds / path / title)
|
|
9
9
|
* POST /api/session-conductor/group/new-session 在分组下新建会话 {workspaceId?, prompt?, handoff?}
|
|
10
|
-
* —— workspaceId 缺省 =
|
|
10
|
+
* —— workspaceId 缺省 = **最近活跃会话的工作区**(该会话 cwd 归属的分组);
|
|
11
11
|
* 找不到则兜底 .dsh 上一层 /workspace 默认工作区(不存在自动创建)。
|
|
12
12
|
*
|
|
13
13
|
* 面板「全部会话按工作区分组」渲染用 group/list(只读数据源);
|
|
@@ -111,7 +111,7 @@ function workspaceIdOfCwd(registry, cwd) {
|
|
|
111
111
|
return ws?.id ?? '';
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
-
/**
|
|
114
|
+
/** 最近活跃会话的 cwd:updatedAt/createdAt 最大者的工作目录(live + 冷会话合并)。 */
|
|
115
115
|
async function lastSessionCwd(ctx) {
|
|
116
116
|
const sessions = ctx.get('sessions');
|
|
117
117
|
const persistence = ctx.get('sessionPersistence');
|
|
@@ -122,17 +122,15 @@ async function lastSessionCwd(ctx) {
|
|
|
122
122
|
if (updatedAtMs >= best.t && header?.cwd) best = { t: updatedAtMs, cwd: header.cwd };
|
|
123
123
|
}
|
|
124
124
|
try {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (updatedAtMs >= best.t && snapshot?.cwd) best = { t: updatedAtMs, cwd: snapshot.cwd };
|
|
135
|
-
}
|
|
125
|
+
// 宿主 list() 返回的是 **SessionPersistenceSnapshot**(`{ header, revision, ... }`),不是裸 header。
|
|
126
|
+
// 旧写法分两支:探测 `listSnapshots()`(宿主从未有过该 session 级方法,探测永远失败),
|
|
127
|
+
// 失败支里直接读 `snapshot.cwd / snapshot.updatedAt` —— 两者都是 undefined,
|
|
128
|
+
// 于是「最近活跃会话的 cwd」在冷会话上**静默失效**(只剩 live 会话参与比较)。
|
|
129
|
+
// 现在统一解包:`entry.header ?? entry` 兼容新快照与旧裸 header 两种形态。
|
|
130
|
+
for (const entry of await persistence?.list?.() ?? []) {
|
|
131
|
+
const header = entry?.header ?? entry ?? {};
|
|
132
|
+
const updatedAtMs = Number(header?.updatedAt ?? header?.createdAt ?? 0);
|
|
133
|
+
if (updatedAtMs >= best.t && header?.cwd) best = { t: updatedAtMs, cwd: header.cwd };
|
|
136
134
|
}
|
|
137
135
|
} catch { /* 冷会话扫描失败不影响 live 结果 */ }
|
|
138
136
|
return best.cwd;
|
|
@@ -198,7 +196,7 @@ export async function registerGroupRoutes(ctx, config = {}, hooks = {}) {
|
|
|
198
196
|
const body = await readJsonBody(req);
|
|
199
197
|
let workspaceId = String(body?.workspaceId ?? '').trim();
|
|
200
198
|
if (!workspaceId) {
|
|
201
|
-
// workspaceId 缺省 =
|
|
199
|
+
// workspaceId 缺省 = 最近活跃会话的工作区(该会话 cwd 归属的分组)
|
|
202
200
|
const lastCwd = await lastSessionCwd(ctx);
|
|
203
201
|
if (lastCwd) workspaceId = workspaceIdOfCwd(registry, lastCwd);
|
|
204
202
|
}
|
package/lib/sessions/events.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
*
|
|
54
|
-
* persistence.open(id,
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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:
|
|
107
|
+
return { events: withInterruptedClosers(events), meta: handle?.header ?? null };
|
|
73
108
|
}
|
package/lib/sessions/list.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
//
|
|
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.
|
|
3
|
+
"version": "1.2.1",
|
|
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`。任一步失败都如实回执(如「⚠️ 会话已建,但模型未设置」),**不谎报成功**。
|