dsh-session-conductor 1.3.18 → 1.4.4
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 +95 -312
- package/lib/apply.js +22 -8
- package/lib/client.js +17 -5
- package/lib/core/domain-fallback.js +93 -0
- package/lib/core/domain.js +37 -7
- package/lib/features/member-model/batch.js +2 -2
- package/lib/features/member-model/command.js +7 -5
- package/lib/features/member-model/create-teammate.js +3 -1
- package/lib/features/member-model/switch.js +76 -18
- package/lib/features/member-model/tools.js +15 -3
- package/lib/features/memory-recall/index.js +10 -36
- package/lib/features/session-ops-routes.js +21 -0
- package/lib/sessions/events.js +6 -17
- package/lib/sessions/export-restore.js +384 -0
- package/lib/sessions/log-stream.js +68 -0
- package/lib/sessions/member-trace.js +343 -0
- package/lib/sessions/resolve.js +120 -0
- package/package.json +1 -1
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
// dsh-session-conductor — 读取成员(子代理)会话的完整执行轨迹
|
|
2
|
+
//
|
|
3
|
+
// 【这个文件解决什么】子代理只有「最后一条回复」会被宿主自动发回主代理,中间的思考与工具调用
|
|
4
|
+
// 主代理完全看不到。一个执行型子代理单轮可以产生上百条 assistant 消息(实测某会话单轮 115 条、
|
|
5
|
+
// 全会话 1382 条、带 reasoning 的 939 条),主代理只拿到一句结论,等于丢了全部过程。
|
|
6
|
+
//
|
|
7
|
+
// 【为什么不复用 recall_memory】那套是「记忆取回」语义,有三条硬限制让它读不了轨迹:
|
|
8
|
+
// ① 每轮只取第一条 assistant 消息(`if (!current || current.agentText) return`);
|
|
9
|
+
// ② reasoning 与 text 二选一(`pick("reasoning") || pick("text")`);
|
|
10
|
+
// ③ 完全不读 tool/call 与 tool/result。
|
|
11
|
+
// 本模块按 turn → step → 事件三层组织,把思考、正文、工具调用与结果一并取出。
|
|
12
|
+
//
|
|
13
|
+
// 【为什么按 zstd 帧流式读】单个会话日志可达数十 MB、上万事件;全量解码会一次性吃掉大块内存。
|
|
14
|
+
// 流式逐帧解析 + 边解析边丢弃旧轮次,内存只与「保留的轮数 × 每轮事件数」有关。
|
|
15
|
+
import { readFile } from "node:fs/promises";
|
|
16
|
+
import { findSessionLogById, sessionsRootOf } from "./events.js";
|
|
17
|
+
// 流式逐事件读取的公共底座(与记忆取回共用;本模块只负责「按 turn/step 怎么组织」)
|
|
18
|
+
import { streamSessionEvents } from "./log-stream.js";
|
|
19
|
+
// 目标解析的公共底座(id / 前缀 / 标题 / 成员名;与记忆取回共用)
|
|
20
|
+
import { resolveTargetSession } from "./resolve.js";
|
|
21
|
+
|
|
22
|
+
/** 单条文本默认截断长度(summary 模式下更短,见 SUMMARY_CLIP)。 */
|
|
23
|
+
const DEFAULT_MAX_ITEM = 800;
|
|
24
|
+
/** summary 模式下单条压成一行时的截断长度。 */
|
|
25
|
+
const SUMMARY_CLIP = 160;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 把一段文本压成单行摘要(summary 模式用)。
|
|
29
|
+
* @param {string} text 原始文本
|
|
30
|
+
* @param {number} max 最大长度
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function oneLine(text, max = SUMMARY_CLIP) {
|
|
34
|
+
const flat = String(text ?? "").replace(/\s+/g, " ").trim();
|
|
35
|
+
if (max > 0 && flat.length > max) return `${flat.slice(0, max)}…`;
|
|
36
|
+
return flat;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 从 assistant/message 的 content 里分别取 reasoning 与正文。
|
|
41
|
+
*
|
|
42
|
+
* 与 recall 的 `firstAgentTextOf` 不同:这里**两者都要**(思考与正文是不同信息,
|
|
43
|
+
* 只留一个会让「想了什么」或「说了什么」缺一半)。
|
|
44
|
+
*
|
|
45
|
+
* @param {object} data 事件 data
|
|
46
|
+
* @returns {{reasoning: string, text: string}}
|
|
47
|
+
*/
|
|
48
|
+
function splitAssistantContent(data) {
|
|
49
|
+
const content = data?.message?.content;
|
|
50
|
+
if (!Array.isArray(content)) return { reasoning: "", text: "" };
|
|
51
|
+
const join = (kind) => content
|
|
52
|
+
.filter((p) => p?.type === kind && typeof p.text === "string" && p.text.trim())
|
|
53
|
+
.map((p) => p.text)
|
|
54
|
+
.join("\n");
|
|
55
|
+
return { reasoning: join("reasoning"), text: join("text") };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 把 tool/call 的参数压成一行摘要。
|
|
60
|
+
*
|
|
61
|
+
* 工具参数里常有整段文件内容或长命令;全量输出会把轨迹淹没。这里只保留键名与短值:
|
|
62
|
+
* 字符串值超过 80 字符即截断,对象/数组只报类型与长度。
|
|
63
|
+
*
|
|
64
|
+
* @param {unknown} args 工具参数
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
function briefArgs(args) {
|
|
68
|
+
if (args === null || args === undefined) return "";
|
|
69
|
+
if (typeof args !== "object") return oneLine(String(args), 80);
|
|
70
|
+
const parts = [];
|
|
71
|
+
for (const [key, value] of Object.entries(args)) {
|
|
72
|
+
if (typeof value === "string") parts.push(`${key}=${value.length > 80 ? `${value.slice(0, 80)}…` : value}`);
|
|
73
|
+
else if (Array.isArray(value)) parts.push(`${key}[${value.length}]`);
|
|
74
|
+
else if (value !== null && typeof value === "object") parts.push(`${key}{…}`);
|
|
75
|
+
else parts.push(`${key}=${String(value)}`);
|
|
76
|
+
if (parts.join(", ").length > 200) break;
|
|
77
|
+
}
|
|
78
|
+
return oneLine(parts.join(", "), 200);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 把 tool/result 压成一行摘要:成功/失败 + 内容长度或错误信息。
|
|
83
|
+
* @param {object} data 事件 data
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function briefResult(data) {
|
|
87
|
+
const ok = data?.ok;
|
|
88
|
+
const error = data?.error;
|
|
89
|
+
if (error !== undefined) {
|
|
90
|
+
const msg = typeof error === "string" ? error : JSON.stringify(error);
|
|
91
|
+
return `失败:${oneLine(msg, 120)}`;
|
|
92
|
+
}
|
|
93
|
+
const content = data?.message?.content ?? data?.content;
|
|
94
|
+
let size = 0;
|
|
95
|
+
if (Array.isArray(content)) size = content.reduce((n, p) => n + (typeof p?.text === "string" ? p.text.length : 0), 0);
|
|
96
|
+
else if (typeof content === "string") size = content.length;
|
|
97
|
+
return `${ok === false ? "失败" : "成功"}(${size} 字符)`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 读取一个会话最近 N 轮的完整轨迹。
|
|
102
|
+
*
|
|
103
|
+
* @param {object} ctx 插件 context(保留给调用方传递,本函数不依赖具体服务)
|
|
104
|
+
* @param {string} sessionId 目标会话 id
|
|
105
|
+
* @param {{rounds?: number, maxCharsPerItem?: number, root?: string}} [opts]
|
|
106
|
+
* rounds 取最近几轮(默认 1);maxCharsPerItem 单条截断(默认 800)
|
|
107
|
+
* @returns {Promise<{turns: Array<object>} | {error: string}>}
|
|
108
|
+
*/
|
|
109
|
+
export async function readMemberTrace(ctx, sessionId, opts = {}) {
|
|
110
|
+
const file = findSessionLogById(sessionId, opts.root ?? sessionsRootOf());
|
|
111
|
+
if (!file) return { error: `找不到会话日志(sessionId=${sessionId})` };
|
|
112
|
+
const rounds = Number.isSafeInteger(opts.rounds) && opts.rounds > 0 ? opts.rounds : 1;
|
|
113
|
+
const perItem = Number.isSafeInteger(opts.maxCharsPerItem) && opts.maxCharsPerItem > 0
|
|
114
|
+
? opts.maxCharsPerItem
|
|
115
|
+
: DEFAULT_MAX_ITEM;
|
|
116
|
+
|
|
117
|
+
const buf = await readFile(file);
|
|
118
|
+
const turns = [];
|
|
119
|
+
let turn = null;
|
|
120
|
+
let step = null;
|
|
121
|
+
|
|
122
|
+
/** 保证当前轮与当前 step 存在(事件可能不按期望顺序出现,缺失时兜底建一个)。 */
|
|
123
|
+
const ensureTurn = () => {
|
|
124
|
+
if (turn === null) {
|
|
125
|
+
turn = { index: 0, task: "", steps: [] };
|
|
126
|
+
turns.push(turn);
|
|
127
|
+
}
|
|
128
|
+
return turn;
|
|
129
|
+
};
|
|
130
|
+
const ensureStep = () => {
|
|
131
|
+
const t = ensureTurn();
|
|
132
|
+
if (step === null) {
|
|
133
|
+
step = { step: 1, items: [] };
|
|
134
|
+
t.steps.push(step);
|
|
135
|
+
}
|
|
136
|
+
return step;
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const onEvent = (event) => {
|
|
140
|
+
const type = event?.type;
|
|
141
|
+
|
|
142
|
+
if (type === "turn/start") {
|
|
143
|
+
// 新回合。注意**不要**在这里用 turns.shift() 裁剪:shift 会让下面 turn/step 两个引用
|
|
144
|
+
// 指向已被移出数组的对象,之后的 assistant 消息会挂到那个孤儿对象上(表现为
|
|
145
|
+
// 「某一轮的思考内容跑到了另一轮」)。改成读完之后统一 slice(-rounds)。
|
|
146
|
+
turn = { index: (turns.length > 0 ? turns[turns.length - 1].index + 1 : 1), task: "", steps: [] };
|
|
147
|
+
turns.push(turn);
|
|
148
|
+
step = null;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (type === "step/start") {
|
|
152
|
+
const t = ensureTurn();
|
|
153
|
+
step = { step: typeof event?.data?.step === "number" ? event.data.step : t.steps.length + 1, items: [] };
|
|
154
|
+
t.steps.push(step);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (type === "user/message") {
|
|
158
|
+
// 用**白名单**而不是黑名单:user/message 的 source.kind 有十余种,除了真实任务以外
|
|
159
|
+
// 全是宿主或插件的注入(实测同一份日志中:skill-catalog×5、runtime-context×7、time-context×62、
|
|
160
|
+
// tool-jobs×17、plugin:dsh-session-conductor×131、plugin:dsh-git-push×61 等)。
|
|
161
|
+
// 黑名单写法只要漏一种,任务文本就会被几百 KB 的注入内容淹没。
|
|
162
|
+
// 真实任务只有两种来源:user(用户直接说)与 agent-message(主代理派给子代理的团队消息)。
|
|
163
|
+
const kind = event?.data?.source?.kind;
|
|
164
|
+
if (kind !== "user" && kind !== "agent-message") return;
|
|
165
|
+
const content = event?.data?.content;
|
|
166
|
+
const text = Array.isArray(content)
|
|
167
|
+
? content.filter((p) => typeof p?.text === "string").map((p) => p.text).join("\n")
|
|
168
|
+
: "";
|
|
169
|
+
if (!text.trim()) return;
|
|
170
|
+
const t = ensureTurn();
|
|
171
|
+
t.task = t.task ? `${t.task}\n\n${oneLine(text, perItem)}` : oneLine(text, perItem);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (type === "assistant/message") {
|
|
175
|
+
const { reasoning, text } = splitAssistantContent(event?.data);
|
|
176
|
+
if (!reasoning && !text) return;
|
|
177
|
+
const s = ensureStep();
|
|
178
|
+
if (reasoning) s.items.push({ kind: "reasoning", text: oneLine(reasoning, perItem) });
|
|
179
|
+
if (text) s.items.push({ kind: "text", text: oneLine(text, perItem) });
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (type === "tool/call") {
|
|
183
|
+
const s = ensureStep();
|
|
184
|
+
s.items.push({
|
|
185
|
+
kind: "tool",
|
|
186
|
+
name: String(event?.data?.name ?? "?"),
|
|
187
|
+
args: briefArgs(event?.data?.arguments),
|
|
188
|
+
result: "",
|
|
189
|
+
});
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (type === "tool/result") {
|
|
193
|
+
// 结果挂到同一 step 里最近一次尚未填结果的 tool 记录上
|
|
194
|
+
const t = ensureTurn();
|
|
195
|
+
for (let i = t.steps.length - 1; i >= 0; i -= 1) {
|
|
196
|
+
const items = t.steps[i].items;
|
|
197
|
+
for (let j = items.length - 1; j >= 0; j -= 1) {
|
|
198
|
+
if (items[j].kind === "tool" && !items[j].result) {
|
|
199
|
+
items[j].result = briefResult(event?.data);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
await streamSessionEvents(buf, onEvent);
|
|
209
|
+
|
|
210
|
+
// 丢掉没有任务的空轮(日志里可能存在只有 turn 边界、没有内容的情况),再取最近 rounds 轮。
|
|
211
|
+
// 顺序很重要:先过滤空轮再裁剪,否则 rounds=2 时可能被一个空轮占掉一个名额。
|
|
212
|
+
const useful = turns.filter((t) => t.task || t.steps.some((s) => s.items.length > 0));
|
|
213
|
+
return { turns: useful.slice(-rounds), file };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 把轨迹格式化成给 AI 读的文本。
|
|
218
|
+
*
|
|
219
|
+
* @param {Array<object>} turns readMemberTrace 的 turns
|
|
220
|
+
* @param {{sessionLabel?: string, detail?: "summary"|"full", include?: string[]}} [opts]
|
|
221
|
+
* detail=summary 时每条压成一行;full 时保留 readMemberTrace 给出的文本长度
|
|
222
|
+
* @param {Set<string>} [kinds] 要包含的内容种类(reasoning / text / tool)
|
|
223
|
+
* @returns {string}
|
|
224
|
+
*/
|
|
225
|
+
export function formatMemberTrace(turns, opts = {}, kinds = new Set(["reasoning", "text", "tool"])) {
|
|
226
|
+
const label = opts.sessionLabel ?? "目标会话";
|
|
227
|
+
const full = opts.detail === "full";
|
|
228
|
+
const lines = [];
|
|
229
|
+
lines.push(`【${label}】最近 ${turns.length} 轮执行轨迹`);
|
|
230
|
+
for (const t of turns) {
|
|
231
|
+
lines.push("");
|
|
232
|
+
lines.push(`── 第 ${t.index} 轮${t.task ? `|任务:${full ? t.task : oneLine(t.task, 200)}` : ""}`);
|
|
233
|
+
for (const s of t.steps) {
|
|
234
|
+
for (const item of s.items) {
|
|
235
|
+
if (item.kind === "reasoning" && !kinds.has("reasoning")) continue;
|
|
236
|
+
if (item.kind === "text" && !kinds.has("text")) continue;
|
|
237
|
+
if (item.kind === "tool" && !kinds.has("tool")) continue;
|
|
238
|
+
if (item.kind === "reasoning") lines.push(` [step ${s.step}] 思考:${item.text}`);
|
|
239
|
+
else if (item.kind === "text") lines.push(` [step ${s.step}] 正文:${item.text}`);
|
|
240
|
+
else {
|
|
241
|
+
const res = item.result ? ` → ${item.result}` : "";
|
|
242
|
+
lines.push(` [step ${s.step}] 调用 ${item.name}(${item.args})${res}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return lines.join("\n");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** 工具输出格式:一律纯文本(与其它工具一致,交给宿主渲染成一段 message)。 */
|
|
251
|
+
const TEXT_OUTPUT = {
|
|
252
|
+
schema: { type: "string" },
|
|
253
|
+
render(args, value) {
|
|
254
|
+
return [{ type: "text", text: String(value) }];
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 注册 `read_member_trace` AI 工具。
|
|
260
|
+
*
|
|
261
|
+
* 【解决什么】子代理只有「最后一条回复」会被宿主自动发回主代理;执行型子代理单轮可产生上百条
|
|
262
|
+
* assistant 消息(思考 + 工具调用 + 逐步结论),主代理只看得到最后一句,等于丢失全部过程。
|
|
263
|
+
* 本工具让主代理按需读取指定会话(通常是子代理)的完整轨迹。
|
|
264
|
+
*
|
|
265
|
+
* @param {object} ctx 插件 context
|
|
266
|
+
* @param {Function} defineTool 宿主 defineTool
|
|
267
|
+
* @param {object} tools 宿主 tools 服务
|
|
268
|
+
* @param {{cfg?: object, resolveSession?: Function}} [deps] 测试注入点
|
|
269
|
+
* @returns {boolean} 是否成功注册
|
|
270
|
+
*/
|
|
271
|
+
export function registerMemberTraceTool(ctx, defineTool, tools, deps = {}) {
|
|
272
|
+
if (typeof defineTool !== "function" || typeof tools?.register !== "function") return false;
|
|
273
|
+
const resolve = deps.resolveSession;
|
|
274
|
+
const maxRounds = 20;
|
|
275
|
+
|
|
276
|
+
tools.register(defineTool({
|
|
277
|
+
name: "read_member_trace",
|
|
278
|
+
description:
|
|
279
|
+
"读取成员(子代理)或其他会话的**完整执行轨迹**:每轮的思考、正文、工具调用与结果摘要。"
|
|
280
|
+
+ "何时用:子代理只把最后一条回复发回来,想看它中间想了什么/调用了什么/为什么这么结论时;"
|
|
281
|
+
+ "或想核对某个成员到底做了哪些操作。"
|
|
282
|
+
+ `参数 rounds = 取最近几轮(默认 1,上限 ${maxRounds};一轮可能上百条,别一次要太多)。`
|
|
283
|
+
+ "detail=summary(默认,每条压成一行)/ full(保留较长文本)。"
|
|
284
|
+
+ "include 可裁掉 reasoning / text / tool 中不需要的部分。只读,不改任何会话。",
|
|
285
|
+
parameters: {
|
|
286
|
+
session: { type: "string", description: "目标会话:成员名 / sessionId / id 前缀 / 标题子串;空 = 自己" },
|
|
287
|
+
rounds: { type: "number", description: `取最近几轮(默认 1,上限 ${maxRounds})` },
|
|
288
|
+
detail: { type: "string", description: "summary(默认,每条一行)或 full(较长文本)" },
|
|
289
|
+
include: { type: "string", description: "只看某些内容:传逗号分隔的 reasoning,text,tool;默认全含" },
|
|
290
|
+
maxCharsPerItem: { type: "number", description: "单条截断字符数(默认 800)" },
|
|
291
|
+
},
|
|
292
|
+
output: TEXT_OUTPUT,
|
|
293
|
+
async execute(args, exec) {
|
|
294
|
+
const selfId = exec?.agent?.session?.id;
|
|
295
|
+
if (!selfId) return "拿不到当前会话 id,无法读取轨迹。";
|
|
296
|
+
|
|
297
|
+
// 目标解析走公共底座(sessions/resolve.js):id / id 前缀 / 标题 / 成员名,命中多个给候选不猜。
|
|
298
|
+
// 两个工具(本工具与记忆取回)共用同一套,避免各自一套解析各缺一块。
|
|
299
|
+
const want = typeof args?.session === "string" ? args.session.trim() : "";
|
|
300
|
+
const resolveFn = resolve ?? resolveTargetSession;
|
|
301
|
+
const rounds = Math.min(
|
|
302
|
+
Number.isSafeInteger(args?.rounds) && args.rounds > 0 ? args.rounds : 1,
|
|
303
|
+
maxRounds,
|
|
304
|
+
);
|
|
305
|
+
const detail = args?.detail === "full" ? "full" : "summary";
|
|
306
|
+
const kinds = new Set(
|
|
307
|
+
(typeof args?.include === "string" && args.include.trim()
|
|
308
|
+
? args.include.split(",").map((s) => s.trim()).filter(Boolean)
|
|
309
|
+
: ["reasoning", "text", "tool"]),
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
let targetId = selfId;
|
|
313
|
+
if (want && want !== selfId) {
|
|
314
|
+
const hit = await resolveFn(ctx, want, selfId, { agent: exec?.agent });
|
|
315
|
+
if (hit?.candidates) {
|
|
316
|
+
return `「${want}」匹配到多个目标,请用更完整的 id 重试:\n`
|
|
317
|
+
+ hit.candidates.slice(0, 10).map((c) => `- ${c.id}${c.title ? ` | ${c.title}` : ""}`).join("\n");
|
|
318
|
+
}
|
|
319
|
+
if (!hit?.id) {
|
|
320
|
+
return `找不到目标会话「${want}」:没有匹配的成员名 / 会话 id / id 前缀 / 标题。`;
|
|
321
|
+
}
|
|
322
|
+
targetId = hit.id;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const read = await readMemberTrace(ctx, targetId, {
|
|
326
|
+
rounds,
|
|
327
|
+
maxCharsPerItem: Number.isSafeInteger(args?.maxCharsPerItem) && args.maxCharsPerItem > 0
|
|
328
|
+
? args.maxCharsPerItem
|
|
329
|
+
: undefined,
|
|
330
|
+
});
|
|
331
|
+
if (read.error) return `读取轨迹失败:${read.error}`;
|
|
332
|
+
if (!read.turns || read.turns.length === 0) {
|
|
333
|
+
return `该会话最近 ${rounds} 轮没有可读内容(可能还没跑过回合,或日志里只有注入事件)。`;
|
|
334
|
+
}
|
|
335
|
+
const text = formatMemberTrace(read.turns, {
|
|
336
|
+
sessionLabel: targetId === selfId ? "自身" : `目标 ${targetId}`,
|
|
337
|
+
detail,
|
|
338
|
+
}, kinds);
|
|
339
|
+
return text || "没有符合 include 过滤条件的内容。";
|
|
340
|
+
},
|
|
341
|
+
}));
|
|
342
|
+
return true;
|
|
343
|
+
}
|
|
@@ -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
|
+
"version": "1.4.4",
|
|
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",
|