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
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话补蒸馏(backfill):把**已经发生过**的会话(含已经撑满、再也聊不动的那些)榨成跨会话知识。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:上下文撑满 → 宿主 compaction 会失败(现场日志:
|
|
5
|
+
* `compaction/end … error: "pi-ai detected context overflow"`)→ 会话再也产不出事件 →
|
|
6
|
+
* **凡是在此之前没被沉淀的内容就永久丢失**。但会话记录本身**还在硬盘上**
|
|
7
|
+
* (`<DSH_HOME>/harness/sessions/<workspace>/<sid>/session.v3.jsonl.zstd`),所以可以离线补。
|
|
8
|
+
*
|
|
9
|
+
* 设计约束(都是被现场教出来的):
|
|
10
|
+
* - **不能阻塞宿主**:插件与宿主同进程,解压大会话要几十~几百毫秒 → 分片执行(每次一个会话,其间让出事件循环)。
|
|
11
|
+
* - **必须幂等**:同一批会话反复扫不能重复写(靠 addFact 的相似度去重 + 本轮已收集文本集)。
|
|
12
|
+
* - **只认最近一段**:默认回看 7 天,避免每次启动扫全部历史。
|
|
13
|
+
* - **失败静默降级**:宿主内部目录结构变了就什么都不做(只记一行日志),绝不影响启动。
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { zstdDecompressSync } from "node:zlib";
|
|
18
|
+
/**
|
|
19
|
+
* 定位 DSH 数据目录(补蒸馏要找 `<base>/harness/sessions`)。
|
|
20
|
+
*
|
|
21
|
+
* 现场教训(2026-09-24):最初只认 `DSH_HOME`,而宿主进程里它可能**不存在**(实测 null),
|
|
22
|
+
* 于是 `startSessionBackfill` 直接静默返回 —— **重启后一条知识都没沉淀,且没有任何日志**。
|
|
23
|
+
* 现在按候选探测(`%APPDATA%\dsh-desktop` 是现成路径),并且**无论成功失败都留痕**。
|
|
24
|
+
*/
|
|
25
|
+
export function resolveDsHome(env = process.env) {
|
|
26
|
+
const candidates = [];
|
|
27
|
+
if (env.DSH_HOME)
|
|
28
|
+
candidates.push(env.DSH_HOME);
|
|
29
|
+
if (env.LUME_DS_HOME)
|
|
30
|
+
candidates.push(env.LUME_DS_HOME);
|
|
31
|
+
if (env.APPDATA)
|
|
32
|
+
candidates.push(join(env.APPDATA, "dsh-desktop"));
|
|
33
|
+
if (env.LOCALAPPDATA)
|
|
34
|
+
candidates.push(join(env.LOCALAPPDATA, "dsh-desktop"));
|
|
35
|
+
for (const candidate of candidates) {
|
|
36
|
+
try {
|
|
37
|
+
if (existsSync(join(candidate, "harness", "sessions")))
|
|
38
|
+
return candidate;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* 探测失败就试下一个 */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const SESSION_FILE = "session.v3.jsonl.zstd";
|
|
47
|
+
const DEFAULT_DAYS = 7;
|
|
48
|
+
const DEFAULT_PER_SESSION = 12;
|
|
49
|
+
/** 解压多帧 zstd 会话日志(宿主按帧追加写)。 */
|
|
50
|
+
function readSessionEvents(file) {
|
|
51
|
+
const buf = readFileSync(file);
|
|
52
|
+
const frames = [];
|
|
53
|
+
let cur = 0;
|
|
54
|
+
for (let i = 4; i < buf.length - 3; i++) {
|
|
55
|
+
if (buf[i] === 0x28 && buf[i + 1] === 0xb5 && buf[i + 2] === 0x2f && buf[i + 3] === 0xfd) {
|
|
56
|
+
frames.push(buf.subarray(cur, i));
|
|
57
|
+
cur = i;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
frames.push(buf.subarray(cur));
|
|
61
|
+
let text = "";
|
|
62
|
+
for (const frame of frames) {
|
|
63
|
+
try {
|
|
64
|
+
text += zstdDecompressSync(frame).toString("utf8");
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
/* 尾部半帧忽略 */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const out = [];
|
|
71
|
+
for (const line of text.split("\n")) {
|
|
72
|
+
if (!line)
|
|
73
|
+
continue;
|
|
74
|
+
try {
|
|
75
|
+
out.push(JSON.parse(line));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
/* 跳过坏行 */
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** 列出最近 N 天内有改动的会话文件(按 mtime 升序,旧的先处理)。 */
|
|
84
|
+
export function recentSessionFiles(dsHome, days = DEFAULT_DAYS) {
|
|
85
|
+
const root = join(dsHome, "harness", "sessions");
|
|
86
|
+
const out = [];
|
|
87
|
+
const cutoff = Date.now() - days * 86_400_000;
|
|
88
|
+
let workspaces = [];
|
|
89
|
+
try {
|
|
90
|
+
workspaces = readdirSync(root);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
for (const ws of workspaces) {
|
|
96
|
+
let sessions = [];
|
|
97
|
+
try {
|
|
98
|
+
sessions = readdirSync(join(root, ws));
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const sid of sessions) {
|
|
104
|
+
const file = join(root, ws, sid, SESSION_FILE);
|
|
105
|
+
try {
|
|
106
|
+
const stat = statSync(file);
|
|
107
|
+
if (stat.isFile() && stat.mtimeMs >= cutoff)
|
|
108
|
+
out.push({ file, mtime: stat.mtimeMs });
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
/* 没有该文件 */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out.sort((a, b) => a.mtime - b.mtime);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 分片执行补蒸馏:每次处理一个会话,`chunkMs` 后处理下一个(不阻塞宿主)。
|
|
119
|
+
* 返回停止函数(会话被销毁/插件卸载时调用)。
|
|
120
|
+
*/
|
|
121
|
+
export function startBackfill(deps, options = {}) {
|
|
122
|
+
const perSession = deps.perSession ?? DEFAULT_PER_SESSION;
|
|
123
|
+
const maxSessions = options.maxSessions ?? 60;
|
|
124
|
+
const chunkMs = options.chunkMs ?? 150;
|
|
125
|
+
const files = recentSessionFiles(deps.dsHome, options.days ?? DEFAULT_DAYS);
|
|
126
|
+
if (files.length === 0)
|
|
127
|
+
return () => {
|
|
128
|
+
/* 无可补 */
|
|
129
|
+
};
|
|
130
|
+
let index = 0;
|
|
131
|
+
let stopped = false;
|
|
132
|
+
let totalAdded = 0;
|
|
133
|
+
let timer = null;
|
|
134
|
+
const step = async () => {
|
|
135
|
+
if (stopped)
|
|
136
|
+
return;
|
|
137
|
+
const item = files[index++];
|
|
138
|
+
if (!item) {
|
|
139
|
+
deps.log(`lume: 会话补蒸馏完成:扫描 ${Math.min(files.length, maxSessions)} 个会话,新增 ${totalAdded} 条跨会话知识`);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
try {
|
|
143
|
+
const events = readSessionEvents(item.file);
|
|
144
|
+
// 会话标题:作用域判定要用(需求级知识只给同一需求看)
|
|
145
|
+
let sessionTitle = "";
|
|
146
|
+
for (const scan of events) {
|
|
147
|
+
if (scan.type !== "session/title")
|
|
148
|
+
continue;
|
|
149
|
+
sessionTitle = String(scan.data?.title ?? "").slice(0, 60);
|
|
150
|
+
if (sessionTitle)
|
|
151
|
+
break;
|
|
152
|
+
}
|
|
153
|
+
let cwd = null;
|
|
154
|
+
for (const event of events) {
|
|
155
|
+
if (event.type !== "user/message")
|
|
156
|
+
continue;
|
|
157
|
+
cwd = deps.workspaceOf(deps.messageText(event.data?.message ?? event.data));
|
|
158
|
+
if (cwd)
|
|
159
|
+
break;
|
|
160
|
+
}
|
|
161
|
+
const key = cwd ? deps.projectKeyOf(cwd) : null;
|
|
162
|
+
if (key) {
|
|
163
|
+
let added = 0;
|
|
164
|
+
const seen = [];
|
|
165
|
+
for (const event of events) {
|
|
166
|
+
if (added >= perSession)
|
|
167
|
+
break;
|
|
168
|
+
let candidates = [];
|
|
169
|
+
if (event.type === "tool/result")
|
|
170
|
+
candidates = deps.extract(deps.messageText(event.data?.message), "tool");
|
|
171
|
+
else if (event.type === "assistant/message") {
|
|
172
|
+
const visible = deps.visibleText(event.data?.message);
|
|
173
|
+
if (visible)
|
|
174
|
+
candidates = deps.extract(visible, "assistant");
|
|
175
|
+
}
|
|
176
|
+
else if (event.type === "user/message") {
|
|
177
|
+
const text = deps.messageText(event.data?.message ?? event.data);
|
|
178
|
+
if (text && !text.includes("Current runtime context"))
|
|
179
|
+
candidates = deps.extract(text, "user");
|
|
180
|
+
}
|
|
181
|
+
for (const candidate of candidates) {
|
|
182
|
+
if (added >= perSession)
|
|
183
|
+
break;
|
|
184
|
+
if (deps.looksSensitive(candidate.text))
|
|
185
|
+
continue;
|
|
186
|
+
if (seen.some((prior) => prior === candidate.text))
|
|
187
|
+
continue;
|
|
188
|
+
const fact = deps.normalizeFact({ kind: candidate.kind, text: candidate.text }, Number(event.time) || Date.now(), {
|
|
189
|
+
taskTitle: sessionTitle,
|
|
190
|
+
requirementHints: deps.requirementHintsOf(cwd),
|
|
191
|
+
});
|
|
192
|
+
if (!fact)
|
|
193
|
+
continue;
|
|
194
|
+
seen.push(candidate.text);
|
|
195
|
+
if (await deps.addFact(key, fact))
|
|
196
|
+
added++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
totalAdded += added;
|
|
200
|
+
if (added > 0)
|
|
201
|
+
deps.log(`lume: 会话补蒸馏:${item.file.split(/[\\/]/).slice(-2)[0]?.slice(0, 18)} → ${key} 新增 ${added} 条`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
deps.log(`lume: 会话补蒸馏跳过(${String(error).slice(0, 80)})`);
|
|
206
|
+
}
|
|
207
|
+
if (index < files.length && index < maxSessions)
|
|
208
|
+
timer = setTimeout(() => void step(), chunkMs);
|
|
209
|
+
else
|
|
210
|
+
deps.log(`lume: 会话补蒸馏收尾:共扫描 ${index} 个会话,新增 ${totalAdded} 条`);
|
|
211
|
+
};
|
|
212
|
+
timer = setTimeout(() => void step(), chunkMs);
|
|
213
|
+
return () => {
|
|
214
|
+
stopped = true;
|
|
215
|
+
if (timer)
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
};
|
|
218
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { IdentityStore, LUME_IDENTITY_SPEC } from "./identity.js";
|
|
2
|
+
import { LUME_PROJECT_SPEC, ProjectStore } from "./project.js";
|
|
3
|
+
import { LUME_REFLECTION_SPEC, ReflectionStore } from "./reflection.js";
|
|
4
|
+
import { FilePersonaStore, PersonaStore } from "./store.js";
|
|
5
|
+
export function initStores(input) {
|
|
6
|
+
// ── 存储就绪:会话选择域(必有)+ 身份域(失败降级为无档案功能)──
|
|
7
|
+
let currentStore = null;
|
|
8
|
+
let identity = null;
|
|
9
|
+
const storesReady = (async () => {
|
|
10
|
+
try {
|
|
11
|
+
const domain = await input.ctx.storageDomain.open(input.personaDomainSpec);
|
|
12
|
+
input.ctx.effect(() => async () => {
|
|
13
|
+
await domain.close();
|
|
14
|
+
}, "lume: close state domain");
|
|
15
|
+
const store = new PersonaStore(domain.table(input.sessionPersonaTable), { maxSessions: input.maxSessions });
|
|
16
|
+
const migrated = await input.migrateLegacyState(store, input.legacyStatePath);
|
|
17
|
+
if (migrated)
|
|
18
|
+
input.ctx.logger?.warn?.("lume: 已从 assets/persona-state.json 迁移旧的人设记忆");
|
|
19
|
+
return store;
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
input.ctx.logger?.warn?.("lume: storageDomain 不可用,降级为 assets 文件存储", error);
|
|
23
|
+
return new FilePersonaStore(input.legacyStatePath, { maxSessions: input.maxSessions });
|
|
24
|
+
}
|
|
25
|
+
})();
|
|
26
|
+
const identityReady = (async () => {
|
|
27
|
+
try {
|
|
28
|
+
const domain = await input.ctx.storageDomain.open(LUME_IDENTITY_SPEC);
|
|
29
|
+
input.ctx.effect(() => async () => {
|
|
30
|
+
await domain.close();
|
|
31
|
+
}, "lume: close identity domain");
|
|
32
|
+
return new IdentityStore({
|
|
33
|
+
profile: domain.table("profile"),
|
|
34
|
+
memory_facts: domain.table("memory_facts"),
|
|
35
|
+
style_rules: domain.table("style_rules"),
|
|
36
|
+
corpus_pins: domain.table("corpus_pins"),
|
|
37
|
+
custom_personas: domain.table("custom_personas"),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
input.ctx.logger?.warn?.("lume: 身份域不可用,档案/记忆/自定义人设功能降级", error);
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
})();
|
|
45
|
+
// 已吞异常:内部 try/catch 后返回降级值,句柄赋值不会 reject
|
|
46
|
+
void storesReady.then((store) => {
|
|
47
|
+
currentStore = store;
|
|
48
|
+
});
|
|
49
|
+
// 已吞异常:内部 try/catch 后返回降级值,句柄赋值不会 reject
|
|
50
|
+
void identityReady.then((store) => {
|
|
51
|
+
identity = store;
|
|
52
|
+
});
|
|
53
|
+
// ── 反思域(会话结束后打分,失败降级为无反思功能)──
|
|
54
|
+
let reflectionStore = null;
|
|
55
|
+
let project = null;
|
|
56
|
+
const reflectionReady = (async () => {
|
|
57
|
+
try {
|
|
58
|
+
const domain = await input.ctx.storageDomain.open(LUME_REFLECTION_SPEC);
|
|
59
|
+
input.ctx.effect(() => async () => {
|
|
60
|
+
await domain.close();
|
|
61
|
+
}, "lume: close reflection domain");
|
|
62
|
+
const store = new ReflectionStore(domain.table("logs"));
|
|
63
|
+
const migrated = await store.migrateLegacy();
|
|
64
|
+
if (migrated > 0)
|
|
65
|
+
input.ctx.logger?.warn?.(`lume: 已迁移 ${migrated} 条旧版反思日志`);
|
|
66
|
+
return store;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
input.ctx.logger?.warn?.("lume: 反思域不可用,反思日志降级", error);
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
})();
|
|
73
|
+
// 已吞异常:内部 try/catch 后返回降级值,句柄赋值不会 reject
|
|
74
|
+
void reflectionReady.then((s) => {
|
|
75
|
+
reflectionStore = s;
|
|
76
|
+
});
|
|
77
|
+
function projectTask(sid, label, run) {
|
|
78
|
+
void projectReady
|
|
79
|
+
.then((store) => {
|
|
80
|
+
if (!store)
|
|
81
|
+
return;
|
|
82
|
+
return Promise.resolve(run(store));
|
|
83
|
+
})
|
|
84
|
+
.catch((error) => input.ctx.logger?.warn?.(`lume: [${sid}] ${label} 失败:${input.describeError(error)}`));
|
|
85
|
+
}
|
|
86
|
+
const projectReady = (async () => {
|
|
87
|
+
if (!input.projectMemoryOn)
|
|
88
|
+
return null;
|
|
89
|
+
try {
|
|
90
|
+
const domain = await input.ctx.storageDomain.open(LUME_PROJECT_SPEC);
|
|
91
|
+
input.ctx.effect(() => async () => {
|
|
92
|
+
await domain.close();
|
|
93
|
+
}, "lume: close project domain");
|
|
94
|
+
return new ProjectStore({
|
|
95
|
+
contract: domain.table("contract"),
|
|
96
|
+
ledger: domain.table("ledger"),
|
|
97
|
+
hypotheses: domain.table("hypotheses"),
|
|
98
|
+
facts: domain.table("facts"),
|
|
99
|
+
design: domain.table("design"),
|
|
100
|
+
requirements: domain.table("requirements"),
|
|
101
|
+
taskMemory: domain.table("task_memory"),
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
input.ctx.logger?.warn?.("lume: 项目域不可用,任务契约/台账/项目知识降级", error);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
})();
|
|
109
|
+
// 已吞异常:内部 try/catch 后返回降级值,句柄赋值不会 reject
|
|
110
|
+
void projectReady.then((s) => {
|
|
111
|
+
project = s;
|
|
112
|
+
});
|
|
113
|
+
/** RPC 等入口可能在存储兑现前被调用:等一次并回填句柄。 */
|
|
114
|
+
async function ensureReady() {
|
|
115
|
+
currentStore ??= await storesReady;
|
|
116
|
+
identity ??= await identityReady;
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
ensureReady,
|
|
120
|
+
storesReady,
|
|
121
|
+
identityReady,
|
|
122
|
+
reflectionReady,
|
|
123
|
+
projectReady,
|
|
124
|
+
currentStore: () => currentStore,
|
|
125
|
+
identity: () => identity,
|
|
126
|
+
reflectionStore: () => reflectionStore,
|
|
127
|
+
project: () => project,
|
|
128
|
+
projectTask,
|
|
129
|
+
};
|
|
130
|
+
}
|
package/lib/host/boundary.js
CHANGED
|
@@ -20,9 +20,7 @@ export function composeBoundary(input) {
|
|
|
20
20
|
const divider = current
|
|
21
21
|
? `第一件事:本条回复的第一行,一字不改地单独输出这一行:\n── 「${labelOf(registry, current)}」接手 ──\n这一行是给用户的切换提示,不算出戏;从第二行起再进入正文。`
|
|
22
22
|
: "";
|
|
23
|
-
const takeover = greeting
|
|
24
|
-
? `${divider}正文第一句用简短的接手招呼,让用户明确听到换人了。`
|
|
25
|
-
: divider;
|
|
23
|
+
const takeover = greeting ? `${divider}正文第一句用简短的接手招呼,让用户明确听到换人了。` : divider;
|
|
26
24
|
const correction = escalated
|
|
27
25
|
? "特别纠偏:上一条回复仍在沿用旧人设的语气,这是偏差。本条回复必须完全按当前人设的契约说话——称呼、自称、口头禅、句式全部切换,不残留任何旧痕迹。"
|
|
28
26
|
: "";
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { ROUTE_CORRECTION_RE } from "./protocol.js";
|
|
2
|
+
import { THINKING_TEXT } from "./thinking.js";
|
|
3
|
+
/**
|
|
4
|
+
* 标题 → 稳定键。用**显式映射**而不是按顺序编号:正文里插一条、删一条都不该让
|
|
5
|
+
* 历史度量里的键集体错位(那种「改了顺序,去年的统计全变意思」的坑很贵)。
|
|
6
|
+
*/
|
|
7
|
+
const CLAUSE_ID_BY_TITLE = {
|
|
8
|
+
身份分工: "identity",
|
|
9
|
+
上下文管理: "context",
|
|
10
|
+
阶段门控: "phase-gate",
|
|
11
|
+
任务分解: "decompose",
|
|
12
|
+
自适应投入: "effort",
|
|
13
|
+
意图对齐: "align",
|
|
14
|
+
信息路由: "routing",
|
|
15
|
+
变更纪律: "change-discipline",
|
|
16
|
+
验证闭环: "verify",
|
|
17
|
+
证据时效: "evidence-recency",
|
|
18
|
+
独立判断: "independent",
|
|
19
|
+
证据来源纪律: "evidence-source",
|
|
20
|
+
事实优先: "facts-first",
|
|
21
|
+
提问纪律: "question-discipline",
|
|
22
|
+
达成标准: "done-criteria",
|
|
23
|
+
振荡预防: "oscillation",
|
|
24
|
+
结果复核: "review",
|
|
25
|
+
工具与安全: "tool-safety",
|
|
26
|
+
代码任务: "code-task",
|
|
27
|
+
对话任务: "chat-task",
|
|
28
|
+
隐私与事实边界: "fact-boundary",
|
|
29
|
+
};
|
|
30
|
+
/** 条款行形如 `**P1 验证闭环**:正文…`;无级别前缀的(工具与安全 / 代码任务…)也认。 */
|
|
31
|
+
const CLAUSE_LINE_RE = /^\*\*(?:P([0-3])\s+)?([^*]+)\*\*[::]\s*(.+)$/;
|
|
32
|
+
/**
|
|
33
|
+
* 从协议正文里切条款。切不出来就返回空表(调用方按「没有重点」处理)——
|
|
34
|
+
* 宁可少一条加权,也不要凭猜测编一条不存在的条款。
|
|
35
|
+
*/
|
|
36
|
+
export function parseProtocolClauses(fullText) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const line of fullText.split("\n")) {
|
|
39
|
+
const hit = CLAUSE_LINE_RE.exec(line.trim());
|
|
40
|
+
if (!hit)
|
|
41
|
+
continue;
|
|
42
|
+
const title = hit[2].trim();
|
|
43
|
+
const text = hit[3].trim();
|
|
44
|
+
if (!title || !text)
|
|
45
|
+
continue;
|
|
46
|
+
out.push({
|
|
47
|
+
id: CLAUSE_ID_BY_TITLE[title] ?? title,
|
|
48
|
+
tier: hit[1] ? `P${hit[1]}` : "core",
|
|
49
|
+
title,
|
|
50
|
+
text,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
/** 完整版协议切出来的条款表(短版/推理版是压缩文本,不参与加权)。 */
|
|
56
|
+
export const PROTOCOL_CLAUSES = parseProtocolClauses(THINKING_TEXT);
|
|
57
|
+
/** 每轮重述的条款上限:三条。四条以上就等于把「全给」搬到了尾部,稀释照旧。 */
|
|
58
|
+
export const FOCUS_CLAUSE_LIMIT = 3;
|
|
59
|
+
export function clauseById(id) {
|
|
60
|
+
return PROTOCOL_CLAUSES.find((clause) => clause.id === id) ?? null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 按「当前形态」选最相关的三条。
|
|
64
|
+
*
|
|
65
|
+
* 判定顺序 = 紧急度:**纠正 > 压缩 > 模式**。纠正与压缩都会让「上一轮的上下文」
|
|
66
|
+
* 不再是可靠前提,此时模式类条款反而是次要的。
|
|
67
|
+
*/
|
|
68
|
+
export function selectFocusClauses(input) {
|
|
69
|
+
const ids = focusClauseIds(input);
|
|
70
|
+
return ids
|
|
71
|
+
.map((id) => clauseById(id))
|
|
72
|
+
.filter((clause) => clause !== null)
|
|
73
|
+
.slice(0, FOCUS_CLAUSE_LIMIT);
|
|
74
|
+
}
|
|
75
|
+
/** 选中条款的稳定键(度量用:记录「这轮加权了哪三条」,才能回头看出效果)。 */
|
|
76
|
+
export function focusClauseIds(input) {
|
|
77
|
+
return applyCorrectionClosedLoop(input, baseClauseIds(input));
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 闭环:度量里采到了「哪个模式在被纠正」就必须有人消费它(2026-09-24 审核指出:
|
|
81
|
+
* correctionsByMode 采了却没有任何消费方,等于白采)。
|
|
82
|
+
*
|
|
83
|
+
* 规则保守:**本模式**被纠正 ≥2 次才动,只把「对齐纠偏」插到最前面,条数上限不变。
|
|
84
|
+
* 注意它只改变加权顺序,不改模式判定——判错模式该修判定,不该靠加一条提醒掩盖。
|
|
85
|
+
*
|
|
86
|
+
* **有冷却**(二审指出:上一版是单调、永久、无冷却的,别的机制都有 NOTICE_CAPS 兜着、这条没有):
|
|
87
|
+
* 只认「近期」纠正——最后一次纠正距今超过 CORRECTION_LOOP_TURNS 轮,就不再加权。
|
|
88
|
+
* 否则某个模式被纠两次之后,这个会话此后每一轮都挂着 align,把提醒变成背景噪音。
|
|
89
|
+
*/
|
|
90
|
+
function applyCorrectionClosedLoop(input, ids) {
|
|
91
|
+
const corrections = input.correctionModes?.[input.mode] ?? 0;
|
|
92
|
+
if (corrections < 2 || input.correction || ids[0] === "align")
|
|
93
|
+
return ids;
|
|
94
|
+
const last = input.lastCorrectionTurnByMode?.[input.mode];
|
|
95
|
+
if (last === undefined || input.turnIndex - last > CORRECTION_LOOP_TURNS)
|
|
96
|
+
return ids;
|
|
97
|
+
return ["align", ...ids].slice(0, FOCUS_CLAUSE_LIMIT);
|
|
98
|
+
}
|
|
99
|
+
/** 闭环的记忆窗口(轮):超过就不再加权,避免「沾上就摘不掉」。 */
|
|
100
|
+
export const CORRECTION_LOOP_TURNS = 6;
|
|
101
|
+
function baseClauseIds(input) {
|
|
102
|
+
if (input.correction)
|
|
103
|
+
return ["align", "question-discipline", "independent"];
|
|
104
|
+
if (input.compactionRecent)
|
|
105
|
+
return ["context", "evidence-recency", "facts-first"];
|
|
106
|
+
switch (input.mode) {
|
|
107
|
+
case "question":
|
|
108
|
+
return ["facts-first", "question-discipline", "evidence-source"];
|
|
109
|
+
case "research":
|
|
110
|
+
return ["evidence-source", "facts-first", "evidence-recency"];
|
|
111
|
+
case "discussion":
|
|
112
|
+
return ["independent", "align", "effort"];
|
|
113
|
+
case "diagnosis":
|
|
114
|
+
return ["evidence-recency", "align", "independent"];
|
|
115
|
+
case "execute": {
|
|
116
|
+
// 已经动过东西:先保「改一处验一处」,其次才是变更纪律与完成判据。
|
|
117
|
+
if ((input.unverifiedChanges ?? 0) > 0 || (input.mutations ?? 0) > 0)
|
|
118
|
+
return ["verify", "change-discipline", "done-criteria"];
|
|
119
|
+
// 还没写契约:先把「阶段门控 + 任务分解」顶上,避免直接动手。
|
|
120
|
+
if (!input.hasContract)
|
|
121
|
+
return ["phase-gate", "decompose", "done-criteria"];
|
|
122
|
+
return ["change-discipline", "verify", "code-task"];
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
return ["facts-first", "align", "done-criteria"];
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
/** 条款正文压成一句话:重述只给「抓手」,正文仍在系统提示里,不在这里复制全文。 */
|
|
129
|
+
function oneLine(text, cap = 110) {
|
|
130
|
+
const first = text.split("。")[0] ?? text;
|
|
131
|
+
const trimmed = first.endsWith("。") ? first : `${first}。`;
|
|
132
|
+
return trimmed.length > cap ? `${trimmed.slice(0, cap)}…` : trimmed;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* 渲染「本轮重点」。返回 null = 本轮不加权(没有可选项时宁可不发)。
|
|
136
|
+
* 文本刻意写明「完整协议已在系统提示」——否则模型会以为条款被缩减了,反而放宽行为。
|
|
137
|
+
*/
|
|
138
|
+
export function buildFocusClauseDirective(input) {
|
|
139
|
+
const clauses = selectFocusClauses(input);
|
|
140
|
+
if (clauses.length === 0)
|
|
141
|
+
return null;
|
|
142
|
+
const lines = clauses.map((clause, i) => {
|
|
143
|
+
const tier = clause.tier === "core" ? "" : `${clause.tier} `;
|
|
144
|
+
return `${i + 1}. ${tier}${clause.title}:${oneLine(clause.text)}`;
|
|
145
|
+
});
|
|
146
|
+
return [`〔本轮重点〕完整协议已在系统提示中(条款一条都没少);这里只按本轮形态加权最相关的 ${clauses.length} 条:`, ...lines].join("\n");
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* 装配入口(给 prompt-blocks 用):与 focusIdsFor 共用 focusInputFor——
|
|
150
|
+
* 选择政策只有一处,改规则不会漏掉记录侧(否则度量测的不是真正注入的东西)。
|
|
151
|
+
*/
|
|
152
|
+
export function focusDirectiveFor(st, mode, query, state) {
|
|
153
|
+
return buildFocusClauseDirective(focusInputFor(st, mode, query, state));
|
|
154
|
+
}
|
|
155
|
+
/** 度量入口(给 index 记「本轮到底加权了哪几条」用):同源,不是另算一遍。 */
|
|
156
|
+
export function focusIdsFor(st, mode, query, state) {
|
|
157
|
+
return focusClauseIds(focusInputFor(st, mode, query, state));
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* 从会话态构造选择输入:装配块与度量记录**共用这一处**,避免两处口径漂移。
|
|
161
|
+
* 契约/未验证条数由调用方(index,只有它拿得到 store)传入——`taskPhase` 当不了判据,
|
|
162
|
+
* 因为模式一旦判成执行,阶段就已经是 execute 了。
|
|
163
|
+
*/
|
|
164
|
+
export function focusInputFor(st, mode, query, state) {
|
|
165
|
+
return {
|
|
166
|
+
mode,
|
|
167
|
+
phase: st.taskPhase,
|
|
168
|
+
turnIndex: st.turnIndex,
|
|
169
|
+
correctionModes: state.correctionModes,
|
|
170
|
+
lastCorrectionTurnByMode: state.lastCorrectionTurnByMode,
|
|
171
|
+
correction: ROUTE_CORRECTION_RE.test(String(query ?? "")),
|
|
172
|
+
compactionRecent: st.compaction !== null && st.turnIndex - st.compaction.turnIndex <= 1,
|
|
173
|
+
unverifiedChanges: state.unverifiedChanges,
|
|
174
|
+
hasContract: state.hasContract,
|
|
175
|
+
mutations: st.triggerCounters.mutations,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// 纠正语用**直接复用** protocol 的那一份常量(曾经这里抄了一份,少了 9 个词——
|
|
179
|
+
// 「谁说让你」能重算路由却选不出「对齐」条款,是典型的口径漂移)。
|
|
180
|
+
// 判错代价只是多给/少给一条加权条款;真正决定重算模式的是 classifyWithTrajectory。
|
package/lib/host/diag.js
CHANGED
|
@@ -7,19 +7,56 @@
|
|
|
7
7
|
*
|
|
8
8
|
* 宿主未提供 DSH_HOME 时静默跳过;任何写失败都不影响功能。
|
|
9
9
|
*/
|
|
10
|
-
import { appendFileSync } from "node:fs";
|
|
10
|
+
import { appendFileSync, existsSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
/** 诊断文件名(位于 DSH_HOME 下)。 */
|
|
13
13
|
export const LUME_LOG_FILE = "lume-compaction.log";
|
|
14
|
-
/**
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* 解析诊断日志目录:`DSH_HOME` → `%APPDATA%\dsh-desktop` → `%LOCALAPPDATA%\dsh-desktop`。
|
|
16
|
+
*
|
|
17
|
+
* 现场教训(2026-09-24):宿主进程里**没有** `DSH_HOME`(实测 null),于是所有走这条通道的
|
|
18
|
+
* 诊断日志**写了等于没写** —— 补蒸馏"跑了却零日志"、映射命中日志也看不见,只能靠行为反推。
|
|
19
|
+
* 现在按候选探测,落到第一个存在的目录。
|
|
20
|
+
*/
|
|
21
|
+
export function lumeLogHome() {
|
|
22
|
+
const candidates = [
|
|
23
|
+
process.env.DSH_HOME,
|
|
24
|
+
process.env.APPDATA ? join(process.env.APPDATA, "dsh-desktop") : null,
|
|
25
|
+
process.env.LOCALAPPDATA ? join(process.env.LOCALAPPDATA, "dsh-desktop") : null,
|
|
26
|
+
];
|
|
27
|
+
for (const candidate of candidates) {
|
|
28
|
+
if (!candidate)
|
|
29
|
+
continue;
|
|
30
|
+
try {
|
|
31
|
+
if (existsSync(candidate))
|
|
32
|
+
return candidate;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* 探测失败就试下一个 */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** 追加一行到**指定目录**(测试与自检要指定落点,否则会把假数据写进真实指标文件)。 */
|
|
41
|
+
export function appendLumeLineAt(home, file, message) {
|
|
16
42
|
try {
|
|
17
|
-
|
|
18
|
-
if (!home)
|
|
19
|
-
return;
|
|
20
|
-
appendFileSync(join(home, LUME_LOG_FILE), `${new Date().toISOString()} ${message}\n`, "utf8");
|
|
43
|
+
appendFileSync(join(home, file), `${message}\n`, "utf8");
|
|
21
44
|
}
|
|
22
45
|
catch {
|
|
23
46
|
/* 诊断失败不阻断功能 */
|
|
24
47
|
}
|
|
25
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* 追加一行到指定诊断文件(自动探测落点)。
|
|
51
|
+
* 度量另开一个文件:压缩日志只在压缩时写、量小;度量每轮都写,混在一起会把压缩记录冲淡。
|
|
52
|
+
*/
|
|
53
|
+
export function appendLumeLine(file, message) {
|
|
54
|
+
const home = lumeLogHome();
|
|
55
|
+
if (!home)
|
|
56
|
+
return;
|
|
57
|
+
appendLumeLineAt(home, file, message);
|
|
58
|
+
}
|
|
59
|
+
/** 追加一行诊断;失败静默。 */
|
|
60
|
+
export function appendLumeLog(message) {
|
|
61
|
+
appendLumeLine(LUME_LOG_FILE, `${new Date().toISOString()} ${message}`);
|
|
62
|
+
}
|