dsh-memory-core 0.1.2 → 0.1.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/CHANGELOG.md +19 -0
- package/README.md +28 -15
- package/client.js +289 -41
- package/index.js +449 -84
- package/lib/defaults.js +3 -2
- package/lib/render.js +62 -35
- package/lib/runtime-config.js +45 -0
- package/lib/schema.js +1 -0
- package/lib/store.js +5 -1
- package/manage.html +575 -287
- package/package.json +3 -3
package/index.js
CHANGED
|
@@ -4,21 +4,38 @@
|
|
|
4
4
|
* 与 shield 包配对使用:shield 挡住其他记忆插件的注入,本包给遮蔽会话加一套
|
|
5
5
|
* 隔离的记忆存储(独立数据目录,与普通会话的 dsh-memory-evolve 完全隔离)。
|
|
6
6
|
*
|
|
7
|
-
* 默认
|
|
7
|
+
* 默认 full 模式(五轨 memory/user/project/key/daily);老配置显式写 lite 的仍跑三轨。
|
|
8
8
|
*/
|
|
9
9
|
import { PLUGIN_NAME } from "./lib/constants.js";
|
|
10
10
|
import { DEFAULTS, resolveMemoryDir } from "./lib/defaults.js";
|
|
11
11
|
import { MemoryStore, SuggestionQueue, ArchiveStore } from "./lib/store.js";
|
|
12
12
|
import { renderSnapshot } from "./lib/render.js";
|
|
13
|
+
import { readLiveConfig, writeLiveConfig, liveConfigPath } from "./lib/runtime-config.js";
|
|
13
14
|
import { writeGapCounter } from "./lib/watchdog.js";
|
|
14
15
|
import { tracksOf, TRACK_FILES, PENDING_FILE } from "./lib/tracks.js";
|
|
15
16
|
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
16
|
-
import { join, dirname } from "node:path";
|
|
17
|
+
import { join, dirname, resolve, sep } from "node:path";
|
|
18
|
+
import { spawn } from "node:child_process";
|
|
17
19
|
import { fileURLToPath } from "node:url";
|
|
18
20
|
|
|
19
21
|
const name = PLUGIN_NAME;
|
|
20
22
|
const inject = ["systemPrompt", "tools", "webServer"];
|
|
21
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 进程级单例:同一进程只允许一个实例注册工具 / 注入 / 路由。
|
|
26
|
+
*
|
|
27
|
+
* 为什么需要:profile 层(插件列表的开关)与 preset 层(preset 的 plugins 挂载)可以同时
|
|
28
|
+
* 启用同一个包。两个实例注册同名工具与同一路径 `/api/memory-core` 时,后装配的会抛
|
|
29
|
+
* `webserver: duplicate prefix route`,把整个 preset 拖成「加载失败」。新手很容易顺手
|
|
30
|
+
* 打开列表开关,所以这里退化成「先到先得」:后到的只写一条 warn,不注册任何东西。
|
|
31
|
+
*/
|
|
32
|
+
const INSTANCE = (globalThis[Symbol.for("dsh-memory-core.instance")] ??= { owner: null });
|
|
33
|
+
|
|
34
|
+
/** 仅供自测:重置单例槽位,让同一进程可以连续验证两个 apply。 */
|
|
35
|
+
export function __resetInstance() {
|
|
36
|
+
INSTANCE.owner = null;
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
const LOOPBACK = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
|
|
23
40
|
|
|
24
41
|
/** 同源守卫:挡 DNS 重绑定与跨站请求;返回空串表示放行 */
|
|
@@ -39,24 +56,74 @@ function sameOriginBlocked(req) {
|
|
|
39
56
|
return "";
|
|
40
57
|
}
|
|
41
58
|
|
|
59
|
+
/** 取当前会话的 agent。Cordis 的 ctx.<服务> 在未声明 inject 时会抛错,所以走 ctx.get(name, false) 探测;取不到返回 undefined。 */
|
|
60
|
+
function agentOf(ctx) {
|
|
61
|
+
for (const name of ["agent", "agents"]) {
|
|
62
|
+
try {
|
|
63
|
+
const a = ctx.get?.(name, false);
|
|
64
|
+
if (a?.session) return a;
|
|
65
|
+
} catch {}
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
42
70
|
function apply(ctx, config) {
|
|
43
71
|
const s = { ...DEFAULTS, ...(config ?? {}) };
|
|
44
72
|
const memoryDir = resolveMemoryDir(s);
|
|
45
73
|
if (!existsSync(memoryDir)) mkdirSync(memoryDir, { recursive: true });
|
|
46
74
|
|
|
75
|
+
/**
|
|
76
|
+
* 把设置页面板写入的覆盖层叠到当前运行配置上(对齐 evolve 的「修改立即生效」)。
|
|
77
|
+
* 每次工具调用、每轮注入、每次路由请求前都重读文件——面板写入后一拍即生效,
|
|
78
|
+
* 不需要重启;文件坏了就退回 patch 配置(readLiveConfig 内部吞掉解析错误)。
|
|
79
|
+
*/
|
|
80
|
+
const refreshLive = () => {
|
|
81
|
+
try {
|
|
82
|
+
Object.assign(s, readLiveConfig(memoryDir));
|
|
83
|
+
} catch {}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// 先到先得:第二个实例只登记、不注册,避免同名工具与重复路由
|
|
87
|
+
const primary = INSTANCE.owner === null;
|
|
88
|
+
if (primary) {
|
|
89
|
+
INSTANCE.owner = { memoryDir, presetName: s.presetName };
|
|
90
|
+
ctx.effect(
|
|
91
|
+
() => () => {
|
|
92
|
+
if (INSTANCE.owner?.memoryDir === memoryDir) INSTANCE.owner = null;
|
|
93
|
+
},
|
|
94
|
+
`${name}: release instance slot`,
|
|
95
|
+
);
|
|
96
|
+
} else {
|
|
97
|
+
ctx.logger.warn(
|
|
98
|
+
`${name}: 已有实例在运行(${INSTANCE.owner.memoryDir}),本实例(${memoryDir})只登记、不注册工具/注入/路由,` +
|
|
99
|
+
`避免同名工具与 /api/memory-core 路由冲突。若要换实例,请关掉插件列表开关或删掉 preset 里的挂载后重启。`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
47
103
|
const store = new MemoryStore(memoryDir, { injectionScan: true, memoryTracks: s.memoryTracks });
|
|
48
104
|
const pending = new SuggestionQueue(join(memoryDir, PENDING_FILE));
|
|
49
105
|
const archive = new ArchiveStore(memoryDir);
|
|
50
106
|
|
|
51
107
|
// ── memory_core 工具 ────────────────────────────────────────
|
|
52
|
-
const writeGap = writeGapCounter(ctx, () => s.
|
|
53
|
-
/**
|
|
54
|
-
|
|
108
|
+
const writeGap = writeGapCounter(ctx, () => s.perTurnWriteGuard === true);
|
|
109
|
+
/**
|
|
110
|
+
* 写入成功后清 gap,否则看门狗每轮都会提醒「没写记忆」。
|
|
111
|
+
* 只看 daily/project(对齐 evolve):写一条 memory/user/key 就清计数,
|
|
112
|
+
* 会让看门狗永远追不到真正漏写的日志。
|
|
113
|
+
*/
|
|
114
|
+
const markWrote = (target, agent) => {
|
|
115
|
+
// full 模式对齐 evolve:只看 daily/project,写 memory/user/key 不清计数。
|
|
116
|
+
// lite 模式没有这两轨可写,退回「任意轨写入即清零」,否则看门狗永远追不到终点。
|
|
117
|
+
if (s.memoryTracks === "full" && target !== "daily" && target !== "project") return;
|
|
55
118
|
try {
|
|
56
119
|
writeGap?.noteWrite?.(agent);
|
|
57
120
|
} catch {}
|
|
58
121
|
};
|
|
59
122
|
|
|
123
|
+
/** 子代理只能读,不能往常驻记忆轨写(对齐 evolve 的写入门禁)。 */
|
|
124
|
+
const isSubagent = (agent) => agent?.session?.header?.origin === "subagent";
|
|
125
|
+
const ARCHIVE_TRACKS = new Set(["memory", "user", "key"]);
|
|
126
|
+
|
|
60
127
|
const memoryTool = {
|
|
61
128
|
name: "memory_core",
|
|
62
129
|
description:
|
|
@@ -67,7 +134,14 @@ function apply(ctx, config) {
|
|
|
67
134
|
action: { type: "string", enum: ["add", "list", "replace", "remove", "archive"], description: "操作" },
|
|
68
135
|
target: { type: "string", enum: ["memory", "user", "key", "project", "daily"], description: "记忆轨" },
|
|
69
136
|
content: { type: "string", description: "新内容(add/replace 时必填)" },
|
|
70
|
-
match: { type: "string", description: "匹配子串(replace/remove/archive
|
|
137
|
+
match: { type: "string", description: "匹配子串(replace/remove/archive 时必填;命中多条会报错)" },
|
|
138
|
+
entries: { type: "array", items: { type: "string" }, description: "批量写入(仅 daily/project,一次调用写多条)" },
|
|
139
|
+
reason: { type: "string", description: "key 建议的理由(写进待确认队列,供确认时参考)" },
|
|
140
|
+
limit: { type: "integer", description: "list 返回条数(默认 50)" },
|
|
141
|
+
recent: { type: "boolean", description: "list 是否按时间倒序(默认 true)" },
|
|
142
|
+
filter: { type: "string", description: "list 关键词过滤" },
|
|
143
|
+
since: { type: "string", description: "list 起始日期 YYYY-MM-DD" },
|
|
144
|
+
until: { type: "string", description: "list 结束日期 YYYY-MM-DD" },
|
|
71
145
|
},
|
|
72
146
|
required: ["action", "target"],
|
|
73
147
|
},
|
|
@@ -81,48 +155,118 @@ function apply(ctx, config) {
|
|
|
81
155
|
target: { type: "string" },
|
|
82
156
|
entries: { type: "array", items: { type: "string" } },
|
|
83
157
|
count: { type: "integer" },
|
|
158
|
+
total: { type: "integer" },
|
|
84
159
|
queued: { type: "boolean" },
|
|
85
160
|
},
|
|
86
161
|
required: ["ok", "message"],
|
|
87
162
|
},
|
|
88
163
|
render: (_args, value) => [{ type: "text", text: value.message || (value.ok ? "ok" : "failed") }],
|
|
89
164
|
},
|
|
90
|
-
async execute(
|
|
165
|
+
async execute(args, exec) {
|
|
166
|
+
refreshLive();
|
|
167
|
+
const { action, target } = args;
|
|
168
|
+
const agent = exec?.agent;
|
|
91
169
|
try {
|
|
170
|
+
if (!tracksOf(s.memoryTracks).includes(target)) {
|
|
171
|
+
return { ok: false, message: `${target} 不在当前模式(${s.memoryTracks})的可用轨里`, target };
|
|
172
|
+
}
|
|
173
|
+
if (isSubagent(agent) && (action === "add" || action === "replace") && (target === "memory" || target === "user")) {
|
|
174
|
+
return { ok: false, message: "子代理不能写常驻记忆轨(memory/user),请在主会话里写", target };
|
|
175
|
+
}
|
|
92
176
|
if (action === "list") {
|
|
93
|
-
|
|
94
|
-
|
|
177
|
+
// 项目轨必须能定位到文件:定位不到要明确报错,
|
|
178
|
+
// 否则 query 会走 readRaw 的降级分支返回 0 条,被误读成「这个项目没有记忆」。
|
|
179
|
+
try {
|
|
180
|
+
store.pathOf(target, agent);
|
|
181
|
+
} catch (e) {
|
|
182
|
+
return { ok: false, message: e?.message ?? String(e), target };
|
|
183
|
+
}
|
|
184
|
+
const stats = {};
|
|
185
|
+
const rows = store.query(target, agent, {
|
|
186
|
+
filter: args.filter,
|
|
187
|
+
since: args.since,
|
|
188
|
+
until: args.until,
|
|
189
|
+
limit: Number.isInteger(args.limit) ? args.limit : 50,
|
|
190
|
+
recent: args.recent !== false,
|
|
191
|
+
}, stats);
|
|
192
|
+
const total = stats.total ?? rows.length;
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
message: rows.length < total
|
|
196
|
+
? `${target} 轨共 ${total} 条,按时间倒序返回 ${rows.length} 条(可用 filter/since/until/limit 收窄)`
|
|
197
|
+
: `${target} 轨 ${total} 条`,
|
|
198
|
+
target, entries: rows, count: rows.length, total,
|
|
199
|
+
};
|
|
95
200
|
}
|
|
96
201
|
if (action === "add") {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
return { ok:
|
|
202
|
+
const many = Array.isArray(args.entries) && args.entries.length > 0;
|
|
203
|
+
const list = many ? args.entries : (args.content ? [args.content] : []);
|
|
204
|
+
if (list.length === 0) return { ok: false, message: "add 需要 content(daily/project 可用 entries 批量)", target };
|
|
205
|
+
if (many && target !== "daily" && target !== "project") {
|
|
206
|
+
return { ok: false, message: "批量 entries 只支持 daily/project", target };
|
|
207
|
+
}
|
|
208
|
+
if (target === "key") {
|
|
209
|
+
const cwd = agent?.session?.header?.cwd ?? null;
|
|
210
|
+
let queuedCount = 0;
|
|
211
|
+
let duplicated = 0;
|
|
212
|
+
for (const text of list) {
|
|
213
|
+
const dup = pending.mutate((items) => {
|
|
214
|
+
const norm = String(text).trim();
|
|
215
|
+
const hit = items.findIndex((p) => p.target === target && String(p.content ?? "").trim() === norm);
|
|
216
|
+
if (hit >= 0) {
|
|
217
|
+
items[hit].hits = (items[hit].hits ?? 1) + 1;
|
|
218
|
+
items[hit].lastSeen = new Date().toISOString();
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
items.push({ target, content: text, at: new Date().toISOString(), cwd, reason: args.reason ?? null, hits: 1 });
|
|
222
|
+
return false;
|
|
223
|
+
});
|
|
224
|
+
if (dup) duplicated += 1; else queuedCount += 1;
|
|
225
|
+
}
|
|
226
|
+
return {
|
|
227
|
+
ok: true,
|
|
228
|
+
message: `已加入待确认队列 ${queuedCount} 条${duplicated ? `(去重 ${duplicated} 条)` : ""},用户确认后生效。`,
|
|
229
|
+
target, queued: true, count: queuedCount,
|
|
230
|
+
};
|
|
102
231
|
}
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
return { ok:
|
|
232
|
+
const results = list.map((text) => store.add(target, text, agent));
|
|
233
|
+
const failed = results.find((r) => r?.ok === false);
|
|
234
|
+
if (failed) return { ok: false, message: failed.message || `写入 ${target} 失败`, target };
|
|
235
|
+
markWrote(target, agent);
|
|
236
|
+
return { ok: true, message: `已写入 ${target} ${list.length} 条`, target, count: list.length };
|
|
106
237
|
}
|
|
107
238
|
if (action === "replace") {
|
|
108
|
-
if (!match || !content) return { ok: false, message: "replace 需要 match 和 content", target };
|
|
109
|
-
const
|
|
110
|
-
if (
|
|
111
|
-
|
|
239
|
+
if (!args.match || !args.content) return { ok: false, message: "replace 需要 match 和 content", target };
|
|
240
|
+
const peek = store.peek(target, args.match, agent);
|
|
241
|
+
if (peek?.ok === false) return { ok: false, message: peek.message || "没有找到要替换的条目", target };
|
|
242
|
+
const r = store.updateEntryContent(target, peek.entry, args.content, agent);
|
|
243
|
+
if (r?.ok === false) return { ok: false, message: r.message || "替换失败", target };
|
|
244
|
+
markWrote(target, agent);
|
|
245
|
+
return { ok: true, message: `已替换 ${target} 轨条目(保留原时间戳)`, target };
|
|
112
246
|
}
|
|
113
247
|
if (action === "remove") {
|
|
114
|
-
if (!match) return { ok: false, message: "remove 需要 match", target };
|
|
115
|
-
const
|
|
116
|
-
return { ok:
|
|
248
|
+
if (!args.match) return { ok: false, message: "remove 需要 match", target };
|
|
249
|
+
const peek = store.peek(target, args.match, agent);
|
|
250
|
+
if (peek?.ok === false) return { ok: false, message: peek.message, target };
|
|
251
|
+
const r = store.removeExact(target, peek.entry, agent);
|
|
252
|
+
if (r?.ok === false) return { ok: false, message: r.message, target };
|
|
253
|
+
return { ok: true, message: r.message || `已删除 ${target} 轨条目`, target };
|
|
117
254
|
}
|
|
118
255
|
if (action === "archive") {
|
|
119
|
-
if (!match) return { ok: false, message: "archive 需要 match", target };
|
|
120
|
-
|
|
121
|
-
|
|
256
|
+
if (!args.match) return { ok: false, message: "archive 需要 match", target };
|
|
257
|
+
if (!ARCHIVE_TRACKS.has(target)) return { ok: false, message: "archive 只支持 memory/user/key 三轨", target };
|
|
258
|
+
// 两步校验:先按子串定位唯一一条(模型常用子串),再按整条精确复核,
|
|
259
|
+
// 保证写进归档与从主轨删掉的是同一条原文,避免子串误伤。
|
|
260
|
+
const peek = store.peek(target, args.match, agent);
|
|
122
261
|
if (peek?.ok === false) return { ok: false, message: peek.message || "找不到要归档的条目", target };
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
262
|
+
const exact = store.peekExact(target, peek.entry, agent);
|
|
263
|
+
if (exact?.ok === false) return { ok: false, message: exact.message || "主轨里没有这条条目", target };
|
|
264
|
+
const cwd = agent?.session?.header?.cwd;
|
|
265
|
+
const a = archive.append(target, exact.entry, cwd);
|
|
266
|
+
if (a?.ok === false) return { ok: false, message: a.message || "写入归档失败", target };
|
|
267
|
+
const r = store.removeExact(target, exact.entry, agent);
|
|
268
|
+
if (r?.ok === false) return { ok: false, message: `已写入归档但删除主轨失败:${r.message}`, target };
|
|
269
|
+
return { ok: true, message: `已归档 ${target} 轨条目`, target };
|
|
126
270
|
}
|
|
127
271
|
return { ok: false, message: `未知 action: ${action}`, target };
|
|
128
272
|
} catch (e) {
|
|
@@ -130,21 +274,28 @@ function apply(ctx, config) {
|
|
|
130
274
|
}
|
|
131
275
|
},
|
|
132
276
|
};
|
|
133
|
-
ctx.effect(() => ctx.tools.register(memoryTool), `${name}: memory tool`);
|
|
277
|
+
if (primary) ctx.effect(() => ctx.tools.register(memoryTool), `${name}: memory tool`);
|
|
134
278
|
|
|
135
279
|
// ── memory-core:snapshot 注入 ──────────────────────────────
|
|
136
|
-
|
|
280
|
+
if (primary)
|
|
281
|
+
ctx.effect(
|
|
137
282
|
() =>
|
|
138
283
|
ctx.systemPrompt.context({
|
|
139
284
|
name: "memory-core:snapshot",
|
|
140
285
|
order: s.snapshotOrder,
|
|
141
|
-
text: () =>
|
|
286
|
+
text: (context) => {
|
|
287
|
+
refreshLive();
|
|
288
|
+
// 优先用宿主渲染上下文里的 agent(evolve 同口径):ctx.get("agent") 在 preset 作用域
|
|
289
|
+
// 常常拿不到带 cwd 的会话,full 模式下 key 会因此整轨静默不注入。
|
|
290
|
+
return renderSnapshot(store, s, writeGap, { agent: context?.agent ?? agentOf(ctx) });
|
|
291
|
+
},
|
|
142
292
|
}),
|
|
143
293
|
`${name}: memory-core:snapshot inject`,
|
|
144
294
|
);
|
|
145
295
|
|
|
146
296
|
// ── webServer API ───────────────────────────────────────────
|
|
147
|
-
|
|
297
|
+
if (primary)
|
|
298
|
+
ctx.effect(
|
|
148
299
|
() =>
|
|
149
300
|
ctx.webServer.register({
|
|
150
301
|
kind: "prefix",
|
|
@@ -160,7 +311,58 @@ function apply(ctx, config) {
|
|
|
160
311
|
json(403, { ok: false, why: denied });
|
|
161
312
|
return;
|
|
162
313
|
}
|
|
163
|
-
|
|
314
|
+
const agent = agentOf(ctx);
|
|
315
|
+
const cwd = agent?.session?.header?.cwd;
|
|
316
|
+
// 设置页面板改过的值每请求重读一次(与工具/注入同口径)
|
|
317
|
+
refreshLive();
|
|
318
|
+
|
|
319
|
+
// GET /config —— 当前生效配置 + 覆盖层文件位置(设置页「配置」页签)
|
|
320
|
+
if (req.method === "GET" && url.pathname === "/api/memory-core/config") {
|
|
321
|
+
json(200, {
|
|
322
|
+
ok: true,
|
|
323
|
+
data: {
|
|
324
|
+
memoryTracks: s.memoryTracks,
|
|
325
|
+
writeGuard: s.writeGuard === true,
|
|
326
|
+
perTurnWriteGuard: s.perTurnWriteGuard === true,
|
|
327
|
+
writeGuardThreshold: s.writeGuardThreshold,
|
|
328
|
+
snapshotOrder: s.snapshotOrder,
|
|
329
|
+
presetName: s.presetName,
|
|
330
|
+
layer: s.presetName === DEFAULTS.presetName ? "profile" : "preset",
|
|
331
|
+
livePath: liveConfigPath(memoryDir),
|
|
332
|
+
live: readLiveConfig(memoryDir),
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// POST /config —— 写覆盖层,下一拍生效(对齐 evolve 的「修改立即生效并持久化」)
|
|
339
|
+
if (req.method === "POST" && url.pathname === "/api/memory-core/config") {
|
|
340
|
+
try {
|
|
341
|
+
const body = await new Promise((resolve) => {
|
|
342
|
+
let raw = "";
|
|
343
|
+
req.on("data", (c) => (raw += c));
|
|
344
|
+
req.on("end", () => {
|
|
345
|
+
try { resolve(JSON.parse(raw || "{}")); } catch { resolve({}); }
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
const live = writeLiveConfig(memoryDir, body?.patch ?? body ?? {});
|
|
349
|
+
refreshLive();
|
|
350
|
+
json(200, {
|
|
351
|
+
ok: true,
|
|
352
|
+
data: {
|
|
353
|
+
live,
|
|
354
|
+
memoryTracks: s.memoryTracks,
|
|
355
|
+
writeGuard: s.writeGuard === true,
|
|
356
|
+
perTurnWriteGuard: s.perTurnWriteGuard === true,
|
|
357
|
+
writeGuardThreshold: s.writeGuardThreshold,
|
|
358
|
+
snapshotOrder: s.snapshotOrder,
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
} catch (e) {
|
|
362
|
+
json(500, { ok: false, why: e?.message ?? String(e) });
|
|
363
|
+
}
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
164
366
|
|
|
165
367
|
// GET /manage —— 返回 manage.html 页面
|
|
166
368
|
if (req.method === "GET" && url.pathname === "/api/memory-core/manage") {
|
|
@@ -183,6 +385,9 @@ function apply(ctx, config) {
|
|
|
183
385
|
memoryEnabled: true,
|
|
184
386
|
memoryTracks: s.memoryTracks,
|
|
185
387
|
presetName: s.presetName,
|
|
388
|
+
// layer=profile 说明它是被插件列表开关拉起来的(没在 preset 里挂):
|
|
389
|
+
// 所有会话都会多一份隔离记忆,还会和 evolve 双写,面板要黄条提示。
|
|
390
|
+
layer: s.presetName === DEFAULTS.presetName ? "profile" : "preset",
|
|
186
391
|
memoryDir,
|
|
187
392
|
tracks: tracksOf(s.memoryTracks),
|
|
188
393
|
},
|
|
@@ -190,13 +395,70 @@ function apply(ctx, config) {
|
|
|
190
395
|
return;
|
|
191
396
|
}
|
|
192
397
|
|
|
398
|
+
// GET /files —— 每轨的文件位置、条数、归档与待确认数(记忆管理页的页签用)
|
|
399
|
+
if (req.method === "GET" && url.pathname === "/api/memory-core/files") {
|
|
400
|
+
const qcwd = url.searchParams.get("cwd") || cwd;
|
|
401
|
+
const probe = qcwd ? { session: { header: { cwd: qcwd } } } : agent;
|
|
402
|
+
const labels = {
|
|
403
|
+
memory: "长期记忆 MEMORY.md",
|
|
404
|
+
user: "用户档案 USER.md",
|
|
405
|
+
project: "项目日志",
|
|
406
|
+
key: "项目关键记忆 KEY.md",
|
|
407
|
+
daily: "今日日志",
|
|
408
|
+
};
|
|
409
|
+
const rows = tracksOf(s.memoryTracks).map((track) => {
|
|
410
|
+
const row = {
|
|
411
|
+
track,
|
|
412
|
+
title: labels[track] ?? track,
|
|
413
|
+
available: true,
|
|
414
|
+
exists: false,
|
|
415
|
+
path: "",
|
|
416
|
+
count: 0,
|
|
417
|
+
archived: 0,
|
|
418
|
+
pending: 0,
|
|
419
|
+
};
|
|
420
|
+
try {
|
|
421
|
+
row.path = store.pathOf(track, probe);
|
|
422
|
+
row.exists = existsSync(row.path);
|
|
423
|
+
row.count = store.entriesOf(track, probe).length;
|
|
424
|
+
} catch {
|
|
425
|
+
row.available = false;
|
|
426
|
+
}
|
|
427
|
+
try {
|
|
428
|
+
row.archived = ARCHIVE_TRACKS.has(track) ? archive.entriesOf(track, qcwd).length : 0;
|
|
429
|
+
} catch {
|
|
430
|
+
row.archived = 0;
|
|
431
|
+
}
|
|
432
|
+
try {
|
|
433
|
+
row.pending = pending.read().filter((p) => p.target === track).length;
|
|
434
|
+
} catch {
|
|
435
|
+
row.pending = 0;
|
|
436
|
+
}
|
|
437
|
+
return row;
|
|
438
|
+
});
|
|
439
|
+
json(200, {
|
|
440
|
+
ok: true,
|
|
441
|
+
data: {
|
|
442
|
+
rows,
|
|
443
|
+
cwd: qcwd ?? null,
|
|
444
|
+
memoryDir,
|
|
445
|
+
memoryTracks: s.memoryTracks,
|
|
446
|
+
presetName: s.presetName,
|
|
447
|
+
},
|
|
448
|
+
});
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
193
452
|
// GET /entries?track=memory —— 列出某轨正式条目 + 待确认 + 归档
|
|
194
453
|
if (req.method === "GET" && url.pathname === "/api/memory-core/entries") {
|
|
195
454
|
const track = url.searchParams.get("track") || "memory";
|
|
455
|
+
const qcwd = url.searchParams.get("cwd") || cwd;
|
|
456
|
+
const probe = qcwd ? { session: { header: { cwd: qcwd } } } : agent;
|
|
196
457
|
try {
|
|
197
|
-
const entries = store.entriesOf(track,
|
|
458
|
+
const entries = store.entriesOf(track, probe);
|
|
198
459
|
const pendingItems = pending.read().filter((p) => p.target === track);
|
|
199
|
-
|
|
460
|
+
// 归档只有 memory/user/key 三轨;project/daily 没有归档文件,直接给空数组
|
|
461
|
+
const archived = ARCHIVE_TRACKS.has(track) ? archive.entriesOf(track, qcwd) : [];
|
|
200
462
|
json(200, { ok: true, data: { track, entries, pending: pendingItems, archived, count: entries.length } });
|
|
201
463
|
} catch (e) {
|
|
202
464
|
json(500, { ok: false, why: e?.message ?? String(e) });
|
|
@@ -204,6 +466,34 @@ function apply(ctx, config) {
|
|
|
204
466
|
return;
|
|
205
467
|
}
|
|
206
468
|
|
|
469
|
+
// POST /reveal —— 用系统资源管理器定位记忆文件(管理页「打开文件」)
|
|
470
|
+
if (req.method === "POST" && url.pathname === "/api/memory-core/reveal") {
|
|
471
|
+
const body = await new Promise((done) => {
|
|
472
|
+
let raw = "";
|
|
473
|
+
req.on("data", (c) => (raw += c));
|
|
474
|
+
req.on("end", () => {
|
|
475
|
+
try { done(JSON.parse(raw || "{}")); } catch { done({}); }
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
const target = resolve(String(body?.path ?? ""));
|
|
479
|
+
const root = resolve(memoryDir);
|
|
480
|
+
if (target !== root && !target.startsWith(root + sep)) {
|
|
481
|
+
json(400, { ok: false, why: "路径不在记忆目录内" });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
if (!existsSync(target)) {
|
|
485
|
+
json(404, { ok: false, why: "文件尚不存在" });
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
spawn("explorer.exe", ["/select," + target], { detached: true, stdio: "ignore" }).unref();
|
|
490
|
+
json(200, { ok: true });
|
|
491
|
+
} catch (e) {
|
|
492
|
+
json(500, { ok: false, why: e?.message ?? String(e) });
|
|
493
|
+
}
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
207
497
|
// POST /entry —— 所有写操作
|
|
208
498
|
if (req.method === "POST" && url.pathname === "/api/memory-core/entry") {
|
|
209
499
|
try {
|
|
@@ -214,62 +504,137 @@ function apply(ctx, config) {
|
|
|
214
504
|
try { resolve(JSON.parse(raw || "{}")); } catch { resolve({}); }
|
|
215
505
|
});
|
|
216
506
|
});
|
|
217
|
-
const { action, track, target, content, entry, match
|
|
507
|
+
const { action, track, target, content, entry, match } = body;
|
|
218
508
|
const t = track || target || "memory";
|
|
509
|
+
if (!tracksOf(s.memoryTracks).includes(t)) {
|
|
510
|
+
return json(400, { ok: false, why: `${t} 不在当前模式(${s.memoryTracks})的可用轨里` });
|
|
511
|
+
}
|
|
512
|
+
const ARCHIVE_ACTIONS = new Set(["archive", "promote", "removeArchive"]);
|
|
513
|
+
if (ARCHIVE_ACTIONS.has(action) && !ARCHIVE_TRACKS.has(t)) {
|
|
514
|
+
return json(400, { ok: false, why: "归档只支持 memory/user/key 三轨" });
|
|
515
|
+
}
|
|
516
|
+
// 管理页在别的会话里打开时,项目记忆与关键事实需要显式 cwd;
|
|
517
|
+
// 会话内调用不传 cwd,退回当前 agent。
|
|
518
|
+
const bcwd = typeof body.cwd === "string" && body.cwd.trim() ? body.cwd.trim() : cwd;
|
|
519
|
+
const actx = bcwd && bcwd !== cwd ? { session: { header: { cwd: bcwd } } } : agent;
|
|
520
|
+
const fail = (r, fallback) => json(400, { ok: false, why: r?.message || fallback });
|
|
521
|
+
// 面板传整条(精确命中优先);工具/脚本常传子串,退回唯一子串定位。
|
|
522
|
+
const resolveEntry = (t, raw, actx) => {
|
|
523
|
+
const key = String(raw ?? "").trim();
|
|
524
|
+
if (!key) return { ok: false, message: "条目内容为空" };
|
|
525
|
+
const exact = store.peekExact(t, key, actx);
|
|
526
|
+
if (exact?.ok !== false) return exact;
|
|
527
|
+
return store.peek(t, key, actx);
|
|
528
|
+
};
|
|
529
|
+
const resolveArchived = (t, raw, cwd) => {
|
|
530
|
+
const key = String(raw ?? "").trim();
|
|
531
|
+
if (!key) return { ok: false, message: "条目内容为空" };
|
|
532
|
+
const all = archive.entriesOf(t, cwd) ?? [];
|
|
533
|
+
const exact = all.find((e) => e.trim() === key);
|
|
534
|
+
if (exact) return { ok: true, entry: exact };
|
|
535
|
+
const hits = all.filter((e) => e.includes(key));
|
|
536
|
+
if (hits.length === 1) return { ok: true, entry: hits[0] };
|
|
537
|
+
if (hits.length === 0) return { ok: false, message: `归档中没有包含「${key}」的条目` };
|
|
538
|
+
return { ok: false, message: `「${key}」在归档里命中 ${hits.length} 条,无法确定是哪一条` };
|
|
539
|
+
};
|
|
540
|
+
// 面板会带 target(当前页签);不带的调用(工具/脚本)退回只按内容匹配。
|
|
541
|
+
const explicitTarget = track || target || null;
|
|
219
542
|
|
|
220
543
|
if (action === "add") {
|
|
221
|
-
if (t === "key"
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
544
|
+
if (t === "key") {
|
|
545
|
+
const dup = pending.mutate((items) => {
|
|
546
|
+
const norm = String(content ?? "").trim();
|
|
547
|
+
const hit = items.findIndex((p) => p.target === t && String(p.content ?? "").trim() === norm);
|
|
548
|
+
if (hit >= 0) {
|
|
549
|
+
items[hit].hits = (items[hit].hits ?? 1) + 1;
|
|
550
|
+
items[hit].lastSeen = new Date().toISOString();
|
|
551
|
+
return true;
|
|
552
|
+
}
|
|
553
|
+
items.push({ target: t, content, at: new Date().toISOString(), cwd: bcwd ?? null, reason: body.reason ?? null, hits: 1 });
|
|
554
|
+
return false;
|
|
555
|
+
});
|
|
556
|
+
return json(200, { ok: true, data: { action, target: t, queued: true, duplicated: !!dup } });
|
|
226
557
|
}
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
558
|
+
const r = store.add(t, content, actx);
|
|
559
|
+
if (r?.ok === false) return fail(r, `写入 ${t} 失败`);
|
|
560
|
+
markWrote(t, agent);
|
|
561
|
+
return json(200, { ok: true, data: { action, target: t } });
|
|
562
|
+
}
|
|
563
|
+
if (action === "replace" || action === "edit" || action === "saveEdit") {
|
|
564
|
+
const src = String(match || entry || "").trim();
|
|
565
|
+
const peek = resolveEntry(t, src, actx);
|
|
566
|
+
if (peek?.ok === false) return fail(peek, "找不到要编辑的条目");
|
|
567
|
+
const r = store.updateEntryContent(t, peek.entry, content, actx);
|
|
568
|
+
if (r?.ok === false) return fail(r, "编辑失败");
|
|
569
|
+
markWrote(t, agent);
|
|
570
|
+
return json(200, { ok: true, data: { action, target: t } });
|
|
571
|
+
}
|
|
572
|
+
if (action === "delete" || action === "remove") {
|
|
573
|
+
const src = String(match || entry || "").trim();
|
|
574
|
+
const peek = resolveEntry(t, src, actx);
|
|
575
|
+
if (peek?.ok === false) return fail(peek, "找不到要删除的条目");
|
|
576
|
+
const r = store.removeExact(t, peek.entry, actx);
|
|
577
|
+
if (r?.ok === false) return fail(r, "删除失败");
|
|
578
|
+
return json(200, { ok: true, data: { action, target: t } });
|
|
579
|
+
}
|
|
580
|
+
if (action === "archive") {
|
|
581
|
+
const src = String(entry || match || "").trim();
|
|
582
|
+
const peek = resolveEntry(t, src, actx);
|
|
583
|
+
if (peek?.ok === false) return fail(peek, "找不到要归档的条目");
|
|
584
|
+
const a = archive.append(t, peek.entry, bcwd);
|
|
585
|
+
if (a?.ok === false) return fail(a, "写入归档失败");
|
|
586
|
+
const r = store.removeExact(t, peek.entry, actx);
|
|
587
|
+
if (r?.ok === false) return json(500, { ok: false, why: `已写入归档但删除主轨失败:${r.message}` });
|
|
588
|
+
return json(200, { ok: true, data: { action, target: t } });
|
|
589
|
+
}
|
|
590
|
+
if (action === "confirm") {
|
|
239
591
|
const want = String(entry ?? "").trim();
|
|
240
|
-
const idx = items.findIndex((p) => String(p.content ?? "").trim() === want);
|
|
241
|
-
if (idx >= 0) {
|
|
242
|
-
const r = store.add(items[idx].target, items[idx].content, ctx.agent);
|
|
243
|
-
if (r?.ok !== false) {
|
|
244
|
-
items.splice(idx, 1);
|
|
245
|
-
pending.write(items);
|
|
246
|
-
markWrote(ctx.agent);
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
} else if (action === "reject") {
|
|
250
|
-
// 待确认 → 删除;同样按内容匹配
|
|
251
592
|
const items = pending.read();
|
|
252
|
-
const want = String(entry ?? "").trim();
|
|
253
593
|
const idx = items.findIndex(
|
|
254
|
-
(p) => p.target === t && String(p.content ?? "").trim() === want,
|
|
594
|
+
(p) => (!explicitTarget || p.target === t) && String(p.content ?? "").trim() === want,
|
|
255
595
|
);
|
|
256
|
-
if (idx
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
596
|
+
if (idx < 0) return json(404, { ok: false, why: "待确认队列里没有这条" });
|
|
597
|
+
const item = items[idx];
|
|
598
|
+
const itemCtx = item.cwd ? { session: { header: { cwd: item.cwd } } } : actx;
|
|
599
|
+
const r = store.add(item.target, item.content, itemCtx);
|
|
600
|
+
if (r?.ok === false) return fail(r, "写入失败,建议条目保留在队列里");
|
|
601
|
+
pending.write(items.filter((_, i) => i !== idx));
|
|
602
|
+
markWrote(item.target, agent);
|
|
603
|
+
return json(200, { ok: true, data: { action, target: t } });
|
|
604
|
+
}
|
|
605
|
+
if (action === "reject") {
|
|
606
|
+
const want = String(entry ?? "").trim();
|
|
607
|
+
const before = pending.read();
|
|
608
|
+
const after = before.filter(
|
|
609
|
+
(p) => !((!explicitTarget || p.target === t) && String(p.content ?? "").trim() === want),
|
|
610
|
+
);
|
|
611
|
+
if (after.length === before.length) return json(404, { ok: false, why: "待确认队列里没有这条" });
|
|
612
|
+
pending.write(after);
|
|
613
|
+
return json(200, { ok: true, data: { action, target: t } });
|
|
614
|
+
}
|
|
615
|
+
if (action === "promote") {
|
|
616
|
+
const pick = resolveArchived(t, entry, bcwd);
|
|
617
|
+
if (pick?.ok === false) return fail(pick, "归档里没有这条");
|
|
618
|
+
const src = pick.entry;
|
|
619
|
+
// 转正前剥掉归档时附带的理由行与原轨行,否则主轨会被污染
|
|
620
|
+
const clean = src
|
|
621
|
+
.replace(/\n?(归档理由:[^)]*)/g, "")
|
|
622
|
+
.replace(/\n?(原轨:[^)]*)/g, "")
|
|
623
|
+
.trim();
|
|
624
|
+
const r = store.add(t, clean, actx);
|
|
625
|
+
if (r?.ok === false) return fail(r, "写回主轨失败");
|
|
626
|
+
const rm = archive.removeExact(t, src, bcwd);
|
|
627
|
+
markWrote(t, agent);
|
|
628
|
+
return json(200, { ok: true, data: { action, target: t, archiveRemoved: rm?.ok === true } });
|
|
629
|
+
}
|
|
630
|
+
if (action === "removeArchive") {
|
|
631
|
+
const pick = resolveArchived(t, entry, bcwd);
|
|
632
|
+
if (pick?.ok === false) return fail(pick, "归档里没有这条");
|
|
633
|
+
const r = archive.removeExact(t, pick.entry, bcwd);
|
|
634
|
+
if (r?.ok === false) return fail(r, "归档里没有这条");
|
|
635
|
+
return json(200, { ok: true, data: { action, target: t } });
|
|
271
636
|
}
|
|
272
|
-
json(
|
|
637
|
+
return json(400, { ok: false, why: "unknown action: " + action });
|
|
273
638
|
} catch (e) {
|
|
274
639
|
json(500, { ok: false, why: e?.message ?? String(e) });
|
|
275
640
|
}
|