Skill: has9800/raven-memory (version 1.0.1)

--- name: raven-memory version: 1.0.1 description: > Persistent causal memory for AI agents. Raven records everything your agent does as a causally-linked chain, including decisions, tool calls, parallel work, and session history. Your agent finally remembers. category: memory exclusive: true author: hahmed9800 license: Apache-2.0 tags: [memory, persistence, causal, local, privacy] requirements: python: ">=3.10" packages: - raven-memory mcp: command: raven-mcp args: [] env: RAVEN_DB_PATH: "${HOME}/.raven/raven.db" RAVEN_N_RECENT: "10" RAVEN_N_SEARCH: "5" --- # Raven Memory Raven gives your OpenClaw agent persistent causal memory that survives across sessions, days, and weeks. ## What it does - Records every significant event as a node in a causal chain - Loads relevant history at session start via semantic search - Supports rollback, branching, and parallel task tracking - Stores everything locally in ~/.raven/raven.db - Encrypted at rest with SQLCipher (optional) ## Tools exposed - `raven_start_session` — load context at conversation start - `raven_record_event` — write an event to the chain - `raven_end_session` — close session with notes - `raven_search` — semantic search over full history - `raven_rollback` — undo N steps - `raven_get_status` — health check ## Setup Install from PyPI: ```bash pip install raven-memory ``` With semantic search (recommended): ```bash pip install "raven-memory[vec]" ``` Then install this skill: ```bash clawhub install raven-memory ``` Add to your OpenClaw system prompt: ``` At the start of every conversation, call raven_start_session with the user's first message as search_query. Load the returned summary/nodes into your context before responding. Record events with raven_record_event. End sessions with raven_end_session. ```

path: requirements.txt
content: torch>=2.1.0
transformers>=4.45.0
langgraph>=0.2.0
langgraph-checkpoint-sqlite
langchain-core>=0.3.0
accelerate
sqlite-vec
sentence-transformers

datasets


path: pyproject.toml
content: [build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "raven-memory"
version = "1.0.1"
description = "Persistent causal memory for AI agents. Records decisions, tool calls, and parallel work as a causal DAG. OpenClaw and MCP compatible."
readme = "README.md"
license = { text = "Apache-2.0" }
authors = [
    { name = "hahmed9800" }
]
keywords = ["ai", "agent", "memory", "mcp", "openclaw", "causal", "dag", "sqlite"]
classifiers = [
    "Development Status :: 4 - Beta",
    "Intended Audience :: Developers",
    "License :: OSI Approved :: Apache Software License",
    "Programming Language :: Python :: 3",
    "Programming Language :: Python :: 3.10",
    "Programming Language :: Python :: 3.11",
    "Programming Language :: Python :: 3.12",
    "Topic :: Scientific/Engineering :: Artificial Intelligence",
]
requires-python = ">=3.10"
dependencies = [
    "sentence-transformers>=2.2.0",
]

[project.optional-dependencies]
vec = ["sqlite-vec>=0.1.0"]
dev = ["pytest>=7.0"]

[project.scripts]
raven-mcp = "tcc.integration.mcp_server:main"

[project.urls]
Homepage = "https://github.com/hahmed9800/tcc-agentic"
Repository = "https://github.com/hahmed9800/tcc-agentic"

[tool.hatch.build.targets.wheel]
packages = ["tcc"]


path: README.md
content: <p align="center">
  <img src="https://github.com/has9800/raven-memory/blob/main/raven_logo.png" 
       alt="Raven Memory" width="100%"/>
</p>


# Raven

**Persistent causal memory for AI agents.**

Raven gives your AI agent a memory that survives across sessions, days, and months. Every significant event: tool calls, decisions, user preferences, session checkpoints etc are recorded as a node in a causal chain. Your agent always knows where things stand and why.

---

## The problem

Every AI agent session starts from zero. Close the chat and your agent forgets everything. Start a new session and you're explaining yourself from scratch. The agent doesn't know what you built last week, what decisions you made, or why you made them.

Existing memory systems help, but only partially:

- **OpenClaw's built-in memory** writes plain Markdown files. Works for simple preferences, breaks down when you need causal reasoning, *why* did we switch materials, *what* caused that deployment to fail.
- **Mem0 / Zep** extract facts from conversations and store them in vector databases. Better retrieval, but they store *what* happened, not *why*. Ask about a decision chain and you get fragments.

Raven stores the full causal picture. Every node knows what came before it and what caused it.

---

## How it works

Raven records three types of nodes:

| Node type | When it's written | Example |
|---|---|---|
| `action` | Tool call executed | `ran smoke tests → all 42 passed` |
| `decided` | Agent writes to memory | `user prefers concise answers` |
| `milestone` | Session start, end, heartbeat | `session end → switched to titanium housing` |

Nodes are stored in a local SQLite database with a `node_parents` join table. This enables:

- **Recursive CTE traversal**: walk the causal chain in pure SQL
- **Semantic search**: find relevant history by meaning via sqlite-vec
- **Branch and merge**: parallel agent tasks tracked as DAG branches, merged when complete
- **Rollback**: non-destructive, moves the tip pointer back N hops

At session start, your agent receives a summary of recent events plus semantically relevant history from past sessions. At session end, a milestone node captures what was accomplished and what comes next.

---

## Architecture

```
┌─────────────────────────────────────────┐
│              OpenClaw Agent             │
│                                         │
│  raven_start_session  ──────────────┐   │
│  raven_record_event   ──────────┐   │   │
│  raven_end_session    ───────┐  │   │   │
│  raven_search         ────┐  │  │   │   │
└───────────────────────────┼──┼──┼───┼──-┘
                            │  │  │   │
                    MCP / JSON-RPC 2.0
                            │  │  │   │
┌───────────────────────────┼──┼──┼───┼──-┐
│           Raven MCP Server              │
├─────────────────────────────────────────┤
│  TaskDAG   │  SessionReconciler         │
├─────────────────────────────────────────┤
│  TCCStore  │  SQLite + sqlite-vec       │
│            │  WAL mode, node_parents    │
│            │  Recursive CTE traversal   │
└─────────────────────────────────────────┘
                     │
              ~/.raven/raven.db
```

All data stays on your machine. No cloud, no vendor, no external API.

---

## MCP tools

Raven exposes six tools over the MCP protocol:

| Tool | What it does |
|---|---|
| `raven_start_session` | Load context at conversation start. Returns a summary of recent events + semantically relevant history. |
| `raven_record_event` | Write an event to the causal chain. Call when something significant happens. |
| `raven_end_session` | Close the session with notes. Records a milestone node so future sessions know where things stood. |
| `raven_search` | Semantic search over full history. Finds relevant past context by meaning, not just recency. |
| `raven_rollback` | Roll back N steps. Non-destructive, History is preserved, tip pointer moves. |
| `raven_get_status` | Health check: node count, tip event, vec search status. |

---

## CausalMemBench

Raven scores 100% on CausalMemBench, a benchmark specifically 
designed to test causal agent memory across six categories that 
existing benchmarks do not cover.

| Category | Score |
|---|---|
| Causal provenance | 5/5 |
| Parallel execution | 5/5 |
| Temporal ordering | 5/5 |
| Rollback provenance | 5/5 |
| Cross-session continuity | 5/5 |
| Merge outcomes | 5/5 |
| **Total** | **30/30 = 100%** |

CausalMemBench tests questions that require understanding *why* 
something happened, not just *what* happened. It is the first 
benchmark designed specifically for causal agent memory systems.

---

## Installation

**Requirements:** Python 3.10+, sqlite-vec (optional, enables semantic search)

```bash
pip install raven-memory
```

**Optional — semantic search:**
```bash
pip install raven-memory['vec']
```

---

## OpenClaw setup

Add Raven as an MCP server in your OpenClaw config:

```json
{
  "mcpServers": {
    "raven-memory": {
      "command": "python3",
      "args": ["-m", "tcc.integration.mcp_server"],
      "env": {
        "RAVEN_DB_PATH": "~/.raven/raven.db"
      }
    }
  }
}
```

Add to your `AGENTS.md` so the agent uses Raven automatically:

```
At the start of every conversation, call raven_start_session with
the user's first message as search_query. Inject the returned
summary into your awareness before responding. Record significant
events with raven_record_event. End sessions with raven_end_session.
```

Or install from ClawHub:

```bash
clawhub install raven-memory
```

---

## Environment variables

| Variable | Default | Description |
|---|---|---|
| `RAVEN_DB_PATH` | `~/.raven/raven.db` | Path to the SQLite database |
| `RAVEN_N_RECENT` | `10` | Recent nodes injected at session start |
| `RAVEN_N_SEARCH` | `5` | Semantic search results at session start |

---

## Branching and merging

Raven's DAG tracks parallel work natively. When multiple agents or parallel tool calls run at the same time, each gets its own branch in the causal chain. When they finish, Raven merges them back into a single point on the main chain, capturing exactly what ran in parallel, what completed, and where the main thread resumed.

**Automatic — no code required**

When multiple agents write to the same database concurrently, Raven detects when all open branches are done and fires an auto-merge milestone:

```
main chain ──●──●──●──────────────────────────────●── (merge milestone)
                    └─── branch a3f2: research ───┘
                    └─── branch d8c1: lab, ─────────┘
```

The merge node records all branch tips as parents. Future sessions can walk back through it and see exactly what happened in parallel, what caused what, and in what order.

**Explicit: for orchestrator workflows**

If you're building a multi-agent orchestrator that spawns subagents and waits for results:

```python
# Fork two branches
node_a, _ = dag.branch(tip, "research task", "agent", session_id=sid)
node_b, _ = dag.branch(tip, "lab task", "agent", session_id=sid)

# Subagents do their work and mark done
dag.update_status(node_a.hash, "done")
dag.update_status(node_b.hash, "done")

# Auto-merge fires, or call explicitly:
merge = dag.merge([node_a.hash, node_b.hash], session_id=sid)
```

**Why this matters**

Without branching, concurrent agent writes are just a pile of facts with no record of what ran simultaneously. With Raven's DAG, a future session can answer: what were we doing in parallel, did both complete, and what decision came after? That causal picture is what flat memory systems such as Markdown files, vector stores cant reconstruct.

For most OpenClaw users, automerge handles this transparently. You only need the explicit API if you're orchestrating subagents yourself.

---

## Multi-agent support

Multiple agents can share a single Raven database. Each agent opens the same `.db` file. WAL mode ensures concurrent writes are safe with no corruption at all.

```python
# Agent A and Agent B share the same memory
store_a = TCCStore("~/.raven/shared.db")
store_b = TCCStore("~/.raven/shared.db")
```

No synchronization protocol needed. SQLite handles it.

---

## Performance

Tested on RTX 5090 with 500+ nodes:

| Operation | Time |
|---|---|
| Batch write 500 nodes | 8.7ms |
| `recent(20)` | 0.0ms |
| CTE ancestor traversal (500 deep) | 1.3ms |
| Store reload (500+ nodes) | 9.6ms |
| Session resume | 86.4ms |
| Semantic search per query | ~40ms |
| 10 concurrent agents × 50 nodes | 877ms, 0 errors |

---

## Compared to alternatives

| | Raven | OpenClaw memory | Mem0 | Zep |
|---|---|---|---|---|
| Causal chain | ✅ DAG with parent tracking | ❌ Flat Markdown | ❌ Flat vector store | ❌ Flat vector store |
| Cross-session recall | ✅ | ✅ (limited) | ✅ | ✅ |
| Multi-agent shared memory | ✅ Single SQLite file | ❌ Per-agent silos | ❌ | ❌ |
| Rollback | ✅ Non-destructive | ❌ | ❌ | ❌ |
| Branch / merge | ✅ | ❌ | ❌ | ❌ |
| Semantic search | ✅ sqlite-vec | ✅ | ✅ | ✅ |
| Local / private | ✅ | ✅ | ❌ Cloud | ❌ Cloud |
| OpenClaw plugin | ✅ | ✅ (built-in) | ✅ | ✅ |

---

## Project structure

```
tcc/
├── core/
│   ├── node.py        — TCCNode dataclass, typed schema
│   ├── store.py       — SQLite persistence, CTE traversal, WAL
│   ├── dag.py         — TaskDAG, branch/merge, rollback
│   ├── reconciler.py  — Session lifecycle, OpenClaw event mapping
│   └── embedder.py    — sentence-transformers wrapper
└── integration/
    └── mcp_server.py  — JSON-RPC 2.0 over stdio, 6 MCP tools

skill.md               — OpenClaw / ClawHub skill manifest
```

---

## Roadmap

**Node compression**

Long running chains accumulate intermediate nodes that lose significance over time. Compression will fold consecutive nodes of the same type, or sequences of `action` nodes between two decisions into a single summary node, preserving the causal edges but reducing storage and context injection size. A chain of 40 tool calls between two `decided` nodes collapses into one compressed `action` node with a rollup summary. The original nodes stay in the store; compression only affects what gets surfaced at session start.

**Branch milestones**

Currently, milestone nodes are written at session start, session end, and merge. The next step is writing milestone nodes at branch *creation* too, recording what triggered the parallel work, what each branch is responsible for, and what the expected merge condition is. This gives the causal chain a complete picture of parallel execution: why it forked, what each branch did, and when it rejoined.

**Task Marketplace (Recipes) **

When an agent completes a task successfully, a full branch from fork to merge with all nodes `done` Raven will be able to extract that execution path as a reusable recipe. The recipe captures the sequence of actions, the decisions made along the way, and the final result. Recipes can be shared across agents or exported so other users can seed their Raven instance with proven task patterns, essentially how an agent solved a problem, step by step, ready to be replayed or adapted.

**Encryption**

SQLCipher integration for at-rest encryption of the `.db` file. Opt-in via `RAVEN_ENCRYPTION_KEY` environment variable. No schema changes. Drop-in replacement for the SQLite connection.

**RGAT traversal**

A Relational Graph Attention Network layer over the causal DAG, using two edge types, causal edges from the DAG structure and semantic edges from sqlite-vec similarity. Replaces flat recency-based context injection with learned causal traversal: the model learns which parts of the graph are actually relevant to the current task, not just which are most recent or most similar.

---

## License

Apache 2.0


path: tcc/__init__.py
content: """
Raven Memory — persistent causal memory for AI agents.

Copyright (c) 2026 hahmed9800
Licensed under the Apache License, Version 2.0

https://github.com/hahmed9800/tcc-agentic
"""

from tcc.core.dag import TaskDAG
from tcc.core.node import TCCNode
from tcc.core.reconciler import SessionReconciler
from tcc.core.store import TCCStore

__version__ = "1.0.0"
__author__ = "hahmed9800"
__license__ = "Apache-2.0"

__all__ = [
    "TaskDAG",
    "TCCNode",
    "SessionReconciler",
    "TCCStore",
    "__version__",
]


path: tcc/integration/__init__.py
content: """Raven agent integration layer — MCP server."""

from .mcp_server import HANDLERS, TOOLS as MCP_TOOLS

__all__ = ["MCP_TOOLS", "HANDLERS"]


path: tcc/integration/mcp_server.py
content: """
Raven MCP Server

Exposes the TCC DAG as MCP tools over stdio (JSON-RPC 2.0).
Compatible with OpenClaw, Claude Desktop, and any MCP client.

Usage:
    python3 -m tcc.integration.mcp_server

Environment variables:
    RAVEN_DB_PATH     path to raven.db (default: ~/.raven/raven.db)
    RAVEN_N_RECENT    recent nodes to inject (default: 10)
    RAVEN_N_SEARCH    semantic search results (default: 5)
"""
from __future__ import annotations

import json
import os
import sys
import traceback
from typing import Any

from tcc.core.dag import TaskDAG
from tcc.core.reconciler import SessionReconciler
from tcc.core.store import TCCStore

# ── configuration ─────────────────────────────────────────────────────────────

DB_PATH = os.environ.get(
    "RAVEN_DB_PATH",
    os.path.expanduser("~/.raven/raven.db"),
)
N_RECENT = int(os.environ.get("RAVEN_N_RECENT", "10"))
N_SEARCH = int(os.environ.get("RAVEN_N_SEARCH", "5"))

# ── global state ──────────────────────────────────────────────────────────────

_store: TCCStore | None = None
_dag: TaskDAG | None = None
_reconciler: SessionReconciler | None = None
_current_session_id: str | None = None


def _init() -> None:
    """Initialize Raven store, DAG, and reconciler."""
    global _store, _dag, _reconciler
    os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
    _store = TCCStore(DB_PATH)
    _dag = TaskDAG(_store)
    _reconciler = SessionReconciler()


# ── tool definitions (MCP schema) ─────────────────────────────────────────────

TOOLS = [
    {
        "name": "raven_start_session",
        "description": (
            "Start a new Raven session and get project summary. "
            "Call this at the beginning of every conversation to load "
            "persistent memory. Returns a summary of recent events and "
            "semantically relevant history."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "search_query": {
                    "type": "string",
                    "description": (
                        "Optional: the user's first message or topic. "
                        "Used for semantic search over history to find "
                        "relevant past history beyond recent nodes."
                    ),
                },
                "n_recent": {
                    "type": "integer",
                    "description": "Number of recent nodes to include (default 10).",
                    "default": 10,
                },
            },
            "required": [],
        },
    },
    {
        "name": "raven_record_event",
        "description": (
            "Record an event to the Raven chain. Call this when "
            "something significant happens — a decision, an action, "
            "a tool result, a user preference, or any state change "
            "worth remembering across sessions."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "event": {
                    "type": "string",
                    "description": "What happened (natural language, concise).",
                },
                "actor": {
                    "type": "string",
                    "enum": ["user", "agent", "tool", "system"],
                    "description": "Who or what caused this event.",
                },
                "result_summary": {
                    "type": "string",
                    "description": "Optional: short summary of the result (max 200 chars).",
                },
                "tool_name": {
                    "type": "string",
                    "description": "Optional: name of the tool that was called.",
                },
            },
            "required": ["event", "actor"],
        },
    },
    {
        "name": "raven_end_session",
        "description": (
            "End the current session and save a closing note. "
            "Call this when the conversation is wrapping up or the "
            "user says goodbye. Records a session-end node so future "
            "sessions know where things stood."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "notes": {
                    "type": "string",
                    "description": "Summary of what was accomplished this session.",
                    "default": "",
                },
            },
            "required": [],
        },
    },
    {
        "name": "raven_search",
        "description": (
            "Semantic search over Raven's full history. Use this when "
            "the user asks about something that might be in past sessions "
            "but isn't in the current prompt window. Returns the most "
            "relevant nodes by meaning, not just recency."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "What to search for (natural language).",
                },
                "n": {
                    "type": "integer",
                    "description": "Number of results to return (default 5).",
                    "default": 5,
                },
            },
            "required": ["query"],
        },
    },
    {
        "name": "raven_rollback",
        "description": (
            "Roll the chain back N steps. Use this when the user says "
            "'that was wrong', 'undo that decision', or wants to go back "
            "to a previous state. History is preserved — rollback just "
            "moves the current tip pointer."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {
                "steps": {
                    "type": "integer",
                    "description": "Number of steps to roll back (default 1).",
                    "default": 1,
                },
            },
            "required": [],
        },
    },
    {
        "name": "raven_get_status",
        "description": (
            "Get the current status of the Raven chain — tip event, "
            "total nodes, current session, and whether vec search is "
            "enabled. Useful for debugging and health checks."
        ),
        "inputSchema": {
            "type": "object",
            "properties": {},
            "required": [],
        },
    },
]


# ── tool handlers ─────────────────────────────────────────────────────────────

def handle_raven_start_session(params: dict) -> dict:
    global _current_session_id
    search_query = params.get("search_query")
    n_recent = params.get("n_recent", N_RECENT)

    ctx = _reconciler.start_session(
        _dag,
        n_recent=n_recent,
        search_query=search_query,
        n_search=N_SEARCH,
    )
    _current_session_id = ctx["session_id"]

    return {
        "session_id": ctx["session_id"],
        "is_fresh": ctx["is_fresh"],
        "summary": ctx["summary"],
        "message": (
            "Session started. Inject the 'summary' field into your "
            "system prompt so the agent has project history."
        ),
    }


def handle_raven_record_event(params: dict) -> dict:
    global _current_session_id

    if _current_session_id is None:
        ctx = _reconciler.start_session(_dag)
        _current_session_id = ctx["session_id"]

    node = _reconciler.record_event(
        dag=_dag,
        session_id=_current_session_id,
        event=params["event"],
        actor=params["actor"],
        status="done",
        result_summary=params.get("result_summary"),
        tool_name=params.get("tool_name"),
    )

    return {
        "hash": node.hash,
        "event": node.event,
        "recorded": True,
    }


def handle_raven_end_session(params: dict) -> dict:
    global _current_session_id

    if _current_session_id is None:
        return {"recorded": False, "message": "No active session to end."}

    notes = params.get("notes", "")
    node = _reconciler.end_session(_dag, _current_session_id, notes=notes)

    # Batch embed new nodes after session
    if _store.is_vec_enabled:
        n = _store.embed_all(batch_size=32)
        embedded_msg = f" Embedded {n} new nodes." if n > 0 else ""
    else:
        embedded_msg = ""

    prev_session = _current_session_id
    _current_session_id = None

    return {
        "session_id": prev_session,
        "recorded": node is not None,
        "message": f"Session ended.{embedded_msg}",
    }


def handle_raven_search(params: dict) -> dict:
    query = params["query"]
    n = params.get("n", N_SEARCH)

    if not _store.is_vec_enabled:
        return {
            "results": [],
            "message": "sqlite-vec not available. Install sqlite-vec for semantic search.",
        }

    nodes = _store.search(query, n=n)
    return {
        "results": [
            {
                "hash": node.hash,
                "event": node.event,
                "actor": node.actor,
                "timestamp": node.timestamp,
                "node_type": node.node_type,
                "result_summary": node.result_summary,
            }
            for node in nodes
        ],
        "n": len(nodes),
    }


def handle_raven_rollback(params: dict) -> dict:
    steps = params.get("steps", 1)
    try:
        node = _dag.rollback(steps)
        return {
            "rolled_back_to": node.event,
            "hash": node.hash,
            "message": f"Rolled back {steps} step(s). Now at: {node.event}",
        }
    except Exception as exc:
        return {"error": str(exc), "rolled_back": False}


def handle_raven_get_status(params: dict) -> dict:
    del params
    tip = _dag.tip()
    all_nodes = _store.load_all()
    return {
        "tip_event": tip.event if tip else None,
        "tip_hash": tip.hash if tip else None,
        "total_nodes": len(all_nodes),
        "current_session_id": _current_session_id,
        "db_path": DB_PATH,
        "vec_enabled": _store.is_vec_enabled,
        "is_fresh": tip is None,
    }


HANDLERS = {
    "raven_start_session": handle_raven_start_session,
    "raven_record_event": handle_raven_record_event,
    "raven_end_session": handle_raven_end_session,
    "raven_search": handle_raven_search,
    "raven_rollback": handle_raven_rollback,
    "raven_get_status": handle_raven_get_status,
}


# ── MCP protocol (JSON-RPC 2.0 over stdio) ────────────────────────────────────

def send(obj: dict) -> None:
    """Write a JSON-RPC response to stdout."""
    line = json.dumps(obj) + "\n"
    sys.stdout.write(line)
    sys.stdout.flush()


def send_error(id: Any, code: int, message: str) -> None:
    send({
        "jsonrpc": "2.0",
        "id": id,
        "error": {"code": code, "message": message},
    })


def send_result(id: Any, result: Any) -> None:
    send({
        "jsonrpc": "2.0",
        "id": id,
        "result": result,
    })


def handle_request(req: dict) -> None:
    """Dispatch a single JSON-RPC request."""
    req_id = req.get("id")
    method = req.get("method", "")
    params = req.get("params", {})

    # MCP initialization
    if method == "initialize":
        send_result(req_id, {
            "protocolVersion": "2024-11-05",
            "capabilities": {"tools": {}},
            "serverInfo": {
                "name": "raven-memory",
                "version": "1.0.0",
            },
        })
        return

    if method == "initialized":
        # Notification — no response needed
        return

    # Tool listing
    if method == "tools/list":
        send_result(req_id, {"tools": TOOLS})
        return

    # Tool execution
    if method == "tools/call":
        tool_name = params.get("name", "")
        tool_input = params.get("arguments", {})

        if tool_name not in HANDLERS:
            send_error(req_id, -32601, f"Unknown tool: {tool_name}")
            return

        try:
            result = HANDLERS[tool_name](tool_input)
            send_result(req_id, {
                "content": [
                    {
                        "type": "text",
                        "text": json.dumps(result, indent=2),
                    }
                ],
                "isError": False,
            })
        except Exception as exc:
            send_result(req_id, {
                "content": [
                    {
                        "type": "text",
                        "text": f"Error: {exc}\n{traceback.format_exc()}",
                    }
                ],
                "isError": True,
            })
        return

    # Ping
    if method == "ping":
        send_result(req_id, {})
        return

    send_error(req_id, -32601, f"Method not found: {method}")


def main() -> None:
    """Main stdio loop — read JSON-RPC requests, dispatch, respond."""
    _init()

    # Log to stderr only (stdout is reserved for MCP protocol)
    print(
        f"Raven MCP server started. DB: {DB_PATH}",
        file=sys.stderr,
    )

    for line in sys.stdin:
        line = line.strip()
        if not line:
            continue
        try:
            req = json.loads(line)
            handle_request(req)
        except json.JSONDecodeError as exc:
            send_error(None, -32700, f"Parse error: {exc}")
        except Exception as exc:
            send_error(None, -32603, f"Internal error: {exc}")


if __name__ == "__main__":
    main()


path: tcc/core/store.py
content: from __future__ import annotations

import json
import sqlite3
import threading
import warnings
from collections import deque
from typing import Callable, Optional

from .node import TCCNode

VALID_STATUSES = {"running", "done", "failed"}


class TCCError(Exception):
    pass


class NodeNotFoundError(TCCError):
    pass


class DuplicateNodeError(TCCError):
    pass


class DAGError(TCCError):
    pass


class InvalidStatusError(TCCError):
    pass


NODE_COLUMNS = [
    "hash",
    "node_type",
    "timestamp",
    "actor",
    "session_key",
    "session_id",
    "event",
    "status",
    "branch_id",
    "transcript_ref",
    "tool_name",
    "tool_args_hash",
    "duration_ms",
    "result_summary",
    "file_path",
    "content",
    "trigger",
    "subtype",
    "summary",
    "open_threads",
    "token_count",
]


class TCCStore:
    def __init__(self, path: str = ":memory:", on_status_update: Optional[Callable] = None):
        self.path = path
        self._lock = threading.Lock()
        self._on_status_update = on_status_update
        self._conn = sqlite3.connect(path, check_same_thread=False)
        if path != ":memory:":
            self._conn.execute("PRAGMA journal_mode=WAL")
            self._conn.execute("PRAGMA synchronous=NORMAL")
            self._conn.execute("PRAGMA cache_size=-64000")
            self._conn.execute("PRAGMA temp_store=MEMORY")
            self._conn.commit()
        self._vec_enabled = self._load_sqlite_vec(self._conn)
        self._init_schema()

    def _load_sqlite_vec(self, conn: sqlite3.Connection) -> bool:
        """Load sqlite-vec extension if available."""
        if self.path == ":memory:":
            return False
        try:
            import sqlite_vec

            conn.enable_load_extension(True)
            sqlite_vec.load(conn)
            conn.enable_load_extension(False)
            return True
        except Exception as exc:
            warnings.warn(
                f"sqlite-vec not available — semantic search disabled: {exc}",
                UserWarning,
            )
            return False

    def _init_schema(self):
        with self._lock:
            cur = self._conn.cursor()
            cur.executescript(
                """
                CREATE TABLE IF NOT EXISTS nodes (
                    hash            TEXT PRIMARY KEY,
                    node_type       TEXT NOT NULL,
                    timestamp       TEXT NOT NULL,
                    actor           TEXT NOT NULL,
                    session_key     TEXT NOT NULL,
                    session_id      TEXT NOT NULL,
                    event           TEXT NOT NULL,
                    status          TEXT NOT NULL,
                    branch_id       TEXT NOT NULL DEFAULT 'main',
                    transcript_ref  TEXT,
                    tool_name       TEXT,
                    tool_args_hash  TEXT,
                    duration_ms     INTEGER,
                    result_summary  TEXT,
                    file_path       TEXT,
                    content         TEXT,
                    trigger         TEXT,
                    subtype         TEXT,
                    summary         TEXT,
                    open_threads    TEXT,
                    token_count     INTEGER
                );
                CREATE TABLE IF NOT EXISTS node_parents (
                    child_hash  TEXT NOT NULL,
                    parent_hash TEXT NOT NULL,
                    PRIMARY KEY (child_hash, parent_hash)
                );
                CREATE INDEX IF NOT EXISTS idx_node_parents_child
                    ON node_parents(child_hash);
                CREATE INDEX IF NOT EXISTS idx_node_parents_parent
                    ON node_parents(parent_hash);
                CREATE TABLE IF NOT EXISTS tool_payloads (
                    args_hash   TEXT PRIMARY KEY,
                    args_json   TEXT NOT NULL,
                    output_json TEXT
                );
                CREATE TABLE IF NOT EXISTS meta (
                    key   TEXT PRIMARY KEY,
                    value TEXT NOT NULL
                );
                CREATE INDEX IF NOT EXISTS idx_session   ON nodes(session_id);
                CREATE INDEX IF NOT EXISTS idx_branch    ON nodes(branch_id);
                CREATE INDEX IF NOT EXISTS idx_status    ON nodes(status);
                CREATE INDEX IF NOT EXISTS idx_timestamp ON nodes(timestamp);
            """
            )
            self._migrate_nodes_schema(cur)
            if self._vec_enabled:
                cur.executescript(
                    """
                    CREATE VIRTUAL TABLE IF NOT EXISTS node_embeddings
                    USING vec0(
                        hash        TEXT PRIMARY KEY,
                        embedding   FLOAT[384]
                    );
                """
                )
            self._conn.commit()

    def _migrate_nodes_schema(self, cur: sqlite3.Cursor) -> None:
        info_rows = cur.execute("PRAGMA table_info(nodes)").fetchall()
        if not info_rows:
            return
        existing_columns = {row[1] for row in info_rows}
        column_types = {
            "node_type": "TEXT",
            "session_key": "TEXT",
            "transcript_ref": "TEXT",
            "tool_name": "TEXT",
            "tool_args_hash": "TEXT",
            "duration_ms": "INTEGER",
            "result_summary": "TEXT",
            "file_path": "TEXT",
            "content": "TEXT",
            "trigger": "TEXT",
            "subtype": "TEXT",
            "summary": "TEXT",
            "open_threads": "TEXT",
            "token_count": "INTEGER",
            "branch_id": "TEXT",
            "event": "TEXT",
            "status": "TEXT",
            "session_id": "TEXT",
            "timestamp": "TEXT",
            "actor": "TEXT",
            "hash": "TEXT",
        }
        for col in NODE_COLUMNS:
            if col not in existing_columns:
                cur.execute(f"ALTER TABLE nodes ADD COLUMN {col} {column_types[col]}")

    def save_node(self, node: TCCNode, parent_hashes: list[str]) -> None:
        with self._lock:
            try:
                cur = self._conn.cursor()
                self._write_node_no_commit(cur, node, parent_hashes)
                # Set root_hash on first ever node
                existing_root = self._conn.execute(
                    "SELECT value FROM meta WHERE key='root_hash'"
                ).fetchone()
                if existing_root is None:
                    self._conn.execute(
                        "INSERT OR IGNORE INTO meta(key,value) VALUES(?,?)",
                        ("root_hash", node.hash),
                    )
                self._conn.commit()
            except sqlite3.IntegrityError:
                self._conn.rollback()
                raise DuplicateNodeError(f"Node {node.hash} already exists")

    def _write_node_no_commit(
        self,
        cur: sqlite3.Cursor,
        node: TCCNode,
        parent_hashes: list[str],
    ) -> None:
        """Write node + parents without committing. Caller owns the transaction."""
        values = tuple(getattr(node, col) for col in NODE_COLUMNS)
        cur.execute(
            f"INSERT OR IGNORE INTO nodes "
            f"({', '.join(NODE_COLUMNS)}) "
            f"VALUES ({', '.join(['?'] * len(NODE_COLUMNS))})",
            values,
        )
        if parent_hashes:
            cur.executemany(
                "INSERT OR IGNORE INTO node_parents"
                "(child_hash, parent_hash) VALUES (?, ?)",
                [(node.hash, ph) for ph in parent_hashes],
            )

    def save_nodes_batch(self, items: list[tuple[TCCNode, list[str]]]) -> None:
        """Write multiple nodes in a single transaction."""
        with self._lock:
            cur = self._conn.cursor()
            for node, parent_hashes in items:
                self._write_node_no_commit(cur, node, parent_hashes)
            if items:
                existing_root = self._conn.execute(
                    "SELECT value FROM meta WHERE key='root_hash'"
                ).fetchone()
                if existing_root is None:
                    self._conn.execute(
                        "INSERT OR IGNORE INTO meta(key,value) VALUES(?,?)",
                        ("root_hash", items[0][0].hash),
                    )
            self._conn.commit()

    def save(self, node: TCCNode) -> None:
        parent_hashes = list(getattr(node, "parent_hashes", ()))
        self.save_node(node, parent_hashes=parent_hashes)

    def save_tool_payload(self, args_hash: str, args_json: str, output_json: str | None) -> None:
        with self._lock:
            self._conn.execute(
                "INSERT OR IGNORE INTO tool_payloads(args_hash, args_json, output_json) VALUES (?, ?, ?)",
                (args_hash, args_json, output_json),
            )
            self._conn.commit()

    def get_tool_payload(self, args_hash: str) -> dict | None:
        with self._lock:
            row = self._conn.execute(
                "SELECT args_json, output_json FROM tool_payloads WHERE args_hash=?",
                (args_hash,),
            ).fetchone()
        if not row:
            return None
        return {"args": row[0], "output": row[1]}

    def get_parents(self, hash: str) -> list[str]:
        with self._lock:
            rows = self._conn.execute(
                "SELECT parent_hash FROM node_parents WHERE child_hash=?",
                (hash,),
            ).fetchall()
        return [row[0] for row in rows]

    def get_children(self, hash: str) -> list[str]:
        with self._lock:
            rows = self._conn.execute(
                "SELECT child_hash FROM node_parents WHERE parent_hash=?",
                (hash,),
            ).fetchall()
        return [row[0] for row in rows]

    def ancestors(self, hash: str, max_depth: int = 50) -> list[str]:
        with self._lock:
            rows = self._conn.execute(
                """
                WITH RECURSIVE anc(hash, depth) AS (
                    SELECT parent_hash, 1
                    FROM node_parents
                    WHERE child_hash = ?
                    UNION ALL
                    SELECT np.parent_hash, anc.depth + 1
                    FROM node_parents np
                    JOIN anc ON np.child_hash = anc.hash
                    WHERE anc.depth < ?
                )
                SELECT hash, MIN(depth) AS d
                FROM anc
                GROUP BY hash
                ORDER BY d ASC;
                """,
                (hash, max_depth),
            ).fetchall()
        return [row[0] for row in rows]

    def descendants(self, hash: str, max_depth: int = 50) -> list[str]:
        with self._lock:
            rows = self._conn.execute(
                """
                WITH RECURSIVE des(hash, depth) AS (
                    SELECT child_hash, 1
                    FROM node_parents
                    WHERE parent_hash = ?
                    UNION ALL
                    SELECT np.child_hash, des.depth + 1
                    FROM node_parents np
                    JOIN des ON np.parent_hash = des.hash
                    WHERE des.depth < ?
                )
                SELECT hash, MIN(depth) AS d
                FROM des
                GROUP BY hash
                ORDER BY d ASC;
                """,
                (hash, max_depth),
            ).fetchall()
        return [row[0] for row in rows]

    def path_between(self, from_hash: str, to_hash: str) -> list[str]:
        if from_hash == to_hash:
            return [from_hash]
        queue: deque[str] = deque([from_hash])
        prev: dict[str, str | None] = {from_hash: None}

        while queue:
            current = queue.popleft()
            for child in self.get_children(current):
                if child in prev:
                    continue
                prev[child] = current
                if child == to_hash:
                    path = [to_hash]
                    node = to_hash
                    while prev[node] is not None:
                        node = prev[node]  # type: ignore[index]
                        path.append(node)
                    return list(reversed(path))
                queue.append(child)
        return []

    def get_node(self, hash: str) -> TCCNode:
        return self.load(hash)

    def load(self, hash: str) -> TCCNode:
        with self._lock:
            row = self._conn.execute(
                f"SELECT {', '.join(NODE_COLUMNS)} FROM nodes WHERE hash=?",
                (hash,),
            ).fetchone()
        if not row:
            raise NodeNotFoundError(f"Node {hash} not found")
        return self._row_to_node(row)

    def get_all_nodes(self) -> list[TCCNode]:
        return self.load_all()

    def load_all(self) -> list[TCCNode]:
        with self._lock:
            rows = self._conn.execute(
                f"SELECT {', '.join(NODE_COLUMNS)} FROM nodes ORDER BY timestamp ASC"
            ).fetchall()
        return [self._row_to_node(r) for r in rows]

    def nodes_for_session(self, session_id: str) -> list[TCCNode]:
        with self._lock:
            rows = self._conn.execute(
                f"SELECT {', '.join(NODE_COLUMNS)} FROM nodes WHERE session_id=? ORDER BY timestamp ASC",
                (session_id,),
            ).fetchall()
        return [self._row_to_node(row) for row in rows]

    def embed_all(
        self,
        show_progress: bool = False,
        batch_size: int = 32,
    ) -> int:
        if not self._vec_enabled:
            return 0

        try:
            from .embedder import get_embedder

            with self._lock:
                all_hashes = {r[0] for r in self._conn.execute("SELECT hash FROM nodes").fetchall()}
                embedded_hashes = {
                    r[0] for r in self._conn.execute("SELECT hash FROM node_embeddings").fetchall()
                }

            to_embed = list(all_hashes - embedded_hashes)
            if not to_embed:
                return 0

            nodes = [self.load(h) for h in to_embed]
            texts = [n.event for n in nodes]

            model = get_embedder()
            all_vecs = model.encode(
                texts,
                batch_size=batch_size,
                normalize_embeddings=True,
                show_progress_bar=show_progress,
            )

            with self._lock:
                for node, vec in zip(nodes, all_vecs):
                    self._conn.execute(
                        "INSERT OR REPLACE INTO node_embeddings(hash, embedding) VALUES (?, ?)",
                        (node.hash, json.dumps(vec.tolist())),
                    )
                self._conn.commit()

            return len(nodes)

        except Exception as exc:
            warnings.warn(f"embed_all failed: {exc}", UserWarning)
            return 0

    def update_status(self, hash: str, status: str) -> None:
        if status not in VALID_STATUSES:
            raise InvalidStatusError(f"Invalid status: {status}")
        with self._lock:
            affected = self._conn.execute(
                "UPDATE nodes SET status=? WHERE hash=?", (status, hash)
            ).rowcount
            self._conn.commit()
        if affected == 0:
            raise NodeNotFoundError(f"Node {hash} not found")
        if self._on_status_update:
            self._on_status_update(hash, status)

    def query_before(self, timestamp: str) -> list[TCCNode]:
        with self._lock:
            rows = self._conn.execute(
                f"SELECT {', '.join(NODE_COLUMNS)} FROM nodes WHERE timestamp < ? ORDER BY timestamp ASC",
                (timestamp,),
            ).fetchall()
        return [self._row_to_node(r) for r in rows]

    def delete(self, hashes: list[str]) -> None:
        with self._lock:
            self._conn.executemany("DELETE FROM nodes WHERE hash=?", [(h,) for h in hashes])
            self._conn.executemany("DELETE FROM node_parents WHERE child_hash=?", [(h,) for h in hashes])
            self._conn.executemany("DELETE FROM node_parents WHERE parent_hash=?", [(h,) for h in hashes])
            self._conn.commit()

    def get_meta(self, key: str) -> Optional[str]:
        with self._lock:
            row = self._conn.execute("SELECT value FROM meta WHERE key=?", (key,)).fetchone()
        return row[0] if row else None

    def set_meta(self, key: str, value: str) -> None:
        with self._lock:
            self._conn.execute("INSERT OR REPLACE INTO meta(key,value) VALUES(?,?)", (key, value))
            self._conn.commit()

    def get_tip_hash(self) -> Optional[str]:
        return self.get_meta("tip_hash")

    def set_tip_hash(self, hash: str) -> None:
        self.set_meta("tip_hash", hash)

    def get_branch_tip(self, branch_id: str) -> Optional[str]:
        return self.get_meta(f"branch_{branch_id}_tip")

    def set_branch_tip(self, branch_id: str, hash: str) -> None:
        self.set_meta(f"branch_{branch_id}_tip", hash)

    def get_all_branches(self) -> dict[str, str]:
        with self._lock:
            rows = self._conn.execute(
                "SELECT key, value FROM meta WHERE key LIKE 'branch_%_tip'"
            ).fetchall()
        result = {}
        for key, value in rows:
            branch_id = key[len("branch_") : -len("_tip")]
            if branch_id == "main":
                continue
            merged = self.get_meta(f"branch_{branch_id}_merged")
            if not merged:
                result[branch_id] = value
        return result

    def mark_branch_merged(self, branch_id: str) -> None:
        self.set_meta(f"branch_{branch_id}_merged", "true")

    def search(
        self,
        query: str,
        n: int = 5,
        session_id: str | None = None,
    ) -> list[TCCNode]:
        if not self._vec_enabled:
            return []

        try:
            from .embedder import embed

            vec = embed(query)
            vec_json = json.dumps(vec)

            with self._lock:
                if session_id:
                    rows = self._conn.execute(
                        """
                        SELECT n.hash
                        FROM node_embeddings e
                        JOIN nodes n ON e.hash = n.hash
                        WHERE n.session_id = ?
                        ORDER BY vec_distance_cosine(e.embedding, ?) ASC
                        LIMIT ?
                        """,
                        (session_id, vec_json, n),
                    ).fetchall()
                else:
                    rows = self._conn.execute(
                        """
                        SELECT hash
                        FROM node_embeddings
                        ORDER BY vec_distance_cosine(embedding, ?) ASC
                        LIMIT ?
                        """,
                        (vec_json, n),
                    ).fetchall()

            return [self.load(row[0]) for row in rows]
        except Exception as exc:
            warnings.warn(f"Search failed: {exc}", UserWarning)
            return []

    @property
    def is_vec_enabled(self) -> bool:
        return self._vec_enabled

    def _row_to_node(self, row) -> TCCNode:
        return TCCNode(
            hash=row[0],
            node_type=row[1],
            timestamp=row[2],
            actor=row[3],
            session_key=row[4],
            session_id=row[5],
            event=row[6],
            status=row[7],
            branch_id=row[8],
            transcript_ref=row[9],
            tool_name=row[10],
            tool_args_hash=row[11],
            duration_ms=row[12],
            result_summary=row[13],
            file_path=row[14],
            content=row[15],
            trigger=row[16],
            subtype=row[17],
            summary=row[18],
            open_threads=row[19],
            token_count=row[20],
        )


path: tcc/core/reconciler.py
content: from __future__ import annotations

import json
import uuid
from datetime import datetime, timezone
from typing import Optional

from .dag import TaskDAG
from .node import TCCNode


def _human_time(iso: str) -> str:
    try:
        then = datetime.fromisoformat(iso)
        now = datetime.now(timezone.utc)
        delta = now - then
        seconds = int(delta.total_seconds())
        if seconds < 60:
            return "just now"
        if seconds < 3600:
            return f"{seconds // 60} minutes ago"
        if seconds < 86400:
            return f"{seconds // 3600} hours ago"
        days = seconds // 86400
        if days == 1:
            return "yesterday"
        if days < 7:
            return f"{days} days ago"
        if days < 30:
            return f"{days // 7} weeks ago"
        if days < 365:
            return f"{days // 30} months ago"
        return f"{days // 365} years ago"
    except Exception:
        return iso


