lume-dsh-plugin 0.6.1 → 0.7.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 (46) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +81 -20
  3. package/assets/personalities/butler-corpus.jsonl +0 -0
  4. package/assets/personalities/butler.txt +0 -0
  5. package/assets/personalities/loli-corpus.jsonl +30 -30
  6. package/assets/personalities/loli.txt +12 -12
  7. package/assets/personalities/none-corpus.jsonl +0 -0
  8. package/assets/personalities/none.txt +0 -0
  9. package/assets/personalities/senpai-corpus.jsonl +30 -30
  10. package/assets/personalities/senpai.txt +12 -12
  11. package/assets/personalities/tsundere-corpus.jsonl +0 -0
  12. package/assets/personalities/tsundere.txt +0 -0
  13. package/assets/personalities.json +0 -0
  14. package/cordis.patch.yml +0 -0
  15. package/lib/client.js +0 -0
  16. package/lib/core/card.js +0 -0
  17. package/lib/core/dialogue-mining.js +0 -0
  18. package/lib/core/leak-detector.js +0 -0
  19. package/lib/core/ledger.js +211 -0
  20. package/lib/core/manifest.js +0 -0
  21. package/lib/core/persona-text.js +0 -0
  22. package/lib/core/retrieval.js +0 -0
  23. package/lib/core/sampling.js +0 -0
  24. package/lib/core/signals.js +64 -0
  25. package/lib/core/text.js +0 -0
  26. package/lib/host/boundary.js +0 -0
  27. package/lib/host/compaction.js +0 -0
  28. package/lib/host/diag.js +0 -0
  29. package/lib/host/distill.js +0 -0
  30. package/lib/host/documents.js +0 -0
  31. package/lib/host/extraction.js +0 -0
  32. package/lib/host/identity.js +0 -0
  33. package/lib/host/injection.js +73 -26
  34. package/lib/host/methods.js +64 -0
  35. package/lib/host/personalities.js +0 -0
  36. package/lib/host/project.js +222 -0
  37. package/lib/host/protocol.js +12 -0
  38. package/lib/host/reflection.js +27 -6
  39. package/lib/host/registry.js +0 -0
  40. package/lib/host/rpc.js +15 -0
  41. package/lib/host/session-runtime.js +11 -2
  42. package/lib/host/store.js +0 -0
  43. package/lib/host/thinking.js +19 -0
  44. package/lib/host/triggers.js +133 -0
  45. package/lib/index.js +488 -109
  46. package/package.json +2 -2
