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:
Raphael Cautus (Maestro) 2026-07-31 14:58:02 +01:00
parent 39fb44fe0e
commit 2f26f2d836
101 changed files with 6692 additions and 24 deletions

1
.gitignore vendored
View file

@ -67,3 +67,4 @@ id_ed25519
.runner
.mimocode/.cron-lock
infrastructure/fabric/drift/target/
firmware/telemetry/rust/target/

View file

@ -187,6 +187,10 @@
{
"value": "local",
"label": "Local (Ollama / sentence-transformers)"
},
{
"value": "google-cloud",
"label": "Google Cloud (Vertex AI)"
}
]
}

View file

@ -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":{}}

View file

@ -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/

View file

@ -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}

View file

@ -1 +0,0 @@
This file has an mtime of when this was started.

View file

@ -1 +0,0 @@
This file has an mtime of when this was started.

View file

@ -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}

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

@ -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

298
scripts/data-sharing.py Normal file
View 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()

View 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",
]

View 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

View 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

View 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)

View 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
View 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())

View 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())

View 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())

View 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

View 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"

View 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()

View file

@ -0,0 +1 @@
"""MuScriptor MCP Server — audio → MIDI transcription for Aurelio/Hermes agents."""

View file

@ -0,0 +1,5 @@
"""Allow running as `python -m muscriptor_mcp.server`."""
import asyncio
from muscriptor_mcp.server import main
asyncio.run(main())

View 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]

View file

@ -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")

View 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()

View 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

View 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

View 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"

View file

@ -0,0 +1 @@
"""Music MCP Server — unified music stack for Aurelio/Hermes agents."""

View file

@ -0,0 +1,4 @@
"""Allow running as `python -m music_mcp.server`."""
import asyncio
from music_mcp.server import main
asyncio.run(main)

View 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)

View 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")

View 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]}

View 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 [])]

View 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)

View 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]]}

View 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

View 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()

View 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
View 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()

View 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"]

View 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

View 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()
'''

View 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
View 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
View 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

View 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,
},
});
}

View 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";

View 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;
}

View 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