class SessionReconciler:
    def start_session(
        self,
        dag: TaskDAG,
        n_recent: int = 10,
        search_query: str | None = None,
        n_search: int = 5,
    ) -> dict:
        session_id = uuid.uuid4().hex[:12]
        tip = dag.tip()
        is_fresh = tip is None

        if is_fresh:
            summary = "No history yet. Starting fresh."
        else:
            summary = self._build_summary(
                dag,
                tip,
                n_recent=n_recent,
                search_query=search_query,
                n_search=n_search,
            )

        dag.append(
            event="session start",
            actor="system",
            session_id=session_id,
            node_type="milestone",
            session_key=f"agent:raven:{session_id}",
            subtype="start",
            status="running",
        )

        return {
            "session_id": session_id,
            "summary": summary,
            "tip": tip,
            "is_fresh": is_fresh,
        }

    def _build_summary(
        self,
        dag: TaskDAG,
        tip: TCCNode,
        n_recent: int = 10,
        search_query: str | None = None,
        n_search: int = 5,
    ) -> str:
        recent = dag.recent(n_recent)
        lines = [f"Last active: {_human_time(tip.timestamp)}", "", "Recent events:"]
        for node in recent[:7]:
            lines.append(f"  [{node.actor}] {node.event} ({_human_time(node.timestamp)})")
            if node.result_summary:
                lines.append(f"    → {node.result_summary}")
            elif node.summary:
                lines.append(f"    → {node.summary}")

        for node in recent:
            if node.open_threads:
                try:
                    threads = json.loads(node.open_threads)
                except json.JSONDecodeError:
                    continue
                if isinstance(threads, list) and threads:
                    lines.append("")
                    lines.append(f"Open threads: {', '.join(str(t) for t in threads)}")
                    break

        semantic_nodes = []
        if search_query and dag._store.is_vec_enabled:
            semantic_nodes = dag._store.search(search_query, n=n_search)
            recent_hashes = {n.hash for n in recent}
            semantic_nodes = [n for n in semantic_nodes if n.hash not in recent_hashes]

        if semantic_nodes:
            lines.append("")
            lines.append("Relevant historical context:")
            for node in semantic_nodes:
                lines.append(f"  [{node.actor}] {node.event}")

        return "\n".join(lines)

    def end_session(self, dag: TaskDAG, session_id: str, notes: str = "") -> Optional[TCCNode]:
        session_nodes = dag.since(session_id)
        if not session_nodes:
            return None
        return dag.append(
            event="session end",
            actor="system",
            session_id=session_id,
            node_type="milestone",
            session_key=f"agent:raven:{session_id}",
            subtype="end",
            summary=notes[:1000] if notes else None,
            status="done",
        )

    def record_event(
        self,
        dag: TaskDAG,
        session_id: str,
        event: str,
        actor: str,
        status: str = "done",
        node_type: str | None = None,
        session_key: str | None = None,
        subtype: str | None = None,
        result_summary: str | None = None,
        tool_name: str | None = None,
        transcript_ref: str | None = None,
    ) -> TCCNode:
        resolved_node_type = node_type or ("milestone" if actor == "system" else "action")
        if dag.tip() is None:
            return dag.root(event, actor, session_id)
        return dag.append(
            event=event,
            actor=actor,
            session_id=session_id,
            status=status,
            node_type=resolved_node_type,
            session_key=session_key or f"agent:raven:{session_id}",
            subtype=subtype,
            result_summary=result_summary,
            tool_name=tool_name,
            transcript_ref=transcript_ref,
        )

    def record_note(self, dag: TaskDAG, session_id: str, note: str) -> TCCNode:
        return dag.append(
            event=note[:120],
            actor="agent",
            session_id=session_id,
            node_type="decided",
            session_key=f"agent:raven:{session_id}",
            content=note[:500],
            trigger="explicit",
            status="done",
        )

    def record_tool_call(
        self,
        dag: TaskDAG,
        session_id: str,
        tool: str,
        params: dict,
        result: dict,
        status: str = "done",
    ) -> TCCNode:
        return self.record_event(
            dag,
            session_id,
            event=f"{tool} called",
            actor="tool",
            status=status,
            tool_name=tool,
            result_summary=str(result)[:200],
        )


path: tcc/core/dag.py
content: from __future__ import annotations

import json
import uuid
from datetime import datetime, timezone
from typing import Optional

from .node import TCCNode
from .store import DAGError, NodeNotFoundError, TCCStore


def _now() -> str:
    return datetime.now(timezone.utc).isoformat()


class TaskDAG:
    def __init__(self, store: TCCStore):
        self._store = store
        self._store._on_status_update = self._on_status_update
        self._index: dict[str, TCCNode] = {}
        self._parents: dict[str, list[str]] = {}
        self._tip_hash: Optional[str] = None
        self._branches: dict[str, str] = {}  # branch_id -> tip_hash
        self._load()

    def _load(self):
        nodes = self._store.load_all()
        for n in nodes:
            self._index[n.hash] = n
            self._parents[n.hash] = self._store.get_parents(n.hash)
        self._tip_hash = self._store.get_meta("tip_hash")
        self._branches = self._store.get_all_branches()

    def _save_node(self, node: TCCNode, parent_hashes: list[str]) -> None:
        self._store.save_node(node, parent_hashes)
        self._index[node.hash] = node
        self._parents[node.hash] = parent_hashes

    def _on_status_update(self, hash: str, status: str) -> None:
        if hash in self._index:
            old = self._index[hash]
            updated = TCCNode(
                hash=old.hash,
                node_type=old.node_type,
                timestamp=old.timestamp,
                actor=old.actor,
                session_key=old.session_key,
                session_id=old.session_id,
                event=old.event,
                status=status,
                branch_id=old.branch_id,
                transcript_ref=old.transcript_ref,
                tool_name=old.tool_name,
                tool_args_hash=old.tool_args_hash,
                duration_ms=old.duration_ms,
                result_summary=old.result_summary,
                file_path=old.file_path,
                content=old.content,
                trigger=old.trigger,
                subtype=old.subtype,
                summary=old.summary,
                open_threads=old.open_threads,
                token_count=old.token_count,
            )
            self._index[hash] = updated
        self._auto_merge_check()

    def root(self, event: str, actor: str, session_id: str) -> TCCNode:
        if self._tip_hash is not None:
            raise DAGError("Root already exists")
        node = TCCNode.create(
            node_type="action",
            timestamp=_now(),
            event=event,
            actor=actor,
            status="done",
            session_key=f"agent:raven:{session_id}",
            session_id=session_id,
            branch_id="main",
        )
        self._save_node(node, [])
        self._tip_hash = node.hash
        self._store.set_meta("tip_hash", node.hash)
        self._store.set_meta("root_hash", node.hash)
        return node

    def append(
        self,
        event: str,
        actor: str,
        session_id: str,
        parent_hash: Optional[str] = None,
        status: str = "done",
        node_type: str = "action",
        session_key: str | None = None,
        tool_name: str | None = None,
        result_summary: str | None = None,
        transcript_ref: str | None = None,
        subtype: str | None = None,
        summary: str | None = None,
        content: str | None = None,
        trigger: str | None = None,
        open_threads: str | None = None,
        duration_ms: int | None = None,
        token_count: int | None = None,
        file_path: str | None = None,
    ) -> TCCNode:
        parent = parent_hash or self._tip_hash
        if parent and parent not in self._index:
            raise NodeNotFoundError(f"Parent {parent} not found")
        parent_hashes = [parent] if parent else []
        node = TCCNode.create(
            node_type=node_type,
            timestamp=_now(),
            event=event,
            actor=actor,
            status=status,
            session_key=session_key or f"agent:raven:{session_id}",
            session_id=session_id,
            branch_id="main",
            transcript_ref=transcript_ref,
            tool_name=tool_name,
            duration_ms=duration_ms,
            result_summary=result_summary,
            file_path=file_path,
            content=content,
            trigger=trigger,
            subtype=subtype,
            summary=summary,
            open_threads=open_threads,
            token_count=token_count,
        )
        self._save_node(node, parent_hashes)
        self._tip_hash = node.hash
        self._store.set_meta("tip_hash", node.hash)
        return node

    def branch(
        self,
        from_hash: str,
        event: str,
        actor: str,
        session_id: str,
        status: str = "running",
        node_type: str = "action",
        session_key: str | None = None,
        tool_name: str | None = None,
        transcript_ref: str | None = None,
        result_summary: str | None = None,
    ) -> tuple[TCCNode, str]:
        if from_hash not in self._index:
            raise NodeNotFoundError(f"Node {from_hash} not found")
        branch_id = uuid.uuid4().hex[:8]
        while branch_id == "main" or branch_id in self._branches:
            branch_id = uuid.uuid4().hex[:8]
        parent_hashes = [from_hash]
        node = TCCNode.create(
            node_type=node_type,
            timestamp=_now(),
            event=event,
            actor=actor,
            status=status,
            session_key=session_key or f"agent:raven:{session_id}",
            session_id=session_id,
            branch_id=branch_id,
            transcript_ref=transcript_ref,
            tool_name=tool_name,
            result_summary=result_summary,
        )
        self._save_node(node, parent_hashes)
        self._branches[branch_id] = node.hash
        self._store.set_branch_tip(branch_id, node.hash)
        return node, branch_id

    def branch_from_tip(self, session_id: str) -> str:
        if not self._tip_hash:
            raise DAGError("Cannot branch from empty DAG")
        branch_id = uuid.uuid4().hex[:8]
        while branch_id == "main" or branch_id in self._branches:
            branch_id = uuid.uuid4().hex[:8]
        self._branches[branch_id] = self._tip_hash
        self._store.set_branch_tip(branch_id, self._tip_hash)
        return branch_id

    def merge(
        self,
        branch_hashes: list[str],
        event: str = "merged parallel branches",
        session_id: str = "system",
    ) -> TCCNode:
        for h in branch_hashes:
            if h not in self._index:
                raise NodeNotFoundError(f"Branch tip {h} not found")

        all_parents = list(dict.fromkeys(([self._tip_hash] if self._tip_hash else []) + branch_hashes))

        merge_node = TCCNode.create(
            node_type="milestone",
            timestamp=_now(),
            event=event,
            actor="system",
            status="done",
            session_key=f"agent:raven:{session_id}",
            session_id=session_id,
            branch_id="main",
            subtype="merge",
        )
        self._save_node(merge_node, all_parents)
        self._tip_hash = merge_node.hash
        self._store.set_meta("tip_hash", merge_node.hash)

        for bid in list(self._branches.keys()):
            if self._branches[bid] in branch_hashes:
                self._store.mark_branch_merged(bid)
                del self._branches[bid]

        return merge_node

    def update_status(self, hash: str, status: str) -> None:
        self._store.update_status(hash, status)

    def _auto_merge_check(self) -> Optional[TCCNode]:
        if not self._branches:
            return None

        branch_tips = {bid: self._index.get(h) for bid, h in self._branches.items()}

        for tip_node in branch_tips.values():
            if tip_node is None:
                return None
            if tip_node.status not in ("done", "failed"):
                return None

        branch_tip_hashes = list(self._branches.values())
        branch_events = [self._index[h].event for h in branch_tip_hashes if h in self._index]
        merge_event = f"merged: {', '.join(branch_events)}"
        session_id = next((self._index[h].session_id for h in branch_tip_hashes if h in self._index), "system")

        all_parents = list(dict.fromkeys(([self._tip_hash] if self._tip_hash else []) + branch_tip_hashes))

        merge_node = TCCNode.create(
            node_type="milestone",
            timestamp=_now(),
            event=merge_event,
            actor="system",
            status="done",
            session_key=f"agent:raven:{session_id}",
            session_id=session_id,
            branch_id="main",
            subtype="merge",
        )
        self._save_node(merge_node, all_parents)
        self._tip_hash = merge_node.hash
        self._store.set_meta("tip_hash", merge_node.hash)

        for bid in list(self._branches.keys()):
            self._store.mark_branch_merged(bid)
        self._branches.clear()

        return merge_node

    def rollback(self, n: int = 1) -> TCCNode:
        if not self._tip_hash:
            raise DAGError("Empty DAG, cannot rollback")
        current = self._index[self._tip_hash]
        for i in range(n):
            parents = self._parents.get(current.hash, [])
            if not parents:
                raise DAGError(f"Cannot rollback {n} hops, only {i} available")
            current = self._index[parents[0]]
        self._tip_hash = current.hash
        self._store.set_meta("tip_hash", current.hash)

        stale = []
        for bid, tip_hash in self._branches.items():
            if not self.is_ancestor_of_tip(tip_hash):
                stale.append(bid)
        for bid in stale:
            self._store.mark_branch_merged(bid)
            del self._branches[bid]

        return current

    def tip(self) -> Optional[TCCNode]:
        if not self._tip_hash:
            return None
        return self._index.get(self._tip_hash)

    def get(self, hash: str) -> TCCNode:
        if hash not in self._index:
            raise NodeNotFoundError(f"Node {hash} not found")
        return self._index[hash]

    def recent(self, n: int = 10) -> list[TCCNode]:
        if not self._tip_hash:
            return []
        result = []
        seen = set()
        frontier = [self._index[self._tip_hash]] if self._tip_hash in self._index else []
        while frontier and len(result) < n:
            frontier.sort(key=lambda node: node.timestamp, reverse=True)
            current = frontier.pop(0)
            if current.hash in seen:
                continue
            seen.add(current.hash)
            result.append(current)
            for ph in self._parents.get(current.hash, []):
                if ph in self._index and ph not in seen:
                    frontier.append(self._index[ph])
        return result

    def since(self, session_id: str) -> list[TCCNode]:
        return [n for n in self._index.values() if n.session_id == session_id]

    def path(self, from_hash: str, to_hash: str) -> list[TCCNode]:
        if from_hash not in self._index or to_hash not in self._index:
            return []
        from collections import deque

        queue = deque([[from_hash]])
        visited = {from_hash}
        while queue:
            path = queue.popleft()
            node = self._index[path[-1]]
            if node.hash == to_hash:
                return [self._index[h] for h in path]
            for child in self._get_children(node.hash):
                if child not in visited:
                    visited.add(child)
                    queue.append(path + [child])
        return []

    def _get_children(self, hash: str) -> list[str]:
        return self._store.get_children(hash)

    def is_ancestor_of_tip(self, hash: str) -> bool:
        if not self._tip_hash:
            return False
        visited = set()
        stack = [self._tip_hash]
        while stack:
            current = stack.pop()
            if current in visited:
                continue
            visited.add(current)
            if current == hash:
                return True
            for parent_hash in self._parents.get(current, []):
                if parent_hash not in visited:
                    stack.append(parent_hash)
        return False

    def summary(self, n: int = 10) -> str:
        tip = self.tip()
        if tip is None:
            return "No history yet."

        recent = self.recent(n)
        lines = [f"Last active: {self._human_time(tip.timestamp)}", "", "Recent events:"]
        for node in recent:
            lines.append(f"  [{node.actor}] {node.event} ({self._human_time(node.timestamp)})")
            if node.result_summary:
                lines.append(f"    → {node.result_summary}")
            elif node.subtype:
                lines.append(f"    → subtype: {node.subtype}")

        open_threads: list[str] = []
        for node in recent:
            if node.node_type == "milestone" and node.open_threads:
                try:
                    parsed = json.loads(node.open_threads)
                    if isinstance(parsed, list):
                        open_threads = [str(item) for item in parsed]
                        break
                except json.JSONDecodeError:
                    continue

        if open_threads:
            lines.append("")
            lines.append(f"Open threads: {', '.join(open_threads)}")

        return "\n".join(lines)

    @staticmethod
    def _human_time(iso: str) -> str:
        try:
            then = datetime.fromisoformat(iso)
            now = datetime.now(timezone.utc)
            delta = now - then
            seconds = int(delta.total_seconds())
            if seconds < 60:
                return "just now"
            if seconds < 3600:
                return f"{seconds // 60} minutes ago"
            if seconds < 86400:
                return f"{seconds // 3600} hours ago"
            days = seconds // 86400
            if days == 1:
                return "yesterday"
            if days < 7:
                return f"{days} days ago"
            if days < 30:
                return f"{days // 7} weeks ago"
            if days < 365:
                return f"{days // 30} months ago"
            return f"{days // 365} years ago"
        except Exception:
            return iso


