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.
- yagami/__init__.py +1 -0
- yagami/api/__init__.py +0 -0
- yagami/api/config.py +118 -0
- yagami/api/costs.py +22 -0
- yagami/api/decisions.py +78 -0
- yagami/api/ingest.py +55 -0
- yagami/api/kb.py +55 -0
- yagami/api/mcp.py +21 -0
- yagami/api/memory.py +124 -0
- yagami/api/openai_compat.py +980 -0
- yagami/api/privacy.py +55 -0
- yagami/api/sessions.py +70 -0
- yagami/api/stats.py +98 -0
- yagami/auth.py +197 -0
- yagami/backends/__init__.py +0 -0
- yagami/backends/anthropic.py +166 -0
- yagami/backends/base.py +89 -0
- yagami/backends/echo.py +31 -0
- yagami/backends/gemini.py +45 -0
- yagami/backends/groq.py +38 -0
- yagami/backends/llama_cpp.py +104 -0
- yagami/backends/mistral.py +46 -0
- yagami/backends/ollama.py +72 -0
- yagami/backends/openai.py +139 -0
- yagami/backends/openai_compat.py +131 -0
- yagami/backends/openrouter.py +48 -0
- yagami/backends/registry.py +86 -0
- yagami/backends/retry.py +82 -0
- yagami/backends/stability.py +66 -0
- yagami/chat/__init__.py +0 -0
- yagami/chat/session.py +140 -0
- yagami/chat/stream.py +583 -0
- yagami/cli.py +102 -0
- yagami/config.py +364 -0
- yagami/doctor.py +51 -0
- yagami/gateway/__init__.py +17 -0
- yagami/gateway/service.py +968 -0
- yagami/generate_key.py +11 -0
- yagami/governance/__init__.py +26 -0
- yagami/governance/approvals.py +195 -0
- yagami/governance/lineage.py +168 -0
- yagami/governance/output.py +32 -0
- yagami/governance/transform.py +263 -0
- yagami/ingest/__init__.py +0 -0
- yagami/ingest/extract.py +85 -0
- yagami/main.py +356 -0
- yagami/memory/__init__.py +8 -0
- yagami/memory/chunker.py +93 -0
- yagami/memory/documents.py +235 -0
- yagami/memory/embedder.py +57 -0
- yagami/memory/retriever.py +189 -0
- yagami/memory/store.py +168 -0
- yagami/memory/worker.py +99 -0
- yagami/policy/__init__.py +23 -0
- yagami/policy/engine.py +215 -0
- yagami/policy/models.py +147 -0
- yagami/policy/replay.py +101 -0
- yagami/privacy.py +129 -0
- yagami/projects.py +134 -0
- yagami/router/__init__.py +0 -0
- yagami/router/classifier.py +124 -0
- yagami/router/fast_path.py +202 -0
- yagami/router/overrides.py +66 -0
- yagami/router/policy.py +484 -0
- yagami/router/prompts.py +47 -0
- yagami/router/schema.py +41 -0
- yagami/router/tool_loop.py +240 -0
- yagami/runtime.py +33 -0
- yagami/secrets.py +54 -0
- yagami/set_key.py +35 -0
- yagami/skills/__init__.py +5 -0
- yagami/skills/adapters.py +32 -0
- yagami/skills/base.py +48 -0
- yagami/skills/calc_eval.py +117 -0
- yagami/skills/kb_recall.py +62 -0
- yagami/skills/mcp_auth.py +113 -0
- yagami/skills/mcp_manager.py +228 -0
- yagami/skills/registry.py +46 -0
- yagami/skills/web_fetch.py +150 -0
- yagami/storage/__init__.py +0 -0
- yagami/storage/db.py +148 -0
- yagami/storage/migrations/001_init.sql +30 -0
- yagami/storage/migrations/002_decision_timings.sql +3 -0
- yagami/storage/migrations/003_costs.sql +3 -0
- yagami/storage/migrations/004_feedback.sql +9 -0
- yagami/storage/migrations/005_observations.sql +57 -0
- yagami/storage/migrations/006_decision_profile.sql +4 -0
- yagami/storage/migrations/007_kb_documents.sql +40 -0
- yagami/storage/migrations/008_message_attachments.sql +15 -0
- yagami/storage/migrations/009_gateway_policy.sql +13 -0
- yagami/storage/migrations/010_privacy_transform_vault.sql +15 -0
- yagami/storage/migrations/011_audit_chain.sql +16 -0
- yagami/storage/migrations/012_tool_approvals.sql +18 -0
- yagami/telemetry/__init__.py +0 -0
- yagami/telemetry/audit.py +169 -0
- yagami/telemetry/costs.py +101 -0
- yagami/telemetry/decisions.py +267 -0
- yagami/telemetry/observability.py +84 -0
- yagami-0.4.1.dist-info/METADATA +749 -0
- yagami-0.4.1.dist-info/RECORD +103 -0
- yagami-0.4.1.dist-info/WHEEL +4 -0
- yagami-0.4.1.dist-info/entry_points.txt +3 -0
- 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
|
+
}
|
yagami/api/decisions.py
ADDED
|
@@ -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
|
+
}
|