from __future__ import annotations

import json
import threading
from pathlib import Path
from urllib.error import HTTPError
from urllib.request import Request, urlopen

from http.server import ThreadingHTTPServer

from shared_agent_memory import MemoryEngine, MemoryService
from shared_agent_memory.http_api import build_handler
from shared_agent_memory.schema import SCHEMA_VERSION

def make_service(tmp_path: Path) -> MemoryService:
    return MemoryService(str(tmp_path / "memory.sqlite3"))

def _request_json(url: str, *, method: str = "GET", payload: dict | None = None) -> tuple[int, dict]:
    body = None if payload is None else json.dumps(payload).encode("utf-8")
    request = Request(url, data=body, method=method)
    if body is not None:
        request.add_header("Content-Type", "application/json")
    with urlopen(request) as response:  # noqa: S310 - local test server only
        return response.status, json.loads(response.read().decode("utf-8"))

def _start_api(service: MemoryService) -> tuple[ThreadingHTTPServer, str]:
    server = ThreadingHTTPServer(("127.0.0.1", 0), build_handler(service))
    thread = threading.Thread(target=server.serve_forever, daemon=True)
    thread.start()
    host, port = server.server_address
    return server, f"http://{host}:{port}"

def test_manual_profile_fact_is_immediately_searchable(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    service.upsert_profile_fact("Communication", "Sebas prefers concise, direct communication.")

    results = service.search("concise communication", scopes=["global"])

    assert results["results"]
    assert results["results"][0]["memory"]["type"] == "profile"
    assert results["results"][0]["memory"]["schema_version"] == SCHEMA_VERSION

def test_captured_conversation_stays_inbox_until_consolidated(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    engine = MemoryEngine(service)

    service.capture_conversation("chat-1", "project", "We agreed to keep one active machine at a time.")
    processed = engine.process_pending()

    memory_id = processed[0]["memory_id"]
    memory = service.get_memory(memory_id)
    assert memory["status"] == "inbox"

    search_without_inbox = service.search("active machine", scopes=["project"])
    assert search_without_inbox["results"] == []

    promoted = service.consolidate([memory_id])
    assert promoted["promoted"] == [memory_id]

    search_with_active = service.search("active machine", scopes=["project"])
    assert search_with_active["results"][0]["memory"]["id"] == memory_id

def test_context_bundle_combines_global_project_and_task_artifacts(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    service.create_project("proj_alpha", "Alpha")
    service.create_repo("repo_alpha", "Repo Alpha", project_id="proj_alpha")
    service.upsert_profile_fact("Preferences", "Sebas wants agents to preserve clarity.")
    service.create_memory(
        {
            "type": "project",
            "scope": "project",
            "project_id": "proj_alpha",
            "repo_id": "repo_alpha",
            "title": "Project goal",
            "content": "Alpha shares memory across projects.",
        }
    )
    service.create_memory(
        {
            "type": "decision",
            "scope": "project",
            "project_id": "proj_alpha",
            "repo_id": "repo_alpha",
            "title": "Storage engine",
            "content": "Use SQLite as canonical store.",
        }
    )
    service.create_memory(
        {
            "type": "artifact",
            "subtype": "research_artifact",
            "scope": "project",
            "project_id": "proj_alpha",
            "repo_id": "repo_alpha",
            "task_id": "task_alpha",
            "title": "Task note",
            "content": "Relevant artifact.",
        }
    )

    bundle = service.context_for(project="proj_alpha", repo="repo_alpha", task="task_alpha")

    assert bundle["profile_facts"]
    assert bundle["project_memories"]
    assert bundle["active_decisions"]
    assert bundle["task_relevant_artifacts"]

def test_export_import_preserves_operational_records_without_approvals_or_handoffs(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    service.create_project("proj_alpha", "Alpha")
    service.create_repo("repo_alpha", "Repo Alpha", project_id="proj_alpha")
    task = service.create_task(
        task_id="task_alpha",
        title="Resume V2 task",
        intent="Need durable task state.",
        project_id="proj_alpha",
        repo_id="repo_alpha",
        owner_agent="personal-agent",
        run_id="run_alpha",
    )
    service.start_task_run(task["id"], "personal-agent", input_payload={"mode": "runner"})
    service.create_artifact(
        task_id=task["id"],
        artifact_type="brief",
        title="Brief",
        content="Keep resumable state in shared DB.",
        source_ref="personal-agent",
    )

    exported = service.export()

    second = MemoryService(str(tmp_path / "second.sqlite3"))
    second.import_data(exported)
    bundle = second.task_bundle(task["id"])

    assert "approvals" not in bundle
    assert "handoffs" not in bundle
    assert bundle["task"]["run_id"] == "run_alpha"
    assert bundle["runs"]
    assert bundle["artifacts"]

def test_list_memories_can_filter_by_v2_facets(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    service.create_memory(
        {
            "id": "run_123",
            "type": "episode",
            "subtype": "research_run",
            "scope": "global",
            "title": "Research run",
            "content": "Goal: inspect",
            "run_id": "run-123",
            "origin_agent": "personal-agent",
        }
    )
    service.create_memory(
        {
            "type": "artifact",
            "subtype": "research_source",
            "scope": "global",
            "title": "Source",
            "content": "source",
            "run_id": "run-123",
            "url": "https://example.com/spec",
            "domain": "example.com",
            "origin_agent": "personal-agent",
        }
    )

    matches = service.list_memories(subtype="research_source", run_id="run-123", domain="example.com", origin_agent="personal-agent")

    assert len(matches) == 1
    assert matches[0]["url"] == "https://example.com/spec"

def test_search_works_without_embeddings(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    service.create_memory(
        {
            "type": "artifact",
            "subtype": "research_claim",
            "scope": "global",
            "title": "Claim",
            "content": "SQLite remains optional for embeddings.",
            "embedding": None,
        }
    )

    results = service.search("optional embeddings", scopes=["global"])

    assert results["results"]
    assert results["results"][0]["memory"]["subtype"] == "research_claim"

def test_http_api_supports_status_ingest_and_search(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    server, base_url = _start_api(service)
    try:
        status_code, status_payload = _request_json(f"{base_url}/api/status")
        assert status_code == 200
        assert status_payload["ok"] is True

        created_code, created_payload = _request_json(
            f"{base_url}/api/ingest",
            method="POST",
            payload={
                "id": "mem_http_1",
                "type": "artifact",
                "subtype": "research_claim",
                "scope": "global",
                "title": "HTTP memory",
                "content": "Remote API keeps shared memory reachable.",
                "status": "active",
            },
        )
        assert created_code == 201
        assert created_payload["id"] == "mem_http_1"

        search_code, search_payload = _request_json(f"{base_url}/api/search?q=reachable&scope=global")
        assert search_code == 200
        assert search_payload["results"]
        assert search_payload["results"][0]["memory"]["id"] == "mem_http_1"
    finally:
        server.shutdown()
        server.server_close()

def test_http_api_supports_tasks_runs_and_bundles(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    server, base_url = _start_api(service)
    try:
        task_code, task = _request_json(
            f"{base_url}/api/tasks",
            method="POST",
            payload={
                "task_id": "task_http_1",
                "title": "Remote task",
                "intent": "Keep task state remote.",
                "owner_agent": "personal-agent",
            },
        )
        assert task_code == 201
        assert task["id"] == "task_http_1"

        run_code, run = _request_json(
            f"{base_url}/api/task-runs",
            method="POST",
            payload={"task_id": task["id"], "agent_id": "personal-agent", "input_payload": {"mode": "remote"}},
        )
        assert run_code == 201
        assert run["task_id"] == task["id"]

        patch_code, patched = _request_json(
            f"{base_url}/api/tasks/{task['id']}",
            method="PATCH",
            payload={"status": "in_progress", "run_id": run["id"]},
        )
        assert patch_code == 200
        assert patched["status"] == "in_progress"
        assert patched["run_id"] == run["id"]

        bundle_code, bundle = _request_json(f"{base_url}/api/tasks/{task['id']}/bundle")
        assert bundle_code == 200
        assert bundle["task"]["id"] == task["id"]
        assert bundle["runs"][0]["id"] == run["id"]
    finally:
        server.shutdown()
        server.server_close()

def test_http_api_rejects_missing_search_query(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    server, base_url = _start_api(service)
    try:
        try:
            _request_json(f"{base_url}/api/search")
        except HTTPError as exc:
            assert exc.code == 400
        else:
            raise AssertionError("Expected HTTP 400 for missing query")
    finally:
        server.shutdown()
        server.server_close()

def test_maintenance_jobs_exist_by_default(tmp_path: Path) -> None:
    service = make_service(tmp_path)

    jobs = {job["id"] for job in service.list_maintenance_jobs()}

    assert "job_consolidation_daily_10" in jobs
    assert "job_consolidation_daily_15" in jobs
    assert "job_consolidation_weekly" in jobs

def test_maintenance_tick_records_run(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    engine = MemoryEngine(service)

    now = "2026-03-20T10:00:00+00:00"
    service.update_maintenance_job("job_consolidation_daily_10", next_due_at=now)
    results = engine.run_due_maintenance(job_id="job_consolidation_daily_10")

    assert results
    job = service.get_maintenance_job("job_consolidation_daily_10")
    runs = service.list_maintenance_runs(job_id="job_consolidation_daily_10")

    assert job["last_status"] == "completed"
    assert runs

def test_service_startup_runs_due_maintenance(tmp_path: Path) -> None:
    db_path = tmp_path / "memory.sqlite3"
    service = MemoryService(str(db_path))
    now = "2026-03-20T10:00:00+00:00"

    service.update_maintenance_job("job_consolidation_daily_10", next_due_at=now)
    restarted = MemoryService(str(db_path))
    job = restarted.get_maintenance_job("job_consolidation_daily_10")
    runs = restarted.list_maintenance_runs(job_id="job_consolidation_daily_10")

    assert job["last_status"] == "completed"
    assert runs

def test_write_operations_trigger_due_maintenance_check(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    now = "2026-03-20T10:00:00+00:00"

    service.update_maintenance_job("job_consolidation_daily_10", next_due_at=now)
    service.create_project("proj_alpha", "Alpha")

    job = service.get_maintenance_job("job_consolidation_daily_10")
    runs = service.list_maintenance_runs(job_id="job_consolidation_daily_10")

    assert job["last_status"] == "completed"
    assert runs

def test_conflicts_only_for_selected_types(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    service.create_memory(
        {
            "id": "dec_active",
            "type": "decision",
            "scope": "project",
            "status": "active",
            "title": "Decision A",
            "content": "Feature X is enabled for launch.",
        }
    )
    service.create_memory(
        {
            "id": "dec_inbox",
            "type": "decision",
            "scope": "project",
            "status": "inbox",
            "title": "Decision B",
            "content": "Feature X is not enabled for launch.",
        }
    )
    service.consolidate_candidates(
        config={
            "candidate_age_days": 0,
            "max_candidates": 10,
            "conflict_threshold": 0.1,
            "dedupe_high_threshold": 0.99,
            "dedupe_mid_threshold": 0.99,
            "promote_confidence": 1.0,
            "llm_enabled": False,
        }
    )
    with service.store.connection() as conn:
        conflicts = conn.execute("SELECT COUNT(*) AS count FROM memory_conflicts").fetchone()["count"]

    assert conflicts == 1

def test_migrate_v2_rewrites_legacy_rows_and_drops_legacy_tables(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    with service.store.connection() as conn:
        conn.execute(
            """
            INSERT INTO memories (
                id, type, scope, status, source_kind, title, content, summary, confidence, freshness,
                created_at, updated_at, observed_at, source_ref, evidence_ref, embedding_json, metadata_json
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                "legacy_run",
                "episode",
                "global",
                "active",
                "run",
                "Research run: Legacy cleanup",
                "Goal: Legacy cleanup\nScope: Repo\nAssumptions: None\nSummary: Pending",
                "Pending",
                0.9,
                0.8,
                "2026-03-19T00:00:00+00:00",
                "2026-03-19T00:00:00+00:00",
                "2026-03-19T00:00:00+00:00",
                "personal-agent:run:run-123",
                "personal-agent:run:run-123",
                None,
                service.store.dumps({"legacy_system": "personal-agent", "legacy_kind": "research_run", "legacy_run_id": "run-123"}),
            ),
        )
        conn.execute(
            """
            INSERT INTO tasks (
                id, title, intent, kind, status, priority, created_at, updated_at, metadata_json
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                "task_legacy",
                "Legacy task",
                "migrate me",
                "task",
                "open",
                3,
                "2026-03-19T00:00:00+00:00",
                "2026-03-19T00:00:00+00:00",
                service.store.dumps({"legacy_kind": "task", "legacy_run_id": "run-123"}),
            ),
        )
        conn.execute(
            """
            CREATE TABLE approvals (
                id TEXT PRIMARY KEY,
                task_id TEXT NOT NULL,
                kind TEXT NOT NULL,
                status TEXT NOT NULL,
                risk_level TEXT NOT NULL,
                payload_json TEXT NOT NULL,
                resolution_note TEXT,
                requested_at TEXT NOT NULL,
                resolved_at TEXT,
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL
            )
            """
        )
        conn.execute(
            """
            INSERT INTO approvals (
                id, task_id, kind, status, risk_level, payload_json, resolution_note, requested_at, resolved_at, created_at, updated_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                "apr_1",
                "task_legacy",
                "external_action",
                "pending",
                "high",
                service.store.dumps({"target": "vendor"}),
                None,
                "2026-03-19T00:00:00+00:00",
                None,
                "2026-03-19T00:00:00+00:00",
                "2026-03-19T00:00:00+00:00",
            ),
        )
        conn.execute(
            """
            CREATE TABLE handoffs (
                id TEXT PRIMARY KEY,
                task_id TEXT NOT NULL,
                from_agent TEXT NOT NULL,
                to_agent TEXT NOT NULL,
                status TEXT NOT NULL,
                reason TEXT NOT NULL,
                payload_json TEXT NOT NULL,
                result_summary TEXT,
                error_message TEXT,
                created_at TEXT NOT NULL,
                updated_at TEXT NOT NULL,
                completed_at TEXT,
                metadata_json TEXT NOT NULL
            )
            """
        )
        conn.execute(
            """
            INSERT INTO handoffs (
                id, task_id, from_agent, to_agent, status, reason, payload_json, result_summary, error_message,
                created_at, updated_at, completed_at, metadata_json
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            """,
            (
                "hof_1",
                "task_legacy",
                "personal-agent",
                "code",
                "completed",
                "Need repo work",
                service.store.dumps({"idempotency_key": "task_legacy"}),
                "done",
                None,
                "2026-03-19T00:00:00+00:00",
                "2026-03-19T00:00:00+00:00",
                "2026-03-19T01:00:00+00:00",
                service.store.dumps({}),
            ),
        )

    migrated = service.migrate_v2()
    audit = service.audit_v2()
    run = service.get_memory("legacy_run")
    task = service.get_task("task_legacy")
    artifacts = service.list_artifacts(task_id="task_legacy", limit=10)

    assert migrated["after"]["legacy_memory_rows"] == 0
    assert migrated["after"]["legacy_task_rows"] == 0
    assert audit["approvals"] == 0
    assert audit["handoffs"] == 0
    assert run["subtype"] == "research_run"
    assert run["run_id"] == "run-123"
    assert run["metadata"]["goal"] == "Legacy cleanup"
    assert task["schema_version"] == SCHEMA_VERSION
    assert task["run_id"] == "run-123"
    assert len(artifacts) == 2


edit

                if parsed.path == "/api/search":
                    search_query = _first(query, "q")
                    if search_query is None:
                        self.send_error(HTTPStatus.BAD_REQUEST, "Missing query parameter 'q'")
                        return
                    scopes = [scope.strip() for scope in query.get("scope", []) if scope.strip()]
                    filters = {
                        key: value
                        for key, value in {
                            "type": _first(query, "type"),
                            "subtype": _first(query, "subtype"),
                            "project_id": _first(query, "project_id"),
                            "repo_id": _first(query, "repo_id"),
                            "source_ref": _first(query, "source_ref"),
                            "evidence_ref": _first(query, "evidence_ref"),
                            "run_id": _first(query, "run_id"),
                            "task_id": _first(query, "task_id"),
                            "origin_agent": _first(query, "origin_agent"),
                            "url": _first(query, "url"),
                            "domain": _first(query, "domain"),
                        }.items()
                        if value is not None
                    }
                    self._send_json(
                        service.search(
                            search_query,
                            scopes or None,
                            filters=filters or None,
                            limit=_first_int(query, "limit", 10),
                            include_inbox=_parse_bool(_first(query, "include_inbox")),
                        )
                    )
                    return

                if parsed.path == "/api/search":
                    memory_id = _first(query, "id")
                    search_query = _first(query, "q")
                    if memory_id is not None:
                        self._send_json({"retrieval_id": memory_id, "results": [service.get_memory(memory_id)]})
                        return
                    if search_query is None:
                        self.send_error(HTTPStatus.BAD_REQUEST, "Missing query parameter 'q' or 'id'")
                        return
                    scopes = [scope.strip() for scope in query.get("scope", []) if scope.strip()]
                    filters = {
                        key: value
                        for key, value in {
                            "type": _first(query, "type"),
                            "subtype": _first(query, "subtype"),
                            "project_id": _first(query, "project_id"),
                            "repo_id": _first(query, "repo_id"),
                            "source_ref": _first(query, "source_ref"),
                            "evidence_ref": _first(query, "evidence_ref"),
                            "run_id": _first(query, "run_id"),
                            "task_id": _first(query, "task_id"),
                            "origin_agent": _first(query, "origin_agent"),
                            "url": _first(query, "url"),
                            "domain": _first(query, "domain"),
                        }.items()
                        if value is not None
                    }
                    self._send_json(
                        service.search(
                            search_query,
                            scopes or None,
                            filters=filters or None,
                            limit=_first_int(query, "limit", 10),
                            include_inbox=_parse_bool(_first(query, "include_inbox")),
                        )
                    )
                    return


Successfully replaced 1 block(s) in /mnt/rpi/agents-database/src/shared_agent_memory/http_api.py.

     ...
  68                         payload["audit_error"] = str(exc)
  69                     self._send_json(payload)
  70                     return
  71                 if parsed.path == "/api/search":
+ 72                     memory_id = _first(query, "id")
  72                     search_query = _first(query, "q")
+ 74                     if memory_id is not None:
+ 75                         self._send_json({"retrieval_id": memory_id, "results": [service.get_memory(memory_id)]})
+ 76                         return
  73                     if search_query is None:
- 74                         self.send_error(HTTPStatus.BAD_REQUEST, "Missing query parameter 'q'")
+ 78                         self.send_error(HTTPStatus.BAD_REQUEST, "Missing query parameter 'q' or 'id'")
  75                         return
  76                     scopes = [scope.strip() for scope in query.get("scope", []) if scope.strip()]
  77                     filters = {
  78                         key: value
     ...


def test_http_api_supports_search_by_id(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    service.create_memory(
        {
            "id": "mem_lookup_1",
            "type": "artifact",
            "scope": "global",
            "title": "Lookup memory",
            "content": "Direct id lookup should work.",
        }
    )
    server, base_url = _start_api(service)
    try:
        status_code, payload = _request_json(f"{base_url}/api/search?id=mem_lookup_1")
        assert status_code == 200
        assert payload["retrieval_id"] == "mem_lookup_1"
        assert payload["results"][0]["id"] == "mem_lookup_1"
    finally:
        server.shutdown()
        server.server_close()

def test_http_api_rejects_missing_search_query(tmp_path: Path) -> None:
    service = make_service(tmp_path)
    server, base_url = _start_api(service)
    try:
        try:
            _request_json(f"{base_url}/api/search")
        except HTTPError as exc:
            assert exc.code == 400
        else:
            raise AssertionError("Expected HTTP 400 for missing query")
    finally:
        server.shutdown()
        server.server_close()


Successfully replaced 1 block(s) in /mnt/rpi/agents-database/tests/test_memory_service.py.

     ...
 275         server.shutdown()
 276         server.server_close()
 277
 278
+279 def test_http_api_supports_search_by_id(tmp_path: Path) -> None:
+280     service = make_service(tmp_path)
+281     service.create_memory(
+282         {
+283             "id": "mem_lookup_1",
+284             "type": "artifact",
+285             "scope": "global",
+286             "title": "Lookup memory",
+287             "content": "Direct id lookup should work.",
+288         }
+289     )
+290     server, base_url = _start_api(service)
+291     try:
+292         status_code, payload = _request_json(f"{base_url}/api/search?id=mem_lookup_1")
+293         assert status_code == 200
+294         assert payload["retrieval_id"] == "mem_lookup_1"
+295         assert payload["results"][0]["id"] == "mem_lookup_1"
+296     finally:
+297         server.shutdown()
+298         server.server_close()
+299
+300
 279 def test_http_api_rejects_missing_search_query(tmp_path: Path) -> None:
 280     service = make_service(tmp_path)
 281     server, base_url = _start_api(service)
 282     try:
     ...


- `GET /api/search?q=<query>&scope=global&scope=project&limit=10`
- `GET /api/search?id=<memory_id>`

## Gotchas

- This service is the correct remote boundary for shared memory. Avoid routing memory CRUD/search through `personal-agent` unless the runtime actually owns that behavior.
- The existing `SERVICES-ARCHITECTURE.md` on the server may be stale relative to live routing. Verify real HTTP reachability with `curl`.

## Gotchas

- This service is the correct remote boundary for shared memory. Avoid routing memory CRUD/search through `personal-agent` unless the runtime actually owns that behavior.
- `/api/search?id=<memory_id>` is a direct lookup shortcut that returns one memory in `results`.
- The existing `SERVICES-ARCHITECTURE.md` on the server may be stale relative to live routing. Verify real HTTP reachability with `curl`.


Successfully replaced 2 block(s) in /mnt/rpi/agents-database/docs/http-api.md.

    ...
  9 ## Endpoints
 10
 11 - `GET /api/status`
 12 - `GET /api/search?q=<query>&scope=global&scope=project&limit=10`
+13 - `GET /api/search?id=<memory_id>`
 13 - `GET /api/context?project=<id>&repo=<id>&task=<id>`
 14 - `GET /api/memories?...filters...`
 15 - `GET /api/memories/<id>`
 16 - `POST /api/ingest`
    ...
 44
 45 ## Gotchas
 46
 47 - This service is the correct remote boundary for shared memory. Avoid routing memory CRUD/search through `personal-agent` unless the runtime actually owns that behavior.
+49 - `/api/search?id=<memory_id>` is a direct lookup shortcut that returns one memory in `results`.
 48 - The existing `SERVICES-ARCHITECTURE.md` on the server may be stale relative to live routing. Verify real HTTP reachability with `curl`.


bash

cd /mnt/rpi/agents-database && pytest -q tests/test_memory_service.py


/bin/bash: line 1: pytest: command not found

Command exited with code 127


**Considering testing tools**

I'm thinking about whether to use Python's `pytest` for testing. It seems like a common choice for running tests, but I wonder if there are other tools that might be better suited for my current project. There's always a chance I haven't considered something that could be more efficient. I need to weigh the pros and cons of using `pytest` versus other options. Let’s see where this leads!

cd /mnt/rpi/agents-database && python3 -m pytest -q tests/test_memory_service.py


/usr/bin/python3: No module named pytest

Command exited with code 1


**Considering potential obstacles**

I might need to use UV—it's just a thought, but I can’t run it just yet. It feels like I should check that out. Inspecting the git diff is also something I might consider doing. Hopefully, this will help clarify what’s going on and resolve any concerns I have! Let’s see if that approach leads to a solution or if I need to rethink things a bit more.

cd /mnt/rpi/agents-database && git diff -- src/shared_agent_memory/http_api.py tests/test_memory_service.py docs/http-api.md


diff --git a/tests/test_memory_service.py b/tests/test_memory_service.py
index 8feda36..07cb52a 100644
--- a/tests/test_memory_service.py
+++ b/tests/test_memory_service.py
@@ -1,8 +1,15 @@
 from __future__ import annotations

+import json
+import threading
 from pathlib import Path
+from urllib.error import HTTPError
+from urllib.request import Request, urlopen
+
+from http.server import ThreadingHTTPServer

 from shared_agent_memory import MemoryEngine, MemoryService
+from shared_agent_memory.http_api import build_handler
 from shared_agent_memory.schema import SCHEMA_VERSION

@@ -10,6 +17,23 @@ def make_service(tmp_path: Path) -> MemoryService:
     return MemoryService(str(tmp_path / "memory.sqlite3"))

+def _request_json(url: str, *, method: str = "GET", payload: dict | None = None) -> tuple[int, dict]:
+    body = None if payload is None else json.dumps(payload).encode("utf-8")
+    request = Request(url, data=body, method=method)
+    if body is not None:
+        request.add_header("Content-Type", "application/json")
+    with urlopen(request) as response:  # noqa: S310 - local test server only
+        return response.status, json.loads(response.read().decode("utf-8"))
+
+
+def _start_api(service: MemoryService) -> tuple[ThreadingHTTPServer, str]:
+    server = ThreadingHTTPServer(("127.0.0.1", 0), build_handler(service))
+    thread = threading.Thread(target=server.serve_forever, daemon=True)
+    thread.start()
+    host, port = server.server_address
+    return server, f"http://{host}:{port}"
+
+
 def test_manual_profile_fact_is_immediately_searchable(tmp_path: Path) -> None:
     service = make_service(tmp_path)
     service.upsert_profile_fact("Communication", "Sebas prefers concise, direct communication.")
@@ -101,7 +125,7 @@ def test_export_import_preserves_operational_records_without_approvals_or_handof
         owner_agent="personal-agent",
         run_id="run_alpha",
     )
-    service.start_task_run(task["id"], "personal-agent", input_payload={"mode": "codex"})
+    service.start_task_run(task["id"], "personal-agent", input_payload={"mode": "runner"})
     service.create_artifact(
         task_id=task["id"],
         artifact_type="brief",
@@ -176,6 +200,119 @@ def test_search_works_without_embeddings(tmp_path: Path) -> None:
     assert results["results"][0]["memory"]["subtype"] == "research_claim"

+def test_http_api_supports_status_ingest_and_search(tmp_path: Path) -> None:
+    service = make_service(tmp_path)
+    server, base_url = _start_api(service)
+    try:
+        status_code, status_payload = _request_json(f"{base_url}/api/status")
+        assert status_code == 200
+        assert status_payload["ok"] is True
+
+        created_code, created_payload = _request_json(
+            f"{base_url}/api/ingest",
+            method="POST",
+            payload={
+                "id": "mem_http_1",
+                "type": "artifact",
+                "subtype": "research_claim",
+                "scope": "global",
+                "title": "HTTP memory",
+                "content": "Remote API keeps shared memory reachable.",
+                "status": "active",
+            },
+        )
+        assert created_code == 201
+        assert created_payload["id"] == "mem_http_1"
+
+        search_code, search_payload = _request_json(f"{base_url}/api/search?q=reachable&scope=global")
+        assert search_code == 200
+        assert search_payload["results"]
+        assert search_payload["results"][0]["memory"]["id"] == "mem_http_1"
+    finally:
+        server.shutdown()
+        server.server_close()
+
+
+def test_http_api_supports_tasks_runs_and_bundles(tmp_path: Path) -> None:
+    service = make_service(tmp_path)
+    server, base_url = _start_api(service)
+    try:
+        task_code, task = _request_json(
+            f"{base_url}/api/tasks",
+            method="POST",
+            payload={
+                "task_id": "task_http_1",
+                "title": "Remote task",
+                "intent": "Keep task state remote.",
+                "owner_agent": "personal-agent",
+            },
+        )
+        assert task_code == 201
+        assert task["id"] == "task_http_1"
+
+        run_code, run = _request_json(
+            f"{base_url}/api/task-runs",
+            method="POST",
+            payload={"task_id": task["id"], "agent_id": "personal-agent", "input_payload": {"mode": "remote"}},
+        )
+        assert run_code == 201
+        assert run["task_id"] == task["id"]
+
+        patch_code, patched = _request_json(
+            f"{base_url}/api/tasks/{task['id']}",
+            method="PATCH",
+            payload={"status": "in_progress", "run_id": run["id"]},
+        )
+        assert patch_code == 200
+        assert patched["status"] == "in_progress"
+        assert patched["run_id"] == run["id"]
+
+        bundle_code, bundle = _request_json(f"{base_url}/api/tasks/{task['id']}/bundle")
+        assert bundle_code == 200
+        assert bundle["task"]["id"] == task["id"]
+        assert bundle["runs"][0]["id"] == run["id"]
+    finally:
+        server.shutdown()
+        server.server_close()
+
+
+def test_http_api_supports_search_by_id(tmp_path: Path) -> None:
+    service = make_service(tmp_path)
+    service.create_memory(
+        {
+            "id": "mem_lookup_1",
+            "type": "artifact",
+            "scope": "global",
+            "title": "Lookup memory",
+            "content": "Direct id lookup should work.",
+        }
+    )
+    server, base_url = _start_api(service)
+    try:
+        status_code, payload = _request_json(f"{base_url}/api/search?id=mem_lookup_1")
+        assert status_code == 200
+        assert payload["retrieval_id"] == "mem_lookup_1"
+        assert payload["results"][0]["id"] == "mem_lookup_1"
+    finally:
+        server.shutdown()
+        server.server_close()
+
+
+def test_http_api_rejects_missing_search_query(tmp_path: Path) -> None:
+    service = make_service(tmp_path)
+    server, base_url = _start_api(service)
+    try:
+        try:
+            _request_json(f"{base_url}/api/search")
+        except HTTPError as exc:
+            assert exc.code == 400
+        else:
+            raise AssertionError("Expected HTTP 400 for missing query")
+    finally:
+        server.shutdown()
+        server.server_close()
+
+
 def test_maintenance_jobs_exist_by_default(tmp_path: Path) -> None:
     service = make_service(tmp_path)

@@ -202,6 +339,34 @@ def test_maintenance_tick_records_run(tmp_path: Path) -> None:
     assert runs

+def test_service_startup_runs_due_maintenance(tmp_path: Path) -> None:
+    db_path = tmp_path / "memory.sqlite3"
+    service = MemoryService(str(db_path))
+    now = "2026-03-20T10:00:00+00:00"
+
+    service.update_maintenance_job("job_consolidation_daily_10", next_due_at=now)
+    restarted = MemoryService(str(db_path))
+    job = restarted.get_maintenance_job("job_consolidation_daily_10")
+    runs = restarted.list_maintenance_runs(job_id="job_consolidation_daily_10")
+
+    assert job["last_status"] == "completed"
+    assert runs
+
+
+def test_write_operations_trigger_due_maintenance_check(tmp_path: Path) -> None:
+    service = make_service(tmp_path)
+    now = "2026-03-20T10:00:00+00:00"
+
+    service.update_maintenance_job("job_consolidation_daily_10", next_due_at=now)
+    service.create_project("proj_alpha", "Alpha")
+
+    job = service.get_maintenance_job("job_consolidation_daily_10")
+    runs = service.list_maintenance_runs(job_id="job_consolidation_daily_10")
+
+    assert job["last_status"] == "completed"
+    assert runs
+
+
 def test_conflicts_only_for_selected_types(tmp_path: Path) -> None:
     service = make_service(tmp_path)
     service.create_memory(


**Evaluating changes and documentation**

I see there are significant differences in the file due to unrelated changes. I’m considering reverting those unrelated changes, but the user asked about the agents-database, and there might have been preexisting modifications I shouldn't touch. I should note that tests can’t run because of a missing pytest. Also, I might need to adjust the documentation to mention the actual endpoint or add /api/search?id. Additionally, I should mention that the current branch is dirty. Just finalizing this concisely!

Hecho en `agents-database`.

Cambio:
- `GET /api/search?id=<memory_id>` ahora hace lookup directo por id
- si viene `id`, no exige `q`
- si faltan ambos, sigue devolviendo 400

Docs actualizadas:
- `docs/http-api.md`

Test agregado:
- `test_http_api_supports_search_by_id`

No pude correr tests acá:
- falta `pytest` en el entorno

Nota:
- en `ballbox` ya había cambios sucios previos; no los toqué.


que hay? veamos si hay que hacer commit


cd /mnt/rpi/agents-database && git status --short && echo '---' && git diff --stat


 M .gitignore
 M data/shared-agent-memory.sqlite3
 M docs/memory-consolidation-rfc.md
 M docs/system-v1.md
 M scripts/run-maintenance-daemon.sh
 M src/shared_agent_memory/cli.py
 M src/shared_agent_memory/service.py
 M tests/test_memory_service.py
?? data/shared-agent-memory.pre-repair-20260419-185338.sqlite3
?? data/shared-agent-memory.rebuild-20260419.sqlite3
?? data/shared-agent-memory.recovered-20260419-184949.sqlite3
?? data/shared-agent-memory.recovery-dump.sql
?? data/shared-agent-memory.recovery-work.sqlite3
?? data/shared-agent-memory.sqlite3.bak-20260419-184938
?? docs/http-api.md
?? scripts/run-http-api.sh
?? src/shared_agent_memory/http_api.py
?? systemd/
---
 .gitignore                         |   3 +
 data/shared-agent-memory.sqlite3   | Bin 5517312 -> 108167168 bytes
 docs/memory-consolidation-rfc.md   |  36 +++++---
 docs/system-v1.md                  |  36 +++++++-
 scripts/run-maintenance-daemon.sh  |   9 +-
 src/shared_agent_memory/cli.py     |   7 ++
 src/shared_agent_memory/service.py |  28 +++++++
 tests/test_memory_service.py       | 167 ++++++++++++++++++++++++++++++++++++-
 8 files changed, 271 insertions(+), 15 deletions(-)


Hay bastante sucio ya en `agents-database`.

Lo tuyo recién agregado:
- `docs/http-api.md`
- `src/shared_agent_memory/http_api.py`
- cambios en `tests/test_memory_service.py`

Pero antes ya había cambios grandes sin commitear:
- `.gitignore`
- `data/shared-agent-memory.sqlite3`
- `docs/memory-consolidation-rfc.md`
- `docs/system-v1.md`
- `scripts/run-maintenance-daemon.sh`
- `src/shared_agent_memory/cli.py`
- `src/shared_agent_memory/service.py`
- `systemd/`
- varios `.sqlite3` de recovery

Conclusión:
- sí, hay para commitear
- pero no conviene mezclar este fix chico con el lote grande de reparación/mantenimiento sin mirar

Siguiente paso recomendado:
1. separar commit del fix de `api/search?id=...`
2. dejar el resto en otro commit o revisarlo aparte

Si querés, te preparo el diff exacto para un commit chico limpio.


creo que alguien lo commiteo, fijate


cd /mnt/rpi/agents-database && git log --oneline --decorate -n 8 && echo '---REMOTE---' && git log --oneline --decorate origin/main -n 8 && echo '---BRANCHES---' && git branch -avv


6a12638 (HEAD -> main) feat: add HTTP API and maintenance daemon support
d9fad54 (origin/main, origin/HEAD) docs: add launchd template and daemon runner
3894ec8 feat: add maintenance daemon and config CLI
70cfc5a test: cover maintenance scheduler and conflict gating
a600b1c feat: add maintenance scheduler and consolidation pipeline
8144072 Cut shared memory v2 contract
ba42677 Feat: add shared memory operational task model (#1)
c7ee8c4 Update shared memory with personal profile details
---REMOTE---
d9fad54 (origin/main, origin/HEAD) docs: add launchd template and daemon runner
3894ec8 feat: add maintenance daemon and config CLI
70cfc5a test: cover maintenance scheduler and conflict gating
a600b1c feat: add maintenance scheduler and consolidation pipeline
8144072 Cut shared memory v2 contract
ba42677 Feat: add shared memory operational task model (#1)
c7ee8c4 Update shared memory with personal profile details
5daa3c1 Update shared memory database
---BRANCHES---
* main                                     6a12638 [origin/main: ahead 1] feat: add HTTP API and maintenance daemon support
  remotes/origin/HEAD                      -> origin/main
  remotes/origin/feat/shared-memory-v1-ops d79a59e Cut shared memory v2 contract
  remotes/origin/main                      d9fad54 docs: add launchd template and daemon runner


**Inspecting commit details**

I need to respond directly regarding the local commit. It seems like there's a chance that our changes might be included in the latest commit. I should check the diff of the last commit to see if these changes were already made by someone else. It’s important to ensure I'm on the right track before moving forward! Let's dive into that diff and confirm.

cd /mnt/rpi/agents-database && git show --stat --summary --oneline --decorate HEAD && echo '---FILES---' && git show --name-only --format=fuller HEAD | sed -n '1,80p'


6a12638 (HEAD -> main) feat: add HTTP API and maintenance daemon support
 .gitignore                                       |   3 +
 data/shared-agent-memory.sqlite3                 | Bin 5517312 -> 108167168 bytes
 docs/http-api.md                                 |  50 +++
 docs/memory-consolidation-rfc.md                 |  36 ++-
 docs/system-v1.md                                |  36 ++-
 scripts/run-http-api.sh                          |  16 +
 scripts/run-maintenance-daemon.sh                |   9 +-
 src/shared_agent_memory/cli.py                   |   7 +
 src/shared_agent_memory/http_api.py              | 367 +++++++++++++++++++++++
 src/shared_agent_memory/service.py               |  28 ++
 systemd/user/agents-database-http.service        |  17 ++
 systemd/user/agents-database-maintenance.service |  16 +
 tests/test_memory_service.py                     | 167 ++++++++++-
 13 files changed, 737 insertions(+), 15 deletions(-)
 create mode 100644 docs/http-api.md
 create mode 100755 scripts/run-http-api.sh
 mode change 100644 => 100755 scripts/run-maintenance-daemon.sh
 create mode 100644 src/shared_agent_memory/http_api.py
 create mode 100644 systemd/user/agents-database-http.service
 create mode 100644 systemd/user/agents-database-maintenance.service
---FILES---
commit 6a12638ea3c9147cdf553bca8743fe650e8cdfcf
Author:     Sebastian Favaron <sebastianfavaron@gmail.com>
AuthorDate: Wed Apr 22 11:24:52 2026 -0300
Commit:     Sebastian Favaron <sebastianfavaron@gmail.com>
CommitDate: Wed Apr 22 11:24:52 2026 -0300

    feat: add HTTP API and maintenance daemon support

.gitignore
data/shared-agent-memory.sqlite3
docs/http-api.md
docs/memory-consolidation-rfc.md
docs/system-v1.md
scripts/run-http-api.sh
scripts/run-maintenance-daemon.sh
src/shared_agent_memory/cli.py
src/shared_agent_memory/http_api.py
src/shared_agent_memory/service.py
systemd/user/agents-database-http.service
systemd/user/agents-database-maintenance.service
tests/test_memory_service.py


**Checking commitment status**

I need to explain whether I'm committed to the current task. I'm wondering if the change has actually been committed, so I should check the status now. Maybe I need to ensure everything is clean before I finalize anything. It’s crucial to have all details in order before moving ahead, so I’ll focus on confirming that everything is as needed. Let's make sure everything is set before wrapping things up!

Sí. Ya está commiteado.

Commit local:
- `6a12638 feat: add HTTP API and maintenance daemon support`

Incluye:
- `src/shared_agent_memory/http_api.py`
- `docs/http-api.md`
- `tests/test_memory_service.py`
- y otros cambios grandes

Estado:
- `main` está **ahead 1** de `origin/main`
- no está pusheado

O sea:
- el fix de `api/search?id=...` quedó dentro de ese commit local, no suelto
- el repo quedó bastante más amplio que solo ese fix

Si querés, ahora miro si conviene:
1. dejar ese commit así y pusharlo después, o
2. separar el fix en otro commit más chico.