pi-okf-memory 0.1.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/recall.js ADDED
@@ -0,0 +1,47 @@
1
+ import { readConcept } from "./store.js";
2
+ import { search } from "./dedupe.js";
3
+ import { rank, recordHit } from "./learning.js";
4
+ //#region src/server/recall.ts
5
+ /**
6
+ * recall.ts — 预测性唤起:摘要层粗筛 → 细节层校验 → 反馈回路。
7
+ * 对应"预测加工":先预测需要什么记忆,再检索验证,命中质量写回权重。
8
+ */
9
+ /**
10
+ * 预测性预取:给定查询,返回按唤起评分排序的记忆摘要。
11
+ */
12
+ async function preload(root, query, opts = {}) {
13
+ const raw = await search(root, query, {
14
+ ...opts,
15
+ limit: (opts.limit || 8) * 3
16
+ });
17
+ return (await rank(root, raw)).slice(0, opts.limit || 8).map(({ conceptId, title, description, type, tags, weight, state, score }) => ({
18
+ conceptId,
19
+ title,
20
+ description,
21
+ type,
22
+ tags,
23
+ weight,
24
+ state,
25
+ score
26
+ }));
27
+ }
28
+ /**
29
+ * 精读:读全文 + 提取交叉链接,并记录命中反馈。
30
+ */
31
+ async function recall(root, conceptId) {
32
+ const concept = await readConcept(root, conceptId);
33
+ const links = [];
34
+ const re = /\[([^\]]+)\]\(\/([^)]+\.md)\)/g;
35
+ let m;
36
+ while ((m = re.exec(concept.body || "")) !== null) links.push({
37
+ text: m[1],
38
+ conceptId: m[2].replace(/\.md$/, "")
39
+ });
40
+ await recordHit(root, conceptId);
41
+ return {
42
+ ...concept,
43
+ links
44
+ };
45
+ }
46
+ //#endregion
47
+ export { preload, recall };
package/lib/store.js ADDED
@@ -0,0 +1,217 @@
1
+ import { parseFrontmatter, slugify, validateConcept } from "./concept.js";
2
+ import path from "node:path";
3
+ import { promises } from "node:fs";
4
+ import os from "node:os";
5
+ //#region src/server/store.ts
6
+ /**
7
+ * store.ts — OKF bundle 存储:根目录初始化、概念落盘、index.md 渐进式目录、log.md 变更历史。
8
+ * 路径即概念 ID:<root>/<type小写>/<kebab-id>.md
9
+ */
10
+ /**
11
+ * 进程内写锁(可重入):串行化对 bundle 文件(index.md/log.md/概念/weights.json)的读-改-写,
12
+ * 避免多会话并发写入时丢失更新。Cordis 单进程内生效,跨进程不保证。
13
+ */
14
+ let writeQueue = Promise.resolve();
15
+ let lockDepth = 0;
16
+ function withLock(fn) {
17
+ if (lockDepth > 0) return Promise.resolve().then(fn);
18
+ const run = writeQueue.then(async () => {
19
+ lockDepth = 1;
20
+ try {
21
+ return await fn();
22
+ } finally {
23
+ lockDepth = 0;
24
+ }
25
+ });
26
+ writeQueue = run.then(() => {}, () => {});
27
+ return run;
28
+ }
29
+ /** 默认记忆库根目录(可被 OKF_MEMORY_ROOT 环境变量覆盖) */
30
+ function defaultRoot() {
31
+ return process.env.OKF_MEMORY_ROOT || path.join(os.homedir(), ".dsh", "memory");
32
+ }
33
+ /** 概念文件路径 → 概念 ID(相对根,去扩展名,正斜杠归一) */
34
+ function conceptIdOf(filePath, root) {
35
+ return path.relative(root, filePath).replace(/\\/g, "/").replace(/\.md$/, "");
36
+ }
37
+ /** 概念 ID → 文件路径(含安全校验:防路径穿越) */
38
+ function filePathOf(root, conceptId) {
39
+ const norm = String(conceptId || "").replace(/\\/g, "/").replace(/^\/+/, "");
40
+ const resolved = path.resolve(root, ...norm.split("/"));
41
+ const rootResolved = path.resolve(root);
42
+ if (!resolved.startsWith(rootResolved + path.sep) && resolved !== rootResolved) throw new Error(`非法 concept_id(路径穿越):${conceptId}`);
43
+ return resolved.endsWith(".md") ? resolved : `${resolved}.md`;
44
+ }
45
+ /** 确保记忆库骨架存在(根 index.md + log.md) */
46
+ async function ensureRoot(root) {
47
+ await promises.mkdir(root, { recursive: true });
48
+ const indexPath = path.join(root, "index.md");
49
+ const logPath = path.join(root, "log.md");
50
+ try {
51
+ await promises.access(indexPath);
52
+ } catch {
53
+ await promises.writeFile(indexPath, [
54
+ "---",
55
+ "type: Bundle Root",
56
+ "title: OKF 记忆库",
57
+ "description: 会话记忆沉淀库(OKF v0.1)",
58
+ "okf_version: \"0.1\"",
59
+ "---",
60
+ "",
61
+ "# OKF 记忆库",
62
+ "",
63
+ "由 okf-memory 插件维护。概念按类型分目录,路径即概念 ID。",
64
+ ""
65
+ ].join("\n"), "utf8");
66
+ }
67
+ try {
68
+ await promises.access(logPath);
69
+ } catch {
70
+ await promises.writeFile(logPath, "---\ntype: Log\ntitle: 变更历史\n---\n\n# 变更历史\n\n", "utf8");
71
+ }
72
+ }
73
+ /** 读取并解析概念文档(校验概念 ID 安全) */
74
+ async function readConcept(root, conceptId) {
75
+ const filePath = filePathOf(root, conceptId);
76
+ const text = await promises.readFile(filePath, "utf8");
77
+ const { meta, body } = parseFrontmatter(text);
78
+ return {
79
+ filePath,
80
+ conceptId: conceptIdOf(filePath, root),
81
+ meta,
82
+ body,
83
+ text
84
+ };
85
+ }
86
+ /** 写概念文档(先做符合性校验,再落盘,更新 index/log) */
87
+ async function writeConcept(root, meta, body) {
88
+ return withLock(async () => {
89
+ const { buildConcept } = await import("./concept.js");
90
+ const md = buildConcept(meta, body);
91
+ const check = validateConcept(md);
92
+ if (!check.ok) throw new Error(`OKF 符合性校验失败:${check.errors.join("; ")}`);
93
+ const typeDir = slugify(meta.type) || "other";
94
+ const dir = path.join(root, typeDir);
95
+ await promises.mkdir(dir, { recursive: true });
96
+ const base = slugify(meta.title || meta.type || "untitled");
97
+ const filePath = path.join(dir, `${base}.md`);
98
+ let action = "created";
99
+ try {
100
+ await promises.access(filePath);
101
+ action = "updated";
102
+ } catch {}
103
+ await promises.writeFile(filePath, md, "utf8");
104
+ const conceptId = conceptIdOf(filePath, root);
105
+ await refreshIndex(root);
106
+ await appendLog(root, {
107
+ action,
108
+ conceptId,
109
+ type: meta.type,
110
+ title: meta.title
111
+ });
112
+ return {
113
+ action,
114
+ conceptId,
115
+ filePath
116
+ };
117
+ });
118
+ }
119
+ /** 全库扫描:返回所有概念文档清单(用于 index 重建与检索) */
120
+ async function scanBundle(root) {
121
+ const out = [];
122
+ let entries;
123
+ try {
124
+ entries = await promises.readdir(root, { withFileTypes: true });
125
+ } catch {
126
+ return out;
127
+ }
128
+ for (const e of entries) {
129
+ if (!e.isDirectory()) continue;
130
+ if (e.name.startsWith(".")) continue;
131
+ const dir = path.join(root, e.name);
132
+ let files;
133
+ try {
134
+ files = await promises.readdir(dir);
135
+ } catch {
136
+ continue;
137
+ }
138
+ for (const f of files) {
139
+ if (!f.endsWith(".md")) continue;
140
+ const filePath = path.join(dir, f);
141
+ const conceptId = conceptIdOf(filePath, root);
142
+ out.push({
143
+ filePath,
144
+ conceptId,
145
+ typeDir: e.name
146
+ });
147
+ }
148
+ }
149
+ return out;
150
+ }
151
+ /** 重建根 index.md(渐进式目录:按类型分组列概念,每行附 description 供模型感知"库里有啥";写锁内串行) */
152
+ async function refreshIndex(root) {
153
+ return withLock(async () => {
154
+ const concepts = await scanBundle(root);
155
+ const metas = await Promise.all(concepts.map(async (c) => {
156
+ try {
157
+ const text = await promises.readFile(c.filePath, "utf8");
158
+ const { meta } = parseFrontmatter(text);
159
+ return meta || {};
160
+ } catch {
161
+ return {};
162
+ }
163
+ }));
164
+ const byType = /* @__PURE__ */ new Map();
165
+ for (let i = 0; i < concepts.length; i++) {
166
+ const c = concepts[i];
167
+ if (!byType.has(c.typeDir)) byType.set(c.typeDir, []);
168
+ const desc = String(metas[i].description || "").trim().replace(/\s+/g, " ").slice(0, 60);
169
+ byType.get(c.typeDir).push({
170
+ id: c.conceptId,
171
+ desc
172
+ });
173
+ }
174
+ const lines = [
175
+ "---",
176
+ "type: Bundle Root",
177
+ "title: OKF 记忆库",
178
+ "description: 会话记忆沉淀库(OKF v0.1)",
179
+ "okf_version: \"0.1\"",
180
+ "---",
181
+ "",
182
+ "# OKF 记忆库",
183
+ "",
184
+ `共 ${concepts.length} 个概念。路径即概念 ID,交叉链接用包内绝对路径。`,
185
+ ""
186
+ ];
187
+ for (const [typeDir, items] of [...byType.entries()].sort()) {
188
+ lines.push(`## ${typeDir}`, "");
189
+ for (const item of [...items].sort((a, b) => a.id.localeCompare(b.id))) lines.push(item.desc ? `- [${item.id}](/${item.id}.md) — ${item.desc}` : `- [${item.id}](/${item.id}.md)`);
190
+ lines.push("");
191
+ }
192
+ if (byType.size === 0) lines.push("(空库 — 在会话中说\"记住这个\",即可沉淀第一条记忆)", "");
193
+ await promises.writeFile(path.join(root, "index.md"), lines.join("\n"), "utf8");
194
+ });
195
+ }
196
+ /** 追加 log.md 变更记录(## YYYY-MM-DD 分组;写锁内串行) */
197
+ async function appendLog(root, entry) {
198
+ return withLock(async () => {
199
+ const logPath = path.join(root, "log.md");
200
+ const now = /* @__PURE__ */ new Date();
201
+ const date = now.toISOString().slice(0, 10);
202
+ const time = now.toISOString().slice(11, 19);
203
+ let text = "";
204
+ try {
205
+ text = await promises.readFile(logPath, "utf8");
206
+ } catch {
207
+ text = "---\ntype: Log\ntitle: 变更历史\n---\n\n# 变更历史\n\n";
208
+ }
209
+ const marker = `## ${date}`;
210
+ const line = `- ${time} — ${entry.action} [${entry.conceptId}](${entry.conceptId}.md) (${entry.type}${entry.title ? ` · ${entry.title}` : ""})`;
211
+ if (text.includes(marker)) text = `${text.replace(/\s*$/, "")}\n${line}\n`;
212
+ else text = `${text.replace(/\s*$/, "")}\n${marker}\n\n${line}\n`;
213
+ await promises.writeFile(logPath, text, "utf8");
214
+ });
215
+ }
216
+ //#endregion
217
+ export { appendLog, conceptIdOf, defaultRoot, ensureRoot, filePathOf, readConcept, refreshIndex, scanBundle, withLock, writeConcept };
package/package.json ADDED
@@ -0,0 +1,89 @@
1
+ {
2
+ "name": "pi-okf-memory",
3
+ "version": "0.1.0",
4
+ "description": "会话记忆 → OKF 知识沉淀的 pi 扩展。把会话中高价值内容按 OKF v0.1 规范自动沉淀为长期记忆,跨会话自动唤起,并基于权重持续学习。Session-to-OKF long-term memory for pi with predictive recall, uncertainty-driven capture and reinforcement feedback.",
5
+ "type": "module",
6
+ "packageManager": "pnpm@12.4.1",
7
+ "engines": {
8
+ "node": ">=20"
9
+ },
10
+ "main": "lib/index.js",
11
+ "exports": {
12
+ ".": "./lib/index.js",
13
+ "./client": "./lib/client.js",
14
+ "./cordis.patch.yml": "./cordis.patch.yml",
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "lib",
19
+ "src",
20
+ "cordis.patch.yml",
21
+ "README.md",
22
+ "README.en.md",
23
+ "docs/graph-demo.png"
24
+ ],
25
+ "keywords": [
26
+ "pi-package",
27
+ "pi-extension",
28
+ "pi",
29
+ "memory",
30
+ "okf",
31
+ "long-term-memory",
32
+ "knowledge",
33
+ "agent"
34
+ ],
35
+ "license": "MIT",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/ZHI-QI/pi-okf-memory.git"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "scripts": {
44
+ "build": "tsdown --config tsdown.config.ts",
45
+ "prepublishOnly": "npm run build",
46
+ "typecheck": "tsc -p tsconfig.typecheck.json",
47
+ "test": "pnpm typecheck && pnpm build && node scripts/smoke.js && node scripts/integration.js && node scripts/schema-check.js && node scripts/concurrency.js && node scripts/regression.js && node scripts/pi-integration.js && node scripts/pi-rpc-commands.js",
48
+ "test:core": "pnpm typecheck && node scripts/smoke.js && node scripts/concurrency.js && node scripts/regression.js",
49
+ "test:dsh": "pnpm build && node scripts/integration.js && node scripts/schema-check.js",
50
+ "test:pi": "pnpm typecheck && node scripts/pi-integration.js && node scripts/pi-rpc-commands.js",
51
+ "test:e2e": "pnpm build && node scripts/pi-e2e-model.js"
52
+ },
53
+ "dsh": {
54
+ "bundle": {
55
+ "patch": "./cordis.patch.yml"
56
+ },
57
+ "client": {
58
+ "platform": "web",
59
+ "inject": [
60
+ "@deepseek-ai/dsh-client-ui-slots",
61
+ "@deepseek-ai/dsh-client-locale",
62
+ "@deepseek-ai/dsh-client-web-react",
63
+ "@deepseek-ai/dsh-client-ui-conversation"
64
+ ]
65
+ }
66
+ },
67
+ "pi": {
68
+ "extensions": [
69
+ "./src/pi/index.ts"
70
+ ]
71
+ },
72
+ "peerDependencies": {
73
+ "@deepseek-ai/cordis": ">=4.0.0"
74
+ },
75
+ "peerDependenciesMeta": {
76
+ "@deepseek-ai/cordis": {
77
+ "optional": true
78
+ }
79
+ },
80
+ "devDependencies": {
81
+ "@earendil-works/pi-coding-agent": "^0.85.1",
82
+ "@types/react": "^19.2.18",
83
+ "jiti": "^2.7.0",
84
+ "react": "^19.2.8",
85
+ "tsdown": "^0.22.14",
86
+ "typebox": "^1.3.30",
87
+ "typescript": "^7.0.2"
88
+ }
89
+ }
@@ -0,0 +1,232 @@
1
+ /**
2
+ * okf-memory client(浏览器半,dsh 专用) — 记忆图谱会话标签页。
3
+ *
4
+ * 契约:
5
+ * - package.json 声明 dsh.client { platform:'web', inject:[client服务] }
6
+ * - ctx.slots.inject("conversation.view", ...) 注册对话视图标签
7
+ * - fetch 后端 /okf-graph → 力导向渲染
8
+ * - 交互:滚轮缩放 / 拖拽平移 / 拖节点 / 悬停显示详情 / 搜索命中+神经传导
9
+ *
10
+ * 只保留「记忆图谱」一个视图。
11
+ */
12
+ import React from "react";
13
+
14
+ export const inject = ["slots", "locale"];
15
+ const NS = "okf-memory";
16
+
17
+ const TYPE_COLORS: Record<string, string> = {
18
+ Fact: "#2f7bff", Preference: "#ffd400", Decision: "#ff2d55", Method: "#00e66e",
19
+ Insight: "#c04dff", Idea: "#ff7a00", Lesson: "#c8d1dd", TechChoice: "#00e5ff", Other: "#9aa7b8",
20
+ };
21
+ type GraphNode = { id: string; title: string; type: string; weight: number; state: string; description?: string; tags?: string[] };
22
+ type GraphEdge = { source: string; target: string };
23
+
24
+ type LayoutNode = GraphNode & { x: number; y: number; vx: number; vy: number; r: number };
25
+
26
+ function MemoryGraphView() {
27
+ const [graph, setGraph] = React.useState<{ nodes: GraphNode[]; edges: GraphEdge[] } | null>(null);
28
+ const [error, setError] = React.useState<string | null>(null);
29
+ const [query, setQuery] = React.useState("");
30
+ const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
31
+ const layoutRef = React.useRef<LayoutNode[]>([]);
32
+ const viewRef = React.useRef({ zoom: 1, panX: 0, panY: 0 });
33
+ const dragRef = React.useRef<{ id: string | null; offX: number; offY: number; panning: boolean; lastX: number; lastY: number }>({ id: null, offX: 0, offY: 0, panning: false, lastX: 0, lastY: 0 });
34
+ const hoverRef = React.useRef<string | null>(null);
35
+ const [, forceRender] = React.useState(0); // 触发 tooltip 更新
36
+
37
+ const queryRef = React.useRef("");
38
+ queryRef.current = query;
39
+
40
+ React.useEffect(() => {
41
+ fetch("/okf-graph")
42
+ .then((r) => r.json())
43
+ .then((g) => { if (g.error) setError(g.error); else setGraph(g); })
44
+ .catch((e) => setError(String(e.message || e)));
45
+ }, []);
46
+
47
+ // 初始化布局(一次,稳定):世界坐标以(0,0)为中心,节点分布半径随数量自适应
48
+ React.useEffect(() => {
49
+ if (!graph || layoutRef.current.length) return;
50
+ const maxW = Math.max(...graph.nodes.map((n) => n.weight), 1);
51
+ const n = graph.nodes.length;
52
+ const R = Math.max(120, Math.min(300, 60 + n * 30));
53
+ layoutRef.current = graph.nodes.map((node, i) => {
54
+ const h = hash(node.id);
55
+ const ang = (i / n) * Math.PI * 2; // 环形均布 + 轻微抖动
56
+ const r = R * (0.6 + ((h % 100) / 100) * 0.5);
57
+ const x = Math.cos(ang) * r;
58
+ const y = Math.sin(ang) * r;
59
+ return { ...node, x, y, vx: 0, vy: 0, r: 8 + Math.sqrt(node.weight / maxW) * 20 };
60
+ });
61
+ }, [graph]);
62
+
63
+ // 缩放画布尺寸变化时重置 zoom
64
+ React.useEffect(() => {
65
+ const canvas = canvasRef.current; if (!canvas) return;
66
+ const fit = () => { viewRef.current = { zoom: 1, panX: 0, panY: 0 }; };
67
+ fit();
68
+ const onResize = () => fit();
69
+ window.addEventListener("resize", onResize);
70
+ return () => window.removeEventListener("resize", onResize);
71
+ }, [graph]);
72
+
73
+ // rAF 动画循环
74
+ React.useEffect(() => {
75
+ if (!graph || !canvasRef.current) return;
76
+ const canvas = canvasRef.current;
77
+ let raf = 0;
78
+ const render = () => { drawGraph(canvas, graph, queryRef.current, layoutRef, viewRef, hoverRef); raf = requestAnimationFrame(render); };
79
+ raf = requestAnimationFrame(render);
80
+ return () => cancelAnimationFrame(raf);
81
+ }, [graph]);
82
+
83
+ const onWheel = (e: React.WheelEvent) => {
84
+ e.preventDefault();
85
+ const v = viewRef.current;
86
+ const factor = e.deltaY < 0 ? 1.1 : 0.9;
87
+ v.zoom = Math.min(4, Math.max(0.3, v.zoom * factor));
88
+ forceRender((x) => x + 1);
89
+ };
90
+ const onMouseDown = (e: React.MouseEvent) => {
91
+ const canvas = canvasRef.current!; const rect = canvas.getBoundingClientRect();
92
+ const px = (e.clientX - rect.left), py = (e.clientY - rect.top);
93
+ const cw = canvas.clientWidth || rect.width, ch = canvas.clientHeight || rect.height;
94
+ const hit = hitTest(px, py, layoutRef.current, viewRef.current, cw, ch);
95
+ if (hit) { dragRef.current = { id: hit.id, offX: hit.x - px, offY: hit.y - py, panning: false, lastX: px, lastY: py }; }
96
+ else { dragRef.current = { id: null, offX: 0, offY: 0, panning: true, lastX: px, lastY: py }; }
97
+ };
98
+ const onMouseMove = (e: React.MouseEvent) => {
99
+ const canvas = canvasRef.current!; const rect = canvas.getBoundingClientRect();
100
+ const px = (e.clientX - rect.left), py = (e.clientY - rect.top);
101
+ const cw = canvas.clientWidth || rect.width, ch = canvas.clientHeight || rect.height;
102
+ const d = dragRef.current;
103
+ if (d.id) {
104
+ const n = layoutRef.current.find((x) => x.id === d.id);
105
+ if (n) {
106
+ // 拖节点:用世界坐标(逆变换),offX 为世界坐标内偏移
107
+ const wx = (px - cw / 2 - viewRef.current.panX) / viewRef.current.zoom;
108
+ const wy = (py - ch / 2 - viewRef.current.panY) / viewRef.current.zoom;
109
+ n.x = wx + d.offX; n.y = wy + d.offY; n.vx = 0; n.vy = 0;
110
+ }
111
+ } else if (d.panning) {
112
+ viewRef.current.panX += px - d.lastX; viewRef.current.panY += py - d.lastY;
113
+ d.lastX = px; d.lastY = py;
114
+ }
115
+ const hover = hitTest(px, py, layoutRef.current, viewRef.current, cw, ch)?.id ?? null;
116
+ if (hover !== hoverRef.current) { hoverRef.current = hover; forceRender((x) => x + 1); }
117
+ };
118
+ const onMouseUp = () => { dragRef.current = { id: null, offX: 0, offY: 0, panning: false, lastX: 0, lastY: 0 }; };
119
+
120
+ if (error) return React.createElement("div", { style: p }, "记忆图谱加载失败: " + error);
121
+ if (!graph) return React.createElement("div", { style: p }, "加载记忆图谱…");
122
+
123
+ const hover = layoutRef.current.find((n) => n.id === hoverRef.current);
124
+ return React.createElement("div", { style: { padding: "12px", position: "relative" } },
125
+ React.createElement("input", {
126
+ value: query, onChange: (e) => setQuery(e.target.value),
127
+ placeholder: "🔍 命中记忆(搜标题/类型/标签)…",
128
+ style: { width: "100%", boxSizing: "border-box", padding: "9px 12px", marginBottom: "10px", borderRadius: "8px", border: "1px solid rgba(120,160,200,.3)", background: "rgba(20,32,46,.8)", color: "#dbe7f3", fontSize: "13px", outline: "none" },
129
+ }),
130
+ React.createElement("div", { style: { color: "#8aa4bd", fontSize: "12px", marginBottom: "8px" } },
131
+ `${graph.nodes.length} 节点 · ${graph.edges.length} 边 · 滚轮缩放 · 拖拽平移 · 悬停查看详情 · 搜索命中→神经传导`),
132
+ React.createElement("canvas", { ref: canvasRef, onWheel, onMouseDown, onMouseMove, onMouseUp, onMouseLeave: () => { hoverRef.current = null; forceRender((x) => x + 1); }, style: { width: "100%", height: "calc(100vh - 200px)", display: "block", cursor: "crosshair" } }),
133
+ hover
134
+ ? React.createElement("div", { style: { position: "absolute", top: "54px", right: "12px", background: "rgba(10,18,28,.97)", border: "1px solid " + (TYPE_COLORS[hover.type] || "#3b5a77"), borderRadius: "8px", padding: "9px 12px", fontSize: "12px", color: "#eaf3fb", maxWidth: "320px", boxShadow: "0 6px 20px rgba(0,0,0,.45)", zIndex: 10 } },
135
+ React.createElement("b", null, hover.title), React.createElement("span", { style: { fontSize: "10px", padding: "1px 6px", borderRadius: "8px", background: (TYPE_COLORS[hover.type] || "#66") + "66", color: "#fff", marginLeft: "6px" } }, hover.type),
136
+ React.createElement("div", { style: { color: "#9db8cf", marginTop: "4px" } }, "权重 " + hover.weight + " · " + hover.state),
137
+ hover.description ? React.createElement("div", { style: { color: "#8aa4bd", marginTop: "3px" } }, hover.description) : null,
138
+ (hover.tags && hover.tags.length) ? React.createElement("div", { style: { color: "#6f8ba5", marginTop: "3px", fontSize: "11px" } }, hover.tags.map((x) => "#" + x).join(" ")) : null,
139
+ )
140
+ : null,
141
+ );
142
+ }
143
+ const p: React.CSSProperties = { padding: "24px", color: "#8aa4bd", fontFamily: "sans-serif" };
144
+
145
+ function matches(n: GraphNode, q: string): boolean {
146
+ const t = q.trim().toLowerCase(); if (!t) return false;
147
+ return (n.title + " " + n.type + " " + (n.tags || []).join(" ")).toLowerCase().includes(t);
148
+ }
149
+ function activateHits(nodes: GraphNode[], edges: GraphEdge[], hits: Set<string>): Set<string> {
150
+ const act = new Set<string>(hits);
151
+ const adj = new Map<string, string[]>();
152
+ edges.forEach((e) => { if (!adj.has(e.source)) adj.set(e.source, []); if (!adj.has(e.target)) adj.set(e.target, []); adj.get(e.source)!.push(e.target); adj.get(e.target)!.push(e.source); });
153
+ const q = [...hits];
154
+ while (q.length) { const cur = q.shift()!; (adj.get(cur) || []).forEach((nb) => { if (!act.has(nb)) { act.add(nb); q.push(nb); } }); }
155
+ return act;
156
+ }
157
+
158
+ function hitTest(px: number, py: number, nodes: LayoutNode[], view: { zoom: number; panX: number; panY: number }, canvasW: number, canvasH: number) {
159
+ // 屏幕坐标 → 世界坐标(世界原点在画布中心 W/2,H/2)
160
+ const wx = (px - canvasW / 2 - view.panX) / view.zoom, wy = (py - canvasH / 2 - view.panY) / view.zoom;
161
+ for (let i = nodes.length - 1; i >= 0; i--) { const n = nodes[i]; const dx = n.x - wx, dy = n.y - wy; if (dx * dx + dy * dy < (n.r + 6) * (n.r + 6)) return n; }
162
+ return null;
163
+ }
164
+
165
+ function drawGraph(canvas: HTMLCanvasElement, graph: { nodes: GraphNode[]; edges: GraphEdge[] }, query: string, layoutRef: React.MutableRefObject<LayoutNode[]>, viewRef: React.MutableRefObject<{ zoom: number; panX: number; panY: number }>, hoverRef: React.MutableRefObject<string | null>) {
166
+ const dpr = window.devicePixelRatio || 1;
167
+ const W = canvas.clientWidth || 800, H = canvas.clientHeight || 500;
168
+ canvas.width = W * dpr; canvas.height = H * dpr;
169
+ const ctx = canvas.getContext("2d")!; ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
170
+ const view = viewRef.current;
171
+
172
+ const maxW = Math.max(...graph.nodes.map((n) => n.weight), 1);
173
+ const nodes = layoutRef.current;
174
+ const byId = new Map(nodes.map((n) => [n.id, n]));
175
+ const edges = graph.edges.filter((e) => byId.has(e.source) && byId.has(e.target));
176
+
177
+ const hits = new Set<string>();
178
+ if (query.trim()) graph.nodes.forEach((n) => { if (matches(n, query)) hits.add(n.id); });
179
+ const active = activateHits(graph.nodes, graph.edges, hits);
180
+
181
+ // 世界原点在画布中心:tx(x)=W/2 + x*zoom + panX
182
+ const tx = (x: number) => W / 2 + x * view.zoom + view.panX;
183
+ const ty = (y: number) => H / 2 + y * view.zoom + view.panY;
184
+
185
+ ctx.fillStyle = "#13233a"; ctx.fillRect(0, 0, W, H);
186
+
187
+ // 边
188
+ edges.forEach((e) => {
189
+ const a = byId.get(e.source)!, b = byId.get(e.target)!;
190
+ const isActive = active.has(e.source) || active.has(e.target);
191
+ ctx.beginPath(); ctx.moveTo(tx(a.x), ty(a.y)); ctx.lineTo(tx(b.x), ty(b.y));
192
+ ctx.strokeStyle = isActive ? "rgba(126,195,255,.5)" : "rgba(120,160,200,.18)"; ctx.lineWidth = 1 / view.zoom; ctx.stroke();
193
+ });
194
+
195
+ const t = performance.now() / 700;
196
+ nodes.forEach((n) => {
197
+ const c = TYPE_COLORS[n.type] || TYPE_COLORS.Other;
198
+ const isHit = hits.has(n.id), isActive = active.has(n.id);
199
+ const rr = n.r * view.zoom;
200
+ const x = tx(n.x), y = ty(n.y);
201
+ if (isHit) {
202
+ const tp = (t + hash(n.id) % 10) % 1;
203
+ const pr = rr * (1.5 + tp * 1.8);
204
+ ctx.beginPath(); ctx.arc(x, y, pr, 0, Math.PI * 2); ctx.strokeStyle = hexToRgba(c, 0.8 * (1 - tp)); ctx.lineWidth = 2.5 / view.zoom; ctx.stroke();
205
+ }
206
+ if (isHit || isActive) {
207
+ const rg = ctx.createRadialGradient(x, y, rr * 0.2, x, y, rr * (isHit ? 2.9 : 2.1));
208
+ rg.addColorStop(0, hexToRgba(c, isHit ? 0.8 : 0.45)); rg.addColorStop(1, "transparent");
209
+ ctx.beginPath(); ctx.arc(x, y, rr * (isHit ? 2.9 : 2.1), 0, Math.PI * 2); ctx.fillStyle = rg; ctx.fill();
210
+ }
211
+ ctx.beginPath(); ctx.arc(x, y, rr, 0, Math.PI * 2); ctx.fillStyle = isActive ? (isHit ? "#fff" : c) : "#13233a"; ctx.globalAlpha = isActive ? 0.95 : 1; ctx.fill(); ctx.globalAlpha = 1;
212
+ ctx.beginPath(); ctx.arc(x, y, rr, 0, Math.PI * 2); ctx.strokeStyle = isHit ? "#fff" : (isActive ? c : "#33506a"); ctx.lineWidth = (isHit ? 3.2 : 1.2) / view.zoom; ctx.stroke();
213
+ ctx.globalAlpha = 1;
214
+ // 标签(缩放时字号随 zoom 微调,避免太小)
215
+ ctx.fillStyle = isActive ? "#fff" : "#5f7d97"; ctx.font = `${11 * Math.min(1.4, Math.max(0.8, view.zoom))}px sans-serif`; ctx.textAlign = "center";
216
+ ctx.fillText(n.title.slice(0, 10), x, y + rr + 12);
217
+ if (isHit) { ctx.fillStyle = "#fff"; ctx.font = `700 ${11 * Math.max(0.8, view.zoom)}px sans-serif`; ctx.fillText("⚡命中", x, y - rr - 9); }
218
+ });
219
+ }
220
+
221
+ function hash(s: string): number { let h = 0; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h; }
222
+ function hexToRgba(hex: string, a: number): string { const c = hex.replace("#", ""); const r = parseInt(c.slice(0, 2), 16), g = parseInt(c.slice(2, 4), 16), b = parseInt(c.slice(4, 6), 16); return `rgba(${r},${g},${b},${a})`; }
223
+
224
+ export function apply(ctx: { slots: { inject: (name: string, factory: () => unknown) => void; register: (spec: Record<string, unknown>, component: unknown) => unknown }; locale: { bind: (ns: string) => (key: string) => string } }): void {
225
+ ctx.locale.bind(NS);
226
+ ctx.slots.inject("conversation.view", () =>
227
+ ctx.slots.register(
228
+ { name: "conversation.view", id: "okf-memory", order: 30, locale: NS, label: () => "记忆图谱" },
229
+ MemoryGraphView,
230
+ ),
231
+ );
232
+ }