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