dsh-layered-memory 0.8.9 → 0.8.10

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
@@ -3066,7 +3066,13 @@ var __defProp = Object.defineProperty;
3066
3066
  if (rc.enabled === false) {
3067
3067
  rcVal = "停用";
3068
3068
  rcLabel = "召回命中";
3069
- rcTitle = "召回已停用(开关关闭 / 档位关闭 / 部署未启用)";
3069
+ const reasonText = {
3070
+ deploy: "部署未启用",
3071
+ global: "全局开关关闭",
3072
+ session: "会话只写",
3073
+ mode: "档位关闭"
3074
+ };
3075
+ rcTitle = rc.reason ? "召回已停用(" + (reasonText[rc.reason] ?? rc.reason) + ")" : "召回已停用(开关关闭 / 档位关闭 / 部署未启用)";
3070
3076
  } else {
3071
3077
  rcVal = (rc.hitTurns || 0) + "/" + (rc.injectedTurns || 0);
3072
3078
  rcLabel = "召回命中 · " + (rc.totalHits || 0) + " 条";
@@ -3383,6 +3389,36 @@ var __defProp = Object.defineProperty;
3383
3389
  }
3384
3390
  ),
3385
3391
  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,
3392
+ props.recall !== void 0 && props.onCommitRecall ? /* @__PURE__ */ (0, import_jsx_runtime16.jsxs)(
3393
+ "div",
3394
+ {
3395
+ style: {
3396
+ borderTop: "1px solid var(--dsh-mem-border)",
3397
+ marginTop: 10,
3398
+ paddingTop: 8,
3399
+ display: "flex",
3400
+ justifyContent: "space-between",
3401
+ alignItems: "center",
3402
+ gap: 8
3403
+ },
3404
+ children: [
3405
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)("span", { style: { fontSize: 12, color: "var(--dsh-mem-text-3)" }, children: "注入" }),
3406
+ /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(
3407
+ Segmented,
3408
+ {
3409
+ value: props.recall === null ? "follow" : props.recall ? "on" : "off",
3410
+ disabled: props.mode === "off",
3411
+ options: [
3412
+ { key: "follow", label: "跟随全局", title: "清除本会话覆盖,跟随全局召回开关" },
3413
+ { key: "on", label: "开", title: "本会话强制注入记忆" },
3414
+ { key: "off", label: "关", title: "只写:记忆照常沉淀,但不注入本会话" }
3415
+ ],
3416
+ onChange: (key) => props.onCommitRecall(key === "on" ? true : key === "off" ? false : null)
3417
+ }
3418
+ )
3419
+ ]
3420
+ }
3421
+ ) : null,
3386
3422
  props.rpc && props.sessionId ? /* @__PURE__ */ (0, import_jsx_runtime16.jsx)(SessionInfoArea, { rpc: props.rpc, sessionId: props.sessionId }) : null
3387
3423
  ]
3388
3424
  }
@@ -3397,6 +3433,8 @@ var __defProp = Object.defineProperty;
3397
3433
  const rpc = props.rpc;
3398
3434
  const sessionId = props.sessionId || props.session && props.session.sessionId;
3399
3435
  const [mode, setMode] = (0, import_react17.useState)(null);
3436
+ const [recall, setRecall] = (0, import_react17.useState)(null);
3437
+ const [recallResolved, setRecallResolved] = (0, import_react17.useState)(true);
3400
3438
  const [error, setError] = (0, import_react17.useState)(null);
3401
3439
  const [open, setOpen] = (0, import_react17.useState)(false);
3402
3440
  const wrapRef = (0, import_react17.useRef)(null);
@@ -3407,8 +3445,11 @@ var __defProp = Object.defineProperty;
3407
3445
  setError(null);
3408
3446
  rpc("dsh-memory/session-mode-get", { sessionId }).then((r) => {
3409
3447
  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");
3448
+ if (r && r.ok && r.value) {
3449
+ setMode(r.value.mode);
3450
+ setRecall(r.value.recall);
3451
+ setRecallResolved(r.value.recallResolved);
3452
+ } else setError(r && !r.ok ? r.error.message : "RPC error");
3412
3453
  }).catch((e) => {
3413
3454
  if (token !== seqRef.current) return;
3414
3455
  setError(String(e && e.message || e));
@@ -3444,25 +3485,54 @@ var __defProp = Object.defineProperty;
3444
3485
  };
3445
3486
  }, [open]);
