suemo 0.0.2 → 0.0.3

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.
@@ -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,6 +145,8 @@ 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
152
  requireEmbedding: config.embedding.provider === 'surreal',
@@ -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
  }
package/src/mcp/stdio.ts CHANGED
@@ -22,6 +22,10 @@ interface McpToolDefinition {
22
22
  const SUPPORTED_PROTOCOL_VERSIONS = new Set(['2024-11-05', '2025-03-26'])
23
23
  const DEFAULT_PROTOCOL_VERSION = '2024-11-05'
24
24
 
25
+ interface StdioServerOptions {
26
+ onMutation?: (tool: string) => Promise<void>
27
+ }
28
+
25
29
  const MCP_TOOLS: McpToolDefinition[] = [
26
30
  {
27
31
  name: 'observe',
@@ -35,6 +39,7 @@ const MCP_TOOLS: McpToolDefinition[] = [
35
39
  scope: { type: 'string' },
36
40
  source: { type: 'string' },
37
41
  confidence: { type: 'number' },
42
+ sessionId: { type: 'string' },
38
43
  },
39
44
  required: ['content'],
40
45
  },
@@ -178,6 +183,11 @@ const MCP_TOOLS: McpToolDefinition[] = [
178
183
  description: 'Get health vitals summary',
179
184
  inputSchema: { type: 'object', properties: {} },
180
185
  },
186
+ {
187
+ name: 'stats',
188
+ description: 'Get lightweight usage stats summary',
189
+ inputSchema: { type: 'object', properties: {} },
190
+ },
181
191
  {
182
192
  name: 'consolidate',
183
193
  description: 'Trigger memory consolidation',
@@ -186,9 +196,69 @@ const MCP_TOOLS: McpToolDefinition[] = [
186
196
  properties: { nremOnly: { type: 'boolean' } },
187
197
  },
188
198
  },
199
+ {
200
+ name: 'upsert_by_key',
201
+ description: 'Upsert a memory node by topic key',
202
+ inputSchema: {
203
+ type: 'object',
204
+ properties: {
205
+ topicKey: { type: 'string' },
206
+ content: { type: 'string' },
207
+ scope: { type: 'string' },
208
+ tags: { type: 'array', items: { type: 'string' } },
209
+ source: { type: 'string' },
210
+ confidence: { type: 'number' },
211
+ sessionId: { type: 'string' },
212
+ kind: { type: 'string', enum: ['observation', 'belief', 'question', 'hypothesis', 'goal'] },
213
+ },
214
+ required: ['topicKey', 'content'],
215
+ },
216
+ },
217
+ {
218
+ name: 'capture_prompt',
219
+ description: 'Capture a raw prompt and link derived observations',
220
+ inputSchema: {
221
+ type: 'object',
222
+ properties: {
223
+ prompt: { type: 'string' },
224
+ derivedIds: { type: 'array', items: { type: 'string' } },
225
+ scope: { type: 'string' },
226
+ sessionId: { type: 'string' },
227
+ },
228
+ required: ['prompt'],
229
+ },
230
+ },
231
+ {
232
+ name: 'session_context_get',
233
+ description: 'Get open session context by session ID',
234
+ inputSchema: {
235
+ type: 'object',
236
+ properties: {
237
+ sessionId: { type: 'string' },
238
+ },
239
+ required: ['sessionId'],
240
+ },
241
+ },
242
+ {
243
+ name: 'session_context_set',
244
+ description: 'Update summary/context for open session by session ID',
245
+ inputSchema: {
246
+ type: 'object',
247
+ properties: {
248
+ sessionId: { type: 'string' },
249
+ summary: { type: 'string' },
250
+ context: { type: 'object' },
251
+ },
252
+ required: ['sessionId'],
253
+ },
254
+ },
189
255
  ]
190
256
 
191
- export async function runStdioServer(db: Surreal, config: SuemoConfig): Promise<void> {
257
+ export async function runStdioServer(
258
+ db: Surreal,
259
+ config: SuemoConfig,
260
+ opts: StdioServerOptions = {},
261
+ ): Promise<void> {
192
262
  log.info('MCP stdio transport started')
193
263
 
194
264
  const rl = createInterface({ input: process.stdin, terminal: false })
@@ -227,7 +297,7 @@ export async function runStdioServer(db: Surreal, config: SuemoConfig): Promise<
227
297
  log.debug('Received stdio MCP request', { method })
228
298
 
229
299
  try {
230
- const result = await handleRpcMethod(db, config, method, params)
300
+ const result = await handleRpcMethod(db, config, method, params, opts)
231
301
  if (id === null) continue
232
302
  writeJsonRpc({ jsonrpc: '2.0', id, result })
233
303
  } catch (error) {
@@ -251,6 +321,7 @@ async function handleRpcMethod(
251
321
  config: SuemoConfig,
252
322
  method: string,
253
323
  params: Record<string, unknown>,
324
+ opts: StdioServerOptions,
254
325
  ): Promise<unknown> {
255
326
  if (method === 'initialize' || method === 'initiaize') {
256
327
  const requestedVersion = typeof params.protocolVersion === 'string'
@@ -294,7 +365,7 @@ async function handleRpcMethod(
294
365
  ? params.arguments as Record<string, unknown>
295
366
  : {}
296
367
 
297
- const toolResult = await handleToolCall(db, config, name, toolArgs)
368
+ const toolResult = await handleToolCall(db, config, name, toolArgs, opts)
298
369
  return {
299
370
  content: [{ type: 'text', text: JSON.stringify(toolResult, null, 2) }],
300
371
  structuredContent: toolResult,
@@ -303,7 +374,7 @@ async function handleRpcMethod(
303
374
 
304
375
  // Backward-compatible direct tool method invocation.
305
376
  if (MCP_TOOLS.some((t) => t.name === method)) {
306
- return handleToolCall(db, config, method, params)
377
+ return handleToolCall(db, config, method, params, opts)
307
378
  }
308
379
 
309
380
  throw new Error(`Unknown MCP method: ${method}`)
package/src/mcp/tools.ts CHANGED
@@ -6,7 +6,11 @@ import type { Surreal } from 'surrealdb'
6
6
  import type { SuemoConfig } from '../config.ts'
7
7
  import { handleToolCall } from './dispatch.ts'
8
8
 
9
- export function buildMcpRouter(db: Surreal, config: SuemoConfig) {
9
+ interface McpRouterOptions {
10
+ onMutation?: (tool: string) => Promise<void>
11
+ }
12
+
13
+ export function buildMcpRouter(db: Surreal, config: SuemoConfig, opts: McpRouterOptions = {}) {
10
14
  // Elysia plugin: route-level transport, shared dispatch handles behavior.
11
15
  return (app: Elysia) =>
12
16
  app
@@ -14,6 +18,6 @@ export function buildMcpRouter(db: Surreal, config: SuemoConfig) {
14
18
  const payload = body && typeof body === 'object' && !Array.isArray(body)
15
19
  ? body as Record<string, unknown>
16
20
  : {}
17
- return handleToolCall(db, config, params.tool, payload)
21
+ return handleToolCall(db, config, params.tool, payload, opts)
18
22
  })
19
23
  }
@@ -15,6 +15,7 @@ export async function episodeStart(
15
15
  session_id: $sessionId,
16
16
  ended_at: NONE,
17
17
  summary: NONE,
18
+ context: NONE,
18
19
  memory_ids: []
19
20
  }
20
21
  `,
@@ -64,3 +65,94 @@ export async function attachToEpisode(
64
65
  { sessionId, memoryId },
65
66
  )
66
67
  }
68
+
69
+ export interface SessionContext {
70
+ summary?: string
71
+ context?: Record<string, unknown>
72
+ memoryCount: number
73
+ startedAt: string
74
+ }
75
+
76
+ export async function getSessionContext(
77
+ db: Surreal,
78
+ sessionId: string,
79
+ ): Promise<SessionContext | null> {
80
+ const result = await db.query<[
81
+ {
82
+ session_id: string
83
+ summary: string | null
84
+ context: Record<string, unknown> | null
85
+ memory_ids: string[]
86
+ started_at: string
87
+ }[],
88
+ ]>(
89
+ `
90
+ SELECT session_id, summary, context, memory_ids, started_at
91
+ FROM episode
92
+ WHERE session_id = $sessionId AND ended_at = NONE
93
+ LIMIT 1
94
+ `,
95
+ { sessionId },
96
+ )
97
+
98
+ const ep = result[0]?.[0]
99
+ if (!ep) return null
100
+
101
+ const out: SessionContext = {
102
+ memoryCount: ep.memory_ids.length,
103
+ startedAt: ep.started_at,
104
+ }
105
+ if (ep.summary !== null) out.summary = ep.summary
106
+ if (ep.context !== null) out.context = ep.context
107
+ return out
108
+ }
109
+
110
+ export async function setSessionContext(
111
+ db: Surreal,
112
+ sessionId: string,
113
+ patch: { summary?: string; context?: Record<string, unknown> },
114
+ ): Promise<void> {
115
+ log.info('setSessionContext()', { sessionId })
116
+
117
+ if (patch.summary === undefined && patch.context === undefined) return
118
+
119
+ if (patch.summary !== undefined && patch.context !== undefined) {
120
+ const result = await db.query<[Episode[]]>(
121
+ `
122
+ UPDATE episode
123
+ SET summary = $summary,
124
+ context = $context
125
+ WHERE session_id = $sessionId AND ended_at = NONE
126
+ RETURN AFTER
127
+ `,
128
+ { sessionId, summary: patch.summary, context: patch.context },
129
+ )
130
+ if (!result[0]?.[0]) throw new Error(`No open episode found for session: ${sessionId}`)
131
+ return
132
+ }
133
+
134
+ if (patch.summary !== undefined) {
135
+ const result = await db.query<[Episode[]]>(
136
+ `
137
+ UPDATE episode
138
+ SET summary = $summary
139
+ WHERE session_id = $sessionId AND ended_at = NONE
140
+ RETURN AFTER
141
+ `,
142
+ { sessionId, summary: patch.summary },
143
+ )
144
+ if (!result[0]?.[0]) throw new Error(`No open episode found for session: ${sessionId}`)
145
+ return
146
+ }
147
+
148
+ const result = await db.query<[Episode[]]>(
149
+ `
150
+ UPDATE episode
151
+ SET context = $context
152
+ WHERE session_id = $sessionId AND ended_at = NONE
153
+ RETURN AFTER
154
+ `,
155
+ { sessionId, context: patch.context },
156
+ )
157
+ if (!result[0]?.[0]) throw new Error(`No open episode found for session: ${sessionId}`)
158
+ }
@@ -1,4 +1,5 @@
1
1
  import type { Surreal } from 'surrealdb'
2
+ import { incrementQueryStats } from '../cognitive/health.ts'
2
3
  import type { SuemoConfig } from '../config.ts'
3
4
  import { getEmbedding } from '../embedding/index.ts'
4
5
  import { getLogger } from '../logger.ts'
@@ -167,6 +168,7 @@ export async function query(
167
168
  })
168
169
 
169
170
  log.info('query() returned', { count: merged.length })
171
+ await incrementQueryStats(db)
170
172
  return merged
171
173
  }
172
174