dsh-layered-memory 0.8.9 → 0.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -159,6 +159,16 @@ trajectory view):
159
159
  - Each session's choice is persisted by sessionId to `session-modes.json`, surviving
160
160
  restarts/session restore; stacks with the global switches (global is the master gate);
161
161
  L2/L3 are fully family-isolated — content never leaks across families.
162
+ - **Write-only sessions (#38)**: a three-state "injection" switch inside the popover
163
+ (follow global / on / off) — set to "off" for a **write-only session**: capture and
164
+ distillation continue as usual (conversation still settles into L0→L1→L2/L3), but
165
+ nothing is injected into this session (recall injection, the persona/navigation
166
+ stable section and the tools guide all stop; `memory_search` and the other read
167
+ tools return a write-only notice). The pill face changes to `Memory · Write-only`;
168
+ the override persists per session, and switching back to "follow global" clears it
169
+ to the settings-page recall toggle. Ideal for debug/eval/sensitive sessions that
170
+ should absorb without interference. Orthogonal to the off mode: off remains full
171
+ stealth (capture off too), while write-only keeps the "in" and gates the "out".
162
172
 
163
173
  ## UI Preview
164
174
 
package/README.md CHANGED
@@ -124,6 +124,12 @@ L1/L2/L3 层级过滤)、层级 × 时间窗口表格(调用数 / 输出与
124
124
  打开期间自适应轮询(忙 2s / 静 5s),关闭即停;
125
125
  - 每会话的选择按 sessionId 持久化到 `session-modes.json`,重启/恢复会话不丢;
126
126
  与全局开关叠加(全局是总闸);L2/L3 完全分类,分类内容不渗透。
127
+ - **只写不读(#38)**:悬浮板内「注入」三态开关(跟随全局 / 开 / 关)——设为「关」
128
+ 即**只写会话**:捕获与蒸馏照常(对话照常沉淀为 L0→L1→L2/L3),但不向本会话注入
129
+ 任何记忆(召回注入、画像/导航稳定区、工具指南一并停止;`memory_search` 等读工具
130
+ 返回只写提示)。pill 面文换作 `记忆·只写` 提示状态;覆盖按会话持久化,切回
131
+ 「跟随全局」即清除、跟随设置页召回开关;适合调试/评测/敏感会话「只吸收不干扰」。
132
+ 与 off 档正交:off 仍是完全隐身(连捕获都关),只写保留「进」关「出」。
127
133
 
128
134
  ## 界面预览
129
135
 
package/dist/client.js CHANGED
@@ -417,6 +417,19 @@ var __defProp = Object.defineProperty;
417
417
  ".dsh-mem-pill-off:focus-visible { outline: 2px solid var(--dsh-mem-accent); outline-offset: 1px; }",
418
418
  // 流光态焦点环(同一物理按钮的两态焦点反馈对称,配方同 .dsh-mem-btn)
419
419
  ".dsh-mem-flow:focus-visible { outline: 2px solid var(--dsh-mem-accent); outline-offset: 1px; }",
420
+ // ── 触屏隐形热区(手机端适配):触点目标补足 44px 标准(iOS HIG)。
421
+ // 伪元素不参与布局(浮层/输入栏几何零变化),指针事件落在宿主元素上。
422
+ // 全端统一不做 pointer:coarse 分端(桌面点中目标变大是纯收益)。
423
+ // pill:视觉高 24px,::after 上下各外扩 10px;只上下不左右——左右是宿主输入栏
424
+ // 邻位控件(模式选择器),外扩制造误触重叠带。
425
+ // 滑轨:视觉轨 22px,::before 上下各外扩 11px;touch-action:none 已随轨声明,
426
+ // 伪元素区的触摸同样命中轨元素。两处 px 值改须与注释口径同步 ──
427
+ ".dsh-mem-pill-hit::after {",
428
+ " content: ''; position: absolute; left: 0; right: 0; top: -10px; bottom: -10px;",
429
+ "}",
430
+ ".dsh-mem-hitband::before {",
431
+ " content: ''; position: absolute; left: 0; right: 0; top: -11px; bottom: -11px;",
432
+ "}",
420
433
  // ── 浮层(dsh 原生菜单同配方:不透明实底 + inverted 描边(浅色不可见)+ lv3 阴影) ──
421
434
  ".dsh-mem-popover {",
422
435
  " border-radius: 12px;",
@@ -3066,7 +3079,13 @@ var __defProp = Object.defineProperty;
3066
3079
  if (rc.enabled === false) {
3067
3080
  rcVal = "停用";
3068
3081
  rcLabel = "召回命中";
3069
- rcTitle = "召回已停用(开关关闭 / 档位关闭 / 部署未启用)";
3082
+ const reasonText = {
3083
+ deploy: "部署未启用",
3084
+ global: "全局开关关闭",
3085
+ session: "会话只写",
3086
+ mode: "档位关闭"
3087
+ };
3088
+ rcTitle = rc.reason ? "召回已停用(" + (reasonText[rc.reason] ?? rc.reason) + ")" : "召回已停用(开关关闭 / 档位关闭 / 部署未启用)";
3070
3089
  } else {
3071
3090
  rcVal = (rc.hitTurns || 0) + "/" + (rc.injectedTurns || 0);
3072
3091
  rcLabel = "召回命中 · " + (rc.totalHits || 0) + " 条";
@@ -3266,6 +3285,35 @@ var __defProp = Object.defineProperty;
3266
3285
  themeObs.disconnect();
3267
3286
  };
3268
3287
  }, []);
