dsh-memory-palace 1.2.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/lib/tools.mjs ADDED
@@ -0,0 +1,388 @@
1
+ // memory-palace 记忆工具:memory_note / memory_note_user / memory_read / memory_delete
2
+ // + 删除硬确认闸门(tools/pre-execute → harness 原生确认弹窗)。
3
+ // 经 registerTools 注入 ctx/config/paths/records/运行时状态;在 index.mjs 装配时调用一次。
4
+ import { defineTool } from "@deepseek-ai/dsh-tools";
5
+ import { join } from "node:path";
6
+ import { todayISO, expandHome, toHomeShort, readMdSync } from "./common/text.mjs";
7
+ import { appendLineDedup, findMatches, removeLineByMatch, recentLogDates } from "./common/records.mjs";
8
+
9
+ /**
10
+ * @param {{ ctx: object, getConfig: () => object, paths: object, records: object, state: object }} deps
11
+ */
12
+ export function registerTools({ ctx, getConfig, paths, records, state }) {
13
+ const cfg = () => getConfig();
14
+
15
+ // 删除作用域的中文标签。
16
+ function levelLabel(level) {
17
+ if (level === "user") return "用户级";
18
+ if (level === "project") return "项目级与每日级";
19
+ if (level === "daily") return "每日级";
20
+ return level;
21
+ }
22
+
23
+ // 根据 level 圈定待查/待删的候选文件,每个文件带层级标签与短路径。
24
+ // - user:用户级 MEMORY.md(~/.deepseek-harness/MEMORY.md)
25
+ // - project:项目级 MEMORY.md(与 memory/ 同级)+ 今日每日日志
26
+ // - daily:今日每日日志 + 最近 3 份历史每日日志(同工作区)
27
+ async function deleteCandidates(level) {
28
+ const c = cfg();
29
+ const files = [];
30
+ if (level === "user") {
31
+ const file = expandHome(c.userMemoryPath);
32
+ files.push({ file, tier: "用户级", path: toHomeShort(file) });
33
+ return files;
34
+ }
35
+ const dirs = paths.writeDirs();
36
+ for (const dir of dirs) {
37
+ const dirShort = toHomeShort(dir);
38
+ if (level === "project") {
39
+ const mem = paths.memoryFileOf(dir);
40
+ files.push({ file: mem, tier: "项目级", path: toHomeShort(mem) });
41
+ }
42
+ // 每日级:今日日志必查;daily 作用域额外纳入最近 3 份历史日志
43
+ const todayFile = join(dir, `${todayISO()}.md`);
44
+ files.push({ file: todayFile, tier: "每日级", path: `${dirShort}/${todayISO()}.md` });
45
+ if (level === "daily") {
46
+ const dates = await recentLogDates(dir, 3);
47
+ for (const d of dates) files.push({ file: join(dir, `${d}.md`), tier: "每日级", path: `${dirShort}/${d}.md` });
48
+ }
49
+ }
50
+ return files;
51
+ }
52
+
53
+ // 生成面向用户的删除确认文案:列出每条匹配的位置 + 实际内容 + 序号(多匹配即对应选项)。
54
+ function buildConfirmPrompt(level, match, matches) {
55
+ const lines = matches.map((mm, i) => `[${i + 1}] [${mm.tier}] ${mm.path}\n "${mm.content}"`);
56
+ return (
57
+ `⚠️ 删除记忆确认(不可逆操作)\n` +
58
+ `匹配文本:"${match}"(作用域:${levelLabel(level)})\n` +
59
+ `共匹配到 ${matches.length} 条记忆:\n${lines.join("\n")}\n\n` +
60
+ `请确认是否全部删除?若只想删除其中部分条目,请提供更精确的 match 重新预览后再确认。`
61
+ );
62
+ }
63
+
64
+ // ---------- 可选工具:用户声明项目级约定/偏好时写入工作区 MEMORY.md ----------
65
+ ctx.tools.register(
66
+ defineTool({
67
+ name: "memory_note",
68
+ description:
69
+ "Save a durable PROJECT-LEVEL memory entry to the active workspace's MEMORY.md " +
70
+ "(the project's .workbuddy/memory, .codebuddy/memory, or .deepseek-harness/MEMORY.md). " +
71
+ "Call it proactively after completing tasks for the CURRENT project (record what was done + key result / paths / numbers), " +
72
+ "and when the user states a lasting preference, convention, or fact, or a key decision / root-cause fix is established. " +
73
+ "Format: one-line conclusion first, then key details. For cross-project user-level memory, use memory_note_user instead.",
74
+ parameters: {
75
+ content: {
76
+ type: "string",
77
+ required: true,
78
+ description: "The convention / preference / fact to remember, in one concise line.",
79
+ },
80
+ },
81
+ output: {
82
+ schema: {
83
+ type: "object",
84
+ additionalProperties: false,
85
+ properties: {
86
+ ok: { type: "boolean", required: true },
87
+ message: { type: "string", required: true },
88
+ },
89
+ },
90
+ render: (_args, value) => [{ type: "text", text: value?.message ?? "Saved." }],
91
+ },
92
+ async execute(args) {
93
+ state.recentAgentWrote = true;
94
+ if (!cfg().enabled) return { ok: false, message: "memory-palace is currently disabled in settings." };
95
+ const dirs = paths.writeDirs();
96
+ if (!dirs.length) return { ok: false, message: "No active workspace." };
97
+ try {
98
+ const line = `- ${args.content}`;
99
+ let wrote = 0;
100
+ let skipped = 0;
101
+ for (const dir of dirs) {
102
+ const mem = paths.memoryFileOf(dir);
103
+ if (await appendLineDedup(mem, line)) wrote++;
104
+ else skipped++;
105
+ }
106
+ const dup = skipped > 0 ? ` (${skipped} already had it)` : "";
107
+ return { ok: true, message: `Saved to ${wrote} workspace MEMORY.md file(s)${dup}.` };
108
+ } catch (e) {
109
+ return { ok: false, message: `Failed: ${e}` };
110
+ }
111
+ },
112
+ }),
113
+ );
114
+
115
+ // ---------- 可选工具:用户声明跨项目个人偏好时写入用户级 MEMORY.md ----------
116
+ ctx.tools.register(
117
+ defineTool({
118
+ name: "memory_note_user",
119
+ description:
120
+ "Save a durable USER-LEVEL (cross-project) preference or fact to the user-level MEMORY.md " +
121
+ `(default ${toHomeShort(expandHome(cfg().userMemoryPath))}). ` +
122
+ "Call it proactively when the user states a personal preference / constraint that applies across ALL projects, " +
123
+ "or a reusable fact / decision worth keeping for future sessions. Format: one-line conclusion first, then key details. " +
124
+ "For current-project conventions, use memory_note instead.",
125
+ parameters: {
126
+ content: {
127
+ type: "string",
128
+ required: true,
129
+ description: "The user-level preference / fact to remember, in one concise line.",
130
+ },
131
+ },
132
+ output: {
133
+ schema: {
134
+ type: "object",
135
+ additionalProperties: false,
136
+ properties: {
137
+ ok: { type: "boolean", required: true },
138
+ message: { type: "string", required: true },
139
+ },
140
+ },
141
+ render: (_args, value) => [{ type: "text", text: value?.message ?? "Saved." }],
142
+ },
143
+ async execute(args) {
144
+ state.recentAgentWrote = true;
145
+ const c = cfg();
146
+ if (!c.enabled) return { ok: false, message: "memory-palace is currently disabled in settings." };
147
+ try {
148
+ const file = expandHome(c.userMemoryPath);
149
+ const fileShort = toHomeShort(file);
150
+ const line = `- ${args.content}`;
151
+ const wrote = await appendLineDedup(file, line);
152
+ return {
153
+ ok: true,
154
+ message: wrote
155
+ ? `Saved to user-level memory (${fileShort}).`
156
+ : `Already present in user-level memory (${fileShort}); skipped.`,
157
+ };
158
+ } catch (e) {
159
+ return { ok: false, message: `Failed: ${e}` };
160
+ }
161
+ },
162
+ }),
163
+ );
164
+
165
+ // ---------- 可选工具:读取全部记忆(用户级 + 项目级 MEMORY.md 与每日日志) ----------
166
+ // AI 说"读取项目记忆/看看记忆"时直接调用,返回聚合内容,避免 AI 自己翻文件、只找 MEMORY.md 而漏掉每日日志。
167
+ ctx.tools.register(
168
+ defineTool({
169
+ name: "memory_read",
170
+ description:
171
+ "Read all persistent memory: user-level MEMORY.md (cross-project preferences), " +
172
+ "and for the current project the workspace MEMORY.md plus recent daily logs " +
173
+ "(YYYY-MM-DD.md) from .workbuddy/memory, .codebuddy/memory, or .deepseek-harness/memory. " +
174
+ "Use this instead of manually globbing/reading memory files — it aggregates everything. " +
175
+ "This tool takes no parameters; call it with an empty object, e.g. tools.memory_read({}) — the runtime rejects undefined arguments.",
176
+ parameters: {},
177
+ output: {
178
+ schema: {
179
+ type: "object",
180
+ additionalProperties: false,
181
+ properties: {
182
+ ok: { type: "boolean", required: true },
183
+ message: { type: "string", required: true },
184
+ memory: { type: "string", required: true },
185
+ },
186
+ },
187
+ render: (_args, value) => [{ type: "text", text: value?.memory ?? value?.message ?? "No memory." }],
188
+ },
189
+ async execute() {
190
+ const c = cfg();
191
+ if (!c.enabled) return { ok: false, message: "memory-palace is currently disabled in settings.", memory: "" };
192
+ try {
193
+ const blocks = [];
194
+ const userFile = expandHome(c.userMemoryPath);
195
+ const userFileShort = toHomeShort(userFile);
196
+ const u = readMdSync(userFile);
197
+ if (u) blocks.push(`# 用户级记忆 (${userFileShort})\n${u}`);
198
+ for (const dir of paths.readDirs()) {
199
+ const dirShort = toHomeShort(dir);
200
+ const w = paths.memoryReadCandidates(dir).map(readMdSync).filter(Boolean).join("\n\n");
201
+ if (w) blocks.push(`# 工作区记忆 (${dirShort})\n${w}`);
202
+ const t = readMdSync(join(dir, `${todayISO()}.md`));
203
+ if (t) blocks.push(`# 今日工作日志 (${todayISO()} @ ${dirShort})\n${t}`);
204
+ const recent = await records.recentLogs(dir);
205
+ if (recent) blocks.push(recent);
206
+ }
207
+ if (!blocks.length) {
208
+ return { ok: true, message: "No memory files found yet.", memory: "" };
209
+ }
210
+ return { ok: true, message: "Memory loaded.", memory: blocks.join("\n\n") };
211
+ } catch (e) {
212
+ return { ok: false, message: `Failed: ${e}`, memory: "" };
213
+ }
214
+ },
215
+ }),
216
+ );
217
+
218
+ // ---------- 可选工具:按内容删除某条记忆(用户级 / 项目级 / 每日级),需用户显式确认 ----------
219
+ // 两阶段安全设计:默认(confirm 省略/非 true)只做预览——查找匹配条目并原样返回其位置与内容,
220
+ // 绝不删除;仅当 AI 把候选展示给用户、用户明确同意后再以相同 match/level 调用并置 confirm:true 才真正删除。
221
+ // 预览结果含每条匹配的位置(用户级/项目级/每日级)、文件短路径与真实内容;多条匹配自动编号成选项。
222
+ ctx.tools.register(
223
+ defineTool({
224
+ name: "memory_delete",
225
+ description:
226
+ "Delete a memory entry by matching its text. DESTRUCTIVE — requires explicit user confirmation. " +
227
+ "This tool NEVER deletes unless you pass confirm:true. " +
228
+ "Step 1 (preview, default): call with confirm omitted/false — it only finds matching entries and returns them " +
229
+ "(each with its location tier: 用户级/项目级/每日级, the short file path, and the exact line content) so you can show the user and ask for confirmation. " +
230
+ "Step 2 (delete): after the user explicitly agrees, call again with the SAME match and level and confirm:true to actually remove them. " +
231
+ "Use it when the user asks to forget / remove / delete a particular remembered fact or preference. " +
232
+ "The 'match' is a case-insensitive substring of the entry (leading '- ' is ignored); " +
233
+ "structural lines (headers, comments) are never deleted. " +
234
+ "The 'level' selects the scope: 'user' (default, cross-project ~/.deepseek-harness/MEMORY.md), " +
235
+ "'project' (active workspace MEMORY.md + today's daily log), or 'daily' (today + recent 3 daily logs of the workspace). " +
236
+ "Prefer calling memory_read first to copy the exact entry text.",
237
+ parameters: {
238
+ match: {
239
+ type: "string",
240
+ required: true,
241
+ description: "Substring of the memory entry to delete (e.g. the exact line text). Non-empty.",
242
+ },
243
+ level: {
244
+ type: "string",
245
+ required: true,
246
+ description: "Scope to search/delete: 'user' (default, cross-project), 'project' (workspace MEMORY.md + today's daily log), or 'daily' (workspace daily logs).",
247
+ },
248
+ confirm: {
249
+ type: "boolean",
250
+ description: "Safety gate. Omit or pass false to preview (find & show matches without deleting). Pass true ONLY after the user explicitly confirms, to actually delete.",
251
+ },
252
+ },
253
+ output: {
254
+ schema: {
255
+ type: "object",
256
+ additionalProperties: false,
257
+ properties: {
258
+ ok: { type: "boolean", required: true },
259
+ preview: { type: "boolean", required: true },
260
+ level: { type: "string", required: true },
261
+ removed: { type: "number", required: true },
262
+ matches: {
263
+ type: "array",
264
+ items: {
265
+ type: "object",
266
+ additionalProperties: false,
267
+ properties: {
268
+ tier: { type: "string", required: true },
269
+ path: { type: "string", required: true },
270
+ content: { type: "string", required: true },
271
+ },
272
+ },
273
+ },
274
+ details: {
275
+ type: "array",
276
+ items: {
277
+ type: "object",
278
+ additionalProperties: false,
279
+ properties: {
280
+ tier: { type: "string", required: true },
281
+ path: { type: "string", required: true },
282
+ content: { type: "string", required: true },
283
+ },
284
+ },
285
+ },
286
+ message: { type: "string", required: true },
287
+ confirmPrompt: { type: "string", required: true },
288
+ },
289
+ },
290
+ render: (_args, value) =>
291
+ [{ type: "text", text: value?.confirmPrompt || value?.message || (value?.preview ? "Preview only — no deletion performed." : "Deleted.") }],
292
+ },
293
+ async execute(args) {
294
+ state.recentAgentWrote = true;
295
+ const c = cfg();
296
+ if (!c.enabled) {
297
+ return { ok: false, preview: false, level: args.level || "user", removed: 0, matches: [], details: [], message: "memory-palace is currently disabled in settings.", confirmPrompt: "" };
298
+ }
299
+ const level = (args.level || "user").toLowerCase();
300
+ if (!["user", "project", "daily"].includes(level)) {
301
+ return { ok: false, preview: false, level, removed: 0, matches: [], details: [], message: `Unknown level: ${level} (use 'user' | 'project' | 'daily').`, confirmPrompt: "" };
302
+ }
303
+ const match = (args.match || "").trim();
304
+ if (!match) {
305
+ return { ok: false, preview: false, level, removed: 0, matches: [], details: [], message: "match 不能为空:请提供要删除的记忆文本(子串)。", confirmPrompt: "" };
306
+ }
307
+
308
+ const candidates = await deleteCandidates(level);
309
+ if (!candidates.length && level !== "user") {
310
+ return { ok: false, preview: false, level, removed: 0, matches: [], details: [], message: "当前没有活动工作区,无法定位项目/每日记忆文件。", confirmPrompt: "" };
311
+ }
312
+
313
+ // 收集所有候选文件中的匹配条目(不修改任何文件)
314
+ const matches = [];
315
+ for (const cand of candidates) {
316
+ const found = findMatches(cand.file, match, cand.tier, cand.path);
317
+ for (const f of found) matches.push(f);
318
+ }
319
+
320
+ const confirm = args.confirm === true;
321
+
322
+ // 预览阶段:只查不删,返回带位置与真实内容的候选供 AI 转述给用户确认
323
+ if (!confirm) {
324
+ if (!matches.length) {
325
+ return {
326
+ ok: true, preview: true, level, removed: 0, matches: [], details: [],
327
+ message: `在「${levelLabel(level)}」未找到匹配"${match}"的记忆条目,无需删除。如需删除其他层级,请调整 level(user/project/daily)。`,
328
+ confirmPrompt: "",
329
+ };
330
+ }
331
+ const confirmPrompt = buildConfirmPrompt(level, match, matches);
332
+ return {
333
+ ok: true, preview: true, level, removed: 0, matches, details: [],
334
+ message: `已找到 ${matches.length} 条匹配"${match}"的记忆条目(位于${levelLabel(level)})。删除为不可逆操作,请先向用户展示下列候选并征得其明确确认;确认后再以相同 match 与 level 调用本工具并置 confirm:true 执行删除。`,
335
+ confirmPrompt,
336
+ };
337
+ }
338
+
339
+ // 已获确认:执行删除,并回传被删条目的位置与内容明细(透明化)
340
+ const details = [];
341
+ let total = 0;
342
+ for (const cand of candidates) {
343
+ const r = await removeLineByMatch(cand.file, match);
344
+ total += r.removed;
345
+ for (const line of r.lines) details.push({ tier: cand.tier, path: cand.path, content: line });
346
+ }
347
+ return {
348
+ ok: true, preview: false, level, removed: total, matches: [], details,
349
+ message: total > 0
350
+ ? `已确认删除 ${total} 条匹配"${match}"的记忆(${levelLabel(level)})。`
351
+ : `确认执行,但「${levelLabel(level)}」下未找到匹配"${match}"的记忆,未删除任何内容。`,
352
+ confirmPrompt: "",
353
+ };
354
+ },
355
+ }),
356
+ );
357
+
358
+ // 删除记忆的硬确认闸门:把"实际删除(confirm:true)"路由到 harness 原生确认弹窗。
359
+ // 这用到 dsh-tools 的 `tools/pre-execute` 事件瀑布 + dsh-user-approval 的 `approval.request` 弹窗
360
+ // (即删除用户记忆时你看到的「沙箱授权弹窗」)——比两阶段的 AI 级 confirm:true 更可靠:
361
+ // 即便模型误传 confirm:true,没有真人点击弹窗允许也绝不会真正删除。
362
+ // 预览(confirm 省略或为 false)直接放行、不弹窗,由 execute 返回候选供 AI 转述、供用户选择删哪几条。
363
+ ctx.on("tools/pre-execute", async (exec, next) => {
364
+ if (exec?.name !== "memory_delete") return next();
365
+ const a = (exec.arguments && typeof exec.arguments === "object") ? exec.arguments : {};
366
+ const confirm = a.confirm === true;
367
+ if (!confirm) return next(); // 预览阶段:只查不删,无需弹窗
368
+ const level = (a.level || "user").toLowerCase();
369
+ if (!["user", "project", "daily"].includes(level)) return next();
370
+ const match = (a.match || "").trim();
371
+ if (!match) return next();
372
+ try {
373
+ const candidates = await deleteCandidates(level);
374
+ const matches = [];
375
+ for (const cand of candidates) {
376
+ const found = findMatches(cand.file, match, cand.tier, cand.path);
377
+ for (const f of found) matches.push(f);
378
+ }
379
+ // 没匹配到就不弹窗,放行后由 execute 回"未找到"
380
+ if (matches.length === 0) return next();
381
+ const reason = buildConfirmPrompt(level, match, matches);
382
+ return { kind: "ask", reason };
383
+ } catch (err) {
384
+ // 预览失败按拒绝处理,宁可不让删也不误删
385
+ return { kind: "deny", reason: `删除前预览失败,已阻止删除:${err?.message || String(err)}` };
386
+ }
387
+ });
388
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "dsh-memory-palace",
3
+ "version": "1.2.0",
4
+ "description": "把 WorkBuddy 的文件式记忆系统移植进 DeepSeek Harness:用户级 + 工作区的人类可读 Markdown 记忆(~/.deepseek-harness/MEMORY.md 与 <cwd>/.deepseek-harness/memory/)。",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "exports": {
8
+ ".": "./lib/index.js",
9
+ "./client": "./lib/client.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "lib",
14
+ "cordis.patch.yml",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "publishConfig": {
19
+ "registry": "https://registry.npmjs.org/"
20
+ },
21
+ "license": "MIT",
22
+ "dsh": {
23
+ "bundle": {
24
+ "patch": "./cordis.patch.yml"
25
+ },
26
+ "client": {
27
+ "platform": "web",
28
+ "inject": [
29
+ "@deepseek-ai/dsh-client-runtime",
30
+ "@deepseek-ai/dsh-client-ui-settings",
31
+ "@deepseek-ai/dsh-client-locale",
32
+ "@deepseek-ai/dsh-client-connection",
33
+ "@deepseek-ai/dsh-api-remotes"
34
+ ]
35
+ }
36
+ },
37
+ "peerDependencies": {
38
+ "@deepseek-ai/cordis": "*",
39
+ "@deepseek-ai/dsh-fs": "^0.1.0-rc.6",
40
+ "@deepseek-ai/dsh-llm": "*",
41
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6",
42
+ "@deepseek-ai/dsh-tools": "^0.1.0-rc.6",
43
+ "@deepseek-ai/schemastery": "^3.18.1"
44
+ },
45
+ "devDependencies": {
46
+ "@deepseek-ai/dsh-settings": "^0.1.0-rc.6"
47
+ },
48
+ "scripts": {
49
+ "build": "node scripts/build.mjs",
50
+ "prepublishOnly": "npm run build"
51
+ }
52
+ }