diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9385db9b..4defcf15 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,6 +138,63 @@ jobs: - name: Run security rules run: ruff check src/ --select S --ignore S101,S110,S112,S311,S324 + integration: + name: Integration (SurrealDB) + runs-on: ubuntu-latest + timeout-minutes: 15 + env: + SURREALDB_URL: http://127.0.0.1:8001 + SURREALDB_USER: root + SURREALDB_PASS: root + SURREALDB_NS: smem_ci + SURREALDB_DB: smem_ci + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + # `services:` can't run this: the image's ENTRYPOINT is `/surreal` with no + # default CMD (no command == prints help and exits), and `services:` only + # accepts `options:` (docker-create flags), never a container command. A + # plain `docker run` step is the only way to pass the required `start …` + # arguments. Command mirrors docker-compose.surrealdb.yml, minus the + # persistence path: an in-memory datastore is enough for a run that's + # thrown away afterward and needs no root-owned named volume. + - name: Start SurrealDB + run: | + docker run -d --name surrealdb-ci -p 8001:8001 \ + surrealdb/surrealdb:v3.2.0 \ + start memory --user root --pass root --bind 0.0.0.0:8001 \ + --allow-experimental gql --allow-eval-query + + - name: Wait for SurrealDB to be ready + run: | + for _ in $(seq 1 30); do + if docker exec surrealdb-ci /surreal isready --endpoint http://localhost:8001; then + exit 0 + fi + sleep 1 + done + echo "SurrealDB did not become ready in time" + docker logs surrealdb-ci + exit 1 + + - name: Install dependencies + run: pip install -e ".[dev,server,surrealdb]" + + # No -n auto here: parallel workers hammering one shared SurrealDB + # connection produce connection resets under load unrelated to any real + # regression. Correctness, not speed, is the point of this job. + - name: Run tests against a live SurrealDB + run: pytest tests/ -v --timeout=120 -m "not stress" + + - name: SurrealDB logs (on failure) + if: failure() + run: docker logs surrealdb-ci + build: name: Build Package runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b23cf6..bb8068fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,92 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.3.0] — 2026-08-03 — a contributor's stop-word acronym fix, and a sweep of small honesty bugs + +### Added + +- **A CI job now runs the full test suite against a live SurrealDB.** Every prior job + either mocked the database or skipped whenever `SURREALDB_URL` was unset, so the only + production backend went untested in the pipeline — the exact gap `#143`'s bug walked + through (a query that returns plausible garbage instead of failing loudly). The job + starts the official image via a `docker run` step (GitHub Actions' `services:` cannot + pass the datastore/flags SurrealDB's entrypoint requires), waits for `/surreal isready`, + then runs the whole suite sequentially — no `-n auto`, since parallel workers sharing one + connection produce transaction-conflict errors unrelated to any real regression. + +### Fixed + +- **Polish stop words `ma`/`na`/`co`/`sa` swallowed the acronyms `MA`/`NA`/`CO`/`SA`.** + A contributor's audit (`#64`) showed the fix everyone assumed — removing the words from + the stop list — was wrong: `N/A` and `S.A.` never actually collide (punctuation + fragments them below the minimum word length before the stop-word check ever runs), so + removing the words would only trade real function-word noise for a niche acronym gain. + A token that is ALL-CAPS in the source now survives even when its lowercased form is a + stop word, gated by how much of the text is uppercase — a shouted note or an all-caps + heading has no acronym to rescue, only every word incidentally capitalized, and must not + have its stop-word filtering disabled wholesale. +- **`check_dead_modules` passed silently while five files imported a module that no + longer exists.** Reachability is computed from each file's imports, so one naming a + module the tree no longer has (the SQLite backend, removed in `3.0.0`) contributed + nothing to the graph and produced no diagnostic — the guard reported `No unreachable + modules.` while five benchmark/script files could not run. It now resolves every import + against the tree and reports the ones that don't. The five files were fixed: one + (`stress_at_scale.py`) measured only the removed backend and is deleted; the other four + now require a live `SURREALDB_URL` instead of silently falling back to it. +- **Two read-only endpoints in the sync hub could redirect scheduled maintenance onto + the wrong brain.** `GET /hub/status/{id}` and `GET /hub/devices/{id}` switched the + process-wide shared storage's active brain to answer a lookup, and the background + consolidation/decay loops read that same mutable state on their next tick — so a + read-only request for brain B could cause the next scheduled pass to run against B + instead of the brain the operator actually left active. Both endpoints now read through + an isolated, brain-scoped connection instead of mutating the shared one — the same + pattern already used for reasoning-training's read endpoints. +- **The tool-stats `days` filter changed the daily chart but not the summary above it.** + `get_tool_stats` took no `days` argument, so its summary was always computed over all + time; switching the dashboard's range selector between 7/30/90 days produced a + byte-identical summary while the chart below it changed correctly. The summary now + respects the same window as the daily series, and the two storage methods behind it are + declared on the storage interface (with an in-memory implementation) instead of being + reachable only on the SurrealDB backend behind an unchecked attribute access. +- **A `SELECT VALUE` query on an array-typed field could be read as if it were rows.** + The shared query helper's return type says it hands back row dictionaries, which is not + what a `SELECT VALUE` query returns — the field it selects for one matching row can + itself be an array, indistinguishable by shape alone from several separate scalar rows. + That mismatch is the mechanism behind a prior release's fixed bug (a result iterated + character by character). The one live call site reachable this way now goes through a + separate, honestly-typed helper instead of the row-shaped one. +- **The Settings brain-files panel showed a plausible path to a file that was never + written.** A brain that exists only in SurrealDB has no on-disk database file, but the + panel built and returned a path for it regardless — worse when a stale file from an + older install happened to sit at that exact path for one brain while its neighbours + showed nothing. The path is now omitted rather than fabricated when the file does not + exist. + +### Removed + +- **The `smem_drift` tool and its underlying tag-drift detector.** Every call path into it + swallowed its own failures, so on the only shipped backend it always reported "clean" — + indistinguishable from a real analysis that found nothing, because the two things it + measures (tag co-occurrence, session summaries) were never implemented on that backend + and never had been, even before the SQLite backend's removal. Its `merge` action never + actually merged tags either. `TagNormalizer`'s own drift detection, which needs no + storage and already backs `smem doctor`'s tag-drift warnings, remains and covers the + same practical need. A small unrelated write path — recording tag co-occurrence for the + now-removed detector, and a periodic session-summary persist with the same swallowed + failure — is removed alongside it. +- **A CLI sandbox-guard helper was renamed** from `ensure_aiosqlite_or_exit_cli` to + `ensure_sqlite_or_exit_cli`: it has only ever checked the stdlib `sqlite3` module, never + the optional `aiosqlite` package, and the name was misleading anyone reading the CLI + startup path. Internal-only; no public interface changed. + +### Changed + +- **BREAKING:** the `smem_drift` MCP tool is gone. If anything called it expecting a real + answer, that answer was never trustworthy on the SurrealDB backend to begin with — see + Removed, above. + +**Full diff**: https://github.com/acidkill/surreal-memory/compare/v3.2.0...v3.3.0 + ## [3.2.0] — 2026-08-03 — reasoning mining sees every profile; embeddings stop inheriting a stranger's endpoint ### Added diff --git a/README.md b/README.md index cd993d6b..c1939228 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ smem recall "auth bug" ## 3 Tools. That's It. -58 MCP tools are available, but you only need three: +57 MCP tools are available, but you only need three: | Tool | What it does | |------|-------------| @@ -126,7 +126,7 @@ Everything else — sessions, context loading, habit tracking, maintenance — w ``` ┌──────────────────────────────┐ - │ MCP Server (58 tools) │ + │ MCP Server (57 tools) │ └──────────┬───────────────────┘ │ ┌──────────▼───────────────────┐ diff --git a/benchmarks/rehearsal_coverage_overhead.py b/benchmarks/rehearsal_coverage_overhead.py index 45edd1ba..917f66a8 100644 --- a/benchmarks/rehearsal_coverage_overhead.py +++ b/benchmarks/rehearsal_coverage_overhead.py @@ -11,15 +11,14 @@ actual ``get_maturation``/``save_maturation`` round trips, since that IS the added cost: each extra rehearsed fiber is one more read plus one more write. -Backend: SurrealDB when ``SURREALDB_URL`` is set (matches this repo's -live-test gating convention), falling back to a temporary SQLite file -otherwise. This matters: SQLite's ``find_fibers`` goes through an indexed -``fiber_neurons`` junction table, while the SurrealDB backend's equivalent -query is an unindexed array-containment scan over ``fiber.neuron_ids`` (no -index on that field, confirmed via an independent database-review pass on -this run) -- an earlier SQLite-only run of this benchmark was measuring a -different, faster query shape than what production actually pays. Prefer the -SurrealDB path whenever a live instance is available. +Backend: SurrealDB, required -- ``SURREALDB_URL`` must be set (matching this +repo's live-test gating convention), and without it the benchmark exits +instead of measuring something else. The old fallback to a temporary SQLite +file was misleading before v3.0.0 deleted that backend: SQLite's +``find_fibers`` went through an indexed ``fiber_neurons`` junction table, +while the SurrealDB equivalent is an unindexed array-containment scan over +``fiber.neuron_ids``. The fallback was measuring a different, faster query +shape than production pays. Intentionally not a CI assertion (microbenchmark timing is too noisy for a hard threshold). @@ -85,45 +84,40 @@ async def _run_against(storage: Any, brain_id: str) -> tuple[float, float]: async def _main() -> None: surrealdb_url = os.environ.get("SURREALDB_URL") + if not surrealdb_url: + print("SURREALDB_URL is not set.") + print() + print("This benchmark needs the production backend: the cost it measures is") + print("get_maturation/save_maturation round trips, which only a live engine") + print("charges honestly. Start SurrealDB and set SURREALDB_URL, e.g.") + print(" docker compose -f docker-compose.surrealdb.yml up -d") + print(" SURREALDB_URL=ws://localhost:8001/rpc python benchmarks/rehearsal_coverage_overhead.py") + raise SystemExit(1) + + backend = "SurrealDB" brain_id = f"bench-rehearsal-{uuid4().hex[:8]}" - if surrealdb_url: - backend = "SurrealDB" - from surreal_memory.storage.surrealdb.store import SurrealDBStorage - - storage = SurrealDBStorage(url=surrealdb_url) - await storage.initialize() - try: - old_ms, new_ms = await _run_against(storage, brain_id) - finally: - # Cleanup discipline (project hard rule): delete only this run's - # own brain, by its exact id, never `default`. Re-fetch the real - # RecordID via SELECT rather than reconstructing one from the - # string id -- a hand-built `type::record('brain', $bid)` looked - # like it succeeded (no error) but silently matched zero rows, - # confirmed live: an earlier version of this script left its test - # brain orphaned this way even though the query "succeeded". - await storage.clear(brain_id) - rows = await storage._query( - "SELECT id FROM brain WHERE id = type::record('brain', $bid)", bid=brain_id - ) - for row in rows: - await storage._query("DELETE $rid", rid=row["id"]) - await storage.close() - else: - backend = ( - "SQLite (SurrealDB not reachable -- set SURREALDB_URL for the production-accurate path)" + from surreal_memory.storage.surrealdb.store import SurrealDBStorage + + storage = SurrealDBStorage(url=surrealdb_url) + await storage.initialize() + try: + old_ms, new_ms = await _run_against(storage, brain_id) + finally: + # Cleanup discipline (project hard rule): delete only this run's + # own brain, by its exact id, never `default`. Re-fetch the real + # RecordID via SELECT rather than reconstructing one from the + # string id -- a hand-built `type::record('brain', $bid)` looked + # like it succeeded (no error) but silently matched zero rows, + # confirmed live: an earlier version of this script left its test + # brain orphaned this way even though the query "succeeded". + await storage.clear(brain_id) + rows = await storage._query( + "SELECT id FROM brain WHERE id = type::record('brain', $bid)", bid=brain_id ) - from surreal_memory.storage.sqlite_store import SQLiteStorage - - with tempfile.TemporaryDirectory() as tmp: - db_path = Path(tmp) / "bench.db" - storage = SQLiteStorage(db_path) - await storage.initialize() - try: - old_ms, new_ms = await _run_against(storage, brain_id) - finally: - await storage.close() + for row in rows: + await storage._query("DELETE $rid", rid=row["id"]) + await storage.close() print(f"backend: {backend}") print(f"reinforce() at limit=10 (old default): {old_ms:8.2f} ms/call") diff --git a/benchmarks/stress_at_scale.py b/benchmarks/stress_at_scale.py deleted file mode 100755 index d5d66ff4..00000000 --- a/benchmarks/stress_at_scale.py +++ /dev/null @@ -1,549 +0,0 @@ -""" -Mega stress test — benchmark encode, recall, consolidation, and diagnostics -at 1K, 5K, and 10K memories on real SQLite. - -Usage: - python benchmarks/stress_at_scale.py - -Outputs: - - Console: live progress + results - - docs/benchmarks.md: appends SQLite-at-scale section -""" - -from __future__ import annotations - -import asyncio -import gc -import os -import random -import statistics -import sys -import time -from datetime import datetime -from pathlib import Path -from tempfile import TemporaryDirectory - -sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) - -from surreal_memory.core.brain import Brain, BrainConfig -from surreal_memory.engine.consolidation import ConsolidationEngine, ConsolidationStrategy -from surreal_memory.engine.diagnostics import DiagnosticsEngine -from surreal_memory.engine.encoder import MemoryEncoder -from surreal_memory.engine.retrieval import DepthLevel, ReflexPipeline -from surreal_memory.storage.sqlite_store import SQLiteStorage - -# ── Content generators ─────────────────────────────────────────────────────── - -TOPICS = [ - "Python", "JavaScript", "Rust", "Go", "TypeScript", "Java", "C++", "Ruby", - "PostgreSQL", "Redis", "MongoDB", "MySQL", "SQLite", "Elasticsearch", "Cassandra", - "Docker", "Kubernetes", "Terraform", "Ansible", "Jenkins", "GitHub Actions", "ArgoCD", - "React", "Vue", "Angular", "Svelte", "Next.js", "FastAPI", "Django", "Flask", - "JWT", "OAuth2", "CORS", "HTTPS", "WebSocket", "gRPC", "GraphQL", "REST", - "AWS", "GCP", "Azure", "Cloudflare", "Vercel", "Netlify", "DigitalOcean", - "Machine Learning", "Neural Networks", "NLP", "Computer Vision", "Transformers", -] - -ACTIONS = [ - "supports", "implements", "requires", "provides", "enables", "handles", - "manages", "processes", "validates", "optimizes", "replaces", "extends", - "integrates with", "depends on", "supersedes", "enhances", -] - -FEATURES = [ - "concurrent request handling", "type-safe data validation", - "automatic schema generation", "efficient memory management", - "distributed caching layers", "real-time event streaming", - "structured error handling", "automated test discovery", - "incremental compilation", "hot module replacement", - "connection pooling", "query optimization", "load balancing", - "rate limiting", "health monitoring", "circuit breaker pattern", - "retry with exponential backoff", "blue-green deployment", - "canary releases", "feature flag management", - "structured logging", "distributed tracing", - "authentication middleware", "authorization policies", - "input sanitization", "CSRF protection", -] - -MEMORY_TYPES = ["fact", "decision", "error", "insight", "todo", "workflow", "context"] - -DECISION_TEMPLATES = [ - "We decided to use {topic1} instead of {topic2} because {reason}", - "Chose {topic1} over {topic2} for {feature}", - "After evaluating both, {topic1} was selected for {feature}", -] - -ERROR_TEMPLATES = [ - "ConnectionError when {topic1} tried to connect to {topic2}: timeout after 30s", - "ImportError in {topic1} module: missing dependency for {feature}", - "{topic1} failed during {feature} with exit code 1", -] - -INSIGHT_TEMPLATES = [ - "Pattern: {topic1} {action} {feature} more efficiently than {topic2}", - "{topic1} and {topic2} both {action} {feature} but through different mechanisms", - "Root cause: {topic1} {feature} breaks when {topic2} is unavailable", -] - -REASONS = [ - "better performance under load", "stronger type safety", - "more active community", "better documentation", - "lower operational cost", "simpler deployment", - "native async support", "better error messages", -] - - -def generate_diverse_memories(n: int) -> list[tuple[str, str]]: - """Generate N unique, diverse memories with types. Returns [(content, type)].""" - random.seed(42) - memories: list[tuple[str, str]] = [] - - for i in range(n): - t1 = random.choice(TOPICS) - t2 = random.choice([t for t in TOPICS if t != t1]) - action = random.choice(ACTIONS) - feature = random.choice(FEATURES) - reason = random.choice(REASONS) - mtype = MEMORY_TYPES[i % len(MEMORY_TYPES)] - - if mtype == "fact": - content = f"{t1} {action} {feature} (fact #{i})" - elif mtype == "decision": - tmpl = random.choice(DECISION_TEMPLATES) - content = tmpl.format(topic1=t1, topic2=t2, feature=feature, reason=reason) - elif mtype == "error": - tmpl = random.choice(ERROR_TEMPLATES) - content = tmpl.format(topic1=t1, topic2=t2, feature=feature) - elif mtype == "insight": - tmpl = random.choice(INSIGHT_TEMPLATES) - content = tmpl.format(topic1=t1, topic2=t2, action=action, feature=feature) - elif mtype == "todo": - content = f"TODO: Implement {feature} using {t1} before next release" - elif mtype == "workflow": - content = f"Workflow: {t1} → {t2} → {feature} → deploy" - else: - content = f"Context: {t1} team meeting discussed {feature} with {t2} integration" - - memories.append((content, mtype)) - - return memories - - -# ── Benchmark runners ──────────────────────────────────────────────────────── - - -async def bench_encode( - storage: SQLiteStorage, - encoder: MemoryEncoder, - memories: list[tuple[str, str]], - batch_label: str, -) -> dict: - """Encode all memories, track per-memory timing.""" - times: list[float] = [] - errors = 0 - - print(f" [{batch_label}] Encoding {len(memories)} memories...", end="", flush=True) - t_start = time.perf_counter() - - for i, (content, mtype) in enumerate(memories): - t0 = time.perf_counter() - try: - await encoder.encode(content, tags={mtype}) - except Exception as e: - errors += 1 - if errors <= 3: - print(f"\n ERROR at #{i}: {e}") - elapsed = (time.perf_counter() - t0) * 1000 - times.append(elapsed) - - # Progress dots - if (i + 1) % 500 == 0: - print(f" {i + 1}", end="", flush=True) - - total_ms = (time.perf_counter() - t_start) * 1000 - print(f" done ({total_ms / 1000:.1f}s)") - - return { - "count": len(memories), - "total_ms": round(total_ms, 1), - "mean_ms": round(statistics.mean(times), 2), - "median_ms": round(statistics.median(times), 2), - "p95_ms": round(sorted(times)[int(len(times) * 0.95)], 2), - "p99_ms": round(sorted(times)[int(len(times) * 0.99)], 2), - "max_ms": round(max(times), 2), - "throughput": round(len(memories) / (total_ms / 1000), 1), - "errors": errors, - } - - -async def bench_recall( - storage: SQLiteStorage, - config: BrainConfig, - queries: list[tuple[str, DepthLevel]], - n_runs: int, - label: str, -) -> list[dict]: - """Run recall queries and measure latency.""" - brain = await storage.get_brain(storage._current_brain_id) # type: ignore[arg-type] - assert brain is not None - pipeline = ReflexPipeline(storage=storage, config=config) - - results: list[dict] = [] - for query, depth in queries: - times: list[float] = [] - last = None - for _ in range(n_runs): - t0 = time.perf_counter() - result = await pipeline.query(query, depth=depth) - times.append((time.perf_counter() - t0) * 1000) - last = result - - results.append({ - "query": query, - "depth": depth.name, - "median_ms": round(statistics.median(times), 2), - "p95_ms": round(sorted(times)[int(len(times) * 0.95)], 2), - "neurons": last.neurons_activated if last else 0, - "confidence": round(last.confidence, 2) if last else 0, - "has_answer": bool(last and last.context), - }) - - return results - - -async def bench_consolidation(storage: SQLiteStorage) -> dict: - """Run full consolidation and measure time.""" - print(" Consolidating...", end="", flush=True) - engine = ConsolidationEngine(storage=storage) - t0 = time.perf_counter() - report = await engine.run(strategies=[ConsolidationStrategy.ALL]) - elapsed = (time.perf_counter() - t0) * 1000 - print(f" done ({elapsed / 1000:.1f}s)") - - return { - "duration_ms": round(elapsed, 1), - "synapses_pruned": report.synapses_pruned, - "neurons_pruned": report.neurons_pruned, - "fibers_merged": report.fibers_merged, - "synapses_enriched": report.synapses_enriched, - } - - -async def bench_diagnostics(storage: SQLiteStorage) -> dict: - """Run diagnostics and extract health metrics.""" - brain_id = storage._current_brain_id - assert brain_id is not None - - engine = DiagnosticsEngine(storage=storage) - t0 = time.perf_counter() - report = await engine.analyze(brain_id) - elapsed = (time.perf_counter() - t0) * 1000 - - stats = await storage.get_stats(brain_id) - - return { - "diagnostics_ms": round(elapsed, 1), - "grade": report.grade, - "purity": round(report.purity_score, 1), - "connectivity": round(report.connectivity, 3), - "diversity": round(report.diversity, 3), - "freshness": round(report.freshness, 3), - "orphan_rate": round(report.orphan_rate, 3), - "neuron_count": stats["neuron_count"], - "synapse_count": stats["synapse_count"], - "fiber_count": stats["fiber_count"], - "warnings": len(report.warnings), - "critical_warnings": len([w for w in report.warnings if w.severity.name == "CRITICAL"]), - } - - -async def get_db_size(db_path: str) -> float: - """Get database file size in MB.""" - path = Path(db_path) - if path.exists(): - return path.stat().st_size / (1024 * 1024) - return 0.0 - - -# ── Recall queries ─────────────────────────────────────────────────────────── - -RECALL_QUERIES = [ - ("Python concurrency", DepthLevel.INSTANT), - ("What database did we choose?", DepthLevel.CONTEXT), - ("connection error Redis", DepthLevel.INSTANT), - ("deployment workflow", DepthLevel.CONTEXT), - ("Why did we choose PostgreSQL?", DepthLevel.DEEP), - ("authentication JWT", DepthLevel.INSTANT), - ("What patterns were discovered?", DepthLevel.CONTEXT), - ("machine learning integration", DepthLevel.DEEP), - ("rate limiting implementation", DepthLevel.INSTANT), - ("TODO before release", DepthLevel.CONTEXT), -] - - -# ── Main benchmark ─────────────────────────────────────────────────────────── - - -async def run_scale_benchmark(n_memories: int, tmpdir: str) -> dict: - """Run full benchmark at a given scale.""" - print(f"\n{'=' * 60}") - print(f" SCALE: {n_memories:,} memories") - print(f"{'=' * 60}") - - db_path = os.path.join(tmpdir, f"bench_{n_memories}.db") - storage = SQLiteStorage(db_path=str(db_path)) - await storage.initialize() - - config = BrainConfig( - decay_rate=0.1, - reinforcement_delta=0.05, - activation_threshold=0.15, - max_spread_hops=4, - max_context_tokens=1500, - ) - brain = Brain.create(name=f"bench-{n_memories}", config=config) - await storage.save_brain(brain) - storage.set_brain(brain.id) - - encoder = MemoryEncoder(storage=storage, config=config) - memories = generate_diverse_memories(n_memories) - - # Phase 1: Encode - encode_result = await bench_encode(storage, encoder, memories, f"{n_memories:,}") - - db_size_after_encode = await get_db_size(db_path) - print(f" DB size after encode: {db_size_after_encode:.1f} MB") - - # Phase 2: Recall (pre-consolidation) - print(f" Running recall queries (pre-consolidation)...") - recall_pre = await bench_recall(storage, config, RECALL_QUERIES, n_runs=5, label="pre") - - # Phase 3: Diagnostics (pre-consolidation) - print(f" Running diagnostics (pre-consolidation)...") - diag_pre = await bench_diagnostics(storage) - print(f" Grade: {diag_pre['grade']} | Purity: {diag_pre['purity']} | " - f"Neurons: {diag_pre['neuron_count']:,} | Synapses: {diag_pre['synapse_count']:,} | " - f"Fibers: {diag_pre['fiber_count']:,}") - - # Phase 4: Consolidation - consolidation = await bench_consolidation(storage) - - db_size_after_consolidation = await get_db_size(db_path) - print(f" DB size after consolidation: {db_size_after_consolidation:.1f} MB") - - # Phase 5: Recall (post-consolidation) - print(f" Running recall queries (post-consolidation)...") - recall_post = await bench_recall(storage, config, RECALL_QUERIES, n_runs=5, label="post") - - # Phase 6: Diagnostics (post-consolidation) - print(f" Running diagnostics (post-consolidation)...") - diag_post = await bench_diagnostics(storage) - print(f" Grade: {diag_post['grade']} | Purity: {diag_post['purity']} | " - f"Neurons: {diag_post['neuron_count']:,} | Synapses: {diag_post['synapse_count']:,} | " - f"Fibers: {diag_post['fiber_count']:,}") - - await storage.close() - gc.collect() - - return { - "scale": n_memories, - "encode": encode_result, - "db_size_mb": round(db_size_after_encode, 2), - "db_size_post_consolidation_mb": round(db_size_after_consolidation, 2), - "recall_pre": recall_pre, - "recall_post": recall_post, - "diag_pre": diag_pre, - "diag_post": diag_post, - "consolidation": consolidation, - } - - -# ── Markdown generation ────────────────────────────────────────────────────── - - -def md_table(headers: list[str], rows: list[list[str]]) -> str: - lines = [ - "| " + " | ".join(headers) + " |", - "|" + "|".join(" --- " for _ in headers) + "|", - ] - for row in rows: - lines.append("| " + " | ".join(row) + " |") - return "\n".join(lines) - - -def generate_scale_markdown(results: list[dict], timestamp: str) -> str: - sections: list[str] = [] - - sections.append("## SQLite at Scale\n") - sections.append(f"Last updated: **{timestamp}**\n") - sections.append("Real SQLiteStorage benchmarks with diverse memory types on Windows 11.\n") - - # ── Encode throughput ── - sections.append("### Encode Throughput\n") - headers = ["Memories", "Total (s)", "Mean (ms)", "Median (ms)", "P95 (ms)", "P99 (ms)", "Throughput (mem/s)", "Errors"] - rows = [] - for r in results: - e = r["encode"] - rows.append([ - f"{r['scale']:,}", - f"{e['total_ms'] / 1000:.1f}", - str(e["mean_ms"]), - str(e["median_ms"]), - str(e["p95_ms"]), - str(e["p99_ms"]), - str(e["throughput"]), - str(e["errors"]), - ]) - sections.append(md_table(headers, rows)) - - # ── Database size ── - sections.append("\n### Database Size\n") - headers = ["Memories", "After Encode (MB)", "After Consolidation (MB)", "Neurons", "Synapses", "Fibers"] - rows = [] - for r in results: - d = r["diag_pre"] - rows.append([ - f"{r['scale']:,}", - str(r["db_size_mb"]), - str(r["db_size_post_consolidation_mb"]), - f"{d['neuron_count']:,}", - f"{d['synapse_count']:,}", - f"{d['fiber_count']:,}", - ]) - sections.append(md_table(headers, rows)) - - # ── Recall latency ── - sections.append("\n### Recall Latency (Post-Consolidation)\n") - sections.append("10 queries, 5 runs each (median reported).\n") - for r in results: - sections.append(f"\n#### {r['scale']:,} memories\n") - headers = ["Query", "Depth", "Median (ms)", "P95 (ms)", "Neurons", "Confidence", "Found"] - rows = [] - for q in r["recall_post"]: - rows.append([ - q["query"], - q["depth"], - str(q["median_ms"]), - str(q["p95_ms"]), - str(q["neurons"]), - str(q["confidence"]), - "yes" if q["has_answer"] else "no", - ]) - # Add average row - avg_median = round(statistics.mean(q["median_ms"] for q in r["recall_post"]), 2) - avg_p95 = round(statistics.mean(q["p95_ms"] for q in r["recall_post"]), 2) - avg_neurons = round(statistics.mean(q["neurons"] for q in r["recall_post"]), 1) - rows.append([ - "**Average**", "", f"**{avg_median}**", f"**{avg_p95}**", - f"**{avg_neurons}**", "", "", - ]) - sections.append(md_table(headers, rows)) - - # ── Consolidation ── - sections.append("\n### Consolidation Performance\n") - headers = ["Memories", "Duration (s)", "Synapses Pruned", "Neurons Pruned", "Fibers Merged", "Synapses Enriched"] - rows = [] - for r in results: - c = r["consolidation"] - rows.append([ - f"{r['scale']:,}", - f"{c['duration_ms'] / 1000:.1f}", - str(c["synapses_pruned"]), - str(c["neurons_pruned"]), - str(c["fibers_merged"]), - str(c["synapses_enriched"]), - ]) - sections.append(md_table(headers, rows)) - - # ── Health ── - sections.append("\n### Health Diagnostics\n") - headers = ["Memories", "Phase", "Grade", "Purity", "Connectivity", "Diversity", "Freshness", "Orphan Rate", "Warnings", "Diagnostics (ms)"] - rows = [] - for r in results: - for phase, key in [("Pre", "diag_pre"), ("Post", "diag_post")]: - d = r[key] - rows.append([ - f"{r['scale']:,}", - phase, - d["grade"], - str(d["purity"]), - str(d["connectivity"]), - str(d["diversity"]), - str(d["freshness"]), - str(d["orphan_rate"]), - str(d["warnings"]), - str(d["diagnostics_ms"]), - ]) - sections.append(md_table(headers, rows)) - - # ── Methodology ── - sections.append("\n### Methodology\n") - sections.append(""" -- **Storage**: Real SQLiteStorage (aiosqlite, WAL mode) -- **Platform**: Windows 11, single-threaded async -- **Memory types**: 7 types (fact, decision, error, insight, todo, workflow, context) -- **Content**: Diverse generated content from 50 topics × 16 actions × 26 features -- **Recall runs**: 5 per query (median reported) -- **Seed**: `random.seed(42)` for reproducibility -""".strip()) - - return "\n\n".join(sections) + "\n" - - -# ── Main ───────────────────────────────────────────────────────────────────── - - -async def main() -> None: - # Default scales. Override with env: BENCH_SCALES="50000,100000" - env_scales = os.environ.get("BENCH_SCALES", "") - if env_scales: - scales = [int(s.strip()) for s in env_scales.split(",")] - else: - scales = [1000, 5000, 10000] - results: list[dict] = [] - - with TemporaryDirectory(prefix="smem_bench_") as tmpdir: - for n in scales: - result = await run_scale_benchmark(n, tmpdir) - results.append(result) - - # Generate markdown - timestamp = datetime.now().strftime("%Y-%m-%d %H:%M") - scale_md = generate_scale_markdown(results, timestamp) - - # Read existing benchmarks.md and append - docs_dir = Path(__file__).resolve().parent.parent / "docs" - bench_path = docs_dir / "benchmarks.md" - - if bench_path.exists(): - existing = bench_path.read_text(encoding="utf-8") - # Remove old SQLite at Scale section if present - marker = "## SQLite at Scale" - if marker in existing: - existing = existing[:existing.index(marker)].rstrip() + "\n\n" - combined = existing + scale_md - else: - combined = scale_md - - bench_path.write_text(combined, encoding="utf-8") - print(f"\nWrote results to {bench_path}") - - # Print summary - print(f"\n{'=' * 60}") - print(" SUMMARY") - print(f"{'=' * 60}") - for r in results: - e = r["encode"] - d = r["diag_post"] - c = r["consolidation"] - avg_recall = round(statistics.mean(q["median_ms"] for q in r["recall_post"]), 2) - print(f" {r['scale']:>6,} memories: " - f"encode={e['throughput']} mem/s, " - f"recall_avg={avg_recall}ms, " - f"consolidation={c['duration_ms'] / 1000:.1f}s, " - f"grade={d['grade']}, " - f"db={r['db_size_post_consolidation_mb']}MB") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/dashboard/src/api/types.ts b/dashboard/src/api/types.ts index 34a2aef6..834c50df 100755 --- a/dashboard/src/api/types.ts +++ b/dashboard/src/api/types.ts @@ -208,7 +208,9 @@ export interface GraphFiber { // GET /api/dashboard/brain-files export interface BrainFileInfo { name: string - path: string + // null when the backend has no on-disk file for this brain (e.g. a + // SurrealDB-only brain never had a SQLite-era .db file to point at). + path: string | null size_bytes: number is_active: boolean } diff --git a/docs/api/mcp-tools.md b/docs/api/mcp-tools.md index eb50ca3d..ba413514 100755 --- a/docs/api/mcp-tools.md +++ b/docs/api/mcp-tools.md @@ -1,7 +1,7 @@ # MCP Tools Reference Complete reference for all Surreal-Memory MCP tools. -**58 tools** available via MCP stdio transport. +**57 tools** available via MCP stdio transport. !!! tip Tools are called as MCP tool calls, not CLI commands. In Claude Code, call `smem_recall` directly — do not run `smem recall` in terminal. @@ -49,7 +49,6 @@ Complete reference for all Surreal-Memory MCP tools. - [`smem_forget`](#smem_forget) - [`smem_pin`](#smem_pin) - [`smem_consolidate`](#smem_consolidate) - - [`smem_drift`](#smem_drift) - [`smem_review`](#smem_review) - [`smem_alerts`](#smem_alerts) - [Cloud Sync & Backup](#sync) @@ -561,11 +560,11 @@ Pin, unpin, or list pinned memories. Pinned memories skip decay, pruning, and co ### `smem_consolidate` -Run memory consolidation on the current brain. Strategies: prune (remove weak synapses/orphans), merge (combine overlapping fibers), summarize (cluster topic neurons), mature (episodic→semantic), infer (co-activation synapses), enrich (metadata extraction), dream (synthetic bridges), learn_habits (workflow patterns), dedup (merge near-duplicates), semantic_link (cross-domain connections), compress (old fibers), process_tool_events, detect_drift (find tag synonyms/aliases), all (run all in dependency order). Use dry_run=true to preview without applying changes. +Run memory consolidation on the current brain. Strategies: prune (remove weak synapses/orphans), merge (combine overlapping fibers), summarize (cluster topic neurons), mature (episodic→semantic), infer (co-activation synapses), enrich (metadata extraction), dream (synthetic bridges), learn_habits (workflow patterns), dedup (merge near-duplicates), semantic_link (cross-domain connections), compress (old fibers), process_tool_events, all (run all in dependency order). Use dry_run=true to preview without applying changes. | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| -| `strategy` | string (`prune`, `merge`, `summarize`, `mature`, `infer`, `enrich`, `dream`, `learn_habits`, `dedup`, `semantic_link`, `compress`, `process_tool_events`, `detect_drift`, `all`) | No | default: all | Consolidation strategy to run (default: all) | +| `strategy` | string (`prune`, `merge`, `summarize`, `mature`, `infer`, `enrich`, `dream`, `learn_habits`, `dedup`, `semantic_link`, `compress`, `process_tool_events`, `all`) | No | default: all | Consolidation strategy to run (default: all) | | `dry_run` | boolean | No | default: false | Preview changes without applying (default: false) | | `prune_weight_threshold` | number | No | default: 0.05 | Synapse weight threshold for pruning (default: 0.05) | | `merge_overlap_threshold` | number | No | default: 0.5 | Jaccard overlap threshold for merging fibers (default: 0.5) | @@ -573,18 +572,6 @@ Run memory consolidation on the current brain. Strategies: prune (remove weak sy | `compact` | boolean | No | — | Return compact response (strip metadata hints, truncate lists). Saves 60-80% tokens. | | `token_budget` | integer | No | — | Max tokens for response. Progressively strips content to fit budget. | -### `smem_drift` - -Semantic drift detection — find tag clusters that should be merged or aliased. Detects when different tags refer to the same concept using Jaccard similarity. Actions: detect (run analysis), list (show clusters), merge (apply canonical tag), alias (mark as related), dismiss (ignore cluster). - -| Parameter | Type | Required | Default | Description | -|-----------|------|----------|---------|-------------| -| `action` | string (`detect`, `list`, `merge`, `alias`, `dismiss`) | Yes | — | detect=run drift analysis, list=show existing clusters, merge/alias/dismiss=resolve a specific cluster | -| `cluster_id` | string | No | — | Cluster ID to resolve (required for merge/alias/dismiss) | -| `status` | string (`detected`, `merged`, `aliased`, `dismissed`) | No | — | Filter clusters by status (for list action) | -| `compact` | boolean | No | — | Return compact response (strip metadata hints, truncate lists). Saves 60-80% tokens. | -| `token_budget` | integer | No | — | Max tokens for response. Progressively strips content to fit budget. | - ### `smem_review` Spaced repetition reviews (Leitner box system). @@ -884,4 +871,4 @@ One-shot snapshot of the current working situation: active session task, top 3 r --- -*Auto-generated by `scripts/gen_mcp_docs.py` from `tool_schemas.py` — 58 tools.* +*Auto-generated by `scripts/gen_mcp_docs.py` from `tool_schemas.py` — 57 tools.* diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 9b69fbb6..19eef2ae 100755 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -3,7 +3,7 @@ Last updated: **2026-03-16** -> **⚠️ Historical figures.** These benchmarks were measured on the legacy **SQLite** backend (`SQLiteStorage`) and old upstream version numbering (e.g. "v4.7.0"). Surreal-Memory has been **SurrealDB-only since v2.0.0** (schema v40, v2.7.x); SQLite is now a test fixture only. A re-run on the SurrealDB backend is pending — the numbers below reflect the algorithmic approach, not the current production backend. +> **⚠️ Historical figures.** These benchmarks were measured on the legacy **SQLite** backend (`SQLiteStorage`) and old upstream version numbering (e.g. "v4.7.0"). Surreal-Memory has been **SurrealDB-only since v2.0.0**, and v3.0.0 removed the SQLite backend outright — the class these numbers were produced with no longer exists. A re-run on the SurrealDB backend is pending; the numbers below reflect the algorithmic approach, not the current production backend. ## Surreal-Memory vs Mem0 — Competitive Benchmark @@ -239,6 +239,11 @@ Last updated: **2026-03-04 02:24** Real SQLiteStorage benchmarks with diverse memory types on Windows 11. +> **No longer reproducible.** The generator (`benchmarks/stress_at_scale.py`) was +> removed in v3.3.0: it measured `SQLiteStorage`, a backend v3.0.0 deleted, so it +> could not be run against anything that ships. The section is kept as a record of +> what was measured, not as a claim about the current engine. + ### Encode Throughput diff --git a/docs/guides/mcp-server.md b/docs/guides/mcp-server.md index 1b64e316..dd946c1f 100755 --- a/docs/guides/mcp-server.md +++ b/docs/guides/mcp-server.md @@ -623,7 +623,7 @@ Surreal-Memory is lightweight — it won't slow down your editor. **3 tools you need. 55 the agent handles automatically.** -58 tools are available, but most users only interact with three: +57 tools are available, but most users only interact with three: ### Essential (You Use These) @@ -683,7 +683,6 @@ These tools fire automatically via MCP instructions and hooks — you don't need | `smem_evolution` | Brain evolution metrics (maturation, plasticity) | | `smem_narrative` | Generate timeline/topic/causal narratives | | `smem_review` | Spaced repetition reviews (Leitner box system) | -| `smem_drift` | Detect and manage semantic drift in tags | ### Admin (Maintenance) @@ -729,7 +728,7 @@ come from each tool's own schema. ## Tool Tiers -By default all 58 tools are exposed on every API turn. If you want to reduce token overhead, configure a **tool tier** in `~/.surrealmemory/config.toml`: +By default all 57 tools are exposed on every API turn. If you want to reduce token overhead, configure a **tool tier** in `~/.surrealmemory/config.toml`: ```toml [tool_tier] @@ -754,7 +753,7 @@ smem config tier full # reset to full - **minimal** — `remember`, `recall`, `context`, `recap` - **standard** — minimal + `todo`, `session`, `auto`, `eternal` -- **full** — all 58 tools +- **full** — all 57 tools > Hidden tools remain callable — only the schema listing changes. If the AI model already knows a tool name, it can still call it even when the tool is not exposed in `tools/list`. diff --git a/docs/guides/migrating-to-3.0.md b/docs/guides/migrating-to-3.0.md index 651d1da9..af051adb 100644 --- a/docs/guides/migrating-to-3.0.md +++ b/docs/guides/migrating-to-3.0.md @@ -78,9 +78,9 @@ Compare `smem stats` against what the SQLite brain reported before the switch. | Carried | Not carried | |---|---| | Neurons, synapses, fibers | Document-training progress (the first `smem train` after migrating re-scans) | -| Typed memories — type, priority, tags, trust score, expiry, tier, validity window, supersession | Drift clusters and tag co-occurrence counts | -| Projects | Sync cursors and device registrations | -| Brain configuration | Change-log history | +| Typed memories — type, priority, tags, trust score, expiry, tier, validity window, supersession | Sync cursors and device registrations | +| Projects | Change-log history | +| Brain configuration | | | Pinned status of trained memories | | Everything in the right column is derived state: consolidation, recall and the @@ -141,3 +141,11 @@ container was started with. `smem doctor --fix` writes a consistent set. **The dashboard and the CLI disagree.** One of them is still on the old backend. This is the failure the deprecation warning calls out: check `smem doctor` in the same environment as each process. + +**`/health` reports `schema_version` dropped from 40 to 9.** That is not a +regression — `schema_version` is the *active backend's* schema version, and +2.x always reported the SQLite constant `40` even on a SurrealDB install. +SurrealDB's own schema is versioned separately and starts at a much lower +number. If you monitor this field, watch `version` (the product release, e.g. +`3.0.0`) for upgrades instead — `schema_version` moving is expected the moment +the active backend changes. diff --git a/integrations/surreal-memory-client/package-lock.json b/integrations/surreal-memory-client/package-lock.json index 391eaea2..8e542e3f 100644 --- a/integrations/surreal-memory-client/package-lock.json +++ b/integrations/surreal-memory-client/package-lock.json @@ -1,12 +1,12 @@ { "name": "@acidkill/surreal-memory-client", - "version": "3.2.0", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@acidkill/surreal-memory-client", - "version": "3.2.0", + "version": "3.3.0", "license": "MIT", "devDependencies": { "@types/node": "^20.0.0", diff --git a/integrations/surreal-memory-client/package.json b/integrations/surreal-memory-client/package.json index 96bc78bb..613479c5 100644 --- a/integrations/surreal-memory-client/package.json +++ b/integrations/surreal-memory-client/package.json @@ -1,6 +1,6 @@ { "name": "@acidkill/surreal-memory-client", - "version": "3.2.0", + "version": "3.3.0", "description": "TypeScript client for the Surreal-Memory REST API \u2014 typed access to brains, neurons, synapses, fibers, recall, and sync.", "type": "module", "main": "dist/index.cjs", diff --git a/integrations/surrealmemory/package-lock.json b/integrations/surrealmemory/package-lock.json index 37fe5b7b..5307084c 100755 --- a/integrations/surrealmemory/package-lock.json +++ b/integrations/surrealmemory/package-lock.json @@ -1,12 +1,12 @@ { "name": "@surrealmemory/openclaw-plugin", - "version": "3.2.0", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@surrealmemory/openclaw-plugin", - "version": "3.2.0", + "version": "3.3.0", "license": "MIT", "devDependencies": { "@types/node": "^25.2.2", diff --git a/integrations/surrealmemory/package.json b/integrations/surrealmemory/package.json index 5ebaf82a..18d3fb89 100755 --- a/integrations/surrealmemory/package.json +++ b/integrations/surrealmemory/package.json @@ -1,6 +1,6 @@ { "name": "surrealmemory", - "version": "3.2.0", + "version": "3.3.0", "description": "Surreal-Memory plugin for OpenClaw \u2014 graph-based persistent memory for AI agents with SurrealDB backend", "type": "module", "main": "dist/index.js", diff --git a/pyproject.toml b/pyproject.toml index 9177a33c..1daf9912 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "surreal-memory" -version = "3.2.0" +version = "3.3.0" description = "Reflex-based memory system for AI agents with SurrealDB backend — retrieval through activation, not search" readme = "README.md" license = "MIT" diff --git a/scripts/benchmark_cognee_vs_nm.py b/scripts/benchmark_cognee_vs_nm.py index 1a181d34..8c0dac79 100755 --- a/scripts/benchmark_cognee_vs_nm.py +++ b/scripts/benchmark_cognee_vs_nm.py @@ -9,10 +9,14 @@ 6. Conversation — store 10-turn chat, then recall context Run (requires Python 3.12 venv with cognee installed): - .venv-cognee/Scripts/python.exe scripts/benchmark_cognee_vs_nm.py + SURREALDB_URL=ws://localhost:8001/rpc \ + .venv-cognee/Scripts/python.exe scripts/benchmark_cognee_vs_nm.py Env vars: DASHSCOPE_API_KEY Alibaba Cloud / DashScope Coding Plan key + SURREALDB_URL Required. Surreal-Memory's production backend since + v2.0.0; this benchmark no longer falls back to SQLite, + which v3.0.0 removed outright. """ from __future__ import annotations @@ -22,9 +26,9 @@ import logging import os import sys -import tempfile import time import traceback +import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -269,11 +273,11 @@ def best_similarity(query: str, candidates: list[str]) -> float: # --------------------------------------------------------------------------- -async def nm_setup(db_path: Path) -> tuple[Any, Any, Any]: +async def nm_setup(brain_id: str) -> tuple[Any, Any, Any]: from surreal_memory.core.brain import Brain, BrainConfig from surreal_memory.engine.encoder import MemoryEncoder from surreal_memory.engine.retrieval import ReflexPipeline - from surreal_memory.storage.sqlite_store import SQLiteStorage + from surreal_memory.storage.surrealdb.store import SurrealDBStorage config = BrainConfig( max_context_tokens=3000, @@ -281,10 +285,10 @@ async def nm_setup(db_path: Path) -> tuple[Any, Any, Any]: graph_expansion_enabled=True, activation_strategy="classic", ) - storage = SQLiteStorage(db_path) + storage = SurrealDBStorage(url=os.environ["SURREALDB_URL"]) await storage.initialize() - brain = Brain.create(name="benchmark_brain", config=config, brain_id="benchmark_brain") + brain = Brain.create(name=brain_id, config=config, brain_id=brain_id) await storage.save_brain(brain) storage.set_brain(brain.id) @@ -294,6 +298,22 @@ async def nm_setup(db_path: Path) -> tuple[Any, Any, Any]: return storage, encoder, pipeline +async def nm_teardown(storage: Any, brain_id: str) -> None: + """Delete only this run's own brain, by its exact id, never ``default``. + + Re-fetch the real RecordID via SELECT rather than reconstructing one from + the string id -- a hand-built ``type::record('brain', $bid)`` can look + like it succeeded (no error) while silently matching zero rows. + """ + await storage.clear(brain_id) + rows = await storage._query( + "SELECT id FROM brain WHERE id = type::record('brain', $bid)", bid=brain_id + ) + for row in rows: + await storage._query("DELETE $rid", rid=row["id"]) + await storage.close() + + async def nm_store_memory(encoder: Any, content: str) -> None: await encoder.encode(content) @@ -813,17 +833,22 @@ async def main() -> None: print(f" Test memories : {len(MEMORIES_50)}") print(f" Recall queries: {len(QUERIES_20)}") + surrealdb_url = os.environ.get("SURREALDB_URL") + if not surrealdb_url: + print("SURREALDB_URL is not set.") + print("Start SurrealDB and set it, e.g.") + print(" docker compose -f docker-compose.surrealdb.yml up -d") + print(" SURREALDB_URL=ws://localhost:8001/rpc python scripts/benchmark_cognee_vs_nm.py") + sys.exit(1) + suite = BenchmarkSuite() - # Temp dirs - tmp_dir = Path(tempfile.mkdtemp(prefix="smem_bench_cognee_")) - nm_db = tmp_dir / "nm_benchmark.db" - print(f" Temp dir : {tmp_dir}") + nm_brain_id = f"bench-cognee-{uuid.uuid4().hex[:8]}" # Setup NM print("\n Setting up Surreal-Memory...") try: - nm_storage, nm_encoder, nm_pipeline = await nm_setup(nm_db) + nm_storage, nm_encoder, nm_pipeline = await nm_setup(nm_brain_id) print(" NM ready.") except Exception as e: print(f" FATAL: Surreal-Memory setup failed: {e}") @@ -850,7 +875,7 @@ async def main() -> None: await bench_conversation(suite, cognee_ok, nm_encoder, nm_pipeline) finally: - await nm_storage.close() + await nm_teardown(nm_storage, nm_brain_id) # Report print_report(suite) diff --git a/scripts/benchmark_mem0_vs_nm.py b/scripts/benchmark_mem0_vs_nm.py index b58a2578..e618e4cf 100755 --- a/scripts/benchmark_mem0_vs_nm.py +++ b/scripts/benchmark_mem0_vs_nm.py @@ -9,10 +9,13 @@ 6. Conversation — store 10-turn chat, then recall context Run: - python scripts/benchmark_mem0_vs_nm.py + SURREALDB_URL=ws://localhost:8001/rpc python scripts/benchmark_mem0_vs_nm.py Env vars: DASHSCOPE_API_KEY Alibaba Cloud / DashScope key (fallback hardcoded) + SURREALDB_URL Required. Surreal-Memory's production backend since + v2.0.0; this benchmark no longer falls back to SQLite, + which v3.0.0 removed outright. """ from __future__ import annotations @@ -25,6 +28,7 @@ import tempfile import time import traceback +import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -278,12 +282,12 @@ def best_similarity(query: str, candidates: list[str]) -> float: # --------------------------------------------------------------------------- -async def nm_setup(db_path: Path) -> tuple[Any, Any, Any]: - """Create + initialise SQLite storage, Brain, Encoder, ReflexPipeline.""" +async def nm_setup(brain_id: str) -> tuple[Any, Any, Any]: + """Create + initialise SurrealDB storage, Brain, Encoder, ReflexPipeline.""" from surreal_memory.core.brain import Brain, BrainConfig from surreal_memory.engine.encoder import MemoryEncoder from surreal_memory.engine.retrieval import DepthLevel, ReflexPipeline - from surreal_memory.storage.sqlite_store import SQLiteStorage + from surreal_memory.storage.surrealdb.store import SurrealDBStorage config = BrainConfig( max_context_tokens=3000, @@ -291,10 +295,10 @@ async def nm_setup(db_path: Path) -> tuple[Any, Any, Any]: graph_expansion_enabled=True, activation_strategy="classic", ) - storage = SQLiteStorage(db_path) + storage = SurrealDBStorage(url=os.environ["SURREALDB_URL"]) await storage.initialize() - brain = Brain.create(name="benchmark_brain", config=config, brain_id="benchmark_brain") + brain = Brain.create(name=brain_id, config=config, brain_id=brain_id) await storage.save_brain(brain) storage.set_brain(brain.id) @@ -304,6 +308,22 @@ async def nm_setup(db_path: Path) -> tuple[Any, Any, Any]: return storage, encoder, pipeline +async def nm_teardown(storage: Any, brain_id: str) -> None: + """Delete only this run's own brain, by its exact id, never ``default``. + + Re-fetch the real RecordID via SELECT rather than reconstructing one from + the string id -- a hand-built ``type::record('brain', $bid)`` can look + like it succeeded (no error) while silently matching zero rows. + """ + await storage.clear(brain_id) + rows = await storage._query( + "SELECT id FROM brain WHERE id = type::record('brain', $bid)", bid=brain_id + ) + for row in rows: + await storage._query("DELETE $rid", rid=row["id"]) + await storage.close() + + async def nm_store_memory(encoder: Any, content: str) -> None: """Encode one memory into NM. NM makes ZERO external API calls.""" await encoder.encode(content) @@ -766,17 +786,25 @@ async def main() -> None: print(f" Test memories : {len(MEMORIES_50)}") print(f" Recall queries: {len(QUERIES_20)}") + surrealdb_url = os.environ.get("SURREALDB_URL") + if not surrealdb_url: + print("SURREALDB_URL is not set.") + print("Start SurrealDB and set it, e.g.") + print(" docker compose -f docker-compose.surrealdb.yml up -d") + print(" SURREALDB_URL=ws://localhost:8001/rpc python scripts/benchmark_mem0_vs_nm.py") + sys.exit(1) + suite = BenchmarkSuite() - # Temp dirs + # Temp dir (Mem0's local Qdrant store only -- NM uses SurrealDB directly) tmp_dir = Path(tempfile.mkdtemp(prefix="smem_bench_")) - nm_db = tmp_dir / "nm_benchmark.db" + nm_brain_id = f"bench-mem0-{uuid.uuid4().hex[:8]}" print(f" Temp dir : {tmp_dir}") # Setup NM print("\n Setting up Surreal-Memory...") try: - nm_storage, nm_encoder, nm_pipeline = await nm_setup(nm_db) + nm_storage, nm_encoder, nm_pipeline = await nm_setup(nm_brain_id) print(" NM ready.") except Exception as e: print(f" FATAL: Surreal-Memory setup failed: {e}") @@ -803,7 +831,7 @@ async def main() -> None: await bench_conversation(suite, mem0_client, nm_encoder, nm_pipeline) finally: - await nm_storage.close() + await nm_teardown(nm_storage, nm_brain_id) # Report print_report(suite) diff --git a/scripts/check_dead_modules.py b/scripts/check_dead_modules.py index 6b15ebf5..dd494d1a 100644 --- a/scripts/check_dead_modules.py +++ b/scripts/check_dead_modules.py @@ -31,6 +31,7 @@ import sys import tomllib from pathlib import Path +from typing import NamedTuple ROOT = Path(__file__).resolve().parent.parent PACKAGE = "surreal_memory" @@ -67,9 +68,18 @@ def _scan_files() -> list[Path]: return files -def _imports_in(path: Path, tree: ast.AST) -> set[str]: - """Every ``surreal_memory.*`` module path this file imports.""" - found: set[str] = set() +def _scan_imports(path: Path, tree: ast.AST) -> tuple[set[str], set[str]]: + """Split this file's ``surreal_memory`` imports by how certain they are. + + The first set holds names that can only be modules: an ``import x.y`` + target, or the ``x.y`` that ``from x.y import …`` reads from. Those are the + ones worth resolving against the tree. The second holds ``x.y.name`` built + from ``from``-import aliases, which may be a submodule *or* an ordinary + attribute — indistinguishable without executing the import, so only the + graph walk consumes them. + """ + modules: set[str] = set() + attributes: set[str] = set() own_module = _module_name(path) if path.is_relative_to(SRC) else "" own_package = own_module.rsplit(".", 1)[0] if "." in own_module else PACKAGE @@ -78,7 +88,7 @@ def _imports_in(path: Path, tree: ast.AST) -> set[str]: if isinstance(node, ast.Import): for alias in node.names: if alias.name.startswith(PACKAGE): - found.add(alias.name) + modules.add(alias.name) elif isinstance(node, ast.ImportFrom): if node.level: parts = own_package.split(".") @@ -88,11 +98,26 @@ def _imports_in(path: Path, tree: ast.AST) -> set[str]: module = node.module or "" if not module.startswith(PACKAGE): continue - found.add(module) + modules.add(module) # `from pkg import submodule` imports a module, not an attribute. for alias in node.names: - found.add(f"{module}.{alias.name}") - return found + attributes.add(f"{module}.{alias.name}") + return modules, attributes + + +def _imports_in(path: Path, tree: ast.AST) -> set[str]: + """Every ``surreal_memory.*`` module path this file imports.""" + modules, attributes = _scan_imports(path, tree) + return modules | attributes + + +def _resolves(name: str) -> bool: + """Whether ``name`` is a module or package that exists in the source tree.""" + parts = name.split(".") + if parts[0] != PACKAGE: + return True + candidate = SRC.joinpath(*parts) + return candidate.with_suffix(".py").is_file() or (candidate / "__init__.py").is_file() def _string_references(parsed: dict[Path, ast.AST]) -> set[str]: @@ -119,7 +144,16 @@ def _console_script_modules() -> set[str]: return {str(target).split(":", 1)[0] for target in targets} -def find_dead_modules() -> list[str]: +class Report(NamedTuple): + """What one pass over the tree found.""" + + #: Modules nothing outside their own tests reaches. + dead: list[str] + #: Missing module -> the files whose `import` names it, repo-relative. + broken: dict[str, list[str]] + + +def analyse() -> Report: modules = _all_modules() parsed: dict[Path, ast.AST] = {} @@ -132,11 +166,23 @@ def find_dead_modules() -> list[str]: # Import graph over the package, plus the roots execution actually starts from. edges: dict[str, set[str]] = {} roots: set[str] = set(_console_script_modules()) | _string_references(parsed) + broken: dict[str, set[str]] = {} for path, tree in parsed.items(): inside_package = path.is_relative_to(PACKAGE_ROOT) importer = _module_name(path) if inside_package else "" - targets = _imports_in(path, tree) + imported, attributes = _scan_imports(path, tree) + targets = imported | attributes + + # Reachability is computed *from* these imports, so one that names a + # module the tree no longer has contributes nothing and says nothing: + # the graph walk simply never matches it. The file holding it cannot + # run either. Neither fact is visible in the dead-module list, so it + # gets its own channel. + for name in imported: + if not _resolves(name): + broken.setdefault(name, set()).add(str(path.relative_to(ROOT))) + if inside_package: edges.setdefault(importer, set()).update(targets) if path.name in _EXEMPT_BASENAMES: @@ -159,7 +205,14 @@ def find_dead_modules() -> list[str]: if package_init and package_init not in reachable: queue.append(package_init) - return sorted(name for name in modules if name not in reachable) + return Report( + dead=sorted(name for name in modules if name not in reachable), + broken={name: sorted(sources) for name, sources in sorted(broken.items())}, + ) + + +def find_dead_modules() -> list[str]: + return analyse().dead def main() -> None: @@ -167,17 +220,32 @@ def main() -> None: parser.add_argument("--strict", action="store_true", help="Exit 1 when anything is dead") args = parser.parse_args() - dead = find_dead_modules() - if not dead: - print("No unreachable modules.") + report = analyse() + if not report.dead and not report.broken: + print("No unreachable modules, no broken imports.") return - print(f"{len(dead)} module(s) reachable only from tests, if at all:") - for name in dead: - print(f" {name}") - print() - print("Delete them, wire them up, or — if something reaches them by a name this") - print("check cannot see — make that reference visible.") + if report.broken: + print(f"{len(report.broken)} import(s) name a module that is not in the tree:") + for name, sources in report.broken.items(): + print(f" {name}") + for source in sources: + print(f" imported by {source}") + print() + print("Reachability starts from these imports, so one that names a module") + print("which no longer exists contributes nothing — silently. The file") + print("holding it cannot run either. Fix the import or drop the file.") + + if report.dead: + if report.broken: + print() + print(f"{len(report.dead)} module(s) reachable only from tests, if at all:") + for name in report.dead: + print(f" {name}") + print() + print("Delete them, wire them up, or — if something reaches them by a name this") + print("check cannot see — make that reference visible.") + sys.exit(1 if args.strict else 0) diff --git a/scripts/e2e_gemini_recall.py b/scripts/e2e_gemini_recall.py index 81ec31b7..e61b25ce 100755 --- a/scripts/e2e_gemini_recall.py +++ b/scripts/e2e_gemini_recall.py @@ -1,4 +1,8 @@ -"""E2E test: Train motorcycle manual PDF → Recall in English via Gemini embeddings.""" +"""E2E test: Train motorcycle manual PDF -> Recall in English via Gemini embeddings. + +Requires GEMINI_API_KEY and SURREALDB_URL. The SQLite fallback this script used +to have was removed along with the SQLite backend in v3.0.0. +""" from __future__ import annotations @@ -7,6 +11,7 @@ import os import sys import tempfile +import uuid from pathlib import Path # Ensure src is importable @@ -40,6 +45,12 @@ async def main() -> None: print("ERROR: Set GEMINI_API_KEY env var") sys.exit(1) + surrealdb_url = os.environ.get("SURREALDB_URL") + if not surrealdb_url: + print("ERROR: Set SURREALDB_URL env var (this script's SQLite fallback") + print("was removed along with the SQLite backend in v3.0.0)") + sys.exit(1) + # --- Step 1: Extract PDF to markdown --- logger.info("Step 1: Extracting PDF → markdown") try: @@ -60,10 +71,10 @@ async def main() -> None: logger.info("Step 2: Creating fresh brain with Gemini embeddings") from surreal_memory.core.brain import Brain, BrainConfig - from surreal_memory.storage.sqlite_store import SQLiteStorage + from surreal_memory.storage.surrealdb.store import SurrealDBStorage - db_path = Path(tmp_dir) / "test_brain.db" - storage = SQLiteStorage(db_path) + brain_id = f"e2e-gemini-{uuid.uuid4().hex[:8]}" + storage = SurrealDBStorage(url=surrealdb_url) await storage.initialize() brain_config = BrainConfig( @@ -73,12 +84,12 @@ async def main() -> None: embedding_similarity_threshold=0.5, max_context_tokens=3000, ) - brain = Brain.create(name="huskyAI", config=brain_config, brain_id="huskyAI") + brain = Brain.create(name=brain_id, config=brain_config, brain_id=brain_id) await storage.save_brain(brain) storage.set_brain(brain.id) # Verify brain config round-trip - loaded_brain = await storage.get_brain("huskyAI") + loaded_brain = await storage.get_brain(brain_id) assert loaded_brain is not None, "Brain not found after save!" logger.info(" embedding_enabled=%s (stored)", loaded_brain.config.embedding_enabled) logger.info(" embedding_provider=%s (stored)", loaded_brain.config.embedding_provider) @@ -125,7 +136,7 @@ async def main() -> None: print("\n" + "=" * 80) print("E2E GEMINI RECALL RESULTS") print("=" * 80) - print(f"DB: {db_path}") + print(f"Brain: {brain_id}") print(f"Total neurons: {len(all_neurons)}, with embeddings: {emb_count}") print(f"Embedding provider: {brain_config.embedding_provider}") print(f"Similarity threshold: {brain_config.embedding_similarity_threshold}") @@ -146,7 +157,17 @@ async def main() -> None: else: print("FAIL: All queries returned 0 results") - # Cleanup + # Cleanup discipline (project hard rule): delete only this run's own + # brain, by its exact id, never `default`. Re-fetch the real RecordID via + # SELECT rather than reconstructing one from the string id -- a hand-built + # `type::record('brain', $bid)` can look like it succeeded (no error) + # while silently matching zero rows. + await storage.clear(brain_id) + rows = await storage._query( + "SELECT id FROM brain WHERE id = type::record('brain', $bid)", bid=brain_id + ) + for row in rows: + await storage._query("DELETE $rid", rid=row["id"]) await storage.close() print(f"\nTemp dir preserved at: {tmp_dir}") diff --git a/src/surreal_memory/__init__.py b/src/surreal_memory/__init__.py index 5ab66f41..633539c7 100755 --- a/src/surreal_memory/__init__.py +++ b/src/surreal_memory/__init__.py @@ -16,7 +16,7 @@ from surreal_memory.engine.reflex_activation import CoActivation, ReflexActivation from surreal_memory.engine.retrieval import DepthLevel, ReflexPipeline, RetrievalResult -__version__ = "3.2.0" +__version__ = "3.3.0" __all__ = [ "__version__", diff --git a/src/surreal_memory/cli/_helpers.py b/src/surreal_memory/cli/_helpers.py index f85eda4f..d7e4f4bb 100755 --- a/src/surreal_memory/cli/_helpers.py +++ b/src/surreal_memory/cli/_helpers.py @@ -54,10 +54,10 @@ def run_async(coro: Coroutine[Any, Any, T]) -> T: Replaces bare ``asyncio.run()`` to ensure aiosqlite connections are closed *before* the event loop is torn down. """ - from surreal_memory.utils.sandbox import ensure_aiosqlite_or_exit_cli + from surreal_memory.utils.sandbox import ensure_sqlite_or_exit_cli try: - ensure_aiosqlite_or_exit_cli() + ensure_sqlite_or_exit_cli() except BaseException: coro.close() raise diff --git a/src/surreal_memory/engine/consolidation.py b/src/surreal_memory/engine/consolidation.py index 3b6b23fc..f1e0024e 100755 --- a/src/surreal_memory/engine/consolidation.py +++ b/src/surreal_memory/engine/consolidation.py @@ -51,7 +51,6 @@ class ConsolidationStrategy(StrEnum): PROCESS_REASONING_TRACES = "process_reasoning_traces" LEARN_REASONING = "learn_reasoning" ESSENCE_BACKFILL = "essence_backfill" - DETECT_DRIFT = "detect_drift" REPLAY = "replay" # Hippocampal replay: LTP/LTD on recent fibers SCHEMA = "schema" # Schema assimilation: bottom-up knowledge organization INTERFERENCE = "interference" # Interference forgetting: memory competition @@ -326,7 +325,6 @@ class ConsolidationEngine: frozenset( { ConsolidationStrategy.SEMANTIC_LINK, - ConsolidationStrategy.DETECT_DRIFT, } ), ) @@ -375,7 +373,6 @@ async def _run_strategy( report, dry_run ), ConsolidationStrategy.LEARN_REASONING: lambda: self._learn_reasoning(report, dry_run), - ConsolidationStrategy.DETECT_DRIFT: lambda: self._detect_drift(report, dry_run), ConsolidationStrategy.ESSENCE_BACKFILL: lambda: self._essence_backfill(report, dry_run), ConsolidationStrategy.REPLAY: lambda: self._replay(report, dry_run), ConsolidationStrategy.SCHEMA: lambda: self._schema(report, dry_run), @@ -2274,28 +2271,3 @@ async def _learn_reasoning( result.patterns_learned, result.traces_processed, ) - - async def _detect_drift(self, report: ConsolidationReport, dry_run: bool) -> None: - """Run semantic drift detection to find tag synonyms/aliases.""" - _logger = logging.getLogger(__name__) - if dry_run: - _logger.debug("DETECT_DRIFT skipped: dry_run mode") - return - - try: - from surreal_memory.engine.drift_detection import run_drift_detection - - result = await run_drift_detection(self._storage) - summary: dict[str, Any] = result.get("summary", {}) # type: ignore[assignment] - total = summary.get("total_clusters", 0) - if total > 0: - _logger.debug( - "DETECT_DRIFT: found %d clusters (%d merge, %d alias, %d review)", - total, - summary.get("merge_suggestions", 0), - summary.get("alias_suggestions", 0), - summary.get("review_suggestions", 0), - ) - report.extra["drift_clusters"] = total - except Exception: - _logger.debug("DETECT_DRIFT failed (non-critical)", exc_info=True) diff --git a/src/surreal_memory/engine/drift_detection.py b/src/surreal_memory/engine/drift_detection.py deleted file mode 100755 index 57f24af9..00000000 --- a/src/surreal_memory/engine/drift_detection.py +++ /dev/null @@ -1,502 +0,0 @@ -"""Semantic drift detection — find tag clusters that should be merged. - -Uses tag co-occurrence matrix + Jaccard similarity to detect when -different tags refer to the same concept. Outputs drift reports -with confidence-based suggestions: merge, alias, or review. - -Runs during consolidation (not hot path). Zero LLM, pure statistics. -""" - -from __future__ import annotations - -import hashlib -import logging -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from surreal_memory.engine.clustering import UnionFind - -if TYPE_CHECKING: - from surreal_memory.storage.base import NeuralStorage - -logger = logging.getLogger(__name__) - -# ── Constants ────────────────────────────────────────────────────────── - -JACCARD_MERGE_THRESHOLD = 0.7 # Jaccard >= 0.7 → likely synonyms (auto-merge) -JACCARD_ALIAS_THRESHOLD = 0.4 # Jaccard >= 0.4 → related concepts (alias) -JACCARD_REVIEW_THRESHOLD = 0.3 # Jaccard >= 0.3 → possible drift (review) -MIN_COOCCURRENCE_COUNT = 3 # Minimum co-occurrences to consider -MAX_CLUSTER_SIZE = 10 # Max tags in a single cluster -MIN_TAG_FIBERS = 2 # Tag must appear in >= 2 fibers to be considered - - -# ── Data Models ──────────────────────────────────────────────────────── - - -@dataclass(frozen=True) -class TagCluster: - """A cluster of tags detected as potentially referring to the same concept.""" - - canonical: str # Most-used tag in the cluster - members: frozenset[str] # All tags in the cluster (including canonical) - confidence: float # Average Jaccard similarity within cluster - evidence: str = "" # Human-readable explanation - - -@dataclass(frozen=True) -class DriftReport: - """A single drift detection result with action suggestion.""" - - cluster: TagCluster - suggestion: str # "merge" | "alias" | "review" - cluster_id: str = "" # Stable ID for persistence - - -# ── Core Algorithm ───────────────────────────────────────────────────── - - -def compute_jaccard( - tag_a: str, - tag_b: str, - tag_fiber_counts: dict[str, int], - cooccurrence_count: int, -) -> float: - """Compute Jaccard similarity between two tags. - - J(A, B) = |A intersection B| / |A union B| - = cooccurrence / (count_a + count_b - cooccurrence) - """ - count_a = tag_fiber_counts.get(tag_a, 0) - count_b = tag_fiber_counts.get(tag_b, 0) - - if count_a == 0 or count_b == 0: - return 0.0 - - union = count_a + count_b - cooccurrence_count - if union <= 0: - return 0.0 - - return cooccurrence_count / union - - -def _cluster_id(members: frozenset[str]) -> str: - """Generate stable cluster ID from sorted member tags.""" - key = "|".join(sorted(members)) - return hashlib.sha256(key.encode()).hexdigest()[:12] - - -def detect_clusters( - cooccurrences: list[tuple[str, str, int]], - tag_fiber_counts: dict[str, int], -) -> list[DriftReport]: - """Detect tag clusters using Union-Find on Jaccard-similar pairs. - - Args: - cooccurrences: List of (tag_a, tag_b, count) pairs. - tag_fiber_counts: Dict of {tag: fiber_count} for Jaccard denominator. - - Returns: - List of DriftReport with confidence-based suggestions. - """ - if not cooccurrences: - return [] - - # Collect all unique tags - all_tags: list[str] = [] - tag_index: dict[str, int] = {} - for tag_a, tag_b, _count in cooccurrences: - for tag in (tag_a, tag_b): - if tag not in tag_index: - tag_index[tag] = len(all_tags) - all_tags.append(tag) - - if len(all_tags) < 2: - return [] - - # Compute Jaccard for each pair and union high-similarity pairs - uf = UnionFind(len(all_tags)) - pair_jaccards: dict[tuple[int, int], float] = {} - - for tag_a, tag_b, count in cooccurrences: - if count < MIN_COOCCURRENCE_COUNT: - continue - - # Skip tags that appear in very few fibers - if tag_fiber_counts.get(tag_a, 0) < MIN_TAG_FIBERS: - continue - if tag_fiber_counts.get(tag_b, 0) < MIN_TAG_FIBERS: - continue - - jaccard = compute_jaccard(tag_a, tag_b, tag_fiber_counts, count) - - if jaccard >= JACCARD_REVIEW_THRESHOLD: - idx_a = tag_index[tag_a] - idx_b = tag_index[tag_b] - pair_jaccards[(idx_a, idx_b)] = jaccard - - # Only union above alias threshold (review pairs stay separate) - if jaccard >= JACCARD_ALIAS_THRESHOLD: - uf.union(idx_a, idx_b) - - if not pair_jaccards: - return [] - - # Extract groups from Union-Find - groups = uf.groups() - - reports: list[DriftReport] = [] - for member_indices in groups.values(): - if len(member_indices) < 2: - continue - if len(member_indices) > MAX_CLUSTER_SIZE: - member_indices = member_indices[:MAX_CLUSTER_SIZE] - - member_tags = frozenset(all_tags[i] for i in member_indices) - - # Compute average Jaccard within cluster - jaccard_sum = 0.0 - jaccard_count = 0 - for i in member_indices: - for j in member_indices: - if i < j: - j_val = pair_jaccards.get((i, j), pair_jaccards.get((j, i), 0.0)) - if j_val > 0: - jaccard_sum += j_val - jaccard_count += 1 - - avg_jaccard = jaccard_sum / jaccard_count if jaccard_count > 0 else 0.0 - - # Pick canonical: tag with highest fiber count - canonical = max(member_tags, key=lambda t: tag_fiber_counts.get(t, 0)) - - # Determine suggestion based on confidence - if avg_jaccard >= JACCARD_MERGE_THRESHOLD: - suggestion = "merge" - elif avg_jaccard >= JACCARD_ALIAS_THRESHOLD: - suggestion = "alias" - else: - suggestion = "review" - - others = sorted(member_tags - {canonical}) - evidence = ( - f"Tags {others} co-occur with '{canonical}' " - f"(avg Jaccard={avg_jaccard:.2f}, " - f"fibers: {', '.join(f'{t}={tag_fiber_counts.get(t, 0)}' for t in sorted(member_tags))})" - ) - - cluster = TagCluster( - canonical=canonical, - members=member_tags, - confidence=round(avg_jaccard, 4), - evidence=evidence, - ) - - reports.append( - DriftReport( - cluster=cluster, - suggestion=suggestion, - cluster_id=_cluster_id(member_tags), - ) - ) - - # Sort by confidence descending - reports.sort(key=lambda r: r.cluster.confidence, reverse=True) - return reports - - -# ── Wasserstein-1 Activation Drift ──────────────────────────────────── - - -@dataclass(frozen=True) -class ActivationDriftResult: - """Result of W1 activation drift analysis for a neuron type.""" - - neuron_type: str - w1_distance: float # Wasserstein-1 distance between period distributions - status: str # "stable" | "drifting" | "major_shift" - period_a_count: int - period_b_count: int - - -@dataclass(frozen=True) -class ActivationDriftReport: - """Full W1 drift report across all neuron types.""" - - results: tuple[ActivationDriftResult, ...] - overall_drift: float # Average W1 across all types - drifting_types: tuple[str, ...] # Types with status != "stable" - - -def wasserstein_1(dist_a: list[float], dist_b: list[float]) -> float: - """Compute Wasserstein-1 (Earth Mover's) distance between two distributions. - - Both distributions are L1-normalized, then the CDF difference is summed. - Inspired by HyperspaceDB's W1 metric for distribution comparison. - - Args: - dist_a: First distribution (raw values, will be normalized). - dist_b: Second distribution (raw values, will be normalized). - - Returns: - W1 distance in [0, 1] for normalized distributions. - """ - if not dist_a or not dist_b: - return 0.0 - - # Align lengths by padding shorter with zeros - max_len = max(len(dist_a), len(dist_b)) - a = list(dist_a) + [0.0] * (max_len - len(dist_a)) - b = list(dist_b) + [0.0] * (max_len - len(dist_b)) - - # L1-normalize - sum_a = sum(a) - sum_b = sum(b) - if sum_a <= 0.0 or sum_b <= 0.0: - return 0.0 - - a = [x / sum_a for x in a] - b = [x / sum_b for x in b] - - # CDF difference - cdf_a = 0.0 - cdf_b = 0.0 - w1 = 0.0 - for i in range(max_len): - cdf_a += a[i] - cdf_b += b[i] - w1 += abs(cdf_a - cdf_b) - - # Normalize by length to keep in [0, 1] - return w1 / max_len if max_len > 0 else 0.0 - - -_W1_DRIFT_THRESHOLD = 0.3 # W1 >= 0.3 → drifting -_W1_MAJOR_THRESHOLD = 0.6 # W1 >= 0.6 → major shift - - -def detect_activation_drift( - period_a_activations: dict[str, list[float]], - period_b_activations: dict[str, list[float]], -) -> ActivationDriftReport: - """Detect drift in neuron activation distributions between two time periods. - - Compares activation level distributions grouped by neuron type - using Wasserstein-1 distance. More sensitive than Jaccard for - detecting gradual shifts in topic importance. - - Args: - period_a_activations: {neuron_type: [activation_levels]} for period A (earlier). - period_b_activations: {neuron_type: [activation_levels]} for period B (recent). - - Returns: - ActivationDriftReport with per-type W1 distances and overall drift. - """ - all_types = sorted(set(period_a_activations.keys()) | set(period_b_activations.keys())) - - results: list[ActivationDriftResult] = [] - for ntype in all_types: - a_vals = period_a_activations.get(ntype, []) - b_vals = period_b_activations.get(ntype, []) - - w1 = wasserstein_1(a_vals, b_vals) - - if w1 >= _W1_MAJOR_THRESHOLD: - status = "major_shift" - elif w1 >= _W1_DRIFT_THRESHOLD: - status = "drifting" - else: - status = "stable" - - results.append( - ActivationDriftResult( - neuron_type=ntype, - w1_distance=round(w1, 4), - status=status, - period_a_count=len(a_vals), - period_b_count=len(b_vals), - ) - ) - - overall = sum(r.w1_distance for r in results) / len(results) if results else 0.0 - drifting = tuple(r.neuron_type for r in results if r.status != "stable") - - return ActivationDriftReport( - results=tuple(results), - overall_drift=round(overall, 4), - drifting_types=drifting, - ) - - -# ── Cross-Session Drift ─────────────────────────────────────────────── - - -async def detect_temporal_drift( - storage: NeuralStorage, -) -> list[dict[str, object]]: - """Detect terminology shifts across session summaries. - - Compares early session topics with recent session topics to find - terms that have been replaced (user used to say X, now says Y). - - Returns list of {old_term, new_term, confidence, evidence}. - """ - try: - summaries = await storage.get_session_summaries(limit=20) # type: ignore[attr-defined] - except Exception: - return [] - - if len(summaries) < 4: - return [] # Need enough history - - # Split into early vs recent halves - mid = len(summaries) // 2 - early = summaries[mid:] # Older (summaries are DESC order) - recent = summaries[:mid] # Newer - - # Count topic frequency in each half - early_topics: dict[str, int] = {} - recent_topics: dict[str, int] = {} - - for s in early: - for topic in s.get("topics") or []: - early_topics[topic] = early_topics.get(topic, 0) + 1 - - for s in recent: - for topic in s.get("topics") or []: - recent_topics[topic] = recent_topics.get(topic, 0) + 1 - - # Find terms that disappeared from early but have a co-occurring replacement - drifts: list[dict[str, object]] = [] - for old_term, old_count in early_topics.items(): - if old_count < 2: - continue - if old_term in recent_topics: - continue # Still in use, no drift - - # Find candidate replacement: term in recent but not early, - # that co-occurs with old_term in co-occurrence matrix - for new_term, new_count in recent_topics.items(): - if new_count < 2: - continue - if new_term in early_topics: - continue # Was already in early, not a replacement - - confidence = min(old_count, new_count) / max(old_count, new_count) - if confidence >= 0.3: - drifts.append( - { - "old_term": old_term, - "new_term": new_term, - "confidence": round(confidence, 2), - "evidence": ( - f"'{old_term}' appeared {old_count}x in early sessions " - f"but absent recently. '{new_term}' appeared {new_count}x " - f"recently but was absent before." - ), - } - ) - - # Sort by confidence and cap - drifts.sort(key=lambda d: float(d["confidence"]), reverse=True) # type: ignore[arg-type] - return drifts[:10] - - -# ── Orchestrator ────────────────────────────────────────────────────── - - -async def run_drift_detection( - storage: NeuralStorage, -) -> dict[str, object]: - """Run full drift detection: co-occurrence clusters + temporal drift. - - Returns a summary dict with clusters and temporal drift findings. - """ - # 1. Get co-occurrence data - try: - cooccurrences = await storage.get_tag_cooccurrence( # type: ignore[attr-defined] - min_count=MIN_COOCCURRENCE_COUNT, - ) - except Exception: - cooccurrences = [] - - # 2. Get fiber counts per tag - try: - tag_fiber_counts = await storage.get_tag_fiber_counts() # type: ignore[attr-defined] - except Exception: - tag_fiber_counts = {} - - # 3. Detect clusters - reports = detect_clusters(cooccurrences, tag_fiber_counts) - - # 4. Persist detected clusters - for report in reports: - try: - await storage.save_drift_cluster( # type: ignore[attr-defined] - cluster_id=report.cluster_id, - canonical=report.cluster.canonical, - members=sorted(report.cluster.members), - confidence=report.cluster.confidence, - status="detected", - ) - except Exception: - pass - - # 5. Detect temporal drift - temporal_drifts = await detect_temporal_drift(storage) - - # 6. Activation drift (W1) — optional, requires activation data - activation_drift_data: dict[str, object] | None = None - try: - period_a = await storage.get_activation_by_type(period="early") # type: ignore[attr-defined] - period_b = await storage.get_activation_by_type(period="recent") # type: ignore[attr-defined] - if period_a and period_b: - w1_report = detect_activation_drift(period_a, period_b) - activation_drift_data = { - "overall_drift": w1_report.overall_drift, - "drifting_types": list(w1_report.drifting_types), - "per_type": [ - { - "type": r.neuron_type, - "w1_distance": r.w1_distance, - "status": r.status, - "period_a_count": r.period_a_count, - "period_b_count": r.period_b_count, - } - for r in w1_report.results - ], - } - except Exception: - pass # Storage doesn't support activation history yet — graceful fallback - - # 7. Build summary - merge_count = sum(1 for r in reports if r.suggestion == "merge") - alias_count = sum(1 for r in reports if r.suggestion == "alias") - review_count = sum(1 for r in reports if r.suggestion == "review") - - result: dict[str, object] = { - "clusters": [ - { - "cluster_id": r.cluster_id, - "canonical": r.cluster.canonical, - "members": sorted(r.cluster.members), - "confidence": r.cluster.confidence, - "suggestion": r.suggestion, - "evidence": r.cluster.evidence, - } - for r in reports - ], - "temporal_drifts": temporal_drifts, - "summary": { - "total_clusters": len(reports), - "merge_suggestions": merge_count, - "alias_suggestions": alias_count, - "review_suggestions": review_count, - "temporal_drifts": len(temporal_drifts), - }, - } - - if activation_drift_data is not None: - result["activation_drift"] = activation_drift_data - - return result diff --git a/src/surreal_memory/engine/pipeline_steps.py b/src/surreal_memory/engine/pipeline_steps.py index 682b13a6..09abde5c 100755 --- a/src/surreal_memory/engine/pipeline_steps.py +++ b/src/surreal_memory/engine/pipeline_steps.py @@ -1718,13 +1718,6 @@ async def execute( await storage.add_fiber(fiber) - # Record tag co-occurrence for drift detection - if ctx.merged_tags and len(ctx.merged_tags) >= 2: - try: - await storage.record_tag_cooccurrence(ctx.merged_tags) # type: ignore[attr-defined] - except Exception: - logger.debug("Tag co-occurrence recording failed (non-critical)", exc_info=True) - # Maturation tracking from surreal_memory.engine.memory_stages import MaturationRecord, MemoryStage diff --git a/src/surreal_memory/engine/retrieval.py b/src/surreal_memory/engine/retrieval.py index 49e3c276..2f2f944b 100755 --- a/src/surreal_memory/engine/retrieval.py +++ b/src/surreal_memory/engine/retrieval.py @@ -826,25 +826,6 @@ def _mark_rerank_degraded(reason: str) -> None: if top_topics: result.metadata["session_topics"] = top_topics result.metadata["session_query_count"] = session.query_count - - # Periodic session summary persist - if session.needs_persist(): - try: - summary = session.to_summary_dict() - await self._storage.save_session_summary( # type: ignore[attr-defined] - session_id=session.session_id, - topics=summary["topics"], - topic_weights=summary["topic_weights"], - top_entities=summary["top_entities"], - query_count=summary["query_count"], - avg_confidence=summary["avg_confidence"], - avg_depth=summary["avg_depth"], - started_at=utcnow().isoformat(), - ended_at=utcnow().isoformat(), - ) - session.mark_persisted() - except Exception: - logger.debug("Session summary persist failed (non-critical)", exc_info=True) except Exception: logger.debug("Session recording failed (non-critical)", exc_info=True) diff --git a/src/surreal_memory/extraction/keywords.py b/src/surreal_memory/extraction/keywords.py index e84b2153..556d96bb 100755 --- a/src/surreal_memory/extraction/keywords.py +++ b/src/surreal_memory/extraction/keywords.py @@ -591,6 +591,14 @@ class WeightedKeyword: weight: float +#: Above this fraction of all-caps tokens, a text is shouted text or a +#: heading, not prose with a sparse acronym in it -- acronym rescue (below) +#: is skipped so a fully-uppercase note doesn't let every stop word back in. +#: Measured: genuine acronym usage sits at ~0.17-0.20 of tokens; fully-caps +#: text sits at ~1.0. Wide margin on both sides. +_MAX_ACRONYM_CAPS_RATIO = 0.6 + + def extract_weighted_keywords( text: str, min_length: int = 2, @@ -603,6 +611,14 @@ def extract_weighted_keywords( - Position: earlier words score higher (1.0 → 0.5 linear decay) - Bi-grams: adjacent non-stop-word pairs get averaged weight * 1.2 boost + An ALL-CAPS token (e.g. ``MA``) survives even when its lowercased form is + a stop word (Polish ``ma``, "has"): punctuation already prevents the + collisions that motivated removing such words from the stop list + (``N/A`` and ``S.A.`` tokenize as two short fragments, not ``na``/``sa``), + so only the bare uppercase form actually collides, and only that form + is rescued. Every other token is unaffected — still lowercased, still + filtered exactly as before. + Args: text: The text to extract from min_length: Minimum word length for unigrams @@ -613,13 +629,32 @@ def extract_weighted_keywords( """ stop_words = _get_stop_words(language, text) - words: list[str] = [] + # Tokenize the ORIGINAL text (not lowercased): _CLAUSE_BOUNDARY and the + # word regex below only match punctuation/letter classes, so this yields + # the identical clause/word boundaries as before -- only each token's own + # casing is preserved for the acronym check that follows. + raw_tokens: list[str] = [] clause_of: list[int] = [] - for clause_idx, clause in enumerate(_CLAUSE_BOUNDARY.split(text.lower())): + for clause_idx, clause in enumerate(_CLAUSE_BOUNDARY.split(text)): for w in re.findall(r"\b[a-zA-ZÀ-ỹ]+(?:_[a-zA-ZÀ-ỹ]+)*\b", clause): - words.append(w) + raw_tokens.append(w) clause_of.append(clause_idx) + caps_ratio = sum(1 for w in raw_tokens if w.isupper()) / len(raw_tokens) if raw_tokens else 0.0 + rescue_acronyms = caps_ratio < _MAX_ACRONYM_CAPS_RATIO + + words: list[str] = [] + for w in raw_tokens: + lower = w.lower() + is_stop_word = lower.replace("_", " ") in stop_words or lower in stop_words + # Tight rule: the WHOLE token must be uppercase in the source, not + # just capitalized -- str.isupper() already rejects "Ma"/"Na" + # (sentence-initial capitalization), which must stay filtered. + if is_stop_word and rescue_acronyms and w.isupper() and len(w.replace("_", "")) >= 2: + words.append(w) + else: + words.append(lower) + # Filter to content words with original position and clause id filtered: list[tuple[str, int, int]] = [ (w, i, clause_of[i]) diff --git a/src/surreal_memory/mcp/drift_handler.py b/src/surreal_memory/mcp/drift_handler.py deleted file mode 100755 index 89029415..00000000 --- a/src/surreal_memory/mcp/drift_handler.py +++ /dev/null @@ -1,134 +0,0 @@ -"""MCP handler for semantic drift detection tool (smem_drift).""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING, Any - -from surreal_memory.mcp.tool_handler_utils import _require_brain_id - -if TYPE_CHECKING: - from surreal_memory.storage.base import NeuralStorage - from surreal_memory.unified_config import UnifiedConfig - -logger = logging.getLogger(__name__) - - -class DriftHandler: - """Mixin providing the smem_drift tool handler for MCPServer.""" - - if TYPE_CHECKING: - config: UnifiedConfig - - async def get_storage(self) -> NeuralStorage: - raise NotImplementedError - - async def _drift(self, args: dict[str, Any]) -> dict[str, Any]: - """Handle smem_drift tool calls. - - Semantic drift detection — find tag clusters that should be - merged or aliased using Jaccard similarity on co-occurrence data. - """ - action = args.get("action", "detect") - valid_actions = ("detect", "list", "merge", "alias", "dismiss") - if action not in valid_actions: - return {"error": f"Invalid action: {action}. Must be one of {valid_actions}."} - - storage = await self.get_storage() - try: - _require_brain_id(storage) - except ValueError: - logger.error("No brain configured for drift action '%s'", action) - return {"error": "No brain configured"} - - if action == "detect": - return await self._drift_detect(storage) - elif action == "list": - return await self._drift_list(storage, args) - elif action in ("merge", "alias", "dismiss"): - return await self._drift_resolve(storage, action, args) - - return {"error": f"Unhandled action: {action}"} - - async def _drift_detect(self, storage: NeuralStorage) -> dict[str, Any]: - """Run drift detection analysis.""" - try: - from surreal_memory.engine.drift_detection import run_drift_detection - - result = await run_drift_detection(storage) - - clusters = result.get("clusters", []) - summary = result.get("summary", {}) - temporal = result.get("temporal_drifts", []) - - if not clusters and not temporal: - return { - "status": "clean", - "message": "No semantic drift detected. Tag usage is consistent.", - } - - return { - "status": "drift_detected", - "clusters": clusters, - "temporal_drifts": temporal, - "summary": summary, - "hint": ( - "Use smem_drift(action='merge', cluster_id='...') to merge synonyms, " - "or 'alias' to mark as related, or 'dismiss' to ignore." - ), - } - except Exception as e: - logger.error("Drift detection failed: %s", e, exc_info=True) - return {"error": "Drift detection failed"} - - async def _drift_list(self, storage: NeuralStorage, args: dict[str, Any]) -> dict[str, Any]: - """List existing drift clusters.""" - status_filter = args.get("status") - try: - clusters = await storage.get_drift_clusters( # type: ignore[attr-defined] - status=status_filter, - limit=50, - ) - if not clusters: - return { - "clusters": [], - "message": "No drift clusters found." - + (f" (filter: status={status_filter})" if status_filter else ""), - } - return {"clusters": clusters, "count": len(clusters)} - except Exception as e: - logger.error("Drift list failed: %s", e, exc_info=True) - return {"error": "Failed to list drift clusters"} - - async def _drift_resolve( - self, - storage: NeuralStorage, - action: str, - args: dict[str, Any], - ) -> dict[str, Any]: - """Resolve a drift cluster (merge/alias/dismiss).""" - cluster_id = args.get("cluster_id") - if not cluster_id: - return {"error": f"cluster_id is required for '{action}' action"} - - # Map action to status - status_map = {"merge": "merged", "alias": "aliased", "dismiss": "dismissed"} - new_status = status_map[action] - - try: - updated = await storage.resolve_drift_cluster( # type: ignore[attr-defined] - cluster_id=cluster_id, - status=new_status, - ) - if not updated: - return {"error": f"Cluster '{cluster_id}' not found"} - - return { - "status": "resolved", - "cluster_id": cluster_id, - "resolution": new_status, - "message": f"Cluster {cluster_id} marked as {new_status}.", - } - except Exception as e: - logger.error("Drift resolve failed: %s", e, exc_info=True) - return {"error": f"Failed to {action} cluster"} diff --git a/src/surreal_memory/mcp/lifecycle_handler.py b/src/surreal_memory/mcp/lifecycle_handler.py index 1007234d..b2704d9d 100755 --- a/src/surreal_memory/mcp/lifecycle_handler.py +++ b/src/surreal_memory/mcp/lifecycle_handler.py @@ -302,12 +302,10 @@ async def _tool_stats(self, args: dict[str, Any]) -> dict[str, Any]: return {"error": "days and limit must be integers"} if action == "summary": - result: dict[str, Any] = await storage.get_tool_stats(brain.id) # type: ignore[attr-defined] + result: dict[str, Any] = await storage.get_tool_stats(brain.id, days=days) return result elif action == "daily": - daily = await storage.get_tool_stats_by_period( # type: ignore[attr-defined] - brain.id, days=days, limit=limit - ) + daily = await storage.get_tool_stats_by_period(brain.id, days=days, limit=limit) return {"daily": daily, "days": days} else: return {"error": f"Unknown action: {action}"} diff --git a/src/surreal_memory/mcp/prompt.py b/src/surreal_memory/mcp/prompt.py index e7467ecc..40cc62d1 100755 --- a/src/surreal_memory/mcp/prompt.py +++ b/src/surreal_memory/mcp/prompt.py @@ -334,10 +334,6 @@ # Cognitive Dashboard smem_cognitive(action="summary") # Hot index: ranked active hypotheses + predictions - -# Tag Drift Detection -smem_drift(action="detect") # Find tag synonyms/aliases -smem_drift(action="merge", cluster_id="...") # Merge synonym tags ``` ## Telegram Backup (smem_telegram_backup) diff --git a/src/surreal_memory/mcp/server.py b/src/surreal_memory/mcp/server.py index 711949ea..b1967d36 100755 --- a/src/surreal_memory/mcp/server.py +++ b/src/surreal_memory/mcp/server.py @@ -36,7 +36,6 @@ from surreal_memory.mcp.conflict_handler import ConflictHandler from surreal_memory.mcp.connection_handler import ConnectionHandler from surreal_memory.mcp.db_train_handler import DBTrainHandler -from surreal_memory.mcp.drift_handler import DriftHandler from surreal_memory.mcp.eternal_handler import EternalHandler from surreal_memory.mcp.expiry_cleanup_handler import ExpiryCleanupHandler from surreal_memory.mcp.index_handler import IndexHandler @@ -110,7 +109,6 @@ class MCPServer( SurfaceHandler, SyncToolHandler, TelegramHandler, - DriftHandler, ReasoningHandler, ): """MCP server that exposes Surreal-Memory tools. @@ -140,7 +138,6 @@ class MCPServer( SyncToolHandler — _sync, _sync_status, _sync_config (multi-device sync) TelegramHandler — _telegram_backup (send brain to Telegram) SurfaceHandler — _surface (knowledge surface generate/show) - DriftHandler — _drift (semantic drift detection + resolution) """ def __init__(self) -> None: @@ -282,7 +279,6 @@ async def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any "smem_edit": self._edit, "smem_forget": self._forget, "smem_consolidate": self._consolidate, - "smem_drift": self._drift, "smem_surface": self._surface, "smem_tool_stats": self._tool_stats, "smem_lifecycle": self._lifecycle, diff --git a/src/surreal_memory/mcp/tool_schemas.py b/src/surreal_memory/mcp/tool_schemas.py index 46aa13d3..504f750d 100755 --- a/src/surreal_memory/mcp/tool_schemas.py +++ b/src/surreal_memory/mcp/tool_schemas.py @@ -1839,7 +1839,7 @@ def get_tool_schemas_for_tier(tier: str) -> list[dict[str, Any]]: "summarize (cluster topic neurons), mature (episodic→semantic), infer (co-activation synapses), " "enrich (metadata extraction), dream (synthetic bridges), learn_habits (workflow patterns), " "dedup (merge near-duplicates), semantic_link (cross-domain connections), compress (old fibers), " - "process_tool_events, detect_drift (find tag synonyms/aliases), all (run all in dependency order). " + "process_tool_events, all (run all in dependency order). " "Use dry_run=true to preview without applying changes.", "inputSchema": { "type": "object", @@ -1859,7 +1859,6 @@ def get_tool_schemas_for_tier(tier: str) -> list[dict[str, Any]]: "semantic_link", "compress", "process_tool_events", - "detect_drift", "all", ], "description": "Consolidation strategy to run (default: all)", @@ -1884,34 +1883,6 @@ def get_tool_schemas_for_tier(tier: str) -> list[dict[str, Any]]: "required": [], }, }, - { - "name": "smem_drift", - "description": "Semantic drift detection — find tag clusters that should be merged or aliased. " - "Detects when different tags refer to the same concept using Jaccard similarity. " - "Actions: detect (run analysis), list (show clusters), merge (apply canonical tag), " - "alias (mark as related), dismiss (ignore cluster).", - "inputSchema": { - "type": "object", - "properties": { - "action": { - "type": "string", - "enum": ["detect", "list", "merge", "alias", "dismiss"], - "description": "detect=run drift analysis, list=show existing clusters, " - "merge/alias/dismiss=resolve a specific cluster", - }, - "cluster_id": { - "type": "string", - "description": "Cluster ID to resolve (required for merge/alias/dismiss)", - }, - "status": { - "type": "string", - "enum": ["detected", "merged", "aliased", "dismissed"], - "description": "Filter clusters by status (for list action)", - }, - }, - "required": ["action"], - }, - }, { "name": "smem_surface", "description": "Knowledge Surface management — generate or inspect the .nm surface file. " diff --git a/src/surreal_memory/server/dependencies.py b/src/surreal_memory/server/dependencies.py index 7a8ea3f9..b7c0d4e6 100755 --- a/src/surreal_memory/server/dependencies.py +++ b/src/surreal_memory/server/dependencies.py @@ -4,6 +4,8 @@ import ipaddress import logging +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager from functools import lru_cache from typing import Annotated @@ -121,3 +123,35 @@ async def get_brain( # took effect server-side: storage.brain_id was already the UUID. storage.set_brain(brain.name) return brain + + +@asynccontextmanager +async def storage_for_scope(storage: NeuralStorage, scope: str) -> AsyncIterator[NeuralStorage]: + """Yield a storage whose implicitly-bound brain IS ``scope``, without mutating ``storage``. + + Several handlers filter on whatever brain the *shared, process-wide* + storage instance is bound to rather than taking an explicit brain_id. + Calling ``storage.set_brain(scope)`` on that shared instance to answer one + request works until something else reads or mutates the same instance + concurrently -- a request for a different brain, or a background + maintenance loop reading ``storage.brain_id`` -- and inherits whichever + brain last won the race. + + The common case (the shared instance is already bound to ``scope``) costs + nothing and reuses it. Otherwise an isolated storage is opened on the + scope and closed afterward: only SurrealDB hands out a private instance to + close; the other backends return the shared one, which must not be closed + out from under concurrent callers. + """ + if storage.brain_id == scope: + yield storage + return + + from surreal_memory.unified_config import create_isolated_storage, get_config + + scoped = await create_isolated_storage(scope) + try: + yield scoped + finally: + if get_config().storage_backend == "surrealdb": + await scoped.close() diff --git a/src/surreal_memory/server/routes/dashboard_api.py b/src/surreal_memory/server/routes/dashboard_api.py index 26815904..244c6ccc 100755 --- a/src/surreal_memory/server/routes/dashboard_api.py +++ b/src/surreal_memory/server/routes/dashboard_api.py @@ -796,7 +796,14 @@ class BrainFileInfo(BaseModel): """Info about a single brain database file.""" name: str - path: str + # None on the surrealdb backend for a brain that has no SQLite-era .db file + # on disk -- get_brain_db_path always returns *a* path (it is pure string + # construction, so it cannot fail), but that path names a file that was + # never written. Reporting it unconditionally showed a plausible-looking + # path to something that does not exist, worse when a stale file from an + # older install happened to sit at that same path for one brain but not + # its neighbours. + path: str | None = None size_bytes: int = 0 is_active: bool = False @@ -828,15 +835,16 @@ async def get_brain_files() -> BrainFilesResponse: for name in brain_names: db_path = Path(cfg.get_brain_db_path(name)) + exists = db_path.exists() size = 0 - if db_path.exists(): + if exists: size = db_path.stat().st_size total_size += size brain_files.append( BrainFileInfo( name=name, - path=str(db_path), + path=str(db_path) if exists else None, size_bytes=size, is_active=name == active_name, ) @@ -1548,8 +1556,8 @@ async def tool_stats( if not brain: return {"summary": {"total_events": 0, "success_rate": 0, "top_tools": []}, "daily": []} - summary = await storage.get_tool_stats(brain.id) # type: ignore[attr-defined] - daily = await storage.get_tool_stats_by_period(brain.id, days=days, limit=limit) # type: ignore[attr-defined] + summary = await storage.get_tool_stats(brain.id, days=days) + daily = await storage.get_tool_stats_by_period(brain.id, days=days, limit=limit) return {"summary": summary, "daily": daily} diff --git a/src/surreal_memory/server/routes/hub.py b/src/surreal_memory/server/routes/hub.py index 0656df5a..f6df3cb1 100755 --- a/src/surreal_memory/server/routes/hub.py +++ b/src/surreal_memory/server/routes/hub.py @@ -10,7 +10,7 @@ from pydantic import BaseModel, Field from surreal_memory.core.brain import Brain, BrainConfig -from surreal_memory.server.dependencies import get_storage, require_local_request +from surreal_memory.server.dependencies import get_storage, require_local_request, storage_for_scope from surreal_memory.storage.base import NeuralStorage from surreal_memory.sync.protocol import ( ConflictStrategy, @@ -266,9 +266,9 @@ async def hub_status( _validate_brain_id(brain_id) try: - storage.set_brain(brain_id) - stats = await storage.get_change_log_stats() - devices_list = await storage.list_devices() + async with storage_for_scope(storage, brain_id) as scoped: + stats = await scoped.get_change_log_stats() + devices_list = await scoped.list_devices() except Exception: logger.error("Failed to get hub status for brain %s", brain_id, exc_info=True) raise HTTPException(status_code=500, detail="Failed to retrieve status") @@ -293,8 +293,8 @@ async def list_devices( _validate_brain_id(brain_id) try: - storage.set_brain(brain_id) - devices_list = await storage.list_devices() + async with storage_for_scope(storage, brain_id) as scoped: + devices_list = await scoped.list_devices() except Exception: logger.error("Failed to list devices for brain %s", brain_id, exc_info=True) raise HTTPException(status_code=500, detail="Failed to retrieve devices") diff --git a/src/surreal_memory/server/routes/reasoning_training.py b/src/surreal_memory/server/routes/reasoning_training.py index b4f1e6ab..9d130cc4 100644 --- a/src/surreal_memory/server/routes/reasoning_training.py +++ b/src/surreal_memory/server/routes/reasoning_training.py @@ -19,8 +19,6 @@ import asyncio import logging import re -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager from dataclasses import replace as dc_replace from typing import Annotated, Any @@ -37,7 +35,12 @@ PHASE_SCANNING, MiningProgress, ) -from surreal_memory.server.dependencies import get_brain, get_storage, require_local_request +from surreal_memory.server.dependencies import ( + get_brain, + get_storage, + require_local_request, + storage_for_scope, +) from surreal_memory.server.models import ErrorResponse from surreal_memory.storage.base import NeuralStorage from surreal_memory.unified_config import MAX_PATTERN_TARGET @@ -286,37 +289,6 @@ async def _fetch_pattern_fibers(storage: NeuralStorage) -> list[Any]: return await storage.find_fibers(metadata_key="_reasoning_pattern", limit=_PATTERN_FETCH_LIMIT) -@asynccontextmanager -async def _storage_for_scope(storage: NeuralStorage, scope: str) -> AsyncIterator[NeuralStorage]: - """Yield a storage whose implicitly-bound brain IS the request's scope. - - Trace reads take an explicit ``brain_id``, but the fiber API does not: - ``find_fibers`` / ``get_fiber`` / ``delete_fiber`` filter on whatever brain - the storage instance is bound to, and the app's ``get_storage`` hands out - the process-wide instance bound at startup without rebinding it. A request - carrying an ``X-Brain-ID`` other than that one would therefore read traces - from one brain and patterns from another in a single response. - - The common case (no header, or a header naming the bound brain) costs - nothing and reuses the shared instance. Otherwise an isolated storage is - opened on the scope and closed on the same terms as the mining job: only - SurrealDB hands out a private instance to close; the other backends return - the shared one, which must not be closed out from under concurrent requests. - """ - if storage.brain_id == scope: - yield storage - return - - from surreal_memory.unified_config import create_isolated_storage, get_config - - scoped = await create_isolated_storage(scope) - try: - yield scoped - finally: - if get_config().storage_backend == "surrealdb": - await scoped.close() - - # ── GET /status ─────────────────────────────────────────────────────────────── @@ -334,7 +306,7 @@ async def get_status( stats = await storage.get_reasoning_stats(brain_id) by_model_traces: dict[str, Any] = stats.get("by_model", {}) - async with _storage_for_scope(storage, brain_id) as scoped: + async with storage_for_scope(storage, brain_id) as scoped: fibers = await _fetch_pattern_fibers(scoped) # Per-model pattern counts and per-category coverage from one fiber fetch. @@ -701,7 +673,7 @@ async def list_patterns( offset: int = Query(0, ge=0), ) -> PatternsListResponse: """List learned reasoning patterns (filter by source model / category).""" - async with _storage_for_scope(storage, _brain_scope(brain)) as scoped: + async with storage_for_scope(storage, _brain_scope(brain)) as scoped: fibers = await _fetch_pattern_fibers(scoped) summaries = [_to_summary(f) for f in fibers] if model: @@ -726,7 +698,7 @@ async def get_pattern( brain: Annotated[Brain, Depends(get_brain)], ) -> PatternDetail: """Return one learned pattern with its full strategy/description.""" - async with _storage_for_scope(storage, _brain_scope(brain)) as scoped: + async with storage_for_scope(storage, _brain_scope(brain)) as scoped: fiber = await scoped.get_fiber(pattern_id) if fiber is None or not _pattern_meta(fiber).get("_reasoning_pattern"): raise HTTPException(status_code=404, detail="Pattern not found") @@ -756,7 +728,7 @@ async def delete_pattern( The pattern's private title-neuron is currently left as a harmless graph orphan (follow-up ticket); the shared reasoning_category neuron is kept by design. """ - async with _storage_for_scope(storage, _brain_scope(brain)) as scoped: + async with storage_for_scope(storage, _brain_scope(brain)) as scoped: fiber = await scoped.get_fiber(pattern_id) if fiber is None or not _pattern_meta(fiber).get("_reasoning_pattern"): raise HTTPException(status_code=404, detail="Pattern not found") @@ -785,7 +757,7 @@ async def delete_patterns_by_model( # Fetch AND delete on the same scoped storage: delete_fiber is brain-filtered # too, so deleting through a differently-bound storage would silently miss # every fiber it just listed. - async with _storage_for_scope(storage, _brain_scope(brain)) as scoped: + async with storage_for_scope(storage, _brain_scope(brain)) as scoped: fibers = await _fetch_pattern_fibers(scoped) victims = [f for f in fibers if _pattern_meta(f).get("_source_model") == model] for f in victims: diff --git a/src/surreal_memory/storage/base.py b/src/surreal_memory/storage/base.py index 3c9ddc1f..b4c3be9b 100755 --- a/src/surreal_memory/storage/base.py +++ b/src/surreal_memory/storage/base.py @@ -1297,6 +1297,27 @@ async def get_tool_events_for_mining( """ return [] + async def get_tool_stats(self, brain_id: str, days: int = 30) -> dict[str, Any]: + """Tool usage statistics: total_events, success_rate, top_tools. + + Default: no tool-event storage → an empty summary. Backends that + buffer tool events (SurrealDB, in-memory) override this. + """ + return {"total_events": 0, "success_rate": 0, "top_tools": []} + + async def get_tool_stats_by_period( + self, + brain_id: str, + days: int = 30, + limit: int = 20, + ) -> list[dict[str, Any]]: + """Tool usage stats aggregated by day. + + Default: no tool-event storage → an empty series. Backends that + buffer tool events (SurrealDB, in-memory) override this. + """ + return [] + # ---- Reasoning traces (staging buffer; graceful no-op defaults) ---- async def insert_reasoning_traces( diff --git a/src/surreal_memory/storage/memory_store.py b/src/surreal_memory/storage/memory_store.py index 87e530fd..4a8eb69c 100755 --- a/src/surreal_memory/storage/memory_store.py +++ b/src/surreal_memory/storage/memory_store.py @@ -890,6 +890,86 @@ async def mark_events_processed(self, brain_id: str, event_ids: list[int]) -> No if str(ev.get("id", "")) in wanted: ev["processed"] = True + def _tool_events_since(self, brain_id: str, days: int) -> list[dict[str, Any]]: + """Buffered tool events (those carrying tool_name) within the last N days.""" + safe_days = min(max(int(days), 1), 365) + cutoff = utcnow() - timedelta(days=safe_days) + out = [] + for ev in self._action_events.get(brain_id, []): + if "tool_name" not in ev: + continue + created = ev.get("created_at") + if isinstance(created, datetime) and created < cutoff: + continue + out.append(ev) + return out + + async def get_tool_stats(self, brain_id: str, days: int = 30) -> dict[str, Any]: + """Tool usage statistics: total_events, success_rate, top_tools.""" + events = self._tool_events_since(brain_id, days) + total = len(events) + successes = sum(1 for ev in events if ev.get("success", True)) + + grouped: dict[tuple[str, str], list[dict[str, Any]]] = {} + for ev in events: + key = (str(ev.get("tool_name", "")), str(ev.get("server_name", ""))) + grouped.setdefault(key, []).append(ev) + + top_tools: list[dict[str, Any]] = [] + for (name, server), group in grouped.items(): + cnt = len(group) + ok = sum(1 for ev in group if ev.get("success", True)) + durations = [float(ev.get("duration_ms", 0) or 0) for ev in group] + top_tools.append( + { + "tool_name": name, + "server_name": server, + "count": cnt, + "success_rate": round(ok / cnt, 2) if cnt > 0 else 0.0, + "avg_duration_ms": round(sum(durations) / cnt) if cnt > 0 else 0, + } + ) + top_tools.sort(key=lambda t: int(t["count"]), reverse=True) + + return { + "total_events": total, + "success_rate": round(successes / total, 2) if total > 0 else 0, + "top_tools": top_tools[:20], + } + + async def get_tool_stats_by_period( + self, + brain_id: str, + days: int = 30, + limit: int = 20, + ) -> list[dict[str, Any]]: + """Tool usage stats aggregated by day.""" + events = self._tool_events_since(brain_id, days) + + grouped: dict[tuple[str, str], list[dict[str, Any]]] = {} + for ev in events: + created = ev.get("created_at") + day = created.strftime("%Y-%m-%d") if isinstance(created, datetime) else str(created) + key = (day, str(ev.get("tool_name", ""))) + grouped.setdefault(key, []).append(ev) + + result = [] + for (day, name), group in grouped.items(): + cnt = len(group) + ok = sum(1 for ev in group if ev.get("success", True)) + durations = [float(ev.get("duration_ms", 0) or 0) for ev in group] + result.append( + { + "date": day, + "tool_name": name, + "count": cnt, + "success_rate": round(ok / cnt, 2) if cnt > 0 else 0.0, + "avg_duration_ms": round(sum(durations) / cnt) if cnt > 0 else 0, + } + ) + result.sort(key=lambda r: (r["date"], r["count"]), reverse=True) + return result[: min(int(limit), 50)] + # ========== Reasoning Traces (in-memory staging buffer) ========== async def insert_reasoning_traces(self, brain_id: str, traces: list[dict[str, Any]]) -> int: diff --git a/src/surreal_memory/storage/surrealdb/store.py b/src/surreal_memory/storage/surrealdb/store.py index 460a66eb..8f2f7926 100755 --- a/src/surreal_memory/storage/surrealdb/store.py +++ b/src/surreal_memory/storage/surrealdb/store.py @@ -686,6 +686,22 @@ async def _query(self, sql: str, **params: Any) -> list[dict[str, Any]]: return result[0] if isinstance(result[0], list) else result return [] + async def _query_values(self, sql: str, **params: Any) -> list[Any]: + """Execute a ``SELECT VALUE ...`` query and return the flat per-row value list. + + ``_query`` is typed ``list[dict[str, Any]]``, which understates what it + actually returns for ``SELECT VALUE``: each element is the selected + field's raw value (a record id, a scalar, or -- if the field is + array-typed -- itself a list), never a row dict. mypy stays quiet about + every ``SELECT VALUE`` call site that (mis)treats the result as rows + precisely because that mismatched annotation makes it look like one; + that silence is the mechanism behind #143's bug (a `SELECT VALUE + ` result iterated character-by-character). Give + ``SELECT VALUE`` call sites an honestly-typed entry point instead of + re-litigating the row-vs-value distinction at each one. + """ + return await self._query(sql, **params) + async def _reconnect(self) -> None: """Re-establish the SurrealDB connection after a token expiry / 401. @@ -2822,7 +2838,7 @@ async def get_connected_neuron_ids(self, brain_id: str | None = None) -> set[str bid = brain_id or self._get_brain_id() async def _endpoints(field: str) -> list[Any]: - return await self._query( + return await self._query_values( f"SELECT VALUE {field} FROM synapse WHERE brain_id = $bid GROUP BY {field}", bid=bid, ) diff --git a/src/surreal_memory/storage/surrealdb/tool_events.py b/src/surreal_memory/storage/surrealdb/tool_events.py index 933af62a..20ef4652 100644 --- a/src/surreal_memory/storage/surrealdb/tool_events.py +++ b/src/surreal_memory/storage/surrealdb/tool_events.py @@ -202,23 +202,37 @@ async def cap_tool_events(self, brain_id: str) -> int: ) return len(ids) - async def get_tool_stats(self, brain_id: str) -> dict[str, Any]: - """Tool usage statistics: total_events, success_rate, top_tools.""" + async def get_tool_stats(self, brain_id: str, days: int = 30) -> dict[str, Any]: + """Tool usage statistics: total_events, success_rate, top_tools. + + ``days`` filters the whole summary, not just the daily series — before + this, the caller-facing filter matched the per-day chart but the + summary above it was always all-time, so three different date ranges + rendered a byte-identical summary. + """ + safe_days = min(max(int(days), 1), 365) + cutoff = utcnow() - timedelta(days=safe_days) total_rows = await self._query( - "SELECT count() AS c FROM tool_events WHERE brain_id = $bid GROUP ALL", + "SELECT count() AS c FROM tool_events" + " WHERE brain_id = $bid AND created_at >= $cutoff GROUP ALL", bid=brain_id, + cutoff=cutoff, ) total = int(total_rows[0]["c"]) if total_rows else 0 ok_rows = await self._query( - "SELECT count() AS c FROM tool_events WHERE brain_id = $bid AND success = true GROUP ALL", + "SELECT count() AS c FROM tool_events" + " WHERE brain_id = $bid AND created_at >= $cutoff AND success = true GROUP ALL", bid=brain_id, + cutoff=cutoff, ) successes = int(ok_rows[0]["c"]) if ok_rows else 0 grouped = await self._query( "SELECT tool_name, server_name, count() AS cnt," " math::mean(duration_ms) AS avg_ms FROM tool_events" - " WHERE brain_id = $bid GROUP BY tool_name, server_name", + " WHERE brain_id = $bid AND created_at >= $cutoff" + " GROUP BY tool_name, server_name", bid=brain_id, + cutoff=cutoff, ) # Per-tool success counts. `math::sum(success)` does NOT coerce a bool to # 1/0 on SurrealDB (it returns 0), so count the success=true rows per @@ -226,8 +240,10 @@ async def get_tool_stats(self, brain_id: str) -> dict[str, Any]: # web UI renders "NaN%" / "NaNs" for every tool row. ok_grouped = await self._query( "SELECT tool_name, server_name, count() AS ok FROM tool_events" - " WHERE brain_id = $bid AND success = true GROUP BY tool_name, server_name", + " WHERE brain_id = $bid AND created_at >= $cutoff AND success = true" + " GROUP BY tool_name, server_name", bid=brain_id, + cutoff=cutoff, ) ok_by_key = { (r.get("tool_name", ""), r.get("server_name", "")): int(r.get("ok", 0) or 0) diff --git a/src/surreal_memory/utils/sandbox.py b/src/surreal_memory/utils/sandbox.py index 7f2344fe..1c6992e4 100644 --- a/src/surreal_memory/utils/sandbox.py +++ b/src/surreal_memory/utils/sandbox.py @@ -1,7 +1,7 @@ """Environment/sandbox guards for CLI execution. Reconstructed module: ``cli/_helpers.run_async`` imports -``ensure_aiosqlite_or_exit_cli`` from here, but the module was absent from the +``ensure_sqlite_or_exit_cli`` from here, but the module was absent from the tree — a regression from the SurrealDB-only refactor (commit 1f6fe80) that broke every CLI command routed through ``run_async`` with a ``ModuleNotFoundError``. This restores a tolerant guard so the CLI runs again. @@ -12,7 +12,7 @@ import sys -def ensure_aiosqlite_or_exit_cli() -> None: +def ensure_sqlite_or_exit_cli() -> None: """Verify the local persistence stack can run, else exit the CLI cleanly. SQLite (stdlib ``sqlite3``) is the minimum requirement for any local-mode diff --git a/tests/unit/test_check_dead_modules.py b/tests/unit/test_check_dead_modules.py index 21a22508..a57c9047 100644 --- a/tests/unit/test_check_dead_modules.py +++ b/tests/unit/test_check_dead_modules.py @@ -88,6 +88,52 @@ def test_entry_points_come_from_pyproject(self, guard) -> None: # type: ignore[ assert "surreal_memory.hooks.pre_compact" in modules +class TestBrokenImportDetection: + """A root that names a module the tree no longer has must not vanish silently. + + This is the defect an import of the since-removed ``sqlite_store`` module + exposed: the graph walk simply never matched it, so the guard reported + ``No unreachable modules.`` while five files that could not run defined + part of its reachability set. + """ + + def test_resolves_true_for_a_real_module(self, guard) -> None: # type: ignore[no-untyped-def] + assert guard._resolves("surreal_memory.storage.base") is True + + def test_resolves_true_for_a_real_package(self, guard) -> None: # type: ignore[no-untyped-def] + assert guard._resolves("surreal_memory.storage") is True + + def test_resolves_false_for_a_module_that_was_removed(self, guard) -> None: # type: ignore[no-untyped-def] + assert guard._resolves("surreal_memory.storage.sqlite_store") is False + + def test_resolves_true_for_non_package_names(self, guard) -> None: # type: ignore[no-untyped-def] + # _resolves only judges surreal_memory.* names; anything else is out + # of scope for this check and must not be reported as broken. + assert guard._resolves("json") is True + + def test_a_root_naming_a_missing_module_is_flagged(self, guard) -> None: # type: ignore[no-untyped-def] + modules, _ = guard._scan_imports( + guard.SRC / "thing.py", + ast.parse("from surreal_memory.storage.sqlite_store import SQLiteStorage"), + ) + + assert modules == {"surreal_memory.storage.sqlite_store"} + assert not guard._resolves("surreal_memory.storage.sqlite_store") + + def test_attribute_only_names_are_never_checked(self, guard) -> None: # type: ignore[no-untyped-def] + # `from surreal_memory.storage import NeuralStorage` produces the + # non-module name `surreal_memory.storage.NeuralStorage` -- legitimate, + # and must not be judged by _resolves at all (only `modules` is). + modules, attributes = guard._scan_imports( + guard.SRC / "thing.py", + ast.parse("from surreal_memory.storage import NeuralStorage"), + ) + + assert modules == {"surreal_memory.storage"} + assert attributes == {"surreal_memory.storage.NeuralStorage"} + assert guard._resolves("surreal_memory.storage") is True + + class TestAgainstTheRealTree: def test_the_package_has_no_unreachable_modules(self, guard) -> None: # type: ignore[no-untyped-def] dead = guard.find_dead_modules() @@ -101,3 +147,27 @@ def test_router_modules_are_not_false_positives(self, guard) -> None: # type: i dead = guard.find_dead_modules() assert not [name for name in dead if ".server.routes." in name] + + def test_the_tree_has_no_broken_imports(self, guard) -> None: # type: ignore[no-untyped-def] + report = guard.analyse() + + assert report.broken == {}, f"broken imports: {report.broken}" + + def test_main_exits_nonzero_under_strict_when_broken_and_nothing_dead( + self, guard, monkeypatch, capsys + ) -> None: # type: ignore[no-untyped-def] + # The regression this guards: a broken import with an otherwise-empty + # `dead` list used to hit the early `if not dead: return` and exit 0 + # even under --strict. + monkeypatch.setattr( + guard, + "analyse", + lambda: guard.Report(dead=[], broken={"surreal_memory.storage.sqlite_store": ["x.py"]}), + ) + monkeypatch.setattr(sys, "argv", ["check_dead_modules.py", "--strict"]) + + with pytest.raises(SystemExit) as exc_info: + guard.main() + + assert exc_info.value.code == 1 + assert "sqlite_store" in capsys.readouterr().out diff --git a/tests/unit/test_dashboard_api.py b/tests/unit/test_dashboard_api.py index 0b7eb617..aca12aaa 100755 --- a/tests/unit/test_dashboard_api.py +++ b/tests/unit/test_dashboard_api.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from pathlib import Path from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -383,3 +384,31 @@ def test_brain_files_lists_surrealdb_only_brain_with_zero_size( entry = next(b for b in data["brains"] if b["name"] == "surrealdb-only-brain") assert entry["size_bytes"] == 0 assert entry["is_active"] is True + # #154 finding 5: a brain with no on-disk file must not report a + # plausible-looking path to something that isn't there. + assert entry["path"] is None + + def test_brain_files_reports_the_real_path_when_the_file_exists( + self, client: TestClient, tmp_path: Path + ) -> None: + """The positive case for the same fix: a brain that DOES have a file + on disk (SQLite-era, or any legacy leftover) still reports its path.""" + real_path = tmp_path / "legacy-brain.db" + real_path.write_bytes(b"x" * 10) + cfg = MagicMock() + cfg.current_brain = "legacy-brain" + cfg.get_brain_db_path = MagicMock(return_value=str(real_path)) + + with ( + patch("surreal_memory.unified_config.get_config", return_value=cfg), + patch( + "surreal_memory.unified_config.list_available_brains", + new=AsyncMock(return_value=["legacy-brain"]), + ), + ): + resp = client.get("/api/dashboard/brain-files") + + assert resp.status_code == 200, resp.text + entry = next(b for b in resp.json()["brains"] if b["name"] == "legacy-brain") + assert entry["path"] == str(real_path) + assert entry["size_bytes"] == 10 diff --git a/tests/unit/test_dashboard_perf_queries.py b/tests/unit/test_dashboard_perf_queries.py index 11811409..5c2e9caf 100644 --- a/tests/unit/test_dashboard_perf_queries.py +++ b/tests/unit/test_dashboard_perf_queries.py @@ -211,6 +211,32 @@ async def test_connected_ids_groups_endpoints(self): assert connected == {"a-1", "b-2"} +# --------------------------------------------------------------------------- # +# _query_values -- honest SELECT VALUE typing (#154 finding 3) +# --------------------------------------------------------------------------- # +class TestQueryValues: + async def test_flat_scalar_list(self): + """`in`/`out` are scalar record links -- one value per row.""" + st, conn = _store_with_mock_conn() + conn.query = AsyncMock(return_value=[["neuron:a", "neuron:b"]]) + values = await st._query_values("SELECT VALUE in FROM synapse") + assert values == ["neuron:a", "neuron:b"] + + async def test_empty_result_is_empty_list(self): + st, conn = _store_with_mock_conn() + conn.query = AsyncMock(return_value=[[]]) + assert await st._query_values("SELECT VALUE in FROM synapse") == [] + + async def test_a_row_whose_value_is_itself_an_array_is_not_collapsed(self): + """The #143 trap: SELECT VALUE on an array-typed field. One matching + row whose value is an array must come back as that one array, not be + confused for "these array elements are separate rows".""" + st, conn = _store_with_mock_conn() + conn.query = AsyncMock(return_value=[[["tag-a", "tag-b"]]]) + values = await st._query_values("SELECT VALUE tags FROM neuron") + assert values == [["tag-a", "tag-b"]] + + class TestGetAllSynapsesProjection: async def test_include_metadata_false_omits_blob(self): st, conn = _store_with_mock_conn() diff --git a/tests/unit/test_health_fixes.py b/tests/unit/test_health_fixes.py index e1975d8d..c4350ae3 100755 --- a/tests/unit/test_health_fixes.py +++ b/tests/unit/test_health_fixes.py @@ -485,7 +485,7 @@ class TestVersionBump: def test_version_is_current(self) -> None: import surreal_memory - assert surreal_memory.__version__ == "3.2.0" + assert surreal_memory.__version__ == "3.3.0" class TestPackageIntegrity: diff --git a/tests/unit/test_inmemory_tool_stats.py b/tests/unit/test_inmemory_tool_stats.py new file mode 100644 index 00000000..98930c9c --- /dev/null +++ b/tests/unit/test_inmemory_tool_stats.py @@ -0,0 +1,145 @@ +"""Tests for InMemoryStorage's tool-event statistics (#154 finding 2). + +`get_tool_stats` / `get_tool_stats_by_period` used to exist only on the +SurrealDB mixin, called through `# type: ignore[attr-defined]` with no +declaration on `NeuralStorage` and no InMemoryStorage implementation -- an +AttributeError on any other backend. These mirror the SurrealDB mixin's own +test shapes (test_surrealdb_tool_events.py) against the real in-memory +buffer instead of a query-routing fake. +""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest + +from surreal_memory.storage.memory_store import InMemoryStorage +from surreal_memory.utils.timeutils import utcnow + +BRAIN = "default" + + +@pytest.fixture +def storage() -> InMemoryStorage: + store = InMemoryStorage() + store.set_brain(BRAIN) + return store + + +async def _seed( + storage: InMemoryStorage, + tool_name: str, + *, + server_name: str = "", + success: bool = True, + duration_ms: float = 10.0, + age_days: float = 0.0, +) -> None: + await storage.insert_tool_events( + BRAIN, + [ + { + "tool_name": tool_name, + "server_name": server_name, + "success": success, + "duration_ms": duration_ms, + "created_at": utcnow() - timedelta(days=age_days), + } + ], + ) + + +class TestGetToolStats: + async def test_empty_brain_returns_zeros(self, storage: InMemoryStorage) -> None: + stats = await storage.get_tool_stats(BRAIN) + + assert stats == {"total_events": 0, "success_rate": 0, "top_tools": []} + + async def test_computes_rate_and_top_tools(self, storage: InMemoryStorage) -> None: + await _seed(storage, "Read", success=True, duration_ms=10.0) + await _seed(storage, "Read", success=True, duration_ms=20.0) + await _seed(storage, "Read", success=False, duration_ms=30.0) + await _seed(storage, "Bash", success=True, duration_ms=100.0) + + stats = await storage.get_tool_stats(BRAIN) + + assert stats["total_events"] == 4 + assert stats["success_rate"] == 0.75 + read = next(t for t in stats["top_tools"] if t["tool_name"] == "Read") + assert read["count"] == 3 + assert read["success_rate"] == round(2 / 3, 2) + assert read["avg_duration_ms"] == 20 # mean(10, 20, 30) + # Most-used tool sorts first. + assert stats["top_tools"][0]["tool_name"] == "Read" + + async def test_zero_successes_is_zero_not_nan(self, storage: InMemoryStorage) -> None: + await _seed(storage, "Bash", success=False) + + stats = await storage.get_tool_stats(BRAIN) + + tool = stats["top_tools"][0] + assert tool["success_rate"] == 0.0 + assert isinstance(tool["success_rate"], float) + + async def test_days_filters_the_summary_not_just_the_daily_series( + self, storage: InMemoryStorage + ) -> None: + """The exact #154 finding: days must filter the summary too.""" + await _seed(storage, "Read", age_days=1) + await _seed(storage, "Read", age_days=100) # outside a 7-day window + + recent = await storage.get_tool_stats(BRAIN, days=7) + everything = await storage.get_tool_stats(BRAIN, days=365) + + assert recent["total_events"] == 1 + assert everything["total_events"] == 2 + + async def test_default_days_is_30(self, storage: InMemoryStorage) -> None: + await _seed(storage, "Read", age_days=45) + + stats = await storage.get_tool_stats(BRAIN) + + assert stats["total_events"] == 0 + + async def test_events_without_tool_name_are_excluded(self, storage: InMemoryStorage) -> None: + """`_action_events` is shared with plain (non-tool) action events.""" + await storage.insert_tool_events(BRAIN, [{"created_at": utcnow()}]) # no tool_name + await _seed(storage, "Read") + + stats = await storage.get_tool_stats(BRAIN) + + assert stats["total_events"] == 1 + + +class TestGetToolStatsByPeriod: + async def test_empty_brain_returns_empty_list(self, storage: InMemoryStorage) -> None: + assert await storage.get_tool_stats_by_period(BRAIN) == [] + + async def test_groups_by_day_and_tool(self, storage: InMemoryStorage) -> None: + await _seed(storage, "Read", success=True) + await _seed(storage, "Read", success=False) + await _seed(storage, "Bash", success=True) + + daily = await storage.get_tool_stats_by_period(BRAIN, days=30) + + by_tool = {row["tool_name"]: row for row in daily} + assert by_tool["Read"]["count"] == 2 + assert by_tool["Read"]["success_rate"] == 0.5 + assert by_tool["Bash"]["count"] == 1 + + async def test_days_excludes_old_events(self, storage: InMemoryStorage) -> None: + await _seed(storage, "Read", age_days=1) + await _seed(storage, "Read", age_days=100) + + daily = await storage.get_tool_stats_by_period(BRAIN, days=7) + + assert sum(row["count"] for row in daily) == 1 + + async def test_limit_caps_result_rows(self, storage: InMemoryStorage) -> None: + for i in range(5): + await _seed(storage, f"Tool{i}") + + daily = await storage.get_tool_stats_by_period(BRAIN, limit=2) + + assert len(daily) == 2 diff --git a/tests/unit/test_mcp.py b/tests/unit/test_mcp.py index 80e35a0f..779f252a 100755 --- a/tests/unit/test_mcp.py +++ b/tests/unit/test_mcp.py @@ -46,7 +46,7 @@ def test_get_tools(self, server: MCPServer) -> None: with patch("surreal_memory.plugins.get_plugin_tools", return_value=[]): tools = server.get_tools() - assert len(tools) == 58 + assert len(tools) == 57 tool_names = {tool["name"] for tool in tools} assert tool_names == { "smem_remember", @@ -99,7 +99,6 @@ def test_get_tools(self, server: MCPServer) -> None: "smem_edit", "smem_forget", "smem_consolidate", - "smem_drift", "smem_surface", "smem_tool_stats", "smem_lifecycle", @@ -1065,7 +1064,7 @@ async def test_tools_list_message(self, server: MCPServer) -> None: assert response["id"] == 2 assert "result" in response assert "tools" in response["result"] - assert len(response["result"]["tools"]) == 58 + assert len(response["result"]["tools"]) == 57 @pytest.mark.asyncio async def test_tools_call_message(self, server: MCPServer) -> None: diff --git a/tests/unit/test_polish_keywords.py b/tests/unit/test_polish_keywords.py index fb36ba3f..493de24b 100644 --- a/tests/unit/test_polish_keywords.py +++ b/tests/unit/test_polish_keywords.py @@ -7,6 +7,8 @@ from __future__ import annotations +import pytest + from surreal_memory.extraction.keywords import ( STOP_WORDS_EN, STOP_WORDS_PL, @@ -137,3 +139,77 @@ def test_ci_survives_as_keyword_in_auto_mode(self) -> None: # for Vietnamese ("ai"/"em"). Guard against reintroducing it. assert "ci" not in STOP_WORDS_PL assert "ci" in _unigrams("Fixed CI lint failure, CI now passes cleanly") + + +class TestAcronymPreservation: + """ma/na/co/sa: the acronym survives, the Polish function word does not (#64). + + Straight removal from STOP_WORDS_PL was rejected — N/A and S.A. don't + actually collide (punctuation fragments them below min_length before the + stop-word check ever runs), and the Polish function word is far more + common than the bare acronym. The fix instead preserves a token that was + ALL-CAPS in the source even though its lowercased form is a stop word. + """ + + @pytest.mark.parametrize( + ("acronym", "stopword", "sentence_template"), + [ + ("MA", "ma", "Stan {} oznacza Massachusetts w bazie"), + ("NA", "na", "Wynik testu {} wskazuje brak danych"), + ("SA", "sa", "Jednostka {} odpowiada za wdrozenie"), + ("CO", "co", "Czujnik {} wykryl usterke w systemie"), + ], + ) + def test_both_directions_per_word( + self, acronym: str, stopword: str, sentence_template: str + ) -> None: + """The maintainer's required pin: MA survives, ma does not (and so on).""" + assert acronym in _unigrams(sentence_template.format(acronym)) + assert stopword not in _unigrams(sentence_template.format(acronym)) + # And the plain lowercase function word, used as itself, stays filtered. + assert stopword not in _unigrams(f"Ona {stopword} nowy komputer w biurze") + + @pytest.mark.parametrize("word", ["Ma", "Na", "Sa", "Co"]) + def test_sentence_initial_capitalization_is_not_rescued(self, word: str) -> None: + """Tight rule: only a token ALL-CAPS in the source is rescued — a + sentence-initial capital must stay filtered like any other stop word.""" + unigrams = _unigrams(f"{word} to jest bardzo wazna sprawa dzisiaj") + assert word not in unigrams + assert word.lower() not in unigrams + + def test_na_slash_a_still_does_not_collide(self) -> None: + """Unaffected by this fix: punctuation still fragments N/A below + min_length, so this never reaches the stop-word check either way.""" + unigrams = _unigrams("Pole N/A w formularzu jest puste") + assert "na" not in unigrams + assert "NA" not in unigrams + + def test_s_dot_a_dot_still_does_not_collide(self) -> None: + unigrams = _unigrams("Firma Orlen S.A. podpisala umowe dzisiaj") + assert "sa" not in unigrams + assert "SA" not in unigrams + + def test_shouted_text_does_not_leak_every_stop_word(self) -> None: + """The risk a naive "ALL-CAPS survives" rule would introduce: fully + uppercase text (a shouted note, a heading) has no acronyms to rescue, + only every word incidentally uppercase. Rescue must not fire here.""" + unigrams = _unigrams("RAPORT JEST NA BIURKU I MA BYC GOTOWY DO PONIEDZIALKU") + for stopword in ("jest", "na", "i", "ma", "byc", "do"): + assert stopword not in unigrams, f"'{stopword}' leaked in shouted text" + assert stopword.upper() not in unigrams, f"'{stopword.upper()}' leaked in shouted text" + + def test_shouted_english_text_does_not_leak_either(self) -> None: + unigrams = _unigrams( + "THIS IS THE MOST IMPORTANT THING TO DO IF IT IS NOT DONE", language="en" + ) + for stopword in ("this", "is", "the", "to", "do", "if", "not"): + assert stopword not in unigrams + assert stopword.upper() not in unigrams + assert "important" in unigrams + assert "done" in unigrams + + def test_a_sparse_acronym_amid_normal_text_is_not_treated_as_shouting(self) -> None: + """Sanity check on the caps-ratio guard's threshold: normal prose with + one acronym must not accidentally trip the shouted-text guard.""" + unigrams = _unigrams("Czujnik CO wykryl usterke this morning w systemie") + assert "CO" in unigrams diff --git a/tests/unit/test_route_hub.py b/tests/unit/test_route_hub.py new file mode 100644 index 00000000..d2415a63 --- /dev/null +++ b/tests/unit/test_route_hub.py @@ -0,0 +1,207 @@ +"""Tests for the hub router's read paths (#152 regression coverage). + +`hub_status` and `list_devices` take brain_id from the URL path, not from +X-Brain-ID, so `get_storage`'s header-based resolution never scopes them — +the handlers used to call `storage.set_brain(brain_id)` directly on whatever +storage the dependency handed them: the process-wide shared instance in the +common (SurrealDB) case. Background maintenance loops read +`storage.brain_id` off that same instance on every tick, so a read-only GET +for brain B could redirect the next scheduled consolidation/decay pass onto +B even though the operator never switched brains. + +Mirrors test_route_reasoning_training.py's pattern: a bare FastAPI app with +just this router, storage injected via dependency_overrides, and +create_isolated_storage patched per test (refused by default so a forgotten +patch fails loudly rather than reaching a real backend). +""" + +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from typing import Any +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from surreal_memory.server.dependencies import get_storage +from surreal_memory.server.routes.hub import router + +BOUND_BRAIN = "default" +OTHER_BRAIN = "other-brain" + + +def _device(device_id: str = "abc123") -> SimpleNamespace: + return SimpleNamespace( + device_id=device_id, + device_name="laptop", + registered_at=datetime(2026, 1, 1), + last_sync_sequence=5, + ) + + +@pytest.fixture(autouse=True) +def _no_real_isolated_storage(monkeypatch: pytest.MonkeyPatch) -> None: + """Fail loudly if a test opens a real storage instead of using a mock. + + Same rationale as test_route_reasoning_training.py: without this, a test + that forgets to patch create_isolated_storage silently reaches a live + SURREALDB_URL backend instead of failing. + """ + + async def _refuse(brain_name: str | None = None) -> None: + raise AssertionError( + f"create_isolated_storage({brain_name!r}) reached the real backend; " + "patch it in the test or set mock_storage.brain_id to the request scope" + ) + + monkeypatch.setattr("surreal_memory.unified_config.create_isolated_storage", _refuse) + + +@pytest.fixture +def mock_storage() -> AsyncMock: + storage = AsyncMock() + storage.brain_id = BOUND_BRAIN + storage.get_change_log_stats = AsyncMock( + return_value={"total": 0, "pending": 0, "synced": 0, "last_sequence": 0} + ) + storage.list_devices = AsyncMock(return_value=[]) + return storage + + +@pytest.fixture +def client(mock_storage: AsyncMock) -> TestClient: + app = FastAPI() + app.include_router(router) + app.dependency_overrides[get_storage] = lambda: mock_storage + return TestClient(app) + + +def _scoped_storage(monkeypatch: pytest.MonkeyPatch, **overrides: Any) -> AsyncMock: + scoped = AsyncMock() + scoped.brain_id = OTHER_BRAIN + scoped.get_change_log_stats = AsyncMock( + return_value=overrides.get( + "stats", {"total": 1, "pending": 0, "synced": 1, "last_sequence": 1} + ) + ) + scoped.list_devices = AsyncMock(return_value=overrides.get("devices", [_device()])) + scoped.close = AsyncMock() + monkeypatch.setattr( + "surreal_memory.unified_config.create_isolated_storage", + AsyncMock(return_value=scoped), + ) + return scoped + + +class TestHubStatusDoesNotLeakBrainState: + """The read-only status endpoint must never mutate the shared storage's brain.""" + + def test_status_for_a_different_brain_does_not_call_set_brain( + self, client: TestClient, mock_storage: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + _scoped_storage(monkeypatch) + + resp = client.get(f"/hub/status/{OTHER_BRAIN}") + + assert resp.status_code == 200 + mock_storage.set_brain.assert_not_called() + + def test_status_for_a_different_brain_leaves_shared_storage_brain_id_unchanged( + self, client: TestClient, mock_storage: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """The exact scenario in #152: a background loop reads storage.brain_id + right after this request and must still see the brain it had before. + """ + _scoped_storage(monkeypatch) + + client.get(f"/hub/status/{OTHER_BRAIN}") + + assert mock_storage.brain_id == BOUND_BRAIN + + def test_status_for_a_different_brain_reads_from_the_scoped_storage( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + scoped = _scoped_storage( + monkeypatch, + stats={"total": 7, "pending": 2, "synced": 5, "last_sequence": 7}, + devices=[_device("aa"), _device("bb")], + ) + + data = client.get(f"/hub/status/{OTHER_BRAIN}").json() + + assert scoped.get_change_log_stats.await_count == 1 + assert scoped.list_devices.await_count == 1 + assert data["brain_id"] == OTHER_BRAIN + assert data["device_count"] == 2 + assert data["change_log"]["total"] == 7 + + def test_status_for_the_bound_brain_reuses_the_shared_storage( + self, client: TestClient, mock_storage: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + """No scope mismatch -> no isolated connection needed.""" + mock_storage.get_change_log_stats.return_value = { + "total": 3, + "pending": 1, + "synced": 2, + "last_sequence": 3, + } + + data = client.get(f"/hub/status/{BOUND_BRAIN}").json() + + assert mock_storage.get_change_log_stats.await_count == 1 + assert data["change_log"]["total"] == 3 + + +class TestListDevicesDoesNotLeakBrainState: + """Same #152 leak, on the /hub/devices/{brain_id} endpoint.""" + + def test_devices_for_a_different_brain_does_not_call_set_brain( + self, client: TestClient, mock_storage: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + _scoped_storage(monkeypatch) + + resp = client.get(f"/hub/devices/{OTHER_BRAIN}") + + assert resp.status_code == 200 + mock_storage.set_brain.assert_not_called() + + def test_devices_for_a_different_brain_leaves_shared_storage_brain_id_unchanged( + self, client: TestClient, mock_storage: AsyncMock, monkeypatch: pytest.MonkeyPatch + ) -> None: + _scoped_storage(monkeypatch) + + client.get(f"/hub/devices/{OTHER_BRAIN}") + + assert mock_storage.brain_id == BOUND_BRAIN + + def test_devices_for_a_different_brain_reads_from_the_scoped_storage( + self, client: TestClient, monkeypatch: pytest.MonkeyPatch + ) -> None: + scoped = _scoped_storage(monkeypatch, devices=[_device("cc")]) + + data = client.get(f"/hub/devices/{OTHER_BRAIN}").json() + + assert scoped.list_devices.await_count == 1 + assert data["brain_id"] == OTHER_BRAIN + assert len(data["devices"]) == 1 + assert data["devices"][0]["device_id"] == "cc" + + def test_devices_for_the_bound_brain_reuses_the_shared_storage( + self, client: TestClient, mock_storage: AsyncMock + ) -> None: + mock_storage.list_devices.return_value = [_device("dd")] + + data = client.get(f"/hub/devices/{BOUND_BRAIN}").json() + + assert mock_storage.list_devices.await_count == 1 + assert data["devices"][0]["device_id"] == "dd" + + +class TestInvalidBrainId: + def test_status_rejects_invalid_brain_id(self, client: TestClient) -> None: + resp = client.get("/hub/status/../etc") + + assert resp.status_code in (404, 422) diff --git a/tests/unit/test_storage_parity.py b/tests/unit/test_storage_parity.py index 8de39342..e550c677 100644 --- a/tests/unit/test_storage_parity.py +++ b/tests/unit/test_storage_parity.py @@ -98,8 +98,6 @@ def test_every_backend_implements_the_full_interface() -> None: "get_connected_neuron_ids", "get_edges_for_neurons", "get_synapse_degrees", - "get_tool_stats", - "get_tool_stats_by_period", "initialize", "list_brain_names", "prune_old_events", diff --git a/tests/unit/test_surrealdb_tool_events.py b/tests/unit/test_surrealdb_tool_events.py index 32e90a6a..d3cdddbe 100644 --- a/tests/unit/test_surrealdb_tool_events.py +++ b/tests/unit/test_surrealdb_tool_events.py @@ -24,6 +24,7 @@ def __init__(self, unprocessed=None, total=0, ok=0, grouped=None, ok_grouped=Non self._ok_grouped = ok_grouped or [] self.updates: list[dict[str, Any]] = [] self.inserts: list[dict[str, Any]] = [] + self.captured_cutoffs: list[Any] = [] def _ensure_conn(self) -> Any: store = self @@ -38,6 +39,8 @@ def _get_brain_id(self) -> str: return "default" async def _query(self, sql: str, **params: Any) -> list[dict[str, Any]]: + if "cutoff" in params: + self.captured_cutoffs.append(params["cutoff"]) if sql.startswith("UPDATE tool_events SET processed = true"): self.updates.append(params) return [] @@ -45,7 +48,7 @@ async def _query(self, sql: str, **params: Any) -> list[dict[str, Any]]: return self._unprocessed if "AND success = true GROUP ALL" in sql: return [{"c": self._ok}] - if "count() AS c FROM tool_events WHERE brain_id = $bid GROUP ALL" in sql: + if "count() AS c FROM tool_events" in sql and "success" not in sql: return [{"c": self._total}] # Per-tool success counts (check before the generic grouped route). if "AND success = true" in sql and "GROUP BY tool_name, server_name" in sql: @@ -136,3 +139,41 @@ async def test_get_tool_stats_no_success_rows_is_zero_not_nan() -> None: assert tool["success_rate"] == 0.0 assert tool["avg_duration_ms"] == 0 assert isinstance(tool["success_rate"], float) + + +async def test_get_tool_stats_passes_days_as_a_cutoff_to_every_query() -> None: + """`days` must filter the summary, not just the daily series. + + Before this, `get_tool_stats` took no `days` at all, so the dashboard's + days=7/30/90 filter changed the per-day chart but left the summary above + it byte-identical -- a working-looking filter that filtered nothing. + """ + store = _ToolEventsStore(total=1, ok=1) + + await store.get_tool_stats("default", days=7) + + # All 4 queries (total, ok, grouped, ok_grouped) must carry the same cutoff. + assert len(store.captured_cutoffs) == 4 + assert len(set(store.captured_cutoffs)) == 1 + + +async def test_get_tool_stats_default_days_is_30() -> None: + store = _ToolEventsStore(total=1, ok=1) + + await store.get_tool_stats("default") + + assert len(store.captured_cutoffs) == 4 + + +async def test_get_tool_stats_clamps_days_to_one_year() -> None: + """Matches get_tool_stats_by_period's existing clamp -- an operator-supplied + `days` should not silently become an unbounded full-table scan.""" + store_small = _ToolEventsStore(total=1, ok=1) + store_large = _ToolEventsStore(total=1, ok=1) + + await store_small.get_tool_stats("default", days=1) + await store_large.get_tool_stats("default", days=999_999) + + # The clamp caps at 365 days, so an absurd `days` produces the same + # earliest-allowed cutoff as 365 would -- not an even-earlier one. + assert store_small.captured_cutoffs[0] > store_large.captured_cutoffs[0] diff --git a/tests/unit/test_tool_tiers.py b/tests/unit/test_tool_tiers.py index 6ec1fd23..0785904f 100755 --- a/tests/unit/test_tool_tiers.py +++ b/tests/unit/test_tool_tiers.py @@ -65,7 +65,7 @@ class TestToolTiers: def test_full_tier_returns_all(self) -> None: tools = get_tool_schemas_for_tier("full") - assert len(tools) == 58 + assert len(tools) == 57 def test_full_tier_matches_get_tool_schemas(self) -> None: full = get_tool_schemas_for_tier("full") @@ -109,7 +109,7 @@ def test_minimal_tier_correct_names(self) -> None: def test_invalid_tier_defaults_to_full(self) -> None: tools = get_tool_schemas_for_tier("bogus") - assert len(tools) == 58 + assert len(tools) == 57 def test_tier_hierarchy_minimal_subset_of_standard(self) -> None: assert TOOL_TIERS["minimal"] < TOOL_TIERS["standard"] @@ -134,7 +134,7 @@ def test_get_tool_schemas_returns_copy(self) -> None: a = get_tool_schemas() b = get_tool_schemas() a.pop() - assert len(b) == 58 + assert len(b) == 57 def test_get_tool_schemas_for_tier_returns_copy(self) -> None: a = get_tool_schemas_for_tier("standard") @@ -160,7 +160,7 @@ def _make_server(self, tier: str) -> MCPServer: # noqa: F821 def test_server_full_tier(self) -> None: server = self._make_server("full") with patch("surreal_memory.plugins.get_plugin_tools", return_value=[]): - assert len(server.get_tools()) == 58 + assert len(server.get_tools()) == 57 def test_server_standard_tier(self) -> None: server = self._make_server("standard") diff --git a/vscode-extension/package-lock.json b/vscode-extension/package-lock.json index aae2fe81..27f5979d 100755 --- a/vscode-extension/package-lock.json +++ b/vscode-extension/package-lock.json @@ -1,12 +1,12 @@ { "name": "surrealmemory", - "version": "3.2.0", + "version": "3.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "surrealmemory", - "version": "3.2.0", + "version": "3.3.0", "license": "MIT", "dependencies": { "cytoscape": "^3.33.1", diff --git a/vscode-extension/package.json b/vscode-extension/package.json index d12f625f..df9229e7 100755 --- a/vscode-extension/package.json +++ b/vscode-extension/package.json @@ -2,7 +2,7 @@ "name": "surrealmemory", "displayName": "Surreal-Memory", "description": "Visual brain explorer, inline recall, and memory management for Surreal-Memory", - "version": "3.2.0", + "version": "3.3.0", "publisher": "ai-flow-nowak", "license": "MIT", "preview": true,