Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions frontend/src/app/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ export default function SettingsPage() {
const [consolidateResult, setConsolidateResult] = useState("");
const fileInputRef = useRef<HTMLInputElement>(null);

// Full audit export (memories + entity graph + trust + conflicts)
const [fullExportBusy, setFullExportBusy] = useState<"" | "json" | "sqlite" | "pdf">("");
const [fullExportError, setFullExportError] = useState("");

// Backup & restore
const [backupPass, setBackupPass] = useState("");
const [backingUp, setBackingUp] = useState(false);
Expand Down Expand Up @@ -242,6 +246,23 @@ export default function SettingsPage() {
setImportingJson(false);
};

const downloadFullExport = async (format: "json" | "sqlite" | "pdf") => {
setFullExportBusy(format);
setFullExportError("");
try {
const { blob, filename } = await api.exportFull(format);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
setFullExportError(e instanceof Error ? e.message : String(e));
}
setFullExportBusy("");
};

const downloadBackup = async () => {
setBackingUp(true);
try {
Expand Down Expand Up @@ -947,6 +968,54 @@ export default function SettingsPage() {
/>
</div>

<div className="pt-2 border-t space-y-1">
<p className="text-xs text-muted-foreground">
Full audit export — memories, entity graph, trust scores, and conflict candidates
in one file. For auditing or backing up everything, not just memories.
</p>
<div className="flex flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={() => downloadFullExport("json")}
disabled={fullExportBusy !== ""}
>
{fullExportBusy === "json" ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Full export (JSON)
</Button>
<Button
variant="outline"
onClick={() => downloadFullExport("sqlite")}
disabled={fullExportBusy !== ""}
>
{fullExportBusy === "sqlite" ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Full export (SQLite)
</Button>
<Button
variant="outline"
onClick={() => downloadFullExport("pdf")}
disabled={fullExportBusy !== ""}
>
{fullExportBusy === "pdf" ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Download className="h-4 w-4 mr-2" />
)}
Audit report (PDF)
</Button>
</div>
{fullExportError && (
<span className="text-xs text-destructive">{fullExportError}</span>
)}
</div>

<div className="flex flex-wrap items-center gap-2 pt-2 border-t">
<Button variant="outline" onClick={runConsolidate} disabled={consolidateBusy}>
{consolidateBusy && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Expand Down
21 changes: 21 additions & 0 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,27 @@ export const api = {
body: JSON.stringify({ content_b64, passphrase: passphrase || "", replace }),
}),

// Full export — memories + entity graph + trust scores + conflicts.
// Binary response, so raw fetch (not fetchApi).
exportFull: async (format: "json" | "sqlite" | "pdf"): Promise<{ blob: Blob; filename: string }> => {
const token = getToken();
const res = await fetch(`${API}/api/export/full.${format}`, {
headers: token ? { "X-LEVH-Token": token } : {},
});
if (!res.ok) {
let detail = `${res.status}`;
try {
const body = await res.json();
if (body?.detail) detail = `${res.status}: ${body.detail}`;
} catch {}
throw new Error(`API error ${detail}`);
}
const disposition = res.headers.get("Content-Disposition") || "";
const match = disposition.match(/filename="([^"]+)"/);
const filename = match ? match[1] : `levh-full-export.${format}`;
return { blob: await res.blob(), filename };
},

// Context
generateContextFile: (project: string | null, style: "claude" | "cursor") =>
fetchApi<{ filename: string; content: string }>("/api/context-file", {
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ openai = []
# Passphrase-encrypted backups. Bundled in core deps too; this extra is a
# convenience alias for anyone pinning security features explicitly.
secure = ["cryptography>=42"]
dev = ["pytest", "pytest-asyncio", "build", "twine"]
# PDF rendering for the full-export audit report.
pdf = ["fpdf2>=2.7"]
dev = ["pytest", "pytest-asyncio", "build", "twine", "fpdf2>=2.7"]

[project.urls]
Homepage = "https://levh.ai-ulu.com/"
Expand Down
63 changes: 63 additions & 0 deletions server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,69 @@ async def import_memories(req: ImportRequest):
return await engine.import_memories_gated(req.data)


# ── Full export (memories + entity graph + trust + conflicts) ──────


def _export_filename(ext: str) -> str:
from datetime import datetime, timezone

stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
return f"levh-full-export-{stamp}.{ext}"


@app.get("/api/export/full.json")
async def export_full_json():
"""One-shot audit bundle: memories, entity graph, trust scores, and
conflict candidates — the raw machine-readable record."""
from server.core.full_export import build_full_export

engine = await get_engine()
export = await build_full_export(engine)
import json as _json

return Response(
content=_json.dumps(export, default=str),
media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{_export_filename("json")}"'},
)


@app.get("/api/export/full.sqlite")
async def export_full_sqlite():
"""Raw SQLite copy of the live database, taken via the online backup API."""
from server.core.full_export import export_full_sqlite as export_sqlite

engine = await get_engine()
try:
blob = await export_sqlite(engine)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return Response(
content=blob,
media_type="application/vnd.sqlite3",
headers={"Content-Disposition": f'attachment; filename="{_export_filename("sqlite")}"'},
)


@app.get("/api/export/full.pdf")
async def export_full_pdf():
"""Human-readable audit report (summary counts, entity/trust/conflict
overview) rendered from the same data as the JSON export."""
from server.core.full_export import PdfUnavailableError, build_full_export, render_full_export_pdf

engine = await get_engine()
export = await build_full_export(engine)
try:
blob = render_full_export_pdf(export)
except PdfUnavailableError as exc:
raise HTTPException(status_code=503, detail=str(exc))
return Response(
content=blob,
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{_export_filename("pdf")}"'},
)


# ── Backup / Restore (Faz 0 security) ───────────────────────────────


Expand Down
67 changes: 67 additions & 0 deletions server/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,58 @@ async def _run() -> dict:
return 0


def cmd_export_full(args: argparse.Namespace) -> int:
"""Export memories + entity graph + trust scores + conflicts to one file."""
import asyncio

from server.core import engine_provider

fmt = args.format
out_path = args.out or f"levh-full-export.{fmt}"

async def _run():
engine = engine_provider.get_engine()
await engine.initialize()
try:
from server.core.full_export import (
PdfUnavailableError,
build_full_export,
export_full_sqlite,
render_full_export_pdf,
)

if fmt == "json":
import json

export = await build_full_export(engine)
with open(out_path, "w") as f:
json.dump(export, f, indent=2, default=str)
return export["counts"]
elif fmt == "sqlite":
blob = await export_full_sqlite(engine)
with open(out_path, "wb") as f:
f.write(blob)
return None
else:
export = await build_full_export(engine)
try:
blob = render_full_export_pdf(export)
except PdfUnavailableError as exc:
print(f" {exc}", file=sys.stderr)
return None
with open(out_path, "wb") as f:
f.write(blob)
return export["counts"]
finally:
await engine.shutdown()

counts = asyncio.run(_run())
if counts is None and fmt == "pdf":
return 1
print(f" Wrote {out_path}" + (f" — {counts}" if counts else ""))
return 0


def cmd_remove_demo(args: argparse.Namespace) -> int:
"""Remove all demo-tagged memories, leaving real data untouched."""
import asyncio
Expand Down Expand Up @@ -1605,6 +1657,19 @@ def main() -> int:
help="Remove demo-tagged memories, leaving real data untouched",
)

# export-full (memories + entity graph + trust + conflicts, one file)
export_full_p = sub.add_parser(
"export-full",
help="Export memories, entity graph, trust scores, and conflicts to one file",
)
export_full_p.add_argument(
"--format",
choices=["json", "sqlite", "pdf"],
default="json",
help="Output format (default: json)",
)
export_full_p.add_argument("--out", help="Output file path (default: levh-full-export.<format>)")

# entities (persistent entity knowledge graph)
ent_p = sub.add_parser("entities", help="Persistent entity knowledge graph")
ent_sub = ent_p.add_subparsers(dest="entities_command")
Expand Down Expand Up @@ -1685,6 +1750,8 @@ def main() -> int:
return cmd_seed_demo(args)
elif args.command == "remove-demo":
return cmd_remove_demo(args)
elif args.command == "export-full":
return cmd_export_full(args)
elif args.command == "entities":
if args.entities_command in ("reindex", "list", "about"):
return cmd_entities(args)
Expand Down
9 changes: 9 additions & 0 deletions server/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -1051,6 +1051,15 @@ async def list_low_trust(self, threshold: float = 0.4, limit: int = 50) -> list[
await cursor.close()
return [dict(r) for r in rows]

async def list_all_trust(self, limit: int = 1_000_000) -> list[dict]:
cursor = await self.conn.execute(
"SELECT * FROM memory_trust_scores ORDER BY confidence ASC LIMIT ?",
(limit,),
)
rows = await cursor.fetchall()
await cursor.close()
return [dict(r) for r in rows]

async def clear_trust(self) -> None:
await self.conn.execute("DELETE FROM memory_trust_scores")
await self.conn.commit()
Expand Down
Loading
Loading