dsh-memory-palace 1.4.1 → 1.6.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,327 @@
1
+ // memory-palace v1.6.0 hybrid 模式:MEMORY.md 主动维护工具集(仅 hybrid 模式注册)。
2
+ // 三个工具(操作规范全部承载在 description——定案 #11,不做 skill):
3
+ // - memory_write 章节追加/新增(项目级多目录同步;用户级单文件)
4
+ // - memory_update_section 整章节精确替换 / 单条标记删除(stale 防护:oldText 归一化精确匹配)
5
+ // - memory_reorganize 项目级全量重整(双门禁机器校验 + 原子替换 + 时间戳注释)
6
+ // 定案约束:用户级禁止重整;重整时间戳由机器读写(HTML 注释落文件尾,agent 不可改);
7
+ // hybrid 下不做三级去重管线(重复治理归子 agent),仅保留同 normLine 精确去重防手滑重复。
8
+ import { defineTool } from "@deepseek-ai/dsh-tools";
9
+ import { writeFile, rename } from "node:fs/promises";
10
+ import { join, dirname } from "node:path";
11
+ import { readFileSync } from "node:fs";
12
+ import { expandHome, toHomeShort, readMdSync, nowStamp, normLine } from "../common/text.mjs";
13
+ import { appendToSectionText, upsertSectionText, replaceSectionText, markEntryDeletedText, locateSection } from "../common/sections.mjs";
14
+
15
+ const REORG_MARK = "memory-palace:last-reorg:";
16
+ const REORG_MARK_RE = new RegExp(`\\n?<!--\\s*${REORG_MARK}[^>]*-->\\s*$`);
17
+
18
+ // 本地 ISO 时间戳(YYYY-MM-DDTHH:mm:ss,冒号保留——落文件内容无文件名限制)。
19
+ // 沿 text.mjs nowStamp 的教训:禁 toISOString(UTC 差 8 小时)。
20
+ const localIsoStamp = () => {
21
+ const d = new Date();
22
+ const p = (n) => String(n).padStart(2, "0");
23
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
24
+ };
25
+
26
+ // 读重整时间戳:文件尾 HTML 注释;无/损坏 → 0(视为从未重整,冷却已过)。
27
+ export function readLastReorg(md) {
28
+ const m = new RegExp(`<!--\\s*${REORG_MARK}([^>]+)-->`).exec(md || "");
29
+ if (!m) return 0;
30
+ const t = new Date(m[1].trim()).getTime();
31
+ return Number.isFinite(t) ? t : 0;
32
+ }
33
+
34
+ // 计算 memory_reorganize 双门禁状态(供 pre-execute 弹窗与 execute 复核共用)。
35
+ export function checkReorgGate(memFile, cfg) {
36
+ const md = readMdSync(memFile);
37
+ const size = md.length;
38
+ const overBudget = size > (cfg.workspaceBudgetChars || 3000);
39
+ const last = readLastReorg(md);
40
+ const cooldownMs = (cfg.reorgCooldownDays ?? 7) * 86400000;
41
+ const cooled = Date.now() - last >= cooldownMs;
42
+ return {
43
+ ok: overBudget && cooled,
44
+ size,
45
+ overBudget,
46
+ last,
47
+ cooled,
48
+ reason: overBudget
49
+ ? cooled
50
+ ? "ok"
51
+ : `重整冷却期内(上次重整 ${new Date(last).toLocaleString()},冷却 ${cfg.reorgCooldownDays ?? 7} 天),请用 memory_update_section 做章节级修正。`
52
+ : `MEMORY.md 当前 ${size} 字符未超出注入预算(${cfg.workspaceBudgetChars || 3000}),无需重整;请用 memory_write / memory_update_section 维护。`,
53
+ };
54
+ }
55
+
56
+ // 同 normLine 精确去重:目标章节内已存在同归一化条目 → skip(不算去重管线,仅防手滑重复)。
57
+ function sectionHasEntry(md, section, entry) {
58
+ const loc = locateSection(md, section);
59
+ if (!loc) return false;
60
+ const want = normLine(entry);
61
+ for (let i = loc.start + 1; i < loc.end; i++) {
62
+ if (normLine(loc.lines[i]) === want) return true;
63
+ }
64
+ return false;
65
+ }
66
+
67
+ /**
68
+ * 注册 hybrid 工具 + pre-execute 闸门。仅在 memoryMode === "hybrid" 时由 index.mjs 调用
69
+ * (memoryMode 切换需重启 dsh——注册时机安全,不存在运行中换挡错配)。
70
+ * @param {{ ctx: object, getConfig: () => object, paths: object, records: object, state: object }} deps
71
+ */
72
+ export function registerHybridTools({ ctx, getConfig, paths, records, state }) {
73
+ const cfg = () => getConfig();
74
+
75
+ // 目标文件解析:project = 主目标(dirs[0],多 buddy 目录时与蒸馏按钮同语义只动主目标,
76
+ // 章节级 stale 校验天然防止错位覆写);user = 用户级 MEMORY.md。
77
+ function memFileOf(scope) {
78
+ if (scope === "user") return { file: expandHome(cfg().userMemoryPath), dirs: [] };
79
+ const dirs = paths.writeDirs();
80
+ if (!dirs.length) return null;
81
+ return { file: paths.memoryFileOf(dirs[0]), dirs };
82
+ }
83
+
84
+ // ---------- memory_write:章节追加/新增(章节化格式规范承载于 description——定案 #11) ----------
85
+ ctx.tools.register(
86
+ defineTool({
87
+ name: "memory_write",
88
+ description:
89
+ "Append a durable memory entry to MEMORY.md in the required chaptered format. " +
90
+ "Format is STRICT: entries live under `## 章节` headings as `- 条目` lines (one-line conclusion first, " +
91
+ "then key details: commands/paths/numbers; objective third-person; no dialogue, no questions, no tags). " +
92
+ "If the section exists the entry is appended under it; if not, the section is created automatically " +
93
+ "(do NOT force-match a semantically wrong section — pick a new section name instead). " +
94
+ "An exact-duplicate entry (same normalized text) in the same section is skipped. " +
95
+ "scope='project' writes the active workspace MEMORY.md; scope='user' writes the cross-project user MEMORY.md. " +
96
+ "NEVER write memory files with the raw write/edit tools — always use this tool. " +
97
+ "For rewriting a whole section use memory_update_section; full-file reorganization is memory_reorganize (gated).",
98
+ parameters: {
99
+ scope: {
100
+ type: "string",
101
+ required: true,
102
+ description: "'project' (active workspace MEMORY.md) or 'user' (cross-project user MEMORY.md).",
103
+ },
104
+ section: { type: "string", required: true, description: "章节标题(不含 `## ` 前缀),如:环境必知。" },
105
+ entry: { type: "string", required: true, description: "条目文本(一行,结论先行 + 关键细节,不带 `- ` 前缀)。" },
106
+ },
107
+ output: {
108
+ schema: {
109
+ type: "object",
110
+ additionalProperties: false,
111
+ properties: { ok: { type: "boolean", required: true }, message: { type: "string", required: true } },
112
+ },
113
+ render: (_args, value) => [{ type: "text", text: value?.message ?? "Saved." }],
114
+ },
115
+ async execute(args) {
116
+ state.recentAgentWrote = true;
117
+ const c = cfg();
118
+ if (!c.enabled) return { ok: false, message: "memory-palace is currently disabled in settings." };
119
+ const section = String(args.section ?? "").trim();
120
+ const entry = String(args.entry ?? "").trim();
121
+ if (!section || !entry) return { ok: false, message: "section 与 entry 均不能为空。" };
122
+ const target = memFileOf(args.scope === "user" ? "user" : "project");
123
+ if (!target) return { ok: false, message: "No active workspace." };
124
+ try {
125
+ let wrote = 0;
126
+ let skipped = 0;
127
+ const files = target.dirs.length ? target.dirs.map((d) => paths.memoryFileOf(d)) : [target.file];
128
+ for (const file of files) {
129
+ const md = readMdSync(file);
130
+ if (sectionHasEntry(md, section, entry)) {
131
+ skipped++;
132
+ continue;
133
+ }
134
+ const r = upsertSectionText(md, section, entry);
135
+ if (!r.ok) {
136
+ skipped++;
137
+ continue;
138
+ }
139
+ await writeFile(file, r.text, "utf8");
140
+ wrote++;
141
+ }
142
+ return {
143
+ ok: wrote > 0,
144
+ message: wrote > 0
145
+ ? `Saved to ${wrote} MEMORY.md file(s) under 「${section}」${skipped ? ` (${skipped} duplicate skipped)` : ""}.`
146
+ : `Duplicate entry under 「${section}」; nothing written.`,
147
+ };
148
+ } catch (e) {
149
+ return { ok: false, message: `Failed: ${e}` };
150
+ }
151
+ },
152
+ }),
153
+ );
154
+
155
+ // ---------- memory_update_section:整章节精确替换 / 单条标记删除(stale 防护) ----------
156
+ ctx.tools.register(
157
+ defineTool({
158
+ name: "memory_update_section",
159
+ description:
160
+ "Update ONE section of MEMORY.md with stale-protection. Two modes:\n" +
161
+ "1) Replace: pass oldText = the FULL current section text including its `## heading` line " +
162
+ "(read it first via memory_read) and newText = the complete replacement section. The oldText is " +
163
+ "matched by normalized exact comparison against the section on disk — on any mismatch the tool REJECTS " +
164
+ "and returns the actual current content, so re-read and retry.\n" +
165
+ "2) Mark-delete a single entry: pass markDelete:true and oldText = the exact entry line text; the entry " +
166
+ "becomes a strikethrough tombstone (~~text~~) — never physically deleted, recoverable by hand.\n" +
167
+ "NEVER edit memory files with raw write/edit tools. Deleting whole information is only allowed as " +
168
+ "strikethrough; section replacement must not drop still-valid entries. " +
169
+ "scope='project' (active workspace MEMORY.md, primary target) or 'user' (user MEMORY.md; NO full reorganize).",
170
+ parameters: {
171
+ scope: {
172
+ type: "string",
173
+ required: true,
174
+ description: "'project' (active workspace MEMORY.md primary target) or 'user'.",
175
+ },
176
+ section: { type: "string", required: true, description: "章节标题(不含 `## ` 前缀)。" },
177
+ oldText: {
178
+ type: "string",
179
+ required: true,
180
+ description: "replace 模式 = 含 `## 标题` 行的整章节当前全文;markDelete 模式 = 目标条目原文(可含 `- ` 前缀)。",
181
+ },
182
+ newText: { type: "string", description: "replace 模式必填:替换后的整章节全文(含 `## 标题` 行,需保留删除线墓碑)。" },
183
+ markDelete: { type: "boolean", description: "true = 仅把 oldText 匹配的单条条目标记为删除线;省略 = 整章节替换。" },
184
+ },
185
+ output: {
186
+ schema: {
187
+ type: "object",
188
+ additionalProperties: false,
189
+ properties: {
190
+ ok: { type: "boolean", required: true },
191
+ message: { type: "string", required: true },
192
+ actual: { type: "string", required: true },
193
+ },
194
+ },
195
+ render: (_args, value) =>
196
+ [{ type: "text", text: value?.actual ? `${value.message}\n\n当前实际内容:\n${value.actual}` : value?.message ?? "Done." }],
197
+ },
198
+ async execute(args) {
199
+ state.recentAgentWrote = true;
200
+ const c = cfg();
201
+ if (!c.enabled) return { ok: false, message: "memory-palace is currently disabled in settings.", actual: "" };
202
+ const section = String(args.section ?? "").trim();
203
+ const oldText = String(args.oldText ?? "");
204
+ if (!section || !oldText.trim()) return { ok: false, message: "section 与 oldText 均不能为空。", actual: "" };
205
+ const target = memFileOf(args.scope === "user" ? "user" : "project");
206
+ if (!target) return { ok: false, message: "No active workspace.", actual: "" };
207
+ try {
208
+ const file = target.file;
209
+ const md = readMdSync(file);
210
+ const r = args.markDelete === true
211
+ ? markEntryDeletedText(md, section, oldText)
212
+ : replaceSectionText(md, section, oldText, args.newText);
213
+ if (!r.ok) {
214
+ return { ok: false, message: `更新被拒绝(${r.reason}):请先 memory_read 重读后再试。`, actual: r.actual ?? "" };
215
+ }
216
+ await writeFile(file, r.text, "utf8");
217
+ return { ok: true, message: `Updated 「${section}」 in ${toHomeShort(file)} (${r.reason}).`, actual: "" };
218
+ } catch (e) {
219
+ return { ok: false, message: `Failed: ${e}`, actual: "" };
220
+ }
221
+ },
222
+ }),
223
+ );
224
+
225
+ // ---------- memory_reorganize:项目级全量重整(双门禁机器校验 + 原子替换 + 时间戳) ----------
226
+ ctx.tools.register(
227
+ defineTool({
228
+ name: "memory_reorganize",
229
+ description:
230
+ "FULLY reorganize the PROJECT-LEVEL MEMORY.md by replacing the whole file with newContent. " +
231
+ "HARD GATES (machine-checked, both required): (1) the file exceeds the injection budget " +
232
+ "(workspaceBudgetChars); (2) at least reorgCooldownDays (default 7) since the last reorganize. " +
233
+ "If either gate fails the call is REJECTED — use memory_update_section instead. " +
234
+ "Requirements for newContent: keep the full `## 章节` + `- 条目` format; NEVER lose still-valid key " +
235
+ "information — read the daily logs (memory_read) and current MEMORY.md for cross-checking first; " +
236
+ "merge duplicates, drop only strikethrough/obsolete content; keep user-level MEMORY.md untouched " +
237
+ "(user-level reorganization is FORBIDDEN). A last-reorg timestamp comment is appended automatically. " +
238
+ "A user confirmation dialog is shown before execution.",
239
+ parameters: {
240
+ newContent: {
241
+ type: "string",
242
+ required: true,
243
+ description: "重整后的项目级 MEMORY.md 完整全文(`## 章节` + `- 条目` 格式;不含重整时间戳注释——机器自动追加)。",
244
+ },
245
+ },
246
+ output: {
247
+ schema: {
248
+ type: "object",
249
+ additionalProperties: false,
250
+ properties: { ok: { type: "boolean", required: true }, message: { type: "string", required: true } },
251
+ },
252
+ render: (_args, value) => [{ type: "text", text: value?.message ?? "Done." }],
253
+ },
254
+ async execute(args) {
255
+ state.recentAgentWrote = true;
256
+ const c = cfg();
257
+ if (!c.enabled) return { ok: false, message: "memory-palace is currently disabled in settings." };
258
+ const dirs = paths.writeDirs();
259
+ if (!dirs.length) return { ok: false, message: "No active workspace." };
260
+ const file = paths.memoryFileOf(dirs[0]);
261
+ // 复核双门禁(pre-execute 已拦一道;这里防绕过)
262
+ const gate = checkReorgGate(file, c);
263
+ if (!gate.ok) return { ok: false, message: `重整被门禁拒绝:${gate.reason}` };
264
+ const newContent = String(args.newContent ?? "").replace(REORG_MARK_RE, "").replace(/\s+$/, "");
265
+ if (!newContent.trim()) return { ok: false, message: "newContent 不能为空。" };
266
+ const stamped = `${newContent}\n\n<!-- ${REORG_MARK}${localIsoStamp()} -->\n`;
267
+ try {
268
+ // 原子替换(沿 distillProjectMemory 模式):cover 同目录写入 → 完整性校验 → 备份 → rename → 失败回滚
269
+ const coverFile = join(dirname(file), "memory-cover.md");
270
+ await writeFile(coverFile, stamped, "utf8");
271
+ if (!readMdSync(coverFile)) {
272
+ await import("node:fs/promises").then((m) => m.unlink(coverFile)).catch(() => {});
273
+ return { ok: false, message: "memory-cover.md 未完整写入,本次重整失败(原记忆未动)。" };
274
+ }
275
+ const backup = `${file}.${nowStamp()}`;
276
+ const fsp = await import("node:fs/promises");
277
+ await fsp.rename(file, backup).catch(async () => {
278
+ await fsp.rename(coverFile, file); // 原文件不存在(首次创建)时直接落位
279
+ return true;
280
+ });
281
+ try {
282
+ await fsp.rename(coverFile, file);
283
+ } catch (e) {
284
+ await fsp.rename(backup, file).catch(() => {});
285
+ throw e;
286
+ }
287
+ return {
288
+ ok: true,
289
+ message: `重整完成:${toHomeShort(file)}(原 ${gate.size} → ${stamped.length} 字符),备份 ${toHomeShort(backup)},时间戳已更新。`,
290
+ };
291
+ } catch (e) {
292
+ return { ok: false, message: `重整失败(原记忆未动或已回滚):${e?.message || String(e)}` };
293
+ }
294
+ },
295
+ }),
296
+ );
297
+ }
298
+
299
+ // hybrid 写工具闸门(pre-execute):plan 模式 deny;memory_reorganize 门禁前置计算 + 原生确认弹窗。
300
+ // 沿 tools.mjs 的 memory_delete 闸门模式(tools/pre-execute → approval.request)。
301
+ export function attachHybridGuards(ctx, getConfig, paths, state) {
302
+ const HYBRID_WRITE_TOOLS = new Set(["memory_write", "memory_update_section", "memory_reorganize"]);
303
+ ctx.on("tools/pre-execute", async (exec, next) => {
304
+ const name = exec?.name;
305
+ if (!HYBRID_WRITE_TOOLS.has(name)) return next();
306
+ if (state.planModeActive) {
307
+ return { kind: "deny", reason: "plan 模式下禁止写入记忆" };
308
+ }
309
+ if (name !== "memory_reorganize") return next();
310
+ const c = getConfig();
311
+ const dirs = paths.writeDirs();
312
+ if (!dirs.length) return { kind: "deny", reason: "无活动工作区,无法重整。" };
313
+ const file = paths.memoryFileOf(dirs[0]);
314
+ const gate = checkReorgGate(file, c);
315
+ if (!gate.ok) return { kind: "deny", reason: gate.reason };
316
+ const args = (exec.arguments && typeof exec.arguments === "object") ? exec.arguments : {};
317
+ const size = String(args.newContent ?? "").length;
318
+ return {
319
+ kind: "ask",
320
+ reason:
321
+ `⚠️ 项目级 MEMORY.md 全量重整确认\n` +
322
+ `当前 ${gate.size} 字符(已超预算 ${c.workspaceBudgetChars || 3000}),冷却已满足(上次重整 ${gate.last ? new Date(gate.last).toLocaleString() : "从未"})。\n` +
323
+ `即将用 ${size} 字符的新全文覆盖 ${file}(原文件会自动备份)。\n` +
324
+ `请确认是否执行重整?`,
325
+ };
326
+ });
327
+ }
package/lib/index.js CHANGED
@@ -24,17 +24,20 @@
24
24
  // 硬约束——dsh 客户端模块系统不支持插件相对 require)
