suemo 0.0.2 → 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 +100 -19
- package/package.json +1 -1
- package/src/cli/commands/export-import.ts +26 -1
- package/src/cli/commands/health.ts +18 -1
- package/src/cli/commands/observe.ts +1 -0
- package/src/cli/commands/serve.ts +73 -1
- package/src/cli/commands/sync.ts +31 -6
- package/src/cli/commands/wander.ts +4 -0
- package/src/cognitive/consolidate.ts +8 -17
- package/src/cognitive/contradiction.ts +5 -11
- package/src/cognitive/health.ts +61 -1
- package/src/config.template.ts +22 -0
- package/src/config.ts +87 -6
- package/src/db/preflight.ts +6 -2
- package/src/db/schema.surql +19 -1
- package/src/db/schema.ts +56 -8
- package/src/embedding/index.ts +2 -2
- package/src/index.ts +5 -1
- package/src/mcp/dispatch.ts +105 -7
- package/src/mcp/server.ts +127 -4
- package/src/mcp/stdio.ts +98 -9
- package/src/mcp/tools.ts +6 -2
- package/src/memory/episode.ts +98 -0
- package/src/memory/read.ts +64 -43
- package/src/memory/write.ts +211 -16
- package/src/sync.ts +310 -66
- package/src/types.ts +31 -6
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: '
|
|
22
|
+
| { provider: 'surrealml'; dimension: number }
|
|
23
23
|
| { provider: 'stub'; dimension: number }
|
|
24
24
|
|
|
25
25
|
export interface LLMConfig {
|
|
@@ -51,9 +51,41 @@ export interface McpConfig {
|
|
|
51
51
|
host: string
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
export type SyncDirectionConfig = 'push' | 'pull' | 'both'
|
|
55
|
+
|
|
56
|
+
export interface SyncAutoConfig {
|
|
57
|
+
enabled?: boolean
|
|
58
|
+
intervalSeconds?: number
|
|
59
|
+
direction?: SyncDirectionConfig
|
|
60
|
+
remote?: string
|
|
61
|
+
onWrite?: boolean
|
|
62
|
+
minWriteIntervalSeconds?: number
|
|
63
|
+
}
|
|
64
|
+
|
|
54
65
|
export interface SyncConfig {
|
|
55
|
-
remote
|
|
56
|
-
|
|
66
|
+
/** Legacy single-remote config (still supported for migration safety). */
|
|
67
|
+
remote?: SurrealTarget
|
|
68
|
+
/** Named remotes for selectable sync targets. */
|
|
69
|
+
remotes?: Record<string, SurrealTarget>
|
|
70
|
+
/** Default key from `remotes` when no explicit remote is selected. */
|
|
71
|
+
defaultRemote?: string
|
|
72
|
+
/** Optional auto-sync behavior for long-running MCP servers. */
|
|
73
|
+
auto?: SyncAutoConfig
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ResolvedSyncAutoConfig {
|
|
77
|
+
enabled: boolean
|
|
78
|
+
intervalSeconds: number
|
|
79
|
+
direction: SyncDirectionConfig
|
|
80
|
+
remote: string
|
|
81
|
+
onWrite: boolean
|
|
82
|
+
minWriteIntervalSeconds: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ResolvedSyncConfig {
|
|
86
|
+
remotes: Record<string, SurrealTarget>
|
|
87
|
+
defaultRemote: string
|
|
88
|
+
auto: ResolvedSyncAutoConfig
|
|
57
89
|
}
|
|
58
90
|
|
|
59
91
|
export interface SuemoConfig {
|
|
@@ -71,6 +103,55 @@ export function defineConfig(config: SuemoConfig): SuemoConfig {
|
|
|
71
103
|
return config
|
|
72
104
|
}
|
|
73
105
|
|
|
106
|
+
export function resolveSyncConfig(config: SuemoConfig): ResolvedSyncConfig | null {
|
|
107
|
+
if (!config.sync) return null
|
|
108
|
+
|
|
109
|
+
const remotes: Record<string, SurrealTarget> = config.sync.remotes
|
|
110
|
+
? { ...config.sync.remotes }
|
|
111
|
+
: (config.sync.remote ? { default: config.sync.remote } : {})
|
|
112
|
+
|
|
113
|
+
const remoteNames = Object.keys(remotes)
|
|
114
|
+
if (remoteNames.length === 0) return null
|
|
115
|
+
|
|
116
|
+
const defaultRemote = config.sync.defaultRemote ?? remoteNames[0]!
|
|
117
|
+
if (!remotes[defaultRemote]) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`sync.defaultRemote \"${defaultRemote}\" does not exist in sync.remotes. Available: ${remoteNames.join(', ')}`,
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const auto = config.sync.auto ?? {}
|
|
124
|
+
const intervalSeconds = auto.intervalSeconds ?? 300
|
|
125
|
+
const minWriteIntervalSeconds = auto.minWriteIntervalSeconds ?? 30
|
|
126
|
+
|
|
127
|
+
if (!Number.isFinite(intervalSeconds) || intervalSeconds <= 0) {
|
|
128
|
+
throw new Error('sync.auto.intervalSeconds must be a positive number')
|
|
129
|
+
}
|
|
130
|
+
if (!Number.isFinite(minWriteIntervalSeconds) || minWriteIntervalSeconds < 0) {
|
|
131
|
+
throw new Error('sync.auto.minWriteIntervalSeconds must be >= 0')
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const autoRemote = auto.remote ?? defaultRemote
|
|
135
|
+
if (!remotes[autoRemote]) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`sync.auto.remote \"${autoRemote}\" does not exist in sync.remotes. Available: ${remoteNames.join(', ')}`,
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
remotes,
|
|
143
|
+
defaultRemote,
|
|
144
|
+
auto: {
|
|
145
|
+
enabled: auto.enabled ?? false,
|
|
146
|
+
intervalSeconds,
|
|
147
|
+
direction: auto.direction ?? 'push',
|
|
148
|
+
remote: autoRemote,
|
|
149
|
+
onWrite: auto.onWrite ?? false,
|
|
150
|
+
minWriteIntervalSeconds,
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
74
155
|
// ── loadConfig — resolution chain ────────────────────────────────────────────
|
|
75
156
|
|
|
76
157
|
const CONFIG_CANDIDATES = [
|
|
@@ -135,9 +216,9 @@ export async function loadConfig(
|
|
|
135
216
|
async function importConfig(path: string): Promise<SuemoConfig> {
|
|
136
217
|
log.debug('Importing config module', { path })
|
|
137
218
|
const mod = await import(pathToFileURL(path).href)
|
|
138
|
-
const
|
|
139
|
-
if (!
|
|
219
|
+
const cfgRaw: unknown = mod.default ?? mod
|
|
220
|
+
if (!cfgRaw || typeof cfgRaw !== 'object') {
|
|
140
221
|
throw new Error(`Config at ${path} does not export a default object`)
|
|
141
222
|
}
|
|
142
|
-
return
|
|
223
|
+
return cfgRaw as SuemoConfig // trust defineConfig() for now; add Zod parse if needed
|
|
143
224
|
}
|
package/src/db/preflight.ts
CHANGED
|
@@ -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('
|
|
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('
|
|
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
|
}
|
package/src/db/schema.surql
CHANGED
|
@@ -13,6 +13,7 @@ DEFINE FIELD OVERWRITE content ON memory TYPE string;
|
|
|
13
13
|
DEFINE FIELD OVERWRITE summary ON memory TYPE option<string>;
|
|
14
14
|
DEFINE FIELD OVERWRITE tags ON memory TYPE set<string> DEFAULT {};
|
|
15
15
|
DEFINE FIELD OVERWRITE scope ON memory TYPE option<string>;
|
|
16
|
+
DEFINE FIELD OVERWRITE topic_key ON memory TYPE option<string> DEFAULT NONE;
|
|
16
17
|
DEFINE FIELD OVERWRITE embedding ON memory TYPE array<float> DEFAULT [];
|
|
17
18
|
DEFINE FIELD OVERWRITE confidence ON memory TYPE float DEFAULT 1.0;
|
|
18
19
|
DEFINE FIELD OVERWRITE salience ON memory TYPE float DEFAULT 0.5;
|
|
@@ -24,6 +25,7 @@ DEFINE FIELD OVERWRITE valid_from ON memory TYPE datetime DEFAULT time::
|
|
|
24
25
|
DEFINE FIELD OVERWRITE valid_until ON memory TYPE option<datetime> DEFAULT NONE;
|
|
25
26
|
|
|
26
27
|
DEFINE FIELD OVERWRITE source ON memory TYPE option<string>;
|
|
28
|
+
DEFINE FIELD OVERWRITE prompt_source ON memory TYPE option<record<memory>> DEFAULT NONE;
|
|
27
29
|
DEFINE FIELD OVERWRITE created_at ON memory TYPE datetime DEFAULT time::now();
|
|
28
30
|
DEFINE FIELD OVERWRITE updated_at ON memory TYPE datetime DEFAULT time::now();
|
|
29
31
|
DEFINE FIELD OVERWRITE consolidated ON memory TYPE bool DEFAULT false;
|
|
@@ -65,6 +67,9 @@ DEFINE INDEX OVERWRITE idx_memory_kind_valid
|
|
|
65
67
|
DEFINE INDEX OVERWRITE idx_memory_salience
|
|
66
68
|
ON memory FIELDS salience;
|
|
67
69
|
|
|
70
|
+
DEFINE INDEX OVERWRITE idx_memory_topic_key
|
|
71
|
+
ON memory FIELDS topic_key;
|
|
72
|
+
|
|
68
73
|
-- ── relates_to ───────────────────────────────────────────────────────────────
|
|
69
74
|
DEFINE TABLE OVERWRITE relates_to SCHEMAFULL
|
|
70
75
|
TYPE RELATION IN memory OUT memory;
|
|
@@ -76,6 +81,7 @@ DEFINE FIELD OVERWRITE strength ON relates_to TYPE float DEFAULT 0.5;
|
|
|
76
81
|
DEFINE FIELD OVERWRITE valid_from ON relates_to TYPE datetime DEFAULT time::now();
|
|
77
82
|
DEFINE FIELD OVERWRITE valid_until ON relates_to TYPE option<datetime> DEFAULT NONE;
|
|
78
83
|
DEFINE FIELD OVERWRITE created_at ON relates_to TYPE datetime DEFAULT time::now();
|
|
84
|
+
DEFINE FIELD OVERWRITE updated_at ON relates_to TYPE datetime DEFAULT time::now();
|
|
79
85
|
|
|
80
86
|
-- ── episode ───────────────────────────────────────────────────────────────────
|
|
81
87
|
DEFINE TABLE OVERWRITE episode SCHEMAFULL;
|
|
@@ -84,6 +90,7 @@ DEFINE FIELD OVERWRITE session_id ON episode TYPE string;
|
|
|
84
90
|
DEFINE FIELD OVERWRITE started_at ON episode TYPE datetime DEFAULT time::now();
|
|
85
91
|
DEFINE FIELD OVERWRITE ended_at ON episode TYPE option<datetime> DEFAULT NONE;
|
|
86
92
|
DEFINE FIELD OVERWRITE summary ON episode TYPE option<string>;
|
|
93
|
+
DEFINE FIELD OVERWRITE context ON episode TYPE option<object> FLEXIBLE DEFAULT NONE;
|
|
87
94
|
|
|
88
95
|
-- REFERENCES: bidirectional array of memory links
|
|
89
96
|
DEFINE FIELD OVERWRITE memory_ids ON episode
|
|
@@ -105,8 +112,19 @@ DEFINE FIELD OVERWRITE error ON consolidation_run TYPE option<string> DEF
|
|
|
105
112
|
|
|
106
113
|
-- ── sync_cursor ───────────────────────────────────────────────────────────────
|
|
107
114
|
-- One record per (remote.url, remote.ns, remote.db) triple.
|
|
108
|
-
-- Stores
|
|
115
|
+
-- Stores per-remote cursors for push/pull sync based on updated_at.
|
|
109
116
|
DEFINE TABLE OVERWRITE sync_cursor SCHEMAFULL;
|
|
110
117
|
DEFINE FIELD OVERWRITE remote_key ON sync_cursor TYPE string; -- sha1(url+ns+db)
|
|
111
118
|
DEFINE FIELD OVERWRITE cursor ON sync_cursor TYPE datetime;
|
|
119
|
+
DEFINE FIELD OVERWRITE push_cursor ON sync_cursor TYPE datetime DEFAULT d'1970-01-01T00:00:00Z';
|
|
120
|
+
DEFINE FIELD OVERWRITE pull_cursor ON sync_cursor TYPE datetime DEFAULT d'1970-01-01T00:00:00Z';
|
|
112
121
|
DEFINE FIELD OVERWRITE last_synced ON sync_cursor TYPE datetime DEFAULT time::now();
|
|
122
|
+
|
|
123
|
+
-- ── suemo_stats ──────────────────────────────────────────────────────────────
|
|
124
|
+
DEFINE TABLE OVERWRITE suemo_stats SCHEMAFULL;
|
|
125
|
+
DEFINE FIELD OVERWRITE ns_db ON suemo_stats TYPE string;
|
|
126
|
+
DEFINE FIELD OVERWRITE total_writes ON suemo_stats TYPE int DEFAULT 0;
|
|
127
|
+
DEFINE FIELD OVERWRITE total_queries ON suemo_stats TYPE int DEFAULT 0;
|
|
128
|
+
DEFINE FIELD OVERWRITE last_write ON suemo_stats TYPE option<datetime> DEFAULT NONE;
|
|
129
|
+
DEFINE FIELD OVERWRITE last_query ON suemo_stats TYPE option<datetime> DEFAULT NONE;
|
|
130
|
+
DEFINE INDEX OVERWRITE idx_stats_ns ON suemo_stats FIELDS ns_db;
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
}
|
package/src/embedding/index.ts
CHANGED
|
@@ -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 '
|
|
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 '
|
|
42
|
+
case 'surrealml':
|
|
43
43
|
return 'fn::embed($content)'
|
|
44
44
|
case 'openai-compatible':
|
|
45
45
|
case 'stub':
|
package/src/index.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
// src/index.ts — public API surface
|
|
2
|
-
export { defineConfig, loadConfig } from './config.ts'
|
|
2
|
+
export { defineConfig, loadConfig, resolveSyncConfig } from './config.ts'
|
|
3
3
|
export type {
|
|
4
4
|
AuthConfig,
|
|
5
5
|
ConsolidationConfig,
|
|
6
6
|
EmbeddingProvider,
|
|
7
7
|
LLMConfig,
|
|
8
8
|
McpConfig,
|
|
9
|
+
ResolvedSyncAutoConfig,
|
|
10
|
+
ResolvedSyncConfig,
|
|
9
11
|
RetrievalConfig,
|
|
10
12
|
SuemoConfig,
|
|
11
13
|
SurrealTarget,
|
|
14
|
+
SyncAutoConfig,
|
|
12
15
|
SyncConfig,
|
|
16
|
+
SyncDirectionConfig,
|
|
13
17
|
} from './config.ts'
|
package/src/mcp/dispatch.ts
CHANGED
|
@@ -1,28 +1,54 @@
|
|
|
1
1
|
import type { Surreal } from 'surrealdb'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { consolidate } from '../cognitive/consolidate.ts'
|
|
4
|
-
import { healthReport, vitals } from '../cognitive/health.ts'
|
|
4
|
+
import { healthReport, suemoStats, vitals } from '../cognitive/health.ts'
|
|
5
5
|
import type { SuemoConfig } from '../config.ts'
|
|
6
6
|
import { goalList, goalResolve, goalSet } from '../goal.ts'
|
|
7
7
|
import { getLogger } from '../logger.ts'
|
|
8
|
-
import { episodeEnd, episodeStart } from '../memory/episode.ts'
|
|
8
|
+
import { episodeEnd, episodeStart, getSessionContext, setSessionContext } from '../memory/episode.ts'
|
|
9
9
|
import { query, recall, timeline, wander } from '../memory/read.ts'
|
|
10
|
-
import { believe, invalidate, observe } from '../memory/write.ts'
|
|
10
|
+
import { believe, capturePrompt, invalidate, observe, upsertByKey } from '../memory/write.ts'
|
|
11
11
|
import { ObserveInputSchema, QueryInputSchema } from '../types.ts'
|
|
12
12
|
|
|
13
13
|
const log = getLogger(['suemo', 'mcp', 'dispatch'])
|
|
14
14
|
|
|
15
|
+
const MUTATING_TOOLS = new Set([
|
|
16
|
+
'observe',
|
|
17
|
+
'believe',
|
|
18
|
+
'invalidate',
|
|
19
|
+
'goal_set',
|
|
20
|
+
'goal_resolve',
|
|
21
|
+
'upsert_by_key',
|
|
22
|
+
'capture_prompt',
|
|
23
|
+
'session_context_set',
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
interface DispatchOptions {
|
|
27
|
+
onMutation?: (tool: string) => Promise<void>
|
|
28
|
+
}
|
|
29
|
+
|
|
15
30
|
export async function handleToolCall(
|
|
16
31
|
db: Surreal,
|
|
17
32
|
config: SuemoConfig,
|
|
18
33
|
method: string,
|
|
19
34
|
params: Record<string, unknown>,
|
|
35
|
+
opts: DispatchOptions = {},
|
|
20
36
|
): Promise<unknown> {
|
|
21
37
|
log.debug('Dispatching MCP tool call', { method, paramKeys: Object.keys(params) })
|
|
22
38
|
|
|
39
|
+
const maybeTriggerMutationSync = (): void => {
|
|
40
|
+
if (!MUTATING_TOOLS.has(method) || !opts.onMutation) return
|
|
41
|
+
void opts.onMutation(method).catch((error) => {
|
|
42
|
+
log.warning('Post-mutation hook failed', { method, error: String(error) })
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
23
46
|
switch (method) {
|
|
24
|
-
case 'observe':
|
|
25
|
-
|
|
47
|
+
case 'observe': {
|
|
48
|
+
const result = await observe(db, ObserveInputSchema.parse(params), config)
|
|
49
|
+
maybeTriggerMutationSync()
|
|
50
|
+
return result
|
|
51
|
+
}
|
|
26
52
|
|
|
27
53
|
case 'believe': {
|
|
28
54
|
const parsed = z
|
|
@@ -32,12 +58,15 @@ export async function handleToolCall(
|
|
|
32
58
|
confidence: z.number().optional(),
|
|
33
59
|
})
|
|
34
60
|
.parse(params)
|
|
35
|
-
|
|
61
|
+
const result = await believe(db, parsed, config)
|
|
62
|
+
maybeTriggerMutationSync()
|
|
63
|
+
return result
|
|
36
64
|
}
|
|
37
65
|
|
|
38
66
|
case 'invalidate': {
|
|
39
67
|
const parsed = z.object({ nodeId: z.string(), reason: z.string().optional() }).parse(params)
|
|
40
68
|
await invalidate(db, parsed.nodeId, parsed.reason)
|
|
69
|
+
maybeTriggerMutationSync()
|
|
41
70
|
return { ok: true }
|
|
42
71
|
}
|
|
43
72
|
|
|
@@ -91,15 +120,18 @@ export async function handleToolCall(
|
|
|
91
120
|
const parsed = z
|
|
92
121
|
.object({ content: z.string(), scope: z.string().optional(), tags: z.array(z.string()).optional() })
|
|
93
122
|
.parse(params)
|
|
94
|
-
|
|
123
|
+
const result = await goalSet(db, parsed.content, config, {
|
|
95
124
|
...(parsed.scope ? { scope: parsed.scope } : {}),
|
|
96
125
|
tags: parsed.tags ?? [],
|
|
97
126
|
})
|
|
127
|
+
maybeTriggerMutationSync()
|
|
128
|
+
return result
|
|
98
129
|
}
|
|
99
130
|
|
|
100
131
|
case 'goal_resolve': {
|
|
101
132
|
const parsed = z.object({ goalId: z.string() }).parse(params)
|
|
102
133
|
await goalResolve(db, parsed.goalId)
|
|
134
|
+
maybeTriggerMutationSync()
|
|
103
135
|
return { ok: true }
|
|
104
136
|
}
|
|
105
137
|
|
|
@@ -114,6 +146,9 @@ export async function handleToolCall(
|
|
|
114
146
|
case 'health':
|
|
115
147
|
return healthReport(db)
|
|
116
148
|
|
|
149
|
+
case 'stats':
|
|
150
|
+
return suemoStats(db)
|
|
151
|
+
|
|
117
152
|
case 'vitals':
|
|
118
153
|
return vitals(db)
|
|
119
154
|
|
|
@@ -128,6 +163,69 @@ export async function handleToolCall(
|
|
|
128
163
|
})
|
|
129
164
|
}
|
|
130
165
|
|
|
166
|
+
case 'upsert_by_key': {
|
|
167
|
+
const parsed = z
|
|
168
|
+
.object({
|
|
169
|
+
topicKey: z.string(),
|
|
170
|
+
content: z.string(),
|
|
171
|
+
scope: z.string().optional(),
|
|
172
|
+
tags: z.array(z.string()).optional(),
|
|
173
|
+
confidence: z.number().optional(),
|
|
174
|
+
source: z.string().optional(),
|
|
175
|
+
sessionId: z.string().optional(),
|
|
176
|
+
kind: z.enum(['observation', 'belief', 'question', 'hypothesis', 'goal']).optional(),
|
|
177
|
+
})
|
|
178
|
+
.parse(params)
|
|
179
|
+
const result = await upsertByKey(db, config, parsed.topicKey, parsed.content, {
|
|
180
|
+
...(parsed.scope ? { scope: parsed.scope } : {}),
|
|
181
|
+
tags: parsed.tags ?? [],
|
|
182
|
+
...(parsed.source ? { source: parsed.source } : {}),
|
|
183
|
+
...(parsed.sessionId ? { sessionId: parsed.sessionId } : {}),
|
|
184
|
+
confidence: parsed.confidence ?? 1.0,
|
|
185
|
+
...(parsed.kind ? { kind: parsed.kind } : {}),
|
|
186
|
+
})
|
|
187
|
+
maybeTriggerMutationSync()
|
|
188
|
+
return result
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case 'capture_prompt': {
|
|
192
|
+
const parsed = z
|
|
193
|
+
.object({
|
|
194
|
+
prompt: z.string(),
|
|
195
|
+
derivedIds: z.array(z.string()).optional(),
|
|
196
|
+
scope: z.string().optional(),
|
|
197
|
+
sessionId: z.string().optional(),
|
|
198
|
+
})
|
|
199
|
+
.parse(params)
|
|
200
|
+
const result = await capturePrompt(db, config, parsed.prompt, parsed.derivedIds ?? [], {
|
|
201
|
+
...(parsed.scope ? { scope: parsed.scope } : {}),
|
|
202
|
+
...(parsed.sessionId ? { sessionId: parsed.sessionId } : {}),
|
|
203
|
+
})
|
|
204
|
+
maybeTriggerMutationSync()
|
|
205
|
+
return result
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case 'session_context_get': {
|
|
209
|
+
const parsed = z.object({ sessionId: z.string() }).parse(params)
|
|
210
|
+
return getSessionContext(db, parsed.sessionId)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case 'session_context_set': {
|
|
214
|
+
const parsed = z
|
|
215
|
+
.object({
|
|
216
|
+
sessionId: z.string(),
|
|
217
|
+
summary: z.string().optional(),
|
|
218
|
+
context: z.record(z.string(), z.unknown()).optional(),
|
|
219
|
+
})
|
|
220
|
+
.parse(params)
|
|
221
|
+
await setSessionContext(db, parsed.sessionId, {
|
|
222
|
+
...(parsed.summary !== undefined ? { summary: parsed.summary } : {}),
|
|
223
|
+
...(parsed.context !== undefined ? { context: parsed.context } : {}),
|
|
224
|
+
})
|
|
225
|
+
maybeTriggerMutationSync()
|
|
226
|
+
return { ok: true }
|
|
227
|
+
}
|
|
228
|
+
|
|
131
229
|
default:
|
|
132
230
|
throw new Error(`Unknown MCP tool: ${method}`)
|
|
133
231
|
}
|
package/src/mcp/server.ts
CHANGED
|
@@ -1,22 +1,142 @@
|
|
|
1
1
|
// src/mcp/server.ts
|
|
2
2
|
import { Elysia } from 'elysia'
|
|
3
|
-
import type
|
|
3
|
+
import { resolveSyncConfig, type SuemoConfig } from '../config.ts'
|
|
4
4
|
import { connect, disconnect } from '../db/client.ts'
|
|
5
5
|
import { checkCompatibility, requireCompatibility } from '../db/preflight.ts'
|
|
6
6
|
import { runSchema } from '../db/schema.ts'
|
|
7
7
|
import { getLogger } from '../logger.ts'
|
|
8
|
+
import { syncTo } from '../sync.ts'
|
|
8
9
|
import { runStdioServer } from './stdio.ts'
|
|
9
10
|
import { buildMcpRouter } from './tools.ts'
|
|
10
11
|
|
|
11
12
|
const log = getLogger(['suemo', 'mcp'])
|
|
12
13
|
|
|
14
|
+
interface AutoSyncOptions {
|
|
15
|
+
reason: 'timer' | 'write'
|
|
16
|
+
force?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createAutoSyncRunner(
|
|
20
|
+
db: ReturnType<typeof connect> extends Promise<infer T> ? T : never,
|
|
21
|
+
config: SuemoConfig,
|
|
22
|
+
): {
|
|
23
|
+
start: () => void
|
|
24
|
+
onWrite: (tool: string) => Promise<void>
|
|
25
|
+
stop: () => void
|
|
26
|
+
} {
|
|
27
|
+
const resolvedSync = resolveSyncConfig(config)
|
|
28
|
+
if (!resolvedSync) {
|
|
29
|
+
return {
|
|
30
|
+
start: () => {},
|
|
31
|
+
onWrite: async () => {},
|
|
32
|
+
stop: () => {},
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let timer: ReturnType<typeof setInterval> | null = null
|
|
37
|
+
let running = false
|
|
38
|
+
let queued = false
|
|
39
|
+
let lastRunAt = 0
|
|
40
|
+
|
|
41
|
+
const runAutoSync = async ({ reason, force = false }: AutoSyncOptions): Promise<void> => {
|
|
42
|
+
if (!resolvedSync.auto.enabled) return
|
|
43
|
+
if (reason === 'write' && !resolvedSync.auto.onWrite) return
|
|
44
|
+
|
|
45
|
+
const now = Date.now()
|
|
46
|
+
const minIntervalMs = resolvedSync.auto.minWriteIntervalSeconds * 1000
|
|
47
|
+
if (!force && reason === 'write' && lastRunAt > 0 && now - lastRunAt < minIntervalMs) {
|
|
48
|
+
return
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (running) {
|
|
52
|
+
queued = true
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const target = resolvedSync.remotes[resolvedSync.auto.remote]
|
|
57
|
+
if (!target) {
|
|
58
|
+
log.warning('Auto-sync skipped: remote missing', { remote: resolvedSync.auto.remote, reason })
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
running = true
|
|
63
|
+
try {
|
|
64
|
+
const result = await syncTo(db, target, {
|
|
65
|
+
direction: resolvedSync.auto.direction,
|
|
66
|
+
})
|
|
67
|
+
lastRunAt = Date.now()
|
|
68
|
+
log.info('Auto-sync completed', {
|
|
69
|
+
reason,
|
|
70
|
+
remote: resolvedSync.auto.remote,
|
|
71
|
+
direction: resolvedSync.auto.direction,
|
|
72
|
+
pushed: result.pushed,
|
|
73
|
+
errors: result.errors,
|
|
74
|
+
})
|
|
75
|
+
} catch (error) {
|
|
76
|
+
log.warning('Auto-sync failed', { reason, error: String(error) })
|
|
77
|
+
} finally {
|
|
78
|
+
running = false
|
|
79
|
+
if (queued) {
|
|
80
|
+
queued = false
|
|
81
|
+
queueMicrotask(() => {
|
|
82
|
+
void runAutoSync({ reason: 'write', force: true })
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const start = (): void => {
|
|
89
|
+
if (!resolvedSync.auto.enabled) return
|
|
90
|
+
if (timer) return
|
|
91
|
+
const intervalMs = resolvedSync.auto.intervalSeconds * 1000
|
|
92
|
+
timer = setInterval(() => {
|
|
93
|
+
void runAutoSync({ reason: 'timer' })
|
|
94
|
+
}, intervalMs)
|
|
95
|
+
log.info('Auto-sync timer started', {
|
|
96
|
+
intervalSeconds: resolvedSync.auto.intervalSeconds,
|
|
97
|
+
remote: resolvedSync.auto.remote,
|
|
98
|
+
direction: resolvedSync.auto.direction,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const stop = (): void => {
|
|
103
|
+
if (!timer) return
|
|
104
|
+
clearInterval(timer)
|
|
105
|
+
timer = null
|
|
106
|
+
log.info('Auto-sync timer stopped')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const onWrite = async (tool: string): Promise<void> => {
|
|
110
|
+
if (!resolvedSync.auto.enabled || !resolvedSync.auto.onWrite) return
|
|
111
|
+
log.debug('Auto-sync write trigger', { tool })
|
|
112
|
+
await runAutoSync({ reason: 'write' })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { start, onWrite, stop }
|
|
116
|
+
}
|
|
117
|
+
|
|
13
118
|
export async function startMcpServer(config: SuemoConfig): Promise<void> {
|
|
14
119
|
const db = await connect(config.surreal)
|
|
15
120
|
await requireCompatibility(db)
|
|
16
121
|
await runSchema(db)
|
|
122
|
+
const autoSync = createAutoSyncRunner(db, config)
|
|
123
|
+
autoSync.start()
|
|
124
|
+
|
|
125
|
+
const shutdown = async (): Promise<void> => {
|
|
126
|
+
autoSync.stop()
|
|
127
|
+
await disconnect()
|
|
128
|
+
process.exit(0)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
process.once('SIGINT', () => {
|
|
132
|
+
void shutdown()
|
|
133
|
+
})
|
|
134
|
+
process.once('SIGTERM', () => {
|
|
135
|
+
void shutdown()
|
|
136
|
+
})
|
|
17
137
|
|
|
18
138
|
new Elysia()
|
|
19
|
-
.use(buildMcpRouter(db, config))
|
|
139
|
+
.use(buildMcpRouter(db, config, { onMutation: autoSync.onWrite }))
|
|
20
140
|
.get('/health', () => ({ status: 'ok' }))
|
|
21
141
|
.listen({ port: config.mcp.port, hostname: config.mcp.host })
|
|
22
142
|
|
|
@@ -25,9 +145,11 @@ export async function startMcpServer(config: SuemoConfig): Promise<void> {
|
|
|
25
145
|
|
|
26
146
|
export async function startMcpStdioServer(config: SuemoConfig): Promise<void> {
|
|
27
147
|
const db = await connect(config.surreal)
|
|
148
|
+
const autoSync = createAutoSyncRunner(db, config)
|
|
149
|
+
autoSync.start()
|
|
28
150
|
try {
|
|
29
151
|
const compat = await checkCompatibility(db, {
|
|
30
|
-
requireEmbedding: config.embedding.provider === '
|
|
152
|
+
requireEmbedding: config.embedding.provider === 'surrealml',
|
|
31
153
|
context: 'mcp:stdio-startup',
|
|
32
154
|
})
|
|
33
155
|
if (!compat.ok) {
|
|
@@ -39,8 +161,9 @@ export async function startMcpStdioServer(config: SuemoConfig): Promise<void> {
|
|
|
39
161
|
process.exit(1)
|
|
40
162
|
}
|
|
41
163
|
await runSchema(db)
|
|
42
|
-
await runStdioServer(db, config)
|
|
164
|
+
await runStdioServer(db, config, { onMutation: autoSync.onWrite })
|
|
43
165
|
} finally {
|
|
166
|
+
autoSync.stop()
|
|
44
167
|
await disconnect()
|
|
45
168
|
}
|
|
46
169
|
}
|