dsh-memory-palace 1.2.1 → 1.3.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.
package/README.md CHANGED
@@ -1,10 +1,9 @@
1
- # dsh-memory-palace
1
+ # dsh-memory-palace <img src="assets/memory-icon.svg" width="36" height="36" alt="dsh-memory-palace" />
2
+ ---
2
3
 
3
4
  把 WorkBuddy 的文件式记忆系统移植进 [DeepSeek Harness](https://www.deepseek.com/harness/) —— 为 Harness 提供**跨会话持久化、人类可直接编辑的 Markdown 记忆**。
4
5
 
5
- [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
-
7
- 记忆就是普通文本文件,用记事本就能改。AI 每轮对话把它读进上下文,对话结束后把新内容追加进日志——不依赖任何私有格式、不落 JSON、不锁数据。
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
8
7
 
9
8
  ## 特性
10
9
 
@@ -80,7 +79,7 @@ turn/end ──► 轻量兜底闸门
80
79
 
81
80
  ```bash
82
81
  dsh plugin --profile web add github:lovezi0/dsh-memory-palace
83
- # 锁定版本:dsh plugin --profile web add github:lovezi0/dsh-memory-palace#v1.2.0
82
+ # 锁定版本:dsh plugin --profile web add github:lovezi0/dsh-memory-palace#v1.3.0
84
83
  ```
85
84
 
86
85
  方式二:clone 后本地安装(开发 / 修改源码场景)
@@ -99,7 +98,7 @@ dsh plugin --profile web add . # 装入 web profile(profile 名按你的实
99
98
  # 直接由 dsh 从 npm 拉取并装入(本机若已配镜像会自动走镜像)
100
99
  dsh plugin --profile web add dsh-memory-palace
101
100
  # 锁定版本:
102
- dsh plugin --profile web add dsh-memory-palace@1.2.0
101
+ dsh plugin --profile web add dsh-memory-palace@1.3.0
103
102
 
104
103
  # 或先手动用 npm 安装(显式指定镜像),再装入:
105
104
  npm install dsh-memory-palace --registry=https://registry.npmmirror.com/
@@ -156,8 +155,16 @@ dsh plugin --profile web remove dsh-memory-palace
156
155
 
157
156
  ## 版本历史
158
157
 
158
+ - **1.3.0**
159
+ - 🔥新增计划模式禁止写入记忆
160
+ - 💪设置-记忆控件样式优化 *使用dsh原生样式*
161
+ - 💪清理重复patch信息
162
+ - 🔥增加调试模式 *默认关闭,仅用于智能模式蒸馏失败输出调试信息*
163
+ - **1.2.3**
164
+ - **1.2.2**
165
+ - 🐛修复智能模式自定义摘要模型不生效的问题
159
166
  - **1.2.1**
160
- - 💪package.json 补 `repository` 字段,关联 npm 包 ↔ GitHub 仓库(dshmarket 自动关联)
167
+ - 💪发布npm
161
168
  - **1.2.0**
162
169
  - 🔥会话标题栏【记忆】按钮
163
170
  - 🔥手动蒸馏会话与项目记忆
@@ -177,10 +184,6 @@ dsh plugin --profile web remove dsh-memory-palace
177
184
  - 🔥新增删除记忆工具
178
185
  - **outdated(0.x)** — 双层 Markdown 记忆读写 / 设置页集成等 0.x 历史,见 [CHANGELOG.md](./CHANGELOG.md)
179
186
 
180
- ## 废弃方案
181
-
182
- 插件侧 LLM 自动摘要(`ctx.llm.stream()` 路线)曾在 v0.7.1 因 4 个契约/时机坑废弃;**v1.1.0 已重新论证并以「智能模式」可选形态复活**——经 `session.events` / `deriveEventMessage` / `requestHeader()?.config` 根除旧坑,默认仍为插件模式。见 [废弃方案:为什么不用 LLM 自动摘要](./ABANDONED-LLM-SUMMARY.md)。
183
-
184
187
  ## 参考与致谢
185
188
 
186
189
  本插件开发深度参考了以下两个开源项目(本包实现为各自机制的简化落地,不含其完整功能):
package/cordis.patch.yml CHANGED
@@ -1,17 +1,9 @@
1
1
  # dsh-memory-palace bundle patch: 一个 insert 注册本插件。
2
2
  # insert.name 必须是【包名】(pnpm add 后由 profile 的 node_modules 解析到 lib/index.js);
3
3
  # insert.id 必须与入口导出的 cordis `name`('memory-palace')一致。
4
+ # 注:包不再自带 config 块——值与原 Config schema 默认一致,cordis 按 schema 填充默认,
5
+ # 不传 config 不会 undefined。如需在 profile 层注入覆盖配置,走自己 profile 的
6
+ # cordis.patch.yml(`- id: memory-palace` + `config:`,id-targeted,非 insert)。
4
7
  - insert:
5
8
  - id: memory-palace
6
9
  name: 'dsh-memory-palace'
7
- config:
8
- enabled: true
9
- userMemoryPath: '~/.deepseek-harness/MEMORY.md'
10
- workspaceMemoryDir: '.deepseek-harness/memory'
11
- dailyLogRetentionDays: 30
12
- userBudgetChars: 4000
13
- workspaceBudgetChars: 3000
14
- bridgeBuddyMemory: true
15
- buddyWorkspaceMemoryDirs:
16
- - '.workbuddy/memory'
17
- - '.codebuddy/memory'
package/lib/client.js CHANGED
@@ -62,7 +62,12 @@ window.__ModuleLoader__.load({
62
62
  distilling: "蒸馏中…",
63
63
  notifySessionDone: "会话记忆蒸馏完成",
64
64
  notifyProjectDone: "项目记忆蒸馏完成",
65
- notifyFail: "记忆蒸馏失败"
65
+ notifyFail: "记忆蒸馏失败",
66
+ distillDebugLogLabel: "蒸馏调试日志",
67
+ distillDebugLogHint: "开启后向 dsh 服务端控制台(stderr)输出蒸馏 LLM 调用诊断,仅排障用,平时关闭。",
68
+ dev: "开发",
69
+ distillTimeoutLabel: "蒸馏超时(秒)",
70
+ distillTimeoutHint: "单次蒸馏 LLM 调用的超时上限(秒);超时视为失败并降级。覆盖智能模式摘要与手动蒸馏,默认 60 秒。"
66
71
  };
67
72
 
68
73
  const en = {
@@ -117,12 +122,17 @@ window.__ModuleLoader__.load({
117
122
  distilling: "Distilling…",
118
123
  notifySessionDone: "Session memory distilled",
119
124
  notifyProjectDone: "Project memory distilled",
120
- notifyFail: "Memory distillation failed"
125
+ notifyFail: "Memory distillation failed",
126
+ distillDebugLogLabel: "Distill debug log",
127
+ distillDebugLogHint: "When on, prints distill LLM call diagnostics to the dsh server console (stderr). For troubleshooting only; keep off normally.",
128
+ dev: "Developer",
129
+ distillTimeoutLabel: "Distill timeout (s)",
130
+ distillTimeoutHint: "Timeout (in seconds) for a single distill LLM call; on timeout it fails and degrades. Covers both smart-mode summary and manual distillation. Default 60s."
121
131
  };
122
132
 
123
133
 
124
- const FIELD_NUMERIC = new Set(["dailyLogRetentionDays", "userBudgetChars", "workspaceBudgetChars"]);
125
- const FIELD_BOOL = new Set(["bridgeBuddyMemory", "summarize", "autoCaptureErrors"]);
134
+ const FIELD_NUMERIC = new Set(["dailyLogRetentionDays", "userBudgetChars", "workspaceBudgetChars", "summaryTimeoutMs"]);
135
+ const FIELD_BOOL = new Set(["bridgeBuddyMemory", "summarize", "autoCaptureErrors", "distillDebugLog"]);
126
136
 
127
137
  function projectDraft(snap) {
128
138
  const v = snap && snap.value ? snap.value : {};
@@ -136,6 +146,8 @@ window.__ModuleLoader__.load({
136
146
  workspaceBudgetChars: v.workspaceBudgetChars != null ? String(v.workspaceBudgetChars) : "3000",
137
147
  summarize: v.summarize === false ? "false" : "true",
138
148
  autoCaptureErrors: v.autoCaptureErrors === false ? "false" : "true",
149
+ distillDebugLog: v.distillDebugLog === false ? "false" : "true",
150
+ summaryTimeoutMs: v.summaryTimeoutMs != null ? String(Math.round((Number(v.summaryTimeoutMs) || 0) / 1000)) : "60",
139
151
  memoryMode: v.memoryMode || "plugin",
140
152
  summaryModel: v.summaryModel || ""
141
153
  };
@@ -220,7 +232,8 @@ window.__ModuleLoader__.load({
220
232
  const opts = [];
221
233
  for (const g of groups || []) {
222
234
  for (const m of (g && g.models) || []) {
223
- opts.push({ value: g.id + "/" + m.id, label: g.name + " / " + m.name });
235
+ // m.id 已经包含 provider 前缀(如 nvidia/nemotron-3-ultra-550b-a55b),直接用,避免重复拼接
236
+ opts.push({ value: m.id, label: g.name + " / " + m.name });
224
237
  }
225
238
  }
226
239
  if (alive) setModelOptions(opts);
@@ -265,7 +278,10 @@ window.__ModuleLoader__.load({
265
278
  if (FIELD_BOOL.has(field)) {
266
279
  section[field] = text === "true";
267
280
  } else if (FIELD_NUMERIC.has(field)) {
268
- if (text !== "") section[field] = Number(text);
281
+ if (text !== "") {
282
+ const n = Number(text);
283
+ section[field] = field === "summaryTimeoutMs" ? n * 1000 : n;
284
+ }
269
285
  } else if (field === "buddyWorkspaceMemoryDirs") {
270
286
  section[field] = text.split(",").map((s) => s.trim()).filter(Boolean);
271
287
  } else {
@@ -305,6 +321,7 @@ window.__ModuleLoader__.load({
305
321
  type: "checkbox",
306
322
  checked: draft[field] === "true",
307
323
  disabled: disabled || disabledOverride,
324
+ style: checkboxStyle,
308
325
  onChange: (e) => edit(field, e.target.checked ? "true" : "false")
309
326
  })
310
327
  ]);
@@ -332,6 +349,23 @@ window.__ModuleLoader__.load({
332
349
  fontSize: "13px",
333
350
  lineHeight: "1.5"
334
351
  };
352
+ const selectStyle = {
353
+ ...inputStyle,
354
+ height: "34px",
355
+ appearance: "none",
356
+ WebkitAppearance: "none",
357
+ backgroundImage: "url(data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12' fill='none'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' stroke='%2381858C' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E)",
358
+ backgroundPosition: "right 12px center",
359
+ backgroundRepeat: "no-repeat",
360
+ backgroundSize: "12px 12px",
361
+ paddingRight: "32px"
362
+ };
363
+ const checkboxStyle = {
364
+ width: "15px",
365
+ height: "15px",
366
+ margin: "0",
367
+ accentColor: "var(--dsw-alias-brand-primary, #4b5bff)"
368
+ };
335
369
  const toggleStyle = {
336
370
  display: "flex",
337
371
  alignItems: "center",
@@ -453,6 +487,7 @@ window.__ModuleLoader__.load({
453
487
  type: "checkbox",
454
488
  checked: draft.bridgeBuddyMemory === "true",
455
489
  disabled,
490
+ style: checkboxStyle,
456
491
  onChange: (e) => edit("bridgeBuddyMemory", e.target.checked ? "true" : "false")
457
492
  })
458
493
  ]),
@@ -470,7 +505,7 @@ window.__ModuleLoader__.load({
470
505
  value: draft.memoryMode === "smart" ? "smart" : "plugin",
471
506
  disabled,
472
507
  onChange: (e) => edit("memoryMode", e.target.value),
473
- style: { ...inputStyle, height: "34px" }
508
+ style: selectStyle
474
509
  }, [
475
510
  h("option", { value: "plugin" }, "插件模式"),
476
511
  h("option", { value: "smart" }, "智能模式")
@@ -478,8 +513,11 @@ window.__ModuleLoader__.load({
478
513
  draft.memoryMode === "smart"
479
514
  ? row(t("summaryModelLabel"), t("summaryModelHint"),
480
515
  h("select", {
481
- value: draft.summaryModel || "",
516
+ // 已存储值不在下拉选项内(如旧版双前缀脏数据)→ 归零为「复用当前会话模型」,
517
+ // 避免 select 显示悬空值、保存时又把脏值写回 settings.yaml。
518
+ value: modelOptions.some((o) => o.value === draft.summaryModel) ? draft.summaryModel : "",
482
519
  disabled,
520
+ style: selectStyle,
483
521
  onChange: (e) => edit("summaryModel", e.target.value)
484
522
  }, [
485
523
  h("option", { value: "" }, "复用当前会话模型"),
@@ -497,6 +535,11 @@ window.__ModuleLoader__.load({
497
535
  row(t("retentionLabel"), t("retentionHint"), h("input", num("dailyLogRetentionDays")), "dailyLogRetentionDays"),
498
536
  row(t("userBudgetLabel"), t("userBudgetHint"), h("input", num("userBudgetChars")), "userBudgetChars"),
499
537
  row(t("wsBudgetLabel"), t("wsBudgetHint"), h("input", num("workspaceBudgetChars")), "workspaceBudgetChars")
538
+ ]),
539
+ h("div", { style: cardStyle }, [
540
+ h("p", { style: groupTitleStyle }, t("dev")),
541
+ row(t("distillTimeoutLabel"), t("distillTimeoutHint"), h("input", num("summaryTimeoutMs")), "summaryTimeoutMs"),
542
+ toggle(t("distillDebugLogLabel"), t("distillDebugLogHint"), "distillDebugLog")
500
543
  ])
501
544
  ]);
502
545
  }
package/lib/distill.mjs CHANGED
@@ -13,6 +13,49 @@ import { appendLineDedup } from "./common/records.mjs";
13
13
  export function createDistill({ ctx, getConfig, paths, records, state }) {
14
14
  const cfg = () => getConfig();
15
15
 
16
+ // 调试日志(v1.3.0):dbgFail=失败/跳过留痕(无条件输出,不受开关控制,保证排障可观测);
17
+ // dbg=受 distillDebugLog 开关控制的详单(模型解析/请求参数/流进度计数/错误详情)。
18
+ // 隐私红线:只打计数/字符数/元数据/错误 message+stack 首行,绝不打印对话或 MEMORY.md 文本。
19
+ const dbgFail = (why, extra) => console.error(`[memory-palace] distill skip: ${why}`, extra ? JSON.stringify(extra) : "");
20
+ const dbg = (why, extra) => { if (!cfg().distillDebugLog) return; console.error(`[memory-palace][debug] ${why}`, extra !== undefined ? JSON.stringify(extra) : ""); };
21
+
22
+ // 模型解析(v1.2.3 修复):summaryModel 存的是注册表 models[].id 原样(client 下拉 value = m.id),
23
+ // pi-ai getModel(provider, id) 按 model.id 全等匹配(pi-ai models.js: getModels(provider).find(m => m.id === id)),
24
+ // 所以 model 参数必须【原样直传】summaryModel——绝不能 split 拆段。v1.2.2 之前拆段把
25
+ // `nvidia/nemotron-3-ultra-550b-a55b` 变成裸 id `nemotron-3-ultra-550b-a55b`,全等匹配失败抛 UNKNOWN_MODEL。
26
+ // provider 解析优先级:
27
+ // 1) 注册表反查(最稳):遍历 ctx.llm.listProviders(),对每个 provider 调 listModels,
28
+ // 找 m.id === summaryModel 全等命中 → 返回 { provider: m.provider, model: m.id }。
29
+ // 带前缀 id(nvidia/nemotron-3-ultra-550b-a55b)与裸 id(mimo-v2.5 属 xiaomi)都能一次命中。
30
+ // 2) 首段兜底:listProviders 不可用(测试 mock 等)时,取 summaryModel 首段当 provider(openai/gpt-4o 格式)。
31
+ // summaryModel 为空 → 会话 requestHeader config 兜底(复用当前会话 provider/model,原样直传)。
32
+ async function findModelInRegistry(modelId) {
33
+ try {
34
+ const providers = typeof ctx.llm?.listProviders === "function" ? ctx.llm.listProviders() : [];
35
+ for (const p of providers || []) {
36
+ try {
37
+ const models = await ctx.llm.listModels(p.id);
38
+ const m = (models || []).find((x) => x.id === modelId);
39
+ if (m) return { provider: m.provider, model: m.id };
40
+ } catch { /* 该 provider 无模型列表或不可用,跳过 */ }
41
+ }
42
+ } catch { /* listProviders 不可用,跳过反查 */ }
43
+ return null;
44
+ }
45
+ async function resolveModel(summaryModel, session) {
46
+ const sm = (summaryModel || "").trim();
47
+ if (sm) {
48
+ const hit = await findModelInRegistry(sm);
49
+ if (hit) { dbg("resolveModel: registry hit", { provider: hit.provider, model: hit.model }); return hit; }
50
+ const provider = sm.split("/")[0].trim();
51
+ if (provider && sm.includes("/")) { dbg("resolveModel: prefix fallback", { provider, model: sm }); return { provider, model: sm }; }
52
+ }
53
+ const conf = session?.requestHeader?.()?.config;
54
+ if (conf && conf.provider && conf.model) { dbg("resolveModel: session fallback", { provider: conf.provider, model: conf.model }); return { provider: conf.provider, model: conf.model }; }
55
+ dbgFail("resolveModel: no model resolved", { summaryModel: sm, hasHeader: !!(conf && conf.provider) });
56
+ return null;
57
+ }
58
+
16
59
  // 项目蒸馏乐观锁:按目标 MEMORY.md 绝对路径防并发重复蒸馏。
17
60
  const distillLocks = new Set();
18
61
 
@@ -20,46 +63,31 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
20
63
  // 自动智能模式(summarizeTurn,增量断点)与按钮「蒸馏会话」(全量 fromSeq=0)共用本核心。
21
64
  // 返回 { ok, summary, durableCount };失败 { ok:false }(调用方各自决定降级策略)。
22
65
  async function distillSessionCore(session, dirs, fromSeq) {
23
- const dbg = (why, extra) => console.error(`[memory-palace] distill skip: ${why}`, extra ?? "");
24
66
  if (!session) {
25
- dbg("no session");
67
+ dbgFail("no session");
26
68
  return { ok: false };
27
69
  }
28
70
  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) });
71
+ // 1. 模型解析:summaryModel 注册表反查(带前缀/裸 id 均可)> 复用当前会话模型;皆缺 → 降级。
72
+ const resolved = await resolveModel(c.summaryModel, session);
73
+ if (!resolved) {
74
+ dbgFail("no model", { summaryModel: c.summaryModel, header: JSON.stringify(session.requestHeader?.()?.config) });
49
75
  return { ok: false };
50
76
  }
77
+ const { provider, model } = resolved;
51
78
  // 2. 输入:取 seq >= fromSeq 的 surface 事件,投影成模型视角 Message[]。
52
79
  const SURFACE = new Set(["user/message", "assistant/message", "tool/result"]);
53
80
  const newEvents = (session.events || []).filter(
54
81
  (e) => e.seq >= fromSeq && SURFACE.has(e.type),
55
82
  );
83
+ dbg("session core entry", { sessionId: session.id, fromSeq, eventsLen: newEvents.length });
56
84
  const hist = [];
57
85
  for (const e of newEvents) {
58
86
  const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
59
87
  if (m) hist.push(m);
60
88
  }
61
89
  if (!hist.length) {
62
- dbg("no events in range", { fromSeq, eventsLen: (session.events || []).length, newEvents: newEvents.length, hasDerive: !!session.deriveEventMessage });
90
+ dbgFail("no events in range", { fromSeq, eventsLen: (session.events || []).length, newEvents: newEvents.length, hasDerive: !!session.deriveEventMessage });
63
91
  return { ok: false };
64
92
  }
65
93
  // 3. LLM 调用:借鉴 dsh-sideband 的 summarizeJob(src/summarizer.ts)——
@@ -70,6 +98,9 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
70
98
  // (JSON.parse 失败自然回落全文/降级,不会产生坏数据)。
71
99
  const timeoutMs = c.summaryTimeoutMs || 60000;
72
100
  const signal = AbortSignal.timeout(timeoutMs);
101
+ const t0 = Date.now();
102
+ let gotFirstChunk = false, tFirstChunk = null, chunks = 0, deltaChars = 0;
103
+ dbg("session request", { provider, model, messages: hist.length, maxTokens: 800, timeoutMs });
73
104
  const res = ctx.llm.stream({
74
105
  provider,
75
106
  model,
@@ -78,23 +109,28 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
78
109
  maxTokens: 800,
79
110
  signal,
80
111
  });
81
- const asm = new BlockAssembler();
82
- try {
83
- for await (const ch of res) {
84
- signal.throwIfAborted();
85
- asm.push(ch);
86
- }
112
+ const asm = new BlockAssembler();
113
+ try {
114
+ for await (const ch of res) {
115
+ signal.throwIfAborted();
116
+ chunks++;
117
+ deltaChars += (ch.text?.length || 0);
118
+ if (!gotFirstChunk) { gotFirstChunk = true; tFirstChunk = Date.now() - t0; dbg("session first chunk", { ms: tFirstChunk }); }
119
+ asm.push(ch);
120
+ }
87
121
  } catch (e) {
88
- dbg("stream aborted/timeout/error", { message: e?.message ?? "", aborted: signal.aborted, reason: signal.reason?.message ?? "" });
122
+ dbgFail("stream aborted/timeout/error", { message: e?.message ?? "", aborted: signal.aborted, reason: signal.reason?.message ?? "" });
89
123
  return { ok: false };
90
124
  }
91
125
  const finish = asm.finish;
126
+ dbg("session finish", { kind: finish?.kind });
127
+ dbg("session stream done", { chunks, deltaChars, firstChunkMs: tFirstChunk });
92
128
  if (finish && (finish.kind === "error" || finish.kind === "aborted")) {
93
- dbg("llm finish", { kind: finish.kind, message: finish.failure?.message ?? "" });
129
+ dbgFail("llm finish", { kind: finish.kind, message: finish.failure?.message ?? "" });
94
130
  return { ok: false };
95
131
  }
96
132
  if (finish && finish.kind === "max-tokens") {
97
- dbg("llm finish max-tokens (attempting partial JSON)", {});
133
+ console.error("[memory-palace] distill warn: llm finish max-tokens (attempting partial JSON)", JSON.stringify({}));
98
134
  }
99
135
  const text = asm
100
136
  .blocks()
@@ -103,15 +139,17 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
103
139
  .join("")
104
140
  .trim();
105
141
  if (!text) {
106
- dbg("empty llm text");
142
+ dbgFail("empty llm text");
107
143
  return { ok: false };
108
144
  }
109
145
  // 4. 解析 JSON:{summary, durable:[{scope,fact}]};解析失败回退全文当 summary。
110
146
  let summary = text;
111
147
  let durable = [];
148
+ let jsonOk = false;
112
149
  try {
113
150
  const parsed = JSON.parse(text);
114
151
  if (parsed && typeof parsed === "object") {
152
+ jsonOk = true;
115
153
  if (typeof parsed.summary === "string") summary = parsed.summary;
116
154
  if (Array.isArray(parsed.durable)) durable = parsed.durable;
117
155
  }
@@ -139,6 +177,8 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
139
177
  }
140
178
  }
141
179
  }
180
+ dbg("session parsed", { jsonOk, summaryChars: summary.length, durableCount: durable.length });
181
+ dbg("session write done", { dirs: dirs.length, durableWritten: durable.length });
142
182
  return { ok: true, summary, durableCount: durable.length };
143
183
  }
144
184
 
@@ -166,34 +206,25 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
166
206
  const memFile = paths.memoryFileOf(primary, cwd);
167
207
  if (!memFile) return { ok: false, message: "无法解析项目 MEMORY.md 路径。" };
168
208
  const memShort = toHomeShort(memFile);
209
+ dbg("project entry", { cwd, memFile: toHomeShort(memFile) });
169
210
 
170
211
  const raw = readMdSync(memFile);
171
212
  if (!raw) return { ok: false, message: `项目记忆为空(${memShort}),无需蒸馏。` };
213
+ dbg("project loaded", { memFile: toHomeShort(memFile), rawChars: raw.length });
172
214
 
173
215
  if (distillLocks.has(memFile)) return { ok: false, message: "蒸馏正在进行中,请稍候再试。" };
174
216
  distillLocks.add(memFile);
175
217
  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) {
218
+ const t0 = Date.now();
219
+ let gotFirstChunk = false, tFirstChunk = null, chunks = 0, deltaChars = 0;
220
+ let provider = null, model = null;
221
+ // 模型解析:与 distillSessionCore 同序(summaryModel 注册表反查 > 会话 requestHeader)。
222
+ const resolved = await resolveModel(c.summaryModel, session);
223
+ if (!resolved) {
195
224
  return { ok: false, message: "无法确定蒸馏模型(summaryModel 未配置且会话无模型信息)。" };
196
225
  }
226
+ ({ provider, model } = resolved);
227
+ dbg("project request", { provider, model, inputChars: raw.length, maxTokens: 4000, timeoutMs: c.summaryTimeoutMs || 60000 });
197
228
 
198
229
  // LLM 蒸馏:system = 固化 DISTILL_PROMPT,user = MEMORY.md 全文。
199
230
  const timeoutMs = c.summaryTimeoutMs || 60000;
@@ -214,19 +245,25 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
214
245
  const asm = new BlockAssembler();
215
246
  for await (const ch of res) {
216
247
  signal.throwIfAborted();
248
+ chunks++;
249
+ deltaChars += (ch.text?.length || 0);
250
+ if (!gotFirstChunk) { gotFirstChunk = true; tFirstChunk = Date.now() - t0; dbg("project first chunk", { ms: tFirstChunk }); }
217
251
  asm.push(ch);
218
252
  }
219
253
  const finish = asm.finish;
220
254
  if (finish && (finish.kind === "error" || finish.kind === "aborted")) {
221
- return { ok: false, message: `蒸馏失败(LLM ${finish.kind}):${finish.failure?.message ?? ""}` };
255
+ const kindZh = finish.kind === "aborted" ? "被中断" : "出错";
256
+ dbgFail("project finish failed", { kind: finish.kind, message: finish.failure?.message ?? "" });
257
+ return { ok: false, message: `蒸馏${kindZh}(LLM ${finish.kind}):${finish.failure?.message ?? ""}` };
222
258
  }
259
+ dbg("project finish", { kind: finish?.kind });
223
260
  const text = asm
224
261
  .blocks()
225
262
  .filter((b) => b.type === "text")
226
263
  .map((b) => b.text ?? "")
227
264
  .join("")
228
265
  .trim();
229
- if (!text) return { ok: false, message: "蒸馏结果为空,未覆盖任何文件。" };
266
+ if (!text) { dbgFail("project empty result", { chunks, deltaChars }); return { ok: false, message: "蒸馏结果为空,未覆盖任何文件。" }; }
230
267
 
231
268
  // 写 cover(与 MEMORY.md 同目录,同文件系统保证 rename 原子)。
232
269
  const coverFile = join(dirname(memFile), "memory-cover.md");
@@ -248,12 +285,21 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
248
285
  await rename(backup, memFile).catch(() => {});
249
286
  throw e;
250
287
  }
288
+ dbg("project write done", { coverChars: text.length, backup: toHomeShort(backup) });
251
289
  return {
252
290
  ok: true,
253
291
  message: `蒸馏成功:${memShort} 已更新(原 ${raw.length} 字符 → ${text.length} 字符),备份 ${toHomeShort(backup)}。`,
254
292
  };
255
293
  } catch (e) {
256
- return { ok: false, message: `蒸馏失败:${e?.message || String(e)}` };
294
+ const elapsedS = Math.round((Date.now() - t0) / 1000);
295
+ dbgFail("project distill timeout/error", { elapsed: Date.now() - t0, chunks, deltaChars, gotFirstChunk, message: e?.message ?? String(e), stack: (e?.stack || "").split("\n")[0] });
296
+ let msg;
297
+ if (gotFirstChunk) {
298
+ msg = `蒸馏中途超时:模型「${model}」已在 ${tFirstChunk}ms 返回首个字符、累计 ${deltaChars} 字符,但在 ${elapsedS}s 内未结束(疑似输出中断或已被服务端截断)。`;
299
+ } else {
300
+ msg = `蒸馏超时:模型「${model}」在 ${elapsedS}s 内未返回任何内容,疑似未响应或限流。`;
301
+ }
302
+ return { ok: false, message: msg };
257
303
  } finally {
258
304
  distillLocks.delete(memFile);
259
305
  }
package/lib/index.js CHANGED
@@ -52,7 +52,8 @@ export const Config = Schema.object({
52
52
  // ---- v1.1.0:双记忆模式(插件模式=现状;智能模式=LLM 智能会话摘要),设置页切换,互斥,切换需重启 dsh ----
53
53
  memoryMode: Schema.union(["plugin", "smart"]).default("plugin").description("记忆模式:plugin=记忆公民指令+轮次轻量+错误捕获;smart=LLM 智能会话摘要(summary→每日日志 + durable→MEMORY.md,带 [smart] 标记)。切换需重启 dsh 生效。"),
54
54
  summaryModel: Schema.string().default("").description("智能模式的摘要模型,留空=复用当前会话 provider/model;也可填 provider/model(如 deepseek/deepseek-chat)固定廉价模型省 token。"),
55
- summaryTimeoutMs: Schema.number().default(60000).description("智能模式单次摘要调用的超时(毫秒),超时视为失败并降级轻量条目;默认 60s。"),
55
+ summaryTimeoutMs: Schema.number().default(60000).description("蒸馏 LLM 调用的超时(毫秒),超时视为失败并降级;覆盖智能模式摘要与手动蒸馏两条链路;默认 60000(60s)。"),
56
+ distillDebugLog: Schema.boolean().default(false).description("调试开关:向 dsh 服务端 stderr 输出蒸馏 LLM 调用诊断日志(模型解析、请求参数、流进度计数、错误详情)。仅排障用,平时关闭;开启后也不打印任何对话内容或记忆文本。"),
56
57
  });
57
58
 
58
59
  export const inject = ["systemPrompt", "tools", "llm", "webServer", "webRuntime"];
@@ -103,6 +104,7 @@ export function apply(ctx, config) {
103
104
  settleTimer: null, // debounce 计时器
104
105
  lastSummarizedSeq: -1, // 智能模式增量摘要断点(session 事件 seq)
105
106
  summarySessionId: null,
107
+ planModeActive: false, // 计划模式:禁写记忆(硬编码默认,无开关;仅经 session/event 的 plan/mode 翻转;未装 dsh-plan-mode 永不触发=不拦截)
106
108
  };
107
109
  const TURN_BUFFER_MAX = 30;
108
110
  // v1.1.3:字符上限提升到 30k(长工具型 request 的工具结果动辄数千字符,6k 上限会触发
@@ -131,6 +133,11 @@ export function apply(ctx, config) {
131
133
  state.lastSummarizedSeq = session.firstLiveSeq;
132
134
  }
133
135
  const type = event?.type;
136
+ // 计划模式:禁写记忆(硬编码默认行为,无开关;不依赖 dsh-plan-mode 服务,未装则永不触发=安全降级=不拦截)
137
+ if (type === "plan/mode") {
138
+ state.planModeActive = !!(event?.data?.active);
139
+ return;
140
+ }
134
141
  if (type === "user/message" || type === "assistant/message" || type === "tool/result") {
135
142
  const text = extractText(event);
136
143
  if (text) {
@@ -204,13 +211,20 @@ export function apply(ctx, config) {
204
211
  : "你拥有持久化、人类可直接编辑的 Markdown 记忆文件(位于 ~/.deepseek-harness/MEMORY.md,以及各项目的 .deepseek-harness/MEMORY.md(长期记忆)与 .deepseek-harness/memory/(每日日志))。" +
205
212
  "写入记忆:项目级约定用 memory_note 工具,跨项目个人偏好用 memory_note_user 工具;读取全部记忆用 memory_read 工具(不要手动 glob/read 记忆文件)。用它保持跨 session 一致性;看不到的内容不要编造。" +
206
213
  antiMangle + proactive;
207
- return blocks.length ? [intro, ...blocks].join("\n\n") : intro;
214
+ // 计划模式禁写提示(硬编码默认行为,无开关):无论记忆模式都追加,让 agent 自觉不写(网关物理兜底仍生效)
215
+ const planNote = state.planModeActive
216
+ ? "\n\n[plan 模式] 当前处于 plan 模式,不要调用 memory_note / memory_note_user 写入记忆,也不要请求删除记忆(读取记忆用 memory_read,仍可用)。"
217
+ : "";
218
+ const introFull = intro + planNote;
219
+ return blocks.length ? [introFull, ...blocks].join("\n\n") : introFull;
208
220
  },
209
221
  });
210
222
 
211
223
  // ---------- 结算一个完整 request:基于累积缓冲做「错误捕获 + 轻量兜底记录(闸门)」,不调 LLM ----------
212
224
  // 主路径是 agent 按记忆公民指令主动调 memory_note;此处只兜底保证"实质轮次不丢"。
213
225
  async function _settle(capturedTurn, isError, errMsg) {
226
+ // 计划模式禁写(硬编码默认,无开关):自动写路径全拦截(智能模式/轻量兜底/错误捕获同源跳过)
227
+ if (state.planModeActive) return;
214
228
  const cfg = source();
215
229
  if (!cfg.enabled) return;
216
230
  const dirs = paths.writeDirs();
package/lib/tools.mjs CHANGED
@@ -360,8 +360,15 @@ export function registerTools({ ctx, getConfig, paths, records, state }) {
360
360
  // (即删除用户记忆时你看到的「沙箱授权弹窗」)——比两阶段的 AI 级 confirm:true 更可靠:
361
361
  // 即便模型误传 confirm:true,没有真人点击弹窗允许也绝不会真正删除。
362
362
  // 预览(confirm 省略或为 false)直接放行、不弹窗,由 execute 返回候选供 AI 转述、供用户选择删哪几条。
363
+ // 计划模式禁写(硬编码默认,无开关):三个写工具统一 deny(连预览/确认都拦),仅禁写不禁读。
364
+ const WRITE_TOOLS = new Set(["memory_note", "memory_note_user", "memory_delete"]);
363
365
  ctx.on("tools/pre-execute", async (exec, next) => {
364
- if (exec?.name !== "memory_delete") return next();
366
+ const name = exec?.name;
367
+ // plan 模式禁写:三个写工具一律 deny,连预览/确认都拦
368
+ if (state.planModeActive && WRITE_TOOLS.has(name)) {
369
+ return { kind: "deny", reason: "plan 模式下禁止写入记忆" };
370
+ }
371
+ if (name !== "memory_delete") return next();
365
372
  const a = (exec.arguments && typeof exec.arguments === "object") ? exec.arguments : {};
366
373
  const confirm = a.confirm === true;
367
374
  if (!confirm) return next(); // 预览阶段:只查不删,无需弹窗
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-palace",
3
- "version": "1.2.1",
3
+ "version": "1.3.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",