188 lines
6.0 KiB
TypeScript
188 lines
6.0 KiB
TypeScript
/**
|
|
* Engram Memory Store
|
|
*
|
|
* A {@link MemoryStore} implementation backed by Engram's REST API.
|
|
* Engram provides shared team memory for AI agents — facts committed by one
|
|
* agent are visible to all others in the workspace.
|
|
*
|
|
* Run:
|
|
* npx tsx examples/integrations/with-engram/research-team.ts
|
|
*
|
|
* Prerequisites:
|
|
* - Engram server running at http://localhost:7474 (or custom baseUrl)
|
|
* - ENGRAM_INVITE_KEY env var (or passed via constructor)
|
|
*/
|
|
|
|
import type { MemoryEntry, MemoryStore } from '../../../src/types.js'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Engram fact shape (as returned by the API)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface EngramFact {
|
|
fact_id: string
|
|
lineage_id: string
|
|
content: string
|
|
scope: string
|
|
agent_id?: string
|
|
committed_at: string
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configuration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface EngramStoreOptions {
|
|
/** Engram server URL. Defaults to `http://localhost:7474`. */
|
|
baseUrl?: string
|
|
/** Workspace invite key. Falls back to `ENGRAM_INVITE_KEY` env var. */
|
|
inviteKey?: string
|
|
/** Default confidence for commits. Defaults to `0.9`. */
|
|
confidence?: number
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// EngramMemoryStore
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export class EngramMemoryStore implements MemoryStore {
|
|
private readonly baseUrl: string
|
|
private readonly inviteKey: string
|
|
private readonly confidence: number
|
|
|
|
constructor(options: EngramStoreOptions = {}) {
|
|
this.baseUrl = (options.baseUrl ?? 'http://localhost:7474').replace(/\/+$/, '')
|
|
this.inviteKey = options.inviteKey ?? process.env.ENGRAM_INVITE_KEY ?? ''
|
|
this.confidence = options.confidence ?? 0.9
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// MemoryStore interface
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Store a value under `key` by committing a fact with `scope=key`.
|
|
* Uses `operation: "update"` so repeated writes to the same key supersede
|
|
* the previous value rather than creating duplicates.
|
|
*/
|
|
async set(key: string, value: string, metadata?: Record<string, unknown>): Promise<void> {
|
|
await this.post('/api/commit', {
|
|
scope: key,
|
|
content: value,
|
|
confidence: this.confidence,
|
|
agent_id: metadata?.agent ?? undefined,
|
|
operation: 'update',
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Retrieve the most recent fact for `key` (scope).
|
|
* Returns `null` when no matching fact exists.
|
|
*/
|
|
async get(key: string): Promise<MemoryEntry | null> {
|
|
const url = `${this.baseUrl}/api/facts?scope=${encodeURIComponent(key)}&limit=1`
|
|
const res = await fetch(url, { headers: this.headers() })
|
|
|
|
if (!res.ok) return null
|
|
|
|
const facts: EngramFact[] = await res.json()
|
|
if (facts.length === 0) return null
|
|
|
|
return this.toMemoryEntry(facts[0])
|
|
}
|
|
|
|
/**
|
|
* List all facts in the workspace (up to 200).
|
|
* Each fact is mapped to a {@link MemoryEntry} using `scope` as the key.
|
|
*/
|
|
async list(): Promise<MemoryEntry[]> {
|
|
const url = `${this.baseUrl}/api/facts?limit=200`
|
|
const res = await fetch(url, { headers: this.headers() })
|
|
|
|
if (!res.ok) return []
|
|
|
|
const facts: EngramFact[] = await res.json()
|
|
return facts.map((f) => this.toMemoryEntry(f))
|
|
}
|
|
|
|
/**
|
|
* Retire the most recent fact for `key` (scope) by its lineage ID.
|
|
*
|
|
* Engram's `delete` operation requires `corrects_lineage` — it retires a
|
|
* specific lineage rather than deleting by scope. We look up the latest
|
|
* fact first to obtain its `lineage_id`, then issue the delete.
|
|
*
|
|
* No-op when no fact exists for the key.
|
|
*/
|
|
async delete(key: string): Promise<void> {
|
|
// Look up the latest fact to get its lineage_id.
|
|
const entry = await this.getFact(key)
|
|
if (!entry) return
|
|
|
|
await this.post('/api/commit', {
|
|
scope: key,
|
|
content: `Retired by MemoryStore.delete("${key}")`,
|
|
confidence: this.confidence,
|
|
operation: 'delete',
|
|
corrects_lineage: entry.lineage_id,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* No-op. Engram preserves full audit history by design — bulk erasure is
|
|
* not supported and would violate the append-only contract.
|
|
*/
|
|
async clear(): Promise<void> {
|
|
// Intentional no-op: Engram preserves audit history.
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
private headers(): Record<string, string> {
|
|
return {
|
|
Authorization: `Bearer ${this.inviteKey}`,
|
|
'Content-Type': 'application/json',
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch the most recent raw fact for a scope.
|
|
* Used internally by `delete()` to obtain the `lineage_id`.
|
|
*/
|
|
private async getFact(scope: string): Promise<EngramFact | null> {
|
|
const url = `${this.baseUrl}/api/facts?scope=${encodeURIComponent(scope)}&limit=1`
|
|
const res = await fetch(url, { headers: this.headers() })
|
|
if (!res.ok) return null
|
|
const facts: EngramFact[] = await res.json()
|
|
return facts.length > 0 ? facts[0] : null
|
|
}
|
|
|
|
private async post(path: string, body: Record<string, unknown>): Promise<void> {
|
|
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
method: 'POST',
|
|
headers: this.headers(),
|
|
body: JSON.stringify(body),
|
|
})
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '<no body>')
|
|
throw new Error(`Engram ${path} failed (${res.status}): ${text}`)
|
|
}
|
|
}
|
|
|
|
private toMemoryEntry(fact: EngramFact): MemoryEntry {
|
|
return {
|
|
key: fact.scope,
|
|
value: fact.content,
|
|
metadata: {
|
|
fact_id: fact.fact_id,
|
|
lineage_id: fact.lineage_id,
|
|
agent_id: fact.agent_id,
|
|
},
|
|
createdAt: new Date(fact.committed_at),
|
|
}
|
|
}
|
|
}
|