open-multi-agent/examples/integrations/with-engram/engram-store.ts

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),
}
}
}