@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 +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/adapter.test.ts +495 -0
- package/src/index.ts +29 -0
- package/src/lib/adapter.ts +268 -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-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
|
+
}
|