lume-dsh-plugin 0.7.4 → 0.8.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +460 -0
  2. package/README.md +245 -275
  3. package/lib/client.js +242 -181
  4. package/lib/core/card.js +11 -9
  5. package/lib/core/citations.js +235 -0
  6. package/lib/core/coverage.js +149 -0
  7. package/lib/core/dialogue-mining.js +49 -11
  8. package/lib/core/knowledge.js +165 -0
  9. package/lib/core/leak-detector.js +1 -3
  10. package/lib/core/ledger.js +118 -14
  11. package/lib/core/manifest.js +3 -1
  12. package/lib/core/memory-id.js +105 -0
  13. package/lib/core/metrics.js +270 -0
  14. package/lib/core/persona-limits.js +25 -0
  15. package/lib/core/scope.js +124 -0
  16. package/lib/core/signals.js +285 -5
  17. package/lib/core/task-memory.js +143 -0
  18. package/lib/core/text.js +45 -3
  19. package/lib/host/backfill.js +218 -0
  20. package/lib/host/bootstrap.js +130 -0
  21. package/lib/host/boundary.js +1 -3
  22. package/lib/host/clauses.js +180 -0
  23. package/lib/host/config.js +7 -0
  24. package/lib/host/diag.js +44 -7
  25. package/lib/host/distill-prompt.js +365 -0
  26. package/lib/host/distill.js +22 -348
  27. package/lib/host/extraction.js +11 -3
  28. package/lib/host/host-context.js +1 -0
  29. package/lib/host/host-events.js +100 -0
  30. package/lib/host/identity.js +12 -29
  31. package/lib/host/inbound.js +133 -0
  32. package/lib/host/injection.js +2 -6
  33. package/lib/host/llm-aux.js +130 -0
  34. package/lib/host/llm-route.js +3 -0
  35. package/lib/host/methods.js +180 -10
  36. package/lib/host/metrics-log.js +169 -0
  37. package/lib/host/notices.js +62 -0
  38. package/lib/host/project-access.js +210 -0
  39. package/lib/host/project.js +153 -2
  40. package/lib/host/prompt-blocks.js +113 -0
  41. package/lib/host/protocol.js +142 -18
  42. package/lib/host/reflection.js +28 -5
  43. package/lib/host/registry.js +0 -4
  44. package/lib/host/requirements-scan.js +108 -0
  45. package/lib/host/rpc-bridge.js +23 -4
  46. package/lib/host/rpc.js +1 -1
  47. package/lib/host/sections.js +48 -0
  48. package/lib/host/session-deps.js +27 -0
  49. package/lib/host/session-events.js +371 -0
  50. package/lib/host/session-runtime.js +17 -5
  51. package/lib/host/thinking.js +10 -1
  52. package/lib/host/tools.js +367 -0
  53. package/lib/host/triggers.js +25 -1
  54. package/lib/host/turn-boundary.js +116 -0
  55. package/lib/host/wiring.js +280 -0
  56. package/lib/host/workspace-map.js +81 -0
  57. package/lib/index.js +326 -877
  58. package/package.json +11 -5
