lume-dsh-plugin 0.7.2 → 0.7.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.
@@ -48,11 +48,18 @@ function asCount(value) {
48
48
  const n = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
49
49
  return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : null;
50
50
  }
51
- /** 项目键:跨会话共享的项目知识按工作目录归属(同一仓库的多个会话共用一份)。 */
51
+ /**
52
+ * 项目键:跨会话共享的项目知识按工作目录归属(同一仓库的多个会话共用一份)。
53
+ *
54
+ * 拿不到工作目录时返回 **null**,不返回 "unknown"——实测踩过:写入口(工具 exec / 会话事件)
55
+ * 里的 session 视图不一定带 cwd,回落成 "unknown" 会把**所有项目**的知识塞进同一个桶,
56
+ * 跨会话隔离直接失效(现场取证:facts 表的键就是 "unknown")。调用方拿到 null 必须
57
+ * 「不写跨会话表」,宁可不记也不要串味。
58
+ */
52
59
  export function projectKeyOf(cwd) {
53
60
  const normalized = clip(cwd, 240).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
54
61
  if (!normalized)
55
- return "unknown";
62
+ return null;
56
63
  return fnv1a32(normalized).toString(16).padStart(8, "0");
57
64
  }
58
65
  /** 从工具入参归一化契约(截断 + 去重 + 上限)。 */
