diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 721b9498..7bf91253 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -290,6 +290,29 @@ jobs:
run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} -p uteke-server --allow-dirty
continue-on-error: true
+ - name: Wait for crates.io index
+ run: sleep 30
+
+ - name: Verify crates.io versions
+ run: |
+ VERSION="${GITHUB_REF_NAME#v}"
+ fail=0
+ for crate in uteke-core uteke-mcp uteke-cli uteke-server; do
+ for attempt in 1 2 3 4 5; do
+ MAX=$(curl -sf -A "uteke-release-verify" "https://crates.io/api/v1/crates/$crate" | jq -r '.crate.max_version') || MAX=""
+ [ "$MAX" = "$VERSION" ] && break
+ echo "attempt $attempt: $crate at $MAX (want $VERSION), waiting..."
+ sleep 30
+ done
+ if [ "$MAX" = "$VERSION" ]; then
+ echo "✓ $crate $VERSION live on crates.io"
+ else
+ echo "::error::$crate not at $VERSION on crates.io (found: $MAX) — publish step failed silently"
+ fail=1
+ fi
+ done
+ exit $fail
+
package-legacy:
name: Package Linux x64 legacy bundle
needs: [build-release, build-ort-legacy]
diff --git a/.gitignore b/.gitignore
index 01875028..d30ebb34 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,31 @@
+/target/
+*.swp
+*.swo
+*~
+.DS_Store
+.idea/
+.vscode/
+*.pdb
+__pycache__/
+milestone*.md
+
+# Runtime index files
+uteke_index.keys
+uteke_index.usearch
+.fusion/
+progress.md
+.cora/
+scripts/demo-record.sh
+
+# Internal docs — never commit to public repo
+internal/
+
# Benchmark results (large, ephemeral — commit RESULTS.md summaries, not raw data)
benchmarks/longmemeval/results_*/
+benchmarks/longmemeval/runs/
+benchmarks/longmemeval/work/
-# cargo-mutants output (local only — run before release)
+# Mutation testing artifacts (local runs, not CI)
mutants*.out/
mutants*.out.old/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3c02760f..1479e019 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,18 @@
# Changelog
+## [0.14.2] — 2026-08-14
+
+Patch release to restore the uteke-cli crates.io publish. The CLI crate embedded asset files from outside its package root, which `cargo publish` cannot bundle, so every publish attempt since June failed silently while the release workflow reported success. The crate was stuck at 0.4.3 on crates.io while the repo shipped 0.14.x.
+
+### Fixed
+
+- `uteke-cli` publishes again: the memory-skill and provider templates now live inside `crates/uteke-cli/assets/` and ship inside the `.crate` archive
+- The release workflow now verifies every crate reports the tagged version on crates.io and fails the job if a publish step was silently skipped
+
+### Note for `cargo install` users
+
+If you installed `uteke-cli` before this release, you have a build from June. Run `cargo install uteke-cli --version 0.14.2` to get the current CLI.
+
## [0.14.1] — 2026-08-14
Patch release: two chunker bug fixes found by mutation testing, plus test-suite hardening. No API or behavior changes beyond the fixes.
diff --git a/Cargo.lock b/Cargo.lock
index 1e774437..95bd0d7a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2764,7 +2764,7 @@ dependencies = [
[[package]]
name = "uteke-cli"
-version = "0.14.1"
+version = "0.14.2"
dependencies = [
"chrono",
"clap",
@@ -2790,7 +2790,7 @@ dependencies = [
[[package]]
name = "uteke-core"
-version = "0.14.1"
+version = "0.14.2"
dependencies = [
"chrono",
"dirs",
@@ -2813,7 +2813,7 @@ dependencies = [
[[package]]
name = "uteke-mcp"
-version = "0.14.1"
+version = "0.14.2"
dependencies = [
"dirs",
"serde",
@@ -2823,7 +2823,7 @@ dependencies = [
[[package]]
name = "uteke-server"
-version = "0.14.1"
+version = "0.14.2"
dependencies = [
"chrono",
"ctrlc",
diff --git a/Cargo.toml b/Cargo.toml
index e61d3ea6..2383e42f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,7 +9,7 @@ members = [
]
[workspace.package]
-version = "0.14.1"
+version = "0.14.2"
edition = "2024"
license = "Apache-2.0"
repository = "https://github.com/codecoradev/uteke"
diff --git a/crates/uteke-cli/Cargo.toml b/crates/uteke-cli/Cargo.toml
index 7b8f35ba..e1ccd3bd 100644
--- a/crates/uteke-cli/Cargo.toml
+++ b/crates/uteke-cli/Cargo.toml
@@ -14,7 +14,7 @@ name = "uteke"
path = "src/main.rs"
[dependencies]
-uteke-core = { path = "../uteke-core", version = "0.14.1", features = ["onnx"] }
+uteke-core = { path = "../uteke-core", version = "0.14.2", features = ["onnx"] }
clap = { version = "4", features = ["derive"] }
serde_json = "1"
tracing = "0.1"
diff --git a/crates/uteke-cli/assets/hermes-memory-provider/__init__.py.tmpl b/crates/uteke-cli/assets/hermes-memory-provider/__init__.py.tmpl
new file mode 100644
index 00000000..2ed1e7ba
--- /dev/null
+++ b/crates/uteke-cli/assets/hermes-memory-provider/__init__.py.tmpl
@@ -0,0 +1,364 @@
+"""Uteke memory plugin — pre_llm_call auto-recall hook.
+
+Offline-first local memory backed by the `uteke` CLI (single Rust binary) or
+`uteke-serve` HTTP daemon. Every turn, before the LLM call, this plugin
+recalls relevant memories via semantic search and injects them into the
+user message.
+
+This replaces the old Mode B (MemoryProvider, removed 2026-06-29) and the
+Mode C (shell hook). Both are now unified into a single Python plugin.
+
+Transport:
+ - subprocess (default): talks to the `uteke` binary directly.
+ Requires the binary on PATH or set UTEKE_BIN.
+ - HTTP (optional): talks to `uteke-serve` via HTTP. Set UTEKE_SERVER_URL
+ to enable. Useful when the binary is unavailable, hangs, or runs as a
+ container (e.g., http://uteke:8767).
+
+Config via $HERMES_HOME/uteke.json (preferred) or environment variables:
+ UTEKE_BIN — path to the uteke binary (default: search PATH)
+ UTEKE_HOME — HOME dir uteke runs under (holds ~/.codecora/uteke store)
+ UTEKE_NAMESPACE — memory namespace (default: agent profile name)
+ UTEKE_SERVER_URL — uteke-serve HTTP URL (default: http://127.0.0.1:8767)
+ UTEKE_TOKEN — auth token for uteke-serve (Bearer token)
+ UTEKE_RECALL_LIMIT — number of memories to prefetch (default: 5)
+ UTEKE_RECALL_MIN_SCORE — drop recall hits below this score (default: 0.40)
+ UTEKE_RECALL_TIMEOUT — max seconds for recall per turn (default: 15)
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import shutil
+import subprocess
+import threading
+import time
+import urllib.error
+import urllib.request
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+# Circuit breaker: after this many consecutive failures, pause calls
+# for _BREAKER_COOLDOWN_SECS to avoid hammering a broken binary/endpoint.
+_BREAKER_THRESHOLD = 5
+_BREAKER_COOLDOWN_SECS = 120
+
+# Subprocess timeout (seconds)
+_DEFAULT_RECALL_TIMEOUT = 15
+
+# HTTP timeout (seconds)
+_HTTP_RECALL_TIMEOUT = 10
+
+
+# ---------------------------------------------------------------------------
+# Config
+# ---------------------------------------------------------------------------
+
+def _load_config() -> dict:
+ """Load config from env vars, overridden by $HERMES_HOME/uteke.json."""
+ try:
+ from hermes_constants import get_hermes_home
+ except ImportError:
+ get_hermes_home = lambda: None # type: ignore[assignment]
+
+ hermes_home = get_hermes_home() if get_hermes_home else os.environ.get("HERMES_HOME", "")
+
+ def _envbool(key: str, default: bool) -> bool:
+ v = os.environ.get(key)
+ if v is None:
+ return default
+ return v.strip().lower() in ("1", "true", "yes", "on")
+
+ def _envfloat(key: str, default: float) -> float:
+ v = os.environ.get(key)
+ if v is None:
+ return default
+ try:
+ return float(v)
+ except ValueError:
+ return default
+
+ def _envint(key: str, default: int) -> int:
+ v = os.environ.get(key)
+ if v is None:
+ return default
+ try:
+ return int(v)
+ except ValueError:
+ return default
+
+ config = {
+ "bin": os.environ.get("UTEKE_BIN", ""),
+ "uteke_home": os.environ.get("UTEKE_HOME", ""),
+ "namespace": os.environ.get("UTEKE_NAMESPACE", ""),
+ "server_url": os.environ.get("UTEKE_SERVER_URL", ""),
+ "token": os.environ.get("UTEKE_TOKEN", ""),
+ "recall_limit": _envint("UTEKE_RECALL_LIMIT", 5),
+ "recall_min_score": _envfloat("UTEKE_RECALL_MIN_SCORE", 0.40),
+ "recall_timeout": _envint("UTEKE_RECALL_TIMEOUT", _DEFAULT_RECALL_TIMEOUT),
+ }
+
+ if hermes_home:
+ config_path = os.path.join(hermes_home, "uteke.json")
+ if os.path.exists(config_path):
+ try:
+ with open(config_path, encoding="utf-8") as f:
+ file_cfg = json.loads(f.read())
+ config.update({k: v for k, v in file_cfg.items()
+ if v is not None and v != ""})
+ except Exception:
+ pass
+
+ return config
+
+
+def _resolve_agent_name() -> str:
+ """Determine the calling agent's name from environment.
+
+ Tries HERMES_HOME path basename (profile name), falls back to
+ HERMES_AGENT_NAME, then "default".
+ """
+ try:
+ home = os.environ.get("HERMES_HOME", "")
+ if home:
+ basename = os.path.basename(home)
+ if basename and basename != "data":
+ return basename.lower()
+ return os.environ.get("HERMES_AGENT_NAME", "default")
+ except Exception:
+ return "default"
+
+
+def _resolve_bin(config: dict) -> str:
+ """Find the uteke binary."""
+ b = config.get("bin") or ""
+ if b and os.path.exists(b):
+ return b
+ found = shutil.which("uteke")
+ return found or ""
+
+
+# ---------------------------------------------------------------------------
+# HTTP transport
+# ---------------------------------------------------------------------------
+
+def _http_request(url: str, method: str = "GET", data: dict = None,
+ token: str = "", timeout: int = 10) -> dict:
+ """Make an HTTP request to uteke-serve. Returns parsed JSON or error dict."""
+ body = json.dumps(data).encode() if data else None
+ req = urllib.request.Request(url, data=body, method=method)
+ req.add_header("Content-Type", "application/json")
+ if token:
+ req.add_header("Authorization", f"Bearer {token}")
+ try:
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
+ return json.loads(resp.read().decode())
+ except urllib.error.HTTPError as e:
+ return {"error": e.read().decode()[:200], "status": e.code}
+ except urllib.error.URLError as e:
+ return {"error": f"uteke-serve not reachable: {e.reason}"}
+
+
+# ---------------------------------------------------------------------------
+# Recall (dual transport)
+# ---------------------------------------------------------------------------
+
+def _recall_subprocess(bin_path: str, env: dict, query: str,
+ namespace: str, limit: int, timeout: int) -> List[Dict[str, Any]]:
+ """Recall via subprocess (uteke binary)."""
+ cmd = [bin_path, "recall", query, "--namespace", namespace,
+ "--limit", str(limit), "--json"]
+ proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env)
+ if proc.returncode != 0:
+ raise RuntimeError(f"recall exit {proc.returncode}: {proc.stderr.strip()[:200]}")
+ return _parse_recall_results(json.loads(proc.stdout or "[]"))
+
+
+def _recall_http(server_url: str, token: str, query: str,
+ namespace: str, limit: int) -> List[Dict[str, Any]]:
+ """Recall via HTTP (uteke-serve)."""
+ result = _http_request(
+ f"{server_url}/recall",
+ method="POST",
+ data={"query": query, "namespace": namespace, "limit": limit},
+ token=token,
+ timeout=_HTTP_RECALL_TIMEOUT,
+ )
+ if "error" in result:
+ raise RuntimeError(f"recall HTTP error: {result['error']}")
+ if not isinstance(result, list):
+ return []
+ return _parse_recall_results(result)
+
+
+def _parse_recall_results(data: list) -> List[Dict[str, Any]]:
+ """Parse recall results from both subprocess JSON and HTTP API."""
+ out = []
+ for item in data:
+ if not isinstance(item, dict):
+ continue
+ mem = item.get("memory", {})
+ score = item.get("score", 0.0)
+ content = mem.get("content", "")
+ if content and score >= 0:
+ out.append({"content": content, "score": score, "tags": mem.get("tags", [])})
+ return out
+
+
+# ---------------------------------------------------------------------------
+# Recall manager (thread-safe, circuit breaker)
+# ---------------------------------------------------------------------------
+
+class _RecallManager:
+ """Manages recall state with circuit breaker and thread safety."""
+
+ def __init__(self):
+ self._config: dict = {}
+ self._bin = ""
+ self._env = dict(os.environ)
+ self._namespace = "default"
+ self._server_url = ""
+ self._token = ""
+ self._use_http = False
+ self._recall_limit = 5
+ self._recall_min_score = 0.40
+ self._recall_timeout = _DEFAULT_RECALL_TIMEOUT
+
+ self._consecutive_failures = 0
+ self._breaker_open_until = 0.0
+ self._lock = threading.Lock()
+
+ def initialize(self) -> None:
+ """Load config and resolve transport. Called once at plugin load."""
+ cfg = _load_config()
+ self._config = cfg
+ self._bin = _resolve_bin(cfg)
+ self._namespace = cfg.get("namespace", "") or _resolve_agent_name()
+ self._server_url = cfg.get("server_url", "").rstrip("/")
+ self._token = cfg.get("token", "")
+ self._use_http = bool(self._server_url)
+ self._recall_limit = int(cfg.get("recall_limit", 5) or 5)
+ self._recall_min_score = float(cfg.get("recall_min_score", 0.40) or 0.40)
+ self._recall_timeout = int(cfg.get("recall_timeout", _DEFAULT_RECALL_TIMEOUT) or _DEFAULT_RECALL_TIMEOUT)
+
+ # uteke stores under $HOME/.uteke; let the user point it at a fixed home.
+ self._env = dict(os.environ)
+ uteke_home = cfg.get("uteke_home")
+ if uteke_home:
+ self._env["HOME"] = uteke_home
+
+ transport = "HTTP" if self._use_http else "subprocess"
+ logger.info(
+ "uteke-memory plugin initialized: transport=%s bin=%s ns=%s server=%s limit=%d min_score=%.2f",
+ transport, self._bin or "(PATH)", self._namespace, self._server_url or "(none)",
+ self._recall_limit, self._recall_min_score,
+ )
+
+ def _is_breaker_open(self) -> bool:
+ if self._consecutive_failures < _BREAKER_THRESHOLD:
+ return False
+ if time.monotonic() >= self._breaker_open_until:
+ with self._lock:
+ self._consecutive_failures = 0
+ return False
+ return True
+
+ def _record_success(self):
+ with self._lock:
+ self._consecutive_failures = 0
+
+ def _record_failure(self):
+ with self._lock:
+ self._consecutive_failures += 1
+ if self._consecutive_failures >= _BREAKER_THRESHOLD:
+ self._breaker_open_until = time.monotonic() + _BREAKER_COOLDOWN_SECS
+ logger.warning("uteke circuit breaker tripped after %d failures; pausing %ds.",
+ self._consecutive_failures, _BREAKER_COOLDOWN_SECS)
+
+ def recall(self, query: str) -> List[Dict[str, Any]]:
+ """Recall memories, applying circuit breaker and score filtering."""
+ if self._is_breaker_open():
+ return []
+
+ try:
+ if self._use_http:
+ hits = _recall_http(self._server_url, self._token, query,
+ self._namespace, self._recall_limit)
+ else:
+ if not self._bin:
+ logger.warning("uteke binary not found on PATH and UTEKE_BIN not set.")
+ return []
+ hits = _recall_subprocess(self._bin, self._env, query,
+ self._namespace, self._recall_limit,
+ self._recall_timeout)
+
+ # Apply min_score filtering
+ hits = [h for h in hits if h.get("score", 0) >= self._recall_min_score]
+ self._record_success()
+ return hits
+ except Exception as e:
+ self._record_failure()
+ logger.debug("uteke recall failed: %s", e)
+ return []
+
+
+# Process-wide singleton
+_manager = _RecallManager()
+
+
+def _pre_llm_call(
+ session_id: str = "",
+ user_message: str = "",
+ conversation_history: list = None,
+ is_first_turn: bool = False,
+ model: str = "",
+ platform: str = "",
+ **kwargs,
+) -> dict:
+ """pre_llm_call plugin hook — auto-recall relevant memories.
+
+ Runs in-process (same Python runtime as the gateway). Has full access
+ to contextvars. Returns {"context": "..."} to inject into the user
+ message before the LLM call.
+
+ This runs synchronously in the turn prologue (turn_context.py). Keep it
+ fast — the recall is bounded by UTEKE_RECALL_TIMEOUT (default 15s).
+ """
+ if not user_message or not isinstance(user_message, str):
+ return {}
+ if len(user_message.strip()) < 5:
+ return {}
+
+ # Truncate long messages to save embedding time
+ query = user_message.strip()[:500]
+
+ hits = _manager.recall(query)
+ if not hits:
+ return {}
+
+ lines = []
+ for i, mem in enumerate(hits, 1):
+ content = mem["content"]
+ if len(content) > 200:
+ content = content[:200] + "..."
+ score_str = f"{mem['score']:.2f}"
+ tags = mem.get("tags", [])
+ tag_str = f" [{', '.join(tags)}]" if tags else ""
+ lines.append(f"{i}. [{score_str}]{tag_str} {content}")
+
+ return {"context": f"\n" + "\n".join(lines) + "\n"}
+
+
+def register(ctx) -> None:
+ """Register the uteke auto-recall plugin.
+
+ 1. Initialize the recall manager (load config, resolve transport).
+ 2. Register a pre_llm_call hook that runs uteke recall before every
+ LLM call and injects relevant memories into the user message.
+ """
+ _manager.initialize()
+ ctx.register_hook("pre_llm_call", _pre_llm_call)
+ logger.info("uteke-memory plugin registered: pre_llm_call hook active.")
diff --git a/crates/uteke-cli/assets/hermes-memory-provider/plugin.yaml.tmpl b/crates/uteke-cli/assets/hermes-memory-provider/plugin.yaml.tmpl
new file mode 100644
index 00000000..7018c925
--- /dev/null
+++ b/crates/uteke-cli/assets/hermes-memory-provider/plugin.yaml.tmpl
@@ -0,0 +1,8 @@
+name: uteke-memory
+version: 2.0.0
+description: "Uteke — offline-first local memory with auto-recall. Registers a pre_llm_call plugin hook for automatic memory injection every turn. Requires uteke binary on PATH or UTEKE_BIN env var."
+author: CodeCoraDev
+pip_dependencies: []
+requires_env: []
+provides_hooks:
+ - pre_llm_call
diff --git a/crates/uteke-cli/assets/pi-memory-provider/index.ts b/crates/uteke-cli/assets/pi-memory-provider/index.ts
new file mode 100644
index 00000000..3c066850
--- /dev/null
+++ b/crates/uteke-cli/assets/pi-memory-provider/index.ts
@@ -0,0 +1,272 @@
+/**
+ * Uteke Memory Provider Extension for Pi
+ *
+ * Automatic persistent memory integration — mirrors the Hermes memory-provider
+ * experience. Hooks `before_agent_start` to inject relevant memories into
+ * every agent turn without manual tool calls.
+ *
+ * Project-aware: auto-detects project name from the current working directory
+ * and tags all memories with `project:` for noise-free recall.
+ *
+ * Install:
+ * Global: ~/.pi/agent/extensions/uteke-memory-provider/index.ts
+ * Project: .pi/extensions/uteke-memory-provider/index.ts
+ *
+ * Requires: `uteke` binary on PATH (same binary as the CLI).
+ */
+
+import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
+import { execSync, execFileSync } from "node:child_process";
+import { basename, resolve } from "node:path";
+import { existsSync } from "node:fs";
+
+// ---------------------------------------------------------------------------
+// Config (reads from env, can be extended)
+// ---------------------------------------------------------------------------
+
+const DEFAULT_NAMESPACE = "default";
+const RECALL_LIMIT = 6;
+const RECALL_MIN_SCORE = 0.45;
+const RECALL_TIMEOUT_MS = 15_000;
+
+// Known project root directories to scan for project detection
+const KNOWN_PROJECT_DIRS = [
+ "/opt/data/repos",
+ "/home",
+ process.env.HOME || "",
+].filter(Boolean);
+
+// ---------------------------------------------------------------------------
+// Project Detection
+// ---------------------------------------------------------------------------
+
+/**
+ * Detect project name from the current working directory.
+ * Walks up from cwd to find a known project root, then extracts the folder name.
+ * Returns lowercase project name or empty string.
+ */
+function detectProjectFromCwd(): string {
+ const cwd = process.cwd();
+
+ for (const root of KNOWN_PROJECT_DIRS) {
+ if (!root || !existsSync(root)) continue;
+ const resolved = resolve(root);
+
+ // Check if cwd is inside or equal to this project root
+ if (cwd.startsWith(resolved + "/") || cwd === resolved) {
+ // Extract the immediate subdirectory name
+ const relative = cwd.slice(resolved.length + 1);
+ const firstSegment = relative.split("/")[0];
+ if (firstSegment && !firstSegment.startsWith(".")) {
+ return firstSegment.toLowerCase();
+ }
+ }
+ }
+
+ return "";
+}
+
+/**
+ * Detect project name from text (file paths, mentions).
+ * Falls back to cwd detection if nothing found in text.
+ */
+function detectProject(text: string, fallbackToCwd = false): string {
+ if (!text) return fallbackToCwd ? detectProjectFromCwd() : "";
+
+ // Strategy 1: file paths containing known project roots
+ for (const root of KNOWN_PROJECT_DIRS) {
+ if (!root) continue;
+ const escaped = root.replace(/\//g, "\\/");
+ const match = text.match(new RegExp(`${escaped}/([a-zA-Z0-9_.-]+)`));
+ if (match && match[1] && !match[1].startsWith(".")) {
+ return match[1].toLowerCase();
+ }
+ }
+
+ return fallbackToCwd ? detectProjectFromCwd() : "";
+}
+
+// ---------------------------------------------------------------------------
+// Subprocess helpers
+// ---------------------------------------------------------------------------
+
+function utekeBin(): string {
+ const envBin = process.env.UTEKE_BIN;
+ if (envBin) return envBin;
+ // pi extensions run as Node.js — use which-compatible lookup
+ try {
+ return execSync("which uteke", { encoding: "utf-8", timeout: 3000 }).trim();
+ } catch {
+ return "uteke";
+ }
+}
+
+function runUteke(args: string[], timeout = RECALL_TIMEOUT_MS): string {
+ const bin = utekeBin();
+ const env = { ...process.env };
+ // Allow pointing uteke at a different home directory
+ if (process.env.UTEKE_HOME) {
+ env.HOME = process.env.UTEKE_HOME;
+ }
+ try {
+ return execFileSync(bin, args, {
+ encoding: "utf-8",
+ timeout,
+ env,
+ });
+ } catch {
+ return "";
+ }
+}
+
+function recallMemories(query: string, project: string): string[] {
+ const ns = process.env.UTEKE_NAMESPACE || DEFAULT_NAMESPACE;
+ const limit = process.env.UTEKE_RECALL_LIMIT || String(RECALL_LIMIT);
+ const minScore = process.env.UTEKE_RECALL_MIN_SCORE || String(RECALL_MIN_SCORE);
+
+ const args: string[] = [
+ "recall", query,
+ "--namespace", ns,
+ "--limit", limit,
+ "--min-score", minScore,
+ "--json",
+ ];
+
+ // Filter by project tag if detected
+ if (project) {
+ args.push("--tags", `project:${project}`);
+ }
+
+ const raw = runUteke(args);
+
+ if (!raw.trim()) return [];
+
+ try {
+ const items = JSON.parse(raw);
+ const results: string[] = [];
+ for (const item of items) {
+ const mem = item.memory || item;
+ const content = mem.content || "";
+ if (content) {
+ results.push(content);
+ }
+ }
+ return results;
+ } catch {
+ return [];
+ }
+}
+
+function formatMemories(memories: string[], project: string): string {
+ if (!memories.length) return "";
+ const projectHint = project ? ` [project: ${project}]` : "";
+ const lines = memories.map((m, i) => `${i + 1}. ${m}`).join("\n");
+ return (
+ `\n\n## Relevant Memories (uteke)${projectHint}\n\n` +
+ lines +
+ "\n\nUse `uteke remember` to store new facts, `uteke recall` to search more."
+ );
+}
+
+// ---------------------------------------------------------------------------
+// Extension
+// ---------------------------------------------------------------------------
+
+export default function (pi: ExtensionAPI) {
+ let available = false;
+
+ // Check uteke availability on session start
+ pi.on("session_start", async (_event, ctx) => {
+ const out = runUteke(["stats"], 3000);
+ available = out.trim().length > 0;
+
+ const project = detectProjectFromCwd();
+ const status = project
+ ? `🧠 uteke: active (${project})`
+ : available
+ ? "🧠 uteke: active"
+ : "🧠 uteke: not found";
+
+ ctx.ui.setStatus("uteke", status);
+ });
+
+ // Core hook: inject relevant memories before every agent turn
+ pi.on("before_agent_start", async (event) => {
+ if (!available) return;
+
+ const prompt = (event.prompt || "").trim();
+ if (!prompt || prompt.length < 3) return;
+
+ // Detect project from prompt text, fallback to cwd
+ const project = detectProject(prompt, true);
+ const memories = recallMemories(prompt, project);
+ if (!memories.length) return;
+
+ const injection = formatMemories(memories, project);
+
+ return {
+ systemPrompt: event.systemPrompt + injection,
+ };
+ });
+
+ // Slash commands for manual control
+ pi.registerCommand("uteke-recall", {
+ description: "Manually recall memories for a topic",
+ handler: async (args, _ctx) => {
+ const query = args.join(" ").trim();
+ if (!query) return "Usage: /uteke-recall ";
+ const project = detectProject(query, true);
+ const memories = recallMemories(query, project);
+ if (!memories.length) return "No relevant memories found.";
+ return formatMemories(memories, project);
+ },
+ });
+
+ pi.registerCommand("uteke-save", {
+ description: "Save a memory to uteke (auto-tagged with project)",
+ handler: async (args, _ctx) => {
+ const content = args.join(" ").trim();
+ if (!content) return "Usage: /uteke-save ";
+
+ const ns = process.env.UTEKE_NAMESPACE || DEFAULT_NAMESPACE;
+ const project = detectProjectFromCwd();
+ const cmdArgs: string[] = [
+ "remember", content,
+ "--namespace", ns,
+ ];
+ if (project) {
+ cmdArgs.push("--tags", `project:${project}`);
+ }
+
+ runUteke(cmdArgs, RECALL_TIMEOUT_MS);
+ const projectNote = project ? ` [project: ${project}]` : "";
+ return `✓ Memory saved.${projectNote}`;
+ },
+ });
+
+ pi.registerCommand("uteke-stats", {
+ description: "Show uteke memory stats",
+ handler: async (_args, ctx) => {
+ const out = runUteke(["stats"], 5000);
+ available = out.trim().length > 0;
+ ctx.ui.setStatus("uteke", available ? "🧠 uteke: active" : "🧠 uteke: not found");
+ return out.trim() || "uteke: not available";
+ },
+ });
+
+ pi.registerCommand("uteke-on", {
+ description: "Enable automatic memory recall",
+ handler: async () => {
+ available = true;
+ return "✓ Automatic uteke recall enabled.";
+ },
+ });
+
+ pi.registerCommand("uteke-off", {
+ description: "Disable automatic memory recall",
+ handler: async () => {
+ available = false;
+ return "✓ Automatic uteke recall disabled.";
+ },
+ });
+}
diff --git a/crates/uteke-cli/assets/pi-memory-provider/package.json b/crates/uteke-cli/assets/pi-memory-provider/package.json
new file mode 100644
index 00000000..7cf4a146
--- /dev/null
+++ b/crates/uteke-cli/assets/pi-memory-provider/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "uteke-memory-provider",
+ "version": "0.1.0",
+ "description": "Pi extension — automatic uteke memory recall injected into every turn",
+ "main": "index.ts",
+ "pi": {
+ "extensions": ["./index.ts"]
+ },
+ "keywords": ["pi-extension", "uteke", "memory", "memory-provider"],
+ "license": "Apache-2.0"
+}
diff --git a/crates/uteke-cli/assets/uteke-memory-skill.md b/crates/uteke-cli/assets/uteke-memory-skill.md
new file mode 100644
index 00000000..09d9ef9b
--- /dev/null
+++ b/crates/uteke-cli/assets/uteke-memory-skill.md
@@ -0,0 +1,299 @@
+---
+description: "Persistent memory engine for AI agents via the uteke CLI — remember, recall, search, forget with semantic + FTS5 hybrid search, documents, knowledge graph, rooms, tiered memory, and multi-agent namespaces."
+---
+
+# Uteke Memory Skill
+
+Persistent memory engine for AI agents via the `uteke` CLI.
+Version: **0.10.0** — SQLite + usearch HNSW + FTS5 hybrid search (RRF k=60). Zero unsafe code.
+
+> **Hermes integration:** Install the `uteke-memory` plugin for automatic recall
+> on every turn via the `pre_llm_call` hook. No shell hook or daemon needed.
+> See `extensions/hermes-memory-provider/` for the plugin source.
+> Manual tool calls via `uteke-tool` plugin remain available for explicit
+> remember/forget/room operations.
+
+## Global Flags (all commands)
+
+| Flag | Description |
+|------|-------------|
+| `--store ` | Override store path (default: `~/.uteke`) |
+| `--namespace ` | Multi-agent isolation (default: `"default"`) |
+| `--json` | Machine-readable JSON output |
+| `--verbose` | Debug logging |
+
+## Commands
+
+### Core Memory Operations
+
+| Command | Description | Key Options |
+|---------|-------------|-------------|
+| `uteke remember ` | Store a new memory | `--tags`, `--type`, `--entity`, `--category`, `--meta`, `--room`, `--author`, `--source`, `--source-type`, `--detect-contradiction` |
+| `uteke recall ` | Hybrid search (vector + FTS5, RRF) | `--limit`, `--tags`, `--entity`, `--category`, `--min`, `--strategy` (vector/fts5/hybrid/graph), `--salience`, `--recency`, `--related`, `--depth`, `--context`, `--at` (time-travel), `--type` (all/memory/doc), `--where` (JSON field filter) |
+| `uteke search ` | Keyword text search | `--limit`, `--tags` |
+| `uteke list` | List memories with filters | `--tag`, `--entity`, `--category`, `--limit`, `--offset`, `--at` |
+| `uteke get ` | Get single memory by UUID | |
+| `uteke forget ` | Delete memory by ID, tag, tier, or all | `--tag`, `--cold`, `--all`, `--confirm` |
+
+### Documents (Wiki / Knowledge Base)
+
+| Command | Description | Key Options |
+|---------|-------------|-------------|
+| `uteke doc create ` | Create document from file/stdin | `--title`, `--file`, `--content`, `--tags`, `--parent` |
+| `uteke doc get ` | Get a document | |
+| `uteke doc update ` | Partial update — only provided fields change | `--title`, `--content`, `--file`, `--tags`, `--metadata` |
+| `uteke doc delete ` | Delete document (cascades to children) | |
+| `uteke doc list` | List documents | `--limit`, `--tree` (hierarchy) |
+| `uteke doc search ` | Search documents (semantic + FTS5) | `--limit`, `--mode` (semantic/fts/hybrid) |
+| `uteke doc children ` | List child documents | `--limit` |
+| `uteke doc move ` | Move to new parent or root | `--parent` |
+| `uteke doc breadcrumbs ` | Show path from root | |
+| `uteke doc descendants ` | List all descendants | `--max-depth`, `--limit` |
+| `uteke doc export` | Export all documents as JSON | `--output` |
+
+### Knowledge Graph
+
+| Command | Description | Key Options |
+|---------|-------------|-------------|
+| `uteke graph nodes` | List all graph nodes | `--entity-type` |
+| `uteke graph edges` | List all graph edges | `--relation` |
+| `uteke graph neighbors