dsh-memory-palace 1.8.0-alpha.1 → 1.8.0-alpha.4

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
@@ -124,6 +124,13 @@ dsh plugin --profile web remove dsh-memory-palace
124
124
  ## 版本历史
125
125
 
126
126
  - **1.8.0**
127
+ - **1.8.0-alpha.4**
128
+ - 💥移除 stderr log 输出
129
+ - 💪新增不同 profiles 场景下 log文件 *详情见 [DEVELOPMENT.md](./DEVELOPMENT.md#日志落盘)*
130
+ - **1.8.0-alpha.2**
131
+ - 🐛修复多会话并发导致写侧串台 @sunligh91
132
+ - 🐛删除会话分桶后遗留的三个全局状态死字段
133
+ - 🐛修复plan 模式禁写记忆串台导致其他会话也禁写记忆
127
134
  - **1.8.0-alpha.1**
128
135
  - 💥移除插件模式与智能模式,记忆写入统一为混合模式
129
136
  - 💥配置项 memoryMode / autoCaptureErrors / dailyLogRetentionDays 删除(旧值静默失效)
package/lib/client.js CHANGED
@@ -69,7 +69,7 @@ window.__ModuleLoader__.load({
69
69
  notifyProjectDone: "项目记忆蒸馏完成",
70
70
  notifyFail: "记忆蒸馏失败",
71
71
  distillDebugLogLabel: "蒸馏调试日志",
72
- distillDebugLogHint: "开启后向 dsh 服务端控制台(stderr)输出蒸馏 LLM 调用诊断。info 级别仅输出元数据(不含文本);配合 distillLogLevel=debug 会额外打印 LLM 原始响应(分隔符包裹),仅限受信本地排障。",
72
+ distillDebugLogHint: "开启后把本插件全部诊断日志落盘到 profile 目录下的 .memory-palace/logs/<会话 id>/(不再输出控制台)。info.log 收失败/跳过留痕与子代理终态台账;debug.log 收详单,配合 distillLogLevel=debug 时另含 LLM 原始响应。关闭时零输出,仅排障用。",
73
73
  dev: "开发",
74
74
  devDesc: "蒸馏调试日志与超时",
75
75
  distillTimeoutLabel: "蒸馏超时(秒)",
@@ -143,7 +143,7 @@ window.__ModuleLoader__.load({
143
143
  notifyProjectDone: "Project memory distilled",
144
144
  notifyFail: "Memory distillation failed",
145
145
  distillDebugLogLabel: "Distill debug log",
146
- distillDebugLogHint: "When on, prints distill LLM call diagnostics to the dsh server console (stderr). info level prints metadata only (no text); with distillLogLevel=debug it additionally prints the raw LLM response (wrapped in delimiters), for trusted local troubleshooting only.",
146
+ distillDebugLogHint: "When on, writes all plugin diagnostics to .memory-palace/logs/<session-id>/ under the profile directory (no console output). info.log holds failure/skip traces and the subagent ledger; debug.log holds verbose detail plus the raw LLM response when distillLogLevel=debug. Off means zero output; troubleshooting only.",
147
147
  dev: "Developer",
148
148
  devDesc: "Distill debug log and timeout",
149
149
  distillTimeoutLabel: "Distill timeout (s)",
@@ -0,0 +1,150 @@
1
+ // dsh-memory-palace v1.8.0-alpha.4:统一日志落盘模块。
2
+ //
3
+ // 背景:插件原先所有诊断输出走 console.error → dsh 服务端 stderr。但 Desktop 下 stderr 只进
4
+ // host 进程内存(apps/desktop/src/host-process.ts:204 存末 64KB),仅崩溃时随报告落盘,正常运行
5
+ // 时完全不可见;而插件其余落盘点只有 Markdown 记忆文件 —— 于是子代理的失败/跳过、蒸馏链路的
6
+ // 跳过原因全都无痕可查。
7
+ //
8
+ // 落点(与 dsh 原生 @deepseek-ai/dsh-plugin-manager 同构):
9
+ // <profileContext.dir>/.memory-palace/logs/<session-id>/<level>.log
10
+ // 其中 profileContext.dir = $DSH_HOME/profiles/<profile>。取径依据:宿主 CLI 与 Desktop 都经
11
+ // 同一个 runProfile 启动(apps/cli/src/profile-boot.ts:298 provide、apps/desktop-host/src/index.ts:25
12
+ // runProfile({profile:'desktop'})),故 ctx.get('profileContext').dir 两端都可用。
13
+ //
14
+ // 门控:统一受 distillDebugLog 控制 —— false(默认)时零输出(既不写终端也不写文件)。
15
+ // 分级:按【行级别】分流 —— warn/info → info.log;debug → debug.log。
16
+ //
17
+ // 失败安全:调用方都位于 turn 结算 / pre-step 钩子栈上,抛错会污染整个 turn(agent/pre-step 抛错
18
+ // 毁 turn 是已知宿主契约),故所有 fs 错误一律吞掉;写入经独立 promise 链串行(不与记忆落盘锁
19
+ // 共用 —— 日志 IO 不得拖慢记忆写入,且二者语义无关)。
20
+ import { appendFile, mkdir, stat } from "node:fs/promises";
21
+ import { dirname, join } from "node:path";
22
+
23
+ /** 单文件大小上限(字节);超出后停止追加(不删旧内容)。 */
24
+ const MAX_FILE_BYTES = 1024 * 1024;
25
+ /**
26
+ * 拿不到会话身份时的兜底目录名。
27
+ * ⚠️ 必须与 index.mjs 既有的 `__nosession__` 约定一致(`sid || "__nosession__"`),
28
+ * 否则会出现两套"无身份"目录并存,排查时难以判断哪份是哪条路径落的。
29
+ */
30
+ const NO_SESSION = "__nosession__";
31
+
32
+ /** 本地时间戳(禁用 toISOString:UTC 会跨日错位,见 AGENTS.md §⑤)。 */
33
+ function stamp() {
34
+ const d = new Date();
35
+ const p = (n, w = 2) => String(n).padStart(w, "0");
36
+ return (
37
+ `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ` +
38
+ `${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}.${p(d.getMilliseconds(), 3)}`
39
+ );
40
+ }
41
+
42
+ /** JSON 化额外字段;循环引用等异常一律降级为字符串(日志失败不得影响主流程)。 */
43
+ function safeJson(value) {
44
+ try {
45
+ return JSON.stringify(value);
46
+ } catch {
47
+ return String(value);
48
+ }
49
+ }
50
+
51
+ /**
52
+ * @param {{ ctx: object, getConfig: () => object }} deps
53
+ * @returns {{ for: (sessionId?: string, scope?: string) => object }}
54
+ */
55
+ export function createLogger({ ctx, getConfig }) {
56
+ let chain = Promise.resolve(); // 独立写链(全局串行)
57
+ const capped = new Set(); // 已达大小上限的文件(停止追加)
58
+ const prepared = new Set(); // 已确保存在的目录
59
+
60
+ // 落盘根 = profile 目录 + 私有尾目录。profileContext 缺失(测试 mock / 非 dsh 启动的宿主)时
61
+ // 返回 null → 放弃落盘,绝不猜路径(process.cwd() 在 CLI 下是用户目录;import.meta.url 在
62
+ // 本地软链安装下解析到开发仓库路径,二者都不可靠)。
63
+ function logRoot() {
64
+ const pc = typeof ctx?.get === "function" ? ctx.get("profileContext") : undefined;
65
+ const dir = pc?.dir;
66
+ return typeof dir === "string" && dir ? join(dir, ".memory-palace", "logs") : null;
67
+ }
68
+
69
+ function fileOf(root, sessionId, level) {
70
+ const box = typeof sessionId === "string" && sessionId ? sessionId : NO_SESSION;
71
+ return join(root, box, level === "debug" ? "debug.log" : "info.log");
72
+ }
73
+
74
+ async function put(file, text) {
75
+ if (capped.has(file)) return;
76
+ const dir = dirname(file);
77
+ if (!prepared.has(dir)) {
78
+ // 0700/0600:与原生 plugin-manager 同款收紧(日志含 cwd / sessionId / 路径等本机信息)。
79
+ // Windows 下 mode 不生效但不报错(plugin-manager 同样如此);Linux/macOS 上生效。
80
+ await mkdir(dir, { recursive: true, mode: 0o700 });
81
+ prepared.add(dir);
82
+ }
83
+ const info = await stat(file).catch(() => null);
84
+ if (info !== null && info.size >= MAX_FILE_BYTES) {
85
+ capped.add(file);
86
+ return;
87
+ }
88
+ // mode 仅在文件首次创建时生效(已存在则忽略),故对新老文件都安全。
89
+ await appendFile(file, text, { encoding: "utf8", mode: 0o600 });
90
+ }
91
+
92
+ /** 入链写入(fire-and-forget):调用点同步返回,异常全部吞掉。 */
93
+ function enqueue(file, text) {
94
+ chain = chain.then(() => put(file, text)).catch(() => {});
95
+ }
96
+
97
+ /** 门控 + 取径 + 入链;任一环节不可用即静默返回。 */
98
+ function emit(sessionId, level, scope, why, extra) {
99
+ let cfg = null;
100
+ try {
101
+ cfg = getConfig();
102
+ } catch {
103
+ return;
104
+ }
105
+ if (!cfg?.distillDebugLog) return;
106
+ const root = logRoot();
107
+ if (!root) return;
108
+ const tail = extra === undefined ? "" : ` ${safeJson(extra)}`;
109
+ enqueue(fileOf(root, sessionId, level), `${stamp()} [${level}] ${scope} · ${why}${tail}\n`);
110
+ }
111
+
112
+ return {
113
+ /** 等待已入链的写入全部落盘(测试用;生产侧 fire-and-forget,无需等待)。 */
114
+ flush: () => chain,
115
+ /**
116
+ * 绑定会话与模块名,返回该作用域的日志器。
117
+ * @param {string|undefined} sessionId 会话 id(缺省落 __nosession__/)
118
+ * @param {string} scope 模块名(subagent / distill / projection / plugin)
119
+ */
120
+ for(sessionId, scope) {
121
+ const name = scope || "plugin";
122
+ return {
123
+ /** 失败/跳过留痕(warn 级 → info.log)。 */
124
+ dbgFail: (why, extra) => emit(sessionId, "warn", name, why, extra),
125
+ /** 详单(debug 级 → debug.log)。 */
126
+ dbg: (why, extra) => emit(sessionId, "debug", name, why, extra),
127
+ /** 信息行(info 级 → info.log),如子代理终态台账。 */
128
+ info: (why, extra) => emit(sessionId, "info", name, why, extra),
129
+ /** LLM 原始响应等文本块(debug 级 → debug.log);调用点须已过双门控。 */
130
+ raw(text) {
131
+ let cfg = null;
132
+ try {
133
+ cfg = getConfig();
134
+ } catch {
135
+ return;
136
+ }
137
+ if (!cfg?.distillDebugLog) return;
138
+ const root = logRoot();
139
+ if (!root) return;
140
+ enqueue(
141
+ fileOf(root, sessionId, "debug"),
142
+ `${stamp()} [debug] ${name} · llm raw text begin >>>\n` +
143
+ `--------------------------->\n${text}\n<----------------------------\n` +
144
+ `${stamp()} [debug] ${name} · llm raw text end <<<\n`,
145
+ );
146
+ },
147
+ };
148
+ },
149
+ };
150
+ }
@@ -0,0 +1,32 @@
1
+ // plan 模式判定(按会话)—— 单一判定入口,供 section 注入 / 结算闸门 / 写工具 deny 共用。
2
+ //
3
+ // 宿主事实:plan 模式是**会话级**状态,不是应用级开关 ——
4
+ // - 状态以事件 append 到单个会话:`session.append('plan/mode', { active })`
5
+ // (packages/plan/plan-mode/src/index.ts:434、:450)
6
+ // - plan-mode 插件自身也按 session 保存待提交意图与已记录状态:
7
+ // `this.pendingIntents.set(session, …)` / `this.loggedActive(session)`(同文件 :425、:434)
8
+ // - 状态由该会话的事件流折叠:`if (event.type === 'plan/mode') …`(同文件 :154)
9
+ //
10
+ // 故本插件不得用进程级全局承载它:A 会话进 plan 会波及 B 会话 —— B 的记忆不落盘、
11
+ // B 的 system prompt 被注入 plan 提示、B 的写工具被 deny(回归测试 tests/test-planmode-crosstalk.mjs)。
12
+ //
13
+ // 判定顺序:
14
+ // ① 拿得到会话身份(session.id)→ **只看该会话的记录**:无记录 = 该会话从未进 plan = false。
15
+ // 🔴 此处**不得**回落到全局值——那等于用别的会话的 plan 状态来判定本会话,正是本次修的串台。
16
+ // ② 拿不到会话身份(宿主某些调用形态的 exec 不带 agent.session,如 tools/pre-execute 的载荷)
17
+ // → 回落全局 `state.planModeActive`(= 最近一次 plan/mode 事件的值;fail-safe:偏向拦写,
18
+ // 绝不会写错项目)。
19
+ //
20
+ // ⚠️ 写成**纯函数**而非 state 上的方法:多处单测用裸 mock state 构造
21
+ // `{ planModeActive: … }`(tests/test-session-cwd.mjs、tests/test-load.mjs),
22
+ // 若挂成 `state.isPlanMode()` 这些 mock 会直接 TypeError。纯函数 + 兜底读全局,两条路径都兼容。
23
+ export function planModeOf(state, session) {
24
+ const sid = session?.id;
25
+ if (sid) {
26
+ const map = state?.planModeBySession;
27
+ // 生产路径:有会话身份 + 有分桶表 → 只看该会话(无记录即 false,绝不看全局)。
28
+ // 单测的裸 mock state 没有 planModeBySession → 落到下面的全局兜底,保持旧行为兼容。
29
+ if (map && typeof map.get === "function") return !!map.get(sid);
30
+ }
31
+ return !!state?.planModeActive;
32
+ }
package/lib/distill.mjs CHANGED
@@ -13,14 +13,18 @@ import { runWithRetry, RETRY_CONSTANTS } from "./common/retry.mjs";
13
13
  /**
14
14
  * @param {{ ctx: object, getConfig: () => object, paths: object, records: object }} deps
15
15
  */
16
- export function createDistill({ ctx, getConfig, paths, records }) {
16
+ export function createDistill({ ctx, getConfig, paths, records, logger }) {
17
17
  const cfg = () => getConfig();
18
18
 
19
- // 调试日志(v1.3.0):dbgFail=失败/跳过留痕(无条件输出,不受开关控制,保证排障可观测);
20
- // dbg=受 distillDebugLog 开关控制的详单(模型解析/请求参数/流进度计数/错误详情)。
21
- // 隐私红线:只打计数/字符数/元数据/错误 message+stack 首行,绝不打印对话或 MEMORY.md 文本。
22
- const dbgFail = (why, extra) => console.error(`[memory-palace] distill skip: ${why}`, extra ? JSON.stringify(extra) : "");
23
- const dbg = (why, extra) => { if (!cfg().distillDebugLog) return; console.error(`[memory-palace][debug] ${why}`, extra !== undefined ? JSON.stringify(extra) : ""); };
19
+ // 日志(v1.8.0-alpha.4):原 stderr 输出改为经统一 logger 落盘
20
+ // (`<profileDir>/.memory-palace/logs/<sessionId>/{info,debug}.log`,见 common/logger.mjs)。
21
+ // 按【会话】取作用域日志器——工厂级闭包会让并发会话的日志落到同一目录;logger 未注入时降级为 no-op。
22
+ // dbgFail=失败/跳过留痕(warn 级 → info.log);dbg=详单(debug 级 → debug.log);
23
+ // 两者统一受 distillDebugLog 门控:关闭时(默认)本插件零输出(既不写终端也不写文件)。
24
+ // 隐私红线:只打计数/字符数/错误 message+stack 首行等元数据,绝不打印对话或 MEMORY.md 文本
25
+ // (唯一例外:distillLogLevel=debug 时的 LLM 原始响应,经 raw() 显式落 debug.log)。
26
+ const noop = () => {};
27
+ const logFor = (session) => (typeof logger?.for === "function" ? logger.for(session?.id, "distill") : null);
24
28
 
25
29
  // 模型解析(v1.2.3 修复):summaryModel 存的是注册表 models[].id 原样(client 下拉 value = m.id),
26
30
  // pi-ai getModel(provider, id) 按 model.id 全等匹配(pi-ai models.js: getModels(provider).find(m => m.id === id)),
@@ -46,6 +50,9 @@ export function createDistill({ ctx, getConfig, paths, records }) {
46
50
  return null;
47
51
  }
48
52
  async function resolveModel(summaryModel, session) {
53
+ const log = logFor(session);
54
+ const dbg = log ? log.dbg : noop;
55
+ const dbgFail = log ? log.dbgFail : noop;
49
56
  const sm = (summaryModel || "").trim();
50
57
  if (sm) {
51
58
  const hit = await findModelInRegistry(sm);
@@ -97,6 +104,9 @@ export function createDistill({ ctx, getConfig, paths, records }) {
97
104
  // v1.8.0:唯一调用方 = 按钮「蒸馏会话」(全量 fromSeq=0);原 smart 模式自动摘要链路已删除。
98
105
  // 返回 { ok, summary, durableCount };失败 { ok:false }(调用方各自决定降级策略)。
99
106
  async function distillSessionCore(session, dirs, fromSeq, opts) {
107
+ const log = logFor(session);
108
+ const dbg = log ? log.dbg : noop;
109
+ const dbgFail = log ? log.dbgFail : noop;
100
110
  const allowDelete = !!(opts && opts.allowDelete);
101
111
  // 回喂存量记忆(v1.4.0 特性3):feedbackEnabled 开启即回喂项目级 + 用户级 MEMORY.md 全文
102
112
  // (逐行编号、无截断)。delete 由 allowDelete 决定(手动按钮传 true)。
@@ -185,17 +195,11 @@ export function createDistill({ ctx, getConfig, paths, records }) {
185
195
  const { finish, text, chunks, deltaChars, tFirstChunk } = result;
186
196
  dbg("session finish", { kind: finish?.kind, attempts: result.attempts });
187
197
  dbg("session stream done", { chunks, deltaChars, firstChunkMs: tFirstChunk });
188
- // 本地调试(受 distillDebugLog + distillLogLevel==="debug" 双门控):把 LLM 原始响应 text 原样打到 stderr,
189
- // 并用分隔符整段包裹,便于从 stderr 一眼框住起止、不与其它 dbg 行混淆。
190
- if (cfg().distillDebugLog && cfg().distillLogLevel === "debug") {
191
- console.error("[memory-palace][debug] session llm raw text begin >>>");
192
- console.error("--------------------------->");
193
- console.error(text);
194
- console.error("<----------------------------");
195
- console.error("[memory-palace][debug] session llm raw text end <<<");
196
- }
198
+ // 本地调试(受 distillDebugLog + distillLogLevel==="debug" 双门控):LLM 原始响应写入 debug.log,
199
+ // 用分隔符整段包裹,便于一眼框住起止、不与其它 dbg 行混淆。
200
+ if (cfg().distillLogLevel === "debug") log?.raw(text);
197
201
  if (finish && finish.kind === "max-tokens") {
198
- console.error("[memory-palace] distill warn: llm finish max-tokens (attempting partial JSON)", JSON.stringify({}));
202
+ dbgFail("session llm finish max-tokens (attempting partial JSON)", {});
199
203
  }
200
204
  if (!text) {
201
205
  dbgFail("empty llm text");
@@ -279,6 +283,9 @@ export function createDistill({ ctx, getConfig, paths, records }) {
279
283
  // 流程:读主目标 MEMORY.md → DISTILL_PROMPT 蒸馏 → 写 memory-cover.md(同目录,保证 rename 原子)
280
284
  // → 完整性检查 → 备份 MEMORY.md.{时间戳} → rename 覆盖 → cover 随 rename 消失。
281
285
  async function distillProjectMemory(cwd, session) {
286
+ const log = logFor(session);
287
+ const dbg = log ? log.dbg : noop;
288
+ const dbgFail = log ? log.dbgFail : noop;
282
289
  const c = cfg();
283
290
  if (!c.enabled) return { ok: false, message: "memory-palace 已停用。" };
284
291
  const dirs = paths.writeDirs(cwd);
@@ -338,14 +345,8 @@ export function createDistill({ ctx, getConfig, paths, records }) {
338
345
  return { ok: false, message: `蒸馏${kindZh}(重试 ${e?.attempts || 0} 次后仍失败):${cls.message || ""}` };
339
346
  }
340
347
  const { finish, text, chunks, deltaChars, gotFirstChunk, tFirstChunk } = result;
341
- // 本地调试(受 distillDebugLog + distillLogLevel==="debug" 双门控,与会话蒸馏路径一致):分隔符包裹整段原始响应。
342
- if (cfg().distillDebugLog && cfg().distillLogLevel === "debug") {
343
- console.error("[memory-palace][debug] project llm raw text begin >>>");
344
- console.error("--------------------------->");
345
- console.error(text);
346
- console.error("<----------------------------");
347
- console.error("[memory-palace][debug] project llm raw text end <<<");
348
- }
348
+ // 本地调试(受 distillDebugLog + distillLogLevel==="debug" 双门控,与会话蒸馏路径一致):原始响应写入 debug.log。
349
+ if (cfg().distillLogLevel === "debug") log?.raw(text);
349
350
  if (finish && finish.kind === "max-tokens") {
350
351
  dbg("project finish", { kind: "max-tokens", attempts: result.attempts });
351
352
  } else {