3446
3487
  const commit = (next) => {
3447
- if (!rpc || next === mode) return;
3488
+ if (!rpc || !sessionId || mode === null || next === mode) return;
3448
3489
  const prev = mode;
3490
+ const token = seqRef.current;
3449
3491
  setMode(next);
3450
3492
  setError(null);
3451
3493
  rpc("dsh-memory/session-mode-set", { sessionId, mode: next }).then((r) => {
3494
+ if (token !== seqRef.current) return;
3452
3495
  if (!r || !r.ok) {
3453
3496
  setMode(prev);
3454
3497
  setError(r && r.error ? "档位写入失败:" + r.error.message : "档位写入失败");
3455
3498
  }
3456
3499
  }).catch((e) => {
3500
+ if (token !== seqRef.current) return;
3457
3501
  setMode(prev);
3458
3502
  setError("档位写入失败:" + String(e && e.message || e));
3459
3503
  });
3460
3504
  };
3505
+ const commitRecall = (next) => {
3506
+ if (!rpc || !sessionId || mode === null || next === recall) return;
3507
+ const prevRecall = recall;
3508
+ const prevResolved = recallResolved;
3509
+ const token = seqRef.current;
3510
+ setRecall(next);
3511
+ if (next !== null) setRecallResolved(next);
3512
+ setError(null);
3513
+ rpc("dsh-memory/session-mode-set", { sessionId, mode, recall: next }).then((r) => {
3514
+ if (token !== seqRef.current) return;
3515
+ if (!r || !r.ok) {
3516
+ setRecall(prevRecall);
3517
+ setRecallResolved(prevResolved);
3518
+ setError(r && r.error ? "注入设置失败:" + r.error.message : "注入设置失败");
3519
+ } else {
3520
+ setRecall(r.value.recall);
3521
+ setRecallResolved(r.value.recallResolved);
3522
+ }
3523
+ }).catch((e) => {
3524
+ if (token !== seqRef.current) return;
3525
+ setRecall(prevRecall);
3526
+ setRecallResolved(prevResolved);
3527
+ setError("注入设置失败:" + String(e && e.message || e));
3528
+ });
3529
+ };
3461
3530
  if (!sessionId || !rpc) return null;
3462
3531
  const info = modeInfo(mode);
3463
3532
  const loaded = mode !== null;
3464
3533
  const isOff = loaded && mode === "off";
3465
3534
  const isFlow = loaded && !isOff;
3535
+ const faceLabel = !loaded ? error ? "⚠" : "…" : isOff ? info.label : !recallResolved ? "只写" : info.label;
3466
3536
  ensureThemeStyle();
3467
3537
  const pillStyle = {
3468
3538
  display: "inline-flex",
@@ -3497,11 +3567,22 @@ var __defProp = Object.defineProperty;
3497
3567
  style: pillStyle,
3498
3568
  children: [
3499
3569
  "记忆 · ",
3500
- /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { children: loaded ? info.label : error ? "⚠" : "…" })
3570
+ /* @__PURE__ */ (0, import_jsx_runtime17.jsx)("span", { children: faceLabel })
3501
3571
  ]
3502
3572
  }
3503
3573
  ),
3504
- open ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(ModeSlider, { mode: mode || "auto", onCommit: commit, error, rpc, sessionId }) : null
3574
+ open ? /* @__PURE__ */ (0, import_jsx_runtime17.jsx)(
3575
+ ModeSlider,
3576
+ {
3577
+ mode: mode || "auto",
3578
+ onCommit: commit,
3579
+ recall: loaded ? recall : void 0,
3580
+ onCommitRecall: commitRecall,
3581
+ error,
3582
+ rpc,
3583
+ sessionId
3584
+ }
3585
+ ) : null
3505
3586
  ] });
3506
3587
  }
3507
3588
 
@@ -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.10",
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",