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
package/src/logger.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// src/logger.ts
|
|
2
|
+
// Call initLogger() once in cli/index.ts and mcp/server.ts before anything else.
|
|
3
|
+
|
|
4
|
+
import { getFileSink } from '@logtape/file'
|
|
5
|
+
import { configure, getConsoleSink, getLogger, type Sink } from '@logtape/logtape'
|
|
6
|
+
|
|
7
|
+
export { getLogger }
|
|
8
|
+
|
|
9
|
+
export type LogLevel = 'debug' | 'info' | 'warning' | 'error' | 'fatal'
|
|
10
|
+
|
|
11
|
+
// Different formatters for different purposes
|
|
12
|
+
const prettySink: Sink = getConsoleSink({
|
|
13
|
+
formatter: record => `${record.level}: ${String(record.message)}\n`,
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
const jsonSink: Sink = getConsoleSink({
|
|
17
|
+
formatter: record =>
|
|
18
|
+
JSON.stringify({
|
|
19
|
+
level: record.level,
|
|
20
|
+
message: record.message,
|
|
21
|
+
category: record.category,
|
|
22
|
+
timestamp: record.timestamp,
|
|
23
|
+
}) + '\n',
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
export async function initLogger(options: {
|
|
27
|
+
level: LogLevel
|
|
28
|
+
logFile?: string // if set, also write to file
|
|
29
|
+
}): Promise<void> {
|
|
30
|
+
const sinks: Record<string, ReturnType<typeof getConsoleSink>> = {
|
|
31
|
+
pretty: prettySink,
|
|
32
|
+
json: jsonSink,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (options.logFile) {
|
|
36
|
+
sinks['file'] = getFileSink(options.logFile)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await configure({
|
|
40
|
+
reset: true,
|
|
41
|
+
sinks,
|
|
42
|
+
loggers: [
|
|
43
|
+
{
|
|
44
|
+
category: ['logtape', 'meta'],
|
|
45
|
+
lowestLevel: 'warning', // or 'error' to silence info messages
|
|
46
|
+
sinks: ['json'], // separate sink for logging diagnostics
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
category: ['suemo'],
|
|
50
|
+
lowestLevel: options.level,
|
|
51
|
+
sinks: Object.keys(sinks).filter(item => item !== 'json'),
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Logger factory — used in every domain module ──────────────────────────────
|
|
58
|
+
// Usage: const log = getLogger(["suemo", "memory", "write"]);
|
|
59
|
+
// log.info("observe called", { content, scope });
|
|
60
|
+
// log.debug`raw result: ${JSON.stringify(result)}`;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// src/mcp/server.ts
|
|
2
|
+
import { Elysia } from 'elysia'
|
|
3
|
+
import type { SuemoConfig } from '../config.ts'
|
|
4
|
+
import { connect } from '../db/client.ts'
|
|
5
|
+
import { requireCompatibility } from '../db/preflight.ts'
|
|
6
|
+
import { runSchema } from '../db/schema.ts'
|
|
7
|
+
import { getLogger } from '../logger.ts'
|
|
8
|
+
import { buildMcpRouter } from './tools.ts'
|
|
9
|
+
|
|
10
|
+
const log = getLogger(['suemo', 'mcp'])
|
|
11
|
+
|
|
12
|
+
export async function startMcpServer(config: SuemoConfig): Promise<void> {
|
|
13
|
+
const db = await connect(config.surreal)
|
|
14
|
+
await requireCompatibility(db)
|
|
15
|
+
await runSchema(db)
|
|
16
|
+
|
|
17
|
+
new Elysia()
|
|
18
|
+
.use(buildMcpRouter(db, config))
|
|
19
|
+
.get('/health', () => ({ status: 'ok' }))
|
|
20
|
+
.listen({ port: config.mcp.port, hostname: config.mcp.host })
|
|
21
|
+
|
|
22
|
+
log.info('MCP server listening', { port: config.mcp.port, host: config.mcp.host })
|
|
23
|
+
}
|
package/src/mcp/tools.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// src/mcp/tools.ts
|
|
2
|
+
// Each tool: parse input with Zod → call domain fn → return result.
|
|
3
|
+
// No business logic here. SSoT lives in domain modules.
|
|
4
|
+
|
|
5
|
+
import type { Elysia } from 'elysia'
|
|
6
|
+
import type { Surreal } from 'surrealdb'
|
|
7
|
+
import { z } from 'zod'
|
|
8
|
+
import { consolidate } from '../cognitive/consolidate.ts'
|
|
9
|
+
import { healthReport, vitals } from '../cognitive/health.ts'
|
|
10
|
+
import type { SuemoConfig } from '../config.ts'
|
|
11
|
+
import { goalList, goalResolve, goalSet } from '../goal.ts'
|
|
12
|
+
import { episodeEnd, episodeStart } from '../memory/episode.ts'
|
|
13
|
+
import { query, recall, timeline, wander } from '../memory/read.ts'
|
|
14
|
+
import { believe, invalidate, observe } from '../memory/write.ts'
|
|
15
|
+
import { ObserveInputSchema, QueryInputSchema } from '../types.ts'
|
|
16
|
+
|
|
17
|
+
export function buildMcpRouter(db: Surreal, config: SuemoConfig) {
|
|
18
|
+
// Returns an Elysia plugin that adds all /mcp/* routes.
|
|
19
|
+
// Each route maps 1:1 to a domain function.
|
|
20
|
+
return (app: Elysia) =>
|
|
21
|
+
app
|
|
22
|
+
.post('/mcp/observe', async ({ body }) => observe(db, ObserveInputSchema.parse(body)))
|
|
23
|
+
.post('/mcp/believe', async ({ body }) => {
|
|
24
|
+
const p = z.object({ content: z.string(), scope: z.string().optional(), confidence: z.number().optional() })
|
|
25
|
+
.parse(body)
|
|
26
|
+
return believe(db, p)
|
|
27
|
+
})
|
|
28
|
+
.post('/mcp/invalidate', async ({ body }) => {
|
|
29
|
+
const p = z.object({ nodeId: z.string(), reason: z.string().optional() }).parse(body)
|
|
30
|
+
await invalidate(db, p.nodeId, p.reason)
|
|
31
|
+
return { ok: true }
|
|
32
|
+
})
|
|
33
|
+
.post('/mcp/query', async ({ body }) => query(db, QueryInputSchema.parse(body)))
|
|
34
|
+
.post('/mcp/recall', async ({ body }) => {
|
|
35
|
+
const p = z.object({ nodeId: z.string() }).parse(body)
|
|
36
|
+
return recall(db, p.nodeId)
|
|
37
|
+
})
|
|
38
|
+
.post('/mcp/wander', async ({ body }) => {
|
|
39
|
+
const p = z.object({ anchor: z.string().optional(), hops: z.number().optional(), scope: z.string().optional() })
|
|
40
|
+
.parse(body)
|
|
41
|
+
return wander(db, {
|
|
42
|
+
...(p.anchor ? { anchor: p.anchor } : {}),
|
|
43
|
+
...(p.hops ? { hops: p.hops } : {}),
|
|
44
|
+
...(p.scope ? { scope: p.scope } : {}),
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
.post('/mcp/timeline', async ({ body }) => {
|
|
48
|
+
const p = z.object({
|
|
49
|
+
scope: z.string().optional(),
|
|
50
|
+
from: z.string().optional(),
|
|
51
|
+
until: z.string().optional(),
|
|
52
|
+
limit: z.number().optional(),
|
|
53
|
+
}).parse(body)
|
|
54
|
+
return timeline(db, {
|
|
55
|
+
...(p.scope ? { scope: p.scope } : {}),
|
|
56
|
+
...(p.from ? { from: p.from } : {}),
|
|
57
|
+
...(p.until ? { until: p.until } : {}),
|
|
58
|
+
...(p.limit ? { limit: p.limit } : {}),
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
.post('/mcp/episode_start', async ({ body }) => {
|
|
62
|
+
const p = z.object({ sessionId: z.string() }).parse(body)
|
|
63
|
+
return episodeStart(db, p.sessionId)
|
|
64
|
+
})
|
|
65
|
+
.post('/mcp/episode_end', async ({ body }) => {
|
|
66
|
+
const p = z.object({ sessionId: z.string(), summary: z.string().optional() }).parse(body)
|
|
67
|
+
return episodeEnd(db, p.sessionId, p.summary)
|
|
68
|
+
})
|
|
69
|
+
.post('/mcp/goal_set', async ({ body }) => {
|
|
70
|
+
const p = z.object({ content: z.string(), scope: z.string().optional(), tags: z.array(z.string()).optional() })
|
|
71
|
+
.parse(body)
|
|
72
|
+
return goalSet(db, p.content, {
|
|
73
|
+
...(p.scope ? { scope: p.scope } : {}),
|
|
74
|
+
tags: p.tags ?? [],
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
.post('/mcp/goal_resolve', async ({ body }) => {
|
|
78
|
+
const p = z.object({ goalId: z.string() }).parse(body)
|
|
79
|
+
await goalResolve(db, p.goalId)
|
|
80
|
+
return { ok: true }
|
|
81
|
+
})
|
|
82
|
+
.post('/mcp/goal_list', async ({ body }) => {
|
|
83
|
+
const p = z.object({ scope: z.string().optional(), includeResolved: z.boolean().optional() }).parse(body)
|
|
84
|
+
return goalList(db, {
|
|
85
|
+
...(p.scope ? { scope: p.scope } : {}),
|
|
86
|
+
includeResolved: p.includeResolved ?? false,
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
.get('/mcp/health', async () => healthReport(db))
|
|
90
|
+
.get('/mcp/vitals', async () => vitals(db))
|
|
91
|
+
.post('/mcp/consolidate', async ({ body }) => {
|
|
92
|
+
const p = z.object({ nremOnly: z.boolean().optional() }).parse(body ?? {})
|
|
93
|
+
return consolidate(db, {
|
|
94
|
+
nremOnly: p.nremOnly ?? false,
|
|
95
|
+
nremSimilarityThreshold: config.consolidation.nremSimilarityThreshold,
|
|
96
|
+
remRelationThreshold: config.consolidation.remRelationThreshold,
|
|
97
|
+
llm: config.consolidation.llm,
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Surreal } from 'surrealdb'
|
|
2
|
+
import { getLogger } from '../logger.ts'
|
|
3
|
+
import type { Episode } from '../types.ts'
|
|
4
|
+
|
|
5
|
+
const log = getLogger(['suemo', 'memory', 'episode'])
|
|
6
|
+
|
|
7
|
+
export async function episodeStart(
|
|
8
|
+
db: Surreal,
|
|
9
|
+
sessionId: string,
|
|
10
|
+
): Promise<Episode> {
|
|
11
|
+
log.info('episodeStart()', { sessionId })
|
|
12
|
+
const result = await db.query<[Episode[]]>(
|
|
13
|
+
`
|
|
14
|
+
CREATE episode CONTENT {
|
|
15
|
+
session_id: $sessionId,
|
|
16
|
+
ended_at: NONE,
|
|
17
|
+
summary: NONE,
|
|
18
|
+
memory_ids: []
|
|
19
|
+
}
|
|
20
|
+
`,
|
|
21
|
+
{ sessionId },
|
|
22
|
+
)
|
|
23
|
+
return result[0]![0]!
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function episodeEnd(
|
|
27
|
+
db: Surreal,
|
|
28
|
+
sessionId: string,
|
|
29
|
+
summary?: string,
|
|
30
|
+
): Promise<Episode> {
|
|
31
|
+
log.info('episodeEnd()', { sessionId })
|
|
32
|
+
const result = await db.query<[Episode[]]>(
|
|
33
|
+
`
|
|
34
|
+
UPDATE episode
|
|
35
|
+
SET ended_at = time::now(),
|
|
36
|
+
summary = $summary
|
|
37
|
+
WHERE session_id = $sessionId
|
|
38
|
+
AND ended_at = NONE
|
|
39
|
+
RETURN AFTER
|
|
40
|
+
`,
|
|
41
|
+
{ sessionId, summary: summary ?? null },
|
|
42
|
+
)
|
|
43
|
+
const episode = result[0]?.[0]
|
|
44
|
+
if (!episode) throw new Error(`No open episode found for session: ${sessionId}`)
|
|
45
|
+
return episode
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Attach a memory node to the current open episode for a session.
|
|
50
|
+
* Called internally from observe() when a session context is active.
|
|
51
|
+
*/
|
|
52
|
+
export async function attachToEpisode(
|
|
53
|
+
db: Surreal,
|
|
54
|
+
sessionId: string,
|
|
55
|
+
memoryId: string,
|
|
56
|
+
): Promise<void> {
|
|
57
|
+
log.debug('attachToEpisode()', { sessionId, memoryId })
|
|
58
|
+
await db.query(
|
|
59
|
+
`
|
|
60
|
+
UPDATE episode
|
|
61
|
+
SET memory_ids += [$memoryId]
|
|
62
|
+
WHERE session_id = $sessionId AND ended_at = NONE
|
|
63
|
+
`,
|
|
64
|
+
{ sessionId, memoryId },
|
|
65
|
+
)
|
|
66
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import type { Surreal } from 'surrealdb'
|
|
2
|
+
import { getLogger } from '../logger.ts'
|
|
3
|
+
import type { MemoryNode, QueryInput } from '../types.ts'
|
|
4
|
+
import { QueryInputSchema } from '../types.ts'
|
|
5
|
+
|
|
6
|
+
const log = getLogger(['suemo', 'memory', 'read'])
|
|
7
|
+
|
|
8
|
+
const ACTIVE_FILTER = '(valid_until = NONE OR valid_until > time::now())'
|
|
9
|
+
|
|
10
|
+
// ── query() — parallel hybrid retrieval ──────────────────────────────────────
|
|
11
|
+
export async function query(
|
|
12
|
+
db: Surreal,
|
|
13
|
+
rawInput: QueryInput,
|
|
14
|
+
): Promise<MemoryNode[]> {
|
|
15
|
+
const input = QueryInputSchema.parse(rawInput)
|
|
16
|
+
log.info('query()', { input: input.input, strategies: input.strategies, topK: input.topK })
|
|
17
|
+
|
|
18
|
+
const weights = { vector: 0.5, bm25: 0.25, graph: 0.15, temporal: 0.1 } // from config ideally
|
|
19
|
+
|
|
20
|
+
const topK = input.topK ?? 5
|
|
21
|
+
const strategies = input.strategies ?? ['vector', 'bm25', 'graph']
|
|
22
|
+
const candidateK = topK * 4 // over-fetch before reranking
|
|
23
|
+
const activeFilter = input.activeOnly ? ACTIVE_FILTER : 'true'
|
|
24
|
+
const scopeFilter = '($scope = NONE OR scope = $scope)'
|
|
25
|
+
const kindFilter = '($kinds = NONE OR kind INSIDE $kinds)'
|
|
26
|
+
|
|
27
|
+
const params = {
|
|
28
|
+
queryText: input.input,
|
|
29
|
+
scope: input.scope ?? null,
|
|
30
|
+
kinds: input.kind ?? null,
|
|
31
|
+
topK: input.topK,
|
|
32
|
+
candidateK,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const promises: Promise<MemoryNode[]>[] = []
|
|
36
|
+
|
|
37
|
+
// ── Strategy A: RRF — vector + BM25 combined (one SurrealDB round trip) ──
|
|
38
|
+
if (strategies.includes('vector') || strategies.includes('bm25')) {
|
|
39
|
+
promises.push(
|
|
40
|
+
db.query<[MemoryNode[]]>(
|
|
41
|
+
`
|
|
42
|
+
LET $vec = fn::embed($queryText);
|
|
43
|
+
SELECT *,
|
|
44
|
+
(
|
|
45
|
+
search::score(1) * ${weights.bm25} +
|
|
46
|
+
vector::similarity::cosine(embedding, $vec) * ${weights.vector}
|
|
47
|
+
) AS _score
|
|
48
|
+
FROM memory
|
|
49
|
+
WHERE content @1@ $queryText
|
|
50
|
+
AND embedding <|$candidateK, 40|> $vec
|
|
51
|
+
AND ${activeFilter}
|
|
52
|
+
AND ${scopeFilter}
|
|
53
|
+
AND ${kindFilter}
|
|
54
|
+
ORDER BY _score DESC
|
|
55
|
+
LIMIT $candidateK
|
|
56
|
+
`,
|
|
57
|
+
params,
|
|
58
|
+
).then((r) => r[0] ?? []),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Strategy B: Graph spreading activation ───────────────────────────────
|
|
63
|
+
if (strategies.includes('graph')) {
|
|
64
|
+
// First get anchor IDs from a quick vector probe, then fan out via graph
|
|
65
|
+
promises.push(
|
|
66
|
+
db.query<[MemoryNode[]]>(
|
|
67
|
+
`
|
|
68
|
+
LET $vec = fn::embed($queryText);
|
|
69
|
+
LET $anchors = (
|
|
70
|
+
SELECT id FROM memory
|
|
71
|
+
WHERE ${activeFilter}
|
|
72
|
+
ORDER BY embedding <|5, 20|> $vec
|
|
73
|
+
LIMIT 5
|
|
74
|
+
).id;
|
|
75
|
+
SELECT *,
|
|
76
|
+
math::mean(<-relates_to[WHERE valid_until = NONE OR valid_until > time::now()].strength) AS _score
|
|
77
|
+
FROM memory
|
|
78
|
+
WHERE id INSIDE (
|
|
79
|
+
SELECT ->relates_to[WHERE strength > 0.3 AND (valid_until = NONE OR valid_until > time::now())]->memory.id
|
|
80
|
+
FROM $anchors
|
|
81
|
+
)
|
|
82
|
+
AND ${activeFilter}
|
|
83
|
+
AND ${scopeFilter}
|
|
84
|
+
ORDER BY _score DESC
|
|
85
|
+
LIMIT $candidateK
|
|
86
|
+
`,
|
|
87
|
+
params,
|
|
88
|
+
).then((r) => r[0] ?? []),
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const resultSets = await Promise.all(promises)
|
|
93
|
+
|
|
94
|
+
// ── Merge and rerank by deduplicated ID ──────────────────────────────────
|
|
95
|
+
const seen = new Map<string, MemoryNode & { _score?: number }>()
|
|
96
|
+
for (const set of resultSets) {
|
|
97
|
+
for (const node of set) {
|
|
98
|
+
const existing = seen.get(node.id)
|
|
99
|
+
if (!existing) {
|
|
100
|
+
seen.set(node.id, node)
|
|
101
|
+
} else {
|
|
102
|
+
// Boost score if the node appears in multiple strategies
|
|
103
|
+
;(existing as Record<string, unknown>)['_score'] =
|
|
104
|
+
((existing as Record<string, unknown>)['_score'] as number ?? 0)
|
|
105
|
+
+ ((node as Record<string, unknown>)['_score'] as number ?? 0)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const merged = [...seen.values()]
|
|
111
|
+
.sort((a, b) =>
|
|
112
|
+
((b as Record<string, unknown>)['_score'] as number ?? 0)
|
|
113
|
+
- ((a as Record<string, unknown>)['_score'] as number ?? 0)
|
|
114
|
+
)
|
|
115
|
+
.slice(0, input.topK)
|
|
116
|
+
.map(({ ...rest }) => {
|
|
117
|
+
delete (rest as Record<string, unknown>)['_score']
|
|
118
|
+
return rest as MemoryNode
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
log.info('query() returned', { count: merged.length })
|
|
122
|
+
return merged
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ── recall() — single node + FSRS tick ───────────────────────────────────────
|
|
126
|
+
export async function recall(
|
|
127
|
+
db: Surreal,
|
|
128
|
+
nodeId: string,
|
|
129
|
+
): Promise<{ node: MemoryNode; neighbors: MemoryNode[] }> {
|
|
130
|
+
log.info('recall()', { nodeId })
|
|
131
|
+
|
|
132
|
+
const [nodeResult, neighborResult] = await Promise.all([
|
|
133
|
+
db.query<[MemoryNode[]]>('SELECT * FROM $id', { id: nodeId }),
|
|
134
|
+
db.query<[MemoryNode[]]>(
|
|
135
|
+
`
|
|
136
|
+
SELECT * FROM memory
|
|
137
|
+
WHERE id INSIDE (
|
|
138
|
+
SELECT ->relates_to[WHERE valid_until = NONE OR valid_until > time::now()]->memory.id
|
|
139
|
+
FROM $id
|
|
140
|
+
)
|
|
141
|
+
LIMIT 10
|
|
142
|
+
`,
|
|
143
|
+
{ id: nodeId },
|
|
144
|
+
),
|
|
145
|
+
])
|
|
146
|
+
|
|
147
|
+
const node = nodeResult[0]?.[0]
|
|
148
|
+
if (!node) throw new Error(`Memory node not found: ${nodeId}`)
|
|
149
|
+
|
|
150
|
+
// FSRS tick — simplified next review interval
|
|
151
|
+
await db.query(
|
|
152
|
+
`
|
|
153
|
+
UPDATE $id SET
|
|
154
|
+
fsrs_next_review = time::now() + 1d,
|
|
155
|
+
updated_at = time::now()
|
|
156
|
+
`,
|
|
157
|
+
{ id: nodeId },
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
return { node, neighbors: neighborResult[0] ?? [] }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── wander() — spreading activation walk ─────────────────────────────────────
|
|
164
|
+
export async function wander(
|
|
165
|
+
db: Surreal,
|
|
166
|
+
opts: { anchor?: string; hops?: number; scope?: string },
|
|
167
|
+
): Promise<MemoryNode[]> {
|
|
168
|
+
// const _hops = opts.hops ?? 3
|
|
169
|
+
log.info('wander()', opts)
|
|
170
|
+
|
|
171
|
+
// If no anchor, pick a random high-salience active node
|
|
172
|
+
const anchorId = opts.anchor ?? await db.query<[{ id: string }[]]>(
|
|
173
|
+
`
|
|
174
|
+
SELECT id FROM memory
|
|
175
|
+
WHERE ${ACTIVE_FILTER} AND ($scope = NONE OR scope = $scope)
|
|
176
|
+
ORDER BY salience * rand() DESC
|
|
177
|
+
LIMIT 1
|
|
178
|
+
`,
|
|
179
|
+
{ scope: opts.scope ?? null },
|
|
180
|
+
).then((r) => r[0]?.[0]?.id)
|
|
181
|
+
|
|
182
|
+
if (!anchorId) return []
|
|
183
|
+
|
|
184
|
+
// Walk hops: follow relates_to edges, weight by strength
|
|
185
|
+
return db.query<[MemoryNode[]]>(
|
|
186
|
+
`
|
|
187
|
+
SELECT * FROM memory
|
|
188
|
+
WHERE id INSIDE (
|
|
189
|
+
SELECT ->relates_to[WHERE strength > 0.3]->memory
|
|
190
|
+
->relates_to[WHERE strength > 0.3]->memory.id
|
|
191
|
+
FROM $anchor
|
|
192
|
+
)
|
|
193
|
+
AND ${ACTIVE_FILTER}
|
|
194
|
+
LIMIT 20
|
|
195
|
+
`,
|
|
196
|
+
{ anchor: anchorId },
|
|
197
|
+
).then((r) => r[0] ?? [])
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ── timeline() ───────────────────────────────────────────────────────────────
|
|
201
|
+
export async function timeline(
|
|
202
|
+
db: Surreal,
|
|
203
|
+
opts: { scope?: string; from?: string; until?: string; limit?: number },
|
|
204
|
+
): Promise<MemoryNode[]> {
|
|
205
|
+
log.info('timeline()', opts)
|
|
206
|
+
return db.query<[MemoryNode[]]>(
|
|
207
|
+
`
|
|
208
|
+
SELECT * FROM memory
|
|
209
|
+
WHERE (valid_until = NONE OR valid_until > time::now())
|
|
210
|
+
AND ($scope = NONE OR scope = $scope)
|
|
211
|
+
AND ($from = NONE OR created_at >= <datetime>$from)
|
|
212
|
+
AND ($until = NONE OR created_at <= <datetime>$until)
|
|
213
|
+
ORDER BY created_at DESC
|
|
214
|
+
LIMIT $limit
|
|
215
|
+
`,
|
|
216
|
+
{
|
|
217
|
+
scope: opts.scope ?? null,
|
|
218
|
+
from: opts.from ?? null,
|
|
219
|
+
until: opts.until ?? null,
|
|
220
|
+
limit: opts.limit ?? 50,
|
|
221
|
+
},
|
|
222
|
+
).then((r) => r[0] ?? [])
|
|
223
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { Surreal } from 'surrealdb'
|
|
2
|
+
import { detectContradiction } from '../cognitive/contradiction.ts'
|
|
3
|
+
import { getLogger } from '../logger.ts'
|
|
4
|
+
import type { MemoryNode, ObserveInput } from '../types.ts'
|
|
5
|
+
import { ObserveInputSchema } from '../types.ts'
|
|
6
|
+
|
|
7
|
+
const log = getLogger(['suemo', 'memory', 'write'])
|
|
8
|
+
|
|
9
|
+
// ── observe() ─────────────────────────────────────────────────────────────────
|
|
10
|
+
export async function observe(
|
|
11
|
+
db: Surreal,
|
|
12
|
+
rawInput: ObserveInput,
|
|
13
|
+
): Promise<MemoryNode> {
|
|
14
|
+
const input = ObserveInputSchema.parse(rawInput)
|
|
15
|
+
log.info('observe()', { kind: input.kind, scope: input.scope, contentPreview: input.content.slice(0, 60) })
|
|
16
|
+
|
|
17
|
+
// 1. Get embedding — fn::embed() runs server-side
|
|
18
|
+
// We embed inside the CREATE query; the embedding field is set by UPSERT logic.
|
|
19
|
+
// We first check for a near-duplicate before inserting.
|
|
20
|
+
|
|
21
|
+
// 2. Dedup probe: ANN search for cosine similarity > 0.97
|
|
22
|
+
const dedupResult = await db.query<[{ id: string; score: number }[]]>(
|
|
23
|
+
`
|
|
24
|
+
LET $vec = fn::embed($content);
|
|
25
|
+
SELECT id, vector::similarity::cosine(embedding, $vec) AS score
|
|
26
|
+
FROM memory
|
|
27
|
+
WHERE (valid_until = NONE OR valid_until > time::now())
|
|
28
|
+
ORDER BY embedding <|1, 20|> $vec
|
|
29
|
+
LIMIT 1
|
|
30
|
+
`,
|
|
31
|
+
{ content: input.content },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
const topHit = dedupResult[0]?.[0]
|
|
35
|
+
if (topHit && topHit.score > 0.97) {
|
|
36
|
+
log.debug('Near-duplicate detected — merging tags, updating updated_at', {
|
|
37
|
+
existingId: topHit.id,
|
|
38
|
+
score: topHit.score,
|
|
39
|
+
})
|
|
40
|
+
const updated = await db.query<[MemoryNode[]]>(
|
|
41
|
+
`
|
|
42
|
+
UPDATE $id SET
|
|
43
|
+
tags += $tags,
|
|
44
|
+
updated_at = time::now()
|
|
45
|
+
`,
|
|
46
|
+
{ id: topHit.id, tags: input.tags },
|
|
47
|
+
)
|
|
48
|
+
return updated[0]![0]!
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 3. Create new node with server-side embedding
|
|
52
|
+
const created = await db.query<[MemoryNode[]]>(
|
|
53
|
+
`
|
|
54
|
+
CREATE memory CONTENT {
|
|
55
|
+
kind: $kind,
|
|
56
|
+
content: $content,
|
|
57
|
+
summary: NONE,
|
|
58
|
+
tags: $tags,
|
|
59
|
+
scope: $scope,
|
|
60
|
+
embedding: fn::embed($content),
|
|
61
|
+
confidence: $confidence,
|
|
62
|
+
salience: 0.5,
|
|
63
|
+
source: $source,
|
|
64
|
+
consolidated: false,
|
|
65
|
+
consolidated_into: NONE,
|
|
66
|
+
fsrs_stability: NONE,
|
|
67
|
+
fsrs_difficulty: NONE,
|
|
68
|
+
fsrs_next_review: NONE
|
|
69
|
+
}
|
|
70
|
+
`,
|
|
71
|
+
{
|
|
72
|
+
kind: input.kind,
|
|
73
|
+
content: input.content,
|
|
74
|
+
tags: input.tags,
|
|
75
|
+
scope: input.scope ?? null,
|
|
76
|
+
confidence: input.confidence,
|
|
77
|
+
source: input.source ?? null,
|
|
78
|
+
},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const node = created[0]![0]!
|
|
82
|
+
log.info('observe() created', { id: node.id })
|
|
83
|
+
return node
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── believe() ─────────────────────────────────────────────────────────────────
|
|
87
|
+
// Calls observe(), then runs contradiction detection on existing beliefs.
|
|
88
|
+
// If a contradiction is found, the old belief is invalidated automatically.
|
|
89
|
+
export async function believe(
|
|
90
|
+
db: Surreal,
|
|
91
|
+
input: Omit<ObserveInput, 'kind'>,
|
|
92
|
+
): Promise<{ node: MemoryNode; contradicted?: MemoryNode }> {
|
|
93
|
+
log.info('believe()', { scope: input.scope })
|
|
94
|
+
|
|
95
|
+
const node = await observe(db, { ...input, kind: 'belief' })
|
|
96
|
+
|
|
97
|
+
const contradicted = await detectContradiction(db, node)
|
|
98
|
+
if (contradicted) {
|
|
99
|
+
log.info('Contradiction detected — invalidating old belief', { old: contradicted.id, new: node.id })
|
|
100
|
+
await invalidate(db, contradicted.id, 'contradicted by ' + node.id)
|
|
101
|
+
// Create a contradicts relation
|
|
102
|
+
await db.query(
|
|
103
|
+
`
|
|
104
|
+
RELATE $new->relates_to->$old CONTENT {
|
|
105
|
+
kind: 'contradicts',
|
|
106
|
+
strength: 0.9
|
|
107
|
+
}
|
|
108
|
+
`,
|
|
109
|
+
{ new: node.id, old: contradicted.id },
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return { node, ...(contradicted ? { contradicted } : {}) }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── invalidate() ──────────────────────────────────────────────────────────────
|
|
117
|
+
// Sets valid_until = now() on the target node. Never deletes.
|
|
118
|
+
// Called by: believe() on contradiction, goal_resolve(), explicit CLI/MCP call.
|
|
119
|
+
// Agents supply the node ID, never a timestamp.
|
|
120
|
+
export async function invalidate(
|
|
121
|
+
db: Surreal,
|
|
122
|
+
nodeId: string,
|
|
123
|
+
reason?: string,
|
|
124
|
+
): Promise<void> {
|
|
125
|
+
log.info('invalidate()', { nodeId, reason })
|
|
126
|
+
await db.query(
|
|
127
|
+
`
|
|
128
|
+
UPDATE $id SET
|
|
129
|
+
valid_until = time::now(),
|
|
130
|
+
updated_at = time::now()
|
|
131
|
+
`,
|
|
132
|
+
{ id: nodeId },
|
|
133
|
+
)
|
|
134
|
+
}
|