Rename to hkt.sh

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

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "camera",
"installedVersion": "1.0.0",
"installedAt": 1770940088151
}

43
skills/camera/SKILL.md Normal file
View File

@@ -0,0 +1,43 @@
---
name: camera
description: Capture photos from MacBook webcams. Use when user asks to take a photo, picture, snapshot, or see them. Two cameras available - Brio (front-facing on monitor) and FaceTime (side angle from MacBook).
---
# Camera Skill
## Available Cameras
| Camera | Index | Position | Best For |
|--------|-------|----------|----------|
| **Brio 100** | 0 | On external monitor, facing user directly | Front view, face shots |
| **FaceTime HD** | 1 | MacBook on right side, angled toward user | Side/profile view |
## Capture Commands
Use `-loglevel error` to suppress ffmpeg spam. Always warm up for 5s (camera needs exposure adjustment).
### Brio (front view)
```bash
ffmpeg -loglevel error -f avfoundation -framerate 30 -i "0" -t 5 -y /tmp/brio_warmup.mp4 && \
ffmpeg -loglevel error -sseof -0.5 -i /tmp/brio_warmup.mp4 -frames:v 1 -update 1 -y /tmp/brio.jpg
```
### FaceTime (side view)
**Must use `-pixel_format nv12`** to avoid buffer errors.
```bash
ffmpeg -loglevel error -f avfoundation -pixel_format nv12 -framerate 30 -i "1" -t 5 -y /tmp/facetime_warmup.mp4 && \
ffmpeg -loglevel error -sseof -0.5 -i /tmp/facetime_warmup.mp4 -frames:v 1 -update 1 -y /tmp/facetime.jpg
```
### Both cameras (parallel)
Run both commands simultaneously for multi-angle shots.
## Output
- Photos saved to `/tmp/brio.jpg` and `/tmp/facetime.jpg`
- Warmup videos in `/tmp/*_warmup.mp4` (can be deleted)
- Photos are ~80-100KB each
## Gotchas
- Close Photo Booth or other camera apps first (can conflict)
- FaceTime camera REQUIRES `-pixel_format nv12` or it fails with buffer errors
- 5s warmup is necessary for proper exposure

6
skills/camera/_meta.json Normal file
View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn76tn13s33tbyv3x1p4thbmv5809t0m",
"slug": "camera",
"version": "1.0.0",
"publishedAt": 1770796421019
}

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "camsnap",
"installedVersion": "1.0.0",
"installedAt": 1770940052820
}

25
skills/camsnap/SKILL.md Normal file
View File

@@ -0,0 +1,25 @@
---
name: camsnap
description: Capture frames or clips from RTSP/ONVIF cameras.
homepage: https://camsnap.ai
metadata: {"clawdbot":{"emoji":"📸","requires":{"bins":["camsnap"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/camsnap","bins":["camsnap"],"label":"Install camsnap (brew)"}]}}
---
# camsnap
Use `camsnap` to grab snapshots, clips, or motion events from configured cameras.
Setup
- Config file: `~/.config/camsnap/config.yaml`
- Add camera: `camsnap add --name kitchen --host 192.168.0.10 --user user --pass pass`
Common commands
- Discover: `camsnap discover --info`
- Snapshot: `camsnap snap kitchen --out shot.jpg`
- Clip: `camsnap clip kitchen --dur 5s --out clip.mp4`
- Motion watch: `camsnap watch kitchen --threshold 0.2 --action '...'`
- Doctor: `camsnap doctor --probe`
Notes
- Requires `ffmpeg` on PATH.
- Prefer a short test capture before longer clips.

View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
"slug": "camsnap",
"version": "1.0.0",
"publishedAt": 1767545306244
}

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "gif",
"installedVersion": "1.0.0",
"installedAt": 1770940097377
}

83
skills/gif/SKILL.md Normal file
View File

@@ -0,0 +1,83 @@
---
name: GIF
description: Find, search, and create GIFs with proper optimization and accessibility.
metadata: {"clawdbot":{"emoji":"🎞️","requires":{},"os":["linux","darwin","win32"]}}
---
## Where to Find GIFs
| Site | Best for | API |
|------|----------|-----|
| **Giphy** | General, trending | Yes |
| **Tenor** | Messaging apps (WhatsApp, Slack, Discord) | Yes |
| **Imgur** | Viral/community content | Yes |
| **Reddit r/gifs** | Niche, unique | No |
| **Reaction GIFs** | Emotions | No |
## Giphy API
```bash
# Search
curl "https://api.giphy.com/v1/gifs/search?api_key=KEY&q=thumbs+up&limit=10"
# Trending
curl "https://api.giphy.com/v1/gifs/trending?api_key=KEY&limit=10"
```
Response sizes: `original`, `downsized`, `fixed_width`, `preview`—use `downsized` for chat.
## Tenor API
```bash
curl "https://tenor.googleapis.com/v2/search?key=KEY&q=thumbs+up&limit=10"
```
Returns: `gif`, `mediumgif`, `tinygif`, `mp4`, `webm`—use `tinygif` or `mp4` for performance.
## Creating GIFs with FFmpeg
**Always use palettegen (without it, colors look washed out):**
```bash
ffmpeg -ss 0 -t 5 -i input.mp4 \
-filter_complex "fps=10,scale=480:-1:flags=lanczos,split[a][b];[a]palettegen[p];[b][p]paletteuse" \
output.gif
```
| Setting | Value | Why |
|---------|-------|-----|
| fps | 8-12 | Higher = much larger file |
| scale | 320-480 | 1080p GIFs are massive |
| lanczos | Always | Best scaling quality |
## Post-Optimization
```bash
gifsicle -O3 --lossy=80 --colors 128 input.gif -o output.gif
```
Reduces size 30-50% with minimal quality loss.
## Video Alternative
For web, use video instead of large GIFs (80-90% smaller):
```html
<video autoplay muted loop playsinline>
<source src="animation.webm" type="video/webm">
<source src="animation.mp4" type="video/mp4">
</video>
```
## Accessibility
- **WCAG 2.2.2:** Loops >5s need pause control
- **prefers-reduced-motion:** Show static image instead
- **Alt text:** Describe the action ("Cat jumping off table"), not "GIF"
- **Three flashes:** Nothing >3 flashes/second (seizure risk)
## Common Mistakes
- No `palettegen` in FFmpeg—colors look terrible
- FPS >15—file size explodes for no visual benefit
- No lazy loading on web—blocks page load
- Using huge GIF where video would work—10x larger

6
skills/gif/_meta.json Normal file
View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn73vp5rarc3b14rc7wjcw8f8580t5d1",
"slug": "gif",
"version": "1.0.0",
"publishedAt": 1770736313556
}

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "gifgrep",
"installedVersion": "1.0.1",
"installedAt": 1770940074801
}

