ability-memory
Memory domain layer — fact reconciliation, entity dedup, soft-delete
ability-memory is a domain-layer “kadi-ability” that provides a thin, opinionated wrapper over ability-graph to manage conversational agent memory. It exposes a set of memory-* tools that enforce a Memory schema, provide agent-isolation by default, perform fact extraction and reconciliation, soft-delete/cascade semantics, and standardize embedding/chat model configuration. It exists to centralize memory write/read patterns (recall, store, relate, forget, context, conversations) and to reduce agent authorship complexity by providing higher-level, memory-specific primitives.
Architecture
Section titled “Architecture”- Data flow:
- Read workflows (memory-recall, memory-context, memory-conversations) delegate to ability-graph’s search/traversal capabilities, adding Memory-specific filters (vertexType=‘Memory’, valid=true) and agent isolation filters.
- Write workflows (memory-store, memory-relate, memory-forget) orchestrate multi-step domain logic: extraction → reconciliation → graph-store/graph-command → soft-delete/orphan cleanup.
- Embedding and LLM calls are routed through configured model transports (api) and configured model names in config.toml. Secrets for API keys are provided via the configured secrets vault or broker-delivered secrets.
- Key components:
- KadiClient tool registrations: each tool is registered via client.registerTool in src/tools/*.ts.
- SignalAbilities interface: the ability expects ability-graph to be available and invoked via abilities.invoke(‘graph-xxx’, …).
- Reconciliation layer: src/lib/reconciliation.ts implements extractFacts/reconcileFacts used by memory-store to dedupe and reconcile facts.
- Agent filter resolution: resolveAgentFilter enforces defaultAgent and wildcard semantics for cross-agent queries.
- Integration in AGENTS ecosystem:
- Declares dependency on ability-graph and secret-ability in agent.json; uses the shared Kadi broker for tool dispatch.
- Other agents interact with memory by invoking memory-* tools over KadiClient (via broker or local client).
- Deployment binds secrets via a vault (example: model-manager) and delivers MODEL_MANAGER_BASE_URL / MODEL_MANAGER_API_KEY to the runtime.
Tools / API
Section titled “Tools / API”The ability registers the following tools. Each tool is implemented in src/tools/* and registered on KadiClient.
| Tool name | Registration function | Purpose | Key input fields |
|---|---|---|---|
| memory-context | registerContextTool (src/tools/context.ts) | Retrieve a graph context around a topic, entity, or memory. Performs recall then expands via graph traversal. | query, topic, entity, entityType, memoryRid, agent, depth, limit |
| memory-conversations | registerConversationsTool (src/tools/conversations.ts) | List conversation sessions with metadata (start/end, memory count, summary). | agent, since, limit |
| memory-forget | registerForgetTool (src/tools/forget.ts) | Delete memories (requires confirm: true). Optional cascade removes orphaned Topics/Entities. | rid, agent, conversationId, olderThan, confirm, cascade |
| memory-recall | registerRecallTool (src/tools/recall.ts) | Search stored memories with hybrid multi-signal recall. Enforces vertexType=‘Memory’ and default signals. | query, agent, limit, mode, signals, topics, conversationId, reflect |
| memory-relate | registerRelateTool (src/tools/relate.ts) | Create typed RelatedTo edges between vertices. Optional bidirectional edges. | fromRid, toRid, relationship, weight, bidirectional |
| memory-store | registerStoreTool (src/tools/store.ts) | Store a memory with automatic fact extraction, reconciliation, embedding, and graph linking. | content, agent, conversationId, topic, skipExtraction, skipReconciliation, … (see function input in source) |
Notes:
- Each tool enforces config.database and model/transport settings from MemoryConfig and delegates heavy-lifting to ability-graph tools via abilities.invoke(…).
- Agent isolation: resolveAgentFilter is used to translate agent input into graph filters. Use agent: ”*” to do cross-agent recall.
Configuration
Section titled “Configuration”Configuration is split between config.toml (plaintext) and secrets.toml (encrypted vault). The runtime also expects broker secrets delivered via the broker (see deploy in agent.json).
config.toml (fields present in repository)
- [broker.local]
- URL — websocket URL for local broker (example: ws://localhost:8080/kadi)
- NETWORKS — array of network names (e.g., [“memory”])
- MODE — broker mode (e.g., “native”)
- [broker.remote]
- URL — remote broker URL (example: wss://broker.dadavidtseng.com/kadi)
- NETWORKS, MODE — as above
- [memory]
- database = “agents_memory” — name of the graph database to use
- embedding_model = “text-embedding-3-small” — default embedding model name
- extraction_model = “gpt-5-nano” — model used for fact extraction
- summarization_model = “gpt-5-mini” — summarization model
- chat_model = “gpt-5-mini” — chat/reflect model
- default_agent = “default” — default agent id used for agent-isolation
- embedding_transport = “api” — transport to use for embeddings (e.g., “api”)
- chat_transport = “api” — transport for chat/reflection
- Note on runtime MemoryConfig properties used in code:
- In code the camelCase properties are referenced: config.database, config.embeddingModel, config.embeddingTransport, config.apiUrl, config.apiKey, config.defaultAgent, config.chatModel, etc. The TOML keys map to the ability’s MemoryConfig loader — ensure secrets and env variables populate config.apiUrl / config.apiKey.
- Secrets and vault:
- The repository expects secrets in secrets.toml or delivered via a secrets vault. agent.json deploy configurations reference a vault named “model-manager” and require:
- MODEL_MANAGER_BASE_URL
- MODEL_MANAGER_API_KEY
- Embedding / LLM API credentials (apiUrl / apiKey) must be provided via the secrets vault or environment to populate config.apiUrl and config.apiKey.
- The build/deploy uses kadi secret receive —vault model-manager to inject these secrets via broker delivery.
- The repository expects secrets in secrets.toml or delivered via a secrets vault. agent.json deploy configurations reference a vault named “model-manager” and require:
Environment / Delivery:
- agent.json shows the service command:
- kadi secret receive —vault model-manager && kadi run start
- Ensure the vault delivers MODEL_MANAGER_BASE_URL and MODEL_MANAGER_API_KEY and any embedding/LLM API keys needed by the configured transports.
Code Examples
Section titled “Code Examples”Below are representative, verbatim excerpts from the repository showing how tools are registered and typical patterns. Use these snippets when modifying or extending tool behavior.
/** * memory-context tool — Graph traversal context around a memory, topic, or entity. * * Thin wrapper over graph-context: delegates with Memory scope. */
import { KadiClient, z } from '@kadi.build/core';
import type { MemoryConfig } from '../lib/config.js';import type { SignalAbilities } from '../lib/graph-types.js';import { resolveAgentFilter } from '../lib/agent-filter.js';
export function registerContextTool( client: KadiClient, config: MemoryConfig, abilities: SignalAbilities,): void {
client.registerTool( { name: 'memory-context', description: 'Retrieve a graph context around a topic, entity, or memory. ' + 'Performs recall then expands via graph traversal for richer context.', input: z.object({ query: z.string().optional().describe('Search query for recall-based context'), topic: z.string().optional().describe('Topic name to start from'), entity: z.string().optional().describe('Entity name to start from'), entityType: z.string().optional().describe('Entity type filter'), memoryRid: z.string().optional().describe('Memory RID to start from (e.g., "#12:0")'), agent: z.union([z.string(), z.array(z.string())]).optional() .describe('Agent filter: string, array, or "*" for all agents'), depth: z.number().optional().describe('Traversal depth (1-4, default: 2)'), limit: z.number().optional().describe('Max recalled results to expand (default: 5)'), }), }, async (input) => { try { const { agentFilter, agentDisplay } = resolveAgentFilter(input.agent, config.defaultAgent); const depth = Math.max(1, Math.min(4, input.depth ?? 2)); const limit = input.limit ?? 5;
// If a query is provided, use graph-context for recall + traversal if (input.query) { const result = await abilities.invoke<Record<string, unknown>>('graph-context', { query: input.query, vertexType: 'Memory', depth, limit, filters: { ...agentFilter }, signals: ['semantic', 'keyword', 'graph'], database: config.database, embedding: { model: config.embeddingModel, transport: config.embeddingTransport, apiUrl: config.apiUrl, apiKey: config.apiKey, }, });
return { ...result, agent: agentDisplay, }; }
// Otherwise, resolve starting vertex and do direct traversal // For topic/entity/memoryRid, query ArcadeDB directly let startRid: string | null = input.memoryRid ?? null;
if (!startRid && input.topic) { startRid = await findVertexRid(abilities, config.database, 'Topic', 'name', input.topic); }
if (!startRid && input.entity) { const conditions = input.entityType ? `name = '${escapeSimple(input.entity)}' AND type = '${escapeSimple(input.entityType)}'` : `name = '${escapeSimple(input.entity)}'`; startRid = await findVertexRidByCondition(abilities, config.database, 'Entity', conditions); }/** * memory-conversations tool — List conversation sessions. * * Domain-specific query that queries Conversation vertices and their * associated memory counts, sorted by most recent. */
import { KadiClient, z } from '@kadi.build/core';
import type { MemoryConfig } from '../lib/config.js';import type { SignalAbilities } from '../lib/graph-types.js';
export function registerConversationsTool( client: KadiClient, config: MemoryConfig, abilities: SignalAbilities,): void {
client.registerTool( { name: 'memory-conversations', description: 'List conversation sessions sorted by most recent. ' + 'Returns conversation metadata including start/end times, memory count, and summary.', input: z.object({ agent: z.union([z.string(), z.array(z.string())]).optional() .describe('Agent filter: string, array, or "*" for all agents (default: from config)'), since: z.string().optional().describe('Only conversations after this ISO date'), limit: z.number().optional().describe('Max results (default: 20)'), }), }, async (input) => { try { const limit = Math.max(1, Math.min(100, input.limit ?? 20));
// Build agent condition based on input type const agentInput = input.agent ?? config.defaultAgent; let agentCondition: string; let agentDisplay: string | string[];
if (agentInput === '*') { // Wildcard — no agent filter agentCondition = ''; agentDisplay = '*'; } else if (Array.isArray(agentInput)) { if (agentInput.length === 0 || agentInput.includes('*')) { agentCondition = ''; agentDisplay = '*'; } else if (agentInput.length === 1) { agentCondition = `agent = '${escapeSimple(agentInput[0])}'`; agentDisplay = agentInput[0]; } else { const escaped = agentInput.map((a) => `'${escapeSimple(a)}'`).join(', '); agentCondition = `agent IN [${escaped}]`; agentDisplay = agentInput; } } else { agentCondition = `agent = '${escapeSimple(agentInput)}'`; agentDisplay = agentInput; }
const conditions: string[] = []; if (agentCondition) { conditions.push(agentCondition); } if (input.since) { conditions.push(`startTime >= '${escapeSimple(input.since)}'`); }
const whereClause = conditions.length > 0 ? ` WHERE ${conditions.join(' AND ')}` : '';
const sql = `SELECT conversationId, startTime, endTime, memoryCount, summary` + ` FROM Conversation` + whereClause + ` ORDER BY startTime DESC` + ` LIMIT ${limit}`;/** * memory-forget tool — Delete memories with optional cascade. * * Wraps graph-delete with domain-specific cascade logic: after deleting * a Memory vertex, queries for orphaned Topic/Entity vertices that have * no remaining edges and removes them. * * Requires `confirm: true` as a safety guard. Supports filtering by RID, * agent, conversationId, or olderThan. */
import { KadiClient, z } from '@kadi.build/core';
import type { MemoryConfig } from '../lib/config.js';import type { SignalAbilities } from '../lib/graph-types.js';
export function registerForgetTool( client: KadiClient, config: MemoryConfig, abilities: SignalAbilities,): void {
client.registerTool( { name: 'memory-forget', description: 'Delete memories matching given criteria. Requires confirm: true as a safety guard. ' + 'Optional cascade removes orphaned Topics and Entities with no remaining connections.', input: z.object({ rid: z.string().optional().describe('Specific Memory RID to delete'), agent: z.string().optional().describe('Delete all memories for this agent'), conversationId: z.string().optional().describe('Delete memories in this conversation'), olderThan: z.string().optional().describe('Delete memories older than this ISO date'), confirm: z.boolean().describe('Must be true to proceed with deletion'), cascade: z.boolean().optional().describe('Remove orphaned Topics/Entities (default: false)'), }), }, async (input) => { try { if (!input.confirm) { return { deleted: false, error: '[memory-forget] Safety guard: set confirm: true to proceed with deletion.', tool: 'memory-forget', }; }
// At least one filter must be provided if (!input.rid && !input.agent && !input.conversationId && !input.olderThan) { return { deleted: false, error: '[memory-forget] At least one filter (rid, agent, conversationId, olderThan) must be provided.', tool: 'memory-forget', }; }
const database = config.database; let memoriesRemoved = 0;
if (input.rid) { // Delete specific memory via graph-delete const deleteResult = await abilities.invoke<{ success: boolean; error?: string }>('graph-delete', { rid: input.rid, cascade: false, // We handle cascade ourselves for domain-specific orphan detection database, });
if (deleteResult.success) { memoriesRemoved = 1; } else { console.error('[memory-forget] graph-delete failed:', deleteResult.error ?? JSON.stringify(deleteResult)); } } else { // Build WHERE clause from filters const conditions: string[] = []; if (input.agent) { conditions.push(`agent = '${escapeSimple(input.agent)}'`); } if (input.conversationId) { conditions.push(`conversationId = '${escapeSimple(input.conversationId)}'`); }/** * memory-recall tool — Search stored memories using N-signal hybrid recall. * * Thin wrapper over graph-recall: enforces vertexType='Memory', adds agent * filter, uses 3-signal default (semantic, keyword, graph — no structural). */
import { KadiClient, z } from '@kadi.build/core';
import type { MemoryConfig } from '../lib/config.js';import type { SignalAbilities } from '../lib/graph-types.js';import { resolveAgentFilter } from '../lib/agent-filter.js';import { reflectOnFragments } from '../lib/reflect.js';
export function registerRecallTool( client: KadiClient, config: MemoryConfig, abilities: SignalAbilities,): void {
client.registerTool( { name: 'memory-recall', description: 'Search stored memories using semantic, keyword, graph, or hybrid mode. ' + 'Default mode is hybrid (combines semantic + keyword + graph with RRF fusion ' + 'and importance weighting). Agent isolation is enforced automatically. ' + 'Pass agent: "*" for cross-agent recall, or agent: ["a", "b"] for multi-agent. ' + 'Pass reflect: true to synthesize fragments into a coherent summary via LLM.', input: z.object({ query: z.string().describe('Search query text'), agent: z.union([z.string(), z.array(z.string())]).optional() .describe('Agent filter: string, array of strings, or "*" for all agents (default: from config)'), limit: z.number().optional().describe('Max results (default: 10)'), mode: z.enum(['semantic', 'keyword', 'graph', 'hybrid']).optional() .describe('Search mode (default: hybrid)'), signals: z.array(z.string()).optional() .describe('Signals for hybrid mode (default: semantic, keyword, graph)'), topics: z.array(z.string()).optional() .describe('Optional topic filter for graph mode'), conversationId: z.string().optional() .describe('Filter to a specific conversation'), reflect: z.boolean().optional() .describe('Synthesize fragments into coherent summary via LLM (default: false)'), }), }, async (input) => { try { const { agentFilter, agentDisplay } = resolveAgentFilter(input.agent, config.defaultAgent); const limit = input.limit ?? 10; const mode = input.mode ?? 'hybrid';
// Build filters — spread agent filter (may be empty for wildcard) const filters: Record<string, unknown> = { ...agentFilter, valid: true, };
if (input.conversationId) { filters.conversationId = input.conversationId; }
// Default 3-signal set: semantic, keyword, graph (NO structural) const signals = input.signals ?? ['semantic', 'keyword', 'graph'];
// Delegate to graph-recall with enforced vertexType='Memory' const result = await abilities.invoke<Record<string, unknown>>('graph-recall', { query: input.query, vertexType: 'Memory', mode, signals, filters, limit, database: config.database, embedding: { model: config.embeddingModel, transport: config.embeddingTransport, apiUrl: config.apiUrl, apiKey: config.apiKey, },/** * memory-relate tool — Create typed RelatedTo edges between vertices. * * Thin wrapper over graph-relate: delegates with optional bidirectional support * and weight/relationship metadata. */
import { KadiClient, z } from '@kadi.build/core';
import type { MemoryConfig } from '../lib/config.js';import type { SignalAbilities } from '../lib/graph-types.js';
export function registerRelateTool( client: KadiClient, config: MemoryConfig, abilities: SignalAbilities,): void {
client.registerTool( { name: 'memory-relate', description: 'Create a typed, weighted relationship between any two vertices (memories, topics, entities). ' + 'Optionally bidirectional.', input: z.object({ fromRid: z.string().describe('Source vertex RID (e.g., "#12:0")'), toRid: z.string().describe('Target vertex RID (e.g., "#13:5")'), relationship: z.string().optional().describe('Relationship type (default: "related")'), weight: z.number().optional().describe('Edge weight 0-1 (default: 0.5)'), bidirectional: z.boolean().optional().describe('Create reverse edge too (default: false)'), }), }, async (input) => { try { const relationship = input.relationship ?? 'related'; const weight = input.weight ?? 0.5; const now = new Date().toISOString();
const properties = { type: relationship, weight, createdAt: now, };
// Delegate to graph-relate await abilities.invoke('graph-relate', { edgeType: 'RelatedTo', fromRid: input.fromRid, toRid: input.toRid, properties, database: config.database, });
if (input.bidirectional) { await abilities.invoke('graph-relate', { edgeType: 'RelatedTo', fromRid: input.toRid, toRid: input.fromRid, properties, database: config.database, }); }
return { created: true, from: input.fromRid, to: input.toRid, relationship, weight, bidirectional: input.bidirectional ?? false, }; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); return { created: false, error: `[memory-relate] ${message}`, tool: 'memory-relate', }; } },/** * memory-store tool — Store a memory with automatic fact extraction, reconciliation, * entity extraction, embedding, and graph linking. * * Write path (when reconciliation enabled): * 1. extractFacts() — decompose input into atomic facts * 2. reconcileFacts() — compare against existing memories → ADD/UPDATE/DELETE/NONE * 3. Execute actions: ADD → graph-store, UPDATE → graph-command, DELETE → soft-delete, NONE → bump mentions * * When skipReconciliation=true or skipExtraction=true, falls back to direct graph-store (legacy path). */
import { KadiClient, z } from '@kadi.build/core';
import type { MemoryConfig } from '../lib/config.js';import type { SignalAbilities } from '../lib/graph-types.js';import { extractFacts, reconcileFacts } from '../lib/reconciliation.js';import type { ReconciliationEntry } from '../lib/reconciliation.js';
export function registerStoreTool( client: KadiClient, config: MemoryConfig, abilities: SignalAbilities,): void {
client.registerTool( { name: 'memory-store', description: 'Store a memory with automatic fact