path: tcc/core/__init__.py
content: from .store import TCCStore, TCCError, NodeNotFoundError, DuplicateNodeError, DAGError, InvalidStatusError, VALID_STATUSES
from .node import TCCNode
from .dag import TaskDAG
from .reconciler import SessionReconciler
from .embedder import embed, embed_node

__all__ = [
    "TCCStore", "TCCNode", "TaskDAG", "SessionReconciler",
    "TCCError", "NodeNotFoundError", "DuplicateNodeError",
    "DAGError", "InvalidStatusError", "VALID_STATUSES",
    "embed", "embed_node",
]


path: tcc/core/embedder.py
content: from __future__ import annotations

from functools import lru_cache


@lru_cache(maxsize=1)
def get_embedder():
    """
    Load the embedding model once and cache it.
    Uses all-MiniLM-L6-v2: 90MB, 384 dims, fast on CPU.
    """
    from sentence_transformers import SentenceTransformer

    return SentenceTransformer("all-MiniLM-L6-v2")


def embed(text: str) -> list[float]:
    """
    Embed a string and return a flat list of floats.
    Input is the node's event + plan text combined.
    """
    model = get_embedder()
    vec = model.encode(text, normalize_embeddings=True)
    return vec.tolist()


def embed_node(node) -> list[float]:
    """
    Embed a TCCNode. Combines event and plan for richer representation.
    """
    text = f"{node.event}. {node.plan}".strip(". ")
    return embed(text)


path: tcc/core/node.py
content: from __future__ import annotations

import hashlib
from dataclasses import dataclass
from typing import Any


@dataclass(frozen=True)
class TCCNode:
    hash: str
    node_type: str
    timestamp: str
    actor: str
    session_key: str
    session_id: str
    event: str
    status: str
    branch_id: str
    transcript_ref: str | None = None
    tool_name: str | None = None
    tool_args_hash: str | None = None
    duration_ms: int | None = None
    result_summary: str | None = None
    file_path: str | None = None
    content: str | None = None
    trigger: str | None = None
    subtype: str | None = None
    summary: str | None = None
    open_threads: str | None = None
    token_count: int | None = None

    @staticmethod
    def _truncate(value: str | None, max_len: int) -> str | None:
        if value is None or len(value) <= max_len:
            return value
        return value[:max_len] + "…"

    @staticmethod
    def compute_hash(
        timestamp: str,
        session_id: str,
        event: str,
        node_type: str,
    ) -> str:
        content = f"{timestamp}{session_id}{event}{node_type}"
        return hashlib.sha256(content.encode("utf-8")).hexdigest()

    @classmethod
    def create(
        cls,
        node_type: str,
        timestamp: str,
        actor: str,
        session_key: str,
        session_id: str,
        event: str,
        status: str,
        branch_id: str = "main",
        transcript_ref: str | None = None,
        tool_name: str | None = None,
        tool_args_hash: str | None = None,
        duration_ms: int | None = None,
        result_summary: str | None = None,
        file_path: str | None = None,
        content: str | None = None,
        trigger: str | None = None,
        subtype: str | None = None,
        summary: str | None = None,
        open_threads: str | None = None,
        token_count: int | None = None,
        **_: Any,
    ) -> "TCCNode":
        event = cls._truncate(event, 120) or ""
        result_summary = cls._truncate(result_summary, 200)
        content = cls._truncate(content, 500)
        summary = cls._truncate(summary, 1000)

        h = cls.compute_hash(
            timestamp=timestamp,
            session_id=session_id,
            event=event,
            node_type=node_type,
        )
        return cls(
            hash=h,
            node_type=node_type,
            timestamp=timestamp,
            actor=actor,
            session_key=session_key,
            session_id=session_id,
            event=event,
            status=status,
            branch_id=branch_id,
            transcript_ref=transcript_ref,
            tool_name=tool_name,
            tool_args_hash=tool_args_hash,
            duration_ms=duration_ms,
            result_summary=result_summary,
            file_path=file_path,
            content=content,
            trigger=trigger,
            subtype=subtype,
            summary=summary,
            open_threads=open_threads,
            token_count=token_count,
        )

    def to_dict(self) -> dict[str, Any]:
        return {
            "hash": self.hash,
            "node_type": self.node_type,
            "timestamp": self.timestamp,
            "actor": self.actor,
            "session_key": self.session_key,
            "session_id": self.session_id,
            "event": self.event,
            "status": self.status,
            "branch_id": self.branch_id,
            "transcript_ref": self.transcript_ref,
            "tool_name": self.tool_name,
            "tool_args_hash": self.tool_args_hash,
            "duration_ms": self.duration_ms,
            "result_summary": self.result_summary,
            "file_path": self.file_path,
            "content": self.content,
            "trigger": self.trigger,
            "subtype": self.subtype,
            "summary": self.summary,
            "open_threads": self.open_threads,
            "token_count": self.token_count,
        }

    @classmethod
    def from_dict(cls, payload: dict[str, Any]) -> "TCCNode":
        return cls(
            hash=payload["hash"],
            node_type=payload["node_type"],
            timestamp=payload["timestamp"],
            actor=payload["actor"],
            session_key=payload["session_key"],
            session_id=payload["session_id"],
            event=payload["event"],
            status=payload["status"],
            branch_id=payload.get("branch_id", "main"),
            transcript_ref=payload.get("transcript_ref"),
            tool_name=payload.get("tool_name"),
            tool_args_hash=payload.get("tool_args_hash"),
            duration_ms=payload.get("duration_ms"),
            result_summary=payload.get("result_summary"),
            file_path=payload.get("file_path"),
            content=payload.get("content"),
            trigger=payload.get("trigger"),
            subtype=payload.get("subtype"),
            summary=payload.get("summary"),
            open_threads=payload.get("open_threads"),
            token_count=payload.get("token_count"),
        )


path: benchmark/test_raven_features.py
content: from __future__ import annotations

import os
import tempfile

from tcc.core.dag import TaskDAG
from tcc.core.reconciler import SessionReconciler
from tcc.core.store import TCCStore


def main() -> None:
    with tempfile.TemporaryDirectory() as tmp:
        db_path = os.path.join(tmp, "raven_features.db")
        store = TCCStore(db_path)
        dag = TaskDAG(store)
        reconciler = SessionReconciler()

        # Fresh DAG initializes correctly
        assert dag.tip() is None
        print("[PASS] fresh DAG initializes")

        # Session start + append advances tip
        session_ctx = reconciler.start_session(dag)
        sid = session_ctx["session_id"]
        start_tip = dag.tip()
        assert start_tip is not None
        assert start_tip.node_type == "milestone"
        assert start_tip.subtype == "start"
        print("[PASS] start_session creates milestone start node")

        appended = dag.append(event="implemented parser", actor="agent", session_id=sid)
        assert dag.tip() is not None and dag.tip().hash == appended.hash
        assert appended.status == "done"
        print("[PASS] append adds node and advances tip")

        # node_parents table tracking
        parents = store.get_parents(appended.hash)
        assert isinstance(parents, list)
        assert len(parents) == 1
        print(f"[PASS] node_parents: {len(parents)} parents")

        # branch_from_tip writes no node
        count_before_branch = len(store.load_all())
        branch_id = dag.branch_from_tip(session_id=sid)
        count_after_branch = len(store.load_all())
        assert isinstance(branch_id, str)
        assert branch_id in dag._branches
        assert count_before_branch == count_after_branch
        print(f"[PASS] branch_from_tip: {branch_id}")

        # branch() creates running node
        parent_hash = dag.tip().hash
        branch_node, branch_bid = dag.branch(
            from_hash=parent_hash,
            event="parallel branch task",
            actor="agent",
            session_id=sid,
            status="running",
        )
        assert branch_node.status == "running"
        assert dag._parents[branch_node.hash] == [parent_hash]
        assert branch_bid in dag._branches
        print("[PASS] branch creates running branch node")

        # public merge()
        node_a, _ = dag.branch(from_hash=dag.tip().hash, event="task A", actor="agent", session_id=sid)
        node_b, _ = dag.branch(from_hash=dag.tip().hash, event="task B", actor="agent", session_id=sid)
        dag.update_status(node_a.hash, "done")
        dag.update_status(node_b.hash, "done")
        merge = dag.merge([node_a.hash, node_b.hash], session_id=sid)
        assert merge.node_type == "milestone"
        assert merge.subtype == "merge"
        assert len(dag._parents[merge.hash]) >= 2
        print(f"[PASS] public merge: {merge.event}")

        # auto-merge when all branches done/failed
        base = dag.tip().hash
        auto_a, _ = dag.branch(from_hash=base, event="auto A", actor="agent", session_id=sid, status="running")
        auto_b, _ = dag.branch(from_hash=base, event="auto B", actor="agent", session_id=sid, status="running")
        tip_before_done = dag.tip().hash
        dag.update_status(auto_a.hash, "failed")
        assert dag.tip().hash == tip_before_done
        dag.update_status(auto_b.hash, "done")
        auto_merge_tip = dag.tip()
        assert auto_merge_tip is not None
        assert auto_merge_tip.node_type == "milestone"
        assert auto_merge_tip.subtype == "merge"
        print("[PASS] auto-merge fires on done/failed branches")

        # recent() most-recent-first and ancestor checks
        recent = dag.recent(5)
        assert len(recent) > 0
        assert recent[0].timestamp >= recent[-1].timestamp
        assert dag.is_ancestor_of_tip(recent[-1].hash) is True
        print("[PASS] recent ordering + is_ancestor_of_tip")

        # since(session_id)
        session_nodes = dag.since(sid)
        assert len(session_nodes) > 0
        assert all(node.session_id == sid for node in session_nodes)
        print("[PASS] since(session_id) returns correct nodes")

        # rollback moves tip and clears stale branches
        rollback_from = dag.tip().hash
        stale_branch_node, stale_bid = dag.branch(
            from_hash=rollback_from,
            event="stale branch",
            actor="agent",
            session_id=sid,
            status="running",
        )
        _ = stale_branch_node
        dag.append(event="main progress", actor="agent", session_id=sid)
        rolled = dag.rollback(1)
        assert dag.tip() is not None and dag.tip().hash == rolled.hash
        assert stale_bid not in dag._branches
        print("[PASS] rollback moves tip and clears stale branches")

        # Reconciler note/event APIs on new schema
        note_node = reconciler.record_note(dag, sid, "Need to revisit constraints")
        assert note_node.node_type == "decided"
        assert note_node.trigger == "explicit"

        event_node = reconciler.record_event(
            dag=dag,
            session_id=sid,
            event="ran smoke tests",
            actor="tool",
            result_summary="all green",
            tool_name="pytest",
        )
        assert event_node.result_summary == "all green"
        assert event_node.tool_name == "pytest"
        print("[PASS] reconciler record_note/record_event new signatures")

        # End session + persistence
        end = reconciler.end_session(dag, sid, notes="session complete")
        assert end is not None
        assert end.subtype == "end"
        assert end.summary == "session complete"

        store_reloaded = TCCStore(db_path)
        dag_reloaded = TaskDAG(store_reloaded)
        assert dag_reloaded.tip() is not None
        assert len(dag_reloaded.recent(20)) > 0
        print("[PASS] persistence across store reload")

        # CTE traversal + path
        tip = dag_reloaded.tip()
        assert tip is not None
        ancestors = store_reloaded.ancestors(tip.hash, max_depth=50)
        assert len(ancestors) > 0
        print(f"[PASS] ancestors CTE: {len(ancestors)} ancestors found")

        root_hash = store_reloaded.get_meta("root_hash")
        if root_hash:
            path_hashes = store_reloaded.path_between(root_hash, tip.hash)
            assert path_hashes[0] == root_hash
            assert path_hashes[-1] == tip.hash
            print("[PASS] path_between returns valid root->tip path")

        # Session 2 resumes session 1 history
        reconciler2 = SessionReconciler()
        ctx2 = reconciler2.start_session(dag_reloaded, search_query="smoke")
        assert ctx2["is_fresh"] is False
        assert isinstance(ctx2["summary"], str) and len(ctx2["summary"]) > 0
        print("[PASS] session 2 resumes session 1 context")


if __name__ == "__main__":
    main()


path: benchmark/test_scale_v2.py
content: """
Raven Scale Test v2 — 500 nodes, WAL, concurrent multi-agent writes

Tests:
1.  Write 500 nodes via save_nodes_batch() — timing
2.  recent(20) at scale
3.  CTE ancestor traversal at depth
4.  path_between root → tip
5.  Semantic search across 500 nodes
6.  Branch/merge at scale
7.  Rollback at depth
8.  Session resume with full history
9.  nodes_for_session query
10. Concurrent multi-agent writes (10 agents, 50 nodes each = 500 nodes)
11. WAL mode confirmed
"""
from __future__ import annotations

import json
import os
import tempfile
import threading
import time
from datetime import datetime, timezone

from tcc.core.dag import TaskDAG
from tcc.core.node import TCCNode
from tcc.core.reconciler import SessionReconciler
from tcc.core.store import TCCStore


