lume-dsh-plugin 0.4.1

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.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 被动提取(安全网通道):会话轮次结束后,把「关于人设/用户关系的持久事实」
3
+ * 从对话中提取出来。三道门保证 99% 的轮次零 token 消耗:
4
+ * ① 关键词门(正则,零成本)→ ② Jaccard 去重门(本地)→ ③ 冷却门(本地)。
5
+ * 触发才调小模型:只喂触发轮,输出 ≤3 条事实。
6
+ */
7
+ import { jaccard } from "../core/retrieval.js";
8
+ export const EXTRACTION_KEYWORD_RE = /你叫|你的名字|叫你|你是|我喜欢|我不喜欢|我讨厌|我是|我在|我叫|记得|上次|以后叫|别忘|最爱|爱好|习惯|讨厌|喜欢/;
9
+ /** 纠偏元反馈信号:用户对人设说话方式的负面反馈("太夸张了""正常点说话")。 */
10
+ export const CORRECTION_KEYWORD_RE = /太夸张|太假了?|油腻|不像你|正常点|收敛|别这么|语气太|太过了?|过头|肉麻|端着|生硬|浮夸|戏太|啰嗦|少用点|别老是|每次都[这那]/;
11
+ /** 认可信号:用户明显认可回复「像本人」——值得摘录进语料。
12
+ * 「说得好」太泛(可能只是认可内容),不收。 */
13
+ export const APPROVAL_KEYWORD_RE = /太像了|有内味|有那味|就是这个味|好像你|一模一样|对味|像本人|本人无疑|很你|有你的味|怎么做到这么/;
14
+ /**
15
+ * 辅助 LLM 路由解析(被动提取/蒸馏共用):配置的专用模型优先,未配置的维度
16
+ * 逐项回落到主对话路由;任一维度都没有 → null(模型不可用,跳过对应功能)。
17
+ */
18
+ export function resolveAuxRoute(override, conversationRoute) {
19
+ const provider = override.provider ?? conversationRoute?.provider;
20
+ const model = override.model ?? conversationRoute?.model;
21
+ return provider && model ? { provider, model } : null;
22
+ }
23
+ export const DUPLICATE_JACCARD_THRESHOLD = 0.7;
24
+ export const MAX_FACT_CHARS = 40;
25
+ export const MAX_FACTS_PER_TURN = 3;
26
+ /** 门①:用户消息是否值得考虑提取。 */
27
+ export function shouldConsider(userText) {
28
+ return EXTRACTION_KEYWORD_RE.test(userText);
29
+ }
30
+ /** 纠偏门:用户消息是否是对说话方式的元反馈(负面纠偏)。 */
31
+ export function shouldConsiderCorrection(userText) {
32
+ return CORRECTION_KEYWORD_RE.test(userText);
33
+ }
34
+ /** 认可门:用户消息是否明确认可了「语气像本人」。 */
35
+ export function shouldCaptureCorpus(userText) {
36
+ return APPROVAL_KEYWORD_RE.test(userText);
37
+ }
38
+ /** 门②:候选文本与既有事实是否重复(高相似或被包含)。 */
39
+ export function isDuplicateFact(candidate, facts) {
40
+ const trimmed = candidate.trim();
41
+ if (!trimmed)
42
+ return true;
43
+ for (const fact of facts) {
44
+ if (fact.text.includes(trimmed) || trimmed.includes(fact.text))
45
+ return true;
46
+ if (jaccard(fact.text, trimmed) >= DUPLICATE_JACCARD_THRESHOLD)
47
+ return true;
48
+ }
49
+ return false;
50
+ }
51
+ /** 门③:冷却判断。 */
52
+ export function isCoolingDown(lastExtractionAt, now, cooldownMs) {
53
+ return lastExtractionAt !== undefined && now - lastExtractionAt < cooldownMs;
54
+ }
55
+ /** 组装提取请求(system + 单条 user 消息文本)。调用方自行喂给 llm。 */
56
+ export function buildExtractionPrompt(userText, assistantText, existingFacts) {
57
+ const known = existingFacts.length > 0 ? `已有记忆(勿重复):\n${existingFacts.map((f) => `- ${f}`).join("\n")}` : "已有记忆:无";
58
+ return {
59
+ system: [
60
+ "你从一段人设对话中提取应当长期记住的事实。",
61
+ "只提取关于用户个人情况、偏好、习惯,或用户与助手之间关系的事实;不提取工作内容、代码、项目信息。",
62
+ `最多 ${MAX_FACTS_PER_TURN} 条,每条 ≤${MAX_FACT_CHARS} 字,用第三人称陈述句。`,
63
+ '只输出一个 JSON 字符串数组,如 ["用户喜欢深夜写代码"];没有值得记的就输出 []。',
64
+ ].join("\n"),
65
+ userText: `${known}\n\n对话:\n用户:${userText}\n助手:${assistantText}`,
66
+ };
67
+ }
68
+ /** 解析模型输出:优先 JSON 数组;退化为「列表样」逐行剥点;非列表样输出一律为空。 */
69
+ export function parseFacts(output) {
70
+ const trimmed = output.trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
71
+ let parsed;
72
+ try {
73
+ parsed = JSON.parse(trimmed);
74
+ }
75
+ catch {
76
+ // 退化路径只接受 - / * / 1. 开头的列表行,其余整段拒绝(模型没按格式给就当没有)
77
+ if (!/^[-*\d]/.test(trimmed))
78
+ return [];
79
+ parsed = trimmed
80
+ .split("\n")
81
+ .map((line) => line.replace(/^[-*\d.\s]+/, "").trim())
82
+ .filter(Boolean);
83
+ }
84
+ if (!Array.isArray(parsed))
85
+ return [];
86
+ const out = [];
87
+ for (const item of parsed) {
88
+ if (typeof item !== "string")
89
+ continue;
90
+ const text = item.trim().slice(0, MAX_FACT_CHARS);
91
+ if (text)
92
+ out.push(text);
93
+ if (out.length >= MAX_FACTS_PER_TURN)
94
+ break;
95
+ }
96
+ return out;
97
+ }
98
+ /** 合并:逐条过门②,返回需要新写入的条目。 */
99
+ export function mergeNewFacts(candidateFacts, existing) {
100
+ return candidateFacts.filter((fact) => !isDuplicateFact(fact, existing));
101
+ }
102
+ /** 取名类事实:从记忆文本中提取用户给人设起的名字(同步 profile 用)。 */
103
+ export const NAMING_RE = /(?:取名[为叫]|叫你|你的名字[是为]|名字[是为]|以后[就]?叫)[「『]?你?[「『]?([^」』"',。,.!!??\s]{1,12})/;
104
+ /** 从事实列表中找出第一个取名事实的名字;没有则 null。 */
105
+ export function extractNaming(facts) {
106
+ for (const fact of facts) {
107
+ const m = fact.match(NAMING_RE);
108
+ if (m?.[1])
109
+ return m[1];
110
+ }
111
+ return null;
112
+ }
113
+ /**
114
+ * 纠偏提炼:用户对人设说话方式的负面元反馈 → 一条长期风格约定。
115
+ * 与记忆提取共用「三道门 + 小模型」模式;输出单条规则,由 addStyleRule 的
116
+ * Jaccard 相似替换保证纠偏不断收敛而不是堆叠。
117
+ */
118
+ export function buildCorrectionPrompt(userText, assistantText, existingRules) {
119
+ const known = existingRules.length > 0 ? `已有风格约定(勿重复):\n${existingRules.map((r) => `- ${r}`).join("\n")}` : "已有风格约定:无";
120
+ return {
121
+ system: [
122
+ "你是人设风格约定提炼器。用户刚才对你(人设)的说话方式表达了不满或纠正。",
123
+ "任务:把用户的反馈转写成一条长期风格约定,写入人设的记忆,让以后每次回复都遵守。",
124
+ "规则:",
125
+ "- 只针对说话方式(语气、用词、口癖、长度、称呼、emoji 使用),不涉及当前话题内容;",
126
+ "- 一句话祈使句,≤40 字,如「少用叠词和语气词,语气放平」「不要在每句话都夸人」;",
127
+ "- 已有约定里语义相同的(换个说法但同一个意思)就输出 null,不重复添加;",
128
+ "- 用户的反馈只是情绪宣泄、没有可执行的风格指令时输出 null。",
129
+ "只输出一个 JSON 字符串(如 \"少用叠词\"),或 null。不要输出任何其他内容。",
130
+ ].join("\n"),
131
+ userText: `${known}\n\n上一轮:\n用户:${userText}\n助手:${assistantText}`,
132
+ };
133
+ }
134
+ /** 解析纠偏输出:合法单条规则返回原文(≤40 字),其余(null/空/非字符串)返回 null。 */
135
+ export function parseCorrectionRule(output) {
136
+ const trimmed = output.trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
137
+ if (!trimmed || trimmed === "null")
138
+ return null;
139
+ let value;
140
+ try {
141
+ value = JSON.parse(trimmed);
142
+ }
143
+ catch {
144
+ value = trimmed.replace(/^["'「]|["'」]$/g, "");
145
+ }
146
+ if (typeof value !== "string")
147
+ return null;
148
+ const rule = value.trim().slice(0, 40);
149
+ return rule ? rule : null;
150
+ }
@@ -0,0 +1,217 @@
1
+ /**
2
+ * 身份层存储:storageDomain 域 `lume_persona_identity` 之上的读写封装。
3
+ *
4
+ * 四张表,键全部 = 人设名(人即键,记忆跟人走,跨会话跨项目):
5
+ * - profile 身份档案 { name }
6
+ * - memory_facts 长期记忆 [{ text, at }],cap 30
7
+ * - style_rules 习得风格约定 [{ rule, at }],cap 20
8
+ * - custom_personas 用户自建人设 { displayName, description, promptText, createdAt }
9
+ */
10
+ import { domainTable, defineDomain } from "@deepseek-ai/dsh-storage-domain";
11
+ import z from "@deepseek-ai/schemastery";
12
+ export const CORPUS_CAP = 12;
13
+ export const CORPUS_LINE_CAP = 240;
14
+ /**
15
+ * schemastery → 存储域 schema 桥接。
16
+ * 官方契约:domainTable 的 schema 必须暴露 `.parse(raw)`(zod 形状),open 时
17
+ * 逐记录调用。schemastery 实例没有 `.parse`(它是可调用函数 + `~standard.validate`
18
+ * 的 Standard Schema 接口),直接传入会在「域内有数据后重启」时炸掉 open,
19
+ * 触发身份域静默降级。这里把 validate 的 `{value}|{issues}` 结果翻译成
20
+ * parse 的「返回|抛错」语义;行为由集成测试的带数据重开域用例锁死。
21
+ */
22
+ export function zodLike(schema) {
23
+ const standard = schema["~standard"];
24
+ if (typeof standard?.validate !== "function") {
25
+ throw new Error("schema does not implement the Standard Schema ~standard interface");
26
+ }
27
+ return {
28
+ parse(raw) {
29
+ const result = standard.validate(raw);
30
+ if (result.issues?.length)
31
+ throw new Error(result.issues.map((issue) => issue.message).join("; "));
32
+ return result.value;
33
+ },
34
+ };
35
+ }
36
+ /** 域名与表名受 UNIT_NAME_RE(^[a-z][a-z0-9_]*$)约束;schema 用 schemastery(zod 兼容面)。 */
37
+ export const LUME_IDENTITY_SPEC = defineDomain({
38
+ name: "lume_persona_identity",
39
+ version: 1,
40
+ tables: {
41
+ profile: domainTable(zodLike(z.object({ name: z.string() }))),
42
+ memory_facts: domainTable(zodLike(z.array(z.object({ text: z.string(), at: z.number() })))),
43
+ style_rules: domainTable(zodLike(z.array(z.object({ rule: z.string(), at: z.number() })))),
44
+ /** 对话中摘录的「被用户认可」语料对(注入时并入采样池);键 = 人设名。 */
45
+ corpus_pins: domainTable(zodLike(z.array(z.object({ user: z.string(), assistant: z.string(), at: z.number() })))),
46
+ custom_personas: domainTable(zodLike(z.object({
47
+ displayName: z.string(),
48
+ description: z.string(),
49
+ promptText: z.string(),
50
+ createdAt: z.number(),
51
+ /** 蒸馏产出的示例对话语料;可选字段,旧记录无此键照常通过 open 校验。 */
52
+ corpus: z.array(z.object({ user: z.string(), assistant: z.string() })),
53
+ distillVersion: z.number(),
54
+ distillSource: z.string(),
55
+ distillHint: z.string(),
56
+ }))),
57
+ },
58
+ });
59
+ export const MEMORY_CAP = 30;
60
+ export const STYLE_CAP = 20;
61
+ /** manifest 内置人设名 —— 自定义创建/删除不可触碰。 */
62
+ export const BUILTIN_PERSONA_NAMES = new Set(["loli", "senpai", "butler", "tsundere", "none"]);
63
+ /** 宽松读取:schema 校验失败或损坏值按空值处理,不炸会话。 */
64
+ function asArray(value) {
65
+ return Array.isArray(value) ? value : [];
66
+ }
67
+ /** 语料净化:只保留 {user?, assistant} 形状的合法样本,超限截断。 */
68
+ export function sanitizeCorpus(value) {
69
+ if (!Array.isArray(value))
70
+ return [];
71
+ const out = [];
72
+ for (const item of value) {
73
+ const assistant = item?.assistant;
74
+ const user = item?.user;
75
+ if (typeof assistant === "string" && assistant.trim()) {
76
+ out.push({
77
+ user: typeof user === "string" ? user.slice(0, CORPUS_LINE_CAP) : "",
78
+ assistant: assistant.slice(0, CORPUS_LINE_CAP),
79
+ });
80
+ }
81
+ if (out.length >= CORPUS_CAP)
82
+ break;
83
+ }
84
+ return out;
85
+ }
86
+ function isFactList(value) {
87
+ return Array.isArray(value) && value.every((v) => typeof v?.text === "string");
88
+ }
89
+ function isRuleList(value) {
90
+ return Array.isArray(value) && value.every((v) => typeof v?.rule === "string");
91
+ }
92
+ /** 四表读写 + 容量治理。core 记忆(身份称呼类)由写入时标记实现恒注入。 */
93
+ export class IdentityStore {
94
+ #table;
95
+ #memoryTable;
96
+ #styleTable;
97
+ #pinsTable;
98
+ #customTable;
99
+ constructor(tables) {
100
+ this.#table = tables.profile;
101
+ this.#memoryTable = tables.memory_facts;
102
+ this.#styleTable = tables.style_rules;
103
+ this.#pinsTable = tables.corpus_pins;
104
+ this.#customTable = tables.custom_personas;
105
+ }
106
+ /** 身份名;未设置返回 null。 */
107
+ getProfileName(persona) {
108
+ const value = this.#table.get(persona);
109
+ return typeof value?.name === "string" && value.name ? value.name : null;
110
+ }
111
+ async setProfileName(persona, name) {
112
+ const trimmed = name.trim();
113
+ if (!trimmed)
114
+ throw new Error("profile name must be non-empty");
115
+ await this.#table.put(persona, { name: trimmed });
116
+ }
117
+ getMemory(persona) {
118
+ const value = this.#memoryTable.get(persona);
119
+ return isFactList(value) ? value : [];
120
+ }
121
+ /** 追加记忆,超限挤掉最旧;与已有事实近似重复的忽略。返回是否写入。 */
122
+ async addMemory(persona, text, isDuplicate) {
123
+ const trimmed = text.trim();
124
+ if (!trimmed)
125
+ return false;
126
+ const facts = this.getMemory(persona);
127
+ if (isDuplicate(trimmed, facts))
128
+ return false;
129
+ const next = [...facts, { text: trimmed, at: Date.now() }];
130
+ while (next.length > MEMORY_CAP)
131
+ next.shift();
132
+ await this.#memoryTable.put(persona, next);
133
+ return true;
134
+ }
135
+ async replaceMemory(persona, facts) {
136
+ await this.#memoryTable.put(persona, facts.slice(-MEMORY_CAP));
137
+ }
138
+ getStyleRules(persona) {
139
+ const value = this.#styleTable.get(persona);
140
+ return isRuleList(value) ? value : [];
141
+ }
142
+ /** 追加风格约定;语义近似(Jaccard 高)的替换旧条而非堆叠。 */
143
+ async addStyleRule(persona, rule, similar) {
144
+ const trimmed = rule.trim();
145
+ if (!trimmed)
146
+ return;
147
+ const rules = this.getStyleRules(persona);
148
+ const next = rules.filter((r) => !similar(r.rule, trimmed));
149
+ next.push({ rule: trimmed, at: Date.now() });
150
+ while (next.length > STYLE_CAP)
151
+ next.shift();
152
+ await this.#styleTable.put(persona, next);
153
+ }
154
+ /** 整体替换风格约定(导入人设卡语义),截断到 cap。 */
155
+ async replaceStyleRules(persona, rules) {
156
+ await this.#styleTable.put(persona, rules.slice(-STYLE_CAP));
157
+ }
158
+ getCorpusPins(persona) {
159
+ const value = this.#pinsTable.get(persona);
160
+ return Array.isArray(value) ? value.filter((p) => typeof p?.assistant === "string" && typeof p?.user === "string") : [];
161
+ }
162
+ /** 摘录语料对:与既有内容高度相似(Jaccard)或完全重复的跳过,超限挤掉最旧。 */
163
+ async addCorpusPin(persona, pin, similar) {
164
+ const assistant = pin.assistant.trim();
165
+ if (!assistant)
166
+ return false;
167
+ const pins = this.getCorpusPins(persona);
168
+ if (pins.some((p) => similar(p.assistant, assistant)))
169
+ return false;
170
+ const next = [...pins, { user: pin.user.slice(0, CORPUS_LINE_CAP), assistant: assistant.slice(0, CORPUS_LINE_CAP), at: Date.now() }];
171
+ while (next.length > CORPUS_CAP)
172
+ next.shift();
173
+ await this.#pinsTable.put(persona, next);
174
+ return true;
175
+ }
176
+ getCustomPersona(name) {
177
+ const value = this.#customTable.get(name);
178
+ if (!value || typeof value?.displayName !== "string" || typeof value?.promptText !== "string")
179
+ return null;
180
+ return { ...value, corpus: sanitizeCorpus(value.corpus) };
181
+ }
182
+ listCustomPersonas() {
183
+ const out = {};
184
+ for (const key of this.#customTable.keys()) {
185
+ const persona = this.getCustomPersona(key);
186
+ if (persona)
187
+ out[key] = persona;
188
+ }
189
+ return out;
190
+ }
191
+ async setCustomPersona(name, persona) {
192
+ if (BUILTIN_PERSONA_NAMES.has(name))
193
+ throw new Error(`cannot shadow builtin persona: ${name}`);
194
+ if (!/^[a-z][a-z0-9_-]{0,31}$/.test(name))
195
+ throw new Error(`invalid persona key: ${name}`);
196
+ await this.#customTable.put(name, {
197
+ displayName: persona.displayName,
198
+ description: persona.description ?? "",
199
+ promptText: persona.promptText,
200
+ createdAt: persona.createdAt,
201
+ corpus: sanitizeCorpus(persona.corpus),
202
+ distillVersion: persona.distillVersion,
203
+ distillSource: persona.distillSource,
204
+ distillHint: persona.distillHint,
205
+ });
206
+ }
207
+ async deleteCustomPersona(name) {
208
+ if (BUILTIN_PERSONA_NAMES.has(name))
209
+ throw new Error(`cannot delete builtin persona: ${name}`);
210
+ // 管理弹窗承诺「删除会连带记忆/风格/档案」——四表同键全清,避免孤儿记忆
211
+ await this.#customTable.delete(name);
212
+ await this.#memoryTable.delete(name);
213
+ await this.#styleTable.delete(name);
214
+ await this.#pinsTable.delete(name);
215
+ await this.#table.delete(name);
216
+ }
217
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * 人设注入组装(纯函数):五段式 + Token 优化算法。
3
+ *
4
+ * 段序遵循缓存友好分层:稳定内容在前(契约),易变内容在后(检索结果、播报)。
5
+ * 语料示例按少样本衰减注入;记忆/风格按与当前用户消息的相关度取 top-k,
6
+ * core 记忆(身份称呼类)恒注入。
7
+ */
8
+ import { decaySampleCount, topKByRelevance } from "../core/retrieval.js";
9
+ import { sampleForSession } from "../core/sampling.js";
10
+ /**
11
+ * core 记忆判定:身份/称呼/名字类事实,无论检索命中与否恒注入。
12
+ * 昵称类只认「小+字母」(小A/小B);不放宽到 CJK,否则「小时/小组」等常用词全部误判。
13
+ */
14
+ export function isCoreMemory(text) {
15
+ return /名字|叫|称呼|昵称|爱称|自称|身份|小[A-Za-z]/.test(text) && text.length <= 30;
16
+ }
17
+ /** 组装人设注入文本;无人设(none/未选)时若带边界播报,仍单独输出播报。 */
18
+ export function buildPersonaSection(input) {
19
+ const { persona, config, query } = input;
20
+ if (!persona)
21
+ return input.boundaryText ?? "";
22
+ const parts = [];
23
+ // 1. 基础契约(基本盘)
24
+ const promptText = persona.promptText.trim();
25
+ if (promptText) {
26
+ // 强身份锚:明确「你是谁」,把模型从默认助手角色里拽出来。
27
+ // 推理型模型思考时默认以助手口吻组织语言,最终答复会顺着推理的语气走——
28
+ // 这条锚要求连思考方向都以人设身份展开,是人设能「上身」的第一道闸。
29
+ const who = input.profileName ?? persona.displayName;
30
+ parts.push(`〔说话人切换〕现在起你不是通用助手,你是「${who}」。你的每一句话——包括解释、提问、拒绝——都要从「${who}」嘴里说出来,用 TA 的口吻、TA 的用词、TA 的断句。下面的人设契约是唯一标准,任何与它冲突的默认助手习惯一律作废。\n\n${promptText}`);
31
+ }
32
+ // 2. 习得的风格约定(覆盖语义:与基础盘冲突时以此为准)
33
+ const styles = input.styleRules;
34
+ if (styles.length > 0) {
35
+ const chosen = config.strategy === "full"
36
+ ? styles.slice(-config.styleInject)
37
+ : topKByRelevance(styles, (r) => r.rule, query, config.styleInject);
38
+ if (chosen.length > 0) {
39
+ parts.push(`【习得的风格约定】以下是你在对话中学到的最新要求,与上方基础风格冲突时以此为准:\n${chosen
40
+ .map((r) => `- ${r.rule}`)
41
+ .join("\n")}`);
42
+ }
43
+ }
44
+ // 3. 身份
45
+ if (input.profileName) {
46
+ parts.push(`【你是谁】你的名字是「${input.profileName}」。这是你自己的身份,跨会话、跨项目不变;用户在任何地方叫这个名字都是在叫你。`);
47
+ }
48
+ // 4. 记忆:core 恒注入 + 其余按相关度 top-k
49
+ const facts = input.memories;
50
+ if (facts.length > 0) {
51
+ const core = facts.filter((f) => isCoreMemory(f.text)).slice(-3);
52
+ const coreTexts = new Set(core.map((f) => f.text));
53
+ const rest = facts.filter((f) => !coreTexts.has(f.text));
54
+ const retrieved = config.strategy === "full"
55
+ ? rest.slice(-Math.max(0, config.memoryInject - core.length))
56
+ : topKByRelevance(rest, (f) => f.text, query, Math.max(0, config.memoryInject - core.length));
57
+ const chosen = [...core, ...retrieved];
58
+ if (chosen.length > 0) {
59
+ parts.push(`【你记得】这些是你与这位用户长期相处的记忆:\n${chosen.map((f) => `- ${f.text}`).join("\n")}`);
60
+ }
61
+ }
62
+ // 5. 接班播报(仅切换窗口)
63
+ if (input.boundaryText)
64
+ parts.push(input.boundaryText);
65
+ // 6. 语料示例:少样本衰减 + 会话级稳定采样。摘录语料(对话中被用户认可的
66
+ // 真实回复)优先占位——它们比蒸馏语料更贴近当前使用中的语气。
67
+ const sampleCount = decaySampleCount(config.sampleCount, input.turnIndex, config.sampleMin);
68
+ const pins = (input.corpusPins ?? []).map((p) => ({ user: p.user, assistant: p.assistant }));
69
+ const pinCount = Math.min(pins.length, sampleCount);
70
+ // 摘录语料直接占前 pinCount 个槽位(最新优先),其余槽位从基础语料确定性采样。
71
+ const pinSlots = pins.slice(-pinCount).reverse();
72
+ const baseSlots = sampleForSession(persona.corpus ?? [], Math.max(0, sampleCount - pinSlots.length), input.sessionKey, persona.name);
73
+ const samples = [...pinSlots, ...baseSlots];
74
+ if (samples.length > 0) {
75
+ const lines = samples
76
+ .map((entry) => {
77
+ const user = entry.user ?? "";
78
+ const assistant = entry.assistant ?? "";
79
+ if (user && assistant)
80
+ return `用户: ${user}\n回复: ${assistant}`;
81
+ if (assistant)
82
+ return `回复: ${assistant}`;
83
+ return "";
84
+ })
85
+ .filter(Boolean)
86
+ .join("\n\n");
87
+ if (lines)
88
+ parts.push(`参考对话示例:\n(只模仿说话方式,不要把示例中的时间、地点、正在做什么或其他事实当成当前事实)\n${lines}`);
89
+ }
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
+ return parts.filter(Boolean).join("\n\n");
100
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * 人设资产的 IO 装载:manifest + 提示词 + 语料。
3
+ *
4
+ * 容错策略与 v0.1.0 一致:单个文件损坏只影响对应人设(空提示词/空语料),
5
+ * manifest 本身损坏则整个人设表为空 —— 插件降级为「无人设可切」而非崩溃。
6
+ */
7
+ import { readFileSync } from "node:fs";
8
+ import { join } from "node:path";
9
+ import { parseCorpus, parseManifest } from "../core/manifest.js";
10
+ /** manifest 中定义「不使用人设」的固定键名;默认人设语义依赖它存在。 */
11
+ export const NONE_PERSONA = "none";
12
+ /** 从资产目录加载人设表(保持 manifest 顺序)。 */
13
+ export function loadPersonalities(assetsDir) {
14
+ const result = {};
15
+ let entries;
16
+ try {
17
+ entries = parseManifest(readFileSync(join(assetsDir, "personalities.json"), "utf8"));
18
+ }
19
+ catch {
20
+ return result;
21
+ }
22
+ const personalitiesDir = join(assetsDir, "personalities");
23
+ for (const entry of entries) {
24
+ let promptText = "";
25
+ try {
26
+ promptText = readFileSync(join(personalitiesDir, entry.promptFile), "utf8").trim();
27
+ }
28
+ catch {
29
+ promptText = "";
30
+ }
31
+ let corpus = [];
32
+ try {
33
+ corpus = parseCorpus(readFileSync(join(personalitiesDir, entry.corpusFile), "utf8"));
34
+ }
35
+ catch {
36
+ corpus = [];
37
+ }
38
+ result[entry.name] = {
39
+ name: entry.name,
40
+ displayName: entry.displayName,
41
+ description: entry.description,
42
+ defaultName: entry.defaultName,
43
+ signatureWords: entry.signatureWords,
44
+ promptText,
45
+ corpus,
46
+ };
47
+ }
48
+ return result;
49
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * 反思日志:会话结束时评估对话是否遵守 Codex 风格任务执行协议,写入本地存储。
3
+ *
4
+ * 零用户感知 token:会话结束后(session/disposed)在空闲时间跑一次小模型调用,
5
+ * 读完对话片段后给四条规则各打 0-2 分并附一句备注,写到 `lume_reflection` 域。
6
+ * 积攒几周后读存储文件即可做定性分析,不用猜。
7
+ */
8
+ import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
9
+ import z from "@deepseek-ai/schemastery";
10
+ import { zodLike } from "./identity.js";
11
+ import { extractBalancedAt } from "./distill.js";
12
+ export const LUME_REFLECTION_SPEC = defineDomain({
13
+ name: "lume_reflection",
14
+ version: 1,
15
+ tables: {
16
+ logs: domainTable(zodLike(z.union([
17
+ z.object({ at: z.number(), context: z.number(), planning: z.number(), verification: z.number(), review: z.number(), note: z.string() }),
18
+ // v0.4.0 之前的存量日志;仅用于打开域并在启动时迁移。
19
+ z.object({ at: z.number(), p0: z.number(), p1: z.number(), p2: z.number(), p3: z.number(), note: z.string() }),
20
+ ]))),
21
+ },
22
+ });
23
+ export class ReflectionStore {
24
+ #table;
25
+ /** getFeedback 的短窗缓存:system prompt 每轮多次构建,避免每次全表扫描。 */
26
+ #feedbackCache = null;
27
+ /** 缓存有效期(毫秒):跨一轮多步构建,又不让新日志长时间不可见。 */
28
+ static FEEDBACK_CACHE_MS = 60_000;
29
+ constructor(table) {
30
+ this.#table = table;
31
+ }
32
+ async log(sessionId, entry) {
33
+ await this.#table.put(sessionId, entry);
34
+ this.#feedbackCache = null; // 新日志可能改变反馈结论,缓存失效
35
+ }
36
+ /** 把旧版 p0~p3 日志迁移为公开的 Codex 协议字段;幂等且只处理旧记录。 */
37
+ async migrateLegacy() {
38
+ let migrated = 0;
39
+ for (const key of this.#table.keys()) {
40
+ const raw = this.#table.get(key);
41
+ if (!raw || typeof raw.context === "number" || typeof raw.p0 !== "number")
42
+ continue;
43
+ await this.#table.put(key, {
44
+ at: typeof raw.at === "number" ? raw.at : Date.now(),
45
+ context: raw.p0,
46
+ planning: raw.p1,
47
+ verification: raw.p2,
48
+ review: raw.p3,
49
+ note: typeof raw.note === "string" ? raw.note : "",
50
+ });
51
+ migrated++;
52
+ }
53
+ if (migrated > 0)
54
+ this.#feedbackCache = null;
55
+ return migrated;
56
+ }
57
+ /** 最近日志持续低分时返回一条短反馈;连续回升后自动淡出。结果带短窗缓存。 */
58
+ getFeedback() {
59
+ const now = Date.now();
60
+ if (this.#feedbackCache && now - this.#feedbackCache.at < ReflectionStore.FEEDBACK_CACHE_MS) {
61
+ return this.#feedbackCache.value;
62
+ }
63
+ const value = this.#computeFeedback();
64
+ this.#feedbackCache = { at: now, value };
65
+ return value;
66
+ }
67
+ #computeFeedback() {
68
+ const entries = [...this.#table.keys()].map((key) => this.#table.get(key)).filter((e) => typeof e?.context === "number").sort((a, b) => b.at - a.at).slice(0, 5);
69
+ if (entries.length < 3)
70
+ return null;
71
+ const avg = (key) => entries.reduce((n, e) => n + e[key], 0) / entries.length;
72
+ const weakest = ["context", "planning", "verification", "review"].slice().sort((a, b) => avg(a) - avg(b))[0];
73
+ if (avg(weakest) > 1.15)
74
+ return null;
75
+ const text = {
76
+ context: "请先确认目标、约束和当前状态,避免遗漏已知信息。",
77
+ planning: "请按任务复杂度先做必要调研和计划,不要过早执行。",
78
+ verification: "本轮修改或执行后请立即做最小验证,不要只看命令是否结束。",
79
+ review: "完成前请对照需求、边界条件和数据保留做一次结果复核。",
80
+ };
81
+ return text[weakest];
82
+ }
83
+ }
84
+ export const REFLECTION_SYSTEM = [
85
+ "你是一个冷静的复盘评估器。下面会给你一段与用户对话的片段。",
86
+ "请评估其中的助手是否遵守了 Codex 风格任务执行协议,每项打 0/1/2 分(0=明显违反,1=一般,2=良好):",
87
+ "",
88
+ "上下文管理:是否理解并保留目标、约束、状态、关键决策和已排除假设",
89
+ "计划与门控:是否拆解任务、先调研再执行,并按风险自适应投入",
90
+ "验证与失败处理:是否在变更后验证,失败时归因并更换方案",
91
+ "结果复核:是否对照完成标准、边界条件、兼容性和数据保留进行复核",
92
+ "",
93
+ "只输出一个 JSON 对象,形如 {\"context\":2,\"planning\":2,\"verification\":1,\"review\":0,\"note\":\"...\"},note 一句话中文,不要输出其他内容。",
94
+ ].join("\n");
95
+ export function buildReflectionPrompt(turns) {
96
+ return {
97
+ system: REFLECTION_SYSTEM,
98
+ userText: `对话片段:\n${turns.join("\n")}`,
99
+ };
100
+ }
101
+ export function parseReflectionScore(output) {
102
+ const trimmed = output.trim().replace(/^```(?:json)?/i, "").replace(/```$/, "").trim();
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(trimmed);
106
+ }
107
+ catch {
108
+ // 推理型模型会在 JSON 前后输出推理文本:扫描所有平衡块,取最长可解析块
109
+ let found = false;
110
+ let best = null;
111
+ let bestLen = -1;
112
+ for (let i = 0; i < trimmed.length; i++) {
113
+ const ch = trimmed[i];
114
+ if (ch !== "{" && ch !== "[")
115
+ continue;
116
+ const block = extractBalancedAt(trimmed, i);
117
+ if (block === null)
118
+ continue;
119
+ try {
120
+ const candidate = JSON.parse(block);
121
+ if (block.length > bestLen) {
122
+ best = candidate;
123
+ bestLen = block.length;
124
+ }
125
+ found = true;
126
+ }
127
+ catch {
128
+ continue;
129
+ }
130
+ }
131
+ if (!found)
132
+ return null;
133
+ parsed = best;
134
+ }
135
+ const r = parsed;
136
+ const context = clampScore(r.context ?? r.p0);
137
+ const planning = clampScore(r.planning ?? r.p1);
138
+ const verification = clampScore(r.verification ?? r.p2);
139
+ const review = clampScore(r.review ?? r.p3);
140
+ const note = typeof r.note === "string" ? r.note.trim().slice(0, 200) : "";
141
+ if (Number.isNaN(context) || Number.isNaN(planning) || Number.isNaN(verification) || Number.isNaN(review))
142
+ return null;
143
+ return { at: Date.now(), context, planning, verification, review, note };
144
+ }
145
+ function clampScore(value) {
146
+ const n = Number(value);
147
+ if (!Number.isFinite(n))
148
+ return NaN;
149
+ return Math.max(0, Math.min(2, Math.round(n)));
150
+ }