Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions dsh-mneme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,23 @@

## [Unreleased]

- **情感记忆此前只写在库里、模型看不见:单次场合的情绪(occasion 档)补上注入出口,`affectiveInjectEnabled` 默认关**。`sleep` 的 affective 相位从 0.13 就在跑——它扫情绪归因、往 `entity_relations` 写 `affective_context` 边(`emotion` / `attribution` / `sessionCount` / `evidence`),面板的「情感」页也能按「谁·情绪·对象·场合/倾向」聚合展示。但**边不是记忆**:`INJECT_TYPES` 是 memory 类型白名单,边不在里面,所以 occasion 档一百条也进不了模型视野。跨够 `sleepAffectiveTraitMinSessions`(默认 3)个独立会话升上来的 trait 会铸成 `type: "pattern"`,那是真进注入的——可模型读到的是一条普通 pattern,不知道它是情绪归因。于是最典型的「她那天很焦虑」写进了库、面板看得见,问模型的时候它答不出来。
- **复用 #641 的注释通道,不新开块**:`src/inject.js` 渲染每行时已经会拼 `m.conflictNote`(矛盾对标注),情绪线索挂在同一位置、另起字段 `affectiveNote`,顺序是「正文 → 情绪 → 冲突」——情绪说明背景,矛盾标注是决策指令必须贴最后。这样不新增 `ctx.systemPrompt.context`(那会改变整个注入块的顺序与预算分配),也不动 schema。
- **挂哪条边、挂不挂由 service 决定**:`src/store.js` 新增 `affectiveRelationsByMemories(memoryIds, perMemory = 1)`,一次 SQL 按候选记忆 id 批量取边(`WHERE relation_type = 'affective_context' AND memory_id IN (…)`,两端 LEFT JOIN entities 取人名与对象名),按 `created_at` 倒序、每条记忆最多一条。`src/service.js` 在 `injectCandidates` 的 superseded 过滤之后、pinnedStats 之前挂注释,**trait 档跳过**(`attribution === "trait"` 或 `promotedToTrait === true`)——它已经以 pattern 身份进来了,再挂一遍等于同一件事说两次。名字为空的边(实体被删)直接跳过,不渲染「unknown 对 unknown」。
- **注释吃注入块预算,所以限长**:`MAX_BLOCK = maxContent + 600`,一条超长注释会把同批候选挤成降级标题行。`AFFECTIVE_NOTE_MAX = 160` 字,超了整条不挂(不是截断——截一半的情绪说明比没有更误导),`why` 单独截 60 字。
- **开关是子项,不占新座位**:`affectiveInjectEnabled` 默认 false,父开关是 `sleepAffectiveEnabled`(后端 `INJECT_CHILD_FLAGS`,面板 `FEATURE_CHILDREN`,两表同源由 `test/inject-parent-gate.test.js` 锁)。父关时子项一并不挂但持久值保留,父开关缺省(未写过)时只按子项自己判。进 `LIGHT_MODE_OFF`:低资源档本来就连 `sleepAffectiveEnabled` 一起关,情绪边都不会产出,留着这个注入开关没有意义。
- **测试**:`test/affective-inject.test.js` 8 条(默认关不改注入、开着带全四项事实且单次场合不写次数、trait 档不重复挂、父关一并不挂与父缺省只按子项判、实体被删不挂、超长整条放弃但记忆仍在块里、同一记忆多条边只挂最新一条、配置默认值与轻量档)。变异自证五轮各自变红:删掉 `inject.js` 的拼接、删掉 trait 跳过、把父闸换成裸读子项、把「任一侧名字为空」写成「两侧都空」、删掉长度闸。

