dsh-memory-palace 1.4.1 → 1.6.1
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 +181 -166
- package/lib/api.mjs +17 -0
- package/lib/client.js +435 -414
- package/lib/common/prompts.mjs +7 -4
- package/lib/common/records.mjs +7 -2
- package/lib/common/sections.mjs +167 -0
- package/lib/common/text.mjs +51 -5
- package/lib/distill.mjs +6 -3
- package/lib/hybrid/index.mjs +18 -0
- package/lib/hybrid/prompts.mjs +42 -0
- package/lib/hybrid/subagent.mjs +349 -0
- package/lib/hybrid/tools.mjs +327 -0
- package/lib/index.js +356 -308
- package/package.json +56 -56
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
// memory-palace v1.6.0 hybrid 模式:记忆子 agent(turn/end 触发的轻量工具循环)。
|
|
2
|
+
// 职责(定案):每轮必跑,判定+产出一体——无重点纯文本收尾(1 次调用);有重点通过循环内
|
|
3
|
+
// 白名单工具把格式化摘要写入今日工作日志(2-5 次调用)。只操作今日日志,三种操作:
|
|
4
|
+
// 新增章节 / 章节内追加 / 标记删除(~~删除线~~ 墓碑),禁止覆盖整文件与物理删除。
|
|
5
|
+
// 架构依据(v1.6.0 调研):ctx.llm.stream 支持 GenerateOptions.tools,finish.kind ===
|
|
6
|
+
// 'tool-calls' 时用 BlockAssembler.message() 回喂 assistant 消息 + createToolResultMessage
|
|
7
|
+
// 回喂工具结果,自建循环;ctx.subagents 需活 Agent 作父,插件不可用。
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { BlockAssembler, createUserMessage, createToolResultMessage } from "@deepseek-ai/dsh-llm";
|
|
12
|
+
import { todayISO, readMdSync } from "../common/text.mjs";
|
|
13
|
+
import { appendToSectionText, upsertSectionText, createSectionText, markEntryDeletedText, parseSections, locateSection } from "../common/sections.mjs";
|
|
14
|
+
import { HYBRID_PROACTIVE, SUBAGENT_SYSTEM } from "./prompts.mjs";
|
|
15
|
+
|
|
16
|
+
export { HYBRID_PROACTIVE };
|
|
17
|
+
|
|
18
|
+
const MAX_ROUNDS = 6; // 循环轮次上限(含首轮);超限降级
|
|
19
|
+
const SURFACE = new Set(["user/message", "assistant/message", "tool/result"]);
|
|
20
|
+
|
|
21
|
+
// 日志文件写锁(全局 promise 链串行化):子 agent 落盘与手动蒸馏按钮/其他写路径并发防护。
|
|
22
|
+
let logWriteChain = Promise.resolve();
|
|
23
|
+
function withLogLock(file, fn) {
|
|
24
|
+
const run = logWriteChain.then(fn, fn);
|
|
25
|
+
logWriteChain = run.catch(() => {});
|
|
26
|
+
return run;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 模型解析(与 distill.mjs resolveModel 同逻辑,改动须两处同步):summaryModel 注册表反查
|
|
30
|
+
// > 前缀兜底 > 会话 requestHeader 兜底。复制而非导出原因:hybrid 走独立模块定案,不碰 distill。
|
|
31
|
+
async function resolveModel(cfg, ctx, session) {
|
|
32
|
+
const sm = (cfg.summaryModel || "").trim();
|
|
33
|
+
if (sm) {
|
|
34
|
+
try {
|
|
35
|
+
const providers = typeof ctx.llm?.listProviders === "function" ? ctx.llm.listProviders() : [];
|
|
36
|
+
for (const p of providers || []) {
|
|
37
|
+
try {
|
|
38
|
+
const models = await ctx.llm.listModels(p.id);
|
|
39
|
+
const m = (models || []).find((x) => x.id === sm);
|
|
40
|
+
if (m) return { provider: m.provider, model: m.id };
|
|
41
|
+
} catch { /* 该 provider 无模型列表,跳过 */ }
|
|
42
|
+
}
|
|
43
|
+
const provider = sm.split("/")[0].trim();
|
|
44
|
+
if (provider && sm.includes("/")) return { provider, model: sm };
|
|
45
|
+
} catch { /* 注册表不可用,走会话兜底 */ }
|
|
46
|
+
}
|
|
47
|
+
const conf = session?.requestHeader?.()?.config;
|
|
48
|
+
if (conf && conf.provider && conf.model) return { provider: conf.provider, model: conf.model };
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 单次流式调用(带 tools)。finish.kind 为 error/aborted 时抛出由上层降级;tool-calls/stop 正常返回。
|
|
53
|
+
async function streamTurn(ctx, params, timeoutMs) {
|
|
54
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
55
|
+
const res = ctx.llm.stream({ ...params, signal });
|
|
56
|
+
const asm = new BlockAssembler();
|
|
57
|
+
for await (const ch of res) {
|
|
58
|
+
signal.throwIfAborted();
|
|
59
|
+
asm.push(ch);
|
|
60
|
+
}
|
|
61
|
+
const finish = asm.finish;
|
|
62
|
+
if (finish.kind === "error" || finish.kind === "aborted") {
|
|
63
|
+
throw new Error(finish.failure?.message || finish.kind);
|
|
64
|
+
}
|
|
65
|
+
return { finish, asm };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// 把本轮 surface 事件投影为消息数组(seq >= fromSeq;与 distillSessionCore 同源逻辑)。
|
|
69
|
+
function projectTurnMessages(session, fromSeq) {
|
|
70
|
+
const hist = [];
|
|
71
|
+
for (const e of session.events || []) {
|
|
72
|
+
if (e.seq < fromSeq || !SURFACE.has(e.type)) continue;
|
|
73
|
+
const m = session.deriveEventMessage ? session.deriveEventMessage(e) : null;
|
|
74
|
+
if (m) hist.push(m);
|
|
75
|
+
}
|
|
76
|
+
return hist;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 今日日志回喂正文:≤ subagentLogBudget 全文;超出仅章节目录(标题 + 条目数 + 首条),
|
|
80
|
+
// 由子 agent 用 log_read_section 按需拉取(定案)。空日志(跨零点新一天)明确告知。
|
|
81
|
+
function buildLogBlock(dir, budget) {
|
|
82
|
+
const file = join(dir, `${todayISO()}.md`);
|
|
83
|
+
const text = readMdSync(file);
|
|
84
|
+
if (!text) {
|
|
85
|
+
return { file, oversized: false, body: "(今日日志为空——新的一天,直接用 append/new_section 建章节写入即可。)" };
|
|
86
|
+
}
|
|
87
|
+
if (text.length <= budget) {
|
|
88
|
+
return { file, oversized: false, body: text };
|
|
89
|
+
}
|
|
90
|
+
const { sections } = parseSections(text);
|
|
91
|
+
const toc = sections
|
|
92
|
+
.map((s) => {
|
|
93
|
+
const items = s.lines.slice(1).filter((l) => l.trim());
|
|
94
|
+
const first = items[0]?.trim().slice(0, 80) || "";
|
|
95
|
+
return `- ${s.title}(${items.length} 条,首条:${first})`;
|
|
96
|
+
})
|
|
97
|
+
.join("\n");
|
|
98
|
+
return {
|
|
99
|
+
file,
|
|
100
|
+
oversized: true,
|
|
101
|
+
body:
|
|
102
|
+
`(日志过大(${text.length} 字符),以下仅目录;需要某章节完整内容时调用 log_read_section 读取)\n${toc}`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 循环内工具执行:仅两个白名单工具,其余一律报错(模型越权时结果回喂 isError,不中断循环)。
|
|
107
|
+
async function execLoopTool(block, logFiles, cfg) {
|
|
108
|
+
const name = block.name;
|
|
109
|
+
let args = {};
|
|
110
|
+
try {
|
|
111
|
+
args = JSON.parse(block.arguments || "{}");
|
|
112
|
+
} catch {
|
|
113
|
+
return { ok: false, message: "工具参数不是合法 JSON。" };
|
|
114
|
+
}
|
|
115
|
+
try {
|
|
116
|
+
if (name === "log_read_section") {
|
|
117
|
+
// v1.6.0 A 改进:一次读取多个章节(sections 数组),合并返回;兼容旧单数 section 参数。
|
|
118
|
+
const file = logFiles[0];
|
|
119
|
+
const md = readMdSync(file) || "";
|
|
120
|
+
const wants = Array.isArray(args.sections)
|
|
121
|
+
? args.sections.filter((s) => s && typeof s === "string")
|
|
122
|
+
: args.section
|
|
123
|
+
? [args.section]
|
|
124
|
+
: [];
|
|
125
|
+
if (!wants.length) return { ok: false, message: "sections 不能为空(数组,可一次传多个章节标题)。" };
|
|
126
|
+
const parts = [];
|
|
127
|
+
const missing = [];
|
|
128
|
+
for (const s of wants) {
|
|
129
|
+
const loc = locateSection(md, String(s).trim());
|
|
130
|
+
if (!loc) {
|
|
131
|
+
missing.push(String(s).trim());
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
parts.push(loc.lines.slice(loc.start, loc.end).join("\n").trim());
|
|
135
|
+
}
|
|
136
|
+
if (!parts.length) {
|
|
137
|
+
return { ok: false, message: `未找到章节:${missing.join("、")}。可用章节:${parseSections(md).sections.map((s) => s.title).join("、") || "(日志为空)"}` };
|
|
138
|
+
}
|
|
139
|
+
const note = missing.length ? `\n\n(以下章节未找到:${missing.join("、")})` : "";
|
|
140
|
+
return { ok: true, content: parts.join("\n\n---\n\n") + note };
|
|
141
|
+
}
|
|
142
|
+
if (name === "log_write_ops") {
|
|
143
|
+
const ops = Array.isArray(args.ops) ? args.ops : [];
|
|
144
|
+
if (!ops.length) return { ok: false, message: "ops 为空。" };
|
|
145
|
+
return await applyLogOps(logFiles, todayISO(), ops);
|
|
146
|
+
}
|
|
147
|
+
return { ok: false, message: `未知工具 ${name}(仅 log_read_section / log_write_ops 可用)。` };
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return { ok: false, message: `执行失败:${e?.message || String(e)}` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 把 ops 序列应用到每个日志文件(读-改-写,经全局写锁串行化)。op → sections 纯函数:
|
|
154
|
+
// append=upsert(存在追加/不存在新增章节,"不强行匹配")、new_section=已存在拒绝、
|
|
155
|
+
// mark_delete=删除线墓碑(结构行保护)。
|
|
156
|
+
function applyLogOps(logFiles, dateStr, ops) {
|
|
157
|
+
const apply = (md) => {
|
|
158
|
+
let cur = md || "";
|
|
159
|
+
const results = [];
|
|
160
|
+
for (const op of ops) {
|
|
161
|
+
if (!op || typeof op !== "object") {
|
|
162
|
+
results.push({ op: "invalid", ok: false, message: "op 非对象" });
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
const section = String(op.section ?? "").trim();
|
|
166
|
+
if (!section) {
|
|
167
|
+
results.push({ op: op.op, ok: false, message: "section 不能为空" });
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (op.op === "append") {
|
|
171
|
+
const r = upsertSectionText(cur, section, op.entry);
|
|
172
|
+
results.push({ op: "append", section, ok: r.ok, message: r.reason });
|
|
173
|
+
if (r.ok) cur = r.text;
|
|
174
|
+
} else if (op.op === "new_section") {
|
|
175
|
+
const r = createSectionText(cur, section, op.entry);
|
|
176
|
+
results.push({ op: "new_section", section, ok: r.ok, message: r.reason });
|
|
177
|
+
if (r.ok) cur = r.text;
|
|
178
|
+
} else if (op.op === "mark_delete") {
|
|
179
|
+
const r = markEntryDeletedText(cur, section, op.oldText);
|
|
180
|
+
results.push({ op: "mark_delete", section, ok: r.ok, message: r.reason });
|
|
181
|
+
if (r.ok && r.text !== undefined) cur = r.text;
|
|
182
|
+
} else {
|
|
183
|
+
results.push({ op: op.op, ok: false, message: "未知 op(仅 append/new_section/mark_delete)" });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return { cur, results };
|
|
187
|
+
};
|
|
188
|
+
return withLogLock(logFiles[0], async () => {
|
|
189
|
+
const all = [];
|
|
190
|
+
for (const file of logFiles) {
|
|
191
|
+
let md = "";
|
|
192
|
+
try {
|
|
193
|
+
md = readFileSync(file, "utf8");
|
|
194
|
+
} catch { /* 文件尚不存在 */ }
|
|
195
|
+
const { cur, results } = apply(md);
|
|
196
|
+
if (cur !== md) {
|
|
197
|
+
await mkdir(dirname(file), { recursive: true });
|
|
198
|
+
await writeFile(file, cur, "utf8");
|
|
199
|
+
}
|
|
200
|
+
all.push(...results);
|
|
201
|
+
}
|
|
202
|
+
const applied = all.filter((r) => r.ok).length;
|
|
203
|
+
return { ok: true, applied, total: all.length, results: all, date: dateStr };
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// 记忆子 agent 主入口。每轮必跑(定案:不走闸门);plan 模式拦截由调用方(index.mjs _settle)负责。
|
|
208
|
+
// 返回 { ok, mode }:mode = "noop"(无重点收尾)| "written"(有落盘)| "no-tool-support";
|
|
209
|
+
// ok=false 表示整体失败(调用方降级 writeLightEntry,不推进断点)。
|
|
210
|
+
export async function runMemorySubagent({ ctx, getConfig, paths, state, session, dirs, isError }) {
|
|
211
|
+
const cfg = getConfig();
|
|
212
|
+
// 调试日志(v1.6.0):与 distill.mjs 同款门控。dbgFail=失败/跳过留痕(无条件输出,排障可观测);
|
|
213
|
+
// dbg=受 distillDebugLog 开关控制的详单(模型解析/请求参数/流进度/ops 结果计数)。
|
|
214
|
+
// 隐私红线:只打计数/字符数/元数据/错误 message,绝不打印对话或日志正文文本。
|
|
215
|
+
const dbgFail = (why, extra) => console.error(`[memory-palace] subagent skip: ${why}`, extra ? JSON.stringify(extra) : "");
|
|
216
|
+
const dbg = (why, extra) => { if (!cfg.distillDebugLog) return; console.error(`[memory-palace][debug] subagent ${why}`, extra !== undefined ? JSON.stringify(extra) : ""); };
|
|
217
|
+
if (!cfg.enabled || !session || !dirs.length) {
|
|
218
|
+
dbgFail("disabled or no session/dirs", { enabled: !!cfg.enabled, hasSession: !!session, dirs: dirs.length });
|
|
219
|
+
return { ok: false, mode: "disabled" };
|
|
220
|
+
}
|
|
221
|
+
const resolved = await resolveModel(cfg, ctx, session);
|
|
222
|
+
if (!resolved) {
|
|
223
|
+
dbgFail("no model", { summaryModel: cfg.summaryModel });
|
|
224
|
+
return { ok: false, mode: "no-model" };
|
|
225
|
+
}
|
|
226
|
+
const { provider, model } = resolved;
|
|
227
|
+
dbg("entry", { sessionId: session.id, fromSeq: state.lastSummarizedSeq, provider, model });
|
|
228
|
+
|
|
229
|
+
const hist = projectTurnMessages(session, state.lastSummarizedSeq);
|
|
230
|
+
if (!hist.length) {
|
|
231
|
+
dbg("no events in range", { fromSeq: state.lastSummarizedSeq });
|
|
232
|
+
return { ok: true, mode: "noop" };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const log = buildLogBlock(dirs[0], cfg.subagentLogBudget || 20000);
|
|
236
|
+
const logFiles = dirs.map((dir) => join(dir, `${todayISO()}.md`));
|
|
237
|
+
dbg("log feed", { file: log.file, oversized: log.oversized, chars: log.body.length, logFiles: logFiles.length });
|
|
238
|
+
|
|
239
|
+
// 首轮 user 消息:本轮对话已在 hist 中逐条保留,末尾追加日志回喂 + 任务指令。
|
|
240
|
+
const brief = isError ? "\n\n(提示:本轮对话中出现过错误信号,错误现象值得记录。)" : "";
|
|
241
|
+
const kickoff = createUserMessage({
|
|
242
|
+
content: [
|
|
243
|
+
{
|
|
244
|
+
type: "text",
|
|
245
|
+
text:
|
|
246
|
+
`【今日工作日志(${todayISO()})】\n${log.body}\n\n` +
|
|
247
|
+
`请按系统指令处理上面的本轮对话(对话见前文消息):判定是否有实质内容需要写入日志,并执行。${brief}`,
|
|
248
|
+
},
|
|
249
|
+
],
|
|
250
|
+
source: { kind: "plugin", plugin: "memory-palace" },
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const tools = [
|
|
254
|
+
{
|
|
255
|
+
name: "log_read_section",
|
|
256
|
+
description:
|
|
257
|
+
"读取今日工作日志中指定章节的完整内容(含标题行与全部条目)。sections 接受数组,可一次读取多个章节(合并返回,未找到的章节会注明)——需要多个章节时务必在单次调用中全部传入,避免多轮往返。仅在回喂为目录模式(日志过大)时需要调用;全文回喂时无需调用。",
|
|
258
|
+
parameters: {
|
|
259
|
+
type: "object",
|
|
260
|
+
additionalProperties: false,
|
|
261
|
+
properties: {
|
|
262
|
+
sections: {
|
|
263
|
+
type: "array",
|
|
264
|
+
description: "要读取的章节标题列表(不含 `## ` 前缀),可一次传多个。",
|
|
265
|
+
items: { type: "string" },
|
|
266
|
+
},
|
|
267
|
+
},
|
|
268
|
+
required: ["sections"],
|
|
269
|
+
},
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
name: "log_write_ops",
|
|
273
|
+
description:
|
|
274
|
+
"批量写入今日工作日志(只能操作今日日志)。ops 数组,每个元素:\n" +
|
|
275
|
+
'- {"op":"append","section":"章节名","entry":"条目文本(一行,结论先行+关键细节,【不带 - 前缀】——插件会自动补)"}:章节存在则末尾追加,不存在则自动新增章节(不要强行匹配语义不符的章节);\n' +
|
|
276
|
+
'- {"op":"new_section","section":"新章节名","entry":"首条条目(不带 - 前缀)"}:显式新建章节(已存在会拒绝,改用 append);\n' +
|
|
277
|
+
'- {"op":"mark_delete","section":"章节名","oldText":"日志中该条目原文(可带或不带 - 前缀)"}:把重复/过时/被推翻的旧条目标记为删除线墓碑(非物理删除),oldText 逐行匹配,匹配失败会被拒绝;\n' +
|
|
278
|
+
"一条消息可带多个 ops 一次提交;entry 为客观第三人称单行文本(不带列表符),禁止对话体与任何标签。",
|
|
279
|
+
parameters: {
|
|
280
|
+
type: "object",
|
|
281
|
+
additionalProperties: false,
|
|
282
|
+
properties: {
|
|
283
|
+
ops: {
|
|
284
|
+
type: "array",
|
|
285
|
+
description: "日志写操作数组(见 description)。",
|
|
286
|
+
items: {
|
|
287
|
+
type: "object",
|
|
288
|
+
additionalProperties: false,
|
|
289
|
+
properties: {
|
|
290
|
+
op: { type: "string", description: "append | new_section | mark_delete" },
|
|
291
|
+
section: { type: "string", description: "章节标题(不含 ## 前缀)。" },
|
|
292
|
+
entry: { type: "string", description: "append/new_section 的条目文本(一行)。" },
|
|
293
|
+
oldText: { type: "string", description: "mark_delete 的目标条目原文。" },
|
|
294
|
+
},
|
|
295
|
+
required: ["op", "section"],
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
required: ["ops"],
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
];
|
|
303
|
+
|
|
304
|
+
const system = SUBAGENT_SYSTEM();
|
|
305
|
+
const messages = [...hist, kickoff];
|
|
306
|
+
const timeoutMs = cfg.summaryTimeoutMs || 60000;
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
for (let round = 0; round < MAX_ROUNDS; round++) {
|
|
310
|
+
const { finish, asm } = await streamTurn(ctx, { provider, model, system, messages, tools }, timeoutMs);
|
|
311
|
+
if (finish.kind === "stop") {
|
|
312
|
+
state.lastSummarizedSeq = session.seq;
|
|
313
|
+
dbg("done", { round, mode: round === 0 ? "noop" : "written" });
|
|
314
|
+
return { ok: true, mode: round === 0 ? "noop" : "written" };
|
|
315
|
+
}
|
|
316
|
+
if (finish.kind !== "tool-calls") {
|
|
317
|
+
// max-tokens 等异常收尾:返回失败,断点不推进——下一次 turn/end 子代理自动补蒸。
|
|
318
|
+
// v1.6.0 定案:不降级 writeLightEntry(无格式原文会破坏日志章节化结构),宁缺勿滥。
|
|
319
|
+
dbgFail(`finish ${finish.kind}`, { round });
|
|
320
|
+
return { ok: false, mode: `finish-${finish.kind}` };
|
|
321
|
+
}
|
|
322
|
+
messages.push(asm.message({ kind: "model", provider, model }));
|
|
323
|
+
const calls = asm.blocks().filter((b) => b.type === "tool-call");
|
|
324
|
+
dbg("tool-calls round", { round, calls: calls.length, names: calls.map((c) => c.name) });
|
|
325
|
+
for (const call of calls) {
|
|
326
|
+
const result = await execLoopTool(call, logFiles, cfg);
|
|
327
|
+
const body = result.ok && result.content !== undefined ? String(result.content) : JSON.stringify(result);
|
|
328
|
+
messages.push(
|
|
329
|
+
createToolResultMessage({
|
|
330
|
+
callId: call.id,
|
|
331
|
+
content: [{ type: "text", text: body }],
|
|
332
|
+
isError: !result.ok,
|
|
333
|
+
}),
|
|
334
|
+
);
|
|
335
|
+
if (!result.ok) dbgFail(`tool ${call.name} rejected`, { message: result.message, opApplied: result.applied });
|
|
336
|
+
else dbg("tool ok", { name: call.name, applied: result.applied, total: result.total });
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
dbgFail("max-rounds exceeded", { rounds: MAX_ROUNDS });
|
|
340
|
+
return { ok: false, mode: "max-rounds" };
|
|
341
|
+
} catch (e) {
|
|
342
|
+
if (e?.name === "TimeoutError" || /timeout|abort/i.test(String(e?.message))) {
|
|
343
|
+
dbgFail("timeout", { timeoutMs });
|
|
344
|
+
return { ok: false, mode: "timeout" };
|
|
345
|
+
}
|
|
346
|
+
dbgFail("error", { message: e?.message || String(e) });
|
|
347
|
+
return { ok: false, mode: "error", message: e?.message || String(e) };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// memory-palace v1.6.0 hybrid 模式:MEMORY.md 主动维护工具集(仅 hybrid 模式注册)。
|
|
2
|
+
// 三个工具(操作规范全部承载在 description——定案 #11,不做 skill):
|
|
3
|
+
// - memory_write 章节追加/新增(项目级多目录同步;用户级单文件)
|
|
4
|
+
// - memory_update_section 整章节精确替换 / 单条标记删除(stale 防护:oldText 归一化精确匹配)
|
|
5
|
+
// - memory_reorganize 项目级全量重整(双门禁机器校验 + 原子替换 + 时间戳注释)
|
|
6
|
+
// 定案约束:用户级禁止重整;重整时间戳由机器读写(HTML 注释落文件尾,agent 不可改);
|
|
7
|
+
// hybrid 下不做三级去重管线(重复治理归子 agent),仅保留同 normLine 精确去重防手滑重复。
|
|
8
|
+
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
9
|
+
import { writeFile, rename } from "node:fs/promises";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { readFileSync } from "node:fs";
|
|
12
|
+
import { expandHome, toHomeShort, readMdSync, nowStamp, normLine } from "../common/text.mjs";
|
|
13
|
+
import { appendToSectionText, upsertSectionText, replaceSectionText, markEntryDeletedText, locateSection } from "../common/sections.mjs";
|
|
14
|
+
|
|
15
|
+
const REORG_MARK = "memory-palace:last-reorg:";
|
|
16
|
+
const REORG_MARK_RE = new RegExp(`\\n?<!--\\s*${REORG_MARK}[^>]*-->\\s*$`);
|
|
17
|
+
|
|
18
|
+
// 本地 ISO 时间戳(YYYY-MM-DDTHH:mm:ss,冒号保留——落文件内容无文件名限制)。
|
|
19
|
+
// 沿 text.mjs nowStamp 的教训:禁 toISOString(UTC 差 8 小时)。
|
|
20
|
+
const localIsoStamp = () => {
|
|
21
|
+
const d = new Date();
|
|
22
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
23
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
// 读重整时间戳:文件尾 HTML 注释;无/损坏 → 0(视为从未重整,冷却已过)。
|
|
27
|
+
export function readLastReorg(md) {
|
|
28
|
+
const m = new RegExp(`<!--\\s*${REORG_MARK}([^>]+)-->`).exec(md || "");
|
|
29
|
+
if (!m) return 0;
|
|
30
|
+
const t = new Date(m[1].trim()).getTime();
|
|
31
|
+
return Number.isFinite(t) ? t : 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 计算 memory_reorganize 双门禁状态(供 pre-execute 弹窗与 execute 复核共用)。
|
|
35
|
+
export function checkReorgGate(memFile, cfg) {
|
|
36
|
+
const md = readMdSync(memFile);
|
|
37
|
+
const size = md.length;
|
|
38
|
+
const overBudget = size > (cfg.workspaceBudgetChars || 3000);
|
|
39
|
+
const last = readLastReorg(md);
|
|
40
|
+
const cooldownMs = (cfg.reorgCooldownDays ?? 7) * 86400000;
|
|
41
|
+
const cooled = Date.now() - last >= cooldownMs;
|
|
42
|
+
return {
|
|
43
|
+
ok: overBudget && cooled,
|
|
44
|
+
size,
|
|
45
|
+
overBudget,
|
|
46
|
+
last,
|
|
47
|
+
cooled,
|
|
48
|
+
reason: overBudget
|
|
49
|
+
? cooled
|
|
50
|
+
? "ok"
|
|
51
|
+
: `重整冷却期内(上次重整 ${new Date(last).toLocaleString()},冷却 ${cfg.reorgCooldownDays ?? 7} 天),请用 memory_update_section 做章节级修正。`
|
|
52
|
+
: `MEMORY.md 当前 ${size} 字符未超出注入预算(${cfg.workspaceBudgetChars || 3000}),无需重整;请用 memory_write / memory_update_section 维护。`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 同 normLine 精确去重:目标章节内已存在同归一化条目 → skip(不算去重管线,仅防手滑重复)。
|
|
57
|
+
function sectionHasEntry(md, section, entry) {
|
|
58
|
+
const loc = locateSection(md, section);
|
|
59
|
+
if (!loc) return false;
|
|
60
|
+
const want = normLine(entry);
|
|
61
|
+
for (let i = loc.start + 1; i < loc.end; i++) {
|
|
62
|
+
if (normLine(loc.lines[i]) === want) return true;
|
|
63
|
+
}
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 注册 hybrid 工具 + pre-execute 闸门。仅在 memoryMode === "hybrid" 时由 index.mjs 调用
|
|
69
|
+
* (memoryMode 切换需重启 dsh——注册时机安全,不存在运行中换挡错配)。
|
|
70
|
+
* @param {{ ctx: object, getConfig: () => object, paths: object, records: object, state: object }} deps
|
|
71
|
+
*/
|
|
72
|
+
export function registerHybridTools({ ctx, getConfig, paths, records, state }) {
|
|
73
|
+
const cfg = () => getConfig();
|
|
74
|
+
|
|
75
|
+
// 目标文件解析:project = 主目标(dirs[0],多 buddy 目录时与蒸馏按钮同语义只动主目标,
|
|
76
|
+
// 章节级 stale 校验天然防止错位覆写);user = 用户级 MEMORY.md。
|
|
77
|
+
function memFileOf(scope) {
|
|
78
|
+
if (scope === "user") return { file: expandHome(cfg().userMemoryPath), dirs: [] };
|
|
79
|
+
const dirs = paths.writeDirs();
|
|
80
|
+
if (!dirs.length) return null;
|
|
81
|
+
return { file: paths.memoryFileOf(dirs[0]), dirs };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---------- memory_write:章节追加/新增(章节化格式规范承载于 description——定案 #11) ----------
|
|
85
|
+
ctx.tools.register(
|
|
86
|
+
defineTool({
|
|
87
|
+
name: "memory_write",
|
|
88
|
+
description:
|
|
89
|
+
"Append a durable memory entry to MEMORY.md in the required chaptered format. " +
|
|
90
|
+
"Format is STRICT: entries live under `## 章节` headings as `- 条目` lines (one-line conclusion first, " +
|
|
91
|
+
"then key details: commands/paths/numbers; objective third-person; no dialogue, no questions, no tags). " +
|
|
92
|
+
"If the section exists the entry is appended under it; if not, the section is created automatically " +
|
|
93
|
+
"(do NOT force-match a semantically wrong section — pick a new section name instead). " +
|
|
94
|
+
"An exact-duplicate entry (same normalized text) in the same section is skipped. " +
|
|
95
|
+
"scope='project' writes the active workspace MEMORY.md; scope='user' writes the cross-project user MEMORY.md. " +
|
|
96
|
+
"NEVER write memory files with the raw write/edit tools — always use this tool. " +
|
|
97
|
+
"For rewriting a whole section use memory_update_section; full-file reorganization is memory_reorganize (gated).",
|
|
98
|
+
parameters: {
|
|
99
|
+
scope: {
|
|
100
|
+
type: "string",
|
|
101
|
+
required: true,
|
|
102
|
+
description: "'project' (active workspace MEMORY.md) or 'user' (cross-project user MEMORY.md).",
|
|
103
|
+
},
|
|
104
|
+
section: { type: "string", required: true, description: "章节标题(不含 `## ` 前缀),如:环境必知。" },
|
|
105
|
+
entry: { type: "string", required: true, description: "条目文本(一行,结论先行 + 关键细节,不带 `- ` 前缀)。" },
|
|
106
|
+
},
|
|
107
|
+
output: {
|
|
108
|
+
schema: {
|
|
109
|
+
type: "object",
|
|
110
|
+
additionalProperties: false,
|
|
111
|
+
properties: { ok: { type: "boolean", required: true }, message: { type: "string", required: true } },
|
|
112
|
+
},
|
|
113
|
+
render: (_args, value) => [{ type: "text", text: value?.message ?? "Saved." }],
|
|
114
|
+
},
|
|
115
|
+
async execute(args) {
|
|
116
|
+
state.recentAgentWrote = true;
|
|
117
|
+
const c = cfg();
|
|
118
|
+
if (!c.enabled) return { ok: false, message: "memory-palace is currently disabled in settings." };
|
|
119
|
+
const section = String(args.section ?? "").trim();
|
|
120
|
+
const entry = String(args.entry ?? "").trim();
|
|
121
|
+
if (!section || !entry) return { ok: false, message: "section 与 entry 均不能为空。" };
|
|
122
|
+
const target = memFileOf(args.scope === "user" ? "user" : "project");
|
|
123
|
+
if (!target) return { ok: false, message: "No active workspace." };
|
|
124
|
+
try {
|
|
125
|
+
let wrote = 0;
|
|
126
|
+
let skipped = 0;
|
|
127
|
+
const files = target.dirs.length ? target.dirs.map((d) => paths.memoryFileOf(d)) : [target.file];
|
|
128
|
+
for (const file of files) {
|
|
129
|
+
const md = readMdSync(file);
|
|
130
|
+
if (sectionHasEntry(md, section, entry)) {
|
|
131
|
+
skipped++;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const r = upsertSectionText(md, section, entry);
|
|
135
|
+
if (!r.ok) {
|
|
136
|
+
skipped++;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
await writeFile(file, r.text, "utf8");
|
|
140
|
+
wrote++;
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ok: wrote > 0,
|
|
144
|
+
message: wrote > 0
|
|
145
|
+
? `Saved to ${wrote} MEMORY.md file(s) under 「${section}」${skipped ? ` (${skipped} duplicate skipped)` : ""}.`
|
|
146
|
+
: `Duplicate entry under 「${section}」; nothing written.`,
|
|
147
|
+
};
|
|
148
|
+
} catch (e) {
|
|
149
|
+
return { ok: false, message: `Failed: ${e}` };
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// ---------- memory_update_section:整章节精确替换 / 单条标记删除(stale 防护) ----------
|
|
156
|
+
ctx.tools.register(
|
|
157
|
+
defineTool({
|
|
158
|
+
name: "memory_update_section",
|
|
159
|
+
description:
|
|
160
|
+
"Update ONE section of MEMORY.md with stale-protection. Two modes:\n" +
|
|
161
|
+
"1) Replace: pass oldText = the FULL current section text including its `## heading` line " +
|
|
162
|
+
"(read it first via memory_read) and newText = the complete replacement section. The oldText is " +
|
|
163
|
+
"matched by normalized exact comparison against the section on disk — on any mismatch the tool REJECTS " +
|
|
164
|
+
"and returns the actual current content, so re-read and retry.\n" +
|
|
165
|
+
"2) Mark-delete a single entry: pass markDelete:true and oldText = the exact entry line text; the entry " +
|
|
166
|
+
"becomes a strikethrough tombstone (~~text~~) — never physically deleted, recoverable by hand.\n" +
|
|
167
|
+
"NEVER edit memory files with raw write/edit tools. Deleting whole information is only allowed as " +
|
|
168
|
+
"strikethrough; section replacement must not drop still-valid entries. " +
|
|
169
|
+
"scope='project' (active workspace MEMORY.md, primary target) or 'user' (user MEMORY.md; NO full reorganize).",
|
|
170
|
+
parameters: {
|
|
171
|
+
scope: {
|
|
172
|
+
type: "string",
|
|
173
|
+
required: true,
|
|
174
|
+
description: "'project' (active workspace MEMORY.md primary target) or 'user'.",
|
|
175
|
+
},
|
|
176
|
+
section: { type: "string", required: true, description: "章节标题(不含 `## ` 前缀)。" },
|
|
177
|
+
oldText: {
|
|
178
|
+
type: "string",
|
|
179
|
+
required: true,
|
|
180
|
+
description: "replace 模式 = 含 `## 标题` 行的整章节当前全文;markDelete 模式 = 目标条目原文(可含 `- ` 前缀)。",
|
|
181
|
+
},
|
|
182
|
+
newText: { type: "string", description: "replace 模式必填:替换后的整章节全文(含 `## 标题` 行,需保留删除线墓碑)。" },
|
|
183
|
+
markDelete: { type: "boolean", description: "true = 仅把 oldText 匹配的单条条目标记为删除线;省略 = 整章节替换。" },
|
|
184
|
+
},
|
|
185
|
+
output: {
|
|
186
|
+
schema: {
|
|
187
|
+
type: "object",
|
|
188
|
+
additionalProperties: false,
|
|
189
|
+
properties: {
|
|
190
|
+
ok: { type: "boolean", required: true },
|
|
191
|
+
message: { type: "string", required: true },
|
|
192
|
+
actual: { type: "string", required: true },
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
render: (_args, value) =>
|
|
196
|
+
[{ type: "text", text: value?.actual ? `${value.message}\n\n当前实际内容:\n${value.actual}` : value?.message ?? "Done." }],
|
|
197
|
+
},
|
|
198
|
+
async execute(args) {
|
|
199
|
+
state.recentAgentWrote = true;
|
|
200
|
+
const c = cfg();
|
|
201
|
+
if (!c.enabled) return { ok: false, message: "memory-palace is currently disabled in settings.", actual: "" };
|
|
202
|
+
const section = String(args.section ?? "").trim();
|
|
203
|
+
const oldText = String(args.oldText ?? "");
|
|
204
|
+
if (!section || !oldText.trim()) return { ok: false, message: "section 与 oldText 均不能为空。", actual: "" };
|
|
205
|
+
const target = memFileOf(args.scope === "user" ? "user" : "project");
|
|
206
|
+
if (!target) return { ok: false, message: "No active workspace.", actual: "" };
|
|
207
|
+
try {
|
|
208
|
+
const file = target.file;
|
|
209
|
+
const md = readMdSync(file);
|
|
210
|
+
const r = args.markDelete === true
|
|
211
|
+
? markEntryDeletedText(md, section, oldText)
|
|
212
|
+
: replaceSectionText(md, section, oldText, args.newText);
|
|
213
|
+
if (!r.ok) {
|
|
214
|
+
return { ok: false, message: `更新被拒绝(${r.reason}):请先 memory_read 重读后再试。`, actual: r.actual ?? "" };
|
|
215
|
+
}
|
|
216
|
+
await writeFile(file, r.text, "utf8");
|
|
217
|
+
return { ok: true, message: `Updated 「${section}」 in ${toHomeShort(file)} (${r.reason}).`, actual: "" };
|
|
218
|
+
} catch (e) {
|
|
219
|
+
return { ok: false, message: `Failed: ${e}`, actual: "" };
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
}),
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
// ---------- memory_reorganize:项目级全量重整(双门禁机器校验 + 原子替换 + 时间戳) ----------
|
|
226
|
+
ctx.tools.register(
|
|
227
|
+
defineTool({
|
|
228
|
+
name: "memory_reorganize",
|
|
229
|
+
description:
|
|
230
|
+
"FULLY reorganize the PROJECT-LEVEL MEMORY.md by replacing the whole file with newContent. " +
|
|
231
|
+
"HARD GATES (machine-checked, both required): (1) the file exceeds the injection budget " +
|
|
232
|
+
"(workspaceBudgetChars); (2) at least reorgCooldownDays (default 7) since the last reorganize. " +
|
|
233
|
+
"If either gate fails the call is REJECTED — use memory_update_section instead. " +
|
|
234
|
+
"Requirements for newContent: keep the full `## 章节` + `- 条目` format; NEVER lose still-valid key " +
|
|
235
|
+
"information — read the daily logs (memory_read) and current MEMORY.md for cross-checking first; " +
|
|
236
|
+
"merge duplicates, drop only strikethrough/obsolete content; keep user-level MEMORY.md untouched " +
|
|
237
|
+
"(user-level reorganization is FORBIDDEN). A last-reorg timestamp comment is appended automatically. " +
|
|
238
|
+
"A user confirmation dialog is shown before execution.",
|
|
239
|
+
parameters: {
|
|
240
|
+
newContent: {
|
|
241
|
+
type: "string",
|
|
242
|
+
required: true,
|
|
243
|
+
description: "重整后的项目级 MEMORY.md 完整全文(`## 章节` + `- 条目` 格式;不含重整时间戳注释——机器自动追加)。",
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
output: {
|
|
247
|
+
schema: {
|
|
248
|
+
type: "object",
|
|
249
|
+
additionalProperties: false,
|
|
250
|
+
properties: { ok: { type: "boolean", required: true }, message: { type: "string", required: true } },
|
|
251
|
+
},
|
|
252
|
+
render: (_args, value) => [{ type: "text", text: value?.message ?? "Done." }],
|
|
253
|
+
},
|
|
254
|
+
async execute(args) {
|
|
255
|
+
state.recentAgentWrote = true;
|
|
256
|
+
const c = cfg();
|
|
257
|
+
if (!c.enabled) return { ok: false, message: "memory-palace is currently disabled in settings." };
|
|
258
|
+
const dirs = paths.writeDirs();
|
|
259
|
+
if (!dirs.length) return { ok: false, message: "No active workspace." };
|
|
260
|
+
const file = paths.memoryFileOf(dirs[0]);
|
|
261
|
+
// 复核双门禁(pre-execute 已拦一道;这里防绕过)
|
|
262
|
+
const gate = checkReorgGate(file, c);
|
|
263
|
+
if (!gate.ok) return { ok: false, message: `重整被门禁拒绝:${gate.reason}` };
|
|
264
|
+
const newContent = String(args.newContent ?? "").replace(REORG_MARK_RE, "").replace(/\s+$/, "");
|
|
265
|
+
if (!newContent.trim()) return { ok: false, message: "newContent 不能为空。" };
|
|
266
|
+
const stamped = `${newContent}\n\n<!-- ${REORG_MARK}${localIsoStamp()} -->\n`;
|
|
267
|
+
try {
|
|
268
|
+
// 原子替换(沿 distillProjectMemory 模式):cover 同目录写入 → 完整性校验 → 备份 → rename → 失败回滚
|
|
269
|
+
const coverFile = join(dirname(file), "memory-cover.md");
|
|
270
|
+
await writeFile(coverFile, stamped, "utf8");
|
|
271
|
+
if (!readMdSync(coverFile)) {
|
|
272
|
+
await import("node:fs/promises").then((m) => m.unlink(coverFile)).catch(() => {});
|
|
273
|
+
return { ok: false, message: "memory-cover.md 未完整写入,本次重整失败(原记忆未动)。" };
|
|
274
|
+
}
|
|
275
|
+
const backup = `${file}.${nowStamp()}`;
|
|
276
|
+
const fsp = await import("node:fs/promises");
|
|
277
|
+
await fsp.rename(file, backup).catch(async () => {
|
|
278
|
+
await fsp.rename(coverFile, file); // 原文件不存在(首次创建)时直接落位
|
|
279
|
+
return true;
|
|
280
|
+
});
|
|
281
|
+
try {
|
|
282
|
+
await fsp.rename(coverFile, file);
|
|
283
|
+
} catch (e) {
|
|
284
|
+
await fsp.rename(backup, file).catch(() => {});
|
|
285
|
+
throw e;
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
message: `重整完成:${toHomeShort(file)}(原 ${gate.size} → ${stamped.length} 字符),备份 ${toHomeShort(backup)},时间戳已更新。`,
|
|
290
|
+
};
|
|
291
|
+
} catch (e) {
|
|
292
|
+
return { ok: false, message: `重整失败(原记忆未动或已回滚):${e?.message || String(e)}` };
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
}),
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// hybrid 写工具闸门(pre-execute):plan 模式 deny;memory_reorganize 门禁前置计算 + 原生确认弹窗。
|
|
300
|
+
// 沿 tools.mjs 的 memory_delete 闸门模式(tools/pre-execute → approval.request)。
|
|
301
|
+
export function attachHybridGuards(ctx, getConfig, paths, state) {
|
|
302
|
+
const HYBRID_WRITE_TOOLS = new Set(["memory_write", "memory_update_section", "memory_reorganize"]);
|
|
303
|
+
ctx.on("tools/pre-execute", async (exec, next) => {
|
|
304
|
+
const name = exec?.name;
|
|
305
|
+
if (!HYBRID_WRITE_TOOLS.has(name)) return next();
|
|
306
|
+
if (state.planModeActive) {
|
|
307
|
+
return { kind: "deny", reason: "plan 模式下禁止写入记忆" };
|
|
308
|
+
}
|
|
309
|
+
if (name !== "memory_reorganize") return next();
|
|
310
|
+
const c = getConfig();
|
|
311
|
+
const dirs = paths.writeDirs();
|
|
312
|
+
if (!dirs.length) return { kind: "deny", reason: "无活动工作区,无法重整。" };
|
|
313
|
+
const file = paths.memoryFileOf(dirs[0]);
|
|
314
|
+
const gate = checkReorgGate(file, c);
|
|
315
|
+
if (!gate.ok) return { kind: "deny", reason: gate.reason };
|
|
316
|
+
const args = (exec.arguments && typeof exec.arguments === "object") ? exec.arguments : {};
|
|
317
|
+
const size = String(args.newContent ?? "").length;
|
|
318
|
+
return {
|
|
319
|
+
kind: "ask",
|
|
320
|
+
reason:
|
|
321
|
+
`⚠️ 项目级 MEMORY.md 全量重整确认\n` +
|
|
322
|
+
`当前 ${gate.size} 字符(已超预算 ${c.workspaceBudgetChars || 3000}),冷却已满足(上次重整 ${gate.last ? new Date(gate.last).toLocaleString() : "从未"})。\n` +
|
|
323
|
+
`即将用 ${size} 字符的新全文覆盖 ${file}(原文件会自动备份)。\n` +
|
|
324
|
+
`请确认是否执行重整?`,
|
|
325
|
+
};
|
|
326
|
+
});
|
|
327
|
+
}
|