suemo 0.0.3 → 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/README.md CHANGED
@@ -96,6 +96,14 @@ suemo serve
96
96
  # Listening on http://127.0.0.1:4242
97
97
  ```
98
98
 
99
+ For development with automatic restart on file changes:
100
+
101
+ ```sh
102
+ suemo serve --dev
103
+ ```
104
+
105
+ `--dev` re-runs `suemo serve` under Bun watch mode and restarts MCP when source files change.
106
+
99
107
  ---
100
108
 
101
109
  ## CLI Reference
@@ -161,8 +169,8 @@ export default defineConfig({
161
169
  },
162
170
  },
163
171
  embedding: {
164
- provider: 'surreal', // fn::embed() — configured in SurrealDB
165
- dimension: 1536,
172
+ provider: 'surrealml', // fn::embed() — configured in SurrealDB
173
+ dimension: 384,
166
174
  },
167
175
  consolidation: {
168
176
  trigger: 'timer',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suemo",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Persistent semantic memory for AI agents — backed by SurrealDB.",
5
5
  "author": {
6
6
  "name": "Umar Alfarouk",
@@ -5,15 +5,77 @@ import { app, initCliCommand } from '../shared.ts'
5
5
 
6
6
  const log = getLogger(['suemo', 'cli', 'serve'])
7
7
 
8
+ function printDevRestartBanner(): void {
9
+ if (process.env.SUEMO_DEV_WATCH !== '1') return
10
+ const now = new Date().toISOString()
11
+ console.log(`\n[suemo:dev] MCP restarted (${now})\n`)
12
+ }
13
+
14
+ async function runServeDevMode(): Promise<never> {
15
+ const scriptPath = process.argv[1] ?? 'src/cli/index.ts'
16
+ const forwardedArgs = process.argv.slice(2).filter((arg) => arg !== '--dev')
17
+ const bunExecutable = process.execPath.includes('bun') ? process.execPath : 'bun'
18
+ const cmd = [bunExecutable, '--watch', scriptPath, ...forwardedArgs]
19
+
20
+ log.info('Starting serve dev mode (bun --watch)', {
21
+ scriptPath,
22
+ forwardedArgs,
23
+ })
24
+
25
+ const child = Bun.spawn({
26
+ cmd,
27
+ env: { ...process.env, SUEMO_DEV_WATCH: '1' },
28
+ stdin: 'inherit',
29
+ stdout: 'inherit',
30
+ stderr: 'inherit',
31
+ })
32
+
33
+ const shutdown = (signal: NodeJS.Signals): void => {
34
+ log.info('Stopping dev watcher', { signal })
35
+ try {
36
+ child.kill(signal)
37
+ } catch {
38
+ // noop
39
+ }
40
+ }
41
+
42
+ process.once('SIGINT', () => {
43
+ shutdown('SIGINT')
44
+ })
45
+ process.once('SIGTERM', () => {
46
+ shutdown('SIGTERM')
47
+ })
48
+
49
+ const exitCode = await child.exited
50
+ process.exit(exitCode)
51
+ }
52
+
53
+ async function maybeDelayDevStartup(): Promise<void> {
54
+ if (process.env.SUEMO_DEV_WATCH !== '1') return
55
+ const rawDelay = process.env.SUEMO_DEV_RESTART_DELAY_MS ?? '250'
56
+ const parsed = Number(rawDelay)
57
+ const delayMs = Number.isFinite(parsed) && parsed > 0 ? parsed : 250
58
+ if (delayMs > 0) {
59
+ log.debug('Delaying startup for dev restart stability', { delayMs })
60
+ await Bun.sleep(delayMs)
61
+ }
62
+ }
63
+
8
64
  export const serveCmd = app.sub('serve')
9
65
  .meta({ description: 'Start the MCP server (HTTP or stdio)' })
10
66
  .flags({
11
67
  port: { type: 'number', short: 'p', description: 'Port to listen on (overrides config)' },
12
68
  host: { type: 'string', description: 'Host to bind to (overrides config)' },
13
69
  stdio: { type: 'boolean', description: 'Use stdio transport instead of HTTP' },
70
+ dev: { type: 'boolean', description: 'Restart MCP server on code changes (bun --watch)' },
14
71
  })
15
72
  .run(async ({ flags }) => {
73
+ printDevRestartBanner()
74
+ await maybeDelayDevStartup()
16
75
  await initCliCommand('serve', { debug: flags.debug, config: flags.config })
76
+ if (flags.dev) {
77
+ await runServeDevMode()
78
+ }
17
79
  const config = await loadConfig(process.cwd(), flags.config)
18
80
  const sync = resolveSyncConfig(config)
19
81
  if (sync?.auto.enabled) {
@@ -33,6 +33,10 @@ export const wanderCmd = app.sub('wander')
33
33
  if (flags.json) {
34
34
  console.log(JSON.stringify(nodes, null, 2))
35
35
  } else {
36
+ if (nodes.length === 0) {
37
+ console.log('No memories found for this wander query.')
38
+ return
39
+ }
36
40
  for (const n of nodes) {
37
41
  console.log(`[${n.kind}] ${n.id} salience=${n.salience.toFixed(2)}`)
38
42
  console.log(` ${n.content.slice(0, 120)}`)
@@ -77,22 +77,16 @@ async function runNREM(
77
77
  // Find similar unassigned nodes using DB-side cosine
78
78
  const similarResult = await db.query<[{ id: string; score: number }[]]>(
79
79
  `
