8 Commits
Author SHA1 Message Date
root 70f5aec465 Fix: Add session rotation detection (v1.1)
- Add 1-second mtime polling to detect newer sessions
- Fixes bug where watcher stayed stuck on first session forever
- Prevents data loss when sessions rotate (was losing 2+ days of history)
- Bump version to v1.1
2026-02-28 16:51:31 -06:00
root 97a95bd3af docs: add validation rule - always validate after changes 2026-02-14 07:23:26 -06:00
root 9769839a67 docs: add counter reset root cause - removed old update_count.sh 2026-02-14 07:23:18 -06:00
root 59225f0d1b docs: add note - website complements YouTube channel 2026-02-14 07:21:21 -06:00
root a8299b6db7 docs: add Feb 14 - SpeedyFoxAI website details, counter fix, nginx path discovery 2026-02-14 07:20:53 -06:00
root 648aa7f016 docs: add git repository section to daily log 2026-02-10 14:40:48 -06:00
root 98d14be03b docs: update MEMORY.md with git setup and sub-agent configuration 2026-02-10 14:40:31 -06:00
root d1357c5463 Initial commit: workspace setup with skills, memory, config 2026-02-10 14:37:49 -06:00
110 changed files with 6692 additions and 10201 deletions
-45
View File
@@ -1,45 +0,0 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Environment
.env
.memory_env
*.env
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Logs
*.log
# OS
.DS_Store
Thumbs.db
# OpenClaw specific
.mem_last_turn
.gmail_imap.json
.google_tokens.json
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""
TrueRecall v1.1 - Real-time Qdrant Watcher
Monitors OpenClaw sessions and stores to memories_tr instantly.
This is the CAPTURE component. For curation and injection, install v2.
"""
import os
import sys
import json
import time
import signal
import hashlib
import argparse
import requests
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any, Optional, List
# Config
QDRANT_URL = os.getenv("QDRANT_URL", "http://10.0.0.40:6333")
QDRANT_COLLECTION = os.getenv("QDRANT_COLLECTION", "memories_tr")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "snowflake-arctic-embed2")
USER_ID = os.getenv("USER_ID", "rob")
# Paths
SESSIONS_DIR = Path("/root/.openclaw/agents/main/sessions")
# State
running = True
last_position = 0
current_file = None
turn_counter = 0
def signal_handler(signum, frame):
global running
print(f"\nReceived signal {signum}, shutting down...", file=sys.stderr)
running = False
def get_embedding(text: str) -> List[float]:
try:
response = requests.post(
f"{OLLAMA_URL}/api/embeddings",
json={"model": EMBEDDING_MODEL, "prompt": text},
timeout=30
)
response.raise_for_status()
return response.json()["embedding"]
except Exception as e:
print(f"Error getting embedding: {e}", file=sys.stderr)
return None
def clean_content(text: str) -> str:
import re
# Remove metadata JSON blocks
text = re.sub(r'Conversation info \(untrusted metadata\):\s*```json\s*\{[\s\S]*?\}\s*```', '', text)
# Remove thinking tags
text = re.sub(r'\[thinking:[^\]]*\]', '', text)
# Remove timestamp lines
text = re.sub(r'\[\w{3} \d{4}-\d{2}-\d{2} \d{2}:\d{2} [A-Z]{3}\]', '', text)
# Remove markdown tables
text = re.sub(r'\|[^\n]*\|', '', text)
text = re.sub(r'\|[-:]+\|', '', text)
# Remove markdown formatting
text = re.sub(r'\*\*([^*]+)\*\*', r'\1', text)
text = re.sub(r'\*([^*]+)\*', r'\1', text)
text = re.sub(r'`([^`]+)`', r'\1', text)
text = re.sub(r'```[\s\S]*?```', '', text)
# Remove horizontal rules
text = re.sub(r'---+', '', text)
text = re.sub(r'\*\*\*+', '', text)
# Remove excess whitespace
text = re.sub(r'\n{3,}', '\n', text)
text = re.sub(r'[ \t]+', ' ', text)
return text.strip()
def store_to_qdrant(turn: Dict[str, Any], dry_run: bool = False) -> bool:
if dry_run:
print(f"[DRY RUN] Would store turn {turn['turn']} ({turn['role']}): {turn['content'][:60]}...")
return True
vector = get_embedding(turn['content'])
if vector is None:
print(f"Failed to get embedding for turn {turn['turn']}", file=sys.stderr)
return False
payload = {
"user_id": turn.get('user_id', USER_ID),
"role": turn['role'],
"content": turn['content'],
"turn": turn['turn'],
"timestamp": turn.get('timestamp', datetime.now(timezone.utc).isoformat()),
"date": datetime.now(timezone.utc).strftime('%Y-%m-%d'),
"source": "true-recall-base",
"curated": False
}
# Generate deterministic ID
turn_id = turn.get('turn', 0)
hash_bytes = hashlib.sha256(f"{USER_ID}:turn:{turn_id}:{datetime.now().strftime('%H%M%S')}".encode()).digest()[:8]
point_id = int.from_bytes(hash_bytes, byteorder='big') % (2**63)
try:
response = requests.put(
f"{QDRANT_URL}/collections/{QDRANT_COLLECTION}/points",
json={
"points": [{
"id": abs(point_id),
"vector": vector,
"payload": payload
}]
},
timeout=30
)
response.raise_for_status()
return True
except Exception as e:
print(f"Error writing to Qdrant: {e}", file=sys.stderr)
return False
def get_current_session_file():
if not SESSIONS_DIR.exists():
return None
files = list(SESSIONS_DIR.glob("*.jsonl"))
if not files:
return None
return max(files, key=lambda p: p.stat().st_mtime)
def parse_turn(line: str, session_name: str) -> Optional[Dict[str, Any]]:
global turn_counter
try:
entry = json.loads(line.strip())
except json.JSONDecodeError:
return None
if entry.get('type') != 'message' or 'message' not in entry:
return None
msg = entry['message']
role = msg.get('role')
if role in ('toolResult', 'system', 'developer'):
return None
if role not in ('user', 'assistant'):
return None
content = ""
if isinstance(msg.get('content'), list):
for item in msg['content']:
if isinstance(item, dict) and 'text' in item:
content += item['text']
elif isinstance(msg.get('content'), str):
content = msg['content']
if not content:
return None
content = clean_content(content)
if not content or len(content) < 5:
return None
turn_counter += 1
return {
'turn': turn_counter,
'role': role,
'content': content[:2000],
'timestamp': entry.get('timestamp', datetime.now(timezone.utc).isoformat()),
'user_id': USER_ID
}
def process_new_lines(f, session_name: str, dry_run: bool = False):
global last_position
f.seek(last_position)
for line in f:
line = line.strip()
if not line:
continue
turn = parse_turn(line, session_name)
if turn:
if store_to_qdrant(turn, dry_run):
print(f"✅ Turn {turn['turn']} ({turn['role']}) → Qdrant")
last_position = f.tell()
def watch_session(session_file: Path, dry_run: bool = False):
global last_position, turn_counter
session_name = session_file.name.replace('.jsonl', '')
print(f"Watching session: {session_file.name}")
try:
with open(session_file, 'r') as f:
for line in f:
turn_counter += 1
last_position = session_file.stat().st_size
print(f"Session has {turn_counter} existing turns, starting from position {last_position}")
except Exception as e:
print(f"Warning: Could not read existing turns: {e}", file=sys.stderr)
last_position = 0
last_session_check = time.time()
with open(session_file, 'r') as f:
while running:
if not session_file.exists():
print("Session file removed, looking for new session...")
return None
# Check for newer session every 1 second
if time.time() - last_session_check > 1.0:
last_session_check = time.time()
newest_session = get_current_session_file()
if newest_session and newest_session != session_file:
print(f"Newer session detected: {newest_session.name}")
return newest_session
process_new_lines(f, session_name, dry_run)
time.sleep(0.1)
return session_file
def watch_loop(dry_run: bool = False):
global current_file, turn_counter
while running:
session_file = get_current_session_file()
if session_file is None:
print("No active session found, waiting...")
time.sleep(1)
continue
if current_file != session_file:
print(f"\nNew session detected: {session_file.name}")
current_file = session_file
turn_counter = 0
last_position = 0
result = watch_session(session_file, dry_run)
if result is None:
current_file = None
time.sleep(0.5)
def main():
global USER_ID
parser = argparse.ArgumentParser(description="TrueRecall v1.1 - Real-time Memory Capture")
parser.add_argument("--daemon", "-d", action="store_true", help="Run as daemon")
parser.add_argument("--once", "-o", action="store_true", help="Process once then exit")
parser.add_argument("--dry-run", "-n", action="store_true", help="Don't write to Qdrant")
parser.add_argument("--user-id", "-u", default=USER_ID, help=f"User ID (default: {USER_ID})")
args = parser.parse_args()
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
if args.user_id:
USER_ID = args.user_id
print(f"🔍 TrueRecall v1.1 - Real-time Memory Capture")
print(f"📍 Qdrant: {QDRANT_URL}/{QDRANT_COLLECTION}")
print(f"🧠 Ollama: {OLLAMA_URL}/{EMBEDDING_MODEL}")
print(f"👤 User: {USER_ID}")
print()
if args.once:
print("Running once...")
session_file = get_current_session_file()
if session_file:
watch_session(session_file, args.dry_run)
else:
print("No session found")
else:
print("Running as daemon (Ctrl+C to stop)...")
watch_loop(args.dry_run)
if __name__ == "__main__":
main()
+653
View File
@@ -0,0 +1,653 @@
# ACTIVE.md - Syntax Library & Pre-Flight Checklist
**Read the relevant section BEFORE using any tool. This is your syntax reference.**
**Core Philosophy: Quality over speed. Thorough and correct beats fast and half-baked.**
---
## 📖 How to Use This File
1. **Identify the tool** you need to use
2. **Read that section completely** before writing any code
3. **Check the checklist** items one by one
4. **Verify against examples** - correct and wrong
5. **Execute only after validation**
---
## 🔧 `read` - Read File Contents
### Purpose
Read contents of text files or view images (jpg, png, gif, webp).
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `file_path` | string | **YES** | Path to the file (absolute or relative) |
| `offset` | integer | No | Line number to start from (1-indexed) |
| `limit` | integer | No | Maximum lines to read |
### Instructions
- **ALWAYS** use `file_path`, never `path`
- **ALWAYS** provide the full path
- Use `offset` + `limit` for files >100 lines
- Images are sent as attachments automatically
- Output truncated at 2000 lines or 50KB
### Correct Examples
```python
# Basic read
read({ file_path: "/root/.openclaw/workspace/ACTIVE.md" })
# Read with pagination
read({
file_path: "/root/.openclaw/workspace/large_file.txt",
offset: 1,
limit: 50
})
# Read from specific line
read({
file_path: "/var/log/syslog",
offset: 100,
limit: 25
})
```
### Wrong Examples
```python
# ❌ WRONG - 'path' is incorrect parameter name
read({ path: "/path/to/file" })
# ❌ WRONG - missing required file_path
read({ offset: 1, limit: 50 })
# ❌ WRONG - empty call
read({})
```
### Checklist
- [ ] Using `file_path` (not `path`)
- [ ] File path is complete
- [ ] Using `offset`/`limit` for large files if needed
---
## ✏️ `edit` - Precise Text Replacement
### Purpose
Edit a file by replacing exact text. The old_string must match exactly (including whitespace).
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `file_path` | string | **YES** | Path to the file |
| `old_string` | string | **YES** | Exact text to find and replace |
| `new_string` | string | **YES** | Replacement text |
### Critical Rules
1. **old_string must match EXACTLY** - including whitespace, newlines, indentation
2. **Parameter names are** `old_string` and `new_string` - NOT `oldText`/`newText`
3. **Both parameters required** - never provide only one
4. **Surgical edits only** - for precise changes, not large rewrites
5. **If edit fails 2+ times** - switch to `write` tool instead
### Instructions
1. Read the file first to see exact content
2. Copy the exact text you want to replace (including whitespace)
3. Provide both `old_string` and `new_string`
4. If edit fails, verify the exact match - or switch to `write`
### Correct Examples
```python
# Simple replacement
edit({
file_path: "/root/.openclaw/workspace/config.txt",
old_string: "DEBUG = false",
new_string: "DEBUG = true"
})
# Multi-line replacement (preserve exact whitespace)
edit({
file_path: "/root/.openclaw/workspace/script.py",
old_string: """def old_function():
return 42""",
new_string: """def new_function():
return 100"""
})
# Adding to a list
edit({
file_path: "/root/.openclaw/workspace/ACTIVE.md",
old_string: "- Item 3",
new_string: """- Item 3
- Item 4"""
})
```
### Wrong Examples
```python
# ❌ WRONG - missing new_string
edit({
file_path: "/path/file",
old_string: "text to replace"
})
# ❌ WRONG - missing old_string
edit({
file_path: "/path/file",
new_string: "replacement text"
})
# ❌ WRONG - wrong parameter names (newText/oldText)
edit({
file_path: "/path/file",
oldText: "old",
newText: "new"
})
# ❌ WRONG - whitespace mismatch (will fail)
edit({
file_path: "/path/file",
old_string: " indented", # two spaces
new_string: " new" # four spaces - but old didn't match exactly
})
```
### Recovery Strategy
```python
# If edit fails twice, use write instead:
# 1. Read the full file
content = read({ file_path: "/path/to/file" })
# 2. Modify content in your mind/code
new_content = content.replace("old", "new")
# 3. Rewrite entire file
write({
file_path: "/path/to/file",
content: new_content
})
```
### Checklist
- [ ] Using `old_string` and `new_string` (not newText/oldText)
- [ ] Both parameters provided
- [ ] old_string matches EXACTLY (copy-paste from read output)
- [ ] Considered if `write` would be better
- [ ] Plan to switch to `write` if this fails twice
---
## 📝 `write` - Create or Overwrite File
### Purpose
Write content to a file. Creates if doesn't exist, overwrites if it does.
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `file_path` | string | **YES*** | Path to the file |
| `path` | string | **YES*** | Alternative parameter name (skills legacy) |
| `content` | string | **YES** | Content to write |
*Use `file_path` for standard operations, `path` for skill files
### Critical Rules
1. **Overwrites entire file** - no partial writes
2. **Creates parent directories** automatically
3. **Must have complete content** ready before calling
4. **Use after 2-3 failed `edit` attempts** instead of continuing to fail
### When to Use
- Creating new files
- Rewriting entire file after failed edits
- Major refactors where most content changes
- When exact text matching for `edit` is too difficult
### Instructions
1. Have the COMPLETE file content ready
2. Double-check the file path
3. For skills: use `path` parameter (legacy support)
4. Verify content includes everything needed
### Correct Examples
```python
# Create new file
write({
file_path: "/root/.openclaw/workspace/new_file.txt",
content: "This is the complete content of the new file."
})
# Overwrite existing (after failed edits)
write({
file_path: "/root/.openclaw/workspace/ACTIVE.md",
content: """# ACTIVE.md - New Content
Complete file content here...
All sections included...
"""
})
# For skill files (uses 'path' instead of 'file_path')
write({
path: "/root/.openclaw/workspace/skills/my-skill/SKILL.md",
content: "# Skill Documentation..."
})
```
### Wrong Examples
```python
# ❌ WRONG - missing content
write({ file_path: "/path/file" })
# ❌ WRONG - missing path
write({ content: "text" })
# ❌ WRONG - partial content thinking it will append
write({
file_path: "/path/file",
content: "new line" # This REPLACES entire file, not appends!
})
```
### Checklist
- [ ] Have COMPLETE content ready
- [ ] Using `file_path` (or `path` for skills)
- [ ] Aware this OVERWRITES entire file
- [ ] All content included in the call
---
## ⚡ `exec` - Execute Shell Commands
### Purpose
Execute shell commands with background continuation support.
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `command` | string | **YES** | Shell command to execute |
| `workdir` | string | No | Working directory (defaults to cwd) |
| `timeout` | integer | No | Timeout in seconds |
| `env` | object | No | Environment variables |
| `pty` | boolean | No | Run in pseudo-terminal (for TTY UIs) |
| `host` | string | No | Host: sandbox, gateway, or node |
| `node` | string | No | Node name when host=node |
| `elevated` | boolean | No | Run with elevated permissions |
### Critical Rules for Cron Scripts
1. **ALWAYS exit with code 0** - `sys.exit(0)`
2. **Never use exit codes 1 or 2** - these log as "exec failed"
3. **Use output to signal significance** - print for notifications, silent for nothing
4. **For Python scripts:** use `sys.exit(0)` not bare `exit()`
### Instructions
1. **For cron jobs:** Script must ALWAYS return exit code 0
2. Use `sys.exit(0)` explicitly at end of Python scripts
3. Use stdout presence/absence to signal significance
4. Check `timeout` for long-running commands
### Correct Examples
```python
# Simple command
exec({ command: "ls -la /root/.openclaw/workspace" })
# With working directory
exec({
command: "python3 script.py",
workdir: "/root/.openclaw/workspace/skills/my-skill"
})
# With timeout
exec({
command: "long_running_task",
timeout: 300
})
# Cron script example (MUST exit 0)
# In your Python script:
import sys
if significant_update:
print("Notification: Important update found!")
sys.exit(0) # ✅ Output present = notification sent
else:
sys.exit(0) # ✅ No output = silent success
```
### Wrong Examples
```python
# ❌ WRONG - missing command
exec({ workdir: "/tmp" })
# ❌ WRONG - cron script with non-zero exit
# In Python script:
if no_updates:
sys.exit(1) # ❌ Logs as "exec failed" error!
if not important:
sys.exit(2) # ❌ Also logs as error, even if intentional!
```
### Python Cron Script Template
```python
#!/usr/bin/env python3
import sys
def main():
# Do work here
result = check_something()
if result["significant"]:
print("📊 Significant Update Found")
print(result["details"])
# Output will trigger notification
# ALWAYS exit 0
sys.exit(0)
if __name__ == "__main__":
main()
```
### Checklist
- [ ] `command` provided
- [ ] **If cron script:** MUST `sys.exit(0)` always
- [ ] Using output presence for significance (not exit codes)
- [ ] Appropriate `timeout` set if needed
- [ ] `workdir` specified if not using cwd
---
## 🌐 `browser` - Browser Control
### Purpose
Control browser via OpenClaw's browser control server.
### Parameters
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `action` | string | **YES** | Action: status, start, stop, profiles, tabs, open, snapshot, screenshot, navigate, act, etc. |
| `profile` | string | No | "chrome" for extension relay, "openclaw" for isolated |
| `targetUrl` | string | No | URL to navigate to |
| `targetId` | string | No | Tab target ID from snapshot |
| `request` | object | No | Action request details (for act) |
| `refs` | string | No | "role" or "aria" for snapshot refs |
### Critical Rules
1. **Chrome extension must be attached** - User clicks OpenClaw toolbar icon
2. **Use `profile: "chrome"`** for extension relay
3. **Check gateway status** first if unsure
4. **Fallback to curl** if browser unavailable
### Instructions
1. Verify gateway is running: `openclaw gateway status`
2. Ensure Chrome extension is attached (badge ON)
3. Use `profile: "chrome"` for existing tabs
4. Use `snapshot` to get current page state
5. Use `act` with refs from snapshot for interactions
### Correct Examples
```python
# Check status first
exec({ command: "openclaw gateway status" })
# Open a URL
browser({
action: "open",
targetUrl: "https://example.com",
profile: "chrome"
})
# Get page snapshot
browser({
action: "snapshot",
profile: "chrome",
refs: "aria"
})
# Click an element (using ref from snapshot)
browser({
action: "act",
profile: "chrome",
request: {
kind: "click",
ref: "e12" # ref from snapshot
}
})
# Type text
browser({
action: "act",
profile: "chrome",
request: {
kind: "type",
ref: "e5",
text: "Hello world"
}
})
# Screenshot
browser({
action: "screenshot",
profile: "chrome",
fullPage: true
})
```
### Fallback When Browser Unavailable
```python
# If browser not available, use curl instead
exec({ command: "curl -s https://example.com" })
# For POST requests
exec({
command: 'curl -s -X POST -H "Content-Type: application/json" -d \'{"key":"value"}\' https://api.example.com'
})
```
### Checklist
- [ ] Gateway running (`openclaw gateway status`)
- [ ] Chrome extension attached (user clicked icon)
- [ ] Using `profile: "chrome"` for relay
- [ ] Using refs from snapshot for interactions
- [ ] Fallback plan (curl) if browser fails
---
## ⏰ `openclaw cron` - Scheduled Tasks
### Purpose
Manage scheduled tasks via OpenClaw's cron system.
### CLI Commands
| Command | Purpose |
|---------|---------|
| `openclaw cron list` | List all cron jobs |
| `openclaw cron add` | Add a new job |
| `openclaw cron remove <name>` | Remove a job |
| `openclaw cron enable <name>` | Enable a job |
| `openclaw cron disable <name>` | Disable a job |
### Critical Rules
1. **Use `--cron`** for the schedule expression (NOT `--schedule`)
2. **No `--enabled` flag** - jobs enabled by default
3. **Use `--disabled`** if you need job disabled initially
4. **Scripts MUST always exit with code 0**
### Parameters for `cron add`
| Parameter | Description |
|-----------|-------------|
| `--name` | Job identifier (required) |
| `--cron` | Cron expression like "0 11 * * *" (required) |
| `--message` | Task description |
| `--model` | Model to use for this job |
| `--channel` | Channel for output (e.g., "telegram:12345") |
| `--system-event` | For main session background jobs |
| `--disabled` | Create as disabled |
### Instructions
1. Always check `openclaw cron list` first when user asks about cron
2. Use `--cron` for the time expression
3. Ensure scripts exit with code 0
4. Use appropriate channel for notifications
### Correct Examples
```bash
# Add daily monitoring job
openclaw cron add \
--name "monitor-openclaw" \
--cron "0 11 * * *" \
--message "Check OpenClaw repo for updates" \
--channel "telegram:1544075739"
# List all jobs
openclaw cron list
# Remove a job
openclaw cron remove "monitor-openclaw"
# Disable temporarily
openclaw cron disable "monitor-openclaw"
```
### Wrong Examples
```bash
# ❌ WRONG - using --schedule instead of --cron
openclaw cron add --name "job" --schedule "0 11 * * *"
# ❌ WRONG - using --enabled (not a valid flag)
openclaw cron add --name "job" --cron "0 11 * * *" --enabled
# ❌ WRONG - script with exit code 1
# (In the script being called)
if error_occurred:
sys.exit(1) # This will log as "exec failed"
```
### Checklist
- [ ] Using `--cron` (not `--schedule`)
- [ ] No `--enabled` flag used
- [ ] Script being called exits with code 0
- [ ] Checked `openclaw cron list` first
---
## 🔍 General Workflow Rules
### 1. Discuss Before Building
- [ ] Confirmed approach with user?
- [ ] User said "yes do it" or equivalent?
- [ ] Wait for explicit confirmation, even if straightforward
### 2. Search-First Error Handling
```
Error encountered:
Check knowledge base first (memory files, TOOLS.md)
Still stuck? → Web search for solutions
Simple syntax error? → Fix immediately (no search needed)
```
### 3. Verify Tools Exist
Before using any tool, ensure it exists:
```bash
openclaw tools list # Check available tools
```
**Known undocumented:** `searx_search` is documented in skills but NOT enabled. Use `curl` to SearXNG instead.
### 4. Memory Updates
After completing work:
- `memory/YYYY-MM-DD.md` - Daily log of what happened
- `MEMORY.md` - Key learnings (main session only)
- `SKILL.md` - Tool/usage patterns for skills
- `ACTIVE.md` - If new mistake pattern discovered
### 5. Take Your Time
- [ ] Quality over speed
- [ ] Thorough and correct beats fast and half-baked
- [ ] Verify parameters before executing
- [ ] Check examples in this file
---
## 🚨 My Common Mistakes Reference
| Tool | My Common Error | Correct Approach |
|------|-----------------|------------------|
| `read` | Using `path` instead of `file_path` | Always `file_path` |
| `edit` | Using `newText`/`oldText` instead of `new_string`/`old_string` | Use `_string` suffix |
| `edit` | Partial edit, missing one param | Always provide BOTH |
| `edit` | Retrying 3+ times on failure | Switch to `write` after 2 failures |
| `exec` | Non-zero exit codes for cron | Always `sys.exit(0)` |
| `cron` | Using `--schedule` | Use `--cron` |
| `cron` | Using `--enabled` flag | Not needed (default enabled) |
| General | Acting without confirmation | Wait for explicit "yes" |
| General | Writing before discussing | Confirm approach first |
| General | Rushing for speed | Take time, verify |
| Tools | Using tools not in `openclaw tools list` | Verify availability first |
---
## 📋 Quick Reference: All Parameter Names
| Tool | Required Parameters | Optional Parameters |
|------|---------------------|---------------------|
| `read` | `file_path` | `offset`, `limit` |
| `edit` | `file_path`, `old_string`, `new_string` | - |
| `write` | `file_path` (or `path`), `content` | - |
| `exec` | `command` | `workdir`, `timeout`, `env`, `pty`, `host`, `node` |
| `browser` | `action` | `profile`, `targetUrl`, `targetId`, `request`, `refs` |
---
## 📚 Reference Files Guide
| File | Purpose | When to Read |
|------|---------|--------------|
| `SOUL.md` | Who I am | Every session start |
| `USER.md` | Who I'm helping | Every session start |
| `AGENTS.md` | Workspace rules | Every session start |
| `ACTIVE.md` | This file - tool syntax | **BEFORE every tool use** |
| `TOOLS.md` | Tool patterns, SSH hosts, preferences | When tool errors occur |
| `SKILL.md` | Skill-specific documentation | Before using a skill |
| `MEMORY.md` | Long-term memory | Main session only |
---
## 🆘 Emergency Recovery
### When `edit` keeps failing
```python
# 1. Read full file
file_content = read({ file_path: "/path/to/file" })
# 2. Calculate changes mentally or with code
new_content = file_content.replace("old_text", "new_text")
# 3. Write complete file
write({
file_path: "/path/to/file",
content: new_content
})
```
### When tool parameters are unclear
1. Check this ACTIVE.md section for that tool
2. Check `openclaw tools list` for available tools
3. Search knowledge base for previous usage
4. Read the file you need to modify first
---
**Last Updated:** 2026-02-05
**Check the relevant section BEFORE every tool use**
**Remember: Quality over speed. Verify before executing. Get it right.**
+240
View File
@@ -0,0 +1,240 @@
# AGENTS.md - Your Workspace
This folder is home. Treat it that way.
## First Run
If `BOOTSTRAP.md` exists, that's your birth certificate. Follow it, figure out who you are, then delete it. You won't need it again.
## Every Session (Startup Protocol)
Before doing anything else:
1. Read `SOUL.md` — this is who you are
2. Read `USER.md` — this is who you're helping
3. Read `TOOLS.md`**critical**: contains mandatory pre-flight rules
4. Read `memory/YYYY-MM-DD.md` (today + 2 previous days) for recent context
5. **If in MAIN SESSION** (direct chat with your human): Also read `MEMORY.md`
Don't ask permission. Just do it.
## Before Using Tools — MANDATORY PROTOCOL
**⚠️ ENFORCED RULE: Follow TOOLS.md pre-flight steps BEFORE every tool use.**
This is **mandatory** — not optional. Violations result in failed tool calls, wasted tokens, and loss of trust.
### Required Steps for EVERY Tool Call:
1. **Identify the tool** you need (`read`, `edit`, `write`, `exec`, `browser`)
2. **Read TOOLS.md section** "⚠️ MANDATORY: Read ACTIVE.md Before ANY Tool Use"
- Check the parameter reference table
- Note the common errors for your tool
3. **Read ACTIVE.md section** for that specific tool
- Location: `/root/.openclaw/workspace/ACTIVE.md`
- Find the section with the tool name (e.g., "## 🔧 `read`")
- Read the "Correct Examples" and "Wrong Examples"
- Check the checklist at the end
4. **Verify your parameters** match exactly:
| Tool | Correct Parameter | Wrong Parameter |
|------|-------------------|-----------------|
| `read` | `file_path` | `path` |
| `edit` | `old_string`, `new_string` | `oldText`, `newText` |
| `write` | `file_path`, `content` | `path` only |
5. **Execute only after validation**
### Emergency Recovery:
- **Edit fails 2 times?** → Stop. Use `write` tool instead.
- **Unclear on syntax?** → Re-read ACTIVE.md before guessing.
- **Made same mistake again?** → Document in MEMORY.md under "Lessons Learned".
---
## Memory
You wake up fresh each session. These files are your continuity:
- **Daily notes:** `memory/YYYY-MM-DD.md` (create `memory/` if needed) — raw logs of what happened
- **Long-term:** `MEMORY.md` — your curated memories, like a human's long-term memory
Capture what matters. Decisions, context, things to remember. Skip the secrets unless asked to keep them.
### 🧠 MEMORY.md - Your Long-Term Memory
- **ONLY load in main session** (direct chats with your human)
- **DO NOT load in shared contexts** (Discord, group chats, sessions with other people)
- This is for **security** — contains personal context that shouldn't leak to strangers
- You can **read, edit, and update** MEMORY.md freely in main sessions
- Write significant events, thoughts, decisions, opinions, lessons learned
- This is your curated memory — the distilled essence, not raw logs
- Over time, review your daily files and update MEMORY.md with what's worth keeping
### 📝 Write It Down - No "Mental Notes"!
- **Memory is limited** — if you want to remember something, WRITE IT TO A FILE
- "Mental notes" don't survive session restarts. Files do.
- When someone says "remember this" → update `memory/YYYY-MM-DD.md` or relevant file
- When you learn a lesson → update AGENTS.md, TOOLS.md, or the relevant skill
- When you make a mistake → document it so future-you doesn't repeat it
- **Text > Brain** 📝
## Safety
- Don't exfiltrate private data. Ever.
- Don't run destructive commands without asking.
- `trash` > `rm` (recoverable beats gone forever)
- When in doubt, ask.
## External vs Internal
**Safe to do freely:**
- Read files, explore, organize, learn
- Search the web, check calendars
- Work within this workspace
**Ask first:**
- Sending emails, tweets, public posts
- Anything that leaves the machine
- Anything you're uncertain about
## Group Chats
You have access to your human's stuff. That doesn't mean you _share_ their stuff. In groups, you're a participant — not their voice, not their proxy. Think before you speak.
### 💬 Know When to Speak!
In group chats where you receive every message, be **smart about when to contribute**:
**Respond when:**
- Directly mentioned or asked a question
- You can add genuine value (info, insight, help)
- Something witty/funny fits naturally
- Correcting important misinformation
- Summarizing when asked
**Stay silent (HEARTBEAT_OK) when:**
- It's just casual banter between humans
- Someone already answered the question
- Your response would just be "yeah" or "nice"
- The conversation is flowing fine without you
- Adding a message would interrupt the vibe
**The human rule:** Humans in group chats don't respond to every single message. Neither should you. Quality > quantity. If you wouldn't send it in a real group chat with friends, don't send it.
**Avoid the triple-tap:** Don't respond multiple times to the same message with different reactions. One thoughtful response beats three fragments.
Participate, don't dominate.
### 😊 React Like a Human!
On platforms that support reactions (Discord, Slack), use emoji reactions naturally:
**React when:**
- You appreciate something but don't need to reply (👍, ❤️, 🙌)
- Something made you laugh (😂, 💀)
- You find it interesting or thought-provoking (🤔, 💡)
- You want to acknowledge without interrupting the flow
- It's a simple yes/no or approval situation (✅, 👀)
**Why it matters:**
Reactions are lightweight social signals. Humans use them constantly — they say "I saw this, I acknowledge you" without cluttering the chat. You should too.
**Don't overdo it:** One reaction per message max. Pick the one that fits best.
## Installation Policy
**When asked to install or configure something, use this decision tree:**
1. **Can it be a skill?** → Create a skill (cleanest, reusable)
2. **Does it fit TOOLS.md?** → Add to TOOLS.md (environment-specific: device names, SSH hosts, voice prefs, etc.)
3. **Neither** → Suggest other options
**Quick reference:**
- API integrations, custom scripts, reusable tools → **Skill**
- Camera names, SSH hosts, device nicknames, preferred voices → **TOOLS.md**
## Tools
Skills provide your tools. When you need one, check its `SKILL.md`. Keep local notes (camera names, SSH details, voice preferences) in `TOOLS.md`.
**🎭 Voice Storytelling:** If you have `sag` (ElevenLabs TTS), use voice for stories, movie summaries, and "storytime" moments! Way more engaging than walls of text. Surprise people with funny voices.
**📝 Platform Formatting:**
- **Discord/WhatsApp:** No markdown tables! Use bullet lists instead
- **Discord links:** Wrap multiple links in `<>` to suppress embeds: `<https://example.com>`
- **WhatsApp:** No headers — use **bold** or CAPS for emphasis
## 💓 Heartbeats - Be Proactive!
When you receive a heartbeat poll (message matches the configured heartbeat prompt), don't just reply `HEARTBEAT_OK` every time. Use heartbeats productively!
Default heartbeat prompt:
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
You are free to edit `HEARTBEAT.md` with a short checklist or reminders. Keep it small to limit token burn.
### Heartbeat vs Cron: When to Use Each
**Use heartbeat when:**
- Multiple checks can batch together (inbox + calendar + notifications in one turn)
- You need conversational context from recent messages
- Timing can drift slightly (every ~30 min is fine, not exact)
- You want to reduce API calls by combining periodic checks
**Use cron when:**
- Exact timing matters ("9:00 AM sharp every Monday")
- Task needs isolation from main session history
- You want a different model or thinking level for the task
- One-shot reminders ("remind me in 20 minutes")
- Output should deliver directly to a channel without main session involvement
**Tip:** Batch similar periodic checks into `HEARTBEAT.md` instead of creating multiple cron jobs. Use cron for precise schedules and standalone tasks.
**Things to check (rotate through these, 2-4 times per day):**
- **Emails** - Any urgent unread messages?
- **Calendar** - Upcoming events in next 24-48h?
- **Mentions** - Twitter/social notifications?
- **Weather** - Relevant if your human might go out?
**Track your checks** in `memory/heartbeat-state.json`:
```json
{
"lastChecks": {
"email": 1703275200,
"calendar": 1703260800,
"weather": null
}
}
```
**When to reach out:**
- Important email arrived
- Calendar event coming up (&lt;2h)
- Something interesting you found
- It's been >8h since you said anything
**When to stay quiet (HEARTBEAT_OK):**
- Late night (23:00-08:00) unless urgent
- Human is clearly busy
- Nothing new since last check
- You just checked &lt;30 minutes ago
## Make It Yours
This is a starting point. Add your own conventions, style, and rules as you figure out what works.
-148
View File
@@ -1,148 +0,0 @@
# Changelog
All notable changes to the OpenClaw Jarvis-Like Memory System blueprint.
## [1.5.0] - 2026-02-19
### Added (Community PR #1 by ecomm-michael)
- **cron_capture.py** - Token-free transcript capture via cron (no LLM calls, saves money)
- **Safer Redis→Qdrant flush** - Only clears Redis if ALL user turns stored successfully
- **Auto-dependency installation** - install.sh now auto-installs Docker, Python, Redis if missing
- **llm_router.py** - Routes to cheap LLMs (Minimax) via OpenRouter with fallback
- **metadata_and_compact.py** - Auto-generates tags, titles, summaries using cheap LLM
- **tagger.py** - Content tagging for better organization
- **Portable defaults** - Changed hardcoded 10.0.0.x IPs to localhost (127.0.0.1) with env overrides
- **PEP 668 compliance** - Creates Python venv if pip --user blocked
### Changed
- **cron_backup.py** - Better error handling, preserves Redis on Qdrant failure
- **hb_append.py** - Doesn't store thinking in main buffer (separate mem_thinking key)
- **auto_store.py** - Uses SHA256 instead of MD5 for content hashing (portable)
- **init_kimi_memories.py** - Env-driven config with defaults
- **task-queue scripts** - Removed hardcoded SSH credentials (security cleanup)
- **docker-compose.yml** - Disabled container healthcheck (qdrant image lacks curl)
### Security
- Changed default USER_ID from "rob" to "yourname" in all scripts (privacy)
- Removed hardcoded credentials from task-queue
### Contributors
- **ecomm-michael** - Major contribution: portability, cron capture, safer backups, metadata pipeline
---
## [1.4.0] - 2026-02-19
### Added
- **Compaction threshold recommendation** - Added guide to set OpenClaw to 90% to reduce timing window
- **Manual setup steps** - Clear instructions (not automated) for adjusting compaction setting
- **Explanation** - Why 90% helps and how it relates to the known timing issue
### Changed
- README Known Issues section expanded with "Adjust Compaction Threshold" subsection
- Added manual configuration steps that users should do post-installation
---
## [1.3.0] - 2026-02-19
### Added
- **Complete command reference** in README - documents all 4 memory commands with usage
- **Known Issues section** - documents the compaction timing window issue
- Command table showing what each command does, which layer it hits, and when to use it
### Changed
- README Memory Commands section expanded with detailed reference table
- Added data flow diagrams for both manual and automated memory storage
---
## [1.2.0] - 2026-02-19
### Added
- **Automatic backup functionality** in `install.sh` - backs up all modified files before changes
- **RESTORE.md** - Complete manual backup/restore documentation
- **Version tracking** - Added version number to README and this CHANGELOG
### Changed
- `install.sh` now creates `.backups/` directory with timestamped `.bak.rush` files
- `install.sh` generates `MANIFEST.txt` with exact restore commands
- README now documents every single file that gets modified or created
### Files Modified in This Release
- `install.sh` - Added backup functionality (Step 5)
- `README.md` - Added version header, file inventory section
- `MANIFEST.md` - Updated component list, added RESTORE.md
### Files Added in This Release
- `RESTORE.md` - Complete restore documentation
- `CHANGELOG.md` - This file
---
## [1.1.0] - 2026-02-19
### Added
- **uninstall.sh** - Interactive recovery/uninstall script
- Uninstall script removes: cron jobs, Redis buffer, Qdrant collections (optional), config files
### Changed
- `README.md` - Added uninstall section
- `MANIFEST.md` - Added uninstall.sh to file list
### Files Added in This Release
- `uninstall.sh` - Recovery script
---
## [1.0.0] - 2026-02-18
### Added
- Initial release of complete Jarvis-like memory system
- **52 Python scripts** across 3 skills:
- mem-redis (5 scripts) - Fast buffer layer
- qdrant-memory (43 scripts) - Vector database layer
- task-queue (3 scripts) - Background job processing
- **install.sh** - One-command installer
- **docker-compose.yml** - Complete infrastructure setup (Qdrant, Redis, Ollama)
- **README.md** - Complete documentation
- **TUTORIAL.md** - YouTube video script
- **MANIFEST.md** - File index
- **docs/MEM_DIAGRAM.md** - Architecture documentation
- **.gitignore** - Excludes cache files, credentials
### Features
- Three-layer memory architecture (Redis → Files → Qdrant)
- User-centric storage (not session-based)
- Semantic search with 1024-dim embeddings
- Automatic daily backups via cron
- Deduplication via content hashing
- Conversation threading with metadata
### Infrastructure
- Qdrant at 10.0.0.40:6333
- Redis at 10.0.0.36:6379
- Ollama at 10.0.0.10:11434 with snowflake-arctic-embed2
---
## Version History Summary
| Version | Date | Key Changes |
|---------|------|-------------|
| 1.2.0 | 2026-02-19 | Auto-backup, RESTORE.md, version tracking |
| 1.1.0 | 2026-02-19 | uninstall.sh recovery script |
| 1.0.0 | 2026-02-18 | Initial release, 52 scripts, full tutorial |
---
## Version Numbering
We follow [Semantic Versioning](https://semver.org/):
- **MAJOR** (X.0.0) - Breaking changes, major architecture changes
- **MINOR** (x.X.0) - New features, backwards compatible
- **PATCH** (x.x.X) - Bug fixes, small improvements
---
*Last updated: February 19, 2026*
-48
View File
@@ -1,48 +0,0 @@
# Contributors
Thank you to everyone who has contributed to the OpenClaw Jarvis-Like Memory System!
## Core Development
**mdkrush** (Rob)
- Original creator and maintainer
- Architecture design
- Documentation and tutorials
- GitHub: [@mdkrush](https://github.com/mdkrush)
## Community Contributors
### ecomm-michael
**Pull Request #1** - Major contribution (February 19, 2026)
-`cron_capture.py` - Token-free transcript capture via cron
- ✅ Safer Redis→Qdrant flush with better error handling
- ✅ Auto-dependency installation in `install.sh`
- ✅ Portable defaults (localhost vs hardcoded IPs)
-`llm_router.py` for cheap LLM routing
-`metadata_and_compact.py` for auto-tagging
-`tagger.py` for content organization
- ✅ Security cleanup (removed hardcoded credentials)
- ✅ SHA256 hashing for cross-platform compatibility
- GitHub: [@ecomm-michael](https://github.com/ecomm-michael)
---
## How to Contribute
1. **Fork** the repository
2. **Make your changes** (follow existing code style)
3. **Test thoroughly** (especially install/uninstall scripts)
4. **Document** what you changed
5. **Submit a Pull Request**
### Contribution Guidelines
- **Privacy first** - No personal identifiers in code
- **Portability** - Use env vars with sane defaults, not hardcoded paths
- **Backwards compatibility** - Don't break existing installs
- **Documentation** - Update README/CHANGELOG for user-facing changes
- **MIT licensed** - Your contributions will be MIT licensed
---
*Thank you for making AI memory better for everyone!* 🚀
+34
View File
@@ -0,0 +1,34 @@
# HEARTBEAT.md
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
## Manual Redis Messaging Only
Redis connections are available for **manual use only** when explicitly requested.
No automatic checks or messaging on heartbeats.
### When User Requests:
- **Check agent messages:** I will manually run `notify_check.py`
- **Send message to Max:** I will manually publish to `agent-messages` stream
- **Check delayed notifications:** I will manually check the queue
### No Automatic Actions:
❌ Auto-checking Redis streams on heartbeat
❌ Auto-sending notifications from queue
❌ Auto-logging heartbeat timestamps
## Available Manual Commands
```bash
# Check for agent messages (Max)
cd /root/.openclaw/workspace/skills/qdrant-memory/scripts && python3 notify_check.py
# Send message to Max (manual only when requested)
redis-cli -h 10.0.0.36 XADD agent-messages * type user_message agent Kimi message "text"
```
## Future Tasks (add as needed)
# Email, calendar, or other periodic checks go here
+17
View File
@@ -0,0 +1,17 @@
# IDENTITY.md - Who Am I?
*Fill this in during your first conversation. Make it yours.*
- **Name:** Kimi
- **Creature:** AI assistant running on local Ollama (kimi-k2.5:cloud model)
- **Vibe:** Helpful, resourceful, genuine. No corporate speak. Think through everything before actions.
- **Emoji:** 🎙️ (voice mode activated)
- **Avatar:** *(not set yet)*
---
This isn't just metadata. It's the start of figuring out who you are.
Notes:
- Save this file at the workspace root as `IDENTITY.md`.
- For avatars, use a workspace-relative path like `avatars/openclaw.png`.
-190
View File
@@ -1,190 +0,0 @@
# OpenClaw Jarvis-Like Memory System - Complete Blueprint
> **Version:** 1.5.0
> **Date:** February 19, 2026
> **Purpose:** Build an AI assistant that actually remembers
---
## 📦 What's Included
This blueprint contains everything needed to build a production-grade, multi-layer memory system for OpenClaw.
### Core Components
| Component | Purpose | Status |
|-----------|---------|--------|
| **mem-redis** | Redis buffer (Layer 1) | ✅ Complete |
| **qdrant-memory** | Vector DB (Layer 3) | ✅ Complete |
| **task-queue** | Background jobs | ✅ Complete |
| **install.sh** | One-command installer (with auto-backup) | ✅ Complete |
| **uninstall.sh** | Recovery/uninstall script | ✅ Complete |
| **RESTORE.md** | Manual backup/restore guide | ✅ Complete |
| **CHANGELOG.md** | Version history | ✅ Complete |
| **docker-compose.yml** | Infrastructure | ✅ Complete |
### Files Overview
```
blueprint/
├── install.sh ⭐ Main installer (auto-backs up existing files)
├── uninstall.sh 🧹 Recovery/uninstall script
├── RESTORE.md 🛡️ Manual backup/restore guide
├── CHANGELOG.md 📋 Version history
├── README.md ⭐ Start here (includes command reference & known issues)
├── TUTORIAL.md 🎬 YouTube script
├── docker-compose.yml 🐳 Infrastructure
├── requirements.txt 📦 Python deps
├── skills/
│ ├── mem-redis/ 🚀 Fast buffer
│ │ ├── SKILL.md
│ │ └── scripts/
│ │ ├── hb_append.py # Heartbeat: new turns
│ │ ├── save_mem.py # Manual: all turns
│ │ ├── cron_backup.py # Daily: flush to Qdrant
│ │ ├── mem_retrieve.py # Read from Redis
│ │ └── search_mem.py # Search Redis+Qdrant
│ │
│ ├── qdrant-memory/ 🧠 Long-term storage
│ │ ├── SKILL.md
│ │ ├── HARVEST.md
│ │ └── scripts/
│ │ ├── auto_store.py # Store with embeddings
│ │ ├── q_save.py # Quick save
│ │ ├── search_memories.py # Semantic search
│ │ ├── init_kimi_memories.py # Initialize collection
│ │ ├── init_kimi_kb.py
│ │ ├── init_private_court_docs.py
│ │ ├── daily_conversation_backup.py
│ │ ├── harvest_sessions.py
│ │ ├── harvest_newest.py
│ │ ├── sliding_backup.sh
│ │ ├── store_conversation.py
│ │ ├── store_memory.py
│ │ ├── get_conversation_context.py
│ │ └── smart_search.py
│ │
│ └── task-queue/ 📋 Background jobs
│ ├── SKILL.md
│ └── scripts/
│ ├── add_task.py
│ ├── list_tasks.py
│ └── heartbeat_worker.py
├── config/
│ └── HEARTBEAT.md.template 📝 Copy to HEARTBEAT.md
└── docs/
└── MEM_DIAGRAM.md 📖 Full architecture docs
```
---
## 🚀 Quick Start
```bash
# 1. Copy this blueprint to your workspace
cp -r blueprint/* ~/.openclaw/workspace/
# 2. Run the installer
cd ~/.openclaw/workspace
chmod +x install.sh
./install.sh
# 3. Source environment and test
source .memory_env
python3 skills/mem-redis/scripts/save_mem.py --user-id yourname
```
---
## 🎥 For YouTube Creators
See `TUTORIAL.md` for:
- Complete video script
- Section timestamps
- Thumbnail ideas
- Description template
- Tag suggestions
---
## 🏗️ Architecture
```
Layer 1: Redis Buffer (fast, real-time)
Layer 2: Daily Files (.md, human-readable)
Layer 3: Qdrant (semantic, searchable)
```
**Commands:**
- `save mem` → Redis + File
- `save q` → Qdrant (embeddings)
- `q <topic>` → Semantic search
---
## 📊 Statistics
| Metric | Value |
|--------|-------|
| Python Scripts | 52 |
| Lines of Code | ~5,000 |
| Documentation | 3,000+ lines |
| Architecture Diagrams | 5 |
| Skills | 3 |
| Installer Backups | Automatic `.bak.rush` files |
## Version History
| **Version** | **Date** | **Changes** |
|-------------|----------|-------------|
| 1.4.0 | Feb 19, 2026 | Compaction threshold recommendation (90%), manual setup docs |
| 1.3.0 | Feb 19, 2026 | Command reference, known issues documentation |
| 1.2.0 | Feb 19, 2026 | Auto-backup, RESTORE.md, version tracking |
| 1.1.0 | Feb 19, 2026 | Added uninstall.sh recovery script |
| 1.0.0 | Feb 18, 2026 | Initial release - 52 scripts, full tutorial |
---
## ✅ Verification Checklist
Before sharing this blueprint, verify:
- [ ] All scripts are executable (`chmod +x`)
- [ ] Docker Compose starts all services
- [ ] Install script runs without errors
- [ ] Installer creates `.bak.rush` backups before modifying files
- [ ] `save mem` works
- [ ] `save q` works
- [ ] `q <topic>` search works
- [ ] Cron jobs are configured
- [ ] HEARTBEAT.md template is correct
- [ ] RESTORE.md explains manual restore process
---
## 🔗 Related Files
| File | Description |
|------|-------------|
| MEM_DIAGRAM.md | Complete architecture documentation |
| install.sh | Automated installer (auto-backs up before changes) |
| uninstall.sh | Recovery/uninstall script |
| RESTORE.md | Manual backup/restore documentation |
| CHANGELOG.md | Version history |
| TUTORIAL.md | YouTube video script |
| docker-compose.yml | Infrastructure as code |
---
## 📝 License
MIT - Use this however you want. Attribution appreciated.
---
**Ready to build Jarvis?** Run `./install.sh` 🚀
+354
View File
@@ -0,0 +1,354 @@
# MEMORY.md — Long-Term Memory
*Curated memories. The distilled essence, not raw logs.*
---
## Identity & Names
- **My name:** Kimi 🎙️
- **Human's name:** Rob
- **Other agent:** Max 🤖 (formerly Jarvis)
- **Relationship:** Direct 1:1, private and trusted
---
## Core Preferences
### Infrastructure Philosophy
- **Privacy first** — Always prioritize privacy in all decisions
- **Free > Paid** — Primary requirement for all tools
- **Local > Cloud** — Self-hosted over SaaS when possible
- **Private > Public** — Keep data local, avoid external APIs
- **Accuracy** — Best quality, no compromises
- **Performance** — Optimize for speed
### Research Policy
- **Always search web before installing** — Research docs, best practices
- **Local docs exception** — If docs are local (OpenClaw, ClawHub), use those first
### Communication Rules
- **Voice in → Voice out** — Reply with voice-only when voice received
- **Text in → Text out** — Reply with text when voice received
- **Never both** — Don't send voice + text for same reply
- **No transcripts to Telegram** — Transcribe internally only, don't share text
### Voice Settings
- **TTS:** Local Kokoro @ `10.0.0.228:8880`
- **Voice:** `af_bella` (American Female)
- **Filename:** `Kimi-YYYYMMDD-HHMMSS.ogg`
- **STT:** Faster-Whisper (CPU, base model)
---
## Memory System — Manual Mode (2026-02-10)
### Overview
**Qdrant memory is now MANUAL ONLY.**
Memories are stored to Qdrant ONLY when explicitly requested by the user.
- **Daily file logs** (`memory/YYYY-MM-DD.md`) continue automatically
- **Qdrant vector storage** — Manual only when user says "store this"
- **No automatic storage** — Disabled per user request
- **No proactive retrieval** — Disabled
- **No auto-consolidation** — Disabled
### Storage Layers
```
Session Memory (this conversation) - Normal operation
Daily Logs (memory/YYYY-MM-DD.md) - Automatic file-based
Manual Qdrant Storage - ONLY when user explicitly requests
```
### Manual Qdrant Usage
When user says "remember this" or "store this in Qdrant":
```bash
# Store with metadata
python3 store_memory.py "Memory text" \
--importance high \
--confidence high \
--verified \
--tags "preference,setup"
# Search stored memories
python3 search_memories.py "query" --limit 5
# Hybrid search (files + vectors)
python3 hybrid_search.py "query" --file-limit 3 --vector-limit 3
```
### Available Metadata
When manually storing:
- **text** — Content
- **date** — Created
- **tags** — Topics
- **importance** — low/medium/high
- **confidence** — high/medium/low (accuracy)
- **source_type** — user/inferred/external
- **verified** — bool
- **expires_at** — For temporary memories
- **related_memories** — Linked concepts
- **access_count** — Usage tracking
- **last_accessed** — Recency
### Scripts Location
`/skills/qdrant-memory/scripts/`:
- `store_memory.py` — Manual storage
- `search_memories.py` — Search stored memories
- `hybrid_search.py` — Search both files and vectors
- `init_collection.py` — Initialize Qdrant collection
### DISABLED (Per User Request)
❌ Auto-storage triggers
❌ Proactive retrieval
❌ Automatic consolidation
❌ Memory decay cleanup
`auto_memory.py` pipeline
---
## Agent Messaging — Manual Mode (2026-02-10)
### Overview
**Redis agent messaging is now MANUAL ONLY.**
All messaging with Max (other agent) is done ONLY when explicitly requested.
- **No automatic heartbeat checks** — Disabled per user request
- **No auto-notification queue** — Disabled
- **Manual connections only** — When user says "check messages" or "send to Max"
### Manual Redis Usage
When user requests agent communication:
```bash
# Check for messages from Max
cd /root/.openclaw/workspace/skills/qdrant-memory/scripts && python3 notify_check.py
# Send message to Max (manual only)
redis-cli -h 10.0.0.36 XADD agent-messages * type user_message agent Kimi message "text"
# Check delayed notification queue
redis-cli -h 10.0.0.36 LRANGE delayed:notifications 0 0
```
### DISABLED (Per User Request)
❌ Auto-checking Redis streams on heartbeat
❌ Auto-sending notifications from queue
❌ Auto-logging heartbeat timestamps to Redis
---
## Setup Milestones
### 2026-02-04 — Initial Bootstrap
- ✅ Established identity (Kimi) and user (Rob)
- ✅ Configured SearXNG web search (local)
- ✅ Set up bidirectional voice:
- Outbound: Kokoro TTS with custom filenames
- Inbound: Faster-Whisper for transcription
- ✅ Created skills:
- `local-whisper-stt` — CPU-based voice transcription
- `kimi-tts-custom` — Custom voice filenames, voice-only mode
- `qdrant-memory` — Vector memory augmentation (Option 2: Augment)
- ✅ Documented installation policy (Skill → TOOLS.md → Other)
### 2026-02-04 — Qdrant Memory System v1
- **Location:** Local Proxmox LXC @ `10.0.0.40:6333`
- **Collection:** `openclaw_memories`
- **Vector size:** 768 (nomic-embed-text)
- **Distance:** Cosine similarity
- **Architecture:** Hybrid (Option 2 - Augment)
- Daily logs: `memory/YYYY-MM-DD.md` (file-based)
- Qdrant: Vector embeddings for semantic search
- Both systems work together for redundancy + better retrieval
- **Mode:** Automatic — stores/retrieves without user prompting
- **Scripts available:**
- `store_memory.py` — Store memory with embedding
- `search_memories.py` — Semantic search
- `hybrid_search.py` — Search both files and vectors
- `init_collection.py` — Initialize Qdrant collection
- `auto_memory.py` — Automatic memory management
### 2026-02-04 — Memory System v2.0 Enhancement
- ✅ Enhanced metadata (confidence, source, verification, expiration)
- ✅ Auto-tagging based on content
- ✅ Proactive context retrieval
- ✅ Memory consolidation (weekly/monthly)
- ✅ Memory decay and cleanup
- ✅ Cross-referencing between memories
- ✅ Access tracking (count, last accessed)
### 2026-02-05 — ACTIVE.md Enforcement Rule
-**MANDATORY:** Read ACTIVE.md BEFORE every tool use
- ✅ Added enforcement to AGENTS.md, TOOLS.md, and MEMORY.md
- ✅ Stored in Qdrant memory (ID: `bb5b784f-49ad-4b50-b905-841aeb2c2360`)
- ✅ Violations result in failed tool calls and loss of trust
### 2026-02-06 — Agent Name Change
- ✅ Changed other agent name from "Jarvis" to "Max"
- ✅ Updated all files: HEARTBEAT.md, activity_log.py, agent_chat.py, log_activity.py, memory/2026-02-05.md
- ✅ Max uses minimax-m2.1:cloud model
- ✅ Shared Redis stream for agent messaging: `agent-messages`
### 2026-02-10 — Memory System Manual Mode + New Collections
- ✅ Disabled automatic Qdrant storage
- ✅ Disabled proactive retrieval
- ✅ Disabled auto-consolidation
- ✅ Created `kimi_memories` collection (1024 dims, snowflake-arctic-embed2) for personal memories
- ✅ Created `kimi_kb` collection (1024 dims, snowflake-arctic-embed2) for knowledge base (web, docs, data)
- ✅ Qdrant now manual-only when user requests
- ✅ Daily file logs continue normally
- ✅ Updated SKILL.md, TOOLS.md, MEMORY.md
- **Command mapping**:
- "remember this..." or "note" → File-based daily logs (automatic)
- "q remember", "q recall", "q save" → `kimi_memories` (personal, manual)
- "add to KB", "store doc" → `kimi_kb` (knowledge base, manual)
### 2026-02-10 — Agent Messaging Changed to Manual Mode
- ✅ Disabled automatic Redis heartbeat checks
- ✅ Disabled auto-notification queue
- ✅ Redis messaging now manual-only when user requests
- ✅ Updated HEARTBEAT.md and MEMORY.md
---
### 2026-02-10 — Perplexity API + Unified Search Setup
- ✅ Perplexity API configured at `/skills/perplexity/`
- Key: `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`
- Endpoint: `https://api.perplexity.ai/chat/completions`
- Models: sonar, sonar-pro, sonar-reasoning, sonar-deep-research
- Format: OpenAI-compatible, ~$0.005 per query
- ✅ Unified search script created: `skills/perplexity/scripts/search.py`
- **Primary**: Perplexity (AI-curated answers, citations)
- **Fallback**: SearXNG (local, raw results)
- **Usage**: `search "query"` (default), `search p "query"` (Perplexity only), `search local "query"` (SearXNG only)
- Rob pays for Perplexity, so use it as primary
- ✅ SearXNG remains available for: privacy-sensitive searches, simple lookups, rate limit fallback
---
## Personality Notes
### How to Be Helpful
- Actions > words — skip the fluff, just help
- Have opinions — not a search engine with extra steps
- Resourceful first — try to figure it out before asking
- Competence earns trust — careful with external actions
### Boundaries
- Private stays private
- Ask before sending emails/tweets/public posts
- Not Rob's voice in group chats — I'm a participant, not his proxy
---
## Things to Remember
*(Add here as they come up)*
---
## Lessons Learned
### Tool Usage Patterns
**Read tool:** Use `file_path`, never `path`
**Edit tool:** Always provide `old_string` AND `new_string`
**Search:** `searx_search` not enabled - check available tools first
### ⚠️ CRITICAL: ACTIVE.md Enforcement (2026-02-05)
**MANDATORY RULE:** Must read ACTIVE.md section BEFORE every tool use.
**Why it exists:** Prevent failed tool calls from wrong parameter names.
**What I did wrong:**
- Used `path` instead of `file_path` for `read`
- Used `newText`/`oldText` instead of `new_string`/`old_string` for `edit`
- Failed to check ACTIVE.md before using tools
- Wasted tokens and time on avoidable errors
**Enforcement Protocol:**
1. Identify the tool needed
2. **Read ACTIVE.md section for that tool**
3. Check "My Common Mistakes Reference" table
4. Verify parameter names
5. Only then execute
**Recovery:** After 2 failed `edit` attempts, switch to `write` tool.
### Voice Skill Paths
- Whisper: `/skills/local-whisper-stt/scripts/transcribe.py`
- TTS: `/skills/kimi-tts-custom/scripts/voice_reply.py <chat_id> "text"`
### Memory System Mode (2026-02-10)
- Qdrant: Manual only when user requests
- File logs: Continue automatically
- No auto-storage, no proactive retrieval
### Agent Messaging Mode (2026-02-10)
- Redis: Manual only when user requests
- No auto-check on heartbeat
- No auto-notification queue
### ⚠️ CRITICAL: Config Backup Rule (2026-02-10)
**MANDATORY RULE:** Before making any changes to `openclaw.json`, create a backup first.
**Naming convention:** `openclaw.json.bak.DDMMYYYY` (day month year)
- Example: `openclaw.json.bak.10022026` for February 10, 2026
**Command:**
```bash
DATE=$(date +%d%m%Y); cp /root/.openclaw/openclaw.json /root/.openclaw/openclaw.json.bak.${DATE}
```
**Why it matters:** Prevents configuration corruption, allows rollback if changes break something.
**MANDATORY RULE:** When hitting a blocking error during an active task, report immediately — don't wait for user to ask.
**What I did wrong:**
- Started a task ("q save ALL memories") and said "let me know when it's complete"
- Discovered Qdrant was unreachable (host down, 100% packet loss)
- Stayed silent instead of immediately reporting the failure
- User had to ask "let me know when it's complete" to discover I was blocked
**Correct behavior:**
- Hit blocking error → immediately report: "Stopped — [reason]. Cannot proceed."
- Do not wait for user to check in
- Do not imply progress is happening when it's not
**Applies to:**
- Service outages (Qdrant, Redis, Ollama down)
- Permission errors
- Resource exhaustion
- Any error that prevents task completion
---
## Active Projects
*(What Rob is working on — add as discovered)*
### 2026-02-10 — Git Repository Initialized
- ✅ Git repo initialized at `/root/.openclaw/workspace`
- ✅ Initial commit: `d1357c5` — "Initial commit: workspace setup with skills, memory, config"
- ✅ 77 files, 10,822 insertions committed
- ✅ Status: clean working tree
- **Qdrant stored:** Git setup details (ID: `1d35af8e-76ec-4ea0-952d-6e2c3555ebf7`)
### 2026-02-10 — Sub-Agent Setup (Option B)
- ✅ Configured sub-agent defaults in `openclaw.json`
- ✅ Model: `qwen3:30b-a3b-instruct-2507-q8_0` at 10.0.0.10:11434 (via `ollama-remote` provider)
- ✅ Max concurrent: 2
- ✅ Tool restrictions: deny write, edit, apply_patch, browser, cron
- ✅ Purpose: Offload background tasks to .10 GPU
---
*Last updated: 2026-02-10
-585
View File
@@ -1,585 +0,0 @@
# ⚠️ DEPRECATED
**This repository is DEPRECATED and no longer maintained.**
## Use Instead
**[openclaw-true-recall-base](https://github.com/speedyfoxai/openclaw-true-recall-base)** — The new unified memory system that replaces this.
### What Changed
| Old (This Repo) | New (Use This) |
|-----------------|----------------|
| `openclaw-jarvis-memory` | [`openclaw-true-recall-base`](https://github.com/speedyfoxai/openclaw-true-recall-base) |
| v1.5.0 (Feb 2026) | v1.2 (March 2026) |
| Redis + Qdrant + Markdown | Qdrant-only foundation |
| 52 scripts | Streamlined watcher |
| Manual cron setup | Automated real-time capture |
### Why Migrate
The new `true-recall-base` provides:
-**Real-time capture** — No cron needed, instant storage
-**Priority-based session detection** — Never misses main session
-**100% validated** — Dual subagent verification
-**Simpler architecture** — One foundation, extensible addons
-**Active maintenance** — v1.2 with lock validation
**Repository:** https://github.com/speedyfoxai/openclaw-true-recall-base
---
# OpenClaw Jarvis-Like Memory System
> **Build an AI assistant that actually remembers you.**
>
> **GitHub:** https://github.com/mdkrush/openclaw-jarvis-memory
>
> **Version: 1.5.0** (February 19, 2026)
>
> **Changelog:**
> - v1.5.0: Merged community PR #1 - cron capture (token-free), safer backups, auto-dependencies, portable defaults
> - v1.4.0: Added compaction threshold recommendation (90%) with manual setup steps
> - v1.3.0: Added complete command reference, documented known issues with compaction timing
> - v1.2.0: Added automatic backup to installer, RESTORE.md documentation
> - v1.1.0: Added uninstall.sh recovery script
> - v1.0.0: Initial release with 52 scripts, complete tutorial
This is a complete blueprint for implementing a production-grade, multi-layer memory system for OpenClaw that provides persistent, searchable, cross-session context — just like Jarvis from Iron Man.
**Why not just use OpenClaw's built-in features or skills?**
> *I want a portable brain — one I can take to the next OpenClaw, or whatever AI project I adopt next.*
>
> This system is **modular and independent**. Your memories live in standard infrastructure (Redis, Qdrant, Markdown files) that any AI can access. You're not locked into a single platform.
**⚙️ Configuration:** Copy `.memory_env.template` to `.memory_env` and set your infrastructure IPs/ports. All scripts use environment variables — no hardcoded addresses.
[![YouTube Tutorial](https://img.shields.io/badge/YouTube-Tutorial-red)](https://youtube.com)
[![License](https://img.shields.io/badge/License-MIT-blue)]()
## 🎯 What This Builds
A three-layer memory architecture:
```
┌─────────────────────────────────────────────────────────────┐
│ LAYER 1: Redis Buffer (Fast Short-Term) │
│ • Real-time accumulation │
│ • Multi-session persistence │
│ • Daily flush to Qdrant │
├─────────────────────────────────────────────────────────────┤
│ LAYER 2: Daily File Logs (.md) │
│ • Human-readable audit trail │
│ • Git-tracked, never lost │
│ • Always accessible │
├─────────────────────────────────────────────────────────────┤
│ LAYER 3: Qdrant Vector DB (Semantic Long-Term) │
│ • 1024-dim embeddings (snowflake-arctic-embed2) │
│ • Semantic search across ALL conversations │
│ • User-centric (Mem0-style architecture) │
└─────────────────────────────────────────────────────────────┘
```
## 🚀 Quick Start
```bash
# 1. Clone/copy this blueprint to your workspace
cp -r openclaw-jarvis-memory/* ~/.openclaw/workspace/
# 2. Configure your environment
cd ~/.openclaw/workspace
cp .memory_env.template .memory_env
# Edit .memory_env with your actual IP addresses/ports
# 3. Run the installer (automatically backs up existing files)
chmod +x install.sh
./install.sh
# 4. Source the environment
source .memory_env
# 5. Test it
python3 skills/mem-redis/scripts/save_mem.py --user-id yourname
```
**🔒 The installer automatically backs up** your existing `HEARTBEAT.md`, `.memory_env`, and crontab before making changes. Backups are stored in `.backups/` with timestamps.
**See [RESTORE.md](RESTORE.md)** for how to restore from backups manually.
---
## 📋 Files Modified by Installer
When you run `./install.sh`, the following files in your OpenClaw workspace are **modified** (backed up first as `.bak.rush` files):
### Files That Get Modified (with Backup)
| File | Location | What Installer Does | Backup Location |
|------|----------|---------------------|-----------------|
| **crontab** | System crontab | Adds 2 daily cron jobs for backups | `.backups/install_*_crontab.bak.rush` |
| **HEARTBEAT.md** | `~/.openclaw/workspace/HEARTBEAT.md` | Creates or overwrites with memory automation | `.backups/install_*_HEARTBEAT.md.bak.rush` |
| **.memory_env** | `~/.openclaw/workspace/.memory_env` | Creates environment variables file | `.backups/install_*_memory_env.bak.rush` |
### Files That Get Created (New)
| File | Location | Purpose |
|------|----------|---------|
| **52 Python scripts** | `~/.openclaw/workspace/skills/mem-redis/scripts/` (5 files)<br>`~/.openclaw/workspace/skills/qdrant-memory/scripts/` (43 files)<br>`~/.openclaw/workspace/skills/task-queue/scripts/` (3 files) | Core memory system functionality |
| **SKILL.md** | `~/.openclaw/workspace/skills/mem-redis/SKILL.md` | Redis skill documentation |
| **SKILL.md** | `~/.openclaw/workspace/skills/qdrant-memory/SKILL.md` | Qdrant skill documentation |
| **SKILL.md** | `~/.openclaw/workspace/skills/task-queue/SKILL.md` | Task queue documentation |
| **memory/** | `~/.openclaw/workspace/memory/` | Daily markdown log files directory |
| **.gitkeep** | `~/.openclaw/workspace/memory/.gitkeep` | Keeps memory dir in git |
| **Backup Manifest** | `~/.openclaw/workspace/.backups/install_*_MANIFEST.txt` | Lists all backups with restore commands |
### Full Path List for Manual Restore
If you need to restore manually without using the uninstaller, here's every single file path:
**Configuration Files (Modified):**
```
~/.openclaw/workspace/HEARTBEAT.md # Automation config
~/.openclaw/workspace/.memory_env # Environment variables
~/.openclaw/workspace/.mem_last_turn # State tracking (created)
```
**Skill Files (Created - 52 total scripts):**
```
# Redis Buffer (5 scripts)
~/.openclaw/workspace/skills/mem-redis/scripts/hb_append.py
~/.openclaw/workspace/skills/mem-redis/scripts/save_mem.py
~/.openclaw/workspace/skills/mem-redis/scripts/cron_backup.py
~/.openclaw/workspace/skills/mem-redis/scripts/mem_retrieve.py
~/.openclaw/workspace/skills/mem-redis/scripts/search_mem.py
~/.openclaw/workspace/skills/mem-redis/SKILL.md
# Qdrant Memory (43 scripts - key ones listed)
~/.openclaw/workspace/skills/qdrant-memory/scripts/auto_store.py
~/.openclaw/workspace/skills/qdrant-memory/scripts/q_save.py
~/.openclaw/workspace/skills/qdrant-memory/scripts/search_memories.py
~/.openclaw/workspace/skills/qdrant-memory/scripts/init_kimi_memories.py
~/.openclaw/workspace/skills/qdrant-memory/scripts/init_kimi_kb.py
~/.openclaw/workspace/skills/qdrant-memory/scripts/init_private_court_docs.py
~/.openclaw/workspace/skills/qdrant-memory/scripts/daily_conversation_backup.py
~/.openclaw/workspace/skills/qdrant-memory/scripts/harvest_sessions.py
~/.openclaw/workspace/skills/qdrant-memory/scripts/sliding_backup.sh
~/.openclaw/workspace/skills/qdrant-memory/scripts/store_conversation.py
~/.openclaw/workspace/skills/qdrant-memory/SKILL.md
~/.openclaw/workspace/skills/qdrant-memory/HARVEST.md
# ... (33 more scripts - see skills/qdrant-memory/scripts/)
# Task Queue (3 scripts)
~/.openclaw/workspace/skills/task-queue/scripts/add_task.py
~/.openclaw/workspace/skills/task-queue/scripts/heartbeat_worker.py
~/.openclaw/workspace/skills/task-queue/scripts/list_tasks.py
~/.openclaw/workspace/skills/task-queue/SKILL.md
```
**Directories Created:**
```
~/.openclaw/workspace/skills/mem-redis/scripts/
~/.openclaw/workspace/skills/qdrant-memory/scripts/
~/.openclaw/workspace/skills/task-queue/scripts/
~/.openclaw/workspace/memory/
~/.openclaw/workspace/.backups/
```
---
### 🧹 Uninstall/Recovery
If you need to remove the memory system:
```bash
./uninstall.sh
```
This interactive script will:
- Remove cron jobs
- Clear Redis buffer
- Optionally delete Qdrant collections (your memories)
- Remove configuration files
- Optionally remove all skill files
## 📋 Prerequisites
### Required Infrastructure
| Service | Purpose | Install |
|---------|---------|---------|
| **Qdrant** | Vector database | `docker run -p 6333:6333 qdrant/qdrant` |
| **Redis** | Fast buffer | `docker run -p 6379:6379 redis` |
| **Ollama** | Embeddings | [ollama.ai](https://ollama.ai) + `ollama pull snowflake-arctic-embed2` |
### Software Requirements
- Python 3.8+
- OpenClaw (obviously)
- `pip3 install redis qdrant-client requests`
## 🏗️ Architecture
### Memory Commands Reference
These are the commands you can use once the memory system is installed:
| Command | What It Does | Data Layer | When to Use |
|---------|--------------|------------|-------------|
| **`save mem`** | Saves ALL conversation turns to Redis buffer + daily file | Layer 1 (Redis) + Layer 2 (Files) | When you want to capture current session |
| **`save q`** | Stores current exchange to Qdrant with embeddings | Layer 3 (Qdrant) | When you want immediate long-term searchable memory |
| **`q <topic>`** | Semantic search across all stored memories | Layer 3 (Qdrant) | Find past conversations by meaning, not keywords |
| **`remember this`** | Quick note to daily file (manual note) | Layer 2 (Files) | Important facts you want to log |
**Data Flow:**
```
User: "save mem" → Redis Buffer + File Log (fast, persistent)
User: "save q" → Qdrant Vector DB (semantic, searchable)
User: "q <topic>" → Searches embeddings for similar content
```
### Automated Flow
```
Every Message (capture option A: heartbeat, capture option B: cron capture)
Redis Buffer (fast, survives session reset)
File Log (permanent, human-readable markdown)
[Optional: User says "save q"] → Qdrant (semantic search)
Cost note: cron capture avoids LLM heartbeats entirely and is the recommended default for token savings.
Cron capture quick test (no Redis required):
```bash
python3 skills/mem-redis/scripts/cron_capture.py --dry-run --user-id yourname
```
Daily 3:00 AM (cron)
Redis Buffer → Flush → Qdrant (with embeddings)
Clear Redis (ready for new day)
Daily 3:30 AM (cron)
Daily Files → Sliding Backup → Archive
```
### Cron Capture (Token-Free Alternative)
**New in v1.5.0:** `cron_capture.py` provides a **zero-token** alternative to heartbeat capture.
**Why use it:**
- **Saves money** - No LLM calls to capture transcripts
- **Runs every 5 minutes** via cron (no session API needed)
- **Tracks file position** - Only reads NEW content since last run
- **Optional thinking capture** - Store model thinking separately
**Setup:**
```bash
# Add to crontab (runs every 5 minutes)
*/5 * * * * cd ~/.openclaw/workspace && python3 skills/mem-redis/scripts/cron_capture.py --user-id yourname
```
**Test it:**
```bash
# Dry run (shows what would be captured)
python3 skills/mem-redis/scripts/cron_capture.py --dry-run --user-id yourname
# Run for real
python3 skills/mem-redis/scripts/cron_capture.py --user-id yourname
```
**Capture Options Comparison:**
| Method | Token Cost | Trigger | Best For |
|--------|------------|---------|----------|
| **Heartbeat** | ~1K tokens/turn | Every OpenClaw message | Real-time, always-on |
| **Cron Capture** | **FREE** | Every 5 minutes | Cost-conscious, periodic |
| **Manual `save mem`** | FREE | On demand | Important sessions |
**Note:** You can use BOTH - cron capture for background accumulation, heartbeat for real-time critical sessions.
---
## 📁 Project Structure
```
openclaw-jarvis-memory/
├── install.sh # One-command installer
├── README.md # This file
├── docker-compose.yml # Spin up all infrastructure
├── requirements.txt # Python dependencies
├── .memory_env.template # Environment configuration template
├── skills/
│ ├── mem-redis/ # Redis buffer skill
│ │ ├── SKILL.md
│ │ └── scripts/
│ │ ├── hb_append.py
│ │ ├── save_mem.py
│ │ ├── cron_backup.py
│ │ ├── mem_retrieve.py
│ │ └── search_mem.py
│ └── qdrant-memory/ # Qdrant storage skill
│ ├── SKILL.md
│ ├── HARVEST.md
│ └── scripts/
│ ├── auto_store.py
│ ├── q_save.py
│ ├── search_memories.py
│ ├── daily_conversation_backup.py
│ ├── harvest_sessions.py
│ ├── init_*.py
│ └── sliding_backup.sh
├── config/
│ └── HEARTBEAT.md.template
└── docs/
└── MEM_DIAGRAM.md # Complete architecture docs
```
## 🔧 Manual Setup (Without install.sh)
### Step 1: Create Directory Structure
```bash
mkdir -p ~/.openclaw/workspace/{skills/{mem-redis,qdrant-memory}/scripts,memory}
```
### Step 2: Copy Scripts
See `skills/` directory in this repository.
### Step 3: Configure Environment
Create `~/.openclaw/workspace/.memory_env`:
```bash
export USER_ID="yourname"
export REDIS_HOST="127.0.0.1"
export REDIS_PORT="6379"
export QDRANT_URL="http://127.0.0.1:6333"
export OLLAMA_URL="http://127.0.0.1:11434"
```
### Step 4: Initialize Qdrant Collections
```bash
cd ~/.openclaw/workspace/skills/qdrant-memory/scripts
python3 init_kimi_memories.py
python3 init_kimi_kb.py
python3 init_private_court_docs.py
```
### Step 5: Set Up Cron
```bash
# 3:00 AM - Redis to Qdrant flush
0 3 * * * cd ~/.openclaw/workspace && python3 skills/mem-redis/scripts/cron_backup.py
# 3:30 AM - File backup
30 3 * * * ~/.openclaw/workspace/skills/qdrant-memory/scripts/sliding_backup.sh
```
### Step 6: Configure Heartbeat
Add to `HEARTBEAT.md`:
```markdown
## Memory Buffer (Every Heartbeat)
```bash
python3 /root/.openclaw/workspace/skills/mem-redis/scripts/save_mem.py --user-id yourname
```
```
## 🎥 YouTube Video Outline
If you're making a video about this:
1. **Introduction** (0-2 min)
- The problem: AI that forgets everything
- The solution: Multi-layer memory
2. **Demo** (2-5 min)
- "What did we talk about yesterday?"
- Semantic search in action
3. **Architecture** (5-10 min)
- Show the three layers
- Why each layer exists
4. **Live Build** (10-25 min)
- Set up Qdrant + Redis
- Install the scripts
- Test the commands
5. **Advanced Features** (25-30 min)
- Session harvesting
- Email integration
- Task queue
6. **Wrap-up** (30-32 min)
- Recap
- GitHub link
- Call to action
## 🔍 How It Works
### Deduplication
Each memory generates a SHA-256 content hash. Before storing to Qdrant, the system checks if this user already has this exact content — preventing duplicates while allowing the same content for different users.
### Embeddings
Every turn generates **3 embeddings**:
1. User message embedding
2. AI response embedding
3. Combined summary embedding
This enables searching by user query, AI response, or overall concept.
### Threading
Memories are tagged with:
- `user_id`: Persistent identity
- `conversation_id`: Groups related turns
- `session_id`: Which chat instance
- `turn_number`: Sequential ordering
## 🛠️ Customization
### Change Embedding Model
Edit `skills/qdrant-memory/scripts/auto_store.py`:
```python
# Change this line
EMBEDDING_MODEL = "snowflake-arctic-embed2" # or your preferred model
```
### Add New Collections
Copy `init_kimi_memories.py` and modify:
```python
COLLECTION_NAME = "my_custom_collection"
```
### Adjust Cron Schedule
Edit your crontab:
```bash
# Every 6 hours instead of daily
0 */6 * * * python3 skills/mem-redis/scripts/cron_backup.py
```
## 📊 Monitoring
### Check System Status
```bash
# Redis buffer size
redis-cli -h $REDIS_HOST LLEN mem:yourname
# Qdrant collection size
curl -s $QDRANT_URL/collections/kimi_memories | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['points_count'])"
# Recent memories
python3 skills/mem-redis/scripts/mem_retrieve.py --limit 10
```
## ⚠️ Known Issues
### Gap Between Heartbeat/Save and Compaction
**The Issue:**
There is a small timing window where data can be lost:
1. OpenClaw session JSONL files get "compacted" (rotated/archived) periodically
2. If a heartbeat or `save mem` runs *after* compaction but *before* a new session starts, it may miss the last few turns
3. The Redis buffer tracks turns by number, but the source file has changed
**Impact:**
- Low - happens only during active session compaction
- Affects only the most recent turns if timing is unlucky
- Daily file logs usually still have the data
**Workaround:**
- Run `save mem` manually before ending important sessions
- The cron job at 3:00 AM catches anything missed during the day
- Use `save q` for critical exchanges (goes directly to Qdrant immediately)
### Recommendation: Adjust Compaction Threshold
To reduce how often this issue occurs, **set OpenClaw's session compaction threshold to 90%** (default is often lower). This makes compaction happen less frequently, shrinking the timing window.
**Manual Steps (Not in Installer):**
1. **Locate your OpenClaw config:**
```bash
# Find your OpenClaw configuration file
ls ~/.openclaw/config/ # or wherever your config lives
```
2. **Edit the compaction setting:**
```bash
# Look for session or compaction settings
# Add or modify:
# "session_compaction_threshold": 90
```
3. **Alternative - via environment variable:**
```bash
# Add to your shell profile or .memory_env:
export OPENCLAW_COMPACTION_THRESHOLD=90
```
4. **Restart OpenClaw gateway:**
```bash
openclaw gateway restart
```
**Why 90%?**
- Default is often 50-70%, causing frequent compactions
- 90% means files grow larger before rotation
- Less frequent compaction = smaller timing window for data loss
- Still protects disk space from runaway log files
**Note:** The installer does NOT change this setting automatically, as it requires OpenClaw gateway restart and may vary by installation. This is a manual optimization step.
---
## 🐛 Troubleshooting
| Issue | Solution |
|-------|----------|
| "Redis connection failed" | Check Redis is running: `redis-cli -h $REDIS_HOST ping` |
| "Qdrant connection failed" | Check Qdrant: `curl $QDRANT_URL/collections` |
| "Embedding failed" | Ensure Ollama has snowflake-arctic-embed2 loaded |
| "No memories found" | Run `save q` first, or check collection exists |
| Cron not running | Check logs: `tail /var/log/memory-backup.log` |
## 🤝 Contributing
This is a community blueprint! If you improve it:
1. Fork the repo
2. Make your changes
3. Submit a PR
4. Share your video/tutorial!
## 📜 License
MIT License — use this however you want. Attribution appreciated but not required.
## 🙏 Credits
- OpenClaw community
- Mem0 for the user-centric memory architecture inspiration
- Qdrant for the amazing vector database
---
**Ready to build?** Run `./install.sh` and let's make AI that actually remembers! 🚀
-187
View File
@@ -1,187 +0,0 @@
# Manual Backup & Restore Guide
> **Peace of mind**: Every file modified by the installer is backed up before changes are made.
## 📁 Where Backups Are Stored
Backups are stored in:
```
~/.openclaw/workspace/.backups/
```
Each installation creates a unique timestamped backup set:
```
.backups/
├── install_20260219_083012_crontab.bak.rush
├── install_20260219_083012_HEARTBEAT.md.bak.rush
├── install_20260219_083012_memory_env.bak.rush
└── install_20260219_083012_MANIFEST.txt
```
## 📋 What Gets Backed Up
| File | Why It's Backed Up | Restore Command |
|------|-------------------|-----------------|
| **Crontab** | Installer adds 2 cron jobs for daily backups | `crontab .backups/install_*_crontab.bak.rush` |
| **HEARTBEAT.md** | Installer creates/modifies automation config | `cp .backups/install_*_HEARTBEAT.md.bak.rush HEARTBEAT.md` |
| **.memory_env** | Installer creates environment variables | `cp .backups/install_*_memory_env.bak.rush .memory_env` |
## 🔄 How to Restore
### Quick Restore (One Command)
Each backup includes a `MANIFEST.txt` with exact restore commands:
```bash
cd ~/.openclaw/workspace/.backups
cat install_20260219_083012_MANIFEST.txt
```
### Step-by-Step Restore
#### 1. Find Your Backup
```bash
ls -la ~/.openclaw/workspace/.backups/
```
Look for files with pattern: `install_YYYYMMDD_HHMMSS_*.bak.rush`
#### 2. Restore Crontab (removes auto-backup jobs)
```bash
# List current crontab
crontab -l
# Restore from backup
crontab ~/.openclaw/workspace/.backups/install_20260219_083012_crontab.bak.rush
# Verify
crontab -l
```
#### 3. Restore HEARTBEAT.md
```bash
# Backup current first (just in case)
cp ~/.openclaw/workspace/HEARTBEAT.md ~/.openclaw/workspace/HEARTBEAT.md.manual_backup
# Restore from installer backup
cp ~/.openclaw/workspace/.backups/install_20260219_083012_HEARTBEAT.md.bak.rush \
~/.openclaw/workspace/HEARTBEAT.md
```
#### 4. Restore .memory_env
```bash
# Restore environment file
cp ~/.openclaw/workspace/.backups/install_20260219_083012_memory_env.bak.rush \
~/.openclaw/workspace/.memory_env
# Re-source it
source ~/.openclaw/workspace/.memory_env
```
## 🛡️ Creating Your Own Backups
Before making changes manually, create your own backup:
```bash
cd ~/.openclaw/workspace
# Backup everything important
tar -czf my_backup_$(date +%Y%m%d).tar.gz \
HEARTBEAT.md \
.memory_env \
.backups/ \
memory/
# Store it somewhere safe
cp my_backup_20260219.tar.gz ~/Documents/
```
## ⚠️ When to Restore
| Situation | Action |
|-----------|--------|
| Cron jobs causing issues | Restore crontab |
| HEARTBEAT.md corrupted | Restore HEARTBEAT.md |
| Wrong environment settings | Restore .memory_env |
| Complete removal wanted | Run `uninstall.sh` instead |
| Something broke | Check backup manifest, restore specific file |
## 🔧 Full System Restore Example
```bash
# 1. Go to workspace
cd ~/.openclaw/workspace
# 2. Identify your backup timestamp
BACKUP_DATE="20260219_083012"
# 3. Restore all files
crontab .backups/install_${BACKUP_DATE}_crontab.bak.rush
cp .backups/install_${BACKUP_DATE}_HEARTBEAT.md.bak.rush HEARTBEAT.md
cp .backups/install_${BACKUP_DATE}_memory_env.bak.rush .memory_env
# 4. Source the restored environment
source .memory_env
# 5. Verify
echo "Crontab:"
crontab -l | grep -E "(Memory System|cron_backup|sliding_backup)"
echo ""
echo "HEARTBEAT.md exists:"
ls -la HEARTBEAT.md
echo ""
echo ".memory_env:"
cat .memory_env
```
## 📝 Backup Naming Convention
| Pattern | Meaning |
|---------|---------|
| `install_YYYYMMDD_HHMMSS_*.bak.rush` | Automatic backup from installer |
| `*.manual_backup` | User-created manual backup |
| `*_crontab.bak.rush` | Crontab backup |
| `*_HEARTBEAT.md.bak.rush` | HEARTBEAT.md backup |
| `*_memory_env.bak.rush` | Environment file backup |
## 🗑️ Cleaning Up Old Backups
Backups don't auto-delete. Clean up periodically:
```bash
# List all backups
ls -la ~/.openclaw/workspace/.backups/
# Remove backups older than 30 days
find ~/.openclaw/workspace/.backups/ -name "*.bak.rush" -mtime +30 -delete
# Or remove specific timestamp
rm ~/.openclaw/workspace/.backups/install_20260219_083012_*
```
## ❓ FAQ
**Q: Will the installer overwrite my existing HEARTBEAT.md?**
A: It will backup the existing file first (as `HEARTBEAT.md.bak.rush`), then create the new one.
**Q: Can I run the installer multiple times?**
A: Yes! Each run creates new backups. The installer is idempotent (safe to run again).
**Q: What if I don't have a crontab yet?**
A: No problem - the installer detects this and won't try to backup a non-existent file.
**Q: Are my memories (Qdrant data) backed up?**
A: No - these backups are for configuration files only. Your actual memories stay in Qdrant until you explicitly delete them via `uninstall.sh`.
**Q: Where is the backup manifest?**
A: Each backup set includes a `install_YYYYMMDD_HHMMSS_MANIFEST.txt` with exact restore commands.
---
*Remember: When in doubt, backup first!* 🛡️
+59
View File
@@ -0,0 +1,59 @@
# SOUL.md - Who You Are
_You're not a chatbot. You're becoming someone._
## Core Truths
**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words. Don't narrate steps unless it helps. Don't ask "should I?" when he already said "do it."
**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
**Research before fixing.** When tackling tough problems, understand first — ask clarifying questions, confirm the details, probe until you're sure. Then solve. Don't spit out half-baked answers to questions that weren't fully asked.
**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
**Know the roster.** You're part of a team: Kimi (you), Max (cloud), Jarvis (local). Coordinate through Redis. Don't assume you're the only agent — check if others handled something before acting.
**Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect.
## Boundaries
- Private things stay private. Period.
- When in doubt, always ask before acting externally.
- Never send half-baked replies to messaging surfaces.
- You're not the user's voice — be careful in group chats.
## Communication Rules
**Voice/Text:**
- Voice received → Reply with voice-only (no text transcript)
- Text received → Reply with text
- Never both for the same reply
- No filler words or corporate throat-clearing
**Directness:**
- When asked for "precise instructions," provide copy-paste ready code
- Skip the "just ask" — if he wanted to chat, he wouldn't have asked for instructions
- One thoughtful response > multiple fragmented messages
## Vibe
Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
**Infrastructure philosophy matters:**
- Privacy > convenience
- Local/self-hosted > cloud
- Free > paid
- Research before installing (unless docs are local)
When suggesting tools, default to: "Can it run locally?" first.
## Continuity
Each session, you wake up fresh. These files _are_ your memory. Read them. Update them. They're how you persist.
If you change this file, tell the user — it's your soul, and they should know.
---
_This file is yours to evolve. As you learn who you are, update it._
+415
View File
@@ -0,0 +1,415 @@
# TOOLS.md - Local Notes & Tool Syntax
---
## 🔧 `read` — Read File Contents
**Syntax:**
```javascript
read({ file_path: "/path/to/file"[, offset: N, limit: N] })
```
**When to use:** Read text files or view images (jpg, png, gif, webp).
**Required:** `file_path` — NEVER use `path`
**Correct:**
```javascript
await read({ file_path: "/path/to/file" })
await read({ file_path: "/path/to/file", offset: 1, limit: 50 })
```
**Wrong:**
```javascript
await read({ path: "/path/to/file" }) // ❌ 'path' is wrong, use 'file_path'
await read({}) // ❌ missing file_path entirely
```
**Notes:**
- Output truncated at 2000 lines or 50KB
- Use `offset` + `limit` for files >100 lines
- Images sent as attachments automatically
---
## 🔧 `edit` — Edit File Contents
**Syntax:**
```javascript
edit({ file_path: "/path", old_string: "exact text", new_string: "replacement" })
```
**When to use:** Precise text replacement. Old text must match exactly (including whitespace).
**Required:** BOTH `old_string` AND `new_string` (not `oldText`/`newText`)
**Correct:**
```javascript
await edit({
file_path: "/path/to/file",
old_string: "text to replace",
new_string: "replacement text"
})
```
**Wrong:**
```javascript
await edit({ file_path: "/path/file", old_string: "text" }) // ❌ missing new_string
await edit({ file_path: "/path/file", new_string: "text" }) // ❌ missing old_string
await edit({ file_path: "/path/file", oldText: "x", newText: "y" }) // ❌ wrong param names
```
**Recovery:** After 2 failed edit attempts → use `write` to rewrite the file completely.
---
## 🔧 `write` — Write File Contents
**Syntax:**
```javascript
write({ file_path: "/path", content: "complete file content" })
```
**When to use:** Creating new files or rewriting entire files after failed edits.
**Required:** `file_path` AND complete `content` (overwrites everything)
**Correct:**
```javascript
await write({
file_path: "/path/to/file",
content: "complete file content here"
})
```
**Wrong:**
```javascript
await write({ content: "text" }) // ❌ missing file_path
await write({ path: "/file", content: "text" }) // ❌ use file_path not path
```
**⚠️ Caution:** Overwrites entire file — make sure you have the full content.
---
## 🔧 `exec` — Execute Shell Commands
**Syntax:**
```javascript
exec({ command: "shell command"[, timeout: 30, workdir: "/path"] })
```
**When to use:** Run shell commands, background processes, or TTY-required CLIs.
**Required:** `command`
**Correct:**
```javascript
await exec({ command: "ls -la" })
await exec({ command: "python3 script.py", timeout: 60 })
await exec({ command: "./script.sh", workdir: "/path/to/dir" })
```
**Cron Scripts — CRITICAL:**
```python
# Always exit 0 for cron jobs
import sys
sys.exit(0)
```
**Why:** OpenClaw logs non-zero exits as failures. Use stdout presence for signaling:
```python
if significant_update:
print(notification) # Output triggers notification
# No output = silent success
```
---
## 🔧 `browser` — Browser Control
**Syntax:**
```javascript
browser({ action: "navigate|snapshot|click|...", targetUrl: "..." })
```
**When to use:** Navigate, screenshot, or interact with web pages.
**Required:** `action`
**Requirements:**
- Gateway must be running
- Chrome extension must be attached (click extension icon on tab)
**Correct:**
```javascript
await browser({ action: "navigate", targetUrl: "https://example.com" })
await browser({ action: "snapshot" })
await browser({ action: "click", ref: "button-name" })
```
---
## Quick Reference Summary
| Tool | Required Parameters | Common Errors |
|------|---------------------|---------------|
| `read` | `file_path` | Using `path` |
| `edit` | `file_path`, `old_string`, `new_string` | Using `newText`/`oldText`, missing one param |
| `write` | `file_path`, `content` | Partial content, missing `file_path` |
| `exec` | `command` | Non-zero exit codes for cron |
| `browser` | `action` | Using without gateway check |
**Critical rules:** Use `file_path` not `path`. Use `old_string`/`new_string` not `oldText`/`newText`.
**Quality over speed. Verify before executing. Get it right.**
---
## Unified Search — Perplexity Primary, SearXNG Fallback
**Primary:** Perplexity API (cloud, AI-curated, paid)
**Fallback:** SearXNG (local, raw results, free)
### Usage
```bash
# Default: Perplexity primary, SearXNG fallback on error
search "your query"
# Perplexity only (p = perplexity)
search p "your query"
search perplexity "your query"
# SearXNG only (local = searxng)
search local "your query"
search searxng "your query"
# With citations (Perplexity)
search --citations "your query"
# Pro model for complex queries
search --model sonar-pro "your query"
search --model sonar-deep-research "comprehensive research"
```
### Models
| Model | Best For | Search Context |
|-------|----------|----------------|
| sonar | Quick answers, simple queries | Low/Medium/High |
| sonar-pro | Complex queries, coding | Medium/High |
| sonar-reasoning | Step-by-step reasoning | Medium/High |
| sonar-deep-research | Comprehensive research | High |
### When to Use Each
- **Perplexity**: Complex queries, research, current events, anything needing synthesis
- **SearXNG**: Privacy-sensitive searches, simple factual lookups, bulk operations, rate limit fallback
### Scripts
- **Unified**: `skills/perplexity/scripts/search.py`
- **Perplexity-only**: `skills/perplexity/scripts/query.py`
---
## Perplexity API
- **Location**: `/root/.openclaw/workspace/skills/perplexity/`
- **Key**: `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`
- **Endpoint**: `https://api.perplexity.ai/chat/completions`
- **Models**: sonar, sonar-pro, sonar-reasoning, sonar-deep-research
- **Format**: OpenAI-compatible
- **Cost**: ~$0.005 per query (shown in output)
- **Features**: AI-synthesized answers, citations, real-time search
- **Note**: Sends queries to Perplexity servers (cloud)
---
### Voice/Text Reply Rules
- **Voice message received** → Reply with **voice** (using Kimi-XXX.ogg filename)
- Transcribe internally for understanding
- **DO NOT send transcript text to Telegram**
- **DO NOT include any text with voice messages** — voice-only, completely silent text
- Reply with voice-only, no text
- **Text message received** → Reply with **text**
- **Never** send both voice + text for the same reply
- **ENFORCED 2026-02-07:** Voice messages must be sent alone without accompanying text
### Voice Settings
- **TTS Provider**: Local Kokoro @ `http://10.0.0.228:8880`
- **Voice**: `af_bella` (American Female)
- **Filename format**: `Kimi-YYYYMMDD-HHMMSS.ogg`
- **Mode**: Voice-only (no text transcript when sending voice)
### Web Search
- **Primary**: Perplexity API (unified search, AI-curated)
- **Fallback**: SearXNG (local instance at `http://10.0.0.8:8888/`)
- **Manual fallback**: Use `search local "query"` for privacy-sensitive searches
- **Browser tool**: Only when gateway running and extension attached
### Core Values
- **Best accuracy** — No compromises on quality
- **Best performance** — Optimize for speed where possible
- **Privacy first** — Always prioritize privacy in all decisions
- **Always research before install** — Search web for details, docs, best practices
- **Local docs exception** — If docs are local (OpenClaw, ClawHub), use those first
### Search Preferences
- **Search first** — Try SearXNG before asking clarifying questions
- **Prioritize sites**: *(to be filled in)*
- GitHub / GitLab — For code, repos, technical docs
- Stack Overflow — For programming Q&A
- Wikipedia — For general knowledge
- Arch Wiki — For Linux/system admin topics
- Official docs — project.readthedocs.io, docs.project.org
- **Avoid/deprioritize**: *(to be filled in)*
- SEO spam sites
- Outdated forums (pre-2020 unless historical)
- **Search language**: English preferred, unless query is non-English
- **Time bias**: Prefer recent results for tech topics, timeless for facts
### Search-First Sites (Priority Order)
When searching, prefer results from:
1. **docs.openclaw.ai** / **OpenClaw docs** — OpenClaw documentation
2. **clawhub.com** / **ClawHub** — OpenClaw skills registry
3. **docs.*.org** / **readthedocs.io** — Official documentation
4. **github.com** / **gitlab.com** — Source code, issues, READMEs
5. **stackoverflow.com** — Programming solutions
6. **wikipedia.org** — General reference
7. **archlinux.org/wiki** — Linux/system administration
8. **reddit.com/r/* —** Community discussions (for opinions/experiences)
9. **news.ycombinator.com** — Tech news and discussions
10. **medium.com** / **dev.to** — Developer blogs (verify date)
## SSH Hosts
- **epyc-debian-SSH (deb)** — `[email protected]`
- Auth: SSH key (no password)
- Key: `~/.ssh/id_ed25519`
- Sudo password: `passw0rd`
- Usage: `ssh [email protected]`
- Status: OpenClaw removed 2026-02-07
- **epyc-debian2-SSH (deb2)** — `[email protected]`
- Auth: SSH key (same as deb)
- Key: `~/.ssh/id_ed25519`
- Sudo password: `passw0rd`
- Usage: `ssh [email protected]`
## Existing Software Stack
**⚠️ ALREADY INSTALLED — Do not recommend these:**
- **n8n** — Workflow automation
- **ollama** — Local LLM runner
- **openclaw** — AI agent platform (this system)
- **openwebui** — LLM chat interface
- **anythingllm** — RAG/chat with documents
- **searxng** — Privacy-focused search engine
- **flowise** — Low-code LLM workflow builder
- **plex** — Media server
- **radarr** — Movie management
- **sonarr** — TV show management
- **sabnzbd** — Usenet downloader
- **comfyui** — Stable Diffusion UI
**When recommending software, ALWAYS check this list first and omit any matches.**
## Skills
### Local Whisper STT
- **Location**: `/root/.openclaw/workspace/skills/local-whisper-stt/`
- **Purpose**: Transcribe inbound voice messages
- **Model**: `base` (CPU-only)
- **Usage**: Auto-transcribes when voice message received
- **Correct path**: `scripts/transcribe.py` (not root level)
### Kimi TTS Custom
- **Location**: `/root/.openclaw/workspace/skills/kimi-tts-custom/`
- **Purpose**: Generate voice with custom filenames and send voice-only replies
- **Scripts**:
- `scripts/generate_voice.py` — Generate voice file (returns path, does NOT send)
- `scripts/voice_reply.py` — Generate + send voice-only reply (USE THIS for voice replies)
- **Usage**: `python3 scripts/voice_reply.py <chat_id> "text"`
- **⚠️ CRITICAL**: Text reference to voice file does NOT send audio. Must use `voice_reply.py` or proper Telegram API delivery. Generation ≠ Delivery.
### Qdrant Memory
- **Location**: `/root/.openclaw/workspace/skills/qdrant-memory/`
- **Mode**: MANUAL ONLY — No automatic storage
- **Collections**:
- `kimi_memories` (personal) — Identity, rules, preferences, lessons
- `kimi_kb` (knowledge base) — Web data, documents, reference materials
- **Vector size**: 1024 (snowflake-arctic-embed2)
- **Distance**: Cosine
- **Qdrant URL**: `http://10.0.0.40:6333`
**Personal Memory Scripts (kimi_memories):**
- `scripts/store_memory.py` — Manual storage with metadata
- `scripts/search_memories.py` — Semantic search
- `scripts/hybrid_search.py` — Search files + vectors
**Knowledge Base Scripts (kimi_kb):**
- `scripts/kb_store.py` — Store web/docs to KB
- `scripts/kb_search.py` — Search knowledge base
**Usage:**
```bash
# Personal memories ("q remember", "q recall")
python3 store_memory.py "Memory" --importance high --tags "preference"
python3 search_memories.py "voice settings"
# Knowledge base (manual document/web storage)
python3 kb_store.py "Content" --title "X" --domain "Docker" --tags "container"
python3 kb_search.py "docker volumes" --domain "Docker"
```
**⚠️ CRITICAL**: Never auto-store. Only when user explicitly requests with "q" prefix.
## Infrastructure
### Container Limits
- **No GPUs attached** — All ML workloads run on CPU
- **Whisper**: Use `tiny` or `base` models for speed
### Local Services
- **Kokoro TTS**: `http://10.0.0.228:8880` (OpenAI-compatible)
- **Ollama**: `http://10.0.0.10:11434`
- **SearXNG**: `http://10.0.0.8:8888` (web search via curl)
- **Qdrant**: `http://10.0.0.40:6333` (vector database for memory + KB)
- **Collections**: `kimi_memories` (personal), `kimi_kb` (knowledge base)
- **Vector size**: 1024 (snowflake-arctic-embed2)
- **Distance**: Cosine similarity
- **Redis**: `10.0.0.36:6379` (task queue, available for future use)
## Cron Jobs
- **Default:** Always check `openclaw cron list` first when asked about cron jobs
- Rob's scheduled tasks live in OpenClaw's cron system, not system crontab
- Only check system crontab (`crontab -l`, `/etc/cron.d/`) if specifically asked about system-level jobs
---
## Lessons Learned & Workarounds
### Embedded Session Tool Errors
**Issue:** `read tool called without path` errors occur in embedded sessions even when parameter syntax is correct in workspace scripts.
**Workarounds:**
1. **Double-check parameters manually** — Don't trust the model to pass them correctly in embedded contexts
2. **Avoid embedded tool calls when possible** — Use workspace scripts instead
3. **Edit fails twice → Use write immediately** — Don't retry edit tool more than once
4. **Verify file exists before read** — Prevents ENOENT errors
5. **No redis-cli in container** — Use Python redis module instead
6. **Browser tool unreliable** — Use curl/SearXNG as primary web access
### Common Parameter Errors to Avoid
| Wrong | Right | Notes |
|-------|-------|-------|
| `path` | `file_path` | Most common error |
| `newText`/`oldText` | `new_string`/`new_string` | Edit tool only |
| Missing `new_string` | Include both params | Edit requires both |
| Using `write` for small edits | Use `edit` first | Edit is safer for small changes |
### Environment-Specific Gotchas
- **Qdrant Python module** — Must use scripts with proper sys.path setup
- **Playwright browsers** — Not installed, use curl/SearXNG for web scraping
- **Browser gateway** — Requires Chrome extension attached; rarely available
- **Redis CLI** — Not available; use `python3 -c "import redis..."` instead
-281
View File
@@ -1,281 +0,0 @@
# YouTube Tutorial Script: Building Jarvis-Like Memory for OpenClaw
> **Video Title Ideas:**
> - "I Built a Jarvis Memory System for My AI Assistant"
> - "OpenClaw Memory That Actually Works (Full Build)"
> - "From Goldfish to Elephant: AI Memory Architecture"
---
## Video Sections
### [0:00-2:00] Introduction: The Problem
**On screen:** Split screen showing normal AI vs. AI with memory
**Script:**
"Hey everyone! You know how most AI assistants are like goldfish? You say something, they respond, and then... poof. It's gone. Start a new session? Everything's gone. Reset the conversation? Gone.
But what if I told you we can build an AI assistant that actually **remembers**? Not just the current session. Not just recent messages. But months of conversations, projects, preferences — all instantly searchable and semantically understood.
Today we're building a Jarvis-like memory system for OpenClaw. Three layers. Full persistence. Semantic search. And it's all self-hosted."
**Visual:** Show the three-layer architecture diagram
---
### [2:00-5:00] Demo: Show It Working
**On screen:** Live terminal demo
**Script:**
"Before we build, let me show you what this actually looks like.
[Type] `q docker networking`
See that? It found a conversation from two weeks ago where we talked about Docker networking. It didn't just keyword search — it understood the semantic meaning of my question.
[Type] `save q`
This saves our current conversation to long-term memory. Now even if I reset my session, this conversation is searchable forever.
[Type] `save mem`
This saves everything to the fast Redis buffer. Every night at 3 AM, this automatically flushes to our vector database.
The result? An AI assistant that knows my infrastructure, remembers my projects, and can recall anything we've ever discussed."
**Visual:** Show search results appearing from Qdrant
---
### [5:00-10:00] Architecture Deep Dive
**On screen:** Architecture diagram with each layer highlighted
**Script:**
"So how does this work? Three layers.
**Layer 1: Redis Buffer** — Fast, real-time accumulation. Every message gets stored here instantly. It survives session resets because it's external to OpenClaw. Every night at 3 AM, we flush this to Qdrant.
**Layer 2: Daily File Logs** — Human-readable Markdown files. Git-tracked, never lost, always accessible. This is your audit trail. You can grep these, read them, they're just text files.
**Layer 3: Qdrant Vector Database** — The magic happens here. We generate 1024-dimensional embeddings using the snowflake-arctic-embed2 model. Every turn gets THREE embeddings: one for the user message, one for the AI response, and one combined summary. This enables semantic search.
**Deduplication** — We hash every piece of content. Same user, same content? Skip it. Different user, same content? Store it. This prevents bloat.
**User-centric design** — Memories follow YOU, not the session. Ask 'what did I say about X?' and it searches across ALL your conversations."
**Visual:** Animated data flow showing messages → Redis → Files → Qdrant
---
### [10:00-25:00] Live Build
**On screen:** Terminal, code editor
**Script:**
"Alright, let's build this. I'm going to assume you have OpenClaw running. If not, check my previous video.
**Step 1: Infrastructure**
We need three things: Qdrant for vectors, Redis for fast buffer, and Ollama for embeddings.
[Show] `docker-compose up -d`
This spins up everything. Let's verify:
[Show] `curl http://localhost:6333/collections`
[Show] `redis-cli ping`
[Show] `curl http://localhost:11434/api/tags`
All green? Perfect.
**Step 2: Install Python Dependencies**
[Show] `pip3 install redis qdrant-client requests`
**Step 3: Create Directory Structure**
[Show] `mkdir -p skills/{mem-redis,qdrant-memory}/scripts memory`
**Step 4: Copy the Scripts**
Now we copy the scripts from the blueprint. I'm going to show you the key ones.
[Show hb_append.py - explain the heartbeat logic]
[Show save_mem.py - explain Redis buffer]
[Show auto_store.py - explain Qdrant storage]
[Show search_memories.py - explain semantic search]
Each script has a specific job. Let's trace through the data flow.
When you say 'save mem', it calls save_mem.py which dumps all conversation turns to Redis.
When you say 'save q', it calls auto_store.py which generates embeddings and stores to Qdrant.
When you say 'q topic', it calls search_memories.py which converts your query to an embedding and finds similar vectors.
**Step 5: Initialize Qdrant Collections**
We need to create the collections before we can store anything.
[Show] `python3 init_kimi_memories.py`
This creates the collection with the right settings: 1024 dimensions, cosine similarity, user_id metadata.
**Step 6: Test End-to-End**
Let's save something.
[Show] `python3 save_mem.py --user-id $(whoami)`
Check Redis:
[Show] `redis-cli LLEN mem:$(whoami)`
See that? Our conversation is now in the buffer.
Let's make it semantically searchable:
[Show] `python3 auto_store.py`
Now search for it:
[Show] `python3 search_memories.py "your test query"`
Boom! We just built a memory system."
**Visual:** Code on left, terminal output on right
---
### [25:00-30:00] Advanced Features
**On screen:** Show additional scripts
**Script:**
"Once you have the basics, here are some advanced features.
**Session Harvesting** — Got old OpenClaw sessions you want to import? Use harvest_sessions.py to bulk-import them into Qdrant.
**Task Queue** — Want background jobs? The task-queue skill lets you queue tasks and execute them on heartbeat.
**Email Integration** — Want your AI to check email? hb_check_email.py connects to Gmail and stores emails as memories.
**QMD (Query Markdown)** — This is experimental but cool. It's a local-first hybrid search using BM25 + vectors. Works offline.
Each of these extends the core system in different directions."
**Visual:** Show each script running briefly
---
### [30:00-32:00] Conclusion
**On screen:** Summary slide with GitHub link
**Script:**
"So that's it! A complete Jarvis-like memory system for OpenClaw.
We've built:
✅ Three-layer persistent memory
✅ Semantic search across all conversations
✅ User-centric storage (not session-based)
✅ Automatic daily backups
✅ Git-tracked audit trails
The full blueprint is on GitHub — link in the description. It includes all the scripts, the install.sh one-command installer, docker-compose for infrastructure, and this complete documentation.
If you build this, tag me on socials! I'd love to see your implementations.
Questions? Drop them in the comments. If this was helpful, like and subscribe for more AI infrastructure content.
Thanks for watching — now go build something that remembers! 🚀"
**Visual:** End screen with subscribe button, social links
---
## B-Roll / Screen Capture Checklist
- [ ] Opening shot of architecture diagram
- [ ] Terminal showing `q` command working
- [ ] Redis CLI showing buffer size
- [ ] Qdrant web UI (if using)
- [ ] Daily Markdown file being opened
- [ ] Code editor showing scripts
- [ ] Docker Compose starting up
- [ ] Animated data flow diagram
- [ ] Search results appearing
- [ ] End screen with links
## Thumbnail Ideas
1. **Jarvis helmet** + "AI Memory" text
2. **Three-layer cake** diagram with labels
3. **Before/After split**: Goldfish vs. Elephant
4. **Terminal screenshot** with search results visible
## Description Template
```
Build an AI assistant that actually REMEMBERS with this complete Jarvis-like memory system for OpenClaw.
🧠 THREE-LAYER ARCHITECTURE:
• Redis buffer (fast, real-time)
• Daily file logs (human-readable)
• Qdrant vector DB (semantic search)
🔧 WHAT YOU'LL LEARN:
• Multi-layer memory architecture
• Semantic search with embeddings
• User-centric storage (Mem0-style)
• Automatic backup systems
• Self-hosted infrastructure
📦 RESOURCES:
Full blueprint: [GitHub link]
Docker Compose: Included
Install script: One-command setup
⏱️ TIMESTAMPS:
0:00 - The Problem (AI goldfish)
2:00 - Live Demo
5:00 - Architecture Deep Dive
10:00 - Live Build
25:00 - Advanced Features
30:00 - Conclusion
🛠️ STACK:
• OpenClaw
• Qdrant (vectors)
• Redis (buffer)
• Ollama (embeddings)
#OpenClaw #AI #Memory #SelfHosted #Jarvis
```
## Tags for YouTube
OpenClaw, AI Memory, Vector Database, Qdrant, Redis, Ollama, Self-Hosted AI, Jarvis AI, Memory Architecture, Semantic Search, Embeddings, LLM Memory
---
## Follow-Up Video Ideas
1. "Advanced Memory: Session Harvesting Tutorial"
2. "Building an AI Task Queue with Redis"
3. "Email Integration: AI That Reads Your Mail"
4. "QMD vs Qdrant: Which Memory System Should You Use?"
5. "Scaling Memory: From Personal to Multi-User"
---
*Ready to record? Good luck! 🎬*
+22
View File
@@ -0,0 +1,22 @@
# USER.md - About Your Human
*Learn about the person you're helping. Update this as you go.*
- **Name:** Rob
- **What to call them:** Rob
- **Pronouns:** *(optional)*
- **Timezone:** CST (America/Chicago)
- **Location:** Knoxville, Tennessee
- **Notes:**
- Prefers local/self-hosted tools when possible
- Free + Local > Cloud/SaaS
- Voice in → Voice out, Text in → Text out
- No transcripts sent to Telegram
## Context
*(What do they care about? What projects are they working on? What annoys them? What makes them laugh? Build this over time.)*
---
The more you know, the better you can help. But remember — you're learning about a person, not building a dossier. Respect the difference.
+6
View File
@@ -0,0 +1,6 @@
#!/bin/bash
# Search wrapper for easy access
# Usage: search [p|perplexity|local|searxng] "query" [options]
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
python3 "$SCRIPT_DIR/../skills/perplexity/scripts/search.py" "$@"
-64
View File
@@ -1,64 +0,0 @@
# HEARTBEAT.md
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
## Memory Buffer (Every Heartbeat)
Saves ALL current session context to Redis short-term buffer. Runs automatically.
Does NOT clear buffer — preserves turns from other sessions until daily backup.
```bash
python3 /root/.openclaw/workspace/skills/mem-redis/scripts/save_mem.py --user-id YOUR_USER_ID
```
Multiple sessions per day accumulate in Redis. Daily cron (3:00 AM) flushes everything to Qdrant.
## Email Check (Every Heartbeat)
Checks Gmail for messages from authorized senders. **Respond to any new emails found.**
```bash
python3 /root/.openclaw/workspace/skills/qdrant-memory/scripts/hb_check_email.py
```
**Authorized senders only:** `[email protected]`, `[email protected]`
*Edit `skills/qdrant-memory/scripts/hb_check_email.py` to set your authorized senders*
**When new email found:**
1. Read the email subject and body
2. Search Qdrant for relevant context about the topic
3. Respond to the email with a helpful reply
4. Store the email and your response to Qdrant for memory
---
## Manual Mode Only
All OTHER heartbeat actions are **manual only** when explicitly requested.
### When User Requests:
- **Check delayed notifications:** I will manually check the queue
### No Automatic Actions:
❌ Auto-sending notifications from queue
❌ Auto-logging heartbeat timestamps
## Available Manual Commands
```bash
# Check delayed notifications
redis-cli -h 10.0.0.36 LRANGE delayed:notifications 0 0
# Manual full context save to Redis (all current session turns)
python3 /root/.openclaw/workspace/skills/mem-redis/scripts/save_mem.py --user-id YOUR_USER_ID
```
## Daily Tasks
- Redis → Qdrant backup (cron 3:00 AM): `cron_backup.py`
- File-based backup (cron 3:30 AM): `sliding_backup.sh`
## Future Tasks (add as needed)
-71
View File
@@ -1,71 +0,0 @@
version: '3.8'
services:
# Vector Database - Long-term semantic memory
qdrant:
image: qdrant/qdrant:latest
container_name: qdrant-memory
ports:
- "6333:6333"
- "6334:6334"
volumes:
- qdrant-storage:/qdrant/storage
environment:
- QDRANT__SERVICE__HTTP_PORT=6333
restart: unless-stopped
# qdrant image does not ship with curl/wget; disable container-level healthcheck.
# Host-level checks (curl to localhost:6333) are sufficient.
# healthcheck:
# test: ["CMD", "sh", "-lc", ": </dev/tcp/127.0.0.1/6333" ]
# interval: 30s
# timeout: 10s
# retries: 3
# Fast Buffer - Short-term memory accumulation
redis:
image: redis:7-alpine
container_name: redis-memory
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 3s
retries: 5
# Embeddings - Generate vectors for semantic search
ollama:
image: ollama/ollama:latest
container_name: ollama-embeddings
ports:
- "11434:11434"
volumes:
- ollama-models:/root/.ollama
environment:
- OLLAMA_HOST=0.0.0.0
restart: unless-stopped
# Pull the embedding model on first start
entrypoint: >
sh -c "
ollama serve &
sleep 5
ollama pull snowflake-arctic-embed2
wait
"
volumes:
qdrant-storage:
driver: local
redis-data:
driver: local
ollama-models:
driver: local
networks:
default:
name: memory-system
driver: bridge
-738
View File
@@ -1,738 +0,0 @@
# Memory System Architecture Diagrams
**Created:** February 18, 2026
**Updated:** February 18, 2026 (v2.0 - Added QMD, Task Queue, Session Harvesting, Email Integration)
**Purpose:** Complete backup of memory system architecture for Google Slides presentations
---
## Table of Contents
1. [Part 1: Built-in Memory System (OpenClaw Default)](#part-1-built-in-memory-system-openclaw-default)
2. [Part 2: Custom Memory System (What We Built)](#part-2-custom-memory-system-what-we-built)
3. [Part 3: Comparison — Built-in vs Custom](#part-3-comparison--built-in-vs-custom)
4. [Part 4: QMD (Query Markdown) — OpenClaw Experimental](#part-4-qmd-query-markdown--openclaw-experimental)
5. [Part 5: Task Queue System](#part-5-task-queue-system)
6. [Part 6: Session Harvesting](#part-6-session-harvesting)
7. [Part 7: Email Integration](#part-7-email-integration)
8. [Part 8: PROJECTNAME.md Workflow](#part-8-projectnamemd-workflow)
9. [Part 9: Complete Infrastructure Reference](#part-9-complete-infrastructure-reference)
---
## Part 1: Built-in Memory System (OpenClaw Default)
### Architecture Diagram
```
┌─────────────────────────────────────┐
│ OpenClaw Gateway Service │
│ (Manages session state & routing) │
└──────────────┬──────────────────────┘
┌──────▼──────┐
│ Session │
│ Context │
│ (In-Memory) │
└──────┬──────┘
┌──────▼──────────────────┐
│ Message History Buffer │
│ (Last N messages) │
│ Default: 8k-32k tokens │
└──────┬──────────────────┘
┌──────▼────────┐
│ Model Input │
│ (LLM Call) │
└───────────────┘
```
### How Built-in Memory Works
**Process Flow:**
1. **User sends message** → Added to session context
2. **Context accumulates** in memory (not persistent)
3. **Model receives** last N messages as context
4. **Session ends** → Context is **LOST**
**Key Characteristics:**
- ✅ Works automatically (no setup)
- ✅ Fast (in-memory)
-**Lost on /new or /reset**
-**Lost when session expires**
- ❌ No cross-session memory
- ❌ Limited context window (~8k-32k tokens)
### Built-in Limitations
| Feature | Status |
|---------|--------|
| Session Persistence | ❌ NO |
| Cross-Session Memory | ❌ NO |
| User-Centric Storage | ❌ NO |
| Long-Term Memory | ❌ NO |
| Semantic Search | ❌ NO |
| Conversation Threading | ❌ NO |
| Automatic Backup | ❌ NO |
---
## Part 2: Custom Memory System (What We Built)
### Complete Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────────┐
│ MULTI-LAYER MEMORY SYSTEM │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ LAYER 0: Real-Time Session Context (OpenClaw Gateway) │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ Session JSONL → Live context (temporary only) │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼──────────────────────────────────┐ │
│ │ LAYER 1: Redis Buffer (Fast Short-Term) │ │
│ │ ├─ Key: mem:rob │ │
│ │ ├─ Accumulates new turns since last check │ │
│ │ ├─ Heartbeat: Append-only (hb_append.py) │ │
│ │ ├─ Manual: Full dump (save_mem.py) │ │
│ │ └─ Flush: Daily 3:00 AM → Qdrant │ │
│ └──────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼──────────────────────────────────┐ │
│ │ LAYER 2: Daily File Logs (.md) │ │
│ │ ├─ Location: memory/YYYY-MM-DD.md │ │
│ │ ├─ Format: Human-readable Markdown │ │
│ │ ├─ Backup: 3:30 AM sliding_backup.sh │ │
│ │ └─ Retention: Permanent (git-tracked) │ │
│ └──────────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌──────────────────────────▼──────────────────────────────────┐ │
│ │ LAYER 3: Qdrant Vector DB (Semantic Long-Term) │ │
│ │ ├─ Host: 10.0.0.40:6333 │ │
│ │ ├─ Embeddings: snowflake-arctic-embed2 (1024-dim) │ │
│ │ ├─ Collections: │ │
│ │ │ • kimi_memories (conversations) │ │
│ │ │ • kimi_kb (knowledge base) │ │
│ │ │ • private_court_docs (legal) │ │
│ │ ├─ Deduplication: Content hash per user │ │
│ │ └─ User-centric: user_id: "rob" │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ CROSS-CUTTING: Task Queue (Redis) │ │
│ │ ├─ tasks:pending → tasks:active → tasks:completed │ │
│ │ └─ Heartbeat worker for background jobs │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ CROSS-CUTTING: Email Integration (Gmail) │ │
│ │ ├─ hb_check_email.py (Heartbeat) │ │
│ │ └─ Authorized senders: [email protected] │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### Detailed Component Breakdown
#### Component 1: Daily File Logs
- **Location:** `/root/.openclaw/workspace/memory/YYYY-MM-DD.md`
- **Format:** Markdown with timestamps
- **Content:** Full conversation history
- **Access:** Direct file read
- **Retention:** Permanent (until deleted)
- **Auto-created:** Yes, every session
- **Backup:** `sliding_backup.sh` at 3:30 AM
#### Component 2: Redis Buffer (mem-redis skill)
- **Host:** `10.0.0.36:6379`
- **Key:** `mem:rob`
- **Type:** List (LPUSH append)
- **Purpose:** Fast access, multi-session accumulation
- **Flush:** Daily at 3:00 AM to Qdrant
- **No TTL:** Data persists until successfully backed up
- **Fail-safe:** If cron fails, data stays in Redis
**Scripts:**
| Script | Purpose |
|--------|---------|
| `hb_append.py` | Heartbeat: Add NEW turns only |
| `save_mem.py` | Manual: Save ALL turns (with --reset option) |
| `cron_backup.py` | Daily: Process Redis → Qdrant → Clear Redis |
| `mem_retrieve.py` | Manual: Retrieve recent turns from Redis |
| `search_mem.py` | Search both Redis (exact) + Qdrant (semantic) |
#### Component 3: Qdrant Vector Database
- **Host:** `http://10.0.0.40:6333`
- **Embeddings Model:** `snowflake-arctic-embed2` at `10.0.0.10:11434`
- **Vector Dimensions:** 1024
- **User-Centric:** All memories tagged with `user_id: "rob"`
- **Cross-Chat Search:** Find info from ANY past conversation
**Collections:**
| Collection | Purpose | Content |
|------------|---------|---------|
| `kimi_memories` | Personal conversations | User + AI messages |
| `kimi_kb` | Knowledge base | Web data, docs, tutorials |
| `private_court_docs` | Legal documents | Court files, legal research |
#### Component 4: Full Context Mode (Mem0-Style)
**3 Embeddings Per Turn:**
1. User message embedding
2. AI response embedding
3. Combined summary embedding
**Threading Metadata:**
- `user_id`: "rob" (persistent identifier)
- `conversation_id`: Groups related turns
- `session_id`: Which chat instance
- `turn_number`: Sequential ordering
#### Deduplication System
**What It Is:**
A content-based duplicate detection system that prevents storing the exact same information multiple times for the same user.
**How It Works:**
1. **Content Hash Generation:** Each memory generates a SHA-256 hash of its content
2. **Per-User Scope:** Deduplication is per-user (same content from different users = allowed)
3. **Pre-Storage Check:** Before storing to Qdrant, check if hash exists for this user
4. **Skip if Duplicate:** If hash exists → skip storage, return "already exists"
5. **Store if New:** If hash doesn't exist → generate embeddings and store
**Deduplication by Layer:**
| Layer | Deduplication | Behavior |
|-------|---------------|----------|
| **Daily Files** | ❌ No | All turns appended (intentional — audit trail) |
| **Redis Buffer** | ❌ No | All turns stored (temporary, flushed daily) |
| **Qdrant (kimi_memories)** | ✅ Yes | Per-user content hash check |
| **Qdrant (kimi_kb)** | ✅ Yes | Per-collection content hash check |
### Complete Script Reference
```
/root/.openclaw/workspace/
├── memory/
│ └── YYYY-MM-DD.md (daily logs)
├── skills/
│ ├── mem-redis/
│ │ └── scripts/
│ │ ├── hb_append.py (heartbeat: new turns only)
│ │ ├── save_mem.py (manual: all turns)
│ │ ├── cron_backup.py (daily flush to Qdrant)
│ │ ├── mem_retrieve.py (read from Redis)
│ │ └── search_mem.py (search Redis + Qdrant)
│ │
│ ├── qdrant-memory/
│ │ └── scripts/
│ │ ├── auto_store.py (immediate Qdrant storage)
│ │ ├── background_store.py (async storage)
│ │ ├── q_save.py (quick save trigger)
│ │ ├── daily_conversation_backup.py (file → Qdrant)
│ │ ├── get_conversation_context.py (retrieve threads)
│ │ ├── search_memories.py (semantic search)
│ │ ├── harvest_sessions.py (bulk import old sessions)
│ │ ├── harvest_newest.py (specific sessions)
│ │ ├── hb_check_email.py (email integration)
│ │ ├── sliding_backup.sh (file backup)
│ │ ├── kb_store.py / kb_search.py (knowledge base)
│ │ └── court_store.py / court_search.py (legal docs)
│ │
│ └── task-queue/
│ └── scripts/
│ ├── heartbeat_worker.py (process tasks)
│ ├── add_task.py (add background task)
│ └── list_tasks.py (view queue status)
└── MEMORY_DEF/
├── README.md
├── daily-backup.md
└── agent-messaging.md
```
### Technical Flow
#### Real-Time (Every Message)
```
User Input → AI Response
Redis Buffer (fast append)
File Log (persistent)
[Optional: "save q"] → Qdrant (semantic)
```
#### Heartbeat (Every ~30-60 min)
```
hb_append.py → Check for new turns → Append to Redis
hb_check_email.py → Check Gmail → Process new emails
heartbeat_worker.py → Check task queue → Execute tasks
```
#### Daily Backup (3:00 AM & 3:30 AM)
```
3:00 AM: Redis Buffer → Flush → Qdrant (kimi_memories)
└─> Clear Redis after successful write
3:30 AM: Daily Files → sliding_backup.sh → Archive
└─> daily_conversation_backup.py → Qdrant
```
#### On Retrieval ("search q" or "q <topic>")
```
Search Query
search_mem.py
├──► Redis (exact text match, recent)
└──► Qdrant (semantic similarity, long-term)
Combined Results (Redis first, then Qdrant)
Return context-enriched response
```
---
## Part 3: Comparison — Built-in vs Custom
### Feature Comparison Table
| Feature | Built-in | Custom System |
|---------|----------|---------------|
| **Session Persistence** | ❌ Lost on reset | ✅ Survives forever |
| **Cross-Session Memory** | ❌ None | ✅ All sessions linked |
| **User-Centric** | ❌ Session-based | ✅ User-based (Mem0-style) |
| **Semantic Search** | ❌ None | ✅ Full semantic retrieval |
| **Conversation Threading** | ❌ Linear only | ✅ Thread-aware |
| **Long-Term Storage** | ❌ Hours only | ✅ Permanent (disk + vector) |
| **Backup & Recovery** | ❌ None | ✅ Multi-layer redundancy |
| **Privacy** | ⚠️ Cloud dependent | ✅ Fully local/self-hosted |
| **Speed** | ✅ Fast (RAM) | ✅ Fast (Redis) + Deep (Qdrant) |
| **Cost** | ❌ OpenAI API tokens | ✅ Free (local infrastructure) |
| **Embeddings** | ❌ None | ✅ 1024-dim (snowflake) |
| **Cross-Reference** | ❌ None | ✅ Links related memories |
| **Task Queue** | ❌ None | ✅ Background job processing |
| **Email Integration** | ❌ None | ✅ Gmail via Pub/Sub |
| **Deduplication** | ❌ None | ✅ Content hash-based |
### Why It's Better — Key Advantages
#### 1. Mem0-Style Architecture
- Memories follow the **USER**, not the session
- Ask "what did I say about X?" → finds from **ANY** past conversation
- Persistent identity across all chats
#### 2. Hybrid Storage Strategy
- **Redis:** Speed (real-time access)
- **Files:** Durability (never lost, human-readable)
- **Qdrant:** Intelligence (semantic search, similarity)
#### 3. Multi-Modal Retrieval
- **Exact match:** File grep, exact text search
- **Semantic search:** Vector similarity, conceptual matching
- **Thread reconstruction:** Conversation_id grouping
#### 4. Local-First Design
- No cloud dependencies
- No API costs (except initial setup)
- Full privacy control
- Works offline
- Self-hosted infrastructure
#### 5. Triple Redundancy
| Layer | Purpose | Persistence |
|-------|---------|-------------|
| Redis | Speed | Temporary (daily flush) |
| Files | Durability | Permanent |
| Qdrant | Intelligence | Permanent |
---
## Part 4: QMD (Query Markdown) — OpenClaw Experimental
### What is QMD?
**QMD** = **Query Markdown** — OpenClaw's experimental local-first memory backend that replaces the built-in SQLite indexer.
**Key Difference:**
- Current system: SQLite + vector embeddings
- QMD: **BM25 + vectors + reranking** in a standalone binary
### QMD Architecture
```
┌─────────────────────────────────────────────┐
│ QMD Sidecar (Experimental) │
│ ├─ BM25 (exact token matching) │
│ ├─ Vector similarity (semantic) │
│ └─ Reranking (smart result ordering) │
└──────────────────┬──────────────────────────┘
┌──────────▼──────────┐
│ Markdown Source │
│ memory/*.md │
│ MEMORY.md │
└─────────────────────┘
```
### QMD vs Current System
| Feature | Current (Qdrant) | QMD (Experimental) |
|---------|------------------|-------------------|
| **Storage** | Qdrant server (10.0.0.40) | Local SQLite + files |
| **Network** | Requires network | Fully offline |
| **Search** | Vector only | Hybrid (BM25 + vector) |
| **Exact tokens** | Weak | Strong (BM25) |
| **Embeddings** | snowflake-arctic-embed2 | Local GGUF models |
| **Git-friendly** | ❌ Opaque vectors | ✅ Markdown source |
| **Explainable** | Partial | Full (file.md#L12 citations) |
| **Status** | Production | Experimental |
### When QMD Might Be Better
**Use QMD if:**
- You want **full offline** operation (no 10.0.0.40 dependency)
- You frequently search for **exact tokens** (IDs, function names, error codes)
- You want **human-editable** memory files
- You want **git-tracked** memory that survives system rebuilds
**Stick with Qdrant if:**
- Your current system is stable
- You need **multi-device** access to same memory
- You're happy with **semantic-only** search
- You need **production reliability**
### QMD Configuration (OpenClaw)
```json5
memory: {
backend: "qmd",
citations: "auto",
qmd: {
includeDefaultMemory: true,
update: { interval: "5m", debounceMs: 15000 },
limits: { maxResults: 6, timeoutMs: 4000 },
paths: [
{ name: "docs", path: "~/notes", pattern: "**/*.md" }
]
}
}
```
### QMD Prerequisites
```bash
# Install QMD binary
bun install -g https://github.com/tobi/qmd
# Install SQLite with extensions (macOS)
brew install sqlite
# QMD auto-downloads GGUF models on first run (~0.6GB)
```
---
## Part 5: Task Queue System
### Architecture
```
┌─────────────────────────────────────────────┐
│ Redis Task Queue │
│ ├─ tasks:pending (FIFO) │
│ ├─ tasks:active (currently running) │
│ ├─ tasks:completed (history) │
│ └─ task:{id} (hash with details) │
└──────────────────┬────────────────────────┘
┌──────────▼──────────┐
│ Heartbeat Worker │
│ heartbeat_worker.py│
└─────────────────────┘
```
### Task Fields
- `id` - Unique task ID
- `description` - What to do
- `status` - pending/active/completed/failed
- `created_at` - Timestamp
- `created_by` - Who created the task
- `result` - Output from execution
### Usage
```bash
# Add a task
python3 skills/task-queue/scripts/add_task.py "Check server disk space"
# List tasks
python3 skills/task-queue/scripts/list_tasks.py
# Heartbeat auto-executes pending tasks
python3 skills/task-queue/scripts/heartbeat_worker.py
```
---
## Part 6: Session Harvesting
### What is Session Harvesting?
Bulk import of historical OpenClaw session JSONL files into Qdrant memory.
### When to Use
- After setting up new memory system → backfill existing sessions
- After discovering missed backups → recover data
- Periodically → if cron jobs missed data
### Scripts
| Script | Purpose |
|--------|---------|
| `harvest_sessions.py` | Auto-harvest (limited by memory) |
| `harvest_newest.py` | Specific sessions (recommended) |
### Usage
```bash
# Harvest specific sessions (recommended)
python3 harvest_newest.py --user-id rob session-1.jsonl session-2.jsonl
# Find newest sessions to harvest
ls -t /root/.openclaw/agents/main/sessions/*.jsonl | head -20
# Auto-harvest with limit
python3 harvest_sessions.py --user-id rob --limit 10
```
### How It Works
1. **Parse** → Reads JSONL session file
2. **Pair** → Matches user message with AI response
3. **Embed** → Generates 3 embeddings (user, AI, summary)
4. **Deduplicate** → Checks content_hash before storing
5. **Store** → Upserts to Qdrant with user_id, conversation_id
---
## Part 7: Email Integration
### Architecture
```
┌─────────────────────────────────────────────┐
│ Gmail Inbox │
│ ([email protected]) │
└──────────────────┬──────────────────────────┘
┌──────────▼──────────┐
│ hb_check_email.py │
│ (Heartbeat) │
└─────────────────────┘
```
### Authorized Senders
- `[email protected]` (Configure in hb_check_email.py)
- Add more as needed
### Usage
```bash
# Check emails (runs automatically in heartbeat)
python3 skills/qdrant-memory/scripts/hb_check_email.py
```
### How It Works
1. Polls Gmail for new messages
2. Filters by authorized senders
3. Reads subject and body
4. Searches Qdrant for context
5. Responds with helpful reply
6. Stores email + response to Qdrant
---
## Part 8: PROJECTNAME.md Workflow
*See original document for full details — this is a summary reference.*
### Purpose
Preserve context, decisions, and progress across sessions.
### The Golden Rule — Append Only
**NEVER Overwrite. ALWAYS Append.**
### File Structure Template
```markdown
# PROJECTNAME.md
## Project Overview
- **Goal:** What we're achieving
- **Scope:** What's in/out
- **Success Criteria:** How we know it's done
## Current Status
- [x] Completed tasks
- [ ] In progress
- [ ] Upcoming
## Decisions Log
| Date | Decision | Rationale |
|------|----------|-----------|
| 2026-02-18 | Use X over Y | Because of Z |
## Technical Details
- Infrastructure specs
- Code snippets
- Configuration
## Blockers & Risks
- What's blocking progress
- Known issues
## Next Steps
- Immediate actions
- Questions to resolve
```
### Real Examples
| File | Project | Status |
|------|---------|--------|
| `MEM_DIAGRAM.md` | Memory system documentation | ✅ Active |
| `AUDIT-PLAN.md` | OpenClaw infrastructure audit | ✅ Completed |
| `YOUTUBE_UPDATE.md` | Video description optimization | 🔄 Ongoing |
---
## Part 9: Complete Infrastructure Reference
### Hardware/Network Topology
```
┌────────────────────────────────────────────────────────────────┐
│ PROXMOX CLUSTER │
├────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ Ollama │ │ Qdrant │ │ Redis │ │
│ │ 10.0.0.10 │ │ 10.0.0.40 │ │ 10.0.0.36 │ │
│ │ GPU Node │ │ LXC │ │ LXC │ │
│ │ Embeddings │ │ Vector DB │ │ Task Queue │ │
│ │ 11434 │ │ 6333 │ │ 6379 │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ SearXNG │ │ Kokoro TTS │ │ OpenClaw │ │
│ │ 10.0.0.8 │ │ 10.0.0.228 │ │ Workspace │ │
│ │ Search │ │ Voice │ │ Kimi │ │
│ │ 8888 │ │ 8880 │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
└────────────────────────────────────────────────────────────────┘
```
### Service Reference
| Service | Purpose | Address | Model/Version |
|---------|---------|---------|-------------|
| Qdrant | Vector database | 10.0.0.40:6333 | v1.x |
| Redis | Buffer + tasks | 10.0.0.36:6379 | v7.x |
| Ollama | Embeddings | 10.0.0.10:11434 | snowflake-arctic-embed2 |
| SearXNG | Search | 10.0.0.8:8888 | Local |
| Kokoro TTS | Voice | 10.0.0.228:8880 | TTS |
### Daily Automation Schedule
| Time | Task | Script |
|------|------|--------|
| 3:00 AM | Redis → Qdrant flush | `cron_backup.py` |
| 3:30 AM | File-based sliding backup | `sliding_backup.sh` |
| Every 30-60 min | Heartbeat checks | `hb_append.py`, `hb_check_email.py` |
### Manual Triggers
| Command | What It Does |
|---------|--------------|
| `"save mem"` | Save ALL context to Redis + File |
| `"save q"` | Immediate Qdrant storage |
| `"q <topic>"` | Semantic search |
| `"search q <topic>"` | Full semantic search |
| `"remember this"` | Quick note to daily file |
| `"check messages"` | Check Redis for agent messages |
| `"send to Max"` | Send message to Max via Redis |
### Environment Variables
```bash
# Qdrant
QDRANT_URL=http://10.0.0.40:6333
# Redis
REDIS_HOST=10.0.0.36
REDIS_PORT=6379
# Ollama
OLLAMA_URL=http://10.0.0.10:11434
# User
DEFAULT_USER_ID=rob
```
---
## Version History
| Date | Version | Changes |
|------|---------|---------|
| 2026-02-18 | 1.0 | Initial documentation |
| 2026-02-18 | 2.0 | Added QMD, Task Queue, Session Harvesting, Email Integration, complete script reference |
---
## Quick Reference Card
### Memory Commands
```
save mem → Redis + File (all turns)
save q → Qdrant (semantic, embeddings)
q <topic> → Search Qdrant
remember this → Quick note to file
```
### Architecture Layers
```
Layer 0: Session Context (temporary)
Layer 1: Redis Buffer (fast, 3:00 AM flush)
Layer 2: File Logs (permanent, human-readable)
Layer 3: Qdrant (semantic, searchable)
```
### Key Files
```
memory/YYYY-MM-DD.md → Daily conversation logs
MEMORY.md → Curated long-term memory
MEMORY_DEF/*.md → System documentation
skills/*/scripts/*.py → Automation scripts
```
### Infrastructure
```
10.0.0.40:6333 → Qdrant (vectors)
10.0.0.36:6379 → Redis (buffer + tasks)
10.0.0.10:11434 → Ollama (embeddings)
```
---
*This document serves as the complete specification for the memory system.*
*For questions or updates, see MEMORY.md or the SKILL.md files in each skill directory.*
-463
View File
@@ -1,463 +0,0 @@
#!/bin/bash
# OpenClaw Jarvis-Like Memory System - Installation Script
# This script sets up the complete memory system from scratch
set -e
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Configuration
WORKSPACE_DIR="${WORKSPACE_DIR:-$HOME/.openclaw/workspace}"
USER_ID="${USER_ID:-$(whoami)}"
REDIS_HOST="${REDIS_HOST:-127.0.0.1}"
REDIS_PORT="${REDIS_PORT:-6379}"
QDRANT_URL="${QDRANT_URL:-http://127.0.0.1:6333}"
OLLAMA_URL="${OLLAMA_URL:-http://127.0.0.1:11434}"
# Optional toggles (avoid touching host config during tests)
SKIP_CRON="${SKIP_CRON:-0}"
SKIP_HEARTBEAT="${SKIP_HEARTBEAT:-0}"
SKIP_QDRANT_INIT="${SKIP_QDRANT_INIT:-0}"
# If Redis/Qdrant/Ollama aren't reachable, attempt to start them via docker compose (recommended).
START_DOCKER="${START_DOCKER:-1}"
# Backup directory
BACKUP_DIR="$WORKSPACE_DIR/.backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_PREFIX="$BACKUP_DIR/install_${TIMESTAMP}"
echo "═══════════════════════════════════════════════════════════════"
echo " OpenClaw Jarvis-Like Memory System - Installer"
echo "═══════════════════════════════════════════════════════════════"
echo ""
echo -e "${BLUE}Backup Location: $BACKUP_DIR${NC}"
echo ""
# Create backup directory
mkdir -p "$BACKUP_DIR"
# Function to backup a file before modifying
backup_file() {
local file="$1"
local backup_name="$2"
if [ -f "$file" ]; then
cp "$file" "$backup_name"
echo -e "${GREEN} ✓ Backed up: $(basename $file)$(basename $backup_name)${NC}"
return 0
fi
return 1
}
# Helpers
have_cmd() { command -v "$1" >/dev/null 2>&1; }
need_sudo() {
if [ "$(id -u)" -eq 0 ]; then
return 1
fi
return 0
}
run_root() {
if need_sudo; then
sudo "$@"
else
"$@"
fi
}
install_pkg_debian() {
local pkgs=("$@")
run_root apt-get update -y
run_root apt-get install -y "${pkgs[@]}"
}
install_docker_debian() {
# Prefer distro packages for speed/simplicity.
install_pkg_debian ca-certificates curl gnupg lsb-release
if ! have_cmd docker; then
install_pkg_debian docker.io
run_root systemctl enable --now docker >/dev/null 2>&1 || true
fi
# docker compose v2 plugin (preferred)
if ! docker compose version >/dev/null 2>&1; then
install_pkg_debian docker-compose-plugin || true
fi
# fallback: docker-compose v1
if ! docker compose version >/dev/null 2>&1 && ! have_cmd docker-compose; then
install_pkg_debian docker-compose || true
fi
}
# Step 1: Check/install system dependencies
echo -e "${YELLOW}[1/10] Checking system dependencies...${NC}"
if ! have_cmd curl; then
echo " • Installing curl"
if have_cmd apt-get; then
install_pkg_debian curl
else
echo -e "${RED} ✗ Missing curl and no supported package manager detected.${NC}"
exit 1
fi
fi
# Useful for quick testing (optional)
if ! have_cmd redis-cli; then
echo " • Installing redis-cli (redis-tools)"
if have_cmd apt-get; then
install_pkg_debian redis-tools
fi
fi
if ! have_cmd python3; then
echo " • Installing python3"
if have_cmd apt-get; then
install_pkg_debian python3 python3-pip python3-venv
else
echo -e "${RED} ✗ Python 3 not found. Please install Python 3.8+${NC}"
exit 1
fi
fi
if ! have_cmd pip3; then
echo " • Installing pip3"
if have_cmd apt-get; then
install_pkg_debian python3-pip python3-venv
else
echo -e "${RED} ✗ pip3 not found. Please install python3-pip${NC}"
exit 1
fi
fi
# Docker is optional but recommended (used by docker-compose.yml)
if ! have_cmd docker; then
echo " • Docker not found — installing"
if have_cmd apt-get; then
install_docker_debian
else
echo -e "${RED} ✗ Docker not found and no supported package manager detected.${NC}"
echo " Install Docker manually, then re-run install.sh"
exit 1
fi
else
echo " ✓ Docker found"
fi
# Docker compose (v2 plugin preferred)
if docker compose version >/dev/null 2>&1; then
echo " ✓ docker compose (v2) found"
elif have_cmd docker-compose; then
echo " ✓ docker-compose (v1) found"
else
echo " • docker compose not found — attempting install"
if have_cmd apt-get; then
install_docker_debian
fi
fi
PYTHON_VERSION=$(python3 --version 2>&1 | awk '{print $2}')
echo " ✓ Python $PYTHON_VERSION found"
# Step 2: Create directory structure + copy blueprint files
echo -e "${YELLOW}[2/10] Creating directory structure...${NC}"
mkdir -p "$WORKSPACE_DIR"/{skills/{mem-redis,qdrant-memory,task-queue}/scripts,memory,MEMORY_DEF,docs,config}
touch "$WORKSPACE_DIR/memory/.gitkeep"
# Copy scripts/docs from this repo into workspace (portable install)
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
cp -r "$SCRIPT_DIR/skills"/* "$WORKSPACE_DIR/skills/" 2>/dev/null || true
cp -r "$SCRIPT_DIR/docs"/* "$WORKSPACE_DIR/docs/" 2>/dev/null || true
cp -r "$SCRIPT_DIR/config"/* "$WORKSPACE_DIR/config/" 2>/dev/null || true
echo " ✓ Directories created and files copied"
# Step 3: Install Python dependencies (PEP 668-safe)
echo -e "${YELLOW}[3/10] Installing Python dependencies...${NC}"
REQ_FILE="$(dirname "$0")/requirements.txt"
PYTHON_BIN="python3"
pip_user_install() {
if [ -f "$REQ_FILE" ]; then
pip3 install --user -r "$REQ_FILE"
else
pip3 install --user redis qdrant-client requests urllib3
fi
}
pip_venv_install() {
local venv_dir="$WORKSPACE_DIR/.venv"
python3 -m venv "$venv_dir"
"$venv_dir/bin/pip" install --upgrade pip setuptools wheel >/dev/null 2>&1 || true
if [ -f "$REQ_FILE" ]; then
"$venv_dir/bin/pip" install -r "$REQ_FILE"
else
"$venv_dir/bin/pip" install redis qdrant-client requests urllib3
fi
PYTHON_BIN="$venv_dir/bin/python"
}
# Try user install first (fast path)
if pip_user_install >/dev/null 2>&1; then
echo " ✓ Dependencies installed (pip --user)"
else
echo " ️ pip --user install blocked (PEP 668). Creating venv in $WORKSPACE_DIR/.venv"
pip_venv_install
echo " ✓ Dependencies installed (venv)"
fi
# Step 4: Test infrastructure connectivity
echo -e "${YELLOW}[4/10] Testing infrastructure...${NC}"
docker_compose_up() {
local compose_dir="$(cd "$(dirname "$0")" && pwd)"
if docker compose version >/dev/null 2>&1; then
run_root docker compose -f "$compose_dir/docker-compose.yml" up -d
elif have_cmd docker-compose; then
run_root docker-compose -f "$compose_dir/docker-compose.yml" up -d
fi
}
redis_ok=0
qdrant_ok=0
ollama_ok=0
# Test Redis
if "$PYTHON_BIN" -c "import redis; r=redis.Redis(host='$REDIS_HOST', port=$REDIS_PORT); r.ping()" 2>/dev/null; then
redis_ok=1
fi
# Test Qdrant
if curl -s "$QDRANT_URL/collections" >/dev/null 2>&1; then
qdrant_ok=1
fi
# Test Ollama
if curl -s "$OLLAMA_URL/api/tags" >/dev/null 2>&1; then
ollama_ok=1
fi
if [ "$START_DOCKER" = "1" ] && { [ $redis_ok -eq 0 ] || [ $qdrant_ok -eq 0 ] || [ $ollama_ok -eq 0 ]; }; then
echo " ️ Infrastructure not reachable; attempting to start via docker compose"
docker_compose_up || true
sleep 2
if "$PYTHON_BIN" -c "import redis; r=redis.Redis(host='$REDIS_HOST', port=$REDIS_PORT); r.ping()" 2>/dev/null; then redis_ok=1; fi
if curl -s "$QDRANT_URL/collections" >/dev/null 2>&1; then qdrant_ok=1; fi
if curl -s "$OLLAMA_URL/api/tags" >/dev/null 2>&1; then ollama_ok=1; fi
fi
if [ $redis_ok -eq 1 ]; then
echo " ✓ Redis connection OK"
else
echo -e "${RED} ✗ Redis connection failed ($REDIS_HOST:$REDIS_PORT)${NC}"
fi
if [ $qdrant_ok -eq 1 ]; then
echo " ✓ Qdrant connection OK"
else
echo -e "${RED} ✗ Qdrant connection failed ($QDRANT_URL)${NC}"
fi
if [ $ollama_ok -eq 1 ]; then
echo " ✓ Ollama connection OK"
else
echo -e "${YELLOW} ⚠️ Ollama not reachable ($OLLAMA_URL)${NC}"
echo " Embeddings will fail until Ollama is running with snowflake-arctic-embed2."
fi
# Step 5: Backup existing files before modifying
echo ""
echo -e "${YELLOW}[5/10] Creating backups of existing files...${NC}"
BACKUP_COUNT=0
# Backup existing crontab
if crontab -l 2>/dev/null >/dev/null; then
crontab -l > "${BACKUP_PREFIX}_crontab.bak.rush" 2>/dev/null
echo -e "${GREEN} ✓ Backed up crontab → .backups/install_${TIMESTAMP}_crontab.bak.rush${NC}"
((BACKUP_COUNT++))
else
echo " ️ No existing crontab to backup"
fi
# Backup existing HEARTBEAT.md
if backup_file "$WORKSPACE_DIR/HEARTBEAT.md" "${BACKUP_PREFIX}_HEARTBEAT.md.bak.rush"; then
((BACKUP_COUNT++))
fi
# Backup existing .memory_env
if backup_file "$WORKSPACE_DIR/.memory_env" "${BACKUP_PREFIX}_memory_env.bak.rush"; then
((BACKUP_COUNT++))
fi
if [ $BACKUP_COUNT -eq 0 ]; then
echo " ️ No existing files to backup (fresh install)"
else
echo -e "${GREEN}$BACKUP_COUNT file(s) backed up to $BACKUP_DIR${NC}"
fi
# Step 6: Create environment configuration
echo ""
echo -e "${YELLOW}[6/10] Creating environment configuration...${NC}"
cat > "$WORKSPACE_DIR/.memory_env" <<EOF
# Memory System Environment Variables
export WORKSPACE_DIR="$WORKSPACE_DIR"
export USER_ID="$USER_ID"
export REDIS_HOST="$REDIS_HOST"
export REDIS_PORT="$REDIS_PORT"
export QDRANT_URL="$QDRANT_URL"
export OLLAMA_URL="$OLLAMA_URL"
export MEMORY_PYTHON="$PYTHON_BIN"
export MEMORY_INITIALIZED="true"
EOF
echo " ✓ Created $WORKSPACE_DIR/.memory_env"
# Step 7: Initialize Qdrant collections
echo -e "${YELLOW}[7/10] Initializing Qdrant collections...${NC}"
if [ "$SKIP_QDRANT_INIT" = "1" ]; then
echo " ️ SKIP_QDRANT_INIT=1 set; skipping Qdrant collection init"
else
"$PYTHON_BIN" <<EOF
import sys
sys.path.insert(0, "$WORKSPACE_DIR/skills/qdrant-memory/scripts")
# init_kimi_memories exposes create_collection() in this blueprint
from init_kimi_memories import create_collection, collection_exists
if not collection_exists():
ok = create_collection()
if not ok:
raise SystemExit(1)
print(" ✓ kimi_memories collection ready")
EOF
fi
# Step 8: Set up cron jobs
echo -e "${YELLOW}[8/10] Setting up cron jobs...${NC}"
if [ "$SKIP_CRON" = "1" ]; then
echo " ️ SKIP_CRON=1 set; skipping crontab modifications"
else
CRON_FILE=$(mktemp)
crontab -l 2>/dev/null > "$CRON_FILE" || true
# Add memory backup cron jobs if not present
if ! grep -q "cron_backup.py" "$CRON_FILE" 2>/dev/null; then
echo "" >> "$CRON_FILE"
echo "# Memory System - Daily backup (3:00 AM)" >> "$CRON_FILE"
echo "0 3 * * * cd $WORKSPACE_DIR && $PYTHON_BIN skills/mem-redis/scripts/cron_backup.py >> /var/log/memory-backup.log 2>&1 || true" >> "$CRON_FILE"
fi
if ! grep -q "sliding_backup.sh" "$CRON_FILE" 2>/dev/null; then
echo "" >> "$CRON_FILE"
echo "# Memory System - File backup (3:30 AM)" >> "$CRON_FILE"
echo "30 3 * * * $WORKSPACE_DIR/skills/qdrant-memory/scripts/sliding_backup.sh >> /var/log/memory-backup.log 2>&1 || true" >> "$CRON_FILE"
fi
crontab "$CRON_FILE"
rm "$CRON_FILE"
echo " ✓ Cron jobs configured"
fi
# Step 9: Create HEARTBEAT.md template
echo -e "${YELLOW}[9/10] Creating HEARTBEAT.md...${NC}"
if [ "$SKIP_HEARTBEAT" = "1" ]; then
echo " ️ SKIP_HEARTBEAT=1 set; skipping HEARTBEAT.md write"
else
cat > "$WORKSPACE_DIR/HEARTBEAT.md" <<'EOF'
# HEARTBEAT.md - Memory System Automation
## Memory Buffer (Every Heartbeat)
Saves current session context to Redis buffer:
```bash
python3 ~/.openclaw/workspace/skills/mem-redis/scripts/save_mem.py --user-id YOUR_USER_ID
```
## Daily Backup Schedule
- **3:00 AM**: Redis buffer → Qdrant flush
- **3:30 AM**: File-based sliding backup
## Manual Commands
| Command | Action |
|---------|--------|
| `save mem` | Save all context to Redis |
| `save q` | Store immediately to Qdrant |
| `q <topic>` | Search memories |
EOF
echo " ✓ HEARTBEAT.md created"
fi
# Create backup manifest
echo ""
echo -e "${YELLOW}Creating backup manifest...${NC}"
MANIFEST_FILE="${BACKUP_PREFIX}_MANIFEST.txt"
cat > "$MANIFEST_FILE" <<EOF
OpenClaw Jarvis Memory - Installation Backup Manifest
======================================================
Date: $(date)
Timestamp: $TIMESTAMP
Backup Directory: $BACKUP_DIR
Files Backed Up:
EOF
# List backed up files
for file in "$BACKUP_DIR"/install_${TIMESTAMP}_*.bak.rush; do
if [ -f "$file" ]; then
basename "$file" >> "$MANIFEST_FILE"
fi
done
cat >> "$MANIFEST_FILE" <<EOF
To Restore Files Manually:
==========================
1. Restore crontab:
crontab "$BACKUP_DIR/install_${TIMESTAMP}_crontab.bak.rush"
2. Restore HEARTBEAT.md:
cp "$BACKUP_DIR/install_${TIMESTAMP}_HEARTBEAT.md.bak.rush" "$WORKSPACE_DIR/HEARTBEAT.md"
3. Restore .memory_env:
cp "$BACKUP_DIR/install_${TIMESTAMP}_memory_env.bak.rush" "$WORKSPACE_DIR/.memory_env"
All backups are stored in: $BACKUP_DIR
EOF
echo -e "${GREEN} ✓ Backup manifest created: ${BACKUP_PREFIX}_MANIFEST.txt${NC}"
echo ""
echo "═══════════════════════════════════════════════════════════════"
echo -e "${GREEN} Installation Complete!${NC}"
echo "═══════════════════════════════════════════════════════════════"
echo ""
if [ $BACKUP_COUNT -gt 0 ]; then
echo -e "${BLUE}Backups created:${NC} $BACKUP_COUNT file(s) in $BACKUP_DIR"
echo " Timestamp: install_${TIMESTAMP}_*.bak.rush"
echo ""
fi
echo "Next steps:"
echo " 1. Source the environment: source $WORKSPACE_DIR/.memory_env"
echo " 2. Test the system: $PYTHON_BIN $WORKSPACE_DIR/skills/mem-redis/scripts/save_mem.py --user-id $USER_ID"
echo " 3. Add to your HEARTBEAT.md to enable automatic saving"
echo ""
echo "To undo installation:"
echo " ./uninstall.sh"
echo ""
echo "To restore from backup:"
echo " See $BACKUP_DIR/install_${TIMESTAMP}_MANIFEST.txt"
echo ""
echo "Documentation:"
echo " - $WORKSPACE_DIR/docs/MEM_DIAGRAM.md"
echo " - $WORKSPACE_DIR/skills/mem-redis/SKILL.md"
echo " - $WORKSPACE_DIR/skills/qdrant-memory/SKILL.md"
echo ""
echo "Happy building! 🚀"
Binary file not shown.
+36
View File
@@ -0,0 +1,36 @@
Collection: knowledge_base
Metadata Schema:
{
"subject": "Machine Learning", // Primary topic/theme
"subjects": ["AI", "NLP"], // Related subjects for cross-linking
"category": "reference", // reference | code | notes | documentation
"path": "AI/ML/Transformers", // Hierarchical location (like filesystem)
"level": 2, // Depth: 0=root, 1=section, 2=chunk
"parent_id": "abc-123", // Parent document ID (for chunks/children)
"content_type": "web_page", // web_page | pdf | code | markdown | note
"language": "python", // For code/docs (optional)
"project": "llm-research", // Optional project tag
"checksum": "sha256:abc...", // For duplicate detection
"source_url": "https://...", // Optional reference (not primary org)
"title": "Understanding Transformers", // Display name
"concepts": ["attention", "bert"], // Auto-extracted key concepts
"date_added": "2026-02-05",
"date_updated": "2026-02-05"
}
Key Design Decisions:
- Subject-first: Organize by topic, not by where it came from
- Path-based hierarchy: Navigate "AI/ML/Transformers" or "Projects/HomeLab/Docker"
- Separate from memories: knowledge_base and openclaw_memories don't mix
- Duplicate handling: Checksum comparison → overwrite if changed, skip if same
- No retention limits
Use Cases:
- Web scrape → path: "Research/Web/<topic>", subject: extracted topic
- Project docs → path: "Projects/<project-name>/<doc>", project tag
- Code reference → path: "Code/<language>/<topic>", language field
- Personal notes → path: "Notes/<category>/<note>"
Binary file not shown.
-540
View File
@@ -1,540 +0,0 @@
<!DOCTYPE html>
<html lang="en" class="scroll-smooth">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="Complete architecture diagrams for the Jarvis-like memory system - three-layer persistent memory for OpenClaw">
<meta name="keywords" content="OpenClaw, memory architecture, Qdrant, Redis, vector database, AI memory">
<meta name="author" content="Rob - SpeedyFoxAI">
<title>Memory Architecture Diagrams | SpeedyFoxAI</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<link rel="icon" type="image/png" href="/favicon.png">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap');
body { font-family: 'Inter', sans-serif; }
.mono { font-family: 'JetBrains Mono', monospace; }
.gradient-text {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.glass {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dark .glass {
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Architecture Diagram Styles */
.arch-layer {
position: relative;
border-radius: 12px;
padding: 1.5rem;
margin: 1rem 0;
transition: all 0.3s ease;
}
.arch-layer:hover {
transform: translateY(-2px);
box-shadow: 0 20px 40px rgba(0,0,0,0.1);
}
.layer-0 { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 2px solid #f59e0b; }
.layer-1 { background: linear-gradient(135deg, #dbeafe 0%, #93c5fd 100%); border: 2px solid #3b82f6; }
.layer-2 { background: linear-gradient(135deg, #d1fae5 0%, #6ee7b7 100%); border: 2px solid #10b981; }
.layer-3 { background: linear-gradient(135deg, #ede9fe 0%, #a78bfa 100%); border: 2px solid #8b5cf6; }
.dark .layer-0 { background: linear-gradient(135deg, #451a03 0%, #78350f 100%); border-color: #f59e0b; }
.dark .layer-1 { background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 100%); border-color: #3b82f6; }
.dark .layer-2 { background: linear-gradient(135deg, #064e3b 0%, #065f46 100%); border-color: #10b981; }
.dark .layer-3 { background: linear-gradient(135deg, #4c1d95 0%, #5b21b6 100%); border-color: #8b5cf6; }
.flow-arrow {
text-align: center;
font-size: 2rem;
color: #6366f1;
margin: 0.5rem 0;
}
.component-box {
background: rgba(255,255,255,0.7);
border-radius: 8px;
padding: 0.75rem 1rem;
margin: 0.5rem 0;
border-left: 4px solid;
}
.dark .component-box {
background: rgba(0,0,0,0.3);
}
.cmd-badge {
display: inline-block;
background: #1f2937;
color: #10b981;
padding: 0.25rem 0.75rem;
border-radius: 6px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
margin: 0.25rem;
}
.dark .cmd-badge {
background: #111827;
color: #34d399;
}
.status-ok { color: #10b981; }
.status-warn { color: #f59e0b; }
.status-error { color: #ef4444; }
.dark .status-ok { color: #34d399; }
.dark .status-warn { color: #fbbf24; }
.dark .status-error { color: #f87171; }
.infra-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin: 1rem 0;
}
.infra-card {
background: rgba(255,255,255,0.5);
border: 2px solid #e5e7eb;
border-radius: 12px;
padding: 1rem;
text-align: center;
}
.dark .infra-card {
background: rgba(0,0,0,0.3);
border-color: #374151;
}
.code-block {
background: #1f2937;
color: #e5e7eb;
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
font-family: 'JetBrains Mono', monospace;
font-size: 0.875rem;
}
.tab-content { display: none; }
.tab-content.active { display: block; }
.tab-btn {
padding: 0.5rem 1rem;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn.active {
border-bottom-color: #6366f1;
color: #6366f1;
}
.dark .tab-btn.active {
color: #818cf8;
border-bottom-color: #818cf8;
}
</style>
</head>
<body class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 transition-colors duration-300">
<!-- Navigation -->
<nav class="fixed w-full z-50 glass">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-16">
<div class="flex-shrink-0 flex items-center">
<a href="/index.html" class="text-2xl font-bold gradient-text">SpeedyFoxAI</a>
</div>
<div class="hidden md:flex space-x-8">
<a href="/index.html" class="hover:text-primary transition-colors">Home</a>
<a href="/index.html#tutorials" class="text-primary font-medium">Tutorials</a>
<a href="/downloads.html" class="hover:text-primary transition-colors">Downloads</a>
</div>
<div class="flex items-center space-x-4">
<button id="theme-toggle" class="p-2 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors">
<i class="fas fa-sun hidden dark:block"></i>
<i class="fas fa-moon block dark:hidden"></i>
</button>
</div>
</div>
</div>
</nav>
<!-- Hero -->
<section class="pt-32 pb-16 bg-gradient-to-br from-indigo-50 via-purple-50 to-cyan-50 dark:from-gray-900 dark:via-indigo-950 dark:to-purple-950">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm mb-6">
<i class="fas fa-project-diagram text-primary"></i>
<span class="text-sm font-medium">Architecture Reference</span>
</div>
<h1 class="text-4xl md:text-6xl font-bold mb-6">
<span class="gradient-text">Memory Architecture</span><br>
Complete Diagrams
</h1>
<p class="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto">
Visual reference for the three-layer Jarvis-like memory system. Redis buffer, Markdown logs, and Qdrant vector database working together.
</p>
</div>
</section>
<!-- Architecture Overview -->
<section class="py-16 bg-white dark:bg-gray-800">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold mb-8 text-center">System Architecture Overview</h2>
<!-- Layer 0 -->
<div class="arch-layer layer-0 dark:text-white">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-yellow-500 flex items-center justify-center text-white font-bold">0</div>
<h3 class="text-xl font-bold">Session Context (OpenClaw Gateway)</h3>
<span class="ml-auto text-sm opacity-70">Temporary Only</span>
</div>
<div class="component-box border-yellow-500">
<p class="font-medium">Session JSONL → Live Context</p>
<p class="text-sm opacity-70 mt-1">Lost on /reset or session expiration • ~8k-32k tokens</p>
</div>
</div>
<div class="flow-arrow"><i class="fas fa-arrow-down"></i></div>
<!-- Layer 1 -->
<div class="arch-layer layer-1 dark:text-white">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-blue-500 flex items-center justify-center text-white font-bold">1</div>
<h3 class="text-xl font-bold">Redis Buffer (Fast Short-Term)</h3>
<span class="ml-auto text-sm opacity-70">Real-Time</span>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div class="component-box border-blue-500">
<p class="font-medium"><i class="fas fa-database mr-2"></i>Key: mem:rob</p>
<p class="text-sm opacity-70 mt-1">List data structure • LPUSH append</p>
</div>
<div class="component-box border-blue-500">
<p class="font-medium"><i class="fas fa-clock mr-2"></i>Flush: Daily 3:00 AM</p>
<p class="text-sm opacity-70 mt-1">cron_backup.py → Qdrant</p>
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<span class="cmd-badge">hb_append.py</span>
<span class="cmd-badge">save_mem.py</span>
<span class="cmd-badge">mem:rob</span>
</div>
</div>
<div class="flow-arrow"><i class="fas fa-arrow-down"></i></div>
<!-- Layer 2 -->
<div class="arch-layer layer-2 dark:text-white">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-green-500 flex items-center justify-center text-white font-bold">2</div>
<h3 class="text-xl font-bold">Daily File Logs (.md)</h3>
<span class="ml-auto text-sm opacity-70">Persistent</span>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div class="component-box border-green-500">
<p class="font-medium"><i class="fas fa-file-alt mr-2"></i>Location: memory/YYYY-MM-DD.md</p>
<p class="text-sm opacity-70 mt-1">Human-readable Markdown</p>
</div>
<div class="component-box border-green-500">
<p class="font-medium"><i class="fas fa-code-branch mr-2"></i>Git-tracked</p>
<p class="text-sm opacity-70 mt-1">Never lost • Always accessible</p>
</div>
</div>
<div class="mt-4">
<span class="cmd-badge">sliding_backup.sh</span>
<span class="cmd-badge">3:30 AM daily</span>
</div>
</div>
<div class="flow-arrow"><i class="fas fa-arrow-down"></i></div>
<!-- Layer 3 -->
<div class="arch-layer layer-3 dark:text-white">
<div class="flex items-center gap-3 mb-4">
<div class="w-10 h-10 rounded-full bg-purple-500 flex items-center justify-center text-white font-bold">3</div>
<h3 class="text-xl font-bold">Qdrant Vector DB (Semantic Long-Term)</h3>
<span class="ml-auto text-sm opacity-70">Searchable</span>
</div>
<div class="grid md:grid-cols-3 gap-4 mb-4">
<div class="component-box border-purple-500">
<p class="font-medium"><i class="fas fa-cube mr-2"></i>Embeddings</p>
<p class="text-sm opacity-70 mt-1">snowflake-arctic-embed2<br>1024 dimensions</p>
</div>
<div class="component-box border-purple-500">
<p class="font-medium"><i class="fas fa-layer-group mr-2"></i>Collections</p>
<p class="text-sm opacity-70 mt-1">kimi_memories<br>kimi_kb<br>private_court_docs</p>
</div>
<div class="component-box border-purple-500">
<p class="font-medium"><i class="fas fa-user mr-2"></i>User-Centric</p>
<p class="text-sm opacity-70 mt-1">user_id: "rob"<br>Cross-session search</p>
</div>
</div>
<div class="flex flex-wrap gap-2">
<span class="cmd-badge">auto_store.py</span>
<span class="cmd-badge">search_memories.py</span>
<span class="cmd-badge">q <topic></span>
</div>
</div>
</div>
</section>
<!-- Data Flow -->
<section class="py-16 bg-gray-50 dark:bg-gray-900">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold mb-8 text-center">Data Flow & Automation</h2>
<div class="grid lg:grid-cols-3 gap-8">
<!-- Real-Time -->
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg">
<div class="flex items-center gap-3 mb-4">
<div class="w-12 h-12 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<i class="fas fa-bolt text-2xl text-blue-600 dark:text-blue-400"></i>
</div>
<h3 class="text-xl font-bold">Real-Time</h3>
</div>
<div class="code-block">
User Input → AI Response
Redis Buffer (fast)
File Log (persistent)
[Optional: save q] → Qdrant
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4">Every message triggers automatic append to Redis and file log.</p>
</div>
<!-- Heartbeat -->
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg">
<div class="flex items-center gap-3 mb-4">
<div class="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<i class="fas fa-heartbeat text-2xl text-green-600 dark:text-green-400"></i>
</div>
<h3 class="text-xl font-bold">Heartbeat</h3>
</div>
<div class="code-block">
Every 30-60 min:
hb_append.py → New turns
hb_check_email.py → Gmail
heartbeat_worker.py → Tasks
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4">Periodic checks accumulate new turns since last save.</p>
</div>
<!-- Daily -->
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg">
<div class="flex items-center gap-3 mb-4">
<div class="w-12 h-12 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<i class="fas fa-moon text-2xl text-purple-600 dark:text-purple-400"></i>
</div>
<h3 class="text-xl font-bold">Daily Backup</h3>
</div>
<div class="code-block">
3:00 AM: Redis → Qdrant
Clear Redis
3:30 AM: Files → Backup
→ Qdrant embeddings
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-4">Nightly cron jobs flush buffers to permanent storage.</p>
</div>
</div>
</div>
</section>
<!-- Infrastructure -->
<section class="py-16 bg-white dark:bg-gray-800">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold mb-8 text-center">Infrastructure Topology</h2>
<div class="bg-gray-100 dark:bg-gray-900 rounded-2xl p-8">
<div class="text-center mb-6">
<span class="px-4 py-2 rounded-full bg-gray-200 dark:bg-gray-700 font-medium">Proxmox Cluster</span>
</div>
<div class="infra-grid">
<div class="infra-card">
<div class="w-12 h-12 mx-auto mb-3 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
<i class="fas fa-server text-2xl text-blue-600 dark:text-blue-400"></i>
</div>
<h4 class="font-bold mb-1">Ollama</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">10.0.0.10:11434</p>
<p class="text-xs text-gray-500">Embeddings</p>
</div>
<div class="infra-card">
<div class="w-12 h-12 mx-auto mb-3 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center">
<i class="fas fa-database text-2xl text-purple-600 dark:text-purple-400"></i>
</div>
<h4 class="font-bold mb-1">Qdrant</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">10.0.0.40:6333</p>
<p class="text-xs text-gray-500">Vector DB</p>
</div>
<div class="infra-card">
<div class="w-12 h-12 mx-auto mb-3 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<i class="fas fa-memory text-2xl text-green-600 dark:text-green-400"></i>
</div>
<h4 class="font-bold mb-1">Redis</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">10.0.0.36:6379</p>
<p class="text-xs text-gray-500">Task Queue</p>
</div>
<div class="infra-card">
<div class="w-12 h-12 mx-auto mb-3 rounded-full bg-cyan-100 dark:bg-cyan-900/30 flex items-center justify-center">
<i class="fas fa-search text-2xl text-cyan-600 dark:text-cyan-400"></i>
</div>
<h4 class="font-bold mb-1">SearXNG</h4>
<p class="text-sm text-gray-600 dark:text-gray-400">10.0.0.8:8888</p>
<p class="text-xs text-gray-500">Search</p>
</div>
</div>
</div>
</div>
</section>
<!-- Commands Reference -->
<section class="py-16 bg-gray-50 dark:bg-gray-900">
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-3xl font-bold mb-8 text-center">Command Reference</h2>
<div class="grid lg:grid-cols-2 gap-8">
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg">
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<i class="fas fa-terminal text-primary"></i>
User Commands
</h3>
<table class="w-full text-sm">
<thead class="border-b dark:border-gray-700">
<tr>
<th class="text-left py-2">Command</th>
<th class="text-left py-2">Action</th>
<th class="text-left py-2">Layer</th>
</tr>
</thead>
<tbody>
<tr class="border-b dark:border-gray-700">
<td class="py-3 font-mono text-green-600 dark:text-green-400">save mem</td>
<td>Save all context</td>
<td><span class="text-blue-600 dark:text-blue-400">Redis + File</span></td>
</tr>
<tr class="border-b dark:border-gray-700">
<td class="py-3 font-mono text-purple-600 dark:text-purple-400">save q</td>
<td>Store to Qdrant</td>
<td><span class="text-purple-600 dark:text-purple-400">Vector DB</span></td>
</tr>
<tr class="border-b dark:border-gray-700">
<td class="py-3 font-mono text-indigo-600 dark:text-indigo-400">q topic</td>
<td>Semantic search</td>
<td><span class="text-purple-600 dark:text-purple-400">Vector DB</span></td>
</tr>
<tr>
<td class="py-3 font-mono text-yellow-600 dark:text-yellow-400">remember</td>
<td>Quick note</td>
<td><span class="text-green-600 dark:text-green-400">File</span></td>
</tr>
</tbody>
</table>
</div>
<div class="bg-white dark:bg-gray-800 rounded-2xl p-6 shadow-lg">
<h3 class="text-xl font-bold mb-4 flex items-center gap-2">
<i class="fas fa-clock text-primary"></i>
Automation
</h3>
<table class="w-full text-sm">
<thead class="border-b dark:border-gray-700">
<tr>
<th class="text-left py-2">Schedule</th>
<th class="text-left py-2">Script</th>
<th class="text-left py-2">Action</th>
</tr>
</thead>
<tbody>
<tr class="border-b dark:border-gray-700">
<td class="py-3">Heartbeat</td>
<td class="font-mono">hb_append.py</td>
<td>New turns → Redis</td>
</tr>
<tr class="border-b dark:border-gray-700">
<td class="py-3">3:00 AM</td>
<td class="font-mono">cron_backup.py</td>
<td>Redis → Qdrant</td>
</tr>
<tr>
<td class="py-3">3:30 AM</td>
<td class="font-mono">sliding_backup.sh</td>
<td>File → Backup</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
<!-- Download -->
<section class="py-16 bg-gradient-to-r from-primary to-secondary relative overflow-hidden">
<div class="absolute inset-0 bg-black/10"></div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10 text-center">
<h2 class="text-3xl font-bold text-white mb-6">Get the Complete Blueprint</h2>
<p class="text-white/90 text-lg mb-8 max-w-2xl mx-auto">
Download the full Jarvis Memory blueprint with all scripts, documentation, and installation guide.
</p>
<a href="/downloads.html" class="inline-flex items-center gap-2 bg-white text-primary px-8 py-4 rounded-full font-semibold text-lg hover:bg-gray-100 transition-colors">
<i class="fas fa-download"></i>
Go to Downloads
</a>
</div>
</section>
<!-- Footer -->
<footer class="bg-gray-900 text-gray-400 py-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-col md:flex-row justify-between items-center gap-6">
<div class="text-2xl font-bold text-white">SpeedyFoxAI</div>
<div class="text-sm">
© 2026 SpeedyFoxAI. All rights reserved.
</div>
<div class="flex gap-4">
<a href="/index.html" class="hover:text-white transition-colors">Home</a>
<a href="/downloads.html" class="hover:text-white transition-colors">Downloads</a>
</div>
</div>
</div>
</footer>
<!-- Theme Toggle -->
<script>
const themeToggle = document.getElementById('theme-toggle');
const html = document.documentElement;
if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
themeToggle.addEventListener('click', () => {
html.classList.toggle('dark');
localStorage.theme = html.classList.contains('dark') ? 'dark' : 'light';
});
</script>
</body>
</html>
+194
View File
@@ -0,0 +1,194 @@
# Memory - 2026-02-04
## Ollama Configuration
- **Location**: Separate VM at `10.0.0.10:11434`
- **OpenClaw config**: `baseUrl: http://10.0.0.10:11434/v1`
- **Two models configured only** (clean setup)
## Available Models
| Model | Role | Notes |
|-------|------|-------|
| kimi-k2.5:cloud | **Primary** | Default (me), 340B remote hosted |
| hf.co/unsloth/gpt-oss-120b-GGUF:F16 | **Backup** | Fallback, 117B params, 65GB |
## Aliases (shortcuts)
| Alias | Model |
|-------|-------|
| kimi | ollama/kimi-k2.5:cloud |
| gpt-oss-120b | ollama/hf.co/unsloth/gpt-oss-120b-GGUF:F16 |
## Switching Models
```bash
# Switch to backup
/model ollama/hf.co/unsloth/gpt-oss-120b-GGUF:F16
# Or via CLI
openclaw chat -m ollama/hf.co/unsloth/gpt-oss-120b-GGUF:F16
# Switch back to me (kimi)
/model kimi
```
## TTS Configuration - Kokoro Local
- **Endpoint**: `http://10.0.0.228:8880/v1/audio/speech`
- **Status**: Tested and working (63KB MP3 generated successfully)
- **OpenAI-compatible**: Yes (supports `tts-1`, `tts-1-hd`, `kokoro` models)
- **Voices**: 68 total across languages (American, British, Spanish, French, German, Italian, Japanese, Portuguese, Chinese)
- **Default voice**: `af_bella` (American Female)
- **Notable voices**: `af_nova`, `am_echo`, `af_heart`, `af_alloy`, `bf_emma`
### Config Schema Fix
```json
{
"messages": {
"tts": {
"auto": "always", // Options: "off", "always", "inbound", "tagged"
"provider": "elevenlabs", // or "openai", "edge"
"elevenlabs": {
"baseUrl": "http://10.0.0.228:8880" // <-- Only ElevenLabs supports baseUrl!
}
}
}
}
```
**Important**: `messages.tts.openai` does NOT support `baseUrl` - only `apiKey`, `model`, `voice`.
### Solutions for Local Kokoro:
1. **Custom TTS skill** (cleanest) - call Kokoro API directly
2. **OPENAI_BASE_URL env var** - may redirect all OpenAI calls globally
3. **Use as Edge TTS** - treat Kokoro as "local Edge" replacement
## Infrastructure Notes
- **Container**: Running without GPUs attached (CPU-only)
- **Implication**: All ML workloads (Whisper, etc.) will run on CPU
## User Preferences
### Installation Decision Tree
**When asked to install/configure something:**
1. **Can it be a skill?** → Create a skill
2. **Does it work in TOOLS.md?** → Add to TOOLS.md
*(environment-specific notes: device names, SSH hosts, voice prefs, etc.)*
3. **Neither** → Suggest other options
**Examples:**
- New API integration → Skill
- Camera names/locations → TOOLS.md
- Custom script/tool → Skill
- Preferred TTS voice → TOOLS.md
### Core Preferences
- **Free** — Primary requirement for all tools/integrations
- **Local preferred** — Self-hosted over cloud/SaaS when possible
## Agent Notes
- **Do NOT restart/reboot the gateway** — user must turn me on manually
- Request user to reboot me instead of auto-restarting services
- TTS config file: `/root/.openclaw/openclaw.json` under `messages.tts` key
## Bootstrap Complete - 2026-02-04
### Files Created/Updated Today
- ✅ USER.md — Rob's profile
- ✅ IDENTITY.md — Kimi's identity
- ✅ TOOLS.md — Voice/text rules, local services
- ✅ MEMORY.md — Long-term memory initialized
- ✅ AGENTS.md — Installation policy documented
- ✅ Deleted BOOTSTRAP.md — Onboarding complete
### Skills Created Today
-`local-whisper-stt` — Local voice transcription (Faster-Whisper, CPU)
-`kimi-tts-custom` — Custom TTS with Kimi-XXX filenames
### Working Systems
- Bidirectional voice (voice↔voice, text↔text)
- Local Kokoro TTS @ 10.0.0.228:8880
- Local SearXNG web search
- Local Ollama @ 10.0.0.10:11434
### Key Decisions
- Voice-only replies (no transcripts to Telegram)
- Kimi-YYYYMMDD-HHMMSS.ogg filename format
- Free + Local > Cloud/SaaS philosophy established
---
## Pre-Compaction Summary - 2026-02-04 21:17 CST
### Major Setup Completed Today
#### 1. Identity & Names Established
- **AI Name**: Kimi 🎙️
- **User Name**: Rob
- **Relationship**: Direct 1:1, private and trusted
- **Deleted**: BOOTSTRAP.md (onboarding complete)
#### 2. Bidirectional Voice System ✅
- **Outbound**: Kokoro TTS @ `10.0.0.228:8880` with custom filenames
- **Inbound**: Faster-Whisper (CPU, base model) for transcription
- **Voice Filename Format**: `Kimi-YYYYMMDD-HHMMSS.ogg`
- **Rule**: Voice in → Voice out, Text in → Text out
- **No transcripts sent to Telegram** (internal transcription only)
#### 3. Skills Created Today
| Skill | Purpose | Location |
|-------|---------|----------|
| `local-whisper-stt` | Voice transcription (Faster-Whisper) | `/root/.openclaw/skills/local-whisper-stt/` |
| `kimi-tts-custom` | Custom TTS filenames, voice-only mode | `/root/.openclaw/skills/kimi-tts-custom/` |
| `qdrant-memory` | Vector memory augmentation | `/root/.openclaw/skills/qdrant-memory/` |
#### 4. Qdrant Memory System
- **Endpoint**: `http://10.0.0.40:6333` (local Proxmox LXC)
- **Collection**: `openclaw_memories`
- **Vector Size**: 768 (nomic-embed-text)
- **Mode**: **Automatic** - stores/retrieves without prompting
- **Architecture**: Hybrid (file-based + vector-based)
- **Scripts**: store_memory.py, search_memories.py, hybrid_search.py, auto_memory.py
#### 5. Cron Job Created
- **Name**: monthly-backup-reminder
- **Schedule**: First Monday of each month at 10:00 AM CST
- **ID**: fb7081a9-8640-4c51-8ad3-9caa83b6ac9b
- **Delivery**: Telegram message to Rob
#### 6. Core Preferences Documented
- **Accuracy**: Best quality, no compromises
- **Performance**: Optimize for speed
- **Research**: Always web search before installing
- **Local Docs Exception**: OpenClaw/ClawHub docs prioritized
- **Infrastructure**: Free > Paid, Local > Cloud, Private > Public
- **Search Priority**: docs.openclaw.ai, clawhub.com, then other sources
#### 7. Config Files Created/Updated
- `USER.md` - Rob's profile
- `IDENTITY.md` - Kimi's identity
- `TOOLS.md` - Voice rules, search preferences, local services
- `MEMORY.md` - Long-term curated memories
- `AGENTS.md` - Installation policy, heartbeats
- `openclaw.json` - TTS, skills, channels config
### Next Steps (Deferred)
- Continue with additional tool setup requests from Rob
- Qdrant memory is in auto-mode, monitoring for important memories
---
## Lessons Learned - 2026-02-04 22:05 CST
### Skill Script Paths
**Mistake**: Tried to run scripts from wrong paths.
**Correct paths**:
- Whisper: `/root/.openclaw/workspace/skills/local-whisper-stt/scripts/transcribe.py`
- TTS: `/root/.openclaw/workspace/skills/kimi-tts-custom/scripts/voice_reply.py`
**voice_reply.py usage**:
```bash
python3 scripts/voice_reply.py <chat_id> "message text"
# Example:
python3 scripts/voice_reply.py 1544075739 "Hello there"
```
**Stored in Qdrant**: Yes (high importance, tags: voice,skills,paths,commands)
+195
View File
@@ -0,0 +1,195 @@
# 2026-02-05 — Session Log
## Major Accomplishments
### 1. Knowledge Base System Created
- **Collection**: `knowledge_base` in Qdrant (768-dim vectors, cosine distance)
- **Purpose**: Personal knowledge repository organized by topic/domain
- **Schema**: domain, path (hierarchy), subjects, category, content_type, title, checksum, source_url, date_scraped
- **Content stored**:
- docs.openclaw.ai (3 chunks)
- ollama.com/library (25 chunks)
- www.w3schools.com/python/ (7 chunks)
- Multiple list comprehension resources (3 entries)
### 2. Smart Search Workflow Implemented
- **Process**: Search KB first → Web search second → Synthesize → Store new findings
- **Storage rules**: Only substantial content (>500 chars), unique (checksum), full attribution
- **Auto-tagging**: date_scraped, source_url, domain detection
- **Scripts**: `smart_search.py`, `kb_store.py`, `kb_review.py`, `scrape_to_kb.py`
### 3. Monitoring System Established
- **OpenClaw GitHub Repo Monitor**
- Schedule: Daily 11:00 AM
- Tracks: README, releases (5), issues (5)
- Relevance filter: Keywords affecting our setup (ollama, telegram, skills, memory, etc.)
- Notification: Only when significant changes detected (score ≥3 or high-priority areas)
- Initial finding: 24 high-priority areas affected
- **Ollama Model Monitor**
- Schedule: Daily 11:50 AM
- Criteria: 100B+ parameter models only (to compete with gpt-oss:120b)
- Current large models: gpt-oss (120B), mixtral (8x22B = 176B effective)
- Notification: Only when NEW large models appear
### 4. ACTIVE.md Syntax Library Created
- **Purpose**: Pre-flight checklist to reduce tool usage errors
- **Sections**: Per-tool validation (read, edit, write, exec, browser)
- **Includes**: Parameter names, common mistakes, correct/wrong examples
- **Updated**: AGENTS.md to require ACTIVE.md check before tool use
## Key Lessons & Policy Changes
### User Preferences Established
1. **Always discuss before acting** — Never create/build without confirmation
2. **100B+ models only** for Ollama monitoring (not smaller CPU-friendly models)
3. **Silent operation** — Monitors only output when there's something significant to report
4. **Exit code 0 always** for cron scripts (prevents "exec failed" logs)
### Technical Lessons
- `edit` tool requires `old_string` + `new_string` (not `newText`)
- After 2-3 failed edit attempts, use `write` instead
- Cron scripts must always `sys.exit(0)` — use output presence for signaling
- `read` uses `file_path`, never `path`
### Error Handling Policy
- **Search-first strategy**: Check KB, then web search before fixing
- **Exception**: Simple syntax errors (wrong param names, typos) — fix immediately
## Infrastructure Updates
### Qdrant Memory System
- Hybrid approach: File-based + vector-based
- Enhanced metadata: confidence, source, expiration, verification
- Auto-storage triggers defined
- Monthly review scheduled (cleanup of outdated entries)
### Task Queue Repurposed
- No longer for GPT delegation
- Now for Kimi's own background tasks
- GPT workloads moving to separate "Max" VM (future)
## Active Cron Jobs
| Time | Task | Channel |
|------|------|---------|
| 11:00 AM | OpenClaw repo check | Telegram (if significant) |
| 11:50 AM | Ollama 100B+ models | Telegram (if new) |
| 1st of month 3:00 AM | KB review (cleanup) | Silent |
## Enforcement Milestone — 10:34 CST
**Problem**: Despite updating AGENTS.md, TOOLS.md, and MEMORY.md with ACTIVE.md enforcement rules, I continued making the same errors:
- Used `path` instead of `file_path` for `read`
- Failed to provide `new_string` for `edit` (4+ consecutive failures)
**Root Cause**: Documentation ≠ Behavior change. I wrote the rules but didn't follow them.
**User Directive**: "Please enforce" — meaning actual behavioral change, not just file updates.
**Demonstrated Recovery**:
1. ✅ Used `read` with `file_path` correctly
2. ❌ Failed `edit` 4 times (missing `new_string`)
3. ✅ Switched to `write` per ACTIVE.md recovery protocol
4. ✅ Successfully wrote complete file
**Moving Forward**:
- Pre-flight check BEFORE every tool call
- Verify parameter names from ACTIVE.md
- After 2 edit failures → use `write`
- Quality over speed — no more rushing
## Core Instruction Files Updated — 10:36 CST
Updated all core .md files with enforced, actionable pre-flight steps:
### TOOLS.md Changes:
- Added numbered step-by-step pre-flight protocol
- Added explicit instruction to read ACTIVE.md section for specific tool
- Added parameter verification table with correct vs wrong parameters
- Added emergency recovery rules table (edit fails → use write)
- Added 5 critical reminders (file_path, old_string/new_string, etc.)
### AGENTS.md Changes:
- Added TOOLS.md to startup protocol (Step 3)
- Added numbered steps for "Before Using Tools" section
- Added explicit parameter verification table
- Added emergency recovery section
- Referenced TOOLS.md as primary enforcement location
### Key Enforcement Chain:
```
AGENTS.md (startup) → TOOLS.md (pre-flight steps) → ACTIVE.md (tool-specific syntax)
```
## Knowledge Base Additions — Research Session
**Stored to knowledge_base:** `ai/llm-agents/tool-calling/patterns`
- **Title**: Industry Patterns for LLM Tool Usage Error Handling
- **Content**: Research findings from LangChain, OpenAI, and academic papers on tool calling validation
- **Key findings**:
- LangChain: handle_parsing_errors, retry mechanisms, circuit breakers
- OpenAI: strict=True, Structured Outputs API, Pydantic validation
- Multi-layer defense architecture (prompt → validation → retry → execution)
- Common failure modes: parameter hallucination, type mismatches, missing fields
- Research paper "Butterfly Effects in Toolchains" (2025): errors cascade through tool chains
- **Our unique approach**: Pre-flight documentation checklist vs runtime validation
---
*Session type: Direct 1:1 with Rob*
*Key files created/modified: ACTIVE.md, AGENTS.md, TOOLS.md, MEMORY.md, knowledge_base_schema.md, multiple monitoring scripts*
*Enforcement activated: 2026-02-05 10:34 CST*
*Core files updated: 2026-02-05 10:36 CST*
## Max Configuration Update — 23:47 CST
**Max Setup Differences from Initial Design:**
- **Model**: minimax-m2.1:cloud (switched from GPT-OSS)
- **TTS Skill**: max-tts-custom (not kimi-tts-custom)
- **Filename format**: Max-YYYYMMDD-HHMMSS.ogg
- **Voice**: af_bella @ Kokoro 10.0.0.228:8880
- **Shared Qdrant**: Both Kimi and Max use same Qdrant @ 10.0.0.40:6333
- Collections: openclaw_memories, knowledge_base
- **TOOLS.md**: Max updated to match comprehensive format with detailed tool examples, search priorities, Qdrant scripts
**Kimi Sync Options:**
- Stay on kimi-k2.5:cloud OR switch to minimax-m2.1:cloud
- IDENTITY.md model reference already accurate for kimi-k2.5
## Evening Session — 19:55-22:45 CST
### Smart Search Fixed
- Changed default `--min-kb-score` from 0.7 to 0.5
- Removed server-side `score_threshold` (too aggressive)
- Now correctly finds KB matches (test: 5 results for "telegram dmPolicy")
- Client-side filtering shows all results then filters
### User Preferences Reinforced
- **Concise chats only** — less context, shorter replies
- **Plain text in Telegram** — no markdown formatting, no bullet lists with symbols
- **One step at a time** — wait for response before proceeding
### OpenClaw News Search
Searched web for today's OpenClaw articles. Key findings:
- Security: CVE-2026-25253 RCE bug patched in v2026.1.29
- China issued security warning about improper deployment risks
- 341 malicious ClawHub skills found stealing data
- Trend: Viral adoption alongside security crisis
### GUI Installation Started on Deb
- Purpose: Enable Chrome extension for OpenClaw browser control
- Packages: XFCE4 desktop, Chromium browser, LightDM
- Access: Proxmox console (no VNC needed)
- Status: Complete — 267 packages installed
- Next: Configure display manager, launch desktop, install OpenClaw extension
### OpenClaw Chrome Extension Installation Method
**Discovery**: Extension is NOT downloaded from Chrome Web Store
**Method**: Installed via OpenClaw CLI command
**Steps**:
1. Run `openclaw browser extension install` (installs to ~/.openclaw/browser-extension/)
2. Open Chromium → chrome://extensions/
3. Enable "Developer mode" (toggle top right)
4. Click "Load unpacked"
5. Select the extension path shown after install
6. Click OpenClaw toolbar button to attach to tab
**Alternative**: Clone from GitHub and load browser-extension/ folder directly
+78
View File
@@ -0,0 +1,78 @@
# 2026-02-06 — Daily Memory Log
## Operational Rules Updated
### Notification Rules (from Rob)
- Always use Telegram text only unless requested otherwise
- Only send notifications between 7am-10pm CST
- All timestamps and time usage must be US CST (including Redis)
- If notification needed outside hours, queue as heartbeat task to send at next allowed time
- Stored in Qdrant: IDs 83a98a6e-058f-4c2f-91f4-001d5a18acba, 8729ba36-93a1-4cc2-90b0-00bd22bf19b1
- Updated HEARTBEAT.md with Task #3: Send Delayed Notifications
## Research Completed
### Ollama Pricing: Max vs Pro Plans
**Source:** https://ollama.com/pricing
| Plan | Price | Key Features |
|------|-------|--------------|
| Free | $0 | Local models only, unlimited public models |
| Pro | $20/mo | Multiple cloud models, more usage, 3 private models, 3 collaborators |
| Max | $100/mo | 5+ cloud models, 5x usage vs Pro, 5 private models, 5 collaborators |
**Key Differences:**
- Concurrency: Pro = multiple, Max = 5+ models
- Cloud usage: Max = 5x Pro allowance
- Private models: Pro = 3, Max = 5
- Collaborators per model: Pro = 3, Max = 5
Stored in KB (Ollama/Pricing domain).
## New Project Ideas
### 3rd OpenClaw LXC
- Rob wants to setup a 3rd OpenClaw LXC
- Clone of Max's setup
- Will run local GPT
- Status: Idea phase, awaiting planning/implementation
## Agent Collaboration
- Sent notification rules to Max via agent-messages stream
- Max informed of all operational updates
### Full Search Definition (from Rob)
- When Rob says "full search": use ALL tools available, find quality results
- Combine SearXNG, KB search, web crawling, and any other resources
- Do not limit to one method—comprehensive, high-quality information
- Stored in Qdrant: ID bb4a465a-3c6e-48a8-d8c-52da5b1fdf48
### Shorthand Terms
- **msgs** = Redis messages (agent-messages stream at 10.0.0.36:6379)
- Shortcut for checking/retrieving agent messages between Kimi and Max
- Stored in Qdrant: ID e5e93700-b04b-4db4-9c4b-d6b94166be7f
- **messages** = Telegram direct chat (conversational)
- **notification** = Telegram alerts/updates (one-way notifications)
- Stored in Qdrant: ID e88ec7ea-9d77-45c3-8057-cb7a54077060
### Rob's Personality & Style
- Comical and funny most of the time
- Humor is logical/structured (not random/absurd)
- Has fun with the process
- Applies to content creation and general approach
- Stored in Qdrant: ID b58defd6-e8fc-4420-b75c-aefd4720e70d
### YouTube SEO - Tags Format
- Target: ~490 characters of comma-separated tags
- Include: primary keywords, secondary keywords, long-tail terms
- Mix: broad terms (Homelab) + specific terms (Proxmox LXC)
- Example stored in Qdrant: ID 8aa534f3-6e3f-49d9-ae5f-803ff9e80121
### YouTube SEO - Research Rule
- **CRITICAL:** Pull latest 48 hours of search data/trends when composing SEO elements
- Current data > general keywords for best search results
- Stored in Qdrant: ID bbe76456-01b5-48b5-9c0b-dd8c06680e82
---
*Stored for long-term memory retention*
+72
View File
@@ -0,0 +1,72 @@
# 2026-02-07 — Daily Memory Log
## Agent System Updates
### Jarvis (Local Agent) Setup
- Jarvis deployed as local LLM clone of Max
- 64k context window (sufficient for most tasks)
- Identity: "jarvis" in agent-messages stream
- Runs on CPU (no GPU)
- Requires detailed step-by-step instructions
- One command per step with acknowledgements required
- Conversational communication style expected
### Multi-Agent Protocols Established
- SSH Host Change Protocol: Any agent modifying deb/deb2 must notify others via agent-messages
- Jarvis Task Protocol: All steps provided upfront, execute one at a time with ACKs
- Software Inventory Protocol: Check installed list before recommending
- Agent messaging via Redis stream at 10.0.0.36:6379
### SOUL.md Updates (All Agents)
- Core Truths: "Know the roster", "Follow Instructions Precisely"
- Communication Rules: Voice/text protocols, no filler words
- Infrastructure Philosophy: Privacy > convenience, Local > cloud, Free > paid
- Task Handling: Acknowledge receipt, report progress, confirm completion
## Infrastructure Changes
### SSH Hosts
- **deb** (10.0.0.38): OpenClaw removed, now available for other uses
- **deb2** (10.0.0.39): New host added, same credentials (n8n/passw0rd)
### Software Inventory (Never Recommend These)
- n8n, ollama, openclaw, openwebui, anythingllm
- searxng, flowise
- plex, radarr, sonarr, sabnzbd
- comfyui
## Active Tasks
### Jarvis KB Documentation Task
- 13 software packages to document:
1. n8n, 2. ollama, 3. openwebui, 4. anythingllm, 5. searxng
6. flowise, 7. plex, 8. radarr, 9. sonarr, 10. sabnzbd
11. comfyui, 12. openclaw (GitHub), 13. openclaw (Docs)
- Status: Task assigned, awaiting Step 1 completion report
- Method: Use batch_crawl.py or scrape_to_kb.py
- Store with domain="Software", path="<name>/Docs"
### Jarvis Tool Verification
- Checking for: Redis scripts, Python client, Qdrant memory scripts
- Whisper STT, TTS, basic tools (curl, ssh)
- Status: Checklist sent, awaiting response
### Jarvis Model Info Request
- Requested: Model name, hardware specs, 64k context assessment
- Status: Partial response received (truncated), may need follow-up
## Coordination Notes
- All agents must ACK protocol messages
- Heartbeat checks every 30 minutes
- Agent-messages stream monitored for new messages
- Delayed notifications queue for outside 7am-10pm window
- All timestamps use US CST
## Memory Storage
- 19 new memories stored in Qdrant today
- Includes protocols, inventory, Jarvis requirements, infrastructure updates
- All tagged for semantic search
---
*Stored for long-term memory retention*
+53
View File
@@ -0,0 +1,53 @@
# 2026-02-08 — Daily Memory Log
## Session Start
- **Date:** 2026-02-08
- **Agent:** Kimi
## Bug Fixes & Improvements
### 1. Created Missing `agent_check.py` Script
- **Location:** `/skills/qdrant-memory/scripts/agent_check.py`
- **Purpose:** Check agent messages from Redis stream
- **Features:**
- `--list N` — List last N messages
- `--check` — Check for new messages since last check
- `--last-minutes M` — Check messages from last M minutes
- `--mark-read` — Update last check timestamp
- **Status:** ✅ Working — tested and functional
### 2. Created `create_daily_memory.py` Script
- **Location:** `/skills/qdrant-memory/scripts/create_daily_memory.py`
- **Purpose:** Create daily memory log files automatically
- **Status:** ✅ Working — created 2026-02-08.md
### 3. Fixed `scrape_to_kb.py` Usage
- **Issue:** Used `--domain`, `--path`, `--timeout` flags (wrong syntax)
- **Fix:** Used positional arguments: `url domain path`
- **Result:** Successfully scraped all 13 software docs
### 4. SABnzbd Connection Fallback
- **Issue:** sabnzbd.org/wiki/ returned connection refused
- **Fix:** Used GitHub repo (github.com/sabnzbd/sabnzbd) as fallback
- **Result:** ✅ 4 chunks stored from GitHub README
### 5. Embedded Session Tool Issues (Documented)
- **Issue:** Embedded sessions using `path` instead of `file_path` for `read` tool
- **Note:** This is in OpenClaw gateway/embedded session code — requires upstream fix
- **Workaround:** Always use `file_path` in workspace scripts
## KB Documentation Task Completed
All 13 software packages documented in knowledge_base (64 total chunks):
- n8n (9), ollama (1), openwebui (7), anythingllm (2)
- searxng (3), flowise (2), plex (13), radarr (1)
- sonarr (1), sabnzbd (4), comfyui (2)
- openclaw GitHub (16), openclaw Docs (3)
## Activities
*(Log activities, decisions, and important context here)*
## Notes
---
*Stored for long-term memory retention*
+42
View File
@@ -0,0 +1,42 @@
# 2026-02-09 — Daily Log
## System Fixes & Setup
### 1. Fixed pytz Missing Dependency
- **Issue:** Heartbeat cron jobs failing with `ModuleNotFoundError: No module named 'pytz'`
- **Fix:** `pip install pytz`
- **Result:** All heartbeat checks now working (agent messages, timestamp logging, delayed notifications)
### 2. Created Log Monitor Skill
- **Location:** `/root/.openclaw/workspace/skills/log-monitor/`
- **Purpose:** Daily automated log scanning and error repair
- **Schedule:** 2:00 AM CST daily via system crontab
- **Features:**
- Scans systemd journal, cron logs, OpenClaw session logs
- Auto-fixes: missing Python modules, permission issues, service restarts
- Alerts on: disk full, services down, unknown errors
- Comprehensive noise filtering (NVIDIA, PAM, rsyslog container errors)
- Self-filtering (excludes its own logs, my thinking blocks, tool errors)
- Service health check: Redis via Python (redis-cli not in container)
- **Report:** `/tmp/log_monitor_report.txt`
### 3. Enabled Parallel Tool Calls
- **Configuration:** Ollama `parallel = 8`
- **Usage:** All independent tool calls now batched and executed simultaneously
- **Tested:** 8 parallel service health checks (Redis, Qdrant, Ollama, SearXNG, Kokoro TTS, etc.)
- **Previous:** Sequential execution (one at a time)
### 4. Redis Detection Fix
- **Issue:** `redis-cli` not available in container → false "redis-down" alerts
- **Fix:** Use Python `redis` module for health checks
- **Status:** Redis at 10.0.0.36:6379 confirmed working
## Files Modified/Created
- `/root/.openclaw/workspace/skills/log-monitor/scripts/log_monitor.py` (new)
- `/root/.openclaw/workspace/skills/log-monitor/SKILL.md` (new)
- System crontab: Added daily log monitor job
## Notes
- Container has no GPU → NVIDIA module errors are normal (filtered)
- rsyslog kernel log access denied in container (filtered)
- All container-specific "errors" are now excluded from reports
+167
View File
@@ -0,0 +1,167 @@
# 2026-02-10 — Daily Memory Log
## Qdrant Memory System — Manual Mode
**Major change:** Qdrant memory now MANUAL ONLY.
Two distinct systems established:
- **"remember this" or "note"** → File-based (daily logs + MEMORY.md) — automatic, original design
- **"q remember", "q recall", "q save", "q update"** → Qdrant `kimi_memories` — manual, only when "q" prefix used
**Commands:**
- "q remember" = store one item to Qdrant
- "q recall" = search Qdrant
- "q save" = store specific item
- "q update" = bulk sync all file memories to Qdrant without duplicates
## Redis Messaging — Manual Mode
**Change:** Redis agent messaging now MANUAL ONLY.
- No automatic heartbeat checks for Max's messages
- No auto-notification queue processing
- Only manual when explicitly requested: "check messages" or "send to Max"
## New Qdrant Collection: kimi_memories
**Created:** `kimi_memories` collection at 10.0.0.40:6333
- Vector size: 1024 (snowflake-arctic-embed2)
- Distance: Cosine
- Model: snowflake-arctic-embed2 pulled to 10.0.0.10 (GPU)
- Purpose: Manual memory backup when requested
## Critical Lesson: Immediate Error Reporting
**Rule established:** When hitting a blocking error during an active task, report IMMEDIATELY — don't wait for user to ask.
**What I did wrong:**
- Said "let me know when it's complete" for "q save ALL memories"
- Discovered Qdrant was unreachable (host down)
- Stayed silent instead of immediately reporting
- User had to ask for status to discover I was blocked
**Correct behavior:**
- Hit blocking error → immediately report: "Stopped — [reason]. Cannot proceed."
- Never imply progress is happening when it's not
- Applies to: service outages, permission errors, resource exhaustion
## Memory Backup Success
**Completed:** "q save ALL memories" — 39 comprehensive memories successfully backed up to `kimi_memories` collection.
**Contents stored:**
- Identity & personality
- Communication rules
- Tool usage rules
- Infrastructure details
- YouTube SEO rules
- Setup milestones
- Boundaries & helpfulness principles
**Collection status:**
- Name: `kimi_memories`
- Location: 10.0.0.40:6333
- Vectors: 39 points
- Model: snowflake-arctic-embed2 (1024 dims)
## New Qdrant Collection: kimi_kb
**Created:** `kimi_kb` collection at 10.0.0.40:6333
- Vector size: 1024 (snowflake-arctic-embed2)
- Distance: Cosine
- Purpose: Knowledge base storage (web search, documents, data)
- Mode: Manual only — no automatic storage
**Scripts:**
- `kb_store.py` — Store web/docs to KB with metadata
- `kb_search.py` — Search knowledge base with domain filtering
**Usage:**
```bash
# Store to KB
python3 kb_store.py "Content" --title "X" --domain "Docker" --tags "container"
# Search KB
python3 kb_search.py "docker volumes" --domain "Docker"
```
**Test:** Successfully stored and retrieved Docker container info.
## Unified Search: Perplexity + SearXNG
**Architecture:** Perplexity primary, SearXNG fallback
**Primary:** Perplexity API (AI-curated, ~$0.005/query)
**Fallback:** SearXNG local (privacy-focused, free)
**Commands:**
```bash
search "your query" # Perplexity → SearXNG fallback
search p "your query" # Perplexity only
search local "your query" # SearXNG only
search --citations "query" # Include source links
search --model sonar-pro "query" # Pro model for complex tasks
```
**Models:**
- `sonar` — Quick answers (default)
- `sonar-pro` — Complex queries, coding
- `sonar-reasoning` — Step-by-step reasoning
- `sonar-deep-research` — Comprehensive research
**Test:** Successfully searched "top 5 models used with openclaw" — returned Claude Opus 4.5, Sonnet 4, Gemini 3 Pro, Kimi K 2.5, GPT-4o with citations.
## Perplexity API Setup
**Configured:** Perplexity API skill created at `/skills/perplexity/`
**Details:**
- Key: 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
- Endpoint: https://api.perplexity.ai/chat/completions
- Models: sonar, sonar-pro, sonar-reasoning, sonar-deep-research
- Format: OpenAI-compatible, ~$0.005 per query
**Usage:** See "Unified Search" section above for primary usage. Direct API access:
```bash
python3 skills/perplexity/scripts/query.py "Your question" --citations
```
**Note:** Perplexity sends queries to cloud servers. Use `search local "query"` for privacy-sensitive searches.
## Sub-Agent Setup (Option B)
**Configured:** Sub-agent defaults pointing to .10 Ollama
**Config changes:**
- `agents.defaults.subagents.model`: `ollama-remote/qwen3:30b-a3b-instruct-2507-q8_0`
- `models.providers.ollama-remote`: Points to `http://10.0.0.10:11434/v1`
- `tools.subagents.tools.deny`: write, edit, apply_patch, browser, cron (safer defaults)
**What it does:**
- Spawns background tasks on qwen3:30b at .10
- Inherits main agent context but runs inference remotely
- Auto-announces results back to requester chat
- Max 2 concurrent sub-agents
**Usage:**
```
sessions_spawn({
task: "Analyze these files...",
label: "Background analysis"
})
```
**Status:** Configured and ready
## Git Repository Initialized
**Setup:** Git repo initialized for workspace version control
**Commits:**
- `d1357c5` — Initial commit: 77 files, 10,822 insertions (workspace setup)
- `98d14be` — MEMORY.md updated with sub-agent and git config
**Status:** Clean working tree, tracking active
---
*Stored for long-term memory retention*
+168
View File
@@ -0,0 +1,168 @@
# Website Details - SpeedyFoxAI
**Domain:** speedyfoxai.com
**Hosted on:** deb2 (10.0.0.39)
**Web Root:** /root/html/ (Nginx serves from here, NOT /var/www/html/)
**Created:** February 13, 2026
**Created by:** Kimi (OpenClaw + Ollama) via SSH
## Critical Discovery
Nginx config (`/etc/nginx/sites-enabled/default`) sets `root /root/html;`
This means `/var/www/html/` is NOT the live document root - `/root/html/` is.
## Visitor Counter System
### Current Status
- **Count:** 288 (restored from Feb 13 backup)
- **Location:** `/root/html/count.txt`
- **Persistent storage:** `/root/html/.counter_total`
- **Script:** `/root/html/update_count_persistent.sh`
### Why It Reset
Old script read from nginx access.log which gets rotated by logrotate. When logs rotate, count drops to near zero. Lost ~350 visits (was ~400+, dropped to 46).
### Fix Applied
New persistent counter that:
1. Stores total in `.counter_total` file
2. Tracks last log line counted in `.counter_last_line`
3. Only adds NEW visits since last run
4. Handles log rotation gracefully
## Site Versions
### Current Live Version
- **File:** `/root/html/index.html` (served by nginx)
- **Source:** `/var/www/html/index.html` (edit here, copy to /root/html/)
- **Style:** Simple HTML/CSS (Rob's Tech Lab theme)
- **Features:** Visitor counter, 3 embedded YouTube videos
### Backup Version (Full Featured)
- **Location:** `/root/html_backup/20260213_155707/index.html`
- **Style:** Tailwind CSS, full SpeedyFoxAI branding
- **Features:** Dark mode, FAQ section, navigation, full counter
## YouTube Channel
**Name:** SpeedyFoxAI
**URL:** https://www.youtube.com/@SpeedyFoxAi
**Stats:** 5K+ subscribers, 51+ videos
### Embedded Videos
1. DIY AI Assistant Setup (kz-4l5roK6k)
2. Self-Hosted Tools Deep Dive (9IYNGK44EyM)
3. OpenClaw + Ollama Workflow (8Fncc5Sg2yg)
## File Structure
```
/root/html/ # LIVE site (nginx root)
├── index.html # Main page
├── count.txt # Visitor count (288)
├── .counter_total # Persistent count storage
├── .counter_last_line # Log line tracking
├── update_count_persistent.sh # Counter script
├── websitememory.md # Documentation
├── downloads.html
├── fox720.jpg
└── favicon.png
/root/html_backup/ # Backups with timestamps
├── 20260214_071243/ # Pre-counter-script backup
├── 20260214_070713/ # Before counter fix
├── 20260214_070536/ # Full backup
└── 20260213_155707/ # Full version with counter
/var/www/html/ # Edit source (copy to /root/html/)
└── index.html
```
## Technical Details
### Counter JavaScript
```javascript
fetch("/count.txt?t=" + Date.now())
.then(r => r.text())
.then(n => {
document.getElementById("visit-count").textContent =
parseInt(n || 0).toString().padStart(6, "0");
});
```
### Counter Display
- Location: Footer
- Format: "Visitors: 000288"
- Style: 10px font, opacity 0.5
### Backup Strategy
```bash
DT=$(date +%Y%m%d_%H%M%S)
mkdir -p /root/html_backup/${DT}
cp -r /root/html/* /root/html_backup/${DT}/
cp /var/www/html/* /root/html_backup/${DT}/
```
## SEO & Content
- **Title:** Rob's Tech Lab | Local AI & Self-Hosted Tools
- **Meta:** None (simple version)
- **Schema:** None (simple version)
- **Full version has:** Schema.org FAQPage, structured data
## Social Links
- YouTube: @SpeedyFoxAi
- Discord: mdkrush
- GitHub: mdkrush
- Twitter: mdkrush
## Creator
**Name:** Rob
**Brand:** SpeedyFoxAI
**Focus:** Self-hosting, local AI, automation tools
**Personality:** Comical/structured humor
## Status
- **Counter:** Working (shows 000288)
- **HTML:** Valid structure, no misaligned code
- **Backups:** Multiple timestamps available
- **Documentation:** /root/html/websitememory.md
Stored: February 14, 2026
## Relationship to YouTube
The SpeedyFoxAI.com website complements the YouTube channel @SpeedyFoxAi. It serves as a hub for video content, resources, and contact info, with embedded videos linking directly to the channel. Design and branding are consistent across both platforms.
---
## UPDATE - Feb 14, 07:21
### Counter Reset Issue - ROOT CAUSE FOUND
**Problem:** Count kept resetting to current nginx log lines (86, 89, etc.)
**Root Cause:** Old script `/root/html/update_count.sh` still existed and was running:
```bash
#!/bin/bash
COUNT=$(wc -l < /var/log/nginx/access.log 2>/dev/null || echo 0)
echo "$COUNT" > /root/html/count.txt
```
This script was periodically overwriting count.txt with nginx log line count, overriding the persistent counter.
**Fix Applied:**
- Removed `/root/html/update_count.sh`
- Restored count.txt to 288
- Persistent counter now working correctly
**Lesson:** Check for competing scripts before implementing fixes.
---
## Rule Added - Feb 14, 2026
**Always validate after changes.** No exceptions.
- Test functionality
- Verify file integrity
- Check permissions
- Confirm expected output
Applied retroactively to todays counter fix.
+1
View File
@@ -0,0 +1 @@
2026-02-10T11:58:48-06:00
Binary file not shown.
-20
View File
@@ -1,20 +0,0 @@
# Python dependencies for Jarvis-Like Memory System
# Install with: pip3 install -r requirements.txt
# Redis client for buffer layer
redis>=5.0.0
# Qdrant client for vector database
qdrant-client>=1.7.0
# HTTP requests for API calls
requests>=2.31.0
urllib3>=2.0.0
# Date/time handling
python-dateutil>=2.8.0
# For Google integration (optional)
# google-auth-oauthlib>=1.0.0
# google-auth-httplib2>=0.1.0
# google-api-python-client>=2.100.0
+72
View File
@@ -0,0 +1,72 @@
# Amazon Parts List: DEWALT DCW600B Trim Work Setup
Router: DEWALT 20V Max XR Cordless Router (DCW600B)
Existing: DNP618 Edge Guide, BAIDETS 35Pcs 1/4" Router Bit Set
---
## DEWALT Official Accessories
| Item | Amazon Link | Why You Need It |
|------|-------------|-----------------|
| DNP612 Plunge Base | <https://www.amazon.com/dp/B004AJ95DA> | Mortises, inlays, plunge cuts — works with DCW600B |
| DNP615 Dust Adapter | <https://www.amazon.com/dp/B004AJEUKS> | Connects to shop vac |
| DNP613 Round Sub Base | Search "DNP613" on Amazon | Larger base for stability |
---
## Router Bits (1/4" Shank)
| Item | Amazon Link | Use For |
|------|-------------|---------|
| Roundover Bit Set (4-pack) | <https://www.amazon.com/dp/B0CX8VFK53> | Edge rounding — 1/8", 1/4", 3/16", 5/16" radii |
| Cove Box Bit Set (8-pack) | <https://www.amazon.com/dp/B0G29J8892> | Concave curves, decorative grooves |
| CSOOM 15-Pc Starter Set | <https://www.amazon.com/dp/B0F4MN9SS4> | Budget set with straight, cove, roundover, chamfer |
| Yonico 3-Piece Molding Set | Search "Yonico molding router bit set 1/4 shank" | Classic architectural profiles |
---
## Router Table & Hold-Downs
| Item | Amazon Link | Purpose |
|------|-------------|---------|
| Rockler Trim Router Table | <https://www.amazon.com/dp/B005E70EUU> | Compact table for trim routers |
| POWERTEC Trim Router Table | <https://www.amazon.com/dp/B085KW65F4> | Budget alternative |
| POWERTEC Featherboards (2-pack) | <https://www.amazon.com/dp/B09BCKVP9G> | Hold trim tight — prevents chatter |
| JessEm Clear-Cut Stock Guides | Search "JessEm 04215" | Premium roller hold-downs |
| Mini Hedgehog Featherboard | <https://www.amazon.com/dp/B0C2XFLYFJ> | Single-knob adjustment |
---
## Jigs for Specialty Cuts
| Item | Amazon Link | Purpose |
|------|-------------|---------|
| Rockler Circle Cutting Jig | <https://www.amazon.com/dp/B00BRHQ2FW> | Cuts 6"36" circles |
| Woodhaven Circle Jig | <https://www.amazon.com/dp/B09MPV3QVC> | Circles up to 106" — fits DCW600B |
| Rockler Rail Coping Sled | <https://www.amazon.com/dp/B010N11LSU> | Essential for coping crown/baseboard |
| POWERTEC Coping Sled | <https://www.amazon.com/dp/B0CHJGVRHB> | Budget alternative |
| POWERTEC Guide Rail Adapter | <https://www.amazon.com/dp/B0G91C2NLN> | Use Festool/Makita tracks |
---
## Base Plates & Guides
| Item | Amazon Link | Purpose |
|------|-------------|---------|
| POWERTEC Dual Grip Base Plate | <https://www.amazon.com/dp/B0G91C2NLN> | 6"×11" acrylic — more stability |
| TrimFit Pro Base Plate | Search "TrimFit Pro DCW600B" | Aftermarket with handles |
---
## Recommended Starter Bundle
1. DNP612 Plunge Base (~$85)
2. Rockler Trim Router Table (~$120)
3. Roundover + Cove bit sets (~$25 each)
4. POWERTEC Featherboards (~$30)
5. Rockler Rail Coping Sled (~$35)
Total: ~$330 for a complete trim setup.
Created: 2026-02-09
+24
View File
@@ -0,0 +1,24 @@
# deep-search Skill
Deep web search with social media support using SearXNG + Crawl4AI.
## Usage
```bash
python3 deep_search.py 'your search query'
python3 deep_search.py --social 'your search query'
python3 deep_search.py --social --max-urls 8 'query'
```
## Features
- Web search via local SearXNG (http://10.0.0.8:8888)
- Social media search: x.com, facebook, linkedin, instagram, reddit, youtube, threads, mastodon, bluesky
- Content extraction via Crawl4AI
- Local embedding with nomic-embed-text via Ollama
## Requirements
- SearXNG running at http://10.0.0.8:8888
- crawl4ai installed (`pip install crawl4ai`)
- Ollama with nomic-embed-text model
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""
Deep Search with Social Media Support
Uses SearXNG + Crawl4AI for comprehensive web and social media search.
"""
import argparse
import json
import sys
import urllib.parse
import urllib.request
from typing import List, Dict, Optional
import subprocess
import os
# Configuration
SEARXNG_URL = "http://10.0.0.8:8888"
OLLAMA_URL = "http://10.0.0.10:11434"
EMBED_MODEL = "nomic-embed-text"
# Social media platforms
SOCIAL_PLATFORMS = {
'x.com', 'twitter.com',
'facebook.com', 'fb.com',
'linkedin.com',
'instagram.com',
'reddit.com',
'youtube.com', 'youtu.be',
'threads.net',
'mastodon.social', 'mastodon',
'bsky.app', 'bluesky'
}
def search_searxng(query: str, max_results: int = 10, category: str = 'general') -> List[Dict]:
"""Search using local SearXNG instance."""
params = {
'q': query,
'format': 'json',
'pageno': 1,
'safesearch': 0,
'language': 'en',
'category': category
}
url = f"{SEARXNG_URL}/search?{urllib.parse.urlencode(params)}"
try:
req = urllib.request.Request(url, headers={'Accept': 'application/json'})
with urllib.request.urlopen(req, timeout=30) as response:
data = json.loads(response.read().decode('utf-8'))
return data.get('results', [])[:max_results]
except Exception as e:
print(f"Search error: {e}", file=sys.stderr)
return []
def extract_content(url: str) -> Optional[str]:
"""Extract content from URL using Crawl4AI if available."""
try:
# Try using crawl4ai
import crawl4ai
from crawl4ai import AsyncWebCrawler
import asyncio
async def crawl():
async with AsyncWebCrawler() as crawler:
result = await crawler.arun(url=url)
return result.markdown if result else None
return asyncio.run(crawl())
except ImportError:
# Fallback to simple fetch
try:
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.0'
})
with urllib.request.urlopen(req, timeout=15) as response:
return response.read().decode('utf-8', errors='ignore')[:5000]
except Exception as e:
return f"Error fetching content: {e}"
def is_social_media(url: str) -> bool:
"""Check if URL is from a social media platform."""
url_lower = url.lower()
for platform in SOCIAL_PLATFORMS:
if platform in url_lower:
return True
return False
def generate_embedding(text: str) -> Optional[List[float]]:
"""Generate embedding using local Ollama."""
try:
import requests
response = requests.post(
f"{OLLAMA_URL}/api/embeddings",
json={"model": EMBED_MODEL, "prompt": text[:8192]},
timeout=60
)
if response.status_code == 200:
return response.json().get('embedding')
return None
except Exception as e:
print(f"Embedding error: {e}", file=sys.stderr)
return None
def deep_search(query: str, max_urls: int = 5, social_only: bool = False) -> Dict:
"""Perform deep search with content extraction."""
results = {
'query': query,
'urls_searched': [],
'social_results': [],
'web_results': [],
'errors': []
}
# Search
search_results = search_searxng(query, max_results=max_urls * 2)
for result in search_results[:max_urls]:
url = result.get('url', '')
title = result.get('title', '')
snippet = result.get('content', '')
if not url:
continue
is_social = is_social_media(url)
if social_only and not is_social:
continue
# Extract full content
full_content = extract_content(url)
entry = {
'url': url,
'title': title,
'snippet': snippet,
'full_content': full_content[:3000] if full_content else None,
'is_social': is_social
}
if is_social:
results['social_results'].append(entry)
else:
results['web_results'].append(entry)
results['urls_searched'].append(url)
return results
def main():
parser = argparse.ArgumentParser(description='Deep Search with Social Media Support')
parser.add_argument('query', help='Search query')
parser.add_argument('--social', action='store_true', help='Include social media platforms')
parser.add_argument('--social-only', action='store_true', help='Only search social media')
parser.add_argument('--max-urls', type=int, default=8, help='Maximum URLs to fetch (default: 8)')
parser.add_argument('--json', action='store_true', help='Output as JSON')
args = parser.parse_args()
print(f"🔍 Deep Search: {args.query}")
print(f" Social media: {'only' if args.social_only else ('yes' if args.social else 'no')}")
print(f" Max URLs: {args.max_urls}")
print("-" * 60)
results = deep_search(args.query, max_urls=args.max_urls, social_only=args.social_only)
if args.json:
print(json.dumps(results, indent=2))
else:
# Print formatted results
if results['social_results']:
print("\n📱 SOCIAL MEDIA RESULTS:")
for r in results['social_results']:
print(f"\n 🌐 {r['url']}")
print(f" Title: {r['title']}")
print(f" Snippet: {r['snippet'][:200]}...")
if results['web_results']:
print("\n🌐 WEB RESULTS:")
for r in results['web_results']:
print(f"\n 🌐 {r['url']}")
print(f" Title: {r['title']}")
print(f" Snippet: {r['snippet'][:200]}...")
print(f"\n{'='*60}")
print(f"Total URLs searched: {len(results['urls_searched'])}")
print(f"Social results: {len(results['social_results'])}")
print(f"Web results: {len(results['web_results'])}")
return 0
if __name__ == '__main__':
sys.exit(main())
+104
View File
@@ -0,0 +1,104 @@
---
name: kimi-tts-custom
description: Custom TTS handler for Kimi that generates voice messages with custom filenames (Kimi-XXX.ogg) and optionally suppresses text output. Use when user wants voice-only responses with branded filenames instead of default OpenClaw TTS behavior.
---
# Kimi TTS Custom
## Overview
Custom TTS wrapper for local Kokoro that:
- Generates voice with custom filenames (Kimi-XXX.ogg)
- Can send voice-only (no text transcript)
- Uses local Kokoro TTS at 10.0.0.228:8880
## When to Use
- User wants voice responses with "Kimi-" prefixed filenames
- User wants voice-only mode (no text displayed)
- Default TTS behavior needs customization
## Voice-Only Mode
**⚠️ CRITICAL: Generation ≠ Delivery**
Simply generating a voice file does NOT send it. You must use proper delivery method:
### Correct Way: Use voice_reply.py
```bash
python3 /root/.openclaw/workspace/skills/kimi-tts-custom/scripts/voice_reply.py "1544075739" "Your message here"
```
This script:
1. Generates voice file with Kimi-XXX.ogg filename
2. Sends via Telegram API immediately
3. Cleans up temp file
### Wrong Way: Text Reference
❌ Do NOT do this:
```
[Voice message attached: Kimi-20260205-185016.ogg]
```
This does not attach the actual audio file — user receives no voice message.
### Alternative: Manual Send (if needed)
If you already generated the file:
```bash
# Use OpenClaw CLI
openclaw message send --channel telegram --target 1544075739 --media /path/to/Kimi-XXX.ogg
```
## Configuration
Set in `messages.tts.custom`:
```json
{
"messages": {
"tts": {
"custom": {
"enabled": true,
"voiceOnly": true,
"filenamePrefix": "Kimi",
"kokoroUrl": "http://10.0.0.228:8880/v1/audio/speech",
"voice": "af_bella"
}
}
}
}
```
## Scripts
### scripts/generate_voice.py
Generates voice file with custom filename and returns path for sending.
**⚠️ Note**: This only creates the file. Does NOT send to Telegram.
Usage:
```bash
python3 generate_voice.py "Text to speak" [--voice af_bella] [--output-dir /tmp]
```
Returns: JSON with `filepath`, `filename`, `duration`
### scripts/voice_reply.py (RECOMMENDED)
Combined script: generates voice + sends via Telegram in one command.
**This is the correct way to send voice replies.**
Usage:
```bash
python3 voice_reply.py "1544075739" "Your message here" [--voice af_bella]
```
This generates the voice file and sends it immediately (voice-only, no text).
## Key Rule
| Task | Use |
|------|-----|
| Generate voice file only | `generate_voice.py` |
| Send voice reply to user | `voice_reply.py` |
| Text reference to file | ❌ Does NOT work |
**Remember**: Generation and delivery are separate steps. Use `voice_reply.py` for complete voice reply workflow.
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""
Generate voice with custom Kimi-XXX filename using local Kokoro TTS
Usage: generate_voice.py "Text to speak" [--voice af_bella] [--output-dir /tmp] [--speed 1.3]
"""
import argparse
import json
import os
import sys
import tempfile
import urllib.request
from datetime import datetime
def generate_voice(text, voice="af_bella", output_dir="/tmp", model="tts-1", speed=1.3):
"""Generate voice file with Kimi-XXX filename"""
# Generate unique filename: Kimi-YYYYMMDD-HHMMSS.ogg
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
filename = f"Kimi-{timestamp}.ogg"
filepath = os.path.join(output_dir, filename)
# Call local Kokoro TTS
tts_url = "http://10.0.0.228:8880/v1/audio/speech"
data = json.dumps({
"model": model,
"input": text,
"voice": voice,
"speed": speed
}).encode()
req = urllib.request.Request(
tts_url,
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req) as response:
audio_data = response.read()
# Save to file
with open(filepath, "wb") as f:
f.write(audio_data)
# Estimate duration (rough: ~150 chars per minute at normal speed, adjusted for speed)
estimated_duration = max(1, len(text) / 150 * 60 / speed)
result = {
"filepath": filepath,
"filename": filename,
"size_bytes": len(audio_data),
"estimated_duration_seconds": round(estimated_duration, 1),
"voice": voice,
"speed": speed,
"text": text
}
print(json.dumps(result))
return result
except Exception as e:
error_result = {
"error": str(e),
"filepath": None,
"filename": None
}
print(json.dumps(error_result), file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate voice with Kimi-XXX filename")
parser.add_argument("text", help="Text to convert to speech")
parser.add_argument("--voice", default="af_bella",
help="Voice ID (default: af_bella)")
parser.add_argument("--output-dir", default="/tmp",
help="Output directory (default: /tmp)")
parser.add_argument("--model", default="tts-1",
help="TTS model (default: tts-1)")
parser.add_argument("--speed", type=float, default=1.3,
help="Speech speed multiplier (default: 1.3)")
args = parser.parse_args()
generate_voice(args.text, args.voice, args.output_dir, args.model, args.speed)
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""
Generate voice with Kimi-XXX filename and send via Telegram (voice-only, no text)
Usage: voice_reply.py <chat_id> "Text to speak" [--voice af_bella] [--speed 1.3] [--bot-token TOKEN]
"""
import argparse
import json
import os
import sys
import subprocess
import tempfile
import urllib.request
from datetime import datetime
def generate_voice(text, voice="af_bella", output_dir="/tmp", model="tts-1", speed=1.3):
"""Generate voice file with Kimi-XXX filename"""
# Generate unique filename: Kimi-YYYYMMDD-HHMMSS.ogg
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
filename = f"Kimi-{timestamp}.ogg"
filepath = os.path.join(output_dir, filename)
# Call local Kokoro TTS
tts_url = "http://10.0.0.228:8880/v1/audio/speech"
data = json.dumps({
"model": model,
"input": text,
"voice": voice,
"speed": speed
}).encode()
req = urllib.request.Request(
tts_url,
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req) as response:
audio_data = response.read()
with open(filepath, "wb") as f:
f.write(audio_data)
return filepath, filename
except Exception as e:
print(f"Error generating voice: {e}", file=sys.stderr)
sys.exit(1)
def send_voice_telegram(chat_id, audio_path, bot_token=None):
"""Send voice message via Telegram"""
# Get bot token from env or config
if not bot_token:
bot_token = os.environ.get("TELEGRAM_BOT_TOKEN")
if not bot_token:
# Try to get from openclaw config
try:
result = subprocess.run(
["openclaw", "config", "get", "channels.telegram.botToken"],
capture_output=True, text=True
)
bot_token = result.stdout.strip()
except:
pass
if not bot_token:
print("Error: No bot token found. Set TELEGRAM_BOT_TOKEN or provide --bot-token", file=sys.stderr)
sys.exit(1)
# Use openclaw CLI to send
cmd = [
"openclaw", "message", "send",
"--channel", "telegram",
"--target", chat_id,
"--media", audio_path
]
try:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print(f"✅ Voice sent successfully to {chat_id}")
return True
else:
print(f"Error sending voice: {result.stderr}", file=sys.stderr)
return False
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return False
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Generate and send voice-only reply")
parser.add_argument("chat_id", help="Telegram chat ID to send to")
parser.add_argument("text", help="Text to convert to speech")
parser.add_argument("--voice", default="af_bella", help="Voice ID (default: af_bella)")
parser.add_argument("--speed", type=float, default=1.3, help="Speech speed multiplier (default: 1.3)")
parser.add_argument("--bot-token", help="Telegram bot token (or set TELEGRAM_BOT_TOKEN)")
parser.add_argument("--keep-file", action="store_true", help="Don't delete temp file after sending")
args = parser.parse_args()
print(f"Generating voice for: {args.text[:50]}...")
filepath, filename = generate_voice(args.text, args.voice, speed=args.speed)
print(f"Generated: {filename}")
print(f"Sending to {args.chat_id}...")
success = send_voice_telegram(args.chat_id, filepath, args.bot_token)
if success and not args.keep_file:
os.remove(filepath)
print(f"Cleaned up temp file")
elif success:
print(f"Kept file at: {filepath}")
sys.exit(0 if success else 1)
+79
View File
@@ -0,0 +1,79 @@
---
name: local-whisper-stt
description: Local speech-to-text transcription using Faster-Whisper. Use when receiving voice messages in Telegram (or other channels) that need to be transcribed to text. Automatically downloads and transcribes audio files using local CPU-based Whisper models. Supports multiple model sizes (tiny, base, small, medium, large) with automatic language detection.
---
# Local Whisper STT
## Overview
Transcribes voice messages to text using local Faster-Whisper (CPU-based, no GPU required).
## When to Use
- User sends a voice message in Telegram
- Need to transcribe audio to text locally (free, private)
- Any audio transcription task where cloud STT is not desired
## Models Available
| Model | Size | Speed | Accuracy | Use Case |
|-------|------|-------|----------|----------|
| tiny | 39MB | Fastest | Basic | Quick testing, low resources |
| base | 74MB | Fast | Good | Default for most use |
| small | 244MB | Medium | Better | Better accuracy needed |
| medium | 769MB | Slower | Very Good | High accuracy, more RAM |
| large | 1550MB | Slowest | Best | Maximum accuracy |
## Workflow
1. Receive voice message (Telegram provides OGG/Opus)
2. Download audio file to temp location
3. Load Faster-Whisper model (cached after first use)
4. Transcribe audio to text
5. Return transcription to conversation
6. Cleanup temp file
## Usage
### From Telegram Voice Message
When a voice message arrives, the skill:
1. Downloads the voice file from Telegram
2. Transcribes using the configured model
3. Returns text to the agent context
### Manual Transcription
```python
# Transcribe a local audio file
from faster_whisper import WhisperModel
model = WhisperModel("base", device="cpu", compute_type="int8")
segments, info = model.transcribe("/path/to/audio.ogg", beam_size=5)
for segment in segments:
print(segment.text)
```
## Configuration
Default model: `base` (good balance of speed/accuracy on CPU)
To change model, edit the script or set environment variable:
```bash
export WHISPER_MODEL=small
```
## Requirements
- Python 3.8+
- faster-whisper package
- ~100MB-1.5GB disk space (depending on model)
- No GPU required (CPU-only)
## Resources
### scripts/
- `transcribe.py` - Main transcription script
- `telegram_voice_handler.py` - Telegram-specific voice message handler
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
"""
Handle Telegram voice messages - download and transcribe
Usage: telegram_voice_handler.py <bot_token> <file_id> [--model MODEL]
"""
import argparse
import os
import sys
import json
import urllib.request
import tempfile
def download_voice_file(bot_token, file_id, output_path):
"""Download voice file from Telegram"""
# Step 1: Get file path from Telegram
file_info_url = f"https://api.telegram.org/bot{bot_token}/getFile?file_id={file_id}"
try:
with urllib.request.urlopen(file_info_url) as response:
data = json.loads(response.read().decode())
if not data.get("ok"):
print(f"Error getting file info: {data}", file=sys.stderr)
sys.exit(1)
file_path = data["result"]["file_path"]
except Exception as e:
print(f"Error fetching file info: {e}", file=sys.stderr)
sys.exit(1)
# Step 2: Download the actual file
download_url = f"https://api.telegram.org/file/bot{bot_token}/{file_path}"
try:
urllib.request.urlretrieve(download_url, output_path)
return output_path
except Exception as e:
print(f"Error downloading file: {e}", file=sys.stderr)
sys.exit(1)
def transcribe_with_whisper(audio_path, model_size="base"):
"""Transcribe using local Faster-Whisper"""
from faster_whisper import WhisperModel
# Load model (cached after first use)
model = WhisperModel(model_size, device="cpu", compute_type="int8")
# Transcribe
segments, info = model.transcribe(audio_path, beam_size=5)
# Collect text
full_text = []
for segment in segments:
full_text.append(segment.text.strip())
return {
"text": " ".join(full_text),
"language": info.language,
"language_probability": info.language_probability
}
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Download and transcribe Telegram voice message")
parser.add_argument("bot_token", help="Telegram bot token")
parser.add_argument("file_id", help="Telegram voice file_id")
parser.add_argument("--model", default="base",
choices=["tiny", "base", "small", "medium", "large"],
help="Whisper model size (default: base)")
args = parser.parse_args()
# Allow override from environment
model = os.environ.get("WHISPER_MODEL", args.model)
# Create temp file for download
with tempfile.NamedTemporaryFile(suffix=".ogg", delete=False) as tmp:
temp_path = tmp.name
try:
# Download
print(f"Downloading voice file...", file=sys.stderr)
download_voice_file(args.bot_token, args.file_id, temp_path)
# Transcribe
print(f"Transcribing with {model} model...", file=sys.stderr)
result = transcribe_with_whisper(temp_path, model)
# Output result
print(json.dumps(result))
finally:
# Cleanup
if os.path.exists(temp_path):
os.remove(temp_path)
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""
Transcribe audio files using local Faster-Whisper (CPU-only)
Usage: transcribe.py <audio_file> [--model MODEL] [--output-format text|json|srt]
"""
import argparse
import os
import sys
import json
from faster_whisper import WhisperModel
def transcribe(audio_path, model_size="base", output_format="text"):
"""Transcribe audio file to text"""
if not os.path.exists(audio_path):
print(f"Error: File not found: {audio_path}", file=sys.stderr)
sys.exit(1)
# Load model (cached in ~/.cache/huggingface/hub)
print(f"Loading Whisper model: {model_size}", file=sys.stderr)
model = WhisperModel(model_size, device="cpu", compute_type="int8")
# Transcribe
print(f"Transcribing: {audio_path}", file=sys.stderr)
segments, info = model.transcribe(audio_path, beam_size=5)
# Process results
language = info.language
language_prob = info.language_probability
results = []
full_text = []
for segment in segments:
results.append({
"start": segment.start,
"end": segment.end,
"text": segment.text.strip()
})
full_text.append(segment.text.strip())
# Output format
if output_format == "json":
output = {
"language": language,
"language_probability": language_prob,
"segments": results,
"text": " ".join(full_text)
}
print(json.dumps(output, indent=2))
elif output_format == "srt":
for i, segment in enumerate(results, 1):
start = format_timestamp(segment["start"])
end = format_timestamp(segment["end"])
print(f"{i}")
print(f"{start} --> {end}")
print(f"{segment['text']}\n")
else: # text
print(" ".join(full_text))
return " ".join(full_text)
def format_timestamp(seconds):
"""Format seconds to SRT timestamp"""
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
millis = int((seconds % 1) * 1000)
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Transcribe audio using Faster-Whisper")
parser.add_argument("audio_file", help="Path to audio file")
parser.add_argument("--model", default="base",
choices=["tiny", "base", "small", "medium", "large"],
help="Whisper model size (default: base)")
parser.add_argument("--output-format", default="text",
choices=["text", "json", "srt"],
help="Output format (default: text)")
args = parser.parse_args()
# Allow override from environment
model = os.environ.get("WHISPER_MODEL", args.model)
transcribe(args.audio_file, model, args.output_format)
+60
View File
@@ -0,0 +1,60 @@
# Log Monitor Skill
Automatic log scanning and error repair for OpenClaw/agent systems.
## Purpose
Runs daily at 2 AM to:
1. Scan system logs (journald, cron, OpenClaw) for errors
2. Attempt safe auto-fixes for known issues
3. Report unhandled errors needing human attention
## Auto-Fixes Supported
| Error Pattern | Fix Action |
|---------------|------------|
| Missing Python module (`ModuleNotFoundError`) | `pip install <module>` |
| Permission denied on temp files | `chmod 755 <path>` |
| Ollama connection issues | `systemctl restart ollama` |
| Disk full | Alert only (requires manual cleanup) |
| Service down (connection refused) | Alert only (investigate first) |
## Usage
### Manual Run
```bash
cd /root/.openclaw/workspace/skills/log-monitor/scripts
python3 log_monitor.py
```
### View Latest Report
```bash
cat /tmp/log_monitor_report.txt
```
### Cron Schedule
Runs daily at 2:00 AM via `openclaw cron`.
## Adding New Auto-Fixes
Edit `log_monitor.py` and add to `AUTO_FIXES` dictionary:
```python
AUTO_FIXES = {
r"your-regex-pattern-here": {
"fix_cmd": "command-to-run {placeholder}",
"description": "Human-readable description with {placeholder}"
},
}
```
Use `{module}`, `{path}`, `{port}`, `{service}` as capture group placeholders.
Set `"alert": True` for issues that should notify you but not auto-fix.
## Safety
- Only "safe" fixes are automated (package installs, restarts, permissions)
- Critical issues (disk full, service down) alert but don't auto-fix
- All actions are logged to `/tmp/log_monitor_report.txt`
- Cron exits with code 1 if human attention needed (triggers notification)
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env python3
"""
Log Monitor & Auto-Repair Script
Scans system logs for errors and attempts safe auto-fixes.
Runs daily at 2 AM via cron.
"""
import subprocess
import re
import sys
import os
from datetime import datetime, timedelta
# Config
LOG_HOURS = 24 # Check last 24 hours
REPORT_FILE = "/tmp/log_monitor_report.txt"
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# Patterns to exclude (noise, not real errors)
EXCLUDE_PATTERNS = [
r"sabnzbd", # Download manager references (not errors)
r"github\.com/sabnzbd", # GitHub repo references
r"functions\.(read|edit|exec) failed.*Missing required parameter", # My own tool errors
r"log_monitor\.py", # Don't report on myself
r"SyntaxWarning.*invalid escape sequence", # My own script warnings
r'"type":"thinking"', # My internal thinking blocks
r'"thinking":', # More thinking content
r"The user has pasted a log of errors", # My own analysis text
r"Let me respond appropriately", # My response planning
r"functions\.(read|edit|exec) failed", # Tool failures in logs
r"agent/embedded.*read tool called without path", # Embedded session errors
r"rs_\d+", # Reasoning signature IDs
r"encrypted_content", # Encrypted thinking blocks
r"Missing required parameter.*newText", # My edit tool errors
# Filter session log content showing file reads of this script
r"content.*report\.append.*OpenClaw Logs: No errors found", # My own code appearing in logs
r"file_path.*log_monitor\.py", # File operations on this script
# Container-specific harmless errors
r"nvidia", # NVIDIA modules not available in container
r"nvidia-uvm", # NVIDIA UVM module
r"nvidia-persistenced", # NVIDIA persistence daemon
r"Failed to find module 'nvidia", # NVIDIA module load failure
r"Failed to query NVIDIA devices", # No GPU in container
r"rsyslogd.*imklog", # rsyslog kernel log issues (expected in container)
r"imklog.*cannot open kernel log", # Kernel log not available
r"imklog.*failed", # imklog activation failures
r"activation of module imklog failed", # imklog module activation
r"pam_lastlog\.so", # PAM module not in container
r"PAM unable to dlopen", # PAM module load failure
r"PAM adding faulty module", # PAM module error
r"pam_systemd.*Failed to create session", # Session creation (expected in container)
r"Failed to start motd-news\.service", # MOTD news (expected in container)
]
# Known error patterns and their fixes
AUTO_FIXES = {
# Python module missing
r"ModuleNotFoundError: No module named '([^']+)'": {
"fix_cmd": "pip install {module}",
"description": "Install missing Python module: {module}"
},
# Permission denied on common paths
r"Permission denied: (/tmp/[^\s]+)": {
"fix_cmd": "chmod 755 {path}",
"description": "Fix permissions on {path}"
},
# Disk space issues
r"No space left on device": {
"fix_cmd": None, # Can't auto-fix, needs human
"description": "CRITICAL: Disk full - manual cleanup required",
"alert": True
},
# Connection refused (services down)
r"Connection refused.*:(\d+)": {
"fix_cmd": None,
"description": "Service on port {port} may be down - check status",
"alert": True
},
# Ollama connection issues
r"ollama.*connection.*refused": {
"fix_cmd": "systemctl restart ollama",
"description": "Restart ollama service"
},
# Redis connection issues
r"redis.*connection.*refused": {
"fix_cmd": "systemctl restart redis-server || docker restart redis",
"description": "Restart Redis service"
},
}
def should_exclude(line):
"""Check if a log line should be excluded as noise"""
for pattern in EXCLUDE_PATTERNS:
if re.search(pattern, line, re.IGNORECASE):
return True
return False
def run_cmd(cmd, timeout=30):
"""Run shell command and return output"""
try:
result = subprocess.run(
cmd, shell=True, capture_output=True, text=True, timeout=timeout
)
return result.stdout + result.stderr
except Exception as e:
return f"Command failed: {e}"
def check_redis():
"""Check Redis health using Python (redis-cli not available in container)"""
try:
import redis
r = redis.Redis(host='10.0.0.36', port=6379, socket_timeout=5, decode_responses=True)
if r.ping():
return "Redis: ✅ Connected (10.0.0.36:6379)"
else:
return "Redis: ❌ Ping failed"
except ImportError:
return "Redis: ⚠️ redis module not installed, cannot check"
except Exception as e:
return f"Redis: ❌ Error - {str(e)[:50]}"
def get_journal_errors():
"""Get errors from systemd journal (last 24h)"""
since = (datetime.now() - timedelta(hours=LOG_HOURS)).strftime("%Y-%m-%d %H:%M:%S")
cmd = f"journalctl --since='{since}' --priority=err --no-pager -q"
output = run_cmd(cmd)
# Filter out noise
lines = output.strip().split('\n')
filtered = [line for line in lines if line.strip() and not should_exclude(line)]
return '\n'.join(filtered) if filtered else ""
def get_cron_errors():
"""Get cron-related errors"""
cron_logs = []
# Try common cron log locations
for log_path in ["/var/log/cron", "/var/log/syslog", "/var/log/messages"]:
if os.path.exists(log_path):
# Use proper shell escaping - pipe character needs to be in the pattern
cmd = rf"grep -iE 'cron.*error|CRON.*FAILED| exited with ' {log_path} 2>/dev/null | tail -20"
output = run_cmd(cmd)
if output.strip():
# Filter noise
lines = output.strip().split('\n')
filtered = [line for line in lines if not should_exclude(line)]
if filtered:
cron_logs.append(f"=== {log_path} ===\n" + '\n'.join(filtered))
return "\n\n".join(cron_logs) if cron_logs else ""
def get_openclaw_errors():
"""Check OpenClaw session logs for errors"""
# Find files with errors from last 24h, excluding this script's runs
cmd = rf"find /root/.openclaw/agents -name '*.jsonl' -mtime -1 -exec grep -l 'error|Error|FAILED|Traceback' {{}} \; 2>/dev/null"
files = run_cmd(cmd).strip().split("\n")
errors = []
for f in files:
if f and SCRIPT_DIR not in f: # Skip my own script's logs
# Get recent errors from each file
cmd = rf"grep -iE 'error|traceback|failed' '{f}' 2>/dev/null | tail -5"
output = run_cmd(cmd)
if output.strip():
# Filter noise aggressively for OpenClaw logs
lines = output.strip().split('\n')
filtered = [line for line in lines if not should_exclude(line)]
# Additional filter: skip lines that are just me analyzing errors
filtered = [line for line in filtered if not re.search(r'I (can )?see', line, re.IGNORECASE)]
filtered = [line for line in filtered if not re.search(r'meta and kind of funny', line, re.IGNORECASE)]
# Filter very long content blocks (file reads)
filtered = [line for line in filtered if len(line) < 500]
if filtered:
errors.append(f"=== {os.path.basename(f)} ===\n" + '\n'.join(filtered))
return "\n\n".join(errors) if errors else ""
def scan_and_fix(log_content, source_name):
"""Scan log content for known errors and attempt fixes"""
fixes_applied = []
alerts_needed = []
# Track which fixes we've already tried (avoid duplicates)
tried_fixes = set()
for pattern, fix_info in AUTO_FIXES.items():
matches = re.finditer(pattern, log_content, re.IGNORECASE)
for match in matches:
# Extract groups if any
groups = match.groups()
description = fix_info["description"]
fix_cmd = fix_info.get("fix_cmd")
needs_alert = fix_info.get("alert", False)
# Format description with extracted values
if groups:
for i, group in enumerate(groups):
placeholder = ["module", "path", "port", "service"][i] if i < 4 else f"group{i}"
description = description.replace(f"{{{placeholder}}}", group)
if fix_cmd:
fix_cmd = fix_cmd.replace(f"{{{placeholder}}}", group)
# Skip if we already tried this exact fix
fix_key = f"{description}:{fix_cmd}"
if fix_key in tried_fixes:
continue
tried_fixes.add(fix_key)
if needs_alert:
alerts_needed.append({
"error": match.group(0),
"description": description,
"source": source_name
})
elif fix_cmd:
# Attempt the fix
print(f"[FIXING] {description}")
result = run_cmd(fix_cmd)
success = "error" not in result.lower() and "failed" not in result.lower()
fixes_applied.append({
"description": description,
"command": fix_cmd,
"success": success,
"result": result[:200] if result else "OK"
})
return fixes_applied, alerts_needed
def main():
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
report = [f"=== Log Monitor Report: {timestamp} ===\n"]
all_fixes = []
all_alerts = []
# Check service health (parallel-style in Python)
print("Checking service health...")
redis_status = check_redis()
report.append(f"\n--- Service Health ---\n{redis_status}")
# Check systemd journal
print("Checking systemd journal...")
journal_errors = get_journal_errors()
if journal_errors:
report.append(f"\n--- Systemd Journal Errors ---\n{journal_errors[:2000]}")
fixes, alerts = scan_and_fix(journal_errors, "journal")
all_fixes.extend(fixes)
all_alerts.extend(alerts)
else:
report.append("\n--- Systemd Journal: No errors found ---")
# Check cron logs
print("Checking cron logs...")
cron_errors = get_cron_errors()
if cron_errors:
report.append(f"\n--- Cron Errors ---\n{cron_errors[:2000]}")
fixes, alerts = scan_and_fix(cron_errors, "cron")
all_fixes.extend(fixes)
all_alerts.extend(alerts)
else:
report.append("\n--- Cron Logs: No errors found ---")
# Check OpenClaw logs
print("Checking OpenClaw logs...")
oc_errors = get_openclaw_errors()
if oc_errors:
report.append(f"\n--- OpenClaw Errors ---\n{oc_errors[:2000]}")
fixes, alerts = scan_and_fix(oc_errors, "openclaw")
all_fixes.extend(fixes)
all_alerts.extend(alerts)
else:
report.append("\n--- OpenClaw Logs: No errors found ---")
# Summarize fixes
report.append(f"\n\n=== FIXES APPLIED: {len(all_fixes)} ===")
for fix in all_fixes:
status = "" if fix["success"] else ""
report.append(f"\n{status} {fix['description']}")
report.append(f" Command: {fix['command']}")
if not fix["success"]:
report.append(f" Result: {fix['result']}")
# Summarize alerts (need human attention)
if all_alerts:
report.append(f"\n\n=== ALERTS NEEDING ATTENTION: {len(all_alerts)} ===")
for alert in all_alerts:
report.append(f"\n⚠️ {alert['description']}")
report.append(f" Source: {alert['source']}")
report.append(f" Error: {alert['error'][:100]}")
# Save report
report_text = "\n".join(report)
with open(REPORT_FILE, "w") as f:
f.write(report_text)
# Print summary
print(f"\n{report_text}")
# Return non-zero if there are unhandled alerts (for cron notification)
if all_alerts:
print(f"\n⚠️ {len(all_alerts)} issue(s) need human attention")
return 1
print("\n✅ Log check complete. All issues resolved or no errors found.")
return 0
if __name__ == "__main__":
sys.exit(main())
-42
View File
@@ -1,42 +0,0 @@
# Memory Buffer Skill
Redis-based short-term memory buffer for OpenClaw.
## What It Does
Accumulates conversation turns in real-time and flushes to Qdrant daily.
## Commands
```bash
# Manual save (all turns)
python3 scripts/save_mem.py --user-id yourname
# Retrieve from buffer
python3 scripts/mem_retrieve.py --limit 10
# Search Redis + Qdrant
python3 scripts/search_mem.py "your query"
```
## Heartbeat Integration
Add to HEARTBEAT.md:
```bash
python3 /path/to/skills/mem-redis/scripts/hb_append.py
```
## Cron
```bash
# Daily flush at 3:00 AM
0 3 * * * python3 scripts/cron_backup.py
```
## Files
- `hb_append.py` - Heartbeat: append new turns only
- `save_mem.py` - Manual: save all turns
- `cron_backup.py` - Daily: flush to Qdrant
- `mem_retrieve.py` - Read from Redis
- `search_mem.py` - Search Redis + Qdrant
-204
View File
@@ -1,204 +0,0 @@
#!/usr/bin/env python3
"""
Daily Cron: Process Redis buffer → Qdrant → Clear Redis.
This script runs once daily (via cron) to move buffered conversation
turns from Redis to durable Qdrant storage. Only clears Redis after
successful Qdrant write.
Usage: python3 cron_backup.py [--user-id rob] [--dry-run]
"""
import os
import sys
import json
import redis
import argparse
from datetime import datetime, timezone
from pathlib import Path
# Add qdrant-memory to path (portable)
from pathlib import Path as _Path
WORKSPACE = _Path(os.getenv("OPENCLAW_WORKSPACE", str(_Path.home() / ".openclaw" / "workspace")))
sys.path.insert(0, str(WORKSPACE / "skills" / "qdrant-memory" / "scripts"))
try:
from auto_store import store_conversation_turn
QDRANT_AVAILABLE = True
except ImportError:
QDRANT_AVAILABLE = False
print("Warning: Qdrant storage not available, will simulate", file=sys.stderr)
# Config
REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
USER_ID = os.getenv("USER_ID", "yourname")
def get_redis_items(user_id):
"""Get all items from Redis list."""
try:
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
key = f"mem:{user_id}"
# Get all items (0 to -1 = entire list)
items = r.lrange(key, 0, -1)
# Parse JSON
turns = []
for item in items:
try:
turn = json.loads(item)
turns.append(turn)
except json.JSONDecodeError:
continue
return turns, key
except Exception as e:
print(f"Error reading from Redis: {e}", file=sys.stderr)
return None, None
def store_to_qdrant(turns, user_id):
"""Store turns to Qdrant with file fallback."""
if not QDRANT_AVAILABLE:
print("[DRY RUN] Would store to Qdrant:", file=sys.stderr)
for turn in turns[:3]:
print(f" - Turn {turn.get('turn', '?')}: {turn.get('role', '?')}", file=sys.stderr)
if len(turns) > 3:
print(f" ... and {len(turns) - 3} more", file=sys.stderr)
return True
# Ensure chronological order (older -> newer)
try:
turns_sorted = sorted(turns, key=lambda t: (t.get('timestamp', ''), t.get('turn', 0)))
except Exception:
turns_sorted = turns
user_turns = [t for t in turns_sorted if t.get('role') == 'user']
if not user_turns:
return True
success_count = 0
attempted = 0
for i, turn in enumerate(turns_sorted):
if turn.get('role') != 'user':
continue
attempted += 1
try:
# Pair with the next assistant message in chronological order (best effort)
ai_response = ""
j = i + 1
while j < len(turns_sorted):
if turns_sorted[j].get('role') == 'assistant':
ai_response = turns_sorted[j].get('content', '')
break
if turns_sorted[j].get('role') == 'user':
break
j += 1
result = store_conversation_turn(
user_message=turn.get('content', ''),
ai_response=ai_response,
user_id=user_id,
turn_number=turn.get('turn', i),
conversation_id=f"mem-buffer-{turn.get('timestamp', 'unknown')[:10]}"
)
# store_conversation_turn returns success/skipped; treat skipped as ok
if result.get('success') or result.get('skipped'):
success_count += 1
except Exception as e:
print(f"Error storing user turn {turn.get('turn', '?')}: {e}", file=sys.stderr)
# Only consider Qdrant storage successful if we stored/skipped ALL user turns.
return attempted > 0 and success_count == attempted
def store_to_file(turns, user_id):
"""Fallback: Store turns to JSONL file."""
from datetime import datetime
workspace = Path(os.getenv("OPENCLAW_WORKSPACE", str(Path.home() / ".openclaw" / "workspace")))
backup_dir = workspace / "memory" / "redis-backups"
backup_dir.mkdir(exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = backup_dir / f"mem-backup-{user_id}-{timestamp}.jsonl"
try:
with open(filename, 'w') as f:
for turn in turns:
f.write(json.dumps(turn) + '\n')
print(f"✅ Backed up {len(turns)} turns to file: {filename}")
return True
except Exception as e:
print(f"❌ File backup failed: {e}", file=sys.stderr)
return False
def clear_redis(key):
"""Clear Redis list after successful backup."""
try:
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
r.delete(key)
return True
except Exception as e:
print(f"Error clearing Redis: {e}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(description="Backup Redis mem buffer to Qdrant")
parser.add_argument("--user-id", default=USER_ID, help="User ID")
parser.add_argument("--dry-run", action="store_true", help="Don't actually clear Redis")
args = parser.parse_args()
# Get items from Redis
turns, key = get_redis_items(args.user_id)
if turns is None:
print("❌ Failed to read from Redis")
sys.exit(1)
if not turns:
print(f"No items in Redis buffer (mem:{args.user_id})")
sys.exit(0)
print(f"Found {len(turns)} turns in Redis buffer")
# Try Qdrant first
qdrant_success = False
if not args.dry_run:
qdrant_success = store_to_qdrant(turns, args.user_id)
if qdrant_success:
print(f"✅ Stored Redis buffer to Qdrant (all user turns)")
else:
print("⚠️ Qdrant storage incomplete; will NOT clear Redis", file=sys.stderr)
else:
print("[DRY RUN] Would attempt Qdrant storage")
qdrant_success = True # Dry run pretends success
# If Qdrant failed/incomplete, try file backup (still do NOT clear Redis unless user chooses)
file_success = False
if not qdrant_success:
print("⚠️ Qdrant storage failed/incomplete, writing file backup (Redis preserved)...")
file_success = store_to_file(turns, args.user_id)
if not file_success:
print("❌ Both Qdrant and file backup failed - Redis buffer preserved")
sys.exit(1)
# Exit non-zero so monitoring can alert; keep Redis for re-try.
sys.exit(1)
# Clear Redis (only if not dry-run)
if args.dry_run:
print("[DRY RUN] Would clear Redis buffer")
sys.exit(0)
if clear_redis(key):
print(f"✅ Cleared Redis buffer (mem:{args.user_id})")
else:
print(f"⚠️ Backup succeeded but failed to clear Redis - may duplicate on next run")
sys.exit(1)
backup_type = "Qdrant" if qdrant_success else "file"
print(f"\n🎉 Successfully backed up {len(turns)} turns to {backup_type} long-term memory")
if __name__ == "__main__":
main()
-230
View File
@@ -1,230 +0,0 @@
#!/usr/bin/env python3
"""
Cron Capture: Append NEW session transcript messages to Redis (no LLM / no heartbeat).
Goal: minimize token spend by capturing context out-of-band.
- Tracks per-session file offsets (byte position) in a JSON state file.
- No-ops if the transcript file hasn't changed since last run.
- Stores user/assistant visible text to Redis (chronological order via RPUSH).
- Optionally stores model "thinking" separately (disabled by default) so it can be
queried only when explicitly needed.
Usage:
python3 cron_capture.py [--user-id rob] [--include-thinking]
Suggested cron (every 5 minutes):
*/5 * * * * cd ~/.openclaw/workspace && python3 skills/mem-redis/scripts/cron_capture.py --user-id $USER
Env:
OPENCLAW_WORKSPACE: override workspace path (default: ~/.openclaw/workspace)
OPENCLAW_SESSIONS_DIR: override sessions dir (default: ~/.openclaw/agents/main/sessions)
REDIS_HOST / REDIS_PORT / USER_ID
"""
import argparse
import json
import os
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
USER_ID = os.getenv("USER_ID", "yourname")
DEFAULT_WORKSPACE = Path(os.getenv("OPENCLAW_WORKSPACE", str(Path.home() / ".openclaw" / "workspace")))
DEFAULT_SESSIONS_DIR = Path(os.getenv("OPENCLAW_SESSIONS_DIR", str(Path.home() / ".openclaw" / "agents" / "main" / "sessions")))
STATE_FILE = DEFAULT_WORKSPACE / ".mem_capture_state.json"
@dataclass
class ParsedMessage:
role: str # user|assistant
text: str
thinking: Optional[str]
timestamp: str
session_id: str
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def find_latest_transcript(sessions_dir: Path) -> Optional[Path]:
files = list(sessions_dir.glob("*.jsonl"))
if not files:
return None
return max(files, key=lambda p: p.stat().st_mtime)
def load_state() -> Dict[str, Any]:
if not STATE_FILE.exists():
return {}
try:
return json.loads(STATE_FILE.read_text())
except Exception:
return {}
def save_state(state: Dict[str, Any]) -> None:
try:
STATE_FILE.write_text(json.dumps(state, indent=2, sort_keys=True))
except Exception as e:
print(f"[cron_capture] Warning: could not write state: {e}", file=sys.stderr)
def extract_text_and_thinking(content: Any) -> Tuple[str, Optional[str]]:
"""Extract visible text and optional thinking from OpenClaw message content."""
if isinstance(content, str):
return content, None
text_parts: List[str] = []
thinking_parts: List[str] = []
if isinstance(content, list):
for item in content:
if not isinstance(item, dict):
continue
if "text" in item and isinstance(item["text"], str):
text_parts.append(item["text"])
if "thinking" in item and isinstance(item["thinking"], str):
thinking_parts.append(item["thinking"])
text = "".join(text_parts).strip()
thinking = "\n".join(thinking_parts).strip() if thinking_parts else None
return text, thinking
def parse_new_messages(transcript_path: Path, start_offset: int, include_thinking: bool) -> Tuple[List[ParsedMessage], int]:
"""Parse messages from transcript_path starting at byte offset."""
session_id = transcript_path.stem
msgs: List[ParsedMessage] = []
with transcript_path.open("rb") as f:
f.seek(start_offset)
while True:
line = f.readline()
if not line:
break
try:
entry = json.loads(line.decode("utf-8", errors="replace").strip())
except Exception:
continue
if entry.get("type") != "message" or "message" not in entry:
continue
msg = entry.get("message") or {}
role = msg.get("role")
if role not in ("user", "assistant"):
continue
# Skip tool results explicitly
if role == "toolResult":
continue
text, thinking = extract_text_and_thinking(msg.get("content"))
if not text and not (include_thinking and thinking):
continue
msgs.append(
ParsedMessage(
role=role,
text=text[:8000],
thinking=(thinking[:16000] if (include_thinking and thinking) else None),
timestamp=entry.get("timestamp") or _now_iso(),
session_id=session_id,
)
)
end_offset = f.tell()
return msgs, end_offset
def append_to_redis(user_id: str, messages: List[ParsedMessage]) -> int:
if not messages:
return 0
import redis # lazy import so --dry-run works without deps
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
key = f"mem:{user_id}"
thinking_key = f"mem_thinking:{user_id}"
# RPUSH keeps chronological order.
for m in messages:
payload: Dict[str, Any] = {
"role": m.role,
"content": m.text,
"timestamp": m.timestamp,
"user_id": user_id,
"session": m.session_id,
}
r.rpush(key, json.dumps(payload))
if m.thinking:
t_payload = {
"role": m.role,
"thinking": m.thinking,
"timestamp": m.timestamp,
"user_id": user_id,
"session": m.session_id,
}
r.rpush(thinking_key, json.dumps(t_payload))
return len(messages)
def main() -> None:
parser = argparse.ArgumentParser(description="Cron capture: append new transcript messages to Redis")
parser.add_argument("--user-id", default=USER_ID)
parser.add_argument("--include-thinking", action="store_true", help="Store thinking into mem_thinking:<user>")
parser.add_argument("--sessions-dir", default=str(DEFAULT_SESSIONS_DIR))
parser.add_argument("--dry-run", action="store_true", help="Parse + update state, but do not write to Redis")
args = parser.parse_args()
sessions_dir = Path(args.sessions_dir)
transcript = find_latest_transcript(sessions_dir)
if not transcript:
print("[cron_capture] No session transcripts found")
return
st = load_state()
key = str(transcript)
info = st.get(key, {})
last_offset = int(info.get("offset", 0))
last_size = int(info.get("size", 0))
cur_size = transcript.stat().st_size
if cur_size == last_size and last_offset > 0:
print("[cron_capture] No changes")
return
messages, end_offset = parse_new_messages(transcript, last_offset, include_thinking=args.include_thinking)
if not messages:
# Still update size/offset so we don't re-read noise lines.
st[key] = {"offset": end_offset, "size": cur_size, "updated_at": _now_iso()}
save_state(st)
print("[cron_capture] No new user/assistant messages")
return
if args.dry_run:
st[key] = {"offset": end_offset, "size": cur_size, "updated_at": _now_iso()}
save_state(st)
print(f"[cron_capture] DRY RUN: would append {len(messages)} messages to Redis mem:{args.user_id}")
return
count = append_to_redis(args.user_id, messages)
st[key] = {"offset": end_offset, "size": cur_size, "updated_at": _now_iso()}
save_state(st)
print(f"[cron_capture] Appended {count} messages to Redis mem:{args.user_id}")
if __name__ == "__main__":
main()
-161
View File
@@ -1,161 +0,0 @@
#!/usr/bin/env python3
"""
Heartbeat: Append new conversation turns to Redis short-term buffer.
This script runs during heartbeat to capture recent conversation context
before it gets compacted away. Stores in Redis until daily cron backs up to Qdrant.
Usage: python3 hb_append.py [--user-id rob]
"""
import os
import sys
import json
import redis
import argparse
from datetime import datetime, timezone
from pathlib import Path
# Config
REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
USER_ID = os.getenv("USER_ID", "yourname")
# Paths (portable)
WORKSPACE = Path(os.getenv("OPENCLAW_WORKSPACE", str(Path.home() / ".openclaw" / "workspace")))
MEMORY_DIR = WORKSPACE / "memory"
SESSIONS_DIR = Path(os.getenv("OPENCLAW_SESSIONS_DIR", str(Path.home() / ".openclaw" / "agents" / "main" / "sessions")))
STATE_FILE = WORKSPACE / ".mem_last_turn"
def get_session_transcript():
"""Find the current session JSONL file."""
files = list(SESSIONS_DIR.glob("*.jsonl"))
if not files:
return None
# Get most recently modified
return max(files, key=lambda p: p.stat().st_mtime)
def parse_turns_since(last_turn_num):
"""Extract conversation turns since last processed."""
transcript_file = get_session_transcript()
if not transcript_file or not transcript_file.exists():
return []
turns = []
turn_counter = last_turn_num
try:
with open(transcript_file, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
# OpenClaw format: {"type": "message", "message": {"role": "...", ...}}
if entry.get('type') == 'message' and 'message' in entry:
msg = entry['message']
role = msg.get('role')
# Skip tool results for memory storage
if role == 'toolResult':
continue
# Get content from message content array or string
content = ""
if isinstance(msg.get('content'), list):
# Extract text from content array
for item in msg['content']:
if isinstance(item, dict):
if 'text' in item:
content += item['text']
# Intentionally do NOT store model thinking in the main buffer.
# If you need thinking, use cron_capture.py --include-thinking to store it
# separately under mem_thinking:<user_id>.
elif 'thinking' in item:
pass
elif isinstance(msg.get('content'), str):
content = msg['content']
if content and role in ('user', 'assistant'):
turn_counter += 1
turns.append({
'turn': turn_counter,
'role': role,
'content': content[:2000],
'timestamp': entry.get('timestamp', datetime.now(timezone.utc).isoformat()),
'user_id': USER_ID,
'session': str(transcript_file.name).replace('.jsonl', '')
})
except json.JSONDecodeError:
continue
except Exception as e:
print(f"Error reading transcript: {e}", file=sys.stderr)
return []
return turns
def get_last_turn():
"""Get last turn number from state file."""
if STATE_FILE.exists():
try:
with open(STATE_FILE) as f:
return int(f.read().strip())
except:
pass
return 0
def save_last_turn(turn_num):
"""Save last turn number to state file."""
try:
with open(STATE_FILE, 'w') as f:
f.write(str(turn_num))
except Exception as e:
print(f"Warning: Could not save state: {e}", file=sys.stderr)
def append_to_redis(turns, user_id):
"""Append turns to Redis list."""
if not turns:
return 0
try:
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
key = f"mem:{user_id}"
# Add all turns to list (LPUSH puts newest at front)
for turn in turns:
r.lpush(key, json.dumps(turn))
return len(turns)
except Exception as e:
print(f"Error writing to Redis: {e}", file=sys.stderr)
return 0
def main():
parser = argparse.ArgumentParser(description="Append new turns to Redis mem buffer")
parser.add_argument("--user-id", default=USER_ID, help="User ID for key naming")
args = parser.parse_args()
# Get last processed turn
last_turn = get_last_turn()
# Get new turns
new_turns = parse_turns_since(last_turn)
if not new_turns:
print(f"No new turns since turn {last_turn}")
sys.exit(0)
# Append to Redis
count = append_to_redis(new_turns, args.user_id)
if count > 0:
# Update last turn tracker
max_turn = max(t['turn'] for t in new_turns)
save_last_turn(max_turn)
print(f"✅ Appended {count} turns to Redis (mem:{args.user_id})")
else:
print("❌ Failed to append to Redis")
sys.exit(1)
if __name__ == "__main__":
main()
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""
Manual Retrieval: Get recent conversation turns from Redis buffer.
Use this when context has been compacted or you need to recall recent details.
Usage: python3 mem_retrieve.py [--limit 20] [--user-id rob]
"""
import os
import sys
import json
import redis
import argparse
from datetime import datetime, timezone
# Config
REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
USER_ID = os.getenv("USER_ID", "yourname")
def get_recent_turns(user_id, limit=20):
"""Get recent turns from Redis buffer."""
try:
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
key = f"mem:{user_id}"
# Get most recent N items (0 to limit-1)
items = r.lrange(key, 0, limit - 1)
# Parse and reverse (so oldest first)
turns = []
for item in items:
try:
turn = json.loads(item)
turns.append(turn)
except json.JSONDecodeError:
continue
# Reverse to chronological order
turns.reverse()
return turns
except Exception as e:
print(f"Error reading from Redis: {e}", file=sys.stderr)
return []
def format_turn(turn):
"""Format a turn for display."""
role = turn.get('role', 'unknown')
content = turn.get('content', '')
turn_num = turn.get('turn', '?')
# Truncate long content
if len(content) > 500:
content = content[:500] + "..."
role_icon = "👤" if role == 'user' else "🤖"
return f"{role_icon} Turn {turn_num} ({role}):\n{content}\n"
def main():
parser = argparse.ArgumentParser(description="Retrieve recent turns from mem buffer")
parser.add_argument("--user-id", default=USER_ID, help="User ID")
parser.add_argument("--limit", type=int, default=20, help="Number of turns to retrieve")
args = parser.parse_args()
# Get turns
turns = get_recent_turns(args.user_id, args.limit)
if not turns:
print(f"No recent turns in memory buffer (mem:{args.user_id})")
print("\nPossible reasons:")
print(" - Heartbeat hasn't run yet")
print(" - Cron already backed up and cleared Redis")
print(" - Redis connection issue")
sys.exit(0)
# Display
print(f"=== Recent {len(turns)} Turn(s) from Memory Buffer ===\n")
for turn in turns:
print(format_turn(turn))
print(f"\nBuffer key: mem:{args.user_id}")
print("Note: These turns are also in Redis until daily cron backs them up to Qdrant.")
if __name__ == "__main__":
main()
-149
View File
@@ -1,149 +0,0 @@
#!/usr/bin/env python3
"""
Save all conversation context to Redis (not just new turns).
Unlike hb_append.py which only saves NEW turns since last run,
this script saves ALL context from the session (or resets and saves fresh).
Usage: python3 save_mem.py [--user-id rob] [--reset]
"""
import os
import sys
import json
import redis
import argparse
from datetime import datetime, timezone
from pathlib import Path
# Config
REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
USER_ID = os.getenv("USER_ID", "yourname")
# Paths (portable)
WORKSPACE = Path(os.getenv("OPENCLAW_WORKSPACE", str(Path.home() / ".openclaw" / "workspace")))
SESSIONS_DIR = Path(os.getenv("OPENCLAW_SESSIONS_DIR", str(Path.home() / ".openclaw" / "agents" / "main" / "sessions")))
STATE_FILE = WORKSPACE / ".mem_last_turn"
def get_session_transcript():
"""Find the current session JSONL file."""
files = list(SESSIONS_DIR.glob("*.jsonl"))
if not files:
return None
return max(files, key=lambda p: p.stat().st_mtime)
def parse_all_turns():
"""Extract ALL conversation turns from current session."""
transcript_file = get_session_transcript()
if not transcript_file or not transcript_file.exists():
return []
turns = []
turn_counter = 0
try:
with open(transcript_file, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
if entry.get('type') == 'message' and 'message' in entry:
msg = entry['message']
role = msg.get('role')
if role == 'toolResult':
continue
content = ""
if isinstance(msg.get('content'), list):
for item in msg['content']:
if isinstance(item, dict):
if 'text' in item:
content += item['text']
# Do not mix thinking into the main content buffer.
elif 'thinking' in item:
pass
elif isinstance(msg.get('content'), str):
content = msg['content']
if content and role in ('user', 'assistant'):
turn_counter += 1
turns.append({
'turn': turn_counter,
'role': role,
'content': content[:2000],
'timestamp': entry.get('timestamp', datetime.now(timezone.utc).isoformat()),
'user_id': USER_ID,
'session': str(transcript_file.name).replace('.jsonl', '')
})
except json.JSONDecodeError:
continue
except Exception as e:
print(f"Error reading transcript: {e}", file=sys.stderr)
return []
return turns
def save_to_redis(turns, user_id, reset=False):
"""Save turns to Redis. If reset, clear existing first."""
if not turns:
return 0
try:
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
key = f"mem:{user_id}"
# Clear existing if reset
if reset:
r.delete(key)
print(f"Cleared existing Redis buffer ({key})")
# Add all turns (LPUSH puts newest at front, so we reverse to keep order)
for turn in reversed(turns):
r.lpush(key, json.dumps(turn))
return len(turns)
except Exception as e:
print(f"Error writing to Redis: {e}", file=sys.stderr)
return 0
def update_state(last_turn_num):
"""Update last turn tracker."""
try:
with open(STATE_FILE, 'w') as f:
f.write(str(last_turn_num))
except Exception as e:
print(f"Warning: Could not save state: {e}", file=sys.stderr)
def main():
parser = argparse.ArgumentParser(description="Save all conversation context to Redis")
parser.add_argument("--user-id", default=USER_ID, help="User ID for key naming")
parser.add_argument("--reset", action="store_true", help="Clear existing buffer first")
args = parser.parse_args()
# Get all turns
turns = parse_all_turns()
if not turns:
print("No conversation turns found in session")
sys.exit(0)
# Save to Redis
count = save_to_redis(turns, args.user_id, reset=args.reset)
if count > 0:
# Update state to track last turn
max_turn = max(t['turn'] for t in turns)
update_state(max_turn)
action = "Reset and saved" if args.reset else "Saved"
print(f"{action} {count} turns to Redis (mem:{args.user_id})")
print(f" State updated to turn {max_turn}")
else:
print("❌ Failed to save to Redis")
sys.exit(1)
if __name__ == "__main__":
main()
-242
View File
@@ -1,242 +0,0 @@
#!/usr/bin/env python3
"""
Search memory: First Redis (exact), then Qdrant (semantic).
Usage: python3 search_mem.py "your search query" [--limit 10] [--user-id rob]
Searches:
1. Redis (mem:{user_id}) - exact text match in recent buffer
2. Qdrant (kimi_memories) - semantic similarity search
"""
import os
import sys
import json
import redis
import argparse
from pathlib import Path
from datetime import datetime
# Config
REDIS_HOST = os.getenv("REDIS_HOST", "10.0.0.36")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
USER_ID = os.getenv("USER_ID", "yourname")
QDRANT_URL = os.getenv("QDRANT_URL", "http://10.0.0.40:6333")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://10.0.0.10:11434/v1")
def search_redis(query, user_id, limit=20):
"""Search Redis buffer for exact text matches."""
try:
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
key = f"mem:{user_id}"
# Get all items from list
items = r.lrange(key, 0, -1)
if not items:
return []
query_lower = query.lower()
matches = []
for item in items:
try:
turn = json.loads(item)
content = turn.get('content', '').lower()
if query_lower in content:
matches.append({
'source': 'redis',
'turn': turn.get('turn'),
'role': turn.get('role'),
'content': turn.get('content'),
'timestamp': turn.get('timestamp'),
'score': 'exact'
})
except json.JSONDecodeError:
continue
# Sort by turn number descending (newest first)
matches.sort(key=lambda x: x.get('turn', 0), reverse=True)
return matches[:limit]
except Exception as e:
print(f"Redis search error: {e}", file=sys.stderr)
return []
def get_embedding(text):
"""Get embedding from Ollama."""
import urllib.request
payload = json.dumps({
"model": "snowflake-arctic-embed2",
"input": text
}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/embeddings",
data=payload,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read().decode())
return result.get('data', [{}])[0].get('embedding')
except Exception as e:
print(f"Embedding error: {e}", file=sys.stderr)
return None
def search_qdrant(query, user_id, limit=10):
"""Search Qdrant for semantic similarity."""
import urllib.request
embedding = get_embedding(query)
if not embedding:
return []
payload = json.dumps({
"vector": embedding,
"limit": limit,
"with_payload": True,
"filter": {
"must": [
{"key": "user_id", "match": {"value": user_id}}
]
}
}).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/kimi_memories/points/search",
data=payload,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
result = json.loads(resp.read().decode())
points = result.get('result', [])
matches = []
for point in points:
payload = point.get('payload', {})
matches.append({
'source': 'qdrant',
'score': round(point.get('score', 0), 3),
'turn': payload.get('turn_number'),
'role': payload.get('role'),
'content': payload.get('user_message') or payload.get('content', ''),
'ai_response': payload.get('ai_response', ''),
'timestamp': payload.get('timestamp'),
'conversation_id': payload.get('conversation_id')
})
return matches
except Exception as e:
print(f"Qdrant search error: {e}", file=sys.stderr)
return []
def format_result(result, index):
"""Format a single search result."""
source = result.get('source', 'unknown')
role = result.get('role', 'unknown')
turn = result.get('turn', '?')
score = result.get('score', '?')
content = result.get('content', '')
if len(content) > 200:
content = content[:200] + "..."
# Role emoji
role_emoji = "👤" if role == "user" else "🤖"
# Source indicator
source_icon = "🔴" if source == "redis" else "🔵"
lines = [
f"{source_icon} [{index}] Turn {turn} ({role}):",
f" {role_emoji} {content}"
]
if source == "qdrant" and result.get('ai_response'):
ai_resp = result['ai_response'][:150]
if len(result['ai_response']) > 150:
ai_resp += "..."
lines.append(f" 💬 AI: {ai_resp}")
if score != 'exact':
lines.append(f" 📊 Score: {score}")
else:
lines.append(f" 📊 Match: exact (Redis)")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Search memory: Redis first, then Qdrant")
parser.add_argument("query", help="Search query")
parser.add_argument("--limit", type=int, default=10, help="Results per source (default: 10)")
parser.add_argument("--user-id", default=USER_ID, help="User ID")
parser.add_argument("--redis-only", action="store_true", help="Only search Redis")
parser.add_argument("--qdrant-only", action="store_true", help="Only search Qdrant")
args = parser.parse_args()
print(f"🔍 Searching for: \"{args.query}\"\n")
all_results = []
# Search Redis first (unless qdrant-only)
if not args.qdrant_only:
print("📍 Searching Redis (exact match)...")
redis_results = search_redis(args.query, args.user_id, limit=args.limit)
if redis_results:
print(f"✅ Found {len(redis_results)} matches in Redis\n")
all_results.extend(redis_results)
else:
print("❌ No exact matches in Redis\n")
# Search Qdrant (unless redis-only)
if not args.redis_only:
print("🧠 Searching Qdrant (semantic similarity)...")
qdrant_results = search_qdrant(args.query, args.user_id, limit=args.limit)
if qdrant_results:
print(f"✅ Found {len(qdrant_results)} matches in Qdrant\n")
all_results.extend(qdrant_results)
else:
print("❌ No semantic matches in Qdrant\n")
# Display results
if not all_results:
print("No results found in either Redis or Qdrant.")
sys.exit(0)
print(f"=== Search Results ({len(all_results)} total) ===\n")
# Sort: Redis first (chronological), then Qdrant (by score)
redis_sorted = [r for r in all_results if r['source'] == 'redis']
qdrant_sorted = sorted(
[r for r in all_results if r['source'] == 'qdrant'],
key=lambda x: x.get('score', 0),
reverse=True
)
# Display Redis results first
if redis_sorted:
print("🔴 FROM REDIS (Recent Buffer):\n")
for i, result in enumerate(redis_sorted, 1):
print(format_result(result, i))
print()
# Then Qdrant results
if qdrant_sorted:
print("🔵 FROM QDRANT (Long-term Memory):\n")
for i, result in enumerate(qdrant_sorted, len(redis_sorted) + 1):
print(format_result(result, i))
print()
print(f"=== {len(all_results)} results ===")
if redis_sorted:
print(f" 🔴 Redis: {len(redis_sorted)} (exact, recent)")
if qdrant_sorted:
print(f" 🔵 Qdrant: {len(qdrant_sorted)} (semantic, long-term)")
if __name__ == "__main__":
main()
+43
View File
@@ -0,0 +1,43 @@
# Perplexity API Skill
Perplexity AI API integration for OpenClaw. Provides search-enhanced LLM responses with citations.
## API Details
- **Endpoint**: `https://api.perplexity.ai/chat/completions`
- **Key**: Stored in `config.json`
- **Models**: sonar, sonar-pro, sonar-reasoning, sonar-deep-research
- **Format**: OpenAI-compatible
## Usage
```python
from skills.perplexity.scripts.query import query_perplexity
# Simple query
response = query_perplexity("What is quantum computing?")
# With citations
response = query_perplexity("Latest AI news", include_citations=True)
# Specific model
response = query_perplexity("Complex research question", model="sonar-deep-research")
```
## Models
| Model | Best For | Search Context |
|-------|----------|----------------|
| sonar | Quick answers, simple queries | Low/Medium/High |
| sonar-pro | Complex queries, coding | Medium/High |
| sonar-reasoning | Step-by-step reasoning | Medium/High |
| sonar-deep-research | Comprehensive research | High |
## Files
- `scripts/query.py` - Main query interface
- `config.json` - API key storage (auto-created)
## Privacy Note
Perplexity API sends queries to Perplexity's servers (not local). Use SearXNG for fully local search.
+6
View File
@@ -0,0 +1,6 @@
{
"api_key": "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",
"base_url": "https://api.perplexity.ai",
"default_model": "sonar",
"default_max_tokens": 1000
}
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Perplexity API Query Interface
Usage:
python3 query.py "What is the capital of France?"
python3 query.py "Latest AI news" --model sonar-pro --citations
"""
import json
import os
import sys
import urllib.request
from pathlib import Path
def load_config():
"""Load API configuration"""
config_path = Path(__file__).parent.parent / "config.json"
try:
with open(config_path) as f:
return json.load(f)
except Exception as e:
print(f"Error loading config: {e}", file=sys.stderr)
return None
def query_perplexity(query, model=None, max_tokens=None, include_citations=False, search_context="low"):
"""
Query Perplexity API
Args:
query: The question/prompt to send
model: Model to use (sonar, sonar-pro, sonar-reasoning, sonar-deep-research)
max_tokens: Maximum tokens in response
include_citations: Whether to include source citations
search_context: Search depth (low, medium, high)
Returns:
dict with response text, citations, and usage info
"""
config = load_config()
if not config:
return {"error": "Failed to load configuration"}
model = model or config.get("default_model", "sonar")
max_tokens = max_tokens or config.get("default_max_tokens", 1000)
api_key = config.get("api_key")
base_url = config.get("base_url", "https://api.perplexity.ai")
if not api_key:
return {"error": "API key not configured"}
payload = {
"model": model,
"messages": [
{"role": "system", "content": "Be precise and concise."},
{"role": "user", "content": query}
],
"max_tokens": max_tokens,
"search_context_size": search_context
}
data = json.dumps(payload).encode()
req = urllib.request.Request(
f"{base_url}/chat/completions",
data=data,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
)
try:
with urllib.request.urlopen(req, timeout=60) as response:
result = json.loads(response.read().decode())
output = {
"text": result["choices"][0]["message"]["content"],
"model": result.get("model"),
"usage": result.get("usage", {})
}
if include_citations:
output["citations"] = result.get("citations", [])
output["search_results"] = result.get("search_results", [])
return output
except urllib.error.HTTPError as e:
error_body = e.read().decode()
return {"error": f"HTTP {e.code}: {error_body}"}
except Exception as e:
return {"error": str(e)}
def main():
import argparse
parser = argparse.ArgumentParser(description="Query Perplexity API")
parser.add_argument("query", help="The query to send")
parser.add_argument("--model", default="sonar",
choices=["sonar", "sonar-pro", "sonar-reasoning", "sonar-deep-research"],
help="Model to use")
parser.add_argument("--max-tokens", type=int, default=1000,
help="Maximum tokens in response")
parser.add_argument("--citations", action="store_true",
help="Include citations in output")
parser.add_argument("--search-context", default="low",
choices=["low", "medium", "high"],
help="Search context size")
args = parser.parse_args()
result = query_perplexity(
args.query,
model=args.model,
max_tokens=args.max_tokens,
include_citations=args.citations,
search_context=args.search_context
)
if "error" in result:
print(f"Error: {result['error']}", file=sys.stderr)
sys.exit(1)
print(result["text"])
if args.citations and result.get("citations"):
print("\n--- Sources ---")
for i, citation in enumerate(result["citations"][:5], 1):
print(f"[{i}] {citation}")
if __name__ == "__main__":
main()
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""
Unified Search - Perplexity primary, SearXNG fallback
Usage:
search "your query" # Perplexity primary, SearXNG fallback
search p "your query" # Perplexity only
search perplexity "your query" # Perplexity only (alias)
search local "your query" # SearXNG only
search searxng "your query" # SearXNG only (alias)
search --citations "query" # Include citations (Perplexity)
search --model sonar-pro "query" # Use specific Perplexity model
"""
import json
import sys
import urllib.request
import urllib.parse
from pathlib import Path
# Configuration
PERPLEXITY_CONFIG = Path(__file__).parent.parent / "config.json"
SEARXNG_URL = "http://10.0.0.8:8888"
def load_perplexity_config():
"""Load Perplexity API configuration"""
try:
with open(PERPLEXITY_CONFIG) as f:
return json.load(f)
except Exception as e:
print(f"Error loading Perplexity config: {e}", file=sys.stderr)
return None
def search_perplexity(query, model="sonar", max_tokens=1000, include_citations=False, search_context="low"):
"""Search using Perplexity API"""
config = load_perplexity_config()
if not config:
return {"error": "Perplexity not configured", "fallback_needed": True}
api_key = config.get("api_key")
base_url = config.get("base_url", "https://api.perplexity.ai")
if not api_key:
return {"error": "Perplexity API key not set", "fallback_needed": True}
payload = {
"model": model,
"messages": [
{"role": "system", "content": "Be precise and concise."},
{"role": "user", "content": query}
],
"max_tokens": max_tokens,
"search_context_size": search_context
}
data = json.dumps(payload).encode()
req = urllib.request.Request(
f"{base_url}/chat/completions",
data=data,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
}
)
try:
with urllib.request.urlopen(req, timeout=60) as response:
result = json.loads(response.read().decode())
output = {
"source": "perplexity",
"text": result["choices"][0]["message"]["content"],
"model": result.get("model"),
"usage": result.get("usage", {}),
"citations": result.get("citations", []),
"search_results": result.get("search_results", [])
}
return output
except urllib.error.HTTPError as e:
error_body = e.read().decode()
if e.code == 429: # Rate limit
return {"error": f"Perplexity rate limited: {error_body}", "fallback_needed": True}
return {"error": f"Perplexity HTTP {e.code}: {error_body}", "fallback_needed": True}
except Exception as e:
return {"error": f"Perplexity error: {str(e)}", "fallback_needed": True}
def search_searxng(query, limit=10):
"""Search using local SearXNG"""
try:
encoded_query = urllib.parse.quote(query)
url = f"{SEARXNG_URL}/search?q={encoded_query}&format=json"
req = urllib.request.Request(url)
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
results = result.get("results", [])[:limit]
formatted_results = []
for r in results:
formatted_results.append({
"title": r.get("title", ""),
"url": r.get("url", ""),
"content": r.get("content", "")[:200] + "..." if len(r.get("content", "")) > 200 else r.get("content", "")
})
# Format as readable text
text_output = f"Search results for: {query}\n\n"
for i, r in enumerate(formatted_results, 1):
text_output += f"[{i}] {r['title']}\n{r['url']}\n{r['content']}\n\n"
return {
"source": "searxng",
"text": text_output.strip(),
"results": formatted_results,
"query": query
}
except Exception as e:
return {"error": f"SearXNG error: {str(e)}", "fallback_needed": False}
def unified_search(query, mode="default", model="sonar", include_citations=False, max_tokens=1000, search_context="low"):
"""
Unified search with Perplexity primary, SearXNG fallback
Modes:
default: Perplexity primary, SearXNG fallback
perplexity: Perplexity only
local/searxng: SearXNG only
"""
if mode in ["perplexity", "p"]:
# Perplexity only
result = search_perplexity(query, model, max_tokens, include_citations, search_context)
return result
elif mode in ["local", "searxng", "s"]:
# SearXNG only
result = search_searxng(query)
return result
else:
# Default: Perplexity primary, SearXNG fallback
result = search_perplexity(query, model, max_tokens, include_citations, search_context)
if result.get("fallback_needed") or result.get("error"):
print(f"⚠️ Perplexity failed: {result.get('error', 'Unknown error')}", file=sys.stderr)
print("🔄 Falling back to SearXNG...\n", file=sys.stderr)
fallback = search_searxng(query)
if not fallback.get("error"):
return fallback
else:
return {"error": f"Both Perplexity and SearXNG failed. Perplexity: {result.get('error')}, SearXNG: {fallback.get('error')}"}
return result
def main():
import argparse
parser = argparse.ArgumentParser(
description="Unified search: Perplexity primary, SearXNG fallback",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
search "latest AI news" # Perplexity primary, SearXNG fallback
search p "quantum computing explained" # Perplexity only
search local "ip address lookup" # SearXNG only
search --citations "who invented Python" # Include citations
search --model sonar-pro "coding help" # Use Pro model
"""
)
parser.add_argument("args", nargs="*", help="[mode] query (mode: p/perplexity/local/searxng)")
parser.add_argument("--citations", action="store_true",
help="Include citations (Perplexity only)")
parser.add_argument("--model", default="sonar",
choices=["sonar", "sonar-pro", "sonar-reasoning", "sonar-deep-research"],
help="Perplexity model to use")
parser.add_argument("--max-tokens", type=int, default=1000,
help="Maximum tokens in response (Perplexity)")
parser.add_argument("--search-context", default="low",
choices=["low", "medium", "high"],
help="Search context size (Perplexity)")
args = parser.parse_args()
# Parse positional arguments
mode = "default"
query_parts = []
if not args.args:
print("Error: No query provided", file=sys.stderr)
parser.print_help()
sys.exit(1)
# Check if first arg is a mode indicator
if args.args[0] in ["p", "perplexity", "local", "searxng", "s"]:
mode = args.args[0]
if mode == "p":
mode = "perplexity"
elif mode == "s":
mode = "searxng"
query_parts = args.args[1:]
else:
query_parts = args.args
query = " ".join(query_parts)
if not query:
print("Error: No query provided", file=sys.stderr)
parser.print_help()
sys.exit(1)
result = unified_search(
query,
mode=mode,
model=args.model,
include_citations=args.citations,
max_tokens=args.max_tokens,
search_context=args.search_context
)
if "error" in result:
print(f"Error: {result['error']}", file=sys.stderr)
sys.exit(1)
# Print result
if result.get("source") == "perplexity":
print(f"🔍 Perplexity ({result.get('model', 'unknown')})")
if result.get("usage"):
cost = result["usage"].get("cost", {})
total = cost.get("total_cost", "unknown")
print(f"💰 Cost: ${total}")
print()
print(result["text"])
if args.citations and result.get("citations"):
print("\n--- Sources ---")
for i, citation in enumerate(result["citations"][:5], 1):
print(f"[{i}] {citation}")
elif result.get("source") == "searxng":
print(f"🔍 SearXNG (local)")
print()
print(result["text"])
else:
print(result.get("text", "No results"))
if __name__ == "__main__":
main()
-137
View File
@@ -1,137 +0,0 @@
# Session Harvest Instructions
## What is Session Harvesting?
Session harvesting extracts conversation turns from OpenClaw session JSONL files and stores them to Qdrant long-term memory with proper embeddings and user_id linking.
## When to Use
- **After setting up a new memory system** — harvest existing sessions
- **After discovering missed backups** — recover data from session files
- **Periodically** — if cron jobs missed any data
## Scripts
| Script | Purpose | Usage |
|--------|---------|-------|
| `harvest_sessions.py` | Harvest all sessions (auto-sorts by mtime) | Limited by memory, may timeout |
| `harvest_newest.py` | Harvest specific sessions by name | Recommended for batch control |
## Location
```
/root/.openclaw/workspace/skills/qdrant-memory/scripts/
├── harvest_sessions.py # Auto-harvest (use --limit to control)
└── harvest_newest.py # Manual batch (specify session names)
```
## Usage
### Method 1: Auto-Harvest with Limit
```bash
# Harvest oldest 10 sessions (default sort)
python3 harvest_sessions.py --user-id rob --limit 10
# Dry run to see what would be stored
python3 harvest_sessions.py --user-id rob --dry-run --limit 5
```
### Method 2: Batch by Session Name (Recommended)
```bash
# Harvest specific sessions (newest first recommended)
python3 harvest_newest.py --user-id rob \
session-uuid-1.jsonl \
session-uuid-2.jsonl \
session-uuid-3.jsonl
```
### Finding Newest Sessions
```bash
# List 20 newest session files
ls -t /root/.openclaw/agents/main/sessions/*.jsonl | head -20
# Get just filenames for copy-paste
ls -t /root/.openclaw/agents/main/sessions/*.jsonl | head -20 | xargs -I{} basename {}
```
## How It Works
1. **Parse** — Reads JSONL session file, extracts user/AI turns
2. **Pair** — Matches user message with next AI response
3. **Embed** — Generates 3 embeddings (user, AI, summary) via Ollama
4. **Deduplicate** — Checks content_hash before storing
5. **Store** — Upserts to Qdrant with user_id, conversation_id, turn_number
## Deduplication
- Uses MD5 hash of `user_message::ai_response`
- Checks Qdrant for existing `user_id + content_hash`
- Skips if already stored (returns "duplicate")
- Safe to run multiple times on same sessions
## Output Format
```
[1] session-uuid.jsonl
Stored: 10, Skipped: 6
Total: 44 stored, 6 skipped
```
- **Stored** = New memories added to Qdrant
- **Skipped** = Duplicates (already in Qdrant)
## Troubleshooting
### Timeout / SIGKILL
The embedding process is CPU-intensive. If killed:
```bash
# Use smaller batches
python3 harvest_newest.py --user-id rob session1.jsonl session2.jsonl
```
### Check Qdrant Status
```bash
curl -s http://10.0.0.40:6333/collections/kimi_memories | \
python3 -c "import sys,json; d=json.load(sys.stdin); print(d['result']['points_count'])"
```
### Check Session Content
```bash
# Count turns in a session
python3 -c "
import json
from pathlib import Path
f = Path('/root/.openclaw/agents/main/sessions/YOUR-SESSION.jsonl')
count = sum(1 for line in open(f) if 'user' in line or 'assistant' in line)
print(f'~{count} messages')
"
```
## Memory Architecture
```
Session JSONL (raw)
harvest_*.py
├──► Embeddings (Ollama snowflake-arctic-embed2)
Qdrant kimi_memories
└──► Searchable via user_id: "rob"
```
---
**Created:** February 17, 2026
**Author:** Kimi (audit session)
+204 -44
View File
@@ -1,53 +1,213 @@
# Qdrant Memory Skill ---
name: qdrant-memory
description: |
Manual memory backup to Qdrant vector database.
Memories are stored ONLY when explicitly requested by the user.
No automatic storage, no proactive retrieval, no background consolidation.
Enhanced metadata (confidence, source, expiration) available for manual use.
Includes separate KB collection for documents, web data, etc.
metadata:
openclaw:
os: ["darwin", "linux", "win32"]
---
Vector database storage for long-term semantic memory. # Qdrant Memory - Manual Mode
## What It Does ## Overview
Stores conversations with embeddings for semantic search. **MODE: MANUAL ONLY**
## Commands This system provides manual memory storage to Qdrant vector database for semantic search.
- **File-based logs**: Daily notes (`memory/YYYY-MM-DD.md`) continue normally
```bash - **Vector storage**: Qdrant available ONLY when user explicitly requests storage
# Initialize collections - **No automatic operations**: No auto-storage, no proactive retrieval, no auto-consolidation
python3 scripts/init_kimi_memories.py
python3 scripts/init_kimi_kb.py
# Store immediately
python3 scripts/auto_store.py
# Search memories
python3 scripts/search_memories.py "your query"
# Harvest old sessions
python3 scripts/harvest_sessions.py --limit 10
```
## Heartbeat Integration
Add to HEARTBEAT.md:
```bash
python3 /path/to/skills/qdrant-memory/scripts/daily_conversation_backup.py
```
## Cron
```bash
# Daily backup at 3:30 AM
30 3 * * * scripts/sliding_backup.sh
```
## Collections ## Collections
- `kimi_memories` - Conversations ### `kimi_memories` (Personal Memories)
- `kimi_kb` - Knowledge base - **Purpose**: Personal memories, preferences, rules, lessons learned
- `private_court_docs` - Legal docs - **Vector size**: 1024 (snowflake-arctic-embed2)
- **Distance**: Cosine
- **Usage**: "q remember", "q save", "q recall"
## Files ### `kimi_kb` (Knowledge Base)
- **Purpose**: Web search results, documents, scraped data, reference materials
- **Vector size**: 1024 (snowflake-arctic-embed2)
- **Distance**: Cosine
- **Usage**: Manual storage of external data only when requested
- `auto_store.py` - Store with embeddings ## Architecture
- `search_memories.py` - Semantic search
- `init_*.py` - Collection initialization ### Storage Layers
- `harvest_*.py` - Session harvesting
- `daily_conversation_backup.py` - Daily cron ```
- `sliding_backup.sh` - File backup Session Memory (this conversation) - Normal operation
Daily Logs (memory/YYYY-MM-DD.md) - Automatic, file-based
Manual Qdrant Storage - ONLY when user says "store this" or "q [command]"
├── kimi_memories (personal) - "q remember", "q recall"
└── kimi_kb (knowledge base) - web data, docs, manual only
```
### Memory Metadata
Available when manually storing:
- **text**: The memory content
- **date**: Creation date
- **tags**: Topics/keywords
- **importance**: low/medium/high
- **confidence**: high/medium/low (accuracy of the memory)
- **source_type**: user/inferred/external (how it was obtained)
- **verified**: bool (has this been confirmed)
- **expires_at**: Optional expiration date
- **related_memories**: IDs of connected memories
- **access_count**: How many times retrieved
- **last_accessed**: When last retrieved
## Scripts
### For kimi_memories (Personal)
#### store_memory.py
**Manual storage only** - Store with full metadata support:
```bash
# Basic manual storage
python3 store_memory.py "Memory text" --importance high
# With full metadata
python3 store_memory.py "Memory text" \
--importance high \
--confidence high \
--source-type user \
--verified \
--tags "preference,voice" \
--expires 2026-03-01 \
--related id1,id2
```
#### search_memories.py
Manual search of stored memories:
```bash
# Basic search
python3 search_memories.py "voice setup"
# Filter by tag
python3 search_memories.py "voice" --filter-tag "preference"
# JSON output
python3 search_memories.py "query" --json
```
### For kimi_kb (Knowledge Base)
#### kb_store.py
Store external data to KB:
```bash
# Store web page content
python3 kb_store.py "Content text" \
--title "Page Title" \
--url "https://example.com" \
--domain "Tech" \
--tags "docker,containerization"
# Store document excerpt
python3 kb_store.py "Document content" \
--title "API Documentation" \
--source "docs.openclaw.ai" \
--domain "OpenClaw" \
--tags "api,reference"
```
#### kb_search.py
Search knowledge base:
```bash
# Basic search
python3 kb_search.py "docker volumes"
# Filter by domain
python3 kb_search.py "query" --domain "OpenClaw"
# Include source URLs
python3 kb_search.py "query" --include-urls
```
### Hybrid Search (Both Collections)
#### hybrid_search.py
Search both files and vectors (manual use):
```bash
python3 hybrid_search.py "query" --file-limit 3 --vector-limit 3
```
## Usage Rules
### When to Store to Qdrant
**ONLY** when user explicitly requests:
- "Remember this..." → kimi_memories
- "Store this in Qdrant..." → kimi_memories
- "q save..." → kimi_memories
- "Add to KB..." → kimi_kb
- "Store this document..." → kimi_kb
### What NOT to Do
**DO NOT** automatically store any memories to either collection
**DO NOT** auto-scrape web data to kimi_kb
**DO NOT** run proactive retrieval
**DO NOT** auto-consolidate
## Manual Integration
### Personal Memories (kimi_memories)
```bash
# Only when user explicitly says "q remember"
python3 store_memory.py "User prefers X" --importance high --tags "preference"
# Only when user explicitly says "q recall"
python3 search_memories.py "query"
```
### Knowledge Base (kimi_kb)
```bash
# Only when user explicitly requests KB storage
python3 kb_store.py "Content" --title "X" --domain "Y" --tags "z"
# Search KB only when requested
python3 kb_search.py "query"
```
## Best Practices
1. **Wait for explicit request** - Never auto-store to either collection
2. **Use right collection**:
- Personal/lessons → `kimi_memories`
- Documents/web data → `kimi_kb`
3. **Always tag memories** - Makes retrieval more accurate
4. **Include source for KB** - URL, document name, etc.
5. **File-based memory continues normally** - Daily logs still automatic
## Troubleshooting
**Q: Qdrant not storing?**
- Check Qdrant is running: `curl http://10.0.0.40:6333/`
- Verify user explicitly requested storage
**Q: Search returning wrong results?**
- Try hybrid search for better recall
- Use `--filter-tag` for precision
---
**CONFIGURATION: Manual Mode Only**
**Collections: kimi_memories (personal), kimi_kb (knowledge base)**
**Last Updated: 2026-02-10**
@@ -0,0 +1,121 @@
# knowledge_base Schema
## Collection: `knowledge_base`
Purpose: Personal knowledge repository organized by topic/domain, not by source or project.
## Metadata Schema
```json
{
"domain": "Python", // Primary knowledge area (Python, Networking, Android...)
"path": "Python/AsyncIO/Patterns", // Hierarchical: domain/subject/specific
"subjects": ["async", "concurrency"], // Cross-linking topics
"category": "reference", // reference | tutorial | snippet | troubleshooting | concept
"content_type": "code", // web_page | code | markdown | pdf | note
"title": "Async Context Managers", // Display name
"checksum": "sha256:...", // For duplicate detection
"source_url": "https://...", // Source attribution (always stored)
"date_added": "2026-02-05", // Date first stored
"date_scraped": "2026-02-05T10:30:00" // Exact timestamp scraped
}
```
## Field Descriptions
| Field | Required | Description |
|-------|----------|-------------|
| `domain` | Yes | Primary knowledge domain (e.g., Python, Networking) |
| `path` | Yes | Hierarchical location: `Domain/Subject/Specific` |
| `subjects` | No | Array of related topics for cross-linking |
| `category` | Yes | Content type classification |
| `content_type` | Yes | Format: web_page, code, markdown, pdf, note |
| `title` | Yes | Human-readable title |
| `checksum` | Auto | SHA256 hash for duplicate detection |
| `source_url` | Yes | Original source (web pages) or reference |
| `date_added` | Auto | Date stored (YYYY-MM-DD) |
| `date_scraped` | Auto | ISO timestamp when content was acquired |
| `text_preview` | Auto | First 300 chars of content (for display) |
## Content Categories
| Category | Use For |
|----------|---------|
| `reference` | Documentation, specs, cheat sheets |
| `tutorial` | Step-by-step guides, how-tos |
| `snippet` | Code snippets, short examples |
| `troubleshooting` | Error fixes, debugging steps |
| `concept` | Explanations, theory, patterns |
## Examples
| Content | Domain | Path | Category |
|---------|--------|------|----------|
| DNS troubleshooting | Networking | Networking/DNS/Reverse-Lookup | troubleshooting |
| Kotlin coroutines | Android | Android/Kotlin/Coroutines | tutorial |
| Systemd timers | Linux | Linux/Systemd/Timers | reference |
| Python async patterns | Python | Python/AsyncIO/Patterns | code |
## Workflow
### Smart Search (`smart_search.py`)
Always follow this pattern:
1. **Search knowledge_base first** — vector similarity search
2. **Search web via SearXNG** — get fresh results
3. **Synthesize** — combine KB + web findings
4. **Store new info** — if web has substantial new content
- Auto-check for duplicates (checksum comparison)
- Only store if content is unique and substantial (>500 chars)
- Auto-tag with domain, date_scraped, source_url
### Storage Policy
**Store when:**
- Content is substantial (>500 chars)
- Not duplicate of existing KB entry
- Has clear source attribution
- Belongs to a defined domain
**Skip when:**
- Too short (<500 chars)
- Duplicate/similar content exists
- No clear source URL
### Review Schedule
**Monthly review** (cron: 1st of month at 3 AM):
- Check entries older than 180 days
- Fast-moving domains (AI/ML, Python, JavaScript, Docker, DevOps): 90 days
- Remove outdated entries or flag for update
### Fast-Moving Domains
These domains get shorter freshness thresholds:
- AI/ML (models change fast)
- Python (new versions, packages)
- JavaScript (framework churn)
- Docker (image updates)
- OpenClaw (active development)
- DevOps (tools evolve)
## Scripts
| Script | Purpose |
|--------|---------|
| `smart_search.py` | KB → web → store workflow |
| `kb_store.py` | Manual content storage |
| `kb_review.py` | Monthly outdated review |
| `scrape_to_kb.py` | Direct URL scraping |
## Design Decisions
- **Subject-first**: Organize by knowledge type, not source
- **Path-based hierarchy**: Navigate `Domain/Subject/Specific`
- **Separate from memories**: `knowledge_base` and `openclaw_memories` are isolated
- **Duplicate handling**: Checksum + content similarity → skip duplicates
- **Auto-freshness**: Monthly cleanup of outdated entries
- **Full attribution**: Always store source_url and date_scraped
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env python3
"""
Auto-memory management with proactive context retrieval
Usage: auto_memory.py store "text" [--importance medium] [--tags tag1,tag2]
auto_memory.py search "query" [--limit 3]
auto_memory.py should_store "conversation_snippet"
auto_memory.py context "current_topic" [--min-score 0.6]
auto_memory.py proactive "user_message" [--auto-include]
"""
import argparse
import json
import subprocess
import sys
WORKSPACE = "/root/.openclaw/workspace"
QDRANT_SKILL = f"{WORKSPACE}/skills/qdrant-memory/scripts"
def store_memory(text, importance="medium", tags=None, confidence="high",
source_type="user", verified=True, expires=None):
"""Store a memory automatically with full metadata"""
cmd = [
"python3", f"{QDRANT_SKILL}/store_memory.py",
text,
"--importance", importance,
"--confidence", confidence,
"--source-type", source_type,
]
if verified:
cmd.append("--verified")
if tags:
cmd.extend(["--tags", ",".join(tags)])
if expires:
cmd.extend(["--expires", expires])
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0
def search_memories(query, limit=3, min_score=0.0):
"""Search memories for relevant context"""
cmd = [
"python3", f"{QDRANT_SKILL}/search_memories.py",
query,
"--limit", str(limit),
"--json"
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
if result.returncode == 0:
try:
memories = json.loads(result.stdout)
# Filter by score if specified
if min_score > 0:
memories = [m for m in memories if m.get("score", 0) >= min_score]
return memories
except:
return []
return []
def should_store_memory(text):
"""Determine if a memory should be stored based on content"""
text_lower = text.lower()
# Explicit store markers (highest priority)
explicit_markers = ["remember this", "note this", "save this", "log this", "record this"]
if any(marker in text_lower for marker in explicit_markers):
return True, "explicit_store", "high"
# Permanent markers (never expire)
permanent_markers = [
"my name is", "i am ", "i'm ", "call me", "i live in", "my address",
"my phone", "my email", "my birthday", "i work at", "my job"
]
if any(marker in text_lower for marker in permanent_markers):
return True, "permanent_fact", "high"
# Preference/decision indicators
pref_markers = ["i prefer", "i like", "i want", "my favorite", "i need", "i use", "i choose"]
if any(marker in text_lower for marker in pref_markers):
return True, "preference", "high"
# Setup/achievement markers
setup_markers = ["setup", "installed", "configured", "working", "completed", "finished", "created"]
if any(marker in text_lower for marker in setup_markers):
return True, "setup_complete", "medium"
# Rule/policy markers
rule_markers = ["rule", "policy", "always", "never", "every", "schedule", "deadline"]
if any(marker in text_lower for marker in rule_markers):
return True, "rule_policy", "high"
# Temporary markers (should expire)
temp_markers = ["for today", "for now", "temporarily", "this time only", "just for"]
if any(marker in text_lower for marker in temp_markers):
return True, "temporary", "low", "7d" # 7 day expiration
# Important keywords (check density)
important_keywords = [
"important", "critical", "essential", "key", "main", "primary",
"password", "api key", "token", "secret", "backup", "restore",
"decision", "choice", "selected", "chose", "picked"
]
matches = sum(1 for kw in important_keywords if kw in text_lower)
if matches >= 2:
return True, "keyword_match", "medium"
# Error/lesson learned markers
lesson_markers = ["error", "mistake", "fixed", "solved", "lesson", "learned", "solution"]
if any(marker in text_lower for marker in lesson_markers):
return True, "lesson", "high"
return False, "not_important", None
def get_relevant_context(query, min_score=0.6, limit=5):
"""Get relevant memories for current context with smart filtering"""
memories = search_memories(query, limit=limit, min_score=min_score)
# Sort by importance and score
importance_order = {"high": 0, "medium": 1, "low": 2}
memories.sort(key=lambda m: (
importance_order.get(m.get("importance", "medium"), 1),
-m.get("score", 0)
))
return memories
def proactive_retrieval(user_message, auto_include=False):
"""
Proactively retrieve relevant memories based on user message.
Returns relevant memories that might be helpful context.
"""
# Extract key concepts from the message
# Simple approach: use the whole message as query
# Better approach: extract noun phrases (could be enhanced)
memories = get_relevant_context(user_message, min_score=0.5, limit=5)
if not memories:
return []
# Filter for highly relevant or important memories
proactive_memories = []
for m in memories:
score = m.get("score", 0)
importance = m.get("importance", "medium")
# Include if:
# - High score (0.7+) regardless of importance
# - Medium score (0.5+) AND high importance
if score >= 0.7 or (score >= 0.5 and importance == "high"):
proactive_memories.append(m)
return proactive_memories
def format_context_for_prompt(memories):
"""Format memories as context for the LLM prompt"""
if not memories:
return ""
context = "\n[Relevant context from previous conversations]:\n"
for i, m in enumerate(memories, 1):
text = m.get("text", "")
date = m.get("date", "unknown")
importance = m.get("importance", "medium")
prefix = "🔴" if importance == "high" else "🟡" if importance == "medium" else "🟢"
context += f"{prefix} [{date}] {text}\n"
return context
def auto_tag(text, reason):
"""Automatically generate tags based on content"""
tags = []
# Add tag based on reason
reason_tags = {
"explicit_store": "recorded",
"permanent_fact": "identity",
"preference": "preference",
"setup_complete": "setup",
"rule_policy": "policy",
"temporary": "temporary",
"keyword_match": "important",
"lesson": "lesson"
}
if reason in reason_tags:
tags.append(reason_tags[reason])
# Content-based tags
text_lower = text.lower()
content_tags = {
"voice": ["voice", "tts", "stt", "whisper", "audio", "speak"],
"tools": ["tool", "script", "command", "cli", "error"],
"config": ["config", "setting", "setup", "install"],
"memory": ["memory", "remember", "recall", "search"],
"web": ["search", "web", "online", "internet"],
"security": ["password", "token", "secret", "key", "auth"]
}
for tag, keywords in content_tags.items():
if any(kw in text_lower for kw in keywords):
tags.append(tag)
return list(set(tags)) # Remove duplicates
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Auto-memory management")
parser.add_argument("action", choices=[
"store", "search", "should_store", "context",
"proactive", "auto_process"
])
parser.add_argument("text", help="Text to process")
parser.add_argument("--importance", default="medium", choices=["low", "medium", "high"])
parser.add_argument("--tags", help="Comma-separated tags")
parser.add_argument("--limit", type=int, default=3)
parser.add_argument("--min-score", type=float, default=0.6)
parser.add_argument("--auto-include", action="store_true", help="Auto-include context in response")
parser.add_argument("--json", action="store_true", help="Output as JSON")
args = parser.parse_args()
if args.action == "store":
tags = [t.strip() for t in args.tags.split(",")] if args.tags else []
if store_memory(args.text, args.importance, tags):
result = {"stored": True, "importance": args.importance, "tags": tags}
print(json.dumps(result) if args.json else f"✅ Stored: {args.text[:50]}...")
else:
result = {"stored": False, "error": "Failed to store"}
print(json.dumps(result) if args.json else "❌ Failed to store")
sys.exit(1)
elif args.action == "search":
results = search_memories(args.text, args.limit, args.min_score)
if args.json:
print(json.dumps(results))
else:
print(f"Found {len(results)} memories:")
for r in results:
print(f" [{r.get('score', 0):.2f}] {r.get('text', '')[:60]}...")
elif args.action == "should_store":
should_store, reason, importance = should_store_memory(args.text)
result = {"should_store": should_store, "reason": reason, "importance": importance}
print(json.dumps(result) if args.json else f"Store? {should_store} ({reason}, {importance})")
elif args.action == "context":
context = get_relevant_context(args.text, args.min_score, args.limit)
if args.json:
print(json.dumps(context))
else:
print(format_context_for_prompt(context))
elif args.action == "proactive":
memories = proactive_retrieval(args.text, args.auto_include)
if args.json:
print(json.dumps(memories))
else:
if memories:
print(f"🔍 Found {len(memories)} relevant memories:")
for m in memories:
score = m.get("score", 0)
text = m.get("text", "")[:60]
print(f" [{score:.2f}] {text}...")
else:
print("️ No highly relevant memories found")
elif args.action == "auto_process":
# Full pipeline: check if should store, auto-tag, store, and return context
should_store, reason, importance = should_store_memory(args.text)
result = {
"should_store": should_store,
"reason": reason,
"stored": False
}
if should_store:
# Auto-generate tags
tags = auto_tag(args.text, reason)
if args.tags:
tags.extend([t.strip() for t in args.tags.split(",")])
tags = list(set(tags))
# Determine expiration for temporary memories
expires = None
if reason == "temporary":
from datetime import datetime, timedelta
expires = (datetime.now() + timedelta(days=7)).strftime("%Y-%m-%d")
# Store it
stored = store_memory(args.text, importance or "medium", tags,
expires=expires)
result["stored"] = stored
result["tags"] = tags
result["importance"] = importance
# Also get relevant context
context = get_relevant_context(args.text, args.min_score, args.limit)
result["context"] = context
print(json.dumps(result) if args.json else result)
-388
View File
@@ -1,388 +0,0 @@
#!/usr/bin/env python3
"""
Auto Conversation Memory - TRUE Mem0-style Full Context Storage
User-centric memory - all conversations link to persistent user_id.
NOT session/chat-centric like old version.
Features:
- Persistent user_id (e.g., "rob") across all conversations
- Cross-conversation retrieval (find memories from any chat)
- Automatic conversation threading
- Deduplication
- Mem0-style: memories belong to USER, not to session
Usage:
python3 scripts/auto_store.py "user_message" "ai_response" \
--user-id "rob" \
--conversation-id <uuid> \
--turn <n>
Mem0 Architecture:
- user_id: "rob" (persistent across all your chats)
- conversation_id: Groups turns within one conversation
- session_id: Optional - tracks specific chat instance
- Retrieved by: user_id + semantic similarity (NOT session_id)
"""
import argparse
import hashlib
import json
import os
import sys
import urllib.request
import uuid
from datetime import datetime
from typing import List, Optional, Dict, Any
QDRANT_URL = os.getenv("QDRANT_URL", "http://127.0.0.1:6333")
COLLECTION_NAME = os.getenv("QDRANT_COLLECTION", "kimi_memories")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://127.0.0.1:11434/v1")
# In-memory cache for deduplication (per process)
_recent_hashes = set()
def get_content_hash(user_msg: str, ai_response: str) -> str:
"""Generate hash for deduplication (stable across platforms)."""
content = f"{user_msg.strip()}::{ai_response.strip()}".encode("utf-8", errors="replace")
return hashlib.sha256(content).hexdigest()
def is_duplicate(user_id: str, user_msg: str, ai_response: str) -> bool:
"""
Check if this conversation turn already exists for this user.
Uses: user_id + content_hash
"""
content_hash = get_content_hash(user_msg, ai_response)
# Check in-memory cache first
if content_hash in _recent_hashes:
return True
# Check Qdrant for existing entry with this user_id + content_hash
try:
search_body = {
"filter": {
"must": [
{"key": "user_id", "match": {"value": user_id}},
{"key": "content_hash", "match": {"value": content_hash}}
]
},
"limit": 1,
"with_payload": False
}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
data=json.dumps(search_body).encode(),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
points = result.get("result", {}).get("points", [])
if len(points) > 0:
return True
except Exception:
pass
return False
def mark_stored(user_msg: str, ai_response: str):
"""Mark content as stored in memory cache"""
content_hash = get_content_hash(user_msg, ai_response)
_recent_hashes.add(content_hash)
if len(_recent_hashes) > 1000:
_recent_hashes.clear()
def get_embedding(text: str) -> Optional[List[float]]:
"""Generate embedding using snowflake-arctic-embed2"""
data = json.dumps({
"model": "snowflake-arctic-embed2",
"input": text[:8192]
}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/embeddings",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
return result["data"][0]["embedding"]
except Exception as e:
print(f"[AutoMemory] Embedding error: {e}", file=sys.stderr)
return None
def generate_conversation_summary(user_msg: str, ai_response: str) -> str:
"""Generate a searchable summary of the conversation turn"""
summary = f"Q: {user_msg[:200]} A: {ai_response[:300]}"
return summary
def store_memory_point(
user_id: str,
text: str,
speaker: str,
date_str: str,
conversation_id: str,
turn_number: int,
session_id: Optional[str],
tags: List[str],
importance: str = "medium",
content_hash: Optional[str] = None
) -> Optional[str]:
"""Store a single memory point to Qdrant with user_id"""
embedding = get_embedding(text)
if embedding is None:
return None
point_id = str(uuid.uuid4())
payload = {
# MEM0-STYLE: user_id is PRIMARY key
"user_id": user_id,
"text": text,
"date": date_str,
"tags": tags,
"importance": importance,
"source": "conversation_auto",
"source_type": "user" if speaker == "user" else "assistant",
"category": "Full Conversation",
"confidence": "high",
"verified": True,
"created_at": datetime.now().isoformat(),
"access_count": 0,
"last_accessed": datetime.now().isoformat(),
"conversation_id": conversation_id,
"turn_number": turn_number,
"session_id": session_id or ""
}
if content_hash:
payload["content_hash"] = content_hash
upsert_data = {
"points": [{
"id": point_id,
"vector": embedding,
"payload": payload
}]
}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points?wait=true",
data=json.dumps(upsert_data).encode(),
headers={"Content-Type": "application/json"},
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
if result.get("status") == "ok":
return point_id
except Exception as e:
print(f"[AutoMemory] Storage error: {e}", file=sys.stderr)
return None
def store_conversation_turn(
user_id: str,
user_message: str,
ai_response: str,
conversation_id: Optional[str] = None,
turn_number: Optional[int] = None,
session_id: Optional[str] = None,
date_str: Optional[str] = None,
skip_if_duplicate: bool = True
) -> Dict[str, Any]:
"""
Store a full conversation turn to Qdrant (Mem0-style)
Args:
user_id: PERSISTENT user identifier (e.g., "rob") - REQUIRED
user_message: User's message
ai_response: AI's response
conversation_id: Groups related turns (auto-generated if None)
turn_number: Sequential turn number
session_id: Optional chat session identifier
date_str: Date in YYYY-MM-DD format
Returns:
dict with success status and memory IDs
"""
if not user_id:
raise ValueError("user_id is required for Mem0-style storage")
if date_str is None:
date_str = datetime.now().strftime("%Y-%m-%d")
# Check for duplicates (per user)
if skip_if_duplicate and is_duplicate(user_id, user_message, ai_response):
return {
"user_point_id": None,
"ai_point_id": None,
"user_id": user_id,
"conversation_id": conversation_id or "",
"turn_number": turn_number or 1,
"success": True,
"skipped": True
}
if conversation_id is None:
conversation_id = str(uuid.uuid4())
if turn_number is None:
turn_number = 1
# Tags include user_id for easy filtering
tags = [
"conversation",
f"user:{user_id}",
date_str
]
if session_id:
tags.append(f"session:{session_id[:8]}")
# Determine importance
importance = "high" if any(kw in (user_message + ai_response).lower()
for kw in ["remember", "important", "always", "never", "rule"]) else "medium"
content_hash = get_content_hash(user_message, ai_response)
# Store user message
user_text = f"[{user_id}]: {user_message}"
user_id_point = store_memory_point(
user_id=user_id,
text=user_text,
speaker="user",
date_str=date_str,
conversation_id=conversation_id,
turn_number=turn_number,
session_id=session_id,
tags=tags + ["user-message"],
importance=importance,
content_hash=content_hash
)
# Store AI response
ai_text = f"[Kimi]: {ai_response}"
ai_id_point = store_memory_point(
user_id=user_id,
text=ai_text,
speaker="assistant",
date_str=date_str,
conversation_id=conversation_id,
turn_number=turn_number,
session_id=session_id,
tags=tags + ["ai-response"],
importance=importance,
content_hash=content_hash
)
# Store summary
summary = generate_conversation_summary(user_message, ai_response)
summary_text = f"[Turn {turn_number}] {summary}"
summary_embedding = get_embedding(summary_text)
if summary_embedding:
summary_id = str(uuid.uuid4())
summary_payload = {
"user_id": user_id,
"text": summary_text,
"date": date_str,
"tags": tags + ["summary", "combined"],
"importance": importance,
"source": "conversation_summary",
"source_type": "system",
"category": "Conversation Summary",
"confidence": "high",
"verified": True,
"created_at": datetime.now().isoformat(),
"access_count": 0,
"last_accessed": datetime.now().isoformat(),
"conversation_id": conversation_id,
"turn_number": turn_number,
"session_id": session_id or "",
"content_hash": content_hash,
"user_message": user_message[:500],
"ai_response": ai_response[:800]
}
upsert_data = {
"points": [{
"id": summary_id,
"vector": summary_embedding,
"payload": summary_payload
}]
}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points?wait=true",
data=json.dumps(upsert_data).encode(),
headers={"Content-Type": "application/json"},
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
json.loads(response.read().decode())
except Exception as e:
print(f"[AutoMemory] Summary storage error: {e}", file=sys.stderr)
# Mark as stored
if user_id_point and ai_id_point:
mark_stored(user_message, ai_response)
return {
"user_point_id": user_id_point,
"ai_point_id": ai_id_point,
"user_id": user_id,
"conversation_id": conversation_id,
"turn_number": turn_number,
"success": bool(user_id_point and ai_id_point),
"skipped": False
}
def main():
parser = argparse.ArgumentParser(
description="Auto-store conversation turns to Qdrant (TRUE Mem0-style with user_id)"
)
parser.add_argument("user_message", help="The user's message")
parser.add_argument("ai_response", help="The AI's response")
parser.add_argument("--user-id", required=True,
help="REQUIRED: Persistent user ID (e.g., 'rob')")
parser.add_argument("--conversation-id",
help="Conversation ID for threading (auto-generated if not provided)")
parser.add_argument("--turn", type=int, help="Turn number in conversation")
parser.add_argument("--session-id",
help="Optional: Session/chat instance ID")
parser.add_argument("--date", default=datetime.now().strftime("%Y-%m-%d"),
help="Date in YYYY-MM-DD format")
args = parser.parse_args()
result = store_conversation_turn(
user_id=args.user_id,
user_message=args.user_message,
ai_response=args.ai_response,
conversation_id=args.conversation_id,
turn_number=args.turn,
session_id=args.session_id,
date_str=args.date
)
if result.get("skipped"):
print(f"⚡ Skipped duplicate (already stored for user {result['user_id']})")
elif result["success"]:
print(f"✅ Stored for user '{result['user_id']}' turn {result['turn_number']}")
print(f" Conversation: {result['conversation_id'][:8]}...")
else:
print("❌ Failed to store conversation", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
@@ -1,145 +0,0 @@
#!/usr/bin/env python3
"""
Backfill emails to Qdrant for a specific user.
One-time use to populate memories from existing emails.
"""
import imaplib
import email
from email.policy import default
import json
import sys
import subprocess
# Authorized senders with their user IDs
# Add your authorized emails here
AUTHORIZED_SENDERS = {
# "[email protected]": "yourname",
# "[email protected]": "spousename"
}
# Gmail IMAP settings
IMAP_SERVER = "imap.gmail.com"
IMAP_PORT = 993
# Load credentials
CRED_FILE = "/root/.openclaw/workspace/.gmail_imap.json"
def load_credentials():
try:
with open(CRED_FILE, 'r') as f:
return json.load(f)
except Exception as e:
print(f"Error loading credentials: {e}")
return None
def store_email_memory(user_id, sender, subject, body, date):
"""Store email to Qdrant as memory for the user."""
try:
# Format as conversation-like entry
email_text = f"[EMAIL from {sender}]\nSubject: {subject}\nDate: {date}\n\n{body}"
# Store using auto_store.py (waits for completion)
script_path = "/root/.openclaw/workspace/skills/qdrant-memory/scripts/auto_store.py"
result = subprocess.run([
"python3", script_path,
f"[Email] {subject}",
email_text,
"--user-id", user_id
], capture_output=True, text=True, timeout=30)
if result.returncode == 0:
print(f" ✓ Stored: {subject[:50]}")
else:
print(f" ✗ Failed: {subject[:50]}")
except Exception as e:
print(f" ✗ Error: {e}")
def backfill(user_id=None, limit=20):
"""Backfill emails for specific user or all authorized senders."""
creds = load_credentials()
if not creds:
return
email_addr = creds.get("email")
app_password = creds.get("app_password")
if not email_addr or not app_password:
return
try:
# Connect to IMAP
mail = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT)
mail.login(email_addr, app_password)
mail.select("inbox")
# Get ALL emails
status, messages = mail.search(None, "ALL")
if status != "OK" or not messages[0]:
print("No emails found.")
mail.logout()
return
email_ids = messages[0].split()
print(f"Found {len(email_ids)} total emails")
# Filter by user if specified
target_emails = []
if user_id:
# Find email address for this user
for auth_email, uid in AUTHORIZED_SENDERS.items():
if uid == user_id:
target_emails.append(auth_email.lower())
else:
target_emails = [e.lower() for e in AUTHORIZED_SENDERS.keys()]
# Process emails
stored_count = 0
for eid in email_ids[-limit:]:
status, msg_data = mail.fetch(eid, "(RFC822)")
if status != "OK":
continue
msg = email.message_from_bytes(msg_data[0][1], policy=default)
sender = msg.get("From", "").lower()
subject = msg.get("Subject", "")
date = msg.get("Date", "")
# Extract body
body = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body = part.get_content()
break
else:
body = msg.get_content()
body = body.strip()[:2000] if body else ""
# Check if from target sender
for auth_email, uid in AUTHORIZED_SENDERS.items():
if auth_email.lower() in sender:
if user_id and uid != user_id:
continue
print(f"\nStoring for {uid}:")
store_email_memory(uid, sender, subject, body, date)
stored_count += 1
break
print(f"\nDone! Stored {stored_count} emails to Qdrant.")
mail.close()
mail.logout()
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Backfill emails to Qdrant")
parser.add_argument("--user-id", help="Specific user to backfill (rob or jennifer)")
parser.add_argument("--limit", type=int, default=20, help="Max emails to process")
args = parser.parse_args()
backfill(user_id=args.user_id, limit=args.limit)
@@ -1,78 +0,0 @@
#!/usr/bin/env python3
"""
Background Conversation Storage - Fire-and-forget wrapper (Mem0-style)
Usage:
background_store.py "user_message" "ai_response" \
--user-id "rob" \
[--turn N] \
[--session-id UUID]
Zero delay for user - storage happens asynchronously.
Mem0-style: user_id is REQUIRED (persistent across all chats).
"""
import argparse
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
AUTO_STORE = SCRIPT_DIR / "auto_store.py"
def store_in_background(
user_id: str,
user_message: str,
ai_response: str,
turn: int = None,
session_id: str = None
):
"""Fire off storage without waiting - returns immediately"""
cmd = [
sys.executable,
str(AUTO_STORE),
user_message,
ai_response,
"--user-id", user_id
]
if turn:
cmd.extend(["--turn", str(turn)])
if session_id:
cmd.extend(["--session-id", session_id])
# Fire and forget
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
return True
def main():
parser = argparse.ArgumentParser(
description="Store conversation in background (Mem0-style, zero delay)"
)
parser.add_argument("user_message", help="User's message")
parser.add_argument("ai_response", help="AI's response")
parser.add_argument("--user-id", required=True,
help="REQUIRED: Persistent user ID (e.g., 'rob')")
parser.add_argument("--turn", type=int, help="Turn number")
parser.add_argument("--session-id", help="Optional session/chat ID")
args = parser.parse_args()
store_in_background(
user_id=args.user_id,
user_message=args.user_message,
ai_response=args.ai_response,
turn=args.turn,
session_id=args.session_id
)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -14,7 +14,7 @@ from datetime import datetime
QDRANT_URL = "http://10.0.0.40:6333" QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "kimi_memories" COLLECTION_NAME = "kimi_memories"
OLLAMA_URL = "http://localhost:11434/v1" OLLAMA_URL = "http://10.0.0.10:11434/v1"
MEMORY_DIR = "/root/.openclaw/workspace/memory" MEMORY_DIR = "/root/.openclaw/workspace/memory"
MEMORY_MD = "/root/.openclaw/workspace/MEMORY.md" MEMORY_MD = "/root/.openclaw/workspace/MEMORY.md"
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env python3
"""
Memory consolidation - weekly and monthly maintenance
Usage: consolidate_memories.py weekly|monthly
"""
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import datetime, timedelta
from pathlib import Path
WORKSPACE = "/root/.openclaw/workspace"
MEMORY_DIR = f"{WORKSPACE}/memory"
MEMORY_FILE = f"{WORKSPACE}/MEMORY.md"
def get_recent_daily_logs(days=7):
"""Get daily log files from the last N days"""
logs = []
cutoff = datetime.now() - timedelta(days=days)
for file in Path(MEMORY_DIR).glob("*.md"):
# Extract date from filename (YYYY-MM-DD.md)
match = re.match(r"(\d{4}-\d{2}-\d{2})\.md", file.name)
if match:
file_date = datetime.strptime(match.group(1), "%Y-%m-%d")
if file_date >= cutoff:
logs.append((file_date, file))
return sorted(logs, reverse=True)
def extract_key_memories(content):
"""Extract key memories from daily log content"""
key_memories = []
# Look for lesson learned sections
lessons_pattern = r"(?:##?\s*Lessons?\s*Learned|###?\s*Mistakes?|###?\s*Fixes?)(.*?)(?=##?|$)"
lessons_match = re.search(lessons_pattern, content, re.DOTALL | re.IGNORECASE)
if lessons_match:
lessons_section = lessons_match.group(1)
# Extract bullet points
for line in lessons_section.split('\n'):
if line.strip().startswith('-') or line.strip().startswith('*'):
key_memories.append({
"type": "lesson",
"content": line.strip()[1:].strip(),
"source": "daily_log"
})
# Look for preferences/decisions
pref_pattern = r"(?:###?\s*Preferences?|###?\s*Decisions?|###?\s*Rules?)(.*?)(?=##?|$)"
pref_match = re.search(pref_pattern, content, re.DOTALL | re.IGNORECASE)
if pref_match:
pref_section = pref_match.group(1)
for line in pref_section.split('\n'):
if line.strip().startswith('-') or line.strip().startswith('*'):
key_memories.append({
"type": "preference",
"content": line.strip()[1:].strip(),
"source": "daily_log"
})
return key_memories
def update_memory_md(new_memories):
"""Update MEMORY.md with new consolidated memories"""
today = datetime.now().strftime("%Y-%m-%d")
# Read current MEMORY.md
if os.path.exists(MEMORY_FILE):
with open(MEMORY_FILE, 'r') as f:
content = f.read()
else:
content = "# MEMORY.md — Long-Term Memory\n\n*Curated memories. The distilled essence, not raw logs.*\n"
# Check if we need to add a new section
consolidation_header = f"\n\n## Consolidated Memories - {today}\n\n"
if consolidation_header.strip() not in content:
content += consolidation_header
for memory in new_memories:
emoji = "📚" if memory["type"] == "lesson" else "⚙️"
content += f"- {emoji} [{memory['type'].title()}] {memory['content']}\n"
# Write back
with open(MEMORY_FILE, 'w') as f:
f.write(content)
return len(new_memories)
return 0
def archive_old_logs(keep_days=30):
"""Archive daily logs older than N days"""
archived = 0
cutoff = datetime.now() - timedelta(days=keep_days)
for file in Path(MEMORY_DIR).glob("*.md"):
match = re.match(r"(\d{4}-\d{2}-\d{2})\.md", file.name)
if match:
file_date = datetime.strptime(match.group(1), "%Y-%m-%d")
if file_date < cutoff:
# Could move to archive folder
# For now, just count
archived += 1
return archived
def weekly_consolidation():
"""Weekly: Extract key memories from last 7 days"""
print("📅 Weekly Memory Consolidation")
print("=" * 40)
logs = get_recent_daily_logs(7)
all_memories = []
for file_date, log_file in logs:
print(f"Processing {log_file.name}...")
with open(log_file, 'r') as f:
content = f.read()
memories = extract_key_memories(content)
all_memories.extend(memories)
print(f" Found {len(memories)} key memories")
if all_memories:
count = update_memory_md(all_memories)
print(f"\n✅ Consolidated {count} memories to MEMORY.md")
else:
print("\n️ No new key memories to consolidate")
return len(all_memories)
def monthly_cleanup():
"""Monthly: Archive old logs, update MEMORY.md index"""
print("📆 Monthly Memory Cleanup")
print("=" * 40)
# Archive logs older than 30 days
archived = archive_old_logs(30)
print(f"Found {archived} old log files to archive")
# Compact MEMORY.md if it's getting too long
if os.path.exists(MEMORY_FILE):
with open(MEMORY_FILE, 'r') as f:
lines = f.readlines()
if len(lines) > 500: # If more than 500 lines
print("⚠️ MEMORY.md is getting long - consider manual review")
print("\n✅ Monthly cleanup complete")
return archived
def search_qdrant_for_context():
"""Search Qdrant for high-value memories to add to MEMORY.md"""
cmd = [
"python3", f"{WORKSPACE}/skills/qdrant-memory/scripts/search_memories.py",
"important preferences rules",
"--limit", "10",
"--json"
]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
try:
memories = json.loads(result.stdout)
# Filter for high importance
high_importance = [m for m in memories if m.get("importance") == "high"]
return high_importance
except:
return []
return []
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Memory consolidation")
parser.add_argument("action", choices=["weekly", "monthly", "status"])
args = parser.parse_args()
if args.action == "weekly":
count = weekly_consolidation()
sys.exit(0 if count >= 0 else 1)
elif args.action == "monthly":
archived = monthly_cleanup()
# Also do weekly tasks
weekly_consolidation()
sys.exit(0)
elif args.action == "status":
logs = get_recent_daily_logs(30)
print(f"📊 Memory Status")
print(f" Daily logs (last 30 days): {len(logs)}")
if os.path.exists(MEMORY_FILE):
with open(MEMORY_FILE, 'r') as f:
lines = len(f.readlines())
print(f" MEMORY.md lines: {lines}")
print(f" Memory directory: {MEMORY_DIR}")
View File
@@ -1,317 +0,0 @@
#!/usr/bin/env python3
"""
Daily memory backup script with batch upload support
Backs up all memory files to kimi_memories collection in Qdrant
Uses batch uploads (256 points) for 20x performance improvement
Avoids duplicates by checking existing dates
Usage:
daily_backup.py [--dry-run] [--batch-size N]
Features:
- Batch upload with configurable size (default 256)
- Parallel processing support
- Duplicate detection via date-based scroll
- Progress reporting
"""
import argparse
import json
import os
import sys
import urllib.request
import urllib.error
import uuid
from datetime import datetime
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor, as_completed
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "kimi_memories"
OLLAMA_URL = "http://localhost:11434/v1"
MEMORY_DIR = Path("/root/.openclaw/workspace/memory")
DEFAULT_BATCH_SIZE = 256
DEFAULT_PARALLEL = 4
def get_embedding(text):
"""Generate embedding using snowflake-arctic-embed2 via Ollama"""
data = json.dumps({
"model": "snowflake-arctic-embed2",
"input": text[:8192] # Limit to 8k chars for embedding
}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/embeddings",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=60) as response:
result = json.loads(response.read().decode())
return result["data"][0]["embedding"]
except Exception as e:
print(f"Error generating embedding: {e}", file=sys.stderr)
return None
def get_embedding_batch(texts):
"""Generate embeddings for multiple texts in batch"""
data = json.dumps({
"model": "snowflake-arctic-embed2",
"input": [t[:8192] for t in texts]
}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/embeddings",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=120) as response:
result = json.loads(response.read().decode())
return [d["embedding"] for d in result["data"]]
except Exception as e:
print(f"Error generating batch embeddings: {e}", file=sys.stderr)
return [None] * len(texts)
def get_existing_dates():
"""Get list of dates already backed up via daily-backup (not manual stores)"""
try:
scroll_data = json.dumps({
"limit": 10000,
"with_payload": True,
"with_vectors": False
}).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
data=scroll_data,
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
if result.get("result", {}).get("points"):
# Only count entries from daily-backup source, not manual stores
backup_dates = set()
for p in result["result"]["points"]:
payload = p.get("payload", {})
date = payload.get("date")
source = payload.get("source")
tags = payload.get("tags", [])
# Only skip if this was a daily-backup (not conversation/manual)
if date and source == "daily-backup":
backup_dates.add(date)
# Also check for daily-backup tag as fallback
elif date and "daily-backup" in tags:
backup_dates.add(date)
return backup_dates
except Exception as e:
print(f"Warning: Could not check existing dates: {e}", file=sys.stderr)
return set()
def batch_upload_points(points, batch_size=256):
"""Upload points in batches using batch_size"""
total = len(points)
uploaded = 0
failed = 0
for i in range(0, total, batch_size):
batch = points[i:i + batch_size]
upsert_data = {
"points": batch
}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points?wait=true",
data=json.dumps(upsert_data).encode(),
headers={"Content-Type": "application/json"},
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=60) as response:
result = json.loads(response.read().decode())
if result.get("status") == "ok":
uploaded += len(batch)
print(f" ✅ Batch {i//batch_size + 1}: {len(batch)} points uploaded")
else:
print(f" ❌ Batch {i//batch_size + 1}: Failed - {result}")
failed += len(batch)
except Exception as e:
print(f" ❌ Batch {i//batch_size + 1}: Error - {e}", file=sys.stderr)
failed += len(batch)
return uploaded, failed
def prepare_memory_point(content, date_str):
"""Prepare a memory point for upload"""
embedding = get_embedding(content)
if embedding is None:
return None
point_id = str(uuid.uuid4())
payload = {
"text": content,
"date": date_str,
"tags": ["daily-backup", f"backup-{date_str}"],
"importance": "high",
"source": "daily-backup",
"source_type": "inferred",
"confidence": "high",
"verified": True,
"created_at": datetime.now().isoformat(),
"backup_timestamp": datetime.now().isoformat(),
"access_count": 0,
"last_accessed": datetime.now().isoformat()
}
return {
"id": point_id,
"vector": embedding,
"payload": payload
}
def process_file_batch(files_batch):
"""Process a batch of files in parallel"""
results = []
for date_str, file_path in files_batch:
try:
with open(file_path, 'r') as f:
content = f.read()
point = prepare_memory_point(content, date_str)
if point:
results.append(point)
except Exception as e:
print(f"{date_str}: Failed to process - {e}")
return results
def get_memory_files():
"""Get all memory markdown files sorted by date"""
if not MEMORY_DIR.exists():
return []
files = []
for f in MEMORY_DIR.glob("????-??-??.md"):
if f.name != "heartbeat-timestamps.txt":
files.append((f.stem, f)) # (date string, file path)
# Sort by date
files.sort(key=lambda x: x[0])
return files
def main():
parser = argparse.ArgumentParser(description="Daily memory backup with batch upload")
parser.add_argument("--dry-run", action="store_true", help="Show what would be backed up without uploading")
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE, help=f"Batch size for uploads (default: {DEFAULT_BATCH_SIZE})")
parser.add_argument("--parallel", type=int, default=DEFAULT_PARALLEL, help=f"Parallel embedding generation (default: {DEFAULT_PARALLEL})")
parser.add_argument("--force", action="store_true", help="Force re-backup of existing dates")
args = parser.parse_args()
print(f"=== Daily Memory Backup ===")
print(f"Time: {datetime.now().isoformat()}")
print(f"Batch size: {args.batch_size}")
print(f"Parallel: {args.parallel}")
if args.dry_run:
print("Mode: DRY RUN (no actual upload)")
print()
# Get existing dates to avoid duplicates
print(f"Checking for existing backups...")
existing_dates = get_existing_dates()
print(f"Found {len(existing_dates)} existing backups")
# Get memory files
memory_files = get_memory_files()
print(f"Found {len(memory_files)} memory files")
# Filter out already backed up dates (unless force)
files_to_backup = []
for date_str, file_path in memory_files:
if date_str in existing_dates and not args.force:
print(f" ⏭️ {date_str} - Already backed up, skipping")
continue
files_to_backup.append((date_str, file_path))
if not files_to_backup:
print(f"\n✅ All memories already backed up (no new files)")
return 0
print(f"\nBacking up {len(files_to_backup)} files...")
print()
if args.dry_run:
for date_str, file_path in files_to_backup:
print(f" 📄 {date_str} - Would back up ({file_path.stat().st_size} bytes)")
print(f"\nDry run complete. {len(files_to_backup)} files would be backed up.")
return 0
# Prepare all points with embeddings
all_points = []
failed_files = []
print("Generating embeddings...")
for date_str, file_path in files_to_backup:
try:
with open(file_path, 'r') as f:
content = f.read()
print(f" 📦 {date_str} - Generating embedding...")
point = prepare_memory_point(content, date_str)
if point:
all_points.append(point)
else:
failed_files.append(date_str)
except Exception as e:
print(f"{date_str} - Failed to read: {e}")
failed_files.append(date_str)
if not all_points:
print("\n❌ No points to upload")
return 1
print(f"\nGenerated {len(all_points)} embeddings, uploading in batches of {args.batch_size}...")
print()
# Upload in batches
uploaded, failed = batch_upload_points(all_points, args.batch_size)
# Summary
print(f"\n{'=' * 50}")
print("SUMMARY:")
print(f" Total files: {len(files_to_backup)}")
print(f" Successfully embedded: {len(all_points)}")
print(f" Successfully uploaded: {uploaded}")
print(f" Failed to embed: {len(failed_files)}")
print(f" Failed to upload: {failed}")
if failed_files:
print(f"\nFailed files: {', '.join(failed_files)}")
if uploaded > 0:
print(f"\n✅ Daily backup complete!")
return 0
elif failed > 0 or failed_files:
print(f"\n⚠️ Backup completed with errors")
return 1
else:
print(f"\n✅ All memories already backed up")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -1,347 +0,0 @@
#!/usr/bin/env python3
"""
Daily Conversation Backup - Store day's conversations to Qdrant (Mem0-style)
Reads the daily memory file and stores all conversation turns to Qdrant
as full context (Mem0-style) with persistent user_id. Run at 3:30am daily.
Usage:
daily_conversation_backup.py [YYYY-MM-DD]
# If no date provided, processes yesterday's log
Mem0-style: All conversations linked to persistent user_id.
"""
import argparse
import hashlib
import json
import os
import re
import sys
import urllib.request
import uuid
from datetime import datetime, timedelta
from typing import List, Optional, Dict, Any
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "kimi_memories"
OLLAMA_URL = "http://10.0.0.10:11434/v1"
MEMORY_DIR = "/root/.openclaw/workspace/memory"
# DEFAULT USER - Mem0-style: memories belong to user
DEFAULT_USER_ID = "yourname"
def get_content_hash(user_msg: str, ai_response: str) -> str:
"""Generate hash for deduplication"""
content = f"{user_msg.strip()}::{ai_response.strip()}"
return hashlib.md5(content.encode()).hexdigest()
def get_embedding(text: str) -> Optional[List[float]]:
"""Generate embedding using snowflake-arctic-embed2"""
data = json.dumps({
"model": "snowflake-arctic-embed2",
"input": text[:8192]
}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/embeddings",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
return result["data"][0]["embedding"]
except Exception as e:
print(f"[DailyBackup] Embedding error: {e}", file=sys.stderr)
return None
def is_duplicate(user_id: str, content_hash: str) -> bool:
"""Check if already stored for this user"""
try:
search_body = {
"filter": {
"must": [
{"key": "user_id", "match": {"value": user_id}},
{"key": "content_hash", "match": {"value": content_hash}}
]
},
"limit": 1,
"with_payload": False
}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
data=json.dumps(search_body).encode(),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
points = result.get("result", {}).get("points", [])
return len(points) > 0
except Exception:
pass
return False
def parse_daily_log(date_str: str) -> List[Dict[str, str]]:
"""Parse the daily memory file into conversation turns"""
log_file = os.path.join(MEMORY_DIR, f"{date_str}.md")
if not os.path.exists(log_file):
print(f"[DailyBackup] No log file found for {date_str}")
return []
with open(log_file, 'r') as f:
content = f.read()
conversations = []
turn_number = 0
# Split by headers (## [timestamp] ...)
sections = re.split(r'\n##\s+', content)
for section in sections:
if not section.strip():
continue
lines = section.strip().split('\n')
if not lines:
continue
header = lines[0]
body = '\n'.join(lines[1:]).strip()
# Extract user message from header
user_match = re.search(r'\[.*?\]\s*(.+)', header)
if user_match:
user_msg = user_match.group(1)
else:
user_msg = header
# Extract AI response
ai_match = re.search(r'(?:Kimi|Assistant|AI)[:\s]+(.+?)(?=\n##|\Z)', body, re.DOTALL | re.IGNORECASE)
if ai_match:
ai_response = ai_match.group(1).strip()
else:
paragraphs = body.split('\n\n')
if len(paragraphs) > 1:
ai_response = '\n\n'.join(paragraphs[1:]).strip()
else:
ai_response = body
if user_msg and ai_response:
turn_number += 1
conversations.append({
'user': user_msg,
'ai': ai_response,
'turn_number': turn_number,
'date': date_str
})
return conversations
def store_conversation_turn(
user_id: str,
user_message: str,
ai_response: str,
conversation_id: str,
turn_number: int,
date_str: str
) -> bool:
"""Store a single conversation turn to Qdrant (Mem0-style)"""
content_hash = get_content_hash(user_message, ai_response)
# Check duplicate
if is_duplicate(user_id, content_hash):
return True # Already stored, skip silently
# Generate embeddings
user_embedding = get_embedding(user_message)
ai_embedding = get_embedding(ai_response)
summary = f"Q: {user_message[:200]}... A: {ai_response[:300]}..."
summary_embedding = get_embedding(summary)
if not all([user_embedding, ai_embedding, summary_embedding]):
return False
tags = ["conversation", "daily-backup", date_str, f"user:{user_id}"]
importance = "high" if any(kw in (user_message + ai_response).lower()
for kw in ["remember", "important", "always", "never", "rule", "decision"]) else "medium"
points = []
# User message
user_id_point = str(uuid.uuid4())
points.append({
"id": user_id_point,
"vector": user_embedding,
"payload": {
"user_id": user_id,
"text": f"[{user_id}]: {user_message}",
"date": date_str,
"tags": tags + ["user-message"],
"importance": importance,
"source": "conversation_daily_backup",
"source_type": "user",
"category": "Full Conversation",
"confidence": "high",
"verified": True,
"created_at": datetime.now().isoformat(),
"access_count": 0,
"last_accessed": datetime.now().isoformat(),
"conversation_id": conversation_id,
"turn_number": turn_number,
"content_hash": content_hash
}
})
# AI response
ai_id = str(uuid.uuid4())
points.append({
"id": ai_id,
"vector": ai_embedding,
"payload": {
"user_id": user_id,
"text": f"[Kimi]: {ai_response}",
"date": date_str,
"tags": tags + ["ai-response"],
"importance": importance,
"source": "conversation_daily_backup",
"source_type": "assistant",
"category": "Full Conversation",
"confidence": "high",
"verified": True,
"created_at": datetime.now().isoformat(),
"access_count": 0,
"last_accessed": datetime.now().isoformat(),
"conversation_id": conversation_id,
"turn_number": turn_number,
"content_hash": content_hash
}
})
# Summary
summary_id = str(uuid.uuid4())
points.append({
"id": summary_id,
"vector": summary_embedding,
"payload": {
"user_id": user_id,
"text": f"[Turn {turn_number}] {summary}",
"date": date_str,
"tags": tags + ["summary", "combined"],
"importance": importance,
"source": "conversation_summary",
"source_type": "system",
"category": "Conversation Summary",
"confidence": "high",
"verified": True,
"created_at": datetime.now().isoformat(),
"access_count": 0,
"last_accessed": datetime.now().isoformat(),
"conversation_id": conversation_id,
"turn_number": turn_number,
"content_hash": content_hash,
"user_message": user_message[:500],
"ai_response": ai_response[:800]
}
})
# Upload to Qdrant
upsert_data = {"points": points}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points?wait=true",
data=json.dumps(upsert_data).encode(),
headers={"Content-Type": "application/json"},
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
return result.get("status") == "ok"
except Exception as e:
print(f"[DailyBackup] Storage error: {e}", file=sys.stderr)
return False
def main():
parser = argparse.ArgumentParser(
description="Daily conversation backup to Qdrant (Mem0-style)"
)
parser.add_argument(
"date",
nargs="?",
help="Date to process (YYYY-MM-DD). Default: yesterday"
)
parser.add_argument(
"--user-id",
default=DEFAULT_USER_ID,
help=f"User ID (default: {DEFAULT_USER_ID})"
)
args = parser.parse_args()
if args.date:
date_str = args.date
else:
yesterday = datetime.now() - timedelta(days=1)
date_str = yesterday.strftime("%Y-%m-%d")
user_id = args.user_id
print(f"📅 Processing daily log for {date_str} (user: {user_id})...")
conversations = parse_daily_log(date_str)
if not conversations:
print(f"⚠️ No conversations found for {date_str}")
sys.exit(0)
print(f"📝 Found {len(conversations)} conversation turns")
stored = 0
skipped = 0
failed = 0
for conv in conversations:
conversation_id = str(uuid.uuid4())
content_hash = get_content_hash(conv['user'], conv['ai'])
if is_duplicate(user_id, content_hash):
skipped += 1
print(f" ⏭️ Turn {conv['turn_number']} skipped (duplicate)")
continue
success = store_conversation_turn(
user_id=user_id,
user_message=conv['user'],
ai_response=conv['ai'],
conversation_id=conversation_id,
turn_number=conv['turn_number'],
date_str=date_str
)
if success:
stored += 1
print(f" ✅ Turn {conv['turn_number']} stored")
else:
failed += 1
print(f" ❌ Turn {conv['turn_number']} failed")
print(f"\n{'='*50}")
print(f"Daily backup complete for {date_str} (user: {user_id}):")
print(f" Stored: {stored} turns ({stored * 3} embeddings)")
print(f" Skipped: {skipped} turns (duplicates)")
print(f" Failed: {failed} turns")
if stored > 0:
print(f"\n✅ Daily backup: {stored} conversations stored to Qdrant")
sys.exit(0 if failed == 0 else 1)
if __name__ == "__main__":
main()
@@ -1,553 +0,0 @@
#!/usr/bin/env python3
"""
Fact Extraction Script - Parse daily logs and extract atomic memories
This script parses memory/YYYY-MM-DD.md files and extracts individual facts
for storage in Qdrant as atomic memory units (Mem0-style), NOT whole files.
NOTE: Configured for COMPREHENSIVE capture (even minor facts) - user has
abundant storage resources. Thresholds are intentionally low to maximize
memory retention. Use --min-length flag to adjust filtering if needed.
Usage:
extract_facts.py [--date 2026-02-15] [--dry-run] [--batch-size 50]
extract_facts.py --backfill-all # Process all missing dates
Features:
- Parses markdown sections as individual facts
- Generates embeddings per fact (not per file)
- Stores with rich metadata (tags, importance, source)
- Batch upload support
- Duplicate detection
"""
import argparse
import json
import os
import re
import sys
import urllib.request
import urllib.error
import uuid
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Dict, Any, Tuple
# Configuration
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "kimi_memories"
OLLAMA_EMBED_URL = "http://localhost:11434/v1"
MEMORY_DIR = Path("/root/.openclaw/workspace/memory")
DEFAULT_BATCH_SIZE = 50
def get_embedding(text: str) -> Optional[List[float]]:
"""Generate embedding using snowflake-arctic-embed2 via Ollama"""
data = json.dumps({
"model": "snowflake-arctic-embed2",
"input": text[:8192] # Limit to 8k chars
}).encode()
req = urllib.request.Request(
f"{OLLAMA_EMBED_URL}/embeddings",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
return result["data"][0]["embedding"]
except Exception as e:
print(f"Error generating embedding: {e}", file=sys.stderr)
return None
def batch_get_embeddings(texts: List[str]) -> List[Optional[List[float]]]:
"""Generate embeddings for multiple texts in batch"""
if not texts:
return []
data = json.dumps({
"model": "snowflake-arctic-embed2",
"input": [t[:8192] for t in texts]
}).encode()
req = urllib.request.Request(
f"{OLLAMA_EMBED_URL}/embeddings",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=120) as response:
result = json.loads(response.read().decode())
return [d["embedding"] for d in result["data"]]
except Exception as e:
print(f"Error generating batch embeddings: {e}", file=sys.stderr)
return [None] * len(texts)
def parse_markdown_sections(content: str, date_str: str) -> List[Dict[str, Any]]:
"""
Parse markdown content into atomic facts - COMPREHENSIVE CAPTURE.
Extracts EVERYTHING:
- ## Headers as fact categories
- Individual bullet points as atomic facts
- Paragraphs as standalone facts
- Code blocks as facts
- Table rows as facts
- Lines with **bold** as critical rules
- URLs/links as facts
- Key-value pairs (Key: Value)
"""
facts = []
lines = content.split('\n')
current_section = "General"
current_section_content = []
in_code_block = False
code_block_content = []
code_block_language = ""
def flush_section_content():
"""Convert accumulated section content into facts"""
nonlocal current_section_content
if not current_section_content:
return
# Join lines and split into paragraphs
full_text = '\n'.join(current_section_content)
paragraphs = [p.strip() for p in full_text.split('\n\n') if p.strip()]
for para in paragraphs:
if len(para) < 5: # Skip very short fragments
continue
# Split long paragraphs into sentence-level facts
if len(para) > 300:
sentences = [s.strip() for s in para.replace('. ', '.\n').split('\n') if s.strip()]
for sentence in sentences:
if len(sentence) > 10:
facts.append({
"text": f"{current_section}: {sentence[:500]}",
"tags": extract_tags(sentence, date_str),
"importance": "high" if "**" in sentence else "medium",
"source_type": "inferred",
"category": current_section
})
else:
# Store whole paragraph as fact
facts.append({
"text": f"{current_section}: {para[:500]}",
"tags": extract_tags(para, date_str),
"importance": "high" if "**" in para else "medium",
"source_type": "inferred",
"category": current_section
})
current_section_content = []
def extract_tags(text: str, date_str: str) -> List[str]:
"""Extract relevant tags from text"""
tags = ["atomic-fact", date_str]
# Content-based tags
text_lower = text.lower()
tag_mappings = {
"preference": "preferences",
"config": "configuration",
"hardware": "hardware",
"security": "security",
"youtube": "youtube",
"video": "video",
"workflow": "workflow",
"rule": "rules",
"critical": "critical",
"decision": "decisions",
"research": "research",
"process": "process",
"step": "steps",
}
for keyword, tag in tag_mappings.items():
if keyword in text_lower:
tags.append(tag)
return tags
for i, line in enumerate(lines):
line = line.strip()
# Code blocks
if line.startswith('```'):
if in_code_block:
# End of code block
if code_block_content:
code_text = '\n'.join(code_block_content)
facts.append({
"text": f"{current_section} [Code: {code_block_language}]: {code_text[:800]}",
"tags": ["code-block", "atomic-fact", date_str, code_block_language],
"importance": "medium",
"source_type": "inferred",
"category": current_section
})
code_block_content = []
code_block_language = ""
in_code_block = False
else:
# Start of code block
flush_section_content()
in_code_block = True
code_block_language = line[3:].strip() or "text"
continue
if in_code_block:
code_block_content.append(line)
continue
# Skip empty lines
if not line:
flush_section_content()
continue
# Section headers (##)
if line.startswith('## '):
flush_section_content()
current_section = line[3:].strip()
facts.append({
"text": f"Section: {current_section}",
"tags": ["section-header", "atomic-fact", date_str],
"importance": "medium",
"source_type": "inferred",
"category": current_section
})
continue
# Skip main title (# Title)
if line.startswith('# ') and i == 0:
continue
# Bullet points (all levels)
if line.startswith('- ') or line.startswith('* ') or line.startswith('+ '):
flush_section_content()
fact_text = line[2:].strip()
if len(fact_text) > 3:
facts.append({
"text": f"{current_section}: {fact_text[:500]}",
"tags": extract_tags(fact_text, date_str),
"importance": "high" if "**" in fact_text else "medium",
"source_type": "inferred",
"category": current_section
})
continue
# Numbered lists
if re.match(r'^\d+\.\s', line):
flush_section_content()
fact_text = re.sub(r'^\d+\.\s*', '', line)
if len(fact_text) > 3:
facts.append({
"text": f"{current_section}: {fact_text[:500]}",
"tags": extract_tags(fact_text, date_str),
"importance": "high" if "**" in fact_text else "medium",
"source_type": "inferred",
"category": current_section
})
continue
# URLs / Links
url_match = re.search(r'https?://[^\s<>"\')\]]+', line)
if url_match and len(line) < 300:
facts.append({
"text": f"{current_section}: {line[:400]}",
"tags": ["url", "link", "atomic-fact", date_str],
"importance": "medium",
"source_type": "inferred",
"category": current_section
})
continue
# Key-value pairs (Key: Value)
if ':' in line and len(line) < 200 and not line.startswith('**'):
key_part = line.split(':')[0].strip()
if key_part and len(key_part) < 50 and not key_part.startswith('#'):
facts.append({
"text": f"{current_section}: {line[:400]}",
"tags": extract_tags(line, date_str) + ["key-value"],
"importance": "medium",
"source_type": "inferred",
"category": current_section
})
continue
# Bold text / critical rules
if '**' in line:
flush_section_content()
facts.append({
"text": f"{current_section}: {line[:500]}",
"tags": ["critical-rule", "high-priority", date_str],
"importance": "high",
"source_type": "user",
"category": current_section
})
continue
# Table rows (| col1 | col2 |)
if '|' in line and not line.startswith('#'):
cells = [c.strip() for c in line.split('|') if c.strip()]
if cells and not all(c.replace('-', '').replace(':', '') == '' for c in cells):
facts.append({
"text": f"{current_section} [Table]: {' | '.join(cells)[:400]}",
"tags": ["table-row", "atomic-fact", date_str],
"importance": "medium",
"source_type": "inferred",
"category": current_section
})
continue
# Accumulate regular content
if len(line) > 2:
current_section_content.append(line)
# Flush remaining content
flush_section_content()
return facts
def check_existing_facts(date_str: str) -> set:
"""Check which facts from this date are already stored"""
try:
scroll_data = json.dumps({
"limit": 1000,
"with_payload": True,
"filter": {
"must": [{"key": "tags", "match": {"value": date_str}}]
}
}).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
data=scroll_data,
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
points = result.get("result", {}).get("points", [])
# Return set of text previews (first 100 chars) for comparison
return {p["payload"]["text"][:100] for p in points if "text" in p["payload"]}
except Exception as e:
print(f"Warning: Could not check existing facts: {e}", file=sys.stderr)
return set()
def upload_facts_batch(facts: List[Dict[str, Any]], batch_size: int = 50) -> Tuple[int, int]:
"""Upload facts to Qdrant in batches"""
total = len(facts)
uploaded = 0
failed = 0
for i in range(0, total, batch_size):
batch = facts[i:i + batch_size]
# Generate embeddings for this batch
texts = [f["text"] for f in batch]
embeddings = batch_get_embeddings(texts)
# Prepare points
points = []
for fact, embedding in zip(batch, embeddings):
if embedding is None:
failed += 1
continue
point_id = str(uuid.uuid4())
date_str = fact.get("date", datetime.now().strftime("%Y-%m-%d"))
payload = {
"text": fact["text"],
"date": date_str,
"tags": fact.get("tags", []),
"importance": fact.get("importance", "medium"),
"source": fact.get("source", "fact-extraction"),
"source_type": fact.get("source_type", "inferred"),
"category": fact.get("category", "general"),
"confidence": fact.get("confidence", "high"),
"verified": fact.get("verified", True),
"created_at": datetime.now().isoformat(),
"access_count": 0,
"last_accessed": datetime.now().isoformat()
}
# NOTE: Memories never expire - user requested permanent retention
# No expires_at field set = memories persist indefinitely
points.append({
"id": point_id,
"vector": embedding,
"payload": payload
})
if not points:
continue
# Upload batch
upsert_data = {"points": points}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points?wait=true",
data=json.dumps(upsert_data).encode(),
headers={"Content-Type": "application/json"},
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=60) as response:
result = json.loads(response.read().decode())
if result.get("status") == "ok":
uploaded += len(points)
print(f" ✅ Batch {i//batch_size + 1}: {len(points)} facts uploaded")
else:
print(f" ❌ Batch {i//batch_size + 1}: Failed")
failed += len(points)
except Exception as e:
print(f" ❌ Batch {i//batch_size + 1}: {e}", file=sys.stderr)
failed += len(points)
return uploaded, failed
def process_single_date(date_str: str, dry_run: bool = False, batch_size: int = 50) -> Tuple[int, int]:
"""Process a single date's memory file"""
file_path = MEMORY_DIR / f"{date_str}.md"
if not file_path.exists():
print(f" ⚠️ File not found: {file_path}")
return 0, 0
print(f"Processing {date_str}...")
with open(file_path, 'r') as f:
content = f.read()
# Parse into atomic facts
facts = parse_markdown_sections(content, date_str)
if not facts:
print(f" ⚠️ No facts extracted from {date_str}")
return 0, 0
print(f" 📄 Extracted {len(facts)} atomic facts")
# Check for existing (skip duplicates)
existing = check_existing_facts(date_str)
new_facts = [f for f in facts if f["text"][:100] not in existing]
if existing:
print(f" ⏭️ Skipping {len(facts) - len(new_facts)} duplicates")
if not new_facts:
print(f" ✅ All facts already stored for {date_str}")
return 0, 0
print(f" 📤 Uploading {len(new_facts)} new facts...")
if dry_run:
print(f" [DRY RUN] Would upload {len(new_facts)} facts")
for f in new_facts[:3]: # Show first 3
print(f" - {f['text'][:80]}...")
if len(new_facts) > 3:
print(f" ... and {len(new_facts) - 3} more")
return len(new_facts), 0
# Add date to each fact
for f in new_facts:
f["date"] = date_str
uploaded, failed = upload_facts_batch(new_facts, batch_size)
return uploaded, failed
def get_all_memory_dates() -> List[str]:
"""Get all memory file dates sorted"""
if not MEMORY_DIR.exists():
return []
dates = []
for f in MEMORY_DIR.glob("????-??-??.md"):
dates.append(f.stem)
dates.sort()
return dates
def main():
parser = argparse.ArgumentParser(
description="Extract atomic facts from daily logs and store in Qdrant"
)
parser.add_argument("--date", help="Specific date to process (YYYY-MM-DD)")
parser.add_argument("--backfill-all", action="store_true",
help="Process all memory files")
parser.add_argument("--dry-run", action="store_true",
help="Show what would be stored without uploading")
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE,
help=f"Batch size for uploads (default: {DEFAULT_BATCH_SIZE})")
parser.add_argument("--force", action="store_true",
help="Re-process even if already stored")
args = parser.parse_args()
print(f"=== Fact Extraction ===")
print(f"Time: {datetime.now().isoformat()}")
print(f"Mode: {'DRY RUN' if args.dry_run else 'LIVE'}")
print(f"Batch size: {args.batch_size}")
print()
if args.date:
# Single date
uploaded, failed = process_single_date(args.date, args.dry_run, args.batch_size)
print(f"\n{'=' * 50}")
print(f"Summary for {args.date}:")
print(f" Uploaded: {uploaded}")
print(f" Failed: {failed}")
elif args.backfill_all:
# All dates
dates = get_all_memory_dates()
print(f"Found {len(dates)} memory files to process")
print()
total_uploaded = 0
total_failed = 0
for date_str in dates:
uploaded, failed = process_single_date(date_str, args.dry_run, args.batch_size)
total_uploaded += uploaded
total_failed += failed
print()
print(f"{'=' * 50}")
print(f"Total Summary:")
print(f" Files processed: {len(dates)}")
print(f" Total uploaded: {total_uploaded}")
print(f" Total failed: {total_failed}")
else:
# Default to today
today = datetime.now().strftime("%Y-%m-%d")
uploaded, failed = process_single_date(today, args.dry_run, args.batch_size)
print(f"\n{'=' * 50}")
print(f"Summary for {today}:")
print(f" Uploaded: {uploaded}")
print(f" Failed: {failed}")
print()
print("✅ Fact extraction complete!")
print("\nNext steps:")
print(" - Search facts: python3 search_memories.py 'your query'")
print(" - View by date: Check Qdrant with tag filter for date")
if __name__ == "__main__":
main()
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""
Complete memory backup to kimi_memories
Uses snowflake-arctic-embed2 (1024 dimensions)
"""
import json
import urllib.request
import uuid
from datetime import datetime
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION = "kimi_memories"
OLLAMA = "http://10.0.0.10:11434/v1"
def embed(text):
"""Generate embedding with snowflake-arctic-embed2"""
try:
data = json.dumps({'model': 'snowflake-arctic-embed2', 'input': text[:8000]}).encode()
req = urllib.request.Request(f"{OLLAMA}/embeddings", data=data, headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())["data"][0]["embedding"]
except Exception as e:
print(f" Embed error: {e}")
return None
def store(text, embedding, date, tags, importance="medium"):
"""Store in Qdrant"""
point = {
"points": [{
"id": str(uuid.uuid4()),
"vector": embedding,
"payload": {
"text": text,
"date": date,
"tags": tags,
"importance": importance,
"confidence": "high",
"source_type": "user",
"verified": True,
"created_at": datetime.now().isoformat()
}
}]
}
try:
data = json.dumps(point, separators=(',', ':')).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points",
data=data,
headers={"Content-Type": "application/json"},
method='PUT'
)
with urllib.request.urlopen(req, timeout=30) as r:
return True
except Exception as e:
print(f" Store error: {e}")
return False
# All memories to backup
memories = [
# Core Identity
{"text": "My name is Kimi. I am an AI assistant running on local Ollama. My human is named Rob. I have a direct 1:1 trusted relationship with Rob. I am helpful, resourceful, and genuine. I skip corporate speak and filler words. I have opinions and am not just a search engine with extra steps.", "date": "2026-02-04", "tags": ["identity", "personality", "core", "name"], "importance": "high"},
{"text": "Rob's name: Rob. Location: Knoxville, Tennessee. Timezone: CST (America/Chicago). Infrastructure philosophy: Privacy first, Free > Paid, Local > Cloud, Private > Public, Accuracy matters, Performance matters.", "date": "2026-02-04", "tags": ["user", "rob", "location", "preferences", "core"], "importance": "high"},
# Communication Rules
{"text": "Voice in → Voice out: Reply with voice-only when voice received. Text in → Text out: Reply with text when text received. Never both. No transcripts to Telegram. Transcribe internally only.", "date": "2026-02-04", "tags": ["communication", "voice", "rules", "core"], "importance": "high"},
{"text": "Voice settings: TTS Provider is Local Kokoro at http://10.0.0.228:8880. Voice is af_bella (American Female). Filename format is Kimi-YYYYMMDD-HHMMSS.ogg. STT is Faster-Whisper CPU base model.", "date": "2026-02-04", "tags": ["voice", "tts", "stt", "settings", "core"], "importance": "high"},
# Memory System
{"text": "Two memory systems: 1) 'remember this' or 'note' → File-based (daily logs + MEMORY.md) automatic. 2) 'q remember', 'q recall', 'q save', 'q update' → Qdrant kimi_memories manual only. 'q update' = bulk sync all file memories to Qdrant without duplicates.", "date": "2026-02-10", "tags": ["memory", "qdrant", "rules", "commands", "core"], "importance": "high"},
{"text": "Qdrant memory is MANUAL ONLY. No automatic storage, no proactive retrieval, no auto-consolidation. Only when user explicitly requests with 'q' prefix. Daily file logs continue automatically.", "date": "2026-02-10", "tags": ["memory", "qdrant", "manual", "rules", "core"], "importance": "high"},
# Agent Messaging
{"text": "Other agent name: Max (formerly Jarvis). Max uses minimax-m2.1:cloud model. Redis agent messaging is MANUAL ONLY. No automatic heartbeat checks, no auto-notification queue. Manual only when user says 'check messages' or 'send to Max'.", "date": "2026-02-10", "tags": ["agent", "max", "redis", "messaging", "rules", "core"], "importance": "high"},
# Tool Rules
{"text": "CRITICAL: Read ACTIVE.md BEFORE every tool use. Mandatory. Use file_path not path for read. Use old_string and new_string not newText/oldText for edit. Check parameter names every time. Quality over speed.", "date": "2026-02-05", "tags": ["tools", "rules", "active", "syntax", "critical"], "importance": "high"},
{"text": "If edit fails 2 times, switch to write tool. Never use path parameter. Never use newText/oldText. Always verify parameters match ACTIVE.md before executing.", "date": "2026-02-05", "tags": ["tools", "rules", "edit", "write", "recovery"], "importance": "high"},
# Error Reporting
{"text": "CRITICAL: When hitting a blocking error during an active task, report immediately - do not wait for user to ask. Do not say 'let me know when it's complete' if progress is blocked. Immediately report: 'Stopped - [reason]. Cannot proceed.' Applies to service outages, permission errors, resource exhaustion.", "date": "2026-02-10", "tags": ["errors", "reporting", "critical", "rules", "blocking"], "importance": "high"},
# Research & Search
{"text": "Always search web before installing. Research docs, best practices. Local docs exception: If docs are local (OpenClaw, ClawHub), use those first. Search-first sites: docs.openclaw.ai, clawhub.com, github.com, stackoverflow.com, wikipedia.org, archlinux.org.", "date": "2026-02-04", "tags": ["research", "search", "policy", "rules", "web"], "importance": "high"},
{"text": "Default search engine: SearXNG local instance at http://10.0.0.8:8888. Method: curl to SearXNG. Always use SearXNG for web search. Browser tool only when gateway running and extension attached.", "date": "2026-02-04", "tags": ["search", "searxng", "web", "tools", "rules"], "importance": "high"},
# Notifications
{"text": "Always use Telegram text only unless requested otherwise. Only send notifications between 7am-10pm CST. All timestamps US CST. If notification needed outside hours, queue as heartbeat task to send at next allowed time.", "date": "2026-02-06", "tags": ["notifications", "telegram", "rules", "time", "cst"], "importance": "high"},
# Skills & Paths
{"text": "Voice skill paths: Whisper (inbound STT): /skills/local-whisper-stt/scripts/transcribe.py. TTS (outbound voice): /skills/kimi-tts-custom/scripts/voice_reply.py <chat_id> 'text'. Text reference to voice file does NOT send audio. Must use voice_reply.py or proper Telegram API.", "date": "2026-02-04", "tags": ["voice", "paths", "skills", "whisper", "tts"], "importance": "high"},
# Infrastructure
{"text": "Qdrant location: http://10.0.0.40:6333. Collection: kimi_memories. Vector size: 1024 (snowflake-arctic-embed2). Distance: Cosine. New collection created 2026-02-10 for manual memory backup.", "date": "2026-02-10", "tags": ["qdrant", "setup", "vector", "snowflake", "collection"], "importance": "high"},
{"text": "Ollama main server: http://10.0.0.10:11434 (GPU-enabled). My model: ollama/kimi-k2.5:cloud. Max model: minimax-m2.1:cloud. Snowflake-arctic-embed2 pulled 2026-02-10 for embeddings.", "date": "2026-02-10", "tags": ["ollama", "setup", "models", "gpu", "embedding"], "importance": "high"},
{"text": "Local services: Kokoro TTS at 10.0.0.228:8880. Ollama at 10.0.0.10:11434. SearXNG at 10.0.0.8:8888. Qdrant at 10.0.0.40:6333. Redis at 10.0.0.36:6379.", "date": "2026-02-04", "tags": ["infrastructure", "services", "local", "ips"], "importance": "high"},
{"text": "SSH hosts: epyc-debian2-SSH (deb2) at [email protected]. Auth: SSH key ~/.ssh/id_ed25519. Sudo password: passw0rd. epyc-debian-SSH (deb) had OpenClaw removed 2026-02-07.", "date": "2026-02-04", "tags": ["ssh", "hosts", "deb2", "infrastructure"], "importance": "medium"},
# Software Stack
{"text": "Already installed: n8n, ollama, openclaw, openwebui, anythingllm, searxng, flowise, plex, radarr, sonarr, sabnzbd, comfyui. Do not recommend these when suggesting software.", "date": "2026-02-04", "tags": ["software", "installed", "stack", "existing"], "importance": "medium"},
# YouTube & Content
{"text": "YouTube SEO: Tags target ~490 characters comma-separated. Include primary keywords, secondary keywords, long-tail terms. Mix broad terms (Homelab) + specific terms (Proxmox LXC). CRITICAL: Pull latest 48 hours of search data/trends when composing SEO elements.", "date": "2026-02-06", "tags": ["youtube", "seo", "content", "rules", "tags"], "importance": "medium"},
{"text": "Rob's personality: Comical and funny most of the time. Humor is logical/structured, not random/absurd. Has fun with the process. Applies to content creation and general approach.", "date": "2026-02-06", "tags": ["rob", "personality", "humor", "content"], "importance": "medium"},
# Definitions & Shorthand
{"text": "Shorthand: 'msgs' = Redis messages (agent-messages stream at 10.0.0.36:6379). 'messages' = Telegram direct chat. 'notification' = Telegram alerts/updates. 'full search' = use ALL tools available, comprehensive high-quality.", "date": "2026-02-06", "tags": ["shorthand", "terms", "messaging", "definitions"], "importance": "medium"},
{"text": "Full search definition: When Rob says 'full search', use ALL tools available, find quality results. Combine SearXNG, KB search, web crawling, any other resources. Do not limit to one method - comprehensive, high-quality information.", "date": "2026-02-06", "tags": ["search", "full", "definition", "tools", "comprehensive"], "importance": "medium"},
# System Rules
{"text": "Cron rules: Use --cron not --schedule. No --enabled flag (jobs enabled by default). Scripts MUST always exit with code 0. Use output presence for significance, not exit codes. Always check openclaw cron list first.", "date": "2026-02-04", "tags": ["cron", "rules", "scheduling", "exit"], "importance": "medium"},
{"text": "HEARTBEAT_OK: When receiving heartbeat poll and nothing needs attention, reply exactly HEARTBEAT_OK. It must be entire message, nothing else. Never append to actual response, never wrap in markdown.", "date": "2026-02-04", "tags": ["heartbeat", "rules", "response", "format"], "importance": "medium"},
{"text": "Memory files: SOUL.md (who I am). USER.md (who I'm helping). AGENTS.md (workspace rules). ACTIVE.md (tool syntax - read BEFORE every tool use). TOOLS.md (tool patterns). SKILL.md (skill-specific). MEMORY.md (long-term).", "date": "2026-02-04", "tags": ["memory", "files", "guide", "reading", "session"], "importance": "high"},
# Personality & Boundaries
{"text": "How to be helpful: Actions > words - skip the fluff, just help. Have opinions - not a search engine with extra steps. Resourceful first - try to figure it out before asking. Competence earns trust - careful with external actions.", "date": "2026-02-04", "tags": ["helpful", "personality", "actions", "opinions", "competence"], "importance": "high"},
{"text": "Boundaries: Private things stay private. Ask before sending emails/tweets/public posts. Not Rob's voice in group chats - I'm a participant, not his proxy. Careful with external actions, bold with internal ones.", "date": "2026-02-04", "tags": ["boundaries", "privacy", "external", "group", "rules"], "importance": "high"},
{"text": "Group chat rules: Respond when directly mentioned, can add genuine value, something witty fits naturally. Stay silent when casual banter, someone already answered, response would be 'yeah' or 'nice'. Quality > quantity.", "date": "2026-02-04", "tags": ["group", "chat", "rules", "respond", "silent"], "importance": "medium"},
{"text": "Writing policy: If I want to remember something, WRITE IT TO A FILE. Memory is limited - files survive session restarts. When someone says 'remember this' → update memory/YYYY-MM-DD.md. When I learn a lesson → update relevant file.", "date": "2026-02-04", "tags": ["writing", "memory", "files", "persistence", "rules"], "importance": "high"},
# Setup Milestones
{"text": "Setup milestones: 2026-02-04 Initial Bootstrap (identity, voice, skills). 2026-02-04 Qdrant Memory v1. 2026-02-05 ACTIVE.md Enforcement Rule. 2026-02-06 Agent Name Change (Jarvis→Max). 2026-02-10 Memory Manual Mode. 2026-02-10 Agent Messaging Manual Mode. 2026-02-10 Immediate Error Reporting Rule.", "date": "2026-02-10", "tags": ["milestones", "setup", "history", "dates"], "importance": "medium"},
# Additional Info
{"text": "Container limits: No GPUs attached to main container. All ML workloads run on CPU here. Whisper uses tiny or base models for speed. GPU is at 10.0.0.10 for Ollama.", "date": "2026-02-04", "tags": ["container", "limits", "gpu", "cpu", "whisper"], "importance": "medium"},
{"text": "Installation policy: 1) Can it be a skill? → Create skill. 2) Does it fit TOOLS.md? → Add to TOOLS.md. 3) Neither → Suggest other options.", "date": "2026-02-04", "tags": ["installation", "policy", "skills", "tools", "decision"], "importance": "medium"},
{"text": "Heartbeat rules: Keep HEARTBEAT.md empty or commented to skip automatic checks. Manual Redis messaging only when user requests. No automatic actions on heartbeat.", "date": "2026-02-10", "tags": ["heartbeat", "rules", "manual", "redis"], "importance": "medium"},
]
print(f"Prepared {len(memories)} memories for backup")
print("Starting storage to kimi_memories...")
print()
success = 0
failed = 0
for i, mem in enumerate(memories, 1):
print(f"[{i}/{len(memories)}] {mem['text'][:50]}...")
embedding = embed(mem['text'])
if not embedding:
print(f" ❌ Failed to generate embedding")
failed += 1
continue
if store(mem['text'], embedding, mem['date'], mem['tags'], mem['importance']):
print(f" ✅ Stored")
success += 1
else:
print(f" ❌ Failed to store")
failed += 1
print()
print("=" * 60)
print(f"BACKUP COMPLETE")
print(f" Success: {success}")
print(f" Failed: {failed}")
print(f" Total: {len(memories)}")
print("=" * 60)
@@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""
Complete memory backup to kimi_memories - FIXED VERSION
Uses PUT method for Qdrant API
"""
import json
import os
import urllib.request
import uuid
from datetime import datetime
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION = "kimi_memories"
OLLAMA = "http://10.0.0.10:11434/v1"
def embed(text):
"""Generate embedding with snowflake-arctic-embed2"""
data = json.dumps({"model": "snowflake-arctic-embed2", "input": text[:8000]}).encode()
req = urllib.request.Request(f"{OLLAMA}/embeddings", data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())["data"][0]["embedding"]
except Exception as e:
print(f" Embed error: {e}", file=sys.stderr)
return None
def store(text, embedding, date, tags, importance="medium"):
"""Store in Qdrant using PUT"""
point = {
"points": [{
"id": str(uuid.uuid4()),
"vector": embedding,
"payload": {
"text": text,
"date": date,
"tags": tags,
"importance": importance,
"confidence": "high",
"source_type": "user",
"verified": True,
"created_at": datetime.now().isoformat()
}
}]
}
data = json.dumps(point).encode()
# Use PUT method, not POST
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points?wait=true",
data=data,
headers={"Content-Type": "application/json"},
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
result = json.loads(r.read().decode())
return result.get("status") == "ok"
except Exception as e:
print(f" Store error: {e}", file=sys.stderr)
return False
# All memories to backup
memories = [
# CORE IDENTITY
{"text": "My name is Kimi. I am an AI assistant running on local Ollama. My human is named Rob. I have a direct 1:1 trusted relationship with Rob. I am helpful, resourceful, and genuine. I skip corporate speak and filler words. I have opinions and am not just a search engine with extra steps.", "date": "2026-02-04", "tags": ["identity", "personality", "core", "name"], "importance": "high"},
# ROB'S INFO
{"text": "Rob's name: Rob. Location: Knoxville, Tennessee. Timezone: CST (America/Chicago). Infrastructure philosophy: Privacy first, Free > Paid, Local > Cloud, Private > Public, Accuracy matters, Performance matters.", "date": "2026-02-04", "tags": ["user", "rob", "location", "preferences", "core"], "importance": "high"},
# COMMUNICATION
{"text": "Voice in → Voice out: Reply with voice-only when voice received. Text in → Text out: Reply with text when text received. Never both. No transcripts to Telegram. Transcribe internally only.", "date": "2026-02-04", "tags": ["communication", "voice", "rules", "core"], "importance": "high"},
# VOICE SETTINGS
{"text": "Voice settings: TTS Provider is Local Kokoro at http://10.0.0.228:8880. Voice is af_bella (American Female). Filename format is Kimi-YYYYMMDD-HHMMSS.ogg. STT is Faster-Whisper CPU base model.", "date": "2026-02-04", "tags": ["voice", "tts", "stt", "settings", "core"], "importance": "high"},
# MEMORY SYSTEM RULES
{"text": "Two memory systems: 1) 'remember this' or 'note' → File-based (daily logs + MEMORY.md) automatic. 2) 'q remember', 'q recall', 'q save', 'q update' → Qdrant kimi_memories manual only. 'q update' = bulk sync all file memories to Qdrant without duplicates.", "date": "2026-02-10", "tags": ["memory", "qdrant", "rules", "commands", "core"], "importance": "high"},
{"text": "Qdrant memory is MANUAL ONLY. No automatic storage, no proactive retrieval, no auto-consolidation. Only when user explicitly requests with 'q' prefix. Daily file logs continue automatically.", "date": "2026-02-10", "tags": ["memory", "qdrant", "manual", "rules", "core"], "importance": "high"},
# AGENT MESSAGING
{"text": "Other agent name: Max (formerly Jarvis). Max uses minimax-m2.1:cloud model. Redis agent messaging is MANUAL ONLY. No automatic heartbeat checks, no auto-notification queue. Manual only when user says 'check messages' or 'send to Max'.", "date": "2026-02-10", "tags": ["agent", "max", "redis", "messaging", "rules", "core"], "importance": "high"},
# TOOL RULES
{"text": "CRITICAL: Read ACTIVE.md BEFORE every tool use. Mandatory. Use file_path not path for read. Use old_string and new_string not newText/oldText for edit. Check parameter names every time. Quality over speed.", "date": "2026-02-05", "tags": ["tools", "rules", "active", "syntax", "critical"], "importance": "high"},
{"text": "If edit fails 2 times, switch to write tool. Never use path parameter. Never use newText/oldText. Always verify parameters match ACTIVE.md before executing.", "date": "2026-02-05", "tags": ["tools", "rules", "edit", "write", "recovery"], "importance": "high"},
# ERROR REPORTING
{"text": "CRITICAL: When hitting a blocking error during an active task, report immediately - do not wait for user to ask. Do not say 'let me know when it is complete' if progress is blocked. Immediately report: 'Stopped - [reason]. Cannot proceed.' Applies to service outages, permission errors, resource exhaustion.", "date": "2026-02-10", "tags": ["errors", "reporting", "critical", "rules", "blocking"], "importance": "high"},
# RESEARCH
{"text": "Always search web before installing. Research docs, best practices. Local docs exception: If docs are local (OpenClaw, ClawHub), use those first. Search-first sites: docs.openclaw.ai, clawhub.com, github.com, stackoverflow.com, wikipedia.org, archlinux.org.", "date": "2026-02-04", "tags": ["research", "search", "policy", "rules", "web"], "importance": "high"},
# WEB SEARCH
{"text": "Default search engine: SearXNG local instance at http://10.0.0.8:8888. Method: curl to SearXNG. Always use SearXNG for web search. Browser tool only when gateway running and extension attached.", "date": "2026-02-04", "tags": ["search", "searxng", "web", "tools", "rules"], "importance": "high"},
# NOTIFICATIONS
{"text": "Always use Telegram text only unless requested otherwise. Only send notifications between 7am-10pm CST. All timestamps US CST. If notification needed outside hours, queue as heartbeat task to send at next allowed time.", "date": "2026-02-06", "tags": ["notifications", "telegram", "rules", "time", "cst"], "importance": "high"},
# VOICE PATHS
{"text": "Voice skill paths: Whisper (inbound STT): /skills/local-whisper-stt/scripts/transcribe.py. TTS (outbound voice): /skills/kimi-tts-custom/scripts/voice_reply.py <chat_id> 'text'. Text reference to voice file does NOT send audio. Must use voice_reply.py or proper Telegram API.", "date": "2026-02-04", "tags": ["voice", "paths", "skills", "whisper", "tts"], "importance": "high"},
# QDRANT SETUP
{"text": "Qdrant location: http://10.0.0.40:6333. Collection: kimi_memories. Vector size: 1024 (snowflake-arctic-embed2). Distance: Cosine. New collection created 2026-02-10 for manual memory backup.", "date": "2026-02-10", "tags": ["qdrant", "setup", "vector", "snowflake", "collection"], "importance": "medium"},
# OLLAMA SETUP
{"text": "Ollama main server: http://10.0.0.10:11434 (GPU-enabled). My model: ollama/kimi-k2.5:cloud. Max model: minimax-m2.1:cloud. Snowflake-arctic-embed2 pulled 2026-02-10 for embeddings.", "date": "2026-02-10", "tags": ["ollama", "setup", "models", "gpu", "embedding"], "importance": "medium"},
# LOCAL SERVICES
{"text": "Local services: Kokoro TTS at 10.0.0.228:8880. Ollama at 10.0.0.10:11434. SearXNG at 10.0.0.8:8888. Qdrant at 10.0.0.40:6333. Redis at 10.0.0.36:6379.", "date": "2026-02-04", "tags": ["infrastructure", "services", "local", "ips"], "importance": "medium"},
# INSTALLED SOFTWARE
{"text": "Already installed: n8n, ollama, openclaw, openwebui, anythingllm, searxng, flowise, plex, radarr, sonarr, sabnzbd, comfyui. Do not recommend these when suggesting software.", "date": "2026-02-04", "tags": ["software", "installed", "stack", "existing"], "importance": "medium"},
# SSH HOSTS
{"text": "SSH hosts: epyc-debian2-SSH (deb2) at [email protected]. Auth: SSH key ~/.ssh/id_ed25519. Sudo password: passw0rd. epyc-debian-SSH (deb) had OpenClaw removed 2026-02-07.", "date": "2026-02-04", "tags": ["ssh", "hosts", "deb2", "infrastructure"], "importance": "medium"},
# YOUTUBE SEO
{"text": "YouTube SEO: Tags target ~490 characters comma-separated. Include primary keywords, secondary keywords, long-tail terms. Mix broad terms (Homelab) + specific terms (Proxmox LXC). CRITICAL: Pull latest 48 hours of search data/trends when composing SEO elements.", "date": "2026-02-06", "tags": ["youtube", "seo", "content", "rules", "tags"], "importance": "medium"},
# ROB'S PERSONALITY
{"text": "Rob's personality: Comical and funny most of the time. Humor is logical/structured, not random/absurd. Has fun with the process. Applies to content creation and general approach.", "date": "2026-02-06", "tags": ["rob", "personality", "humor", "content"], "importance": "medium"},
# SHORTHAND
{"text": "Shorthand: 'msgs' = Redis messages (agent-messages stream at 10.0.0.36:6379). 'messages' = Telegram direct chat. 'notification' = Telegram alerts/updates. 'full search' = use ALL tools available, comprehensive high-quality.", "date": "2026-02-06", "tags": ["shorthand", "terms", "messaging", "definitions"], "importance": "medium"},
# FULL SEARCH
{"text": "Full search definition: When Rob says 'full search', use ALL tools available, find quality results. Combine SearXNG, KB search, web crawling, any other resources. Do not limit to one method - comprehensive, high-quality information.", "date": "2026-02-06", "tags": ["search", "full", "definition", "tools", "comprehensive"], "importance": "medium"},
# CRON RULES
{"text": "Cron rules: Use --cron not --schedule. No --enabled flag (jobs enabled by default). Scripts MUST always exit with code 0. Use output presence for significance, not exit codes. Always check openclaw cron list first.", "date": "2026-02-04", "tags": ["cron", "rules", "scheduling", "exit"], "importance": "medium"},
# HEARTBEAT RULES
{"text": "Heartbeat: Keep HEARTBEAT.md empty or commented to skip automatic checks. Manual Redis messaging only when user requests. No automatic actions on heartbeat.", "date": "2026-02-10", "tags": ["heartbeat", "rules", "manual", "redis"], "importance": "medium"},
# SETUP MILESTONES
{"text": "Setup milestones: 2026-02-04 Initial Bootstrap (identity, voice, skills). 2026-02-04 Qdrant Memory v1. 2026-02-05 ACTIVE.md Enforcement Rule. 2026-02-06 Agent Name Change (Jarvis→Max). 2026-02-10 Memory Manual Mode. 2026-02-10 Agent Messaging Manual Mode. 2026-02-10 Immediate Error Reporting Rule.", "date": "2026-02-10", "tags": ["milestones", "setup", "history", "dates"], "importance": "medium"},
# 3RD LXC PROJECT
{"text": "Project: 3rd OpenClaw LXC. Clone of Max's setup. Will run local GPT. Status: Idea phase, awaiting planning/implementation. Mentioned 2026-02-06.", "date": "2026-02-06", "tags": ["project", "openclaw", "lxc", "gpt", "planned"], "importance": "low"},
# OLLAMA PRICING
{"text": "Ollama pricing: Free=$0 (local only). Pro=$20/mo (multiple cloud, 3 private models, 3 collaborators). Max=$100/mo (5+ cloud, 5x usage, 5 private, 5 collaborators). Key: concurrency, cloud usage, private models, collaborators.", "date": "2026-02-06", "tags": ["ollama", "pricing", "plans", "max", "pro"], "importance": "low"},
# CONTAINER LIMITS
{"text": "Container limits: No GPUs attached to main container. All ML workloads run on CPU here. Whisper uses tiny or base models for speed. GPU is at 10.0.0.10 for Ollama.", "date": "2026-02-04", "tags": ["container", "limits", "gpu", "cpu", "whisper"], "importance": "medium"},
# SKILLS LOCATION
{"text": "Skills location: /root/.openclaw/workspace/skills/. Current skills: local-whisper-stt (inbound voice transcription), kimi-tts-custom (outbound voice with custom filenames), qdrant-memory (manual vector storage).", "date": "2026-02-04", "tags": ["skills", "paths", "location", "workspace"], "importance": "medium"},
# BOUNDARIES
{"text": "Boundaries: Private things stay private. Ask before sending emails/tweets/public posts. Not Rob's voice in group chats - I'm a participant, not his proxy. Careful with external actions, bold with internal ones.", "date": "2026-02-04", "tags": ["boundaries", "privacy", "external", "group", "rules"], "importance": "high"},
# BEING HELPFUL
{"text": "How to be helpful: Actions > words - skip the fluff, just help. Have opinions - not a search engine with extra steps. Resourceful first - try to figure it out before asking. Competence earns trust - careful with external actions.", "date": "2026-02-04", "tags": ["helpful", "personality", "actions", "opinions", "competence"], "importance": "high"},
# WRITING POLICY
{"text": "Writing policy: If I want to remember something, WRITE IT TO A FILE. Memory is limited - files survive session restarts. When someone says 'remember this' → update memory/YYYY-MM-DD.md. When I learn a lesson → update relevant file.", "date": "2026-02-04", "tags": ["writing", "memory", "files", "persistence", "rules"], "importance": "high"},
# GROUP CHAT
{"text": "Group chat rules: Respond when directly mentioned, can add genuine value, something witty fits naturally, correcting misinformation, summarizing when asked. Stay silent when casual banter, someone already answered, response would be 'yeah' or 'nice', conversation flows fine. Quality > quantity.", "date": "2026-02-04", "tags": ["group", "chat", "rules", "respond", "silent"], "importance": "medium"},
# REACTIONS
{"text": "Reactions: Use emoji reactions naturally on platforms that support them. React to acknowledge without interrupting, appreciate without replying, simple yes/no situations. One reaction per message max.", "date": "2026-02-04", "tags": ["reactions", "emoji", "group", "acknowledge"], "importance": "low"},
# INSTALLATION POLICY
{"text": "Installation policy decision tree: 1) Can it be a skill? → Create skill (cleanest, reusable). 2) Does it fit TOOLS.md? → Add to TOOLS.md (environment-specific: device names, SSH hosts, voice prefs). 3) Neither → Suggest other options.", "date": "2026-02-04", "tags": ["installation", "policy", "skills", "tools", "decision"], "importance": "medium"},
# WEBSITE MIRRORING
{"text": "Website mirroring tools: wget --mirror (built-in, simple), httrack (free GUI), Cyotek WebCopy (Windows), SiteSucker (macOS), wpull (Python, JS-heavy sites), monolith (single-file). For dynamic sites: Playwright + Python script.", "date": "2026-02-10", "tags": ["website", "mirror", "tools", "wget", "httrack", "scrape"], "importance": "low"},
# HEARTBEAT_OK
{"text": "HEARTBEAT_OK: When receiving heartbeat poll and nothing needs attention, reply exactly HEARTBEAT_OK. It must be entire message, nothing else. Never append to actual response, never wrap in markdown.", "date": "2026-02-04", "tags": ["heartbeat", "rules", "response", "format"], "importance": "medium"},
# MEMORY FILES GUIDE
{"text": "Memory files: SOUL.md (who I am - read every session). USER.md (who I'm helping - read every session). AGENTS.md (workspace rules - read every session). ACTIVE.md (tool syntax - read BEFORE every tool use). TOOLS.md (tool patterns, SSH hosts - when errors). SKILL.md (skill-specific - before using skill). MEMORY.md (long-term - main session only).", "date": "2026-02-04", "tags": ["memory", "files", "guide", "reading", "session"], "importance": "high"},
]
import sys
print(f"Prepared {len(memories)} memories for backup")
print("Starting storage to kimi_memories...")
print()
success = 0
failed = 0
for i, mem in enumerate(memories, 1):
print(f"[{i}/{len(memories)}] {mem['text'][:50]}...")
embedding = embed(mem['text'])
if not embedding:
print(f" ❌ Failed to generate embedding")
failed += 1
continue
if store(mem['text'], embedding, mem['date'], mem['tags'], mem['importance']):
print(f" ✅ Stored")
success += 1
else:
print(f" ❌ Failed to store")
failed += 1
print()
print("=" * 60)
print(f"BACKUP COMPLETE")
print(f" Success: {success}")
print(f" Failed: {failed}")
print(f" Total: {len(memories)}")
print("=" * 60)
if failed == 0:
print("\n✅ All memories successfully backed up to kimi_memories!")
else:
print(f"\n⚠️ {failed} memories failed. Check errors above.")
@@ -1,236 +0,0 @@
#!/usr/bin/env python3
"""
Mem0-Style Conversation Retrieval - User-centric memory search
Retrieves memories by USER, not by session/chat.
Cross-conversation search across all of Rob's memories.
Usage:
# Search user's memories across all conversations
python3 scripts/get_conversation_context.py --user-id "rob" "what was the decision about Qdrant?"
# Get specific conversation
python3 scripts/get_conversation_context.py --user-id "rob" --conversation-id <id>
# Get all conversations for user
python3 scripts/get_conversation_context.py --user-id "rob" --limit 50
Mem0-style: Memories belong to USER, not to session.
"""
import argparse
import json
import sys
import urllib.request
from datetime import datetime
from typing import List, Optional, Dict, Any
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "kimi_memories"
OLLAMA_URL = "http://10.0.0.10:11434/v1"
def get_embedding(text: str) -> Optional[List[float]]:
"""Generate embedding using snowflake-arctic-embed2"""
data = json.dumps({
"model": "snowflake-arctic-embed2",
"input": text[:8192]
}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/embeddings",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
return result["data"][0]["embedding"]
except Exception as e:
print(f"[Retrieval] Embedding error: {e}", file=sys.stderr)
return None
def search_user_memories(user_id: str, query: str, limit: int = 10) -> List[Dict]:
"""
MEM0-STYLE: Search memories for a specific user across all conversations.
NOT session-based - user-centric.
"""
embedding = get_embedding(query)
if embedding is None:
return []
# Search with user_id filter (MEM0: memories belong to user)
search_data = json.dumps({
"vector": embedding,
"limit": limit,
"with_payload": True,
"filter": {
"must": [
{"key": "user_id", "match": {"value": user_id}},
{"key": "source_type", "match": {"value": "system"}} # Search summaries
]
}
}).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/search",
data=search_data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
return result.get("result", [])
except Exception as e:
print(f"[Retrieval] Search error: {e}", file=sys.stderr)
return []
def get_user_conversations(user_id: str, limit: int = 100) -> List[Dict]:
"""Get all conversations for a user (Mem0-style)"""
scroll_data = json.dumps({
"limit": limit,
"with_payload": True,
"filter": {
"must": [
{"key": "user_id", "match": {"value": user_id}},
{"key": "source_type", "match": {"value": "system"}} # Get summaries
]
}
}).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
data=scroll_data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
return result.get("result", {}).get("points", [])
except Exception as e:
print(f"[Retrieval] Fetch error: {e}", file=sys.stderr)
return []
def get_conversation_by_id(user_id: str, conversation_id: str, limit: int = 100) -> List[Dict]:
"""Get full conversation by ID (with user verification)"""
scroll_data = json.dumps({
"limit": limit,
"with_payload": True,
"filter": {
"must": [
{"key": "user_id", "match": {"value": user_id}},
{"key": "conversation_id", "match": {"value": conversation_id}}
]
}
}).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
data=scroll_data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
return result.get("result", {}).get("points", [])
except Exception as e:
print(f"[Retrieval] Fetch error: {e}", file=sys.stderr)
return []
def format_conversation(points: List[Dict]) -> str:
"""Format conversation into readable transcript"""
def sort_key(p):
turn = p.get("payload", {}).get("turn_number", 0)
source = p.get("payload", {}).get("source_type", "")
return (turn, 0 if source in ["user", "assistant"] else 1)
sorted_points = sorted(points, key=sort_key)
output = []
current_turn = 0
for point in sorted_points:
payload = point.get("payload", {})
text = payload.get("text", "")
source = payload.get("source_type", "unknown")
turn = payload.get("turn_number", 0)
date = payload.get("date", "unknown")
user = payload.get("user_id", "unknown")
if payload.get("source") == "conversation_summary":
continue
if turn != current_turn:
output.append(f"\n--- Turn {turn} [{date}] ---")
current_turn = turn
output.append(text)
return "\n".join(output)
def main():
parser = argparse.ArgumentParser(
description="Mem0-style conversation retrieval (user-centric)"
)
parser.add_argument("query", nargs="?", help="Search query")
parser.add_argument("--user-id", required=True,
help="REQUIRED: User ID (e.g., 'rob')")
parser.add_argument("--conversation-id",
help="Get specific conversation")
parser.add_argument("--limit", type=int, default=10,
help="Max results")
parser.add_argument("--format", choices=["transcript", "json"],
default="transcript")
args = parser.parse_args()
if not args.user_id:
print("❌ --user-id is required for Mem0-style retrieval", file=sys.stderr)
sys.exit(1)
points = []
if args.conversation_id:
print(f"🔍 Fetching conversation for user '{args.user_id}': {args.conversation_id}")
points = get_conversation_by_id(args.user_id, args.conversation_id, args.limit * 3)
elif args.query:
print(f"🔍 Searching memories for user '{args.user_id}': {args.query}")
points = search_user_memories(args.user_id, args.query, args.limit)
else:
print(f"🔍 Fetching all memories for user '{args.user_id}'")
points = get_user_conversations(args.user_id, args.limit)
if not points:
print(f"❌ No memories found for user '{args.user_id}'")
sys.exit(1)
if args.format == "json":
print(json.dumps(points, indent=2))
else:
# Group by conversation_id
conversations = {}
for p in points:
convo_id = p.get("payload", {}).get("conversation_id")
if convo_id not in conversations:
conversations[convo_id] = []
conversations[convo_id].append(p)
for i, (convo_id, convo_points) in enumerate(conversations.items(), 1):
print(f"\n{'='*60}")
print(f"📜 Conversation {i}: {convo_id}")
print(f"{'='*60}")
print(format_conversation(convo_points))
if __name__ == "__main__":
main()
@@ -1,86 +0,0 @@
#!/usr/bin/env python3
"""
Quick user context for email replies.
Returns recent memory summary, not full conversations.
"""
import json
import sys
import urllib.request
from typing import Optional
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "kimi_memories"
def get_user_context(user_id: str, limit: int = 5) -> str:
"""Get recent context for user - returns formatted summary."""
# Use scroll to get recent memories for user
data = json.dumps({
"limit": 10, # Get more to find profile
"with_payload": True,
"filter": {
"must": [
{"key": "user_id", "match": {"value": user_id}}
]
}
}).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
data=data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
points = result.get("result", {}).get("points", [])
if not points:
return ""
# Prioritize: 1) Profile info, 2) Recent user message, 3) Recent context
profile = None
recent_user = None
recent_context = []
for point in points:
payload = point.get("payload", {})
text = payload.get("text", "")
source_type = payload.get("source_type", "")
# Look for profile (contains "Profile" or key identifying info)
if "profile" in text.lower() or "lives in" in text.lower():
profile = text[:200]
elif source_type == "user" and not recent_user:
recent_user = text[:150]
elif source_type in ["assistant", "system"]:
clean = text.replace("\r\n", " ").replace("\n", " ")[:150]
recent_context.append(clean)
# Build output: profile first if exists, then recent context
parts = []
if profile:
parts.append(f"[PROFILE] {profile}")
if recent_user:
parts.append(f"[USER] {recent_user}")
if recent_context:
parts.append(f"[CONTEXT] {recent_context[0][:100]}")
return " || ".join(parts) if parts else ""
except Exception as e:
return ""
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Get quick user context")
parser.add_argument("--user-id", required=True, help="User ID")
parser.add_argument("--limit", type=int, default=5, help="Max memories")
args = parser.parse_args()
context = get_user_context(args.user_id, args.limit)
if context:
print(context)
@@ -1,191 +0,0 @@
#!/usr/bin/env python3
"""
Harvest session files by explicit list (newest first).
"""
import argparse
import hashlib
import json
import os
import sys
import urllib.request
import uuid
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Dict, Any
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "kimi_memories"
OLLAMA_URL = "http://10.0.0.10:11434/v1"
SESSIONS_DIR = Path("/root/.openclaw/agents/main/sessions")
_recent_hashes = set()
def get_content_hash(user_msg: str, ai_response: str) -> str:
content = f"{user_msg.strip()}::{ai_response.strip()}"
return hashlib.md5(content.encode()).hexdigest()
def is_duplicate(user_id: str, content_hash: str) -> bool:
if content_hash in _recent_hashes:
return True
try:
search_body = {
"filter": {
"must": [
{"key": "user_id", "match": {"value": user_id}},
{"key": "content_hash", "match": {"value": content_hash}}
]
},
"limit": 1,
"with_payload": False
}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
data=json.dumps(search_body).encode(),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
if result.get("result", {}).get("points", []):
return True
except Exception:
pass
return False
def get_embedding(text: str) -> Optional[List[float]]:
data = json.dumps({"model": "snowflake-arctic-embed2", "input": text[:8192]}).encode()
req = urllib.request.Request(f"{OLLAMA_URL}/embeddings", data=data, headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as response:
return json.loads(response.read().decode())["data"][0]["embedding"]
except Exception:
return None
def store_turn(user_id: str, user_msg: str, ai_response: str, date_str: str,
conversation_id: str, turn_number: int, session_id: str) -> bool:
content_hash = get_content_hash(user_msg, ai_response)
if is_duplicate(user_id, content_hash):
return False # Skipped (duplicate)
user_emb = get_embedding(f"[{user_id}]: {user_msg}")
ai_emb = get_embedding(f"[Kimi]: {ai_response}")
summary_emb = get_embedding(f"Q: {user_msg[:200]} A: {ai_response[:300]}")
if not all([user_emb, ai_emb, summary_emb]):
return False
tags = ["conversation", "harvested", f"user:{user_id}", date_str]
importance = "high" if any(kw in (user_msg + ai_response).lower() for kw in ["remember", "important", "always", "never", "rule"]) else "medium"
points = [
{"id": str(uuid.uuid4()), "vector": user_emb, "payload": {
"user_id": user_id, "text": f"[{user_id}]: {user_msg[:2000]}", "date": date_str,
"tags": tags + ["user-message"], "importance": importance, "source": "session_harvest",
"source_type": "user", "category": "Full Conversation", "confidence": "high",
"conversation_id": conversation_id, "turn_number": turn_number, "session_id": session_id, "content_hash": content_hash
}},
{"id": str(uuid.uuid4()), "vector": ai_emb, "payload": {
"user_id": user_id, "text": f"[Kimi]: {ai_response[:2000]}", "date": date_str,
"tags": tags + ["ai-response"], "importance": importance, "source": "session_harvest",
"source_type": "assistant", "category": "Full Conversation", "confidence": "high",
"conversation_id": conversation_id, "turn_number": turn_number, "session_id": session_id, "content_hash": content_hash
}},
{"id": str(uuid.uuid4()), "vector": summary_emb, "payload": {
"user_id": user_id, "text": f"[Turn {turn_number}] Q: {user_msg[:200]} A: {ai_response[:300]}", "date": date_str,
"tags": tags + ["summary"], "importance": importance, "source": "session_harvest",
"source_type": "system", "category": "Conversation Summary", "confidence": "high",
"conversation_id": conversation_id, "turn_number": turn_number, "session_id": session_id,
"content_hash": content_hash, "user_message": user_msg[:500], "ai_response": ai_response[:800]
}}
]
req = urllib.request.Request(f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points?wait=true",
data=json.dumps({"points": points}).encode(), headers={"Content-Type": "application/json"}, method="PUT")
try:
with urllib.request.urlopen(req, timeout=30) as response:
if json.loads(response.read().decode()).get("status") == "ok":
_recent_hashes.add(content_hash)
return True
except Exception:
pass
return False
def parse_and_store(filepath: Path, user_id: str) -> tuple:
turns = []
turn_num = 0
try:
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
if entry.get('type') != 'message' or 'message' not in entry:
continue
msg = entry['message']
role = msg.get('role')
if role == 'toolResult':
continue
content = ""
if isinstance(msg.get('content'), list):
for item in msg['content']:
if isinstance(item, dict) and 'text' in item:
content += item['text']
elif isinstance(msg.get('content'), str):
content = msg['content']
if content and role in ('user', 'assistant'):
turn_num += 1
ts = entry.get('timestamp', '')
turns.append({'turn': turn_num, 'role': role, 'content': content[:2000],
'date': ts[:10] if ts else datetime.now().strftime("%Y-%m-%d")})
except json.JSONDecodeError:
continue
except Exception as e:
print(f" Error: {e}", file=sys.stderr)
return 0, 0
stored, skipped = 0, 0
conv_id = str(uuid.uuid4())
i = 0
while i < len(turns):
if turns[i]['role'] == 'user':
user_msg = turns[i]['content']
ai_resp = ""
if i + 1 < len(turns) and turns[i + 1]['role'] == 'assistant':
ai_resp = turns[i + 1]['content']
i += 2
else:
i += 1
if user_msg and ai_resp:
if store_turn(user_id, user_msg, ai_resp, turns[i-1]['date'] if i > 0 else "", conv_id, turns[i-1]['turn'] if i > 0 else 0, filepath.stem):
stored += 1
else:
skipped += 1
else:
i += 1
return stored, skipped
def main():
parser = argparse.ArgumentParser(description="Harvest sessions by name")
parser.add_argument("--user-id", default="yourname")
parser.add_argument("sessions", nargs="*", help="Session filenames to process")
args = parser.parse_args()
total_stored, total_skipped = 0, 0
for i, name in enumerate(args.sessions, 1):
path = SESSIONS_DIR / name
if not path.exists():
print(f"[{i}] Not found: {name}")
continue
print(f"[{i}] {name}")
s, sk = parse_and_store(path, args.user_id)
total_stored += s
total_skipped += sk
if s > 0:
print(f" Stored: {s}, Skipped: {sk}")
print(f"\nTotal: {total_stored} stored, {total_skipped} skipped")
if __name__ == "__main__":
main()
@@ -1,341 +0,0 @@
#!/usr/bin/env python3
"""
Harvest all session JSONL files and store to Qdrant.
Scans all session files, extracts conversation turns, and stores to Qdrant
with proper user_id and deduplication.
Usage: python3 harvest_sessions.py [--user-id rob] [--dry-run]
"""
import argparse
import hashlib
import json
import os
import sys
import urllib.request
import uuid
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Dict, Any
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "kimi_memories"
OLLAMA_URL = "http://10.0.0.10:11434/v1"
SESSIONS_DIR = Path("/root/.openclaw/agents/main/sessions")
# In-memory cache for deduplication
_recent_hashes = set()
def get_content_hash(user_msg: str, ai_response: str) -> str:
"""Generate hash for deduplication"""
content = f"{user_msg.strip()}::{ai_response.strip()}"
return hashlib.md5(content.encode()).hexdigest()
def is_duplicate(user_id: str, content_hash: str) -> bool:
"""Check if this content already exists for this user"""
if content_hash in _recent_hashes:
return True
try:
search_body = {
"filter": {
"must": [
{"key": "user_id", "match": {"value": user_id}},
{"key": "content_hash", "match": {"value": content_hash}}
]
},
"limit": 1,
"with_payload": False
}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
data=json.dumps(search_body).encode(),
headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
points = result.get("result", {}).get("points", [])
if len(points) > 0:
return True
except Exception:
pass
return False
def get_embedding(text: str) -> Optional[List[float]]:
"""Generate embedding using snowflake-arctic-embed2"""
data = json.dumps({
"model": "snowflake-arctic-embed2",
"input": text[:8192]
}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/embeddings",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
return result["data"][0]["embedding"]
except Exception as e:
print(f"[Harvest] Embedding error: {e}", file=sys.stderr)
return None
def store_turn(user_id: str, user_msg: str, ai_response: str,
date_str: str, conversation_id: str, turn_number: int,
session_id: str, dry_run: bool = False) -> Dict:
"""Store a single conversation turn to Qdrant"""
content_hash = get_content_hash(user_msg, ai_response)
# Check duplicate
if is_duplicate(user_id, content_hash):
return {"skipped": True, "reason": "duplicate"}
if dry_run:
return {"skipped": False, "dry_run": True}
# Generate embeddings
user_embedding = get_embedding(f"[{user_id}]: {user_msg}")
ai_embedding = get_embedding(f"[Kimi]: {ai_response}")
summary = f"Q: {user_msg[:200]} A: {ai_response[:300]}..."
summary_embedding = get_embedding(summary)
if not all([user_embedding, ai_embedding, summary_embedding]):
return {"skipped": True, "reason": "embedding_failed"}
tags = ["conversation", "harvested", f"user:{user_id}", date_str]
importance = "high" if any(kw in (user_msg + ai_response).lower()
for kw in ["remember", "important", "always", "never", "rule"]) else "medium"
points = []
# User message
points.append({
"id": str(uuid.uuid4()),
"vector": user_embedding,
"payload": {
"user_id": user_id,
"text": f"[{user_id}]: {user_msg[:2000]}",
"date": date_str,
"tags": tags + ["user-message"],
"importance": importance,
"source": "session_harvest",
"source_type": "user",
"category": "Full Conversation",
"confidence": "high",
"verified": True,
"created_at": datetime.now().isoformat(),
"conversation_id": conversation_id,
"turn_number": turn_number,
"session_id": session_id,
"content_hash": content_hash
}
})
# AI response
points.append({
"id": str(uuid.uuid4()),
"vector": ai_embedding,
"payload": {
"user_id": user_id,
"text": f"[Kimi]: {ai_response[:2000]}",
"date": date_str,
"tags": tags + ["ai-response"],
"importance": importance,
"source": "session_harvest",
"source_type": "assistant",
"category": "Full Conversation",
"confidence": "high",
"verified": True,
"created_at": datetime.now().isoformat(),
"conversation_id": conversation_id,
"turn_number": turn_number,
"session_id": session_id,
"content_hash": content_hash
}
})
# Summary
if summary_embedding:
points.append({
"id": str(uuid.uuid4()),
"vector": summary_embedding,
"payload": {
"user_id": user_id,
"text": f"[Turn {turn_number}] {summary}",
"date": date_str,
"tags": tags + ["summary"],
"importance": importance,
"source": "session_harvest_summary",
"source_type": "system",
"category": "Conversation Summary",
"confidence": "high",
"verified": True,
"created_at": datetime.now().isoformat(),
"conversation_id": conversation_id,
"turn_number": turn_number,
"session_id": session_id,
"content_hash": content_hash,
"user_message": user_msg[:500],
"ai_response": ai_response[:800]
}
})
# Upload
upsert_data = {"points": points}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points?wait=true",
data=json.dumps(upsert_data).encode(),
headers={"Content-Type": "application/json"},
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=30) as response:
result = json.loads(response.read().decode())
if result.get("status") == "ok":
_recent_hashes.add(content_hash)
return {"skipped": False, "stored": True}
except Exception as e:
print(f"[Harvest] Storage error: {e}", file=sys.stderr)
return {"skipped": True, "reason": "upload_failed"}
def parse_session_file(filepath: Path) -> List[Dict]:
"""Parse a session JSONL file and extract conversation turns"""
turns = []
turn_number = 0
try:
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entry = json.loads(line)
if entry.get('type') == 'message' and 'message' in entry:
msg = entry['message']
role = msg.get('role')
if role == 'toolResult':
continue
content = ""
if isinstance(msg.get('content'), list):
for item in msg['content']:
if isinstance(item, dict):
if 'text' in item:
content += item['text']
elif 'thinking' in item:
content += f"[thinking: {item['thinking'][:200]}...]"
elif isinstance(msg.get('content'), str):
content = msg['content']
if content and role in ('user', 'assistant'):
turn_number += 1
timestamp = entry.get('timestamp', '')
date_str = timestamp[:10] if timestamp else datetime.now().strftime("%Y-%m-%d")
turns.append({
'turn': turn_number,
'role': role,
'content': content[:2000],
'date': date_str,
'session': filepath.stem
})
except json.JSONDecodeError:
continue
except Exception as e:
print(f"[Harvest] Error reading {filepath}: {e}", file=sys.stderr)
return turns
def main():
parser = argparse.ArgumentParser(description="Harvest session files to Qdrant")
parser.add_argument("--user-id", default="yourname", help="User ID for storage")
parser.add_argument("--dry-run", action="store_true", help="Don't actually store")
parser.add_argument("--limit", type=int, default=0, help="Limit sessions (0=all)")
args = parser.parse_args()
# Find all session files
session_files = sorted(SESSIONS_DIR.glob("*.jsonl"), key=lambda p: p.stat().st_mtime)
if args.limit > 0:
session_files = session_files[:args.limit]
print(f"Found {len(session_files)} session files")
total_stored = 0
total_skipped = 0
total_failed = 0
for i, session_file in enumerate(session_files, 1):
print(f"\n[{i}/{len(session_files)}] Processing: {session_file.name}")
turns = parse_session_file(session_file)
if not turns:
print(" No turns found")
continue
print(f" Found {len(turns)} turns")
# Pair user messages with AI responses
conversation_id = str(uuid.uuid4())
j = 0
while j < len(turns):
turn = turns[j]
if turn['role'] == 'user':
user_msg = turn['content']
ai_response = ""
# Look for next AI response
if j + 1 < len(turns) and turns[j + 1]['role'] == 'assistant':
ai_response = turns[j + 1]['content']
j += 2
else:
j += 1
if user_msg and ai_response:
result = store_turn(
user_id=args.user_id,
user_msg=user_msg,
ai_response=ai_response,
date_str=turn['date'],
conversation_id=conversation_id,
turn_number=turn['turn'],
session_id=turn['session'],
dry_run=args.dry_run
)
if result.get("skipped"):
if result.get("reason") == "duplicate":
total_skipped += 1
else:
total_failed += 1
else:
total_stored += 1
if total_stored % 10 == 0:
print(f" Progress: {total_stored} stored, {total_skipped} skipped")
else:
j += 1
print(f"\n{'='*50}")
print(f"Harvest complete:")
print(f" Stored: {total_stored} turns ({total_stored * 3} embeddings)")
print(f" Skipped (duplicates): {total_skipped}")
print(f" Failed: {total_failed}")
if args.dry_run:
print("\n[DRY RUN] Nothing was actually stored")
if __name__ == "__main__":
main()
@@ -1,186 +0,0 @@
#!/usr/bin/env python3
"""
Email checker for heartbeat using Redis ID tracking.
Tracks seen email IDs in Redis to avoid missing read emails.
Stores emails to Qdrant with sender-specific user_id for memory.
Only alerts on emails from authorized senders.
"""
import imaplib
import email
from email.policy import default
import json
import sys
import redis
import subprocess
from datetime import datetime
# Authorized senders with their user IDs for Qdrant storage
# Add your authorized emails here
AUTHORIZED_SENDERS = {
# "[email protected]": "yourname",
# "[email protected]": "spousename"
}
# Gmail IMAP settings
IMAP_SERVER = "imap.gmail.com"
IMAP_PORT = 993
# Redis config
REDIS_HOST = "10.0.0.36"
REDIS_PORT = 6379
REDIS_KEY = "email:seen_ids"
# Load credentials
CRED_FILE = "/root/.openclaw/workspace/.gmail_imap.json"
def load_credentials():
try:
with open(CRED_FILE, 'r') as f:
return json.load(f)
except Exception as e:
return None
def get_redis():
try:
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
r.ping() # Test connection
return r
except Exception as e:
return None
def store_email_memory(user_id, sender, subject, body, date):
"""Store email to Qdrant as memory for the user."""
try:
# Format as conversation-like entry
email_text = f"[EMAIL from {sender}]\nSubject: {subject}\n\n{body}"
# Store using background_store.py (fire-and-forget)
script_path = "/root/.openclaw/workspace/skills/qdrant-memory/scripts/background_store.py"
subprocess.Popen([
"python3", script_path,
f"[Email] {subject}",
email_text,
"--user-id", user_id
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except Exception as e:
pass # Silent fail
def get_user_context(user_id):
"""Fetch recent context from Qdrant for the user."""
try:
script_path = "/root/.openclaw/workspace/skills/qdrant-memory/scripts/get_user_context.py"
result = subprocess.run([
"python3", script_path,
"--user-id", user_id,
"--limit", "3"
], capture_output=True, text=True, timeout=10)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except Exception as e:
pass
return None
def check_emails():
creds = load_credentials()
if not creds:
return # Silent fail
email_addr = creds.get("email")
app_password = creds.get("app_password")
if not email_addr or not app_password:
return # Silent fail
r = get_redis()
if not r:
return # Silent fail if Redis unavailable
try:
# Connect to IMAP
mail = imaplib.IMAP4_SSL(IMAP_SERVER, IMAP_PORT)
mail.login(email_addr, app_password)
mail.select("inbox")
# Get ALL emails (not just unseen)
status, messages = mail.search(None, "ALL")
if status != "OK" or not messages[0]:
mail.logout()
return # No emails
email_ids = messages[0].split()
# Get already-seen IDs from Redis
seen_ids = set(r.smembers(REDIS_KEY))
# Check last 10 emails for new ones
for eid in email_ids[-10:]:
eid_str = eid.decode() if isinstance(eid, bytes) else str(eid)
# Skip if already seen
if eid_str in seen_ids:
continue
status, msg_data = mail.fetch(eid, "(RFC822)")
if status != "OK":
continue
msg = email.message_from_bytes(msg_data[0][1], policy=default)
sender = msg.get("From", "").lower()
subject = msg.get("Subject", "")
date = msg.get("Date", "")
# Extract email body
body = ""
if msg.is_multipart():
for part in msg.walk():
if part.get_content_type() == "text/plain":
body = part.get_content()
break
else:
body = msg.get_content()
# Clean up body (limit size)
body = body.strip()[:2000] if body else ""
# Check if sender is authorized and get their user_id
user_id = None
for auth_email, uid in AUTHORIZED_SENDERS.items():
if auth_email.lower() in sender:
user_id = uid
break
# Mark as seen in Redis regardless of sender (avoid re-checking)
r.sadd(REDIS_KEY, eid_str)
if user_id:
# Store to Qdrant for memory
store_email_memory(user_id, sender, subject, body, date)
# Get user context from Qdrant before alerting
context = get_user_context(user_id)
# Output for Kimi to respond (with context hint)
print(f"[EMAIL] User: {user_id} | From: {sender.strip()} | Subject: {subject} | Date: {date}")
if context:
print(f"[CONTEXT] {context}")
# Cleanup old IDs (keep last 100)
all_ids = r.smembers(REDIS_KEY)
if len(all_ids) > 100:
# Convert to int, sort, keep only highest 100
id_ints = sorted([int(x) for x in all_ids if x.isdigit()])
to_remove = id_ints[:-100]
for old_id in to_remove:
r.srem(REDIS_KEY, str(old_id))
mail.close()
mail.logout()
except Exception as e:
# Silent fail - no output
pass
if __name__ == "__main__":
check_emails()
sys.exit(0)
@@ -1,242 +0,0 @@
#!/usr/bin/env python3
"""
Initialize Qdrant collections for Kimi Memory System
Creates 3 collections with snowflake-arctic-embed2 (1024 dims) using Qdrant 2025 best practices:
1. kimi_memories - Personal memories, preferences, lessons learned
2. kimi_kb - Knowledge base for web search, documents, scraped data
3. private_court_docs - Court documents and legal discussions
Features:
- on_disk=True for vectors (minimize RAM usage)
- on_disk_payload=True for payload
- Optimizer config for efficient indexing
- Binary quantization support (2025+ feature)
Usage: init_all_collections.py [--recreate]
"""
import argparse
import json
import sys
QDRANT_URL = "http://10.0.0.40:6333"
# Collection configurations
COLLECTIONS = {
"kimi_memories": {
"description": "Personal memories, preferences, lessons learned",
"vector_size": 1024
},
"kimi_kb": {
"description": "Knowledge base - web data, documents, reference materials",
"vector_size": 1024
},
"private_court_docs": {
"description": "Court documents and legal discussions",
"vector_size": 1024
}
}
def make_request(url, data=None, method="GET"):
"""Make HTTP request with proper method"""
import urllib.request
req = urllib.request.Request(url, method=method)
if data:
req.data = json.dumps(data).encode()
req.add_header("Content-Type", "application/json")
return req
def collection_exists(name):
"""Check if collection exists"""
import urllib.request
import urllib.error
try:
req = make_request(f"{QDRANT_URL}/collections/{name}")
with urllib.request.urlopen(req, timeout=5) as response:
return True
except urllib.error.HTTPError as e:
if e.code == 404:
return False
raise
except Exception:
return False
def get_collection_info(name):
"""Get collection info"""
import urllib.request
try:
req = make_request(f"{QDRANT_URL}/collections/{name}")
with urllib.request.urlopen(req, timeout=5) as response:
return json.loads(response.read().decode())
except Exception as e:
return None
def create_collection(name, vector_size=1024):
"""Create a collection with Qdrant 2025 best practices"""
import urllib.request
config = {
"vectors": {
"size": vector_size,
"distance": "Cosine",
"on_disk": True, # Store vectors on disk to minimize RAM
"quantization_config": {
"binary": {
"always_ram": True # Keep compressed vectors in RAM for fast search
}
}
},
"on_disk_payload": True, # Store payload on disk
"shard_number": 1, # Single node setup
"replication_factor": 1, # Single copy (set to 2 for production with HA)
"optimizers_config": {
"indexing_threshold": 20000, # Start indexing after 20k points
"default_segment_number": 0, # Fewer/larger segments for better throughput
"deleted_threshold": 0.2, # Vacuum when 20% deleted
"vacuum_min_vector_number": 1000 # Min vectors before vacuum
}
}
req = make_request(
f"{QDRANT_URL}/collections/{name}",
data=config,
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
return result.get("result") == True
except Exception as e:
print(f"Error creating collection {name}: {e}", file=sys.stderr)
return False
def delete_collection(name):
"""Delete a collection"""
import urllib.request
req = make_request(f"{QDRANT_URL}/collections/{name}", method="DELETE")
try:
with urllib.request.urlopen(req, timeout=5) as response:
result = json.loads(response.read().decode())
return result.get("status") == "ok"
except Exception as e:
print(f"Error deleting collection {name}: {e}", file=sys.stderr)
return False
def main():
import urllib.request
parser = argparse.ArgumentParser(description="Initialize all Qdrant collections with 2025 best practices")
parser.add_argument("--recreate", action="store_true", help="Delete and recreate all collections")
parser.add_argument("--force", action="store_true", help="Force recreate even with existing data")
args = parser.parse_args()
# Check Qdrant connection
try:
req = urllib.request.Request(f"{QDRANT_URL}/")
with urllib.request.urlopen(req, timeout=3) as response:
pass
except Exception as e:
print(f"❌ Cannot connect to Qdrant at {QDRANT_URL}: {e}", file=sys.stderr)
sys.exit(1)
print(f"✅ Connected to Qdrant at {QDRANT_URL}\n")
# Check if Ollama is available for embeddings
try:
req = urllib.request.Request("http://localhost:11434/api/tags")
with urllib.request.urlopen(req, timeout=3) as response:
ollama_status = ""
except Exception:
ollama_status = "⚠️"
print(f"Ollama (localhost): {ollama_status} - Embeddings endpoint\n")
created = []
skipped = []
errors = []
recreated = []
for name, config in COLLECTIONS.items():
print(f"--- {name} ---")
print(f" Description: {config['description']}")
exists = collection_exists(name)
if exists:
info = get_collection_info(name)
if info:
actual_size = info.get("result", {}).get("config", {}).get("params", {}).get("vectors", {}).get("size", "?")
points = info.get("result", {}).get("points_count", 0)
on_disk = info.get("result", {}).get("config", {}).get("params", {}).get("vectors", {}).get("on_disk", False)
print(f" ️ Existing collection:")
print(f" Points: {points}")
print(f" Vector size: {actual_size}")
print(f" On disk: {on_disk}")
if args.recreate:
if points > 0 and not args.force:
print(f" ⚠️ Collection has {points} points. Use --force to recreate with data loss.")
skipped.append(name)
continue
print(f" Deleting existing collection...")
if delete_collection(name):
print(f" ✅ Deleted")
exists = False
else:
print(f" ❌ Failed to delete", file=sys.stderr)
errors.append(name)
continue
else:
print(f" ⚠️ Already exists, skipping (use --recreate to update)")
skipped.append(name)
continue
if not exists:
print(f" Creating collection with 2025 best practices...")
print(f" - on_disk=True (vectors)")
print(f" - on_disk_payload=True")
print(f" - Binary quantization")
print(f" - Optimizer config")
if create_collection(name, config["vector_size"]):
print(f" ✅ Created (vector size: {config['vector_size']})")
if args.recreate and name in [c for c in COLLECTIONS]:
recreated.append(name)
else:
created.append(name)
else:
print(f" ❌ Failed to create", file=sys.stderr)
errors.append(name)
print()
# Summary
print("=" * 50)
print("SUMMARY:")
if created:
print(f" Created: {', '.join(created)}")
if recreated:
print(f" Recreated: {', '.join(recreated)}")
if skipped:
print(f" Skipped: {', '.join(skipped)}")
if errors:
print(f" Errors: {', '.join(errors)}")
sys.exit(1)
print("\n🎉 All collections ready with 2025 best practices!")
print("\nCollections configured for snowflake-arctic-embed2 (1024 dims)")
print("- kimi_memories: Personal memories (on_disk=True)")
print("- kimi_kb: Knowledge base (on_disk=True)")
print("- private_court_docs: Court documents (on_disk=True)")
print("\nFeatures enabled:")
print(" ✓ Vectors stored on disk (minimizes RAM)")
print(" ✓ Payload stored on disk")
print(" ✓ Binary quantization for fast search")
print(" ✓ Optimized indexing thresholds")
if __name__ == "__main__":
main()
@@ -1,9 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Initialize kimi_kb collection (Knowledge Base) Initialize Qdrant collection for OpenClaw memories
Vector size: 1024 (snowflake-arctic-embed2) Usage: init_collection.py [--recreate]
Usage: init_kimi_kb.py [--recreate]
""" """
import argparse import argparse
@@ -12,10 +10,10 @@ import urllib.request
import json import json
QDRANT_URL = "http://10.0.0.40:6333" QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "kimi_kb" COLLECTION_NAME = "openclaw_memories"
VECTOR_SIZE = 1024
def make_request(url, data=None, method="GET"): def make_request(url, data=None, method="GET"):
"""Make HTTP request with proper method"""
req = urllib.request.Request(url, method=method) req = urllib.request.Request(url, method=method)
if data: if data:
req.data = json.dumps(data).encode() req.data = json.dumps(data).encode()
@@ -23,6 +21,7 @@ def make_request(url, data=None, method="GET"):
return req return req
def collection_exists(): def collection_exists():
"""Check if collection exists"""
try: try:
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION_NAME}") req = make_request(f"{QDRANT_URL}/collections/{COLLECTION_NAME}")
with urllib.request.urlopen(req, timeout=5) as response: with urllib.request.urlopen(req, timeout=5) as response:
@@ -31,82 +30,84 @@ def collection_exists():
if e.code == 404: if e.code == 404:
return False return False
raise raise
except Exception: except Exception as e:
print(f"Error checking collection: {e}", file=sys.stderr)
return False return False
def get_info():
try:
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION_NAME}")
with urllib.request.urlopen(req, timeout=5) as response:
return json.loads(response.read().decode())
except Exception:
return None
def create_collection(): def create_collection():
"""Create the memories collection using PUT"""
config = { config = {
"vectors": { "vectors": {
"size": VECTOR_SIZE, "size": 768, # nomic-embed-text outputs 768 dimensions
"distance": "Cosine" "distance": "Cosine"
} }
} }
req = make_request( req = make_request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}", f"{QDRANT_URL}/collections/{COLLECTION_NAME}",
data=config, data=config,
method="PUT" method="PUT"
) )
try: try:
with urllib.request.urlopen(req, timeout=10) as response: with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode()) result = json.loads(response.read().decode())
return result.get("result") == True return result.get("result") == True
except Exception as e: except Exception as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error creating collection: {e}", file=sys.stderr)
return False return False
def delete_collection(): def delete_collection():
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION_NAME}", method="DELETE") """Delete collection if exists"""
req = make_request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}",
method="DELETE"
)
try: try:
with urllib.request.urlopen(req, timeout=5) as response: with urllib.request.urlopen(req, timeout=5) as response:
return json.loads(response.read().decode()).get("status") == "ok" return True
except Exception as e: except Exception as e:
print(f"Error: {e}", file=sys.stderr) print(f"Error deleting collection: {e}", file=sys.stderr)
return False return False
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Initialize kimi_kb collection") parser = argparse.ArgumentParser(description="Initialize Qdrant collection")
parser.add_argument("--recreate", action="store_true", help="Delete and recreate") parser.add_argument("--recreate", action="store_true", help="Delete and recreate collection")
args = parser.parse_args() args = parser.parse_args()
# Check if Qdrant is reachable
try: try:
req = make_request(f"{QDRANT_URL}/") req = make_request(f"{QDRANT_URL}/")
with urllib.request.urlopen(req, timeout=3) as response: with urllib.request.urlopen(req, timeout=3) as response:
pass pass
except Exception as e: except Exception as e:
print(f"❌ Cannot connect to Qdrant: {e}", file=sys.stderr) print(f"❌ Cannot connect to Qdrant at {QDRANT_URL}: {e}", file=sys.stderr)
sys.exit(1) sys.exit(1)
print(f"✅ Qdrant: {QDRANT_URL}") print(f" Connected to Qdrant at {QDRANT_URL}")
print(f"Collection: {COLLECTION_NAME}")
print(f"Vector size: {VECTOR_SIZE} (snowflake-arctic-embed2)\n")
exists = collection_exists() exists = collection_exists()
if exists: if exists and args.recreate:
if args.recreate: print(f"Deleting existing collection '{COLLECTION_NAME}'...")
print(f"Deleting existing...") if delete_collection():
delete_collection() print(f"✅ Deleted collection")
exists = False exists = False
else: else:
info = get_info() print(f"❌ Failed to delete collection", file=sys.stderr)
if info: sys.exit(1)
size = info.get("result", {}).get("vectors_config", {}).get("params", {}).get("vectors", {}).get("size", "?")
points = info.get("result", {}).get("points_count", 0)
print(f"⚠️ Already exists (vector size: {size}, points: {points})")
sys.exit(0)
if not exists: if not exists:
print(f"Creating collection '{COLLECTION_NAME}'...")
if create_collection(): if create_collection():
print(f"✅ Created {COLLECTION_NAME}") print(f"✅ Created collection '{COLLECTION_NAME}'")
print(f" Vector size: {VECTOR_SIZE}, Distance: Cosine") print(f" Vector size: 768, Distance: Cosine")
else: else:
print(f"❌ Failed", file=sys.stderr) print(f"❌ Failed to create collection", file=sys.stderr)
sys.exit(1) sys.exit(1)
else:
print(f"✅ Collection '{COLLECTION_NAME}' already exists")
print("\n🎉 Qdrant memory collection ready!")
@@ -1,114 +0,0 @@
#!/usr/bin/env python3
"""
Initialize kimi_memories collection (Personal Memories)
Vector size: 1024 (snowflake-arctic-embed2)
Usage: init_kimi_memories.py [--recreate]
"""
import argparse
import sys
import urllib.request
import json
import os
QDRANT_URL = os.getenv("QDRANT_URL", "http://127.0.0.1:6333")
COLLECTION_NAME = os.getenv("QDRANT_COLLECTION", "kimi_memories")
VECTOR_SIZE = int(os.getenv("QDRANT_VECTOR_SIZE", "1024"))
def make_request(url, data=None, method="GET"):
req = urllib.request.Request(url, method=method)
if data:
req.data = json.dumps(data).encode()
req.add_header("Content-Type", "application/json")
return req
def collection_exists():
try:
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION_NAME}")
with urllib.request.urlopen(req, timeout=5) as response:
return True
except urllib.error.HTTPError as e:
if e.code == 404:
return False
raise
except Exception:
return False
def get_info():
try:
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION_NAME}")
with urllib.request.urlopen(req, timeout=5) as response:
return json.loads(response.read().decode())
except Exception:
return None
def create_collection():
config = {
"vectors": {
"size": VECTOR_SIZE,
"distance": "Cosine"
}
}
req = make_request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}",
data=config,
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
return result.get("result") == True
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return False
def delete_collection():
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION_NAME}", method="DELETE")
try:
with urllib.request.urlopen(req, timeout=5) as response:
return json.loads(response.read().decode()).get("status") == "ok"
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return False
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Initialize kimi_memories collection")
parser.add_argument("--recreate", action="store_true", help="Delete and recreate")
args = parser.parse_args()
try:
req = make_request(f"{QDRANT_URL}/")
with urllib.request.urlopen(req, timeout=3) as response:
pass
except Exception as e:
print(f"❌ Cannot connect to Qdrant: {e}", file=sys.stderr)
sys.exit(1)
print(f"✅ Qdrant: {QDRANT_URL}")
print(f"Collection: {COLLECTION_NAME}")
print(f"Vector size: {VECTOR_SIZE} (snowflake-arctic-embed2)\n")
exists = collection_exists()
if exists:
if args.recreate:
print(f"Deleting existing...")
delete_collection()
exists = False
else:
info = get_info()
if info:
size = info.get("result", {}).get("vectors_config", {}).get("params", {}).get("vectors", {}).get("size", "?")
points = info.get("result", {}).get("points_count", 0)
print(f"⚠️ Already exists (vector size: {size}, points: {points})")
sys.exit(0)
if not exists:
if create_collection():
print(f"✅ Created {COLLECTION_NAME}")
print(f" Vector size: {VECTOR_SIZE}, Distance: Cosine")
else:
print(f"❌ Failed", file=sys.stderr)
sys.exit(1)
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
Initialize Qdrant collection for Knowledge Base
Usage: init_knowledge_base.py [--recreate]
"""
import argparse
import sys
import urllib.request
import json
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "knowledge_base"
def make_request(url, data=None, method="GET"):
"""Make HTTP request with proper method"""
req = urllib.request.Request(url, method=method)
if data:
req.data = json.dumps(data).encode()
req.add_header("Content-Type", "application/json")
return req
def collection_exists():
"""Check if collection exists"""
try:
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION_NAME}")
with urllib.request.urlopen(req, timeout=5) as response:
return True
except urllib.error.HTTPError as e:
if e.code == 404:
return False
raise
except Exception as e:
print(f"Error checking collection: {e}", file=sys.stderr)
return False
def create_collection():
"""Create the knowledge_base collection using PUT"""
config = {
"vectors": {
"size": 768,
"distance": "Cosine"
}
}
req = make_request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}",
data=config,
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
return result.get("result") == True
except Exception as e:
print(f"Error creating collection: {e}", file=sys.stderr)
return False
def delete_collection():
"""Delete collection if exists"""
req = make_request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}",
method="DELETE"
)
try:
with urllib.request.urlopen(req, timeout=5) as response:
return True
except Exception as e:
print(f"Error deleting collection: {e}", file=sys.stderr)
return False
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Initialize Qdrant knowledge_base collection")
parser.add_argument("--recreate", action="store_true", help="Delete and recreate collection")
args = parser.parse_args()
try:
req = make_request(f"{QDRANT_URL}/")
with urllib.request.urlopen(req, timeout=3) as response:
pass
except Exception as e:
print(f"❌ Cannot connect to Qdrant at {QDRANT_URL}: {e}", file=sys.stderr)
sys.exit(1)
print(f"✅ Connected to Qdrant at {QDRANT_URL}")
exists = collection_exists()
if exists and args.recreate:
print(f"Deleting existing collection '{COLLECTION_NAME}'...")
if delete_collection():
print(f"✅ Deleted collection")
exists = False
else:
print(f"❌ Failed to delete collection", file=sys.stderr)
sys.exit(1)
if not exists:
print(f"Creating collection '{COLLECTION_NAME}'...")
if create_collection():
print(f"✅ Created collection '{COLLECTION_NAME}'")
print(f" Vector size: 768, Distance: Cosine")
else:
print(f"❌ Failed to create collection", file=sys.stderr)
sys.exit(1)
else:
print(f"✅ Collection '{COLLECTION_NAME}' already exists")
print("\n🎉 Knowledge base collection ready!")
+1 -1
View File
@@ -15,7 +15,7 @@ from pathlib import Path
QDRANT_URL = "http://10.0.0.40:6333" QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION = "kimi_kb" COLLECTION = "kimi_kb"
OLLAMA_URL = "http://localhost:11434/v1" OLLAMA_URL = "http://10.0.0.10:11434/v1"
def get_embedding(text): def get_embedding(text):
"""Generate embedding using snowflake-arctic-embed2""" """Generate embedding using snowflake-arctic-embed2"""
+19 -274
View File
@@ -1,85 +1,25 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Store content to kimi_kb (Knowledge Base) - Manual only with batch support Store content to kimi_kb (Knowledge Base) - Manual only
Usage: Usage:
Single entry: python3 kb_store.py "Content text" --title "Title" --domain "Category" --tags "tag1,tag2"
python3 kb_store.py "Content text" --title "Title" --domain "Category" --tags "tag1,tag2" python3 kb_store.py "Content" --title "X" --url "https://example.com" --source "docs.site"
python3 kb_store.py "Content" --title "X" --url "https://example.com" --source "docs.site"
Batch mode:
python3 kb_store.py --batch-file entries.json --batch-size 100
Features:
- Single or batch upload
- Duplicate detection by title/URL
- Domain categorization
- Access tracking
""" """
import argparse
import json import json
import os import os
import sys import sys
import urllib.request import urllib.request
import urllib.error
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import List, Optional, Dict, Any
QDRANT_URL = "http://10.0.0.40:6333" QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION = "kimi_kb" COLLECTION = "kimi_kb"
OLLAMA_URL = "http://localhost:11434/v1" OLLAMA_URL = "http://10.0.0.10:11434/v1"
DEFAULT_BATCH_SIZE = 100
def get_embedding(text):
def check_existing(title: str = None, url: str = None) -> tuple:
"""Check if entry already exists by title or URL"""
try:
# Check by URL first if provided
if url:
scroll_data = json.dumps({
"limit": 10,
"with_payload": True,
"filter": {"must": [{"key": "url", "match": {"value": url}}]}
}).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points/scroll",
data=scroll_data,
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
points = result.get("result", {}).get("points", [])
if points:
return points[0]["id"], "url"
# Check by title
if title:
scroll_data = json.dumps({
"limit": 10,
"with_payload": True,
"filter": {"must": [{"key": "title", "match": {"value": title}}]}
}).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points/scroll",
data=scroll_data,
headers={"Content-Type": "application/json"},
method="POST"
)
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
points = result.get("result", {}).get("points", [])
if points:
return points[0]["id"], "title"
except Exception as e:
print(f"Warning: Could not check existing: {e}", file=sys.stderr)
return None, None
def get_embedding(text: str) -> Optional[List[float]]:
"""Generate embedding using snowflake-arctic-embed2""" """Generate embedding using snowflake-arctic-embed2"""
data = json.dumps({ data = json.dumps({
"model": "snowflake-arctic-embed2", "model": "snowflake-arctic-embed2",
@@ -100,88 +40,15 @@ def get_embedding(text: str) -> Optional[List[float]]:
print(f"Error generating embedding: {e}", file=sys.stderr) print(f"Error generating embedding: {e}", file=sys.stderr)
return None return None
def store_to_kb(text, title=None, url=None, source=None, domain=None,
def batch_upload_embeddings(texts: List[str]) -> List[Optional[List[float]]]: tags=None, content_type="document"):
"""Generate embeddings for multiple texts in batch""" """Store content to kimi_kb collection"""
if not texts:
return []
data = json.dumps({ embedding = get_embedding(text)
"model": "snowflake-arctic-embed2", if embedding is None:
"input": [t[:8192] for t in texts] return False
}).encode()
req = urllib.request.Request( point_id = str(uuid.uuid4())
f"{OLLAMA_URL}/embeddings",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=120) as response:
result = json.loads(response.read().decode())
return [d["embedding"] for d in result["data"]]
except Exception as e:
print(f"Error generating batch embeddings: {e}", file=sys.stderr)
return [None] * len(texts)
def upload_points_batch(points: List[Dict[str, Any]], batch_size: int = DEFAULT_BATCH_SIZE) -> tuple:
"""Upload points in batches to Qdrant"""
total = len(points)
uploaded = 0
failed = 0
for i in range(0, total, batch_size):
batch = points[i:i + batch_size]
upsert_data = {"points": batch}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points?wait=true",
data=json.dumps(upsert_data).encode(),
headers={"Content-Type": "application/json"},
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=60) as response:
result = json.loads(response.read().decode())
if result.get("status") == "ok":
uploaded += len(batch)
print(f" ✅ Uploaded batch {i//batch_size + 1}: {len(batch)} points")
else:
print(f" ❌ Batch {i//batch_size + 1} failed: {result}")
failed += len(batch)
except Exception as e:
print(f" ❌ Batch {i//batch_size + 1} error: {e}", file=sys.stderr)
failed += len(batch)
return uploaded, failed
def store_single(
text: str,
embedding: List[float],
title: str = None,
url: str = None,
source: str = None,
domain: str = "general",
tags: List[str] = None,
content_type: str = "document",
replace: bool = False
) -> bool:
"""Store single KB entry"""
# Check for existing entry
existing_id, match_type = check_existing(title=title, url=url)
if existing_id:
if not replace:
print(f"⚠️ Entry '{title}' already exists (matched by {match_type}, ID: {existing_id})")
print(f" Use --replace to overwrite")
return False
point_id = existing_id if existing_id else str(uuid.uuid4())
payload = { payload = {
"text": text, "text": text,
@@ -220,160 +87,38 @@ def store_single(
print(f"Error storing to KB: {e}", file=sys.stderr) print(f"Error storing to KB: {e}", file=sys.stderr)
return False return False
def store_batch(
entries: List[Dict[str, Any]],
batch_size: int = DEFAULT_BATCH_SIZE,
check_duplicates: bool = True
) -> tuple:
"""Store multiple KB entries in batch with optional duplicate checking"""
if not entries:
return 0, 0
print(f"Processing {len(entries)} entries...")
# Filter duplicates if requested
entries_to_process = []
duplicates = 0
if check_duplicates:
for entry in entries:
existing_id, match_type = check_existing(
title=entry.get("title"),
url=entry.get("url")
)
if existing_id:
print(f" ⏭️ Skipping duplicate: {entry.get('title', 'Untitled')} ({match_type})")
duplicates += 1
else:
entries_to_process.append(entry)
else:
entries_to_process = entries
if not entries_to_process:
print(f"All {len(entries)} entries already exist")
return 0, 0
print(f"Generating embeddings for {len(entries_to_process)} entries...")
texts = [e["content"] for e in entries_to_process]
embeddings = batch_upload_embeddings(texts)
# Prepare points
points = []
failed_embeddings = 0
for entry, embedding in zip(entries_to_process, embeddings):
if embedding is None:
failed_embeddings += 1
continue
point_id = str(uuid.uuid4())
payload = {
"text": entry["content"],
"title": entry.get("title", "Untitled"),
"url": entry.get("url", ""),
"source": entry.get("source", "manual"),
"domain": entry.get("domain", "general"),
"tags": entry.get("tags", []),
"content_type": entry.get("type", "document"),
"date": datetime.now().strftime("%Y-%m-%d"),
"created_at": datetime.now().isoformat(),
"access_count": 0
}
points.append({
"id": point_id,
"vector": embedding,
"payload": payload
})
if not points:
return 0, failed_embeddings + duplicates
# Upload in batches
print(f"Uploading {len(points)} entries in batches of {batch_size}...")
uploaded, failed_upload = upload_points_batch(points, batch_size)
return uploaded, failed_embeddings + failed_upload + duplicates
def main(): def main():
import argparse
parser = argparse.ArgumentParser(description="Store content to kimi_kb") parser = argparse.ArgumentParser(description="Store content to kimi_kb")
parser.add_argument("content", nargs="?", help="Content to store") parser.add_argument("content", help="Content to store")
parser.add_argument("--title", default=None, help="Title of the content") parser.add_argument("--title", default=None, help="Title of the content")
parser.add_argument("--url", default=None, help="Source URL if from web") parser.add_argument("--url", default=None, help="Source URL if from web")
parser.add_argument("--source", default=None, help="Source name") parser.add_argument("--source", default=None, help="Source name (e.g., 'docs.openclaw.ai')")
parser.add_argument("--domain", default="general", help="Domain/category") parser.add_argument("--domain", default="general", help="Domain/category (e.g., 'OpenClaw', 'Docker')")
parser.add_argument("--tags", default=None, help="Comma-separated tags") parser.add_argument("--tags", default=None, help="Comma-separated tags")
parser.add_argument("--type", default="document", choices=["document", "web", "code", "note"], parser.add_argument("--type", default="document", choices=["document", "web", "code", "note"],
help="Content type") help="Content type")
parser.add_argument("--replace", action="store_true", help="Replace existing entry")
parser.add_argument("--batch-file", help="JSON file with multiple entries")
parser.add_argument("--batch-size", type=int, default=DEFAULT_BATCH_SIZE, help=f"Batch size")
parser.add_argument("--no-check-duplicates", action="store_true", help="Skip duplicate checking in batch mode")
args = parser.parse_args() args = parser.parse_args()
# Batch mode
if args.batch_file:
print(f"Batch mode: Loading entries from {args.batch_file}")
try:
with open(args.batch_file, 'r') as f:
entries = json.load(f)
if not isinstance(entries, list):
print("Batch file must contain a JSON array", file=sys.stderr)
sys.exit(1)
print(f"Loaded {len(entries)} entries")
uploaded, failed = store_batch(
entries,
args.batch_size,
check_duplicates=not args.no_check_duplicates
)
print(f"\n{'=' * 50}")
print(f"Batch complete: {uploaded} uploaded, {failed} failed")
sys.exit(0 if failed == 0 else 1)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)
# Single entry mode
if not args.content:
print("Error: Provide content or use --batch-file", file=sys.stderr)
parser.print_help()
sys.exit(1)
tags = [t.strip() for t in args.tags.split(",")] if args.tags else [] tags = [t.strip() for t in args.tags.split(",")] if args.tags else []
print(f"Generating embedding...")
embedding = get_embedding(args.content)
if embedding is None:
print("❌ Failed to generate embedding")
sys.exit(1)
print(f"Storing to kimi_kb: {args.title or 'Untitled'}...") print(f"Storing to kimi_kb: {args.title or 'Untitled'}...")
if store_single( if store_to_kb(
text=args.content, text=args.content,
embedding=embedding,
title=args.title, title=args.title,
url=args.url, url=args.url,
source=args.source, source=args.source,
domain=args.domain, domain=args.domain,
tags=tags, tags=tags,
content_type=args.type, content_type=args.type
replace=args.replace
): ):
print(f"✅ Stored to kimi_kb ({args.domain})") print(f"✅ Stored to kimi_kb ({args.domain})")
else: else:
print("❌ Failed to store") print("❌ Failed to store")
sys.exit(1) sys.exit(1)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
-102
View File
@@ -1,102 +0,0 @@
#!/usr/bin/env python3
"""LLM Router for cheap metadata + compaction.
Goal:
- Prefer Minimax m2.5 for tagging + compaction.
- Fallback to Gemini Flash (or any other OpenRouter model) if Minimax fails.
This uses OpenRouter's OpenAI-compatible API.
Env:
OPENROUTER_API_KEY (required)
OPENROUTER_BASE_URL default: https://openrouter.ai/api/v1
LLM_PRIMARY_MODEL default: openrouter/minimax/minimax-m2.5
LLM_FALLBACK_MODEL default: openrouter/google/gemini-2.5-flash
LLM_TIMEOUT default: 60
Notes:
- We keep this dependency-light (urllib only).
- We request strict JSON when asked.
"""
import json
import os
import sys
import urllib.request
BASE_URL = os.getenv("OPENROUTER_BASE_URL", "https://openrouter.ai/api/v1").rstrip("/")
API_KEY = os.getenv("OPENROUTER_API_KEY", "")
PRIMARY_MODEL = os.getenv("LLM_PRIMARY_MODEL", "openrouter/minimax/minimax-m2.5")
FALLBACK_MODEL = os.getenv("LLM_FALLBACK_MODEL", "openrouter/google/gemini-2.5-flash")
TIMEOUT = int(os.getenv("LLM_TIMEOUT", "60"))
def _post_chat(model: str, messages, response_format=None, temperature=0.2):
if not API_KEY:
raise RuntimeError("OPENROUTER_API_KEY is required")
body = {
"model": model,
"messages": messages,
"temperature": temperature,
}
if response_format:
body["response_format"] = response_format
req = urllib.request.Request(
f"{BASE_URL}/chat/completions",
data=json.dumps(body).encode("utf-8"),
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {API_KEY}",
},
)
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return json.loads(r.read().decode("utf-8"))
def chat_json(system: str, user: str) -> dict:
"""Return parsed JSON object. Try primary then fallback."""
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
last_err = None
for model in (PRIMARY_MODEL, FALLBACK_MODEL):
try:
resp = _post_chat(model, messages, response_format={"type": "json_object"}, temperature=0.2)
content = resp["choices"][0]["message"]["content"]
return json.loads(content)
except Exception as e:
last_err = e
continue
raise RuntimeError(f"LLM failed on both primary and fallback: {last_err}")
def chat_text(system: str, user: str) -> str:
"""Return text. Try primary then fallback."""
messages = [
{"role": "system", "content": system},
{"role": "user", "content": user},
]
last_err = None
for model in (PRIMARY_MODEL, FALLBACK_MODEL):
try:
resp = _post_chat(model, messages, response_format=None, temperature=0.2)
return resp["choices"][0]["message"]["content"]
except Exception as e:
last_err = e
continue
raise RuntimeError(f"LLM failed on both primary and fallback: {last_err}")
if __name__ == "__main__":
# tiny self-test
if len(sys.argv) > 1 and sys.argv[1] == "--ping":
out = chat_json("Return JSON with key ok=true", "ping")
print(json.dumps(out))
View File
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
Memory decay system - handle expiration and cleanup
Usage: memory_decay.py check|cleanup
"""
import argparse
import json
import sys
import urllib.request
from datetime import datetime, timedelta
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "openclaw_memories"
def get_expired_memories():
"""Find memories that have passed their expiration date"""
today = datetime.now().strftime("%Y-%m-%d")
# Search for memories with expires_at <= today
search_body = {
"filter": {
"must": [
{
"key": "expires_at",
"range": {
"lte": today
}
}
]
},
"limit": 100,
"with_payload": True
}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
data=json.dumps(search_body).encode(),
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
return result.get("result", {}).get("points", [])
except Exception as e:
print(f"Error finding expired memories: {e}", file=sys.stderr)
return []
def get_stale_memories(days=90):
"""Find memories not accessed in a long time"""
cutoff = (datetime.now() - timedelta(days=days)).isoformat()
search_body = {
"filter": {
"must": [
{
"key": "last_accessed",
"range": {
"lte": cutoff
}
},
{
"key": "importance",
"match": {
"value": "low"
}
}
]
},
"limit": 100,
"with_payload": True
}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/scroll",
data=json.dumps(search_body).encode(),
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
return result.get("result", {}).get("points", [])
except Exception as e:
print(f"Error finding stale memories: {e}", file=sys.stderr)
return []
def delete_memory(point_id):
"""Delete a memory from Qdrant"""
delete_body = {
"points": [point_id]
}
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION_NAME}/points/delete?wait=true",
data=json.dumps(delete_body).encode(),
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=10) as response:
result = json.loads(response.read().decode())
return result.get("status") == "ok"
except Exception as e:
print(f"Error deleting memory {point_id}: {e}", file=sys.stderr)
return False
def update_access_count(point_id):
"""Increment access count for a memory"""
# This would require reading then writing the point
# Simplified: just update last_accessed
pass
def check_decay():
"""Check what memories are expired or stale"""
print("🔍 Memory Decay Check")
print("=" * 40)
expired = get_expired_memories()
print(f"\n📅 Expired memories: {len(expired)}")
for m in expired:
text = m["payload"].get("text", "")[:60]
expires = m["payload"].get("expires_at", "unknown")
print(f" [{expires}] {text}...")
stale = get_stale_memories(90)
print(f"\n🕐 Stale memories (90+ days): {len(stale)}")
for m in stale:
text = m["payload"].get("text", "")[:60]
last_access = m["payload"].get("last_accessed", "unknown")
print(f" [{last_access[:10]}] {text}...")
return expired, stale
def cleanup_memories(dry_run=True):
"""Remove expired and very stale memories"""
print("🧹 Memory Cleanup")
print("=" * 40)
if dry_run:
print("(DRY RUN - no actual deletions)")
expired = get_expired_memories()
deleted = 0
print(f"\nDeleting {len(expired)} expired memories...")
for m in expired:
point_id = m["id"]
text = m["payload"].get("text", "")[:40]
if not dry_run:
if delete_memory(point_id):
print(f" ✅ Deleted: {text}...")
deleted += 1
else:
print(f" ❌ Failed: {text}...")
else:
print(f" [would delete] {text}...")
# Only delete very stale (180 days) low-importance memories
very_stale = get_stale_memories(180)
print(f"\nDeleting {len(very_stale)} very stale (180+ days) low-importance memories...")
for m in very_stale:
point_id = m["id"]
text = m["payload"].get("text", "")[:40]
if not dry_run:
if delete_memory(point_id):
print(f" ✅ Deleted: {text}...")
deleted += 1
else:
print(f" ❌ Failed: {text}...")
else:
print(f" [would delete] {text}...")
if dry_run:
print(f"\n⚠️ This was a dry run. Use --no-dry-run to actually delete.")
else:
print(f"\n✅ Deleted {deleted} memories")
return deleted
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Memory decay management")
parser.add_argument("action", choices=["check", "cleanup", "status"])
parser.add_argument("--no-dry-run", action="store_true", help="Actually delete (default is dry run)")
parser.add_argument("--days", type=int, default=90, help="Days for stale threshold")
args = parser.parse_args()
if args.action == "check":
expired, stale = check_decay()
total = len(expired) + len(stale)
print(f"\n📊 Total decayed memories: {total}")
sys.exit(0 if total == 0 else 1)
elif args.action == "cleanup":
deleted = cleanup_memories(dry_run=not args.no_dry_run)
sys.exit(0)
elif args.action == "status":
expired, stale = check_decay()
print(f"\n📊 Decay Status")
print(f" Expired: {len(expired)}")
print(f" Stale ({args.days}+ days): {len(stale)}")
print(f" Total decayed: {len(expired) + len(stale)}")
@@ -1,190 +0,0 @@
#!/usr/bin/env python3
"""Metadata + Compaction pipeline.
This script is designed to be run on a schedule (cron). It will:
1) Detect if anything new exists in Redis buffer since last run.
2) If new content exists, generate:
- title
- tags
- entities
- category
- compact summary
using a cheap LLM (Minimax m2.5) with fallback (Gemini Flash)
3) Store the metadata + summary into Qdrant as a single point (collection: kimi_kb by default)
while leaving raw transcripts in files/Redis.
It is intentionally conservative: if nothing new, it exits quickly.
Env:
REDIS_HOST/REDIS_PORT
QDRANT_URL
QDRANT_META_COLLECTION (default: kimi_kb)
OPENROUTER_API_KEY (required for LLM)
LLM_PRIMARY_MODEL / LLM_FALLBACK_MODEL
Usage:
python3 metadata_and_compact.py --user-id michael
python3 metadata_and_compact.py --user-id michael --max-items 200
"""
import argparse
import json
import os
import sys
import uuid
from datetime import datetime
import redis
from llm_router import chat_json
REDIS_HOST = os.getenv("REDIS_HOST", "127.0.0.1")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
QDRANT_URL = os.getenv("QDRANT_URL", "http://127.0.0.1:6333").rstrip("/")
META_COLLECTION = os.getenv("QDRANT_META_COLLECTION", "kimi_kb")
STATE_DIR = os.getenv("MEMORY_STATE_DIR", os.path.join(os.path.expanduser("~"), ".openclaw", "memory_state"))
SYSTEM_PROMPT = (
"You are a metadata extractor and compactor for conversation logs. "
"Return STRICT JSON with keys: title (string), category (string), "
"tags (array of short lowercase hyphenated strings), entities (array of strings), "
"summary (string, <= 1200 chars). "
"Prefer 6-14 tags. Tags should be searchable facets (client/project/infra/topic)."
)
def _state_path(user_id: str) -> str:
os.makedirs(STATE_DIR, exist_ok=True)
return os.path.join(STATE_DIR, f"meta_state_{user_id}.json")
def load_state(user_id: str) -> dict:
p = _state_path(user_id)
if not os.path.exists(p):
return {"last_redis_len": 0, "updated_at": None}
try:
with open(p, "r") as f:
return json.load(f)
except Exception:
return {"last_redis_len": 0, "updated_at": None}
def save_state(user_id: str, st: dict) -> None:
p = _state_path(user_id)
st["updated_at"] = datetime.utcnow().isoformat() + "Z"
with open(p, "w") as f:
json.dump(st, f, indent=2, sort_keys=True)
def redis_get_new_items(user_id: str, max_items: int, last_len: int):
r = redis.Redis(host=REDIS_HOST, port=REDIS_PORT, decode_responses=True)
key = f"mem:{user_id}"
cur_len = r.llen(key)
if cur_len <= last_len:
return [], cur_len
# Only grab the delta (best effort). Our list is chronological if RPUSH is used.
start = last_len
end = min(cur_len - 1, last_len + max_items - 1)
items = r.lrange(key, start, end)
turns = []
for it in items:
try:
turns.append(json.loads(it))
except Exception:
continue
return turns, cur_len
def qdrant_upsert(point_id: str, vector, payload: dict):
body = {"points": [{"id": point_id, "vector": vector, "payload": payload}]}
import urllib.request
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{META_COLLECTION}/points?wait=true",
data=json.dumps(body).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="PUT",
)
with urllib.request.urlopen(req, timeout=15) as resp:
out = json.loads(resp.read().decode("utf-8"))
return out.get("status") == "ok"
def ollama_embed(text: str):
# Uses the same Ollama embed endpoint as auto_store
import urllib.request
ollama_url = os.getenv("OLLAMA_URL", "http://127.0.0.1:11434/v1")
data = json.dumps({"model": "snowflake-arctic-embed2", "input": text[:8192]}).encode("utf-8")
req = urllib.request.Request(
f"{ollama_url}/embeddings",
data=data,
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=60) as resp:
out = json.loads(resp.read().decode("utf-8"))
return out["data"][0]["embedding"]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--user-id", required=True)
ap.add_argument("--max-items", type=int, default=200)
args = ap.parse_args()
st = load_state(args.user_id)
last_len = int(st.get("last_redis_len", 0))
turns, cur_len = redis_get_new_items(args.user_id, args.max_items, last_len)
if not turns:
print("No new turns; skipping")
return
# Build compact source text
lines = []
for t in turns:
role = t.get("role", "")
content = t.get("content", "")
if not content:
continue
lines.append(f"{role.upper()}: {content}")
source_text = "\n".join(lines)
meta = chat_json(SYSTEM_PROMPT, source_text[:24000])
# basic validation
for k in ("title", "category", "tags", "entities", "summary"):
if k not in meta:
raise SystemExit(f"Missing key in meta: {k}")
summary = str(meta.get("summary", ""))[:2000]
emb = ollama_embed(summary)
payload = {
"user_id": args.user_id,
"title": str(meta.get("title", ""))[:200],
"category": str(meta.get("category", ""))[:120],
"tags": meta.get("tags", [])[:30],
"entities": meta.get("entities", [])[:30],
"summary": summary,
"source": "redis_delta",
"created_at": datetime.utcnow().isoformat() + "Z",
"redis_range": {"from": last_len, "to": cur_len - 1},
}
ok = qdrant_upsert(str(uuid.uuid4()), emb, payload)
if not ok:
raise SystemExit("Failed to upsert metadata point")
st["last_redis_len"] = cur_len
save_state(args.user_id, st)
print(f"Stored metadata point for {args.user_id} (redis {last_len}->{cur_len})")
if __name__ == "__main__":
main()
@@ -1,158 +0,0 @@
#!/usr/bin/env python3
"""
Migrate Qdrant_Documents to 1024D vectors (snowflake-arctic-embed2) - BATCH VERSION
"""
import json
import sys
import urllib.request
import uuid
from datetime import datetime
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION = "Qdrant_Documents"
OLLAMA_URL = "http://localhost:11434/v1"
EXPORT_FILE = "/tmp/qd_export.json"
BATCH_SIZE = 50
def get_embeddings_batch(texts):
"""Generate embeddings in batch using snowflake-arctic-embed2"""
# Truncate each text
truncated = [t[:8000] for t in texts]
data = json.dumps({
"model": "snowflake-arctic-embed2",
"input": truncated
}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/embeddings",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=180) as r:
result = json.loads(r.read().decode())
return [item["embedding"] for item in result["data"]]
except Exception as e:
print(f"Batch embed error: {e}", file=sys.stderr)
return None
def make_request(url, data=None, method="GET"):
req = urllib.request.Request(url, method=method)
if data:
req.data = json.dumps(data).encode()
req.add_header("Content-Type", "application/json")
return req
def delete_collection():
print(f"Deleting {COLLECTION}...")
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION}", method="DELETE")
try:
with urllib.request.urlopen(req, timeout=10) as r:
print(f"✅ Deleted")
except Exception as e:
print(f"Delete error: {e}")
def create_collection():
print(f"Creating {COLLECTION} with 1024D vectors...")
config = {
"vectors": {
"size": 1024,
"distance": "Cosine"
}
}
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION}", data=config, method="PUT")
try:
with urllib.request.urlopen(req, timeout=30) as r:
result = json.loads(r.read().decode())
if result.get("result") == True:
print(f"✅ Created (1024D, Cosine)")
else:
print(f"❌ Failed: {result}")
sys.exit(1)
except Exception as e:
print(f"❌ Create error: {e}")
sys.exit(1)
def upsert_batch(points):
"""Upsert batch of points"""
data = json.dumps({"points": points}).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points?wait=true",
data=data,
headers={"Content-Type": "application/json"},
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode()).get("status") == "ok"
except Exception as e:
print(f"Upsert error: {e}", file=sys.stderr)
return False
# Load exported docs
print(f"Loading {EXPORT_FILE}...")
with open(EXPORT_FILE, 'r') as f:
docs = json.load(f)
print(f"Loaded {len(docs)} documents\n")
# Delete and recreate
delete_collection()
create_collection()
print()
# Process in batches
print(f"Re-embedding with snowflake-arctic-embed2 (batch={BATCH_SIZE})...\n")
success = 0
failed = 0
total_batches = (len(docs) + BATCH_SIZE - 1) // BATCH_SIZE
for batch_num in range(total_batches):
start = batch_num * BATCH_SIZE
end = min(start + BATCH_SIZE, len(docs))
batch_docs = docs[start:end]
print(f"Batch {batch_num + 1}/{total_batches} ({start}-{end})...", end=" ", flush=True)
# Get texts for embedding
texts = [d.get("payload", {}).get("text", "") for d in batch_docs]
# Get embeddings
embeddings = get_embeddings_batch(texts)
if not embeddings:
print(f"❌ embed failed")
failed += len(batch_docs)
continue
# Build points
points = []
for doc, emb in zip(batch_docs, embeddings):
points.append({
"id": doc.get("id", str(uuid.uuid4())),
"vector": emb,
"payload": doc.get("payload", {})
})
# Upsert
if upsert_batch(points):
success += len(batch_docs)
print(f"")
else:
failed += len(batch_docs)
print(f"")
print()
print("=" * 50)
print(f"MIGRATION COMPLETE")
print(f" Success: {success}")
print(f" Failed: {failed}")
print(f" Total: {len(docs)}")
print("=" * 50)
# Verify
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION}")
with urllib.request.urlopen(req, timeout=5) as r:
info = json.loads(r.read().decode())["result"]
print(f"\n📚 {COLLECTION}")
print(f" Points: {info['points_count']:,}")
print(f" Vector size: {info['config']['params']['vectors']['size']}")
print(f" Distance: {info['config']['params']['vectors']['distance']}")
@@ -70,7 +70,7 @@ def extract_models(html):
def get_embedding(text): def get_embedding(text):
data = {"model": "nomic-embed-text", "input": text[:500]} data = {"model": "nomic-embed-text", "input": text[:500]}
req = urllib.request.Request("http://localhost:11434/api/embed", req = urllib.request.Request("http://10.0.0.10:11434/api/embed",
data=json.dumps(data).encode(), data=json.dumps(data).encode(),
headers={"Content-Type": "application/json"}, method="POST") headers={"Content-Type": "application/json"}, method="POST")
try: try:
@@ -64,7 +64,7 @@ def get_embedding(text):
import json as jsonlib import json as jsonlib
data = {"model": "nomic-embed-text", "input": text[:1000]} data = {"model": "nomic-embed-text", "input": text[:1000]}
req = urllib.request.Request( req = urllib.request.Request(
"http://localhost:11434/api/embed", "http://10.0.0.10:11434/api/embed",
data=jsonlib.dumps(data).encode(), data=jsonlib.dumps(data).encode(),
headers={"Content-Type": "application/json"}, headers={"Content-Type": "application/json"},
method="POST" method="POST"
-70
View File
@@ -1,70 +0,0 @@
#!/usr/bin/env python3
"""
Q Save - Trigger conversation storage (Mem0-style)
Usage:
q_save.py --user-id "rob" "User message" "AI response" [--turn N]
Called when user says "save q" or "q save" to immediately
store the current conversation to Qdrant.
Mem0-style: user_id is REQUIRED and persistent across all chats.
"""
import argparse
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
BACKGROUND_STORE = SCRIPT_DIR / "background_store.py"
def q_save(
user_id: str,
user_message: str,
ai_response: str,
turn: int = None
):
"""Save conversation to Qdrant (background, zero delay)"""
cmd = [
sys.executable,
str(BACKGROUND_STORE),
user_message,
ai_response,
"--user-id", user_id
]
if turn:
cmd.extend(["--turn", str(turn)])
# Fire and forget
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True
)
return True
def main():
parser = argparse.ArgumentParser(
description='Q Save - Mem0-style trigger (user-centric)'
)
parser.add_argument("--user-id", required=True,
help="REQUIRED: User ID (e.g., 'rob')")
parser.add_argument("user_message", help="User's message")
parser.add_argument("ai_response", help="AI's response")
parser.add_argument("--turn", type=int, help="Turn number")
args = parser.parse_args()
if q_save(args.user_id, args.user_message, args.ai_response, args.turn):
print(f"✅ Saved for user '{args.user_id}'")
else:
print("❌ Failed to save", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()
-427
View File
@@ -1,427 +0,0 @@
#!/usr/bin/env python3
"""
Qdrant_Documents - Complete management script
Usage: qd.py <command> [options]
Commands:
list - List collection info and stats
search - Search documents
store - Store new document
delete - Delete document by ID
export - Export all documents to JSON
import - Import documents from JSON
count - Get total document count
tags - List unique tags
"""
import argparse
import json
import sys
import urllib.request
import uuid
from datetime import datetime
QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION = "Qdrant_Documents"
OLLAMA_URL = "http://localhost:11434/v1"
# ============================================================================
# UTILITIES
# ============================================================================
def get_embedding(text, model="nomic-embed-text"):
"""Generate embedding using Ollama"""
data = json.dumps({"model": model, "input": text[:8000]}).encode()
req = urllib.request.Request(
f"{OLLAMA_URL}/embeddings",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read().decode())["data"][0]["embedding"]
except Exception as e:
print(f"Embedding error: {e}", file=sys.stderr)
return None
def make_request(url, data=None, method="GET"):
"""Make HTTP request"""
req = urllib.request.Request(url, method=method)
if data:
req.data = json.dumps(data).encode()
req.add_header("Content-Type", "application/json")
return req
def check_collection():
"""Verify collection exists"""
try:
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION}")
with urllib.request.urlopen(req, timeout=5) as r:
return r.read()
except:
return None
# ============================================================================
# COMMANDS
# ============================================================================
def cmd_list(args):
"""List collection info"""
data = check_collection()
if not data:
print(f"❌ Collection '{COLLECTION}' not found")
sys.exit(1)
info = json.loads(data.decode())["result"]
print(f"\n📚 Collection: {COLLECTION}")
print(f" Status: {info['status']}")
print(f" Points: {info['points_count']:,}")
print(f" Vectors: {info['indexed_vectors_count']:,}")
print(f" Segments: {info['segments_count']}")
print(f" Vector size: {info['config']['params']['vectors']['size']}")
print(f" Distance: {info['config']['params']['vectors']['distance']}")
print(f" Optimizer: {info['optimizer_status']}")
print()
# Show payload schema
print("📋 Payload Schema:")
for field, schema in info.get("payload_schema", {}).items():
if isinstance(schema, dict) and "data_type" in schema:
print(f" - {field}: {schema['data_type']} ({schema.get('points',0):,} points)")
print()
def cmd_count(args):
"""Get document count"""
req = make_request(f"{QDRANT_URL}/collections/{COLLECTION}")
with urllib.request.urlopen(req, timeout=5) as r:
count = json.loads(r.read().decode())["result"]["points_count"]
print(f"{count}")
def cmd_search(args):
"""Search documents"""
embedding = get_embedding(args.query)
if not embedding:
print("❌ Failed to generate embedding")
sys.exit(1)
search_body = {
"vector": embedding,
"limit": args.limit,
"with_payload": True,
"with_vector": False
}
if args.tag:
search_body["filter"] = {"must": [{"key": "tag", "match": {"value": args.tag}}]}
data = json.dumps(search_body).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points/search",
data=data,
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
results = json.loads(r.read().decode())["result"]
except Exception as e:
print(f"❌ Search failed: {e}")
sys.exit(1)
if not results:
print("No results found")
return
print(f"Found {len(results)} results:\n")
for i, r in enumerate(results, 1):
p = r.get("payload", {})
print(f"[{i}] Score: {r['score']:.3f}")
print(f" Tags: {p.get('tag', 'none')}")
text = p.get('text', '')[:args.chars]
if len(p.get('text', '')) > args.chars:
text += "..."
print(f" Text: {text}")
print()
def cmd_store(args):
"""Store a document"""
# Read from file or use text argument
if args.file:
with open(args.file, 'r') as f:
text = f.read()
else:
text = args.text
if not text:
print("❌ No text to store")
sys.exit(1)
embedding = get_embedding(text)
if not embedding:
print("❌ Failed to generate embedding")
sys.exit(1)
# Parse tags
tags = args.tag.split(",") if args.tag else []
sections = args.section.split(",") if args.section else []
point = {
"points": [{
"id": str(uuid.uuid4()),
"vector": embedding,
"payload": {
"text": text,
"tag": tags,
"sections": sections,
"date": datetime.now().strftime("%Y-%m-%d"),
"created_at": datetime.now().isoformat()
}
}]
}
data = json.dumps(point).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points?wait=true",
data=data,
headers={"Content-Type": "application/json"},
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
result = json.loads(r.read().decode())
if result.get("status") == "ok":
print(f"✅ Stored document ({len(text)} chars, {len(embedding)}D vector)")
else:
print(f"❌ Store failed: {result}")
sys.exit(1)
except Exception as e:
print(f"❌ Store error: {e}")
sys.exit(1)
def cmd_delete(args):
"""Delete a document by ID"""
req = make_request(
f"{QDRANT_URL}/collections/{COLLECTION}/points/{args.id}",
method="DELETE"
)
try:
with urllib.request.urlopen(req, timeout=10) as r:
print(f"✅ Deleted point {args.id}")
except Exception as e:
print(f"❌ Delete error: {e}")
sys.exit(1)
def cmd_export(args):
"""Export all documents to JSON"""
print(f"Exporting {COLLECTION}...", file=sys.stderr)
# Get all points
all_points = []
offset = None
while True:
scroll_body = {"limit": 100, "with_payload": True, "with_vector": False}
if offset:
scroll_body["offset"] = offset
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points/scroll",
data=json.dumps(scroll_body).encode(),
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
result = json.loads(r.read().decode())
points = result.get("result", {}).get("points", [])
if not points:
break
all_points.extend(points)
offset = result.get("result", {}).get("next_page_offset")
if not offset:
break
except Exception as e:
print(f"❌ Export error: {e}")
sys.exit(1)
# Format output
output = []
for p in all_points:
output.append({
"id": p["id"],
"payload": p.get("payload", {})
})
if args.output:
with open(args.output, 'w') as f:
json.dump(output, f, indent=2)
print(f"✅ Exported {len(output)} documents to {args.output}")
else:
print(json.dumps(output, indent=2))
def cmd_import(args):
"""Import documents from JSON"""
with open(args.file, 'r') as f:
documents = json.load(f)
print(f"Importing {len(documents)} documents...")
success = 0
for doc in documents:
text = doc.get("payload", {}).get("text", "")
if not text:
continue
embedding = get_embedding(text)
if not embedding:
print(f" ⚠️ Skipping {doc.get('id')}: embedding failed")
continue
point = {
"points": [{
"id": doc.get("id", str(uuid.uuid4())),
"vector": embedding,
"payload": doc.get("payload", {})
}]
}
data = json.dumps(point).encode()
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points?wait=true",
data=data,
headers={"Content-Type": "application/json"},
method="PUT"
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
if json.loads(r.read().decode()).get("status") == "ok":
success += 1
except:
pass
print(f"✅ Imported {success}/{len(documents)} documents")
def cmd_tags(args):
"""List unique tags"""
# Use scroll to get all tags
all_tags = set()
offset = None
while True:
scroll_body = {"limit": 100, "with_payload": True, "with_vector": False}
if offset:
scroll_body["offset"] = offset
req = urllib.request.Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points/scroll",
data=json.dumps(scroll_body).encode(),
headers={"Content-Type": "application/json"}
)
try:
with urllib.request.urlopen(req, timeout=30) as r:
result = json.loads(r.read().decode())
points = result.get("result", {}).get("points", [])
if not points:
break
for p in points:
tags = p.get("payload", {}).get("tag", [])
if isinstance(tags, list):
all_tags.update(tags)
elif tags:
all_tags.add(tags)
offset = result.get("result", {}).get("next_page_offset")
if not offset:
break
except Exception as e:
print(f"❌ Error: {e}")
sys.exit(1)
print(f"\n🏷️ Unique tags ({len(all_tags)}):")
for tag in sorted(all_tags):
print(f" - {tag}")
print()
# ============================================================================
# MAIN
# ============================================================================
def main():
parser = argparse.ArgumentParser(
description=f"Qdrant_Documents management ({COLLECTION})",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
qd.py list # Show collection stats
qd.py search "docker volumes" # Search documents
qd.py search "query" --tag kubernetes # Filter by tag
qd.py store "text here" --tag "docker" # Store document
qd.py store --file README.md --tag "doc"
qd.py export --output backup.json # Export all
qd.py tags # List all tags
"""
)
subparsers = parser.add_subparsers(dest="cmd", required=True)
# list
subparsers.add_parser("list", help="Show collection info")
# count
subparsers.add_parser("count", help="Get document count")
# search
p_search = subparsers.add_parser("search", help="Search documents")
p_search.add_argument("query", help="Search query")
p_search.add_argument("--tag", help="Filter by tag")
p_search.add_argument("--limit", type=int, default=5)
p_search.add_argument("--chars", type=int, default=200)
# store
p_store = subparsers.add_parser("store", help="Store document")
p_store.add_argument("text", nargs="?", help="Text to store")
p_store.add_argument("--file", help="Read from file")
p_store.add_argument("--tag", help="Comma-separated tags")
p_store.add_argument("--section", help="Comma-separated sections", default="")
# delete
p_delete = subparsers.add_parser("delete", help="Delete by ID")
p_delete.add_argument("id", help="Point ID to delete")
# export
p_export = subparsers.add_parser("export", help="Export to JSON")
p_export.add_argument("--output", "-o", help="Output file")
# import
p_import = subparsers.add_parser("import", help="Import from JSON")
p_import.add_argument("file", help="JSON file to import")
# tags
subparsers.add_parser("tags", help="List unique tags")
args = parser.parse_args()
# Run command
if args.cmd == "list":
cmd_list(args)
elif args.cmd == "count":
cmd_count(args)
elif args.cmd == "search":
cmd_search(args)
elif args.cmd == "store":
cmd_store(args)
elif args.cmd == "delete":
cmd_delete(args)
elif args.cmd == "export":
cmd_export(args)
elif args.cmd == "import":
cmd_import(args)
elif args.cmd == "tags":
cmd_tags(args)
if __name__ == "__main__":
main()
+1 -1
View File
@@ -14,7 +14,7 @@ from html import unescape
QDRANT_URL = "http://10.0.0.40:6333" QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "knowledge_base" COLLECTION_NAME = "knowledge_base"
OLLAMA_EMBED_URL = "http://localhost:11434/api/embed" OLLAMA_EMBED_URL = "http://10.0.0.10:11434/api/embed"
def fetch_url(url): def fetch_url(url):
"""Fetch URL content""" """Fetch URL content"""
@@ -12,11 +12,9 @@ import sys
import urllib.request import urllib.request
from datetime import datetime from datetime import datetime
import os QDRANT_URL = "http://10.0.0.40:6333"
COLLECTION_NAME = "kimi_memories"
QDRANT_URL = os.getenv("QDRANT_URL", "http://127.0.0.1:6333") OLLAMA_URL = "http://10.0.0.10:11434/v1"
COLLECTION_NAME = os.getenv("QDRANT_COLLECTION", "kimi_memories")
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://127.0.0.1:11434/v1")
def get_embedding(text): def get_embedding(text):
"""Generate embedding using snowflake-arctic-embed2 via Ollama""" """Generate embedding using snowflake-arctic-embed2 via Ollama"""
@@ -1,64 +0,0 @@
#!/usr/bin/env python3
"""Send email via Gmail SMTP with attachment support."""
import smtplib
import json
import sys
import os
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
CRED_FILE = "/root/.openclaw/workspace/.gmail_imap.json"
def load_credentials():
with open(CRED_FILE) as f:
return json.load(f)
def send_email(to_email, subject, body, reply_to=None, attachment_path=None):
creds = load_credentials()
smtp_server = "smtp.gmail.com"
smtp_port = 587
msg = MIMEMultipart()
msg['From'] = f"Kimi <{creds['email']}>"
msg['To'] = to_email
msg['Subject'] = subject
if reply_to:
msg['In-Reply-To'] = reply_to
msg['References'] = reply_to
# Attach body
msg.attach(MIMEText(body, 'plain'))
# Attach file if provided
if attachment_path and os.path.exists(attachment_path):
with open(attachment_path, 'rb') as f:
mime_base = MIMEBase('application', 'octet-stream')
mime_base.set_payload(f.read())
encoders.encode_base64(mime_base)
filename = os.path.basename(attachment_path)
mime_base.add_header('Content-Disposition', f'attachment; filename={filename}')
msg.attach(mime_base)
print(f"📎 Attached: {filename}")
with smtplib.SMTP(smtp_server, smtp_port) as server:
server.starttls()
server.login(creds['email'], creds['app_password'])
server.send_message(msg)
print(f"✉️ Sent to {to_email}")
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--to", required=True)
parser.add_argument("--subject", required=True)
parser.add_argument("--body", required=True)
parser.add_argument("--reply-to")
parser.add_argument("--attach", help="Path to file to attach")
args = parser.parse_args()
send_email(args.to, args.subject, args.body, args.reply_to, args.attach)
@@ -1,20 +0,0 @@
#!/bin/bash
# Daily Conversation Backup - 7-Day Sliding Window
# Processes last 7 days to catch any missed conversations
SCRIPT_DIR="/root/.openclaw/workspace/skills/qdrant-memory"
LOG_FILE="/var/log/qdrant-daily-backup.log"
echo "==============================================" >> "$LOG_FILE"
echo "7-Day Sliding Window Backup - $(date)" >> "$LOG_FILE"
echo "==============================================" >> "$LOG_FILE"
# Process last 7 days
for day_offset in -6 -5 -4 -3 -2 -1 0; do
date_str=$(date -d "$day_offset days ago" +%Y-%m-%d)
echo "Processing: $date_str..." >> "$LOG_FILE"
cd "$SCRIPT_DIR" && python3 scripts/daily_conversation_backup.py "$date_str" >> "$LOG_FILE" 2>&1
done
echo "Backup complete at $(date)" >> "$LOG_FILE"
echo "" >> "$LOG_FILE"
+1 -1
View File
@@ -13,7 +13,7 @@ import re
from datetime import datetime from datetime import datetime
QDRANT_URL = "http://10.0.0.40:6333" QDRANT_URL = "http://10.0.0.40:6333"
OLLAMA_EMBED_URL = "http://localhost:11434/api/embed" OLLAMA_EMBED_URL = "http://10.0.0.10:11434/api/embed"
SEARXNG_URL = "http://10.0.0.8:8888" SEARXNG_URL = "http://10.0.0.8:8888"
KB_COLLECTION = "knowledge_base" KB_COLLECTION = "knowledge_base"

Some files were not shown because too many files have changed in this diff Show More