suemo 0.1.7 → 0.1.8

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.
@@ -110,8 +110,23 @@ export async function handleToolCall(
110
110
  }
111
111
 
112
112
  case 'recall': {
113
- const parsed = z.object({ nodeId: z.string() }).parse(params)
114
- return recall(db, parsed.nodeId)
113
+ const parsed = z.object({
114
+ nodeId: z.string(),
115
+ rating: z.number().int().min(1).max(4).optional(),
116
+ at: z.string().optional(),
117
+ dryRun: z.boolean().optional(),
118
+ }).parse(params)
119
+ log.debug('Dispatch recall tool', {
120
+ nodeId: parsed.nodeId,
121
+ rating: parsed.rating ?? 3,
122
+ hasAt: parsed.at !== undefined,
123
+ dryRun: parsed.dryRun ?? false,
124
+ })
125
+ return recall(db, parsed.nodeId, {
126
+ ...(parsed.rating !== undefined ? { rating: parsed.rating as 1 | 2 | 3 | 4 } : {}),
127
+ ...(parsed.at !== undefined ? { at: parsed.at } : {}),
128
+ ...(parsed.dryRun !== undefined ? { dryRun: parsed.dryRun } : {}),
129
+ })
115
130
  }
116
131
 
117
132
  case 'wander': {
@@ -143,9 +158,20 @@ export async function handleToolCall(
143
158
  }
144
159
 
145
160
  case 'context': {
146
- const parsed = z.object({ scope: z.string().optional(), limit: z.number().optional() }).parse(params)
161
+ const parsed = z.object({
162
+ scope: z.string().optional(),
163
+ sessionId: z.string().optional(),
164
+ limit: z.number().optional(),
165
+ }).parse(params)
166
+ const resolvedScope = parsed.scope?.trim() || inferredScope
167
+ log.debug('Dispatch context tool', {
168
+ scope: resolvedScope,
169
+ sessionId: parsed.sessionId ?? null,
170
+ limit: parsed.limit ?? null,
171
+ })
147
172
  return context(db, {
148
- scope: parsed.scope?.trim() || inferredScope,
173
+ scope: resolvedScope,
174
+ ...(parsed.sessionId !== undefined ? { sessionId: parsed.sessionId } : {}),
149
175
  ...(parsed.limit !== undefined ? { limit: parsed.limit } : {}),
150
176
  })
151
177
  }
@@ -205,7 +231,7 @@ export async function handleToolCall(
205
231
  }
206
232
 
207
233
  case 'health':
208
- return healthReport(db)
234
+ return healthReport(db, { embeddingProvider: config.embedding.provider })
209
235
 
210
236
  case 'stats':
211
237
  return suemoStats(db)
package/src/mcp/server.ts CHANGED
@@ -127,7 +127,14 @@ export async function startMcpServer(config: SuemoConfig): Promise<void> {
127
127
  projectDir: config.main?.projectDir ?? '.ua',
128
128
  })
129
129
  const db = await connect(config.surreal)
130
- await requireCompatibility(db)
130
+ log.debug('Running MCP HTTP compatibility preflight', {
131
+ embeddingProvider: config.embedding.provider,
132
+ context: 'mcp:http-startup',
133
+ })
134
+ await requireCompatibility(db, {
135
+ embeddingProvider: config.embedding.provider,
136
+ context: 'mcp:http-startup',
137
+ })
131
138
  await runSchema(db)
132
139
  const autoSync = createAutoSyncRunner(db, config)
133
140
  autoSync.start()
