suemo 0.0.2 → 0.0.4

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/src/config.ts CHANGED
@@ -19,7 +19,7 @@ export interface SurrealTarget {
19
19
 
20
20
  export type EmbeddingProvider =
21
21
  | { provider: 'openai-compatible'; url: string; model: string; dimension: number; apiKey?: string }
22
- | { provider: 'surreal'; dimension: number }
22
+ | { provider: 'surrealml'; dimension: number }
23
23
  | { provider: 'stub'; dimension: number }
24
24
 
25
25
  export interface LLMConfig {
@@ -51,9 +51,41 @@ export interface McpConfig {
51
51
  host: string
52
52
  }
53
53
 
54
+ export type SyncDirectionConfig = 'push' | 'pull' | 'both'
55
+
56
+ export interface SyncAutoConfig {
57
+ enabled?: boolean
58
+ intervalSeconds?: number
59
+ direction?: SyncDirectionConfig
60
+ remote?: string
61
+ onWrite?: boolean
62
+ minWriteIntervalSeconds?: number
63
+ }
64
+
54
65
  export interface SyncConfig {
55
- remote: SurrealTarget
56
- cursor?: string // ISO datetime; absence means full sync on first run
66
+ /** Legacy single-remote config (still supported for migration safety). */
67
+ remote?: SurrealTarget
68
+ /** Named remotes for selectable sync targets. */
69
+ remotes?: Record<string, SurrealTarget>
70
+ /** Default key from `remotes` when no explicit remote is selected. */
71
+ defaultRemote?: string
72
+ /** Optional auto-sync behavior for long-running MCP servers. */
73
+ auto?: SyncAutoConfig
74
+ }
75
+
76
+ export interface ResolvedSyncAutoConfig {
77
+ enabled: boolean
78
+ intervalSeconds: number
79
+ direction: SyncDirectionConfig
80
+ remote: string
81
+ onWrite: boolean
82
+ minWriteIntervalSeconds: number
83
+ }
84
+
85
+ export interface ResolvedSyncConfig {
86
+ remotes: Record<string, SurrealTarget>
87
+ defaultRemote: string
88
+ auto: ResolvedSyncAutoConfig
57
89
  }
58
90
 
59
91
  export interface SuemoConfig {
@@ -71,6 +103,55 @@ export function defineConfig(config: SuemoConfig): SuemoConfig {
71
103
  return config
72
104
  }
73
105
 
106
+ export function resolveSyncConfig(config: SuemoConfig): ResolvedSyncConfig | null {
107
+ if (!config.sync) return null
108
+
109
+ const remotes: Record<string, SurrealTarget> = config.sync.remotes
110
+ ? { ...config.sync.remotes }
111
+ : (config.sync.remote ? { default: config.sync.remote } : {})
112
+
113
+ const remoteNames = Object.keys(remotes)
114
+ if (remoteNames.length === 0) return null
115
+
116
+ const defaultRemote = config.sync.defaultRemote ?? remoteNames[0]!
117
+ if (!remotes[defaultRemote]) {
118
+ throw new Error(
119
+ `sync.defaultRemote \"${defaultRemote}\" does not exist in sync.remotes. Available: ${remoteNames.join(', ')}`,
120
+ )
121
+ }
122
+
123
+ const auto = config.sync.auto ?? {}
124
+ const intervalSeconds = auto.intervalSeconds ?? 300
125
+ const minWriteIntervalSeconds = auto.minWriteIntervalSeconds ?? 30
126
+
127
+ if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
128
+ throw new Error('sync.auto.intervalSeconds must be a positive number')
129
+ }
130
+ if (!Number.isFinite(minWriteIntervalSeconds) || minWriteIntervalSeconds < 0) {
131
+ throw new Error('sync.auto.minWriteIntervalSeconds must be >= 0')
132
+ }
133
+
134
+ const autoRemote = auto.remote ?? defaultRemote
135
+ if (!remotes[autoRemote]) {
136
+ throw new Error(
137
+ `sync.auto.remote \"${autoRemote}\" does not exist in sync.remotes. Available: ${remoteNames.join(', ')}`,
138
+ )
139
+ }
140
+
141
+ return {
142
+ remotes,
143
+ defaultRemote,
144
+ auto: {
145
+ enabled: auto.enabled ?? false,
146
+ intervalSeconds,
147
+ direction: auto.direction ?? 'push',
148
+ remote: autoRemote,
149
+ onWrite: auto.onWrite ?? false,
150
+ minWriteIntervalSeconds,
151
+ },
152
+ }
153
+ }
154
+
74
155
  // ── loadConfig — resolution chain ────────────────────────────────────────────
75
156
 
76
157
  const CONFIG_CANDIDATES = [
@@ -135,9 +216,9 @@ export async function loadConfig(
135
216
  async function importConfig(path: string): Promise<SuemoConfig> {
136
217
  log.debug('Importing config module', { path })
137
218
  const mod = await import(pathToFileURL(path).href)
138
- const cfg: unknown = mod.default ?? mod
139
- if (!cfg || typeof cfg !== 'object') {
219
+ const cfgRaw: unknown = mod.default ?? mod
220
+ if (!cfgRaw || typeof cfgRaw !== 'object') {
140
221
  throw new Error(`Config at ${path} does not export a default object`)
141
222
  }
142
- return cfg as SuemoConfig // trust defineConfig() for now; add Zod parse if needed
223
+ return cfgRaw as SuemoConfig // trust defineConfig() for now; add Zod parse if needed
143
224
  }
@@ -65,7 +65,7 @@ export async function checkCompatibility(
65
65
  // We CREATE a sentinel record, query it with VERSION, then DELETE it.
66
66
  // If VERSION errors, the storage engine is not SurrealKV.
67
67
  try {
68
- await db.query('CREATE suemo_preflight:probe SET checked_at = time::now()')
68
+ await db.query('UPSERT suemo_preflight:probe SET checked_at = time::now()')
69
69
  // VERSION at a past datetime should return an empty result set (or the record
70
70
  // if it was around then), not throw. An error means VERSION is unsupported.
71
71
  await db.query("SELECT * FROM suemo_preflight:probe VERSION d'2020-01-01T00:00:00Z'")
@@ -97,7 +97,7 @@ export async function checkCompatibility(
97
97
  if (surrealkv) {
98
98
  try {
99
99
  const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString()
100
- await db.query('CREATE suemo_retention_probe:v SET t = time::now()')
100
+ await db.query('UPSERT suemo_retention_probe:v SET t = time::now()')
101
101
  await db.query(`SELECT * FROM suemo_retention_probe:v VERSION d'${ninetyDaysAgo}'`)
102
102
  retention_ok = true
103
103
  log.debug('Retention probe passed', { ninetyDaysAgo })
@@ -152,9 +152,13 @@ export async function checkCompatibility(
152
152
  }
153
153
 
154
154
  const ok = errors.length === 0
155
+ const embedSkipped = !requireEmbedding && !embedding
155
156
 
156
157
  if (ok) {
157
158
  log.info('All preflight checks passed', { surrealVersion, surrealkv, retention_ok, embedding })
159
+ if (embedSkipped) {
160
+ log.info('fn::embed preflight check skipped due to non-surrealml embedding profile', { context })
161
+ }
158
162
  } else {
159
163
  log.error('Preflight checks failed', { errors })
160
164
  }
@@ -13,6 +13,7 @@ DEFINE FIELD OVERWRITE content ON memory TYPE string;
13
13
  DEFINE FIELD OVERWRITE summary ON memory TYPE option<string>;
14
14
  DEFINE FIELD OVERWRITE tags ON memory TYPE set<string> DEFAULT {};
15
15
  DEFINE FIELD OVERWRITE scope ON memory TYPE option<string>;
16
+ DEFINE FIELD OVERWRITE topic_key ON memory TYPE option<string> DEFAULT NONE;
16
17
  DEFINE FIELD OVERWRITE embedding ON memory TYPE array<float> DEFAULT [];
17
18
  DEFINE FIELD OVERWRITE confidence ON memory TYPE float DEFAULT 1.0;
18
19
  DEFINE FIELD OVERWRITE salience ON memory TYPE float DEFAULT 0.5;
@@ -24,6 +25,7 @@ DEFINE FIELD OVERWRITE valid_from ON memory TYPE datetime DEFAULT time::
24
25
  DEFINE FIELD OVERWRITE valid_until ON memory TYPE option<datetime> DEFAULT NONE;
25
26
 
26
27
  DEFINE FIELD OVERWRITE source ON memory TYPE option<string>;
28
+ DEFINE FIELD OVERWRITE prompt_source ON memory TYPE option<record<memory>> DEFAULT NONE;
27
29
  DEFINE FIELD OVERWRITE created_at ON memory TYPE datetime DEFAULT time::now();
28
30
  DEFINE FIELD OVERWRITE updated_at ON memory TYPE datetime DEFAULT time::now();
29
31
  DEFINE FIELD OVERWRITE consolidated ON memory TYPE bool DEFAULT false;
@@ -65,6 +67,9 @@ DEFINE INDEX OVERWRITE idx_memory_kind_valid
65
67
  DEFINE INDEX OVERWRITE idx_memory_salience
66
68
  ON memory FIELDS salience;
67
69
 
70
+ DEFINE INDEX OVERWRITE idx_memory_topic_key
71
+ ON memory FIELDS topic_key;
72
+
68
73
  -- ── relates_to ───────────────────────────────────────────────────────────────
69
74
  DEFINE TABLE OVERWRITE relates_to SCHEMAFULL
70
75
  TYPE RELATION IN memory OUT memory;
@@ -76,6 +81,7 @@ DEFINE FIELD OVERWRITE strength ON relates_to TYPE float DEFAULT 0.5;
76
81
  DEFINE FIELD OVERWRITE valid_from ON relates_to TYPE datetime DEFAULT time::now();
77
82
  DEFINE FIELD OVERWRITE valid_until ON relates_to TYPE option<datetime> DEFAULT NONE;
78
83
  DEFINE FIELD OVERWRITE created_at ON relates_to TYPE datetime DEFAULT time::now();
84
+ DEFINE FIELD OVERWRITE updated_at ON relates_to TYPE datetime DEFAULT time::now();
79
85
 
80
86
  -- ── episode ───────────────────────────────────────────────────────────────────
81
87
  DEFINE TABLE OVERWRITE episode SCHEMAFULL;
@@ -84,6 +90,7 @@ DEFINE FIELD OVERWRITE session_id ON episode TYPE string;
84
90
  DEFINE FIELD OVERWRITE started_at ON episode TYPE datetime DEFAULT time::now();
85
91
  DEFINE FIELD OVERWRITE ended_at ON episode TYPE option<datetime> DEFAULT NONE;
86
92
  DEFINE FIELD OVERWRITE summary ON episode TYPE option<string>;
93
+ DEFINE FIELD OVERWRITE context ON episode TYPE option<object> FLEXIBLE DEFAULT NONE;
87
94
 
88
95
  -- REFERENCES: bidirectional array of memory links
89
96
  DEFINE FIELD OVERWRITE memory_ids ON episode
@@ -105,8 +112,19 @@ DEFINE FIELD OVERWRITE error ON consolidation_run TYPE option<string> DEF
105
112
 
106
113
  -- ── sync_cursor ───────────────────────────────────────────────────────────────
107
114
  -- One record per (remote.url, remote.ns, remote.db) triple.
108
- -- Stores the last successfully synced created_at timestamp.
115
+ -- Stores per-remote cursors for push/pull sync based on updated_at.
109
116
  DEFINE TABLE OVERWRITE sync_cursor SCHEMAFULL;
110
117
  DEFINE FIELD OVERWRITE remote_key ON sync_cursor TYPE string; -- sha1(url+ns+db)
111
118
  DEFINE FIELD OVERWRITE cursor ON sync_cursor TYPE datetime;
119
+ DEFINE FIELD OVERWRITE push_cursor ON sync_cursor TYPE datetime DEFAULT d'1970-01-01T00:00:00Z';
120
+ DEFINE FIELD OVERWRITE pull_cursor ON sync_cursor TYPE datetime DEFAULT d'1970-01-01T00:00:00Z';
112
121
  DEFINE FIELD OVERWRITE last_synced ON sync_cursor TYPE datetime DEFAULT time::now();
122
+
123
+ -- ── suemo_stats ──────────────────────────────────────────────────────────────
124
+ DEFINE TABLE OVERWRITE suemo_stats SCHEMAFULL;
125
+ DEFINE FIELD OVERWRITE ns_db ON suemo_stats TYPE string;
126
+ DEFINE FIELD OVERWRITE total_writes ON suemo_stats TYPE int DEFAULT 0;
127
+ DEFINE FIELD OVERWRITE total_queries ON suemo_stats TYPE int DEFAULT 0;
128
+ DEFINE FIELD OVERWRITE last_write ON suemo_stats TYPE option<datetime> DEFAULT NONE;
129
+ DEFINE FIELD OVERWRITE last_query ON suemo_stats TYPE option<datetime> DEFAULT NONE;
130
+ DEFINE INDEX OVERWRITE idx_stats_ns ON suemo_stats FIELDS ns_db;
package/src/db/schema.ts CHANGED
@@ -5,6 +5,29 @@ import SCHEMA from './schema.surql' with { type: 'text' }
5
5
 
6
6
  const log = getLogger(['suemo', 'db', 'schema'])
7
7
 
8
+ const MAX_RETRY_ATTEMPTS = 6
9
+ const BASE_RETRY_DELAY_MS = 100
10
+ const MAX_RETRY_DELAY_MS = 1500
11
+
12
+ function isRetryableSchemaError(error: unknown): boolean {
13
+ const message = String(error).toLowerCase()
14
+ return message.includes('transaction conflict')
15
+ || message.includes('write conflict')
16
+ || message.includes('serialization')
17
+ || message.includes('temporarily unavailable')
18
+ || message.includes('can be retried')
19
+ }
20
+
21
+ function computeRetryDelayMs(attempt: number): number {
22
+ const raw = Math.min(MAX_RETRY_DELAY_MS, BASE_RETRY_DELAY_MS * (2 ** attempt))
23
+ const jitterFactor = 0.8 + Math.random() * 0.4
24
+ return Math.round(raw * jitterFactor)
25
+ }
26
+
27
+ async function sleep(ms: number): Promise<void> {
28
+ await new Promise((resolve) => setTimeout(resolve, ms))
29
+ }
30
+
8
31
  // schema.surql is inlined as a template string to avoid filesystem read concerns
9
32
  // at runtime. Each statement is separated by ";\n" and executed individually.
10
33
 
@@ -13,15 +36,40 @@ export async function runSchema(db: Surreal): Promise<void> {
13
36
  const statements = SCHEMA.split(/;\s*\n/).map((s) => s.trim()).filter(Boolean)
14
37
  log.debug('Prepared schema statements', { count: statements.length, schemaBytes: SCHEMA.length })
15
38
  for (const [index, stmt] of statements.entries()) {
16
- try {
17
- const snippet = stmt.length > 160 ? `${stmt.slice(0, 160)}…` : stmt
18
- log.debug('Executing schema statement', { index, snippet })
19
- await db.query(stmt)
20
- log.debug('Schema statement OK', { index, stmt: stmt.slice(0, 60) })
21
- } catch (e) {
22
- log.error('Schema statement failed', { index, stmt, error: String(e) })
23
- throw e
39
+ const snippet = stmt.length > 160 ? `${stmt.slice(0, 160)}…` : stmt
40
+ let lastError: unknown
41
+ for (let attempt = 0; attempt < MAX_RETRY_ATTEMPTS; attempt++) {
42
+ try {
43
+ log.debug('Executing schema statement', { index, attempt, snippet })
44
+ await db.query(stmt)
45
+ log.debug('Schema statement OK', { index, attempt, stmt: stmt.slice(0, 60) })
46
+ lastError = undefined
47
+ break
48
+ } catch (e) {
49
+ lastError = e
50
+ const retryable = isRetryableSchemaError(e)
51
+ const hasNextAttempt = attempt < MAX_RETRY_ATTEMPTS - 1
52
+ if (!retryable || !hasNextAttempt) {
53
+ log.error('Schema statement failed', {
54
+ index,
55
+ attempt,
56
+ retryable,
57
+ stmt,
58
+ error: String(e),
59
+ })
60
+ throw e
61
+ }
62
+ const delayMs = computeRetryDelayMs(attempt)
63
+ log.warning('Schema statement conflict; retrying', {
64
+ index,
65
+ attempt,
66
+ delayMs,
67
+ error: String(e),
68
+ })
69
+ await sleep(delayMs)
70
+ }
24
71
  }
72
+ if (lastError) throw lastError
25
73
  }
26
74
  log.info('Schema ready')
27
75
  }
@@ -16,7 +16,7 @@ export async function getEmbedding(
16
16
  log.debug('getEmbedding()', { provider: config.provider, dimension: config.dimension })
17
17
 
18
18
  switch (config.provider) {
19
- case 'surreal':
19
+ case 'surrealml':
20
20
  return { clause: 'fn::embed($content)' }
21
21
 
22
22
  case 'openai-compatible': {
@@ -39,7 +39,7 @@ export async function getEmbedding(
39
39
 
40
40
  export function buildEmbeddingClause(config: EmbeddingProvider): string {
41
41
  switch (config.provider) {
42
- case 'surreal':
42
+ case 'surrealml':
43
43
  return 'fn::embed($content)'
44
44
  case 'openai-compatible':
45
45
  case 'stub':
package/src/index.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  // src/index.ts — public API surface
2
- export { defineConfig, loadConfig } from './config.ts'
2
+ export { defineConfig, loadConfig, resolveSyncConfig } from './config.ts'
3
3
  export type {
4
4
  AuthConfig,
5
5
  ConsolidationConfig,
6
6
  EmbeddingProvider,
7
7
  LLMConfig,
8
8
  McpConfig,
9
+ ResolvedSyncAutoConfig,
10
+ ResolvedSyncConfig,
9
11
  RetrievalConfig,
10
12
  SuemoConfig,
11
13
  SurrealTarget,
14
+ SyncAutoConfig,
12
15
  SyncConfig,
16
+ SyncDirectionConfig,
13
17
  } from './config.ts'
@@ -1,28 +1,54 @@
1
1
  import type { Surreal } from 'surrealdb'
2
2
  import { z } from 'zod'
3
3
  import { consolidate } from '../cognitive/consolidate.ts'
4
- import { healthReport, vitals } from '../cognitive/health.ts'
4
+ import { healthReport, suemoStats, vitals } from '../cognitive/health.ts'
5
5
  import type { SuemoConfig } from '../config.ts'
6
6
  import { goalList, goalResolve, goalSet } from '../goal.ts'
7
7
  import { getLogger } from '../logger.ts'
8
- import { episodeEnd, episodeStart } from '../memory/episode.ts'
8
+ import { episodeEnd, episodeStart, getSessionContext, setSessionContext } from '../memory/episode.ts'
9
9
  import { query, recall, timeline, wander } from '../memory/read.ts'
10
- import { believe, invalidate, observe } from '../memory/write.ts'
10
+ import { believe, capturePrompt, invalidate, observe, upsertByKey } from '../memory/write.ts'
11
11
  import { ObserveInputSchema, QueryInputSchema } from '../types.ts'
12
12
 
13
13
  const log = getLogger(['suemo', 'mcp', 'dispatch'])
14
14
 
15
+ const MUTATING_TOOLS = new Set([
16
+ 'observe',
17
+ 'believe',
18
+ 'invalidate',
19
+ 'goal_set',
20
+ 'goal_resolve',
21
+ 'upsert_by_key',
22
+ 'capture_prompt',
23
+ 'session_context_set',
24
+ ])
25
+
26
+ interface DispatchOptions {
27
+ onMutation?: (tool: string) => Promise<void>
28
+ }
29
+
15
30
  export async function handleToolCall(
16
31
  db: Surreal,
17
32
  config: SuemoConfig,
18
33
  method: string,
19
34
  params: Record<string, unknown>,
35
+ opts: DispatchOptions = {},
20
36
  ): Promise<unknown> {
21
37
  log.debug('Dispatching MCP tool call', { method, paramKeys: Object.keys(params) })
22
38
 
39
+ const maybeTriggerMutationSync = (): void => {
40
+ if (!MUTATING_TOOLS.has(method) || !opts.onMutation) return
41
+ void opts.onMutation(method).catch((error) => {
42
+ log.warning('Post-mutation hook failed', { method, error: String(error) })
43
+ })
44
+ }
45
+
23
46
  switch (method) {
24
- case 'observe':
25
- return observe(db, ObserveInputSchema.parse(params), config)
47
+ case 'observe': {
48
+ const result = await observe(db, ObserveInputSchema.parse(params), config)
49
+ maybeTriggerMutationSync()
50
+ return result
51
+ }
26
52
 
27
53
  case 'believe': {
28
54
  const parsed = z
@@ -32,12 +58,15 @@ export async function handleToolCall(
32
58
  confidence: z.number().optional(),
33
59
  })
34
60
  .parse(params)
35
- return believe(db, parsed, config)
61
+ const result = await believe(db, parsed, config)
62
+ maybeTriggerMutationSync()
63
+ return result
36
64
  }
37
65
 
38
66
  case 'invalidate': {
39
67
  const parsed = z.object({ nodeId: z.string(), reason: z.string().optional() }).parse(params)
40
68
  await invalidate(db, parsed.nodeId, parsed.reason)
69
+ maybeTriggerMutationSync()
41
70
  return { ok: true }
42
71
  }
43
72
 
@@ -91,15 +120,18 @@ export async function handleToolCall(
91
120
  const parsed = z
92
121
  .object({ content: z.string(), scope: z.string().optional(), tags: z.array(z.string()).optional() })
93
122
  .parse(params)
94
- return goalSet(db, parsed.content, config, {
123
+ const result = await goalSet(db, parsed.content, config, {
95
124
  ...(parsed.scope ? { scope: parsed.scope } : {}),
96
125
  tags: parsed.tags ?? [],
97
126
  })
127
+ maybeTriggerMutationSync()
128
+ return result
98
129
  }
99
130
 
100
131
  case 'goal_resolve': {
101
132
  const parsed = z.object({ goalId: z.string() }).parse(params)
102
133
  await goalResolve(db, parsed.goalId)
134
+ maybeTriggerMutationSync()
103
135
  return { ok: true }
104
136
  }
105
137
 
@@ -114,6 +146,9 @@ export async function handleToolCall(
114
146
  case 'health':
115
147
  return healthReport(db)
116
148
 
149
+ case 'stats':
150
+ return suemoStats(db)
151
+
117
152
  case 'vitals':
118
153
  return vitals(db)
119
154
 
@@ -128,6 +163,69 @@ export async function handleToolCall(
128
163
  })
129
164
  }
130
165
 
166
+ case 'upsert_by_key': {
167
+ const parsed = z
168
+ .object({
169
+ topicKey: z.string(),
170
+ content: z.string(),
171
+ scope: z.string().optional(),
172
+ tags: z.array(z.string()).optional(),
173
+ confidence: z.number().optional(),
174
+ source: z.string().optional(),
175
+ sessionId: z.string().optional(),
176
+ kind: z.enum(['observation', 'belief', 'question', 'hypothesis', 'goal']).optional(),
177
+ })
178
+ .parse(params)
179
+ const result = await upsertByKey(db, config, parsed.topicKey, parsed.content, {
180
+ ...(parsed.scope ? { scope: parsed.scope } : {}),
181
+ tags: parsed.tags ?? [],
182
+ ...(parsed.source ? { source: parsed.source } : {}),
183
+ ...(parsed.sessionId ? { sessionId: parsed.sessionId } : {}),
184
+ confidence: parsed.confidence ?? 1.0,
185
+ ...(parsed.kind ? { kind: parsed.kind } : {}),
186
+ })
187
+ maybeTriggerMutationSync()
188
+ return result
189
+ }
190
+
191
+ case 'capture_prompt': {
192
+ const parsed = z
193
+ .object({
194
+ prompt: z.string(),
195
+ derivedIds: z.array(z.string()).optional(),
196
+ scope: z.string().optional(),
197
+ sessionId: z.string().optional(),
198
+ })
199
+ .parse(params)
200
+ const result = await capturePrompt(db, config, parsed.prompt, parsed.derivedIds ?? [], {
201
+ ...(parsed.scope ? { scope: parsed.scope } : {}),
202
+ ...(parsed.sessionId ? { sessionId: parsed.sessionId } : {}),
203
+ })
204
+ maybeTriggerMutationSync()
205
+ return result
206
+ }
207
+
208
+ case 'session_context_get': {
209
+ const parsed = z.object({ sessionId: z.string() }).parse(params)
210
+ return getSessionContext(db, parsed.sessionId)
211
+ }
212
+
213
+ case 'session_context_set': {
214
+ const parsed = z
215
+ .object({
216
+ sessionId: z.string(),
217
+ summary: z.string().optional(),
218
+ context: z.record(z.string(), z.unknown()).optional(),
219
+ })
220
+ .parse(params)
221
+ await setSessionContext(db, parsed.sessionId, {
222
+ ...(parsed.summary !== undefined ? { summary: parsed.summary } : {}),
223
+ ...(parsed.context !== undefined ? { context: parsed.context } : {}),
224
+ })
225
+ maybeTriggerMutationSync()
226
+ return { ok: true }
227
+ }
228
+
131
229
  default:
132
230
  throw new Error(`Unknown MCP tool: ${method}`)
133
231
  }
package/src/mcp/server.ts CHANGED
@@ -1,22 +1,142 @@
1
1
  // src/mcp/server.ts
2
2
  import { Elysia } from 'elysia'
3
- import type { SuemoConfig } from '../config.ts'
3
+ import { resolveSyncConfig, type SuemoConfig } from '../config.ts'
4
4
  import { connect, disconnect } from '../db/client.ts'
5
5
  import { checkCompatibility, requireCompatibility } from '../db/preflight.ts'
6
6
  import { runSchema } from '../db/schema.ts'
7
7
  import { getLogger } from '../logger.ts'
8
+ import { syncTo } from '../sync.ts'
8
9
  import { runStdioServer } from './stdio.ts'
9
10
  import { buildMcpRouter } from './tools.ts'
10
11
 
11
12
  const log = getLogger(['suemo', 'mcp'])
12
13
 
14
+ interface AutoSyncOptions {
15
+ reason: 'timer' | 'write'
16
+ force?: boolean
17
+ }
18
+
19
+ function createAutoSyncRunner(
20
+ db: ReturnType<typeof connect> extends Promise<infer T> ? T : never,
21
+ config: SuemoConfig,
22
+ ): {
23
+ start: () => void
24
+ onWrite: (tool: string) => Promise<void>
25
+ stop: () => void
26
+ } {
27
+ const resolvedSync = resolveSyncConfig(config)
28
+ if (!resolvedSync) {
29
+ return {
30
+ start: () => {},
31
+ onWrite: async () => {},
32
+ stop: () => {},
33
+ }
34
+ }
35
+
36
+ let timer: ReturnType<typeof setInterval> | null = null
37
+ let running = false
38
+ let queued = false
39
+ let lastRunAt = 0
40
+
41
+ const runAutoSync = async ({ reason, force = false }: AutoSyncOptions): Promise<void> => {
42
+ if (!resolvedSync.auto.enabled) return
43
+ if (reason === 'write' && !resolvedSync.auto.onWrite) return
44
+
45
+ const now = Date.now()
46
+ const minIntervalMs = resolvedSync.auto.minWriteIntervalSeconds * 1000
47
+ if (!force && reason === 'write' && lastRunAt > 0 && now - lastRunAt < minIntervalMs) {
48
+ return
49
+ }
50
+
51
+ if (running) {
52
+ queued = true
53
+ return
54
+ }
55
+
56
+ const target = resolvedSync.remotes[resolvedSync.auto.remote]
57
+ if (!target) {
58
+ log.warning('Auto-sync skipped: remote missing', { remote: resolvedSync.auto.remote, reason })
59
+ return
60
+ }
61
+
62
+ running = true
63
+ try {
64
+ const result = await syncTo(db, target, {
65
+ direction: resolvedSync.auto.direction,
66
+ })
67
+ lastRunAt = Date.now()
68
+ log.info('Auto-sync completed', {
69
+ reason,
70
+ remote: resolvedSync.auto.remote,
71
+ direction: resolvedSync.auto.direction,
72
+ pushed: result.pushed,
73
+ errors: result.errors,
74
+ })
75
+ } catch (error) {
76
+ log.warning('Auto-sync failed', { reason, error: String(error) })
77
+ } finally {
78
+ running = false
79
+ if (queued) {
80
+ queued = false
81
+ queueMicrotask(() => {
82
+ void runAutoSync({ reason: 'write', force: true })
83
+ })
84
+ }
85
+ }
86
+ }
87
+
88
+ const start = (): void => {
89
+ if (!resolvedSync.auto.enabled) return
90
+ if (timer) return
91
+ const intervalMs = resolvedSync.auto.intervalSeconds * 1000
92
+ timer = setInterval(() => {
93
+ void runAutoSync({ reason: 'timer' })
94
+ }, intervalMs)
95
+ log.info('Auto-sync timer started', {
96
+ intervalSeconds: resolvedSync.auto.intervalSeconds,
97
+ remote: resolvedSync.auto.remote,
98
+ direction: resolvedSync.auto.direction,
99
+ })
100
+ }
101
+
102
+ const stop = (): void => {
103
+ if (!timer) return
104
+ clearInterval(timer)
105
+ timer = null
106
+ log.info('Auto-sync timer stopped')
107
+ }
108
+
109
+ const onWrite = async (tool: string): Promise<void> => {
110
+ if (!resolvedSync.auto.enabled || !resolvedSync.auto.onWrite) return
111
+ log.debug('Auto-sync write trigger', { tool })
112
+ await runAutoSync({ reason: 'write' })
113
+ }
114
+
115
+ return { start, onWrite, stop }
116
+ }
117
+
13
118
  export async function startMcpServer(config: SuemoConfig): Promise<void> {
14
119
  const db = await connect(config.surreal)
15
120
  await requireCompatibility(db)
16
121
  await runSchema(db)
122
+ const autoSync = createAutoSyncRunner(db, config)
123
+ autoSync.start()
124
+
125
+ const shutdown = async (): Promise<void> => {
126
+ autoSync.stop()
127
+ await disconnect()
128
+ process.exit(0)
129
+ }
130
+
131
+ process.once('SIGINT', () => {
132
+ void shutdown()
133
+ })
134
+ process.once('SIGTERM', () => {
135
+ void shutdown()
136
+ })
17
137
 
18
138
  new Elysia()
19
- .use(buildMcpRouter(db, config))
139
+ .use(buildMcpRouter(db, config, { onMutation: autoSync.onWrite }))
20
140
  .get('/health', () => ({ status: 'ok' }))
21
141
  .listen({ port: config.mcp.port, hostname: config.mcp.host })
22
142
 
@@ -25,9 +145,11 @@ export async function startMcpServer(config: SuemoConfig): Promise<void> {
25
145
 
26
146
  export async function startMcpStdioServer(config: SuemoConfig): Promise<void> {
27
147
  const db = await connect(config.surreal)
148
+ const autoSync = createAutoSyncRunner(db, config)
149
+ autoSync.start()
28
150
  try {
29
151
  const compat = await checkCompatibility(db, {
30
- requireEmbedding: config.embedding.provider === 'surreal',
152
+ requireEmbedding: config.embedding.provider === 'surrealml',
31
153
  context: 'mcp:stdio-startup',
32
154
  })
33
155
  if (!compat.ok) {
@@ -39,8 +161,9 @@ export async function startMcpStdioServer(config: SuemoConfig): Promise<void> {
39
161
  process.exit(1)
40
162
  }
41
163
  await runSchema(db)
42
- await runStdioServer(db, config)
164
+ await runStdioServer(db, config, { onMutation: autoSync.onWrite })
43
165
  } finally {
166
+ autoSync.stop()
44
167
  await disconnect()
45
168
  }
46
169
  }