3288
+ const popRef = (0, import_react16.useRef)(null);
3289
+ const shiftRef = (0, import_react16.useRef)(0);
3290
+ const [shiftX, setShiftX] = (0, import_react16.useState)(0);
3291
+ (0, import_react16.useLayoutEffect)(() => {
3292
+ const clamp = () => {
3293
+ const el = popRef.current;
3294
+ if (!el) return;
3295
+ const r = el.getBoundingClientRect();
3296
+ if (r.width === 0) return;
3297
+ const left = r.left - shiftRef.current;
3298
+ const edge = 8;
3299
+ let next = 0;
3300
+ if (left < edge) next = edge - left;
3301
+ else if (left + r.width > window.innerWidth - edge) {
3302
+ next = window.innerWidth - edge - (left + r.width);
3303
+ }
3304
+ if (next !== shiftRef.current) {
3305
+ shiftRef.current = next;
3306
+ setShiftX(next);
3307
+ }
3308
+ };
3309
+ clamp();
3310
+ window.addEventListener("resize", clamp);
3311
+ const iv = window.setInterval(clamp, 100);
3312
+ return () => {
3313
+ window.removeEventListener("resize", clamp);
3314
+ window.clearInterval(iv);
3315
+ };
3316
+ }, []);
3269
3317
  const stops = [];
3270
3318
  for (let i = 0; i < MODES.length; i++) {
3271
3319
  const stopLeft = i / (MODES.length - 1) * INNER_W + THUMB / 2;
@@ -3292,11 +3340,12 @@ var __defProp = Object.defineProperty;
3292
3340
  return /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3293
3341
  "div",
3294
3342
  {
3343
+ ref: popRef,
3295
3344
  style: {
3296
3345
  position: "absolute",
3297
3346
  bottom: "calc(100% + 8px)",
3298
3347
  left: "50%",
3299
- transform: "translateX(-50%)",
3348
+ transform: "translateX(calc(-50% + " + shiftX + "px))",
3300
3349
  zIndex: 1e3
3301
3350
  },
3302
3351
  children: /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
@@ -3309,6 +3358,7 @@ var __defProp = Object.defineProperty;
3309
3358
  "div",
3310
3359
  {
3311
3360
  ref: trackRef,
3361
+ className: "dsh-mem-hitband",
3312
3362
  style: {
3313
3363
  position: "relative",
3314
3364
  // 容器宽 = thumb 活动范围(0..INNER_W + THUMB),点击映射与视觉两端严格对齐
@@ -3383,6 +3433,36 @@ var __defProp = Object.defineProperty;
3383
3433
  }
3384
3434
  ),
3385
3435
  props.error ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("div", { style: { fontSize: 11, color: "var(--dsh-mem-danger)", marginTop: 10, whiteSpace: "nowrap" }, children: props.error }) : null,
3436
+ props.recall !== void 0 && props.onCommitRecall ? /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
3437
+ "div",
3438
+ {
3439
+ style: {
3440
+ borderTop: "1px solid var(--dsh-mem-border)",
3441
+ marginTop: 10,
3442
+ paddingTop: 8,
3443
+ display: "flex",
3444
+ justifyContent: "space-between",
3445
+ alignItems: "center",
3446
+ gap: 8
3447
+ },
3448
+ children: [
3449
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: { fontSize: 12, color: "var(--dsh-mem-text-3)" }, children: "注入" }),
3450
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3451
+ Segmented,
3452
+ {
3453
+ value: props.recall === null ? "follow" : props.recall ? "on" : "off",
3454
+ disabled: props.mode === "off",
3455
+ options: [
3456
+ { key: "follow", label: "跟随全局", title: "清除本会话覆盖,跟随全局召回开关" },
3457
+ { key: "on", label: "开", title: "本会话强制注入记忆" },
3458
+ { key: "off", label: "关", title: "只写:记忆照常沉淀,但不注入本会话" }
3459
+ ],
3460
+ onChange: (key) => props.onCommitRecall(key === "on" ? true : key === "off" ? false : null)
3461
+ }
3462
+ )
3463
+ ]
3464
+ }
3465
+ ) : null,
3386
3466
  props.rpc && props.sessionId ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(SessionInfoArea, { rpc: props.rpc, sessionId: props.sessionId }) : null
