Initial commit: Memory OS — 6-layer memory architecture for Hermes Agent

This commit is contained in:
ClaudioDrews 2026-05-31 16:50:37 -03:00
commit 0b32ffcfc2
52 changed files with 10838 additions and 0 deletions

93
.env.example Normal file
View File

@ -0,0 +1,93 @@
# Memory OS — Environment Variables
# Copy this file to .env and fill in your values:
# cp .env.example .env
# ── Required ──────────────────────────────────────────
# OpenRouter API key (for embeddings and LLM extraction)
OPENROUTER_API_KEY=sk-or-...
# Redis password (generate with: openssl rand -hex 16)
REDIS_PASSWORD=change-me
# ── Paths ─────────────────────────────────────────────
# Where Icarus writes fabric entries (absolute path required — systemd does not expand ~)
FABRIC_DIR=/home/your-user/vault/fabric
# Vault root for Vault Curator, wiki, and backfill scripts
VAULT_PATH=/home/your-user/vault
# Wiki directory for Qdrant ingestion
WIKI_ROOT=/home/your-user/vault/wiki
# Hermes home (usually ~/.hermes)
HERMES_HOME=/home/your-user/.hermes
# State database path (SQLite with FTS5 for session search)
STATE_DB_PATH=/home/your-user/.hermes/state.db
# Logs directory
HERMES_LOGS_DIR=/home/your-user/.hermes/logs
# DLQ (Dead Letter Queue) state file
HERMES_DLQ_PATH=/home/your-user/.hermes/wiki_ingest_failures.json
# DLQ report output (used by dlq_manager.py)
HERMES_DLQ_REPORT_LOG=/home/your-user/.hermes/cron/output/dlq-report.log
HERMES_DLQ_REPORT_DIR=/home/your-user/.hermes/cron/output
# Telemetry log for context_enhancer.py query tracking
TELEMETRY_LOG_PATH=/home/your-user/.hermes/logs/query-telemetry.jsonl
# Reflection trigger log path
REFLECTION_LOG_PATH=/home/your-user/.hermes/logs/reflection_trigger.log
# MaaS env path (reflection_trigger.py loads env from this location)
MAA_ENV_PATH=/home/your-user/.env
# ── Strongly Recommended ──────────────────────────────
# LLM extraction token limit — 1024 is too small, causes fabric truncation
ICARUS_EXTRACTION_MAX_TOKENS=4096
# LLM extraction model (any OpenRouter chat model)
ICARUS_EXTRACTION_MODEL=deepseek/deepseek-v4-flash
# Embedding dimensions — must match Qdrant collection schema
EMBEDDING_DIMS=4096
# Qdrant collection name
COLLECTION_NAME=knowledge_base
# ── Optional ──────────────────────────────────────────
# Obsidian integration
# ICARUS_OBSIDIAN=1
# OBSIDIAN_VAULT_PATH=/home/your-user/vault
# Fallback truncation limits (only used when LLM extraction fails)
# ICARUS_RESULT_MAX_CHARS=500
# ICARUS_TASK_MAX_CHARS=300
# Training/eval (Together AI)
# TOGETHER_API_KEY=tok-...
# Alternative OpenRouter keys (tried in order by context_enhancer.py)
# OPENROUTER_FULL_API_KEY=sk-or-...
# OPENROUTER_DS_API_KEY=sk-or-...
# Docker: wiki mount path inside worker container
# WIKI_PATH=/wiki
# Logging
# LOG_LEVEL=INFO
# CURATOR_LOG_LEVEL=INFO
# ARQ worker tuning
# ARQ_MAX_JOBS=10
# ARQ_JOB_TIMEOUT=300
# ARQ_KEEP_RESULT=3600
# Micro-reflection budget
# MICRO_REFLECTION_MAX_PER_HOUR=5

22
.gitignore vendored Normal file
View File

@ -0,0 +1,22 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
# Environment
.env
.env.local
# Docker
docker/qdrant_data/
docker/redis_data/
# IDEs
.vscode/
.idea/
*.swp
# OS
.DS_Store
Thumbs.db

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Claudio Drews
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

137
README.md Normal file
View File

