Rename to hkt.sh

This commit is contained in:
mango
2026-03-21 01:10:53 +08:00
parent 76a263d0f9
commit 8f1171fe99
6676 changed files with 1724268 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
/**
* Configuration Schema for Memory-as-Tools Plugin
*/
import { Type, type Static } from '@sinclair/typebox';
import { MEMORY_CATEGORIES, VECTOR_DIMS } from './types.js';
export const embeddingConfigSchema = Type.Object({
apiKey: Type.String(),
model: Type.Optional(Type.Union([
Type.Literal('text-embedding-3-small'),
Type.Literal('text-embedding-3-large'),
])),
});
export const memoryToolsConfigSchema = Type.Object({
embedding: embeddingConfigSchema,
dbPath: Type.Optional(Type.String()),
autoInjectInstructions: Type.Optional(Type.Boolean()),
decayCheckInterval: Type.Optional(Type.Number()),
});
export type MemoryToolsConfig = Static<typeof memoryToolsConfigSchema>;
/**
* Expand environment variables in a string.
* Supports ${VAR_NAME} syntax.
*/
function expandEnvVars(value: string): string {
return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
return process.env[varName] ?? '';
});
}
export function parseConfig(raw: unknown): MemoryToolsConfig {
const config = (raw ?? {}) as Record<string, unknown>;
// Handle missing or empty embedding config
const embedding = (config.embedding ?? {}) as Record<string, unknown>;
let apiKey = embedding.apiKey as string | undefined;
// Support environment variable expansion
if (apiKey) {
apiKey = expandEnvVars(apiKey);
}
// Fall back to OPENAI_API_KEY env var if not configured
if (!apiKey) {
apiKey = process.env.OPENAI_API_KEY;
}
if (!apiKey) {
throw new Error(
'Missing OpenAI API key. Set embedding.apiKey in config, use ${OPENAI_API_KEY}, or set OPENAI_API_KEY environment variable.'
);
}
const model = (embedding.model as string) || 'text-embedding-3-small';
return {
embedding: {
apiKey,
model: model as 'text-embedding-3-small' | 'text-embedding-3-large',
},
dbPath: (config.dbPath as string) || '~/.openclaw/memory/tools',
autoInjectInstructions: config.autoInjectInstructions !== false,
decayCheckInterval: (config.decayCheckInterval as number) ?? 24,
};
}
export { MEMORY_CATEGORIES, VECTOR_DIMS };

View File

@@ -0,0 +1,38 @@
/**
* Embedding Provider
*
* Handles text -> vector embedding via OpenAI API
*/
import OpenAI from 'openai';
export class EmbeddingProvider {
private client: OpenAI;
private model: string;
constructor(apiKey: string, model: string = 'text-embedding-3-small') {
this.client = new OpenAI({ apiKey });
this.model = model;
}
async embed(text: string): Promise<number[]> {
const response = await this.client.embeddings.create({
model: this.model,
input: text,
});
return response.data[0].embedding;
}
async embedBatch(texts: string[]): Promise<number[][]> {
if (texts.length === 0) return [];
const response = await this.client.embeddings.create({
model: this.model,
input: texts,
});
return response.data
.sort((a, b) => a.index - b.index)
.map(d => d.embedding);
}
}

View File

@@ -0,0 +1,267 @@
/**
* OpenClaw Memory-as-Tools Plugin
*
* Agent-controlled memory with confidence scoring, decay, and semantic search.
* The agent decides WHEN to store/retrieve memories (AgeMem pattern).
*
* Key features:
* - Six memory tools: store, update, forget, search, summarize, list
* - Hybrid SQLite + LanceDB storage
* - Confidence scoring (how accurate)
* - Importance scoring (how critical)
* - Decay/expiration for temporal memories
* - Auto-inject standing instructions at conversation start
*/
import type { OpenClawPluginApi } from './plugin-types.js';
import { Type } from '@sinclair/typebox';
import { parseConfig } from './config.js';
import { vectorDimsForModel, MEMORY_CATEGORIES } from './types.js';
import { EmbeddingProvider } from './embeddings.js';
import { MemoryStore } from './store.js';
import { createMemoryTools } from './tools.js';
// System prompt addition to guide agent on memory usage
const MEMORY_SYSTEM_PROMPT = `
## Memory Management
You have access to persistent memory tools. Use them thoughtfully:
**STORE** new memories when:
- User shares personal information (names, dates, preferences)
- User gives standing instructions ("always...", "never...", "I prefer...")
- User mentions relationships ("my wife", "my boss")
- Something would be useful in future conversations
**SEARCH** memories when:
- Starting a new conversation (get context)
- User references the past ("remember when...")
- Personalizing responses
- Before storing (avoid duplicates)
**UPDATE** when information changes or becomes more accurate.
**FORGET** when user requests or info becomes obsolete.
### Guidelines
1. **Be selective** — Don't store everything. Store what matters.
2. **Be atomic** — One fact per memory. "User likes coffee and tea" → two memories.
3. **Be confident** — Use confidence scores honestly:
- 1.0 = User explicitly stated
- 0.7-0.9 = User strongly implied
- 0.5-0.7 = Inferred from context
4. **Use decay** — Events should decay (decayDays). Facts usually shouldn't.
5. **Check first** — Search before storing to avoid duplicates.
`;
// Plugin definition
const memoryToolsPlugin = {
id: 'memory-tools',
name: 'Memory Tools',
description: 'Agent-controlled memory with confidence scoring, decay, and semantic search',
kind: 'memory' as const,
register(api: OpenClawPluginApi) {
const cfg = parseConfig(api.pluginConfig);
const resolvedDbPath = api.resolvePath(cfg.dbPath!);
const model = cfg.embedding.model ?? 'text-embedding-3-small';
const vectorDim = vectorDimsForModel(model);
const embeddings = new EmbeddingProvider(cfg.embedding.apiKey, model);
const store = new MemoryStore(resolvedDbPath, embeddings, vectorDim);
const tools = createMemoryTools(store);
api.logger.info(`memory-tools: initialized (db: ${resolvedDbPath}, model: ${model})`);
// ═══════════════════════════════════════════════════════════════════════
// Register Tools
// ═══════════════════════════════════════════════════════════════════════
api.registerTool(
{
name: tools.memory_store.name,
label: tools.memory_store.label,
description: tools.memory_store.description,
parameters: tools.memory_store.parameters,
execute: (id, params) => tools.memory_store.execute(id, params as any, {}),
},
{ name: 'memory_store' }
);
api.registerTool(
{
name: tools.memory_update.name,
label: tools.memory_update.label,
description: tools.memory_update.description,
parameters: tools.memory_update.parameters,
execute: (id, params) => tools.memory_update.execute(id, params as any),
},
{ name: 'memory_update' }
);
api.registerTool(
{
name: tools.memory_forget.name,
label: tools.memory_forget.label,
description: tools.memory_forget.description,
parameters: tools.memory_forget.parameters,
execute: (id, params) => tools.memory_forget.execute(id, params as any),
},
{ name: 'memory_forget' }
);
api.registerTool(
{
name: tools.memory_search.name,
label: tools.memory_search.label,
description: tools.memory_search.description,
parameters: tools.memory_search.parameters,
execute: (id, params) => tools.memory_search.execute(id, params as any),
},
{ name: 'memory_search' }
);
api.registerTool(
{
name: tools.memory_summarize.name,
label: tools.memory_summarize.label,
description: tools.memory_summarize.description,
parameters: tools.memory_summarize.parameters,
execute: (id, params) => tools.memory_summarize.execute(id, params as any),
},
{ name: 'memory_summarize' }
);
api.registerTool(
{
name: tools.memory_list.name,
label: tools.memory_list.label,
description: tools.memory_list.description,
parameters: tools.memory_list.parameters,
execute: (id, params) => tools.memory_list.execute(id, params as any),
},
{ name: 'memory_list' }
);
// ═══════════════════════════════════════════════════════════════════════
// Lifecycle Hooks
// ═══════════════════════════════════════════════════════════════════════
// Auto-inject standing instructions at conversation start
if (cfg.autoInjectInstructions !== false) {
api.on('before_agent_start', async (event: { prompt?: string }) => {
// Get standing instructions
const instructions = store.getByCategory('instruction', 10);
if (instructions.length === 0) {
return { systemPrompt: MEMORY_SYSTEM_PROMPT };
}
const instructionList = instructions
.map(m => `- ${m.content}`)
.join('\n');
api.logger.info?.(`memory-tools: injecting ${instructions.length} standing instructions`);
return {
systemPrompt: MEMORY_SYSTEM_PROMPT,
prependContext: `<standing-instructions>\nRemember these user instructions:\n${instructionList}\n</standing-instructions>`,
};
});
}
// ═══════════════════════════════════════════════════════════════════════
// CLI Commands
// ═══════════════════════════════════════════════════════════════════════
api.registerCli(
({ program }: { program: any }) => {
const memory = program
.command('memory-tools')
.description('Memory-as-Tools plugin commands');
memory
.command('stats')
.description('Show memory statistics')
.action(() => {
const total = store.count();
const instructions = store.getByCategory('instruction').length;
const facts = store.getByCategory('fact').length;
const preferences = store.getByCategory('preference').length;
console.log(`Memory Statistics:`);
console.log(` Total: ${total}`);
console.log(` Instructions: ${instructions}`);
console.log(` Facts: ${facts}`);
console.log(` Preferences: ${preferences}`);
});
memory
.command('list')
.description('List memories')
.option('-c, --category <category>', 'Filter by category')
.option('-l, --limit <n>', 'Max results', '20')
.action((opts: { category?: string; limit?: string }) => {
const results = store.list({
category: opts.category as any,
limit: parseInt(opts.limit ?? '20'),
});
console.log(`Showing ${results.items.length} of ${results.total} memories:\n`);
for (const m of results.items) {
console.log(`[${m.id.slice(0, 8)}] [${m.category}] ${m.content.slice(0, 60)}...`);
}
});
memory
.command('search <query>')
.description('Search memories')
.option('-l, --limit <n>', 'Max results', '10')
.action(async (query: string, opts: { limit?: string }) => {
const results = await store.search({
query,
limit: parseInt(opts.limit ?? '10'),
});
console.log(`Found ${results.length} memories:\n`);
for (const r of results) {
console.log(`[${r.memory.id.slice(0, 8)}] (${(r.score * 100).toFixed(0)}%) ${r.memory.content}`);
}
});
memory
.command('export')
.description('Export all memories as JSON')
.action(() => {
const results = store.list({ limit: 10000 });
console.log(JSON.stringify(results.items, null, 2));
});
},
{ commands: ['memory-tools'] }
);
// ═══════════════════════════════════════════════════════════════════════
// Service (lifecycle management)
// ═══════════════════════════════════════════════════════════════════════
api.registerService({
id: 'memory-tools',
start: () => {
api.logger.info(`memory-tools: service started (${store.count()} memories)`);
},
stop: () => {
store.close();
api.logger.info('memory-tools: service stopped');
},
});
},
};
export default memoryToolsPlugin;
// Re-export types for external use
export * from './types.js';
export { MemoryStore } from './store.js';
export { EmbeddingProvider } from './embeddings.js';
export { createMemoryTools } from './tools.js';