3387
3467
  ]
3388
3468
  }
@@ -3397,6 +3477,8 @@ var __defProp = Object.defineProperty;
3397
3477
  const rpc = props.rpc;
3398
3478
  const sessionId = props.sessionId || props.session && props.session.sessionId;
3399
3479
  const [mode, setMode] = (0, import_react17.useState)(null);
3480
+ const [recall, setRecall] = (0, import_react17.useState)(null);
3481
+ const [recallResolved, setRecallResolved] = (0, import_react17.useState)(true);
3400
3482
  const [error, setError] = (0, import_react17.useState)(null);
3401
3483
  const [open, setOpen] = (0, import_react17.useState)(false);
3402
3484
  const wrapRef = (0, import_react17.useRef)(null);
@@ -3407,8 +3489,11 @@ var __defProp = Object.defineProperty;
3407
3489
  setError(null);
3408
3490
  rpc("dsh-memory/session-mode-get", { sessionId }).then((r) => {
3409
3491
  if (token !== seqRef.current) return;
3410
- if (r && r.ok && r.value) setMode(r.value.mode);
3411
- else setError(r && !r.ok ? r.error.message : "RPC error");
3492
+ if (r && r.ok && r.value) {
3493
+ setMode(r.value.mode);
3494
+ setRecall(r.value.recall);
3495
+ setRecallResolved(r.value.recallResolved);
3496
+ } else setError(r && !r.ok ? r.error.message : "RPC error");
3412
3497
  }).catch((e) => {
3413
3498
  if (token !== seqRef.current) return;
3414
3499
  setError(String(e && e.message || e));
@@ -3436,35 +3521,66 @@ var __defProp = Object.defineProperty;
3436
3521
  const onKey = (e) => {
3437
3522
  if (e.key === "Escape") setOpen(false);
3438
3523
  };
3439
- document.addEventListener("mousedown", onDown);
3524
+ document.addEventListener("pointerdown", onDown);
3440
3525
  document.addEventListener("keydown", onKey);
3441
3526
  return () => {
3442
- document.removeEventListener("mousedown", onDown);
3527
+ document.removeEventListener("pointerdown", onDown);
3443
3528
  document.removeEventListener("keydown", onKey);
3444
3529
  };
3445
3530
  }, [open]);
3446
3531
  const commit = (next) => {
3447
- if (!rpc || next === mode) return;
3532
+ if (!rpc || !sessionId || mode === null || next === mode) return;
3448
3533
  const prev = mode;
3534
+ const token = seqRef.current;
3449
3535
  setMode(next);
3450
3536
  setError(null);
3451
3537
  rpc("dsh-memory/session-mode-set", { sessionId, mode: next }).then((r) => {
3538
+ if (token !== seqRef.current) return;
3452
3539
  if (!r || !r.ok) {
3453
3540
  setMode(prev);
3454
3541
  setError(r && r.error ? "档位写入失败:" + r.error.message : "档位写入失败");
3455
3542
  }
3456
3543
  }).catch((e) => {
3544
+ if (token !== seqRef.current) return;
3457
3545
  setMode(prev);
3458
3546
  setError("档位写入失败:" + String(e && e.message || e));
3459
3547
  });
3460
3548
  };
3549
+ const commitRecall = (next) => {
3550
+ if (!rpc || !sessionId || mode === null || next === recall) return;
3551
+ const prevRecall = recall;
3552
+ const prevResolved = recallResolved;
3553
+ const token = seqRef.current;
3554
+ setRecall(next);
3555
+ if (next !== null) setRecallResolved(next);
3556
+ setError(null);
3557
+ rpc("dsh-memory/session-mode-set", { sessionId, mode, recall: next }).then((r) => {
3558
+ if (token !== seqRef.current) return;
3559
+ if (!r || !r.ok) {
3560
+ setRecall(prevRecall);
3561
+ setRecallResolved(prevResolved);
3562
+ setError(r && r.error ? "注入设置失败:" + r.error.message : "注入设置失败");
3563
+ } else {
3564
+ setRecall(r.value.recall);
3565
+ setRecallResolved(r.value.recallResolved);
3566
+ }
3567
+ }).catch((e) => {
3568
+ if (token !== seqRef.current) return;
3569
+ setRecall(prevRecall);
3570
+ setRecallResolved(prevResolved);
3571
+ setError("注入设置失败:" + String(e && e.message || e));
3572
+ });
3573
+ };
3461
3574
  if (!sessionId || !rpc) return null;
