Skip to content

Commit ff3f861

Browse files
committed
feat(mcp): 学习记忆服务器主体落地(宪法P2第二批·内层防御)
- mcpMemoryServer.ts:stdio MCP Server 独立入口——授权标记门禁(无consent即拒启)、只读打开keban.db、调用留痕memory-server-access.log、零网络监听 - mcp/memoryQueries.ts:8个learning_memory.*只读摘要查询(profile/mastery/review_candidates/focus_stats/streak/discoveries/recent_sessions/world_state),粒度上限100、焦虑防线传导(零倒计时、朦胧档位表达) - 宿主以ELECTRON_RUN_AS_NODE方式启动(原生模块ABI兼容),接入配置写入接口文档§八 - discoveries/world_state精确态为派生占位(珊瑚/发现存于渲染进程IndexedDB),跨进程接线列入后续批次
1 parent 52268d0 commit ff3f861

4 files changed

Lines changed: 437 additions & 4 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
/**
2+
* MCP 学习记忆查询层(纯函数,只读)
3+
* MCP learning-memory query layer (pure, read-only)
4+
*
5+
* @ai-context: 宪法 P2 内层防御的数据层:把本地学习数据聚合为
6+
* 摘要级 JSON,供 mcpMemoryServer 的 8 个 learning_memory.* 工具消费。
7+
* 硬约束(docs/product/mcp-learning-memory-interface.md §五):
8+
* - 只读:仅 SELECT,无写入;limit 上限 100(粒度上限防整库抽取)
9+
* - 焦虑防线传导:不输出倒计时/赤字/比较字段,朦胧度只给档位
10+
* - 摘要级:不返回卡片背面原文等整行数据(front 截断 60 字)
11+
*
12+
* @ai-context: Read-only aggregation over the local SQLite learning DB.
13+
* Anxiety-defense compliant: no countdowns, no deficits, tier labels only.
14+
*/
15+
import type Database from 'better-sqlite3';
16+
17+
/** 粒度上限(草案 §五.6:单次响应 ≤100 条摘要级记录) */
18+
export const MAX_LIMIT = 100;
19+
export const DEFAULT_LIMIT = 20;
20+
21+
/** 归一化 limit 入参(防注入仅靠参数绑定,防超量靠此处封顶) */
22+
export function clampLimit(input: unknown): number {
23+
const n = typeof input === 'number' && Number.isFinite(input) ? Math.floor(input) : DEFAULT_LIMIT;
24+
return Math.min(Math.max(n, 1), MAX_LIMIT);
25+
}
26+
27+
/** 朦胧度档位(焦虑防线:只给档位不给天数) / Haze tier labels */
28+
function hazeTier(easeFactor: number, intervalDays: number): '牢固' | '成长中' | '朦胧' {
29+
if (easeFactor >= 2.4 && intervalDays >= 14) return '牢固';
30+
if (easeFactor >= 2.0 || intervalDays >= 3) return '成长中';
31+
return '朦胧';
32+
}
33+
34+
/** learning_memory.profile — 学习画像摘要 */
35+
export function queryProfile(db: Database.Database): Record<string, unknown> {
36+
const focus = db.prepare(`
37+
SELECT COUNT(*) AS sessions, COALESCE(SUM(actual_duration), 0) AS totalSeconds
38+
FROM pomodoro_sessions WHERE interrupted = 0
39+
`).get() as { sessions: number; totalSeconds: number };
40+
41+
const hourRow = db.prepare(`
42+
SELECT CAST(substr(completed_at, 12, 2) AS INTEGER) AS hour, COUNT(*) AS n
43+
FROM pomodoro_sessions GROUP BY hour ORDER BY n DESC LIMIT 1
44+
`).get() as { hour: number; n: number } | undefined;
45+
46+
const counts = {
47+
notes: (db.prepare('SELECT COUNT(*) AS n FROM notes').get() as { n: number }).n,
48+
flashcards: (db.prepare('SELECT COUNT(*) AS n FROM flashcards').get() as { n: number }).n,
49+
feynmanCompleted: (db.prepare(
50+
"SELECT COUNT(*) AS n FROM feynman_notes WHERE status = 'completed'",
51+
).get() as { n: number }).n,
52+
inspirations: (db.prepare('SELECT COUNT(*) AS n FROM inspirations').get() as { n: number }).n,
53+
};
54+
55+
return {
56+
totalFocusMinutes: Math.round(focus.totalSeconds / 60),
57+
completedSessions: focus.sessions,
58+
bestFocusHour: hourRow ? hourRow.hour : null,
59+
moduleFootprint: counts,
60+
note: '摘要级画像;原始数据始终保留在用户本地',
61+
};
62+
}
63+
64+
/** learning_memory.mastery — 概念掌握度(topic 可选模糊过滤) */
65+
export function queryMastery(db: Database.Database, topic?: string): Record<string, unknown> {
66+
const where = topic ? "WHERE front LIKE ? ESCAPE '\\'" : '';
67+
const param = topic ? [`%${topic.replace(/[%_\\]/g, '\\$&')}%`] : [];
68+
const rows = db.prepare(`
69+
SELECT front, ease_factor, "interval" FROM flashcards ${where} ORDER BY updated_at DESC LIMIT ?
70+
`).all(...param, MAX_LIMIT) as Array<{ front: string; ease_factor: number; interval: number }>;
71+
72+
const items = rows.map((r) => ({
73+
concept: r.front.slice(0, 60),
74+
tier: hazeTier(r.ease_factor, r.interval),
75+
}));
76+
const tally = { 牢固: 0, 成长中: 0, 朦胧: 0 };
77+
for (const it of items) tally[it.tier]++;
78+
79+
return { total: items.length, tally, items, note: '档位为相对状态,不含任何倒计时' };
80+
}
81+
82+
/** learning_memory.review_candidates — 待唤醒知识(零倒计时表达) */
83+
export function queryReviewCandidates(db: Database.Database, limitRaw: unknown): Record<string, unknown> {
84+
const limit = clampLimit(limitRaw);
85+
const rows = db.prepare(`
86+
SELECT front, ease_factor, "interval" FROM flashcards
87+
WHERE due_date <= datetime('now') ORDER BY due_date ASC LIMIT ?
88+
`).all(limit) as Array<{ front: string; ease_factor: number; interval: number }>;
89+
90+
return {
91+
count: rows.length,
92+
items: rows.map((r) => ({
93+
concept: r.front.slice(0, 60),
94+
haze: hazeTier(r.ease_factor, r.interval),
95+
})),
96+
note: '朦胧的知识等待唤醒——复习即点亮,无截止压力',
97+
};
98+
}
99+
100+
/** learning_memory.focus_stats — 专注历史统计 */
101+
export function queryFocusStats(db: Database.Database, rangeDaysRaw: unknown): Record<string, unknown> {
102+
const rangeDays = typeof rangeDaysRaw === 'number' && rangeDaysRaw > 0
103+
? Math.min(Math.floor(rangeDaysRaw), 365) : 30;
104+
const rows = db.prepare(`
105+
SELECT actual_duration, interrupted, CAST(substr(completed_at, 12, 2) AS INTEGER) AS hour
106+
FROM pomodoro_sessions WHERE completed_at >= datetime('now', ?)
107+
`).all(`-${rangeDays} days`) as Array<{ actual_duration: number; interrupted: number; hour: number }>;
108+
109+
if (rows.length === 0) return { rangeDays, sessions: 0, note: '这段时间没有深潜记录' };
110+
111+
const total = rows.reduce((s, r) => s + r.actual_duration, 0);
112+
const done = rows.filter((r) => !r.interrupted);
113+
const hourTally = new Map<number, number>();
114+
for (const r of rows) hourTally.set(r.hour, (hourTally.get(r.hour) ?? 0) + 1);
115+
const bestHour = [...hourTally.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null;
116+
117+
return {
118+
rangeDays,
119+
sessions: rows.length,
120+
totalMinutes: Math.round(total / 60),
121+
averageMinutes: Math.round(total / rows.length / 60),
122+
completionRate: rows.length ? Math.round((done.length / rows.length) * 100) : 0,
123+
bestFocusHour: bestHour,
124+
};
125+
}
126+
127+
/** learning_memory.streak — 连击状态(study_check_ins 口径,含休息日语义) */
128+
export function queryStreak(db: Database.Database): Record<string, unknown> {
129+
const rows = db.prepare(
130+
'SELECT "date", streak_days FROM study_check_ins ORDER BY "date" DESC LIMIT ?',
131+
).all(MAX_LIMIT) as Array<{ date: string; streak_days: number }>;
132+
133+
if (rows.length === 0) return { currentStreak: 0, note: '还没有打卡记录——首次深潜即开始' };
134+
135+
// 从最近日期向前逐日检查(与 worldState 派生口径一致)
136+
let current = 1;
137+
for (let i = 1; i < rows.length; i++) {
138+
const prev = new Date(rows[i - 1].date).getTime();
139+
const curr = new Date(rows[i].date).getTime();
140+
if ((prev - curr) / 86_400_000 === 1) current++;
141+
else break;
142+
}
143+
return {
144+
currentStreak: current,
145+
recordedStreakDays: rows[0].streak_days,
146+
totalCheckInDays: rows.length,
147+
note: '洋流休息日不计为断裂——可逆原则',
148+
};
149+
}
150+
151+
/** learning_memory.discoveries — 深海发现(当前存于客户端 IndexedDB,sqlite 未接线) */
152+
export function queryDiscoveries(): Record<string, unknown> {
153+
return {
154+
count: null,
155+
items: [],
156+
note: '发现图鉴暂存于客户端本地存储,跨进程接线将在后续批次完成',
157+
};
158+
}
159+
160+
/** learning_memory.recent_sessions — 最近学习会话摘要 */
161+
export function queryRecentSessions(db: Database.Database, limitRaw: unknown): Record<string, unknown> {
162+
const limit = clampLimit(limitRaw);
163+
const dives = db.prepare(`
164+
SELECT mode, duration, actual_duration, interrupted, goal, completed_at
165+
FROM pomodoro_sessions ORDER BY completed_at DESC LIMIT ?
166+
`).all(limit) as Array<{
167+
mode: string; duration: number; actual_duration: number;
168+
interrupted: number; goal: string | null; completed_at: string;
169+
}>;
170+
const feynman = db.prepare(`
171+
SELECT concept, status, completed_at FROM feynman_notes ORDER BY updated_at DESC LIMIT ?
172+
`).all(limit) as Array<{ concept: string; status: string; completed_at: string | null }>;
173+
174+
return {
175+
dives: dives.map((d) => ({
176+
mode: d.mode,
177+
plannedMinutes: Math.round(d.duration / 60),
178+
actualMinutes: Math.round(d.actual_duration / 60),
179+
completed: !d.interrupted,
180+
goal: d.goal ?? null,
181+
at: d.completed_at,
182+
})),
183+
feynman: feynman.map((f) => ({ concept: f.concept.slice(0, 60), status: f.status, at: f.completed_at })),
184+
};
185+
}
186+
187+
/**
188+
* learning_memory.world_state — 世界状态快照(sqlite 派生版)
189+
* @ai-context 珊瑚/发现实体存于渲染进程 IndexedDB,此处以 sqlite 过程数据
190+
* 派生近似信号并显式标注 provenance,避免误导消费方。
191+
*/
192+
export function queryWorldState(db: Database.Database): Record<string, unknown> {
193+
const focus = db.prepare(`
194+
SELECT COALESCE(SUM(actual_duration), 0) AS totalSeconds FROM pomodoro_sessions WHERE interrupted = 0
195+
`).get() as { totalSeconds: number };
196+
const due = (db.prepare(
197+
"SELECT COUNT(*) AS n FROM flashcards WHERE due_date <= datetime('now')",
198+
).get() as { n: number }).n;
199+
const totalCards = (db.prepare('SELECT COUNT(*) AS n FROM flashcards').get() as { n: number }).n;
200+
201+
return {
202+
depthMeters: Math.round(focus.totalSeconds / 60),
203+
depthNote: '以专注分钟折算的潜航深度(1 分钟 ≈ 1 米)',
204+
hazyShare: totalCards > 0 ? Math.round((due / totalCards) * 100) / 100 : 0,
205+
hazyNote: '朦胧知识占比(0-1)——雾的浓度,复习即拨开',
206+
provenance: '由主库过程数据派生;珊瑚/发现等世界实体的精确状态以后续批次为准',
207+
};
208+
}

0 commit comments

Comments
 (0)