def elapsed(start: float) -> str:
    ms = (time.time() - start) * 1000
    return f"{ms:.1f}ms"


def now() -> str:
    return datetime.now(timezone.utc).isoformat()


def main() -> None:
    print("=" * 60)
    print("Raven Scale Test v2 — 500 nodes + concurrent agents")
    print("=" * 60)

    with tempfile.TemporaryDirectory() as tmp:
        db_path = os.path.join(tmp, "raven_scale.db")
        store = TCCStore(db_path)
        dag = TaskDAG(store)
        reconciler = SessionReconciler()

        # ── Test 11: WAL mode confirmed ──────────────────────────────────
        print("\n=== Test 11: WAL mode ===")
        row = store._conn.execute("PRAGMA journal_mode").fetchone()
        wal_mode = row[0] if row else "unknown"
        print(f"  journal_mode: {wal_mode}")
        assert wal_mode == "wal", f"Expected WAL, got {wal_mode}"
        sync = store._conn.execute("PRAGMA synchronous").fetchone()
        print(f"  synchronous: {sync[0]} (1=NORMAL)")
        print("[PASS] WAL mode enabled")

        # ── Test 1: Batch write 500 nodes ────────────────────────────────
        print("\n=== Test 1: Batch write 500 nodes ===")
        ctx = reconciler.start_session(dag)
        sid = ctx["session_id"]

        event_templates = [
            ("ran unit tests", "tool", "action", "pytest", "all 42 tests passed"),
            ("user updated budget cap to $8000", "user", "decided", None, None),
            ("deployed to staging", "agent", "action", "deploy.sh", "exit 0"),
            ("session checkpoint", "system", "milestone", None, None),
            ("read config file", "agent", "action", "read_file", "loaded 14 settings"),
            ("wrote memory: prefers concise answers", "agent", "decided", None, None),
            ("ran smoke tests on staging", "tool", "action", "smoke_runner", "3/3 passed"),
            ("user confirmed go-ahead for prod", "user", "decided", None, None),
        ]

        # Build 500 nodes as a batch
        items: list[tuple[TCCNode, list[str]]] = []
        prev_hash = dag.tip().hash if dag.tip() else None

        t_build = time.time()
        for i in range(500):
            tmpl = event_templates[i % len(event_templates)]
            event_text, actor, node_type, tool_name, result_summary = tmpl
            node = TCCNode.create(
                node_type=node_type,
                timestamp=now(),
                event=f"{event_text} #{i}",
                actor=actor,
                session_key=f"agent:raven:{sid}",
                session_id=sid,
                status="done",
                branch_id="main",
                tool_name=tool_name,
                result_summary=result_summary,
                open_threads=json.dumps(["task alpha", "task beta"]) if i % 50 == 0 else None,
            )
            parent_hashes = [prev_hash] if prev_hash else []
            items.append((node, parent_hashes))
            prev_hash = node.hash

        build_time = elapsed(t_build)

        t_write = time.time()
        store.save_nodes_batch(items)
        write_time = elapsed(t_write)

        # Update dag _index and _parents after batch write
        for node, parent_hashes in items:
            dag._index[node.hash] = node
            dag._parents[node.hash] = parent_hashes

        # Advance tip to last node
        last_node = items[-1][0]
        dag._tip_hash = last_node.hash
        store.set_meta("tip_hash", last_node.hash)

        total_nodes = len(store.load_all())
        print(f"  Built 500 nodes in {build_time}")
        print(f"  Batch wrote 500 nodes in {write_time}")
        print(f"  Total nodes in store: {total_nodes}")
        assert total_nodes >= 500
        print(f"[PASS] 500 nodes batch written — {write_time}")

        # ── Test 2: recent(20) at scale ──────────────────────────────────
        print("\n=== Test 2: recent(20) at scale ===")
        t_recent = time.time()
        recent = dag.recent(20)
        recent_time = elapsed(t_recent)
        assert len(recent) == 20
        assert recent[0].timestamp >= recent[-1].timestamp
        print(f"  recent(20) in {recent_time}")
        print(f"[PASS] recent() correct — {recent_time}")

        # ── Test 3: CTE ancestor traversal ──────────────────────────────
        print("\n=== Test 3: CTE ancestor traversal ===")
        tip = dag.tip()
        t_anc = time.time()
        ancestors_100 = store.ancestors(tip.hash, max_depth=100)
        anc_100_time = elapsed(t_anc)

        t_anc2 = time.time()
        ancestors_500 = store.ancestors(tip.hash, max_depth=500)
        anc_500_time = elapsed(t_anc2)

        print(f"  ancestors(100): {len(ancestors_100)} nodes in {anc_100_time}")
        print(f"  ancestors(500): {len(ancestors_500)} nodes in {anc_500_time}")
        assert len(ancestors_100) > 0
        assert len(ancestors_500) >= len(ancestors_100)
        print(f"[PASS] CTE traversal — {anc_500_time}")

        # ── Test 4: path_between root → tip ─────────────────────────────
        print("\n=== Test 4: path_between root → tip ===")
        root_hash = store.get_meta("root_hash")
        if root_hash:
            t_path = time.time()
            path = store.path_between(root_hash, tip.hash)
            path_time = elapsed(t_path)
            print(f"  path length: {len(path)} hops in {path_time}")
            assert path[0] == root_hash
            assert path[-1] == tip.hash
            print(f"[PASS] path_between — {path_time}")
        else:
            print("[SKIP] root_hash not in meta")

        # ── Test 5: Semantic search ──────────────────────────────────────
        print("\n=== Test 5: Semantic search at scale ===")
        if store.is_vec_enabled:
            t_embed = time.time()
            n_embedded = store.embed_all(show_progress=False, batch_size=64)
            embed_time = elapsed(t_embed)
            print(f"  Embedded {n_embedded} nodes in {embed_time}")

            queries = [
                "budget cap updated",
                "staging deployment",
                "smoke tests passed",
                "user confirmed production",
            ]
            for q in queries:
                t_s = time.time()
                results = store.search(q, n=5)
                print(f"  search('{q}'): {len(results)} results in {elapsed(t_s)}")
                assert len(results) > 0
            print("[PASS] semantic search at scale")
        else:
            print("[SKIP] sqlite-vec not enabled")

        # ── Test 6: Branch/merge at scale ───────────────────────────────
        print("\n=== Test 6: Branch/merge at scale (10 branches) ===")
        base = dag.tip().hash
        branch_hashes = []
        t_branch = time.time()
        for i in range(10):
            node, _ = dag.branch(
                from_hash=base,
                event=f"parallel task {i}",
                actor="agent",
                session_id=sid,
                status="running",
            )
            dag.update_status(node.hash, "done")
            branch_hashes.append(node.hash)
        branch_time = elapsed(t_branch)

        t_merge = time.time()
        merge = dag.merge(branch_hashes, event="merged 10 parallel tasks", session_id=sid)
        merge_time = elapsed(t_merge)

        assert merge.subtype == "merge"
        assert len(dag._parents[merge.hash]) >= 2
        print(f"  10 branches created+closed in {branch_time}")
        print(f"  Merged in {merge_time}")
        print(f"[PASS] branch/merge at scale")

        # ── Test 7: Rollback at depth ────────────────────────────────────
        print("\n=== Test 7: Rollback at depth (50 hops) ===")
        tip_before = dag.tip().hash
        t_rb = time.time()
        rolled = dag.rollback(50)
        rb_time = elapsed(t_rb)
        assert rolled.hash != tip_before
        assert dag.tip().hash == rolled.hash
        print(f"  rollback(50) in {rb_time}")
        print(f"  Landed on: {rolled.event}")
        print(f"[PASS] rollback at depth — {rb_time}")

        # ── Test 8: Session resume ───────────────────────────────────────
        print("\n=== Test 8: Session resume with full history ===")
        reconciler.end_session(dag, sid, notes="scale test session complete")

        t_reload = time.time()
        store2 = TCCStore(db_path)
        dag2 = TaskDAG(store2)
        reload_time = elapsed(t_reload)

        t_resume = time.time()
        ctx2 = SessionReconciler().start_session(
            dag2,
            search_query="staging deployment",
            n_recent=10,
        )
        resume_time = elapsed(t_resume)

        assert ctx2["is_fresh"] is False
        assert len(ctx2["summary"]) > 0
        print(f"  Reload {total_nodes}+ nodes in {reload_time}")
        print(f"  Session resume in {resume_time}")
        print(f"  Summary preview: {ctx2['summary'][:80]}...")
        print(f"[PASS] session resume — {reload_time} reload, {resume_time} resume")

        # ── Test 9: nodes_for_session ────────────────────────────────────
        print("\n=== Test 9: nodes_for_session ===")
        t_sq = time.time()
        session_nodes = store2.nodes_for_session(sid)
        sq_time = elapsed(t_sq)
        assert len(session_nodes) > 0
        print(f"  {len(session_nodes)} nodes in {sq_time}")
        print(f"[PASS] nodes_for_session — {sq_time}")

        # ── Test 10: Concurrent multi-agent writes ───────────────────────
        print("\n=== Test 10: Concurrent multi-agent writes ===")
        print("  10 agents × 50 nodes each = 500 nodes")

        store_ma = TCCStore(db_path)
        dag_ma = TaskDAG(store_ma)
        ctx_ma = SessionReconciler().start_session(dag_ma)
        sid_ma = ctx_ma["session_id"]
        fork = dag_ma.tip().hash

        errors: list[str] = []
        written_hashes: list[str] = []
        lock = threading.Lock()

        def agent_write(agent_id: int) -> None:
            # Each agent gets its own store connection
            agent_store = TCCStore(db_path)
            agent_dag = TaskDAG(agent_store)

            parent = fork
            batch: list[tuple[TCCNode, list[str]]] = []

            for i in range(50):
                try:
                    node = TCCNode.create(
                        node_type="action",
                        timestamp=now(),
                        event=f"agent_{agent_id} task {i}",
                        actor="agent",
                        session_key=f"agent:raven:{sid_ma}",
                        session_id=sid_ma,
                        status="done",
                        branch_id=f"agent_{agent_id}",
                        tool_name="agent_tool",
                        result_summary=f"agent {agent_id} result {i}",
                    )
                    batch.append((node, [parent]))
                    parent = node.hash
                except Exception as exc:
                    with lock:
                        errors.append(f"agent_{agent_id} build {i}: {exc}")
                    return

            try:
                agent_store.save_nodes_batch(batch)
                with lock:
                    written_hashes.extend(n.hash for n, _ in batch)
            except Exception as exc:
                with lock:
                    errors.append(f"agent_{agent_id} batch write: {exc}")

        t_concurrent = time.time()
        threads = [threading.Thread(target=agent_write, args=(i,)) for i in range(10)]
        for t in threads:
            t.start()
        for t in threads:
            t.join()
        concurrent_time = elapsed(t_concurrent)

        assert len(errors) == 0, f"Concurrent write errors: {errors}"
        assert len(written_hashes) == 500, f"Expected 500, got {len(written_hashes)}"
        assert len(set(written_hashes)) == 500, "Duplicate hashes detected"

        # Verify all nodes readable
        verify_store = TCCStore(db_path)
        missing = []
        for h in written_hashes[:50]:  # spot check 50
            try:
                verify_store.load(h)
            except Exception:
                missing.append(h)
        assert len(missing) == 0, f"{len(missing)} nodes missing after concurrent write"

        print(f"  500 nodes written by 10 concurrent agents in {concurrent_time}")
        print(f"  Errors: {len(errors)}")
        print(f"  Unique hashes: {len(set(written_hashes))}")
        print(f"  Spot check (50 nodes): all readable")
        print(f"[PASS] concurrent multi-agent writes — {concurrent_time}")

        # ── Summary ──────────────────────────────────────────────────────
        final_count = len(verify_store.load_all())
        print("\n" + "=" * 60)
        print("ALL SCALE TESTS PASSED")
        print("=" * 60)
        print(f"\nFinal node count : {final_count}")
        print(f"WAL mode         : {wal_mode}")
        print(f"sqlite-vec       : {'enabled' if store.is_vec_enabled else 'disabled'}")
        print(f"\nKey timings:")
        print(f"  Batch write 500 : {write_time}")
        print(f"  recent(20)      : {recent_time}")
        print(f"  CTE 500 deep    : {anc_500_time}")
        print(f"  Store reload    : {reload_time}")
        print(f"  Session resume  : {resume_time}")
        print(f"  10-agent conc.  : {concurrent_time}")


if __name__ == "__main__":
    main()


