dsh-memory-palace 1.2.2 → 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.2
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.2
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,6 +155,12 @@ 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**
159
164
  - **1.2.2**
160
165
  - 🐛修复智能模式自定义摘要模型不生效的问题
161
166
  - **1.2.1**
@@ -179,10 +184,6 @@ dsh plugin --profile web remove dsh-memory-palace
179
184
  - 🔥新增删除记忆工具
180
185
  - **outdated(0.x)** — 双层 Markdown 记忆读写 / 设置页集成等 0.x 历史,见 [CHANGELOG.md](./CHANGELOG.md)
181
186
 
182
- ## 废弃方案
183
-
184
- 插件侧 LLM 自动摘要(`ctx.llm.stream()` 路线)曾在 v0.7.1 因 4 个契约/时机坑废弃;**v1.1.0 已重新论证并以「智能模式」可选形态复活**——经 `session.events` / `deriveEventMessage` / `requestHeader()?.config` 根除旧坑,默认仍为插件模式。见 [废弃方案:为什么不用 LLM 自动摘要](./ABANDONED-LLM-SUMMARY.md)。
185
-
186
187
  ## 参考与致谢
187
188
 
188
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
  };
@@ -266,7 +278,10 @@ window.__ModuleLoader__.load({
266
278
  if (FIELD_BOOL.has(field)) {
267
279
  section[field] = text === "true";
268
280
  } else if (FIELD_NUMERIC.has(field)) {
269
- if (text !== "") section[field] = Number(text);
281
+ if (text !== "") {
282
+ const n = Number(text);
283
+ section[field] = field === "summaryTimeoutMs" ? n * 1000 : n;
284
+ }
270
285
  } else if (field === "buddyWorkspaceMemoryDirs") {
271
286
  section[field] = text.split(",").map((s) => s.trim()).filter(Boolean);
272
287
  } else {
@@ -306,6 +321,7 @@ window.__ModuleLoader__.load({
306
321
  type: "checkbox",
307
322
  checked: draft[field] === "true",
308
323
  disabled: disabled || disabledOverride,
324
+ style: checkboxStyle,
309
325
  onChange: (e) => edit(field, e.target.checked ? "true" : "false")
310
326
  })
311
327
  ]);
@@ -333,6 +349,23 @@ window.__ModuleLoader__.load({
333
349
  fontSize: "13px",
334
350
  lineHeight: "1.5"
335
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
+ };
336
369
  const toggleStyle = {
337
370
  display: "flex",
338
371
  alignItems: "center",
@@ -454,6 +487,7 @@ window.__ModuleLoader__.load({
454
487
  type: "checkbox",
455
488
  checked: draft.bridgeBuddyMemory === "true",
456
489
  disabled,
490
+ style: checkboxStyle,
457
491
  onChange: (e) => edit("bridgeBuddyMemory", e.target.checked ? "true" : "false")
458
492
  })
459
493
  ]),
@@ -471,7 +505,7 @@ window.__ModuleLoader__.load({
471
505
  value: draft.memoryMode === "smart" ? "smart" : "plugin",
472
506
  disabled,
473
507
  onChange: (e) => edit("memoryMode", e.target.value),
474
- style: { ...inputStyle, height: "34px" }
508
+ style: selectStyle
475
509
  }, [
476
510
  h("option", { value: "plugin" }, "插件模式"),
477
511
  h("option", { value: "smart" }, "智能模式")
@@ -483,6 +517,7 @@ window.__ModuleLoader__.load({
483
517
  // 避免 select 显示悬空值、保存时又把脏值写回 settings.yaml。
484
518
  value: modelOptions.some((o) => o.value === draft.summaryModel) ? draft.summaryModel : "",
485
519
  disabled,
520
+ style: selectStyle,
486
521
  onChange: (e) => edit("summaryModel", e.target.value)
487
522
  }, [
488
523
  h("option", { value: "" }, "复用当前会话模型"),
@@ -500,6 +535,11 @@ window.__ModuleLoader__.load({
500
535
  row(t("retentionLabel"), t("retentionHint"), h("input", num("dailyLogRetentionDays")), "dailyLogRetentionDays"),
501
536
  row(t("userBudgetLabel"), t("userBudgetHint"), h("input", num("userBudgetChars")), "userBudgetChars"),
502
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")
503
543
  ])
504
544
  ]);
505
545
  }
package/lib/distill.mjs CHANGED
@@ -13,22 +13,46 @@ 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
+
16
22
  // 模型解析(v1.2.3 修复):summaryModel 存的是注册表 models[].id 原样(client 下拉 value = m.id),
17
23
  // pi-ai getModel(provider, id) 按 model.id 全等匹配(pi-ai models.js: getModels(provider).find(m => m.id === id)),
18
24
  // 所以 model 参数必须【原样直传】summaryModel——绝不能 split 拆段。v1.2.2 之前拆段把
19
25
  // `nvidia/nemotron-3-ultra-550b-a55b` 变成裸 id `nemotron-3-ultra-550b-a55b`,全等匹配失败抛 UNKNOWN_MODEL。
20
- // provider 取 summaryModel 首段(带前缀 id 的 route key,如 nvidia/nemotron-3-ultra-550b-a55b → nvidia)。
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 格式)。
21
31
  // summaryModel 为空 → 会话 requestHeader config 兜底(复用当前会话 provider/model,原样直传)。
22
- // 边界:裸 id 格式(如 GLM-4.7-Flash,无 provider 前缀)首段取不到正确 provider → 需要
23
- // ctx.llm.listProviders() 反查注册表;当前配置(nvidia 带前缀 / zai 裸 id)用首段即可,裸 id 场景暂不处理。
24
- function resolveModel(summaryModel, session) {
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) {
25
46
  const sm = (summaryModel || "").trim();
26
47
  if (sm) {
48
+ const hit = await findModelInRegistry(sm);
49
+ if (hit) { dbg("resolveModel: registry hit", { provider: hit.provider, model: hit.model }); return hit; }
27
50
  const provider = sm.split("/")[0].trim();
28
- if (provider) return { provider, model: sm };
51
+ if (provider && sm.includes("/")) { dbg("resolveModel: prefix fallback", { provider, model: sm }); return { provider, model: sm }; }
29
52
  }
30
53
  const conf = session?.requestHeader?.()?.config;
31
- if (conf && conf.provider && conf.model) return { provider: conf.provider, model: conf.model };
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) });
32
56
  return null;
