Skip to content

Commit e65035c

Browse files
committed
feat(mcp): 知识图谱 MCP 工具与应用内演示剧本——learning_memory.knowledge_graph+三步引导+访问审计+快照事件同步(阶段C)
1 parent a2875d4 commit e65035c

6 files changed

Lines changed: 288 additions & 37 deletions

File tree

‎client/electron/mcp/memoryQueries.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,53 @@ export function queryDiscoveries(db: Database.Database): Record<string, unknown>
181181
};
182182
}
183183

184+
/** learning_memory.knowledge_graph — 知识图谱摘要(节点档位 + 关联数,无卡片背面原文) */
185+
export function queryKnowledgeGraph(db: Database.Database, limitRaw: unknown): Record<string, unknown> {
186+
const limit = clampLimit(limitRaw);
187+
188+
// 节点一:概念卡(摘要级:concept 截断 60 字 + 掌握档位 + 同源簇大小)
189+
const cards = db.prepare(`
190+
SELECT id, front, ease_factor, "interval", source_ref
191+
FROM flashcards ORDER BY updated_at DESC LIMIT ?
192+
`).all(limit) as Array<{
193+
id: string; front: string; ease_factor: number; interval: number; source_ref: string | null;
194+
}>;
195+
const refRows = db.prepare(`
196+
SELECT source_ref, COUNT(*) AS n FROM flashcards
197+
WHERE source_ref IS NOT NULL AND source_ref != '' GROUP BY source_ref
198+
`).all() as Array<{ source_ref: string; n: number }>;
199+
const refSize = new Map(refRows.map((r) => [r.source_ref, r.n]));
200+
201+
// 节点二:费曼薄弱点(in_progress,关联卡片数 = 概念精确匹配)
202+
const feynman = db.prepare(`
203+
SELECT f.id, f.concept,
204+
(SELECT COUNT(*) FROM flashcards c WHERE c.front = f.concept) AS linkedCards
205+
FROM feynman_notes f WHERE f.status = 'in_progress'
206+
ORDER BY f.updated_at DESC LIMIT ?
207+
`).all(limit) as Array<{ id: string; concept: string; linkedCards: number }>;
208+
209+
const nodes: Array<Record<string, unknown>> = [
210+
...cards.map((c) => ({
211+
id: `card:${c.id}`,
212+
concept: c.front.slice(0, 60),
213+
tier: hazeTier(c.ease_factor, c.interval),
214+
sourceLinks: c.source_ref ? (refSize.get(c.source_ref) ?? 1) : 0,
215+
})),
216+
...feynman.map((f) => ({
217+
id: `feynman:${f.id}`,
218+
concept: f.concept.slice(0, 60),
219+
tier: '成长中',
220+
weakLinks: f.linkedCards,
221+
})),
222+
];
223+
224+
return {
225+
total: nodes.length,
226+
nodes,
227+
note: '图谱摘要:概念前 60 字 + 掌握档位 + 关联数;原始数据始终保留在用户本地',
228+
};
229+
}
230+
184231
/** learning_memory.recent_sessions — 最近学习会话摘要 */
185232
export function queryRecentSessions(db: Database.Database, limitRaw: unknown): Record<string, unknown> {
186233
const limit = clampLimit(limitRaw);

‎client/electron/mcpMemoryServer.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ async function main(): Promise<void> {
9393
const {
9494
queryProfile, queryMastery, queryReviewCandidates, queryFocusStats,
9595
queryStreak, queryDiscoveries, queryRecentSessions, queryWorldState,
96+
queryKnowledgeGraph,
9697
} = await import('./mcp/memoryQueries.js');
9798

9899
/** 工具表:name → 描述 + JSON Schema + 处理函数(全部只读摘要) */
@@ -139,7 +140,7 @@ async function main(): Promise<void> {
139140
run: () => queryStreak(db),
140141
},
141142
discoveries: {
142-
description: '深海发现图鉴(跨进程接线建设中,当前返回占位)',
143+
description: '深海发现图鉴:累计发现数量(跨进程快照同步,明细存于客户端本地)',
143144
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
144145
run: () => queryDiscoveries(db),
145146
},
@@ -157,6 +158,15 @@ async function main(): Promise<void> {
157158
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
158159
run: () => queryWorldState(db),
159160
},
161+
knowledge_graph: {
162+
description: '知识图谱摘要:概念节点(掌握档位+同源关联数)与费曼薄弱点(关联卡片数),无卡片背面原文',
163+
inputSchema: {
164+
type: 'object',
165+
properties: { limit: { type: 'number', description: '返回上限(1-100,默认 20)' } },
166+
additionalProperties: false,
167+
},
168+
run: (a) => queryKnowledgeGraph(db, a.limit),
169+
},
160170
};
161171

162172
// SDK 低层 Server API(避免对传递依赖 zod 的直接引用)

‎client/electron/memoryServerConsent.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,28 @@ import * as fs from 'fs';
1616
import { logger } from './logger.js';
1717

1818
const CONSENT_MARKER = 'memory-server-consent';
19+
const ACCESS_LOG_FILE = 'memory-server-access.log';
20+
/** 审计列表长度上限(最近 50 条,倒序) */
21+
const ACCESS_LOG_MAX = 50;
1922

2023
function markerPath(): string {
2124
return path.join(app.getPath('userData'), CONSENT_MARKER);
2225
}
2326

27+
/** 读取访问审计日志(最近 50 条,倒序;日志行格式:ISO8601 learning_memory.<tool>) */
28+
function readAccessLog(): { entries: Array<{ at: string; tool: string }>; total: number } {
29+
const p = path.join(app.getPath('userData'), ACCESS_LOG_FILE);
30+
if (!fs.existsSync(p)) return { entries: [], total: 0 };
31+
const text = fs.readFileSync(p, 'utf-8');
32+
const lines = text.split(/\r?\n/).filter((l) => l.trim().length > 0);
33+
const entries = lines.slice(-ACCESS_LOG_MAX).reverse().map((line) => {
34+
const idx = line.indexOf(' ');
35+
if (idx <= 0) return { at: line, tool: '' };
36+
return { at: line.slice(0, idx), tool: line.slice(idx + 1) };
37+
});
38+
return { entries, total: lines.length };
39+
}
40+
2441
export function registerMemoryServerConsentHandlers(): void {
2542
ipcMain.handle('memory_server:get_consent', () => {
2643
try {
@@ -51,4 +68,24 @@ export function registerMemoryServerConsentHandlers(): void {
5168
return { success: false, error: message };
5269
}
5370
});
71+
72+
// 访问审计:谁在何时读过你的记忆(计划 C2:最近 50 条)
73+
ipcMain.handle('memory_server:get_access_log', () => {
74+
try {
75+
return readAccessLog();
76+
} catch (err) {
77+
const message = err instanceof Error ? err.message : String(err);
78+
logger.error('[MemoryServer] read access log failed:', message);
79+
return { entries: [], total: 0, error: message };
80+
}
81+
});
82+
83+
// 宿主配置:三步引导第一步「复制配置」的素材(供 Claude Desktop 等粘贴)
84+
ipcMain.handle('memory_server:get_host_config', () => {
85+
return {
86+
command: process.execPath,
87+
env: { ELECTRON_RUN_AS_NODE: '1' },
88+
args: [path.join(__dirname, 'mcpMemoryServer.js')],
89+
};
90+
});
5491
}

‎client/electron/preload.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ const ALLOWED_CHANNELS = [
125125
// MCP 学习记忆服务器应用内授权开关
126126
'memory_server:get_consent',
127127
'memory_server:set_consent',
128+
// 阶段 C:访问审计(最近 50 条)+ 宿主配置(三步引导第一步)
129+
'memory_server:get_access_log',
130+
'memory_server:get_host_config',
128131
// 知识入籍 IPC channel(阶段 A:PDF 解析/URL 抓取/入籍记录)
129132
'import:parse-pdf',
130133
'import:fetch-url',

‎client/src/features/retention/hooks/useWorldSnapshotSync.ts‎

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@
33
* World snapshot sync hook (cross-process bridge to the MCP memory server)
44
*
55
* @ai-context: 珊瑚/发现数据存于渲染进程 IndexedDB,MCP 记忆服务器读主进程
6-
* sqlite——本 hook 是两者间的单向桥:订阅 retention store 变化,防抖 2s 后
7-
* 把摘要快照 upsert 到 world_snapshots 表(单行 id='latest')。
6+
* sqlite——本 hook 是两者间的单向桥:数据变化防抖 2s 后把摘要快照 upsert
7+
* 到 world_snapshots 表(单行 id='latest')。阶段 C 新增事件驱动:订阅
8+
* useWorldEvents 签名时刻(掌握/创世/入籍,含 knowledge_settled 语义)
9+
* 立即同步——AI 在签名时刻后马上读到新世界,不等防抖窗口。
810
* 失败静默(快照是增强项,不影响主流程)。
911
*
1012
* @ai-context: One-way bridge from renderer retention stores to the
11-
* world_snapshots sqlite row consumed by the MCP memory server.
13+
* world_snapshots sqlite row. Debounced 2s on data change; immediate on
14+
* signature moments (mastery/genesis/settling) via the world event bus.
1215
*/
13-
import { useEffect, useRef } from 'react';
16+
import { useCallback, useEffect, useRef } from 'react';
1417
import { useEcosystemStore } from '../store/useEcosystemStore';
1518
import { useDiscoveryStore } from '../store/useDiscoveryStore';
19+
import { useWorldEvents } from '../store/useWorldEvents';
1620

1721
const SNAPSHOT_ID = 'latest';
1822
const DEBOUNCE_MS = 2000;
@@ -21,42 +25,54 @@ export function useWorldSnapshotSync(): void {
2125
const corals = useEcosystemStore((s) => s.corals);
2226
const totalDepth = useEcosystemStore((s) => s.totalDepth);
2327
const discoveriesCount = useDiscoveryStore((s) => s.totalCount);
28+
// 签名时刻序列号(宪法第三条:掌握/创世/入籍)——事件驱动同步信号
29+
const signatureSeq = useWorldEvents((s) => s.signatureSeq);
2430
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
2531

26-
useEffect(() => {
32+
/** 写快照(幂等 upsert,失败静默)——防抖与事件驱动共用 / Shared upsert */
33+
const write = useCallback(async () => {
2734
if (!window.electronAPI?.db) return;
35+
const healthy = corals.filter((c) => c.health === 'healthy').length;
36+
const payload = JSON.stringify({
37+
corals: {
38+
total: corals.length,
39+
healthy,
40+
bleached: corals.length - healthy,
41+
totalDepth,
42+
},
43+
discoveries: { count: discoveriesCount },
44+
capturedAt: new Date().toISOString(),
45+
});
46+
const row = { id: SNAPSHOT_ID, payload, updated_at: new Date().toISOString() };
47+
try {
48+
// 先查后写实现 upsert(db 桥查询方法白名单为 getAll/getById/count)
49+
const existing = await window.electronAPI.db.query('worldSnapshots', 'getById', [SNAPSHOT_ID]);
50+
if (existing) {
51+
await window.electronAPI.db.update('worldSnapshots', SNAPSHOT_ID, {
52+
payload, updated_at: row.updated_at,
53+
});
54+
} else {
55+
await window.electronAPI.db.insert('worldSnapshots', row);
56+
}
57+
} catch { /* 快照失败静默降级 */ }
58+
}, [corals, totalDepth, discoveriesCount]);
2859

29-
const write = async () => {
30-
const healthy = corals.filter((c) => c.health === 'healthy').length;
31-
const payload = JSON.stringify({
32-
corals: {
33-
total: corals.length,
34-
healthy,
35-
bleached: corals.length - healthy,
36-
totalDepth,
37-
},
38-
discoveries: { count: discoveriesCount },
39-
capturedAt: new Date().toISOString(),
40-
});
41-
const row = { id: SNAPSHOT_ID, payload, updated_at: new Date().toISOString() };
42-
try {
43-
// 先查后写实现 upsert(db 桥查询方法白名单为 getAll/getById/count)
44-
const existing = await window.electronAPI.db.query('worldSnapshots', 'getById', [SNAPSHOT_ID]);
45-
if (existing) {
46-
await window.electronAPI.db.update('worldSnapshots', SNAPSHOT_ID, {
47-
payload, updated_at: row.updated_at,
48-
});
49-
} else {
50-
await window.electronAPI.db.insert('worldSnapshots', row);
51-
}
52-
} catch { /* 快照失败静默降级 */ }
53-
};
54-
60+
// 数据变化:防抖 2s 后写(批量小变化合并为一次)
61+
useEffect(() => {
5562
if (timer.current) clearTimeout(timer.current);
5663
timer.current = setTimeout(() => { void write(); }, DEBOUNCE_MS);
57-
}, [corals, totalDepth, discoveriesCount]);
64+
}, [write]);
5865

66+
// 事件驱动:签名时刻立即同步(取消挂起防抖,避免重复写)
67+
useEffect(() => {
68+
if (signatureSeq === 0) return;
69+
if (timer.current) clearTimeout(timer.current);
70+
void write();
71+
}, [signatureSeq, write]);
72+
73+
// 卸载清理
5974
useEffect(() => () => {
6075
if (timer.current) clearTimeout(timer.current);
6176
}, []);
6277
}
78+

0 commit comments

Comments
 (0)