Skip to content

Commit 5cb8427

Browse files
committed
feat(mcp): 学习记忆接口闭环——应用内授权开关与世界快照跨进程桥
- memoryServerConsent.ts:设置页授权开关IPC(读写memory-server-consent标记文件,标记仍是唯一事实源),main/preload白名单接线 - MemoryServerSection:数据与存储页授权卡片,开启前披露将被读取的记忆清单,关闭即撤销 - world_snapshots表(schema v7)+白名单登记:渲染进程retention数据→sqlite的跨进程桥 - useWorldSnapshotSync:订阅珊瑚/发现store,防抖2s upsert快照行(失败静默) - memoryQueries:discoveries/world_state优先读快照精确态,无则回退派生值并标注provenance
1 parent ff3f861 commit 5cb8427

11 files changed

Lines changed: 295 additions & 15 deletions

File tree

‎client/electron/db/dbIpcHandlers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ const ALLOWED_TABLES = new Set([
3434
'assistant_messages',
3535
// assistant_triggers — AI 助手触发规则记录
3636
'assistant_triggers',
37+
// world_snapshots — 世界状态快照(渲染进程 retention 数据→MCP 记忆服务器跨进程桥)
38+
'world_snapshots',
3739
// crdt_docs — CRDT 同步引擎文档快照(v3 迁移新增)
3840
'crdt_docs',
3941
// crdt_changes — CRDT 同步引擎变更日志(v3 迁移新增)

‎client/electron/db/schema.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
*/
77
import type Database from 'better-sqlite3';
88

9-
export const SCHEMA_VERSION = 6;
9+
export const SCHEMA_VERSION = 7;
1010

1111
export const SCHEMA_DDL = /* sql */ `
1212
CREATE TABLE IF NOT EXISTS pomodoro_sessions (
@@ -189,6 +189,10 @@ CREATE TABLE IF NOT EXISTS implementation_intentions (
189189
created_at TEXT NOT NULL
190190
);
191191
CREATE INDEX IF NOT EXISTS idx_impl_intention_status ON implementation_intentions(status, trigger_at);
192+
-- v7:世界状态快照(渲染进程 retention 数据→主进程 sqlite→MCP 记忆服务器的跨进程桥)
193+
CREATE TABLE IF NOT EXISTS world_snapshots (
194+
id TEXT PRIMARY KEY, payload TEXT NOT NULL DEFAULT '{}', updated_at TEXT NOT NULL
195+
);
192196
`;
193197

194198
/** v3 迁移 DDL:CRDT 同步引擎元数据表(条件执行) */
@@ -246,5 +250,8 @@ export function initializeSchema(db: Database.Database): void {
246250
// v6 迁移:A4 实施意图表 implementation_intentions
247251
// 表 DDL 已包含在 SCHEMA_DDL 中(CREATE IF NOT EXISTS 幂等),此处无需额外操作
248252

253+
// v7 迁移:世界状态快照表 world_snapshots
254+
// 表 DDL 已包含在 SCHEMA_DDL 中(CREATE IF NOT EXISTS 幂等),此处无需额外操作
255+
249256
db.pragma(`user_version = ${SCHEMA_VERSION}`);
250257
}

‎client/electron/main.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { createMainWindow, saveCloseChoice, completeSyncBeforeQuit } from './win
2727
import { destroyTray } from './trayManager.js';
2828
import { registerCaptureHandlers, disposeCaptureHandlers } from './captureHandlers.js';
2929
import { mcpManager } from './mcpManager.js';
30+
import { registerMemoryServerConsentHandlers } from './memoryServerConsent.js';
3031
import { initialize, close as closeDb } from './db/sqliteService.js';
3132
import { initializeSchema } from './db/schema.js';
3233
import { initializeFTS, rebuildIndex, collectIndexableData } from './db/fts5Search.js';
@@ -172,6 +173,8 @@ if (!gotTheLock) {
172173
// 数据访问与存储/备份 IPC(详见 db/dbIpcHandlers.ts、storageIpcHandlers.ts)
173174
registerDbIpcHandlers();
174175
registerStorageIpcHandlers();
176+
// MCP 学习记忆服务器应用内授权开关(详见 memoryServerConsent.ts)
177+
registerMemoryServerConsentHandlers();
175178
// 课堂关键帧图片持久化 IPC + keyframe:// 协议(详见 ipc/keyframeStorage.ts)
176179
registerKeyframeIpcHandlers();
177180

‎client/electron/mcp/memoryQueries.ts‎

Lines changed: 55 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,36 @@ export function queryStreak(db: Database.Database): Record<string, unknown> {
148148
};
149149
}
150150

151-
/** learning_memory.discoveries — 深海发现(当前存于客户端 IndexedDB,sqlite 未接线) */
152-
export function queryDiscoveries(): Record<string, unknown> {
151+
/** 读取渲染进程同步过来的世界快照(useWorldSnapshotSync 写入,无则 null) */
152+
function readWorldSnapshot(db: Database.Database): {
153+
corals?: { total: number; healthy: number; bleached: number; totalDepth: number };
154+
discoveries?: { count: number };
155+
capturedAt?: string;
156+
} | null {
157+
try {
158+
const row = db.prepare("SELECT payload FROM world_snapshots WHERE id = 'latest'").get() as
159+
{ payload: string } | undefined;
160+
if (!row) return null;
161+
return JSON.parse(row.payload);
162+
} catch {
163+
return null;
164+
}
165+
}
166+
167+
/** learning_memory.discoveries — 深海发现(优先读世界快照,无则占位) */
168+
export function queryDiscoveries(db: Database.Database): Record<string, unknown> {
169+
const snap = readWorldSnapshot(db);
170+
if (!snap?.discoveries) {
171+
return {
172+
count: null,
173+
items: [],
174+
note: '尚未获取到发现图鉴快照(应用启动并产生发现后自动同步)',
175+
};
176+
}
153177
return {
154-
count: null,
155-
items: [],
156-
note: '发现图鉴暂存于客户端本地存储,跨进程接线将在后续批次完成',
178+
count: snap.discoveries.count,
179+
capturedAt: snap.capturedAt ?? null,
180+
note: '图鉴明细存于客户端本地,此处为累计数量快照',
157181
};
158182
}
159183

@@ -185,11 +209,12 @@ export function queryRecentSessions(db: Database.Database, limitRaw: unknown): R
185209
}
186210

187211
/**
188-
* learning_memory.world_state — 世界状态快照(sqlite 派生版)
189-
* @ai-context 珊瑚/发现实体存于渲染进程 IndexedDB,此处以 sqlite 过程数据
190-
* 派生近似信号并显式标注 provenance,避免误导消费方。
212+
* learning_memory.world_state — 世界状态快照
213+
* @ai-context 优先消费渲染进程同步的精确快照(珊瑚健康度→雾与活力);
214+
* 无快照时回退 sqlite 过程数据派生值并显式标注 provenance。
191215
*/
192216
export function queryWorldState(db: Database.Database): Record<string, unknown> {
217+
const snap = readWorldSnapshot(db);
193218
const focus = db.prepare(`
194219
SELECT COALESCE(SUM(actual_duration), 0) AS totalSeconds FROM pomodoro_sessions WHERE interrupted = 0
195220
`).get() as { totalSeconds: number };
@@ -198,11 +223,31 @@ export function queryWorldState(db: Database.Database): Record<string, unknown>
198223
).get() as { n: number }).n;
199224
const totalCards = (db.prepare('SELECT COUNT(*) AS n FROM flashcards').get() as { n: number }).n;
200225

201-
return {
226+
const base = {
202227
depthMeters: Math.round(focus.totalSeconds / 60),
203228
depthNote: '以专注分钟折算的潜航深度(1 分钟 ≈ 1 米)',
204229
hazyShare: totalCards > 0 ? Math.round((due / totalCards) * 100) / 100 : 0,
205230
hazyNote: '朦胧知识占比(0-1)——雾的浓度,复习即拨开',
206-
provenance: '由主库过程数据派生;珊瑚/发现等世界实体的精确状态以后续批次为准',
231+
};
232+
233+
if (snap?.corals) {
234+
const { total, healthy, bleached, totalDepth } = snap.corals;
235+
return {
236+
...base,
237+
ecosystem: {
238+
coralTotal: total,
239+
coralHealthy: healthy,
240+
coralBleached: bleached,
241+
vitality: total > 0 ? Math.round((healthy / total) * 100) / 100 : null,
242+
accumulatedDepthMeters: totalDepth,
243+
},
244+
capturedAt: snap.capturedAt ?? null,
245+
provenance: '珊瑚生态来自渲染进程同步快照(精确态);潜航深度与朦胧占比由主库派生',
246+
};
247+
}
248+
249+
return {
250+
...base,
251+
provenance: '由主库过程数据派生;珊瑚精确态待应用启动后同步',
207252
};
208253
}

‎client/electron/mcpMemoryServer.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ async function main(): Promise<void> {
141141
discoveries: {
142142
description: '深海发现图鉴(跨进程接线建设中,当前返回占位)',
143143
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
144-
run: () => queryDiscoveries(),
144+
run: () => queryDiscoveries(db),
145145
},
146146
recent_sessions: {
147147
description: '最近学习会话摘要(深潜与费曼)',
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/**
2+
* MCP 学习记忆服务器 · 应用内授权开关
3+
* MCP learning-memory server · in-app consent toggle
4+
*
5+
* @ai-context: 宪法 P2 内层防御的授权面:此前授权依赖手动创建
6+
* memory-server-consent 标记文件,本模块把开关搬进应用设置页。
7+
* 标记文件仍是唯一事实源(mcpMemoryServer 独立进程只认它),
8+
* 本模块仅负责读写该文件——双端语义天然一致。
9+
*
10+
* @ai-context: In-app consent toggle for the memory server. The marker file
11+
* remains the single source of truth; this module only reads/writes it.
12+
*/
13+
import { app, ipcMain } from 'electron';
14+
import * as path from 'path';
15+
import * as fs from 'fs';
16+
import { logger } from './logger.js';
17+
18+
const CONSENT_MARKER = 'memory-server-consent';
19+
20+
function markerPath(): string {
21+
return path.join(app.getPath('userData'), CONSENT_MARKER);
22+
}
23+
24+
export function registerMemoryServerConsentHandlers(): void {
25+
ipcMain.handle('memory_server:get_consent', () => {
26+
try {
27+
return fs.existsSync(markerPath());
28+
} catch {
29+
return false;
30+
}
31+
});
32+
33+
ipcMain.handle('memory_server:set_consent', (_event, enabled: unknown) => {
34+
try {
35+
const p = markerPath();
36+
if (enabled === true) {
37+
fs.writeFileSync(
38+
p,
39+
`用户于 ${new Date().toISOString()} 在应用设置页显式开启学习记忆接口\n`,
40+
'utf-8',
41+
);
42+
logger.info('[MemoryServer] consent granted via settings');
43+
} else {
44+
fs.rmSync(p, { force: true });
45+
logger.info('[MemoryServer] consent revoked via settings');
46+
}
47+
return { success: true };
48+
} catch (err) {
49+
const message = err instanceof Error ? err.message : String(err);
50+
logger.error('[MemoryServer] consent toggle failed:', message);
51+
return { success: false, error: message };
52+
}
53+
});
54+
}

‎client/electron/preload.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,9 @@ const ALLOWED_CHANNELS = [
120120
'local_asr_stream_available',
121121
'local_asr_stream_start',
122122
'local_asr_stream_stop',
123+
// MCP 学习记忆服务器应用内授权开关
124+
'memory_server:get_consent',
125+
'memory_server:set_consent',
123126
] as const;
124127

125128
/** 允许渲染进程监听的事件 channel 白名单(主进程 → 渲染进程推送) */
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* 世界状态快照同步 Hook(宪法 P2 · discoveries/world_state 跨进程接线)
3+
* World snapshot sync hook (cross-process bridge to the MCP memory server)
4+
*
5+
* @ai-context: 珊瑚/发现数据存于渲染进程 IndexedDB,MCP 记忆服务器读主进程
6+
* sqlite——本 hook 是两者间的单向桥:订阅 retention store 变化,防抖 2s 后
7+
* 把摘要快照 upsert 到 world_snapshots 表(单行 id='latest')。
8+
* 失败静默(快照是增强项,不影响主流程)。
9+
*
10+
* @ai-context: One-way bridge from renderer retention stores to the
11+
* world_snapshots sqlite row consumed by the MCP memory server.
12+
*/
13+
import { useEffect, useRef } from 'react';
14+
import { useEcosystemStore } from '../store/useEcosystemStore';
15+
import { useDiscoveryStore } from '../store/useDiscoveryStore';
16+
17+
const SNAPSHOT_ID = 'latest';
18+
const DEBOUNCE_MS = 2000;
19+
20+
export function useWorldSnapshotSync(): void {
21+
const corals = useEcosystemStore((s) => s.corals);
22+
const totalDepth = useEcosystemStore((s) => s.totalDepth);
23+
const discoveriesCount = useDiscoveryStore((s) => s.totalCount);
24+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
25+
26+
useEffect(() => {
27+
if (!window.electronAPI?.db) return;
28+
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 桥无 put 语义)
44+
const existing = await window.electronAPI.db.query('worldSnapshots', 'get', [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+
55+
if (timer.current) clearTimeout(timer.current);
56+
timer.current = setTimeout(() => { void write(); }, DEBOUNCE_MS);
57+
}, [corals, totalDepth, discoveriesCount]);
58+
59+
useEffect(() => () => {
60+
if (timer.current) clearTimeout(timer.current);
61+
}, []);
62+
}

‎client/src/pages/settings/DataSettings.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@ import { StoragePathSection } from './StoragePathSection';
1111
import { ImportExportSection } from './ImportExportSection';
1212
import { EncryptedBackupSection } from './EncryptedBackupSection';
1313
import { ClearDataSection } from './ClearDataSection';
14+
import { MemoryServerSection } from './MemoryServerSection';
1415

1516
export default function DataSettings() {
1617
return (
1718
<Card padding="md" className="flex flex-col gap-kb-md">
1819
<h2 className="text-b1 font-semibold text-text-primary">数据与存储</h2>
1920

2021
<StoragePathSection />
22+
<MemoryServerSection />
2123
<ImportExportSection />
2224
<EncryptedBackupSection />
2325
<ClearDataSection />

0 commit comments

Comments
 (0)