@modusensus/dsh-mneme 0.8.4 → 0.8.5

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 (69) hide show
  1. package/README.md +16 -7
  2. package/bin/dsh-mneme-mcp.mjs +11 -3
  3. package/lib/api-standalone.js +11 -0
  4. package/lib/api.js +21 -0
  5. package/lib/client.js +213 -31
  6. package/lib/config.js +82 -0
  7. package/lib/document.js +221 -0
  8. package/lib/dream/sleep.js +6 -4
  9. package/lib/dream.js +1347 -1319
  10. package/lib/guide.js +44 -0
  11. package/lib/heat.js +2 -0
  12. package/lib/inject.js +91 -15
  13. package/lib/lang.js +6 -0
  14. package/lib/organize.js +450 -0
  15. package/lib/recall-stats.js +23 -1
  16. package/lib/runtime/layout.js +30 -0
  17. package/lib/runtime/loader.js +4 -1
  18. package/lib/runtime/verify.js +19 -6
  19. package/lib/search/adaptive.js +38 -0
  20. package/lib/service.js +165 -12
  21. package/lib/settings.js +31 -2
  22. package/lib/store.js +109 -8
  23. package/lib/summarize.js +253 -8
  24. package/lib/tools.js +136 -8
  25. package/package.json +6 -6
  26. package/runtime-manifest.json +104 -174
  27. package/scripts/build-runtime-manifest.mjs +8 -1
  28. package/scripts/sync-test-badge.mjs +18 -12
  29. package/src/api-standalone.js +11 -0
  30. package/src/api.js +21 -0
  31. package/src/config.js +82 -0
  32. package/src/document.js +221 -0
  33. package/src/dream/sleep.js +6 -4
  34. package/src/dream.js +1347 -1319
  35. package/src/guide.js +44 -0
  36. package/src/heat.js +2 -0
  37. package/src/inject.js +91 -15
  38. package/src/lang.js +6 -0
  39. package/src/organize.js +450 -0
  40. package/src/recall-stats.js +23 -1
  41. package/src/runtime/layout.js +30 -0
  42. package/src/runtime/loader.js +4 -1
  43. package/src/runtime/verify.js +19 -6
  44. package/src/search/adaptive.js +38 -0
  45. package/src/service.js +165 -12
  46. package/src/settings.js +31 -2
  47. package/src/store.js +109 -8
  48. package/src/summarize.js +253 -8
  49. package/src/tools.js +136 -8
  50. package/test/adaptive-inject-budget.test.js +85 -0
  51. package/test/api.test.js +65 -3
  52. package/test/client.test.js +63 -2
  53. package/test/document.test.js +523 -0
  54. package/test/dream.test.js +51 -0
  55. package/test/inject-content-cap.test.js +5 -3
  56. package/test/inject-pools.test.js +170 -0
  57. package/test/inject-recall.test.js +105 -0
  58. package/test/inject.test.js +35 -1
  59. package/test/llm-audit.test.js +153 -0
  60. package/test/organize.test.js +418 -0
  61. package/test/recall-stats.test.js +7 -1
  62. package/test/runtime-loader.test.js +18 -0
  63. package/test/runtime-manifest.test.js +2 -1
  64. package/test/runtime-verify.test.js +65 -0
  65. package/test/sleep.test.js +37 -0
  66. package/test/store.test.js +13 -0
  67. package/test/summarize.test.js +407 -3
  68. package/test/tools-dto-schema-matrix.test.js +325 -0
  69. package/test/tools.test.js +36 -3
package/lib/summarize.js CHANGED
@@ -256,13 +256,136 @@ function enqueueDistill(task, intervalMs = 0) {
256
256
  return run;
257
257
  }
258
258
 
