Rename to hkt.sh
This commit is contained in:
7
skills/memory-tools/.clawhub/origin.json
Normal file
7
skills/memory-tools/.clawhub/origin.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"registry": "https://clawhub.ai",
|
||||
"slug": "memory-tools",
|
||||
"installedVersion": "1.1.1",
|
||||
"installedAt": 1770940086445
|
||||
}
|
||||
37
skills/memory-tools/CLAUDE.md
Normal file
37
skills/memory-tools/CLAUDE.md
Normal 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
|
||||
```
|
||||
290
skills/memory-tools/README.md
Normal file
290
skills/memory-tools/README.md
Normal 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)
|
||||
168
skills/memory-tools/SKILL.md
Normal file
168
skills/memory-tools/SKILL.md
Normal 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)
|
||||
6
skills/memory-tools/_meta.json
Normal file
6
skills/memory-tools/_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7ajybsnj998tbfcgvrc7731s803brp",
|
||||
"slug": "memory-tools",
|
||||
"version": "1.1.1",
|
||||
"publishedAt": 1770662268370
|
||||
}
|
||||
65
skills/memory-tools/clawdbot.plugin.json
Normal file
65
skills/memory-tools/clawdbot.plugin.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
65
skills/memory-tools/openclaw.plugin.json
Normal file
65
skills/memory-tools/openclaw.plugin.json
Normal 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
2705
skills/memory-tools/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
skills/memory-tools/package.json
Normal file
59
skills/memory-tools/package.json
Normal 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
7892
skills/memory-tools/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
70
skills/memory-tools/src/config.ts
Normal file
70
skills/memory-tools/src/config.ts
Normal 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 };
|
||||
38
skills/memory-tools/src/embeddings.ts
Normal file
38
skills/memory-tools/src/embeddings.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
267
skills/memory-tools/src/index.ts
Normal file
267
skills/memory-tools/src/index.ts
Normal 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';
|
||||
48
skills/memory-tools/src/plugin-types.ts
Normal file
48
skills/memory-tools/src/plugin-types.ts
Normal 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;
|
||||
}
|
||||
422
skills/memory-tools/src/store.test.ts
Normal file
422
skills/memory-tools/src/store.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
614
skills/memory-tools/src/store.ts
Normal file
614
skills/memory-tools/src/store.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
415
skills/memory-tools/src/tools.test.ts
Normal file
415
skills/memory-tools/src/tools.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
574
skills/memory-tools/src/tools.ts
Normal file
574
skills/memory-tools/src/tools.ts
Normal 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>;
|
||||
108
skills/memory-tools/src/types.ts
Normal file
108
skills/memory-tools/src/types.ts
Normal 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;
|
||||
}
|
||||
22
skills/memory-tools/tsconfig.json
Normal file
22
skills/memory-tools/tsconfig.json
Normal 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"]
|
||||
}
|
||||
20
skills/memory-tools/vitest.config.ts
Normal file
20
skills/memory-tools/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user