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.
- package/README.md +20 -2
- package/package.json +4 -2
- package/skills/suemo/SKILL.md +2 -2
- package/skills/suemo/references/agents-snippet.md +1 -1
- package/skills/suemo/references/cli-reference.md +1 -1
- package/skills/suemo/references/core-workflow.md +1 -1
- package/skills/suemo/references/manual-test-plan.md +1 -1
- package/skills/suemo/references/mcp-reference.md +1 -1
- package/skills/suemo/references/schema-retention-longevity.md +1 -1
- package/skills/suemo/references/sync-local-vps.md +1 -1
- package/src/AGENTS.md +1 -1
- package/src/cli/commands/health.ts +2 -2
- package/src/cli/commands/recall.ts +29 -1
- package/src/cli/commands/serve.ts +12 -0
- package/src/cognitive/consolidate.ts +6 -1
- package/src/cognitive/health.ts +25 -3
- package/src/db/client.ts +29 -26
- package/src/db/engines.ts +113 -0
- package/src/db/preflight.ts +56 -27
- package/src/db/schema.surql +7 -0
- package/src/mcp/dispatch.ts +31 -5
- package/src/mcp/server.ts +14 -1
- package/src/mcp/stdio.ts +33 -5
- package/src/memory/episode.ts +153 -7
- package/src/memory/fsrs.ts +237 -0
- package/src/memory/read.ts +191 -23
- package/src/memory/write.ts +46 -22
- package/src/opencode/plugin.ts +138 -17
- package/src/sync.ts +20 -17
- package/src/types.ts +9 -2
package/src/mcp/dispatch.ts
CHANGED
|
@@ -110,8 +110,23 @@ export async function handleToolCall(
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
case 'recall': {
|
|
113
|
-
const parsed = z.object({
|
|
114
|
-
|
|
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({
|
|
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:
|
|
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
|
-
|
|
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: [
|
|
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: {
|
|
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: [
|
|
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: [
|
|
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
|
package/src/memory/episode.ts
CHANGED
|
@@ -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
|
|
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<
|
|
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
|
|
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
|
-
{
|
|
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
|
+
}
|