25
25
  import { join } from "node:path";
26
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";
27
+ // v1.6.0-rc1:适配 DSH 0.1.2-alpha.2——dsh-settings 不再导出自由函数 installSettingsSection /
28
+ // settingsNamespace,设置节注册改用 SettingsProvider 实例方法 installSection(经 ctx.inject(["settings"]))。
29
+ import { todayISO, expandHome, toHomeShort, readMdSync, budgetClip, stripSmartTag, stripDeletedLines, extractText, extractToolErrorText } from "./common/text.mjs";
29
30
  import { SCENE_KEYWORDS } from "./common/prompts.mjs";
30
31
  import { createPaths } from "./common/paths.mjs";
31
32
  import { createRecords } from "./common/records.mjs";
32
33
  import { createDistill } from "./distill.mjs";
33
34
  import { registerTools } from "./tools.mjs";
34
35
  import { registerApi } from "./api.mjs";
36
+ import { registerHybrid, HYBRID_PROACTIVE } from "./hybrid/index.mjs";
35
37
 
36
38
  export const name = "memory-palace";
37
- const MEMORY_PALACE_SETTINGS_NAMESPACE = settingsNamespace("memory-palace");
39
+ // v1.6.0-rc1:settingsNamespace() 已删除,namespace 直接用字符串(宿主侧做类型级校验)。
40
+ const MEMORY_PALACE_SETTINGS_NAMESPACE = "memory-palace";
38
41
 