View File

@@ -0,0 +1,48 @@
/**
* OpenClaw Plugin SDK Types
*
* Minimal type definitions for OpenClaw plugin development.
* These match the OpenClaw plugin API.
*/
export interface PluginLogger {
debug?: (message: string) => void;
info: (message: string) => void;
warn: (message: string) => void;
error: (message: string) => void;
}
export interface OpenClawPluginApi {
id: string;
name: string;
version?: string;
description?: string;
source: string;
config: Record<string, unknown>;
pluginConfig?: Record<string, unknown>;
logger: PluginLogger;
registerTool: (tool: AnyAgentTool, opts?: { name?: string }) => void;
registerHook: (events: string | string[], handler: unknown, opts?: unknown) => void;
registerCli: (registrar: (ctx: { program: unknown }) => void, opts?: { commands?: string[] }) => void;
registerService: (service: { id: string; start: () => void; stop?: () => void }) => void;
resolvePath: (input: string) => string;
on: (hookName: string, handler: unknown, opts?: { priority?: number }) => void;
}
export interface AnyAgentTool {
name: string;
label?: string;
description: string;
parameters: unknown;
execute: (toolCallId: string, params: unknown) => Promise<ToolResult>;
}
export interface ToolResult {
content: Array<{ type: 'text'; text: string }>;
details?: Record<string, unknown>;
}
export interface PluginHookBeforeAgentStartResult {
systemPrompt?: string;
prependContext?: string;
}

View File

