suemo 0.0.1 → 0.0.2

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,4 +1,6 @@
1
1
  import type { Surreal } from 'surrealdb'
2
+ import type { SuemoConfig } from '../config.ts'
3
+ import { getEmbedding } from '../embedding/index.ts'
2
4
  import { getLogger } from '../logger.ts'
3
5
  import type { MemoryNode, QueryInput } from '../types.ts'
4
6
  import { QueryInputSchema } from '../types.ts'
@@ -11,35 +13,70 @@ const ACTIVE_FILTER = '(valid_until = NONE OR valid_until > time::now())'
11
13
  export async function query(
12
14
  db: Surreal,
13
15
  rawInput: QueryInput,
16
+ config: SuemoConfig,
14
17
  ): Promise<MemoryNode[]> {
15
18
  const input = QueryInputSchema.parse(rawInput)
16
19
  log.info('query()', { input: input.input, strategies: input.strategies, topK: input.topK })
17
20
 
18
- const weights = { vector: 0.5, bm25: 0.25, graph: 0.15, temporal: 0.1 } // from config ideally
21
+ const weights = config.retrieval.weights
19
22
 
20
23
  const topK = input.topK ?? 5
21
24
  const strategies = input.strategies ?? ['vector', 'bm25', 'graph']
22
25
  const candidateK = topK * 4 // over-fetch before reranking
26
+ const vectorProbeK = Math.max(1, Math.min(200, candidateK))
27
+ const vectorProbeEF = Math.max(40, vectorProbeK * 2)
23
28
  const activeFilter = input.activeOnly ? ACTIVE_FILTER : 'true'
24
29
  const scopeFilter = '($scope = NONE OR scope = $scope)'
25
30
  const kindFilter = '($kinds = NONE OR kind INSIDE $kinds)'
31
+ const needsQueryEmbedding = strategies.includes('vector') || strategies.includes('bm25')
32
+ || strategies.includes('graph')
33
+
34
+ let vectorExpr = 'fn::embed($queryText)'
35
+ let embeddingParam: number[] | undefined
36
+ if (needsQueryEmbedding) {
37
+ const embedding = await getEmbedding(input.input, config.embedding)
38
+ vectorExpr = embedding.clause === 'fn::embed($content)'
39
+ ? 'fn::embed($queryText)'
40
+ : embedding.clause
41
+ embeddingParam = embedding.param
42
+ log.debug('query embedding resolved', {
43
+ provider: config.embedding.provider,
44
+ hasVectorParam: Boolean(embeddingParam),
45
+ dimension: embeddingParam?.length,
46
+ strategies,
47
+ })
48
+ }
26
49
 
27
50
  const params = {
28
51
  queryText: input.input,
29
52
  scope: input.scope ?? null,
30
53
  kinds: input.kind ?? null,
31
- topK: input.topK,
32
54
  candidateK,
55
+ ...(embeddingParam ? { embedding: embeddingParam } : {}),
33
56
  }
34
57
 
35
58
  const promises: Promise<MemoryNode[]>[] = []
36
59
 
37
60
  // ── Strategy A: RRF — vector + BM25 combined (one SurrealDB round trip) ──
38
61
  if (strategies.includes('vector') || strategies.includes('bm25')) {
62
+ log.debug('query vector/bm25 strategy enabled', {
63
+ vectorProbeK,
64
+ vectorProbeEF,
65
+ weights: { vector: weights.vector, bm25: weights.bm25 },
66
+ })
39
67
  promises.push(
40
68
  db.query<[MemoryNode[]]>(
41
69
  `
42
- LET $vec = fn::embed($queryText);
70
+ LET $vec = ${vectorExpr};
71
+ LET $vecCand = (
72
+ SELECT id, embedding
73
+ FROM memory
74
+ WHERE ${activeFilter}
75
+ AND ${scopeFilter}
76
+ AND ${kindFilter}
77
+ AND embedding <|${vectorProbeK}, ${vectorProbeEF}|> $vec
78
+ );
79
+
43
80
  SELECT *,
44
81
  (
45
82
  search::score(1) * ${weights.bm25} +
@@ -47,7 +84,7 @@ export async function query(
47
84
  ) AS _score
48
85
  FROM memory
49
86
  WHERE content @1@ $queryText
50
- AND embedding <|$candidateK, 40|> $vec
87
+ AND id INSIDE $vecCand.id
51
88
  AND ${activeFilter}
52
89
  AND ${scopeFilter}
53
90
  AND ${kindFilter}
@@ -62,16 +99,27 @@ export async function query(
62
99
  // ── Strategy B: Graph spreading activation ───────────────────────────────
63
100
  if (strategies.includes('graph')) {
64
101
  // First get anchor IDs from a quick vector probe, then fan out via graph
102
+ log.debug('query graph strategy enabled', {
103
+ weights: { graph: weights.graph },
104
+ })
65
105
  promises.push(
66
106
  db.query<[MemoryNode[]]>(
67
107
  `
68
- LET $vec = fn::embed($queryText);
69
- LET $anchors = (
70
- SELECT id FROM memory
108
+ LET $vec = ${vectorExpr};
109
+ LET $anchor_cand = (
110
+ SELECT id, embedding
111
+ FROM memory
71
112
  WHERE ${activeFilter}
72
- ORDER BY embedding <|5, 20|> $vec
113
+ AND embedding <|5, 20|> $vec
114
+ );
115
+
116
+ LET $anchor_rows = (
117
+ SELECT id, embedding, vector::similarity::cosine(embedding, $vec) AS _score
118
+ FROM $anchor_cand
119
+ ORDER BY _score DESC
73
120
  LIMIT 5
74
- ).id;
121
+ );
122
+ LET $anchors = $anchor_rows.id;
75
123
  SELECT *,
76
124
  math::mean(<-relates_to[WHERE valid_until = NONE OR valid_until > time::now()].strength) AS _score
77
125
  FROM memory
@@ -112,7 +160,7 @@ export async function query(
112
160
  ((b as Record<string, unknown>)['_score'] as number ?? 0)
113
161
  - ((a as Record<string, unknown>)['_score'] as number ?? 0)
114
162
  )
115
- .slice(0, input.topK)
163
+ .slice(0, topK)
116
164
  .map(({ ...rest }) => {
117
165
  delete (rest as Record<string, unknown>)['_score']
118
166
  return rest as MemoryNode
@@ -130,13 +178,16 @@ export async function recall(
130
178
  log.info('recall()', { nodeId })
131
179
 
132
180
  const [nodeResult, neighborResult] = await Promise.all([
133
- db.query<[MemoryNode[]]>('SELECT * FROM $id', { id: nodeId }),
181
+ db.query<[MemoryNode[]]>(
182
+ 'SELECT * FROM <record<memory>>$id',
183
+ { id: nodeId },
184
+ ),
134
185
  db.query<[MemoryNode[]]>(
135
186
  `
136
187
  SELECT * FROM memory
137
188
  WHERE id INSIDE (
138
189
  SELECT ->relates_to[WHERE valid_until = NONE OR valid_until > time::now()]->memory.id
139
- FROM $id
190
+ FROM <record<memory>>$id
140
191
  )
141
192
  LIMIT 10
142
193
  `,
@@ -150,7 +201,7 @@ export async function recall(
150
201
  // FSRS tick — simplified next review interval
151
202
  await db.query(
152
203
  `
153
- UPDATE $id SET
204
+ UPDATE <record<memory>>$id SET
154
205
  fsrs_next_review = time::now() + 1d,
155
206
  updated_at = time::now()
156
207
  `,
@@ -171,9 +222,9 @@ export async function wander(
171
222
  // If no anchor, pick a random high-salience active node
172
223
  const anchorId = opts.anchor ?? await db.query<[{ id: string }[]]>(
173
224
  `
174
- SELECT id FROM memory
225
+ SELECT id, salience, (salience * rand()) AS _pick FROM memory
175
226
  WHERE ${ACTIVE_FILTER} AND ($scope = NONE OR scope = $scope)
176
- ORDER BY salience * rand() DESC
227
+ ORDER BY _pick DESC
177
228
  LIMIT 1
178
229
  `,
179
230
  { scope: opts.scope ?? null },
@@ -203,20 +254,24 @@ export async function timeline(
203
254
  opts: { scope?: string; from?: string; until?: string; limit?: number },
204
255
  ): Promise<MemoryNode[]> {
205
256
  log.info('timeline()', opts)
257
+ const fromIso = opts.from ? new Date(opts.from).toISOString() : null
258
+ const untilIso = opts.until ? new Date(opts.until).toISOString() : null
259
+ const fromExpr = fromIso ? '<datetime>$from' : 'NONE'
260
+ const untilExpr = untilIso ? '<datetime>$until' : 'NONE'
206
261
  return db.query<[MemoryNode[]]>(
207
262
  `
208
263
  SELECT * FROM memory
209
264
  WHERE (valid_until = NONE OR valid_until > time::now())
210
265
  AND ($scope = NONE OR scope = $scope)
211
- AND ($from = NONE OR created_at >= <datetime>$from)
212
- AND ($until = NONE OR created_at <= <datetime>$until)
266
+ AND (${fromExpr} = NONE OR created_at >= ${fromExpr})
267
+ AND (${untilExpr} = NONE OR created_at <= ${untilExpr})
213
268
  ORDER BY created_at DESC
214
269
  LIMIT $limit
215
270
  `,
216
271
  {
217
272
  scope: opts.scope ?? null,
218
- from: opts.from ?? null,
219
- until: opts.until ?? null,
273
+ ...(fromIso ? { from: fromIso } : {}),
274
+ ...(untilIso ? { until: untilIso } : {}),
220
275
  limit: opts.limit ?? 50,
221
276
  },
222
277
  ).then((r) => r[0] ?? [])
@@ -1,5 +1,7 @@
1
1
  import type { Surreal } from 'surrealdb'
2
2
  import { detectContradiction } from '../cognitive/contradiction.ts'
3
+ import type { SuemoConfig } from '../config.ts'
4
+ import { getEmbedding } from '../embedding/index.ts'
3
5
  import { getLogger } from '../logger.ts'
4
6
  import type { MemoryNode, ObserveInput } from '../types.ts'
5
7
  import { ObserveInputSchema } from '../types.ts'
@@ -10,25 +12,57 @@ const log = getLogger(['suemo', 'memory', 'write'])
10
12
  export async function observe(
11
13
  db: Surreal,
12
14
  rawInput: ObserveInput,
15
+ config: SuemoConfig,
13
16
  ): Promise<MemoryNode> {
14
17
  const input = ObserveInputSchema.parse(rawInput)
15
18
  log.info('observe()', { kind: input.kind, scope: input.scope, contentPreview: input.content.slice(0, 60) })
16
19
 
17
- // 1. Get embedding fn::embed() runs server-side
18
- // We embed inside the CREATE query; the embedding field is set by UPSERT logic.
19
- // We first check for a near-duplicate before inserting.
20
+ const { clause: embeddingClause, param: embeddingParam } = await getEmbedding(
21
+ input.content,
22
+ config.embedding,
23
+ )
24
+ log.debug('observe embedding resolved', {
25
+ provider: config.embedding.provider,
26
+ hasVectorParam: Boolean(embeddingParam),
27
+ dimension: embeddingParam?.length,
28
+ })
20
29
 
21
30
  // 2. Dedup probe: ANN search for cosine similarity > 0.97
31
+ // For non-surreal providers, we pass the pre-computed vector as $qvec.
32
+ if (config.embedding.provider !== 'surreal' && !embeddingParam) {
33
+ throw new Error(`Missing embedding vector for provider: ${config.embedding.provider}`)
34
+ }
35
+
36
+ const dedupVecClause = config.embedding.provider === 'surreal'
37
+ ? 'fn::embed($content)'
38
+ : '$qvec'
39
+ const dedupParams = config.embedding.provider === 'surreal'
40
+ ? { content: input.content }
41
+ : { qvec: embeddingParam }
42
+ log.debug('observe dedup probe', {
43
+ provider: config.embedding.provider,
44
+ queryMode: config.embedding.provider === 'surreal' ? 'fn::embed($content)' : '$qvec',
45
+ })
46
+
22
47
  const dedupResult = await db.query<[{ id: string; score: number }[]]>(
23
48
  `
24
- LET $vec = fn::embed($content);
25
- SELECT id, vector::similarity::cosine(embedding, $vec) AS score
26
- FROM memory
27
- WHERE (valid_until = NONE OR valid_until > time::now())
28
- ORDER BY embedding <|1, 20|> $vec
49
+ LET $vec = ${dedupVecClause};
50
+ LET $cand = (
51
+ SELECT id, embedding
52
+ FROM memory
53
+ WHERE (valid_until = NONE OR valid_until > time::now())
54
+ AND embedding <|1, 20|> $vec
55
+ );
56
+
57
+ SELECT id, score
58
+ FROM (
59
+ SELECT id, vector::similarity::cosine(embedding, $vec) AS score
60
+ FROM $cand
61
+ )
62
+ ORDER BY score DESC
29
63
  LIMIT 1
30
64
  `,
31
- { content: input.content },
65
+ dedupParams,
32
66
  )
33
67
 
34
68
  const topHit = dedupResult[0]?.[0]
@@ -39,8 +73,8 @@ export async function observe(
39
73
  })
40
74
  const updated = await db.query<[MemoryNode[]]>(
41
75
  `
42
- UPDATE $id SET
43
- tags += $tags,
76
+ UPDATE <record<memory>>$id SET
77
+ tags += <set<string>>$tags,
44
78
  updated_at = time::now()
45
79
  `,
46
80
  { id: topHit.id, tags: input.tags },
@@ -48,16 +82,16 @@ export async function observe(
48
82
  return updated[0]![0]!
49
83
  }
50
84
 
51
- // 3. Create new node with server-side embedding
85
+ // 3. Create new node with embedding
52
86
  const created = await db.query<[MemoryNode[]]>(
53
87
  `
54
88
  CREATE memory CONTENT {
55
89
  kind: $kind,
56
90
  content: $content,
57
91
  summary: NONE,
58
- tags: $tags,
92
+ tags: <set<string>>$tags,
59
93
  scope: $scope,
60
- embedding: fn::embed($content),
94
+ embedding: ${embeddingClause},
61
95
  confidence: $confidence,
62
96
  salience: 0.5,
63
97
  source: $source,
@@ -72,9 +106,10 @@ export async function observe(
72
106
  kind: input.kind,
73
107
  content: input.content,
74
108
  tags: input.tags,
75
- scope: input.scope ?? null,
109
+ ...(input.scope !== undefined ? { scope: input.scope } : {}),
76
110
  confidence: input.confidence,
77
- source: input.source ?? null,
111
+ ...(input.source !== undefined ? { source: input.source } : {}),
112
+ ...(embeddingParam ? { embedding: embeddingParam } : {}),
78
113
  },
79
114
  )
80
115
 
@@ -89,10 +124,11 @@ export async function observe(
89
124
  export async function believe(
90
125
  db: Surreal,
91
126
  input: Omit<ObserveInput, 'kind'>,
127
+ config: SuemoConfig,
92
128
  ): Promise<{ node: MemoryNode; contradicted?: MemoryNode }> {
93
129
  log.info('believe()', { scope: input.scope })
94
130
 
95
- const node = await observe(db, { ...input, kind: 'belief' })
131
+ const node = await observe(db, { ...input, kind: 'belief' }, config)
96
132
 
97
133
  const contradicted = await detectContradiction(db, node)
98
134
  if (contradicted) {
@@ -125,7 +161,7 @@ export async function invalidate(
125
161
  log.info('invalidate()', { nodeId, reason })
126
162
  await db.query(
127
163
  `
128
- UPDATE $id SET
164
+ UPDATE <record<memory>>$id SET
129
165
  valid_until = time::now(),
130
166
  updated_at = time::now()
131
167
  `,
@@ -1,20 +0,0 @@
1
- import { Crust } from '@crustjs/core'
2
-
3
- // Root CLI — shared instance all commands are built off via .sub()
4
- // The two flags here are inherited by every subcommand.
5
- export const app = new Crust('suemo')
6
- .meta({ description: 'Persistent semantic memory for AI agents — SurrealDB backend' })
7
- .flags({
8
- config: {
9
- type: 'string',
10
- short: 'c',
11
- description: 'Path to suemo config file',
12
- inherit: true,
13
- },
14
- debug: {
15
- type: 'boolean',
16
- short: 'd',
17
- description: 'Enable verbose debug logging',
18
- inherit: true,
19
- },
20
- })