Rename to hkt.sh
This commit is contained in:
7
skills/camera/.clawhub/origin.json
Normal file
7
skills/camera/.clawhub/origin.json
Normal 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
43
skills/camera/SKILL.md
Normal 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
6
skills/camera/_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn76tn13s33tbyv3x1p4thbmv5809t0m",
|
||||
"slug": "camera",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1770796421019
|
||||
}
|
||||
7
skills/camsnap/.clawhub/origin.json
Normal file
7
skills/camsnap/.clawhub/origin.json
Normal 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
25
skills/camsnap/SKILL.md
Normal 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.
|
||||
6
skills/camsnap/_meta.json
Normal file
6
skills/camsnap/_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
|
||||
"slug": "camsnap",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1767545306244
|
||||
}
|
||||
7
skills/gif/.clawhub/origin.json
Normal file
7
skills/gif/.clawhub/origin.json
Normal 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
83
skills/gif/SKILL.md
Normal 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
6
skills/gif/_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn73vp5rarc3b14rc7wjcw8f8580t5d1",
|
||||
"slug": "gif",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1770736313556
|
||||
}
|
||||
7
skills/gifgrep/.clawhub/origin.json
Normal file
7
skills/gifgrep/.clawhub/origin.json
Normal 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
47
skills/gifgrep/SKILL.md
Normal 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
|
||||
6
skills/gifgrep/_meta.json
Normal file
6
skills/gifgrep/_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
|
||||
"slug": "gifgrep",
|
||||
"version": "1.0.1",
|
||||
"publishedAt": 1767545342229
|
||||
}
|
||||
7
skills/imsg/.clawhub/origin.json
Normal file
7
skills/imsg/.clawhub/origin.json
Normal 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
25
skills/imsg/SKILL.md
Normal 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
6
skills/imsg/_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
|
||||
"slug": "imsg",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1767545348833
|
||||
}
|
||||
7
skills/macbook-optimizer/.clawhub/origin.json
Normal file
7
skills/macbook-optimizer/.clawhub/origin.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"registry": "https://clawhub.ai",
|
||||
"slug": "macbook-optimizer",
|
||||
"installedVersion": "1.0.1",
|
||||
"installedAt": 1770940095707
|
||||
}
|
||||
59
skills/macbook-optimizer/README.md
Normal file
59
skills/macbook-optimizer/README.md
Normal 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
|
||||
233
skills/macbook-optimizer/SKILL.md
Normal file
233
skills/macbook-optimizer/SKILL.md
Normal 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 |
|
||||
6
skills/macbook-optimizer/_meta.json
Normal file
6
skills/macbook-optimizer/_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7aeenqraa6r252zsz35j06nd808tnk",
|
||||
"slug": "macbook-optimizer",
|
||||
"version": "1.0.1",
|
||||
"publishedAt": 1769893454044
|
||||
}
|
||||
7
skills/memory-tools/.clawhub/origin.json
Normal file
7
skills/memory-tools/.clawhub/origin.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"registry": "https://clawhub.ai",
|
||||
"slug": "memory-tools",
|
||||
"installedVersion": "1.1.1",
|
||||
"installedAt": 1770940086445
|
||||
}
|
||||
37
skills/memory-tools/CLAUDE.md
Normal file
37
skills/memory-tools/CLAUDE.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Project context for Claude Code.
|
||||
|
||||
## Project
|
||||
|
||||
**memory-tools** - Agent-controlled memory plugin for OpenClaw/ClawHub.
|
||||
|
||||
- **Repo**: `Purple-Horizons/memory-tools`
|
||||
- **ClawHub slug**: `memory-tools`
|
||||
- **Owner**: gianni-dalerta
|
||||
|
||||
## Architecture
|
||||
|
||||
- **SQLite via sql.js (WASM)** - No native compilation, works on any Node version
|
||||
- **LanceDB** - Vector storage for semantic search
|
||||
- **Hybrid storage** - Metadata in SQLite, embeddings in LanceDB
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- Switched from `better-sqlite3` to `sql.js` (v1.1.0) to eliminate `NODE_MODULE_VERSION` mismatch errors when users have different Node.js versions
|
||||
- Database initialization is async (WASM loading), so sync methods require `await store.init()` first
|
||||
|
||||
## Publishing to ClawHub
|
||||
|
||||
```bash
|
||||
clawhub publish . --slug memory-tools --version X.Y.Z --changelog "description"
|
||||
```
|
||||
|
||||
Always use `--slug memory-tools` to update the correct skill.
|
||||
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
npm run build # TypeScript compile
|
||||
npm test # Run vitest
|
||||
```
|
||||
290
skills/memory-tools/README.md
Normal file
290
skills/memory-tools/README.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# OpenClaw Memory Tools
|
||||
|
||||
Agent-controlled memory plugin for OpenClaw with confidence scoring, decay, and semantic search.
|
||||
|
||||
## Why Memory-as-Tools?
|
||||
|
||||
Traditional AI memory systems auto-capture everything, flooding context with irrelevant information. **Memory-as-Tools** follows the [AgeMem](https://arxiv.org/abs/2409.02634) approach: the agent decides **when** to store and retrieve memories.
|
||||
|
||||
```
|
||||
Traditional: Agent → always retrieves → context flooded
|
||||
Memory-as-Tools: Agent → decides IF/WHAT to remember → uses tools explicitly
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- **6 Memory Tools**: `memory_store`, `memory_update`, `memory_forget`, `memory_search`, `memory_summarize`, `memory_list`
|
||||
- **Confidence Scoring**: Track how certain you are about each memory (1.0 = explicit, 0.5 = inferred)
|
||||
- **Importance Scoring**: Prioritize critical instructions over nice-to-know facts
|
||||
- **Decay/Expiration**: Temporal memories (events) automatically become stale
|
||||
- **Semantic Search**: Vector-based similarity search via LanceDB
|
||||
- **Hybrid Storage**: SQLite (via WASM) for metadata + LanceDB for vectors
|
||||
- **Zero Native Dependencies**: Uses sql.js (WASM) - no C++ compilation, works on any Node version
|
||||
- **Standing Instructions**: Auto-inject category="instruction" memories at conversation start
|
||||
|
||||
## Installation
|
||||
|
||||
### Quick Install
|
||||
|
||||
```bash
|
||||
# Clone to OpenClaw extensions directory
|
||||
git clone https://github.com/purple-horizons/openclaw-memory-tools.git ~/.openclaw/extensions/memory-tools
|
||||
cd ~/.openclaw/extensions/memory-tools
|
||||
|
||||
# Install dependencies and build
|
||||
pnpm install && pnpm build
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Add to `~/.openclaw/openclaw.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"slots": {
|
||||
"memory": "memory-tools"
|
||||
},
|
||||
"entries": {
|
||||
"memory-tools": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"embedding": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tools": {
|
||||
"alsoAllow": ["group:plugins"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### OpenAI API Key
|
||||
|
||||
The plugin needs an OpenAI API key for embeddings. Three options:
|
||||
|
||||
**Option 1: Environment variable (recommended)**
|
||||
```bash
|
||||
# Add to ~/.zshrc or ~/.bashrc
|
||||
export OPENAI_API_KEY="sk-proj-..."
|
||||
```
|
||||
|
||||
**Option 2: Reference env var in config**
|
||||
```json
|
||||
{
|
||||
"embedding": {
|
||||
"apiKey": "${OPENAI_API_KEY}"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Option 3: Direct in config (not recommended)**
|
||||
```json
|
||||
{
|
||||
"embedding": {
|
||||
"apiKey": "sk-proj-..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Verify Installation
|
||||
|
||||
```bash
|
||||
# Restart gateway
|
||||
openclaw gateway stop && openclaw gateway run
|
||||
|
||||
# Check plugin loaded
|
||||
openclaw plugins list
|
||||
|
||||
# Test CLI
|
||||
openclaw memory-tools stats
|
||||
```
|
||||
|
||||
## Memory Categories
|
||||
|
||||
| Category | Use For | Example |
|
||||
|----------|---------|---------|
|
||||
| `fact` | Static information | "User's dog is named Rex" |
|
||||
| `preference` | Likes/dislikes | "User prefers dark mode" |
|
||||
| `event` | Temporal things | "Dentist appointment Tuesday 3pm" |
|
||||
| `relationship` | People connections | "User's sister is Sarah" |
|
||||
| `context` | Current work | "Working on React project" |
|
||||
| `instruction` | Standing orders | "Always respond in Spanish" |
|
||||
| `decision` | Choices made | "We decided to use PostgreSQL" |
|
||||
| `entity` | Contact info | "User's email is x@y.com" |
|
||||
|
||||
## Tool Reference
|
||||
|
||||
### memory_store
|
||||
|
||||
Store a new memory.
|
||||
|
||||
```typescript
|
||||
memory_store({
|
||||
content: "User prefers bullet points",
|
||||
category: "preference",
|
||||
confidence: 0.9, // How sure (0-1)
|
||||
importance: 0.7, // How critical (0-1)
|
||||
decayDays: null, // null = permanent
|
||||
tags: ["formatting"]
|
||||
})
|
||||
```
|
||||
|
||||
### memory_update
|
||||
|
||||
Update an existing memory.
|
||||
|
||||
```typescript
|
||||
memory_update({
|
||||
id: "abc-123",
|
||||
content: "User prefers numbered lists", // Optional
|
||||
confidence: 0.95 // Optional
|
||||
})
|
||||
```
|
||||
|
||||
### memory_forget
|
||||
|
||||
Delete a memory.
|
||||
|
||||
```typescript
|
||||
memory_forget({
|
||||
id: "abc-123", // If known
|
||||
query: "bullet points", // Or search
|
||||
reason: "User corrected"
|
||||
})
|
||||
```
|
||||
|
||||
### memory_search
|
||||
|
||||
Semantic search.
|
||||
|
||||
```typescript
|
||||
memory_search({
|
||||
query: "formatting preferences",
|
||||
category: "preference", // Optional filter
|
||||
minConfidence: 0.7, // Optional filter
|
||||
limit: 10
|
||||
})
|
||||
```
|
||||
|
||||
### memory_summarize
|
||||
|
||||
Get topic summary.
|
||||
|
||||
```typescript
|
||||
memory_summarize({
|
||||
topic: "user's work",
|
||||
maxMemories: 20
|
||||
})
|
||||
```
|
||||
|
||||
### memory_list
|
||||
|
||||
Browse all memories.
|
||||
|
||||
```typescript
|
||||
memory_list({
|
||||
category: "instruction",
|
||||
sortBy: "importance",
|
||||
limit: 20
|
||||
})
|
||||
```
|
||||
|
||||
## CLI Commands
|
||||
|
||||
```bash
|
||||
# Show statistics
|
||||
openclaw memory-tools stats
|
||||
|
||||
# List memories
|
||||
openclaw memory-tools list --category preference
|
||||
|
||||
# Search memories
|
||||
openclaw memory-tools search "dark mode"
|
||||
|
||||
# Export all memories as JSON
|
||||
openclaw memory-tools export
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ OpenClaw Agent │
|
||||
│ │
|
||||
│ Agent decides: "This is worth remembering" │
|
||||
│ ↓ │
|
||||
│ Calls: memory_store(...) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Memory Tools │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ store │ update │ forget │ search │ summarize │ list │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Storage Layer │
|
||||
│ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ SQLite/WASM │ │ LanceDB │ │
|
||||
│ │ (metadata) │◄──►│ (vectors) │ │
|
||||
│ └──────────────┘ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Run tests
|
||||
pnpm test
|
||||
|
||||
# Run tests with coverage
|
||||
pnpm test:coverage
|
||||
|
||||
# Type check
|
||||
pnpm typecheck
|
||||
|
||||
# Build
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Comparison with Other Memory Systems
|
||||
|
||||
| Feature | [memU](https://github.com/NevaMind-AI/memU) | [claude-mem](https://github.com/thedotmack/claude-mem) | **memory-tools** |
|
||||
|---------|------|------------|--------------|
|
||||
| **Architecture** | 3-tier hierarchical (Resource → Item → Category) | Hook-based observer with lifecycle events | Tool-based agent control |
|
||||
| **Storage Trigger** | Automatic extraction during background processing | Lifecycle hooks (SessionStart, PostToolUse, etc.) | Agent explicitly decides when to store |
|
||||
| **Conflict Handling** | None - relies on proactive pattern detection | None - auto-capture model | Auto-supersede + explicit forget |
|
||||
| **Context Injection** | Proactive - predicts and pre-loads context | Progressive disclosure (3-layer filtering) | On-demand via memory_search |
|
||||
| **Token Efficiency** | Compression via fact extraction | ~10x savings via progressive disclosure | Semantic search with configurable limits |
|
||||
| **Auditability** | Background processing | Hook-based capture | Full SQLite inspection, explicit tool calls |
|
||||
| **User Corrections** | Accumulates conflicting facts | Accumulates conflicting facts | Replaces old with new automatically |
|
||||
| **Best For** | 24/7 agents with predictable patterns | Automatic session continuity | Personal assistants with ongoing relationships |
|
||||
|
||||
### Design Philosophy
|
||||
|
||||
Different memory systems optimize for different things:
|
||||
|
||||
- **Automatic systems** (memU, claude-mem) minimize agent cognitive load by extracting memories in the background. Trade-off: less control over what's captured, conflicts accumulate.
|
||||
|
||||
- **Agent-controlled systems** (memory-tools) put the agent in charge of what matters. Trade-off: requires active management, but memories are deliberate choices.
|
||||
|
||||
For agents that maintain ongoing relationships with users—where someone might say "no, my favorite color is purple, not blue"—explicit conflict handling prevents contradictory memories from accumulating. Every memory has a clear provenance: the agent decided it was worth remembering, and corrections replace rather than compete with old information.
|
||||
|
||||
The hybrid SQLite (WASM) + LanceDB storage means you can always `sqlite3 ~/.openclaw/memory/tools/memory.db` to inspect exactly what your agent knows and why.
|
||||
|
||||
## References
|
||||
|
||||
- [AgeMem Paper](https://arxiv.org/abs/2409.02634) - Memory operations as first-class tools
|
||||
- [memU](https://github.com/NevaMind-AI/memU) - Hierarchical memory with proactive context
|
||||
- [claude-mem](https://github.com/thedotmack/claude-mem) - Hook-based automatic memory
|
||||
- [Mem0](https://github.com/mem0ai/mem0) - AI memory layer
|
||||
- [OpenClaw](https://github.com/openclaw/openclaw) - Personal AI assistant
|
||||
|
||||
## License
|
||||
|
||||
MIT - [Purple Horizons](https://github.com/Purple-Horizons)
|
||||
168
skills/memory-tools/SKILL.md
Normal file
168
skills/memory-tools/SKILL.md
Normal file
@@ -0,0 +1,168 @@
|
||||
---
|
||||
name: memory-tools
|
||||
description: Agent-controlled memory plugin for OpenClaw with confidence scoring, decay, and semantic search. The agent decides WHEN to store/retrieve memories — no auto-capture noise.
|
||||
homepage: https://github.com/Purple-Horizons/openclaw-memory-tools
|
||||
metadata:
|
||||
openclaw:
|
||||
emoji: 🧠
|
||||
kind: plugin
|
||||
requires:
|
||||
env:
|
||||
- OPENAI_API_KEY
|
||||
---
|
||||
|
||||
# Memory Tools
|
||||
|
||||
Agent-controlled persistent memory for OpenClaw.
|
||||
|
||||
## Why Memory-as-Tools?
|
||||
|
||||
Traditional memory systems auto-capture everything, flooding context with irrelevant information. Memory Tools follows the [AgeMem](https://arxiv.org/abs/2409.02634) approach: **the agent decides** when to store and retrieve memories.
|
||||
|
||||
## Features
|
||||
|
||||
- **6 Memory Tools**: `memory_store`, `memory_update`, `memory_forget`, `memory_search`, `memory_summarize`, `memory_list`
|
||||
- **Confidence Scoring**: Track how certain you are (1.0 = explicit, 0.5 = inferred)
|
||||
- **Importance Scoring**: Prioritize critical instructions over nice-to-know facts
|
||||
- **Decay/Expiration**: Temporal memories automatically become stale
|
||||
- **Semantic Search**: Vector-based similarity via LanceDB
|
||||
- **Hybrid Storage**: SQLite (debuggable) + LanceDB (fast vectors)
|
||||
- **Conflict Resolution**: New info auto-supersedes old (no contradictions)
|
||||
|
||||
## Installation
|
||||
|
||||
### Step 1: Install from ClawHub
|
||||
|
||||
```bash
|
||||
clawhub install memory-tools
|
||||
```
|
||||
|
||||
### Step 2: Build the plugin
|
||||
|
||||
```bash
|
||||
cd skills/memory-tools
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Step 3: Activate the plugin
|
||||
|
||||
```bash
|
||||
openclaw plugins install --link .
|
||||
openclaw plugins enable memory-tools
|
||||
```
|
||||
|
||||
### Step 4: Restart the gateway
|
||||
|
||||
**Standard (systemd):**
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
**Docker (no systemd):**
|
||||
```bash
|
||||
# Kill existing gateway
|
||||
pkill -f openclaw-gateway
|
||||
|
||||
# Start in background
|
||||
nohup openclaw gateway --port 18789 --verbose > /tmp/openclaw-gateway.log 2>&1 &
|
||||
```
|
||||
|
||||
### Requirements
|
||||
|
||||
- `OPENAI_API_KEY` environment variable (for embeddings)
|
||||
|
||||
## Memory Categories
|
||||
|
||||
| Category | Use For | Example |
|
||||
|----------|---------|---------|
|
||||
| fact | Static information | "User's dog is named Rex" |
|
||||
| preference | Likes/dislikes | "User prefers dark mode" |
|
||||
| event | Temporal things | "Dentist Tuesday 3pm" |
|
||||
| relationship | People connections | "Sarah is user's wife" |
|
||||
| instruction | Standing orders | "Always respond in Spanish" |
|
||||
| decision | Choices made | "We decided to use PostgreSQL" |
|
||||
| context | Situational info | "User is job hunting" |
|
||||
| entity | Named things | "Project Apollo is their startup" |
|
||||
|
||||
## Tool Reference
|
||||
|
||||
### memory_store
|
||||
```
|
||||
memory_store({
|
||||
content: "User prefers bullet points",
|
||||
category: "preference",
|
||||
confidence: 0.9,
|
||||
importance: 0.7,
|
||||
tags: ["formatting", "communication"]
|
||||
})
|
||||
```
|
||||
|
||||
### memory_search
|
||||
```
|
||||
memory_search({
|
||||
query: "formatting preferences",
|
||||
category: "preference",
|
||||
limit: 10
|
||||
})
|
||||
```
|
||||
|
||||
### memory_update
|
||||
```
|
||||
memory_update({
|
||||
id: "abc123",
|
||||
content: "User now prefers numbered lists",
|
||||
confidence: 1.0
|
||||
})
|
||||
```
|
||||
|
||||
### memory_forget
|
||||
```
|
||||
memory_forget({
|
||||
query: "bullet points",
|
||||
reason: "User corrected preference"
|
||||
})
|
||||
```
|
||||
|
||||
### memory_summarize
|
||||
```
|
||||
memory_summarize({
|
||||
topic: "user's work projects",
|
||||
maxMemories: 20
|
||||
})
|
||||
```
|
||||
|
||||
### memory_list
|
||||
```
|
||||
memory_list({
|
||||
category: "instruction",
|
||||
sortBy: "importance",
|
||||
limit: 20
|
||||
})
|
||||
```
|
||||
|
||||
## Debugging
|
||||
|
||||
Inspect what your agent knows:
|
||||
```bash
|
||||
sqlite3 ~/.openclaw/memory/tools/memory.db "SELECT id, category, content FROM memories"
|
||||
```
|
||||
|
||||
Export all memories:
|
||||
```bash
|
||||
openclaw memory-tools export > memories.json
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Database connection not open" error:**
|
||||
- Hard restart the gateway: `pkill -f openclaw-gateway`
|
||||
- Check permissions: `chown -R $(whoami) ~/.openclaw/memory/tools`
|
||||
|
||||
**Plugin not loading:**
|
||||
- Verify build: `ls skills/memory-tools/dist/index.js`
|
||||
- Check doctor: `openclaw doctor --non-interactive`
|
||||
|
||||
## License
|
||||
|
||||
MIT — [Purple Horizons](https://github.com/Purple-Horizons)
|
||||
6
skills/memory-tools/_meta.json
Normal file
6
skills/memory-tools/_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7ajybsnj998tbfcgvrc7731s803brp",
|
||||
"slug": "memory-tools",
|
||||
"version": "1.1.1",
|
||||
"publishedAt": 1770662268370
|
||||
}
|
||||
65
skills/memory-tools/clawdbot.plugin.json
Normal file
65
skills/memory-tools/clawdbot.plugin.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"id": "memory-tools",
|
||||
"kind": "memory",
|
||||
"name": "Memory Tools",
|
||||
"description": "Agent-controlled memory with confidence scoring, decay, and semantic search. The agent decides WHEN to store/retrieve memories.",
|
||||
"version": "1.0.0",
|
||||
"uiHints": {
|
||||
"embedding.apiKey": {
|
||||
"label": "OpenAI API Key",
|
||||
"sensitive": true,
|
||||
"placeholder": "sk-proj-...",
|
||||
"help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})"
|
||||
},
|
||||
"embedding.model": {
|
||||
"label": "Embedding Model",
|
||||
"placeholder": "text-embedding-3-small",
|
||||
"help": "OpenAI embedding model to use"
|
||||
},
|
||||
"dbPath": {
|
||||
"label": "Database Path",
|
||||
"placeholder": "~/.clawdbot/memory/tools",
|
||||
"advanced": true
|
||||
},
|
||||
"autoInjectInstructions": {
|
||||
"label": "Auto-Inject Instructions",
|
||||
"help": "Inject standing instructions (category='instruction') at conversation start"
|
||||
},
|
||||
"decayCheckInterval": {
|
||||
"label": "Decay Check Interval (hours)",
|
||||
"help": "How often to check for decayed memories (0 = disabled)"
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"embedding": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"description": "OpenAI API key. Supports ${OPENAI_API_KEY} syntax or falls back to OPENAI_API_KEY env var."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"enum": ["text-embedding-3-small", "text-embedding-3-large"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"dbPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"autoInjectInstructions": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"decayCheckInterval": {
|
||||
"type": "number",
|
||||
"default": 24
|
||||
}
|
||||
},
|
||||
"required": ["embedding"]
|
||||
}
|
||||
}
|
||||
65
skills/memory-tools/openclaw.plugin.json
Normal file
65
skills/memory-tools/openclaw.plugin.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"id": "memory-tools",
|
||||
"kind": "memory",
|
||||
"name": "Memory Tools",
|
||||
"description": "Agent-controlled memory with confidence scoring, decay, and semantic search. The agent decides WHEN to store/retrieve memories.",
|
||||
"version": "1.0.0",
|
||||
"uiHints": {
|
||||
"embedding.apiKey": {
|
||||
"label": "OpenAI API Key",
|
||||
"sensitive": true,
|
||||
"placeholder": "sk-proj-...",
|
||||
"help": "API key for OpenAI embeddings (or use ${OPENAI_API_KEY})"
|
||||
},
|
||||
"embedding.model": {
|
||||
"label": "Embedding Model",
|
||||
"placeholder": "text-embedding-3-small",
|
||||
"help": "OpenAI embedding model to use"
|
||||
},
|
||||
"dbPath": {
|
||||
"label": "Database Path",
|
||||
"placeholder": "~/.openclaw/memory/tools",
|
||||
"advanced": true
|
||||
},
|
||||
"autoInjectInstructions": {
|
||||
"label": "Auto-Inject Instructions",
|
||||
"help": "Inject standing instructions (category='instruction') at conversation start"
|
||||
},
|
||||
"decayCheckInterval": {
|
||||
"label": "Decay Check Interval (hours)",
|
||||
"help": "How often to check for decayed memories (0 = disabled)"
|
||||
}
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"embedding": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"apiKey": {
|
||||
"type": "string",
|
||||
"description": "OpenAI API key. Supports ${OPENAI_API_KEY} syntax or falls back to OPENAI_API_KEY env var."
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"enum": ["text-embedding-3-small", "text-embedding-3-large"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"dbPath": {
|
||||
"type": "string"
|
||||
},
|
||||
"autoInjectInstructions": {
|
||||
"type": "boolean",
|
||||
"default": true
|
||||
},
|
||||
"decayCheckInterval": {
|
||||
"type": "number",
|
||||
"default": 24
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
}
|
||||
}
|
||||
2705
skills/memory-tools/package-lock.json
generated
Normal file
2705
skills/memory-tools/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
59
skills/memory-tools/package.json
Normal file
59
skills/memory-tools/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "memory-tools",
|
||||
"version": "1.1.1",
|
||||
"description": "Memory-as-Tools plugin for OpenClaw - Agent-controlled persistent memory with confidence scoring and decay",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "oxlint",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lancedb/lancedb": "^0.23.0",
|
||||
"@sinclair/typebox": "0.34.48",
|
||||
"openai": "^6.17.0",
|
||||
"sql.js": "^1.13.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/sql.js": "^1.4.9",
|
||||
"@vitest/coverage-v8": "^2.1.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"openclaw": ">=2026.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"openclaw": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./dist/index.js"
|
||||
]
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"keywords": [
|
||||
"openclaw",
|
||||
"memory",
|
||||
"ai-agent",
|
||||
"plugin",
|
||||
"vector-search"
|
||||
],
|
||||
"author": "Purple Horizons",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/Purple-Horizons/memory-tools.git"
|
||||
}
|
||||
}
|
||||
7892
skills/memory-tools/pnpm-lock.yaml
generated
Normal file
7892
skills/memory-tools/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
70
skills/memory-tools/src/config.ts
Normal file
70
skills/memory-tools/src/config.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Configuration Schema for Memory-as-Tools Plugin
|
||||
*/
|
||||
|
||||
import { Type, type Static } from '@sinclair/typebox';
|
||||
import { MEMORY_CATEGORIES, VECTOR_DIMS } from './types.js';
|
||||
|
||||
export const embeddingConfigSchema = Type.Object({
|
||||
apiKey: Type.String(),
|
||||
model: Type.Optional(Type.Union([
|
||||
Type.Literal('text-embedding-3-small'),
|
||||
Type.Literal('text-embedding-3-large'),
|
||||
])),
|
||||
});
|
||||
|
||||
export const memoryToolsConfigSchema = Type.Object({
|
||||
embedding: embeddingConfigSchema,
|
||||
dbPath: Type.Optional(Type.String()),
|
||||
autoInjectInstructions: Type.Optional(Type.Boolean()),
|
||||
decayCheckInterval: Type.Optional(Type.Number()),
|
||||
});
|
||||
|
||||
export type MemoryToolsConfig = Static<typeof memoryToolsConfigSchema>;
|
||||
|
||||
/**
|
||||
* Expand environment variables in a string.
|
||||
* Supports ${VAR_NAME} syntax.
|
||||
*/
|
||||
function expandEnvVars(value: string): string {
|
||||
return value.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] ?? '';
|
||||
});
|
||||
}
|
||||
|
||||
export function parseConfig(raw: unknown): MemoryToolsConfig {
|
||||
const config = (raw ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Handle missing or empty embedding config
|
||||
const embedding = (config.embedding ?? {}) as Record<string, unknown>;
|
||||
let apiKey = embedding.apiKey as string | undefined;
|
||||
|
||||
// Support environment variable expansion
|
||||
if (apiKey) {
|
||||
apiKey = expandEnvVars(apiKey);
|
||||
}
|
||||
|
||||
// Fall back to OPENAI_API_KEY env var if not configured
|
||||
if (!apiKey) {
|
||||
apiKey = process.env.OPENAI_API_KEY;
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
'Missing OpenAI API key. Set embedding.apiKey in config, use ${OPENAI_API_KEY}, or set OPENAI_API_KEY environment variable.'
|
||||
);
|
||||
}
|
||||
|
||||
const model = (embedding.model as string) || 'text-embedding-3-small';
|
||||
return {
|
||||
embedding: {
|
||||
apiKey,
|
||||
model: model as 'text-embedding-3-small' | 'text-embedding-3-large',
|
||||
},
|
||||
dbPath: (config.dbPath as string) || '~/.openclaw/memory/tools',
|
||||
autoInjectInstructions: config.autoInjectInstructions !== false,
|
||||
decayCheckInterval: (config.decayCheckInterval as number) ?? 24,
|
||||
};
|
||||
}
|
||||
|
||||
export { MEMORY_CATEGORIES, VECTOR_DIMS };
|
||||
38
skills/memory-tools/src/embeddings.ts
Normal file
38
skills/memory-tools/src/embeddings.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Embedding Provider
|
||||
*
|
||||
* Handles text -> vector embedding via OpenAI API
|
||||
*/
|
||||
|
||||
import OpenAI from 'openai';
|
||||
|
||||
export class EmbeddingProvider {
|
||||
private client: OpenAI;
|
||||
private model: string;
|
||||
|
||||
constructor(apiKey: string, model: string = 'text-embedding-3-small') {
|
||||
this.client = new OpenAI({ apiKey });
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const response = await this.client.embeddings.create({
|
||||
model: this.model,
|
||||
input: text,
|
||||
});
|
||||
return response.data[0].embedding;
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
if (texts.length === 0) return [];
|
||||
|
||||
const response = await this.client.embeddings.create({
|
||||
model: this.model,
|
||||
input: texts,
|
||||
});
|
||||
|
||||
return response.data
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map(d => d.embedding);
|
||||
}
|
||||
}
|
||||
267
skills/memory-tools/src/index.ts
Normal file
267
skills/memory-tools/src/index.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* OpenClaw Memory-as-Tools Plugin
|
||||
*
|
||||
* Agent-controlled memory with confidence scoring, decay, and semantic search.
|
||||
* The agent decides WHEN to store/retrieve memories (AgeMem pattern).
|
||||
*
|
||||
* Key features:
|
||||
* - Six memory tools: store, update, forget, search, summarize, list
|
||||
* - Hybrid SQLite + LanceDB storage
|
||||
* - Confidence scoring (how accurate)
|
||||
* - Importance scoring (how critical)
|
||||
* - Decay/expiration for temporal memories
|
||||
* - Auto-inject standing instructions at conversation start
|
||||
*/
|
||||
|
||||
import type { OpenClawPluginApi } from './plugin-types.js';
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import { parseConfig } from './config.js';
|
||||
import { vectorDimsForModel, MEMORY_CATEGORIES } from './types.js';
|
||||
import { EmbeddingProvider } from './embeddings.js';
|
||||
import { MemoryStore } from './store.js';
|
||||
import { createMemoryTools } from './tools.js';
|
||||
|
||||
// System prompt addition to guide agent on memory usage
|
||||
const MEMORY_SYSTEM_PROMPT = `
|
||||
## Memory Management
|
||||
|
||||
You have access to persistent memory tools. Use them thoughtfully:
|
||||
|
||||
**STORE** new memories when:
|
||||
- User shares personal information (names, dates, preferences)
|
||||
- User gives standing instructions ("always...", "never...", "I prefer...")
|
||||
- User mentions relationships ("my wife", "my boss")
|
||||
- Something would be useful in future conversations
|
||||
|
||||
**SEARCH** memories when:
|
||||
- Starting a new conversation (get context)
|
||||
- User references the past ("remember when...")
|
||||
- Personalizing responses
|
||||
- Before storing (avoid duplicates)
|
||||
|
||||
**UPDATE** when information changes or becomes more accurate.
|
||||
|
||||
**FORGET** when user requests or info becomes obsolete.
|
||||
|
||||
### Guidelines
|
||||
|
||||
1. **Be selective** — Don't store everything. Store what matters.
|
||||
2. **Be atomic** — One fact per memory. "User likes coffee and tea" → two memories.
|
||||
3. **Be confident** — Use confidence scores honestly:
|
||||
- 1.0 = User explicitly stated
|
||||
- 0.7-0.9 = User strongly implied
|
||||
- 0.5-0.7 = Inferred from context
|
||||
4. **Use decay** — Events should decay (decayDays). Facts usually shouldn't.
|
||||
5. **Check first** — Search before storing to avoid duplicates.
|
||||
`;
|
||||
|
||||
// Plugin definition
|
||||
const memoryToolsPlugin = {
|
||||
id: 'memory-tools',
|
||||
name: 'Memory Tools',
|
||||
description: 'Agent-controlled memory with confidence scoring, decay, and semantic search',
|
||||
kind: 'memory' as const,
|
||||
|
||||
register(api: OpenClawPluginApi) {
|
||||
const cfg = parseConfig(api.pluginConfig);
|
||||
const resolvedDbPath = api.resolvePath(cfg.dbPath!);
|
||||
const model = cfg.embedding.model ?? 'text-embedding-3-small';
|
||||
const vectorDim = vectorDimsForModel(model);
|
||||
|
||||
const embeddings = new EmbeddingProvider(cfg.embedding.apiKey, model);
|
||||
const store = new MemoryStore(resolvedDbPath, embeddings, vectorDim);
|
||||
const tools = createMemoryTools(store);
|
||||
|
||||
api.logger.info(`memory-tools: initialized (db: ${resolvedDbPath}, model: ${model})`);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Register Tools
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: tools.memory_store.name,
|
||||
label: tools.memory_store.label,
|
||||
description: tools.memory_store.description,
|
||||
parameters: tools.memory_store.parameters,
|
||||
execute: (id, params) => tools.memory_store.execute(id, params as any, {}),
|
||||
},
|
||||
{ name: 'memory_store' }
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: tools.memory_update.name,
|
||||
label: tools.memory_update.label,
|
||||
description: tools.memory_update.description,
|
||||
parameters: tools.memory_update.parameters,
|
||||
execute: (id, params) => tools.memory_update.execute(id, params as any),
|
||||
},
|
||||
{ name: 'memory_update' }
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: tools.memory_forget.name,
|
||||
label: tools.memory_forget.label,
|
||||
description: tools.memory_forget.description,
|
||||
parameters: tools.memory_forget.parameters,
|
||||
execute: (id, params) => tools.memory_forget.execute(id, params as any),
|
||||
},
|
||||
{ name: 'memory_forget' }
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: tools.memory_search.name,
|
||||
label: tools.memory_search.label,
|
||||
description: tools.memory_search.description,
|
||||
parameters: tools.memory_search.parameters,
|
||||
execute: (id, params) => tools.memory_search.execute(id, params as any),
|
||||
},
|
||||
{ name: 'memory_search' }
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: tools.memory_summarize.name,
|
||||
label: tools.memory_summarize.label,
|
||||
description: tools.memory_summarize.description,
|
||||
parameters: tools.memory_summarize.parameters,
|
||||
execute: (id, params) => tools.memory_summarize.execute(id, params as any),
|
||||
},
|
||||
{ name: 'memory_summarize' }
|
||||
);
|
||||
|
||||
api.registerTool(
|
||||
{
|
||||
name: tools.memory_list.name,
|
||||
label: tools.memory_list.label,
|
||||
description: tools.memory_list.description,
|
||||
parameters: tools.memory_list.parameters,
|
||||
execute: (id, params) => tools.memory_list.execute(id, params as any),
|
||||
},
|
||||
{ name: 'memory_list' }
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Lifecycle Hooks
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
// Auto-inject standing instructions at conversation start
|
||||
if (cfg.autoInjectInstructions !== false) {
|
||||
api.on('before_agent_start', async (event: { prompt?: string }) => {
|
||||
// Get standing instructions
|
||||
const instructions = store.getByCategory('instruction', 10);
|
||||
|
||||
if (instructions.length === 0) {
|
||||
return { systemPrompt: MEMORY_SYSTEM_PROMPT };
|
||||
}
|
||||
|
||||
const instructionList = instructions
|
||||
.map(m => `- ${m.content}`)
|
||||
.join('\n');
|
||||
|
||||
api.logger.info?.(`memory-tools: injecting ${instructions.length} standing instructions`);
|
||||
|
||||
return {
|
||||
systemPrompt: MEMORY_SYSTEM_PROMPT,
|
||||
prependContext: `<standing-instructions>\nRemember these user instructions:\n${instructionList}\n</standing-instructions>`,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// CLI Commands
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
api.registerCli(
|
||||
({ program }: { program: any }) => {
|
||||
const memory = program
|
||||
.command('memory-tools')
|
||||
.description('Memory-as-Tools plugin commands');
|
||||
|
||||
memory
|
||||
.command('stats')
|
||||
.description('Show memory statistics')
|
||||
.action(() => {
|
||||
const total = store.count();
|
||||
const instructions = store.getByCategory('instruction').length;
|
||||
const facts = store.getByCategory('fact').length;
|
||||
const preferences = store.getByCategory('preference').length;
|
||||
|
||||
console.log(`Memory Statistics:`);
|
||||
console.log(` Total: ${total}`);
|
||||
console.log(` Instructions: ${instructions}`);
|
||||
console.log(` Facts: ${facts}`);
|
||||
console.log(` Preferences: ${preferences}`);
|
||||
});
|
||||
|
||||
memory
|
||||
.command('list')
|
||||
.description('List memories')
|
||||
.option('-c, --category <category>', 'Filter by category')
|
||||
.option('-l, --limit <n>', 'Max results', '20')
|
||||
.action((opts: { category?: string; limit?: string }) => {
|
||||
const results = store.list({
|
||||
category: opts.category as any,
|
||||
limit: parseInt(opts.limit ?? '20'),
|
||||
});
|
||||
|
||||
console.log(`Showing ${results.items.length} of ${results.total} memories:\n`);
|
||||
for (const m of results.items) {
|
||||
console.log(`[${m.id.slice(0, 8)}] [${m.category}] ${m.content.slice(0, 60)}...`);
|
||||
}
|
||||
});
|
||||
|
||||
memory
|
||||
.command('search <query>')
|
||||
.description('Search memories')
|
||||
.option('-l, --limit <n>', 'Max results', '10')
|
||||
.action(async (query: string, opts: { limit?: string }) => {
|
||||
const results = await store.search({
|
||||
query,
|
||||
limit: parseInt(opts.limit ?? '10'),
|
||||
});
|
||||
|
||||
console.log(`Found ${results.length} memories:\n`);
|
||||
for (const r of results) {
|
||||
console.log(`[${r.memory.id.slice(0, 8)}] (${(r.score * 100).toFixed(0)}%) ${r.memory.content}`);
|
||||
}
|
||||
});
|
||||
|
||||
memory
|
||||
.command('export')
|
||||
.description('Export all memories as JSON')
|
||||
.action(() => {
|
||||
const results = store.list({ limit: 10000 });
|
||||
console.log(JSON.stringify(results.items, null, 2));
|
||||
});
|
||||
},
|
||||
{ commands: ['memory-tools'] }
|
||||
);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Service (lifecycle management)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
api.registerService({
|
||||
id: 'memory-tools',
|
||||
start: () => {
|
||||
api.logger.info(`memory-tools: service started (${store.count()} memories)`);
|
||||
},
|
||||
stop: () => {
|
||||
store.close();
|
||||
api.logger.info('memory-tools: service stopped');
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default memoryToolsPlugin;
|
||||
|
||||
// Re-export types for external use
|
||||
export * from './types.js';
|
||||
export { MemoryStore } from './store.js';
|
||||
export { EmbeddingProvider } from './embeddings.js';
|
||||
export { createMemoryTools } from './tools.js';
|
||||
48
skills/memory-tools/src/plugin-types.ts
Normal file
48
skills/memory-tools/src/plugin-types.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* OpenClaw Plugin SDK Types
|
||||
*
|
||||
* Minimal type definitions for OpenClaw plugin development.
|
||||
* These match the OpenClaw plugin API.
|
||||
*/
|
||||
|
||||
export interface PluginLogger {
|
||||
debug?: (message: string) => void;
|
||||
info: (message: string) => void;
|
||||
warn: (message: string) => void;
|
||||
error: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface OpenClawPluginApi {
|
||||
id: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
source: string;
|
||||
config: Record<string, unknown>;
|
||||
pluginConfig?: Record<string, unknown>;
|
||||
logger: PluginLogger;
|
||||
registerTool: (tool: AnyAgentTool, opts?: { name?: string }) => void;
|
||||
registerHook: (events: string | string[], handler: unknown, opts?: unknown) => void;
|
||||
registerCli: (registrar: (ctx: { program: unknown }) => void, opts?: { commands?: string[] }) => void;
|
||||
registerService: (service: { id: string; start: () => void; stop?: () => void }) => void;
|
||||
resolvePath: (input: string) => string;
|
||||
on: (hookName: string, handler: unknown, opts?: { priority?: number }) => void;
|
||||
}
|
||||
|
||||
export interface AnyAgentTool {
|
||||
name: string;
|
||||
label?: string;
|
||||
description: string;
|
||||
parameters: unknown;
|
||||
execute: (toolCallId: string, params: unknown) => Promise<ToolResult>;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
content: Array<{ type: 'text'; text: string }>;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PluginHookBeforeAgentStartResult {
|
||||
systemPrompt?: string;
|
||||
prependContext?: string;
|
||||
}
|
||||
422
skills/memory-tools/src/store.test.ts
Normal file
422
skills/memory-tools/src/store.test.ts
Normal file
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* Memory Store Tests
|
||||
*
|
||||
* Tests for hybrid SQLite + LanceDB storage layer.
|
||||
* Uses mocked embeddings to avoid API calls.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { MemoryStore } from './store.js';
|
||||
import { EmbeddingProvider } from './embeddings.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
// Mock embedding provider
|
||||
class MockEmbeddingProvider {
|
||||
private counter = 0;
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
// Generate deterministic pseudo-random vector based on text hash
|
||||
const hash = this.hashString(text);
|
||||
const vector = new Array(1536).fill(0).map((_, i) =>
|
||||
Math.sin(hash + i * 0.1) * 0.5 + 0.5
|
||||
);
|
||||
return vector;
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
return Promise.all(texts.map(t => this.embed(t)));
|
||||
}
|
||||
|
||||
private hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
describe('MemoryStore', () => {
|
||||
let store: MemoryStore;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `memory-test-${Date.now()}`);
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
|
||||
const embeddings = new MockEmbeddingProvider() as unknown as EmbeddingProvider;
|
||||
store = new MemoryStore(testDir, embeddings, 1536);
|
||||
await store.init(); // Initialize sql.js WASM before tests
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a memory with all fields', async () => {
|
||||
const memory = await store.create({
|
||||
content: 'User prefers dark mode',
|
||||
category: 'preference',
|
||||
confidence: 0.9,
|
||||
importance: 0.7,
|
||||
tags: ['ui', 'settings'],
|
||||
});
|
||||
|
||||
expect(memory.id).toBeDefined();
|
||||
expect(memory.content).toBe('User prefers dark mode');
|
||||
expect(memory.category).toBe('preference');
|
||||
expect(memory.confidence).toBe(0.9);
|
||||
expect(memory.importance).toBe(0.7);
|
||||
expect(memory.tags).toEqual(['ui', 'settings']);
|
||||
expect(memory.createdAt).toBeGreaterThan(0);
|
||||
expect(memory.deletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use default values', async () => {
|
||||
const memory = await store.create({
|
||||
content: 'Test memory',
|
||||
category: 'fact',
|
||||
});
|
||||
|
||||
expect(memory.confidence).toBe(0.8);
|
||||
expect(memory.importance).toBe(0.5);
|
||||
expect(memory.tags).toEqual([]);
|
||||
});
|
||||
|
||||
it('should support decay days', async () => {
|
||||
const memory = await store.create({
|
||||
content: 'Meeting tomorrow',
|
||||
category: 'event',
|
||||
decayDays: 7,
|
||||
});
|
||||
|
||||
expect(memory.decayDays).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should retrieve a memory by id', async () => {
|
||||
const created = await store.create({
|
||||
content: 'Test content',
|
||||
category: 'fact',
|
||||
});
|
||||
|
||||
const retrieved = store.get(created.id);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved!.id).toBe(created.id);
|
||||
expect(retrieved!.content).toBe('Test content');
|
||||
});
|
||||
|
||||
it('should return null for non-existent id', () => {
|
||||
const result = store.get('non-existent-id');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update content', async () => {
|
||||
const memory = await store.create({
|
||||
content: 'Original content',
|
||||
category: 'fact',
|
||||
});
|
||||
|
||||
const updated = await store.update(memory.id, {
|
||||
content: 'Updated content',
|
||||
});
|
||||
|
||||
expect(updated.content).toBe('Updated content');
|
||||
expect(updated.updatedAt).toBeGreaterThan(memory.updatedAt);
|
||||
});
|
||||
|
||||
it('should update confidence and importance', async () => {
|
||||
const memory = await store.create({
|
||||
content: 'Test',
|
||||
category: 'fact',
|
||||
confidence: 0.5,
|
||||
importance: 0.3,
|
||||
});
|
||||
|
||||
const updated = await store.update(memory.id, {
|
||||
confidence: 0.9,
|
||||
importance: 0.8,
|
||||
});
|
||||
|
||||
expect(updated.confidence).toBe(0.9);
|
||||
expect(updated.importance).toBe(0.8);
|
||||
});
|
||||
|
||||
it('should update tags', async () => {
|
||||
const memory = await store.create({
|
||||
content: 'Test',
|
||||
category: 'fact',
|
||||
tags: ['old'],
|
||||
});
|
||||
|
||||
const updated = await store.update(memory.id, {
|
||||
tags: ['new', 'tags'],
|
||||
});
|
||||
|
||||
expect(updated.tags).toEqual(['new', 'tags']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should soft delete a memory', async () => {
|
||||
const memory = await store.create({
|
||||
content: 'To be deleted',
|
||||
category: 'fact',
|
||||
});
|
||||
|
||||
await store.delete(memory.id, 'Test deletion');
|
||||
|
||||
const retrieved = store.get(memory.id);
|
||||
expect(retrieved!.deletedAt).toBeDefined();
|
||||
expect(retrieved!.deleteReason).toBe('Test deletion');
|
||||
});
|
||||
|
||||
it('should delete using short ID (first 8 chars)', async () => {
|
||||
const memory = await store.create({
|
||||
content: 'Delete with short ID',
|
||||
category: 'fact',
|
||||
});
|
||||
|
||||
const shortId = memory.id.slice(0, 8);
|
||||
await store.delete(shortId, 'Short ID deletion');
|
||||
|
||||
const retrieved = store.get(memory.id);
|
||||
expect(retrieved!.deletedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should get using short ID (first 8 chars)', async () => {
|
||||
const memory = await store.create({
|
||||
content: 'Get with short ID',
|
||||
category: 'fact',
|
||||
});
|
||||
|
||||
const shortId = memory.id.slice(0, 8);
|
||||
const retrieved = store.get(shortId);
|
||||
|
||||
expect(retrieved).not.toBeNull();
|
||||
expect(retrieved!.id).toBe(memory.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('search', () => {
|
||||
beforeEach(async () => {
|
||||
await store.create({
|
||||
content: 'User loves coffee',
|
||||
category: 'preference',
|
||||
confidence: 0.9,
|
||||
});
|
||||
await store.create({
|
||||
content: 'User hates tea',
|
||||
category: 'preference',
|
||||
confidence: 0.8,
|
||||
});
|
||||
await store.create({
|
||||
content: 'Meeting at 3pm',
|
||||
category: 'event',
|
||||
confidence: 0.95,
|
||||
});
|
||||
});
|
||||
|
||||
it('should search by semantic query', async () => {
|
||||
const results = await store.search({
|
||||
query: 'coffee preference',
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].score).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should filter by category', async () => {
|
||||
const results = await store.search({
|
||||
category: 'preference',
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(results.every(r => r.memory.category === 'preference')).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by minimum confidence', async () => {
|
||||
const results = await store.search({
|
||||
minConfidence: 0.85,
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(results.every(r => r.memory.confidence >= 0.85)).toBe(true);
|
||||
});
|
||||
|
||||
it('should respect limit', async () => {
|
||||
const results = await store.search({
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
beforeEach(async () => {
|
||||
await store.create({ content: 'First', category: 'fact', importance: 0.3 });
|
||||
await store.create({ content: 'Second', category: 'fact', importance: 0.9 });
|
||||
await store.create({ content: 'Third', category: 'preference', importance: 0.5 });
|
||||
});
|
||||
|
||||
it('should list all memories', () => {
|
||||
const results = store.list();
|
||||
|
||||
expect(results.total).toBe(3);
|
||||
expect(results.items.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should filter by category', () => {
|
||||
const results = store.list({ category: 'fact' });
|
||||
|
||||
expect(results.total).toBe(2);
|
||||
expect(results.items.every(m => m.category === 'fact')).toBe(true);
|
||||
});
|
||||
|
||||
it('should sort by importance', () => {
|
||||
const results = store.list({ sortBy: 'importance', sortOrder: 'desc' });
|
||||
|
||||
expect(results.items[0].importance).toBeGreaterThanOrEqual(results.items[1].importance);
|
||||
});
|
||||
|
||||
it('should paginate', () => {
|
||||
const page1 = store.list({ limit: 2, offset: 0 });
|
||||
const page2 = store.list({ limit: 2, offset: 2 });
|
||||
|
||||
expect(page1.items.length).toBe(2);
|
||||
expect(page2.items.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findDuplicates', () => {
|
||||
it('should find similar memories', async () => {
|
||||
await store.create({
|
||||
content: 'User prefers dark mode',
|
||||
category: 'preference',
|
||||
});
|
||||
|
||||
const duplicates = await store.findDuplicates('User likes dark mode', 0.5);
|
||||
|
||||
expect(duplicates.length).toBe(1);
|
||||
expect(duplicates[0].memory.content).toBe('User prefers dark mode');
|
||||
});
|
||||
|
||||
it('should return empty for dissimilar content', async () => {
|
||||
await store.create({
|
||||
content: 'User prefers dark mode',
|
||||
category: 'preference',
|
||||
});
|
||||
|
||||
const duplicates = await store.findDuplicates('Meeting tomorrow at 3pm', 0.95);
|
||||
|
||||
expect(duplicates.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('count', () => {
|
||||
it('should count all non-deleted memories', async () => {
|
||||
expect(store.count()).toBe(0);
|
||||
|
||||
await store.create({ content: 'First', category: 'fact' });
|
||||
await store.create({ content: 'Second', category: 'fact' });
|
||||
|
||||
expect(store.count()).toBe(2);
|
||||
|
||||
const m = await store.create({ content: 'Third', category: 'fact' });
|
||||
await store.delete(m.id);
|
||||
|
||||
expect(store.count()).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getByCategory', () => {
|
||||
it('should get memories by category sorted by importance', async () => {
|
||||
await store.create({ content: 'Low', category: 'instruction', importance: 0.3 });
|
||||
await store.create({ content: 'High', category: 'instruction', importance: 0.9 });
|
||||
await store.create({ content: 'Medium', category: 'instruction', importance: 0.6 });
|
||||
await store.create({ content: 'Other', category: 'fact', importance: 1.0 });
|
||||
|
||||
const results = store.getByCategory('instruction');
|
||||
|
||||
expect(results.length).toBe(3);
|
||||
expect(results[0].content).toBe('High');
|
||||
expect(results[1].content).toBe('Medium');
|
||||
expect(results[2].content).toBe('Low');
|
||||
});
|
||||
});
|
||||
|
||||
describe('touchMany', () => {
|
||||
it('should update lastAccessedAt for multiple memories', async () => {
|
||||
const m1 = await store.create({ content: 'First', category: 'fact' });
|
||||
const m2 = await store.create({ content: 'Second', category: 'fact' });
|
||||
|
||||
const originalAccess = store.get(m1.id)!.lastAccessedAt;
|
||||
|
||||
// Wait a bit to ensure timestamp changes
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
store.touchMany([m1.id, m2.id]);
|
||||
|
||||
const updated = store.get(m1.id)!;
|
||||
expect(updated.lastAccessedAt).toBeGreaterThan(originalAccess);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close and reopen', () => {
|
||||
it('should allow database to be reopened after close (SIGUSR1 restart scenario)', async () => {
|
||||
// Create a memory before close
|
||||
const memory = await store.create({
|
||||
content: 'Persistent memory',
|
||||
category: 'fact',
|
||||
importance: 0.8,
|
||||
});
|
||||
|
||||
// Close the database (simulates service stop)
|
||||
store.close();
|
||||
|
||||
// Reopen by calling an async method (simulates service restart)
|
||||
// This should re-initialize both SQLite and LanceDB
|
||||
await store.init();
|
||||
|
||||
// Verify we can still read the persisted memory
|
||||
const retrieved = store.get(memory.id);
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved!.content).toBe('Persistent memory');
|
||||
|
||||
// Verify we can create new memories
|
||||
const newMemory = await store.create({
|
||||
content: 'New memory after restart',
|
||||
category: 'fact',
|
||||
});
|
||||
expect(newMemory.id).toBeDefined();
|
||||
|
||||
// Verify search still works (uses LanceDB)
|
||||
const results = await store.search('Persistent', { limit: 5 });
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should handle multiple close/reopen cycles', async () => {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await store.create({ content: `Cycle ${i}`, category: 'fact' });
|
||||
store.close();
|
||||
await store.init();
|
||||
}
|
||||
|
||||
// Should have all 3 memories
|
||||
const all = store.list();
|
||||
expect(all.total).toBe(3);
|
||||
expect(all.items.length).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
614
skills/memory-tools/src/store.ts
Normal file
614
skills/memory-tools/src/store.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
/**
|
||||
* Hybrid Memory Store
|
||||
*
|
||||
* SQLite (via sql.js/WASM) for metadata (fast queries, debuggable, no native deps)
|
||||
* LanceDB for vectors (semantic search)
|
||||
*/
|
||||
|
||||
import initSqlJs, { type Database as SqlJsDatabase, type SqlValue } from 'sql.js';
|
||||
import * as lancedb from '@lancedb/lancedb';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import type {
|
||||
Memory,
|
||||
MemorySearchResult,
|
||||
CreateMemoryInput,
|
||||
UpdateMemoryInput,
|
||||
SearchOptions,
|
||||
ListOptions,
|
||||
MemoryCategory,
|
||||
} from './types.js';
|
||||
import { EmbeddingProvider } from './embeddings.js';
|
||||
|
||||
const VECTOR_TABLE = 'memory_vectors';
|
||||
|
||||
export class MemoryStore {
|
||||
private db: SqlJsDatabase | null = null;
|
||||
private vectorDb: lancedb.Connection | null = null;
|
||||
private vectorTable: lancedb.Table | null = null;
|
||||
private embeddings: EmbeddingProvider;
|
||||
private vectorDim: number;
|
||||
private dbPath: string;
|
||||
private dbFilePath: string;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
private sqliteInitPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
dbPath: string,
|
||||
embeddings: EmbeddingProvider,
|
||||
vectorDim: number
|
||||
) {
|
||||
this.dbPath = dbPath;
|
||||
this.embeddings = embeddings;
|
||||
this.vectorDim = vectorDim;
|
||||
this.dbFilePath = path.join(dbPath, 'memory.db');
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(dbPath)) {
|
||||
fs.mkdirSync(dbPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database. Call this before using sync methods.
|
||||
* Async methods will auto-initialize, but this is useful for tests
|
||||
* or when you need to use sync methods without calling async ones first.
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
await this.ensureSqlite();
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure SQLite is initialized (lazy async init for sql.js WASM)
|
||||
*/
|
||||
private async ensureSqlite(): Promise<SqlJsDatabase> {
|
||||
if (this.db) return this.db;
|
||||
if (this.sqliteInitPromise) {
|
||||
await this.sqliteInitPromise;
|
||||
return this.db!;
|
||||
}
|
||||
|
||||
this.sqliteInitPromise = this.initSqlite();
|
||||
await this.sqliteInitPromise;
|
||||
return this.db!;
|
||||
}
|
||||
|
||||
private async initSqlite(): Promise<void> {
|
||||
const SQL = await initSqlJs();
|
||||
|
||||
// Load existing database if it exists
|
||||
if (fs.existsSync(this.dbFilePath)) {
|
||||
const buffer = fs.readFileSync(this.dbFilePath);
|
||||
this.db = new SQL.Database(buffer);
|
||||
} else {
|
||||
this.db = new SQL.Database();
|
||||
}
|
||||
|
||||
// Create schema
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
confidence REAL DEFAULT 0.8,
|
||||
importance REAL DEFAULT 0.5,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
last_accessed_at INTEGER,
|
||||
decay_days INTEGER,
|
||||
source_channel TEXT,
|
||||
source_message_id TEXT,
|
||||
tags TEXT,
|
||||
supersedes TEXT,
|
||||
deleted_at INTEGER,
|
||||
delete_reason TEXT
|
||||
)
|
||||
`);
|
||||
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category)`);
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_memories_confidence ON memories(confidence)`);
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance)`);
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_memories_created ON memories(created_at)`);
|
||||
this.db.run(`CREATE INDEX IF NOT EXISTS idx_memories_deleted ON memories(deleted_at)`);
|
||||
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist database to disk
|
||||
*/
|
||||
private save(): void {
|
||||
if (!this.db) return;
|
||||
const data = this.db.export();
|
||||
fs.writeFileSync(this.dbFilePath, Buffer.from(data));
|
||||
}
|
||||
|
||||
private async ensureVectorDb(): Promise<void> {
|
||||
if (this.vectorTable) return;
|
||||
if (this.initPromise) return this.initPromise;
|
||||
|
||||
this.initPromise = this.initVectorDb();
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async initVectorDb(): Promise<void> {
|
||||
const vectorPath = path.join(this.dbPath, 'vectors');
|
||||
this.vectorDb = await lancedb.connect(vectorPath);
|
||||
|
||||
const tables = await this.vectorDb.tableNames();
|
||||
|
||||
if (tables.includes(VECTOR_TABLE)) {
|
||||
this.vectorTable = await this.vectorDb.openTable(VECTOR_TABLE);
|
||||
} else {
|
||||
// Create with schema row then delete it
|
||||
this.vectorTable = await this.vectorDb.createTable(VECTOR_TABLE, [{
|
||||
id: '__schema__',
|
||||
vector: new Array(this.vectorDim).fill(0),
|
||||
text: '',
|
||||
}]);
|
||||
await this.vectorTable.delete('id = "__schema__"');
|
||||
}
|
||||
}
|
||||
|
||||
async create(input: CreateMemoryInput): Promise<Memory> {
|
||||
const db = await this.ensureSqlite();
|
||||
await this.ensureVectorDb();
|
||||
|
||||
const id = randomUUID();
|
||||
const now = Date.now();
|
||||
|
||||
// Generate embedding
|
||||
const vector = await this.embeddings.embed(input.content);
|
||||
|
||||
// Store vector in LanceDB
|
||||
await this.vectorTable!.add([{
|
||||
id,
|
||||
vector,
|
||||
text: input.content,
|
||||
}]);
|
||||
|
||||
// Store metadata in SQLite
|
||||
db.run(`
|
||||
INSERT INTO memories (
|
||||
id, content, category, confidence, importance,
|
||||
created_at, updated_at, last_accessed_at, decay_days,
|
||||
source_channel, source_message_id, tags
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
id,
|
||||
input.content,
|
||||
input.category,
|
||||
input.confidence ?? 0.8,
|
||||
input.importance ?? 0.5,
|
||||
now,
|
||||
now,
|
||||
now,
|
||||
input.decayDays ?? null,
|
||||
input.sourceChannel ?? null,
|
||||
input.sourceMessageId ?? null,
|
||||
JSON.stringify(input.tags ?? [])
|
||||
]);
|
||||
|
||||
this.save();
|
||||
return (await this.getAsync(id))!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of get for internal use
|
||||
*/
|
||||
private async getAsync(id: string): Promise<Memory | null> {
|
||||
const db = await this.ensureSqlite();
|
||||
return this.getFromDb(db, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync get - requires ensureSqlite() to have been called first
|
||||
*/
|
||||
get(id: string): Memory | null {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized. Call an async method first or use getAsync().');
|
||||
}
|
||||
return this.getFromDb(this.db, id);
|
||||
}
|
||||
|
||||
private getFromDb(db: SqlJsDatabase, id: string): Memory | null {
|
||||
let query = 'SELECT * FROM memories WHERE id = ?';
|
||||
let param: string = id;
|
||||
|
||||
if (id.length === 8) {
|
||||
query = 'SELECT * FROM memories WHERE id LIKE ? LIMIT 1';
|
||||
param = `${id}%`;
|
||||
}
|
||||
|
||||
const stmt = db.prepare(query);
|
||||
stmt.bind([param]);
|
||||
|
||||
if (!stmt.step()) {
|
||||
stmt.free();
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = stmt.getAsObject() as Record<string, unknown>;
|
||||
stmt.free();
|
||||
return this.rowToMemory(row);
|
||||
}
|
||||
|
||||
async update(id: string, updates: UpdateMemoryInput): Promise<Memory> {
|
||||
const db = await this.ensureSqlite();
|
||||
await this.ensureVectorDb();
|
||||
|
||||
const sets: string[] = ['updated_at = ?'];
|
||||
const params: SqlValue[] = [Date.now()];
|
||||
|
||||
if (updates.content !== undefined) {
|
||||
sets.push('content = ?');
|
||||
params.push(updates.content);
|
||||
|
||||
// Re-embed and update vector
|
||||
const vector = await this.embeddings.embed(updates.content);
|
||||
await this.vectorTable!.update({
|
||||
where: `id = '${id}'`,
|
||||
values: { vector, text: updates.content },
|
||||
});
|
||||
}
|
||||
|
||||
if (updates.confidence !== undefined) {
|
||||
sets.push('confidence = ?');
|
||||
params.push(updates.confidence);
|
||||
}
|
||||
|
||||
if (updates.importance !== undefined) {
|
||||
sets.push('importance = ?');
|
||||
params.push(updates.importance);
|
||||
}
|
||||
|
||||
if (updates.decayDays !== undefined) {
|
||||
sets.push('decay_days = ?');
|
||||
params.push(updates.decayDays);
|
||||
}
|
||||
|
||||
if (updates.tags !== undefined) {
|
||||
sets.push('tags = ?');
|
||||
params.push(JSON.stringify(updates.tags));
|
||||
}
|
||||
|
||||
params.push(id);
|
||||
|
||||
db.run(`UPDATE memories SET ${sets.join(', ')} WHERE id = ?`, params as SqlValue[]);
|
||||
this.save();
|
||||
|
||||
return (await this.getAsync(id))!;
|
||||
}
|
||||
|
||||
async delete(id: string, reason?: string): Promise<void> {
|
||||
const db = await this.ensureSqlite();
|
||||
await this.ensureVectorDb();
|
||||
|
||||
// Support both full UUID and short ID (first 8 chars)
|
||||
let fullId = id;
|
||||
if (id.length === 8) {
|
||||
const stmt = db.prepare('SELECT id FROM memories WHERE id LIKE ? AND deleted_at IS NULL LIMIT 1');
|
||||
stmt.bind([`${id}%`]);
|
||||
if (stmt.step()) {
|
||||
const row = stmt.getAsObject() as { id: string };
|
||||
fullId = row.id;
|
||||
}
|
||||
stmt.free();
|
||||
}
|
||||
|
||||
// Soft delete in SQLite
|
||||
db.run(`
|
||||
UPDATE memories
|
||||
SET deleted_at = ?, delete_reason = ?
|
||||
WHERE id = ?
|
||||
`, [Date.now(), reason ?? null, fullId]);
|
||||
|
||||
this.save();
|
||||
|
||||
// Remove from vector index
|
||||
await this.vectorTable!.delete(`id = '${fullId}'`);
|
||||
}
|
||||
|
||||
async search(opts: SearchOptions): Promise<MemorySearchResult[]> {
|
||||
const db = await this.ensureSqlite();
|
||||
await this.ensureVectorDb();
|
||||
|
||||
let vectorIds: string[] = [];
|
||||
const vectorScores = new Map<string, number>();
|
||||
|
||||
// Semantic search if query provided
|
||||
if (opts.query) {
|
||||
const queryVector = await this.embeddings.embed(opts.query);
|
||||
const results = await this.vectorTable!
|
||||
.vectorSearch(queryVector)
|
||||
.limit((opts.limit ?? 10) * 2) // Over-fetch for filtering
|
||||
.toArray();
|
||||
|
||||
for (const row of results) {
|
||||
const distance = (row._distance as number) ?? 0;
|
||||
const score = 1 / (1 + distance); // Convert L2 distance to similarity
|
||||
vectorIds.push(row.id as string);
|
||||
vectorScores.set(row.id as string, score);
|
||||
}
|
||||
}
|
||||
|
||||
// Build SQL query
|
||||
let sql = 'SELECT * FROM memories WHERE deleted_at IS NULL';
|
||||
const params: SqlValue[] = [];
|
||||
|
||||
if (vectorIds.length > 0) {
|
||||
sql += ` AND id IN (${vectorIds.map(() => '?').join(',')})`;
|
||||
params.push(...vectorIds);
|
||||
}
|
||||
|
||||
if (opts.category) {
|
||||
sql += ' AND category = ?';
|
||||
params.push(opts.category);
|
||||
}
|
||||
|
||||
if (opts.minConfidence !== undefined) {
|
||||
sql += ' AND confidence >= ?';
|
||||
params.push(opts.minConfidence);
|
||||
}
|
||||
|
||||
if (opts.minImportance !== undefined) {
|
||||
sql += ' AND importance >= ?';
|
||||
params.push(opts.minImportance);
|
||||
}
|
||||
|
||||
if (opts.tags?.length) {
|
||||
for (const tag of opts.tags) {
|
||||
sql += ' AND tags LIKE ?';
|
||||
params.push(`%"${tag}"%`);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.excludeDecayed !== false) {
|
||||
sql += ` AND (decay_days IS NULL OR
|
||||
(created_at + decay_days * 86400000) > ?)`;
|
||||
params.push(Date.now());
|
||||
}
|
||||
|
||||
sql += ' LIMIT ?';
|
||||
params.push(opts.limit ?? 10);
|
||||
|
||||
const rows = this.queryAll(db, sql, params);
|
||||
|
||||
// Map results with scores
|
||||
const results: MemorySearchResult[] = rows.map(row => ({
|
||||
memory: this.rowToMemory(row),
|
||||
score: vectorScores.get(row.id as string) ?? 1.0,
|
||||
}));
|
||||
|
||||
// Sort by vector score if semantic search was used
|
||||
if (vectorIds.length > 0) {
|
||||
const idOrder = new Map(vectorIds.map((id, i) => [id, i]));
|
||||
results.sort((a, b) =>
|
||||
(idOrder.get(a.memory.id) ?? 999) - (idOrder.get(b.memory.id) ?? 999)
|
||||
);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of list
|
||||
*/
|
||||
async listAsync(opts: ListOptions = {}): Promise<{ total: number; items: Memory[] }> {
|
||||
const db = await this.ensureSqlite();
|
||||
return this.listFromDb(db, opts);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync list - requires database to be initialized
|
||||
*/
|
||||
list(opts: ListOptions = {}): { total: number; items: Memory[] } {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized. Call an async method first or use listAsync().');
|
||||
}
|
||||
return this.listFromDb(this.db, opts);
|
||||
}
|
||||
|
||||
private listFromDb(db: SqlJsDatabase, opts: ListOptions): { total: number; items: Memory[] } {
|
||||
const sortBy = opts.sortBy ?? 'created_at';
|
||||
const sortCol = sortBy.replace(/([A-Z])/g, '_$1').toLowerCase();
|
||||
const sortOrder = opts.sortOrder ?? 'desc';
|
||||
|
||||
let sql = 'SELECT * FROM memories WHERE deleted_at IS NULL';
|
||||
const params: SqlValue[] = [];
|
||||
|
||||
if (opts.category) {
|
||||
sql += ' AND category = ?';
|
||||
params.push(opts.category);
|
||||
}
|
||||
|
||||
const countSql = sql.replace('SELECT *', 'SELECT COUNT(*) as count');
|
||||
const countResult = this.queryOne(db, countSql, params as SqlValue[]) as { count: number };
|
||||
|
||||
sql += ` ORDER BY ${sortCol} ${sortOrder}`;
|
||||
sql += ' LIMIT ? OFFSET ?';
|
||||
params.push(opts.limit ?? 20, opts.offset ?? 0);
|
||||
|
||||
const rows = this.queryAll(db, sql, params);
|
||||
|
||||
return {
|
||||
total: countResult.count,
|
||||
items: rows.map(row => this.rowToMemory(row)),
|
||||
};
|
||||
}
|
||||
|
||||
async findDuplicates(content: string, threshold: number = 0.95): Promise<MemorySearchResult[]> {
|
||||
await this.ensureSqlite();
|
||||
await this.ensureVectorDb();
|
||||
|
||||
const vector = await this.embeddings.embed(content);
|
||||
const results = await this.vectorTable!
|
||||
.vectorSearch(vector)
|
||||
.limit(1)
|
||||
.toArray();
|
||||
|
||||
if (results.length === 0) return [];
|
||||
|
||||
const distance = (results[0]._distance as number) ?? 0;
|
||||
const score = 1 / (1 + distance);
|
||||
|
||||
if (score < threshold) return [];
|
||||
|
||||
const memory = await this.getAsync(results[0].id as string);
|
||||
if (!memory || memory.deletedAt) return [];
|
||||
|
||||
return [{ memory, score }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of touchMany
|
||||
*/
|
||||
async touchManyAsync(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const db = await this.ensureSqlite();
|
||||
this.touchManyInDb(db, ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync touchMany - requires database to be initialized
|
||||
*/
|
||||
touchMany(ids: string[]): void {
|
||||
if (ids.length === 0) return;
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized. Call an async method first or use touchManyAsync().');
|
||||
}
|
||||
this.touchManyInDb(this.db, ids);
|
||||
}
|
||||
|
||||
private touchManyInDb(db: SqlJsDatabase, ids: string[]): void {
|
||||
const now = Date.now();
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
db.run(`UPDATE memories SET last_accessed_at = ? WHERE id IN (${placeholders})`, [now, ...ids]);
|
||||
this.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of count
|
||||
*/
|
||||
async countAsync(): Promise<number> {
|
||||
const db = await this.ensureSqlite();
|
||||
return this.countFromDb(db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync count - requires database to be initialized
|
||||
*/
|
||||
count(): number {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized. Call an async method first or use countAsync().');
|
||||
}
|
||||
return this.countFromDb(this.db);
|
||||
}
|
||||
|
||||
private countFromDb(db: SqlJsDatabase): number {
|
||||
const result = this.queryOne(db, 'SELECT COUNT(*) as count FROM memories WHERE deleted_at IS NULL', []) as { count: number };
|
||||
return result.count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of getByCategory
|
||||
*/
|
||||
async getByCategoryAsync(category: MemoryCategory, limit: number = 50): Promise<Memory[]> {
|
||||
const db = await this.ensureSqlite();
|
||||
return this.getByCategoryFromDb(db, category, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync getByCategory - requires database to be initialized
|
||||
*/
|
||||
getByCategory(category: MemoryCategory, limit: number = 50): Memory[] {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not initialized. Call an async method first or use getByCategoryAsync().');
|
||||
}
|
||||
return this.getByCategoryFromDb(this.db, category, limit);
|
||||
}
|
||||
|
||||
private getByCategoryFromDb(db: SqlJsDatabase, category: MemoryCategory, limit: number): Memory[] {
|
||||
const rows = this.queryAll(db, `
|
||||
SELECT * FROM memories
|
||||
WHERE category = ? AND deleted_at IS NULL
|
||||
ORDER BY importance DESC, created_at DESC
|
||||
LIMIT ?
|
||||
`, [category, limit]);
|
||||
|
||||
return rows.map(row => this.rowToMemory(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to run a query and get all results as objects
|
||||
*/
|
||||
private queryAll(db: SqlJsDatabase, sql: string, params: SqlValue[]): Record<string, unknown>[] {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.bind(params);
|
||||
|
||||
const results: Record<string, unknown>[] = [];
|
||||
while (stmt.step()) {
|
||||
results.push(stmt.getAsObject() as Record<string, unknown>);
|
||||
}
|
||||
stmt.free();
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to run a query and get first result as object
|
||||
*/
|
||||
private queryOne(db: SqlJsDatabase, sql: string, params: SqlValue[]): Record<string, unknown> | null {
|
||||
const stmt = db.prepare(sql);
|
||||
stmt.bind(params);
|
||||
|
||||
if (!stmt.step()) {
|
||||
stmt.free();
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = stmt.getAsObject() as Record<string, unknown>;
|
||||
stmt.free();
|
||||
return row;
|
||||
}
|
||||
|
||||
private rowToMemory(row: Record<string, unknown>): Memory {
|
||||
return {
|
||||
id: row.id as string,
|
||||
content: row.content as string,
|
||||
category: row.category as MemoryCategory,
|
||||
confidence: row.confidence as number,
|
||||
importance: row.importance as number,
|
||||
createdAt: row.created_at as number,
|
||||
updatedAt: row.updated_at as number,
|
||||
lastAccessedAt: row.last_accessed_at as number,
|
||||
decayDays: row.decay_days as number | null,
|
||||
sourceChannel: (row.source_channel as string | null) ?? undefined,
|
||||
sourceMessageId: (row.source_message_id as string | null) ?? undefined,
|
||||
tags: JSON.parse((row.tags as string) || '[]'),
|
||||
supersedes: (row.supersedes as string | null) ?? undefined,
|
||||
deletedAt: (row.deleted_at as number | null) ?? undefined,
|
||||
deleteReason: (row.delete_reason as string | null) ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
close(): void {
|
||||
// Close SQLite connection
|
||||
if (this.db) {
|
||||
this.save();
|
||||
this.db.close();
|
||||
this.db = null;
|
||||
}
|
||||
|
||||
// Close LanceDB connection
|
||||
if (this.vectorDb) {
|
||||
this.vectorDb.close();
|
||||
this.vectorDb = null;
|
||||
}
|
||||
this.vectorTable = null;
|
||||
|
||||
// Reset initialization promises so databases can be reopened
|
||||
this.initPromise = null;
|
||||
this.sqliteInitPromise = null;
|
||||
}
|
||||
}
|
||||
415
skills/memory-tools/src/tools.test.ts
Normal file
415
skills/memory-tools/src/tools.test.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Memory Tools Tests
|
||||
*
|
||||
* Tests for the six agent-controlled memory operations.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { createMemoryTools } from './tools.js';
|
||||
import { MemoryStore } from './store.js';
|
||||
import { EmbeddingProvider } from './embeddings.js';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import * as os from 'node:os';
|
||||
|
||||
// Mock embedding provider with word-level similarity
|
||||
// Simulates semantic embeddings by averaging word hashes
|
||||
class MockEmbeddingProvider {
|
||||
async embed(text: string): Promise<number[]> {
|
||||
// Tokenize into words
|
||||
const words = text.toLowerCase().split(/\s+/).filter(w => w.length > 0);
|
||||
|
||||
// Create embedding by averaging word contributions
|
||||
const embedding = new Array(1536).fill(0);
|
||||
|
||||
for (const word of words) {
|
||||
const wordHash = this.hashString(word);
|
||||
for (let i = 0; i < 1536; i++) {
|
||||
embedding[i] += Math.sin(wordHash + i * 0.1) * 0.5 + 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize by word count
|
||||
if (words.length > 0) {
|
||||
for (let i = 0; i < 1536; i++) {
|
||||
embedding[i] /= words.length;
|
||||
}
|
||||
}
|
||||
|
||||
return embedding;
|
||||
}
|
||||
|
||||
async embedBatch(texts: string[]): Promise<number[][]> {
|
||||
return Promise.all(texts.map(t => this.embed(t)));
|
||||
}
|
||||
|
||||
private hashString(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Memory Tools', () => {
|
||||
let store: MemoryStore;
|
||||
let tools: ReturnType<typeof createMemoryTools>;
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testDir = path.join(os.tmpdir(), `memory-tools-test-${Date.now()}`);
|
||||
fs.mkdirSync(testDir, { recursive: true });
|
||||
|
||||
const embeddings = new MockEmbeddingProvider() as unknown as EmbeddingProvider;
|
||||
store = new MemoryStore(testDir, embeddings, 1536);
|
||||
await store.init(); // Initialize sql.js WASM before tests
|
||||
tools = createMemoryTools(store);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('memory_store', () => {
|
||||
it('should store a new memory', async () => {
|
||||
const result = await tools.memory_store.execute('call-1', {
|
||||
content: 'User prefers dark mode',
|
||||
category: 'preference',
|
||||
confidence: 0.9,
|
||||
});
|
||||
|
||||
expect(result.details.action).toBe('created');
|
||||
expect(result.details.id).toBeDefined();
|
||||
expect(result.content[0].text).toContain('Stored');
|
||||
});
|
||||
|
||||
it('should detect duplicates', async () => {
|
||||
// Store first memory
|
||||
await tools.memory_store.execute('call-1', {
|
||||
content: 'User prefers dark mode',
|
||||
category: 'preference',
|
||||
});
|
||||
|
||||
// Try to store very similar memory
|
||||
const result = await tools.memory_store.execute('call-2', {
|
||||
content: 'User prefers dark mode',
|
||||
category: 'preference',
|
||||
});
|
||||
|
||||
expect(result.details.action).toBe('duplicate');
|
||||
expect(result.content[0].text).toContain('Similar memory already exists');
|
||||
});
|
||||
|
||||
it('should replace memory when supersedes is provided', async () => {
|
||||
// Store first memory
|
||||
const first = await tools.memory_store.execute('call-1', {
|
||||
content: 'User favorite color is blue',
|
||||
category: 'preference',
|
||||
});
|
||||
expect(first.details.action).toBe('created');
|
||||
|
||||
// Store replacement using explicit supersedes parameter
|
||||
const result = await tools.memory_store.execute('call-2', {
|
||||
content: 'User favorite color is purple',
|
||||
category: 'preference',
|
||||
supersedes: first.details.id,
|
||||
});
|
||||
|
||||
// Should replace the old memory
|
||||
expect(result.details.action).toBe('replaced');
|
||||
expect(result.details.supersededId).toBe(first.details.id);
|
||||
expect(result.content[0].text).toContain('replaced previous entry');
|
||||
|
||||
// Old memory should be soft-deleted
|
||||
const oldMemory = store.get(first.details.id);
|
||||
expect(oldMemory!.deletedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include tags and decay', async () => {
|
||||
const result = await tools.memory_store.execute('call-1', {
|
||||
content: 'Meeting tomorrow at 3pm',
|
||||
category: 'event',
|
||||
tags: ['meeting', 'work'],
|
||||
decayDays: 7,
|
||||
});
|
||||
|
||||
expect(result.details.action).toBe('created');
|
||||
|
||||
const memory = store.get(result.details.id);
|
||||
expect(memory!.tags).toEqual(['meeting', 'work']);
|
||||
expect(memory!.decayDays).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory_update', () => {
|
||||
it('should update memory content', async () => {
|
||||
const created = await tools.memory_store.execute('call-1', {
|
||||
content: 'User dog name is Max',
|
||||
category: 'fact',
|
||||
});
|
||||
|
||||
const result = await tools.memory_update.execute('call-2', {
|
||||
id: created.details.id,
|
||||
content: 'User dog name is Rex',
|
||||
});
|
||||
|
||||
expect(result.details.action).toBe('updated');
|
||||
expect(result.content[0].text).toContain('Rex');
|
||||
});
|
||||
|
||||
it('should update confidence', async () => {
|
||||
const created = await tools.memory_store.execute('call-1', {
|
||||
content: 'User might like coffee',
|
||||
category: 'preference',
|
||||
confidence: 0.5,
|
||||
});
|
||||
|
||||
await tools.memory_update.execute('call-2', {
|
||||
id: created.details.id,
|
||||
confidence: 0.95,
|
||||
});
|
||||
|
||||
const memory = store.get(created.details.id);
|
||||
expect(memory!.confidence).toBe(0.95);
|
||||
});
|
||||
|
||||
it('should return error for non-existent memory', async () => {
|
||||
const result = await tools.memory_update.execute('call-1', {
|
||||
id: 'non-existent',
|
||||
content: 'New content',
|
||||
});
|
||||
|
||||
expect(result.details.error).toBe('not_found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory_forget', () => {
|
||||
it('should delete by id', async () => {
|
||||
const created = await tools.memory_store.execute('call-1', {
|
||||
content: 'Delete me',
|
||||
category: 'fact',
|
||||
});
|
||||
|
||||
const result = await tools.memory_forget.execute('call-2', {
|
||||
id: created.details.id,
|
||||
reason: 'User requested',
|
||||
});
|
||||
|
||||
expect(result.details.action).toBe('deleted');
|
||||
|
||||
const memory = store.get(created.details.id);
|
||||
expect(memory!.deletedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should auto-delete high-confidence single match', async () => {
|
||||
await tools.memory_store.execute('call-1', {
|
||||
content: 'My old car is a Honda',
|
||||
category: 'fact',
|
||||
});
|
||||
|
||||
const result = await tools.memory_forget.execute('call-2', {
|
||||
query: 'My old car is a Honda',
|
||||
});
|
||||
|
||||
expect(result.details.action).toBe('deleted');
|
||||
});
|
||||
|
||||
it('should return candidates when multiple matches', async () => {
|
||||
await tools.memory_store.execute('call-1', {
|
||||
content: 'User likes coffee',
|
||||
category: 'preference',
|
||||
});
|
||||
await tools.memory_store.execute('call-2', {
|
||||
content: 'User likes tea',
|
||||
category: 'preference',
|
||||
});
|
||||
|
||||
// Search with ambiguous query that doesn't exactly match either
|
||||
const result = await tools.memory_forget.execute('call-3', {
|
||||
query: 'beverage preferences',
|
||||
});
|
||||
|
||||
// Should return candidates since no exact text match and scores are similar
|
||||
expect(result.details.action).toBe('candidates');
|
||||
expect(result.details.candidates.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should return error when no params provided', async () => {
|
||||
const result = await tools.memory_forget.execute('call-1', {});
|
||||
|
||||
expect(result.details.error).toBe('missing_param');
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory_search', () => {
|
||||
beforeEach(async () => {
|
||||
await tools.memory_store.execute('call-1', {
|
||||
content: 'User prefers dark mode',
|
||||
category: 'preference',
|
||||
confidence: 0.9,
|
||||
});
|
||||
await tools.memory_store.execute('call-2', {
|
||||
content: 'User sister is Sarah',
|
||||
category: 'relationship',
|
||||
confidence: 0.95,
|
||||
});
|
||||
await tools.memory_store.execute('call-3', {
|
||||
content: 'Meeting at 3pm tomorrow',
|
||||
category: 'event',
|
||||
confidence: 0.8,
|
||||
});
|
||||
});
|
||||
|
||||
it('should search by query', async () => {
|
||||
const result = await tools.memory_search.execute('call-4', {
|
||||
query: 'dark mode settings',
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
expect(result.details.count).toBeGreaterThan(0);
|
||||
expect(result.details.memories.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should filter by category', async () => {
|
||||
const result = await tools.memory_search.execute('call-4', {
|
||||
category: 'preference',
|
||||
});
|
||||
|
||||
expect(result.details.memories.every(
|
||||
(m: any) => m.category === 'preference'
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should filter by minimum confidence', async () => {
|
||||
const result = await tools.memory_search.execute('call-4', {
|
||||
minConfidence: 0.9,
|
||||
});
|
||||
|
||||
expect(result.details.memories.every(
|
||||
(m: any) => m.confidence >= 0.9
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return no results message', async () => {
|
||||
store.close();
|
||||
|
||||
// Create fresh store in a new directory
|
||||
const emptyDir = path.join(os.tmpdir(), `memory-empty-test-${Date.now()}`);
|
||||
fs.mkdirSync(emptyDir, { recursive: true });
|
||||
|
||||
const embeddings = new MockEmbeddingProvider() as unknown as EmbeddingProvider;
|
||||
const emptyStore = new MemoryStore(emptyDir, embeddings, 1536);
|
||||
const emptyTools = createMemoryTools(emptyStore);
|
||||
|
||||
const result = await emptyTools.memory_search.execute('call-1', {
|
||||
query: 'something',
|
||||
});
|
||||
|
||||
expect(result.details.count).toBe(0);
|
||||
expect(result.content[0].text).toBe('No relevant memories found.');
|
||||
|
||||
emptyStore.close();
|
||||
fs.rmSync(emptyDir, { recursive: true, force: true });
|
||||
|
||||
// Restore original store for other tests
|
||||
store = new MemoryStore(testDir, embeddings, 1536);
|
||||
tools = createMemoryTools(store);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory_summarize', () => {
|
||||
beforeEach(async () => {
|
||||
await tools.memory_store.execute('call-1', {
|
||||
content: 'User works at Acme Corp',
|
||||
category: 'fact',
|
||||
});
|
||||
await tools.memory_store.execute('call-2', {
|
||||
content: 'User is a software engineer',
|
||||
category: 'fact',
|
||||
});
|
||||
await tools.memory_store.execute('call-3', {
|
||||
content: 'User prefers morning meetings',
|
||||
category: 'preference',
|
||||
});
|
||||
});
|
||||
|
||||
it('should summarize memories by topic', async () => {
|
||||
const result = await tools.memory_summarize.execute('call-4', {
|
||||
topic: 'work',
|
||||
});
|
||||
|
||||
expect(result.details.memoryCount).toBeGreaterThan(0);
|
||||
expect(result.content[0].text).toContain('Summary');
|
||||
});
|
||||
|
||||
it('should return no memories message', async () => {
|
||||
const result = await tools.memory_summarize.execute('call-4', {
|
||||
topic: 'completely unrelated xyz123',
|
||||
});
|
||||
|
||||
// With our mock embeddings, might still return some results
|
||||
// Just verify the response structure
|
||||
expect(result.content[0].text).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory_list', () => {
|
||||
beforeEach(async () => {
|
||||
await tools.memory_store.execute('call-1', {
|
||||
content: 'First memory',
|
||||
category: 'fact',
|
||||
importance: 0.3,
|
||||
});
|
||||
await tools.memory_store.execute('call-2', {
|
||||
content: 'Second memory',
|
||||
category: 'preference',
|
||||
importance: 0.9,
|
||||
});
|
||||
await tools.memory_store.execute('call-3', {
|
||||
content: 'Third memory',
|
||||
category: 'fact',
|
||||
importance: 0.6,
|
||||
});
|
||||
});
|
||||
|
||||
it('should list all memories', async () => {
|
||||
const result = await tools.memory_list.execute('call-4', {});
|
||||
|
||||
expect(result.details.total).toBe(3);
|
||||
expect(result.details.count).toBe(3);
|
||||
});
|
||||
|
||||
it('should filter by category', async () => {
|
||||
const result = await tools.memory_list.execute('call-4', {
|
||||
category: 'fact',
|
||||
});
|
||||
|
||||
expect(result.details.total).toBe(2);
|
||||
expect(result.details.memories.every(
|
||||
(m: any) => m.category === 'fact'
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('should sort by importance', async () => {
|
||||
const result = await tools.memory_list.execute('call-4', {
|
||||
sortBy: 'importance',
|
||||
});
|
||||
|
||||
const importances = result.details.memories.map((m: any) => m.importance);
|
||||
expect(importances[0]).toBeGreaterThanOrEqual(importances[1]);
|
||||
});
|
||||
|
||||
it('should paginate', async () => {
|
||||
const result = await tools.memory_list.execute('call-4', {
|
||||
limit: 2,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
expect(result.details.total).toBe(3);
|
||||
expect(result.details.count).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
574
skills/memory-tools/src/tools.ts
Normal file
574
skills/memory-tools/src/tools.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
/**
|
||||
* Memory Tools
|
||||
*
|
||||
* The six agent-controlled memory operations:
|
||||
* - memory_store: Save new memories
|
||||
* - memory_update: Modify existing memories
|
||||
* - memory_forget: Delete memories
|
||||
* - memory_search: Semantic search
|
||||
* - memory_summarize: Get topic summary
|
||||
* - memory_list: Browse all memories
|
||||
*/
|
||||
|
||||
import { Type } from '@sinclair/typebox';
|
||||
import type { MemoryStore } from './store.js';
|
||||
import { MEMORY_CATEGORIES, type MemoryCategory } from './types.js';
|
||||
|
||||
// Type helper for string enums (OpenClaw compatible)
|
||||
function stringEnum<T extends string>(values: readonly T[]) {
|
||||
return Type.Unsafe<T>({ type: 'string', enum: [...values] });
|
||||
}
|
||||
|
||||
export function createMemoryTools(store: MemoryStore) {
|
||||
return {
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// STORE - Add new memory
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
memory_store: {
|
||||
name: 'memory_store',
|
||||
label: 'Memory Store',
|
||||
description: `Store a new memory about the user. Use when you learn something worth remembering long-term.
|
||||
|
||||
WHEN to use:
|
||||
- User shares personal info (name, birthday, preferences)
|
||||
- User gives standing instructions ("always summarize emails")
|
||||
- User mentions relationships ("my wife Sarah")
|
||||
- User states preferences ("I prefer bullet points")
|
||||
- Important decisions are made
|
||||
|
||||
WHEN NOT to use:
|
||||
- Trivial conversation (weather, greetings)
|
||||
- Already stored (use memory_update instead)
|
||||
- Temporary context (use conversation history)`,
|
||||
|
||||
parameters: Type.Object({
|
||||
content: Type.String({
|
||||
description: 'The fact/preference/info to remember. Be specific and atomic.'
|
||||
}),
|
||||
category: stringEnum(MEMORY_CATEGORIES),
|
||||
confidence: Type.Optional(Type.Number({
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description: 'How confident this is accurate. 1.0 = explicitly stated, 0.5 = inferred'
|
||||
})),
|
||||
importance: Type.Optional(Type.Number({
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description: 'How important is this. 1.0 = critical instruction, 0.3 = nice to know'
|
||||
})),
|
||||
decayDays: Type.Optional(Type.Number({
|
||||
description: 'Days until memory becomes stale. Omit for permanent. Events should have decay.'
|
||||
})),
|
||||
tags: Type.Optional(Type.Array(Type.String(), {
|
||||
description: 'Tags for categorization and retrieval'
|
||||
})),
|
||||
supersedes: Type.Optional(Type.String({
|
||||
description: 'ID of memory this replaces (will delete the old one)'
|
||||
})),
|
||||
}),
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: {
|
||||
content: string;
|
||||
category: MemoryCategory;
|
||||
confidence?: number;
|
||||
importance?: number;
|
||||
decayDays?: number;
|
||||
tags?: string[];
|
||||
supersedes?: string;
|
||||
},
|
||||
ctx?: { messageChannel?: string; }
|
||||
) {
|
||||
// Handle explicit supersedes first (user knows what to replace)
|
||||
let supersededId: string | undefined = params.supersedes;
|
||||
if (params.supersedes) {
|
||||
await store.delete(params.supersedes, 'superseded by new memory');
|
||||
}
|
||||
|
||||
// Check for similar/conflicting memories
|
||||
// Use low threshold (0.4) to catch potential conflicts, then decide based on score
|
||||
const similar = await store.findDuplicates(params.content, 0.4);
|
||||
|
||||
if (similar.length > 0 && !params.supersedes) {
|
||||
const match = similar[0];
|
||||
const isHighSimilarity = match.score > 0.92;
|
||||
const isSameCategory = match.memory.category === params.category;
|
||||
|
||||
// High similarity = likely duplicate (exact same info)
|
||||
if (isHighSimilarity) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Similar memory already exists: "${match.memory.content}" (${(match.score * 100).toFixed(0)}% match). Use memory_update to modify it, or pass supersedes="${match.memory.id}" to replace it.`
|
||||
}],
|
||||
details: {
|
||||
action: 'duplicate',
|
||||
existingId: match.memory.id,
|
||||
existingContent: match.memory.content,
|
||||
similarity: match.score,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Same category + moderate similarity = conflicting info -> AUTO-REPLACE
|
||||
// This handles corrections like "favorite color is blue" -> "favorite color is purple"
|
||||
else if (isSameCategory && match.score > 0.5) {
|
||||
await store.delete(match.memory.id, 'auto-superseded by updated info');
|
||||
supersededId = match.memory.id;
|
||||
}
|
||||
}
|
||||
|
||||
const memory = await store.create({
|
||||
content: params.content,
|
||||
category: params.category,
|
||||
confidence: params.confidence ?? 0.8,
|
||||
importance: params.importance ?? 0.5,
|
||||
decayDays: params.decayDays,
|
||||
tags: params.tags ?? [],
|
||||
sourceChannel: ctx?.messageChannel,
|
||||
});
|
||||
|
||||
// Build response message
|
||||
const contentPreview = `${params.content.slice(0, 80)}${params.content.length > 80 ? '...' : ''}`;
|
||||
const message = supersededId
|
||||
? `Updated: "${contentPreview}" [${params.category}] (replaced previous entry)`
|
||||
: `Stored: "${contentPreview}" [${params.category}]`;
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: message,
|
||||
}],
|
||||
details: {
|
||||
action: supersededId ? 'replaced' : 'created',
|
||||
id: memory.id,
|
||||
category: memory.category,
|
||||
confidence: memory.confidence,
|
||||
supersededId,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// UPDATE - Modify existing memory
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
memory_update: {
|
||||
name: 'memory_update',
|
||||
label: 'Memory Update',
|
||||
description: `Update an existing memory when information changes or was incorrect.
|
||||
|
||||
Use when:
|
||||
- User corrects previous info ("actually my dog's name is Rex, not Max")
|
||||
- Information becomes more specific ("my meeting is at 3pm, not just 'afternoon'")
|
||||
- Confidence changes (user confirms something you inferred)`,
|
||||
|
||||
parameters: Type.Object({
|
||||
id: Type.String({
|
||||
description: 'ID of memory to update (from memory_search results)'
|
||||
}),
|
||||
content: Type.Optional(Type.String({
|
||||
description: 'Updated content'
|
||||
})),
|
||||
confidence: Type.Optional(Type.Number({
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description: 'Updated confidence score'
|
||||
})),
|
||||
importance: Type.Optional(Type.Number({
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description: 'Updated importance score'
|
||||
})),
|
||||
}),
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: {
|
||||
id: string;
|
||||
content?: string;
|
||||
confidence?: number;
|
||||
importance?: number;
|
||||
}
|
||||
) {
|
||||
const existing = store.get(params.id);
|
||||
if (!existing) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Memory ${params.id} not found.` }],
|
||||
details: { error: 'not_found' },
|
||||
};
|
||||
}
|
||||
|
||||
const memory = await store.update(params.id, {
|
||||
content: params.content,
|
||||
confidence: params.confidence,
|
||||
importance: params.importance,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Updated memory: "${memory.content.slice(0, 80)}${memory.content.length > 80 ? '...' : ''}"`
|
||||
}],
|
||||
details: {
|
||||
action: 'updated',
|
||||
id: memory.id,
|
||||
content: memory.content,
|
||||
confidence: memory.confidence,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// FORGET - Delete memory
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
memory_forget: {
|
||||
name: 'memory_forget',
|
||||
label: 'Memory Forget',
|
||||
description: `Delete a memory permanently.
|
||||
|
||||
Use when:
|
||||
- User explicitly asks you to forget something
|
||||
- Information is no longer relevant ("I sold that car")
|
||||
- Memory was stored in error`,
|
||||
|
||||
parameters: Type.Object({
|
||||
id: Type.Optional(Type.String({
|
||||
description: 'ID of memory to delete (if known)'
|
||||
})),
|
||||
query: Type.Optional(Type.String({
|
||||
description: 'Search query to find memory to delete (if ID unknown)'
|
||||
})),
|
||||
reason: Type.Optional(Type.String({
|
||||
description: 'Why this memory is being forgotten (for audit log)'
|
||||
})),
|
||||
}),
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: {
|
||||
id?: string;
|
||||
query?: string;
|
||||
reason?: string;
|
||||
}
|
||||
) {
|
||||
if (params.id) {
|
||||
const existing = store.get(params.id);
|
||||
if (!existing) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Memory ${params.id} not found.` }],
|
||||
details: { error: 'not_found' },
|
||||
};
|
||||
}
|
||||
|
||||
await store.delete(params.id, params.reason);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: `Forgotten: "${existing.content.slice(0, 60)}..."` }],
|
||||
details: { action: 'deleted', id: params.id },
|
||||
};
|
||||
}
|
||||
|
||||
if (params.query) {
|
||||
const results = await store.search({
|
||||
query: params.query,
|
||||
limit: 5,
|
||||
minConfidence: 0.3,
|
||||
});
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'No matching memories found.' }],
|
||||
details: { found: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// Check if query text appears in any result (case-insensitive exact match)
|
||||
const queryLower = params.query.toLowerCase();
|
||||
const exactMatch = results.find(r =>
|
||||
r.memory.content.toLowerCase().includes(queryLower)
|
||||
);
|
||||
|
||||
// Auto-delete if:
|
||||
// 1. Single high-confidence match (score > 0.9), OR
|
||||
// 2. Query text appears literally in top result, OR
|
||||
// 3. Top result has significantly higher score than second (clear winner)
|
||||
const topResult = results[0];
|
||||
const secondScore = results.length > 1 ? results[1].score : 0;
|
||||
const clearWinner = topResult.score > 0.5 && topResult.score > secondScore * 1.5;
|
||||
|
||||
if (exactMatch || (results.length === 1 && topResult.score > 0.9) || clearWinner) {
|
||||
const toDelete = exactMatch || topResult;
|
||||
await store.delete(toDelete.memory.id, params.reason);
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Forgotten: "${toDelete.memory.content.slice(0, 60)}..."`
|
||||
}],
|
||||
details: { action: 'deleted', id: toDelete.memory.id },
|
||||
};
|
||||
}
|
||||
|
||||
// Return candidates for user selection
|
||||
const list = results
|
||||
.map(r => `- [${r.memory.id.slice(0, 8)}] ${r.memory.content.slice(0, 50)}... (${(r.score * 100).toFixed(0)}%)`)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Found ${results.length} candidates. Specify id:\n${list}`
|
||||
}],
|
||||
details: {
|
||||
action: 'candidates',
|
||||
candidates: results.map(r => ({
|
||||
id: r.memory.id,
|
||||
content: r.memory.content,
|
||||
score: r.score,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'Provide id or query.' }],
|
||||
details: { error: 'missing_param' },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SEARCH - Semantic search
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
memory_search: {
|
||||
name: 'memory_search',
|
||||
label: 'Memory Search',
|
||||
description: `Search memories by semantic similarity and/or filters.
|
||||
|
||||
Use when:
|
||||
- You need context about the user to answer well
|
||||
- User references something from the past ("remember when I told you...")
|
||||
- You want to personalize a response
|
||||
- Before storing, to check if memory already exists`,
|
||||
|
||||
parameters: Type.Object({
|
||||
query: Type.Optional(Type.String({
|
||||
description: 'Semantic search query'
|
||||
})),
|
||||
category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
|
||||
tags: Type.Optional(Type.Array(Type.String(), {
|
||||
description: 'Filter by tags (AND logic)'
|
||||
})),
|
||||
minConfidence: Type.Optional(Type.Number({
|
||||
minimum: 0,
|
||||
maximum: 1,
|
||||
description: 'Minimum confidence threshold (default: 0.5)'
|
||||
})),
|
||||
limit: Type.Optional(Type.Number({
|
||||
maximum: 50,
|
||||
description: 'Max results to return (default: 10)'
|
||||
})),
|
||||
}),
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: {
|
||||
query?: string;
|
||||
category?: MemoryCategory;
|
||||
tags?: string[];
|
||||
minConfidence?: number;
|
||||
limit?: number;
|
||||
}
|
||||
) {
|
||||
const results = await store.search({
|
||||
query: params.query,
|
||||
category: params.category,
|
||||
tags: params.tags,
|
||||
minConfidence: params.minConfidence ?? 0.5,
|
||||
limit: params.limit ?? 10,
|
||||
excludeDecayed: true,
|
||||
});
|
||||
|
||||
// Update last accessed
|
||||
store.touchMany(results.map(r => r.memory.id));
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'No relevant memories found.' }],
|
||||
details: { count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const text = results
|
||||
.map((r, i) =>
|
||||
`${i + 1}. [${r.memory.category}] ${r.memory.content} (${(r.score * 100).toFixed(0)}% match, ${(r.memory.confidence * 100).toFixed(0)}% confident)`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Found ${results.length} memories:\n\n${text}`
|
||||
}],
|
||||
details: {
|
||||
count: results.length,
|
||||
memories: results.map(r => ({
|
||||
id: r.memory.id,
|
||||
content: r.memory.content,
|
||||
category: r.memory.category,
|
||||
confidence: r.memory.confidence,
|
||||
importance: r.memory.importance,
|
||||
score: r.score,
|
||||
tags: r.memory.tags,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// SUMMARIZE - Topic summary
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
memory_summarize: {
|
||||
name: 'memory_summarize',
|
||||
label: 'Memory Summarize',
|
||||
description: `Get a summary of memories related to a topic.
|
||||
|
||||
Use when:
|
||||
- Starting a conversation and want general context
|
||||
- Topic is broad ("what do I know about user's work")
|
||||
- Too many memories would be retrieved with search`,
|
||||
|
||||
parameters: Type.Object({
|
||||
topic: Type.String({
|
||||
description: 'Topic to summarize memories about'
|
||||
}),
|
||||
maxMemories: Type.Optional(Type.Number({
|
||||
description: 'Max memories to include in summary (default: 20)'
|
||||
})),
|
||||
}),
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: {
|
||||
topic: string;
|
||||
maxMemories?: number;
|
||||
}
|
||||
) {
|
||||
const results = await store.search({
|
||||
query: params.topic,
|
||||
limit: params.maxMemories ?? 20,
|
||||
excludeDecayed: true,
|
||||
});
|
||||
|
||||
if (results.length === 0) {
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `No memories found about "${params.topic}".`
|
||||
}],
|
||||
details: { memoryCount: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// Group by category
|
||||
const byCategory = new Map<string, string[]>();
|
||||
for (const r of results) {
|
||||
const cat = r.memory.category;
|
||||
if (!byCategory.has(cat)) byCategory.set(cat, []);
|
||||
byCategory.get(cat)!.push(r.memory.content);
|
||||
}
|
||||
|
||||
// Format summary
|
||||
const sections = Array.from(byCategory.entries())
|
||||
.map(([cat, items]) => `**${cat}**:\n${items.map(i => `- ${i}`).join('\n')}`)
|
||||
.join('\n\n');
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Summary of "${params.topic}" (${results.length} memories):\n\n${sections}`
|
||||
}],
|
||||
details: {
|
||||
memoryCount: results.length,
|
||||
categories: Object.fromEntries(byCategory),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// LIST - Browse all memories
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
memory_list: {
|
||||
name: 'memory_list',
|
||||
label: 'Memory List',
|
||||
description: `List all memories, optionally filtered. Use for browsing/auditing, not semantic search.`,
|
||||
|
||||
parameters: Type.Object({
|
||||
category: Type.Optional(stringEnum(MEMORY_CATEGORIES)),
|
||||
sortBy: Type.Optional(stringEnum([
|
||||
'createdAt', 'updatedAt', 'importance', 'confidence', 'lastAccessedAt'
|
||||
] as const)),
|
||||
limit: Type.Optional(Type.Number({
|
||||
description: 'Max results (default: 20)'
|
||||
})),
|
||||
offset: Type.Optional(Type.Number({
|
||||
description: 'Skip first N results (for pagination)'
|
||||
})),
|
||||
}),
|
||||
|
||||
async execute(
|
||||
_toolCallId: string,
|
||||
params: {
|
||||
category?: MemoryCategory;
|
||||
sortBy?: 'createdAt' | 'updatedAt' | 'importance' | 'confidence' | 'lastAccessedAt';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
) {
|
||||
const results = store.list({
|
||||
category: params.category,
|
||||
sortBy: params.sortBy ?? 'createdAt',
|
||||
sortOrder: 'desc',
|
||||
limit: params.limit ?? 20,
|
||||
offset: params.offset ?? 0,
|
||||
});
|
||||
|
||||
if (results.items.length === 0) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'No memories found.' }],
|
||||
details: { total: 0, count: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
const text = results.items
|
||||
.map((m, i) =>
|
||||
`${i + 1}. [${m.category}] ${m.content.slice(0, 60)}${m.content.length > 60 ? '...' : ''} (${(m.confidence * 100).toFixed(0)}%)`
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
return {
|
||||
content: [{
|
||||
type: 'text' as const,
|
||||
text: `Showing ${results.items.length} of ${results.total} memories:\n\n${text}`
|
||||
}],
|
||||
details: {
|
||||
total: results.total,
|
||||
count: results.items.length,
|
||||
memories: results.items.map(m => ({
|
||||
id: m.id,
|
||||
content: m.content,
|
||||
category: m.category,
|
||||
confidence: m.confidence,
|
||||
importance: m.importance,
|
||||
createdAt: m.createdAt,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type MemoryTools = ReturnType<typeof createMemoryTools>;
|
||||
108
skills/memory-tools/src/types.ts
Normal file
108
skills/memory-tools/src/types.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Memory-as-Tools Type Definitions
|
||||
*
|
||||
* Core types for the agent-controlled memory system with
|
||||
* confidence scoring, decay, and semantic search.
|
||||
*/
|
||||
|
||||
export const MEMORY_CATEGORIES = [
|
||||
'fact', // "User's dog is named Rex"
|
||||
'preference', // "User prefers dark mode"
|
||||
'event', // "User has dentist appointment Tuesday"
|
||||
'relationship', // "User's sister is named Sarah"
|
||||
'context', // "User is working on a React project"
|
||||
'instruction', // "Always respond in Spanish"
|
||||
'decision', // "We decided to use PostgreSQL"
|
||||
'entity', // Contact info, phone numbers, emails
|
||||
] as const;
|
||||
|
||||
export type MemoryCategory = typeof MEMORY_CATEGORIES[number];
|
||||
|
||||
export interface Memory {
|
||||
id: string;
|
||||
content: string;
|
||||
category: MemoryCategory;
|
||||
confidence: number; // 0.0 - 1.0: how sure are we this is accurate
|
||||
importance: number; // 0.0 - 1.0: how important is this
|
||||
|
||||
// Temporal
|
||||
createdAt: number; // Unix timestamp ms
|
||||
updatedAt: number; // Unix timestamp ms
|
||||
lastAccessedAt: number; // Unix timestamp ms
|
||||
decayDays: number | null; // null = permanent
|
||||
|
||||
// Provenance
|
||||
sourceChannel?: string; // 'whatsapp' | 'telegram' | 'discord' | etc
|
||||
sourceMessageId?: string; // for traceability
|
||||
|
||||
// Relations
|
||||
tags: string[];
|
||||
supersedes?: string; // id of memory this updates/replaces
|
||||
|
||||
// Soft delete
|
||||
deletedAt?: number;
|
||||
deleteReason?: string;
|
||||
}
|
||||
|
||||
export interface MemorySearchResult {
|
||||
memory: Memory;
|
||||
score: number; // Similarity score 0-1
|
||||
}
|
||||
|
||||
export interface CreateMemoryInput {
|
||||
content: string;
|
||||
category: MemoryCategory;
|
||||
confidence?: number;
|
||||
importance?: number;
|
||||
decayDays?: number | null;
|
||||
tags?: string[];
|
||||
sourceChannel?: string;
|
||||
sourceMessageId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMemoryInput {
|
||||
content?: string;
|
||||
confidence?: number;
|
||||
importance?: number;
|
||||
decayDays?: number | null;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface SearchOptions {
|
||||
query?: string;
|
||||
category?: MemoryCategory;
|
||||
tags?: string[];
|
||||
minConfidence?: number;
|
||||
minImportance?: number;
|
||||
limit?: number;
|
||||
excludeDecayed?: boolean;
|
||||
includeDeleted?: boolean;
|
||||
}
|
||||
|
||||
export interface ListOptions {
|
||||
category?: MemoryCategory;
|
||||
sortBy?: 'createdAt' | 'updatedAt' | 'importance' | 'confidence' | 'lastAccessedAt';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface PluginConfig {
|
||||
embedding: {
|
||||
apiKey: string;
|
||||
model?: string;
|
||||
};
|
||||
dbPath?: string;
|
||||
autoInjectInstructions?: boolean;
|
||||
decayCheckInterval?: number;
|
||||
}
|
||||
|
||||
// Vector dimensions for different embedding models
|
||||
export const VECTOR_DIMS: Record<string, number> = {
|
||||
'text-embedding-3-small': 1536,
|
||||
'text-embedding-3-large': 3072,
|
||||
};
|
||||
|
||||
export function vectorDimsForModel(model: string): number {
|
||||
return VECTOR_DIMS[model] ?? 1536;
|
||||
}
|
||||
22
skills/memory-tools/tsconfig.json
Normal file
22
skills/memory-tools/tsconfig.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
20
skills/memory-tools/vitest.config.ts
Normal file
20
skills/memory-tools/vitest.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'**/*.test.ts',
|
||||
'vitest.config.ts',
|
||||
],
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
59
skills/pixel6-screen/SKILL.md
Normal file
59
skills/pixel6-screen/SKILL.md
Normal 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` 可以读取所有界面文字和按钮位置
|
||||
7
skills/remindme/.clawhub/origin.json
Normal file
7
skills/remindme/.clawhub/origin.json
Normal 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
312
skills/remindme/SKILL.md
Normal 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.
|
||||
6
skills/remindme/_meta.json
Normal file
6
skills/remindme/_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn7586w2dker87hc3kr4qp0a1d80h3y0",
|
||||
"slug": "remindme",
|
||||
"version": "2.0.2",
|
||||
"publishedAt": 1770853231820
|
||||
}
|
||||
109
skills/remindme/references/TEMPLATES.md
Normal file
109
skills/remindme/references/TEMPLATES.md
Normal 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.
|
||||
7
skills/skill-vetter/.clawhub/origin.json
Normal file
7
skills/skill-vetter/.clawhub/origin.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 1,
|
||||
"registry": "https://clawhub.ai",
|
||||
"slug": "skill-vetter",
|
||||
"installedVersion": "1.0.0",
|
||||
"installedAt": 1773013767623
|
||||
}
|
||||
138
skills/skill-vetter/SKILL.md
Normal file
138
skills/skill-vetter/SKILL.md
Normal 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.* 🔒🦀
|
||||
6
skills/skill-vetter/_meta.json
Normal file
6
skills/skill-vetter/_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn71j6xbmpwfvx4c6y1ez8cd718081mg",
|
||||
"slug": "skill-vetter",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1769863429632
|
||||
}
|
||||
7
skills/summarize/.clawhub/origin.json
Normal file
7
skills/summarize/.clawhub/origin.json
Normal 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
49
skills/summarize/SKILL.md
Normal 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
|
||||
6
skills/summarize/_meta.json
Normal file
6
skills/summarize/_meta.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ownerId": "kn70pywhg0fyz996kpa8xj89s57yhv26",
|
||||
"slug": "summarize",
|
||||
"version": "1.0.0",
|
||||
"publishedAt": 1767545383635
|
||||
}
|
||||
53
skills/vps-migrate/SKILL.md
Normal file
53
skills/vps-migrate/SKILL.md
Normal 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
|
||||
1223
skills/vps-migrate/scripts/vps-snapshot.sh
Executable file
1223
skills/vps-migrate/scripts/vps-snapshot.sh
Executable file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user