dsh-memory-palace 1.8.0-alpha.2 → 1.8.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
@@ -2,7 +2,7 @@
2
2
 
3
3
  把 WorkBuddy 的文件式记忆系统移植进 [DeepSeek Harness](https://www.deepseek.com/harness/) —— 为 Harness 提供**跨会话持久化、人类可直接编辑的 Markdown 记忆**。
4
4
 
5
- [![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) [![npm](https://img.shields.io/npm/v/dsh-memory-palace.svg?label=npm&labelColor=000000&color=ff4b01)](https://www.npmjs.com/package/dsh-memory-palace) [![DeepSeek Harness:0.1.7-rc.2](https://img.shields.io/badge/DeepSeek%20Harness-0.1.7--rc.2-success.svg?labelColor=4D6BFE)](https://github.com/deepseek-ai/deepseek-harness) [![Desktop: supported](https://img.shields.io/badge/Desktop-supported-success.svg?labelColor=4D6BFE)](#安装)
5
+ [![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) [![npm](https://img.shields.io/npm/v/dsh-memory-palace.svg?label=npm&labelColor=000000&color=ff4b01)](https://www.npmjs.com/package/dsh-memory-palace) [![DeepSeek Harness:0.2.0-rc.1](https://img.shields.io/badge/DeepSeek%20Harness-0.2.0--rc.1-success.svg?labelColor=4D6BFE)](https://github.com/deepseek-ai/deepseek-harness) [![Desktop: supported](https://img.shields.io/badge/Desktop-supported-success.svg?labelColor=4D6BFE)](#安装)
6
6
 
7
7
  ## 特性
8
8
 
@@ -79,7 +79,7 @@
79
79
 
80
80
  ```bash
81
81
  dsh plugin --profile web add github:lovezi0/dsh-memory-palace
82
- # 锁定版本:dsh plugin --profile web add github:lovezi0/dsh-memory-palace#v1.7.2
82
+ # 锁定版本:dsh plugin --profile web add github:lovezi0/dsh-memory-palace#v1.8.0
83
83
  ```
84
84
 
85
85
  方式二:clone 后本地安装(开发 / 修改源码场景)
@@ -124,6 +124,11 @@ dsh plugin --profile web remove dsh-memory-palace
124
124
  ## 版本历史
125
125
 
126
126
  - **1.8.0**
127
+ - dsh peer [0.1.7 ... 0.2.0]
128
+ - npm publish
129
+ - **1.8.0-alpha.4**
130
+ - 💥移除 stderr log 输出
131
+ - 💪新增不同 profiles 场景下 log文件 *详情见 [DEVELOPMENT.md](./DEVELOPMENT.md#日志落盘)*
127
132
  - **1.8.0-alpha.2**
128
133
  - 🐛修复多会话并发导致写侧串台 @sunligh91
129
134
  - 🐛删除会话分桶后遗留的三个全局状态死字段
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
+ }
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 {
@@ -52,19 +52,29 @@ async function resolveModel(cfg, ctx, session) {
52
52
  }
53
53
 
54
54
  // 单次流式调用(带 tools)。finish.kind 为 error/aborted 时抛出由上层降级;tool-calls/stop 正常返回。
55
+ // v1.8.0-alpha.4:附带 LLM 流统计(字段与 distill.mjs 的 streamOnce 对齐)供 dbg("stream turn") 落盘。
56
+ // 起因:子代理链路此前看不到任何 LLM 行为——耗时只能靠相邻两条日志的时间戳手动相减,
57
+ // 无法判断慢在网关排队还是模型生成。
55
58
  async function streamTurn(ctx, params, timeoutMs) {
56
59
  const signal = AbortSignal.timeout(timeoutMs);
60
+ const t0 = Date.now();
61
+ let chunks = 0;
62
+ let deltaChars = 0;
63
+ let tFirstChunk = null;
57
64
  const res = ctx.llm.stream({ ...params, signal });
58
65
  const asm = new BlockAssembler();
59
66
  for await (const ch of res) {
60
67
  signal.throwIfAborted();
68
+ chunks++;
69
+ deltaChars += ch.text?.length || 0;
70
+ if (tFirstChunk === null) tFirstChunk = Date.now() - t0;
61
71
  asm.push(ch);
62
72
  }
63
73
  const finish = asm.finish;
64
74
  if (finish.kind === "error" || finish.kind === "aborted") {
65
75
  throw new Error(finish.failure?.message || finish.kind);
66
76
  }
67
- return { finish, asm };
77
+ return { finish, asm, stats: { chunks, deltaChars, firstChunkMs: tFirstChunk, elapsedMs: Date.now() - t0 } };
68
78
  }
69
79
 
70
80
  // 把本轮 surface 事件投影为消息数组(seq >= fromSeq;与 distillSessionCore 同源逻辑)。
@@ -236,17 +246,21 @@ function applyLogOps(logFiles, dateStr, ops) {
236
246
  // 记忆子 agent 主入口。每轮必跑(定案:不走闸门);plan 模式拦截由调用方(index.mjs _settle)负责。
237
247
  // 返回 { ok, mode }:mode = "noop"(无重点收尾)| "written"(有落盘)| "no-tool-support";
238
248
  // ok=false 表示整体失败(不推进断点、下一次 turn/end 自动补蒸;不降级为任何轻量写入)。
239
- export async function runMemorySubagent({ ctx, getConfig, paths, state, session, dirs, isError, getSeq, setSeq }) {
249
+ export async function runMemorySubagent({ ctx, getConfig, paths, state, session, dirs, isError, getSeq, setSeq, logger }) {
240
250
  const cfg = getConfig();
241
251
  // 增量断点按会话取用:调用方(index.mjs)注入 getSeq/setSeq(按 sessionId 分桶)。
242
252
  // 未注入时回落全局 state.lastSummarizedSeq,保持旧行为与向后兼容。
243
253
  const readSeq = typeof getSeq === "function" ? getSeq : () => state.lastSummarizedSeq;
244
254
  const writeSeq = typeof setSeq === "function" ? setSeq : (v) => { state.lastSummarizedSeq = v; };
245
- // 调试日志(v1.6.0):与 distill.mjs 同款门控。dbgFail=失败/跳过留痕(无条件输出,排障可观测);
246
- // dbg=受 distillDebugLog 开关控制的详单(模型解析/请求参数/流进度/ops 结果计数)。
247
- // 隐私红线:只打计数/字符数/元数据/错误 message,绝不打印对话或日志正文文本。
248
- const dbgFail = (why, extra) => console.error(`[memory-palace] subagent warn: ${why}`, extra ? JSON.stringify(extra) : "");
249
- const dbg = (why, extra) => { if (!cfg.distillDebugLog) return; console.error(`[memory-palace][debug] subagent ${why}`, extra !== undefined ? JSON.stringify(extra) : ""); };
255
+ // 日志(v1.8.0-alpha.4):原 stderr 输出改为经统一 logger 落盘
256
+ // (`<profileDir>/.memory-palace/logs/<sessionId>/{info,debug}.log`,见 common/logger.mjs)。
257
+ // dbgFail=失败/跳过留痕(warn 级 → info.log);dbg=详单(debug 级 → debug.log)。
258
+ // 两者统一受 distillDebugLog 门控:关闭时(默认)本插件零输出(既不写终端也不写文件)。
259
+ // 隐私红线:只打计数/字符数/错误 message 等元数据,绝不打印对话或日志正文文本。
260
+ // logger 未注入时(如直接调本函数的单测)降级为 no-op,绝不让日志拖垮主流程。
261
+ const logSink = typeof logger?.for === "function" ? logger.for(session?.id, "subagent") : null;
262
+ const dbgFail = logSink ? logSink.dbgFail : () => {};
263
+ const dbg = logSink ? logSink.dbg : () => {};
250
264
  if (!cfg.enabled || !session || !dirs.length) {
251
265
  dbgFail("disabled or no session/dirs", { enabled: !!cfg.enabled, hasSession: !!session, dirs: dirs.length });
252
266
  return { ok: false, mode: "disabled" };
@@ -346,21 +360,30 @@ export async function runMemorySubagent({ ctx, getConfig, paths, state, session,
346
360
  let unknownHits = 0;
347
361
  let retries = 0;
348
362
  const unknownNames = new Set();
363
+ // v1.8.0-alpha.4:随返回值带上统计量,供调用方(index.mjs _settle)写终态台账行。
364
+ // 这些量原本算完即弃,却是排障刚需(本轮跑没跑、成没成、写了几条、误调几次)。
365
+ const stats = (round) => ({
366
+ round, appliedWrites, toolAttempts, unknownHits, retries, unknownNames: [...unknownNames],
367
+ });
349
368
  for (let round = 0; round < MAX_ROUNDS; round++) {
350
- const { finish, asm } = await streamTurn(ctx, { provider, model, system, messages, tools }, timeoutMs);
369
+ const { finish, asm, stats: llmStats } = await streamTurn(ctx, { provider, model, system, messages, tools }, timeoutMs);
370
+ // LLM 流统计(debug 级 → debug.log):每轮一次,直接看得到耗时与流进度。
371
+ // ⚠️ 必须解构成 `llmStats` 而非 `stats`:本循环体内已有 `const stats = (round) => …`(结算统计量
372
+ // 函数),同名解构会把它遮蔽,导致后面的 `stats(round)` 抛 "stats is not a function"。
373
+ dbg("stream turn", { round, ...llmStats });
351
374
  if (finish.kind === "stop") {
352
375
  // v1.6.3 stop 三态判定(修复:误调未知工具后模型直接收尾 → 日志丢失且断点被无脑推进)。
353
376
  // ① 有落盘 → 正常完成,推进断点。
354
377
  if (appliedWrites > 0) {
355
378
  writeSeq(session.seq);
356
379
  dbg("done", { round, mode: "written", appliedWrites });
357
- return { ok: true, mode: "written" };
380
+ return { ok: true, mode: "written", stats: stats(round) };
358
381
  }
359
382
  // ② 从未调用任何工具 → 模型判定本轮无实质内容(noop),推进断点。
360
383
  if (toolAttempts === 0) {
361
384
  writeSeq(session.seq);
362
385
  dbg("done", { round, mode: "noop" });
363
- return { ok: true, mode: "noop" };
386
+ return { ok: true, mode: "noop", stats: stats(round) };
364
387
  }
365
388
  // ③ 命中过未知工具且零写入 → 回告工具清单 + 催促重试(预算 MAX_RETRY 次)。
366
389
  if (unknownHits > 0 && retries < MAX_RETRY) {
@@ -379,13 +402,13 @@ export async function runMemorySubagent({ ctx, getConfig, paths, state, session,
379
402
  }
380
403
  // ④ 重试预算耗尽 / 已知工具用错后自主放弃 → 零写入,不推进断点(下一轮 turn/end 补蒸)。
381
404
  dbgFail("no write applied", { round, toolAttempts, unknownHits, retries });
382
- return { ok: false, mode: "no-write" };
405
+ return { ok: false, mode: "no-write", stats: stats(round) };
383
406
  }
384
407
  if (finish.kind !== "tool-calls") {
385
408
  // max-tokens 等异常收尾:返回失败,断点不推进——下一次 turn/end 子代理自动补蒸。
386
409
  // v1.6.0 定案:不降级为轻量原文写入(无格式原文会破坏日志章节化结构),宁缺勿滥。
387
410
  dbgFail(`finish ${finish.kind}`, { round });
388
- return { ok: false, mode: `finish-${finish.kind}` };
411
+ return { ok: false, mode: `finish-${finish.kind}`, stats: stats(round) };
389
412
  }
390
413
  messages.push(asm.message({ kind: "model", provider, model }));
391
414
  const calls = asm.blocks().filter((b) => b.type === "tool-call");
@@ -411,7 +434,7 @@ export async function runMemorySubagent({ ctx, getConfig, paths, state, session,
411
434
  }
412
435
  }
413
436
  dbgFail("max-rounds exceeded", { rounds: MAX_ROUNDS });
414
- return { ok: false, mode: "max-rounds" };
437
+ return { ok: false, mode: "max-rounds", stats: stats(MAX_ROUNDS) };
415
438
  } catch (e) {
416
439
  if (e?.name === "TimeoutError" || /timeout|abort/i.test(String(e?.message))) {
417
440
  dbgFail("timeout", { timeoutMs });
package/lib/index.js CHANGED
@@ -30,6 +30,7 @@ import Schema from "@deepseek-ai/schemastery";
30
30
  // settingsNamespace,设置节注册改用 SettingsProvider 实例方法 installSection(经 ctx.inject(["settings"]))。
31
31
  import { extractText, extractToolErrorText } from "./common/text.mjs";
32
32
  import { createPaths } from "./common/paths.mjs";
33
+ import { createLogger } from "./common/logger.mjs";
33
34
  import { planModeOf } from "./common/planmode.mjs";
34
35
  import { createRecords } from "./common/records.mjs";
35
36
  import { createDistill } from "./distill.mjs";
@@ -79,9 +80,9 @@ export const Config = Schema.object({
79
80
  // `silentPresets`(会话预设级静默)决定,不再有独立的写入闸门。
80
81
  summaryModel: Schema.string().default("").description("记忆插件当前使用的模型(手动「蒸馏会话」/「蒸馏项目记忆」与记忆子代理共用)。留空=复用当前会话 provider/model;也可填 provider/model(如 deepseek/deepseek-chat)固定廉价模型省 token。").volatile(),
81
82
  summaryTimeoutMs: Schema.number().default(60000).description("蒸馏 LLM 调用的超时(毫秒),超时视为失败并降级;覆盖手动「蒸馏会话」/「蒸馏项目记忆」与记忆子代理三条链路;默认 60000(60s)。").volatile(),
82
- distillDebugLog: Schema.boolean().default(false).description("调试开关:向 dsh 服务端 stderr 输出蒸馏 LLM 调用诊断。distillLogLevel=info 时仅输出元数据(模型解析/请求参数/流进度/错误详情,不含文本);distillLogLevel=debug 会额外打印 LLM 原始响应文本(分隔符包裹),仅限受信本地排障开启。").volatile(),
83
- // ---- v1.4.1:蒸馏 stderr 日志级别(平铺键,不进 UI;默认 info;distillDebugLog=true 时生效) ----
84
- distillLogLevel: Schema.string().default("info").description("蒸馏 stderr 日志级别:info=仅元数据诊断(默认,不打印 LLM 原始响应);debug=额外打印 LLM 原始响应文本(分隔符包裹),仅限受信本地排障开启。无需 UI 配置,经 profile 的 cordis.patch.yml 在 memory-palace 条目 config 下设置 distillLogLevel 键(dsh 0.1.7 起 settings.yaml 已被宿主移除、导入 profile patch)。").volatile(),
83
+ distillDebugLog: Schema.boolean().default(false).description("调试开关:开启后把本插件**全部诊断日志落盘**(v1.8.0-alpha.4 起不再输出 stderr)。落点 = `<profile 目录>/.memory-palace/logs/<会话 id>/`(如 `$DSH_HOME/profiles/web/.memory-palace/logs/<sid>/`),按级别分文件:`info.log` 收失败/跳过留痕与子代理终态台账,`debug.log` 收详单(`distillLogLevel=debug` 时另含 LLM 原始响应)。关闭(默认)时零输出。单文件上限 1 MB、不自动清理。仅排障用").volatile(),
84
+ // ---- v1.4.1:蒸馏日志级别(平铺键,不进 UI;默认 info;distillDebugLog=true 时生效) ----
85
+ distillLogLevel: Schema.string().default("info").description("日志级别:`info`=只落盘元数据诊断(默认,不含 LLM 原始响应);`debug`=额外把 LLM 原始响应文本落盘到 `debug.log`(分隔符包裹),仅限受信本地排障开启。无需 UI 配置,经 profile 的 cordis.patch.yml 在 memory-palace 条目 config 下设置 distillLogLevel 键(dsh 0.1.7 起 settings.yaml 已被宿主移除、导入 profile patch)。").volatile(),
85
86
  // ---- v1.4.0:手动「蒸馏会话」最终输出软预算(v1.4.1 起语义变更:prompt 软约束,思考不受限;实际硬上限由模型自身 maxTokens 决定) ----
86
87
  summaryMaxTokens: Schema.number().default(2000).description("手动「蒸馏会话」LLM 的最终输出软预算(prompt 约束,思考不受限;实际硬上限由模型自身 maxTokens 决定)。默认 2000;可调大以容纳更多 durable 事实。").volatile(),
87
88
  projectMaxTokens: Schema.number().default(8000).description("手动「蒸馏项目记忆」LLM 的最终输出软预算(prompt 约束,思考不受限;实际硬上限由模型自身 maxTokens 决定)。默认 8000。").volatile(),
@@ -248,7 +249,11 @@ export function apply(ctx, config) {
248
249
  // ---- 装配:路径解析 / 记录读写 / 蒸馏 / 工具 / route ----
249
250
  const paths = createPaths(() => source(), () => state.activeCwd);
250
251
  const records = createRecords({ getConfig: () => source(), paths });
251
- const distill = createDistill({ ctx, getConfig: () => source(), paths, records });
252
+ // v1.8.0-alpha.4:统一日志落盘器——原 stderr 输出(含原「无条件」的失败留痕)全部改为落盘,
253
+ // 落点 `<profileDir>/.memory-palace/logs/<sessionId>/{info,debug}.log`,统一受 distillDebugLog 门控。
254
+ // 取径与分级依据见 src/common/logger.mjs 文件头。
255
+ const logger = createLogger({ ctx, getConfig: () => source() });
256
+ const distill = createDistill({ ctx, getConfig: () => source(), paths, records, logger });
252
257
  registerTools({ ctx, getConfig: () => source(), paths, state });
253
258
  registerApi({ ctx, paths, distill, getSettingsFace });
254
259
  // v1.8.0:记忆子代理模块(原 hybrid)。必须【无条件注册】——apply() 同步段执行时 settings 服务
@@ -261,7 +266,7 @@ export function apply(ctx, config) {
261
266
  // v1.7.0 特性1:E 投影通道(记忆正文常驻注入)。与 hybrid 同理,无条件注册——
262
267
  // 其内部读热配置,无副作用(enabled=false 或命中静默预设时 buildProjections 返回空)。
263
268
  // v1.7.2:把 isSessionSilent 透传下去——投影按**会话**判定,故必须知道 agent 所属会话的预设。
264
- registerProjection({ ctx, getConfig: () => source(), paths, isSilent: isSessionSilent });
269
+ registerProjection({ ctx, getConfig: () => source(), paths, isSilent: isSessionSilent, logger });
265
270
 
266
271
  // ---------- v1.7.2:静默会话(enabled=false 或命中静默预设)的工具 schema 隐身 ----------
267
272
  // 背景:`complete: true` 只收缩 sections、**不裁剪 tools**——记忆工具的 schema 仍会经 toolProvider
@@ -309,7 +314,8 @@ export function apply(ctx, config) {
309
314
  } catch (error) {
310
315
  // 绝不冒泡:restrict 抛错(如某工具因异常未注册命中 unknown-tool)时降级为"不隐身",
311
316
  // 由 execute 的 enabled 兜底保证功能仍停用,只是没省掉 schema token。
312
- console.error(`[memory-palace] tool-hide skipped: ${error?.message || String(error)}`);
317
+ // v1.8.0-alpha.4:原 stderr 输出改为落盘(warn 级 → info.log),受 distillDebugLog 门控。
318
+ logger.for(sessionId, "plugin").dbgFail("tool-hide skipped", { message: error?.message || String(error) });
313
319
  }
314
320
  }
315
321
 
@@ -510,10 +516,19 @@ export function apply(ctx, config) {
510
516
  session,
511
517
  dirs,
512
518
  isError: effectiveIsError,
519
+ logger,
513
520
  // 断点按会话分桶:全局单值在并发会话下会被互相重置,导致子代理静默 noop、日志不落盘。
514
521
  getSeq: () => state.seqBySession.get(sid) ?? session?.firstLiveSeq ?? 0,
515
522
  setSeq: (v) => state.seqBySession.set(sid, v),
516
523
  })
524
+ .then((r) => {
525
+ // v1.8.0-alpha.4 终态台账:每轮结算留一行(info 级 → info.log),把子代理结果与其已算好的
526
+ // 统计量落盘。原先这些量算完即弃,导致「本轮跑没跑、成没成、写了几条」无从查证。
527
+ // 写入本身同样受 distillDebugLog 门控(emit 内部判定),失败静默、绝不影响主流程。
528
+ logger.for(sid, "subagent").info(`done ok=${!!r?.ok} mode=${r?.mode ?? "?"}`, {
529
+ ...(r?.stats ?? {}), cwd: cwd ?? "", isError: effectiveIsError,
530
+ });
531
+ })
517
532
  .catch(() => {});
518
533
  }
519
534
 
@@ -152,7 +152,7 @@ export function buildProjections({ getConfig, paths, session, isSilent }) {
152
152
  * 注册 E 投影钩子。
153
153
  * @param {{ ctx: object, getConfig: () => object, paths: object, isSilent?: (session?: object) => boolean }} deps
154
154
  */
155
- export function registerProjection({ ctx, getConfig, paths, isSilent }) {
155
+ export function registerProjection({ ctx, getConfig, paths, isSilent, logger }) {
156
156
  ctx.on("agent/pre-step", async ({ agent, messages, step, signal }, next) => {
157
157
  // 先让瀑布流继续,拿到宿主与其他插件合成后的决策——投影要折叠进它的 messages。
158
158
  const decision = await next();
@@ -178,9 +178,10 @@ export function registerProjection({ ctx, getConfig, paths, isSilent }) {
178
178
  } catch (error) {
179
179
  // 🔴 绝不冒泡:pre-step 抛错会让整个 turn 失败(宿主契约,见文件头)。
180
180
  // 契约失效时表现为"记忆正文不注入",这行 warn 是唯一排查线索。
181
- console.error(
182
- `[memory-palace] projection skipped (step ${step}): ${error?.message || String(error)}`,
183
- );
181
+ // v1.8.0-alpha.4:原 stderr 输出改为落盘(warn 级 → info.log),受 distillDebugLog 门控。
182
+ logger?.for?.(agent?.session?.id, "projection")?.dbgFail(`projection skipped (step ${step})`, {
183
+ message: error?.message || String(error),
184
+ });
184
185
  return decision;
185
186
  }
186
187
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-memory-palace",
3
- "version": "1.8.0-alpha.2",
3
+ "version": "1.8.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",
@@ -42,13 +42,13 @@
42
42
  },
43
43
  "peerDependencies": {
44
44
  "@deepseek-ai/cordis": "*",
45
- "@deepseek-ai/dsh-llm": "^0.1.7-alpha.1",
46
- "@deepseek-ai/dsh-settings": "^0.1.7-alpha.1",
47
- "@deepseek-ai/dsh-tools": "^0.1.7-alpha.1",
45
+ "@deepseek-ai/dsh-llm": "^0.2.0-rc.1 || ^0.1.7-alpha.1",
46
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.1 || ^0.1.7-alpha.1",
47
+ "@deepseek-ai/dsh-tools": "^0.2.0-rc.1 || ^0.1.7-alpha.1",
48
48
  "@deepseek-ai/schemastery": "^3.18.3"
49
49
  },
50
50
  "devDependencies": {
51
- "@deepseek-ai/dsh-settings": "^0.1.7-alpha.1"
51
+ "@deepseek-ai/dsh-settings": "^0.2.0-rc.1"
52
52
  },
53
53
  "scripts": {
54
54
  "build": "node scripts/build.mjs",