@onmars/lunar-memory-engram 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-engram
2
+
3
+ Engram memory provider (MCP-based local memory) 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,32 @@
1
+ {
2
+ "name": "@onmars/lunar-memory-engram",
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
+ "@onmars/lunar-mcp": "0.1.0"
14
+ },
15
+ "description": "Engram memory provider for Lunar (MCP-based local memory)",
16
+ "author": "onMars Tech",
17
+ "license": "MIT",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "https://github.com/onmars-tech/lunar",
21
+ "directory": "packages/memory-engram"
22
+ },
23
+ "homepage": "https://github.com/onmars-tech/lunar",
24
+ "bugs": "https://github.com/onmars-tech/lunar/issues",
25
+ "keywords": ["lunar", "memory", "engram", "mcp", "bun"],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "engines": {
30
+ "bun": ">=1.2"
31
+ }
32
+ }
@@ -0,0 +1,495 @@
1
+ /**
2
+ * EngramMemoryAdapter test suite
3
+ *
4
+ * Tests the adapter using the mock MCP server from @onmars/lunar-mcp.
5
+ * Validates: init, recall, save, onSessionEnd, health, agentInstructions.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
9
+ import { resolve } from 'node:path'
10
+ import { MCPConnection } from '@onmars/lunar-mcp'
11
+ import { EngramMemoryAdapter } from '../lib/adapter'
12
+
13
+ const MOCK_SERVER = resolve(
14
+ import.meta.dir,
15
+ '../../..',
16
+ 'mcp',
17
+ 'src',
18
+ '__tests__',
19
+ 'mock-mcp-server.ts',
20
+ )
21
+ const BUN = process.env.HOME + '/.bun/bin/bun'
22
+
23
+ describe('EngramMemoryAdapter', () => {
24
+ let conn: MCPConnection
25
+ let adapter: EngramMemoryAdapter
26
+
27
+ beforeEach(async () => {
28
+ conn = new MCPConnection({
29
+ id: 'engram-test',
30
+ command: `${BUN} run ${MOCK_SERVER}`,
31
+ timeoutMs: 5000,
32
+ autoRestart: false,
33
+ })
34
+ await conn.connect()
35
+ adapter = new EngramMemoryAdapter(conn, { project: 'test-project' })
36
+ })
37
+
38
+ afterEach(() => {
39
+ conn.kill()
40
+ })
41
+
42
+ // ═══════════════════════════════════════════════════════════
43
+ // Identity
44
+ // ═══════════════════════════════════════════════════════════
45
+
46
+ test('has correct id and name', () => {
47
+ expect(adapter.id).toBe('engram')
48
+ expect(adapter.name).toBe('Engram Session Memory')
49
+ })
50
+
51
+ test('has recall and session traits', () => {
52
+ expect(adapter.traits.has('recall')).toBe(true)
53
+ expect(adapter.traits.has('session')).toBe(true)
54
+ expect(adapter.traits.has('semantic')).toBe(false)
55
+ expect(adapter.traits.has('graph')).toBe(false)
56
+ })
57
+
58
+ // ═══════════════════════════════════════════════════════════
59
+ // Init
60
+ // ═══════════════════════════════════════════════════════════
61
+
62
+ test('init starts a session via MCP', async () => {
63
+ await adapter.init()
64
+ // After init, connection should still be healthy (session was started)
65
+ const health = await adapter.health()
66
+ expect(health.ok).toBe(true)
67
+ })
68
+
69
+ test('init throws when connection is not ready', async () => {
70
+ conn.kill()
71
+ const deadAdapter = new EngramMemoryAdapter(conn)
72
+ expect(deadAdapter.init()).rejects.toThrow('not ready')
73
+ })
74
+
75
+ // ═══════════════════════════════════════════════════════════
76
+ // Recall
77
+ // ═══════════════════════════════════════════════════════════
78
+
79
+ test('recall returns search results mapped to MemoryResult', async () => {
80
+ const results = await adapter.recall('auth')
81
+ expect(results.length).toBeGreaterThan(0)
82
+
83
+ const searchResults = results.filter((r) => r.source === 'engram:search')
84
+ expect(searchResults.length).toBe(2) // Mock returns 2 results
85
+
86
+ expect(searchResults[0].content).toContain('Auth middleware')
87
+ expect(searchResults[0].score).toBe(0.9)
88
+ expect(searchResults[0].metadata?.title).toBe('Auth middleware')
89
+ })
90
+
91
+ test('recall includes context results when enabled', async () => {
92
+ const results = await adapter.recall('anything')
93
+ const contextResults = results.filter((r) => r.source === 'engram:context')
94
+ expect(contextResults.length).toBe(1)
95
+ expect(contextResults[0].content).toContain('MCP integration')
96
+ })
97
+
98
+ test('recall without context when disabled', async () => {
99
+ const noCtxAdapter = new EngramMemoryAdapter(conn, { includeContext: false })
100
+ const results = await noCtxAdapter.recall('auth')
101
+ const contextResults = results.filter((r) => r.source === 'engram:context')
102
+ expect(contextResults.length).toBe(0)
103
+ })
104
+
105
+ test('recall with custom limit', async () => {
106
+ const results = await adapter.recall('auth', { limit: 5 })
107
+ expect(results.length).toBeGreaterThan(0)
108
+ })
109
+
110
+ // ═══════════════════════════════════════════════════════════
111
+ // Save
112
+ // ═══════════════════════════════════════════════════════════
113
+
114
+ test('save sends facts as mem_save calls', async () => {
115
+ await adapter.init()
116
+ // save() should complete without error; verify connection stays healthy
117
+ await adapter.save([
118
+ { content: 'Authentication uses JWT tokens', title: 'Auth pattern', type: 'decision' },
119
+ { content: 'PostgreSQL for persistence', title: 'DB choice', type: 'architecture' },
120
+ ])
121
+ const health = await adapter.health()
122
+ expect(health.ok).toBe(true)
123
+ })
124
+
125
+ test('save generates title from content when missing', async () => {
126
+ // When no title is provided, save() should use content.slice(0, 60) as title
127
+ // This should complete without error on the mock server
128
+ await adapter.save([{ content: 'Some important observation without a title field' }])
129
+ const health = await adapter.health()
130
+ expect(health.ok).toBe(true)
131
+ })
132
+
133
+ // ═══════════════════════════════════════════════════════════
134
+ // Session end
135
+ // ═══════════════════════════════════════════════════════════
136
+
137
+ test('onSessionEnd sends summary and ends session', async () => {
138
+ await adapter.init()
139
+ await adapter.onSessionEnd({
140
+ messages: [{ role: 'user', content: 'Build the auth system' }],
141
+ sessionSummary: 'Implemented JWT auth with refresh tokens',
142
+ topics: ['auth', 'security'],
143
+ })
144
+ // Session ID should be cleared; health should still be ok
145
+ const health = await adapter.health()
146
+ expect(health.ok).toBe(true)
147
+ })
148
+
149
+ test('onSessionEnd builds summary from messages when none provided', async () => {
150
+ await adapter.init()
151
+ await adapter.onSessionEnd({
152
+ messages: [
153
+ { role: 'user', content: 'Fix the login bug' },
154
+ { role: 'assistant', content: 'Fixed by updating the JWT validation' },
155
+ { role: 'user', content: 'Now add rate limiting' },
156
+ ],
157
+ topics: ['auth', 'security'],
158
+ })
159
+ // Verify the operation completed and connection is still healthy
160
+ const health = await adapter.health()
161
+ expect(health.ok).toBe(true)
162
+ })
163
+
164
+ test('onSessionEnd is no-op without init', async () => {
165
+ // No init => no sessionId => onSessionEnd returns early
166
+ // Verify recall still works (adapter is not in broken state)
167
+ await adapter.onSessionEnd({
168
+ messages: [{ role: 'user', content: 'test' }],
169
+ })
170
+ const results = await adapter.recall('test')
171
+ expect(results.length).toBeGreaterThan(0)
172
+ })
173
+
174
+ // ═══════════════════════════════════════════════════════════
175
+ // Health
176
+ // ═══════════════════════════════════════════════════════════
177
+
178
+ test('health returns ok when connected', async () => {
179
+ const health = await adapter.health()
180
+ expect(health.ok).toBe(true)
181
+ })
182
+
183
+ test('health returns error when disconnected', async () => {
184
+ conn.kill()
185
+ const health = await adapter.health()
186
+ expect(health.ok).toBe(false)
187
+ expect(health.error).toContain('disconnected')
188
+ })
189
+
190
+ // ═══════════════════════════════════════════════════════════
191
+ // Agent instructions
192
+ // ═══════════════════════════════════════════════════════════
193
+
194
+ test('agentInstructions includes project name', () => {
195
+ const instructions = adapter.agentInstructions()
196
+ expect(instructions).toContain('test-project')
197
+ expect(instructions).toContain('Engram')
198
+ expect(instructions).toContain('mem_search')
199
+ })
200
+ })
201
+
202
+ // ═══════════════════════════════════════════════════════════════════
203
+ // Unit tests with mocked MCP connection (no subprocess)
204
+ // ═══════════════════════════════════════════════════════════════════
205
+
206
+ describe('EngramMemoryAdapter — unit (mocked connection)', () => {
207
+ /** Create a fake MCPConnection with controllable callTool and state */
208
+ function mockConnection(state: string = 'ready') {
209
+ const calls: Array<{ tool: string; args: any }> = []
210
+ return {
211
+ conn: {
212
+ state,
213
+ callTool: async (tool: string, args: any) => {
214
+ calls.push({ tool, args })
215
+ return { content: [{ type: 'text', text: '{}' }] }
216
+ },
217
+ } as any,
218
+ calls,
219
+ }
220
+ }
221
+
222
+ // ═══════════════════════════════════════════════════════════
223
+ // onSessionEnd — detailed unit tests
224
+ // ═══════════════════════════════════════════════════════════
225
+
226
+ test('onSessionEnd calls mem_session_summary then mem_session_end with provided summary', async () => {
227
+ const { conn, calls } = mockConnection()
228
+ const adapter = new EngramMemoryAdapter(conn, { project: 'unit-test' })
229
+
230
+ // Must init first to get a sessionId
231
+ await adapter.init()
232
+ const sessionId = (adapter as any).sessionId
233
+ expect(sessionId).toBeTruthy()
234
+
235
+ calls.length = 0 // clear init calls
236
+
237
+ await adapter.onSessionEnd({
238
+ messages: [{ role: 'user', content: 'Hello' }],
239
+ sessionSummary: 'We discussed greetings',
240
+ topics: ['greetings'],
241
+ })
242
+
243
+ // Should have called mem_session_summary with the provided summary
244
+ const summaryCall = calls.find((c) => c.tool === 'mem_session_summary')
245
+ expect(summaryCall).toBeTruthy()
246
+ expect(summaryCall!.args.summary).toBe('We discussed greetings')
247
+ expect(summaryCall!.args.session_id).toBe(sessionId)
248
+
249
+ // Should have called mem_session_end
250
+ const endCall = calls.find((c) => c.tool === 'mem_session_end')
251
+ expect(endCall).toBeTruthy()
252
+ expect(endCall!.args.id).toBe(sessionId)
253
+ expect(endCall!.args.summary).toBe('We discussed greetings')
254
+ })
255
+
256
+ test('onSessionEnd builds summary from messages when sessionSummary is not provided', async () => {
257
+ const { conn, calls } = mockConnection()
258
+ const adapter = new EngramMemoryAdapter(conn, { project: 'unit-test' })
259
+
260
+ await adapter.init()
261
+ calls.length = 0
262
+
263
+ await adapter.onSessionEnd({
264
+ messages: [
265
+ { role: 'user', content: 'Fix the login bug' },
266
+ { role: 'assistant', content: 'I fixed it by updating the JWT validation' },
267
+ { role: 'user', content: 'Now add rate limiting' },
268
+ ],
269
+ topics: ['auth', 'security'],
270
+ })
271
+
272
+ const summaryCall = calls.find((c) => c.tool === 'mem_session_summary')
273
+ expect(summaryCall).toBeTruthy()
274
+ // The built summary should contain user topics and messages
275
+ expect(summaryCall!.args.summary).toContain('auth, security')
276
+ expect(summaryCall!.args.summary).toContain('Fix the login bug')
277
+ expect(summaryCall!.args.summary).toContain('Now add rate limiting')
278
+ })
279
+
280
+ test('onSessionEnd clears sessionId after completion', async () => {
281
+ const { conn } = mockConnection()
282
+ const adapter = new EngramMemoryAdapter(conn)
283
+
284
+ await adapter.init()
285
+ expect((adapter as any).sessionId).toBeTruthy()
286
+
287
+ await adapter.onSessionEnd({
288
+ messages: [{ role: 'user', content: 'done' }],
289
+ sessionSummary: 'Session complete',
290
+ })
291
+
292
+ expect((adapter as any).sessionId).toBeNull()
293
+ })
294
+
295
+ test('onSessionEnd is no-op when no sessionId (not initialized)', async () => {
296
+ const { conn, calls } = mockConnection()
297
+ const adapter = new EngramMemoryAdapter(conn)
298
+
299
+ // Do NOT call init — no sessionId
300
+ await adapter.onSessionEnd({
301
+ messages: [{ role: 'user', content: 'test' }],
302
+ sessionSummary: 'Should be skipped',
303
+ })
304
+
305
+ // Should not have called any MCP tools
306
+ expect(calls.length).toBe(0)
307
+ })
308
+
309
+ test('onSessionEnd handles MCP call failures gracefully (catch blocks)', async () => {
310
+ const callCount = { summary: 0, end: 0 }
311
+ const conn = {
312
+ state: 'ready',
313
+ callTool: async (tool: string, args: any) => {
314
+ if (tool === 'mem_session_start') return { content: [] }
315
+ if (tool === 'mem_session_summary') {
316
+ callCount.summary++
317
+ throw new Error('MCP summary failed')
318
+ }
319
+ if (tool === 'mem_session_end') {
320
+ callCount.end++
321
+ throw new Error('MCP end failed')
322
+ }
323
+ return { content: [] }
324
+ },
325
+ } as any
326
+ const adapter = new EngramMemoryAdapter(conn)
327
+
328
+ await adapter.init()
329
+
330
+ // Should NOT throw even though MCP calls fail (both have .catch(() => {}))
331
+ await adapter.onSessionEnd({
332
+ messages: [{ role: 'user', content: 'test' }],
333
+ sessionSummary: 'test summary',
334
+ })
335
+
336
+ expect(callCount.summary).toBe(1)
337
+ expect(callCount.end).toBe(1)
338
+ // sessionId should still be cleared
339
+ expect((adapter as any).sessionId).toBeNull()
340
+ })
341
+
342
+ test('onSessionEnd sends "Session ended" as default summary to mem_session_end', async () => {
343
+ const { conn, calls } = mockConnection()
344
+ const adapter = new EngramMemoryAdapter(conn)
345
+
346
+ await adapter.init()
347
+ calls.length = 0
348
+
349
+ // Empty messages, no sessionSummary, no topics → buildSummaryFromMessages returns ''
350
+ await adapter.onSessionEnd({
351
+ messages: [],
352
+ })
353
+
354
+ // mem_session_summary should NOT have been called (summary is empty)
355
+ const summaryCall = calls.find((c) => c.tool === 'mem_session_summary')
356
+ expect(summaryCall).toBeUndefined()
357
+
358
+ // mem_session_end should use 'Session ended' as fallback
359
+ const endCall = calls.find((c) => c.tool === 'mem_session_end')
360
+ expect(endCall).toBeTruthy()
361
+ expect(endCall!.args.summary).toBe('Session ended')
362
+ })
363
+
364
+ // ═══════════════════════════════════════════════════════════
365
+ // save — unit tests
366
+ // ═══════════════════════════════════════════════════════════
367
+
368
+ test('save calls mem_save with correct fact type mapping', async () => {
369
+ const { conn, calls } = mockConnection()
370
+ const adapter = new EngramMemoryAdapter(conn, { project: 'unit-test' })
371
+
372
+ await adapter.init()
373
+ calls.length = 0
374
+
375
+ await adapter.save([
376
+ { content: 'Decision content', title: 'My Decision', type: 'decision' },
377
+ { content: 'Bug was fixed', title: 'Bug Fix', type: 'bugfix' },
378
+ { content: 'Unknown type', title: 'Unknown', type: 'unknown_type' },
379
+ { content: 'No type', title: 'No Type' },
380
+ ])
381
+
382
+ expect(calls).toHaveLength(4)
383
+ expect(calls[0].args.type).toBe('decision')
384
+ expect(calls[1].args.type).toBe('bugfix')
385
+ expect(calls[2].args.type).toBe('discovery') // unknown maps to discovery
386
+ expect(calls[3].args.type).toBe('discovery') // no type maps to discovery
387
+ })
388
+
389
+ test('save includes session_id when session is active', async () => {
390
+ const { conn, calls } = mockConnection()
391
+ const adapter = new EngramMemoryAdapter(conn, { project: 'unit-test' })
392
+
393
+ await adapter.init()
394
+ const sessionId = (adapter as any).sessionId
395
+ calls.length = 0
396
+
397
+ await adapter.save([{ content: 'Test fact', title: 'Test' }])
398
+
399
+ expect(calls[0].args.session_id).toBe(sessionId)
400
+ expect(calls[0].args.project).toBe('unit-test')
401
+ })
402
+
403
+ // ═══════════════════════════════════════════════════════════
404
+ // destroy — unit tests
405
+ // ═══════════════════════════════════════════════════════════
406
+
407
+ test('destroy clears sessionId', async () => {
408
+ const { conn } = mockConnection()
409
+ const adapter = new EngramMemoryAdapter(conn)
410
+
411
+ await adapter.init()
412
+ expect((adapter as any).sessionId).toBeTruthy()
413
+
414
+ await adapter.destroy()
415
+ expect((adapter as any).sessionId).toBeNull()
416
+ })
417
+
418
+ // ═══════════════════════════════════════════════════════════
419
+ // Private helpers — unit tests
420
+ // ═══════════════════════════════════════════════════════════
421
+
422
+ test('extractText extracts text content blocks', () => {
423
+ const { conn } = mockConnection()
424
+ const adapter = new EngramMemoryAdapter(conn)
425
+ const extract = (adapter as any).extractText.bind(adapter)
426
+
427
+ expect(
428
+ extract({
429
+ content: [
430
+ { type: 'text', text: 'Hello' },
431
+ { type: 'text', text: 'World' },
432
+ ],
433
+ }),
434
+ ).toBe('Hello\nWorld')
435
+ expect(extract({ content: [{ type: 'image', url: 'x' }] })).toBe('')
436
+ expect(extract({})).toBe('')
437
+ expect(extract(null)).toBe('')
438
+ })
439
+
440
+ test('mapFactType maps known types and defaults to discovery', () => {
441
+ const { conn } = mockConnection()
442
+ const adapter = new EngramMemoryAdapter(conn)
443
+ const mapType = (adapter as any).mapFactType.bind(adapter)
444
+
445
+ expect(mapType('decision')).toBe('decision')
446
+ expect(mapType('achievement')).toBe('discovery')
447
+ expect(mapType('learning')).toBe('learning')
448
+ expect(mapType('observation')).toBe('pattern')
449
+ expect(mapType('bugfix')).toBe('bugfix')
450
+ expect(mapType('architecture')).toBe('architecture')
451
+ expect(mapType('config')).toBe('config')
452
+ expect(mapType('unknown')).toBe('discovery')
453
+ expect(mapType(undefined)).toBe('discovery')
454
+ })
455
+
456
+ test('buildSummaryFromMessages handles more than 5 user messages', () => {
457
+ const { conn } = mockConnection()
458
+ const adapter = new EngramMemoryAdapter(conn)
459
+ const build = (adapter as any).buildSummaryFromMessages.bind(adapter)
460
+
461
+ const context = {
462
+ messages: Array.from({ length: 8 }, (_, i) => ({
463
+ role: 'user',
464
+ content: `Message ${i + 1}`,
465
+ })),
466
+ topics: ['topic1'],
467
+ }
468
+
469
+ const summary = build(context)
470
+ expect(summary).toContain('Message 1')
471
+ expect(summary).toContain('Message 5')
472
+ expect(summary).toContain('and 3 more')
473
+ expect(summary).not.toContain('Message 6')
474
+ })
475
+
476
+ test('buildSummaryFromMessages returns empty string with no user messages', () => {
477
+ const { conn } = mockConnection()
478
+ const adapter = new EngramMemoryAdapter(conn)
479
+ const build = (adapter as any).buildSummaryFromMessages.bind(adapter)
480
+
481
+ expect(build({ messages: [], topics: ['auth'] })).toBe('')
482
+ expect(build({ messages: [{ role: 'assistant', content: 'only assistant' }] })).toBe('')
483
+ })
484
+
485
+ test('buildSummaryFromMessages uses "various topics" when no topics provided', () => {
486
+ const { conn } = mockConnection()
487
+ const adapter = new EngramMemoryAdapter(conn)
488
+ const build = (adapter as any).buildSummaryFromMessages.bind(adapter)
489
+
490
+ const summary = build({
491
+ messages: [{ role: 'user', content: 'Some question' }],
492
+ })
493
+ expect(summary).toContain('various topics')
494
+ })
495
+ })
package/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * @onmars/lunar-memory-engram — Engram session memory provider for Lunar
3
+ *
4
+ * Wraps Engram's MCP tools as a standard MemoryProvider.
5
+ * Requires an MCPConnection to an Engram server.
6
+ *
7
+ * @example
8
+ * ```typescript
9
+ * import { MCPConnection } from '@onmars/lunar-mcp'
10
+ * import { EngramMemoryAdapter } from '@onmars/lunar-memory-engram'
11
+ *
12
+ * const conn = new MCPConnection({ id: 'engram', command: 'engram mcp' })
13
+ * await conn.connect()
14
+ *
15
+ * const engram = new EngramMemoryAdapter(conn, { project: 'my-project' })
16
+ * await engram.init()
17
+ *
18
+ * const results = await engram.recall('auth middleware')
19
+ * ```
20
+ */
21
+
22
+ export type {
23
+ EngramAdapterConfig,
24
+ Fact,
25
+ MemoryResult,
26
+ MemoryTrait,
27
+ SessionContext,
28
+ } from './lib/adapter'
29
+ export { EngramMemoryAdapter } from './lib/adapter'
@@ -0,0 +1,268 @@
1
+ /**
2
+ * EngramMemoryAdapter — Wraps an MCP connection to Engram as a MemoryProvider
3
+ *
4
+ * Maps MemoryProvider interface to Engram MCP tools:
5
+ * recall() → mem_search + mem_context (parallel)
6
+ * onSessionEnd() → mem_session_summary + mem_session_end
7
+ * init() → mem_session_start
8
+ * save() → mem_save
9
+ * health() → connection state
10
+ *
11
+ * Traits: recall, session
12
+ */
13
+
14
+ import type { MCPConnection } from '@onmars/lunar-mcp'
15
+
16
+ // ════════════════════════════════════════════════════════════
17
+ // Types
18
+ // ════════════════════════════════════════════════════════════
19
+
20
+ export type MemoryTrait = 'recall' | 'session' | 'semantic' | 'graph' | 'file-based' | 'extraction'
21
+
22
+ export interface MemoryResult {
23
+ source: string
24
+ content: string
25
+ score: number
26
+ metadata?: Record<string, unknown>
27
+ }
28
+
29
+ export interface SessionContext {
30
+ messages: Array<{ role: string; content: string }>
31
+ sessionSummary?: string
32
+ topics?: string[]
33
+ }
34
+
35
+ export interface Fact {
36
+ content: string
37
+ title?: string
38
+ type?: string
39
+ category?: string
40
+ }
41
+
42
+ export interface EngramAdapterConfig {
43
+ project?: string
44
+ searchLimit?: number
45
+ includeContext?: boolean
46
+ }
47
+
48
+ // ════════════════════════════════════════════════════════════
49
+ // Adapter
50
+ // ════════════════════════════════════════════════════════════
51
+
52
+ export class EngramMemoryAdapter {
53
+ readonly id = 'engram'
54
+ readonly name = 'Engram Session Memory'
55
+ readonly traits = new Set<MemoryTrait>(['recall', 'session'])
56
+
57
+ private connection: MCPConnection
58
+ private config: Required<EngramAdapterConfig>
59
+ private sessionId: string | null = null
60
+
61
+ constructor(connection: MCPConnection, config: EngramAdapterConfig = {}) {
62
+ this.connection = connection
63
+ this.config = {
64
+ project: config.project || 'default',
65
+ searchLimit: config.searchLimit || 10,
66
+ includeContext: config.includeContext ?? true,
67
+ }
68
+ }
69
+
70
+ async init(): Promise<void> {
71
+ if (this.connection.state !== 'ready') {
72
+ throw new Error('Engram MCP connection is not ready')
73
+ }
74
+ this.sessionId = `lunar-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
75
+ await this.connection.callTool('mem_session_start', {
76
+ id: this.sessionId,
77
+ project: this.config.project,
78
+ directory: process.cwd(),
79
+ })
80
+ }
81
+
82
+ async destroy(): Promise<void> {
83
+ this.sessionId = null
84
+ }
85
+
86
+ async recall(query: string, options?: { limit?: number }): Promise<MemoryResult[]> {
87
+ const limit = options?.limit || this.config.searchLimit
88
+ const results: MemoryResult[] = []
89
+ const promises: Promise<void>[] = []
90
+
91
+ // 1. FTS5 search
92
+ promises.push(
93
+ this.connection
94
+ .callTool('mem_search', {
95
+ query,
96
+ limit,
97
+ project: this.config.project,
98
+ })
99
+ .then((result) => {
100
+ const text = this.extractText(result)
101
+ const parsed = this.tryParseJson(text)
102
+ if (Array.isArray(parsed)) {
103
+ for (const item of parsed) {
104
+ results.push({
105
+ source: 'engram:search',
106
+ content: this.formatObservation(item),
107
+ score: item.score ?? 0.5,
108
+ metadata: {
109
+ id: item.id,
110
+ type: item.type,
111
+ title: item.title,
112
+ sessionId: item.session_id,
113
+ },
114
+ })
115
+ }
116
+ } else if (typeof text === 'string' && text.trim()) {
117
+ results.push({ source: 'engram:search', content: text, score: 0.5 })
118
+ }
119
+ })
120
+ .catch(() => {}),
121
+ )
122
+
123
+ // 2. Recent context
124
+ if (this.config.includeContext) {
125
+ promises.push(
126
+ this.connection
127
+ .callTool('mem_context', {
128
+ project: this.config.project,
129
+ })
130
+ .then((result) => {
131
+ const text = this.extractText(result)
132
+ if (text?.trim()) {
133
+ results.push({
134
+ source: 'engram:context',
135
+ content: text,
136
+ score: 0.6,
137
+ metadata: { type: 'session_context' },
138
+ })
139
+ }
140
+ })
141
+ .catch(() => {}),
142
+ )
143
+ }
144
+
145
+ await Promise.allSettled(promises)
146
+ return results
147
+ }
148
+
149
+ async save(facts: Fact[]): Promise<void> {
150
+ for (const fact of facts) {
151
+ await this.connection.callTool('mem_save', {
152
+ title: fact.title || fact.content.slice(0, 60),
153
+ content: fact.content,
154
+ type: this.mapFactType(fact.type || fact.category),
155
+ project: this.config.project,
156
+ ...(this.sessionId && { session_id: this.sessionId }),
157
+ })
158
+ }
159
+ }
160
+
161
+ async onSessionEnd(context: SessionContext): Promise<void> {
162
+ if (!this.sessionId) return
163
+
164
+ const summary = context.sessionSummary || this.buildSummaryFromMessages(context)
165
+
166
+ if (summary) {
167
+ await this.connection
168
+ .callTool('mem_session_summary', {
169
+ summary,
170
+ session_id: this.sessionId,
171
+ })
172
+ .catch(() => {})
173
+ }
174
+
175
+ await this.connection
176
+ .callTool('mem_session_end', {
177
+ id: this.sessionId,
178
+ summary: summary || 'Session ended',
179
+ })
180
+ .catch(() => {})
181
+
182
+ this.sessionId = null
183
+ }
184
+
185
+ async health(): Promise<{ ok: boolean; error?: string }> {
186
+ if (this.connection.state !== 'ready') {
187
+ return { ok: false, error: `MCP connection state: ${this.connection.state}` }
188
+ }
189
+ return { ok: true }
190
+ }
191
+
192
+ agentInstructions(): string {
193
+ return [
194
+ '## Engram Session Memory',
195
+ '',
196
+ 'Engram provides session-level memory with full-text search.',
197
+ 'Memory is automatically recalled before each query.',
198
+ '',
199
+ 'Available MCP tools (if direct access is enabled):',
200
+ '- `mem_search` — Search across all memories (FTS5)',
201
+ '- `mem_save` — Save an observation (decision, bugfix, pattern, etc.)',
202
+ '- `mem_context` — Get recent context from previous sessions',
203
+ '- `mem_timeline` — Chronological context around a specific observation',
204
+ '- `mem_get_observation` — Get full content of a specific memory',
205
+ '',
206
+ `Project: ${this.config.project}`,
207
+ ].join('\n')
208
+ }
209
+
210
+ // ═════════════════════════════════════════════════════
211
+ // Private helpers
212
+ // ═════════════════════════════════════════════════════
213
+
214
+ private extractText(result: unknown): string {
215
+ const r = result as { content?: Array<{ type: string; text?: string }> }
216
+ if (!r?.content) return ''
217
+ return r.content
218
+ .filter((c) => c.type === 'text' && c.text)
219
+ .map((c) => c.text!)
220
+ .join('\n')
221
+ }
222
+
223
+ private tryParseJson(text: string): unknown {
224
+ try {
225
+ return JSON.parse(text)
226
+ } catch {
227
+ return null
228
+ }
229
+ }
230
+
231
+ private formatObservation(obs: Record<string, unknown>): string {
232
+ const parts: string[] = []
233
+ if (obs.title) parts.push(`[${obs.type || 'observation'}] ${obs.title}`)
234
+ if (obs.content) parts.push(String(obs.content))
235
+ return parts.join('\n') || JSON.stringify(obs)
236
+ }
237
+
238
+ private mapFactType(type?: string): string {
239
+ const map: Record<string, string> = {
240
+ decision: 'decision',
241
+ achievement: 'discovery',
242
+ learning: 'learning',
243
+ observation: 'pattern',
244
+ bugfix: 'bugfix',
245
+ architecture: 'architecture',
246
+ config: 'config',
247
+ }
248
+ return type ? map[type] || 'discovery' : 'discovery'
249
+ }
250
+
251
+ private buildSummaryFromMessages(context: SessionContext): string {
252
+ const userMsgs = context.messages
253
+ .filter((m) => m.role === 'user')
254
+ .map((m) => m.content.slice(0, 100))
255
+ if (!userMsgs.length) return ''
256
+ const topics = context.topics?.join(', ') || 'various topics'
257
+ return [
258
+ `## Goal`,
259
+ `User discussed: ${topics}`,
260
+ '',
261
+ `## Topics covered`,
262
+ ...userMsgs.slice(0, 5).map((m) => `- ${m}`),
263
+ userMsgs.length > 5 ? `- ... and ${userMsgs.length - 5} more` : '',
264
+ ]
265
+ .filter(Boolean)
266
+ .join('\n')
267
+ }
268
+ }