Diagnostics Quick Checks
++ Run projection shadow, event ledger smoke, and trace diagnostics from one place. +
+diff --git a/.gitignore b/.gitignore index 31c1f2c..c22dfd9 100644 --- a/.gitignore +++ b/.gitignore @@ -167,3 +167,5 @@ node_modules/ .podman/ .beads/ .sisyphus/ + +.container-home/ diff --git a/INSTALL.md b/INSTALL.md index 462ea78..979e168 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -54,6 +54,13 @@ Prosody container helpers: - `QUADLET_PROSODY_DATA_DIR` - `QUADLET_PROSODY_LOGS_DIR` +Memory/wiki search helpers: + +- `MEMORY_SEARCH_BACKEND` (`django` or `manticore`) +- `MANTICORE_HTTP_URL` +- `MANTICORE_MEMORY_TABLE` +- `MANTICORE_HTTP_TIMEOUT` + For XMPP media upload, configure one of: - `XMPP_UPLOAD_SERVICE` @@ -173,6 +180,26 @@ Certificate renewal helper (run as root on host): ./utilities/prosody/renew_prosody_cert.sh ``` +### E) Manticore container for memory/wiki retrieval + +```bash +./utilities/memory/manage_manticore_container.sh up +./utilities/memory/manage_manticore_container.sh status +./utilities/memory/manage_manticore_container.sh logs +``` + +Reindex memory into configured backend: + +```bash +podman exec ur_gia /venv/bin/python manage.py memory_search_reindex --user-id 1 --statuses active +``` + +Query memory backend: + +```bash +podman exec ur_gia /venv/bin/python manage.py memory_search_query --user-id 1 --query "reply style" +``` + ### C) Signal or WhatsApp send failures - Verify account/link status in service pages. diff --git a/app/local_settings.py b/app/local_settings.py index 4acb735..9c40b85 100644 --- a/app/local_settings.py +++ b/app/local_settings.py @@ -67,3 +67,8 @@ CAPABILITY_ENFORCEMENT_ENABLED = ( ) TRACE_PROPAGATION_ENABLED = getenv("TRACE_PROPAGATION_ENABLED", "true").lower() in trues EVENT_PRIMARY_WRITE_PATH = getenv("EVENT_PRIMARY_WRITE_PATH", "false").lower() in trues + +MEMORY_SEARCH_BACKEND = getenv("MEMORY_SEARCH_BACKEND", "django") +MANTICORE_HTTP_URL = getenv("MANTICORE_HTTP_URL", "http://127.0.0.1:9308") +MANTICORE_MEMORY_TABLE = getenv("MANTICORE_MEMORY_TABLE", "gia_memory_items") +MANTICORE_HTTP_TIMEOUT = int(getenv("MANTICORE_HTTP_TIMEOUT", "5") or 5) diff --git a/app/urls.py b/app/urls.py index 75535f8..5390b03 100644 --- a/app/urls.py +++ b/app/urls.py @@ -83,6 +83,21 @@ urlpatterns = [ system.EventProjectionShadowAPI.as_view(), name="system_projection_shadow", ), + path( + "settings/system/event-ledger-smoke/", + system.EventLedgerSmokeAPI.as_view(), + name="system_event_ledger_smoke", + ), + path( + "settings/system/memory-search/status/", + system.MemorySearchStatusAPI.as_view(), + name="system_memory_search_status", + ), + path( + "settings/system/memory-search/query/", + system.MemorySearchQueryAPI.as_view(), + name="system_memory_search_query", + ), path( "settings/command-routing/", automation.CommandRoutingSettings.as_view(), diff --git a/artifacts/plans/15-simplify-task-settings-and-more.md b/artifacts/plans/15-simplify-task-settings-and-more.md new file mode 100644 index 0000000..993ed10 --- /dev/null +++ b/artifacts/plans/15-simplify-task-settings-and-more.md @@ -0,0 +1,13 @@ + +No Tasks Yet + +This group has no derived tasks yet. To start populating this view: + + Open Task Settings and confirm this chat is mapped under Group Mapping. + Send task-like messages in this group, for example: task: ship v1, todo: write tests, please review PR. + Mark completion explicitly with a phrase + reference, for example: done #12, completed #12, fixed #12. + Refresh this page; new derived tasks and events should appear automatically. + + + +task settings sound complicated, make them simpler \ No newline at end of file diff --git a/artifacts/plans/16-agent-knowledge-memory-foundation.md b/artifacts/plans/16-agent-knowledge-memory-foundation.md new file mode 100644 index 0000000..39a2db3 --- /dev/null +++ b/artifacts/plans/16-agent-knowledge-memory-foundation.md @@ -0,0 +1,34 @@ +# Feature Plan: Agent Knowledge Memory Foundation (Pre-11/12) + +## Goal +Establish a scalable, queryable memory substrate so wiki and MCP features can rely on fast retrieval instead of markdown-file scans. + +## Why This Comes Before 11/12 +- Plan 11 (personal memory) needs performant retrieval and indexing guarantees. +- Plan 12 (MCP wiki/tools) needs a stable backend abstraction independent of UI and tool transport. + +## Scope +- Pluggable memory search backend interface. +- Default Django backend for zero-infra operation. +- Optional Manticore backend for scalable full-text/vector-ready indexing. +- Reindex + query operational commands. +- System diagnostics endpoints for backend status and query inspection. + +## Implementation Slice +1. Add `core/memory/search_backend.py` abstraction and backends. +2. Add `memory_search_reindex` and `memory_search_query` management commands. +3. Add system APIs: + - backend status + - memory query +4. Add lightweight Podman utility script for Manticore runtime. +5. Add tests for diagnostics and query behavior. + +## Acceptance Criteria +- Memory retrieval works with `MEMORY_SEARCH_BACKEND=django` out of the box. +- Switching to `MEMORY_SEARCH_BACKEND=manticore` requires only env/config + container startup. +- Operators can verify backend health and query output from system settings. + +## Out of Scope +- Full wiki article model/UI. +- Full MCP server process/tooling. +- Embedding generation pipeline (next slice after backend foundation). diff --git a/artifacts/plans/16-memory-backend-evaluation.md b/artifacts/plans/16-memory-backend-evaluation.md new file mode 100644 index 0000000..8295d23 --- /dev/null +++ b/artifacts/plans/16-memory-backend-evaluation.md @@ -0,0 +1,25 @@ +# Memory Backend Evaluation: Manticore vs Alternatives + +## Decision Summary +- **Recommended now:** Manticore for indexed text retrieval and future vector layering. +- **Default fallback:** Django/ORM backend for zero-infra environments. +- **Revisit later:** dedicated vector DB only if recall quality or ANN latency requires it. + +## Why Manticore Fits This Stage +- Already present in adjacent infra and codebase history. +- Runs well as a small standalone container with low operational complexity. +- Supports SQL-like querying and fast full-text retrieval for agent memory/wiki content. +- Lets us keep one retrieval abstraction while deferring embedding complexity. + +## Tradeoff Notes +- Manticore-first gives immediate performance over markdown scans. +- For advanced ANN/vector-only workloads, Qdrant/pgvector/Weaviate may outperform with less custom shaping. +- A hybrid approach remains possible: + - Manticore for lexical + metadata filtering, + - optional vector store for semantic recall. + +## Practical Rollout +1. Start with `MEMORY_SEARCH_BACKEND=django` and verify API/command workflows. +2. Start Manticore container and switch to `MEMORY_SEARCH_BACKEND=manticore`. +3. Run reindex and validate query latency/quality on real agent workflows. +4. Add embedding pipeline only after baseline lexical retrieval is stable. diff --git a/auth_django.py b/auth_django.py deleted file mode 100755 index 94abc5d..0000000 --- a/auth_django.py +++ /dev/null @@ -1,85 +0,0 @@ -# Create a debug log to confirm script execution - -import os -import sys - -import django - -LOG_PATH = os.environ.get("AUTH_DEBUG_LOG", "/tmp/auth_debug.log") - - -def log(data): - try: - with open(LOG_PATH, "a") as f: - f.write(f"{data}\n") - except Exception: - pass - - -# Set up Django environment -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "app.settings") # Adjust if needed -django.setup() - -from django.contrib.auth import authenticate # noqa: E402 -from django.contrib.auth.models import User # noqa: E402 - - -def check_credentials(username, password): - """Authenticate user via Django""" - user = authenticate(username=username, password=password) - return user is not None and user.is_active - - -def main(): - """Process authentication requests from Prosody""" - while True: - try: - # Read a single line from stdin - line = sys.stdin.readline().strip() - if not line: - break # Exit if input is empty (EOF) - - # Log received command (for debugging) - # log(f"Received: {line}") - - parts = line.split(":") - if len(parts) < 3: - log("Sending 0") - print("0", flush=True) # Invalid format, return failure - continue - - command, username, domain = parts[:3] - password = ( - ":".join(parts[3:]) if len(parts) > 3 else None - ) # Reconstruct password - - if command == "auth": - if password and check_credentials(username, password): - log("Authentication success") - log("Sent 1") - print("1", flush=True) # Success - else: - log("Authentication failure") - log("Sent 0") - print("0", flush=True) # Failure - - elif command == "isuser": - if User.objects.filter(username=username).exists(): - print("1", flush=True) # User exists - else: - print("0", flush=True) # User does not exist - - elif command == "setpass": - print("0", flush=True) # Not supported - - else: - print("0", flush=True) # Unknown command, return failure - - except Exception as e: - # Log any unexpected errors - log(f"Error: {str(e)}\n") - print("0", flush=True) # Return failure for any error - - -if __name__ == "__main__": - main() diff --git a/auth_django.sh b/auth_django.sh deleted file mode 100755 index 5857a4d..0000000 --- a/auth_django.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/bin/sh -set -eu - -# Prosody external auth expects a single long-lived stdin/stdout process. -# Keep one stable process chain and hand off with exec. -exec podman exec -i gia sh -lc ' - cd /code && - . /venv/bin/activate && - exec python -u auth_django.py -' diff --git a/core/clients/xmpp.py b/core/clients/xmpp.py index fec691a..858bc81 100644 --- a/core/clients/xmpp.py +++ b/core/clients/xmpp.py @@ -840,7 +840,6 @@ class XMPPComponent(ComponentXMPP): connected = self.connect() if connected is False: raise RuntimeError("connect returned false") - self.process(forever=False) return except Exception as exc: self.log.warning("XMPP reconnect attempt failed: %s", exc) @@ -1754,7 +1753,6 @@ class XMPPClient(ClientBase): self.client.loop = self.loop self.client.connect() - self.client.process(forever=False) async def start_typing_for_person(self, user, person_identifier): await self.client.send_typing_for_person(user, person_identifier, True) diff --git a/core/management/commands/event_ledger_smoke.py b/core/management/commands/event_ledger_smoke.py index 6dbbba3..4ea1d48 100644 --- a/core/management/commands/event_ledger_smoke.py +++ b/core/management/commands/event_ledger_smoke.py @@ -3,7 +3,7 @@ from __future__ import annotations import json import time -from django.core.management.base import BaseCommand +from django.core.management.base import BaseCommand, CommandError from core.models import ConversationEvent @@ -16,6 +16,8 @@ class Command(BaseCommand): parser.add_argument("--service", default="") parser.add_argument("--user-id", default="") parser.add_argument("--limit", type=int, default=200) + parser.add_argument("--require-types", default="") + parser.add_argument("--fail-if-empty", action="store_true", default=False) parser.add_argument("--json", action="store_true", default=False) def handle(self, *args, **options): @@ -23,7 +25,14 @@ class Command(BaseCommand): service = str(options.get("service") or "").strip().lower() user_id = str(options.get("user_id") or "").strip() limit = max(1, int(options.get("limit") or 200)) + require_types_raw = str(options.get("require_types") or "").strip() + fail_if_empty = bool(options.get("fail_if_empty")) as_json = bool(options.get("json")) + required_types = [ + item.strip().lower() + for item in require_types_raw.split(",") + if item.strip() + ] cutoff_ts = int(time.time() * 1000) - (minutes * 60 * 1000) queryset = ConversationEvent.objects.filter(ts__gte=cutoff_ts).order_by("-ts") @@ -48,6 +57,11 @@ class Command(BaseCommand): for row in rows: key = str(row.get("event_type") or "") event_type_counts[key] = int(event_type_counts.get(key) or 0) + 1 + missing_required_types = [ + event_type + for event_type in required_types + if int(event_type_counts.get(event_type) or 0) <= 0 + ] payload = { "minutes": minutes, @@ -55,6 +69,8 @@ class Command(BaseCommand): "user_id": user_id, "count": len(rows), "event_type_counts": event_type_counts, + "required_types": required_types, + "missing_required_types": missing_required_types, "sample": rows[:25], } @@ -66,3 +82,14 @@ class Command(BaseCommand): f"event-ledger-smoke minutes={minutes} service={service or '-'} user={user_id or '-'} count={len(rows)}" ) self.stdout.write(f"event_type_counts={event_type_counts}") + if required_types: + self.stdout.write( + f"required_types={required_types} missing_required_types={missing_required_types}" + ) + + if fail_if_empty and len(rows) == 0: + raise CommandError("No recent ConversationEvent rows found.") + if missing_required_types: + raise CommandError( + "Missing required event types: " + ", ".join(missing_required_types) + ) diff --git a/core/management/commands/event_projection_shadow.py b/core/management/commands/event_projection_shadow.py index acf426f..3be8861 100644 --- a/core/management/commands/event_projection_shadow.py +++ b/core/management/commands/event_projection_shadow.py @@ -19,6 +19,7 @@ class Command(BaseCommand): parser.add_argument("--user-id", default="") parser.add_argument("--session-id", default="") parser.add_argument("--service", default="") + parser.add_argument("--recent-only", action="store_true", default=False) parser.add_argument("--recent-minutes", type=int, default=0) parser.add_argument("--limit-sessions", type=int, default=50) parser.add_argument("--detail-limit", type=int, default=25) @@ -29,7 +30,10 @@ class Command(BaseCommand): user_id = str(options.get("user_id") or "").strip() session_id = str(options.get("session_id") or "").strip() service = str(options.get("service") or "").strip().lower() + recent_only = bool(options.get("recent_only")) recent_minutes = max(0, int(options.get("recent_minutes") or 0)) + if recent_only and recent_minutes <= 0: + recent_minutes = 120 limit_sessions = max(1, int(options.get("limit_sessions") or 50)) detail_limit = max(0, int(options.get("detail_limit") or 25)) as_json = bool(options.get("json")) @@ -98,6 +102,7 @@ class Command(BaseCommand): "user_id": user_id, "session_id": session_id, "service": service, + "recent_only": recent_only, "recent_minutes": recent_minutes, "limit_sessions": limit_sessions, "detail_limit": detail_limit, diff --git a/core/management/commands/memory_search_query.py b/core/management/commands/memory_search_query.py new file mode 100644 index 0000000..8332c20 --- /dev/null +++ b/core/management/commands/memory_search_query.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import json + +from django.core.management.base import BaseCommand, CommandError + +from core.memory.search_backend import get_memory_search_backend + + +class Command(BaseCommand): + help = "Run a query against configured memory search backend." + + def add_arguments(self, parser): + parser.add_argument("--user-id", required=True) + parser.add_argument("--query", required=True) + parser.add_argument("--conversation-id", default="") + parser.add_argument("--statuses", default="active") + parser.add_argument("--limit", type=int, default=20) + parser.add_argument("--json", action="store_true", default=False) + + def handle(self, *args, **options): + user_id_raw = str(options.get("user_id") or "").strip() + query = str(options.get("query") or "").strip() + conversation_id = str(options.get("conversation_id") or "").strip() + statuses = tuple( + item.strip().lower() + for item in str(options.get("statuses") or "active").split(",") + if item.strip() + ) + limit = max(1, int(options.get("limit") or 20)) + as_json = bool(options.get("json")) + + if not user_id_raw: + raise CommandError("--user-id is required") + if not query: + raise CommandError("--query is required") + + backend = get_memory_search_backend() + hits = backend.search( + user_id=int(user_id_raw), + query=query, + conversation_id=conversation_id, + limit=limit, + include_statuses=statuses, + ) + payload = { + "backend": getattr(backend, "name", "unknown"), + "query": query, + "user_id": int(user_id_raw), + "conversation_id": conversation_id, + "statuses": statuses, + "count": len(hits), + "hits": [ + { + "memory_id": item.memory_id, + "score": item.score, + "summary": item.summary, + "payload": item.payload, + } + for item in hits + ], + } + if as_json: + self.stdout.write(json.dumps(payload, indent=2, sort_keys=True)) + return + self.stdout.write( + f"memory-search-query backend={payload['backend']} count={payload['count']} query={query!r}" + ) + for row in payload["hits"]: + self.stdout.write( + f"- id={row['memory_id']} score={row['score']:.2f} summary={row['summary'][:120]}" + ) diff --git a/core/management/commands/memory_search_reindex.py b/core/management/commands/memory_search_reindex.py new file mode 100644 index 0000000..9c9d5ad --- /dev/null +++ b/core/management/commands/memory_search_reindex.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import json + +from django.core.management.base import BaseCommand + +from core.memory.search_backend import get_memory_search_backend + + +class Command(BaseCommand): + help = "Reindex MemoryItem rows into the configured memory search backend." + + def add_arguments(self, parser): + parser.add_argument("--user-id", default="") + parser.add_argument("--statuses", default="active") + parser.add_argument("--limit", type=int, default=2000) + parser.add_argument("--json", action="store_true", default=False) + + def handle(self, *args, **options): + user_id_raw = str(options.get("user_id") or "").strip() + statuses = tuple( + item.strip().lower() + for item in str(options.get("statuses") or "active").split(",") + if item.strip() + ) + limit = max(1, int(options.get("limit") or 2000)) + as_json = bool(options.get("json")) + + backend = get_memory_search_backend() + result = backend.reindex( + user_id=int(user_id_raw) if user_id_raw else None, + include_statuses=statuses, + limit=limit, + ) + payload = { + "backend": getattr(backend, "name", "unknown"), + "user_id": user_id_raw, + "statuses": statuses, + "limit": limit, + "result": result, + } + if as_json: + self.stdout.write(json.dumps(payload, indent=2, sort_keys=True)) + return + self.stdout.write( + f"memory-search-reindex backend={payload['backend']} " + f"user={user_id_raw or '-'} statuses={','.join(statuses) or '-'} " + f"scanned={int(result.get('scanned') or 0)} indexed={int(result.get('indexed') or 0)}" + ) diff --git a/core/memory/__init__.py b/core/memory/__init__.py new file mode 100644 index 0000000..c86cbcb --- /dev/null +++ b/core/memory/__init__.py @@ -0,0 +1,3 @@ +from .search_backend import get_memory_search_backend + +__all__ = ["get_memory_search_backend"] diff --git a/core/memory/search_backend.py b/core/memory/search_backend.py new file mode 100644 index 0000000..b48bd0b --- /dev/null +++ b/core/memory/search_backend.py @@ -0,0 +1,283 @@ +from __future__ import annotations + +import hashlib +import json +import time +from dataclasses import dataclass +from typing import Any + +import requests +from django.conf import settings + +from core.models import MemoryItem +from core.util import logs + +log = logs.get_logger("memory-search") + + +@dataclass +class MemorySearchHit: + memory_id: str + score: float + summary: str + payload: dict[str, Any] + + +def _flatten_to_text(value: Any) -> str: + if value is None: + return "" + if isinstance(value, dict): + parts = [] + for key, item in value.items(): + parts.append(str(key)) + parts.append(_flatten_to_text(item)) + return " ".join(part for part in parts if part).strip() + if isinstance(value, (list, tuple, set)): + return " ".join(_flatten_to_text(item) for item in value if item).strip() + return str(value).strip() + + +class BaseMemorySearchBackend: + def upsert(self, item: MemoryItem) -> None: + raise NotImplementedError + + def delete(self, memory_id: str) -> None: + raise NotImplementedError + + def search( + self, + *, + user_id: int, + query: str, + conversation_id: str = "", + limit: int = 20, + include_statuses: tuple[str, ...] = ("active",), + ) -> list[MemorySearchHit]: + raise NotImplementedError + + def reindex( + self, + *, + user_id: int | None = None, + include_statuses: tuple[str, ...] = ("active",), + limit: int = 2000, + ) -> dict[str, int]: + queryset = MemoryItem.objects.all().order_by("-updated_at") + if user_id is not None: + queryset = queryset.filter(user_id=int(user_id)) + if include_statuses: + queryset = queryset.filter(status__in=list(include_statuses)) + + scanned = 0 + indexed = 0 + for item in queryset[: max(1, int(limit))]: + scanned += 1 + try: + self.upsert(item) + indexed += 1 + except Exception as exc: + log.warning("memory-search upsert failed id=%s err=%s", item.id, exc) + return {"scanned": scanned, "indexed": indexed} + + +class DjangoMemorySearchBackend(BaseMemorySearchBackend): + name = "django" + + def upsert(self, item: MemoryItem) -> None: + # No-op because Django backend queries source-of-truth rows directly. + _ = item + + def delete(self, memory_id: str) -> None: + _ = memory_id + + def search( + self, + *, + user_id: int, + query: str, + conversation_id: str = "", + limit: int = 20, + include_statuses: tuple[str, ...] = ("active",), + ) -> list[MemorySearchHit]: + needle = str(query or "").strip().lower() + if not needle: + return [] + + queryset = MemoryItem.objects.filter(user_id=int(user_id)) + if conversation_id: + queryset = queryset.filter(conversation_id=conversation_id) + if include_statuses: + queryset = queryset.filter(status__in=list(include_statuses)) + + hits: list[MemorySearchHit] = [] + for item in queryset.order_by("-updated_at")[:500]: + text_blob = _flatten_to_text(item.content).lower() + if needle not in text_blob: + continue + raw_summary = _flatten_to_text(item.content) + summary = raw_summary[:280] + score = 1.0 + min(1.0, len(needle) / max(1.0, len(text_blob))) + hits.append( + MemorySearchHit( + memory_id=str(item.id), + score=float(score), + summary=summary, + payload={ + "memory_kind": str(item.memory_kind or ""), + "status": str(item.status or ""), + "conversation_id": str(item.conversation_id or ""), + "updated_at": item.updated_at.isoformat(), + }, + ) + ) + if len(hits) >= max(1, int(limit)): + break + return hits + + +class ManticoreMemorySearchBackend(BaseMemorySearchBackend): + name = "manticore" + + def __init__(self): + self.base_url = str( + getattr(settings, "MANTICORE_HTTP_URL", "http://127.0.0.1:9308") + ).rstrip("/") + self.table = str( + getattr(settings, "MANTICORE_MEMORY_TABLE", "gia_memory_items") + ).strip() or "gia_memory_items" + self.timeout_seconds = int(getattr(settings, "MANTICORE_HTTP_TIMEOUT", 5) or 5) + + def _sql(self, query: str) -> dict[str, Any]: + response = requests.post( + f"{self.base_url}/sql", + data={"mode": "raw", "query": query}, + timeout=self.timeout_seconds, + ) + response.raise_for_status() + payload = response.json() + if isinstance(payload, list): + return payload[0] if payload else {} + return dict(payload or {}) + + def ensure_table(self) -> None: + self._sql( + ( + f"CREATE TABLE IF NOT EXISTS {self.table} (" + "id BIGINT," + "memory_uuid STRING," + "user_id BIGINT," + "conversation_id STRING," + "memory_kind STRING," + "status STRING," + "updated_ts BIGINT," + "summary TEXT," + "body TEXT" + ")" + ) + ) + + def _doc_id(self, memory_id: str) -> int: + digest = hashlib.blake2b( + str(memory_id or "").encode("utf-8"), + digest_size=8, + ).digest() + value = int.from_bytes(digest, byteorder="big", signed=False) + return max(1, int(value)) + + def _escape(self, value: Any) -> str: + text = str(value or "") + text = text.replace("\\", "\\\\").replace("'", "\\'") + return text + + def upsert(self, item: MemoryItem) -> None: + self.ensure_table() + memory_id = str(item.id) + doc_id = self._doc_id(memory_id) + summary = _flatten_to_text(item.content)[:280] + body = _flatten_to_text(item.content) + updated_ts = int(item.updated_at.timestamp() * 1000) + query = ( + f"REPLACE INTO {self.table} " + "(id,memory_uuid,user_id,conversation_id,memory_kind,status,updated_ts,summary,body) " + f"VALUES ({doc_id},'{self._escape(memory_id)}',{int(item.user_id)}," + f"'{self._escape(item.conversation_id)}','{self._escape(item.memory_kind)}'," + f"'{self._escape(item.status)}',{updated_ts}," + f"'{self._escape(summary)}','{self._escape(body)}')" + ) + self._sql(query) + + def delete(self, memory_id: str) -> None: + self.ensure_table() + doc_id = self._doc_id(memory_id) + self._sql(f"DELETE FROM {self.table} WHERE id={doc_id}") + + def search( + self, + *, + user_id: int, + query: str, + conversation_id: str = "", + limit: int = 20, + include_statuses: tuple[str, ...] = ("active",), + ) -> list[MemorySearchHit]: + self.ensure_table() + needle = str(query or "").strip() + if not needle: + return [] + + where_parts = [f"user_id={int(user_id)}", f"MATCH('{self._escape(needle)}')"] + if conversation_id: + where_parts.append(f"conversation_id='{self._escape(conversation_id)}'") + statuses = [str(item or "").strip() for item in include_statuses if str(item or "").strip()] + if statuses: + in_clause = ",".join(f"'{self._escape(item)}'" for item in statuses) + where_parts.append(f"status IN ({in_clause})") + where_sql = " AND ".join(where_parts) + query_sql = ( + f"SELECT memory_uuid,memory_kind,status,conversation_id,updated_ts,summary,WEIGHT() AS score " + f"FROM {self.table} WHERE {where_sql} ORDER BY score DESC LIMIT {max(1, int(limit))}" + ) + payload = self._sql(query_sql) + rows = list(payload.get("data") or []) + hits = [] + for row in rows: + item = dict(row or {}) + hits.append( + MemorySearchHit( + memory_id=str(item.get("memory_uuid") or ""), + score=float(item.get("score") or 0.0), + summary=str(item.get("summary") or ""), + payload={ + "memory_kind": str(item.get("memory_kind") or ""), + "status": str(item.get("status") or ""), + "conversation_id": str(item.get("conversation_id") or ""), + "updated_ts": int(item.get("updated_ts") or 0), + }, + ) + ) + return hits + + +def get_memory_search_backend() -> BaseMemorySearchBackend: + backend = str(getattr(settings, "MEMORY_SEARCH_BACKEND", "django")).strip().lower() + if backend == "manticore": + return ManticoreMemorySearchBackend() + return DjangoMemorySearchBackend() + + +def backend_status() -> dict[str, Any]: + backend = get_memory_search_backend() + status = { + "backend": getattr(backend, "name", "unknown"), + "ok": True, + "ts": int(time.time() * 1000), + } + if isinstance(backend, ManticoreMemorySearchBackend): + try: + backend.ensure_table() + status["manticore_http_url"] = backend.base_url + status["manticore_table"] = backend.table + except Exception as exc: + status["ok"] = False + status["error"] = str(exc) + return status diff --git a/core/templates/pages/system-settings.html b/core/templates/pages/system-settings.html index 067f206..9f686fa 100644 --- a/core/templates/pages/system-settings.html +++ b/core/templates/pages/system-settings.html @@ -39,6 +39,113 @@ +
+ Run projection shadow, event ledger smoke, and trace diagnostics from one place. +
+