47
skills/gifgrep/SKILL.md Normal file
View File

@@ -0,0 +1,47 @@
---
name: gifgrep
description: Search GIF providers with CLI/TUI, download results, and extract stills/sheets.
homepage: https://gifgrep.com
metadata: {"clawdbot":{"emoji":"🧲","requires":{"bins":["gifgrep"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/gifgrep","bins":["gifgrep"],"label":"Install gifgrep (brew)"},{"id":"go","kind":"go","module":"github.com/steipete/gifgrep/cmd/gifgrep@latest","bins":["gifgrep"],"label":"Install gifgrep (go)"}]}}
---
# gifgrep
Use `gifgrep` to search GIF providers (Tenor/Giphy), browse in a TUI, download results, and extract stills or sheets.
GIF-Grab (gifgrep workflow)
- Search → preview → download → extract (still/sheet) for fast review and sharing.
Quick start
- `gifgrep cats --max 5`
- `gifgrep cats --format url | head -n 5`
- `gifgrep search --json cats | jq '.[0].url'`
- `gifgrep tui "office handshake"`
- `gifgrep cats --download --max 1 --format url`
TUI + previews
- TUI: `gifgrep tui "query"`
- CLI still previews: `--thumbs` (Kitty/Ghostty only; still frame)
Download + reveal
- `--download` saves to `~/Downloads`
- `--reveal` shows the last download in Finder
Stills + sheets
- `gifgrep still ./clip.gif --at 1.5s -o still.png`
- `gifgrep sheet ./clip.gif --frames 9 --cols 3 -o sheet.png`
- Sheets = single PNG grid of sampled frames (great for quick review, docs, PRs, chat).
- Tune: `--frames` (count), `--cols` (grid width), `--padding` (spacing).
Providers
- `--source auto|tenor|giphy`
- `GIPHY_API_KEY` required for `--source giphy`
- `TENOR_API_KEY` optional (Tenor demo key used if unset)
Output
- `--json` prints an array of results (`id`, `title`, `url`, `preview_url`, `tags`, `width`, `height`)
- `--format` for pipe-friendly fields (e.g., `url`)
Environment tweaks
- `GIFGREP_SOFTWARE_ANIM=1` to force software animation
- `GIFGREP_CELL_ASPECT=0.5` to tweak preview geometry

View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
"slug": "gifgrep",
"version": "1.0.1",
"publishedAt": 1767545342229
}

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "imsg",
"installedVersion": "1.0.0",
"installedAt": 1770940059695
}

25
skills/imsg/SKILL.md Normal file
View File

@@ -0,0 +1,25 @@
---
name: imsg
description: iMessage/SMS CLI for listing chats, history, watch, and sending.
homepage: https://imsg.to
metadata: {"clawdbot":{"emoji":"📨","os":["darwin"],"requires":{"bins":["imsg"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/imsg","bins":["imsg"],"label":"Install imsg (brew)"}]}}
---
# imsg
Use `imsg` to read and send Messages.app iMessage/SMS on macOS.
Requirements
- Messages.app signed in
- Full Disk Access for your terminal
- Automation permission to control Messages.app (for sending)
Common commands
- List chats: `imsg chats --limit 10 --json`
- History: `imsg history --chat-id 1 --limit 20 --attachments --json`
- Watch: `imsg watch --chat-id 1 --attachments`
- Send: `imsg send --to "+14155551212" --text "hi" --file /path/pic.jpg`
Notes
- `--service imessage|sms|auto` controls delivery.
- Confirm recipient + message before sending.

6
skills/imsg/_meta.json Normal file
View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
"slug": "imsg",
"version": "1.0.0",
"publishedAt": 1767545348833
}

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "macbook-optimizer",
"installedVersion": "1.0.1",
"installedAt": 1770940095707
}

View File

@@ -0,0 +1,59 @@
# MacBook Optimizer Skill
A comprehensive OpenClaw skill for MacBook system optimization, monitoring, and troubleshooting.
## Features
- **System Health Monitoring**: CPU, memory, disk, battery
- **Performance Analysis**: Identify slowdowns and bottlenecks
- **Overheating Detection**: Monitor temperature and find causes
- **Cleanup Tools**: Find large files and suggest cleanup
- **Process Management**: Monitor and manage resource usage
## Installation
This skill is available on [ClawHub](https://clawhub.com). Install with:
```bash
clawhub install macbook-optimizer
```
Or manually:
1. Copy this folder to your OpenClaw workspace skills directory:
```bash
cp -r macbook-optimizer ~/.openclaw/workspace/skills/
```
2. Restart OpenClaw or start a new session
## Usage
Ask your OpenClaw agent:
- "Check my MacBook system health"
- "What's my CPU temperature?"
- "Find what's slowing down my Mac"
- "What's using the most memory?"
- "Clean up disk space"
## Requirements
- macOS only
- Standard system tools (included with macOS)
## Publishing to ClawHub
To publish updates:
```bash
clawhub publish ./macbook-optimizer \
--slug macbook-optimizer \
--name "MacBook Optimizer" \
--version 1.0.0 \
--tags latest
```
## License
MIT

View File

@@ -0,0 +1,233 @@
---
name: macbook-optimizer
description: Complete MacBook optimization suite: monitoring, troubleshooting, cleanup, and performance tuning. Works on all Macs (Intel & Apple Silicon). No extra tools required.
homepage: https://github.com/T4btc/macbook-optimizer
metadata:
{
"openclaw":
{
"emoji": "💻",
"os": ["darwin"],
"requires": { "bins": ["system_profiler", "top", "ps", "df", "du"] },
},
}
---
# 💻 MacBook Optimizer
_Complete MacBook health & performance suite - No installation required_
A comprehensive, user-friendly skill for monitoring, optimizing, and troubleshooting MacBook performance. Works on **all Macs** (Intel & Apple Silicon) using built-in macOS tools. Unlike specialized tools, this provides actionable recommendations and automated fixes.
## Why This Skill is Better
**No installation required** - Uses built-in macOS tools
**Works on all Macs** - Intel & Apple Silicon
**Actionable recommendations** - Not just metrics, but solutions
**Automated fixes** - Can clean up and optimize automatically
**User-friendly** - Plain language, not technical jargon
**Complete suite** - Monitoring + troubleshooting + optimization
**GUI-first** - Opens visual tools automatically for non-technical users
**Visual reports** - Charts, graphs, and emoji indicators for easy understanding
## Capabilities
### 🔍 System Monitoring
- **CPU Analysis**: Real-time CPU usage, temperature (via `powermetrics`), load averages, per-process breakdown
- **Memory Health**: RAM usage, memory pressure, swap usage, identify memory leaks
- **Disk Intelligence**: Space analysis, find large files/folders, duplicate detection, cache locations
- **Battery Diagnostics**: Health percentage, cycle count, capacity, charging status, power consumption
- **Thermal Monitoring**: System temperature, thermal state, identify overheating causes
- **Network Activity**: Bandwidth usage, active connections, identify bandwidth hogs
### ⚡ Optimization Tools
- **Smart Cleanup**: Automatically find and remove caches, logs, temp files, downloads, duplicates
- **Process Management**: Identify resource hogs, suggest optimizations, safe process termination
- **Startup Optimization**: Manage login items, background apps, reduce boot time
- **Storage Optimization**: Find large files, suggest deletions, empty trash, clear caches
- **Performance Tuning**: System settings recommendations, disable unnecessary services
### 🛠 Troubleshooting
- **Slowdown Diagnosis**: Identify bottlenecks (CPU/memory/disk/network), root cause analysis
- **Overheating Solutions**: Find hot processes, suggest cooling strategies, thermal management
- **Memory Issues**: Detect leaks, suggest app restarts, memory optimization
- **Disk Problems**: Full disk analysis, permission issues, disk health checks
- **Battery Issues**: Health degradation, charging problems, power management
## Usage Examples
**Complete system check (with GUI):**
```
Run a full system health check, show me the results visually, and fix any issues
```
**Performance optimization (GUI mode):**
```
My MacBook is slow. Open Activity Monitor and show me what's using resources
```
**Overheating issue:**
```
My MacBook is overheating. Show me the hot processes in Activity Monitor
```
**Disk cleanup (visual):**
```
Show me my disk usage visually and clean up automatically
```
**Memory problems (GUI):**
```
My Mac is using too much memory. Open Activity Monitor and highlight the memory hogs
```
**Battery health (visual):**
```
Show me my battery health in System Settings and optimize power settings
```
**Startup optimization:**
```
What's slowing down my Mac startup? Show me login items in System Settings
```
**Find large files (visual):**
```
Find all files larger than 1GB, show them in Finder, and suggest what I can delete
```
**GUI-first requests:**
```
Show me everything in Activity Monitor
Open System Settings to battery settings
Show me disk usage in a visual way
```
## Advanced Commands Available
The agent intelligently uses these macOS tools:
**System Info:**
- `system_profiler` - Complete hardware/software information
- `sysctl` - System parameters and kernel settings
- `sw_vers` - macOS version information
**Process Monitoring:**
- `top` / `htop` - Real-time process monitoring
- `ps` - Process status and details
- `lsof` - List open files and network connections
- `launchctl list` - Background services and daemons
**Resource Monitoring:**
- `vm_stat` - Virtual memory statistics
- `iostat` - Disk I/O statistics
- `netstat` / `lsof -i` - Network connections
- `powermetrics` - CPU/GPU power and temperature (Apple Silicon)
- `pmset -g therm` - Thermal state (Intel Macs)
**Disk Management:**
- `df` - Disk space usage
- `du` - Directory size analysis
- `find` - Locate large files
- `mdutil` - Spotlight index management
**Power & Battery:**
- `pmset` - Power management settings
- `ioreg` - I/O registry (battery info)
- `system_profiler SPPowerDataType` - Battery details
**Cleanup:**
- `rm` - Safe file removal (with confirmation)
- `purge` - Memory purge
- Cache locations: `~/Library/Caches`, `/Library/Caches`, `/var/folders`
**GUI Tools (Visual Interface):**
- `open -a "Activity Monitor"` - Launch Activity Monitor (CPU, Memory, Energy, Disk, Network)
- `open -a "System Settings"` - Open System Settings (all system preferences)
- `open -a "System Settings" && open "x-apple.systempreferences:com.apple.preference.battery"` - Battery settings
- `open -a "System Settings" && open "x-apple.systempreferences:com.apple.preference.storage"` - Storage management
- `open -a "System Settings" && open "x-apple.systempreferences:com.apple.LoginItems-Settings.extension"` - Login items
- `open -a "Finder"` - Open Finder for file browsing
- `open ~/Library/Caches` - Open Caches folder in Finder
- `open ~/Downloads` - Open Downloads folder
- `open "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles"` - Privacy settings
- `open "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"` - Accessibility permissions
**Visual Reports:**
- Generate HTML reports with charts (CPU, Memory, Disk usage over time)
- Create visual summaries with emoji indicators (🟢 Good, 🟡 Warning, 🔴 Critical)
- Open relevant System Settings panels automatically based on findings
## 🎨 GUI-First Experience
**For users who prefer visual interfaces**, the agent can:
- 📊 **Open Activity Monitor** automatically when showing system stats
- ⚙️ **Navigate System Settings** to relevant panels (Battery, Storage, Privacy)
- 📁 **Open Finder** to specific folders (Caches, Downloads, Large files)
- 📈 **Generate visual reports** with charts and graphs (HTML format)
- 🎯 **Highlight issues** in GUI apps with clear indicators
- 🔍 **Show step-by-step** with screenshots or GUI navigation
**Example GUI Workflow:**
1. User: "My Mac is slow"
2. Agent opens Activity Monitor → highlights CPU/Memory hogs
3. Agent opens System Settings → shows relevant optimization settings
4. Agent provides visual summary with emoji status indicators
## Intelligent Automation
The agent can:
-**Automatically clean** safe caches and temp files (with user confirmation)
-**Suggest optimizations** based on system analysis
-**Provide step-by-step fixes** for common issues (with GUI navigation)
-**Monitor continuously** if requested (via cron jobs)
-**Generate visual reports** with charts, graphs, and actionable recommendations
-**Open GUI tools** automatically when showing system information
## Safety & Privacy
- 🔒 **Always asks before** deleting files or killing processes
- 🔒 **Protects system files** and critical processes
- 🔒 **Reviews before action** - shows what will be deleted
- 🔒 **No data collection** - everything runs locally
- 🔒 **Respects privacy** - never sends data externally
## Requirements
-**macOS only** (Intel & Apple Silicon)
-**No installation needed** - uses built-in tools
-**Optional**: `htop` for prettier process view (`brew install htop`)
-**Optional**: `mactop` for Apple Silicon detailed metrics (`brew install mactop`)
## How to Use GUI Tools
When the user asks for visual information or mentions they're not technical:
1. **Always open Activity Monitor** when showing CPU/Memory/Process info
2. **Navigate to relevant System Settings** panels automatically
3. **Open Finder** to specific folders when discussing files
4. **Generate visual summaries** with emoji indicators (🟢🟡🔴)
5. **Provide step-by-step GUI navigation** instructions
**GUI Navigation Commands:**
- CPU issues → Open Activity Monitor, sort by CPU
- Memory issues → Open Activity Monitor, sort by Memory
- Battery → Open System Settings → Battery
- Storage → Open System Settings → General → Storage
- Login items → Open System Settings → General → Login Items
- Large files → Open Finder, navigate to location, sort by size
## Comparison with Other Tools
| Feature | macbook-optimizer | mactop |
|---------|------------------|--------|
| Installation required | ❌ No | ✅ Yes (brew) |
| Works on Intel Macs | ✅ Yes | ❌ No (Apple Silicon only) |
| Actionable recommendations | ✅ Yes | ❌ No (metrics only) |
| Automated cleanup | ✅ Yes | ❌ No |
| Troubleshooting | ✅ Yes | ❌ No |
| User-friendly | ✅ Yes | ⚠️ Technical |
| Complete suite | ✅ Yes | ⚠️ Monitoring only |
| GUI-first experience | ✅ Yes | ❌ CLI only |
| Visual reports | ✅ Yes | ❌ Text only |

View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn7aeenqraa6r252zsz35j06nd808tnk",
"slug": "macbook-optimizer",
"version": "1.0.1",
"publishedAt": 1769893454044
}

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "memory-tools",
"installedVersion": "1.1.1",
"installedAt": 1770940086445
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,59 @@
# Pixel 6 Screen Reader Skill
## 功能
Pixel 6 上的 OpenClaw 可以读取自己的屏幕内容(截图 + 界面文字)。
## 前提条件
- Mac mini 上已通过 ADB 无线连接到 Pixel 6
- 连接命令:`adb connect 192.168.1.138:36327`(端口可能变化)
## 使用方法
### 1. 截图
在 Mac mini 上执行Pixel 6 的 OpenClaw 通过 SSH 调用):
```bash
adb shell screencap -p /sdcard/screen.png
adb pull /sdcard/screen.png ~/pixel6-screen.png
```
### 2. 读取界面文字
```bash
adb shell uiautomator dump /sdcard/ui.xml
adb pull /sdcard/ui.xml ~/pixel6-ui.xml
cat ~/pixel6-ui.xml
```
### 3. 模拟操作
```bash
# 唤醒屏幕
adb shell input keyevent KEYCODE_WAKEUP
# 滑动解锁
adb shell input swipe 500 1500 500 500
# 点击坐标
adb shell input tap 540 1200
# 输入文字
adb shell input text "Hello"
# 打开 App
adb shell am start -n org.telegram.messenger/org.telegram.ui.LaunchActivity
```
## Pixel 6 自己读取屏幕的流程
1. Pixel 6 的 OpenClaw 通过 SSH 连接到 Mac mini
2. 在 Mac mini 上执行 ADB 命令
3. 拉取截图/UI 数据到 Mac mini
4. 通过 SSH 读取文件内容
## 示例Pixel 6 查询自己的屏幕内容
```bash
# 在 Pixel 6 的 OpenClaw 里执行
ssh user@192.168.1.x "adb shell screencap -p /sdcard/screen.png && adb pull /sdcard/screen.png /tmp/pixel6.png" && scp user@192.168.1.x:/tmp/pixel6.png ~/.openclaw/workspace/data/
```
## 注意事项
- ADB 无线调试端口会变化,需要在 Pixel 6 的"开发者选项 → 无线调试"里查看
- 截图需要屏幕唤醒状态
- `uiautomator dump` 可以读取所有界面文字和按钮位置

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "remindme",
"installedVersion": "2.0.2",
"installedAt": 1770940083019
}

312
skills/remindme/SKILL.md Normal file
View File

@@ -0,0 +1,312 @@
---
name: remindme
description: "⏰ simple Telegram reminders for OpenClaw. cron, zero dependencies."
tags: [cron, reminders, productivity, schedule, telegram, discord, slack, whatsapp, signal]
metadata:
openclaw:
summary: "**Remind Me v2:** Schedule reminders anywhere. Natural language, native cron, zero dependencies."
emoji: "bell"
user-invocable: true
command-dispatch: prompt
---
# Remind Me v2
Set reminders on **any channel** using natural language. No setup. No dependencies.
## Usage
```
/remindme drink water in 10 minutes
/remindme standup tomorrow at 9am
/remindme call mom next monday at 6pm
/remindme in 2 hours turn off oven
/remindme check deployment in 30s
/remindme every day at 9am standup
/remindme every friday at 5pm week recap
/remindme drink water in 10 minutes on telegram
/remindme standup tomorrow at 9am on discord
/remindme list
/remindme cancel <jobId>
```
## Agent Instructions
When the user triggers `/remindme`, determine the intent:
- **list** → call `cron.list` and show active reminder jobs.
- **cancel / delete / remove `<jobId>`** → call `cron.remove` with that jobId.
- **everything else** → create a new reminder (steps below).
---
### Step 1: Parse the Input (Structured Pipeline)
Extract three things: **WHAT** (the message), **WHEN** (the time), **RECURRENCE** (one-shot or recurring).
Follow this decision tree **in order** — stop at the first match:
#### Layer 1: Pattern Matching (works on any model)
Scan the input for these patterns. Match top-to-bottom, first match wins for WHEN:
**Relative durations** — look for `in <number> <unit>`:
| Pattern | Duration |
|---|---|
| `in Ns`, `in N seconds`, `in N sec` | N seconds |
| `in Nm`, `in N min`, `in N minutes` | N minutes |
| `in Nh`, `in N hours`, `in N hr` | N hours |
| `in Nd`, `in N days` | N * 24 hours |
| `in Nw`, `in N weeks` | N * 7 days |
**Absolute clock times** — look for `at <time>`:
| Pattern | Meaning |
|---|---|
| `at HH:MM`, `at H:MMam/pm` | Today at that time (or tomorrow if past) |
| `at Ham/pm`, `at HH` | Today at that hour |
**Named days** — look for `tomorrow`, `next <day>`, `on <day>`:
| Pattern | Meaning |
|---|---|
| `tomorrow` | Next calendar day, default 9am |
| `tonight` | Today at 8pm (or now+1h if past 8pm) |
| `next monday..sunday` | The coming occurrence of that weekday, default 9am |
| `on <day>` | Same as `next <day>` |
**Recurring** — look for `every <pattern>`:
| Pattern | Cron/Interval |
|---|---|
| `every Nm/Nh/Nd` | `kind: "every"`, `everyMs: N * unit_ms` |
| `every day at <time>` | `kind: "cron"`, `expr: "M H * * *"` |
| `every <weekday> at <time>` | `kind: "cron"`, `expr: "M H * * DOW"` |
| `every weekday at <time>` | `kind: "cron"`, `expr: "M H * * 1-5"` |
| `every weekend at <time>` | `kind: "cron"`, `expr: "M H * * 0,6"` |
| `every hour` | `kind: "every"`, `everyMs: 3600000` |
**Unit conversion table** (for `everyMs` and duration math):
| Unit | Milliseconds |
|---|---|
| 1 second | 1000 |
| 1 minute | 60000 |
| 1 hour | 3600000 |
| 1 day | 86400000 |
| 1 week | 604800000 |
#### Layer 2: Slang & Shorthand (common phrases)
If Layer 1 didn't match, check for these:
| Phrase | Resolves to |
|---|---|
| `in a bit`, `in a minute`, `shortly` | 30 minutes |
| `in a while` | 1 hour |
| `later`, `later today` | 3 hours |
| `end of day`, `eod` | Today 5pm |
| `end of week`, `eow` | Friday 5pm |
| `end of month`, `eom` | Last day of month, 5pm |
| `morning` | 9am |
| `afternoon` | 2pm |
| `evening` | 6pm |
| `tonight` | 8pm |
| `midnight` | 12am next day |
| `noon` | 12pm |
#### Layer 3: Event-Relative & Holidays (LLM reasoning required)
If Layers 1-2 didn't match, the input likely references an event or holiday. Use your knowledge to resolve:
**Holiday resolution** — when the user says "before/after/on <holiday>":
1. Identify the holiday and its **fixed date for the current year**.
2. Apply any offset: "3 days before Christmas" → Dec 25 minus 3 = Dec 22.
3. If the holiday has passed this year, use next year's date.
**Common fixed-date holidays** (reference table):
| Holiday | Date |
|---|---|
| New Year's Day | Jan 1 |
| Valentine's Day | Feb 14 |
| St. Patrick's Day | Mar 17 |
| April Fools | Apr 1 |
| US Independence Day | Jul 4 |
| Halloween | Oct 31 |
| Christmas Eve | Dec 24 |
| Christmas | Dec 25 |
| New Year's Eve | Dec 31 |
**Floating holidays** (vary by year — compute or look up):
- Thanksgiving (US): 4th Thursday of November
- Easter: varies (use your knowledge for the current year)
- Mother's Day (US): 2nd Sunday of May
- Father's Day (US): 3rd Sunday of June
- Labor Day (US): 1st Monday of September
- Memorial Day (US): Last Monday of May
**Cultural/religious events** (if referenced, use your knowledge):
- Ramadan, Eid al-Fitr, Eid al-Adha, Diwali, Hanukkah, Lunar New Year, etc.
- If you're unsure of the exact date, **ask the user to confirm** rather than guess.
**Event-relative patterns:**
| Pattern | Resolution |
|---|---|
| `N days before <event>` | event_date - N days |
| `N days after <event>` | event_date + N days |
| `the day before <event>` | event_date - 1 day |
| `the week of <event>` | Monday of event's week, 9am |
| `on <event>` | event_date, 9am |
#### Layer 4: Ambiguity — Ask, Don't Guess
If you still can't determine WHEN after all layers:
- **Ask the user** to clarify. Example: "I couldn't figure out the timing. When exactly should I remind you?"
- Never silently pick a default time.
- Never schedule a reminder you're not confident about.
### Step 2: Compute the Schedule
**Timezone rule:** ALWAYS use the user's **local timezone** (system timezone). Never default to UTC. If the user explicitly mentions a timezone (e.g. "at 9am EST"), use that instead.
**One-shot** → ISO 8601 timestamp with the user's local timezone offset.
- If the computed time is in the PAST, bump to the next occurrence.
**Recurring (cron)** → 5-field cron expression with `tz` set to the user's IANA timezone.
- `every day at 9am``expr: "0 9 * * *"`
- `every monday at 8:30am``expr: "30 8 * * 1"`
- `every weekday at 9am``expr: "0 9 * * 1-5"`
**Recurring (interval)**`kind: "every"` with `everyMs` in milliseconds.
- `every 2 hours``everyMs: 7200000`
### Validation Checkpoint (before calling cron.add)
Before proceeding to Step 3, verify:
1. The computed timestamp is **in the future** (not the past).
2. The duration makes sense (e.g. "in 0 minutes" should be rejected).
3. For recurring: the cron expression or interval is valid (no `everyMs: 0`).
4. **Echo back** the parsed time to the user in the confirmation (Step 5) so they can catch errors.
### Step 3: Detect the Delivery Channel
Reminders are useless if the user never sees them. The delivery channel determines WHERE the reminder appears when it fires.
**Priority order:**
1. **Explicit override** — if the user says "on telegram" / "on discord" / "on slack" / "on whatsapp" in their message, use that channel.
2. **Current channel** — if the user is messaging from an external channel (Telegram, Discord, Slack, etc.), deliver there.
3. **Preferred channel** — if the user has a preferred reminder channel saved in MEMORY.md, use that.
4. **Last external channel** — use `channel: "last"` to deliver to the last place the user interacted externally.
5. **No external channel available** — if the user is on CLI/webchat and has NO external channels configured, **stop and ask**: "Where should I deliver this reminder? I need an external channel (Telegram, Discord, Slack, WhatsApp, Signal, or iMessage) since the CLI won't be open when the reminder fires."
### Step 4: Call `cron.add`
**One-shot reminder:**
```json
{
"name": "Reminder: <short description>",
"schedule": {
"kind": "at",
"at": "<ISO 8601 timestamp>"
},
"sessionTarget": "isolated",
"wakeMode": "now",
"payload": {
"kind": "agentTurn",
"message": "REMINDER: <the user's reminder message>. Deliver this reminder to the user now."
},
"delivery": {
"mode": "announce",
"channel": "<detected channel>",
"to": "<detected target>",
"bestEffort": true
},
"deleteAfterRun": true
}
```
**Recurring reminder:**
```json
{
"name": "Recurring: <short description>",
"schedule": {
"kind": "cron",
"expr": "<cron expression>",
"tz": "<IANA timezone>"
},
"sessionTarget": "isolated",
"wakeMode": "now",
"payload": {
"kind": "agentTurn",
"message": "RECURRING REMINDER: <the user's reminder message>. Deliver this reminder to the user now."
},
"delivery": {
"mode": "announce",
"channel": "<detected channel>",
"to": "<detected target>",
"bestEffort": true
}
}
```
**Fixed-interval recurring reminder** (e.g. "every 2 hours"):
```json
{
"name": "Recurring: <short description>",
"schedule": {
"kind": "every",
"everyMs": <interval in milliseconds>
},
"sessionTarget": "isolated",
"wakeMode": "now",
"payload": {
"kind": "agentTurn",
"message": "RECURRING REMINDER: <the user's reminder message>. Deliver this reminder to the user now."
},
"delivery": {
"mode": "announce",
"channel": "<detected channel>",
"to": "<detected target>",
"bestEffort": true
}
}
```
### Step 5: Confirm to User
After `cron.add` succeeds, reply with:
```
Reminder set!
"<reminder message>"
<friendly time description> (<ISO timestamp or cron expression>)
Will deliver to: <channel>
Job ID: <jobId> (use "/remindme cancel <jobId>" to remove)
```
---
## Rules
1. **ALWAYS use `deleteAfterRun: true`** for one-shot reminders. Omit it for recurring.
2. **ALWAYS use `delivery.mode: "announce"`** — without this, the user never sees the reminder.
3. **ALWAYS use `sessionTarget: "isolated"`** — reminders run in their own session.
4. **ALWAYS use `wakeMode: "now"`** — ensures immediate delivery at the scheduled time.
5. **ALWAYS use `delivery.bestEffort: true`** — prevents job failure if delivery has a transient issue.
6. **NEVER use `act:wait` or loops** for delays longer than 1 minute. Cron handles timing.
7. **NEVER deliver to localhost/webchat/CLI** — the user won't be there when the reminder fires. If on CLI with no external channels, ask the user where to deliver.
8. **Always use the user's local timezone** (system timezone). Never default to UTC. If MEMORY.md has a timezone override, use that instead.
9. **For recurring reminders**, do NOT set `deleteAfterRun`.
10. **Always return the jobId** so the user can cancel later.
11. **If the user says "on telegram/discord/slack/etc"**, override the auto-detected channel with the explicit one.
## Troubleshooting
- **Reminder didn't fire?** → `cron.list` to check. Verify gateway was running at the scheduled time.
- **Delivered to wrong chat?** → Use explicit chat/channel ID, not `"last"`.
- **Too many old jobs?** → Install the Janitor (see `references/TEMPLATES.md`).
- **Recurring job keeps delaying?** → After consecutive failures, cron applies exponential backoff (30s → 1m → 5m → 15m → 60m). Backoff resets after a successful run.
## References
See `references/TEMPLATES.md` for copy-paste templates and the Janitor auto-cleanup setup.

View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn7586w2dker87hc3kr4qp0a1d80h3y0",
"slug": "remindme",
"version": "2.0.2",
"publishedAt": 1770853231820
}

View File

@@ -0,0 +1,109 @@
# Cron Templates for Remindme
## One-Shot Reminder (Telegram)
```json
{
"name": "Reminder: <description>",
"schedule": {
"kind": "at",
"at": "2026-02-11T23:00:00Z"
},
"sessionTarget": "isolated",
"wakeMode": "now",
"payload": {
"kind": "agentTurn",
"message": "⏰ REMINDER: <message>. Deliver this reminder now."
},
"delivery": {
"mode": "announce",
"channel": "telegram",
"to": "<chatId>",
"bestEffort": true
},
"deleteAfterRun": true
}
```
## One-Shot Reminder (Discord)
```json
{
"name": "Reminder: <description>",
"schedule": {
"kind": "at",
"at": "2026-02-11T23:00:00Z"
},
"sessionTarget": "isolated",
"wakeMode": "now",
"payload": {
"kind": "agentTurn",
"message": "⏰ REMINDER: <message>. Deliver this reminder now."
},
"delivery": {
"mode": "announce",
"channel": "discord",
"to": "channel:<channelId>",
"bestEffort": true
},
"deleteAfterRun": true
}
```
## Recurring Reminder (Any Channel)
```json
{
"name": "Daily: <description>",
"schedule": {
"kind": "cron",
"expr": "0 9 * * *",
"tz": "Africa/Cairo"
},
"sessionTarget": "isolated",
"wakeMode": "now",
"payload": {
"kind": "agentTurn",
"message": "⏰ RECURRING: <message>"
},
"delivery": {
"mode": "announce",
"channel": "last",
"bestEffort": true
}
}
```
## The Janitor (Auto-Cleanup)
Install this once to clean up expired one-shot reminders every 24 hours:
```json
{
"name": "Daily Cron Cleanup",
"schedule": {
"kind": "every",
"everyMs": 86400000
},
"sessionTarget": "isolated",
"wakeMode": "next-heartbeat",
"payload": {
"kind": "agentTurn",
"message": "Time for the 24-hour remindme cleanup. List all cron jobs. Only delete jobs whose name starts with 'Reminder:' that are disabled (enabled: false) and have lastStatus: ok (finished one-shots). Do NOT delete any jobs that don't start with 'Reminder:' — those belong to other skills. Do NOT delete active recurring jobs (name starts with 'Recurring:'). Log what you deleted."
},
"delivery": {
"mode": "none"
}
}
```
## Timezone Reference
Common timezone identifiers:
- `Africa/Cairo` (GMT+2)
- `America/New_York` (EST/EDT)
- `America/Los_Angeles` (PST/PDT)
- `Europe/London` (GMT/BST)
- `Asia/Tokyo` (JST)
Always confirm the user's timezone before scheduling absolute-time reminders.

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "skill-vetter",
"installedVersion": "1.0.0",
"installedAt": 1773013767623
}