33
57
  }
34
58
 
@@ -39,16 +63,15 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
39
63
  // 自动智能模式(summarizeTurn,增量断点)与按钮「蒸馏会话」(全量 fromSeq=0)共用本核心。
40
64
  // 返回 { ok, summary, durableCount };失败 { ok:false }(调用方各自决定降级策略)。
41
65
  async function distillSessionCore(session, dirs, fromSeq) {
42
- const dbg = (why, extra) => console.error(`[memory-palace] distill skip: ${why}`, extra ?? "");
43
66
  if (!session) {
44
- dbg("no session");
67
+ dbgFail("no session");
45
68
  return { ok: false };
46
69
  }
47
70
  const c = cfg();
48
- // 1. 模型解析:summaryModel 原样直传(注册表 id)> 复用当前会话模型;皆缺 → 降级。
49
- const resolved = resolveModel(c.summaryModel, session);
71
+ // 1. 模型解析:summaryModel 注册表反查(带前缀/裸 id 均可)> 复用当前会话模型;皆缺 → 降级。
72
+ const resolved = await resolveModel(c.summaryModel, session);
50
73
  if (!resolved) {
51
- dbg("no model", { summaryModel: c.summaryModel, header: JSON.stringify(session.requestHeader?.()?.config) });
74
+ dbgFail("no model", { summaryModel: c.summaryModel, header: JSON.stringify(session.requestHeader?.()?.config) });
52
75
  return { ok: false };
53
76
  }
54
77
  const { provider, model } = resolved;
@@ -57,13 +80,14 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
57
80
  const newEvents = (session.events || []).filter(
58
81
  (e) => e.seq >= fromSeq && SURFACE.has(e.type),
59
82
  );
83
+ dbg("session core entry", { sessionId: session.id, fromSeq, eventsLen: newEvents.length });
60
84
  const hist = [];
61
85
  for (const e of newEvents) {
62
86
  const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
63
87
  if (m) hist.push(m);
64
88
  }
65
89
  if (!hist.length) {
66
- 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 });
67
91
  return { ok: false };
68
92
  }
69
93
  // 3. LLM 调用:借鉴 dsh-sideband 的 summarizeJob(src/summarizer.ts)——
@@ -74,6 +98,9 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
74
98
  // (JSON.parse 失败自然回落全文/降级,不会产生坏数据)。
75
99
  const timeoutMs = c.summaryTimeoutMs || 60000;
76
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 });
77
104
  const res = ctx.llm.stream({
78
105
  provider,
79
106
  model,
@@ -82,23 +109,28 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
82
109
  maxTokens: 800,
83
110
  signal,
84
111
  });
85
- const asm = new BlockAssembler();
86
- try {
87
- for await (const ch of res) {
88
- signal.throwIfAborted();
89
- asm.push(ch);
90
- }
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
+ }
91
121
  } catch (e) {
92
- 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 ?? "" });
93
123
  return { ok: false };
94
124
  }
95
125
  const finish = asm.finish;
126
+ dbg("session finish", { kind: finish?.kind });
127
+ dbg("session stream done", { chunks, deltaChars, firstChunkMs: tFirstChunk });
96
128
  if (finish && (finish.kind === "error" || finish.kind === "aborted")) {
97
- dbg("llm finish", { kind: finish.kind, message: finish.failure?.message ?? "" });
129
+ dbgFail("llm finish", { kind: finish.kind, message: finish.failure?.message ?? "" });
98
130
  return { ok: false };
99
131
  }