@@ -0,0 +1,422 @@
/**
* Memory Store Tests
*
* Tests for hybrid SQLite + LanceDB storage layer.
* Uses mocked embeddings to avoid API calls.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { MemoryStore } from './store.js';
import { EmbeddingProvider } from './embeddings.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
// Mock embedding provider
class MockEmbeddingProvider {
private counter = 0;
async embed(text: string): Promise<number[]> {
// Generate deterministic pseudo-random vector based on text hash
const hash = this.hashString(text);
const vector = new Array(1536).fill(0).map((_, i) =>
Math.sin(hash + i * 0.1) * 0.5 + 0.5
);
return vector;
}
async embedBatch(texts: string[]): Promise<number[][]> {
return Promise.all(texts.map(t => this.embed(t)));
}
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
}
describe('MemoryStore', () => {
let store: MemoryStore;
let testDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `memory-test-${Date.now()}`);
fs.mkdirSync(testDir, { recursive: true });
const embeddings = new MockEmbeddingProvider() as unknown as EmbeddingProvider;
store = new MemoryStore(testDir, embeddings, 1536);
await store.init(); // Initialize sql.js WASM before tests
});
afterEach(() => {
store.close();
fs.rmSync(testDir, { recursive: true, force: true });
});
describe('create', () => {
it('should create a memory with all fields', async () => {
const memory = await store.create({
content: 'User prefers dark mode',
category: 'preference',
confidence: 0.9,
importance: 0.7,
tags: ['ui', 'settings'],
});
expect(memory.id).toBeDefined();
expect(memory.content).toBe('User prefers dark mode');
expect(memory.category).toBe('preference');
expect(memory.confidence).toBe(0.9);
expect(memory.importance).toBe(0.7);
expect(memory.tags).toEqual(['ui', 'settings']);
expect(memory.createdAt).toBeGreaterThan(0);
expect(memory.deletedAt).toBeUndefined();
});
it('should use default values', async () => {
const memory = await store.create({
content: 'Test memory',
category: 'fact',
});
expect(memory.confidence).toBe(0.8);
expect(memory.importance).toBe(0.5);
expect(memory.tags).toEqual([]);
});
it('should support decay days', async () => {
const memory = await store.create({
content: 'Meeting tomorrow',
category: 'event',
decayDays: 7,
});
expect(memory.decayDays).toBe(7);
});
});
describe('get', () => {
it('should retrieve a memory by id', async () => {
const created = await store.create({
content: 'Test content',
category: 'fact',
});
const retrieved = store.get(created.id);
expect(retrieved).not.toBeNull();
expect(retrieved!.id).toBe(created.id);
expect(retrieved!.content).toBe('Test content');
});
it('should return null for non-existent id', () => {
const result = store.get('non-existent-id');
expect(result).toBeNull();
});
});
describe('update', () => {
it('should update content', async () => {
const memory = await store.create({
content: 'Original content',
category: 'fact',
});
const updated = await store.update(memory.id, {
content: 'Updated content',
});
expect(updated.content).toBe('Updated content');
expect(updated.updatedAt).toBeGreaterThan(memory.updatedAt);
});
it('should update confidence and importance', async () => {
const memory = await store.create({
content: 'Test',
category: 'fact',
confidence: 0.5,
importance: 0.3,
});
const updated = await store.update(memory.id, {
confidence: 0.9,
importance: 0.8,
});
expect(updated.confidence).toBe(0.9);
expect(updated.importance).toBe(0.8);
});
it('should update tags', async () => {
const memory = await store.create({
content: 'Test',
category: 'fact',
tags: ['old'],
});
const updated = await store.update(memory.id, {
tags: ['new', 'tags'],
});
expect(updated.tags).toEqual(['new', 'tags']);
});
});
describe('delete', () => {
it('should soft delete a memory', async () => {
const memory = await store.create({
content: 'To be deleted',
category: 'fact',
});
await store.delete(memory.id, 'Test deletion');
const retrieved = store.get(memory.id);
expect(retrieved!.deletedAt).toBeDefined();
expect(retrieved!.deleteReason).toBe('Test deletion');
});
it('should delete using short ID (first 8 chars)', async () => {
const memory = await store.create({
content: 'Delete with short ID',
category: 'fact',
});
const shortId = memory.id.slice(0, 8);
await store.delete(shortId, 'Short ID deletion');
const retrieved = store.get(memory.id);
expect(retrieved!.deletedAt).toBeDefined();
});
it('should get using short ID (first 8 chars)', async () => {
const memory = await store.create({
content: 'Get with short ID',
category: 'fact',
});
const shortId = memory.id.slice(0, 8);
const retrieved = store.get(shortId);
expect(retrieved).not.toBeNull();
expect(retrieved!.id).toBe(memory.id);
});
});
describe('search', () => {
beforeEach(async () => {
await store.create({
content: 'User loves coffee',
category: 'preference',
confidence: 0.9,
});
await store.create({
content: 'User hates tea',
category: 'preference',
confidence: 0.8,
});
await store.create({
content: 'Meeting at 3pm',
category: 'event',
confidence: 0.95,
});
});
it('should search by semantic query', async () => {
const results = await store.search({
query: 'coffee preference',
limit: 5,
});
expect(results.length).toBeGreaterThan(0);
expect(results[0].score).toBeGreaterThan(0);
});
it('should filter by category', async () => {
const results = await store.search({
category: 'preference',
limit: 10,
});
expect(results.every(r => r.memory.category === 'preference')).toBe(true);
});
it('should filter by minimum confidence', async () => {
const results = await store.search({
minConfidence: 0.85,
limit: 10,
});
expect(results.every(r => r.memory.confidence >= 0.85)).toBe(true);
});
it('should respect limit', async () => {
const results = await store.search({
limit: 1,
});
expect(results.length).toBe(1);
});
});
describe('list', () => {
beforeEach(async () => {
await store.create({ content: 'First', category: 'fact', importance: 0.3 });
await store.create({ content: 'Second', category: 'fact', importance: 0.9 });
await store.create({ content: 'Third', category: 'preference', importance: 0.5 });
});
it('should list all memories', () => {
const results = store.list();
expect(results.total).toBe(3);
expect(results.items.length).toBe(3);
});
it('should filter by category', () => {
const results = store.list({ category: 'fact' });
expect(results.total).toBe(2);
expect(results.items.every(m => m.category === 'fact')).toBe(true);
});
it('should sort by importance', () => {
const results = store.list({ sortBy: 'importance', sortOrder: 'desc' });
expect(results.items[0].importance).toBeGreaterThanOrEqual(results.items[1].importance);
});
it('should paginate', () => {
const page1 = store.list({ limit: 2, offset: 0 });
const page2 = store.list({ limit: 2, offset: 2 });
expect(page1.items.length).toBe(2);
expect(page2.items.length).toBe(1);
});
});
describe('findDuplicates', () => {
it('should find similar memories', async () => {
await store.create({
content: 'User prefers dark mode',
category: 'preference',
});
const duplicates = await store.findDuplicates('User likes dark mode', 0.5);
expect(duplicates.length).toBe(1);
expect(duplicates[0].memory.content).toBe('User prefers dark mode');
});
it('should return empty for dissimilar content', async () => {
await store.create({
content: 'User prefers dark mode',
category: 'preference',
});
const duplicates = await store.findDuplicates('Meeting tomorrow at 3pm', 0.95);
expect(duplicates.length).toBe(0);
});
});
describe('count', () => {
it('should count all non-deleted memories', async () => {
expect(store.count()).toBe(0);
await store.create({ content: 'First', category: 'fact' });
await store.create({ content: 'Second', category: 'fact' });
expect(store.count()).toBe(2);
const m = await store.create({ content: 'Third', category: 'fact' });
await store.delete(m.id);
expect(store.count()).toBe(2);
});
});
describe('getByCategory', () => {
it('should get memories by category sorted by importance', async () => {
await store.create({ content: 'Low', category: 'instruction', importance: 0.3 });
await store.create({ content: 'High', category: 'instruction', importance: 0.9 });
await store.create({ content: 'Medium', category: 'instruction', importance: 0.6 });
await store.create({ content: 'Other', category: 'fact', importance: 1.0 });
const results = store.getByCategory('instruction');
expect(results.length).toBe(3);
expect(results[0].content).toBe('High');
expect(results[1].content).toBe('Medium');
expect(results[2].content).toBe('Low');
});
});
describe('touchMany', () => {
it('should update lastAccessedAt for multiple memories', async () => {
const m1 = await store.create({ content: 'First', category: 'fact' });
const m2 = await store.create({ content: 'Second', category: 'fact' });
const originalAccess = store.get(m1.id)!.lastAccessedAt;
// Wait a bit to ensure timestamp changes
await new Promise(resolve => setTimeout(resolve, 10));
store.touchMany([m1.id, m2.id]);
const updated = store.get(m1.id)!;
expect(updated.lastAccessedAt).toBeGreaterThan(originalAccess);
});
});
describe('close and reopen', () => {
it('should allow database to be reopened after close (SIGUSR1 restart scenario)', async () => {
// Create a memory before close
const memory = await store.create({
content: 'Persistent memory',
category: 'fact',
importance: 0.8,
});
// Close the database (simulates service stop)
store.close();
// Reopen by calling an async method (simulates service restart)
// This should re-initialize both SQLite and LanceDB
await store.init();
// Verify we can still read the persisted memory
const retrieved = store.get(memory.id);
expect(retrieved).toBeDefined();
expect(retrieved!.content).toBe('Persistent memory');
// Verify we can create new memories
const newMemory = await store.create({
content: 'New memory after restart',
category: 'fact',
});
expect(newMemory.id).toBeDefined();
// Verify search still works (uses LanceDB)
const results = await store.search('Persistent', { limit: 5 });
expect(results.length).toBeGreaterThan(0);
});
it('should handle multiple close/reopen cycles', async () => {
for (let i = 0; i < 3; i++) {
await store.create({ content: `Cycle ${i}`, category: 'fact' });
store.close();
await store.init();
}
// Should have all 3 memories
const all = store.list();
expect(all.total).toBe(3);
expect(all.items.length).toBe(3);
});
});
});

View File

@@ -0,0 +1,614 @@
/**
* Hybrid Memory Store
*
* SQLite (via sql.js/WASM) for metadata (fast queries, debuggable, no native deps)
* LanceDB for vectors (semantic search)
*/
import initSqlJs, { type Database as SqlJsDatabase, type SqlValue } from 'sql.js';
import * as lancedb from '@lancedb/lancedb';
import { randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import type {
Memory,
MemorySearchResult,
CreateMemoryInput,
UpdateMemoryInput,
SearchOptions,
ListOptions,
MemoryCategory,
} from './types.js';
import { EmbeddingProvider } from './embeddings.js';
const VECTOR_TABLE = 'memory_vectors';
export class MemoryStore {
private db: SqlJsDatabase | null = null;
private vectorDb: lancedb.Connection | null = null;
private vectorTable: lancedb.Table | null = null;
private embeddings: EmbeddingProvider;
private vectorDim: number;
private dbPath: string;
private dbFilePath: string;
private initPromise: Promise<void> | null = null;
private sqliteInitPromise: Promise<void> | null = null;
constructor(
dbPath: string,
embeddings: EmbeddingProvider,
vectorDim: number
) {
this.dbPath = dbPath;
this.embeddings = embeddings;
this.vectorDim = vectorDim;
this.dbFilePath = path.join(dbPath, 'memory.db');
// Ensure directory exists
if (!fs.existsSync(dbPath)) {
fs.mkdirSync(dbPath, { recursive: true });
}
}
/**
* Initialize the database. Call this before using sync methods.
* Async methods will auto-initialize, but this is useful for tests
* or when you need to use sync methods without calling async ones first.
*/
async init(): Promise<void> {
await this.ensureSqlite();
}
/**
* Ensure SQLite is initialized (lazy async init for sql.js WASM)
*/
private async ensureSqlite(): Promise<SqlJsDatabase> {
if (this.db) return this.db;
if (this.sqliteInitPromise) {
await this.sqliteInitPromise;
return this.db!;
}
this.sqliteInitPromise = this.initSqlite();
await this.sqliteInitPromise;
return this.db!;
}
private async initSqlite(): Promise<void> {
const SQL = await initSqlJs();
// Load existing database if it exists
if (fs.existsSync(this.dbFilePath)) {
const buffer = fs.readFileSync(this.dbFilePath);
this.db = new SQL.Database(buffer);
} else {
this.db = new SQL.Database();
}
// Create schema
this.db.run(`
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
category TEXT NOT NULL,
confidence REAL DEFAULT 0.8,
importance REAL DEFAULT 0.5,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_accessed_at INTEGER,
decay_days INTEGER,
source_channel TEXT,
source_message_id TEXT,
tags TEXT,
supersedes TEXT,
deleted_at INTEGER,
delete_reason TEXT
)
`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_memories_confidence ON memories(confidence)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_memories_deleted ON memories(deleted_at)`);
this.save();
}
/**
* Persist database to disk
*/
private save(): void {
if (!this.db) return;
const data = this.db.export();
fs.writeFileSync(this.dbFilePath, Buffer.from(data));
}
private async ensureVectorDb(): Promise<void> {
if (this.vectorTable) return;
if (this.initPromise) return this.initPromise;
this.initPromise = this.initVectorDb();
return this.initPromise;
}
private async initVectorDb(): Promise<void> {
const vectorPath = path.join(this.dbPath, 'vectors');
this.vectorDb = await lancedb.connect(vectorPath);
const tables = await this.vectorDb.tableNames();
if (tables.includes(VECTOR_TABLE)) {
this.vectorTable = await this.vectorDb.openTable(VECTOR_TABLE);
} else {
// Create with schema row then delete it
this.vectorTable = await this.vectorDb.createTable(VECTOR_TABLE, [{
id: '__schema__',
vector: new Array(this.vectorDim).fill(0),
text: '',
}]);
await this.vectorTable.delete('id = "__schema__"');
}
}
async create(input: CreateMemoryInput): Promise<Memory> {
const db = await this.ensureSqlite();
await this.ensureVectorDb();
const id = randomUUID();
const now = Date.now();
// Generate embedding
const vector = await this.embeddings.embed(input.content);
// Store vector in LanceDB
await this.vectorTable!.add([{
id,
vector,
text: input.content,
}]);
// Store metadata in SQLite
db.run(`
INSERT INTO memories (
id, content, category, confidence, importance,
created_at, updated_at, last_accessed_at, decay_days,
source_channel, source_message_id, tags
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
id,
input.content,
input.category,
input.confidence ?? 0.8,
input.importance ?? 0.5,
now,
now,
now,
input.decayDays ?? null,
input.sourceChannel ?? null,
input.sourceMessageId ?? null,
JSON.stringify(input.tags ?? [])
]);
this.save();
return (await this.getAsync(id))!;
}
/**
* Async version of get for internal use
*/
private async getAsync(id: string): Promise<Memory | null> {
const db = await this.ensureSqlite();
return this.getFromDb(db, id);
}
/**
* Sync get - requires ensureSqlite() to have been called first
*/
get(id: string): Memory | null {
if (!this.db) {
throw new Error('Database not initialized. Call an async method first or use getAsync().');
}
return this.getFromDb(this.db, id);
}
private getFromDb(db: SqlJsDatabase, id: string): Memory | null {
let query = 'SELECT * FROM memories WHERE id = ?';
let param: string = id;
if (id.length === 8) {
query = 'SELECT * FROM memories WHERE id LIKE ? LIMIT 1';
param = `${id}%`;
}
const stmt = db.prepare(query);
stmt.bind([param]);
if (!stmt.step()) {
stmt.free();
return null;
}
const row = stmt.getAsObject() as Record<string, unknown>;
stmt.free();
return this.rowToMemory(row);
}
async update(id: string, updates: UpdateMemoryInput): Promise<Memory> {
const db = await this.ensureSqlite();
await this.ensureVectorDb();
const sets: string[] = ['updated_at = ?'];
const params: SqlValue[] = [Date.now()];
if (updates.content !== undefined) {
sets.push('content = ?');
params.push(updates.content);
// Re-embed and update vector
const vector = await this.embeddings.embed(updates.content);
await this.vectorTable!.update({
where: `id = '${id}'`,
values: { vector, text: updates.content },
});
}
if (updates.confidence !== undefined) {
sets.push('confidence = ?');
params.push(updates.confidence);
}
if (updates.importance !== undefined) {
sets.push('importance = ?');
params.push(updates.importance);
}
if (updates.decayDays !== undefined) {
sets.push('decay_days = ?');
params.push(updates.decayDays);
}
if (updates.tags !== undefined) {
sets.push('tags = ?');
params.push(JSON.stringify(updates.tags));
}
params.push(id);
db.run(`UPDATE memories SET ${sets.join(', ')} WHERE id = ?`, params as SqlValue[]);
this.save();
return (await this.getAsync(id))!;
}
async delete(id: string, reason?: string): Promise<void> {
const db = await this.ensureSqlite();
await this.ensureVectorDb();
// Support both full UUID and short ID (first 8 chars)
let fullId = id;
if (id.length === 8) {
const stmt = db.prepare('SELECT id FROM memories WHERE id LIKE ? AND deleted_at IS NULL LIMIT 1');
stmt.bind([`${id}%`]);
if (stmt.step()) {
const row = stmt.getAsObject() as { id: string };
fullId = row.id;
}
stmt.free();
}
// Soft delete in SQLite
db.run(`
UPDATE memories
SET deleted_at = ?, delete_reason = ?
WHERE id = ?
`, [Date.now(), reason ?? null, fullId]);
this.save();
// Remove from vector index
await this.vectorTable!.delete(`id = '${fullId}'`);
}
async search(opts: SearchOptions): Promise<MemorySearchResult[]> {
const db = await this.ensureSqlite();
await this.ensureVectorDb();
let vectorIds: string[] = [];
const vectorScores = new Map<string, number>();
// Semantic search if query provided
if (opts.query) {
const queryVector = await this.embeddings.embed(opts.query);
const results = await this.vectorTable!
.vectorSearch(queryVector)
.limit((opts.limit ?? 10) * 2) // Over-fetch for filtering
.toArray();
for (const row of results) {
const distance = (row._distance as number) ?? 0;
const score = 1 / (1 + distance); // Convert L2 distance to similarity
vectorIds.push(row.id as string);
vectorScores.set(row.id as string, score);
}
}
// Build SQL query
let sql = 'SELECT * FROM memories WHERE deleted_at IS NULL';
const params: SqlValue[] = [];
if (vectorIds.length > 0) {
sql += ` AND id IN (${vectorIds.map(() => '?').join(',')})`;
params.push(...vectorIds);
}
if (opts.category) {
sql += ' AND category = ?';
params.push(opts.category);
}
if (opts.minConfidence !== undefined) {
sql += ' AND confidence >= ?';
params.push(opts.minConfidence);
}
if (opts.minImportance !== undefined) {
sql += ' AND importance >= ?';
params.push(opts.minImportance);
}
if (opts.tags?.length) {
for (const tag of opts.tags) {
sql += ' AND tags LIKE ?';
params.push(`%"${tag}"%`);
}
}
if (opts.excludeDecayed !== false) {
sql += ` AND (decay_days IS NULL OR
(created_at + decay_days * 86400000) > ?)`;
params.push(Date.now());
}
sql += ' LIMIT ?';
params.push(opts.limit ?? 10);
const rows = this.queryAll(db, sql, params);
// Map results with scores
const results: MemorySearchResult[] = rows.map(row => ({
memory: this.rowToMemory(row),
score: vectorScores.get(row.id as string) ?? 1.0,
}));
// Sort by vector score if semantic search was used
if (vectorIds.length > 0) {
const idOrder = new Map(vectorIds.map((id, i) => [id, i]));
results.sort((a, b) =>
(idOrder.get(a.memory.id) ?? 999) - (idOrder.get(b.memory.id) ?? 999)
);
}
return results;
}
/**
* Async version of list
*/
async listAsync(opts: ListOptions = {}): Promise<{ total: number; items: Memory[] }> {
const db = await this.ensureSqlite();
return this.listFromDb(db, opts);
}
/**
* Sync list - requires database to be initialized
*/
list(opts: ListOptions = {}): { total: number; items: Memory[] } {
if (!this.db) {
throw new Error('Database not initialized. Call an async method first or use listAsync().');
}
return this.listFromDb(this.db, opts);
}
private listFromDb(db: SqlJsDatabase, opts: ListOptions): { total: number; items: Memory[] } {
const sortBy = opts.sortBy ?? 'created_at';
const sortCol = sortBy.replace(/([A-Z])/g, '_$1').toLowerCase();
const sortOrder = opts.sortOrder ?? 'desc';
let sql = 'SELECT * FROM memories WHERE deleted_at IS NULL';
const params: SqlValue[] = [];
if (opts.category) {
sql += ' AND category = ?';
params.push(opts.category);
}
const countSql = sql.replace('SELECT *', 'SELECT COUNT(*) as count');
const countResult = this.queryOne(db, countSql, params as SqlValue[]) as { count: number };
sql += ` ORDER BY ${sortCol} ${sortOrder}`;
sql += ' LIMIT ? OFFSET ?';
params.push(opts.limit ?? 20, opts.offset ?? 0);
const rows = this.queryAll(db, sql, params);
return {
total: countResult.count,
items: rows.map(row => this.rowToMemory(row)),
};
}
async findDuplicates(content: string, threshold: number = 0.95): Promise<MemorySearchResult[]> {
await this.ensureSqlite();
await this.ensureVectorDb();
const vector = await this.embeddings.embed(content);
const results = await this.vectorTable!
.vectorSearch(vector)
.limit(1)
.toArray();
if (results.length === 0) return [];
const distance = (results[0]._distance as number) ?? 0;
const score = 1 / (1 + distance);
if (score < threshold) return [];
const memory = await this.getAsync(results[0].id as string);
if (!memory || memory.deletedAt) return [];
return [{ memory, score }];
}
/**
* Async version of touchMany
*/
async touchManyAsync(ids: string[]): Promise<void> {
if (ids.length === 0) return;
const db = await this.ensureSqlite();
this.touchManyInDb(db, ids);
}
/**
* Sync touchMany - requires database to be initialized
*/
touchMany(ids: string[]): void {
if (ids.length === 0) return;
if (!this.db) {
throw new Error('Database not initialized. Call an async method first or use touchManyAsync().');
}
this.touchManyInDb(this.db, ids);
}
private touchManyInDb(db: SqlJsDatabase, ids: string[]): void {
const now = Date.now();
const placeholders = ids.map(() => '?').join(',');
db.run(`UPDATE memories SET last_accessed_at = ? WHERE id IN (${placeholders})`, [now, ...ids]);
this.save();
}
/**
* Async version of count
*/
async countAsync(): Promise<number> {
const db = await this.ensureSqlite();
return this.countFromDb(db);
}
/**
* Sync count - requires database to be initialized
*/
count(): number {
if (!this.db) {
throw new Error('Database not initialized. Call an async method first or use countAsync().');
}
return this.countFromDb(this.db);
}
private countFromDb(db: SqlJsDatabase): number {
const result = this.queryOne(db, 'SELECT COUNT(*) as count FROM memories WHERE deleted_at IS NULL', []) as { count: number };
return result.count;
}
/**
* Async version of getByCategory
*/
async getByCategoryAsync(category: MemoryCategory, limit: number = 50): Promise<Memory[]> {
const db = await this.ensureSqlite();
return this.getByCategoryFromDb(db, category, limit);
}
/**
* Sync getByCategory - requires database to be initialized
*/
getByCategory(category: MemoryCategory, limit: number = 50): Memory[] {
if (!this.db) {
throw new Error('Database not initialized. Call an async method first or use getByCategoryAsync().');
}
return this.getByCategoryFromDb(this.db, category, limit);
}
private getByCategoryFromDb(db: SqlJsDatabase, category: MemoryCategory, limit: number): Memory[] {
const rows = this.queryAll(db, `
SELECT * FROM memories
WHERE category = ? AND deleted_at IS NULL
ORDER BY importance DESC, created_at DESC
LIMIT ?
`, [category, limit]);
return rows.map(row => this.rowToMemory(row));
}
/**
* Helper to run a query and get all results as objects
*/
private queryAll(db: SqlJsDatabase, sql: string, params: SqlValue[]): Record<string, unknown>[] {
const stmt = db.prepare(sql);
stmt.bind(params);
const results: Record<string, unknown>[] = [];
while (stmt.step()) {
results.push(stmt.getAsObject() as Record<string, unknown>);
}
stmt.free();
return results;
}
/**
* Helper to run a query and get first result as object
*/
private queryOne(db: SqlJsDatabase, sql: string, params: SqlValue[]): Record<string, unknown> | null {
const stmt = db.prepare(sql);
stmt.bind(params);
if (!stmt.step()) {
stmt.free();
return null;
}
const row = stmt.getAsObject() as Record<string, unknown>;
stmt.free();
return row;
}
private rowToMemory(row: Record<string, unknown>): Memory {
return {
id: row.id as string,
content: row.content as string,
category: row.category as MemoryCategory,
confidence: row.confidence as number,
importance: row.importance as number,
createdAt: row.created_at as number,
updatedAt: row.updated_at as number,
lastAccessedAt: row.last_accessed_at as number,
decayDays: row.decay_days as number | null,
sourceChannel: (row.source_channel as string | null) ?? undefined,
sourceMessageId: (row.source_message_id as string | null) ?? undefined,
tags: JSON.parse((row.tags as string) || '[]'),
supersedes: (row.supersedes as string | null) ?? undefined,
deletedAt: (row.deleted_at as number | null) ?? undefined,
deleteReason: (row.delete_reason as string | null) ?? undefined,
};
}
close(): void {
// Close SQLite connection
if (this.db) {
this.save();
this.db.close();
this.db = null;
}
// Close LanceDB connection
if (this.vectorDb) {
this.vectorDb.close();
this.vectorDb = null;
}
this.vectorTable = null;
// Reset initialization promises so databases can be reopened
this.initPromise = null;
this.sqliteInitPromise = null;
}
}

View File

@@ -0,0 +1,415 @@
/**
* Memory Tools Tests
*
* Tests for the six agent-controlled memory operations.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { createMemoryTools } from './tools.js';
import { MemoryStore } from './store.js';
import { EmbeddingProvider } from './embeddings.js';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
// Mock embedding provider with word-level similarity
// Simulates semantic embeddings by averaging word hashes
class MockEmbeddingProvider {
async embed(text: string): Promise<number[]> {
// Tokenize into words
const words = text.toLowerCase().split(/\s+/).filter(w => w.length > 0);
// Create embedding by averaging word contributions
const embedding = new Array(1536).fill(0);
for (const word of words) {
const wordHash = this.hashString(word);
for (let i = 0; i < 1536; i++) {
embedding[i] += Math.sin(wordHash + i * 0.1) * 0.5 + 0.5;
}
}
// Normalize by word count
if (words.length > 0) {
for (let i = 0; i < 1536; i++) {
embedding[i] /= words.length;
}
}
return embedding;
}
async embedBatch(texts: string[]): Promise<number[][]> {
return Promise.all(texts.map(t => this.embed(t)));
}
private hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) - hash) + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
}
describe('Memory Tools', () => {
let store: MemoryStore;
let tools: ReturnType<typeof createMemoryTools>;
let testDir: string;
beforeEach(async () => {
testDir = path.join(os.tmpdir(), `memory-tools-test-${Date.now()}`);
fs.mkdirSync(testDir, { recursive: true });
const embeddings = new MockEmbeddingProvider() as unknown as EmbeddingProvider;
store = new MemoryStore(testDir, embeddings, 1536);
await store.init(); // Initialize sql.js WASM before tests
tools = createMemoryTools(store);
});
afterEach(() => {
store.close();
fs.rmSync(testDir, { recursive: true, force: true });
});
describe('memory_store', () => {
it('should store a new memory', async () => {
const result = await tools.memory_store.execute('call-1', {
content: 'User prefers dark mode',
category: 'preference',
confidence: 0.9,
});
expect(result.details.action).toBe('created');
expect(result.details.id).toBeDefined();
expect(result.content[0].text).toContain('Stored');
});
it('should detect duplicates', async () => {
// Store first memory
await tools.memory_store.execute('call-1', {
content: 'User prefers dark mode',
category: 'preference',
});
// Try to store very similar memory
const result = await tools.memory_store.execute('call-2', {
content: 'User prefers dark mode',
category: 'preference',
});
expect(result.details.action).toBe('duplicate');
expect(result.content[0].text).toContain('Similar memory already exists');
});
it('should replace memory when supersedes is provided', async () => {
// Store first memory
const first = await tools.memory_store.execute('call-1', {
content: 'User favorite color is blue',
category: 'preference',
});
expect(first.details.action).toBe('created');
// Store replacement using explicit supersedes parameter
const result = await tools.memory_store.execute('call-2', {
content: 'User favorite color is purple',
category: 'preference',
supersedes: first.details.id,
});
// Should replace the old memory
expect(result.details.action).toBe('replaced');
expect(result.details.supersededId).toBe(first.details.id);
expect(result.content[0].text).toContain('replaced previous entry');
// Old memory should be soft-deleted
const oldMemory = store.get(first.details.id);
expect(oldMemory!.deletedAt).toBeDefined();
});
it('should include tags and decay', async () => {
const result = await tools.memory_store.execute('call-1', {
content: 'Meeting tomorrow at 3pm',
category: 'event',
tags: ['meeting', 'work'],
decayDays: 7,
});
expect(result.details.action).toBe('created');
const memory = store.get(result.details.id);
expect(memory!.tags).toEqual(['meeting', 'work']);
expect(memory!.decayDays).toBe(7);
});
});
describe('memory_update', () => {
it('should update memory content', async () => {
const created = await tools.memory_store.execute('call-1', {
content: 'User dog name is Max',
category: 'fact',
});
const result = await tools.memory_update.execute('call-2', {
id: created.details.id,
content: 'User dog name is Rex',
});
expect(result.details.action).toBe('updated');
expect(result.content[0].text).toContain('Rex');
});
it('should update confidence', async () => {
const created = await tools.memory_store.execute('call-1', {
content: 'User might like coffee',
category: 'preference',
confidence: 0.5,
});
await tools.memory_update.execute('call-2', {
id: created.details.id,
confidence: 0.95,
});
const memory = store.get(created.details.id);
expect(memory!.confidence).toBe(0.95);
});
it('should return error for non-existent memory', async () => {
const result = await tools.memory_update.execute('call-1', {
id: 'non-existent',
content: 'New content',
});
expect(result.details.error).toBe('not_found');
});
});
describe('memory_forget', () => {
it('should delete by id', async () => {
const created = await tools.memory_store.execute('call-1', {
content: 'Delete me',
category: 'fact',
});
const result = await tools.memory_forget.execute('call-2', {
id: created.details.id,
reason: 'User requested',
});
expect(result.details.action).toBe('deleted');
const memory = store.get(created.details.id);
expect(memory!.deletedAt).toBeDefined();
});
it('should auto-delete high-confidence single match', async () => {
await tools.memory_store.execute('call-1', {
content: 'My old car is a Honda',
category: 'fact',
});
const result = await tools.memory_forget.execute('call-2', {
query: 'My old car is a Honda',
});
expect(result.details.action).toBe('deleted');
});
it('should return candidates when multiple matches', async () => {
await tools.memory_store.execute('call-1', {
content: 'User likes coffee',
category: 'preference',
});
await tools.memory_store.execute('call-2', {
content: 'User likes tea',
category: 'preference',
});
// Search with ambiguous query that doesn't exactly match either
const result = await tools.memory_forget.execute('call-3', {
query: 'beverage preferences',
});
// Should return candidates since no exact text match and scores are similar
expect(result.details.action).toBe('candidates');
expect(result.details.candidates.length).toBeGreaterThanOrEqual(1);
});
it('should return error when no params provided', async () => {
const result = await tools.memory_forget.execute('call-1', {});
expect(result.details.error).toBe('missing_param');
});
});
describe('memory_search', () => {
beforeEach(async () => {
await tools.memory_store.execute('call-1', {
content: 'User prefers dark mode',
category: 'preference',
confidence: 0.9,
});
await tools.memory_store.execute('call-2', {
content: 'User sister is Sarah',
category: 'relationship',
confidence: 0.95,
});
await tools.memory_store.execute('call-3', {
content: 'Meeting at 3pm tomorrow',
category: 'event',
confidence: 0.8,
});
});
it('should search by query', async () => {
const result = await tools.memory_search.execute('call-4', {
query: 'dark mode settings',
limit: 5,
});
expect(result.details.count).toBeGreaterThan(0);
expect(result.details.memories.length).toBeGreaterThan(0);
});
it('should filter by category', async () => {
const result = await tools.memory_search.execute('call-4', {
category: 'preference',
});
expect(result.details.memories.every(
(m: any) => m.category === 'preference'
)).toBe(true);
});
it('should filter by minimum confidence', async () => {
const result = await tools.memory_search.execute('call-4', {
minConfidence: 0.9,
});
expect(result.details.memories.every(
(m: any) => m.confidence >= 0.9
)).toBe(true);
});
it('should return no results message', async () => {
store.close();
// Create fresh store in a new directory
const emptyDir = path.join(os.tmpdir(), `memory-empty-test-${Date.now()}`);
fs.mkdirSync(emptyDir, { recursive: true });
const embeddings = new MockEmbeddingProvider() as unknown as EmbeddingProvider;
const emptyStore = new MemoryStore(emptyDir, embeddings, 1536);
const emptyTools = createMemoryTools(emptyStore);
const result = await emptyTools.memory_search.execute('call-1', {
query: 'something',
});
expect(result.details.count).toBe(0);
expect(result.content[0].text).toBe('No relevant memories found.');
emptyStore.close();
fs.rmSync(emptyDir, { recursive: true, force: true });
// Restore original store for other tests
store = new MemoryStore(testDir, embeddings, 1536);
tools = createMemoryTools(store);
});
});
describe('memory_summarize', () => {
beforeEach(async () => {
await tools.memory_store.execute('call-1', {
content: 'User works at Acme Corp',
category: 'fact',
});
await tools.memory_store.execute('call-2', {
content: 'User is a software engineer',
category: 'fact',
});
await tools.memory_store.execute('call-3', {
content: 'User prefers morning meetings',
category: 'preference',
});
});
it('should summarize memories by topic', async () => {
const result = await tools.memory_summarize.execute('call-4', {
topic: 'work',
});
expect(result.details.memoryCount).toBeGreaterThan(0);
expect(result.content[0].text).toContain('Summary');
});
it('should return no memories message', async () => {
const result = await tools.memory_summarize.execute('call-4', {
topic: 'completely unrelated xyz123',
});
// With our mock embeddings, might still return some results
// Just verify the response structure
expect(result.content[0].text).toBeDefined();
});
});
describe('memory_list', () => {
beforeEach(async () => {
await tools.memory_store.execute('call-1', {
content: 'First memory',
category: 'fact',
importance: 0.3,
});
await tools.memory_store.execute('call-2', {
content: 'Second memory',
category: 'preference',
importance: 0.9,
});
await tools.memory_store.execute('call-3', {
content: 'Third memory',
category: 'fact',
importance: 0.6,
});
});
it('should list all memories', async () => {
const result = await tools.memory_list.execute('call-4', {});
expect(result.details.total).toBe(3);
expect(result.details.count).toBe(3);
});
it('should filter by category', async () => {
const result = await tools.memory_list.execute('call-4', {
category: 'fact',
});
expect(result.details.total).toBe(2);
expect(result.details.memories.every(
(m: any) => m.category === 'fact'
)).toBe(true);
});
it('should sort by importance', async () => {
const result = await tools.memory_list.execute('call-4', {
sortBy: 'importance',
});
const importances = result.details.memories.map((m: any) => m.importance);
expect(importances[0]).toBeGreaterThanOrEqual(importances[1]);
});
it('should paginate', async () => {
const result = await tools.memory_list.execute('call-4', {
limit: 2,
offset: 0,
});
expect(result.details.total).toBe(3);
expect(result.details.count).toBe(2);
});
});
});

View File

@@ -0,0 +1,574 @@
/**
* Memory Tools
*
* The six agent-controlled memory operations:
* - memory_store: Save new memories
* - memory_update: Modify existing memories
* - memory_forget: Delete memories
* - memory_search: Semantic search
* - memory_summarize: Get topic summary
* - memory_list: Browse all memories
*/
import { Type } from '@sinclair/typebox';
import type { MemoryStore } from './store.js';
import { MEMORY_CATEGORIES, type MemoryCategory } from './types.js';
// Type helper for string enums (OpenClaw compatible)
function stringEnum<T extends string>(values: readonly T[]) {
return Type.Unsafe<T>({ type: 'string', enum: [...values] });
}
export function createMemoryTools(store: MemoryStore) {
return {
// ═══════════════════════════════════════════════════════════════════════
// STORE - Add new memory
// ═══════════════════════════════════════════════════════════════════════
memory_store: {
name: 'memory_store',
label: 'Memory Store',
description: `Store a new memory about the user. Use when you learn something worth remembering long-term.
WHEN to use:
- User shares personal info (name, birthday, preferences)
- User gives standing instructions ("always summarize emails")
- User mentions relationships ("my wife Sarah")
- User states preferences ("I prefer bullet points")
- Important decisions are made
WHEN NOT to use:
- Trivial conversation (weather, greetings)
- Already stored (use memory_update instead)
- Temporary context (use conversation history)`,
parameters: Type.Object({
content: Type.String({
description: 'The fact/preference/info to remember. Be specific and atomic.'
}),
category: stringEnum(MEMORY_CATEGORIES),
confidence: Type.Optional(Type.Number({
minimum: 0,
maximum: 1,
description: 'How confident this is accurate. 1.0 = explicitly stated, 0.5 = inferred'
})),
importance: Type.Optional(Type.Number({
minimum: 0,
maximum: 1,
description: 'How important is this. 1.0 = critical instruction, 0.3 = nice to know'
})),
decayDays: Type.Optional(Type.Number({
description: 'Days until memory becomes stale. Omit for permanent. Events should have decay.'
})),
tags: Type.Optional(Type.Array(Type.String(), {
description: 'Tags for categorization and retrieval'
})),
supersedes: Type.Optional(Type.String({
description: 'ID of memory this replaces (will delete the old one)'
})),
}),
async execute(
_toolCallId: string,
params: {
content: string;
category: MemoryCategory;
confidence?: number;
importance?: number;
decayDays?: number;
tags?: string[];
supersedes?: string;
},
ctx?: { messageChannel?: string; }
) {
// Handle explicit supersedes first (user knows what to replace)
let supersededId: string | undefined = params.supersedes;
if (params.supersedes) {
await store.delete(params.supersedes, 'superseded by new memory');
}
// Check for similar/conflicting memories
// Use low threshold (0.4) to catch potential conflicts, then decide based on score
const similar = await store.findDuplicates(params.content, 0.4);
if (similar.length > 0 && !params.supersedes) {
const match = similar[0];
const isHighSimilarity = match.score > 0.92;
const isSameCategory = match.memory.category === params.category;
// High similarity = likely duplicate (exact same info)
if (isHighSimilarity) {
return {
content: [{
type: 'text' as const,
text: `Similar memory already exists: "${match.memory.content}" (${(match.score * 100).toFixed(0)}% match). Use memory_update to modify it, or pass supersedes="${match.memory.id}" to replace it.`
}],
details: {
action: 'duplicate',
existingId: match.memory.id,
existingContent: match.memory.content,
similarity: match.score,
},
};
}
// Same category + moderate similarity = conflicting info -> AUTO-REPLACE
// This handles corrections like "favorite color is blue" -> "favorite color is purple"
else if (isSameCategory && match.score > 0.5) {
await store.delete(match.memory.id, 'auto-superseded by updated info');
supersededId = match.memory.id;
}
}
const memory = await store.create({
content: params.content,
category: params.category,
confidence: params.confidence ?? 0.8,
importance: params.importance ?? 0.5,
decayDays: params.decayDays,
tags: params.tags ?? [],
sourceChannel: ctx?.messageChannel,
});
// Build response message
const contentPreview = `${params.content.slice(0, 80)}${params.content.length > 80 ? '...' : ''}`;
const message = supersededId
? `Updated: "${contentPreview}" [${params.category}] (replaced previous entry)`
: `Stored: "${contentPreview}" [${params.category}]`;
return {
content: [{
type: 'text' as const,
text: message,
}],
details: {
action: supersededId ? 'replaced' : 'created',
id: memory.id,
category: memory.category,
confidence: memory.confidence,
supersededId,
},
};
},
},
// ═══════════════════════════════════════════════════════════════════════
// UPDATE - Modify existing memory
// ═══════════════════════════════════════════════════════════════════════
memory_update: {
name: 'memory_update',
label: 'Memory Update',
description: `Update an existing memory when information changes or was incorrect.
Use when:
- User corrects previous info ("actually my dog's name is Rex, not Max")
- Information becomes more specific ("my meeting is at 3pm, not just 'afternoon'")
- Confidence changes (user confirms something you inferred)`,
parameters: Type.Object({
id: Type.String({
description: 'ID of memory to update (from memory_search results)'
}),
content: Type.Optional(Type.String({
description: 'Updated content'
})),
confidence: Type.Optional(Type.Number({
minimum: 0,
maximum: 1,
description: 'Updated confidence score'
})),
importance: Type.Optional(Type.Number({
minimum: 0,
maximum: 1,
description: 'Updated importance score'
})),
}),
async execute(
_toolCallId: string,
params: {
id: string;
content?: string;
confidence?: number;
importance?: number;
}
) {
const existing = store.get(params.id);
if (!existing) {
return {
content: [{ type: 'text' as const, text: `Memory ${params.id} not found.` }],
details: { error: 'not_found' },
};
}
const memory = await store.update(params.id, {
content: params.content,
confidence: params.confidence,
importance: params.importance,
});
return {
content: [{
type: 'text' as const,
text: `Updated memory: "${memory.content.slice(0, 80)}${memory.content.length > 80 ? '...' : ''}"`
}],
details: {
action: 'updated',
id: memory.id,
content: memory.content,
confidence: memory.confidence,
},
};
},
},
// ═══════════════════════════════════════════════════════════════════════
// FORGET - Delete memory
// ═══════════════════════════════════════════════════════════════════════
memory_forget: {
name: 'memory_forget',
label: 'Memory Forget',
description: `Delete a memory permanently.
Use when:
- User explicitly asks you to forget something
- Information is no longer relevant ("I sold that car")
- Memory was stored in error`,
parameters: Type.Object({
id: Type.Optional(Type.String({
description: 'ID of memory to delete (if known)'
})),
query: Type.Optional(Type.String({
description: 'Search query to find memory to delete (if ID unknown)'
})),
reason: Type.Optional(Type.String({
description: 'Why this memory is being forgotten (for audit log)'
})),
}),
async execute(
_toolCallId: string,
params: {
id?: string;
query?: string;
reason?: string;
}
) {
if (params.id) {
const existing = store.get(params.id);
if (!existing) {
return {
content: [{ type: 'text' as const, text: `Memory ${params.id} not found.` }],
details: { error: 'not_found' },
};
}
await store.delete(params.id, params.reason);
return {
content: [{ type: 'text' as const, text: `Forgotten: "${existing.content.slice(0, 60)}..."` }],
details: { action: 'deleted', id: params.id },
};
}
if (params.query) {
const results = await store.search({
query: params.query,
limit: 5,
minConfidence: 0.3,
});
if (results.length === 0) {
return {
content: [{ type: 'text' as const, text: 'No matching memories found.' }],
details: { found: 0 },
};
}
// Check if query text appears in any result (case-insensitive exact match)
const queryLower = params.query.toLowerCase();
const exactMatch = results.find(r =>
r.memory.content.toLowerCase().includes(queryLower)
);
// Auto-delete if:
// 1. Single high-confidence match (score > 0.9), OR
// 2. Query text appears literally in top result, OR
// 3. Top result has significantly higher score than second (clear winner)
const topResult = results[0];
const secondScore = results.length > 1 ? results[1].score : 0;
const clearWinner = topResult.score > 0.5 && topResult.score > secondScore * 1.5;
if (exactMatch || (results.length === 1 && topResult.score > 0.9) || clearWinner) {
const toDelete = exactMatch || topResult;
await store.delete(toDelete.memory.id, params.reason);
return {
content: [{
type: 'text' as const,
text: `Forgotten: "${toDelete.memory.content.slice(0, 60)}..."`
}],
details: { action: 'deleted', id: toDelete.memory.id },
};
}
// Return candidates for user selection
const list = results
.map(r => `- [${r.memory.id.slice(0, 8)}] ${r.memory.content.slice(0, 50)}... (${(r.score * 100).toFixed(0)}%)`)
.join('\n');
return {
content: [{
type: 'text' as const,
text: `Found ${results.length} candidates. Specify id:\n${list}`
}],
details: {
action: 'candidates',
candidates: results.map(r => ({
id: r.memory.id,
content: r.memory.content,
score: r.score,
})),
},
};
}
return {
content: [{ type: 'text' as const, text: 'Provide id or query.' }],
details: { error: 'missing_param' },
};
},
},
// ═══════════════════════════════════════════════════════════════════════
// SEARCH - Semantic search
// ═══════════════════════════════════════════════════════════════════════
memory_search: {
name: 'memory_search',
label: 'Memory Search',
description: `Search memories by semantic similarity and/or filters.
Use when:
- You need context about the user to answer well
- User references something from the past ("remember when I told you...")
- You want to personalize a response
- Before storing, to check if memory already exists`,
parameters: Type.Object({
query: Type.Optional(Type.String({
description: 'Semantic search query'
})),
category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
tags: Type.Optional(Type.Array(Type.String(), {
description: 'Filter by tags (AND logic)'
})),
minConfidence: Type.Optional(Type.Number({
minimum: 0,
maximum: 1,
description: 'Minimum confidence threshold (default: 0.5)'
})),
limit: Type.Optional(Type.Number({
maximum: 50,
description: 'Max results to return (default: 10)'
})),
}),
async execute(
_toolCallId: string,
params: {
query?: string;
category?: MemoryCategory;
tags?: string[];
minConfidence?: number;
limit?: number;
}
) {
const results = await store.search({
query: params.query,
category: params.category,
tags: params.tags,
minConfidence: params.minConfidence ?? 0.5,
limit: params.limit ?? 10,
excludeDecayed: true,
});
// Update last accessed
store.touchMany(results.map(r => r.memory.id));
if (results.length === 0) {
return {
content: [{ type: 'text' as const, text: 'No relevant memories found.' }],
details: { count: 0 },
};
}
const text = results
.map((r, i) =>
`${i + 1}. [${r.memory.category}] ${r.memory.content} (${(r.score * 100).toFixed(0)}% match, ${(r.memory.confidence * 100).toFixed(0)}% confident)`
)
.join('\n');
return {
content: [{
type: 'text' as const,
text: `Found ${results.length} memories:\n\n${text}`
}],
details: {
count: results.length,
memories: results.map(r => ({
id: r.memory.id,
content: r.memory.content,
category: r.memory.category,
confidence: r.memory.confidence,
importance: r.memory.importance,
score: r.score,
tags: r.memory.tags,
})),
},
};
},
},
// ═══════════════════════════════════════════════════════════════════════
// SUMMARIZE - Topic summary
// ═══════════════════════════════════════════════════════════════════════
memory_summarize: {
name: 'memory_summarize',
label: 'Memory Summarize',
description: `Get a summary of memories related to a topic.
Use when:
- Starting a conversation and want general context
- Topic is broad ("what do I know about user's work")
- Too many memories would be retrieved with search`,
parameters: Type.Object({
topic: Type.String({
description: 'Topic to summarize memories about'
}),
maxMemories: Type.Optional(Type.Number({
description: 'Max memories to include in summary (default: 20)'
})),
}),
async execute(
_toolCallId: string,
params: {
topic: string;
maxMemories?: number;
}
) {
const results = await store.search({
query: params.topic,
limit: params.maxMemories ?? 20,
excludeDecayed: true,
});
if (results.length === 0) {
return {
content: [{
type: 'text' as const,
text: `No memories found about "${params.topic}".`
}],
details: { memoryCount: 0 },
};
}
// Group by category
const byCategory = new Map<string, string[]>();
for (const r of results) {
const cat = r.memory.category;
if (!byCategory.has(cat)) byCategory.set(cat, []);
byCategory.get(cat)!.push(r.memory.content);
}
// Format summary
const sections = Array.from(byCategory.entries())
.map(([cat, items]) => `**${cat}**:\n${items.map(i => `- ${i}`).join('\n')}`)
.join('\n\n');
return {
content: [{
type: 'text' as const,
text: `Summary of "${params.topic}" (${results.length} memories):\n\n${sections}`
}],
details: {
memoryCount: results.length,
categories: Object.fromEntries(byCategory),
},
};
},
},
// ═══════════════════════════════════════════════════════════════════════
// LIST - Browse all memories
// ═══════════════════════════════════════════════════════════════════════
memory_list: {
name: 'memory_list',
label: 'Memory List',
description: `List all memories, optionally filtered. Use for browsing/auditing, not semantic search.`,
parameters: Type.Object({
category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
sortBy: Type.Optional(stringEnum([
'createdAt', 'updatedAt', 'importance', 'confidence', 'lastAccessedAt'
] as const)),
limit: Type.Optional(Type.Number({
description: 'Max results (default: 20)'
})),
offset: Type.Optional(Type.Number({
description: 'Skip first N results (for pagination)'
})),
}),
async execute(
_toolCallId: string,
params: {
category?: MemoryCategory;
sortBy?: 'createdAt' | 'updatedAt' | 'importance' | 'confidence' | 'lastAccessedAt';
limit?: number;
offset?: number;
}
) {
const results = store.list({
category: params.category,
sortBy: params.sortBy ?? 'createdAt',
sortOrder: 'desc',
limit: params.limit ?? 20,
offset: params.offset ?? 0,
});
if (results.items.length === 0) {
return {
content: [{ type: 'text' as const, text: 'No memories found.' }],
details: { total: 0, count: 0 },
};
}
const text = results.items
.map((m, i) =>
`${i + 1}. [${m.category}] ${m.content.slice(0, 60)}${m.content.length > 60 ? '...' : ''} (${(m.confidence * 100).toFixed(0)}%)`
)
.join('\n');
return {
content: [{
type: 'text' as const,
text: `Showing ${results.items.length} of ${results.total} memories:\n\n${text}`
}],
details: {
total: results.total,
count: results.items.length,
memories: results.items.map(m => ({
id: m.id,
content: m.content,
category: m.category,
confidence: m.confidence,
importance: m.importance,
createdAt: m.createdAt,
})),
},
};
},
},
};
}
export type MemoryTools = ReturnType<typeof createMemoryTools>;

View File

@@ -0,0 +1,108 @@
/**
* Memory-as-Tools Type Definitions
*
* Core types for the agent-controlled memory system with
* confidence scoring, decay, and semantic search.
*/
export const MEMORY_CATEGORIES = [
'fact', // "User's dog is named Rex"
'preference', // "User prefers dark mode"
'event', // "User has dentist appointment Tuesday"
'relationship', // "User's sister is named Sarah"
'context', // "User is working on a React project"
'instruction', // "Always respond in Spanish"
'decision', // "We decided to use PostgreSQL"
'entity', // Contact info, phone numbers, emails
] as const;
export type MemoryCategory = typeof MEMORY_CATEGORIES[number];
export interface Memory {
id: string;
content: string;
category: MemoryCategory;
confidence: number; // 0.0 - 1.0: how sure are we this is accurate
importance: number; // 0.0 - 1.0: how important is this
// Temporal
createdAt: number; // Unix timestamp ms
updatedAt: number; // Unix timestamp ms
lastAccessedAt: number; // Unix timestamp ms
decayDays: number | null; // null = permanent
// Provenance
sourceChannel?: string; // 'whatsapp' | 'telegram' | 'discord' | etc
sourceMessageId?: string; // for traceability
// Relations
tags: string[];
supersedes?: string; // id of memory this updates/replaces
// Soft delete
deletedAt?: number;
deleteReason?: string;
}
export interface MemorySearchResult {
memory: Memory;
score: number; // Similarity score 0-1
}
export interface CreateMemoryInput {
content: string;
category: MemoryCategory;
confidence?: number;
importance?: number;
decayDays?: number | null;
tags?: string[];
sourceChannel?: string;
sourceMessageId?: string;
}
export interface UpdateMemoryInput {
content?: string;
confidence?: number;
importance?: number;
decayDays?: number | null;
tags?: string[];
}
export interface SearchOptions {
query?: string;
category?: MemoryCategory;
tags?: string[];
minConfidence?: number;
minImportance?: number;
limit?: number;
excludeDecayed?: boolean;
includeDeleted?: boolean;
}
export interface ListOptions {
category?: MemoryCategory;
sortBy?: 'createdAt' | 'updatedAt' | 'importance' | 'confidence' | 'lastAccessedAt';
sortOrder?: 'asc' | 'desc';
limit?: number;
offset?: number;
}
export interface PluginConfig {
embedding: {
apiKey: string;
model?: string;
};
dbPath?: string;
autoInjectInstructions?: boolean;
decayCheckInterval?: number;
}
// Vector dimensions for different embedding models
export const VECTOR_DIMS: Record<string, number> = {
'text-embedding-3-small': 1536,
'text-embedding-3-large': 3072,
};
export function vectorDimsForModel(model: string): number {
return VECTOR_DIMS[model] ?? 1536;
}