@onmars/lunar-memory-brain 0.1.0
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 +21 -0
- package/README.md +13 -0
- package/package.json +31 -0
- package/src/__tests__/client.test.ts +472 -0
- package/src/client.ts +631 -0
- package/src/index.ts +2 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 onMars Tech
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# @onmars/lunar-memory-brain
|
|
2
|
+
|
|
3
|
+
Brain memory provider (Supabase-backed) for [Lunar](https://github.com/onmars-tech/lunar).
|
|
4
|
+
|
|
5
|
+
This package is used internally by `@onmars/lunar-cli`. Install the CLI instead:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun install -g @onmars/lunar-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## License
|
|
12
|
+
|
|
13
|
+
MIT — [onMars Tech](https://github.com/onmars-tech)
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onmars/lunar-memory-brain",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"files": ["src/", "LICENSE"],
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@onmars/lunar-core": "0.1.0"
|
|
13
|
+
},
|
|
14
|
+
"description": "Brain memory provider for Lunar (Supabase-backed long-term memory)",
|
|
15
|
+
"author": "onMars Tech",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "https://github.com/onmars-tech/lunar",
|
|
20
|
+
"directory": "packages/memory-brain"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/onmars-tech/lunar",
|
|
23
|
+
"bugs": "https://github.com/onmars-tech/lunar/issues",
|
|
24
|
+
"keywords": ["lunar", "ai", "memory", "supabase", "bun"],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"bun": ">=1.2"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* # BrainClient — Functional Specification
|
|
3
|
+
*
|
|
4
|
+
* Implements the `MemoryProvider` interface for lunar-brain (Postgres-backed
|
|
5
|
+
* semantic memory API).
|
|
6
|
+
*
|
|
7
|
+
* ## MemoryProvider contract (generic):
|
|
8
|
+
* - `init()` → health check (throws if brain is unreachable)
|
|
9
|
+
* - `destroy()` → no-op (HTTP client, no persistent connection)
|
|
10
|
+
* - `recall(query, options?)` → search memories by semantic similarity
|
|
11
|
+
* - `save(facts)` → batch-save generic facts
|
|
12
|
+
* - `health()` → `{ ok, error? }`
|
|
13
|
+
* - `agentInstructions()` → returns brain API reference for the agent's system prompt
|
|
14
|
+
*
|
|
15
|
+
* ## Brain-specific extensions:
|
|
16
|
+
* - `saveFact(fact)` → save with brain-specific fields (domain, category, importance, tags, decay)
|
|
17
|
+
* - `createEntity(entity)` → knowledge graph nodes (person, project, concept, etc.)
|
|
18
|
+
* - `createRelation(relation)` → knowledge graph edges (works_at, knows, etc.)
|
|
19
|
+
* - `listEntities(options?)` → paginated entity listing
|
|
20
|
+
* - `extractPipeline(text, channel, date)` → LLM-powered extraction (facts + entities + relations + episode)
|
|
21
|
+
*
|
|
22
|
+
* ## Brain API response format:
|
|
23
|
+
* Paginated endpoints return `{ data: T[], count: number }`.
|
|
24
|
+
* Recall results: `{ source, id, score, data: { ...details } }` — note `source` not `type`,
|
|
25
|
+
* and entity details are nested in `data`.
|
|
26
|
+
*
|
|
27
|
+
* ## Auth:
|
|
28
|
+
* Optional API key → `Authorization: Bearer <key>` header. No key → no header.
|
|
29
|
+
* Currently brain is Docker-internal (no auth needed), but the client supports it.
|
|
30
|
+
*/
|
|
31
|
+
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'
|
|
32
|
+
import { BrainClient } from '../client'
|
|
33
|
+
|
|
34
|
+
const originalFetch = globalThis.fetch
|
|
35
|
+
|
|
36
|
+
describe('BrainClient', () => {
|
|
37
|
+
let client: BrainClient
|
|
38
|
+
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
client = new BrainClient({ url: 'http://localhost:8082', timeoutMs: 5000 })
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
globalThis.fetch = originalFetch
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
// ─── Constructor ─────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
describe('constructor — URL and timeout configuration', () => {
|
|
50
|
+
it('normalizes trailing slashes from URL', () => {
|
|
51
|
+
const c = new BrainClient({ url: 'http://brain:8082///' })
|
|
52
|
+
expect((c as any).url).toBe('http://brain:8082')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('stores API key for auth header injection', () => {
|
|
56
|
+
const c = new BrainClient({ url: 'http://brain:8082', apiKey: 'test-key' })
|
|
57
|
+
expect((c as any).apiKey).toBe('test-key')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('defaults timeout to 10 seconds', () => {
|
|
61
|
+
const c = new BrainClient({ url: 'http://brain:8082' })
|
|
62
|
+
expect((c as any).timeoutMs).toBe(10000)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('accepts custom timeout', () => {
|
|
66
|
+
const c = new BrainClient({ url: 'http://brain:8082', timeoutMs: 30000 })
|
|
67
|
+
expect((c as any).timeoutMs).toBe(30000)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ─── Health check ────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
describe('health — brain availability check', () => {
|
|
74
|
+
it('returns ok:true when brain responds with status "ok"', async () => {
|
|
75
|
+
globalThis.fetch = mock(() =>
|
|
76
|
+
Promise.resolve(new Response(JSON.stringify({ status: 'ok' }), { status: 200 })),
|
|
77
|
+
) as any
|
|
78
|
+
|
|
79
|
+
const result = await client.health()
|
|
80
|
+
expect(result.ok).toBe(true)
|
|
81
|
+
expect(result.error).toBeUndefined()
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('returns ok:false with HTTP status on server error', async () => {
|
|
85
|
+
globalThis.fetch = mock(() =>
|
|
86
|
+
Promise.resolve(new Response('Internal Server Error', { status: 500 })),
|
|
87
|
+
) as any
|
|
88
|
+
|
|
89
|
+
const result = await client.health()
|
|
90
|
+
expect(result.ok).toBe(false)
|
|
91
|
+
expect(result.error).toBe('HTTP 500')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('returns ok:false with error message on network failure', async () => {
|
|
95
|
+
globalThis.fetch = mock(() => Promise.reject(new Error('ECONNREFUSED'))) as any
|
|
96
|
+
|
|
97
|
+
const result = await client.health()
|
|
98
|
+
expect(result.ok).toBe(false)
|
|
99
|
+
expect(result.error).toContain('ECONNREFUSED')
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('returns ok:false when status is not "ok" (degraded)', async () => {
|
|
103
|
+
globalThis.fetch = mock(() =>
|
|
104
|
+
Promise.resolve(new Response(JSON.stringify({ status: 'degraded' }), { status: 200 })),
|
|
105
|
+
) as any
|
|
106
|
+
|
|
107
|
+
const result = await client.health()
|
|
108
|
+
expect(result.ok).toBe(false)
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
// ─── Init ────────────────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
describe('init — startup health gate', () => {
|
|
115
|
+
it('passes silently when brain is healthy', async () => {
|
|
116
|
+
globalThis.fetch = mock(() =>
|
|
117
|
+
Promise.resolve(new Response(JSON.stringify({ status: 'ok' }), { status: 200 })),
|
|
118
|
+
) as any
|
|
119
|
+
|
|
120
|
+
await client.init() // should not throw
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('throws if brain is unreachable (fail-fast at startup)', async () => {
|
|
124
|
+
globalThis.fetch = mock(() => Promise.resolve(new Response('error', { status: 500 }))) as any
|
|
125
|
+
|
|
126
|
+
expect(client.init()).rejects.toThrow('Brain health check failed')
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// ─── Recall ──────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
describe('recall — semantic memory search', () => {
|
|
133
|
+
it('sends POST /recall and unwraps paginated { data, count } response', async () => {
|
|
134
|
+
const mockResponse = {
|
|
135
|
+
data: [
|
|
136
|
+
{
|
|
137
|
+
source: 'fact',
|
|
138
|
+
id: '1',
|
|
139
|
+
score: 0.9,
|
|
140
|
+
data: { id: '1', content: 'test fact', domain: 'work', category: 'decision' },
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
count: 1,
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let capturedUrl = ''
|
|
147
|
+
let capturedBody = ''
|
|
148
|
+
|
|
149
|
+
globalThis.fetch = mock(async (url: string, init: any) => {
|
|
150
|
+
capturedUrl = url
|
|
151
|
+
capturedBody = init.body
|
|
152
|
+
return new Response(JSON.stringify(mockResponse), { status: 200 })
|
|
153
|
+
}) as any
|
|
154
|
+
|
|
155
|
+
const results = await client.recall('test query', { limit: 5, minScore: 0.5 })
|
|
156
|
+
|
|
157
|
+
expect(capturedUrl).toBe('http://localhost:8082/recall')
|
|
158
|
+
expect(JSON.parse(capturedBody)).toEqual({ query: 'test query', limit: 5, minScore: 0.5 })
|
|
159
|
+
expect(results).toHaveLength(1)
|
|
160
|
+
expect(results[0].content).toBe('test fact')
|
|
161
|
+
expect(results[0].score).toBe(0.9)
|
|
162
|
+
expect(results[0].metadata?.type).toBe('fact')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('maps all 5 source types to MemoryResult format', async () => {
|
|
166
|
+
// Brain uses `source` field (not `type`) and nests details in `data`
|
|
167
|
+
const mockResponse = {
|
|
168
|
+
data: [
|
|
169
|
+
{ source: 'fact', id: '1', score: 0.9, data: { id: '1', content: 'a fact' } },
|
|
170
|
+
{ source: 'entity', id: '2', score: 0.8, data: { id: '2', name: 'Alice' } },
|
|
171
|
+
{
|
|
172
|
+
source: 'episode',
|
|
173
|
+
id: '3',
|
|
174
|
+
score: 0.7,
|
|
175
|
+
data: { id: '3', summary: 'session', keyOutcomes: ['shipped v0.2'] },
|
|
176
|
+
},
|
|
177
|
+
{
|
|
178
|
+
source: 'relation',
|
|
179
|
+
id: '4',
|
|
180
|
+
score: 0.6,
|
|
181
|
+
data: { id: '4', description: 'works at', relationType: 'works_at' },
|
|
182
|
+
},
|
|
183
|
+
{ source: 'summary', id: '5', score: 0.5, data: { id: '5', summary: 'weekly summary' } },
|
|
184
|
+
],
|
|
185
|
+
count: 5,
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
globalThis.fetch = mock(() =>
|
|
189
|
+
Promise.resolve(new Response(JSON.stringify(mockResponse), { status: 200 })),
|
|
190
|
+
) as any
|
|
191
|
+
|
|
192
|
+
const results = await client.recall('test')
|
|
193
|
+
|
|
194
|
+
expect(results[0].content).toBe('a fact') // fact → content
|
|
195
|
+
expect(results[1].content).toBe('[Entity: Alice]') // entity → name
|
|
196
|
+
expect(results[2].content).toContain('session') // episode → summary
|
|
197
|
+
expect(results[2].content).toContain('shipped v0.2') // episode → keyOutcomes
|
|
198
|
+
expect(results[3].content).toBe('works at') // relation → description
|
|
199
|
+
expect(results[4].content).toBe('weekly summary') // summary → summary
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it('returns empty array for no results', async () => {
|
|
203
|
+
globalThis.fetch = mock(() =>
|
|
204
|
+
Promise.resolve(new Response(JSON.stringify({ data: [], count: 0 }), { status: 200 })),
|
|
205
|
+
) as any
|
|
206
|
+
|
|
207
|
+
expect(await client.recall('nonexistent')).toHaveLength(0)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('throws with HTTP status on API error', async () => {
|
|
211
|
+
globalThis.fetch = mock(() =>
|
|
212
|
+
Promise.resolve(new Response('Bad Request', { status: 400 })),
|
|
213
|
+
) as any
|
|
214
|
+
|
|
215
|
+
expect(client.recall('test')).rejects.toThrow('Brain recall failed (400)')
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('passes optional filters (category, tags) to API', async () => {
|
|
219
|
+
let capturedBody: any
|
|
220
|
+
|
|
221
|
+
globalThis.fetch = mock(async (_: string, init: any) => {
|
|
222
|
+
capturedBody = JSON.parse(init.body)
|
|
223
|
+
return new Response(JSON.stringify({ data: [], count: 0 }), { status: 200 })
|
|
224
|
+
}) as any
|
|
225
|
+
|
|
226
|
+
await client.recall('query', { limit: 3, category: 'decision', tags: ['work'] })
|
|
227
|
+
|
|
228
|
+
expect(capturedBody.category).toBe('decision')
|
|
229
|
+
expect(capturedBody.tags).toEqual(['work'])
|
|
230
|
+
})
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
// ─── Save ────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
describe('save — generic MemoryProvider batch save', () => {
|
|
236
|
+
it('saves each fact as individual POST request', async () => {
|
|
237
|
+
let callCount = 0
|
|
238
|
+
|
|
239
|
+
globalThis.fetch = mock(async () => {
|
|
240
|
+
callCount++
|
|
241
|
+
return new Response(JSON.stringify({ id: `id-${callCount}` }), { status: 201 })
|
|
242
|
+
}) as any
|
|
243
|
+
|
|
244
|
+
await client.save([
|
|
245
|
+
{ content: 'fact 1', category: 'observation' },
|
|
246
|
+
{ content: 'fact 2', category: 'decision', importance: 7 },
|
|
247
|
+
])
|
|
248
|
+
|
|
249
|
+
expect(callCount).toBe(2)
|
|
250
|
+
})
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
// ─── saveFact (brain-specific) ───────────────────────────────────
|
|
254
|
+
|
|
255
|
+
describe('saveFact — brain-specific fields', () => {
|
|
256
|
+
it('sends all brain-specific fields (domain, tags, decay, confidence)', async () => {
|
|
257
|
+
let capturedBody: any
|
|
258
|
+
|
|
259
|
+
globalThis.fetch = mock(async (_: string, init: any) => {
|
|
260
|
+
capturedBody = JSON.parse(init.body)
|
|
261
|
+
return new Response(JSON.stringify({ id: 'new-id' }), { status: 201 })
|
|
262
|
+
}) as any
|
|
263
|
+
|
|
264
|
+
const id = await client.saveFact({
|
|
265
|
+
content: 'Important decision',
|
|
266
|
+
entityId: 'entity-1',
|
|
267
|
+
domain: 'work',
|
|
268
|
+
category: 'decision',
|
|
269
|
+
importance: 8,
|
|
270
|
+
tags: ['session:2026-02-28'],
|
|
271
|
+
sessionDate: '2026-02-28',
|
|
272
|
+
decayImmune: false,
|
|
273
|
+
confidence: 0.95,
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
expect(id).toBe('new-id')
|
|
277
|
+
expect(capturedBody.content).toBe('Important decision')
|
|
278
|
+
expect(capturedBody.entityId).toBe('entity-1')
|
|
279
|
+
expect(capturedBody.domain).toBe('work')
|
|
280
|
+
expect(capturedBody.importance).toBe(8)
|
|
281
|
+
expect(capturedBody.sessionDate).toBe('2026-02-28')
|
|
282
|
+
expect(capturedBody.decayImmune).toBe(false)
|
|
283
|
+
expect(capturedBody.confidence).toBe(0.95)
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
it('omits undefined optional fields from request body', async () => {
|
|
287
|
+
let capturedBody: any
|
|
288
|
+
|
|
289
|
+
globalThis.fetch = mock(async (_: string, init: any) => {
|
|
290
|
+
capturedBody = JSON.parse(init.body)
|
|
291
|
+
return new Response(JSON.stringify({ id: 'id' }), { status: 201 })
|
|
292
|
+
}) as any
|
|
293
|
+
|
|
294
|
+
await client.saveFact({ content: 'minimal fact' })
|
|
295
|
+
|
|
296
|
+
expect(capturedBody.content).toBe('minimal fact')
|
|
297
|
+
expect(capturedBody.entityId).toBeUndefined()
|
|
298
|
+
expect(capturedBody.domain).toBeUndefined()
|
|
299
|
+
})
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
// ─── Auth ────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
describe('auth — optional Bearer token', () => {
|
|
305
|
+
it('includes Authorization header when apiKey is configured', async () => {
|
|
306
|
+
const authClient = new BrainClient({ url: 'http://brain:8082', apiKey: 'secret' })
|
|
307
|
+
let capturedHeaders: any
|
|
308
|
+
|
|
309
|
+
globalThis.fetch = mock(async (_: string, init: any) => {
|
|
310
|
+
capturedHeaders = init.headers
|
|
311
|
+
return new Response(JSON.stringify({ status: 'ok' }), { status: 200 })
|
|
312
|
+
}) as any
|
|
313
|
+
|
|
314
|
+
await authClient.health()
|
|
315
|
+
expect(capturedHeaders['Authorization']).toBe('Bearer secret')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('omits Authorization header when no apiKey', async () => {
|
|
319
|
+
let capturedHeaders: any
|
|
320
|
+
|
|
321
|
+
globalThis.fetch = mock(async (_: string, init: any) => {
|
|
322
|
+
capturedHeaders = init.headers
|
|
323
|
+
return new Response(JSON.stringify({ status: 'ok' }), { status: 200 })
|
|
324
|
+
}) as any
|
|
325
|
+
|
|
326
|
+
await client.health()
|
|
327
|
+
expect(capturedHeaders['Authorization']).toBeUndefined()
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
// ─── Agent instructions ──────────────────────────────────────────
|
|
332
|
+
|
|
333
|
+
describe('agentInstructions — brain API reference for system prompt', () => {
|
|
334
|
+
it('documents all brain endpoints (recall, save, entities, etc.)', () => {
|
|
335
|
+
const instructions = client.agentInstructions()
|
|
336
|
+
|
|
337
|
+
expect(instructions).toContain('Brain API')
|
|
338
|
+
expect(instructions).toContain('Recall')
|
|
339
|
+
expect(instructions).toContain('Save fact')
|
|
340
|
+
expect(instructions).toContain('Entities')
|
|
341
|
+
expect(instructions).toContain('BRAIN_URL')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('includes curl examples for direct API access', () => {
|
|
345
|
+
const instructions = client.agentInstructions()
|
|
346
|
+
expect(instructions).toContain('```')
|
|
347
|
+
expect(instructions).toContain('curl')
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
it('returns substantial content (not a stub)', () => {
|
|
351
|
+
const instructions = client.agentInstructions()
|
|
352
|
+
expect(typeof instructions).toBe('string')
|
|
353
|
+
expect(instructions.length).toBeGreaterThan(100)
|
|
354
|
+
})
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
// ─── Knowledge graph: entities ───────────────────────────────────
|
|
358
|
+
|
|
359
|
+
describe('createEntity — knowledge graph nodes', () => {
|
|
360
|
+
it('sends entity with type, name, aliases, domains', async () => {
|
|
361
|
+
let capturedBody: any
|
|
362
|
+
|
|
363
|
+
globalThis.fetch = mock(async (_: string, init: any) => {
|
|
364
|
+
capturedBody = JSON.parse(init.body)
|
|
365
|
+
return new Response(JSON.stringify({ id: 'entity-id' }), { status: 201 })
|
|
366
|
+
}) as any
|
|
367
|
+
|
|
368
|
+
const id = await client.createEntity({
|
|
369
|
+
type: 'person',
|
|
370
|
+
name: 'Test Person',
|
|
371
|
+
aliases: ['TP'],
|
|
372
|
+
domains: ['work'],
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
expect(id).toBe('entity-id')
|
|
376
|
+
expect(capturedBody.type).toBe('person')
|
|
377
|
+
expect(capturedBody.name).toBe('Test Person')
|
|
378
|
+
})
|
|
379
|
+
})
|
|
380
|
+
|
|
381
|
+
describe('listEntities — paginated entity retrieval', () => {
|
|
382
|
+
it('unwraps { data, count } response', async () => {
|
|
383
|
+
globalThis.fetch = mock(() =>
|
|
384
|
+
Promise.resolve(
|
|
385
|
+
new Response(
|
|
386
|
+
JSON.stringify({
|
|
387
|
+
data: [{ id: '1', name: 'Alice', type: 'person' }],
|
|
388
|
+
count: 1,
|
|
389
|
+
}),
|
|
390
|
+
{ status: 200 },
|
|
391
|
+
),
|
|
392
|
+
),
|
|
393
|
+
) as any
|
|
394
|
+
|
|
395
|
+
const entities = await client.listEntities({ limit: 5 })
|
|
396
|
+
expect(entities).toHaveLength(1)
|
|
397
|
+
expect((entities[0] as any).name).toBe('Alice')
|
|
398
|
+
})
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
// ─── Knowledge graph: relations ──────────────────────────────────
|
|
402
|
+
|
|
403
|
+
describe('createRelation — knowledge graph edges', () => {
|
|
404
|
+
it('sends relation with source, target, type, strength', async () => {
|
|
405
|
+
let capturedBody: any
|
|
406
|
+
|
|
407
|
+
globalThis.fetch = mock(async (_: string, init: any) => {
|
|
408
|
+
capturedBody = JSON.parse(init.body)
|
|
409
|
+
return new Response(JSON.stringify({ id: 'rel-id' }), { status: 201 })
|
|
410
|
+
}) as any
|
|
411
|
+
|
|
412
|
+
const id = await client.createRelation({
|
|
413
|
+
sourceId: 'entity-1',
|
|
414
|
+
targetId: 'entity-2',
|
|
415
|
+
relationType: 'works_at',
|
|
416
|
+
strength: 'strong',
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
expect(id).toBe('rel-id')
|
|
420
|
+
expect(capturedBody.relationType).toBe('works_at')
|
|
421
|
+
})
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
// ─── Pipeline extraction ─────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
describe('extractPipeline — LLM-powered conversation extraction', () => {
|
|
427
|
+
it('sends conversation text and returns extraction counts', async () => {
|
|
428
|
+
let capturedBody: any
|
|
429
|
+
|
|
430
|
+
globalThis.fetch = mock(async (_: string, init: any) => {
|
|
431
|
+
capturedBody = JSON.parse(init.body)
|
|
432
|
+
return new Response(
|
|
433
|
+
JSON.stringify({
|
|
434
|
+
facts: 3,
|
|
435
|
+
entities: 1,
|
|
436
|
+
relations: 2,
|
|
437
|
+
episodeId: 'ep-1',
|
|
438
|
+
}),
|
|
439
|
+
{ status: 200 },
|
|
440
|
+
)
|
|
441
|
+
}) as any
|
|
442
|
+
|
|
443
|
+
const result = await client.extractPipeline('conversation text', 'research', '2026-02-28')
|
|
444
|
+
|
|
445
|
+
expect(result.facts).toBe(3)
|
|
446
|
+
expect(result.episodeId).toBe('ep-1')
|
|
447
|
+
expect(capturedBody.conversation).toBe('conversation text')
|
|
448
|
+
expect(capturedBody.channel).toBe('research')
|
|
449
|
+
})
|
|
450
|
+
})
|
|
451
|
+
|
|
452
|
+
// ─── MemoryProvider contract ─────────────────────────────────────
|
|
453
|
+
|
|
454
|
+
describe('MemoryProvider interface compliance', () => {
|
|
455
|
+
it('exposes required id="brain" and name="Lunar Brain"', () => {
|
|
456
|
+
expect(client.id).toBe('brain')
|
|
457
|
+
expect(client.name).toBe('Lunar Brain')
|
|
458
|
+
})
|
|
459
|
+
|
|
460
|
+
it('implements all 5 required methods', () => {
|
|
461
|
+
expect(typeof client.init).toBe('function')
|
|
462
|
+
expect(typeof client.destroy).toBe('function')
|
|
463
|
+
expect(typeof client.recall).toBe('function')
|
|
464
|
+
expect(typeof client.save).toBe('function')
|
|
465
|
+
expect(typeof client.health).toBe('function')
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
it('destroy() is a clean no-op (HTTP client, no teardown needed)', async () => {
|
|
469
|
+
await client.destroy() // should not throw
|
|
470
|
+
})
|
|
471
|
+
})
|
|
472
|
+
})
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
import type { Fact, MemoryProvider, MemoryResult, SearchOptions } from '@onmars/lunar-core'
|
|
2
|
+
import { log } from '@onmars/lunar-core'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Brain-specific fact with extra fields beyond the generic Fact interface.
|
|
6
|
+
* Use this when you need brain-specific features (entityId, domain, decayImmune, etc.)
|
|
7
|
+
*/
|
|
8
|
+
export interface BrainFact extends Fact {
|
|
9
|
+
/** Entity this fact belongs to */
|
|
10
|
+
entityId?: string
|
|
11
|
+
/** Knowledge domain */
|
|
12
|
+
domain?: string
|
|
13
|
+
/** Confidence score (0.0-1.0) */
|
|
14
|
+
confidence?: number
|
|
15
|
+
/** Session date (YYYY-MM-DD) */
|
|
16
|
+
sessionDate?: string
|
|
17
|
+
/** Whether this fact is immune to decay */
|
|
18
|
+
decayImmune?: boolean
|
|
19
|
+
/** Episode ID this fact belongs to */
|
|
20
|
+
episodeId?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Brain entity for the knowledge graph.
|
|
25
|
+
*/
|
|
26
|
+
export interface BrainEntity {
|
|
27
|
+
type: string
|
|
28
|
+
name: string
|
|
29
|
+
aliases?: string[]
|
|
30
|
+
domains?: string[]
|
|
31
|
+
subtype?: string
|
|
32
|
+
meta?: Record<string, unknown>
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Brain relation between two entities.
|
|
37
|
+
*/
|
|
38
|
+
export interface BrainRelation {
|
|
39
|
+
sourceId: string
|
|
40
|
+
targetId: string
|
|
41
|
+
relationType: string
|
|
42
|
+
strength?: 'weak' | 'moderate' | 'strong'
|
|
43
|
+
description?: string
|
|
44
|
+
importance?: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Brain episode — groups all facts from a session.
|
|
49
|
+
*/
|
|
50
|
+
export interface BrainEpisode {
|
|
51
|
+
id: string
|
|
52
|
+
sessionDate: string
|
|
53
|
+
agentId: string
|
|
54
|
+
channel?: string
|
|
55
|
+
summary: string
|
|
56
|
+
keyOutcomes: string[]
|
|
57
|
+
topics: string[]
|
|
58
|
+
moodEnergy?: Record<string, unknown>
|
|
59
|
+
factIds: string[]
|
|
60
|
+
entityIds: string[]
|
|
61
|
+
messageCount?: number
|
|
62
|
+
durationMinutes?: number
|
|
63
|
+
importance: number
|
|
64
|
+
createdAt: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Raw recall result from the brain API.
|
|
69
|
+
* API returns { data: BrainRecallResult[], count: number }
|
|
70
|
+
* Each result has: source (type), id, score, data (nested object with details)
|
|
71
|
+
*/
|
|
72
|
+
interface BrainRecallResult {
|
|
73
|
+
source: 'fact' | 'entity' | 'relation' | 'episode' | 'summary'
|
|
74
|
+
id: string
|
|
75
|
+
score: number
|
|
76
|
+
data: {
|
|
77
|
+
id: string
|
|
78
|
+
content?: string
|
|
79
|
+
name?: string
|
|
80
|
+
summary?: string
|
|
81
|
+
domain?: string
|
|
82
|
+
category?: string
|
|
83
|
+
importance?: number
|
|
84
|
+
entityId?: string
|
|
85
|
+
tags?: string[]
|
|
86
|
+
createdAt?: string
|
|
87
|
+
sessionDate?: string
|
|
88
|
+
description?: string
|
|
89
|
+
relationType?: string
|
|
90
|
+
keyOutcomes?: string[]
|
|
91
|
+
[key: string]: unknown
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Wrapper for paginated API responses.
|
|
97
|
+
*/
|
|
98
|
+
interface BrainPaginatedResponse<T> {
|
|
99
|
+
data: T[]
|
|
100
|
+
count: number
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface BrainClientOptions {
|
|
104
|
+
/** Brain API base URL */
|
|
105
|
+
url: string
|
|
106
|
+
/** API key for authenticated brains (optional) */
|
|
107
|
+
apiKey?: string
|
|
108
|
+
/** Request timeout in milliseconds (default: 10000) */
|
|
109
|
+
timeoutMs?: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* BrainClient — MemoryProvider implementation for lunar-brain.
|
|
114
|
+
*
|
|
115
|
+
* Wraps the lunar-brain REST API (Postgres + pgvector + BM25).
|
|
116
|
+
* Implements the generic MemoryProvider interface for framework integration,
|
|
117
|
+
* plus brain-specific methods for advanced usage.
|
|
118
|
+
*/
|
|
119
|
+
export class BrainClient implements MemoryProvider {
|
|
120
|
+
readonly id = 'brain'
|
|
121
|
+
readonly name = 'Lunar Brain'
|
|
122
|
+
|
|
123
|
+
private url: string
|
|
124
|
+
private apiKey?: string
|
|
125
|
+
private timeoutMs: number
|
|
126
|
+
|
|
127
|
+
constructor(options: BrainClientOptions) {
|
|
128
|
+
// Normalize URL: remove trailing slash
|
|
129
|
+
this.url = options.url.replace(/\/+$/, '')
|
|
130
|
+
this.apiKey = options.apiKey
|
|
131
|
+
this.timeoutMs = options.timeoutMs ?? 10_000
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── MemoryProvider interface ──────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
async init(): Promise<void> {
|
|
137
|
+
const result = await this.health()
|
|
138
|
+
if (!result.ok) {
|
|
139
|
+
throw new Error(`Brain health check failed: ${result.error}`)
|
|
140
|
+
}
|
|
141
|
+
log.info({ url: this.url }, 'Brain memory provider initialized')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async destroy(): Promise<void> {
|
|
145
|
+
// No persistent connections to clean up
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Agent instructions — injected into system prompt.
|
|
150
|
+
* Tells the agent what the brain is, how to query it, and when to save.
|
|
151
|
+
*/
|
|
152
|
+
agentInstructions(): string {
|
|
153
|
+
const fence = '`' + '`' + '`'
|
|
154
|
+
return [
|
|
155
|
+
'## Memory — Long-term Knowledge (Brain API)',
|
|
156
|
+
'',
|
|
157
|
+
'You have access to a Brain API for persistent semantic memory.',
|
|
158
|
+
'The system auto-recalls relevant memories before each query (see "Memory Context" below if present).',
|
|
159
|
+
'',
|
|
160
|
+
'### Direct access (for specific queries)',
|
|
161
|
+
'',
|
|
162
|
+
'**Recall** — search across facts, entities, relations, episodes:',
|
|
163
|
+
fence + 'bash',
|
|
164
|
+
"curl -s -X POST $BRAIN_URL/recall -H 'Content-Type: application/json' \\",
|
|
165
|
+
' -d \'{"query": "topic or question", "limit": 5}\'',
|
|
166
|
+
fence,
|
|
167
|
+
'',
|
|
168
|
+
'**Save fact** — store decisions, learnings, observations:',
|
|
169
|
+
fence + 'bash',
|
|
170
|
+
"curl -s -X POST $BRAIN_URL/facts -H 'Content-Type: application/json' \\",
|
|
171
|
+
' -d \'{"content": "...", "domain": "work|personal|...", "category": "decision|learning|...", "importance": 7, "tags": ["..."], "sessionDate": "YYYY-MM-DD"}\'',
|
|
172
|
+
fence,
|
|
173
|
+
'',
|
|
174
|
+
'**Entities** — knowledge graph nodes (people, projects, orgs):',
|
|
175
|
+
fence + 'bash',
|
|
176
|
+
'curl -s $BRAIN_URL/entities?limit=10 # list',
|
|
177
|
+
'curl -s $BRAIN_URL/entities/<id> # entity + relations + facts',
|
|
178
|
+
fence,
|
|
179
|
+
'',
|
|
180
|
+
'### When to use',
|
|
181
|
+
'- **Recall**: before answering about people, projects, decisions, preferences, history',
|
|
182
|
+
'- **Save**: after important decisions, learnings, milestones, or configuration changes',
|
|
183
|
+
'- Auto-recalled context (if any) appears at the end of the system prompt',
|
|
184
|
+
].join('\n')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Semantic recall — searches across facts, entities, relations, episodes, summaries.
|
|
189
|
+
*/
|
|
190
|
+
async recall(query: string, options?: SearchOptions): Promise<MemoryResult[]> {
|
|
191
|
+
const body: Record<string, unknown> = {
|
|
192
|
+
query,
|
|
193
|
+
limit: options?.limit ?? 10,
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (options?.minScore !== undefined) body.minScore = options.minScore
|
|
197
|
+
if (options?.category) body.category = options.category
|
|
198
|
+
if (options?.tags?.length) body.tags = options.tags
|
|
199
|
+
|
|
200
|
+
const response = await this.fetch('/recall', {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
body: JSON.stringify(body),
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
const text = await response.text()
|
|
207
|
+
throw new Error(`Brain recall failed (${response.status}): ${text}`)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const result = (await response.json()) as BrainPaginatedResponse<BrainRecallResult>
|
|
211
|
+
|
|
212
|
+
return result.data.map((r) => this.toMemoryResult(r))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Save facts to brain.
|
|
217
|
+
* Uses the generic Fact interface. For brain-specific fields (domain, entityId, etc.),
|
|
218
|
+
* use saveFact() instead.
|
|
219
|
+
*/
|
|
220
|
+
async save(facts: Fact[]): Promise<string[]> {
|
|
221
|
+
const ids: string[] = []
|
|
222
|
+
for (const fact of facts) {
|
|
223
|
+
const id = await this.saveFact({
|
|
224
|
+
content: fact.content,
|
|
225
|
+
category: fact.category,
|
|
226
|
+
domain: fact.domain ?? 'general',
|
|
227
|
+
tags: fact.tags,
|
|
228
|
+
importance: fact.importance ?? 5,
|
|
229
|
+
source: fact.source,
|
|
230
|
+
timestamp: fact.timestamp,
|
|
231
|
+
sessionDate: (fact as BrainFact).sessionDate,
|
|
232
|
+
episodeId: (fact as BrainFact).episodeId,
|
|
233
|
+
})
|
|
234
|
+
ids.push(id)
|
|
235
|
+
}
|
|
236
|
+
return ids
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async health(): Promise<{ ok: boolean; error?: string }> {
|
|
240
|
+
try {
|
|
241
|
+
const response = await this.fetch('/health', { method: 'GET' })
|
|
242
|
+
if (!response.ok) {
|
|
243
|
+
return { ok: false, error: `HTTP ${response.status}` }
|
|
244
|
+
}
|
|
245
|
+
const data = (await response.json()) as { status: string }
|
|
246
|
+
return { ok: data.status === 'ok', error: data.status !== 'ok' ? 'Unhealthy' : undefined }
|
|
247
|
+
} catch (err) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
error: err instanceof Error ? err.message : String(err),
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── Brain-specific methods ────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Save a single fact with brain-specific fields.
|
|
259
|
+
* Returns the created fact ID.
|
|
260
|
+
*/
|
|
261
|
+
async saveFact(fact: BrainFact): Promise<string> {
|
|
262
|
+
const body: Record<string, unknown> = {
|
|
263
|
+
content: fact.content,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (fact.entityId) body.entityId = fact.entityId
|
|
267
|
+
if (fact.domain) body.domain = fact.domain
|
|
268
|
+
if (fact.category) body.category = fact.category
|
|
269
|
+
if (fact.confidence !== undefined) body.confidence = fact.confidence
|
|
270
|
+
if (fact.importance !== undefined) body.importance = fact.importance
|
|
271
|
+
if (fact.tags?.length) body.tags = fact.tags
|
|
272
|
+
if (fact.sessionDate) body.sessionDate = fact.sessionDate
|
|
273
|
+
if (fact.decayImmune !== undefined) body.decayImmune = fact.decayImmune
|
|
274
|
+
if (fact.episodeId) body.episodeId = fact.episodeId
|
|
275
|
+
|
|
276
|
+
const response = await this.fetch('/facts', {
|
|
277
|
+
method: 'POST',
|
|
278
|
+
body: JSON.stringify(body),
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
if (!response.ok) {
|
|
282
|
+
const text = await response.text()
|
|
283
|
+
throw new Error(`Brain saveFact failed (${response.status}): ${text}`)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const result = (await response.json()) as { id: string }
|
|
287
|
+
return result.id
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Create a brain entity.
|
|
292
|
+
* Returns the created entity ID.
|
|
293
|
+
*/
|
|
294
|
+
async createEntity(entity: BrainEntity): Promise<string> {
|
|
295
|
+
const response = await this.fetch('/entities', {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
body: JSON.stringify(entity),
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
if (!response.ok) {
|
|
301
|
+
const text = await response.text()
|
|
302
|
+
throw new Error(`Brain createEntity failed (${response.status}): ${text}`)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const result = (await response.json()) as { id: string }
|
|
306
|
+
return result.id
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Create a relation between two entities.
|
|
311
|
+
* Returns the created relation ID.
|
|
312
|
+
*/
|
|
313
|
+
async createRelation(relation: BrainRelation): Promise<string> {
|
|
314
|
+
const response = await this.fetch('/relations', {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
body: JSON.stringify(relation),
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
if (!response.ok) {
|
|
320
|
+
const text = await response.text()
|
|
321
|
+
throw new Error(`Brain createRelation failed (${response.status}): ${text}`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const result = (await response.json()) as { id: string }
|
|
325
|
+
return result.id
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Run the extraction pipeline on a conversation.
|
|
330
|
+
* Extracts facts, entities, relations and creates an episode automatically.
|
|
331
|
+
*/
|
|
332
|
+
async extractPipeline(
|
|
333
|
+
conversation: string,
|
|
334
|
+
channel?: string,
|
|
335
|
+
sessionDate?: string,
|
|
336
|
+
): Promise<{ facts: number; entities: number; relations: number; episodeId?: string }> {
|
|
337
|
+
const response = await this.fetch('/pipeline/extract', {
|
|
338
|
+
method: 'POST',
|
|
339
|
+
body: JSON.stringify({
|
|
340
|
+
conversation,
|
|
341
|
+
channel,
|
|
342
|
+
agentId: 'lunar',
|
|
343
|
+
sessionDate,
|
|
344
|
+
}),
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
if (!response.ok) {
|
|
348
|
+
const text = await response.text()
|
|
349
|
+
throw new Error(`Brain pipeline failed (${response.status}): ${text}`)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
return (await response.json()) as {
|
|
353
|
+
facts: number
|
|
354
|
+
entities: number
|
|
355
|
+
relations: number
|
|
356
|
+
episodeId?: string
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Get an entity with its full graph (relations + facts).
|
|
362
|
+
*/
|
|
363
|
+
async getEntity(id: string): Promise<Record<string, unknown> | null> {
|
|
364
|
+
const response = await this.fetch(`/entities/${id}`, { method: 'GET' })
|
|
365
|
+
if (response.status === 404) return null
|
|
366
|
+
if (!response.ok) {
|
|
367
|
+
const text = await response.text()
|
|
368
|
+
throw new Error(`Brain getEntity failed (${response.status}): ${text}`)
|
|
369
|
+
}
|
|
370
|
+
return (await response.json()) as Record<string, unknown>
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* List entities with optional filters.
|
|
375
|
+
*/
|
|
376
|
+
async listEntities(filters?: {
|
|
377
|
+
type?: string
|
|
378
|
+
search?: string
|
|
379
|
+
limit?: number
|
|
380
|
+
}): Promise<Record<string, unknown>[]> {
|
|
381
|
+
const params = new URLSearchParams()
|
|
382
|
+
if (filters?.type) params.set('type', filters.type)
|
|
383
|
+
if (filters?.search) params.set('search', filters.search)
|
|
384
|
+
if (filters?.limit) params.set('limit', String(filters.limit))
|
|
385
|
+
|
|
386
|
+
const qs = params.toString()
|
|
387
|
+
const response = await this.fetch(`/entities${qs ? `?${qs}` : ''}`, { method: 'GET' })
|
|
388
|
+
|
|
389
|
+
if (!response.ok) {
|
|
390
|
+
const text = await response.text()
|
|
391
|
+
throw new Error(`Brain listEntities failed (${response.status}): ${text}`)
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const result = (await response.json()) as { data?: Record<string, unknown>[] }
|
|
395
|
+
return result.data ?? (result as unknown as Record<string, unknown>[])
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Graph traversal — BFS multi-hop from an entity.
|
|
400
|
+
*/
|
|
401
|
+
async graphTraverse(entityId: string, maxHops?: number): Promise<Record<string, unknown>> {
|
|
402
|
+
const response = await this.fetch('/graph/traverse', {
|
|
403
|
+
method: 'POST',
|
|
404
|
+
body: JSON.stringify({ entityId, maxHops: maxHops ?? 2 }),
|
|
405
|
+
})
|
|
406
|
+
|
|
407
|
+
if (!response.ok) {
|
|
408
|
+
const text = await response.text()
|
|
409
|
+
throw new Error(`Brain graphTraverse failed (${response.status}): ${text}`)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
return (await response.json()) as Record<string, unknown>
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Temporal query — what changed since a date.
|
|
417
|
+
*/
|
|
418
|
+
async graphTemporal(since: string): Promise<Record<string, unknown>> {
|
|
419
|
+
const response = await this.fetch('/graph/temporal', {
|
|
420
|
+
method: 'POST',
|
|
421
|
+
body: JSON.stringify({ since }),
|
|
422
|
+
})
|
|
423
|
+
|
|
424
|
+
if (!response.ok) {
|
|
425
|
+
const text = await response.text()
|
|
426
|
+
throw new Error(`Brain graphTemporal failed (${response.status}): ${text}`)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return (await response.json()) as Record<string, unknown>
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Query facts by direct filters (not semantic recall).
|
|
434
|
+
* Used by diary recall to get facts by date + domain.
|
|
435
|
+
*/
|
|
436
|
+
async queryFacts(filters: {
|
|
437
|
+
sessionDate?: string
|
|
438
|
+
domain?: string
|
|
439
|
+
category?: string
|
|
440
|
+
episodeId?: string
|
|
441
|
+
limit?: number
|
|
442
|
+
}): Promise<
|
|
443
|
+
Array<{
|
|
444
|
+
id: string
|
|
445
|
+
content: string
|
|
446
|
+
domain: string
|
|
447
|
+
category: string
|
|
448
|
+
episodeId?: string
|
|
449
|
+
sessionDate?: string
|
|
450
|
+
importance: number
|
|
451
|
+
createdAt: string
|
|
452
|
+
}>
|
|
453
|
+
> {
|
|
454
|
+
const params = new URLSearchParams()
|
|
455
|
+
if (filters.sessionDate) params.set('sessionDate', filters.sessionDate)
|
|
456
|
+
if (filters.domain) params.set('domain', filters.domain)
|
|
457
|
+
if (filters.category) params.set('category', filters.category)
|
|
458
|
+
if (filters.episodeId) params.set('episodeId', filters.episodeId)
|
|
459
|
+
if (filters.limit) params.set('limit', String(filters.limit))
|
|
460
|
+
|
|
461
|
+
const response = await this.fetch(`/facts?${params}`, { method: 'GET' })
|
|
462
|
+
if (!response.ok) {
|
|
463
|
+
const text = await response.text()
|
|
464
|
+
throw new Error(`Brain queryFacts failed (${response.status}): ${text}`)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const result = (await response.json()) as {
|
|
468
|
+
data: Array<Record<string, unknown>>
|
|
469
|
+
count: number
|
|
470
|
+
}
|
|
471
|
+
return result.data as any
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ─── Episode management ─────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Get or create an episode for a session date + channel.
|
|
478
|
+
* If an episode already exists for today+channel, returns it.
|
|
479
|
+
* Otherwise creates a new one.
|
|
480
|
+
*/
|
|
481
|
+
async getOrCreateEpisode(
|
|
482
|
+
sessionDate: string,
|
|
483
|
+
channel?: string,
|
|
484
|
+
agentId = 'lunar',
|
|
485
|
+
): Promise<BrainEpisode> {
|
|
486
|
+
// Try to find existing episode for this date+channel
|
|
487
|
+
const params = new URLSearchParams({ date: sessionDate })
|
|
488
|
+
if (channel) params.set('channel', channel)
|
|
489
|
+
params.set('limit', '1')
|
|
490
|
+
|
|
491
|
+
const response = await this.fetch(`/episodes?${params}`, { method: 'GET' })
|
|
492
|
+
if (response.ok) {
|
|
493
|
+
const result = (await response.json()) as { data: BrainEpisode[]; count: number }
|
|
494
|
+
if (result.data.length > 0) {
|
|
495
|
+
log.debug({ episodeId: result.data[0].id, sessionDate, channel }, 'Existing episode found')
|
|
496
|
+
return result.data[0]
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Create new episode
|
|
501
|
+
const createResponse = await this.fetch('/episodes', {
|
|
502
|
+
method: 'POST',
|
|
503
|
+
body: JSON.stringify({
|
|
504
|
+
sessionDate,
|
|
505
|
+
channel: channel || null,
|
|
506
|
+
agentId,
|
|
507
|
+
summary: '',
|
|
508
|
+
}),
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
if (!createResponse.ok) {
|
|
512
|
+
const text = await createResponse.text()
|
|
513
|
+
throw new Error(`Brain createEpisode failed (${createResponse.status}): ${text}`)
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const episode = (await createResponse.json()) as BrainEpisode
|
|
517
|
+
log.info({ episodeId: episode.id, sessionDate, channel }, 'New episode created')
|
|
518
|
+
return episode
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Update an episode (e.g., add factIds, summary, topics at session close).
|
|
523
|
+
*/
|
|
524
|
+
async updateEpisode(
|
|
525
|
+
id: string,
|
|
526
|
+
data: Partial<
|
|
527
|
+
Pick<
|
|
528
|
+
BrainEpisode,
|
|
529
|
+
| 'factIds'
|
|
530
|
+
| 'entityIds'
|
|
531
|
+
| 'summary'
|
|
532
|
+
| 'keyOutcomes'
|
|
533
|
+
| 'topics'
|
|
534
|
+
| 'moodEnergy'
|
|
535
|
+
| 'messageCount'
|
|
536
|
+
| 'importance'
|
|
537
|
+
>
|
|
538
|
+
>,
|
|
539
|
+
): Promise<BrainEpisode> {
|
|
540
|
+
const response = await this.fetch(`/episodes/${id}`, {
|
|
541
|
+
method: 'PATCH',
|
|
542
|
+
body: JSON.stringify(data),
|
|
543
|
+
})
|
|
544
|
+
|
|
545
|
+
if (!response.ok) {
|
|
546
|
+
const text = await response.text()
|
|
547
|
+
throw new Error(`Brain updateEpisode failed (${response.status}): ${text}`)
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
return (await response.json()) as BrainEpisode
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ─── Private ───────────────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
private async fetch(path: string, init: RequestInit): Promise<Response> {
|
|
556
|
+
const headers: Record<string, string> = {
|
|
557
|
+
'Content-Type': 'application/json',
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (this.apiKey) {
|
|
561
|
+
headers['Authorization'] = `Bearer ${this.apiKey}`
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const controller = new AbortController()
|
|
565
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs)
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
return await fetch(`${this.url}${path}`, {
|
|
569
|
+
...init,
|
|
570
|
+
headers: { ...headers, ...(init.headers as Record<string, string>) },
|
|
571
|
+
signal: controller.signal,
|
|
572
|
+
})
|
|
573
|
+
} catch (err) {
|
|
574
|
+
if (err instanceof DOMException && err.name === 'AbortError') {
|
|
575
|
+
throw new Error(`Brain request timed out after ${this.timeoutMs}ms: ${path}`)
|
|
576
|
+
}
|
|
577
|
+
throw err
|
|
578
|
+
} finally {
|
|
579
|
+
clearTimeout(timeout)
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Map a brain recall result to the generic MemoryResult format.
|
|
585
|
+
* Brain API returns: { source, id, score, data: { ...details } }
|
|
586
|
+
*/
|
|
587
|
+
private toMemoryResult(r: BrainRecallResult): MemoryResult {
|
|
588
|
+
const d = r.data
|
|
589
|
+
|
|
590
|
+
// Extract the best content text based on result source type
|
|
591
|
+
let content: string
|
|
592
|
+
switch (r.source) {
|
|
593
|
+
case 'fact':
|
|
594
|
+
content = d.content ?? ''
|
|
595
|
+
break
|
|
596
|
+
case 'entity':
|
|
597
|
+
content = `[Entity: ${d.name ?? 'unknown'}]`
|
|
598
|
+
break
|
|
599
|
+
case 'relation':
|
|
600
|
+
content = d.description ?? `[Relation: ${d.relationType ?? r.source}]`
|
|
601
|
+
break
|
|
602
|
+
case 'episode':
|
|
603
|
+
content = d.summary ?? ''
|
|
604
|
+
if (d.keyOutcomes?.length) {
|
|
605
|
+
content += ` | Outcomes: ${d.keyOutcomes.join(', ')}`
|
|
606
|
+
}
|
|
607
|
+
break
|
|
608
|
+
case 'summary':
|
|
609
|
+
content = d.summary ?? ''
|
|
610
|
+
break
|
|
611
|
+
default:
|
|
612
|
+
content = d.content ?? d.summary ?? d.name ?? ''
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
content,
|
|
617
|
+
score: r.score,
|
|
618
|
+
metadata: {
|
|
619
|
+
type: r.source,
|
|
620
|
+
id: r.id,
|
|
621
|
+
domain: d.domain,
|
|
622
|
+
category: d.category,
|
|
623
|
+
importance: d.importance,
|
|
624
|
+
entityId: d.entityId,
|
|
625
|
+
tags: d.tags,
|
|
626
|
+
sessionDate: d.sessionDate,
|
|
627
|
+
},
|
|
628
|
+
storedAt: d.createdAt ? new Date(d.createdAt) : undefined,
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
package/src/index.ts
ADDED