dsh-session-conductor 1.3.9 → 1.3.11

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.
@@ -20,8 +20,8 @@
20
20
  import { readRecentUserTurns } from "../../sessions/events.js";
21
21
  import { log } from "../../shared/log.js";
22
22
  import { cfg } from "../../core/config.js";
23
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
- import { dirname, join } from "node:path";
23
+ import { writeJsonAtomicSync, readJsonSafeSync } from "../../shared/json-store.js";
24
+ import { join } from "node:path";
25
25
 
26
26
  // ── 用量记忆的**持久化**(解决「重启后不触发」)──────────────────────────────
27
27
  // 只用内存 WeakMap 时实例一重启就清空 ⇒ 首个回合没有「上次用量」可比 ⇒
@@ -54,12 +54,9 @@ function loadDiskState() {
54
54
  diskState = {};
55
55
  const file = stateFilePath();
56
56
  if (!file) return diskState;
57
- try {
58
- const parsed = JSON.parse(readFileSync(file, "utf8"));
59
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) diskState = parsed;
60
- } catch {
61
- // 首次运行 / 文件损坏:按空表继续
62
- }
57
+ // 统一读实现:不存在/损坏都返回 null,按空表继续
58
+ const parsed = readJsonSafeSync(file);
59
+ if (parsed && !Array.isArray(parsed)) diskState = parsed;
63
60
  return diskState;
64
61
  }
65
62
 
