|
| 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