3462
3575
  const info = modeInfo(mode);
3463
3576
  const loaded = mode !== null;
3464
3577
  const isOff = loaded && mode === "off";
3465
3578
  const isFlow = loaded && !isOff;
3579
+ const faceLabel = !loaded ? error ? "⚠" : "…" : isOff ? info.label : !recallResolved ? "只写" : info.label;
3466
3580
  ensureThemeStyle();
3467
3581
  const pillStyle = {
3582
+ position: "relative",
3583
+ // dsh-mem-pill-hit 的 ::after 隐形热区以此为定位基准
3468
3584
  display: "inline-flex",
3469
3585
  alignItems: "center",
3470
3586
  gap: 4,
@@ -3493,15 +3609,26 @@ var __defProp = Object.defineProperty;
3493
3609
  if (error) load();
3494
3610
  setOpen(!open);
3495
3611
  },
3496
- className: isFlow ? "dsh-mem-flow" : "dsh-mem-pill-off",
3612
+ className: (isFlow ? "dsh-mem-flow" : "dsh-mem-pill-off") + " dsh-mem-pill-hit",
3497
3613
  style: pillStyle,
3498
3614
  children: [
3499
3615
  "记忆 · ",
3500
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { children: loaded ? info.label : error ? "⚠" : "…" })
3616
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { children: faceLabel })
3501
3617
  ]
3502
3618
  }
3503
3619
  ),
3504
- open ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(ModeSlider, { mode: mode || "auto", onCommit: commit, error, rpc, sessionId }) : null
3620
+ open ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
3621
+ ModeSlider,
3622
+ {
3623
+ mode: mode || "auto",
3624
+ onCommit: commit,
3625
+ recall: loaded ? recall : void 0,
3626
+ onCommitRecall: commitRecall,
3627
+ error,
3628
+ rpc,
3629
+ sessionId
3630
+ }
3631
+ ) : null
3505
3632
  ] });
3506
3633
  }
3507
3634
 
@@ -75,6 +75,8 @@ export interface MemoryLiveSettings {
75
75
  * 跟随(静态层链 → 全局解析逐级兜底)。写入经 settings-set 逐层校验(头行必须显式)。 */
76
76
  distillLayerChains: Record<LayerRouteKey, DistillChainEntry[]>;
77
77
  }
78
+ /** 召回停用原因(session-stats recall.enabled=false 时带出;短路序第一个为假的因子)。 */
79
+ export type RecallDisabledReason = 'deploy' | 'global' | 'session' | 'mode';
78
80
  /** 单会话召回统计(悬浮卡信息区数据源;口径见 recall.ts 注释)。 */
