@modusensus/dsh-mneme 0.8.15 → 0.8.16
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 +8 -3
- package/bin/cli.mjs +4 -2
- package/docs/CONFIGURATION.md +14 -2
- package/docs/DISTILL.md +162 -0
- package/docs/STORAGE.md +11 -2
- package/lib/api-standalone.js +22 -39
- package/lib/api.js +171 -3
- package/lib/client.js +1065 -156
- package/lib/config.js +68 -1
- package/lib/dream/decisions.js +20 -0
- package/lib/dream.js +2 -0
- package/lib/hot-memory.js +62 -0
- package/lib/index.js +24 -7
- package/lib/inject.js +27 -63
- package/lib/maintenance.js +25 -5
- package/lib/recall-stats.js +22 -7
- package/lib/reclaim-http.js +150 -0
- package/lib/safe-slice.js +18 -0
- package/lib/service.js +27 -2
- package/lib/settings.js +13 -2
- package/lib/store.js +196 -7
- package/lib/summarize.js +208 -60
- package/package.json +1 -1
- package/src/api-standalone.js +22 -39
- package/src/api.js +171 -3
- package/src/config.js +68 -1
- package/src/dream/decisions.js +20 -0
- package/src/dream.js +2 -0
- package/src/hot-memory.js +62 -0
- package/src/index.js +24 -7
- package/src/inject.js +27 -63
- package/src/maintenance.js +25 -5
- package/src/recall-stats.js +22 -7
- package/src/reclaim-http.js +150 -0
- package/src/safe-slice.js +18 -0
- package/src/service.js +27 -2
- package/src/settings.js +13 -2
- package/src/store.js +196 -7
- package/src/summarize.js +208 -60
- package/test/api-routes-gaps.test.js +681 -0
- package/test/api.test.js +25 -2
- package/test/client.test.js +354 -2
- package/test/contrast.test.js +314 -0
- package/test/dream-merge-guardrail.test.js +219 -0
- package/test/hot-memory.test.js +67 -1
- package/test/inject-recall.test.js +39 -0
- package/test/inject.test.js +23 -0
- package/test/llm-audit.test.js +98 -0
- package/test/maintenance.test.js +112 -2
- package/test/pre-inject-gate.test.js +10 -0
- package/test/recall-stats.test.js +89 -4
- package/test/reclaim-http.test.js +209 -0
- package/test/service.test.js +7 -2
- package/test/settings.test.js +8 -0
- package/test/store.test.js +108 -0
- package/test/summarize.test.js +174 -0
package/src/config.js
CHANGED
|
@@ -189,6 +189,42 @@ export const Config = z.object({
|
|
|
189
189
|
// 模型的推理与正文共享该预算,effort 未压低时 32768 恰好被推理烧光 → 空体
|
|
190
190
|
// ("no json array in llm output",报告实测 146 轮因此失败)。默认抬到上限,
|
|
191
191
|
// 让默认配置也留足正文预算;流式计费按实际用量,不按上限。
|
|
192
|
+
//
|
|
193
|
+
// 成本审计(2026-10-12,本机库只读探针)复验了「默认值该不该降回去」这个问题,
|
|
194
|
+
// 结论是**不该降**,故默认维持 131072。三条实测证据:
|
|
195
|
+
// 1. 这个键是**上限**,不是计费口径——流式按实际用量计费(与 sleepMaxTokens
|
|
196
|
+
// 的 description 同口径)。压上限只在**真撞顶**时生效:近 10 天 88 个成功
|
|
197
|
+
// 轮 output 中位 24022、73.9% 超过 16384,因此把默认压到 16384 会让近四分之三
|
|
198
|
+
// 的正常轮撞顶。
|
|
199
|
+
// 2. 撞顶的后果不是「少烧一点」,而是**整轮失败**:max_tokens 截断 → 流上没有
|
|
200
|
+
// 完整 JSON → extractJsonArray 返回 null → finish({ok:false,
|
|
201
|
+
// error:"no json array in llm output"})(src/dream.js:1167-1171);而调度器
|
|
202
|
+
// **只有 result.ok 才刷新 baseline**(src/dream.js:905-915,注释明写这是刻意
|
|
203
|
+
// 设计「失败必须能立刻重试」)→ 下一次写入用**同一批输入**重跑一遍。按 133 个
|
|
204
|
+
// 有真实用量的成功轮逐轮折算:cap=16384 撞顶 78.9%、净总量 +54.1%;
|
|
205
|
+
// cap=24576 +43.3%;cap=32768 +15.9%;cap=49152 +6.5%;98304 起为 0。
|
|
206
|
+
// 即**调低这个值会把 token 花得更多**,而不是更少。
|
|
207
|
+
// 3. 真正的大头它治不到:把「时间就近」配上的 133 次成功调用(同一次配对里取
|
|
208
|
+
// 分子与分母)的决策正文(merge/update 的 content+title,剥离代码自动补的隐式
|
|
209
|
+
// keep)按中英分估(中文 ≈1.5 字符/token、英文 ≈4)折 419,487 token,而同一批
|
|
210
|
+
// 调用输出合计 3,543,111 token —— 正文约占 11.8%(换算方法敏感性区间
|
|
211
|
+
// 8.2%~22.0%,逐轮中位 12.1%),其余约 88% 是推理与 JSON 骨架。
|
|
212
|
+
// 注意别用「全部有 decisions 的 run」当分子配「配对子集」的分母(会得 13.9%
|
|
213
|
+
// 这种偏高的数)——跨口径比较先校量纲、分子分母必须同集合。
|
|
214
|
+
// 病根是推理与正文共享 max_tokens 单一池子。宿主(DSH)侧确有分离机制:
|
|
215
|
+
// DEFAULT_THINKING_BUDGETS = { minimal:1024, low:2048, medium:8192, high:16384 }、
|
|
216
|
+
// adjustMaxTokensForThinking()(正文预算 + 推理预算相加后卡模型上限)、
|
|
217
|
+
// clampThinkingBudgetToAnswerRoom()(MIN_ANSWER_TOKENS=1024,保证正文至少留
|
|
218
|
+
// 1024 token)——但它只对 Anthropic Claude 路径默认生效;chat-completions 网关
|
|
219
|
+
// (volcano-engine / deepseek / glm 均在此列)须由路由 profile 开
|
|
220
|
+
// compat.supportsThinkingTokenBudget 并指定 compat.thinkingTokenBudgetField
|
|
221
|
+
// (vLLM/Qwen/llama.cpp 风格顶层字段,上游端点还必须认它),asar 注释明写
|
|
222
|
+
// "Off by default"。两者都是 **profile 级**配置,插件 stream 调用无法
|
|
223
|
+
// per-request 传递(ctx.llm.stream 唯一的推理旋钮是 reasoningEffort)——故本插件
|
|
224
|
+
// 在调用面无法单独约束推理预算:抬高这个上限是当前路由下唯一可用的止损手段,
|
|
225
|
+
// 治标不治本;治本是换非思考模型,或走能分离预算的路由 profile。
|
|
226
|
+
// 显式调小仍是允许的(min 256):适合「宁可整轮失败也要压单轮上限」的场景,但请
|
|
227
|
+
// 知悉上面第 2 条的净成本方向。需要旧行为(无上限感)的库显式设回 131072。
|
|
192
228
|
dreamMaxTokens: z.natural().min(256).max(131072).default(131072),
|
|
193
229
|
// Pass-through reasoning effort for dream's LLM calls (Issue #135 建议 4/5).
|
|
194
230
|
// Unset (no value) resolves to the LOWEST effort the model declares before
|
|
@@ -216,6 +252,17 @@ export const Config = z.object({
|
|
|
216
252
|
// 被批量归档,其中 8 条疑似误伤长保留类型)是失控信号,与 update 上限同类的
|
|
217
253
|
// 全局闸门:skipInvalid 也不豁免,超限整单拒绝。正常清理可调高。
|
|
218
254
|
dreamMaxArchivePerRun: z.natural().min(1).max(200).default(8),
|
|
255
|
+
// #135 成本审计派生:单轮 merge 决策上限。merge 是唯一**回吐正文**的动作(每条
|
|
256
|
+
// 要求模型重新写出合并后的 content,实测平均 550 字符/条),其余动作都不带正文,
|
|
257
|
+
// 所以这是「输出里正文占比」唯一可控的杠杆。与 update/archive 上限同类:全局
|
|
258
|
+
// 闸门,skipInvalid 也不豁免,超限整单拒绝(errors 由 decisions.js 的数量闸段
|
|
259
|
+
// 统一汇总,run 记 failed)。默认 40 的校准依据(只读探针 246 轮 / 近 10 天 88 轮):
|
|
260
|
+
// 每轮 merge 数全库 p50 7 / p90 15 / p99 28 / max 39,近 10 天 p99 39 / max 39
|
|
261
|
+
// ——40 即观测分布上沿(历史命中 0/246),与 archive 闸默认 8 恰为其 p90=max 是
|
|
262
|
+
// 同一校准法。刻意保守(不取 p90=15 之类):超限代价不对称,整单拒绝会让调度器
|
|
263
|
+
// 不刷新 baseline、下轮同输入重跑,一次误伤约等于该轮成本 ×2,而探针里 merge≥15
|
|
264
|
+
// 的大轮正文占比正常(10~22%),没有失控特征。想拿覆盖换成本的用户可调低。
|
|
265
|
+
dreamMaxMergePerRun: z.natural().min(1).max(200).default(40),
|
|
219
266
|
// Issue #125:候选集构造方式。"window"(默认,等同现状)只取最近
|
|
220
267
|
// dreamMaxSnapshotSize 条;"hybrid" 在此基础上并入**向量翻出的高相似组**——
|
|
221
268
|
// 本机实测 45 对「双方活跃且 sim≥0.85」里 0 对能同时进窗口(窗口覆盖率 11.7%),
|
|
@@ -630,6 +677,22 @@ export const Config = z.object({
|
|
|
630
677
|
retentionDays: z.natural().min(1).max(3650).default(90)
|
|
631
678
|
}).default({}),
|
|
632
679
|
|
|
680
|
+
// --- distill records + provenance (#399) ------------------------------------
|
|
681
|
+
// 蒸馏把窗口压成记忆后,原文默认不留——于是「这条记忆出自哪段对话」事后无法
|
|
682
|
+
// 复核(arXiv 2609.36059 §3 Eq.1:旁路索引不丢信息,替换才丢)。开启后蒸馏时
|
|
683
|
+
// 顺带把**公开**正文原样落盘(distill_records),并给写出的记忆挂上窗口链接
|
|
684
|
+
// (memory_provenance)。默认关:这是一份对话原文副本,价值与暴露面都取决于
|
|
685
|
+
// 用户是否愿意留档,不该由升级默认打开。
|
|
686
|
+
//
|
|
687
|
+
// retentionDays 只裁 distill_records(启动即清,挂 llmAudit purge 旁);链接
|
|
688
|
+
// 保留指向空区间,面板/CLI 显示「来源已过期」——删原文不等于篡改历史。落盘
|
|
689
|
+
// 文本不受 distillMaxChars / trim(args,300) / trim(out,500) 约束(记录是凭证,
|
|
690
|
+
// 不是喂给 LLM 的预算);工具调用入参与结果、code 输出不落盘(推理亦然)。
|
|
691
|
+
distillRecords: z.object({
|
|
692
|
+
enabled: z.boolean().default(false),
|
|
693
|
+
retentionDays: z.natural().min(1).max(3650).default(90)
|
|
694
|
+
}).default({}),
|
|
695
|
+
|
|
633
696
|
// --- write admission (#254) ------------------------------------------------
|
|
634
697
|
// 写入准入:把「这条该不该进库」前移到 LLM 之前。与 memoryQualityFilter 的分工
|
|
635
698
|
// 是时点不是判据——那个是**写入后**打分归档(扣分可以错,只影响排序与归档),
|
|
@@ -821,7 +884,11 @@ const LIGHT_MODE_OFF = [
|
|
|
821
884
|
// 轻量档(小模型 / 小上下文)最不该再多一份注入物。这是预设给的默认值、不是强制:
|
|
822
885
|
// 用户显式勾选仍然赢(合并顺序「用户开关 > 轻量预设 > bundle 配置」,同
|
|
823
886
|
// injectGuidanceEnabled)。
|
|
824
|
-
"continuityRescueEnabled"
|
|
887
|
+
"continuityRescueEnabled",
|
|
888
|
+
// #399:轻量档不开蒸馏原文留存——它是一份对话原文副本(磁盘 + 暴露面),
|
|
889
|
+
// 与轻量档「小模型 / 小上下文 / 少留痕」的取向相反。默认关,这里只是
|
|
890
|
+
// 轻量预设再确认一次;用户显式勾选仍然赢(合并顺序同 continuityRescueEnabled)。
|
|
891
|
+
"distillRecords.enabled"
|
|
825
892
|
];
|
|
826
893
|
|
|
827
894
|
/**
|
package/src/dream/decisions.js
CHANGED
|
@@ -264,6 +264,26 @@ export function validateDecisions(decisions, snapshot, options = {}) {
|
|
|
264
264
|
if (archiveCount > maxArchivePerRun) {
|
|
265
265
|
errors.push(`too many archive decisions: ${archiveCount} > ${maxArchivePerRun}`);
|
|
266
266
|
}
|
|
267
|
+
// merge 批量上限(#135 成本审计的派生护栏)。merge 是唯一**回吐正文**的动作:
|
|
268
|
+
// 每条 merge 都要求模型重新写出合并后的 content(实测 2113 条 merge 平均 550
|
|
269
|
+
// 字符/条),而 archive/keep/conflict 都不带正文——所以单轮 merge 数是「输出
|
|
270
|
+
// 里正文占比」唯一可控的杠杆(实测 dream_consolidate 的输出 token 只有约 11.8%
|
|
271
|
+
// 是决策正文,其余是推理与 JSON 骨架,那部分由 effort 决定、与本闸无关)。
|
|
272
|
+
// 与 update/create/archive 三道闸同类:全局闸门,skipInvalid 也不豁免,超限整单
|
|
273
|
+
// 拒绝。默认值取 40 的校准依据(只读探针,全库 246 轮 / 近 10 天 88 轮):
|
|
274
|
+
// 每轮 merge 数 全库 p50 7 / p90 15 / p99 28 / max 39,近 10 天 p50 9 / p90 20 /
|
|
275
|
+
// p99 39 / max 39 —— 40 是观测分布的上沿(历史命中 0/246),与 archive 闸默认 8
|
|
276
|
+
// 恰为其 p90=max 同一校准法:这是面向未来的尾险护栏,不是成本削减工具(现有三
|
|
277
|
+
// 道闸在实测分布下命中率均为 0.0%,同一性质)。刻意不取更小的值:超限的代价
|
|
278
|
+
// 不对称——整单拒绝 = run 记 failed = 调度器不刷新 baseline(src/dream.js:905-915)
|
|
279
|
+
// = 下一次写入用同一批输入重跑,一次误伤约等于该轮成本 ×2;而探针里 merge≥15 的
|
|
280
|
+
// 大轮正文/显式决策比例都正常(10~22%),没有失控特征。想用覆盖换成本的用户可
|
|
281
|
+
// 自行调低 dreamMaxMergePerRun。
|
|
282
|
+
const mergeCount = survivors.filter((d) => d.action === "merge").length;
|
|
283
|
+
const maxMergePerRun = options.maxMergePerRun ?? 40;
|
|
284
|
+
if (mergeCount > maxMergePerRun) {
|
|
285
|
+
errors.push(`too many merge decisions: ${mergeCount} > ${maxMergePerRun}`);
|
|
286
|
+
}
|
|
267
287
|
// Issue #89 防洗白核心(#104 方向 1 重审后保留):全部决策都被跳过、合法
|
|
268
288
|
// 子集为空时仍整单拒绝——宽容的前提是「有合法子集可应用」;模型输出整体
|
|
269
289
|
// 非法(如全部幻觉 id)是真故障,不能被隐式 keep 洗成 ok。#104 只豁免
|
package/src/dream.js
CHANGED
|
@@ -1173,6 +1173,8 @@ export function createDreamScheduler({ onRun, thresholdCount = 10, thresholdChar
|
|
|
1173
1173
|
maxUpdatePerRun: config.reflectionUpdateMaxPerRun,
|
|
1174
1174
|
// Issue #104 方向 2:archive 批量上限(全局闸门,超限整单拒绝)。
|
|
1175
1175
|
maxArchivePerRun: config.dreamMaxArchivePerRun,
|
|
1176
|
+
// #135 成本审计派生:merge 批量上限(merge 是唯一回吐正文的动作,同款全局闸门)。
|
|
1177
|
+
maxMergePerRun: config.dreamMaxMergePerRun,
|
|
1176
1178
|
minAgeHours: config.reflectionUpdateMinAgeHours,
|
|
1177
1179
|
// v0.4.4 fix:显式透传,用户配 dreamImplicitKeep:false 时严格模式必须
|
|
1178
1180
|
// 真正生效,dreamMinExplicitCoverage 决定隐式 keep 下的覆盖率下限。
|
package/src/hot-memory.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { safeSlice } from "./safe-slice.js";
|
|
2
|
+
|
|
1
3
|
// Session-scoped hot memory (v0.5.0 召回率优化 1.3): a short-term buffer of
|
|
2
4
|
// the latest dialogue rounds, kept strictly apart from the long-term memory
|
|
3
5
|
// store. The injector renders it ahead of the long-term recall block so the
|
|
@@ -44,3 +46,63 @@ export function createHotMemory({ maxRounds = 5, maxTokens = 2000 } = {}) {
|
|
|
44
46
|
clear() { buffer.length = 0; }
|
|
45
47
|
};
|
|
46
48
|
}
|
|
49
|
+
|
|
50
|
+
// Issue:rewind(surfaceOp replace)/compact 遮蔽过的事件仍留在 append-only 事件
|
|
51
|
+
// 流里——热记忆若直接遍历原始流,被撤回的轮次会回流进 prompt(用户实测:一次
|
|
52
|
+
// 撤回 3+ 条后模型仍能复述大部分内容)。宿主在 session.surface.nodes 维护
|
|
53
|
+
// 「模型可见事件 seq」列表(dsh-rewind 插件即据此计算替换区间);调用方把可见
|
|
54
|
+
// 集合传入本函数,只统计可见事件,热记忆与模型看到的对话因此同源。visibleSeqs
|
|
55
|
+
// 传 null 表示宿主未提供 surface(老版本 DSH),保持旧行为——宁可持续旧偏差,
|
|
56
|
+
// 绝不误删仍可见的轮次。
|
|
57
|
+
//
|
|
58
|
+
// 配对规则与 inject.js 原实现一致:user/message(source.kind 缺省或 "user")开启
|
|
59
|
+
// 新一轮;下一条 assistant 起源的事件关闭该轮。tool/system 等携带正文的事件不得
|
|
60
|
+
// 冒充模型回复;rewind 标记(source.kind = "dsh-rewind")不是用户提问。
|
|
61
|
+
export function roundsFromEvents(events, maxRounds, visibleSeqs = null) {
|
|
62
|
+
const rounds = [];
|
|
63
|
+
let pendingQuery = null;
|
|
64
|
+
const textOf = (event) => {
|
|
65
|
+
// user/message 的正文就在 data 上,而 assistant/message(以及 tool/result)的消息体
|
|
66
|
+
// 嵌在 data.message 下 —— DSH 侧由 @deepseek-ai/dsh-session 的
|
|
67
|
+
// assertMessageEventShape 一行写死:const message = type === "user/message" ? record : record?.["message"]。
|
|
68
|
+
// 只读 data.content 会让每一轮的 response 恒为空(Issue #129)。
|
|
69
|
+
// 这里用 ?? 兜底而不是直接改成 data.message.content:user 消息与旧的扁平形状都照旧可用。
|
|
70
|
+
const parts = event?.data?.content ?? event?.data?.message?.content;
|
|
71
|
+
if (!Array.isArray(parts)) return "";
|
|
72
|
+
// 只取正文 part(type === "text" 或字符串),跳过 reasoning(issue #162):
|
|
73
|
+
// reasoning 里出现 Go template / Vue / Handlebars / 正则 / 日志原文的概率远高于
|
|
74
|
+
// 正文,把它带进 hot memory 会让 `{{...}}` 模板片段注入 prompt。旧的扁平形状
|
|
75
|
+
// (字符串 part)照旧取用,无 type 字段的兜底视为正文。
|
|
76
|
+
return parts
|
|
77
|
+
.map((p) => {
|
|
78
|
+
if (typeof p === "string") return p;
|
|
79
|
+
if (p?.type && p.type !== "text") return "";
|
|
80
|
+
return p?.text ?? "";
|
|
81
|
+
})
|
|
82
|
+
.filter(Boolean)
|
|
83
|
+
.join("\n")
|
|
84
|
+
.trim();
|
|
85
|
+
};
|
|
86
|
+
for (const event of events) {
|
|
87
|
+
// surface 投影:不在「模型可见面」上的事件(被 rewind 撤回、被 compact 替换)
|
|
88
|
+
// 不参与配对。
|
|
89
|
+
if (visibleSeqs && !visibleSeqs.has(event?.seq)) continue;
|
|
90
|
+
const kind = event?.data?.source?.kind;
|
|
91
|
+
const isUser = event?.type === "user/message" && (kind === undefined || kind === "user");
|
|
92
|
+
if (isUser) {
|
|
93
|
+
if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
|
|
94
|
+
pendingQuery = safeSlice(textOf(event), 500);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
// Only assistant-originated events close a round; tool/system events
|
|
98
|
+
// carrying text must not be mistaken for the model's reply.
|
|
99
|
+
const isAssistant = typeof event?.type === "string" && event.type.includes("assistant")
|
|
100
|
+
|| kind === "assistant";
|
|
101
|
+
const body = isAssistant ? textOf(event) : "";
|
|
102
|
+
if (!body || !pendingQuery) continue;
|
|
103
|
+
rounds.push({ query: pendingQuery, response: safeSlice(body, 800) });
|
|
104
|
+
pendingQuery = null;
|
|
105
|
+
}
|
|
106
|
+
if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
|
|
107
|
+
return rounds.slice(-maxRounds);
|
|
108
|
+
}
|
package/src/index.js
CHANGED
|
@@ -238,6 +238,20 @@ export const apply = (ctx, config) => {
|
|
|
238
238
|
}
|
|
239
239
|
} catch { /* non-fatal */ }
|
|
240
240
|
|
|
241
|
+
// #399 的启动期裁剪:与上面同款(读装配后的 cfg,理由同上——面板写的保留期必须
|
|
242
|
+
// 与写入侧取到同一个值)。不同的一点:蒸馏原文留存默认关,而「默认关」在写入侧
|
|
243
|
+
// 的含义就是零写入,所以这里也只在 enabled === true 时才动手——否则一张空表被
|
|
244
|
+
// 每次启动扫一遍,白付一次全表 DELETE 的代价。只裁 distill_records;链接
|
|
245
|
+
// (memory_provenance)保留,指向空区间即为「来源已过期」。
|
|
246
|
+
try {
|
|
247
|
+
if (cfg.distillRecords?.enabled === true) {
|
|
248
|
+
const retentionMs = Number.isInteger(cfg.distillRecords?.retentionDays)
|
|
249
|
+
? cfg.distillRecords.retentionDays
|
|
250
|
+
: 90;
|
|
251
|
+
store.clearDistillRecords(new Date(Date.now() - retentionMs * 86400000).toISOString());
|
|
252
|
+
}
|
|
253
|
+
} catch { /* non-fatal */ }
|
|
254
|
+
|
|
241
255
|
// 记忆语言(memory.language):本实例逐层传入 inject / summarize / dream /
|
|
242
256
|
// sleep / mirror,多实例(如 agent preset 内挂载)互不影响。
|
|
243
257
|
const mirror = createMirror(memoryDir, langOf(cfg));
|
|
@@ -483,6 +497,15 @@ export const apply = (ctx, config) => {
|
|
|
483
497
|
const summarizer = createSummarizer(ctx, service, cfg);
|
|
484
498
|
disposers.push(summarizer.dispose);
|
|
485
499
|
|
|
500
|
+
// #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
|
|
501
|
+
// 这一步是不可逆的内容丢弃,只由人显式触发(面板的维护卡、`dsh-mneme reclaim`)。
|
|
502
|
+
// 实例建在插件进程内,是为了让 CLI 也能走这条路:VACUUM 要排他锁,跟宿主抢锁的
|
|
503
|
+
// 那条路走不通。
|
|
504
|
+
// 位置在 webServer 块之前:面板的 in-process API(createApi)与 standalone 数据面
|
|
505
|
+
// 都要用它,而 webServer 是可选的——headless 宿主不注册面板路由,构造点若留在块后
|
|
506
|
+
// 就永远晚于 createApi(#386 第 4 条)。构造期无副作用(只返回 { reclaim }),前移安全。
|
|
507
|
+
const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger });
|
|
508
|
+
|
|
486
509
|
// webServer 可选依赖:cordis 4 的 ctx 是 Proxy,直接访问未在 inject 声明的
|
|
487
510
|
// 属性会抛 "cannot get property without inject"(不会返回 undefined),所以
|
|
488
511
|
// 不能用 if (ctx.webServer) 守卫。ctx.reflect.get 是 cordis 提供的免 inject
|
|
@@ -495,16 +518,10 @@ export const apply = (ctx, config) => {
|
|
|
495
518
|
add: () => { throw new Error("commands unavailable"); },
|
|
496
519
|
remove: () => false,
|
|
497
520
|
list: () => []
|
|
498
|
-
}, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg);
|
|
521
|
+
}, embedder, { vectorIndex, reranker }, cfg.apiToken, cfg, maintenance);
|
|
499
522
|
disposers.push(api.dispose);
|
|
500
523
|
}
|
|
501
524
|
|
|
502
|
-
// #275 存储生命周期第一批:无损回收的手动入口。刻意不挂启动路径、不接定时器——
|
|
503
|
-
// 这一步是不可逆的内容丢弃,只由人显式触发(`dsh-mneme reclaim`)。实例在这里建、
|
|
504
|
-
// 在 standalone API 上暴露,是为了让 CLI 能在插件进程内跑(VACUUM 要排他锁,跟宿主
|
|
505
|
-
// 抢锁的那条路走不通)。
|
|
506
|
-
const maintenance = createMaintenance({ store, config: cfg, logger: ctx.logger });
|
|
507
|
-
|
|
508
525
|
// Standalone external API (v0.7.12): plain node:http server for ecosystem
|
|
509
526
|
// integrations outside the DSH host. Persisted external_api settings win
|
|
510
527
|
// over the bundle config (enabled/port); the Bearer token lives in the same
|
package/src/inject.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createScopeResolver } from "./scope.js";
|
|
2
|
-
import { createHotMemory } from "./hot-memory.js";
|
|
2
|
+
import { createHotMemory, roundsFromEvents } from "./hot-memory.js";
|
|
3
3
|
import { STR, langOf } from "./lang.js";
|
|
4
4
|
import { MEMORY_GUIDE_SECTION } from "./guide.js";
|
|
5
5
|
import { adaptiveInjectBudget } from "./search/adaptive.js";
|
|
6
6
|
import { injectChildEnabled } from "./config.js";
|
|
7
|
+
import { safeSlice } from "./safe-slice.js";
|
|
7
8
|
|
|
8
9
|
// Issue #179:注入预览的数据底座。systemPrompt 渲染是同步回调,面板只能事后
|
|
9
10
|
// 拉取,所以在这里旁路缓存「最近一帧组装」——快照就是本次渲染用过的同一份
|
|
@@ -16,21 +17,19 @@ export function getInjectionSnapshot() {
|
|
|
16
17
|
return injectionSnapshot;
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
if (tail >= 0xd800 && tail <= 0xdbff) cut = cut.slice(0, -1);
|
|
33
|
-
return cut;
|
|
20
|
+
// 「模型可见事件 seq」集合:宿主在 session.surface.nodes 维护可见面——rewind 的
|
|
21
|
+
// surfaceOp replace 与 compact 的隐藏替换都作用在这层(dsh-rewind 插件即据此计算
|
|
22
|
+
// 替换区间)。拿不到(老版本宿主)返回 null,调用方退回「全量事件」的旧行为。
|
|
23
|
+
// 读取必须在 try 内:`surface.nodes` 是宿主 getter,其 _assertProjections 在消息投影
|
|
24
|
+
// 所属插件被移除时会 throw(dsh-session lib/types/surface.js)。抛出等同于「没有
|
|
25
|
+
// 可见面」,绝不能让整块热记忆消失——那是比旧偏差更糟的退化。
|
|
26
|
+
function visibleSeqsOf(session) {
|
|
27
|
+
try {
|
|
28
|
+
const nodes = session?.surface?.nodes;
|
|
29
|
+
return Array.isArray(nodes) ? new Set(nodes) : null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
34
33
|
}
|
|
35
34
|
|
|
36
35
|
// Best-effort extraction of the current user's latest message text from the
|
|
@@ -46,8 +45,11 @@ function lastUserQuery(ctx) {
|
|
|
46
45
|
// still have the .events property, so fall back to it.
|
|
47
46
|
const events = session?.snapshotEvents?.() ?? session?.events;
|
|
48
47
|
if (!Array.isArray(events) || events.length === 0) return "";
|
|
48
|
+
const visibleSeqs = visibleSeqsOf(session);
|
|
49
49
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
50
50
|
const event = events[i];
|
|
51
|
+
// 被撤回/被压缩遮蔽的事件不算「最近一条用户消息」(与 extractRounds 同口径)。
|
|
52
|
+
if (visibleSeqs && !visibleSeqs.has(event?.seq)) continue;
|
|
51
53
|
if (event?.type !== "user/message") continue;
|
|
52
54
|
const kind = event.data?.source?.kind;
|
|
53
55
|
if (kind !== undefined && kind !== "user") continue;
|
|
@@ -66,58 +68,20 @@ function lastUserQuery(ctx) {
|
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
// Hot-memory round extraction (v0.5.0 1.3): pairs each user/message with the
|
|
69
|
-
// next assistant reply
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
71
|
+
// next assistant reply. The pairing lives in hot-memory.js (roundsFromEvents)
|
|
72
|
+
// so it can be unit-tested directly; this adapter only resolves the session's
|
|
73
|
+
// raw event log and the model-visible seq set (session.surface.nodes — see
|
|
74
|
+
// visibleSeqsOf). Rewind-shadowed / compacted events are thereby excluded:
|
|
75
|
+
// hot memory and the model see the same conversation. Best-effort: returns []
|
|
76
|
+
// on any failure, and the hot block simply does not render.
|
|
73
77
|
function extractRounds(ctx, maxRounds) {
|
|
74
78
|
try {
|
|
75
79
|
const session = ctx?.agent?.session;
|
|
80
|
+
// DSH ≥0.1.2-rc only exposes events via snapshotEvents(); older builds
|
|
81
|
+
// still have the .events property, so fall back to it.
|
|
76
82
|
const events = session?.snapshotEvents?.() ?? session?.events;
|
|
77
83
|
if (!Array.isArray(events) || events.length === 0) return [];
|
|
78
|
-
|
|
79
|
-
let pendingQuery = null;
|
|
80
|
-
const textOf = (event) => {
|
|
81
|
-
// user/message 的正文就在 data 上,而 assistant/message(以及 tool/result)的消息体
|
|
82
|
-
// 嵌在 data.message 下 —— DSH 侧由 @deepseek-ai/dsh-session 的
|
|
83
|
-
// assertMessageEventShape 一行写死:const message = type === "user/message" ? record : record?.["message"]。
|
|
84
|
-
// 只读 data.content 会让每一轮的 response 恒为空(Issue #129)。
|
|
85
|
-
// 这里用 ?? 兜底而不是直接改成 data.message.content:user 消息与旧的扁平形状都照旧可用。
|
|
86
|
-
const parts = event?.data?.content ?? event?.data?.message?.content;
|
|
87
|
-
if (!Array.isArray(parts)) return "";
|
|
88
|
-
// 只取正文 part(type === "text" 或字符串),跳过 reasoning(issue #162):
|
|
89
|
-
// reasoning 里出现 Go template / Vue / Handlebars / 正则 / 日志原文的概率远高于
|
|
90
|
-
// 正文,把它带进 hot memory 会让 `{{...}}` 模板片段注入 prompt。旧的扁平形状
|
|
91
|
-
// (字符串 part)照旧取用,无 type 字段的兜底视为正文。
|
|
92
|
-
return parts
|
|
93
|
-
.map((p) => {
|
|
94
|
-
if (typeof p === "string") return p;
|
|
95
|
-
if (p?.type && p.type !== "text") return "";
|
|
96
|
-
return p?.text ?? "";
|
|
97
|
-
})
|
|
98
|
-
.filter(Boolean)
|
|
99
|
-
.join("\n")
|
|
100
|
-
.trim();
|
|
101
|
-
};
|
|
102
|
-
for (const event of events) {
|
|
103
|
-
const kind = event?.data?.source?.kind;
|
|
104
|
-
const isUser = event?.type === "user/message" && (kind === undefined || kind === "user");
|
|
105
|
-
if (isUser) {
|
|
106
|
-
if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
|
|
107
|
-
pendingQuery = safeSlice(textOf(event), 500);
|
|
108
|
-
continue;
|
|
109
|
-
}
|
|
110
|
-
// Only assistant-originated events close a round; tool/system events
|
|
111
|
-
// carrying text must not be mistaken for the model's reply.
|
|
112
|
-
const isAssistant = typeof event?.type === "string" && event.type.includes("assistant")
|
|
113
|
-
|| kind === "assistant";
|
|
114
|
-
const body = isAssistant ? textOf(event) : "";
|
|
115
|
-
if (!body || !pendingQuery) continue;
|
|
116
|
-
rounds.push({ query: pendingQuery, response: safeSlice(body, 800) });
|
|
117
|
-
pendingQuery = null;
|
|
118
|
-
}
|
|
119
|
-
if (pendingQuery) rounds.push({ query: pendingQuery, response: "" });
|
|
120
|
-
return rounds.slice(-maxRounds);
|
|
84
|
+
return roundsFromEvents(events, maxRounds, visibleSeqsOf(session));
|
|
121
85
|
} catch {
|
|
122
86
|
return [];
|
|
123
87
|
}
|
package/src/maintenance.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// 东西)。所以模块里没有任何定时器,挂点只有两个:standalone 数据面的
|
|
7
7
|
// `POST /maintenance/reclaim` 与它上面的 `dsh-mneme reclaim` 子命令。
|
|
8
8
|
//
|
|
9
|
-
//
|
|
9
|
+
// 三项都零价值判断、零条数变化:
|
|
10
10
|
// A `dream_runs.input` 置空。这一列的含义按 run_type 分叉:auto / sleep 的 run 存的
|
|
11
11
|
// 是当时的记忆库快照,可由记忆库重建;**organize 的 run 存的是 apply 的重放载荷**,
|
|
12
12
|
// 置空会让那次 apply 静默空转并把报告锁死,所以 organize 行不在可清范围内(谓词
|
|
@@ -14,6 +14,13 @@
|
|
|
14
14
|
// 与 receipt 一律保留,行永不删(拍板 3:裁列同意、删行不同意)。
|
|
15
15
|
// B 归档行向量置空:检索 SQL 恒带 `archived = 0`,这部分向量按定义不可达。它不是
|
|
16
16
|
// 单程票——取消归档时 service 会重新排队嵌入(archiveMemory 的还原分支)。
|
|
17
|
+
// C #399 蒸馏原文记录按 distilled_at 到期删除:记录是蒸馏那次调用的凭证,越旧越
|
|
18
|
+
// 没有复核价值,而它按定义就是整段对话原文(本入口里唯一的「真删内容」项)。它的
|
|
19
|
+
// 常态保留期是独立配置键 distillRecords.retentionDays(启动期裁剪走那个键);本入口
|
|
20
|
+
// **不读那个键**,与 A/B 共用同一条 --older-than 截止(想对齐就显式传同样的天数,
|
|
21
|
+
// 见 STORAGE.md「两处不冲突」)。链接
|
|
22
|
+
// (memory_provenance)**不在可清范围内**:保留指向空区间即「来源已过期」,删链
|
|
23
|
+
// 接等于假装这条记忆从来没有过出处——那是篡改历史,不是回收。
|
|
17
24
|
//
|
|
18
25
|
// 报告口径(拍板 2):收益按 VACUUM 前后体积量,不按列字节估——实际释放来自溢出页与
|
|
19
26
|
// 索引页回收,列文本大小只是上界,所以两个数分开报,不混成一个「省了多少」。
|
|
@@ -33,7 +40,8 @@ export const DEFAULT_INPUT_RETENTION_DAYS = 7;
|
|
|
33
40
|
*
|
|
34
41
|
* @param {object} deps
|
|
35
42
|
* @param {object} deps.store 存储层(dreamRunInputStats / clearDreamRunInputs /
|
|
36
|
-
* archivedEmbeddingStats / clearArchivedEmbeddings /
|
|
43
|
+
* archivedEmbeddingStats / clearArchivedEmbeddings / distillRecordStats /
|
|
44
|
+
* clearDistillRecords / storageStats / vacuum / saveLlmAudit)
|
|
37
45
|
* @param {object} [deps.config] 已解析配置(读 llmAudit.enabled:关掉 audit 时不落 receipt)
|
|
38
46
|
* @param {object} [deps.logger]
|
|
39
47
|
* @param {() => number} [deps.now] 时钟注入(测试用)
|
|
@@ -86,15 +94,26 @@ export function createMaintenance({ store, config, logger, now = Date.now } = {}
|
|
|
86
94
|
const before = store.storageStats();
|
|
87
95
|
const inputs = store.dreamRunInputStats(cutoff);
|
|
88
96
|
const embeddings = store.archivedEmbeddingStats();
|
|
97
|
+
// #399:蒸馏原文记录与上面两项同款——按 distilled_at 到期裁剪。链接
|
|
98
|
+
// (memory_provenance)不在此列:它保留指向空区间,显示「来源已过期」。
|
|
99
|
+
const records = store.distillRecordStats(cutoff);
|
|
89
100
|
const report = {
|
|
90
101
|
dryRun,
|
|
91
102
|
olderThanDays: days,
|
|
92
103
|
cutoff,
|
|
93
104
|
// planned = 这次会动几行;cleared = 实际动了几行(dry-run 恒 0)
|
|
94
|
-
planned: {
|
|
105
|
+
planned: {
|
|
106
|
+
dream_run_inputs: inputs.runs,
|
|
107
|
+
archived_embeddings: embeddings.rows,
|
|
108
|
+
distill_records: records.rows
|
|
109
|
+
},
|
|
95
110
|
// 列文本大小:上界,不是回收量(真实释放看下面的 size 差值)
|
|
96
|
-
column_bytes: {
|
|
97
|
-
|
|
111
|
+
column_bytes: {
|
|
112
|
+
dream_run_inputs: inputs.bytes,
|
|
113
|
+
archived_embeddings: embeddings.bytes,
|
|
114
|
+
distill_records: records.bytes
|
|
115
|
+
},
|
|
116
|
+
cleared: { dream_run_inputs: 0, archived_embeddings: 0, distill_records: 0 },
|
|
98
117
|
size: {
|
|
99
118
|
before_bytes: before.fileBytes,
|
|
100
119
|
after_bytes: before.fileBytes,
|
|
@@ -114,6 +133,7 @@ export function createMaintenance({ store, config, logger, now = Date.now } = {}
|
|
|
114
133
|
}
|
|
115
134
|
report.cleared.dream_run_inputs = store.clearDreamRunInputs(cutoff);
|
|
116
135
|
report.cleared.archived_embeddings = store.clearArchivedEmbeddings();
|
|
136
|
+
report.cleared.distill_records = store.clearDistillRecords(cutoff);
|
|
117
137
|
if (report.vacuum.requested) {
|
|
118
138
|
// VACUUM 失败不能把前面的清理一起吞掉:两个 UPDATE 已经各自提交了,此时抛出去
|
|
119
139
|
// 就是「数据已经不可逆地清了,但没有任何回执」。所以失败记进报告、回执照写,
|
package/src/recall-stats.js
CHANGED
|
@@ -24,11 +24,18 @@ import { scopeKeyOf } from "./scope.js";
|
|
|
24
24
|
* 上限,超出标 truncated(degraded 口径),不静默少算。
|
|
25
25
|
* - archive(#275 拍板的第五指标,与上面几项同位):
|
|
26
26
|
* · total = 归档区现有行数;addedInWindow / perDay = 窗口内进入归档区的行数
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* setArchived
|
|
30
|
-
*
|
|
31
|
-
*
|
|
27
|
+
* 与其日均(净增速率)。归档时刻取 archived_at(#275 第二批落的精确列):
|
|
28
|
+
* setArchived 只在「活跃 → 归档」那一次落值,取消归档清 NULL,重复归档不刷新。
|
|
29
|
+
* 本指标 v1 曾用 updated_at 代理(setArchived 翻标志位时刷它),偏差方向是
|
|
30
|
+
* **偏高**——对已归档行做一次 memory_update、或对同一行重复调 setArchived(…,
|
|
31
|
+
* true),都会把旧归档算成「本窗口新进」。archived_at 就是为消灭这两个假阳性
|
|
32
|
+
* 而加的,别再退回 updated_at。真正的「净」增本来也要跨快照比 total(物理
|
|
33
|
+
* 删除落地后,差值才会由负向变化体现)。
|
|
34
|
+
* · undated = archived_at 为 NULL 的归档行数(本列落地前就已归档的存量行,时刻
|
|
35
|
+
* 不可追溯)。**刻意不计入 addedInWindow**:它们的归档时刻早于本列落地,无法
|
|
36
|
+
* 证明落在窗口内还是窗口外(迁移当刻,窗口内的存量归档行同样会是 NULL)。用
|
|
37
|
+
* updated_at 顶替就是走回被推翻的代理口径;按 0 充数则是把「不知道」伪装成
|
|
38
|
+
* 「没有」。单列出来让读侧自判可信度——宁少不脏。
|
|
32
39
|
* · compressible = 可压掉行数:同 type、同 scope 三维且内容哈希完全相同的归档
|
|
33
40
|
* 行里,多出来的那些(每组留一行)。用内容哈希而不是向量近重复,是因为回收
|
|
34
41
|
* 动作本身会清掉归档行向量(clearArchivedEmbeddings)——指标不能建在它自己
|
|
@@ -85,12 +92,19 @@ export function recallStats(store, options = {}) {
|
|
|
85
92
|
// (type + 哈希 + scope 三维)——可压掉的必须是「本来就会被判成同一件事」的行。
|
|
86
93
|
let archivedTotal = 0;
|
|
87
94
|
let archivedInWindow = 0;
|
|
95
|
+
let archivedUndated = 0;
|
|
88
96
|
const hashGroups = new Map();
|
|
89
97
|
for (const m of memories) {
|
|
90
98
|
if (m.archived) {
|
|
91
99
|
archivedTotal += 1;
|
|
92
|
-
|
|
93
|
-
|
|
100
|
+
// 精确口径:只认 archived_at。NULL = 本列落地前的存量归档行,时刻不可追溯
|
|
101
|
+
// (见文件头 undated 段)——不拿 updated_at 顶替,那正是被本批推翻的代理口径。
|
|
102
|
+
const archivedMs = Date.parse(m.archived_at ?? "");
|
|
103
|
+
if (Number.isFinite(archivedMs)) {
|
|
104
|
+
if (archivedMs >= sinceMs) archivedInWindow += 1;
|
|
105
|
+
} else {
|
|
106
|
+
archivedUndated += 1;
|
|
107
|
+
}
|
|
94
108
|
if (m.content_hash) {
|
|
95
109
|
const key = [
|
|
96
110
|
m.type ?? "",
|
|
@@ -161,6 +175,7 @@ export function recallStats(store, options = {}) {
|
|
|
161
175
|
total: archivedTotal,
|
|
162
176
|
addedInWindow: archivedInWindow,
|
|
163
177
|
perDay: Math.round((archivedInWindow / windowDays) * 100) / 100,
|
|
178
|
+
undated: archivedUndated,
|
|
164
179
|
compressible: { rows: compressibleRows, groups: compressibleGroups }
|
|
165
180
|
},
|
|
166
181
|
zombie: {
|