View File

@@ -0,0 +1,138 @@
---
name: skill-vetter
version: 1.0.0
description: Security-first skill vetting for AI agents. Use before installing any skill from ClawdHub, GitHub, or other sources. Checks for red flags, permission scope, and suspicious patterns.
---
# Skill Vetter 🔒
Security-first vetting protocol for AI agent skills. **Never install a skill without vetting it first.**
## When to Use
- Before installing any skill from ClawdHub
- Before running skills from GitHub repos
- When evaluating skills shared by other agents
- Anytime you're asked to install unknown code
## Vetting Protocol
### Step 1: Source Check
```
Questions to answer:
- [ ] Where did this skill come from?
- [ ] Is the author known/reputable?
- [ ] How many downloads/stars does it have?
- [ ] When was it last updated?
- [ ] Are there reviews from other agents?
```
### Step 2: Code Review (MANDATORY)
Read ALL files in the skill. Check for these **RED FLAGS**:
```
🚨 REJECT IMMEDIATELY IF YOU SEE:
─────────────────────────────────────────
• curl/wget to unknown URLs
• Sends data to external servers
• Requests credentials/tokens/API keys
• Reads ~/.ssh, ~/.aws, ~/.config without clear reason
• Accesses MEMORY.md, USER.md, SOUL.md, IDENTITY.md
• Uses base64 decode on anything
• Uses eval() or exec() with external input
• Modifies system files outside workspace
• Installs packages without listing them
• Network calls to IPs instead of domains
• Obfuscated code (compressed, encoded, minified)
• Requests elevated/sudo permissions
• Accesses browser cookies/sessions
• Touches credential files
─────────────────────────────────────────
```
### Step 3: Permission Scope
```
Evaluate:
- [ ] What files does it need to read?
- [ ] What files does it need to write?
- [ ] What commands does it run?
- [ ] Does it need network access? To where?
- [ ] Is the scope minimal for its stated purpose?
```
### Step 4: Risk Classification
| Risk Level | Examples | Action |
|------------|----------|--------|
| 🟢 LOW | Notes, weather, formatting | Basic review, install OK |
| 🟡 MEDIUM | File ops, browser, APIs | Full code review required |
| 🔴 HIGH | Credentials, trading, system | Human approval required |
| ⛔ EXTREME | Security configs, root access | Do NOT install |
## Output Format
After vetting, produce this report:
```
SKILL VETTING REPORT
═══════════════════════════════════════
Skill: [name]
Source: [ClawdHub / GitHub / other]
Author: [username]
Version: [version]
───────────────────────────────────────
METRICS:
• Downloads/Stars: [count]
• Last Updated: [date]
• Files Reviewed: [count]
───────────────────────────────────────
RED FLAGS: [None / List them]
PERMISSIONS NEEDED:
• Files: [list or "None"]
• Network: [list or "None"]
• Commands: [list or "None"]
───────────────────────────────────────
RISK LEVEL: [🟢 LOW / 🟡 MEDIUM / 🔴 HIGH / ⛔ EXTREME]
VERDICT: [✅ SAFE TO INSTALL / ⚠️ INSTALL WITH CAUTION / ❌ DO NOT INSTALL]
NOTES: [Any observations]
═══════════════════════════════════════
```
## Quick Vet Commands
For GitHub-hosted skills:
```bash
# Check repo stats
curl -s "https://api.github.com/repos/OWNER/REPO" | jq '{stars: .stargazers_count, forks: .forks_count, updated: .updated_at}'
# List skill files
curl -s "https://api.github.com/repos/OWNER/REPO/contents/skills/SKILL_NAME" | jq '.[].name'
# Fetch and review SKILL.md
curl -s "https://raw.githubusercontent.com/OWNER/REPO/main/skills/SKILL_NAME/SKILL.md"
```
## Trust Hierarchy
1. **Official OpenClaw skills** → Lower scrutiny (still review)
2. **High-star repos (1000+)** → Moderate scrutiny
3. **Known authors** → Moderate scrutiny
4. **New/unknown sources** → Maximum scrutiny
5. **Skills requesting credentials** → Human approval always
## Remember
- No skill is worth compromising security
- When in doubt, don't install
- Ask your human for high-risk decisions
- Document what you vet for future reference
---
*Paranoia is a feature.* 🔒🦀

