dsh-memory-palace 1.7.2 → 1.8.0-alpha.2

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/index.js CHANGED
@@ -10,11 +10,14 @@
10
10
  // 4. 不引入 dsh-storage:用户已确认 .md 必须保留,dsh-storage-json 落 JSON 与之冲突。
11
11
  // 5. 依赖解析:本包作为标准 npm 包经 `dsh plugin --profile web add .` 装入 profile 后,
12
12
  // @deepseek-ai/* 由 profile 的 node_modules 提供(声明为 peerDependencies),运行时裸 import 即可。
13
- // 6. 记忆写入双模式(v1.1.0 起):[插件模式](默认)= 记忆公民 prompt 主动记忆 + turn/end 轻量兜底 +
14
- // 错误捕获(均不调 LLM,v0.7.1 定稿);[智能模式] = LLM 智能会话摘要(turn/end 闸门命中后调
15
- // ctx.llm.stream,增量提炼 summary→每日日志 + durable→MEMORY.md,产物带 [smart] 标记,失败降级轻量)。
16
- // 两种模式在设置页切换,互斥;切换需重启 dsh 生效。技术依据(v1.1.0 重新论证,见 ABANDONED-LLM-SUMMARY.md):
17
- // session.events / deriveEventMessage / requestHeader()?.config 系统性解决 v0.7.1 废弃时踩的 4 个坑。
13
+ // 6. 记忆写入(v1.8.0 起恒定「混合模式」):由记忆子代理每轮自动维护今日日志(章节化、标删去重),
14
+ // 长期记忆 MEMORY.md 由 agent 主动写入(章节化写入 / 整章节替换 / 门禁内全量重整)。
15
+ // v1.8.0 破坏性变更:`plugin`(记忆公民指令 + 轮次轻量兜底 + 错误捕获)与 `smart`
16
+ // (LLM 自动会话摘要)两模式连同 `memoryMode` 配置一并移除——它们的能力已被混合模式覆盖
17
+ // (主动记忆由 agent 按 HYBRID_PROACTIVE 承担,摘要由子代理写入日志承担),且格式化质量更高。
18
+ // 配置项一律「直接从 schema 摘除」,绝不收窄 union:宿主装载期校验走 standard-schema 入口
19
+ // (cordis resolveConfig → Config["~standard"].validate),收窄枚举会让存量 profile 装载期抛
20
+ // ValidationError;摘除字段则未知键原样透传、老配置值静默失效(见 DSH-CONTRACT-TRACKING.md)。
18
21
  //
19
22
  // 模块结构(v1.2.0 混合式重构):
20
23
  // - src/common/prompts.mjs / text.mjs / paths.mjs / records.mjs:纯函数与常量(可独立单测/复用)
@@ -26,10 +29,9 @@ import Schema from "@deepseek-ai/schemastery";
26
29
  // v1.6.0-rc1:适配 DSH 0.1.2-alpha.2——dsh-settings 不再导出自由函数 installSettingsSection /
27
30
  // settingsNamespace,设置节注册改用 SettingsProvider 实例方法 installSection(经 ctx.inject(["settings"]))。
28
31
  import { extractText, extractToolErrorText } from "./common/text.mjs";
29
- import { SCENE_KEYWORDS } from "./common/prompts.mjs";
30
32
  import { createPaths } from "./common/paths.mjs";
33
+ import { planModeOf } from "./common/planmode.mjs";
31
34
  import { createRecords } from "./common/records.mjs";
32
- import { eventsFrom } from "./common/session.mjs";
33
35
  import { createDistill } from "./distill.mjs";
34
36
  import { registerTools } from "./tools.mjs";
35
37
  import { registerApi } from "./api.mjs";
@@ -49,7 +51,7 @@ const MEMORY_PALACE_SETTINGS_NAMESPACE = "memory-palace";
49
51
  // source() 每次解包读 config 即得最新值,不再需要旧版 setSource 覆盖层切换。
50
52
  // - settings.yaml 已被宿主移除(导入 profile patch 一次)→ distillLogLevel 说明改口。
