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.
@@ -2,6 +2,7 @@ import { incrementQueryStats } from '@/src/cognitive/health.ts'
2
2
  import type { SuemoConfig } from '@/src/config.ts'
3
3
  import { getEmbedding } from '@/src/embedding/index.ts'
4
4
  import { getLogger } from '@/src/logger.ts'
5
+ import { applyFsrsReview, type FsrsRating } from '@/src/memory/fsrs.ts'
5
6
  import type {
6
7
  Episode,
7
8
  MemoryNode,
@@ -498,12 +499,85 @@ export async function query(
498
499
  return merged
499
500
  }
500
501
 
501
- // ── recall() — single node + FSRS tick ───────────────────────────────────────
502
+ export interface RecallOptions {
503
+ rating?: FsrsRating
504
+ at?: string
505
+ dryRun?: boolean
506
+ }
507
+
508
+ export interface RecallResult {
509
+ node: MemoryNode
510
+ neighbors: MemoryNode[]
511
+ fsrs: {
512
+ rating: FsrsRating
513
+ retrievability: number
514
+ elapsedDays: number
515
+ intervalDays: number
516
+ nextReview: string
517
+ stateBefore: 'new' | 'learning' | 'review' | 'relearning'
518
+ stateAfter: 'new' | 'learning' | 'review' | 'relearning'
519
+ dryRun: boolean
520
+ }
521
+ }
522
+
523
+ function parseFsrsState(value: unknown): 'new' | 'learning' | 'review' | 'relearning' | null {
524
+ if (value === null || value === undefined) return null
525
+ if (value === 'new' || value === 'learning' || value === 'review' || value === 'relearning') {
526
+ return value
527
+ }
528
+ return null
529
+ }
530
+
531
+ function parseFsrsGrade(value: unknown): number | null {
532
+ if (typeof value !== 'number' || !Number.isInteger(value)) return null
533
+ if (value < 1 || value > 4) return null
534
+ return value
535
+ }
536
+
537
+ function parseFsrsCount(value: unknown): number | null {
538
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null
539
+ const parsed = Math.trunc(value)
540
+ if (parsed < 0) return null
541
+ return parsed
542
+ }
543
+
544
+ function withFsrsDefaults(node: MemoryNode): MemoryNode {
545
+ const raw = node as MemoryNode & {
546
+ fsrs_state?: unknown
547
+ fsrs_last_review?: unknown
548
+ fsrs_reps?: unknown
549
+ fsrs_lapses?: unknown
550
+ fsrs_last_grade?: unknown
551
+ }
552
+ const nextReview = typeof raw.fsrs_next_review === 'string' ? raw.fsrs_next_review : null
553
+ const lastReview = typeof raw.fsrs_last_review === 'string' ? raw.fsrs_last_review : null
554
+ return {
555
+ ...node,
556
+ fsrs_next_review: nextReview,
557
+ fsrs_state: parseFsrsState(raw.fsrs_state),
558
+ fsrs_last_review: lastReview,
559
+ fsrs_reps: parseFsrsCount(raw.fsrs_reps),
560
+ fsrs_lapses: parseFsrsCount(raw.fsrs_lapses),
561
+ fsrs_last_grade: parseFsrsGrade(raw.fsrs_last_grade),
562
+ }
563
+ }
564
+
565
+ // ── recall() — single node + FSRS scheduling ─────────────────────────────────
502
566
  export async function recall(
503
567
  db: Surreal,
504
568
  nodeId: string,
505
- ): Promise<{ node: MemoryNode; neighbors: MemoryNode[] }> {
506
- log.info('recall()', { nodeId })
569
+ options: RecallOptions = {},
570
+ ): Promise<RecallResult> {
571
+ const rating = options.rating ?? 3
572
+ if (![1, 2, 3, 4].includes(rating)) {
573
+ throw new Error(`Invalid recall rating: ${rating}. Expected 1|2|3|4.`)
574
+ }
575
+ const reviewAt = options.at ? new Date(options.at) : new Date()
576
+ if (Number.isNaN(reviewAt.getTime())) {
577
+ throw new Error(`Invalid recall.at datetime: ${options.at}`)
578
+ }
579
+
580
+ log.info('recall()', { nodeId, rating, at: reviewAt.toISOString(), dryRun: options.dryRun ?? false })
507
581
 
508
582
  const [nodeResult, neighborResult] = await Promise.all([
509
583
  db.query<[MemoryNode[]]>(
@@ -523,21 +597,94 @@ export async function recall(
523
597
  ),
524
598
  ])
525
599
 
526
- const node = nodeResult[0]?.[0]
527
- if (!node) throw new Error(`Memory node not found: ${nodeId}`)
600
+ const nodeRaw = nodeResult[0]?.[0]
601
+ if (!nodeRaw) throw new Error(`Memory node not found: ${nodeId}`)
602
+ const node = withFsrsDefaults(nodeRaw)
603
+ const neighbors = neighborResult[0] ?? []
528
604
 
529
- // FSRS tick — simplified next review interval
530
- await db.query(
531
- `
532
- UPDATE <record<memory>>$id SET
533
- fsrs_next_review = time::now() + 1d,
534
- updated_at = time::now()
535
- `,
536
- { id: nodeId },
605
+ const fsrs = applyFsrsReview(
606
+ {
607
+ fsrs_stability: node.fsrs_stability,
608
+ fsrs_difficulty: node.fsrs_difficulty,
609
+ fsrs_next_review: node.fsrs_next_review,
610
+ fsrs_state: node.fsrs_state,
611
+ fsrs_last_review: node.fsrs_last_review,
612
+ fsrs_reps: node.fsrs_reps,
613
+ fsrs_lapses: node.fsrs_lapses,
614
+ fsrs_last_grade: node.fsrs_last_grade,
615
+ },
616
+ rating,
617
+ reviewAt,
537
618
  )
619
+
620
+ if (!options.dryRun) {
621
+ await db.query(
622
+ `
623
+ UPDATE <record<memory>>$id SET
624
+ fsrs_stability = $stability,
625
+ fsrs_difficulty = $difficulty,
626
+ fsrs_next_review = <datetime>$nextReview,
627
+ fsrs_state = $state,
628
+ fsrs_last_review = <datetime>$lastReview,
629
+ fsrs_reps = $reps,
630
+ fsrs_lapses = $lapses,
631
+ fsrs_last_grade = $lastGrade,
632
+ updated_at = time::now()
633
+ `,
634
+ {
635
+ id: nodeId,
636
+ stability: fsrs.stability,
637
+ difficulty: fsrs.difficulty,
638
+ nextReview: fsrs.nextReview,
639
+ state: fsrs.stateAfter,
640
+ lastReview: fsrs.lastReview,
641
+ reps: fsrs.reps,
642
+ lapses: fsrs.lapses,
643
+ lastGrade: fsrs.rating,
644
+ },
645
+ )
646
+ log.debug('recall() persisted FSRS review state', {
647
+ nodeId,
648
+ rating,
649
+ nextReview: fsrs.nextReview,
650
+ state: fsrs.stateAfter,
651
+ reps: fsrs.reps,
652
+ lapses: fsrs.lapses,
653
+ })
654
+ } else {
655
+ log.debug('recall() dry-run; skipped FSRS persistence', {
656
+ nodeId,
657
+ rating,
658
+ nextReview: fsrs.nextReview,
659
+ state: fsrs.stateAfter,
660
+ })
661
+ }
538
662
  await incrementQueryStats(db)
539
663
 
540
- return { node, neighbors: neighborResult[0] ?? [] }
664
+ return {
665
+ node: {
666
+ ...node,
667
+ fsrs_stability: fsrs.stability,
668
+ fsrs_difficulty: fsrs.difficulty,
669
+ fsrs_next_review: fsrs.nextReview,
670
+ fsrs_state: fsrs.stateAfter,
671
+ fsrs_last_review: fsrs.lastReview,
672
+ fsrs_reps: fsrs.reps,
673
+ fsrs_lapses: fsrs.lapses,
674
+ fsrs_last_grade: fsrs.rating,
675
+ },
676
+ neighbors,
677
+ fsrs: {
678
+ rating: fsrs.rating,
679
+ retrievability: fsrs.retrievability,
680
+ elapsedDays: fsrs.elapsedDays,
681
+ intervalDays: fsrs.intervalDays,
682
+ nextReview: fsrs.nextReview,
683
+ stateBefore: fsrs.stateBefore,
684
+ stateAfter: fsrs.stateAfter,
685
+ dryRun: options.dryRun ?? false,
686
+ },
687
+ }
541
688
  }
542
689
 
543
690
  // ── wander() — spreading activation walk ─────────────────────────────────────
@@ -615,6 +762,7 @@ export async function timeline(
615
762
 
616
763
  export interface ContextPayload {
617
764
  scope: string
765
+ sessionId: string | null
618
766
  openEpisode: Episode | null
619
767
  lastEpisode: Episode | null
620
768
  recent: MemoryNode[]
@@ -623,20 +771,31 @@ export interface ContextPayload {
623
771
 
624
772
  export async function context(
625
773
  db: Surreal,
626
- opts: { scope: string; limit?: number },
774
+ opts: { scope: string; sessionId?: string; limit?: number },
627
775
  ): Promise<ContextPayload> {
628
776
  const limit = Math.min(Math.max(opts.limit ?? 20, 1), 100)
629
777
  const scope = opts.scope
778
+ const sessionId = opts.sessionId?.trim() || null
779
+
780
+ log.debug('context()', { scope, sessionId, limit })
781
+
782
+ const openEpisodePromise: Promise<[Episode[]]> = sessionId
783
+ ? db.query<[Episode[]]>(
784
+ `SELECT * FROM episode WHERE session_id = $sessionId AND ended_at = NONE ORDER BY started_at DESC LIMIT 1`,
785
+ { sessionId },
786
+ )
787
+ : Promise.resolve([[]] as [Episode[]])
788
+
789
+ const lastEpisodePromise: Promise<[Episode[]]> = sessionId
790
+ ? db.query<[Episode[]]>(
791
+ `SELECT * FROM episode WHERE session_id = $sessionId ORDER BY started_at DESC LIMIT 1`,
792
+ { sessionId },
793
+ )
794
+ : Promise.resolve([[]] as [Episode[]])
630
795
 
631
796
  const [openEpisodeR, lastEpisodeR, recentR, totalR, activeR, statsR] = await Promise.all([
632
- db.query<[Episode[]]>(
633
- `SELECT * FROM episode WHERE session_id = $scope AND ended_at = NONE ORDER BY started_at DESC LIMIT 1`,
634
- { scope },
635
- ),
636
- db.query<[Episode[]]>(
637
- `SELECT * FROM episode WHERE session_id = $scope ORDER BY started_at DESC LIMIT 1`,
638
- { scope },
639
- ),
797
+ openEpisodePromise,
798
+ lastEpisodePromise,
640
799
  db.query<[MemoryNode[]]>(
641
800
  `
642
801
  SELECT * FROM memory
@@ -662,11 +821,20 @@ export async function context(
662
821
  ),
663
822
  ])
664
823
 
824
+ log.debug('context() query batches complete', {
825
+ scope,
826
+ sessionId,
827
+ recentCount: recentR[0]?.length ?? 0,
828
+ hasOpenEpisode: Boolean(openEpisodeR[0]?.[0]),
829
+ hasLastEpisode: Boolean(lastEpisodeR[0]?.[0]),
830
+ })
831
+
665
832
  await incrementQueryStats(db)
666
833
 
667
834
  const statsRow = statsR[0]?.[0]
668
835
  return {
669
836
  scope,
837
+ sessionId,
670
838
  openEpisode: openEpisodeR[0]?.[0] ?? null,
671
839
  lastEpisode: lastEpisodeR[0]?.[0] ?? null,
672
840
  recent: recentR[0] ?? [],
@@ -10,6 +10,36 @@ import type { Surreal } from 'surrealdb'
10
10
 
11
11
  const log = getLogger(['suemo', 'memory', 'write'])
12
12
 
13
+ async function attachToEpisodeWithLogging(
14
+ db: Surreal,
15
+ sessionId: string,
16
+ memoryId: string,
17
+ ): Promise<void> {
18
+ const result = await attachToEpisode(db, sessionId, memoryId)
19
+ if (result.attached) {
20
+ log.debug('Attached memory to episode', {
21
+ sessionId,
22
+ memoryId,
23
+ episodeId: result.episodeId,
24
+ })
25
+ return
26
+ }
27
+
28
+ if (result.reason === 'no-open-episode') {
29
+ log.warning('attachToEpisode skipped: no open episode', { sessionId, memoryId })
30
+ return
31
+ }
32
+
33
+ if (result.reason === 'already-attached') {
34
+ log.debug('attachToEpisode skipped: already attached', {
35
+ sessionId,
36
+ memoryId,
37
+ episodeId: result.episodeId,
38
+ })
39
+ return
40
+ }
41
+ }
42
+
13
43
  // ── observe() ─────────────────────────────────────────────────────────────────
14
44
  export async function observe(
15
45
  db: Surreal,
@@ -84,11 +114,7 @@ export async function observe(
84
114
  )
85
115
  const merged = updated[0]![0]!
86
116
  if (input.sessionId) {
87
- try {
88
- await attachToEpisode(db, input.sessionId, merged.id)
89
- } catch {
90
- log.debug('attachToEpisode skipped (no open episode)', { sessionId: input.sessionId })
91
- }
117
+ await attachToEpisodeWithLogging(db, input.sessionId, merged.id)
92
118
  }
93
119
  await incrementWriteStats(db)
94
120
  return merged
@@ -111,7 +137,12 @@ export async function observe(
111
137
  consolidated_into: NONE,
112
138
  fsrs_stability: NONE,
113
139
  fsrs_difficulty: NONE,
114
- fsrs_next_review: NONE
140
+ fsrs_next_review: NONE,
141
+ fsrs_state: NONE,
142
+ fsrs_last_review: NONE,
143
+ fsrs_reps: NONE,
144
+ fsrs_lapses: NONE,
145
+ fsrs_last_grade: NONE
115
146
  }
116
147
  `,
117
148
  {
@@ -127,11 +158,7 @@ export async function observe(
127
158
 
128
159
  const node = created[0]![0]!
129
160
  if (input.sessionId) {
130
- try {
131
- await attachToEpisode(db, input.sessionId, node.id)
132
- } catch {
133
- log.debug('attachToEpisode skipped (no open episode)', { sessionId: input.sessionId })
134
- }
161
+ await attachToEpisodeWithLogging(db, input.sessionId, node.id)
135
162
  }
136
163
  await incrementWriteStats(db)
137
164
  log.info('observe() created', { id: node.id })
@@ -267,11 +294,7 @@ export async function upsertByKey(
267
294
 
268
295
  const node = updated[0]![0]!
269
296
  if (parsed.sessionId) {
270
- try {
271
- await attachToEpisode(db, parsed.sessionId, node.id)
272
- } catch {
273
- log.debug('attachToEpisode skipped (no open episode)', { sessionId: parsed.sessionId })
274
- }
297
+ await attachToEpisodeWithLogging(db, parsed.sessionId, node.id)
275
298
  }
276
299
  await incrementWriteStats(db)
277
300
  return { node, wasUpdate: true }
@@ -296,7 +319,12 @@ export async function upsertByKey(
296
319
  consolidated_into: NONE,
297
320
  fsrs_stability: NONE,
298
321
  fsrs_difficulty: NONE,
299
- fsrs_next_review: NONE
322
+ fsrs_next_review: NONE,
323
+ fsrs_state: NONE,
324
+ fsrs_last_review: NONE,
325
+ fsrs_reps: NONE,
326
+ fsrs_lapses: NONE,
327
+ fsrs_last_grade: NONE
300
328
  }
301
329
  `,
302
330
  {
@@ -313,11 +341,7 @@ export async function upsertByKey(
313
341
 
314
342
  const node = created[0]![0]!
315
343
  if (parsed.sessionId) {
316
- try {
317
- await attachToEpisode(db, parsed.sessionId, node.id)
318
- } catch {
319
- log.debug('attachToEpisode skipped (no open episode)', { sessionId: parsed.sessionId })
320
- }
344
+ await attachToEpisodeWithLogging(db, parsed.sessionId, node.id)
321
345
  }
322
346
  await incrementWriteStats(db)
323
347
  return { node, wasUpdate: false }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * suemo OpenCode plugin (v0.1.7)
2
+ * suemo OpenCode plugin (v0.1.8)
3
3
  *
4
4
  * Purpose:
5
5
  * - Inject strict, always-on suemo memory workflow instructions.
@@ -9,6 +9,12 @@
9
9
  * ~/.config/opencode/plugins/suemo.ts
10
10
  */
11
11
 
12
+ import type { Plugin } from '@opencode-ai/plugin'
13
+
14
+ const SERVICE = 'suemo-plugin'
15
+ const PLUGIN_VERSION = '0.1.8'
16
+ const PROTOCOL_MARKER = 'Suemo Persistent Memory — Protocol'
17
+
12
18
  interface SystemTransformOutput {
13
19
  system: string[]
14
20
  }
@@ -21,7 +27,18 @@ interface SessionCompactingOutput {
21
27
  context: string[]
22
28
  }
23
29
 
24
- const MEMORY_INSTRUCTIONS = `## Suemo Persistent Memory — Protocol (v0.1.7)
30
+ interface MessageTransformOutput {
31
+ messages: Array<{
32
+ info: Record<string, unknown>
33
+ parts: Array<Record<string, unknown>>
34
+ }>
35
+ }
36
+
37
+ interface PluginLogExtra {
38
+ [key: string]: unknown
39
+ }
40
+
41
+ const MEMORY_INSTRUCTIONS = `## Suemo Persistent Memory — Protocol (v0.1.8)
25
42
 
26
43
  You have access to suemo persistent memory tools (commonly prefixed as \`suemo_*\`).
27
44
 
@@ -76,30 +93,134 @@ If context is compacted/reset, FIRST:
76
93
  3. Then continue work
77
94
  `
78
95
 
79
- export const Suemo = async () => {
80
- return {
81
- 'experimental.chat.system.transform': async (_input: unknown, output: SystemTransformOutput) => {
82
- if (output.system.length > 0) {
83
- output.system[output.system.length - 1] += `\n\n${MEMORY_INSTRUCTIONS}`
84
- return
96
+ function isDebugEnabled(): boolean {
97
+ const raw = process.env.SUEMO_PLUGIN_DEBUG?.trim().toLowerCase()
98
+ return raw === '1' || raw === 'true' || raw === 'yes'
99
+ }
100
+
101
+ function stringifyError(error: unknown): string {
102
+ if (error instanceof Error) return `${error.name}: ${error.message}`
103
+ return String(error)
104
+ }
105
+
106
+ function hasProtocolMarkerInSystem(system: string[]): boolean {
107
+ return system.some((item) => item.includes(PROTOCOL_MARKER))
108
+ }
109
+
110
+ function hasProtocolMarkerInMessages(output: MessageTransformOutput): boolean {
111
+ return output.messages.some((message) =>
112
+ message.parts.some((part) => {
113
+ const text = part.text
114
+ return typeof text === 'string' && text.includes(PROTOCOL_MARKER)
115
+ })
116
+ )
117
+ }
118
+
119
+ export const Suemo: Plugin = async (ctx) => {
120
+ async function pluginLog(
121
+ level: 'debug' | 'info' | 'warn' | 'error',
122
+ message: string,
123
+ extra?: PluginLogExtra,
124
+ ): Promise<void> {
125
+ try {
126
+ await ctx.client.app.log({
127
+ body: {
128
+ service: SERVICE,
129
+ level,
130
+ message,
131
+ ...(extra ? { extra } : {}),
132
+ },
133
+ })
134
+ } catch {
135
+ if (level === 'error' || isDebugEnabled()) {
136
+ const suffix = extra ? ` ${JSON.stringify(extra)}` : ''
137
+ process.stderr.write(`[${SERVICE}] ${level}: ${message}${suffix}\n`)
138
+ }
139
+ }
140
+ }
141
+
142
+ await pluginLog('info', 'plugin.loaded', { version: PLUGIN_VERSION })
143
+
144
+ const hooks: Awaited<ReturnType<Plugin>> = {
145
+ 'experimental.chat.system.transform': async (input: unknown, output: SystemTransformOutput) => {
146
+ try {
147
+ if (hasProtocolMarkerInSystem(output.system)) {
148
+ await pluginLog('debug', 'system.transform.skip.already-present', {
149
+ hasSystem: output.system.length > 0,
150
+ sessionID: (input as { sessionID?: string })?.sessionID ?? null,
151
+ })
152
+ return
153
+ }
154
+
155
+ if (output.system.length > 0) {
156
+ output.system[output.system.length - 1] += `\n\n${MEMORY_INSTRUCTIONS}`
157
+ } else {
158
+ output.system.push(MEMORY_INSTRUCTIONS)
159
+ }
160
+
161
+ await pluginLog('info', 'system.transform.injected', {
162
+ hasSystem: output.system.length > 0,
163
+ sessionID: (input as { sessionID?: string })?.sessionID ?? null,
164
+ })
165
+ } catch (error) {
166
+ await pluginLog('error', 'system.transform.failed', { error: stringifyError(error) })
167
+ }
168
+ },
169
+
170
+ // Fallback injection path for clients where system transform is not surfaced as expected.
171
+ 'experimental.chat.messages.transform': async (_input: unknown, output: MessageTransformOutput) => {
172
+ try {
173
+ if (hasProtocolMarkerInMessages(output)) {
174
+ await pluginLog('debug', 'messages.transform.skip.already-present', {
175
+ messageCount: output.messages.length,
176
+ })
177
+ return
178
+ }
179
+
180
+ output.messages.unshift({
181
+ info: {
182
+ role: 'system',
183
+ source: SERVICE,
184
+ },
185
+ parts: [
186
+ {
187
+ type: 'text',
188
+ text: MEMORY_INSTRUCTIONS,
189
+ synthetic: true,
190
+ },
191
+ ],
192
+ })
193
+
194
+ await pluginLog('info', 'messages.transform.injected-fallback', {
195
+ messageCount: output.messages.length,
196
+ })
197
+ } catch (error) {
198
+ await pluginLog('error', 'messages.transform.failed', { error: stringifyError(error) })
85
199
  }
86
- output.system.push(MEMORY_INSTRUCTIONS)
87
200
  },
88
201
 
89
202
  'experimental.session.compacting': async (
90
203
  input: SessionCompactingInput,
91
204
  output: SessionCompactingOutput,
92
205
  ) => {
93
- const sessionId = input.sessionID ?? '<current-session-id>'
94
- output.context.push(
95
- [
96
- 'FIRST ACTION REQUIRED:',
97
- `Call suemo_session_context_set({ sessionId: "${sessionId}", summary: "<compacted summary>" }) before any other work.`,
98
- 'Then call suemo_context({ scope: "<project-scope>", limit: 20 }) to recover continuity.',
99
- ].join(' '),
100
- )
206
+ try {
207
+ const sessionId = input.sessionID ?? '<current-session-id>'
208
+ output.context.push(
209
+ [
210
+ 'FIRST ACTION REQUIRED:',
211
+ `Call suemo_session_context_set({ sessionId: "${sessionId}", summary: "<compacted summary>" }) before any other work.`,
212
+ 'Then call suemo_context({ scope: "<project-scope>", limit: 20 }) to recover continuity.',
213
+ ].join(' '),
214
+ )
215
+
216
+ await pluginLog('info', 'session.compacting.reminder-injected', { sessionID: sessionId })
217
+ } catch (error) {
218
+ await pluginLog('error', 'session.compacting.failed', { error: stringifyError(error) })
219
+ }
101
220
  },
102
221
  }
222
+
223
+ return hooks
103
224
  }
104
225
 
105
226
  export default Suemo
package/src/sync.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import type { SurrealTarget } from '@/src/config.ts'
2
+ import { createSurrealEngines, toSurrealConnectionError } from '@/src/db/engines.ts'
2
3
  import { getLogger } from '@/src/logger.ts'
3
4
  import type { SyncResult } from '@/src/types.ts'
4
- import { createNodeEngines } from '@surrealdb/node'
5
5
  import { createHash } from 'node:crypto'
6
- import { createRemoteEngines, Surreal } from 'surrealdb'
6
+ import { Surreal } from 'surrealdb'
7
7
 
8
8
  const log = getLogger(['suemo', 'sync'])
9
9
 
@@ -32,21 +32,24 @@ function remoteKey(target: SurrealTarget): string {
32
32
  }
33
33
 
34
34
  async function makeRemoteDb(target: SurrealTarget): Promise<Surreal> {
35
- const db = new Surreal({
36
- engines: {
37
- ...createRemoteEngines(),
38
- ...createNodeEngines(),
39
- },
40
- })
41
- await db.connect(target.url, {
42
- namespace: target.namespace,
43
- database: target.database,
44
- authentication: () => ({
45
- username: target.auth.user,
46
- password: target.auth.pass,
47
- }),
48
- })
49
- return db
35
+ try {
36
+ const engines = await createSurrealEngines({
37
+ url: target.url,
38
+ context: 'sync:remote-connect',
39
+ })
40
+ const db = new Surreal({ engines })
41
+ await db.connect(target.url, {
42
+ namespace: target.namespace,
43
+ database: target.database,
44
+ authentication: () => ({
45
+ username: target.auth.user,
46
+ password: target.auth.pass,
47
+ }),
48
+ })
49
+ return db
50
+ } catch (error) {
51
+ throw toSurrealConnectionError(error, 'sync:remote-connect', target.url)
52
+ }
50
53
  }
51
54
 
52
55
  async function pushMemoryBatch(
package/src/types.ts CHANGED
@@ -3,13 +3,15 @@
3
3
  import { z } from 'zod'
4
4
 
5
5
  // ── Memory kinds ─────────────────────────────────────────────────────────────
6
- export const MemoryKindSchema = z.enum([
6
+ export const MEMORY_KIND_VALUES = [
7
7
  'observation',
8
8
  'belief',
9
9
  'question',
10
10
  'hypothesis',
11
11
  'goal',
12
- ])
12
+ ] as const
13
+
14
+ export const MemoryKindSchema = z.enum(MEMORY_KIND_VALUES)
13
15
  export type MemoryKind = z.infer<typeof MemoryKindSchema>
14
16
 
15
17
  // ── Relation kinds ───────────────────────────────────────────────────────────
@@ -47,6 +49,11 @@ export const MemoryNodeSchema = z.object({
47
49
  fsrs_stability: z.number().nullable(),
48
50
  fsrs_difficulty: z.number().nullable(),
49
51
  fsrs_next_review: z.iso.datetime().nullable(),
52
+ fsrs_state: z.enum(['new', 'learning', 'review', 'relearning']).nullable(),
53
+ fsrs_last_review: z.iso.datetime().nullable(),
54
+ fsrs_reps: z.number().int().nullable(),
55
+ fsrs_lapses: z.number().int().nullable(),
56
+ fsrs_last_grade: z.number().int().min(1).max(4).nullable(),
50
57
  })
51
58
  export type MemoryNode = z.infer<typeof MemoryNodeSchema>
52
59