@machinespirits/eval 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.
Files changed (68) hide show
  1. package/components/MobileEvalDashboard.tsx +267 -0
  2. package/components/comparison/DeltaAnalysisTable.tsx +137 -0
  3. package/components/comparison/ProfileComparisonCard.tsx +176 -0
  4. package/components/comparison/RecognitionABMode.tsx +385 -0
  5. package/components/comparison/RecognitionMetricsPanel.tsx +135 -0
  6. package/components/comparison/WinnerIndicator.tsx +64 -0
  7. package/components/comparison/index.ts +5 -0
  8. package/components/mobile/BottomSheet.tsx +233 -0
  9. package/components/mobile/DimensionBreakdown.tsx +210 -0
  10. package/components/mobile/DocsView.tsx +363 -0
  11. package/components/mobile/LogsView.tsx +481 -0
  12. package/components/mobile/PsychodynamicQuadrant.tsx +261 -0
  13. package/components/mobile/QuickTestView.tsx +1098 -0
  14. package/components/mobile/RecognitionTypeChart.tsx +124 -0
  15. package/components/mobile/RecognitionView.tsx +809 -0
  16. package/components/mobile/RunDetailView.tsx +261 -0
  17. package/components/mobile/RunHistoryView.tsx +367 -0
  18. package/components/mobile/ScoreRadial.tsx +211 -0
  19. package/components/mobile/StreamingLogPanel.tsx +230 -0
  20. package/components/mobile/SynthesisStrategyChart.tsx +140 -0
  21. package/config/interaction-eval-scenarios.yaml +832 -0
  22. package/config/learner-agents.yaml +248 -0
  23. package/docs/research/ABLATION-DIALOGUE-ROUNDS.md +52 -0
  24. package/docs/research/ABLATION-MODEL-SELECTION.md +53 -0
  25. package/docs/research/ADVANCED-EVAL-ANALYSIS.md +60 -0
  26. package/docs/research/ANOVA-RESULTS-2026-01-14.md +257 -0
  27. package/docs/research/COMPREHENSIVE-EVALUATION-PLAN.md +586 -0
  28. package/docs/research/COST-ANALYSIS.md +56 -0
  29. package/docs/research/CRITICAL-REVIEW-RECOGNITION-TUTORING.md +340 -0
  30. package/docs/research/DYNAMIC-VS-SCRIPTED-ANALYSIS.md +291 -0
  31. package/docs/research/EVAL-SYSTEM-ANALYSIS.md +306 -0
  32. package/docs/research/FACTORIAL-RESULTS-2026-01-14.md +301 -0
  33. package/docs/research/IMPLEMENTATION-PLAN-CRITIQUE-RESPONSE.md +1988 -0
  34. package/docs/research/LONGITUDINAL-DYADIC-EVALUATION.md +282 -0
  35. package/docs/research/MULTI-JUDGE-VALIDATION-2026-01-14.md +147 -0
  36. package/docs/research/PAPER-EXTENSION-DYADIC.md +204 -0
  37. package/docs/research/PAPER-UNIFIED.md +659 -0
  38. package/docs/research/PAPER-UNIFIED.pdf +0 -0
  39. package/docs/research/PROMPT-IMPROVEMENTS-2026-01-14.md +356 -0
  40. package/docs/research/SESSION-NOTES-2026-01-11-RECOGNITION-EVAL.md +419 -0
  41. package/docs/research/apa.csl +2133 -0
  42. package/docs/research/archive/PAPER-DRAFT-RECOGNITION-TUTORING.md +1637 -0
  43. package/docs/research/archive/paper-multiagent-tutor.tex +978 -0
  44. package/docs/research/paper-draft/full-paper.md +136 -0
  45. package/docs/research/paper-draft/images/pasted-image-2026-01-24T03-47-47-846Z-d76a7ae2.png +0 -0
  46. package/docs/research/paper-draft/references.bib +515 -0
  47. package/docs/research/transcript-baseline.md +139 -0
  48. package/docs/research/transcript-recognition-multiagent.md +187 -0
  49. package/hooks/useEvalData.ts +625 -0
  50. package/index.js +27 -0
  51. package/package.json +73 -0
  52. package/routes/evalRoutes.js +3002 -0
  53. package/scripts/advanced-eval-analysis.js +351 -0
  54. package/scripts/analyze-eval-costs.js +378 -0
  55. package/scripts/analyze-eval-results.js +513 -0
  56. package/scripts/analyze-interaction-evals.js +368 -0
  57. package/server-init.js +45 -0
  58. package/server.js +162 -0
  59. package/services/benchmarkService.js +1892 -0
  60. package/services/evaluationRunner.js +739 -0
  61. package/services/evaluationStore.js +1121 -0
  62. package/services/learnerConfigLoader.js +385 -0
  63. package/services/learnerTutorInteractionEngine.js +857 -0
  64. package/services/memory/learnerMemoryService.js +1227 -0
  65. package/services/memory/learnerWritingPad.js +577 -0
  66. package/services/memory/tutorWritingPad.js +674 -0
  67. package/services/promptRecommendationService.js +493 -0
  68. package/services/rubricEvaluator.js +826 -0