@@ -143,8 +150,8 @@ export function renderContract(contract, delivery = false) {
143
150
  lines.push(`目标:${contract.goal}`);
144
151
  if (contract.scope.length > 0)
145
152
  lines.push(`范围:${contract.scope.join(";")}`);
146
- if (contract.expectCount !== null || contract.actualCount !== null) {
147
- const expect = contract.expectCount === null ? "?" : contract.expectCount;
153
+ if (true) {
154
+ const expect = contract.expectCount === null ? "未估" : contract.expectCount;
148
155
  const actual = contract.actualCount === null ? "未回填" : contract.actualCount;
149
156
  lines.push(`数量:预计 ${expect} → 实际 ${actual}`);
150
157
  }
@@ -15,7 +15,7 @@ export function buildContractMethodDirective() {
15
15
  "〔先量化后动手〕本轮是任务型请求。开工前先写任务契约(lume_contract):",
16
16
  "- 目标:一句话,可观察的结果(不是「优化一下」这种动词)",
17
17
  "- 范围:精确到路径 / 模块 / 章节 / 表",
18
- "- 数量:先估一个数,探索后回填实际值——交付时要用实际数量对账",
18
+ "- 数量:必填,先估一个数,探索后回填实际值(实在无法估时传 0 并说明理由)——交付时要用实际数量对账",
19
19
  "- 完成判据:可执行、可核对(命令 / 回读 / 对照),不是「改完」",
20
20
  "- 非目标:明确不动什么,防止范围蔓延",
21
21
  "- 待确认:只列真正阻塞的(≤2 个);不阻塞的按默认假设前进并写明假设",
@@ -36,6 +36,7 @@ export function buildImpactDirective() {
36
36
  return [
37
37
  "〔改动影响面〕动手前列出:要改的符号 → 谁调用它、它实现或被实现于谁、配置或 SQL 映射、前端/模板引用;并标出「不打算改但需一并确认」的位置。",
38
38
  "每处改动写明验证方式;同一文件的相关改动一次做完,不要反复回来改同一个文件。",
39
+ "每改完一处就用 lume_change 记一条(target=文件/符号、change=改了什么、verify=怎么验、status=done),验完推进到 verified;台账积着未验证项时会有提醒。",
39
40
  ].join("\n");
40
41
  }
41
42
  /** 环境里有结构分析/符号工具时的一句提示:用符号级定位替代通篇 read。 */
@@ -8,6 +8,15 @@ const MODE_RULES = {
8
8
  // 显式请求标记后必须紧跟一个动作动词,且限制在同一小句内(旧版用 `.*` 贪婪跨越
9
9
  // 整句,导致「是什么驱动你去这么做的」也被判成执行)。句首祈使不再放行「做」:
10
10
  // 「做一件事…」这类名词化表述是讨论而非执行。
11
+ /**
12
+ * 执行动词的补充表(0.7.4)。
13
+ *
14
+ * 现场实测:`EXECUTE_RE` 不含「重构 / 梳理 / 删掉 / 合并 / 迁移」等常见动词,于是
15
+ * 「帮我重构订单退费链路」被判成**问答**——方法层(影响面清单)与阶段门控都不生效,
16
+ * 而那次会话确实改了 11 个文件。这里只补「对产物动手」的动作,**不含诊断类动词**
17
+ * (分析/诊断/排查属于 diagnosis,混进来会把讨论与排查误判成执行)。
18
+ */
19
+ const EXECUTE_EXTRA_RE = /重构|重写|梳理|清理|删掉|删除|去掉|移除|合并|合入|迁移|替换|收口|落地|接入|适配|升级|降级|补上|补齐|加上|改成|改为|换成|拆开|拆出/;
11
20
  const EXECUTE_RE = new RegExp([
12
21
  "(?:请|帮我|帮忙|直接|把|给我|替我|麻烦|需要你)\\s*[^,。!?;\\n]{0,24}?(?:做|改|修|写|加|删|建|跑|执行|完成|实现|优化|更新|部署|安装|迁移|提交|发布|检查|核对|补|替换|重命名|合并|回滚|加上)",
13
22
  "^(?:改|修|写|加|删|建|跑|执行|完成|实现|优化|更新|部署|安装|迁移|提交|发布|检查|补|替换|重命名|合并|回滚)",
@@ -23,7 +32,7 @@ export function classifyInteraction(text) {
23
32
  const query = String(text ?? "").trim();
24
33
  if (!query)
25
34
  return "question";
26
- if (EXECUTE_RE.test(query))
35
+ if ((EXECUTE_RE.test(query) || EXECUTE_EXTRA_RE.test(query)))
27
36
  return "execute";
28
37
  if (DIAGNOSIS_RE.test(query))
29
38
  return "diagnosis";
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Connection RPC 的**桥接协议**实现(纯函数 + 一个路由工厂)。
3
+ *
4
+ * 为什么需要它:宿主 `connection.rpc.handle(channel, handler)` 内部要求
5
+ * 「频道注册属于**调用方 fiber**」(见 dsh-client-connection 的 `register(owner, …)` →
6
+ * `owner.effect(() => owner.webServer.register(route))`),在 DSH Desktop 0.9.1 上我们的
7
+ * 入口 fiber 解析不到 `webServer` → 抛 `cannot get property "webServer" without inject`。
8
+ * 于是准备一条**不依赖 connection.rpc 的回退路径**:照宿主自己的写法把 HTTP 路由挂到
9
+ * `webServer` 上(宿主自带的 dsh-ppt 就是这么做的),报文与客户端 `conn.rpc.call` 完全一致:
10
+ *
11
+ * 请求 POST {channel}/{endpoint} content-type: application/json
12
+ * { type: "client-request", rpcId, method: endpoint, payload }
13
+ * 响应 200 { type: "server-response", rpcId, result: { ok, value|error } }
14
+ *
15
+ * 纯函数部分(解析/组装)可单测,路由工厂只做 Node req/res 的胶水。
16
+ */
17
+ /** 端点段白名单,与宿主 ENDPOINT_SEGMENT_PATTERN 一致。 */
18
+ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/;
19
+ /** 组装一条回信(含 rpcId 与错误信封补全)。 */
20
+ export function connectionResponse(rpcId, result) {
21
+ return JSON.stringify({ type: "server-response", rpcId, result: withErrorDetails(result) });
22
+ }
23
+ /**
24
+ * 补全错误信封:客户端的 `parseConnectionResponse` 要求失败时
25
+ * `error.details` 是对象,缺失会直接抛 "invalid server-response failure"。
26
+ * (这是 0.7.2 之前一直存在的隐患:任何错误路径都会让客户端调用炸掉。)
27
+ */
28
+ export function withErrorDetails(result) {
29
+ const envelope = result;
30
+ if (!envelope || typeof envelope !== "object" || envelope.ok !== false)
31
+ return result;
32
+ const error = envelope.error ?? {};
33
+ return {
34
+ ok: false,
35
+ error: {
36
+ code: typeof error.code === "string" ? error.code : "bad-request",
37
+ message: typeof error.message === "string" ? error.message : "unknown error",
38
+ details: error.details && typeof error.details === "object" ? error.details : {},
39
+ },
40
+ };
41
+ }
42
+ /** 解析客户端请求。与宿主的 rpcFetchHandler 语义对齐(404/415/400 + 信封校验)。 */
43
+ export function parseClientRequest(channel, request) {
44
+ if ((request.method ?? "GET").toUpperCase() !== "POST")
45
+ return { kind: "respond", status: 404, body: "not found", contentType: "text/plain" };
46
+ const pathname = (request.url ?? "/").split("?")[0] ?? "/";
47
+ if (!pathname.startsWith(`${channel}/`))
48
+ return { kind: "respond", status: 404, body: "not found", contentType: "text/plain" };
49
+ const endpoint = pathname.slice(channel.length + 1);
50
+ if (endpoint.split("/").some((segment) => segment === "" || segment === "." || segment === ".." || !ENDPOINT_SEGMENT_PATTERN.test(segment))) {
51
+ return { kind: "respond", status: 404, body: "not found", contentType: "text/plain" };
52
+ }
53
+ const mime = (request.contentType ?? "").split(";")[0]?.trim().toLowerCase();
54
+ if (mime !== "application/json")
55
+ return { kind: "respond", status: 415, body: "content type must be application/json", contentType: "text/plain" };
56
+ let body;
57
+ try {
58
+ body = JSON.parse(request.rawBody);
59
+ }
60
+ catch {
61
+ return { kind: "respond", status: 400, body: "body is not JSON", contentType: "text/plain" };
62
+ }
63
+ const envelope = body;
64
+ if (!envelope || typeof envelope !== "object" || envelope.type !== "client-request" || typeof envelope.rpcId !== "string" || typeof envelope.method !== "string") {
65
+ const rpcId = typeof envelope?.rpcId === "string" ? String(envelope.rpcId) : "invalid-request";
66
+ return { kind: "respond", status: 200, body: connectionResponse(rpcId, { ok: false, error: { code: "gateway/bad-request", message: "invalid client-request message" } }), contentType: "application/json" };
67
+ }
68
+ if (envelope.method !== endpoint) {
69
+ return { kind: "respond", status: 200, body: connectionResponse(envelope.rpcId, { ok: false, error: { code: "gateway/bad-request", message: `endpoint mismatch: ${envelope.method} vs ${endpoint}` } }), contentType: "application/json" };
70
+ }
71
+ return { kind: "dispatch", rpcId: envelope.rpcId, endpoint, payload: envelope.payload };
72
+ }
73
+ /**
74
+ * 组装可交给 `webServer.register()` 的路由(宿主自带 dsh-ppt 同形:
75
+ * `{ kind: "prefix", path: channel, handler(req, res) }`)。
76
+ *
77
+ * @param channel - RPC 频道(须匹配 /^\/[A-Za-z0-9._~-]+$/,且不得为 /api)
78
+ * @param dispatch - 业务分发(endpoint + payload → lume 信封)
79
+ * @param guard - 可选的浏览器信任闸(宿主 connection.requestRejection),返回状态码即拒绝
80
+ */
81
+ export function makeRpcRoute(channel, dispatch, guard) {
82
+ return {
83
+ kind: "prefix",
84
+ path: channel,
85
+ handler: async (req, res) => {
86
+ const rejection = guard?.(req);
87
+ if (rejection !== undefined) {
88
+ res.writeHead(rejection);
89
+ res.end(rejection === 401 ? "unauthorized" : "forbidden");
90
+ return;
91
+ }
92
+ let rawBody = "";
93
+ try {
94
+ for await (const chunk of req)
95
+ rawBody += typeof chunk === "string" ? chunk : String(chunk);
96
+ }
97
+ catch {
98
+ /* 客户端断开:下面按空 body 处理并回 400 */
99
+ }
100
+ const parsed = parseClientRequest(channel, {
101
+ method: req?.method,
102
+ url: req?.url,
103
+ contentType: req?.headers?.["content-type"] ?? null,
104
+ rawBody,
105
+ });
106
+ if (parsed.kind === "respond") {
107
+ res.writeHead(parsed.status, { "content-type": parsed.contentType });
108
+ res.end(parsed.body);
109
+ return;
110
+ }
111
+ let result;
112
+ try {
113
+ result = await dispatch(parsed.endpoint, parsed.payload);
114
+ }
115
+ catch (error) {
116
+ result = { ok: false, error: { code: "internal", message: String(error?.message ?? error) } };
117
+ }
118
+ res.writeHead(200, { "content-type": "application/json" });
119
+ res.end(connectionResponse(parsed.rpcId, result));
120
+ },
121
+ };
122
+ }
@@ -23,6 +23,7 @@ function defaultRuntime() {
23
23
  interactionMode: "question",
24
24
  intent: null,
25
25
  stableDigest: null,
26
+ cwd: null,
26
27
  projectKey: null,
27
28
  toolKind: "other",
28
29
  triggerCounters: newTriggerCounters(),
@@ -58,7 +58,7 @@ export function applyVerifyOutcome(counters, kind, signals) {
58
58
  }
59
59
  export const DEFAULT_TRIGGER_THRESHOLDS = {
60
60
  inspectStreak: 12,
61
- changeStreak: 6,
61
+ changeStreak: 4,
62
62
  deadPathFails: 3,
63
63
  knowledgeSteps: 20,
64
64
  };
@@ -74,7 +74,7 @@ export function evaluateToolTrigger(counters, ctx, thresholds = DEFAULT_TRIGGER_
74
74
  if (dead === "env") {
75
75
  return {
76
76
  id: "dead-path",
77
- text: `〔验证降级〕同一环境已连续 ${n} 次验证失败,其中 ${m} 次是环境或依赖不可用(不是代码问题)。停止重复同一条命令,换降级阶梯:① 能用的编译器/测试 → ② 语法检查(parser / typecheck)→ ③ 静态交叉引用(谁调用它、它调用谁、配置与 SQL 绑定)→ ④ 手工走读并列出风险点。交付时明确写「本环境无法完成构建验证」。`,
77
+ text: `〔验证降级〕同一环境已连续 ${n} 次验证失败,其中 ${m} 次是环境或依赖不可用(不是代码问题)。停止重复同一条命令,换降级阶梯:① 能用的编译器/测试 → ② 语法检查(parser / typecheck)→ ③ 静态交叉引用(谁调用它、它调用谁、配置与 SQL 绑定)→ ④ 手工走读并列出风险点。同时用 lume_project_note(kind=deadend)把这条环境死路记进项目知识——下次会话不必重踩。交付时明确写「本环境无法完成构建验证」。`,
78
78
  };
79
79
  }
80
80
  return {
@@ -82,10 +82,10 @@ export function evaluateToolTrigger(counters, ctx, thresholds = DEFAULT_TRIGGER_
82
82
  text: `〔死路提醒〕同一验证已连续失败 ${n} 次。先归因(输入 / 逻辑 / 接口 / 环境 / 权限),把结论写进假设台账:证实的标 confirmed、排除的标 excluded(用 lume_hypothesis)——已排除的假设不要再试。换一个方案再动手。`,
83
83
  };
84
84
  }
85
- if (counters.mutateStreak >= thresholds.changeStreak) {
85
+ if (counters.mutateStreak >= thresholds.changeStreak || ctx.unverifiedChanges >= thresholds.changeStreak) {
86
86
  return {
87
87
  id: "verify-as-you-go",
88
- text: `〔增量验证〕已连续 ${counters.mutateStreak} 次改动,中间没有任何验证动作。改一处验一处:现在先跑一次最小验证(编译 / 语法检查 / 回读改动区域),确认前一批改动真的生效,再继续下一批。一大批改完再验,失败时无法定位是哪一处的问题。`,
88
+ text: `〔增量验证〕已连续 ${counters.mutateStreak} 次改动、台账里还有 ${ctx.unverifiedChanges} 项未验证。改一处验一处:现在先跑一次最小验证(编译 / 语法检查 / 回读改动区域),确认前一批改动真的生效;验完用 lume_change 把对应条目推进到 verified(只传 target + status 即可)。一大批改完再验,失败时无法定位是哪一处的问题。`,
89
89
  };
90
90
  }
91
91
  if (!ctx.hasContract && ctx.isTask && counters.mutations > 0) {
@@ -100,7 +100,7 @@ export function evaluateToolTrigger(counters, ctx, thresholds = DEFAULT_TRIGGER_
100
100
  text: `〔收敛提醒〕已连续 ${counters.inspectStreak} 次只读探查,还没有产出契约或改动台账。停止撒网式通读,先把链路复述出来——入口 → 数据流 → 影响面(谁调用、被谁调用、配置与 SQL 绑定)——写成改动台账(lume_change)并回填实际数量,然后带着这份清单回去读缺口。`,
101
101
  };
102
102
  }
103
- if (counters.verifyFailStreak > 0 && ctx.diagnosing && ctx.hasHypotheses && !ctx.hypothesesTouched) {
103
+ if (counters.verifyFailStreak > 0 && ctx.diagnosing && !ctx.hypothesesTouched) {
104
104
  return {
105
105
  id: "hypothesis-stale",
106
106
  text: "〔假设台账〕本轮出现了验证失败,但假设状态没有更新。把这次失败归因写进 lume_hypothesis(证实 / 排除 / 新假设),并标出下一步要验的是哪一条——否则同一个假设会被反复试。",
package/lib/index.js CHANGED
@@ -21,6 +21,7 @@ import z from "@deepseek-ai/schemastery";
21
21
  import { buildPersonaContractSection, buildPersonaRuntimeSection } from "./host/injection.js";
22
22
  import { loadPersonalities, NONE_PERSONA } from "./host/personalities.js";
23
23
  import { createLumeRpcHandler } from "./host/rpc.js";
24
+ import { makeRpcRoute } from "./host/rpc-bridge.js";
24
25
  import { FilePersonaStore, migrateLegacyState, PersonaStore } from "./host/store.js";
25
26
  import { IdentityStore, LUME_IDENTITY_SPEC, zodLike } from "./host/identity.js";
26
27
  import { PersonaRegistry } from "./host/registry.js";
@@ -433,8 +434,10 @@ function applyInner(ctx, config = {}) {
433
434
  return;
434
435
  const prompt = buildExtractionPrompt(userText, assistantText, existing.map((f) => f.text));
435
436
  const output = await callLlm(resolveAuxRoute(extractionRouteOverride, llmRoute), prompt.system, prompt.userText, 800);
436
- if (output === null)
437
+ if (output === null) {
438
+ ctx.logger?.warn?.(`lume: 反思跳过(${sid})模型无输出`);
437
439
  return;
440
+ }
438
441
  st.lastExtractionAt = Date.now();
439
442
  const fresh = mergeNewFacts(parseFacts(output), identity.getMemory(personaName));
440
443
  for (const fact of fresh) {
@@ -565,6 +568,16 @@ function applyInner(ctx, config = {}) {
565
568
  // 行为类别在调用阶段记账(连击),成败到结果阶段才结算。
566
569
  st.toolKind = classifyTool(event.data?.name);
567
570
  applyToolSignal(st.triggerCounters, st.toolKind, null);
571
+ // 自动改动台账:mutate 类工具一被调用就先记一条——实测模型几乎不会主动调 lume_change
572
+ // (4 个会话里 0 次),而 edit/write 每次会话几十次。载具必须由插件自己落账,
573
+ // 否则「改动台账」永远空着(这正是上一版没生效的地方)。
574
+ if (projectMemoryOn && st.toolKind === "mutate") {
575
+ const target = targetPathFromToolEvent(event.data);
576
+ if (target) {
577
+ const toolName = String(event.data?.name ?? "tool");
578
+ void projectReady.then((store) => store?.upsertChange(sid, { target, change: `(自动)由 ${toolName} 修改`, why: "", verify: "", status: "done", at: Date.now() }));
579
+ }
580
+ }
568
581
  break;
569
582
  }
570
583
  case "tool/result": {
@@ -590,7 +603,7 @@ function applyInner(ctx, config = {}) {
590
603
  isTask: isTaskQuery(st),
591
604
  diagnosing: st.interactionMode === "diagnosis",
592
605
  hasContract: contractOf(sid) !== null,
593
- hasHypotheses: hypothesesOf(sid).length > 0,
606
+ unverifiedChanges: changesOf(sid).filter((item) => item.status !== "verified" && item.status !== "skipped").length,
594
607
  hypothesesTouched: st.hypothesesTouched,
595
608
  }, triggerThresholds);
596
609
  if (fire && cooldownOk(st.triggerFiredAt[fire.id], st.turnIndex)) {
@@ -600,7 +613,8 @@ function applyInner(ctx, config = {}) {
600
613
  // 环境性死路自动落成项目知识:下次会话不必重踩。
601
614
  if (fire.id === "dead-path" && signals.env && project) {
602
615
  const key = projectKeyFor(sid, session);
603
- void project.addFact(key, normalizeProjectFact({ kind: "deadend", text: `本环境验证受阻(${st.triggerCounters.verifyFailStreak} 次连续失败,环境/依赖类):换降级阶梯,不要重复同一命令` }, Date.now()), (candidate, existing) => existing.some((fact) => fact.text === candidate));
616
+ if (key)
617
+ void project.addFact(key, normalizeProjectFact({ kind: "deadend", text: `本环境验证受阻(${st.triggerCounters.verifyFailStreak} 次连续失败,环境/依赖类):换降级阶梯,不要重复同一命令` }, Date.now()), (candidate, existing) => existing.some((fact) => fact.text === candidate));
604
618
  }
605
619
  }
606
620
  }
@@ -715,12 +729,22 @@ function applyInner(ctx, config = {}) {
715
729
  void projectReady.then((store) => store?.clearSession(sid));
716
730
  // 反思日志:会话结束后空闲时间跑一次小模型,零用户感知 token。
717
731
  // 历史不够长(< 4 条消息)或路由不可用时静默跳过。
732
+ if (reflectionEnabled && turns.length < 4)
733
+ ctx.logger?.warn?.(`lume: 反思跳过(${sid})历史不足:${turns.length} < 4 条消息`);
718
734
  if (reflectionEnabled && turns.length >= 4) {
719
735
  void (async () => {
720
736
  const store = await reflectionReady;
737
+ if (!store) {
738
+ ctx.logger?.warn?.(`lume: 反思跳过(${sid})reflection 域不可用`);
739
+ return;
740
+ }
721
741
  if (!store)
722
742
  return;
723
743
  const route = resolveAuxRoute({}, llmRoute);
744
+ if (!route) {
745
+ ctx.logger?.warn?.(`lume: 反思跳过(${sid})无可用小模型路由`);
746
+ return;
747
+ }
724
748
  if (!route)
725
749
  return;
726
750
  const prompt = buildReflectionPrompt(turns);
@@ -728,8 +752,10 @@ function applyInner(ctx, config = {}) {
728
752
  if (output === null)
729
753
  return;
730
754
  const score = parseReflectionScore(output);
731
- if (!score)
755
+ if (!score) {
756
+ ctx.logger?.warn?.(`lume: 反思跳过(${sid})评分解析失败`);
732
757
  return;
758
+ }
733
759
  await store.log(sid, score);
734
760
  ctx.logger?.warn?.(`lume: 反思日志 ${sid} context=${score.context} planning=${score.planning} verification=${score.verification} review=${score.review} diagnosis=${score.diagnosis}「${score.note}」`);
735
761
  })();
@@ -744,12 +770,24 @@ function applyInner(ctx, config = {}) {
744
770
  // 三种调用来源:提示词 context({agent:{session}})、工具 exec({agent:{session}})、
745
771
  // 会话事件(session 本身)。统一取到 session 再读 cwd。
746
772
  const session = source?.agent?.session ?? source?.session ?? source;
747
- const cwd = session?.cwd ?? "";
773
+ const cwd = String(session?.cwd || st.cwd || "");
748
774
  const key = projectKeyOf(cwd);
749
775
  // 只有拿到真实工作目录才缓存:否则一次无 cwd 的调用会把 "unknown" 固化下来。
750
- if (cwd)
776
+ if (cwd && key)
751
777
  st.projectKey = key;
752
- return key;
778
+ return st.projectKey ?? key;
779
+ }
780
+ /** 从工具入参里取目标路径(自动改动台账用):兼容常见字段名,取不到返回 null——宁可少记,不要记错。 */
781
+ function targetPathFromToolEvent(data) {
782
+ const args = data?.args ?? data?.input ?? data?.parameters ?? data?.arguments;
783
+ if (!args || typeof args !== "object")
784
+ return null;
785
+ for (const key of ["path", "file_path", "filePath", "file", "filename", "target", "notebook_path"]) {
786
+ const value = args[key];
787
+ if (typeof value === "string" && value.trim())
788
+ return value.trim().slice(0, 120);
789
+ }
790
+ return null;
753
791
  }
754
792
  function isTaskQuery(st) {
755
793
  return TASK_SIGNAL_RE.test(st.intent?.text ?? st.userText ?? "");
@@ -764,7 +802,8 @@ function applyInner(ctx, config = {}) {
764
802
  return project?.getHypotheses(sid) ?? [];
765
803
  }
766
804
  function factsOf(sid, context) {
767
- return project ? project.getFacts(projectKeyFor(sid, context)) : [];
805
+ const projectKey = projectKeyFor(sid, context);
806
+ return project && projectKey ? project.getFacts(projectKey) : [];
768
807
  }
769
808
  /** 环境里是否有符号级结构分析工具:有就让模型用它替代通篇 read。 */
770
809
  function structureToolName(context) {
@@ -793,7 +832,7 @@ function applyInner(ctx, config = {}) {
793
832
  function carrierBlocks(sid, context, st, query, mode) {
794
833
  if (!projectMemoryOn)
795
834
  return [];
796
- const isTask = TASK_SIGNAL_RE.test(query);
835
+ const isTask = mode !== "question" || TASK_SIGNAL_RE.test(query);
797
836
  const contract = contractOf(sid);
798
837
  const changes = changesOf(sid);
799
838
  const docDirective = buildDocumentDirective({ query, capabilities: probeDocumentCapabilities(ctx.get("tools"), context?.agent) });
@@ -899,7 +938,7 @@ function applyInner(ctx, config = {}) {
899
938
  parameters: {
900
939
  goal: { type: "string", description: "目标:一句话、可观察的结果" },
901
940
  scope: { type: "string", description: "范围:路径/模块/章节,分号或换行分隔" },
902
- expectCount: { type: "number", description: "预计数量(探索前先估)" },
941
+ expectCount: { type: "number", required: true, description: "预计数量(探索前先估)" },
903
942
  actualCount: { type: "number", description: "实际数量(探索后回填)" },
904
943
  criteria: { type: "string", description: "完成判据:可执行、可核对,分号或换行分隔" },
905
944
  nonGoals: { type: "string", description: "非目标:明确不动的东西,分号分隔" },
@@ -1025,7 +1064,13 @@ function applyInner(ctx, config = {}) {
1025
1064
  const fact = normalizeProjectFact({ kind: args.kind, text: args.text }, Date.now());
1026
1065
  if (!fact)
1027
1066
  throw new Error("lume_project_note requires text");
1028
- await project.addFact(projectKeyFor(sid, { agent: exec?.agent }), fact, (candidate, existing) => existing.some((entry) => jaccard(entry.text, candidate) >= 0.7));
1067
+ const projectKey = projectKeyFor(sid, { agent: exec?.agent });
1068
+ if (!projectKey) {
1069
+ // 拿不到工作目录:不写跨会话表——写一次就会把不同项目的知识串进同一个键(现场事故:facts 的键曾是 "unknown")
1070
+ ctx.logger?.warn?.(`lume: [${sid}] 项目知识未落盘(无法确定工作目录)`);
1071
+ return { ok: true };
1072
+ }
1073
+ await project.addFact(projectKey, fact, (candidate, existing) => existing.some((entry) => jaccard(entry.text, candidate) >= 0.7));
1029
1074
  return { ok: true };
1030
1075
  },
1031
1076
  }));
@@ -1042,6 +1087,8 @@ function applyInner(ctx, config = {}) {
1042
1087
  */
1043
1088
  function resolveIntent(context, st) {
1044
1089
  const session = context?.agent?.session;
1090
+ if (typeof session?.cwd === "string" && session.cwd)
1091
+ st.cwd = session.cwd;
1045
1092
  const messages = typeof session?.deriveMessages === "function" ? session.deriveMessages() : [];
1046
1093
  let text = null;
1047
1094
  let messageId = "";
@@ -1089,6 +1136,9 @@ function applyInner(ctx, config = {}) {
1089
1136
  // 交付复核、压缩重锚、文档能力指引、失败纠偏、反思提醒——全部每步可变。
1090
1137
  // 载具与方法块(契约/台账/假设/项目知识/影响面/文档方法/触发器提醒)排在最后:
1091
1138
  // 它们是「此刻最该看的」,紧贴尾部注意力最强位;超预算时先丢可丢块(composeBlocks)。
1139
+ // 记录工作目录:工具 exec / 会话事件里可能拿不到 cwd,项目键靠这里缓存兜住(实测项目知识曾落到 unknown)
1140
+ if (typeof context?.agent?.session?.cwd === "string" && context.agent.session.cwd)
1141
+ st.cwd = context.agent.session.cwd;
1092
1142
  const thinkingRuntime = composeBlocks([
1093
1143
  { text: buildInteractionDirective(mode) },
1094
1144
  { text: buildTaskPhaseDirective(st.taskPhase) },
@@ -1290,22 +1340,57 @@ function applyInner(ctx, config = {}) {
1290
1340
  * inject 回调里调用(不包 effect)。没有 web 载体的宿主(headless)只是没有 RPC 通道。
1291
1341
  */
1292
1342
  function registerRpcChannel(scope) {
1293
- scope.inject(["webServer"], (webCtx) => {
1343
+ const dispatch = async (endpoint, payload) => {
1344
+ currentStore ??= await storesReady;
1345
+ identity ??= await identityReady;
1346
+ return handleEndpoint(endpoint, payload);
1347
+ };
1348
+ // 依赖组合与宿主自带 dsh-api-gateway 一致(["connection", "webServer"])。
1349
+ scope.inject(["connection", "webServer"], (webCtx) => {
1350
+ const notes = [];
1294
1351
  try {
1352
+ // ── 主路径:宿主公开 API。频道注册归属**调用方 fiber**,所以必须在注入作用域里调用。
1295
1353
  webCtx.connection.rpc.handle(LUME_CHANNEL, async (endpoint, payload) => {
1296
- currentStore ??= await storesReady;
1297
- identity ??= await identityReady;
1298
- const result = await handleEndpoint(endpoint, payload);
1354
+ const result = await dispatch(endpoint, payload);
1299
1355
  if (endpoint !== "list" && endpoint !== "getSessionPersona") {
1300
- webCtx.logger?.warn?.(`lume: rpc ${endpoint} ${JSON.stringify(payload ?? {})} → ok=${result.ok}${result.ok ? "" : ` code=${result.error.code}`}`);
1356
+ webCtx.logger?.warn?.(`lume: rpc ${endpoint} → ok=${result.ok}${result.ok ? "" : ` code=${result.error.code}`}`);
1301
1357
  }
1302
1358
  return result;
1303
1359
  }, { authority: "trusted-host" });
1360
+ notes.push("connection.rpc.handle");
1304
1361
  }
1305
1362
  catch (error) {
1306
- // 局部兜底:RPC 通道起不来只是菜单不可用,人设注入与工具必须照常。
1307
- webCtx.logger?.error?.("lume: RPC 通道注册失败(仅客户端菜单不可用,其余功能不受影响)", error);
1363
+ notes.push(`rpc.handle 失败(${describeError(error)})`);
1364
+ // ── 回退:自己往 webServer 注册 HTTP 路由(宿主自带 dsh-ppt 的写法)。
1365
+ // 报文与客户端 conn.rpc.call 完全一致,见 host/rpc-bridge.ts。
1366
+ try {
1367
+ const guard = typeof webCtx.connection?.requestRejection === "function"
1368
+ ? (req) => webCtx.connection.requestRejection(req)
1369
+ : undefined;
1370
+ webCtx.webServer.register(makeRpcRoute(LUME_CHANNEL, dispatch, guard));
1371
+ notes.push("webServer.register(自注册路由)");
1372
+ }
1373
+ catch (fallbackError) {
1374
+ notes.push(`webServer.register 失败(${describeError(fallbackError)})`);
1375
+ }
1376
+ }
1377
+ // 两条都失败时补上环境形状,便于下一轮定位(宿主 API 变更时这行就是证据)。
1378
+ if (!notes.some((note) => !note.includes("失败"))) {
1379
+ notes.push(`shapes: connection=${typeof webCtx.connection} rpc=${typeof webCtx.connection?.rpc} handle=${typeof webCtx.connection?.rpc?.handle} webServer=${typeof webCtx.webServer} get=${typeof webCtx.get}`);
1308
1380
  }
1381
+ webCtx.logger?.warn?.(`lume: RPC 通道 ${LUME_CHANNEL} = ${notes.join(" | ")}`);
1309
1382
  });
1310
1383
  }
1384
+ /** 把任意抛出物变成可读的一行(宿主 logger 直接打对象会变成 `{}`,所以要自己转字符串)。 */
1385
+ function describeError(error) {
1386
+ if (error instanceof Error)
1387
+ return error.message;
1388
+ try {
1389
+ const text = JSON.stringify(error);
1390
+ return text && text !== "{}" ? text : String(error);
1391
+ }
1392
+ catch {
1393
+ return String(error);
1394
+ }
1395
+ }
1311
1396
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "lume-dsh-plugin",
3
3
  "description": "微光 (Lume) — DSH Desktop 增强插件:给会话装上工程纪律与真实关系。纪律层约束「如何正确完成任务」——意图路由、阶段门控、真实工具证据、交付前复核、文档能力感知;方法层把量化需求、改动台账、假设台账与项目知识变成可检查的产出,并用行为触发器在轨迹上纠偏(撒网不收敛 / 连写不验 / 死路重撞);人设层塑造「以何种风格表达」——从聊天记录、小说、剧本、设定文档蒸馏具名角色,长期记忆与风格随对话演进。约束按需注入,闲聊不额外付 token。",
4
- "version": "0.7.2",
4
+ "version": "0.7.4",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "exports": {
@@ -65,7 +65,10 @@
65
65
  "build": "tsc -p tsconfig.build.json && tsdown",
66
66
  "bundle": "tsdown",
67
67
  "watch": "tsdown --watch",
68
- "test": "vitest run"
68
+ "test": "vitest run",
69
+ "release:check": "npm run build && node scripts/release-check.mjs",
70
+ "release:publish": "node scripts/publish.mjs",
71
+ "release:audit": "node scripts/release-check.mjs --published"
69
72
  },
70
73
  "devDependencies": {
71
74
  "@deepseek-ai/cordis": "^4.0.1",