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