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/lib/defaults.js
CHANGED
|
@@ -5,10 +5,11 @@ import path from "node:path";
|
|
|
5
5
|
import os from "node:os";
|
|
6
6
|
|
|
7
7
|
export const DEFAULTS = {
|
|
8
|
-
memoryTracks: "
|
|
8
|
+
memoryTracks: "full", // "full" = 五轨 memory/user/project/key/daily(默认);"lite" = 三轨 memory/user/key
|
|
9
9
|
presetName: "default",
|
|
10
10
|
memoryDir: "",
|
|
11
|
-
writeGuard: true,
|
|
11
|
+
writeGuard: true, // key 轨写入后进待确认队列(语义闸门,不控制看门狗)
|
|
12
|
+
perTurnWriteGuard: false, // 看门狗:连续 N 轮没写记忆时在快照末尾提醒(独立开关,对齐 evolve)
|
|
12
13
|
writeGuardThreshold: 2,
|
|
13
14
|
snapshotOrder: 500,
|
|
14
15
|
};
|
package/lib/render.js
CHANGED
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 注入层:记忆注入渲染、预算分配、看门狗提醒拼接。
|
|
3
|
+
*
|
|
4
|
+
* 形制对齐 dsh-memory-evolve:
|
|
5
|
+
* - 每轨一个 `## 标签`,其下逐条 `- ` 开头,标题与正文一一对应(不再把所有正文拼在末尾);
|
|
6
|
+
* - key 只注入一次(不再「循环注一份 + 额外补一段」);
|
|
7
|
+
* - 写引导与看门狗文案按当前启用的轨生成,full 模式必须指向 daily/project,否则那两轨永远是死档。
|
|
3
8
|
*/
|
|
4
9
|
import { ENTRY_DELIMITER } from "./store.js";
|
|
5
|
-
import { TRACK_LABELS } from "./tracks.js";
|
|
6
|
-
import { BUDGETS, TOTAL_BUDGET, KEY_TOP_N, KEY_SCAN_N,
|
|
10
|
+
import { TRACK_LABELS, tracksOf } from "./tracks.js";
|
|
11
|
+
import { BUDGETS, TOTAL_BUDGET, KEY_TOP_N, KEY_SCAN_N, KEY_HOT_WORDS } from "./budgets.js";
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
13
|
+
/** 写引导:按启用轨给出收尾动作,full 模式强调 daily + project 一次批量写。 */
|
|
14
|
+
export function writeGuideFor(memoryTracks) {
|
|
15
|
+
const tracks = tracksOf(memoryTracks);
|
|
16
|
+
const hasProject = tracks.includes("project");
|
|
17
|
+
const hasDaily = tracks.includes("daily");
|
|
18
|
+
if (hasProject && hasDaily) {
|
|
19
|
+
return (
|
|
20
|
+
"## 每轮收尾\n" +
|
|
21
|
+
"用 memory_core 工具写一条:一次调用写 daily(今天做了什么)与 project(本项目的进展/决策/坑)," +
|
|
22
|
+
"重要事实写 `key`,用户偏好写 `user`,长期记忆写 `memory`。"
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
return "## 每轮收尾\n用 memory_core 工具写一条:重要事实写 `key`,用户偏好写 `user`,长期记忆写 `memory`。";
|
|
26
|
+
}
|
|
10
27
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
28
|
+
/** 看门狗提醒:说明盯的是哪几轨、阈值是多少,并指向补写。 */
|
|
29
|
+
export function writeGuardNoticeFor(memoryTracks, threshold) {
|
|
30
|
+
const tracks = tracksOf(memoryTracks);
|
|
31
|
+
const duty = tracks.includes("daily") ? "daily / project" : "memory / user / key";
|
|
32
|
+
return (
|
|
33
|
+
`## 提醒\n连续 ${threshold} 轮没写 ${duty} 记忆了。收尾时用 memory_core 工具写一条,把这几轮的进展补上。`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
14
36
|
|
|
15
|
-
/**
|
|
37
|
+
/** 截断后清理开头的孤立分隔符与残行。 */
|
|
16
38
|
function trimCutHead(text) {
|
|
17
39
|
let out = text.replace(/^[ \t]*\n+/, "");
|
|
18
40
|
while (/^§[ \t]*(\n|$)/.test(out)) out = out.replace(/^§[ \t]*\n?/, "").replace(/^[ \t]*\n+/, "");
|
|
@@ -36,59 +58,64 @@ function pickKeyEntries(store, agent) {
|
|
|
36
58
|
return weighted.map((row) => row.entry);
|
|
37
59
|
}
|
|
38
60
|
|
|
39
|
-
/**
|
|
61
|
+
/** 把条目渲染成 `- 首行` + 续行缩进两格,避免多行条目在列表里断掉层级。 */
|
|
62
|
+
function asBullet(entry) {
|
|
63
|
+
const lines = String(entry).split("\n");
|
|
64
|
+
return "- " + lines[0] + lines.slice(1).map((l) => "\n " + l).join("");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 渲染常驻记忆注入:按 memory > user > key 顺序分配预算,每轨一个块。
|
|
69
|
+
* 只注入 memory/user/key 三轨;project/daily 是「按需读取」的轨,不进上下文。
|
|
70
|
+
*/
|
|
40
71
|
export function renderResident(store, agent, tracks) {
|
|
41
72
|
const order = tracks ?? ["memory", "user", "key"];
|
|
42
73
|
const parts = [];
|
|
43
74
|
let used = 0;
|
|
44
75
|
for (const track of order) {
|
|
45
76
|
if (used >= TOTAL_BUDGET) break;
|
|
46
|
-
const budget = Math.min(BUDGETS[track], TOTAL_BUDGET - used);
|
|
47
|
-
if (budget
|
|
48
|
-
const entries = store.entriesOf(track, agent);
|
|
77
|
+
const budget = Math.min(BUDGETS[track] ?? 0, TOTAL_BUDGET - used);
|
|
78
|
+
if (!(budget > 0)) continue;
|
|
79
|
+
const entries = track === "key" ? pickKeyEntries(store, agent) : store.entriesOf(track, agent);
|
|
49
80
|
if (entries.length === 0) continue;
|
|
50
|
-
let text = entries.join(
|
|
81
|
+
let text = entries.map(asBullet).join("\n");
|
|
51
82
|
if (text.length > budget) {
|
|
52
83
|
text = text.slice(text.length - budget);
|
|
53
84
|
const nl = text.indexOf("\n");
|
|
54
85
|
if (nl >= 0) text = text.slice(nl + 1);
|
|
55
86
|
text = trimCutHead(text);
|
|
56
87
|
}
|
|
88
|
+
if (text.length === 0) continue;
|
|
57
89
|
parts.push({ track, text, chars: text.length });
|
|
58
90
|
used += text.length;
|
|
59
91
|
}
|
|
60
|
-
const keyBudget = Math.min(KEY_EXTRA_CAP, TOTAL_BUDGET - used);
|
|
61
|
-
if (keyBudget > 0) {
|
|
62
|
-
const keyEntries = pickKeyEntries(store, agent);
|
|
63
|
-
let keyText = keyEntries.join(ENTRY_DELIMITER);
|
|
64
|
-
if (keyText.length > keyBudget) {
|
|
65
|
-
keyText = keyText.slice(keyText.length - keyBudget);
|
|
66
|
-
const nl = keyText.indexOf("\n");
|
|
67
|
-
if (nl >= 0) keyText = keyText.slice(nl + 1);
|
|
68
|
-
keyText = trimCutHead(keyText);
|
|
69
|
-
}
|
|
70
|
-
if (keyText.length > 0) {
|
|
71
|
-
parts.push({ track: "key", text: keyText, chars: keyText.length });
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
92
|
return parts;
|
|
75
93
|
}
|
|
76
94
|
|
|
77
|
-
/**
|
|
95
|
+
/**
|
|
96
|
+
* 快照净化(对齐 evolve issue #53):宿主把段正文当模板解析,正文里出现未注册的 `{{name}}`
|
|
97
|
+
* 会让整段渲染抛错,该会话每一步都起不来且无法用工具自救。记忆正文由模型/用户写入,
|
|
98
|
+
* 天然可能出现 `{{...}}`,所以出口必须只降级、不展开:`{{session}}` → `{session}`。
|
|
99
|
+
*/
|
|
100
|
+
export function sanitizeSnapshot(content) {
|
|
101
|
+
return String(content ?? "")
|
|
102
|
+
.replace(/\{\{\s*([\w.-]+)\s*\}\}/g, "{$1}")
|
|
103
|
+
.replace(/\{\{/g, "{");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 渲染 memory-core:snapshot 注入文本:常驻记忆 + 恒引导 + 条件看门狗提醒。 */
|
|
78
107
|
export function renderSnapshot(store, s, writeGap, context) {
|
|
79
108
|
const agent = context?.agent;
|
|
80
|
-
const parts = renderResident(store, agent,
|
|
81
|
-
? ["memory", "user", "project", "key", "daily"]
|
|
82
|
-
: ["memory", "user", "key"]);
|
|
109
|
+
const parts = renderResident(store, agent, ["memory", "user", "key"]);
|
|
83
110
|
const body = parts.length === 0
|
|
84
111
|
? ""
|
|
85
|
-
:
|
|
112
|
+
: parts.map((p) => `## ${TRACK_LABELS[p.track]}\n${p.text}`).join("\n\n");
|
|
86
113
|
const due =
|
|
87
|
-
s.
|
|
114
|
+
s.perTurnWriteGuard === true &&
|
|
88
115
|
writeGap !== null &&
|
|
89
116
|
agent !== undefined &&
|
|
90
117
|
agent.session?.header?.origin !== "subagent" &&
|
|
91
118
|
writeGap.gapOf(agent) >= s.writeGuardThreshold;
|
|
92
|
-
const notice = due ?
|
|
93
|
-
return [body,
|
|
119
|
+
const notice = due ? writeGuardNoticeFor(s.memoryTracks, s.writeGuardThreshold) : "";
|
|
120
|
+
return sanitizeSnapshot([body, writeGuideFor(s.memoryTracks), notice].filter(Boolean).join("\n\n"));
|
|
94
121
|
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 运行期配置:设置页面板改的值单独存一份覆盖层。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不直接改 preset:preset 的 config 是启动装配时的输入,改了要重启才生效;
|
|
5
|
+
* 而设置页的语义是「修改立即生效并持久化」(对齐 dsh-memory-evolve 的设置页)。
|
|
6
|
+
* 所以落一份 `<memoryDir>/../memory-core.config.json`,apply 时以 patch 配置为底,
|
|
7
|
+
* 每轮/每次调用前再叠一层这里(见 index.js 的 refresh)。
|
|
8
|
+
*/
|
|
9
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
|
|
12
|
+
/** 允许从文件覆盖的键。其余键(presetName / memoryDir)只认 patch,避免面板改坏落点。 */
|
|
13
|
+
export const LIVE_KEYS = ["memoryTracks", "writeGuard", "perTurnWriteGuard", "writeGuardThreshold", "snapshotOrder"];
|
|
14
|
+
|
|
15
|
+
export function liveConfigPath(memoryDir) {
|
|
16
|
+
return join(dirname(memoryDir), "memory-core.config.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 只留下合法值:类型不对的键直接丢弃,不写坏当前运行态。 */
|
|
20
|
+
function pick(raw) {
|
|
21
|
+
const out = {};
|
|
22
|
+
const src = raw && typeof raw === "object" ? raw : {};
|
|
23
|
+
if (src.memoryTracks === "lite" || src.memoryTracks === "full") out.memoryTracks = src.memoryTracks;
|
|
24
|
+
if (typeof src.writeGuard === "boolean") out.writeGuard = src.writeGuard;
|
|
25
|
+
if (typeof src.perTurnWriteGuard === "boolean") out.perTurnWriteGuard = src.perTurnWriteGuard;
|
|
26
|
+
if (Number.isInteger(src.writeGuardThreshold) && src.writeGuardThreshold >= 1) {
|
|
27
|
+
out.writeGuardThreshold = src.writeGuardThreshold;
|
|
28
|
+
}
|
|
29
|
+
if (Number.isInteger(src.snapshotOrder)) out.snapshotOrder = src.snapshotOrder;
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readLiveConfig(memoryDir) {
|
|
34
|
+
try {
|
|
35
|
+
return pick(JSON.parse(readFileSync(liveConfigPath(memoryDir), "utf8")));
|
|
36
|
+
} catch {
|
|
37
|
+
return {};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function writeLiveConfig(memoryDir, patch) {
|
|
42
|
+
const next = { ...readLiveConfig(memoryDir), ...pick(patch) };
|
|
43
|
+
writeFileSync(liveConfigPath(memoryDir), JSON.stringify(next, null, 2) + "\n", "utf8");
|
|
44
|
+
return next;
|
|
45
|
+
}
|
package/lib/schema.js
CHANGED
|
@@ -9,6 +9,7 @@ export const Config = z.object({
|
|
|
9
9
|
presetName: z.string().default(DEFAULTS.presetName),
|
|
10
10
|
memoryDir: z.string().default(DEFAULTS.memoryDir),
|
|
11
11
|
writeGuard: z.boolean().default(DEFAULTS.writeGuard),
|
|
12
|
+
perTurnWriteGuard: z.boolean().default(DEFAULTS.perTurnWriteGuard),
|
|
12
13
|
writeGuardThreshold: z.number().default(DEFAULTS.writeGuardThreshold),
|
|
13
14
|
snapshotOrder: z.number().default(DEFAULTS.snapshotOrder),
|
|
14
15
|
});
|
package/lib/store.js
CHANGED
|
@@ -525,7 +525,11 @@ export class MemoryStore {
|
|
|
525
525
|
|
|
526
526
|
/** Read the raw file; a missing file reads as an empty store. */
|
|
527
527
|
readRaw(target, agent) {
|
|
528
|
-
|
|
528
|
+
// project / full 模式的 key 需要会话工作目录:没有 cwd 时读路径降级为空,
|
|
529
|
+
// 不抛错——注入段和面板都会读这些轨,抛一次就把整段注入带崩。写入仍走 pathOf(会明确报错)。
|
|
530
|
+
const loc = this.locate(target, agent)
|
|
531
|
+
if (!loc) return { text: '', size: 0 }
|
|
532
|
+
const path = join(loc.dir, loc.file)
|
|
529
533
|
try {
|
|
530
534
|
return { text: readFileSync(path, 'utf8'), size: statSync(path).size }
|
|
531
535
|
} catch (error) {
|