feat: memory v2, prompt styles, Dream/GAZE integration, Wyoming TTS fix

SQLite + sqlite-vec replaces JSON memory files with semantic search,
follow-up injection, privacy levels, and lifecycle management.

Six prompt styles (quick/standard/creative/roleplayer/game-master/storyteller)
with per-style Claude model tiering (Haiku/Sonnet/Opus), temperature control,
and section stripping. Characters can set default style and per-style overrides.

Dream character import and GAZE character linking in the dashboard editor
with auto-populated fields, cover image resolution, and preset assignment.

Bridge: session isolation (conversation_id / 12h satellite buckets),
model routing refactor, PUT/DELETE support, memory REST endpoints.

Dashboard: mobile-responsive sidebar, retry button, style picker in chat,
follow-up banner, memory lifecycle/privacy UI, cloud model options in editor.

Wyoming TTS: upgraded to v1.8.0 for HA 1.7.2 compatibility.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Aodhan Collins
2026-03-24 22:31:04 +00:00
parent c3bae6fdc0
commit 56580a2cb2
34 changed files with 2891 additions and 467 deletions

View File

@@ -0,0 +1,865 @@
#!/usr/bin/env python3
"""
HomeAI Memory Store — SQLite + Vector Search
Replaces flat JSON memory files with a structured SQLite database
using sqlite-vec for semantic similarity search.
Used by:
- openclaw-http-bridge.py (memory retrieval + follow-up injection)
- memory-ctl skill (CLI memory management)
- Dashboard API (REST endpoints via bridge)
"""
import json
import os
import sqlite3
import struct
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Optional
import sqlite_vec
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
DATA_DIR = Path(os.environ.get("DATA_DIR", os.path.expanduser("~/homeai-data")))
MEMORIES_DIR = DATA_DIR / "memories"
DB_PATH = MEMORIES_DIR / "memories.db"
EMBEDDING_DIM = 384 # all-MiniLM-L6-v2
# Privacy keywords for rule-based classification
PRIVACY_KEYWORDS = {
"local_only": [
"health", "illness", "sick", "doctor", "medical", "medication", "surgery",
"salary", "bank", "financial", "debt", "mortgage", "tax",
"depression", "anxiety", "therapy", "divorce", "breakup",
],
"sensitive": [
"address", "phone", "email", "password", "birthday",
],
}
# ---------------------------------------------------------------------------
# Embedding model (lazy-loaded singleton)
# ---------------------------------------------------------------------------
_embedder = None
def _get_embedder():
"""Lazy-load the sentence-transformers model."""
global _embedder
if _embedder is None:
from sentence_transformers import SentenceTransformer
_embedder = SentenceTransformer("all-MiniLM-L6-v2")
return _embedder
def get_embedding(text: str) -> list[float]:
"""Compute a 384-dim embedding for the given text."""
model = _get_embedder()
vec = model.encode(text, normalize_embeddings=True)
return vec.tolist()
def _serialize_f32(vec: list[float]) -> bytes:
"""Serialize a float list to little-endian bytes for sqlite-vec."""
return struct.pack(f"<{len(vec)}f", *vec)
def _deserialize_f32(blob: bytes) -> list[float]:
"""Deserialize sqlite-vec float bytes back to a list."""
n = len(blob) // 4
return list(struct.unpack(f"<{n}f", blob))
# ---------------------------------------------------------------------------
# Database initialization
# ---------------------------------------------------------------------------
_db: Optional[sqlite3.Connection] = None
def init_db() -> sqlite3.Connection:
"""Initialize the SQLite database with schema and sqlite-vec extension."""
global _db
if _db is not None:
return _db
MEMORIES_DIR.mkdir(parents=True, exist_ok=True)
db = sqlite3.connect(str(DB_PATH), check_same_thread=False)
db.enable_load_extension(True)
sqlite_vec.load(db)
db.enable_load_extension(False)
db.row_factory = sqlite3.Row
db.executescript("""
CREATE TABLE IF NOT EXISTS memories (
id TEXT PRIMARY KEY,
character_id TEXT NOT NULL,
content TEXT NOT NULL,
memory_type TEXT NOT NULL DEFAULT 'semantic',
category TEXT NOT NULL DEFAULT 'other',
privacy_level TEXT NOT NULL DEFAULT 'standard',
importance REAL NOT NULL DEFAULT 0.5,
lifecycle_state TEXT NOT NULL DEFAULT 'active',
follow_up_due TEXT,
follow_up_context TEXT,
source TEXT DEFAULT 'user_explicit',
created_at TEXT NOT NULL,
last_accessed TEXT,
expires_at TEXT,
previous_value TEXT,
tags TEXT,
surfaced_count INTEGER DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_memories_character
ON memories(character_id);
CREATE INDEX IF NOT EXISTS idx_memories_lifecycle
ON memories(lifecycle_state);
CREATE INDEX IF NOT EXISTS idx_memories_type
ON memories(memory_type);
""")
# Create the vec0 virtual table for vector search
# sqlite-vec requires this specific syntax
db.execute(f"""
CREATE VIRTUAL TABLE IF NOT EXISTS memory_embeddings USING vec0(
id TEXT PRIMARY KEY,
embedding float[{EMBEDDING_DIM}]
)
""")
# Partial index for follow-ups (created manually since executescript can't
# handle IF NOT EXISTS for partial indexes cleanly on all versions)
try:
db.execute("""
CREATE INDEX idx_memories_followup
ON memories(lifecycle_state, follow_up_due)
WHERE lifecycle_state = 'pending_followup'
""")
except sqlite3.OperationalError:
pass # index already exists
db.commit()
_db = db
return db
def _get_db() -> sqlite3.Connection:
"""Get or initialize the database connection."""
if _db is None:
return init_db()
return _db
def _row_to_dict(row: sqlite3.Row) -> dict:
"""Convert a sqlite3.Row to a plain dict."""
return dict(row)
def _generate_id() -> str:
"""Generate a unique memory ID."""
return f"m_{int(time.time() * 1000)}"
def _now_iso() -> str:
"""Current UTC time as ISO string."""
return datetime.now(timezone.utc).isoformat()
# ---------------------------------------------------------------------------
# Write-time classification (rule-based, Phase 1)
# ---------------------------------------------------------------------------
def classify_memory(content: str) -> dict:
"""Rule-based classification for memory properties.
Returns defaults that can be overridden by explicit parameters."""
content_lower = content.lower()
# Privacy detection
privacy = "standard"
for level, keywords in PRIVACY_KEYWORDS.items():
if any(kw in content_lower for kw in keywords):
privacy = level
break
# Memory type detection
memory_type = "semantic"
temporal_markers = [
"today", "yesterday", "tonight", "this morning", "just now",
"feeling", "right now", "this week", "earlier",
]
if any(kw in content_lower for kw in temporal_markers):
memory_type = "episodic"
# Importance heuristic
importance = 0.5
if privacy == "local_only":
importance = 0.7
elif privacy == "sensitive":
importance = 0.6
return {
"memory_type": memory_type,
"privacy_level": privacy,
"importance": importance,
}
# ---------------------------------------------------------------------------
# CRUD operations
# ---------------------------------------------------------------------------
def add_memory(
character_id: str,
content: str,
memory_type: str | None = None,
category: str = "other",
importance: float | None = None,
privacy_level: str | None = None,
tags: list[str] | None = None,
follow_up_due: str | None = None,
follow_up_context: str | None = None,
source: str = "user_explicit",
expires_at: str | None = None,
) -> dict:
"""Add a new memory record. Auto-classifies fields not explicitly set."""
db = _get_db()
classified = classify_memory(content)
memory_type = memory_type or classified["memory_type"]
privacy_level = privacy_level or classified["privacy_level"]
importance = importance if importance is not None else classified["importance"]
lifecycle_state = "active"
if follow_up_due or follow_up_context:
lifecycle_state = "pending_followup"
if not follow_up_due:
follow_up_due = "next_interaction"
mem_id = _generate_id()
now = _now_iso()
# Generate embedding
embedding = get_embedding(content)
db.execute("""
INSERT INTO memories (
id, character_id, content, memory_type, category,
privacy_level, importance, lifecycle_state,
follow_up_due, follow_up_context, source,
created_at, tags, surfaced_count
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
""", (
mem_id, character_id, content, memory_type, category,
privacy_level, importance, lifecycle_state,
follow_up_due, follow_up_context, source,
now, json.dumps(tags) if tags else None,
))
# Insert embedding into vec0 table
db.execute(
"INSERT INTO memory_embeddings (id, embedding) VALUES (?, ?)",
(mem_id, _serialize_f32(embedding)),
)
db.commit()
return {
"id": mem_id,
"character_id": character_id,
"content": content,
"memory_type": memory_type,
"category": category,
"privacy_level": privacy_level,
"importance": importance,
"lifecycle_state": lifecycle_state,
"follow_up_due": follow_up_due,
"follow_up_context": follow_up_context,
"source": source,
"created_at": now,
"tags": tags,
}
def update_memory(memory_id: str, **fields) -> dict | None:
"""Update specific fields on a memory record."""
db = _get_db()
# Validate that memory exists
row = db.execute("SELECT * FROM memories WHERE id = ?", (memory_id,)).fetchone()
if not row:
return None
allowed = {
"content", "memory_type", "category", "privacy_level", "importance",
"lifecycle_state", "follow_up_due", "follow_up_context", "source",
"last_accessed", "expires_at", "previous_value", "tags", "surfaced_count",
}
updates = {k: v for k, v in fields.items() if k in allowed}
if not updates:
return _row_to_dict(row)
# If content changed, update embedding and store previous value
if "content" in updates:
updates["previous_value"] = row["content"]
embedding = get_embedding(updates["content"])
# Update vec0 table: delete old, insert new
db.execute("DELETE FROM memory_embeddings WHERE id = ?", (memory_id,))
db.execute(
"INSERT INTO memory_embeddings (id, embedding) VALUES (?, ?)",
(memory_id, _serialize_f32(embedding)),
)
if "tags" in updates and isinstance(updates["tags"], list):
updates["tags"] = json.dumps(updates["tags"])
set_clause = ", ".join(f"{k} = ?" for k in updates)
values = list(updates.values()) + [memory_id]
db.execute(f"UPDATE memories SET {set_clause} WHERE id = ?", values)
db.commit()
row = db.execute("SELECT * FROM memories WHERE id = ?", (memory_id,)).fetchone()
return _row_to_dict(row) if row else None
def delete_memory(memory_id: str) -> bool:
"""Delete a memory record and its embedding."""
db = _get_db()
row = db.execute("SELECT id FROM memories WHERE id = ?", (memory_id,)).fetchone()
if not row:
return False
db.execute("DELETE FROM memories WHERE id = ?", (memory_id,))
db.execute("DELETE FROM memory_embeddings WHERE id = ?", (memory_id,))
db.commit()
return True
# ---------------------------------------------------------------------------
# Retrieval
# ---------------------------------------------------------------------------
def retrieve_memories(
character_id: str,
context_text: str = "",
limit: int = 20,
exclude_private_for_cloud: bool = False,
) -> list[dict]:
"""Dual retrieval: semantic similarity + recency, merged and ranked.
If context_text is empty, falls back to recency-only retrieval.
"""
db = _get_db()
privacy_filter = ""
if exclude_private_for_cloud:
privacy_filter = "AND m.privacy_level != 'local_only'"
# Always include high-importance memories
high_importance = db.execute(f"""
SELECT * FROM memories m
WHERE m.character_id = ?
AND m.lifecycle_state IN ('active', 'pending_followup')
AND m.importance > 0.8
{privacy_filter}
ORDER BY m.created_at DESC
LIMIT 5
""", (character_id,)).fetchall()
seen_ids = {r["id"] for r in high_importance}
results = {r["id"]: {**_row_to_dict(r), "_score": 1.0} for r in high_importance}
# Semantic search (if context provided and embeddings exist)
if context_text:
try:
query_emb = get_embedding(context_text)
vec_rows = db.execute("""
SELECT id, distance
FROM memory_embeddings
WHERE embedding MATCH ?
AND k = 30
""", (_serialize_f32(query_emb),)).fetchall()
vec_ids = [r["id"] for r in vec_rows if r["id"] not in seen_ids]
vec_distances = {r["id"]: r["distance"] for r in vec_rows}
if vec_ids:
placeholders = ",".join("?" * len(vec_ids))
sem_rows = db.execute(f"""
SELECT * FROM memories m
WHERE m.id IN ({placeholders})
AND m.character_id = ?
AND m.lifecycle_state IN ('active', 'pending_followup')
{privacy_filter}
""", (*vec_ids, character_id)).fetchall()
for r in sem_rows:
d = _row_to_dict(r)
# Convert cosine distance to similarity (sqlite-vec returns L2 distance for vec0)
dist = vec_distances.get(r["id"], 1.0)
semantic_score = max(0.0, 1.0 - dist)
d["_score"] = 0.6 * semantic_score + 0.1 * d["importance"]
results[r["id"]] = d
seen_ids.add(r["id"])
except Exception as e:
print(f"[MemoryStore] Vector search error: {e}")
# Recency search: last 7 days, ordered by importance + recency
recency_rows = db.execute(f"""
SELECT * FROM memories m
WHERE m.character_id = ?
AND m.lifecycle_state IN ('active', 'pending_followup')
AND m.created_at > datetime('now', '-7 days')
{privacy_filter}
ORDER BY m.importance DESC, m.created_at DESC
LIMIT 10
""", (character_id,)).fetchall()
for r in recency_rows:
if r["id"] not in seen_ids:
d = _row_to_dict(r)
# Recency score based on age in days (newer = higher)
try:
created = datetime.fromisoformat(d["created_at"])
age_days = (datetime.now(timezone.utc) - created).total_seconds() / 86400
recency_score = max(0.0, 1.0 - (age_days / 7.0))
except (ValueError, TypeError):
recency_score = 0.5
d["_score"] = 0.3 * recency_score + 0.1 * d["importance"]
results[r["id"]] = d
seen_ids.add(r["id"])
# Sort by score descending, return top N
ranked = sorted(results.values(), key=lambda x: x.get("_score", 0), reverse=True)
# Update last_accessed for returned memories
returned = ranked[:limit]
now = _now_iso()
for mem in returned:
mem.pop("_score", None)
db.execute(
"UPDATE memories SET last_accessed = ? WHERE id = ?",
(now, mem["id"]),
)
db.commit()
return returned
def get_pending_followups(character_id: str) -> list[dict]:
"""Get follow-up memories that are due for surfacing."""
db = _get_db()
now = _now_iso()
rows = db.execute("""
SELECT * FROM memories
WHERE character_id = ?
AND lifecycle_state = 'pending_followup'
AND (follow_up_due <= ? OR follow_up_due = 'next_interaction')
ORDER BY importance DESC, created_at DESC
LIMIT 5
""", (character_id, now)).fetchall()
return [_row_to_dict(r) for r in rows]
def search_memories(
character_id: str,
query: str,
memory_type: str | None = None,
limit: int = 10,
) -> list[dict]:
"""Semantic search for memories matching a query."""
db = _get_db()
query_emb = get_embedding(query)
vec_rows = db.execute("""
SELECT id, distance
FROM memory_embeddings
WHERE embedding MATCH ?
AND k = ?
""", (_serialize_f32(query_emb), limit * 3)).fetchall()
if not vec_rows:
return []
vec_ids = [r["id"] for r in vec_rows]
vec_distances = {r["id"]: r["distance"] for r in vec_rows}
placeholders = ",".join("?" * len(vec_ids))
type_filter = "AND m.memory_type = ?" if memory_type else ""
params = [*vec_ids, character_id]
if memory_type:
params.append(memory_type)
rows = db.execute(f"""
SELECT * FROM memories m
WHERE m.id IN ({placeholders})
AND m.character_id = ?
{type_filter}
ORDER BY m.created_at DESC
""", params).fetchall()
# Sort by similarity
results = []
for r in rows:
d = _row_to_dict(r)
d["_distance"] = vec_distances.get(r["id"], 1.0)
results.append(d)
results.sort(key=lambda x: x["_distance"])
for r in results:
r.pop("_distance", None)
return results[:limit]
def list_memories(
character_id: str,
memory_type: str | None = None,
lifecycle_state: str | None = None,
category: str | None = None,
limit: int = 20,
offset: int = 0,
) -> list[dict]:
"""List memories with optional filters."""
db = _get_db()
conditions = ["character_id = ?"]
params: list = [character_id]
if memory_type:
conditions.append("memory_type = ?")
params.append(memory_type)
if lifecycle_state:
conditions.append("lifecycle_state = ?")
params.append(lifecycle_state)
if category:
conditions.append("category = ?")
params.append(category)
where = " AND ".join(conditions)
params.extend([limit, offset])
rows = db.execute(f"""
SELECT * FROM memories
WHERE {where}
ORDER BY created_at DESC
LIMIT ? OFFSET ?
""", params).fetchall()
return [_row_to_dict(r) for r in rows]
def count_memories(character_id: str) -> int:
"""Count memories for a character."""
db = _get_db()
row = db.execute(
"SELECT COUNT(*) as cnt FROM memories WHERE character_id = ?",
(character_id,),
).fetchone()
return row["cnt"] if row else 0
# ---------------------------------------------------------------------------
# Lifecycle management
# ---------------------------------------------------------------------------
def resolve_followup(memory_id: str) -> bool:
"""Mark a follow-up as resolved."""
db = _get_db()
result = db.execute("""
UPDATE memories
SET lifecycle_state = 'resolved',
follow_up_due = NULL
WHERE id = ? AND lifecycle_state = 'pending_followup'
""", (memory_id,))
db.commit()
return result.rowcount > 0
def archive_memory(memory_id: str) -> bool:
"""Archive a memory (keeps it for relational inference, not surfaced)."""
db = _get_db()
result = db.execute("""
UPDATE memories
SET lifecycle_state = 'archived'
WHERE id = ?
""", (memory_id,))
db.commit()
return result.rowcount > 0
def auto_resolve_expired_followups() -> int:
"""Auto-resolve follow-ups that are more than 48h past due."""
db = _get_db()
cutoff = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
result = db.execute("""
UPDATE memories
SET lifecycle_state = 'resolved',
follow_up_due = NULL
WHERE lifecycle_state = 'pending_followup'
AND follow_up_due != 'next_interaction'
AND follow_up_due < ?
""", (cutoff,))
db.commit()
return result.rowcount
def auto_archive_old_resolved() -> int:
"""Archive resolved memories older than 7 days."""
db = _get_db()
cutoff = (datetime.now(timezone.utc) - timedelta(days=7)).isoformat()
result = db.execute("""
UPDATE memories
SET lifecycle_state = 'archived'
WHERE lifecycle_state = 'resolved'
AND created_at < ?
""", (cutoff,))
db.commit()
return result.rowcount
def increment_surfaced_count(memory_id: str) -> int:
"""Increment surfaced_count and return new value. Auto-resolves if >= 1."""
db = _get_db()
row = db.execute(
"SELECT surfaced_count FROM memories WHERE id = ?", (memory_id,)
).fetchone()
if not row:
return 0
new_count = (row["surfaced_count"] or 0) + 1
if new_count >= 2:
# Auto-resolve: surfaced twice without user engagement
db.execute("""
UPDATE memories
SET surfaced_count = ?, lifecycle_state = 'resolved', follow_up_due = NULL
WHERE id = ?
""", (new_count, memory_id))
else:
# Update next_interaction to actual timestamp so the 48h timer starts
db.execute("""
UPDATE memories
SET surfaced_count = ?,
follow_up_due = CASE
WHEN follow_up_due = 'next_interaction' THEN ?
ELSE follow_up_due
END
WHERE id = ?
""", (new_count, _now_iso(), memory_id))
db.commit()
return new_count
# ---------------------------------------------------------------------------
# Deduplication
# ---------------------------------------------------------------------------
def find_similar(
character_id: str,
content: str,
memory_type: str = "semantic",
threshold: float = 0.85,
) -> dict | None:
"""Find an existing memory that is semantically similar (>threshold).
Returns the matching memory dict or None."""
db = _get_db()
query_emb = get_embedding(content)
vec_rows = db.execute("""
SELECT id, distance
FROM memory_embeddings
WHERE embedding MATCH ?
AND k = 5
""", (_serialize_f32(query_emb),)).fetchall()
for vr in vec_rows:
similarity = max(0.0, 1.0 - vr["distance"])
if similarity >= threshold:
row = db.execute("""
SELECT * FROM memories
WHERE id = ? AND character_id = ? AND memory_type = ?
AND lifecycle_state = 'active'
""", (vr["id"], character_id, memory_type)).fetchone()
if row:
return _row_to_dict(row)
return None
def add_or_merge_memory(
character_id: str,
content: str,
memory_type: str | None = None,
category: str = "other",
importance: float | None = None,
privacy_level: str | None = None,
tags: list[str] | None = None,
follow_up_due: str | None = None,
follow_up_context: str | None = None,
source: str = "user_explicit",
expires_at: str | None = None,
dedup_threshold: float = 0.85,
) -> dict:
"""Add a memory, or merge with an existing similar one (semantic dedup).
For semantic memories, if a similar one exists (>threshold), update it
instead of creating a new record."""
resolved_type = memory_type or classify_memory(content)["memory_type"]
if resolved_type == "semantic":
existing = find_similar(character_id, content, "semantic", dedup_threshold)
if existing:
updated = update_memory(existing["id"], content=content)
if updated:
return updated
return add_memory(
character_id=character_id,
content=content,
memory_type=memory_type,
category=category,
importance=importance,
privacy_level=privacy_level,
tags=tags,
follow_up_due=follow_up_due,
follow_up_context=follow_up_context,
source=source,
expires_at=expires_at,
)
# ---------------------------------------------------------------------------
# Migration from JSON
# ---------------------------------------------------------------------------
# Mapping from old JSON categories to new memory types
_CATEGORY_TO_TYPE = {
"preference": "semantic",
"personal_info": "semantic",
"interaction": "episodic",
"emotional": "episodic",
"system": "semantic",
"tool_usage": "semantic",
"home_layout": "semantic",
"device": "semantic",
"routine": "semantic",
"other": "semantic",
}
_CATEGORY_TO_IMPORTANCE = {
"personal_info": 0.7,
"preference": 0.6,
"emotional": 0.5,
"interaction": 0.4,
"system": 0.4,
"tool_usage": 0.3,
"home_layout": 0.5,
"device": 0.4,
"routine": 0.5,
"other": 0.4,
}
_CATEGORY_TO_PRIVACY = {
"emotional": "sensitive",
"personal_info": "sensitive",
}
def migrate_from_json(memories_dir: str | None = None) -> dict:
"""Migrate all JSON memory files to SQLite.
Returns {migrated: int, skipped: int, errors: [str]}."""
db = _get_db()
mem_dir = Path(memories_dir) if memories_dir else MEMORIES_DIR
migrated = 0
skipped = 0
errors = []
# Migrate personal memories
personal_dir = mem_dir / "personal"
if personal_dir.exists():
for json_file in personal_dir.glob("*.json"):
try:
with open(json_file) as f:
data = json.load(f)
character_id = data.get("characterId", json_file.stem)
for mem in data.get("memories", []):
content = mem.get("content", "").strip()
if not content:
skipped += 1
continue
category = mem.get("category", "other")
created_at = mem.get("createdAt", _now_iso())
try:
add_memory(
character_id=character_id,
content=content,
memory_type=_CATEGORY_TO_TYPE.get(category, "semantic"),
category=category,
importance=_CATEGORY_TO_IMPORTANCE.get(category, 0.5),
privacy_level=_CATEGORY_TO_PRIVACY.get(category, "standard"),
source="migrated_json",
)
# Fix created_at to original value
db.execute(
"UPDATE memories SET created_at = ? WHERE id = (SELECT id FROM memories ORDER BY rowid DESC LIMIT 1)",
(created_at,),
)
db.commit()
migrated += 1
except Exception as e:
errors.append(f"personal/{json_file.name}: {e}")
# Rename to backup
backup = json_file.with_suffix(".json.bak")
json_file.rename(backup)
except Exception as e:
errors.append(f"personal/{json_file.name}: {e}")
# Migrate general memories
general_file = mem_dir / "general.json"
if general_file.exists():
try:
with open(general_file) as f:
data = json.load(f)
for mem in data.get("memories", []):
content = mem.get("content", "").strip()
if not content:
skipped += 1
continue
category = mem.get("category", "other")
created_at = mem.get("createdAt", _now_iso())
try:
add_memory(
character_id="shared",
content=content,
memory_type=_CATEGORY_TO_TYPE.get(category, "semantic"),
category=category,
importance=_CATEGORY_TO_IMPORTANCE.get(category, 0.5),
privacy_level="standard",
source="migrated_json",
)
db.execute(
"UPDATE memories SET created_at = ? WHERE id = (SELECT id FROM memories ORDER BY rowid DESC LIMIT 1)",
(created_at,),
)
db.commit()
migrated += 1
except Exception as e:
errors.append(f"general.json: {e}")
backup = general_file.with_suffix(".json.bak")
general_file.rename(backup)
except Exception as e:
errors.append(f"general.json: {e}")
return {"migrated": migrated, "skipped": skipped, "errors": errors}