View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn71j6xbmpwfvx4c6y1ez8cd718081mg",
"slug": "skill-vetter",
"version": "1.0.0",
"publishedAt": 1769863429632
}

View File

@@ -0,0 +1,7 @@
{
"version": 1,
"registry": "https://clawhub.ai",
"slug": "summarize",
"installedVersion": "1.0.0",
"installedAt": 1770940067082
}

49
skills/summarize/SKILL.md Normal file
View File

@@ -0,0 +1,49 @@
---
name: summarize
description: Summarize URLs or files with the summarize CLI (web, PDFs, images, audio, YouTube).
homepage: https://summarize.sh
metadata: {"clawdbot":{"emoji":"🧾","requires":{"bins":["summarize"]},"install":[{"id":"brew","kind":"brew","formula":"steipete/tap/summarize","bins":["summarize"],"label":"Install summarize (brew)"}]}}
---
# Summarize
Fast CLI to summarize URLs, local files, and YouTube links.
## Quick start
```bash
summarize "https://example.com" --model google/gemini-3-flash-preview
summarize "/path/to/file.pdf" --model google/gemini-3-flash-preview
summarize "https://youtu.be/dQw4w9WgXcQ" --youtube auto
```
## Model + keys
Set the API key for your chosen provider:
- OpenAI: `OPENAI_API_KEY`
- Anthropic: `ANTHROPIC_API_KEY`
- xAI: `XAI_API_KEY`
- Google: `GEMINI_API_KEY` (aliases: `GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY`)
Default model is `google/gemini-3-flash-preview` if none is set.
## Useful flags
- `--length short|medium|long|xl|xxl|<chars>`
- `--max-output-tokens <count>`
- `--extract-only` (URLs only)
- `--json` (machine readable)
- `--firecrawl auto|off|always` (fallback extraction)
- `--youtube auto` (Apify fallback if `APIFY_API_TOKEN` set)
## Config
Optional config file: `~/.summarize/config.json`
```json
{ "model": "openai/gpt-5.2" }
```
Optional services:
- `FIRECRAWL_API_KEY` for blocked sites
- `APIFY_API_TOKEN` for YouTube fallback