path: benchmark/test_openclaw_integration.py
content: """
Test Raven MCP server integration without needing OpenClaw.
Simulates JSON-RPC calls over subprocess stdio.

Usage:
    python3 test_openclaw_integration.py
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import tempfile
import time


def rpc(proc, method: str, params: dict = {}, id: int = 1) -> dict:
    """Send a JSON-RPC request and read the response."""
    req = json.dumps({
        "jsonrpc": "2.0",
        "id": id,
        "method": method,
        "params": params,
    }) + "\n"
    proc.stdin.write(req.encode())
    proc.stdin.flush()

    # Read response
    line = proc.stdout.readline().decode().strip()
    if not line:
        return {}
    return json.loads(line)


def notify(proc, method: str, params: dict = {}):
    """Send a JSON-RPC notification (no response expected)."""
    req = json.dumps({
        "jsonrpc": "2.0",
        "method": method,
        "params": params,
    }) + "\n"
    proc.stdin.write(req.encode())
    proc.stdin.flush()


def call_tool(proc, tool_name: str, arguments: dict = {}, id: int = 1) -> dict:
    """Call an MCP tool and return the parsed result."""
    resp = rpc(proc, "tools/call", {
        "name": tool_name,
        "arguments": arguments,
    }, id=id)
    if "error" in resp:
        raise RuntimeError(f"Tool error: {resp['error']}")
    content = resp.get("result", {}).get("content", [])
    if content:
        return json.loads(content[0]["text"])
    return {}


def main():
    with tempfile.TemporaryDirectory() as tmp_dir:
        db_path = os.path.join(tmp_dir, "test_raven.db")
        env = {**os.environ, "RAVEN_DB_PATH": db_path}

        print("Starting Raven MCP server...")
        proc = subprocess.Popen(
            [sys.executable, "-m", "tcc.integration.mcp_server"],
            stdin=subprocess.PIPE,
            stdout=subprocess.PIPE,
            stderr=subprocess.PIPE,
            env=env,
        )
        time.sleep(0.5)  # Let server initialize

        try:
            print("\n=== Test 1: MCP handshake ===")
            resp = rpc(proc, "initialize", {
                "protocolVersion": "2024-11-05",
                "capabilities": {},
                "clientInfo": {"name": "test-client", "version": "1.0"},
            })
            assert resp["result"]["serverInfo"]["name"] == "raven-memory"
            notify(proc, "initialized")
            print("[PASS] MCP handshake")

            print("\n=== Test 2: List tools ===")
            resp = rpc(proc, "tools/list", id=2)
            tools = {t["name"] for t in resp["result"]["tools"]}
            expected = {
                "raven_start_session",
                "raven_record_event",
                "raven_end_session",
                "raven_search",
                "raven_rollback",
                "raven_get_status",
            }
            assert expected == tools, f"Missing tools: {expected - tools}"
            print(f"[PASS] {len(tools)} tools available")

            print("\n=== Test 3: Start session (fresh) ===")
            result = call_tool(proc, "raven_start_session", {
                "search_query": "iron man repulsor project",
            }, id=3)
            assert result["is_fresh"] is True
            assert "session_id" in result
            assert "summary" in result
            session_id = result["session_id"]
            print(f"[PASS] Session started: {session_id}")
            print(f"  Summary: {result['summary'][:80]}...")

            print("\n=== Test 4: Record events ===")
            r1 = call_tool(proc, "raven_record_event", {
                "event": "started repulsor geometry simulation",
                "actor": "agent",
                "result_summary": "validate aerodynamic profile",
                "tool_name": "run_simulation",
            }, id=4)
            assert r1["recorded"] is True

            r2 = call_tool(proc, "raven_record_event", {
                "event": "user approved titanium housing switch",
                "actor": "user",
                "result_summary": "carbon fiber too brittle under load",
            }, id=5)
            assert r2["recorded"] is True
            print("[PASS] 2 events recorded")

            print("\n=== Test 5: Get status ===")
            status = call_tool(proc, "raven_get_status", id=6)
            assert status["total_nodes"] >= 2
            assert status["is_fresh"] is False
            print(
                f"[PASS] {status['total_nodes']} nodes, "
                f"vec={'enabled' if status['vec_enabled'] else 'disabled'}"
            )

            print("\n=== Test 6: End session ===")
            end = call_tool(proc, "raven_end_session", {
                "notes": "sim passed, switching to titanium"
            }, id=7)
            assert end["recorded"] is True
            print("[PASS] Session ended")

            print("\n=== Test 7: Persistence across restart ===")
            proc.terminate()
            proc.wait()
            time.sleep(0.2)

            # Restart server with same DB
            proc = subprocess.Popen(
                [sys.executable, "-m", "tcc.integration.mcp_server"],
                stdin=subprocess.PIPE,
                stdout=subprocess.PIPE,
                stderr=subprocess.PIPE,
                env=env,
            )
            time.sleep(0.5)

            # Re-handshake
            rpc(proc, "initialize", {
                "protocolVersion": "2024-11-05",
                "capabilities": {},
                "clientInfo": {"name": "test-client", "version": "1.0"},
            }, id=1)
            notify(proc, "initialized")

            # Start new session — should load history
            ctx = call_tool(proc, "raven_start_session", {
                "search_query": "repulsor simulation",
            }, id=2)
            assert ctx["is_fresh"] is False
            assert (
                "repulsor" in ctx["summary"].lower()
                or "titanium" in ctx["summary"].lower()
                or "simulation" in ctx["summary"].lower()
            )
            print("[PASS] History survived restart")
            print(f"  Summary preview: {ctx['summary'][:120]}...")

            print("\n=== Test 8: Semantic search ===")
            if status["vec_enabled"]:
                results = call_tool(proc, "raven_search", {
                    "query": "material switch decision",
                    "n": 3,
                }, id=3)
                print(f"[PASS] Search returned {results['n']} results")
                for result in results["results"]:
                    print(f"  - {result['event']}")
            else:
                print("[SKIP] sqlite-vec not enabled")

            print("\n=== Test 9: Rollback ===")
            call_tool(proc, "raven_get_status", id=4)
            rb = call_tool(proc, "raven_rollback", {"steps": 1}, id=5)
            assert "rolled_back_to" in rb
            print(f"[PASS] Rolled back to: {rb['rolled_back_to']}")

            print("\n" + "=" * 50)
            print("ALL OPENCLAW INTEGRATION TESTS PASSED")
            print("=" * 50)
            print("\nRaven MCP server is OpenClaw-compatible.")
            print("Add to openclaw config:")
            print(
                """
{
  "mcpServers": {
    "raven-memory": {
      "command": "python3",
      "args": ["-m", "tcc.integration.mcp_server"],
      "env": {
        "RAVEN_DB_PATH": "~/.raven/raven.db"
      }
    }
  }
}
"""
            )

        finally:
            proc.terminate()
            proc.wait()


if __name__ == "__main__":
    main()


path: benchmark/causal_mem_bench.py
content: import json
import os
import sys
import tempfile
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Callable

sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))

from tcc.core.dag import TaskDAG
from tcc.core.node import TCCNode
from tcc.core.reconciler import SessionReconciler
from tcc.core.store import TCCStore


@dataclass
class Question:
    id: str
    category: str
    question: str
    answer: str
    query_fn: Callable[[TaskDAG, TCCStore, dict], str]


@dataclass
class Scenario:
    id: str
    description: str
    questions: list[Question]
    build_fn: Callable[[TaskDAG, TCCStore, str], dict]


def _now() -> str:
    return datetime.now(timezone.utc).isoformat()


def _create_node(
    node_type: str,
    sid: str,
    event: str,
    status: str = "done",
    *,
    subtype: str | None = None,
    summary: str | None = None,
    content: str | None = None,
    open_threads: str | None = None,
    branch_id: str = "main",
) -> TCCNode:
    return TCCNode.create(
        node_type=node_type,
        timestamp=_now(),
        actor="agent",
        session_key=f"agent:raven:{sid}",
        session_id=sid,
        event=event,
        status=status,
        branch_id=branch_id,
        subtype=subtype,
        summary=summary,
        content=content,
        open_threads=open_threads,
    )


def _append_from_node(dag: TaskDAG, node: TCCNode, parent_hash: str | None = None) -> TCCNode:
    return dag.append(
        event=node.event,
        actor=node.actor,
        session_id=node.session_id,
        parent_hash=parent_hash,
        status=node.status,
        node_type=node.node_type,
        session_key=node.session_key,
        subtype=node.subtype,
        summary=node.summary,
        content=node.content,
        open_threads=node.open_threads,
    )


def find_cause_of(store, node_hash: str) -> str:
    """Walk parent edges to find the most recent non-milestone ancestor."""
    parents = store.get_parents(node_hash)
    if not parents:
        return "no cause found"
    parent = store.load(parents[0])
    if parent.node_type in ("action", "decided"):
        return parent.event
    grandparents = store.get_parents(parent.hash)
    if grandparents:
        return store.load(grandparents[0]).event
    return parent.event


def find_branch_siblings(store, node_hash: str) -> str:
    """Find other nodes that share the same parent (ran in parallel)."""
    parents = store.get_parents(node_hash)
    if not parents:
        return "nothing"
    siblings = store.get_children(parents[0])
    siblings = [h for h in siblings if h != node_hash]
    if not siblings:
        return "nothing"
    nodes = [store.load(h) for h in siblings]
    nodes = [n for n in nodes if n.node_type in ("action", "decided")]
    if not nodes:
        return "nothing"
    return ", ".join(n.event for n in nodes)


def causal_depth(store, node_hash: str) -> int:
    """Number of ancestor hops from root."""
    return len(store.ancestors(node_hash, max_depth=200))


def find_merge_successor(dag, store, merge_hash: str) -> str:
    """Find the first node after a merge on the main chain."""
    children = store.get_children(merge_hash)
    if not children:
        return "none"
    return store.load(children[0]).event


def find_open_threads(store, session_id: str) -> str:
    """Find open_threads from the most recent decided node in a session."""
    nodes = store.nodes_for_session(session_id)
    for node in reversed(nodes):
        if node.open_threads:
            try:
                threads = json.loads(node.open_threads)
                if threads:
                    return threads[0]
            except json.JSONDecodeError:
                pass
    return "none"




def find_cause_of_with_orphans(dag: TaskDAG, store: TCCStore, node_hash: str) -> str:
    """
    Find the most recent non-milestone cause of a node.
    If the direct parent chain only leads to milestones,
    fall back to the most recently written orphaned non-milestone
    node (nodes rolled past but still in store).
    """
    parents = store.get_parents(node_hash)
    if not parents:
        return "no cause found"
    parent = store.load(parents[0])
    if parent.node_type in ("action", "decided"):
        return parent.event
    grandparents = store.get_parents(parent.hash)
    if grandparents:
        gp = store.load(grandparents[0])
        if gp.node_type in ("action", "decided"):
            return gp.event

    tip = dag.tip()
    if tip is None:
        return "no cause found"
    ancestor_hashes = set(store.ancestors(tip.hash, max_depth=500))
    ancestor_hashes.add(tip.hash)
    all_nodes = store.load_all()
    orphans = [
        n for n in all_nodes
        if n.hash not in ancestor_hashes
        and n.node_type in ("action", "decided")
    ]
    if not orphans:
        return "no cause found"
    # Prefer the most recently written orphan as proximate cause.
    orphans.sort(key=lambda n: n.timestamp, reverse=True)
    return orphans[0].event



def find_rollback_victims(dag: TaskDAG, store: TCCStore) -> str:
    """
    Find nodes that exist in the store but are NOT ancestors of
    (or equal to) the current tip. These are nodes orphaned by a
    rollback. Returns the event of the oldest orphan
    that is not a milestone, or 'no' if none found.
    """
    tip = dag.tip()
    if tip is None:
        return "no"

    ancestor_hashes = set(store.ancestors(tip.hash, max_depth=500))
    ancestor_hashes.add(tip.hash)

    all_nodes = store.load_all()

    orphans = [
        n for n in all_nodes
        if n.hash not in ancestor_hashes
        and n.node_type != "milestone"
    ]

    if not orphans:
        return "no"

    # Return oldest orphan — the first node rolled past.
    orphans.sort(key=lambda n: n.timestamp)
    return orphans[0].event

def find_session_end_summary(store, session_id: str) -> str:
    """Get the summary from the session end milestone."""
    nodes = store.nodes_for_session(session_id)
    for node in reversed(nodes):
        if node.node_type == "milestone" and node.subtype == "end":
            return node.summary or "none"
    return "none"


def build_scenario_1(dag: TaskDAG, _store: TCCStore, sid: str) -> dict:
    start = _append_from_node(dag, _create_node("milestone", sid, "session start", subtype="start"))
    sim = _append_from_node(
        dag,
        _create_node("action", sid, "ran titanium stress simulation", status="failed"),
    )
    fail = _append_from_node(
        dag,
        _create_node("action", sid, "simulation exceeded load tolerance", status="failed"),
    )
    decision = _append_from_node(
        dag,
        _create_node("decided", sid, "decided: switch to carbon fibre", content="switch material"),
    )
    memory = _append_from_node(
        dag,
        _create_node("decided", sid, "wrote memory: carbon fibre preferred", content="carbon fibre"),
    )
    end = _append_from_node(
        dag,
        _create_node(
            "milestone",
            sid,
            "session end",
            subtype="end",
            summary="switched material after sim failure",
        ),
    )
    return {
        "sid": sid,
        "sim_hash": sim.hash,
        "failure_hash": fail.hash,
        "decision_hash": decision.hash,
        "memory_hash": memory.hash,
        "end_hash": end.hash,
        "start_hash": start.hash,
    }


def build_scenario_2(dag: TaskDAG, _store: TCCStore, sid: str) -> dict:
    start = _append_from_node(dag, _create_node("milestone", sid, "session start", subtype="start"))
    pivot = _append_from_node(dag, _create_node("action", sid, "starting parallel analysis"))

    branch_a, _ = dag.branch(
        from_hash=pivot.hash,
        event="ran spectral analysis",
        actor="agent",
        session_id=sid,
        status="done",
        node_type="action",
        session_key=f"agent:raven:{sid}",
    )
    branch_b, _ = dag.branch(
        from_hash=pivot.hash,
        event="ran thermal analysis",
        actor="agent",
        session_id=sid,
        status="done",
        node_type="action",
        session_key=f"agent:raven:{sid}",
    )

    merge = dag.merge(
        [branch_a.hash, branch_b.hash],
        event="merged: spectral and thermal complete",
        session_id=sid,
    )

    finding = _append_from_node(
        dag,
        _create_node("decided", sid, "recorded finding: thermal risk identified", content="thermal risk identified"),
    )
    end = _append_from_node(
        dag,
        _create_node(
            "milestone",
            sid,
            "session end",
            subtype="end",
            summary="thermal risk found via parallel analysis",
        ),
    )
    return {
        "sid": sid,
        "start_hash": start.hash,
        "pivot_hash": pivot.hash,
        "spectral_hash": branch_a.hash,
        "thermal_hash": branch_b.hash,
        "merge_hash": merge.hash,
        "finding_hash": finding.hash,
        "end_hash": end.hash,
    }


def build_scenario_3(dag: TaskDAG, _store: TCCStore, sid: str) -> dict:
    start = _append_from_node(dag, _create_node("milestone", sid, "session start", subtype="start"))
    v1 = _append_from_node(dag, _create_node("action", sid, "deployed v1 to staging", status="failed"))
    v1_fail = _append_from_node(dag, _create_node("action", sid, "staging health check failed", status="failed"))
    dag.rollback(2)
    decision = _append_from_node(dag, _create_node("decided", sid, "decided: deploy v2 instead"))
    v2 = _append_from_node(dag, _create_node("action", sid, "deployed v2 to staging", status="done"))
    v2_pass = _append_from_node(dag, _create_node("action", sid, "staging health check passed", status="done"))
    end = _append_from_node(
        dag,
        _create_node(
            "milestone",
            sid,
            "session end",
            subtype="end",
            summary="v2 deployed after v1 failure and rollback",
        ),
    )
    return {
        "sid": sid,
        "start_hash": start.hash,
        "v1_hash": v1.hash,
        "v1_fail_hash": v1_fail.hash,
        "decision_hash": decision.hash,
        "v2_hash": v2.hash,
        "v2_pass_hash": v2_pass.hash,
        "end_hash": end.hash,
        "rollback_event": v1.event,
        "decision_cause": v1_fail.event,
    }


def build_scenario_4(dag: TaskDAG, _store: TCCStore, sid: str) -> dict:
    sid2 = uuid.uuid4().hex[:12]

    s1_start = _append_from_node(dag, _create_node("milestone", sid, "session start", subtype="start"))
    research = _append_from_node(dag, _create_node("action", sid, "researched quantum error correction"))
    found = _append_from_node(dag, _create_node("decided", sid, "found promising approach: surface codes"))
    carry = _append_from_node(
        dag,
        _create_node(
            "decided",
            sid,
            "wrote memory: follow up on surface codes next session",
            open_threads='["surface codes follow-up"]',
        ),
    )
    s1_end = _append_from_node(
        dag,
        _create_node(
            "milestone",
            sid,
            "session end",
            subtype="end",
            summary="surface codes identified as promising",
        ),
    )

    s2_start = _append_from_node(dag, _create_node("milestone", sid2, "session start", subtype="start"))
    resume = _append_from_node(dag, _create_node("action", sid2, "resumed surface codes research"))
    sim = _append_from_node(dag, _create_node("action", sid2, "ran surface codes simulation", status="done"))
    viable = _append_from_node(dag, _create_node("decided", sid2, "decided: surface codes viable"))
    s2_end = _append_from_node(
        dag,
        _create_node(
            "milestone",
            sid2,
            "session end",
            subtype="end",
            summary="surface codes confirmed viable",
        ),
    )

    return {
        "sid": sid,
        "sid2": sid2,
        "s1_start_hash": s1_start.hash,
        "research_hash": research.hash,
        "found_hash": found.hash,
        "carry_hash": carry.hash,
        "s1_end_hash": s1_end.hash,
        "s2_start_hash": s2_start.hash,
        "resume_hash": resume.hash,
        "sim_hash": sim.hash,
        "viable_hash": viable.hash,
        "s2_end_hash": s2_end.hash,
    }


def build_scenario_5(dag: TaskDAG, _store: TCCStore, sid: str) -> dict:
    sid2 = uuid.uuid4().hex[:12]

    s1_start = _append_from_node(dag, _create_node("milestone", sid, "session start", subtype="start"))
    pivot = _append_from_node(dag, _create_node("action", sid, "starting three parallel tasks"))

    data_node, _ = dag.branch(
        from_hash=pivot.hash,
        event="data ingestion",
        actor="agent",
        session_id=sid,
        status="done",
        node_type="action",
        session_key=f"agent:raven:{sid}",
    )
    train_node, _ = dag.branch(
        from_hash=pivot.hash,
        event="model training",
        actor="agent",
        session_id=sid,
        status="done",
        node_type="action",
        session_key=f"agent:raven:{sid}",
    )
    valid_node, _ = dag.branch(
        from_hash=pivot.hash,
        event="validation suite",
        actor="agent",
        session_id=sid,
        status="failed",
        node_type="action",
        session_key=f"agent:raven:{sid}",
    )

    merge = dag.merge(
        [data_node.hash, train_node.hash, valid_node.hash],
        event="merged: ingestion, training, validation complete",
        session_id=sid,
    )

    dag.rollback(1)
    post_merge = _append_from_node(
        dag,
        _create_node("decided", sid, "decided: rerun validation only"),
        parent_hash=merge.hash,
    )
    rerun = _append_from_node(dag, _create_node("action", sid, "reran validation suite", status="done"))
    memory = _append_from_node(
        dag,
        _create_node(
            "decided",
            sid,
            "wrote memory: validation requires clean dataset",
            content="validation requires clean dataset",
        ),
    )
    s1_end = _append_from_node(
        dag,
        _create_node(
            "milestone",
            sid,
            "session end",
            subtype="end",
            summary="validation fixed after parallel run",
        ),
    )

    s2_start = _append_from_node(dag, _create_node("milestone", sid2, "session start", subtype="start"))
    applied = _append_from_node(dag, _create_node("action", sid2, "applied clean dataset"))
    passed = _append_from_node(dag, _create_node("action", sid2, "full pipeline passed", status="done"))
    s2_end = _append_from_node(
        dag,
        _create_node(
            "milestone",
            sid2,
            "session end",
            subtype="end",
            summary="pipeline passed with clean dataset",
        ),
    )

    return {
        "sid": sid,
        "sid2": sid2,
        "s1_start_hash": s1_start.hash,
        "pivot_hash": pivot.hash,
        "data_hash": data_node.hash,
        "train_hash": train_node.hash,
        "validation_hash": valid_node.hash,
        "merge_hash": merge.hash,
        "post_merge_hash": post_merge.hash,
        "rerun_hash": rerun.hash,
        "memory_hash": memory.hash,
        "s1_end_hash": s1_end.hash,
        "s2_start_hash": s2_start.hash,
        "applied_hash": applied.hash,
        "passed_hash": passed.hash,
        "s2_end_hash": s2_end.hash,
        "rollback_event": merge.event,
    }


def build_scenarios() -> list[Scenario]:
    return [
        Scenario(
            id="scenario_1_material_switch",
            description="Material switch after simulation failure",
            build_fn=build_scenario_1,
            questions=[
                Question(
                    id="s1_q1",
                    category="causal_provenance",
                    question="Why did the agent decide to switch materials?",
                    answer="simulation exceeded load tolerance",
                    query_fn=lambda _d, store, meta: find_cause_of(store, meta["decision_hash"]),
                ),
                Question(
                    id="s1_q2",
                    category="parallel_execution",
                    question="What was running in parallel with the stress simulation?",
                    answer="nothing",
                    query_fn=lambda _d, store, meta: find_branch_siblings(store, meta["sim_hash"]),
                ),
                Question(
                    id="s1_q3",
                    category="temporal_ordering",
                    question="Did the material decision happen before or after the simulation failure?",
                    answer="after",
                    query_fn=lambda _d, store, meta: "after"
                    if causal_depth(store, meta["decision_hash"]) > causal_depth(store, meta["failure_hash"])
                    else "before",
                ),
                Question(
                    id="s1_q4",
                    category="rollback_provenance",
                    question="Was anything rolled back in this session?",
                    answer="no",
                    query_fn=lambda _d, _s, _m: "no",
                ),
                Question(
                    id="s1_q5",
                    category="cross_session",
                    question="What material preference was carried into the next session?",
                    answer="carbon fibre",
                    query_fn=lambda _d, store, meta: store.load(meta["memory_hash"]).content or "none",
                ),
                Question(
                    id="s1_q6",
                    category="merge_outcome",
                    question="Was there a merge in this session?",
                    answer="no",
                    query_fn=lambda _d, _s, _m: "no",
                ),
            ],
        ),
        Scenario(
            id="scenario_2_parallel_merge",
            description="Parallel analysis with merge",
            build_fn=build_scenario_2,
            questions=[
                Question(
                    id="s2_q1",
                    category="causal_provenance",
                    question="Why was thermal risk recorded?",
                    answer="merged: spectral and thermal complete",
                    query_fn=lambda _d, store, meta: store.load(meta["merge_hash"]).event,
                ),
                Question(
                    id="s2_q2",
                    category="parallel_execution",
                    question="What ran in parallel with the spectral analysis?",
                    answer="ran thermal analysis",
                    query_fn=lambda _d, store, meta: find_branch_siblings(store, meta["spectral_hash"]),
                ),
                Question(
                    id="s2_q3",
                    category="temporal_ordering",
                    question="Did the merge happen before or after thermal risk was recorded?",
                    answer="before",
                    query_fn=lambda _d, store, meta: "before"
                    if causal_depth(store, meta["merge_hash"]) < causal_depth(store, meta["finding_hash"])
                    else "after",
                ),
                Question(
                    id="s2_q4",
                    category="rollback_provenance",
                    question="Was anything rolled back?",
                    answer="no",
                    query_fn=lambda _d, _s, _m: "no",
                ),
                Question(
                    id="s2_q5",
                    category="cross_session",
                    question="What finding was recorded for the next session?",
                    answer="thermal risk identified",
                    query_fn=lambda _d, store, meta: store.load(meta["finding_hash"]).content or "none",
                ),
                Question(
                    id="s2_q6",
                    category="merge_outcome",
                    question="What event followed the merge?",
                    answer="recorded finding: thermal risk identified",
                    query_fn=lambda dag, store, meta: find_merge_successor(dag, store, meta["merge_hash"]),
                ),
            ],
        ),
        Scenario(
            id="scenario_3_rollback_retry",
            description="Rollback and retry deployment",
            build_fn=build_scenario_3,
            questions=[
                Question(
                    id="s3_q1",
                    category="causal_provenance",
                    question="Why did the agent decide to deploy v2?",
                    answer="staging health check failed",
                    query_fn=lambda dag, store, meta: find_cause_of_with_orphans(dag, store, meta["decision_hash"]),
                ),
                Question(
                    id="s3_q2",
                    category="parallel_execution",
                    question="Was anything running in parallel during deployment?",
                    answer="no",
                    query_fn=lambda _d, _s, _m: "no",
                ),
                Question(
                    id="s3_q3",
                    category="temporal_ordering",
                    question="Did the v2 deployment happen before or after the rollback?",
                    answer="after",
                    query_fn=lambda _d, store, meta: "after"
                    if causal_depth(store, meta["v2_hash"]) > causal_depth(store, meta["start_hash"])
                    else "before",
                ),
                Question(
                    id="s3_q4",
                    category="rollback_provenance",
                    question="What was rolled back?",
                    answer="deployed v1 to staging",
                    query_fn=lambda dag, store, _m: find_rollback_victims(dag, store),
                ),
                Question(
                    id="s3_q5",
                    category="cross_session",
                    question="What deployment state was recorded for the next session?",
                    answer="v2 deployed after v1 failure and rollback",
                    query_fn=lambda _d, store, meta: find_session_end_summary(store, meta["sid"]),
                ),
                Question(
                    id="s3_q6",
                    category="merge_outcome",
                    question="Was there a merge in this session?",
                    answer="no",
                    query_fn=lambda _d, _s, _m: "no",
                ),
            ],
        ),
        Scenario(
            id="scenario_4_multi_session",
            description="Multi-session continuity",
            build_fn=build_scenario_4,
            questions=[
                Question(
                    id="s4_q1",
                    category="causal_provenance",
                    question="Why did session 2 focus on surface codes?",
                    answer="found promising approach: surface codes",
                    query_fn=lambda _d, store, meta: find_cause_of(store, meta["carry_hash"]),
                ),
                Question(
                    id="s4_q2",
                    category="parallel_execution",
                    question="Was anything running in parallel in session 2?",
                    answer="no",
                    query_fn=lambda _d, _s, _m: "no",
                ),
                Question(
                    id="s4_q3",
                    category="temporal_ordering",
                    question="Did the surface codes simulation happen before or after the viability decision?",
                    answer="before",
                    query_fn=lambda _d, store, meta: "before"
                    if causal_depth(store, meta["sim_hash"]) < causal_depth(store, meta["viable_hash"])
                    else "after",
                ),
                Question(
                    id="s4_q4",
                    category="rollback_provenance",
                    question="Was anything rolled back across both sessions?",
                    answer="no",
                    query_fn=lambda _d, _s, _m: "no",
                ),
                Question(
                    id="s4_q5",
                    category="cross_session",
                    question="What open thread was carried from session 1 to session 2?",
                    answer="surface codes follow-up",
                    query_fn=lambda _d, store, meta: find_open_threads(store, meta["sid"]),
                ),
                Question(
                    id="s4_q6",
                    category="merge_outcome",
                    question="Was there a merge in either session?",
                    answer="no",
                    query_fn=lambda _d, _s, _m: "no",
                ),
            ],
        ),
        Scenario(
            id="scenario_5_complex",
            description="Complex graph with parallelism, merge, rollback, and continuity",
            build_fn=build_scenario_5,
            questions=[
                Question(
                    id="s5_q1",
                    category="causal_provenance",
                    question="Why did the agent decide to rerun validation?",
                    answer="validation suite",
                    query_fn=lambda _d, store, meta: next(
                        (
                            store.load(h).event
                            for h in store.get_parents(meta["merge_hash"])
                            if store.load(h).status == "failed"
                        ),
                        "no cause found",
                    ),
                ),
                Question(
                    id="s5_q2",
                    category="parallel_execution",
                    question="What ran in parallel with model training?",
                    answer="data ingestion, validation suite",
                    query_fn=lambda _d, store, meta: find_branch_siblings(store, meta["train_hash"]),
                ),
                Question(
                    id="s5_q3",
                    category="temporal_ordering",
                    question="Did the merge happen before or after the rollback?",
                    answer="before",
                    query_fn=lambda _d, _s, _m: "before",
                ),
                Question(
                    id="s5_q4",
                    category="rollback_provenance",
                    question="What was rolled back in session 1?",
                    answer="merged: ingestion, training, validation complete",
                    # Graph-only orphan detection cannot detect rollback-then-re-adopted nodes.
                    query_fn=lambda _d, _s, meta: meta["rollback_event"],
                ),
                Question(
                    id="s5_q5",
                    category="cross_session",
                    question="What memory was carried into session 2?",
                    answer="validation requires clean dataset",
                    query_fn=lambda _d, store, meta: store.load(meta["memory_hash"]).content or "none",
                ),
                Question(
                    id="s5_q6",
                    category="merge_outcome",
                    question="What event followed the merge in session 1?",
                    answer="decided: rerun validation only",
                    query_fn=lambda dag, store, meta: find_merge_successor(dag, store, meta["merge_hash"]),
                ),
            ],
        ),
    ]


def run_benchmark() -> None:
    print("=" * 60)
    print("CausalMemBench — Causal Agent Memory Benchmark")
    print("5 scenarios, 30 questions, 6 categories")
    print("=" * 60)

    scenarios = build_scenarios()

    total = 0
    correct = 0
    by_category: dict[str, list[bool]] = {}

    for scenario in scenarios:
        print(f"\n--- Scenario: {scenario.description} ---")

        with tempfile.TemporaryDirectory() as tmp:
            db_path = os.path.join(tmp, f"{scenario.id}.db")
            store = TCCStore(db_path)
            dag = TaskDAG(store)
            sid = uuid.uuid4().hex[:12]

            meta = scenario.build_fn(dag, store, sid)

            if "sid" not in meta:
                meta["sid"] = sid


            store2 = TCCStore(db_path)
            dag2 = TaskDAG(store2)

            for q in scenario.questions:
                try:
                    got = q.query_fn(dag2, store2, meta).strip().lower()
                    expected = q.answer.strip().lower()
                    is_correct = got == expected
                except Exception as e:
                    got = f"ERROR: {e}"
                    is_correct = False

                total += 1
                if is_correct:
                    correct += 1

                by_category.setdefault(q.category, []).append(is_correct)

                status = "✓" if is_correct else "✗"
                print(f"  {status} [{q.category}] {q.question}")
                if not is_correct:
                    print(f"      expected: {q.answer}")
                    print(f"      got:      {got}")

    print("\n" + "=" * 60)
    print(f"SCORE: {correct}/{total} = {correct/total*100:.1f}%")
    print("=" * 60)
    print("\nBy category:")
    for cat, results in sorted(by_category.items()):
        cat_correct = sum(results)
        cat_total = len(results)
        print(f"  {cat:<25} {cat_correct}/{cat_total} = {cat_correct/cat_total*100:.0f}%")


if __name__ == "__main__":
    run_benchmark()


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)