@modusensus/dsh-mneme 0.8.6 → 0.8.8
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 +19 -148
- package/bin/cli.mjs +689 -603
- package/lib/api-standalone.js +57 -1
- package/lib/api.js +34 -11
- package/lib/client.js +349 -61
- package/lib/config.js +118 -7
- package/lib/content-hash.js +54 -0
- package/lib/continuity.js +220 -0
- package/lib/document-index.js +94 -0
- package/lib/document.js +93 -11
- package/lib/dream.js +138 -39
- package/lib/guide.js +18 -0
- package/lib/index.js +87 -12
- package/lib/inject.js +6 -2
- package/lib/lang.js +15 -4
- package/lib/maintenance.js +142 -0
- package/lib/mirror.js +147 -15
- package/lib/peak-hours.js +117 -0
- package/lib/recall-stats.js +52 -2
- package/lib/scope.js +34 -6
- package/lib/service.js +86 -7
- package/lib/settings.js +16 -2
- package/lib/store.js +318 -21
- package/lib/summarize.js +52 -113
- package/lib/tools.js +37 -11
- package/lib/write-admission.js +259 -0
- package/package.json +4 -4
- package/src/api-standalone.js +57 -1
- package/src/api.js +34 -11
- package/src/config.js +118 -7
- package/src/content-hash.js +54 -0
- package/src/continuity.js +220 -0
- package/src/document-index.js +94 -0
- package/src/document.js +93 -11
- package/src/dream.js +138 -39
- package/src/guide.js +18 -0
- package/src/index.js +87 -12
- package/src/inject.js +6 -2
- package/src/lang.js +15 -4
- package/src/maintenance.js +142 -0
- package/src/mirror.js +147 -15
- package/src/peak-hours.js +117 -0
- package/src/recall-stats.js +52 -2
- package/src/scope.js +34 -6
- package/src/service.js +86 -7
- package/src/settings.js +16 -2
- package/src/store.js +318 -21
- package/src/summarize.js +52 -113
- package/src/tools.js +37 -11
- package/src/write-admission.js +259 -0
- package/test/api.test.js +21 -3
- package/test/client.test.js +103 -6
- package/test/content-hash.test.js +204 -0
- package/test/continuity.test.js +333 -0
- package/test/document-dir.test.js +233 -0
- package/test/document.test.js +151 -3
- package/test/dream-failure-backoff.test.js +173 -0
- package/test/dream-peak-hours.test.js +194 -0
- package/test/inject-parent-gate.test.js +106 -0
- package/test/inject-pools.test.js +8 -3
- package/test/llm-audit.test.js +42 -0
- package/test/maintenance.test.js +391 -0
- package/test/mirror-types.test.js +208 -0
- package/test/reasoning-effort.test.js +6 -0
- package/test/recall-evals.test.js +18 -0
- package/test/recall-stats.test.js +66 -0
- package/test/summarize-reasoning-effort.test.js +189 -0
- package/test/write-admission.test.js +345 -0
package/src/summarize.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
2
2
|
import { STR, langOf } from "./lang.js";
|
|
3
|
+
// Issue #315:蒸馏链路的 effort 降级复用巩固侧的 withEffortFallback(拒绝
|
|
4
|
+
// 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)——
|
|
5
|
+
// 三条链路同一降级语义,不另造第二份实现。
|
|
6
|
+
import { withEffortFallback, describeStreamFailure, EFFORT_REJECT_RE } from "./dream.js";
|
|
3
7
|
|
|
4
8
|
// 编码记忆蒸馏 prompt(codingRetrospect 开启时启用):在通用记忆之外,额外提取
|
|
5
9
|
// 三类编码专属记忆,专治重复踩坑 / 遗忘被否决方案 / 丢失工程约束。字段仍沿用
|
|
@@ -256,117 +260,14 @@ function enqueueDistill(task, intervalMs = 0) {
|
|
|
256
260
|
return run;
|
|
257
261
|
}
|
|
258
262
|
|
|
259
|
-
// Issue #239(第 4
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const DAY_NAMES = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 };
|
|
268
|
-
|
|
269
|
-
function parseDayToken(token) {
|
|
270
|
-
const days = new Set();
|
|
271
|
-
const normalize = (value) => (/^\d$/.test(value) ? Number(value) : DAY_NAMES[value] ?? null);
|
|
272
|
-
for (const piece of token.split(",")) {
|
|
273
|
-
const matched = /^([a-z]{3}|\d)(?:-([a-z]{3}|\d))?$/.exec(piece.trim().toLowerCase());
|
|
274
|
-
if (!matched) return null;
|
|
275
|
-
const from = normalize(matched[1]);
|
|
276
|
-
const to = matched[2] === undefined ? from : normalize(matched[2]);
|
|
277
|
-
if (from === null || to === null || from < 1 || from > 7 || to < 1 || to > 7) return null;
|
|
278
|
-
// 支持跨周环绕(fri-mon):从 from 起逐天推进到 to,最多绕一圈。
|
|
279
|
-
for (let day = from; ; day = (day % 7) + 1) {
|
|
280
|
-
days.add(day);
|
|
281
|
-
if (day === to) break;
|
|
282
|
-
}
|
|
283
|
-
}
|
|
284
|
-
return days.size > 0 ? [...days].sort((a, b) => a - b) : null;
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
/** JS 的 getDay() 是 0=周日…6=周六;这里统一成 ISO(1=周一…7=周日)。 */
|
|
288
|
-
function isoDay(date) {
|
|
289
|
-
const day = date.getDay();
|
|
290
|
-
return day === 0 ? 7 : day;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
export function parsePeakSpec(spec) {
|
|
294
|
-
if (typeof spec !== "string" || spec.trim() === "") return null;
|
|
295
|
-
let rest = spec.trim();
|
|
296
|
-
let days = null;
|
|
297
|
-
// 星期前缀 = 第一个空白之前的部分,但**头段含冒号就不是前缀**(那是时段本身,
|
|
298
|
-
// 例如 "09:00-12:00, 14:00-18:00" 里的逗号空格)。前缀解析失败一律按未配置处理,
|
|
299
|
-
// 不做猜测——宁可不省,也不能误停。
|
|
300
|
-
const sep = rest.search(/\s/);
|
|
301
|
-
if (sep > 0) {
|
|
302
|
-
const head = rest.slice(0, sep);
|
|
303
|
-
if (!head.includes(":")) {
|
|
304
|
-
days = parseDayToken(head);
|
|
305
|
-
if (days === null) return null;
|
|
306
|
-
rest = rest.slice(sep).trim();
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
const windows = [];
|
|
310
|
-
for (const part of rest.split(",")) {
|
|
311
|
-
const matched = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(part);
|
|
312
|
-
if (!matched) return null;
|
|
313
|
-
const [sh, sm, eh, em] = [Number(matched[1]), Number(matched[2]), Number(matched[3]), Number(matched[4])];
|
|
314
|
-
if (sh > 23 || eh > 23 || sm > 59 || em > 59) return null;
|
|
315
|
-
const start = sh * 60 + sm;
|
|
316
|
-
const end = eh * 60 + em;
|
|
317
|
-
if (start === end) return null;
|
|
318
|
-
windows.push({ start, end });
|
|
319
|
-
}
|
|
320
|
-
return windows.length > 0 ? { days, windows } : null;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* 该时刻是否落在高峰内。跨零点段(start > end)按「窗口所属的那一天」认星期:
|
|
325
|
-
* `mon-fri 23:00-06:00` 的周六 02:00 属于周五开的那个窗口,仍算高峰。
|
|
326
|
-
*/
|
|
327
|
-
export function isInPeakWindow(date, spec) {
|
|
328
|
-
const parsed = parsePeakSpec(spec);
|
|
329
|
-
if (!parsed) return false;
|
|
330
|
-
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
331
|
-
const today = isoDay(date);
|
|
332
|
-
const yesterday = today === 1 ? 7 : today - 1;
|
|
333
|
-
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
334
|
-
return parsed.windows.some(({ start, end }) => {
|
|
335
|
-
if (start < end) return minutes >= start && minutes < end && allowed(today);
|
|
336
|
-
return (minutes >= start && allowed(today)) || (minutes < end && allowed(yesterday));
|
|
337
|
-
});
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/**
|
|
341
|
-
* 高峰内则返回「距当前最近的一个高峰结束时刻」(择时补跑用),否则 null。
|
|
342
|
-
* 落在多个时段重叠处时取最早结束的那个——早跑不亏,晚跑才亏。
|
|
343
|
-
*/
|
|
344
|
-
export function nextOffPeakAt(date, spec) {
|
|
345
|
-
const parsed = parsePeakSpec(spec);
|
|
346
|
-
if (!parsed) return null;
|
|
347
|
-
const minutes = date.getHours() * 60 + date.getMinutes();
|
|
348
|
-
const today = isoDay(date);
|
|
349
|
-
const yesterday = today === 1 ? 7 : today - 1;
|
|
350
|
-
const allowed = (day) => !parsed.days || parsed.days.includes(day);
|
|
351
|
-
let bestDelta = null;
|
|
352
|
-
const consider = (delta) => {
|
|
353
|
-
if (bestDelta === null || delta < bestDelta) bestDelta = delta;
|
|
354
|
-
};
|
|
355
|
-
for (const { start, end } of parsed.windows) {
|
|
356
|
-
if (start < end) {
|
|
357
|
-
if (minutes >= start && minutes < end && allowed(today)) consider(end - minutes);
|
|
358
|
-
continue;
|
|
359
|
-
}
|
|
360
|
-
if (minutes >= start && allowed(today)) consider((1440 - minutes) + end);
|
|
361
|
-
else if (minutes < end && allowed(yesterday)) consider(end - minutes);
|
|
362
|
-
}
|
|
363
|
-
if (bestDelta === null) return null;
|
|
364
|
-
const at = new Date(date.getTime());
|
|
365
|
-
at.setSeconds(0, 0);
|
|
366
|
-
at.setMinutes(at.getMinutes() + bestDelta);
|
|
367
|
-
return at;
|
|
368
|
-
}
|
|
369
|
-
|
|
263
|
+
// Issue #239(第 4 项,错峰队列):高峰时段解析与判定已抽到独立零依赖模块
|
|
264
|
+
// src/peak-hours.js(PR #320 review:#316 后 summarize 反向依赖 dream,dream 镜像
|
|
265
|
+
// 错峰再 import 本文件会成真循环;时段调度本就不是蒸馏私有语义)。这里 re-export
|
|
266
|
+
// 兼容既有调用方与测试(test/summarize.test.js 从本文件 import),零改动。
|
|
267
|
+
// 注意 re-export 不引入本地作用域——本文件 331/387 行仍调用这两个函数,
|
|
268
|
+
// import + re-export 两行都要。
|
|
269
|
+
import { parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "./peak-hours.js";
|
|
270
|
+
export { parsePeakSpec, isInPeakWindow, nextOffPeakAt };
|
|
370
271
|
export function createSummarizer(ctx, service, config, deps = {}) {
|
|
371
272
|
if (!config.autoSummarize) return { dispose: () => {} };
|
|
372
273
|
|
|
@@ -584,6 +485,11 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
584
485
|
};
|
|
585
486
|
}
|
|
586
487
|
|
|
488
|
+
// Issue #315:蒸馏思考强度。'none'/未配置都不发送字段(服务商默认生效,
|
|
489
|
+
// 行为与历史版本一致);off/low/medium/high 原样传递。
|
|
490
|
+
const summarizeEffort = config.summarizeReasoningEffort;
|
|
491
|
+
const withEffort = typeof summarizeEffort === "string" && summarizeEffort !== "none";
|
|
492
|
+
|
|
587
493
|
const options = {
|
|
588
494
|
provider: route.provider,
|
|
589
495
|
model: route.model,
|
|
@@ -597,15 +503,30 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
597
503
|
// 智能调速器:整段蒸馏 LLM 调用进全局串行队列,按间隔分批放行;429 时
|
|
598
504
|
// 指数退避自动重试,全程对用户透明,不把 429 错误码直接抛出去。
|
|
599
505
|
const intervalMs = config.distillRateLimitIntervalMs ?? 1000;
|
|
600
|
-
|
|
506
|
+
// Issue #315:流式失败原因暂存槽。蒸馏的流失败以 aborted 结果而非 throw
|
|
507
|
+
// 返回,withEffortFallback 靠它甄别「effort 被拒收」型 aborted(dream 侧
|
|
508
|
+
// runNarratives 的 streamFailure 同款模式)。
|
|
509
|
+
let streamFailure = "";
|
|
510
|
+
const runDistill = (withEffort) => {
|
|
511
|
+
streamFailure = "";
|
|
512
|
+
return enqueueDistill(async () => {
|
|
601
513
|
const retries = config.distillRateLimitRetries ?? 3;
|
|
602
514
|
const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000;
|
|
515
|
+
// 每次尝试独立拼 effort 字段:降级重试(withEffort=false)必须真的
|
|
516
|
+
// 不带 reasoningEffort,不能复用带字段的同一 options 对象。
|
|
517
|
+
const callOptions = withEffort && summarizeEffort
|
|
518
|
+
? { ...options, reasoningEffort: summarizeEffort }
|
|
519
|
+
: options;
|
|
603
520
|
for (let attempt = 0; ; attempt++) {
|
|
521
|
+
// 每次尝试重置审计状态:effort 拒收/429 的失败 attempt 会把 status 置
|
|
522
|
+
// error,若后续重试成功,审计必须记录最终成功而不是残留第一次的失败
|
|
523
|
+
// (否则「摘要成功但审计报失败」,污染 llm_audit_logs 统计)。
|
|
524
|
+
if (audit) audit.status = "success";
|
|
604
525
|
const assembler = new BlockAssembler();
|
|
605
526
|
let text = "";
|
|
606
527
|
let aborted = false;
|
|
607
528
|
try {
|
|
608
|
-
for await (const chunk of ctx.llm.stream(
|
|
529
|
+
for await (const chunk of ctx.llm.stream(callOptions)) {
|
|
609
530
|
if (STREAM_CHUNK_TYPES.has(chunk.type)) assembler.push(toProtocolChunk(chunk));
|
|
610
531
|
if (chunk.type === "text-delta") {
|
|
611
532
|
text += chunk.text ?? chunk.delta ?? "";
|
|
@@ -627,6 +548,9 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
627
548
|
if (isRateLimited(chunk.reason ?? chunk)) {
|
|
628
549
|
throw Object.assign(new Error("rate limited"), { status: 429 });
|
|
629
550
|
}
|
|
551
|
+
// Issue #315:把失败原因原样留给外层的 effort 甄别
|
|
552
|
+
// (withEffortFallback 只认「effort 被拒收」型失败)。
|
|
553
|
+
streamFailure = describeStreamFailure(chunk.reason ?? chunk);
|
|
630
554
|
if (audit) {
|
|
631
555
|
audit.status = "error";
|
|
632
556
|
audit.errorMessage = `llm stream ${reasonKind}`;
|
|
@@ -666,6 +590,21 @@ export function createSummarizer(ctx, service, config, deps = {}) {
|
|
|
666
590
|
}
|
|
667
591
|
}
|
|
668
592
|
}, intervalMs);
|
|
593
|
+
};
|
|
594
|
+
// Issue #315:effort 被拒收时自动去掉字段重试一次(dream/sleep/entity
|
|
595
|
+
// 同一降级策略,withEffortFallback 共享)。蒸馏的流失败以 aborted 结果
|
|
596
|
+
// 而非 throw/undefined 返回,因此甄别在这里做:effort 型失败折叠成
|
|
597
|
+
// undefined,让 withEffortFallback 走 streamFailure 甄别分支触发重试;
|
|
598
|
+
// 非 effort 的 aborted 原样返回,仍走既有的 abortedRun 路径。
|
|
599
|
+
const result = await withEffortFallback(
|
|
600
|
+
ctx,
|
|
601
|
+
summarizeEffort,
|
|
602
|
+
() => runDistill(withEffort).then((r) =>
|
|
603
|
+
(r?.aborted && EFFORT_REJECT_RE.test(streamFailure)) ? undefined : r),
|
|
604
|
+
() => runDistill(false),
|
|
605
|
+
() => streamFailure
|
|
606
|
+
);
|
|
607
|
+
const { text, assembledText, aborted } = result ?? { text: "", assembledText: "", aborted: true };
|
|
669
608
|
if (aborted) {
|
|
670
609
|
abortedRun = true;
|
|
671
610
|
return;
|
package/src/tools.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
2
|
-
import { createScopeResolver, normalizeExplicitScope } from "./scope.js";
|
|
2
|
+
import { createScopeResolver, normalizeExplicitScope, sessionKeyOf } from "./scope.js";
|
|
3
3
|
import { describeLocalRuntime, resolveRuntimeEntry } from "./runtime/loader.js";
|
|
4
4
|
import { defaultRuntimeDir } from "./runtime/layout.js";
|
|
5
5
|
import { hostModulesDir, loadRuntimeManifest, provisionRuntime } from "./runtime/provision.js";
|
|
6
6
|
import { matchesPlatform } from "./runtime/closure.js";
|
|
7
7
|
import { verifyPayload } from "./runtime/verify.js";
|
|
8
|
-
import { TOOL_GUIDE } from "./guide.js";
|
|
8
|
+
import { TOOL_GUIDE, CONTINUITY_TOOL_RULE } from "./guide.js";
|
|
9
|
+
import { injectChildEnabled } from "./config.js";
|
|
9
10
|
|
|
10
11
|
const TEXT_OUTPUT = (text) => [{ type: "text", text }];
|
|
11
12
|
// Per-registry tool-name registry: guards against duplicate registration on
|
|
@@ -84,11 +85,21 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
84
85
|
// flag 关闭时恒返回 null——写入不标注,检索不加权,行为与 A1 前完全一致。
|
|
85
86
|
// logger 透传:registry 反查失败时 warnOnce 才有出口(否则静默降级无观测)。
|
|
86
87
|
const resolveSessionScope = createScopeResolver({ ctx, config, logger: ctx.logger });
|
|
87
|
-
// #249
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
88
|
+
// #249 能力说明(injectGuidanceEnabled):开启时给「何时用」最有歧义的两个
|
|
89
|
+
// 工具补一句判断指引。工具描述常驻、不进每轮上下文,这个位子零注入成本;
|
|
90
|
+
// 关闭时描述逐字节不变,也不动其他工具的文案。
|
|
91
|
+
// 它是注入父开关的子项(#249 第二批):`autoInject` 关掉时这里同样不生效——
|
|
92
|
+
// 工具描述是这个子项唯一住在注入器之外的落点,不在闸门内就漏了。
|
|
93
|
+
// #249 N3 降级路径(continuityRescueEnabled):宿主若不提供可挂钩的压缩前时机,
|
|
94
|
+
// 双落点里的注入那一半就没有触发者,这时把规则交给 agent 自判(见 CONTINUITY_TOOL_RULE)。
|
|
95
|
+
// 与能力说明各自独立:任一开关关闭时描述逐字节不变。
|
|
96
|
+
// 本批不做宿主能力探测(没有可靠的探测口),所以子开关打开时这条规则常驻,与自动双落点并存。
|
|
97
|
+
const withToolGuide = (name, description) => {
|
|
98
|
+
let text = description;
|
|
99
|
+
if (injectChildEnabled(config, "injectGuidanceEnabled") && TOOL_GUIDE[name]) text += TOOL_GUIDE[name];
|
|
100
|
+
if (injectChildEnabled(config, "continuityRescueEnabled") && CONTINUITY_TOOL_RULE[name]) text += CONTINUITY_TOOL_RULE[name];
|
|
101
|
+
return text;
|
|
102
|
+
};
|
|
92
103
|
|
|
93
104
|
// 复核项 4(issue #170):strictScope 下他 scope(explicit)的行对工具侧按
|
|
94
105
|
// 「不存在」处理——update/delete 与 memory_get 同款无存在性泄漏。strictScope
|
|
@@ -162,6 +173,10 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
162
173
|
? { value: normalizeExplicitScope(args.workspace_scope), source: "explicit" }
|
|
163
174
|
: autoStamping && scope ? { value: scope.workspace_scope, source: "auto" } : null;
|
|
164
175
|
const { action, memory } = service.saveWithDedupe({
|
|
176
|
+
// #254 写入准入(第一阶段只计量)的计数单位:会话键作为瞬时字段随写入
|
|
177
|
+
// 传递(与 _mergeInto / _overwrite 同款约定,不落库)。缺会话身份的宿主
|
|
178
|
+
// 返回 null = 这次写入不进预算。
|
|
179
|
+
_sessionKey: sessionKeyOf(exec),
|
|
165
180
|
type: args.type,
|
|
166
181
|
title: args.title,
|
|
167
182
|
content: args.content,
|
|
@@ -482,7 +497,11 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
482
497
|
"intersects evidence with real memory ids (all-fabricated evidence is rejected; unknown ids are dropped and the row " +
|
|
483
498
|
"is tagged evidence_degraded), and dedupes: re-registering the same path or title supersedes the old row (the old " +
|
|
484
499
|
"file is never touched; content_history stays traceable), while a merely near-duplicate summary of a different " +
|
|
485
|
-
"document row is rejected — update that row instead.
|
|
500
|
+
"document row is rejected — update that row instead. On success the absorbed atomic evidence rows leave the active " +
|
|
501
|
+
"face in the same transaction (recoverable, nothing deleted); constraint/preference rows and other " +
|
|
502
|
+
"document/summary rows are never auto-archived, and keep_evidence_active: true opts out. Ids this same document " +
|
|
503
|
+
"absorbed on an earlier version still count as its evidence, so re-registering a new version is not read as " +
|
|
504
|
+
"fabricated evidence. Use for 'where is the conclusion doc for this project?' " +
|
|
486
505
|
"lookups; atomic facts still go to memory_save.",
|
|
487
506
|
parameters: {
|
|
488
507
|
path: { type: "string", required: true, description: "Absolute path of the document file (~ is expanded); must already exist as a non-empty regular file. The full text stays agent-owned — this pipeline never touches it." },
|
|
@@ -490,7 +509,8 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
490
509
|
summary: { type: "string", required: true, description: "One-paragraph summary stored in the DB and used for injection (any language)" },
|
|
491
510
|
tags: { type: "array", items: { type: "string" }, description: "Optional tags (English recommended)" },
|
|
492
511
|
importance: { type: "integer", description: "1-5 (default 3); the summary row injects at the next-priority tier within documentInjectBudget when importance >= threshold" },
|
|
493
|
-
evidence: { type: "array", items: { type: "string" }, description: "Memory ids this document is grounded in; each is verified against the store
|
|
512
|
+
evidence: { type: "array", items: { type: "string" }, description: "Memory ids this document is grounded in (atomic facts, not other pointer rows); each is verified against the store — fabricated evidence is rejected, unknown/archived ids are dropped and the row is tagged evidence_degraded, except ids this same document absorbed on an earlier version, which stay its evidence. On success the absorbed atomic rows leave the active face unless keep_evidence_active is true." },
|
|
513
|
+
keep_evidence_active: { type: "boolean", description: "Opt out of archiving the evidence rows absorbed by this document. Default false: after a successful registration the absorbed atomic rows leave the active face (recoverable, nothing deleted; constraint/preference rows and other document/summary rows are never auto-archived)." },
|
|
494
514
|
source: { type: "string", description: "Optional provenance" },
|
|
495
515
|
sensitivity: { type: "string", description: "Optional sensitivity label (free-form, e.g. personal). Part of the supersede matching key — same path/title with a different sensitivity stays a separate document." },
|
|
496
516
|
agent_scope: { type: "string", description: "Optional explicit agent-scope declaration (issue #170): 'global' or '*' makes this document visible to every agent; any other value narrows it to that label. Overrides the automatic carrier label for this write; honored even when automatic scope labeling is disabled." },
|
|
@@ -506,12 +526,13 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
506
526
|
superseded_id: { type: "string" },
|
|
507
527
|
evidence_kept: { type: "integer", required: true },
|
|
508
528
|
evidence_dropped: { type: "integer", required: true },
|
|
529
|
+
evidence_archived: { type: "integer", required: true },
|
|
509
530
|
degraded: { type: "boolean", required: true }
|
|
510
531
|
}
|
|
511
532
|
},
|
|
512
533
|
render: (_args, value) => {
|
|
513
534
|
const sup = value.superseded_id ? ` (supersedes ${value.superseded_id})` : "";
|
|
514
|
-
const ev = ` | evidence: ${value.evidence_kept} kept, ${value.evidence_dropped} dropped${value.degraded ? " [degraded]" : ""}`;
|
|
535
|
+
const ev = ` | evidence: ${value.evidence_kept} kept, ${value.evidence_dropped} dropped, ${value.evidence_archived} archived${value.degraded ? " [degraded]" : ""}`;
|
|
515
536
|
return TEXT_OUTPUT(`document ${value.action}: ${value.id}${sup}${ev}`);
|
|
516
537
|
}
|
|
517
538
|
},
|
|
@@ -550,13 +571,18 @@ export function createTools(ctx, service, config, embedder) {
|
|
|
550
571
|
...(args.sensitivity !== undefined ? { sensitivity: args.sensitivity } : {}),
|
|
551
572
|
...(agentLabel ? { agent_scope: agentLabel.value, agent_scope_source: agentLabel.source } : {}),
|
|
552
573
|
...(workspaceLabel ? { workspace_scope: workspaceLabel.value, workspace_scope_source: workspaceLabel.source } : {})
|
|
553
|
-
}, {
|
|
574
|
+
}, {
|
|
575
|
+
hiddenEvidenceIds: hiddenEvidence,
|
|
576
|
+
// 默认开着归档(#275 拍板 5);agent 显式要保留活跃面时走 keep_evidence_active。
|
|
577
|
+
archiveEvidence: args.keep_evidence_active !== true
|
|
578
|
+
});
|
|
554
579
|
return {
|
|
555
580
|
action: result.action,
|
|
556
581
|
id: result.memory.id,
|
|
557
582
|
...(result.superseded ? { superseded_id: result.superseded.id } : {}),
|
|
558
583
|
evidence_kept: result.evidence_kept,
|
|
559
584
|
evidence_dropped: result.evidence_dropped,
|
|
585
|
+
evidence_archived: result.evidence_archived,
|
|
560
586
|
degraded: result.degraded
|
|
561
587
|
};
|
|
562
588
|
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// #254 写入准入(第一阶段:只计量,不拦截)。
|
|
2
|
+
//
|
|
3
|
+
// 为什么先只计量:会话写入预算的 N 与同话题冷却的 X 在没有真实分布时拍任何数字
|
|
4
|
+
// 都是误杀面(维护者 2026-09-23 拍板「先记录不拦,由遥测定」)。所以这一阶段只把
|
|
5
|
+
// 两个闸门的原始测量点写进 llm_audit_logs(status='skipped'、metadata.gate='g1'
|
|
6
|
+
// 且同话题重复时附 metadata.g2),不做判定、不改任何写入行为;第二阶段把拦截打开
|
|
7
|
+
// 时才引入阈值与二次确认(memory_save({confirm:true}))。
|
|
8
|
+
//
|
|
9
|
+
// 为什么复用 llm_audit_logs:它是仓库既有的「后台动作回执」表(bookkeeping,不
|
|
10
|
+
// 触发写钩子,见 store.js 的表注释)。第二阶段落地时「第一次 skipped/gate →
|
|
11
|
+
// 第二次 confirm:true 放行」在审计里成对出现,因果可回放。
|
|
12
|
+
//
|
|
13
|
+
// 一行一个新建行(不是一行一个信号):这样「会话内新建条数」就是按 session_key
|
|
14
|
+
// 的直接计数,不需要任何 JSON 解析;g2 命中时作为该行的 metadata.g2 附带,不做第
|
|
15
|
+
// 二行。测量点只取在新行上——并入已有行是去重机制在正常工作,既不进预算也不记行。
|
|
16
|
+
// 会话身份来自工具 exec(scope.js › sessionKeyOf):没有会话身份的写入(dream /
|
|
17
|
+
// summarize / import / organize)不进预算,预算管的是「一次对话里写飞了」。
|
|
18
|
+
//
|
|
19
|
+
// 穿透口(与 #275 拍板 5 的 pinned 豁免同口径):constraint / preference 是 #249
|
|
20
|
+
// 第一批立的逐字保真池,永不进预算;仍记一行审计(metadata.exempt='pinned')以便
|
|
21
|
+
// 观察穿透频率。穿透行既不参与 g2 判定,也不推进同话题的时间基准——它整个不在闸门
|
|
22
|
+
// 里,成为下一次比较的基准会把 g2 的样本混进穿透流量。
|
|
23
|
+
//
|
|
24
|
+
// 内容哈希(exact duplicate,第三类测量点,维护者 2026-09-24 拍板):归一化口径定在
|
|
25
|
+
// content-hash.js,命中即「同一内容又被写了一次」。判据用哈希而不是相似度——相似度
|
|
26
|
+
// 阈值在同一件事换个说法 / 不同的事共享话题词之间来回挪,只会换一种错法。候选集先按
|
|
27
|
+
// type 与 scope 三维等值收窄再比哈希(不是全表扫),并把归档行一并纳入:「出口止体积、
|
|
28
|
+
// 不止重复」,被质量闸归档的同一个事实不在 saveWithDedupe 的候选集里(store.list 默认
|
|
29
|
+
// 排除归档),同样的内容再写一次就是一条新行,这正是这条信号要量的穿透。两个穿透口与
|
|
30
|
+
// pinned 豁免同口径:pinned 在这里返回得更早,连候选集查询都不发。
|
|
31
|
+
//
|
|
32
|
+
// 信号跟着同一行审计走,所以内容哈希与 g1/g2 覆盖同一批写入(会话内新建行);无会话
|
|
33
|
+
// 身份的系统写入(dream / summarize / import / organize)要另立行形状,不在本批。
|
|
34
|
+
//
|
|
35
|
+
// 与 llmAudit.enabled 的关系:那个开关同时关掉审计行的启动期清理(index.js 的
|
|
36
|
+
// deleteOldLlmAudits),所以关掉时本模块一行都不写,否则就是在无保留期的表里做
|
|
37
|
+
// 按写入频次增长。
|
|
38
|
+
import { PINNED_MEMORY_TYPES } from "./service.js";
|
|
39
|
+
import { contentHashOf } from "./content-hash.js";
|
|
40
|
+
|
|
41
|
+
// 审计口径(llm_audit_logs 的既有列):trigger_source = 组件名,operation_type =
|
|
42
|
+
// 动作名,status='skipped' = 本行没有产生任何 LLM 花费(与 summarize 的间隔门、
|
|
43
|
+
// 尖峰门同款读法)。
|
|
44
|
+
export const ADMISSION_TRIGGER_SOURCE = "writeAdmission";
|
|
45
|
+
export const ADMISSION_OPERATION_TYPE = "write_admission";
|
|
46
|
+
export const ADMISSION_GATE_SESSION_BUDGET = "g1";
|
|
47
|
+
|
|
48
|
+
// 首次见到某会话时的回填深度:话题表由审计行重建,只回填最近这么多行。更早的
|
|
49
|
+
// 话题会漏(测量口径可接受),换来的是一次查询与有界的常驻内存。
|
|
50
|
+
export const TOPIC_LOOKBACK_ROWS = 200;
|
|
51
|
+
|
|
52
|
+
// 话题锚只收机械可判的两类:issue/PR 引用与文件路径。两条都带前缀或分隔符,误报
|
|
53
|
+
// 面小;自由文本里看不出确定性的「同话题」,硬判只会把误杀面提前引进来(#254 已
|
|
54
|
+
// 定:计量信号用确定性锚,不用相似度阈值——同一件事换个说法就掉下去,不同的事
|
|
55
|
+
// 共享话题词又顶上来,阈值怎么挪都是在两种误判之间来回)。
|
|
56
|
+
// 两处收紧都来自误报样本:`(?<![#\w])` 挡掉 Markdown 标题 `###1` 与紧跟标识符的
|
|
57
|
+
// 井号;扩展名要求首字符是字母,挡掉 `3.5/2.0` 这类比值(否则一个算式就成了话题)。
|
|
58
|
+
const ISSUE_REF = /(?<![#\w])#\d{1,7}\b/g;
|
|
59
|
+
const FILE_PATH = /(?:[\w.-]+[\\/])+[\w.-]+\.[A-Za-z][A-Za-z0-9]{0,5}\b/g;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 写入文本里的确定性话题锚:小写归一 + 路径分隔符统一为 `/` + 去重 + 排序。
|
|
63
|
+
* 同一话题在不同轮次必须逐字节相等,才能用等值比较而不是相似度比较——所以
|
|
64
|
+
* `src\service.js` 与 `src/service.js` 必须归到同一个锚。
|
|
65
|
+
* @param {{title?: string, content?: string, tags?: string[]}} memory
|
|
66
|
+
* @returns {string[]} 排序后的话题锚
|
|
67
|
+
*/
|
|
68
|
+
export function extractTopicKeys(memory) {
|
|
69
|
+
const text = [memory?.title, memory?.content, ...(Array.isArray(memory?.tags) ? memory.tags : [])]
|
|
70
|
+
.filter((s) => typeof s === "string")
|
|
71
|
+
.join("\n");
|
|
72
|
+
const keys = new Set();
|
|
73
|
+
const add = (raw) => keys.add(raw.toLowerCase().replace(/\\/g, "/"));
|
|
74
|
+
for (const m of text.matchAll(ISSUE_REF)) add(m[0]);
|
|
75
|
+
for (const m of text.matchAll(FILE_PATH)) add(m[0]);
|
|
76
|
+
return [...keys].sort();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 写入准入(#254)。返回的两个方法就是三个消费方共用的那份接口:
|
|
81
|
+
* evaluate 给出决策形状(阶段一恒 allow),record 把测量点写成审计行。
|
|
82
|
+
* 阶段二在 evaluate 里引入阈值与 confirm 分支;#249 的注入提示与 #275 的水位计数
|
|
83
|
+
* 都读同一份 decision/审计行,而不是各写一套判定。
|
|
84
|
+
*
|
|
85
|
+
* @param {object} deps
|
|
86
|
+
* @param {object} deps.store 存储层(listLlmAudits / saveLlmAudit)
|
|
87
|
+
* @param {object} [deps.config] 已解析配置(读 llmAudit.enabled)
|
|
88
|
+
* @param {object} [deps.logger]
|
|
89
|
+
* @param {() => number} [deps.now] 时钟注入(测试用)
|
|
90
|
+
*/
|
|
91
|
+
export function createWriteAdmission({ store, config, logger, now = Date.now } = {}) {
|
|
92
|
+
// sessionKey → Map<话题锚, 最近一次新建行时刻(ms)>。真相源是审计行,这里只是它的
|
|
93
|
+
// 增量视图:进程重启后按需回填。FIFO 上限防长驻进程内存无界(同 store.js 的
|
|
94
|
+
// embedding 解析缓存口径)。
|
|
95
|
+
const topicTables = new Map();
|
|
96
|
+
const SESSION_CACHE_MAX = 200;
|
|
97
|
+
|
|
98
|
+
const warn = (msg) => {
|
|
99
|
+
// 计量是旁路:任何一环失败都不能影响写入,日志自身故障也一样。
|
|
100
|
+
try {
|
|
101
|
+
logger?.warn?.(msg);
|
|
102
|
+
} catch { /* ignore */ }
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
/** 某会话的话题表(首见时从审计行回填,之后走内存增量)。 */
|
|
106
|
+
function topicTable(sessionKey) {
|
|
107
|
+
const cached = topicTables.get(sessionKey);
|
|
108
|
+
if (cached) return cached;
|
|
109
|
+
const table = new Map();
|
|
110
|
+
try {
|
|
111
|
+
const rows = store?.listLlmAudits?.({ sessionKey, limit: TOPIC_LOOKBACK_ROWS }) ?? [];
|
|
112
|
+
for (const row of rows) {
|
|
113
|
+
const metadata = row?.metadata;
|
|
114
|
+
if (!metadata || typeof metadata !== "object" || !Array.isArray(metadata.topics)) continue;
|
|
115
|
+
// 穿透行(pinned)整个不在闸门里,也就不该进基准表——只挡内存路径不挡回填,
|
|
116
|
+
// 重启后口径就变了(同一个话题会因为一条穿透行而被判成重复)。
|
|
117
|
+
if (metadata.exempt) continue;
|
|
118
|
+
const at = Date.parse(row.timestamp);
|
|
119
|
+
// 坏时间戳跳过:落到 0 会让 gap 变成几十年,一个离群值就能带偏按分位定的 X。
|
|
120
|
+
if (!Number.isFinite(at)) continue;
|
|
121
|
+
for (const topic of metadata.topics) {
|
|
122
|
+
// 审计行按时间倒序返回,先到的那条就是该话题最近一次写入——后来的不覆盖。
|
|
123
|
+
if (!table.has(topic)) table.set(topic, at);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (e) {
|
|
127
|
+
// 回填失败降级为「本会话此前的话题不可见」:漏一个 g2 测量点,不影响写入。
|
|
128
|
+
warn(`[dsh-mneme] write admission topic backfill failed: ${String(e)}`);
|
|
129
|
+
}
|
|
130
|
+
if (topicTables.size >= SESSION_CACHE_MAX && !topicTables.has(sessionKey)) {
|
|
131
|
+
topicTables.delete(topicTables.keys().next().value);
|
|
132
|
+
}
|
|
133
|
+
topicTables.set(sessionKey, table);
|
|
134
|
+
return table;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 内容哈希候选集查询(#254 第三类测量点)。失败降级为「没命中」:漏一个测量点,
|
|
139
|
+
* 不影响写入,也不让计量反噬。
|
|
140
|
+
* 多条命中时的取舍(活区优先):有活跃/未遗忘的命中就报它(判据是「去重候选集本该
|
|
141
|
+
* 拦住」),只有归档/遗忘行命中才报它们。store 侧已把活跃行排在窗口头部,这里的 find
|
|
142
|
+
* 是防御性重复:排序口径将来再变,本判据也不跟着变。
|
|
143
|
+
* archived 与 forgotten 分开回:两者都是「出口」,但穿透含义不同——只命中已遗忘行时
|
|
144
|
+
* 若只回 archived=false,读审计的人会以为库里有活跃重复;而 saveWithDedupe 的候选集
|
|
145
|
+
* 本来就排除遗忘行(store.list 默认 includeForgotten=false),这类命中同样是穿透。
|
|
146
|
+
* @returns {{memory_id: string, archived: boolean, forgotten: boolean}|null}
|
|
147
|
+
*/
|
|
148
|
+
function lookupContentDup(memory) {
|
|
149
|
+
try {
|
|
150
|
+
const hash = contentHashOf(memory);
|
|
151
|
+
if (!hash) return null;
|
|
152
|
+
const hits = store?.findContentHashMatches?.({
|
|
153
|
+
type: memory?.type,
|
|
154
|
+
hash,
|
|
155
|
+
agent_scope: memory?.agent_scope,
|
|
156
|
+
workspace_scope: memory?.workspace_scope,
|
|
157
|
+
sensitivity: memory?.sensitivity
|
|
158
|
+
}) ?? [];
|
|
159
|
+
const hit = hits.find((h) => !h.archived && !h.forgotten) ?? hits[0];
|
|
160
|
+
return hit ? { memory_id: hit.id, archived: hit.archived === true, forgotten: hit.forgotten === true } : null;
|
|
161
|
+
} catch (e) {
|
|
162
|
+
warn(`[dsh-mneme] write admission content hash lookup failed: ${String(e)}`);
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 阶段一决策:恒 allow(本模块里没有阈值),只算出三个闸门的测量点。返回形状一次
|
|
169
|
+
* 定死,阶段二只往里加分支、不改字段:
|
|
170
|
+
* decision — "allow" | "confirm"(阶段一只可能 allow)
|
|
171
|
+
* gate — "g1" | null(null = 这次写入不进预算,不记行)
|
|
172
|
+
* topics — 本次写入的确定性话题锚
|
|
173
|
+
* repeat — {topic, gapMs} | null(g2 的命中面)
|
|
174
|
+
* dup — {memory_id, archived, forgotten} | null(内容哈希的命中面)
|
|
175
|
+
* exempt — null | "pinned"
|
|
176
|
+
* @param {{memory: object, sessionKey?: string|null}} input
|
|
177
|
+
*/
|
|
178
|
+
function evaluate({ memory, sessionKey } = {}) {
|
|
179
|
+
const verdict = { decision: "allow", gate: null, topics: [], repeat: null, dup: null, exempt: null };
|
|
180
|
+
// 无会话身份 = 系统写入(dream / summarize / import / organize),不进预算。
|
|
181
|
+
if (!sessionKey) return verdict;
|
|
182
|
+
// 审计关掉时不记行、也不推进话题表(见文件头:那个开关连启动期清理一起关掉)。
|
|
183
|
+
if (config?.llmAudit?.enabled === false) return verdict;
|
|
184
|
+
const topics = extractTopicKeys(memory);
|
|
185
|
+
const pinned = PINNED_MEMORY_TYPES.has(String(memory?.type ?? ""));
|
|
186
|
+
if (pinned) return { ...verdict, gate: ADMISSION_GATE_SESSION_BUDGET, topics, exempt: "pinned" };
|
|
187
|
+
const dup = lookupContentDup(memory);
|
|
188
|
+
const table = topicTable(sessionKey);
|
|
189
|
+
const at = now();
|
|
190
|
+
let repeat = null;
|
|
191
|
+
for (const topic of topics) {
|
|
192
|
+
if (!table.has(topic)) continue;
|
|
193
|
+
const gapMs = Math.max(0, at - table.get(topic));
|
|
194
|
+
// 同一行可能命中多个锚:取最近的那次(间隔最短=最有价值的那次重复)。
|
|
195
|
+
if (!repeat || gapMs < repeat.gapMs) repeat = { topic, gapMs };
|
|
196
|
+
}
|
|
197
|
+
return { ...verdict, gate: ADMISSION_GATE_SESSION_BUDGET, topics, repeat, dup };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 把测量点落成审计行(一行一个新建行)。三个信号的读法:
|
|
202
|
+
* g1 会话写入预算:`GROUP BY session_key` 计数即得「会话内新建条数」分布;要剔
|
|
203
|
+
* 掉穿透行(pinned 不进预算)就加 `json_extract(metadata,'$.exempt') IS NULL`。
|
|
204
|
+
* g2 同话题冷却:`json_extract(metadata,'$.g2.gap_ms')` 即得「同话题新建行间隔」
|
|
205
|
+
* 分布。间隔算的是同一话题两次**新建行**之间——标题命中走并入的那次不在这里
|
|
206
|
+
* (并入正是冷却要做的事,已经做到了)。
|
|
207
|
+
* dup 内容哈希:`json_extract(metadata,'$.dup.memory_id')` 非空即「这条新行的内容
|
|
208
|
+
* 与某条既有行归一化后逐字节相同」;`$.dup.archived` 区分命中那条在活区还是
|
|
209
|
+
* 归档区——归档区命中就是去重候选集漏掉的那一类穿透。
|
|
210
|
+
* 计量绝不影响写入:任何异常只 warn。
|
|
211
|
+
* @returns {object[]} 落下的审计行(无测量点或失败时为空数组)
|
|
212
|
+
*/
|
|
213
|
+
function record({ sessionKey, verdict, memoryId } = {}) {
|
|
214
|
+
if (!sessionKey || !verdict || verdict.gate == null) return [];
|
|
215
|
+
const at = now();
|
|
216
|
+
const topics = Array.isArray(verdict.topics) ? verdict.topics : [];
|
|
217
|
+
const rows = [];
|
|
218
|
+
try {
|
|
219
|
+
rows.push(store.saveLlmAudit({
|
|
220
|
+
timestamp: new Date(at).toISOString(),
|
|
221
|
+
trigger_source: ADMISSION_TRIGGER_SOURCE,
|
|
222
|
+
operation_type: ADMISSION_OPERATION_TYPE,
|
|
223
|
+
// 不是模型调用,但该列 NOT NULL——用显式占位串,免得被当成某条路由的
|
|
224
|
+
// 花费来源统计进去。
|
|
225
|
+
model_id: "-",
|
|
226
|
+
status: "skipped",
|
|
227
|
+
related_memory_ids: memoryId ? [memoryId] : [],
|
|
228
|
+
session_key: sessionKey,
|
|
229
|
+
metadata: {
|
|
230
|
+
gate: verdict.gate,
|
|
231
|
+
topics,
|
|
232
|
+
...(verdict.exempt ? { exempt: verdict.exempt } : {}),
|
|
233
|
+
...(verdict.repeat ? { g2: { topic: verdict.repeat.topic, gap_ms: verdict.repeat.gapMs } } : {}),
|
|
234
|
+
// dup 两个出口分字段落盘:archived 与 forgotten 是两类不同的穿透,合成一个布尔
|
|
235
|
+
// 会让「只剩遗忘行命中」读起来像「库里有活跃重复」(见 lookupContentDup 注释)。
|
|
236
|
+
...(verdict.dup
|
|
237
|
+
? { dup: {
|
|
238
|
+
memory_id: verdict.dup.memory_id,
|
|
239
|
+
archived: verdict.dup.archived === true,
|
|
240
|
+
forgotten: verdict.dup.forgotten === true
|
|
241
|
+
} }
|
|
242
|
+
: {})
|
|
243
|
+
}
|
|
244
|
+
}));
|
|
245
|
+
} catch (e) {
|
|
246
|
+
// 审计失败只 warn(同 saveLlmAudit 的调用惯例),写入本身照常完成。
|
|
247
|
+
warn(`[dsh-mneme] write admission audit failed: ${String(e)}`);
|
|
248
|
+
}
|
|
249
|
+
// 只有真正落库、且不是穿透口的行才推进话题表:审计写失败时内存视图不能跑在审计
|
|
250
|
+
// 前面(重启回填会得到另一条时间线),pinned 写入整个不在闸门里。
|
|
251
|
+
if (rows.length > 0 && !verdict.exempt) {
|
|
252
|
+
const table = topicTable(sessionKey);
|
|
253
|
+
for (const topic of topics) table.set(topic, at);
|
|
254
|
+
}
|
|
255
|
+
return rows;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { evaluate, record };
|
|
259
|
+
}
|
package/test/api.test.js
CHANGED
|
@@ -663,12 +663,16 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
663
663
|
// issue #257 新增 sleepMaxTokens、issue #258 新增总览路由两键与
|
|
664
664
|
// dreamSummaryMaxInputs、issue #230 新增 documentMemoryEnabled/
|
|
665
665
|
// documentInjectBudget、issue #249 第一批新增 injectGuidanceEnabled/
|
|
666
|
-
// pinnedInjectBudget
|
|
667
|
-
//
|
|
668
|
-
|
|
666
|
+
// pinnedInjectBudget、issue #249 N3 新增 continuityRescueEnabled,
|
|
667
|
+
// v0.8.5 新增 disableMemorySearch/disableMemoryArchive,
|
|
668
|
+
// 本地嵌入池化新增 localEmbedPooling,issue #315 新增 summarizeReasoningEffort,
|
|
669
|
+
// issue #239 第 4 项镜像到巩固新增 dreamPeakHours/dreamPeakMaxDeferMinutes,
|
|
670
|
+
// issue #292 新增 autoDreamFailureBackoff)
|
|
671
|
+
assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1);
|
|
669
672
|
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
670
673
|
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
671
674
|
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
|
|
675
|
+
assert.equal(data.effective.autoDreamFailureBackoff, false);
|
|
672
676
|
assert.equal(data.effective.dreamMaxTokens, 131072);
|
|
673
677
|
assert.equal(data.effective.sleepProvider, "");
|
|
674
678
|
assert.equal(data.effective.sleepModel, "");
|
|
@@ -695,6 +699,8 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
695
699
|
assert.equal(data.effective.entityExtractionProvider, "");
|
|
696
700
|
assert.equal(data.effective.entityExtractionModel, "");
|
|
697
701
|
assert.equal(data.effective.entityExtractionReasoning, "none");
|
|
702
|
+
// issue #315:蒸馏思考强度(默认 none = 不发送字段,行为不变)
|
|
703
|
+
assert.equal(data.effective.summarizeReasoningEffort, "none");
|
|
698
704
|
// issue #127:summarize 节流五键(均有默认值,故计入 effective 计数)
|
|
699
705
|
assert.equal(data.effective.summarizeMinIntervalMinutes, 0);
|
|
700
706
|
assert.equal(data.effective.summarizeMaxEntriesPerRun, 0);
|
|
@@ -1010,6 +1016,18 @@ test("GET /api/dsh-mneme/export markdown feeds straight back through import", as
|
|
|
1010
1016
|
const md = res.body;
|
|
1011
1017
|
assert.ok(md.includes("- **ID**: `" + a.memory.id + "`"), "anchor line present (readHumanEdits-compatible)");
|
|
1012
1018
|
|
|
1019
|
+
// 导出是一个文档(#278 审查 F1/F3):frontmatter 只能有一份并在最前,分节不带
|
|
1020
|
+
// 各自的文件头;而且它的覆盖声明必须与正文条目数一致——「说一套写一套」比没有
|
|
1021
|
+
// 这个字段更糟,外部工具会照着它聚合。
|
|
1022
|
+
assert.equal([...md.matchAll(/^type: /gm)].length, 1, "只有一个 frontmatter 的 type 键");
|
|
1023
|
+
assert.match(md, /^type: memory-export$/m);
|
|
1024
|
+
assert.match(md, /^coverage: all$/m, "导出含归档/已遗忘行,声明要说实话");
|
|
1025
|
+
assert.equal(
|
|
1026
|
+
Number(md.match(/^covered: (\d+)$/m)[1]),
|
|
1027
|
+
(md.match(/^- \*\*ID\*\*: /gm) ?? []).length,
|
|
1028
|
+
"covered 必须等于实际导出的条目数"
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1013
1031
|
// 黄金用例:导出文本原样导入 → 解析出全部条目,且字段无漂移
|
|
1014
1032
|
const back = new FakeRes();
|
|
1015
1033
|
await importRoute.handler(req("/api/dsh-mneme/import", "POST", { type: "preference", markdown: md }), back);
|