51
53
  export const Config = Schema.object({
52
- enabled: Schema.boolean().default(true).description("启用 memory-palace 记忆注入与每日日志写入。").volatile(),
54
+ enabled: Schema.boolean().default(true).description("启用 memory-palace:记忆注入、记忆工具与每日日志写入的总开关(v1.8.0 起为**唯一**写入开关——不再有独立的「记忆写入总开关」,插件装好并启用即视为写入启用)。").volatile(),
53
55
  // ---- v1.7.2:预设级静默(官方 minimal「裸测环境」口径)----
54
56
  // 宿主把「模式」实现为 agent 平面的 preset:minimal 预设用 persona complete:true 独占 system prompt。
55
57
  // 但本插件注册在 host 平面(profile bundle 的 insert),各通道对**每个 agent** 都生效,preset 并不会
@@ -57,34 +59,40 @@ export const Config = Schema.object({
57
59
  silentPresets: Schema.array(Schema.string()).default(["minimal"]).description("在这些 agent 预设下本插件整体静默(等价 enabled=false):不注入系统提示词与投影、记忆工具对模型隐身、不写日志、不跑记忆子代理。默认 [\"minimal\"](官方极简模式);置空数组 = 关闭该机制。判据取会话创建头 agentPreset 与 agent-preset/selected 事件(宿主公开事实)。").volatile(),
58
60
  userMemoryPath: Schema.string().default("~/.deepseek-harness/MEMORY.md").description("用户级记忆文件路径(支持 ~ 展开)。").volatile(),
59
61
  workspaceMemoryDir: Schema.string().default(".deepseek-harness/memory").description("无 buddy 目录时使用的项目级每日日志目录;项目级 MEMORY.md 位于其同级(.deepseek-harness/MEMORY.md)。").volatile(),
60
- dailyLogRetentionDays: Schema.number().default(30).description("每日日志保留天数,过期日志会被蒸馏进 MEMORY.md。").volatile(),
61
- userBudgetChars: Schema.number().default(4000).description("注入系统提示词的用户级记忆长度上限(字符)。").volatile(),
62
- workspaceBudgetChars: Schema.number().default(3000).description("注入系统提示词的工作区级记忆长度上限(字符)。").volatile(),
62
+ // v1.8.0:默认值由 4000/3000 调大为 8000/6000 —— 实测项目级 MEMORY.md 8507 字符、
63
+ // 单日日志峰值 26776 字符,旧值把它们砍掉 6 成以上。
64
+ // ⚠️ 语义(定案:保持「每文件上限」而非改成总量封顶):
65
+ // 预算对**每个文件**各生效一次,不是所有记忆文件合计的总量。注入份数 = 用户级 1
66
+ // + 项目级 MEMORY.md(dsh 原生 + dsh 旧嵌套 + 每个已存在 buddy 目录各 1,最多 4)
67
+ // + 今日日志(dsh + 每个 buddy 目录各 1,最多 3)→ 实测最坏 8 条消息 ≈ 5 万字符 = 7.1 倍。
68
+ // 故文档/文案必须写明「每个文件各自的上限」,不得写成「总量」。
69
+ // ⚠️ workspaceBudgetChars 一身三职(E 投影截断 / memory_read 每块截断 / **memory_reorganize 双门禁阈值**):
70
+ // 调它会同时移动「重整触发线」,二者保持一致(超出注入预算 = 该重整了)是刻意设计。
71
+ userBudgetChars: Schema.number().default(8000).description("用户级 MEMORY.md 经「E 投影」注入时的长度上限(字符)。⚠️ 是「每个文件各自」的上限,不是所有记忆文件合计的总量上限。").volatile(),
72
+ workspaceBudgetChars: Schema.number().default(6000).description("工作区侧注入上限(字符),一身三职:① 项目级 MEMORY.md 的 E 投影上限;② 今日工作日志的 E 投影上限——①②各自独立截断、互不挤占;③ 项目记忆重整门禁阈值(超出即允许重整)+ memory_read 读日志的每块截断预算。⚠️ 同样是「每个文件各自」的上限,不是总量:dsh 目录与每个已存在的 buddy 目录会各注一份。").volatile(),
63
73
  // 桥接 WorkBuddy / CodeBuddy 项目记忆:项目已存在这些目录时直接读写,不再单独建 .deepseek-harness/memory/。
64
74
  bridgeBuddyMemory: Schema.boolean().default(true).description("检测并直接读写 WorkBuddy / CodeBuddy 项目记忆目录。").volatile(),
65
75
  buddyWorkspaceMemoryDirs: Schema.array(Schema.string()).default([".workbuddy/memory", ".codebuddy/memory"]).description("要桥接的 buddy 项目记忆目录列表(按优先级,全部已存在目录会同步写入)。").volatile(),
66
- // ---- v0.7.1 调整:废弃插件侧 callLlm,主路径 = 记忆公民 prompt 主动记忆;turn/end 仅做轻量兜底 ----
67
- summarize: Schema.boolean().default(true).description("它是「agent 主动记忆」主路径失效时的安全网,保证实质工作不丢,代价是只留原始文本、不做总结。").volatile(),
68
- autoCaptureErrors: Schema.boolean().default(true).description("对话出错时(含代码运行报错、工具执行失败)自动把『错误现象』写入对应 MEMORY.md(『根因/方案』由 agent 按记忆公民指令场景①主动记;默认开,关闭无需重启)。").volatile(),
69
- // ---- v1.1.0:记忆模式(plugin=记忆公民指令+轮次轻量+错误捕获;smart=LLM 智能会话摘要)----
70
- // v1.6.0:+hybrid(子 agent 自动维护日志 + agent 主动维护 MEMORY.md),三档并存,切换需重启 dsh。
71
- memoryMode: Schema.union(["plugin", "smart", "hybrid"]).default("hybrid").description("记忆模式(默认 hybrid):plugin=记忆公民指令+轮次轻量+错误捕获;smart=LLM 智能会话摘要(summary→每日日志 + durable→MEMORY.md);hybrid=记忆子代理自动维护今日日志(标删去重)+ agent 主动维护 MEMORY.md(章节化写入/整章节替换/门禁内全量重整)。切换需重启 dsh 生效。").volatile(),
72
- summaryModel: Schema.string().default("").description("记忆插件当前使用的模型(智能模式会话摘要 / 手动蒸馏 / hybrid 记忆子代理共用)。留空=复用当前会话 provider/model;也可填 provider/model(如 deepseek/deepseek-chat)固定廉价模型省 token。").volatile(),
73
- summaryTimeoutMs: Schema.number().default(60000).description("蒸馏 LLM 调用的超时(毫秒),超时视为失败并降级;覆盖智能模式摘要与手动蒸馏两条链路;默认 60000(60s)。").volatile(),
76
+ // ---- v1.8.0:memoryMode / autoCaptureErrors / summarize / dailyLogRetentionDays 已移除 ----
77
+ // 旧 profile 若仍写着这些键,作为未知键原样透传、静默失效;用户下次在设置页保存时自动从 profile patch 消失。
78
+ // 「记忆写入总开关」也一并删除(v1.8.0):写入是否发生改由 `enabled`(profile config 级停用)与
79
+ // `silentPresets`(会话预设级静默)决定,不再有独立的写入闸门。
80
+ summaryModel: Schema.string().default("").description("记忆插件当前使用的模型(手动「蒸馏会话」/「蒸馏项目记忆」与记忆子代理共用)。留空=复用当前会话 provider/model;也可填 provider/model(如 deepseek/deepseek-chat)固定廉价模型省 token。").volatile(),
81
+ summaryTimeoutMs: Schema.number().default(60000).description("蒸馏 LLM 调用的超时(毫秒),超时视为失败并降级;覆盖手动「蒸馏会话」/「蒸馏项目记忆」与记忆子代理三条链路;默认 60000(60s)。").volatile(),
74
82
  distillDebugLog: Schema.boolean().default(false).description("调试开关:向 dsh 服务端 stderr 输出蒸馏 LLM 调用诊断。distillLogLevel=info 时仅输出元数据(模型解析/请求参数/流进度/错误详情,不含文本);distillLogLevel=debug 会额外打印 LLM 原始响应文本(分隔符包裹),仅限受信本地排障开启。").volatile(),
75
83
  // ---- v1.4.1:蒸馏 stderr 日志级别(平铺键,不进 UI;默认 info;distillDebugLog=true 时生效) ----
76
84
  distillLogLevel: Schema.string().default("info").description("蒸馏 stderr 日志级别:info=仅元数据诊断(默认,不打印 LLM 原始响应);debug=额外打印 LLM 原始响应文本(分隔符包裹),仅限受信本地排障开启。无需 UI 配置,经 profile 的 cordis.patch.yml 在 memory-palace 条目 config 下设置 distillLogLevel 键(dsh 0.1.7 起 settings.yaml 已被宿主移除、导入 profile patch)。").volatile(),
77
- // ---- v1.4.0:智能模式最终输出软预算(v1.4.1 起语义变更:prompt 软约束,思考不受限;实际硬上限由模型自身 maxTokens 决定) ----
78
- summaryMaxTokens: Schema.number().default(2000).description("会话摘要 LLM 的最终输出软预算(prompt 约束,思考不受限;实际硬上限由模型自身 maxTokens 决定)。默认 2000;可调大以容纳更多 durable 事实。").volatile(),
85
+ // ---- v1.4.0:手动「蒸馏会话」最终输出软预算(v1.4.1 起语义变更:prompt 软约束,思考不受限;实际硬上限由模型自身 maxTokens 决定) ----
86
+ summaryMaxTokens: Schema.number().default(2000).description("手动「蒸馏会话」LLM 的最终输出软预算(prompt 约束,思考不受限;实际硬上限由模型自身 maxTokens 决定)。默认 2000;可调大以容纳更多 durable 事实。").volatile(),
79
87
  projectMaxTokens: Schema.number().default(8000).description("手动「蒸馏项目记忆」LLM 的最终输出软预算(prompt 约束,思考不受限;实际硬上限由模型自身 maxTokens 决定)。默认 8000。").volatile(),
80
- // ---- v1.4.0:蒸馏时回喂存量记忆(特性3,默认关;开启后自动智能模式 turn/end 与手动蒸馏按钮均回喂,delete 仅手动放开) ----
81
- feedbackEnabled: Schema.boolean().default(false).description("每次蒸馏时把项目级 + 用户级 MEMORY.md 全文(逐行编号、无截断)回喂给 LLM,使其能基于既有记忆做增量维护。delete 仍仅手动蒸馏按钮开放,自动模式跳过(防误删)。hybrid 模式下仅手动触发蒸馏受影响(自动路径走记忆子代理,自带日志回喂)。默认关。").volatile(),
82
- // ---- v1.6.0:hybrid 模式配置(仅 memoryMode=hybrid 时生效) ----
83
- reorgCooldownDays: Schema.number().default(7).description("hybrid 模式:项目级 MEMORY.md 全量重整的冷却天数(距上次重整)。与「超出注入预算」双条件同时满足才允许 memory_reorganize;时间戳以 HTML 注释落在 MEMORY.md 文件尾。").volatile(),
84
- subagentLogBudget: Schema.number().default(20000).description("hybrid 模式:记忆子代理回喂今日工作日志的字符上限。超出时仅回喂章节目录,子代理用 log_read_section 按需读取章节。").volatile(),
88
+ // ---- v1.4.0:蒸馏时回喂存量记忆(特性3,默认关;开启后手动蒸馏按钮回喂,delete 仅手动放开) ----
89
+ feedbackEnabled: Schema.boolean().default(false).description("每次蒸馏时把项目级 + 用户级 MEMORY.md 全文(逐行编号、无截断)回喂给 LLM,使其能基于既有记忆做增量维护(delete 手动蒸馏按钮开放)。自动路径走记忆子代理、自带日志回喂,不受此项影响。默认关。").volatile(),
90
+ // ---- v1.6.0:记忆子代理配置 ----
91
+ reorgCooldownDays: Schema.number().default(7).description("项目级 MEMORY.md 全量重整的冷却天数(距上次重整)。与「超出注入预算」(`workspaceBudgetChars`,即项目级 MEMORY.md 大于该值)双条件同时满足才允许 memory_reorganize;时间戳以 HTML 注释落在 MEMORY.md 文件尾。").volatile(),
92
+ subagentLogBudget: Schema.number().default(20000).description("记忆子代理回喂今日工作日志的字符上限。超出时仅回喂章节目录,子代理用 log_read_section 按需读取章节。").volatile(),
85
93
  // ---- v1.7.1(特性3):自定义指令(用户自写,经 system prompt 注入到「记忆分工 prompt」之后) ----
86
94
  // 放 system 而非 E 投影:用户配置一次即恒定 → 零缓存成本(见下方 section 的分流原则)。
87
- customInstructions: Schema.string().default("").description("自定义指令:非空时追加到系统提示词「记忆分工说明」之后,用于承载不适合写进用户记忆的特殊指令。对全部会话与三种记忆模式生效;留空则不注入。").volatile(),
95
+ customInstructions: Schema.string().default("").description("自定义指令:非空时追加到系统提示词「记忆分工说明」之后,用于承载不适合写进用户记忆的特殊指令。对全部会话生效;留空则不注入。").volatile(),
88
96
  });
89
97
 
90
98
  export const inject = ["systemPrompt", "tools", "llm", "webServer", "webRuntime"];
@@ -168,19 +176,29 @@ export function apply(ctx, config) {
168
176
  const state = {
169
177
  activeCwd: null, // 最近活跃 session 的 cwd(session/event 更新)
170
178
  activeSession: null, // 最近活跃 session 引用
171
- turnBuffer: [], // 每轮缓冲 {role,text},role ∈ user/assistant/tool
172
- recentAgentWrote: false, // agent 主动调 memory_note / memory_note_user 标志
173
- sawErrorTurn: false, // 跨 turn 累积的 request 级错误信号
174
- lastErrorMsg: "",
175
- settleTimer: null, // debounce 计时器
176
- lastSummarizedSeq: -1, // 智能模式增量摘要断点(session 事件 seq)
177
- summarySessionId: null,
178
- planModeActive: false, // 计划模式:禁写记忆(硬编码默认,无开关;仅经 session/event 的 plan/mode 翻转;未装 dsh-plan-mode 永不触发=不拦截)
179
+ // ---- 每会话状态分桶(修复:结算写入错项目)----
180
+ // 下面三者的语义都是「当前请求所属会话」,原实现放在进程级全局上,而 session/event 是无条件
181
+ // 覆盖的 → 多会话并发时必然互相污染:A 会话的内容被结算进 B 项目目录。按 sessionId 分桶后互不干扰。
182
+ // 被取代并删除的全局单值字段(勿再引回):`turnBuffer` / `sawErrorTurn` / `settleTimer`
183
+ // 对应前三个 Map;`lastSummarizedSeq` / `breakpointSessionId` 对应 seqBySession。
184
+ // 注:seqBySession 是记忆子代理的**增量断点**(session 事件 seq),不是已删的 smart 模式残留
185
+ // ——`hybrid/subagent.mjs` 在调用方未注入 getSeq/setSeq 时仍会回落到全局 state.lastSummarizedSeq
186
+ // (单测 tests/test-hybrid.mjs 就走该回退路径)。
187
+ buffersBySession: new Map(), // sessionId -> { turnBuffer, sawErrorTurn, session }
188
+ settleTimers: new Map(), // sessionId -> debounce timer
189
+ seqBySession: new Map(), // sessionId -> 记忆子代理增量断点
190
+ // plan 模式:禁写记忆(硬编码默认,无开关)。
191
+ // 宿主是**会话级**状态(`session.append('plan/mode')`,见 common/planmode.mjs),故按会话记;
192
+ // `planModeActive` 仅作「拿不到会话上下文」时的兜底值。未装 dsh-plan-mode 则永不触发 = 不拦截。
193
+ planModeBySession: new Map(), // sessionId -> 该会话是否处于 plan 模式(判定优先看它)
194
+ planModeActive: false, // 兜底:最近一次 plan/mode 事件的值,供拿不到会话上下文的场合使用
179
195
  // v1.7.2:会话 → agent 预设 id(仅"切换预设"事件写入;创建头另在 presetOfSession 里兜底读)。
180
196
  // 只增不减:会话结束后残留几条字符串,代价可忽略;切回同一 session 断言预设不变(宿主侧保证)。
181
197
  presetBySession: new Map(),
182
198
  // v1.7.0:injectedSessionIds 已删除——记忆正文改经 E 投影(src/projection.mjs)注入为
183
199
  // 常驻消息,compaction 后由投影自动重注,不再需要"仅首次注入"的会话级标记。
200
+ // v1.8.0:recentAgentWrote(原防闲聊闸门信号)与 summarySessionId(原 smart 模式会话标记)
201
+ // 随 plugin / smart 两模式一并删除。
184
202
  };
185
203
  const TURN_BUFFER_MAX = 30;
186
204
  // v1.1.3:字符上限提升到 30k(长工具型 request 的工具结果动辄数千字符,6k 上限会触发
@@ -230,16 +248,15 @@ export function apply(ctx, config) {
230
248
  // ---- 装配:路径解析 / 记录读写 / 蒸馏 / 工具 / route ----
231
249
  const paths = createPaths(() => source(), () => state.activeCwd);
232
250
  const records = createRecords({ getConfig: () => source(), paths });
233
- const distill = createDistill({ ctx, getConfig: () => source(), paths, records, state });
251
+ const distill = createDistill({ ctx, getConfig: () => source(), paths, records });
234
252
  registerTools({ ctx, getConfig: () => source(), paths, state });
235
- registerApi({ ctx, paths, distill, state, getSettingsFace });
236
- // v1.6.0:hybrid 模式独立模块。必须【无条件注册】——apply() 同步段执行时 settings 服务
237
- // 尚未挂载(v1.6.0-rc1 起设置节经 SettingsProvider.installSection 在 ctx.inject(["settings"])
238
- // 回调里注册并同步 setSource),此刻 source() 读的是 cordis 组合配置(未显式配置时即 schema 默认),
239
- // 条件注册会导致设置页切 hybrid 后工具/子代理从未注册(v1.6.0 实装踩坑:settings.yaml
240
- // memoryMode=hybrid 但 registerHybrid 未执行 → 子代理 TypeError 被吞、日志全空)。
241
- // 运行时分派靠 _settle / proactive 的热 source() 判断;工具注册无副作用(plugin/smart
242
- // 的 prompt 不引导使用,guards 白名单只拦 hybrid 工具)。
253
+ registerApi({ ctx, paths, distill, getSettingsFace });
254
+ // v1.8.0:记忆子代理模块(原 hybrid)。必须【无条件注册】——apply() 同步段执行时 settings 服务
255
+ // 尚未挂载,此刻 source() 读的是 cordis 组合配置(未显式配置时即 schema 默认);
256
+ // 条件注册会在热配置变化后导致工具/子代理从未注册(v1.6.0 实装踩坑:配置写着启用但
257
+ // registerHybrid 未执行 → 子代理 TypeError 被吞、日志全空)。
258
+ // 运行时分派靠 _settle / proactive 的热 source() 判断;工具注册无副作用(enabled=false 或静默
259
+ // 会话下 execute 内有兜底)。
243
260
  const hybrid = registerHybrid({ ctx, getConfig: () => source(), paths, records, state });
244
261
  // v1.7.0 特性1:E 投影通道(记忆正文常驻注入)。与 hybrid 同理,无条件注册——
245
262
  // 其内部读热配置,无副作用(enabled=false 或命中静默预设时 buildProjections 返回空)。
@@ -312,18 +329,35 @@ export function apply(ctx, config) {
312
329
  agentBySession.delete(sessionId);
313
330
  });
314
331
 
315
- // ---- session/event 跟踪:更新 activeCwd/activeSession/增量断点 + 累积 turnBuffer ----
332
+ // ---- session/event 跟踪:更新 activeCwd/activeSession + 累积 turnBuffer ----
316
333
  // 关键:一次用户请求在 dsh agent 循环里会被拆成多个 turn(每个工具调用一轮),
317
334
  // 故 buffer 必须跨 turn 累积整个 request,不能在单个 turn/end 清空——否则最终 turn/end
318
- // 触发时 buffer 只剩残缺片段(无 user 请求、无 tool/result),防闲聊闸门误判为非实质轮次、不写记忆。
335
+ // 触发时 buffer 只剩残缺片段(无 user 请求、无 tool/result),工具期错误信号(extractToolErrorText)
336
+ // 会取不到,传给记忆子代理的 isError 随之失准。
319
337
  // 结算策略:debounce(安静期后只结算一次)+ 新 user/message 时立即结算上一个 request。
338
+ // 取/建某会话的状态桶(sessionId 缺失时归入同一个兜底桶,保持旧行为不失效)。
339
+ function bucketOf(sessionId) {
340
+ const id = sessionId || "__nosession__";
341
+ let b = state.buffersBySession.get(id);
342
+ if (!b) {
343
+ b = { turnBuffer: [], sawErrorTurn: false, session: null };
344
+ state.buffersBySession.set(id, b);
345
+ }
346
+ return b;
347
+ }
348
+
320
349
  ctx.on("session/event", (session, event) => {
321
350
  if (session) state.activeSession = session;
322
351
  if (session?.header?.cwd) state.activeCwd = session.header.cwd;
323
- // 切换 session 时重置智能模式增量断点(新会话从 firstLiveSeq 起算)。
324
- if (session && session.id !== state.summarySessionId) {
325
- state.summarySessionId = session.id;
326
- state.lastSummarizedSeq = session.firstLiveSeq;
352
+ const sid = session?.id || "__nosession__";
353
+ const bucket = bucketOf(sid);
354
+ if (session) bucket.session = session;
355
+ // 切换 session 时重置记忆子代理的增量断点(新会话从 firstLiveSeq 起算)。
356
+ // 必须做:seq 按会话独立编号,沿用上一会话的断点会让 projectTurnMessages 取到空区间 →
357
+ // 子代理静默 noop、日志永不落盘(v1.6.0 起沿用至今;v1.8.0 核查确认此处不是 smart 残留)。
358
+ // 断点同样按会话分桶:全局单值会让并发会话互相重置。
359
+ if (session && !state.seqBySession.has(sid)) {
360
+ state.seqBySession.set(sid, session.firstLiveSeq);
327
361
  }
328
362
  const type = event?.type;
329
363
  // v1.7.2:预设切换(仅空会话可切;宿主在切换提交后追加该事件)。记进 Map 供 isSessionSilent 读取。
@@ -338,42 +372,47 @@ export function apply(ctx, config) {
338
372
  }
339
373
  // 计划模式:禁写记忆(硬编码默认行为,无开关;不依赖 dsh-plan-mode 服务,未装则永不触发=安全降级=不拦截)
340
374
  if (type === "plan/mode") {
341
- state.planModeActive = !!(event?.data?.active);
375
+ // plan 模式是**会话级**事实(宿主 `session.append('plan/mode')`)→ 必须按会话记:
376
+ // 用全局单值会让 A 的 plan 状态波及 B —— B 记忆不落盘 / B 的 prompt 被注入 plan 提示 / B 写工具被拒。
377
+ const on = !!(event?.data?.active);
378
+ // 兜底值【始终】跟随最近一次事件:工具闸门等拿不到会话上下文的场合靠它判定
379
+ // (若只在"无会话事件"时才写,兜底会恒为 false → 那些场合漏拦)。
380
+ state.planModeActive = on;
381
+ // 会话级精确记录:有会话身份时判定只看这张表(无记录 = 该会话未进 plan = false,不回落全局)。
382
+ if (session?.id) state.planModeBySession.set(session.id, on);
342
383
  return;
343
384
  }
344
385
  if (type === "user/message" || type === "assistant/message" || type === "tool/result") {
345
386
  // v1.7.0 特性1 连带修复:turnBuffer 只采集【真实用户消息】。
346
- // E 投影的 user/message(source.kind === 'plugin')也在 SURFACE 里,若不过滤会被
347
- // 采集进 buffer,并触发下方 `type === "user/message" && state.turnBuffer.length` 的
348
- // _flushTurn() —— 打乱 request 边界、污染关键词闸门(结算时取第一条 user 文本)。
387
+ // E 投影的 user/message(source.kind === 'plugin:dsh-memory-palace')也在 SURFACE 里,若不过滤
388
+ // 会被采集进 buffer,并触发下方 `type === "user/message" && bucket.turnBuffer.length` 的
389
+ // _flushTurn() —— 打乱 request 边界、污染回喂子代理的对话内容。
349
390
  // 判定用【黑名单】而非白名单(fail-open):只在 source 明确存在且非 'user' 时排除。
350
- // 理由:source 缺失时若按"非 user"丢弃,真实用户消息会被漏掉 → 闸门关闭 → 记忆整体
351
- // 丢失(本项目历史上最严重的一类 bug)。注入类消息在 DSH 中必带 source,故放行风险极低。
391
+ // 理由:source 缺失时若按"非 user"丢弃,真实用户消息会被漏掉 → request 边界与错误信号全错
392
+ // (本项目历史上最严重的一类 bug)。注入类消息在 DSH 中必带 source,故放行风险极低。
352
393
  const srcKind = event?.data?.source?.kind;
353
394
  if (type === "user/message" && srcKind && srcKind !== "user") return;
354
395
  const text = extractText(event);
355
396
  if (text) {
356
397
  const role = type === "tool/result" ? "tool" : type === "user/message" ? "user" : "assistant";
357
398
  // 新用户请求到来:先结算并清空上一个 request(若存在),避免与本请求混淆。
358
- if (type === "user/message" && state.turnBuffer.length) _flushTurn();
359
- state.turnBuffer.push({ role, text });
360
- if (state.turnBuffer.length > TURN_BUFFER_MAX) state.turnBuffer = state.turnBuffer.slice(-TURN_BUFFER_MAX);
361
- const total = state.turnBuffer.reduce((n, b) => n + b.text.length, 0);
399
+ // 缓冲按会话分桶:否则 A 会话的消息会混进 B 会话的结算,连同内容一起写错项目。
400
+ if (type === "user/message" && bucket.turnBuffer.length) _flushTurn(sid, bucket);
401
+ bucket.turnBuffer.push({ role, text });
402
+ if (bucket.turnBuffer.length > TURN_BUFFER_MAX) bucket.turnBuffer = bucket.turnBuffer.slice(-TURN_BUFFER_MAX);
403
+ const total = bucket.turnBuffer.reduce((n, b) => n + b.text.length, 0);
362
404
  if (total > TURN_BUFFER_CHAR_CAP) {
363
- // v1.1.3 修复:溢出时【保留尾部最近内容】而非替换成占位——占位会让 hasTool/关键词判定
364
- // 全部失效(长工具型 request 的 tool 块被清空 → 结算闸门误判关闭 → 记忆整体丢失)。
365
- state.turnBuffer = state.turnBuffer.slice(-8);
405
+ // v1.1.3 修复:溢出时【保留尾部最近内容】而非替换成占位——占位会让工具期错误检测
406
+ // (extractToolErrorText)失效,传给记忆子代理的 isError 失准、错误现象漏记。
407
+ bucket.turnBuffer = bucket.turnBuffer.slice(-8);
366
408
  }
367
409
  }
368
410
  } else if (type === "turn/end") {
369
- // 累积 request 级错误信号(跨 turn 合并),供结算时统一判定。
370
- const reason = event?.data?.reason;
371
- if (reason?.kind === "error") {
372
- state.sawErrorTurn = true;
373
- state.lastErrorMsg = (reason?.message || reason?.error?.message || "")?.toString() || "";
374
- }
375
- // 不清空 buffer;用 debounce 在整段请求安静后结算一次。
376
- _scheduleSettle();
411
+ // 累积 request 级错误信号(跨 turn 合并),供结算时统一判定并传给记忆子代理。
412
+ // 同样按会话分桶:全局标志会让 A 会话的错误把 B 会话的日志标成 isError。
413
+ if (event?.data?.reason?.kind === "error") bucket.sawErrorTurn = true;
414
+ // 不清空 buffer;用 debounce 在整段请求安静后结算一次(定时器按会话独立)。
415
+ _scheduleSettle(sid);
377
416
  } else if (type === "compaction" || type?.startsWith?.("compaction/")) {
378
417
  // v1.7.0:无需再清"已注入标记"(该机制已删)——记忆正文改经 E 投影注入,
379
418
  // compaction 把投影消息移出 surface 后,后续 step 的去重扫描找不到它 → 自动重注。
@@ -400,26 +439,15 @@ export function apply(ctx, config) {
400
439
  // 通道严格一致,便于排障(不会出现"投影关了、section 还在"的错觉)。
401
440
  if (isSessionSilent(context?.agent?.session)) return "";
402
441
  // v1.7.0:alreadyInjected / injectedSessionIds 已整体删除(记忆正文改走 E 投影)。
403
- // intro(含记忆公民指令)仍【始终】注入——若因记忆为空而整体返回空串,agent 将不知道
404
- // 记忆系统存在、不会主动记,形成「无记忆 → 无指令 → 永不记」死循环。
442
+ // intro【始终】注入——若因记忆为空而整体返回空串,agent 将不知道记忆系统存在、不会主动记,
443
+ // 形成「无记忆 → 无指令 → 永不记」死循环。
405
444
  const bridged = paths.buddyDirs().length > 0;
406
445
  const antiMangle =
407
446
  "提及记忆文件路径时一律用 ~ 简写(如 ~/.deepseek-harness/MEMORY.md),不要逐字拼写绝对路径——你转述绝对路径容易漏掉目录分隔符。";
408
- // v1.1.0:按记忆模式注入不同指令。插件模式=记忆公民指令(引导 agent 主动记);
409
- // 智能模式=简短说明(记忆由 LLM 智能摘要自动维护,无需主动记,但仍可用 memory_read 读)。
410
- // v1.6.0:hybrid=记忆子代理自动维护日志 + agent 主动维护 MEMORY.md(见 HYBRID_PROACTIVE)。
411
- const proactive =
412
- source().memoryMode === "smart"
413
- ? "\n\n[记忆说明] 你的跨 session 记忆由 LLM 智能摘要自动维护(每轮结束自动提炼摘要并沉淀 durable 事实到 MEMORY.md),无需主动调用 memory_note / memory_note_user;读取记忆用 memory_read 工具,scope 默认 'memory',需要日志时显式传 scope(不要手动 glob/read 记忆文件)。"
414
- : source().memoryMode === "hybrid"
415
- ? HYBRID_PROACTIVE
416
- : "\n\n[记忆公民指令] 你拥有跨 session 的 Markdown 记忆。以下场景【必须】主动调用 memory_note(项目级约定)或 memory_note_user(跨项目个人偏好)落档,不要依赖轮次结束的自动兜底——它只做原始文本截断,无法代替你的高质量总结:\n" +
417
- "① 完成任务 / 工作并产出结果(写脚本、统计、分析、修复、交付)→ 记任务做了什么 + 关键结果(路径/命令/数字)\n" +
418
- "② 修复 bug / 定位根因(记现象 + 根因 + 绕过/修复手法,防复发)\n" +
419
- "③ 验证 build/test/CI 通过(记命令与结论)\n" +
420
- "④ 完成里程碑 / 关键决策 / 变更约定(记决策与理由)\n" +
421
- "⑤ 用户表达的偏好、约束、durable 事实(记原话要点)\n" +
422
- "判定标准:这条信息「下个 session 的我」还需要吗?不需要(闲聊、一次性操作、显而易见)就不记。格式:一句话结论开头 + 关键细节(命令/路径/数字),不写流水账。";
447
+ // v1.8.0:记忆写入恒定混合模式——今日日志由记忆子代理自动维护,长期记忆 MEMORY.md 由 agent 主动维护。
448
+ // 原 plugin(记忆公民指令)/ smart(智能摘要说明)两分支已随模式一并删除;
449
+ // HYBRID_PROACTIVE 内含 agent 侧写入职责与场景,是唯一的分工指令来源。
450
+ const proactive = HYBRID_PROACTIVE;
423
451
  const intro = bridged
424
452
  ? "你拥有持久化、人类可直接编辑的 Markdown 记忆文件。当前项目已存在 WorkBuddy/CodeBuddy 项目记忆目录,本插件直接读写这些目录(不再单独创建 .deepseek-harness/memory/)。" +
425
453
  "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取记忆用 memory_read 工具,scope 默认 'memory'(用户级 + 项目级 MEMORY.md),需要今日/历史日志时显式传 scope:'daily' 或 'all'(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
@@ -429,8 +457,9 @@ export function apply(ctx, config) {
429
457
  "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取记忆用 memory_read 工具,scope 默认 'memory'(用户级 + 项目级 MEMORY.md),需要今日/历史日志时显式传 scope:'daily' 或 'all'(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
430
458
  "注意:上下文中的记忆是**会话起始快照**,不随记忆文件之后的更新自动刷新;需要以当前状态为依据时(改记忆前、或据记忆作答前)用 memory_read 重读。" +
431
459
  antiMangle + proactive;
432
- // 计划模式禁写提示(硬编码默认行为,无开关):无论记忆模式都追加,让 agent 自觉不写(网关物理兜底仍生效)
433
- const planNote = state.planModeActive
460
+ // 计划模式禁写提示(硬编码默认行为,无开关):让 agent 自觉不写(网关物理兜底仍生效)。
461
+ // 按**会话**判定:plan 模式是会话级状态,别的会话进 plan 不该往本会话 prompt 里塞这条提示。
462
+ const planNote = planModeOf(state, context?.agent?.session)
434
463
  ? "\n\n[plan 模式] 当前处于 plan 模式,不要调用 memory_note / memory_note_user 写入记忆,也不要请求删除记忆(读取记忆用 memory_read,仍可用)。"
435
464
  : "";
436
465
  const introFull = intro + planNote;
@@ -444,99 +473,73 @@ export function apply(ctx, config) {
444
473
  },
445
474
  });
446
475
 
447
- // ---------- 结算一个完整 request:基于累积缓冲做「错误捕获 + 轻量兜底记录(闸门)」,不调 LLM ----------
448
- // 主路径是 agent 按记忆公民指令主动调 memory_note;此处只兜底保证"实质轮次不丢"。
449
- async function _settle(capturedTurn, isError, errMsg) {
450
- // 计划模式禁写(硬编码默认,无开关):自动写路径全拦截(智能模式/轻量兜底/错误捕获同源跳过)
451
- if (state.planModeActive) return;
476
+ // ---------- 结算一个完整 request:交给记忆子代理写今日日志(不在此处做任何直接写入) ----------
477
+ // v1.8.0:原「防闲聊闸门 + 轻量兜底 + 错误捕获」(plugin)、「LLM 摘要分派」(smart)
478
+ // 与「记忆写入总开关」(summarize) 均已删除,本函数收敛为单一路径:前置闸门 → 算错误信号 → 子代理。
479
+ async function _settle(capturedTurn, isError, sid, boundSession) {
480
+ // 会话身份由入参显式传入(原实现读全局 state.activeSession,多会话并发时会写到别的项目目录)。
481
+ // boundSession 缺失时兜底用全局,保持旧行为不失效。
482
+ const session = boundSession || state.activeSession;
483
+ // 计划模式禁写(硬编码默认,无开关):plan 模式是**会话级**状态 → 只拦该会话本身;写入路径在此一处拦截
484
+ if (planModeOf(state, session)) return;
452
485
  const cfg = source();
453
486
  if (!cfg.enabled) return;
454
- // v1.7.2:静默会话(极简等预设)不写任何记忆——错误捕获 / 轻量兜底 / smart 摘要 / hybrid 子代理
455
- // 全部在此一处提前返回(hybrid 分支在其下方,天然被拦)。
456
- if (isSessionSilent(state.activeSession)) return;
457
- const cwd = state.activeSession?.header?.cwd ?? null;
487
+ // v1.7.2:静默会话(极简等预设)不写任何记忆——记忆子代理在此一处提前返回。
488
+ if (isSessionSilent(session)) return;
489
+ const cwd = session?.header?.cwd ?? null;
458
490
  const dirs = paths.writeDirs(cwd);
459
491
  if (!dirs.length) return;
460
492
 
461
- // 基础闸门(A+D 合并):结构信号(工具/错误/主动记)或 场景关键词 → 开。
462
- const userText = capturedTurn.find((b) => b.role === "user")?.text ?? "";
463
- const hitKeyword = SCENE_KEYWORDS.some((k) => userText.includes(k));
464
- const hasTool = capturedTurn.some((b) => b.role === "tool");
465
- const agentWrote = state.recentAgentWrote;
466
-
467
493
  // 错误信号:request 级终态错误(任一 turn/end reason.kind==='error')或 工具/代码执行期错误。
468
- const toolErr = extractToolErrorText(capturedTurn);
469
- const effectiveIsError = isError || !!toolErr;
470
-
471
- let baseGateOpen = effectiveIsError || hasTool || agentWrote || hitKeyword;
472
-
473
- // v1.1.0:按记忆模式分派。智能模式=LLM 智能会话摘要(错误由摘要提炼;失败/返回 false 均降级轻量兜底)。
474
- if (cfg.memoryMode === "smart") {
475
- // v1.1.3 修复:turnBuffer 字符上限(30k)仍可能截断超长工具型 request,导致 hasTool 误判为
476
- // false(tool 块被裁剪)→ 结算闸门误关 → 记忆整体丢失。智能模式闸门改用 session 事件增量
477
- // (真实完整信号,不依赖会被裁剪的 turnBuffer)。
478
- // v1.6.2-alpha.4:宿主 0.1.2-alpha.4 删除 Session.events getter,事件读取统一走
479
- // eventsFrom 兼容层(snapshotEvents 半开区间,旧宿主回退 events+filter)。
480
- if (!baseGateOpen && state.activeSession) {
481
- const SURFACE = new Set(["user/message", "assistant/message", "tool/result"]);
482
- const newEvents = eventsFrom(state.activeSession, state.lastSummarizedSeq).filter(
483
- (e) => SURFACE.has(e.type),
484
- );
485
- if (newEvents.some((e) => e.type === "tool/result")) baseGateOpen = true;
486
- }
487
- }
494
+ // 只作布尔传给子代理(错误现象本身在对话里,子代理自己看得到)——原 plugin 模式据此写
495
+ // MEMORY.md 的 `in-session 错误` 行,该链路已随 plugin 模式删除。
496
+ const effectiveIsError = isError || !!extractToolErrorText(capturedTurn);
488
497
 
489
- // v1.6.0 hybrid:记忆子代理每轮必跑(定案——不走闸门;plan 模式在上方已拦)。
490
- // 失败不降级 writeLightEntry(v1.6.0 定案:无格式原文会破坏日志章节化结构)——
491
- // 断点不推进,下一次 turn/end 子代理自动补蒸。
492
- if (cfg.memoryMode === "hybrid") {
493
- if (!cfg.summarize) return; // 总闸门(dirs.length 已在上方保证非空)
494
- void hybrid
495
- .runMemorySubagent({ ctx, getConfig: () => source(), paths, records, state, session: state.activeSession, dirs, isError: effectiveIsError })
496
- .catch(() => {});
497
- return;
498
- }
499
-
500
- // 写门控:命中闸门才写(受 summarize 总开关,插件/smart 模式共用)。
501
- if (!cfg.summarize || !baseGateOpen) return;
502
-
503
- if (cfg.memoryMode === "smart") {
504
- void distill.summarizeTurn(capturedTurn, dirs, effectiveIsError)
505
- .then((ok) => {
506
- if (!ok) return records.writeLightEntry(dirs, capturedTurn, effectiveIsError);
507
- })
508
- .catch(() => records.writeLightEntry(dirs, capturedTurn, effectiveIsError))
509
- .catch(() => {});
510
- return;
511
- }
512
-
513
- // 插件模式:错误捕获(受 autoCaptureErrors 门控)+ 轻量兜底记录。
514
- if (cfg.autoCaptureErrors && effectiveIsError) {
515
- const finalErrMsg = errMsg || toolErr || "(in-session error, no message)";
516
- void records.captureError(capturedTurn, { kind: "error", message: finalErrMsg }, dirs, cwd).catch(() => {});
517
- }
518
- void records.writeLightEntry(dirs, capturedTurn, effectiveIsError).catch(() => {});
498
+ // v1.8.0:原「记忆写入总开关」(summarize) 已删除——走到这里即写入。
499
+ // 停写只剩两条路:`enabled=false`(profile config)与 `silentPresets` 命中(会话预设级静默),二者均在上方拦截。
500
+ // v1.6.0 hybrid:记忆子代理每轮必跑(定案——不走内容闸门;plan 模式与静默会话在上方已拦)。
501
+ // 失败【不】降级为任何轻量写入(v1.6.0 定案:无格式原文会破坏日志章节化结构)——
502
+ // 子代理内部不推进断点,下一次 turn/end 自动补蒸。
503
+ void hybrid
504
+ .runMemorySubagent({
505
+ ctx,
506
+ getConfig: () => source(),
507
+ paths,
508
+ records,
509
+ state,
510
+ session,
511
+ dirs,
512
+ isError: effectiveIsError,
513
+ // 断点按会话分桶:全局单值在并发会话下会被互相重置,导致子代理静默 noop、日志不落盘。
514
+ getSeq: () => state.seqBySession.get(sid) ?? session?.firstLiveSeq ?? 0,
515
+ setSeq: (v) => state.seqBySession.set(sid, v),
516
+ })
517
+ .catch(() => {});
519
518
  }
520
519
 
521
520
  // 立即结算并清空当前缓冲(供 debounce 到点、或收到新 user/message 时调用)。
522
- function _flushTurn() {
523
- const capturedTurn = state.turnBuffer;
524
- const wasErrorTurn = state.sawErrorTurn;
525
- const errMsg = state.lastErrorMsg;
526
- state.turnBuffer = [];
527
- state.sawErrorTurn = false;
528
- state.lastErrorMsg = "";
529
- state.recentAgentWrote = false;
521
+ function _flushTurn(sid, bucket) {
522
+ // 结算归属由入参会话决定(不再读全局 state.activeSession)。
523
+ const bound = bucket || bucketOf(sid);
524
+ const capturedTurn = bound.turnBuffer;
525
+ const wasErrorTurn = bound.sawErrorTurn;
526
+ bound.turnBuffer = [];
527
+ bound.sawErrorTurn = false;
530
528
  if (!capturedTurn.length) return;
531
- void _settle(capturedTurn, wasErrorTurn, errMsg).catch(() => {});
529
+ void _settle(capturedTurn, wasErrorTurn, sid, bound.session).catch(() => {});
532
530
  }
533
531
 
534
532
  // debounce:每次 turn/end 重置计时器;SETTLE_DELAY 内无新 turn/end(即整段请求安静)才结算一次。
535
- function _scheduleSettle() {
536
- if (state.settleTimer) clearTimeout(state.settleTimer);
537
- state.settleTimer = setTimeout(() => {
538
- state.settleTimer = null;
539
- _flushTurn();
533
+ function _scheduleSettle(sid) {
534
+ // 每个会话独立计时:共用单一定时器时,B 的 turn/end 会把 A 的待结算 clearTimeout 掉 → A 整段丢失。
535
+ const id = sid || "__nosession__";
536
+ const prev = state.settleTimers.get(id);
537
+ if (prev) clearTimeout(prev);
538
+ const t = setTimeout(() => {
539
+ state.settleTimers.delete(id);
540
+ const bound = state.buffersBySession.get(id);
541
+ if (bound) _flushTurn(id, bound);
540
542
  }, SETTLE_DELAY);
543
+ state.settleTimers.set(id, t);
541
544
  }
542
545
  }
@@ -114,7 +114,9 @@ export function buildProjections({ getConfig, paths, session, isSilent }) {
114
114
  const userFile = expandHome(cfg.userMemoryPath);
115
115
  const userText = readMdSync(userFile);
116
116
  if (userText) {
117
- const clipped = stripSmartTag(stripDeletedLines(budgetClip(userText, cfg.userBudgetChars)));
117
+ // v1.8.0-alpha.1 修复(C1):先剥墓碑/标签再裁剪——旧顺序 budgetClip 在内层,墓碑先消耗
118
+ // 预算再被外层剥掉 = 白白挤掉有效条目。三处(用户级/项目级/今日日志)同此顺序。
119
+ const clipped = budgetClip(stripDeletedLines(stripSmartTag(userText)), cfg.userBudgetChars);
118
120
  const msg = buildProjection(clipped, `# 用户级记忆 (${toHomeShort(userFile)})`);
119
121
  if (msg) out.push(msg);
120
122
  }
@@ -126,9 +128,9 @@ export function buildProjections({ getConfig, paths, session, isSilent }) {
126
128
  for (const file of paths.memoryReadCandidates(dir, cwd)) {
127
129
  if (seen.has(file)) continue;
128
130
  seen.add(file);
129
- const text = readMdSync(file);
130
- if (!text) continue;
131
- const clipped = stripSmartTag(stripDeletedLines(budgetClip(text, cfg.workspaceBudgetChars)));
131
+ const text = readMdSync(file);
132
+ if (!text) continue;
133
+ const clipped = budgetClip(stripDeletedLines(stripSmartTag(text)), cfg.workspaceBudgetChars);
132
134
  const msg = buildProjection(clipped, `# 项目级记忆 (${toHomeShort(file)})`);
133
135
  if (msg) out.push(msg);
134
136
  }
@@ -139,7 +141,7 @@ export function buildProjections({ getConfig, paths, session, isSilent }) {
139
141
  for (const dir of paths.readDirs(cwd)) {
140
142
  const text = readMdSync(join(dir, `${todayISO()}.md`));
141
143
  if (!text) continue;
142
- const clipped = stripSmartTag(stripDeletedLines(budgetClip(text, cfg.workspaceBudgetChars)));
144
+ const clipped = budgetClip(stripDeletedLines(stripSmartTag(text)), cfg.workspaceBudgetChars);
143
145
  const msg = buildProjection(clipped, `# 今日工作日志 (${todayISO()} @ ${toHomeShort(dir)})`);
144
146
  if (msg) out.push(msg);
145
147
  }
package/lib/tools.mjs CHANGED
@@ -3,8 +3,9 @@
3
3
  // 经 registerTools 注入 ctx/config/paths/运行时状态;在 index.mjs 装配时调用一次。
4
4
  import { defineTool } from "@deepseek-ai/dsh-tools";
5
5
  import { join } from "node:path";
6
- import { todayISO, isoDaysAgo, expandHome, toHomeShort, readMdSync, budgetClip } from "./common/text.mjs";
6
+ import { todayISO, isoDaysAgo, expandHome, toHomeShort, readMdSync, budgetClip, stripDeletedLines } from "./common/text.mjs";
7
7
  import { appendLineDedup, findMatches, removeLineByMatch, recentLogDates } from "./common/records.mjs";
8
+ import { planModeOf } from "./common/planmode.mjs";
8
9
 
9
10
  /**
10
11
  * @param {{ ctx: object, getConfig: () => object, paths: object, state: object }} deps
@@ -94,7 +95,6 @@ export function registerTools({ ctx, getConfig, paths, state }) {
94
95
  render: (_args, value) => [{ type: "text", text: value?.message ?? "Saved." }],
95
96
  },
96
97
  async execute(args, exec) {
97
- state.recentAgentWrote = true;
98
98
  if (!cfg().enabled) return { ok: false, message: "memory-palace is currently disabled in settings." };
99
99
  const cwd = sessionCwd(exec);
100
100
  const dirs = paths.writeDirs(cwd);
@@ -146,7 +146,6 @@ export function registerTools({ ctx, getConfig, paths, state }) {
146
146
  render: (_args, value) => [{ type: "text", text: value?.message ?? "Saved." }],
147
147
  },
148
148
  async execute(args) {
149
- state.recentAgentWrote = true;
150
149
  const c = cfg();
151
150
  if (!c.enabled) return { ok: false, message: "memory-palace is currently disabled in settings." };
152
151
  try {
@@ -245,7 +244,11 @@ export function registerTools({ ctx, getConfig, paths, state }) {
245
244
  const t = readMdSync(join(dir, `${day}.md`));
246
245
  if (!t) continue;
247
246
  const label = n === 0 ? "今日" : n === 1 ? "昨日" : "前日";
248
- blocks.push(`# ${label}工作日志 (${day} @ ${dirShort})\n${budgetClip(t, c.workspaceBudgetChars)}`);
247
+ // v1.8.0-alpha.1 修复(D2):日志 scope 先剥删除线墓碑再裁剪——墓碑对日志阅读是
248
+ // 纯噪声(实测 79 有效+42 墓碑 → 返回 51+14,28 条有效被墓碑挤掉)。注意
249
+ // memory/project scope 刻意**保留**墓碑不过滤:memory_update_section 的 replace
250
+ // 模式要求 oldText 与磁盘含墓碑逐字一致,过滤会让 stale 校验永远失败。
251
+ blocks.push(`# ${label}工作日志 (${day} @ ${dirShort})\n${budgetClip(stripDeletedLines(t), c.workspaceBudgetChars)}`);
249
252
  }
250
253
  }
251
254
  if (!blocks.length) {
@@ -335,7 +338,6 @@ export function registerTools({ ctx, getConfig, paths, state }) {
335
338
  [{ type: "text", text: value?.confirmPrompt || value?.message || (value?.preview ? "Preview only — no deletion performed." : "Deleted.") }],
336
339
  },
337
340
  async execute(args, exec) {
338
- state.recentAgentWrote = true;
339
341
  const c = cfg();
340
342
  if (!c.enabled) {
341
343
  return { ok: false, preview: false, level: args.level || "user", removed: 0, matches: [], details: [], message: "memory-palace is currently disabled in settings.", confirmPrompt: "" };
@@ -408,8 +410,9 @@ export function registerTools({ ctx, getConfig, paths, state }) {
408
410
  const WRITE_TOOLS = new Set(["memory_note", "memory_note_user", "memory_delete"]);
409
411
  ctx.on("tools/pre-execute", async (exec, next) => {
410
412
  const name = exec?.name;
411
- // plan 模式禁写:三个写工具一律 deny,连预览/确认都拦
412
- if (state.planModeActive && WRITE_TOOLS.has(name)) {
413
+ // plan 模式禁写:三个写工具一律 deny,连预览/确认都拦。
414
+ // 按**会话**判定(plan 模式是会话级状态,见 common/planmode.mjs)——别的会话进 plan 不该 deny 本会话。
415
+ if (planModeOf(state, exec?.agent?.session) && WRITE_TOOLS.has(name)) {
413
416
  return { kind: "deny", reason: "plan 模式下禁止写入记忆" };
414
417
  }
415
418
  if (name !== "memory_delete") return next();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-palace",
3
- "version": "1.7.2",
3
+ "version": "1.8.0-alpha.2",
4
4
  "description": "把 WorkBuddy 的文件式记忆系统移植进 DeepSeek Harness:用户级 + 工作区的人类可读 Markdown 记忆(~/.deepseek-harness/MEMORY.md 与 <cwd>/.deepseek-harness/memory/)。",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",