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/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
+ }
@@ -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
+ }