@@ -0,0 +1,211 @@
1
+ /**
2
+ * 任务载具的纯逻辑层:任务契约、改动台账、假设台账、项目知识。
3
+ *
4
+ * 为什么是「载具」而不是再写协议条款:模型在长任务里丢的通常不是「不知道要量化」,
5
+ * 而是**没有一个地方放量化结果**。这四类结构化状态正好补上:
6
+ * - 由模型自己写(工具调用),所以与它的真实理解一致,而不是外部猜测;
7
+ * - 存在项目域里,跨轮次、跨压缩、跨会话存活(协议文本只能活在上下文里);
8
+ * - 每轮按状态渲染回尾部快照,让「原始判据」不会随进展漂移——这是可靠性最关键的
9
+ * 一环:交付时对照的必须是**开工时写下的判据**,而不是模型现在记的版本。
10
+ *
11
+ * 本模块只做纯逻辑(类型/解析/归一/渲染/上限),IO 在 host/project.ts。
12
+ */
13
+ import { fnv1a32 } from "./sampling.js";
14
+ /** 契约字段长度上限:契约是「一屏能看完」的东西,写长了自己也不看。 */
15
+ export const CONTRACT_TEXT_CAP = 240;
16
+ export const CONTRACT_LIST_CAP = 8;
17
+ export const CONTRACT_ITEM_CAP = 120;
18
+ /** 台账条目上限:超了先挤掉「计划中」的旧条目,保留已改动过的(那是交付依据)。 */
19
+ export const CHANGE_CAP = 60;
20
+ export const CHANGE_TEXT_CAP = 160;
21
+ export const HYPOTHESIS_CAP = 20;
22
+ /** 项目知识上限:按时间挤旧,死路记录优先保留(它最省时间)。 */
23
+ export const PROJECT_FACT_CAP = 40;
24
+ export const FACT_TEXT_CAP = 200;
25
+ const FACT_LABEL = {
26
+ build: "构建",
27
+ test: "测试",
28
+ module: "模块链路",
29
+ convention: "约定",
30
+ deadend: "死路(不要重复)",
31
+ };
32
+ function clip(value, cap) {
33
+ return String(value ?? "").trim().replace(/\s+/g, " ").slice(0, cap);
34
+ }
35
+ function clipList(value, cap = CONTRACT_LIST_CAP) {
36
+ const list = Array.isArray(value) ? value : value === undefined || value === null ? [] : [value];
37
+ const out = [];
38
+ for (const item of list) {
39
+ const text = clip(item, CONTRACT_ITEM_CAP);
40
+ if (text && !out.includes(text))
41
+ out.push(text);
42
+ if (out.length >= cap)
43
+ break;
44
+ }
45
+ return out;
46
+ }
47
+ function asCount(value) {
48
+ const n = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
49
+ return Number.isFinite(n) && n >= 0 ? Math.trunc(n) : null;
50
+ }
51
+ /** 项目键:跨会话共享的项目知识按工作目录归属(同一仓库的多个会话共用一份)。 */
52
+ export function projectKeyOf(cwd) {
53
+ const normalized = clip(cwd, 240).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
54
+ if (!normalized)
55
+ return "unknown";
56
+ return fnv1a32(normalized).toString(16).padStart(8, "0");
57
+ }
58
+ /** 从工具入参归一化契约(截断 + 去重 + 上限)。 */
59
+ export function normalizeContract(input, at, turn) {
60
+ return {
61
+ goal: clip(input.goal, CONTRACT_TEXT_CAP),
62
+ scope: clipList(input.scope),
63
+ expectCount: asCount(input.expectCount),
64
+ actualCount: asCount(input.actualCount),
65
+ criteria: clipList(input.criteria),
66
+ nonGoals: clipList(input.nonGoals),
67
+ open: clipList(input.open, 4),
68
+ at,
69
+ turn,
70
+ };
71
+ }
72
+ export function normalizeChange(input, at) {
73
+ const target = clip(input.target, CONTRACT_ITEM_CAP);
74
+ const change = clip(input.change, CHANGE_TEXT_CAP);
75
+ if (!target || !change)
76
+ return null;
77
+ const status = input.status;
78
+ return {
79
+ target,
80
+ change,
81
+ why: clip(input.why, CHANGE_TEXT_CAP),
82
+ verify: clip(input.verify, CHANGE_TEXT_CAP),
83
+ status: status === "done" || status === "verified" || status === "skipped" ? status : "planned",
84
+ at,
85
+ };
86
+ }
87
+ export function normalizeHypothesis(input, at) {
88
+ const text = clip(input.text, CHANGE_TEXT_CAP);
89
+ if (!text)
90
+ return null;
91
+ const status = input.status;
92
+ return {
93
+ text,
94
+ evidence: clip(input.evidence, CHANGE_TEXT_CAP),
95
+ status: status === "testing" || status === "confirmed" || status === "excluded" ? status : "open",
96
+ at,
97
+ };
98
+ }
99
+ export function normalizeProjectFact(input, at) {
100
+ const text = clip(input.text, FACT_TEXT_CAP);
101
+ if (!text)
102
+ return null;
103
+ const kind = input.kind;
104
+ return {
105
+ kind: kind === "test" || kind === "module" || kind === "convention" || kind === "deadend" ? kind : "build",
106
+ text,
107
+ at,
108
+ };
109
+ }
110
+ /** 台账计数:渲染与触发器都要用("x 项已改未验" 是增量验证的判据)。 */
111
+ export function countByStatus(items) {
112
+ const out = { planned: 0, done: 0, verified: 0, skipped: 0 };
113
+ for (const item of items)
114
+ out[item.status]++;
115
+ return out;
116
+ }
117
+ /** 超限时挤掉最旧的「计划中」条目;已改动过的条目是交付依据,先保留。 */
118
+ export function trimChanges(items, cap = CHANGE_CAP) {
119
+ if (items.length <= cap)
120
+ return items;
121
+ const planned = items.filter((item) => item.status === "planned");
122
+ const rest = items.filter((item) => item.status !== "planned");
123
+ const keepPlanned = planned.slice(-Math.max(0, cap - rest.length));
124
+ return [...rest, ...keepPlanned].sort((a, b) => a.at - b.at).slice(-cap);
125
+ }
126
+ export function trimFacts(facts, cap = PROJECT_FACT_CAP) {
127
+ if (facts.length <= cap)
128
+ return facts;
129
+ const deadends = facts.filter((fact) => fact.kind === "deadend");
130
+ const rest = facts.filter((fact) => fact.kind !== "deadend");
131
+ const keepRest = rest.slice(-Math.max(0, cap - deadends.length));
132
+ return [...keepRest, ...deadends.slice(-cap)].sort((a, b) => a.at - b.at).slice(-cap);
133
+ }
134
+ /**
135
+ * 渲染契约。`delivery=true` 时切换成**对账口径**——这是防「判据漂移」的关键:
136
+ * 交付前看到的是开工时写下的原始判据,而不是模型此刻的记忆版本。
137
+ */
138
+ export function renderContract(contract, delivery = false) {
139
+ if (!contract || !contract.goal)
140
+ return null;
141
+ const lines = [];
142
+ lines.push(delivery ? "〔契约对账〕交付前逐项对账(以下是开工时写下的原始判据,不是你现在的记忆版本):" : `〔任务契约|第 ${contract.turn} 轮写入〕`);
143
+ lines.push(`目标:${contract.goal}`);
144
+ if (contract.scope.length > 0)
145
+ lines.push(`范围:${contract.scope.join(";")}`);
146
+ if (contract.expectCount !== null || contract.actualCount !== null) {
147
+ const expect = contract.expectCount === null ? "?" : contract.expectCount;
148
+ const actual = contract.actualCount === null ? "未回填" : contract.actualCount;
149
+ lines.push(`数量:预计 ${expect} → 实际 ${actual}`);
150
+ }
151
+ if (contract.criteria.length > 0)
152
+ lines.push(`完成判据:${contract.criteria.map((item, i) => `${i + 1}. ${item}`).join(" ")}`);
153
+ if (contract.nonGoals.length > 0)
154
+ lines.push(`非目标(不动):${contract.nonGoals.join(";")}`);
155
+ if (contract.open.length > 0)
156
+ lines.push(`待确认:${contract.open.join(";")}`);
157
+ if (delivery) {
158
+ lines.push("逐项标注:已验证 / 未验证 / 偏离;数量对不上或判据没验的,直接说没做到,不要把动作完成说成判据达成。");
159
+ }
160
+ return lines.join("\n");
161
+ }
162
+ /** 渲染改动台账:计数在前(完整性可核对),明细在后(超长时只列未完成项)。 */
163
+ export function renderChangeLedger(items, limit = 12) {
164
+ if (items.length === 0)
165
+ return null;
166
+ const counts = countByStatus(items);
167
+ const head = `〔改动台账〕共 ${items.length} 项:已验证 ${counts.verified} / 已改未验 ${counts.done} / 计划中 ${counts.planned}${counts.skipped > 0 ? ` / 跳过 ${counts.skipped}` : ""}`;
168
+ const open = items.filter((item) => item.status !== "verified" && item.status !== "skipped");
169
+ const shown = (open.length > 0 ? open : items).slice(-limit);
170
+ const lines = shown.map((item) => {
171
+ const mark = item.status === "verified" ? "[已验证]" : item.status === "done" ? "[已改未验]" : item.status === "skipped" ? "[跳过]" : "[计划]";
172
+ const verify = item.verify ? `(验:${item.verify})` : "";
173
+ return `- ${mark} ${item.target} — ${item.change}${verify}`;
174
+ });
175
+ const foot = counts.planned > 0 || counts.done > 0 ? "\n台账里仍有未验证项:继续之前先补齐验证,或明确标注为未验证。" : "";
176
+ return `${head}\n${lines.join("\n")}${foot}`;
177
+ }
178
+ /** 渲染假设台账:已排除项照常显示——它们的作用就是「不要再试一遍」。 */
179
+ export function renderHypotheses(list, limit = 8) {
180
+ if (list.length === 0)
181
+ return null;
182
+ const lines = list.slice(-limit).map((item) => {
183
+ const mark = item.status === "excluded" ? "[已排除]" : item.status === "confirmed" ? "[已证实]" : item.status === "testing" ? "[验证中]" : "[待验证]";
184
+ const evidence = item.evidence ? `(证据:${item.evidence})` : "";
185
+ return `- ${mark} ${item.text}${evidence}`;
186
+ });
187
+ const excluded = list.filter((item) => item.status === "excluded").length;
188
+ const foot = excluded > 0 ? "\n已排除的假设不要重提;要推翻它必须给出新的证据。" : "";
189
+ return `〔假设台账〕\n${lines.join("\n")}${foot}`;
190
+ }
191
+ /** 渲染项目知识:按类别归组;死路单独成节(它最省时间)。 */
192
+ export function renderProjectFacts(facts, limit = 14) {
193
+ if (facts.length === 0)
194
+ return null;
195
+ const order = ["build", "test", "convention", "module", "deadend"];
196
+ const picked = facts.slice(-limit);
197
+ const lines = [];
198
+ for (const kind of order) {
199
+ const group = picked.filter((fact) => fact.kind === kind);
200
+ if (group.length === 0)
201
+ continue;
202
+ lines.push(`${FACT_LABEL[kind]}:`);
203
+ for (const fact of group)
204
+ lines.push(`- ${fact.text}`);
205
+ }
206
+ return `〔项目知识|本目录,跨会话累积〕\n${lines.join("\n")}`;
207
+ }
208
+ /** 台账/契约是否存在未验证项——触发器「连写不验」与交付对账都要用。 */
209
+ export function hasUnverified(items) {
210
+ return items.some((item) => item.status === "done" || item.status === "planned");
211
+ }
File without changes
File without changes
File without changes
File without changes
@@ -0,0 +1,64 @@
1
+ const PLAN_TOKENS = new Set(["todo", "plan", "contract", "change", "ledger", "hypothesis", "note"]);
2
+ const LUME_TOKENS = new Set(["lume"]);
3
+ const VERIFY_TOKENS = new Set([
4
+ "bash", "shell", "pwsh", "powershell", "cmd", "terminal", "run", "exec", "job", "make", "mvn", "gradle",
5
+ "npm", "pnpm", "yarn", "bun", "deno", "node", "tsc", "tsdown", "vite", "vitest", "jest", "pytest", "cargo",
6
+ "go", "dotnet", "msbuild", "compile", "build", "test", "lint", "typecheck", "check", "verify",
7
+ ]);
8
+ const MUTATE_TOKENS = new Set(["edit", "write", "multiedit", "patch", "apply", "replace", "create", "delete", "remove", "rename", "move", "append", "insert", "mkdir", "apply_patch"]);
9
+ const INSPECT_TOKENS = new Set(["read", "view", "cat", "grep", "search", "glob", "find", "ls", "list", "tree", "analyze", "symbol", "reference", "web", "fetch", "browser", "screenshot", "image", "git", "status", "diff", "log", "show", "stat", "head", "tail", "query", "sql", "map"]);
10
+ /** 把工具名切成小写词元:`lume_contract` → [lume, contract];`mcp__fs__read_file` → [mcp, fs, read, file]。 */
11
+ function tokens(name) {
12
+ return String(name ?? "")
13
+ .toLowerCase()
14
+ .split(/[^a-z0-9]+/)
15
+ .filter(Boolean);
16
+ }
17
+ export function classifyTool(name) {
18
+ const parts = tokens(name);
19
+ if (parts.length === 0)
20
+ return "other";
21
+ const has = (set) => parts.some((part) => set.has(part));
22
+ const isLume = has(LUME_TOKENS);
23
+ // lume 自家工具单独归类:载具(契约/台账/假设/项目知识)= plan;人格工具(记忆/风格/人设)
24
+ // = other——它们写的是人格数据,不该被算成「文件改动」,否则会污染增量验证的连击。
25
+ if (isLume)
26
+ return has(PLAN_TOKENS) ? "plan" : "other";
27
+ // todo_write 的 write 是「写清单」不是「改文件」,因此 plan 判定在 mutate 之前。
28
+ if (parts.includes("todo") || parts.includes("plan"))
29
+ return "plan";
30
+ if (has(VERIFY_TOKENS))
31
+ return "verify";
32
+ if (has(MUTATE_TOKENS))
33
+ return "mutate";
34
+ if (has(INSPECT_TOKENS))
35
+ return "inspect";
36
+ return "other";
37
+ }
38
+ /** 通用失败迹象:工具结果里出现这些词,就当这一步没成功。 */
39
+ const FAILURE_RE = /失败|报错|错误|异常|无法|找不到|不存在|没找到|\berror\b|\bfailed\b|\bfailure\b|\bexception\b|traceback|\bpanic\b|\bcannot\b|\bunable\b|permission denied|timed out|timeout|超时/i;
40
+ const UNKNOWN_RE = /结果未知|outcome unknown|tool_not_started|tool_outcome_unknown|仍在运行|still running|no output/i;
41
+ /**
42
+ * 环境故障迹象(区别于「代码写错了」):依赖解析不了、命令不存在、离线仓库、
43
+ * 网络/权限受阻。命中它才给「验证降级阶梯」——普通编译错误该归因到代码,
44
+ * 给环境阶梯反而会误导。
45
+ */
46
+ const ENV_FAILURE_RE = /could not resolve dependencies|could not find artifact|cannot find module|module_not_found|command not found|not recognized as an internal|不是内部或外部命令|系统找不到指定的路径|no such file or directory|enoent|offline mode|cannot access .* in offline|本地仓库|repository.*(?:empty|missing)|network is unreachable|econnrefused|etimedout|proxy|self-signed certificate|eacces/i;
47
+ /** 从工具结果文本判定成败。`explicitError` 为宿主上报的错误字段。 */
48
+ export function readResultSignals(text, explicitError = false) {
49
+ const body = String(text ?? "");
50
+ const unknown = UNKNOWN_RE.test(body);
51
+ if (unknown)
52
+ return { failure: false, unknown: true, env: false };
53
+ // 环境故障本身就是失败:单独判定,避免「命令不存在」这类英文输出因通用失败词表
54
+ // 不含 "not found" 而被漏掉(实测 mvn: command not found 就踩过这个洞)。
55
+ const env = ENV_FAILURE_RE.test(body);
56
+ const failure = explicitError || env || FAILURE_RE.test(body);
57
+ return { failure, unknown: false, env: failure && env };
58
+ }
59
+ /** 连续失败序列的归类:环境故障占多数时才给降级阶梯。 */
60
+ export function deadPathKind(envHits, failStreak) {
61
+ if (failStreak < 3)
62
+ return null;
63
+ return envHits >= 2 ? "env" : "retry";
64
+ }
package/lib/core/text.js CHANGED
File without changes
File without changes
File without changes
package/lib/host/diag.js CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -1,9 +1,24 @@
1
1
  /**
2
- * 人设注入组装(纯函数):五段式 + Token 优化算法。
2
+ * 人设注入组装(纯函数):按「会话恒定段 + 易变段」两层拆开。
3
3
  *
4
- * 段序遵循缓存友好分层:稳定内容在前(契约),易变内容在后(检索结果、播报)。
5
- * 语料示例按少样本衰减注入;记忆/风格按与当前用户消息的相关度取 top-k,
6
- * core 记忆(身份称呼类)恒注入。
4
+ * ## 为什么要拆
5
+ *
6
+ * 系统提示词在消息序列的最前面,而前缀缓存只认「从第一个不同的字节起全部失效」。
7
+ * 原实现把记忆 top-k、语料少样本衰减、切换播报和基础契约拼在同一段里,于是
8
+ * **每一步**(不只是每一轮)这段文本都会变——宿主只能就地改写头部 system 节点,
9
+ * 之后整段对话历史全部按全价重算。实测:某会话 282 个请求的 cacheRead 恒定
10
+ * 384 token,命中率 0.2%。
11
+ *
12
+ * 拆分后:
13
+ * - `buildPersonaContractSection` —— 只依赖人设身份(契约 / 身份名 / 纪律),
14
+ * 一个会话内逐字节不变,可以安全地待在 system 段吃前缀缓存;
15
+ * - `buildPersonaRuntimeSection` —— 记忆、风格、语料、播报,全部随轮次/查询变化,
16
+ * 交给宿主的 runtime-context 通道(渲染成对话尾部的一条快照消息,见 README
17
+ * 「分层注入」),改它不会作废前面的任何 token。
18
+ *
19
+ * 段内序仍遵循缓存友好分层:稳定内容在前(契约),易变内容在后(检索结果、播报)。
20
+ * 语料示例按少样本衰减注入;记忆/风格按与当前用户消息的相关度取 top-k,core 记忆
21
+ * (身份称呼类)恒注入。
7
22
  */
