@modusensus/dsh-mneme 0.8.7 → 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/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
@@ -5,7 +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
+ import { TOOL_GUIDE, CONTINUITY_TOOL_RULE } from "./guide.js";
9
9
  import { injectChildEnabled } from "./config.js";
10
10
 
11
11
  const TEXT_OUTPUT = (text) => [{ type: "text", text }];
@@ -90,8 +90,16 @@ export function createTools(ctx, service, config, embedder) {
90
90
  // 关闭时描述逐字节不变,也不动其他工具的文案。
91
91
  // 它是注入父开关的子项(#249 第二批):`autoInject` 关掉时这里同样不生效——
92
92
  // 工具描述是这个子项唯一住在注入器之外的落点,不在闸门内就漏了。
93
- const withToolGuide = (name, description) =>
94
- injectChildEnabled(config, "injectGuidanceEnabled") && TOOL_GUIDE[name] ? `${description}${TOOL_GUIDE[name]}` : description;
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
+ };
95
103
 
96
104
  // 复核项 4(issue #170):strictScope 下他 scope(explicit)的行对工具侧按
97
105
  // 「不存在」处理——update/delete 与 memory_get 同款无存在性泄漏。strictScope
package/package.json CHANGED
@@ -1,7 +1,7 @@
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.7",
4
+ "version": "0.8.8",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
package/src/config.js CHANGED
@@ -87,6 +87,22 @@ export const Config = z.object({
87
87
  // 父开关 `autoInject` 关闭时它不生效(闸门见 injectChildEnabled);用户显式写进
88
88
  // feature_flags 的值永远优先于这里的默认值。
89
89
  injectGuidanceEnabled: z.boolean().default(true),
90
+ // #249 N3(压缩边缘双落点):上下文即将大幅精简前抢救「正在做什么」。默认关。
91
+ // 触发靠宿主自己的压缩事件(`compaction/start|summary|end`,都在事件白名单里),
92
+ // 不自定一套阈值参数——「压缩边缘」由宿主定义,我们再校准一份只会与之漂移。
93
+ // 为什么是「新时机 + 新表面」因而默认关:它往对话里**追加消息**(新的注入表面,
94
+ // 参照实现里最容易累积成一堆历史的那类),并多写一张提案表。按 §10 判据,引入
95
+ // 新时机/新表面/新成本的子项独立成键、默认关;只修正既有块的(基础内容分池、
96
+ // 库可见性行)才随父开关默认开。`pinnedInjectBudget` 与它同批,但属前者之外:
97
+ // 那是既有块内的预算,不是新表面。
98
+ // 双落点是硬要求,不能只留一半:宿主的压缩摘要器**只看对话里的内容**,只落库不
99
+ // 注入,等于在摘要重建里什么都没留下;只注入不落库,则压缩一过就随旧消息一起
100
+ // 消失。两者都做,且落库先于注入(注入失败不该丢提案)。
101
+ // 祖先:`autoInject`(父关则本项不生效,闸门见 injectChildEnabled)。轻量档默认
102
+ // 置关(见 LIGHT_MODE_OFF):轻量档多一份注入物是反的,与 injectGuidanceEnabled
103
+ // 同一取舍。注意预设只是默认值而非强制——装配时用户显式开关在它之后展开,勾了就赢
104
+ // (合并顺序见 index.js 装配处)。
105
+ continuityRescueEnabled: z.boolean().default(false),
90
106
  // #249(第一批):B1 pin 池预算——约束/偏好类注入条目的独立小上限。约束与
91
107
  // 偏好被静默降级是本议题的立项核心(同类知识与情景日志同池同速率摘要,实测
92
108
  // 一轮压缩后仅保 53%、五轮 10%),故这两类不进相关性竞争、不参与跨轮轮换、
@@ -129,6 +145,31 @@ export const Config = z.object({
129
145
  // 起算,失败/degraded 的 run 也占用间隔;间隔内的触发请求静默跳过,下一次
130
146
  // 写入事件会重新评估。
131
147
  dreamMinIntervalMinutes: z.natural().min(0).max(10080).default(0),
148
+ // Issue #292(#135 派生):autoDream 连续失败退避(opt-in,默认关 = 行为与
149
+ // 现状逐字节一致)。开启后调度器对连续失败做指数退避:有效最小间隔 =
150
+ // dreamMinIntervalMinutes × 2^连续失败数(成功一次清零恢复),封顶 30 分钟。
151
+ // #89 的最小间隔闸失败 run 也占用,但间隔恒定——恒定失败的模型(#135 空体
152
+ // 面)会按固定节奏连发刷爆配额;退避把下次重试按失败次数指数推远。基数取
153
+ // dreamMinIntervalMinutes:基数为 0 时无闸可翻倍,本键不自己产生间隔(先配
154
+ // dreamMinIntervalMinutes 再开本键)。与 dreamPeakHours / dreamMinIntervalMinutes
155
+ // 同族(节流阀,不新增任何 LLM 调用),故不进 LIGHT_MODE_OFF。
156
+ autoDreamFailureBackoff: z.boolean().default(false),
157
+ // Issue #239(第 4 项,错峰队列)镜像到巩固:高峰期不做梦。与
158
+ // summarizePeakHours 同一份时段语法(复用 src/summarize.js 的 parsePeakSpec /
159
+ // isInPeakWindow / nextOffPeakAt,不另写解析器):逗号分隔、可带星期前缀、支持
160
+ // 跨零点。空串 = 关闭,行为与现状逐字节一致。
161
+ // "09:00-18:00" 每天 09:00-18:00
162
+ // "mon-fri 08:00-12:00,14:00-18:00" 工作日两段(按高峰计费的供应商即此形态)
163
+ // 为什么巩固比蒸馏更该有这道闸:单次巩固的输入是整窗快照(dreamMaxSnapshotSize
164
+ // 条),实测一次 run 的 LLM 时长可达数分钟量级,撞上高峰时既贵又慢;而它由写入
165
+ // 事件触发、没有天然的「等到空闲再跑」路径。命中高峰时:不调 LLM、不刷新
166
+ // baseline(阈值继续累积,留到非高峰一次性巩固),登记一行 status='skipped' /
167
+ // error_message='peak-hours' 审计,并按下面的上限择时补跑。任一写法非法则整串
168
+ // 按「未配置」处理——排程是省钱手段,绝不该因为写错格式把巩固停掉。
169
+ dreamPeakHours: z.string().default(""),
170
+ // 高峰顺延上限(分钟,0 = 不设上限):到点仍处高峰就照常跑,避免整天高峰把巩固
171
+ // 饿死。默认 120,与 summarizePeakMaxDeferMinutes 对齐。仅在时段串非空时生效。
172
+ dreamPeakMaxDeferMinutes: z.natural().min(0).max(1440).default(120),
132
173
  // 巩固模型路由(settings panel「巩固模型」/ dreamProvider+dreamModel):
133
174
  // dream 的记忆沉淀专用 LLM 路由,显式配置优先于 agent 默认模型(config-first,
134
175
  // Issue #25)。模型分类声明:
@@ -476,6 +517,23 @@ export const Config = z.object({
476
517
  z.const("high"),
477
518
  z.const("none")
478
519
  ]).description("同 dreamReasoningEffort:sleep 各阶段 LLM 的推理档位,未配置 = 自动取模型支持的最低档;显式 'none' = 不发送字段、用服务商自带默认。"),
520
+ // Pass-through reasoning effort for the distill (summarize) LLM call
521
+ // (issue #315). Mirrors entityExtractionReasoning rather than dream: the
522
+ // default 'none' omits the field entirely (provider default), so current
523
+ // behavior is unchanged until the user opts in — no auto-lowest resolution
524
+ // here, distill failures are retried at the window level anyway. off/low/
525
+ // medium/high are forwarded verbatim; a provider that rejects the effort
526
+ // retries once without it (withEffortFallback), so opting in is safe to
527
+ // experiment with. The failure being addressed is the #9 shape on the
528
+ // distill path: a thinking model drains the output budget on reasoning and
529
+ // the summary comes back empty/truncated.
530
+ summarizeReasoningEffort: z.union([
531
+ z.const("off"),
532
+ z.const("low"),
533
+ z.const("medium"),
534
+ z.const("high"),
535
+ z.const("none")
536
+ ]).default("none").description("蒸馏(会话总结提炼)LLM 的推理档位:默认 'none' = 不发送字段、用服务商自带默认;off/low/medium/high 原样传递,被模型拒收时自动去掉字段重试一次(与巩固/睡眠同款降级,#315)。思考型模型建议 off/low,避免推理烧光输出预算。"),
479
537
  // Issue #257:sleep 冲突/模式两阶段的输出预算(原硬编码 2048)。实测默认档
480
538
  // 每对裁决约 90 token、24 对 2097——2048 恰好压在边界(53 次运行 48 败);
481
539
  // full 档六分支实测约 290 token/对、24 对 6967,2048 必然截断。默认 8192
@@ -633,7 +691,12 @@ const LIGHT_MODE_OFF = [
633
691
  // 轻量模式不开 document 指针行(#230,opt-in:注册/注入/检索增强全随闸)。
634
692
  "documentMemoryEnabled",
635
693
  // 轻量模式不开热计算(heat 属于重型增强;关掉后 sleep 降级也退回纯时间分层)。
636
- "heatEnabled"
694
+ "heatEnabled",
695
+ // #249 N3:轻量档默认不开压缩边缘双落点——它往对话里追加消息(新的注入表面),
696
+ // 轻量档(小模型 / 小上下文)最不该再多一份注入物。这是预设给的默认值、不是强制:
697
+ // 用户显式勾选仍然赢(合并顺序「用户开关 > 轻量预设 > bundle 配置」,同
698
+ // injectGuidanceEnabled)。
699
+ "continuityRescueEnabled"
637
700
  ];
638
701
 
639
702
  /**
@@ -670,7 +733,7 @@ export function applyLightModePreset(cfg) {
670
733
  * 是扁平键,不受这条限制。
671
734
  */
672
735
  export const INJECT_CHILD_FLAGS = Object.freeze({
673
- autoInject: Object.freeze(["injectGuidanceEnabled"])
736
+ autoInject: Object.freeze(["injectGuidanceEnabled", "continuityRescueEnabled"])
674
737
  });
675
738
 
676
739
  /** 子开关的运行时生效值:父开关显式关(false)时恒不生效。 */
@@ -0,0 +1,220 @@
1
+ import { createUserMessage } from "@deepseek-ai/dsh-llm";
2
+
3
+ // #249 N3(压缩边缘双落点):上下文即将大幅精简前,抢救「正在做什么」。
4
+ //
5
+ // 为什么是这个形状(#249 §4.4 / §6.2 / §11;维护者 2026-09-24 拍板「都同意,开工」):
6
+ //
7
+ // 1. **必须双落点**。宿主的压缩摘要器只看对话里的内容:压缩时宿主按固定字段模板
8
+ // 重建对话,只放系统提示段的东西不进这次重建,等于白写。所以除了落库,还要把同
9
+ // 一条连续性快照追加成靠近序列末尾的消息。DSH 没有 in-memory 消息改写钩子
10
+ // (§7 已核),因此这是「注入 + 序号引用寻址」,不是改写历史。
11
+ // 2. **落库落成提案,不落成记忆行**。边缘产出直接进 memories,长会话会攒出第 N 条
12
+ // 同主题条目(#275 记的失败形态);转正通道与 #254 的二次确认共用一套、阶段二才
13
+ // 打开,本批只写 pending 行。
14
+ // 3. **全程不调模型**。三个字段是确定性抽取 + 截断(口径见 deriveContinuity),判定
15
+ // 也只用本地字符串比较;门控成本为零。
16
+ // 4. **触发器就是宿主真的压缩**,不自定一套阈值。宿主在 `agent/pre-step`(压力)与
17
+ // `agent/request-error`(溢出)两处触发压缩,我们只订阅压缩事件
18
+ // (`compaction/start|summary|end`,都在 @deepseek-ai/dsh-session 的事件白名单里
19
+ // `lib/types/known-event-types.js:31-34`)。自定阈值会与宿主口径漂移,还多一份没
20
+ // 人校准的参数;「压缩边缘」本来就有宿主自己下的定义。
21
+ //
22
+ // 落点顺序(为什么 pre-step 里挂得住):宿主的压缩插件在它自己的 pre-step 监听器里先
23
+ // `await compactIfNeeded(...)` 再 `return next()`,所以我们的监听器拿到 `next()` 结果
24
+ // 时压缩事件已经落库;而我们返回的 `decision.messages` 由宿主在本步末尾以
25
+ // `surfaceOp: "append"` 追加(`@deepseek-ai/dsh-agent-loop/lib/index.js:1046`),晚于
26
+ // 压缩的 `surfaceOp: "replace"`(`dsh-compaction-basic/lib/index.js:649`)——落点天然
27
+ // 在压缩之后、靠近序列末尾。两条注册顺序都成立(谁先谁后,我们的 `await next()` 都在
28
+ // 压缩工作之后返回)。
29
+
30
+ /** 提案的 kind(唯一键的一半):一次会话里"压缩边缘"这一类只留一条。 */
31
+ export const CONTINUITY_KIND = "compaction-edge";
32
+ /** 注入消息的统一前缀:机械校验与「不变不重复」判重都靠它(#249 §6.3)。 */
33
+ export const CONTINUITY_NOTICE_PREFIX = "[dsh-mneme continuity]";
34
+ /** 单字段字符上限:抽取是截断,不是摘要(改口径不需要动调用方)。 */
35
+ export const CONTINUITY_FIELD_MAX = 200;
36
+ /** 整条注入消息的字符上限。比 §6.3 的 160B 单行提醒宽:它是结构化快照,不是一句话提醒。 */
37
+ export const CONTINUITY_NOTICE_MAX = 900;
38
+ /** 边缘表的兜底上限:只留序号、且有界——压缩完就没有下一次 pre-step 的会话不走消费路径。 */
39
+ const MAX_TRACKED_EDGES = 50;
40
+
41
+ const EDGE_EVENT_TYPES = new Set(["compaction/start", "compaction/summary", "compaction/end"]);
42
+
43
+ // 与 summarize.js 的 collectMessages 同一个口径:只取公开文本块,reasoning 私有推理
44
+ // 不进快照(快照可能被宿主重建进摘要)。刻意副本而不是导出——那边是会话蒸馏的内部
45
+ // 细节,两边取文本的理由不同。
46
+ function textOf(content) {
47
+ if (typeof content === "string") return content;
48
+ if (!Array.isArray(content)) return "";
49
+ return content
50
+ .map((block) => (typeof block === "string" ? block : (block?.type === "text" && typeof block.text === "string" ? block.text : "")))
51
+ .filter((s) => s)
52
+ .join("\n");
53
+ }
54
+
55
+ // DSH 0.1.2-rc.1 起 Session 用 snapshotEvents(),兼容旧版 .events。这是**全量日志**
56
+ // (append-only,含已被 surfaceOp:"replace" 折叠出表面的旧事件)——抽取要的正是全量:
57
+ // 被压缩折叠掉的最后一条真实用户消息,也曾是"正在做什么"。
58
+ function sessionEvents(session) {
59
+ let events = typeof session?.snapshotEvents === "function" ? session.snapshotEvents() : session?.events;
60
+ // 与 summarize.js 的同名兼容层同形:snapshotEvents 不可用或返回非数组时退回 .events。
61
+ if (!Array.isArray(events)) events = session?.events;
62
+ return Array.isArray(events) ? events : [];
63
+ }
64
+
65
+ // 「当前表面」= `session.surface.nodes`(只存活节点的 seq,事件体在上面那份日志里)。
66
+ // 判重必须按表面而不是全量日志:旧注入还在日志里、但已被折叠出表面,照日志判重会让
67
+ // 这一次压缩后该补的注入被静默跳过——双落点的 ② 就空了一次,而日志里看不出发生过。
68
+ // 宿主没有表面投影(旧版)时退回全量:宁可少追加一次,也不在表面里重复追加同一份文本。
69
+ function surfaceEvents(session) {
70
+ const events = sessionEvents(session);
71
+ let nodes;
72
+ // 宿主的 `surface.nodes` 是会抛的 getter(它自己用过、之后又被移除或替换的 message
73
+ // projection 会让宿主内部的 `_assertProjections` 抛)。判重是锦上添花的一步:读不到就
74
+ // 退回全量,别让一个可选的抢救功能因为读宿主状态失败而把 agent 的这一步变失败。
75
+ try {
76
+ nodes = session?.surface?.nodes;
77
+ } catch {
78
+ return events;
79
+ }
80
+ if (!Array.isArray(nodes)) return events;
81
+ const inSurface = new Set(nodes);
82
+ return events.filter((event) => inSurface.has(event?.seq));
83
+ }
84
+
85
+ /**
86
+ * 抽取连续性三字段(纯函数、无 LLM、无副作用,供测试直接钉住口径):
87
+ * - `currentWork`:最近一条**真实** user/message(插件注入与子代理上报不算用户指令),
88
+ * 取头部 CONTINUITY_FIELD_MAX 字符——"正在做什么"就是最后那条指令。
89
+ * - `nextStep`:最近一条 assistant/message 的**尾部**同长度——"下一步"活在末尾那
90
+ * 一段/最后一句里,取头会把开场白带进来。
91
+ * - `openQuestions`:恒 null。确定性抽取判不出「哪些问题还没解决」,硬用反引号/问号
92
+ * 猜会给出似是而非的字段;这个字段留给转正通道与人工补(不是所有字段都必须自动
93
+ * 填满——宁可空着,也不编造)。空字段在注入文本里如实标 (none)。
94
+ */
95
+ export function deriveContinuity(session, fieldMax = CONTINUITY_FIELD_MAX) {
96
+ let currentWork = null;
97
+ let nextStep = null;
98
+ for (const event of sessionEvents(session)) {
99
+ const data = event?.data ?? {};
100
+ if (event?.type === "user/message") {
101
+ // 只认用户自己写的:source.kind 缺省(旧事件)或 "user"。插件消息(我们自己的
102
+ // 注入、其它插件)与子代理上报都不是"当前指令"。
103
+ const kind = data?.source?.kind;
104
+ if (kind !== undefined && kind !== "user") continue;
105
+ const text = textOf(data.content).trim();
106
+ if (text) currentWork = text.slice(0, fieldMax);
107
+ } else if (event?.type === "assistant/message") {
108
+ const text = textOf(data?.message?.content).trim();
109
+ if (text) nextStep = text.slice(-fieldMax);
110
+ }
111
+ }
112
+ return { currentWork, nextStep, openQuestions: null };
113
+ }
114
+
115
+ /** 单行化:字段里的换行会把「靠近末尾的一条消息」摊成多行,也破坏 §6.3 的单行约定。 */
116
+ function oneLine(text) {
117
+ return typeof text === "string" ? text.replace(/\s*\n\s*/g, " ").trim() : "";
118
+ }
119
+
120
+ /** 渲染注入文本(纯函数):统一前缀 + 三个固定字段(#249 §6.2)。 */
121
+ export function renderContinuityNotice(fields, noticeMax = CONTINUITY_NOTICE_MAX) {
122
+ const body = [
123
+ `${CONTINUITY_NOTICE_PREFIX} Context compaction happened in this session; here is the continuity snapshot ` +
124
+ "recorded so it survives outside the transcript (a proposal — not yet in the memory store):",
125
+ `current_work: ${oneLine(fields?.currentWork) || "(none)"}`,
126
+ `next_step: ${oneLine(fields?.nextStep) || "(none)"}`,
127
+ `open_questions: ${oneLine(fields?.openQuestions) || "(none)"}`
128
+ ].join("\n");
129
+ return body.length > noticeMax ? body.slice(0, noticeMax) : body;
130
+ }
131
+
132
+ /** 表面上是否已经有同一条注入(「不变不重复」,#249 §6.5):按前缀 + 全文比较,
133
+ * 不做语义判定——没有改写钩子,能做的只有"同一份文本不追加第二次"。 */
134
+ function noticeAlreadyInSurface(session, text) {
135
+ return surfaceEvents(session).some((event) => {
136
+ if (event?.type !== "user/message") return false;
137
+ if (event?.data?.source?.plugin !== "dsh-mneme") return false;
138
+ const content = textOf(event?.data?.content);
139
+ return content === text;
140
+ });
141
+ }
142
+
143
+ /**
144
+ * 挂两条监听(#249 §7 的 `agent/pre-step` 触发位 + 压缩事件面),返回 { dispose }。
145
+ *
146
+ * `store` 直接注入:本模块只写提案表、不碰检索/嵌入,没有用到 service 的任何能力。
147
+ * 调用方负责父/子闸门(`autoInject` + `continuityRescueEnabled`);本函数不做开关判定,
148
+ * 保持"挂上就是开"的单一口径。
149
+ */
150
+ export function createContinuityRescue(ctx, store) {
151
+ // 待处理的边缘:sessionId → 压缩事件序号。压缩事件到达时置位,下一次 pre-step 消费后
152
+ // 删除(同一个边缘绝不注入第二次)。只留序号、不留 Session:插件在宿主进程里只 apply
153
+ // 一次、服务所有会话,握着 Session 就是握着它的全量日志不放——压缩完就收工、不再有
154
+ // pre-step 是常见路径,这些条目永远走不到消费路径上。
155
+ const edge = new Map();
156
+
157
+ const unsubscribe = ctx.on("session/event", (session, event) => {
158
+ if (!EDGE_EVENT_TYPES.has(event?.type)) return;
159
+ if (!session?.id) return;
160
+ if (!edge.has(session.id) && edge.size >= MAX_TRACKED_EDGES) {
161
+ // 兜底淘汰最旧的(Map 是插入序):这里漏掉最坏也只少一次注入,不会无界增长。
162
+ edge.delete(edge.keys().next().value);
163
+ }
164
+ // 同一轮压缩会依次落 start/summary/end,后到的覆盖前一个:留着最后一个(更有
165
+ // 信息——summary 带摘要事件 seq),序号即触发证据。
166
+ edge.set(session.id, typeof event.seq === "number" ? event.seq : null);
167
+ });
168
+
169
+ const offStep = ctx.on("agent/pre-step", async (payload, next) => {
170
+ const decision = await next();
171
+ // reject 是"这一步不该发生",往里面塞消息没有意义;未知形状原样放行。
172
+ if (decision?.kind !== "enter") return decision;
173
+ const session = payload?.agent?.session;
174
+ const sessionId = session?.id;
175
+ if (!sessionId) return decision;
176
+ if (!edge.has(sessionId)) return decision;
177
+ const edgeSeq = edge.get(sessionId);
178
+ edge.delete(sessionId);
179
+
180
+ // 抽取就地用 payload 上的这个 session(与置位时同一个 id),不从边缘表里握对象引用。
181
+ const fields = deriveContinuity(session);
182
+ // 落库先于注入:提案是"脱离对话独立存活"的那一半。写不进去不算宿主这一步的失败——
183
+ // 可选插件的可选能力把 agent 的一步带崩,比丢一次抢救严重得多(本函数的不变量)。
184
+ try {
185
+ const saved = store.saveContinuityProposal({
186
+ sessionId,
187
+ kind: CONTINUITY_KIND,
188
+ currentWork: fields.currentWork,
189
+ nextStep: fields.nextStep,
190
+ openQuestions: fields.openQuestions,
191
+ edgeSeq
192
+ });
193
+ // 弃新是"静默失效"的那一类:不报出来,运维与 §8 的触发率口径都会把它当成没触发。
194
+ if (saved?.dropped) {
195
+ ctx.logger?.warn?.(`[dsh-mneme] continuity: pending queue full, dropped the edge for session ${sessionId}`);
196
+ }
197
+ } catch (error) {
198
+ ctx.logger?.warn?.(`[dsh-mneme] continuity proposal not recorded: ${String(error)}`);
199
+ }
200
+
201
+ const text = renderContinuityNotice(fields);
202
+ if (noticeAlreadyInSurface(session, text)) return decision;
203
+ const messages = Array.isArray(decision.messages) ? decision.messages : [];
204
+ return {
205
+ ...decision,
206
+ messages: [
207
+ ...messages,
208
+ createUserMessage({ content: [{ type: "text", text }], source: { kind: "plugin", plugin: "dsh-mneme" } })
209
+ ]
210
+ };
211
+ });
212
+
213
+ return {
214
+ dispose() {
215
+ unsubscribe?.();
216
+ offStep?.();
217
+ edge.clear();
218
+ }
219
+ };
220
+ }