259
- export function createSummarizer(ctx, service, config) {
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
+
370
+ export function createSummarizer(ctx, service, config, deps = {}) {
260
371
  if (!config.autoSummarize) return { dispose: () => {} };
261
372
 
373
+ // Issue #239(第 4 项):时钟与定时器可注入——排程不绑死真实时钟,测试才能确定性
374
+ // 覆盖「高峰顺延 → 非高峰补跑」。房型同 dream/sleep.js 的注入参数。
375
+ const now = typeof deps.now === "function" ? deps.now : () => Date.now();
376
+ const setTimer = typeof deps.setTimeoutFn === "function" ? deps.setTimeoutFn : setTimeout;
377
+ const clearTimer = typeof deps.clearTimeoutFn === "function" ? deps.clearTimeoutFn : clearTimeout;
378
+
262
379
  const inFlight = new Map();
263
380
  // Issue #127:per-session 上一次实际开跑时刻(最小间隔闸门用)。与 inFlight
264
381
  // 同生命周期,dispose 时一并清空。
265
382
  const lastRunAt = new Map();
383
+ // Issue #239:per-session 已发起的蒸馏次数(有界检查点用)。只在实际发起 LLM
384
+ // 调用时自增,因此被零 LLM 预判挡下的窗口不消耗预算。
385
+ const runsUsed = new Map();
386
+ // Issue #239(第 4 项):每会话最多挂一个待补跑定时器(错峰队列用);dispose 时
387
+ // 一并清理,避免插件卸载后还留着一个会调模型的定时器。
388
+ const deferredRuns = new Map();
266
389
  // Issue #210:只在一次蒸馏完整成功后记录已消费的最后事件 seq。
267
390
  const lastDistilledSeq = new Map();
268
391
  let disposed = false;
@@ -282,7 +405,52 @@ export function createSummarizer(ctx, service, config) {
282
405
  }
283
406
  }
284
407
 
285
- async function summarize(session, triggerEvent) {
408
+ function persistCursor(sessionId, nextSeq) {
409
+ if (!Number.isFinite(nextSeq)) return;
410
+ if (typeof service.setDistillCursor !== "function") {
411
+ // 第三方宿主拿旧版 service 构造时没有持久化游标能力:降级为内存游标
412
+ // (#274 之前的行为),本进程内不重复蒸馏,重启后窗口重放由
413
+ // saveWithDedupe 三元组兜底。方法存在但抛错仍向上传播——那是
414
+ // 「写失败须回滚」的恰一次语义,不能吞(见 summarize.test.js 回滚用例)。
415
+ ctx.logger?.warn?.("dsh-mneme: service.setDistillCursor unavailable, distill cursor falls back to in-memory");
416
+ return;
417
+ }
418
+ service.setDistillCursor(sessionId, nextSeq);
419
+ }
420
+
421
+ function commitCursor(sessionId, nextSeq) {
422
+ persistCursor(sessionId, nextSeq);
423
+ if (Number.isFinite(nextSeq)) lastDistilledSeq.set(sessionId, nextSeq);
424
+ }
425
+
426
+ /**
427
+ * Issue #239(第 4 项):把这一轮蒸馏推迟到最近的「高峰结束」时刻。每会话只挂
428
+ * 一个定时器(重复触发不叠加);被 summarizePeakMaxDeferMinutes 截断时到点照跑
429
+ * (bypassPeak),长高峰不会把蒸馏饿死。定时器 unref——不阻止宿主退出。
430
+ */
431
+ function scheduleDeferredRun(session) {
432
+ if (disposed || deferredRuns.has(session.id)) return;
433
+ const at = nextOffPeakAt(new Date(now()), config.summarizePeakHours ?? "");
434
+ if (!at) return;
435
+ const maxDeferMs = (config.summarizePeakMaxDeferMinutes ?? 0) * 60000;
436
+ let delay = Math.max(0, at.getTime() - now());
437
+ const capped = maxDeferMs > 0 && delay > maxDeferMs;
438
+ if (capped) delay = maxDeferMs;
439
+ const timer = setTimer(() => {
440
+ deferredRuns.delete(session.id);
441
+ if (disposed) return;
442
+ // 不传触发事件:补跑要蒸的是「累积后的整窗」(从上次成功游标到最新事件),
443
+ // 拿老 triggerEvent 反而会把窗口卡在它那个 seq 上。
444
+ summarize(session, undefined, { bypassPeak: capped }).catch((error) => {
445
+ if (disposed || error?.name === "AbortError") return;
446
+ ctx.logger?.warn?.(`dsh-mneme: deferred summarization failed: ${String(error)}`);
447
+ });
448
+ }, delay);
449
+ timer?.unref?.();
450
+ deferredRuns.set(session.id, timer);
451
+ }
452
+
453
+ async function summarize(session, triggerEvent, opts = {}) {
286
454
  if (disposed || inFlight.has(session.id)) return;
287
455
 
288
456
  const header = session.requestHeader?.()?.config;
@@ -308,6 +476,37 @@ export function createSummarizer(ctx, service, config) {
308
476
  });
309
477
  return;
310
478
  }
479
+ // Issue #239(有界检查点):每会话 run 预算。放在间隔门之后,两者都命中时
480
+ // 先报间隔——间隔是「刚刚跑过」,预算是「额度用尽」,前者信息量更大。skip
481
+ // 仍写审计(沿用间隔门的 error_message 口径),否则「为什么不再蒸馏了」对
482
+ // 用户不可观测。prevRuns 在这里捕获、finally 里按 aborted 回滚(同 lastRunAt)。
483
+ // 游标刻意不消费:预算是「这个会话先不再蒸馏了」,把窗口留在原地,日后调高
484
+ // 额度(或宿主重启)仍能蒸馏到它;这与 window-too-small 主动消费游标相反——
485
+ // 后者是「这些事件不值得蒸馏」,留着只会每个 turn/end 重评一次。
486
+ // Issue #239(第 4 项,错峰队列):高峰期不调模型——只登记一行 skip 审计并择时
487
+ // 补跑。游标刻意不消费:窗口继续累积,留到非高峰一次性蒸馏(批量比逐轮碎蒸更
488
+ // 省)。被 maxDefer 截断后到点仍处高峰时,由 opts.bypassPeak 放行。
489
+ if (!opts.bypassPeak && isInPeakWindow(new Date(now()), config.summarizePeakHours ?? "")) {
490
+ writeAudit({
491
+ timestamp: new Date(now()).toISOString(),
492
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
493
+ status: "skipped",
494
+ error_message: "peak-hours"
495
+ });
496
+ scheduleDeferredRun(session);
497
+ return;
498
+ }
499
+ const prevRuns = runsUsed.get(session.id);
500
+ const maxRuns = config.summarizeMaxRunsPerSession ?? 0;
501
+ if (maxRuns > 0 && (prevRuns ?? 0) >= maxRuns) {
502
+ writeAudit({
503
+ timestamp: new Date().toISOString(),
504
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
505
+ status: "skipped",
506
+ error_message: "max-runs-per-session"
507
+ });
508
+ return;
509
+ }
311
510
  const controller = new AbortController();
312
511
  inFlight.set(session.id, controller);
313
512
  // audit state for the compression call. null = no audit for this run
@@ -319,7 +518,10 @@ export function createSummarizer(ctx, service, config) {
319
518
  let abortedRun = false;
320
519
  try {
321
520
  if (!route) return;
322
- const previousSeq = lastDistilledSeq.get(session.id);
521
+ const persistedCursor = typeof service.getDistillCursor === "function"
522
+ ? service.getDistillCursor(session.id)
523
+ : undefined;
524
+ const previousSeq = persistedCursor?.last_seq ?? lastDistilledSeq.get(session.id);
323
525
  const triggerSeq = eventSeq(triggerEvent);
324
526
  // 只读取上次成功游标之后、当前 turn/end 之前的事件。旧版 snapshotEvents
325
527
  // 即使忽略范围参数,collectMessages 仍会按事件 seq 二次过滤。
@@ -338,13 +540,37 @@ export function createSummarizer(ctx, service, config) {
338
540
  if (!collected.messages.length) {
339
541
  // 没有可蒸馏的公开文本也算成功消费当前事件窗口,避免每个 turn/end
340
542
  // 都重新扫描同一批无内容事件;没有 seq 时则不提交不可验证的游标。
341
- if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
543
+ commitCursor(session.id, nextSeq);
342
544
  return;
343
545
  }
344
546
  const messages = collected.messages;
345
- // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔;失败路径仍按原语义
346
- // 占用,aborted 则由 finally 回滚。
547
+ // Issue #239(成本感知级联第一级):零 LLM 预判。窗口内可蒸馏文本不足阈值
548
+ // 时直接跳过——短窗口交给模型的产出多是泛泛而谈,代价却是一次完整调用。
549
+ // 判定纯规则(字符数),无模型参与;被挡下的窗口照常消费游标,否则每个
550
+ // turn/end 都会重新评估同一段短文本。skip 原因进审计,作为后续按真实负载
551
+ // 决定下一级规则的依据。
552
+ const minWindowChars = config.summarizeMinWindowChars ?? 0;
553
+ if (minWindowChars > 0) {
554
+ const distillChars = messages.reduce(
555
+ (total, message) => total + (Array.isArray(message.content) ? message.content : [])
556
+ .reduce((n, part) => n + (typeof part?.text === "string" ? part.text.length : 0), 0),
557
+ 0
558
+ );
559
+ if (distillChars < minWindowChars) {
560
+ commitCursor(session.id, nextSeq);
561
+ writeAudit({
562
+ timestamp: new Date().toISOString(),
563
+ model_id: route ? `${route.provider}:${route.model}` : "unknown",
564
+ status: "skipped",
565
+ error_message: "window-too-small"
566
+ });
567
+ return;
568
+ }
569
+ }
570
+ // 只有真正准备发起一次 LLM 蒸馏时才占用最小间隔与 run 预算;失败路径仍按
571
+ // 原语义占用(调用确实发生过),aborted 则由 finally 回滚。
347
572
  lastRunAt.set(session.id, Date.now());
573
+ runsUsed.set(session.id, (prevRuns ?? 0) + 1);
348
574
 
349
575
  if (config?.llmAudit?.enabled !== false && typeof service.saveLlmAudit === "function") {
350
576
  audit = {
@@ -385,8 +611,11 @@ export function createSummarizer(ctx, service, config) {
385
611
  text += chunk.text ?? chunk.delta ?? "";
386
612
  }
387
613
  if (chunk.type === "usage" && audit) {
388
- const i = chunk.input_tokens ?? chunk.inputTokens ?? chunk.prompt_tokens ?? chunk.promptTokens;
389
- const o = chunk.output_tokens ?? chunk.outputTokens ?? chunk.completion_tokens ?? chunk.completionTokens;
614
+ // 同 dream.js:真实协议是 {type:"usage", usage:TokenUsage},用量不在
615
+ // chunk 顶层;顶层读取只作为把用量平铺的替身兜底。
616
+ const usage = chunk.usage ?? chunk;
617
+ const i = usage.input_tokens ?? usage.inputTokens ?? usage.prompt_tokens ?? usage.promptTokens;
618
+ const o = usage.output_tokens ?? usage.outputTokens ?? usage.completion_tokens ?? usage.completionTokens;
390
619
  if (Number.isFinite(i)) audit.inputTokens = i;
391
620
  if (Number.isFinite(o)) audit.outputTokens = o;
392
621
  }
@@ -504,7 +733,11 @@ export function createSummarizer(ctx, service, config) {
504
733
  ...(dup ? { _mergeInto: dup.memory.id } : {})
505
734
  });
506
735
  }
736
+ persistCursor(session.id, nextSeq);
507
737
  });
738
+ } else {
739
+ // 空数组是合法成功:没有记忆写入,但本次事件窗口仍然应被持久消费。
740
+ persistCursor(session.id, nextSeq);
508
741
  }
509
742
  if (audit && (capped > 0 || deduped > 0)) {
510
743
  audit.metadata = {
@@ -516,12 +749,22 @@ export function createSummarizer(ctx, service, config) {
516
749
  // 记忆写入和解析都成功后才提交窗口;流失败、中止、解析失败或写入异常
517
750
  // 都会在此之前退出,从而保留窗口供下一次重试。
518
751
  if (Number.isFinite(nextSeq)) lastDistilledSeq.set(session.id, nextSeq);
752
+ } catch (error) {
753
+ if (audit) {
754
+ audit.status = "error";
755
+ audit.errorMessage = String(error?.message ?? error);
756
+ }
757
+ throw error;
519
758
  } finally {
520
759
  // Issue #127:aborted(会话关闭 / 插件 dispose)不占间隔,避免误伤该会话的
521
760
  // 下一次蒸馏;其余情况(含失败)的打点保留,与 dreamMinIntervalMinutes 一致。
522
761
  if (abortedRun || controller.signal.aborted) {
523
762
  if (prevClaim === undefined) lastRunAt.delete(session.id);
524
763
  else lastRunAt.set(session.id, prevClaim);
764
+ // Issue #239:aborted 同样不占 run 预算——调用没有真正发生,占了会误伤
765
+ // 该会话后续的蒸馏(与上面的间隔回滚同一口径)。
766
+ if (prevRuns === undefined) runsUsed.delete(session.id);
767
+ else runsUsed.set(session.id, prevRuns);
525
768
  }
526
769
  if (audit) {
527
770
  try {
@@ -566,6 +809,8 @@ export function createSummarizer(ctx, service, config) {
566
809
  if (disposed) return;
567
810
  disposed = true;
568
811
  unsubscribe?.();
812
+ for (const timer of deferredRuns.values()) clearTimer(timer);
813
+ deferredRuns.clear();
569
814
  for (const controller of inFlight.values()) controller.abort();
570
815
  inFlight.clear();
571
816
  lastRunAt.clear();
package/lib/tools.js CHANGED
@@ -5,6 +5,7 @@ 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
9
 
9
10
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
10
11
  // Per-registry tool-name registry: guards against duplicate registration on
@@ -35,6 +36,8 @@ const MEMORY_ITEM_SCHEMA = {
35
36
  scope_decided_at: { type: "string" },
36
37
  sensitivity: { type: "string" },
37
38
  occurred_at: { type: "string" },
39
+ // #230:document 指针行的文件定位——「全文按需读」的入口;普通行缺省。
40
+ doc_path: { type: "string" },
38
41
  created_at: { type: "string", required: true },
39
42
  updated_at: { type: "string", required: true }
40
43
  }
@@ -81,6 +84,11 @@ export function createTools(ctx, service, config, embedder) {
81
84
  // flag 关闭时恒返回 null——写入不标注,检索不加权,行为与 A1 前完全一致。
82
85
  // logger 透传:registry 反查失败时 warnOnce 才有出口(否则静默降级无观测)。
83
86
  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;
84
92
 
85
93
  // 复核项 4(issue #170):strictScope 下他 scope(explicit)的行对工具侧按
86
94
  // 「不存在」处理——update/delete 与 memory_get 同款无存在性泄漏。strictScope
@@ -104,11 +112,16 @@ export function createTools(ctx, service, config, embedder) {
104
112
  const tools = [
105
113
  defineTool({
106
114
  name: "memory_save",
107
- description:
115
+ description: withToolGuide(
116
+ "memory_save",
108
117
  "Persist one memory entry for future sessions (user preferences, project state, decisions). " +
109
- "Call this when the user states a durable preference, a project decision is made, or a lesson is learned. " +
110
- "Merges into an existing entry of the same type when the title matches.",
118
+ "Call this when the user states a durable preference, a project decision is made, or a lesson is learned. " +
119
+ "Merges into an existing entry of the same type when the title matches."
120
+ ),
111
121
  parameters: {
122
+ // document 不在此列(#230):document 行只能经 memory_register_document
123
+ // 铸造(注册校验 + doc_path + evidence),防止 memory_save 造出无指针
124
+ // 无回链的伪行。
112
125
  type: { type: "string", required: true, enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"], description: "preference=user profile; project=project knowledge/state; decision=key decision; history=conversation summary; rejected_solution=rejected/abandoned implementation approach; pitfall=debugging lesson (symptom+root cause+fix); constraint=engineering constraint" },
113
126
  title: { type: "string", required: true, description: "Short unique title" },
114
127
  content: { type: "string", required: true, description: "Memory body" },
@@ -166,7 +179,10 @@ export function createTools(ctx, service, config, embedder) {
166
179
 
167
180
  defineTool({
168
181
  name: "memory_search",
169
- description: "Search the cross-session memory store. Use when you need past context: how a problem was solved, user preferences, project decisions. Substring-matches title/content/tags, and augments results with semantic (vector) recall + optional rerank when an embeddings provider is configured. Returns matching entries with source and timestamps.",
182
+ description: withToolGuide(
183
+ "memory_search",
184
+ "Search the cross-session memory store for past context (solved problems, user preferences, project decisions). IMPORTANT: relevant cross-session memories are already injected into your context every turn — only call this when the injected memory block does NOT contain what you need, or you need to look something specific up. Substring-matches title/content/tags, and augments results with semantic (vector) recall + optional rerank when an embeddings provider is configured. Returns matching entries with source and timestamps."
185
+ ),
170
186
  // A2 检索接线:occurred 时间窗 + 会话 scope 加成(见 execute)。
171
187
  parameters: {
172
188
  query: { type: "string", required: true, description: "Search text; substring match over title/content/tags" },
@@ -224,7 +240,7 @@ export function createTools(ctx, service, config, embedder) {
224
240
  name: "memory_list",
225
241
  description: "List memory entries by type, high-importance first, then newest, paginated. Set include_archived=true to also list archived (hidden) entries so they can be located and restored.",
226
242
  parameters: {
227
- type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"], description: "Filter by type; omit for all" },
243
+ type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint", "document"], description: "Filter by type; omit for all. 'document' (#230) = agent-registered document pointer rows" },
228
244
  limit: { type: "integer", description: "Page size (default 50)" },
229
245
  offset: { type: "integer", description: "Page offset (default 0)" },
230
246
  include_archived: { type: "boolean", description: "Include archived (hidden) entries so they can be found and restored (default false)" },
@@ -287,7 +303,9 @@ export function createTools(ctx, service, config, embedder) {
287
303
  },
288
304
  render: (_args, value) => {
289
305
  const m = value.memory;
290
- return TEXT_OUTPUT(`${m.title}\nID: ${m.id} | type: ${m.type} | importance: ${m.importance}${MEMORY_PROVENANCE(m)}\n\n${m.content}`);
306
+ // #230:document 指针行把文件路径亮在首行——「全文按需读」从这里拿路径。
307
+ const doc = m.doc_path ? ` | doc: ${m.doc_path}` : "";
308
+ return TEXT_OUTPUT(`${m.title}\nID: ${m.id} | type: ${m.type} | importance: ${m.importance}${doc}${MEMORY_PROVENANCE(m)}\n\n${m.content}`);
291
309
  }
292
310
  },
293
311
  async execute(args) {
@@ -311,6 +329,8 @@ export function createTools(ctx, service, config, embedder) {
311
329
  id: { type: "string", required: true, description: "Memory id" },
312
330
  title: { type: "string" },
313
331
  content: { type: "string" },
332
+ // document 不在此列(#230):type 不许改入 document——既有 document 行
333
+ // 的摘要修复(只改 content/title)不受影响。
314
334
  type: { type: "string", enum: ["preference", "project", "decision", "history", "rejected_solution", "pitfall", "constraint"] },
315
335
  tags: { type: "array", items: { type: "string" } },
316
336
  importance: { type: "integer", description: "1-5" },
@@ -415,7 +435,8 @@ export function createTools(ctx, service, config, embedder) {
415
435
  description:
416
436
  "Archive a memory (hide it from active lists, search, injection and dream consolidation) or restore it. " +
417
437
  "Archived entries stay in storage and are recoverable: pass archived=false to restore, and use memory_list with " +
418
- "include_archived=true to find archived entries.",
438
+ "include_archived=true to find archived entries. " +
439
+ "Only archive when the user asks, or an entry is clearly stale/no longer relevant — do not archive proactively mid-session to tidy up.",
419
440
  parameters: {
420
441
  id: { type: "string", required: true, description: "Memory id" },
421
442
  archived: { type: "boolean", description: "Archive (true, default) or restore (false) the entry" }
@@ -446,6 +467,101 @@ export function createTools(ctx, service, config, embedder) {
446
467
  }
447
468
  }),
448
469
 
470
+ // #230(#164 设计稿评审线):document 型记忆注册——agent 产长文档的指针
471
+ // 行铸造口。写入权分离:全文归 agent(管线对文件零读零写零改),库里只
472
+ // 存摘要 + doc_path + evidence 三样;C2 比对去重、supersede 记账在
473
+ // service.registerDocument(src/document.js)内完成。判断指引:调研报告/
474
+ // 设计稿/长整理产物「存外档」用本工具;原子事实(偏好/决策/教训)仍走
475
+ // memory_save;tag 与章节名建议用英文(跨语言稳定键),摘要正文可多语言。
476
+ defineTool({
477
+ name: "memory_register_document",
478
+ description:
479
+ "Register an agent-authored document (research report, design doc, long digest) into memory as a pointer row. " +
480
+ "Write-ownership split: the full text stays agent-owned on disk — the pipeline never reads or rewrites it; the DB " +
481
+ "stores only the summary + doc_path + evidence. Validates the file exists (absolute path, non-empty regular file), " +
482
+ "intersects evidence with real memory ids (all-fabricated evidence is rejected; unknown ids are dropped and the row " +
483
+ "is tagged evidence_degraded), and dedupes: re-registering the same path or title supersedes the old row (the old " +
484
+ "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?' " +
486
+ "lookups; atomic facts still go to memory_save.",
487
+ parameters: {
488
+ 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." },
489
+ title: { type: "string", required: true, description: "Short unique title (English recommended: it is the cross-language stable dedupe/supersede key)" },
490
+ summary: { type: "string", required: true, description: "One-paragraph summary stored in the DB and used for injection (any language)" },
491
+ tags: { type: "array", items: { type: "string" }, description: "Optional tags (English recommended)" },
492
+ 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)" },
494
+ source: { type: "string", description: "Optional provenance" },
495
+ 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
+ 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." },
497
+ workspace_scope: { type: "string", description: "Optional explicit workspace-scope declaration (issue #170): 'global' or '*' makes this document visible in every workspace; any other value narrows it to that label. Overrides the automatic carrier label for this write; honored even when automatic scope labeling is disabled." }
498
+ },
499
+ output: {
500
+ schema: {
501
+ type: "object",
502
+ additionalProperties: false,
503
+ properties: {
504
+ action: { type: "string", required: true, enum: ["created", "superseded"] },
505
+ id: { type: "string", required: true },
506
+ superseded_id: { type: "string" },
507
+ evidence_kept: { type: "integer", required: true },
508
+ evidence_dropped: { type: "integer", required: true },
509
+ degraded: { type: "boolean", required: true }
510
+ }
511
+ },
512
+ render: (_args, value) => {
513
+ const sup = value.superseded_id ? ` (supersedes ${value.superseded_id})` : "";
514
+ const ev = ` | evidence: ${value.evidence_kept} kept, ${value.evidence_dropped} dropped${value.degraded ? " [degraded]" : ""}`;
515
+ return TEXT_OUTPUT(`document ${value.action}: ${value.id}${sup}${ev}`);
516
+ }
517
+ },
518
+ async execute(args, exec) {
519
+ // scope 标注与 memory_save 同款(#170):默认会话身份自动标注,显式
520
+ // 参数逐维覆盖。校验/去重/supersede 全在 service.registerDocument。
521
+ const scope = resolveSessionScope(exec);
522
+ const autoStamping = config?.scopeEnabled === true;
523
+ const agentLabel = args.agent_scope !== undefined
524
+ ? { value: normalizeExplicitScope(args.agent_scope), source: "explicit" }
525
+ : autoStamping && scope ? { value: scope.agent_scope, source: "auto" } : null;
526
+ const workspaceLabel = args.workspace_scope !== undefined
527
+ ? { value: normalizeExplicitScope(args.workspace_scope), source: "explicit" }
528
+ : autoStamping && scope ? { value: scope.workspace_scope, source: "auto" } : null;
529
+ // strictScope(#170 复核项 4 同款无存在性泄漏):他 scope 的 evidence id
530
+ // 按「不存在」处理——注册器用全局 getById 求交,看不见的行在这里先标出,
531
+ // 与 unknown/archived 同落 dropped,不进持久化 evidence(不得为跨 scope
532
+ // id 建立引用)。
533
+ const hiddenEvidence = [];
534
+ if (config?.strictScope === true && scope && Array.isArray(args.evidence)) {
535
+ for (const raw of args.evidence) {
536
+ const id = String(raw ?? "").trim();
537
+ if (!id) continue;
538
+ const row = service.getById(id);
539
+ if (row && !service.isVisibleInScope(row, scope)) hiddenEvidence.push(id);
540
+ }
541
+ }
542
+ const result = await service.registerDocument({
543
+ path: args.path,
544
+ title: args.title,
545
+ summary: args.summary,
546
+ tags: args.tags ?? [],
547
+ ...(args.importance !== undefined ? { importance: args.importance } : {}),
548
+ evidence: args.evidence ?? [],
549
+ source: args.source ?? "tool",
550
+ ...(args.sensitivity !== undefined ? { sensitivity: args.sensitivity } : {}),
551
+ ...(agentLabel ? { agent_scope: agentLabel.value, agent_scope_source: agentLabel.source } : {}),
552
+ ...(workspaceLabel ? { workspace_scope: workspaceLabel.value, workspace_scope_source: workspaceLabel.source } : {})
553
+ }, { hiddenEvidenceIds: hiddenEvidence });
554
+ return {
555
+ action: result.action,
556
+ id: result.memory.id,
557
+ ...(result.superseded ? { superseded_id: result.superseded.id } : {}),
558
+ evidence_kept: result.evidence_kept,
559
+ evidence_dropped: result.evidence_dropped,
560
+ degraded: result.degraded
561
+ };
562
+ }
563
+ }),
564
+
449
565
  // 运行时口子(issue #131 / PR-C)。
450
566
  //
451
567
  // 为什么要有这个工具:mneme 的取向是「本体保持完善但轻量;重的功能存在、默认不开、不被依赖」。
@@ -561,8 +677,20 @@ export function createTools(ctx, service, config, embedder) {
561
677
  })
562
678
  ];
563
679
 
680
+ // Tool-exposure gate (v0.8.5): 跨会话记忆已自动注入,memory_search /
681
+ // memory_archive 在慢/轻量模型上是多余往返,面板开关可直接隐藏工具——
682
+ // 模型看不到就不会调,比在描述里劝它少调用更可靠。注意:live patch reload
683
+ // 下已注册的工具不会被宿主反注册,排除只对全新会话生效。
684
+ const hiddenTools = new Set();
685
+ if (config?.disableMemorySearch === true) hiddenTools.add("memory_search");
686
+ if (config?.disableMemoryArchive === true) hiddenTools.add("memory_archive");
687
+
564
688
  for (const tool of tools) {
565
- if (registeredTools.has(tool.name)) {
689
+ if (hiddenTools.has(tool.name)) {
690
+ ctx.logger?.info?.(`[dsh-mneme] tool "${tool.name}" hidden by config (disableMemorySearch/disableMemoryArchive)`);
691
+ continue;
692
+ }
693
+ if (registeredTools.has(tool.name)) {
566
694
  ctx.logger?.warn?.(`[dsh-mneme] tool "${tool.name}" already registered, skipping duplicate`);
567
695
  continue;
568
696
  }
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.4",
4
+ "version": "0.8.5",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "git+https://github.com/modusensus/dsh-mneme.git"
8
+ "url": "git+https://github.com/slow-stack/dsh-mneme.git"
9
9
  },
10
- "homepage": "https://github.com/modusensus/dsh-mneme#readme",
10
+ "homepage": "https://github.com/slow-stack/dsh-mneme#readme",
11
11
  "bugs": {
12
- "url": "https://github.com/modusensus/dsh-mneme/issues"
12
+ "url": "https://github.com/slow-stack/dsh-mneme/issues"
13
13
  },
14
14
  "type": "module",
15
15
  "main": "lib/index.js",
@@ -72,7 +72,7 @@
72
72
  "@deepseek-ai/dsh-system-prompt": "^0.1.0-rc.6",
73
73
  "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
74
74
  "@deepseek-ai/schemastery": "^3.18.1",
75
- "@huggingface/transformers": "^4.2.0",
75
+ "@huggingface/transformers": "^4.3.0",
76
76
  "c8": "^12.0.0",
77
77
  "lucide": "^1.42.0"
78
78
  },
@@ -100,7 +100,7 @@
100
100
  "morphicons": "^1.7.1"
101
101
  },
102
102
  "overrides": {
103
- "adm-zip": "0.6.0"
103
+ "adm-zip": "0.6.1"
104
104
  },
105
105
  "allowScripts": {
106
106
  "onnxruntime-node@1.24.3": true,