yagami 0.4.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. yagami/__init__.py +1 -0
  2. yagami/api/__init__.py +0 -0
  3. yagami/api/config.py +118 -0
  4. yagami/api/costs.py +22 -0
  5. yagami/api/decisions.py +78 -0
  6. yagami/api/ingest.py +55 -0
  7. yagami/api/kb.py +55 -0
  8. yagami/api/mcp.py +21 -0
  9. yagami/api/memory.py +124 -0
  10. yagami/api/openai_compat.py +980 -0
  11. yagami/api/privacy.py +55 -0
  12. yagami/api/sessions.py +70 -0
  13. yagami/api/stats.py +98 -0
  14. yagami/auth.py +197 -0
  15. yagami/backends/__init__.py +0 -0
  16. yagami/backends/anthropic.py +166 -0
  17. yagami/backends/base.py +89 -0
  18. yagami/backends/echo.py +31 -0
  19. yagami/backends/gemini.py +45 -0
  20. yagami/backends/groq.py +38 -0
  21. yagami/backends/llama_cpp.py +104 -0
  22. yagami/backends/mistral.py +46 -0
  23. yagami/backends/ollama.py +72 -0
  24. yagami/backends/openai.py +139 -0
  25. yagami/backends/openai_compat.py +131 -0
  26. yagami/backends/openrouter.py +48 -0
  27. yagami/backends/registry.py +86 -0
  28. yagami/backends/retry.py +82 -0
  29. yagami/backends/stability.py +66 -0
  30. yagami/chat/__init__.py +0 -0
  31. yagami/chat/session.py +140 -0
  32. yagami/chat/stream.py +583 -0
  33. yagami/cli.py +102 -0
  34. yagami/config.py +364 -0
  35. yagami/doctor.py +51 -0
  36. yagami/gateway/__init__.py +17 -0
  37. yagami/gateway/service.py +968 -0
  38. yagami/generate_key.py +11 -0
  39. yagami/governance/__init__.py +26 -0
  40. yagami/governance/approvals.py +195 -0
  41. yagami/governance/lineage.py +168 -0
  42. yagami/governance/output.py +32 -0
  43. yagami/governance/transform.py +263 -0
  44. yagami/ingest/__init__.py +0 -0
  45. yagami/ingest/extract.py +85 -0
  46. yagami/main.py +356 -0
  47. yagami/memory/__init__.py +8 -0
  48. yagami/memory/chunker.py +93 -0
  49. yagami/memory/documents.py +235 -0
  50. yagami/memory/embedder.py +57 -0
  51. yagami/memory/retriever.py +189 -0
  52. yagami/memory/store.py +168 -0
  53. yagami/memory/worker.py +99 -0
  54. yagami/policy/__init__.py +23 -0
  55. yagami/policy/engine.py +215 -0
  56. yagami/policy/models.py +147 -0
  57. yagami/policy/replay.py +101 -0
  58. yagami/privacy.py +129 -0
  59. yagami/projects.py +134 -0
  60. yagami/router/__init__.py +0 -0
  61. yagami/router/classifier.py +124 -0
  62. yagami/router/fast_path.py +202 -0
  63. yagami/router/overrides.py +66 -0
  64. yagami/router/policy.py +484 -0
  65. yagami/router/prompts.py +47 -0
  66. yagami/router/schema.py +41 -0
  67. yagami/router/tool_loop.py +240 -0
  68. yagami/runtime.py +33 -0
  69. yagami/secrets.py +54 -0
  70. yagami/set_key.py +35 -0
  71. yagami/skills/__init__.py +5 -0
  72. yagami/skills/adapters.py +32 -0
  73. yagami/skills/base.py +48 -0
  74. yagami/skills/calc_eval.py +117 -0
  75. yagami/skills/kb_recall.py +62 -0
  76. yagami/skills/mcp_auth.py +113 -0
  77. yagami/skills/mcp_manager.py +228 -0
  78. yagami/skills/registry.py +46 -0
  79. yagami/skills/web_fetch.py +150 -0
  80. yagami/storage/__init__.py +0 -0
  81. yagami/storage/db.py +148 -0
  82. yagami/storage/migrations/001_init.sql +30 -0
  83. yagami/storage/migrations/002_decision_timings.sql +3 -0
  84. yagami/storage/migrations/003_costs.sql +3 -0
  85. yagami/storage/migrations/004_feedback.sql +9 -0
  86. yagami/storage/migrations/005_observations.sql +57 -0
  87. yagami/storage/migrations/006_decision_profile.sql +4 -0
  88. yagami/storage/migrations/007_kb_documents.sql +40 -0
  89. yagami/storage/migrations/008_message_attachments.sql +15 -0
  90. yagami/storage/migrations/009_gateway_policy.sql +13 -0
  91. yagami/storage/migrations/010_privacy_transform_vault.sql +15 -0
  92. yagami/storage/migrations/011_audit_chain.sql +16 -0
  93. yagami/storage/migrations/012_tool_approvals.sql +18 -0
  94. yagami/telemetry/__init__.py +0 -0
  95. yagami/telemetry/audit.py +169 -0
  96. yagami/telemetry/costs.py +101 -0
  97. yagami/telemetry/decisions.py +267 -0
  98. yagami/telemetry/observability.py +84 -0
  99. yagami-0.4.1.dist-info/METADATA +749 -0
  100. yagami-0.4.1.dist-info/RECORD +103 -0
  101. yagami-0.4.1.dist-info/WHEEL +4 -0
  102. yagami-0.4.1.dist-info/entry_points.txt +3 -0
  103. yagami-0.4.1.dist-info/licenses/LICENSE +21 -0