100
132
  if (finish && finish.kind === "max-tokens") {
101
- dbg("llm finish max-tokens (attempting partial JSON)", {});
133
+ console.error("[memory-palace] distill warn: llm finish max-tokens (attempting partial JSON)", JSON.stringify({}));
102
134
  }
103
135
  const text = asm
104
136
  .blocks()
@@ -107,15 +139,17 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
107
139
  .join("")
108
140
  .trim();
109
141
  if (!text) {
110
- dbg("empty llm text");
142
+ dbgFail("empty llm text");
111
143
  return { ok: false };
112
144
  }
113
145
  // 4. 解析 JSON:{summary, durable:[{scope,fact}]};解析失败回退全文当 summary。
114
146
  let summary = text;
115
147
  let durable = [];
148
+ let jsonOk = false;
116
149
  try {
117
150
  const parsed = JSON.parse(text);
118
151
  if (parsed && typeof parsed === "object") {
152
+ jsonOk = true;
119
153
  if (typeof parsed.summary === "string") summary = parsed.summary;
120
154
  if (Array.isArray(parsed.durable)) durable = parsed.durable;
121
155
  }
@@ -143,6 +177,8 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
143
177
  }
144
178
  }
145
179
  }
180
+ dbg("session parsed", { jsonOk, summaryChars: summary.length, durableCount: durable.length });
181
+ dbg("session write done", { dirs: dirs.length, durableWritten: durable.length });
146
182
  return { ok: true, summary, durableCount: durable.length };
147
183
  }
148
184
 
@@ -170,19 +206,25 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
170
206
  const memFile = paths.memoryFileOf(primary, cwd);
171
207
  if (!memFile) return { ok: false, message: "无法解析项目 MEMORY.md 路径。" };
172
208
  const memShort = toHomeShort(memFile);
209
+ dbg("project entry", { cwd, memFile: toHomeShort(memFile) });
173
210
 
174
211
  const raw = readMdSync(memFile);
175
212
  if (!raw) return { ok: false, message: `项目记忆为空(${memShort}),无需蒸馏。` };
213
+ dbg("project loaded", { memFile: toHomeShort(memFile), rawChars: raw.length });
176
214
 
177
215
  if (distillLocks.has(memFile)) return { ok: false, message: "蒸馏正在进行中,请稍候再试。" };
178
216
  distillLocks.add(memFile);
179
217
  try {
180
- // 模型解析:与 distillSessionCore 同序(summaryModel 原样直传 > 会话 requestHeader)。
181
- const resolved = resolveModel(c.summaryModel, session);
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);
182
223
  if (!resolved) {
183
224
  return { ok: false, message: "无法确定蒸馏模型(summaryModel 未配置且会话无模型信息)。" };
184
225
  }
185
- const { provider, model } = resolved;
226
+ ({ provider, model } = resolved);
227
+ dbg("project request", { provider, model, inputChars: raw.length, maxTokens: 4000, timeoutMs: c.summaryTimeoutMs || 60000 });
186
228
 
187
229
  // LLM 蒸馏:system = 固化 DISTILL_PROMPT,user = MEMORY.md 全文。
188
230
  const timeoutMs = c.summaryTimeoutMs || 60000;
@@ -203,19 +245,25 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
203
245
  const asm = new BlockAssembler();
204
246
  for await (const ch of res) {
205
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 }); }
206
251
  asm.push(ch);
207
252
  }
208
253
  const finish = asm.finish;
209
254
  if (finish && (finish.kind === "error" || finish.kind === "aborted")) {
210
- 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 ?? ""}` };
211
258
  }
259
+ dbg("project finish", { kind: finish?.kind });
212
260
  const text = asm
213
261
  .blocks()
214
262
  .filter((b) => b.type === "text")
215
263
  .map((b) => b.text ?? "")
216
264
  .join("")
217
265
  .trim();
218
- if (!text) return { ok: false, message: "蒸馏结果为空,未覆盖任何文件。" };
266
+ if (!text) { dbgFail("project empty result", { chunks, deltaChars }); return { ok: false, message: "蒸馏结果为空,未覆盖任何文件。" }; }
219
267
 
220
268
  // 写 cover(与 MEMORY.md 同目录,同文件系统保证 rename 原子)。
221
269
  const coverFile = join(dirname(memFile), "memory-cover.md");
@@ -237,12 +285,21 @@ export function createDistill({ ctx, getConfig, paths, records, state }) {
237
285
  await rename(backup, memFile).catch(() => {});
238
286
  throw e;
239
287
  }
288
+ dbg("project write done", { coverChars: text.length, backup: toHomeShort(backup) });
240
289
  return {
241
290
  ok: true,
242
291
  message: `蒸馏成功:${memShort} 已更新(原 ${raw.length} 字符 → ${text.length} 字符),备份 ${toHomeShort(backup)}。`,
243
292
  };
244
293
  } catch (e) {
245
- 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 };
246
303
  } finally {
247
304
  distillLocks.delete(memFile);
248
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.2",
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",