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,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "memory-tools",
"installedVersion": "1.1.1",
"installedAt": 1770940086445
}

View File

@@ -0,0 +1,37 @@
# CLAUDE.md
Project context for Claude Code.
## Project
**memory-tools** - Agent-controlled memory plugin for OpenClaw/ClawHub.
- **Repo**: `Purple-Horizons/memory-tools`
- **ClawHub slug**: `memory-tools`
- **Owner**: gianni-dalerta
## Architecture
- **SQLite via sql.js (WASM)** - No native compilation, works on any Node version
- **LanceDB** - Vector storage for semantic search
- **Hybrid storage** - Metadata in SQLite, embeddings in LanceDB
## Key Decisions
- Switched from `better-sqlite3` to `sql.js` (v1.1.0) to eliminate `NODE_MODULE_VERSION` mismatch errors when users have different Node.js versions
- Database initialization is async (WASM loading), so sync methods require `await store.init()` first
## Publishing to ClawHub
```bash
clawhub publish . --slug memory-tools --version X.Y.Z --changelog "description"
```
Always use `--slug memory-tools` to update the correct skill.
## Build & Test
```bash
npm run build # TypeScript compile
npm test # Run vitest
```

View File

@@ -0,0 +1,290 @@
# OpenClaw Memory Tools
Agent-controlled memory plugin for OpenClaw with confidence scoring, decay, and semantic search.
## Why Memory-as-Tools?
Traditional AI memory systems auto-capture everything, flooding context with irrelevant information. **Memory-as-Tools** follows the [AgeMem](https://arxiv.org/abs/2409.02634) approach: the agent decides **when** to store and retrieve memories.
```
Traditional: Agent → always retrieves → context flooded
Memory-as-Tools: Agent → decides IF/WHAT to remember → uses tools explicitly
```
## Features
- **6 Memory Tools**: `memory_store`, `memory_update`, `memory_forget`, `memory_search`, `memory_summarize`, `memory_list`
- **Confidence Scoring**: Track how certain you are about each memory (1.0 = explicit, 0.5 = inferred)
- **Importance Scoring**: Prioritize critical instructions over nice-to-know facts
- **Decay/Expiration**: Temporal memories (events) automatically become stale
- **Semantic Search**: Vector-based similarity search via LanceDB
- **Hybrid Storage**: SQLite (via WASM) for metadata + LanceDB for vectors
- **Zero Native Dependencies**: Uses sql.js (WASM) - no C++ compilation, works on any Node version
- **Standing Instructions**: Auto-inject category="instruction" memories at conversation start
## Installation
### Quick Install
```bash
# Clone to OpenClaw extensions directory
git clone https://github.com/purple-horizons/openclaw-memory-tools.git ~/.openclaw/extensions/memory-tools
cd ~/.openclaw/extensions/memory-tools
# Install dependencies and build
pnpm install && pnpm build
```
### Configuration
Add to `~/.openclaw/openclaw.json`:
```json
{
"plugins": {
"slots": {
"memory": "memory-tools"
},
"entries": {
"memory-tools": {
"enabled": true,
"config": {
"embedding": {}
}
}
}
},
"tools": {
"alsoAllow": ["group:plugins"]
}
}
```
### OpenAI API Key
The plugin needs an OpenAI API key for embeddings. Three options:
**Option 1: Environment variable (recommended)**
```bash
# Add to ~/.zshrc or ~/.bashrc
export OPENAI_API_KEY="sk-proj-..."
```
**Option 2: Reference env var in config**
```json
{
"embedding": {
"apiKey": "${OPENAI_API_KEY}"
}
}
```
**Option 3: Direct in config (not recommended)**
```json
{
"embedding": {
"apiKey": "sk-proj-..."
}
}
```
### Verify Installation
```bash
# Restart gateway
openclaw gateway stop && openclaw gateway run
# Check plugin loaded
openclaw plugins list
# Test CLI
openclaw memory-tools stats
```
## Memory Categories
| Category | Use For | Example |
|----------|---------|---------|
| `fact` | Static information | "User's dog is named Rex" |
| `preference` | Likes/dislikes | "User prefers dark mode" |
| `event` | Temporal things | "Dentist appointment Tuesday 3pm" |
| `relationship` | People connections | "User's sister is Sarah" |
| `context` | Current work | "Working on React project" |
| `instruction` | Standing orders | "Always respond in Spanish" |
| `decision` | Choices made | "We decided to use PostgreSQL" |
| `entity` | Contact info | "User's email is x@y.com" |
## Tool Reference
### memory_store
Store a new memory.
```typescript
memory_store({
content: "User prefers bullet points",
category: "preference",
confidence: 0.9, // How sure (0-1)
importance: 0.7, // How critical (0-1)
decayDays: null, // null = permanent
tags: ["formatting"]
})
```
### memory_update
Update an existing memory.
```typescript
memory_update({
id: "abc-123",
content: "User prefers numbered lists", // Optional
confidence: 0.95 // Optional
})
```
### memory_forget
Delete a memory.
```typescript
memory_forget({
id: "abc-123", // If known
query: "bullet points", // Or search
reason: "User corrected"
})
```
### memory_search
Semantic search.
```typescript
memory_search({
query: "formatting preferences",
category: "preference", // Optional filter
minConfidence: 0.7, // Optional filter
limit: 10
})
```
### memory_summarize
Get topic summary.
```typescript
memory_summarize({
topic: "user's work",
maxMemories: 20
})
```
### memory_list
Browse all memories.
```typescript
memory_list({
category: "instruction",
sortBy: "importance",
limit: 20
})
```
## CLI Commands
```bash
# Show statistics
openclaw memory-tools stats
# List memories
openclaw memory-tools list --category preference
# Search memories
openclaw memory-tools search "dark mode"
# Export all memories as JSON
openclaw memory-tools export
```
## Architecture
```
┌─────────────────────────────────────────────────────────┐
│ OpenClaw Agent │
│ │
│ Agent decides: "This is worth remembering" │
│ ↓ │
│ Calls: memory_store(...) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Memory Tools │
├─────────────────────────────────────────────────────────┤
│ store │ update │ forget │ search │ summarize │ list │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Storage Layer │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ SQLite/WASM │ │ LanceDB │ │
│ │ (metadata) │◄──►│ (vectors) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
```
## Development
```bash
# Install dependencies
pnpm install
# Run tests
pnpm test
# Run tests with coverage
pnpm test:coverage
# Type check
pnpm typecheck
# Build
pnpm build
```
## Comparison with Other Memory Systems
| Feature | [memU](https://github.com/NevaMind-AI/memU) | [claude-mem](https://github.com/thedotmack/claude-mem) | **memory-tools** |
|---------|------|------------|--------------|
| **Architecture** | 3-tier hierarchical (Resource → Item → Category) | Hook-based observer with lifecycle events | Tool-based agent control |
| **Storage Trigger** | Automatic extraction during background processing | Lifecycle hooks (SessionStart, PostToolUse, etc.) | Agent explicitly decides when to store |
| **Conflict Handling** | None - relies on proactive pattern detection | None - auto-capture model | Auto-supersede + explicit forget |
| **Context Injection** | Proactive - predicts and pre-loads context | Progressive disclosure (3-layer filtering) | On-demand via memory_search |
| **Token Efficiency** | Compression via fact extraction | ~10x savings via progressive disclosure | Semantic search with configurable limits |
| **Auditability** | Background processing | Hook-based capture | Full SQLite inspection, explicit tool calls |
| **User Corrections** | Accumulates conflicting facts | Accumulates conflicting facts | Replaces old with new automatically |
| **Best For** | 24/7 agents with predictable patterns | Automatic session continuity | Personal assistants with ongoing relationships |
### Design Philosophy
Different memory systems optimize for different things:
- **Automatic systems** (memU, claude-mem) minimize agent cognitive load by extracting memories in the background. Trade-off: less control over what's captured, conflicts accumulate.
- **Agent-controlled systems** (memory-tools) put the agent in charge of what matters. Trade-off: requires active management, but memories are deliberate choices.
For agents that maintain ongoing relationships with users—where someone might say "no, my favorite color is purple, not blue"—explicit conflict handling prevents contradictory memories from accumulating. Every memory has a clear provenance: the agent decided it was worth remembering, and corrections replace rather than compete with old information.
The hybrid SQLite (WASM) + LanceDB storage means you can always `sqlite3 ~/.openclaw/memory/tools/memory.db` to inspect exactly what your agent knows and why.
## References
- [AgeMem Paper](https://arxiv.org/abs/2409.02634) - Memory operations as first-class tools
- [memU](https://github.com/NevaMind-AI/memU) - Hierarchical memory with proactive context
- [claude-mem](https://github.com/thedotmack/claude-mem) - Hook-based automatic memory
- [Mem0](https://github.com/mem0ai/mem0) - AI memory layer
- [OpenClaw](https://github.com/openclaw/openclaw) - Personal AI assistant
## License
MIT - [Purple Horizons](https://github.com/Purple-Horizons)

View File

@@ -0,0 +1,168 @@
---
name: memory-tools
description: Agent-controlled memory plugin for OpenClaw with confidence scoring, decay, and semantic search. The agent decides WHEN to store/retrieve memories — no auto-capture noise.
homepage: https://github.com/Purple-Horizons/openclaw-memory-tools
metadata:
openclaw:
emoji: 🧠
kind: plugin
requires:
env:
- OPENAI_API_KEY
---
# Memory Tools
Agent-controlled persistent memory for OpenClaw.
## Why Memory-as-Tools?
Traditional memory systems auto-capture everything, flooding context with irrelevant information. Memory Tools follows the [AgeMem](https://arxiv.org/abs/2409.02634) approach: **the agent decides** when to store and retrieve memories.
## Features
- **6 Memory Tools**: `memory_store`, `memory_update`, `memory_forget`, `memory_search`, `memory_summarize`, `memory_list`
- **Confidence Scoring**: Track how certain you are (1.0 = explicit, 0.5 = inferred)
- **Importance Scoring**: Prioritize critical instructions over nice-to-know facts
- **Decay/Expiration**: Temporal memories automatically become stale
- **Semantic Search**: Vector-based similarity via LanceDB
- **Hybrid Storage**: SQLite (debuggable) + LanceDB (fast vectors)
- **Conflict Resolution**: New info auto-supersedes old (no contradictions)
## Installation
### Step 1: Install from ClawHub
```bash
clawhub install memory-tools
```
### Step 2: Build the plugin
```bash
cd skills/memory-tools
npm install
npm run build
```
### Step 3: Activate the plugin
```bash
openclaw plugins install --link .
openclaw plugins enable memory-tools
```
### Step 4: Restart the gateway
**Standard (systemd):**
```bash
openclaw gateway restart
```
**Docker (no systemd):**
```bash
# Kill existing gateway
pkill -f openclaw-gateway
# Start in background
nohup openclaw gateway --port 18789 --verbose > /tmp/openclaw-gateway.log 2>&1 &
```
### Requirements
- `OPENAI_API_KEY` environment variable (for embeddings)
## Memory Categories
| Category | Use For | Example |
|----------|---------|---------|
| fact | Static information | "User's dog is named Rex" |
| preference | Likes/dislikes | "User prefers dark mode" |
| event | Temporal things | "Dentist Tuesday 3pm" |
| relationship | People connections | "Sarah is user's wife" |
| instruction | Standing orders | "Always respond in Spanish" |
| decision | Choices made | "We decided to use PostgreSQL" |
| context | Situational info | "User is job hunting" |
| entity | Named things | "Project Apollo is their startup" |
## Tool Reference
### memory_store
```
memory_store({
content: "User prefers bullet points",
category: "preference",
confidence: 0.9,
importance: 0.7,
tags: ["formatting", "communication"]
})
```
### memory_search
```
memory_search({
query: "formatting preferences",
category: "preference",
limit: 10
})
```
### memory_update
```
memory_update({
id: "abc123",
content: "User now prefers numbered lists",
confidence: 1.0
})
```
### memory_forget
```
memory_forget({
query: "bullet points",
reason: "User corrected preference"
})
```
### memory_summarize
```
memory_summarize({
topic: "user's work projects",
maxMemories: 20
})
```
### memory_list
```
memory_list({
category: "instruction",
sortBy: "importance",
limit: 20
})
```
## Debugging
Inspect what your agent knows:
```bash
sqlite3 ~/.openclaw/memory/tools/memory.db "SELECT id, category, content FROM memories"
```
Export all memories:
```bash
openclaw memory-tools export > memories.json
```
## Troubleshooting
**"Database connection not open" error:**
- Hard restart the gateway: `pkill -f openclaw-gateway`
- Check permissions: `chown -R $(whoami) ~/.openclaw/memory/tools`
**Plugin not loading:**
- Verify build: `ls skills/memory-tools/dist/index.js`
- Check doctor: `openclaw doctor --non-interactive`
## License
MIT — [Purple Horizons](https://github.com/Purple-Horizons)

View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn7ajybsnj998tbfcgvrc7731s803brp",
"slug": "memory-tools",
"version": "1.1.1",
"publishedAt": 1770662268370
}

View File

@@ -0,0 +1,65 @@
{
"id": "memory-tools",
"kind": "memory",
"name": "Memory Tools",
"description": "Agent-controlled memory with confidence scoring, decay, and semantic search. The agent decides WHEN to store/retrieve memories.",
"version": "1.0.0",
"uiHints": {
"embedding.apiKey": {
"label": "OpenAI API Key",
"sensitive": true,
"placeholder": "sk-proj-...",
"help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})"
},
"embedding.model": {
"label": "Embedding Model",
"placeholder": "text-embedding-3-small",
"help": "OpenAI embedding model to use"
},
"dbPath": {
"label": "Database Path",
"placeholder": "~/.clawdbot/memory/tools",
"advanced": true
},
"autoInjectInstructions": {
"label": "Auto-Inject Instructions",
"help": "Inject standing instructions (category='instruction') at conversation start"
},
"decayCheckInterval": {
"label": "Decay Check Interval (hours)",
"help": "How often to check for decayed memories (0 = disabled)"
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"embedding": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string",
"description": "OpenAI API key. Supports ${OPENAI_API_KEY} syntax or falls back to OPENAI_API_KEY env var."
},
"model": {
"type": "string",
"enum": ["text-embedding-3-small", "text-embedding-3-large"]
}
}
},
"dbPath": {
"type": "string"
},
"autoInjectInstructions": {
"type": "boolean",
"default": true
},
"decayCheckInterval": {
"type": "number",
"default": 24
}
},
"required": ["embedding"]
}
}

View File

@@ -0,0 +1,65 @@
{
"id": "memory-tools",
"kind": "memory",
"name": "Memory Tools",
"description": "Agent-controlled memory with confidence scoring, decay, and semantic search. The agent decides WHEN to store/retrieve memories.",
"version": "1.0.0",
"uiHints": {
"embedding.apiKey": {
"label": "OpenAI API Key",
"sensitive": true,
"placeholder": "sk-proj-...",
"help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})"
},
"embedding.model": {
"label": "Embedding Model",
"placeholder": "text-embedding-3-small",
"help": "OpenAI embedding model to use"
},
"dbPath": {
"label": "Database Path",
"placeholder": "~/.openclaw/memory/tools",
"advanced": true
},
"autoInjectInstructions": {
"label": "Auto-Inject Instructions",
"help": "Inject standing instructions (category='instruction') at conversation start"
},
"decayCheckInterval": {
"label": "Decay Check Interval (hours)",
"help": "How often to check for decayed memories (0 = disabled)"
}
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"embedding": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string",
"description": "OpenAI API key. Supports ${OPENAI_API_KEY} syntax or falls back to OPENAI_API_KEY env var."
},
"model": {
"type": "string",
"enum": ["text-embedding-3-small", "text-embedding-3-large"]
}
}
},
"dbPath": {
"type": "string"
},
"autoInjectInstructions": {
"type": "boolean",
"default": true
},
"decayCheckInterval": {
"type": "number",
"default": 24
}
},
"required": []
}
}

2705
skills/memory-tools/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,59 @@
{
"name": "memory-tools",
"version": "1.1.1",
"description": "Memory-as-Tools plugin for OpenClaw - Agent-controlled persistent memory with confidence scoring and decay",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsc",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "oxlint",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@lancedb/lancedb": "^0.23.0",
"@sinclair/typebox": "0.34.48",
"openai": "^6.17.0",
"sql.js": "^1.13.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/sql.js": "^1.4.9",
"@vitest/coverage-v8": "^2.1.0",
"typescript": "^5.7.0",
"vitest": "^2.1.0"
},
"peerDependencies": {
"openclaw": ">=2026.0.0"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./dist/index.js"
]
},
"files": [
"dist",
"README.md"
],
"keywords": [
"openclaw",
"memory",
"ai-agent",
"plugin",
"vector-search"
],
"author": "Purple Horizons",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/Purple-Horizons/memory-tools.git"
}
}

7892
skills/memory-tools/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

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;
}

View File

@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['src/**/*.test.ts'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
exclude: [
'node_modules',
'dist',
'**/*.test.ts',
'vitest.config.ts',
],
},
testTimeout: 30000,
},
});