/** * dsh-super-memory — 配置与设置持久化 * * 启动默认值写在代码里(不占 profile 的 cordis.patch.yml); * 用户在「设置 → 超级记忆」里的改动写入 * $DSH_HOME/dsh-super-memory.settings.json,宿主每次用到时现读,即时生效、重启保持。 */ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'; import { createHash } from 'node:crypto'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; /** 设置文件格式版本(写进文件,便于将来迁移)。 */ const SETTINGS_VERSION = 1; /** * 「已知工作区」名单容量(设置文件里的 `knownWorkspaces`)。 * * 40 → 200(2026-10-08):40 个槽太小 —— 每次 GET(`/session`、`/diagnose`、`/trash`) * 都会把当前会话的 cwd 登记进来,用户真实工作区被挤出去之后就一律 **403** * (面板上表现为"点了没反应")。容量放大 + 只记"最近用过的"(新的排最前、 * 淘汰最老的),并让 GET **完全不写盘**(见 `routes.js` 的 `resolveTarget`)。 */ export const KNOWN_WORKSPACES_MAX = 200; /** 写死的默认值(对应交接报告 §5.8 / §1.2 的成本红线)。 */ export const DEFAULTS = Object.freeze({ enabled: true, // ① 注入开关 injectRecap: true, injectRecall: true, // ② 入库开关(都是 0 模型调用) ingestSummary: true, ingestRawText: true, // ③ 成本上限(都是**单次**口径)。 // // 这里曾经还有一个"会话累计注入上限"(`sessionBudgetRatio`,默认窗口 2%),2026-10-07 按 // 用户实测决定**删除**:一天只有 19 次辅助调用、整场超长会话的插件总开销约 6 万 token, // 而主模型累积已用 5.6 亿 token(占比约 0.01%)。累计闸门带来的唯一实际效果是"命中率 // 会莫名其妙下降"——用户不会想到是自己多点了几次 ✕ 把额度花完了。成本控制改由**单次** // 口径负责(总览上限 / 单轮上限 / 单次输出上限 / 每批块数 / 每块字符数 / 日调用上限)。 compactionRecapMaxTokens: 300, // 500 → 700(2026-10-08,用户决定:"≤2 条"必须真的能落地)。 // 依据:中文下"2 条 × 每条 300 字符" ≈ 510 token,再加上 HEADER(≈105 token)就超过 500, // 于是 `formatRecall` 的预算循环**先 pop() 掉第二条** —— 实际永远只注入 1 条, // "≤2 条"从来没生效过(实测症状:命中时无论多相关都只看到一条)。 // 700 才放得下"两条满额中文块 + HEADER"。**不要**为了省 token 把它调回去: // 那等于把 maxItems 偷偷变成 1,用户看到的设置与实际行为不一致。 maxTokensPerTurn: 700, maxItems: 2, maxCharsPerItem: 300, // 总览注入形态:true = 本次压缩窗口内保持稳定(省一次快照追加,推荐); // false = 压缩后只注入一轮(严格照报告 §5.3) recapPersist: true, // 参考块注入形态:true = 命中过的参考块在本窗口内一直挂着(推荐)。 // 下一次未命中时若把它撤掉,整份运行上下文快照的文本就变了,DSH 会再追加一份 // (实测 1054 字符 ≈ 252 token)。挂着则文本不变、不再追加,且历史参考始终可见。 stickyRecall: true, // 检索 observationTurns: 3, minScore: 0.28, dedupe: true, cooldownTurns: 1, preferSummaryChunks: true, // ── 时间指代词的"时间形状"参数(2026-10-12 新增,用户明确要求"参数可调 + 给默认值")── // 三类的语义与形状见 `lib/text.js` 的 `TIME_REFERENCE_CLASSES` 与 `lib/retrieval.js` // 的 `timeCoefficient`。**这里只放"形状参数",不放词表**(词表是语言事实,不是旋钮)。 // // ⚠️ **面板不暴露这六个键**(`EDITABLE_FIELDS` 里**没有**它们,见下面的说明): // 它们进的是 `DEFAULTS`,用户手改设置文件即可生效(`load()` 会读、`normalizeSettings` // 会夹紧),但面板上没有控件 —— 理由: // · 这六个值**调不动也看不出效果**:它们只改变"已经命中的候选之间谁排前面", // 用户拿肉眼没法判断"波峰从 0.65 挪到 0.7 之后结果对不对"; // · 面板每多一个滑块,就要多一条"它到底影响什么"的解释,而这是本插件面板一直在 // 精简的东西(README 记过这次改版的整段理由)。 // `panel-check.mjs` 的口径是"面板用到的键必须在 `DEFAULTS` 与 `EDITABLE_FIELDS` 里", // 这六个键**面板没用到**,所以放在 `DEFAULTS` 里而不在 `EDITABLE_FIELDS` 里, // 与那条口径完全一致(它同时会打印"可编辑但面板没暴露"的清单,这六个**不在**那个 // 清单里 —— 因为它们本来就不是可编辑字段)。 // // 默认值怎么定的(**拍不准的地方都留成旋钮,这里只解释为什么是这几个数**): // · `timeNearPeakShare = 1`:`'new'`(刚刚/刚才)要的是"**越新权重越大、不设硬界限**"。 // 峰值 = 1(最新那一端)配上线性升函数 `p / peak`,就是**整条时间轴上的线性单调递增** // —— 没有任何"到了某个位置就封顶"的硬界限,正是用户要求的形状。 // · `timeFarPeakShare = 0`:`'old'`(最早/最初)与它镜像,峰值在最早那一端。 // · `timeBellPeakShare = 0.7`:`'bell'`(之前/上次/原来)的波峰**贴近当前**但不等于当前 —— // 用户原话是"接近正态分布,但波峰接近当前""最近和最早的权重都不如中间高"。 // 0.7 而不是 1:取 1 会让它退化成 `'new'`(与"刚刚"类混为一谈),取 0.5 会让 // "最近"和"最早"一样高,与"波峰贴近当前"直接冲突。0.7 落在两者之间。 // · `timeBellWidth = 0.35`:钟形宽度(以"位置比例"为单位)。0.35 时两端 // (p=0 与 p=1)的系数约是峰值的 `exp(-(0.7/0.35)²)=exp(-4)≈0.018` → 贴着下界, // 也就是"两端明显更低";再小(0.2)会把曲线压成"只有峰附近算数"(等于硬界限), // 再大(0.6)两端几乎与峰值一样高(形状失效)。0.35 是"两端明显更低、但处处非零"。 // · `timeBoostMin = 0.7` / `timeBoostMax = 1.35`:幅度**必须有界**(用户明确要求), // 上界沿用旧的 `TIME_REFERENCE_BOOST`(1.35)—— 那张"1.2/1.35/1.5 三档"的标定 // 还能对上,不引入新的量级。下界 0.7 与上界对称偏保守:它只影响"时间明显不对"的块 // 被排到多后面,而**不会**把它们踢出注入(资格只看词法分)。 // · `timeBothEndsDip = 0.2`(2026-10-12 新增,**第 4 种形状 `both_ends`**):中间凹陷深度, // 归一化到 [0,1] 的"形状值"尺度上,中间(`p = 0.5`)比两端压低多少。取值 0 = 退化成 // 水平直线(两端与中间一样高,形状失效),1 = 中间压到形状值 0(贴着 `timeBoostMin`)。 // 0.2 的实测后果(默认 min/max = 0.7/1.35):两端 1.35、中间 ≈ 1.22 —— 两端**明显更高** // 但中间**并不塌陷**(用户要的是"中间次要",不是"中间不要")。给 0.4 时中间 ≈ 1.09, // 两端与中间的差距从 0.13 拉到 0.26:仍然可调,面板不暴露(与其余时间形状参数同一条口径)。 // ⚠️ 它**只由「✕」路径的模型时间意图 `both_ends` 驱动**(见 `lib/text.js` 的 // `TIME_INTENT_SHAPES`)—— 自动检索那条路判不出"两头",只会返回 `null`(不重排)。 timeNearPeakShare: 1, timeFarPeakShare: 0, timeBellPeakShare: 0.7, timeBellWidth: 0.35, timeBothEndsDip: 0.2, timeBoostMin: 0.7, timeBoostMax: 1.35, // 入库口径:思考过程**永不入库**。工具结果默认只收"读类工具"的结果原文 // (模型读过的设定文件/章节/检索结果,往往是项目真正的知识),带白名单与两级上限; // shell 之类的高噪声输出默认不收。 storeDir: '.dsh-compaction-memory', includeToolResults: true, toolResultNames: 'read, grep, glob, web_fetch, history_read', toolResultMaxChars: 4000, toolResultBudgetChars: 120000, includePrune: false, maxRawCharsPerCompaction: 400000, backfillOnStart: true, // 本地记忆管理 trashEnabled: true, protectRecentDays: 7, trashAutoPurgeEnabled: true, trashAutoPurgeDays: 7, // 诊断 logScores: true, // 模型辅助(可选;**默认全关**,关着时行为与"没有这个功能"逐字节一致) // 铁律:模型只用在 ① 压缩入库时 与 ③ 用户主动点击"未命中诊断"之后,绝不进提问热路径。 llmAssistEnabled: false, llmIngestExpand: false, llmIngestTimeoutMs: 8000, // 每批块数 5 → 8(2026-10-07,用户决定:再砍一刀成本,但**不许为省 token 降智**)。 // 依据:扩写的每块输入字符数由 `llmIngestBlockChars`(600)夹死,与批大小无关, // 所以"同样的块摊到更少的调用"是纯省钱 —— 15 块从 3 次调用降到 2 次, // 系统提示词(约 120 token)也少交一次。批大了唯一风险是"整批 JSON 解析失败就整批丢", // 因此 24 块的输出上限(见 llmIngestMaxTokens)与 240 的额度配套,格式不对仍然整批放弃、 // 保留原有词频(绝不写坏数据)。 llmIngestBatchBlocks: 8, // 每块**送多少字符**给模型做关键词扩写(2026-10-07 新增,取代原先写死的 1200)。 // 实测 1200 时一次压缩入库的输入是 3,926 字符;600 已足够覆盖"这块在讲什么", // 输入 token 直接砍掉约一半(扩写只需要主题,不需要全文细节)。 llmIngestBlockChars: 600, // 单次**输出**上限。600 → 300 → 240(2026-10-07):一批 8 块的严格 JSON // (`{"0":["词",…]}`,每块 3–6 个 ≤24 字的词)用不到 240 token; // 实测模型常在额度里"多说几句"再被我们丢掉(格式不对就整批放弃),收紧反而更稳。 llmIngestMaxTokens: 240, llmRecallRewrite: false, // 这里曾经有 `llmRecallRerank`("让模型给候选重排"):面板从来没有它的控件、 // `applyLlmMode` 也不设它 —— 于是 `routes.js` 里那个 rerank 分支**永不可达**, // 是个纯死旋钮(只读审查报告 8)。2026-10-08 连同分支与白名单一起去掉。 // **注意**:✕ 路径的"强相关判定"走的是 `deps.llm.rerank`(host.js 注入的那个函数), // 与这个设置键没有任何关系,那条**保留不动**。 // ✕ 路径的检索调用超时 4000 → 8000(2026-10-07,用户决定)。 // 依据:这条路径本来就是"用户在等一个明确结果",而且现在有「检索中」提示(见 client.js // MissTail),等待是可见的;实测思考型模型(glm-5.3-flash)4 秒常常只吐思考、正文一个字 // 都没有 → 这一次就白点了。8 秒是**上限**,不是每次都等满:模型正常返回就立刻结束。 llmRecallTimeoutMs: 8000, // ── ✕ 通道的**分页挑选**(2026-10-08;修的是用户实测的失效案例)────────────── // 旧实现只把本地词面分数最高的 **3 条 × 100 字**喂给辅助模型:用户问 // "我最早对设置面板要求的原话是什么?"时,正确答案(本会话第一条消息)字面上 // 没有"最早"二字 → 词面分数更低 → 根本没进候选 → 模型只能在错的里挑。 // 现在按页喂:每页 `diagnosePageSize` 条(序号 + 时间 + 标题 + 首句), // 模型只回一个页内编号或 `NONE`;`NONE` 就翻下一页,最多 `diagnosePageLimit` 页。 // 成本:一页约 800 token 输入 + 8 token 输出;4 页封顶 ≈ 3.2k token/次 // (与"几百到一两千 token/次"同量级,且**只在用户点 ✕ 时**发生)。 diagnosePageSize: 30, diagnosePageLimit: 4, // 本地粗筛进池的候选条数(页数 × 每页条数的下界就够;再多只是白读磁盘)。 diagnoseCandidatePool: 120, // 查询改写的输出上限:只要求 5–10 个关键词(JSON 数组),120 token 足够; // 原先 200 是白给的额度(模型会用它写解释,反而更容易触发 BAD_OUTPUT 被整批丢弃)。 llmRewriteMaxTokens: 120, // 每日调用上限:**默认 0 = 不限**(2026-10-07 按用户实测改,原先 200)。 // 依据:一天实测只有 19 次调用,200 的闸门从未触发;而它一旦触发就是"静默停用到次日", // 用户只会觉得插件突然不灵了。字段保留,想自设上限的人仍然可以填。 llmDailyCallCap: 0, llmCacheEnabled: true, // 思考强度:**插件没有这个概念,也不许有**(2026-10-07 按用户决定删除 `llmReasoningEffort`)。 // 插件只让模型做"换个说法"这类小事,思考强度完全交给 DSH 官方「设置 → 模型」页; // 插件既不指定、也**绝不继承主对话的**(继承会又慢又贵 —— 实测确认过这就是个 bug)。 // ── 用哪个模型:只给三种选择(用户明确要求,取代早先"入库/检索各配一套")── // off = 插件不调用大模型(默认;命中率稍低,成本最低) // main = 调用主模型(命中率高,成本随主模型) // custom = 调用指定模型(命中率高,成本随所选模型) // **唯一的一套模型字段**:`llmMode` + `llmProvider` + `llmModel`。入库(expandKeywords) // 与检索(rewriteQuery / rerank)读的是**同一对** provider/model(见 lib/llm.js 的 // `resolveRoute` 调用点)—— 早先"入库/检索各配一套"的四个影子键已随单一字段集收敛删除。 // 派生规则(见 `applyLlmMode`):custom 时 provider/model 就是生效路由(空 = 跟随主模型); // main 清空这一对("跟随主模型"必须真的是空的);off **不清空**任何 provider/model // (那是"上次选了什么"的记忆,清掉之后 off → custom 就找不回来了),只把三个开关压回 false。 // 说明:可读文档**只在用户点 ✕ 的「打开原文」时生成一份"相关段落摘抄"**(短、 // 逐字、0 token)。早先设想的"每次压缩落一份整会话 L2 抄本"经用户判定取消, // 因此这里不保留任何开关(不留死旋钮)。 llmMode: 'off', llmProvider: '', llmModel: '', }); /** 布尔字段白名单。 */ const BOOLEAN_FIELDS = Object.freeze([ 'enabled', 'injectRecap', 'injectRecall', 'ingestSummary', 'ingestRawText', 'recapPersist', 'stickyRecall', 'dedupe', 'preferSummaryChunks', 'includeToolResults', 'includePrune', 'backfillOnStart', 'trashEnabled', 'trashAutoPurgeEnabled', 'logScores', 'llmAssistEnabled', 'llmIngestExpand', 'llmRecallRewrite', 'llmCacheEnabled', ]); /** * 整数字段白名单及其下限(0 表示允许为 0)。 * 上界在下面的 `INTEGER_MAX` 里;两者拼成 `INTEGER_BOUNDS`(面板 API 与设置文件共用)。 * 导出是为了让 `scripts/unit.mjs` 能核对"每个整数字段都有上界"。 */ export const INTEGER_FIELDS = Object.freeze({ compactionRecapMaxTokens: 0, maxTokensPerTurn: 0, maxItems: 0, // 下限 50(不是 0、也不是 20):20 字符连一句结论都装不下,注入了也是白花 token; // 不想注入应当去关注入开关,而不是把每条压到没有信息量。 maxCharsPerItem: 50, observationTurns: 1, cooldownTurns: 0, maxRawCharsPerCompaction: 0, protectRecentDays: 0, trashAutoPurgeDays: 1, toolResultMaxChars: 200, toolResultBudgetChars: 0, llmIngestTimeoutMs: 1, llmIngestBatchBlocks: 1, llmIngestBlockChars: 100, llmIngestMaxTokens: 1, llmRecallTimeoutMs: 1, // ✕ 分页挑选:每页至少 5 条(再少就失去"一次多看几条"的意义)、最多 100 条; // 页数至少 1、最多 10(每页一次模型调用,页数就是"这次最多花几次调用")。 diagnosePageSize: 5, diagnosePageLimit: 1, diagnoseCandidatePool: 30, llmRewriteMaxTokens: 1, llmDailyCallCap: 0, }); /** * 整数字段的**上界**表(下限见 `INTEGER_FIELDS`)。这张表是**唯一来源**: * 面板 API(`validatePatch`)与磁盘设置文件(`normalizeSettings` → `load()`) * 共用它,两处不许各写一份。 * * 为什么必须共用(2026-10-08 修的结构性缺口):API 有上下界,而 `load()` 原来只判 * "是不是数字" —— 手改设置文件塞一个 `maxRawCharsPerCompaction: 1e21`(一次压缩往 * 内存里灌 1e21 字符)或 `protectRecentDays: 1e15`(保护期到永远、整会话永远删不掉), * 插件会照单全收。现在**同一个数**在两条路径上都夹得一样紧。 */ const INTEGER_MAX = Object.freeze({ compactionRecapMaxTokens: 2000, maxTokensPerTurn: 4000, maxItems: 5, maxCharsPerItem: 1000, observationTurns: 8, cooldownTurns: 20, maxRawCharsPerCompaction: 4000000, protectRecentDays: 365, trashAutoPurgeDays: 365, toolResultMaxChars: 20000, toolResultBudgetChars: 2000000, llmIngestTimeoutMs: 300000, llmIngestBatchBlocks: 50, llmIngestBlockChars: 4000, llmIngestMaxTokens: 8000, llmRecallTimeoutMs: 300000, diagnosePageSize: 100, diagnosePageLimit: 10, diagnoseCandidatePool: 1000, llmRewriteMaxTokens: 4000, llmDailyCallCap: 100000, }); /** * 每个整数字段的 `[下限, 上限]`(`EDITABLE_FIELDS` 里的每个整数键都必须在这里有一行: * `unit.mjs` 有一条断言逐个核对,漏一个就红)。 */ export const INTEGER_BOUNDS = Object.freeze(Object.fromEntries( Object.entries(INTEGER_FIELDS).map(([key, minimum]) => [key, Object.freeze({ min: minimum, max: INTEGER_MAX[key] })]), )); /** * 越界的特殊说明(拼在统一的错误信息后面)。只给"为什么是这个数"需要解释的字段写, * 其余字段一句"必须是 X–Y 的整数"就够。 */ const INTEGER_HINTS = Object.freeze({ maxItems: '(面板只允许 1 条或 2 条一起注入,上限就是 5)', llmIngestBlockChars: '(这就是要收紧的那个量,别再放大)', llmDailyCallCap: '(0 = 不限,不要写成一个巨大的数来假装不限)', diagnosePageSize: '(每页喂给辅助模型的候选条数:一条约 25 token 输入,调大直接涨成本)', diagnosePageLimit: '(页数上限 = 一次点 ✕ 最多会让辅助模型看几页,也就是最多几次调用)', diagnoseCandidatePool: '(本地粗筛进池的条数;页数 × 每页条数就够,调大只是白读磁盘)', }); /** 浮点字段白名单(目前只剩命中阈值;原有的 `sessionBudgetRatio` 已按用户决定删除)。 */ const NUMBER_FIELDS = Object.freeze(['minScore']); /** * **只读设置文件、面板不暴露**的浮点字段及其 `[下限, 上界]`(2026-10-12 新增)。 * * 为什么与 `NUMBER_FIELDS` 分开:`NUMBER_FIELDS` 是"面板可编辑的浮点字段"(`minScore`), * 走 `validatePatch` 那条"不小于 0"的宽松口径;这一张是"时间形状参数", * 它们**不在 `EDITABLE_FIELDS` 里**(见 `DEFAULTS` 里那六个键的说明), * 因此面板 API 根本不会收到它们(收到会被拦成"未知设置项",这是对的)。 * * 它们**仍然必须有上下界**(用户要求"上界要能夹住"):手改设置文件是另一条入口, * 而 `normalizeSettings` 是两条入口共用的夹紧点。没有这张表的话, * `timeBellWidth: 1e21` 会把钟形压成一个 δ 函数(只有恰好落在波峰那一块得到加成), * `timeBoostMax: 1e9` 则等于把"有界微调"这句话直接抹掉。 */ export const NUMBER_FIELDS_READONLY = Object.freeze({ timeNearPeakShare: Object.freeze({ min: 0.5, max: 1 }), timeFarPeakShare: Object.freeze({ min: 0, max: 0.5 }), timeBellPeakShare: Object.freeze({ min: 0.5, max: 1 }), timeBellWidth: Object.freeze({ min: 0.05, max: 2 }), // `both_ends` 的"中间凹陷深度"(2026-10-12 新增):同样必须夹住 —— // 1e21 会把中间直接压到形状值 0(两端与中间的关系还在,但曲线变成一条"两端尖"的折线, // 与 `timeBellWidth: 1e21` 是同一类病),负值则等于把中间**抬高**(形状反转)。 timeBothEndsDip: Object.freeze({ min: 0, max: 1 }), timeBoostMin: Object.freeze({ min: 0.1, max: 1 }), timeBoostMax: Object.freeze({ min: 1, max: 3 }), }); /** 浮点夹紧(越界夹到边界,不是"悄悄变回默认值")。非数字 → 默认值。 */ function clampNumber(value, fallback, minimum, maximum) { if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; if (value < minimum) return minimum; return maximum === undefined ? value : Math.min(maximum, value); } /** 字符串字段白名单(不允许为空)。 */ const STRING_FIELDS = Object.freeze(['storeDir', 'toolResultNames']); /** 允许为空的字符串字段:空 = **跟随当前会话的主模型**(不填就是默认行为)。 */ const OPTIONAL_STRING_FIELDS = Object.freeze([ 'llmMode', 'llmProvider', 'llmModel', ]); /** * 从模型模式派生出"要不要调用、开哪两个时机"。 * * 用户只需要回答一个问题:**插件要不要调用模型、调用哪个**。 * * 2026-10-08 收敛为**单一字段集**:`llmMode` + `llmProvider` + `llmModel`。 * 早先还并存一套"入库/检索各配一套"的影子键(`llmIngestProvider/llmIngestModel/ * llmRecallProvider/llmRecallModel`),这里曾把模式**派生**到那两对字段上、还带一条 * "新字段为空就回落到旧字段"的兼容分支 —— 那正是"静默清空用户配置"的温床 * (面板选完型号就变空),已整体删除。`custom` 时 `llmProvider`/`llmModel` 就是生效路由, * 为空按"跟随主模型"处理(`resolveRoute` 的既有 `null` 降级路径,不新增行为)。 * @param {object} out - 正在规范化中的设置对象(会被就地修改)。 * @returns {object} 同一个对象。 */ function applyLlmMode(out) { const mode = out.llmMode === 'main' || out.llmMode === 'custom' ? out.llmMode : 'off'; out.llmMode = mode; if (mode === 'off') { out.llmAssistEnabled = false; out.llmIngestExpand = false; out.llmRecallRewrite = false; // provider/model **一个都不清**:它们是"用户上次选过哪个模型"的记忆,清掉之后 // off → custom 就再也找不回来("旧字段被静默清空"正是这次要修的坑)。 // "off = 不调用模型"由**模型档位本身**保证:网关 `check()` 与面板都只判 // `llmMode !== 'off'`(2026-10-08 统一,早先网关判的是 `llmAssistEnabled`), // 所以留着字段不会让任何一次调用真的发生。 return out; } // 选了模式 = 明确要它干活:两个时机都开(检索那条仍然只在用户点击时才真的调用) out.llmAssistEnabled = true; out.llmIngestExpand = true; out.llmRecallRewrite = true; // main:清空这一对,"跟随当前会话主模型"必须真的是空的。 // custom:原样保留用户填的 provider/model(空了就走 resolveRoute 的 null 降级)。 if (mode === 'main') { out.llmProvider = ''; out.llmModel = ''; } return out; } /** 面板可改的全部键。 */ export const EDITABLE_FIELDS = Object.freeze([ ...BOOLEAN_FIELDS, ...Object.keys(INTEGER_FIELDS), ...NUMBER_FIELDS, ...STRING_FIELDS, ...OPTIONAL_STRING_FIELDS, ]); /** * 「恢复默认设置」时**必须保留**的键。 * * 只有模型档位这一套:它是用户**显式选过**的配置(选了哪个模型、跟随主模型还是指定模型), * 不是可以"恢复默认"的参数。用户实测踩过:点一次「恢复默认设置」把整个设置文件删掉, * 模型档位一起丢(2026-10-08)。其余可编辑字段一律回到 `DEFAULTS`。 */ export const RESET_KEEP_FIELDS = Object.freeze(['llmMode', 'llmProvider', 'llmModel']); /** DSH_HOME 解析(环境变量优先,回落到 ~/.dsh)。 */ export function resolveDshHome() { const fromEnv = process.env.DSH_HOME; if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv; return join(homedir(), '.dsh'); } /** * 插件**全局数据**目录(设置 + 诊断日志)。 * * 记忆本体永远在工作区的 `.dsh-compaction-memory` 里;只有这两个跨工作区的文件需要 * 一个"全局的家"。默认跟 DSH 自己的配置放一起(`$DSH_HOME`);如果不想让它落在系统盘, * 设环境变量 `DSH_SUPER_MEMORY_HOME` 指到任意目录即可(例:`E:\DSH-data\dsh-super-memory`)。 * @returns {string} 全局数据目录绝对路径。 */ export function resolveDataHome() { const fromEnv = process.env.DSH_SUPER_MEMORY_HOME; if (typeof fromEnv === 'string' && fromEnv.trim() !== '') return fromEnv.trim(); return resolveDshHome(); } /** * 全局数据目录的来源(面板用它提示"现在落在哪个盘、为什么")。 * @returns {{dir:string, source:'env'|'dsh-home', envName:string}} 目录与来源。 */ export function dataHomeInfo() { const fromEnv = process.env.DSH_SUPER_MEMORY_HOME; const hasEnv = typeof fromEnv === 'string' && fromEnv.trim() !== ''; return { dir: hasEnv ? fromEnv.trim() : resolveDshHome(), source: hasEnv ? 'env' : 'dsh-home', envName: 'DSH_SUPER_MEMORY_HOME' }; } /** 默认设置文件路径。 */ export function defaultSettingsPath() { return join(resolveDataHome(), 'dsh-super-memory.settings.json'); } /** 诊断日志路径(与工作区内的记忆库分开,删库不会连带丢日志)。 */ export function defaultDiagPath() { return join(resolveDataHome(), 'dsh-super-memory.diag.jsonl'); } function clampInt(value, fallback, minimum, maximum) { if (typeof value !== 'number' || !Number.isFinite(value)) return fallback; const v = Math.floor(value); // 越界就夹到边界,而不是悄悄变回默认值(否则 observationTurns:0 会变成 3, // 用户看到的值和他填的完全不是一回事) const clamped = v < minimum ? minimum : v; return maximum === undefined ? clamped : Math.min(maximum, clamped); } /** * 把不可信的部分设置规范化到已知良好的默认值之上。 * @param {unknown} value - 待规范化的部分设置。 * @param {object} [defaults] - 默认值。 * @returns {object} 完整设置对象。 */ export function normalizeSettings(value, defaults = DEFAULTS, options = {}) { const record = typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {}; const out = { ...defaults }; for (const key of BOOLEAN_FIELDS) { if (typeof record[key] === 'boolean') out[key] = record[key]; } for (const [key, bound] of Object.entries(INTEGER_BOUNDS)) { if (key in record) out[key] = clampInt(record[key], defaults[key], bound.min, bound.max); } for (const key of NUMBER_FIELDS) { const v = record[key]; if (typeof v === 'number' && Number.isFinite(v) && v >= 0) out[key] = v; } // 只读的浮点字段(时间形状参数):**只在设置文件里给了合法数字时**才覆盖默认值, // 并逐个夹到 `NUMBER_FIELDS_READONLY` 的界内(`load()` 走的就是这条路)。 for (const [key, bound] of Object.entries(NUMBER_FIELDS_READONLY)) { if (!(key in record)) continue; out[key] = clampNumber(record[key], defaults[key], bound.min, bound.max); } // ⚠️ 这里**不需要**"min ≥ max 就把 max 顶上去"的补丁:两个字段的上下界分别是 // `timeBoostMin ∈ [0.1, 1]`、`timeBoostMax ∈ [1, 3]`,于是**结构上**恒有 // `min ≤ 1 ≤ max` —— 写反在夹紧这一步就不可能发生(早先那版写过的补丁是不可达的死代码)。 // `min === max` 是**合法**取值(幅度为零),必须原样保留。 // `llmIngestBlockChars` 的上下界现在由 `INTEGER_BOUNDS` 统一夹(见上面那张表的注释)。 // 字符串字段:统一 trim + 长度上限。storeDir 另有"不能含 .."的规则(见下), // 空串一律视为"用默认值",避免面板清空后插件拿到空目录名。 for (const key of STRING_FIELDS) { const raw = record[key]; if (typeof raw !== 'string') continue; const next = raw.trim().slice(0, 300); if (next !== '') out[key] = next; } // 可为空的字符串(provider/model):空串是**有意义的取值**(= 跟随主模型),要原样保留 for (const key of OPTIONAL_STRING_FIELDS) { const raw = record[key]; if (typeof raw !== 'string') continue; out[key] = raw.trim().slice(0, 120); } if (typeof out.storeDir === 'string' && out.storeDir.split(/[\\/]/).includes('..')) { out.storeDir = defaults.storeDir; } // 插件自己的登记信息(面板用它按工作区分组):不在"可编辑字段"里,但必须**原样保留**。 // 否则每次改设置、每次重载设置文件都会把它丢掉(曾经的 bug:改个开关,工作区名单就空了)。 const known = record.knownWorkspaces ?? defaults.knownWorkspaces; if (Array.isArray(known)) { out.knownWorkspaces = known.filter((item) => typeof item === 'string' && item !== '').slice(0, KNOWN_WORKSPACES_MAX); } // 派生夹紧:会话累计占比的上限(0.5)随 `sessionBudgetRatio` 一起去掉了。 // 用户只需选"用哪个模型",内部两对字段由模式派生。 // // **派生条件必须严格**:只认"这次提交/这份文件**真的带了非默认的 llmMode**"。 // 早先写成 `'llmMode' in record` —— 而设置文件里一旦被写入默认的 'off', // 之后**每次保存任何设置**都会触发派生,把用户刚选的 provider/model 清空、 // 开关也关掉(实测现象:下拉选了就弹回"跟随主模型"、点不动)。 const modeExplicit = options.deriveMode !== undefined ? options.deriveMode === true : ('llmMode' in record && record.llmMode !== defaults.llmMode); if (modeExplicit) applyLlmMode(out); return out; } /** * 校验面板提交的增量设置。返回错误信息字符串,或 undefined 表示通过。 * @param {unknown} patch - 面板提交的字段。 * @param {object} base - 当前设置。 * @returns {string|undefined} 错误信息。 */ export function validatePatch(patch, base) { if (typeof patch !== 'object' || patch === null || Array.isArray(patch)) return '设置必须是一个对象'; const keys = Object.keys(patch); if (keys.length === 0) return '没有需要更新的字段'; const unknown = keys.find((key) => !EDITABLE_FIELDS.includes(key)); if (unknown !== undefined) return `未知设置项:${unknown}`; for (const key of BOOLEAN_FIELDS) { if (key in patch && typeof patch[key] !== 'boolean') return `${key} 必须是布尔值`; } for (const [key, bound] of Object.entries(INTEGER_BOUNDS)) { if (!(key in patch)) continue; const v = patch[key]; if (!Number.isSafeInteger(v) || v < bound.min || v > bound.max) { return `${key} 必须是 ${bound.min}–${bound.max} 的整数${INTEGER_HINTS[key] ?? ''}`; } } for (const key of NUMBER_FIELDS) { if (!(key in patch)) continue; const v = patch[key]; if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) return `${key} 必须是不小于 0 的数字`; } for (const key of STRING_FIELDS) { if (key in patch && (typeof patch[key] !== 'string' || patch[key].trim() === '')) return `${key} 必须是非空字符串`; if (key in patch && patch[key].length > 300) return `${key} 太长(上限 300 字符)`; } if (typeof patch.storeDir === 'string' && patch.storeDir.split(/[\\/]/).includes('..')) { return 'storeDir 不能包含 ..(它参与删除时的路径拼接)'; } // 单字段的上下界**不再在这里逐个写死**:它们全部由 `INTEGER_BOUNDS`(= 面板 API // 与磁盘设置文件共用的那张表)统一判定,见上面的循环。原先那批 // `if (patch.toolResultMaxChars > 20000) …` 是同一批数字的第二份拷贝,两处一旦不一致 // 就会出现"文件里能塞进去、面板改不动"的鬼故事,所以删掉。 for (const key of OPTIONAL_STRING_FIELDS) { if (!(key in patch)) continue; if (typeof patch[key] !== 'string') return `${key} 必须是字符串(空 = 跟随主模型)`; if (patch[key].length > 120) return `${key} 太长(上限 120 字符)`; } // 这两条是**合并后**才看得见的越界(本次没提交它,但当前设置 + 本次提交会超出上界), // 所以只能在这里判 —— 与 `INTEGER_BOUNDS` 里的同一个数保持一致。 const merged = { ...base, ...patch }; if (merged.compactionRecapMaxTokens > INTEGER_BOUNDS.compactionRecapMaxTokens.max) return '压缩后总览上限最大 2000 token'; if (merged.maxTokensPerTurn > INTEGER_BOUNDS.maxTokensPerTurn.max) return '单轮注入上限最大 4000 token'; if (merged.trashAutoPurgeEnabled && merged.trashAutoPurgeDays < 1) return '回收站自动清空天数至少为 1'; return undefined; } /** 原子写文件(同目录临时文件 + rename)。 */ export function writeFileAtomic(path, text) { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.${process.pid}.tmp`; writeFileSync(tmp, text, 'utf8'); try { renameSync(tmp, path); } catch (error) { try { unlinkSync(tmp); } catch { /* 忽略 */ } throw error; } } /** * 进程中现读的设置存储:写入即落盘并广播,宿主各处每次现读 → 即时生效。 */ export class SettingsStore { /** @param {object} [options] - 选项。 */ constructor(options = {}) { this.path = options.path ?? defaultSettingsPath(); this.defaults = normalizeSettings(options.defaults ?? {}, DEFAULTS); this.value = { ...this.defaults }; this.persisted = false; this.listeners = new Set(); this.load(); } /** 当前设置快照。 */ get() { return { settings: { ...this.value }, source: this.persisted ? 'web' : 'default' }; } /** 订阅变更。 */ subscribe(listener) { this.listeners.add(listener); return () => { this.listeners.delete(listener); }; } /** * 更新设置(浅合并 + 落盘 + 广播)。 * @param {object} patch - 字段增量。 * @returns {object} 新快照。 */ update(patch) { const error = validatePatch(patch, this.value); if (error !== undefined) throw new TypeError(error); const next = normalizeSettings({ ...this.value, ...patch }, this.defaults, { deriveMode: 'llmMode' in patch }); this.save(next); this.value = next; this.persisted = true; return this.emit(); } /** * 「恢复默认设置」:把**可编辑字段逐个**恢复为启动默认值,但**保留模型档位**。 * * 2026-10-08 改(用户实测踩过):这里原先直接 `unlinkSync(this.path)` 把整个设置文件删掉 —— * 于是**模型选择也跟着没了**(用户点了一次"恢复默认设置",`llmMode/llmProvider/llmModel` * 一起被清空)。模型档位是用户的**显式配置**,不是"参数",恢复默认不该动它。 * 现在: * · 参数类字段 → 逐个回到 `this.defaults`(= 启动默认值); * · `llmMode/llmProvider/llmModel` → 原样保留(见 `RESET_KEEP_FIELDS`); * · `knownWorkspaces` → 保留(否则面板的工作区列表会突然变空); * · 结果**落盘**(盘上文件反映重置后的状态,而不是"文件消失、下次全靠默认值")。 * 注意:`llmAssistEnabled/llmIngestExpand/llmRecallRewrite` 三个开关是 `llmMode` 的**派生物** * (见 `applyLlmMode`),保留档位就必然保留它们 —— 这正是 2026-10-08 统一后的判据: * "会不会调用模型"只看 `llmMode`,所以档位与开关不可能给出两种答案。 * @returns {object} 新快照。 */ reset() { const kept = {}; for (const key of RESET_KEEP_FIELDS) kept[key] = this.value[key]; const next = normalizeSettings( { ...kept, knownWorkspaces: this.value.knownWorkspaces }, this.defaults, { deriveMode: 'llmMode' in kept }, ); this.value = next; this.persisted = true; try { this.save(next); } catch { /* 写盘失败不影响本次重置的内存状态 */ } return this.emit(); } /** * 记录一个"已知工作区"(用于面板按工作区分组列出,容量见 `KNOWN_WORKSPACES_MAX`)。 * * 语义是"最近使用优先"(LRU):命中的条目会被提到最前,满了才淘汰最后一个。 * `options.persist === false` 时**只改内存不写盘** —— 面板的 GET 请求走这条路 * (见 `routes.js` 的 `resolveTarget`):一次「看」不该留下持久副作用。 * @param {string} root - 工作区绝对路径。 * @param {object} [options] - `{persist: false}` 表示不落盘。 */ rememberWorkspace(root, options = {}) { if (typeof root !== 'string' || root === '') return; const list = Array.isArray(this.value.knownWorkspaces) ? this.value.knownWorkspaces.slice() : []; const next = [root, ...list.filter((item) => item !== root)].slice(0, KNOWN_WORKSPACES_MAX); const unchanged = next.length === list.length && next.every((item, i) => item === list[i]); if (unchanged) return; this.value = { ...this.value, knownWorkspaces: next }; if (options.persist === false) return; try { this.save(this.value); } catch { /* 记忆工作区失败不影响主流程 */ } } emit() { const snapshot = this.get(); for (const listener of [...this.listeners]) { try { listener(snapshot); } catch { /* 监听器异常不影响设置写入 */ } } return snapshot; } load() { if (!existsSync(this.path)) return; try { const parsed = JSON.parse(readFileSync(this.path, 'utf8')); if (typeof parsed !== 'object' || parsed === null) return; const known = Array.isArray(parsed.knownWorkspaces) ? parsed.knownWorkspaces.filter((item) => typeof item === 'string').slice(0, KNOWN_WORKSPACES_MAX) : []; const candidate = {}; // ── 一次性迁移:早先"入库/检索各配一套"的四个影子键 → 单一字段集 ────────── // **这里确实有一处兼容分支,且只保留这一处**(2026-10-08 只读审查报告 P2 修正注释: // 早先这段注释写的是"已整体删除、不保留任何兼容分支",与代码不符 —— 注释说的其实是 // `applyLlmMode()` 里那条"新键为空就回落旧键"的**运行时回落**分支,那条才是被删掉的)。 // 口径是"读一次、搬一次、删掉": // · 只有旧键存在、且它对应的新键为空时才搬运(新键优先,绝不覆盖用户已有的选择); // · 搬完由 `save()` 把四个旧键从盘上删掉(见那里的 `delete payload.*`); // · 旧键不在 `EDITABLE_FIELDS` / `DEFAULTS` 里,读回来也不会再出现在设置对象中。 // 之所以不做成长期兼容层:本插件还没有对外发布、零老用户,迁移只是为了让开发机上 // 已有的设置文件不至于把用户选的型号丢掉。 if (parsed.llmProvider === undefined && typeof parsed.llmIngestProvider === 'string') { candidate.llmProvider = parsed.llmIngestProvider; } if (parsed.llmModel === undefined && typeof parsed.llmIngestModel === 'string') { candidate.llmModel = parsed.llmIngestModel; } for (const key of EDITABLE_FIELDS) { if (Object.prototype.hasOwnProperty.call(parsed, key)) candidate[key] = parsed[key]; } this.value = { ...normalizeSettings(candidate, this.defaults), knownWorkspaces: known }; this.persisted = true; } catch { // 损坏的可选设置文件不能让 DSH 起不来:保持默认值,等用户显式保存。 } } save(value) { const payload = { version: SETTINGS_VERSION, ...value }; // 已删除的设置项必须**真的从盘上消失**:`load()` 只挑 EDITABLE_FIELDS 里的键, // 所以旧文件里的这些键不会再被读回来;这里再显式删一次,让用户下次打开设置文件时 // 不会看到一个"还在、但改不动"的死旋钮。 delete payload.sessionBudgetRatio; delete payload.llmReasoningEffort; // 收敛为单一字段集后删掉的四个影子键(旧文件里可能还在;值已由 `load()` 搬到 // `llmProvider`/`llmModel`,这里只负责让它从盘上消失)。 delete payload.llmIngestProvider; delete payload.llmIngestModel; delete payload.llmRecallProvider; delete payload.llmRecallModel; writeFileAtomic(this.path, `${JSON.stringify(payload, null, 2)}\n`); } } /** * 诊断日志里用的**短指纹**(8 位小写十六进制)。 * * 2026-10-08 隐私修正:`recall` 事件原先落 `queryHead`(提问前 60 字符明文),而诊断日志 * 在 `$DSH_HOME`(跨工作区汇聚)—— 等于把用户提过的问题明文抄了一份到另一个地方。 * 现在只落"哈希 + 长度":排障时仍然能靠它区分"这两轮问的是不是同一件事"、判断 * "是不是空查询",但日志里再也读不出提问内容。同一段文本永远得到同一个哈希 * (sha256 前 4 字节),与进程、机器无关。 * @param {unknown} text - 任意文本。 * @returns {string} 8 位十六进制指纹。 */ export function shortHash(text) { return createHash('sha256').update(String(text ?? ''), 'utf8').digest('hex').slice(0, 8); } /** 极简追加式诊断日志(有上限,防止无限增长)。 */ export class DiagnosticsLog { /** @param {object} [options] - 选项。 */ constructor(options = {}) { this.path = options.path ?? defaultDiagPath(); this.enabled = options.enabled ?? true; this.maxLines = options.maxLines ?? 4000; // 从文件既有行数起算:否则每次启动都从 0 数,文件会一路长到 2×maxLines + 上次残留 this.lines = 0; try { if (existsSync(this.path)) { this.lines = readFileSync(this.path, 'utf8').split('\n').filter((line) => line.trim() !== '').length; } } catch { /* 读不到就从 0 起算 */ } } /** * 追加一条诊断记录;失败静默。 * * `options.scoring` 标记"这条是**每条召回的打分日志**"(量最大、最可选的那类): * 只有它受 `logScores`(= `this.enabled`)开关控制。错误 / 入库 / 加载失败这类 * **排查问题必需的**事件一律照写 —— 曾经整个日志被这一个开关连带关掉,用户一关 * 打分日志,真出问题时连一条证据都没有("为什么没想起"永远查不出来)。 * @param {object} entry - 记录。 * @param {object} [options] - `{scoring: true}` = 属于可开关的打分日志。 */ write(entry, options = {}) { if (options.scoring === true && !this.enabled) return; try { mkdirSync(dirname(this.path), { recursive: true }); writeFileSync(this.path, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', flag: 'a' }); this.lines += 1; if (this.lines > this.maxLines * 2) this.compact(); } catch { /* 诊断日志失败绝不影响主流程 */ } } /** 只保留最后 maxLines 行。 */ compact() { try { if (!existsSync(this.path)) return; const all = readFileSync(this.path, 'utf8').split('\n').filter((line) => line.trim() !== ''); const kept = all.slice(-this.maxLines); writeFileAtomic(this.path, kept.length === 0 ? '' : `${kept.join('\n')}\n`); this.lines = kept.length; } catch { /* 忽略 */ } } /** 读取最近 n 条。 */ tail(n = 200) { try { if (!existsSync(this.path)) return []; const all = readFileSync(this.path, 'utf8').split('\n').filter((line) => line.trim() !== ''); return all.slice(-n).map((line) => { try { return JSON.parse(line); } catch { return { raw: line }; } }); } catch { return []; } } }