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/memory/read.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
506
|
-
|
|
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
|
|
527
|
-
if (!
|
|
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
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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 {
|
|
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
|
-
|
|
633
|
-
|
|
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] ?? [],
|
package/src/memory/write.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 }
|
package/src/opencode/plugin.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* suemo OpenCode plugin (v0.1.
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 {
|
|
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
|
-
|
|
36
|
-
engines
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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
|
|