80
- LET $cand = (
81
- SELECT id, embedding
80
+ SELECT id, score
81
+ FROM (
82
+ SELECT id, vector::similarity::cosine(embedding, $emb) AS score
82
83
  FROM memory
83
84
  WHERE consolidated = false
84
85
  AND id != $self
85
86
  AND (valid_until = NONE OR valid_until > time::now())
86
- AND embedding <|10, 20|> $emb
87
- );
88
-
89
- SELECT id, score
90
- FROM (
91
- SELECT id, vector::similarity::cosine(embedding, $emb) AS score
92
- FROM $cand
93
87
  )
94
88
  ORDER BY score DESC
95
- LIMIT 10
89
+ LIMIT 500
96
90
  `,
97
91
  { emb: node.embedding, self: node.id },
98
92
  )
@@ -238,13 +232,10 @@ async function runREM(
238
232
  const candidates = await db.query<[MemoryNode[]]>(
239
233
  `
240
234
  SELECT * FROM memory
241
- WHERE id IN (
242
- SELECT VALUE id FROM memory
243
- WHERE id != $self
244
- AND consolidated = true
245
- AND (valid_until = NONE OR valid_until > time::now())
246
- AND embedding <|10, 40|> $emb
247
- )
235
+ WHERE id != $self
236
+ AND consolidated = true
237
+ AND (valid_until = NONE OR valid_until > time::now())
238
+ ORDER BY vector::similarity::cosine(embedding, $emb) DESC
248
239
  LIMIT 10
249
240
  `,
250
241
  { self: node.id, emb: node.embedding },
@@ -21,21 +21,15 @@ export async function detectContradiction(
21
21
 
22
22
  log.debug('detectContradiction()', { nodeId: newNode.id })
23
23
 
24
- const candidates = await db.query<[{ id: string; score: number }[]]>(
24
+ const candidates = await db.query<unknown[]>(
25
25
  `
26
- LET $cand = (
27
- SELECT id, embedding
26
+ SELECT id, score
27
+ FROM (
28
+ SELECT id, vector::similarity::cosine(embedding, $emb) AS score
28
29
  FROM memory
29
30
  WHERE kind = 'belief'
30
31
  AND id != $self
31
32
  AND (valid_until = NONE OR valid_until > time::now())
32
- AND embedding <|3, 20|> $emb
33
- );
34
-
35
- SELECT id, score
36
- FROM (
37
- SELECT id, vector::similarity::cosine(embedding, $emb) AS score
38
- FROM $cand
39
33
  )
40
34
  ORDER BY score DESC
41
35
  LIMIT 3
@@ -43,7 +37,7 @@ export async function detectContradiction(
43
37
  { emb: newNode.embedding, self: newNode.id },
44
38
  )
45
39
 
46
- const top = candidates[0]
40
+ const top = candidates.at(-1) as { id: string; score: number }[] | undefined
47
41
  if (!top || top.length === 0) return null
48
42
 
49
43
  const best = top[0]!
@@ -161,11 +161,11 @@ export async function suemoStats(db: Surreal): Promise<SuemoStats> {
161
161
  export async function incrementWriteStats(db: Surreal): Promise<void> {
162
162
  await db.query(
163
163
  `
164
- UPSERT suemo_stats:default SET
165
- ns_db = $nsDb,
166
- total_writes = total_writes + 1,
167
- last_write = time::now()
168
- `,
164
+ UPSERT suemo_stats:default SET
165
+ ns_db = $nsDb,
166
+ total_writes = IF total_writes = NONE THEN 1 ELSE total_writes + 1 END,
167
+ last_write = time::now()
168
+ `,
169
169
  { nsDb: 'default' },
170
170
  )
171
171
  }
@@ -173,11 +173,11 @@ export async function incrementWriteStats(db: Surreal): Promise<void> {
173
173
  export async function incrementQueryStats(db: Surreal): Promise<void> {
174
174
  await db.query(
175
175
  `
176
- UPSERT suemo_stats:default SET
177
- ns_db = $nsDb,
178
- total_queries = total_queries + 1,
179
- last_query = time::now()
180
- `,
176
+ UPSERT suemo_stats:default SET
177
+ ns_db = $nsDb,
178
+ total_queries = IF total_queries = NONE THEN 1 ELSE total_queries + 1 END,
179
+ last_query = time::now()
180
+ `,
181
181
  { nsDb: 'default' },
182
182
  )
183
183
  }
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 {
@@ -216,9 +216,9 @@ export async function loadConfig(
216
216
  async function importConfig(path: string): Promise<SuemoConfig> {
217
217
  log.debug('Importing config module', { path })
218
218
  const mod = await import(pathToFileURL(path).href)
219
- const cfg: unknown = mod.default ?? mod
220
- if (!cfg || typeof cfg !== 'object') {
219
+ const cfgRaw: unknown = mod.default ?? mod
220
+ if (!cfgRaw || typeof cfgRaw !== 'object') {
221
221
  throw new Error(`Config at ${path} does not export a default object`)
222
222
  }
223
- 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
224
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
  }
@@ -90,7 +90,7 @@ DEFINE FIELD OVERWRITE session_id ON episode TYPE string;
90
90
  DEFINE FIELD OVERWRITE started_at ON episode TYPE datetime DEFAULT time::now();
91
91
  DEFINE FIELD OVERWRITE ended_at ON episode TYPE option<datetime> DEFAULT NONE;
92
92
  DEFINE FIELD OVERWRITE summary ON episode TYPE option<string>;
93
- DEFINE FIELD OVERWRITE context ON episode TYPE option<object> DEFAULT NONE;
93
+ DEFINE FIELD OVERWRITE context ON episode TYPE option<object> FLEXIBLE DEFAULT NONE;
94
94
 
95
95
  -- REFERENCES: bidirectional array of memory links
96
96
  DEFINE FIELD OVERWRITE memory_ids ON episode
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/mcp/server.ts CHANGED
@@ -149,7 +149,7 @@ export async function startMcpStdioServer(config: SuemoConfig): Promise<void> {
149
149
  autoSync.start()
150
150
  try {
151
151
  const compat = await checkCompatibility(db, {
152
- requireEmbedding: config.embedding.provider === 'surreal',
152
+ requireEmbedding: config.embedding.provider === 'surrealml',
153
153
  context: 'mcp:stdio-startup',
154
154
  })
155
155
  if (!compat.ok) {
package/src/mcp/stdio.ts CHANGED
@@ -4,6 +4,8 @@ import type { SuemoConfig } from '../config.ts'
4
4
  import { getLogger } from '../logger.ts'
5
5
  import { handleToolCall } from './dispatch.ts'
6
6
 
7
+ import packageJson from '../../package.json' with { type: 'json' }
8
+
7
9
  const log = getLogger(['suemo', 'mcp', 'stdio'])
8
10
 
9
11
  interface JsonRpcRequest {
@@ -338,7 +340,7 @@ async function handleRpcMethod(
338
340
  },
339
341
  serverInfo: {
340
342
  name: 'suemo',
341
- version: '0.0.1',
343
+ version: packageJson.version ?? '0.0.0',
342
344
  },
343
345
  }
344
346
  }
@@ -358,17 +360,33 @@ async function handleRpcMethod(
358
360
  if (method === 'tools/call') {
359
361
  const name = typeof params.name === 'string' ? params.name : null
360
362
  if (!name) {
361
- throw new Error('Invalid tools/call params: missing string field `name`')
363
+ return {
364
+ isError: true,
365
+ content: [{ type: 'text', text: 'Invalid tools/call params: missing string field `name`' }],
366
+ structuredContent: { error: 'Invalid tools/call params: missing string field `name`' },
367
+ }
362
368
  }
363
369
 
364
370
  const toolArgs = params.arguments && typeof params.arguments === 'object' && !Array.isArray(params.arguments)
365
371
  ? params.arguments as Record<string, unknown>
366
372
  : {}
367
373
 
368
- const toolResult = await handleToolCall(db, config, name, toolArgs, opts)
369
- return {
370
- content: [{ type: 'text', text: JSON.stringify(toolResult, null, 2) }],
371
- structuredContent: toolResult,
374
+ try {
375
+ const toolResult = await handleToolCall(db, config, name, toolArgs, opts)
376
+ const structuredContent = toolResult && typeof toolResult === 'object' && !Array.isArray(toolResult)
377
+ ? toolResult
378
+ : { result: toolResult }
379
+ return {
380
+ content: [{ type: 'text', text: JSON.stringify(toolResult, null, 2) }],
381
+ structuredContent,
382
+ }
383
+ } catch (error) {
384
+ const message = String(error)
385
+ return {
386
+ isError: true,
387
+ content: [{ type: 'text', text: message }],
388
+ structuredContent: { error: message },
389
+ }
372
390
  }
373
391
  }
374
392
 
@@ -1,4 +1,5 @@
1
1
  import type { Surreal } from 'surrealdb'
2
+ import { incrementWriteStats } from '../cognitive/health.ts'
2
3
  import { getLogger } from '../logger.ts'
3
4
  import type { Episode } from '../types.ts'
4
5
 
@@ -21,6 +22,7 @@ export async function episodeStart(
21
22
  `,
22
23
  { sessionId },
23
24
  )
25
+ await incrementWriteStats(db)
24
26
  return result[0]![0]!
25
27
  }
26
28
 
@@ -43,6 +45,7 @@ export async function episodeEnd(
43
45
  )
44
46
  const episode = result[0]?.[0]
45
47
  if (!episode) throw new Error(`No open episode found for session: ${sessionId}`)
48
+ await incrementWriteStats(db)
46
49
  return episode
47
50
  }
48
51
 
@@ -128,6 +131,7 @@ export async function setSessionContext(
128
131
  { sessionId, summary: patch.summary, context: patch.context },
129
132
  )
130
133
  if (!result[0]?.[0]) throw new Error(`No open episode found for session: ${sessionId}`)
134
+ await incrementWriteStats(db)
131
135
  return
132
136
  }
133
137
 
@@ -142,6 +146,7 @@ export async function setSessionContext(
142
146
  { sessionId, summary: patch.summary },
143
147
  )
144
148
  if (!result[0]?.[0]) throw new Error(`No open episode found for session: ${sessionId}`)
149
+ await incrementWriteStats(db)
145
150
  return
146
151
  }
147
152
 
@@ -155,4 +160,5 @@ export async function setSessionContext(
155
160
  { sessionId, context: patch.context },
156
161
  )
157
162
  if (!result[0]?.[0]) throw new Error(`No open episode found for session: ${sessionId}`)
163
+ await incrementWriteStats(db)
158
164
  }
@@ -24,11 +24,9 @@ export async function query(
24
24
  const topK = input.topK ?? 5
25
25
  const strategies = input.strategies ?? ['vector', 'bm25', 'graph']
26
26
  const candidateK = topK * 4 // over-fetch before reranking
27
- const vectorProbeK = Math.max(1, Math.min(200, candidateK))
28
- const vectorProbeEF = Math.max(40, vectorProbeK * 2)
29
27
  const activeFilter = input.activeOnly ? ACTIVE_FILTER : 'true'
30
- const scopeFilter = '($scope = NONE OR scope = $scope)'
31
- const kindFilter = '($kinds = NONE OR kind INSIDE $kinds)'
28
+ const scopeFilter = '(array::len($scopes) = 0 OR scope INSIDE $scopes)'
29
+ const kindFilter = '(array::len($kinds) = 0 OR kind INSIDE $kinds)'
32
30
  const needsQueryEmbedding = strategies.includes('vector') || strategies.includes('bm25')
33
31
  || strategies.includes('graph')
34
32
 
@@ -50,42 +48,52 @@ export async function query(
50
48
 
51
49
  const params = {
52
50
  queryText: input.input,
53
- scope: input.scope ?? null,
54
- kinds: input.kind ?? null,
51
+ scopes: input.scope ? [input.scope] : [],
52
+ kinds: input.kind ?? [],
55
53
  candidateK,
56
54
  ...(embeddingParam ? { embedding: embeddingParam } : {}),
57
55
  }
58
56
 
59
57
  const promises: Promise<MemoryNode[]>[] = []
60
58
 
61
- // ── Strategy A: RRF — vector + BM25 combined (one SurrealDB round trip) ──
62
- if (strategies.includes('vector') || strategies.includes('bm25')) {
63
- log.debug('query vector/bm25 strategy enabled', {
64
- vectorProbeK,
65
- vectorProbeEF,
66
- weights: { vector: weights.vector, bm25: weights.bm25 },
59
+ // ── Strategy A: vector similarity ──────────────────────────────────────────
60
+ if (strategies.includes('vector')) {
61
+ log.debug('query vector strategy enabled', {
62
+ weights: { vector: weights.vector },
67
63
  })
68
64
  promises.push(
69
- db.query<[MemoryNode[]]>(
65
+ db.query<unknown[]>(
70
66
  `
71
67
  LET $vec = ${vectorExpr};
72
- LET $vecCand = (
73
- SELECT id, embedding
74
- FROM memory
75
- WHERE ${activeFilter}
76
- AND ${scopeFilter}
77
- AND ${kindFilter}
78
- AND embedding <|${vectorProbeK}, ${vectorProbeEF}|> $vec
79
- );
68
+ SELECT *,
69
+ vector::similarity::cosine(embedding, $vec) * ${weights.vector} AS _score
70
+ FROM memory
71
+ WHERE ${activeFilter}
72
+ AND ${scopeFilter}
73
+ AND ${kindFilter}
74
+ ORDER BY _score DESC
75
+ LIMIT $candidateK
76
+ `,
77
+ params,
78
+ ).then((r) => {
79
+ const rows = r.at(-1)
80
+ return Array.isArray(rows) ? rows as MemoryNode[] : []
81
+ }),
82
+ )
83
+ }
80
84
 
85
+ // ── Strategy B: BM25 lexical match ─────────────────────────────────────────
86
+ if (strategies.includes('bm25')) {
87
+ log.debug('query bm25 strategy enabled', {
88
+ weights: { bm25: weights.bm25 },
89
+ })
90
+ promises.push(
91
+ db.query<unknown[]>(
92
+ `
81
93
  SELECT *,
82
- (
83
- search::score(1) * ${weights.bm25} +
84
- vector::similarity::cosine(embedding, $vec) * ${weights.vector}
85
- ) AS _score
94
+ search::score(1) * ${weights.bm25} AS _score
86
95
  FROM memory
87
96
  WHERE content @1@ $queryText
88
- AND id INSIDE $vecCand.id
89
97
  AND ${activeFilter}
90
98
  AND ${scopeFilter}
91
99
  AND ${kindFilter}
@@ -93,30 +101,29 @@ export async function query(
93
101
  LIMIT $candidateK
94
102
  `,
95
103
  params,
96
- ).then((r) => r[0] ?? []),
104
+ ).then((r) => {
105
+ const rows = r.at(-1)
106
+ return Array.isArray(rows) ? rows as MemoryNode[] : []
107
+ }),
97
108
  )
98
109
  }
99
110
 
100
- // ── Strategy B: Graph spreading activation ───────────────────────────────
111
+ // ── Strategy C: Graph spreading activation ───────────────────────────────
101
112
  if (strategies.includes('graph')) {
102
- // First get anchor IDs from a quick vector probe, then fan out via graph
113
+ // First get anchor IDs from cosine similarity, then fan out via graph
103
114
  log.debug('query graph strategy enabled', {
104
115
  weights: { graph: weights.graph },
105
116
  })
106
117
  promises.push(
107
- db.query<[MemoryNode[]]>(
118
+ db.query<unknown[]>(
108
119
  `
109
120
  LET $vec = ${vectorExpr};
110
- LET $anchor_cand = (
111
- SELECT id, embedding
112
- FROM memory
113
- WHERE ${activeFilter}
114
- AND embedding <|5, 20|> $vec
115
- );
116
-
117
121
  LET $anchor_rows = (
118
122
  SELECT id, embedding, vector::similarity::cosine(embedding, $vec) AS _score
119
- FROM $anchor_cand
123
+ FROM memory
124
+ WHERE ${activeFilter}
125
+ AND ${scopeFilter}
126
+ AND ${kindFilter}
120
127
  ORDER BY _score DESC
121
128
  LIMIT 5
122
129
  );
@@ -130,11 +137,15 @@ export async function query(
130
137
  )
131
138
  AND ${activeFilter}
132
139
  AND ${scopeFilter}
140
+ AND ${kindFilter}
133
141
  ORDER BY _score DESC
134
142
  LIMIT $candidateK
135
- `,
143
+ `,
136
144
  params,
137
- ).then((r) => r[0] ?? []),
145
+ ).then((r) => {
146
+ const rows = r.at(-1)
147
+ return Array.isArray(rows) ? rows as MemoryNode[] : []
148
+ }),
138
149
  )
139
150
  }
140
151
 
@@ -209,6 +220,7 @@ export async function recall(
209
220
  `,
210
221
  { id: nodeId },
211
222
  )
223
+ await incrementQueryStats(db)
212
224
 
213
225
  return { node, neighbors: neighborResult[0] ?? [] }
214
226
  }
@@ -232,10 +244,13 @@ export async function wander(
232
244
  { scope: opts.scope ?? null },
233
245
  ).then((r) => r[0]?.[0]?.id)
234
246
 
235
- if (!anchorId) return []
247
+ if (!anchorId) {
248
+ await incrementQueryStats(db)
249
+ return []
250
+ }
236
251
 
237
252
  // Walk hops: follow relates_to edges, weight by strength
238
- return db.query<[MemoryNode[]]>(
253
+ const result = await db.query<[MemoryNode[]]>(
239
254
  `
240
255
  SELECT * FROM memory
241
256
  WHERE id INSIDE (
@@ -248,6 +263,8 @@ export async function wander(
248
263
  `,
249
264
  { anchor: anchorId },
250
265
  ).then((r) => r[0] ?? [])
266
+ await incrementQueryStats(db)
267
+ return result
251
268
  }
252
269
 
253
270
  // ── timeline() ───────────────────────────────────────────────────────────────
@@ -260,7 +277,7 @@ export async function timeline(
260
277
  const untilIso = opts.until ? new Date(opts.until).toISOString() : null
261
278
  const fromExpr = fromIso ? '<datetime>$from' : 'NONE'
262
279
  const untilExpr = untilIso ? '<datetime>$until' : 'NONE'
263
- return db.query<[MemoryNode[]]>(
280
+ const result = await db.query<[MemoryNode[]]>(
264
281
  `
265
282
  SELECT * FROM memory
266
283
  WHERE (valid_until = NONE OR valid_until > time::now())
@@ -277,4 +294,6 @@ export async function timeline(
277
294
  limit: opts.limit ?? 50,
278
295
  },
279
296
  ).then((r) => r[0] ?? [])
297
+ await incrementQueryStats(db)
298
+ return result
280
299
  }
@@ -31,35 +31,30 @@ export async function observe(
31
31
 
32
32
  // 2. Dedup probe: ANN search for cosine similarity > 0.97
33
33
  // For non-surreal providers, we pass the pre-computed vector as $qvec.
34
- if (config.embedding.provider !== 'surreal' && !embeddingParam) {
34
+ const usesSurrealEmbedding = config.embedding.provider === 'surrealml'
35
+ if (!usesSurrealEmbedding && !embeddingParam) {
35
36
  throw new Error(`Missing embedding vector for provider: ${config.embedding.provider}`)
36
37
  }
37
38
 
38
- const dedupVecClause = config.embedding.provider === 'surreal'
39
+ const dedupVecClause = usesSurrealEmbedding
39
40
  ? 'fn::embed($content)'
40
41
  : '$qvec'
41
- const dedupParams = config.embedding.provider === 'surreal'
42
+ const dedupParams = usesSurrealEmbedding
42
43
  ? { content: input.content }
43
44
  : { qvec: embeddingParam }
44
45
  log.debug('observe dedup probe', {
45
46
  provider: config.embedding.provider,
46
- queryMode: config.embedding.provider === 'surreal' ? 'fn::embed($content)' : '$qvec',
47
+ queryMode: usesSurrealEmbedding ? 'fn::embed($content)' : '$qvec',
47
48
  })
48
49
 
49
- const dedupResult = await db.query<[{ id: string; score: number }[]]>(
50
+ const dedupResult = await db.query<unknown[]>(
50
51
  `
51
52
  LET $vec = ${dedupVecClause};
52
- LET $cand = (
53
- SELECT id, embedding
54
- FROM memory
55
- WHERE (valid_until = NONE OR valid_until > time::now())
56
- AND embedding <|1, 20|> $vec
57
- );
58
-
59
53
  SELECT id, score
60
54
  FROM (
61
55
  SELECT id, vector::similarity::cosine(embedding, $vec) AS score
62
- FROM $cand
56
+ FROM memory
57
+ WHERE (valid_until = NONE OR valid_until > time::now())
63
58
  )
64
59
  ORDER BY score DESC
65
60
  LIMIT 1
@@ -67,7 +62,10 @@ export async function observe(
67
62
  dedupParams,
68
63
  )
69
64
 
70
- const topHit = dedupResult[0]?.[0]
65
+ const dedupRows = dedupResult.at(-1)
66
+ const topHit = Array.isArray(dedupRows)
67
+ ? (dedupRows[0] as { id: string; score: number } | undefined)
68
+ : undefined
71
69
  if (topHit && topHit.score > 0.97) {
72
70
  log.debug('Near-duplicate detected — merging tags, updating updated_at', {
73
71
  existingId: topHit.id,
package/src/types.ts CHANGED
@@ -125,7 +125,7 @@ export const QueryInputSchema = z.object({
125
125
  topK: z.number().int().min(1).max(50).default(5).optional(),
126
126
  activeOnly: z.boolean().default(true).optional(),
127
127
  strategies: z
128
- .array(z.enum(['vector', 'bm25', 'graph', 'temporal']))
128
+ .array(z.enum(['vector', 'bm25', 'graph']))
129
129
  .default(['vector', 'bm25', 'graph'])
130
130
  .optional(),
131
131
  })