39
42
  export const Config = Schema.object({
40
43
  enabled: Schema.boolean().default(true).description("启用 memory-palace 记忆注入与每日日志写入。"),
@@ -49,8 +52,9 @@ export const Config = Schema.object({
49
52
  // ---- v0.7.1 调整:废弃插件侧 callLlm,主路径 = 记忆公民 prompt 主动记忆;turn/end 仅做轻量兜底 ----
50
53
  summarize: Schema.boolean().default(true).description("它是「agent 主动记忆」主路径失效时的安全网,保证实质工作不丢,代价是只留原始文本、不做总结。"),
51
54
  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 生效。"),
55
+ // ---- v1.1.0:记忆模式(plugin=记忆公民指令+轮次轻量+错误捕获;smart=LLM 智能会话摘要)----
56
+ // v1.6.0:+hybrid(子 agent 自动维护日志 + agent 主动维护 MEMORY.md),三档并存,切换需重启 dsh。
57
+ memoryMode: Schema.union(["plugin", "smart", "hybrid"]).default("plugin").description("记忆模式:plugin=记忆公民指令+轮次轻量+错误捕获;smart=LLM 智能会话摘要(summary→每日日志 + durable→MEMORY.md);hybrid=记忆子代理自动维护今日日志(标删去重)+ agent 主动维护 MEMORY.md(章节化写入/整章节替换/门禁内全量重整)。切换需重启 dsh 生效。"),
54
58
  summaryModel: Schema.string().default("").description("智能模式的摘要模型,留空=复用当前会话 provider/model;也可填 provider/model(如 deepseek/deepseek-chat)固定廉价模型省 token。"),
55
59
  summaryTimeoutMs: Schema.number().default(60000).description("蒸馏 LLM 调用的超时(毫秒),超时视为失败并降级;覆盖智能模式摘要与手动蒸馏两条链路;默认 60000(60s)。"),
56
60
  distillDebugLog: Schema.boolean().default(false).description("调试开关:向 dsh 服务端 stderr 输出蒸馏 LLM 调用诊断。distillLogLevel=info 时仅输出元数据(模型解析/请求参数/流进度/错误详情,不含文本);distillLogLevel=debug 会额外打印 LLM 原始响应文本(分隔符包裹),仅限受信本地排障开启。"),
@@ -60,7 +64,10 @@ export const Config = Schema.object({
60
64
  summaryMaxTokens: Schema.number().default(2000).description("智能模式会话摘要 LLM 的【最终输出软预算(prompt 约束,思考不受限)】。默认 2000。实际硬上限由模型自身 maxTokens 决定;可调大以容纳更多 durable 事实。"),
61
65
  projectMaxTokens: Schema.number().default(8000).description("手动「蒸馏项目记忆」LLM 的【最终输出软预算(prompt 约束,思考不受限)】。默认 8000。实际硬上限由模型自身 maxTokens 决定。"),
62
66
  // ---- v1.4.0:蒸馏时回喂存量记忆(特性3,默认关;开启后自动智能模式 turn/end 与手动蒸馏按钮均回喂,delete 仅手动放开) ----
63
- feedbackEnabled: Schema.boolean().default(false).description("蒸馏时把项目级 + 用户级 MEMORY.md 全文(逐行编号、无截断)回喂给 LLM,使其能基于既有记忆做 add/replace/delete 增量维护。默认关;开启后自动智能模式(turn/end)与手动蒸馏会话按钮均回喂存量记忆;delete 仍仅手动按钮开放,自动模式跳过(防误删)。"),
67
+ feedbackEnabled: Schema.boolean().default(false).description("蒸馏时把项目级 + 用户级 MEMORY.md 全文(逐行编号、无截断)回喂给 LLM,使其能基于既有记忆做 add/replace/delete 增量维护。默认关;开启后自动智能模式(turn/end)与手动蒸馏会话按钮均回喂存量记忆;delete 仍仅手动按钮开放,自动模式跳过(防误删)。hybrid 模式下此开关不参与(子代理自带日志回喂)。"),
68
+ // ---- v1.6.0:hybrid 模式配置(仅 memoryMode=hybrid 时生效) ----
69
+ reorgCooldownDays: Schema.number().default(7).description("hybrid 模式:项目级 MEMORY.md 全量重整的冷却天数(距上次重整)。与「超出注入预算」双条件同时满足才允许 memory_reorganize;时间戳以 HTML 注释落在 MEMORY.md 文件尾。"),
70
+ subagentLogBudget: Schema.number().default(20000).description("hybrid 模式:记忆子代理回喂今日工作日志的字符上限。超出时仅回喂章节目录,子代理用 log_read_section 按需读取章节。"),
64
71
  });
65
72
 
66
73
  export const inject = ["systemPrompt", "tools", "llm", "webServer", "webRuntime"];
@@ -68,14 +75,10 @@ export const inject = ["systemPrompt", "tools", "llm", "webServer", "webRuntime"
68
75
  export function apply(ctx, config) {
69
76
  // 设置集成:在 DSH 设置页暴露 memory-palace 配置面板;运行时优先读用户设置覆盖层,
70
77
  // 未挂载 settings 服务时回退到 cordis 组合里的 config。
78
+ // v1.6.0-rc1:注册动作挪进下方 ctx.inject(["settings"]) 回调(installSection 是
79
+ // SettingsProvider 实例方法,须等 settings 服务挂载;其内部同步 setSource,时序与旧版等价)。
71
80
  let source = () => config;
72
81
  const baseEntry = { ...config };
73
- installSettingsSection(ctx, MEMORY_PALACE_SETTINGS_NAMESPACE, Config, baseEntry, {
74
- setSource: (next) => {
75
- source = next;
76
- },
77
- onChange: () => {},
78
- });
79
82
 
80
83
  // ---- v1.1.3:设置读写 route(真保存)----
81
84
  // client fetch 同源 /memory-palace/api/settings.get|update,绕过 settingsScope(persistence=memory no-op)
@@ -84,6 +87,15 @@ export function apply(ctx, config) {
84
87
  let settingsFace = null;
85
88
  ctx.inject(["settings"], (sctx) => {
86
89
  const ns = MEMORY_PALACE_SETTINGS_NAMESPACE;
90
+ // v1.6.0-rc1:设置节注册挪到此处(settings 服务就绪后)。installSection 内部会同步
91
+ // 执行 setSource(() => scope.get()),把 source 从 cordis config 切到用户设置覆盖层;
92
+ // owner=ctx 使插件卸载时宿主自动回退 setSource(() => entry)。与旧版自由函数时序等价。
93
+ sctx.settings.installSection(ctx, ns, Config, baseEntry, {
94
+ setSource: (next) => {
95
+ source = next;
96
+ },
97
+ onChange: () => {},
98
+ });
87
99
  const viewOf = () => {
88
100
  const descriptor = sctx.settings.describe({ redactSecrets: true }).find((candidate) => candidate.ns === ns);
89
101
  return descriptor === undefined
@@ -112,6 +124,9 @@ export function apply(ctx, config) {
112
124
  lastSummarizedSeq: -1, // 智能模式增量摘要断点(session 事件 seq)
113
125
  summarySessionId: null,
114
126
  planModeActive: false, // 计划模式:禁写记忆(硬编码默认,无开关;仅经 session/event 的 plan/mode 翻转;未装 dsh-plan-mode 永不触发=不拦截)
127
+ // v1.4.1:已注入记忆的 session 集合——仅首次注入(DSH 会话自身继承历史轮次,逐轮注入
128
+ // = 内容重复 + 过时断言每轮在场);compaction 发生后清除对应 id,下次构建重注。
129
+ injectedSessionIds: new Set(),
115
130
  };
116
131
  const TURN_BUFFER_MAX = 30;
117
132
  // v1.1.3:字符上限提升到 30k(长工具型 request 的工具结果动辄数千字符,6k 上限会触发
@@ -125,6 +140,14 @@ export function apply(ctx, config) {
125
140
  const distill = createDistill({ ctx, getConfig: () => source(), paths, records, state });
126
141
  registerTools({ ctx, getConfig: () => source(), paths, records, state });
127
142
  registerApi({ ctx, paths, distill, state, getSettingsFace });
143
+ // v1.6.0:hybrid 模式独立模块。必须【无条件注册】——apply() 同步段执行时 settings 服务
144
+ // 尚未挂载(v1.6.0-rc1 起设置节经 SettingsProvider.installSection 在 ctx.inject(["settings"])
145
+ // 回调里注册并同步 setSource),此刻 source() 读的是 cordis 组合配置(默认 plugin),
146
+ // 条件注册会导致设置页切 hybrid 后工具/子代理从未注册(v1.6.0 实装踩坑:settings.yaml
147
+ // memoryMode=hybrid 但 registerHybrid 未执行 → 子代理 TypeError 被吞、日志全空)。
148
+ // 运行时分派靠 _settle / proactive 的热 source() 判断;工具注册无副作用(plugin/smart
149
+ // 的 prompt 不引导使用,guards 白名单只拦 hybrid 工具)。
150
+ const hybrid = registerHybrid({ ctx, getConfig: () => source(), paths, records, state });
128
151
 
129
152
  // ---- session/event 跟踪:更新 activeCwd/activeSession/增量断点 + 累积 turnBuffer ----
130
153
  // 关键:一次用户请求在 dsh agent 循环里会被拆成多个 turn(每个工具调用一轮),
@@ -169,6 +192,10 @@ export function apply(ctx, config) {
169
192
  }
170
193
  // 不清空 buffer;用 debounce 在整段请求安静后结算一次。
171
194
  _scheduleSettle();
195
+ } else if (type === "compaction" || type?.startsWith?.("compaction/")) {
196
+ // v1.4.1:compaction 压缩上下文后,记忆块可能被压掉——清除已注入标记,
197
+ // 下次 system prompt 构建时重注(压缩后上下文变薄,重注时机依然正确)。
198
+ if (session?.id) state.injectedSessionIds.delete(session.id);
172
199
  }
173
200
  });
174
201
 
@@ -183,16 +210,28 @@ export function apply(ctx, config) {
183
210
  const userFileShort = toHomeShort(userFileResolved);
184
211
  const blocks = [];
185
212
  // v1.2.0:注入按预算截断——用户级 ≤ userBudgetChars、工作区级(MEMORY.md 与今日日志)
186
- // 各自 ≤ workspaceBudgetChars;保留头部并追加截断标记(修复预算死配置问题)。
213
+ // 各自 ≤ workspaceBudgetChars。v1.4.1:budgetClip 改为"结构行 + 尾部最新条目"(修
214
+ // LIFO/FIFO 错配——写入追加在尾部,旧实现只留头部导致最新结论永远进不了视野),
215
+ // stripSmartTag 剥除行首 [smart] 标记(模型不可见该标签,存量自然消亡)。
216
+ // v1.6.0:stripDeletedLines 剔除整行删除线墓碑(文件保留、注入不过)。
187
217
  const u = readMdSync(userFileResolved);
188
- if (u) blocks.push(`# 用户级记忆 (${userFileShort})\n${budgetClip(u, cfg.userBudgetChars)}`);
218
+ if (u) blocks.push(`# 用户级记忆 (${userFileShort})\n${stripSmartTag(stripDeletedLines(budgetClip(u, cfg.userBudgetChars)))}`);
189
219
  for (const dir of paths.readDirs()) {
190
220
  const dirShort = toHomeShort(dir);
191
221
  const w = paths.memoryReadCandidates(dir).map(readMdSync).filter(Boolean).join("\n\n");
192
- if (w) blocks.push(`# 工作区记忆 (${dirShort})\n${budgetClip(w, cfg.workspaceBudgetChars)}`);
222
+ if (w) blocks.push(`# 工作区记忆 (${dirShort})\n${stripSmartTag(stripDeletedLines(budgetClip(w, cfg.workspaceBudgetChars)))}`);
193
223
  const t = readMdSync(join(dir, `${todayISO()}.md`));
194
- if (t) blocks.push(`# 今日工作日志 (${todayISO()} @ ${dirShort})\n${budgetClip(t, cfg.workspaceBudgetChars)}`);
224
+ if (t) blocks.push(`# 今日工作日志 (${todayISO()} @ ${dirShort})\n${stripSmartTag(stripDeletedLines(budgetClip(t, cfg.workspaceBudgetChars)))}`);
195
225
  }
226
+ // v1.4.1:仅首次注入。DSH 会话自身引用历史轮次作为上下文,逐轮注入 = 内容重复 +
227
+ // 过时断言每轮在场;记忆注入的价值大头在冷启动第一轮。已注入过的 session 返回
228
+ // 空串;compaction 后经 session/event 清除标记实现重注。
229
+ // intro 指令仍始终注入(无记忆时也不例外——否则 agent 不知道记忆系统存在,形成
230
+ // 「无记忆→无指令→永不记」死循环);被跳过的只是记忆正文块。
231
+ const sessionId = state.activeSession?.id ?? null;
232
+ const alreadyInjected = sessionId ? state.injectedSessionIds.has(sessionId) : false;
233
+ if (sessionId && !alreadyInjected && blocks.length) state.injectedSessionIds.add(sessionId);
234
+ if (alreadyInjected) blocks.length = 0;
196
235
  // 记忆正文可能为空(冷启动 / 全新环境尚无任何记忆)。
197
236
  // 注意:intro(含记忆公民指令)必须【始终】注入——若因记忆为空而整体返回空串,
198
237
  // agent 将不知道记忆系统存在、不会主动记,形成「无记忆 → 无指令 → 永不记」的死循环。
@@ -201,16 +240,19 @@ export function apply(ctx, config) {
201
240
  "提及记忆文件路径时一律用 ~ 简写(如 ~/.deepseek-harness/MEMORY.md),不要逐字拼写绝对路径——你转述绝对路径容易漏掉目录分隔符。";
202
241
  // v1.1.0:按记忆模式注入不同指令。插件模式=记忆公民指令(引导 agent 主动记);
203
242
  // 智能模式=简短说明(记忆由 LLM 智能摘要自动维护,无需主动记,但仍可用 memory_read 读)。
243
+ // v1.6.0:hybrid=记忆子代理自动维护日志 + agent 主动维护 MEMORY.md(见 HYBRID_PROACTIVE)。
204
244
  const proactive =
205
245
  source().memoryMode === "smart"
206
246
  ? "\n\n[记忆说明] 你的跨 session 记忆由 LLM 智能摘要自动维护(每轮结束自动提炼摘要并沉淀 durable 事实到 MEMORY.md),无需主动调用 memory_note / memory_note_user;读取全部记忆用 memory_read 工具(不要手动 glob/read 记忆文件)。"
207
- : "\n\n[记忆公民指令] 你拥有跨 session 的 Markdown 记忆。以下场景【必须】主动调用 memory_note(项目级约定)或 memory_note_user(跨项目个人偏好)落档,不要依赖轮次结束的自动兜底——它只做原始文本截断,无法代替你的高质量总结:\n" +
208
- "① 完成任务 / 工作并产出结果(写脚本、统计、分析、修复、交付)→ 记任务做了什么 + 关键结果(路径/命令/数字)\n" +
209
- "② 修复 bug / 定位根因(记现象 + 根因 + 绕过/修复手法,防复发)\n" +
210
- "③ 验证 build/test/CI 通过(记命令与结论)\n" +
211
- "④ 完成里程碑 / 关键决策 / 变更约定(记决策与理由)\n" +
212
- "⑤ 用户表达的偏好、约束、durable 事实(记原话要点)\n" +
213
- "判定标准:这条信息「下个 session 的我」还需要吗?不需要(闲聊、一次性操作、显而易见)就不记。格式:一句话结论开头 + 关键细节(命令/路径/数字),不写流水账。";
247
+ : source().memoryMode === "hybrid"
248
+ ? HYBRID_PROACTIVE
249
+ : "\n\n[记忆公民指令] 你拥有跨 session 的 Markdown 记忆。以下场景【必须】主动调用 memory_note(项目级约定)或 memory_note_user(跨项目个人偏好)落档,不要依赖轮次结束的自动兜底——它只做原始文本截断,无法代替你的高质量总结:\n" +
250
+ "① 完成任务 / 工作并产出结果(写脚本、统计、分析、修复、交付)→ 记任务做了什么 + 关键结果(路径/命令/数字)\n" +
251
+ "② 修复 bug / 定位根因(记现象 + 根因 + 绕过/修复手法,防复发)\n" +
252
+ "③ 验证 build/test/CI 通过(记命令与结论)\n" +
253
+ "④ 完成里程碑 / 关键决策 / 变更约定(记决策与理由)\n" +
254
+ "⑤ 用户表达的偏好、约束、durable 事实(记原话要点)\n" +
255
+ "判定标准:这条信息「下个 session 的我」还需要吗?不需要(闲聊、一次性操作、显而易见)就不记。格式:一句话结论开头 + 关键细节(命令/路径/数字),不写流水账。";
214
256
  const intro = bridged
215
257
  ? "你拥有持久化、人类可直接编辑的 Markdown 记忆文件。当前项目已存在 WorkBuddy/CodeBuddy 项目记忆目录,本插件直接读写这些目录(不再单独创建 .deepseek-harness/memory/)。" +
216
258
  "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取全部记忆用 memory_read 工具(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
@@ -223,6 +265,8 @@ export function apply(ctx, config) {
223
265
  ? "\n\n[plan 模式] 当前处于 plan 模式,不要调用 memory_note / memory_note_user 写入记忆,也不要请求删除记忆(读取记忆用 memory_read,仍可用)。"
224
266
  : "";
225
267
  const introFull = intro + planNote;
268
+ // v1.4.1:blocks 为空(已注入过)时只返回 intro 指令;返回值永非空串,
269
+ // 避免 harness 对空 section 的处理歧义。
226
270
  return blocks.length ? [introFull, ...blocks].join("\n\n") : introFull;
227
271
  },
228
272
  });
@@ -263,7 +307,18 @@ export function apply(ctx, config) {
263
307
  }
264
308
  }
265
309
 
266
- // 写门控:命中闸门才写(受 summarize 总开关,两种模式共用)。
310
+ // v1.6.0 hybrid:记忆子代理每轮必跑(定案——不走闸门;plan 模式在上方已拦)。
311
+ // 失败不降级 writeLightEntry(v1.6.0 定案:无格式原文会破坏日志章节化结构)——
312
+ // 断点不推进,下一次 turn/end 子代理自动补蒸。
313
+ if (cfg.memoryMode === "hybrid") {
314
+ if (!cfg.summarize) return; // 总闸门(dirs.length 已在上方保证非空)
315
+ void hybrid
316
+ .runMemorySubagent({ ctx, getConfig: () => source(), paths, records, state, session: state.activeSession, dirs, isError: effectiveIsError })
317
+ .catch(() => {});
318
+ return;
319
+ }
320
+
321
+ // 写门控:命中闸门才写(受 summarize 总开关,插件/smart 模式共用)。
267
322
  if (!cfg.summarize || !baseGateOpen) return;
268
323
 
269
324
  if (cfg.memoryMode === "smart") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-palace",
3
- "version": "1.4.1",
3
+ "version": "1.6.0",
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",
@@ -30,24 +30,24 @@
30
30
  "client": {
31
31
  "platform": "web",
32
32
  "inject": [
33
- "@deepseek-ai/dsh-client-runtime",
34
33
  "@deepseek-ai/dsh-client-ui-settings",
34
+ "@deepseek-ai/dsh-client-ui-renderer",
35
35
  "@deepseek-ai/dsh-client-locale",
36
36
  "@deepseek-ai/dsh-client-connection",
37
- "@deepseek-ai/dsh-api-remotes"
37
+ "@deepseek-ai/dsh-api-remotes",
38
+ "@deepseek-ai/dsh-api-session-controller"
38
39
  ]
39
40
  }
40
41
  },
41
42
  "peerDependencies": {
42
43
  "@deepseek-ai/cordis": "*",
43
- "@deepseek-ai/dsh-fs": "^0.1.0-rc.6",
44
44
  "@deepseek-ai/dsh-llm": "*",
45
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
46
- "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
47
- "@deepseek-ai/schemastery": "^3.18.1"
45
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2",
46
+ "@deepseek-ai/dsh-tools": "^0.1.2-alpha.2",
47
+ "@deepseek-ai/schemastery": "^3.18.2"
48
48
  },
49
49
  "devDependencies": {
50
- "@deepseek-ai/dsh-settings": "^0.1.0-rc.6"
50
+ "@deepseek-ai/dsh-settings": "^0.1.2-alpha.2"
51
51
  },
52
52
  "scripts": {
53
53
  "build": "node scripts/build.mjs",