@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.
Files changed (68) hide show
  1. package/README.md +19 -148
  2. package/bin/cli.mjs +689 -603
  3. package/lib/api-standalone.js +57 -1
  4. package/lib/api.js +34 -11
  5. package/lib/client.js +349 -61
  6. package/lib/config.js +118 -7
  7. package/lib/content-hash.js +54 -0
  8. package/lib/continuity.js +220 -0
  9. package/lib/document-index.js +94 -0
  10. package/lib/document.js +93 -11
  11. package/lib/dream.js +138 -39
  12. package/lib/guide.js +18 -0
  13. package/lib/index.js +87 -12
  14. package/lib/inject.js +6 -2
  15. package/lib/lang.js +15 -4
  16. package/lib/maintenance.js +142 -0
  17. package/lib/mirror.js +147 -15
  18. package/lib/peak-hours.js +117 -0
  19. package/lib/recall-stats.js +52 -2
  20. package/lib/scope.js +34 -6
  21. package/lib/service.js +86 -7
  22. package/lib/settings.js +16 -2
  23. package/lib/store.js +318 -21
  24. package/lib/summarize.js +52 -113
  25. package/lib/tools.js +37 -11
  26. package/lib/write-admission.js +259 -0
  27. package/package.json +4 -4
  28. package/src/api-standalone.js +57 -1
  29. package/src/api.js +34 -11
  30. package/src/config.js +118 -7
  31. package/src/content-hash.js +54 -0
  32. package/src/continuity.js +220 -0
  33. package/src/document-index.js +94 -0
  34. package/src/document.js +93 -11
  35. package/src/dream.js +138 -39
  36. package/src/guide.js +18 -0
  37. package/src/index.js +87 -12
  38. package/src/inject.js +6 -2
  39. package/src/lang.js +15 -4
  40. package/src/maintenance.js +142 -0
  41. package/src/mirror.js +147 -15
  42. package/src/peak-hours.js +117 -0
  43. package/src/recall-stats.js +52 -2
  44. package/src/scope.js +34 -6
  45. package/src/service.js +86 -7
  46. package/src/settings.js +16 -2
  47. package/src/store.js +318 -21
  48. package/src/summarize.js +52 -113
  49. package/src/tools.js +37 -11
  50. package/src/write-admission.js +259 -0
  51. package/test/api.test.js +21 -3
  52. package/test/client.test.js +103 -6
  53. package/test/content-hash.test.js +204 -0
  54. package/test/continuity.test.js +333 -0
  55. package/test/document-dir.test.js +233 -0
  56. package/test/document.test.js +151 -3
  57. package/test/dream-failure-backoff.test.js +173 -0
  58. package/test/dream-peak-hours.test.js +194 -0
  59. package/test/inject-parent-gate.test.js +106 -0
  60. package/test/inject-pools.test.js +8 -3
  61. package/test/llm-audit.test.js +42 -0
  62. package/test/maintenance.test.js +391 -0
  63. package/test/mirror-types.test.js +208 -0
  64. package/test/reasoning-effort.test.js +6 -0
  65. package/test/recall-evals.test.js +18 -0
  66. package/test/recall-stats.test.js +66 -0
  67. package/test/summarize-reasoning-effort.test.js +189 -0
  68. package/test/write-admission.test.js +345 -0
package/lib/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
- // spec 语法:`[<星期> ]<时段>[,<时段>...]`,星期前缀可省(省 = 每天):
262
- // "09:00-18:00" 每天 09:00-18:00
263
- // "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(ISO 1=周一…7=周日,也认 mon..sun)
264
- // "sat,sun 23:00-06:00" 周末跨零点段
265
- // 时间取宿主本地时区。任一写法非法 → 整串视为未配置(返回 null):排程是省钱手段,
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
- const { text, assembledText, aborted } = await enqueueDistill(async () => {
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(options)) {
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/lib/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 第一批:能力说明(injectGuidanceEnabled)开启时,给「何时用」最有歧义
88
- // 的两个工具补一句判断指引。工具描述常驻、不进每轮上下文,这个位子零注入
89
- // 成本;关闭时描述逐字节不变,也不动其他工具的文案。
90
- const withToolGuide = (name, description) =>
91
- config?.injectGuidanceEnabled === true && TOOL_GUIDE[name] ? `${description}${TOOL_GUIDE[name]}` : description;
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. Use for 'where is the conclusion doc for this project?' " +
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 (fabricated evidence is rejected; unknown/archived ids are dropped and the row is tagged evidence_degraded)" },
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
- }, { hiddenEvidenceIds: hiddenEvidence });
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/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@modusensus/dsh-mneme",
3
3
  "description": "Cross-session memory plugin for DeepSeek Harness with autoDream consolidation: SQLite store, Markdown mirrors, 9 model tools, automatic injection, session summarization, user profile/rules, custom slash commands, vector (semantic) search, and a Web GUI panel",
4
- "version": "0.8.6",
4
+ "version": "0.8.8",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/slow-stack/dsh-mneme.git"
8
+ "url": "git+https://github.com/slow-stack/mneme.git"
9
9
  },
10
- "homepage": "https://github.com/slow-stack/dsh-mneme#readme",
10
+ "homepage": "https://github.com/slow-stack/mneme#readme",
11
11
  "bugs": {
12
- "url": "https://github.com/slow-stack/dsh-mneme/issues"
12
+ "url": "https://github.com/slow-stack/mneme/issues"
13
13
  },
14
14
  "type": "module",
15
15
  "main": "lib/index.js",