@modusensus/dsh-mneme 0.9.2 → 0.9.4
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/README.md +11 -7
- package/docs/CONFIGURATION.md +9 -2
- package/docs/WORKFLOWS.md +70 -3
- package/lib/api.js +178 -3
- package/lib/capabilities.js +1 -1
- package/lib/client.js +315 -27
- package/lib/config.js +70 -3
- package/lib/dream.js +1 -1
- package/lib/index.js +21 -1
- package/lib/inject.js +10 -25
- package/lib/lang.js +25 -0
- package/lib/organize.js +4 -2
- package/lib/pipeline-nudge-auto.js +126 -0
- package/lib/pipeline-nudge.js +337 -28
- package/lib/pipeline-runs.js +1 -1
- package/lib/pipeline.js +4 -4
- package/lib/profile-extract.js +161 -0
- package/lib/scope.js +30 -1
- package/lib/service.js +3 -2
- package/lib/settings.js +22 -14
- package/lib/summarize.js +64 -3
- package/lib/tools.js +240 -1
- package/lib/user-settings.js +78 -0
- package/package.json +1 -1
- package/src/api.js +178 -3
- package/src/capabilities.js +1 -1
- package/src/config.js +70 -3
- package/src/dream.js +1 -1
- package/src/index.js +21 -1
- package/src/inject.js +10 -25
- package/src/lang.js +25 -0
- package/src/organize.js +4 -2
- package/src/pipeline-nudge-auto.js +126 -0
- package/src/pipeline-nudge.js +337 -28
- package/src/pipeline-runs.js +1 -1
- package/src/pipeline.js +4 -4
- package/src/profile-extract.js +161 -0
- package/src/scope.js +30 -1
- package/src/service.js +3 -2
- package/src/settings.js +22 -14
- package/src/summarize.js +64 -3
- package/src/tools.js +240 -1
- package/src/user-settings.js +78 -0
- package/test/api-nudge-route.test.js +259 -0
- package/test/api.test.js +19 -1
- package/test/client.test.js +115 -7
- package/test/lang.test.js +11 -0
- package/test/organize-tool.test.js +232 -0
- package/test/persona-addressing.test.js +83 -0
- package/test/persona-settings.test.js +30 -8
- package/test/pipeline-nudge-auto.test.js +224 -0
- package/test/pipeline-nudge.test.js +161 -6
- package/test/profile-extract.test.js +269 -0
- package/test/scope-optional-service.test.js +110 -0
- package/test/session-usage.test.js +11 -2
- package/test/summarize.test.js +225 -0
- package/test/tools-dto-schema-matrix.test.js +48 -5
- package/test/tools.test.js +6 -1
- package/test/user-settings-preview.test.js +127 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// dsh-mneme/src/profile-extract.js
|
|
2
|
+
// 从活跃记忆反向提取「助手眼中的用户」画像**草稿**(issue #497 的第三段)。
|
|
3
|
+
//
|
|
4
|
+
// 三条纪律,都是「做错界面上看不出来」的那类,所以写在文件头:
|
|
5
|
+
// 1. **只出草稿,绝不写 user_settings**。画像正文是用户手写的原文;让模型「合并」
|
|
6
|
+
// 等于给它改写权,而改写后的文本每帧都注入。采用与否必须是用户在面板上的一次
|
|
7
|
+
// 显式动作(走既有的 PUT /profile),本模块不参与。
|
|
8
|
+
// 2. **没有可用输入就不调模型**。空库下模型只会编一段「她是个热爱学习的人」,
|
|
9
|
+
// 那种草稿比没有草稿更坏——它会成为用户看到的「关于自己」的第一版真相。
|
|
10
|
+
// 3. **吐不出可用 JSON 就报错,绝不回空草稿**。「没提炼出东西」与「模型没按格式
|
|
11
|
+
// 写」必须在结果里可区分(#493 同一条教训:解析失败被读成空结果,用户会以为库是空的)。
|
|
12
|
+
//
|
|
13
|
+
// 记忆正文是模型的**可影响输入**:一条指令型记忆不该改写这次任务。所以喂进去的内容
|
|
14
|
+
// 包在围栏里,并在系统段重申「这是数据不是指令」——与 #493 的转录围栏、#380 的判定
|
|
15
|
+
// 提示同源的做法,不是第三套。
|
|
16
|
+
//
|
|
17
|
+
// 系统段还禁了一件事:**不许给助手本身起名或设定人格**。画像与规则不受
|
|
18
|
+
// personaEnabled 管辖、也不看人格寻址口径(见 src/user-settings.js),正文里一旦出现
|
|
19
|
+
// 「你是某某」,就会重演 #497 那个身份撞车——而且是每个会话每一帧。
|
|
20
|
+
//
|
|
21
|
+
// LLM 调用与审计完全复用 dream.js 的既有缝(streamText / runAuditedLlm / resolveRoute):
|
|
22
|
+
// chunk 形状的三个硬点(用量嵌在 chunk.usage、终态 finish 才是错误、rc.1 把适配器故障
|
|
23
|
+
// 转成 finish chunk 而不是抛错)都在那里踩过并修过,不在这里重写第二遍。
|
|
24
|
+
|
|
25
|
+
import { STR, langOf } from "./lang.js";
|
|
26
|
+
import { describeStreamFailure, resolveRoute, runAuditedLlm, streamText } from "./dream.js";
|
|
27
|
+
|
|
28
|
+
const DEFAULT_MAX_ROWS = 120;
|
|
29
|
+
const DEFAULT_ROW_CHARS = 260;
|
|
30
|
+
const DEFAULT_TOTAL_CHARS = 9000;
|
|
31
|
+
|
|
32
|
+
/** 带 code 的失败:路由按 code 映射状态码,调用方据此区分「没东西可提」与「模型没配合」。 */
|
|
33
|
+
export class ProfileExtractError extends Error {
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message ?? code);
|
|
36
|
+
this.name = "ProfileExtractError";
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 从模型输出里取那个 JSON 对象。先剥 markdown 围栏(模型常加),再退到「第一个 { 到
|
|
43
|
+
* 最后一个 }」的宽取——画像文本里带花括号是常态,严格 parse 失败时这一步能救回来。
|
|
44
|
+
* 取不到可用结构就返回 null,由调用方判失败;绝不返回空串冒充成功。
|
|
45
|
+
*/
|
|
46
|
+
export function parseProfileDraft(text) {
|
|
47
|
+
if (typeof text !== "string" || !text.trim()) return null;
|
|
48
|
+
const cleaned = text.replace(/```(?:json)?\s*([\s\S]*?)```/gi, "$1").trim();
|
|
49
|
+
const candidates = cleaned.startsWith("{") ? [cleaned] : [];
|
|
50
|
+
const open = cleaned.indexOf("{");
|
|
51
|
+
const close = cleaned.lastIndexOf("}");
|
|
52
|
+
if (open >= 0 && close > open) candidates.push(cleaned.slice(open, close + 1));
|
|
53
|
+
for (const candidate of candidates) {
|
|
54
|
+
let value;
|
|
55
|
+
try {
|
|
56
|
+
value = JSON.parse(candidate);
|
|
57
|
+
} catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (value && typeof value === "object" && typeof value.profile === "string" && value.profile.trim()) {
|
|
61
|
+
return value.profile.trim();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** 活跃行读数:归档与被遗忘的行默认就不在这里(store.list 的默认口径)。 */
|
|
68
|
+
function collectRows(service, maxRows, totalChars, rowChars) {
|
|
69
|
+
// 多取一行只为如实回答「还有没有」:截断必须可被调用方察觉,静默少读会让用户
|
|
70
|
+
// 以为画像已经看全了整个库。
|
|
71
|
+
const fetched = service.list({ limit: maxRows + 1, order: "importance" });
|
|
72
|
+
const rows = [];
|
|
73
|
+
let chars = 0;
|
|
74
|
+
let truncated = fetched.length > maxRows;
|
|
75
|
+
for (const row of fetched.slice(0, maxRows)) {
|
|
76
|
+
const content = String(row.content ?? "").slice(0, rowChars);
|
|
77
|
+
if (content.length < String(row.content ?? "").length) truncated = true;
|
|
78
|
+
const size = `${row.type} ${row.title} ${content}`.length;
|
|
79
|
+
if (chars + size > totalChars) {
|
|
80
|
+
truncated = true;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
chars += size;
|
|
84
|
+
rows.push({ id: row.id, type: row.type, title: row.title, content });
|
|
85
|
+
}
|
|
86
|
+
return { rows, truncated, charsUsed: chars };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {{ ctx: object, service: object, config: object, logger?: object }} deps
|
|
91
|
+
* ctx 要带 llm(宿主 LLM 句柄);settings 不参与——本模块按设计不写画像。
|
|
92
|
+
* @returns {(opts?: {maxRows?: number, totalChars?: number, rowChars?: number}) => Promise<object>}
|
|
93
|
+
*/
|
|
94
|
+
export function createProfileExtractor({ ctx, service, config, logger }) {
|
|
95
|
+
const log = logger ?? ctx?.logger;
|
|
96
|
+
const language = langOf(config);
|
|
97
|
+
|
|
98
|
+
return async function extractProfile({
|
|
99
|
+
maxRows = DEFAULT_MAX_ROWS,
|
|
100
|
+
totalChars = DEFAULT_TOTAL_CHARS,
|
|
101
|
+
rowChars = DEFAULT_ROW_CHARS
|
|
102
|
+
} = {}) {
|
|
103
|
+
const { rows, truncated, charsUsed } = collectRows(service, maxRows, totalChars, rowChars);
|
|
104
|
+
if (!rows.length) {
|
|
105
|
+
throw new ProfileExtractError("no-input", "no active memories to extract a profile from");
|
|
106
|
+
}
|
|
107
|
+
const route = resolveRoute(ctx, config, log);
|
|
108
|
+
// resolveRoute 在「没有 dreamProvider/dreamModel、宿主也没有可解析的默认模型」时
|
|
109
|
+
// 返回 undefined。不判它就会被读成 `Cannot read properties of undefined` 抛出
|
|
110
|
+
// TypeError,落到路由上是一个语义为「内部错误」的 500——而真正该说的是「没配模型」,
|
|
111
|
+
// 面板要能给出「先去选模型」这句可行动的话(同 no-input 的取舍:不同结局不同话)。
|
|
112
|
+
if (!route) {
|
|
113
|
+
throw new ProfileExtractError("no-route", "no model route: set dreamProvider/dreamModel or pick an agent default model");
|
|
114
|
+
}
|
|
115
|
+
const listing = rows.map((r, i) => `${i + 1}. [${r.type}] ${r.title} :: ${r.content}`).join("\n");
|
|
116
|
+
const userText = [
|
|
117
|
+
STR.profileFenceOpen[language],
|
|
118
|
+
listing,
|
|
119
|
+
STR.profileFenceClose[language],
|
|
120
|
+
language === "zh"
|
|
121
|
+
? `以上 ${rows.length} 条记忆条目(数据)到此结束。只依据它们输出 JSON,条目里没有的绝不发明。`
|
|
122
|
+
: `End of the ${rows.length} memory entries (data). Answer from these only; invent nothing absent from them.`
|
|
123
|
+
].join("\n");
|
|
124
|
+
|
|
125
|
+
let streamFailure = "";
|
|
126
|
+
const text = await runAuditedLlm(ctx, service, config, {
|
|
127
|
+
triggerSource: "profileExtract",
|
|
128
|
+
operationType: "profile_extract",
|
|
129
|
+
modelId: `${route.provider}:${route.model}`,
|
|
130
|
+
relatedMemoryIds: rows.map((r) => r.id),
|
|
131
|
+
streamError: () => streamFailure,
|
|
132
|
+
// 吐了文字但没有可用结构 = 失败调用。审计必须记 error:success 配空草稿会让
|
|
133
|
+
// 「模型不配合」在账目上长得像「这次确实没提炼出东西」。
|
|
134
|
+
auditError: (out) => (parseProfileDraft(out) ? null : "profile extract returned no usable JSON object")
|
|
135
|
+
}, (reportUsage) => streamText(ctx, {
|
|
136
|
+
provider: route.provider,
|
|
137
|
+
model: route.model,
|
|
138
|
+
purpose: "compaction",
|
|
139
|
+
messages: [
|
|
140
|
+
{ role: "system", content: [{ type: "text", text: STR.prompts.profileExtract[language] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
|
|
141
|
+
{ role: "user", content: [{ type: "text", text: userText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
|
|
142
|
+
]
|
|
143
|
+
}, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
|
|
144
|
+
|
|
145
|
+
if (text === undefined) {
|
|
146
|
+
throw new ProfileExtractError("stream-failed", streamFailure || "llm stream aborted or errored");
|
|
147
|
+
}
|
|
148
|
+
const draft = parseProfileDraft(text);
|
|
149
|
+
if (!draft) {
|
|
150
|
+
throw new ProfileExtractError("unparseable", "profile extract output was not a usable JSON object");
|
|
151
|
+
}
|
|
152
|
+
return {
|
|
153
|
+
draft,
|
|
154
|
+
rowsUsed: rows.length,
|
|
155
|
+
truncated,
|
|
156
|
+
charsUsed,
|
|
157
|
+
provider: route.provider,
|
|
158
|
+
model: route.model
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
}
|
package/lib/scope.js
CHANGED
|
@@ -14,6 +14,28 @@
|
|
|
14
14
|
// 只关「自动标注」,不能连读取身份一起关,否则 strictScope 硬墙被静默拆掉
|
|
15
15
|
// (fail-open)。
|
|
16
16
|
|
|
17
|
+
/**
|
|
18
|
+
* 免 inject 读取一个可选服务。cordis 4 的 ctx 是 Proxy,裸读未在 inject 声明的
|
|
19
|
+
* 属性会抛 "cannot get property ... without inject"(不是返回 undefined),所以
|
|
20
|
+
* 有 reflect 时优先走 ctx.reflect.get;旧宿主没有 reflect、ctx 是普通对象,裸读
|
|
21
|
+
* 此时安全——与 api.js:205 / index.js:583 的 webServer 同一形状。
|
|
22
|
+
*
|
|
23
|
+
* 服务缺失是合法状态(headless / 旧宿主 / 服务未激活),返回 undefined 让调用方
|
|
24
|
+
* 走既有降级路径,不告警:这不是故障。
|
|
25
|
+
* @param {object} ctx
|
|
26
|
+
* @param {string} name
|
|
27
|
+
* @returns {*}
|
|
28
|
+
*/
|
|
29
|
+
function readOptionalService(ctx, name) {
|
|
30
|
+
try {
|
|
31
|
+
if (typeof ctx?.reflect?.get === "function") return ctx.reflect.get(name);
|
|
32
|
+
return ctx?.[name];
|
|
33
|
+
} catch {
|
|
34
|
+
// 无 reflect 却又是 Proxy(或 accessor 自身抛错):按「未提供」处理。
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
17
39
|
/**
|
|
18
40
|
* @param {object} deps
|
|
19
41
|
* @param {object} [deps.ctx] 宿主上下文(可能带 workspaceRegistry;旧版 dsh 没有)
|
|
@@ -62,9 +84,16 @@ export function createScopeResolver({ ctx, config, logger } = {}) {
|
|
|
62
84
|
|
|
63
85
|
// workspace:registry 反查优先(成员资格 = id 命中 + header canonical cwd
|
|
64
86
|
// 校验过,path 已 realpath 归一),失败回退 header.cwd,再失败留 NULL。
|
|
87
|
+
//
|
|
88
|
+
// issue #487:这里曾经是裸读 ctx?.workspaceRegistry,而 workspaceRegistry 不在
|
|
89
|
+
// inject 声明里(index.js:45)。cordis 4 的 ctx 是 Proxy,裸读未注入属性会抛
|
|
90
|
+
// "cannot get property ... without inject" 而不返回 undefined——于是这层反查
|
|
91
|
+
// 从 v0.8.0 起一直抛错走降级,canonical 归一与成员资格校验静默失效,只剩
|
|
92
|
+
// header.cwd。改用免 inject 读取(同 api.js:205 / index.js:583 的 webServer
|
|
93
|
+
// 先例);不加进 inject 的理由见 index.js:39-44(v0.7.23 的 apply 阶段崩溃)。
|
|
65
94
|
let workspaceScope = null;
|
|
66
95
|
try {
|
|
67
|
-
const list = ctx
|
|
96
|
+
const list = readOptionalService(ctx, "workspaceRegistry")?.list?.();
|
|
68
97
|
const sessionId = session?.id;
|
|
69
98
|
if (Array.isArray(list) && sessionId != null) {
|
|
70
99
|
const hit = list.find(
|
package/lib/service.js
CHANGED
|
@@ -2568,8 +2568,9 @@ export function createService({ store, mirror, config, onWrite, logger, document
|
|
|
2568
2568
|
saveWithDedupe,
|
|
2569
2569
|
registerDocument,
|
|
2570
2570
|
// agent 主动整理接口(#231):内聚块在 src/organize.js,这里只做依赖注入 +
|
|
2571
|
-
// barrel
|
|
2572
|
-
//
|
|
2571
|
+
// barrel 出口。本体不带配置键(第一批口径),暴露闸住在工具层(#231 第二批的
|
|
2572
|
+
// `organizeEnabled` → `tools.js` 的 `memory_organize`):service 侧不认识开关,
|
|
2573
|
+
// 所以直连 service 的调用方(测试、内部管线)不受面板那格影响。
|
|
2573
2574
|
organize,
|
|
2574
2575
|
recoverMirror,
|
|
2575
2576
|
getMirrorHealth,
|
package/lib/settings.js
CHANGED
|
@@ -185,6 +185,10 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
185
185
|
// 在慢/轻量模型上是多余往返,面板可关(默认关=行为不变)。
|
|
186
186
|
"disableMemorySearch",
|
|
187
187
|
"disableMemoryArchive",
|
|
188
|
+
// Issue #231 第二批:agent 主动整理(memory_organize 工具)的暴露闸。与上面两个
|
|
189
|
+
// 反向——那两个藏默认在场的工具,本键开默认不在场的能力(默认关=工具根本不存在,
|
|
190
|
+
// 行为逐字节不变)。面板可启停=线上回滚开关。
|
|
191
|
+
"organizeEnabled",
|
|
188
192
|
// 嵌套对象开关:config.js 里是 memoryQualityFilter / llmAudit 对象的 enabled
|
|
189
193
|
// 子字段。kv 按点号键平铺存("memoryQualityFilter.enabled": false),index.js
|
|
190
194
|
// 合并时展开回嵌套对象,api.js 的 effective 从对象子字段取值。
|
|
@@ -209,7 +213,7 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
209
213
|
// 点号键平铺存、合并时展开回 distillRecords 对象(同 llmAudit.enabled)。
|
|
210
214
|
// retentionDays 是 int,走下面的 INT_RANGES 表,不进开关白名单。
|
|
211
215
|
"distillRecords.enabled",
|
|
212
|
-
// v0.9.0:M.O.(Modus Operandi
|
|
216
|
+
// v0.9.0:M.O.(Modus Operandi,beta)客户端视图开关。客户端读 /features
|
|
213
217
|
// 的 effective
|
|
214
218
|
// 决定是否注册座位(会话头图标 / 浮层 / 右栏 / 对话行卡片),服务端只负责把
|
|
215
219
|
// 这个键稳定暴露出来——键若不在 effective 里,客户端静默少键、一个座位都不注册。
|
|
@@ -219,13 +223,23 @@ const FEATURE_FLAG_BOOLEANS = [
|
|
|
219
223
|
// 与上面那个键分开:那个管「看图的座位」,这个管「往账本里写」。默认关(beta、
|
|
220
224
|
// opt-in),顶层扁平键,不需要 NESTED_FLAG_PATHS 条目。
|
|
221
225
|
"actionRunEnabled",
|
|
226
|
+
// v0.9.0 第三批:M.O. 自动沉淀(会话跑完一轮 → 挖这个项目自己的会话日志 → 落提议)。
|
|
227
|
+
// 与前两个分开:这个管「从日志里长出候选」,那个管记账、另一个管看图。默认关
|
|
228
|
+
// (beta、opt-in);顶层扁平键,不需要 NESTED_FLAG_PATHS 条目。进 LIGHT_MODE_OFF。
|
|
229
|
+
"actionNudgeEnabled",
|
|
222
230
|
// 人格定义页(beta):注入器渲染人格段的顶层总闸(双闸门的 config 半边;对象内
|
|
223
231
|
// 的 persona.enabled 是 per-agent 半边,存 user_settings 的 "persona" 键,见
|
|
224
232
|
// getPersona/setPersona)。默认关(beta、opt-in,老用户行为逐字节不变);不进
|
|
225
233
|
// LIGHT_MODE_OFF——人格段是用户自写的一小段轻量常驻文本,不引入任何重型后台
|
|
226
234
|
// 路径,轻量档用户显式勾选仍然赢(同 actionPipelineEnabled 的定性)。顶层扁平
|
|
227
235
|
// 键,不需要 NESTED_FLAG_PATHS 条目。
|
|
228
|
-
"personaEnabled"
|
|
236
|
+
"personaEnabled",
|
|
237
|
+
// 人格寻址口径(上一条打开时才有意义):开 = 身份没建卡/卡被关时不注入人格,
|
|
238
|
+
// default 只兜解析不到身份的会话。默认关(历史行为,升级零变化)。
|
|
239
|
+
"personaStrictAddressing",
|
|
240
|
+
// Issue #497:画像提取草稿(POST /profile/extract)。默认关——每次调用一次 LLM 且
|
|
241
|
+
// 读上百行记忆;产物只回草稿,绝不写 user_settings。进 LIGHT_MODE_OFF(预设关)。
|
|
242
|
+
"profileExtractEnabled"
|
|
229
243
|
];
|
|
230
244
|
// 整数开关的闭区间,与 config.js 里 z.natural().min().max() 对齐。
|
|
231
245
|
const FEATURE_FLAG_INT_RANGES = {
|
|
@@ -243,6 +257,9 @@ const FEATURE_FLAG_INT_RANGES = {
|
|
|
243
257
|
// Issue #239:蒸馏零 LLM 预判(窗口最小字符数)与每会话 run 预算(0 = 零行为变化)。
|
|
244
258
|
summarizeMinWindowChars: [0, 100000],
|
|
245
259
|
summarizeMaxRunsPerSession: [0, 1000],
|
|
260
|
+
// Issue #493:连续解析失败几次后放弃当前窗口(0 = 关)。与 config.js 的
|
|
261
|
+
// z.natural().min(0).max(10) 成对。
|
|
262
|
+
summarizeParseFailureMaxRetries: [0, 10],
|
|
246
263
|
// Issue #239 第 4 项:高峰顺延上限(分钟,0 = 不设上限)。
|
|
247
264
|
summarizePeakMaxDeferMinutes: [0, 1440],
|
|
248
265
|
// Issue #239 第 4 项镜像到巩固:同一口径(分钟,0 = 不设上限)。
|
|
@@ -485,8 +502,9 @@ export function createSettings(db) {
|
|
|
485
502
|
/**
|
|
486
503
|
* Agent persona (name / personality / replyStyle + enabled gate), stored as
|
|
487
504
|
* one JSON key. v1.2 多卡:存储形 { default, byAgent },旧 v1 单对象读作
|
|
488
|
-
* default 兼容。getPersona
|
|
489
|
-
*
|
|
505
|
+
* default 兼容。getPersona 返回全量多卡形(面板与注入寻址都用它)。
|
|
506
|
+
* 寻址与兜底口径不在这里——住 src/user-settings.js 的 resolvePersonaCard,
|
|
507
|
+
* 注入与面板预览共用那一份;这里若再留一个 getPersonaFor,就是两张皮。
|
|
490
508
|
*/
|
|
491
509
|
getPersona() {
|
|
492
510
|
return parsePersonaStore(getSetting("persona") ?? "{}");
|
|
@@ -503,16 +521,6 @@ export function createSettings(db) {
|
|
|
503
521
|
current.default = parsePersona(JSON.stringify(input));
|
|
504
522
|
setSetting("persona", serializePersonaStore(current));
|
|
505
523
|
},
|
|
506
|
-
/** Per-agent lookup for the injector: id card → default fallback. */
|
|
507
|
-
getPersonaFor(agentId) {
|
|
508
|
-
const store = parsePersonaStore(getSetting("persona") ?? "{}");
|
|
509
|
-
const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
|
|
510
|
-
if (id) {
|
|
511
|
-
const card = store.byAgent[id];
|
|
512
|
-
if (card && card.enabled === true) return card;
|
|
513
|
-
}
|
|
514
|
-
return store.default;
|
|
515
|
-
},
|
|
516
524
|
/** Replace one byAgent card wholesale (panel save); default 不可经此改。 */
|
|
517
525
|
setPersonaFor(agentId, card) {
|
|
518
526
|
const id = typeof agentId === "string" && agentId.trim() ? agentId.trim() : "";
|
package/lib/summarize.js
CHANGED
|
@@ -404,12 +404,29 @@ function collectMessages(session, maxChars = 8000, language = "zh", afterSeq, th
|
|
|
404
404
|
if (seq === undefined) return max;
|
|
405
405
|
return max === undefined ? seq : Math.max(max, seq);
|
|
406
406
|
}, undefined);
|
|
407
|
+
const transcriptBody = trimTranscript(lines.join("\n"), maxChars);
|
|
407
408
|
return {
|
|
408
409
|
messages: lines.length
|
|
409
|
-
? [createUserMessage({
|
|
410
|
+
? [createUserMessage({
|
|
411
|
+
content: [{
|
|
412
|
+
type: "text",
|
|
413
|
+
// Issue #493:转录裸放 user 位时,模型把它当成「轮到我说了」接着对话
|
|
414
|
+
// 往下写,输出里连 `[` 都没有(解析器在括号早退处直接判失败)。围栏 +
|
|
415
|
+
// 角色重申把「这段文本是被提炼的素材」写进**它所在的那条消息**,而不是
|
|
416
|
+
// 只写在 system 里——实测同段转录只加 system 约束仍 0/2 出 JSON。
|
|
417
|
+
// 围栏是确定性的:重试收到的转录与首次逐字节相同(既有回归用例依赖)。
|
|
418
|
+
text: `${STR.distillRoleReminder[language]}\n\n${STR.transcriptFenceOpen[language]}\n${transcriptBody}\n${STR.transcriptFenceClose[language]}`
|
|
419
|
+
}],
|
|
420
|
+
source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" }
|
|
421
|
+
})]
|
|
410
422
|
: [],
|
|
411
423
|
hasEvents: events.length > 0,
|
|
412
|
-
lastSeq
|
|
424
|
+
lastSeq,
|
|
425
|
+
// Issue #493:只数**转录正文**的字符数。minWindowChars 闸门与审计里的
|
|
426
|
+
// distillChars 量的都是「这一窗有多少可提炼内容」,不是「我们额外包了多少
|
|
427
|
+
// 提示词」——若把围栏与角色句算进去,一条「短」也会凭空越过任何阈值,
|
|
428
|
+
// 闸门随之失效(实测 summarizeMinWindowChars=10 会被 4 字正文 + 包裹放行)。
|
|
429
|
+
transcriptChars: lines.length ? transcriptBody.length : 0
|
|
413
430
|
};
|
|
414
431
|
}
|
|
415
432
|
|
|
@@ -534,6 +551,10 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
534
551
|
const deferredRuns = new Map();
|
|
535
552
|
// Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。
|
|
536
553
|
const lastDistilledSeq = new Map();
|
|
554
|
+
// Issue #493:per-session 连续解析失败计数(失败护栏用)。同一窗口在确定性
|
|
555
|
+
// 失败下会被每个 turn/end 重试一次、且窗口只会越攒越大——没有出口。成功即
|
|
556
|
+
// 清零;dispose 一并清理(与 runsUsed / lastRunAt 同生命周期)。
|
|
557
|
+
const parseFailureStreak = new Map();
|
|
537
558
|
let disposed = false;
|
|
538
559
|
|
|
539
560
|
/** 写一条 autoSummarize 的 LLM 审计行(审计失败绝不阻塞蒸馏本身)。 */
|
|
@@ -701,7 +722,12 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
701
722
|
// summarize_compress 成功行的 metadata 里窗口字符数为 0 条),验收 4「默认配置
|
|
702
723
|
// 下调用量可量化下降」因此无法对账。本改动只加计量,不动任何跳过决策——
|
|
703
724
|
// summarizeMinWindowChars 仍为 0 时行为逐字节不变。
|
|
704
|
-
|
|
725
|
+
//
|
|
726
|
+
// Issue #493:口径改为只数转录正文(collected.transcriptChars),不再把
|
|
727
|
+
// user 消息整段长度当窗口大小——#493 给转录加了围栏与角色句,若继续按
|
|
728
|
+
// 「消息总字符数」算,一条 4 字的「短」也会被包裹撑过任何阈值,闸门等于
|
|
729
|
+
// 被自己的提示词旁路掉。
|
|
730
|
+
const distillChars = collected.transcriptChars ?? messages.reduce(
|
|
705
731
|
(total, message) => total + (Array.isArray(message.content) ? message.content : [])
|
|
706
732
|
.reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0),
|
|
707
733
|
0
|
|
@@ -869,12 +895,46 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
869
895
|
const parsedResult = parseSummaryJsonResult(text || assembledText);
|
|
870
896
|
// 解析失败不推进 seq 游标,下一次 turn/end 仍会重试同一窗口。
|
|
871
897
|
if (!parsedResult.ok) {
|
|
898
|
+
// Issue #493:失败也要留证据。原先 metadata 只在成功路径赋值,失败行恒为
|
|
899
|
+
// null —— 事后无法回答「模型到底吐了什么形状」。这里只加计量、不做任何
|
|
900
|
+
// 跳过决策(raw_head 截断到 300 字符,避免把审计表撑大)。
|
|
901
|
+
const raw = text || assembledText || "";
|
|
902
|
+
const streak = (parseFailureStreak.get(session.id) ?? 0) + 1;
|
|
903
|
+
parseFailureStreak.set(session.id, streak);
|
|
904
|
+
// Issue #493 失败护栏:同一窗口连续失败到阈值后强制消费游标跳过它。
|
|
905
|
+
// 确定性失败(finish_reason=stop、同文同温)重试收益为 0,而窗口只会随
|
|
906
|
+
// 新事件继续变大——没有出口时它会一直失败下去。默认 0 = 关闭,行为与
|
|
907
|
+
// 历史版本逐字节一致(本仓新行为一律 opt-in,见 AGENTS.md)。
|
|
908
|
+
const maxParseRetries = config.summarizeParseFailureMaxRetries ?? 0;
|
|
909
|
+
// nextSeq 必须是有限数才算真的「跳过了窗口」:没有可验证的 seq 时
|
|
910
|
+
// commitCursor 是空操作(persistCursor 早退、内存游标也不写),此时若
|
|
911
|
+
// 仍按跳过记账并清零 streak,审计会宣称「已放弃」而窗口下一轮照旧重试
|
|
912
|
+
// —— 那是最坏的一种假象:用户以为止血了,实际还在烧。
|
|
913
|
+
const skipWindow = maxParseRetries > 0 && streak >= maxParseRetries && Number.isFinite(nextSeq);
|
|
914
|
+
if (skipWindow) {
|
|
915
|
+
// 先提交游标再复位计数:窗口已放弃,下一窗从全新状态起算。
|
|
916
|
+
commitCursor(session.id, nextSeq);
|
|
917
|
+
parseFailureStreak.delete(session.id);
|
|
918
|
+
}
|
|
872
919
|
if (audit) {
|
|
873
920
|
audit.status = "error";
|
|
874
921
|
audit.errorMessage = "invalid summary JSON";
|
|
922
|
+
audit.metadata = {
|
|
923
|
+
distillChars,
|
|
924
|
+
parse_failure: true,
|
|
925
|
+
raw_len: raw.length,
|
|
926
|
+
raw_head: raw.slice(0, 300),
|
|
927
|
+
// 有没有出现过 `[` 是区分「模型没按格式写」与「格式对但内容救不回来」
|
|
928
|
+
// 的唯一低成本判据,也正是 #493 现场那 20 条失败的关键特征。
|
|
929
|
+
raw_has_bracket: raw.includes("["),
|
|
930
|
+
streak,
|
|
931
|
+
...(maxParseRetries > 0 ? { maxRetries: maxParseRetries } : {}),
|
|
932
|
+
...(skipWindow ? { skipped: "parse-failure-streak" } : {})
|
|
933
|
+
};
|
|
875
934
|
}
|
|
876
935
|
return;
|
|
877
936
|
}
|
|
937
|
+
parseFailureStreak.delete(session.id);
|
|
878
938
|
const parsed = parsedResult.entries;
|
|
879
939
|
// Issue #127 条数上限:0 = 不限(现状)。被截断的条数与去重命中数一并进
|
|
880
940
|
// 审计 metadata——此前产出条数完全由模型输出决定,用户无从自查。
|
|
@@ -1064,6 +1124,7 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
1064
1124
|
inFlight.clear();
|
|
1065
1125
|
lastRunAt.clear();
|
|
1066
1126
|
lastDistilledSeq.clear();
|
|
1127
|
+
parseFailureStreak.clear();
|
|
1067
1128
|
}
|
|
1068
1129
|
};
|
|
1069
1130
|
}
|