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
4 changes: 4 additions & 0 deletions dsh-mneme/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [Unreleased]

## 🐛 修复

- **dream / sleep 侧那 5 处 prompt 一直没有围栏:巩固把 46K 清单读成「没人给我下指令」,约 43 万 token/天白烧(#501,社区报告 #493 追加)**:#493 修的是「模型把喂进去的会话转录当成自己的发言」,但围栏只装在 `autoSummarize` 那一处——`src/summarize.js` 之外,`dream.js` 的叙述条/巩固/摘要与 `sleep.js` 的冲突裁决/模式提炼这 5 处一直是把清单**裸放**在 user 消息里,system 之后没有任何分隔与角色约束。现场:`dream_consolidate` 9 次失败、输入 47K→55K tokens 而输出只有 212–907 tokens,日志里模型说「收到——这是一包记忆检索结果,但没有带具体指令,我先过一遍给你一份导览」,于是开始寒暄;报告者的对照实验同时说明方向是对的——只加 system 约束那一臂从 2/2 掉到 0/1,围栏 + 角色重申那一臂仍 1/1。修法:① `lang.js` 新增**记忆清单专用**的成对围栏与角色声明,不复用「会话转录」那对名字(喂的是清单,标错名字会让读审计与读日志的人以为这次喂进去的是一段对话);角色声明落在 **user 段开头**——#493 实测判断「这段文本是什么」的依据是它所在的那条 user 消息,只写 system 救不回来。② `fenceListBody()` 是纯函数(同一批记忆在下次阈值到来时会被原样再喂一次,重试收到的 prompt 必须与首次逐字节相同,否则「同输入同错」这条诊断依据就没了),并把正文里的 `<<<`/`>>>` 用零宽空格断链——**只中和标记、不删内容**:不然一条写着「<<<记忆清单结束>>>」的记忆就能提前闭合围栏,把后面的文字升格成指令,而记忆正文本来就是模型的可影响输入。③ 失败行的审计 `metadata` 不再为 null:`runAuditedLlm` / `auditedSleepLlm` 加 `spec.metadataFrom(result)`,dream 侧写 `parse_failure` / `raw_len` / `raw_head` / `raw_has_bracket`(`raw_has_bracket` 是区分「压根没按格式写」与「格式对但内容救不回来」的最低成本判据;报告者另指的 `raw_tail` 缺口属 #502)。审计完全复用 dream 既有那条缝,只往导出列表加名字,不重写第三份 chunk 适配。④ 顺带修掉一处**测试侧的脆弱**:多个 mock 靠「user 段以 `id=` / `候选冲突` 开头」认阶段,围栏让首行变成角色声明;改用清单形状(`| type=`)与 `options.purpose`。这不是迁就测试——`dream-candidate-hybrid.test.js` 早就注释说过前缀判据在语义预分组下会把巩固误判成摘要、只能绕开共用 mock,现在那个绕法不再需要。回归锁 5 条(围栏结构、确定性、提前闭合、consolidate 实测带围栏且失败行有读数、5 处不许再裸放的形状锁),变异自证三条(摘掉中和 → 只有提前闭合那条红;摘掉 `metadataFrom` → 只有审计那条红;拆开 consolidate 的围栏 → 审计与形状锁两条红)。评审补的两处:① `fenceListBody` 带输出契约参数——总览摘要要的是散文,对它说「只输出 JSON 数组」会让模型回 `[]`,而 `summaryText.trim()` 判非空通过,方括号就这么被存成库内总览(Major;散文契约用独立的角色声明,叙述条保留 JSON 那份,它的 system 确实要严格数组);② 流以错误结束、根本没有正文时不再写 `parse_failure` 与 `raw_len: 0`,否则一次限流会被读成「模型没按格式写」。两处各自做过变异自证。

## [0.9.4] - 2026-10-21

## 🆕 新增
Expand Down
62 changes: 57 additions & 5 deletions dsh-mneme/lib/dream.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,45 @@ function describeStreamFailure(reason) {
* operation_type, model_id, related_memory_ids); `body(reportUsage)` performs
* the actual stream consumption and is handed a usage reporter for the chunks.
*/
/**
* 把喂给模型的清单包进围栏(#493 的另一半:dream / sleep 那 5 处 user 消息以前是裸放)。
* 三个细节各自有原因,别当装饰删:
* - 角色声明落在**开头**:判断「这段文本是什么」的是它所在的那条 user 消息,只写在 system
* 里实测救不回来(#493 的 A/C 臂:只加 system 约束仍 0/2 出 JSON);
* - 正文里的 `<<<` / `>>>` 用零宽空格断链——**只中和标记,不删内容**。不这么做时,一条写着
* 「<<<记忆清单结束>>>」的记忆就能提前闭合围栏,把后面的文字升格成指令;而记忆正文本来就
* 是模型的可影响输入,这是最省事的注入入口;
* - 纯函数、不带 nonce:同一批记忆在下一次巩固阈值到来时会被原样再喂一次,重试收到的 prompt
* 必须与首次逐字节相同,否则「同输入同错」这条诊断依据就没了(#493 的重试 20 次现场就靠它)。
* @param {"json"|"text"} [shape] 该次调用的**输出契约**:巩固/叙述/冲突/模式要 JSON 数组,
* 总览摘要要散文。角色声明里那句「只输出 JSON 数组」必须跟着契约走——对散文契约说它,
* 模型会回 `[]`,而 `summaryText.trim()` 判非空通过,`[]` 就被当成摘要存进库(评审实测)。
*/
export function fenceListBody(language, text, shape = "json") {
const open = STR.memoryFenceOpen[language] ?? STR.memoryFenceOpen.en;
const close = STR.memoryFenceClose[language] ?? STR.memoryFenceClose.en;
const table = shape === "text" ? STR.dreamTextRoleReminder : STR.dreamRoleReminder;
const reminder = table[language] ?? table.en;
const body = String(text ?? "").replace(/<<<|>>>/g, (run) => `${run[0]}\u200B${run.slice(1)}`);
return `${reminder}\n\n${open}\n${body}\n${close}`;
}

/**
* 解析失败行的审计读数(#493 同款,报告者提的第 2 条)。dream 的 warn 日志本来就带
* raw length + 前 300 字,缺的是**可查询**的那一面:日志要翻、按不了、也进不了面板的
* 「LLM 消耗」。`raw_has_bracket` 是区分「模型压根没按格式写」与「格式对但内容救不回来」
* 的唯一低成本判据。
*/
export function parseFailureMeta(text) {
const raw = typeof text === "string" ? text : "";
return {
parse_failure: true,
raw_len: raw.length,
raw_head: raw.slice(0, 300).replace(/\s+/g, " ").trim(),
raw_has_bracket: raw.includes("[")
};
}

async function runAuditedLlm(ctx, service, config, spec, body) {
const audit = config?.llmAudit;
if (audit?.enabled === false || typeof service?.saveLlmAudit !== "function") return body(() => {});
Expand Down Expand Up @@ -351,6 +390,17 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
errorMessage = String(error?.message ?? error);
throw error;
} finally {
// spec.metadataFrom(result):调用方按**这一轮的原始输出**决定要不要带读数(解析成功就
// 返回 null → 整键不写)。判据函数必须是纯的:它读的是 result,不读控制流里的缓存,
// 否则「审计说了什么」会随调用方走到哪一行而变(sleep.js 同一条硬约定)。
let metadata;
if (result !== undefined && typeof spec.metadataFrom === "function") {
try {
metadata = spec.metadataFrom(result) ?? undefined;
} catch {
metadata = undefined; // 读数失败不许把审计行整个弄没
}
}
try {
service.saveLlmAudit({
timestamp,
Expand All @@ -364,7 +414,8 @@ async function runAuditedLlm(ctx, service, config, spec, body) {
duration_ms: Date.now() - startedAt,
status,
error_message: errorMessage,
related_memory_ids: spec.relatedMemoryIds ?? []
related_memory_ids: spec.relatedMemoryIds ?? [],
...(metadata ? { metadata } : {})
});
} catch (auditError) {
ctx.logger?.warn?.(`dsh-mneme: llm audit write failed: ${String(auditError)}`);
Expand Down Expand Up @@ -511,7 +562,7 @@ async function generateNarratives({ ctx, service, config, route, language, effor
...(withEffort && effort ? { reasoningEffort: effort } : {}),
messages: [
{ role: "system", content: [{ type: "text", text: STR.prompts.narrative[language] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
{ role: "user", content: [{ type: "text", text: listing }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
{ role: "user", content: [{ type: "text", text: fenceListBody(language, listing) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
]
}, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
};
Expand Down Expand Up @@ -1206,7 +1257,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
auditError: (text) => {
decisions = extractJsonArray(text);
return Array.isArray(decisions) ? null : "no json array in llm output";
}
},
metadataFrom: (text) => (Array.isArray(decisions) ? null : parseFailureMeta(text))
}, (reportUsage) => streamText(ctx, {
provider: route.provider,
model: route.model,
Expand All @@ -1215,7 +1267,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
...(withEffort && effort ? { reasoningEffort: effort } : {}),
messages: [
{ role: "system", content: [{ type: "text", text: consolidationPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
{ role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
]
}, reportUsage, (reason) => { streamFailure = describeStreamFailure(reason); }));
};
Expand Down Expand Up @@ -1458,7 +1510,7 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
...(withEffort && effort ? { reasoningEffort: effort } : {}),
messages: [
{ role: "system", content: [{ type: "text", text: STR.prompts.dreamSummary[language] }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
{ role: "user", content: [{ type: "text", text: summaryInputs.map((m) => `- ${m.title}: ${m.content}`).join("\n") }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
{ role: "user", content: [{ type: "text", text: fenceListBody(language, summaryInputs.map((m) => `- ${m.title}: ${m.content}`).join("\n"), "text") }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
]
}, reportUsage, (reason) => { summaryStreamFailure = describeStreamFailure(reason); }));
};
Expand Down
35 changes: 28 additions & 7 deletions dsh-mneme/lib/dream/sleep.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { randomUUID, createHash } from "node:crypto";
import { validateDecisions, applyDecisions, ACTIONS } from "./decisions.js";
import { findPotentialConflicts, cosineSimilarity } from "./clustering.js";
import { scopeKeyOf } from "../scope.js";
import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, withMaxTokensFallback, maintainIndexAfterDream } from "../dream.js";
import { buildReceipt, describeStreamFailure, resolveDreamEffort, withEffortFallback, withMaxTokensFallback, maintainIndexAfterDream, fenceListBody, parseFailureMeta } from "../dream.js";
import { computeHeat } from "../heat.js";

// Issue #126 review(Copilot):默认档(sleepActionSet="conflict")允许的动作 = 全局
Expand Down Expand Up @@ -102,8 +102,11 @@ async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
let outputTokens = 0;
let status = "success";
let errorMessage = null;
// 提到 try 之外:finally 里的 metadataFrom 要读这一轮的原始输出(dream.js 的
// runAuditedLlm 就是这么声明的)。留在 try 里,finally 看不见它,读数会静默变空。
let result;
try {
const result = await body((usage) => {
result = await body((usage) => {
if (!usage) return;
const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
Expand All @@ -129,6 +132,17 @@ async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
errorMessage = String(error?.message ?? error);
throw error;
} finally {
// spec.metadataFrom(result):与 dream.js 的 runAuditedLlm 同一形状(这里是一份并行
// 实现,不是同一个函数——两份审计写法的字段必须一致,否则面板按 operation_type
// 筛出来的读数会时有时没有)。判据函数只读 result,不读闭包,理由同 dream.js。
let metadata;
if (result !== undefined && typeof spec.metadataFrom === "function") {
try {
metadata = spec.metadataFrom(result) ?? undefined;
} catch {
metadata = undefined;
}
}
try {
service.saveLlmAudit({
timestamp,
Expand All @@ -142,7 +156,8 @@ async function auditedSleepLlm(ctx, service, config, logger, spec, body) {
duration_ms: Date.now() - startedAt,
status,
error_message: errorMessage,
related_memory_ids: spec.relatedMemoryIds ?? []
related_memory_ids: spec.relatedMemoryIds ?? [],
...(metadata ? { metadata } : {})
});
} catch (auditError) {
logger?.warn?.(`dsh-mneme sleep: llm audit write failed: ${String(auditError)}`);
Expand Down Expand Up @@ -342,7 +357,10 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
// 解析结果存进闭包给下面的控制流用——审计关闭时 auditedSleepLlm 整段早退、
// 这个钩子不会被调用,控制流会跟着失效(dream 的既有坑)。代价是审计开启时
// 多解析一次,换来两条路径彻底解耦。
auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output"),
// 纯判据,与 auditError 同一条约定:重解析一次、不读闭包(审计关掉时那些闭包
// 根本不会被赋值,读它会让「审计说了什么」依赖调用方走到了哪一行)。
metadataFrom: (text) => (parseJsonArray(text) ? null : parseFailureMeta(text))
}, (reportUsage) => streamText(ctx, {
provider: route.provider,
model: route.model,
Expand All @@ -351,7 +369,7 @@ async function phaseConflicts(ctx, service, config, logger, runId, semantic = nu
...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
messages: [
{ role: "system", content: [{ type: "text", text: conflictPrompt }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
{ role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
]
}, reportUsage, (reason) => { conflictStreamFailure = describeStreamFailure(reason); }));
};
Expand Down Expand Up @@ -518,7 +536,10 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
relatedMemoryIds: memories.map((m) => m.id),
streamError: () => patternStreamFailure,
// 纯判据,同 conflict 阶段:不把解析结果存进闭包供控制流使用。
auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output")
auditError: (text) => (parseJsonArray(text) ? null : "no json array in llm output"),
// 纯判据,与 auditError 同一条约定:重解析一次、不读闭包(审计关掉时那些闭包
// 根本不会被赋值,读它会让「审计说了什么」依赖调用方走到了哪一行)。
metadataFrom: (text) => (parseJsonArray(text) ? null : parseFailureMeta(text))
}, (reportUsage) => streamText(ctx, {
provider: route.provider,
model: route.model,
Expand All @@ -527,7 +548,7 @@ async function phasePatterns(ctx, service, config, logger, runId, signal = null)
...(withEffort && sleepEffort ? { reasoningEffort: sleepEffort } : {}),
messages: [
{ role: "system", content: [{ type: "text", text: STR.prompts.pattern[language].replace("N", String(maxPatterns)) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } },
{ role: "user", content: [{ type: "text", text: listText }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
{ role: "user", content: [{ type: "text", text: fenceListBody(language, listText) }], source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } }
]
}, reportUsage, (reason) => { patternStreamFailure = describeStreamFailure(reason); }));
};
Expand Down
Loading
Loading