- **描述进得了转录、留不进记忆:蒸馏提示词补一句「描述行是事实来源」,让图里写了什么真的落库、真的能被检索到**。上一版的描述档只把 `描述:…` 并进转录,然后把转录交给蒸馏——但蒸馏提示词从没提过「描述」这回事,模型不知道那一行值不值得记。实测最坏的情况是把描述当排版噪音丢掉,蒸馏出来只剩「用户发了张截图」,而那张图里唯一能复原的信息(报错原文、界面上的数字)在落库前就没了,等于描述档白跑一次视觉调用。
- **只在真有描述时加**:`describeStats.described > 0` 才把这句拼进 system prompt。没有描述却说「描述行是事实来源」,等于让模型去找一行不存在的文本;默认关着、或描述全失败时,提示词与 #611 逐字节一致。
- **前缀字面量从生成端导入**,不在提示词里手抄一遍 `[图: ` / `描述:`。这两处一旦漂移,症状是「提示词让模型去找『描述:』,而转录里写的是别的」,不报错、不崩、只是安静地不生效——最难查的一类。
- **不扩大自由度**:这句话只陈述「描述里的内容属于事实来源,与用户原话同等对待」,仍然受既有的立场纪律约束(不记对用户本人的评价);它让模型可以抄图里的关键文字进 content,而不是把「发了张图」当成记忆的全部。
- **测试**:`test/image-description.test.js` 再增 3 条(有描述才加且前缀同源、描述失败不加、默认关逐字节不变)。变异自证两轮:把提示恒置空(1 红)、把 `> 0` 放松成 `>= 0` 让失败态也加(2 红)。

- **图像描述档此前整档静默:开了开关、配了模型、一次都没跑成,审计里与「这个窗口没有图」长得一样——现在四种结局都留痕**。`describeImages` 的 `catch {}` 吞掉所有异常,provider/model 留空整档不跑也不报,宿主 `textOnly` 降级同样无声;审计 metadata 里只有一个 `imageCount`(图块数)。于是「我明明开了」和「库里从来没进过图」在运维上无法区分(实测本库 273MB 里含 `[图` 的记忆 6 条,全是关于这个功能本身的说明文字,没有一条来自真实图片)。
- **计数走出参,不认开关态**:`describeImages(..., options.stats)` 多一个可选出参(口径同 `injectCandidates` 的 `pinnedStats`:不传时行为逐字节等于改前),逐张记 `attempted`、成功记 `described`、空描述与抛异常各记 `failed` 并留首条错误原文(截 120 字)。`src/summarize.js` 加的 `describeAuditPatch(stats)` 是唯一出口,三处写审计的分支(窗口过小 / 解析失败 / 成功)共用,避免抄三遍漂移。
- **四条结局**:跑成了写 `imageDescribed` + `imageDescribeFailed: 0`(失败数显式给 0,缺键会被读成「没跑」,那正是要避免的模糊);试了失败写失败数与 `imageDescribeError`;开着没配路由写 `imageDescribeSkipped: "no route"`;模型明确不能看图写 `"model-cannot-see-images"`。**默认关着时一个 `imageDescribe*` 键都不写**,老档位审计行逐字节不变。
- **测试**:`test/image-description.test.js` 增 5 条(跑成 / 失败带原文 / 没路由 / 看不懂图 / 关闭态不写任何键)。变异自证三轮:去掉成功分支的 patch(4 红)、去掉 no route 的记账(1 红)、去掉 `attempted` 计数(2 红)。
## [0.14.0-beta.3] - 2026-11-12