@@ -163,7 +170,13 @@ export async function startMcpStdioServer(config: SuemoConfig): Promise<void> {
163
170
  const autoSync = createAutoSyncRunner(db, config)
164
171
  autoSync.start()
165
172
  try {
173
+ log.debug('Running MCP stdio compatibility preflight', {
174
+ embeddingProvider: config.embedding.provider,
175
+ requireEmbedding: config.embedding.provider === 'surrealml',
176
+ context: 'mcp:stdio-startup',
177
+ })
166
178
  const compat = await checkCompatibility(db, {
179
+ embeddingProvider: config.embedding.provider,
167
180
  requireEmbedding: config.embedding.provider === 'surrealml',
168
181
  context: 'mcp:stdio-startup',
169
182
  })
package/src/mcp/stdio.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type { SuemoConfig } from '@/src/config.ts'
2
2
  import { getLogger } from '@/src/logger.ts'
3
3
  import { handleToolCall } from '@/src/mcp/dispatch.ts'
4
+ import { MEMORY_KIND_VALUES } from '@/src/types.ts'
4
5
  import { createInterface } from 'node:readline'
5
6
  import type { Surreal } from 'surrealdb'
6
7
 
@@ -36,7 +37,7 @@ const MCP_TOOLS: McpToolDefinition[] = [
36
37
  type: 'object',
37
38
  properties: {
38
39
  content: { type: 'string' },
39
- kind: { type: 'string', enum: ['observation', 'belief', 'question', 'hypothesis', 'goal'] },
40
+ kind: { type: 'string', enum: [...MEMORY_KIND_VALUES] },
40
41
  tags: { type: 'array', items: { type: 'string' } },
41
42
  scope: { type: 'string' },
42
43
  source: { type: 'string' },
@@ -96,7 +97,12 @@ const MCP_TOOLS: McpToolDefinition[] = [
96
97
  description: 'Fetch one node and neighbors; tick review schedule',
97
98
  inputSchema: {
98
99
  type: 'object',
99
- properties: { nodeId: { type: 'string' } },
100
+ properties: {
101
+ nodeId: { type: 'string' },
102
+ rating: { type: 'number', minimum: 1, maximum: 4 },
103
+ at: { type: 'string', description: 'ISO datetime override for deterministic replay/testing' },
104
+ dryRun: { type: 'boolean' },
105
+ },
100
106
  required: ['nodeId'],
101
107
  },
102
108
  },
@@ -132,6 +138,7 @@ const MCP_TOOLS: McpToolDefinition[] = [
132
138
  type: 'object',
133
139
  properties: {
134
140
  scope: { type: 'string' },
141
+ sessionId: { type: 'string' },
135
142
  limit: { type: 'number' },
136
143
  },
137
144
  },
@@ -233,7 +240,7 @@ const MCP_TOOLS: McpToolDefinition[] = [
233
240
  source: { type: 'string' },
234
241
  confidence: { type: 'number' },
235
242
  sessionId: { type: 'string' },
236
- kind: { type: 'string', enum: ['observation', 'belief', 'question', 'hypothesis', 'goal'] },
243
+ kind: { type: 'string', enum: [...MEMORY_KIND_VALUES] },
237
244
  },
238
245
  required: ['topicKey', 'content'],
239
246
  },
@@ -246,7 +253,7 @@ const MCP_TOOLS: McpToolDefinition[] = [
246
253
  properties: {
247
254
  nodeId: { type: 'string' },
248
255
  content: { type: 'string' },
249
- kind: { type: 'string', enum: ['observation', 'belief', 'question', 'hypothesis', 'goal'] },
256
+ kind: { type: 'string', enum: [...MEMORY_KIND_VALUES] },
250
257
  tags: { type: 'array', items: { type: 'string' } },
251
258
  scope: { type: ['string', 'null'] },
252
259
  source: { type: ['string', 'null'] },
@@ -378,7 +385,7 @@ export async function runStdioServer(
378
385
  log.info('stdin closed — stdio server exiting')
379
386
  }
380
387
 
381
- async function handleRpcMethod(
388
+ export async function handleRpcMethod(
382
389
  db: Surreal,
383
390
  config: SuemoConfig,
384
391
  method: string,
@@ -397,6 +404,8 @@ async function handleRpcMethod(
397
404
  protocolVersion,
398
405
  capabilities: {
399
406
  tools: { listChanged: false },
407
+ prompts: { listChanged: false },
408
+ resources: { listChanged: false },
400
409
  },
401
410
  serverInfo: {
402
411
  name: 'suemo',
@@ -417,6 +426,21 @@ async function handleRpcMethod(
417
426
  return { tools: MCP_TOOLS }
418
427
  }
419
428
 
429
+ // Optional MCP surfaces queried by some clients during capability probing.
430
+ // We currently do not expose custom prompts/resources, but we should answer
431
+ // with empty lists instead of throwing unknown-method errors.
432
+ if (method === 'prompts/list') {
433
+ return { prompts: [] }
434
+ }
435
+
436
+ if (method === 'resources/list') {
437
+ return { resources: [] }
438
+ }
439
+
440
+ if (method === 'resources/templates/list') {
441
+ return { resourceTemplates: [] }
442
+ }
443
+
420
444
  if (method === 'tools/call') {
421
445
  const name = typeof params.name === 'string' ? params.name : null
422
446
  if (!name) {
@@ -432,6 +456,10 @@ async function handleRpcMethod(
432
456
  : {}
433
457
 
434
458
  try {
459
+ log.debug('tools/call dispatching', {
460
+ name,
461
+ argumentKeys: Object.keys(toolArgs),
462
+ })
435
463
  const toolResult = await handleToolCall(db, config, name, toolArgs, opts)
436
464
  const structuredContent = toolResult && typeof toolResult === 'object' && !Array.isArray(toolResult)
437
465
  ? toolResult
@@ -5,11 +5,127 @@ import type { Surreal } from 'surrealdb'
5
5
 
6
6
  const log = getLogger(['suemo', 'memory', 'episode'])
7
7
 
8
+ function normalizeRecordId(value: unknown): string {
9
+ if (typeof value === 'string') return value
10
+ if (value && typeof value === 'object') {
11
+ const recordLike = value as { tb?: unknown; id?: unknown; toString?: () => string }
12
+ if (typeof recordLike.tb === 'string' && (typeof recordLike.id === 'string' || typeof recordLike.id === 'number')) {
13
+ return `${recordLike.tb}:${String(recordLike.id)}`
14
+ }
15
+ if (typeof recordLike.toString === 'function') {
16
+ const asString = recordLike.toString()
17
+ if (asString && asString !== '[object Object]') return asString
18
+ }
19
+ }
20
+ return String(value)
21
+ }
22
+
23
+ function normalizeDatetime(value: unknown, field: string): string {
24
+ if (typeof value === 'string') {
25
+ const parsed = Date.parse(value)
26
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString()
27
+ }
28
+ if (value instanceof Date) return value.toISOString()
29
+ if (value && typeof value === 'object') {
30
+ const maybeDate = value as { toISOString?: () => string; toString?: () => string }
31
+ if (typeof maybeDate.toISOString === 'function') {
32
+ const iso = maybeDate.toISOString()
33
+ const parsed = Date.parse(iso)
34
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString()
35
+ }
36
+ if (typeof maybeDate.toString === 'function') {
37
+ const asString = maybeDate.toString()
38
+ const parsed = Date.parse(asString)
39
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString()
40
+ }
41
+ }
42
+ throw new Error(`Invalid episode datetime field: ${field}`)
43
+ }
44
+
45
+ function normalizeMemoryRef(value: unknown): string {
46
+ return normalizeRecordId(value)
47
+ }
48
+
49
+ function normalizeEpisodeRow(raw: unknown): Episode {
50
+ if (!raw || typeof raw !== 'object') {
51
+ throw new Error('Invalid episode row returned from database')
52
+ }
53
+ const row = raw as Record<string, unknown>
54
+ const sessionId = typeof row.session_id === 'string' ? row.session_id : null
55
+ if (!sessionId) throw new Error('Episode row missing session_id')
56
+
57
+ return {
58
+ id: normalizeRecordId(row.id),
59
+ session_id: sessionId,
60
+ started_at: normalizeDatetime(row.started_at, 'started_at'),
61
+ ended_at: row.ended_at == null ? null : normalizeDatetime(row.ended_at, 'ended_at'),
62
+ summary: typeof row.summary === 'string' ? row.summary : null,
63
+ context: row.context && typeof row.context === 'object' && !Array.isArray(row.context)
64
+ ? row.context as Record<string, unknown>
65
+ : null,
66
+ goal: typeof row.goal === 'string' ? row.goal : null,
67
+ discoveries: Array.isArray(row.discoveries)
68
+ ? row.discoveries.filter((item): item is string => typeof item === 'string')
69
+ : null,
70
+ accomplished: Array.isArray(row.accomplished)
71
+ ? row.accomplished.filter((item): item is string => typeof item === 'string')
72
+ : null,
73
+ files_changed: Array.isArray(row.files_changed)
74
+ ? row.files_changed.filter((item): item is string => typeof item === 'string')
75
+ : null,
76
+ memory_ids: Array.isArray(row.memory_ids)
77
+ ? row.memory_ids.map((item) => normalizeMemoryRef(item))
78
+ : [],
79
+ }
80
+ }
81
+
8
82
  export async function episodeStart(
9
83
  db: Surreal,
10
84
  sessionId: string,
11
85
  ): Promise<Episode> {
12
86
  log.info('episodeStart()', { sessionId })
87
+ const openEpisodesResult = await db.query<[Episode[]]>(
88
+ `
89
+ SELECT * FROM episode
90
+ WHERE session_id = $sessionId
91
+ AND ended_at = NONE
92
+ ORDER BY started_at DESC
93
+ LIMIT 20
94
+ `,
95
+ { sessionId },
96
+ )
97
+ const openEpisodes = (openEpisodesResult[0] ?? []).map((episode) => normalizeEpisodeRow(episode))
98
+ log.debug('episodeStart() open episode probe', {
99
+ sessionId,
100
+ openCount: openEpisodes.length,
101
+ })
102
+ if (openEpisodes.length > 0) {
103
+ const [latest, ...stale] = openEpisodes
104
+ if (stale.length > 0 && latest) {
105
+ const staleIds = stale.map((episode) => episode.id)
106
+ log.warning('episodeStart() found multiple open episodes, closing stale entries', {
107
+ sessionId,
108
+ staleCount: staleIds.length,
109
+ latestEpisodeId: latest.id,
110
+ })
111
+ await db.query(
112
+ `
113
+ UPDATE episode
114
+ SET ended_at = time::now(),
115
+ updated_at = time::now()
116
+ WHERE id INSIDE $staleIds
117
+ `,
118
+ { staleIds },
119
+ )
120
+ await incrementWriteStats(db)
121
+ }
122
+ log.debug('episodeStart() reused existing open episode', {
123
+ sessionId,
124
+ episodeId: latest?.id,
125
+ })
126
+ if (latest) return latest
127
+ }
128
+
13
129
  const result = await db.query<[Episode[]]>(
14
130
  `
15
131
  CREATE episode CONTENT {
@@ -27,7 +143,7 @@ export async function episodeStart(
27
143
  { sessionId },
28
144
  )
29
145
  await incrementWriteStats(db)
30
- return result[0]![0]!
146
+ return normalizeEpisodeRow(result[0]![0]!)
31
147
  }
32
148
 
33
149
  export async function episodeEnd(
@@ -71,8 +187,15 @@ export async function episodeEnd(
71
187
  : null,
72
188
  },
73
189
  )
74
- const episode = result[0]?.[0]
190
+ const episodeRaw = result[0]?.[0]
191
+ const episode = episodeRaw ? normalizeEpisodeRow(episodeRaw) : undefined
75
192
  if (!episode) throw new Error(`No open episode found for session: ${sessionId}`)
193
+ log.debug('episodeEnd() closed open episode', {
194
+ sessionId,
195
+ episodeId: episode.id,
196
+ hasSummary: Boolean(summaryText),
197
+ hasStructured: Boolean(fields),
198
+ })
76
199
  await incrementWriteStats(db)
77
200
  return episode
78
201
  }
@@ -85,17 +208,40 @@ export async function attachToEpisode(
85
208
  db: Surreal,
86
209
  sessionId: string,
87
210
  memoryId: string,
88
- ): Promise<void> {
211
+ ): Promise<{ attached: boolean; reason?: 'no-open-episode' | 'already-attached'; episodeId?: string }> {
89
212
  log.debug('attachToEpisode()', { sessionId, memoryId })
213
+ const openEpisodeResult = await db.query<[Episode[]]>(
214
+ `
215
+ SELECT * FROM episode
216
+ WHERE session_id = $sessionId
217
+ AND ended_at = NONE
218
+ ORDER BY started_at DESC
219
+ LIMIT 1
220
+ `,
221
+ { sessionId },
222
+ )
223
+ const openEpisodeRaw = openEpisodeResult[0]?.[0]
224
+ const openEpisode = openEpisodeRaw ? normalizeEpisodeRow(openEpisodeRaw) : undefined
225
+ if (!openEpisode) {
226
+ log.debug('attachToEpisode() no open episode found', { sessionId, memoryId })
227
+ return { attached: false, reason: 'no-open-episode' }
228
+ }
229
+
230
+ const currentIds = (openEpisode.memory_ids ?? []).map((entry) => normalizeMemoryRef(entry))
231
+ if (currentIds.includes(memoryId)) {
232
+ return { attached: false, reason: 'already-attached', episodeId: openEpisode.id }
233
+ }
234
+
90
235
  await db.query(
91
236
  `
92
- UPDATE episode
93
- SET memory_ids += [$memoryId]
237
+ UPDATE <record<episode>>$episodeId
238
+ SET memory_ids = array::distinct(memory_ids + [<record<memory>>$memoryId])
94
239
  , updated_at = time::now()
95
- WHERE session_id = $sessionId AND ended_at = NONE
96
240
  `,
97
- { sessionId, memoryId },
241
+ { episodeId: openEpisode.id, memoryId },
98
242
  )
243
+ await incrementWriteStats(db)
244
+ return { attached: true, episodeId: openEpisode.id }
99
245
  }
100
246
 
101
247
  export interface SessionContext {
@@ -0,0 +1,237 @@
1
+ import { getLogger } from '@/src/logger.ts'
2
+
3
+ const log = getLogger(['suemo', 'memory', 'fsrs'])
4
+
5
+ export type FsrsRating = 1 | 2 | 3 | 4
6
+ export type FsrsState = 'new' | 'learning' | 'review' | 'relearning'
7
+
8
+ export interface FsrsSnapshot {
9
+ fsrs_stability: number | null
10
+ fsrs_difficulty: number | null
11
+ fsrs_next_review: string | null
12
+ fsrs_state: FsrsState | null
13
+ fsrs_last_review: string | null
14
+ fsrs_reps: number | null
15
+ fsrs_lapses: number | null
16
+ fsrs_last_grade: number | null
17
+ }
18
+
19
+ export interface FsrsReviewResult {
20
+ rating: FsrsRating
21
+ retrievability: number
22
+ elapsedDays: number
23
+ intervalDays: number
24
+ stability: number
25
+ difficulty: number
26
+ stateBefore: FsrsState
27
+ stateAfter: FsrsState
28
+ nextReview: string
29
+ lastReview: string
30
+ reps: number
31
+ lapses: number
32
+ }
33
+
34
+ const FSRS5_WEIGHTS = [
35
+ 0.40255,
36
+ 1.18385,
37
+ 3.173,
38
+ 15.69105,
39
+ 7.1949,
40
+ 0.5345,
41
+ 1.4604,
42
+ 0.0046,
43
+ 1.54575,
44
+ 0.1192,
45
+ 1.01925,
46
+ 1.9395,
47
+ 0.11,
48
+ 0.29605,
49
+ 2.2698,
50
+ 0.2315,
51
+ 2.9898,
52
+ 0.51655,
53
+ 0.6621,
54
+ ] as const
55
+
56
+ const FORGET_DECAY = -0.5
57
+ const FORGET_FACTOR = 19 / 81
58
+ const REQUEST_RETENTION = 0.9
59
+ const MIN_STABILITY = 0.1
60
+ const MAX_STABILITY = 36500
61
+
62
+ function clamp(value: number, min: number, max: number): number {
63
+ return Math.max(min, Math.min(max, value))
64
+ }
65
+
66
+ function round(value: number, precision = 8): number {
67
+ const factor = 10 ** precision
68
+ return Math.round(value * factor) / factor
69
+ }
70
+
71
+ function parseDate(value: string | null): Date | null {
72
+ if (!value) return null
73
+ const parsed = new Date(value)
74
+ if (Number.isNaN(parsed.getTime())) return null
75
+ return parsed
76
+ }
77
+
78
+ function elapsedDaysSince(previous: Date | null, now: Date): number {
79
+ if (!previous) return 0
80
+ const diffMs = now.getTime() - previous.getTime()
81
+ if (!Number.isFinite(diffMs) || diffMs <= 0) return 0
82
+ return diffMs / (24 * 60 * 60 * 1000)
83
+ }
84
+
85
+ function initStability(rating: FsrsRating): number {
86
+ const weight = FSRS5_WEIGHTS[rating - 1] ?? FSRS5_WEIGHTS[2]
87
+ return clamp(weight, MIN_STABILITY, MAX_STABILITY)
88
+ }
89
+
90
+ function initDifficulty(rating: FsrsRating): number {
91
+ const value = FSRS5_WEIGHTS[4] - Math.exp((rating - 1) * FSRS5_WEIGHTS[5]) + 1
92
+ return clamp(round(value), 1, 10)
93
+ }
94
+
95
+ function forgettingCurve(stability: number, elapsedDays: number): number {
96
+ if (elapsedDays <= 0) return 1
97
+ const retrievability = Math.pow(1 + (FORGET_FACTOR * elapsedDays) / stability, FORGET_DECAY)
98
+ return clamp(round(retrievability), 0, 1)
99
+ }
100
+
101
+ function nextDifficulty(currentDifficulty: number, rating: FsrsRating): number {
102
+ const delta = -FSRS5_WEIGHTS[6] * (rating - 3)
103
+ const damped = currentDifficulty + (delta * (10 - currentDifficulty)) / 9
104
+ const targetEasyDifficulty = initDifficulty(4)
105
+ const reverted = FSRS5_WEIGHTS[7] * targetEasyDifficulty + (1 - FSRS5_WEIGHTS[7]) * damped
106
+ return clamp(round(reverted), 1, 10)
107
+ }
108
+
109
+ function nextRecallStability(
110
+ difficulty: number,
111
+ stability: number,
112
+ retrievability: number,
113
+ rating: FsrsRating,
114
+ ): number {
115
+ const hardPenalty = rating === 2 ? FSRS5_WEIGHTS[15] : 1
116
+ const easyBonus = rating === 4 ? FSRS5_WEIGHTS[16] : 1
117
+ const increment = Math.exp(FSRS5_WEIGHTS[8])
118
+ * (11 - difficulty)
119
+ * Math.pow(stability, -FSRS5_WEIGHTS[9])
120
+ * (Math.exp((1 - retrievability) * FSRS5_WEIGHTS[10]) - 1)
121
+ * hardPenalty
122
+ * easyBonus
123
+ const next = stability * (1 + increment)
124
+ return clamp(round(next), MIN_STABILITY, MAX_STABILITY)
125
+ }
126
+
127
+ function nextForgetStability(
128
+ difficulty: number,
129
+ stability: number,
130
+ retrievability: number,
131
+ ): number {
132
+ const next = FSRS5_WEIGHTS[11]
133
+ * Math.pow(difficulty, -FSRS5_WEIGHTS[12])
134
+ * (Math.pow(stability + 1, FSRS5_WEIGHTS[13]) - 1)
135
+ * Math.exp((1 - retrievability) * FSRS5_WEIGHTS[14])
136
+ return clamp(round(next), MIN_STABILITY, MAX_STABILITY)
137
+ }
138
+
139
+ function nextShortTermStability(stability: number, rating: FsrsRating): number {
140
+ const increment = Math.exp(FSRS5_WEIGHTS[17] * (rating - 3 + FSRS5_WEIGHTS[18]))
141
+ const next = stability * increment
142
+ return clamp(round(next), MIN_STABILITY, MAX_STABILITY)
143
+ }
144
+
145
+ function nextState(current: FsrsState, rating: FsrsRating): FsrsState {
146
+ if (rating === 1) {
147
+ return current === 'review' ? 'relearning' : 'learning'
148
+ }
149
+ if (current === 'new' || current === 'learning' || current === 'relearning') {
150
+ return rating >= 3 ? 'review' : current
151
+ }
152
+ return 'review'
153
+ }
154
+
155
+ function intervalFromStability(stability: number, rating: FsrsRating): number {
156
+ const modifier = (Math.pow(REQUEST_RETENTION, 1 / FORGET_DECAY) - 1) / FORGET_FACTOR
157
+ let interval = clamp(Math.round(stability * modifier), 1, MAX_STABILITY)
158
+ if (rating === 2) interval = Math.max(1, Math.round(interval * 0.85))
159
+ if (rating === 4) interval = Math.min(MAX_STABILITY, Math.round(interval * 1.15))
160
+ return interval
161
+ }
162
+
163
+ function learningIntervalDays(rating: FsrsRating, state: FsrsState): number {
164
+ if (rating === 1) return state === 'relearning' ? 5 / 1440 : 10 / 1440
165
+ if (rating === 2) return 20 / 1440
166
+ if (rating === 3) return 1
167
+ return 2
168
+ }
169
+
170
+ export function applyFsrsReview(
171
+ current: FsrsSnapshot,
172
+ rating: FsrsRating,
173
+ reviewAt: Date,
174
+ ): FsrsReviewResult {
175
+ if (![1, 2, 3, 4].includes(rating)) {
176
+ throw new Error(`Invalid FSRS rating: ${rating}. Expected 1|2|3|4.`)
177
+ }
178
+ if (Number.isNaN(reviewAt.getTime())) {
179
+ throw new Error('Invalid review date for FSRS review')
180
+ }
181
+
182
+ const stateBefore: FsrsState = current.fsrs_state ?? 'new'
183
+ const lastReviewDate = parseDate(current.fsrs_last_review)
184
+ const elapsedDays = round(elapsedDaysSince(lastReviewDate, reviewAt))
185
+
186
+ const hadState = current.fsrs_stability !== null && current.fsrs_difficulty !== null
187
+
188
+ let stability = hadState ? current.fsrs_stability! : initStability(rating)
189
+ let difficulty = hadState ? current.fsrs_difficulty! : initDifficulty(rating)
190
+ let retrievability = hadState ? forgettingCurve(stability, elapsedDays) : 1
191
+
192
+ if (hadState) {
193
+ difficulty = nextDifficulty(difficulty, rating)
194
+ if (elapsedDays < 1) {
195
+ stability = nextShortTermStability(stability, rating)
196
+ } else if (rating === 1) {
197
+ stability = nextForgetStability(difficulty, stability, retrievability)
198
+ } else {
199
+ stability = nextRecallStability(difficulty, stability, retrievability, rating)
200
+ }
201
+ }
202
+
203
+ const stateAfter = nextState(stateBefore, rating)
204
+ const intervalDays = stateAfter === 'review'
205
+ ? intervalFromStability(stability, rating)
206
+ : learningIntervalDays(rating, stateAfter)
207
+
208
+ const nextReview = new Date(reviewAt.getTime() + intervalDays * 24 * 60 * 60 * 1000)
209
+ const reps = (current.fsrs_reps ?? 0) + 1
210
+ const lapses = (current.fsrs_lapses ?? 0) + (rating === 1 ? 1 : 0)
211
+
212
+ const result: FsrsReviewResult = {
213
+ rating,
214
+ retrievability,
215
+ elapsedDays,
216
+ intervalDays: round(intervalDays),
217
+ stability,
218
+ difficulty,
219
+ stateBefore,
220
+ stateAfter,
221
+ nextReview: nextReview.toISOString(),
222
+ lastReview: reviewAt.toISOString(),
223
+ reps,
224
+ lapses,
225
+ }
226
+
227
+ log.debug('FSRS review computed', {
228
+ rating,
229
+ elapsedDays: result.elapsedDays,
230
+ intervalDays: result.intervalDays,
231
+ stateBefore,
232
+ stateAfter,
233
+ nextReview: result.nextReview,
234
+ })
235
+
236
+ return result
237
+ }