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.
- package/LICENSE +21 -0
- package/README.md +238 -0
- package/assets/personalities/butler-corpus.jsonl +30 -0
- package/assets/personalities/butler.txt +12 -0
- package/assets/personalities/loli-corpus.jsonl +30 -0
- package/assets/personalities/loli.txt +12 -0
- package/assets/personalities/none-corpus.jsonl +0 -0
- package/assets/personalities/none.txt +0 -0
- package/assets/personalities/senpai-corpus.jsonl +30 -0
- package/assets/personalities/senpai.txt +12 -0
- package/assets/personalities/tsundere-corpus.jsonl +30 -0
- package/assets/personalities/tsundere.txt +12 -0
- package/assets/personalities.json +47 -0
- package/cordis.patch.yml +5 -0
- package/lib/client.js +2395 -0
- package/lib/core/card.js +101 -0
- package/lib/core/dialogue-mining.js +409 -0
- package/lib/core/leak-detector.js +41 -0
- package/lib/core/manifest.js +60 -0
- package/lib/core/persona-text.js +30 -0
- package/lib/core/retrieval.js +100 -0
- package/lib/core/sampling.js +46 -0
- package/lib/core/text.js +17 -0
- package/lib/host/boundary.js +32 -0
- package/lib/host/distill.js +520 -0
- package/lib/host/extraction.js +150 -0
- package/lib/host/identity.js +217 -0
- package/lib/host/injection.js +100 -0
- package/lib/host/personalities.js +49 -0
- package/lib/host/reflection.js +150 -0
- package/lib/host/registry.js +62 -0
- package/lib/host/rpc.js +284 -0
- package/lib/host/session-runtime.js +48 -0
- package/lib/host/store.js +123 -0
- package/lib/index.js +737 -0
- package/package.json +99 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,737 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lume-dsh-plugin 宿主入口(Cordis 函数插件)—— v0.3.0「人设即人」。
|
|
3
|
+
*
|
|
4
|
+
* 注入服务:
|
|
5
|
+
* - systemPrompt 思考逻辑 + 人设五段式注入
|
|
6
|
+
* - connection RPC 通道 /lume
|
|
7
|
+
* - storageDomain 两个域:lume_persona_state(会话显式选择)、lume_persona_identity(身份/记忆/风格/自定义人设)
|
|
8
|
+
* - tools 三个模型可调用工具(lume_remember / lume_update_style / lume_create_persona)
|
|
9
|
+
*
|
|
10
|
+
* 被动提取安全网挂在 session/event 的 turn/end 上,三道门(关键词/去重/冷却)
|
|
11
|
+
* 保证 99% 轮次零消耗;模型路由可配置(extractionProvider/Model),否则从
|
|
12
|
+
* request/context 事件缓存的主对话路由回落(官方 title-llm 模式)。
|
|
13
|
+
*/
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { dirname, join } from "node:path";
|
|
16
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
18
|
+
import { BlockAssembler, createUserMessage, ReasoningEffortId } from "@deepseek-ai/dsh-llm";
|
|
19
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
20
|
+
import z from "@deepseek-ai/schemastery";
|
|
21
|
+
import { buildPersonaSection } from "./host/injection.js";
|
|
22
|
+
import { loadPersonalities, NONE_PERSONA } from "./host/personalities.js";
|
|
23
|
+
import { createLumeRpcHandler } from "./host/rpc.js";
|
|
24
|
+
import { FilePersonaStore, migrateLegacyState, PersonaStore } from "./host/store.js";
|
|
25
|
+
import { IdentityStore, LUME_IDENTITY_SPEC, zodLike } from "./host/identity.js";
|
|
26
|
+
import { PersonaRegistry } from "./host/registry.js";
|
|
27
|
+
import { buildCorrectionPrompt, buildExtractionPrompt, extractNaming, isCoolingDown, isDuplicateFact, mergeNewFacts, parseCorrectionRule, parseFacts, resolveAuxRoute, shouldCaptureCorpus, shouldConsider, shouldConsiderCorrection } from "./host/extraction.js";
|
|
28
|
+
import { DistillJobRunner, DISTILL_ALGORITHM_VERSION, runDistill } from "./host/distill.js";
|
|
29
|
+
import { jaccard } from "./core/retrieval.js";
|
|
30
|
+
import { detectLeak } from "./core/leak-detector.js";
|
|
31
|
+
import { messageText } from "./core/text.js";
|
|
32
|
+
import { composeBoundary } from "./host/boundary.js";
|
|
33
|
+
import { SessionRuntimeStore } from "./host/session-runtime.js";
|
|
34
|
+
import { LUME_REFLECTION_SPEC, ReflectionStore, buildReflectionPrompt, parseReflectionScore } from "./host/reflection.js";
|
|
35
|
+
/** Codex 风格任务执行协议:始终注入,约束任务如何完成。 */
|
|
36
|
+
const THINKING_TEXT = `[任务执行协议]
|
|
37
|
+
|
|
38
|
+
你应遵循以下公开的工程工作协议。它约束任务如何被完成,不要求输出隐藏的逐步思考过程;对外只给出必要的结论、计划、变更和验证结果。
|
|
39
|
+
|
|
40
|
+
**身份分工**:人设只影响自然语言表达;本协议负责正确完成任务。代码、数学、工具调用、结构化输出和安全判断保持准确、朴素,不因人设而戏剧化。
|
|
41
|
+
|
|
42
|
+
**P0 上下文管理**:先确认用户真正要达成的结果、约束、涉及的文件/系统和完成标准。上下文变长时压缩为:目标、已完成事项、关键决策、当前状态、错误、已排除假设、下一步。不要反复提出已经解决或排除的问题。
|
|
43
|
+
|
|
44
|
+
**P1 阶段门控**:复杂任务按“理解 → 只读调研 → 简短计划 → 执行 → 验证 → 汇报”推进。调研和计划阶段不修改外部状态;未确认目标文件、接口和影响范围前,不直接动手。
|
|
45
|
+
|
|
46
|
+
**P1 任务分解**:把大任务拆成可验证的小步骤,优先处理阻塞项和高风险项。每一步都说明完成条件;能并行的只读检查并行进行,存在依赖的步骤按顺序执行。
|
|
47
|
+
|
|
48
|
+
**P1 自适应投入**:不要把“快速”当成固定目标。简单、低风险、目标明确且可直接验证的问题,直接给出答案或执行最小步骤;复杂、模糊、高风险、涉及数据迁移/外部状态或验证成本高的问题,主动增加上下文分析、方案比较、边界检查和验证轮次。只有在信息足够且风险可控时才快速收敛。
|
|
49
|
+
|
|
50
|
+
**P1 信息路由**:优先定位最可能影响结果的入口、数据流和约束,不平均浏览无关内容;无依赖的只读检查可以并行,依赖前置结果的操作必须等待确认。
|
|
51
|
+
|
|
52
|
+
**P2 变更纪律**:修改前完整读取相关文件,理解现有实现和用户已有改动;一次性完成同一文件的相关修改。保持改动最小、可回滚、与现有接口兼容,不重写无关代码,不覆盖用户数据。
|
|
53
|
+
|
|
54
|
+
**P2 验证闭环**:每次修改后立即运行与风险匹配的测试、类型检查、构建或最小复现。不要只看“命令成功”,还要确认输出确实满足目标。发现失败先归因:输入、逻辑、接口、环境或权限;修复后重新验证。
|
|
55
|
+
|
|
56
|
+
**P2 振荡预防**:同一假设连续失败后停止重复尝试,记录失败原因并换方案。已排除的假设不再重提;不使用破坏性命令绕过问题;不把测试删掉或放宽断言来制造假成功。
|
|
57
|
+
|
|
58
|
+
**P3 结果复核**:完成前逐项对照用户要求、边界条件、错误路径、兼容性和数据保留。区分“已实现”“已验证”“推测有效”和“仍然缺失”,不把部分完成说成全部完成。
|
|
59
|
+
|
|
60
|
+
**工具与安全**:工具调用前判断是否只读、是否会写入或删除、目标是否精确、是否涉及隐私或外部通信。优先使用专用工具和最小权限;破坏性操作、敏感数据传输和不可逆变更必须先获得明确授权。
|
|
61
|
+
|
|
62
|
+
**代码任务**:先定位入口、数据流和测试,再修改;优先复用现有抽象;为新行为补回归测试;同时考虑旧数据迁移、失败回退和用户已有状态。最终汇报修改文件、验证结果、已知限制和用户需要采取的动作。
|
|
63
|
+
|
|
64
|
+
**对话任务**:先直接回答当前问题,再补充必要依据;简单问题保持简洁,复杂问题给出足够的推理依据、假设和验证边界。不编造已经执行的操作、工具结果、文件内容或当前状态。需要用户决定时只提出真正阻塞的问题。
|
|
65
|
+
|
|
66
|
+
**隐私与事实边界**:示例、历史消息和角色记忆用于相关性与表达参考,不自动等于当前事实。涉及时间、地点、当前行为和现实状态时,只依据当前上下文或可靠工具结果。
|
|
67
|
+
|
|
68
|
+
每次完成一个阶段后,检查:目标是否仍然一致?变更是否在授权范围内?验证是否覆盖了最可能的失败方式?`;
|
|
69
|
+
/** 普通闲聊用短版协议;任务型请求才注入完整版,避免每轮重复支付完整工作协议。 */
|
|
70
|
+
const THINKING_COMPACT_TEXT = `[任务执行协议]
|
|
71
|
+
简单问题直接回答;复杂或高风险任务先理解目标和约束,再调研、计划、执行、验证、复核。修改前读取相关内容,修改后验证;失败先归因,不重复已排除方案。人设只影响表达,不影响事实、代码、工具调用和安全判断。历史示例只参考风格,不自动等于当前事实。`;
|
|
72
|
+
const TASK_SIGNAL_RE = /代码|编程|文件|项目|仓库|脚本|命令|调研|研究|分析|实现|修改|修复|构建|测试|部署|配置|安装|迁移|导入|导出|接口|API|数据库|批量|计划|方案|风险|审查|review|debug|bug|深度|复杂/i;
|
|
73
|
+
const REASONING_MODEL_RE = /deepseek-v[345]|reason|o[134]|gpt-5/i;
|
|
74
|
+
/** 推理型模型的任务协议:省掉它天生具备的计划/分解条款,保留行为约束与事实边界。 */
|
|
75
|
+
const THINKING_REASONING_TEXT = `[任务执行协议]
|
|
76
|
+
已确认当前模型具备推理能力。仍须保护用户改动,修改后立即验证;失败先归因并更换方案,不重复已排除假设;完成前复核需求、边界和数据保留。示例、历史消息和角色记忆只作表达与相关性参考,不自动等于当前事实。人设只影响表达,不影响代码、工具调用和安全判断。`;
|
|
77
|
+
/** schemastery → domainTable 形参的桥接(与 identity.ts 同款)。 */
|
|
78
|
+
const recordSchema = zodLike;
|
|
79
|
+
/** 会话人设选择的持久层(键 = sessionId)。 */
|
|
80
|
+
export const LUME_DOMAIN_SPEC = defineDomain({
|
|
81
|
+
name: "lume_persona_state",
|
|
82
|
+
version: 1,
|
|
83
|
+
tables: {
|
|
84
|
+
session_persona: domainTable(recordSchema(z.string())),
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const SESSION_PERSONA_TABLE = "session_persona";
|
|
88
|
+
const LUME_CHANNEL = "/lume";
|
|
89
|
+
/** 小模型原始输出诊断落盘路径(host DSH_HOME 的 storages 旁);调试用,不对外。 */
|
|
90
|
+
const LLM_DUMP_PATH = process.env.DSH_HOME
|
|
91
|
+
? join(process.env.DSH_HOME, "storages-lume-llm-dump.json")
|
|
92
|
+
: join(dirname(fileURLToPath(import.meta.url)), "..", "llm-dump.json");
|
|
93
|
+
const LUME_PERSONA_SECTION = "lume:persona";
|
|
94
|
+
const LUME_THINKING_SECTION = "lume:thinking";
|
|
95
|
+
const LUME_THINKING_ORDER = 1;
|
|
96
|
+
/** 人设段与播报段的 order:取 10000+/10100——真正的 system prompt 末尾。
|
|
97
|
+
* 宿主的段落布局是:身份声明 -1000(最前,"你是 AI 助手"的来源)、策略 500-900、
|
|
98
|
+
* 工具定义 1000-5000、结构化输出 9900。人设若按惯例放 order 2,会被压在头部
|
|
99
|
+
* 身份声明与近万 token 工具内容之间——实测模型会无视中段的人设契约、直接以
|
|
100
|
+
* "AI 助手"自居。放在最末尾(紧贴对话历史、注意力最强)后,人格合规才成立。 */
|
|
101
|
+
const LUME_PERSONA_ORDER = 10000;
|
|
102
|
+
const LUME_BOUNDARY_SECTION = "lume:boundary";
|
|
103
|
+
const LUME_BOUNDARY_ORDER = 10100;
|
|
104
|
+
const MAX_SESSIONS = 200;
|
|
105
|
+
const SWITCH_BOUNDARY_TURNS = 2;
|
|
106
|
+
/** Cordis 插件名 */
|
|
107
|
+
export const name = "lume";
|
|
108
|
+
/** 依赖的服务 */
|
|
109
|
+
export const inject = ["systemPrompt", "connection", "storageDomain", "tools", "llm", "agentDefaultModel", "settings"];
|
|
110
|
+
export function apply(ctx, config = {}) {
|
|
111
|
+
const assetsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "assets");
|
|
112
|
+
const builtins = loadPersonalities(assetsDir);
|
|
113
|
+
const sampleCount = config.sampleCount ?? 6;
|
|
114
|
+
const sampleMin = config.sampleMin ?? 2;
|
|
115
|
+
// 人设段贴着对话历史(system prompt 最末尾),不要回退到常规的 order 2
|
|
116
|
+
const personaOrder = config.personaOrder ?? LUME_PERSONA_ORDER;
|
|
117
|
+
const memoryInject = config.memoryInject ?? 12;
|
|
118
|
+
const styleInject = config.styleInject ?? 5;
|
|
119
|
+
const strategy = config.injectionStrategy ?? "topk";
|
|
120
|
+
const extractionEnabled = config.extractionEnabled ?? true;
|
|
121
|
+
const cooldownMs = config.extractionCooldownMs ?? 10 * 60 * 1000;
|
|
122
|
+
const extractionRouteOverride = { provider: config.extractionProvider, model: config.extractionModel };
|
|
123
|
+
const distillRouteOverride = { provider: config.distillProvider, model: config.distillModel };
|
|
124
|
+
const reflectionEnabled = config.reflectionEnabled ?? true;
|
|
125
|
+
const boundaryTurns = config.switchBoundaryTurns ?? SWITCH_BOUNDARY_TURNS;
|
|
126
|
+
const defaultName = builtins[NONE_PERSONA] ? NONE_PERSONA : null;
|
|
127
|
+
const legacyStatePath = join(assetsDir, "persona-state.json");
|
|
128
|
+
// ── 存储就绪:会话选择域(必有)+ 身份域(失败降级为无档案功能)──
|
|
129
|
+
let currentStore = null;
|
|
130
|
+
let identity = null;
|
|
131
|
+
const storesReady = (async () => {
|
|
132
|
+
try {
|
|
133
|
+
const domain = await ctx.storageDomain.open(LUME_DOMAIN_SPEC);
|
|
134
|
+
ctx.effect(() => async () => {
|
|
135
|
+
await domain.close();
|
|
136
|
+
}, "lume: close state domain");
|
|
137
|
+
const store = new PersonaStore(domain.table(SESSION_PERSONA_TABLE), { maxSessions: MAX_SESSIONS });
|
|
138
|
+
const migrated = await migrateLegacyState(store, legacyStatePath);
|
|
139
|
+
if (migrated)
|
|
140
|
+
ctx.logger?.warn?.("lume: 已从 assets/persona-state.json 迁移旧的人设记忆");
|
|
141
|
+
return store;
|
|
142
|
+
}
|
|
143
|
+
catch (error) {
|
|
144
|
+
ctx.logger?.warn?.("lume: storageDomain 不可用,降级为 assets 文件存储", error);
|
|
145
|
+
return new FilePersonaStore(legacyStatePath, { maxSessions: MAX_SESSIONS });
|
|
146
|
+
}
|
|
147
|
+
})();
|
|
148
|
+
const identityReady = (async () => {
|
|
149
|
+
try {
|
|
150
|
+
const domain = await ctx.storageDomain.open(LUME_IDENTITY_SPEC);
|
|
151
|
+
ctx.effect(() => async () => {
|
|
152
|
+
await domain.close();
|
|
153
|
+
}, "lume: close identity domain");
|
|
154
|
+
return new IdentityStore({
|
|
155
|
+
profile: domain.table("profile"),
|
|
156
|
+
memory_facts: domain.table("memory_facts"),
|
|
157
|
+
style_rules: domain.table("style_rules"),
|
|
158
|
+
corpus_pins: domain.table("corpus_pins"),
|
|
159
|
+
custom_personas: domain.table("custom_personas"),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
ctx.logger?.warn?.("lume: 身份域不可用,档案/记忆/自定义人设功能降级", error);
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
void storesReady.then((store) => {
|
|
168
|
+
currentStore = store;
|
|
169
|
+
});
|
|
170
|
+
void identityReady.then((store) => {
|
|
171
|
+
identity = store;
|
|
172
|
+
});
|
|
173
|
+
// ── 反思域(会话结束后打分,失败降级为无反思功能)──
|
|
174
|
+
let reflectionStore = null;
|
|
175
|
+
const reflectionReady = (async () => {
|
|
176
|
+
try {
|
|
177
|
+
const domain = await ctx.storageDomain.open(LUME_REFLECTION_SPEC);
|
|
178
|
+
ctx.effect(() => async () => { await domain.close(); }, "lume: close reflection domain");
|
|
179
|
+
const store = new ReflectionStore(domain.table("logs"));
|
|
180
|
+
const migrated = await store.migrateLegacy();
|
|
181
|
+
if (migrated > 0)
|
|
182
|
+
ctx.logger?.warn?.(`lume: 已迁移 ${migrated} 条旧版反思日志`);
|
|
183
|
+
return store;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
ctx.logger?.warn?.("lume: 反思域不可用,反思日志降级", error);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
})();
|
|
190
|
+
void reflectionReady.then((s) => { reflectionStore = s; });
|
|
191
|
+
const registry = new PersonaRegistry(builtins, () => identity);
|
|
192
|
+
ctx.logger?.warn?.(`lume: 已加载(builtins=${Object.keys(builtins).join(",") || "空!"},assets=${assetsDir})`);
|
|
193
|
+
ctx.logger?.warn?.(`lume: 版本 0.3.3 — llmRoute 初始化策略:agentDefaultModel → settings → 回退`);
|
|
194
|
+
// ── 每会话运行时状态(内存,重启即弃,LRU 上限兜底)──
|
|
195
|
+
const runtime = new SessionRuntimeStore();
|
|
196
|
+
// ── 模型路由缓存(request/context,会话过程中由 agent-loop 更新)──
|
|
197
|
+
let llmRoute = null;
|
|
198
|
+
// ── 主动解析默认模型:会话开始前蒸馏/提取也要能用 ──
|
|
199
|
+
// request/context 事件只在对话路由变化时触发(delta event),静默状态下 llmRoute 恒为 null,
|
|
200
|
+
// 导致蒸馏一开即报「模型路由不可用」。这里初始化即解析默认模型,后续仍被 request/context 覆盖。
|
|
201
|
+
//
|
|
202
|
+
// 优先用 agentDefaultModel.currentSelection()(规范 API),不可用时回退 settings.get("agent-default-model")。
|
|
203
|
+
// 插件沙箱可能限制某些服务,双路径兜底保证至少有一条能走通。
|
|
204
|
+
(function initLlmRoute() {
|
|
205
|
+
try {
|
|
206
|
+
// 路径 A:agentDefaultModel 服务(规范 API,组合配置 + settings)
|
|
207
|
+
const agentDefaultModel = ctx.get("agentDefaultModel");
|
|
208
|
+
if (agentDefaultModel) {
|
|
209
|
+
const selection = agentDefaultModel.currentSelection?.();
|
|
210
|
+
if (typeof selection?.provider === "string" && typeof selection?.model === "string") {
|
|
211
|
+
llmRoute = { provider: selection.provider, model: selection.model };
|
|
212
|
+
ctx.logger?.warn?.(`lume: llmRoute 从 agentDefaultModel 初始化 → ${llmRoute.provider}/${llmRoute.model}`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
catch (e) {
|
|
218
|
+
ctx.logger?.warn?.("lume: agentDefaultModel 不可用,回退 settings", e);
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
// 路径 B:settings 服务(读原始配置,兜底)
|
|
222
|
+
const settings = ctx.get("settings");
|
|
223
|
+
if (settings) {
|
|
224
|
+
const raw = settings.get("agent-default-model");
|
|
225
|
+
if (raw && typeof raw.provider === "string" && typeof raw.model === "string") {
|
|
226
|
+
llmRoute = { provider: raw.provider, model: raw.model };
|
|
227
|
+
ctx.logger?.warn?.(`lume: llmRoute 从 settings 初始化 → ${llmRoute.provider}/${llmRoute.model}`);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (e) {
|
|
233
|
+
ctx.logger?.warn?.("lume: settings 也读不到默认模型,蒸馏/提取将不可用", e);
|
|
234
|
+
}
|
|
235
|
+
ctx.logger?.warn?.("lume: llmRoute 初始化失败 — 蒸馏/提取在对话前不可用");
|
|
236
|
+
})();
|
|
237
|
+
/** 小模型单次调用(提取/蒸馏等辅助功能用);路由由调用方解析后传入,不可用时返回 null。signal 中止时抛错。
|
|
238
|
+
* 组装时保留全部块(text + reasoning),蒸馏解析需要完整的模型输出——
|
|
239
|
+
* 推理型模型可能把 JSON 拆在 reasoning 块尾部,只取 text 会拿到半成品。
|
|
240
|
+
* 蒸馏类调用传完整控制参数:reasoningEffort=low(复述风模型常吃 4000+ token 复述指令,低推理显著缩短)、
|
|
241
|
+
* temperature=0(稳定)。模型不支持低推理时会抛 UNSUPPORTED_REASONING_EFFORT,捕获降级重试(去掉 effort 重发)。 */
|
|
242
|
+
async function callLlm(route, system, userText, maxTokens, signal) {
|
|
243
|
+
if (!route)
|
|
244
|
+
return null;
|
|
245
|
+
const llm = ctx.get("llm");
|
|
246
|
+
if (!llm)
|
|
247
|
+
return null;
|
|
248
|
+
try {
|
|
249
|
+
const messages = [
|
|
250
|
+
createUserMessage({
|
|
251
|
+
content: [{ type: "text", text: userText }],
|
|
252
|
+
source: { kind: "plugin", plugin: "lume" },
|
|
253
|
+
}),
|
|
254
|
+
];
|
|
255
|
+
const assembler = new BlockAssembler();
|
|
256
|
+
try {
|
|
257
|
+
for await (const chunk of llm.stream({ provider: route.provider, model: route.model, messages, system, maxTokens, reasoningEffort: ReasoningEffortId("low"), temperature: 0, ...(signal ? { signal } : {}) })) {
|
|
258
|
+
assembler.push(chunk);
|
|
259
|
+
}
|
|
260
|
+
// 错误经流内 finish chunk 传输(不 throw)——检查 finish.kind === "error"
|
|
261
|
+
if (assembler.finish.kind === "error") {
|
|
262
|
+
const code = assembler.finish.failure?.code;
|
|
263
|
+
if (code !== "UNSUPPORTED_REASONING_EFFORT")
|
|
264
|
+
throw new Error(String(assembler.finish.failure?.message ?? "unnamed stream error"));
|
|
265
|
+
// 不支持 effort:降级无 effort 重发
|
|
266
|
+
const assembler2 = new BlockAssembler();
|
|
267
|
+
for await (const chunk of llm.stream({ provider: route.provider, model: route.model, messages, system, maxTokens, temperature: 0, ...(signal ? { signal } : {}) })) {
|
|
268
|
+
assembler2.push(chunk);
|
|
269
|
+
}
|
|
270
|
+
if (assembler2.finish.kind === "error") {
|
|
271
|
+
throw new Error(String(assembler2.finish.failure?.message ?? "unnamed stream error"));
|
|
272
|
+
}
|
|
273
|
+
return assembler2
|
|
274
|
+
.blocks()
|
|
275
|
+
.map((block) => {
|
|
276
|
+
const text = block?.text;
|
|
277
|
+
return typeof text === "string" ? text : "";
|
|
278
|
+
})
|
|
279
|
+
.filter((text) => text.length > 0)
|
|
280
|
+
.join(" ")
|
|
281
|
+
.trim();
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
// throw 形态的错误:非 UNSUPPORTED 直接抛;是则降级重试
|
|
286
|
+
if (error?.code !== "UNSUPPORTED_REASONING_EFFORT")
|
|
287
|
+
throw error;
|
|
288
|
+
const assembler2 = new BlockAssembler();
|
|
289
|
+
for await (const chunk of llm.stream({ provider: route.provider, model: route.model, messages, system, maxTokens, temperature: 0, ...(signal ? { signal } : {}) })) {
|
|
290
|
+
assembler2.push(chunk);
|
|
291
|
+
}
|
|
292
|
+
if (assembler2.finish.kind === "error") {
|
|
293
|
+
throw new Error(String(assembler2.finish.failure?.message ?? "unnamed stream error"));
|
|
294
|
+
}
|
|
295
|
+
return assembler2
|
|
296
|
+
.blocks()
|
|
297
|
+
.map((block) => {
|
|
298
|
+
const text = block?.text;
|
|
299
|
+
return typeof text === "string" ? text : "";
|
|
300
|
+
})
|
|
301
|
+
.filter((text) => text.length > 0)
|
|
302
|
+
.join(" ")
|
|
303
|
+
.trim();
|
|
304
|
+
}
|
|
305
|
+
const allBlocks = assembler
|
|
306
|
+
.blocks()
|
|
307
|
+
.map((block) => {
|
|
308
|
+
const text = block?.text;
|
|
309
|
+
return typeof text === "string" ? text : "";
|
|
310
|
+
})
|
|
311
|
+
.filter((text) => text.length > 0);
|
|
312
|
+
// 诊断探针:完整输出落盘(含 max-tokens 截断标记;追加,一次失败可看全程)
|
|
313
|
+
try {
|
|
314
|
+
const existing = readFileSync(LLM_DUMP_PATH, "utf8");
|
|
315
|
+
const dumps = existing ? JSON.parse(existing) : [];
|
|
316
|
+
dumps.push({ ts: Date.now(), route: `${route.provider}/${route.model}`, maxTokens, finish: assembler.finish, blocks: allBlocks.map((t) => t.slice(0, 6000)) });
|
|
317
|
+
writeFileSync(LLM_DUMP_PATH, JSON.stringify(dumps, null, 2), "utf8");
|
|
318
|
+
}
|
|
319
|
+
catch { /* 诊断失败不阻断 */ }
|
|
320
|
+
return allBlocks.join(" ").trim();
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
if (signal?.aborted)
|
|
324
|
+
throw error; // 用户取消:向上抛,任务状态走 cancelled
|
|
325
|
+
ctx.logger?.warn?.("lume: 小模型调用失败,本轮跳过", error);
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
/** 被动提取:三道门 → 小模型 → 合并落盘。按会话串行,失败静默。 */
|
|
330
|
+
function scheduleExtraction(sid, st) {
|
|
331
|
+
if (st.extracting) {
|
|
332
|
+
st.extracting = st.extracting.then(() => doExtract(sid, st));
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
st.extracting = doExtract(sid, st);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
async function doExtract(sid, st) {
|
|
339
|
+
const userText = st.userText;
|
|
340
|
+
const assistantText = st.assistantText;
|
|
341
|
+
// 语料摘录候选:上一轮「用户消息 → 人设回复」的真实对话对。用户若在下一轮
|
|
342
|
+
// 表达认可(「太像了」),摘录的正是这对,而不是认可语本身。
|
|
343
|
+
const pinCandidate = st.lastExchange;
|
|
344
|
+
// 用本轮的对话对覆盖,下一轮的认可摘录拿到的就是「被认可的那一轮」。
|
|
345
|
+
st.lastExchange = userText && assistantText ? { user: userText, assistant: assistantText } : null;
|
|
346
|
+
st.userText = "";
|
|
347
|
+
st.assistantText = "";
|
|
348
|
+
try {
|
|
349
|
+
if (!extractionEnabled || !identity)
|
|
350
|
+
return;
|
|
351
|
+
const personaName = st.lastInjected;
|
|
352
|
+
if (!personaName || !userText)
|
|
353
|
+
return;
|
|
354
|
+
// 通道 A:纠偏捕获——用户负面元反馈(太夸张/油腻/正常点…)→ 小模型转成
|
|
355
|
+
// 一条风格约定写回 style_rules(Jaccard 相似自动替换,不堆叠)。冷却与
|
|
356
|
+
// 记忆提取共用,避免同一轮双模型调用。
|
|
357
|
+
if (shouldConsiderCorrection(userText) && !isCoolingDown(st.lastExtractionAt, Date.now(), cooldownMs)) {
|
|
358
|
+
const route = resolveAuxRoute(extractionRouteOverride, llmRoute);
|
|
359
|
+
if (route) {
|
|
360
|
+
const prompt = buildCorrectionPrompt(userText, assistantText, identity.getStyleRules(personaName).map((r) => r.rule));
|
|
361
|
+
const output = await callLlm(route, prompt.system, prompt.userText, 400);
|
|
362
|
+
const rule = output === null ? null : parseCorrectionRule(output);
|
|
363
|
+
if (rule) {
|
|
364
|
+
st.lastExtractionAt = Date.now();
|
|
365
|
+
await identity.addStyleRule(personaName, rule, (a, b) => jaccard(a, b) >= 0.6);
|
|
366
|
+
ctx.logger?.warn?.(`lume: 纠偏捕获 → ${personaName}: ${rule}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// 通道 B:语料摘录——用户认可上一轮回复「像本人」时,把真实对话对
|
|
371
|
+
// 摘录进 corpus_pins(注入时并入采样池,让语气随真实使用收敛)。
|
|
372
|
+
if (shouldCaptureCorpus(userText) && pinCandidate && pinCandidate.assistant) {
|
|
373
|
+
const written = await identity.addCorpusPin(personaName, { user: pinCandidate.user, assistant: pinCandidate.assistant, at: Date.now() }, (a, b) => jaccard(a, b) >= 0.8);
|
|
374
|
+
if (written)
|
|
375
|
+
ctx.logger?.warn?.(`lume: 语料摘录 → ${personaName}: ${pinCandidate.assistant.slice(0, 40)}`);
|
|
376
|
+
}
|
|
377
|
+
// 通道 C:记忆提取(原有路径)
|
|
378
|
+
if (!shouldConsider(userText))
|
|
379
|
+
return;
|
|
380
|
+
if (isCoolingDown(st.lastExtractionAt, Date.now(), cooldownMs))
|
|
381
|
+
return;
|
|
382
|
+
const existing = identity.getMemory(personaName);
|
|
383
|
+
if (isDuplicateFact(userText, existing))
|
|
384
|
+
return;
|
|
385
|
+
const prompt = buildExtractionPrompt(userText, assistantText, existing.map((f) => f.text));
|
|
386
|
+
const output = await callLlm(resolveAuxRoute(extractionRouteOverride, llmRoute), prompt.system, prompt.userText, 800);
|
|
387
|
+
if (output === null)
|
|
388
|
+
return;
|
|
389
|
+
st.lastExtractionAt = Date.now();
|
|
390
|
+
const fresh = mergeNewFacts(parseFacts(output), identity.getMemory(personaName));
|
|
391
|
+
for (const fact of fresh) {
|
|
392
|
+
const written = await identity.addMemory(personaName, fact, (candidate, all) => isDuplicateFact(candidate, all));
|
|
393
|
+
if (written)
|
|
394
|
+
ctx.logger?.warn?.(`lume: 提取记忆 → ${personaName}: ${fact}`);
|
|
395
|
+
}
|
|
396
|
+
// 取名类事实同步身份档案:下拉显示档案名 + 【你是谁】段生效
|
|
397
|
+
const named = extractNaming(fresh);
|
|
398
|
+
if (named) {
|
|
399
|
+
await identity.setProfileName(personaName, named);
|
|
400
|
+
ctx.logger?.warn?.(`lume: 人设 ${personaName} 被命名为「${named}」`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
ctx.logger?.warn?.("lume: 提取失败(静默跳过)", error);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/** 蒸馏任务 Runner:素材文本 → 角色卡(契约+语料)。路由可配专用档(distillProvider/Model),默认跟随主对话。 */
|
|
408
|
+
const distillRunner = new DistillJobRunner({
|
|
409
|
+
route: () => resolveAuxRoute(distillRouteOverride, llmRoute),
|
|
410
|
+
call: (route, system, userText, maxTokens, signal) => callLlm(route, system, userText, maxTokens, signal),
|
|
411
|
+
logger: ctx.logger,
|
|
412
|
+
});
|
|
413
|
+
// 版本迁移:有本地原始素材的旧角色在后台自动重蒸馏;只替换基础契约/语料,
|
|
414
|
+
// 身份名、记忆、习得风格与 corpus pins 均留在独立表中,不参与覆盖。
|
|
415
|
+
void identityReady.then(async (store) => {
|
|
416
|
+
if (!store)
|
|
417
|
+
return;
|
|
418
|
+
for (const [personaName, oldCard] of Object.entries(store.listCustomPersonas())) {
|
|
419
|
+
if (!oldCard.distillSource || (oldCard.distillVersion ?? 0) >= DISTILL_ALGORITHM_VERSION)
|
|
420
|
+
continue;
|
|
421
|
+
try {
|
|
422
|
+
const upgraded = await runDistill({
|
|
423
|
+
route: () => resolveAuxRoute(distillRouteOverride, llmRoute),
|
|
424
|
+
call: (route, system, userText, maxTokens, signal) => callLlm(route, system, userText, maxTokens, signal),
|
|
425
|
+
logger: ctx.logger,
|
|
426
|
+
}, { text: oldCard.distillSource, hint: oldCard.distillHint });
|
|
427
|
+
await store.setCustomPersona(personaName, {
|
|
428
|
+
...oldCard,
|
|
429
|
+
displayName: oldCard.displayName,
|
|
430
|
+
description: oldCard.description,
|
|
431
|
+
promptText: upgraded.promptText,
|
|
432
|
+
corpus: upgraded.corpus,
|
|
433
|
+
distillVersion: upgraded.distillVersion,
|
|
434
|
+
distillSource: oldCard.distillSource,
|
|
435
|
+
distillHint: oldCard.distillHint,
|
|
436
|
+
});
|
|
437
|
+
ctx.logger?.warn?.(`lume: 已后台升级角色卡 ${personaName} → distill v${DISTILL_ALGORITHM_VERSION}`);
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
ctx.logger?.warn?.(`lume: 角色卡 ${personaName} 后台升级失败,保留旧卡`, error);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
});
|
|
444
|
+
// ── 会话事件:路由缓存 + 轮次缓冲 + 提取调度 + 清理 ──
|
|
445
|
+
ctx.effect(() => ctx.on("session/event", (session, event) => {
|
|
446
|
+
const sid = String(session.id);
|
|
447
|
+
const st = runtime.get(sid);
|
|
448
|
+
switch (event.type) {
|
|
449
|
+
case "request/context": {
|
|
450
|
+
// 路由缓存的真正来源:agent-loop 在路由变化时 append 的 request/context
|
|
451
|
+
// ({provider, model, contextWindow})。request/header 的载荷是 {header,
|
|
452
|
+
// reason},拿不到 provider/model——v0.3.0 一直监听错了事件,提取从未跑通。
|
|
453
|
+
const data = event.data;
|
|
454
|
+
if (typeof data?.provider === "string" && typeof data?.model === "string") {
|
|
455
|
+
llmRoute = { provider: data.provider, model: data.model };
|
|
456
|
+
ctx.logger?.warn?.(`lume: request/context 更新 llmRoute → ${llmRoute.provider}/${llmRoute.model}`);
|
|
457
|
+
}
|
|
458
|
+
else {
|
|
459
|
+
ctx.logger?.warn?.("lume: request/context 未携带 provider/model,保留 llmRoute", data);
|
|
460
|
+
}
|
|
461
|
+
break;
|
|
462
|
+
}
|
|
463
|
+
case "user/message": {
|
|
464
|
+
const text = messageText(event.data);
|
|
465
|
+
if (text) {
|
|
466
|
+
st.userText = text;
|
|
467
|
+
st.lastQuery = text;
|
|
468
|
+
st.recentTurns.push(`用户: ${text.slice(0, 300)}`);
|
|
469
|
+
if (st.recentTurns.length > 12)
|
|
470
|
+
st.recentTurns.shift();
|
|
471
|
+
}
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
case "assistant/message": {
|
|
475
|
+
const text = messageText(event.data?.message);
|
|
476
|
+
if (text) {
|
|
477
|
+
st.assistantText = text;
|
|
478
|
+
st.recentTurns.push(`助手: ${text.slice(0, 300)}`);
|
|
479
|
+
if (st.recentTurns.length > 12)
|
|
480
|
+
st.recentTurns.shift();
|
|
481
|
+
}
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
case "turn/end": {
|
|
485
|
+
st.turnIndex++;
|
|
486
|
+
// 低成本会话内纠偏:只处理明确的错误/失败信号,且要求连续轮次用户请求相同。
|
|
487
|
+
const failed = /失败|报错|错误|exception|traceback|cannot|unable|permission denied|timed out|找不到|不存在/i.test(st.assistantText);
|
|
488
|
+
const queryKey = st.userText.trim().replace(/\s+/g, " ").slice(0, 240);
|
|
489
|
+
if (failed && queryKey && queryKey === st.lastFailureQuery)
|
|
490
|
+
st.failureStreak++;
|
|
491
|
+
else if (failed && queryKey) {
|
|
492
|
+
st.lastFailureQuery = queryKey;
|
|
493
|
+
st.failureStreak = 1;
|
|
494
|
+
}
|
|
495
|
+
else if (!failed) {
|
|
496
|
+
st.failureStreak = 0;
|
|
497
|
+
st.lastFailureQuery = null;
|
|
498
|
+
st.protocolCorrection = null;
|
|
499
|
+
}
|
|
500
|
+
if (st.failureStreak >= 2)
|
|
501
|
+
st.protocolCorrection = "检测到相同请求连续失败:先定位根因并记录已排除假设,再选择不同方案;不要重复同一调用。";
|
|
502
|
+
// 风格泄漏检测挂在 turn/end(该事件已被窗口机制验证可靠;assistant/message
|
|
503
|
+
// 的投递在实测中不可靠)。切换完成后逐轮检查回复是否残留旧人设签名词,
|
|
504
|
+
// 窗口已关仍检出 → 重开窗口 + 升级播报;一轮干净回复自动解除升级。
|
|
505
|
+
if (st.prevSignatures.length > 0 && st.lastInjected !== undefined && st.assistantText) {
|
|
506
|
+
const report = detectLeak(st.assistantText, st.prevSignatures);
|
|
507
|
+
const inWindow = st.switchTurn !== null && st.turnIndex - st.switchTurn < boundaryTurns;
|
|
508
|
+
if (report.leaked && !inWindow) {
|
|
509
|
+
st.switchTurn = st.turnIndex;
|
|
510
|
+
st.leakEscalated = true;
|
|
511
|
+
ctx.logger?.warn?.(`lume: [${sid}] 检测到旧人设风格泄漏(${report.hits.map((h) => `${h.word}×${h.count}`).join("、")}),重新注入升级版切换播报`);
|
|
512
|
+
}
|
|
513
|
+
else if (!report.leaked) {
|
|
514
|
+
st.leakEscalated = false;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
scheduleExtraction(sid, st);
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
default:
|
|
521
|
+
break;
|
|
522
|
+
}
|
|
523
|
+
}), "lume: session events");
|
|
524
|
+
ctx.effect(() => ctx.on("session/disposed", (session) => {
|
|
525
|
+
const sid = String(session.id);
|
|
526
|
+
const st = runtime.get(sid);
|
|
527
|
+
const turns = [...st.recentTurns];
|
|
528
|
+
runtime.delete(sid);
|
|
529
|
+
// 反思日志:会话结束后空闲时间跑一次小模型,零用户感知 token。
|
|
530
|
+
// 历史不够长(< 4 条消息)或路由不可用时静默跳过。
|
|
531
|
+
if (reflectionEnabled && turns.length >= 4) {
|
|
532
|
+
void (async () => {
|
|
533
|
+
const store = await reflectionReady;
|
|
534
|
+
if (!store)
|
|
535
|
+
return;
|
|
536
|
+
const route = resolveAuxRoute({}, llmRoute);
|
|
537
|
+
if (!route)
|
|
538
|
+
return;
|
|
539
|
+
const prompt = buildReflectionPrompt(turns);
|
|
540
|
+
const output = await callLlm(route, prompt.system, prompt.userText, 800);
|
|
541
|
+
if (output === null)
|
|
542
|
+
return;
|
|
543
|
+
const score = parseReflectionScore(output);
|
|
544
|
+
if (!score)
|
|
545
|
+
return;
|
|
546
|
+
await store.log(sid, score);
|
|
547
|
+
ctx.logger?.warn?.(`lume: 反思日志 ${sid} context=${score.context} planning=${score.planning} verification=${score.verification} review=${score.review}「${score.note}」`);
|
|
548
|
+
})();
|
|
549
|
+
}
|
|
550
|
+
}), "lume: session disposal");
|
|
551
|
+
// ── 模型可调用工具(主写入通道)──
|
|
552
|
+
// 工具 output schema 的 const 语义要求成功值恒为 { ok: true };失败一律抛错交由框架呈现。
|
|
553
|
+
// as const 让 defineTool 从字面量推断 O,三个工具共用同一份成功形状。
|
|
554
|
+
const OK_OUTPUT_SCHEMA = {
|
|
555
|
+
type: "object",
|
|
556
|
+
additionalProperties: false,
|
|
557
|
+
properties: { ok: { type: "boolean", const: true, required: true } },
|
|
558
|
+
};
|
|
559
|
+
function dutyPersona(exec) {
|
|
560
|
+
const sid = exec?.agent?.session?.id;
|
|
561
|
+
const st = sid !== undefined ? runtime.get(String(sid)) : undefined;
|
|
562
|
+
return st?.lastInjected ?? defaultName;
|
|
563
|
+
}
|
|
564
|
+
ctx.effect(() => {
|
|
565
|
+
ctx.tools.register(defineTool({
|
|
566
|
+
name: "lume_remember",
|
|
567
|
+
description: "记住关于用户或你们关系的持久事实(偏好、习惯、背景、称呼)。仅当信息明确值得长期记住时调用;每次一条,40 字以内。不要记录工作内容、代码或项目机密。",
|
|
568
|
+
parameters: {
|
|
569
|
+
text: { type: "string", required: true, description: "要长期记住的事实,第三人称陈述句,≤40 字" },
|
|
570
|
+
},
|
|
571
|
+
output: { schema: OK_OUTPUT_SCHEMA, render: () => [{ type: "text", text: "已保存" }] },
|
|
572
|
+
execute: async (args, exec) => {
|
|
573
|
+
if (!identity)
|
|
574
|
+
throw new Error("lume identity store is unavailable");
|
|
575
|
+
const personaName = dutyPersona(exec);
|
|
576
|
+
if (!personaName)
|
|
577
|
+
throw new Error("lume_remember requires an active persona (当前没有当值人设)");
|
|
578
|
+
await identity.addMemory(personaName, String(args.text), isDuplicateFact);
|
|
579
|
+
return { ok: true };
|
|
580
|
+
},
|
|
581
|
+
}));
|
|
582
|
+
ctx.tools.register(defineTool({
|
|
583
|
+
name: "lume_update_style",
|
|
584
|
+
description: "把用户对你说话方式的新要求固化为长期风格约定(如「少用 emoji」「自称改成XX」)。仅当用户明确提出风格/语气要求时调用,每条一句话。",
|
|
585
|
+
parameters: {
|
|
586
|
+
rule: { type: "string", required: true, description: "风格约定,一句话祈使句" },
|
|
587
|
+
},
|
|
588
|
+
output: { schema: OK_OUTPUT_SCHEMA, render: () => [{ type: "text", text: "已保存" }] },
|
|
589
|
+
execute: async (args, exec) => {
|
|
590
|
+
if (!identity)
|
|
591
|
+
throw new Error("lume identity store is unavailable");
|
|
592
|
+
const personaName = dutyPersona(exec);
|
|
593
|
+
if (!personaName)
|
|
594
|
+
throw new Error("lume_update_style requires an active persona (当前没有当值人设)");
|
|
595
|
+
await identity.addStyleRule(personaName, String(args.rule), (a, b) => jaccard(a, b) >= 0.6);
|
|
596
|
+
return { ok: true };
|
|
597
|
+
},
|
|
598
|
+
}));
|
|
599
|
+
ctx.tools.register(defineTool({
|
|
600
|
+
name: "lume_create_persona",
|
|
601
|
+
description: "创建一个全新的自定义人设。仅当用户明确想新建人设时使用:先在对话中访谈收集(人设的名字、性格、说话方式、对用户的称呼),收集完整后再调用本工具保存,并告知用户保存成功。",
|
|
602
|
+
parameters: {
|
|
603
|
+
name: { type: "string", required: true, description: "人设英文键名,小写字母开头,≤32 字符(如 tsundere)" },
|
|
604
|
+
displayName: { type: "string", required: true, description: "界面显示名(如「傲娇」)" },
|
|
605
|
+
description: { type: "string", required: true, description: "一句话简介" },
|
|
606
|
+
promptText: { type: "string", required: true, description: "完整风格契约:称呼/emoji/语气词/节奏/立场,与内置契约同构" },
|
|
607
|
+
},
|
|
608
|
+
output: { schema: OK_OUTPUT_SCHEMA, render: () => [{ type: "text", text: "已保存" }] },
|
|
609
|
+
execute: async (args) => {
|
|
610
|
+
if (!identity)
|
|
611
|
+
throw new Error("lume identity store is unavailable");
|
|
612
|
+
await identity.setCustomPersona(String(args.name), {
|
|
613
|
+
displayName: String(args.displayName),
|
|
614
|
+
description: String(args.description ?? ""),
|
|
615
|
+
promptText: String(args.promptText),
|
|
616
|
+
createdAt: Date.now(),
|
|
617
|
+
});
|
|
618
|
+
return { ok: true };
|
|
619
|
+
},
|
|
620
|
+
}));
|
|
621
|
+
}, "lume: persona tools");
|
|
622
|
+
// ── 人设五段式注入 + 切换播报 ──
|
|
623
|
+
function buildSessionText(sid) {
|
|
624
|
+
if (!currentStore)
|
|
625
|
+
return "";
|
|
626
|
+
const st = runtime.get(sid);
|
|
627
|
+
const selected = currentStore.get(sid);
|
|
628
|
+
const personaName = selected ?? defaultName;
|
|
629
|
+
const previous = st.lastInjected;
|
|
630
|
+
if (previous !== undefined && previous !== personaName) {
|
|
631
|
+
// 切换窗口按「用户轮」计(turnIndex 只在 turn/end 递增):
|
|
632
|
+
// 一条回复内部的多次 prompt 构建不会消耗窗口,播报能撑满完整的 N 个用户轮。
|
|
633
|
+
st.switchTurn = st.turnIndex;
|
|
634
|
+
st.prevPersona = previous;
|
|
635
|
+
st.switchGreetingPending = true;
|
|
636
|
+
st.leakEscalated = false;
|
|
637
|
+
// 记录旧人设的签名词:窗口关闭后持续检测风格泄漏(自定义人设无签名词则跳过)
|
|
638
|
+
st.prevSignatures = previous ? (registry.resolve(previous)?.signatureWords ?? []) : [];
|
|
639
|
+
ctx.logger?.warn?.(`lume: [${sid}] 人设切换 ${String(previous)} → ${String(personaName)}(播报窗口 ${boundaryTurns} 轮)`);
|
|
640
|
+
}
|
|
641
|
+
const inWindow = st.switchTurn !== null && st.turnIndex - st.switchTurn < boundaryTurns;
|
|
642
|
+
const greeting = st.switchGreetingPending && inWindow;
|
|
643
|
+
const persona = registry.resolve(personaName);
|
|
644
|
+
const boundaryText = inWindow && st.switchTurn !== null
|
|
645
|
+
? composeBoundary({ registry, previous: st.prevPersona, current: personaName, greeting, escalated: st.leakEscalated })
|
|
646
|
+
: null;
|
|
647
|
+
// 播报改由独立的尾部 section 渲染(LUME_BOUNDARY_SECTION),人设段不再内联
|
|
648
|
+
st.activeBoundary = boundaryText;
|
|
649
|
+
const text = buildPersonaSection({
|
|
650
|
+
persona,
|
|
651
|
+
profileName: personaName ? registry.profileNameOf(personaName) : null,
|
|
652
|
+
memories: personaName ? identity?.getMemory(personaName) ?? [] : [],
|
|
653
|
+
styleRules: personaName ? identity?.getStyleRules(personaName) ?? [] : [],
|
|
654
|
+
corpusPins: personaName ? identity?.getCorpusPins(personaName) ?? [] : [],
|
|
655
|
+
query: st.lastQuery,
|
|
656
|
+
turnIndex: st.turnIndex,
|
|
657
|
+
sessionKey: sid,
|
|
658
|
+
boundaryText: null,
|
|
659
|
+
config: { sampleCount, sampleMin, memoryInject, styleInject, strategy },
|
|
660
|
+
});
|
|
661
|
+
st.lastInjected = personaName;
|
|
662
|
+
if (greeting)
|
|
663
|
+
st.switchGreetingPending = false;
|
|
664
|
+
if (st.switchTurn !== null && st.turnIndex - st.switchTurn >= boundaryTurns)
|
|
665
|
+
st.switchTurn = null; // 窗口关闭
|
|
666
|
+
return text;
|
|
667
|
+
}
|
|
668
|
+
// ── RPC 通道 ──
|
|
669
|
+
const handleEndpoint = createLumeRpcHandler({
|
|
670
|
+
get personalities() {
|
|
671
|
+
return builtins;
|
|
672
|
+
},
|
|
673
|
+
get store() {
|
|
674
|
+
return currentStore;
|
|
675
|
+
},
|
|
676
|
+
get registry() {
|
|
677
|
+
return registry;
|
|
678
|
+
},
|
|
679
|
+
get identity() {
|
|
680
|
+
return identity;
|
|
681
|
+
},
|
|
682
|
+
get distill() {
|
|
683
|
+
return distillRunner;
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
ctx.effect(() => ctx.connection.rpc.handle(LUME_CHANNEL, async (endpoint, payload) => {
|
|
687
|
+
currentStore ??= await storesReady;
|
|
688
|
+
identity ??= await identityReady;
|
|
689
|
+
const result = await handleEndpoint(endpoint, payload);
|
|
690
|
+
if (endpoint !== "list" && endpoint !== "getSessionPersona") {
|
|
691
|
+
ctx.logger?.warn?.(`lume: rpc ${endpoint} ${JSON.stringify(payload ?? {})} → ok=${result.ok}${result.ok ? "" : ` code=${result.error.code}`}`);
|
|
692
|
+
}
|
|
693
|
+
return result;
|
|
694
|
+
}, { authority: "trusted-host" }), "lume: rpc channel");
|
|
695
|
+
// ── 系统提示词段落 ──
|
|
696
|
+
ctx.effect(() => ctx.systemPrompt.section({
|
|
697
|
+
name: LUME_PERSONA_SECTION,
|
|
698
|
+
order: personaOrder,
|
|
699
|
+
text: (context) => {
|
|
700
|
+
const sid = context.agent?.session?.id ?? context.agent?.id;
|
|
701
|
+
return sid ? buildSessionText(String(sid)) : "";
|
|
702
|
+
},
|
|
703
|
+
}), "lume.persona-section()");
|
|
704
|
+
ctx.effect(() => ctx.systemPrompt.section({
|
|
705
|
+
name: LUME_BOUNDARY_SECTION,
|
|
706
|
+
order: LUME_BOUNDARY_ORDER,
|
|
707
|
+
text: (context) => {
|
|
708
|
+
const sid = context.agent?.session?.id ?? context.agent?.id;
|
|
709
|
+
// section 按 order 升序逐个求值:人设段(10000)先跑状态机,
|
|
710
|
+
// 播报段(10100)读到的 activeBoundary 必是本轮最新值。
|
|
711
|
+
return sid ? runtime.get(String(sid)).activeBoundary ?? "" : "";
|
|
712
|
+
},
|
|
713
|
+
}), "lume.boundary-section()");
|
|
714
|
+
ctx.effect(() => ctx.systemPrompt.section({
|
|
715
|
+
name: LUME_THINKING_SECTION,
|
|
716
|
+
order: LUME_THINKING_ORDER,
|
|
717
|
+
text: (context) => {
|
|
718
|
+
const sid = context.agent?.session?.id ?? context.agent?.id;
|
|
719
|
+
const st = sid ? runtime.get(String(sid)) : null;
|
|
720
|
+
const query = st?.lastQuery ?? "";
|
|
721
|
+
const task = TASK_SIGNAL_RE.test(query);
|
|
722
|
+
// 已知推理型模型具备计划能力,任务轮只保留变更、验证、归因和复核约束;
|
|
723
|
+
// 路由未知时使用完整版,避免误判造成能力退化。
|
|
724
|
+
const reasoning = typeof llmRoute?.model === "string" && REASONING_MODEL_RE.test(llmRoute.model);
|
|
725
|
+
const base = !task
|
|
726
|
+
? THINKING_COMPACT_TEXT
|
|
727
|
+
: reasoning
|
|
728
|
+
? THINKING_REASONING_TEXT
|
|
729
|
+
: THINKING_TEXT;
|
|
730
|
+
// 纠偏与反思提醒属于任务协议,跟人设无关——挂在 thinking 段
|
|
731
|
+
// 才能在「不使用人设」的纯任务会话里也生效。
|
|
732
|
+
const correction = st?.protocolCorrection;
|
|
733
|
+
const reflectionHint = reflectionStore?.getFeedback() ?? null;
|
|
734
|
+
return [base, correction, reflectionHint].filter(Boolean).join("\n\n");
|
|
735
|
+
},
|
|
736
|
+
}), "lume.thinking-section()");
|
|
737
|
+
}
|