lume-dsh-plugin 0.7.3 → 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.
- package/CHANGELOG.md +460 -0
- package/README.md +245 -275
- package/lib/client.js +242 -181
- package/lib/core/card.js +11 -9
- package/lib/core/citations.js +235 -0
- package/lib/core/coverage.js +149 -0
- package/lib/core/dialogue-mining.js +49 -11
- package/lib/core/knowledge.js +165 -0
- package/lib/core/leak-detector.js +1 -3
- package/lib/core/ledger.js +129 -18
- package/lib/core/manifest.js +3 -1
- package/lib/core/memory-id.js +105 -0
- package/lib/core/metrics.js +270 -0
- package/lib/core/persona-limits.js +25 -0
- package/lib/core/scope.js +124 -0
- package/lib/core/signals.js +285 -5
- package/lib/core/task-memory.js +143 -0
- package/lib/core/text.js +45 -3
- package/lib/host/backfill.js +218 -0
- package/lib/host/bootstrap.js +130 -0
- package/lib/host/boundary.js +1 -3
- package/lib/host/clauses.js +180 -0
- package/lib/host/config.js +7 -0
- package/lib/host/diag.js +44 -7
- package/lib/host/distill-prompt.js +365 -0
- package/lib/host/distill.js +22 -348
- package/lib/host/extraction.js +11 -3
- package/lib/host/host-context.js +1 -0
- package/lib/host/host-events.js +100 -0
- package/lib/host/identity.js +12 -29
- package/lib/host/inbound.js +133 -0
- package/lib/host/injection.js +2 -6
- package/lib/host/llm-aux.js +130 -0
- package/lib/host/llm-route.js +3 -0
- package/lib/host/methods.js +182 -11
- package/lib/host/metrics-log.js +169 -0
- package/lib/host/notices.js +62 -0
- package/lib/host/project-access.js +210 -0
- package/lib/host/project.js +153 -2
- package/lib/host/prompt-blocks.js +113 -0
- package/lib/host/protocol.js +144 -11
- package/lib/host/reflection.js +28 -5
- package/lib/host/registry.js +0 -4
- package/lib/host/requirements-scan.js +108 -0
- package/lib/host/rpc-bridge.js +23 -4
- package/lib/host/rpc.js +1 -1
- package/lib/host/sections.js +48 -0
- package/lib/host/session-deps.js +27 -0
- package/lib/host/session-events.js +371 -0
- package/lib/host/session-runtime.js +18 -5
- package/lib/host/thinking.js +10 -1
- package/lib/host/tools.js +367 -0
- package/lib/host/triggers.js +30 -6
- package/lib/host/turn-boundary.js +116 -0
- package/lib/host/wiring.js +280 -0
- package/lib/host/workspace-map.js +81 -0
- package/lib/index.js +334 -836
- package/package.json +13 -4
package/lib/core/ledger.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isRequirementStatement } from "./coverage.js";
|
|
1
2
|
/**
|
|
2
3
|
* 任务载具的纯逻辑层:任务契约、改动台账、假设台账、项目知识。
|
|
3
4
|
*
|
|
@@ -11,6 +12,8 @@
|
|
|
11
12
|
* 本模块只做纯逻辑(类型/解析/归一/渲染/上限),IO 在 host/project.ts。
|
|
12
13
|
*/
|
|
13
14
|
import { fnv1a32 } from "./sampling.js";
|
|
15
|
+
import { memoryId, numberFacts } from "./memory-id.js";
|
|
16
|
+
import { classifyScope, visibleForTask } from "./scope.js";
|
|
14
17
|
/** 契约字段长度上限:契约是「一屏能看完」的东西,写长了自己也不看。 */
|
|
15
18
|
export const CONTRACT_TEXT_CAP = 240;
|
|
16
19
|
export const CONTRACT_LIST_CAP = 8;
|
|
@@ -21,6 +24,12 @@ export const CHANGE_TEXT_CAP = 160;
|
|
|
21
24
|
export const HYPOTHESIS_CAP = 20;
|
|
22
25
|
/** 项目知识上限:按时间挤旧,死路记录优先保留(它最省时间)。 */
|
|
23
26
|
export const PROJECT_FACT_CAP = 40;
|
|
27
|
+
/** 设计决策上限:一次任务的设计决策点到 20 个已经很多了。 */
|
|
28
|
+
export const DESIGN_CAP = 20;
|
|
29
|
+
/** 需求锚点上限:保留首条(原始需求)+ 最近若干条(修正与追加)。 */
|
|
30
|
+
export const REQUIREMENT_CAP = 10;
|
|
31
|
+
export const REQUIREMENT_TEXT_CAP = 800;
|
|
32
|
+
export const DESIGN_TEXT_CAP = 160;
|
|
24
33
|
export const FACT_TEXT_CAP = 200;
|
|
25
34
|
const FACT_LABEL = {
|
|
26
35
|
build: "构建",
|
|
@@ -30,7 +39,10 @@ const FACT_LABEL = {
|
|
|
30
39
|
deadend: "死路(不要重复)",
|
|
31
40
|
};
|
|
32
41
|
function clip(value, cap) {
|
|
33
|
-
return String(value ?? "")
|
|
42
|
+
return String(value ?? "")
|
|
43
|
+
.trim()
|
|
44
|
+
.replace(/\s+/g, " ")
|
|
45
|
+
.slice(0, cap);
|
|
34
46
|
}
|
|
35
47
|
function clipList(value, cap = CONTRACT_LIST_CAP) {
|
|
36
48
|
const list = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
|
|
@@ -48,11 +60,18 @@ function asCount(value) {
|
|
|
48
60
|
const n = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
|
|
49
61
|
return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : null;
|
|
50
62
|
}
|
|
51
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* 项目键:跨会话共享的项目知识按工作目录归属(同一仓库的多个会话共用一份)。
|
|
65
|
+
*
|
|
66
|
+
* 拿不到工作目录时返回 **null**,不返回 "unknown"——实测踩过:写入口(工具 exec / 会话事件)
|
|
67
|
+
* 里的 session 视图不一定带 cwd,回落成 "unknown" 会把**所有项目**的知识塞进同一个桶,
|
|
68
|
+
* 跨会话隔离直接失效(现场取证:facts 表的键就是 "unknown")。调用方拿到 null 必须
|
|
69
|
+
* 「不写跨会话表」,宁可不记也不要串味。
|
|
70
|
+
*/
|
|
52
71
|
export function projectKeyOf(cwd) {
|
|
53
72
|
const normalized = clip(cwd, 240).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
54
73
|
if (!normalized)
|
|
55
|
-
return
|
|
74
|
+
return null;
|
|
56
75
|
return fnv1a32(normalized).toString(16).padStart(8, "0");
|
|
57
76
|
}
|
|
58
77
|
/** 从工具入参归一化契约(截断 + 去重 + 上限)。 */
|
|
@@ -93,10 +112,13 @@ export function normalizeHypothesis(input, at) {
|
|
|
93
112
|
text,
|
|
94
113
|
evidence: clip(input.evidence, CHANGE_TEXT_CAP),
|
|
95
114
|
status: status === "testing" || status === "confirmed" || status === "excluded" ? status : "open",
|
|
115
|
+
// 裁决三件套里的另外两件:结构化保存(不折进 evidence,否则「多少结论带反例检查」没法机械统计)
|
|
116
|
+
method: clip(input.method, CHANGE_TEXT_CAP),
|
|
117
|
+
counter: clip(input.counter, CHANGE_TEXT_CAP),
|
|
96
118
|
at,
|
|
97
119
|
};
|
|
98
120
|
}
|
|
99
|
-
export function normalizeProjectFact(input, at) {
|
|
121
|
+
export function normalizeProjectFact(input, at, options = {}) {
|
|
100
122
|
const text = clip(input.text, FACT_TEXT_CAP);
|
|
101
123
|
if (!text)
|
|
102
124
|
return null;
|
|
@@ -105,6 +127,10 @@ export function normalizeProjectFact(input, at) {
|
|
|
105
127
|
kind: kind === "test" || kind === "module" || kind === "convention" || kind === "deadend" ? kind : "build",
|
|
106
128
|
text,
|
|
107
129
|
at,
|
|
130
|
+
// 内容寻址 id:同主题 → 同 id(去重/覆盖靠它,不再靠字面相似度)
|
|
131
|
+
id: memoryId(typeof kind === "string" ? kind : "build", text),
|
|
132
|
+
// 作用域:需求特有的结论不该污染别的需求(判定见 core/scope.ts,机械规则)
|
|
133
|
+
...classifyScope(text, { taskTitle: options.taskTitle, requirementHints: options.requirementHints }),
|
|
108
134
|
};
|
|
109
135
|
}
|
|
110
136
|
/** 台账计数:渲染与触发器都要用("x 项已改未验" 是增量验证的判据)。 */
|
|
@@ -139,12 +165,14 @@ export function renderContract(contract, delivery = false) {
|
|
|
139
165
|
if (!contract || !contract.goal)
|
|
140
166
|
return null;
|
|
141
167
|
const lines = [];
|
|
142
|
-
lines.push(delivery
|
|
168
|
+
lines.push(delivery
|
|
169
|
+
? "〔契约对账〕交付前逐项对账(以下是开工时写下的原始判据,不是你现在的记忆版本):"
|
|
170
|
+
: `〔任务契约|第 ${contract.turn} 轮写入〕`);
|
|
143
171
|
lines.push(`目标:${contract.goal}`);
|
|
144
172
|
if (contract.scope.length > 0)
|
|
145
173
|
lines.push(`范围:${contract.scope.join(";")}`);
|
|
146
|
-
if (
|
|
147
|
-
const expect = contract.expectCount === null ? "
|
|
174
|
+
if (true) {
|
|
175
|
+
const expect = contract.expectCount === null ? "未估" : contract.expectCount;
|
|
148
176
|
const actual = contract.actualCount === null ? "未回填" : contract.actualCount;
|
|
149
177
|
lines.push(`数量:预计 ${expect} → 实际 ${actual}`);
|
|
150
178
|
}
|
|
@@ -180,32 +208,115 @@ export function renderHypotheses(list, limit = 8) {
|
|
|
180
208
|
if (list.length === 0)
|
|
181
209
|
return null;
|
|
182
210
|
const lines = list.slice(-limit).map((item) => {
|
|
183
|
-
const mark = item.status === "excluded"
|
|
184
|
-
|
|
185
|
-
|
|
211
|
+
const mark = item.status === "excluded"
|
|
212
|
+
? "[已排除]"
|
|
213
|
+
: item.status === "confirmed"
|
|
214
|
+
? "[已证实]"
|
|
215
|
+
: item.status === "testing"
|
|
216
|
+
? "[验证中]"
|
|
217
|
+
: "[待验证]";
|
|
218
|
+
const parts = [
|
|
219
|
+
item.evidence ? `证据:${item.evidence}` : "",
|
|
220
|
+
item.method ? `裁决:${item.method}` : "",
|
|
221
|
+
item.counter ? `反例:${item.counter}` : "",
|
|
222
|
+
].filter(Boolean);
|
|
223
|
+
return `- ${mark} ${item.text}${parts.length > 0 ? `(${parts.join(" | ")})` : ""}`;
|
|
186
224
|
});
|
|
187
225
|
const excluded = list.filter((item) => item.status === "excluded").length;
|
|
188
226
|
const foot = excluded > 0 ? "\n已排除的假设不要重提;要推翻它必须给出新的证据。" : "";
|
|
189
227
|
return `〔假设台账〕\n${lines.join("\n")}${foot}`;
|
|
190
228
|
}
|
|
229
|
+
/** 知识新鲜度:跨会话知识必须一眼看出是多久前记的(过时的事实比没有更危险)。 */
|
|
230
|
+
function ageLabel(at, now = Date.now()) {
|
|
231
|
+
const hours = (now - at) / 3_600_000;
|
|
232
|
+
if (!Number.isFinite(hours) || hours < 0)
|
|
233
|
+
return "";
|
|
234
|
+
if (hours < 1)
|
|
235
|
+
return "(刚记)";
|
|
236
|
+
if (hours < 48)
|
|
237
|
+
return `(${Math.round(hours)} 小时前)`;
|
|
238
|
+
return `(${Math.round(hours / 24)} 天前)`;
|
|
239
|
+
}
|
|
191
240
|
/** 渲染项目知识:按类别归组;死路单独成节(它最省时间)。 */
|
|
192
|
-
export function renderProjectFacts(facts, limit = 14) {
|
|
241
|
+
export function renderProjectFacts(facts, limit = 14, currentTask) {
|
|
193
242
|
if (facts.length === 0)
|
|
194
243
|
return null;
|
|
195
244
|
const order = ["build", "test", "convention", "module", "deadend"];
|
|
196
|
-
|
|
245
|
+
// 作用域隔离:通用知识人人可见;需求级知识只给同一需求看(否则会拿别的需求的结论误导当前需求)。
|
|
246
|
+
const numbered = numberFacts(facts.filter((fact) => visibleForTask(fact, currentTask)));
|
|
247
|
+
if (numbered.length === 0)
|
|
248
|
+
return null;
|
|
249
|
+
const picked = numbered.slice(-limit);
|
|
197
250
|
const lines = [];
|
|
198
251
|
for (const kind of order) {
|
|
199
|
-
const group = picked.filter((
|
|
252
|
+
const group = picked.filter((entry) => entry.item.kind === kind);
|
|
200
253
|
if (group.length === 0)
|
|
201
254
|
continue;
|
|
202
255
|
lines.push(`${FACT_LABEL[kind]}:`);
|
|
203
|
-
|
|
204
|
-
|
|
256
|
+
// 编号(#n)+ 短 id:用户可点名纠正(“#7 过时了”),模型可引用;id 跨裁剪稳定。
|
|
257
|
+
for (const entry of group)
|
|
258
|
+
lines.push(`- #${entry.n}${entry.item.id ? `·${entry.item.id.slice(0, 4)}` : ""} ${entry.item.text}${entry.item.scope === "task" ? "(本需求)" : ""}${ageLabel(entry.item.at)}`);
|
|
205
259
|
}
|
|
206
260
|
return `〔项目知识|本目录,跨会话累积〕\n${lines.join("\n")}`;
|
|
207
261
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
262
|
+
export function normalizeDesign(input, at) {
|
|
263
|
+
const point = clip(input.point, CONTRACT_ITEM_CAP);
|
|
264
|
+
const choice = clip(input.choice, DESIGN_TEXT_CAP);
|
|
265
|
+
if (!point || !choice)
|
|
266
|
+
return null;
|
|
267
|
+
return { point, choice, rejected: clip(input.rejected, DESIGN_TEXT_CAP), impact: clip(input.impact, DESIGN_TEXT_CAP), at };
|
|
268
|
+
}
|
|
269
|
+
export function trimDesign(items, cap = DESIGN_CAP) {
|
|
270
|
+
return items.length <= cap ? items : items.slice(-cap);
|
|
271
|
+
}
|
|
272
|
+
/** 渲染设计决策:决策点在前,取舍与影响面在后(三者缺一就是没做完设计 pass)。 */
|
|
273
|
+
export function renderDesign(items, limit = 8) {
|
|
274
|
+
if (items.length === 0)
|
|
275
|
+
return null;
|
|
276
|
+
const lines = items.slice(-limit).map((item, index) => {
|
|
277
|
+
const rejected = item.rejected ? `|放弃:${item.rejected}` : "|⚠ 没写被放弃的方案";
|
|
278
|
+
const impact = item.impact ? `|影响面:${item.impact}` : "";
|
|
279
|
+
return `${index + 1}. ${item.point} → ${item.choice}${rejected}${impact}`;
|
|
280
|
+
});
|
|
281
|
+
return `〔设计决策|本会话,跨轮跨压缩保留〕\n${lines.join("\n")}\n定下来的决策不要反复推翻;要改就写一条新的并说明为什么推翻上一条。`;
|
|
282
|
+
}
|
|
283
|
+
export function normalizeRequirement(input, at) {
|
|
284
|
+
const text = clip(input.text, REQUIREMENT_TEXT_CAP);
|
|
285
|
+
if (!text)
|
|
286
|
+
return null;
|
|
287
|
+
return { text, at };
|
|
288
|
+
}
|
|
289
|
+
/**
|
|
290
|
+
* 首条永远保留,**有结构的需求原文优先保留**(闲聊与评审粘贴先被挤掉)。
|
|
291
|
+
*
|
|
292
|
+
* 现场教训(2026-09-23):需求原文在第 4 条,后面被 8 段评审粘贴挤出了表外 →
|
|
293
|
+
* 需求覆盖核对只能拿评审条目当需求逐条列,反而误导。只按时间挤旧是不够的,要按"是不是需求"分层。
|
|
294
|
+
*/
|
|
295
|
+
export function trimRequirements(items, cap = REQUIREMENT_CAP) {
|
|
296
|
+
if (items.length <= cap)
|
|
297
|
+
return items;
|
|
298
|
+
const [first, ...rest] = items;
|
|
299
|
+
const keep = rest.filter((item) => isRequirementStatement(item.text));
|
|
300
|
+
const others = rest.filter((item) => !isRequirementStatement(item.text));
|
|
301
|
+
const room = cap - 1;
|
|
302
|
+
if (keep.length >= room)
|
|
303
|
+
return [first, ...keep.slice(-room)];
|
|
304
|
+
return [first, ...keep, ...others.slice(-(room - keep.length))];
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* 渲染需求锚点。
|
|
308
|
+
*
|
|
309
|
+
* 为什么要逐字回显:现场实测(B2I 优惠视图与订单属性)模型用自己的转述工作,"新增字段" 被它转成
|
|
310
|
+
* "复用 create_id/modify_id",随后几轮都在错误前提上推论;而契约工具它 14 次提示都没调用。
|
|
311
|
+
* 所以锚点由**插件自己写**,并在每轮把原话摆回它眼前。
|
|
312
|
+
*/
|
|
313
|
+
export function renderRequirements(items, limit = 6) {
|
|
314
|
+
if (items.length === 0)
|
|
315
|
+
return null;
|
|
316
|
+
const head = items[0];
|
|
317
|
+
const rest = items.slice(1).slice(-(limit - 1));
|
|
318
|
+
const lines = [`1.(原始需求,最重要)${head.text}`];
|
|
319
|
+
for (const [index, item] of rest.entries())
|
|
320
|
+
lines.push(`${index + 2}. ${item.text}`);
|
|
321
|
+
return `〔需求锚点|用户原话,逐字保留〕\n${lines.join("\n")}\n你的理解与方案必须能追溯到上面这些句子;与它们冲突时改方案,不要改需求。`;
|
|
211
322
|
}
|
package/lib/core/manifest.js
CHANGED
|
@@ -21,7 +21,9 @@ export function parseManifest(raw) {
|
|
|
21
21
|
displayName: typeof entry.displayName === "string" ? entry.displayName : entry.name,
|
|
22
22
|
description: typeof entry.description === "string" ? entry.description : "",
|
|
23
23
|
defaultName: typeof entry.defaultName === "string" && entry.defaultName ? entry.defaultName : undefined,
|
|
24
|
-
signatureWords: Array.isArray(entry.signatureWords)
|
|
24
|
+
signatureWords: Array.isArray(entry.signatureWords)
|
|
25
|
+
? entry.signatureWords.filter((w) => typeof w === "string" && w.length > 0)
|
|
26
|
+
: undefined,
|
|
25
27
|
promptFile: typeof entry.promptFile === "string" ? entry.promptFile : `${entry.name}.txt`,
|
|
26
28
|
corpusFile: typeof entry.corpusFile === "string" ? entry.corpusFile : `${entry.name}-corpus.jsonl`,
|
|
27
29
|
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 记忆 ID:把「同一条知识」从**模糊文本比对**变成**内容寻址**。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要:原先去重靠 token Jaccard(中文二元组)——同义不同词就会漏(「列名必须用 PERMISSION_NAME」
|
|
5
|
+
* vs「权限人字段的列名固定为 PERMISSION_NAME」),而且每次落盘都要跟全部条目算一遍相似度。
|
|
6
|
+
*
|
|
7
|
+
* 做法:一条记忆的**身份**不是整句文本,而是它的**主题**:
|
|
8
|
+
* ① 大写标识符 / 表名字段名(PERMISSION_NAME、WTPF_GOODS_PROPERTY_DEF)
|
|
9
|
+
* ② 驼峰标识符(permissionName、busType)
|
|
10
|
+
* ③ 文件名(去掉目录与扩展名)
|
|
11
|
+
* ④ 命令名(mvn / gradle / npm / git / curl / psql …)
|
|
12
|
+
* 取这些实体排序后拼成 topicKey,再哈希成短 id(fnv1a32,与 projectKeyOf 同一套哈希)。
|
|
13
|
+
*
|
|
14
|
+
* 于是:**同主题 → 同 id → 精确去重 / 精确覆盖(O(1),不再全表算相似度)**;
|
|
15
|
+
* 注入里显示编号(#n)与短 id,用户可以点名纠正(“#7 过时了”),模型也能引用。
|
|
16
|
+
*
|
|
17
|
+
* 取舍(不吹):没有标识符的纯中文约定只能退化到关键词指纹 → 同义不同词仍可能漏,
|
|
18
|
+
* 所以**兜底仍保留 Jaccard**(低频路径),主路径走 id。
|
|
19
|
+
*/
|
|
20
|
+
import { fnv1a32 } from "./sampling.js";
|
|
21
|
+
/** 命令名白名单:它们常出现在“这个仓库怎么跑”类知识里。 */
|
|
22
|
+
const COMMANDS = [
|
|
23
|
+
"mvn",
|
|
24
|
+
"gradle",
|
|
25
|
+
"npm",
|
|
26
|
+
"pnpm",
|
|
27
|
+
"yarn",
|
|
28
|
+
"node",
|
|
29
|
+
"git",
|
|
30
|
+
"curl",
|
|
31
|
+
"curl.exe",
|
|
32
|
+
"psql",
|
|
33
|
+
"mysql",
|
|
34
|
+
"docker",
|
|
35
|
+
"kubectl",
|
|
36
|
+
"npx",
|
|
37
|
+
"tsc",
|
|
38
|
+
"vitest",
|
|
39
|
+
"jest",
|
|
40
|
+
"eslint",
|
|
41
|
+
"dotnet",
|
|
42
|
+
"python",
|
|
43
|
+
"pip",
|
|
44
|
+
];
|
|
45
|
+
const UPPER_RE = /[A-Z][A-Z0-9_]{2,}/g;
|
|
46
|
+
const CAMEL_RE = /\b[a-z]+[A-Z][A-Za-z0-9]{2,}/g;
|
|
47
|
+
const FILE_RE = /[\w.\u4e00-\u9fff-]+\.(?:java|xml|vue|ts|tsx|js|sql|md|yml|yaml|json|ps1|py|sh|properties|toml)/gi;
|
|
48
|
+
/**
|
|
49
|
+
* 主题键:优先实体(标识符/文件/命令),退化到中文关键词指纹。
|
|
50
|
+
* 返回空串表示"完全无可提取的主题"(调用方应退回 Jaccard)。
|
|
51
|
+
*/
|
|
52
|
+
export function topicKey(text) {
|
|
53
|
+
const raw = String(text ?? "");
|
|
54
|
+
const upper = (raw.match(UPPER_RE) ?? []).map((item) => item.toLowerCase());
|
|
55
|
+
const camel = (raw.match(CAMEL_RE) ?? []).map((item) => item.toLowerCase());
|
|
56
|
+
const files = (raw.match(FILE_RE) ?? []).map((item) => (item.split(/[\\/]/).pop() ?? item).toLowerCase());
|
|
57
|
+
const lower = raw.toLowerCase();
|
|
58
|
+
const commands = COMMANDS.filter((command) => lower.includes(command));
|
|
59
|
+
const entities = [...new Set([...upper, ...camel, ...files, ...commands])].sort();
|
|
60
|
+
if (entities.length > 0)
|
|
61
|
+
return entities.slice(0, 5).join("+");
|
|
62
|
+
// 没有实体:退化到中文/英文关键词(去重后取前 6 个),仅供"同句重复"兜底
|
|
63
|
+
const cjk = (raw.match(/[\u4e00-\u9fff]{2,}/g) ?? []).flatMap((run) => {
|
|
64
|
+
const grams = [];
|
|
65
|
+
for (let i = 0; i < run.length - 1; i++)
|
|
66
|
+
grams.push(run.slice(i, i + 2));
|
|
67
|
+
return grams;
|
|
68
|
+
});
|
|
69
|
+
const words = lower.match(/[a-z0-9]{4,}/g) ?? [];
|
|
70
|
+
const picked = [...new Set([...words, ...cjk])].slice(0, 6);
|
|
71
|
+
return picked.join("+");
|
|
72
|
+
}
|
|
73
|
+
/** 记忆 id:kind 参与哈希(同一标识符的“构建”与“死路”是两条知识)。 */
|
|
74
|
+
export function memoryId(kind, text) {
|
|
75
|
+
const key = topicKey(text);
|
|
76
|
+
if (!key)
|
|
77
|
+
return "";
|
|
78
|
+
return fnv1a32(`${kind}|${key}`).toString(16).padStart(8, "0").slice(0, 8);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* 新版本是否比旧版本更"具体":更长,且覆盖了旧文本里的标识符。
|
|
82
|
+
* 用于把「先到先得」改成「更精确的版本覆盖旧的」——否则后来更完整的表述会被丢掉。
|
|
83
|
+
*/
|
|
84
|
+
export function isMoreSpecific(next, previous) {
|
|
85
|
+
const before = topicKey(previous).split("+").filter(Boolean);
|
|
86
|
+
const after = topicKey(next).split("+").filter(Boolean);
|
|
87
|
+
if (after.length === 0)
|
|
88
|
+
return false;
|
|
89
|
+
// 旧主题必须被**完整覆盖**(新文本可能提到更多实体——那正是"更具体");
|
|
90
|
+
// 覆盖不到就说明讲的是另一件事,不许覆盖。
|
|
91
|
+
if (!before.every((part) => after.includes(part)))
|
|
92
|
+
return false;
|
|
93
|
+
return next.length > previous.length + 4;
|
|
94
|
+
}
|
|
95
|
+
/** 给记忆挂上 id(老数据缺 id 时按算法补,幂等)。 */
|
|
96
|
+
export function withIds(facts) {
|
|
97
|
+
return facts.map((fact) => ({ ...fact, id: fact.id && fact.id.length > 0 ? fact.id : memoryId(fact.kind, fact.text) }));
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 稳定编号:按时间升序的序号(1 起)。注入与 markdown 里都用它,用户可点名(“#7 过时了”)。
|
|
101
|
+
* 注意:条目被裁掉后编号会顺移,所以同时给出短 id(跨裁剪稳定)——引用时 id 更可靠。
|
|
102
|
+
*/
|
|
103
|
+
export function numberFacts(facts) {
|
|
104
|
+
return facts.map((item, index) => ({ item, n: index + 1 }));
|
|
105
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 运行时度量:把「Lume 到底有没有让它更聪明」从口头判断变成可统计的事实(0.8.x)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要(2026-09-24 复盘):此前整条链路里**没有任何外部信号进回路**——
|
|
5
|
+
* 路由判错率、触发器命中后行为是否真的变了、条款加权有没有用、用户纠正率,
|
|
6
|
+
* 一个都没测。反思日志打的是模型自评(5 个维度自评分),自评不是度量:
|
|
7
|
+
* 它既不能证明改对了,也不能指出改哪里。于是每加一条规则都只能靠「感觉这次好点」。
|
|
8
|
+
*
|
|
9
|
+
* 度量口径的三条纪律(照着写,别放宽):
|
|
10
|
+
* 1. **只收机械可判的事实**:模式/命中规则/计数器/工具计数/用户纠正词,
|
|
11
|
+
* 一律不解释语义(`coverage.ts` 那句「语义正确性判不了,不装」同样适用)。
|
|
12
|
+
* 2. **外部信号优先**:用户纠正、重复请求、越权改动都是**用户给的**,
|
|
13
|
+
* 不与模型自评混在一起统计。
|
|
14
|
+
* 3. **测不了就说测不了**:触发器里只有一部分有机械可判的「预期行为变化」,
|
|
15
|
+
* 其余明确标 `none` 并排除在比例之外——宁可样本小,也不要假绿灯。
|
|
16
|
+
*
|
|
17
|
+
* 本模块是纯函数 + 纯类型(无 I/O),落盘与环形缓冲在 host/metrics-log.ts。
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* 触发器的预期行为变化(机械可判):
|
|
21
|
+
* - verify:窗口内出现一次「真验证命令」(`verify-run`;台账 verified 自动推进,不算证据)
|
|
22
|
+
* - contract / design / ledger / hypothesis:对应载具从无到有
|
|
23
|
+
* - none:**测不了**(判据漂移、知识采集这类没有机械口径),排除在比例外
|
|
24
|
+
*/
|
|
25
|
+
export const TRIGGER_EXPECT = {
|
|
26
|
+
"verify-as-you-go": "verify",
|
|
27
|
+
"dead-path": "verify",
|
|
28
|
+
"contract-missing": "contract",
|
|
29
|
+
"design-missing": "design",
|
|
30
|
+
// 决策分档:提醒是二选一(先做最便宜的核实 / 或落成假设),所以两条路都认。
|
|
31
|
+
// 只认「假设」会虚低——模型走了被鼓励的那条路(核实)反而记 0 改善(外部审核指出)。
|
|
32
|
+
"unfounded-change": "verify-or-hypothesis",
|
|
33
|
+
converge: "ledger",
|
|
34
|
+
"hypothesis-stale": "hypothesis",
|
|
35
|
+
"criteria-drift": "none",
|
|
36
|
+
"knowledge-capture": "none",
|
|
37
|
+
};
|
|
38
|
+
export function triggerExpect(id) {
|
|
39
|
+
return TRIGGER_EXPECT[id] ?? "none";
|
|
40
|
+
}
|
|
41
|
+
export function toMetricLine(record) {
|
|
42
|
+
return JSON.stringify(record);
|
|
43
|
+
}
|
|
44
|
+
/** 容错解析:坏行直接跳过(日志是诊断通道,一行坏不该让统计整体失败)。 */
|
|
45
|
+
export function parseMetricLines(text) {
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const line of text.split("\n")) {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (!trimmed.startsWith("{"))
|
|
50
|
+
continue;
|
|
51
|
+
try {
|
|
52
|
+
const parsed = JSON.parse(trimmed);
|
|
53
|
+
if (parsed && typeof parsed === "object" && typeof parsed.kind === "string")
|
|
54
|
+
out.push(parsed);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* 半行/损坏行:跳过 */
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
/** 默认效能观察窗:命中后 3 轮内看行为是否变了(一多就归因不清)。 */
|
|
63
|
+
export const EFFICACY_WINDOW_TURNS = 3;
|
|
64
|
+
function bump(map, key) {
|
|
65
|
+
map[key] = (map[key] ?? 0) + 1;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 聚合。`sid` 给了就只看那条会话;不给则看全部记录。
|
|
69
|
+
* 效能判定只在**同一会话**内做(跨会话的轮次不可比),且只看命中之后的状态快照。
|
|
70
|
+
*/
|
|
71
|
+
export function summarizeMetrics(records, opts = {}) {
|
|
72
|
+
const sid = opts.sid;
|
|
73
|
+
const window = opts.efficacyWindow ?? EFFICACY_WINDOW_TURNS;
|
|
74
|
+
const scoped = sid ? records.filter((record) => record.sid === sid) : records;
|
|
75
|
+
const sessions = new Set(scoped.map((record) => record.sid));
|
|
76
|
+
// 单趟建 sid → 记录 索引:效能判定只扫同会话的记录。
|
|
77
|
+
// 原来每个命中都全量重扫(叠加 baselineAt 再来一遍),ring 800 时最坏几十万次比较,
|
|
78
|
+
// lume_metrics 频繁手调会有可见延迟(2026-09-24 审核指出)。
|
|
79
|
+
const bySid = new Map();
|
|
80
|
+
for (const record of scoped) {
|
|
81
|
+
const list = bySid.get(record.sid);
|
|
82
|
+
if (list)
|
|
83
|
+
list.push(record);
|
|
84
|
+
else
|
|
85
|
+
bySid.set(record.sid, [record]);
|
|
86
|
+
}
|
|
87
|
+
const routes = { total: 0, byMode: {}, byMatched: {}, bySource: {} };
|
|
88
|
+
const outcomes = { corrections: 0, repeats: 0, overreach: 0, noAction: 0, correctionsByMode: {} };
|
|
89
|
+
const blocks = { steps: 0, droppedSteps: 0, avgChars: 0, budget: 0, focusCounts: {} };
|
|
90
|
+
let charsTotal = 0;
|
|
91
|
+
for (const record of scoped) {
|
|
92
|
+
if (record.kind === "route") {
|
|
93
|
+
routes.total++;
|
|
94
|
+
bump(routes.byMode, record.mode);
|
|
95
|
+
bump(routes.byMatched, record.matched);
|
|
96
|
+
bump(routes.bySource, record.source);
|
|
97
|
+
}
|
|
98
|
+
else if (record.kind === "outcome") {
|
|
99
|
+
if (record.event === "user-correction") {
|
|
100
|
+
outcomes.corrections++;
|
|
101
|
+
bump(outcomes.correctionsByMode, record.mode);
|
|
102
|
+
}
|
|
103
|
+
else if (record.event === "repeat-request")
|
|
104
|
+
outcomes.repeats++;
|
|
105
|
+
else if (record.event === "overreach")
|
|
106
|
+
outcomes.overreach++;
|
|
107
|
+
else if (record.event === "no-action")
|
|
108
|
+
outcomes.noAction++;
|
|
109
|
+
}
|
|
110
|
+
else if (record.kind === "blocks") {
|
|
111
|
+
blocks.steps++;
|
|
112
|
+
charsTotal += record.chars;
|
|
113
|
+
blocks.budget = record.budget;
|
|
114
|
+
if (record.dropped > 0)
|
|
115
|
+
blocks.droppedSteps++;
|
|
116
|
+
for (const id of record.focus)
|
|
117
|
+
bump(blocks.focusCounts, id);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
blocks.avgChars = blocks.steps > 0 ? Math.round(charsTotal / blocks.steps) : 0;
|
|
121
|
+
// 触发器效能:拿命中轮的状态快照当基线,看窗口内是否出现预期变化。
|
|
122
|
+
const byId = new Map();
|
|
123
|
+
for (const record of scoped) {
|
|
124
|
+
if (record.kind !== "trigger")
|
|
125
|
+
continue;
|
|
126
|
+
const expect = record.expect ?? triggerExpect(record.id);
|
|
127
|
+
const entry = byId.get(record.id) ?? { id: record.id, expect, fired: 0, improved: 0 };
|
|
128
|
+
entry.fired++;
|
|
129
|
+
if (expect !== "none" && improvedAfter(bySid.get(record.sid) ?? [record], { ...record, expect }, window))
|
|
130
|
+
entry.improved++;
|
|
131
|
+
byId.set(record.id, entry);
|
|
132
|
+
}
|
|
133
|
+
const triggers = [...byId.values()].sort((a, b) => b.fired - a.fired);
|
|
134
|
+
const measured = triggers.filter((entry) => entry.expect !== "none");
|
|
135
|
+
return {
|
|
136
|
+
sessions: sessions.size,
|
|
137
|
+
records: scoped.length,
|
|
138
|
+
routes,
|
|
139
|
+
outcomes,
|
|
140
|
+
blocks,
|
|
141
|
+
triggers,
|
|
142
|
+
measuredTriggers: measured.length,
|
|
143
|
+
improvedTriggers: measured.filter((entry) => entry.improved > 0).length,
|
|
144
|
+
measuredHits: measured.reduce((sum, entry) => sum + entry.fired, 0),
|
|
145
|
+
improvedHits: measured.reduce((sum, entry) => sum + entry.improved, 0),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/** 窗口内是否出现过「真验证命令」(机械可判,且不是台账自动推进的产物)。 */
|
|
149
|
+
function hasVerifyRun(records, fire, window) {
|
|
150
|
+
for (const record of records) {
|
|
151
|
+
if (record.kind !== "outcome" || record.sid !== fire.sid || record.event !== "verify-run")
|
|
152
|
+
continue;
|
|
153
|
+
// 验证常常就发生在命中的**同一轮**里,所以允许同轮但必须在命中之后。
|
|
154
|
+
if (record.turn < fire.turn || record.turn > fire.turn + window)
|
|
155
|
+
continue;
|
|
156
|
+
if (record.at < fire.at)
|
|
157
|
+
continue;
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
/** 命中之后(窗口轮内)是否出现过预期变化。 */
|
|
163
|
+
function improvedAfter(records, fire, window) {
|
|
164
|
+
// verify 类只认「真验证命令」:台账 verified 由自动推进产生,拿它当判据等于自我表扬。
|
|
165
|
+
if (fire.expect === "verify")
|
|
166
|
+
return hasVerifyRun(records, fire, window);
|
|
167
|
+
// 二选一的提醒:真验证命令与「假设从无到有」任一出现都算改善
|
|
168
|
+
if (fire.expect === "verify-or-hypothesis") {
|
|
169
|
+
if (hasVerifyRun(records, fire, window))
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
const base = baselineAt(records, fire);
|
|
173
|
+
if (!base)
|
|
174
|
+
return false;
|
|
175
|
+
for (const record of records) {
|
|
176
|
+
if (record.kind !== "state" || record.sid !== fire.sid)
|
|
177
|
+
continue;
|
|
178
|
+
// 允许**同轮**(但必须在命中之后):快照已经按步记录了,如果再要求 turn 严格大于命中轮,
|
|
179
|
+
// 同一轮里新增的快照对效能判定就完全不可见——只有 verify-run 那条路吃到了新分辨率(审核指出)。
|
|
180
|
+
if (record.turn < fire.turn || record.turn > fire.turn + window)
|
|
181
|
+
continue;
|
|
182
|
+
if (record.at < fire.at)
|
|
183
|
+
continue;
|
|
184
|
+
switch (fire.expect) {
|
|
185
|
+
case "contract":
|
|
186
|
+
if (record.hasContract && !base.hasContract)
|
|
187
|
+
return true;
|
|
188
|
+
break;
|
|
189
|
+
case "design":
|
|
190
|
+
if (record.designs > base.designs)
|
|
191
|
+
return true;
|
|
192
|
+
break;
|
|
193
|
+
case "ledger":
|
|
194
|
+
if (record.changes > base.changes)
|
|
195
|
+
return true;
|
|
196
|
+
break;
|
|
197
|
+
case "verify-or-hypothesis":
|
|
198
|
+
case "hypothesis":
|
|
199
|
+
if (record.hypotheses > base.hypotheses)
|
|
200
|
+
return true;
|
|
201
|
+
break;
|
|
202
|
+
default:
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
/** 命中轮(或之前最近一轮)的状态快照——命中发生在工具结果阶段,同轮快照可能还没写。 */
|
|
209
|
+
function baselineAt(records, fire) {
|
|
210
|
+
let best = null;
|
|
211
|
+
for (const record of records) {
|
|
212
|
+
if (record.kind !== "state" || record.sid !== fire.sid)
|
|
213
|
+
continue;
|
|
214
|
+
if (record.turn > fire.turn)
|
|
215
|
+
continue;
|
|
216
|
+
if (record.at > fire.at)
|
|
217
|
+
continue;
|
|
218
|
+
if (!best || record.turn > best.turn || (record.turn === best.turn && record.at > best.at))
|
|
219
|
+
best = record;
|
|
220
|
+
}
|
|
221
|
+
return best;
|
|
222
|
+
}
|
|
223
|
+
function ratio(part, total) {
|
|
224
|
+
if (total <= 0)
|
|
225
|
+
return "—";
|
|
226
|
+
return `${part}/${total}(${Math.round((part / total) * 100)}%)`;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* 人读格式。刻意把**口径**写在数字旁边:没有口径的漂亮数字比没有数字更坏
|
|
230
|
+
* (「改善率 80%」如果分母是 5 条且窗口是 3 轮,读者必须能看出来)。
|
|
231
|
+
*/
|
|
232
|
+
export function formatMetricsSummary(summary, opts = {}) {
|
|
233
|
+
const lines = [];
|
|
234
|
+
lines.push(`Lume 度量${opts.label ? `(${opts.label})` : ""}:${summary.sessions} 个会话 / ${summary.records} 条记录`);
|
|
235
|
+
const routeTail = Object.entries(summary.routes.byMode)
|
|
236
|
+
.sort((a, b) => b[1] - a[1])
|
|
237
|
+
.map(([mode, count]) => `${mode} ${count}`)
|
|
238
|
+
.join(" · ");
|
|
239
|
+
lines.push(`- 路由判定 ${summary.routes.total} 次${routeTail ? `:${routeTail}` : ""}`);
|
|
240
|
+
const matchedTail = Object.entries(summary.routes.byMatched)
|
|
241
|
+
.sort((a, b) => b[1] - a[1])
|
|
242
|
+
.slice(0, 5)
|
|
243
|
+
.map(([key, count]) => `${key} ${count}`)
|
|
244
|
+
.join(" · ");
|
|
245
|
+
if (matchedTail)
|
|
246
|
+
lines.push(` 命中判据:${matchedTail}`);
|
|
247
|
+
const sourceTail = Object.entries(summary.routes.bySource)
|
|
248
|
+
.map(([key, count]) => `${key} ${count}`)
|
|
249
|
+
.join(" · ");
|
|
250
|
+
if (sourceTail)
|
|
251
|
+
lines.push(` 证据来源:${sourceTail}(text=只看这一句,trajectory/sticky=轨迹补证,correction=纠正后重算)`);
|
|
252
|
+
const o = summary.outcomes;
|
|
253
|
+
lines.push(`- 外部结果信号:用户纠正 ${o.corrections} · 重复请求 ${o.repeats} · 问答轮改动 ${o.overreach} · 执行轮零动作 ${o.noAction}`);
|
|
254
|
+
const wrongModes = Object.entries(o.correctionsByMode)
|
|
255
|
+
.sort((a, b) => b[1] - a[1])
|
|
256
|
+
.map(([mode, count]) => `${mode} ${count}`)
|
|
257
|
+
.join(" · ");
|
|
258
|
+
if (wrongModes)
|
|
259
|
+
lines.push(` 纠正落在:${wrongModes}(落在哪个模式上就是哪个模式在误判)`);
|
|
260
|
+
lines.push(`- 块装配:${summary.blocks.steps} 步,平均 ${summary.blocks.avgChars} 字符 / 预算 ${summary.blocks.budget || 4200},超预算丢块 ${ratio(summary.blocks.droppedSteps, summary.blocks.steps)}`);
|
|
261
|
+
if (summary.triggers.length === 0)
|
|
262
|
+
lines.push("- 触发器:本区间没有命中记录");
|
|
263
|
+
else {
|
|
264
|
+
lines.push(`- 触发器效能(**观察性,不是因果**;窗口 ${EFFICACY_WINDOW_TURNS} 轮):按命中次数 ${ratio(summary.improvedHits, summary.measuredHits)};按类别 ${summary.improvedTriggers}/${summary.measuredTriggers} 类出现过改善`);
|
|
265
|
+
lines.push(" 判据:窗口内出现「真验证命令」或对应载具(契约/设计/台账/假设)从无到有;没机械口径的类别标「未判定」、不计入分母。");
|
|
266
|
+
for (const entry of summary.triggers)
|
|
267
|
+
lines.push(` · ${entry.id}:命中 ${entry.fired} 次,改善 ${entry.improved}${entry.expect === "none" ? "(无机械口径,未判定)" : ""}`);
|
|
268
|
+
}
|
|
269
|
+
return lines.join("\n");
|
|
270
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** manifest 内置人设名 —— 自定义创建/删除不可触碰。 */
|
|
2
|
+
export const BUILTIN_PERSONA_NAMES = new Set(["loli", "senpai", "butler", "tsundere", "none"]);
|
|
3
|
+
export const CORPUS_CAP = 12;
|
|
4
|
+
export const CORPUS_LINE_CAP = 240;
|
|
5
|
+
export const MEMORY_CAP = 30;
|
|
6
|
+
export const STYLE_CAP = 20;
|
|
7
|
+
/** 语料净化:只保留 {user?, assistant} 形状的合法样本,超限截断。 */
|
|
8
|
+
export function sanitizeCorpus(value) {
|
|
9
|
+
if (!Array.isArray(value))
|
|
10
|
+
return [];
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const item of value) {
|
|
13
|
+
const assistant = item?.assistant;
|
|
14
|
+
const user = item?.user;
|
|
15
|
+
if (typeof assistant === "string" && assistant.trim()) {
|
|
16
|
+
out.push({
|
|
17
|
+
user: typeof user === "string" ? user.slice(0, CORPUS_LINE_CAP) : "",
|
|
18
|
+
assistant: assistant.slice(0, CORPUS_LINE_CAP),
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
if (out.length >= CORPUS_CAP)
|
|
22
|
+
break;
|
|
23
|
+
}
|
|
24
|
+
return out;
|
|
25
|
+
}
|