dsh-memory-fish 0.0.0-stage → 0.7.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/CHANGELOG.md +99 -0
- package/DESIGN.md +226 -0
- package/LICENSE +21 -0
- package/README.en.md +121 -0
- package/README.md +169 -2
- package/SECURITY.md +61 -0
- package/client/client.js +201 -0
- package/cordis.patch.yml +5 -0
- package/icon.svg +43 -0
- package/index.mjs +101 -0
- package/lib/config.mjs +44 -0
- package/lib/core.mjs +104 -0
- package/lib/dream.mjs +162 -0
- package/lib/inject.mjs +83 -0
- package/lib/lock.mjs +41 -0
- package/lib/log.mjs +44 -0
- package/lib/panel.mjs +215 -0
- package/lib/store.mjs +264 -0
- package/lib/tools.mjs +398 -0
- package/lib/util.mjs +156 -0
- package/package.json +77 -4
package/SECURITY.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Trust model
|
|
4
|
+
|
|
5
|
+
`dsh-memory-fish` is a **local-first** plugin. It has **no network access** — no
|
|
6
|
+
telemetry, no remote sync, no embedding API. All data lives under
|
|
7
|
+
`~/.dsh/memory-fish/` (or the configured `dir`).
|
|
8
|
+
|
|
9
|
+
The plugin runs inside the DeepSeek Harness host process, so it inherits the
|
|
10
|
+
host's privileges. It can read and write the memory directory and register
|
|
11
|
+
tools and local HTTP routes.
|
|
12
|
+
|
|
13
|
+
## What is stored
|
|
14
|
+
|
|
15
|
+
| Path | Content | Notes |
|
|
16
|
+
|---|---|---|
|
|
17
|
+
| `profile.md` | L0 profile shown to the model every session | user-editable |
|
|
18
|
+
| `memories/*.md` | durable memories | plain Markdown |
|
|
19
|
+
| `archive/*.md` | superseded/deleted versions | kept for audit |
|
|
20
|
+
| `journal/*.jsonl` | raw conversation capture (optional) | **secrets are redacted before write** |
|
|
21
|
+
| `dream/*.md` | nightly consolidation reports | |
|
|
22
|
+
| `index.json`, `stats.json` | metadata | |
|
|
23
|
+
|
|
24
|
+
`autoCapture` is on by default. It stores user/assistant message text (up to
|
|
25
|
+
`captureMaxChars` per message) to `journal/`. The text is passed through a
|
|
26
|
+
secret filter (`sk-…`, `api_key=…`, `password=…`, bearer tokens, PEM private
|
|
27
|
+
keys) before it hits disk, but the filter is best-effort — do not paste
|
|
28
|
+
credentials into chat.
|
|
29
|
+
|
|
30
|
+
Set `autoCapture: false` and delete `journal/` if you do not want any raw
|
|
31
|
+
conversation history on disk.
|
|
32
|
+
|
|
33
|
+
## Local panel
|
|
34
|
+
|
|
35
|
+
The optional HTTP panel is served by the DSH host web server on
|
|
36
|
+
`/memory-fish/panel`. It enforces:
|
|
37
|
+
|
|
38
|
+
- `Origin` vs `Host` match (blocks cross-site requests)
|
|
39
|
+
- `Sec-Fetch-Site` must be `same-origin` or `none`
|
|
40
|
+
- `Host` allow-list (localhost / loopback IPs only)
|
|
41
|
+
- method policing per route
|
|
42
|
+
|
|
43
|
+
It has **no authentication beyond that**: any local process that can open a
|
|
44
|
+
connection to the host port can read and modify memories. Run DSH on a trusted
|
|
45
|
+
machine and do not expose the port to the network.
|
|
46
|
+
|
|
47
|
+
## Multi-instance writes
|
|
48
|
+
|
|
49
|
+
Mutating operations take a lock file (`<dir>/.index.lock`) so two DSH instances
|
|
50
|
+
do not corrupt `index.json`. Stale locks (older than 15 s) are broken
|
|
51
|
+
automatically; a 5 s timeout raises an error instead of hanging. Mixing this
|
|
52
|
+
plugin with other tools that write `index.json` directly is not supported.
|
|
53
|
+
|
|
54
|
+
## Reporting a vulnerability
|
|
55
|
+
|
|
56
|
+
Open a private security advisory on the repository, or email the maintainer.
|
|
57
|
+
Please do not file a public issue for an exploitable problem.
|
|
58
|
+
|
|
59
|
+
## Supported versions
|
|
60
|
+
|
|
61
|
+
Only the latest minor release receives fixes.
|
package/client/client.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "dsh-memory-fish",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
const react = require("react");
|
|
8
|
+
const h = react.createElement;
|
|
9
|
+
|
|
10
|
+
/** 与宿主同源的面板 API(host 插件注册的路由)。 */
|
|
11
|
+
const API = "/memory-fish/api";
|
|
12
|
+
|
|
13
|
+
async function api(path, body) {
|
|
14
|
+
const res = await fetch(`${API}${path}`, {
|
|
15
|
+
method: body === undefined ? "GET" : "POST",
|
|
16
|
+
headers: body === undefined ? {} : { "content-type": "application/json" },
|
|
17
|
+
body: body === undefined ? undefined : JSON.stringify(body)
|
|
18
|
+
});
|
|
19
|
+
return await res.json();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const S = {
|
|
23
|
+
wrap: { padding: "12px 4px", display: "flex", flexDirection: "column", gap: "14px", color: "var(--fg-primary, inherit)" },
|
|
24
|
+
cards: { display: "grid", gridTemplateColumns: "repeat(auto-fit,minmax(120px,1fr))", gap: "8px" },
|
|
25
|
+
card: { border: "1px solid var(--border-default, #2a3547)", borderRadius: "8px", padding: "8px 10px" },
|
|
26
|
+
k: { fontSize: "11px", opacity: 0.65 },
|
|
27
|
+
v: { fontSize: "18px", fontWeight: 600, marginTop: "2px" },
|
|
28
|
+
bar: { height: "6px", background: "rgba(127,127,127,.2)", borderRadius: "3px", overflow: "hidden", marginTop: "6px" },
|
|
29
|
+
barFill: { height: "100%", background: "var(--accent-primary, #4cc2ff)" },
|
|
30
|
+
section: { border: "1px solid var(--border-default, #2a3547)", borderRadius: "8px", padding: "10px" },
|
|
31
|
+
h2: { fontSize: "13px", fontWeight: 600, margin: "0 0 8px", opacity: 0.9 },
|
|
32
|
+
row: { display: "flex", gap: "8px", alignItems: "center", flexWrap: "wrap" },
|
|
33
|
+
input: { flex: 1, minWidth: "160px", padding: "5px 8px", borderRadius: "6px", border: "1px solid var(--border-default, #2a3547)", background: "transparent", color: "inherit", fontSize: "13px" },
|
|
34
|
+
textarea: { width: "100%", minHeight: "74px", padding: "6px 8px", borderRadius: "6px", border: "1px solid var(--border-default, #2a3547)", background: "transparent", color: "inherit", fontSize: "13px", fontFamily: "inherit", resize: "vertical" },
|
|
35
|
+
btn: { padding: "4px 10px", borderRadius: "6px", border: "1px solid var(--border-default, #2a3547)", background: "transparent", color: "inherit", cursor: "pointer", fontSize: "12px" },
|
|
36
|
+
btnPrimary: { padding: "4px 10px", borderRadius: "6px", border: "1px solid var(--accent-primary, #4cc2ff)", background: "var(--accent-primary, #4cc2ff)", color: "#04121c", cursor: "pointer", fontSize: "12px", fontWeight: 600 },
|
|
37
|
+
mem: { display: "flex", gap: "8px", alignItems: "flex-start", padding: "6px 0", borderBottom: "1px solid rgba(127,127,127,.18)" },
|
|
38
|
+
memMain: { flex: 1, minWidth: 0 },
|
|
39
|
+
title: { fontSize: "13px", fontWeight: 600 },
|
|
40
|
+
meta: { fontSize: "11px", opacity: 0.6, marginTop: "2px" },
|
|
41
|
+
tag: { display: "inline-block", marginLeft: "5px", padding: "0 5px", borderRadius: "4px", background: "rgba(76,194,255,.14)", color: "var(--accent-primary, #4cc2ff)", fontSize: "10px" },
|
|
42
|
+
pre: { whiteSpace: "pre-wrap", fontSize: "11px", lineHeight: 1.5, maxHeight: "220px", overflow: "auto", margin: "8px 0 0", opacity: 0.85 },
|
|
43
|
+
muted: { fontSize: "11px", opacity: 0.6 }
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const name = "dsh-memory-fish";
|
|
47
|
+
const inject = ["slots"];
|
|
48
|
+
|
|
49
|
+
function apply(ctx) {
|
|
50
|
+
ctx.slots.inject("settings.section", () => ctx.slots.register({
|
|
51
|
+
name: "settings.section",
|
|
52
|
+
id: "dsh-memory-fish",
|
|
53
|
+
order: 75,
|
|
54
|
+
label: () => "小肥鱼记忆"
|
|
55
|
+
}, () => h(MemoryPanel)));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function MemoryPanel() {
|
|
59
|
+
const [data, setData] = react.useState(null);
|
|
60
|
+
const [error, setError] = react.useState("");
|
|
61
|
+
const [query, setQuery] = react.useState("");
|
|
62
|
+
const [results, setResults] = react.useState(null);
|
|
63
|
+
const [profileDraft, setProfileDraft] = react.useState(null);
|
|
64
|
+
const [busy, setBusy] = react.useState("");
|
|
65
|
+
const [notice, setNotice] = react.useState("");
|
|
66
|
+
|
|
67
|
+
const load = react.useCallback(async () => {
|
|
68
|
+
try {
|
|
69
|
+
const d = await api("/state");
|
|
70
|
+
if (d.ok !== true) throw new Error(d.error || "加载失败");
|
|
71
|
+
setData(d);
|
|
72
|
+
setError("");
|
|
73
|
+
setProfileDraft(prev => prev === null ? d.profile : prev);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
setError(String(e && e.message ? e.message : e));
|
|
76
|
+
}
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
react.useEffect(() => {
|
|
80
|
+
load();
|
|
81
|
+
const timer = setInterval(load, 10000);
|
|
82
|
+
return () => clearInterval(timer);
|
|
83
|
+
}, [load]);
|
|
84
|
+
|
|
85
|
+
const act = async (label, body) => {
|
|
86
|
+
setBusy(label);
|
|
87
|
+
setNotice("");
|
|
88
|
+
try {
|
|
89
|
+
const r = await api("/action", body);
|
|
90
|
+
if (r.ok !== true) setNotice(r.message || r.error || "操作失败");
|
|
91
|
+
else setNotice("已完成");
|
|
92
|
+
await load();
|
|
93
|
+
} catch (e) {
|
|
94
|
+
setNotice(String(e && e.message ? e.message : e));
|
|
95
|
+
} finally {
|
|
96
|
+
setBusy("");
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
if (error !== "") return h("div", { style: S.wrap }, h("div", { style: S.muted }, `小肥鱼记忆加载失败:${error}`));
|
|
101
|
+
if (data === null) return h("div", { style: S.wrap }, h("div", { style: S.muted }, "正在加载记忆…"));
|
|
102
|
+
|
|
103
|
+
const pct = Math.min(100, Math.round((data.injected / Math.max(1, data.budget)) * 100));
|
|
104
|
+
const entries = results === null ? data.entries : results;
|
|
105
|
+
|
|
106
|
+
return h("div", { style: S.wrap },
|
|
107
|
+
h("div", { style: S.cards },
|
|
108
|
+
h(Card, { k: "注入 tokens", v: `${data.injected} / ${data.budget}`, extra: h("div", { style: S.bar }, h("i", { style: { ...S.barFill, width: `${pct}%`, display: "block" } })) }),
|
|
109
|
+
h(Card, { k: "活跃记忆", v: data.active }),
|
|
110
|
+
h(Card, { k: "置顶", v: data.pinned }),
|
|
111
|
+
h(Card, { k: "待复习", v: data.due ?? 0 }),
|
|
112
|
+
h(Card, { k: "归档 / 遗忘", v: data.archived }),
|
|
113
|
+
h(Card, { k: "今日对话", v: data.todayMessages }),
|
|
114
|
+
h(Card, { k: "候选线索", v: data.dreamCues })
|
|
115
|
+
),
|
|
116
|
+
|
|
117
|
+
h("div", { style: S.section },
|
|
118
|
+
h("h2", { style: S.h2 }, "L0 画像(每次会话常驻注入)"),
|
|
119
|
+
h("textarea", {
|
|
120
|
+
style: S.textarea,
|
|
121
|
+
value: profileDraft === null ? data.profile : profileDraft,
|
|
122
|
+
onChange: e => setProfileDraft(e.target.value)
|
|
123
|
+
}),
|
|
124
|
+
h("div", { style: { ...S.row, marginTop: "6px" } },
|
|
125
|
+
h("button", { style: S.btnPrimary, disabled: busy !== "", onClick: () => act("profile", { action: "profile", content: profileDraft ?? data.profile }) }, busy === "profile" ? "保存中…" : "保存画像"),
|
|
126
|
+
h("span", { style: S.muted }, `约 ${data.profileTokens} tokens`)
|
|
127
|
+
)
|
|
128
|
+
),
|
|
129
|
+
|
|
130
|
+
h("div", { style: S.section },
|
|
131
|
+
h("h2", { style: S.h2 }, "记忆(热索引 + 冷区)"),
|
|
132
|
+
h("div", { style: S.row },
|
|
133
|
+
h("input", {
|
|
134
|
+
style: S.input,
|
|
135
|
+
placeholder: "搜索记忆:标题 / 标签 / 正文",
|
|
136
|
+
value: query,
|
|
137
|
+
onChange: e => setQuery(e.target.value),
|
|
138
|
+
onKeyDown: async e => {
|
|
139
|
+
if (e.key !== "Enter") return;
|
|
140
|
+
if (query.trim() === "") { setResults(null); return; }
|
|
141
|
+
const r = await api("/action", { action: "search", query: query.trim() });
|
|
142
|
+
setResults(r.ok === true ? (r.matches || []) : []);
|
|
143
|
+
}
|
|
144
|
+
}),
|
|
145
|
+
h("button", { style: S.btn, onClick: async () => {
|
|
146
|
+
if (query.trim() === "") { setResults(null); return; }
|
|
147
|
+
const r = await api("/action", { action: "search", query: query.trim() });
|
|
148
|
+
setResults(r.ok === true ? (r.matches || []) : []);
|
|
149
|
+
} }, "搜索"),
|
|
150
|
+
h("button", { style: S.btn, onClick: () => { setQuery(""); setResults(null); } }, "重置")
|
|
151
|
+
),
|
|
152
|
+
entries.length === 0
|
|
153
|
+
? h("div", { style: { ...S.muted, marginTop: "8px" } }, results === null ? "还没有记忆" : "没有匹配的记忆")
|
|
154
|
+
: h("div", { style: { marginTop: "8px" } }, entries.map(e => h(MemoryRow, { key: e.id, entry: e, busy, onAct: act })))
|
|
155
|
+
),
|
|
156
|
+
|
|
157
|
+
h("div", { style: S.section },
|
|
158
|
+
h("h2", { style: S.h2 }, "睡梦机制"),
|
|
159
|
+
h("div", { style: S.muted }, `已做梦 ${data.dreams} 次 · 最近一次:${data.lastDream === "" ? "无" : data.lastDream}`),
|
|
160
|
+
h("div", { style: { ...S.row, marginTop: "6px" } },
|
|
161
|
+
h("button", { style: S.btnPrimary, disabled: busy !== "", onClick: () => act("dream", { action: "dream" }) }, busy === "dream" ? "做梦ing…" : "现在做梦"),
|
|
162
|
+
h("button", { style: S.btn, disabled: busy !== "", onClick: () => act("consolidate", { action: "consolidate" }) }, busy === "consolidate" ? "整理中…" : "整理记忆"),
|
|
163
|
+
notice === "" ? null : h("span", { style: S.muted }, notice)
|
|
164
|
+
),
|
|
165
|
+
data.dreamReport === "" ? null : h("pre", { style: S.pre }, data.dreamReport)
|
|
166
|
+
)
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function Card(props) {
|
|
171
|
+
return h("div", { style: S.card },
|
|
172
|
+
h("div", { style: S.k }, props.k),
|
|
173
|
+
h("div", { style: S.v }, String(props.v)),
|
|
174
|
+
props.extra || null
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function MemoryRow(props) {
|
|
179
|
+
const e = props.entry;
|
|
180
|
+
const isBusy = props.busy !== "";
|
|
181
|
+
const mark = e.due && !e.pinned ? "⏰ " : (e.pinned ? "★ " : "");
|
|
182
|
+
return h("div", { style: S.mem },
|
|
183
|
+
h("div", { style: S.memMain },
|
|
184
|
+
h("div", { style: S.title },
|
|
185
|
+
mark,
|
|
186
|
+
e.title,
|
|
187
|
+
(e.tags || []).map(t => h("span", { key: t, style: S.tag }, t))
|
|
188
|
+
),
|
|
189
|
+
h("div", { style: S.meta }, `${e.updated} · 生效 ${e.validFrom ?? ""} · 显著性 ${e.salience ?? 5} · 重要度 ${e.importance} · 命中 ${e.hits}${e.nextReview ? ` · 复习 ${String(e.nextReview).slice(0, 10)}` : ""}`)
|
|
190
|
+
),
|
|
191
|
+
h("button", { style: S.btn, disabled: isBusy, onClick: () => props.onAct(e.pinned ? "unpin" : "pin", { action: e.pinned ? "unpin" : "pin", id: e.id }) }, e.pinned ? "取消置顶" : "置顶"),
|
|
192
|
+
h("button", { style: S.btn, disabled: isBusy, onClick: () => props.onAct("forget", { action: "forget", id: e.id }) }, "遗忘")
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
exports.apply = apply;
|
|
197
|
+
exports.inject = inject;
|
|
198
|
+
exports.name = name;
|
|
199
|
+
return module.exports;
|
|
200
|
+
}
|
|
201
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
|
2
|
+
<rect width="128" height="128" rx="28" fill="#0f1419"/>
|
|
3
|
+
<defs>
|
|
4
|
+
<radialGradient id="g" cx="50%" cy="48%" r="58%">
|
|
5
|
+
<stop offset="0%" stop-color="#1e6f9f" stop-opacity=".5"/>
|
|
6
|
+
<stop offset="100%" stop-color="#0f1419" stop-opacity="0"/>
|
|
7
|
+
</radialGradient>
|
|
8
|
+
</defs>
|
|
9
|
+
<rect width="128" height="128" rx="28" fill="url(#g)"/>
|
|
10
|
+
<!-- tail -->
|
|
11
|
+
<path d="M80 64 L104 44 L98 64 Z" fill="#4cc2ff"/>
|
|
12
|
+
<path d="M80 64 L104 84 L98 64 Z" fill="#4cc2ff"/>
|
|
13
|
+
<!-- body -->
|
|
14
|
+
<ellipse cx="56" cy="64" rx="30" ry="24" fill="#56cdff"/>
|
|
15
|
+
<!-- memory strata -->
|
|
16
|
+
<rect x="54" y="55" width="24" height="6" rx="3" fill="#5ee0a0"/>
|
|
17
|
+
<rect x="54" y="65" width="21" height="6" rx="3" fill="#aaeBff"/>
|
|
18
|
+
<rect x="54" y="75" width="18" height="6" rx="3" fill="#ffffff"/>
|
|
19
|
+
<!-- neural nodes -->
|
|
20
|
+
<g stroke="#0f1419" stroke-width="1.6" opacity=".95">
|
|
21
|
+
<path d="M42 46 L56 40 L70 47 M56 40 L70 56 M42 46 L52 56"/>
|
|
22
|
+
</g>
|
|
23
|
+
<g fill="#0f1419">
|
|
24
|
+
<circle cx="42" cy="46" r="2.6"/><circle cx="56" cy="40" r="2.6"/>
|
|
25
|
+
<circle cx="70" cy="47" r="2.6"/><circle cx="70" cy="56" r="2.6"/>
|
|
26
|
+
</g>
|
|
27
|
+
<!-- eye + smile -->
|
|
28
|
+
<circle cx="40" cy="62" r="6" fill="#0f1419"/>
|
|
29
|
+
<circle cx="38" cy="59.5" r="2" fill="#fff"/>
|
|
30
|
+
<path d="M36 74 q6 7 12 1" stroke="#0f1419" stroke-width="2.6" fill="none" stroke-linecap="round"/>
|
|
31
|
+
<!-- belly pins -->
|
|
32
|
+
<g fill="#5ee0a0">
|
|
33
|
+
<rect x="38" y="88" width="6" height="9" rx="1.5"/>
|
|
34
|
+
<rect x="48" y="88" width="6" height="9" rx="1.5"/>
|
|
35
|
+
<rect x="58" y="88" width="6" height="9" rx="1.5"/>
|
|
36
|
+
<rect x="68" y="88" width="6" height="9" rx="1.5"/>
|
|
37
|
+
</g>
|
|
38
|
+
<!-- puzzle dorsal fin -->
|
|
39
|
+
<g fill="#5ee0a0">
|
|
40
|
+
<rect x="48" y="22" width="22" height="13" rx="3"/>
|
|
41
|
+
<circle cx="65" cy="22" r="5"/>
|
|
42
|
+
</g>
|
|
43
|
+
</svg>
|
package/index.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-memory-fish — 小肥鱼记忆
|
|
3
|
+
*
|
|
4
|
+
* 上下文成本恒定的本地记忆插件:
|
|
5
|
+
* L0 画像 (≤200 tok, 常驻) + L1 热索引 (≤400 tok, 常驻)
|
|
6
|
+
* L2 温记忆 (磁盘, memory_search/read 取) + L3 冷档案 (从不注入)
|
|
7
|
+
*
|
|
8
|
+
* 零依赖、零服务端、零进程。数据全部是 Markdown + JSON。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { resolveConfig } from './lib/config.mjs';
|
|
13
|
+
import { Store } from './lib/store.mjs';
|
|
14
|
+
import { buildInjection } from './lib/inject.mjs';
|
|
15
|
+
import { registerTools } from './lib/tools.mjs';
|
|
16
|
+
import { registerPanelRoutes } from './lib/panel.mjs';
|
|
17
|
+
import { nowIso, messageText, redactSecrets } from './lib/util.mjs';
|
|
18
|
+
import { configureLogger, log } from './lib/log.mjs';
|
|
19
|
+
|
|
20
|
+
export const name = 'dsh-memory-fish';
|
|
21
|
+
export const inject = ['tools', 'systemPrompt'];
|
|
22
|
+
|
|
23
|
+
export function apply(ctx, config = {}) {
|
|
24
|
+
const cfg = resolveConfig(config);
|
|
25
|
+
const store = new Store(cfg.dir);
|
|
26
|
+
configureLogger({ enabled: cfg.debug, file: path.join(cfg.dir, 'debug.log') });
|
|
27
|
+
|
|
28
|
+
// 启动时初始化(失败不拖垮宿主)
|
|
29
|
+
store.init().catch(() => {});
|
|
30
|
+
|
|
31
|
+
// ---- 注入 ----
|
|
32
|
+
try {
|
|
33
|
+
if (ctx.systemPrompt !== undefined) {
|
|
34
|
+
let order = 500;
|
|
35
|
+
try {
|
|
36
|
+
const raw = ctx.systemPrompt.getSectionOrder?.('TOOL_GOAL');
|
|
37
|
+
if (typeof raw === 'number' && Number.isFinite(raw)) order = raw;
|
|
38
|
+
} catch { /* 用默认 */ }
|
|
39
|
+
ctx.systemPrompt.section({
|
|
40
|
+
name: 'dsh-memory-fish:auto',
|
|
41
|
+
order,
|
|
42
|
+
text: () => {
|
|
43
|
+
try {
|
|
44
|
+
return buildInjection(store, cfg).text;
|
|
45
|
+
} catch {
|
|
46
|
+
return '';
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
} catch { /* 没有 systemPrompt 服务:工具仍然可用 */ }
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
const toolImpl = {};
|
|
55
|
+
|
|
56
|
+
// ---- 自动捕获:对话落 journal(L3 原始层,永不注入,供做梦回顾) ----
|
|
57
|
+
try {
|
|
58
|
+
if (cfg.autoCapture && typeof ctx.on === 'function') {
|
|
59
|
+
ctx.on('session/event', (session, event) => {
|
|
60
|
+
try {
|
|
61
|
+
if (!event || typeof event !== 'object') return;
|
|
62
|
+
if (cfg.skipSubagentSessions && session?.header?.origin === 'subagent') return;
|
|
63
|
+
const types = ['user/message', 'assistant/message'];
|
|
64
|
+
if (!types.includes(event.type)) return;
|
|
65
|
+
const message = event.type === 'user/message' ? event.data : event.data?.message;
|
|
66
|
+
if (!message || message.role === 'system') return;
|
|
67
|
+
const kind = message.source?.kind;
|
|
68
|
+
if (event.type === 'user/message' && kind && kind !== 'user') return; // 跳过注入的合成上下文
|
|
69
|
+
if (message.source?.kind === 'tool') return;
|
|
70
|
+
const text = messageText(message).trim();
|
|
71
|
+
if (!text) return;
|
|
72
|
+
const role = event.type === 'assistant/message' ? 'assistant' : 'user';
|
|
73
|
+
store.appendJournal({
|
|
74
|
+
ts: nowIso(),
|
|
75
|
+
session: String(session?.id ?? ''),
|
|
76
|
+
role,
|
|
77
|
+
text: redactSecrets(text).slice(0, cfg.captureMaxChars),
|
|
78
|
+
}).catch(() => {});
|
|
79
|
+
store.stats.captured = (store.stats.captured || 0) + 1;
|
|
80
|
+
} catch { /* 捕获失败绝不影响会话 */ }
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
} catch { /* 宿主没有事件总线 */ }
|
|
84
|
+
|
|
85
|
+
registerTools(ctx, store, cfg, toolImpl);
|
|
86
|
+
|
|
87
|
+
// ---- journal 清理(启动 5 秒后跑一次,不拖慢启动) ----
|
|
88
|
+
setTimeout(() => { store.pruneJournal(cfg.journalKeepDays).catch(() => {}); }, 5000);
|
|
89
|
+
|
|
90
|
+
// ---- 可视化面板:宿主的 webServer 路由;没有也不影响工具 ----
|
|
91
|
+
try {
|
|
92
|
+
const injectFn = ctx.inject;
|
|
93
|
+
if (typeof injectFn === 'function') {
|
|
94
|
+
injectFn.call(ctx, ['webServer'], (webCtx) => {
|
|
95
|
+
try {
|
|
96
|
+
registerPanelRoutes(webCtx?.webServer, store, cfg, toolImpl);
|
|
97
|
+
} catch { /* 面板挂了也不影响记忆工具 */ }
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
} catch { /* 宿主没有 webServer 服务 */ }
|
|
101
|
+
}
|
package/lib/config.mjs
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------- 配置
|
|
5
|
+
|
|
6
|
+
export const DEFAULTS = {
|
|
7
|
+
debug: false, // 调试日志(写 <dir>/debug.log)
|
|
8
|
+
dir: path.join(os.homedir(), '.dsh', 'memory-fish'),
|
|
9
|
+
budgetTokens: 600, // L0 + L1 硬预算
|
|
10
|
+
profileTokens: 200, // L0 预算
|
|
11
|
+
maxHotLines: 12, // L1 最多行数(人脑工作记忆 4±1,取 8-12)
|
|
12
|
+
archiveAfterDays: 60, // 衰减归档阈值(天,低 salience 才适用)
|
|
13
|
+
dedupeThreshold: 0.75, // 写入去重相似度(>= 更新)
|
|
14
|
+
noopThreshold: 0.92, // 新颖性门控:>= 视为完全重复,不写只记一次检索
|
|
15
|
+
salienceBase: 3, // 显著性基础分
|
|
16
|
+
reviewEnabled: true, // 复习调度(SM-2 简化版)
|
|
17
|
+
autoCapture: true, // 自动捕获:把对话写进 journal(L3 原始层,永不注入)
|
|
18
|
+
captureMaxChars: 4000, // 单条消息最多记录字符数
|
|
19
|
+
skipSubagentSessions: true,
|
|
20
|
+
journalKeepDays: 30, // journal 保留天数
|
|
21
|
+
dreamDays: 2, // 做梦默认回顾最近几天的 journal
|
|
22
|
+
dreamAutoSave: false, // 做梦时是否自动落库(默认只给候选)
|
|
23
|
+
dreamReminder: true, // C1:到点后在注入块里提醒做梦(不依赖宿主调度器)
|
|
24
|
+
dreamAfterHours: 24, // 距上次做梦超过多少小时算"欠一次"
|
|
25
|
+
panelPath: '/memory-fish/panel',
|
|
26
|
+
apiBase: '/memory-fish/api',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** 运行期合并用户配置;只接受已知字段。 */
|
|
30
|
+
export function resolveConfig(input = {}) {
|
|
31
|
+
const base = { ...DEFAULTS };
|
|
32
|
+
const out = { ...base };
|
|
33
|
+
if (input && typeof input === 'object') {
|
|
34
|
+
for (const key of Object.keys(base)) {
|
|
35
|
+
if (input[key] !== undefined) out[key] = input[key];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// 数值字段兜底,避免用户填字符串把预算算崩
|
|
39
|
+
for (const key of ['budgetTokens', 'profileTokens', 'maxHotLines', 'archiveAfterDays', 'dedupeThreshold', 'noopThreshold', 'captureMaxChars', 'journalKeepDays', 'dreamDays', 'dreamAfterHours']) {
|
|
40
|
+
const n = Number(out[key]);
|
|
41
|
+
if (Number.isFinite(n)) out[key] = n;
|
|
42
|
+
}
|
|
43
|
+
return out;
|
|
44
|
+
}
|
package/lib/core.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import { SECRET_RE, similar, salienceFor, initialStability, addDays, slugify, stamp, nowIso, out, reinforce } from './util.mjs';
|
|
3
|
+
import { buildInjection } from './inject.mjs';
|
|
4
|
+
|
|
5
|
+
export async function saveMemoryCore(store, cfg, args) {
|
|
6
|
+
const title = String(args.title ?? '').trim();
|
|
7
|
+
const content = String(args.content ?? '').trim();
|
|
8
|
+
if (!title || !content) return out({ ok: false, code: 'invalid_input', message: 'title 与 content 不能为空' });
|
|
9
|
+
if (SECRET_RE.test(content) || SECRET_RE.test(title)) {
|
|
10
|
+
return out({ ok: false, code: 'secret_refused', message: '疑似密钥/凭据,拒绝保存。请去掉敏感值后重试。' });
|
|
11
|
+
}
|
|
12
|
+
if (!store.entries.length) await store.load();
|
|
13
|
+
|
|
14
|
+
const tags = (args.tags || []).map(t => String(t).trim()).filter(Boolean).slice(0, 3);
|
|
15
|
+
const importance = Math.min(5, Math.max(1, Math.trunc(Number(args.importance) || 3)));
|
|
16
|
+
const pin = args.pin === true;
|
|
17
|
+
|
|
18
|
+
let target = null;
|
|
19
|
+
let best = 0;
|
|
20
|
+
for (const e of store.entries) {
|
|
21
|
+
if (e.status !== 'active') continue;
|
|
22
|
+
const sim = similar(`${e.title}\n${e.content ?? ''}`, `${title}\n${content}`);
|
|
23
|
+
if (sim > best) { best = sim; target = e; }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 新颖性门控:几乎一模一样 → NOOP,只记一次"被检索"(提取练习)
|
|
27
|
+
if (target && best >= cfg.noopThreshold) {
|
|
28
|
+
reinforce(target, 1.2);
|
|
29
|
+
await store.saveIndex();
|
|
30
|
+
return out({
|
|
31
|
+
ok: true, saved: false, updated: false, noop: true, id: target.id,
|
|
32
|
+
similarity: Number(best.toFixed(2)), reason: '与已有记忆几乎相同,已跳过写入(只记录一次复习)',
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 显著性:编码阶段的"值不值得记"
|
|
37
|
+
const novelty = best > 0 ? 1 - best : 1;
|
|
38
|
+
const salience = Math.max(
|
|
39
|
+
salienceFor({ title, content, importance, pin, novelty }),
|
|
40
|
+
args.salience ? Math.min(10, Math.max(1, Math.trunc(Number(args.salience)))) : 0,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
if (target && best >= cfg.dedupeThreshold) {
|
|
44
|
+
const at = nowIso();
|
|
45
|
+
try {
|
|
46
|
+
const oldText = await fs.readFile(target.file, 'utf8');
|
|
47
|
+
await store.archiveContent(target, oldText);
|
|
48
|
+
} catch { /* 老文件丢了也继续 */ }
|
|
49
|
+
// P3 时间有效性:旧版本封存为历史(带 validFrom/validTo),当前版本换新有效期
|
|
50
|
+
target.history = [
|
|
51
|
+
...(target.history || []),
|
|
52
|
+
{
|
|
53
|
+
title: target.title,
|
|
54
|
+
content: target.content,
|
|
55
|
+
validFrom: target.validFrom || target.created || at,
|
|
56
|
+
validTo: at,
|
|
57
|
+
},
|
|
58
|
+
].slice(-10);
|
|
59
|
+
target.title = title;
|
|
60
|
+
target.content = content;
|
|
61
|
+
target.tags = tags.length ? tags : target.tags;
|
|
62
|
+
target.importance = Math.max(target.importance ?? 3, importance);
|
|
63
|
+
target.pinned = target.pinned || pin;
|
|
64
|
+
target.salience = Math.max(target.salience ?? 5, salience);
|
|
65
|
+
target.updated = at;
|
|
66
|
+
target.validFrom = at;
|
|
67
|
+
target.validTo = null;
|
|
68
|
+
// 再巩固:旧记忆被改写,复习节奏重新起算(但保留已有稳定性)
|
|
69
|
+
target.lastReview = at;
|
|
70
|
+
target.stability = Math.max(target.stability || initialStability(target.importance, target.salience), initialStability(target.importance, target.salience));
|
|
71
|
+
target.nextReview = addDays(at, Math.round(target.stability));
|
|
72
|
+
await store.writeMemory(target, content);
|
|
73
|
+
await store.saveIndex();
|
|
74
|
+
store.stats.updated += 1;
|
|
75
|
+
await store.saveStats();
|
|
76
|
+
return out({
|
|
77
|
+
ok: true, saved: true, updated: true, id: target.id,
|
|
78
|
+
similarity: Number(best.toFixed(2)), salience: target.salience,
|
|
79
|
+
validFrom: target.validFrom, historyCount: (target.history || []).length, scope: 'user',
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const now = nowIso();
|
|
84
|
+
const stability = initialStability(importance, salience);
|
|
85
|
+
const id = `${stamp()}-${slugify(title)}`;
|
|
86
|
+
const entry = {
|
|
87
|
+
id, title, content, tags, importance, salience, pinned: pin,
|
|
88
|
+
created: now, updated: now, validFrom: now, validTo: null, history: [],
|
|
89
|
+
hits: 0, lastHit: null,
|
|
90
|
+
status: 'active', supersedes: [], links: [], chars: 0, tokens: 0, file: '',
|
|
91
|
+
stability, lastReview: now, nextReview: addDays(now, Math.round(stability)), reviews: 0,
|
|
92
|
+
};
|
|
93
|
+
await store.writeMemory(entry, content);
|
|
94
|
+
store.entries.push(entry);
|
|
95
|
+
await store.saveIndex();
|
|
96
|
+
store.stats.saved += 1;
|
|
97
|
+
await store.saveStats();
|
|
98
|
+
const hot = buildInjection(store, cfg);
|
|
99
|
+
return out({
|
|
100
|
+
ok: true, saved: true, updated: false, id, novelty: Number(novelty.toFixed(2)), salience, stability,
|
|
101
|
+
nextReview: entry.nextReview, validFrom: entry.validFrom, scope: 'user',
|
|
102
|
+
indexLines: hot.l1Lines, coldRemaining: hot.cold, injectedTokens: hot.l1Tokens,
|
|
103
|
+
});
|
|
104
|
+
}
|