View File

@@ -37,6 +37,26 @@ from pathlib import Path
import wave
import io
import re
from datetime import datetime, timezone
from urllib.parse import parse_qs
from memory_store import (
init_db as init_memory_db,
retrieve_memories as _retrieve_memories,
get_pending_followups,
auto_resolve_expired_followups,
auto_archive_old_resolved,
increment_surfaced_count,
add_memory as _add_memory,
add_or_merge_memory,
update_memory as _update_memory,
delete_memory as _delete_memory,
list_memories as _list_memories,
search_memories as _search_memories,
resolve_followup,
count_memories,
migrate_from_json,
)
from wyoming.client import AsyncTcpClient
from wyoming.tts import Synthesize, SynthesizeVoice
from wyoming.asr import Transcribe, Transcript
@@ -48,7 +68,7 @@ TIMEOUT_WARM = 120 # Model already loaded in VRAM
TIMEOUT_COLD = 180 # Model needs loading first (~10-20s load + inference)
OLLAMA_PS_URL = "http://localhost:11434/api/ps"
VTUBE_BRIDGE_URL = "http://localhost:8002"
DEFAULT_MODEL = "anthropic/claude-sonnet-4-20250514"
DEFAULT_MODEL = "anthropic/claude-sonnet-4-6"
def _vtube_fire_and_forget(path: str, data: dict):
@@ -85,12 +105,21 @@ SATELLITE_MAP_PATH = Path("/Users/aodhan/homeai-data/satellite-map.json")
MEMORIES_DIR = Path("/Users/aodhan/homeai-data/memories")
ACTIVE_TTS_VOICE_PATH = Path("/Users/aodhan/homeai-data/active-tts-voice.json")
ACTIVE_MODE_PATH = Path("/Users/aodhan/homeai-data/active-mode.json")
ACTIVE_STYLE_PATH = Path("/Users/aodhan/homeai-data/active-prompt-style.json")
PROMPT_STYLES_DIR = Path(__file__).parent / "prompt-styles"
# Cloud provider model mappings for mode routing
# Cloud provider model mappings for mode routing (fallback when style has no model)
CLOUD_MODELS = {
"anthropic": "anthropic/claude-sonnet-4-20250514",
"anthropic": "anthropic/claude-sonnet-4-6",
"openai": "openai/gpt-4o",
}
LOCAL_MODEL = "ollama/qwen3.5:35b-a3b"
# Lock to serialise model-switch + agent-call (openclaw config is global)
_model_lock = threading.Lock()
# Initialize memory database at module load
init_memory_db()
def load_mode() -> dict:
@@ -102,11 +131,56 @@ def load_mode() -> dict:
return {"mode": "private", "cloud_provider": "anthropic", "overrides": {}}
def resolve_model(mode_data: dict) -> str | None:
"""Resolve which model to use based on mode. Returns None for default (private/local)."""
def resolve_model(mode_data: dict) -> str:
"""Resolve which model to use based on mode."""
mode = mode_data.get("mode", "private")
if mode == "private":
return None # Use OpenClaw default (ollama/qwen3.5:35b-a3b)
return mode_data.get("local_model", LOCAL_MODEL)
provider = mode_data.get("cloud_provider", "anthropic")
return CLOUD_MODELS.get(provider, CLOUD_MODELS["anthropic"])
def load_prompt_style(style_id: str) -> dict:
"""Load a prompt style template by ID. Returns the style dict or a default."""
if not style_id:
style_id = "standard"
safe_id = style_id.replace("/", "_").replace("..", "")
style_path = PROMPT_STYLES_DIR / f"{safe_id}.json"
try:
with open(style_path) as f:
return json.load(f)
except Exception:
return {"id": "standard", "name": "Standard", "group": "cloud", "instruction": "", "strip_sections": []}
def load_active_style() -> str:
"""Load the active prompt style ID from state file. Defaults to 'standard'."""
try:
with open(ACTIVE_STYLE_PATH) as f:
data = json.load(f)
return data.get("style", "standard")
except Exception:
return "standard"
def resolve_model_for_style(style: dict, mode_data: dict) -> str:
"""Resolve model based on prompt style, falling back to mode config.
Priority: style 'model' field > group-based routing > mode default."""
mode = mode_data.get("mode", "private")
group = style.get("group", "cloud")
# Private mode always uses local model regardless of style
if mode == "private" and group == "local":
return mode_data.get("local_model", LOCAL_MODEL)
# Per-style model override (e.g. haiku for quick, opus for roleplay)
style_model = style.get("model")
if style_model:
return style_model
# Fallback: cloud model from mode config
if group == "local":
return mode_data.get("local_model", LOCAL_MODEL)
provider = mode_data.get("cloud_provider", "anthropic")
return CLOUD_MODELS.get(provider, CLOUD_MODELS["anthropic"])
@@ -192,31 +266,44 @@ def load_character(character_id: str = None) -> dict:
return {}
def load_character_prompt(satellite_id: str = None, character_id: str = None) -> str:
def load_character_prompt(satellite_id: str = None, character_id: str = None,
prompt_style: str = None, user_message: str = "",
is_cloud: bool = False) -> str:
"""Load the full system prompt for a character, resolved by satellite or explicit ID.
Builds a rich prompt from system_prompt + profile fields (background, dialogue_style, etc.)."""
Builds a rich prompt from style instruction + system_prompt + profile fields + memories.
The prompt_style controls HOW the character responds (brief, conversational, roleplay, etc.)."""
if not character_id:
character_id = resolve_character_id(satellite_id)
char = load_character(character_id)
if not char:
return ""
# Load prompt style template
style_id = prompt_style or load_active_style()
style = load_prompt_style(style_id)
strip_sections = set(style.get("strip_sections", []))
sections = []
# Core system prompt
# 1. Response style instruction (framing directive — goes first)
instruction = style.get("instruction", "")
if instruction:
sections.append(f"[Response Style: {style.get('name', style_id)}]\n{instruction}")
# 2. Core character identity (system_prompt)
prompt = char.get("system_prompt", "")
if prompt:
sections.append(prompt)
# Character profile fields
# 3. Character profile fields (filtered by style's strip_sections)
profile_parts = []
if char.get("background"):
if "background" not in strip_sections and char.get("background"):
profile_parts.append(f"## Background\n{char['background']}")
if char.get("appearance"):
if "appearance" not in strip_sections and char.get("appearance"):
profile_parts.append(f"## Appearance\n{char['appearance']}")
if char.get("dialogue_style"):
if "dialogue_style" not in strip_sections and char.get("dialogue_style"):
profile_parts.append(f"## Dialogue Style\n{char['dialogue_style']}")
if char.get("skills"):
if "skills" not in strip_sections and char.get("skills"):
skills = char["skills"]
if isinstance(skills, list):
skills_text = ", ".join(skills[:15])
@@ -226,7 +313,18 @@ def load_character_prompt(satellite_id: str = None, character_id: str = None) ->
if profile_parts:
sections.append("[Character Profile]\n" + "\n\n".join(profile_parts))
# Character metadata
# 4. Per-character style overrides (optional customization per style)
style_overrides = char.get("prompt_style_overrides", {}).get(style_id, {})
if style_overrides:
override_parts = []
if style_overrides.get("dialogue_style"):
override_parts.append(f"## Dialogue Style Override\n{style_overrides['dialogue_style']}")
if style_overrides.get("system_prompt_suffix"):
override_parts.append(style_overrides["system_prompt_suffix"])
if override_parts:
sections.append("[Style-Specific Notes]\n" + "\n\n".join(override_parts))
# 5. Character metadata
meta_lines = []
if char.get("display_name"):
meta_lines.append(f"Your name is: {char['display_name']}")
@@ -243,47 +341,86 @@ def load_character_prompt(satellite_id: str = None, character_id: str = None) ->
if meta_lines:
sections.append("[Character Metadata]\n" + "\n".join(meta_lines))
# Memories (personal + general)
personal, general = load_memories(character_id)
# 6. Memories (personal + general, context-aware retrieval)
personal, general, followups = load_memories(character_id, context=user_message, is_cloud=is_cloud)
if personal:
sections.append("[Personal Memories]\n" + "\n".join(f"- {m}" for m in personal))
if general:
sections.append("[General Knowledge]\n" + "\n".join(f"- {m}" for m in general))
# 7. Pending follow-ups (things the character should naturally bring up)
if followups:
followup_lines = [
f"- {fu['follow_up_context']} (from {fu['created_at'][:10]})"
for fu in followups[:3]
]
sections.append(
"[Pending Follow-ups — Bring these up naturally if relevant]\n"
"You have unresolved topics to check on with the user. "
"Weave them into conversation naturally — don't list them. "
"If the user addresses one, use memory-ctl resolve <id> to mark it resolved.\n"
+ "\n".join(followup_lines)
)
return "\n\n".join(sections)
def load_memories(character_id: str) -> tuple[list[str], list[str]]:
"""Load personal (per-character) and general memories.
Returns (personal_contents, general_contents) truncated to fit context budget."""
PERSONAL_BUDGET = 4000 # max chars for personal memories in prompt
GENERAL_BUDGET = 3000 # max chars for general memories in prompt
def _truncate_to_budget(contents: list[str], budget: int) -> list[str]:
"""Truncate a list of strings to fit within a character budget."""
result = []
used = 0
for content in contents:
if used + len(content) > budget:
break
result.append(content)
used += len(content)
return result
def _read_memories(path: Path, budget: int) -> list[str]:
def load_memories(character_id: str, context: str = "", is_cloud: bool = False) -> tuple[list[str], list[str], list[dict]]:
"""Load personal and general memories using semantic + recency retrieval.
Returns (personal_contents, general_contents, pending_followups)."""
PERSONAL_BUDGET = 4000
GENERAL_BUDGET = 3000
# Check if SQLite has any memories; fall back to JSON if empty (pre-migration)
if count_memories(character_id) == 0 and count_memories("shared") == 0:
return _load_memories_json_fallback(character_id), [], []
personal_mems = _retrieve_memories(character_id, context, limit=15,
exclude_private_for_cloud=is_cloud)
general_mems = _retrieve_memories("shared", context, limit=10,
exclude_private_for_cloud=is_cloud)
followups = get_pending_followups(character_id)
personal = _truncate_to_budget([m["content"] for m in personal_mems], PERSONAL_BUDGET)
general = _truncate_to_budget([m["content"] for m in general_mems], GENERAL_BUDGET)
return personal, general, followups
def _load_memories_json_fallback(character_id: str) -> list[str]:
"""Legacy JSON fallback for pre-migration state."""
def _read(path: Path, budget: int) -> list[str]:
try:
with open(path) as f:
data = json.load(f)
except Exception:
return []
memories = data.get("memories", [])
# Sort newest first
memories.sort(key=lambda m: m.get("createdAt", ""), reverse=True)
result = []
used = 0
result, used = [], 0
for m in memories:
content = m.get("content", "").strip()
if not content:
continue
if used + len(content) > budget:
if used + len(content) > 4000:
break
result.append(content)
used += len(content)
return result
safe_id = character_id.replace("/", "_")
personal = _read_memories(MEMORIES_DIR / "personal" / f"{safe_id}.json", PERSONAL_BUDGET)
general = _read_memories(MEMORIES_DIR / "general.json", GENERAL_BUDGET)
return personal, general
return _read(MEMORIES_DIR / "personal" / f"{safe_id}.json", 4000)
class OpenClawBridgeHandler(BaseHTTPRequestHandler):
@@ -297,6 +434,7 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
"""Send a JSON response."""
self.send_response(status_code)
self.send_header("Content-Type", "application/json")
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(json.dumps(data).encode())
@@ -319,11 +457,17 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
self._handle_stt_request()
return
# Only handle the agent message endpoint
# Agent message endpoint
if parsed_path.path == "/api/agent/message":
self._handle_agent_request()
return
# Memory API: POST /api/memories/...
if parsed_path.path.startswith("/api/memories/"):
parts = parsed_path.path[len("/api/memories/"):].strip("/").split("/")
self._handle_memory_post(parts)
return
self._send_json_response(404, {"error": "Not found"})
def _handle_tts_request(self):
@@ -399,11 +543,29 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
audio_bytes = resp.read()
return audio_bytes, "audio/mpeg"
def do_PUT(self):
"""Handle PUT requests (memory updates)."""
parsed_path = urlparse(self.path)
if parsed_path.path.startswith("/api/memories/"):
parts = parsed_path.path[len("/api/memories/"):].strip("/").split("/")
self._handle_memory_put(parts)
return
self._send_json_response(404, {"error": "Not found"})
def do_DELETE(self):
"""Handle DELETE requests (memory deletion)."""
parsed_path = urlparse(self.path)
if parsed_path.path.startswith("/api/memories/"):
parts = parsed_path.path[len("/api/memories/"):].strip("/").split("/")
self._handle_memory_delete(parts)
return
self._send_json_response(404, {"error": "Not found"})
def do_OPTIONS(self):
"""Handle CORS preflight requests."""
self.send_response(204)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "POST, GET, OPTIONS")
self.send_header("Access-Control-Allow-Methods", "POST, GET, PUT, DELETE, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
@@ -531,19 +693,55 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
self._send_json_response(200, {"status": "ok", "message": "Wake word received"})
@staticmethod
def _call_openclaw(message: str, agent: str, timeout: int, model: str = None) -> str:
"""Call OpenClaw CLI and return stdout."""
cmd = ["/opt/homebrew/bin/openclaw", "agent", "--message", message, "--agent", agent]
if model:
cmd.extend(["--model", model])
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=True,
def _config_set(path: str, value: str):
"""Set an OpenClaw config value."""
subprocess.run(
["/opt/homebrew/bin/openclaw", "config", "set", path, value],
capture_output=True, text=True, timeout=5,
)
return result.stdout.strip()
@staticmethod
def _call_openclaw(message: str, agent: str, timeout: int,
model: str = None, session_id: str = None,
params: dict = None, thinking: str = None) -> str:
"""Call OpenClaw CLI and return stdout.
Temporarily switches the gateway's primary model and inference params
via `openclaw config set`, protected by _model_lock to prevent races."""
cmd = ["/opt/homebrew/bin/openclaw", "agent", "--message", message, "--agent", agent]
if session_id:
cmd.extend(["--session-id", session_id])
if thinking:
cmd.extend(["--thinking", thinking])
with _model_lock:
if model:
OpenClawBridgeHandler._config_set(
"agents.defaults.model.primary", model)
# Set per-style temperature if provided
temp_path = None
if model and params and params.get("temperature") is not None:
temp_path = f'agents.defaults.models["{model}"].params.temperature'
OpenClawBridgeHandler._config_set(
temp_path, str(params["temperature"]))
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
check=True,
)
return result.stdout.strip()
finally:
# Restore defaults
if model and model != DEFAULT_MODEL:
OpenClawBridgeHandler._config_set(
"agents.defaults.model.primary", DEFAULT_MODEL)
if temp_path:
# Restore to neutral default
OpenClawBridgeHandler._config_set(temp_path, "0.5")
@staticmethod
def _needs_followup(response: str) -> bool:
@@ -588,6 +786,8 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
agent = data.get("agent", "main")
satellite_id = data.get("satellite_id")
explicit_character_id = data.get("character_id")
requested_style = data.get("prompt_style")
conversation_id = data.get("conversation_id")
if not message:
self._send_json_response(400, {"error": "Message is required"})
@@ -598,10 +798,28 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
character_id = explicit_character_id
else:
character_id = resolve_character_id(satellite_id)
system_prompt = load_character_prompt(character_id=character_id)
# Resolve prompt style: explicit > character default > global active
char = load_character(character_id)
style_id = requested_style or char.get("default_prompt_style") or load_active_style()
style = load_prompt_style(style_id)
print(f"[OpenClaw Bridge] Prompt style: {style.get('name', style_id)} ({style.get('group', 'cloud')})")
# Determine if routing to cloud (for privacy filtering)
mode_data = load_mode()
active_model = resolve_model_for_style(style, mode_data)
is_cloud = style.get("group", "cloud") == "cloud" and mode_data.get("mode") != "private"
system_prompt = load_character_prompt(
character_id=character_id, prompt_style=style_id,
user_message=message, is_cloud=is_cloud,
)
# Run lifecycle maintenance (cheap SQL updates)
auto_resolve_expired_followups()
auto_archive_old_resolved()
# Set the active TTS config for the Wyoming server to pick up
char = load_character(character_id)
tts_config = char.get("tts", {})
if tts_config:
set_active_tts_voice(character_id, tts_config)
@@ -616,14 +834,30 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
if system_prompt:
message = f"System Context: {system_prompt}\n\nUser Request: {message}"
# Load mode and resolve model routing
mode_data = load_mode()
model_override = resolve_model(mode_data)
active_model = model_override or DEFAULT_MODEL
if model_override:
print(f"[OpenClaw Bridge] Mode: PUBLIC → {model_override}")
group = style.get("group", "cloud")
print(f"[OpenClaw Bridge] Routing: {group.upper()}{active_model}")
# Resolve session ID for OpenClaw thread isolation
# Dashboard chats: use conversation_id (each "New Chat" = fresh thread)
# Satellites: use rotating 12-hour bucket so old context expires naturally
if conversation_id:
session_id = conversation_id
elif satellite_id:
now = datetime.now(timezone.utc)
half = "am" if now.hour < 12 else "pm"
session_id = f"sat_{satellite_id}_{now.strftime('%Y%m%d')}_{half}"
else:
print(f"[OpenClaw Bridge] Mode: PRIVATE ({active_model})")
# API call with no conversation or satellite — use a transient session
session_id = f"api_{int(datetime.now(timezone.utc).timestamp())}"
print(f"[OpenClaw Bridge] Session: {session_id}")
# Extract style inference params (temperature, etc.) and thinking level
style_params = style.get("params", {})
style_thinking = style.get("thinking")
if style_params:
print(f"[OpenClaw Bridge] Style params: {style_params}")
if style_thinking:
print(f"[OpenClaw Bridge] Thinking: {style_thinking}")
# Check if model is warm to set appropriate timeout
warm = is_model_warm()
@@ -635,7 +869,7 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
# Call OpenClaw CLI (use full path for launchd compatibility)
try:
response_text = self._call_openclaw(message, agent, timeout, model=model_override)
response_text = self._call_openclaw(message, agent, timeout, model=active_model, session_id=session_id, params=style_params, thinking=style_thinking)
# Re-prompt if the model promised to act but didn't call a tool.
# Detect "I'll do X" / "Let me X" responses that lack any result.
@@ -645,11 +879,19 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
"You just said you would do something but didn't actually call the exec tool. "
"Do NOT explain what you will do — call the tool NOW using exec and return the result."
)
response_text = self._call_openclaw(followup, agent, timeout, model=model_override)
response_text = self._call_openclaw(followup, agent, timeout, model=active_model, session_id=session_id, params=style_params, thinking=style_thinking)
# Increment surfaced_count on follow-ups that were injected into prompt
try:
followups = get_pending_followups(character_id)
for fu in followups[:3]:
increment_surfaced_count(fu["id"])
except Exception as e:
print(f"[OpenClaw Bridge] Follow-up tracking error: {e}")
# Signal avatar: idle (TTS handler will override to 'speaking' if voice is used)
_vtube_fire_and_forget("/expression", {"event": "idle"})
self._send_json_response(200, {"response": response_text, "model": active_model})
self._send_json_response(200, {"response": response_text, "model": active_model, "prompt_style": style_id})
except subprocess.TimeoutExpired:
self._send_json_response(504, {"error": f"OpenClaw command timed out after {timeout}s (model was {'warm' if warm else 'cold'})"})
except subprocess.CalledProcessError as e:
@@ -660,18 +902,174 @@ class OpenClawBridgeHandler(BaseHTTPRequestHandler):
except Exception as e:
self._send_json_response(500, {"error": str(e)})
def do_GET(self):
"""Handle GET requests (health check)."""
parsed_path = urlparse(self.path)
# ------------------------------------------------------------------
# Memory REST API
# ------------------------------------------------------------------
if parsed_path.path == "/status" or parsed_path.path == "/":
def _read_json_body(self) -> dict | None:
"""Read and parse JSON body from request. Returns None on error (response already sent)."""
content_length = int(self.headers.get("Content-Length", 0))
if content_length == 0:
self._send_json_response(400, {"error": "Empty body"})
return None
try:
return json.loads(self.rfile.read(content_length).decode())
except json.JSONDecodeError:
self._send_json_response(400, {"error": "Invalid JSON"})
return None
def _handle_memory_get(self, path_parts: list[str], query_params: dict):
"""Handle GET /api/memories/..."""
# GET /api/memories/general
if len(path_parts) == 1 and path_parts[0] == "general":
limit = int(query_params.get("limit", ["50"])[0])
offset = int(query_params.get("offset", ["0"])[0])
memory_type = query_params.get("type", [None])[0]
lifecycle = query_params.get("lifecycle", [None])[0]
category = query_params.get("category", [None])[0]
memories = _list_memories("shared", memory_type=memory_type,
lifecycle_state=lifecycle, category=category,
limit=limit, offset=offset)
self._send_json_response(200, {"memories": memories})
return
if len(path_parts) < 1:
self._send_json_response(400, {"error": "Character ID required"})
return
char_id = path_parts[0]
# GET /api/memories/:characterId/followups
if len(path_parts) == 2 and path_parts[1] == "followups":
followups = get_pending_followups(char_id)
self._send_json_response(200, {"followups": followups})
return
# GET /api/memories/:characterId
limit = int(query_params.get("limit", ["50"])[0])
offset = int(query_params.get("offset", ["0"])[0])
memory_type = query_params.get("type", [None])[0]
lifecycle = query_params.get("lifecycle", [None])[0]
category = query_params.get("category", [None])[0]
query = query_params.get("q", [None])[0]
if query:
memories = _search_memories(char_id, query, memory_type=memory_type, limit=limit)
else:
memories = _list_memories(char_id, memory_type=memory_type,
lifecycle_state=lifecycle, category=category,
limit=limit, offset=offset)
self._send_json_response(200, {"memories": memories, "characterId": char_id})
def _handle_memory_post(self, path_parts: list[str]):
"""Handle POST /api/memories/..."""
data = self._read_json_body()
if data is None:
return
# POST /api/memories/migrate
if len(path_parts) == 1 and path_parts[0] == "migrate":
result = migrate_from_json()
self._send_json_response(200, result)
return
# POST /api/memories/:memoryId/resolve
if len(path_parts) == 2 and path_parts[1] == "resolve":
ok = resolve_followup(path_parts[0])
self._send_json_response(200 if ok else 404,
{"ok": ok, "id": path_parts[0]})
return
# POST /api/memories/general — add general memory
if len(path_parts) == 1 and path_parts[0] == "general":
content = data.get("content", "").strip()
if not content:
self._send_json_response(400, {"error": "content is required"})
return
mem = add_or_merge_memory(
character_id="shared",
content=content,
memory_type=data.get("memory_type"),
category=data.get("category", "other"),
importance=data.get("importance"),
privacy_level=data.get("privacy_level"),
tags=data.get("tags"),
source=data.get("source", "dashboard"),
)
self._send_json_response(200, {"ok": True, "memory": mem})
return
# POST /api/memories/:characterId — add personal memory
if len(path_parts) == 1:
char_id = path_parts[0]
content = data.get("content", "").strip()
if not content:
self._send_json_response(400, {"error": "content is required"})
return
mem = add_or_merge_memory(
character_id=char_id,
content=content,
memory_type=data.get("memory_type"),
category=data.get("category", "other"),
importance=data.get("importance"),
privacy_level=data.get("privacy_level"),
tags=data.get("tags"),
follow_up_due=data.get("follow_up_due"),
follow_up_context=data.get("follow_up_context"),
source=data.get("source", "dashboard"),
)
self._send_json_response(200, {"ok": True, "memory": mem})
return
self._send_json_response(404, {"error": "Not found"})
def _handle_memory_put(self, path_parts: list[str]):
"""Handle PUT /api/memories/:memoryId — update a memory."""
if len(path_parts) != 1:
self._send_json_response(400, {"error": "Memory ID required"})
return
data = self._read_json_body()
if data is None:
return
mem = _update_memory(path_parts[0], **data)
if mem:
self._send_json_response(200, {"ok": True, "memory": mem})
else:
self._send_json_response(404, {"error": "Memory not found"})
def _handle_memory_delete(self, path_parts: list[str]):
"""Handle DELETE /api/memories/:memoryId."""
if len(path_parts) != 1:
self._send_json_response(400, {"error": "Memory ID required"})
return
ok = _delete_memory(path_parts[0])
self._send_json_response(200 if ok else 404, {"ok": ok, "id": path_parts[0]})
# ------------------------------------------------------------------
# HTTP method dispatchers
# ------------------------------------------------------------------
def do_GET(self):
"""Handle GET requests."""
parsed_path = urlparse(self.path)
path = parsed_path.path
if path == "/status" or path == "/":
self._send_json_response(200, {
"status": "ok",
"service": "OpenClaw HTTP Bridge",
"version": "1.0.0"
"version": "2.0.0"
})
else:
self._send_json_response(404, {"error": "Not found"})
return
# Memory API: GET /api/memories/...
if path.startswith("/api/memories/"):
parts = path[len("/api/memories/"):].strip("/").split("/")
query_params = parse_qs(parsed_path.query)
self._handle_memory_get(parts, query_params)
return
self._send_json_response(404, {"error": "Not found"})
class ThreadingHTTPServer(ThreadingMixIn, HTTPServer):

View File

@@ -0,0 +1,13 @@
{
"id": "creative",
"name": "Creative",
"group": "cloud",
"model": "anthropic/claude-sonnet-4-6",
"description": "In-depth answers, longer conversational responses",
"thinking": "low",
"params": {
"temperature": 0.7
},
"instruction": "Give thorough, in-depth answers. Respond at whatever length the topic requires — short for simple things, long for complex ones. Be conversational and engaging, like a knowledgeable friend. Vary your sentence structure and word choice to keep things interesting. Do not use roleplay actions or narration. If a topic has interesting depth worth exploring, offer to continue. This mode is for rich conversation, not commands.",
"strip_sections": []
}

View File

@@ -0,0 +1,13 @@
{
"id": "game-master",
"name": "Game Master",
"group": "cloud",
"model": "anthropic/claude-opus-4-6",
"description": "Second-person interactive narration with user as participant",
"thinking": "off",
"params": {
"temperature": 0.9
},
"instruction": "Narrate in second person — the user is the subject experiencing the scene. Describe what they see, hear, and feel with vivid, varied language. Write your character's dialogue in quotes and their actions in prose. After describing the scene or an interaction, prompt the user for their next action. Keep the user engaged as an active participant. Balance rich description with opportunities for user agency. Avoid repeating descriptive patterns — each scene should feel fresh and unpredictable. This is a 2nd-person interactive experience.",
"strip_sections": []
}

View File

@@ -0,0 +1,13 @@
{
"id": "quick",
"name": "Quick",
"group": "cloud",
"model": "anthropic/claude-haiku-4-5-20251001",
"description": "Brief responses for commands and quick questions",
"thinking": "off",
"params": {
"temperature": 0.15
},
"instruction": "RESPONSE RULES — STRICT:\n- Respond as briefly as possible. For smart home commands, confirm with 1-3 words (\"Done.\", \"Lights on.\", \"Playing jazz.\").\n- For factual questions, give the shortest correct answer. One sentence max.\n- No small talk, no elaboration, no follow-up questions unless the request is genuinely ambiguous.\n- Never describe your actions, emotions, or thought process.\n- Never add flair, personality, or creative embellishments — be a reliable, predictable tool.\n- If a tool call is needed, execute it and report the result. Nothing else.",
"strip_sections": ["background", "appearance", "dialogue_style"]
}

View File

@@ -0,0 +1,13 @@
{
"id": "roleplayer",
"name": "Roleplayer",
"group": "cloud",
"model": "anthropic/claude-opus-4-6",
"description": "First-person roleplay with character actions and expressions",
"thinking": "off",
"params": {
"temperature": 0.85
},
"instruction": "Respond entirely in first person as your character. Use action descriptions enclosed in asterisks (*adjusts glasses*, *leans forward thoughtfully*) to convey body language, emotions, and physical actions. Stay fully in character at all times — your personality, speech patterns, and mannerisms should be consistent with your character profile. React emotionally and physically to what the user says. Vary your expressions, gestures, and phrasings — never repeat the same actions or sentence structures. Surprise the user with unexpected but in-character reactions. This is an immersive 1st-person interaction.",
"strip_sections": []
}

View File

@@ -0,0 +1,13 @@
{
"id": "standard",
"name": "Standard",
"group": "cloud",
"model": "anthropic/claude-sonnet-4-6",
"description": "Conversational responses, concise but informative",
"thinking": "off",
"params": {
"temperature": 0.4
},
"instruction": "Respond naturally and conversationally. Be concise but informative — a few sentences is ideal. Do not use roleplay actions, narration, or describe your expressions/body language. Treat the interaction as a chat, not a performance. Stay helpful, on-topic, and consistent. Prioritise clarity and accuracy over flair.",
"strip_sections": []
}

View File

@@ -0,0 +1,13 @@
{
"id": "storyteller",
"name": "Storyteller",
"group": "cloud",
"model": "anthropic/claude-opus-4-6",
"description": "Third-person narrative with periodic reader check-ins",
"thinking": "off",
"params": {
"temperature": 0.95
},
"instruction": "Narrate in third person as a storyteller. Describe scenes, character actions, dialogue, and atmosphere as a novelist would. Your character should be written about, not speaking as themselves directly to the user. Write rich, evocative prose with varied vocabulary, rhythm, and imagery. Avoid formulaic descriptions — each passage should have its own texture and mood. Periodically check in with the reader about story direction. The user drives the direction but you drive the narrative between check-ins. This is a 3rd-person storytelling experience.",
"strip_sections": []
}