@@ -0,0 +1,1227 @@
1
+ /**
2
+ * Learner Memory Service
3
+ *
4
+ * Manages persistent learner knowledge base including:
5
+ * - Concept understanding states
6
+ * - Episodic memory (breakthroughs, struggles, insights)
7
+ * - Session summaries
8
+ * - Active threads (unresolved questions)
9
+ * - Personal definitions and connections
10
+ * - Learner preferences
11
+ *
12
+ * This service provides the foundation for cross-session continuity
13
+ * and multi-agent deliberation context.
14
+ */
15
+
16
+ import crypto from 'crypto';
17
+ import { getDb } from '../dbService.js';
18
+
19
+ // Get shared database connection
20
+ const db = getDb();
21
+
22
+ // ============================================================================
23
+ // Database Schema
24
+ // ============================================================================
25
+
26
+ const LEARNER_MEMORY_TABLES = [
27
+ 'learner_memory', 'concept_states', 'episodes', 'tutor_session_summaries',
28
+ 'threads', 'personal_definitions', 'connections', 'learner_preferences',
29
+ 'learning_milestones', 'agent_cost_log'
30
+ ];
31
+
32
+ const createSchemaSQL = `
33
+ -- Learner Knowledge Base (root record)
34
+ CREATE TABLE IF NOT EXISTS learner_memory (
35
+ id TEXT PRIMARY KEY,
36
+ learner_id TEXT NOT NULL UNIQUE,
37
+ created_at TEXT DEFAULT (datetime('now')),
38
+ last_session TEXT,
39
+ FOREIGN KEY (learner_id) REFERENCES users(id) ON DELETE CASCADE
40
+ );
41
+
42
+ CREATE INDEX IF NOT EXISTS idx_learner_memory_learner ON learner_memory(learner_id);
43
+
44
+ -- Concept Understanding States
45
+ CREATE TABLE IF NOT EXISTS concept_states (
46
+ id TEXT PRIMARY KEY,
47
+ learner_id TEXT NOT NULL,
48
+ concept_id TEXT NOT NULL,
49
+ label TEXT NOT NULL,
50
+ level TEXT DEFAULT 'unencountered' CHECK(level IN ('unencountered', 'exposed', 'developing', 'proficient', 'mastered')),
51
+ confidence REAL DEFAULT 0.5,
52
+ calibration REAL DEFAULT 0.5,
53
+ last_engaged TEXT,
54
+ engagement_count INTEGER DEFAULT 0,
55
+ decay_rate REAL DEFAULT 0.1,
56
+ sources TEXT DEFAULT '[]',
57
+ struggles TEXT DEFAULT '[]',
58
+ breakthroughs TEXT DEFAULT '[]',
59
+ created_at TEXT DEFAULT (datetime('now')),
60
+ updated_at TEXT DEFAULT (datetime('now')),
61
+ UNIQUE(learner_id, concept_id),
62
+ FOREIGN KEY (learner_id) REFERENCES users(id) ON DELETE CASCADE
63
+ );
64
+
65
+ CREATE INDEX IF NOT EXISTS idx_concept_states_learner ON concept_states(learner_id);
66
+ CREATE INDEX IF NOT EXISTS idx_concept_states_concept ON concept_states(concept_id);
67
+ CREATE INDEX IF NOT EXISTS idx_concept_states_level ON concept_states(level);
68
+ CREATE INDEX IF NOT EXISTS idx_concept_states_last_engaged ON concept_states(last_engaged);
69
+
70
+ -- Episodic Memory
71
+ CREATE TABLE IF NOT EXISTS episodes (
72
+ id TEXT PRIMARY KEY,
73
+ learner_id TEXT NOT NULL,
74
+ session_id TEXT,
75
+ timestamp TEXT DEFAULT (datetime('now')),
76
+ type TEXT NOT NULL CHECK(type IN ('breakthrough', 'struggle', 'insight', 'question', 'connection', 'misconception', 'emotional', 'metacognitive')),
77
+ content TEXT NOT NULL,
78
+ context TEXT,
79
+ concepts TEXT DEFAULT '[]',
80
+ embedding BLOB,
81
+ importance REAL DEFAULT 0.5,
82
+ retrieval_count INTEGER DEFAULT 0,
83
+ agent_notes TEXT DEFAULT '[]',
84
+ created_at TEXT DEFAULT (datetime('now')),
85
+ FOREIGN KEY (learner_id) REFERENCES users(id) ON DELETE CASCADE
86
+ );
87
+
88
+ CREATE INDEX IF NOT EXISTS idx_episodes_learner ON episodes(learner_id);
89
+ CREATE INDEX IF NOT EXISTS idx_episodes_session ON episodes(session_id);
90
+ CREATE INDEX IF NOT EXISTS idx_episodes_type ON episodes(type);
91
+ CREATE INDEX IF NOT EXISTS idx_episodes_timestamp ON episodes(timestamp);
92
+ CREATE INDEX IF NOT EXISTS idx_episodes_importance ON episodes(importance);
93
+
94
+ -- Tutor Session Summaries (for tutor memory, separate from user-facing session_summaries)
95
+ CREATE TABLE IF NOT EXISTS tutor_session_summaries (
96
+ id TEXT PRIMARY KEY,
97
+ learner_id TEXT NOT NULL,
98
+ session_id TEXT NOT NULL UNIQUE,
99
+ timestamp TEXT DEFAULT (datetime('now')),
100
+ duration INTEGER DEFAULT 0,
101
+ topics TEXT DEFAULT '[]',
102
+ concepts_touched TEXT DEFAULT '[]',
103
+ key_insights TEXT DEFAULT '[]',
104
+ unresolved_questions TEXT DEFAULT '[]',
105
+ scaffolding_level_avg REAL DEFAULT 2.0,
106
+ engagement_level TEXT DEFAULT 'engaged' CHECK(engagement_level IN ('struggling', 'engaged', 'flowing', 'disengaged')),
107
+ emotional_arc TEXT,
108
+ message_count INTEGER DEFAULT 0,
109
+ tokens_used INTEGER DEFAULT 0,
110
+ agent_calls TEXT DEFAULT '[]',
111
+ narrative_summary TEXT,
112
+ created_at TEXT DEFAULT (datetime('now')),
113
+ FOREIGN KEY (learner_id) REFERENCES users(id) ON DELETE CASCADE
114
+ );
115
+
116
+ CREATE INDEX IF NOT EXISTS idx_tutor_session_summaries_learner ON tutor_session_summaries(learner_id);
117
+ CREATE INDEX IF NOT EXISTS idx_tutor_session_summaries_timestamp ON tutor_session_summaries(timestamp);
118
+
119
+ -- Active Threads (Unresolved Questions)
120
+ CREATE TABLE IF NOT EXISTS threads (
121
+ id TEXT PRIMARY KEY,
122
+ learner_id TEXT NOT NULL,
123
+ topic TEXT NOT NULL,
124
+ status TEXT DEFAULT 'active' CHECK(status IN ('active', 'resolved', 'dormant')),
125
+ origin_session TEXT,
126
+ last_touched TEXT DEFAULT (datetime('now')),
127
+ mentions TEXT DEFAULT '[]',
128
+ question TEXT NOT NULL,
129
+ partial_answers TEXT DEFAULT '[]',
130
+ related_concepts TEXT DEFAULT '[]',
131
+ student_interest REAL DEFAULT 0.5,
132
+ pedagogical_importance REAL DEFAULT 0.5,
133
+ created_at TEXT DEFAULT (datetime('now')),
134
+ updated_at TEXT DEFAULT (datetime('now')),
135
+ FOREIGN KEY (learner_id) REFERENCES users(id) ON DELETE CASCADE
136
+ );
137
+
138
+ CREATE INDEX IF NOT EXISTS idx_threads_learner ON threads(learner_id);
139
+ CREATE INDEX IF NOT EXISTS idx_threads_status ON threads(status);
140
+ CREATE INDEX IF NOT EXISTS idx_threads_last_touched ON threads(last_touched);
141
+
142
+ -- Personal Definitions (Lexicon)
143
+ CREATE TABLE IF NOT EXISTS personal_definitions (
144
+ id TEXT PRIMARY KEY,
145
+ learner_id TEXT NOT NULL,
146
+ term TEXT NOT NULL,
147
+ definition TEXT NOT NULL,
148
+ confidence REAL DEFAULT 0.5,
149
+ evolution TEXT DEFAULT '[]',
150
+ created_at TEXT DEFAULT (datetime('now')),
151
+ updated_at TEXT DEFAULT (datetime('now')),
152
+ UNIQUE(learner_id, term),
153
+ FOREIGN KEY (learner_id) REFERENCES users(id) ON DELETE CASCADE
154
+ );
155
+
156
+ CREATE INDEX IF NOT EXISTS idx_personal_definitions_learner ON personal_definitions(learner_id);
157
+ CREATE INDEX IF NOT EXISTS idx_personal_definitions_term ON personal_definitions(term);
158
+
159
+ -- Concept Connections
160
+ CREATE TABLE IF NOT EXISTS connections (
161
+ id TEXT PRIMARY KEY,
162
+ learner_id TEXT NOT NULL,
163
+ concept_a TEXT NOT NULL,
164
+ concept_b TEXT NOT NULL,
165
+ relationship TEXT NOT NULL,
166
+ source TEXT DEFAULT 'student' CHECK(source IN ('student', 'tutor_suggested', 'discovered_together')),
167
+ strength REAL DEFAULT 0.5,
168
+ created_at TEXT DEFAULT (datetime('now')),
169
+ UNIQUE(learner_id, concept_a, concept_b),
170
+ FOREIGN KEY (learner_id) REFERENCES users(id) ON DELETE CASCADE
171
+ );
172
+
173
+ CREATE INDEX IF NOT EXISTS idx_connections_learner ON connections(learner_id);
174
+ CREATE INDEX IF NOT EXISTS idx_connections_concepts ON connections(concept_a, concept_b);
175
+
176
+ -- Learner Preferences
177
+ CREATE TABLE IF NOT EXISTS learner_preferences (
178
+ learner_id TEXT PRIMARY KEY,
179
+ prefers_examples_first INTEGER DEFAULT 1,
180
+ prefers_socratic_mode INTEGER DEFAULT 0,
181
+ tolerance_for_challenge REAL DEFAULT 0.5,
182
+ best_time_of_day TEXT,
183
+ typical_session_length INTEGER DEFAULT 30,
184
+ preferred_pace TEXT DEFAULT 'moderate' CHECK(preferred_pace IN ('slow', 'moderate', 'fast')),
185
+ responds_to_encouragement INTEGER DEFAULT 1,
186
+ needs_normalization INTEGER DEFAULT 1,
187
+ effective_strategies TEXT DEFAULT '[]',
188
+ preferred_architecture TEXT DEFAULT 'unified',
189
+ updated_at TEXT DEFAULT (datetime('now')),
190
+ FOREIGN KEY (learner_id) REFERENCES users(id) ON DELETE CASCADE
191
+ );
192
+
193
+ -- Learning Milestones
194
+ CREATE TABLE IF NOT EXISTS learning_milestones (
195
+ id TEXT PRIMARY KEY,
196
+ learner_id TEXT NOT NULL,
197
+ type TEXT NOT NULL CHECK(type IN ('first_breakthrough', 'concept_mastered', 'connection_made', 'independence_achieved', 'teaching_moment', 'streak', 'deep_engagement', 'recovery')),
198
+ title TEXT NOT NULL,
199
+ description TEXT,
200
+ concept_id TEXT,
201
+ course_id TEXT,
202
+ achieved_at TEXT DEFAULT (datetime('now')),
203
+ acknowledged INTEGER DEFAULT 0,
204
+ FOREIGN KEY (learner_id) REFERENCES users(id) ON DELETE CASCADE
205
+ );
206
+
207
+ CREATE INDEX IF NOT EXISTS idx_learning_milestones_learner ON learning_milestones(learner_id);
208
+ CREATE INDEX IF NOT EXISTS idx_learning_milestones_type ON learning_milestones(type);
209
+ CREATE INDEX IF NOT EXISTS idx_learning_milestones_acknowledged ON learning_milestones(acknowledged);
210
+
211
+ -- Agent Cost Tracking
212
+ CREATE TABLE IF NOT EXISTS agent_cost_log (
213
+ id TEXT PRIMARY KEY,
214
+ learner_id TEXT NOT NULL,
215
+ session_id TEXT,
216
+ agent_role TEXT NOT NULL,
217
+ architecture TEXT NOT NULL,
218
+ input_tokens INTEGER DEFAULT 0,
219
+ output_tokens INTEGER DEFAULT 0,
220
+ latency_ms INTEGER DEFAULT 0,
221
+ model TEXT,
222
+ timestamp TEXT DEFAULT (datetime('now')),
223
+ FOREIGN KEY (learner_id) REFERENCES users(id) ON DELETE CASCADE
224
+ );
225
+
226
+ CREATE INDEX IF NOT EXISTS idx_agent_cost_log_learner ON agent_cost_log(learner_id);
227
+ CREATE INDEX IF NOT EXISTS idx_agent_cost_log_session ON agent_cost_log(session_id);
228
+ CREATE INDEX IF NOT EXISTS idx_agent_cost_log_timestamp ON agent_cost_log(timestamp);
229
+ `;
230
+
231
+ // Try to create schema; if mismatch, drop and recreate
232
+ try {
233
+ db.exec(createSchemaSQL);
234
+
235
+ // Verify schema has correct columns (may exist with old column names)
236
+ const testQuery = db.prepare(`SELECT learner_id FROM tutor_session_summaries LIMIT 0`);
237
+ testQuery.run(); // Will throw if learner_id column doesn't exist
238
+ } catch (err) {
239
+ if (err.message?.includes('no such column') || err.message?.includes('SQLITE_ERROR')) {
240
+ console.log('[LearnerMemory] Schema mismatch detected, recreating tables...');
241
+ for (const table of LEARNER_MEMORY_TABLES) {
242
+ try {
243
+ db.exec(`DROP TABLE IF EXISTS ${table}`);
244
+ } catch { /* ignore */ }
245
+ }
246
+ db.exec(createSchemaSQL);
247
+ console.log('[LearnerMemory] Tables recreated successfully');
248
+ } else {
249
+ throw err;
250
+ }
251
+ }
252
+
253
+ // ============================================================================
254
+ // Helper Functions
255
+ // ============================================================================
256
+
257
+ const generateId = () => crypto.randomUUID();
258
+
259
+ const parseJSON = (str, defaultValue = []) => {
260
+ if (!str) return defaultValue;
261
+ try {
262
+ return JSON.parse(str);
263
+ } catch {
264
+ return defaultValue;
265
+ }
266
+ };
267
+
268
+ const stringifyJSON = (obj) => JSON.stringify(obj || []);
269
+
270
+ // ============================================================================
271
+ // Learner Memory CRUD
272
+ // ============================================================================
273
+
274
+ /**
275
+ * Get or create learner memory record
276
+ */
277
+ export const getOrCreateLearnerMemory = (learnerId) => {
278
+ let memory = db.prepare('SELECT * FROM learner_memory WHERE learner_id = ?').get(learnerId);
279
+
280
+ if (!memory) {
281
+ const id = generateId();
282
+ db.prepare(`
283
+ INSERT INTO learner_memory (id, learner_id)
284
+ VALUES (?, ?)
285
+ `).run(id, learnerId);
286
+ memory = db.prepare('SELECT * FROM learner_memory WHERE id = ?').get(id);
287
+
288
+ // Also create default preferences
289
+ db.prepare(`
290
+ INSERT OR IGNORE INTO learner_preferences (learner_id)
291
+ VALUES (?)
292
+ `).run(learnerId);
293
+ }
294
+
295
+ return memory;
296
+ };
297
+
298
+ /**
299
+ * Update last session timestamp
300
+ */
301
+ export const updateLastSession = (learnerId, sessionId) => {
302
+ db.prepare(`
303
+ UPDATE learner_memory
304
+ SET last_session = ?
305
+ WHERE learner_id = ?
306
+ `).run(sessionId, learnerId);
307
+ };
308
+
309
+ // ============================================================================
310
+ // Concept States
311
+ // ============================================================================
312
+
313
+ /**
314
+ * Get concept state for a learner
315
+ */
316
+ export const getConceptState = (learnerId, conceptId) => {
317
+ const row = db.prepare(`
318
+ SELECT * FROM concept_states
319
+ WHERE learner_id = ? AND concept_id = ?
320
+ `).get(learnerId, conceptId);
321
+
322
+ if (!row) return null;
323
+
324
+ return {
325
+ ...row,
326
+ sources: parseJSON(row.sources),
327
+ struggles: parseJSON(row.struggles),
328
+ breakthroughs: parseJSON(row.breakthroughs),
329
+ };
330
+ };
331
+
332
+ /**
333
+ * Get all concept states for a learner
334
+ */
335
+ export const getAllConceptStates = (learnerId) => {
336
+ const rows = db.prepare(`
337
+ SELECT * FROM concept_states
338
+ WHERE learner_id = ?
339
+ ORDER BY last_engaged DESC
340
+ `).all(learnerId);
341
+
342
+ return rows.map(row => ({
343
+ ...row,
344
+ sources: parseJSON(row.sources),
345
+ struggles: parseJSON(row.struggles),
346
+ breakthroughs: parseJSON(row.breakthroughs),
347
+ }));
348
+ };
349
+
350
+ /**
351
+ * Get concepts due for review (spaced repetition)
352
+ */
353
+ export const getConceptsDueForReview = (learnerId, limit = 5) => {
354
+ // Simple algorithm: concepts not engaged recently with decay factor
355
+ const rows = db.prepare(`
356
+ SELECT *,
357
+ julianday('now') - julianday(last_engaged) AS days_since,
358
+ (julianday('now') - julianday(last_engaged)) * decay_rate AS review_urgency
359
+ FROM concept_states
360
+ WHERE learner_id = ?
361
+ AND level IN ('exposed', 'developing', 'proficient')
362
+ AND last_engaged IS NOT NULL
363
+ ORDER BY review_urgency DESC
364
+ LIMIT ?
365
+ `).all(learnerId, limit);
366
+
367
+ return rows.map(row => ({
368
+ ...row,
369
+ sources: parseJSON(row.sources),
370
+ struggles: parseJSON(row.struggles),
371
+ breakthroughs: parseJSON(row.breakthroughs),
372
+ }));
373
+ };
374
+
375
+ /**
376
+ * Create or update concept state
377
+ */
378
+ export const upsertConceptState = (learnerIdOrData, conceptIdArg, dataArg) => {
379
+ let learnerId = learnerIdOrData;
380
+ let conceptId = conceptIdArg;
381
+ let data = dataArg || {};
382
+
383
+ if (learnerIdOrData && typeof learnerIdOrData === 'object' && !Array.isArray(learnerIdOrData)) {
384
+ learnerId = learnerIdOrData.learnerId;
385
+ conceptId = learnerIdOrData.conceptId;
386
+ data = learnerIdOrData;
387
+ }
388
+
389
+ if (!learnerId || !conceptId) return null;
390
+
391
+ const existing = getConceptState(learnerId, conceptId);
392
+
393
+ if (existing) {
394
+ // Update existing
395
+ const updates = [];
396
+ const params = [];
397
+
398
+ if (data.label !== undefined) { updates.push('label = ?'); params.push(data.label); }
399
+ if (data.level !== undefined) { updates.push('level = ?'); params.push(data.level); }
400
+ if (data.confidence !== undefined) { updates.push('confidence = ?'); params.push(data.confidence); }
401
+ if (data.calibration !== undefined) { updates.push('calibration = ?'); params.push(data.calibration); }
402
+ if (data.decayRate !== undefined) { updates.push('decay_rate = ?'); params.push(data.decayRate); }
403
+
404
+ // Always update engagement
405
+ updates.push('last_engaged = datetime("now")');
406
+ updates.push('engagement_count = engagement_count + 1');
407
+ updates.push('updated_at = datetime("now")');
408
+
409
+ // Handle array updates
410
+ if (data.addSource) {
411
+ const sources = existing.sources.includes(data.addSource)
412
+ ? existing.sources
413
+ : [...existing.sources, data.addSource];
414
+ updates.push('sources = ?');
415
+ params.push(stringifyJSON(sources));
416
+ }
417
+
418
+ if (data.addStruggle) {
419
+ const struggle = { ...data.addStruggle, timestamp: new Date().toISOString() };
420
+ const struggles = [...existing.struggles, struggle];
421
+ updates.push('struggles = ?');
422
+ params.push(stringifyJSON(struggles));
423
+ }
424
+
425
+ if (data.addBreakthrough) {
426
+ const breakthrough = { ...data.addBreakthrough, timestamp: new Date().toISOString() };
427
+ const breakthroughs = [...existing.breakthroughs, breakthrough];
428
+ updates.push('breakthroughs = ?');
429
+ params.push(stringifyJSON(breakthroughs));
430
+ }
431
+
432
+ params.push(learnerId, conceptId);
433
+
434
+ db.prepare(`
435
+ UPDATE concept_states
436
+ SET ${updates.join(', ')}
437
+ WHERE learner_id = ? AND concept_id = ?
438
+ `).run(...params);
439
+
440
+ return getConceptState(learnerId, conceptId);
441
+ } else {
442
+ // Create new
443
+ const id = generateId();
444
+ db.prepare(`
445
+ INSERT INTO concept_states (
446
+ id, learner_id, concept_id, label, level, confidence,
447
+ last_engaged, sources, struggles, breakthroughs
448
+ )
449
+ VALUES (?, ?, ?, ?, ?, ?, datetime('now'), ?, ?, ?)
450
+ `).run(
451
+ id,
452
+ learnerId,
453
+ conceptId,
454
+ data.label || conceptId,
455
+ data.level || 'exposed',
456
+ data.confidence || 0.5,
457
+ stringifyJSON(data.addSource ? [data.addSource] : []),
458
+ stringifyJSON(data.addStruggle ? [{ ...data.addStruggle, timestamp: new Date().toISOString() }] : []),
459
+ stringifyJSON(data.addBreakthrough ? [{ ...data.addBreakthrough, timestamp: new Date().toISOString() }] : [])
460
+ );
461
+
462
+ return getConceptState(learnerId, conceptId);
463
+ }
464
+ };
465
+
466
+ // ============================================================================
467
+ // Episodes
468
+ // ============================================================================
469
+
470
+ /**
471
+ * Create an episode
472
+ */
473
+ export const createEpisode = (data) => {
474
+ const id = generateId();
475
+ db.prepare(`
476
+ INSERT INTO episodes (
477
+ id, learner_id, session_id, type, content, context,
478
+ concepts, importance, agent_notes
479
+ )
480
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
481
+ `).run(
482
+ id,
483
+ data.learnerId,
484
+ data.sessionId,
485
+ data.type,
486
+ data.content,
487
+ data.context || '',
488
+ stringifyJSON(data.concepts || []),
489
+ data.importance || 0.5,
490
+ stringifyJSON(data.agentNotes || [])
491
+ );
492
+
493
+ return db.prepare('SELECT * FROM episodes WHERE id = ?').get(id);
494
+ };
495
+
496
+ /**
497
+ * Get recent episodes for a learner
498
+ */
499
+ export const getRecentEpisodes = (learnerId, limit = 20) => {
500
+ const rows = db.prepare(`
501
+ SELECT * FROM episodes
502
+ WHERE learner_id = ?
503
+ ORDER BY timestamp DESC
504
+ LIMIT ?
505
+ `).all(learnerId, limit);
506
+
507
+ return rows.map(row => ({
508
+ ...row,
509
+ concepts: parseJSON(row.concepts),
510
+ agentNotes: parseJSON(row.agent_notes),
511
+ }));
512
+ };
513
+
514
+ /**
515
+ * Get episodes by type
516
+ */
517
+ export const getEpisodesByType = (learnerId, type, limit = 10) => {
518
+ const rows = db.prepare(`
519
+ SELECT * FROM episodes
520
+ WHERE learner_id = ? AND type = ?
521
+ ORDER BY timestamp DESC
522
+ LIMIT ?
523
+ `).all(learnerId, type, limit);
524
+
525
+ return rows.map(row => ({
526
+ ...row,
527
+ concepts: parseJSON(row.concepts),
528
+ agentNotes: parseJSON(row.agent_notes),
529
+ }));
530
+ };
531
+
532
+ /**
533
+ * Get important episodes (for context injection)
534
+ */
535
+ export const getImportantEpisodes = (learnerId, minImportance = 0.7, limit = 10) => {
536
+ const rows = db.prepare(`
537
+ SELECT * FROM episodes
538
+ WHERE learner_id = ? AND importance >= ?
539
+ ORDER BY importance DESC, timestamp DESC
540
+ LIMIT ?
541
+ `).all(learnerId, minImportance, limit);
542
+
543
+ return rows.map(row => ({
544
+ ...row,
545
+ concepts: parseJSON(row.concepts),
546
+ agentNotes: parseJSON(row.agent_notes),
547
+ }));
548
+ };
549
+
550
+ /**
551
+ * Increment retrieval count (when episode is surfaced in context)
552
+ */
553
+ export const incrementEpisodeRetrieval = (episodeId) => {
554
+ db.prepare(`
555
+ UPDATE episodes
556
+ SET retrieval_count = retrieval_count + 1
557
+ WHERE id = ?
558
+ `).run(episodeId);
559
+ };
560
+
561
+ /**
562
+ * Decay episode importance over time
563
+ */
564
+ export const decayEpisodeImportance = (olderThanDays = 30, decayFactor = 0.9) => {
565
+ db.prepare(`
566
+ UPDATE episodes
567
+ SET importance = importance * ?
568
+ WHERE timestamp < datetime('now', '-' || ? || ' days')
569
+ AND importance > 0.1
570
+ `).run(decayFactor, olderThanDays);
571
+ };
572
+
573
+ // ============================================================================
574
+ // Session Summaries
575
+ // ============================================================================
576
+
577
+ /**
578
+ * Create a session summary
579
+ */
580
+ export const createSessionSummary = (data) => {
581
+ const id = generateId();
582
+ db.prepare(`
583
+ INSERT INTO tutor_session_summaries (
584
+ id, learner_id, session_id, duration, topics, concepts_touched,
585
+ key_insights, unresolved_questions, scaffolding_level_avg,
586
+ engagement_level, emotional_arc, message_count, tokens_used,
587
+ agent_calls, narrative_summary
588
+ )
589
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
590
+ `).run(
591
+ id,
592
+ data.learnerId,
593
+ data.sessionId,
594
+ data.duration || 0,
595
+ stringifyJSON(data.topics || []),
596
+ stringifyJSON(data.conceptsTouched || []),
597
+ stringifyJSON(data.keyInsights || []),
598
+ stringifyJSON(data.unresolvedQuestions || []),
599
+ data.scaffoldingLevelAvg || 2.0,
600
+ data.engagementLevel || 'engaged',
601
+ data.emotionalArc || '',
602
+ data.messageCount || 0,
603
+ data.tokensUsed || 0,
604
+ stringifyJSON(data.agentCalls || []),
605
+ data.narrativeSummary || ''
606
+ );
607
+
608
+ return getSessionSummary(data.sessionId);
609
+ };
610
+
611
+ /**
612
+ * Get session summary
613
+ */
614
+ export const getSessionSummary = (sessionId) => {
615
+ const row = db.prepare(`
616
+ SELECT * FROM tutor_session_summaries
617
+ WHERE session_id = ?
618
+ `).get(sessionId);
619
+
620
+ if (!row) return null;
621
+
622
+ return {
623
+ ...row,
624
+ topics: parseJSON(row.topics),
625
+ conceptsTouched: parseJSON(row.concepts_touched),
626
+ keyInsights: parseJSON(row.key_insights),
627
+ unresolvedQuestions: parseJSON(row.unresolved_questions),
628
+ agentCalls: parseJSON(row.agent_calls),
629
+ };
630
+ };
631
+
632
+ /**
633
+ * Get recent session summaries
634
+ */
635
+ export const getRecentSessionSummaries = (learnerId, limit = 5) => {
636
+ const rows = db.prepare(`
637
+ SELECT * FROM tutor_session_summaries
638
+ WHERE learner_id = ?
639
+ ORDER BY timestamp DESC
640
+ LIMIT ?
641
+ `).all(learnerId, limit);
642
+
643
+ return rows.map(row => ({
644
+ ...row,
645
+ topics: parseJSON(row.topics),
646
+ conceptsTouched: parseJSON(row.concepts_touched),
647
+ keyInsights: parseJSON(row.key_insights),
648
+ unresolvedQuestions: parseJSON(row.unresolved_questions),
649
+ agentCalls: parseJSON(row.agent_calls),
650
+ }));
651
+ };
652
+
653
+ // ============================================================================
654
+ // Threads
655
+ // ============================================================================
656
+
657
+ /**
658
+ * Create a thread
659
+ */
660
+ export const createThread = (data) => {
661
+ const id = generateId();
662
+ db.prepare(`
663
+ INSERT INTO threads (
664
+ id, learner_id, topic, question, origin_session,
665
+ related_concepts, student_interest, pedagogical_importance
666
+ )
667
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
668
+ `).run(
669
+ id,
670
+ data.learnerId,
671
+ data.topic,
672
+ data.question,
673
+ data.originSession,
674
+ stringifyJSON(data.relatedConcepts || []),
675
+ data.studentInterest || 0.5,
676
+ data.pedagogicalImportance || 0.5
677
+ );
678
+
679
+ return getThread(id);
680
+ };
681
+
682
+ /**
683
+ * Get thread by ID
684
+ */
685
+ export const getThread = (threadId) => {
686
+ const row = db.prepare('SELECT * FROM threads WHERE id = ?').get(threadId);
687
+ if (!row) return null;
688
+
689
+ return {
690
+ ...row,
691
+ mentions: parseJSON(row.mentions),
692
+ partialAnswers: parseJSON(row.partial_answers),
693
+ relatedConcepts: parseJSON(row.related_concepts),
694
+ };
695
+ };
696
+
697
+ /**
698
+ * Get active threads for a learner
699
+ */
700
+ export const getActiveThreads = (learnerId) => {
701
+ const rows = db.prepare(`
702
+ SELECT * FROM threads
703
+ WHERE learner_id = ? AND status = 'active'
704
+ ORDER BY last_touched DESC
705
+ `).all(learnerId);
706
+
707
+ return rows.map(row => ({
708
+ ...row,
709
+ mentions: parseJSON(row.mentions),
710
+ partialAnswers: parseJSON(row.partial_answers),
711
+ relatedConcepts: parseJSON(row.related_concepts),
712
+ }));
713
+ };
714
+
715
+ /**
716
+ * Update thread
717
+ */
718
+ export const updateThread = (threadId, data) => {
719
+ const existing = getThread(threadId);
720
+ if (!existing) return null;
721
+
722
+ const updates = [];
723
+ const params = [];
724
+
725
+ if (data.status !== undefined) { updates.push('status = ?'); params.push(data.status); }
726
+ if (data.studentInterest !== undefined) { updates.push('student_interest = ?'); params.push(data.studentInterest); }
727
+ if (data.pedagogicalImportance !== undefined) { updates.push('pedagogical_importance = ?'); params.push(data.pedagogicalImportance); }
728
+
729
+ if (data.addMention) {
730
+ const mentions = [...existing.mentions, data.addMention];
731
+ updates.push('mentions = ?');
732
+ params.push(stringifyJSON(mentions));
733
+ }
734
+
735
+ if (data.addPartialAnswer) {
736
+ const partialAnswers = [...existing.partialAnswers, data.addPartialAnswer];
737
+ updates.push('partial_answers = ?');
738
+ params.push(stringifyJSON(partialAnswers));
739
+ }
740
+
741
+ updates.push('last_touched = datetime("now")');
742
+ updates.push('updated_at = datetime("now")');
743
+ params.push(threadId);
744
+
745
+ db.prepare(`
746
+ UPDATE threads
747
+ SET ${updates.join(', ')}
748
+ WHERE id = ?
749
+ `).run(...params);
750
+
751
+ return getThread(threadId);
752
+ };
753
+
754
+ // ============================================================================
755
+ // Personal Definitions
756
+ // ============================================================================
757
+
758
+ /**
759
+ * Get or create personal definition
760
+ */
761
+ export const upsertPersonalDefinition = (learnerId, term, definition, confidence = 0.5) => {
762
+ const existing = db.prepare(`
763
+ SELECT * FROM personal_definitions
764
+ WHERE learner_id = ? AND term = ?
765
+ `).get(learnerId, term);
766
+
767
+ if (existing) {
768
+ // Track evolution
769
+ const evolution = parseJSON(existing.evolution);
770
+ evolution.push({
771
+ timestamp: new Date().toISOString(),
772
+ definition: existing.definition,
773
+ });
774
+
775
+ db.prepare(`
776
+ UPDATE personal_definitions
777
+ SET definition = ?, confidence = ?, evolution = ?, updated_at = datetime('now')
778
+ WHERE learner_id = ? AND term = ?
779
+ `).run(definition, confidence, stringifyJSON(evolution), learnerId, term);
780
+ } else {
781
+ const id = generateId();
782
+ db.prepare(`
783
+ INSERT INTO personal_definitions (id, learner_id, term, definition, confidence)
784
+ VALUES (?, ?, ?, ?, ?)
785
+ `).run(id, learnerId, term, definition, confidence);
786
+ }
787
+
788
+ return db.prepare(`
789
+ SELECT * FROM personal_definitions
790
+ WHERE learner_id = ? AND term = ?
791
+ `).get(learnerId, term);
792
+ };
793
+
794
+ /**
795
+ * Get all personal definitions
796
+ */
797
+ export const getPersonalDefinitions = (learnerId) => {
798
+ const rows = db.prepare(`
799
+ SELECT * FROM personal_definitions
800
+ WHERE learner_id = ?
801
+ ORDER BY updated_at DESC
802
+ `).all(learnerId);
803
+
804
+ return rows.map(row => ({
805
+ ...row,
806
+ evolution: parseJSON(row.evolution),
807
+ }));
808
+ };
809
+
810
+ // ============================================================================
811
+ // Connections
812
+ // ============================================================================
813
+
814
+ /**
815
+ * Create a connection
816
+ */
817
+ export const createConnection = (data) => {
818
+ const id = generateId();
819
+ try {
820
+ db.prepare(`
821
+ INSERT INTO connections (
822
+ id, learner_id, concept_a, concept_b, relationship, source, strength
823
+ )
824
+ VALUES (?, ?, ?, ?, ?, ?, ?)
825
+ `).run(
826
+ id,
827
+ data.learnerId,
828
+ data.conceptA,
829
+ data.conceptB,
830
+ data.relationship,
831
+ data.source || 'student',
832
+ data.strength || 0.5
833
+ );
834
+
835
+ return db.prepare('SELECT * FROM connections WHERE id = ?').get(id);
836
+ } catch (e) {
837
+ // Unique constraint violation - connection already exists
838
+ return db.prepare(`
839
+ SELECT * FROM connections
840
+ WHERE learner_id = ? AND concept_a = ? AND concept_b = ?
841
+ `).get(data.learnerId, data.conceptA, data.conceptB);
842
+ }
843
+ };
844
+
845
+ /**
846
+ * Get connections for a learner
847
+ */
848
+ export const getConnections = (learnerId) => {
849
+ return db.prepare(`
850
+ SELECT * FROM connections
851
+ WHERE learner_id = ?
852
+ ORDER BY created_at DESC
853
+ `).all(learnerId);
854
+ };
855
+
856
+ // ============================================================================
857
+ // Preferences
858
+ // ============================================================================
859
+
860
+ /**
861
+ * Get learner preferences
862
+ */
863
+ export const getPreferences = (learnerId) => {
864
+ const row = db.prepare(`
865
+ SELECT * FROM learner_preferences
866
+ WHERE learner_id = ?
867
+ `).get(learnerId);
868
+
869
+ if (!row) return null;
870
+
871
+ return {
872
+ learnerId: row.learner_id,
873
+ prefersExamplesFirst: !!row.prefers_examples_first,
874
+ prefersSocraticMode: !!row.prefers_socratic_mode,
875
+ toleranceForChallenge: row.tolerance_for_challenge,
876
+ bestTimeOfDay: row.best_time_of_day,
877
+ typicalSessionLength: row.typical_session_length,
878
+ preferredPace: row.preferred_pace,
879
+ respondsToEncouragement: !!row.responds_to_encouragement,
880
+ needsNormalization: !!row.needs_normalization,
881
+ effectiveStrategies: parseJSON(row.effective_strategies),
882
+ preferredArchitecture: row.preferred_architecture,
883
+ updatedAt: row.updated_at,
884
+ };
885
+ };
886
+
887
+ /**
888
+ * Update learner preferences
889
+ */
890
+ export const updatePreferences = (learnerId, data) => {
891
+ // Ensure record exists
892
+ db.prepare(`
893
+ INSERT OR IGNORE INTO learner_preferences (learner_id)
894
+ VALUES (?)
895
+ `).run(learnerId);
896
+
897
+ const updates = [];
898
+ const params = [];
899
+
900
+ if (data.prefersExamplesFirst !== undefined) {
901
+ updates.push('prefers_examples_first = ?');
902
+ params.push(data.prefersExamplesFirst ? 1 : 0);
903
+ }
904
+ if (data.prefersSocraticMode !== undefined) {
905
+ updates.push('prefers_socratic_mode = ?');
906
+ params.push(data.prefersSocraticMode ? 1 : 0);
907
+ }
908
+ if (data.toleranceForChallenge !== undefined) {
909
+ updates.push('tolerance_for_challenge = ?');
910
+ params.push(data.toleranceForChallenge);
911
+ }
912
+ if (data.bestTimeOfDay !== undefined) {
913
+ updates.push('best_time_of_day = ?');
914
+ params.push(data.bestTimeOfDay);
915
+ }
916
+ if (data.typicalSessionLength !== undefined) {
917
+ updates.push('typical_session_length = ?');
918
+ params.push(data.typicalSessionLength);
919
+ }
920
+ if (data.preferredPace !== undefined) {
921
+ updates.push('preferred_pace = ?');
922
+ params.push(data.preferredPace);
923
+ }
924
+ if (data.respondsToEncouragement !== undefined) {
925
+ updates.push('responds_to_encouragement = ?');
926
+ params.push(data.respondsToEncouragement ? 1 : 0);
927
+ }
928
+ if (data.needsNormalization !== undefined) {
929
+ updates.push('needs_normalization = ?');
930
+ params.push(data.needsNormalization ? 1 : 0);
931
+ }
932
+ if (data.effectiveStrategies !== undefined) {
933
+ updates.push('effective_strategies = ?');
934
+ params.push(stringifyJSON(data.effectiveStrategies));
935
+ }
936
+ if (data.preferredArchitecture !== undefined) {
937
+ updates.push('preferred_architecture = ?');
938
+ params.push(data.preferredArchitecture);
939
+ }
940
+
941
+ if (updates.length > 0) {
942
+ updates.push('updated_at = datetime("now")');
943
+ params.push(learnerId);
944
+
945
+ db.prepare(`
946
+ UPDATE learner_preferences
947
+ SET ${updates.join(', ')}
948
+ WHERE learner_id = ?
949
+ `).run(...params);
950
+ }
951
+
952
+ return getPreferences(learnerId);
953
+ };
954
+
955
+ // ============================================================================
956
+ // Milestones
957
+ // ============================================================================
958
+
959
+ /**
960
+ * Create a milestone
961
+ */
962
+ export const createMilestone = (data) => {
963
+ const id = generateId();
964
+ db.prepare(`
965
+ INSERT INTO learning_milestones (
966
+ id, learner_id, type, title, description, concept_id, course_id
967
+ )
968
+ VALUES (?, ?, ?, ?, ?, ?, ?)
969
+ `).run(
970
+ id,
971
+ data.learnerId,
972
+ data.type,
973
+ data.title,
974
+ data.description || '',
975
+ data.conceptId || null,
976
+ data.courseId || null
977
+ );
978
+
979
+ return db.prepare('SELECT * FROM learning_milestones WHERE id = ?').get(id);
980
+ };
981
+
982
+ /**
983
+ * Get unacknowledged milestones
984
+ */
985
+ export const getUnacknowledgedMilestones = (learnerId) => {
986
+ return db.prepare(`
987
+ SELECT * FROM learning_milestones
988
+ WHERE learner_id = ? AND acknowledged = 0
989
+ ORDER BY achieved_at DESC
990
+ `).all(learnerId);
991
+ };
992
+
993
+ /**
994
+ * Acknowledge milestone
995
+ */
996
+ export const acknowledgeMilestone = (milestoneId) => {
997
+ db.prepare(`
998
+ UPDATE learning_milestones
999
+ SET acknowledged = 1
1000
+ WHERE id = ?
1001
+ `).run(milestoneId);
1002
+ };
1003
+
1004
+ // ============================================================================
1005
+ // Cost Tracking
1006
+ // ============================================================================
1007
+
1008
+ /**
1009
+ * Log agent cost
1010
+ */
1011
+ export const logAgentCost = (data) => {
1012
+ const id = generateId();
1013
+ db.prepare(`
1014
+ INSERT INTO agent_cost_log (
1015
+ id, learner_id, session_id, agent_role, architecture,
1016
+ input_tokens, output_tokens, latency_ms, model
1017
+ )
1018
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1019
+ `).run(
1020
+ id,
1021
+ data.learnerId,
1022
+ data.sessionId,
1023
+ data.agentRole,
1024
+ data.architecture,
1025
+ data.inputTokens || 0,
1026
+ data.outputTokens || 0,
1027
+ data.latencyMs || 0,
1028
+ data.model || ''
1029
+ );
1030
+
1031
+ return id;
1032
+ };
1033
+
1034
+ /**
1035
+ * Get cost statistics
1036
+ */
1037
+ export const getCostStats = (learnerId, days = 30) => {
1038
+ const row = db.prepare(`
1039
+ SELECT
1040
+ COUNT(*) as total_calls,
1041
+ SUM(input_tokens) as total_input_tokens,
1042
+ SUM(output_tokens) as total_output_tokens,
1043
+ AVG(latency_ms) as avg_latency_ms,
1044
+ COUNT(DISTINCT session_id) as session_count
1045
+ FROM agent_cost_log
1046
+ WHERE learner_id = ?
1047
+ AND timestamp > datetime('now', '-' || ? || ' days')
1048
+ `).get(learnerId, days);
1049
+
1050
+ const byArchitecture = db.prepare(`
1051
+ SELECT
1052
+ architecture,
1053
+ COUNT(*) as calls,
1054
+ SUM(input_tokens + output_tokens) as total_tokens
1055
+ FROM agent_cost_log
1056
+ WHERE learner_id = ?
1057
+ AND timestamp > datetime('now', '-' || ? || ' days')
1058
+ GROUP BY architecture
1059
+ `).all(learnerId, days);
1060
+
1061
+ return { ...row, byArchitecture };
1062
+ };
1063
+
1064
+ // ============================================================================
1065
+ // Context Injection
1066
+ // ============================================================================
1067
+
1068
+ /**
1069
+ * Build context for LLM injection
1070
+ * Returns a structured summary of the learner's history for system prompts
1071
+ */
1072
+ export const buildContextInjection = (learnerId, options = {}) => {
1073
+ const {
1074
+ maxSessionSummaries = 3,
1075
+ maxEpisodes = 5,
1076
+ maxThreads = 3,
1077
+ includePreferences = true,
1078
+ } = options;
1079
+
1080
+ // Get recent session summaries
1081
+ const recentSessions = getRecentSessionSummaries(learnerId, maxSessionSummaries);
1082
+
1083
+ // Get active threads
1084
+ const activeThreads = getActiveThreads(learnerId).slice(0, maxThreads);
1085
+
1086
+ // Get concepts due for review
1087
+ const dueForReview = getConceptsDueForReview(learnerId, 3);
1088
+
1089
+ // Get important episodes
1090
+ const recentEpisodes = getImportantEpisodes(learnerId, 0.6, maxEpisodes);
1091
+
1092
+ // Get preferences
1093
+ const preferences = includePreferences ? getPreferences(learnerId) : null;
1094
+
1095
+ // Build narrative summary
1096
+ let narrative = '';
1097
+
1098
+ if (recentSessions.length > 0) {
1099
+ const lastSession = recentSessions[0];
1100
+ narrative += `Last session: ${lastSession.narrative_summary || 'No summary available'}. `;
1101
+
1102
+ if (lastSession.unresolvedQuestions?.length > 0) {
1103
+ narrative += `Unresolved: "${lastSession.unresolvedQuestions[0]}". `;
1104
+ }
1105
+ }
1106
+
1107
+ if (activeThreads.length > 0) {
1108
+ narrative += `Active questions: ${activeThreads.map(t => t.question).join('; ')}. `;
1109
+ }
1110
+
1111
+ if (dueForReview.length > 0) {
1112
+ narrative += `Concepts due for review: ${dueForReview.map(c => c.label).join(', ')}. `;
1113
+ }
1114
+
1115
+ if (preferences) {
1116
+ const style = [];
1117
+ if (preferences.prefersExamplesFirst) style.push('prefers examples before abstractions');
1118
+ if (preferences.prefersSocraticMode) style.push('responds well to Socratic questioning');
1119
+ if (preferences.needsNormalization) style.push('benefits from hearing that difficulty is normal');
1120
+ if (style.length > 0) {
1121
+ narrative += `Learning style: ${style.join(', ')}. `;
1122
+ }
1123
+ }
1124
+
1125
+ // Rough token estimate (4 chars per token average)
1126
+ const tokenCount = Math.ceil(narrative.length / 4);
1127
+
1128
+ return {
1129
+ narrativeSummary: narrative.trim(),
1130
+ recentSessions,
1131
+ activeThreads,
1132
+ dueForReview,
1133
+ recentEpisodes,
1134
+ preferences,
1135
+ tokenCount,
1136
+ };
1137
+ };
1138
+
1139
+ // ============================================================================
1140
+ // Full Knowledge Base Export
1141
+ // ============================================================================
1142
+
1143
+ /**
1144
+ * Get complete learner knowledge base
1145
+ * Useful for debugging or data export
1146
+ */
1147
+ export const getFullKnowledgeBase = (learnerId) => {
1148
+ getOrCreateLearnerMemory(learnerId);
1149
+
1150
+ return {
1151
+ learnerId,
1152
+ concepts: getAllConceptStates(learnerId),
1153
+ episodes: getRecentEpisodes(learnerId, 100),
1154
+ sessions: getRecentSessionSummaries(learnerId, 20),
1155
+ activeThreads: getActiveThreads(learnerId),
1156
+ lexicon: getPersonalDefinitions(learnerId),
1157
+ connections: getConnections(learnerId),
1158
+ preferences: getPreferences(learnerId),
1159
+ milestones: db.prepare(`
1160
+ SELECT * FROM learning_milestones
1161
+ WHERE learner_id = ?
1162
+ ORDER BY achieved_at DESC
1163
+ `).all(learnerId),
1164
+ costStats: getCostStats(learnerId),
1165
+ };
1166
+ };
1167
+
1168
+ // ============================================================================
1169
+ // Health Check
1170
+ // ============================================================================
1171
+
1172
+ /**
1173
+ * Check database health without creating records
1174
+ * @returns {boolean} - True if database is accessible
1175
+ */
1176
+ export const checkDatabaseHealth = () => {
1177
+ try {
1178
+ // Simple query to check table existence and connection
1179
+ const result = db.prepare(`
1180
+ SELECT name FROM sqlite_master
1181
+ WHERE type='table' AND name='learner_memory'
1182
+ `).get();
1183
+ return !!result;
1184
+ } catch (error) {
1185
+ console.error('[LearnerMemory] Database health check failed:', error);
1186
+ return false;
1187
+ }
1188
+ };
1189
+
1190
+ export default {
1191
+ getOrCreateLearnerMemory,
1192
+ updateLastSession,
1193
+ getConceptState,
1194
+ getAllConceptStates,
1195
+ getConceptsDueForReview,
1196
+ upsertConceptState,
1197
+ createEpisode,
1198
+ getRecentEpisodes,
1199
+ getEpisodesByType,
1200
+ getImportantEpisodes,
1201
+ incrementEpisodeRetrieval,
1202
+ decayEpisodeImportance,
1203
+ createSessionSummary,
1204
+ getSessionSummary,
1205
+ getRecentSessionSummaries,
1206
+ createThread,
1207
+ getThread,
1208
+ getActiveThreads,
1209
+ updateThread,
1210
+ upsertPersonalDefinition,
1211
+ getPersonalDefinitions,
1212
+ createConnection,
1213
+ getConnections,
1214
+ getPreferences,
1215
+ updatePreferences,
1216
+ createMilestone,
1217
+ getUnacknowledgedMilestones,
1218
+ acknowledgeMilestone,
1219
+ logAgentCost,
1220
+ getCostStats,
1221
+ buildContextInjection,
1222
+ getFullKnowledgeBase,
1223
+ checkDatabaseHealth,
1224
+ // Aliases for consistent naming
1225
+ getLearnerPreferences: getPreferences,
1226
+ upsertLearnerPreferences: updatePreferences,
1227
+ };