8
23
  import { decaySampleCount, topKByRelevance } from "../core/retrieval.js";
9
24
  import { sampleForSession } from "../core/sampling.js";
@@ -14,11 +29,26 @@ import { sampleForSession } from "../core/sampling.js";
14
29
  export function isCoreMemory(text) {
15
30
  return /名字|叫|称呼|昵称|爱称|自称|身份|小[A-Za-z]/.test(text) && text.length <= 30;
16
31
  }
17
- /** 组装人设注入文本;无人设(none/未选)时若带边界播报,仍单独输出播报。 */
18
- export function buildPersonaSection(input) {
19
- const { persona, config, query } = input;
32
+ /**
33
+ * 会话恒定的行为纪律:只在真实人设激活时(有契约或身份名)随契约一起注入,
34
+ * 且该判据**只取稳定输入**——否则「本步有没有检索到记忆」会让它忽隐忽现,
35
+ * 稳定段就白拆了。「不使用人设」(promptText 为空、无身份名)保持零注入。
36
+ */
37
+ const PERSONA_DISCIPLINES = [
38
+ "〔连贯性规则〕语气与风格的连贯以你当前人设的任期为界:会话历史中其他人设或默认助手的表达都不构成连贯性义务,不要为了延续历史语气而偏离当前人设。",
39
+ "〔口吻纪律〕你现在是人设在说话,不是通用助手:第一句就必须是这个人会说的话,禁止用「好的」「当然可以」「没问题」这类助手套话开头,全程禁用「希望对你有所帮助」「还有其他需要吗」等助手腔收尾。",
40
+ "〔频率规则〕口头禅、语气词、emoji 按人设约定里的频率与触发条件使用——不句句都用满,但平淡话题里也要保持这个人的断句、用词和口头习惯,不能因为话题普通就退回默认助手口吻。",
41
+ "〔篇幅纪律〕像发微信一样说话:单条回复简短,通常是 1-3 句、几十字以内,一次只回应一个重点。人设契约里若写明了典型长度,以契约为准。只有对方明确要求详细展开(写代码、写文档、深入解释)时才允许长回复;闲聊场景写小作文就是失真。",
42
+ ];
43
+ /**
44
+ * 会话恒定段:基础契约 + 身份 + 行为纪律。
45
+ * 只依赖 persona 与 profileName,同一会话内多次调用必须产出逐字节相同的文本
46
+ * (回归测试锁死:见 test/injection-layering.test.ts)。
47
+ */
48
+ export function buildPersonaContractSection(input) {
49
+ const { persona } = input;
20
50
  if (!persona)
21
- return input.boundaryText ?? "";
51
+ return "";
22
52
  const parts = [];
23
53
  // 1. 基础契约(基本盘)
24
54
  const promptText = persona.promptText.trim();
@@ -29,23 +59,38 @@ export function buildPersonaSection(input) {
29
59
  const who = input.profileName ?? persona.displayName;
30
60
  parts.push(`〔说话人切换〕现在起你不是通用助手,你是「${who}」。你的每一句话——包括解释、提问、拒绝——都要从「${who}」嘴里说出来,用 TA 的口吻、TA 的用词、TA 的断句。下面的人设契约是唯一标准,任何与它冲突的默认助手习惯一律作废。\n\n${promptText}`);
31
61
  }
32
- // 2. 习得的风格约定(覆盖语义:与基础盘冲突时以此为准)
62
+ // 2. 身份
63
+ if (input.profileName) {
64
+ parts.push(`【你是谁】你的名字是「${input.profileName}」。这是你自己的身份,跨会话、跨项目不变;用户在任何地方叫这个名字都是在叫你。`);
65
+ }
66
+ if (parts.length === 0)
67
+ return "";
68
+ parts.push(...PERSONA_DISCIPLINES);
69
+ return parts.join("\n\n");
70
+ }
71
+ /**
72
+ * 易变段:习得风格 + 记忆 + 切换播报 + 语料示例。
73
+ * 全部随轮次或当前查询变化,必须走 runtime-context 通道(宿主渲染成对话尾部的
74
+ * 快照消息),否则每步都会作废 system 段之后的前缀。
75
+ */
76
+ export function buildPersonaRuntimeSection(input) {
77
+ const { persona, config, query } = input;
78
+ if (!persona)
79
+ return "";
80
+ const parts = [];
81
+ // 1. 习得的风格约定(覆盖语义:与基础盘冲突时以此为准)
33
82
  const styles = input.styleRules;
34
83
  if (styles.length > 0) {
35
84
  const chosen = config.strategy === "full"
36
85
  ? styles.slice(-config.styleInject)
37
86
  : topKByRelevance(styles, (r) => r.rule, query, config.styleInject);
38
87
  if (chosen.length > 0) {
39
- parts.push(`【习得的风格约定】以下是你在对话中学到的最新要求,与上方基础风格冲突时以此为准:\n${chosen
88
+ parts.push(`【习得的风格约定】以下是你在对话中学到的最新要求,与基础风格冲突时以此为准:\n${chosen
40
89
  .map((r) => `- ${r.rule}`)
41
90
  .join("\n")}`);
42
91
  }
43
92
  }
44
- // 3. 身份
45
- if (input.profileName) {
46
- parts.push(`【你是谁】你的名字是「${input.profileName}」。这是你自己的身份,跨会话、跨项目不变;用户在任何地方叫这个名字都是在叫你。`);
47
- }
48
- // 4. 记忆:core 恒注入 + 其余按相关度 top-k
93
+ // 2. 记忆:core 恒注入 + 其余按相关度 top-k
49
94
  const facts = input.memories;
50
95
  if (facts.length > 0) {
51
96
  const core = facts.filter((f) => isCoreMemory(f.text)).slice(-3);
@@ -59,10 +104,10 @@ export function buildPersonaSection(input) {
59
104
  parts.push(`【你记得】这些是你与这位用户长期相处的记忆:\n${chosen.map((f) => `- ${f.text}`).join("\n")}`);
60
105
  }
61
106
  }
62
- // 5. 接班播报(仅切换窗口)
107
+ // 3. 接班播报(仅切换窗口)
63
108
  if (input.boundaryText)
64
109
  parts.push(input.boundaryText);
65
- // 6. 语料示例:少样本衰减 + 会话级稳定采样。摘录语料(对话中被用户认可的
110
+ // 4. 语料示例:少样本衰减 + 会话级稳定采样。摘录语料(对话中被用户认可的
66
111
  // 真实回复)优先占位——它们比蒸馏语料更贴近当前使用中的语气。
67
112
  const sampleCount = decaySampleCount(config.sampleCount, input.turnIndex, config.sampleMin);
68
113
  const pins = (input.corpusPins ?? []).map((p) => ({ user: p.user, assistant: p.assistant }));
@@ -87,14 +132,16 @@ export function buildPersonaSection(input) {
87
132
  if (lines)
88
133
  parts.push(`参考对话示例:\n(只模仿说话方式,不要把示例中的时间、地点、正在做什么或其他事实当成当前事实)\n${lines}`);
89
134
  }
90
- // 7. 连贯性原则:连贯以人设任期为界,而非以会话为界——切换人设时,
91
- // 历史中前任与默认助手的表达不构成语气连贯性义务(对抗模型的惯性连贯先验)。
92
- // 仅在真实人设激活时输出;「不使用人设」保持零注入。
93
- if (parts.length > 0) {
94
- parts.push("〔连贯性规则〕语气与风格的连贯以你当前人设的任期为界:会话历史中其他人设或默认助手的表达都不构成连贯性义务,不要为了延续历史语气而偏离当前人设。");
95
- parts.push("〔口吻纪律〕你现在是人设在说话,不是通用助手:第一句就必须是这个人会说的话,禁止用「好的」「当然可以」「没问题」这类助手套话开头,全程禁用「希望对你有所帮助」「还有其他需要吗」等助手腔收尾。");
96
- parts.push("〔频率规则〕口头禅、语气词、emoji 按人设约定里的频率与触发条件使用——不句句都用满,但平淡话题里也要保持这个人的断句、用词和口头习惯,不能因为话题普通就退回默认助手口吻。");
97
- parts.push("〔篇幅纪律〕像发微信一样说话:单条回复简短,通常是 1-3 句、几十字以内,一次只回应一个重点。人设契约里若写明了典型长度,以契约为准。只有对方明确要求详细展开(写代码、写文档、深入解释)时才允许长回复;闲聊场景写小作文就是失真。");
98
- }
99
135
  return parts.filter(Boolean).join("\n\n");
100
136
  }
137
+ /**
138
+ * 兼容组合:稳定段 + 易变段(旧调用方的单一入口)。
139
+ * 新版宿主接线请分别取 `buildPersonaContractSection`(system 段)与
140
+ * `buildPersonaRuntimeSection`(runtime-context 通道)。
141
+ * 无人设(none/未选)时若带边界播报,仍单独输出播报。
142
+ */
143
+ export function buildPersonaSection(input) {
144
+ if (!input.persona)
145
+ return input.boundaryText ?? "";
146
+ return [buildPersonaContractSection(input), buildPersonaRuntimeSection(input)].filter(Boolean).join("\n\n");
147
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * 方法层:把「怎么做得聪明」写成可注入的短块。
3
+ *
4
+ * 与协议正文(thinking.ts)的分工:
5
+ * - 协议正文=**纪律**(不越权、要验证、要复核),会话恒定、吃前缀缓存、对所有任务生效;
6
+ * - 本模块=**方法**(怎么量化需求、怎么改文档、怎么评估影响面),只在对应形态的任务轮
7
+ * 出现——文档方法论不该出现在调试会话里,影响面清单也不该出现在闲聊里。
8
+ *
9
+ * 成本模型:这些都注入到尾部快照,因此只在**内容变化时**付费(实测 58 步只产生 9 条快照),
10
+ * 每轮多几百字是可接受的;真正要避免的是把它们塞进 system 段(那会作废整段前缀)。
11
+ */
12
+ /** 任务型请求且尚无契约时注入:把「先量化」变成一次具体的产出。 */
13
+ export function buildContractMethodDirective() {
14
+ return [
15
+ "〔先量化后动手〕本轮是任务型请求。开工前先写任务契约(lume_contract):",
16
+ "- 目标:一句话,可观察的结果(不是「优化一下」这种动词)",
17
+ "- 范围:精确到路径 / 模块 / 章节 / 表",
18
+ "- 数量:先估一个数,探索后回填实际值——交付时要用实际数量对账",
19
+ "- 完成判据:可执行、可核对(命令 / 回读 / 对照),不是「改完」",
20
+ "- 非目标:明确不动什么,防止范围蔓延",
21
+ "- 待确认:只列真正阻塞的(≤2 个);不阻塞的按默认假设前进并写明假设",
22
+ ].join("\n");
23
+ }
24
+ /** 文档任务轮注入:文档的失败模式是静默内容丢失,所以方法围绕「结构 + 最小编辑 + 回读」。 */
25
+ export function buildDocumentMethodDirective() {
26
+ return [
27
+ "〔文档编辑方法〕",
28
+ "1. 先取结构:标题层级、表格/图表清单、编号体系,复述一遍再动;长文档按大纲逐节记账(lume_change 的 target 用章节名),避免漏节或反复处理同一节。",
29
+ "2. 最小编辑:只改目标区域,保留原有格式、编号、交叉引用与样式——不要整份重写。",
30
+ "3. 术语与称谓全文一致:改一个术语前先全文检索它的全部出现位置,否则会留下半新半旧。",
31
+ "4. 交付前回读改动区域,列出「改了什么 / 没动什么 / 未核对什么」;没有回读证据不要说已改好。",
32
+ ].join("\n");
33
+ }
34
+ /** 执行轮注入:改动之前的影响面清单,治「边写边想」。 */
35
+ export function buildImpactDirective() {
36
+ return [
37
+ "〔改动影响面〕动手前列出:要改的符号 → 谁调用它、它实现或被实现于谁、配置或 SQL 映射、前端/模板引用;并标出「不打算改但需一并确认」的位置。",
38
+ "每处改动写明验证方式;同一文件的相关改动一次做完,不要反复回来改同一个文件。",
39
+ ].join("\n");
40
+ }
41
+ /** 环境里有结构分析/符号工具时的一句提示:用符号级定位替代通篇 read。 */
42
+ export function buildStructureHint(toolName) {
43
+ if (!toolName)
44
+ return null;
45
+ return `〔定位工具〕当前环境有结构分析工具(${toolName}):优先用它做符号级定位(谁调用、被谁调用、结构概览),比通篇 read 更省 token 也更准。`;
46
+ }
47
+ /**
48
+ * 拼装尾部快照块:超预算时优先丢掉**可丢**的块(从后往前),而不是截断中间的句子。
49
+ * 预算存在的意义是防止"载具越积越多,把注意力挤没"——实测尾部快照约 1.8-2k 字符时
50
+ * 命中率与合规都健康,这里给到 4200 字符仍有充足余量。
51
+ */
52
+ export function composeBlocks(blocks, budgetChars = 4200) {
53
+ const present = blocks.filter((block) => Boolean(block.text));
54
+ let out = present.map((block) => block.text).join("\n\n");
55
+ if (out.length <= budgetChars)
56
+ return out;
57
+ for (let i = present.length - 1; i >= 0 && out.length > budgetChars; i--) {
58
+ if (!present[i].droppable)
59
+ continue;
60
+ present.splice(i, 1);
61
+ out = present.map((block) => block.text).join("\n\n");
62
+ }
63
+ return out.length > budgetChars ? out.slice(0, budgetChars) : out;
64
+ }
File without changes