suemo 0.0.1
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/LICENSE +674 -0
- package/README.md +248 -0
- package/package.json +58 -0
- package/src/cli/commands/believe.ts +27 -0
- package/src/cli/commands/consolidate.ts +24 -0
- package/src/cli/commands/export-import.ts +91 -0
- package/src/cli/commands/goal.ts +71 -0
- package/src/cli/commands/health.ts +43 -0
- package/src/cli/commands/init.ts +90 -0
- package/src/cli/commands/observe.ts +40 -0
- package/src/cli/commands/query.ts +31 -0
- package/src/cli/commands/recall.ts +17 -0
- package/src/cli/commands/serve.ts +19 -0
- package/src/cli/commands/shared.ts +20 -0
- package/src/cli/commands/sync.ts +23 -0
- package/src/cli/commands/timeline.ts +37 -0
- package/src/cli/commands/wander.ts +34 -0
- package/src/cli/index.ts +41 -0
- package/src/cli/shared.ts +9 -0
- package/src/cognitive/consolidate.ts +349 -0
- package/src/cognitive/contradiction.ts +50 -0
- package/src/cognitive/health.ts +123 -0
- package/src/config.ts +114 -0
- package/src/db/client.ts +59 -0
- package/src/db/preflight.ts +152 -0
- package/src/db/schema.surql +109 -0
- package/src/db/schema.ts +24 -0
- package/src/env.d.ts +4 -0
- package/src/goal.ts +39 -0
- package/src/index.ts +13 -0
- package/src/logger.ts +60 -0
- package/src/mcp/server.ts +23 -0
- package/src/mcp/tools.ts +100 -0
- package/src/memory/episode.ts +66 -0
- package/src/memory/read.ts +223 -0
- package/src/memory/write.ts +134 -0
- package/src/sync.ts +120 -0
- package/src/types.ts +144 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { Surreal } from 'surrealdb'
|
|
2
|
+
import { checkCompatibility } from '../db/preflight.ts'
|
|
3
|
+
import { getLogger } from '../logger.ts'
|
|
4
|
+
import type { ConsolidationRun, HealthReport } from '../types.ts'
|
|
5
|
+
|
|
6
|
+
const log = getLogger(['suemo', 'cognitive', 'health'])
|
|
7
|
+
|
|
8
|
+
export async function healthReport(db: Surreal): Promise<HealthReport> {
|
|
9
|
+
log.info('healthReport()')
|
|
10
|
+
|
|
11
|
+
const [
|
|
12
|
+
totalResult,
|
|
13
|
+
activeResult,
|
|
14
|
+
consolidatedResult,
|
|
15
|
+
byKindResult,
|
|
16
|
+
byScopeResult,
|
|
17
|
+
relationCountResult,
|
|
18
|
+
activeGoalsResult,
|
|
19
|
+
frssDueResult,
|
|
20
|
+
lastRunResult,
|
|
21
|
+
compatResult,
|
|
22
|
+
] = await Promise.all([
|
|
23
|
+
// total nodes
|
|
24
|
+
db.query<[{ count: number }[]]>('SELECT count() AS count FROM memory GROUP ALL'),
|
|
25
|
+
// active nodes
|
|
26
|
+
db.query<[{ count: number }[]]>(`
|
|
27
|
+
SELECT count() AS count FROM memory
|
|
28
|
+
WHERE valid_until = NONE OR valid_until > time::now()
|
|
29
|
+
GROUP ALL
|
|
30
|
+
`),
|
|
31
|
+
// consolidated nodes
|
|
32
|
+
db.query<[{ count: number }[]]>(
|
|
33
|
+
'SELECT count() AS count FROM memory WHERE consolidated = true GROUP ALL',
|
|
34
|
+
),
|
|
35
|
+
// by kind
|
|
36
|
+
db.query<[{ kind: string; count: number }[]]>(
|
|
37
|
+
'SELECT kind, count() AS count FROM memory GROUP BY kind',
|
|
38
|
+
),
|
|
39
|
+
// by scope
|
|
40
|
+
db.query<[{ scope: string | null; count: number }[]]>(
|
|
41
|
+
'SELECT scope, count() AS count FROM memory WHERE scope != NONE GROUP BY scope',
|
|
42
|
+
),
|
|
43
|
+
// relation count
|
|
44
|
+
db.query<[{ count: number }[]]>('SELECT count() AS count FROM relates_to GROUP ALL'),
|
|
45
|
+
// active goals
|
|
46
|
+
db.query<[{ count: number }[]]>(`
|
|
47
|
+
SELECT count() AS count FROM memory
|
|
48
|
+
WHERE kind = 'goal' AND (valid_until = NONE OR valid_until > time::now())
|
|
49
|
+
GROUP ALL
|
|
50
|
+
`),
|
|
51
|
+
// FSRS due
|
|
52
|
+
db.query<[{ count: number }[]]>(`
|
|
53
|
+
SELECT count() AS count FROM memory
|
|
54
|
+
WHERE fsrs_next_review != NONE
|
|
55
|
+
AND fsrs_next_review <= time::now()
|
|
56
|
+
AND (valid_until = NONE OR valid_until > time::now())
|
|
57
|
+
GROUP ALL
|
|
58
|
+
`),
|
|
59
|
+
// last consolidation run
|
|
60
|
+
db.query<[ConsolidationRun[]]>(
|
|
61
|
+
'SELECT * FROM consolidation_run ORDER BY started_at DESC LIMIT 1',
|
|
62
|
+
),
|
|
63
|
+
// compat check (non-blocking)
|
|
64
|
+
checkCompatibility(db),
|
|
65
|
+
])
|
|
66
|
+
|
|
67
|
+
const byKind: Record<string, number> = {}
|
|
68
|
+
for (const row of byKindResult[0] ?? []) byKind[row.kind] = row.count
|
|
69
|
+
|
|
70
|
+
const byScope: Record<string, number> = {}
|
|
71
|
+
for (const row of byScopeResult[0] ?? []) {
|
|
72
|
+
if (row.scope) byScope[row.scope] = row.count
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
nodes: {
|
|
77
|
+
total: totalResult[0]?.[0]?.count ?? 0,
|
|
78
|
+
active: activeResult[0]?.[0]?.count ?? 0,
|
|
79
|
+
consolidated: consolidatedResult[0]?.[0]?.count ?? 0,
|
|
80
|
+
by_kind: byKind,
|
|
81
|
+
by_scope: byScope,
|
|
82
|
+
},
|
|
83
|
+
relations: relationCountResult[0]?.[0]?.count ?? 0,
|
|
84
|
+
goals_active: activeGoalsResult[0]?.[0]?.count ?? 0,
|
|
85
|
+
fsrs_due: frssDueResult[0]?.[0]?.count ?? 0,
|
|
86
|
+
last_consolidation: lastRunResult[0]?.[0] ?? null,
|
|
87
|
+
version_check: {
|
|
88
|
+
surreal_version: compatResult.surrealVersion,
|
|
89
|
+
surrealkv: compatResult.surrealkv,
|
|
90
|
+
retention_ok: compatResult.retention_ok,
|
|
91
|
+
},
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function vitals(db: Surreal): Promise<{
|
|
96
|
+
last10Runs: ConsolidationRun[]
|
|
97
|
+
nodesByKind: Record<string, number>
|
|
98
|
+
nodesByScope: Record<string, number>
|
|
99
|
+
}> {
|
|
100
|
+
log.info('vitals()')
|
|
101
|
+
const [runsResult, byKindResult, byScopeResult] = await Promise.all([
|
|
102
|
+
db.query<[ConsolidationRun[]]>(
|
|
103
|
+
'SELECT * FROM consolidation_run ORDER BY started_at DESC LIMIT 10',
|
|
104
|
+
),
|
|
105
|
+
db.query<[{ kind: string; count: number }[]]>(
|
|
106
|
+
'SELECT kind, count() AS count FROM memory GROUP BY kind',
|
|
107
|
+
),
|
|
108
|
+
db.query<[{ scope: string | null; count: number }[]]>(
|
|
109
|
+
'SELECT scope, count() AS count FROM memory WHERE scope != NONE GROUP BY scope',
|
|
110
|
+
),
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
const nodesByKind: Record<string, number> = {}
|
|
114
|
+
for (const r of byKindResult[0] ?? []) nodesByKind[r.kind] = r.count
|
|
115
|
+
const nodesByScope: Record<string, number> = {}
|
|
116
|
+
for (const r of byScopeResult[0] ?? []) if (r.scope) nodesByScope[r.scope] = r.count
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
last10Runs: runsResult[0] ?? [],
|
|
120
|
+
nodesByKind,
|
|
121
|
+
nodesByScope,
|
|
122
|
+
}
|
|
123
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { join, resolve } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
// ── Config shape ──────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface AuthConfig {
|
|
8
|
+
user: string
|
|
9
|
+
pass: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SurrealTarget {
|
|
13
|
+
url: string
|
|
14
|
+
namespace: string
|
|
15
|
+
database: string
|
|
16
|
+
auth: AuthConfig
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type EmbeddingProvider =
|
|
20
|
+
| { provider: 'surreal'; dimension: number }
|
|
21
|
+
| { provider: 'stub'; dimension: number } // test-only
|
|
22
|
+
|
|
23
|
+
export interface LLMConfig {
|
|
24
|
+
url: string // OpenAI-compatible endpoint
|
|
25
|
+
model: string
|
|
26
|
+
apiKey: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ConsolidationConfig {
|
|
30
|
+
trigger: 'timer' | 'reactive' | 'manual'
|
|
31
|
+
intervalMinutes: number
|
|
32
|
+
reactiveThreshold: number // unconsolidated node count before auto-trigger
|
|
33
|
+
nremSimilarityThreshold: number // cosine threshold for clustering (0.0–1.0)
|
|
34
|
+
remRelationThreshold: number // min score to create an auto-relation (0.0–1.0)
|
|
35
|
+
llm: LLMConfig
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RetrievalConfig {
|
|
39
|
+
weights: {
|
|
40
|
+
vector: number
|
|
41
|
+
bm25: number
|
|
42
|
+
graph: number
|
|
43
|
+
temporal: number
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface McpConfig {
|
|
48
|
+
port: number
|
|
49
|
+
host: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SyncConfig {
|
|
53
|
+
remote: SurrealTarget
|
|
54
|
+
cursor?: string // ISO datetime; absence means full sync on first run
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface SuemoConfig {
|
|
58
|
+
surreal: SurrealTarget
|
|
59
|
+
embedding: EmbeddingProvider
|
|
60
|
+
consolidation: ConsolidationConfig
|
|
61
|
+
retrieval: RetrievalConfig
|
|
62
|
+
mcp: McpConfig
|
|
63
|
+
sync?: SyncConfig
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── defineConfig — identity fn for type safety ─────────────────────────────
|
|
67
|
+
|
|
68
|
+
export function defineConfig(config: SuemoConfig): SuemoConfig {
|
|
69
|
+
return config
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── loadConfig — resolution chain ────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const CONFIG_CANDIDATES = [
|
|
75
|
+
'suemo.config.ts',
|
|
76
|
+
'suemo.config.js',
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
const HOME_CONFIG = join(
|
|
80
|
+
process.env.HOME ?? process.env.USERPROFILE ?? '~',
|
|
81
|
+
'.suemo',
|
|
82
|
+
'suemo.ts',
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
export async function loadConfig(
|
|
86
|
+
cwd = process.cwd(),
|
|
87
|
+
overridePath?: string,
|
|
88
|
+
): Promise<SuemoConfig> {
|
|
89
|
+
// 1. Explicit --config flag
|
|
90
|
+
if (overridePath) {
|
|
91
|
+
return importConfig(resolve(overridePath))
|
|
92
|
+
}
|
|
93
|
+
// 2. Project-local
|
|
94
|
+
for (const name of CONFIG_CANDIDATES) {
|
|
95
|
+
const p = resolve(cwd, name)
|
|
96
|
+
if (existsSync(p)) return importConfig(p)
|
|
97
|
+
}
|
|
98
|
+
// 3. User-level
|
|
99
|
+
if (existsSync(HOME_CONFIG)) return importConfig(HOME_CONFIG)
|
|
100
|
+
|
|
101
|
+
throw new Error(
|
|
102
|
+
'No suemo config found.\n'
|
|
103
|
+
+ 'Run `suemo init` to create ~/.suemo/suemo.ts, or create suemo.config.ts in the project root.',
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function importConfig(path: string): Promise<SuemoConfig> {
|
|
108
|
+
const mod = await import(pathToFileURL(path).href)
|
|
109
|
+
const cfg: unknown = mod.default ?? mod
|
|
110
|
+
if (!cfg || typeof cfg !== 'object') {
|
|
111
|
+
throw new Error(`Config at ${path} does not export a default object`)
|
|
112
|
+
}
|
|
113
|
+
return cfg as SuemoConfig // trust defineConfig() for now; add Zod parse if needed
|
|
114
|
+
}
|
package/src/db/client.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// SDK v2: requires @surrealdb/node for WebSocket support in Bun/Node runtimes.
|
|
2
|
+
// createRemoteEngines() handles ws://, wss://, http://, https:// connections.
|
|
3
|
+
// createNodeEngines() patches in the Node.js WebSocket implementation.
|
|
4
|
+
import { createNodeEngines } from '@surrealdb/node'
|
|
5
|
+
import { createRemoteEngines, Surreal } from 'surrealdb'
|
|
6
|
+
import type { SurrealTarget } from '../config.ts'
|
|
7
|
+
import { getLogger } from '../logger.ts'
|
|
8
|
+
|
|
9
|
+
const log = getLogger(['suemo', 'db', 'client'])
|
|
10
|
+
|
|
11
|
+
let _db: Surreal | null = null
|
|
12
|
+
|
|
13
|
+
export async function connect(target: SurrealTarget): Promise<Surreal> {
|
|
14
|
+
if (_db) return _db
|
|
15
|
+
|
|
16
|
+
log.info('Connecting to SurrealDB', {
|
|
17
|
+
url: target.url,
|
|
18
|
+
ns: target.namespace,
|
|
19
|
+
db: target.database,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
// v2 SDK: engines are registered at construction time, not at connect.
|
|
23
|
+
// Both remote (ws/wss/http) and Node.js engines are needed for Bun.
|
|
24
|
+
const db = new Surreal({
|
|
25
|
+
engines: {
|
|
26
|
+
...createRemoteEngines(),
|
|
27
|
+
...createNodeEngines(),
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
// v2 SDK: namespace, database, and authentication can all be passed inline
|
|
32
|
+
// to connect(). The `authentication` callback is invoked on connect and on
|
|
33
|
+
// any automatic reconnection/token-refresh cycle.
|
|
34
|
+
await db.connect(target.url, {
|
|
35
|
+
namespace: target.namespace,
|
|
36
|
+
database: target.database,
|
|
37
|
+
authentication: () => ({
|
|
38
|
+
username: target.auth.user,
|
|
39
|
+
password: target.auth.pass,
|
|
40
|
+
}),
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
_db = db
|
|
44
|
+
log.info('Connected')
|
|
45
|
+
return db
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getDb(): Surreal {
|
|
49
|
+
if (!_db) throw new Error('SurrealDB not connected. Call connect() first.')
|
|
50
|
+
return _db
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function disconnect(): Promise<void> {
|
|
54
|
+
if (_db) {
|
|
55
|
+
await _db.close()
|
|
56
|
+
_db = null
|
|
57
|
+
log.info('Disconnected')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import type { Surreal } from 'surrealdb'
|
|
2
|
+
import { getLogger } from '../logger.ts'
|
|
3
|
+
|
|
4
|
+
const log = getLogger(['suemo', 'db', 'preflight'])
|
|
5
|
+
|
|
6
|
+
export interface CompatibilityResult {
|
|
7
|
+
ok: boolean
|
|
8
|
+
surrealVersion: string
|
|
9
|
+
surrealkv: boolean
|
|
10
|
+
retention_ok: boolean // SURREAL_DATASTORE_RETENTION on server >= 90d
|
|
11
|
+
embedding: boolean
|
|
12
|
+
errors: string[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const MIN_MAJOR = 3
|
|
16
|
+
|
|
17
|
+
export async function checkCompatibility(
|
|
18
|
+
db: Surreal,
|
|
19
|
+
): Promise<CompatibilityResult> {
|
|
20
|
+
const errors: string[] = []
|
|
21
|
+
let surrealVersion = 'unknown'
|
|
22
|
+
let surrealkv = false
|
|
23
|
+
let retention_ok = false
|
|
24
|
+
let embedding = false
|
|
25
|
+
|
|
26
|
+
log.info('Running preflight compatibility checks')
|
|
27
|
+
|
|
28
|
+
// ── Check 1: version string ───────────────────────────────────────────────
|
|
29
|
+
try {
|
|
30
|
+
surrealVersion = (await db.version()).version
|
|
31
|
+
// version string: "surrealdb-3.0.4" → extract semver
|
|
32
|
+
const match = surrealVersion.match(/(\d+)\.(\d+)\.(\d+)/)
|
|
33
|
+
if (!match) {
|
|
34
|
+
errors.push(`Cannot parse version string: "${surrealVersion}"`)
|
|
35
|
+
} else {
|
|
36
|
+
const major = parseInt(match[1]!, 10)
|
|
37
|
+
if (major < MIN_MAJOR) {
|
|
38
|
+
errors.push(
|
|
39
|
+
`SurrealDB ${surrealVersion} is below the required v${MIN_MAJOR}.0.0. `
|
|
40
|
+
+ `Start suemo with: surreal start --bind 0.0.0.0:8000 surrealkv://path/to/data`,
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch (e) {
|
|
45
|
+
errors.push(`Cannot retrieve SurrealDB version: ${String(e)}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Check 2: SurrealKV (VERSION keyword probe) ────────────────────────────
|
|
49
|
+
// We CREATE a sentinel record, query it with VERSION, then DELETE it.
|
|
50
|
+
// If VERSION errors, the storage engine is not SurrealKV.
|
|
51
|
+
try {
|
|
52
|
+
await db.query('CREATE suemo_preflight:probe SET checked_at = time::now()')
|
|
53
|
+
// VERSION at a past datetime should return an empty result set (or the record
|
|
54
|
+
// if it was around then), not throw. An error means VERSION is unsupported.
|
|
55
|
+
await db.query("SELECT * FROM suemo_preflight:probe VERSION d'2020-01-01T00:00:00Z'")
|
|
56
|
+
surrealkv = true
|
|
57
|
+
} catch (e: unknown) {
|
|
58
|
+
const msg = String(e).toLowerCase()
|
|
59
|
+
if (msg.includes('version') || msg.includes('surrealkv') || msg.includes('not supported')) {
|
|
60
|
+
errors.push(
|
|
61
|
+
'VERSION queries not supported — SurrealDB must be started with SurrealKV storage. '
|
|
62
|
+
+ 'Use: surreal start surrealkv://path/to/data',
|
|
63
|
+
)
|
|
64
|
+
} else {
|
|
65
|
+
// Unexpected error — still fail but report verbatim
|
|
66
|
+
errors.push(`SurrealKV probe failed unexpectedly: ${String(e)}`)
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
try {
|
|
70
|
+
await db.query('DELETE suemo_preflight:probe')
|
|
71
|
+
} catch { /* ignore cleanup errors */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ── Check 3: SURREAL_DATASTORE_RETENTION — probe VERSION reach ──────────────
|
|
75
|
+
// We can't read env vars from the server directly via SurrealQL. Instead we
|
|
76
|
+
// probe how far back VERSION queries can reach using the sentinel record we
|
|
77
|
+
// already created above (or a second probe). If VERSION 90 days ago resolves
|
|
78
|
+
// without a "retention" error, the server has sufficient retention configured.
|
|
79
|
+
// Note: SurrealKV has no hard deletes yet — retention controls time-travel depth.
|
|
80
|
+
if (surrealkv) {
|
|
81
|
+
try {
|
|
82
|
+
const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString()
|
|
83
|
+
await db.query('CREATE suemo_retention_probe:v SET t = time::now()')
|
|
84
|
+
await db.query(`SELECT * FROM suemo_retention_probe:v VERSION d'${ninetyDaysAgo}'`)
|
|
85
|
+
retention_ok = true
|
|
86
|
+
} catch (e: unknown) {
|
|
87
|
+
const msg = String(e).toLowerCase()
|
|
88
|
+
if (msg.includes('retention') || msg.includes('version')) {
|
|
89
|
+
errors.push(
|
|
90
|
+
'SurrealDB VERSION time-travel cannot reach 90 days back. '
|
|
91
|
+
+ 'Start SurrealDB with: SURREAL_DATASTORE_RETENTION=90d surreal start surrealkv://path/to/data',
|
|
92
|
+
)
|
|
93
|
+
} else {
|
|
94
|
+
// Unexpected error — still flag but don't assume retention is wrong
|
|
95
|
+
retention_ok = true
|
|
96
|
+
log.debug('Retention probe returned unexpected error (may be fine)', { error: String(e) })
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
try {
|
|
100
|
+
await db.query('DELETE suemo_retention_probe:v')
|
|
101
|
+
} catch { /* ignore */ }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Check 4: fn::embed() resolves ────────────────────────────────────────
|
|
106
|
+
// We don't actually embed anything — we just check that the function exists.
|
|
107
|
+
// An "Unknown function" error means the embedding plugin is not configured.
|
|
108
|
+
try {
|
|
109
|
+
// fn::embed requires the ML module and a configured model.
|
|
110
|
+
// If it throws "No embedding model configured" that's acceptable — the
|
|
111
|
+
// function exists. If it throws "Unknown function 'fn::embed'" → not available.
|
|
112
|
+
await db.query(`RETURN fn::embed("suemo preflight test")`)
|
|
113
|
+
embedding = true
|
|
114
|
+
} catch (e: unknown) {
|
|
115
|
+
const msg = String(e).toLowerCase()
|
|
116
|
+
if (msg.includes('unknown function') || msg.includes('fn::embed')) {
|
|
117
|
+
errors.push(
|
|
118
|
+
'fn::embed() is not available. Configure an embedding model in SurrealDB, or '
|
|
119
|
+
+ "use embedding.provider = 'stub' in suemo config (test-only).",
|
|
120
|
+
)
|
|
121
|
+
} else {
|
|
122
|
+
// Other error (e.g. no model configured but function exists) — treat as available
|
|
123
|
+
embedding = true
|
|
124
|
+
log.debug('fn::embed() exists but returned an error (model may not be configured yet)', { error: String(e) })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const ok = errors.length === 0
|
|
129
|
+
|
|
130
|
+
if (ok) {
|
|
131
|
+
log.info('All preflight checks passed', { surrealVersion, surrealkv, retention_ok, embedding })
|
|
132
|
+
} else {
|
|
133
|
+
log.error('Preflight checks failed', { errors })
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { ok, surrealVersion, surrealkv, retention_ok, embedding, errors }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Hard-exit variant. Call in CLI commands; throw in MCP server startup.
|
|
141
|
+
*/
|
|
142
|
+
export async function requireCompatibility(db: Surreal): Promise<void> {
|
|
143
|
+
const result = await checkCompatibility(db)
|
|
144
|
+
if (!result.ok) {
|
|
145
|
+
console.error('\n[suemo] Compatibility check failed:\n')
|
|
146
|
+
for (const err of result.errors) {
|
|
147
|
+
console.error(` ✗ ${err}`)
|
|
148
|
+
}
|
|
149
|
+
console.error('\nFix the issues above and retry.\n')
|
|
150
|
+
process.exit(1)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
-- suemo schema — SurrealDB v3.0 + SurrealKV
|
|
3
|
+
-- All DEFINE statements use OVERWRITE so this is idempotent.
|
|
4
|
+
-- ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
|
|
6
|
+
-- ── memory ───────────────────────────────────────────────────────────────────
|
|
7
|
+
DEFINE TABLE OVERWRITE memory SCHEMAFULL;
|
|
8
|
+
|
|
9
|
+
DEFINE FIELD OVERWRITE kind ON memory TYPE string
|
|
10
|
+
ASSERT $value INSIDE ['observation','belief','question','hypothesis','goal'];
|
|
11
|
+
|
|
12
|
+
DEFINE FIELD OVERWRITE content ON memory TYPE string;
|
|
13
|
+
DEFINE FIELD OVERWRITE summary ON memory TYPE option<string>;
|
|
14
|
+
DEFINE FIELD OVERWRITE tags ON memory TYPE set<string> DEFAULT {};
|
|
15
|
+
DEFINE FIELD OVERWRITE scope ON memory TYPE option<string>;
|
|
16
|
+
DEFINE FIELD OVERWRITE embedding ON memory TYPE array<float> DEFAULT [];
|
|
17
|
+
DEFINE FIELD OVERWRITE confidence ON memory TYPE float DEFAULT 1.0;
|
|
18
|
+
DEFINE FIELD OVERWRITE salience ON memory TYPE float DEFAULT 0.5;
|
|
19
|
+
|
|
20
|
+
-- Temporal: valid_from always set by system at write time
|
|
21
|
+
-- valid_until: set by system on contradiction detection or explicit invalidate()
|
|
22
|
+
-- Agents NEVER supply either field.
|
|
23
|
+
DEFINE FIELD OVERWRITE valid_from ON memory TYPE datetime DEFAULT time::now();
|
|
24
|
+
DEFINE FIELD OVERWRITE valid_until ON memory TYPE option<datetime> DEFAULT NONE;
|
|
25
|
+
|
|
26
|
+
DEFINE FIELD OVERWRITE source ON memory TYPE option<string>;
|
|
27
|
+
DEFINE FIELD OVERWRITE created_at ON memory TYPE datetime DEFAULT time::now();
|
|
28
|
+
DEFINE FIELD OVERWRITE updated_at ON memory TYPE datetime DEFAULT time::now();
|
|
29
|
+
DEFINE FIELD OVERWRITE consolidated ON memory TYPE bool DEFAULT false;
|
|
30
|
+
|
|
31
|
+
-- REFERENCES: bidirectional record link (SurrealDB v3.0)
|
|
32
|
+
DEFINE FIELD OVERWRITE consolidated_into ON memory
|
|
33
|
+
TYPE option<record<memory>> REFERENCES;
|
|
34
|
+
|
|
35
|
+
-- FSRS spaced-repetition fields (all optional; populated on first recall())
|
|
36
|
+
DEFINE FIELD OVERWRITE fsrs_stability ON memory TYPE option<float>;
|
|
37
|
+
DEFINE FIELD OVERWRITE fsrs_difficulty ON memory TYPE option<float>;
|
|
38
|
+
DEFINE FIELD OVERWRITE fsrs_next_review ON memory TYPE option<datetime>;
|
|
39
|
+
|
|
40
|
+
-- Vector index: HNSW, DEFER so init does not block on large existing datasets
|
|
41
|
+
DEFINE INDEX OVERWRITE idx_memory_embedding
|
|
42
|
+
ON memory FIELDS embedding
|
|
43
|
+
HNSW DIMENSION 1536 DIST COSINE
|
|
44
|
+
DEFER;
|
|
45
|
+
|
|
46
|
+
-- Full-text index: BM25 over content + summary
|
|
47
|
+
DEFINE ANALYZER OVERWRITE suemo_analyzer
|
|
48
|
+
TOKENIZERS blank,class
|
|
49
|
+
FILTERS lowercase,snowball(english);
|
|
50
|
+
|
|
51
|
+
DEFINE INDEX OVERWRITE idx_memory_fts
|
|
52
|
+
ON memory FIELDS content, summary
|
|
53
|
+
SEARCH ANALYZER suemo_analyzer BM25;
|
|
54
|
+
|
|
55
|
+
-- Compound indexes for timeline and filter queries
|
|
56
|
+
DEFINE INDEX OVERWRITE idx_memory_scope_time
|
|
57
|
+
ON memory FIELDS scope, created_at;
|
|
58
|
+
|
|
59
|
+
DEFINE INDEX OVERWRITE idx_memory_kind_valid
|
|
60
|
+
ON memory FIELDS kind, valid_until;
|
|
61
|
+
|
|
62
|
+
DEFINE INDEX OVERWRITE idx_memory_salience
|
|
63
|
+
ON memory FIELDS salience;
|
|
64
|
+
|
|
65
|
+
-- ── relates_to ───────────────────────────────────────────────────────────────
|
|
66
|
+
DEFINE TABLE OVERWRITE relates_to SCHEMAFULL
|
|
67
|
+
TYPE RELATION IN memory OUT memory;
|
|
68
|
+
|
|
69
|
+
DEFINE FIELD OVERWRITE kind ON relates_to TYPE string
|
|
70
|
+
ASSERT $value INSIDE ['supports','contradicts','derived_from','caused_by','similar_to','updates','sequential'];
|
|
71
|
+
|
|
72
|
+
DEFINE FIELD OVERWRITE strength ON relates_to TYPE float DEFAULT 0.5;
|
|
73
|
+
DEFINE FIELD OVERWRITE valid_from ON relates_to TYPE datetime DEFAULT time::now();
|
|
74
|
+
DEFINE FIELD OVERWRITE valid_until ON relates_to TYPE option<datetime> DEFAULT NONE;
|
|
75
|
+
DEFINE FIELD OVERWRITE created_at ON relates_to TYPE datetime DEFAULT time::now();
|
|
76
|
+
|
|
77
|
+
-- ── episode ───────────────────────────────────────────────────────────────────
|
|
78
|
+
DEFINE TABLE OVERWRITE episode SCHEMAFULL;
|
|
79
|
+
|
|
80
|
+
DEFINE FIELD OVERWRITE session_id ON episode TYPE string;
|
|
81
|
+
DEFINE FIELD OVERWRITE started_at ON episode TYPE datetime DEFAULT time::now();
|
|
82
|
+
DEFINE FIELD OVERWRITE ended_at ON episode TYPE option<datetime> DEFAULT NONE;
|
|
83
|
+
DEFINE FIELD OVERWRITE summary ON episode TYPE option<string>;
|
|
84
|
+
|
|
85
|
+
-- REFERENCES: bidirectional array of memory links
|
|
86
|
+
DEFINE FIELD OVERWRITE memory_ids ON episode
|
|
87
|
+
TYPE array<record<memory>> REFERENCES
|
|
88
|
+
DEFAULT [];
|
|
89
|
+
|
|
90
|
+
-- ── consolidation_run ────────────────────────────────────────────────────────
|
|
91
|
+
DEFINE TABLE OVERWRITE consolidation_run SCHEMAFULL;
|
|
92
|
+
|
|
93
|
+
DEFINE FIELD OVERWRITE started_at ON consolidation_run TYPE datetime DEFAULT time::now();
|
|
94
|
+
DEFINE FIELD OVERWRITE completed_at ON consolidation_run TYPE option<datetime> DEFAULT NONE;
|
|
95
|
+
DEFINE FIELD OVERWRITE phase ON consolidation_run TYPE string
|
|
96
|
+
ASSERT $value INSIDE ['nrem','rem','full'];
|
|
97
|
+
DEFINE FIELD OVERWRITE nodes_in ON consolidation_run TYPE int DEFAULT 0;
|
|
98
|
+
DEFINE FIELD OVERWRITE nodes_out ON consolidation_run TYPE int DEFAULT 0;
|
|
99
|
+
DEFINE FIELD OVERWRITE status ON consolidation_run TYPE string
|
|
100
|
+
ASSERT $value INSIDE ['running','done','failed'];
|
|
101
|
+
DEFINE FIELD OVERWRITE error ON consolidation_run TYPE option<string> DEFAULT NONE;
|
|
102
|
+
|
|
103
|
+
-- ── sync_cursor ───────────────────────────────────────────────────────────────
|
|
104
|
+
-- One record per (remote.url, remote.ns, remote.db) triple.
|
|
105
|
+
-- Stores the last successfully synced created_at timestamp.
|
|
106
|
+
DEFINE TABLE OVERWRITE sync_cursor SCHEMAFULL;
|
|
107
|
+
DEFINE FIELD OVERWRITE remote_key ON sync_cursor TYPE string; -- sha1(url+ns+db)
|
|
108
|
+
DEFINE FIELD OVERWRITE cursor ON sync_cursor TYPE datetime;
|
|
109
|
+
DEFINE FIELD OVERWRITE last_synced ON sync_cursor TYPE datetime DEFAULT time::now();
|
package/src/db/schema.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Surreal } from 'surrealdb'
|
|
2
|
+
import { getLogger } from '../logger.ts'
|
|
3
|
+
|
|
4
|
+
import SCHEMA from './schema.surql' with { type: 'text' }
|
|
5
|
+
|
|
6
|
+
const log = getLogger(['suemo', 'db', 'schema'])
|
|
7
|
+
|
|
8
|
+
// schema.surql is inlined as a template string to avoid filesystem read concerns
|
|
9
|
+
// at runtime. Each statement is separated by ";\n" and executed individually.
|
|
10
|
+
|
|
11
|
+
export async function runSchema(db: Surreal): Promise<void> {
|
|
12
|
+
log.info('Running schema migrations')
|
|
13
|
+
const statements = SCHEMA.split(/;\s*\n/).map((s) => s.trim()).filter(Boolean)
|
|
14
|
+
for (const stmt of statements) {
|
|
15
|
+
try {
|
|
16
|
+
await db.query(stmt)
|
|
17
|
+
log.debug('Schema statement OK', { stmt: stmt.slice(0, 60) })
|
|
18
|
+
} catch (e) {
|
|
19
|
+
log.error('Schema statement failed', { stmt, error: String(e) })
|
|
20
|
+
throw e
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
log.info('Schema ready')
|
|
24
|
+
}
|
package/src/env.d.ts
ADDED
package/src/goal.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Surreal } from 'surrealdb'
|
|
2
|
+
import { getLogger } from './logger.ts'
|
|
3
|
+
import { invalidate, observe } from './memory/write.ts'
|
|
4
|
+
import type { MemoryNode } from './types.ts'
|
|
5
|
+
|
|
6
|
+
const log = getLogger(['suemo', 'goal'])
|
|
7
|
+
|
|
8
|
+
export async function goalSet(
|
|
9
|
+
db: Surreal,
|
|
10
|
+
content: string,
|
|
11
|
+
opts: { scope?: string; tags?: string[] } = {},
|
|
12
|
+
): Promise<MemoryNode> {
|
|
13
|
+
log.info('goalSet()', { content: content.slice(0, 60) })
|
|
14
|
+
return observe(db, { content, kind: 'goal', ...opts })
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function goalResolve(db: Surreal, goalId: string): Promise<void> {
|
|
18
|
+
log.info('goalResolve()', { goalId })
|
|
19
|
+
await invalidate(db, goalId, 'resolved')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function goalList(
|
|
23
|
+
db: Surreal,
|
|
24
|
+
opts: { scope?: string; includeResolved?: boolean } = {},
|
|
25
|
+
): Promise<MemoryNode[]> {
|
|
26
|
+
log.info('goalList()', opts)
|
|
27
|
+
const filter = opts.includeResolved
|
|
28
|
+
? "kind = 'goal'"
|
|
29
|
+
: "kind = 'goal' AND (valid_until = NONE OR valid_until > time::now())"
|
|
30
|
+
return db.query<[MemoryNode[]]>(
|
|
31
|
+
`
|
|
32
|
+
SELECT * FROM memory
|
|
33
|
+
WHERE ${filter}
|
|
34
|
+
AND ($scope = NONE OR scope = $scope)
|
|
35
|
+
ORDER BY created_at DESC
|
|
36
|
+
`,
|
|
37
|
+
{ scope: opts.scope ?? null },
|
|
38
|
+
).then((r) => r[0] ?? [])
|
|
39
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// src/index.ts — public API surface
|
|
2
|
+
export { defineConfig, loadConfig } from './config.ts'
|
|
3
|
+
export type {
|
|
4
|
+
AuthConfig,
|
|
5
|
+
ConsolidationConfig,
|
|
6
|
+
EmbeddingProvider,
|
|
7
|
+
LLMConfig,
|
|
8
|
+
McpConfig,
|
|
9
|
+
RetrievalConfig,
|
|
10
|
+
SuemoConfig,
|
|
11
|
+
SurrealTarget,
|
|
12
|
+
SyncConfig,
|
|
13
|
+
} from './config.ts'
|