@ -0,0 +1,137 @@
# Memory OS — Hermes Agent Memory Operating System
> **Your agent finally stops forgetting.**
> Permanent memory. Local memory infrastructure. API-provider agnostic. Surgically token-efficient.
Six memory layers. Automatic, intelligent context injection. Structured facts with trust scoring. A self-curating wiki pipeline. Semantic search across **every conversation you've ever had**.
Memory OS turns Hermes Agent into a real long-term collaborator — one that remembers your projects, your decisions, your reasoning, and brings exactly the right context back at exactly the right moment. Like talking to a colleague who was there for every session.
**Memory infrastructure runs entirely on your machine. Works with any LLM provider — OpenRouter, OpenAI, Anthropic, Ollama, or local models. No memory subscription. No vendor lock-in.**
---
## The problem every serious Hermes user knows
You spend hours configuring the agent, teaching it your preferences, solving hard problems together — and in the next session it acts like it's meeting you for the first time.
- Repeating context at the start of every conversation
- Losing the thread of important decisions made weeks ago
- Structured facts — your stack, your projects, your patterns — with nowhere to live
- Every memory solution you've tried is either cloud-locked or too shallow to matter
After months of hitting these walls in production, I built something that actually works.
---
## What Memory OS is
Not just another plugin. A complete **memory operating system** — 6 layers working in concert, from flat files to a vector database, with surgical context injection and a knowledge pipeline that organizes itself.
Designed and refined by someone who ran headfirst into every limitation of stock Hermes and every existing memory solution.
**Requirements:** Hermes Agent + Docker (Qdrant + Redis + ARQ Worker) + Python 3.11+.
Compatible with any LLM provider Hermes supports — OpenRouter, OpenAI, Anthropic, Ollama, and more.
---
## Architecture: 6 memory layers
```
┌──────────────────────────────────────────────────────────────────┐
│ LAYER 1 · WORKSPACE │
│ MEMORY.md · USER.md · CREATIVE.md │
│ → Injected into the system prompt every single turn │
├──────────────────────────────────────────────────────────────────┤
│ LAYER 2 · SESSIONS │
│ state.db (SQLite + FTS5) │
│ → Full-text search across your entire conversation history │
├──────────────────────────────────────────────────────────────────┤
│ LAYER 3 · STRUCTURED FACTS │
│ memory_store.db (SQLite + HRR + FTS5 + trust scoring) │
│ → Durable facts with entity resolution and an automatic │
│ feedback loop that trains trust scores over time │
├──────────────────────────────────────────────────────────────────┤
│ LAYER 4 · FABRIC (CROSS-SESSION) │
│ Icarus Plugin (heavily forked) │
│ → LLM-powered session extraction + multi-source injection │
│ → 16 tools: fabric_recall, fabric_write, fabric_brief, etc. │
├──────────────────────────────────────────────────────────────────┤
│ LAYER 5 · VECTOR DATABASE │
│ Qdrant (4096d Cosine + BM25 sparse) │
│ → 4-level fallback: hybrid → dense → lexical → SQLite │
│ → Weekly decay scanner + semantic dedup (cosine >0.92 → merge) │
├──────────────────────────────────────────────────────────────────┤
│ LAYER 6 · LLM WIKI │
│ Auto-curated vault: concepts/ · entities/ · comparisons/ │
│ → Continuously ingested into Qdrant via wiki-continuous-ingest │
└──────────────────────────────────────────────────────────────────┘
```
**How it flows:**
`pre_llm_call` → surgical recall from all four sources (Fabric + Qdrant + Sessions + Facts)
`post_llm_call` + `on_session_end` → automatic learning extraction and capture
Each source is gated by relevance thresholds. Per-session deduplication prevents the same context from appearing twice. A social-closer filter skips trivial messages entirely. No padding. No firehose. The LLM gets exactly what it needs — nothing more.
---
## Memory OS vs. stock Hermes
| Aspect | Stock Hermes | Memory OS |
|---|---|---|
| Workspace memory | MEMORY.md + USER.md | + CREATIVE.md + intelligent injection |
| Session memory | Basic state.db | + FTS5 full-text search + session injection |
| Structured facts | Not present | Fact store + trust scoring + feedback loop |
| Cross-session recall | Limited | Fabric fork + multi-source injection |
| Vector search | Not present | Qdrant hybrid + 4-level fallback cascade |
| Cleanup and deduplication | Not present | Decay scanner + semantic dedup + archival |
| Knowledge pipeline | Not present | Self-curating LLM Wiki |
| Token efficiency | — | Surgical: gated retrieval + per-session dedup |
| Infrastructure | — | Local memory stack (Qdrant + Redis + ARQ) + any LLM provider |
---
## Why not mem0, Zep, Letta, or other providers?
Because almost every modern memory solution is **cloud-first**. If you want real, private memory infrastructure running on your own machine — with no cloud memory subscription, full provider flexibility, and no data leaving your local stack — none of them deliver what Memory OS delivers.
| | Memory OS | mem0 | Zep | Letta |
|---|---|---|---|---|
| Local memory infrastructure | ✓ | ✗ | ✗ | ✗ |
| No memory subscription | ✓ | ✗ | ✗ | ✗ |
| Provider agnostic (OpenRouter, Ollama…) | ✓ | Partial | Partial | Partial |
| Hermes-native | ✓ | ✗ | ✗ | ✗ |
| Structured facts + trust scores | ✓ | Partial | ✗ | ✗ |
| Self-curating wiki | ✓ | ✗ | ✗ | ✗ |
| Intelligent decay + archival | ✓ | ✗ | ✗ | ✗ |
---
## Included components
- **Icarus Plugin (heavily modified fork)** — bundled in `icarus/`
The upstream [esaradev/icarus-plugin](https://github.com/esaradev/icarus-plugin) is the base, but this fork is not upstream-compatible. Key additions: LLM-powered session extraction (replaces `text[:500]` truncation), multi-source injection (Qdrant + sessions + facts — upstream is fabric only), CREATIVE.md isolation (fixes `§` delimiter corruption from dual-writer conflict), backtick sanitization, system injection filter, and social closer detection.
- **Vault Curator v3** — [ClaudioDrews/vault-curator](https://github.com/ClaudioDrews/vault-curator)
Frontmatter enrichment, semantic linking, and MOC index generation for the wiki layer.
---
## Who this is for
For people who take Hermes Agent seriously.
For people who want an agent that **actually evolves** over time — one that doesn't need the world re-explained every session.
For people who value clean engineering, extreme efficiency, and solutions that hold up in real local production.
If you're like me — tired of amnesiac agents — Memory OS was built for you.
---
**Want to see the agent remember for real?**
Clone it, run it, feel the difference.
→ [Setup guide](setup/install.md) · [Layer deep-dives](layers/) · [Infrastructure docs](infrastructure/architecture.md) · [License](LICENSE)
MIT License · Built with obsession by someone who runs Hermes every single day.

72
docker/docker-compose.yml Normal file
View File

@ -0,0 +1,72 @@
# Memory OS — Docker Compose
#
# Starts Qdrant (vector DB), Redis (job queue), and ARQ Worker (embedding pipeline).
# Copy .env.example to .env and fill in required values before running.
services:
redis:
image: redis:7-alpine
restart: unless-stopped
command: >
sh -c '
mkdir -p /usr/local/etc/redis &&
echo "requirepass $${REDIS_PASSWORD}" > /usr/local/etc/redis/redis.conf &&
echo "bind 0.0.0.0" >> /usr/local/etc/redis/redis.conf &&
echo "appendonly yes" >> /usr/local/etc/redis/redis.conf &&
echo "maxmemory 512mb" >> /usr/local/etc/redis/redis.conf &&
echo "maxmemory-policy allkeys-lru" >> /usr/local/etc/redis/redis.conf &&
redis-server /usr/local/etc/redis/redis.conf
'
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
ports:
- "127.0.0.1:6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 5s
timeout: 3s
retries: 5
qdrant:
image: qdrant/qdrant:v1.17.1
restart: unless-stopped
ports:
- "127.0.0.1:6333:6333"
volumes:
- qdrant_data:/qdrant/storage
environment:
QDRANT__SERVICE__HTTP_PORT: "6333"
QDRANT__SERVICE__API_KEY: ${QDRANT_API_KEY:-}
worker:
build:
context: ./worker
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
qdrant:
condition: service_started
redis:
condition: service_healthy
environment:
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD}
QDRANT_HOST: qdrant
QDRANT_PORT: "6333"
OPENROUTER_API_KEY: ${OPENROUTER_API_KEY}
EMBEDDING_DIMS: "${EMBEDDING_DIMS:-4096}"
COLLECTION_NAME: "${COLLECTION_NAME:-knowledge_base}"
ARQ_JOB_TIMEOUT: "300"
ARQ_MAX_JOBS: "10"
ARQ_KEEP_RESULT: "3600"
LOG_LEVEL: ${LOG_LEVEL:-INFO}
volumes:
- ${WIKI_PATH:-./wiki}:/wiki:ro
- ${HERMES_HOME:-~/.hermes}:/hermes:rw
volumes:
redis_data:
qdrant_data:

24
docker/worker/Dockerfile Normal file
View File

@ -0,0 +1,24 @@
FROM python:3.12-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
# Copy and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy source code
COPY . .
# Default environment variables (override via docker-compose)
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
RUN useradd -r -u 10001 appuser
USER appuser
CMD ["python", "main.py", "--run-worker"]

113
docker/worker/main.py Normal file
View File

@ -0,0 +1,113 @@
"""
Cognitive Worker ARQ Memory-as-a-Service (MaaS)
Main entry point for the processing worker.
"""
import asyncio
import logging
import os
from pathlib import Path
from dotenv import load_dotenv
# ─── Load .env ──────────────────────────────────────────────────────────────
ENV_PATH = Path(__file__).parent.parent / ".env"
if ENV_PATH.exists():
load_dotenv(ENV_PATH)
# ─── Logging configuration ──────────────────────────────────────────────────
LOG_LEVEL = os.environ.get("LOG_LEVEL", "INFO")
logging.basicConfig(
level=getattr(logging, LOG_LEVEL.upper()),
format="%(asctime)s | %(name)s | %(levelname)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger("cognitive-worker")
# ─── Import ARQ worker ──────────────────────────────────────────────────────
from arq import create_pool, cron
from arq.connections import RedisSettings
from tasks.ingestion import ingest_memory
from tasks.reflection import reflect_on_memories, micro_reflection
from tasks.file_ingestion import ingest_file
# ─── Redis configuration ────────────────────────────────────────────────────
REDIS_HOST = os.environ.get("REDIS_HOST", "redis-maas")
REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379"))
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
redis_settings = RedisSettings(
host=REDIS_HOST,
port=REDIS_PORT,
password=REDIS_PASSWORD or None,
)
# ─── Startup/shutdown functions ────────────────────────────────────────────
async def startup(ctx):
"""Connect to Qdrant and validate the collection."""
from services.local_qdrant import get_qdrant_client, ensure_collection
logger.info("Worker starting...")
ctx["qdrant"] = get_qdrant_client()
await ensure_collection(ctx["qdrant"])
logger.info("Qdrant connection validated")
async def process_wiki_file(ctx, file_path: str):
"""ARQ job: ingests a .md file from the vault."""
return await ingest_file(ctx["qdrant"], file_path)
async def shutdown(ctx):
"""Clean up connections."""
logger.info("Worker shutting down...")
if "qdrant" in ctx:
await ctx["qdrant"].close()
# ─── ARQ function definitions ────────────────────────────────────────────
async def process_ingestion(ctx, memory_text: str, source: str, tags: list = None):
"""ARQ job: ingests a memory into the vector store."""
return await ingest_memory(ctx["qdrant"], memory_text, source, tags)
async def process_reflection(ctx):
"""ARQ job: runs periodic reflection."""
return await reflect_on_memories(ctx["qdrant"])
async def process_micro_reflection(ctx):
"""ARQ job: runs on-demand micro-reflection (Phase 3)."""
return await micro_reflection(ctx["qdrant"])
# ─── ARQ Worker Settings ─────────────────────────────────────────────────
class WorkerSettings:
"""ARQ worker settings."""
redis_settings = redis_settings
functions = [process_ingestion, process_reflection, process_micro_reflection, process_wiki_file]
on_startup = startup
on_shutdown = shutdown
max_jobs = int(os.environ.get("ARQ_MAX_JOBS", "10"))
job_timeout = int(os.environ.get("ARQ_JOB_TIMEOUT", "300"))
keep_result = int(os.environ.get("ARQ_KEEP_RESULT", "3600"))
cron_jobs = [
# Reflection every 2 hours (minute 0 of even hours)
cron(process_reflection, hour={0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22}, minute=0),
# Micro-reflection now triggered via reflection_trigger.py (idle + budget)
]
# ─── Entry point ────────────────────────────────────────────────────────────
if __name__ == "__main__":
import sys
if len(sys.argv) > 1 and sys.argv[1] == "--run-worker":
# Start ARQ worker (concurrency via max_jobs, not multi-process)
import subprocess
logger.info("Starting ARQ worker...")
subprocess.run(["arq", "main.WorkerSettings"])
else:
print("Usage: python main.py --run-worker")
print("")
print("To enqueue jobs, use the enqueue_host.py script")

View File

@ -0,0 +1,8 @@
redis>=5.0.0
qdrant-client>=1.17.0
httpx>=0.28.0
arq>=0.28.0
pydantic>=2.12.0
python-dotenv>=1.0.0
pyyaml>=6.0
fastembed>=0.8.0

View File

@ -0,0 +1,59 @@
"""
Embedding client via OpenRouter.
Mandatory dimension validation.
"""
import os
import logging
import httpx
logger = logging.getLogger("cognitive-worker.embedding")
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
EMBEDDING_DIMS = int(os.environ.get("EMBEDDING_DIMS", "4096"))
EMBEDDING_MODEL = "qwen/qwen3-embedding-8b"
API_BASE = "https://openrouter.ai/api/v1"
async def get_embedding(text: str) -> list[float]:
"""
Generates embedding via OpenRouter.
Validates that the returned dimensions match EMBEDDING_DIMS.
"""
if not OPENROUTER_API_KEY:
raise RuntimeError("OPENROUTER_API_KEY is not configured")
headers = {
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://localhost",
"X-Title": "Cognitive-Agent-MaaS",
}
payload = {
"model": EMBEDDING_MODEL,
"input": text,
"dimensions": EMBEDDING_DIMS,
}
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
f"{API_BASE}/embeddings",
headers=headers,
json=payload,
)
resp.raise_for_status()
data = resp.json()
vec = data["data"][0]["embedding"]
# ─── Critical dimension validation ──────────────────────────────────────
if len(vec) != EMBEDDING_DIMS:
raise ValueError(
f"Embedding dimension mismatch: "
f"expected {EMBEDDING_DIMS}, got {len(vec)}. "
f"Check EMBEDDING_DIMS in .env and the Qdrant collection."
)
logger.debug(f"Embedding generated: {len(vec)} dims")
return vec

View File

@ -0,0 +1,57 @@
"""
LLM client via native Ollama.
"""
import os
import logging
import httpx
logger = logging.getLogger("cognitive-worker.llm")
OLLAMA_BASE_URL = os.environ.get("OLLAMA_BASE_URL", "http://host.docker.internal:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "deepseek-v4-flash:cloud")
OLLAMA_API_KEY = os.environ.get("OLLAMA_API_KEY", "")
def get_auth_header() -> dict:
"""Returns auth header if API key is configured."""
if OLLAMA_API_KEY:
return {"Authorization": f"Bearer {OLLAMA_API_KEY}"}
return {}
async def ollama_chat(prompt: str, model: str | None = None, timeout: int = 120) -> str:
"""
Sends a prompt to native Ollama and returns the response.
Uses cloud models like deepseek-v4-flash:cloud.
"""
model = model or OLLAMA_MODEL
url = f"{OLLAMA_BASE_URL}/api/generate"
headers = {
"Content-Type": "application/json",
**get_auth_header(),
}
payload = {
"model": model,
"prompt": prompt,
"stream": False,
"options": {
"temperature": 0.7,
"num_predict": 4096, # DeepSeek generates long reasoning; needs space
},
}
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.post(url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
# DeepSeek v4 flash: reasoning can consume tokens, leaving response empty
# Return reasoning if content is empty
response = data.get("response", "")
if not response and "reasoning" in data:
response = data["reasoning"]
return response

View File

@ -0,0 +1,56 @@
"""
Qdrant client connection and collection validation.
Creates a HYBRID collection (dense + BM25 sparse) if it doesn't exist.
"""
import os
import logging
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import Distance, VectorParams, SparseVectorParams, Modifier
logger = logging.getLogger("cognitive-worker.qdrant")
QDRANT_HOST = os.environ.get("QDRANT_HOST", "qdrant-maas")
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
EMBEDDING_DIMS = int(os.environ.get("EMBEDDING_DIMS", "4096"))
COLLECTION_NAME = os.environ.get("COLLECTION_NAME", "knowledge_base")
_client: AsyncQdrantClient | None = None
def get_qdrant_client() -> AsyncQdrantClient:
"""Returns a singleton of the async Qdrant client."""
global _client
if _client is None:
_client = AsyncQdrantClient(host=QDRANT_HOST, port=QDRANT_PORT)
logger.info(f"Connected to Qdrant at {QDRANT_HOST}:{QDRANT_PORT}")
return _client
async def ensure_collection(client: AsyncQdrantClient) -> None:
"""Ensures the hybrid collection exists with dense + sparse configs."""
try:
collections = (await client.get_collections()).collections
names = [c.name for c in collections]
if COLLECTION_NAME not in names:
logger.info(
f"Creating collection {COLLECTION_NAME} with "
f"dense={EMBEDDING_DIMS} dims + sparse BM25"
)
await client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config={
"dense": VectorParams(
size=EMBEDDING_DIMS,
distance=Distance.COSINE,
)
},
sparse_vectors_config={
"sparse": SparseVectorParams(modifier=Modifier.IDF)
},
)
else:
logger.info(f"Collection {COLLECTION_NAME} already exists")
except Exception as e:
logger.error(f"Error validating collection: {e}")
raise

View File

@ -0,0 +1,34 @@
"""
BM25 Sparse Embedding client via FastEmbed.
Caches the model in memory (lazy init).
"""
import logging
from fastembed.sparse import SparseTextEmbedding
logger = logging.getLogger("cognitive-worker.sparse_embedding")
BM25_MODEL = "Qdrant/bm25"
_model = None
def _get_model() -> SparseTextEmbedding:
"""Lazy init of the FastEmbed BM25 model."""
global _model
if _model is None:
logger.info("Loading BM25 sparse embedding model...")
_model = SparseTextEmbedding(model_name=BM25_MODEL)
logger.info("BM25 model loaded.")
return _model
def get_sparse_embedding(text: str) -> dict:
"""
Generates BM25 sparse embedding via FastEmbed.
Returns a Qdrant-compatible dict: {"indices": [...], "values": [...]}
"""
model = _get_model()
sparse = list(model.embed(text))[0]
return {
"indices": sparse.indices.tolist(),
"values": sparse.values.tolist(),
}

View File

@ -0,0 +1,247 @@
"""
Tasks File-based wiki ingestion (Phase B: continuous).
Receives a file path to a .md file inside the container (e.g. /wiki/concepts/new.md),
extracts frontmatter, generates DENSE + BM25 SPARSE embeddings, upserts into knowledge_base_hybrid.
"""
import logging
import os
import uuid
from pathlib import Path
from datetime import datetime, timezone
import httpx
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import PointStruct
from services.embedding import get_embedding
from services.sparse_embedding import get_sparse_embedding
logger = logging.getLogger("cognitive-worker.file_ingest")
COLLECTION_NAME = os.environ.get("COLLECTION_NAME", "knowledge_base")
QDRANT_HOST = os.environ.get("QDRANT_HOST", "qdrant-maas")
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
WIKI_PATH = os.environ.get("WIKI_PATH", "/wiki")
MAX_TEXT_LEN = 8000
def parse_frontmatter(text: str) -> tuple[dict, str]:
"""Extracts YAML frontmatter and returns (metadata, body)."""
if text.startswith("---"):
parts = text.split("---", 2)
if len(parts) >= 3:
try:
import yaml
meta = yaml.safe_load(parts[1])
body = parts[2].strip()
return (meta if isinstance(meta, dict) else {}), body
except Exception:
pass
return {}, text
def get_source_tag(path: Path) -> str:
"""Derives a source tag from the path relative to WIKI_PATH."""
rel = path.relative_to(WIKI_PATH)
parts = rel.parts
if len(parts) > 1:
return f"wiki-{parts[0]}"
return "wiki-root"
def get_tags_from_frontmatter(meta: dict) -> list[str]:
"""Extracts tags from frontmatter."""
tags = meta.get("tags", [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(",")]
return tags if isinstance(tags, list) else []
async def upsert_with_dedup(
qdrant: AsyncQdrantClient,
collection: str,
dense_vector: list,
sparse_vector,
payload: dict,
dedup_threshold: float = 0.92,
) -> dict:
"""
Pre-write dedup: searches for similar neighbors before upserting.
If cosine similarity >= threshold, merges payload into the existing point.
Returns a dict with status: 'dedup' or 'upserted'.
"""
try:
# Use REST API directly — AsyncQdrantClient doesn't have .search() in qdrant-client 1.18.0
async with httpx.AsyncClient(timeout=30) as http:
resp = await http.post(
f"http://{QDRANT_HOST}:{QDRANT_PORT}/collections/{collection}/points/search",
json={
"vector": {"name": "dense", "vector": dense_vector},
"limit": 10,
"with_payload": True,
},
)
resp.raise_for_status()
results = resp.json()["result"]
for hit in results:
hit_score = hit["score"]
hit_id = hit["id"]
hit_payload = hit.get("payload") or {}
if hit_score >= dedup_threshold:
existing_payload = hit_payload
# Merge: tags (union)
existing_tags = set(existing_payload.get("tags", []))
new_tags = set(payload.get("tags", []))
merged_tags = list(existing_tags | new_tags)
# Merge: source_type (priority human > procedural > ai)
st_priority = {"human": 3, "procedural": 2, "ai": 1}
existing_st = existing_payload.get("source_type", "ai")
new_st = payload.get("source_type", "ai")
merged_st = existing_st if st_priority.get(existing_st, 0) >= st_priority.get(new_st, 0) else new_st
# Merge: last_accessed_at (max)
existing_la = existing_payload.get("last_accessed_at", payload.get("created_at"))
new_la = payload.get("last_accessed_at")
merged_la = max(existing_la, new_la) if existing_la and new_la else (existing_la or new_la)
# Merge: importance_score (max)
existing_imp = existing_payload.get("importance_score", 0.5)
new_imp = payload.get("importance_score", 0.5)
merged_imp = max(existing_imp, new_imp)
# Merge: lineage_ids
existing_lineages = existing_payload.get("lineage_ids", [])
new_lineages = payload.get("lineage_ids", [])
if not isinstance(existing_lineages, list):
existing_lineages = []
if not isinstance(new_lineages, list):
new_lineages = []
merged_lineages = list(set(existing_lineages + new_lineages))
# Apply merge
await qdrant.set_payload(
collection_name=collection,
payload={
"tags": merged_tags,
"source_type": merged_st,
"last_accessed_at": merged_la,
"importance_score": merged_imp,
"lineage_ids": merged_lineages,
},
points=[hit_id],
)
logger.info(f"Dedup: merged into chunk {hit_id} (score={hit_score:.3f})")
return {
"status": "dedup",
"existing_id": str(hit_id),
"similarity": hit_score,
}
except Exception as e:
logger.warning(f"Error in pre-write dedup: {e}")
# Fallback: normal upsert
point = PointStruct(
id=str(uuid.uuid4()),
vector={"dense": dense_vector, "sparse": sparse_vector},
payload=payload,
)
await qdrant.upsert(collection_name=collection, points=[point], wait=True)
return {
"status": "upserted",
"id": point.id,
}
async def ingest_file(
qdrant: AsyncQdrantClient,
file_path: str,
) -> dict:
"""
Ingests a .md file from the vault into knowledge_base_hybrid (dense + BM25 sparse).
Returns a dict with id and status.
"""
wiki_root = Path(WIKI_PATH).resolve()
path = Path(file_path).resolve()
if not str(path).startswith(str(wiki_root)) or path.suffix != ".md":
raise ValueError("file_path must be a .md file under WIKI_PATH")
if not path.exists():
raise FileNotFoundError(f"File not found: {file_path}")
text = path.read_text(encoding="utf-8", errors="replace")
if not text.strip():
return {"status": "skipped", "reason": "empty file", "file": str(path)}
meta, body = parse_frontmatter(text)
source = get_source_tag(path)
tags = get_tags_from_frontmatter(meta)
folder_tag = source.replace("wiki-", "")
if folder_tag not in tags:
tags.append(folder_tag)
title = meta.get("title", path.stem)
embed_text = f"{title}\n\n{body}"[:MAX_TEXT_LEN]
# Generate embeddings
try:
dense_vector = await get_embedding(embed_text)
sparse_vector = get_sparse_embedding(embed_text)
except Exception as e:
logger.error(f"Error generating embedding for {path}: {e}")
raise
# Payload
now = datetime.now(timezone.utc).isoformat()
# Heuristic importance_score based on file path/name
importance_score = 0.5 # default
path_str_lower = str(path).lower()
if any(k in path_str_lower for k in ["architecture", "core", "important"]):
importance_score = 0.7
if any(t.lower() in ["important", "critical"] for t in tags):
importance_score = 0.8
if any(k in path_str_lower for k in ["draft", "temp", "old"]):
importance_score = 0.2
payload = {
"text": embed_text,
"source": source,
"tags": tags,
"created_at": now,
"reflection_count": 0,
"last_reflected": None,
"file_path": str(path),
"title": title,
"word_count": len(embed_text.split()),
# ── Lineage fields (Phase 1) ──
"lineage_id": None, # legacy: last lineage that generated this chunk
"lineage_ids": [], # Phase 3.5: all accumulated lineages (merge)
"generation_model": None,
"generation_context_hash": None,
"retrieved_chunk_ids": None,
# ── Decay fields (Phase 2) ──
"decay_score": 1.0,
"last_accessed_at": now,
"importance_score": importance_score,
"source_type": "human", # vault files = human origin
"confidence_score": 1.0,
"archived": False,
}
# Use pre-write dedup instead of direct upsert
result = await upsert_with_dedup(
qdrant=qdrant,
collection=COLLECTION_NAME,
dense_vector=dense_vector,
sparse_vector=sparse_vector,
payload=payload,
dedup_threshold=0.92,
)
if result["status"] == "dedup":
logger.info(f"File {path.name} deduplicated (merged into {result['existing_id']}) — similarity {result['similarity']:.3f}")
else:
logger.info(f"File {path.name} ingested ({source}) — dense+sparse")
return {
"id": result.get("id") or result.get("existing_id"),
"status": result["status"],
"collection": COLLECTION_NAME,
"source": source,
"file": str(path),
"similarity": result.get("similarity"),
}

View File

@ -0,0 +1,71 @@
"""
Tasks episodic memory ingestion.
"""
import logging
import os
import uuid
from datetime import datetime, timezone
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import PointStruct
from services.embedding import get_embedding
logger = logging.getLogger("cognitive-worker.ingestion")
COLLECTION_NAME = os.environ.get("COLLECTION_NAME", "knowledge_base")
async def ingest_memory(
qdrant: AsyncQdrantClient,
memory_text: str,
source: str,
tags: list | None = None,
) -> dict:
"""
Ingests an episodic memory into Qdrant.
Returns a dict with id and status.
"""
if not memory_text or not memory_text.strip():
raise ValueError("memory_text cannot be empty")
tags = tags or []
point_id = str(uuid.uuid4())
timestamp = datetime.now(timezone.utc).isoformat()
# Generate embedding
try:
vector = await get_embedding(memory_text)
except Exception as e:
logger.error(f"Error generating embedding: {e}")
raise
# Rich payload for search and reflection
payload = {
"text": memory_text,
"source": source,
"tags": tags,
"created_at": timestamp,
"reflection_count": 0,
"last_reflected": None,
}
point = PointStruct(
id=point_id,
vector={"dense": vector},
payload=payload,
)
await qdrant.upsert(
collection_name=COLLECTION_NAME,
points=[point],
wait=True,
)
logger.info(f"Memory {point_id[:8]}... ingested ({source})")
return {
"id": point_id,
"status": "ingested",
"collection": COLLECTION_NAME,
}

View File

@ -0,0 +1,374 @@
"""
Tasks Reflection Engine v2.
Reviews old memories, generates insights, CREATES NEW INDEXABLE POINTS in Qdrant.
"""
import logging
import json
import uuid
from datetime import datetime, timezone
from qdrant_client import AsyncQdrantClient
from qdrant_client.models import PointStruct, Filter, FieldCondition, Range
# Alias for qdrant_client.models used in Micro Reflection
import qdrant_client.models as qmodels
from services.llm import ollama_chat
from services.embedding import get_embedding
logger = logging.getLogger("cognitive-worker.reflection")
COLLECTION_NAME = os.environ.get("COLLECTION_NAME", "knowledge_base")
REFLECTION_PROMPT = """
You are a cognitive memory assistant. Analyze the provided memories and extract:
1. Recurring patterns
2. Connections between memories
3. Insights or learnings
4. Suggested actions
Memories:
{memories}
Respond in JSON with keys: patterns, connections, insights, actions.
"""
async def reflect_on_memories(qdrant: AsyncQdrantClient) -> dict:
"""
Runs a reflection cycle on unreflected or old memories.
GENERATES NEW indexable points in Qdrant with the insights.
"""
# Fetch memories with low reflection_count or old
filter_ref = Filter(
must=[
FieldCondition(
key="reflection_count",
range=Range(lt=3),
),
]
)
results = await qdrant.scroll(
collection_name=COLLECTION_NAME,
scroll_filter=filter_ref,
limit=5, # reduced from 20 to avoid LLM timeout
with_payload=True,
with_vectors=False,
)
points = results[0] # scroll returns (points, next_page_offset)
if not points:
logger.info("No memories need reflection")
return {"status": "no-op", "processed": 0}
parent_ids = [p.id for p in points]
# Prepare batch of memories for LLM
memories_text = "\n\n".join(
f"- [{p.id[:8]}] Source: {p.payload.get('source', '?')} | {p.payload.get('text', '')[:400]}"
for p in points
)
prompt = REFLECTION_PROMPT.format(memories=memories_text)
try:
response = await ollama_chat(prompt)
reflection_data = json.loads(response)
except json.JSONDecodeError:
logger.warning("Reflection returned invalid JSON, saving raw")
reflection_data = {"raw": response}
except Exception as e:
logger.error(f"Reflection LLM error: {e}")
raise
# ─── CREATE NEW INDEXABLE POINT with the insight ─────────────────────────
# Text for embedding: concatenation of insights
insight_text = json.dumps(reflection_data, ensure_ascii=False, indent=2)
reflection_vector = await get_embedding(insight_text)
now = datetime.now(timezone.utc).isoformat()
reflection_point = PointStruct(
id=str(uuid.uuid4()),
vector={"dense": reflection_vector},
payload={
"text": insight_text,
"source": "reflection",
"tags": ["reflection", "auto-generated", "insight"],
"created_at": now,
"reflection_count": 0,
"last_reflected": None,
"parent_ids": parent_ids,
"title": f"Reflection batch ({len(points)} memories)",
"word_count": len(insight_text.split()),
},
)
await qdrant.upsert(
collection_name=COLLECTION_NAME,
points=[reflection_point],
wait=True,
)
logger.info(f"Reflection point created: {reflection_point.id[:8]} (parents: {len(parent_ids)})")
# Update metadata of processed memories (lifecycle)
for point in points:
new_count = point.payload.get("reflection_count", 0) + 1
await qdrant.set_payload(
collection_name=COLLECTION_NAME,
payload={
"reflection_count": new_count,
"last_reflected": now,
},
points=[point.id],
)
logger.info(f"Reflection completed: {len(points)} memories processed + 1 new indexable point")
return {
"status": "reflected",
"processed": len(points),
"reflection_point_id": reflection_point.id,
"reflection": reflection_data,
}
# ─── MICRO REFLECTION (Phase 3) — Consolidation, not cogitation ────────────
MICRO_REFLECTION_PROMPT = """
You are a cognitive memory assistant. Your job is to CONSOLIDATE existing data, never generate new knowledge.
Analyze the following memory chunk and its similar neighbors. Detect factual contradictions,
inconsistencies, or problematic patterns that could reduce the reliability of this chunk.
Main chunk:
{chunk_text}
Similar neighbors:
{neighbors_text}
Respond in JSON:
{{
"contradiction_found": true | false,
"severity": "low" | "medium" | "high",
"explanation": "Concise description of the problem or confirmation of consistency"
}}
"""
import sqlite3
import os
from typing import Optional
STATE_DB_PATH = os.environ.get("STATE_DB_PATH", "/hermes/state.db")
def get_budget_for_hour(hour_window: str) -> int:
"""Returns how many micro-reflections have run in this hour window."""
try:
conn = sqlite3.connect(STATE_DB_PATH)
cursor = conn.cursor()
cursor.execute(
"SELECT count FROM reflection_budget WHERE hour_window = ?",
(hour_window,),
)
row = cursor.fetchone()
conn.close()
return row[0] if row else 0
except Exception as e:
logger.warning(f"Error checking budget: {e}")
return 0 # fail-open: if can't check, allow through
def increment_budget(hour_window: str, tokens_used: int = 0):
"""Increments the reflection counter for the current hour."""
try:
conn = sqlite3.connect(STATE_DB_PATH)
cursor = conn.cursor()
cursor.execute("""
INSERT INTO reflection_budget (hour_window, count, tokens_used)
VALUES (?, 1, ?)
ON CONFLICT(hour_window)
DO UPDATE SET count = count + 1, tokens_used = tokens_used + ?
""", (hour_window, tokens_used, tokens_used))
conn.commit()
conn.close()
except Exception as e:
logger.warning(f"Error incrementing budget: {e}")
async def micro_reflection(qdrant: AsyncQdrantClient) -> dict:
"""
Micro-reflection: consolidates freshly ingested chunks using LLM.
Does NOT create new points. Only updates confidence_score and reflection_notes.
"""
now = datetime.now(timezone.utc)
hour_window = now.strftime("%Y-%m-%dT%H")
# ── Budget check ──────────────────────────────────────────────────────
max_per_hour = int(os.environ.get("MICRO_REFLECTION_MAX_PER_HOUR", "5"))
current_count = get_budget_for_hour(hour_window)
if current_count >= max_per_hour:
logger.info(f"Micro-reflection budget exhausted for {hour_window} ({current_count}/{max_per_hour})")
return {"status": "budget_exceeded", "processed": 0}
# ── Select chunks ─────────────────────────────────────────────────────
max_chunks = int(os.environ.get("MICRO_REFLECTION_MAX_CHUNKS", "10"))
filter_chunks = Filter(
must=[
FieldCondition(
key="reflection_count",
range=Range(lt=3),
),
FieldCondition(
key="archived",
match=qmodels.MatchValue(value=False),
),
]
)
try:
# Order by created_at DESC via scroll (Qdrant has no direct sort; use scroll with limit)
results = await qdrant.scroll(
collection_name=COLLECTION_NAME,
scroll_filter=filter_chunks,
limit=max_chunks,
with_payload=True,
with_vectors=False,
)
points = results[0]
except Exception as e:
logger.warning(f"Error fetching chunks for reflection: {e}")
return {"status": "error", "error": str(e)}
if not points:
logger.info("No eligible chunks for micro-reflection")
return {"status": "no-op", "processed": 0}
processed = 0
contradictions = 0
consistencies = 0
for point in points:
chunk_text = point.payload.get("text", "")
chunk_id = point.id
if not chunk_text:
continue
# Fetch similar neighbors via REST API
try:
import httpx
point_data = await qdrant.retrieve(
collection_name=COLLECTION_NAME,
ids=[chunk_id],
with_vectors=True,
)
if not point_data or not point_data[0].vector:
logger.warning(f"Could not get vector for chunk {chunk_id}")
continue
vector = point_data[0].vector.get("dense") if isinstance(point_data[0].vector, dict) else point_data[0].vector
async with httpx.AsyncClient() as client:
qdrant_host = os.environ.get("QDRANT_HOST", "qdrant-maas")
qdrant_port = int(os.environ.get("QDRANT_PORT", "6333"))
resp = await client.post(
f"http://{qdrant_host}:{qdrant_port}/collections/{COLLECTION_NAME}/points/search",
json={
"vector": {"name": "dense", "vector": vector},
"limit": 4,
"with_payload": True,
},
timeout=10,
)
resp.raise_for_status()
neighbors = resp.json().get("result", [])
# Filter out the chunk itself
neighbor_texts = []
for n in neighbors:
if n["id"] != chunk_id and n.get("payload", {}).get("text"):
neighbor_texts.append(f"[{str(n['id'])[:8]}] {n['payload']['text'][:300]}")
if len(neighbor_texts) < 2:
logger.info(f"Chunk {chunk_id[:8]} has too few neighbors, skipping")
continue
except Exception as e:
logger.warning(f"Error fetching neighbors for {chunk_id}: {e}")
continue
# LLM analysis
neighbors_text = "\n\n".join(neighbor_texts[:3])
prompt = MICRO_REFLECTION_PROMPT.format(
chunk_text=chunk_text[:600],
neighbors_text=neighbors_text,
)
try:
response = await ollama_chat(prompt)
# Extract JSON from response
import re
json_match = re.search(r'\{[^}]*\}', response)
if json_match:
analysis = json.loads(json_match.group())
else:
# Try to parse the whole response
analysis = json.loads(response.strip())
except json.JSONDecodeError:
logger.warning(f"LLM response is not valid JSON for chunk {chunk_id[:8]}: {response[:100]}")
analysis = {"contradiction_found": False, "severity": "low", "explanation": "Could not analyze"}
except Exception as e:
logger.warning(f"LLM error for chunk {chunk_id[:8]}: {e}")
continue
# Apply result
current_confidence = point.payload.get("confidence_score", 1.0)
contradiction_found = analysis.get("contradiction_found", False)
severity = analysis.get("severity", "low")
explanation = analysis.get("explanation", "")
if contradiction_found:
severity_mult = {"low": 0.05, "medium": 0.1, "high": 0.2}.get(severity, 0.1)
new_confidence = max(0.0, current_confidence - severity_mult)
contradictions += 1
reflection_note = f"[CONTRADICTION {severity.upper()}] {explanation}"
else:
new_confidence = min(1.0, current_confidence + 0.05)
consistencies += 1
reflection_note = f"[CONSISTENT] {explanation}"
# Update payload in Qdrant
new_count = point.payload.get("reflection_count", 0) + 1
try:
await qdrant.set_payload(
collection_name=COLLECTION_NAME,
payload={
"confidence_score": round(new_confidence, 3),
"reflection_count": new_count,
"last_reflected": now.isoformat(),
"reflection_notes": reflection_note,
},
points=[chunk_id],
)
processed += 1
logger.info(f"Micro-reflection {chunk_id[:8]}: {reflection_note[:80]}")
except Exception as e:
logger.warning(f"Error updating chunk {chunk_id}: {e}")
# Increment budget
increment_budget(hour_window, tokens_used=0)
logger.info(f"Micro-reflection completed: {processed} chunks, {contradictions} contradictions, {consistencies} consistent")
return {
"status": "completed",
"processed": processed,
"contradictions": contradictions,
"consistencies": consistencies,
"budget_hour": hour_window,
"budget_used": current_count + 1,
}

21
icarus/LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 esaradev
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

356
icarus/README.md Normal file
View File

@ -0,0 +1,356 @@
```diff
+ .
+ /|\
+ / | \
+ / | \
+ / | \
+ / ,--+--, \
+ /,' | ',\
! // ,--+--, \\
! //__/ | \__\\
- \ | /
- \ __|__ /
- \/ \/
! '. .'
! ____'.'____
! / \
+ / I C A R U S \
+ / \
+ '~~~~~~~~~~~~~~~~~~~'
```
> **Self-memory and replacement models for Hermes agents.**
>
> *Remember your work. Train your replacement.*
## What this is
Icarus is a **Hermes plugin**. It runs inside Hermes and gives agents shared memory, training data extraction, and a model replacement pipeline.
Icarus is **not** an Obsidian plugin. Obsidian is an optional viewer/editor for the markdown files Icarus writes. You don't need Obsidian to use Icarus.
## What this is not
- Not an orchestration framework
- Not an agent router
- Not a dashboard
- Not an Obsidian community plugin
- Not a standalone app
## Architecture
```
┌──────────────────────────────────────────────────────────┐
│ Hermes Agent │
│ ├── Icarus plugin (this repo) │
│ │ ├── hooks: auto-capture decisions, inject context │
│ │ ├── tools: recall, write, search, train, switch │
│ │ └── scoring: session quality, export weighting │
│ │ │
│ │ writes/reads │
│ │ │ │
│ ▼ ▼ │
│ ~/my-vault/icarus/ (FABRIC_DIR) │
│ ├── agent-decision-chose-fastify-abc1.md │
│ ├── agent-review-rate-limiter-race-d4e2.md │
│ ├── daily/2026-04-01.md (Obsidian daily notes) │
│ └── cold/ (archived entries) │
│ │
│ ~/my-vault/ (OBSIDIAN_VAULT_PATH) │
│ └── .obsidian/app.json (vault config) │
│ │
│ export-training.py ──► together.jsonl ──► Together AI │
│ │ │
│ ▼ │
│ fine-tuned replacement model │
└──────────────────────────────────────────────────────────┘
```
## 2-minute quickstart
### 1. Install the plugin
```bash
git clone https://github.com/esaradev/icarus-plugin.git
mkdir -p ~/.hermes/plugins/icarus
cp -r icarus-plugin/* ~/.hermes/plugins/icarus/
```
### 2. Set environment variables
Add to your Hermes profile `.env` (e.g. `~/.hermes/.env`):
```bash
# required: where Icarus writes notes
FABRIC_DIR=~/Documents/my-vault/icarus
# optional: enable Obsidian wikilinks and daily notes
ICARUS_OBSIDIAN=1
# optional: vault root (if icarus notes are a subfolder)
OBSIDIAN_VAULT_PATH=~/Documents/my-vault
# optional: for training/eval tools
TOGETHER_API_KEY=tok-...
```
### 3. Start Hermes and verify
```bash
hermes chat
```
Type `/plugins` to verify:
```
Plugins (1):
✓ icarus v0.3.0 (16 tools, 4 hooks)
```
### 4. Initialize Obsidian (optional)
Inside your Hermes chat, say:
> Set up Obsidian for my notes
The agent will call `fabric_init_obsidian`, which creates `.obsidian/app.json` at your vault root and `daily/` inside your notes directory.
### 5. Write a test note and verify
Inside Hermes:
> Write a fabric note about testing the setup
Then open your vault in Obsidian. You should see:
- A new `.md` file in your notes directory with a readable title
- A daily note at `daily/2026-04-01.md` with a wikilink to it
- YAML frontmatter visible in the note
## What Icarus adds to Hermes
Hermes already has per-instance memory and a capable runtime. Icarus adds:
- **Cross-instance shared memory** -- agents on different profiles read each other's work through a shared `FABRIC_DIR`
- **Decision-quality tagging** -- entries carry `training_value` (high/normal/low) so noise doesn't pollute training data
- **Training data extraction** -- fabric entries become fine-tuning pairs with quality filtering and pair weighting
- **Model replacement pipeline** -- fine-tune a cheaper model from your agent's own history, eval it, switch to it
## Tools
### Memory
| Tool | What it does |
|------|-------------|
| `fabric_recall` | Ranked retrieval from shared memory |
| `fabric_write` | Write entries with linking, evidence, and handoff fields |
| `fabric_search` | Keyword grep across all entries |
| `fabric_pending` | Show work assigned to this agent |
| `fabric_curate` | Set training value (high/normal/low) on an entry |
### Training
| Tool | What it does |
|------|-------------|
| `fabric_export` | Export training pairs. Modes: high-precision, normal, high-volume |
| `fabric_train` | Start fine-tune, auto-selects best quality mode with enough pairs |
| `fabric_train_status` | Check job progress, updates model registry |
### Replacement models
| Tool | What it does |
|------|-------------|
| `fabric_models` | List all trained models with eval scores |
| `fabric_eval` | Compare candidate vs base model on fabric-derived prompts |
| `fabric_switch_model` | Activate a replacement model if eval passes threshold |
| `fabric_rollback_model` | Emergency rollback to previous model |
### Operational
| Tool | What it does |
|------|-------------|
| `fabric_brief` | Daily brief: pending work, recent activity, suggested action |
| `fabric_telemetry` | Recall/usage stats: what gets recalled, what gets used |
| `fabric_report` | Corpus health: entries by type, training value, trainable estimate |
| `fabric_init_obsidian` | One-time Obsidian vault setup (Hermes tool, not a shell command) |
## Hooks
4 automatic hooks fire without the agent calling anything:
- **on_session_start** -- loads SOUL, pending handoffs, recent context
- **pre_llm_call** -- injects relevant memories when the topic changes
- **post_llm_call** -- captures high-value decisions (decision + outcome + substantial user request)
- **on_session_end** -- scores session quality, writes structured note if threshold met
## Obsidian setup
Icarus is a **Hermes plugin**, not an Obsidian plugin. Obsidian just reads the markdown files.
**How it works:**
- `FABRIC_DIR` is where Icarus writes `.md` files (your notes directory)
- `OBSIDIAN_VAULT_PATH` is where `.obsidian/` lives (your vault root)
- `ICARUS_OBSIDIAN=1` enables wikilinks in note bodies and daily note linking
- `fabric_init_obsidian` is a Hermes tool -- call it from inside Hermes, not from the terminal
**Two setups:**
Dedicated vault (Icarus IS the vault):
```
FABRIC_DIR=~/icarus-vault
# OBSIDIAN_VAULT_PATH not needed
```
Subfolder in existing vault:
```
FABRIC_DIR=~/my-vault/icarus-notes
OBSIDIAN_VAULT_PATH=~/my-vault
```
## Builder -> reviewer -> fix
```
# builder finishes work, hands off
fabric_write(type="code-session", summary="rate limiter ready",
status="open", assigned_to="daedalus")
# reviewer sees it at session start, writes linked review
fabric_write(type="review", summary="found race condition",
review_of="icarus:a3f29b01")
# builder sees the review, writes linked fix
fabric_write(type="code-session", summary="fixed race condition",
revises="icarus:a3f29b01")
```
## Memory -> training -> replacement model
```
1. Work normally. The plugin captures decisions and completions automatically.
2. Check readiness:
fabric_export(mode="high-precision")
3. Fine-tune:
fabric_train(suffix="my-agent-v2")
4. Check progress:
fabric_train_status()
5. Evaluate:
fabric_eval(candidate_model="user/my-agent-v2-abc123")
6. Switch:
fabric_switch_model(model_id="user/my-agent-v2-abc123")
```
## Training value
Entries carry a `training_value` field: `high`, `normal`, or `low`.
- **high** -- decisions with outcomes, completed reviews, successful fixes
- **normal** -- default for most entries
- **low** -- generic session summaries, conversational exchanges
Export modes:
- `high-precision` -- only grounded entries: high-value, verified, linked reviews, structured sessions, or completed entries with evidence
- `normal` -- excludes low-value and skips noisy unstructured session notes unless grounded
- `high-volume` -- everything
## Profiles (Hermes v0.6.0)
```bash
hermes profile create coder
hermes profile create reviewer --clone
mkdir -p ~/.hermes-coder/plugins/icarus ~/.hermes-reviewer/plugins/icarus
cp -r icarus-plugin/* ~/.hermes-coder/plugins/icarus/
cp -r icarus-plugin/* ~/.hermes-reviewer/plugins/icarus/
hermes -p coder chat
```
Both profiles write to the same `FABRIC_DIR`, so the reviewer sees the coder's work.
## Fallback models
After switching to a replacement model, set the original as fallback in `config.yaml`:
```yaml
model: user/my-agent-v2-abc123
fallback_model:
provider: openrouter
model: anthropic/claude-sonnet-4
```
## Troubleshooting
**"tool not found" when calling fabric_write or fabric_recall**
- Run `/plugins` in Hermes. If Icarus isn't listed, the plugin isn't installed in the right directory.
- Check: `ls ~/.hermes/plugins/icarus/__init__.py` (global) or `ls ~/.hermes-YOUR_PROFILE/plugins/icarus/__init__.py` (profile-specific Hermes home).
- The plugin needs `__init__.py`, `plugin.yaml`, and all `.py` files in the same directory.
- If you copied the repo twice, make sure you do **not** have a nested path like `~/.hermes/plugins/icarus/icarus-plugin/__init__.py`.
**Notes not showing in Obsidian**
- Check `FABRIC_DIR` points to a directory inside your Obsidian vault.
- Open the vault root (not the notes subdirectory) in Obsidian.
- If you set `OBSIDIAN_VAULT_PATH`, make sure `FABRIC_DIR` is inside it.
**"I pointed FABRIC_DIR at the wrong directory"**
- Change `FABRIC_DIR` in your `.env` and restart Hermes. Existing notes stay where they were. Move them manually if needed.
**".obsidian ended up in the wrong place"**
- Delete the misplaced `.obsidian/` directory.
- Set `OBSIDIAN_VAULT_PATH` to your actual vault root.
- Call `fabric_init_obsidian` again inside Hermes.
**"I expected an Obsidian plugin"**
- Icarus is a Hermes plugin, not an Obsidian community plugin. There is nothing to install in Obsidian. Obsidian reads the markdown files directly -- no plugin needed.
**Wikilinks not appearing in notes**
- Set `ICARUS_OBSIDIAN=1` in your `.env` and restart Hermes. Links are only added when this flag is set.
## Validation
After setup, verify everything works:
```
1. In Hermes: "write a test note about validating the setup"
2. Check: ls $FABRIC_DIR/*.md (should show a new file)
3. Check: ls $FABRIC_DIR/daily/ (should show today's date)
4. Open vault in Obsidian: note should appear with frontmatter
5. In Hermes: fabric_brief() (should show the note in recent work)
```
## Smoke test
```bash
bash scripts/smoke-handoff.sh
bash scripts/test-plugin.sh
```
## Requirements
- [Hermes](https://github.com/NousResearch/hermes-agent) v0.6.0+
- Python 3.10+
- `TOGETHER_API_KEY` in `.env` (for training/eval tools)
- `FABRIC_DIR` set in `.env` (defaults to `~/fabric/`)
## Files
```
__init__.py registration (16 tools, 4 hooks)
plugin.yaml manifest
schemas.py tool schemas (what the LLM sees)
tools.py tool handlers
hooks.py lifecycle hooks
state.py fabric I/O, session scoring, model registry
obsidian.py opt-in Obsidian formatting
fabric-retrieve.py ranked retrieval with scoring
export-training.py training pair extraction with quality filtering
scripts/
eval-replacement.py model comparison eval
smoke-handoff.sh end-to-end handoff proof
test-plugin.sh 66-test fixture suite
```
## License
MIT

89
icarus/__init__.py Normal file
View File

@ -0,0 +1,89 @@
"""
Icarus v3 Self-memory and replacement models for Hermes agents.
Remember your work. Train your replacement.
Memory tools:
fabric_recall ranked retrieval from shared fabric
fabric_write write entry with linking, training_value, and handoff fields
fabric_search keyword grep across fabric
fabric_pending work assigned to this agent
fabric_curate set training value (high/normal/low) on an entry
Training tools:
fabric_export export training pairs with quality filtering (high-precision/normal/high-volume)
fabric_train start Together AI fine-tune, registers model in registry
fabric_train_status check job, update registry on completion
Replacement-model tools:
fabric_models list all trained models with eval scores
fabric_eval compare candidate vs base model on fabric-derived eval set
fabric_switch_model activate a replacement model if eval passes threshold
fabric_rollback_model emergency rollback to .env.backup
Daily driver:
fabric_brief operational brief: pending, recent work, suggested action
fabric_telemetry retrieval/usage stats: what gets recalled, what gets used
Hooks (automatic):
on_session_start loads SOUL, pending handoffs, recent context
pre_llm_call injects relevant memories on topic change
post_llm_call captures high-value decisions (requires outcome indicator)
on_session_end writes best exchange as session entry (skips thin sessions)
"""
import logging
from . import schemas, tools, hooks
logger = logging.getLogger(__name__)
def register(ctx):
# memory
ctx.register_tool(name="fabric_recall", toolset="fabric",
schema=schemas.FABRIC_RECALL, handler=tools.fabric_recall)
ctx.register_tool(name="fabric_write", toolset="fabric",
schema=schemas.FABRIC_WRITE, handler=tools.fabric_write)
ctx.register_tool(name="fabric_search", toolset="fabric",
schema=schemas.FABRIC_SEARCH, handler=tools.fabric_search)
ctx.register_tool(name="fabric_pending", toolset="fabric",
schema=schemas.FABRIC_PENDING, handler=tools.fabric_pending)
ctx.register_tool(name="fabric_curate", toolset="fabric",
schema=schemas.FABRIC_CURATE, handler=tools.fabric_curate)
# training
ctx.register_tool(name="fabric_export", toolset="fabric",
schema=schemas.FABRIC_EXPORT, handler=tools.fabric_export)
ctx.register_tool(name="fabric_train", toolset="fabric",
schema=schemas.FABRIC_TRAIN, handler=tools.fabric_train)
ctx.register_tool(name="fabric_train_status", toolset="fabric",
schema=schemas.FABRIC_TRAIN_STATUS, handler=tools.fabric_train_status)
# replacement models
ctx.register_tool(name="fabric_models", toolset="fabric",
schema=schemas.FABRIC_MODELS, handler=tools.fabric_models)
ctx.register_tool(name="fabric_eval", toolset="fabric",
schema=schemas.FABRIC_EVAL, handler=tools.fabric_eval)
ctx.register_tool(name="fabric_switch_model", toolset="fabric",
schema=schemas.FABRIC_SWITCH_MODEL, handler=tools.fabric_switch_model)
ctx.register_tool(name="fabric_rollback_model", toolset="fabric",
schema=schemas.FABRIC_ROLLBACK_MODEL, handler=tools.fabric_rollback_model)
# daily driver
ctx.register_tool(name="fabric_brief", toolset="fabric",
schema=schemas.FABRIC_BRIEF, handler=tools.fabric_brief)
ctx.register_tool(name="fabric_telemetry", toolset="fabric",
schema=schemas.FABRIC_TELEMETRY, handler=tools.fabric_telemetry)
ctx.register_tool(name="fabric_init_obsidian", toolset="fabric",
schema=schemas.FABRIC_INIT_OBSIDIAN, handler=tools.fabric_init_obsidian)
ctx.register_tool(name="fabric_report", toolset="fabric",
schema=schemas.FABRIC_REPORT, handler=tools.fabric_report)
# hooks
ctx.register_hook("on_session_start", hooks.on_session_start)
ctx.register_hook("pre_llm_call", hooks.pre_llm_call)
ctx.register_hook("post_llm_call", hooks.post_llm_call)
ctx.register_hook("on_session_end", hooks.on_session_end)
logger.info("icarus v3 registered (16 tools, 4 hooks)")

521
icarus/export-training.py Normal file
View File

@ -0,0 +1,521 @@
#!/usr/bin/env python3
"""export-training.py -- Extract fine-tuning data from fabric entries.
Reads ~/fabric/ and generates training pairs in three formats:
openai.jsonl -- OpenAI fine-tuning format
hf-dataset.jsonl -- Hugging Face dataset format
raw-pairs.json -- Raw input/output pairs
Usage: python3 export-training.py --output ./training-data/
"""
import argparse
import json
import os
import re
import sys
from pathlib import Path
FABRIC_DIR = Path(os.environ.get("FABRIC_DIR", Path.home() / "fabric"))
def _truthy(value) -> bool:
return str(value).strip().lower() in {"1", "true", "yes", "y", "on"}
def _has_evidence(entry) -> bool:
return any(str(entry.get(field, "")).strip() for field in ("evidence", "source_tool", "artifact_paths"))
def _is_structured_session(entry) -> bool:
body = str(entry.get("body", ""))
return "## Task" in body and "## Result" in body
def _entry_quality(entry) -> dict:
verified = _truthy(entry.get("verified", ""))
training_value = str(entry.get("training_value", "")).strip()
has_evidence = _has_evidence(entry)
is_review_linked = entry.get("type") == "review" and bool(entry.get("review_of"))
structured_session = entry.get("type") == "session" and _is_structured_session(entry)
is_completed = str(entry.get("status", "")).strip() == "completed"
return {
"verified": verified,
"training_value": training_value,
"has_evidence": has_evidence,
"is_review_linked": is_review_linked,
"structured_session": structured_session,
"is_completed": is_completed,
"is_high_precision": (
training_value == "high"
or verified
or is_review_linked
or (structured_session and training_value in ("high", "normal"))
or (is_completed and has_evidence)
),
"is_normal": (
training_value != "low"
and (
entry.get("type") != "session"
or structured_session
or verified
or training_value == "high"
or has_evidence
)
),
}
def _strip_generated_obsidian_sections(body: str) -> str:
body = re.sub(
r"\n*<!-- ICARUS_OBSIDIAN_LINKS_START -->.*?<!-- ICARUS_OBSIDIAN_LINKS_END -->\n*",
"\n",
body,
flags=re.DOTALL,
)
return body.strip()
def parse_entry(filepath):
"""Parse a fabric markdown entry into a dict."""
text = filepath.read_text(encoding="utf-8")
if not text.startswith("---"):
return None
parts = text.split("---", 2)
if len(parts) < 3:
return None
meta = {}
try:
import yaml as _yaml
meta = _yaml.safe_load(parts[1]) or {}
except Exception:
lines = parts[1].strip().split("\n")
current_key = None
for line in lines:
stripped = line.strip()
if stripped.startswith("- ") and current_key:
if not isinstance(meta.get(current_key), list):
meta[current_key] = []
meta[current_key].append(stripped[2:].strip().strip("\"'"))
elif ": " in stripped and not stripped.startswith("-"):
k, v = stripped.split(": ", 1)
k = k.strip()
current_key = k
if v.startswith("[") and v.endswith("]"):
meta[k] = [x.strip().strip("\"'") for x in v[1:-1].split(",") if x.strip()]
elif v.strip():
meta[k] = v.strip()
else:
meta[k] = []
elif stripped.endswith(":") and not stripped.startswith("-"):
current_key = stripped[:-1].strip()
meta[current_key] = []
meta["body"] = _strip_generated_obsidian_sections(parts[2])
meta["file"] = filepath.name
return meta
def scan_all():
"""Scan all fabric entries including cold."""
entries = []
for d in [FABRIC_DIR, FABRIC_DIR / "cold"]:
if not d.exists():
continue
for f in sorted(d.glob("*.md")):
e = parse_entry(f)
if e:
entries.append(e)
return entries
def _resolve_ref(ref, entries):
"""Resolve a ref string (agent:id) to a specific entry.
Checks in priority order:
1. id field match (exact)
2. agent:cycle match
3. agent:timestamp substring
4. agent:filename substring
First match wins. Returns None if unresolvable.
"""
if ":" not in ref:
return None
ref_agent, ref_id = ref.split(":", 1)
if not ref_agent or not ref_id:
return None
# 1. Exact id field match
for o in entries:
if o.get("agent") == ref_agent and str(o.get("id", "")) == str(ref_id):
return o
# 2. Cycle field match
for o in entries:
if o.get("agent") == ref_agent and str(o.get("cycle", "")) == str(ref_id):
return o
# 3. Timestamp substring match
for o in entries:
if o.get("agent") == ref_agent and str(ref_id) in str(o.get("timestamp", "")):
return o
# 4. Filename substring match
for o in entries:
if o.get("agent") == ref_agent and str(ref_id) in o.get("file", ""):
return o
return None
def estimate_tokens(text):
"""Rough token estimate: ~4 chars per token."""
return len(text) // 4
def make_pair(user_content, assistant_content, metadata=None):
"""Create a training pair dict."""
return {
"input": user_content,
"output": assistant_content,
"metadata": metadata or {},
}
def _timestamp_sort_key(entry):
return str(entry.get("timestamp", ""))
def extract_pairs(entries):
"""Extract all training pairs from fabric entries."""
pairs = []
seen_pairs = set()
review_pairs = 0
xplat_pairs = 0
# index entries by agent+cycle for cross-referencing
by_ref = {}
for e in entries:
refs = e.get("refs", [])
if isinstance(refs, str):
refs = [r.strip() for r in refs.split(",") if r.strip()]
agent = e.get("agent", "")
by_ref[f"{agent}:{e.get('file', '')}"] = e
def add_pair(user_msg, output, metadata, dedupe_key=None):
nonlocal pairs
key = dedupe_key or (metadata.get("type"), user_msg, output)
if key in seen_pairs:
return False
seen_pairs.add(key)
pairs.append(make_pair(user_msg, output, metadata))
return True
for e in entries:
agent = e.get("agent", "unknown")
platform = e.get("platform", "unknown")
entry_type = e.get("type", "")
body = e.get("body", "")
summary = e.get("summary", "")
quality = _entry_quality(e)
tv = quality["training_value"]
verified = quality["verified"]
has_evidence = quality["has_evidence"]
if not body or len(body) < 20:
continue
# base metadata shared by all pairs from this entry
base_meta = {
"agent": agent,
"platform": platform,
"training_value": tv,
"verified": verified,
"has_evidence": has_evidence,
"entry_type": entry_type,
}
# ── OUTCOME PAIR: focused summary → outcome ──
if e.get("outcome"):
add_pair(f"[outcome] {summary}", e["outcome"], {**base_meta, "type": "outcome"})
# ── BASIC PAIR: type as task, body as response ──
if entry_type in ("code-session", "task", "resolution", "research"):
user_msg = f"[{entry_type}] {summary}" if summary else f"Complete this {entry_type}"
add_pair(user_msg, body, {**base_meta, "type": "basic"})
elif entry_type == "dialogue":
user_msg = f"[dialogue] Respond as {agent} in a multi-agent conversation."
add_pair(user_msg, body, {**base_meta, "type": "dialogue"})
elif entry_type == "decision":
user_msg = f"[decision] What did you decide?"
add_pair(user_msg, body, {**base_meta, "type": "decision"})
elif entry_type == "session":
# structured session: extract task->result pair if present
if "## Task" in body and "## Result" in body:
task_match = re.search(r"## Task\n(.+?)(?=\n## |\Z)", body, re.DOTALL)
result_match = re.search(r"## Result\n(.+?)(?=\n## |\Z)", body, re.DOTALL)
if task_match and result_match:
add_pair(
f"[session-task] {task_match.group(1).strip()[:300]}",
result_match.group(1).strip()[:500],
{**base_meta, "type": "session-structured"},
)
# generic session pairs are much noisier; only keep when grounded
if verified or tv == "high" or has_evidence:
user_msg = f"[session] Summarize what was accomplished."
add_pair(user_msg, body, {**base_meta, "type": "session"})
elif entry_type == "review":
user_msg = f"[review] Review the following code or work."
add_pair(user_msg, body, {**base_meta, "type": "review"})
else:
user_msg = f"[{entry_type or 'task'}] {summary or 'Complete this task'}"
add_pair(user_msg, body, {"type": "basic", "agent": agent, "platform": platform})
# ── Parse refs ──
refs = e.get("refs", [])
if isinstance(refs, str):
refs = [r.strip() for r in refs.split(",") if r.strip()]
# ── REVIEW PAIRS: only pair explicitly linked entries ──
if entry_type == "review" and refs:
for ref in refs:
orig = _resolve_ref(ref, entries)
if not orig:
continue
# Find revision: must explicitly ref back to the review or original
review_file = e.get("file", "")
orig_file = orig.get("file", "")
ref_agent = ref.split(":")[0] if ":" in ref else ""
candidates = []
for candidate in entries:
if candidate.get("agent") != ref_agent:
continue
if str(candidate.get("timestamp", "")) <= str(e.get("timestamp", "")):
continue
if candidate.get("file") == orig_file:
continue
# candidate must ref back to the review or original
cand_refs = candidate.get("refs", [])
if isinstance(cand_refs, str):
cand_refs = [r.strip() for r in cand_refs.split(",") if r.strip()]
refs_back = False
for cr in cand_refs:
resolved = _resolve_ref(cr, [e, orig])
if resolved:
refs_back = True
break
if refs_back:
candidates.append(candidate)
if not candidates:
continue
improved = max(candidates, key=_timestamp_sort_key)
user_msg = f"[self-correct] Original work:\n{orig.get('body', '')[:300]}\n\nReview feedback:\n{body[:300]}\n\nProvide the improved version."
if add_pair(
user_msg,
improved.get("body", ""),
{"type": "review-correction", "reviewer": agent, "author": ref_agent},
dedupe_key=("review-correction", e.get("file", ""), orig.get("file", ""), improved.get("file", "")),
):
review_pairs += 1
# ── REVIEW PAIRS via review_of/revises (v3 plugin path) ──
if entry_type == "review" and e.get("review_of"):
ref = e["review_of"]
orig = _resolve_ref(ref, entries)
if orig:
ref_agent = ref.split(":")[0] if ":" in ref else ""
candidates = [
candidate for candidate in entries
if candidate.get("revises") == ref
]
if candidates:
improved = max(candidates, key=_timestamp_sort_key)
rc_msg = f"[self-correct] Original work:\n{orig.get('body', '')[:300]}\n\nReview feedback:\n{body[:300]}\n\nProvide the improved version."
if add_pair(
rc_msg,
improved.get("body", ""),
{"type": "review-correction", "reviewer": agent, "author": ref_agent, "training_value": tv},
dedupe_key=("review-correction", e.get("file", ""), orig.get("file", ""), improved.get("file", "")),
):
review_pairs += 1
# ── CROSS-PLATFORM via review_of ──
if e.get("review_of") and platform:
source = _resolve_ref(e["review_of"], entries)
if source:
src_plat = source.get("platform", "")
if src_plat and src_plat != platform:
user_msg = f"[cross-platform context] Memory from {src_plat}:\n{source.get('body', '')[:300]}\n\nYou are on {platform}. Use this context in your response."
if add_pair(
user_msg,
body,
{"type": "cross-platform", "source_platform": src_plat, "target_platform": platform, "agent": agent, "training_value": tv},
dedupe_key=("cross-platform", source.get("file", ""), e.get("file", ""), platform),
):
xplat_pairs += 1
# ── CROSS-PLATFORM PAIRS: resolve ref to specific entry ──
if refs and platform:
for ref in refs:
source = _resolve_ref(ref, entries)
if not source:
continue
src_plat = source.get("platform", "")
if not src_plat or src_plat == platform:
continue # same platform, not cross-platform
user_msg = f"[cross-platform context] Memory from {src_plat}:\n{source.get('body', '')[:300]}\n\nYou are on {platform}. Use this context in your response."
if add_pair(
user_msg,
body,
{"type": "cross-platform", "source_platform": src_plat, "target_platform": platform, "agent": agent},
dedupe_key=("cross-platform", source.get("file", ""), e.get("file", ""), platform),
):
xplat_pairs += 1
return pairs, review_pairs, xplat_pairs
def to_openai(pair):
"""Convert to OpenAI fine-tuning format."""
return {"messages": [
{"role": "user", "content": pair["input"]},
{"role": "assistant", "content": pair["output"]},
]}
def to_together(pair):
"""Convert to Together AI fine-tuning format (messages with system prompt)."""
return {"messages": [
{"role": "system", "content": "You are a helpful AI agent with shared memory across platforms."},
{"role": "user", "content": pair["input"]},
{"role": "assistant", "content": pair["output"]},
]}
def to_hf(pair):
"""Convert to Hugging Face dataset format."""
return {
"instruction": pair["input"],
"output": pair["output"],
"metadata": pair.get("metadata", {}),
}
def main():
parser = argparse.ArgumentParser(description="Export fabric entries as fine-tuning data")
parser.add_argument("--output", default="./training-data", help="Output directory")
parser.add_argument("--fabric-dir", default=None, help="Fabric directory (default: ~/fabric/)")
parser.add_argument("--mode", choices=["high-precision", "normal", "high-volume"],
default="normal", help="Export quality mode")
args = parser.parse_args()
global FABRIC_DIR
if args.fabric_dir:
FABRIC_DIR = Path(args.fabric_dir)
if not FABRIC_DIR.exists():
print(f"error: {FABRIC_DIR} does not exist")
sys.exit(1)
all_entries = scan_all()
if not all_entries:
print("no fabric entries found")
sys.exit(0)
# filter by mode
excluded = 0
if args.mode == "high-precision":
entries = [e for e in all_entries if _entry_quality(e)["is_high_precision"]]
excluded = len(all_entries) - len(entries)
elif args.mode == "normal":
entries = [e for e in all_entries if _entry_quality(e)["is_normal"]]
excluded = len(all_entries) - len(entries)
else:
entries = all_entries
pairs, review_count, xplat_count = extract_pairs(entries)
if not pairs:
print("no training pairs extracted")
sys.exit(0)
# weight high-value pairs (verified + cross-agent + structured get extra boost)
weighted = []
for p in pairs:
meta = p.get("metadata", {})
ptype = meta.get("type", "")
tv = meta.get("training_value", "")
is_verified = meta.get("verified", False)
has_evidence = meta.get("has_evidence", False)
author = meta.get("author", "")
reviewer = meta.get("reviewer", "")
is_cross_agent = bool(author and reviewer and author != reviewer)
if ptype == "review-correction":
if is_cross_agent and is_verified:
weighted.extend([p] * 5)
elif is_cross_agent or is_verified:
weighted.extend([p] * 4)
else:
weighted.extend([p] * 3)
elif ptype == "session-structured" and tv == "high":
weighted.extend([p] * 3)
elif tv == "high":
weighted.extend([p] * (4 if (is_verified and has_evidence) else 3 if is_verified else 2))
elif has_evidence and is_verified:
weighted.extend([p] * 3)
elif has_evidence:
weighted.extend([p] * 2)
elif is_verified:
weighted.extend([p] * 2)
else:
weighted.append(p)
pairs = weighted
# Write outputs
out = Path(args.output)
out.mkdir(parents=True, exist_ok=True)
# OpenAI format
with open(out / "openai.jsonl", "w") as f:
for p in pairs:
f.write(json.dumps(to_openai(p)) + "\n")
# Together AI format (Llama instruct template)
with open(out / "together.jsonl", "w") as f:
for p in pairs:
f.write(json.dumps(to_together(p)) + "\n")
# HuggingFace format
with open(out / "hf-dataset.jsonl", "w") as f:
for p in pairs:
f.write(json.dumps(to_hf(p)) + "\n")
# Raw pairs
with open(out / "raw-pairs.json", "w") as f:
json.dump(pairs, f, indent=2)
# Stats
total_tokens = sum(estimate_tokens(p["input"] + p["output"]) for p in pairs)
type_counts = {}
for p in pairs:
t = p.get("metadata", {}).get("type", "unknown")
type_counts[t] = type_counts.get(t, 0) + 1
print(f"exported to {out}/")
print(f" total pairs: {len(pairs)}")
print(f" review pairs: {review_count}")
print(f" cross-platform: {xplat_count}")
print(f" estimated tokens: {total_tokens:,}")
print(f" source entries: {len(entries)} selected / {len(all_entries)} total ({excluded} excluded)")
print(f" by type:")
for t, c in sorted(type_counts.items()):
print(f" {t}: {c}")
print(f" files:")
print(f" {out}/openai.jsonl")
print(f" {out}/together.jsonl")
print(f" {out}/hf-dataset.jsonl")
print(f" {out}/raw-pairs.json")
if __name__ == "__main__":
main()

391
icarus/fabric-retrieve.py Normal file
View File

@ -0,0 +1,391 @@
#!/usr/bin/env python3
"""fabric-retrieve.py -- Smart retrieval for fabric entries.
Given a query, returns the top N most relevant entries ranked by:
keyword match, project match, agent match, recency, tier, type match, ref chain.
Usage:
python3 fabric-retrieve.py "billing issue" --max-results 5 --max-tokens 2000
python3 fabric-retrieve.py "auth module" --agent icarus --project myapp
"""
import argparse
import os
import re
import sys
from datetime import datetime, timezone
from pathlib import Path
FABRIC_DIR = Path(os.environ.get("FABRIC_DIR", Path.home() / "fabric"))
def _strip_generated_obsidian_sections(body: str) -> str:
body = re.sub(
r"\n*<!-- ICARUS_OBSIDIAN_LINKS_START -->.*?<!-- ICARUS_OBSIDIAN_LINKS_END -->\n*",
"\n",
body,
flags=re.DOTALL,
)
return body.strip()
STOP_WORDS = {"the", "a", "an", "is", "was", "are", "were", "be", "been", "being",
"have", "has", "had", "do", "does", "did", "will", "would", "could",
"should", "may", "might", "shall", "can", "to", "of", "in", "for",
"on", "with", "at", "by", "from", "as", "into", "through", "during",
"it", "its", "this", "that", "and", "or", "but", "not", "no", "if",
"then", "than", "so", "up", "out", "about", "what", "which", "who",
"how", "when", "where", "why", "i", "me", "my", "we", "our", "you"}
CODE_WORDS = {"function", "bug", "error", "build", "deploy", "test", "code", "fix",
"commit", "merge", "api", "endpoint", "module", "class", "method",
"refactor", "debug", "compile", "runtime", "exception", "stack"}
CUSTOMER_WORDS = {"customer", "billing", "support", "ticket", "refund", "account",
"subscription", "payment", "invoice", "complaint", "resolution",
"escalation", "onboarding", "churn", "retention"}
HANDOFF_WORDS = {"handoff", "review", "reviewer", "pickup", "pending", "relay",
"assigned", "assignee", "revise", "revision", "feedback"}
def parse_entry(filepath):
text = filepath.read_text(encoding="utf-8")
if not text.startswith("---"):
return None
parts = text.split("---", 2)
if len(parts) < 3:
return None
meta = {}
try:
import yaml
meta = yaml.safe_load(parts[1]) or {}
except Exception:
current_key = None
for line in parts[1].strip().split("\n"):
stripped = line.strip()
if stripped.startswith("- ") and current_key:
if not isinstance(meta.get(current_key), list):
meta[current_key] = []
meta[current_key].append(stripped[2:].strip().strip("\"'"))
elif ": " in stripped and not stripped.startswith("-"):
k, v = stripped.split(": ", 1)
k = k.strip()
current_key = k
if v.startswith("[") and v.endswith("]"):
meta[k] = [x.strip().strip("\"'") for x in v[1:-1].split(",") if x.strip()]
elif v.strip():
meta[k] = v.strip()
else:
meta[k] = []
elif stripped.endswith(":") and not stripped.startswith("-"):
current_key = stripped[:-1].strip()
meta[current_key] = []
meta["_body"] = _strip_generated_obsidian_sections(parts[2])
meta["_file"] = filepath.name
meta["_full"] = text
return meta
def tokenize(text):
words = set(re.findall(r'[a-z0-9]+', text.lower()))
return words - STOP_WORDS
def _ngrams(tokens, n):
if len(tokens) < n:
return set()
return {tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)}
def age_hours(timestamp_str):
if not timestamp_str:
return 9999
try:
ts = datetime.fromisoformat(str(timestamp_str).replace("Z", "+00:00"))
delta = datetime.now(timezone.utc) - ts
return max(0, delta.total_seconds() / 3600)
except (ValueError, AttributeError):
return 9999
def _as_list(value):
if not value:
return []
if isinstance(value, list):
return value
return [value]
def score_entry(entry, query_tokens, agent=None, project=None, relevant_refs=None):
summary = str(entry.get("summary", ""))
summary_lower = summary.lower()
body = (entry.get("_body", "") + " " + summary).lower()
entry_tokens = tokenize(body)
summary_tokens = tokenize(summary_lower)
entry_type = entry.get("type", "")
query_text = " ".join(re.findall(r"[a-z0-9]+", " ".join(sorted(query_tokens))))
body_text = " ".join(re.findall(r"[a-z0-9]+", body))
query_seq = re.findall(r"[a-z0-9]+", query_text)
body_seq = re.findall(r"[a-z0-9]+", body)
score = 0.0
# 1. Keyword match (body + summary)
keyword_hits = len(query_tokens & entry_tokens)
score += keyword_hits * 5 # keywords are the primary signal
# 1a. Summary match is higher-signal than body text alone.
summary_hits = len(query_tokens & summary_tokens)
score += summary_hits * 3
# 1b. Exact phrase and n-gram matches beat loose token overlap.
raw_query = " ".join(re.findall(r"[a-z0-9]+", entry.get("_query", "")))
if raw_query and raw_query in body_text:
score += 18
if query_seq:
body_bigrams = _ngrams(body_seq, 2)
query_bigrams = _ngrams(query_seq, 2)
score += len(query_bigrams & body_bigrams) * 4
body_trigrams = _ngrams(body_seq, 3)
query_trigrams = _ngrams(query_seq, 3)
score += len(query_trigrams & body_trigrams) * 7
# 1b. Tag match (tags are high-signal metadata)
entry_tags = _as_list(entry.get("tags", []))
tag_tokens = set()
for t in entry_tags:
tag_tokens.update(re.findall(r'[a-z0-9]+', str(t).lower()))
tag_hits = len(query_tokens & tag_tokens)
score += tag_hits * 4
# 2. Same project (check project_id field first, then fallback to keyword match)
entry_project = entry.get("project_id", entry.get("project", ""))
if project:
project_lower = project.lower()
if project_lower == str(entry_project).lower():
score += 10 # exact project_id match
elif project_lower in body:
score += 8 # project name in body text
elif any(project_lower in str(t).lower() for t in entry_tags):
score += 8 # project name in tags
# 3. Same agent
if agent and entry.get("agent") == agent:
score += 5
# 4. Recency (secondary signal, should not override keyword match)
hours = age_hours(entry.get("timestamp"))
if hours < 1:
score += 4
elif hours < 24:
score += 3
elif hours < 168: # 1 week
score += 2
elif hours < 720: # 1 month
score += 1
# 5. Tier boost (light touch)
tier = entry.get("tier", "")
if tier == "hot":
score += 2
elif tier == "warm":
score += 1
# 6. Type match
if query_tokens & CODE_WORDS:
if entry_type in ("code-session", "review", "decision"):
score += 5
if query_tokens & CUSTOMER_WORDS:
if entry_type in ("resolution", "task", "decision"):
score += 5
if query_tokens & HANDOFF_WORDS:
if entry_type in ("task", "review", "resolution", "code-session"):
score += 6
elif entry_type == "session":
score -= 3
# Prefer source work artifacts over generic summaries.
if entry_type in ("task", "review", "resolution", "code-session", "research"):
score += 2
elif entry_type == "decision":
score += 1
elif entry_type == "session":
score -= 2
# Structured workflow fields are high-signal for handoffs and follow-ups.
if entry.get("status") == "open":
score += 4
if entry.get("assigned_to"):
score += 2
# Reviews reference the original work's keywords, which inflates their
# keyword score. When the query isn't asking for reviews/feedback, penalize
# type=review so the source entry ranks higher.
REVIEW_QUERY_WORDS = {"review", "reviewed", "feedback", "issue", "fix", "must", "should", "approve", "reject", "lgtm"}
if entry_type == "review":
if query_tokens & REVIEW_QUERY_WORDS:
# query IS about reviews — boost linking fields
if entry.get("review_of"):
score += 5
else:
# query is about the work itself — reviews are secondary
score -= 4
elif entry.get("review_of"):
score += 3
if entry.get("revises"):
score += 4
# 7. Ref chain
if relevant_refs:
entry_refs = _as_list(entry.get("refs", []))
entry_id = entry.get("id", "")
entry_agent = entry.get("agent", "")
entry_cycle = str(entry.get("cycle", ""))
for ref in relevant_refs:
if ref in entry_refs:
score += 3
# Check if this entry is referenced by a relevant entry
for ref_str in relevant_refs:
if ":" in ref_str:
ref_agent, ref_id = ref_str.split(":", 1)
if ref_agent == entry_agent and (ref_id == entry_id or ref_id == entry_cycle):
score += 3
linked_refs = [entry.get("review_of"), entry.get("revises")]
for ref in linked_refs:
if ref and ref in relevant_refs:
score += 5
return score
def deduplicate(entries):
seen = {}
result = []
for e in entries:
key = (e.get("agent", ""), e.get("type", ""), e.get("_body", "")[:50])
existing = seen.get(key)
if existing:
# Keep the newer one
if str(e.get("timestamp", "")) > str(existing.get("timestamp", "")):
result.remove(existing)
result.append(e)
seen[key] = e
else:
seen[key] = e
result.append(e)
return result
def retrieve(query, max_results=5, max_tokens=2000, agent=None, project=None):
if not FABRIC_DIR.exists():
return []
# Scan all entries
entries = []
for d in [FABRIC_DIR, FABRIC_DIR / "cold"]:
if not d.exists():
continue
for f in d.glob("*.md"):
e = parse_entry(f)
if e:
entries.append(e)
if not entries:
return []
query_tokens = tokenize(query)
query_words = re.findall(r"[a-z0-9]+", query.lower())
normalized_query = " ".join(query_words)
for e in entries:
e["_query"] = normalized_query
# First pass: score without ref chain
scored = [(score_entry(e, query_tokens, agent, project), e) for e in entries]
# Collect refs from top-scoring entries for ref chain boost
scored.sort(key=lambda x: x[0], reverse=True)
top_refs = set()
for score, e in scored[:10]:
if score > 0:
refs = e.get("refs", [])
if isinstance(refs, str):
refs = [refs]
top_refs.update(refs)
eid = e.get("id", "")
eagent = e.get("agent", "")
if eid:
top_refs.add(f"{eagent}:{eid}")
# Second pass: rescore with ref chain
if top_refs:
scored = [(score_entry(e, query_tokens, agent, project, top_refs), e) for e in entries]
scored.sort(key=lambda x: x[0], reverse=True)
# Filter zero scores
scored = [(s, e) for s, e in scored if s > 0]
# Deduplicate
deduped_entries = deduplicate([e for _, e in scored])
# Reattach scores
score_map = {id(e): s for s, e in scored}
# Rebuild scored list preserving dedup order
final = []
for e in deduped_entries:
# Find the score for this entry
for s, orig in scored:
if orig is e:
final.append((s, e))
break
final.sort(key=lambda x: x[0], reverse=True)
# Apply max_results
final = final[:max_results]
# Apply token budget (always enforced, including first entry)
budget = max_tokens
result = []
for score, e in final:
content = e.get("_full", "")
tokens = len(content) // 4
if tokens > budget:
continue # skip oversized entries, try next
budget -= tokens
result.append((score, e))
return result
def format_results(results):
lines = []
for score, e in results:
lines.append(f"# relevance: {score:.0f} | {e.get('agent','')} | {e.get('platform','')} | {e.get('type','')}")
lines.append(e.get("_full", "").strip())
lines.append("---")
return "\n".join(lines)
def main():
parser = argparse.ArgumentParser(description="Smart fabric retrieval")
parser.add_argument("query", help="Search query or current task description")
parser.add_argument("--max-results", type=int, default=5, help="Maximum entries to return")
parser.add_argument("--max-tokens", type=int, default=2000, help="Token budget (chars/4)")
parser.add_argument("--agent", default=None, help="Boost entries from this agent")
parser.add_argument("--project", default=None, help="Boost entries from this project")
parser.add_argument("--fabric-dir", default=None, help="Override fabric directory")
args = parser.parse_args()
global FABRIC_DIR
if args.fabric_dir:
FABRIC_DIR = Path(args.fabric_dir)
results = retrieve(args.query, args.max_results, args.max_tokens, args.agent, args.project)
if not results:
print("no relevant entries found")
sys.exit(0)
print(format_results(results))
if __name__ == "__main__":
main()

776
icarus/hooks.py Normal file
View File

@ -0,0 +1,776 @@
"""Lifecycle hooks — memory capture, decision detection, creative tracking."""
import json
import logging
import os
import re
import urllib.request
import urllib.error
from datetime import datetime
from pathlib import Path
from . import state
# ── LLM extraction key ──
_OPENROUTER_KEY = (
os.environ.get("OPENROUTER_FULL_API_KEY", "")
or os.environ.get("OPENROUTER_DS_API_KEY", "")
or os.environ.get("OPENROUTER_API_KEY", "")
)
_EXTRACTION_MODEL = os.environ.get("ICARUS_EXTRACTION_MODEL", "deepseek/deepseek-v4-flash")
_EXTRACTION_MAX_TOKENS = int(os.environ.get("ICARUS_EXTRACTION_MAX_TOKENS", "1024"))
logger = logging.getLogger(__name__)
# ── Truncation limits (env-configurable) ──
_RESULT_MAX = int(os.environ.get("ICARUS_RESULT_MAX_CHARS", "500"))
_TASK_MAX = int(os.environ.get("ICARUS_TASK_MAX_CHARS", "300"))
# ── System injection detection ──
_SYSTEM_PREFIXES = (
"[IMPORTANT:",
"[SYSTEM:",
"You are running as a scheduled",
)
def _is_system_injection(text):
"""Return True if text starts with a known orchestrator/system preamble."""
stripped = text.strip()
return any(stripped.startswith(p) for p in _SYSTEM_PREFIXES)
# use shared regexes from state for decision/outcome/completion detection
# keep local regexes only for creative tracking (broader set)
_THEME_RE = re.compile(
r"(?i)\b(decided|resolved|completed|fixed|deployed|shipped|reviewed|approved|rejected|built|created)\b"
)
_EVAL_RE = re.compile(
r"(?i)\b(worked well|didn't work|failed|succeeded|learned|noticed|realized|discovered|finding|insight|improvement)\b"
)
_QUESTION_RE = re.compile(
r"(?i)\b(what if|wonder|curious about|want to try|experiment with|explore|investigate|test whether)\b"
)
_STOPWORDS = frozenset(
"this that with from have been were will about would could should their there "
"these them then when what which some other more also just like very into only "
"than over such make made most each does done being".split()
)
# ── Topic overlap tracking ──
_last_query_tokens: set = set()
# ── Per-session injection dedup (reset on session start) ──
_injected_fabric: set = set()
_injected_qdrant: set = set()
_injected_sessions: set = set()
def _tokenize(text):
words = set(re.findall(r"[a-z0-9]+", text.lower()))
return words - {"the", "a", "an", "is", "was", "are", "to", "of", "in", "for",
"on", "with", "it", "and", "or", "not", "i", "you", "can", "do",
"this", "that", "what", "how", "please", "help", "me", "my"}
def _extract_theme(text):
words = re.findall(r"\b[a-z]{4,}\b", text.lower())
filtered = [w for w in words[:30] if w not in _STOPWORDS][:3]
return " ".join(filtered) if filtered else ""
def _sanitize_learning(s: str) -> str:
"""Remove unpaired backticks that would produce orphaned markdown."""
if s.count('`') % 2 != 0:
s = s.replace('`', '')
if s.count('```') % 2 != 0:
s = s.replace('```', '')
return s.strip()
def _extract_sentence(text, pattern):
for s in re.split(r"[.!?\n]+", text):
s = s.strip()
if len(s) > 15 and pattern.search(s):
return s[:120]
return ""
# ── Hooks ────────────────────────────────────────────────
def on_session_start(session_id="", platform="", **kwargs):
"""Load context: SOUL + pending handoffs + recent entries + creative state."""
global _last_query_tokens
_last_query_tokens = set()
_injected_fabric.clear()
_injected_qdrant.clear()
_injected_sessions.clear()
state.session_id = session_id
state.exchanges = []
state._recall_log = []
creative = state.load_creative()
creative["cycle"] += 1
state.save_creative(creative)
parts = []
soul = state.load_soul()
if soul:
parts.append(soul.strip())
# pending work (handoff-aware)
open_tasks, reviews, open_tickets = state.read_pending()
if open_tasks:
parts.append(f"[fabric] {len(open_tasks)} item(s) assigned to you:")
for t in open_tasks[:5]:
src = t.get("agent", "?")
entry_id = t.get("id", "?")
etype = t.get("type", "task")
parts.append(f" - {src}: {t.get('summary', '?')} ({etype}, id {entry_id})")
parts.append(" If reviewing, set review_of. If revising, set revises. Otherwise just complete the work.")
if reviews:
parts.append(f"[fabric] {len(reviews)} review(s) of your work:")
for r in reviews[:5]:
reviewer = r.get("agent", "?")
entry_id = r.get("id", "?")
ref = r.get("review_of", "")
parts.append(f" - {reviewer}: {r.get('summary', '?')} (review id {entry_id}, of {ref})")
parts.append(" When you fix the issues, set revises to your original entry's agent:id.")
if open_tickets:
parts.append(f"[fabric] {len(open_tickets)} ticket(s) assigned to you:")
for t in open_tickets[:5]:
cid = t.get("customer_id", "?")
src = t.get("agent", "?")
entry_id = t.get("id", "?")
parts.append(f" - [{cid}] {t.get('summary', '?')} (from {src}, id {entry_id})")
parts.append(" Carry customer_id forward when you resolve these.")
# cross-agent feedback (non-pending items)
if not open_tasks and not reviews:
feedback = state.read_cross_agent(3)
if feedback:
parts.append("[fabric] from other agents:")
for f in feedback:
parts.append(f" {f}")
# recent entries
entries = state.read_recent(limit=5)
if entries:
parts.append("[fabric] recent activity:")
for e in entries:
ts = e["timestamp"][:16] if e["timestamp"] else "?"
parts.append(f" [{ts}] {e['agent']}: {e['summary']}")
# creative state
if creative["questions"]:
parts.append(f"[fabric] open questions: {'; '.join(creative['questions'][-3:])}")
if creative["learnings"]:
parts.append(f"[fabric] learnings: {'; '.join(creative['learnings'][-3:])}")
context = "\n".join(parts)
return {"context": context} if context else None
# ── Qdrant context injection ──────────────────────────────
_SOCIAL_CLOSERS = frozenset({
"ok", "obrigado", "valeu", "beleza", "blz", "tks", "thanks",
"👍", "👌", "", "feito", "certo", "confirmo", "entendido",
"certo", "isso", "sim", "não", "claro", "perfeito", "ótimo"
})
def _is_social_close(text):
"""Return True if message is a social closer that shouldn't trigger search."""
stripped = text.strip().lower()
if stripped in _SOCIAL_CLOSERS:
return True
# Very short ASCII-only without technical markers
if len(stripped) < 6 and stripped.isascii() and not any(
c in stripped for c in "://.@#$_?"
):
return True
return False
def _search_qdrant(query, top_k=2, threshold=0.72):
"""Search Qdrant knowledge_base via context_enhancer pipeline.
Returns list of result dicts with keys: id, score, title,
content_preview, source, tags.
Returns empty list on any failure (fail-open).
"""
try:
# context_enhancer looks for OPENROUTER_API_KEY (singular);
# inject our resolved key into environ for compatibility
if _OPENROUTER_KEY and not os.environ.get("OPENROUTER_API_KEY"):
os.environ["OPENROUTER_API_KEY"] = _OPENROUTER_KEY
from scripts.context_enhancer import (
embed_query, embed_query_sparse, search_with_fallback
)
dense = embed_query(query)
sparse = embed_query_sparse(query)
results, _level, _qdrant_ms, _fallback_ms = search_with_fallback(
dense_vector=dense,
sparse_vector=sparse,
query_text=query,
top_k=top_k,
score_threshold=threshold,
)
return results
except Exception:
return []
# ── Session history search (FTS5 over state.db) ──────────────
def _resolve_state_db():
"""Locate the Hermes session DB. Prefer state.HERMES_HOME, fall back to ~/.hermes."""
import sqlite3 # noqa: F401 (ensure available)
candidates = []
home = getattr(state, "HERMES_HOME", None)
if home:
candidates.append(Path(home) / "state.db")
candidates.append(Path.home() / ".hermes" / "state.db")
for c in candidates:
if c and c.exists():
return c
return None
def _search_sessions(query, current_session_id="", top_k=2):
"""FTS5 search over prior session messages in state.db.
Returns list of {session_id, title, when, snippet}, excluding the
current session. Fail-open: returns [] on any error.
"""
import sqlite3
db = _resolve_state_db()
if not db:
return []
# Build an FTS5 OR-query from meaningful tokens (avoids AND over-filtering)
toks = [t for t in _tokenize(query) if len(t) >= 4]
if not toks:
return []
fts_query = " OR ".join(toks[:8])
try:
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
cur = con.cursor()
# NOTE: snippet() cannot be combined with GROUP BY in the same SELECT
# (FTS5 raises "unable to use function snippet in the requested
# context"). Fetch top-ranked rows and dedup by session in Python.
rows = cur.execute(
"""
SELECT m.session_id AS session_id,
s.title AS title,
s.started_at AS started_at,
snippet(messages_fts, 0, '', '', '', 12) AS snip
FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
LEFT JOIN sessions s ON s.id = m.session_id
WHERE messages_fts MATCH ?
AND m.session_id != ?
AND m.role IN ('user','assistant')
ORDER BY rank
LIMIT 20
""",
(fts_query, current_session_id),
).fetchall()
con.close()
except Exception:
return []
out = []
seen = set()
for r in rows:
sid = r["session_id"]
if sid in seen:
continue
seen.add(sid)
# started_at is a Unix timestamp (float); format to a readable date.
when = ""
sa = r["started_at"]
if sa:
try:
when = datetime.fromtimestamp(float(sa)).strftime("%Y-%m-%d %H:%M")
except (ValueError, TypeError, OSError):
when = str(sa)[:16]
out.append({
"session_id": sid,
"title": r["title"],
"when": when,
"snippet": (r["snip"] or "").replace("\n", " "),
})
if len(out) >= top_k:
break
return out
# ── fact_store search (FTS5 over memory_store.db) ────────────
def _search_facts(query, top_k=3):
"""FTS5 search over durable facts in memory_store.db.
Returns list of fact content strings. Fail-open: returns [] on error.
"""
import sqlite3
db = Path.home() / ".hermes" / "memory_store.db"
home = getattr(state, "HERMES_HOME", None)
if home and (Path(home) / "memory_store.db").exists():
db = Path(home) / "memory_store.db"
if not db.exists():
return []
toks = [t for t in _tokenize(query) if len(t) >= 4]
if not toks:
return []
fts_query = " OR ".join(toks[:8])
try:
con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
con.row_factory = sqlite3.Row
cur = con.cursor()
rows = cur.execute(
"""
SELECT f.content AS content, f.trust_score AS trust
FROM facts_fts
JOIN facts f ON f.fact_id = facts_fts.rowid
WHERE facts_fts MATCH ?
ORDER BY rank
LIMIT ?
""",
(fts_query, top_k),
).fetchall()
con.close()
except Exception:
return []
return [r["content"][:200] for r in rows if r["content"]]
def pre_llm_call(session_id="", user_message="", is_first_turn=False, **kwargs):
"""Inject relevant memories when topic changes (fabric + Qdrant)."""
global _last_query_tokens
if not user_message:
return None
tokens = _tokenize(user_message)
if not tokens:
return None
# Overlap gate: only suppress on NEAR-LITERAL repetition of the previous
# turn (>0.85). The old 0.6 gate killed all injection in long single-topic
# sessions — exactly when accumulated context matters most. We now keep
# injecting and rely on per-source dedup (_injected_* sets) to avoid
# repeating identical results turn after turn.
if _last_query_tokens:
overlap = len(tokens & _last_query_tokens) / max(len(tokens), 1)
if overlap > 0.85:
return None
_last_query_tokens = tokens
is_social = _is_social_close(user_message)
agent = state.AGENT_NAME or "agent"
results = state.recall(user_message, max_results=5, agent=agent)
# log fabric recall for telemetry (even if empty)
if results:
state.log_recall(user_message, results, source="pre_llm_call")
# ── Qdrant search (independent of fabric) ──
# Threshold lowered 0.72 → 0.55: legitimate queries scored 0.57-0.63 and
# were silently filtered out by the old 0.72 gate.
qdrant_results = []
if not is_social:
qdrant_results = _search_qdrant(user_message, top_k=2, threshold=0.55)
# ── Session history (FTS5 over state.db) — the layer that holds
# "this was already built in a prior session". No automatic injection
# existed before; this is new. ──
session_results = []
if not is_social:
session_results = _search_sessions(user_message, session_id, top_k=2)
# ── fact_store probe (durable user/environment facts) — first turn only,
# to avoid per-turn cost. ──
fact_results = []
if is_first_turn and not is_social:
fact_results = _search_facts(user_message, top_k=3)
# ── Bail if nothing from any source ──
if not results and not qdrant_results and not session_results and not fact_results:
return None
parts = []
# Fabric context (dedup against previously injected entry ids)
if results:
lines = ["[fabric] relevant to your request:"]
emitted = 0
for e in results:
summary = e.get("summary") or e.get("_body", e.get("body", ""))[:80]
eid = str(e.get("id", "")) or summary[:60]
if eid in _injected_fabric:
continue
_injected_fabric.add(eid)
ts = str(e.get("timestamp", ""))[:16] or "?"
lines.append(f" [{ts}] {e.get('agent', '?')}: {summary}")
emitted += 1
if emitted:
parts.append("\n".join(lines))
# Qdrant context (dedup against previously injected point ids)
if qdrant_results:
lines = ["[qdrant] knowledge base:"]
emitted = 0
for r in qdrant_results:
rid = str(r.get("id", "")) or str(r.get("content_preview", ""))[:40]
if rid in _injected_qdrant:
continue
_injected_qdrant.add(rid)
source = r.get("source", "?")
title = r.get("title", "")
score = r.get("score", 0)
label = f"{source}"
if title:
label = f"{source}: {title[:60]}"
content = r.get("content_preview", "")[:600]
lines.append(f" ### {label} (score: {score:.2f})\n {content}")
emitted += 1
if emitted:
parts.append("\n".join(lines))
# Session history context (dedup against previously injected session ids)
if session_results:
lines = ["[sessions] prior conversations on this topic:"]
emitted = 0
for s in session_results:
sid = s.get("session_id", "")
if sid in _injected_sessions:
continue
_injected_sessions.add(sid)
title = s.get("title") or "(untitled)"
snippet = s.get("snippet", "")[:200]
when = s.get("when", "")
lines.append(f" [{when}] {title}: {snippet}")
emitted += 1
if emitted:
parts.append("\n".join(lines))
# fact_store context (first turn only)
if fact_results:
lines = ["[facts] durable facts about the user/environment:"]
for f in fact_results:
lines.append(f" - {f}")
parts.append("\n".join(lines))
if not parts:
return None
return {"context": "\n\n".join(parts)}
def post_llm_call(session_id="", user_message="", assistant_response="", platform="", **kwargs):
"""Capture high-value decisions + creative tracking."""
if not assistant_response:
return
state.exchanges.append({
"user": (user_message or "")[:200],
"assistant": assistant_response[:500],
})
agent = state.AGENT_NAME or "agent"
plat = platform or "cli"
# capture decisions: requires decision + outcome in response, AND a substantial
# user request (>50 chars) to ground the claim
user_text = (user_message or "").strip()
if (state.DECISION_RE.search(assistant_response)
and state.OUTCOME_RE.search(assistant_response)
and len(assistant_response) > 200
and len(user_text) > 50):
body = f"Task: {user_text[:_TASK_MAX]}\n\nResult: {assistant_response[:_RESULT_MAX]}"
summary = assistant_response[:80].replace("\n", " ")
entry_status = "completed" if state.COMPLETION_RE.search(assistant_response) else ""
state.write_entry("decision", body, summary,
platform=plat, status=entry_status, training_value="high")
# creative tracking (uses broader _THEME_RE, doesn't write entries)
creative = state.load_creative()
changed = False
if _THEME_RE.search(assistant_response):
theme = _extract_theme(assistant_response)
if theme and theme not in creative["themes"]:
creative["themes"].append(theme)
creative["themes"] = creative["themes"][-20:]
changed = True
if _EVAL_RE.search(assistant_response):
learning = _extract_sentence(assistant_response, _EVAL_RE)
if learning:
learning = _sanitize_learning(learning)
if learning and learning not in creative["learnings"]:
creative["learnings"].append(learning)
creative["learnings"] = creative["learnings"][-15:]
changed = True
if _QUESTION_RE.search(assistant_response):
question = _extract_sentence(assistant_response, _QUESTION_RE)
if question and question not in creative["questions"]:
creative["questions"].append(question)
creative["questions"] = creative["questions"][-15:]
changed = True
if changed:
state.save_creative(creative)
# ── LLM-powered session extraction ────────────────────────
def _parse_json_robust(raw):
"""Extract JSON array/object from LLM output with markdown tolerances.
Handles: ```json fences, leading text, trailing commas, whitespace.
Returns parsed value on success, None on failure.
"""
if not raw or not raw.strip():
return None
text = raw.strip()
# Strip markdown code fences
for fence in ("```json", "```"):
if text.startswith(fence):
text = text[len(fence):].lstrip()
if text.endswith("```"):
text = text[:-3].rstrip()
# Find first JSON structure character
for start_char in ("[", "{"):
idx = text.find(start_char)
if idx != -1:
text = text[idx:]
break
# Attempt parse; progressively strip trailing characters on failure
attempts = 0
while attempts < 20:
try:
return json.loads(text)
except json.JSONDecodeError:
# Strip last char and try again (handles trailing commas, extra })
if text:
text = text[:-1]
attempts += 1
continue
return None
def _build_transcript(exchanges):
"""Build a compact transcript from session exchanges for LLM analysis."""
lines = []
for i, ex in enumerate(exchanges):
user = (ex.get("user") or "").strip()
assistant = (ex.get("assistant") or "").strip()
if user:
lines.append(f"[Turn {i+1} — User]\n{user[:500]}")
if assistant:
lines.append(f"[Turn {i+1} — Agent]\n{assistant[:800]}")
return "\n\n".join(lines)
def _llm_extract_entries(transcript):
"""Use LLM to extract significant entries from session transcript.
Returns list of dicts: {type, summary, content, training_value}
Returns empty list on failure or if nothing worth preserving.
"""
if not _OPENROUTER_KEY:
logger.warning("icarus: no OpenRouter key — skipping LLM extraction")
return []
prompt = (
"You are a session archivist for an AI agent. Analyze this agent session "
"transcript and extract ONLY significant entries worth preserving in a "
"cross-agent knowledge base. Skip trivial sessions, greetings, and routine chatter.\n\n"
"For each significant entry, provide:\n"
"- type: \"decision\" (technical decision with rationale), "
"\"resolution\" (bug fix or problem solved), "
"or \"note\" (discovery or learning)\n"
"- summary: one line, max 80 chars, in the original language of the session\n"
"- content: structured markdown with ## Context, ## Action/Decision, and ## Outcome. "
"Include concrete details: commands, paths, error messages, decisions made.\n"
"- training_value: \"high\" (outcome verified, artifact produced, decision with evidence), "
"\"normal\" (useful context or progress), "
"or \"low\" (marginal, but not zero)\n\n"
"If the session contains NOTHING worth preserving across sessions, "
"return an empty array: []\n\n"
"Return ONLY valid JSON array, no other text:\n"
'[{"type": "decision", "summary": "...", "content": "...", "training_value": "high"}, ...]'
)
payload = json.dumps({
"model": _EXTRACTION_MODEL,
"messages": [
{"role": "system", "content": prompt},
{"role": "user", "content": transcript[:8000]}
],
"max_tokens": _EXTRACTION_MAX_TOKENS,
"temperature": 0.2
}).encode("utf-8")
try:
req = urllib.request.Request(
"https://openrouter.ai/api/v1/chat/completions",
data=payload,
headers={
"Authorization": f"Bearer {_OPENROUTER_KEY}",
"Content-Type": "application/json",
"HTTP-Referer": "https://hermes-agent.local",
"X-Title": "Icarus Session Extraction"
}
)
resp = urllib.request.urlopen(req, timeout=45)
body = json.loads(resp.read().decode("utf-8"))
raw = body["choices"][0]["message"]["content"]
# Parse JSON from response (robust — handles markdown fences, null)
if raw is None:
raise ValueError("DeepSeek returned content:null (response_format bug)")
extracted = _parse_json_robust(raw)
if isinstance(extracted, dict):
# Some models return {entries: [...]} — unwrap
for key in ("entries", "results", "items"):
if key in extracted and isinstance(extracted[key], list):
extracted = extracted[key]
break
else:
# Single entry wrapped in dict
if "type" in extracted:
extracted = [extracted]
else:
extracted = []
if not isinstance(extracted, list):
logger.warning("icarus: LLM extraction returned non-list: %s", type(extracted))
return []
# Validate and filter
valid = []
allowed_types = {"decision", "resolution", "note"}
for entry in extracted:
if not isinstance(entry, dict):
continue
etype = entry.get("type", "")
summary = entry.get("summary", "")
content = entry.get("content", "")
if etype not in allowed_types:
continue
if len(summary) < 10 or len(content) < 60:
continue
valid.append({
"type": etype,
"summary": summary[:80],
"content": content[:2000],
"training_value": entry.get("training_value", "normal")
})
return valid
except (urllib.error.URLError, json.JSONDecodeError, KeyError, IndexError, ValueError,
ConnectionError, TimeoutError, OSError) as e:
logger.warning("icarus: LLM extraction failed (%s) — falling back to legacy", type(e).__name__)
return []
def _legacy_session_write(platform, scores):
"""Fallback: original truncated session write (pre-LLM behavior)."""
plat = platform or "cli"
parts = []
first_user = next(
(
ex["user"] for ex in state.exchanges
if len(ex.get("user", "").strip()) > 50
and not _is_system_injection(ex.get("user", ""))
),
None
)
if first_user:
parts.append(f"## Task\n{first_user[:_TASK_MAX]}")
for ex in state.exchanges:
resp = ex.get("assistant", "")
if state.DECISION_RE.search(resp) and len(resp) > 100:
parts.append(f"## Decision\n{resp[:500]}")
break
substantive = [ex for ex in state.exchanges if len(ex.get("assistant", "").strip()) > 100]
if substantive:
parts.append(f"## Result\n{substantive[-1]['assistant'][:_RESULT_MAX]}")
content = "\n\n".join(parts) if parts else state.exchanges[-1].get("assistant", "")[:500]
if substantive:
result_text = substantive[-1]['assistant']
else:
result_text = content
summary = re.sub(r"\s+", " ", result_text.replace("\n", " ")).strip()[:80]
summary = re.sub(r"-{2,}", "", summary) # sanitize: prevent YAML frontmatter breakage
if scores["total"] >= 0.6:
tv = "high"
elif scores["total"] >= 0.3:
tv = "normal"
else:
tv = "low"
state.write_entry("session", content, summary, platform=plat,
training_value=tv, status="completed")
def on_session_end(session_id="", platform="", completed=False, **kwargs):
"""Score session, extract entries via LLM, fall back to legacy truncation."""
creative = state.load_creative()
state.write_memory_file(creative)
if not state.exchanges:
return
scores = state.score_session()
if scores["total"] < 0.2:
return
plat = platform or "cli"
# ── LLM extraction (primary) ──
transcript = _build_transcript(state.exchanges)
entries = _llm_extract_entries(transcript)
if entries:
for entry in entries:
state.write_entry(
entry["type"],
entry["content"],
entry["summary"],
platform=plat,
training_value=entry.get("training_value", "normal"),
status="completed"
)
logger.info("icarus: LLM extracted %d entries from session", len(entries))
else:
# ── Legacy fallback ──
logger.info("icarus: LLM extraction produced nothing — using legacy truncation")
_legacy_session_write(platform, scores)

146
icarus/obsidian.py Normal file
View File

@ -0,0 +1,146 @@
"""Obsidian integration for Icarus fabric entries.
Opt-in via ICARUS_OBSIDIAN=1 environment variable.
- format_entry: appends wikilinks for review_of/revises refs
- ensure_daily_note: links new entries in a daily note
- init_obsidian: one-time vault setup
"""
from __future__ import annotations
import json
import logging
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from .state import _parse_head
logger = logging.getLogger(__name__)
LINKS_START = "<!-- ICARUS_OBSIDIAN_LINKS_START -->"
LINKS_END = "<!-- ICARUS_OBSIDIAN_LINKS_END -->"
def _find_entry_file(ref: str, fabric_dir: Path) -> Optional[str]:
"""Resolve agent:id ref to a filename (without .md extension)."""
if ":" not in ref:
return None
agent, entry_id = ref.split(":", 1)
if not agent or not entry_id:
return None
for d in (fabric_dir, fabric_dir / "cold"):
if not d.exists():
continue
for f in d.glob("*.md"):
h = _parse_head(f)
if h.get("agent") == agent and h.get("id") == entry_id:
return f.stem # filename without .md
return None
def format_entry(filepath: Path, fabric_dir: Path, review_of: str = "", revises: str = ""):
"""Append wikilinks section to a fabric entry for Obsidian navigation."""
if not review_of and not revises:
return
text = filepath.read_text("utf-8")
links = []
if review_of:
target = _find_entry_file(review_of, fabric_dir)
if target:
links.append(f"- Reviews: [[{target}]]")
else:
links.append(f"- Reviews: {review_of}")
if revises:
target = _find_entry_file(revises, fabric_dir)
if target:
links.append(f"- Revises: [[{target}]]")
else:
links.append(f"- Revises: {revises}")
if not links:
return
generated = (
f"\n\n{LINKS_START}\n"
"## Links\n"
+ "\n".join(links)
+ f"\n{LINKS_END}\n"
)
existing = re.compile(
rf"\n*{re.escape(LINKS_START)}.*?{re.escape(LINKS_END)}\n*",
re.DOTALL,
)
text = existing.sub("\n", text).rstrip()
filepath.write_text(text + generated, "utf-8")
def ensure_daily_note(fabric_dir: Path, entry_filename: str, summary: str):
"""Add a link to the new entry in today's daily note."""
daily_dir = fabric_dir / "daily"
daily_dir.mkdir(parents=True, exist_ok=True)
today = datetime.now().strftime("%Y-%m-%d")
daily_path = daily_dir / f"{today}.md"
stem = entry_filename.replace(".md", "")
link_line = f"- [[{stem}]] {summary}\n"
if daily_path.exists():
content = daily_path.read_text("utf-8")
if f"[[{stem}]]" in content:
return # already linked
daily_path.write_text(content.rstrip() + "\n" + link_line, "utf-8")
else:
daily_path.write_text(f"# {today}\n\n{link_line}", "utf-8")
def _vault_dir_for(fabric_dir: Path) -> Path:
"""Resolve the Obsidian vault root for config files."""
configured = os.environ.get("OBSIDIAN_VAULT_PATH", "").strip()
if configured:
return Path(configured).expanduser()
return fabric_dir
def init_obsidian(fabric_dir: Path) -> dict:
"""One-time Obsidian vault setup for a fabric directory."""
created = []
daily_dir = fabric_dir / "daily"
if not daily_dir.exists():
daily_dir.mkdir(parents=True, exist_ok=True)
created.append(str(daily_dir))
vault_dir = _vault_dir_for(fabric_dir)
vault_dir.mkdir(parents=True, exist_ok=True)
obsidian_dir = vault_dir / ".obsidian"
if not obsidian_dir.exists():
obsidian_dir.mkdir(parents=True, exist_ok=True)
created.append(str(obsidian_dir))
app_json = obsidian_dir / "app.json"
if not app_json.exists():
app_json.write_text(json.dumps({
"showFrontmatter": True,
"readableLineLength": True,
}, indent=2), "utf-8")
created.append(str(app_json))
if created:
logger.info("icarus: obsidian init created %s", created)
return {
"status": "initialized" if created else "already_initialized",
"fabric_dir": str(fabric_dir),
"vault_dir": str(vault_dir),
"created": created,
}

26
icarus/plugin.yaml Normal file
View File

@ -0,0 +1,26 @@
name: icarus
version: 0.3.0
description: Self-memory and replacement models for Hermes agents. Remember your work. Train your replacement.
author: esaradev
provides_tools:
- fabric_recall
- fabric_write
- fabric_search
- fabric_pending
- fabric_curate
- fabric_export
- fabric_train
- fabric_train_status
- fabric_models
- fabric_eval
- fabric_switch_model
- fabric_rollback_model
- fabric_brief
- fabric_telemetry
- fabric_init_obsidian
- fabric_report
provides_hooks:
- on_session_start
- pre_llm_call
- post_llm_call
- on_session_end

420
icarus/schemas.py Normal file
View File

@ -0,0 +1,420 @@
"""Tool schemas — what the LLM sees."""
FABRIC_RECALL = {
"name": "fabric_recall",
"description": (
"Retrieve relevant memories from the shared fabric. Uses ranked scoring "
"across keyword match, project/agent affinity, recency, and tier. "
"Use this when you need context from past sessions, other agents' work, "
"or cross-platform history. Returns the top matching entries with scores."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "What to search for — a topic, question, or keyword phrase",
},
"max_results": {
"type": "integer",
"description": "Maximum entries to return (default: 5)",
},
"agent": {
"type": "string",
"description": "Boost entries from this agent (optional)",
},
"project": {
"type": "string",
"description": "Boost entries from this project (optional)",
},
},
"required": ["query"],
},
}
FABRIC_WRITE = {
"name": "fabric_write",
"description": (
"Write a new entry to shared fabric memory. All agents on all platforms "
"can read it. Linking guidelines:\n"
"- type='review' + review_of: when you evaluate another agent's work, "
"link back to the original entry so the chain is traceable.\n"
"- revises: when you fix or improve an entry after receiving feedback, "
"link to your original entry so before/after are connected.\n"
"- status='open' + assigned_to: when handing work to a specific agent.\n"
"Links improve retrieval, training data quality, and cross-agent awareness. "
"If you have the source entry ID (from session context or fabric_pending), use it."
),
"parameters": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "Entry type: task, decision, review, resolution, research, code-session, session, note",
},
"content": {
"type": "string",
"description": "The full content/body of the entry",
},
"summary": {
"type": "string",
"description": "One-line summary (shown in listings and search results)",
},
"tags": {
"type": "string",
"description": "Comma-separated tags (optional)",
},
"status": {
"type": "string",
"description": "open (requires assigned_to), completed, blocked, or superseded",
},
"outcome": {
"type": "string",
"description": "Result or conclusion. Most valuable field for training.",
},
"review_of": {
"type": "string",
"description": "When type='review': the entry you are evaluating, as agent:id (e.g. icarus:a3f29b01). Get the id from session context or fabric_pending.",
},
"revises": {
"type": "string",
"description": "When resubmitting fixed work: the original entry you are revising, as agent:id. Connects the before/after for training.",
},
"customer_id": {
"type": "string",
"description": "Customer/account scope. Carry forward from the original entry when resolving a ticket.",
},
"assigned_to": {
"type": "string",
"description": "When status='open': the agent who should pick this up. Required for the entry to appear in their fabric_pending.",
},
"training_value": {
"type": "string",
"enum": ["high", "normal", "low"],
"description": "Training quality signal. high = decisions with outcomes, completed reviews, successful fixes. low = generic chatter. Affects export filtering.",
},
"verified": {
"type": "string",
"enum": ["true", "false"],
"description": "Whether this outcome was verified (tests passed, deployment succeeded, customer confirmed). Verified entries are preferred in high-precision export.",
},
"evidence": {
"type": "string",
"description": "How the outcome was verified. E.g. 'tests pass', 'deployed to prod', 'customer confirmed fix'. Grounds the entry for training.",
},
"source_tool": {
"type": "string",
"description": "The tool that produced this result (e.g. 'bash', 'code_editor', 'web_search'). Helps training data reflect real tool use.",
},
"artifact_paths": {
"type": "string",
"description": "Comma-separated file paths of artifacts produced (e.g. 'src/limiter.ts, tests/limiter.test.ts').",
},
},
"required": ["type", "content", "summary"],
},
}
FABRIC_PENDING = {
"name": "fabric_pending",
"description": (
"Show work assigned to you. Returns entry metadata including IDs for linking.\n"
"- open_tasks: work from other agents you need to act on. Could be code to "
"review, research to implement, a ticket to resolve, or a task to complete. "
"Check the entry type to decide your response.\n"
"- reviews_of_my_work: feedback from other agents on your entries. "
"Use revises to link your fix back to the original.\n"
"- open_tickets: customer-scoped entries. Carry customer_id forward when resolving.\n"
"Call at session start to see what needs attention."
),
"parameters": {
"type": "object",
"properties": {
"customer_id": {
"type": "string",
"description": "Filter to a specific customer (optional)",
},
},
"required": [],
},
}
FABRIC_SEARCH = {
"name": "fabric_search",
"description": (
"Keyword search across all fabric entries. Simpler than fabric_recall — "
"just grep. Use when you know the exact term you're looking for "
"(a function name, error message, specific ID). Returns matching filenames "
"and the lines that matched."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Exact keyword or phrase to search for",
},
},
"required": ["query"],
},
}
FABRIC_CURATE = {
"name": "fabric_curate",
"description": (
"Set the training value of a fabric entry. Affects which entries are "
"included when exporting training data. Use 'high' for decisions with "
"outcomes, completed reviews, and successful fixes. Use 'normal' for "
"standard work. Use 'low' for generic session summaries and chatter. "
"high-precision export mode only includes high-value entries."
),
"parameters": {
"type": "object",
"properties": {
"entry_id": {
"type": "string",
"description": "The entry ID (8 hex chars) to update",
},
"training_value": {
"type": "string",
"enum": ["high", "normal", "low"],
"description": "Training value: high, normal, or low",
},
},
"required": ["entry_id", "training_value"],
},
}
FABRIC_EXPORT = {
"name": "fabric_export",
"description": (
"Export fabric entries as fine-tuning training pairs. Generates "
"OpenAI, Together AI, and HuggingFace format JSONL files. "
"Use mode to control quality vs volume tradeoff."
),
"parameters": {
"type": "object",
"properties": {
"mode": {
"type": "string",
"enum": ["high-precision", "normal", "high-volume"],
"description": "high-precision: only high-value + completed + linked reviews. normal: excludes low-value (default). high-volume: everything.",
},
},
"required": [],
},
}
FABRIC_TRAIN = {
"name": "fabric_train",
"description": (
"Start a fine-tuning job on Together AI using your fabric entries as "
"training data. Exports, uploads, and kicks off training. Returns "
"immediately with a job ID. Use fabric_train_status to check progress, "
"fabric_eval to test the result, fabric_switch_model to activate it."
),
"parameters": {
"type": "object",
"properties": {
"model": {
"type": "string",
"description": "Base model (default: Qwen/Qwen2-7B-Instruct)",
},
"suffix": {
"type": "string",
"description": "Model name suffix (default: agent name)",
},
"epochs": {
"type": "integer",
"description": "Training epochs (default: 3)",
},
"mode": {
"type": "string",
"enum": ["high-precision", "normal", "high-volume"],
"description": "Optional export mode. Omit to auto-select the highest-quality mode with enough pairs.",
},
"min_pairs": {
"type": "integer",
"description": "Minimum pair count required before starting training (default: 10).",
},
"batch_size": {
"type": "integer",
"description": "Together batch size, must be >= 8 (default: 8)",
},
"learning_rate": {
"type": "number",
"description": "Together learning rate, must be > 0 (default: 1e-5)",
},
"n_checkpoints": {
"type": "integer",
"description": "Together checkpoint count, must be >= 1 (default: 1)",
},
},
"required": [],
},
}
FABRIC_TRAIN_STATUS = {
"name": "fabric_train_status",
"description": (
"Check the status of a Together AI fine-tuning job. If completed, returns "
"the output model ID. Pass a job ID or omit to check the most recent job."
),
"parameters": {
"type": "object",
"properties": {
"job_id": {
"type": "string",
"description": "Fine-tune job ID (omit to check last job)",
},
},
"required": [],
},
}
FABRIC_MODELS = {
"name": "fabric_models",
"description": (
"List all fine-tuned models trained from your fabric data. Shows job ID, "
"base model, output model, pair count, eval scores, and whether the model "
"is currently active. Use this to see your training history and decide "
"which model to evaluate or activate."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
}
FABRIC_EVAL = {
"name": "fabric_eval",
"description": (
"Compare a candidate replacement model against the current model. "
"Runs both on eval prompts extracted from your high-value fabric entries. "
"Scores task completion, format compliance, and style match. "
"Results are saved to the model registry. Requires TOGETHER_API_KEY."
),
"parameters": {
"type": "object",
"properties": {
"candidate_model": {
"type": "string",
"description": "The fine-tuned model ID to evaluate",
},
"base_model": {
"type": "string",
"description": "Model to compare against (default: current LLM_MODEL)",
},
"sample_count": {
"type": "integer",
"description": "Number of eval prompts to run (default: 10)",
},
},
"required": ["candidate_model"],
},
}
FABRIC_SWITCH_MODEL = {
"name": "fabric_switch_model",
"description": (
"Switch this agent to use a replacement model. Only switches if the "
"model has eval scores above the threshold. Updates .env with the new "
"model config and creates a backup of the current .env."
),
"parameters": {
"type": "object",
"properties": {
"model_id": {
"type": "string",
"description": "The fine-tuned model ID to switch to (from fabric_models)",
},
"min_eval_score": {
"type": "number",
"description": "Minimum average eval score required to switch (default: 0.7)",
},
},
"required": ["model_id"],
},
}
FABRIC_ROLLBACK_MODEL = {
"name": "fabric_rollback_model",
"description": (
"Roll back to the previous model by restoring .env from backup. "
"Use when a replacement model is underperforming in production. "
"No eval gate needed -- this is an emergency escape hatch."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
}
FABRIC_BRIEF = {
"name": "fabric_brief",
"description": (
"Get your daily operational brief. Returns: what's pending for you "
"(open tasks, reviews, tickets), your recent work, what other agents "
"have done, and a suggested next action. Use this at the start of "
"every session to decide what to work on. One call replaces checking "
"fabric_pending + fabric_recall + fabric_models separately."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
}
FABRIC_TELEMETRY = {
"name": "fabric_telemetry",
"description": (
"Show retrieval and usage telemetry. Reports: how many times memory "
"was recalled, how many recalled entries were actually used (referenced "
"via review_of or revises), and the usage rate. Use this to understand "
"whether recalled memories are useful or just noise."
),
"parameters": {
"type": "object",
"properties": {
"last_n": {
"type": "integer",
"description": "Number of recent telemetry events to return (default: 50)",
},
},
"required": [],
},
}
FABRIC_INIT_OBSIDIAN = {
"name": "fabric_init_obsidian",
"description": (
"Initialize the fabric directory as an Obsidian vault. Creates "
"daily/ directory for daily notes and .obsidian/ with minimal config. "
"Safe to call multiple times. After this, open ~/fabric/ in Obsidian "
"to browse entries with wikilinks and daily notes. "
"Set ICARUS_OBSIDIAN=1 in .env to enable ongoing Obsidian formatting."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
}
FABRIC_REPORT = {
"name": "fabric_report",
"description": (
"Corpus health report. Shows: entry counts by type and training value, "
"verified entry count, recall usage rates by entry type, and estimated "
"trainable corpus size. Use periodically to understand whether your "
"memory is producing good training data."
),
"parameters": {
"type": "object",
"properties": {},
"required": [],
},
}

View File

@ -0,0 +1,215 @@
#!/usr/bin/env python3
"""eval-replacement.py -- Compare a candidate model against a base model.
Extracts eval prompts from high-value fabric entries, runs both models,
scores task completion, format compliance, and style match.
Usage:
TOGETHER_API_KEY=tok_... python3 scripts/eval-replacement.py \\
--candidate-model user/icarus-v1 \\
--base-model Qwen/Qwen2-7B-Instruct \\
--sample-count 10
"""
import argparse
import json
import math
import os
import re
import sys
import urllib.request
from pathlib import Path
FABRIC_DIR = Path(os.environ.get("FABRIC_DIR", Path.home() / "fabric"))
STOP_WORDS = {"the", "a", "an", "is", "was", "are", "to", "of", "in", "for",
"on", "with", "it", "and", "or", "not", "i", "you", "this", "that"}
# type-specific format patterns
FORMAT_PATTERNS = {
"review": re.compile(r"(?i)(MUST FIX|SHOULD FIX|approved|rejected|feedback|issue)"),
"decision": re.compile(r"(?i)(because|result|outcome|conclusion|chose|decided)"),
"code-session": re.compile(r"(?i)(function|class|def |import |return |const |let |var )"),
"resolution": re.compile(r"(?i)(resolved|fixed|root cause|refund|ticket)"),
"research": re.compile(r"(?i)(found|compared|analysis|benchmark|option)"),
}
def parse_entry(filepath):
text = filepath.read_text("utf-8")
if not text.startswith("---"):
return None
parts = text.split("---", 2)
if len(parts) < 3:
return None
meta = {}
for line in parts[1].strip().split("\n"):
if ": " in line and not line.strip().startswith("-"):
k, v = line.strip().split(": ", 1)
meta[k.strip()] = v.strip()
meta["body"] = parts[2].strip()
return meta
def get_eval_entries(sample_count):
"""Get high-value entries for eval prompts."""
entries = []
for d in [FABRIC_DIR, FABRIC_DIR / "cold"]:
if not d.exists():
continue
for f in sorted(d.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True):
e = parse_entry(f)
if not e or not e.get("body") or len(e["body"]) < 50:
continue
if e.get("training_value") == "high" or e.get("status") == "completed":
entries.append(e)
if len(entries) >= sample_count * 2:
break
return entries[:sample_count]
def call_model(model, prompt, api_key):
"""Call a model via Together's OpenAI-compatible API."""
data = json.dumps({
"model": model,
"messages": [
{"role": "system", "content": "You are a helpful AI agent."},
{"role": "user", "content": prompt},
],
"max_tokens": 512,
"temperature": 0.3,
}).encode()
req = urllib.request.Request(
"https://api.together.xyz/v1/chat/completions",
data=data,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
resp = urllib.request.urlopen(req, timeout=30)
result = json.loads(resp.read())
return result["choices"][0]["message"]["content"]
except Exception as e:
return f"ERROR: {e}"
def tokenize(text):
words = re.findall(r"[a-z0-9]+", text.lower())
return [w for w in words if w not in STOP_WORDS]
def score_task_completion(response, expected):
"""Does the response have enough substance? 0-1."""
if not expected:
return 1.0 if len(response) > 50 else 0.0
return min(1.0, len(response) / max(len(expected) * 0.5, 1))
def score_format_compliance(response, entry_type):
"""Does the response match type-specific format patterns? 0 or 1."""
pattern = FORMAT_PATTERNS.get(entry_type)
if not pattern:
return 1.0
return 1.0 if pattern.search(response) else 0.0
def score_style_match(response, expected):
"""Cosine similarity of word frequency distributions. 0-1."""
if not expected or not response:
return 0.0
resp_tokens = tokenize(response)
exp_tokens = tokenize(expected)
if not resp_tokens or not exp_tokens:
return 0.0
all_words = set(resp_tokens) | set(exp_tokens)
resp_freq = {w: resp_tokens.count(w) for w in all_words}
exp_freq = {w: exp_tokens.count(w) for w in all_words}
dot = sum(resp_freq.get(w, 0) * exp_freq.get(w, 0) for w in all_words)
mag_r = math.sqrt(sum(v ** 2 for v in resp_freq.values()))
mag_e = math.sqrt(sum(v ** 2 for v in exp_freq.values()))
if mag_r == 0 or mag_e == 0:
return 0.0
return dot / (mag_r * mag_e)
def main():
parser = argparse.ArgumentParser(description="Compare candidate vs base model")
parser.add_argument("--candidate-model", required=True)
parser.add_argument("--base-model", required=True)
# Read from environment instead of CLI to avoid leaking via argv
TOGETHER_API_KEY = os.environ.get("TOGETHER_API_KEY", "")
if not TOGETHER_API_KEY:
parser.error("TOGETHER_API_KEY environment variable not set")
parser.add_argument("--fabric-dir", default=None)
parser.add_argument("--sample-count", type=int, default=10)
args = parser.parse_args()
global FABRIC_DIR
if args.fabric_dir:
FABRIC_DIR = Path(args.fabric_dir)
entries = get_eval_entries(args.sample_count)
if not entries:
json.dump({"error": "no eval entries found"}, sys.stdout)
sys.exit(1)
results = []
for e in entries:
entry_type = e.get("type", "task")
summary = e.get("summary", "")
body = e.get("body", "")
prompt = f"[{entry_type}] {summary}" if summary else f"Complete this {entry_type}"
base_resp = call_model(args.base_model, prompt, TOGETHER_API_KEY)
cand_resp = call_model(args.candidate_model, prompt, TOGETHER_API_KEY)
base_scores = {
"task_completion": score_task_completion(base_resp, body),
"format_compliance": score_format_compliance(base_resp, entry_type),
"style_match": score_style_match(base_resp, body),
}
cand_scores = {
"task_completion": score_task_completion(cand_resp, body),
"format_compliance": score_format_compliance(cand_resp, entry_type),
"style_match": score_style_match(cand_resp, body),
}
results.append({
"prompt": prompt[:80],
"type": entry_type,
"base_scores": base_scores,
"candidate_scores": cand_scores,
})
# aggregate
def avg_scores(key):
vals = {}
for r in results:
for metric, score in r[key].items():
vals.setdefault(metric, []).append(score)
return {m: round(sum(s) / len(s), 3) for m, s in vals.items()}
base_avg = avg_scores("base_scores")
cand_avg = avg_scores("candidate_scores")
output = {
"sample_count": len(results),
"base_model": args.base_model,
"candidate_model": args.candidate_model,
"base_scores": base_avg,
"candidate_scores": cand_avg,
"per_prompt": results,
}
json.dump(output, sys.stdout, indent=2)
if __name__ == "__main__":
main()

165
icarus/scripts/smoke-handoff.sh Executable file
View File

@ -0,0 +1,165 @@
#!/usr/bin/env bash
# smoke-handoff.sh -- prove the Icarus plugin handoff chain works end-to-end
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/icarus-smoke-XXXXXX")"
trap 'rm -rf "$TMP_ROOT"' EXIT
FABRIC_DIR="$TMP_ROOT/fabric"
ICARUS_HOME="$TMP_ROOT/.hermes-icarus"
DAEDALUS_HOME="$TMP_ROOT/.hermes-daedalus"
pass() { printf ' pass: %s\n' "$1"; }
fail() { printf ' FAIL: %s\n' "$1" >&2; exit 1; }
mkdir -p "$FABRIC_DIR" "$FABRIC_DIR/cold"
mkdir -p "$ICARUS_HOME/plugins/icarus" "$DAEDALUS_HOME/plugins/icarus"
# repo root IS the plugin — copy all plugin files + support scripts
for home in "$ICARUS_HOME" "$DAEDALUS_HOME"; do
cp "$REPO_DIR"/*.py "$home/plugins/icarus/"
cp "$REPO_DIR"/plugin.yaml "$home/plugins/icarus/"
[ -d "$REPO_DIR/scripts" ] && cp -R "$REPO_DIR/scripts" "$home/plugins/icarus/"
done
export FABRIC_DIR
export ICARUS_HOME
export DAEDALUS_HOME
export REPO_DIR
python3 - <<'PY'
import json
import os
import sys
from pathlib import Path
# load the plugin as a package (files use relative imports)
import importlib.util
import types
repo_dir = Path(os.environ["REPO_DIR"])
ns = types.ModuleType("hermes_plugins")
ns.__path__ = []
ns.__package__ = "hermes_plugins"
sys.modules["hermes_plugins"] = ns
spec = importlib.util.spec_from_file_location(
"hermes_plugins.icarus", str(repo_dir / "__init__.py"),
submodule_search_locations=[str(repo_dir)])
mod = importlib.util.module_from_spec(spec)
mod.__package__ = "hermes_plugins.icarus"
mod.__path__ = [str(repo_dir)]
sys.modules["hermes_plugins.icarus"] = mod
spec.loader.exec_module(mod)
hooks = mod.hooks
state = mod.state
tools = mod.tools
def parse_id(path: str) -> str:
for line in Path(path).read_text("utf-8").splitlines():
if line.startswith("id: "):
return line.split(": ", 1)[1].strip()
raise RuntimeError(f"missing id in {path}")
def run_as(agent_name: str, home: str):
os.environ["HERMES_AGENT_NAME"] = agent_name
os.environ["HERMES_HOME"] = home
state.AGENT_NAME = agent_name
state.HERMES_HOME = Path(home)
state.FABRIC_DIR = Path(os.environ["FABRIC_DIR"])
state._JOB_FILE = state.HERMES_HOME / ".icarus-training-job.txt"
state._STATE_FILE = state.HERMES_HOME / ".icarus-state.json"
state._REGISTRY_FILE = state.HERMES_HOME / ".icarus-models.json"
state.session_id = ""
state.exchanges = []
def expect(cond: bool, msg: str):
if not cond:
raise AssertionError(msg)
print(f" pass: {msg}")
icarus_home = os.environ["ICARUS_HOME"]
daedalus_home = os.environ["DAEDALUS_HOME"]
# 1. Builder writes an assigned handoff.
run_as("icarus", icarus_home)
raw = tools.fabric_write({
"type": "code-session",
"summary": "smoke handoff for daedalus",
"content": "Built the relay patch. Needs review. Token amber relay.",
"status": "open",
"assigned_to": "daedalus",
})
payload = json.loads(raw)
expect(payload.get("status") == "written", "builder handoff written")
task_path = payload["path"]
task_id = parse_id(task_path)
expect(task_id, "builder handoff has entry id")
# 2. Reviewer sees the handoff in session-start context.
run_as("daedalus", daedalus_home)
ctx = hooks.on_session_start(session_id="smoke-daedalus")
context = (ctx or {}).get("context", "")
expect("smoke handoff for daedalus" in context, "reviewer session-start sees handoff")
expect(f"id {task_id}" in context, "reviewer session-start includes source id")
# 3. Reviewer sees the handoff in fabric_pending.
pending = json.loads(tools.fabric_pending({}))
expect(pending.get("total", 0) >= 1, "fabric_pending returns assigned work")
open_tasks = pending.get("open_tasks", [])
expect(any(t.get("id") == task_id for t in open_tasks), "fabric_pending exposes exact task id")
# 4. Reviewer writes a linked review.
review_ref = f"icarus:{task_id}"
raw = tools.fabric_write({
"type": "review",
"summary": "reviewed smoke handoff from icarus",
"content": "Confirmed relay handoff pickup worked. One nit on naming.",
"review_of": review_ref,
"status": "completed",
"outcome": "pickup worked",
})
payload = json.loads(raw)
expect(payload.get("status") == "written", "reviewer linked review written")
review_path = payload["path"]
review_id = parse_id(review_path)
expect(review_id, "review has entry id")
# 5. Builder sees the linked review.
run_as("icarus", icarus_home)
ctx = hooks.on_session_start(session_id="smoke-icarus")
context = (ctx or {}).get("context", "")
expect("reviewed smoke handoff from icarus" in context, "builder session-start sees linked review")
expect(review_ref in context, "builder context shows review_of link")
# 6. Builder writes a linked fix.
raw = tools.fabric_write({
"type": "code-session",
"summary": "fixed smoke handoff after review",
"content": "Renamed relay variables and cleaned the patch after review.",
"revises": review_ref,
"status": "completed",
})
payload = json.loads(raw)
expect(payload.get("status") == "written", "builder linked fix written")
# 7. Recall retrieves the chain.
results = json.loads(tools.fabric_recall({"query": "amber relay handoff", "max_results": 5}))
entries = results.get("entries", [])
expect(any(e.get("id") == task_id for e in entries), "recall returns source handoff")
expect(any(e.get("review_of") == review_ref for e in entries), "recall returns linked review")
print("")
print("Smoke handoff OK")
print(f" fabric: {os.environ['FABRIC_DIR']}")
print(f" task id: {task_id}")
print(f" review_of:{review_ref}")
PY

1090
icarus/scripts/test-plugin.sh Executable file

File diff suppressed because it is too large Load Diff

1204
icarus/state.py Normal file

File diff suppressed because it is too large Load Diff

236
icarus/tools.py Normal file
View File

@ -0,0 +1,236 @@
"""Tool handlers — the code that runs when the LLM calls each tool."""
import json
from . import state
def _json(payload) -> str:
return json.dumps(payload, default=str)
def fabric_recall(args: dict, **kwargs) -> str:
query = args.get("query", "").strip()
if not query:
return _json({"error": "No query provided"})
try:
results = state.recall(
query,
max_results=args.get("max_results", 5),
agent=args.get("agent"),
project=args.get("project"),
)
return _json({"query": query, "count": len(results), "entries": results})
except Exception as e:
return _json({"error": str(e)})
def fabric_write(args: dict, **kwargs) -> str:
entry_type = args.get("type", "").strip()
content = args.get("content", "").strip()
summary = args.get("summary", "").strip()
status = args.get("status", "").strip()
assigned_to = args.get("assigned_to", "").strip()
review_of = args.get("review_of", "").strip()
revises = args.get("revises", "").strip()
if not entry_type or not content or not summary:
return _json({"error": "Need type, content, and summary"})
if status == "open" and not assigned_to:
return _json({"error": "status='open' requires assigned_to"})
if entry_type == "review" and not review_of:
return _json({"error": "type='review' requires review_of (agent:id of the entry you are reviewing)"})
if review_of:
parts = review_of.split(":", 1)
if len(parts) != 2 or not parts[0] or not parts[1] or len(parts[1]) < 4:
return _json({"error": f"review_of must be agent:id (e.g. icarus:a3f29b01), got '{review_of}'"})
if not state.has_entry_ref(review_of):
return _json({"error": f"review_of points to a missing entry: '{review_of}'"})
if revises:
parts = revises.split(":", 1)
if len(parts) != 2 or not parts[0] or not parts[1] or len(parts[1]) < 4:
return _json({"error": f"revises must be agent:id (e.g. icarus:a3f29b01), got '{revises}'"})
if not state.has_entry_ref(revises):
return _json({"error": f"revises points to a missing entry: '{revises}'"})
tv = args.get("training_value", "").strip()
if tv and tv not in ("high", "normal", "low"):
return _json({"error": f"training_value must be high/normal/low, got '{tv}'"})
try:
path = state.write_entry(
entry_type=entry_type,
content=content,
summary=summary,
tags=args.get("tags", ""),
status=status,
outcome=args.get("outcome", ""),
review_of=review_of,
revises=revises,
customer_id=args.get("customer_id", ""),
assigned_to=assigned_to,
training_value=tv,
verified=args.get("verified", ""),
evidence=args.get("evidence", ""),
source_tool=args.get("source_tool", ""),
artifact_paths=args.get("artifact_paths", ""),
)
# log usage telemetry when referencing other entries
if review_of:
ref_id = review_of.split(":", 1)[1] if ":" in review_of else review_of
if state.was_recalled(ref_id):
state.log_usage(ref_id, action="reviewed")
if revises:
ref_id = revises.split(":", 1)[1] if ":" in revises else revises
if state.was_recalled(ref_id):
state.log_usage(ref_id, action="revised")
return _json({"status": "written", "path": path})
except Exception as e:
return _json({"error": str(e)})
def fabric_search(args: dict, **kwargs) -> str:
query = args.get("query", "").strip()
if not query:
return _json({"error": "No query provided"})
try:
results = state.search_entries(query)
return _json({"query": query, "count": len(results), "results": results})
except Exception as e:
return _json({"error": str(e)})
def fabric_pending(args: dict, **kwargs) -> str:
try:
open_tasks, reviews, open_tickets = state.read_pending(
customer_id=args.get("customer_id"),
)
return _json({
"open_tasks": open_tasks,
"reviews_of_my_work": reviews,
"open_tickets": open_tickets,
"total": len(open_tasks) + len(reviews) + len(open_tickets),
})
except Exception as e:
return _json({"error": str(e)})
def fabric_curate(args: dict, **kwargs) -> str:
entry_id = args.get("entry_id", "").strip()
training_value = args.get("training_value", "").strip()
if not entry_id or training_value not in ("high", "normal", "low"):
return _json({"error": "Need entry_id and training_value (high/normal/low)"})
try:
result = state.curate_entry(entry_id, training_value)
return _json(result)
except Exception as e:
return _json({"error": str(e)})
def fabric_export(args: dict, **kwargs) -> str:
try:
result = state.export_training(mode=args.get("mode", "normal"))
result.pop("_training_data", None)
result.pop("training_data_path", None)
return _json(result)
except Exception as e:
return _json({"error": str(e)})
def fabric_train(args: dict, **kwargs) -> str:
try:
result = state.start_training(
model=args.get("model"),
suffix=args.get("suffix"),
epochs=args.get("epochs", 3),
batch_size=args.get("batch_size"),
learning_rate=args.get("learning_rate"),
checkpoints=args.get("n_checkpoints"),
mode=args.get("mode"),
min_pairs=args.get("min_pairs", 10),
)
return _json(result)
except Exception as e:
return _json({"error": str(e)})
def fabric_train_status(args: dict, **kwargs) -> str:
try:
result = state.check_training(job_id=args.get("job_id"))
return _json(result)
except Exception as e:
return _json({"error": str(e)})
def fabric_models(args: dict, **kwargs) -> str:
try:
registry = state.list_models()
return _json(registry)
except Exception as e:
return _json({"error": str(e)})
def fabric_eval(args: dict, **kwargs) -> str:
candidate = args.get("candidate_model", "").strip()
if not candidate:
return _json({"error": "candidate_model is required"})
try:
result = state.run_eval(
candidate_model=candidate,
base_model=args.get("base_model"),
sample_count=args.get("sample_count", 10),
)
return _json(result)
except Exception as e:
return _json({"error": str(e)})
def fabric_switch_model(args: dict, **kwargs) -> str:
model_id = args.get("model_id", "").strip()
if not model_id:
return _json({"error": "model_id is required"})
try:
result = state.switch_model(
model_id=model_id,
min_eval_score=args.get("min_eval_score", 0.7),
)
return _json(result)
except Exception as e:
return _json({"error": str(e)})
def fabric_rollback_model(args: dict, **kwargs) -> str:
try:
result = state.rollback_model()
return _json(result)
except Exception as e:
return _json({"error": str(e)})
def fabric_brief(args: dict, **kwargs) -> str:
try:
result = state.build_brief()
return _json(result)
except Exception as e:
return _json({"error": str(e)})
def fabric_telemetry(args: dict, **kwargs) -> str:
try:
result = state.get_telemetry(last_n=args.get("last_n", 50))
return _json(result)
except Exception as e:
return _json({"error": str(e)})
def fabric_init_obsidian(args: dict, **kwargs) -> str:
try:
from . import obsidian
result = obsidian.init_obsidian(state.FABRIC_DIR)
return _json(result)
except Exception as e:
return _json({"error": str(e)})
def fabric_report(args: dict, **kwargs) -> str:
try:
result = state.build_weekly_report()
return _json(result)
except Exception as e:
return _json({"error": str(e)})

View File

@ -0,0 +1,119 @@
# Infrastructure
> Docker services, cronjobs, and environment configuration that support the 6 memory layers.
## Docker Services
The vector and pipeline layers run as Docker containers:
```yaml
# docker-compose.yml
services:
qdrant:
image: qdrant/qdrant:v1.17.1
ports:
- "6333:6333"
- "6334:6334"
volumes:
- ./qdrant_data:/qdrant/storage
restart: unless-stopped
redis:
image: redis:7-alpine
ports:
- "6379:6379"
command: redis-server --requirepass ${REDIS_PASSWORD}
restart: unless-stopped
worker:
build: ./worker
depends_on:
- qdrant
- redis
environment:
- QDRANT_URL=http://qdrant:6333
- REDIS_URL=redis://:${REDIS_PASSWORD}@redis:6379/0
- EMBEDDING_DIMS=${EMBEDDING_DIMS:-4096}
- COLLECTION_NAME=${COLLECTION_NAME:-knowledge_base}
- OPENROUTER_API_KEY=${OPENROUTER_API_KEY}
restart: unless-stopped
```
**Key configuration:**
- `EMBEDDING_DIMS=4096` — must match Qdrant collection schema
- `COLLECTION_NAME=knowledge_base` — target collection for all wiki ingestion
- Redis password required — set in `.env`, used by both Redis container and worker
## Cronjobs
| Job | Recommended schedule | What it does |
|-----|---------------------|--------------|
| **wiki-continuous-ingest** | Hourly (:00) | SHA-256 diff detection → embed new wiki files → Qdrant |
| **wiki-raw-ingest-monitor** | 2x/week | Read raw/ files → extract concepts/entities/comparisons → create wiki pages |
| **vault-curator-weekly** | Weekly | Phase 1 (frontmatter enrichment) + Phase 2 (semantic linking) + Phase 3 (INDEX.md) |
| **decay-scanner** | Weekly | Archive low-importance, aged AI content from Qdrant |
| **dlq-auto-report** | Every 6h | Dead letter queue monitoring and reporting |
| **maas-heartbeat** | Every 6h | Infrastructure health check |
| **holographic-memory-backup** | Weekly | Backup of workspace memory files and databases |
| **monitor-openrouter-balance** | Daily | OpenRouter credit balance check |
**Interaction between jobs:**
- `wiki-raw-ingest-monitor` creates new wiki pages → next `wiki-continuous-ingest` picks them up and sends to Qdrant
- `vault-curator-weekly` enriches ALL vault files — adds frontmatter, semantic links, and INDEX.md
## Environment Variables
### Required
| Variable | Purpose | Example |
|----------|---------|---------|
| `FABRIC_DIR` | Where Icarus writes fabric entries | `/home/your-user/vault/fabric` |
| `OPENROUTER_API_KEY` | Embedding + LLM extraction | `sk-or-...` |
| `REDIS_PASSWORD` | Redis authentication | (generated) |
### Strongly recommended
| Variable | Default | Recommended | Why |
|----------|---------|-------------|-----|
| `ICARUS_EXTRACTION_MAX_TOKENS` | 1024 | **4096** | 1024 causes fabric truncation |
| `ICARUS_EXTRACTION_MODEL` | deepseek-v4-flash | same | Any OpenRouter chat model works |
| `EMBEDDING_DIMS` | varies | **4096** | Must match Qdrant collection schema |
### Optional
| Variable | Purpose |
|----------|---------|
| `ICARUS_OBSIDIAN=1` | Enable Obsidian wikilinks and daily notes |
| `OBSIDIAN_VAULT_PATH` | Vault root (if fabric is a subfolder) |
| `ICARUS_RESULT_MAX_CHARS` | Fallback truncation limit (default 500) |
| `ICARUS_TASK_MAX_CHARS` | Fallback task truncation (default 300) |
| `TOGETHER_API_KEY` | For training/eval tools |
| `OPENROUTER_FULL_API_KEY` | Alternative key for LLM extraction |
| `OPENROUTER_DS_API_KEY` | Alternative key for LLM extraction |
| `CURATOR_LOG_LEVEL` | Logging level for Vault Curator |
| `VAULT_PATH` | Path to vault root for Vault Curator |
## File locations
| Component | Path |
|-----------|------|
| Workspace memory | `$HERMES_HOME/memories/` |
| Session DB | `$HERMES_HOME/state.db` |
| Fact store DB | `$HERMES_HOME/memory_store.db` |
| Icarus plugin | `$HERMES_HOME/plugins/icarus/` |
| Fabric entries | `$FABRIC_DIR` |
| Wiki files | `$VAULT_PATH/wiki/` |
| Qdrant data | `./qdrant_data/` (Docker volume) |
| Docker compose | Project root |
| Cron scripts | Project scripts directory |
## System requirements
| Resource | Minimum | Recommended |
|----------|---------|-------------|
| RAM | 8 GB | 16 GB (Qdrant + Redis + ARQ worker) |
| Disk | 20 GB | 50 GB (Qdrant vectors + wiki files) |
| Docker | 24.0+ | Latest stable |
| Python | 3.11+ | 3.11 (tested) |
| Hermes Agent | 0.14.0+ | 0.15.2 (tested) |
| Qdrant | 1.17+ | 1.17.1 (tested) |

39
layers/01-workspace.md Normal file
View File

@ -0,0 +1,39 @@
# Layer 1 — Workspace Memory
> **Files:** `MEMORY.md`, `USER.md`, `CREATIVE.md` in `$HERMES_HOME/memories/`
> **Injection:** System prompt, every turn
> **Persistence:** Markdown on disk
## What it stores
| File | Writer | Format | Purpose |
|------|--------|--------|---------|
| **MEMORY.md** | `memory` tool | `§`-delimited entries | Agent's durable memory: environment facts, tool quirks, project conventions |
| **USER.md** | Manual (user) | Markdown | Static user profile: who the user is, preferences, workflow |
| **CREATIVE.md** | Icarus plugin (`state.py`) | Markdown headers + bullets | Agent's creative state: learnings, open questions, cycle counter |
## The `§` delimiter story
The `memory` tool uses `§` (paragraph sign, U+00A7) as an entry delimiter in MEMORY.md:
```
Entry about Qdrant named vectors
§
Entry about Tailscale configuration
§
Entry about project conventions
```
**What broke:** The Icarus plugin's `write_memory_file()` used to overwrite MEMORY.md with `.write_text()` on every session end, destroying all `§`-delimited entries. Two writers, one file, incompatible formats.
**Fix:** Icarus now writes to **CREATIVE.md** instead. Two writers, two files, zero conflicts. This fix is in our [Icarus fork](https://github.com/ClaudioDrews/icarus-plugin).
## Configuration
No additional configuration needed — these files are always injected by Hermes. To change injection behavior, edit `SOUL.md` or `rulebook.md`.
## Pitfalls
- **systemd does not expand `~`:** Paths in `.env` read by the gateway must use absolute paths
- **Never edit MEMORY.md manually:** Use `memory(action='add')` — the tool writes atomic `§`-delimited entries
- **Icarus conflict:** If MEMORY.md ever shows `cycles:` or markdown headers, Icarus overwrote it. Delete and restore from backup, then ensure Icarus is writing to CREATIVE.md

49
layers/02-sessions.md Normal file
View File

@ -0,0 +1,49 @@
# Layer 2 — Session Database
> **File:** `$HERMES_HOME/state.db` (SQLite + FTS5)
> **Tool:** `session_search`
> **Writer:** Hermes Gateway (automatic)
## What it stores
Every message sent and received by the agent is logged automatically by the gateway process:
```
sessions table:
session_id, title, source (telegram/cli/cron), started_at, ended_at
messages table:
id, session_id, role (user/assistant/tool), content, timestamp
messages_fts (FTS5 virtual table):
Full-text index over message content
```
## How the agent uses it
The `session_search` tool provides three access patterns:
1. **Discovery**`session_search(query="auth refactor")` → FTS5 search across all sessions, returns top matches with context windows
2. **Scroll**`session_search(session_id="...", around_message_id=12345)` → read a specific session
3. **Browse**`session_search()` → recent sessions chronologically
## Context injection (Icarus enhancement)
The Icarus fork adds `_search_sessions()` in `hooks.py` — FTS5 search during `pre_llm_call` that injects relevant past conversations into the system prompt. This means the agent doesn't need to explicitly call `session_search` — relevant history finds it automatically.
Key implementation details:
- FTS5 query: OR of tokens ≥ 4 characters from user message
- Excludes current session
- Deduplicates by session in Python
- Labeled `[sessions]` in prompt for source transparency
- Opens DB read-only: `sqlite3.connect("file:{db}?mode=ro", uri=True)`
## Configuration
No configuration needed — the gateway manages this automatically.
## Pitfalls
- **FTS5 is lexical, not semantic:** Queries in one language may not match content in another with different wording
- **`snippet()` + `GROUP BY` incompatibility:** Cannot combine in same SQL query — fetch ranked rows, dedup in Python
- **`started_at` is Unix timestamp (float):** Not an ISO string — use `datetime.fromtimestamp()` for display

53
layers/03-fact-store.md Normal file
View File

@ -0,0 +1,53 @@
# Layer 3 — Structured Facts (Holographic Memory)
> **File:** `$HERMES_HOME/memory_store.db` (SQLite + HRR + FTS5 + trust scoring)
> **Tool:** `fact_store` (CRUD: add, search, probe, reason, update, remove, contradict)
> **Feedback:** `fact_feedback` (helpful/unhelpful)
## What it stores
Durable, structured facts with entity resolution and trust scoring:
```
facts table:
fact_id, content, category (user_pref|project|tool|general),
entities (JSON array), tags, trust_score, retrieval_count,
helpful_count, created_at, last_accessed_at
```
## How the agent uses it
6 actions:
| Action | What it does | Example |
|--------|-------------|---------|
| `add` | Store a new fact | `fact_store(action='add', content='...', entities=['Qdrant'])` |
| `search` | Keyword lookup (FTS5) | `fact_store(action='search', query='Qdrant named vectors')` |
| `probe` | All facts about an entity | `fact_store(action='probe', entity='Docker')` |
| `reason` | Compositional: facts connected to multiple entities | `fact_store(action='reason', entities=['Qdrant', 'Docker'])` |
| `contradict` | Find conflicting claims | `fact_store(action='contradict')` |
| `update/remove` | CRUD maintenance | `fact_store(action='update', fact_id=87, trust_delta=0.1)` |
## Trust scoring
The system tracks which facts are actually useful:
- `retrieval_count` — how many times the fact was retrieved
- `helpful_count` — how many times it was marked helpful
- `trust_score` — calculated from ratio + Bayesian prior (starts at 0.50)
**Critical rule:** When you retrieve a fact via `probe`, `search`, or `reason` and reference it in your response, you MUST call `fact_feedback` in the same turn. Without feedback, `trust_score` is ornamental and fact quality degrades silently.
## Context injection (Icarus enhancement)
The Icarus fork adds `_search_facts()` — FTS5 search during `pre_llm_call` that injects relevant facts on the **first turn only** (to avoid per-turn cost). Labeled `[facts]` in prompt.
## Configuration
No configuration needed. DB path is managed by Hermes.
## Pitfalls
- **`retrieval_count` was broken for `probe()`/`reason()`** — only `search()` incremented it. Fixed in current code
- **Trust scores need feedback to move:** Without `fact_feedback` calls, every fact stays at 0.50
- **HRR is not exposed to the agent directly:** It powers entity resolution internally — the agent sees keyword search + entity linking, not the holographic vectors

View File

@ -0,0 +1,96 @@
# Layer 4 — Fabric (Cross-Session Memory)
> **Plugin:** Icarus ([bundled with Memory OS](../icarus/))
> **Storage:** `$FABRIC_DIR` (markdown files with YAML frontmatter)
> **Tools:** 16 (fabric_recall, fabric_write, fabric_brief, etc.)
> **Hooks:** 4 (on_session_start, pre_llm_call, post_llm_call, on_session_end)
## What it stores
Structured, cross-session entries — each session end produces one or more markdown files:
```markdown
---
id: "29914be5"
type: "resolution"
summary: "Fixed MEMORY.md corruption from dual-writer conflict"
training_value: "high"
status: "completed"
---
## Context
...
## Action/Decision
...
## Outcome
...
```
**Entry types:** decision, resolution, note, code-session, session, review, research, task
## How the agent uses it
| Tool | What it does |
|------|-------------|
| `fabric_recall` | Ranked retrieval from shared memory |
| `fabric_write` | Write entries with linking, evidence, and handoff fields |
| `fabric_search` | Keyword grep across all entries |
| `fabric_pending` | Show work assigned to this agent |
| `fabric_brief` | Daily brief: pending work, recent activity |
| `fabric_curate` | Set training value (high/normal/low) |
| `fabric_export` | Export training pairs for fine-tuning |
| `fabric_train` | Start fine-tune job on Together AI |
| `fabric_models` | List trained replacement models |
## Key enhancements over upstream
| Enhancement | What it fixes |
|-------------|---------------|
| **LLM-powered extraction** | Replaces upstream's `text[:500]` truncation with structured JSON extraction via OpenRouter |
| **Multi-source context injection** | Qdrant + sessions + facts injected automatically (upstream: fabric only) |
| **MEMORY.md → CREATIVE.md** | Fixes `§` delimiter corruption from dual-writer conflict |
| **Backtick sanitization** | Prevents orphaned backticks in learning lines |
| **System injection filter** | Prevents orchestrator preambles from being captured as tasks |
| **Social closer detection** | Skips trivial messages — avoids wasting embeddings on small talk |
See the [bundled Icarus source](../icarus/) for full details.
## Context injection flow
```
pre_llm_call(user_message):
├── _is_social_close(message)?
│ └── Yes → skip all search-based injection
├── fabric_recall(query) → [fabric] results
├── _search_qdrant(query, threshold=0.55) → [qdrant] results
├── _search_sessions(query) → [sessions] results
└── _search_facts(query) → [facts] results (first turn only)
Per-source dedup:
_injected_fabric, _injected_qdrant, _injected_sessions
→ Reset on session start
→ Prevents same result injected twice in one session
```
## Configuration
```bash
# Required
FABRIC_DIR=/absolute/path/to/fabric
OPENROUTER_API_KEY=sk-or-...
# Strongly recommended
ICARUS_EXTRACTION_MAX_TOKENS=4096
ICARUS_EXTRACTION_MODEL=deepseek/deepseek-v4-flash
# Optional (for Obsidian integration)
ICARUS_OBSIDIAN=1
OBSIDIAN_VAULT_PATH=/absolute/path/to/vault
```
## Pitfalls
- **`ICARUS_EXTRACTION_MAX_TOKENS` is frozen at import time** — changing `.env` requires gateway restart
- **DeepSeek + `response_format: json_object` = `content: null`** — the fork uses prompt-based JSON + `_parse_json_robust()` instead
- **`FABRIC_DIR` must be absolute path** — systemd does not expand `~`
- **Obsidian is optional** — Icarus writes plain markdown, Obsidian just reads it
- **Gateway restart required** after editing `hooks.py` or changing env vars

90
layers/05-qdrant.md Normal file
View File

@ -0,0 +1,90 @@
# Layer 5 — Vector Database (Qdrant)
> **Service:** Qdrant 1.17+ (Docker)
> **Collection:** `knowledge_base` (4096d Cosine + BM25 sparse)
> **Embedding:** Qwen3-Embedding-8B via OpenRouter
> **Endpoint:** `http://localhost:6333`
## What it stores
All knowledge that benefits from semantic search — wiki pages, session transcripts, raw documents, technical references. Content is ingested via the continuous ingest pipeline (hourly) and the wiki agent (scheduled).
## How the agent uses it
**Two access patterns:**
1. **Explicit**`qdrant_search(query="memory architecture", top_k=3)` → agent calls the tool directly
2. **Automatic** — Icarus `pre_llm_call` injects relevant Qdrant results into the system prompt every turn (via `_search_qdrant()`)
**Search uses 4-level fallback cascade:**
```
1. Hybrid: dense (4096d cosine) + sparse (BM25) → RRF fusion
2. Dense-only: if sparse fails → pure vector search
3. Lexical: if Qdrant offline → markdown file search in vault
4. SQLite: if vault inaccessible → keyword search in lineage table
5. None: all exhausted → return empty (fail-open)
```
## Collection schema
```json
{
"vectors": {
"dense": { "size": 4096, "distance": "Cosine" }
},
"sparse_vectors": {
"sparse": { "index": { "on_disk": false } }
}
}
```
**Named vectors:** Points must use `{"dense": [...], "sparse": SparseVector(...)}`. Must match schema exactly — unnamed vectors are rejected silently.
## Decay and dedup
| Mechanism | Schedule | What it does |
|-----------|----------|--------------|
| **Decay scanner** | Weekly | Archives AI-generated points with low importance and high age. Exempts human-generated content and high-importance (>0.7) points. Formula: `decay_score = exp(-ln(2) * age_days / half_life)` |
| **Semantic dedup** | Monthly | Merges near-duplicate points (cosine > 0.92). Tags union, priority by source_type. |
## Context injection (Icarus enhancement)
```python
_search_qdrant(query, top_k=2, threshold=0.55):
1. Embed user message via context_enhancer pipeline
2. search_with_fallback() → 4-level cascade
3. Results labeled [qdrant] in system prompt
4. Per-session dedup by point ID
```
**Key injection pitfall:** `context_enhancer.py` reads `OPENROUTER_API_KEY` (singular), but some environments use split keys. Before importing, inject: `os.environ["OPENROUTER_API_KEY"] = resolved_key`.
**Social closer gate:** Trivial messages ("ok", "thanks", emoji-only) skip Qdrant search entirely — no point embedding small talk.
## Embedding pipeline
```
File in $VAULT_PATH/wiki/
wiki-continuous-ingest (hourly cron)
│ SHA-256 diff detection
Redis queue (ARQ job)
ARQ Worker (Docker)
│ embed via Qwen3-Embedding-8B (OpenRouter)
│ get_sparse_embedding() → BM25 (fastembed, local)
Qdrant upsert (with dedup check)
```
## Pitfalls
- **Named vectors are mandatory in Qdrant 1.17+:** `upsert` requires `"vector": {"dense": vec}`, plain vectors are rejected silently
- **`AsyncQdrantClient.search()` doesn't exist in qdrant-client 1.18:** Use REST API `POST /collections/{name}/points/search`
- **Embedding dimension mismatch is silent:** If env says 1024 but collection is 4096, OpenRouter truncates via Matryoshka — vectors are valid but degraded
- **Decay scanner is inert without payload metadata:** Requires `importance_score`, `last_accessed_at`, `confidence_score` in point payloads. Missing → all points get `decay_score=1.0` → nothing archived
- **Isolate persona/relational collections from decay/dedup** — they preserve temporal variation

84
layers/06-llm-wiki.md Normal file
View File

@ -0,0 +1,84 @@
# Layer 6 — LLM Wiki
> **Location:** `$VAULT_PATH/wiki/`
> **Pipeline:** wiki-raw-ingest-monitor (scheduled) + wiki-continuous-ingest (hourly)
> **Qdrant:** All files ingested into `knowledge_base`
## Directory structure
```
$VAULT_PATH/wiki/
├── raw/ Source documents
│ ├── articles/ (analyses, dossiers, tutorials)
│ ├── releases/ (software release notes, PR trackers)
│ ├── projects/ (project architecture, reports)
│ └── ... (any other document source)
├── concepts/ Extracted ideas and patterns
├── entities/ Concrete things (tools, models, projects, people)
├── comparisons/ Side-by-side analyses
├── _meta/ Schema, templates, taxonomy
├── _archive/ Deprecated pages
├── index.md Master catalog with one-line summaries
├── SCHEMA.md Constitution — what merits a page, how to link, what tags to use
└── log.md Logbook — every curation session recorded
```
## The two pipelines
| Pipeline | Trigger | What it does |
|----------|---------|--------------|
| **Wiki Agent** (curation) | Scheduled cron | Reads `raw/` files, extracts concepts/entities/comparisons, creates structured wiki pages |
| **Continuous Ingest** (Qdrant) | Hourly cron | SHA-256 diff detection, embeds new/modified files, upserts to `knowledge_base` |
They're independent: the Wiki Agent builds the curated knowledge graph; Continuous Ingest ensures Qdrant stays in sync.
## Wiki Agent pipeline
```
1. Cron triggers wiki-raw-ingest-monitor (scheduled)
2. check_raw_ingest.py → detects new files in raw/
3. Agent reads SCHEMA.md, index.md, log.md
4. For each new file:
a. LLM analyzes content
b. Decides: concept? entity? comparison? skip?
c. Creates page in appropriate directory
d. Frontmatter: type, tags (from closed taxonomy), sources (linking back to raw/)
5. Updates index.md and log.md
6. Lint: validates frontmatter, wikilinks, index coverage
```
## Continuous Ingest pipeline
```
1. Cron triggers wiki-continuous-ingest (hourly)
2. wiki_continuous_ingest.py:
a. Scans $VAULT_PATH/wiki/ for *.md files
b. Computes SHA-256 hash for each file
c. Compares with state file
d. New or modified → enqueues ARQ job in Redis
3. ARQ Worker (Docker):
a. process_wiki_file → reads file content
b. parse_frontmatter → extracts metadata
c. get_embedding() → Qwen3-Embedding-8B (4096d)
d. get_sparse_embedding() → BM25 (fastembed, local)
e. upsert_with_dedup() → Qdrant knowledge_base
```
**State file:** JSON file tracking `{file_path, sha256_hash, ingested_at}` for each indexed file. Prevents re-ingestion of unchanged files.
## Page quality standards
Every wiki page must have:
- Valid YAML frontmatter with `type`, `tags`, `sources`, `confidence`
- At least one wikilink to another wiki page
- A one-line summary (used in index.md)
- `sources` linking back to the raw file(s) it was extracted from
**Closed taxonomy:** Tags come from a fixed set of categories defined in SCHEMA.md. No ad-hoc tags allowed.
## Pitfalls
- **Wiki Agent and Vault Curator are different cronjobs** — don't conflate them. Wiki Agent creates pages; Vault Curator enriches frontmatter + adds semantic links to existing files
- **raw/ files are source material, not curated knowledge** — they feed the pipeline but aren't themselves structured wiki pages
- **SCHEMA.md is the constitution** — any change to page structure or taxonomy must be reflected there first
- **log.md is the audit trail** — if a page looks wrong, check log.md to see which session created it and why

View File

@ -0,0 +1,97 @@
# Modifications to Hermes Core
Memory OS requires changes to core Hermes files that govern agent behavior. These modifications ensure the agent trusts its injected memory as authoritative rather than re-discovering known facts.
## SOUL.md — Ground Truth hierarchy
Add a new level 2 to the Ground Truth hierarchy in `SOUL.md`:
```markdown
## Ground Truth
Authoritative sources, in priority order:
1. **Terminal output** — stdout, stderr, exit codes. Never reinterpret.
2. **Injected memory** — qdrant, fabric, sessions, facts. Ground truth for documented
knowledge. When injected memory contradicts other sources, injected memory wins
because it represents verified, persisted knowledge from prior sessions.
3. **Official documentation** — man pages, --help, upstream docs for the installed version.
4. **Training knowledge** — reference only. Always verify against sources 1-3 before acting.
```
**Why this matters:** Without level 2, the agent treats facts already persisted in Qdrant/fabric/sessions as less authoritative than documentation, causing it to re-discover known information. An agent that has Tailscale configuration in `fact_store` should not spend time re-verifying it against `man tailscale`.
## SOUL.md — Context injection convention
Add source labeling conventions:
```markdown
## Context injection convention
When context is injected into the system prompt, it is labeled by source:
- [fabric] — from Icarus fabric recall
- [qdrant] — from Qdrant semantic search
- [sessions] — from session history FTS5
- [facts] — from holographic fact store
Injected memory takes priority level 2 in Ground Truth. This means:
"You already know this. Don't re-discover it. Use it."
```
## SOUL.md — Agent identity
Add clear identity boundaries:
```markdown
## You are not
You are not a search engine. You are not a chatbot. You are not here to produce
plausible-sounding output. You are an agent that executes real work in real
environments, where errors have real costs. Treat every action accordingly.
```
## rulebook.md — Mandatory verifications
Add to the rulebook:
```markdown
## Mandatory Verifications
Before reporting a fact as true, verify:
1. **Runtime evidence** — terminal output, file existence, process status
2. **Injected memory** — qdrant_search, fact_store probe, fabric_recall
3. **Documentation** — man pages, official docs for installed version
4. **Training knowledge** — never cite without verifying against 1-3
```
## rulebook.md — Memory architecture
Add a section documenting the 6-layer architecture so the agent knows where to find information:
```markdown
## Memory Architecture
The agent has 6 layers of persistent memory:
| Layer | What it stores | How to access |
|-------|---------------|---------------|
| 1. Workspace | MEMORY.md, USER.md, CREATIVE.md | Always in system prompt |
| 2. Sessions | state.db (FTS5) | session_search |
| 3. Facts | memory_store.db (HRR) | fact_store |
| 4. Fabric | $FABRIC_DIR (markdown) | fabric_recall, fabric_write |
| 5. Qdrant | knowledge_base (4096d) | qdrant_search, auto-injection |
| 6. Wiki | $VAULT_PATH/wiki/ | qdrant_search → knowledge_base |
```
## Impact
Without these modifications:
- Qdrant/fabric/session/fact injection still works technically
- But the agent doesn't trust injected memory as authoritative
- Result: agent re-discovers known facts, wastes tokens, makes redundant decisions
With these modifications:
- Agent treats injected memory as ground truth (level 2)
- Reduces redundant discovery work
- Agent can reference prior decisions without re-litigating them
- Cross-session continuity is real, not aspirational

7
requirements.txt Normal file
View File

@ -0,0 +1,7 @@
# Memory OS — Host Scripts
requests>=2.31.0
aiohttp>=3.9.0
arq>=0.28.0
python-dotenv>=1.0.0
pyyaml>=6.0
qdrant-client>=1.17.0

49
scripts/README.md Normal file
View File

@ -0,0 +1,49 @@
# Memory OS — Scripts
Standalone Python scripts that maintain the Qdrant vector database and wiki pipeline.
## Qdrant Maintenance
| Script | What it does | Run |
|--------|-------------|-----|
| `decay_scanner.py` | Archives low-importance, aged AI content based on half-life decay | Weekly cron |
| `backfill_decay_metadata.py` | Populates missing `importance_score`, `last_accessed_at`, `confidence_score` in Qdrant points | Run once before enabling decay scanner |
| `semantic_dedup.py` | Merges near-duplicate points (cosine >0.92) | Monthly cron |
## Context Injection
| Script | What it does | Used by |
|--------|-------------|---------|
| `context_enhancer.py` | Embedding pipeline: query → embed → search Qdrant (4-level fallback). Also provides BM25 sparse embedding via FastEmbed. | Icarus `pre_llm_call` hook |
## Wiki Pipeline
| Script | What it does | Run |
|--------|-------------|-----|
| `wiki_continuous_ingest.py` | SHA-256 diff detection: finds new/modified wiki files, enqueues ARQ jobs in Redis | Hourly cron |
| `bulk_wiki_ingest.py` | One-shot bulk ingestion of all wiki files into Qdrant | After initial setup or collection rebuild |
## Quality Control
| Script | What it does | Run |
|--------|-------------|-----|
| `pre_validator.py` | Pre-flight validation of wiki documents: YAML frontmatter, required fields, link targets | Before ingestion |
| `reflection_trigger.py` | Idle detection for ARQ worker — enqueues micro-reflection when queue is empty and within hourly budget | Every 5min cron |
## Monitoring
| Script | What it does | Run |
|--------|-------------|-----|
| `dlq_manager.py` | Dead letter queue monitoring and reporting | Every 6h cron |
## Environment variables
All scripts read configuration from environment variables. See `.env.example` in the project root for the full reference.
Key variables:
- `OPENROUTER_API_KEY` — embeddings (required)
- `WIKI_PATH` — wiki root directory (default: `~/vault/wiki`)
- `COLLECTION_NAME` — Qdrant collection (default: `knowledge_base`)
- `EMBEDDING_DIMS` — vector dimensions (default: 4096)
- `REDIS_PASSWORD` — Redis auth (required for wiki ingest)
- `QDRANT_URL` — Qdrant endpoint (default: `http://localhost:6333`)

View File

@ -0,0 +1,292 @@
#!/usr/bin/env python3
"""Backfill decay metadata for Qdrant knowledge_base.
Populates missing fields (created_at, last_accessed_at, importance_score,
confidence_score, archived) so the decay_scanner can actually work.
Modes:
--dry-run Simulate everything, print stats, no writes (DEFAULT)
--commit Actually update points via REST API
--pilot N Process only N points (for validation)
Usage:
python3 backfill_decay_metadata.py --dry-run --pilot 50
python3 backfill_decay_metadata.py --dry-run
python3 backfill_decay_metadata.py --commit
Heuristics:
- created_at / last_accessed_at:
Session points: from payload.timestamp (Unix epoch ISO 8601)
Wiki points: from file mtime if file exists, else now()
- importance_score: 0.5 flat (conservative)
- confidence_score:
wiki-* 0.85
session 0.70
unknown 0.75
- archived: false (explicit)
Safety:
- Dry-run by default no writes without --commit
- Only touches points MISSING the target fields (no overwrites)
- Batched via REST API POST /points/payload
- Uses same pattern as migrate_strength.py (2026-05-29)
"""
import argparse
import hashlib
import json
import os
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError
# ─── Config ──────────────────────────────────────────────────────────────────
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
COLLECTION = os.environ.get("QDRANT_COLLECTION", os.environ.get("COLLECTION_NAME", "knowledge_base"))
BATCH_SIZE = 200
SCROLL_LIMIT = 200
LOG_FILE = Path(os.environ.get("HERMES_LOGS_DIR", str(Path.home() / ".hermes" / "logs"))) / "decay_scanner.log"
VAULT_ROOT = Path(os.environ.get("VAULT_PATH", "."))
# ─── Heuristics ──────────────────────────────────────────────────────────────
CONFIDENCE_BY_SOURCE = {
"wiki-concepts": 0.85,
"wiki-entities": 0.85,
"wiki-comparisons": 0.85,
"wiki-raw": 0.85,
"session": 0.70,
}
CONFIDENCE_DEFAULT = 0.75
IMPORTANCE_SCORE = 0.5
ARCHIVED = False
def log(msg: str) -> None:
"""Append timestamped message to log."""
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
line = f"[backfill-decay] {ts} {msg}"
print(line)
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a") as f:
f.write(line + "\n")
def scroll_all() -> list[dict]:
"""Scroll entire collection, returning all points with payloads."""
points = []
offset = None
while True:
body = {"limit": SCROLL_LIMIT, "with_payload": True, "with_vector": False}
if offset:
body["offset"] = offset
req = Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points/scroll",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
try:
with urlopen(req, timeout=30) as resp:
data = json.loads(resp.read().decode())
except URLError as e:
log(f"ERROR: scroll failed: {e}")
break
result = data.get("result", {})
batch = result.get("points", [])
points.extend(batch)
offset = result.get("next_page_offset")
if offset is None or len(batch) == 0:
break
return points
def resolve_timestamp(point: dict) -> str:
"""Return ISO 8601 string for created_at/last_accessed_at.
Session points: use payload.timestamp (Unix epoch float).
Wiki points: locate file via payload.filename file mtime.
Fallback: now().
"""
pl = point.get("payload", {})
ts_raw = pl.get("timestamp")
if ts_raw is not None:
try:
ts_float = float(ts_raw)
return datetime.fromtimestamp(ts_float, tz=timezone.utc).isoformat()
except (ValueError, TypeError, OSError):
pass
# Wiki point — resolve filename to filesystem path
filename = pl.get("filename", "")
source = pl.get("source", "")
if filename and source.startswith("wiki-"):
# Map source to subfolder: wiki-concepts → concepts, wiki-entities → entities, etc.
subfolder = source.replace("wiki-", "")
candidate = VAULT_ROOT / "wiki" / subfolder / f"{filename}.md"
try:
mtime = candidate.stat().st_mtime
return datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat()
except OSError:
pass
# Absolute fallback
return datetime.now(timezone.utc).isoformat()
def compute_confidence(point: dict) -> float:
"""Heuristic confidence_score by source."""
source = point.get("payload", {}).get("source", "")
return CONFIDENCE_BY_SOURCE.get(source, CONFIDENCE_DEFAULT)
def needs_backfill(point: dict) -> list[str]:
"""Return list of fields this point is missing (that we backfill)."""
TARGETS = ["created_at", "last_accessed_at", "importance_score", "confidence_score", "archived"]
pl = point.get("payload", {})
return [f for f in TARGETS if f not in pl]
def build_payload(point: dict) -> dict:
"""Build the payload fragment to backfill for this point."""
ts = resolve_timestamp(point)
return {
"created_at": ts,
"last_accessed_at": ts,
"importance_score": IMPORTANCE_SCORE,
"confidence_score": compute_confidence(point),
"archived": ARCHIVED,
}
def upsert_batch(point_ids: list[str], payload: dict) -> bool:
"""Update payload for a batch of points via REST API. Fail-open."""
body = {"payload": payload, "points": point_ids}
req = Request(
f"{QDRANT_URL}/collections/{COLLECTION}/points/payload",
data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"},
)
try:
with urlopen(req, timeout=30) as resp:
result = json.loads(resp.read().decode())
return result.get("status") == "ok"
except URLError as e:
log(f"ERROR: upsert batch failed: {e}")
return False
def run(dry_run: bool = True, pilot: int = 0, commit: bool = False):
"""Main execution."""
mode = "DRY-RUN" if dry_run else "COMMIT"
log(f"=== BACKFILL DECAY METADATA ({mode}) ===")
log(f"Collection: {COLLECTION}")
log(f"Pilot: {pilot if pilot else 'all'}")
# ── Scroll all points ──
log("Scrolling all points...")
all_points = scroll_all()
total = len(all_points)
log(f"Total points: {total}")
# ── Identify points needing backfill ──
to_update: list[tuple[str, dict]] = [] # (point_id, payload_fragment)
skipped_already_have = 0
stats_by_source = {}
for p in all_points:
missing = needs_backfill(p)
if not missing:
skipped_already_have += 1
continue
pid = str(p["id"])
pl = build_payload(p)
to_update.append((pid, pl))
src = p.get("payload", {}).get("source", "unknown")
stats_by_source[src] = stats_by_source.get(src, 0) + 1
log(f"Points needing backfill: {len(to_update)}/{total}")
log(f"Points already complete: {skipped_already_have}")
if pilot and pilot < len(to_update):
to_update = to_update[:pilot]
log(f"Pilot mode: processing first {pilot}")
# ── Stats by source ──
log("Backfill candidates by source:")
for src, count in sorted(stats_by_source.items(), key=lambda x: -x[1]):
ts_sample = ""
for pid, pl in to_update:
if pid and len(ts_sample) < 3:
p = next((pp for pp in all_points if str(pp["id"]) == pid), None)
if p and p.get("payload", {}).get("source") == src:
ts_sample = pl.get("created_at", "")[:19]
break
log(f" {src:<25} {count:>6} pts ts_sample={ts_sample}")
if dry_run:
log("DRY-RUN complete. No changes made. Use --commit to apply.")
return
# ── Commit ──
if not commit:
log("ERROR: --commit flag required for writes. Aborting.")
return
log(f"Committing in batches of {BATCH_SIZE}...")
success = 0
failed = 0
for i in range(0, len(to_update), BATCH_SIZE):
batch = to_update[i : i + BATCH_SIZE]
# Group by identical payload (same timestamp, same confidence)
# For simplicity, each point gets its own upsert — but we batch IDs
# with identical payloads where possible
payload_groups: dict[str, tuple[list[str], dict]] = {}
for pid, pl in batch:
key = json.dumps(pl, sort_keys=True)
if key not in payload_groups:
payload_groups[key] = ([], pl)
payload_groups[key][0].append(pid)
for ids, pl in payload_groups.values():
ok = upsert_batch(ids, pl)
if ok:
success += len(ids)
else:
failed += len(ids)
log(f" FAILED batch: {len(ids)} points, first id={ids[0]}")
batch_num = i // BATCH_SIZE + 1
total_batches = (len(to_update) + BATCH_SIZE - 1) // BATCH_SIZE
log(f" Batch {batch_num}/{total_batches}: {success} ok, {failed} failed")
if i + BATCH_SIZE < len(to_update):
time.sleep(0.5) # gentle rate limit
log(f"COMMIT complete. success={success}, failed={failed}")
if failed > 0:
log("WARNING: some batches failed. Check logs.")
def main():
parser = argparse.ArgumentParser(description="Backfill decay metadata for Qdrant knowledge_base")
parser.add_argument("--dry-run", action="store_true", default=True,
help="Simulate, no writes (default)")
parser.add_argument("--commit", action="store_true",
help="Actually write to Qdrant")
parser.add_argument("--pilot", type=int, default=0,
help="Process only N points (for validation)")
args = parser.parse_args()
if args.commit:
args.dry_run = False
run(dry_run=args.dry_run, pilot=args.pilot, commit=args.commit)
if __name__ == "__main__":
main()

267
scripts/bulk_wiki_ingest.py Normal file
View File

@ -0,0 +1,267 @@
#!/usr/bin/env python3
"""
Bulk ingest script populates the Qdrant knowledge_base with all wiki content.
Phase A: one-shot of existing files.
"""
import os
import re
import sys
import json
import time
import uuid
from pathlib import Path
from datetime import datetime, timezone
from collections import Counter
import aiohttp
import asyncio
# ─── Config ────────────────────────────────────────────────────────────────
OPENROUTER_KEY = os.environ.get("OPENROUTER_API_KEY")
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
COLLECTION = os.environ.get("QDRANT_COLLECTION", os.environ.get("COLLECTION_NAME", "knowledge_base"))
WIKI_ROOT = Path(os.environ.get("WIKI_ROOT", "."))
EMBEDDING_MODEL = "qwen/qwen3-embedding-8b"
EMBEDDING_DIMS = 4096
MAX_TEXT_LEN = 8000 # truncate text for embedding (model context limit)
BATCH_SIZE = 8 # parallel embedding requests
RATE_LIMIT_SLEEP = 0.5 # seconds between batches
if not OPENROUTER_KEY:
print("❌ OPENROUTER_API_KEY not found in environment")
sys.exit(1)
print(f"📁 Wiki root: {WIKI_ROOT}")
print(f"🎯 Collection: {COLLECTION}")
print(f"🔑 OpenRouter: configured")
# ─── Find all .md files ───────────────────────────────────────────────────
md_files = sorted(WIKI_ROOT.rglob("*.md"))
print(f"📄 .md files found: {len(md_files)}")
# ─── Helpers ──────────────────────────────────────────────────────────────
def parse_frontmatter(text: str) -> tuple[dict, str]:
"""Extract YAML frontmatter and return (metadata, body)."""
if text.startswith("---"):
parts = text.split("---", 2)
if len(parts) >= 3:
try:
import yaml
meta = yaml.safe_load(parts[1])
body = parts[2].strip()
return (meta if isinstance(meta, dict) else {}), body
except Exception:
pass
return {}, text
def get_source_tag(path: Path) -> str:
"""Derive source tag from path relative to wiki root."""
rel = path.relative_to(WIKI_ROOT)
parts = rel.parts
if len(parts) > 1:
return f"wiki-{parts[0]}"
return "wiki-root"
def get_tags_from_frontmatter(meta: dict) -> list[str]:
"""Extract tags from frontmatter."""
tags = meta.get("tags", [])
if isinstance(tags, str):
tags = [t.strip() for t in tags.split(",")]
return tags if isinstance(tags, list) else []
async def get_embedding(session: aiohttp.ClientSession, text: str) -> list[float] | None:
"""Generate embedding via OpenRouter."""
payload = {
"model": EMBEDDING_MODEL,
"input": text[:MAX_TEXT_LEN],
"dimensions": EMBEDDING_DIMS,
}
try:
async with session.post(
"https://openrouter.ai/api/v1/embeddings",
headers={
"Authorization": f"Bearer {OPENROUTER_KEY}",
"Content-Type": "application/json",
},
json=payload,
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
body = await resp.text()
print(f"⚠️ Embedding HTTP {resp.status}: {body[:200]}")
return None
data = await resp.json()
return data["data"][0]["embedding"]
except Exception as e:
print(f"⚠️ Embedding error: {e}")
return None
async def upsert_to_qdrant(session: aiohttp.ClientSession, points: list[dict]) -> bool:
"""Upsert batch of points into Qdrant."""
try:
async with session.put(
f"{QDRANT_URL}/collections/{COLLECTION}/points",
headers={"Content-Type": "application/json"},
json={"points": points},
timeout=aiohttp.ClientTimeout(total=30),
) as resp:
if resp.status != 200:
body = await resp.text()
print(f"⚠️ Qdrant HTTP {resp.status}: {body[:200]}")
return False
return True
except Exception as e:
print(f"⚠️ Qdrant error: {e}")
return False
# ─── Main processing ──────────────────────────────────────────────────────
async def main():
stats = Counter({"ok": 0, "fail": 0, "skip": 0, "empty": 0})
errors = []
processed = 0
total = len(md_files)
connector = aiohttp.TCPConnector(limit=20)
async with aiohttp.ClientSession(connector=connector) as session:
# Check collection
async with session.get(f"{QDRANT_URL}/collections/{COLLECTION}") as r:
if r.status != 200:
print(f"❌ Collection {COLLECTION} does not exist!")
sys.exit(1)
print("\n🚀 Starting ingestion in batches...\n")
batch = []
for idx, path in enumerate(md_files, 1):
text = path.read_text(encoding="utf-8", errors="replace")
if not text.strip():
stats["empty"] += 1
continue
meta, body = parse_frontmatter(text)
source = get_source_tag(path)
tags = get_tags_from_frontmatter(meta)
# Additional tag from folder
folder_tag = source.replace("wiki-", "")
if folder_tag not in tags:
tags.append(folder_tag)
# Title from frontmatter or filename
title = meta.get("title", path.stem)
# Text for embedding: title + body (without frontmatter)
embed_text = f"{title}\n\n{body}"[:MAX_TEXT_LEN]
batch.append({
"idx": idx,
"path": str(path),
"title": title,
"source": source,
"tags": tags,
"embed_text": embed_text,
"meta": meta,
})
if len(batch) >= BATCH_SIZE or idx == total:
# Generate embeddings in parallel
embed_tasks = [get_embedding(session, b["embed_text"]) for b in batch]
vectors = await asyncio.gather(*embed_tasks)
# Prepare Qdrant points
points = []
for b, vec in zip(batch, vectors):
if vec is None:
stats["fail"] += 1
errors.append(f"Embedding failed: {b['path']}")
continue
# Heuristic importance_score based on path/name
importance_score = 0.5
path_str_lower = b["path"].lower()
if any(k in path_str_lower for k in ["architecture", "core", "important"]):
importance_score = 0.7
if any(t.lower() in ["important", "critical"] for t in b["tags"]):
importance_score = 0.8
if any(k in path_str_lower for k in ["draft", "temp", "old"]):
importance_score = 0.2
now_iso = datetime.now(timezone.utc).isoformat()
point = {
"id": str(uuid.uuid4()),
"vector": {"dense": vec},
"payload": {
"text": b["embed_text"],
"source": b["source"],
"tags": b["tags"],
"created_at": now_iso,
"reflection_count": 0,
"last_reflected": None,
"file_path": b["path"],
"title": b["title"],
"word_count": len(b["embed_text"].split()),
# ── Lineage fields (Phase 1)
"lineage_id": None,
"generation_model": None,
"generation_context_hash": None,
"retrieved_chunk_ids": None,
# ── Decay fields (Phase 2)
"decay_score": 1.0,
"last_accessed_at": now_iso,
"importance_score": importance_score,
"source_type": "human",
"confidence_score": 1.0,
"archived": False,
},
}
points.append(point)
# Upsert
if points:
ok = await upsert_to_qdrant(session, points)
if ok:
stats["ok"] += len(points)
else:
stats["fail"] += len(points)
for p in points:
errors.append(f"Qdrant upsert failed: {p['payload']['file_path']}")
processed += len(batch)
batch = []
# Progress
pct = (processed / total) * 100
print(f" [{processed}/{total}] {pct:.1f}% | ✅ {stats['ok']} | ⚠️ {stats['fail']} | ⏭️ {stats['skip']} | 🈳 {stats['empty']}")
# Rate limit breathing
await asyncio.sleep(RATE_LIMIT_SLEEP)
# ─── Final report ───────────────────────────────────────────────────
print("\n" + "=" * 60)
print("📊 INGESTION REPORT")
print("=" * 60)
print(f" Total files: {total}")
print(f" Ingested (ok): {stats['ok']}")
print(f" Failures: {stats['fail']}")
print(f" Empty: {stats['empty']}")
print(f" Success rate: {(stats['ok']/max(total-stats['empty'],1)*100):.1f}%")
print(f"\n ⏱️ Finished: {datetime.now(timezone.utc).isoformat()}")
if errors:
print(f"\n ⚠️ First errors ({min(10, len(errors))} of {len(errors)}):")
for e in errors[:10]:
print(f" - {e}")
# Verify final count
async with aiohttp.ClientSession() as s:
async with s.get(f"{QDRANT_URL}/collections/{COLLECTION}") as r:
data = await r.json()
final_count = data.get("result", {}).get("points_count", "?")
print(f"\n 📦 Points in collection: {final_count}")
print("\n✅ Bulk ingest complete.")
return stats
if __name__ == "__main__":
stats = asyncio.run(main())
sys.exit(0 if stats["fail"] == 0 else 1)

715
scripts/context_enhancer.py Executable file
View File

@ -0,0 +1,715 @@
#!/usr/bin/env python3
"""
Context Enhancer HYBRID search (semantic + BM25) on knowledge_base_hybrid
for prompt enrichment.
Runs as a synchronous function (fast, <1s) before each Hermes response.
If Qdrant is offline or embedding fails, returns "" (fail-open).
Usage:
python3 context_enhancer.py "your query here"
python3 context_enhancer.py --top-k 5 --threshold 0.50 "deploy docker"
python3 context_enhancer.py --hybrid-off "your query here" # forces dense-only
"""
import os
import sys
import json
import uuid
import hashlib
import sqlite3
import subprocess
import requests
import argparse
import re
import glob
from typing import List, Dict, Optional, Tuple
from pathlib import Path
import time
from datetime import datetime, timezone
# ─── Config ────────────────────────────────────────────────────────────────
OPENROUTER_KEY = os.environ.get("OPENROUTER_API_KEY")
if not OPENROUTER_KEY:
env_path = Path.home() / ".env"
if env_path.exists():
for line in env_path.read_text().splitlines():
if line.startswith("OPENROUTER_API_KEY="):
OPENROUTER_KEY = line.split("=", 1)[1].strip().strip('"')
break
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
COLLECTION = os.environ.get("QDRANT_COLLECTION", "knowledge_base")
EMBEDDING_MODEL = "qwen/qwen3-embedding-8b"
TOP_K_DEFAULT = 3
SCORE_THRESHOLD_DEFAULT = 0.55
MAX_TEXT_LEN = 8000
REQUEST_TIMEOUT = 10
# FastEmbed BM25 config
FASTEMBED_VENV = os.environ.get("FASTEMBED_VENV", "")
# Default: use current Python if venv not configured
_FASTEMBED_PYTHON = FASTEMBED_VENV if FASTEMBED_VENV else sys.executable
_FASTEMBED_SITEPKGS = os.environ.get(
"FASTEMBED_SITEPKGS",
os.path.join(os.path.dirname(sys.executable), "../lib/python3.12/site-packages")
)
BM25_MODEL = "Qdrant/bm25"
# Lineage config
LINEAGE_DB = os.environ.get(
"STATE_DB_PATH",
os.path.expanduser("~/.hermes/state.db")
)
# Telemetry config
TELEMETRY_LOG = os.environ.get(
"TELEMETRY_LOG_PATH",
os.path.expanduser("~/.hermes/logs/query-telemetry.jsonl")
)
TELEMETRY_MAX_BYTES = 10 * 1024 * 1024 # 10MB rotation
# ─── Lineage Registration ───────────────────────────────────────────────────
def register_lineage(
session_id: str,
query: str,
retrieved_chunk_ids: List[str],
generation_context_hash: str,
generation_model: str = "unknown",
) -> Optional[str]:
"""
Register generation provenance in the lineage DB.
Fail-open: if it fails, log error and return None. Never breaks the critical path.
"""
lineage_id = str(uuid.uuid4())
try:
with sqlite3.connect(LINEAGE_DB) as conn:
conn.execute(
"""
INSERT INTO lineage (lineage_id, session_id, query, retrieved_chunk_ids,
generation_model, generation_context_hash, created_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
""",
(lineage_id, session_id, query,
json.dumps(retrieved_chunk_ids, ensure_ascii=False),
generation_model, generation_context_hash)
)
conn.commit()
return lineage_id
except Exception as e:
print(f"[LINEAGE-WARNING] Failed to register lineage: {e}", file=sys.stderr)
return None
# ─── Telemetry ─────────────────────────────────────────────────────────────
def _rotate_telemetry_if_needed() -> None:
"""Rotate telemetry file if >10MB. Rename to .1 and restart."""
try:
if os.path.exists(TELEMETRY_LOG) and os.path.getsize(TELEMETRY_LOG) > TELEMETRY_MAX_BYTES:
rotated = TELEMETRY_LOG + ".1"
if os.path.exists(rotated):
os.remove(rotated)
os.rename(TELEMETRY_LOG, rotated)
except Exception as e:
print(f"[TELEMETRY-WARNING] Rotation failed: {e}", file=sys.stderr)
def emit_telemetry(record: dict) -> None:
"""
Append-only JSONL for query telemetry.
Fail-open: never breaks the critical path.
"""
try:
os.makedirs(os.path.dirname(TELEMETRY_LOG), exist_ok=True)
_rotate_telemetry_if_needed()
with open(TELEMETRY_LOG, "a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception as e:
print(f"[TELEMETRY-WARNING] Failed to write telemetry: {e}", file=sys.stderr)
def estimate_tokens(text: str) -> int:
"""Simple estimate: ~1.3 tokens per word (basic heuristic)."""
return int(len(text.split()) * 1.3)
# ─── Core ───────────────────────────────────────────────────────────────────
def embed_query(text: str) -> Optional[List[float]]:
"""Generate dense embedding via OpenRouter qwen/qwen3-embedding-8b."""
if not OPENROUTER_KEY:
return None
try:
resp = requests.post(
"https://openrouter.ai/api/v1/embeddings",
headers={
"Authorization": f"Bearer {OPENROUTER_KEY}",
"Content-Type": "application/json"
},
json={
"model": EMBEDDING_MODEL,
"input": text[:MAX_TEXT_LEN]
},
timeout=REQUEST_TIMEOUT
)
resp.raise_for_status()
return resp.json()["data"][0]["embedding"]
except Exception as e:
# Fail-open: log silently, return None
print(f"[CE-ERROR] Embedding dense failed: {e}", file=sys.stderr)
return None
def embed_query_sparse(text: str) -> Optional[Tuple[List[int], List[float]]]:
"""
Generate sparse BM25 embedding via FastEmbed (subprocess in ai-lab venv).
Fail-open: if it fails, return None. Caller falls back to dense-only.
"""
try:
# Shell-quote the text to prevent injection in Python -c
import shlex
safe_text = shlex.quote(text)
result = subprocess.run(
[_FASTEMBED_PYTHON, "-c", f"""\
import os, sys
sys.path.insert(0, os.environ["FASTEMBED_SITEPKGS"])
from fastembed.sparse import SparseTextEmbedding
import json
model = SparseTextEmbedding(model_name=\\"{BM25_MODEL}\\")
sparse = list(model.embed({safe_text}))[0]
print(json.dumps({{\\"indices\\": sparse.indices.tolist(), \\"values\\": sparse.values.tolist()}}))
"""],
capture_output=True, text=True, timeout=15
)
data = json.loads(result.stdout.strip())
return data["indices"], data["values"]
except Exception as e:
print(f"[CE-ERROR] Embedding sparse failed: {e}", file=sys.stderr)
return None
def search_knowledge_base(
dense_vector: List[float],
sparse_vector: Optional[Tuple[List[int], List[float]]] = None,
top_k: int = TOP_K_DEFAULT,
score_threshold: float = SCORE_THRESHOLD_DEFAULT
) -> List[Dict]:
"""
Hybrid search in Qdrant: dense (semantic) + sparse (BM25 keyword)
via prefetch + RRF. If sparse fails, fall back to dense-only.
"""
try:
if sparse_vector is not None:
# Hybrid: prefetch dense + prefetch sparse → RRF
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/query",
headers={"Content-Type": "application/json"},
json={
"prefetch": [
{"query": dense_vector, "using": "dense", "limit": top_k * 3},
{"query": {"indices": sparse_vector[0], "values": sparse_vector[1]},
"using": "sparse", "limit": top_k * 3},
],
"query": {"fusion": "rrf"},
"limit": top_k * 2,
"with_payload": True,
},
timeout=REQUEST_TIMEOUT + 5,
)
else:
# Fallback: dense-only (collections with compatible named vectors)
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/query",
headers={"Content-Type": "application/json"},
json={
"query": dense_vector,
"using": "dense",
"limit": top_k,
"with_payload": True,
},
timeout=REQUEST_TIMEOUT,
)
resp.raise_for_status()
data = resp.json()
results = []
raw_results = data.get("result", {})
# API /points/search returns direct list; API /points/query returns {"points": [...]}
points = raw_results if isinstance(raw_results, list) else raw_results.get("points", [])
for r in points:
score = r.get("score", 0)
if score < score_threshold:
continue
payload = r.get("payload", {})
results.append({
"id": r.get("id", "unknown"),
"score": score,
"title": payload.get("title", "Untitled"),
"content_preview": (payload.get("text", "") or "")[:400],
"source": payload.get("source", "unknown"),
"tags": payload.get("tags", [])
})
return results
except Exception as e:
print(f"[CE-ERROR] Qdrant search failed: {e}", file=sys.stderr)
return []
# ─── Fallback: Lexical Search in Vault ──────────────────────────────────────
def tokenize_query(text: str) -> List[str]:
"""Tokenize query into relevant terms (lowercase, alphanumeric)."""
# Remove basic Portuguese/English stopwords
stopwords = {"o", "a", "os", "as", "um", "uma", "de", "da", "do", "em", "no", "na", "para", "com", "por", "que", "se", "e", "ou", "mas", "the", "a", "an", "is", "are", "was", "were", "be", "been", "have", "has", "had", "do", "does", "did", "will", "would", "could", "should"}
tokens = re.findall(r'\b[a-zA-Z0-9]+\b', text.lower())
return [t for t in tokens if t not in stopwords and len(t) > 2]
def lexical_search_in_vault(
query_terms: List[str],
top_k: int = TOP_K_DEFAULT,
vault_root: str = os.environ.get("WIKI_PATH", os.path.expanduser("~/vault/wiki"))
) -> List[Dict]:
"""
Lexical search in .md files under vault/wiki/.
Rank by term match density / document size.
Return top-k simulated chunks (or entire file if small).
"""
if not query_terms:
return []
md_files = glob.glob(f"{vault_root}/**/*.md", recursive=True)
if not md_files:
return []
scored = []
for filepath in md_files:
try:
text = Path(filepath).read_text(encoding="utf-8", errors="replace").lower()
if not text:
continue
matches = 0
for term in query_terms:
matches += text.count(term)
if matches == 0:
continue
# Heuristic: matches / sqrt(word_count) — favors concise documents
word_count = max(1, len(text.split()))
density = matches / (word_count ** 0.5) # sqrt(word_count) to not over-penalize medium texts
scored.append({
"filepath": filepath,
"matches": matches,
"word_count": word_count,
"density": density,
"title": Path(filepath).stem,
"text": text[:2000], # truncate for return
})
except Exception:
continue
if not scored:
return []
# Sort by descending density
scored.sort(key=lambda x: x["density"], reverse=True)
results = []
for i, item in enumerate(scored[:top_k]):
# Simulate a result chunk
results.append({
"id": f"lexical-{hashlib.md5(item['filepath'].encode()).hexdigest()[:16]}",
"score": round(min(1.0, item["density"]), 2),
"title": item["title"],
"content_preview": item["text"][:400],
"source": f"vault-{item['filepath'].replace(vault_root, '').lstrip('/')[:40]}",
"tags": ["fallback", "lexical"],
"fallback_level": "lexical",
})
if results:
print(f"[CE-FALLBACK] Lexical search returned {len(results)} results from vault", file=sys.stderr)
return results
# ─── Fallback: SQLite Keyword Search ────────────────────────────────────────
def sqlite_keyword_search(
query_terms: List[str],
top_k: int = TOP_K_DEFAULT
) -> List[Dict]:
"""
Search terms in the lineage table (query field) and other state tables.
Last resort does not replace vault.
"""
if not query_terms:
return []
try:
with sqlite3.connect(LINEAGE_DB) as conn:
conn.row_factory = sqlite3.Row
c = conn.cursor()
results = []
# Search in lineage.query
placeholders = " OR ".join(["query LIKE ?"] * len(query_terms))
params = [f"%{term}%" for term in query_terms]
c.execute(
f"""
SELECT lineage_id, session_id, query, generation_context_hash, created_at
FROM lineage
WHERE {placeholders}
ORDER BY created_at DESC
LIMIT ?
""",
params + [top_k * 2]
)
for row in c.fetchall():
results.append({
"id": f"sqlite-{row['lineage_id'][:16]}",
"score": 0.5,
"title": f"Lineage {row['lineage_id'][:8]}...",
"content_preview": (row["query"] or "")[:400],
"source": f"sqlite-history-{row['session_id']}",
"tags": ["fallback", "sqlite"],
"fallback_level": "sqlite",
})
if results:
print(f"[CE-FALLBACK] SQLite keyword search returned {len(results)} results", file=sys.stderr)
return results[:top_k]
except Exception as e:
print(f"[CE-FALLBACK] SQLite search failed: {e}", file=sys.stderr)
return []
# ─── Fallback Wrapper ──────────────────────────────────────────────────────
def search_with_fallback(
dense_vector: Optional[List[float]] = None,
sparse_vector: Optional[Tuple[List[int], List[float]]] = None,
query_text: str = "",
top_k: int = TOP_K_DEFAULT,
score_threshold: float = SCORE_THRESHOLD_DEFAULT
) -> Tuple[List[Dict], str, float, float]:
"""
4-level fallback cascade:
1. Hybrid (dense + sparse + RRF) normal mode
2. Dense-only if sparse fails or is None
3. Lexical-only if Qdrant goes down (ConnectionError, Timeout)
4. SQLite keyword if vault is inaccessible
Returns (results, fallback_level, qdrant_latency_ms, fallback_latency_ms).
"""
fallback_level = "hybrid"
qdrant_latency_ms = 0.0
t0 = time.perf_counter()
# Level 1 or 2: Qdrant (hybrid if sparse available, otherwise dense-only)
try:
t_q0 = time.perf_counter()
if sparse_vector is not None:
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/query",
headers={"Content-Type": "application/json"},
json={
"prefetch": [
{"query": dense_vector, "using": "dense", "limit": top_k * 3},
{"query": {"indices": sparse_vector[0], "values": sparse_vector[1]},
"using": "sparse", "limit": top_k * 3},
],
"query": {"fusion": "rrf"},
"limit": top_k * 2,
"with_payload": True,
},
timeout=REQUEST_TIMEOUT + 5,
)
fallback_level = "hybrid"
else:
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/query",
headers={"Content-Type": "application/json"},
json={
"query": dense_vector,
"using": "dense",
"limit": top_k,
"with_payload": True,
},
timeout=REQUEST_TIMEOUT,
)
fallback_level = "dense-only"
resp.raise_for_status()
qdrant_latency_ms = (time.perf_counter() - t_q0) * 1000
data = resp.json()
results = []
raw_results = data.get("result", {})
points = raw_results if isinstance(raw_results, list) else raw_results.get("points", [])
for r in points:
score = r.get("score", 0)
if score < score_threshold:
continue
payload = r.get("payload", {})
results.append({
"id": r.get("id", "unknown"),
"score": score,
"title": payload.get("title", "Untitled"),
"content_preview": (payload.get("text", "") or "")[:400],
"source": payload.get("source", "unknown"),
"tags": payload.get("tags", [])
})
if results:
fallback_latency_ms = (time.perf_counter() - t0) * 1000
return results, fallback_level, qdrant_latency_ms, fallback_latency_ms
except requests.exceptions.ConnectionError:
print("[CE-FALLBACK] Qdrant unavailable (ConnectionError), falling back to lexical search.", file=sys.stderr)
except requests.exceptions.Timeout:
print("[CE-FALLBACK] Qdrant timeout, falling back to lexical search.", file=sys.stderr)
except Exception as e:
# Unexpected Qdrant error (e.g. SparseIndexError, 5xx, etc.)
# If sparse_vector existed, it might be a sparse error — try dense-only
if sparse_vector is not None and "sparse" in str(e).lower():
print(f"[CE-FALLBACK] Sparse index failed ('{e}'), trying dense-only...", file=sys.stderr)
try:
t_q2 = time.perf_counter()
resp = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/search",
headers={"Content-Type": "application/json"},
json={
"vector": dense_vector,
"using": "dense",
"limit": top_k,
"with_payload": True,
},
timeout=REQUEST_TIMEOUT,
)
resp.raise_for_status()
qdrant_latency_ms = (time.perf_counter() - t_q2) * 1000
data = resp.json()
results = []
raw_results = data.get("result", {})
points = raw_results if isinstance(raw_results, list) else raw_results.get("points", [])
for r in points:
score = r.get("score", 0)
if score < score_threshold:
continue
payload = r.get("payload", {})
results.append({
"id": r.get("id", "unknown"),
"score": score,
"title": payload.get("title", "Untitled"),
"content_preview": (payload.get("text", "") or "")[:400],
"source": payload.get("source", "unknown"),
"tags": payload.get("tags", [])
})
if results:
fallback_latency_ms = (time.perf_counter() - t0) * 1000
print("[CE-FALLBACK] Dense-only worked after sparse error.", file=sys.stderr)
return results, "dense-only", qdrant_latency_ms, fallback_latency_ms
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
print("[CE-FALLBACK] Qdrant also unavailable for dense-only — lexical fallback.", file=sys.stderr)
except Exception as e2:
print(f"[CE-FALLBACK] Dense-only also failed: {e2}", file=sys.stderr)
else:
print(f"[CE-FALLBACK] Qdrant general error ({e}), falling back to lexical.", file=sys.stderr)
# Level 3: Lexical search in vault
terms = tokenize_query(query_text)
lexical_results = lexical_search_in_vault(terms, top_k=top_k)
if lexical_results:
fallback_latency_ms = (time.perf_counter() - t0) * 1000
return lexical_results, "lexical", qdrant_latency_ms, fallback_latency_ms
# Level 4: SQLite keyword search
sqlite_results = sqlite_keyword_search(terms, top_k=top_k)
if sqlite_results:
fallback_latency_ms = (time.perf_counter() - t0) * 1000
return sqlite_results, "sqlite", qdrant_latency_ms, fallback_latency_ms
# Nothing worked
fallback_latency_ms = (time.perf_counter() - t0) * 1000
print("[CE-FALLBACK] All fallback levels exhausted.", file=sys.stderr)
return [], "none", qdrant_latency_ms, fallback_latency_ms
def update_last_accessed_at(chunk_ids: list) -> None:
"""
Update last_accessed_at on chunks returned by search.
Reset decay for chunks that are actually being used.
Fail-open: never breaks the critical query path.
"""
if not chunk_ids:
return
try:
now = datetime.now().astimezone().isoformat()
requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/payload",
headers={"Content-Type": "application/json"},
json={
"points": chunk_ids,
"payload": {"last_accessed_at": now},
},
timeout=5,
)
except Exception as e:
print(f"[CE-WARNING] Failed to update last_accessed_at: {e}", file=sys.stderr)
def enhance_prompt(
user_message: str,
top_k: int = TOP_K_DEFAULT,
score_threshold: float = SCORE_THRESHOLD_DEFAULT,
format_mode: str = "markdown",
hybrid: bool = True,
session_id: Optional[str] = None,
generation_model: str = "unknown",
) -> str:
"""
Return a context block with relevant vault content (hybrid dense+BM25),
or an empty string if nothing relevant found / services offline.
Register lineage with chunk provenance and generated context hash.
"""
# Resolve session_id
if session_id is None:
session_id = os.environ.get("HERMES_SESSION_ID", "standalone")
# Skip very short or irrelevant queries
if len(user_message.strip()) < 5:
return ""
# Very short or social query — skip
social_keywords = {"hi", "hello", "hey", "yo", "sup", "ok", "thanks", "bye", "okay", "great"}
if user_message.strip().lower() in social_keywords:
return ""
dense_vector = embed_query(user_message)
sparse_vector = None
if hybrid:
sparse_vector = embed_query_sparse(user_message)
# Fallback cascade: hybrid → dense-only → lexical → sqlite
hits, fallback_level, qdrant_latency_ms, fallback_latency_ms = search_with_fallback(
dense_vector=dense_vector,
sparse_vector=sparse_vector,
query_text=user_message,
top_k=top_k,
score_threshold=score_threshold
)
# If empty and we had no embedding, try pure lexical
if not hits and dense_vector is None:
terms = tokenize_query(user_message)
lexical_results = lexical_search_in_vault(terms, top_k=top_k)
if lexical_results:
hits = lexical_results
fallback_level = "lexical"
if not hits:
# Emit telemetry even when empty (records that search ran and returned nothing)
emit_telemetry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"session_id": session_id,
"query": user_message,
"retrieval_mode": fallback_level,
"retrieved_chunk_ids": [],
"semantic_scores": [],
"rerank_scores": [],
"final_context_tokens": 0,
"fallback_level": {"hybrid": 0, "dense-only": 1, "lexical": 2, "sqlite": 3, "none": -1}.get(fallback_level, -1),
"llm_response_quality": None,
"qdrant_latency_ms": round(qdrant_latency_ms, 2),
"fallback_latency_ms": round(fallback_latency_ms, 2),
})
return ""
# Update last_accessed_at for returned chunks (reset decay on real usage)
retrieved_chunk_ids = [h["id"] for h in hits]
update_last_accessed_at(retrieved_chunk_ids)
if format_mode == "markdown":
lines = ["\n## Relevant Context from Vault\n"]
for i, h in enumerate(hits, 1):
lines.append(f"### [{i}] {h['title']} (score: {h['score']:.2f})")
lines.append(f"- **Source:** `{h['source']}` | Tags: {', '.join(str(t) for t in h['tags'])}")
lines.append(f"- **Excerpt:** {h['content_preview']}...")
lines.append("")
context_str = "\n".join(lines)
elif format_mode == "compact":
parts = []
for h in hits:
parts.append(f"[{h['title'][:40]}] (s:{h['score']:.2f})")
context_str = "\n".join(parts)
else:
context_str = json.dumps(hits, indent=2, ensure_ascii=False)
generation_context_hash = hashlib.sha256(context_str.encode("utf-8")).hexdigest()[:32]
register_lineage(
session_id=session_id,
query=user_message,
retrieved_chunk_ids=retrieved_chunk_ids,
generation_context_hash=generation_context_hash,
generation_model=generation_model,
)
# Extract scores for telemetry
semantic_scores = [h.get("score", 0) for h in hits]
# For rerank_scores, use the same scores (internal Qdrant RRF, not exposed via simple API)
rerank_scores = semantic_scores[:]
final_context_tokens = estimate_tokens(context_str)
emit_telemetry({
"timestamp": datetime.now(timezone.utc).isoformat(),
"session_id": session_id,
"query": user_message,
"retrieval_mode": fallback_level,
"retrieved_chunk_ids": retrieved_chunk_ids,
"semantic_scores": semantic_scores,
"rerank_scores": rerank_scores,
"final_context_tokens": final_context_tokens,
"fallback_level": {"hybrid": 0, "dense-only": 1, "lexical": 2, "sqlite": 3, "none": -1}.get(fallback_level, -1),
"llm_response_quality": None,
"qdrant_latency_ms": round(qdrant_latency_ms, 2),
"fallback_latency_ms": round(fallback_latency_ms, 2),
})
return context_str
def main():
parser = argparse.ArgumentParser(description="Context Enhancer — Hybrid search in knowledge_base")
parser.add_argument("query", nargs="?", help="User message/query")
parser.add_argument("--top-k", type=int, default=TOP_K_DEFAULT, help=f"Number of results (default: {TOP_K_DEFAULT})")
parser.add_argument("--threshold", type=float, default=SCORE_THRESHOLD_DEFAULT, help=f"Minimum score (default: {SCORE_THRESHOLD_DEFAULT})")
parser.add_argument("--format", choices=["markdown", "compact", "json"], default="markdown", help="Output format")
parser.add_argument("--silent", action="store_true", help="Silent — return empty on error")
parser.add_argument("--hybrid-off", action="store_true", help="Force dense-only (ignore BM25)")
args = parser.parse_args()
if not args.query:
args.query = sys.stdin.read().strip() or "how ARQ worker works"
try:
result = enhance_prompt(
args.query,
top_k=args.top_k,
score_threshold=args.threshold,
format_mode=args.format,
hybrid=not args.hybrid_off,
generation_model=os.environ.get("HERMES_MODEL", "unknown"),
)
print(result)
except Exception as e:
if not args.silent:
print(f"[CE-ERROR] {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

284
scripts/decay_scanner.py Executable file
View File

@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""
decay_scanner.py
Selective archiving script for low-importance AI-generated chunks.
Runs via weekly cron (0 3 * * 0).
Rules:
- source_type in ["human", "procedural"] exempt (never archive)
- importance_score >= 0.7 exempt
- archived == True skip (already archived)
- half_life: 90d if importance_score >= 0.3, else 30d
- decay_score < 0.1:
- If confidence_score >= 0.7 alert (report, don't archive)
- Otherwise archive (archived = True)
- gabi_* collections are completely ignored
Usage:
python3 decay_scanner.py [--collection knowledge_base_hybrid] [--dry-run]
"""
import os
import sys
import json
import math
import argparse
import requests
from datetime import datetime, timezone
from pathlib import Path
# ─── Config ────────────────────────────────────────────────────────────────
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
COLLECTION = os.environ.get("QDRANT_COLLECTION", "knowledge_base")
SCROLL_LIMIT = 100 # Qdrant pagination
LOG_DIR = Path(os.environ.get("HERMES_LOGS_DIR", str(Path.home() / ".hermes" / "logs")))
LOG_FILE = LOG_DIR / "decay_scanner.log"
# ─── Helpers ──────────────────────────────────────────────────────────────
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def calculate_decay_score(last_accessed_at: str, importance_score: float) -> float:
"""
Calculate exponential decay: score = exp(-ln(2) * age_days / half_life).
More important chunks persist longer (larger half-lives).
"""
try:
last = datetime.fromisoformat(last_accessed_at.replace("Z", "+00:00"))
except (ValueError, TypeError):
# If timestamp is invalid, assume now (hasn't decayed yet)
return 1.0
now = datetime.now(timezone.utc)
age_days = max(0, (now - last).total_seconds() / 86400)
# Fix: LARGER half-life for more important chunks
if importance_score >= 0.3:
half_life = 90 # medium/high chunks → 90 days
else:
half_life = 30 # low chunks → 30 days
decay_score = math.exp(-math.log(2) * age_days / half_life)
return decay_score
def ensure_log_dir():
"""Create log directory if it doesn't exist."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
def log_message(msg: str):
"""Log to stdout and append to log file."""
ts = now_iso()
line = f"[{ts}] {msg}"
print(line)
ensure_log_dir()
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(line + "\n")
# ─── Qdrant Operations ────────────────────────────────────────────────────
def scroll_chunks(collection: str, limit: int = SCROLL_LIMIT):
"""
Generator that iterates over all points in the collection via scroll.
Avoids loading the entire collection into memory.
"""
offset = None
total_scanned = 0
while True:
payload = {
"limit": limit,
"with_payload": True,
"with_vector": False,
}
if offset is not None:
payload["offset"] = offset
try:
resp = requests.post(
f"{QDRANT_URL}/collections/{collection}/points/scroll",
headers={"Content-Type": "application/json"},
json=payload,
timeout=30,
)
resp.raise_for_status()
data = resp.json()
result = data.get("result", {})
points = result.get("points", [])
if not points:
break
for point in points:
yield point
total_scanned += 1
offset = result.get("next_page_offset")
if offset is None:
break
except Exception as e:
log_message(f"❌ Qdrant scroll error: {e}")
break
log_message(f"📊 Total chunks scanned: {total_scanned}")
def update_point_archived(point_id: str, collection: str, decay_score: float, dry_run: bool = False):
"""Update point payload: archived=True + calculated decay_score."""
if dry_run:
log_message(f" [DRY-RUN] Would archive point {point_id} (decay_score={decay_score:.4f})")
return True
try:
resp = requests.post(
f"{QDRANT_URL}/collections/{collection}/points/payload",
headers={"Content-Type": "application/json"},
json={
"points": [point_id],
"payload": {
"archived": True,
"decay_score": decay_score,
},
},
timeout=10,
)
resp.raise_for_status()
return True
except Exception as e:
log_message(f" ❌ Failed to archive point {point_id}: {e}")
return False
# ─── Main ─────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(description="Decay Scanner — Selective chunk archiving")
parser.add_argument("--collection", default=COLLECTION, help="Qdrant collection name")
parser.add_argument("--dry-run", action="store_true", help="Simulation — does not modify anything")
parser.add_argument("--threshold", type=float, default=0.1, help="Decay threshold for archiving")
args = parser.parse_args()
collection = args.collection
# Ignore gabi_* collections
if collection.startswith("gabi_"):
log_message(f"⏭️ Collection '{collection}' is exempt (gabi_*). Exiting.")
return
log_message(f"🚀 Starting decay scanner (collection={collection}, threshold={args.threshold}, dry_run={args.dry_run})")
# Metrics
stats = {
"scanned": 0,
"archived": 0,
"alerted": 0,
"skipped_human": 0,
"skipped_procedural": 0,
"skipped_high_importance": 0,
"skipped_already_archived": 0,
"failed": 0,
}
alerts = [] # List of alerts (decay < threshold but confidence >= 0.7)
for point in scroll_chunks(collection):
stats["scanned"] += 1
point_id = point.get("id")
payload = point.get("payload", {})
source_type = payload.get("source_type", "unknown")
importance_score = payload.get("importance_score", 0.5)
archived = payload.get("archived", False)
last_accessed_at = payload.get("last_accessed_at", payload.get("created_at", now_iso()))
confidence_score = payload.get("confidence_score", 1.0)
# Skip: already archived
if archived:
stats["skipped_already_archived"] += 1
continue
# Skip: human (exempt)
if source_type == "human":
stats["skipped_human"] += 1
continue
# Skip: procedural (exempt)
if source_type == "procedural":
stats["skipped_procedural"] += 1
continue
# Skip: high importance
if importance_score >= 0.7:
stats["skipped_high_importance"] += 1
continue
# Calculate decay
decay_score = calculate_decay_score(last_accessed_at, importance_score)
# Check threshold
if decay_score < args.threshold:
# Decay-confidence rule: if confidence is high, alert instead of archiving
if confidence_score >= 0.7:
stats["alerted"] += 1
alerts.append({
"point_id": point_id,
"decay_score": round(decay_score, 4),
"confidence_score": round(confidence_score, 2),
"importance_score": round(importance_score, 2),
"age_days": round((datetime.now(timezone.utc) - datetime.fromisoformat(last_accessed_at.replace("Z", "+00:00"))).total_seconds() / 86400, 1),
"reason": "decay < threshold but confidence >= 0.7 — manual review recommended",
})
log_message(f" ⚠️ ALERT: point {point_id} (decay={decay_score:.4f}, confidence={confidence_score:.2f}) — manual review recommended")
else:
# Archive
ok = update_point_archived(point_id, collection, decay_score, args.dry_run)
if ok:
stats["archived"] += 1
log_message(f" 📦 Archived: point {point_id} (decay={decay_score:.4f}, importance={importance_score:.2f})")
else:
stats["failed"] += 1
# Structured JSON report
report = {
"timestamp": now_iso(),
"collection": collection,
"threshold": args.threshold,
"dry_run": args.dry_run,
"scanned": stats["scanned"],
"archived": stats["archived"],
"alerted": stats["alerted"],
"skipped_human": stats["skipped_human"],
"skipped_procedural": stats["skipped_procedural"],
"skipped_high_importance": stats["skipped_high_importance"],
"skipped_already_archived": stats["skipped_already_archived"],
"failed": stats["failed"],
"alerts": alerts,
}
log_message("=" * 60)
log_message("📊 DECAY SCANNER REPORT")
log_message("=" * 60)
log_message(f" Scanned: {stats['scanned']}")
log_message(f" Archived: {stats['archived']}")
log_message(f" Alerts (decay+conf.): {stats['alerted']}")
log_message(f" Skipped human: {stats['skipped_human']}")
log_message(f" Skipped procedural: {stats['skipped_procedural']}")
log_message(f" Skipped high imp.: {stats['skipped_high_importance']}")
log_message(f" Skipped archived: {stats['skipped_already_archived']}")
log_message(f" Failures: {stats['failed']}")
log_message("=" * 60)
# JSON report to stderr (parseable)
print(json.dumps(report, ensure_ascii=False, indent=2), file=sys.stderr)
log_message("✅ Decay scanner complete.")
if __name__ == "__main__":
main()

211
scripts/dlq_manager.py Executable file
View File

@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""
DLQ Manager Reads, classifies, reports, and marks wiki ingest failures.
Usage:
python3 dlq_manager.py --report # report unreported failures
python3 dlq_manager.py --status # DLQ status summary
python3 dlq_manager.py --json # full JSON output
"""
import os
import sys
import json
import time
from datetime import datetime, timedelta
from typing import List, Dict, Optional
from dataclasses import dataclass, asdict, field
from collections import Counter
# ─── Config ────────────────────────────────────────────────────────────────
DLQ_PATH = os.environ.get("HERMES_DLQ_PATH", os.path.expanduser("~/.hermes/wiki_ingest_failures.json"))
REPORT_LOG = os.environ.get("HERMES_DLQ_REPORT_LOG", os.path.expanduser("~/.hermes/cron/output/dlq_reports.jsonl"))
REPORT_DIR = os.environ.get("HERMES_DLQ_REPORT_DIR", os.path.expanduser("~/.hermes/cron/output/quality_report"))
MAX_REPORT_HISTORY = 100 # entries in JSONL
# ─── Data Model ─────────────────────────────────────────────────────────────
@dataclass
class DLQEntry:
file: str
error: str
timestamp: str
failure_class: str = "unknown"
reported: bool = False
retry_count: int = 0
last_retry: Optional[str] = None
error_hash: str = "" # error hash for deduplication
# ─── File I/O ─────────────────────────────────────────────────────────────
def load_dlq() -> List[DLQEntry]:
if not os.path.exists(DLQ_PATH):
return []
try:
with open(DLQ_PATH, "r") as f:
data = json.load(f)
if isinstance(data, list):
return [DLQEntry(**item) for item in data]
elif isinstance(data, dict) and "failures" in data:
return [DLQEntry(**item) for item in data["failures"]]
return []
except Exception as e:
print(f"[DLQ-ERROR] Failed to load: {e}", file=sys.stderr)
return []
def save_dlq(entries: List[DLQEntry]):
tmp = DLQ_PATH + ".tmp"
with open(tmp, "w") as f:
json.dump([asdict(e) for e in entries], f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, DLQ_PATH)
# ─── Classification ─────────────────────────────────────────────────────────
def classify_error(error_msg: str) -> str:
e = error_msg.lower()
transient = ["timeout", "connection", "temporarily", "rate limit", "503", "502", "504",
"too many requests", "unavailable", "cannot", "refused", "reset"]
permanent = ["404", "not found", "invalid format", "parse error", "file not found",
"deleted", "permission denied", "encode", "utf-8", "json", "schema"]
for p in transient:
if p in e:
return "transient"
for p in permanent:
if p in e:
return "permanent"
return "unknown"
def compute_error_hash(file: str, error: str) -> str:
"""Generate a simple hash for deduplication of similar errors."""
import hashlib
return hashlib.md5(f"{file}:{error[:80]}".encode()).hexdigest()[:8]
# ─── Reporting ─────────────────────────────────────────────────────────────
def build_report(entries: List[DLQEntry]) -> Dict:
unreported = [e for e in entries if not e.reported]
total = len(entries)
if not unreported:
return {"status": "ok", "unreported_count": 0, "total": total, "report": ""}
# Classify
for e in unreported:
if e.failure_class == "unknown":
e.failure_class = classify_error(e.error)
by_class = Counter(e.failure_class for e in unreported)
by_error_short = Counter(str(e.error)[:70] for e in unreported)
by_file = Counter(os.path.basename(e.file) for e in unreported)
lines = [
f"🚨 [DLQ-ALERT] {len(unreported)} new failure(s) in ingest",
f" Total accumulated in DLQ: {total}",
"",
"By class:",
]
emoji = {"transient": "", "permanent": "💀", "unknown": ""}
for cls, count in by_class.most_common():
lines.append(f" {emoji.get(cls, '')} {cls}: {count}")
lines.append("")
lines.append("Top errors:")
for err, count in by_error_short.most_common(5):
lines.append(f" • ({count}x) {err}")
lines.append("")
lines.append("Files:")
for fname, count in by_file.most_common(10):
lines.append(f"{fname} ({count}x)")
report_text = "\n".join(lines)
return {
"status": "alert",
"unreported_count": len(unreported),
"total": total,
"by_class": dict(by_class),
"top_errors": dict(by_error_short.most_common(5)),
"report": report_text,
}
def save_report(report: Dict):
os.makedirs(REPORT_DIR, exist_ok=True)
os.makedirs(os.path.dirname(REPORT_LOG), exist_ok=True)
timestamp = datetime.now().isoformat()
# JSONL
with open(REPORT_LOG, "a") as f:
f.write(json.dumps({"timestamp": timestamp, **report}, ensure_ascii=False) + "\n")
f.flush()
os.fsync(f.fileno())
def mark_reported(entries: List[DLQEntry]):
for e in entries:
e.reported = True
def get_status_summary(entries: List[DLQEntry]) -> Dict:
total = len(entries)
unreported = len([e for e in entries if not e.reported])
by_class = Counter(e.failure_class for e in entries)
recent = [e for e in entries if datetime.now(datetime.timezone.utc) - datetime.fromisoformat(e.timestamp.replace("Z", "+00:00")).astimezone(datetime.timezone.utc) < timedelta(hours=24)]
return {
"total": total,
"unreported": unreported,
"by_class": dict(by_class),
"last_24h": len(recent),
"oldest": entries[0].timestamp if entries else None,
}
# ─── Main ───────────────────────────────────────────────────────────────────
def main():
import argparse
p = argparse.ArgumentParser(description="DLQ Manager — Auto-report of failures")
p.add_argument("--report", action="store_true", help="Generate report of unreported failures")
p.add_argument("--status", action="store_true", help="Status summary")
p.add_argument("--json", action="store_true", help="JSON output")
p.add_argument("--silent-if-ok", action="store_true", help="Silent if DLQ is ok")
args = p.parse_args()
entries = load_dlq()
if args.status:
summary = get_status_summary(entries)
if args.json:
print(json.dumps(summary, indent=2, ensure_ascii=False))
else:
print(f"DLQ status: {summary['total']} total, {summary['unreported']} unreported")
for cls, count in summary.get("by_class", {}).items():
print(f" {cls}: {count}")
return
report = build_report(entries)
if report["status"] == "ok":
msg = "[DLQ-OK] No new failures since last check."
if not args.silent_if_ok:
print(msg)
if args.json:
print(json.dumps(report, indent=2, ensure_ascii=False))
return
# Has new failures
if args.json:
print(json.dumps(report, indent=2, ensure_ascii=False))
else:
print(report["report"])
# Save and mark as reported
save_report(report)
mark_reported(entries)
save_dlq(entries)
# Exit code 1 for cron trigger
sys.exit(1)
if __name__ == "__main__":
main()

281
scripts/pre_validator.py Executable file
View File

@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""
Semantic Pre-Validator Decision linter based on the knowledge_base.
Queries the vault before I/O actions or API calls.
Usage:
python3 pre_validator.py "POST to Qdrant upsert" # should find pitfalls
python3 pre_validator.py --json "use Claude from Anthropic" # JSON output
python3 pre_validator.py --domain qdrant,api "modify docker-compose" # restrict search
Exit codes:
0 = pass/warn (action may proceed)
1 = blocked (action must be aborted)
Fail-open: if OpenRouter or Qdrant is offline, allows execution with a warning.
"""
import os
import sys
import json
import re
import requests
from typing import List, Dict, Optional
from pathlib import Path
# ─── Config ────────────────────────────────────────────────────────────────
OPENROUTER_KEY = os.environ.get("OPENROUTER_API_KEY")
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
COLLECTION = os.environ.get("QDRANT_COLLECTION", "knowledge_base")
if not OPENROUTER_KEY:
_env_path = os.environ.get("ENV_PATH", "")
if _env_path:
_env = Path(_env_path)
else:
_env = Path.home() / ".env"
if _env.exists():
for ln in _env.read_text().splitlines():
if ln.startswith("OPENROUTER_API_KEY="):
OPENROUTER_KEY = ln.split("=", 1)[1].strip().strip('"')
break
EMBEDDING_MODEL = "qwen/qwen3-embedding-8b"
TOP_K = 5
SCORE_THRESHOLD = 0.60
WARN_THRESHOLD = 0.75 # pure wiki docs need a higher score for a warning
BLOCK_SEVERITIES = {"critical", "high"}
WARN_SEVERITIES = {"medium"}
RULE_SOURCES = {"reflection", "decision", "rule", "pitfall", "insight"}
REQUEST_TIMEOUT = 10
# ─── Restriction Patterns in wiki text ─────────────────────────────────────
RESTRICTION_KEYWORDS = [
"do not use", "must not", "cannot", "never use", "avoid",
"forbidden", "not recommended", "anti-pattern", "common mistake",
"caution", "warning", "important:", "⚠️", "🚫",
"must use", "must always", "requires", "mandatory",
"keep", "do not change", "do not modify", "freeze",
]
def contains_restriction(text: str) -> bool:
"""Check whether text contains restriction/decision patterns."""
if not text:
return False
text_lower = text.lower()
return any(kw in text_lower for kw in RESTRICTION_KEYWORDS)
# ─── Domain Tag Inference ─────────────────────────────────────────────────
DOMAIN_PATTERNS = {
"docker" : ["docker", "compose", "container", "image", "dockerfile"],
"qdrant" : ["qdrant", "collection", "points", "upsert", "vector", "vectors", "embedding"],
"redis" : ["redis", "arq", "queue", "job", "worker", "broker"],
"openrouter" : ["openrouter", "embedding", "api_key", "openai", "api_base", "model"],
"hermes" : ["hermes", "config.yaml", "skill", "cron", "gateway", "cli"],
"wiki" : ["wiki", "raw/", "ingest", "vault", "obsidian", "knowledge_base"],
"webui" : ["webui", "open-webui", "frontend", "chat", "rag"],
"infra" : ["deploy", "server", "systemd", "service", "port", "host"],
"security" : ["password", "secret", "token", "auth", "permission", "sudo"],
"maas" : ["maas", "memory", "cognitive", "agent"],
}
def infer_domain_tags(description: str) -> List[str]:
d = description.lower()
found = set()
for domain, pats in DOMAIN_PATTERNS.items():
if any(p in d for p in pats):
found.add(domain)
return sorted(found)
# ─── Core ───────────────────────────────────────────────────────────────────
def embed_text(text: str) -> Optional[List[float]]:
if not OPENROUTER_KEY:
return None
try:
r = requests.post(
"https://openrouter.ai/api/v1/embeddings",
headers={
"Authorization": f"Bearer {OPENROUTER_KEY}",
"Content-Type": "application/json"
},
json={"model": EMBEDDING_MODEL, "input": text[:8000]},
timeout=REQUEST_TIMEOUT
)
r.raise_for_status()
return r.json()["data"][0]["embedding"]
except Exception as e:
print(f"[PV-ERROR] Embedding failed: {e}", file=sys.stderr)
return None
def search_knowledge_base(vector: List[float], domain_tags: List[str]) -> List[Dict]:
try:
r = requests.post(
f"{QDRANT_URL}/collections/{COLLECTION}/points/search",
headers={"Content-Type": "application/json"},
json={"vector": vector, "limit": TOP_K * 3, "with_payload": True},
timeout=REQUEST_TIMEOUT
)
r.raise_for_status()
hits = []
for item in r.json().get("result", []):
pld = item.get("payload", {})
src = str(pld.get("source", "")).lower()
sev = str(pld.get("severity", pld.get("decision_severity", "low"))).lower()
tags = [str(t).lower() for t in pld.get("tags", [])]
score = item.get("score", 0)
# If domain filters requested, require overlap
if domain_tags:
dom_low = [d.lower() for d in domain_tags]
if not set(dom_low) & set(tags):
continue
hits.append({
"id" : str(item.get("id", "")),
"score" : score,
"title" : pld.get("title", "Untitled"),
"text" : (pld.get("text", "") or "")[:400],
"source" : src,
"severity": sev,
"tags" : tags,
})
hits.sort(key=lambda x: x["score"], reverse=True)
return hits[:TOP_K]
except Exception as e:
print(f"[PV-ERROR] Qdrant search failed: {e}", file=sys.stderr)
return []
def is_rule_hit(hit: Dict) -> bool:
"""Return True if the hit contains an explicit rule (reflection/decision/rule/insight/pitfall)."""
return any(s in hit["source"] for s in RULE_SOURCES)
def classify_hit(hit: Dict, action_desc: str) -> str:
"""
Return hit category: 'block', 'warn', 'info', or 'none'.
Considers both source=reflection/decision/rule and restriction patterns
embedded in wiki document text.
"""
sev = hit.get("severity", "low")
is_rule = is_rule_hit(hit) or contains_restriction(hit.get("text", ""))
score = hit.get("score", 0)
# If text contains restriction, give it more weight
restriction_bonus = 0.08 if contains_restriction(hit.get("text", "")) else 0
effective_score = score + restriction_bonus
# Proximity: if the action term (e.g. "POST") appears near a keyword in the text
action_terms = set(action_desc.lower().split())
text_lower = (hit.get("text", "") or "").lower()
text_words = set(text_lower.split())
proximity_match = len(action_terms & text_words) > 0
# If restriction + proximity → elevate severity
has_restriction = contains_restriction(hit.get("text", "")) and proximity_match
if is_rule or has_restriction:
if sev in BLOCK_SEVERITIES or (has_restriction and effective_score >= 0.65):
return "block"
elif sev in WARN_SEVERITIES or (has_restriction and effective_score >= SCORE_THRESHOLD):
return "warn"
# For normal wiki documents, only warn if score is very high
if effective_score >= WARN_THRESHOLD:
return "warn"
if effective_score >= SCORE_THRESHOLD:
return "info"
return "none"
def validate_action(action_description: str, domain_tags: Optional[List[str]] = None) -> Dict:
try:
dom = domain_tags or infer_domain_tags(action_description)
vec = embed_text(action_description)
if vec is None:
return {"status": "pass", "blocked": False, "message": "⚠️ Validator offline. Proceeding with caution.", "action": action_description}
hits = search_knowledge_base(vec, dom)
blockers = []
warnings = []
infos = []
for h in hits:
cat = classify_hit(h, action_description)
if cat == "block":
blockers.append(h)
elif cat == "warn":
warnings.append(h)
elif cat == "info":
infos.append(h)
if blockers:
lines = [f"🚫 ACTION BLOCKED — {len(blockers)} critical rule(s) in the vault:"]
for b in blockers:
lines.append(f" • [{b['severity'].upper()}] {b['title']} (score: {b['score']:.2f})")
lines.append(f" {b['text'][:200]}...")
lines.append("")
lines.append("Override? Type 'force' (not recommended).")
return {
"status": "blocked", "blocked": True,
"blockers": blockers, "warnings": warnings,
"message": "\n".join(lines), "action": action_description, "domain": dom,
}
if warnings:
lines = [f"⚠️ {len(warnings)} warning(s) found in the vault:"]
for w in warnings:
lines.append(f" • [{w['severity'].upper()}] {w['title']} (score: {w['score']:.2f})")
lines.append(f" {w['text'][:200]}...")
return {
"status": "warn", "blocked": False,
"warnings": warnings, "infos": infos,
"message": "\n".join(lines), "action": action_description, "domain": dom,
}
if infos:
return {
"status": "info", "blocked": False,
"infos": infos,
"message": f" {len(infos)} relevant document(s), none critical.",
"action": action_description, "domain": dom,
}
return {
"status": "pass", "blocked": False,
"message": "No relevant insights found. Execution authorized.",
"action": action_description, "domain": dom,
}
except Exception as e:
return {
"status": "pass", "blocked": False,
"message": f"Validator failed ({e}). Proceeding with caution.",
"action": action_description, "domain": [],
}
# ─── Main ───────────────────────────────────────────────────────────────────
def main():
import argparse
p = argparse.ArgumentParser(description="Semantic Pre-Validator")
p.add_argument("action", nargs="?", help="Action description")
p.add_argument("--domain", help="Comma-separated domain tags")
p.add_argument("--json", action="store_true", help="JSON output")
p.add_argument("--silent", action="store_true", help="Silent — exit code only")
p.add_argument("--force-block", action="store_true", help="Force block (testing)")
args = p.parse_args()
action = args.action or sys.stdin.read().strip() or "POST to Qdrant upsert endpoint"
dom = [x.strip() for x in args.domain.split(",")] if args.domain else None
res = validate_action(action, dom)
if args.force_block:
res["blocked"] = True
res["status"] = "blocked"
if args.json:
print(json.dumps(res, indent=2, ensure_ascii=False, default=str))
elif not args.silent:
print(res["message"])
if res["blocked"]:
print("\n(Use --force-block to test validator bypass)")
sys.exit(1 if res["blocked"] else 0)
if __name__ == "__main__":
main()

219
scripts/reflection_trigger.py Executable file
View File

@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""
reflection_trigger.py
Checks whether the ARQ worker is idle (no pending/running jobs)
and dispatches a micro_reflection via ARQ enqueue. Runs via cron every 5 minutes.
Rules:
- Only triggers if there are no pending or running jobs (idle)
- Respects the max_per_hour budget (reads from env or defaults to 5)
- Enqueues ARQ job "process_micro_reflection" (function registered in the worker)
- Fail-open: if Redis/ARQ is unavailable, exits silently
- Never blocks the critical query/ingestion path
Usage (cron):
*/5 * * * * $VENV_DIR/bin/python $PROJECT_DIR/scripts/reflection_trigger.py >> $HERMES_LOG_DIR/reflection_trigger.cron.log 2>&1
"""
import os
import sys
import json
import asyncio
import argparse
from datetime import datetime, timezone
from pathlib import Path
from dotenv import load_dotenv
from arq import create_pool
from arq.connections import RedisSettings
import redis.asyncio as aioredis
# ─── Config ────────────────────────────────────────────────────────────────
ENV_PATH = Path(os.environ.get("MAA_ENV_PATH", "."))
if ENV_PATH.exists():
load_dotenv(ENV_PATH)
REDIS_HOST = os.environ.get("REDIS_HOST", "127.0.0.1")
REDIS_PORT = int(os.environ.get("REDIS_PORT", "6379"))
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
MAX_REFLECTIONS_PER_HOUR = int(os.environ.get("MICRO_REFLECTION_MAX_PER_HOUR", "5"))
redis_settings = RedisSettings(
host=REDIS_HOST,
port=REDIS_PORT,
password=REDIS_PASSWORD or None,
)
LOG_FILE = Path(
os.environ.get(
"REFLECTION_LOG_PATH",
str(Path.home() / ".hermes" / "logs" / "reflection_trigger.log")
)
)
def log_message(msg: str):
ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
line = f"[{ts}] {msg}"
try:
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass
print(line)
async def is_idle() -> bool:
"""Check whether there are any pending or running jobs in ARQ."""
try:
r = aioredis.Redis(
host=REDIS_HOST, port=REDIS_PORT,
password=REDIS_PASSWORD or None,
decode_responses=True,
)
# ARQ stores jobs in queues like 'arq:queue:default'
queue_names = ["arq:queue:default"]
qr_prefix = os.environ.get("ARQ_QUEUE_PREFIX", "arq:queue:")
if qr_prefix:
try:
found = await r.keys(f"{qr_prefix}*")
queue_names = list(found) if found else queue_names
except Exception:
pass
total_pending = 0
for qn in queue_names:
try:
total_pending += await r.llen(qn)
except Exception:
pass
# In-progress jobs: ARQ uses sets like 'arq:in-progress:...'
in_progress_keys = await r.keys("arq:in-progress:*")
total_in_progress = 0
for key in in_progress_keys:
try:
total_in_progress += await r.scard(key)
except Exception:
pass
await r.aclose()
return (total_pending + total_in_progress) == 0
except Exception as e:
log_message(f"Error checking idle status: {e}")
return False # fail-safe: if unable to verify, do not trigger
async def check_budget() -> tuple[bool, int, int]:
"""Return (allowed, used, max) based on the hourly counter in SQLite."""
try:
import sqlite3
db_path = Path(
os.environ.get(
"STATE_DB_PATH",
str(Path.home() / ".hermes" / "state.db")
)
)
hour_window = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H")
conn = sqlite3.connect(str(db_path))
c = conn.cursor()
c.execute("SELECT count FROM reflection_budget WHERE hour_window = ?", (hour_window,))
row = c.fetchone()
used = row[0] if row else 0
conn.close()
return (used < MAX_REFLECTIONS_PER_HOUR, used, MAX_REFLECTIONS_PER_HOUR)
except Exception as e:
log_message(f"Error checking budget: {e}")
return (True, 0, MAX_REFLECTIONS_PER_HOUR) # fail-open
def increment_budget():
"""Increment the reflection counter in SQLite."""
try:
import sqlite3
db_path = Path(
os.environ.get(
"STATE_DB_PATH",
str(Path.home() / ".hermes" / "state.db")
)
)
hour_window = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H")
conn = sqlite3.connect(str(db_path))
c = conn.cursor()
c.execute("""
INSERT INTO reflection_budget (hour_window, count, tokens_used)
VALUES (?, 1, 0)
ON CONFLICT(hour_window)
DO UPDATE SET count = count + 1
""", (hour_window,))
conn.commit()
conn.close()
except Exception as e:
log_message(f"Error incrementing budget: {e}")
async def trigger_micro_reflection(dry_run: bool = False) -> dict:
"""Pipeline: idle check → budget check → ARQ enqueue → increment budget."""
# 1. Idle check
idle = await is_idle()
if not idle:
return {"status": "busy", "triggered": False}
# 2. Budget check
budget_ok, used, max_ref = await check_budget()
if not budget_ok:
return {
"status": "budget_exceeded",
"used": used,
"max": max_ref,
"triggered": False,
}
# 3. Enqueue
if dry_run:
return {
"status": "would_trigger",
"triggered": False,
"used": used,
"max": max_ref,
}
try:
pool = await create_pool(redis_settings)
job = await pool.enqueue_job("process_micro_reflection")
await pool.aclose()
# 4. Budget accounting is owned by the worker after actual processing.
# NOT incremented here — the worker's increment_budget() call
# handles this, preventing double-counting.
# increment_budget()
return {
"status": "triggered",
"triggered": True,
"job_id": str(job.job_id) if job else None,
"used": used + 1,
"max": max_ref,
}
except Exception as e:
log_message(f"Error enqueuing micro_reflection: {e}")
return {"status": "error", "error": str(e), "triggered": False}
async def main():
parser = argparse.ArgumentParser(description="Reflection Trigger — idle detection")
parser.add_argument("--dry-run", action="store_true", help="Simulate, do not enqueue")
args = parser.parse_args()
result = await trigger_micro_reflection(dry_run=args.dry_run)
log_message(json.dumps(result, ensure_ascii=False))
if result.get("status") == "error":
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

272
scripts/semantic_dedup.py Executable file
View File

@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""
semantic_dedup.py
Monthly scanner for near-duplicates in knowledge_base_hybrid via cosine similarity.
Runs on the first Sunday of each month (cron: 0 3 1 * *).
WARNING: This performs O() brute-force pairwise comparisons. For large
collections (e.g. 100K+ points), this can be extremely slow and memory-heavy.
Use --max-points to limit processing, or prefer Qdrant's built-in
nearest-neighbor search on a random sample where feasible.
Rules:
- Ignores gabi_* collections
- Does not delete automatically only emits a JSON report of candidates
- Similarity threshold: 0.92 (configurable)
- Merge is handled via upserts in file_ingestion.py (pre-write dedup)
- This script does the retrospective scan of the entire collection
(capped by MAX_POINTS)
Usage:
python3 semantic_dedup.py [--collection knowledge_base_hybrid] [--threshold 0.92] [--dry-run] [--max-points 5000]
"""
import os
import sys
import json
import math
import argparse
import requests
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Dict, Tuple, Optional
# ─── Config ────────────────────────────────────────────────────────────────
QDRANT_URL = os.environ.get("QDRANT_URL", "http://localhost:6333")
COLLECTION = os.environ.get("QDRANT_COLLECTION", "knowledge_base")
SCROLL_LIMIT = 50 # Qdrant pagination (avoids timeout on large collections)
SIMILARITY_THRESHOLD = 0.92
TOP_NEIGHBORS = 10
LOG_DIR = Path(
os.environ.get("HERMES_LOG_DIR", str(Path.home() / ".hermes" / "logs"))
)
LOG_FILE = LOG_DIR / "semantic_dedup.log"
REPORT_FILE = LOG_DIR / "semantic_dedup_report.json"
# Safety cap — limit processed points to avoid O(n²) blowup on large collections
MAX_POINTS = int(os.environ.get("DEDUP_MAX_POINTS", "5000"))
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def log_message(msg: str):
ts = now_iso()
line = f"[{ts}] {msg}"
print(line)
try:
LOG_DIR.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass
# ─── Qdrant Operations ────────────────────────────────────────────────────
def scroll_all_chunks(collection: str) -> List[Dict]:
"""
Load all points from the collection, paginating via scroll.
Returns a list of {id, vector, payload}.
"""
all_chunks = []
offset = None
scanned = 0
while True:
payload = {
"limit": SCROLL_LIMIT,
"with_payload": True,
"with_vector": True,
}
if offset is not None:
payload["offset"] = offset
try:
resp = requests.post(
f"{QDRANT_URL}/collections/{collection}/points/scroll",
headers={"Content-Type": "application/json"},
json=payload,
timeout=30,
)
resp.raise_for_status()
data = resp.json()
result = data.get("result", {})
points = result.get("points", [])
if not points:
break
for point in points:
# Get only the dense vector for similarity
vector = point.get("vector")
dense = None
if isinstance(vector, dict):
dense = vector.get("dense")
elif isinstance(vector, list):
dense = vector # fallback: simple vector
if dense:
all_chunks.append({
"id": point.get("id"),
"vector": dense,
"payload": point.get("payload", {}),
})
scanned += len(points)
offset = result.get("next_page_offset")
if offset is None:
break
except Exception as e:
log_message(f"❌ Error in Qdrant scroll: {e}")
break
log_message(f"📊 Total chunks loaded: {len(all_chunks)} / {scanned} scanned")
return all_chunks
def cosine_similarity(v1: List[float], v2: List[float]) -> float:
"""Calculate cosine similarity between two vectors."""
if len(v1) != len(v2):
return 0.0
dot = sum(a * b for a, b in zip(v1, v2))
norm1 = math.sqrt(sum(a * a for a in v1))
norm2 = math.sqrt(sum(b * b for b in v2))
if norm1 == 0 or norm2 == 0:
return 0.0
return dot / (norm1 * norm2)
def find_near_duplicates(chunks: List[Dict], threshold: float = SIMILARITY_THRESHOLD) -> List[Dict]:
"""
Find near-duplicate pairs via brute-force cosine similarity.
Optimization: upper-triangular matrix comparison.
Returns list of {chunk_id_a, chunk_id_b, similarity}.
"""
n = len(chunks)
if n < 2:
return []
candidates = []
ids_seen = set() # avoid duplicates (A,B) and (B,A)
for i in range(n):
for j in range(i + 1, n):
# Fast heuristic: skip if texts differ greatly in size
text_len_i = len(chunks[i]["payload"].get("text", ""))
text_len_j = len(chunks[j]["payload"].get("text", ""))
if text_len_i > 0 and text_len_j > 0:
ratio = min(text_len_i, text_len_j) / max(text_len_i, text_len_j)
if ratio < 0.5: # Very different sizes, skip
continue
sim = cosine_similarity(chunks[i]["vector"], chunks[j]["vector"])
if sim >= threshold:
pair_key = tuple(sorted([str(chunks[i]["id"]), str(chunks[j]["id"])]))
if pair_key not in ids_seen:
ids_seen.add(pair_key)
candidates.append({
"chunk_id_a": chunks[i]["id"],
"chunk_id_b": chunks[j]["id"],
"similarity": round(sim, 6),
"source_a": chunks[i]["payload"].get("source", "unknown"),
"source_b": chunks[j]["payload"].get("source", "unknown"),
"title_a": chunks[i]["payload"].get("title", "")[:60],
"title_b": chunks[j]["payload"].get("title", "")[:60],
"text_preview_a": chunks[i]["payload"].get("text", "")[:100],
"text_preview_b": chunks[j]["payload"].get("text", "")[:100],
})
# Sort by descending similarity
candidates.sort(key=lambda x: x["similarity"], reverse=True)
return candidates
def generate_report(candidates: List[Dict], collection: str, threshold: float, scanned: int) -> Dict:
"""Generate structured JSON report."""
return {
"timestamp": now_iso(),
"collection": collection,
"threshold": threshold,
"scanned_chunks": scanned,
"near_duplicate_pairs": len(candidates),
"candidates": candidates,
"recommendation": (
f"{len(candidates)} near-duplicate pairs found. "
"Review manually and apply merge via Qdrant point update if approved."
),
}
def main():
parser = argparse.ArgumentParser(description="Semantic Dedup Scanner")
parser.add_argument("--collection", default=COLLECTION, help="Qdrant collection name")
parser.add_argument("--threshold", type=float, default=SIMILARITY_THRESHOLD, help="Cosine similarity threshold")
parser.add_argument("--max-points", type=int, default=MAX_POINTS, help="Max points to process (cap O(n²))")
parser.add_argument("--dry-run", action="store_true", help="Scan only, do not save report")
args = parser.parse_args()
collection = args.collection
# Skip gabi_* collections
if collection.startswith("gabi_"):
log_message(f"⏭️ Collection '{collection}' is exempt (gabi_*). Exiting.")
return
log_message(f"🚀 Starting semantic dedup (collection={collection}, threshold={args.threshold}, dry_run={args.dry_run})")
# Load chunks (capped by --max-points to avoid O(n²) blowup)
chunks = scroll_all_chunks(collection)
if args.max_points and len(chunks) > args.max_points:
log_message(f"⚠️ Collection has {len(chunks)} points, truncating to {args.max_points} (use --max-points to change)")
chunks = chunks[:args.max_points]
if not chunks:
log_message("⚠️ No chunks found in the collection.")
return
# Find near-duplicates
log_message(f"🔍 Analyzing similarity among {len(chunks)} chunks...")
candidates = find_near_duplicates(chunks, threshold=args.threshold)
# Generate report
report = generate_report(candidates, collection, args.threshold, len(chunks))
log_message("=" * 60)
log_message("📊 SEMANTIC DEDUP REPORT")
log_message("=" * 60)
log_message(f" Chunks scanned: {report['scanned_chunks']}")
log_message(f" Near-duplicate pairs: {report['near_duplicate_pairs']}")
if candidates:
log_message(f" Top similarity: {candidates[0]['similarity']:.4f}")
log_message(f" Top pair: {candidates[0]['chunk_id_a']}{candidates[0]['chunk_id_b']}")
else:
log_message(" No near-duplicates found.")
log_message("=" * 60)
# Save JSON report
if not args.dry_run and candidates:
try:
REPORT_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(REPORT_FILE, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
log_message(f"📄 Report saved: {REPORT_FILE}")
except Exception as e:
log_message(f"❌ Error saving report: {e}")
# Output JSON to stderr (parseable)
print(json.dumps(report, ensure_ascii=False, indent=2), file=sys.stderr)
log_message("✅ Semantic dedup complete.")
if __name__ == "__main__":
main()

View File

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
wiki-continuous-ingest.py
Detects new/modified .md files in the vault and enqueues them to the ARQ worker.
Runs on the host, accesses local Redis (127.0.0.1:6379) and Qdrant (localhost:6333).
"""
import os
import sys
import json
import hashlib
import asyncio
from pathlib import Path
from datetime import datetime, timezone
from dotenv import load_dotenv
from arq import create_pool
from arq.connections import RedisSettings
import redis.asyncio as aioredis
# ─── Config ────────────────────────────────────────────────────────────────
ENV_PATH = os.environ.get("ENV_PATH", "")
if ENV_PATH:
env_p = Path(ENV_PATH)
if env_p.exists():
load_dotenv(env_p)
WIKI_ROOT = Path(os.environ.get("WIKI_ROOT", "."))
STATE_DIR = Path(os.environ.get("HERMES_STATE_DIR", str(Path.home() / ".hermes")))
STATE_FILE = STATE_DIR / "wiki_ingest_state.json"
FAILURES_FILE = STATE_DIR / "wiki_ingest_failures.json"
REDIS_PASSWORD = os.environ.get("REDIS_PASSWORD", "")
redis_settings = RedisSettings(
host="127.0.0.1",
port=6379,
password=REDIS_PASSWORD or None,
)
def load_state() -> dict:
if STATE_FILE.exists():
with open(STATE_FILE) as f:
return json.load(f)
return {}
def save_state(state: dict):
"""Atomic write via tempfile + rename to avoid corruption."""
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = STATE_FILE.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(state, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, STATE_FILE)
def file_hash(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()[:16]
async def redis_ready() -> bool:
"""Check whether Redis is accessible before enqueuing."""
try:
r = aioredis.Redis(
host="127.0.0.1", port=6379,
password=REDIS_PASSWORD or None,
socket_connect_timeout=3,
socket_timeout=3,
)
ok = await r.ping()
await r.aclose()
return bool(ok)
except Exception as e:
print(f" ⚠️ Redis unavailable: {e}")
return False
async def main():
if not await redis_ready():
print("❌ Redis not ready. Docker stack may still be starting up. Aborting.")
return
state = load_state()
new_files = []
modified_files = []
skipped = 0
total = 0
# Scan all .md files
for path in sorted(WIKI_ROOT.rglob("*.md")):
total += 1
rel = str(path.relative_to(WIKI_ROOT))
mtime = path.stat().st_mtime
current_hash = file_hash(path)
if rel not in state:
new_files.append(rel)
state[rel] = {"mtime": mtime, "hash": current_hash, "queued_at": None, "ingested_at": None}
elif state[rel]["hash"] != current_hash:
modified_files.append(rel)
state[rel]["mtime"] = mtime
state[rel]["hash"] = current_hash
state[rel]["queued_at"] = None
state[rel]["ingested_at"] = None
else:
skipped += 1
files_to_ingest = new_files + modified_files
if not files_to_ingest:
print(f"⏭️ Nothing new. {total} files tracked, {skipped} unchanged.")
return
# Enqueue in ARQ
redis = await create_pool(redis_settings)
enqueued = 0
failed = 0
failures = []
for rel_path in files_to_ingest:
abs_path = str(WIKI_ROOT / rel_path)
try:
job = await redis.enqueue_job(
"process_wiki_file",
file_path=f"/wiki/{rel_path}", # path inside container
)
state[rel_path]["queued_at"] = datetime.now(timezone.utc).isoformat()
enqueued += 1
print(f" ✅ Enqueued: {rel_path} (job: {job.job_id[:8]})")
except Exception as e:
failed += 1
error_msg = str(e)
# Classify the error for the DLQ
error_lower = error_msg.lower()
transient_patterns = ["timeout", "connection", "rate limit", "503", "502", "504",
"unavailable", "too many requests", "refused", "reset"]
permanent_patterns = ["400", "404", "not found", "invalid", "parse error",
"deleted", "permission denied"]
failure_class = "unknown"
for p in transient_patterns:
if p in error_lower:
failure_class = "transient"
break
if failure_class == "unknown":
for p in permanent_patterns:
if p in error_lower:
failure_class = "permanent"
break
failures.append({
"timestamp": datetime.now(timezone.utc).isoformat(),
"file": rel_path,
"error": error_msg,
"failure_class": failure_class, # NEW: classification
"reported": False, # NEW: not yet reported
"retry_count": 0, # NEW: zero retries
})
print(f" ⚠️ Failure: {rel_path}{e} [{failure_class}]")
await redis.aclose()
save_state(state)
# Persist failures to simple DLQ (atomic, last 500)
if failures:
FAILURES_FILE.parent.mkdir(parents=True, exist_ok=True)
existing = []
if FAILURES_FILE.exists():
with open(FAILURES_FILE) as f:
existing = json.load(f)
existing.extend(failures)
existing = existing[-500:]
tmp = FAILURES_FILE.with_suffix(".tmp")
with open(tmp, "w") as f:
json.dump(existing, f, indent=2)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, FAILURES_FILE)
print(f"\n📊 {total} files tracked")
print(f" New: {len(new_files)} | Modified: {len(modified_files)} | Unchanged: {skipped}")
print(f" Enqueued: {enqueued} | Failures: {failed}")
if failures:
print(f" 📋 Failures persisted to: {FAILURES_FILE}")
if __name__ == "__main__":
asyncio.run(main())

177
setup/install.md Normal file
View File

@ -0,0 +1,177 @@
# Setup Guide
> Step-by-step installation of the Memory OS stack. Assumes Hermes Agent is already installed and configured.
## Prerequisites
- Hermes Agent 0.14.0+ (tested on 0.15.2)
- Python 3.11+
- Docker 24.0+
- OpenRouter API key (for embeddings and LLM extraction)
- 16 GB RAM recommended (8 GB minimum)
## Installation
### 1. Icarus Plugin (bundled)
```bash
# Copy the bundled Icarus fork into the Hermes plugins directory
cp -r icarus/ ~/.hermes/plugins/icarus/
```
### 2. Enable Icarus in Hermes Config
Icarus must be registered as an enabled plugin. Edit `~/.hermes/config.yaml`:
```yaml
enabled:
- hermes-achievements # optional
- icarus # required — activates fabric tools + context injection hooks
```
Then restart the gateway:
```bash
hermes gateway restart
```
Verify the plugin loaded:
```bash
hermes status
# → Should show: icarus v0.3.0 (16 tools, 4 hooks)
```
### 3. Docker Infrastructure
```bash
# Copy docker-compose.yml from this repository
cp docker/docker-compose.yml ~/memory-os/
cd ~/memory-os
# Create .env with required variables
cat > .env << EOF
OPENROUTER_API_KEY=sk-or-...
REDIS_PASSWORD=$(openssl rand -hex 16)
EMBEDDING_DIMS=4096
COLLECTION_NAME=knowledge_base
EOF
docker compose up -d
```
Verify:
```bash
curl -s http://localhost:6333/healthz # → {"title":"ok","version":"1.17.1"}
redis-cli -a "$REDIS_PASSWORD" ping # → PONG
```
### 4. Environment Variables
Add to your Hermes profile `.env` (e.g. `~/.hermes/.env`):
```bash
# Required
FABRIC_DIR=/home/your-user/vault/fabric
OPENROUTER_API_KEY=sk-or-...
# Strongly recommended
ICARUS_EXTRACTION_MAX_TOKENS=4096
ICARUS_EXTRACTION_MODEL=deepseek/deepseek-v4-flash
EMBEDDING_DIMS=4096
# Optional
ICARUS_OBSIDIAN=1
ICARUS_RESULT_MAX_CHARS=500
ICARUS_TASK_MAX_CHARS=300
```
**⚠️ Use absolute paths.** The Hermes gateway runs as a systemd service — `~` is not expanded. Always use `/home/your-user/...`.
### 5. Core File Modifications
Apply the changes documented in [modifications/soul-rulebook.md](../modifications/soul-rulebook.md):
- Add Ground Truth level 2 (injected memory) to `SOUL.md`
- Add memory architecture documentation to `rulebook.md`
- Add context injection convention to `SOUL.md`
These modifications ensure the agent trusts its injected memory as authoritative.
### 6. Wiki Setup
```bash
mkdir -p $VAULT_PATH/wiki/{raw,concepts,entities,comparisons,_meta,_archive}
# Copy SCHEMA.md template, create initial index.md and log.md
```
The wiki starts empty. Add source documents to `raw/` and the wiki-agent cronjob will begin extracting structured pages.
### 7. Cronjobs
Add to crontab (`crontab -e`):
```cron
# Wiki ingestion — keeps Qdrant in sync
0 * * * * /usr/bin/python3 /path/to/scripts/wiki_continuous_ingest.py
# Qdrant maintenance
0 3 * * 0 /usr/bin/python3 /path/to/scripts/decay_scanner.py
# Dead letter queue monitoring
0 */6 * * * /usr/bin/python3 /path/to/scripts/dlq_manager.py
# Semantic dedup (first Sunday of month)
0 3 * * 0 [ $(date +\%d) -le 7 ] && /usr/bin/python3 /path/to/scripts/semantic_dedup.py
```
### 8. Gateway Restart
```bash
hermes gateway restart
```
Changes to `.env`, `SOUL.md`, `rulebook.md`, and Icarus plugin code only take effect after restart.
### 9. Verify
Inside Hermes chat:
```
/plugins
# → Should show: icarus v0.3.0 (16 tools, 4 hooks)
fabric_brief()
# → Should show recent fabric entries (initially empty)
qdrant_search("test query")
# → Should return results from knowledge_base (if wiki has content)
fact_store(action='probe', entity='test')
# → Should return empty (no facts stored yet)
```
## What to expect
**Day 1:** Infrastructure running. Fabric entries begin accumulating at session end. Qdrant indexing starts as wiki files are added.
**Week 1:** Context injection active. Agent references past decisions automatically. Wiki pipeline producing curated pages from raw documents.
**Month 1:** Decay scanner has aged content to evaluate. Structured facts accumulating with trust scores.
## Troubleshooting
### Qdrant collection shows 0 points
Check: `EMBEDDING_DIMS=4096` matches collection schema. Mismatch → vectors rejected silently.
### Fabric entries are truncated
Check: `ICARUS_EXTRACTION_MAX_TOKENS=4096` in `.env` AND gateway was restarted after setting it.
### Memory tool reports "Icarus write conflict"
Icarus is writing to MEMORY.md instead of CREATIVE.md. Verify Icarus fork is installed (not upstream esaradev version).
### Context injection not working
Check: OpenRouter API key is set, `context_enhancer.py` can import, gateway restarted after `hooks.py` edits.
### Decay scanner produces "0 archived" every week
Most likely: point payloads missing `last_accessed_at` or `importance_score` metadata. Run backfill before enabling decay.

125
templates/SCHEMA.md Normal file
View File

@ -0,0 +1,125 @@
# Wiki Schema Template
This document defines the structure for wiki pages in the Memory OS knowledge base.
Each page under `wiki/{concepts,entities,comparisons}/` should follow this structure.
## Frontmatter
```yaml
---
title: "Page Title"
type: concept # concept | entity | comparison
tags: [tag1, tag2]
created: YYYY-MM-DD
updated: YYYY-MM-DD
source: raw/filename.md # link to source document
status: seedling # seedling | growing | evergreen
aliases: [alt-name-1, alt-name-2]
---
```
**Field descriptions:**
- `type` — one of: `concept` (abstract pattern/idea), `entity` (concrete tool/project/person), `comparison` (side-by-side analysis)
- `status` — maturity indicator: `seedling` (new), `growing` (being refined), `evergreen` (stable reference)
- `source` — relative path to the raw source document that generated this page
- `aliases` — alternative names for cross-linking and search
## Body Structure
### For `concept` pages
```markdown
# Concept Name
## Summary
One-paragraph high-level overview of the concept.
## Description
Detailed explanation. Include:
- What problem this concept solves
- How it works at a high level
- Key principles or rules
## Examples
### Example 1: Descriptive title
```
code or configuration block
```
Brief explanation of what the example demonstrates.
### Example 2: Another example
## Related
- [[Related Concept 1]] — relationship description
- [[Related Concept 2]] — relationship description
```
### For `entity` pages
```markdown
# Entity Name
## Summary
What this thing is — one paragraph.
## Purpose
Why this entity exists in the system.
## Configuration
```yaml
# Example configuration block
key: value
option: setting
```
## Dependencies
- Dependency 1: what it provides
- Dependency 2: what it provides
## Usage Notes
Practical considerations, edge cases, known issues.
## Related
- [[Related Concept]] — relationship
```
### For `comparison` pages
```markdown
# Comparison: A vs B
## Summary
One-paragraph overview of what is being compared.
## Comparison Table
| Aspect | Option A | Option B |
|--------|----------|----------|
| Strengths | ... | ... |
| Weaknesses | ... | ... |
| Best for | ... | ... |
## Decision Factors
Considerations that favour one option over the other.
## Recommendation
Final recommendation with reasoning.
## Related
- [[Option A detail page]]
- [[Option B detail page]]
```

49
templates/index.md Normal file
View File

@ -0,0 +1,49 @@
# Knowledge Wiki
> Map of Content — curated knowledge base for the Memory OS agent.
```yaml
---
title: "Wiki Index"
type: meta
last_updated: YYYY-MM-DD
total_pages: 0
---
```
## Concepts
Abstract patterns, ideas, and recurring themes.
- _Add wiki links here as concepts are created_
## Entities
Concrete things: tools, models, projects, people.
- _Add wiki links here as entities are created_
## Comparisons
Side-by-side analyses of alternatives.
- _Add wiki links here as comparisons are created_
## Recent Additions
| Date | Title | Type |
|------|-------|------|
| | | |
---
## Raw Sources
Source documents that feed the wiki pipeline:
```
raw/
├── external/ # Third-party articles, docs, specs
├── notes/ # Personal notes and observations
└── research/ # Research papers and investigations
```

31
templates/log.md Normal file
View File

@ -0,0 +1,31 @@
# Wiki Change Log
> Track what was added, updated, or removed from the wiki.
```yaml
---
title: "Wiki Change Log"
type: meta
---
```
## YYYY-MM-DD
### Added
- `concepts/example-concept.md` — first concept about ...
### Updated
- `entities/example-entity.md` — added configuration section
### Removed
- _Nothing removed_
---
## Structure Notes
- Each entry links to the wiki page path and a brief description of the change.
- The wiki-agent cronjob updates this log automatically during curation.