@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 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
@@ -0,0 +1,2 @@
1
+ export type { BrainClientOptions, BrainEntity, BrainFact, BrainRelation } from './client'
2
+ export { BrainClient } from './client'