dsh-memory-palace 1.2.0

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.
@@ -0,0 +1,263 @@
1
+ // memory-palace 蒸馏业务:会话蒸馏核心(自动智能模式 + 按钮「蒸馏会话」共用)+ 项目记忆蒸馏。
2
+ // 经 createDistill 工厂注入 ctx/config/paths/records/运行时状态;返回句柄供 index.mjs 装配与 api.mjs 调用。
3
+ import { mkdir, unlink, writeFile, rename } from "node:fs/promises";
4
+ import { join, dirname } from "node:path";
5
+ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
6
+ import { SUMMARY_PROMPT, DISTILL_PROMPT } from "./common/prompts.mjs";
7
+ import { expandHome, readMdSync, toHomeShort, nowStamp } from "./common/text.mjs";
8
+ import { appendLineDedup } from "./common/records.mjs";
9
+
10
+ /**
11
+ * @param {{ ctx: object, getConfig: () => object, paths: object, records: object, state: object }} deps
12
+ */
13
+ export function createDistill({ ctx, getConfig, paths, records, state }) {
14
+ const cfg = () => getConfig();
15
+
16
+ // 项目蒸馏乐观锁:按目标 MEMORY.md 绝对路径防并发重复蒸馏。
17
+ const distillLocks = new Set();
18
+
19
+ // 蒸馏核心(v1.2.0 抽取):对指定 session 的 [fromSeq, ∞) surface 事件做 LLM 智能摘要并写盘。
20
+ // 自动智能模式(summarizeTurn,增量断点)与按钮「蒸馏会话」(全量 fromSeq=0)共用本核心。
21
+ // 返回 { ok, summary, durableCount };失败 { ok:false }(调用方各自决定降级策略)。
22
+ async function distillSessionCore(session, dirs, fromSeq) {
23
+ const dbg = (why, extra) => console.error(`[memory-palace] distill skip: ${why}`, extra ?? "");
24
+ if (!session) {
25
+ dbg("no session");
26
+ return { ok: false };
27
+ }
28
+ const c = cfg();
29
+ // 1. 模型解析:显式 summaryModel > 复用当前会话模型;皆缺 → 降级。
30
+ let provider;
31
+ let model;
32
+ const sm = (c.summaryModel || "").trim();
33
+ if (sm) {
34
+ const parts = sm.split("/");
35
+ if (parts.length === 2) {
36
+ provider = parts[0].trim();
37
+ model = parts[1].trim();
38
+ }
39
+ }
40
+ if (!provider || !model) {
41
+ const conf = session.requestHeader?.()?.config;
42
+ if (conf && conf.provider && conf.model) {
43
+ provider = conf.provider;
44
+ model = conf.model;
45
+ }
46
+ }
47
+ if (!provider || !model) {
48
+ dbg("no model", { summaryModel: c.summaryModel, header: JSON.stringify(session.requestHeader?.()?.config) });
49
+ return { ok: false };
50
+ }
51
+ // 2. 输入:取 seq >= fromSeq 的 surface 事件,投影成模型视角 Message[]。
52
+ const SURFACE = new Set(["user/message", "assistant/message", "tool/result"]);
53
+ const newEvents = (session.events || []).filter(
54
+ (e) => e.seq >= fromSeq && SURFACE.has(e.type),
55
+ );
56
+ const hist = [];
57
+ for (const e of newEvents) {
58
+ const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
59
+ if (m) hist.push(m);
60
+ }
61
+ if (!hist.length) {
62
+ dbg("no events in range", { fromSeq, eventsLen: (session.events || []).length, newEvents: newEvents.length, hasDerive: !!session.deriveEventMessage });
63
+ return { ok: false };
64
+ }
65
+ // 3. LLM 调用:借鉴 dsh-sideband 的 summarizeJob(src/summarizer.ts)——
66
+ // a) SUMMARY_PROMPT 放 system 参数(GenerateOptions.system),指令不占 user 消息、模型更遵守;
67
+ // b) AbortSignal.timeout 超时保护(默认 60s,Config.summaryTimeoutMs 可调),流循环内 throwIfAborted,
68
+ // 杜绝「LLM 慢/挂起 → for await 无限等待」导致的摘要静默失败;
69
+ // c) finish.kind 细化:error/aborted → 失败降级(探针带 failure.message);max-tokens → 尝试用已有文本
70
+ // (JSON.parse 失败自然回落全文/降级,不会产生坏数据)。
71
+ const timeoutMs = c.summaryTimeoutMs || 60000;
72
+ const signal = AbortSignal.timeout(timeoutMs);
73
+ const res = ctx.llm.stream({
74
+ provider,
75
+ model,
76
+ system: SUMMARY_PROMPT,
77
+ messages: hist,
78
+ maxTokens: 800,
79
+ signal,
80
+ });
81
+ const asm = new BlockAssembler();
82
+ try {
83
+ for await (const ch of res) {
84
+ signal.throwIfAborted();
85
+ asm.push(ch);
86
+ }
87
+ } catch (e) {
88
+ dbg("stream aborted/timeout/error", { message: e?.message ?? "", aborted: signal.aborted, reason: signal.reason?.message ?? "" });
89
+ return { ok: false };
90
+ }
91
+ const finish = asm.finish;
92
+ if (finish && (finish.kind === "error" || finish.kind === "aborted")) {
93
+ dbg("llm finish", { kind: finish.kind, message: finish.failure?.message ?? "" });
94
+ return { ok: false };
95
+ }
96
+ if (finish && finish.kind === "max-tokens") {
97
+ dbg("llm finish max-tokens (attempting partial JSON)", {});
98
+ }
99
+ const text = asm
100
+ .blocks()
101
+ .filter((b) => b.type === "text")
102
+ .map((b) => b.text ?? "")
103
+ .join("")
104
+ .trim();
105
+ if (!text) {
106
+ dbg("empty llm text");
107
+ return { ok: false };
108
+ }
109
+ // 4. 解析 JSON:{summary, durable:[{scope,fact}]};解析失败回退全文当 summary。
110
+ let summary = text;
111
+ let durable = [];
112
+ try {
113
+ const parsed = JSON.parse(text);
114
+ if (parsed && typeof parsed === "object") {
115
+ if (typeof parsed.summary === "string") summary = parsed.summary;
116
+ if (Array.isArray(parsed.durable)) durable = parsed.durable;
117
+ }
118
+ } catch {
119
+ /* 非 JSON:全文当 summary */
120
+ }
121
+ // 5. 写盘:summary → 每日日志 [smart] 前缀;durable → MEMORY.md `- [smart] fact`(按 scope 分层,去重)。
122
+ // v1.2.0 修复:手动蒸馏(按钮)时 dirs 按 session.header.cwd 解析,但 memoryFileOf 缺省用闭包
123
+ // activeCwd——会话 cwd ≠ activeCwd 时 isDshDir 判假,durable 会落进旧嵌套 memory/MEMORY.md。
124
+ // 此处显式传会话 cwd,使 dsh 目录正确解析到同级 MEMORY.md(自动模式 activeCwd 即会话 cwd,行为不变)。
125
+ const sessCwd = session?.header?.cwd ?? undefined;
126
+ for (const dir of dirs) {
127
+ await records.appendDaily(dir, `\n[smart] ${summary}\n\n`);
128
+ }
129
+ for (const d of durable) {
130
+ if (!d || typeof d !== "object") continue;
131
+ const fact = String(d.fact ?? "").trim();
132
+ if (!fact) continue;
133
+ const line = `- [smart] ${fact}`;
134
+ if (d.scope === "user") {
135
+ await appendLineDedup(expandHome(c.userMemoryPath), line).catch(() => {});
136
+ } else {
137
+ for (const dir of dirs) {
138
+ await appendLineDedup(paths.memoryFileOf(dir, sessCwd), line).catch(() => {});
139
+ }
140
+ }
141
+ }
142
+ return { ok: true, summary, durableCount: durable.length };
143
+ }
144
+
145
+ // 智能模式核心:LLM 智能会话摘要(增量范围,产物带 [smart] 标记)。
146
+ // 输入:capturedTurn(本轮缓冲,仅用于失败降级)、dirs(写盘目标)、isError。
147
+ // 核心逻辑在 distillSessionCore;此处只负责增量断点(state.lastSummarizedSeq)的推进。
148
+ // 失败返回 false 由调用方降级轻量条目;handler 永不 reject(调用方包 catch)。
149
+ async function summarizeTurn(capturedTurn, dirs, isError) {
150
+ if (!state.activeSession) return false;
151
+ const r = await distillSessionCore(state.activeSession, dirs, state.lastSummarizedSeq);
152
+ if (r.ok) state.lastSummarizedSeq = state.activeSession.seq;
153
+ return r.ok;
154
+ }
155
+
156
+ // ---------- v1.2.0 项目记忆蒸馏(按钮「蒸馏项目记忆」) ----------
157
+ // 流程:读主目标 MEMORY.md → DISTILL_PROMPT 蒸馏 → 写 memory-cover.md(同目录,保证 rename 原子)
158
+ // → 完整性检查 → 备份 MEMORY.md.{时间戳} → rename 覆盖 → cover 随 rename 消失。
159
+ async function distillProjectMemory(cwd, session) {
160
+ const c = cfg();
161
+ if (!c.enabled) return { ok: false, message: "memory-palace 已停用。" };
162
+ const dirs = paths.writeDirs(cwd);
163
+ if (!dirs.length) return { ok: false, message: "当前工作区没有可用的记忆目录。" };
164
+ // 只蒸馏主目标(最高优先级 buddy 目录或 dsh 目录);多 buddy 目录并存时不逐目录蒸馏。
165
+ const primary = dirs[0];
166
+ const memFile = paths.memoryFileOf(primary, cwd);
167
+ if (!memFile) return { ok: false, message: "无法解析项目 MEMORY.md 路径。" };
168
+ const memShort = toHomeShort(memFile);
169
+
170
+ const raw = readMdSync(memFile);
171
+ if (!raw) return { ok: false, message: `项目记忆为空(${memShort}),无需蒸馏。` };
172
+
173
+ if (distillLocks.has(memFile)) return { ok: false, message: "蒸馏正在进行中,请稍候再试。" };
174
+ distillLocks.add(memFile);
175
+ try {
176
+ // 模型解析:与 distillSessionCore 同序(summaryModel > 会话 requestHeader)。
177
+ let provider;
178
+ let model;
179
+ const sm = (c.summaryModel || "").trim();
180
+ if (sm) {
181
+ const parts = sm.split("/");
182
+ if (parts.length === 2) {
183
+ provider = parts[0].trim();
184
+ model = parts[1].trim();
185
+ }
186
+ }
187
+ if (!provider || !model) {
188
+ const conf = session?.requestHeader?.()?.config;
189
+ if (conf && conf.provider && conf.model) {
190
+ provider = conf.provider;
191
+ model = conf.model;
192
+ }
193
+ }
194
+ if (!provider || !model) {
195
+ return { ok: false, message: "无法确定蒸馏模型(summaryModel 未配置且会话无模型信息)。" };
196
+ }
197
+
198
+ // LLM 蒸馏:system = 固化 DISTILL_PROMPT,user = MEMORY.md 全文。
199
+ const timeoutMs = c.summaryTimeoutMs || 60000;
200
+ const signal = AbortSignal.timeout(timeoutMs);
201
+ const res = ctx.llm.stream({
202
+ provider,
203
+ model,
204
+ system: DISTILL_PROMPT,
205
+ messages: [
206
+ createUserMessage({
207
+ content: [{ type: "text", text: raw }],
208
+ source: { kind: "plugin", plugin: "memory-palace" },
209
+ }),
210
+ ],
211
+ maxTokens: 4000,
212
+ signal,
213
+ });
214
+ const asm = new BlockAssembler();
215
+ for await (const ch of res) {
216
+ signal.throwIfAborted();
217
+ asm.push(ch);
218
+ }
219
+ const finish = asm.finish;
220
+ if (finish && (finish.kind === "error" || finish.kind === "aborted")) {
221
+ return { ok: false, message: `蒸馏失败(LLM ${finish.kind}):${finish.failure?.message ?? ""}` };
222
+ }
223
+ const text = asm
224
+ .blocks()
225
+ .filter((b) => b.type === "text")
226
+ .map((b) => b.text ?? "")
227
+ .join("")
228
+ .trim();
229
+ if (!text) return { ok: false, message: "蒸馏结果为空,未覆盖任何文件。" };
230
+
231
+ // 写 cover(与 MEMORY.md 同目录,同文件系统保证 rename 原子)。
232
+ const coverFile = join(dirname(memFile), "memory-cover.md");
233
+ await mkdir(dirname(memFile), { recursive: true });
234
+ await writeFile(coverFile, text, "utf8");
235
+ // 完整性检查:回读非空才算写入完整;失败则删除 cover、原记忆不动。
236
+ if (!readMdSync(coverFile)) {
237
+ await unlink(coverFile).catch(() => {});
238
+ return { ok: false, message: "memory-cover.md 未完整写入,本次蒸馏失败(原记忆未动)。" };
239
+ }
240
+
241
+ // 原子替换:备份 → 覆盖(两次 rename,同目录);覆盖失败回滚备份。
242
+ // 备份名用【本地时间戳】(nowStamp),与 todayISO 同源教训——toISOString 是 UTC 会差 8 小时。
243
+ const backup = `${memFile}.${nowStamp()}`;
244
+ await rename(memFile, backup);
245
+ try {
246
+ await rename(coverFile, memFile);
247
+ } catch (e) {
248
+ await rename(backup, memFile).catch(() => {});
249
+ throw e;
250
+ }
251
+ return {
252
+ ok: true,
253
+ message: `蒸馏成功:${memShort} 已更新(原 ${raw.length} 字符 → ${text.length} 字符),备份 ${toHomeShort(backup)}。`,
254
+ };
255
+ } catch (e) {
256
+ return { ok: false, message: `蒸馏失败:${e?.message || String(e)}` };
257
+ } finally {
258
+ distillLocks.delete(memFile);
259
+ }
260
+ }
261
+
262
+ return { distillSessionCore, summarizeTurn, distillProjectMemory, distillLocks };
263
+ }
package/lib/index.js ADDED
@@ -0,0 +1,287 @@
1
+ // dsh-memory-palace — 把 WorkBuddy 的文件式记忆移植进 DeepSeek Harness。
2
+ //
3
+ // 设计决策(已与用户确认 + 源码核实):
4
+ // 1. 真源 = 人类可读 Markdown 文件,可被记事本直接编辑。这是核心价值,绝不妥协。
5
+ // 2. 读取:agent 每轮 assemble 时把记忆注入 system prompt。因 systemPrompt.section 的
6
+ // text() 必须是【同步】函数(dsh-system-prompt 源码不 await),读盘用同步 node:fs。
7
+ // 3. 写入:监听 session/event 的 turn/end,用异步 node:fs 追加。异步不在同步热路径上,
8
+ // 且用户级记忆位于 ~/.deepseek-harness(harness 自身数据约定是 ~/.dsh;此处单独用
9
+ // .deepseek-harness 表示本插件的记忆,与 WorkBuddy 的 .workbuddy 解耦),用 node:fs 才能稳定读写。
10
+ // 4. 不引入 dsh-storage:用户已确认 .md 必须保留,dsh-storage-json 落 JSON 与之冲突。
11
+ // 5. 依赖解析:本包作为标准 npm 包经 `dsh plugin --profile web add .` 装入 profile 后,
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 个坑。
18
+ //
19
+ // 模块结构(v1.2.0 混合式重构):
20
+ // - src/common/prompts.mjs / text.mjs / paths.mjs / records.mjs:纯函数与常量(可独立单测/复用)
21
+ // - src/distill.mjs / tools.mjs / api.mjs:蒸馏业务 / 记忆工具 / 自有 route(经工厂注入依赖)
22
+ // - src/index.mjs:最薄入口——apply() 装配 + 闭包运行时(session/event 跟踪、settle 时序、section 注入)
23
+ // - src/client/(00-head…90-tail):浏览器 bundle 源码(build 时零依赖拼接为 lib/client.js 单文件,
24
+ // 硬约束——dsh 客户端模块系统不支持插件相对 require)
25
+ import { join } from "node:path";
26
+ import Schema from "@deepseek-ai/schemastery";
27
+ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
28
+ import { todayISO, expandHome, toHomeShort, readMdSync, budgetClip, extractText, extractToolErrorText } from "./common/text.mjs";
29
+ import { SCENE_KEYWORDS } from "./common/prompts.mjs";
30
+ import { createPaths } from "./common/paths.mjs";
31
+ import { createRecords } from "./common/records.mjs";
32
+ import { createDistill } from "./distill.mjs";
33
+ import { registerTools } from "./tools.mjs";
34
+ import { registerApi } from "./api.mjs";
35
+
36
+ export const name = "memory-palace";
37
+ const MEMORY_PALACE_SETTINGS_NAMESPACE = settingsNamespace("memory-palace");
38
+
39
+ export const Config = Schema.object({
40
+ enabled: Schema.boolean().default(true).description("启用 memory-palace 记忆注入与每日日志写入。"),
41
+ userMemoryPath: Schema.string().default("~/.deepseek-harness/MEMORY.md").description("用户级记忆文件路径(支持 ~ 展开)。"),
42
+ workspaceMemoryDir: Schema.string().default(".deepseek-harness/memory").description("无 buddy 目录时使用的项目级每日日志目录;项目级 MEMORY.md 位于其同级(.deepseek-harness/MEMORY.md)。"),
43
+ dailyLogRetentionDays: Schema.number().default(30).description("每日日志保留天数,过期日志会被蒸馏进 MEMORY.md。"),
44
+ userBudgetChars: Schema.number().default(4000).description("注入系统提示词的用户级记忆长度上限(字符)。"),
45
+ workspaceBudgetChars: Schema.number().default(3000).description("注入系统提示词的工作区级记忆长度上限(字符)。"),
46
+ // 桥接 WorkBuddy / CodeBuddy 项目记忆:项目已存在这些目录时直接读写,不再单独建 .deepseek-harness/memory/。
47
+ bridgeBuddyMemory: Schema.boolean().default(true).description("检测并直接读写 WorkBuddy / CodeBuddy 项目记忆目录。"),
48
+ buddyWorkspaceMemoryDirs: Schema.array(Schema.string()).default([".workbuddy/memory", ".codebuddy/memory"]).description("要桥接的 buddy 项目记忆目录列表(按优先级,全部已存在目录会同步写入)。"),
49
+ // ---- v0.7.1 调整:废弃插件侧 callLlm,主路径 = 记忆公民 prompt 主动记忆;turn/end 仅做轻量兜底 ----
50
+ summarize: Schema.boolean().default(true).description("它是「agent 主动记忆」主路径失效时的安全网,保证实质工作不丢,代价是只留原始文本、不做总结。"),
51
+ autoCaptureErrors: Schema.boolean().default(true).description("对话出错时(含代码运行报错、工具执行失败)自动把『错误现象』写入对应 MEMORY.md(『根因/方案』由 agent 按记忆公民指令场景①主动记;默认开,关闭无需重启)。"),
52
+ // ---- v1.1.0:双记忆模式(插件模式=现状;智能模式=LLM 智能会话摘要),设置页切换,互斥,切换需重启 dsh ----
53
+ memoryMode: Schema.union(["plugin", "smart"]).default("plugin").description("记忆模式:plugin=记忆公民指令+轮次轻量+错误捕获;smart=LLM 智能会话摘要(summary→每日日志 + durable→MEMORY.md,带 [smart] 标记)。切换需重启 dsh 生效。"),
54
+ summaryModel: Schema.string().default("").description("智能模式的摘要模型,留空=复用当前会话 provider/model;也可填 provider/model(如 deepseek/deepseek-chat)固定廉价模型省 token。"),
55
+ summaryTimeoutMs: Schema.number().default(60000).description("智能模式单次摘要调用的超时(毫秒),超时视为失败并降级轻量条目;默认 60s。"),
56
+ });
57
+
58
+ export const inject = ["systemPrompt", "tools", "llm", "webServer", "webRuntime"];
59
+
60
+ export function apply(ctx, config) {
61
+ // 设置集成:在 DSH 设置页暴露 memory-palace 配置面板;运行时优先读用户设置覆盖层,
62
+ // 未挂载 settings 服务时回退到 cordis 组合里的 config。
63
+ let source = () => config;
64
+ const baseEntry = { ...config };
65
+ installSettingsSection(ctx, MEMORY_PALACE_SETTINGS_NAMESPACE, Config, baseEntry, {
66
+ setSource: (next) => {
67
+ source = next;
68
+ },
69
+ onChange: () => {},
70
+ });
71
+
72
+ // ---- v1.1.3:设置读写 route(真保存)----
73
+ // client fetch 同源 /memory-palace/api/settings.get|update,绕过 settingsScope(persistence=memory no-op)
74
+ // 与 apiproxy allowlist 两层限制;handler 内直接走服务端 settings → settings-file 持久化。
75
+ // 写用 replace 语义(整节替换):表单里留空的字段自动回退 base/schema 默认,与「保存=提交整个表单」一致。
76
+ let settingsFace = null;
77
+ ctx.inject(["settings"], (sctx) => {
78
+ const ns = MEMORY_PALACE_SETTINGS_NAMESPACE;
79
+ const viewOf = () => {
80
+ const descriptor = sctx.settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
81
+ return descriptor === undefined
82
+ ? { value: undefined, user: undefined, revision: undefined }
83
+ : { value: descriptor.value, user: descriptor.user, revision: descriptor.revision };
84
+ };
85
+ settingsFace = {
86
+ get: viewOf,
87
+ replace: async (section, expectedRevision) => {
88
+ await sctx.settings.replace(ns, section, expectedRevision);
89
+ return viewOf();
90
+ },
91
+ };
92
+ });
93
+ const getSettingsFace = () => settingsFace;
94
+
95
+ // ---- 运行时状态(跨模块共享的可变状态;distill/tools/api/records 经工厂注入读取) ----
96
+ const state = {
97
+ activeCwd: null, // 最近活跃 session 的 cwd(session/event 更新)
98
+ activeSession: null, // 最近活跃 session 引用
99
+ turnBuffer: [], // 每轮缓冲 {role,text},role ∈ user/assistant/tool
100
+ recentAgentWrote: false, // agent 主动调 memory_note / memory_note_user 标志
101
+ sawErrorTurn: false, // 跨 turn 累积的 request 级错误信号
102
+ lastErrorMsg: "",
103
+ settleTimer: null, // debounce 计时器
104
+ lastSummarizedSeq: -1, // 智能模式增量摘要断点(session 事件 seq)
105
+ summarySessionId: null,
106
+ };
107
+ const TURN_BUFFER_MAX = 30;
108
+ // v1.1.3:字符上限提升到 30k(长工具型 request 的工具结果动辄数千字符,6k 上限会触发
109
+ // 溢出保护清空 tool 块 → 闸门误判 → 记忆整体丢失)。
110
+ const TURN_BUFFER_CHAR_CAP = 30000;
111
+ const SETTLE_DELAY = 1500;
112
+
113
+ // ---- 装配:路径解析 / 记录读写 / 蒸馏 / 工具 / route ----
114
+ const paths = createPaths(() => source(), () => state.activeCwd);
115
+ const records = createRecords({ getConfig: () => source(), paths });
116
+ const distill = createDistill({ ctx, getConfig: () => source(), paths, records, state });
117
+ registerTools({ ctx, getConfig: () => source(), paths, records, state });
118
+ registerApi({ ctx, paths, distill, state, getSettingsFace });
119
+
120
+ // ---- session/event 跟踪:更新 activeCwd/activeSession/增量断点 + 累积 turnBuffer ----
121
+ // 关键:一次用户请求在 dsh agent 循环里会被拆成多个 turn(每个工具调用一轮),
122
+ // 故 buffer 必须跨 turn 累积整个 request,不能在单个 turn/end 清空——否则最终 turn/end
123
+ // 触发时 buffer 只剩残缺片段(无 user 请求、无 tool/result),防闲聊闸门误判为非实质轮次、不写记忆。
124
+ // 结算策略:debounce(安静期后只结算一次)+ 新 user/message 时立即结算上一个 request。
125
+ ctx.on("session/event", (session, event) => {
126
+ if (session) state.activeSession = session;
127
+ if (session?.header?.cwd) state.activeCwd = session.header.cwd;
128
+ // 切换 session 时重置智能模式增量断点(新会话从 firstLiveSeq 起算)。
129
+ if (session && session.id !== state.summarySessionId) {
130
+ state.summarySessionId = session.id;
131
+ state.lastSummarizedSeq = session.firstLiveSeq;
132
+ }
133
+ const type = event?.type;
134
+ if (type === "user/message" || type === "assistant/message" || type === "tool/result") {
135
+ const text = extractText(event);
136
+ if (text) {
137
+ const role = type === "tool/result" ? "tool" : type === "user/message" ? "user" : "assistant";
138
+ // 新用户请求到来:先结算并清空上一个 request(若存在),避免与本请求混淆。
139
+ if (type === "user/message" && state.turnBuffer.length) _flushTurn();
140
+ state.turnBuffer.push({ role, text });
141
+ if (state.turnBuffer.length > TURN_BUFFER_MAX) state.turnBuffer = state.turnBuffer.slice(-TURN_BUFFER_MAX);
142
+ const total = state.turnBuffer.reduce((n, b) => n + b.text.length, 0);
143
+ if (total > TURN_BUFFER_CHAR_CAP) {
144
+ // v1.1.3 修复:溢出时【保留尾部最近内容】而非替换成占位——占位会让 hasTool/关键词判定
145
+ // 全部失效(长工具型 request 的 tool 块被清空 → 结算闸门误判关闭 → 记忆整体丢失)。
146
+ state.turnBuffer = state.turnBuffer.slice(-8);
147
+ }
148
+ }
149
+ } else if (type === "turn/end") {
150
+ // 累积 request 级错误信号(跨 turn 合并),供结算时统一判定。
151
+ const reason = event?.data?.reason;
152
+ if (reason?.kind === "error") {
153
+ state.sawErrorTurn = true;
154
+ state.lastErrorMsg = (reason?.message || reason?.error?.message || "")?.toString() || "";
155
+ }
156
+ // 不清空 buffer;用 debounce 在整段请求安静后结算一次。
157
+ _scheduleSettle();
158
+ }
159
+ });
160
+
161
+ // ---------- 读取:同步 section text(systemPrompt 要求同步) ----------
162
+ ctx.systemPrompt.section({
163
+ name: "memory-palace",
164
+ order: 50,
165
+ text: () => {
166
+ const cfg = source();
167
+ if (!cfg.enabled) return "";
168
+ const userFileResolved = expandHome(cfg.userMemoryPath);
169
+ const userFileShort = toHomeShort(userFileResolved);
170
+ const blocks = [];
171
+ // v1.2.0:注入按预算截断——用户级 ≤ userBudgetChars、工作区级(MEMORY.md 与今日日志)
172
+ // 各自 ≤ workspaceBudgetChars;保留头部并追加截断标记(修复预算死配置问题)。
173
+ const u = readMdSync(userFileResolved);
174
+ if (u) blocks.push(`# 用户级记忆 (${userFileShort})\n${budgetClip(u, cfg.userBudgetChars)}`);
175
+ for (const dir of paths.readDirs()) {
176
+ const dirShort = toHomeShort(dir);
177
+ const w = paths.memoryReadCandidates(dir).map(readMdSync).filter(Boolean).join("\n\n");
178
+ if (w) blocks.push(`# 工作区记忆 (${dirShort})\n${budgetClip(w, cfg.workspaceBudgetChars)}`);
179
+ const t = readMdSync(join(dir, `${todayISO()}.md`));
180
+ if (t) blocks.push(`# 今日工作日志 (${todayISO()} @ ${dirShort})\n${budgetClip(t, cfg.workspaceBudgetChars)}`);
181
+ }
182
+ // 记忆正文可能为空(冷启动 / 全新环境尚无任何记忆)。
183
+ // 注意:intro(含记忆公民指令)必须【始终】注入——若因记忆为空而整体返回空串,
184
+ // agent 将不知道记忆系统存在、不会主动记,形成「无记忆 → 无指令 → 永不记」的死循环。
185
+ const bridged = paths.buddyDirs().length > 0;
186
+ const antiMangle =
187
+ "提及记忆文件路径时一律用 ~ 简写(如 ~/.deepseek-harness/MEMORY.md),不要逐字拼写绝对路径——你转述绝对路径容易漏掉目录分隔符。";
188
+ // v1.1.0:按记忆模式注入不同指令。插件模式=记忆公民指令(引导 agent 主动记);
189
+ // 智能模式=简短说明(记忆由 LLM 智能摘要自动维护,无需主动记,但仍可用 memory_read 读)。
190
+ const proactive =
191
+ source().memoryMode === "smart"
192
+ ? "\n\n[记忆说明] 你的跨 session 记忆由 LLM 智能摘要自动维护(每轮结束自动提炼摘要并沉淀 durable 事实到 MEMORY.md),无需主动调用 memory_note / memory_note_user;读取全部记忆用 memory_read 工具(不要手动 glob/read 记忆文件)。"
193
+ : "\n\n[记忆公民指令] 你拥有跨 session 的 Markdown 记忆。以下场景【必须】主动调用 memory_note(项目级约定)或 memory_note_user(跨项目个人偏好)落档,不要依赖轮次结束的自动兜底——它只做原始文本截断,无法代替你的高质量总结:\n" +
194
+ "① 完成任务 / 工作并产出结果(写脚本、统计、分析、修复、交付)→ 记任务做了什么 + 关键结果(路径/命令/数字)\n" +
195
+ "② 修复 bug / 定位根因(记现象 + 根因 + 绕过/修复手法,防复发)\n" +
196
+ "③ 验证 build/test/CI 通过(记命令与结论)\n" +
197
+ "④ 完成里程碑 / 关键决策 / 变更约定(记决策与理由)\n" +
198
+ "⑤ 用户表达的偏好、约束、durable 事实(记原话要点)\n" +
199
+ "判定标准:这条信息「下个 session 的我」还需要吗?不需要(闲聊、一次性操作、显而易见)就不记。格式:一句话结论开头 + 关键细节(命令/路径/数字),不写流水账。";
200
+ const intro = bridged
201
+ ? "你拥有持久化、人类可直接编辑的 Markdown 记忆文件。当前项目已存在 WorkBuddy/CodeBuddy 项目记忆目录,本插件直接读写这些目录(不再单独创建 .deepseek-harness/memory/)。" +
202
+ "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取全部记忆用 memory_read 工具(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
203
+ antiMangle + proactive
204
+ : "你拥有持久化、人类可直接编辑的 Markdown 记忆文件(位于 ~/.deepseek-harness/MEMORY.md,以及各项目的 .deepseek-harness/MEMORY.md(长期记忆)与 .deepseek-harness/memory/(每日日志))。" +
205
+ "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取全部记忆用 memory_read 工具(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
206
+ antiMangle + proactive;
207
+ return blocks.length ? [intro, ...blocks].join("\n\n") : intro;
208
+ },
209
+ });
210
+
211
+ // ---------- 结算一个完整 request:基于累积缓冲做「错误捕获 + 轻量兜底记录(闸门)」,不调 LLM ----------
212
+ // 主路径是 agent 按记忆公民指令主动调 memory_note;此处只兜底保证"实质轮次不丢"。
213
+ async function _settle(capturedTurn, isError, errMsg) {
214
+ const cfg = source();
215
+ if (!cfg.enabled) return;
216
+ const dirs = paths.writeDirs();
217
+ if (!dirs.length) return;
218
+
219
+ // 基础闸门(A+D 合并):结构信号(工具/错误/主动记)或 场景关键词 → 开。
220
+ const userText = capturedTurn.find((b) => b.role === "user")?.text ?? "";
221
+ const hitKeyword = SCENE_KEYWORDS.some((k) => userText.includes(k));
222
+ const hasTool = capturedTurn.some((b) => b.role === "tool");
223
+ const agentWrote = state.recentAgentWrote;
224
+
225
+ // 错误信号:request 级终态错误(任一 turn/end reason.kind==='error')或 工具/代码执行期错误。
226
+ const toolErr = extractToolErrorText(capturedTurn);
227
+ const effectiveIsError = isError || !!toolErr;
228
+
229
+ let baseGateOpen = effectiveIsError || hasTool || agentWrote || hitKeyword;
230
+
231
+ // v1.1.0:按记忆模式分派。智能模式=LLM 智能会话摘要(错误由摘要提炼;失败/返回 false 均降级轻量兜底)。
232
+ if (cfg.memoryMode === "smart") {
233
+ // v1.1.3 修复:turnBuffer 字符上限(30k)仍可能截断超长工具型 request,导致 hasTool 误判为
234
+ // false(tool 块被裁剪)→ 结算闸门误关 → 记忆整体丢失。智能模式闸门改用 session 事件增量
235
+ // (真实完整信号,不依赖会被裁剪的 turnBuffer)。
236
+ if (!baseGateOpen && state.activeSession && state.activeSession.events) {
237
+ const SURFACE = new Set(["user/message", "assistant/message", "tool/result"]);
238
+ const newEvents = state.activeSession.events.filter(
239
+ (e) => e.seq >= state.lastSummarizedSeq && SURFACE.has(e.type),
240
+ );
241
+ if (newEvents.some((e) => e.type === "tool/result")) baseGateOpen = true;
242
+ }
243
+ }
244
+
245
+ // 写门控:命中闸门才写(受 summarize 总开关,两种模式共用)。
246
+ if (!cfg.summarize || !baseGateOpen) return;
247
+
248
+ if (cfg.memoryMode === "smart") {
249
+ void distill.summarizeTurn(capturedTurn, dirs, effectiveIsError)
250
+ .then((ok) => {
251
+ if (!ok) return records.writeLightEntry(dirs, capturedTurn, effectiveIsError);
252
+ })
253
+ .catch(() => records.writeLightEntry(dirs, capturedTurn, effectiveIsError))
254
+ .catch(() => {});
255
+ return;
256
+ }
257
+
258
+ // 插件模式:错误捕获(受 autoCaptureErrors 门控)+ 轻量兜底记录。
259
+ if (cfg.autoCaptureErrors && effectiveIsError) {
260
+ const finalErrMsg = errMsg || toolErr || "(in-session error, no message)";
261
+ void records.captureError(capturedTurn, { kind: "error", message: finalErrMsg }).catch(() => {});
262
+ }
263
+ void records.writeLightEntry(dirs, capturedTurn, effectiveIsError).catch(() => {});
264
+ }
265
+
266
+ // 立即结算并清空当前缓冲(供 debounce 到点、或收到新 user/message 时调用)。
267
+ function _flushTurn() {
268
+ const capturedTurn = state.turnBuffer;
269
+ const wasErrorTurn = state.sawErrorTurn;
270
+ const errMsg = state.lastErrorMsg;
271
+ state.turnBuffer = [];
272
+ state.sawErrorTurn = false;
273
+ state.lastErrorMsg = "";
274
+ state.recentAgentWrote = false;
275
+ if (!capturedTurn.length) return;
276
+ void _settle(capturedTurn, wasErrorTurn, errMsg).catch(() => {});
277
+ }
278
+
279
+ // debounce:每次 turn/end 重置计时器;SETTLE_DELAY 内无新 turn/end(即整段请求安静)才结算一次。
280
+ function _scheduleSettle() {
281
+ if (state.settleTimer) clearTimeout(state.settleTimer);
282
+ state.settleTimer = setTimeout(() => {
283
+ state.settleTimer = null;
284
+ _flushTurn();
285
+ }, SETTLE_DELAY);
286
+ }
287
+ }