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:
865
homeai-agent/memory_store.py
Normal file
865
homeai-agent/memory_store.py
Normal 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}
|
||||
@@ -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):
|
||||
|
||||
13
homeai-agent/prompt-styles/creative.json
Normal file
13
homeai-agent/prompt-styles/creative.json
Normal 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": []
|
||||
}
|
||||
13
homeai-agent/prompt-styles/game-master.json
Normal file
13
homeai-agent/prompt-styles/game-master.json
Normal 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": []
|
||||
}
|
||||
13
homeai-agent/prompt-styles/quick.json
Normal file
13
homeai-agent/prompt-styles/quick.json
Normal 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"]
|
||||
}
|
||||
13
homeai-agent/prompt-styles/roleplayer.json
Normal file
13
homeai-agent/prompt-styles/roleplayer.json
Normal 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": []
|
||||
}
|
||||
13
homeai-agent/prompt-styles/standard.json
Normal file
13
homeai-agent/prompt-styles/standard.json
Normal 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": []
|
||||
}
|
||||
13
homeai-agent/prompt-styles/storyteller.json
Normal file
13
homeai-agent/prompt-styles/storyteller.json
Normal 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": []
|
||||
}
|
||||
Reference in New Issue
Block a user