@@ -71,9 +68,7 @@ function saveDiskStateThrottled(ctx) {
71
68
  const file = stateFilePath();
72
69
  if (!file) return;
73
70
  try {
74
- mkdirSync(dirname(file), { recursive: true });
75
- writeFileSync(`${file}.tmp`, JSON.stringify(diskState), "utf8");
76
- renameSync(`${file}.tmp`, file);
71
+ writeJsonAtomicSync(file, diskState); // 统一原子写(tmp + rename)
77
72
  } catch (error) {
78
73
  log(ctx, `记忆取回:用量状态落盘失败(不影响功能): ${String(error?.message ?? error)}`);
79
74
  }
@@ -254,6 +249,26 @@ const TEXT_OUTPUT = {
254
249
  *
255
250
  * @returns {boolean} 是否成功注册
256
251
  */
252
+ /**
253
+ * 解析 recall_memory 的 `session` 参数:
254
+ * · 缺省 / 空 / 等于自身 ⇒ 取自身(行为与未加该参数时逐字一致)
255
+ * · 多个候选 / 找不到 ⇒ 返回可直接回给用户的 message(不猜一个)
256
+ * @param {Function} resolve 会话解析函数(由工具注册处注入,默认 resolveSessionTarget)
257
+ * @returns {Promise<{targetId: string, fromOther: boolean} | {message: string}>}
258
+ */
259
+ async function resolveRecallTarget(ctx, want, selfId, resolve) {
260
+ if (!want || want === selfId) return { targetId: selfId, fromOther: false };
261
+ const hit = await resolve(ctx, want, selfId);
262
+ if (hit?.candidates) {
263
+ return {
264
+ message: `「${want}」匹配到多个目标,请用更完整的 id 重试:\n`
265
+ + hit.candidates.slice(0, 10).map((c) => `- ${c.id}${c.title ? ` | ${c.title}` : ""}`).join("\n"),
266
+ };
267
+ }
268
+ if (!hit?.id) return { message: `找不到目标会话「${want}」:没有匹配的会话 id / id 前缀 / 标题。` };
269
+ return { targetId: hit.id, fromOther: true };
270
+ }
271
+
257
272
  export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
258
273
  if (typeof defineTool !== "function" || typeof tools?.register !== "function") return false;
259
274
  const read = deps.readTurns ?? readRecentUserTurns;
@@ -279,19 +294,9 @@ export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
279
294
  const asked = Number.isSafeInteger(args?.rounds) && args.rounds > 0 ? args.rounds : (config?.retrieveMemoryRounds ?? 3);
280
295
  const rounds = Math.min(asked, maxRounds);
281
296
  const want = typeof args?.session === "string" ? args.session.trim() : "";
282
- // 缺省 / 空 / 等于自身 ⇒ 走自身路径,行为与之前逐字一致
283
- let targetId = selfId;
284
- let fromOther = false;
285
- if (want && want !== selfId) {
286
- const hit = await resolve(ctx, want, selfId);
287
- if (hit?.candidates) {
288
- return `「${want}」匹配到多个目标,请用更完整的 id 重试:\n`
289
- + hit.candidates.slice(0, 10).map((c) => `- ${c.id}${c.title ? ` | ${c.title}` : ""}`).join("\n");
290
- }
291
- if (!hit?.id) return `找不到目标会话「${want}」:没有匹配的会话 id / id 前缀 / 标题。`;
292
- targetId = hit.id;
293
- fromOther = true;
294
- }
297
+ const target = await resolveRecallTarget(ctx, want, selfId, resolve);
298
+ if (target.message) return target.message;
299
+ const { targetId, fromOther } = target;
295
300
  try {
296
301
  const turns = await read(ctx, targetId, {
297
302
  rounds,
@@ -325,16 +330,20 @@ export function registerMemoryRecallTool(ctx, defineTool, tools, deps = {}) {
325
330
  */
326
331
  export async function resolveSessionTarget(ctx, want, selfId) {
327
332
  const { sessionsRootOf } = await import("../../sessions/events.js");
328
- const { readdirSync, existsSync, statSync } = await import("node:fs");
333
+ // 异步遍历:会话目录可能上千个,同步 stat/readdir 会把事件循环钉住
334
+ const { readdir, stat } = await import("node:fs/promises");
329
335
  const { join } = await import("node:path");
330
336
  const root = sessionsRootOf();
331
337
  const ids = [];
332
- if (root && existsSync(root)) {
333
- for (const proj of readdirSync(root)) {
338
+ const isDirectory = async (p) => {
339
+ try { return (await stat(p)).isDirectory(); } catch { return false; }
340
+ };
341
+ if (root && await isDirectory(root)) {
342
+ for (const proj of await readdir(root)) {
334
343
  const pp = join(root, proj);
335
- try { if (!statSync(pp).isDirectory()) continue; } catch { continue; }
336
- for (const dir of readdirSync(pp)) {
337
- try { if (!statSync(join(pp, dir)).isDirectory()) continue; } catch { continue; }
344
+ if (!(await isDirectory(pp))) continue;
345
+ for (const dir of await readdir(pp)) {
346
+ if (!(await isDirectory(join(pp, dir)))) continue;
338
347
  if (dir.startsWith("session-") || dir === selfId) ids.push(dir);
339
348
  }
340
349
  }
@@ -63,29 +63,38 @@ export async function runAnalysis(ctx, sessionId, opts = {}) {
63
63
  * modelCatalog 匹配 provider;匹配不到则借用当前路由的 provider)。
64
64
  * @returns {provider, model} 或 null(无法解析)
65
65
  */
66
- export async function resolveModelOverride(ctx, modelArg, fallbackRoute) {
67
- const raw = String(modelArg ?? "").trim();
68
- if (!raw) return null;
69
- // "provider/model" 显式拆分;不匹配(含纯模型名)→ 走 catalog 匹配/fallback
70
- const explicit = /^([^/]+)\/(.+)$/.exec(raw);
71
- if (explicit) {
72
- const provider = explicit[1].trim();
73
- const model = explicit[2].trim();
74
- if (provider && model) return { provider, model };
75
- }
76
- // 纯模型名:modelCatalog 里找第一个含该 model 的分组
66
+ /** 拆 "provider/model" 形式;不是该形式返回 null(含纯模型名)。 */
67
+ function splitExplicitModel(raw) {
68
+ const matched = /^([^/]+)\/(.+)$/.exec(raw);
69
+ if (!matched) return null;
70
+ const provider = matched[1].trim();
71
+ const model = matched[2].trim();
72
+ return provider && model ? { provider, model } : null;
73
+ }
74
+
75
+ /** 纯模型名:在 modelCatalog 里找第一个包含该模型的分组;catalog 不可用返回 null。 */
76
+ async function providerOfModelName(ctx, raw) {
77
77
  try {
78
78
  const sc = ctx.get("sessionController");
79
79
  const catalog = sc?.modelCatalog ? await sc.modelCatalog() : null;
80
- const groups = catalog?.groups ?? [];
81
- for (const g of groups) {
82
- if ((g.models ?? []).some((m) => m.id === raw || m.name === raw)) {
83
- return { provider: g.id, model: raw };
80
+ for (const group of catalog?.groups ?? []) {
81
+ if ((group.models ?? []).some((m) => m.id === raw || m.name === raw)) {
82
+ return { provider: group.id, model: raw };
84
83
  }
85
84
  }
86
85
  } catch {
87
- // catalog 不可用 → 落到 fallback provider
86
+ // catalog 不可用 → 交给 fallback provider
88
87
  }
88
+ return null;
89
+ }
90
+
91
+ export async function resolveModelOverride(ctx, modelArg, fallbackRoute) {
92
+ const raw = String(modelArg ?? "").trim();
93
+ if (!raw) return null;
94
+ const explicit = splitExplicitModel(raw);
95
+ if (explicit) return explicit;
96
+ const byCatalog = await providerOfModelName(ctx, raw);
97
+ if (byCatalog) return byCatalog;
89
98
  if (fallbackRoute?.provider) return { provider: fallbackRoute.provider, model: raw };
90
99
  return null;
91
100
  }
@@ -52,25 +52,37 @@ export function foldTitle(events) {
52
52
  }
53
53
 
54
54
  /** 从会话 cwd 推导工作区名:优先匹配 workspaceRegistry 分组 title,否则取 cwd basename。 */
55
- export function workspaceNameOf(ctx, cwd) {
56
- if (typeof cwd !== "string" || cwd === "") return "";
57
- const registry = ctx?.get?.("workspaceRegistry");
58
- if (registry) {
59
- try {
60
- for (const workspace of registry.list?.() ?? []) {
61
- const path = String(workspace?.path ?? "").replace(/\/+$/, "");
62
- const normalizedCwd = String(cwd).replace(/\/+$/, "");
63
- if (path !== "" && (normalizedCwd === path || normalizedCwd.startsWith(path + "/"))) {
64
- if (typeof workspace.title === "string" && workspace.title.trim() !== "") return workspace.title.trim();
65
- }
66
- }
67
- } catch {
68
- // 分组服务不可用时回退 basename
55
+ /**
56
+ * 在 workspaceRegistry 里找 cwd 所属工作区的标题(**第一个命中即返回**,与原实现同口径)。
57
+ * 服务不可用/无匹配返回 ""。
58
+ */
59
+ function workspaceTitleOf(registry, normalizedCwd) {
60
+ try {
61
+ for (const workspace of registry.list?.() ?? []) {
62
+ const path = String(workspace?.path ?? "").replace(/\/+$/, "");
63
+ if (path === "") continue;
64
+ if (normalizedCwd !== path && !normalizedCwd.startsWith(path + "/")) continue;
65
+ const title = typeof workspace.title === "string" ? workspace.title.trim() : "";
66
+ if (title !== "") return title;
69
67
  }
68
+ } catch {
69
+ // 分组服务不可用:交给调用方回退 basename
70
70
  }
71
+ return "";
72
+ }
73
+
74
+ /** 取路径最后一段(工作区服务不可用时的兜底显示名)。 */
75
+ function basenameOfPath(cwd) {
71
76
  return String(cwd).split(/[/\\]/).filter(Boolean).pop() ?? "";
72
77
  }
73
78
 
79
+ export function workspaceNameOf(ctx, cwd) {
80
+ if (typeof cwd !== "string" || cwd === "") return "";
81
+ const registry = ctx?.get?.("workspaceRegistry");
82
+ const title = registry ? workspaceTitleOf(registry, cwd.replace(/\/+$/, "")) : "";
83
+ return title !== "" ? title : basenameOfPath(cwd);
84
+ }
85
+
74
86
  /** 给标题加工作区前缀(幂等:已带前缀不叠加)。返回 {title, ws}。 */
75
87
  export function archiveTitleWithWs(title, ws) {
76
88
  const t = String(title ?? "");
@@ -21,42 +21,54 @@ const VALUE_LLM_MAX_TOKENS = 120;
21
21
  * @param {Function} [onError] 日志回调
22
22
  * @returns {Promise<object>} 会话 id → {value:"high"|"medium"|"low", reason}
23
23
  */
24
+ /** 价值判定用的系统提示词(长常量提到模块级,函数体只留编排)。 */
25
+ const VALUE_SYSTEM_PROMPT = "你是 DSH(DeepSeek Harness)会话价值分析师。给定一个会话的标题与最后回复片段,判断它在长期保留意义上的价值。高价值=含独有信息/未完成任务/学习成果/资产/待继续工作;低价值=灰尘/重复/过时空壳/可安全丢弃。输出严格 JSON,无 Markdown:{\"value\":\"high\"|\"medium\"|\"low\",\"reason\":\"一句话中文理由\"}";
26
+
27
+ /**
28
+ * 单个会话的 LLM 价值判定。
29
+ * 无模型路由 / 输出解析失败都返回 null(由调用方的规则评分兜底)。
30
+ * @returns {Promise<{value:string, reason?:string}|null>}
31
+ */
32
+ async function judgeSessionValue(llm, session, texts) {
33
+ const route = resolveRoute(session, llm);
34
+ if (!route?.provider || !route.model) return null; // 无模型路由 → 跳过
35
+ const title = session.title || session.cwd || session.id || "(无标题)";
36
+ const lastText = (texts?.[session.id] || "").slice(0, VALUE_PROMPT_TEXT_MAX_CHARS);
37
+ const text = [`会话标题:${title}`, "最后回复片段:", lastText].join("\n");
38
+ const assembler = new BlockAssembler();
39
+ for await (const chunk of llm.stream({
40
+ provider: route.provider,
41
+ model: route.model,
42
+ messages: [createUserMessage({ content: [{ type: "text", text }], source: { kind: "plugin:dsh-session-conductor", plugin: "dsh-session-conductor" } })],
43
+ system: VALUE_SYSTEM_PROMPT,
44
+ maxTokens: VALUE_LLM_MAX_TOKENS,
45
+ sessionId: session.id,
46
+ purpose: "session-conductor-value-analysis",
47
+ signal: AbortSignal.timeout(AUTO_RENAME_TIMEOUT_MS),
48
+ })) assembler.push(chunk);
49
+ const raw = assembler.blocks().filter((b) => b.type === "text").map((b) => b.text).join(" ").trim();
50
+ return parseValueJson(raw);
51
+ }
52
+
24
53
  export async function analyzeValueWithLlm(ctx, sessions, texts, onError) {
25
54
  const out = {};
26
- let llm;
55
+ let llm = null;
27
56
  try {
28
57
  llm = ctx.get("llm");
29
- } catch { llm = null; }
58
+ } catch {
59
+ llm = null;
60
+ }
30
61
  if (!llm) {
31
62
  if (typeof onError === "function") onError("会话价值 LLM 判断跳过:llm 服务不可用");
32
63
  return out;
33
64
  }
34
- for (const s of sessions ?? []) {
65
+ for (const session of sessions ?? []) {
35
66
  try {
36
- const route = resolveRoute(s, llm);
37
- if (!route?.provider || !route.model) continue; // 无模型路由 → 跳过,规则兜底
38
- const title = s.title || s.cwd || s.id || "(无标题)";
39
- const last = (texts?.[s.id] || "").slice(0, VALUE_PROMPT_TEXT_MAX_CHARS);
40
- const system = "你是 DSH(DeepSeek Harness)会话价值分析师。给定一个会话的标题与最后回复片段,判断它在长期保留意义上的价值。高价值=含独有信息/未完成任务/学习成果/资产/待继续工作;低价值=灰尘/重复/过时空壳/可安全丢弃。输出严格 JSON,无 Markdown:{\"value\":\"high\"|\"medium\"|\"low\",\"reason\":\"一句话中文理由\"}";
41
- const text = [`会话标题:${title}`, "最后回复片段:", last].join("\n");
42
- const assembler = new BlockAssembler();
43
- for await (const chunk of llm.stream({
44
- provider: route.provider,
45
- model: route.model,
46
- messages: [createUserMessage({ content: [{ type: "text", text }], source: { kind: "plugin:dsh-session-conductor", plugin: "dsh-session-conductor" } })],
47
- system,
48
- maxTokens: VALUE_LLM_MAX_TOKENS,
49
- sessionId: s.id,
50
- purpose: "session-conductor-value-analysis",
51
- signal: AbortSignal.timeout(AUTO_RENAME_TIMEOUT_MS),
52
- })) assembler.push(chunk);
53
- const blocks = assembler.blocks();
54
- const raw = blocks.filter((b) => b.type === "text").map((b) => b.text).join(" ").trim();
55
- const parsed = parseValueJson(raw);
56
- if (parsed) out[s.id] = { value: parsed.value, reason: parsed.reason || "" };
67
+ const parsed = await judgeSessionValue(llm, session, texts);
68
+ if (parsed) out[session.id] = { value: parsed.value, reason: parsed.reason || "" };
57
69
  } catch (error) {
58
- if (typeof onError === "function") onError(`会话价值 LLM 判断失败 ${s.id}: ${String(error?.message ?? error)}`);
59
- /* 单个会话失败跳过,规则兜底 */
70
+ if (typeof onError === "function") onError(`会话价值 LLM 判断失败 ${session.id}: ${String(error?.message ?? error)}`);
71
+ // 单个会话失败跳过,规则兜底
60
72
  }
61
73
  }
62
74
  return out;
@@ -12,6 +12,7 @@
12
12
  // —— 自己写日志等于拿对方会话的完整性冒险。因此本域**不碰任何日志文件**。
13
13
 
14
14
  import { log } from "../../shared/log.js";
15
+ import { sessionMetaOf } from "../../sessions/lookup.js";
15
16
 
16
17
  /** 单条消息长度上限(字符):够写交接要点,不够塞长文档(长内容应落共享文件)。 */
17
18
  export const TEXT_LIMIT = 8 * 1024;
@@ -143,7 +144,7 @@ export function currentSessionId(agent) {
143
144
  /**
144
145
  * 调用方会话的工作目录。
145
146
  * 用途:`create_session` 不传 cwd/workspaceId 时的缺省值 —— 新会话落在**发起它的那个会话**
146
- * 的工作目录里,而不是本体的默认 cwd(实测是 DSH 安装目录 `/volume1/@appstore/DeepSeekHarness-NAS`,
147
+ * 的工作目录里,而不是本体的默认 cwd(实测是 DSH 安装目录(即本机程序目录),
147
148
  * 对干活毫无意义)。
148
149
  */
149
150
  export function callerCwd(agent) {
@@ -173,19 +174,11 @@ export async function listSessions(ctx) {
173
174
  */
174
175
  export async function describeTarget(ctx, sessionId) {
175
176
  try {
176
- const live = ctx.get?.("sessions")?.get?.(sessionId);
177
- if (live) {
178
- const cwd = String(live.header?.cwd ?? "(无工作区)");
179
- return `(进行中 | ${cwd})`;
180
- }
181
- const { loadListDiskCache } = await import("../../sessions/list-cache.js");
182
- const entry = loadListDiskCache(ctx)?.entries?.[sessionId];
183
- if (entry) {
184
- const cwd = String(entry.cwd ?? "(无工作区)");
185
- const updated = typeof entry.updatedAt === "number" ? new Date(entry.updatedAt).toISOString() : "未知";
186
- return `(已落盘 | ${cwd} | 最后活动 ${updated})`;
187
- }
188
- return "(⚠️ 内存与落盘索引里都没有这个 id —— 可能写错了,或它是刚建、尚未落盘的会话)";
177
+ const meta = await sessionMetaOf(ctx, sessionId);
178
+ if (!meta) return "(⚠️ 内存与落盘索引里都没有这个 id —— 可能写错了,或它是刚建、尚未落盘的会话)";
179
+ if (meta.live) return `(进行中 | ${String(meta.cwd ?? "(无工作区)")})`;
180
+ const updated = meta.updatedAt ? new Date(meta.updatedAt).toISOString() : "未知";
181
+ return `(已落盘 | ${String(meta.cwd ?? "(无工作区)")} | 最后活动 ${updated})`;
189
182
  } catch (error) {
190
183
  return `(目标存在性无法核对:${shortError(error)})`;
191
184
  }