79
81
  export interface RecallSessionStats {
80
82
  /** 发生过召回检索的轮次数(含零命中、全量压制与超时)。 */
@@ -332,14 +334,27 @@ export interface SessionModeGetResponse {
332
334
  sessionId: string;
333
335
  mode: MemoryMode;
334
336
  defaultMode: MemoryMode;
337
+ /** 会话级注入覆盖(#38 只写不读):null = 未覆盖(跟随全局)——线上 JSON 用 null
338
+ * 不用 undefined(序列化丢包)。 */
339
+ recall: boolean | null;
340
+ /** host 解析后的注入生效值(会话覆盖 ?? 全局开关):pill 面文直接消费,
341
+ * client 无需另知全局开关。 */
342
+ recallResolved: boolean;
335
343
  }
336
344
  export interface SessionModeSetRequest {
337
345
  sessionId: string;
338
346
  mode: MemoryMode;
347
+ /** 会话级注入覆盖:布尔 = 设置覆盖;显式 null = 清除(跟随全局);缺省 = 不动
348
+ * (旧 client 纯切档兼容,覆盖不丢)。mode 与 recall 可独立设置。 */
349
+ recall?: boolean | null;
339
350
  }
340
351
  export interface SessionModeSetResponse {
341
352
  sessionId: string;
342
353
  mode: MemoryMode;
354
+ /** 设置后的覆盖态(null = 跟随全局)。 */
355
+ recall: boolean | null;
356
+ /** 设置后的注入生效值(client 面文直接消费;清除覆盖后由 host 告知解析结果)。 */
357
+ recallResolved: boolean;
343
358
  }
344
359
  /** dsh-memory/session-stats(悬浮卡信息区;热路径端点)。 */
345
360
  export interface SessionStatsRequest {
@@ -360,8 +375,12 @@ export type SessionStatsResponse = {
360
375
  sessionId: string;
361
376
  mode: MemoryMode;
362
377
  defaultMode: MemoryMode;
378
+ /** 注入统计 + 生效位;enabled=false 时 reason 带短路序第一个为假因子
379
+ * (deploy 部署上限 / global 全局开关 / session 会话只写 / mode 档位关闭),
380
+ * 旧宿主缺省该字段时 client 回退本地枚举文案。 */
363
381
  recall: {
364
382
  enabled: boolean;
383
+ reason?: RecallDisabledReason;
365
384
  } & RecallSessionStats;
366
385
  /** 记忆上下文占用账本(未注入过的会话为 null)。 */
367
386
  memoryOccupancy: MemoryOccupancy | null;
@@ -132,7 +132,8 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
132
132
  try {
133
133
  const s = live.get();
134
134
  const mode = modes.get(payload.agent.id);
135
- if (!s.enabled || !s.recall || mode === 'off')
135
+ // 三级读闸:主闸 off 档(完全隐身)→ 注入开关(#38:会话覆盖 ?? 全局)
136
+ if (!s.enabled || mode === 'off' || !modes.resolvedRecall(payload.agent.id, s.recall))
136
137
  return decision;
137
138
  // 只在有新的用户来源消息的步骤注入(轮首 claim 或 steering 插话);纯工具步透传
138
139
  const hasNewUserMessage = decision.messages.some((m) => m.source?.kind === 'user');
@@ -222,10 +223,9 @@ export function registerRecall(ctx, cfg, stores, logger, live, modes, dataDir) {
222
223
  * 供旧会话回填估算(RecallHooks.estimateProfileTokens)复用同一口径。
223
224
  */ const composeStableText = (agentId) => {
224
225
  const s = live.get();
225
- if (!s.enabled || !s.recall)
226
- return '';
227
226
  const mode = modes.get(agentId);
228
- if (mode === 'off')
227
+ // pre-step 同款三级读闸(#38):主闸 → off 档 → 注入开关;空串即物理离场
228
+ if (!s.enabled || mode === 'off' || !modes.resolvedRecall(agentId, s.recall))
229
229
  return '';
230
230
  // auto 档:两族按类别归组(画像/导航各一个标签,域内 <domain> 分块);纯档:单族原格式
231
231
  const body = mode === 'auto'
package/dist/index.js CHANGED
@@ -299,7 +299,7 @@ export async function apply(ctx, config) {
299
299
  }
300
300
  const recall = registerRecall(ctx, config, stores, logger, live, modes, dataDir);
301
301
  runner.setAfterRun(recall.invalidateProfile);
302
- registerMemoryTools(ctx, config, stores, logger, modes);
302
+ registerMemoryTools(ctx, config, stores, logger, modes, live);
303
303
  registerMemoryRpc(ctx, config, stores, logger, {
304
304
  degraded: () => !storageOk || db.isDegraded(),
305
305
  pending: () => runner.pendingCount,
package/dist/stats.js CHANGED
@@ -165,7 +165,16 @@ async function handleEndpoint(endpoint, payload, deps) {
165
165
  throw new Error('档位存储未初始化');
166
166
  const p = (payload ?? {});
167
167
  const sessionId = expectSessionId(p.sessionId);
168
- const v = { sessionId, mode: modes.get(sessionId), defaultMode: modes.default };
168
+ // 注入解析权威在 host:recall 是原始覆盖(null=跟随全局),recallResolved 是生效值
169
+ const s = live?.get();
170
+ const globalRecall = s?.recall ?? true;
171
+ const v = {
172
+ sessionId,
173
+ mode: modes.get(sessionId),
174
+ defaultMode: modes.default,
175
+ recall: modes.getRecall(sessionId) ?? null,
176
+ recallResolved: modes.resolvedRecall(sessionId, globalRecall),
177
+ };
169
178
  return v;
170
179
  }
171
180
  case 'dsh-memory/session-mode-set': {
@@ -177,9 +186,27 @@ async function handleEndpoint(endpoint, payload, deps) {
177
186
  if (typeof p.mode !== 'string' || !allowed.includes(p.mode)) {
178
187
  throw new Error(`非法档位: ${String(p.mode)}(允许 ${allowed.join('/')})`);
179
188
  }
189
+ // 注入覆盖可选同车(#38):布尔 = 设置覆盖;显式 null = 清除覆盖(跟随全局);
190
+ // 缺省(undefined)= 仅切档、覆盖保持不动(旧 client 永不传 recall,行为不变)。
191
+ // 校验前置:非法 recall 在任何写穿发生前拒绝(不做部分提交)
192
+ if (p.recall !== undefined && typeof p.recall !== 'boolean' && p.recall !== null) {
193
+ throw new Error(`非法注入覆盖: ${String(p.recall)}(允许 true/false/null)`);
194
+ }
180
195
  modes.set(sessionId, p.mode);
181
- deps.logger.info(`[memory] 会话档位设置 session=${sessionId} mode=${p.mode}`);
182
- const v = { sessionId, mode: p.mode };
196
+ if (typeof p.recall === 'boolean') {
197
+ modes.setRecall(sessionId, p.recall);
198
+ }
199
+ else if (p.recall === null) {
200
+ modes.setRecall(sessionId, undefined);
201
+ }
202
+ deps.logger.info(`[memory] 会话档位设置 session=${sessionId} mode=${p.mode} recall=${JSON.stringify(modes.getRecall(sessionId) ?? null)}`);
203
+ const s = live?.get();
204
+ const v = {
205
+ sessionId,
206
+ mode: p.mode,
207
+ recall: modes.getRecall(sessionId) ?? null,
208
+ recallResolved: modes.resolvedRecall(sessionId, s?.recall ?? true),
209
+ };
183
210
  return v;
184
211
  }
185
212
  // ── 会话级统计(悬浮卡信息区;热路径端点,见 SessionInfoSource 的零 I/O 硬规则) ──
@@ -192,7 +219,20 @@ async function handleEndpoint(endpoint, payload, deps) {
192
219
  const caps = sessionInfo.capabilities();
193
220
  const l0Count = await sessionInfo.l0Count(sessionId);
194
221
  const s = live?.get();
195
- const recallOn = cfg.recall.enabled && (s?.recall ?? true) && mode !== 'off';
222
+ // 注入生效四因子短路序(#38):部署上限 全局开关 会话覆盖 档位;
223
+ // disabled 时 reason 带第一个为假因子(悬浮卡停用文案的数据源)
224
+ const globalRecall = s?.recall ?? true;
225
+ const sessionRecall = modes ? modes.resolvedRecall(sessionId, globalRecall) : true;
226
+ let recallReason;
227
+ if (!cfg.recall.enabled)
228
+ recallReason = 'deploy';
229
+ else if (!globalRecall)
230
+ recallReason = 'global';
231
+ else if (!sessionRecall)
232
+ recallReason = 'session';
233
+ else if (mode === 'off')
234
+ recallReason = 'mode';
235
+ const recallOn = recallReason === undefined;
196
236
  const view = sessionInfo.runnerView(sessionId, mode);
197
237
  // lastDistillAt 统一转 ISO(与 global.lastExtractAt 口径一致,client 直接 fmtAgo)
198
238
  const distillView = { ...view, lastDistillAt: view.lastDistillAt ? new Date(view.lastDistillAt).toISOString() : null };
@@ -220,7 +260,11 @@ async function handleEndpoint(endpoint, payload, deps) {
220
260
  sessionId,
221
261
  mode,
222
262
  defaultMode: modes?.default ?? cfg.family,
223
- recall: { enabled: recallOn, ...(sessionInfo.recallStats(sessionId) ?? emptyRecallStats()) },
263
+ recall: {
264
+ enabled: recallOn,
265
+ ...(recallReason ? { reason: recallReason } : {}),
266
+ ...(sessionInfo.recallStats(sessionId) ?? emptyRecallStats()),
267
+ },
224
268
  memoryOccupancy: sessionInfo.memoryOccupancy(sessionId),
225
269
  occupancyBackfill: sessionInfo.profileEstimate
226
270
  ? {
@@ -17,6 +17,13 @@ export declare class SessionModeStore {
17
17
  get default(): MemoryMode;
18
18
  /** 同步读取:未设置过的会话返回默认档。 */
19
19
  get(sessionId: string): MemoryMode;
20
+ /** 会话级注入覆盖原始值(#38):undefined = 未覆盖,跟随全局。 */
21
+ getRecall(sessionId: string): boolean | undefined;
22
+ /** 解析后的注入开关:会话覆盖 ?? 全局运行时开关(部署级 cfg.recall.enabled
23
+ * 与主闸 s.enabled 不经此处,仍按既有硬门生效——覆盖打不穿部署上限)。 */
24
+ resolvedRecall(sessionId: string, globalRecall: boolean): boolean;
25
+ /** 设置会话级注入覆盖(#38;undefined = 清除覆盖跟随全局。写穿持久化)。 */
26
+ setRecall(sessionId: string, recall: boolean | undefined): void;
20
27
  /** 注册档位切换回调(同步调用;回调异常只记日志不阻断写穿)。 */
21
28
  setModeChangeHandler(cb: (sessionId: string, oldMode: MemoryMode, newMode: MemoryMode) => void): void;
22
29
  /** 设置会话档位(写穿持久化;持久化失败保持内存态生效)。 */
@@ -42,7 +42,12 @@ export class SessionModeStore {
42
42
  continue;
43
43
  if (now - (entry.updatedAt ?? 0) > PRUNE_MS)
44
44
  continue;
45
- this.entries.set(sid, { mode: entry.mode, updatedAt: entry.updatedAt ?? now });
45
+ this.entries.set(sid, {
46
+ mode: entry.mode,
47
+ // 非布尔视为损坏丢弃(= 跟随全局);旧文件无此键同款兼容
48
+ recall: typeof entry.recall === 'boolean' ? entry.recall : undefined,
49
+ updatedAt: entry.updatedAt ?? now,
50
+ });
46
51
  count++;
47
52
  }
48
53
  if (count > 0)
@@ -55,6 +60,25 @@ export class SessionModeStore {
55
60
  get(sessionId) {
56
61
  return this.entries.get(sessionId)?.mode ?? this.loaded;
57
62
  }
63
+ /** 会话级注入覆盖原始值(#38):undefined = 未覆盖,跟随全局。 */
64
+ getRecall(sessionId) {
65
+ return this.entries.get(sessionId)?.recall;
66
+ }
67
+ /** 解析后的注入开关:会话覆盖 ?? 全局运行时开关(部署级 cfg.recall.enabled
68
+ * 与主闸 s.enabled 不经此处,仍按既有硬门生效——覆盖打不穿部署上限)。 */
69
+ resolvedRecall(sessionId, globalRecall) {
70
+ return this.entries.get(sessionId)?.recall ?? globalRecall;
71
+ }
72
+ /** 设置会话级注入覆盖(#38;undefined = 清除覆盖跟随全局。写穿持久化)。 */
73
+ setRecall(sessionId, recall) {
74
+ const entry = this.entries.get(sessionId);
75
+ this.entries.set(sessionId, {
76
+ mode: entry?.mode ?? this.loaded,
77
+ recall,
78
+ updatedAt: Date.now(),
79
+ });
80
+ this.writeChain = this.writeChain.then(() => this.persist());
81
+ }
58
82
  /** 注册档位切换回调(同步调用;回调异常只记日志不阻断写穿)。 */
59
83
  setModeChangeHandler(cb) {
60
84
  this.onModeChange = cb;
@@ -62,7 +86,9 @@ export class SessionModeStore {
62
86
  /** 设置会话档位(写穿持久化;持久化失败保持内存态生效)。 */
63
87
  set(sessionId, mode) {
64
88
  const old = this.get(sessionId);
65
- this.entries.set(sessionId, { mode, updatedAt: Date.now() });
89
+ // 已有注入覆盖跨切档保留(#38:档位与注入正交,切档不动覆盖)
90
+ const recall = this.entries.get(sessionId)?.recall;
91
+ this.entries.set(sessionId, { mode, recall, updatedAt: Date.now() });
66
92
  this.writeChain = this.writeChain.then(() => this.persist());
67
93
  if (old !== mode && this.onModeChange) {
68
94
  try {
@@ -4,9 +4,12 @@
4
4
  * 会话档位联动:execute 的 exec.agent 即发起调用的 agent(agent.id === sessionId),
5
5
  * memory_search 按会话档位过滤族(auto 不过滤,纯档只查本族);off 档下三工具统一
6
6
  * 返回提示(本会话已对记忆系统隐身)。conversation_search 检索范围保持全库。
7
+ * 只写会话(#38:注入覆盖=关)同款拒读,notice 区分文案——写入走捕获钩子不经工具,
8
+ * 拒读不影响"只写"语义。
7
9
  */
8
10
  import type { Context } from '@deepseek-ai/cordis';
9
11
  import type { MemoryConfig } from '../config.js';
12
+ import type { LiveSettingsHandle } from '../settings.js';
10
13
  import type { L0Store } from '../store/l0.js';
11
14
  import type { L1Store } from '../store/l1.js';
12
15
  import type { PersonaStore } from '../store/persona.js';
@@ -18,4 +21,4 @@ export declare function registerMemoryTools(ctx: Context, cfg: MemoryConfig, sto
18
21
  l1: L1Store;
19
22
  scenes: Record<MemoryFamily, SceneStore>;
20
23
  persona: Record<MemoryFamily, PersonaStore>;
21
- }, logger: MemoryLogger, modes: SessionModeStore): void;
24
+ }, logger: MemoryLogger, modes: SessionModeStore, live: LiveSettingsHandle): void;
@@ -1,10 +1,12 @@
1
1
  import { defineTool } from '@deepseek-ai/dsh-tools';
2
2
  const OFF_NOTICE = '本会话的记忆档位为"关闭":该会话对记忆系统完全隐身,不读取也不写入记忆。';
3
- export function registerMemoryTools(ctx, cfg, stores, logger, modes) {
3
+ const WRITE_ONLY_NOTICE = '本会话为只写模式:记忆照常沉淀,但不读取。';
4
+ const GLOBAL_OFF_NOTICE = '记忆注入已全局停用:本会话不读取记忆(沉淀照常)。';
5
+ export function registerMemoryTools(ctx, cfg, stores, logger, modes, live) {
4
6
  if (!cfg.tools)
5
7
  return;
6
8
  /**
7
- * 调用会话的检索族(auto → undefined 不过滤;off → null 表示整体禁用)。
9
+ * 调用会话的检索族(auto → undefined 不过滤;off/只写 → null 表示整体禁用)。
8
10
  * fail-open:exec.agent 缺失(宿主调用路径未带 agent 标识)按全族检索放行——
9
11
  * 档位隔离依赖宿主正确传递 exec.agent.id,缺失只告警一次不拒绝工具调用。
10
12
  */
@@ -20,8 +22,24 @@ export function registerMemoryTools(ctx, cfg, stores, logger, modes) {
20
22
  const mode = modes.get(agentId);
21
23
  if (mode === 'off')
22
24
  return null;
25
+ // 只写会话拒读(#38,T2 裁决):与注入同属读维度,不拒则"不注入"从工具路径漏风
26
+ if (!modes.resolvedRecall(agentId, live.get().recall))
27
+ return null;
23
28
  return mode === 'auto' ? undefined : mode;
24
29
  };
30
+ /** 拒读时的归因文案(familyOfCaller 判 null 后重查内存 Map,成本可忽略):
31
+ * off 完全隐身 / 会话只写覆盖 / 全局召回关——三种停用各说各话,不谎报只写。 */
32
+ const blockNoticeOf = (agentId) => {
33
+ if (agentId !== undefined) {
34
+ if (modes.get(agentId) === 'off')
35
+ return OFF_NOTICE;
36
+ if (modes.getRecall(agentId) === false)
37
+ return WRITE_ONLY_NOTICE;
38
+ if (!modes.resolvedRecall(agentId, live.get().recall))
39
+ return GLOBAL_OFF_NOTICE;
40
+ }
41
+ return OFF_NOTICE;
42
+ };
25
43
  // ── memory_search: L1 结构化记忆 ──
26
44
  ctx.tools.register(defineTool({
27
45
  name: 'memory_search',
@@ -59,7 +77,7 @@ export function registerMemoryTools(ctx, cfg, stores, logger, modes) {
59
77
  execute: async (args, exec) => {
60
78
  const family = familyOfCaller(exec.agent?.id);
61
79
  if (family === null)
62
- return { items: [], notice: OFF_NOTICE };
80
+ return { items: [], notice: blockNoticeOf(exec.agent?.id) };
63
81
  const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
64
82
  const hits = await stores.l1.search(args.query, limit, { type: args.type || undefined, family: family ?? undefined });
65
83
  return {
@@ -107,7 +125,7 @@ export function registerMemoryTools(ctx, cfg, stores, logger, modes) {
107
125
  },
108
126
  execute: async (args, exec) => {
109
127
  if (familyOfCaller(exec.agent?.id) === null)
110
- return { items: [], notice: OFF_NOTICE };
128
+ return { items: [], notice: blockNoticeOf(exec.agent?.id) };
111
129
  const limit = Math.min(Math.max(args.limit ?? 5, 1), 20);
112
130
  const records = await stores.l0.search(args.query, limit);
113
131
  return {
@@ -141,7 +159,7 @@ export function registerMemoryTools(ctx, cfg, stores, logger, modes) {
141
159
  },
142
160
  execute: async (args, exec) => {
143
161
  if (familyOfCaller(exec.agent?.id) === null)
144
- return { content: OFF_NOTICE };
162
+ return { content: blockNoticeOf(exec.agent?.id) };
145
163
  const p = args.path.trim();
146
164
  let content;
147
165
  if (p === 'persona.md' || p === 'persona-chat.md' || p === 'persona' || p === 'persona-chat') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-layered-memory",
3
- "version": "0.8.9",
3
+ "version": "0.8.11",
4
4
  "description": "L0~L3 分层蒸馏记忆插件 for DeepSeek Harness:自动捕获对话(L0)、抽取原子记忆(L1)、整合场景块(L2)、蒸馏核心画像/团队方法论(L3),并在模型步骤前自动召回注入。移植自 MemoryCore (TencentDB Agent Memory) 的管线设计。",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",