- **情绪页白屏(beta.1 起的回归,beta.2 仍在):点开「情感」页整块面板变空白,且此后每次打开都空白**。根因是 `lib/client.js` 的 `AffectivePanel` 里一行 `formatRelativeTime(it.lastSeen)` 漏传第二个参数 `t`——该函数内部要调 `t("memory.time.now")`,缺参就抛 `t is not a function`,而这行跑在 render 里,React 整棵树卸载,日志里什么都不留。引入提交是 #619(`244da66d`),`v0.14.0-beta.1` 与 `v0.14.0-beta.2` 都带它;只有库里存在带 `lastSeen` 的情绪边时才触发,所以不是每个用户都会遇到。修法是补上 `, t`,并加一条静态断言:扫出所有 `formatRelativeTime(` 调用点,逐个做括号配对、数顶层逗号,实参不足 2 个就红。全库只有这一处漏传(`formatDate`/`formatDateShort` 本来只收一个参数,扫描器报的几处是误报)。变异自证:撤掉修复后断言变红并直接指出 `:8168 (argc=1)`。
Expand Down
3 changes: 2 additions & 1 deletion dsh-mneme/docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,7 @@
| `sleepAffectiveEnabled` **light** | `false` | 情感归因相位(#619):情境情绪写成 `affective_context` 关系,跨多次独立会话的稳定倾向才铸成 pattern | opt-in;`sleepModeEnabled` 的子相,父关着不跑;要调模型且依赖实体抽取 |
| `sleepAffectiveTraitMinSessions` | `3` | 判「稳定倾向」所需的**独立会话**数(2–10) | 下界 2 是故意的:N=1 就是单次场合,正是必须禁止升特质的那个情形 |
| `sleepAffectiveMaxRelationsPerRun` | `10` | 单轮最多写多少条 `affective_context` 关系(0–50) | 同 `sleepMaxPatternPerRun` 的角色;0 = 一条也不写 |
| `affectiveInjectEnabled` **light** | `false` | 注入侧情绪注释(#619 补链):把 `attribution=occasion` 的情绪边作为一行注释挂到对应记忆下,让模型看见「她那天为什么那样」 | 默认关;`sleepAffectiveEnabled` 的子项——父关则无情绪边可挂;只挂 occasion,trait 已铸成 pattern 自己进注入;注释限 160 字(吃注入块预算) |
| `sleepProvider` / `sleepModel` | 空 | sleep 专用路由 | 空 = 用巩固路由 / 当前模型 |
| `sleepReasoningEffort` | 未配置 | 同 `dreamReasoningEffort` | — |
| `sleepMaxTokens` | `8192` | 冲突/模式阶段输出预算(#257) | 原硬编码 2048 对 `full` 档必然截断 |
Expand Down Expand Up @@ -390,7 +391,7 @@
`lightMode: true`(或面板 `panel_mode=light`)时,以下键被预设强制为 `false`——都是
重型路径;核心循环(autoInject、autoSummarize、hotMemory*、质量过滤、关键词检索)不动:

`entityExtractionEnabled` · `autoDream` · `sleepModeEnabled` · `sleepAffectiveEnabled`(#619) · `rerankEnabled` ·
`entityExtractionEnabled` · `autoDream` · `sleepModeEnabled` · `sleepAffectiveEnabled`(#619) · `affectiveInjectEnabled` · `rerankEnabled` ·
`autoReindexOnBoot` · `hybridInject` · `injectGuidanceEnabled` · `searchSemanticDedup` ·
`selectiveInjectEnabled` · `bm25SearchEnabled` · `entityRecallEnabled` ·
`graphAnchoringEnabled` · `graphWeightEnabled` · `graphInjectHint` · `graphPassiveConfirm` ·
Expand Down
1 change: 1 addition & 0 deletions dsh-mneme/docs/SLEEP.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ flowchart TD
| `sleepPatternLookbackDays` | `30` | 模式发现回溯的时间窗口(天) |
| `sleepMaxPatternPerRun` | `3` | 单次运行最多创建的模式数量上限 |
| `sleepAffectiveEnabled` | `false` | 情感归因相位(#619,opt-in):情境情绪写成 `affective_context` 关系,跨多次独立会话的稳定倾向才铸成 pattern。是 `sleepModeEnabled` 的子相,父开关关着整个 sleep 不跑 |
| `affectiveInjectEnabled` | `false` | 注入侧情绪注释(#619 补链):单次场合的情绪边挂成记忆行下的一行注释。`sleepAffectiveEnabled` 的子项,父关则无从挂起 |
| `sleepAffectiveTraitMinSessions` | `3` | 判「稳定倾向」所需的**独立会话**数(2–10)。下界 2 是故意的:N=1 就是单次场合,正是必须禁止升成特质的那个情形 |
| `sleepAffectiveMaxRelationsPerRun` | `10` | 单轮最多写多少条 `affective_context` 关系(0–50,0 = 一条不写) |
| `sleepProvider` | `''` | 睡眠模式专用 LLM Provider(留空则复用默认) |
Expand Down
9 changes: 8 additions & 1 deletion dsh-mneme/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,8 @@ window.__ModuleLoader__.load({
// #619:情感归因是 sleep 的子相——父开关关着它就一定不跑,所以紧挨着摆。
"memory.features.sleepAffectiveEnabled": "情感归因",
"memory.features.sleepAffectiveEnabled.hint": "整理时记下「谁在哪件事上什么心情」,反复出现才认。",
"memory.features.affectiveInjectEnabled": "情绪带进对话",
"memory.features.affectiveInjectEnabled.hint": "把记下的单次情绪附在相关记忆后面,模型才知道「那天为什么那样」。",
// #427 建议 5:hybridInject / selectiveInjectEnabled / adaptiveThresholdEnabled
// 是同一件事「带什么回来」的三档,相邻摆放并在 hint 里点明,不再摊成
// 三个不相干的并列开关。
Expand Down Expand Up @@ -2142,6 +2144,8 @@ window.__ModuleLoader__.load({
// the parent is off, so it sits next to it.
"memory.features.sleepAffectiveEnabled": "Affect attribution",
"memory.features.sleepAffectiveEnabled.hint": "Records who feels what about which matter, across repeated occasions.",
"memory.features.affectiveInjectEnabled": "Bring affect into chat",
"memory.features.affectiveInjectEnabled.hint": "Attaches a recorded one-off emotion to the related memory, so the model knows why that day felt like that.",
// #427 suggestion 5: hybridInject / selectiveInjectEnabled /
// adaptiveThresholdEnabled are three dials of one question — what comes
// back. Adjacent placement plus a hint that names the other two.
Expand Down Expand Up @@ -4644,7 +4648,10 @@ window.__ModuleLoader__.load({
heatEnabled: ["injectHeatEnabled"],
// 地志的两个子开关贴着总闸(#607 后续):都是「地志开着才有意义」的
// 口径开关——读路径自动重跑 T0、面板按钮能否发起 T1。
chorographyEnabled: ["chorographyAutoRefresh", "chorographyT1FromPanel"]
chorographyEnabled: ["chorographyAutoRefresh", "chorographyT1FromPanel"],
// #619 补链:情绪注释是情感归因的下游——写下的情绪要能进模型视野,否则
// 面板上看得见、对话里看不见。贴着父开关摆,父关时这一行标注不生效。
sleepAffectiveEnabled: ["affectiveInjectEnabled"]
};
// Issue #427 建议 1:折叠线挪到「新手之外」。group.basic 是首屏层,只列新
// 用户敢动的总开关;group.scope 也在首屏——它回答新用户的第二个必答题
Expand Down
17 changes: 16 additions & 1 deletion dsh-mneme/lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -764,6 +764,15 @@ export const Config = z.object({
// sleepMaxPatternPerRun for the relation channel: a runaway model that
// "finds" an emotion in every memory must be capped, not trusted.
sleepAffectiveMaxRelationsPerRun: z.natural().min(0).max(50).default(10),
// #619 补链(注入侧):把**单次场合**(attribution=occasion)的情绪边作为一行
// 注释挂到它所属的那条记忆下,让模型看得见「她那天为什么那样」,而不是只在
// 面板里留着。trait 档不走这条——它已被铸成 type=pattern 的记忆,本来就会
// 自己进注入,再挂一遍是同一件事说两次。
//
// 默认关:它与 sleepAffectiveEnabled 是父子关系(父关则无情绪边可挂),父开
// 子留关时行为与加这个开关之前逐字节一致。违反「不加开关就默认生效」的直觉
// 是有意的——这条注释每轮都会重复出现,未经用户同意不该占注入预算。
affectiveInjectEnabled: z.boolean().default(false),
// Optional LLM route override for sleep's bulk passes (empty = use dream
// route / agent default model).
sleepProvider: z.string().default("").description("sleep 深维护专用模型服务商,留空用巩固模型或当前模型。"),
Expand Down Expand Up @@ -1238,6 +1247,8 @@ const LIGHT_MODE_OFF = [
// #619:情感归因相位要调模型、且依赖实体抽取产出(抽取关掉就没有实体可挂边),
// 与低资源档双重互斥。默认本就是关,这里只是轻量预设再确认一次。
"sleepAffectiveEnabled",
// 注入侧的情绪注释同理:轻量档不开情感线,注释自然无从挂起。
"affectiveInjectEnabled",
"rerankEnabled",
"autoReindexOnBoot",
"hybridInject",
Expand Down Expand Up @@ -1349,7 +1360,11 @@ export function applyLightModePreset(cfg) {
* 是扁平键,不受这条限制。
*/
export const INJECT_CHILD_FLAGS = Object.freeze({
autoInject: Object.freeze(["injectGuidanceEnabled", "continuityRescueEnabled"])
autoInject: Object.freeze(["injectGuidanceEnabled", "continuityRescueEnabled"]),
// #619 补链(注入侧):情绪注释是「情感归因」的下游——父关掉(不再产生新情绪
// 边)之后,库里遗留的边不该继续往注入里说话。父关 = 注释一并不挂,子项持久
// 值原样保留,与 autoInject 那组同一条纪律。
sleepAffectiveEnabled: Object.freeze(["affectiveInjectEnabled"])
});

/** 子开关的运行时生效值:父开关显式关(false)时恒不生效。 */
Expand Down
36 changes: 31 additions & 5 deletions dsh-mneme/lib/image-describe.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,16 +80,29 @@ function trimDescription(text) {
* @param {{provider: string, model: string}} route 描述专用路由
* @param {object} config 由 langOf 取提示词语言
* @param {Array} blocks image 块数组(通常是同一个 part 的)
* @param {{signal?: AbortSignal}} [options]
* @param {{signal?: AbortSignal, stats?: object}} [options]
* `stats` 是可选的**出参**(同 injectCandidates 的 pinnedStats 口径):调用方
* 传一个对象进来,本函数往里累计 attempted/described/failed 与第一条失败原因。
* 它存在的原因是「整档静默」——描述失败原先被 catch 吞掉,审计里只有一个
* 图块数 imageCount,用户开了开关、选了模型、一次没跑成,界面上零痕迹。
* 不传就逐字节等于改前(只返回 Map)。
*/
export async function describeImages(ctx, route, config, blocks, options = {}) {
const out = new Map();
const stats = options.stats && typeof options.stats === "object" ? options.stats : null;
const bump = (key) => { if (stats) stats[key] = (stats[key] ?? 0) + 1; };
if (!Array.isArray(blocks) || blocks.length === 0) return out;
if (typeof ctx?.llm?.stream !== "function") return out;
if (typeof ctx?.llm?.stream !== "function") {
// 宿主没给 llm:这不是「这张图失败」,是整档根本没条件跑。记一条原因,
// 别让它看起来像「库里就是没图」。
if (stats) stats.unavailable = "no llm stream";
return out;
}
const prompt = STR.prompts.imageDescription[langOf(config)];
for (const [index, block] of blocks.entries()) {
const key = attachmentKey(block, index);
if (out.has(key)) continue;
bump("attempted");
try {
let text = "";
const stream = ctx.llm.stream({
Expand Down Expand Up @@ -117,9 +130,22 @@ export async function describeImages(ctx, route, config, blocks, options = {}) {
}
}
const description = trimDescription(text);
if (description) out.set(key, description);
} catch {
// 单张图失败不影响其他张,也不影响蒸馏(见文件头 ③)。
if (description) {
out.set(key, description);
bump("described");
} else {
// 流正常结束但一个字都没有:模型拒答、被内容策略拦、或适配器没把图
// 编进去。这也是失败,只是没有异常可抓——最容易被误读成「成功」的一种。
bump("failed");
if (stats && !stats.firstError) stats.firstError = "empty description";
}
} catch (error) {
// 单张图失败不影响其他张,也不影响蒸馏(见文件头 ③)——但现在要留痕:
// 失败计数与第一条原因由调用方写进审计,否则用户永远看不见「一次都没成」。
bump("failed");
if (stats && !stats.firstError) {
stats.firstError = String(error?.message ?? error ?? "unknown").slice(0, 120);
}
}
}
return out;
Expand Down
Loading
Loading