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.
- package/README.md +37 -10
- package/package.json +1 -1
- package/src/cli/commands/believe.ts +22 -12
- package/src/cli/commands/consolidate.ts +18 -11
- package/src/cli/commands/doctor.ts +123 -0
- package/src/cli/commands/export-import.ts +58 -47
- package/src/cli/commands/goal.ts +52 -27
- package/src/cli/commands/health.ts +35 -17
- package/src/cli/commands/init.ts +155 -75
- package/src/cli/commands/observe.ts +25 -13
- package/src/cli/commands/query.ts +23 -7
- package/src/cli/commands/recall.ts +12 -6
- package/src/cli/commands/serve.ts +14 -5
- package/src/cli/commands/sync.ts +15 -6
- package/src/cli/commands/timeline.ts +30 -18
- package/src/cli/commands/wander.ts +27 -16
- package/src/cli/index.ts +3 -4
- package/src/cli/shared.ts +34 -0
- package/src/cognitive/consolidate.ts +48 -19
- package/src/cognitive/contradiction.ts +19 -7
- package/src/config.template.ts +36 -0
- package/src/config.ts +41 -12
- package/src/db/preflight.ts +32 -6
- package/src/db/schema.surql +11 -8
- package/src/db/schema.ts +6 -3
- package/src/embedding/index.ts +52 -0
- package/src/embedding/openai-compatible.ts +43 -0
- package/src/goal.ts +3 -1
- package/src/mcp/dispatch.ts +134 -0
- package/src/mcp/server.ts +25 -2
- package/src/mcp/stdio.ts +314 -0
- package/src/mcp/tools.ts +8 -89
- package/src/memory/read.ts +74 -19
- package/src/memory/write.ts +54 -18
- package/src/cli/commands/shared.ts +0 -20
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Surreal } from 'surrealdb'
|
|
2
|
-
import type { LLMConfig } from '../config.ts'
|
|
2
|
+
import type { LLMConfig, SuemoConfig } from '../config.ts'
|
|
3
|
+
import { getEmbedding } from '../embedding/index.ts'
|
|
3
4
|
import { getLogger } from '../logger.ts'
|
|
4
5
|
import type { ConsolidationRun, MemoryNode } from '../types.ts'
|
|
5
6
|
|
|
@@ -40,6 +41,7 @@ async function runNREM(
|
|
|
40
41
|
db: Surreal,
|
|
41
42
|
llm: LLMConfig,
|
|
42
43
|
similarityThreshold: number,
|
|
44
|
+
embeddingConfig: SuemoConfig['embedding'],
|
|
43
45
|
): Promise<{ nodesIn: number; nodesOut: number }> {
|
|
44
46
|
log.info('NREM phase starting')
|
|
45
47
|
|
|
@@ -75,12 +77,21 @@ async function runNREM(
|
|
|
75
77
|
// Find similar unassigned nodes using DB-side cosine
|
|
76
78
|
const similarResult = await db.query<[{ id: string; score: number }[]]>(
|
|
77
79
|
`
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
80
|
+
LET $cand = (
|
|
81
|
+
SELECT id, embedding
|
|
82
|
+
FROM memory
|
|
83
|
+
WHERE consolidated = false
|
|
84
|
+
AND id != $self
|
|
85
|
+
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
|
+
)
|
|
94
|
+
ORDER BY score DESC
|
|
84
95
|
LIMIT 10
|
|
85
96
|
`,
|
|
86
97
|
{ emb: node.embedding, self: node.id },
|
|
@@ -103,7 +114,10 @@ async function runNREM(
|
|
|
103
114
|
for (const cluster of clusters) {
|
|
104
115
|
if (cluster.length === 1) {
|
|
105
116
|
// Singleton — mark consolidated without compression
|
|
106
|
-
await db.query(
|
|
117
|
+
await db.query(
|
|
118
|
+
'UPDATE <record<memory>>$id SET consolidated = true',
|
|
119
|
+
{ id: cluster[0]!.id },
|
|
120
|
+
)
|
|
107
121
|
nodesOut++
|
|
108
122
|
continue
|
|
109
123
|
}
|
|
@@ -122,13 +136,22 @@ async function runNREM(
|
|
|
122
136
|
} catch (e) {
|
|
123
137
|
log.error('LLM compression failed — marking cluster nodes individually', { error: String(e) })
|
|
124
138
|
for (const n of cluster) {
|
|
125
|
-
await db.query(
|
|
139
|
+
await db.query(
|
|
140
|
+
'UPDATE <record<memory>>$id SET consolidated = true',
|
|
141
|
+
{ id: n.id },
|
|
142
|
+
)
|
|
126
143
|
}
|
|
127
144
|
nodesOut += cluster.length
|
|
128
145
|
continue
|
|
129
146
|
}
|
|
130
147
|
|
|
131
148
|
// Create the compressed summary node
|
|
149
|
+
const { clause: embeddingClause, param: embeddingParam } = await getEmbedding(summary, embeddingConfig)
|
|
150
|
+
log.debug('NREM summary embedding resolved', {
|
|
151
|
+
provider: embeddingConfig.provider,
|
|
152
|
+
hasVectorParam: Boolean(embeddingParam),
|
|
153
|
+
dimension: embeddingParam?.length,
|
|
154
|
+
})
|
|
132
155
|
const created = await db.query<[MemoryNode[]]>(
|
|
133
156
|
`
|
|
134
157
|
CREATE memory CONTENT {
|
|
@@ -137,7 +160,7 @@ async function runNREM(
|
|
|
137
160
|
summary: $summary,
|
|
138
161
|
tags: $tags,
|
|
139
162
|
scope: $scope,
|
|
140
|
-
embedding:
|
|
163
|
+
embedding: ${embeddingClause},
|
|
141
164
|
confidence: 1.0,
|
|
142
165
|
salience: 0.7,
|
|
143
166
|
source: 'consolidation:nrem',
|
|
@@ -151,7 +174,8 @@ async function runNREM(
|
|
|
151
174
|
{
|
|
152
175
|
summary,
|
|
153
176
|
tags: [...new Set(cluster.flatMap((n) => n.tags))],
|
|
154
|
-
scope: cluster[0]!.scope
|
|
177
|
+
...(cluster[0]!.scope !== null && cluster[0]!.scope !== undefined ? { scope: cluster[0]!.scope } : {}),
|
|
178
|
+
...(embeddingParam ? { embedding: embeddingParam } : {}),
|
|
155
179
|
},
|
|
156
180
|
)
|
|
157
181
|
const summaryNode = created[0]?.[0]
|
|
@@ -161,7 +185,7 @@ async function runNREM(
|
|
|
161
185
|
for (const n of cluster) {
|
|
162
186
|
await db.query(
|
|
163
187
|
`
|
|
164
|
-
UPDATE
|
|
188
|
+
UPDATE <record<memory>>$id SET
|
|
165
189
|
consolidated = true,
|
|
166
190
|
consolidated_into = $into,
|
|
167
191
|
updated_at = time::now()
|
|
@@ -214,10 +238,13 @@ async function runREM(
|
|
|
214
238
|
const candidates = await db.query<[MemoryNode[]]>(
|
|
215
239
|
`
|
|
216
240
|
SELECT * FROM memory
|
|
217
|
-
WHERE id
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
+
)
|
|
221
248
|
LIMIT 10
|
|
222
249
|
`,
|
|
223
250
|
{ self: node.id, emb: node.embedding },
|
|
@@ -274,7 +301,7 @@ async function runREM(
|
|
|
274
301
|
|
|
275
302
|
// Bump salience on newly connected node
|
|
276
303
|
await db.query(
|
|
277
|
-
'UPDATE
|
|
304
|
+
'UPDATE <record<memory>>$id SET salience = math::min(salience + 0.1, 1.0)',
|
|
278
305
|
{ id: node.id },
|
|
279
306
|
)
|
|
280
307
|
}
|
|
@@ -288,6 +315,7 @@ export async function consolidate(
|
|
|
288
315
|
nremSimilarityThreshold?: number
|
|
289
316
|
remRelationThreshold?: number
|
|
290
317
|
llm: LLMConfig
|
|
318
|
+
embedding: SuemoConfig['embedding']
|
|
291
319
|
},
|
|
292
320
|
): Promise<ConsolidationRun> {
|
|
293
321
|
const phase = opts.nremOnly ? 'nrem' : 'full'
|
|
@@ -313,6 +341,7 @@ export async function consolidate(
|
|
|
313
341
|
db,
|
|
314
342
|
opts.llm,
|
|
315
343
|
opts.nremSimilarityThreshold ?? 0.85,
|
|
344
|
+
opts.embedding,
|
|
316
345
|
)
|
|
317
346
|
|
|
318
347
|
if (!opts.nremOnly) {
|
|
@@ -321,7 +350,7 @@ export async function consolidate(
|
|
|
321
350
|
|
|
322
351
|
await db.query(
|
|
323
352
|
`
|
|
324
|
-
UPDATE
|
|
353
|
+
UPDATE <record<consolidation_run>>$id SET
|
|
325
354
|
completed_at = time::now(),
|
|
326
355
|
status = 'done',
|
|
327
356
|
nodes_in = $nodesIn,
|
|
@@ -337,7 +366,7 @@ export async function consolidate(
|
|
|
337
366
|
log.error('consolidate() failed', { error: errMsg })
|
|
338
367
|
await db.query(
|
|
339
368
|
`
|
|
340
|
-
UPDATE
|
|
369
|
+
UPDATE <record<consolidation_run>>$id SET
|
|
341
370
|
completed_at = time::now(),
|
|
342
371
|
status = 'failed',
|
|
343
372
|
error = $err
|
|
@@ -23,12 +23,21 @@ export async function detectContradiction(
|
|
|
23
23
|
|
|
24
24
|
const candidates = await db.query<[{ id: string; score: number }[]]>(
|
|
25
25
|
`
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
LET $cand = (
|
|
27
|
+
SELECT id, embedding
|
|
28
|
+
FROM memory
|
|
29
|
+
WHERE kind = 'belief'
|
|
30
|
+
AND id != $self
|
|
31
|
+
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
|
+
)
|
|
40
|
+
ORDER BY score DESC
|
|
32
41
|
LIMIT 3
|
|
33
42
|
`,
|
|
34
43
|
{ emb: newNode.embedding, self: newNode.id },
|
|
@@ -41,7 +50,10 @@ export async function detectContradiction(
|
|
|
41
50
|
if (best.score < similarityThreshold) return null
|
|
42
51
|
|
|
43
52
|
// Fetch full node
|
|
44
|
-
const result = await db.query<[MemoryNode[]]>(
|
|
53
|
+
const result = await db.query<[MemoryNode[]]>(
|
|
54
|
+
'SELECT * FROM <record<memory>>$id',
|
|
55
|
+
{ id: best.id },
|
|
56
|
+
)
|
|
45
57
|
const existing = result[0]?.[0]
|
|
46
58
|
if (!existing) return null
|
|
47
59
|
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { defineConfig } from 'suemo'
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
surreal: {
|
|
5
|
+
url: process.env.SUEMO_URL ?? 'ws://localhost:8000',
|
|
6
|
+
namespace: process.env.SUEMO_NS ?? 'suemo',
|
|
7
|
+
database: process.env.SUEMO_DB ?? 'suemo',
|
|
8
|
+
auth: {
|
|
9
|
+
user: process.env.SUEMO_USER ?? 'root',
|
|
10
|
+
pass: process.env.SUEMO_PASS ?? 'pass',
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
embedding: {
|
|
14
|
+
provider: 'stub',
|
|
15
|
+
dimension: 384,
|
|
16
|
+
},
|
|
17
|
+
consolidation: {
|
|
18
|
+
trigger: 'timer',
|
|
19
|
+
intervalMinutes: 30,
|
|
20
|
+
reactiveThreshold: 50,
|
|
21
|
+
nremSimilarityThreshold: 0.85,
|
|
22
|
+
remRelationThreshold: 0.4,
|
|
23
|
+
llm: {
|
|
24
|
+
url: process.env.SUEMO_LLM_URL ?? 'https://api.z.ai/api/coding/paas/v4',
|
|
25
|
+
model: process.env.SUEMO_LLM_MODEL ?? 'glm-4.7-flash',
|
|
26
|
+
apiKey: process.env.SUEMO_LLM_API_KEY!,
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
retrieval: {
|
|
30
|
+
weights: { vector: 0.5, bm25: 0.25, graph: 0.15, temporal: 0.1 },
|
|
31
|
+
},
|
|
32
|
+
mcp: {
|
|
33
|
+
port: Number(process.env.SUEMO_PORT) || 4242,
|
|
34
|
+
host: '127.0.0.1',
|
|
35
|
+
},
|
|
36
|
+
})
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
2
|
import { join, resolve } from 'node:path'
|
|
3
3
|
import { pathToFileURL } from 'node:url'
|
|
4
|
+
import { getLogger } from './logger.ts'
|
|
4
5
|
|
|
5
6
|
// ── Config shape ──────────────────────────────────────────────────────────────
|
|
6
7
|
|
|
@@ -17,8 +18,9 @@ export interface SurrealTarget {
|
|
|
17
18
|
}
|
|
18
19
|
|
|
19
20
|
export type EmbeddingProvider =
|
|
21
|
+
| { provider: 'openai-compatible'; url: string; model: string; dimension: number; apiKey?: string }
|
|
20
22
|
| { provider: 'surreal'; dimension: number }
|
|
21
|
-
| { provider: 'stub'; dimension: number }
|
|
23
|
+
| { provider: 'stub'; dimension: number }
|
|
22
24
|
|
|
23
25
|
export interface LLMConfig {
|
|
24
26
|
url: string // OpenAI-compatible endpoint
|
|
@@ -76,11 +78,21 @@ const CONFIG_CANDIDATES = [
|
|
|
76
78
|
'suemo.config.js',
|
|
77
79
|
]
|
|
78
80
|
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
81
|
+
const log = getLogger(['suemo', 'config'])
|
|
82
|
+
|
|
83
|
+
function getHomeConfigPath(): string | null {
|
|
84
|
+
const home = process.env.HOME ?? process.env.USERPROFILE
|
|
85
|
+
if (!home) return null
|
|
86
|
+
return join(home, '.suemo', 'suemo.ts')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function resolveConfigPath(p: string): string {
|
|
90
|
+
if (p.startsWith('~/')) {
|
|
91
|
+
const home = process.env.HOME ?? process.env.USERPROFILE
|
|
92
|
+
if (home) return resolve(home, p.slice(2))
|
|
93
|
+
}
|
|
94
|
+
return resolve(p)
|
|
95
|
+
}
|
|
84
96
|
|
|
85
97
|
export async function loadConfig(
|
|
86
98
|
cwd = process.cwd(),
|
|
@@ -88,23 +100,40 @@ export async function loadConfig(
|
|
|
88
100
|
): Promise<SuemoConfig> {
|
|
89
101
|
// 1. Explicit --config flag
|
|
90
102
|
if (overridePath) {
|
|
91
|
-
|
|
103
|
+
const resolved = resolveConfigPath(overridePath)
|
|
104
|
+
log.debug('Loading config from --config', { path: resolved })
|
|
105
|
+
return importConfig(resolved)
|
|
106
|
+
}
|
|
107
|
+
// 2. Environment override
|
|
108
|
+
const envConfigPath = process.env.SUEMO_CONFIG_PATH?.trim()
|
|
109
|
+
if (envConfigPath) {
|
|
110
|
+
const resolved = resolveConfigPath(envConfigPath)
|
|
111
|
+
log.debug('Loading config from SUEMO_CONFIG_PATH', { path: resolved })
|
|
112
|
+
return importConfig(resolved)
|
|
92
113
|
}
|
|
93
|
-
//
|
|
114
|
+
// 3. Project-local
|
|
94
115
|
for (const name of CONFIG_CANDIDATES) {
|
|
95
116
|
const p = resolve(cwd, name)
|
|
96
|
-
if (existsSync(p))
|
|
117
|
+
if (existsSync(p)) {
|
|
118
|
+
log.debug('Loading project-local config', { path: p })
|
|
119
|
+
return importConfig(p)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// 4. User-level
|
|
123
|
+
const homeConfig = getHomeConfigPath()
|
|
124
|
+
if (homeConfig && existsSync(homeConfig)) {
|
|
125
|
+
log.debug('Loading user-level config', { path: homeConfig })
|
|
126
|
+
return importConfig(homeConfig)
|
|
97
127
|
}
|
|
98
|
-
// 3. User-level
|
|
99
|
-
if (existsSync(HOME_CONFIG)) return importConfig(HOME_CONFIG)
|
|
100
128
|
|
|
101
129
|
throw new Error(
|
|
102
130
|
'No suemo config found.\n'
|
|
103
|
-
+ 'Run `suemo init` to create ~/.suemo/suemo.ts, or create suemo.config.ts in the project root.',
|
|
131
|
+
+ 'Run `suemo init config` to create ~/.suemo/suemo.ts, set SUEMO_CONFIG_PATH, or create suemo.config.ts in the project root.',
|
|
104
132
|
)
|
|
105
133
|
}
|
|
106
134
|
|
|
107
135
|
async function importConfig(path: string): Promise<SuemoConfig> {
|
|
136
|
+
log.debug('Importing config module', { path })
|
|
108
137
|
const mod = await import(pathToFileURL(path).href)
|
|
109
138
|
const cfg: unknown = mod.default ?? mod
|
|
110
139
|
if (!cfg || typeof cfg !== 'object') {
|
package/src/db/preflight.ts
CHANGED
|
@@ -12,18 +12,33 @@ export interface CompatibilityResult {
|
|
|
12
12
|
errors: string[]
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
export interface CompatibilityOptions {
|
|
16
|
+
/**
|
|
17
|
+
* Whether missing fn::embed() should fail compatibility.
|
|
18
|
+
* Keep true for runtime paths that execute vector/embed queries.
|
|
19
|
+
*/
|
|
20
|
+
requireEmbedding?: boolean
|
|
21
|
+
/**
|
|
22
|
+
* Human-readable label for logging this preflight execution.
|
|
23
|
+
*/
|
|
24
|
+
context?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
15
27
|
const MIN_MAJOR = 3
|
|
16
28
|
|
|
17
29
|
export async function checkCompatibility(
|
|
18
30
|
db: Surreal,
|
|
31
|
+
options: CompatibilityOptions = {},
|
|
19
32
|
): Promise<CompatibilityResult> {
|
|
20
33
|
const errors: string[] = []
|
|
21
34
|
let surrealVersion = 'unknown'
|
|
22
35
|
let surrealkv = false
|
|
23
36
|
let retention_ok = false
|
|
24
37
|
let embedding = false
|
|
38
|
+
const requireEmbedding = options.requireEmbedding ?? true
|
|
39
|
+
const context = options.context ?? 'default'
|
|
25
40
|
|
|
26
|
-
log.info('Running preflight compatibility checks')
|
|
41
|
+
log.info('Running preflight compatibility checks', { requireEmbedding, context })
|
|
27
42
|
|
|
28
43
|
// ── Check 1: version string ───────────────────────────────────────────────
|
|
29
44
|
try {
|
|
@@ -41,6 +56,7 @@ export async function checkCompatibility(
|
|
|
41
56
|
)
|
|
42
57
|
}
|
|
43
58
|
}
|
|
59
|
+
log.debug('SurrealDB version check complete', { surrealVersion })
|
|
44
60
|
} catch (e) {
|
|
45
61
|
errors.push(`Cannot retrieve SurrealDB version: ${String(e)}`)
|
|
46
62
|
}
|
|
@@ -54,6 +70,7 @@ export async function checkCompatibility(
|
|
|
54
70
|
// if it was around then), not throw. An error means VERSION is unsupported.
|
|
55
71
|
await db.query("SELECT * FROM suemo_preflight:probe VERSION d'2020-01-01T00:00:00Z'")
|
|
56
72
|
surrealkv = true
|
|
73
|
+
log.debug('SurrealKV VERSION probe passed')
|
|
57
74
|
} catch (e: unknown) {
|
|
58
75
|
const msg = String(e).toLowerCase()
|
|
59
76
|
if (msg.includes('version') || msg.includes('surrealkv') || msg.includes('not supported')) {
|
|
@@ -83,6 +100,7 @@ export async function checkCompatibility(
|
|
|
83
100
|
await db.query('CREATE suemo_retention_probe:v SET t = time::now()')
|
|
84
101
|
await db.query(`SELECT * FROM suemo_retention_probe:v VERSION d'${ninetyDaysAgo}'`)
|
|
85
102
|
retention_ok = true
|
|
103
|
+
log.debug('Retention probe passed', { ninetyDaysAgo })
|
|
86
104
|
} catch (e: unknown) {
|
|
87
105
|
const msg = String(e).toLowerCase()
|
|
88
106
|
if (msg.includes('retention') || msg.includes('version')) {
|
|
@@ -104,20 +122,28 @@ export async function checkCompatibility(
|
|
|
104
122
|
|
|
105
123
|
// ── Check 4: fn::embed() resolves ────────────────────────────────────────
|
|
106
124
|
// We don't actually embed anything — we just check that the function exists.
|
|
107
|
-
// An "Unknown function" error means
|
|
125
|
+
// An "Unknown function" error means embedding runtime is unavailable.
|
|
108
126
|
try {
|
|
109
127
|
// fn::embed requires the ML module and a configured model.
|
|
110
128
|
// If it throws "No embedding model configured" that's acceptable — the
|
|
111
129
|
// function exists. If it throws "Unknown function 'fn::embed'" → not available.
|
|
112
130
|
await db.query(`RETURN fn::embed("suemo preflight test")`)
|
|
113
131
|
embedding = true
|
|
132
|
+
log.debug('Embedding function probe passed')
|
|
114
133
|
} catch (e: unknown) {
|
|
115
134
|
const msg = String(e).toLowerCase()
|
|
116
135
|
if (msg.includes('unknown function') || msg.includes('fn::embed')) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
136
|
+
embedding = false
|
|
137
|
+
if (requireEmbedding) {
|
|
138
|
+
errors.push(
|
|
139
|
+
'fn::embed() is not available in this SurrealDB database. Import/configure a SurrealML embedding model for this namespace/database, then retry.',
|
|
140
|
+
)
|
|
141
|
+
} else {
|
|
142
|
+
log.warn('Embedding function unavailable; continuing due to non-strict preflight mode', {
|
|
143
|
+
context,
|
|
144
|
+
error: String(e),
|
|
145
|
+
})
|
|
146
|
+
}
|
|
121
147
|
} else {
|
|
122
148
|
// Other error (e.g. no model configured but function exists) — treat as available
|
|
123
149
|
embedding = true
|
package/src/db/schema.surql
CHANGED
|
@@ -30,27 +30,30 @@ DEFINE FIELD OVERWRITE consolidated ON memory TYPE bool DEFAULT false;
|
|
|
30
30
|
|
|
31
31
|
-- REFERENCES: bidirectional record link (SurrealDB v3.0)
|
|
32
32
|
DEFINE FIELD OVERWRITE consolidated_into ON memory
|
|
33
|
-
TYPE option<record<memory>>
|
|
33
|
+
TYPE option<record<memory>> REFERENCE;
|
|
34
34
|
|
|
35
35
|
-- FSRS spaced-repetition fields (all optional; populated on first recall())
|
|
36
36
|
DEFINE FIELD OVERWRITE fsrs_stability ON memory TYPE option<float>;
|
|
37
37
|
DEFINE FIELD OVERWRITE fsrs_difficulty ON memory TYPE option<float>;
|
|
38
38
|
DEFINE FIELD OVERWRITE fsrs_next_review ON memory TYPE option<datetime>;
|
|
39
39
|
|
|
40
|
-
-- Vector index: HNSW
|
|
40
|
+
-- Vector index: HNSW (keep syntax compatible with current SurrealDB target)
|
|
41
41
|
DEFINE INDEX OVERWRITE idx_memory_embedding
|
|
42
42
|
ON memory FIELDS embedding
|
|
43
|
-
HNSW DIMENSION
|
|
44
|
-
DEFER;
|
|
43
|
+
HNSW DIMENSION 384 DIST COSINE;
|
|
45
44
|
|
|
46
45
|
-- Full-text index: BM25 over content + summary
|
|
47
46
|
DEFINE ANALYZER OVERWRITE suemo_analyzer
|
|
48
47
|
TOKENIZERS blank,class
|
|
49
48
|
FILTERS lowercase,snowball(english);
|
|
50
49
|
|
|
51
|
-
DEFINE INDEX OVERWRITE
|
|
52
|
-
ON memory FIELDS content
|
|
53
|
-
|
|
50
|
+
DEFINE INDEX OVERWRITE idx_memory_content_fts
|
|
51
|
+
ON memory FIELDS content
|
|
52
|
+
FULLTEXT ANALYZER suemo_analyzer BM25;
|
|
53
|
+
|
|
54
|
+
DEFINE INDEX OVERWRITE idx_memory_summary_fts
|
|
55
|
+
ON memory FIELDS summary
|
|
56
|
+
FULLTEXT ANALYZER suemo_analyzer BM25;
|
|
54
57
|
|
|
55
58
|
-- Compound indexes for timeline and filter queries
|
|
56
59
|
DEFINE INDEX OVERWRITE idx_memory_scope_time
|
|
@@ -84,7 +87,7 @@ DEFINE FIELD OVERWRITE summary ON episode TYPE option<string>;
|
|
|
84
87
|
|
|
85
88
|
-- REFERENCES: bidirectional array of memory links
|
|
86
89
|
DEFINE FIELD OVERWRITE memory_ids ON episode
|
|
87
|
-
TYPE array<record<memory>>
|
|
90
|
+
TYPE array<record<memory>> REFERENCE
|
|
88
91
|
DEFAULT [];
|
|
89
92
|
|
|
90
93
|
-- ── consolidation_run ────────────────────────────────────────────────────────
|
package/src/db/schema.ts
CHANGED
|
@@ -11,12 +11,15 @@ const log = getLogger(['suemo', 'db', 'schema'])
|
|
|
11
11
|
export async function runSchema(db: Surreal): Promise<void> {
|
|
12
12
|
log.info('Running schema migrations')
|
|
13
13
|
const statements = SCHEMA.split(/;\s*\n/).map((s) => s.trim()).filter(Boolean)
|
|
14
|
-
|
|
14
|
+
log.debug('Prepared schema statements', { count: statements.length, schemaBytes: SCHEMA.length })
|
|
15
|
+
for (const [index, stmt] of statements.entries()) {
|
|
15
16
|
try {
|
|
17
|
+
const snippet = stmt.length > 160 ? `${stmt.slice(0, 160)}…` : stmt
|
|
18
|
+
log.debug('Executing schema statement', { index, snippet })
|
|
16
19
|
await db.query(stmt)
|
|
17
|
-
log.debug('Schema statement OK', { stmt: stmt.slice(0, 60) })
|
|
20
|
+
log.debug('Schema statement OK', { index, stmt: stmt.slice(0, 60) })
|
|
18
21
|
} catch (e) {
|
|
19
|
-
log.error('Schema statement failed', { stmt, error: String(e) })
|
|
22
|
+
log.error('Schema statement failed', { index, stmt, error: String(e) })
|
|
20
23
|
throw e
|
|
21
24
|
}
|
|
22
25
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { EmbeddingProvider } from '../config.ts'
|
|
2
|
+
import { getLogger } from '../logger.ts'
|
|
3
|
+
import { embedText } from './openai-compatible.ts'
|
|
4
|
+
|
|
5
|
+
const log = getLogger(['suemo', 'embedding'])
|
|
6
|
+
|
|
7
|
+
export interface EmbeddingResult {
|
|
8
|
+
clause: string
|
|
9
|
+
param?: number[]
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function getEmbedding(
|
|
13
|
+
text: string,
|
|
14
|
+
config: EmbeddingProvider,
|
|
15
|
+
): Promise<EmbeddingResult> {
|
|
16
|
+
log.debug('getEmbedding()', { provider: config.provider, dimension: config.dimension })
|
|
17
|
+
|
|
18
|
+
switch (config.provider) {
|
|
19
|
+
case 'surreal':
|
|
20
|
+
return { clause: 'fn::embed($content)' }
|
|
21
|
+
|
|
22
|
+
case 'openai-compatible': {
|
|
23
|
+
const vec = await embedText(text, config)
|
|
24
|
+
if (vec.length !== config.dimension) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
`Embedding dimension mismatch: expected ${config.dimension}, got ${vec.length}`,
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
return { clause: '$embedding', param: vec }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
case 'stub': {
|
|
33
|
+
const vec = new Array(config.dimension).fill(0)
|
|
34
|
+
log.debug('stub embedding: returning zero vector', { dimension: config.dimension })
|
|
35
|
+
return { clause: '$embedding', param: vec }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildEmbeddingClause(config: EmbeddingProvider): string {
|
|
41
|
+
switch (config.provider) {
|
|
42
|
+
case 'surreal':
|
|
43
|
+
return 'fn::embed($content)'
|
|
44
|
+
case 'openai-compatible':
|
|
45
|
+
case 'stub':
|
|
46
|
+
return '$embedding'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getZeroVector(dimension: number): number[] {
|
|
51
|
+
return new Array(dimension).fill(0)
|
|
52
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// src/embedding/openai_compatible.ts
|
|
2
|
+
import { getLogger } from "../logger.ts";
|
|
3
|
+
|
|
4
|
+
const log = getLogger(["suemo", "embedding", "openai-compatible"]);
|
|
5
|
+
|
|
6
|
+
export interface OpenAICompatibleEmbeddingConfig {
|
|
7
|
+
url: string; // e.g. http://127.0.0.1:8080/v1/embeddings
|
|
8
|
+
model: string;
|
|
9
|
+
dimension: number;
|
|
10
|
+
apiKey?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function embedText(
|
|
14
|
+
text: string,
|
|
15
|
+
config: OpenAICompatibleEmbeddingConfig,
|
|
16
|
+
): Promise<number[]> {
|
|
17
|
+
log.debug("embedText()", { textPreview: text.slice(0, 60) });
|
|
18
|
+
|
|
19
|
+
const res = await fetch(config.url, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: {
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"Authorization": `Bearer ${config.apiKey ?? "local"}`,
|
|
24
|
+
},
|
|
25
|
+
body: JSON.stringify({ input: text, model: config.model }),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
if (!res.ok) {
|
|
29
|
+
throw new Error(`Embedding server error ${res.status}: ${await res.text()}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const json = await res.json() as {
|
|
33
|
+
data: { embedding: number[]; index: number }[];
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const vec = json.data[0]?.embedding;
|
|
37
|
+
if (!vec) throw new Error("Embedding server returned empty data");
|
|
38
|
+
if (vec.length !== config.dimension) {
|
|
39
|
+
throw new Error(`Dimension mismatch: expected ${config.dimension}, got ${vec.length}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return vec;
|
|
43
|
+
}
|
package/src/goal.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Surreal } from 'surrealdb'
|
|
2
|
+
import type { SuemoConfig } from './config.ts'
|
|
2
3
|
import { getLogger } from './logger.ts'
|
|
3
4
|
import { invalidate, observe } from './memory/write.ts'
|
|
4
5
|
import type { MemoryNode } from './types.ts'
|
|
@@ -8,10 +9,11 @@ const log = getLogger(['suemo', 'goal'])
|
|
|
8
9
|
export async function goalSet(
|
|
9
10
|
db: Surreal,
|
|
10
11
|
content: string,
|
|
12
|
+
config: SuemoConfig,
|
|
11
13
|
opts: { scope?: string; tags?: string[] } = {},
|
|
12
14
|
): Promise<MemoryNode> {
|
|
13
15
|
log.info('goalSet()', { content: content.slice(0, 60) })
|
|
14
|
-
return observe(db, { content, kind: 'goal', ...opts })
|
|
16
|
+
return observe(db, { content, kind: 'goal', ...opts }, config)
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
export async function goalResolve(db: Surreal, goalId: string): Promise<void> {
|