yagami/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.4.1"
yagami/api/__init__.py ADDED
File without changes
yagami/api/config.py ADDED
@@ -0,0 +1,118 @@
1
+ """Read + write `config/yagami.toml` from the browser.
2
+
3
+ GET returns the current config plus a `defaults` snapshot so the UI can
4
+ show "reset to default" affordances. Secrets are never in this file (they
5
+ live in the OS keyring) - there is nothing to redact, but if the config
6
+ schema ever grows a secret-like field, redact it here before returning.
7
+
8
+ PUT accepts a partial JSON patch (any subset of YagamiConfig fields).
9
+ Validates against the full pydantic schema after merging. On success,
10
+ writes the file, invalidates the get_config LRU cache, pushes the new
11
+ effective RoutingConfig into the live RoutingPolicy (see set_policy /
12
+ RoutingPolicy.update_config), and returns the new config. routing.* changes
13
+ (default backend, spend cap, threshold, active profile) apply on the very
14
+ next turn, no restart. Backend model/URL/API-key changes still need a
15
+ process restart - those backends were constructed once at boot.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from fastapi import APIRouter, HTTPException
21
+ from pydantic import BaseModel, ValidationError
22
+
23
+ from ..config import YagamiConfig, effective_routing, get_config, write_config
24
+ from ..router.policy import RoutingPolicy
25
+ from ..router.prompts import PHI_MEDICAL_SYSTEM_PROMPT, PHI_SYSTEM_PROMPT
26
+
27
+ router = APIRouter(prefix="/api/config", tags=["config"])
28
+
29
+ # Set once at app startup (main.build_app) via set_policy() - same pattern as
30
+ # sessions_api.set_store(). Lets a live PUT /api/config (including a profile
31
+ # switch) take effect on the policy's next decide() call without a restart.
32
+ _policy: RoutingPolicy | None = None
33
+
34
+
35
+ def set_policy(policy: RoutingPolicy) -> None:
36
+ global _policy
37
+ _policy = policy
38
+
39
+
40
+ def _config_payload() -> dict:
41
+ cfg = get_config()
42
+ return {
43
+ "config": cfg.model_dump(mode="json"),
44
+ "defaults": YagamiConfig().model_dump(mode="json"),
45
+ "prompts": {
46
+ "phi_default": PHI_SYSTEM_PROMPT,
47
+ "phi_medical_default": PHI_MEDICAL_SYSTEM_PROMPT,
48
+ },
49
+ "notes": {
50
+ "phi_must_be_local": (
51
+ "Locked on. Disabling would let PHI prompts reach cloud "
52
+ "backends - defeats the local-first guarantee."
53
+ ),
54
+ "live_reload": (
55
+ "Routing settings (default_backend, daily_spend_cap_usd, "
56
+ "long_message_token_threshold, active_profile) take effect on "
57
+ "the next turn. Backend model name / URL changes require "
58
+ "restarting uvicorn."
59
+ ),
60
+ "storage_encryption": (
61
+ "Yagami does not apply application-level database encryption. "
62
+ "Use BitLocker, FileVault, or full-disk encryption to protect "
63
+ "the local database and saved image attachments at rest."
64
+ ),
65
+ },
66
+ }
67
+
68
+
69
+ @router.get("")
70
+ async def get_config_endpoint() -> dict:
71
+ return _config_payload()
72
+
73
+
74
+ class ConfigPatch(BaseModel):
75
+ """Loose patch shape - any field is optional. The server merges and
76
+ re-validates against the full YagamiConfig pydantic model before
77
+ persisting.
78
+
79
+ `profiles` is a shallow merge like every other section: PUT-ing
80
+ `{"profiles": {"work": {...}}}` replaces the *entire* "work" entry, it
81
+ doesn't deep-merge individual override fields into an existing one. Send
82
+ the full profile body each time.
83
+ """
84
+
85
+ ollama: dict | None = None
86
+ anthropic: dict | None = None
87
+ stability: dict | None = None
88
+ openai: dict | None = None
89
+ mistral: dict | None = None
90
+ groq: dict | None = None
91
+ openrouter: dict | None = None
92
+ gemini: dict | None = None
93
+ routing: dict | None = None
94
+ profiles: dict[str, dict] | None = None
95
+ privacy: dict | None = None
96
+
97
+
98
+ @router.put("")
99
+ async def put_config(patch: ConfigPatch) -> dict:
100
+ cfg = get_config()
101
+ merged = cfg.model_dump(mode="json")
102
+ for section, body in patch.model_dump(exclude_none=True).items():
103
+ merged.setdefault(section, {}).update(body)
104
+
105
+ # Always pin phi_must_be_local on - defense in depth in case the UI
106
+ # somehow PUTs it false despite the locked toggle. No profile can touch
107
+ # this either - see ProfileOverrides.
108
+ merged.setdefault("routing", {})["phi_must_be_local"] = True
109
+
110
+ try:
111
+ new_cfg = YagamiConfig.model_validate(merged)
112
+ except ValidationError as exc:
113
+ raise HTTPException(422, exc.errors())
114
+
115
+ path = write_config(new_cfg)
116
+ if _policy is not None:
117
+ _policy.update_config(effective_routing(new_cfg))
118
+ return {"ok": True, "path": str(path), **_config_payload()}
yagami/api/costs.py ADDED
@@ -0,0 +1,22 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import APIRouter, Query
4
+
5
+ from ..config import effective_routing, get_config
6
+ from ..telemetry.costs import spend_session_usd, spend_today_usd
7
+
8
+ router = APIRouter(prefix="/api/costs", tags=["costs"])
9
+
10
+
11
+ @router.get("")
12
+ async def costs(session_id: str | None = Query(default=None)) -> dict:
13
+ today = await spend_today_usd()
14
+ session = await spend_session_usd(session_id) if session_id else 0.0
15
+ cap = effective_routing(get_config()).daily_spend_cap_usd
16
+ return {
17
+ "today_usd": round(today, 4),
18
+ "session_usd": round(session, 4),
19
+ "daily_cap_usd": cap,
20
+ "cap_remaining_usd": max(0.0, cap - today) if cap > 0 else None,
21
+ "cap_exceeded": cap > 0 and today >= cap,
22
+ }
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+
6
+ from fastapi import APIRouter, HTTPException, Query, Response
7
+ from pydantic import BaseModel
8
+
9
+ from ..storage.db import get_db
10
+ from ..telemetry.decisions import export_decisions_csv, list_decisions
11
+
12
+ router = APIRouter(prefix="/api/decisions", tags=["decisions"])
13
+
14
+
15
+ @router.get("")
16
+ async def get_decisions(
17
+ session_id: str | None = Query(default=None),
18
+ limit: int = Query(default=100, ge=1, le=1000),
19
+ ) -> dict:
20
+ rows = await list_decisions(session_id=session_id, limit=limit)
21
+ return {"decisions": rows, "count": len(rows)}
22
+
23
+
24
+ @router.get("/export")
25
+ async def export_decisions(
26
+ session_id: str | None = Query(default=None),
27
+ limit: int = Query(default=10_000, ge=1, le=100_000),
28
+ ) -> Response:
29
+ """Download the Privacy Ledger as CSV - see telemetry/decisions.py for
30
+ what's included. Same scrubbing as the UI ledger view; nothing extra
31
+ leaks through this export."""
32
+ csv_text = await export_decisions_csv(session_id=session_id, limit=limit)
33
+ # session_id is caller-supplied; never interpolate it into a header
34
+ # unsanitized. Session ids are uuid4().hex (see chat/session.py) - a
35
+ # non-matching value just falls back to the unscoped filename.
36
+ suffix = ""
37
+ if session_id and re.fullmatch(r"[a-f0-9]{8,64}", session_id):
38
+ suffix = f"-{session_id}"
39
+ filename = f"yagami-privacy-ledger{suffix}.csv"
40
+ return Response(
41
+ content=csv_text,
42
+ media_type="text/csv",
43
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
44
+ )
45
+
46
+
47
+ class FeedbackPayload(BaseModel):
48
+ rating: int # -1 (thumb down) or 1 (thumb up)
49
+
50
+
51
+ @router.post("/{decision_id}/feedback")
52
+ async def post_feedback(decision_id: int, payload: FeedbackPayload) -> dict:
53
+ if payload.rating not in (-1, 1):
54
+ raise HTTPException(400, "rating must be -1 or 1")
55
+ db = get_db()
56
+ async with db.execute("SELECT 1 FROM decisions WHERE id = ?", (decision_id,)) as cur:
57
+ if await cur.fetchone() is None:
58
+ raise HTTPException(404, f"decision {decision_id} not found")
59
+ # Latest feedback for a decision wins - overwrite any prior row so the
60
+ # user can flip their vote without leaving stale rating data.
61
+ await db.execute("DELETE FROM feedback WHERE decision_id = ?", (decision_id,))
62
+ await db.execute(
63
+ "INSERT INTO feedback (decision_id, rating, created_at) VALUES (?, ?, ?)",
64
+ (decision_id, payload.rating, int(time.time() * 1000)),
65
+ )
66
+ await db.commit()
67
+ return {"ok": True, "decision_id": decision_id, "rating": payload.rating}
68
+
69
+
70
+ @router.delete("/{decision_id}/feedback")
71
+ async def delete_feedback(decision_id: int) -> dict:
72
+ db = get_db()
73
+ async with db.execute("SELECT 1 FROM decisions WHERE id = ?", (decision_id,)) as cur:
74
+ if await cur.fetchone() is None:
75
+ raise HTTPException(404, f"decision {decision_id} not found")
76
+ cur = await db.execute("DELETE FROM feedback WHERE decision_id = ?", (decision_id,))
77
+ await db.commit()
78
+ return {"ok": True, "decision_id": decision_id, "deleted": cur.rowcount > 0}
yagami/api/ingest.py ADDED
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+
5
+ from fastapi import APIRouter, File, HTTPException, UploadFile
6
+
7
+ from ..ingest.extract import extract
8
+
9
+ router = APIRouter(prefix="/api/ingest", tags=["ingest"])
10
+
11
+ _MAX_BYTES = 20 * 1024 * 1024 # 20 MB hard cap
12
+ _IMAGE_MIMES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
13
+
14
+
15
+ @router.post("")
16
+ async def ingest(file: UploadFile = File(...)) -> dict:
17
+ # Read one byte past the limit so oversized uploads are rejected without
18
+ # first copying an arbitrarily large file into process memory.
19
+ blob = await file.read(_MAX_BYTES + 1)
20
+ if len(blob) > _MAX_BYTES:
21
+ raise HTTPException(413, f"file too large ({len(blob)} > {_MAX_BYTES} bytes)")
22
+
23
+ mime = file.content_type or ""
24
+ fname = file.filename or "upload"
25
+ low = fname.lower()
26
+ if mime in _IMAGE_MIMES or low.endswith((".png", ".jpg", ".jpeg", ".gif", ".webp")):
27
+ # Normalize mime if browser was vague
28
+ if low.endswith(".png"):
29
+ mime = "image/png"
30
+ elif low.endswith((".jpg", ".jpeg")):
31
+ mime = "image/jpeg"
32
+ elif low.endswith(".gif"):
33
+ mime = "image/gif"
34
+ elif low.endswith(".webp"):
35
+ mime = "image/webp"
36
+ return {
37
+ "kind": "image",
38
+ "filename": fname,
39
+ "media_type": mime,
40
+ "bytes": len(blob),
41
+ "data_b64": base64.b64encode(blob).decode("ascii"),
42
+ }
43
+
44
+ doc = extract(filename=fname, mime=mime, blob=blob)
45
+ if doc.error:
46
+ raise HTTPException(415, doc.error)
47
+ return {
48
+ "kind": "document",
49
+ "filename": doc.filename,
50
+ "mime": doc.mime,
51
+ "text": doc.text,
52
+ "truncated": doc.truncated,
53
+ "bytes": len(blob),
54
+ "chars": len(doc.text),
55
+ }
yagami/api/kb.py ADDED
@@ -0,0 +1,55 @@
1
+ """Folder-based document knowledge base admin: index a directory, list
2
+ what's indexed, and remove a source. See memory/documents.py for the
3
+ storage/search implementation and skills/kb_recall.py for how the LLM
4
+ queries what's indexed here.
5
+
6
+ Trust note: like `PUT /api/config` (arbitrary TOML rewrite) and the file
7
+ ingest endpoint (arbitrary uploaded file content), this lets anyone who can
8
+ reach the local API make the server read files from disk - `index_folder`
9
+ takes a path and walks it recursively. That's consistent with Yagami's
10
+ existing single-user local-app trust model (bind to 127.0.0.1, as the
11
+ `yagami` CLI already defaults to), not a new category of exposure, but it's
12
+ worth stating plainly: don't expose this port beyond localhost.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pathlib import Path
18
+
19
+ from fastapi import APIRouter, HTTPException
20
+ from pydantic import BaseModel
21
+
22
+ from ..config import get_config
23
+ from ..memory import documents
24
+ from ..memory.embedder import Embedder
25
+
26
+ router = APIRouter(prefix="/api/kb", tags=["kb"])
27
+
28
+
29
+ class IndexRequest(BaseModel):
30
+ path: str
31
+
32
+
33
+ @router.post("/index")
34
+ async def index_folder(req: IndexRequest) -> dict:
35
+ folder = Path(req.path).expanduser()
36
+ if not folder.exists() or not folder.is_dir():
37
+ raise HTTPException(400, f"not a directory: {folder}")
38
+ cfg = get_config()
39
+ embedder = Embedder(url=cfg.ollama.url, model=cfg.memory.embedding_model)
40
+ summary = await documents.index_folder(folder, embedder=embedder)
41
+ return {"ok": True, "folder": str(folder), **summary}
42
+
43
+
44
+ @router.get("")
45
+ async def list_indexed() -> dict:
46
+ sources = await documents.list_sources()
47
+ return {"sources": sources, "count": len(sources)}
48
+
49
+
50
+ @router.delete("/source")
51
+ async def delete_source(path: str) -> dict:
52
+ n = await documents.delete_source(path)
53
+ if n == 0:
54
+ raise HTTPException(404, f"no indexed chunks for {path!r}")
55
+ return {"ok": True, "source_path": path, "deleted_chunks": n}
yagami/api/mcp.py ADDED
@@ -0,0 +1,21 @@
1
+ """Read-only status for connected MCP servers. See skills/mcp_manager.py for
2
+ the actual client/connection logic - this just reports what it found.
3
+ Connection failures are logged at startup and don't crash boot (one bad
4
+ server config shouldn't take down the app), so this endpoint is how you'd
5
+ actually notice one didn't come up.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from fastapi import APIRouter
11
+
12
+ from ..skills.mcp_manager import get_manager
13
+
14
+ router = APIRouter(prefix="/api/mcp", tags=["mcp"])
15
+
16
+
17
+ @router.get("")
18
+ async def mcp_status() -> dict:
19
+ manager = get_manager()
20
+ tools = manager.status() if manager is not None else []
21
+ return {"connected": manager is not None, "tools": tools, "count": len(tools)}
yagami/api/memory.py ADDED
@@ -0,0 +1,124 @@
1
+ """Browser-facing endpoints for the cross-session memory store.
2
+
3
+ GET /api/memory - list recent observations, paginated
4
+ GET /api/memory/search?q=... - keyword search via FTS5 (no embed call needed)
5
+ DELETE /api/memory/{id} - remove a single observation + its vec row
6
+ GET /api/memory/stats - counts by status, total bytes
7
+
8
+ PHI rows are NOT redacted in this endpoint - the user is reviewing their
9
+ OWN memory in their OWN browser, on-device. Surfacing PHI to the user
10
+ is correct; surfacing it to a cloud-text turn is not (the retriever's
11
+ PHI quarantine handles that).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from fastapi import APIRouter, HTTPException, Query
17
+
18
+ from ..memory import store as memory_store
19
+ from ..storage.db import get_db
20
+
21
+ router = APIRouter(prefix="/api/memory", tags=["memory"])
22
+
23
+
24
+ @router.get("")
25
+ async def list_observations(
26
+ limit: int = Query(default=50, ge=1, le=500),
27
+ offset: int = Query(default=0, ge=0),
28
+ ) -> dict:
29
+ db = get_db()
30
+ async with db.execute(
31
+ """SELECT id, session_id, role, text, sensitivity, source_app,
32
+ ttl_until, created_at, embedding_status, chunk_index, parent_id
33
+ FROM observations
34
+ ORDER BY id DESC
35
+ LIMIT ? OFFSET ?""",
36
+ (limit, offset),
37
+ ) as cur:
38
+ rows = await cur.fetchall()
39
+ return {
40
+ "observations": [
41
+ {
42
+ "id": int(r[0]),
43
+ "session_id": str(r[1]),
44
+ "role": str(r[2]),
45
+ "text": str(r[3]),
46
+ "sensitivity": str(r[4]),
47
+ "source_app": str(r[5]),
48
+ "ttl_until": r[6],
49
+ "created_at": int(r[7]),
50
+ "embedding_status": str(r[8]),
51
+ "chunk_index": int(r[9]),
52
+ "parent_id": r[10],
53
+ }
54
+ for r in rows
55
+ ],
56
+ "count": len(rows),
57
+ }
58
+
59
+
60
+ @router.get("/search")
61
+ async def search(
62
+ q: str = Query(min_length=1, max_length=500),
63
+ limit: int = Query(default=20, ge=1, le=100),
64
+ ) -> dict:
65
+ db = get_db()
66
+ cleaned = q.replace('"', "").strip()
67
+ if not cleaned:
68
+ return {"observations": [], "count": 0}
69
+ try:
70
+ async with db.execute(
71
+ """SELECT o.id, o.session_id, o.role, o.text, o.sensitivity,
72
+ o.created_at, o.embedding_status
73
+ FROM observations_fts f
74
+ JOIN observations o ON o.id = f.rowid
75
+ WHERE f.text MATCH ?
76
+ ORDER BY rank
77
+ LIMIT ?""",
78
+ (cleaned, limit),
79
+ ) as cur:
80
+ rows = await cur.fetchall()
81
+ except Exception as exc: # noqa: BLE001 - FTS MATCH parser is picky
82
+ raise HTTPException(400, f"search failed: {exc}")
83
+ return {
84
+ "observations": [
85
+ {
86
+ "id": int(r[0]),
87
+ "session_id": str(r[1]),
88
+ "role": str(r[2]),
89
+ "text": str(r[3]),
90
+ "sensitivity": str(r[4]),
91
+ "created_at": int(r[5]),
92
+ "embedding_status": str(r[6]),
93
+ }
94
+ for r in rows
95
+ ],
96
+ "count": len(rows),
97
+ }
98
+
99
+
100
+ @router.delete("/{observation_id}")
101
+ async def delete_observation(observation_id: int) -> dict:
102
+ db = get_db()
103
+ async with db.execute("SELECT 1 FROM observations WHERE id=?", (observation_id,)) as cur:
104
+ if await cur.fetchone() is None:
105
+ raise HTTPException(404, f"observation {observation_id} not found")
106
+ await db.execute("DELETE FROM observations_vec WHERE rowid=?", (observation_id,))
107
+ await db.execute("DELETE FROM observations WHERE id=?", (observation_id,))
108
+ await db.commit()
109
+ return {"ok": True, "deleted": observation_id}
110
+
111
+
112
+ @router.get("/stats")
113
+ async def memory_stats() -> dict:
114
+ counts = await memory_store.count_by_status()
115
+ db = get_db()
116
+ async with db.execute("SELECT COUNT(*) FROM observations") as cur:
117
+ total = (await cur.fetchone())[0]
118
+ async with db.execute("SELECT COUNT(*) FROM observations_vec") as cur:
119
+ vec_total = (await cur.fetchone())[0]
120
+ return {
121
+ "total": int(total),
122
+ "vec_total": int(vec_total),
123
+ "by_status": counts,
124
+ }