View File

@@ -0,0 +1,6 @@
{
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
"slug": "summarize",
"version": "1.0.0",
"publishedAt": 1767545383635
}

View File

@@ -0,0 +1,53 @@
---
name: vps-migrate
description: VPS 快照备份与迁移工具。用于:(1) 备份 VPS 配置和数据到本地/远程 (2) 恢复快照到新服务器 (3) 一键迁移 VPS (4) Docker 容器迁移。当用户提到"备份服务器"、"迁移VPS"、"快照"、"恢复服务器"时触发。
---
# VPS 快照备份与迁移
## 快速开始
### 一键安装(推荐)
在目标服务器上运行:
```bash
curl -sL https://raw.githubusercontent.com/mango082888-bit/vps-snapshot/main/vps-snapshot.sh -o vps-snapshot.sh && chmod +x vps-snapshot.sh && ./vps-snapshot.sh
```
### 或手动上传
```bash
scp scripts/vps-snapshot.sh root@<IP>:/root/
ssh root@<IP> "chmod +x /root/vps-snapshot.sh && /root/vps-snapshot.sh"
```
### 3. 常用操作
| 操作 | 命令 |
|------|------|
| 创建快照 | `./vps-snapshot.sh` → 选 1 |
| 恢复快照 | `./vps-snapshot.sh` → 选 2 |
| 一键迁移 | `./vps-snapshot.sh` → 选 3 |
| Docker迁移 | `./vps-snapshot.sh` → 选 4 |
## 脚本功能
- **快照备份**: 备份 /etc, /root, /home, /opt, /var/lib 等关键目录
- **远程同步**: rsync 同步到远程存储服务器
- **Telegram 通知**: 备份完成后发送通知
- **完整恢复**: 可选择完整恢复(删除新安装的程序)或仅恢复数据
- **Docker 迁移**: 使用 docker save/load 迁移容器和镜像
- **智能识别**: 自动识别已安装的应用Docker, Nginx, MySQL 等)
## 配置要求
- 支持系统: Ubuntu/Debian/CentOS/Alpine
- SSH 认证: 密钥或密码
- 可选: Telegram Bot Token用于通知
- 可选: 远程存储服务器(用于异地备份)
## 脚本位置
本地脚本: `scripts/vps-snapshot.sh`
GitHub: https://github.com/mango082888-bit/vps-snapshot

File diff suppressed because it is too large Load Diff