@@ -0,0 +1,169 @@
1
+ /**
2
+ * 度量的落地与读取(0.8.x):一份 JSONL + 一个进程内环形缓冲。
3
+ *
4
+ * 为什么不用数据库/存储域:度量是**诊断通道**,不是用户资产。它要满足三条:
5
+ * ① 宿主没提供落点(没有 DSH_HOME)时静默跳过、不影响功能;
6
+ * ② 单行追加、永不回读失败(用户随时可以 `type` 出来看,也可以直接删);
7
+ * ③ 写失败绝不阻断对话(与 diag.ts 同一套纪律)。
8
+ * 存储域那边表名/事务/迁移的代价,对「一行一条事实」的用法全是负担。
9
+ *
10
+ * 环形缓冲的意义:模型要用度量做自校(lume_metrics 工具、纠正率过高时顶一句提醒),
11
+ * 而每次读文件太贵——缓冲保最近 N 条,聚合在内存里做(core/metrics.ts 是纯函数)。
12
+ */
13
+ import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { EFFICACY_WINDOW_TURNS, formatMetricsSummary, parseMetricLines, summarizeMetrics, toMetricLine, triggerExpect, } from "../core/metrics.js";
16
+ import { appendLumeLineAt, lumeLogHome } from "./diag.js";
17
+ export const LUME_METRICS_FILE = "lume-metrics.jsonl";
18
+ /** 环形缓冲上限:一次会话的步数远小于它,跨几个会话也够看趋势。 */
19
+ export const METRIC_RING = 800;
20
+ /** 回读上限(字节):只读文件尾部,避免长年累积把启动拖慢。 */
21
+ const READ_TAIL_BYTES = 512 * 1024;
22
+ /**
23
+ * 攒够多少条就强制刷盘。
24
+ *
25
+ * 为什么必须攒批(2026-09-24 实测):块装配记录是**每次构建提示词**都会写的,
26
+ * 一步里可能构建好几次;原来是每条一次同步 appendFileSync,热路径上非常贵——
27
+ * 本仓最重的 apply-carriers 用例因此从 3.0s 涨到超过 5s 超时。改成缓冲 + 批量写。
28
+ */
29
+ const FLUSH_AT = 24;
30
+ export function createMetricsLog(opts = {}) {
31
+ const enabled = opts.enabled ?? true;
32
+ const ringSize = opts.ring ?? METRIC_RING;
33
+ const ring = [];
34
+ // 落点:生产走自动探测;测试/自检可以显式指定(否则测试数据会混进真实指标文件)。
35
+ const home = opts.home ?? lumeLogHome();
36
+ const path = home ? join(home, LUME_METRICS_FILE) : null;
37
+ let healthCache = null;
38
+ /** 待落盘的 JSONL 行(攒批写;进程若中途死掉最多丢一轮)。 */
39
+ let pending = [];
40
+ /** 真正落盘:一次 append 写多行。失败静默(诊断通道纪律)。 */
41
+ const flush = () => {
42
+ if (!enabled || !home || pending.length === 0) {
43
+ pending = [];
44
+ return;
45
+ }
46
+ const lines = pending.join("\n");
47
+ pending = [];
48
+ appendLumeLineAt(home, LUME_METRICS_FILE, lines);
49
+ };
50
+ const record = (incoming) => {
51
+ if (!enabled)
52
+ return;
53
+ // 归一(写入侧唯一一处):
54
+ // ① 触发器记录补 expect——调用点只报「谁命中了」,预期行为变化由 TRIGGER_EXPECT 定义;
55
+ // ② 同轮同类的结果信号(越权改动在一步里能连触发十几次)只留第一条,否则计数被灌水。
56
+ const entry = incoming.kind === "trigger" && !incoming.expect ? { ...incoming, expect: triggerExpect(incoming.id) } : incoming;
57
+ const last = ring[ring.length - 1];
58
+ if (entry.kind === "outcome" && last?.kind === "outcome") {
59
+ if (last.sid === entry.sid && last.turn === entry.turn && last.event === entry.event)
60
+ return;
61
+ }
62
+ // 块装配同样去重:一步里提示词会被构建多次,逐次落盘会把环形缓冲冲淡——
63
+ // 缓冲里最该留住的是状态快照与路由判定(效能判定与误判率都靠它们)。
64
+ if (entry.kind === "blocks" && last?.kind === "blocks") {
65
+ const same = last.sid === entry.sid &&
66
+ last.turn === entry.turn &&
67
+ last.kept === entry.kept &&
68
+ last.dropped === entry.dropped &&
69
+ last.chars === entry.chars &&
70
+ last.focus.join(",") === entry.focus.join(",");
71
+ if (same)
72
+ return;
73
+ }
74
+ ring.push(entry);
75
+ if (ring.length > ringSize)
76
+ ring.shift();
77
+ healthCache = null;
78
+ pending.push(toMetricLine(entry));
79
+ // 每轮的状态快照必然出现一次 —— 用它当"本轮结束"的落盘点,避免新增依赖接线;
80
+ // 长轮次(一步里构建多次提示词)则由条数上限兜住。
81
+ if (entry.kind === "state" || pending.length >= FLUSH_AT)
82
+ flush();
83
+ };
84
+ const records = (sid) => (sid ? ring.filter((entry) => entry.sid === sid) : ring);
85
+ const health = (sid) => {
86
+ if (healthCache && healthCache.sid === sid && healthCache.count === ring.length)
87
+ return healthCache.value;
88
+ const value = {
89
+ routes: 0,
90
+ corrections: 0,
91
+ overreach: 0,
92
+ noAction: 0,
93
+ correctionsByMode: {},
94
+ lastCorrectionTurnByMode: {},
95
+ };
96
+ for (const entry of ring) {
97
+ if (entry.sid !== sid)
98
+ continue;
99
+ if (entry.kind === "route")
100
+ value.routes++;
101
+ else if (entry.kind === "outcome") {
102
+ if (entry.event === "user-correction") {
103
+ value.corrections++;
104
+ value.correctionsByMode[entry.mode] = (value.correctionsByMode[entry.mode] ?? 0) + 1;
105
+ value.lastCorrectionTurnByMode[entry.mode] = Math.max(value.lastCorrectionTurnByMode[entry.mode] ?? -1, entry.turn);
106
+ }
107
+ else if (entry.event === "overreach")
108
+ value.overreach++;
109
+ else if (entry.event === "no-action")
110
+ value.noAction++;
111
+ }
112
+ }
113
+ healthCache = { sid, count: ring.length, value };
114
+ return value;
115
+ };
116
+ const loadFromDisk = () => {
117
+ if (!enabled || !path)
118
+ return 0;
119
+ try {
120
+ if (!existsSync(path))
121
+ return 0;
122
+ const size = statSync(path).size;
123
+ // 只读文件尾部:长年累积的度量不该拖慢启动,也不该把内存吃满。
124
+ const length = Math.min(size, READ_TAIL_BYTES);
125
+ const handle = openSync(path, "r");
126
+ let text = "";
127
+ try {
128
+ const buffer = Buffer.alloc(length);
129
+ readSync(handle, buffer, 0, length, size - length);
130
+ text = buffer.toString("utf8");
131
+ }
132
+ finally {
133
+ closeSync(handle);
134
+ }
135
+ const parsed = parseMetricLines(text);
136
+ const recent = parsed.slice(-ringSize);
137
+ ring.length = 0;
138
+ ring.push(...recent);
139
+ healthCache = null;
140
+ return recent.length;
141
+ }
142
+ catch {
143
+ /* 回读失败不影响本轮记录 */
144
+ return 0;
145
+ }
146
+ };
147
+ const summaryTo = (sid) => summarizeMetrics(records(), sid ? { sid } : {});
148
+ return {
149
+ record,
150
+ flush,
151
+ records,
152
+ summary: summaryTo,
153
+ summaryText: (scope) => {
154
+ if (!enabled)
155
+ return "Lume 度量已关闭(config.metrics = false):本次运行没有记录任何事实。";
156
+ const label = scope ? "本会话" : "全部会话(内存中保留的最近记录)";
157
+ const head = formatMetricsSummary(summaryTo(scope), { label });
158
+ const where = path
159
+ ? "落点:" + path + "(一行一条 JSON,可直接看/可删)"
160
+ : "落点不可用:宿主没提供 DSH_HOME / APPDATA,本次只有内存记录(重启即丢)。";
161
+ // 口径自证:样本多大、窗口多长都写清楚——没有口径的数字比没有数字更坏。
162
+ return (head + "\n" + where + ";效能窗口 " + EFFICACY_WINDOW_TURNS + " 轮,纠正率是**代理指标**(用户纠正次数 / 判定次数),不是真值。");
163
+ },
164
+ health,
165
+ loadFromDisk,
166
+ path,
167
+ enabled,
168
+ };
169
+ }
@@ -0,0 +1,62 @@
1
+ /** 各机制的每会话上限(要加机制只改这张表)。 */
2
+ export const NOTICE_CAPS = {
3
+ /** 需求漂移:反复顶会让模型开始躲词而不是解决问题(实测) */
4
+ drift: 2,
5
+ /** 引用-证据对齐 */
6
+ citation: 3,
7
+ /** 断言-证据对齐(没核实就下的否定断言) */
8
+ claim: 2,
9
+ /** 提问核对 */
10
+ question: 2,
11
+ /** 需求覆盖核对 */
12
+ coverage: 2,
13
+ // 上下文预警:warn/critical 各一次就够(反复催会变噪音)
14
+ pressure: 3,
15
+ /** 载具缺口(动了代码但契约/设计都空) */
16
+ carrierGap: 2,
17
+ /** 度量自校:本会话路由被反复纠正时顶一次(上限 2,防噪音) */
18
+ metrics: 2,
19
+ // 以下无上限(靠场景与冷却控制)
20
+ trigger: Number.POSITIVE_INFINITY,
21
+ turn: Number.POSITIVE_INFINITY,
22
+ verifyFail: Number.POSITIVE_INFINITY,
23
+ postTurn: Number.POSITIVE_INFINITY,
24
+ align: Number.POSITIVE_INFINITY,
25
+ protocol: Number.POSITIVE_INFINITY,
26
+ /** 外部(人设/记忆/压缩等)一次性提示 */
27
+ extra: Number.POSITIVE_INFINITY,
28
+ };
29
+ export function noticeSlot(st, id) {
30
+ st.notices[id] ??= { text: null, used: 0 };
31
+ return st.notices[id];
32
+ }
33
+ /** 还能不能顶(上限没到)。 */
34
+ export function noticeOpen(st, id) {
35
+ return noticeSlot(st, id).used < (NOTICE_CAPS[id] ?? Number.POSITIVE_INFINITY);
36
+ }
37
+ export function noticeText(st, id) {
38
+ return st.notices[id]?.text ?? null;
39
+ }
40
+ /** 写入:text 为空 → 清空该槽且不计数;超上限 → 不写(返回 false)。 */
41
+ export function setNotice(st, id, text) {
42
+ const slot = noticeSlot(st, id);
43
+ if (!text) {
44
+ slot.text = null;
45
+ return false;
46
+ }
47
+ if (!noticeOpen(st, id))
48
+ return false;
49
+ slot.text = text;
50
+ slot.used += 1;
51
+ return true;
52
+ }
53
+ /** 不经上限的强制写入(首改定位、验证失败这类一次性提示)。 */
54
+ export function forceNotice(st, id, text) {
55
+ const slot = noticeSlot(st, id);
56
+ slot.text = text ?? null;
57
+ }
58
+ export function clearNotice(st, id) {
59
+ const slot = st.notices[id];
60
+ if (slot)
61
+ slot.text = null;
62
+ }
@@ -0,0 +1,210 @@
1
+ import { DESIGN_SIGNAL_RE } from "./protocol.js";
2
+ import { forceNotice, noticeText } from "./notices.js";
3
+ import { buildTaskMemory } from "../core/task-memory.js";
4
+ export function createProjectAccess(deps) {
5
+ /** 项目键:优先取会话工作目录(跨会话共享同一仓库的知识)。 */
6
+ function projectKeyFor(sid, source) {
7
+ const st = deps.runtime.get(sid);
8
+ if (st.projectKey)
9
+ return st.projectKey;
10
+ // 三种调用来源:提示词 context({agent:{session}})、工具 exec({agent:{session}})、
11
+ // 会话事件(session 本身)。统一取到 session 再读 cwd。
12
+ const session = source?.agent?.session ?? source?.session ?? source;
13
+ const cwd = String(session?.cwd || st.cwd || "");
14
+ const key = deps.projectKeyOf(cwd);
15
+ // 只有拿到真实工作目录才缓存:否则一次无 cwd 的调用会把 "unknown" 固化下来。
16
+ if (cwd && key)
17
+ st.projectKey = key;
18
+ return st.projectKey ?? key;
19
+ }
20
+ /** 命令摘要:验证证据要写进台账,太长的命令只留前 120 字。 */
21
+ function commandSummary(raw) {
22
+ if (!raw)
23
+ return "(未记录命令行)";
24
+ try {
25
+ const parsed = JSON.parse(raw);
26
+ const command = parsed?.command ?? parsed?.cmd ?? parsed?.script;
27
+ if (typeof command === "string" && command.trim())
28
+ return command.trim().replace(/\s+/g, " ").slice(0, 120);
29
+ }
30
+ catch {
31
+ /* 不是 JSON:按原文处理 */
32
+ }
33
+ return raw.replace(/\s+/g, " ").slice(0, 120);
34
+ }
35
+ /**
36
+ * 项目知识补落盘:事件流里拿不到 cwd 时先暂存,等提示词上下文给出 cwd 再补写。
37
+ *
38
+ * 现场代价(0.7.4):模型主动调了 3 次 lume_project_note,全部因为"当时还不知道工作目录"
39
+ * 被丢弃——facts 表里一条都没有。cwd 在同一轮稍后就能拿到,所以丢弃太早、太永久。
40
+ */
41
+ function flushPendingFacts(sid, source) {
42
+ const st = deps.runtime.get(sid);
43
+ if (st.pendingFacts.length === 0)
44
+ return;
45
+ const key = projectKeyFor(sid, source);
46
+ if (!key)
47
+ return;
48
+ const pending = st.pendingFacts.splice(0, st.pendingFacts.length);
49
+ void deps.stores.projectReady
50
+ .then(async (store) => {
51
+ if (!store)
52
+ return;
53
+ let saved = 0;
54
+ for (const fact of pending) {
55
+ const ok = await store.addFact(key, fact, (candidate, existing) => existing.some((entry) => deps.jaccard(entry.text, candidate) >= 0.7));
56
+ if (ok)
57
+ saved++;
58
+ }
59
+ deps.ctx.logger?.warn?.(`lume: [${sid}] 项目知识补落盘 ${saved}/${pending.length} 条 → ${key}`);
60
+ })
61
+ .catch((error) => deps.ctx.logger?.warn?.(`lume: [${sid}] 项目知识补落盘失败:${String(error)}`));
62
+ }
63
+ /**
64
+ * 验证结算(插件侧的「改一处验一处」):成功的**真验证**自动把台账推进到 verified,
65
+ * 真验证失败立刻顶一句先修红。
66
+ *
67
+ * 为什么必须插件做:实测模型 4 个会话 0 次调用 lume_change、0 次推进状态,台账里的
68
+ * 「未验证」于是永远是未验证。判据取**宁窄勿宽**(`git grep` 不算验证),并把证据
69
+ * (命令 + 结果首行)写进 verify 字段,让真假一眼可辨。
70
+ */
71
+ function settleVerification(sid, st, resultText, signals) {
72
+ if (st.toolKind !== "verify" && st.toolKind !== "inspect")
73
+ return;
74
+ const realVerify = st.toolKind === "verify" && deps.isRealVerifyCommand(st.agent.lastToolArgs ?? "");
75
+ const readbackTarget = st.toolKind === "inspect" ? st.agent.lastToolTarget : null;
76
+ if (!realVerify && !readbackTarget)
77
+ return;
78
+ if (signals.failure || signals.unknown) {
79
+ if (realVerify) {
80
+ if (!noticeText(st, "trigger"))
81
+ forceNotice(st, "trigger", `〔验证失败〕刚才那条验证没过(${commandSummary(st.agent.lastToolArgs)})。先定位并修红:看第一条错误属于输入 / 逻辑 / 接口 / 环境哪一类,修完重新验;不要在这个状态上继续扩大改动范围,也不要把动作完成当成验证通过。`);
82
+ deps.ctx.logger?.warn?.(`lume: [${sid}] 真验证失败:${commandSummary(st.agent.lastToolArgs)}`);
83
+ }
84
+ return;
85
+ }
86
+ const changed = changesOf(sid);
87
+ const targets = realVerify ? undefined : [readbackTarget];
88
+ if (!realVerify && !changed.some((item) => item.target === readbackTarget))
89
+ return;
90
+ const firstLine = resultText
91
+ .split(/\r?\n/)
92
+ .map((line) => line.trim())
93
+ .filter(Boolean)[0] ?? "";
94
+ const evidence = realVerify
95
+ ? `自动:${commandSummary(st.agent.lastToolArgs)} → ${firstLine.slice(0, 80)}`
96
+ : `自动:回读 ${readbackTarget} → ${firstLine.slice(0, 60)}`;
97
+ void deps.stores.projectReady
98
+ .then(async (store) => {
99
+ const count = (await store?.verifyChanges(sid, { before: Date.now(), evidence, targets })) ?? 0;
100
+ if (count > 0)
101
+ deps.ctx.logger?.warn?.(`lume: [${sid}] 自动推进台账 ${count} 条 → verified(${evidence.slice(0, 60)})`);
102
+ })
103
+ .catch((error) => deps.ctx.logger?.warn?.(`lume: [${sid}] 自动推进台账失败:${String(error)}`));
104
+ }
105
+ function contractOf(sid) {
106
+ return deps.stores.project()?.getContract(sid) ?? null;
107
+ }
108
+ function changesOf(sid) {
109
+ return deps.stores.project()?.getChanges(sid) ?? [];
110
+ }
111
+ function hypothesesOf(sid) {
112
+ return deps.stores.project()?.getHypotheses(sid) ?? [];
113
+ }
114
+ function factsOf(sid, context) {
115
+ const projectKey = projectKeyFor(sid, context);
116
+ const store = deps.stores.project();
117
+ return store && projectKey ? store.getFacts(projectKey) : [];
118
+ }
119
+ /** 本会话的设计决策(设计 pass 产出)。 */
120
+ /** 本会话的需求锚点(用户原话,逐字)。 */
121
+ function requirementsOf(sid) {
122
+ return deps.stores.project()?.getRequirements(sid) ?? [];
123
+ }
124
+ function designOf(sid) {
125
+ return deps.stores.project()?.getDesign(sid) ?? [];
126
+ }
127
+ /** 该不该顶〔设计三问〕:要动数据/接口 + 还没写下设计 + 不是纯问答。 */
128
+ function needsDesignPass(sid, st, query, mode) {
129
+ return mode !== "question" && DESIGN_SIGNAL_RE.test(query) && designOf(sid).length === 0;
130
+ }
131
+ function structureToolName(context) {
132
+ try {
133
+ const schemas = deps.ctx.get("tools")?.schemas?.(context?.agent);
134
+ if (!Array.isArray(schemas))
135
+ return null;
136
+ for (const schema of schemas) {
137
+ const name = String(schema?.name ?? "");
138
+ if (/analy|tree|symbol|lsp|reference|code_map|outline/i.test(name))
139
+ return name;
140
+ }
141
+ return null;
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }
147
+ // ── 人设五段式注入 + 切换播报 ──
148
+ /**
149
+ * 导出**会话记忆**(零 token、机械):把会话的结构化状态(目标/需求原话/已拍板/改动/未决/死路/定位)
150
+ * 落成可跨会话续接的一份记忆。上下文撑满时宿主的压缩会失败(现场:context overflow),
151
+ * 会话再也聊不动——**上下文不能当记忆载体**,所以每轮都得把记忆搬出来。
152
+ */
153
+ async function saveSessionMemory(sid) {
154
+ try {
155
+ const store = await deps.stores.projectReady;
156
+ const key = projectKeyFor(sid, {});
157
+ if (!store || !key)
158
+ return false;
159
+ const st = deps.runtime.get(sid);
160
+ const memory = buildTaskMemory({
161
+ sid,
162
+ title: st?.sessionTitle ?? "",
163
+ turn: st?.turnIndex ?? 0,
164
+ goal: contractOf(sid)?.goal ?? "",
165
+ requirement: requirementsOf(sid),
166
+ design: designOf(sid),
167
+ changes: changesOf(sid),
168
+ hypotheses: hypothesesOf(sid),
169
+ deadends: factsOf(sid, {}).filter((fact) => fact.kind === "deadend"),
170
+ locate: [...(st?.agent.inspectedTargets ?? [])].slice(-5),
171
+ });
172
+ if (!memory)
173
+ return false;
174
+ return await store.saveTaskMemory(key, memory);
175
+ }
176
+ catch (error) {
177
+ deps.ctx.logger?.warn?.(`lume: [${sid}] 会话记忆导出失败:${String(error).slice(0, 80)}`);
178
+ return false;
179
+ }
180
+ }
181
+ /** 本项目键下最近的会话记忆(新的在前);新会话开局用它接上上一个会话。 */
182
+ async function taskMemoriesOf(sid, limit = 3) {
183
+ try {
184
+ const store = await deps.stores.projectReady;
185
+ const key = projectKeyFor(sid, {});
186
+ if (!store || !key)
187
+ return [];
188
+ return store.getTaskMemories(key, limit);
189
+ }
190
+ catch {
191
+ return [];
192
+ }
193
+ }
194
+ return {
195
+ projectKeyFor,
196
+ commandSummary,
197
+ flushPendingFacts,
198
+ settleVerification,
199
+ contractOf,
200
+ saveSessionMemory,
201
+ taskMemoriesOf,
202
+ changesOf,
203
+ hypothesesOf,
204
+ factsOf,
205
+ requirementsOf,
206
+ designOf,
207
+ needsDesignPass,
208
+ structureToolName,
209
+ };
210
+ }
@@ -15,10 +15,13 @@
15
15
  */
16
16
  import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
17
17
  import z from "@deepseek-ai/schemastery";
18
- import { CHANGE_CAP, HYPOTHESIS_CAP, PROJECT_FACT_CAP, normalizeChange, normalizeProjectFact, trimChanges, trimFacts, } from "../core/ledger.js";
18
+ import { CHANGE_CAP, DESIGN_CAP, HYPOTHESIS_CAP, REQUIREMENT_CAP, PROJECT_FACT_CAP, normalizeChange, normalizeDesign, normalizeProjectFact, normalizeRequirement, trimDesign, trimRequirements, trimChanges, trimFacts, } from "../core/ledger.js";
19
+ import { isMoreSpecific } from "../core/memory-id.js";
19
20
  import { zodLike } from "./identity.js";
20
21
  /** 存储里用 -1 表示「未估/未回填」:schemastery 的 number 不接受 null,避免为它引入联合类型。 */
21
22
  const UNSET = -1;
23
+ /** 每个项目键保留的会话记忆条数(够了;只用于「接着上次干」)。 */
24
+ const TASK_MEMORY_CAP = 10;
22
25
  export const LUME_PROJECT_SPEC = defineDomain({
23
26
  name: "lume_project",
24
27
  version: 1,
@@ -40,6 +43,24 @@ export const LUME_PROJECT_SPEC = defineDomain({
40
43
  /** 假设台账(键 = sessionId)。 */
41
44
  hypotheses: domainTable(zodLike(z.array(z.object({ text: z.string(), evidence: z.string(), status: z.string(), at: z.number() })))),
42
45
  /** 项目知识(键 = projectKey,跨会话共享)。 */
46
+ /** 设计决策(键 = sessionId):功能型任务的设计 pass 产出,跨轮/跨压缩保留。 */
47
+ /** 需求锚点(键 = sessionId):用户原话逐字保留,每轮回显——治「用自己的转述替代需求」。 */
48
+ /** 会话记忆(键 = projectKey):结构化导出会话状态,供下次开新会话续接——上下文不能当记忆载体。 */
49
+ task_memory: domainTable(zodLike(z.array(z.object({
50
+ sid: z.string(),
51
+ title: z.string(),
52
+ turn: z.number(),
53
+ goal: z.string(),
54
+ requirement: z.array(z.string()),
55
+ decided: z.array(z.string()),
56
+ changed: z.array(z.string()),
57
+ open: z.array(z.string()),
58
+ deadends: z.array(z.string()),
59
+ locate: z.array(z.string()),
60
+ at: z.number(),
61
+ })))),
62
+ requirements: domainTable(zodLike(z.array(z.object({ text: z.string(), at: z.number() })))),
63
+ design: domainTable(zodLike(z.array(z.object({ point: z.string(), choice: z.string(), rejected: z.string(), impact: z.string(), at: z.number() })))),
43
64
  facts: domainTable(zodLike(z.array(z.object({ kind: z.string(), text: z.string(), at: z.number() })))),
44
65
  },
45
66
  });
@@ -78,11 +99,17 @@ export class ProjectStore {
78
99
  #ledgerTable;
79
100
  #hypothesisTable;
80
101
  #factTable;
102
+ #designTable;
103
+ #requirementTable;
104
+ #taskMemoryTable;
81
105
  constructor(tables) {
82
106
  this.#contractTable = tables.contract;
83
107
  this.#ledgerTable = tables.ledger;
84
108
  this.#hypothesisTable = tables.hypotheses;
85
109
  this.#factTable = tables.facts;
110
+ this.#designTable = tables.design;
111
+ this.#requirementTable = tables.requirements;
112
+ this.#taskMemoryTable = tables.taskMemory;
86
113
  }
87
114
  // ── 契约 ──
88
115
  getContract(sid) {
@@ -145,6 +172,31 @@ export class ProjectStore {
145
172
  await this.#ledgerTable.put(sid, trimChanges(items, CHANGE_CAP));
146
173
  return hit;
147
174
  }
175
+ /**
176
+ * 插件自己推进台账:一次**成功且真实**的验证,把此前「已改未验」的条目推进成 verified。
177
+ *
178
+ * 为什么必须由插件做:实测模型几乎不会主动调 lume_change(4 个会话 0 次),所以台账里的
179
+ * 「未验证」永远是未验证——而"改完必须有验证"正是这一版要保证的东西。证据写进 verify 字段
180
+ * (命令 + 结果首行),让用户一眼能看出是真验还是假验。
181
+ */
182
+ async verifyChanges(sid, input) {
183
+ const items = this.getChanges(sid);
184
+ let count = 0;
185
+ for (const item of items) {
186
+ if (item.status !== "done")
187
+ continue;
188
+ if (item.at > input.before)
189
+ continue;
190
+ if (input.targets && !input.targets.includes(item.target))
191
+ continue;
192
+ item.status = "verified";
193
+ item.verify = item.verify ? `${item.verify}|${input.evidence}` : input.evidence;
194
+ count++;
195
+ }
196
+ if (count > 0)
197
+ await this.#ledgerTable.put(sid, trimChanges(items, CHANGE_CAP));
198
+ return count;
199
+ }
148
200
  // ── 假设台账 ──
149
201
  getHypotheses(sid) {
150
202
  const value = this.#hypothesisTable.get(sid);
@@ -179,6 +231,21 @@ export class ProjectStore {
179
231
  lastHypothesisAt(sid) {
180
232
  return this.getHypotheses(sid).reduce((max, item) => Math.max(max, item.at), 0);
181
233
  }
234
+ // ── 会话记忆(按项目键,跨会话续接)──
235
+ /** 读最近的会话记忆(新的在前)。 */
236
+ getTaskMemories(projectKey, limit = 5) {
237
+ const value = this.#taskMemoryTable.get(projectKey);
238
+ if (!Array.isArray(value))
239
+ return [];
240
+ return value.slice(-limit).reverse();
241
+ }
242
+ /** 写会话记忆:同 sid 覆盖,保留最近 10 条(免得桶里全是陈年会话)。 */
243
+ async saveTaskMemory(projectKey, memory) {
244
+ const list = this.getTaskMemories(projectKey, TASK_MEMORY_CAP).filter((item) => item.sid !== memory.sid);
245
+ list.push(memory);
246
+ await this.#taskMemoryTable.put(projectKey, list.slice(-TASK_MEMORY_CAP));
247
+ return true;
248
+ }
182
249
  // ── 项目知识(跨会话)──
183
250
  getFacts(projectKey) {
184
251
  const value = this.#factTable.get(projectKey);
@@ -194,12 +261,47 @@ export class ProjectStore {
194
261
  /** 追加项目事实;近似重复的忽略。返回是否写入。 */
195
262
  async addFact(projectKey, fact, isDuplicate) {
196
263
  const facts = this.getFacts(projectKey);
264
+ // 内容寻址(见 core/memory-id.ts):同主题就是同一条知识 → 精确命中,不再全表算相似度。
265
+ const same = fact.id ? facts.findIndex((item) => item.id === fact.id) : -1;
266
+ if (same >= 0) {
267
+ const current = facts[same];
268
+ // 先到先得会让后来更完整的表述被丢掉;只有"更具体"(更长且主题一致)才覆盖。
269
+ if (isMoreSpecific(fact.text, current.text)) {
270
+ facts[same] = { ...fact, at: current.at };
271
+ await this.#factTable.put(projectKey, trimFacts(facts, PROJECT_FACT_CAP));
272
+ return true;
273
+ }
274
+ // 唯一例外:作用域修正(老数据没有 scope,补扫时能把它纠正成"本需求")——这让老库自愈。
275
+ // ⚠️ 只在**新判定是 task**(也就是这次真的拿到了需求线索)时才改:否则一旦某轮没有线索
276
+ // (比如没预热到 cwd、`doc/` 下没有需求目录),就会把已经归好的 task 标签**改回 repo**——
277
+ // 我在 2026-09-24 的存量重归属里真踩过(22 条标签被冲掉,从备份才救回来)。
278
+ if (fact.scope === "task" && current.scope !== "task") {
279
+ facts[same] = { ...current, scope: fact.scope, task: fact.task };
280
+ await this.#factTable.put(projectKey, trimFacts(facts, PROJECT_FACT_CAP));
281
+ return true;
282
+ }
283
+ return false;
284
+ }
197
285
  if (isDuplicate(fact.text, facts))
198
286
  return false;
199
287
  facts.push(fact);
200
288
  await this.#factTable.put(projectKey, trimFacts(facts, PROJECT_FACT_CAP));
201
289
  return true;
202
290
  }
291
+ /** 按 id 或编号删除一条项目知识(编号 = 注入块里的 #n,会随裁剪顺移;id 稳定)。 */
292
+ async deleteFactById(projectKey, ref) {
293
+ const facts = this.getFacts(projectKey);
294
+ const wanted = ref.trim().replace(/^#/, "");
295
+ if (!wanted)
296
+ return false;
297
+ const byId = facts.findIndex((item) => item.id === wanted || (item.id ?? "").startsWith(wanted));
298
+ const index = byId >= 0 ? byId : Number(wanted) - 1;
299
+ if (index < 0 || index >= facts.length)
300
+ return false;
301
+ facts.splice(index, 1);
302
+ await this.#factTable.put(projectKey, facts);
303
+ return true;
304
+ }
203
305
  async deleteFact(projectKey, index) {
204
306
  const facts = this.getFacts(projectKey);
205
307
  if (index < 0 || index >= facts.length)
@@ -213,9 +315,58 @@ export class ProjectStore {
213
315
  }
214
316
  /** 会话结束清理:任务态数据不跨会话保留(项目知识是另一张表,不受影响)。 */
215
317
  async clearSession(sid) {
216
- await Promise.all([this.#contractTable.delete(sid), this.#ledgerTable.delete(sid), this.#hypothesisTable.delete(sid)]);
318
+ await Promise.all([
319
+ this.#contractTable.delete(sid),
320
+ this.#ledgerTable.delete(sid),
321
+ this.#hypothesisTable.delete(sid),
322
+ this.#designTable.delete(sid),
323
+ this.#requirementTable.delete(sid),
324
+ ]);
217
325
  }
218
326
  /** 诊断用:当前项目键下的事实条数。 */
327
+ // ── 设计决策(会话态,跨轮跨压缩保留)──
328
+ getDesign(sid) {
329
+ const value = this.#designTable.get(sid);
330
+ if (!Array.isArray(value))
331
+ return [];
332
+ return value
333
+ .map((entry) => {
334
+ const raw = entry;
335
+ return normalizeDesign({ point: raw?.point, choice: raw?.choice, rejected: raw?.rejected, impact: raw?.impact }, typeof raw?.at === "number" ? raw.at : 0);
336
+ })
337
+ .filter((item) => item !== null);
338
+ }
339
+ /** 同一决策点视为更新(改主意就覆盖,保留新的理由)。 */
340
+ async upsertDesign(sid, item) {
341
+ const items = this.getDesign(sid);
342
+ const index = items.findIndex((entry) => entry.point === item.point);
343
+ if (index >= 0)
344
+ items[index] = { ...items[index], ...item };
345
+ else
346
+ items.push(item);
347
+ await this.#designTable.put(sid, trimDesign(items, DESIGN_CAP));
348
+ }
349
+ // ── 需求锚点(会话态)──
350
+ getRequirements(sid) {
351
+ const value = this.#requirementTable.get(sid);
352
+ if (!Array.isArray(value))
353
+ return [];
354
+ return value
355
+ .map((entry) => {
356
+ const raw = entry;
357
+ return normalizeRequirement({ text: raw?.text }, typeof raw?.at === "number" ? raw.at : 0);
358
+ })
359
+ .filter((item) => item !== null);
360
+ }
361
+ /** 逐字追加一条用户原话(近似重复的忽略,避免同一句被记两次)。 */
362
+ async appendRequirement(sid, item) {
363
+ const items = this.getRequirements(sid);
364
+ if (items.some((entry) => entry.text === item.text))
365
+ return false;
366
+ items.push(item);
367
+ await this.#requirementTable.put(sid, trimRequirements(items, REQUIREMENT_CAP));
368
+ return true;
369
+ }
219
370
  factCount(projectKey) {
220
371
  return this.getFacts(projectKey).length;
221
372
  }