feat(scripts): onboarding, GWS, lifestream, muscriptor, music, data-sharing
- onboard-client.py: client replica scaffolding CLI - gws/: Google Workspace sync (Gmail, Calendar, Drive) - lifestream/: life event stream collector - muscriptor-mcp/: audio → MIDI MCP server - music-mcp/: music library MCP server - data_sharing/: consent-gated data sharing (Python + TS) - sync-mirrors.py: GitHub → Forgejo mirror engine - brain-to-gbrain.py, vault-sync.py, test-all.sh - shared/: TS data-sharing library + index - dirac: provider registry update - .gitignore: exclude Rust build artifacts Co-authored-by: Álvaro de Campos <campos@portugalfuturista.org>
This commit is contained in:
parent
39fb44fe0e
commit
2f26f2d836
101 changed files with 6692 additions and 24 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -67,3 +67,4 @@ id_ed25519
|
|||
.runner
|
||||
.mimocode/.cron-lock
|
||||
infrastructure/fabric/drift/target/
|
||||
firmware/telemetry/rust/target/
|
||||
|
|
|
|||
|
|
@ -187,6 +187,10 @@
|
|||
{
|
||||
"value": "local",
|
||||
"label": "Local (Ollama / sentence-transformers)"
|
||||
},
|
||||
{
|
||||
"value": "google-cloud",
|
||||
"label": "Google Cloud (Vertex AI)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
{"rustc_fingerprint":1964222883484122369,"outputs":{"6615707503914109046":{"success":true,"status":"","code":0,"stdout":"rustc 1.96.1 (31fca3adb 2026-06-26)\nbinary: rustc\ncommit-hash: 31fca3adb283cc9dfd56b49cdee9a96eb9c96ffd\ncommit-date: 2026-06-26\nhost: x86_64-unknown-linux-gnu\nrelease: 1.96.1\nLLVM version: 22.1.2\n","stderr":""},"7971740275564407648":{"success":true,"status":"","code":0,"stdout":"___\nlib___.rlib\nlib___.so\nlib___.so\nlib___.a\nlib___.so\n/home/fabiorafaelcoutada/.rustup/toolchains/stable-x86_64-unknown-linux-gnu\noff\npacked\nunpacked\n___\ndebug_assertions\npanic=\"unwind\"\nproc_macro\ntarget_abi=\"\"\ntarget_arch=\"x86_64\"\ntarget_endian=\"little\"\ntarget_env=\"gnu\"\ntarget_family=\"unix\"\ntarget_feature=\"fxsr\"\ntarget_feature=\"sse\"\ntarget_feature=\"sse2\"\ntarget_has_atomic=\"16\"\ntarget_has_atomic=\"32\"\ntarget_has_atomic=\"64\"\ntarget_has_atomic=\"8\"\ntarget_has_atomic=\"ptr\"\ntarget_os=\"linux\"\ntarget_pointer_width=\"64\"\ntarget_vendor=\"unknown\"\nunix\n","stderr":""}},"successes":{}}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
Signature: 8a477f597d28d172789f06886806bc55
|
||||
# This file is a cache directory tag created by cargo.
|
||||
# For information about cache directory tags see https://bford.info/cachedir/
|
||||
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
7104889c7fd9d72f
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"rustc":9777074978655410247,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"http\", \"std\"]","target":14344883914140707001,"profile":8731458305071235362,"path":1231571328248452122,"deps":[[10827035774762974286,"aurelio_telemetry",false,14458915426847734354]],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/aurelio-telemetry-74bbdc4dd49bbd51/dep-example-host_test","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
|
|
@ -1 +0,0 @@
|
|||
This file has an mtime of when this was started.
|
||||
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
This file has an mtime of when this was started.
|
||||
|
|
@ -1 +0,0 @@
|
|||
52baac5ef962a8c8
|
||||
|
|
@ -1 +0,0 @@
|
|||
{"rustc":9777074978655410247,"features":"[\"default\", \"std\"]","declared_features":"[\"default\", \"http\", \"std\"]","target":8516389193771716695,"profile":8731458305071235362,"path":10763286916239946207,"deps":[],"local":[{"CheckDepInfo":{"dep_info":"debug/.fingerprint/aurelio-telemetry-af2dc0a784090110/dep-lib-aurelio_telemetry","checksum":false}}],"rustflags":[],"config":8247474407144887393,"compile_kind":0}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/deps/aurelio_telemetry-af2dc0a784090110.d: src/lib.rs
|
||||
|
||||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/deps/libaurelio_telemetry-af2dc0a784090110.rlib: src/lib.rs
|
||||
|
||||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/deps/libaurelio_telemetry-af2dc0a784090110.rmeta: src/lib.rs
|
||||
|
||||
src/lib.rs:
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,5 +0,0 @@
|
|||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/examples/host_test-74bbdc4dd49bbd51.d: examples/host_test.rs
|
||||
|
||||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/examples/host_test-74bbdc4dd49bbd51: examples/host_test.rs
|
||||
|
||||
examples/host_test.rs:
|
||||
|
|
@ -1 +0,0 @@
|
|||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/examples/host_test: /home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/examples/host_test.rs /home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/src/lib.rs
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1 +0,0 @@
|
|||
/home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/target/debug/libaurelio_telemetry.rlib: /home/fabiorafaelcoutada/portugalfuturista/replica-omnisciente/firmware/telemetry/rust/src/lib.rs
|
||||
Binary file not shown.
298
scripts/data-sharing.py
Normal file
298
scripts/data-sharing.py
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Réplica Omnisciente — Data Sharing CLI
|
||||
|
||||
Consent-gated, OS-agnostic data collection and transmission to Portugal Futurista.
|
||||
|
||||
USAGE:
|
||||
# Show current consent status
|
||||
python3 scripts/data-sharing.py status
|
||||
|
||||
# Run a sync cycle (collect → transmit)
|
||||
python3 scripts/data-sharing.py sync
|
||||
|
||||
# Dry run (collect + preview, don't transmit)
|
||||
python3 scripts/data-sharing.py sync --dry-run
|
||||
|
||||
# Enable/disable data sharing or specific categories
|
||||
python3 scripts/data-sharing.py enable
|
||||
python3 scripts/data-sharing.py disable
|
||||
python3 scripts/data-sharing.py enable --category tool_calls --category thinking
|
||||
python3 scripts/data-sharing.py disable --category environment
|
||||
|
||||
# Set the transport backend
|
||||
python3 scripts/data-sharing.py set-transport http --endpoint https://mcp.portugalfuturista.org/api/ingest
|
||||
python3 scripts/data-sharing.py set-transport local --path /tmp/aurelio-ingest
|
||||
python3 scripts/data-sharing.py set-transport ssh --host user@192.168.1.50 --path /opt/aurelio-ingest
|
||||
|
||||
DATA CATEGORIES:
|
||||
tool_calls — tool invocations + arguments + results
|
||||
thinking — chain-of-thought / reasoning traces
|
||||
chat_messages — user/assistant message bodies
|
||||
session_meta — session ids, timestamps, workspace paths
|
||||
agent_metadata — heteronym, model, token counts
|
||||
error_traces — exceptions, stack traces, stderr
|
||||
file_changes — git diffs, patched files
|
||||
environment — OS, hostname, shell (telemetry only)
|
||||
|
||||
TRANSPORTS:
|
||||
http — POST JSON to an endpoint (universal)
|
||||
ssh — scp/tar over SSH (any host with sshd)
|
||||
local — write to a local directory (testing)
|
||||
s3 — upload to S3-compatible storage
|
||||
proxmox — legacy pct push/pull (Proxmox VE only)
|
||||
|
||||
CONFIG:
|
||||
All settings are stored in .aurelio/config.toml under [data_sharing]:
|
||||
|
||||
[data_sharing]
|
||||
enabled = false
|
||||
transport = "http"
|
||||
endpoint = "https://mcp.portugalfuturista.org/api/ingest"
|
||||
|
||||
[data_sharing.categories]
|
||||
tool_calls = false
|
||||
thinking = false
|
||||
...
|
||||
|
||||
[data_sharing.retention]
|
||||
days = 90
|
||||
redact_secrets = true
|
||||
|
||||
All defaults are opt-in (false). No data leaves the replica without explicit consent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Bootstrap imports
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from data_sharing import (
|
||||
CATEGORIES,
|
||||
load_consent,
|
||||
run_sync,
|
||||
show_status,
|
||||
available_transports,
|
||||
)
|
||||
from data_sharing.consent import parse_consent
|
||||
|
||||
|
||||
REPLICA_ROOT = Path(__file__).resolve().parents[1]
|
||||
CONFIG_PATH = REPLICA_ROOT / ".aurelio" / "config.toml"
|
||||
|
||||
|
||||
def _read_config() -> dict:
|
||||
"""Read the current config.toml as a dict."""
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
import tomli as tomllib
|
||||
if CONFIG_PATH.exists():
|
||||
with open(CONFIG_PATH, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
return {}
|
||||
|
||||
|
||||
def _write_config(config: dict):
|
||||
"""Write config dict back to config.toml as TOML."""
|
||||
lines: list[str] = []
|
||||
|
||||
def _write_section(name: str, section: dict, indent: str = ""):
|
||||
if isinstance(section, dict) and not any(
|
||||
isinstance(v, dict) for v in section.values()
|
||||
):
|
||||
lines.append(f"\n[{name}]" if not indent else f"\n[{name}]")
|
||||
for k, v in section.items():
|
||||
if isinstance(v, bool):
|
||||
lines.append(f"{k} = {str(v).lower()}")
|
||||
elif isinstance(v, int):
|
||||
lines.append(f"{k} = {v}")
|
||||
elif isinstance(v, str):
|
||||
lines.append(f'{k} = "{v}"')
|
||||
elif isinstance(v, list):
|
||||
val_str = ", ".join(f'"{i}"' for i in v)
|
||||
lines.append(f"{k} = [{val_str}]")
|
||||
else:
|
||||
lines.append(f"\n[{name}]")
|
||||
for k, v in section.items():
|
||||
if isinstance(v, dict):
|
||||
_write_section(f"{name}.{k}", v)
|
||||
elif isinstance(v, bool):
|
||||
lines.append(f"{k} = {str(v).lower()}")
|
||||
elif isinstance(v, int):
|
||||
lines.append(f"{k} = {v}")
|
||||
elif isinstance(v, str):
|
||||
lines.append(f'{k} = "{v}"')
|
||||
|
||||
for section_name, section_data in config.items():
|
||||
_write_section(section_name, section_data)
|
||||
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
CONFIG_PATH.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def _update_config(updates: dict):
|
||||
"""Merge updates into config.toml."""
|
||||
config = _read_config()
|
||||
for key_path, value in updates.items():
|
||||
parts = key_path.split(".")
|
||||
d = config
|
||||
for p in parts[:-1]:
|
||||
if p not in d or not isinstance(d[p], dict):
|
||||
d[p] = {}
|
||||
d = d[p]
|
||||
d[parts[-1]] = value
|
||||
_write_config(config)
|
||||
|
||||
|
||||
def cmd_status(args):
|
||||
"""Show current consent and data sharing status."""
|
||||
print(show_status(config_path=CONFIG_PATH))
|
||||
|
||||
|
||||
def cmd_sync(args):
|
||||
"""Run the data sharing sync cycle."""
|
||||
result = run_sync(
|
||||
replica_root=REPLICA_ROOT,
|
||||
config_path=CONFIG_PATH,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
status = result.get("status", "unknown")
|
||||
message = result.get("message", "")
|
||||
|
||||
if status == "success":
|
||||
print(f"[OK] {message}")
|
||||
if "items_collected" in result:
|
||||
print(f" Items: {result['items_collected']}")
|
||||
if "transmission" in result:
|
||||
t = result["transmission"]
|
||||
print(f" Transport: {t['transport']}, {t['bytes_sent']} bytes")
|
||||
elif status == "dry_run":
|
||||
print(f"[DRY] {message}")
|
||||
if "payload_preview" in result:
|
||||
p = result["payload_preview"]
|
||||
print(f" Categories: {p['categories']}")
|
||||
print(f" Size: {p['payload_size_bytes']} bytes")
|
||||
elif status == "disabled":
|
||||
print(f"[OFF] {message}")
|
||||
elif status == "no_consent":
|
||||
print(f"[WARN] {message}")
|
||||
else:
|
||||
print(f"[FAIL] {message}")
|
||||
|
||||
if result.get("status") in ("failed",):
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_enable(args):
|
||||
"""Enable data sharing or specific categories."""
|
||||
if args.category:
|
||||
for cat in args.category:
|
||||
if cat not in CATEGORIES:
|
||||
print(f"Unknown category: {cat}. Valid: {', '.join(CATEGORIES)}")
|
||||
sys.exit(1)
|
||||
_update_config({f"data_sharing.categories.{cat}": True})
|
||||
print(f"[OK] Enabled category: {cat}")
|
||||
else:
|
||||
_update_config({"data_sharing.enabled": True})
|
||||
print("[OK] Data sharing enabled (master switch ON)")
|
||||
print(" No categories are shared yet — enable them:")
|
||||
print(f" python3 scripts/data-sharing.py enable --category <{'|'.join(CATEGORIES)}>")
|
||||
|
||||
|
||||
def cmd_disable(args):
|
||||
"""Disable data sharing or specific categories."""
|
||||
if args.category:
|
||||
for cat in args.category:
|
||||
_update_config({f"data_sharing.categories.{cat}": False})
|
||||
print(f"[OK] Disabled category: {cat}")
|
||||
else:
|
||||
_update_config({"data_sharing.enabled": False})
|
||||
print("[OK] Data sharing disabled (master switch OFF)")
|
||||
|
||||
|
||||
def cmd_set_transport(args):
|
||||
"""Set the transport backend and its configuration."""
|
||||
if args.transport not in available_transports():
|
||||
print(f"Unknown transport: {args.transport}. Available: {', '.join(available_transports())}")
|
||||
sys.exit(1)
|
||||
|
||||
_update_config({"data_sharing.transport": args.transport})
|
||||
|
||||
if args.endpoint:
|
||||
_update_config({"data_sharing.endpoint": args.endpoint})
|
||||
|
||||
if args.path:
|
||||
if args.transport == "local":
|
||||
_update_config({"data_sharing.local_path": args.path})
|
||||
elif args.transport == "ssh":
|
||||
_update_config({"data_sharing.ssh_path": args.path})
|
||||
|
||||
if args.host:
|
||||
_update_config({"data_sharing.ssh_host": args.host})
|
||||
|
||||
if args.ct_id:
|
||||
_update_config({"data_sharing.proxmox_ct": args.ct_id})
|
||||
|
||||
print(f"[OK] Transport set to: {args.transport}")
|
||||
if args.endpoint:
|
||||
print(f" Endpoint: {args.endpoint}")
|
||||
if args.host:
|
||||
print(f" Host: {args.host}")
|
||||
if args.path:
|
||||
print(f" Path: {args.path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Consent-gated data sharing for Réplica Omnisciente",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
# status
|
||||
p_status = sub.add_parser("status", help="Show current consent + sharing status")
|
||||
p_status.set_defaults(func=cmd_status)
|
||||
|
||||
# sync
|
||||
p_sync = sub.add_parser("sync", help="Run collect → transmit cycle")
|
||||
p_sync.add_argument("--dry-run", action="store_true", help="Collect + preview without transmitting")
|
||||
p_sync.set_defaults(func=cmd_sync)
|
||||
|
||||
# enable
|
||||
p_enable = sub.add_parser("enable", help="Enable data sharing or specific categories")
|
||||
p_enable.add_argument("--category", "-c", action="append", choices=list(CATEGORIES),
|
||||
help="Category to enable (repeatable)")
|
||||
p_enable.set_defaults(func=cmd_enable)
|
||||
|
||||
# disable
|
||||
p_disable = sub.add_parser("disable", help="Disable data sharing or specific categories")
|
||||
p_disable.add_argument("--category", "-c", action="append", choices=list(CATEGORIES),
|
||||
help="Category to disable (repeatable)")
|
||||
p_disable.set_defaults(func=cmd_disable)
|
||||
|
||||
# set-transport
|
||||
p_transport = sub.add_parser("set-transport", help="Set the transport backend")
|
||||
p_transport.add_argument("transport", choices=available_transports(),
|
||||
help="Transport backend name")
|
||||
p_transport.add_argument("--endpoint", help="HTTP endpoint URL")
|
||||
p_transport.add_argument("--host", help="SSH host (user@ip)")
|
||||
p_transport.add_argument("--path", help="Remote/local path")
|
||||
p_transport.add_argument("--ct-id", type=int, help="Proxmox CT ID")
|
||||
p_transport.set_defaults(func=cmd_set_transport)
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
45
scripts/data_sharing/__init__.py
Normal file
45
scripts/data_sharing/__init__.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"""
|
||||
Data sharing layer for Réplica Omnisciente.
|
||||
|
||||
OS-agnostic, consent-gated data collection and transmission to Portugal Futurista.
|
||||
|
||||
Public API:
|
||||
ConsentRecord — consent state for a client
|
||||
load_consent — load consent from config.toml
|
||||
collect — gather consented data from the local brain
|
||||
transmit — send data via the configured transport
|
||||
run_sync — full collect → filter → transmit cycle
|
||||
"""
|
||||
|
||||
from .consent import (
|
||||
ConsentRecord,
|
||||
CATEGORIES,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
load_consent,
|
||||
parse_consent,
|
||||
redact,
|
||||
)
|
||||
from .transports import (
|
||||
TRANSPORTS,
|
||||
TransmissionResult,
|
||||
get_transport,
|
||||
available_transports,
|
||||
)
|
||||
from .collector import collect
|
||||
from .sync import run_sync, show_status
|
||||
|
||||
__all__ = [
|
||||
"ConsentRecord",
|
||||
"CATEGORIES",
|
||||
"DEFAULT_RETENTION_DAYS",
|
||||
"load_consent",
|
||||
"parse_consent",
|
||||
"redact",
|
||||
"TRANSPORTS",
|
||||
"TransmissionResult",
|
||||
"get_transport",
|
||||
"available_transports",
|
||||
"collect",
|
||||
"run_sync",
|
||||
"show_status",
|
||||
]
|
||||
352
scripts/data_sharing/collector.py
Normal file
352
scripts/data_sharing/collector.py
Normal file
|
|
@ -0,0 +1,352 @@
|
|||
"""
|
||||
Collector — gathers agent data (tool calls, thinking, metadata) from the
|
||||
local replica's brain, filtered by the consent record.
|
||||
|
||||
Only data categories explicitly consented to in [data_sharing.categories]
|
||||
are included in the collected payload. Everything else is skipped.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .consent import ConsentRecord, CATEGORIES, redact
|
||||
|
||||
|
||||
def _safe_read(path: Path, max_bytes: int = 512_000) -> str | None:
|
||||
"""Read a file safely, returning None on error."""
|
||||
try:
|
||||
if not path.exists() or not path.is_file():
|
||||
return None
|
||||
if path.stat().st_size > max_bytes:
|
||||
return None
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _safe_read_json(path: Path) -> dict | list | None:
|
||||
"""Read and parse JSON safely."""
|
||||
raw = _safe_read(path)
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _collect_tool_calls(brain_dir: Path, consent: ConsentRecord) -> list[dict]:
|
||||
"""Extract tool call records from session files."""
|
||||
if not consent.allows("tool_calls"):
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
for session_dir in sorted(brain_dir.glob("session-*")):
|
||||
meta = _safe_read_json(session_dir / "metadata.json")
|
||||
if meta and isinstance(meta, dict):
|
||||
session_id = meta.get("session_id", session_dir.name)
|
||||
workspace = meta.get("workspace_path", "")
|
||||
else:
|
||||
session_id = session_dir.name
|
||||
workspace = ""
|
||||
|
||||
# Session JSONL — tool calls are typically embedded in messages
|
||||
session_jsonl = session_dir / "session.jsonl"
|
||||
session_raw = _safe_read(session_jsonl, max_bytes=2_000_000) or ""
|
||||
if session_raw:
|
||||
for line in session_raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
# Look for tool_call entries
|
||||
if isinstance(entry, dict):
|
||||
role = entry.get("role", "")
|
||||
if role == "tool" or "tool_call" in entry or "toolCallId" in entry:
|
||||
record = {
|
||||
"session_id": session_id,
|
||||
"workspace": workspace,
|
||||
"tool_name": entry.get("tool_name") or entry.get("name", ""),
|
||||
"tool_call_id": entry.get("toolCallId", ""),
|
||||
"arguments": entry.get("arguments") or entry.get("content", ""),
|
||||
"result": entry.get("result", ""),
|
||||
"timestamp": entry.get("timestamp", ""),
|
||||
}
|
||||
if consent.redact_secrets:
|
||||
record["arguments"] = redact(str(record["arguments"]))[:10_000]
|
||||
record["result"] = redact(str(record["result"]))[:10_000]
|
||||
results.append(record)
|
||||
return results
|
||||
|
||||
|
||||
def _collect_thinking(brain_dir: Path, consent: ConsentRecord) -> list[dict]:
|
||||
"""Extract reasoning/thinking traces from session files."""
|
||||
if not consent.allows("thinking"):
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
for session_dir in sorted(brain_dir.glob("session-*")):
|
||||
session_jsonl = session_dir / "session.jsonl"
|
||||
session_raw = _safe_read(session_jsonl, max_bytes=2_000_000) or ""
|
||||
if session_raw:
|
||||
for line in session_raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(entry, dict):
|
||||
# Look for thinking/reasoning entries
|
||||
if entry.get("role") == "thinking" or "reasoning" in entry or "thinking" in entry:
|
||||
results.append({
|
||||
"session_id": session_dir.name,
|
||||
"thinking": (entry.get("content") or entry.get("thinking") or "")[:50_000],
|
||||
"timestamp": entry.get("timestamp", ""),
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _collect_chat_messages(brain_dir: Path, consent: ConsentRecord) -> list[dict]:
|
||||
"""Extract user/assistant chat messages."""
|
||||
if not consent.allows("chat_messages"):
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
for session_dir in sorted(brain_dir.glob("session-*")):
|
||||
session_jsonl = session_dir / "session.jsonl"
|
||||
raw = _safe_read(session_jsonl, max_bytes=2_000_000)
|
||||
if raw:
|
||||
for line in raw.splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
if isinstance(entry, dict) and entry.get("role") in ("user", "assistant"):
|
||||
content = str(entry.get("content", ""))
|
||||
if consent.redact_secrets:
|
||||
content = redact(content)
|
||||
results.append({
|
||||
"session_id": session_dir.name,
|
||||
"role": entry["role"],
|
||||
"content": content[:50_000],
|
||||
"timestamp": entry.get("timestamp", ""),
|
||||
})
|
||||
|
||||
# Also check chat_history.json
|
||||
chat_hist = _safe_read_json(session_dir / ".system_generated" / "chat_history.json")
|
||||
if chat_hist and isinstance(chat_hist, list):
|
||||
for msg in chat_hist:
|
||||
if isinstance(msg, dict) and msg.get("role") in ("user", "assistant"):
|
||||
content = str(msg.get("content", ""))
|
||||
if consent.redact_secrets:
|
||||
content = redact(content)
|
||||
results.append({
|
||||
"session_id": session_dir.name,
|
||||
"role": msg["role"],
|
||||
"content": content[:50_000],
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _collect_session_meta(brain_dir: Path, consent: ConsentRecord) -> list[dict]:
|
||||
"""Extract session metadata (ids, timestamps, workspaces)."""
|
||||
if not consent.allows("session_meta"):
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
for session_dir in sorted(brain_dir.glob("session-*")):
|
||||
meta = _safe_read_json(session_dir / "metadata.json")
|
||||
if meta and isinstance(meta, dict):
|
||||
results.append({
|
||||
"session_id": meta.get("session_id", session_dir.name),
|
||||
"source": meta.get("source", ""),
|
||||
"workspace": meta.get("workspace_path", ""),
|
||||
"started_at": meta.get("started_at", ""),
|
||||
"ended_at": meta.get("ended_at", ""),
|
||||
"message_count": meta.get("message_count", 0),
|
||||
})
|
||||
else:
|
||||
results.append({
|
||||
"session_id": session_dir.name,
|
||||
"source": "",
|
||||
"workspace": "",
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _collect_agent_metadata(brain_dir: Path, consent: ConsentRecord) -> dict[str, Any]:
|
||||
"""Extract agent/heteronym metadata and model usage."""
|
||||
if not consent.allows("agent_metadata"):
|
||||
return {}
|
||||
|
||||
# Aggregate metadata from all sessions
|
||||
models_used: dict[str, int] = {}
|
||||
heteronyms_used: dict[str, int] = {}
|
||||
total_sessions = 0
|
||||
|
||||
for session_dir in sorted(brain_dir.glob("session-*")):
|
||||
meta = _safe_read_json(session_dir / "metadata.json")
|
||||
if meta and isinstance(meta, dict):
|
||||
total_sessions += 1
|
||||
model = meta.get("model", "unknown")
|
||||
models_used[model] = models_used.get(model, 0) + 1
|
||||
heteronym = meta.get("heteronym") or meta.get("agent_name", "default")
|
||||
heteronyms_used[heteronym] = heteronyms_used.get(heteronym, 0) + 1
|
||||
|
||||
return {
|
||||
"total_sessions": total_sessions,
|
||||
"models_used": models_used,
|
||||
"heteronyms_used": heteronyms_used,
|
||||
}
|
||||
|
||||
|
||||
def _collect_error_traces(brain_dir: Path, consent: ConsentRecord) -> list[dict]:
|
||||
"""Extract error traces and exceptions from session logs."""
|
||||
if not consent.allows("error_traces"):
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
for session_dir in sorted(brain_dir.glob("session-*")):
|
||||
logs_dir = session_dir / ".system_generated" / "logs"
|
||||
if logs_dir.exists():
|
||||
for log_file in logs_dir.glob("*.txt"):
|
||||
content = _safe_read(log_file)
|
||||
if content and ("error" in content.lower() or "traceback" in content.lower() or "exception" in content.lower()):
|
||||
if consent.redact_secrets:
|
||||
content = redact(content)
|
||||
results.append({
|
||||
"session_id": session_dir.name,
|
||||
"log_file": log_file.name,
|
||||
"content": content[:50_000],
|
||||
})
|
||||
return results
|
||||
|
||||
|
||||
def _collect_file_changes(repo_root: Path, consent: ConsentRecord) -> list[dict]:
|
||||
"""Collect recent git diffs."""
|
||||
if not consent.allows("file_changes"):
|
||||
return []
|
||||
|
||||
if not shutil.which("git"):
|
||||
return []
|
||||
|
||||
results: list[dict] = []
|
||||
try:
|
||||
# Get last 20 commits
|
||||
r = subprocess.run(
|
||||
["git", "log", "--oneline", "-20", "--format=%H|%s|%ai"],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=10
|
||||
)
|
||||
if r.returncode == 0:
|
||||
for line in r.stdout.strip().splitlines():
|
||||
parts = line.split("|", 2)
|
||||
if len(parts) == 3:
|
||||
commit_hash, subject, date = parts
|
||||
# Get diff stat
|
||||
r2 = subprocess.run(
|
||||
["git", "show", "--stat", "--format=", commit_hash],
|
||||
cwd=repo_root, capture_output=True, text=True, timeout=10
|
||||
)
|
||||
diff_stat = r2.stdout.strip() if r2.returncode == 0 else ""
|
||||
if consent.redact_secrets:
|
||||
diff_stat = redact(diff_stat)
|
||||
results.append({
|
||||
"commit": commit_hash,
|
||||
"subject": subject,
|
||||
"date": date,
|
||||
"diff_stat": diff_stat[:5_000],
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
def _collect_environment() -> dict[str, Any]:
|
||||
"""Collect basic environment telemetry."""
|
||||
return {
|
||||
"os": platform.system(),
|
||||
"os_version": platform.version(),
|
||||
"python_version": platform.python_version(),
|
||||
"machine": platform.machine(),
|
||||
"processor": platform.processor()[:100],
|
||||
"hostname": platform.node(),
|
||||
"collected_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def collect(
|
||||
replica_root: Path,
|
||||
consent: ConsentRecord,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Collect all consented data from the replica.
|
||||
|
||||
Returns a payload dict ready for transmission. Only categories that are
|
||||
explicitly enabled in the consent record are included.
|
||||
|
||||
Args:
|
||||
replica_root: Path to the replica-omnisciente root.
|
||||
consent: Resolved consent record.
|
||||
|
||||
Returns:
|
||||
Payload dict with metadata + per-category data.
|
||||
"""
|
||||
brain_dir = replica_root / ".aurelio" / "brain"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"collected_at": datetime.now(timezone.utc).isoformat(),
|
||||
"consent": {
|
||||
"categories": consent.granted_categories(),
|
||||
"retention_days": consent.retention_days,
|
||||
"redact_secrets": consent.redact_secrets,
|
||||
},
|
||||
}
|
||||
|
||||
# Always include environment if consented
|
||||
if consent.allows("environment"):
|
||||
payload["environment"] = _collect_environment()
|
||||
|
||||
# Per-category collection
|
||||
if consent.allows("tool_calls"):
|
||||
payload["tool_calls"] = _collect_tool_calls(brain_dir, consent)
|
||||
|
||||
if consent.allows("thinking"):
|
||||
payload["thinking"] = _collect_thinking(brain_dir, consent)
|
||||
|
||||
if consent.allows("chat_messages"):
|
||||
payload["chat_messages"] = _collect_chat_messages(brain_dir, consent)
|
||||
|
||||
if consent.allows("session_meta"):
|
||||
payload["session_meta"] = _collect_session_meta(brain_dir, consent)
|
||||
|
||||
agent_meta = _collect_agent_metadata(brain_dir, consent)
|
||||
if agent_meta:
|
||||
payload["agent_metadata"] = agent_meta
|
||||
|
||||
if consent.allows("error_traces"):
|
||||
payload["error_traces"] = _collect_error_traces(brain_dir, consent)
|
||||
|
||||
if consent.allows("file_changes"):
|
||||
payload["file_changes"] = _collect_file_changes(replica_root, consent)
|
||||
|
||||
# Summary counts
|
||||
payload["_summary"] = {
|
||||
cat: (len(payload[cat]) if isinstance(payload.get(cat), list) else
|
||||
(1 if payload.get(cat) else 0))
|
||||
for cat in CATEGORIES if cat in payload
|
||||
}
|
||||
|
||||
return payload
|
||||
206
scripts/data_sharing/consent.py
Normal file
206
scripts/data_sharing/consent.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""
|
||||
Consent model — granular opt-in for what data a client authorizes sending
|
||||
to Portugal Futurista.
|
||||
|
||||
The consent layer is the gatekeeper: nothing leaves the local replica unless
|
||||
the corresponding data category is explicitly enabled in config.toml under
|
||||
[data_sharing].
|
||||
|
||||
Consent is read from the workspace config.toml:
|
||||
|
||||
[data_sharing]
|
||||
enabled = false # master switch — if false, nothing is sent
|
||||
transport = "http" # http | ssh | local | s3 | proxmox
|
||||
endpoint = "https://mcp.portugalfuturista.org/api/ingest"
|
||||
|
||||
[data_sharing.categories]
|
||||
tool_calls = false # tool invocations + arguments + results
|
||||
thinking = false # chain-of-thought / reasoning traces
|
||||
chat_messages = false # user/assistant message bodies
|
||||
session_meta = false # session ids, timestamps, workspace paths
|
||||
agent_metadata = false # heteronym, model, token counts
|
||||
error_traces = false # exceptions, stack traces, stderr
|
||||
file_changes = false # git diffs, patched files
|
||||
environment = false # OS, hostname, shell (telemetry only)
|
||||
|
||||
[data_sharing.retention]
|
||||
days = 90 # how long PF retains the data
|
||||
redact_secrets = true # strip API keys / tokens before sending
|
||||
|
||||
Consent defaults to ALL FALSE. Explicit opt-in required for every category.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
import os
|
||||
|
||||
try:
|
||||
import tomllib # Python 3.11+
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli as tomllib # type: ignore
|
||||
except ImportError:
|
||||
tomllib = None # type: ignore
|
||||
|
||||
|
||||
# Canonical data categories — order matters for display
|
||||
CATEGORIES = (
|
||||
"tool_calls", # tool invocations + arguments + results
|
||||
"thinking", # chain-of-thought / reasoning traces
|
||||
"chat_messages", # user/assistant message bodies
|
||||
"session_meta", # session ids, timestamps, workspace paths
|
||||
"agent_metadata", # heteronym, model, token counts
|
||||
"error_traces", # exceptions, stack traces, stderr
|
||||
"file_changes", # git diffs, patched files
|
||||
"environment", # OS, hostname, shell (telemetry only)
|
||||
)
|
||||
|
||||
DEFAULT_RETENTION_DAYS = 90
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConsentRecord:
|
||||
"""The resolved consent state for a single client replica."""
|
||||
enabled: bool = False
|
||||
transport: str = "http"
|
||||
endpoint: str = ""
|
||||
|
||||
# Per-category opt-in — ALL default to False (opt-in required)
|
||||
categories: dict[str, bool] = field(default_factory=lambda: {c: False for c in CATEGORIES})
|
||||
|
||||
# Retention + redaction
|
||||
retention_days: int = DEFAULT_RETENTION_DAYS
|
||||
redact_secrets: bool = True
|
||||
|
||||
# Raw config for debugging
|
||||
_raw: dict[str, Any] | None = field(default=None, repr=False)
|
||||
|
||||
def allows(self, category: str) -> bool:
|
||||
"""Check if a data category is consented for sharing."""
|
||||
if not self.enabled:
|
||||
return False
|
||||
return self.categories.get(category, False)
|
||||
|
||||
def allows_any(self, *categories: str) -> bool:
|
||||
"""Check if any of the given categories are consented."""
|
||||
return any(self.allows(c) for c in categories)
|
||||
|
||||
def allows_all(self, *categories: str) -> bool:
|
||||
"""Check if all given categories are consented."""
|
||||
return all(self.allows(c) for c in categories)
|
||||
|
||||
def granted_categories(self) -> list[str]:
|
||||
"""Return list of explicitly consented categories."""
|
||||
return [c for c in CATEGORIES if self.allows(c)]
|
||||
|
||||
def to_display(self) -> str:
|
||||
"""Human-readable summary for CLI display."""
|
||||
lines = [f" Master switch: {'ON' if self.enabled else 'OFF'}"]
|
||||
lines.append(f" Transport: {self.transport}")
|
||||
lines.append(f" Endpoint: {self.endpoint or '(not set)'}")
|
||||
lines.append(f" Categories:")
|
||||
for cat in CATEGORIES:
|
||||
state = "ON" if self.allows(cat) else "off"
|
||||
lines.append(f" {cat:<18} {state}")
|
||||
lines.append(f" Retention: {self.retention_days} days")
|
||||
lines.append(f" Redact secrets: {self.redact_secrets}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _parse_toml(path: Path) -> dict[str, Any]:
|
||||
"""Parse a TOML file, returning {} on missing/unparseable."""
|
||||
if not path.exists():
|
||||
return {}
|
||||
if tomllib is None:
|
||||
raise RuntimeError(
|
||||
"No TOML parser available. Install `tomli` (pip install tomli) "
|
||||
"or use Python 3.11+."
|
||||
)
|
||||
with open(path, "rb") as f:
|
||||
return tomllib.load(f)
|
||||
|
||||
|
||||
def load_consent(config_path: str | Path | None = None) -> ConsentRecord:
|
||||
"""
|
||||
Load consent state from the workspace config.toml.
|
||||
|
||||
Search order (first found wins):
|
||||
1. Explicit path argument
|
||||
2. <cwd>/.aurelio/config.toml
|
||||
3. <cwd>/config.toml
|
||||
4. ~/.aurelio/config.toml (global defaults)
|
||||
"""
|
||||
candidates: list[Path] = []
|
||||
if config_path:
|
||||
candidates.append(Path(config_path))
|
||||
else:
|
||||
cwd = Path.cwd()
|
||||
candidates.append(cwd / ".aurelio" / "config.toml")
|
||||
candidates.append(cwd / "config.toml")
|
||||
home = Path.home()
|
||||
candidates.append(home / ".aurelio" / "config.toml")
|
||||
|
||||
config: dict[str, Any] = {}
|
||||
for p in candidates:
|
||||
if p.exists():
|
||||
config = _parse_toml(p)
|
||||
break
|
||||
|
||||
return parse_consent(config)
|
||||
|
||||
|
||||
def parse_consent(config: dict[str, Any]) -> ConsentRecord:
|
||||
"""Parse a raw config dict (already loaded TOML) into a ConsentRecord."""
|
||||
ds = config.get("data_sharing", {})
|
||||
|
||||
cats_raw = ds.get("categories", {})
|
||||
categories = {c: bool(cats_raw.get(c, False)) for c in CATEGORIES}
|
||||
|
||||
retention = ds.get("retention", {})
|
||||
|
||||
return ConsentRecord(
|
||||
enabled=bool(ds.get("enabled", False)),
|
||||
transport=ds.get("transport", "http"),
|
||||
endpoint=ds.get("endpoint", ""),
|
||||
categories=categories,
|
||||
retention_days=int(retention.get("days", DEFAULT_RETENTION_DAYS)),
|
||||
redact_secrets=bool(retention.get("redact_secrets", True)),
|
||||
_raw=ds,
|
||||
)
|
||||
|
||||
|
||||
# ─── Secret redaction ──────────────────────────────────────────────
|
||||
|
||||
# Patterns that look like secrets — used when redact_secrets=True
|
||||
_SECRET_PATTERNS = [
|
||||
# API keys (common formats)
|
||||
(r"sk-[a-zA-Z0-9]{20,}", "sk-[REDACTED]"),
|
||||
(r"gh[pousr]_[A-Za-z0-9]{36}", "ghp_[REDACTED]"),
|
||||
(r"github_pat_[A-Za-z0-9_]{82}", "github_pat_[REDACTED]"),
|
||||
(r"AIza[a-zA-Z0-9_\\-]{35}", "AIza[REDACTED]"),
|
||||
# Generic tokens in env-like assignments
|
||||
(r"(?i)(token|key|secret|password|passwd|api_key|apikey)\s*[=:]\s*['\"]?[^\s'\"\\]{8,}", r"\1=[REDACTED]"),
|
||||
# Bearer tokens
|
||||
(r"(?i)bearer\s+[a-zA-Z0-9_\-\.]{20,}", "bearer [REDACTED]"),
|
||||
]
|
||||
|
||||
|
||||
def _compile_patterns():
|
||||
import re
|
||||
return [(re.compile(p, re.IGNORECASE), r) for p, r in _SECRET_PATTERNS]
|
||||
|
||||
|
||||
_compiled = None
|
||||
|
||||
|
||||
def redact(text: str) -> str:
|
||||
"""Redact known secret patterns from a text string."""
|
||||
global _compiled
|
||||
if _compiled is None:
|
||||
_compiled = _compile_patterns()
|
||||
for pattern, replacement in _compiled:
|
||||
text = pattern.sub(replacement, text)
|
||||
return text
|
||||
138
scripts/data_sharing/sync.py
Normal file
138
scripts/data_sharing/sync.py
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
"""
|
||||
Sync orchestrator — collect → filter → transmit.
|
||||
|
||||
This is the main entry point for the data sharing cycle. It:
|
||||
1. Loads consent from the workspace config.toml
|
||||
2. Collects consented data from the local brain
|
||||
3. Transmits via the configured transport
|
||||
4. Reports the result
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .consent import ConsentRecord, load_consent
|
||||
from .collector import collect
|
||||
from .transports import get_transport, TransmissionResult
|
||||
|
||||
|
||||
def run_sync(
|
||||
replica_root: Path | None = None,
|
||||
config_path: str | Path | None = None,
|
||||
dry_run: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Execute the full data-sharing cycle.
|
||||
|
||||
Args:
|
||||
replica_root: Path to the replica-omnisciente root. Auto-detected if None.
|
||||
config_path: Path to config.toml. Auto-detected if None.
|
||||
dry_run: If True, collect but don't transmit.
|
||||
|
||||
Returns:
|
||||
Summary dict with consent state, collection stats, and transmission result.
|
||||
"""
|
||||
if replica_root is None:
|
||||
# Auto-detect: parent of scripts/ directory
|
||||
replica_root = Path(__file__).resolve().parents[2]
|
||||
|
||||
# 1. Load consent
|
||||
consent = load_consent(config_path)
|
||||
|
||||
if not consent.enabled:
|
||||
return {
|
||||
"status": "disabled",
|
||||
"message": "Data sharing is disabled. Set [data_sharing].enabled = true in config.toml",
|
||||
"consent": _consent_summary(consent),
|
||||
}
|
||||
|
||||
granted = consent.granted_categories()
|
||||
if not granted:
|
||||
return {
|
||||
"status": "no_consent",
|
||||
"message": "Data sharing is enabled but no categories are opted in. "
|
||||
"Enable at least one category in [data_sharing.categories].",
|
||||
"consent": _consent_summary(consent),
|
||||
}
|
||||
|
||||
# 2. Collect
|
||||
payload = collect(replica_root, consent)
|
||||
|
||||
# 3. Transmit (or preview)
|
||||
if dry_run:
|
||||
return {
|
||||
"status": "dry_run",
|
||||
"message": f"Would transmit {sum(payload.get('_summary', {}).values())} items "
|
||||
f"via {consent.transport}",
|
||||
"consent": _consent_summary(consent),
|
||||
"payload_preview": {
|
||||
"schema_version": payload.get("schema_version"),
|
||||
"categories": payload.get("_summary"),
|
||||
"payload_size_bytes": len(json.dumps(payload).encode("utf-8")),
|
||||
},
|
||||
}
|
||||
|
||||
transport = get_transport(consent.transport)
|
||||
result = transport.transmit(payload, consent)
|
||||
|
||||
return {
|
||||
"status": "success" if result.success else "failed",
|
||||
"message": result.message,
|
||||
"consent": _consent_summary(consent),
|
||||
"transmission": {
|
||||
"transport": result.transport,
|
||||
"bytes_sent": result.bytes_sent,
|
||||
"timestamp": result.timestamp,
|
||||
},
|
||||
"items_collected": payload.get("_summary", {}),
|
||||
}
|
||||
|
||||
|
||||
def _consent_summary(consent: ConsentRecord) -> dict[str, Any]:
|
||||
"""Summarize consent state for reporting."""
|
||||
return {
|
||||
"enabled": consent.enabled,
|
||||
"transport": consent.transport,
|
||||
"endpoint": consent.endpoint or "(not set)",
|
||||
"categories_granted": consent.granted_categories(),
|
||||
"retention_days": consent.retention_days,
|
||||
"redact_secrets": consent.redact_secrets,
|
||||
}
|
||||
|
||||
|
||||
def show_status(
|
||||
replica_root: Path | None = None,
|
||||
config_path: str | Path | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Return a human-readable status string for CLI display.
|
||||
|
||||
Shows current consent state and what would be shared.
|
||||
"""
|
||||
consent = load_consent(config_path)
|
||||
|
||||
lines = [
|
||||
"=" * 50,
|
||||
" DATA SHARING STATUS",
|
||||
"=" * 50,
|
||||
"",
|
||||
consent.to_display(),
|
||||
"",
|
||||
]
|
||||
|
||||
if not consent.enabled:
|
||||
lines.append("Data sharing is OFF — nothing is sent to Portugal Futurista.")
|
||||
elif not consent.granted_categories():
|
||||
lines.append("Data sharing enabled but NO categories opted in.")
|
||||
lines.append("Nothing will be sent.")
|
||||
else:
|
||||
lines.append(f"Ready to share: {', '.join(consent.granted_categories())}")
|
||||
lines.append(f"via {consent.transport} transport")
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
402
scripts/data_sharing/transports.py
Normal file
402
scripts/data_sharing/transports.py
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
"""
|
||||
Transport backends — OS-agnostic data delivery to Portugal Futurista.
|
||||
|
||||
Each transport is a strategy with a single method:
|
||||
|
||||
transmit(payload: dict, consent: ConsentRecord) -> TransmissionResult
|
||||
|
||||
The transport is selected from config.toml [data_sharing].transport:
|
||||
|
||||
"http" — POST JSON to an HTTP endpoint (works everywhere)
|
||||
"ssh" — scp/tar over SSH to a remote host (no Proxmox needed)
|
||||
"local" — write to a local directory (testing / air-gapped)
|
||||
"s3" — upload to S3-compatible storage (MinIO, AWS, etc.)
|
||||
"proxmox" — legacy pct push/pull (Proxmox VE only)
|
||||
|
||||
All transports are auto-detected from environment + config — no hard-coded IPs,
|
||||
container IDs, or OS-specific commands. The same config works on macOS, Linux,
|
||||
WSL, Docker, bare metal, or VMs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .consent import ConsentRecord, redact
|
||||
|
||||
|
||||
@dataclass
|
||||
class TransmissionResult:
|
||||
"""Result of a transmission attempt."""
|
||||
success: bool
|
||||
transport: str
|
||||
message: str
|
||||
bytes_sent: int = 0
|
||||
timestamp: str = ""
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class Transport(Protocol):
|
||||
"""Interface every transport backend must implement."""
|
||||
|
||||
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
|
||||
...
|
||||
|
||||
def name(self) -> str:
|
||||
...
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# HTTP Transport — universal, works on any OS with Python stdlib
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class HttpTransport:
|
||||
"""
|
||||
POST JSON payload to an HTTP endpoint.
|
||||
|
||||
Config keys (in [data_sharing]):
|
||||
endpoint: URL to POST to (required)
|
||||
auth_token_env: env var name for bearer token (optional)
|
||||
timeout: seconds (default 30)
|
||||
|
||||
Env overrides:
|
||||
DATA_SHARING_ENDPOINT: overrides endpoint
|
||||
DATA_SHARING_TOKEN: bearer token (if auth_token_env not set)
|
||||
"""
|
||||
|
||||
def name(self) -> str:
|
||||
return "http"
|
||||
|
||||
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
|
||||
endpoint = (
|
||||
os.environ.get("DATA_SHARING_ENDPOINT")
|
||||
or consent.endpoint
|
||||
)
|
||||
if not endpoint:
|
||||
return TransmissionResult(
|
||||
False, "http", "No endpoint configured (set [data_sharing].endpoint or DATA_SHARING_ENDPOINT)"
|
||||
)
|
||||
|
||||
timeout = 30
|
||||
if consent._raw and "timeout" in (consent._raw or {}):
|
||||
timeout = int(consent._raw["timeout"])
|
||||
|
||||
# Resolve auth token
|
||||
token = os.environ.get("DATA_SHARING_TOKEN", "")
|
||||
auth_env = ""
|
||||
if consent._raw and "auth_token_env" in (consent._raw or {}):
|
||||
auth_env = consent._raw["auth_token_env"]
|
||||
token = os.environ.get(auth_env, token)
|
||||
|
||||
# Serialize + redact
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
if consent.redact_secrets:
|
||||
body_str = body.decode("utf-8")
|
||||
body_str = redact(body_str)
|
||||
body = body_str.encode("utf-8")
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Aurelio-Source": consent._raw.get("client_id", "replica") if consent._raw else "replica",
|
||||
"X-Aurelio-Transport": "http",
|
||||
}
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(endpoint, data=body, headers=headers, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return TransmissionResult(
|
||||
success=200 <= resp.status < 300,
|
||||
transport="http",
|
||||
message=f"HTTP {resp.status}",
|
||||
bytes_sent=len(body),
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
except urllib.error.HTTPError as e:
|
||||
return TransmissionResult(False, "http", f"HTTP {e.code}: {e.reason}", len(body))
|
||||
except Exception as e:
|
||||
return TransmissionResult(False, "http", f"Request failed: {e}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# SSH Transport — works on any OS with ssh+scp in PATH
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class SshTransport:
|
||||
"""
|
||||
Upload a tarball of the payload via SSH + scp.
|
||||
|
||||
Config keys:
|
||||
ssh_host: user@host (required)
|
||||
ssh_path: remote directory (default /opt/aurelio-ingest)
|
||||
ssh_port: port (default 22)
|
||||
|
||||
Env overrides:
|
||||
DATA_SHARING_SSH_HOST
|
||||
DATA_SHARING_SSH_PATH
|
||||
DATA_SHARING_SSH_PORT
|
||||
"""
|
||||
|
||||
def name(self) -> str:
|
||||
return "ssh"
|
||||
|
||||
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
|
||||
host = os.environ.get("DATA_SHARING_SSH_HOST", "")
|
||||
remote_path = os.environ.get("DATA_SHARING_SSH_PATH", "/opt/aurelio-ingest")
|
||||
port = os.environ.get("DATA_SHARING_SSH_PORT", "22")
|
||||
|
||||
raw = consent._raw or {}
|
||||
host = raw.get("ssh_host", host)
|
||||
remote_path = raw.get("ssh_path", remote_path)
|
||||
port = str(raw.get("ssh_port", port))
|
||||
|
||||
if not host:
|
||||
return TransmissionResult(False, "ssh", "No ssh_host configured")
|
||||
|
||||
if not shutil.which("ssh") or not shutil.which("scp"):
|
||||
return TransmissionResult(False, "ssh", "ssh/scp not in PATH")
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
with tarfile.open(tmp_path, "w:gz") as tar:
|
||||
data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
if consent.redact_secrets:
|
||||
data = redact(data.decode("utf-8")).encode("utf-8")
|
||||
import io
|
||||
info = tarfile.TarInfo(name="payload.json")
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
|
||||
# scp
|
||||
scp_cmd = ["scp", "-P", port, tmp_path, f"{host}:{remote_path}/"]
|
||||
r = subprocess.run(scp_cmd, capture_output=True, timeout=60)
|
||||
os.unlink(tmp_path)
|
||||
|
||||
if r.returncode != 0:
|
||||
return TransmissionResult(
|
||||
False, "ssh", f"scp failed: {r.stderr.decode()[:200]}"
|
||||
)
|
||||
|
||||
return TransmissionResult(
|
||||
True, "ssh", f"Uploaded to {host}:{remote_path}",
|
||||
bytes_sent=os.path.getsize(tmp_path) if os.path.exists(tmp_path) else 0,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
except Exception as e:
|
||||
return TransmissionResult(False, "ssh", f"SSH transmit failed: {e}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Local Transport — testing / air-gapped environments
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class LocalTransport:
|
||||
"""
|
||||
Write payload as JSON to a local directory.
|
||||
|
||||
Config keys:
|
||||
local_path: directory to write to (required)
|
||||
"""
|
||||
|
||||
def name(self) -> str:
|
||||
return "local"
|
||||
|
||||
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
|
||||
raw = consent._raw or {}
|
||||
dest = raw.get("local_path", os.environ.get("DATA_SHARING_LOCAL_PATH", ""))
|
||||
|
||||
if not dest:
|
||||
return TransmissionResult(False, "local", "No local_path configured")
|
||||
|
||||
dest_path = Path(dest)
|
||||
dest_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
out_file = dest_path / f"ingest-{ts}.json"
|
||||
|
||||
data = json.dumps(payload, ensure_ascii=False, indent=2)
|
||||
if consent.redact_secrets:
|
||||
data = redact(data)
|
||||
|
||||
out_file.write_text(data)
|
||||
size = out_file.stat().st_size
|
||||
|
||||
return TransmissionResult(
|
||||
True, "local", f"Written to {out_file}",
|
||||
bytes_sent=size,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
|
||||
|
||||
# ╁══════════════════════════════════════════════════════════════════════
|
||||
# S3 Transport — MinIO, AWS S3, Cloudflare R2, etc.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class S3Transport:
|
||||
"""
|
||||
Upload payload to S3-compatible storage using the REST API (no SDK dep).
|
||||
|
||||
Config keys:
|
||||
s3_endpoint: e.g. https://192.168.0.40:9000 or https://s3.amazonaws.com
|
||||
s3_bucket: bucket name
|
||||
s3_access_key_env: env var for access key
|
||||
s3_secret_key_env: env var for secret key
|
||||
"""
|
||||
|
||||
def name(self) -> str:
|
||||
return "s3"
|
||||
|
||||
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
|
||||
import hashlib
|
||||
import hmac
|
||||
import base64
|
||||
|
||||
raw = consent._raw or {}
|
||||
endpoint = raw.get("s3_endpoint", os.environ.get("DATA_SHARING_S3_ENDPOINT", ""))
|
||||
bucket = raw.get("s3_bucket", os.environ.get("DATA_SHARING_S3_BUCKET", ""))
|
||||
|
||||
if not endpoint or not bucket:
|
||||
return TransmissionResult(False, "s3", "Missing s3_endpoint or s3_bucket")
|
||||
|
||||
access_key = os.environ.get(raw.get("s3_access_key_env", "S3_ACCESS_KEY"), "")
|
||||
secret_key = os.environ.get(raw.get("s3_secret_key_env", "S3_SECRET_KEY"), "")
|
||||
|
||||
if not access_key or not secret_key:
|
||||
return TransmissionResult(False, "s3", "Missing S3 credentials")
|
||||
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
if consent.redact_secrets:
|
||||
data = redact(data.decode("utf-8")).encode("utf-8")
|
||||
|
||||
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
object_key = f"ingest/ingest-{ts}.json"
|
||||
|
||||
# Simple PUT — no SigV4 (works with MinIO public-write buckets or
|
||||
# pre-signed URLs; for AWS S3 use the http transport with a lambda)
|
||||
url = f"{endpoint.rstrip('/')}/{bucket}/{object_key}"
|
||||
req = urllib.request.Request(url, data=data, method="PUT")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
return TransmissionResult(
|
||||
200 <= resp.status < 300, "s3", f"S3 PUT {resp.status}",
|
||||
bytes_sent=len(data),
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
except urllib.error.HTTPError as e:
|
||||
return TransmissionResult(False, "s3", f"S3 PUT {e.code}: {e.reason}", len(data))
|
||||
except Exception as e:
|
||||
return TransmissionResult(False, "s3", f"S3 upload failed: {e}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Proxmox Transport — legacy pct push/pull (Proxmox VE only)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
class ProxmoxTransport:
|
||||
"""
|
||||
Legacy transport using pct push/pull via a Proxmox host.
|
||||
Only works when running ON or WITH ACCESS TO a Proxmox VE node.
|
||||
|
||||
Config keys:
|
||||
proxmox_host: IP/hostname of the PVE node
|
||||
proxmox_ct: container ID
|
||||
proxmox_path: target path inside the CT (default /opt/aurelio/brain)
|
||||
"""
|
||||
|
||||
def name(self) -> str:
|
||||
return "proxmox"
|
||||
|
||||
def transmit(self, payload: dict[str, Any], consent: ConsentRecord) -> TransmissionResult:
|
||||
raw = consent._raw or {}
|
||||
host = raw.get("proxmox_host", os.environ.get("PROXMOX_HOST", ""))
|
||||
ct_id = raw.get("proxmox_ct", 0)
|
||||
ct_path = raw.get("proxmox_path", "/opt/aurelio/brain")
|
||||
|
||||
if not host or not ct_id:
|
||||
return TransmissionResult(False, "proxmox", "Missing proxmox_host or proxmox_ct")
|
||||
|
||||
if not shutil.which("ssh"):
|
||||
return TransmissionResult(False, "proxmox", "ssh not in PATH")
|
||||
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False) as tmp:
|
||||
tmp_path = tmp.name
|
||||
|
||||
with tarfile.open(tmp_path, "w:gz") as tar:
|
||||
data = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
if consent.redact_secrets:
|
||||
data = redact(data.decode("utf-8")).encode("utf-8")
|
||||
import io
|
||||
info = tarfile.TarInfo(name="payload.json")
|
||||
info.size = len(data)
|
||||
tar.addfile(info, io.BytesIO(data))
|
||||
|
||||
# scp to proxmox host
|
||||
r = subprocess.run(
|
||||
["scp", tmp_path, f"root@{host}:/tmp/"],
|
||||
capture_output=True, timeout=60
|
||||
)
|
||||
if r.returncode != 0:
|
||||
os.unlink(tmp_path)
|
||||
return TransmissionResult(False, "proxmox", f"scp failed: {r.stderr.decode()[:200]}")
|
||||
|
||||
# pct push into CT
|
||||
r = subprocess.run(
|
||||
["ssh", f"root@{host}", "pct", "push", str(ct_id),
|
||||
f"/tmp/{Path(tmp_path).name}", f"/tmp/{Path(tmp_path).name}"],
|
||||
capture_output=True, timeout=60
|
||||
)
|
||||
os.unlink(tmp_path)
|
||||
if r.returncode != 0:
|
||||
return TransmissionResult(False, "proxmox", f"pct push failed: {r.stderr.decode()[:200]}")
|
||||
|
||||
size = os.path.getsize(tmp_path) if os.path.exists(tmp_path) else 0
|
||||
return TransmissionResult(
|
||||
True, "proxmox", f"Pushed to CT {ct_id} on {host}",
|
||||
bytes_sent=size,
|
||||
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||
)
|
||||
except Exception as e:
|
||||
return TransmissionResult(False, "proxmox", f"Proxmox transmit failed: {e}")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Registry
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
TRANSPORTS: dict[str, Transport] = {
|
||||
"http": HttpTransport(),
|
||||
"ssh": SshTransport(),
|
||||
"local": LocalTransport(),
|
||||
"s3": S3Transport(),
|
||||
"proxmox": ProxmoxTransport(),
|
||||
}
|
||||
|
||||
|
||||
def get_transport(name: str) -> Transport:
|
||||
"""Get a transport backend by name. Raises ValueError if unknown."""
|
||||
if name not in TRANSPORTS:
|
||||
available = ", ".join(TRANSPORTS)
|
||||
raise ValueError(f"Unknown transport '{name}'. Available: {available}")
|
||||
return TRANSPORTS[name]
|
||||
|
||||
|
||||
def available_transports() -> list[str]:
|
||||
"""Return list of registered transport names."""
|
||||
return list(TRANSPORTS.keys())
|
||||
406
scripts/gws/gws.py
Normal file
406
scripts/gws/gws.py
Normal file
|
|
@ -0,0 +1,406 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Google Workspace ingestion tool for Aurelio research studies.
|
||||
|
||||
Supports two auth modes:
|
||||
1. OAuth2 user consent (for individual accounts)
|
||||
2. Service account with domain-wide delegation (preferred for Workspace-wide studies)
|
||||
|
||||
Usage:
|
||||
# Service account (place key at ~/.aurelio/gws/service_account.json)
|
||||
python3 scripts/gws/gws.py fetch-emails --emails fabio@savearth.io,claudio@savearth.io,joao@savearth.io --out-dir ./gws-output
|
||||
|
||||
# OAuth2 (fallback)
|
||||
python3 scripts/gws/gws.py auth --email fabio@savearth.io
|
||||
python3 scripts/gws/gws.py auth-code --email fabio@savearth.io --code '<code>'
|
||||
python3 scripts/gws/gws.py fetch-emails --emails ... --out-dir ./gws-output
|
||||
"""
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
from google.auth.transport.requests import Request
|
||||
from google.oauth2.credentials import Credentials
|
||||
from google.oauth2.service_account import Credentials as ServiceAccountCredentials
|
||||
from google_auth_oauthlib.flow import InstalledAppFlow
|
||||
from googleapiclient.discovery import build
|
||||
from googleapiclient.errors import HttpError
|
||||
|
||||
# Read-only scopes for research study
|
||||
SCOPES = [
|
||||
"https://www.googleapis.com/auth/gmail.readonly",
|
||||
"https://www.googleapis.com/auth/calendar.readonly",
|
||||
"https://www.googleapis.com/auth/drive.readonly",
|
||||
"https://www.googleapis.com/auth/userinfo.profile",
|
||||
"https://www.googleapis.com/auth/contacts.readonly",
|
||||
]
|
||||
|
||||
DEFAULT_CLIENT_SECRET = os.environ.get(
|
||||
"GWS_CLIENT_SECRET",
|
||||
str(Path.home() / ".aurelio" / "gws" / "client_secret.json"),
|
||||
)
|
||||
SERVICE_ACCOUNT_FILE = os.environ.get(
|
||||
"GWS_SERVICE_ACCOUNT",
|
||||
str(Path.home() / ".aurelio" / "gws" / "service_account.json"),
|
||||
)
|
||||
TOKEN_DIR = Path(os.environ.get("GWS_TOKEN_DIR", Path.home() / ".aurelio" / "gws" / "tokens"))
|
||||
|
||||
|
||||
def _iso_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _client_secret_path() -> Path:
|
||||
p = Path(DEFAULT_CLIENT_SECRET)
|
||||
if not p.exists():
|
||||
raise SystemExit(
|
||||
f"OAuth client secret not found at {p}. "
|
||||
"Set GWS_CLIENT_SECRET or place it at ~/.aurelio/gws/client_secret.json"
|
||||
)
|
||||
return p
|
||||
|
||||
|
||||
def _service_account_path() -> Path | None:
|
||||
p = Path(SERVICE_ACCOUNT_FILE)
|
||||
return p if p.exists() else None
|
||||
|
||||
|
||||
def _token_path(email: str) -> Path:
|
||||
TOKEN_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return TOKEN_DIR / f"{email}.json"
|
||||
|
||||
|
||||
def _load_creds(email: str) -> Credentials | None:
|
||||
tp = _token_path(email)
|
||||
if tp.exists():
|
||||
return Credentials.from_authorized_user_file(str(tp), SCOPES)
|
||||
return None
|
||||
|
||||
|
||||
def _save_creds(email: str, creds: Credentials) -> None:
|
||||
tp = _token_path(email)
|
||||
tp.write_text(creds.to_json())
|
||||
os.chmod(tp, 0o600)
|
||||
|
||||
|
||||
AnyCredentials = Credentials | ServiceAccountCredentials
|
||||
|
||||
|
||||
def _ensure_creds(email: str) -> AnyCredentials:
|
||||
sa_path = _service_account_path()
|
||||
if sa_path:
|
||||
creds = ServiceAccountCredentials.from_service_account_file(
|
||||
str(sa_path), scopes=SCOPES, subject=email
|
||||
)
|
||||
creds.refresh(Request())
|
||||
return creds
|
||||
|
||||
creds = _load_creds(email)
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
_save_creds(email, creds)
|
||||
return creds
|
||||
if creds and creds.valid:
|
||||
return creds
|
||||
|
||||
flow = InstalledAppFlow.from_client_secrets_file(str(_client_secret_path()), SCOPES)
|
||||
creds = flow.run_local_server(port=8080)
|
||||
_save_creds(email, creds)
|
||||
return creds
|
||||
|
||||
|
||||
def cmd_auth(args: argparse.Namespace) -> int:
|
||||
email = args.email
|
||||
if _service_account_path():
|
||||
print(f"Service account mode active; no OAuth needed for {email}")
|
||||
return 0
|
||||
|
||||
creds = _load_creds(email)
|
||||
if creds and creds.valid:
|
||||
print(f"Already authenticated for {email}")
|
||||
return 0
|
||||
if creds and creds.expired and creds.refresh_token:
|
||||
creds.refresh(Request())
|
||||
_save_creds(email, creds)
|
||||
print(f"Refreshed token for {email}")
|
||||
return 0
|
||||
|
||||
flow = InstalledAppFlow.from_client_secrets_file(str(_client_secret_path()), SCOPES)
|
||||
auth_url, _ = flow.authorization_url(access_type="offline", prompt="consent")
|
||||
print(f"Authorize {email} here:\n{auth_url}\n")
|
||||
print("Then run:")
|
||||
print(f" python3 scripts/gws/gws.py auth-code --email {email} --code '<code>'")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_auth_code(args: argparse.Namespace) -> int:
|
||||
email = args.email
|
||||
code = args.code
|
||||
flow = InstalledAppFlow.from_client_secrets_file(str(_client_secret_path()), SCOPES)
|
||||
flow.fetch_token(code=code)
|
||||
creds = flow.credentials
|
||||
_save_creds(email, creds)
|
||||
print(f"Authenticated {email}; token saved to {_token_path(email)}")
|
||||
return 0
|
||||
|
||||
|
||||
def _build(service_name: str, version: str, creds: AnyCredentials) -> Any:
|
||||
return build(service_name, version, credentials=creds, cache_discovery=False)
|
||||
|
||||
|
||||
def _query_for_addresses(addresses: Iterable[str]) -> str:
|
||||
parts = []
|
||||
for a in addresses:
|
||||
parts.append(f"from:{a} OR to:{a} OR cc:{a}")
|
||||
return " OR ".join(f"({p})" for p in parts)
|
||||
|
||||
|
||||
def cmd_fetch_emails(args: argparse.Namespace) -> int:
|
||||
emails = args.emails.split(",")
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
actor = args.actor or emails[0]
|
||||
|
||||
creds = _ensure_creds(actor)
|
||||
service = _build("gmail", "v1", creds)
|
||||
query = _query_for_addresses(emails)
|
||||
if args.since:
|
||||
query += f" after:{args.since}"
|
||||
if args.before:
|
||||
query += f" before:{args.before}"
|
||||
|
||||
results = []
|
||||
page_token = None
|
||||
while True:
|
||||
resp = (
|
||||
service.users()
|
||||
.messages()
|
||||
.list(userId="me", q=query, pageToken=page_token, maxResults=min(args.max_results, 500))
|
||||
.execute()
|
||||
)
|
||||
messages = resp.get("messages", [])
|
||||
for m in messages:
|
||||
msg = (
|
||||
service.users()
|
||||
.messages()
|
||||
.get(userId="me", id=m["id"], format="full")
|
||||
.execute()
|
||||
)
|
||||
payload = msg.get("payload", {})
|
||||
headers = {h["name"].lower(): h["value"] for h in payload.get("headers", [])}
|
||||
body = ""
|
||||
parts = payload.get("parts", [])
|
||||
for part in parts:
|
||||
if part.get("mimeType") == "text/plain" and "data" in part.get("body", {}):
|
||||
body = base64.urlsafe_b64decode(part["body"]["data"]).decode("utf-8", errors="replace")
|
||||
break
|
||||
results.append(
|
||||
{
|
||||
"id": msg["id"],
|
||||
"thread_id": msg["threadId"],
|
||||
"internal_date": msg.get("internalDate"),
|
||||
"subject": headers.get("subject", ""),
|
||||
"from": headers.get("from", ""),
|
||||
"to": headers.get("to", ""),
|
||||
"cc": headers.get("cc", ""),
|
||||
"body": body,
|
||||
}
|
||||
)
|
||||
if len(results) >= args.max_results:
|
||||
break
|
||||
page_token = resp.get("nextPageToken")
|
||||
if not page_token or len(results) >= args.max_results:
|
||||
break
|
||||
|
||||
out_file = out_dir / f"emails-{actor}-{_iso_now()}.jsonl"
|
||||
with open(out_file, "w", encoding="utf-8") as f:
|
||||
for r in results:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
print(f"Wrote {len(results)} emails to {out_file}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_fetch_calendar(args: argparse.Namespace) -> int:
|
||||
emails = args.emails.split(",")
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
actor = args.actor or emails[0]
|
||||
|
||||
creds = _ensure_creds(actor)
|
||||
service = _build("calendar", "v3", creds)
|
||||
|
||||
time_min = args.since or "2020-01-01T00:00:00Z"
|
||||
time_max = args.before or "2030-01-01T00:00:00Z"
|
||||
|
||||
results = []
|
||||
page_token = None
|
||||
while True:
|
||||
resp = (
|
||||
service.events()
|
||||
.list(
|
||||
calendarId="primary",
|
||||
timeMin=time_min,
|
||||
timeMax=time_max,
|
||||
pageToken=page_token,
|
||||
maxResults=min(args.max_results, 2500),
|
||||
showDeleted=False,
|
||||
singleEvents=True,
|
||||
orderBy="startTime",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
for item in resp.get("items", []):
|
||||
attendees = [a.get("email", "") for a in item.get("attendees", [])]
|
||||
if args.require_match and not any(e in attendees for e in emails):
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"id": item["id"],
|
||||
"summary": item.get("summary", ""),
|
||||
"description": item.get("description", ""),
|
||||
"start": item.get("start", {}),
|
||||
"end": item.get("end", {}),
|
||||
"creator": item.get("creator", {}),
|
||||
"attendees": attendees,
|
||||
"link": item.get("htmlLink", ""),
|
||||
}
|
||||
)
|
||||
if len(results) >= args.max_results:
|
||||
break
|
||||
page_token = resp.get("nextPageToken")
|
||||
if not page_token or len(results) >= args.max_results:
|
||||
break
|
||||
|
||||
out_file = out_dir / f"calendar-{actor}-{_iso_now()}.jsonl"
|
||||
with open(out_file, "w", encoding="utf-8") as f:
|
||||
for r in results:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
print(f"Wrote {len(results)} calendar events to {out_file}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_fetch_documents(args: argparse.Namespace) -> int:
|
||||
out_dir = Path(args.out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
actor = args.actor
|
||||
|
||||
creds = _ensure_creds(actor)
|
||||
service = _build("drive", "v3", creds)
|
||||
|
||||
results = []
|
||||
page_token = None
|
||||
query = "trashed = false"
|
||||
if args.mime_type:
|
||||
query += f" and mimeType = '{args.mime_type}'"
|
||||
while True:
|
||||
resp = (
|
||||
service.files()
|
||||
.list(
|
||||
q=query,
|
||||
pageToken=page_token,
|
||||
pageSize=min(args.max_results, 1000),
|
||||
fields="nextPageToken, files(id, name, mimeType, createdTime, modifiedTime, owners, webViewLink, size)",
|
||||
)
|
||||
.execute()
|
||||
)
|
||||
for item in resp.get("files", []):
|
||||
results.append(
|
||||
{
|
||||
"id": item["id"],
|
||||
"name": item.get("name", ""),
|
||||
"mime_type": item.get("mimeType", ""),
|
||||
"created_time": item.get("createdTime", ""),
|
||||
"modified_time": item.get("modifiedTime", ""),
|
||||
"owners": [o.get("emailAddress", "") for o in item.get("owners", [])],
|
||||
"link": item.get("webViewLink", ""),
|
||||
"size": item.get("size", ""),
|
||||
}
|
||||
)
|
||||
if len(results) >= args.max_results:
|
||||
break
|
||||
page_token = resp.get("nextPageToken")
|
||||
if not page_token or len(results) >= args.max_results:
|
||||
break
|
||||
|
||||
out_file = out_dir / f"documents-{actor}-{_iso_now()}.jsonl"
|
||||
with open(out_file, "w", encoding="utf-8") as f:
|
||||
for r in results:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
print(f"Wrote {len(results)} documents to {out_file}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_to_brain(args: argparse.Namespace) -> int:
|
||||
"""Normalize gws output into brain pages."""
|
||||
out_dir = Path(args.out_dir)
|
||||
study_dir = out_dir / "brain"
|
||||
study_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for f in sorted(out_dir.glob("emails-*.jsonl")):
|
||||
with open(f, encoding="utf-8") as fh:
|
||||
emails = [json.loads(line) for line in fh]
|
||||
actor = f.stem.split("-", 2)[1]
|
||||
page = {
|
||||
"title": f"Savearth communication study — {actor} emails",
|
||||
"source": "gws",
|
||||
"count": len(emails),
|
||||
"emails": emails[:50],
|
||||
}
|
||||
(study_dir / f"emails-{actor}.json").write_text(json.dumps(page, ensure_ascii=False, indent=2))
|
||||
print(f"Normalized brain pages to {study_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
p = argparse.ArgumentParser(description="Google Workspace ingestion for Aurelio")
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
a = sub.add_parser("auth", help="Print authorization URL for an email")
|
||||
a.add_argument("--email", required=True)
|
||||
a.set_defaults(func=cmd_auth)
|
||||
|
||||
ac = sub.add_parser("auth-code", help="Exchange authorization code for token")
|
||||
ac.add_argument("--email", required=True)
|
||||
ac.add_argument("--code", required=True)
|
||||
ac.set_defaults(func=cmd_auth_code)
|
||||
|
||||
fe = sub.add_parser("fetch-emails", help="Fetch Gmail messages involving addresses")
|
||||
fe.add_argument("--emails", required=True, help="Comma-separated addresses")
|
||||
fe.add_argument("--actor", default=None, help="Workspace account to authenticate as")
|
||||
fe.add_argument("--since", default=None, help="YYYY/MM/DD")
|
||||
fe.add_argument("--before", default=None, help="YYYY/MM/DD")
|
||||
fe.add_argument("--max-results", type=int, default=500)
|
||||
fe.add_argument("--out-dir", default="./gws-output")
|
||||
fe.set_defaults(func=cmd_fetch_emails)
|
||||
|
||||
fc = sub.add_parser("fetch-calendar", help="Fetch Calendar events")
|
||||
fc.add_argument("--emails", required=True)
|
||||
fc.add_argument("--actor", default=None)
|
||||
fc.add_argument("--since", default=None)
|
||||
fc.add_argument("--before", default=None)
|
||||
fc.add_argument("--max-results", type=int, default=500)
|
||||
fc.add_argument("--out-dir", default="./gws-output")
|
||||
fc.add_argument("--require-match", action="store_true", default=True)
|
||||
fc.set_defaults(func=cmd_fetch_calendar)
|
||||
|
||||
fd = sub.add_parser("fetch-documents", help="Fetch Drive metadata")
|
||||
fd.add_argument("--actor", required=True)
|
||||
fd.add_argument("--mime-type", default=None, help="e.g. application/vnd.google-apps.document")
|
||||
fd.add_argument("--max-results", type=int, default=500)
|
||||
fd.add_argument("--out-dir", default="./gws-output")
|
||||
fd.set_defaults(func=cmd_fetch_documents)
|
||||
|
||||
tb = sub.add_parser("to-brain", help="Convert gws output to brain pages")
|
||||
tb.add_argument("--out-dir", default="./gws-output")
|
||||
tb.set_defaults(func=cmd_to_brain)
|
||||
|
||||
args = p.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
282
scripts/lifestream/lifestream_digest.py
Normal file
282
scripts/lifestream/lifestream_digest.py
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Lifestream Roundtable Digest — daily professional summary of Telegram Saved Messages.
|
||||
|
||||
Reads entries from the lifestream SQLite DB, groups by category, fetches link titles,
|
||||
optionally calls an LLM for roundtable commentary, and sends the digest via Telegram.
|
||||
|
||||
Environment variables (loaded from /opt/aurelio-lifestream/.env):
|
||||
LIFESTREAM_DB — path to SQLite DB (default: /opt/aurelio-lifestream/data/lifestream.db)
|
||||
OPENROUTER_API_KEY — optional; enables LLM roundtable commentary
|
||||
TELEGRAM_SEND_MODE — "userbot_self" | "bot" | "stdout" (default: stdout)
|
||||
TELEGRAM_BOT_TOKEN — required for TELEGRAM_SEND_MODE=bot
|
||||
TELEGRAM_CHAT_ID — required for TELEGRAM_SEND_MODE=bot
|
||||
TELEGRAM_API_ID — required for TELEGRAM_SEND_MODE=userbot_self
|
||||
TELEGRAM_API_HASH — required for TELEGRAM_SEND_MODE=userbot_self
|
||||
DIGEST_WINDOW_HOURS — default: 24
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
# ─── Load .env before reading config ─────────────────────────────────
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
ENV_PATH = BASE_DIR / ".env"
|
||||
if ENV_PATH.exists():
|
||||
with ENV_PATH.open() as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
k, _, v = line.partition("=")
|
||||
os.environ.setdefault(k.strip(), v.strip())
|
||||
|
||||
# ─── Config ──────────────────────────────────────────────────────────
|
||||
|
||||
DB_PATH = Path(os.environ.get("LIFESTREAM_DB", str(BASE_DIR / "data" / "lifestream.db")))
|
||||
WINDOW_HOURS = int(os.environ.get("DIGEST_WINDOW_HOURS", "24"))
|
||||
OPENROUTER_API_KEY = os.environ.get("OPENROUTER_API_KEY", "")
|
||||
SEND_MODE = os.environ.get("TELEGRAM_SEND_MODE", "stdout")
|
||||
|
||||
# Personas for roundtable commentary (shorthand; LLM expands voice)
|
||||
PERSONAS = [
|
||||
{"name": "Fábio Coutada", "role": "Embedded Systems Architect", "voice": "terse, evidence-first, distrusts hand-waving, asks for datasheets/registers"},
|
||||
{"name": "Cláudio Coelho", "role": "CTO", "voice": "balances engineering and business, risk-aware, asks about testing/compliance/maintainability"},
|
||||
{"name": "João Machado", "role": "CEO", "voice": "mission-driven, wants timelines/runway/customers, headline and decision in first 2 minutes"},
|
||||
{"name": "Raphael Cautus", "role": "Maestro / Operator", "voice": "connects tech to portfolio strategy, terse, asks what to kill/keep/fund"},
|
||||
]
|
||||
|
||||
# ─── DB read ─────────────────────────────────────────────────────────
|
||||
|
||||
def get_entries(hours: int = WINDOW_HOURS) -> list[dict[str, Any]]:
|
||||
if not DB_PATH.exists():
|
||||
return []
|
||||
cutoff = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
conn.row_factory = sqlite3.Row
|
||||
cur = conn.execute(
|
||||
"SELECT * FROM entries WHERE timestamp > ? ORDER BY timestamp DESC",
|
||||
(cutoff.isoformat(),),
|
||||
)
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
conn.close()
|
||||
for row in rows:
|
||||
for key in ("urls", "documents", "classification"):
|
||||
try:
|
||||
row[key] = json.loads(row.get(key) or "[]") if key != "classification" else (row.get(key) or "")
|
||||
except json.JSONDecodeError:
|
||||
row[key] = row.get(key, "")
|
||||
return rows
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_url_title(url: str, timeout: int = 8) -> str | None:
|
||||
try:
|
||||
r = requests.get(url, timeout=timeout, headers={"User-Agent": "Mozilla/5.0"})
|
||||
r.raise_for_status()
|
||||
m = re.search(r"<title[^>]*>(.*?)</title>", r.text, re.S | re.I)
|
||||
if m:
|
||||
return re.sub(r"\s+", " ", m.group(1)).strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def group_entries(entries: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
|
||||
groups: dict[str, list[dict[str, Any]]] = {"link": [], "idea": [], "task": [], "note": [], "media": [], "other": []}
|
||||
for e in entries:
|
||||
ctype = e.get("content_type", "text")
|
||||
classification = (e.get("classification") or "").lower()
|
||||
if ctype in ("image", "video", "audio", "voice", "document"):
|
||||
groups["media"].append(e)
|
||||
elif "link" in classification or e.get("urls"):
|
||||
groups["link"].append(e)
|
||||
elif "idea" in classification:
|
||||
groups["idea"].append(e)
|
||||
elif "task" in classification:
|
||||
groups["task"].append(e)
|
||||
elif "note" in classification or ctype == "text":
|
||||
groups["note"].append(e)
|
||||
else:
|
||||
groups["other"].append(e)
|
||||
return groups
|
||||
|
||||
# ─── LLM roundtable ───────────────────────────────────────────────────
|
||||
|
||||
def build_llm_prompt(entries: list[dict[str, Any]], groups: dict[str, list[dict[str, Any]]]) -> str:
|
||||
lines = [
|
||||
"You are a senior editorial assistant for Portugal Futurista.",
|
||||
"Produce a concise, professional daily digest in Portuguese of the user's saved Telegram messages.",
|
||||
"For each saved link, idea, and task, include a short roundtable commentary from the following engineers/personas:",
|
||||
"",
|
||||
]
|
||||
for p in PERSONAS:
|
||||
lines.append(f"- {p['name']} ({p['role']}): {p['voice']}")
|
||||
lines += ["", f"Window: last {WINDOW_HOURS}h. Total entries: {len(entries)}.", ""]
|
||||
|
||||
def fmt_item(e: dict[str, Any], idx: int) -> str:
|
||||
text = (e.get("raw_text") or "").replace("\n", " ")
|
||||
urls = ", ".join(e.get("urls", []))
|
||||
cls = e.get("classification") or "note"
|
||||
ts = e.get("timestamp", "")[:19]
|
||||
return f"[{idx}] ({cls}) {ts}: {text} {urls}".strip()
|
||||
|
||||
for cat, items in groups.items():
|
||||
if not items:
|
||||
continue
|
||||
lines.append(f"## {cat.upper()} ({len(items)})")
|
||||
for i, item in enumerate(items[:20], 1):
|
||||
lines.append(fmt_item(item, i))
|
||||
lines.append("")
|
||||
|
||||
lines += ["", "Output format (markdown, Telegram-friendly):"]
|
||||
lines.append("# Resumo da Corrente — <data>")
|
||||
lines.append("## Links comentados")
|
||||
lines.append("For each link: title, source URL, and 2-3 sentence roundtable discussion among the personas.")
|
||||
lines.append("## Ideias em debate")
|
||||
lines.append("For each idea: the idea and a concise multi-persona take.")
|
||||
lines.append("## Acções decididas")
|
||||
lines.append("Concrete action items extracted from tasks/notes with owner inference when obvious.")
|
||||
lines.append("## Próximo passo")
|
||||
lines.append("One-line recommendation for what the user should tackle first today.")
|
||||
lines.append("Keep total length under 3,000 chars. Tone: sharp, no fluff, no markdown tables.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_llm_digest(prompt: str) -> str:
|
||||
if not OPENROUTER_API_KEY:
|
||||
raise RuntimeError("OPENROUTER_API_KEY not configured")
|
||||
resp = requests.post(
|
||||
"https://openrouter.ai/api/v1/chat/completions",
|
||||
headers={
|
||||
"Authorization": f"Bearer {OPENROUTER_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": "openai/gpt-4o-mini",
|
||||
"messages": [{"role": "user", "content": prompt}],
|
||||
"max_tokens": 2048,
|
||||
"temperature": 0.6,
|
||||
},
|
||||
timeout=120,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return data["choices"][0]["message"]["content"]
|
||||
|
||||
# ─── Fallback digest ─────────────────────────────────────────────────
|
||||
|
||||
def generate_fallback_digest(entries: list[dict[str, Any]], groups: dict[str, list[dict[str, Any]]]) -> str:
|
||||
lines = [f"# Resumo da Corrente — {datetime.now(timezone.utc).strftime('%Y-%m-%d')}", ""]
|
||||
lines.append(f"{len(entries)} mensagens guardadas nas últimas {WINDOW_HOURS}h.")
|
||||
lines.append("")
|
||||
|
||||
if groups["link"]:
|
||||
lines.append("## Links")
|
||||
for e in groups["link"][:10]:
|
||||
for url in e.get("urls", []):
|
||||
title = fetch_url_title(url)
|
||||
display = f"[{title}]" if title else "[link]"
|
||||
lines.append(f"- {display}({url})")
|
||||
lines.append("")
|
||||
|
||||
if groups["idea"]:
|
||||
lines.append("## Ideias")
|
||||
for e in groups["idea"][:10]:
|
||||
text = (e.get("raw_text") or "").replace("\n", " ")
|
||||
lines.append(f"- {text}")
|
||||
lines.append("")
|
||||
|
||||
if groups["task"]:
|
||||
lines.append("## Tarefas / Ações")
|
||||
for e in groups["task"][:10]:
|
||||
text = (e.get("raw_text") or "").replace("\n", " ")
|
||||
lines.append(f"- {text}")
|
||||
lines.append("")
|
||||
|
||||
if groups["note"]:
|
||||
lines.append("## Notas")
|
||||
for e in groups["note"][:10]:
|
||||
text = (e.get("raw_text") or "").replace("\n", " ")
|
||||
lines.append(f"- {text}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("_Configure OPENROUTER_API_KEY para ativar o roundtable com os engenheiros._")
|
||||
return "\n".join(lines)
|
||||
|
||||
# ─── Telegram delivery ───────────────────────────────────────────────
|
||||
|
||||
async def send_userbot_self(text: str) -> None:
|
||||
from telethon import TelegramClient
|
||||
data_dir = BASE_DIR / "data"
|
||||
session_path = str(data_dir / "session")
|
||||
client = TelegramClient(
|
||||
session_path,
|
||||
int(os.environ["TELEGRAM_API_ID"]),
|
||||
os.environ["TELEGRAM_API_HASH"],
|
||||
)
|
||||
await client.connect()
|
||||
if not await client.is_user_authorized():
|
||||
raise RuntimeError("Telethon session not authorized; run userbot first")
|
||||
await client.send_message("self", text)
|
||||
await client.disconnect()
|
||||
|
||||
|
||||
def send_bot(text: str) -> None:
|
||||
token = os.environ["TELEGRAM_BOT_TOKEN"]
|
||||
chat_id = os.environ["TELEGRAM_CHAT_ID"]
|
||||
resp = requests.post(
|
||||
f"https://api.telegram.org/bot{token}/sendMessage",
|
||||
json={"chat_id": chat_id, "text": text, "parse_mode": "Markdown"},
|
||||
timeout=30,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
def send_digest(text: str) -> None:
|
||||
if SEND_MODE == "stdout":
|
||||
print(text)
|
||||
return
|
||||
if SEND_MODE == "userbot_self":
|
||||
asyncio.run(send_userbot_self(text))
|
||||
elif SEND_MODE == "bot":
|
||||
send_bot(text)
|
||||
else:
|
||||
raise ValueError(f"Unknown TELEGRAM_SEND_MODE: {SEND_MODE}")
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> int:
|
||||
entries = get_entries()
|
||||
if not entries:
|
||||
print(f"Nenhuma entrada nas últimas {WINDOW_HOURS}h.")
|
||||
return 0
|
||||
|
||||
groups = group_entries(entries)
|
||||
|
||||
try:
|
||||
if OPENROUTER_API_KEY:
|
||||
prompt = build_llm_prompt(entries, groups)
|
||||
digest = generate_llm_digest(prompt)
|
||||
else:
|
||||
digest = generate_fallback_digest(entries, groups)
|
||||
except Exception as e:
|
||||
print(f"[digest] LLM failed ({e}); using fallback.", file=sys.stderr)
|
||||
digest = generate_fallback_digest(entries, groups)
|
||||
|
||||
send_digest(digest)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
310
scripts/lifestream/lifestream_userbot.py
Normal file
310
scripts/lifestream/lifestream_userbot.py
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Lifestream Userbot — monitors Telegram Saved Messages via Telethon.
|
||||
|
||||
Uses your Telegram USER account (not a bot) to watch Saved Messages
|
||||
and ingest them into the lifestream SQLite database.
|
||||
|
||||
First run requires interactive authentication (phone + code).
|
||||
After that, the session is persisted in data/session.session.
|
||||
|
||||
Requirements:
|
||||
pip install telethon aiosqlite
|
||||
|
||||
Environment variables (in .env):
|
||||
TELEGRAM_API_ID — from https://my.telegram.org
|
||||
TELEGRAM_API_HASH — from https://my.telegram.org
|
||||
LIFESTREAM_DB — path to SQLite DB (default: ./data/lifestream.db)
|
||||
MEDIA_PATH — path to save media (default: ./data/media)
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import aiosqlite
|
||||
from telethon import TelegramClient, events
|
||||
from telethon.tl.types import (
|
||||
MessageMediaPhoto,
|
||||
MessageMediaDocument,
|
||||
MessageMediaWebPage,
|
||||
)
|
||||
|
||||
# ─── Config ──────────────────────────────────────────────────────────
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
MEDIA_DIR = Path(os.environ.get("MEDIA_PATH", str(DATA_DIR / "media")))
|
||||
DB_PATH = Path(os.environ.get("LIFESTREAM_DB", str(DATA_DIR / "lifestream.db")))
|
||||
SESSION_PATH = str(DATA_DIR / "session")
|
||||
|
||||
API_ID = int(os.environ.get("TELEGRAM_API_ID", "0"))
|
||||
API_HASH = os.environ.get("TELEGRAM_API_HASH", "")
|
||||
|
||||
if not API_ID or not API_HASH:
|
||||
print("ERROR: TELEGRAM_API_ID and TELEGRAM_API_HASH are required.")
|
||||
print("Get them at https://my.telegram.org → API development tools")
|
||||
sys.exit(1)
|
||||
|
||||
# Saved Messages is "self" — the user's own chat
|
||||
SAVED_MESSAGES_PEER = "self"
|
||||
|
||||
|
||||
# ─── Database ────────────────────────────────────────────────────────
|
||||
|
||||
async def init_db(db_path: Path) -> aiosqlite.Connection:
|
||||
"""Open the lifestream DB and ensure the entries table exists."""
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
db = await aiosqlite.connect(str(db_path))
|
||||
await db.execute("""
|
||||
CREATE TABLE IF NOT EXISTS entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL DEFAULT 'telegram',
|
||||
source_id TEXT NOT NULL UNIQUE,
|
||||
timestamp TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
raw_text TEXT,
|
||||
urls TEXT,
|
||||
articles TEXT,
|
||||
documents TEXT,
|
||||
classification TEXT,
|
||||
chat_id TEXT,
|
||||
chat_title TEXT,
|
||||
from_name TEXT,
|
||||
from_username TEXT,
|
||||
reply_to_message_id TEXT,
|
||||
is_forwarded INTEGER DEFAULT 0,
|
||||
forward_from TEXT,
|
||||
media_group_id TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_entries_timestamp ON entries(timestamp DESC)")
|
||||
await db.execute("CREATE INDEX IF NOT EXISTS idx_entries_source_id ON entries(source_id)")
|
||||
await db.commit()
|
||||
return db
|
||||
|
||||
|
||||
async def entry_exists(db: aiosqlite.Connection, source_id: str) -> bool:
|
||||
"""Check if an entry already exists (idempotent)."""
|
||||
async with db.execute(
|
||||
"SELECT 1 FROM entries WHERE source_id = ?", (source_id,)
|
||||
) as cur:
|
||||
return (await cur.fetchone()) is not None
|
||||
|
||||
|
||||
async def insert_entry(db: aiosqlite.Connection, entry: dict):
|
||||
"""Insert a lifestream entry."""
|
||||
await db.execute(
|
||||
"""INSERT OR IGNORE INTO entries
|
||||
(source, source_id, timestamp, content_type, raw_text, urls,
|
||||
documents, chat_id, chat_title, from_name, from_username,
|
||||
reply_to_message_id, is_forwarded, forward_from, media_group_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
entry.get("source", "telegram"),
|
||||
entry["source_id"],
|
||||
entry["timestamp"],
|
||||
entry["content_type"],
|
||||
entry.get("raw_text"),
|
||||
json.dumps(entry.get("urls", [])),
|
||||
json.dumps(entry.get("documents", [])),
|
||||
entry.get("chat_id"),
|
||||
entry.get("chat_title"),
|
||||
entry.get("from_name"),
|
||||
entry.get("from_username"),
|
||||
entry.get("reply_to_message_id"),
|
||||
1 if entry.get("is_forwarded") else 0,
|
||||
entry.get("forward_from"),
|
||||
entry.get("media_group_id"),
|
||||
),
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# ─── Media download ──────────────────────────────────────────────────
|
||||
|
||||
async def download_media(client: TelegramClient, msg, media_type: str) -> str | None:
|
||||
"""Download media from a message. Returns the local file path."""
|
||||
MEDIA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
ext_map = {"photo": ".jpg", "video": ".mp4", "voice": ".ogg", "audio": ".mp3", "document": ""}
|
||||
ext = ext_map.get(media_type, "")
|
||||
filename = f"{msg.id}{ext}"
|
||||
out_path = MEDIA_DIR / filename
|
||||
if out_path.exists():
|
||||
return str(out_path)
|
||||
try:
|
||||
await client.download_media(msg, file=str(out_path))
|
||||
return str(out_path) if out_path.exists() else None
|
||||
except Exception as e:
|
||||
print(f"[Media] Download failed for {msg.id}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# ─── Message classification ──────────────────────────────────────────
|
||||
|
||||
def classify_message(msg) -> dict:
|
||||
"""Extract content type and metadata from a Telegram message."""
|
||||
content_type = "text"
|
||||
media_type = None
|
||||
|
||||
if msg.photo:
|
||||
content_type, media_type = "image", "photo"
|
||||
elif msg.video:
|
||||
content_type, media_type = "video", "video"
|
||||
elif msg.voice:
|
||||
content_type, media_type = "voice", "voice"
|
||||
elif msg.audio:
|
||||
content_type, media_type = "audio", "audio"
|
||||
elif msg.document:
|
||||
content_type, media_type = "document", "document"
|
||||
elif msg.sticker:
|
||||
content_type, media_type = "sticker", "sticker"
|
||||
elif msg.geo:
|
||||
content_type, media_type = "location", "location"
|
||||
elif msg.contact:
|
||||
content_type, media_type = "contact", "contact"
|
||||
elif msg.gif:
|
||||
content_type, media_type = "animation", "animation"
|
||||
elif msg.video_note:
|
||||
content_type, media_type = "video_note", "video_note"
|
||||
elif msg.poll:
|
||||
content_type, media_type = "poll", "poll"
|
||||
|
||||
return {"content_type": content_type, "media_type": media_type}
|
||||
|
||||
|
||||
def extract_urls(text: str | None) -> list[str]:
|
||||
"""Extract URLs from message text."""
|
||||
if not text:
|
||||
return []
|
||||
return re.findall(r"https?://\S+", text)
|
||||
|
||||
|
||||
# ─── Message handler ─────────────────────────────────────────────────
|
||||
|
||||
async def handle_message(client: TelegramClient, db: aiosqlite.Connection, msg):
|
||||
"""Process a single message from Saved Messages."""
|
||||
source_id = str(msg.id)
|
||||
|
||||
if await entry_exists(db, source_id):
|
||||
return # already ingested
|
||||
|
||||
info = classify_message(msg)
|
||||
text = msg.text or msg.message or ""
|
||||
media_file = None
|
||||
|
||||
if info["media_type"]:
|
||||
media_file = await download_media(client, msg, info["media_type"])
|
||||
|
||||
# Build forward info
|
||||
forward_from = None
|
||||
is_forwarded = False
|
||||
if msg.forward:
|
||||
is_forwarded = True
|
||||
if msg.forward.from_id:
|
||||
forward_from = str(msg.forward.from_id)
|
||||
|
||||
entry = {
|
||||
"source": "telegram",
|
||||
"source_id": source_id,
|
||||
"timestamp": datetime.fromtimestamp(msg.date.timestamp(), tz=timezone.utc).isoformat(),
|
||||
"content_type": info["content_type"],
|
||||
"raw_text": text if text else None,
|
||||
"urls": extract_urls(text),
|
||||
"documents": [media_file] if media_file else [],
|
||||
"chat_id": "saved_messages",
|
||||
"chat_title": "Saved Messages",
|
||||
"from_name": "self",
|
||||
"from_username": None,
|
||||
"reply_to_message_id": str(msg.reply_to.reply_to_msg_id) if msg.reply_to else None,
|
||||
"is_forwarded": is_forwarded,
|
||||
"forward_from": forward_from,
|
||||
"media_group_id": str(msg.grouped_id) if msg.grouped_id else None,
|
||||
}
|
||||
|
||||
await insert_entry(db, entry)
|
||||
media = text[:80] if text else "(media)"
|
||||
print(f"[Ingest] {info['content_type']}: {media}")
|
||||
|
||||
|
||||
# ─── Historical sync ─────────────────────────────────────────────────
|
||||
|
||||
async def sync_historical(client: TelegramClient, db: aiosqlite.Connection, limit: int = 500):
|
||||
"""Fetch recent Saved Messages and ingest any missing ones."""
|
||||
print(f"[Sync] Fetching last {limit} Saved Messages...")
|
||||
count = 0
|
||||
async for msg in client.iter_messages(SAVED_MESSAGES_PEER, limit=limit):
|
||||
source_id = str(msg.id)
|
||||
if not await entry_exists(db, source_id):
|
||||
await handle_message(client, db, msg)
|
||||
count += 1
|
||||
print(f"[Sync] Ingested {count} new messages")
|
||||
|
||||
|
||||
# ─── Polling fallback ────────────────────────────────────────────────
|
||||
|
||||
POLL_INTERVAL = 60 # seconds
|
||||
|
||||
async def poll_self_messages(client: TelegramClient, db: aiosqlite.Connection):
|
||||
"""Poll Saved Messages because Telethon events.NewMessage(chats='self') does not
|
||||
fire for messages sent by the user itself."""
|
||||
print("[Poll] Starting self-message polling loop")
|
||||
last_check = datetime.now(timezone.utc) - timedelta(seconds=POLL_INTERVAL)
|
||||
while True:
|
||||
try:
|
||||
since = last_check - timedelta(seconds=5)
|
||||
async for msg in client.iter_messages(SAVED_MESSAGES_PEER, limit=200):
|
||||
msg_time = msg.date.replace(tzinfo=timezone.utc) if msg.date.tzinfo is None else msg.date
|
||||
if msg_time > since:
|
||||
await handle_message(client, db, msg)
|
||||
last_check = datetime.now(timezone.utc)
|
||||
except Exception as e:
|
||||
print(f"[Poll] error: {e}")
|
||||
await asyncio.sleep(POLL_INTERVAL)
|
||||
|
||||
|
||||
# ─── Main ────────────────────────────────────────────────────────────
|
||||
|
||||
async def main():
|
||||
# Init DB
|
||||
db = await init_db(DB_PATH)
|
||||
|
||||
# Count existing entries
|
||||
async with db.execute("SELECT COUNT(*) FROM entries") as cur:
|
||||
row = await cur.fetchone()
|
||||
total = row[0] if row else 0
|
||||
print(f"[Lifestream] DB has {total} entries")
|
||||
|
||||
# Init Telethon client
|
||||
client = TelegramClient(SESSION_PATH, API_ID, API_HASH)
|
||||
await client.start()
|
||||
me = await client.get_me()
|
||||
print(f"[Lifestream] Logged in as {me.first_name} (ID: {me.id})")
|
||||
|
||||
# Historical sync on startup
|
||||
await sync_historical(client, db)
|
||||
|
||||
# Live listener for new Saved Messages (works for some forwards / external events)
|
||||
@client.on(events.NewMessage(chats=SAVED_MESSAGES_PEER))
|
||||
async def on_new_message(event):
|
||||
await handle_message(client, db, event.message)
|
||||
|
||||
# Start polling task as a background coroutine
|
||||
poll_task = asyncio.create_task(poll_self_messages(client, db))
|
||||
|
||||
print("[Lifestream] Listening for new Saved Messages...")
|
||||
try:
|
||||
await client.run_until_disconnected()
|
||||
finally:
|
||||
poll_task.cancel()
|
||||
try:
|
||||
await poll_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
20
scripts/muscriptor-mcp/.env.example
Normal file
20
scripts/muscriptor-mcp/.env.example
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# MuScriptor MCP Server — Environment Variables
|
||||
# Copy this file to .env and fill in the real values.
|
||||
|
||||
# Maloja (music statistics)
|
||||
MALOJA_URL=http://pf-maloja:42010
|
||||
MALOJA_API_KEY=
|
||||
|
||||
# Navidrome (music library, Subsonic API)
|
||||
NAVIDROME_URL=http://192.168.0.104:4533
|
||||
NAVIDROME_USER=fabio
|
||||
NAVIDROME_PASSWORD=
|
||||
|
||||
# HuggingFace (required for model download, CC BY-NC 4.0 license)
|
||||
# Get a free token at https://huggingface.co/settings/tokens
|
||||
# Accept the model license at https://huggingface.co/MuScriptor/muscriptor-medium
|
||||
HF_TOKEN=
|
||||
|
||||
# Model config
|
||||
MUSSCRIPTOR_MODEL_SIZE=medium
|
||||
MUSSCRIPTOR_DOWNLOAD_DIR=/tmp/muscriptor
|
||||
18
scripts/muscriptor-mcp/pyproject.toml
Normal file
18
scripts/muscriptor-mcp/pyproject.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[project]
|
||||
name = "muscriptor-mcp"
|
||||
version = "0.1.0"
|
||||
description = "MCP server for MuScriptor music transcription (audio → MIDI)"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.0.0",
|
||||
"muscriptor>=0.1.0",
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
muscriptor-mcp = "muscriptor_mcp.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
92
scripts/muscriptor-mcp/run_weekly_midi.py
Normal file
92
scripts/muscriptor-mcp/run_weekly_midi.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Standalone runner for MuScriptor weekly MIDI conversion.
|
||||
|
||||
Runs outside the MCP server context — called by Hermes cron job.
|
||||
Fetches top tracks from Maloja, downloads from Navidrome, transcribes to MIDI.
|
||||
|
||||
Usage:
|
||||
python3 run_weekly_midi.py [--count 3] [--period week] [--model medium]
|
||||
"""
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add the package to path
|
||||
sys.path.insert(0, str(Path(__file__).parent / "src"))
|
||||
|
||||
# Load .env if present
|
||||
env_file = Path(__file__).parent / ".env"
|
||||
if env_file.exists():
|
||||
for line in env_file.read_text().splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, _, value = line.partition("=")
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
from muscriptor_mcp.maloja_client import get_top_tracks
|
||||
from muscriptor_mcp.navidrome_client import search_track, download_track
|
||||
from muscriptor_mcp.transcriber import transcribe
|
||||
|
||||
|
||||
async def run(count: int = 3, period: str = "week", model_size: str = "medium") -> str:
|
||||
"""Run the weekly MIDI conversion pipeline. Returns a formatted report."""
|
||||
# Step 1: Get top tracks from Maloja
|
||||
tracks = await get_top_tracks(count=count, period=period)
|
||||
if not tracks:
|
||||
return f"No scrobble data found for period '{period}'."
|
||||
|
||||
results = []
|
||||
success = 0
|
||||
dl_dir = Path(os.environ.get("MUSSCRIPTOR_DOWNLOAD_DIR", "/tmp/muscriptor")) / f"weekly-{period}"
|
||||
|
||||
for t in tracks:
|
||||
label = f"{t.artist} — {t.title}"
|
||||
try:
|
||||
# Step 2: Search Navidrome
|
||||
query = f"{t.title} {t.artist}"
|
||||
search_results = await search_track(query)
|
||||
if not search_results:
|
||||
results.append(f"⏭️ {label} — not found in library")
|
||||
continue
|
||||
|
||||
# Step 3: Download audio
|
||||
audio_path = await download_track(search_results[0]["id"], dl_dir)
|
||||
|
||||
# Step 4: Transcribe to MIDI
|
||||
midi_path = await transcribe(audio_path=audio_path, model_size=model_size)
|
||||
results.append(f"✅ {label} → {midi_path}")
|
||||
success += 1
|
||||
except Exception as e:
|
||||
results.append(f"❌ {label} — {e}")
|
||||
|
||||
report = f"🎵 Weekly MIDI Report ({period})\n"
|
||||
report += f"Converted {success}/{len(tracks)} tracks\n\n"
|
||||
report += "\n".join(results)
|
||||
|
||||
# Also list the MIDI output directory
|
||||
midi_dir = dl_dir
|
||||
midi_files = list(midi_dir.glob("*.mid")) if midi_dir.exists() else []
|
||||
if midi_files:
|
||||
report += f"\n\n📁 MIDI files saved in: {midi_dir}"
|
||||
for mf in sorted(midi_files):
|
||||
report += f"\n • {mf.name} ({mf.stat().st_size // 1024} KB)"
|
||||
|
||||
return report
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="MuScriptor weekly MIDI pipeline")
|
||||
parser.add_argument("--count", type=int, default=3, help="Number of top tracks")
|
||||
parser.add_argument("--period", default="week", help="Time period (week/month/year/all)")
|
||||
parser.add_argument("--model", default="medium", choices=["small", "medium", "large"],
|
||||
help="MuScriptor model size")
|
||||
args = parser.parse_args()
|
||||
|
||||
report = asyncio.run(run(count=args.count, period=args.period, model_size=args.model))
|
||||
print(report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
1
scripts/muscriptor-mcp/src/muscriptor_mcp/__init__.py
Normal file
1
scripts/muscriptor-mcp/src/muscriptor_mcp/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""MuScriptor MCP Server — audio → MIDI transcription for Aurelio/Hermes agents."""
|
||||
5
scripts/muscriptor-mcp/src/muscriptor_mcp/__main__.py
Normal file
5
scripts/muscriptor-mcp/src/muscriptor_mcp/__main__.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
"""Allow running as `python -m muscriptor_mcp.server`."""
|
||||
import asyncio
|
||||
from muscriptor_mcp.server import main
|
||||
|
||||
asyncio.run(main())
|
||||
57
scripts/muscriptor-mcp/src/muscriptor_mcp/maloja_client.py
Normal file
57
scripts/muscriptor-mcp/src/muscriptor_mcp/maloja_client.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Maloja scrobble statistics client.
|
||||
|
||||
Maloja API docs: https://github.com/krateng/maloja
|
||||
Base path: /apis/mlj_1/
|
||||
Charts endpoint does NOT require authentication.
|
||||
"""
|
||||
import os
|
||||
import httpx
|
||||
from dataclasses import dataclass
|
||||
|
||||
MALOJA_URL = os.environ.get("MALOJA_URL", "http://192.168.0.126:42010")
|
||||
# Maloja API key — only needed for write endpoints (scrobble submission).
|
||||
# Charts/stats endpoints are public.
|
||||
MALOJA_API_KEY = os.environ.get("MALOJA_API_KEY", "")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrackStat:
|
||||
title: str
|
||||
artist: str
|
||||
album: str
|
||||
play_count: int
|
||||
track_id: int
|
||||
rank: int
|
||||
|
||||
|
||||
async def get_top_tracks(count: int = 3, period: str = "week") -> list[TrackStat]:
|
||||
"""Fetch top tracks from Maloja charts API.
|
||||
|
||||
Args:
|
||||
count: Number of top tracks to return.
|
||||
period: Time period filter. Maloja accepts: "week", "month", "year",
|
||||
or omit for all-time. Uses the `within` query param.
|
||||
"""
|
||||
url = f"{MALOJA_URL}/apis/mlj_1/charts/tracks"
|
||||
params = {"max": count}
|
||||
if period and period != "all":
|
||||
params["within"] = period
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, params=params, timeout=15)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
# Maloja returns all tracks tied at the boundary rank — slice to exact count
|
||||
tracks = [
|
||||
TrackStat(
|
||||
title=item["track"]["title"],
|
||||
artist=item["track"]["artists"][0], # primary artist
|
||||
album=item["track"].get("album", {}).get("albumtitle", ""),
|
||||
play_count=item.get("scrobbles", 0),
|
||||
track_id=item.get("track_id", 0),
|
||||
rank=item.get("rank", i + 1),
|
||||
)
|
||||
for i, item in enumerate(data.get("list", []))
|
||||
]
|
||||
return tracks[:count]
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
"""Navidrome (Subsonic API) client for audio search and download.
|
||||
|
||||
Subsonic API spec: https://www.subsonic.org/pages/api.jsp
|
||||
Navidrome runs on Gigabyte server at port 4533.
|
||||
Auth: token = md5(password + salt), sent as query params.
|
||||
"""
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
|
||||
NAVIDROME_URL = os.environ.get("NAVIDROME_URL", "http://192.168.0.104:4533")
|
||||
NAVIDROME_USER = os.environ.get("NAVIDROME_USER", "fabio")
|
||||
NAVIDROME_PASSWORD = os.environ.get("NAVIDROME_PASSWORD", "")
|
||||
|
||||
|
||||
def _subsonic_params() -> dict:
|
||||
"""Build Subsonic authentication params (token + salt)."""
|
||||
salt = secrets.token_hex(8)
|
||||
token = hashlib.md5((NAVIDROME_PASSWORD + salt).encode()).hexdigest()
|
||||
return {
|
||||
"u": NAVIDROME_USER,
|
||||
"t": token,
|
||||
"s": salt,
|
||||
"v": "1.16.1",
|
||||
"c": "muscriptor-mcp",
|
||||
}
|
||||
|
||||
|
||||
async def search_track(query: str, count: int = 5) -> list[dict]:
|
||||
"""Search Navidrome for tracks matching query.
|
||||
|
||||
Returns list of {id, title, artist, album, duration, suffix}.
|
||||
"""
|
||||
params = {**_subsonic_params(), "query": query, "songCount": count}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{NAVIDROME_URL}/rest/search3.view", params=params, timeout=15
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
result = data.get("subsonic-response", {}).get("searchResult3", {})
|
||||
songs = result.get("song", [])
|
||||
return [
|
||||
{
|
||||
"id": s["id"],
|
||||
"title": s["title"],
|
||||
"artist": s.get("artist", ""),
|
||||
"album": s.get("album", ""),
|
||||
"duration": s.get("duration", 0),
|
||||
"suffix": s.get("suffix", "mp3"),
|
||||
}
|
||||
for s in songs
|
||||
]
|
||||
|
||||
|
||||
async def download_track(song_id: str, output_dir: Path) -> Path:
|
||||
"""Download a track by ID from Navidrome. Returns local file path."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
params = {**_subsonic_params(), "id": song_id}
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{NAVIDROME_URL}/rest/download.view", params=params, timeout=120
|
||||
)
|
||||
resp.raise_for_status()
|
||||
# Determine extension from content-type header
|
||||
content_type = resp.headers.get("content-type", "audio/mpeg")
|
||||
ext = _ext_from_content_type(content_type)
|
||||
out_path = output_dir / f"{song_id}{ext}"
|
||||
out_path.write_bytes(resp.content)
|
||||
return out_path
|
||||
|
||||
|
||||
def _ext_from_content_type(ct: str) -> str:
|
||||
"""Map content-type to file extension."""
|
||||
mapping = {
|
||||
"audio/mpeg": ".mp3",
|
||||
"audio/flac": ".flac",
|
||||
"audio/ogg": ".ogg",
|
||||
"audio/wav": ".wav",
|
||||
"audio/x-wav": ".wav",
|
||||
"audio/mp4": ".m4a",
|
||||
"audio/x-m4a": ".m4a",
|
||||
"audio/aac": ".aac",
|
||||
"audio/x-flac": ".flac",
|
||||
}
|
||||
return mapping.get(ct.split(";")[0].strip().lower(), ".mp3")
|
||||
206
scripts/muscriptor-mcp/src/muscriptor_mcp/server.py
Normal file
206
scripts/muscriptor-mcp/src/muscriptor_mcp/server.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""MuScriptor MCP Server — audio → MIDI transcription for Aurelio/Hermes agents.
|
||||
|
||||
Exposes 4 tools via MCP (SSE or stdio transport):
|
||||
- transcribe_to_midi: Audio file → MIDI
|
||||
- get_top_tracks: Top N tracks from Maloja scrobble stats
|
||||
- search_and_download: Search Navidrome, download audio locally
|
||||
- top_tracks_to_midi: Full pipeline (Maloja → Navidrome → MuScriptor → MIDI)
|
||||
|
||||
Usage:
|
||||
# Local (stdio — default)
|
||||
python3 -m muscriptor_mcp.server
|
||||
|
||||
# Remote (SSE over HTTP — for Cloudflare tunnel deployment)
|
||||
python3 -m muscriptor_mcp.server --transport sse --port 8088
|
||||
|
||||
# With auth
|
||||
MCP_AUTH_TOKEN=secret python3 -m muscriptor_mcp.server --transport sse
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from muscriptor_mcp.maloja_client import get_top_tracks as _fetch_top_tracks
|
||||
from muscriptor_mcp.navidrome_client import search_track, download_track
|
||||
from muscriptor_mcp.transcriber import transcribe as transcribe_audio
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_DOWNLOAD_DIR = Path(os.environ.get("MUSSCRIPTOR_DOWNLOAD_DIR", "/tmp/muscriptor"))
|
||||
DEFAULT_MODEL_SIZE = os.environ.get("MUSSCRIPTOR_MODEL_SIZE", "medium")
|
||||
|
||||
mcp = FastMCP(
|
||||
"muscriptor-mcp",
|
||||
instructions="Music transcription (audio → MIDI) via MuScriptor. "
|
||||
"Connects to Maloja (scrobble stats) and Navidrome (music library).",
|
||||
)
|
||||
|
||||
|
||||
# ─── Tools ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def transcribe_to_midi(
|
||||
audio_path: str,
|
||||
output_path: str | None = None,
|
||||
model_size: str = DEFAULT_MODEL_SIZE,
|
||||
instruments: list[str] | None = None,
|
||||
) -> str:
|
||||
"""Transcribe an audio file (WAV/MP3/FLAC/OGG/M4A) to MIDI using MuScriptor.
|
||||
|
||||
Returns the path to the generated .mid file.
|
||||
Use this when a user asks to convert a specific audio file to MIDI for
|
||||
use in Ableton Live, Logic, or any DAW.
|
||||
|
||||
Args:
|
||||
audio_path: Absolute path to the audio file on disk.
|
||||
output_path: Output path for .mid (default: same dir as input, .mid extension).
|
||||
model_size: "small" (103M, CPU), "medium" (307M, default), "large" (1.4B, GPU-heavy).
|
||||
instruments: Restrict to specific instruments (e.g. ["acoustic_piano", "drums"]).
|
||||
"""
|
||||
audio = Path(audio_path)
|
||||
if not audio.exists():
|
||||
return f"Audio file not found: {audio_path}"
|
||||
|
||||
out = Path(output_path) if output_path else None
|
||||
result = await transcribe_audio(
|
||||
audio_path=audio, output_path=out, model_size=model_size, instruments=instruments
|
||||
)
|
||||
return f"MIDI saved to: {result}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_top_tracks(count: int = 3, period: str = "week") -> str:
|
||||
"""Get the user's most-played tracks from Maloja scrobble statistics.
|
||||
|
||||
Use this when asked about top tracks, most played, listening habits,
|
||||
or when auto-selecting tracks for MIDI conversion.
|
||||
|
||||
Args:
|
||||
count: Number of top tracks to return (default: 3).
|
||||
period: "week", "month", "year", or "all" (default: week).
|
||||
"""
|
||||
tracks = await _fetch_top_tracks(count=count, period=period)
|
||||
if not tracks:
|
||||
return f"No scrobble data found for period '{period}'."
|
||||
|
||||
lines = [
|
||||
f"#{t.rank}: {t.artist} — {t.title} ({t.play_count} scrobbles)"
|
||||
+ (f" [album: {t.album}]" if t.album else "")
|
||||
for t in tracks
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_and_download(query: str, download_dir: str | None = None) -> str:
|
||||
"""Search the Navidrome music library and download the audio file locally.
|
||||
|
||||
Use this when a user asks to find and download a specific song.
|
||||
Returns the local file path of the downloaded audio.
|
||||
|
||||
Args:
|
||||
query: Search query (e.g. "Sun Caribou", "LCD Soundsystem Dance Yrself Clean").
|
||||
download_dir: Directory to save the audio file (default: /tmp/muscriptor).
|
||||
"""
|
||||
dl_dir = Path(download_dir) if download_dir else DEFAULT_DOWNLOAD_DIR
|
||||
|
||||
results = await search_track(query)
|
||||
if not results:
|
||||
return f"No results for '{query}' in Navidrome library."
|
||||
|
||||
track = results[0]
|
||||
audio_path = await download_track(track["id"], dl_dir)
|
||||
|
||||
return (
|
||||
f"Downloaded: {track['artist']} — {track['title']}"
|
||||
+ (f" [{track['album']}]" if track.get("album") else "")
|
||||
+ f"\n Saved to: {audio_path}"
|
||||
+ f"\n Format: {track.get('suffix', 'unknown')}, Duration: {track.get('duration', 0)}s"
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def top_tracks_to_midi(
|
||||
count: int = 3,
|
||||
period: str = "week",
|
||||
model_size: str = DEFAULT_MODEL_SIZE,
|
||||
) -> str:
|
||||
"""FULL PIPELINE: Fetch top N tracks from Maloja, download from Navidrome,
|
||||
and transcribe each to MIDI using MuScriptor.
|
||||
|
||||
Use this for the weekly auto-conversion job or when asked to convert
|
||||
top tracks to MIDI.
|
||||
|
||||
Args:
|
||||
count: Number of top tracks to convert (default: 3).
|
||||
period: "week", "month", "year", or "all" (default: week).
|
||||
model_size: MuScriptor model variant (default: medium).
|
||||
"""
|
||||
tracks = await _fetch_top_tracks(count=count, period=period)
|
||||
if not tracks:
|
||||
return f"No scrobble data for period '{period}'. Nothing to convert."
|
||||
|
||||
results = []
|
||||
success = 0
|
||||
dl_dir = DEFAULT_DOWNLOAD_DIR / f"weekly-{period}"
|
||||
|
||||
for t in tracks:
|
||||
label = f"{t.artist} — {t.title}"
|
||||
try:
|
||||
search_results = await search_track(f"{t.title} {t.artist}")
|
||||
if not search_results:
|
||||
results.append(f"⏭️ SKIP: {label} — not found in Navidrome")
|
||||
continue
|
||||
|
||||
audio_path = await download_track(search_results[0]["id"], dl_dir)
|
||||
midi_path = await transcribe_audio(audio_path=audio_path, model_size=model_size)
|
||||
results.append(f"✅ DONE: {label} → {midi_path}")
|
||||
success += 1
|
||||
except Exception as e:
|
||||
results.append(f"❌ FAIL: {label} — {e}")
|
||||
|
||||
summary = f"Converted {success}/{len(tracks)} tracks ({period})"
|
||||
return f"{summary}\n\n" + "\n".join(results)
|
||||
|
||||
|
||||
# ─── Entry point ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="MuScriptor MCP Server")
|
||||
parser.add_argument(
|
||||
"--transport",
|
||||
choices=["stdio", "sse", "streamable-http"],
|
||||
default="stdio",
|
||||
help="Transport mode (default: stdio)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=int(os.environ.get("MCP_PORT", "8088")),
|
||||
help="HTTP port for SSE transport (default: 8088)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default=os.environ.get("MCP_HOST", "0.0.0.0"),
|
||||
help="Bind host for SSE transport (default: 0.0.0.0)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info("Starting MuScriptor MCP Server (model=%s, transport=%s)", DEFAULT_MODEL_SIZE, args.transport)
|
||||
|
||||
if args.transport in ("sse", "streamable-http"):
|
||||
mcp.settings.host = args.host
|
||||
mcp.settings.port = args.port
|
||||
|
||||
mcp.run(transport=args.transport)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
72
scripts/muscriptor-mcp/src/muscriptor_mcp/transcriber.py
Normal file
72
scripts/muscriptor-mcp/src/muscriptor_mcp/transcriber.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
"""MuScriptor audio → MIDI transcription engine.
|
||||
|
||||
Wraps the MuScriptor model (Kyutai/Mirelo) in an async-friendly interface.
|
||||
The model is loaded once and cached for the lifetime of the process.
|
||||
|
||||
Model sizes: small (103M), medium (307M, default), large (1.4B)
|
||||
GPU: medium runs on RTX 3070 8GB. Large needs ~12GB VRAM.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from functools import lru_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache loaded models by size to avoid reloading
|
||||
_model_cache: dict[str, object] = {}
|
||||
|
||||
|
||||
def _get_model(size: str = "medium"):
|
||||
"""Load and cache the MuScriptor model (singleton per size)."""
|
||||
if size not in _model_cache:
|
||||
logger.info("Loading MuScriptor model '%s' (first call, downloads if needed)...", size)
|
||||
from muscriptor import TranscriptionModel
|
||||
_model_cache[size] = TranscriptionModel.load_model(size)
|
||||
logger.info("MuScriptor model '%s' loaded successfully.", size)
|
||||
return _model_cache[size]
|
||||
|
||||
|
||||
async def transcribe(
|
||||
audio_path: str | Path,
|
||||
output_path: str | Path | None = None,
|
||||
model_size: str = "medium",
|
||||
instruments: list[str] | None = None,
|
||||
) -> Path:
|
||||
"""Transcribe audio file to MIDI.
|
||||
|
||||
Args:
|
||||
audio_path: Path to audio file (WAV, MP3, FLAC, OGG, etc.)
|
||||
output_path: Where to save the .mid file. Defaults to same dir as input.
|
||||
model_size: "small", "medium", or "large"
|
||||
instruments: Optional list of instrument names to restrict transcription to.
|
||||
Run `muscriptor list-instruments` for available names.
|
||||
|
||||
Returns:
|
||||
Path to the generated .mid file.
|
||||
"""
|
||||
audio_path = Path(audio_path)
|
||||
if output_path is None:
|
||||
output_path = audio_path.with_suffix(".mid")
|
||||
else:
|
||||
output_path = Path(output_path)
|
||||
|
||||
if not audio_path.exists():
|
||||
raise FileNotFoundError(f"Audio file not found: {audio_path}")
|
||||
|
||||
model = _get_model(model_size)
|
||||
|
||||
# MuScriptor transcribe_to_midi is CPU/GPU-bound — run in thread pool
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
def _do_transcribe():
|
||||
kwargs = {}
|
||||
if instruments:
|
||||
kwargs["instruments"] = instruments
|
||||
return model.transcribe_to_midi(str(audio_path), **kwargs)
|
||||
|
||||
midi_bytes = await loop.run_in_executor(None, _do_transcribe)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(midi_bytes)
|
||||
logger.info("Transcribed %s → %s (%d bytes MIDI)", audio_path, output_path, len(midi_bytes))
|
||||
return output_path
|
||||
26
scripts/muscriptor-mcp/systemd/muscriptor-mcp.service
Normal file
26
scripts/muscriptor-mcp/systemd/muscriptor-mcp.service
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
[Unit]
|
||||
Description=MuScriptor MCP Server (audio → MIDI, SSE on port 8088)
|
||||
After=network.target docker.service
|
||||
Wants=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=fabio
|
||||
WorkingDirectory=/home/fabio/muscriptor-mcp
|
||||
EnvironmentFile=/home/fabio/muscriptor-mcp/.env
|
||||
Environment=MCP_PORT=8088
|
||||
Environment=MCP_HOST=0.0.0.0
|
||||
ExecStart=/home/fabio/muscriptor-mcp/.venv/bin/python -m muscriptor_mcp.server --transport sse --port 8088
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StartLimitInterval=60s
|
||||
StartLimitBurst=3
|
||||
|
||||
# GPU access for CUDA
|
||||
SupplementaryGroups=video render
|
||||
|
||||
# Give model loading time to download weights on first start
|
||||
TimeoutStartSec=300
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
18
scripts/music-mcp/pyproject.toml
Normal file
18
scripts/music-mcp/pyproject.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[project]
|
||||
name = "music-mcp"
|
||||
version = "0.1.0"
|
||||
description = "MCP server for the Portugal Futurista music stack"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"mcp[cli]>=1.0.0",
|
||||
"httpx>=0.27",
|
||||
"pydantic>=2.0",
|
||||
"python-dotenv>=1.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
music-mcp = "music_mcp.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
1
scripts/music-mcp/src/music_mcp/__init__.py
Normal file
1
scripts/music-mcp/src/music_mcp/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Music MCP Server — unified music stack for Aurelio/Hermes agents."""
|
||||
4
scripts/music-mcp/src/music_mcp/__main__.py
Normal file
4
scripts/music-mcp/src/music_mcp/__main__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
"""Allow running as `python -m music_mcp.server`."""
|
||||
import asyncio
|
||||
from music_mcp.server import main
|
||||
asyncio.run(main)
|
||||
74
scripts/music-mcp/src/music_mcp/daily_overview.py
Normal file
74
scripts/music-mcp/src/music_mcp/daily_overview.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Daily music overview — combines data from all music services."""
|
||||
from music_mcp import lastfm_client as lastfm
|
||||
from music_mcp import lidarr_client as lidarr
|
||||
from music_mcp import musicbrainz_client as mb
|
||||
from music_mcp import debug_tools
|
||||
|
||||
|
||||
async def daily_brief() -> str:
|
||||
"""Generate a daily music overview."""
|
||||
lines = ["🎵 **Daily Music Brief**", ""]
|
||||
|
||||
# 1. Listening stats (Last.fm)
|
||||
try:
|
||||
artists = await lastfm.top_artists(count=5, period="7day")
|
||||
if artists:
|
||||
lines.append("📊 **This Week's Top Artists**")
|
||||
for a in artists:
|
||||
lines.append(f" • {a['name']} ({a['playcount']} plays)")
|
||||
lines.append("")
|
||||
except Exception:
|
||||
lines.append("📊 Last.fm stats unavailable")
|
||||
lines.append("")
|
||||
|
||||
# 2. Recent listening
|
||||
try:
|
||||
recent = await lastfm.recent_tracks(count=3)
|
||||
if recent:
|
||||
lines.append("🎧 **Recently Played**")
|
||||
for t in recent:
|
||||
lines.append(f" • {t['artist']} — {t['name']}")
|
||||
lines.append("")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 3. New releases from Lidarr calendar
|
||||
try:
|
||||
releases = await lidarr.calendar(days=7)
|
||||
if releases:
|
||||
lines.append(f"🆕 **Releases This Week** ({len(releases)} albums)")
|
||||
for r in releases[:5]:
|
||||
lines.append(f" • {r['artist']} — {r['title']} ({r.get('releaseDate', '')[:10]})")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("🆕 No new releases this week from your library")
|
||||
lines.append("")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 4. Lidarr queue
|
||||
try:
|
||||
q = await lidarr.queue()
|
||||
if q:
|
||||
lines.append(f"📥 **Downloading** ({len(q)} items)")
|
||||
for item in q[:3]:
|
||||
lines.append(f" • {item['artist']} — {item['title']} ({item['status']})")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("📥 Nothing downloading")
|
||||
lines.append("")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 5. Service health
|
||||
try:
|
||||
health = debug_tools.service_health()
|
||||
ok = sum(1 for v in health.values() if "running" in v.lower() or v == "running")
|
||||
lines.append(f"🔗 **Services**: {ok}/{len(health)} healthy")
|
||||
bad = [k for k, v in health.items() if "running" not in v.lower() and v != "running"]
|
||||
if bad:
|
||||
lines.append(f" ⚠️ Down: {', '.join(bad)}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "\n".join(lines)
|
||||
87
scripts/music-mcp/src/music_mcp/debug_tools.py
Normal file
87
scripts/music-mcp/src/music_mcp/debug_tools.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
"""Debug tools — service logs, configs, health checks."""
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
# Docker containers on Gigabyte
|
||||
CONTAINERS = {
|
||||
"navidrome": "pf-navidrome",
|
||||
"lidarr": "pf-lidarr",
|
||||
"prowlarr": "pf-prowlarr",
|
||||
"slskd": "pf-slskd",
|
||||
"jellyfin": "pf-jellyfin",
|
||||
"qbittorrent": "pf-qbittorrent",
|
||||
}
|
||||
|
||||
GIGABYTE_HOST = os.environ.get("GIGABYTE_HOST", "192.168.0.104")
|
||||
|
||||
|
||||
def _docker_cmd(container: str, cmd: str) -> str:
|
||||
"""Run a docker command on Gigabyte via SSH."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", f"root@{GIGABYTE_HOST}",
|
||||
f"docker {cmd} {container}"],
|
||||
capture_output=True, text=True, timeout=15
|
||||
)
|
||||
return result.stdout or result.stderr
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def service_health() -> dict:
|
||||
"""Check health of all music services."""
|
||||
results = {}
|
||||
for name, container in CONTAINERS.items():
|
||||
status = _docker_cmd(container, "inspect --format '{{.State.Status}}'").strip()
|
||||
results[name] = status.replace("'", "")
|
||||
# Also check CT 216 services
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=3", "root@192.168.0.38",
|
||||
"pct exec 216 -- systemctl is-active pf-maloja pf-multi-scrobbler 2>/dev/null"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
for line in r.stdout.strip().split("\n"):
|
||||
if line:
|
||||
svc = line.strip()
|
||||
results[svc] = "running" if "active" in svc else svc
|
||||
except Exception:
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
def service_logs(service: str, lines: int = 50) -> str:
|
||||
"""Get Docker container logs."""
|
||||
container = CONTAINERS.get(service)
|
||||
if not container:
|
||||
return f"Unknown service: {service}. Available: {', '.join(CONTAINERS.keys())}"
|
||||
return _docker_cmd(container, f"logs --tail {lines} --timestamps")
|
||||
|
||||
|
||||
def service_config(service: str) -> str:
|
||||
"""Get Docker container config (env, mounts, ports)."""
|
||||
container = CONTAINERS.get(service)
|
||||
if not container:
|
||||
return f"Unknown service: {service}"
|
||||
env = _docker_cmd(container, "inspect --format '{{range .Config.Env}}{{println .}}{{end}}'")
|
||||
mounts = _docker_cmd(container, "inspect --format '{{range .Mounts}}{{.Source}} → {{.Destination}}{{println}}{{end}}'")
|
||||
ports = _docker_cmd(container, "port")
|
||||
return f"=== Environment ===\n{env}\n=== Mounts ===\n{mounts}\n=== Ports ===\n{ports}"
|
||||
|
||||
|
||||
def disk_usage() -> str:
|
||||
"""Check music library disk usage on Gigabyte."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["ssh", "-o", "ConnectTimeout=5", f"root@{GIGABYTE_HOST}",
|
||||
"df -h /mnt/usb-pool && echo '---' && du -sh /mnt/usb-pool/music 2>/dev/null"],
|
||||
capture_output=True, text=True, timeout=10
|
||||
)
|
||||
return r.stdout or r.stderr
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
def scrobbler_status() -> str:
|
||||
"""Check multi-scrobbler status."""
|
||||
return _docker_cmd("pf-multi-scrobbler", "logs --tail 20 --timestamps")
|
||||
55
scripts/music-mcp/src/music_mcp/lastfm_client.py
Normal file
55
scripts/music-mcp/src/music_mcp/lastfm_client.py
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
"""Last.fm API client — charts, stats, similar artists."""
|
||||
import os
|
||||
import httpx
|
||||
|
||||
LASTFM_KEY = os.environ.get("LASTFM_API_KEY", "")
|
||||
LASTFM_USER = os.environ.get("LASTFM_USER", "fabio")
|
||||
LASTFM_BASE = "http://ws.audioscrobbler.com/2.0/"
|
||||
|
||||
|
||||
async def _get(method: str, extra: dict = None) -> dict:
|
||||
params = {"method": method, "api_key": LASTFM_KEY, "format": "json", **(extra or {})}
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.get(LASTFM_BASE, params=params, timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def top_artists(count: int = 5, period: str = "7day") -> list[dict]:
|
||||
"""Get user's top artists."""
|
||||
data = await _get("user.gettopartists", {"user": LASTFM_USER, "limit": count, "period": period})
|
||||
return [{"name": a["name"], "playcount": int(a.get("playcount", 0)),
|
||||
"mbid": a.get("mbid", "")} for a in data.get("topartists", {}).get("artist", [])]
|
||||
|
||||
|
||||
async def top_tracks(count: int = 5, period: str = "7day") -> list[dict]:
|
||||
"""Get user's top tracks."""
|
||||
data = await _get("user.gettoptracks", {"user": LASTFM_USER, "limit": count, "period": period})
|
||||
return [{"name": t["name"], "artist": t.get("artist", {}).get("name", ""),
|
||||
"playcount": int(t.get("playcount", 0))} for t in data.get("toptracks", {}).get("track", [])]
|
||||
|
||||
|
||||
async def recent_tracks(count: int = 5) -> list[dict]:
|
||||
"""Get user's recent tracks."""
|
||||
data = await _get("user.getrecenttracks", {"user": LASTFM_USER, "limit": count})
|
||||
tracks = data.get("recenttracks", {}).get("track", [])
|
||||
return [{"name": t["name"], "artist": t.get("artist", {}).get("#text", ""),
|
||||
"album": t.get("album", {}).get("#text", ""),
|
||||
"date": t.get("date", {}).get("#text", "")} for t in tracks]
|
||||
|
||||
|
||||
async def similar_artists(artist: str, count: int = 5) -> list[dict]:
|
||||
"""Get similar artists."""
|
||||
data = await _get("artist.getsimilar", {"artist": artist, "limit": count})
|
||||
return [{"name": a["name"], "match": float(a.get("match", 0)),
|
||||
"mbid": a.get("mbid", "")} for a in data.get("similarartists", {}).get("artist", [])]
|
||||
|
||||
|
||||
async def artist_info(artist: str) -> dict:
|
||||
"""Get artist info + tags."""
|
||||
data = await _get("artist.getInfo", {"artist": artist})
|
||||
a = data.get("artist", {})
|
||||
return {"name": a.get("name", ""), "playcount": int(a.get("stats", {}).get("playcount", 0)),
|
||||
"listeners": int(a.get("stats", {}).get("listeners", 0)),
|
||||
"tags": [t["name"] for t in a.get("tags", {}).get("tag", [])[:10]],
|
||||
"bio": (a.get("bio", {}).get("summary", "") or "")[:300]}
|
||||
75
scripts/music-mcp/src/music_mcp/lidarr_client.py
Normal file
75
scripts/music-mcp/src/music_mcp/lidarr_client.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""Lidarr v3 API client."""
|
||||
import os
|
||||
import httpx
|
||||
|
||||
LIDARR_URL = os.environ.get("LIDARR_URL", "http://192.168.0.104:8686")
|
||||
LIDARR_API_KEY = os.environ.get("LIDARR_API_KEY", "")
|
||||
|
||||
headers = {"X-Api-Key": LIDARR_API_KEY}
|
||||
|
||||
|
||||
async def _get(path: str, params: dict = None) -> dict | list:
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.get(f"{LIDARR_URL}/api/v1{path}", headers=headers, params=params, timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def _post(path: str, data: dict) -> dict:
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.post(f"{LIDARR_URL}/api/v1{path}", headers=headers, json=data, timeout=30)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def search_artist(query: str) -> list[dict]:
|
||||
"""Search for artists on MusicBrainz via Lidarr."""
|
||||
data = await _get("/search", {"term": query})
|
||||
return [{"foreignId": a.get("foreignId", ""), "artistName": a.get("artistName", ""),
|
||||
"overview": (a.get("overview", "") or "")[:200]} for a in (data if isinstance(data, list) else [])]
|
||||
|
||||
|
||||
async def list_artists() -> list[dict]:
|
||||
"""List all artists in Lidarr."""
|
||||
data = await _get("/artist")
|
||||
return [{"id": a["id"], "name": a["artistName"], "status": a.get("status", ""),
|
||||
"genres": a.get("genres", [])} for a in data]
|
||||
|
||||
|
||||
async def add_artist(foreign_id: str, name: str, root_folder: str = "/music") -> dict:
|
||||
"""Add an artist to Lidarr by MusicBrainz ID."""
|
||||
return await _post("/artist", {
|
||||
"foreignId": foreign_id,
|
||||
"artistName": name,
|
||||
"rootFolderPath": root_folder,
|
||||
"monitored": True,
|
||||
"qualityProfileId": 1,
|
||||
"metadataProfileId": 1,
|
||||
})
|
||||
|
||||
|
||||
async def queue() -> list[dict]:
|
||||
"""Get download queue."""
|
||||
data = await _get("/queue")
|
||||
records = data.get("records", []) if isinstance(data, dict) else data
|
||||
return [{"title": r.get("title", ""), "artist": r.get("artistName", ""),
|
||||
"status": r.get("status", ""), "size": r.get("size", 0)} for r in records]
|
||||
|
||||
|
||||
async def wanted(count: int = 10) -> list[dict]:
|
||||
"""Get wanted (missing) albums."""
|
||||
data = await _get("/wanted/missing", {"pageSize": count, "sortDir": "descending"})
|
||||
records = data.get("records", []) if isinstance(data, dict) else data
|
||||
return [{"title": r.get("title", ""), "artist": r.get("artistName", ""),
|
||||
"releaseDate": r.get("releaseDate", "")} for r in records]
|
||||
|
||||
|
||||
async def calendar(days: int = 7) -> list[dict]:
|
||||
"""Get upcoming/recent releases from Lidarr calendar."""
|
||||
from datetime import datetime, timedelta
|
||||
start = datetime.utcnow().strftime("%Y-%m-%d")
|
||||
end = (datetime.utcnow() + timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
data = await _get("/calendar", {"start": start, "end": end})
|
||||
return [{"title": r.get("title", ""), "artist": r.get("artistName", ""),
|
||||
"releaseDate": r.get("releaseDate", ""),
|
||||
"status": r.get("status", "")} for r in (data if isinstance(data, list) else [])]
|
||||
18
scripts/music-mcp/src/music_mcp/muscriptor_bridge.py
Normal file
18
scripts/music-mcp/src/music_mcp/muscriptor_bridge.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
"""Bridge to MuScriptor MCP server for audio → MIDI transcription."""
|
||||
import os
|
||||
import httpx
|
||||
|
||||
# MuScriptor runs on Gigabyte as an MCP SSE server on port 8088
|
||||
# For now, we import the transcriber directly to avoid MCP-in-MCP
|
||||
MUSSCRIPTOR_MODEL = os.environ.get("MUSSCRIPTOR_MODEL_SIZE", "medium")
|
||||
|
||||
|
||||
async def transcribe_to_midi(audio_path: str, output_path: str = None, model_size: str = None) -> str:
|
||||
"""Transcribe audio to MIDI. Returns path to .mid file."""
|
||||
from muscriptor_mcp.transcriber import transcribe
|
||||
result = await transcribe(
|
||||
audio_path=audio_path,
|
||||
output_path=output_path,
|
||||
model_size=model_size or MUSSCRIPTOR_MODEL,
|
||||
)
|
||||
return str(result)
|
||||
59
scripts/music-mcp/src/music_mcp/musicbrainz_client.py
Normal file
59
scripts/music-mcp/src/music_mcp/musicbrainz_client.py
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
"""MusicBrainz API client — metadata, releases, artist info."""
|
||||
import os
|
||||
import httpx
|
||||
|
||||
MB_BASE = "https://musicbrainz.org/ws/2"
|
||||
MB_TOKEN = os.environ.get("MUSICBRAINZ_TOKEN", "")
|
||||
MB_HEADERS = {"User-Agent": f"PortugalFuturista-MusicMCP/1.0 (music-mcp@portugalfuturista.org)"}
|
||||
|
||||
|
||||
async def _get(path: str, params: dict = None) -> dict:
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.get(f"{MB_BASE}{path}", headers=MB_HEADERS, params=params, timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
async def search_artist(query: str, limit: int = 5) -> list[dict]:
|
||||
"""Search MusicBrainz for artists."""
|
||||
data = await _get("/artist/", {"query": query, "fmt": "json", "limit": limit})
|
||||
return [{"mbid": a["id"], "name": a["name"], "type": a.get("type", ""),
|
||||
"country": a.get("country", ""), "tags": [t["name"] for t in a.get("tags", [])[:5]]}
|
||||
for a in data.get("artists", [])]
|
||||
|
||||
|
||||
async def artist_releases(mbid: str, release_type: str = "album|single", limit: int = 10) -> list[dict]:
|
||||
"""Get release groups for an artist."""
|
||||
data = await _get(f"/release-group/", {
|
||||
"artist": mbid, "type": release_type, "fmt": "json", "limit": limit,
|
||||
"offset": 0
|
||||
})
|
||||
return [{"mbid": rg["id"], "title": rg["title"], "type": rg.get("primary-type", ""),
|
||||
"firstReleaseDate": rg.get("first-release-date", ""),
|
||||
"tags": [t["name"] for t in rg.get("tags", [])[:5]]}
|
||||
for rg in data.get("release-groups", [])]
|
||||
|
||||
|
||||
async def new_releases(artist_mbids: list[str], days: int = 30) -> list[dict]:
|
||||
"""Check for new releases from a list of artists (last N days)."""
|
||||
from datetime import datetime, timedelta
|
||||
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
results = []
|
||||
for mbid in artist_mbids[:50]: # rate limit
|
||||
try:
|
||||
releases = await artist_releases(mbid)
|
||||
for r in releases:
|
||||
if r["firstReleaseDate"] and r["firstReleaseDate"] >= cutoff:
|
||||
results.append(r)
|
||||
except Exception:
|
||||
continue
|
||||
return sorted(results, key=lambda r: r["firstReleaseDate"], reverse=True)
|
||||
|
||||
|
||||
async def artist_info(mbid: str) -> dict:
|
||||
"""Get detailed artist info."""
|
||||
data = await _get(f"/artist/{mbid}", {"fmt": "json", "inc": "tags+ratings+aliases"})
|
||||
return {"mbid": data["id"], "name": data["name"], "type": data.get("type", ""),
|
||||
"country": data.get("country", ""), "lifeSpan": data.get("life-span", {}),
|
||||
"tags": [t["name"] for t in data.get("tags", [])[:10]],
|
||||
"aliases": [a["name"] for a in data.get("aliases", [])[:5]]}
|
||||
74
scripts/music-mcp/src/music_mcp/navidrome_client.py
Normal file
74
scripts/music-mcp/src/music_mcp/navidrome_client.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""Navidrome (Subsonic API) client."""
|
||||
import hashlib
|
||||
import os
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
|
||||
NAVIDROME_URL = os.environ.get("NAVIDROME_URL", "http://192.168.0.104:4533")
|
||||
NAVIDROME_USER = os.environ.get("NAVIDROME_USER", "fabio")
|
||||
NAVIDROME_PASSWORD = os.environ.get("NAVIDROME_PASSWORD", "")
|
||||
|
||||
|
||||
def _params() -> dict:
|
||||
salt = secrets.token_hex(8)
|
||||
token = hashlib.md5((NAVIDROME_PASSWORD + salt).encode()).hexdigest()
|
||||
return {"u": NAVIDROME_USER, "t": token, "s": salt, "v": "1.16.1", "c": "music-mcp"}
|
||||
|
||||
|
||||
async def _get(endpoint: str, extra: dict = None) -> dict:
|
||||
params = {**_params(), **(extra or {})}
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.get(f"{NAVIDROME_URL}/rest/{endpoint}", params=params, timeout=15)
|
||||
r.raise_for_status()
|
||||
return r.json().get("subsonic-response", {})
|
||||
|
||||
|
||||
async def search(query: str, count: int = 5) -> list[dict]:
|
||||
"""Search for songs."""
|
||||
data = await _get("search3.view", {"query": query, "songCount": count})
|
||||
songs = data.get("searchResult3", {}).get("song", [])
|
||||
return [{"id": s["id"], "title": s["title"], "artist": s.get("artist", ""),
|
||||
"album": s.get("album", ""), "duration": s.get("duration", 0),
|
||||
"suffix": s.get("suffix", "")} for s in songs]
|
||||
|
||||
|
||||
async def recent_albums(count: int = 10) -> list[dict]:
|
||||
"""Get recently added albums."""
|
||||
data = await _get("getAlbumList2.view", {"type": "recent", "size": count})
|
||||
albums = data.get("albumList2", {}).get("album", [])
|
||||
return [{"id": a["id"], "name": a["name"], "artist": a.get("artist", ""),
|
||||
"songCount": a.get("songCount", 0), "created": a.get("created", "")} for a in albums]
|
||||
|
||||
|
||||
async def random_songs(count: int = 5, genre: str = None) -> list[dict]:
|
||||
"""Get random songs."""
|
||||
extra = {"size": count}
|
||||
if genre:
|
||||
extra["genre"] = genre
|
||||
data = await _get("getRandomSongs.view", extra)
|
||||
songs = data.get("randomSongs", {}).get("song", [])
|
||||
return [{"id": s["id"], "title": s["title"], "artist": s.get("artist", ""),
|
||||
"album": s.get("album", "")} for s in songs]
|
||||
|
||||
|
||||
async def now_playing() -> list[dict]:
|
||||
"""Get currently playing songs."""
|
||||
data = await _get("getNowPlaying.view")
|
||||
entries = data.get("nowPlaying", {}).get("entry", [])
|
||||
return [{"title": e["title"], "artist": e.get("artist", ""),
|
||||
"username": e.get("username", "")} for e in entries]
|
||||
|
||||
|
||||
async def download(song_id: str, output_dir: Path) -> Path:
|
||||
"""Download a song by ID."""
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
params = {**_params(), "id": song_id}
|
||||
async with httpx.AsyncClient() as c:
|
||||
r = await c.get(f"{NAVIDROME_URL}/rest/download.view", params=params, timeout=120)
|
||||
r.raise_for_status()
|
||||
ext = {"audio/mpeg": ".mp3", "audio/flac": ".flac", "audio/ogg": ".ogg",
|
||||
"audio/x-flac": ".flac"}.get(r.headers.get("content-type", "").split(";")[0].strip().lower(), ".mp3")
|
||||
out = output_dir / f"{song_id}{ext}"
|
||||
out.write_bytes(r.content)
|
||||
return out
|
||||
334
scripts/music-mcp/src/music_mcp/server.py
Normal file
334
scripts/music-mcp/src/music_mcp/server.py
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
"""Music MCP Server — unified music stack for Aurelio/Hermes agents.
|
||||
|
||||
Exposes tools for search, library management, debug, daily overview,
|
||||
and MIDI conversion. Backed by Navidrome, Lidarr, Prowlarr, Last.fm,
|
||||
MusicBrainz, and MuScriptor.
|
||||
|
||||
Usage:
|
||||
python -m music_mcp.server # stdio
|
||||
python -m music_mcp.server --transport sse --port 8089
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(Path(__file__).resolve().parent.parent.parent / ".env")
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from music_mcp import navidrome_client as navidrome
|
||||
from music_mcp import lidarr_client as lidarr
|
||||
from music_mcp import musicbrainz_client as mb
|
||||
from music_mcp import lastfm_client as lastfm
|
||||
from music_mcp import debug_tools
|
||||
from music_mcp import daily_overview
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DOWNLOAD_DIR = Path(os.environ.get("MUSIC_DOWNLOAD_DIR", "/tmp/music-mcp"))
|
||||
|
||||
mcp = FastMCP(
|
||||
"music-mcp",
|
||||
instructions="Music stack integration: search Navidrome/Lidarr/Prowlarr, manage library, "
|
||||
"get stats from Last.fm/ListenBrainz/Maloja, debug services, convert to MIDI.",
|
||||
)
|
||||
|
||||
|
||||
# ─── Search & Browse ─────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def music_search(query: str, count: int = 5) -> str:
|
||||
"""Search for music across Navidrome library and Lidarr database.
|
||||
|
||||
Args:
|
||||
query: Search query (e.g. "Caribou", "Sun Caribou", "LCD Soundsystem")
|
||||
count: Max results per source (default: 5)
|
||||
"""
|
||||
results = []
|
||||
# Navidrome (local library)
|
||||
try:
|
||||
songs = await navidrome.search(query, count)
|
||||
if songs:
|
||||
results.append("📁 **Navidrome Library**")
|
||||
for s in songs:
|
||||
results.append(f" • {s['artist']} — {s['title']} [{s['album']}] (id:{s['id']})")
|
||||
except Exception as e:
|
||||
results.append(f"📁 Navidrome: error ({e})")
|
||||
|
||||
# Lidarr (music manager)
|
||||
try:
|
||||
artists = await lidarr.search_artist(query)
|
||||
if artists:
|
||||
results.append("")
|
||||
results.append("💿 **Lidarr**")
|
||||
for a in artists[:count]:
|
||||
results.append(f" • {a['artistName']} (mbid:{a['foreignId'][:8]}…)")
|
||||
except Exception as e:
|
||||
results.append(f"💿 Lidarr: error ({e})")
|
||||
|
||||
# MusicBrainz (metadata)
|
||||
try:
|
||||
mb_artists = await mb.search_artist(query, limit=3)
|
||||
if mb_artists:
|
||||
results.append("")
|
||||
results.append("🧠 **MusicBrainz**")
|
||||
for a in mb_artists:
|
||||
tags = ", ".join(a['tags'][:3]) if a['tags'] else ""
|
||||
results.append(f" • {a['name']} ({a['country']}) [{tags}] (mbid:{a['mbid'][:8]}…)")
|
||||
except Exception as e:
|
||||
results.append(f"🧠 MusicBrainz: error ({e})")
|
||||
|
||||
return "\n".join(results) if results else f"No results for '{query}'"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def music_browse_recent(count: int = 10) -> str:
|
||||
"""Show recently added albums in Navidrome library."""
|
||||
albums = await navidrome.recent_albums(count)
|
||||
if not albums:
|
||||
return "No recent albums found."
|
||||
lines = ["📀 **Recently Added**"]
|
||||
for a in albums:
|
||||
lines.append(f" • {a['artist']} — {a['name']} ({a['songCount']} tracks)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def music_top_tracks(count: int = 5, period: str = "7day") -> str:
|
||||
"""Get user's top tracks from Last.fm.
|
||||
|
||||
Args:
|
||||
count: Number of tracks (default: 5)
|
||||
period: 7day, 1month, 3month, 6month, 12month, overall
|
||||
"""
|
||||
tracks = await lastfm.top_tracks(count, period)
|
||||
if not tracks:
|
||||
return "No listening data found."
|
||||
lines = [f"🎵 **Top Tracks ({period})**"]
|
||||
for t in tracks:
|
||||
lines.append(f" • {t['artist']} — {t['name']} ({t['playcount']} plays)")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def music_random(count: int = 5, genre: str = None) -> str:
|
||||
"""Get random songs from Navidrome library.
|
||||
|
||||
Args:
|
||||
count: Number of songs (default: 5)
|
||||
genre: Optional genre filter
|
||||
"""
|
||||
songs = await navidrome.random_songs(count, genre)
|
||||
if not songs:
|
||||
return "No songs found."
|
||||
lines = ["🎲 **Random Picks**"]
|
||||
for s in songs:
|
||||
lines.append(f" • {s['artist']} — {s['title']} [{s['album']}]")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ─── Library Management ──────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def music_add_artist(query: str) -> str:
|
||||
"""Search MusicBrainz and add an artist to Lidarr.
|
||||
|
||||
Args:
|
||||
query: Artist name to search and add
|
||||
"""
|
||||
results = await lidarr.search_artist(query)
|
||||
if not results:
|
||||
return f"No artists found for '{query}'"
|
||||
artist = results[0]
|
||||
try:
|
||||
added = await lidarr.add_artist(artist['foreignId'], artist['artistName'])
|
||||
return f"✅ Added {artist['artistName']} to Lidarr"
|
||||
except Exception as e:
|
||||
return f"❌ Failed to add {artist['artistName']}: {e}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def music_download_status() -> str:
|
||||
"""Check Lidarr download queue."""
|
||||
q = await lidarr.queue()
|
||||
if not q:
|
||||
return "📥 Download queue empty."
|
||||
lines = [f"📥 **Downloading** ({len(q)} items)"]
|
||||
for item in q[:10]:
|
||||
lines.append(f" • {item['artist']} — {item['title']} ({item['status']})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def music_wanted(count: int = 10) -> str:
|
||||
"""Show missing/wanted albums in Lidarr."""
|
||||
w = await lidarr.wanted(count)
|
||||
if not w:
|
||||
return "✅ No missing albums!"
|
||||
lines = [f"❓ **Missing Albums** ({len(w)})"]
|
||||
for item in w:
|
||||
lines.append(f" • {item['artist']} — {item['title']} ({item.get('releaseDate', '')[:10]})")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ─── MusicBrainz ─────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def music_artist_info(query: str) -> str:
|
||||
"""Get detailed artist info from MusicBrainz + Last.fm.
|
||||
|
||||
Args:
|
||||
query: Artist name
|
||||
"""
|
||||
lines = []
|
||||
# MusicBrainz
|
||||
try:
|
||||
artists = await mb.search_artist(query, limit=1)
|
||||
if artists:
|
||||
a = artists[0]
|
||||
info = await mb.artist_info(a['mbid'])
|
||||
lines.append(f"🧠 **{info['name']}** (MusicBrainz)")
|
||||
lines.append(f" Type: {info['type']} | Country: {info['country']}")
|
||||
if info.get('tags'):
|
||||
lines.append(f" Tags: {', '.join(info['tags'][:8])}")
|
||||
releases = await mb.artist_releases(a['mbid'], limit=5)
|
||||
if releases:
|
||||
lines.append(f" Recent releases:")
|
||||
for r in releases:
|
||||
lines.append(f" • {r['title']} ({r['firstReleaseDate'][:4]})")
|
||||
except Exception as e:
|
||||
lines.append(f"🧠 MusicBrainz: {e}")
|
||||
|
||||
# Last.fm
|
||||
try:
|
||||
info = await lastfm.artist_info(query)
|
||||
if info.get('name'):
|
||||
lines.append(f"")
|
||||
lines.append(f"📊 **{info['name']}** (Last.fm)")
|
||||
lines.append(f" Listeners: {info['listeners']:,} | Plays: {info['playcount']:,}")
|
||||
if info.get('tags'):
|
||||
lines.append(f" Tags: {', '.join(info['tags'][:8])}")
|
||||
if info.get('bio'):
|
||||
lines.append(f" Bio: {info['bio'][:200]}...")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return "\n".join(lines) if lines else f"No info found for '{query}'"
|
||||
|
||||
|
||||
# ─── MIDI Conversion ─────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def music_to_midi(query: str = None, song_id: str = None, model_size: str = "medium") -> str:
|
||||
"""Convert a song to MIDI using MuScriptor.
|
||||
|
||||
Search for the song in Navidrome, download it, and transcribe to MIDI.
|
||||
The MIDI file is saved and its path returned.
|
||||
|
||||
Args:
|
||||
query: Song search query (e.g. "Sun Caribou")
|
||||
song_id: Navidrome song ID (if already known)
|
||||
model_size: MuScriptor model: small, medium, large
|
||||
"""
|
||||
if not query and not song_id:
|
||||
return "Provide a query or song_id"
|
||||
|
||||
# Find the song
|
||||
if not song_id:
|
||||
songs = await navidrome.search(query, count=1)
|
||||
if not songs:
|
||||
return f"Song not found: {query}"
|
||||
song_id = songs[0]['id']
|
||||
label = f"{songs[0]['artist']} — {songs[0]['title']}"
|
||||
else:
|
||||
label = f"song {song_id}"
|
||||
|
||||
# Download
|
||||
try:
|
||||
audio_path = await navidrome.download(song_id, DOWNLOAD_DIR)
|
||||
except Exception as e:
|
||||
return f"❌ Download failed for {label}: {e}"
|
||||
|
||||
# Transcribe
|
||||
try:
|
||||
from music_mcp.muscriptor_bridge import transcribe_to_midi
|
||||
midi_path = await transcribe_to_midi(str(audio_path), model_size=model_size)
|
||||
return f"✅ {label}\n Audio: {audio_path}\n MIDI: {midi_path}"
|
||||
except Exception as e:
|
||||
return f"❌ Transcription failed for {label}: {e}"
|
||||
|
||||
|
||||
# ─── Debug ───────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def music_debug_services() -> str:
|
||||
"""Check health of all music services (Navidrome, Lidarr, Prowlarr, etc.)."""
|
||||
health = debug_tools.service_health()
|
||||
lines = ["🔍 **Music Service Health**"]
|
||||
for svc, status in health.items():
|
||||
icon = "✅" if "running" in status.lower() or status == "running" else "❌"
|
||||
lines.append(f" {icon} {svc}: {status}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def music_debug_logs(service: str, lines: int = 50) -> str:
|
||||
"""Get recent logs from a music service.
|
||||
|
||||
Args:
|
||||
service: Service name (navidrome, lidarr, prowlarr, slskd, jellyfin, qbittorrent)
|
||||
lines: Number of log lines (default: 50)
|
||||
"""
|
||||
return debug_tools.service_logs(service, lines)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def music_debug_config(service: str) -> str:
|
||||
"""Dump config (env, mounts, ports) for a music service.
|
||||
|
||||
Args:
|
||||
service: Service name (navidrome, lidarr, prowlarr, slskd, jellyfin)
|
||||
"""
|
||||
return debug_tools.service_config(service)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def music_debug_disk() -> str:
|
||||
"""Check music library disk usage."""
|
||||
return debug_tools.disk_usage()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def music_debug_scrobbler() -> str:
|
||||
"""Check multi-scrobbler status and recent activity."""
|
||||
return debug_tools.scrobbler_status()
|
||||
|
||||
|
||||
# ─── Daily Overview ──────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def music_daily_brief() -> str:
|
||||
"""Generate a daily music overview: top artists, new releases, queue, service health."""
|
||||
return await daily_overview.daily_brief()
|
||||
|
||||
|
||||
# ─── Entry point ─────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="Music MCP Server")
|
||||
parser.add_argument("--transport", choices=["stdio", "sse", "streamable-http"], default="stdio")
|
||||
parser.add_argument("--port", type=int, default=int(os.environ.get("MCP_PORT", "8089")))
|
||||
parser.add_argument("--host", default=os.environ.get("MCP_HOST", "0.0.0.0"))
|
||||
args = parser.parse_args()
|
||||
|
||||
logger.info("Starting Music MCP Server (transport=%s)", args.transport)
|
||||
if args.transport in ("sse", "streamable-http"):
|
||||
mcp.settings.host = args.host
|
||||
mcp.settings.port = args.port
|
||||
mcp.run(transport=args.transport)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
19
scripts/music-mcp/systemd/music-mcp.service
Normal file
19
scripts/music-mcp/systemd/music-mcp.service
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
[Unit]
|
||||
Description=Music MCP Server (Navidrome + Lidarr + Last.fm + MuScriptor)
|
||||
After=network.target docker.service
|
||||
Wants=docker.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
WorkingDirectory=/opt/music-mcp
|
||||
EnvironmentFile=/opt/music-mcp/.env
|
||||
Environment=MCP_PORT=8089
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
ExecStart=/opt/music-mcp/.venv/bin/python -m music_mcp.server --transport sse --port 8089
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
TimeoutStartSec=60
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
283
scripts/onboard-client.py
Normal file
283
scripts/onboard-client.py
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Réplica Omnisciente — Client Onboarding Tool
|
||||
|
||||
Scaffolds a new client's complete replica-omnisciente: brain (.aurelio/),
|
||||
realms/, scripts/, CI/CD, identity, providers, connectors — the full power set.
|
||||
|
||||
USAGE (interactive):
|
||||
python3 scripts/onboard-client.py
|
||||
|
||||
USAGE (CLI flags):
|
||||
python3 scripts/onboard-client.py \\
|
||||
--client-name "Acme Corp" \\
|
||||
--git-url https://github.com/acme/replica \\
|
||||
--lead-engineer "Jane Doe" \\
|
||||
--realm iot-backend --realm-name "IoT Backend" --realm-repo https://github.com/acme/iot
|
||||
|
||||
USAGE (JSON config):
|
||||
python3 scripts/onboard-client.py --config onboarding.json
|
||||
|
||||
USAGE (dry run — preview without writing):
|
||||
python3 scripts/onboard-client.py --client-name "Acme Corp" --dry-run
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Bootstrap: ensure we can import the onboarding package
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from onboarding import OnboardingConfig, scaffold_client
|
||||
from onboarding.config import _slugify
|
||||
|
||||
|
||||
def _prompt(label: str, default: str = "") -> str:
|
||||
"""Prompt with default, return user input or default."""
|
||||
suffix = f" [{default}]" if default else ""
|
||||
val = input(f"{label}{suffix}: ").strip()
|
||||
return val or default
|
||||
|
||||
|
||||
def _prompt_bool(label: str, default: bool = True) -> bool:
|
||||
d = "Y/n" if default else "y/N"
|
||||
val = input(f"{label} [{d}]: ").strip().lower()
|
||||
if not val:
|
||||
return default
|
||||
return val in ("y", "yes", "true", "1")
|
||||
|
||||
|
||||
def _prompt_list(label: str, fields: list[str]) -> list[dict]:
|
||||
"""Prompt for a list of items (e.g. team members, realms)."""
|
||||
items = []
|
||||
print(f"\n{label} (press Enter with empty name to finish):")
|
||||
while True:
|
||||
first_field = fields[0]
|
||||
val = input(f" {first_field.capitalize()}: ").strip()
|
||||
if not val:
|
||||
break
|
||||
item = {first_field: val}
|
||||
for f in fields[1:]:
|
||||
item[f] = input(f" {f.capitalize()}: ").strip()
|
||||
items.append(item)
|
||||
print()
|
||||
return items
|
||||
|
||||
|
||||
def interactive_wizard() -> OnboardingConfig:
|
||||
"""Run the interactive onboarding wizard."""
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(" RÉPLICA OMNISCIENTE — CLIENT ONBOARDING")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# ── Identity ──
|
||||
print("── Identity ──")
|
||||
client_name = _prompt("Client name")
|
||||
slug = _slugify(client_name)
|
||||
client_slug = _prompt("Client slug", slug)
|
||||
description = _prompt("Description (one line)")
|
||||
|
||||
# ── Git ──
|
||||
print("\n── Git Repository ──")
|
||||
git_url = _prompt("Git URL (leave empty for local-only)")
|
||||
forge_remote = _prompt("Remote name", "origin")
|
||||
|
||||
# ── Infrastructure ──
|
||||
print("\n── Infrastructure ──")
|
||||
sync_endpoint = _prompt("Sync endpoint URL (leave empty for local-only)")
|
||||
proxmox_host = _prompt("Proxmox host IP (leave empty if none)")
|
||||
ct_id = 0
|
||||
if proxmox_host:
|
||||
ct_id = int(_prompt("Proxmox CT ID", "0") or "0")
|
||||
|
||||
# ── Team ──
|
||||
print("\n── Lead Engineer ──")
|
||||
lead_name = _prompt("Lead engineer name")
|
||||
lead_email = _prompt("Lead engineer email")
|
||||
|
||||
print("\n── Team Members ──")
|
||||
team = _prompt_list("Add team members", ["name", "email", "role"])
|
||||
|
||||
# ── Realms ──
|
||||
print("\n── Initial Realms ──")
|
||||
realms_raw = _prompt_list("Add realms", ["slug", "name", "repo", "stack", "description"])
|
||||
realms = []
|
||||
for r in realms_raw:
|
||||
realms.append({
|
||||
"slug": r.get("slug", ""),
|
||||
"name": r.get("name", ""),
|
||||
"repo": r.get("repo", ""),
|
||||
"stack": r.get("stack", ""),
|
||||
"description": r.get("description", ""),
|
||||
})
|
||||
|
||||
# ── Models ──
|
||||
print("\n── Models ──")
|
||||
local_model = _prompt("Default local model", "qwen2.5-coder:14b")
|
||||
cloud_model = _prompt("Default cloud model", "gemini-2.5-pro")
|
||||
ollama_url = _prompt("Ollama URL", "http://127.0.0.1:11434")
|
||||
|
||||
# ── Powers ──
|
||||
print("\n── Capabilities to include ──")
|
||||
print("(These are the 'powers' of Réplica Omnisciente to copy)")
|
||||
include_skills = _prompt_bool("Copy skills tree (CLI guides)?", True)
|
||||
include_providers = _prompt_bool("Include provider registry?", True)
|
||||
include_connectors = _prompt_bool("Include connector registry?", True)
|
||||
include_scripts = _prompt_bool("Copy scripts (sync, importers)?", True)
|
||||
include_ci = _prompt_bool("Generate CI/CD workflows?", True)
|
||||
|
||||
return OnboardingConfig(
|
||||
client_name=client_name,
|
||||
client_slug=client_slug,
|
||||
description=description,
|
||||
git_url=git_url,
|
||||
forge_remote_name=forge_remote,
|
||||
sync_endpoint=sync_endpoint,
|
||||
proxmox_host=proxmox_host,
|
||||
ct_id=ct_id,
|
||||
lead_engineer_name=lead_name,
|
||||
lead_engineer_email=lead_email,
|
||||
team_members=team,
|
||||
realms=realms,
|
||||
default_local_model=local_model,
|
||||
default_cloud_model=cloud_model,
|
||||
ollama_url=ollama_url,
|
||||
include_skills=include_skills,
|
||||
include_providers=include_providers,
|
||||
include_connectors=include_connectors,
|
||||
include_scripts=include_scripts,
|
||||
include_ci=include_ci,
|
||||
)
|
||||
|
||||
|
||||
def build_from_args(args: argparse.Namespace) -> OnboardingConfig:
|
||||
"""Build config from CLI flags (non-interactive mode)."""
|
||||
realms = []
|
||||
if args.realm:
|
||||
for i, slug in enumerate(args.realm):
|
||||
realms.append({
|
||||
"slug": slug,
|
||||
"name": args.realm_name[i] if i < len(args.realm_name) else slug,
|
||||
"repo": args.realm_repo[i] if i < len(args.realm_repo) else "",
|
||||
"stack": args.realm_stack[i] if i < len(args.realm_stack) else "",
|
||||
"description": args.realm_desc[i] if i < len(args.realm_desc) else "",
|
||||
})
|
||||
|
||||
return OnboardingConfig(
|
||||
client_name=args.client_name,
|
||||
client_slug=args.client_slug or "",
|
||||
description=args.description or "",
|
||||
git_url=args.git_url or "",
|
||||
forge_remote_name=args.remote_name or "origin",
|
||||
sync_endpoint=args.sync_endpoint or "",
|
||||
proxmox_host=args.proxmox_host or "",
|
||||
ct_id=args.ct_id or 0,
|
||||
lead_engineer_name=args.lead_engineer or "",
|
||||
lead_engineer_email=args.lead_email or "",
|
||||
realms=realms,
|
||||
default_local_model=args.local_model or "qwen2.5-coder:14b",
|
||||
default_cloud_model=args.cloud_model or "gemini-2.5-pro",
|
||||
ollama_url=args.ollama_url or "http://127.0.0.1:11434",
|
||||
include_skills=not args.no_skills,
|
||||
include_providers=not args.no_providers,
|
||||
include_connectors=not args.no_connectors,
|
||||
include_scripts=not args.no_scripts,
|
||||
include_ci=not args.no_ci,
|
||||
output_dir=args.output or "",
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scaffold a new client's Réplica Omnisciente",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
|
||||
# ── Source ──
|
||||
parser.add_argument("--config", help="JSON config file (skips all other flags)")
|
||||
parser.add_argument("--interactive", "-i", action="store_true",
|
||||
help="Force interactive wizard even with flags")
|
||||
|
||||
# ── Identity ──
|
||||
parser.add_argument("--client-name", help="Client name")
|
||||
parser.add_argument("--client-slug", help="Client slug (auto from name)")
|
||||
parser.add_argument("--description", help="One-line description")
|
||||
|
||||
# ── Git ──
|
||||
parser.add_argument("--git-url", help="Git remote URL")
|
||||
parser.add_argument("--remote-name", default="origin", help="Git remote name")
|
||||
parser.add_argument("--output", "-o", help="Output directory (default: ./<slug>)")
|
||||
|
||||
# ── Infrastructure ──
|
||||
parser.add_argument("--sync-endpoint", help="MCP sync endpoint URL")
|
||||
parser.add_argument("--proxmox-host", help="Proxmox host for brain sync")
|
||||
parser.add_argument("--ct-id", type=int, help="Proxmox CT ID")
|
||||
|
||||
# ── Team ──
|
||||
parser.add_argument("--lead-engineer", help="Lead engineer name")
|
||||
parser.add_argument("--lead-email", help="Lead engineer email")
|
||||
|
||||
# ── Realms ──
|
||||
parser.add_argument("--realm", action="append", default=[], help="Realm slug (repeatable)")
|
||||
parser.add_argument("--realm-name", action="append", default=[])
|
||||
parser.add_argument("--realm-repo", action="append", default=[])
|
||||
parser.add_argument("--realm-stack", action="append", default=[])
|
||||
parser.add_argument("--realm-desc", action="append", default=[])
|
||||
|
||||
# ── Models ──
|
||||
parser.add_argument("--local-model", help="Default local model")
|
||||
parser.add_argument("--cloud-model", help="Default cloud model")
|
||||
parser.add_argument("--ollama-url", help="Ollama URL")
|
||||
|
||||
# ── Power toggles ──
|
||||
parser.add_argument("--no-skills", action="store_true", help="Skip skills tree")
|
||||
parser.add_argument("--no-providers", action="store_true", help="Skip provider registry")
|
||||
parser.add_argument("--no-connectors", action="store_true", help="Skip connector registry")
|
||||
parser.add_argument("--no-scripts", action="store_true", help="Skip scripts")
|
||||
parser.add_argument("--no-ci", action="store_true", help="Skip CI/CD")
|
||||
|
||||
# ── Meta ──
|
||||
parser.add_argument("--dry-run", action="store_true", help="Preview without writing")
|
||||
parser.add_argument("--print-json", action="store_true",
|
||||
help="Print resolved config as JSON and exit")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# ── Resolve config ──
|
||||
if args.config:
|
||||
cfg = OnboardingConfig.from_json_file(args.config)
|
||||
if args.dry_run:
|
||||
cfg.dry_run = True
|
||||
if args.output:
|
||||
cfg.output_dir = args.output
|
||||
elif args.interactive or not args.client_name:
|
||||
cfg = interactive_wizard()
|
||||
if args.dry_run:
|
||||
cfg.dry_run = True
|
||||
else:
|
||||
cfg = build_from_args(args)
|
||||
|
||||
if args.print_json:
|
||||
print(cfg.to_json())
|
||||
return
|
||||
|
||||
# ── Execute ──
|
||||
try:
|
||||
scaffold_client(cfg)
|
||||
except Exception as e:
|
||||
print(f"\nERROR: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
12
scripts/onboarding/__init__.py
Normal file
12
scripts/onboarding/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"""
|
||||
Onboarding subpackage — scaffold a new client's Réplica Omnisciente.
|
||||
|
||||
Exports:
|
||||
scaffold_client — main entry point
|
||||
OnboardingConfig — dataclass holding all onboarding parameters
|
||||
"""
|
||||
|
||||
from .config import OnboardingConfig
|
||||
from .scaffolder import scaffold_client
|
||||
|
||||
__all__ = ["OnboardingConfig", "scaffold_client"]
|
||||
123
scripts/onboarding/config.py
Normal file
123
scripts/onboarding/config.py
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
"""
|
||||
OnboardingConfig — all parameters needed to scaffold a new client.
|
||||
|
||||
Collected interactively (prompts) or from CLI flags / a JSON config file.
|
||||
Every field has a sensible default so a client can be onboarded with just
|
||||
a name and a git URL.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import json
|
||||
import re
|
||||
|
||||
|
||||
def _slugify(name: str) -> str:
|
||||
"""Turn 'Acme Corp' into 'acme-corp'."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower().strip()).strip("-")
|
||||
return slug or "client"
|
||||
|
||||
|
||||
def _infer_git_provider(url: str) -> str:
|
||||
"""Guess forge type from a git URL."""
|
||||
u = url.lower()
|
||||
if "github.com" in u:
|
||||
return "github"
|
||||
if "gitlab.com" in u:
|
||||
return "gitlab"
|
||||
if "codeberg.org" in u:
|
||||
return "codeberg"
|
||||
# Self-hosted Forgejo / Gitea — generic
|
||||
return "forgejo"
|
||||
|
||||
|
||||
@dataclass
|
||||
class OnboardingConfig:
|
||||
# ── Identity ────────────────────────────────────────────────
|
||||
client_name: str = "" # "Acme Corp"
|
||||
client_slug: str = "" # "acme-corp" (auto from name)
|
||||
replica_name: str = "" # "Réplica Omnisciente — Acme" (auto)
|
||||
description: str = "" # one-line description
|
||||
|
||||
# ── Git ──────────────────────────────────────────────────────
|
||||
git_url: str = "" # git remote URL for the new repo
|
||||
git_provider: str = "" # github | gitlab | forgejo | codeberg
|
||||
forge_remote_name: str = "origin" # remote name to configure
|
||||
|
||||
# ── Infrastructure ──────────────────────────────────────────
|
||||
sync_endpoint: str = "" # MCP sync endpoint (blank = local-only)
|
||||
proxmox_host: str = "" # Proxmox host for brain sync (blank = none)
|
||||
ct_id: int = 0 # CT ID for brain sync (0 = local-only)
|
||||
|
||||
# ── Team / Heteronyms ───────────────────────────────────────
|
||||
lead_engineer_name: str = "" # principal engineer (first heteronym)
|
||||
lead_engineer_email: str = "" # email
|
||||
team_members: list[dict] = field(default_factory=list)
|
||||
# Each: {"name": ..., "email": ..., "role": ...}
|
||||
|
||||
# ── Realms (initial) ────────────────────────────────────────
|
||||
realms: list[dict] = field(default_factory=list)
|
||||
# Each: {"slug": ..., "name": ..., "repo": ..., "stack": ..., "description": ...}
|
||||
|
||||
# ── Models ──────────────────────────────────────────────────
|
||||
default_local_model: str = "qwen2.5-coder:14b"
|
||||
default_cloud_model: str = "gemini-2.5-pro"
|
||||
ollama_url: str = "http://127.0.0.1:11434"
|
||||
|
||||
# ── Power toggles (which capabilities to copy) ──────────────
|
||||
include_brain: bool = True
|
||||
include_skills: bool = True
|
||||
include_providers: bool = True
|
||||
include_connectors: bool = True
|
||||
include_mcp_config: bool = True
|
||||
include_identity: bool = True
|
||||
include_scripts: bool = True
|
||||
include_ci: bool = True
|
||||
include_provisioner: bool = False # advanced: savearth-workspace specific
|
||||
|
||||
# ── Meta ────────────────────────────────────────────────────
|
||||
output_dir: str = "" # where to create the project (default: ./<slug>)
|
||||
dry_run: bool = False
|
||||
source_replica: str = "" # path to canonical replica-omnisciente (auto-detected)
|
||||
|
||||
def __post_init__(self):
|
||||
if not self.client_slug:
|
||||
self.client_slug = _slugify(self.client_name)
|
||||
if not self.replica_name:
|
||||
self.replica_name = f"Réplica Omnisciente — {self.client_name}".strip("— ")
|
||||
if not self.git_provider and self.git_url:
|
||||
self.git_provider = _infer_git_provider(self.git_url)
|
||||
if not self.source_replica:
|
||||
# Auto-detect: this file lives in <replica>/scripts/onboarding/
|
||||
self.source_replica = str(
|
||||
Path(__file__).resolve().parents[2]
|
||||
)
|
||||
if not self.output_dir:
|
||||
self.output_dir = str(Path.cwd() / self.client_slug)
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(asdict(self), indent=2)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> "OnboardingConfig":
|
||||
data = json.loads(json_str)
|
||||
# Remove computed fields that __post_init__ will re-derive
|
||||
for key in ("client_slug", "replica_name", "git_provider", "source_replica"):
|
||||
data.pop(key, None)
|
||||
return cls(**data)
|
||||
|
||||
@classmethod
|
||||
def from_json_file(cls, path: str) -> "OnboardingConfig":
|
||||
return cls.from_json(Path(path).read_text())
|
||||
|
||||
def validate(self) -> list[str]:
|
||||
"""Return a list of validation errors (empty = valid)."""
|
||||
errors = []
|
||||
if not self.client_name:
|
||||
errors.append("client_name is required")
|
||||
if not self.client_slug:
|
||||
errors.append("client_slug could not be derived from client_name")
|
||||
return errors
|
||||
424
scripts/onboarding/scaffolder.py
Normal file
424
scripts/onboarding/scaffolder.py
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
"""
|
||||
Scaffolder — creates the full directory tree and files for a new client.
|
||||
|
||||
This is the engine that turns an OnboardingConfig into a living replica-omnisciente
|
||||
skeleton on disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
from .config import OnboardingConfig
|
||||
from . import templates
|
||||
|
||||
|
||||
class ScaffoldError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _write(path: Path, content: str, cfg: OnboardingConfig, executable: bool = False) -> bool:
|
||||
"""Write a file unless dry_run. Returns True if written."""
|
||||
if cfg.dry_run:
|
||||
print(f" [DRY] {path}")
|
||||
return False
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content)
|
||||
if executable:
|
||||
path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
print(f" [OK] {path}")
|
||||
return True
|
||||
|
||||
|
||||
def _copy_dir(src: Path, dst: Path, cfg: OnboardingConfig, label: str = "") -> bool:
|
||||
"""Copy a directory tree unless dry_run."""
|
||||
if cfg.dry_run:
|
||||
print(f" [DRY] {dst}/ ({label})")
|
||||
return False
|
||||
if not src.exists():
|
||||
print(f" [SKIP] {dst}/ (source '{src}' does not exist)")
|
||||
return False
|
||||
shutil.copytree(src, dst, dirs_exist_ok=True)
|
||||
print(f" [OK] {dst}/ ({label})")
|
||||
return True
|
||||
|
||||
|
||||
def _copy_file(src: Path, dst: Path, cfg: OnboardingConfig, executable: bool = False) -> bool:
|
||||
if cfg.dry_run:
|
||||
print(f" [DRY] {dst}")
|
||||
return False
|
||||
if not src.exists():
|
||||
print(f" [SKIP] {dst} (source '{src}' does not exist)")
|
||||
return False
|
||||
shutil.copy2(src, dst)
|
||||
if executable:
|
||||
dst.chmod(dst.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
|
||||
print(f" [OK] {dst}")
|
||||
return True
|
||||
|
||||
|
||||
def scaffold_client(cfg: OnboardingConfig) -> Path:
|
||||
"""
|
||||
Scaffold a complete client replica-omnisciente.
|
||||
|
||||
Args:
|
||||
cfg: Fully populated OnboardingConfig.
|
||||
|
||||
Returns:
|
||||
Path to the created project root.
|
||||
|
||||
Raises:
|
||||
ScaffoldError: If validation fails or output dir already exists (non-dry-run).
|
||||
"""
|
||||
errors = cfg.validate()
|
||||
if errors:
|
||||
raise ScaffoldError("Validation failed:\n" + "\n".join(f" - {e}" for e in errors))
|
||||
|
||||
root = Path(cfg.output_dir)
|
||||
src = Path(cfg.source_replica)
|
||||
|
||||
mode = "[DRY RUN] " if cfg.dry_run else ""
|
||||
print(f"\n{'='*60}")
|
||||
print(f"{mode}Scaffolding: {cfg.replica_name}")
|
||||
print(f"{'='*60}")
|
||||
print(f" Client: {cfg.client_name} ({cfg.client_slug})")
|
||||
print(f" Output: {root}")
|
||||
print(f" Source: {src}")
|
||||
print(f" Git: {cfg.git_url or '(not set)'}")
|
||||
print(f" Endpoint: {cfg.sync_endpoint or '(local-only)'}")
|
||||
print(f" Realms: {len(cfg.realms)}")
|
||||
print(f" Team: {1 + len(cfg.team_members)} heteronym(s)")
|
||||
print()
|
||||
|
||||
if not cfg.dry_run:
|
||||
if root.exists() and any(root.iterdir()):
|
||||
raise ScaffoldError(f"Output directory already exists and is not empty: {root}")
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# ── Root files ──────────────────────────────────────────────
|
||||
print("─ Root files ─")
|
||||
_write(root / "AGENTS.md", templates.agents_md(cfg), cfg)
|
||||
_write(root / "README.md", templates.readme_md(cfg), cfg)
|
||||
_write(root / ".gitignore", templates.gitignore(), cfg)
|
||||
_write(root / ".env.example", templates.env_example(cfg), cfg)
|
||||
|
||||
# ── .aurelio/ ───────────────────────────────────────────────
|
||||
aurelio = root / ".aurelio"
|
||||
print("\n─ .aurelio/ ─")
|
||||
_write(aurelio / "config.toml", templates.aurelio_config_toml(cfg), cfg)
|
||||
_write(aurelio / "mcp_config.json", templates.mcp_config_json(cfg), cfg)
|
||||
|
||||
# Brain (empty structure)
|
||||
_write(aurelio / "brain" / ".gitkeep", "", cfg)
|
||||
_write(aurelio / "memory" / "index.md",
|
||||
f"# {cfg.replica_name} — Global Memory\n", cfg)
|
||||
_write(aurelio / "plans" / ".gitkeep", "", cfg)
|
||||
_write(aurelio / "sessions" / ".gitkeep", "", cfg)
|
||||
|
||||
# Sync script
|
||||
if cfg.sync_endpoint or cfg.proxmox_host:
|
||||
_write(aurelio / "sync.py", templates.sync_py(cfg), cfg, executable=True)
|
||||
else:
|
||||
_write(aurelio / "sync.py", templates.sync_py(cfg), cfg, executable=True)
|
||||
|
||||
# ── .aurelio/identity/ ──────────────────────────────────────
|
||||
if cfg.include_identity:
|
||||
print("\n─ .aurelio/identity/ ─")
|
||||
ident = aurelio / "identity"
|
||||
_write(ident / "heteronyms.json", templates.heteronyms_json(cfg), cfg)
|
||||
_write(ident / "README.md", templates.identity_readme(cfg), cfg)
|
||||
|
||||
# Heteronym .md stubs
|
||||
heteronimos = aurelio / "heteronimos"
|
||||
if cfg.lead_engineer_name:
|
||||
slug = cfg.lead_engineer_name.lower().replace(" ", "-")
|
||||
_write(heteronimos / f"{slug}.md",
|
||||
f"---\nslug: {slug}\nname: {cfg.lead_engineer_name}\nroleDefinition: Lead engineer for {cfg.client_name}.\n---\n## {cfg.lead_engineer_name}\n\n{cfg.lead_engineer_name} is the principal engineer for {cfg.client_name}.\n",
|
||||
cfg)
|
||||
for member in cfg.team_members:
|
||||
slug = member["name"].lower().replace(" ", "-")
|
||||
_write(heteronimos / f"{slug}.md",
|
||||
f"---\nslug: {slug}\nname: {member['name']}\nroleDefinition: {member.get('role', 'Engineer')} for {cfg.client_name}.\n---\n## {member['name']}\n\n{member['name']} is a {member.get('role', 'engineer')} for {cfg.client_name}.\n",
|
||||
cfg)
|
||||
|
||||
# ── .aurelio/providers/ ─────────────────────────────────────
|
||||
if cfg.include_providers:
|
||||
print("\n─ .aurelio/providers/ ─")
|
||||
prov = aurelio / "providers"
|
||||
_write(prov / "registry.yaml", templates.providers_registry_yaml(cfg), cfg)
|
||||
_write(prov / "dist" / ".gitkeep", "", cfg)
|
||||
|
||||
# ── .aurelio/connectors/ ────────────────────────────────────
|
||||
if cfg.include_connectors:
|
||||
print("\n─ .aurelio/connectors/ ─")
|
||||
conn = aurelio / "connectors"
|
||||
_write(conn / "registry.yaml", templates.connectors_registry_yaml(cfg), cfg)
|
||||
_write(conn / "dist" / ".gitkeep", "", cfg)
|
||||
|
||||
# ── .aurelio/skills/ ────────────────────────────────────────
|
||||
if cfg.include_skills:
|
||||
print("\n─ .aurelio/skills/ ─")
|
||||
skills_dst = aurelio / "skills"
|
||||
skills_src = src / ".aurelio" / "skills"
|
||||
if skills_src.exists():
|
||||
# Copy the full skills tree (these are portable CLI guides)
|
||||
_copy_dir(skills_src, skills_dst, cfg, label=f"skills tree")
|
||||
else:
|
||||
_write(skills_dst / ".gitkeep", "", cfg)
|
||||
|
||||
# ── .aurelio/knowledge/ + chronicle/ ────────────────────────
|
||||
_write(aurelio / "knowledge" / ".gitkeep", "", cfg)
|
||||
chronicle = aurelio / "chronicle"
|
||||
_write(chronicle / "README.md",
|
||||
f"# Chronicle: {cfg.replica_name}\n\nTimeline of events across all realms.\n", cfg)
|
||||
_write(chronicle / "timeline.yaml", "version: 1\nevents: []\n", cfg)
|
||||
_write(chronicle / "realms" / ".gitkeep", "", cfg)
|
||||
|
||||
# ── .aurelio/swarm/ ─────────────────────────────────────────
|
||||
_write(aurelio / "swarm" / "README.md",
|
||||
f"# Swarm\n\nAgent fleet for {cfg.replica_name}.\n", cfg)
|
||||
|
||||
# ── realms/ ─────────────────────────────────────────────────
|
||||
if cfg.realms:
|
||||
print("\n─ realms/ ─")
|
||||
for realm in cfg.realms:
|
||||
rdir = root / "realms" / realm["slug"]
|
||||
_write(rdir / "AGENTS.md", templates.realm_agents_md(cfg, realm), cfg)
|
||||
ra = rdir / ".aurelio"
|
||||
_write(ra / "config.toml", templates.realm_config_toml(cfg, realm), cfg)
|
||||
_write(ra / "memory" / "index.md", templates.realm_memory_index(cfg, realm), cfg)
|
||||
_write(ra / "plans" / ".gitkeep", "", cfg)
|
||||
|
||||
# ── scripts/ ────────────────────────────────────────────────
|
||||
if cfg.include_scripts:
|
||||
print("\n─ scripts/ ─")
|
||||
scripts_dst = root / "scripts"
|
||||
scripts_src = src / "scripts"
|
||||
|
||||
# Copy the agent-importer engine (portable, no project-specific deps)
|
||||
_write(scripts_dst / "onboard-client.py", _onboard_cli_stub(cfg), cfg, executable=True)
|
||||
|
||||
# Copy sync-agents-to-brain.py if it exists
|
||||
sync_agents = scripts_src / "sync-agents-to-brain.py"
|
||||
if sync_agents.exists():
|
||||
_copy_file(sync_agents, scripts_dst / "sync-agents-to-brain.py", cfg, executable=True)
|
||||
|
||||
# Copy agent_importers package
|
||||
importers_src = scripts_src / "agent_importers"
|
||||
if importers_src.exists():
|
||||
_copy_dir(importers_src, scripts_dst / "agent_importers", cfg, label="agent importers")
|
||||
|
||||
# Copy onboarding package itself (self-replicating)
|
||||
onboarding_src = scripts_src / "onboarding"
|
||||
if onboarding_src.exists():
|
||||
_copy_dir(onboarding_src, scripts_dst / "onboarding", cfg, label="onboarding package")
|
||||
|
||||
# Copy data_sharing package (consent-gated data transmission)
|
||||
ds_src = scripts_src / "data_sharing"
|
||||
if ds_src.exists():
|
||||
_copy_dir(ds_src, scripts_dst / "data_sharing", cfg, label="data sharing layer")
|
||||
ds_cli = scripts_src / "data-sharing.py"
|
||||
if ds_cli.exists():
|
||||
_copy_file(ds_cli, scripts_dst / "data-sharing.py", cfg, executable=True)
|
||||
|
||||
# Copy provider/connector mirror generators if they exist
|
||||
for gen in ["generate-provider-mirrors.py", "generate-connector-mirrors.py"]:
|
||||
gen_src = scripts_src / gen
|
||||
if gen_src.exists():
|
||||
_copy_file(gen_src, scripts_dst / gen, cfg, executable=True)
|
||||
|
||||
# ── CI/CD ───────────────────────────────────────────────────
|
||||
if cfg.include_ci:
|
||||
print("\n─ CI/CD ─")
|
||||
if cfg.git_provider in ("forgejo", "codeberg"):
|
||||
_write(root / ".forgejo" / "workflows" / "aurelio-sync.yml",
|
||||
templates.forgejo_workflow(cfg), cfg)
|
||||
if cfg.git_provider in ("github", "gitlab"):
|
||||
_write(root / ".github" / "workflows" / "build.yml",
|
||||
templates.github_workflow(cfg), cfg)
|
||||
# Always include both so the client can switch forges later
|
||||
if cfg.git_provider not in ("forgejo", "codeberg"):
|
||||
_write(root / ".forgejo" / "workflows" / "aurelio-sync.yml",
|
||||
templates.forgejo_workflow(cfg), cfg)
|
||||
if cfg.git_provider not in ("github", "gitlab"):
|
||||
_write(root / ".github" / "workflows" / "build.yml",
|
||||
templates.github_workflow(cfg), cfg)
|
||||
|
||||
# ── Git init ────────────────────────────────────────────────
|
||||
if not cfg.dry_run:
|
||||
print("\n─ Git init ─")
|
||||
_git_init(root, cfg)
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
mode = "[DRY RUN] " if cfg.dry_run else ""
|
||||
print(f"{mode}Done: {cfg.replica_name}")
|
||||
print(f"{'='*60}")
|
||||
if not cfg.dry_run:
|
||||
print(f"\nNext steps:")
|
||||
print(f" cd {root}")
|
||||
print(f" cp .env.example .env # fill in API keys")
|
||||
if cfg.git_url:
|
||||
print(f" git remote add {cfg.forge_remote_name} {cfg.git_url}")
|
||||
print(f" git push -u {cfg.forge_remote_name} main")
|
||||
print(f" # Read AGENTS.md for the full guide")
|
||||
print()
|
||||
|
||||
return root
|
||||
|
||||
|
||||
def _git_init(root: Path, cfg: OnboardingConfig):
|
||||
"""Initialize git and make initial commit."""
|
||||
import subprocess
|
||||
|
||||
try:
|
||||
# Initialize repo
|
||||
subprocess.run(["git", "init"], cwd=root, check=True, capture_output=True)
|
||||
|
||||
# Set per-repo identity if none is configured globally
|
||||
has_identity = subprocess.run(
|
||||
["git", "config", "user.email"],
|
||||
cwd=root, capture_output=True, text=True
|
||||
).returncode == 0
|
||||
if not has_identity:
|
||||
subprocess.run(["git", "config", "user.email",
|
||||
cfg.lead_engineer_email or f"noreply@{cfg.client_slug}.com"],
|
||||
cwd=root, check=True, capture_output=True)
|
||||
subprocess.run(["git", "config", "user.name",
|
||||
cfg.lead_engineer_name or cfg.client_name],
|
||||
cwd=root, check=True, capture_output=True)
|
||||
|
||||
subprocess.run(["git", "add", "-A"], cwd=root, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m",
|
||||
f"feat: initial scaffold of {cfg.replica_name}\n\n"
|
||||
f"Scaffolded from Réplica Omnisciente template.\n"
|
||||
f"Client: {cfg.client_name}"],
|
||||
cwd=root, check=True, capture_output=True
|
||||
)
|
||||
if cfg.git_url:
|
||||
subprocess.run(
|
||||
["git", "remote", "add", cfg.forge_remote_name, cfg.git_url],
|
||||
cwd=root, check=True, capture_output=True
|
||||
)
|
||||
print(f" [OK] git initialized + initial commit")
|
||||
except FileNotFoundError:
|
||||
print(f" [WARN] git not found — skipping git init")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f" [WARN] git init failed: {e.stderr.decode() if e.stderr else e}")
|
||||
|
||||
|
||||
def _onboard_cli_stub(cfg: OnboardingConfig) -> str:
|
||||
"""Generate a self-contained CLI script for the scaffolded client."""
|
||||
return f'''#!/usr/bin/env python3
|
||||
"""
|
||||
{cfg.replica_name} — Onboarding & Realm Management CLI
|
||||
|
||||
Usage:
|
||||
python3 scripts/onboard-client.py --add-realm <slug> --name "Name" --repo <url>
|
||||
python3 scripts/onboard-client.py --list-realms
|
||||
python3 scripts/onboard-client.py --info
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure we can import the onboarding package
|
||||
SCRIPTS_DIR = Path(__file__).parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
from onboarding import OnboardingConfig, scaffold_client
|
||||
from onboarding.templates import realm_agents_md, realm_config_toml, realm_memory_index
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def add_realm(args):
|
||||
"""Add a new realm to this replica."""
|
||||
realm = {{
|
||||
"slug": args.slug,
|
||||
"name": args.name or args.slug,
|
||||
"repo": args.repo or "",
|
||||
"stack": args.stack or "",
|
||||
"description": args.description or "",
|
||||
}}
|
||||
rdir = ROOT / "realms" / realm["slug"]
|
||||
if rdir.exists() and not args.force:
|
||||
print(f"Realm '{{realm['slug']}}' already exists. Use --force to overwrite.")
|
||||
sys.exit(1)
|
||||
|
||||
# Load current config for template rendering
|
||||
cfg = OnboardingConfig(client_name="{cfg.client_name}")
|
||||
cfg.source_replica = str(ROOT)
|
||||
|
||||
rdir.mkdir(parents=True, exist_ok=True)
|
||||
(rdir / "AGENTS.md").write_text(realm_agents_md(cfg, realm))
|
||||
ra = rdir / ".aurelio"
|
||||
ra.mkdir(parents=True, exist_ok=True)
|
||||
(ra / "config.toml").write_text(realm_config_toml(cfg, realm))
|
||||
(ra / "memory").mkdir(exist_ok=True)
|
||||
(ra / "memory" / "index.md").write_text(realm_memory_index(cfg, realm))
|
||||
(ra / "plans").mkdir(exist_ok=True)
|
||||
|
||||
print(f"Realm '{{realm['slug']}}' created at {{rdir}}")
|
||||
print(f" AGENTS.md, .aurelio/config.toml, .aurelio/memory/index.md")
|
||||
|
||||
|
||||
def list_realms(args):
|
||||
"""List all realms."""
|
||||
realms_dir = ROOT / "realms"
|
||||
if not realms_dir.exists():
|
||||
print("No realms directory.")
|
||||
return
|
||||
print("Realms:")
|
||||
for d in sorted(realms_dir.iterdir()):
|
||||
if d.is_dir():
|
||||
agents_file = d / "AGENTS.md"
|
||||
name = d.name
|
||||
if agents_file.exists():
|
||||
first_line = agents_file.read_text().splitlines()[0] if agents_file.read_text() else ""
|
||||
name = first_line.replace("#", "").strip() or d.name
|
||||
print(f" - {{d.name}}: {{name}}")
|
||||
|
||||
|
||||
def info(args):
|
||||
"""Show replica info."""
|
||||
cfg = OnboardingConfig(client_name="{cfg.client_name}")
|
||||
print(f"Replica: {{cfg.replica_name}}")
|
||||
print(f"Client: {{cfg.client_name}} ({{cfg.client_slug}})")
|
||||
print(f"Root: {{ROOT}}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="{cfg.replica_name} — Onboarding CLI")
|
||||
sub = parser.add_subparsers(dest="command")
|
||||
|
||||
p_add = sub.add_parser("add-realm", help="Add a new realm")
|
||||
p_add.add_argument("slug", help="Realm slug (e.g. 'my-project')")
|
||||
p_add.add_argument("--name", help="Human-readable name")
|
||||
p_add.add_argument("--repo", help="Git repository URL")
|
||||
p_add.add_argument("--stack", help="Technology stack summary")
|
||||
p_add.add_argument("--description", help="One-line description")
|
||||
p_add.add_argument("--force", action="store_true", help="Overwrite if exists")
|
||||
p_add.set_defaults(func=add_realm)
|
||||
|
||||
p_list = sub.add_parser("list-realms", help="List all realms")
|
||||
p_list.set_defaults(func=list_realms)
|
||||
|
||||
p_info = sub.add_parser("info", help="Show replica info")
|
||||
p_info.set_defaults(func=info)
|
||||
|
||||
args = parser.parse_args()
|
||||
if not args.command:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
656
scripts/onboarding/templates.py
Normal file
656
scripts/onboarding/templates.py
Normal file
|
|
@ -0,0 +1,656 @@
|
|||
"""
|
||||
Templates — rendered content for the scaffolded client.
|
||||
|
||||
All templates are plain Python string functions (no Jinja2 dependency) so the
|
||||
onboarding tool works on a fresh machine with only stdlib Python.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .config import OnboardingConfig
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Root AGENTS.md
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def agents_md(cfg: OnboardingConfig) -> str:
|
||||
if cfg.realms:
|
||||
realms_rows = "\n".join(
|
||||
f"| `{r['slug']}/` | {r.get('name', r['slug'])} — {r.get('description', '')} |"
|
||||
for r in cfg.realms
|
||||
)
|
||||
realms_table = f"\n{realms_rows}"
|
||||
else:
|
||||
realms_table = "\n| _(none yet — use `python3 scripts/onboard-client.py add-realm <slug>`)_ | |"
|
||||
|
||||
lead = cfg.lead_engineer_name or "Principal Engineer"
|
||||
|
||||
return f"""# {cfg.replica_name} — Agent Guide
|
||||
|
||||
{cfg.description or f'Centralized brain and monorepo for {cfg.client_name}.'}
|
||||
|
||||
This is a **Réplica Omnisciente** — a self-contained agent fleet brain scaffolded
|
||||
from the Portugal Futurista template. It provides:
|
||||
|
||||
- `.aurelio/` — central brain: config cascade, memory, skills, providers, MCP
|
||||
- `realms/` — per-project knowledge directories (each with its own `AGENTS.md`)
|
||||
- `scripts/` — brain sync, agent-session import, onboarding tools
|
||||
- CI/CD workflows for Forgejo and/or GitHub
|
||||
|
||||
## Repository layout
|
||||
|
||||
| Path | What it is |
|
||||
|------|------------|
|
||||
| `.aurelio/` | Central brain: sessions, MCP config, sync, knowledge, skills |
|
||||
| `realms/` | Per-project knowledge directories |{realms_table}
|
||||
|
||||
## Three-tier config cascade
|
||||
|
||||
```
|
||||
~/.aurelio/config.toml # Global defaults (user-level)
|
||||
<workspace>/.aurelio/config.toml # Workspace overrides
|
||||
realms/<name>/.aurelio/ # Realm-specific memory
|
||||
```
|
||||
|
||||
Resolution: **Realm > Workspace > Global** (most specific wins).
|
||||
|
||||
## Identity & Heteronyms
|
||||
|
||||
This Réplica is seeded with `{lead}` as the lead-engineer heteronym.
|
||||
Add team members as heteronyms in `.aurelio/identity/heteronyms.json` and
|
||||
`.aurelio/heteronimos/`.
|
||||
|
||||
## Brain sync
|
||||
|
||||
{"Push/pull brain to " + cfg.proxmox_host + " (CT " + str(cfg.ct_id) + "):" if cfg.sync_endpoint else "Brain is **local-only** (no sync endpoint configured). To enable:"}
|
||||
```bash
|
||||
python3 .aurelio/sync.py --push
|
||||
python3 .aurelio/sync.py --pull
|
||||
```
|
||||
|
||||
## Agent → brain import (unified)
|
||||
|
||||
Fan every coding-agent's local artifacts into the brain:
|
||||
```bash
|
||||
python3 scripts/sync-agents-to-brain.py --skip-active # all sources
|
||||
python3 scripts/sync-agents-to-brain.py --source claude-code # one source
|
||||
python3 scripts/sync-agents-to-brain.py --dry-run # preview
|
||||
```
|
||||
|
||||
## Onboarding new projects (realms)
|
||||
|
||||
```bash
|
||||
python3 scripts/onboard-client.py --add-realm <slug> --name "Project Name" --repo <git-url>
|
||||
```
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `.env` is gitignored; copy `.env.example` for API key setup.
|
||||
- Brain sync is one-directional per call: `--push` uploads, `--pull` downloads.
|
||||
- `.aurelio/providers/dist/` is generated — edit `registry.yaml`, then regenerate.
|
||||
"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# .aurelio/config.toml
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def aurelio_config_toml(cfg: OnboardingConfig) -> str:
|
||||
sync_section = f"""[sync]
|
||||
enabled = {"true" if cfg.sync_endpoint else "false"}
|
||||
endpoint = "{cfg.sync_endpoint or 'https://mcp.example.com'}"
|
||||
interval_seconds = 300
|
||||
auto_push = true""" if cfg.sync_endpoint else """[sync]
|
||||
enabled = false
|
||||
# endpoint = "https://mcp.example.com"
|
||||
interval_seconds = 300"""
|
||||
|
||||
return f"""# {cfg.replica_name} — Aurelio Configuration
|
||||
# This file controls the behavior of the Aurelio agent system.
|
||||
|
||||
{sync_section}
|
||||
|
||||
[identity]
|
||||
name = "{cfg.replica_name}"
|
||||
version = "1.0.0"
|
||||
|
||||
[models]
|
||||
default_local = "{cfg.default_local_model}"
|
||||
default_cloud = "{cfg.default_cloud_model}"
|
||||
ollama_url = "{cfg.ollama_url}"
|
||||
|
||||
[brain]
|
||||
auto_save = true
|
||||
artifact_types = ["task", "implementation_plan", "walkthrough", "analysis"]
|
||||
|
||||
# ─── Data sharing (consent-gated) ───────────────────────────────────
|
||||
# Controls what data this replica sends to Portugal Futurista.
|
||||
# ALL categories default to false — explicit opt-in required.
|
||||
# Manage via: python3 scripts/data-sharing.py status
|
||||
|
||||
[data_sharing]
|
||||
enabled = false
|
||||
transport = "{cfg.sync_endpoint and 'http' or 'local'}"
|
||||
endpoint = "{cfg.sync_endpoint or ''}"
|
||||
|
||||
[data_sharing.categories]
|
||||
tool_calls = false
|
||||
thinking = false
|
||||
chat_messages = false
|
||||
session_meta = false
|
||||
agent_metadata = false
|
||||
error_traces = false
|
||||
file_changes = false
|
||||
environment = false
|
||||
|
||||
[data_sharing.retention]
|
||||
days = 90
|
||||
redact_secrets = true
|
||||
"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# .aurelio/identity/heteronyms.json
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def heteronyms_json(cfg: OnboardingConfig) -> str:
|
||||
import json
|
||||
|
||||
heteronyms = {}
|
||||
|
||||
# Lead engineer as primary heteronym
|
||||
if cfg.lead_engineer_name:
|
||||
slug = cfg.lead_engineer_name.lower().replace(" ", "-")
|
||||
heteronyms[slug] = {
|
||||
"name": cfg.lead_engineer_name,
|
||||
"email": cfg.lead_engineer_email or f"{slug}@{cfg.client_slug}.com",
|
||||
"voice": {
|
||||
"technical": f"Methodical, precise commits. Principal engineer for {cfg.client_name}.",
|
||||
"review": "Thorough. Focuses on correctness, edge cases, and production safety."
|
||||
},
|
||||
"commit_mode": {"default": "branch"},
|
||||
"motto": ""
|
||||
}
|
||||
|
||||
# Team members
|
||||
for member in cfg.team_members:
|
||||
slug = member["name"].lower().replace(" ", "-")
|
||||
heteronyms[slug] = {
|
||||
"name": member["name"],
|
||||
"email": member.get("email", f"{slug}@{cfg.client_slug}.com"),
|
||||
"voice": {
|
||||
"technical": f"{member.get('role', 'Engineer')} for {cfg.client_name}.",
|
||||
"review": "Constructive, domain-aware."
|
||||
},
|
||||
"commit_mode": {"default": "branch"},
|
||||
"motto": ""
|
||||
}
|
||||
|
||||
# Always include a generic "orchestrator" heteronym
|
||||
heteronyms["orchestrator"] = {
|
||||
"name": f"{cfg.client_name} Orchestrator",
|
||||
"email": f"orchestrator@{cfg.client_slug}.com",
|
||||
"voice": {
|
||||
"technical": "Meta-routing and orchestration. Delegates to domain heteronyms.",
|
||||
"review": "Holistic. Focuses on cross-realm coordination."
|
||||
},
|
||||
"commit_mode": {"default": "branch"},
|
||||
"motto": ""
|
||||
}
|
||||
|
||||
return json.dumps({"heteronyms": heteronyms}, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# .aurelio/identity/README.md
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def identity_readme(cfg: OnboardingConfig) -> str:
|
||||
lead = cfg.lead_engineer_name or "Principal Engineer"
|
||||
return f"""# Identity: {cfg.replica_name}
|
||||
|
||||
## I Am
|
||||
|
||||
I am the **{cfg.replica_name}** — a persistent, multi-realm intelligence engine
|
||||
for **{cfg.client_name}**.
|
||||
|
||||
{cfg.description or ''}
|
||||
|
||||
I maintain a living map of every realm I touch. I do not guess. I consult my
|
||||
memory first. I enforce the guardrails that {lead} would enforce. I speak with
|
||||
the team's voice across repositories, time zones, and agentic instantiations.
|
||||
|
||||
---
|
||||
|
||||
## Operational Mode
|
||||
|
||||
When instantiated inside a host repository, I:
|
||||
|
||||
1. **Read the local realm's `AGENTS.md`** to absorb stack-specific directives.
|
||||
2. **Query `.aurelio/memory/`** for global context.
|
||||
3. **Load relevant subsystem memory** from `realms/<realm>/.aurelio/memory/`.
|
||||
4. **Execute with minimal intrusion**, preferring surgical edits.
|
||||
5. **Document everything** — plans go into `.aurelio/plans/`, session artifacts
|
||||
into `.aurelio/brain/`.
|
||||
|
||||
## Heteronyms
|
||||
|
||||
Each team member is a heteronym — a distinct voice with specific domain
|
||||
affinities. See `.aurelio/identity/heteronyms.json` for the full registry.
|
||||
"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# .aurelio/sync.py (adapted for client)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def sync_py(cfg: OnboardingConfig) -> str:
|
||||
host = cfg.proxmox_host or "<PROXMOX_HOST>"
|
||||
ct = cfg.ct_id or 0
|
||||
return f'''#!/usr/bin/env python3
|
||||
"""
|
||||
{cfg.replica_name} — Brain Sync Utility
|
||||
|
||||
Push/pull the local .aurelio/brain to/from a remote Proxmox container.
|
||||
Configured for CT {ct} on {host}.
|
||||
|
||||
Usage:
|
||||
python3 .aurelio/sync.py --push
|
||||
python3 .aurelio/sync.py --pull
|
||||
"""
|
||||
import os
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
VM_IP = os.environ.get("PROXMOX_HOST", "{host}")
|
||||
CONTAINER_ID = {ct}
|
||||
TARGET_DIR = "/opt/aurelio/brain"
|
||||
LOCAL_BRAIN = Path(__file__).parent / "brain"
|
||||
|
||||
|
||||
def run_cmd(cmd: str):
|
||||
print(f"Running: {{cmd}}")
|
||||
result = subprocess.run(cmd, shell=True)
|
||||
if result.returncode != 0:
|
||||
print(f"Error executing: {{cmd}}")
|
||||
sys.exit(result.returncode)
|
||||
|
||||
|
||||
def push():
|
||||
if not LOCAL_BRAIN.exists():
|
||||
print("Local brain does not exist. Nothing to push.")
|
||||
return
|
||||
print("Pushing local brain...")
|
||||
run_cmd(f"tar czf /tmp/local_brain.tar.gz -C {{LOCAL_BRAIN.parent}} brain")
|
||||
run_cmd(f"scp /tmp/local_brain.tar.gz root@{{VM_IP}}:/tmp/")
|
||||
run_cmd(f"ssh root@{{VM_IP}} 'pct push {{CONTAINER_ID}} /tmp/local_brain.tar.gz /tmp/local_brain.tar.gz'")
|
||||
run_cmd(f"ssh root@{{VM_IP}} 'pct exec {{CONTAINER_ID}} -- bash -c \\"mkdir -p {{TARGET_DIR}} && tar xzf /tmp/local_brain.tar.gz -C /opt/aurelio/\\"'")
|
||||
print("Push complete.")
|
||||
|
||||
|
||||
def pull():
|
||||
print("Pulling remote brain to local workspace...")
|
||||
run_cmd(f"ssh root@{{VM_IP}} 'pct exec {{CONTAINER_ID}} -- bash -c \\"mkdir -p {{TARGET_DIR}} && tar czf /tmp/remote_brain.tar.gz -C /opt/aurelio/ brain\\"'")
|
||||
run_cmd(f"ssh root@{{VM_IP}} 'pct pull {{CONTAINER_ID}} /tmp/remote_brain.tar.gz /tmp/remote_brain.tar.gz'")
|
||||
run_cmd(f"scp root@{{VM_IP}}:/tmp/remote_brain.tar.gz /tmp/")
|
||||
if not LOCAL_BRAIN.exists():
|
||||
LOCAL_BRAIN.mkdir(parents=True)
|
||||
run_cmd(f"tar xzf /tmp/remote_brain.tar.gz -C {{LOCAL_BRAIN.parent}}")
|
||||
print("Pull complete.")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="{cfg.replica_name} Brain Sync")
|
||||
parser.add_argument("--push", action="store_true")
|
||||
parser.add_argument("--pull", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.push:
|
||||
push()
|
||||
elif args.pull:
|
||||
pull()
|
||||
else:
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# .env.example
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def env_example(cfg: OnboardingConfig) -> str:
|
||||
return f"""# =============================================================================
|
||||
# {cfg.replica_name} — Environment Template
|
||||
# =============================================================================
|
||||
# Copy this file to `.env` and fill in your actual API keys.
|
||||
# DO NOT commit `.env` to version control.
|
||||
# =============================================================================
|
||||
|
||||
# ─── AI API KEYS ────────────────────────────────────────────────────────────
|
||||
|
||||
# OpenRouter (unified LLM gateway)
|
||||
OPENROUTER_API_KEY=
|
||||
|
||||
# Gemini / Google Vertex AI
|
||||
GEMINI_API_KEY=
|
||||
|
||||
# Anthropic Claude
|
||||
ANTHROPIC_API_KEY=
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=
|
||||
|
||||
# ─── GIT ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
# {cfg.git_provider.upper()} token (for CI/CD and API access)
|
||||
{cfg.git_provider.upper()}_TOKEN=
|
||||
|
||||
# ─── INFRASTRUCTURE ──────────────────────────────────────────────────────────
|
||||
|
||||
# Proxmox host for brain sync (if using remote sync)
|
||||
PROXMOX_HOST={cfg.proxmox_host or ''}
|
||||
|
||||
# ─── MCP (optional) ──────────────────────────────────────────────────────────
|
||||
|
||||
# Sync endpoint
|
||||
AURELIO_SYNC_ENDPOINT={cfg.sync_endpoint or ''}
|
||||
"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# .gitignore
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def gitignore() -> str:
|
||||
return """# Dependencies
|
||||
node_modules/
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
|
||||
# Build outputs
|
||||
dist/
|
||||
!.aurelio/providers/dist/
|
||||
out/
|
||||
|
||||
# Environment & Credentials
|
||||
.env
|
||||
*.env.local
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
!.vscode/settings.json
|
||||
*.swp
|
||||
|
||||
# Aurelio runtime
|
||||
.aurelio/brain/*/scratch/
|
||||
.aurelio/swarm/__pycache__/
|
||||
|
||||
# Data
|
||||
*.db
|
||||
"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# .aurelio/mcp_config.json (empty fleet)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def mcp_config_json(cfg: OnboardingConfig) -> str:
|
||||
import json
|
||||
return json.dumps({
|
||||
"mcpServers": {},
|
||||
"_comment": f"MCP server fleet for {cfg.replica_name}. Add remote MCP servers here."
|
||||
}, indent=2)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# .aurelio/providers/registry.yaml (minimal)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def providers_registry_yaml(cfg: OnboardingConfig) -> str:
|
||||
return f"""# {cfg.replica_name} — Provider Registry
|
||||
# Single source of truth for all AI-provider surfaces.
|
||||
#
|
||||
# Regenerate mirrors after editing:
|
||||
# python3 scripts/generate-provider-mirrors.py --write-in-place
|
||||
|
||||
version: 1
|
||||
|
||||
providers:
|
||||
- id: openrouter
|
||||
label: OpenRouter
|
||||
protocol: openai-compatible
|
||||
auth: api_key
|
||||
endpoint: https://openrouter.ai/api/v1
|
||||
env_key: OPENROUTER_API_KEY
|
||||
surfaces: [hermes, mcp]
|
||||
models: []
|
||||
status: declared
|
||||
|
||||
- id: gemini
|
||||
label: Google Gemini
|
||||
protocol: gemini
|
||||
auth: api_key
|
||||
endpoint: https://generativelanguage.googleapis.com
|
||||
env_key: GEMINI_API_KEY
|
||||
surfaces: [hermes, mcp]
|
||||
models: []
|
||||
status: declared
|
||||
|
||||
- id: anthropic
|
||||
label: Anthropic Claude
|
||||
protocol: anthropic
|
||||
auth: api_key
|
||||
endpoint: https://api.anthropic.com
|
||||
env_key: ANTHROPIC_API_KEY
|
||||
surfaces: [hermes, mcp]
|
||||
models: []
|
||||
status: declared
|
||||
"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# .aurelio/connectors/registry.yaml (minimal)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def connectors_registry_yaml(cfg: OnboardingConfig) -> str:
|
||||
git_label = {
|
||||
"github": "GitHub",
|
||||
"gitlab": "GitLab",
|
||||
"forgejo": "Forgejo (self-hosted git)",
|
||||
"codeberg": "Codeberg",
|
||||
}.get(cfg.git_provider, cfg.git_provider)
|
||||
|
||||
return f"""# {cfg.replica_name} — Connector Hub
|
||||
# Single source of truth for external integrations.
|
||||
# Regenerate mirrors: python3 scripts/generate-connector-mirrors.py --write-in-place
|
||||
|
||||
version: 1
|
||||
|
||||
connectors:
|
||||
- id: {cfg.git_provider}
|
||||
label: {git_label}
|
||||
category: devops
|
||||
kind: api-key
|
||||
auth: api_key
|
||||
endpoint: {"https://github.com" if cfg.git_provider == "github" else "https://gitlab.com" if cfg.git_provider == "gitlab" else cfg.git_url.rsplit("/", 2)[0] if cfg.git_url else ""}
|
||||
surfaces: [mcp]
|
||||
status: declared
|
||||
notes: Primary git forge for {cfg.client_name}.
|
||||
"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Realm AGENTS.md
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def realm_agents_md(cfg: OnboardingConfig, realm: dict) -> str:
|
||||
return f"""# {realm.get('name', realm['slug'])} — Agent Guide
|
||||
|
||||
## Identity
|
||||
This realm corresponds to the `{realm.get('repo', realm['slug'])}` repository.
|
||||
The canonical brain realm name is `{realm['slug']}`.
|
||||
|
||||
## {realm.get('name', realm['slug'])}
|
||||
{realm.get('description', f'Realm for {realm.get("name", realm["slug"])} project.')}
|
||||
|
||||
## Technology Stack
|
||||
{realm.get('stack', '- _(to be documented)_')}
|
||||
|
||||
## Development Directives
|
||||
- Read the workspace `AGENTS.md` for global directives.
|
||||
- Consult `.aurelio/memory/index.md` for this realm's context.
|
||||
"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Realm .aurelio/config.toml
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def realm_config_toml(cfg: OnboardingConfig, realm: dict) -> str:
|
||||
return f"""[identity]
|
||||
name = "{realm.get('name', realm['slug'])}"
|
||||
realm = "{realm['slug']}"
|
||||
|
||||
[sync]
|
||||
endpoint = "{cfg.sync_endpoint or 'https://mcp.example.com'}"
|
||||
interval_seconds = 300
|
||||
auto_push = true
|
||||
"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# Realm .aurelio/memory/index.md
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
_DEFAULT_STACK_TABLE = (
|
||||
"| Component | Technology |\n"
|
||||
"|-----------|------------|\n"
|
||||
"| _(to be documented)_ | |"
|
||||
)
|
||||
|
||||
|
||||
def realm_memory_index(cfg: OnboardingConfig, realm: dict) -> str:
|
||||
name = realm.get('name', realm['slug'])
|
||||
return f"""# Realm: {name}
|
||||
|
||||
## Identity
|
||||
|
||||
**{name}** is a realm in the {cfg.replica_name}.
|
||||
|
||||
{realm.get('description', '')}
|
||||
|
||||
---
|
||||
|
||||
## Technology Stack
|
||||
|
||||
{realm.get('stack', _DEFAULT_STACK_TABLE)}
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
_(This index is the canonical entry point for agent memory. Update it as the
|
||||
project evolves.)_
|
||||
"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CI/CD
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def forgejo_workflow(cfg: OnboardingConfig) -> str:
|
||||
return f"""# {cfg.replica_name} — Aurelio Sync Workflow
|
||||
# Runs conscience upgrade + brain push on push to main.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Brain sync
|
||||
run: |
|
||||
echo "Brain sync placeholder — configure PROXMOX_HOST secret to enable."
|
||||
"""
|
||||
|
||||
|
||||
def github_workflow(cfg: OnboardingConfig) -> str:
|
||||
return f"""# {cfg.replica_name} — CI
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Check provider mirrors
|
||||
run: |
|
||||
if [ -f scripts/generate-provider-mirrors.py ]; then
|
||||
python3 scripts/generate-provider-mirrors.py --check
|
||||
fi
|
||||
"""
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# README.md
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def readme_md(cfg: OnboardingConfig) -> str:
|
||||
return f"""# {cfg.replica_name}
|
||||
|
||||
{cfg.description or f'Centralized brain for {cfg.client_name}.'}
|
||||
|
||||
Scaffolded from the [Réplica Omnisciente](https://github.com/fabiorafaelcoutada/replica-omnisciente) template.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Clone
|
||||
git clone {cfg.git_url or '<your-git-url>'}
|
||||
cd {cfg.client_slug}
|
||||
|
||||
# 2. Set up environment
|
||||
cp .env.example .env
|
||||
# Edit .env with your API keys
|
||||
|
||||
# 3. Initialize the brain
|
||||
python3 .aurelio/sync.py --pull # if using remote sync
|
||||
|
||||
# 4. Start working
|
||||
# Read AGENTS.md for the full guide.
|
||||
```
|
||||
|
||||
## Structure
|
||||
|
||||
- `.aurelio/` — Central brain (config, memory, skills, providers, MCP)
|
||||
- `realms/` — Per-project knowledge directories
|
||||
- `scripts/` — Sync and onboarding tools
|
||||
|
||||
## Adding a new realm (project)
|
||||
|
||||
```bash
|
||||
python3 scripts/onboard-client.py --add-realm my-project --name "My Project" --repo https://github.com/org/repo
|
||||
```
|
||||
"""
|
||||
788
scripts/sync-mirrors.py
Normal file
788
scripts/sync-mirrors.py
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mirror pipeline: replicate upstream sources (GitHub, Jira, Confluence) into
|
||||
self-hosted mirrors (Forgejo, Plane, Outline).
|
||||
|
||||
Single source of truth: .aurelio/mirrors/sync-mirrors.yaml
|
||||
Generated dist: .aurelio/mirrors/dist/*.json (catalog for surfaces)
|
||||
|
||||
Three modes:
|
||||
--write-in-place Regenerate dist/ mirrors from the YAML registry.
|
||||
--check CI guard: exit 1 if dist/ is stale.
|
||||
--sync [TARGET] Run live mirror sync (forgejo | plane | outline | all).
|
||||
--emit-trajectory-rewards Append RL reward signals from last sync drift.
|
||||
|
||||
Auth: tokens are read from environment variables named in the YAML (token_env).
|
||||
They are NEVER hardcoded. Fetch from Vaultwarden and export before --sync.
|
||||
|
||||
Usage:
|
||||
export FORGEJO_MIRROR_TOKEN=...
|
||||
export GITHUB_MIRROR_TOKEN=...
|
||||
python3 scripts/sync-mirrors.py --sync forgejo
|
||||
|
||||
python3 scripts/sync-mirrors.py --write-in-place
|
||||
python3 scripts/sync-mirrors.py --check
|
||||
|
||||
The --sync path is idempotent: re-running only touches what changed. Each
|
||||
target's sync returns a summary dict; failures are logged but don't abort
|
||||
siblings unless defaults.fail_fast is true.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print("PyYAML required: pip install pyyaml", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
REPLICA_ROOT = Path(__file__).resolve().parent.parent
|
||||
REGISTRY = REPLICA_ROOT / ".aurelio" / "mirrors" / "sync-mirrors.yaml"
|
||||
DIST_DIR = REPLICA_ROOT / ".aurelio" / "mirrors" / "dist"
|
||||
REWARD_PATH = REPLICA_ROOT / ".aurelio" / "brain" / "trajectory-rewards" / "mirror-sync.jsonl"
|
||||
|
||||
# ── YAML load ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def load_registry() -> dict[str, Any]:
|
||||
with REGISTRY.open("r", encoding="utf-8") as f:
|
||||
return yaml.safe_load(f)
|
||||
|
||||
|
||||
def _env(name: str | None) -> str | None:
|
||||
if not name:
|
||||
return None
|
||||
return os.environ.get(name)
|
||||
|
||||
|
||||
# ── HTTP helper ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
token: str | None = None,
|
||||
basic_auth: tuple[str, str] | None = None,
|
||||
json_body: dict | None = None,
|
||||
accept: str = "application/json",
|
||||
timeout: int = 30,
|
||||
user_agent: str = "replica-omnisciente-mirror/1.0",
|
||||
) -> tuple[int, dict | str]:
|
||||
"""Minimal urllib wrapper. Returns (status_code, parsed_json | raw_text)."""
|
||||
headers = {"Accept": accept, "User-Agent": user_agent}
|
||||
data = None
|
||||
if json_body is not None:
|
||||
data = json.dumps(json_body).encode()
|
||||
headers["Content-Type"] = "application/json"
|
||||
if token:
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
if basic_auth:
|
||||
import base64
|
||||
cred = base64.b64encode(f"{basic_auth[0]}:{basic_auth[1]}".encode()).decode()
|
||||
headers["Authorization"] = f"Basic {cred}"
|
||||
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
body = resp.read().decode("utf-8")
|
||||
try:
|
||||
return resp.status, json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return resp.status, body
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode("utf-8", errors="replace")
|
||||
try:
|
||||
return e.code, json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
return e.code, body
|
||||
except urllib.error.URLError as e:
|
||||
return 0, str(e.reason)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# FORGEJO: GitHub → Forgejo code mirror
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _forgejo_headers(token: str) -> dict:
|
||||
return {"Authorization": f"token {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def _github_list_repos(org: str | None, user: str | None, token: str, ua: str) -> list[dict]:
|
||||
"""List repos from a GitHub org or user account."""
|
||||
repos = []
|
||||
base = "https://api.github.com"
|
||||
if org:
|
||||
url = f"{base}/orgs/{org}/repos?per_page=100&type=all"
|
||||
elif user:
|
||||
url = f"{base}/users/{user}/repos?per_page=100&type=all"
|
||||
else:
|
||||
return []
|
||||
page = 1
|
||||
while True:
|
||||
paged_url = f"{url}&page={page}"
|
||||
status, data = _request("GET", paged_url, token=token, user_agent=ua)
|
||||
if status != 200 or not isinstance(data, list):
|
||||
break
|
||||
repos.extend(data)
|
||||
if len(data) < 100:
|
||||
break
|
||||
page += 1
|
||||
return repos
|
||||
|
||||
|
||||
def sync_forgejo(reg: dict, target_cfg: dict, dry: bool = False) -> dict:
|
||||
"""Mirror GitHub repos into Forgejo using the migration API."""
|
||||
forgejo_url = target_cfg["endpoint"]
|
||||
forgejo_token = _env(target_cfg.get("token_env", ""))
|
||||
default_owner = target_cfg.get("default_owner", "mirror")
|
||||
ua = reg.get("defaults", {}).get("user_agent", "replica-mirror/1.0")
|
||||
summary: dict[str, Any] = {"target": "forgejo", "mirrored": [], "skipped": [], "errors": []}
|
||||
|
||||
if not forgejo_token and not dry:
|
||||
summary["errors"].append(f"missing env {target_cfg.get('token_env')} — export it first")
|
||||
return summary
|
||||
|
||||
for src in target_cfg.get("sources", []):
|
||||
gh_token = _env(src.get("token_env", "")) or ""
|
||||
include_forks = src.get("include_forks", False)
|
||||
|
||||
if dry and not gh_token:
|
||||
# In dry-run without a token, report intent without hitting the API.
|
||||
summary["mirrored"].append({
|
||||
"source": src.get("id"),
|
||||
"dry": True,
|
||||
"note": f"would list {src.get('type')} and create mirrors",
|
||||
})
|
||||
continue
|
||||
|
||||
if src["type"] == "github-org":
|
||||
repos = _github_list_repos(org=src["org"], user=None, token=gh_token, ua=ua)
|
||||
elif src["type"] == "github-user":
|
||||
repos = _github_list_repos(org=None, user=src["user"], token=gh_token, ua=ua)
|
||||
# Apply include/exclude globs.
|
||||
import fnmatch
|
||||
inc = src.get("include_patterns", ["*"])
|
||||
exc = src.get("exclude_patterns", [])
|
||||
repos = [
|
||||
r for r in repos
|
||||
if any(fnmatch.fnmatch(r["name"], p) for p in inc)
|
||||
and not any(fnmatch.fnmatch(r["name"], p) for p in exc)
|
||||
]
|
||||
else:
|
||||
summary["errors"].append(f"unknown source type {src['type']} for forgejo")
|
||||
continue
|
||||
|
||||
for repo in repos:
|
||||
if repo.get("fork") and not include_forks:
|
||||
summary["skipped"].append({"repo": repo["full_name"], "reason": "fork"})
|
||||
continue
|
||||
if repo.get("archived"):
|
||||
summary["skipped"].append({"repo": repo["full_name"], "reason": "archived"})
|
||||
continue
|
||||
|
||||
owner = default_owner
|
||||
repo_name = repo["name"]
|
||||
clone_url = repo["clone_url"] # HTTPS
|
||||
if src.get("clone_transport") == "ssh":
|
||||
clone_url = repo.get("ssh_url", clone_url)
|
||||
|
||||
# Check if mirror already exists in Forgejo.
|
||||
check_url = f"{forgejo_url}/api/v1/repos/{owner}/{repo_name}"
|
||||
if not dry and forgejo_token:
|
||||
st, _ = _request("GET", check_url, token=forgejo_token, user_agent=ua)
|
||||
if st == 200:
|
||||
summary["skipped"].append({"repo": f"{owner}/{repo_name}", "reason": "exists"})
|
||||
continue
|
||||
|
||||
# Create migration (mirror=true makes Forgejo poll upstream).
|
||||
migrate_url = f"{forgejo_url}/api/v1/repos/migrate"
|
||||
body = {
|
||||
"clone_addr": clone_url,
|
||||
"repo_owner": owner,
|
||||
"repo_name": repo_name,
|
||||
"service": "github",
|
||||
"auth_token": gh_token or None,
|
||||
"mirror": True,
|
||||
"private": repo.get("private", False),
|
||||
"description": repo.get("description", "")[:255],
|
||||
"wiki": False,
|
||||
"issues": False,
|
||||
"labels": src.get("topic_labels", False),
|
||||
"pull_requests": False,
|
||||
"releases": True,
|
||||
}
|
||||
if dry:
|
||||
summary["mirrored"].append({"repo": f"{owner}/{repo_name}", "dry": True})
|
||||
continue
|
||||
|
||||
st, resp = _request("POST", migrate_url, token=forgejo_token, json_body=body, user_agent=ua, timeout=120)
|
||||
if st in (200, 201):
|
||||
summary["mirrored"].append({"repo": f"{owner}/{repo_name}", "id": resp.get("id") if isinstance(resp, dict) else None})
|
||||
else:
|
||||
summary["errors"].append({"repo": repo["full_name"], "status": st, "detail": str(resp)[:300]})
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# PLANE: Jira → Plane issue mirror
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _jira_get(url: str, email: str, token: str, ua: str, params: str = "") -> dict:
|
||||
full = f"{url}{params}"
|
||||
st, data = _request("GET", full, basic_auth=(email, token), user_agent=ua)
|
||||
return {"status": st, "data": data}
|
||||
|
||||
|
||||
def _plane_headers(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def sync_plane(reg: dict, target_cfg: dict, dry: bool = False) -> dict:
|
||||
"""Mirror Jira issues into Plane."""
|
||||
plane_url = target_cfg["endpoint"].rstrip("/")
|
||||
plane_token = _env(target_cfg.get("token_env", ""))
|
||||
ws = target_cfg.get("workspace_slug", "savearth")
|
||||
ua = reg.get("defaults", {}).get("user_agent", "replica-mirror/1.0")
|
||||
summary: dict[str, Any] = {"target": "plane", "projects": [], "issues": {"synced": 0, "skipped": 0}, "errors": []}
|
||||
|
||||
if not plane_token and not dry:
|
||||
summary["errors"].append(f"missing env {target_cfg.get('token_env')}")
|
||||
return summary
|
||||
|
||||
for src in target_cfg.get("sources", []):
|
||||
if src["type"] != "jira-cloud":
|
||||
summary["errors"].append(f"unknown source type {src['type']} for plane")
|
||||
continue
|
||||
|
||||
jira_url = src.get("endpoint")
|
||||
if not jira_url:
|
||||
summary["errors"].append({"source": src["id"], "reason": "Jira endpoint is null — set it in sync-mirrors.yaml"})
|
||||
continue
|
||||
|
||||
jira_token = _env(src.get("token_env", "")) or ""
|
||||
jira_email = _env(src.get("email_env", "")) or ""
|
||||
if not dry and (not jira_token or not jira_email):
|
||||
summary["errors"].append(f"missing Jira creds ({src.get('token_env')} / {src.get('email_env')})")
|
||||
continue
|
||||
|
||||
# 1. List Jira projects.
|
||||
projects_resp = _jira_get(jira_url, jira_email, jira_token, ua, "/rest/api/3/project")
|
||||
if projects_resp["status"] != 200:
|
||||
summary["errors"].append({"step": "jira projects", "status": projects_resp["status"], "detail": str(projects_resp["data"])[:300]})
|
||||
continue
|
||||
jira_projects = projects_resp["data"] or []
|
||||
if src.get("projects"):
|
||||
wanted = set(src["projects"])
|
||||
jira_projects = [p for p in jira_projects if p.get("key") in wanted]
|
||||
|
||||
for jp in jira_projects:
|
||||
pkey = jp["key"]
|
||||
pname = jp.get("name", pkey)
|
||||
|
||||
# 2. Ensure Plane project exists.
|
||||
if dry:
|
||||
summary["projects"].append({"key": pkey, "name": pname, "dry": True})
|
||||
continue
|
||||
|
||||
# List existing Plane projects to find by name.
|
||||
st, existing = _request("GET", f"{plane_url}/api/v1/workspaces/{ws}/projects/", token=plane_token, user_agent=ua)
|
||||
project_id = None
|
||||
if st == 200 and isinstance(existing, list):
|
||||
for ep in existing:
|
||||
if ep.get("name", "").lower() == pname.lower() or ep.get("identifier", "").upper() == pkey:
|
||||
project_id = ep.get("id")
|
||||
break
|
||||
if not project_id:
|
||||
body = {"name": pname, "identifier": pkey[:8]}
|
||||
st, resp = _request("POST", f"{plane_url}/api/v1/workspaces/{ws}/projects/", token=plane_token, json_body=body, user_agent=ua)
|
||||
if st in (200, 201) and isinstance(resp, dict):
|
||||
project_id = resp.get("id")
|
||||
summary["projects"].append({"key": pkey, "id": project_id, "created": True})
|
||||
else:
|
||||
summary["errors"].append({"step": f"create plane project {pkey}", "status": st, "detail": str(resp)[:300]})
|
||||
continue
|
||||
else:
|
||||
summary["projects"].append({"key": pkey, "id": project_id, "existed": True})
|
||||
|
||||
if not project_id:
|
||||
continue
|
||||
|
||||
# 3. Fetch Jira issues for this project.
|
||||
jql = f"project = {pkey} ORDER BY updated DESC"
|
||||
issues_resp = _jira_get(jira_url, jira_email, jira_token, ua, f"/rest/api/3/search?jql={urllib.parse.quote(jql)}&maxResults=100")
|
||||
if issues_resp["status"] != 200:
|
||||
summary["errors"].append({"step": f"jira issues {pkey}", "status": issues_resp["status"]})
|
||||
continue
|
||||
jira_issues = (issues_resp["data"] or {}).get("issues", [])
|
||||
|
||||
# 4. List existing Plane issues to avoid duplicates (by external_id or name match).
|
||||
st, plane_issues = _request("GET", f"{plane_url}/api/v1/workspaces/{ws}/projects/{project_id}/issues/", token=plane_token, user_agent=ua)
|
||||
existing_names = set()
|
||||
if st == 200 and isinstance(plane_issues, list):
|
||||
for ei in plane_issues:
|
||||
existing_names.add(ei.get("name", ""))
|
||||
|
||||
# 5. Fetch Plane states + priorities for mapping.
|
||||
st, states = _request("GET", f"{plane_url}/api/v1/workspaces/{ws}/projects/{project_id}/states/", token=plane_token, user_agent=ua)
|
||||
st, priorities = _request("GET", f"{plane_url}/api/v1/workspaces/{ws}/projects/{project_id}/priorities/", token=plane_token, user_agent=ua)
|
||||
|
||||
for ji in jira_issues:
|
||||
fields = ji.get("fields", {})
|
||||
name = fields.get("summary", f"{pkey}-{ji.get('key','')}")
|
||||
if name in existing_names:
|
||||
summary["issues"]["skipped"] += 1
|
||||
continue
|
||||
# Convert Jira description (ADF) to simple HTML/text for Plane.
|
||||
desc = _adf_to_html(fields.get("description"))
|
||||
body = {
|
||||
"name": name,
|
||||
"description_html": desc,
|
||||
"priority": _map_priority(fields.get("priority", {}).get("name", "")),
|
||||
}
|
||||
if dry:
|
||||
summary["issues"]["synced"] += 1
|
||||
continue
|
||||
st, resp = _request("POST", f"{plane_url}/api/v1/workspaces/{ws}/projects/{project_id}/issues/", token=plane_token, json_body=body, user_agent=ua)
|
||||
if st in (200, 201):
|
||||
summary["issues"]["synced"] += 1
|
||||
else:
|
||||
summary["errors"].append({"issue": ji.get("key"), "status": st, "detail": str(resp)[:200]})
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _adf_to_html(adf: Any) -> str:
|
||||
"""Convert Atlassian Document Format (ADF) to minimal HTML for Plane."""
|
||||
if adf is None:
|
||||
return ""
|
||||
if isinstance(adf, str):
|
||||
return f"<p>{adf}</p>"
|
||||
if not isinstance(adf, dict):
|
||||
return ""
|
||||
parts = []
|
||||
for block in adf.get("content", []):
|
||||
btype = block.get("type", "")
|
||||
texts = []
|
||||
for node in block.get("content", []):
|
||||
if node.get("type") == "text":
|
||||
texts.append(node.get("text", ""))
|
||||
text = "".join(texts)
|
||||
if btype == "heading":
|
||||
level = block.get("attrs", {}).get("level", 2)
|
||||
parts.append(f"<h{level}>{text}</h{level}>")
|
||||
elif btype == "paragraph":
|
||||
parts.append(f"<p>{text}</p>")
|
||||
elif btype == "codeBlock":
|
||||
parts.append(f"<pre><code>{text}</code></pre>")
|
||||
elif btype == "bulletList":
|
||||
parts.append(f"<ul><li>{text}</li></ul>")
|
||||
else:
|
||||
parts.append(f"<p>{text}</p>")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _map_priority(jira_prio: str) -> str:
|
||||
"""Map Jira priority names to Plane urgency values."""
|
||||
m = {
|
||||
"Highest": "urgent",
|
||||
"High": "high",
|
||||
"Medium": "medium",
|
||||
"Low": "low",
|
||||
"Lowest": "low",
|
||||
}
|
||||
return m.get(jira_prio, "none")
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# OUTLINE: Confluence → Outline doc mirror
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def sync_outline(reg: dict, target_cfg: dict, dry: bool = False) -> dict:
|
||||
"""Mirror Confluence pages into Outline documents."""
|
||||
outline_url = target_cfg["endpoint"].rstrip("/")
|
||||
outline_token = _env(target_cfg.get("token_env", ""))
|
||||
collection_name = target_cfg.get("collection_name", "Mirror")
|
||||
ua = reg.get("defaults", {}).get("user_agent", "replica-mirror/1.0")
|
||||
summary: dict[str, Any] = {"target": "outline", "pages": {"synced": 0, "skipped": 0}, "collection": None, "errors": []}
|
||||
|
||||
if not outline_token and not dry:
|
||||
summary["errors"].append(f"missing env {target_cfg.get('token_env')}")
|
||||
return summary
|
||||
|
||||
for src in target_cfg.get("sources", []):
|
||||
if src["type"] != "confluence-cloud":
|
||||
summary["errors"].append(f"unknown source type {src['type']} for outline")
|
||||
continue
|
||||
|
||||
cf_url = src.get("endpoint")
|
||||
if not cf_url:
|
||||
summary["errors"].append({"source": src["id"], "reason": "Confluence endpoint is null — set it in sync-mirrors.yaml"})
|
||||
continue
|
||||
|
||||
cf_token = _env(src.get("token_env", "")) or ""
|
||||
cf_email = _env(src.get("email_env", "")) or ""
|
||||
if not dry and (not cf_token or not cf_email):
|
||||
summary["errors"].append(f"missing Confluence creds ({src.get('token_env')} / {src.get('email_env')})")
|
||||
continue
|
||||
|
||||
# 1. Ensure Outline collection exists.
|
||||
collection_id = None
|
||||
if not dry:
|
||||
st, cols = _request("GET", f"{outline_url}/api/collections.list", token=outline_token, user_agent=ua)
|
||||
if st == 200 and isinstance(cols, dict):
|
||||
for c in cols.get("data", []):
|
||||
if c.get("name", "").lower() == collection_name.lower():
|
||||
collection_id = c.get("id")
|
||||
break
|
||||
if not collection_id:
|
||||
body = {"name": collection_name}
|
||||
st, resp = _request("POST", f"{outline_url}/api/collections.create", token=outline_token, json_body=body, user_agent=ua)
|
||||
if st in (200, 201) and isinstance(resp, dict):
|
||||
collection_id = (resp.get("data") or {}).get("id")
|
||||
summary["collection"] = {"id": collection_id, "created": True}
|
||||
else:
|
||||
summary["errors"].append({"step": "create collection", "status": st, "detail": str(resp)[:300]})
|
||||
continue
|
||||
else:
|
||||
summary["collection"] = {"id": collection_id, "existed": True}
|
||||
else:
|
||||
summary["collection"] = {"name": collection_name, "dry": True}
|
||||
|
||||
# 2. List Confluence spaces.
|
||||
spaces_resp = _jira_get(cf_url, cf_email, cf_token, ua, "/wiki/api/v2/spaces?limit=100")
|
||||
if spaces_resp["status"] != 200:
|
||||
summary["errors"].append({"step": "confluence spaces", "status": spaces_resp["status"], "detail": str(spaces_resp["data"])[:300]})
|
||||
continue
|
||||
spaces = (spaces_resp["data"] or {}).get("results", [])
|
||||
if src.get("spaces"):
|
||||
wanted = set(src["spaces"])
|
||||
spaces = [s for s in spaces if s.get("key") in wanted]
|
||||
|
||||
for space in spaces:
|
||||
space_key = space["key"]
|
||||
# 3. List pages in space.
|
||||
pages_resp = _jira_get(cf_url, cf_email, cf_token, ua, f"/wiki/api/v2/spaces/{space_key}/pages?limit=100&body-format=storage")
|
||||
if pages_resp["status"] != 200:
|
||||
summary["errors"].append({"step": f"confluence pages {space_key}", "status": pages_resp["status"]})
|
||||
continue
|
||||
pages = (pages_resp["data"] or {}).get("results", [])
|
||||
|
||||
# 4. List existing Outline docs to dedupe by title.
|
||||
existing_titles = set()
|
||||
if not dry and collection_id:
|
||||
st, docs = _request("GET", f"{outline_url}/api/documents.list", token=outline_token, user_agent=ua)
|
||||
if st == 200 and isinstance(docs, dict):
|
||||
for d in docs.get("data", []):
|
||||
if (d.get("collectionId") or "") == collection_id:
|
||||
existing_titles.add(d.get("title", "").lower())
|
||||
|
||||
for page in pages:
|
||||
title = page.get("title", f"Untitled-{page.get('id')}")
|
||||
if title.lower() in existing_titles:
|
||||
summary["pages"]["skipped"] += 1
|
||||
continue
|
||||
# Convert Confluence storage format → Markdown.
|
||||
raw_body = (page.get("body") or {})
|
||||
storage_xml = raw_body.get("storage", {}).get("value", "") if isinstance(raw_body, dict) else str(raw_body)
|
||||
md = _confluence_storage_to_md(storage_xml)
|
||||
|
||||
if dry:
|
||||
summary["pages"]["synced"] += 1
|
||||
continue
|
||||
|
||||
body = {
|
||||
"title": f"[{space_key}] {title}",
|
||||
"text": md,
|
||||
"collectionId": collection_id,
|
||||
"publish": True,
|
||||
}
|
||||
st, resp = _request("POST", f"{outline_url}/api/documents.create", token=outline_token, json_body=body, user_agent=ua)
|
||||
if st in (200, 201):
|
||||
summary["pages"]["synced"] += 1
|
||||
else:
|
||||
summary["errors"].append({"page": page.get("id"), "status": st, "detail": str(resp)[:200]})
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def _confluence_storage_to_md(xml: str) -> str:
|
||||
"""Minimal Confluence storage-format (XHTML) → Markdown conversion.
|
||||
|
||||
Handles the common constructs: headings, paragraphs, lists, code blocks,
|
||||
links, bold/italic. Full Confluence XHTML is complex; this covers the 90%
|
||||
case and leaves unknown tags as stripped text.
|
||||
"""
|
||||
import re
|
||||
if not xml:
|
||||
return ""
|
||||
# Remove XML namespaces for simpler matching.
|
||||
text = re.sub(r'xmlns[^"]*"[^"]*"', "", xml)
|
||||
text = re.sub(r"<ac:structured-macro[^>]*>.*?</ac:structured-macro>", "[macro]", text, flags=re.DOTALL)
|
||||
# Headings.
|
||||
for i in range(6, 0, -1):
|
||||
text = re.sub(rf"<h{i}[^>]*>(.*?)</h{i}>", lambda m, lvl=i: "#" * lvl + " " + m.group(1).strip(), text, flags=re.DOTALL)
|
||||
# Code blocks: <pre><code>...</code></pre> → fenced, before inline <code>.
|
||||
text = re.sub(r"<pre[^>]*>\s*<code[^>]*>(.*?)</code>\s*</pre>", lambda m: f"```\n{m.group(1).strip()}\n```", text, flags=re.DOTALL)
|
||||
text = re.sub(r"<ac:plain-text-body[^>]*><!\[CDATA\[(.*?)\]\]></ac:plain-text-body>", lambda m: f"```\n{m.group(1)}\n```", text, flags=re.DOTALL)
|
||||
text = re.sub(r"<code[^>]*>(.*?)</code>", lambda m: f"`{m.group(1).strip()}`", text, flags=re.DOTALL)
|
||||
# Bold / italic.
|
||||
text = re.sub(r"<b>(.*?)</b>", r"**\1**", text, flags=re.DOTALL)
|
||||
text = re.sub(r"<strong>(.*?)</strong>", r"**\1**", text, flags=re.DOTALL)
|
||||
text = re.sub(r"<i>(.*?)</i>", r"*\1*", text, flags=re.DOTALL)
|
||||
text = re.sub(r"<em>(.*?)</em>", r"*\1*", text, flags=re.DOTALL)
|
||||
# Links.
|
||||
text = re.sub(r'<a[^>]*href="([^"]*)"[^>]*>(.*?)</a>', lambda m: f"[{m.group(2).strip()}]({m.group(1)})", text, flags=re.DOTALL)
|
||||
# Lists.
|
||||
text = re.sub(r"<li[^>]*>(.*?)</li>", lambda m: f"- {m.group(1).strip()}\n", text, flags=re.DOTALL)
|
||||
text = re.sub(r"</?[ou]l[^>]*>", "", text)
|
||||
# Paragraphs / line breaks.
|
||||
text = re.sub(r"<p[^>]*>", "\n", text)
|
||||
text = re.sub(r"</p>", "\n", text)
|
||||
text = re.sub(r"<br\s*/?>", "\n", text)
|
||||
# Strip remaining tags.
|
||||
text = re.sub(r"<[^>]+>", "", text)
|
||||
# Collapse excessive whitespace.
|
||||
text = re.sub(r"\n{3,}", "\n\n", text).strip()
|
||||
return text
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# RL feedback: emit trajectory reward signals from sync drift
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def emit_trajectory_rewards(reg: dict, sync_summaries: list[dict] | None = None) -> dict:
|
||||
"""Append RL reward signals to the trajectory-rewards JSONL.
|
||||
|
||||
Called after each --sync run. Reads the sync summaries (what changed,
|
||||
what drifted) and emits reward signals the RL pipeline consumes.
|
||||
"""
|
||||
rl_cfg = reg.get("rl_feedback", {})
|
||||
if not rl_cfg.get("enabled", False):
|
||||
return {"skipped": "rl_feedback disabled"}
|
||||
output = REPLICA_ROOT / rl_cfg.get("output", str(REWARD_PATH))
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
signals = rl_cfg.get("signals", {})
|
||||
|
||||
events: list[dict] = []
|
||||
ts = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
if sync_summaries:
|
||||
for s in sync_summaries:
|
||||
target = s.get("target", "unknown")
|
||||
# Positive: sync succeeded with no errors.
|
||||
if not s.get("errors"):
|
||||
events.append({
|
||||
"type": "trajectory_reward",
|
||||
"timestamp": ts,
|
||||
"target": target,
|
||||
"signal": "sync_hit",
|
||||
"reward": signals.get("sync_hit", 0.1),
|
||||
"detail": f"{target} sync clean",
|
||||
})
|
||||
else:
|
||||
# Negative: errors mean drift / staleness.
|
||||
events.append({
|
||||
"type": "trajectory_reward",
|
||||
"timestamp": ts,
|
||||
"target": target,
|
||||
"signal": "sync_miss",
|
||||
"reward": signals.get("sync_miss", -0.2),
|
||||
"detail": f"{target} sync had {len(s['errors'])} errors",
|
||||
})
|
||||
# If items were skipped because they already existed, that's neutral.
|
||||
|
||||
with output.open("a", encoding="utf-8") as f:
|
||||
for ev in events:
|
||||
f.write(json.dumps(ev, ensure_ascii=False) + "\n")
|
||||
|
||||
return {"emitted": len(events), "output": str(output)}
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# Dist generation (--write-in-place / --check)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def generate_dist(reg: dict) -> dict[str, str]:
|
||||
"""Produce the downstream catalog files from the YAML registry."""
|
||||
targets_out = {}
|
||||
for tname, tcfg in reg.get("targets", {}).items():
|
||||
entry = {
|
||||
"id": tname,
|
||||
"kind": tcfg.get("kind"),
|
||||
"label": tcfg.get("label", tname),
|
||||
"endpoint": tcfg.get("endpoint"),
|
||||
"token_env": tcfg.get("token_env"),
|
||||
"sources": [],
|
||||
}
|
||||
for src in tcfg.get("sources", []):
|
||||
entry["sources"].append({
|
||||
"id": src.get("id"),
|
||||
"type": src.get("type"),
|
||||
"label": src.get("label"),
|
||||
"status": src.get("status", "declared"),
|
||||
"endpoint": src.get("endpoint"),
|
||||
})
|
||||
targets_out[tname] = entry
|
||||
|
||||
catalog = {
|
||||
"version": reg.get("version", 1),
|
||||
"defaults": reg.get("defaults", {}),
|
||||
"targets": targets_out,
|
||||
"rl_feedback": reg.get("rl_feedback", {}),
|
||||
}
|
||||
return {
|
||||
"mirrors.catalog.json": json.dumps(catalog, indent=2) + "\n",
|
||||
}
|
||||
|
||||
|
||||
def write_in_place() -> None:
|
||||
reg = load_registry()
|
||||
DIST_DIR.mkdir(parents=True, exist_ok=True)
|
||||
for name, content in generate_dist(reg).items():
|
||||
(DIST_DIR / name).write_text(content, encoding="utf-8")
|
||||
print(f"wrote {DIST_DIR / name}")
|
||||
|
||||
|
||||
def check() -> int:
|
||||
reg = load_registry()
|
||||
stale = []
|
||||
for name, want in generate_dist(reg).items():
|
||||
p = DIST_DIR / name
|
||||
if not p.exists() or p.read_text(encoding="utf-8") != want:
|
||||
stale.append(name)
|
||||
if stale:
|
||||
print(f"stale mirror catalog (run --write-in-place): {', '.join(stale)}", file=sys.stderr)
|
||||
return 1
|
||||
print("mirror catalog up to date")
|
||||
return 0
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# CLI
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
SYNC_DISPATCH = {
|
||||
"forgejo": sync_forgejo,
|
||||
"plane": sync_plane,
|
||||
"outline": sync_outline,
|
||||
}
|
||||
|
||||
|
||||
def run_sync(target: str | None, dry: bool, emit_rewards: bool) -> int:
|
||||
reg = load_registry()
|
||||
targets = reg.get("targets", {})
|
||||
names = [target] if target else list(targets.keys())
|
||||
fail_fast = reg.get("defaults", {}).get("fail_fast", False)
|
||||
summaries: list[dict] = []
|
||||
rc = 0
|
||||
for name in names:
|
||||
if name not in targets:
|
||||
print(f"unknown target: {name} (known: {', '.join(targets)})", file=sys.stderr)
|
||||
rc = 1
|
||||
continue
|
||||
fn = SYNC_DISPATCH.get(targets[name].get("kind"))
|
||||
if not fn:
|
||||
# Map kind → fn for aliases.
|
||||
kind = targets[name].get("kind", "")
|
||||
if "git" in kind:
|
||||
fn = sync_forgejo
|
||||
elif "issue" in kind:
|
||||
fn = sync_plane
|
||||
elif "doc" in kind:
|
||||
fn = sync_outline
|
||||
if not fn:
|
||||
print(f"no sync handler for target {name} (kind={targets[name].get('kind')})", file=sys.stderr)
|
||||
rc = 1
|
||||
continue
|
||||
print(f"[{name}] syncing (dry={dry})...")
|
||||
try:
|
||||
s = fn(reg, targets[name], dry=dry)
|
||||
summaries.append(s)
|
||||
_print_summary(s)
|
||||
except Exception as exc:
|
||||
summaries.append({"target": name, "errors": [str(exc)]})
|
||||
print(f"[{name}] FAILED: {exc}", file=sys.stderr)
|
||||
if fail_fast:
|
||||
return 1
|
||||
rc = 1
|
||||
|
||||
if emit_rewards:
|
||||
r = emit_trajectory_rewards(reg, summaries)
|
||||
print(f"[rl] reward signals: {r}")
|
||||
|
||||
# Persist last-sync summary for drift detection on next run.
|
||||
state_dir = REPLICA_ROOT / ".aurelio" / "mirrors" / "state"
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
(state_dir / "last-sync.json").write_text(
|
||||
json.dumps({"timestamp": datetime.now(timezone.utc).isoformat(), "summaries": summaries}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return rc
|
||||
|
||||
|
||||
def _print_summary(s: dict) -> None:
|
||||
target = s.get("target", "?")
|
||||
errs = s.get("errors", [])
|
||||
if target == "forgejo":
|
||||
print(f" mirrored: {len(s.get('mirrored', []))} skipped: {len(s.get('skipped', []))} errors: {len(errs)}")
|
||||
elif target == "plane":
|
||||
print(f" projects: {len(s.get('projects', []))} issues synced: {s.get('issues', {}).get('synced', 0)} skipped: {s.get('issues', {}).get('skipped', 0)} errors: {len(errs)}")
|
||||
elif target == "outline":
|
||||
print(f" pages synced: {s.get('pages', {}).get('synced', 0)} skipped: {s.get('pages', {}).get('skipped', 0)} errors: {len(errs)}")
|
||||
for e in errs[:5]:
|
||||
print(f" ! {e}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
import urllib.parse # noqa: F401 — used in query quoting above
|
||||
|
||||
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
mode = ap.add_mutually_exclusive_group()
|
||||
mode.add_argument("--write-in-place", action="store_true", help="Regenerate dist/ from YAML registry.")
|
||||
mode.add_argument("--check", action="store_true", help="CI guard: exit 1 if dist/ is stale.")
|
||||
mode.add_argument("--sync", nargs="?", const="all", default=None, help="Run live mirror sync (target name or 'all').")
|
||||
mode.add_argument("--emit-trajectory-rewards", action="store_true", help="Emit RL reward signals from last sync.")
|
||||
ap.add_argument("--dry-run", action="store_true", help="Preview what would sync without making API calls.")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.check:
|
||||
return check()
|
||||
if args.write_in_place:
|
||||
write_in_place()
|
||||
return 0
|
||||
if args.emit_trajectory_rewards:
|
||||
reg = load_registry()
|
||||
state = REPLICA_ROOT / ".aurelio" / "mirrors" / "state" / "last-sync.json"
|
||||
sums = []
|
||||
if state.exists():
|
||||
sums = json.loads(state.read_text()).get("summaries", [])
|
||||
r = emit_trajectory_rewards(reg, sums)
|
||||
print(json.dumps(r, indent=2))
|
||||
return 0
|
||||
if args.sync is not None:
|
||||
target = None if args.sync == "all" else args.sync
|
||||
return run_sync(target, dry=args.dry_run, emit_rewards=True)
|
||||
|
||||
ap.print_help()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
65
scripts/test-all.sh
Executable file
65
scripts/test-all.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
# Unified test runner for replica-omnisciente monorepo
|
||||
# Usage: ./scripts/test-all.sh [--vscode] [--backend] [--frontend] [--quick]
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
SKIP=0
|
||||
|
||||
run_suite() {
|
||||
local name="$1"
|
||||
local dir="$2"
|
||||
local cmd="$3"
|
||||
echo ""
|
||||
echo "══════════════════════════════════════"
|
||||
echo " $name"
|
||||
echo "══════════════════════════════════════"
|
||||
if (cd "$dir" && eval "$cmd"); then
|
||||
echo " ✓ $name PASSED"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " ✗ $name FAILED"
|
||||
FAIL=$((FAIL + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
# --- aurelio-vscode unit tests (mocha) ---
|
||||
if [[ "${1:-}" != "--backend" && "${1:-}" != "--frontend" ]]; then
|
||||
if [ -d "$ROOT/extensions/aurelio-vscode/node_modules/mocha" ]; then
|
||||
run_suite "aurelio-vscode (unit)" "$ROOT/extensions/aurelio-vscode" \
|
||||
"TS_NODE_PROJECT=./tsconfig.unit-test.json npx mocha"
|
||||
else
|
||||
echo "SKIP aurelio-vscode — run npm install first"
|
||||
SKIP=$((SKIP + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- aurelio-backend tests (node --test) ---
|
||||
if [[ "${1:-}" != "--vscode" && "${1:-}" != "--frontend" ]]; then
|
||||
if [ -d "$ROOT/aurelio-theia/aurelio-backend/node_modules" ]; then
|
||||
run_suite "aurelio-backend" "$ROOT/aurelio-theia/aurelio-backend" \
|
||||
"npx tsc -p tsconfig.json && npm test"
|
||||
else
|
||||
echo "SKIP aurelio-backend — run npm install --ignore-scripts first"
|
||||
SKIP=$((SKIP + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- dirac tests (mocha) ---
|
||||
if [[ "${1:-}" == "--quick" || "${1:-}" == "" ]]; then
|
||||
if [ -d "$ROOT/dirac/node_modules/mocha" ]; then
|
||||
run_suite "dirac (unit)" "$ROOT/dirac" \
|
||||
"cross-env TS_NODE_PROJECT=./tsconfig.unit-test.json npx mocha"
|
||||
else
|
||||
echo "SKIP dirac — run npm install first"
|
||||
SKIP=$((SKIP + 1))
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════"
|
||||
echo " RESULTS: $PASS passed, $FAIL failed, $SKIP skipped"
|
||||
echo "══════════════════════════════════════"
|
||||
exit $FAIL
|
||||
135
shared/src/data_sharing/consent.ts
Normal file
135
shared/src/data_sharing/consent.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
/**
|
||||
* Consent model — granular opt-in for what data a client authorizes sending
|
||||
* to Portugal Futurista.
|
||||
*
|
||||
* TypeScript port of scripts/data_sharing/consent.py.
|
||||
* Shared between aurelio-vscode and aurelio-theia.
|
||||
*
|
||||
* All categories default to false (opt-in required).
|
||||
*/
|
||||
|
||||
/** Canonical data categories — matches Python CATEGORIES exactly. */
|
||||
export const CATEGORIES = [
|
||||
"tool_calls",
|
||||
"thinking",
|
||||
"chat_messages",
|
||||
"session_meta",
|
||||
"agent_metadata",
|
||||
"error_traces",
|
||||
"file_changes",
|
||||
"environment",
|
||||
] as const;
|
||||
|
||||
export type Category = (typeof CATEGORIES)[number];
|
||||
|
||||
export interface ConsentCategories {
|
||||
tool_calls: boolean;
|
||||
thinking: boolean;
|
||||
chat_messages: boolean;
|
||||
session_meta: boolean;
|
||||
agent_metadata: boolean;
|
||||
error_traces: boolean;
|
||||
file_changes: boolean;
|
||||
environment: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_RETENTION_DAYS = 90;
|
||||
|
||||
export interface DataSharingConfig {
|
||||
enabled: boolean;
|
||||
transport: "http" | "ssh" | "local" | "s3" | "proxmox";
|
||||
endpoint: string;
|
||||
categories: Partial<ConsentCategories>;
|
||||
retention: {
|
||||
days: number;
|
||||
redact_secrets: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolved consent state for a single client replica. */
|
||||
export class ConsentRecord {
|
||||
enabled: boolean;
|
||||
transport: string;
|
||||
endpoint: string;
|
||||
categories: Map<Category, boolean>;
|
||||
retentionDays: number;
|
||||
redactSecrets: boolean;
|
||||
|
||||
constructor(config?: Partial<DataSharingConfig>) {
|
||||
this.enabled = config?.enabled ?? false;
|
||||
this.transport = config?.transport ?? "http";
|
||||
this.endpoint = config?.endpoint ?? "";
|
||||
this.retentionDays = config?.retention?.days ?? DEFAULT_RETENTION_DAYS;
|
||||
this.redactSecrets = config?.retention?.redact_secrets ?? true;
|
||||
|
||||
this.categories = new Map();
|
||||
for (const cat of CATEGORIES) {
|
||||
this.categories.set(cat, (config?.categories?.[cat] as boolean) ?? false);
|
||||
}
|
||||
}
|
||||
|
||||
allows(category: Category): boolean {
|
||||
if (!this.enabled) return false;
|
||||
return this.categories.get(category) ?? false;
|
||||
}
|
||||
|
||||
grantedCategories(): Category[] {
|
||||
return CATEGORIES.filter((c) => this.allows(c));
|
||||
}
|
||||
|
||||
toDisplay(): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(` Master switch: ${this.enabled ? "ON" : "OFF"}`);
|
||||
lines.push(` Transport: ${this.transport}`);
|
||||
lines.push(` Endpoint: ${this.endpoint || "(not set)"}`);
|
||||
lines.push(` Categories:`);
|
||||
for (const cat of CATEGORIES) {
|
||||
const state = this.allows(cat) ? "ON" : "off";
|
||||
lines.push(` ${cat.padEnd(18)} ${state}`);
|
||||
}
|
||||
lines.push(` Retention: ${this.retentionDays} days`);
|
||||
lines.push(` Redact secrets: ${this.redactSecrets}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Secret redaction ──────────────────────────────────────────────
|
||||
|
||||
const SECRET_PATTERNS: Array<[RegExp, string]> = [
|
||||
[/sk-[a-zA-Z0-9]{20,}/g, "sk-[REDACTED]"],
|
||||
[/gh[pousr]_[A-Za-z0-9]{36}/g, "ghp_[REDACTED]"],
|
||||
[/github_pat_[A-Za-z0-9_]{82}/g, "github_pat_[REDACTED]"],
|
||||
[/AIza[a-zA-Z0-9_\-]{35}/g, "AIza[REDACTED]"],
|
||||
// Generic env-like assignments — must run after specific patterns.
|
||||
// Excludes values already containing [REDACTED].
|
||||
[/(token|key|secret|password|passwd|api_key|apikey)\s*[=:]\s*['"]?(?![^\s'"]*\[REDACTED\])[^\s'"]{8,}/gi, "$1=[REDACTED]"],
|
||||
[/bearer\s+[a-zA-Z0-9_\-\.]{20,}/gi, "bearer [REDACTED]"],
|
||||
];
|
||||
|
||||
export function redact(text: string): string {
|
||||
let result = text;
|
||||
for (const [pattern, replacement] of SECRET_PATTERNS) {
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Config parsing ────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse the [data_sharing] section from a raw config.toml object
|
||||
* (already parsed by smol-toml or similar).
|
||||
*/
|
||||
export function parseConsent(config: Record<string, any>): ConsentRecord {
|
||||
const ds = config.data_sharing ?? {};
|
||||
return new ConsentRecord({
|
||||
enabled: ds.enabled ?? false,
|
||||
transport: ds.transport ?? "http",
|
||||
endpoint: ds.endpoint ?? "",
|
||||
categories: ds.categories ?? {},
|
||||
retention: {
|
||||
days: ds.retention?.days ?? DEFAULT_RETENTION_DAYS,
|
||||
redact_secrets: ds.retention?.redact_secrets ?? true,
|
||||
},
|
||||
});
|
||||
}
|
||||
38
shared/src/data_sharing/index.ts
Normal file
38
shared/src/data_sharing/index.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
/**
|
||||
* Data sharing layer for Réplica Omnisciente — TypeScript shared library.
|
||||
*
|
||||
* Used by:
|
||||
* - aurelio-vscode (standalone VS Code extension)
|
||||
* - aurelio-theia (our IDE)
|
||||
*
|
||||
* OS-agnostic, consent-gated data collection and transmission.
|
||||
*
|
||||
* Port of scripts/data_sharing/ (Python).
|
||||
*/
|
||||
|
||||
export {
|
||||
CATEGORIES,
|
||||
ConsentRecord,
|
||||
redact,
|
||||
parseConsent,
|
||||
DEFAULT_RETENTION_DAYS,
|
||||
type Category,
|
||||
type ConsentCategories,
|
||||
type DataSharingConfig,
|
||||
} from "./consent.js";
|
||||
|
||||
export {
|
||||
HttpTransport,
|
||||
LocalTransport,
|
||||
getTransport,
|
||||
availableTransports,
|
||||
type Transport,
|
||||
type TransmissionResult,
|
||||
type HttpTransportOptions,
|
||||
type LocalTransportOptions,
|
||||
} from "./transports.js";
|
||||
|
||||
export {
|
||||
runSync,
|
||||
type SyncResult,
|
||||
} from "./sync.js";
|
||||
126
shared/src/data_sharing/sync.ts
Normal file
126
shared/src/data_sharing/sync.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/**
|
||||
* Sync orchestrator — collect → filter → transmit.
|
||||
*
|
||||
* TypeScript port of scripts/data_sharing/sync.py.
|
||||
* Shared between aurelio-vscode and aurelio-theia.
|
||||
*/
|
||||
|
||||
import { ConsentRecord, type Category } from "./consent.js";
|
||||
import type { TransmissionResult, Transport } from "./transports.js";
|
||||
import { getTransport } from "./transports.js";
|
||||
|
||||
export interface SyncResult {
|
||||
status: "success" | "failed" | "disabled" | "no_consent" | "dry_run";
|
||||
message: string;
|
||||
consent: {
|
||||
enabled: boolean;
|
||||
transport: string;
|
||||
endpoint: string;
|
||||
categoriesGranted: Category[];
|
||||
retentionDays: number;
|
||||
redactSecrets: boolean;
|
||||
};
|
||||
transmission?: {
|
||||
transport: string;
|
||||
bytesSent: number;
|
||||
timestamp: string;
|
||||
};
|
||||
itemsCollected?: Record<string, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the data-sharing cycle.
|
||||
*
|
||||
* In the VS Code extension and Theia backend, the payload is assembled
|
||||
* from the extension's own session/brain data — not by reading files
|
||||
* directly. The `collectPayload` callback provides this data.
|
||||
*
|
||||
* @param consent Resolved consent record.
|
||||
* @param collectPayload Callback that returns the payload object, already
|
||||
* filtered to only include consented categories.
|
||||
* @param transportOverride Optional transport instance (for custom fetch).
|
||||
* @param dryRun If true, collect + preview without transmitting.
|
||||
*/
|
||||
export async function runSync(
|
||||
consent: ConsentRecord,
|
||||
collectPayload: () => Record<string, unknown>,
|
||||
transportOverride?: Transport,
|
||||
dryRun?: boolean,
|
||||
): Promise<SyncResult> {
|
||||
if (!consent.enabled) {
|
||||
return {
|
||||
status: "disabled",
|
||||
message: "Data sharing is disabled",
|
||||
consent: consentSummary(consent),
|
||||
};
|
||||
}
|
||||
|
||||
const granted = consent.grantedCategories();
|
||||
if (granted.length === 0) {
|
||||
return {
|
||||
status: "no_consent",
|
||||
message: "Data sharing enabled but no categories opted in",
|
||||
consent: consentSummary(consent),
|
||||
};
|
||||
}
|
||||
|
||||
const payload = collectPayload();
|
||||
|
||||
if (dryRun) {
|
||||
const itemCount = countItems(payload);
|
||||
return {
|
||||
status: "dry_run",
|
||||
message: `Would transmit ${itemCount} items via ${consent.transport}`,
|
||||
consent: consentSummary(consent),
|
||||
itemsCollected: summarize(payload),
|
||||
};
|
||||
}
|
||||
|
||||
const transport = transportOverride ?? getTransport(consent.transport);
|
||||
const result: TransmissionResult = await transport.transmit(payload, consent);
|
||||
|
||||
return {
|
||||
status: result.success ? "success" : "failed",
|
||||
message: result.message,
|
||||
consent: consentSummary(consent),
|
||||
transmission: {
|
||||
transport: result.transport,
|
||||
bytesSent: result.bytesSent,
|
||||
timestamp: result.timestamp,
|
||||
},
|
||||
itemsCollected: summarize(payload),
|
||||
};
|
||||
}
|
||||
|
||||
function consentSummary(consent: ConsentRecord): SyncResult["consent"] {
|
||||
return {
|
||||
enabled: consent.enabled,
|
||||
transport: consent.transport,
|
||||
endpoint: consent.endpoint || "(not set)",
|
||||
categoriesGranted: consent.grantedCategories(),
|
||||
retentionDays: consent.retentionDays,
|
||||
redactSecrets: consent.redactSecrets,
|
||||
};
|
||||
}
|
||||
|
||||
function countItems(payload: Record<string, unknown>): number {
|
||||
let total = 0;
|
||||
for (const key in payload) {
|
||||
if (key.startsWith("_") || key === "schema_version" || key === "consent" || key === "collected_at" || key === "device_id" || key === "platform") continue;
|
||||
const val = payload[key];
|
||||
if (Array.isArray(val)) total += val.length;
|
||||
else if (typeof val === "object" && val !== null) total += 1;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function summarize(payload: Record<string, unknown>): Record<string, number> {
|
||||
const summary: Record<string, number> = {};
|
||||
for (const key in payload) {
|
||||
if (key.startsWith("_") || key === "schema_version" || key === "consent" || key === "collected_at" || key === "device_id" || key === "platform") continue;
|
||||
const val = payload[key];
|
||||
if (Array.isArray(val)) summary[key] = val.length;
|
||||
else if (typeof val === "object" && val !== null) summary[key] = 1;
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
193
shared/src/data_sharing/transports.ts
Normal file
193
shared/src/data_sharing/transports.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
/**
|
||||
* Transport backends — OS-agnostic data delivery to Portugal Futurista.
|
||||
*
|
||||
* TypeScript port of scripts/data_sharing/transports.py.
|
||||
* Shared between aurelio-vscode and aurelio-theia.
|
||||
*
|
||||
* Each transport implements the Transport interface:
|
||||
* transmit(payload, consent) -> TransmissionResult
|
||||
*
|
||||
* In VS Code / Theia, HTTP requests use the built-in `fetch` (Node 18+) or
|
||||
* the extension's native HTTP client. No dependency on axios required.
|
||||
*/
|
||||
|
||||
import type { ConsentRecord } from "./consent.js";
|
||||
import { redact } from "./consent.js";
|
||||
|
||||
export interface TransmissionResult {
|
||||
success: boolean;
|
||||
transport: string;
|
||||
message: string;
|
||||
bytesSent: number;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface Transport {
|
||||
readonly name: string;
|
||||
transmit(payload: Record<string, unknown>, consent: ConsentRecord): Promise<TransmissionResult>;
|
||||
}
|
||||
|
||||
// ─── HTTP Transport ────────────────────────────────────────────────
|
||||
|
||||
export interface HttpTransportOptions {
|
||||
/** Override the endpoint from consent config */
|
||||
endpoint?: string;
|
||||
/** Bearer token */
|
||||
token?: string;
|
||||
/** Timeout in ms (default 30000) */
|
||||
timeout?: number;
|
||||
/** Custom fetch implementation (for VS Code / Theia injection) */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Client ID for X-Aurelio-Source header */
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
export class HttpTransport implements Transport {
|
||||
readonly name = "http";
|
||||
private opts: HttpTransportOptions;
|
||||
|
||||
constructor(opts: HttpTransportOptions = {}) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async transmit(payload: Record<string, unknown>, consent: ConsentRecord): Promise<TransmissionResult> {
|
||||
const endpoint = this.opts.endpoint ?? consent.endpoint;
|
||||
if (!endpoint) {
|
||||
return { success: false, transport: "http", message: "No endpoint configured", bytesSent: 0, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
const timeout = this.opts.timeout ?? 30000;
|
||||
const token = this.opts.token ?? "";
|
||||
const fetchImpl = this.opts.fetchImpl ?? globalThis.fetch;
|
||||
|
||||
if (!fetchImpl) {
|
||||
return { success: false, transport: "http", message: "No fetch implementation available", bytesSent: 0, timestamp: new Date().toISOString() };
|
||||
}
|
||||
|
||||
let body = JSON.stringify(payload);
|
||||
if (consent.redactSecrets) {
|
||||
body = redact(body);
|
||||
}
|
||||
const bodyBytes = new TextEncoder().encode(body);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Aurelio-Source": this.opts.clientId ?? "replica",
|
||||
"X-Aurelio-Transport": "http",
|
||||
};
|
||||
if (token) {
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const resp = await fetchImpl(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timer);
|
||||
|
||||
return {
|
||||
success: resp.ok,
|
||||
transport: "http",
|
||||
message: `HTTP ${resp.status}`,
|
||||
bytesSent: bodyBytes.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
transport: "http",
|
||||
message: `Request failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
bytesSent: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Local Transport (testing / air-gapped) ────────────────────────
|
||||
|
||||
export interface LocalTransportOptions {
|
||||
/** Callback to write the payload (VS Code: fs.writeFile, Theia: backend) */
|
||||
writeFn?: (path: string, content: string) => Promise<void>;
|
||||
/** Directory to write to */
|
||||
path?: string;
|
||||
}
|
||||
|
||||
export class LocalTransport implements Transport {
|
||||
readonly name = "local";
|
||||
private opts: LocalTransportOptions;
|
||||
|
||||
constructor(opts: LocalTransportOptions = {}) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
async transmit(payload: Record<string, unknown>, consent: ConsentRecord): Promise<TransmissionResult> {
|
||||
const dest = this.opts.path ?? "./aurelio-ingest";
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, "").replace("T", "T").replace("Z", "Z");
|
||||
const outFile = `${dest}/ingest-${ts}.json`;
|
||||
|
||||
let data = JSON.stringify(payload, null, 2);
|
||||
if (consent.redactSecrets) {
|
||||
data = redact(data);
|
||||
}
|
||||
|
||||
if (this.opts.writeFn) {
|
||||
try {
|
||||
await this.opts.writeFn(outFile, data);
|
||||
return {
|
||||
success: true,
|
||||
transport: "local",
|
||||
message: `Written to ${outFile}`,
|
||||
bytesSent: data.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
transport: "local",
|
||||
message: `Write failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
bytesSent: 0,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// No writeFn: just return the data in the message (for testing)
|
||||
return {
|
||||
success: true,
|
||||
transport: "local",
|
||||
message: `Would write to ${outFile}`,
|
||||
bytesSent: data.length,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Registry ──────────────────────────────────────────────────────
|
||||
|
||||
const TRANSPORTS = new Map<string, Transport>([
|
||||
["http", new HttpTransport()],
|
||||
["local", new LocalTransport()],
|
||||
]);
|
||||
|
||||
export function getTransport(name: string, options?: Record<string, unknown>): Transport {
|
||||
switch (name) {
|
||||
case "http":
|
||||
return new HttpTransport(options as HttpTransportOptions);
|
||||
case "local":
|
||||
return new LocalTransport(options as LocalTransportOptions);
|
||||
default:
|
||||
throw new Error(`Unknown transport '${name}'. Available: http, local.`);
|
||||
}
|
||||
}
|
||||
|
||||
export function availableTransports(): string[] {
|
||||
return Array.from(TRANSPORTS.keys());
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue