diff --git a/.cargo/audit.toml b/.cargo/audit.toml index de85751fa4c..d83aaa10935 100644 --- a/.cargo/audit.toml +++ b/.cargo/audit.toml @@ -13,5 +13,8 @@ ignore = [ "RUSTSEC-2026-0099", # rustls-webpki < 0.103.12: wildcard name constraints issue "RUSTSEC-2026-0068", # tar < 0.4.45: PAX size header handling "RUSTSEC-2026-0067", # tar < 0.4.45: unpack_in symlink chmod behavior - "RUSTSEC-2026-0009" # time < 0.3.47: stack exhaustion DoS + "RUSTSEC-2026-0009", # time < 0.3.47: stack exhaustion DoS + # quick-xml 0.30/0.37 pinned by upstream WezTerm — fix requires WezTerm to update their dep tree + "RUSTSEC-2026-0194", # quick-xml < 0.41.0: quadratic duplicate-attribute-name check + "RUSTSEC-2026-0195" # quick-xml < 0.41.0: unbounded namespace-allocation DoS in NsReader ] diff --git a/.github/workflows/turtle-term-preflight.yml b/.github/workflows/turtle-term-preflight.yml new file mode 100644 index 00000000000..05b0854b741 --- /dev/null +++ b/.github/workflows/turtle-term-preflight.yml @@ -0,0 +1,52 @@ +name: TurtleTerm Preflight (real-artifact parity) + +# One authoritative gate that runs the full packaging verifier set the way a +# developer's `make preflight` does — on a host with every packaging tool +# present, so the verdict is "full CI parity". It also fails if CI has grown a +# verifier the local preflight doesn't run (gate drift). This is the gate that +# makes a green mean the real packages were built and checked, not that a static +# file happened to match. + +on: + pull_request: + paths: + - 'packaging/**' + - 'assets/sourceos/**' + - 'Makefile' + - '.github/workflows/turtle-term-preflight.yml' + push: + branches: [main] + paths: + - 'packaging/**' + - 'assets/sourceos/**' + - 'Makefile' + workflow_dispatch: + +permissions: + contents: read + +jobs: + preflight: + name: Full-parity preflight + runs-on: ubuntu-24.04 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install packaging tools (deb + rpm + zstd) + run: | + sudo apt-get update -q + sudo apt-get install -y dpkg-dev rpm zstd + + - name: Run preflight + run: make preflight + + - name: Assert full parity (no gate was skipped) + run: | + out="$(bash packaging/scripts/preflight.sh)" + echo "$out" + if echo "$out" | grep -q 'PREFLIGHT PARTIAL'; then + echo "::error::preflight reported PARTIAL on a fully-tooled runner — a real-build gate was skipped; investigate the skip reason above" >&2 + exit 1 + fi + echo "$out" | grep -q 'full CI parity' diff --git a/.github/workflows/turtle-term-scripts.yml b/.github/workflows/turtle-term-scripts.yml index 51675d1b6ca..42b7679154d 100644 --- a/.github/workflows/turtle-term-scripts.yml +++ b/.github/workflows/turtle-term-scripts.yml @@ -102,6 +102,9 @@ jobs: - name: Run language intelligence smoke test run: python3 assets/sourceos/tests/test_turtle_language_intelligence.py + - name: Run language symbol-merge robustness test + run: python3 assets/sourceos/tests/test_turtle_language_symbol_merge.py + - name: Run branding guard run: python3 assets/sourceos/tests/test_turtle_term_branding.py diff --git a/Cargo.lock b/Cargo.lock index 0fb931fd9f1..1c00025752c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1131,9 +1131,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] @@ -4189,7 +4189,7 @@ checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1" dependencies = [ "base64 0.22.1", "indexmap 2.12.0", - "quick-xml 0.38.3", + "quick-xml 0.38.4", "serde", "time", ] @@ -4496,9 +4496,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.3" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" dependencies = [ "memchr", ] diff --git a/Makefile b/Makefile index 6b1cc2f3364..ac28f9192a1 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: all fmt build check test docs servedocs turtle-build turtle-smoke turtle-package turtle-homebrew-test turtle-release-check +.PHONY: all fmt build check test docs servedocs turtle-build turtle-smoke turtle-package turtle-homebrew-test turtle-release-check preflight all: build @@ -53,3 +53,9 @@ turtle-release-check: turtle-smoke git diff --quiet git diff --cached --quiet git rev-parse --verify HEAD + +# Run the SAME gates CI runs, locally, and report honestly which ran vs were +# skipped. A green here means what a green in CI means (real package builds), +# and it self-checks that the local gate set hasn't drifted from CI's. +preflight: + bash packaging/scripts/preflight.sh diff --git a/assets/sourceos/bin/turtle-context b/assets/sourceos/bin/turtle-context new file mode 100755 index 00000000000..1f4e70f2569 --- /dev/null +++ b/assets/sourceos/bin/turtle-context @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""turtle-context — snapshot of active context across the SourceOS stack. + +Reads from: + memory-mesh/active.json — current cwd/branch/title + memory-mesh/context.jsonl — last N mesh events + ~/.local/state/sourceos/status/ — CI/PR/Noetica/board state + BearBrowser memory candidates — last browsed pages w/ agent context + ~/notes/*.md — most recently modified notes + +Usage: + turtle-context # pretty snapshot + turtle-context --json # machine-readable JSON + turtle-context --short # one-liner summary for use in prompts +""" +from __future__ import annotations + +import datetime +import json +import os +import sys +from pathlib import Path + +MESH_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "memory-mesh" +STATUS_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "status" +NOTES_DIR = Path(os.getenv("GOOSE_NOTES_DIR", str(Path.home() / "notes"))) +BB_SUPPORT = Path.home() / "Library" / "Application Support" / "BearBrowser" + +C_RESET = "\033[0m" +C_BOLD = "\033[1m" +C_DIM = "\033[2m" +C_BLUE = "\033[38;2;88;166;255m" +C_TEAL = "\033[38;2;63;185;80m" +C_PURPLE = "\033[38;2;188;140;255m" +C_CYAN = "\033[38;2;0;200;200m" +C_YELLOW = "\033[38;2;210;153;34m" +C_WHITE = "\033[38;2;230;237;243m" +C_GREY = "\033[38;2;139;148;158m" +C_ORANGE = "\033[38;2;255;123;114m" + + +def load_json(path: Path) -> dict: + if not path.exists(): + return {} + try: + return json.loads(path.read_text()) + except Exception: + return {} + + +def load_jsonl_tail(path: Path, n: int = 10) -> list[dict]: + if not path.exists(): + return [] + items = [] + try: + for line in path.read_text(errors="replace").splitlines()[-n * 3:]: + try: + items.append(json.loads(line)) + except Exception: + pass + except Exception: + pass + return items[-n:] + + +def age_str(iso: str) -> str: + if not iso: + return "" + try: + t = datetime.datetime.fromisoformat(iso.replace("Z", "+00:00")) + s = int((datetime.datetime.now(datetime.timezone.utc) - t).total_seconds()) + if s < 60: + return f"{s}s ago" + if s < 3600: + return f"{s//60}m ago" + return f"{s//3600}h ago" + except Exception: + return "" + + +def gather() -> dict: + active = load_json(MESH_DIR / "active.json") + mesh_tail = load_jsonl_tail(MESH_DIR / "context.jsonl", 8) + ci = load_json(STATUS_DIR / "ci.json") + pr = load_json(STATUS_DIR / "pr.json") + noetica = load_json(STATUS_DIR / "noetica.json") + board = load_json(STATUS_DIR / "board.json") + + # BearBrowser recent activity (last 3 candidates) + bb_cands: list[dict] = [] + bb_cand_path = BB_SUPPORT / "memory" / "candidates.jsonl" + if bb_cand_path.exists(): + for line in bb_cand_path.read_text(errors="replace").splitlines()[-30:]: + try: + bb_cands.append(json.loads(line)) + except Exception: + pass + bb_cands = bb_cands[-3:] + + # Recent notes + notes: list[str] = [] + if NOTES_DIR.exists(): + md_files = sorted(NOTES_DIR.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True) + for p in md_files[:3]: + notes.append(p.name) + + return { + "active": active, + "mesh": mesh_tail, + "ci": ci, + "pr": pr, + "noetica": noetica, + "board": board, + "bb_cands": bb_cands, + "notes": notes, + } + + +def print_pretty(ctx: dict) -> None: + def hr(w: int = 68) -> str: + return C_GREY + "─" * w + C_RESET + + print() + print(f" {C_BLUE}{C_BOLD}◆ SourceOS Context{C_RESET} {C_GREY}{datetime.datetime.now().strftime('%H:%M:%S')}{C_RESET}") + print(f" {hr()}") + + # Active focus + active = ctx["active"] + if active: + cwd = active.get("cwd", "") + branch = active.get("branch", "") + title = active.get("title", "") + upd = age_str(active.get("updated", "")) + print(f" {C_BLUE}FOCUS{C_RESET} {C_GREY}{upd}{C_RESET}") + if cwd: + print(f" {C_WHITE}cwd: {C_RESET}{cwd}") + if branch: + print(f" {C_TEAL}branch: {C_RESET}{branch}") + if title: + print(f" {C_GREY}title: {C_RESET}{title}") + else: + print(f" {C_DIM} — no active context (use tc to capture){C_RESET}") + print(f" {hr()}") + + # Services + noe = ctx["noetica"] + ci = ctx["ci"] + pr = ctx["pr"] + bd = ctx["board"] + + noe_s = (C_TEAL + "● Noetica ok") if noe.get("ok") else (C_ORANGE + "○ Noetica down") + noe_d = f" brain={noe.get('brain','?')} queries={noe.get('queries','?')}" if noe.get("ok") else "" + print(f" {noe_s}{C_GREY}{noe_d}{C_RESET}") + + if ci: + conc = ci.get("conclusion", "") + ci_c = C_TEAL if conc == "success" else (C_ORANGE if conc == "failure" else C_YELLOW) + print(f" {ci_c}● CI {ci.get('status','?')} / {conc or '…'}{C_RESET} {C_GREY}{ci.get('name','?')}{C_RESET}") + + if pr: + print(f" {C_YELLOW}● PRs {pr.get('count', 0)} open{C_RESET} {C_GREY}{pr.get('repo','')}{C_RESET}") + + if bd: + score = bd.get("score", 0) + print(f" {C_PURPLE}● Board {score:.1f}%{C_RESET} {C_GREY}{age_str(bd.get('ts',''))}{C_RESET}") + + print(f" {hr()}") + + # Memory mesh events + mesh = ctx["mesh"] + if mesh: + print(f" {C_PURPLE}MESH{C_RESET} {C_GREY}(last {len(mesh)} events){C_RESET}") + for ev in reversed(mesh): + ts = ev.get("ts", "")[:16] + kind = ev.get("kind", "?")[:10] + src = ev.get("source", "?")[:10] + ttl = (ev.get("title") or ev.get("content", ""))[:52] + print(f" {C_PURPLE}·{C_RESET} {C_GREY}{ts} {C_YELLOW}{kind:<10}{C_GREY}[{src}]{C_RESET} {C_WHITE}{ttl}{C_RESET}") + print(f" {hr()}") + + # BearBrowser + bb_cands = ctx["bb_cands"] + if bb_cands: + print(f" {C_CYAN}BEARBROWSER{C_RESET} {C_GREY}memory candidates{C_RESET}") + for c in reversed(bb_cands): + content = c.get("content", {}) + ttl = (content.get("title", "") if isinstance(content, dict) else "?")[:52] + status = c.get("status", "proposed") + ts = c.get("timestamp", "")[:16] + sc = C_TEAL if status == "committed" else C_YELLOW + print(f" {sc}{'✓' if status=='committed' else '·'}{C_RESET} {C_GREY}{ts}{C_RESET} {C_WHITE}{ttl}{C_RESET}") + print(f" {hr()}") + + # Notes + notes = ctx["notes"] + if notes: + print(f" {C_BLUE}NOTES{C_RESET} {C_GREY}(recent){C_RESET}") + for n in notes: + print(f" {C_GREY}·{C_RESET} {n}") + + print() + + +def print_short(ctx: dict) -> None: + """Single-line summary for injecting into prompts.""" + active = ctx["active"] + parts = [] + if active.get("cwd"): + parts.append(f"cwd={active['cwd']}") + if active.get("branch"): + parts.append(f"branch={active['branch']}") + noe = ctx["noetica"] + parts.append(f"noetica={'up' if noe.get('ok') else 'down'}") + ci = ctx["ci"] + if ci: + parts.append(f"ci={ci.get('conclusion', ci.get('status','?'))}") + mesh = ctx["mesh"] + if mesh: + last = mesh[-1] + parts.append(f"last_event={last.get('kind','?')}:{(last.get('title') or '')[:30]}") + print(" ".join(parts)) + + +def main() -> None: + args = sys.argv[1:] + ctx = gather() + + if "--json" in args: + print(json.dumps(ctx, indent=2, default=str)) + elif "--short" in args: + print_short(ctx) + else: + print_pretty(ctx) + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-goose-bridge b/assets/sourceos/bin/turtle-goose-bridge new file mode 100755 index 00000000000..3db925e44b4 --- /dev/null +++ b/assets/sourceos/bin/turtle-goose-bridge @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""turtle-goose-bridge — bidirectional sync between memory mesh and Goose Notes. + +Direction 1 (mesh → Goose Notes): + Watches memory-mesh/context.jsonl for new `capture` events. + Converts them to ~/notes/{date}-{slug}.md (Goose Notes pickup format). + Also POSTs to Goose Notes HTTP API on :8765 if running. + +Direction 2 (Goose Notes → mesh): + Scans ~/notes/*.md for notes not yet in the mesh. + Adds them as `note` events in context.jsonl. + +Usage: + turtle-goose-bridge # run bidirectional sync once + turtle-goose-bridge --watch # watch mode: poll every 30s + turtle-goose-bridge --status # show sync state + turtle-goose-bridge --push # push new mesh events → Goose Notes only + turtle-goose-bridge --pull # pull new Goose Notes → mesh only +""" + +from __future__ import annotations + +import datetime +import json +import os +import re +import sys +import time +from pathlib import Path +from urllib import request as urlreq + +MESH_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "memory-mesh" +NOTES_DIR = Path(os.getenv("GOOSE_NOTES_DIR", str(Path.home() / "notes"))) +GOOSE_API = os.getenv("GOOSE_NOTES_API", "http://localhost:8765") +SYNC_DB = MESH_DIR / ".goose-sync.json" # tracks which events/notes already synced + + +def now_iso() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + + +def slugify(s: str) -> str: + s = s.lower().strip() + s = re.sub(r"[^\w\s-]", "", s) + s = re.sub(r"[\s_-]+", "-", s) + return s[:60] + + +def load_sync_db() -> dict: + if SYNC_DB.exists(): + try: + return json.loads(SYNC_DB.read_text()) + except Exception: + pass + return {"mesh_to_notes": [], "notes_to_mesh": []} + + +def save_sync_db(db: dict) -> None: + MESH_DIR.mkdir(parents=True, exist_ok=True) + SYNC_DB.write_text(json.dumps(db, indent=2)) + + +def load_mesh_events() -> list[dict]: + ctx = MESH_DIR / "context.jsonl" + if not ctx.exists(): + return [] + events = [] + try: + for line in ctx.read_text(errors="replace").splitlines(): + try: + events.append(json.loads(line)) + except Exception: + pass + except Exception: + pass + return events + + +def append_mesh_event(event: dict) -> None: + MESH_DIR.mkdir(parents=True, exist_ok=True) + ctx = MESH_DIR / "context.jsonl" + with ctx.open("a") as f: + f.write(json.dumps(event) + "\n") + + +def write_note_file(title: str, body: str, ts: str, tags: list[str]) -> Path: + NOTES_DIR.mkdir(parents=True, exist_ok=True) + date = ts[:10] if ts else datetime.date.today().isoformat() + slug = slugify(title) + path = NOTES_DIR / f"{date}-{slug}.md" + if path.exists(): + return path # already exists + front = f"""--- +title: "{title}" +date: {ts or now_iso()} +source: memory-mesh +tags: [{", ".join(tags)}] +--- + +""" + path.write_text(front + body) + return path + + +def post_goose_api(title: str, body: str, tags: list[str]) -> bool: + try: + payload = json.dumps({"title": title, "body": body, "tags": tags, + "source_type": "terminal"}).encode() + req = urlreq.Request(f"{GOOSE_API}/api/notes", data=payload, + headers={"Content-Type": "application/json"}) + with urlreq.urlopen(req, timeout=2) as r: + return r.status < 300 + except Exception: + return False + + +def mesh_to_notes(db: dict) -> int: + """Convert new capture events from mesh → Goose Notes files.""" + synced = set(db.get("mesh_to_notes", [])) + events = load_mesh_events() + pushed = 0 + + for ev in events: + key = ev.get("ts", "") + ev.get("title", "") + if key in synced: + continue + if ev.get("kind") not in ("capture", "note"): + continue + + title = ev.get("title", "Untitled") + content = ev.get("content", "") + source = ev.get("source", "terminal") + ts = ev.get("ts", "") + tags = ["mesh", source] + + body = f"**Source:** `{source}`\n\n```\n{content.rstrip()}\n```\n" + write_note_file(title, body, ts, tags) + post_goose_api(title, body, tags) + + synced.add(key) + pushed += 1 + + db["mesh_to_notes"] = list(synced) + return pushed + + +def notes_to_mesh(db: dict) -> int: + """Ingest Goose Notes files into the memory mesh.""" + synced = set(db.get("notes_to_mesh", [])) + if not NOTES_DIR.exists(): + return 0 + + ingested = 0 + for path in sorted(NOTES_DIR.glob("*.md"), key=lambda p: p.stat().st_mtime): + key = path.name + if key in synced: + continue + + try: + text = path.read_text(errors="replace") + except Exception: + continue + + # Parse frontmatter title + m_title = re.search(r'^title:\s*"?([^"\n]+)"?', text, re.M) + m_date = re.search(r'^date:\s*(\S+)', text, re.M) + title = m_title.group(1).strip() if m_title else path.stem + ts = m_date.group(1).strip() if m_date else now_iso() + + # Strip frontmatter for content + body = re.sub(r'^---\n.*?\n---\n', '', text, count=1, flags=re.DOTALL).strip() + + append_mesh_event({ + "ts": ts, + "kind": "note", + "source": "goose-notes", + "title": title, + "content": body[:400], + "path": str(path), + }) + + synced.add(key) + ingested += 1 + + db["notes_to_mesh"] = list(synced) + return ingested + + +def sync_once(push_only: bool = False, pull_only: bool = False) -> tuple[int, int]: + db = load_sync_db() + pushed = mesh_to_notes(db) if not pull_only else 0 + pulled = notes_to_mesh(db) if not push_only else 0 + save_sync_db(db) + return pushed, pulled + + +def print_status() -> None: + db = load_sync_db() + mesh_count = len(load_mesh_events()) + notes_count = len(list(NOTES_DIR.glob("*.md"))) if NOTES_DIR.exists() else 0 + print(f"mesh events: {mesh_count}") + print(f"goose notes: {notes_count}") + print(f"mesh→notes sync: {len(db.get('mesh_to_notes', []))} items") + print(f"notes→mesh sync: {len(db.get('notes_to_mesh', []))} items") + # Goose API reachability + try: + with urlreq.urlopen(f"{GOOSE_API}/health", timeout=1): + print(f"goose-notes api: reachable ({GOOSE_API})") + except Exception: + print(f"goose-notes api: unreachable ({GOOSE_API})") + + +def main() -> None: + args = sys.argv[1:] + + if "--status" in args: + print_status() + return + + push_only = "--push" in args + pull_only = "--pull" in args + watch = "--watch" in args + + if watch: + print("turtle-goose-bridge: watching (poll every 30s, Ctrl+C to stop)") + while True: + try: + pushed, pulled = sync_once(push_only, pull_only) + if pushed or pulled: + print(f" [{now_iso()[:19]}] pushed={pushed} pulled={pulled}") + except Exception as e: + print(f" sync error: {e}", file=sys.stderr) + time.sleep(30) + else: + pushed, pulled = sync_once(push_only, pull_only) + print(f"pushed {pushed} → Goose Notes · pulled {pulled} → mesh") + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-install-launchd b/assets/sourceos/bin/turtle-install-launchd new file mode 100755 index 00000000000..2bbee4ae043 --- /dev/null +++ b/assets/sourceos/bin/turtle-install-launchd @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +# turtle-install-launchd — install SourceOS mesh launchd agents on macOS. +# +# Installs: +# com.sourceos.turtle-mesh-push — GCS mesh sync every 5 minutes +# com.sourceos.turtle-mesh-serve — local mesh dashboard on :7788 +# +# Usage: +# turtle-install-launchd # install both +# turtle-install-launchd --unload # stop + unload both +# turtle-install-launchd --status # show launchctl status +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BIN_DIR="$SCRIPT_DIR" +LAUNCHD_DIR="$SCRIPT_DIR/../launchd" +LAUNCH_AGENTS="$HOME/Library/LaunchAgents" +LABELS=(com.sourceos.turtle-mesh-push com.sourceos.turtle-mesh-serve) + +usage() { + echo "Usage: turtle-install-launchd [--unload|--status]" +} + +status_mode=false +unload_mode=false +for arg in "$@"; do + case "$arg" in + --status) status_mode=true ;; + --unload) unload_mode=true ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown arg: $arg" >&2; usage >&2; exit 1 ;; + esac +done + +if $status_mode; then + for label in "${LABELS[@]}"; do + echo -n " $label: " + launchctl list "$label" 2>/dev/null | grep '"PID"' || echo "(not loaded)" + done + exit 0 +fi + +if $unload_mode; then + for label in "${LABELS[@]}"; do + launchctl unload "$LAUNCH_AGENTS/$label.plist" 2>/dev/null && echo " unloaded $label" || echo " $label not loaded" + done + exit 0 +fi + +mkdir -p "$LAUNCH_AGENTS" + +for plist_name in "${LABELS[@]}"; do + src="$LAUNCHD_DIR/$plist_name.plist" + dst="$LAUNCH_AGENTS/$plist_name.plist" + + if [[ ! -f "$src" ]]; then + echo " WARN: plist not found: $src" >&2 + continue + fi + + # Substitute placeholders + sed -e "s|TURTLE_BIN_DIR|$BIN_DIR|g" \ + -e "s|HOME_DIR|$HOME|g" \ + "$src" > "$dst" + + # Unload first if already loaded + launchctl unload "$dst" 2>/dev/null ||: + launchctl load -w "$dst" + echo " loaded $plist_name" +done + +echo "" +echo " Mesh dashboard → http://localhost:7788" +echo " GCS sync → every 5 minutes (requires SOURCEOS_GCS_BUCKET env in ~/.zshrc)" diff --git a/assets/sourceos/bin/turtle-mesh-serve b/assets/sourceos/bin/turtle-mesh-serve new file mode 100755 index 00000000000..b18afecb8af --- /dev/null +++ b/assets/sourceos/bin/turtle-mesh-serve @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +"""turtle-mesh-serve — local HTTP dashboard for the SourceOS memory mesh. + +Serves a live web dashboard at http://localhost:7788 showing: + • Recent mesh events (auto-refreshes every 3s via SSE) + • Active context (cwd/branch/focus) + • CI/PR/Noetica/board status bar + • BearBrowser memory candidates + • Recent ~/notes/ files + +Open with: bb http://localhost:7788 +Or: open http://localhost:7788 + +Usage: + turtle-mesh-serve # serve on :7788 + turtle-mesh-serve --port 8899 # custom port + turtle-mesh-serve --once # dump JSON and exit (no server) +""" +from __future__ import annotations + +import argparse +import datetime +import json +import os +import queue +import sys +import threading +import time +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path + +MESH_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "memory-mesh" +STATUS_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "status" +NOTES_DIR = Path(os.getenv("GOOSE_NOTES_DIR", str(Path.home() / "notes"))) +BB_SUPPORT = Path.home() / "Library" / "Application Support" / "BearBrowser" + +DEFAULT_PORT = int(os.getenv("TURTLE_MESH_PORT", "7788")) + + +def load_json(path: Path) -> dict: + try: + return json.loads(path.read_text()) if path.exists() else {} + except Exception: + return {} + + +def load_jsonl_tail(path: Path, n: int = 50) -> list[dict]: + if not path.exists(): + return [] + items: list[dict] = [] + try: + for line in path.read_text(errors="replace").splitlines()[-n * 3:]: + try: + items.append(json.loads(line)) + except Exception: + pass + except Exception: + pass + return items[-n:] + + +def gather_state() -> dict: + mesh = load_jsonl_tail(MESH_DIR / "context.jsonl", 60) + active = load_json(MESH_DIR / "active.json") + ci = load_json(STATUS_DIR / "ci.json") + pr = load_json(STATUS_DIR / "pr.json") + noetica= load_json(STATUS_DIR / "noetica.json") + board = load_json(STATUS_DIR / "board.json") + + bb_cands: list[dict] = [] + cand_path = BB_SUPPORT / "memory" / "candidates.jsonl" + if cand_path.exists(): + for line in cand_path.read_text(errors="replace").splitlines()[-60:]: + try: + bb_cands.append(json.loads(line)) + except Exception: + pass + bb_cands = bb_cands[-10:] + + notes: list[dict] = [] + if NOTES_DIR.exists(): + for p in sorted(NOTES_DIR.glob("*.md"), key=lambda f: f.stat().st_mtime, reverse=True)[:8]: + notes.append({"name": p.name, "mtime": p.stat().st_mtime}) + + return { + "ts": datetime.datetime.now().isoformat(), + "active": active, + "mesh": mesh, + "ci": ci, + "pr": pr, + "noetica": noetica, + "board": board, + "bb_cands": bb_cands, + "notes": notes, + } + + +# ── SSE broadcast ───────────────────────────────────────────────────────────── + +_sse_clients: list[queue.Queue] = [] +_sse_lock = threading.Lock() + + +def broadcast_state() -> None: + state = gather_state() + data = json.dumps(state, default=str) + with _sse_lock: + dead = [] + for q in _sse_clients: + try: + q.put_nowait(data) + except Exception: + dead.append(q) + for q in dead: + _sse_clients.remove(q) + + +def poller_thread() -> None: + while True: + try: + broadcast_state() + except Exception: + pass + time.sleep(3) + + +# ── HTML dashboard ───────────────────────────────────────────────────────────── + +DASHBOARD_HTML = r""" + + + + +SourceOS Mesh · Dashboard + + + +

◆ SourceOS Memory Mesh

+
+ Noetica … + CI — + PRs — + Board — + +
+ +
+

Active Focus

+
+
+ +
+
+

Memory Mesh

+
+
+
+
+

BearBrowser Memory

+
+
+
+

Recent Notes

+
+
+
+
+ + + + + +""" + + +class DashHandler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass # quiet + + def do_GET(self): + if self.path in ("/", "/index.html"): + body = DASHBOARD_HTML.encode() + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + elif self.path == "/api/state": + state = gather_state() + body = json.dumps(state, default=str).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + elif self.path == "/events": + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.send_header("Cache-Control", "no-cache") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + + q: queue.Queue = queue.Queue(maxsize=8) + with _sse_lock: + _sse_clients.append(q) + try: + # Send initial state + initial = json.dumps(gather_state(), default=str) + self.wfile.write(f"data: {initial}\n\n".encode()) + self.wfile.flush() + while True: + try: + data = q.get(timeout=20) + self.wfile.write(f"data: {data}\n\n".encode()) + self.wfile.flush() + except queue.Empty: + self.wfile.write(b": ping\n\n") + self.wfile.flush() + except Exception: + pass + finally: + with _sse_lock: + try: + _sse_clients.remove(q) + except ValueError: + pass + else: + self.send_response(404) + self.end_headers() + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("--port", type=int, default=DEFAULT_PORT) + parser.add_argument("--once", action="store_true", help="dump JSON and exit") + args = parser.parse_args() + + if args.once: + print(json.dumps(gather_state(), indent=2, default=str)) + return + + t = threading.Thread(target=poller_thread, daemon=True) + t.start() + + server = HTTPServer(("127.0.0.1", args.port), DashHandler) + print(f" ◆ Mesh dashboard → http://localhost:{args.port} (Ctrl+C to stop)", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + finally: + server.server_close() + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-mission-control b/assets/sourceos/bin/turtle-mission-control index 947e5d50dfe..d3e43a02ea3 100755 --- a/assets/sourceos/bin/turtle-mission-control +++ b/assets/sourceos/bin/turtle-mission-control @@ -130,6 +130,45 @@ def recent_mesh_events(limit: int = 8) -> list[dict]: return events[-limit:] +BB_SUPPORT = Path.home() / "Library" / "Application Support" / "BearBrowser" +BB_CANDIDATES = BB_SUPPORT / "memory" / "candidates.jsonl" +BB_EVENTS = BB_SUPPORT / "provenance" / "events.jsonl" + + +def bearbrowser_candidates(limit: int = 4) -> list[dict]: + """Most-recent BearBrowser memory candidates (proposed + committed).""" + if not BB_CANDIDATES.exists(): + return [] + items = [] + try: + for line in BB_CANDIDATES.read_text(errors="replace").splitlines()[-100:]: + try: + items.append(json.loads(line)) + except Exception: + pass + except Exception: + pass + return items[-limit:] + + +def bearbrowser_recent_nav(limit: int = 3) -> list[dict]: + """Most-recent navigation.committed events from BearBrowser provenance log.""" + if not BB_EVENTS.exists(): + return [] + navs = [] + try: + for line in BB_EVENTS.read_text(errors="replace").splitlines()[-200:]: + try: + ev = json.loads(line) + if ev.get("eventType") in ("navigation.committed", "page.shared_with_agent"): + navs.append(ev) + except Exception: + pass + except Exception: + pass + return navs[-limit:] + + def recent_commands(limit: int = 5) -> list[dict]: if not HISTORY_FILE.exists(): return [] @@ -150,7 +189,10 @@ def hr(width: int = 70) -> str: return C_GREY + "─" * width + C_RESET -def format_panel(agents: list[dict], noetica: dict, bb: dict, mesh: list[dict], cmds: list[dict], now: str) -> str: +def format_panel(agents: list[dict], noetica: dict, bb: dict, mesh: list[dict], + cmds: list[dict], now: str, + bb_candidates: list[dict] | None = None, + bb_navs: list[dict] | None = None) -> str: lines = [] W = 72 @@ -192,8 +234,33 @@ def format_panel(agents: list[dict], noetica: dict, bb: dict, mesh: list[dict], bb_detail = f" {bb.get('tabs', 0)} tab(s)" if bb["ok"] else " unreachable — open BearBrowser" lines.append(f" {bb_dot}{C_RESET} {C_WHITE}BearBrowser{C_RESET} {C_GREY}{bb_detail}{C_RESET}") + # BearBrowser recent navigation + if bb_navs: + for nav in reversed(bb_navs): + payload = nav.get("payload", {}) + url = (payload.get("url", "") or payload.get("navigationUrl", ""))[:60] if isinstance(payload, dict) else "" + title = (payload.get("title", "") or url)[:50] + ts = nav.get("timestamp", "")[:16] + lines.append(f" {C_CYAN}↗{C_RESET} {C_GREY}{ts}{C_RESET} {C_WHITE}{title}{C_RESET}") + lines.append(f" {hr(W)}") + # BearBrowser memory candidates (cross-product context from browser to terminal) + if bb_candidates: + lines.append(f" {C_CYAN}BEARBROWSER MEMORY{C_RESET} {C_GREY}(mesh candidates){C_RESET}") + for cand in reversed(bb_candidates): + content = cand.get("content", {}) + title = content.get("title", "?")[:50] if isinstance(content, dict) else str(cand.get("title", "?"))[:50] + status = cand.get("status", "proposed") + ts = cand.get("timestamp", "")[:16] + status_c = C_TEAL if status == "committed" else C_YELLOW + lines.append( + f" {status_c}{'✓' if status=='committed' else '·'}{C_RESET}" + f" {C_GREY}{ts}{C_RESET} {C_WHITE}{title}{C_RESET}" + f" {C_GREY}[{status}]{C_RESET}" + ) + lines.append(f" {hr(W)}") + # Memory mesh section lines.append(f" {C_PURPLE}MEMORY MESH{C_RESET} {C_GREY}(recent events){C_RESET}") if not mesh: @@ -239,8 +306,10 @@ def main(): bb = bearbrowser_status() mesh = recent_mesh_events() cmds = recent_commands() + bb_cands = bearbrowser_candidates() + bb_navs = bearbrowser_recent_nav() now = datetime.datetime.now().strftime("%H:%M:%S") - print(format_panel(agents, noetica, bb, mesh, cmds, now)) + print(format_panel(agents, noetica, bb, mesh, cmds, now, bb_cands, bb_navs)) return # Live refresh loop @@ -251,8 +320,10 @@ def main(): bb = bearbrowser_status() mesh = recent_mesh_events() cmds = recent_commands() + bb_cands = bearbrowser_candidates() + bb_navs = bearbrowser_recent_nav() now = datetime.datetime.now().strftime("%H:%M:%S") - panel = format_panel(agents, noetica, bb, mesh, cmds, now) + panel = format_panel(agents, noetica, bb, mesh, cmds, now, bb_cands, bb_navs) # Count lines for in-place rewrite panel_lines = panel.splitlines() diff --git a/assets/sourceos/bin/turtle-render b/assets/sourceos/bin/turtle-render new file mode 100755 index 00000000000..cfaf830d281 --- /dev/null +++ b/assets/sourceos/bin/turtle-render @@ -0,0 +1,330 @@ +#!/usr/bin/env python3 +"""turtle-render — inline file renderer for TurtleTerm. + +Renders files inside the terminal using protocol-appropriate methods: + Images (.png .jpg .gif .webp .svg .bmp .ico) — iTerm2/WezTerm inline protocol + PDFs (.pdf) — first page via pdftoppm/Pillow → inline image + metadata + CSV (.csv .tsv) — aligned table printed inline + JSON (.json) — colorized pretty-print + TOML/YAML (.toml .yaml .yml) — syntax-highlighted via bat + Markdown (.md .markdown) — rendered via mdcat or bat + Code (anything else) — bat with syntax highlight + line numbers + +Usage: + turtle-render file.png + turtle-render data.csv + turtle-render --width 100 diagram.svg + cat file.json | turtle-render --stdin --type json + turtle-render file.pdf --page 2 +""" + +from __future__ import annotations + +import argparse +import base64 +import csv +import io +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +# ── image rendering (iTerm2 / WezTerm inline protocol) ──────────────────────── + +def emit_image_bytes(data: bytes, filename: str = "", width: int = 0) -> None: + b64 = base64.b64encode(data).decode("ascii") + size = len(data) + fn_b64 = base64.b64encode(filename.encode()).decode() if filename else "" + parts = [f"size={size}", "inline=1"] + if fn_b64: + parts.append(f"name={fn_b64}") + if width: + parts.append(f"width={width}") + sys.stdout.buffer.write(f"\x1b]1337;File={';'.join(parts)}:{b64}\x07\n".encode()) + sys.stdout.buffer.flush() + + +def render_image(path: Path, width: int = 0) -> bool: + try: + data = path.read_bytes() + emit_image_bytes(data, path.name, width) + dim_info = "" + # Try to get dimensions via PIL + try: + from PIL import Image as _PILImage # type: ignore + with _PILImage.open(path) as img: + dim_info = f" {img.width}×{img.height} {img.mode}" + except Exception: + pass + size_kb = len(data) / 1024 + print(f"\033[2m {path.name} {size_kb:.1f}KB{dim_info}\033[0m") + return True + except Exception as e: + print(f"render_image: {e}", file=sys.stderr) + return False + + +def render_svg(path: Path, width: int = 0) -> bool: + # Try cairosvg → PNG → inline + try: + import cairosvg # type: ignore + png_data = cairosvg.svg2png(url=str(path), output_width=800) + emit_image_bytes(png_data, path.name, width or 80) + print(f"\033[2m {path.name} (SVG→PNG)\033[0m") + return True + except Exception: + pass + # Fallback: show as source code + return render_code(path) + + +# ── PDF rendering ────────────────────────────────────────────────────────────── + +def render_pdf(path: Path, page: int = 1, width: int = 0) -> bool: + # Try pdftoppm (poppler-utils) + if shutil.which("pdftoppm"): + try: + result = subprocess.run( + ["pdftoppm", "-r", "144", "-f", str(page), "-l", str(page), + "-png", "-singlefile", str(path), "/tmp/turtle-pdf-render"], + capture_output=True, timeout=15, + ) + png_path = Path("/tmp/turtle-pdf-render.png") + if png_path.exists(): + data = png_path.read_bytes() + emit_image_bytes(data, f"{path.name} (p{page})", width or 80) + png_path.unlink(missing_ok=True) + # Show metadata + _show_pdf_meta(path) + return True + except Exception: + pass + + # Try Pillow (PDF support requires Pillow + poppler or pdf2image) + try: + from pdf2image import convert_from_path # type: ignore + imgs = convert_from_path(str(path), first_page=page, last_page=page, dpi=144) + if imgs: + buf = io.BytesIO() + imgs[0].save(buf, format="PNG") + emit_image_bytes(buf.getvalue(), f"{path.name} (p{page})", width or 80) + _show_pdf_meta(path) + return True + except Exception: + pass + + # Final fallback: metadata only + return _show_pdf_meta(path) + + +def _show_pdf_meta(path: Path) -> bool: + meta_lines = [f"\033[38;2;88;166;255m◆ PDF\033[0m {path.name}"] + if shutil.which("pdfinfo"): + try: + out = subprocess.check_output(["pdfinfo", str(path)], timeout=5, text=True) + for line in out.splitlines()[:8]: + if any(k in line for k in ("Pages:", "Title:", "Author:", "Creator:", "File size")): + meta_lines.append(f" \033[2m{line.strip()}\033[0m") + except Exception: + pass + print("\n".join(meta_lines)) + return True + + +# ── CSV / TSV rendering ──────────────────────────────────────────────────────── + +def render_csv(path: Path | None = None, text: str | None = None, max_rows: int = 40) -> bool: + try: + if path: + text = path.read_text(errors="replace") + if not text: + return False + + dialect = "excel-tab" if (path and path.suffix == ".tsv") else "excel" + reader = csv.reader(io.StringIO(text), dialect=dialect) + rows = list(reader) + if not rows: + print("(empty CSV)") + return True + + header = rows[0] + data_rows = rows[1:max_rows + 1] + truncated = len(rows) - 1 > max_rows + + # Compute column widths + col_w = [len(h) for h in header] + for row in data_rows: + for i, cell in enumerate(row): + if i < len(col_w): + col_w[i] = max(col_w[i], min(len(cell), 40)) + + def fmt_row(row: list[str], color: str = "") -> str: + cells = [] + for i, cell in enumerate(row): + w = col_w[i] if i < len(col_w) else 10 + cells.append(cell[:w].ljust(w)) + return color + " " + " │ ".join(cells) + "\033[0m" + + sep = " " + "──┼──".join("─" * w for w in col_w) + + print() + print(fmt_row(header, "\033[38;2;88;166;255m\033[1m")) + print(f"\033[2m{sep}\033[0m") + for row in data_rows: + print(fmt_row(row)) + if truncated: + print(f"\033[2m … {len(rows)-1-max_rows} more rows\033[0m") + print(f"\033[2m {len(header)} columns · {len(rows)-1} rows\033[0m") + print() + return True + except Exception as e: + print(f"render_csv: {e}", file=sys.stderr) + return False + + +# ── JSON rendering ───────────────────────────────────────────────────────────── + +def render_json(path: Path | None = None, text: str | None = None) -> bool: + if path: + text = path.read_text(errors="replace") + if not text: + return False + + # Try bat for syntax highlight first + if shutil.which("bat"): + try: + subprocess.run( + ["bat", "--language=json", "--style=numbers,header-filename", + "--color=always", "--paging=never"] + + ([str(path)] if path else []), + input=text.encode() if not path else None, + check=False, timeout=10, + ) + return True + except Exception: + pass + + # Fallback: python pretty-print + try: + parsed = json.loads(text) + pretty = json.dumps(parsed, indent=2, ensure_ascii=False) + print(pretty[:8000]) + if len(pretty) > 8000: + print("\033[2m … truncated\033[0m") + return True + except json.JSONDecodeError as e: + print(f"JSON parse error: {e}", file=sys.stderr) + return False + + +# ── markdown rendering ───────────────────────────────────────────────────────── + +def render_markdown(path: Path) -> bool: + for cmd in [["mdcat", str(path)], ["glow", str(path)], ["bat", "--language=md", "--style=full", str(path)]]: + if shutil.which(cmd[0]): + try: + subprocess.run(cmd, check=False, timeout=15) + return True + except Exception: + pass + return render_code(path) + + +# ── generic code rendering ───────────────────────────────────────────────────── + +def render_code(path: Path) -> bool: + if shutil.which("bat"): + try: + subprocess.run( + ["bat", "--style=numbers,header-filename,grid", "--color=always", "--paging=never", str(path)], + check=False, timeout=15, + ) + return True + except Exception: + pass + try: + print(path.read_text(errors="replace")) + return True + except Exception as e: + print(f"render_code: {e}", file=sys.stderr) + return False + + +# ── dispatcher ───────────────────────────────────────────────────────────────── + +IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".ico", ".tiff", ".tif", ".avif"} +CODE_EXTS = {".py", ".js", ".ts", ".tsx", ".jsx", ".rs", ".go", ".c", ".cpp", ".h", + ".java", ".rb", ".sh", ".bash", ".zsh", ".lua", ".toml", ".yaml", ".yml", + ".tf", ".hcl", ".sql", ".css", ".scss", ".html", ".xml", ".dockerfile"} + + +def render_file(path: Path, width: int = 0, page: int = 1) -> bool: + ext = path.suffix.lower() + + if ext in IMAGE_EXTS: + return render_image(path, width) + if ext == ".svg": + return render_svg(path, width) + if ext == ".pdf": + return render_pdf(path, page, width) + if ext in {".csv", ".tsv"}: + return render_csv(path) + if ext == ".json": + return render_json(path) + if ext in {".md", ".markdown"}: + return render_markdown(path) + if ext in CODE_EXTS or path.stat().st_size < 512 * 1024: + return render_code(path) + + # Binary / unknown: show metadata + size = path.stat().st_size + print(f"\033[38;2;139;148;158m {path.name} {size/1024:.1f}KB (binary — no renderer)\033[0m") + return True + + +# ── main ─────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="TurtleTerm inline file renderer") + parser.add_argument("files", nargs="*") + parser.add_argument("--width", type=int, default=0, help="image width in columns") + parser.add_argument("--page", type=int, default=1, help="PDF page number") + parser.add_argument("--stdin", action="store_true", help="read from stdin") + parser.add_argument("--type", default="", help="force file type (json/csv/image/code)") + args = parser.parse_args() + + ok = True + + if args.stdin or (not args.files and not sys.stdin.isatty()): + data = sys.stdin.buffer.read() + ftype = args.type.lower() + if ftype == "image": + emit_image_bytes(data, "stdin", args.width) + elif ftype in ("json", ""): + try: + json.loads(data) + render_json(text=data.decode(errors="replace")) + except Exception: + render_csv(text=data.decode(errors="replace")) + elif ftype == "csv": + render_csv(text=data.decode(errors="replace")) + else: + sys.stdout.buffer.write(data) + return + + for fname in args.files: + path = Path(fname).expanduser() + if not path.exists(): + print(f"turtle-render: not found: {fname}", file=sys.stderr) + ok = False + continue + if not render_file(path, args.width, args.page): + ok = False + + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-status-daemon b/assets/sourceos/bin/turtle-status-daemon new file mode 100755 index 00000000000..595cebd274d --- /dev/null +++ b/assets/sourceos/bin/turtle-status-daemon @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""turtle-status-daemon — background poller for the TurtleTerm status bar. + +Writes cached state files every 30s so the WezTerm event loop can read them +synchronously without blocking on network calls. + +State files written to ~/.local/state/sourceos/status/: + ci.json — last CI run: {status, conclusion, name, repo, ts} + pr.json — open PR count: {count, repo, ts} + noetica.json — health: {ok, brain, queries, ts} + board.json — last board result: {score, subjects, ts} + +Usage: + turtle-status-daemon # run forever (daemonize yourself with &!) + turtle-status-daemon --once # poll once and exit + turtle-status-daemon --status # print current cached state +""" + +from __future__ import annotations + +import datetime +import json +import os +import subprocess +import sys +import time +from pathlib import Path +from urllib import request as urlreq + +STATE_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "status" +NOETICA_URL = os.getenv("NOETICA_URL", "http://localhost:7700") +GCS_MANIFEST = os.getenv("SOURCEOS_BRAIN_MANIFEST", "gs://sourceos-artifacts-socioprophet/ocw-corpus/multipass-manifest.json") +GITEA_URL = os.getenv("GITEA_URL", "") +GITEA_TOKEN = os.getenv("GITEA_TOKEN", "") +POLL_INTERVAL = int(os.getenv("TURTLE_STATUS_POLL", "30")) + + +def now_iso() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + + +def write_state(name: str, data: dict) -> None: + STATE_DIR.mkdir(parents=True, exist_ok=True) + data["ts"] = now_iso() + (STATE_DIR / f"{name}.json").write_text(json.dumps(data)) + + +def read_state(name: str) -> dict: + f = STATE_DIR / f"{name}.json" + if not f.exists(): + return {} + try: + return json.loads(f.read_text()) + except Exception: + return {} + + +# ── pollers ─────────────────────────────────────────────────────────────────── + +def poll_noetica() -> dict: + try: + with urlreq.urlopen(f"{NOETICA_URL}/health", timeout=2) as r: + data = json.load(r) + return {"ok": True, "brain": data.get("brain", "?"), "queries": data.get("queries", 0)} + except Exception: + return {"ok": False} + + +def poll_ci() -> dict: + # Try Gitea first + if GITEA_URL and GITEA_TOKEN: + try: + repo = _current_repo() + if repo: + url = f"{GITEA_URL.rstrip('/')}/api/v1/repos/{repo}/actions/runs?limit=1" + req = urlreq.Request(url, headers={"Authorization": f"token {GITEA_TOKEN}"}) + with urlreq.urlopen(req, timeout=3) as r: + data = json.load(r) + runs = data.get("workflow_runs", []) + if runs: + run = runs[0] + return { + "status": run.get("status", "?"), + "conclusion": run.get("conclusion", ""), + "name": run.get("name", "?"), + "repo": repo, + } + except Exception: + pass + + # Try gh CLI fallback + try: + out = subprocess.check_output( + ["gh", "run", "list", "--limit", "1", "--json", "status,conclusion,name,headBranch"], + timeout=5, text=True, stderr=subprocess.DEVNULL, + ) + runs = json.loads(out) + if runs: + r = runs[0] + return { + "status": r.get("status", "?"), + "conclusion": r.get("conclusion", ""), + "name": r.get("name", "?"), + "repo": _current_repo() or "?", + } + except Exception: + pass + + return {} + + +def poll_prs() -> dict: + if GITEA_URL and GITEA_TOKEN: + try: + repo = _current_repo() + if repo: + url = f"{GITEA_URL.rstrip('/')}/api/v1/repos/{repo}/pulls?state=open&limit=50" + req = urlreq.Request(url, headers={"Authorization": f"token {GITEA_TOKEN}"}) + with urlreq.urlopen(req, timeout=3) as r: + data = json.load(r) + return {"count": len(data), "repo": repo} + except Exception: + pass + + try: + out = subprocess.check_output( + ["gh", "pr", "list", "--json", "number", "--state", "open"], + timeout=5, text=True, stderr=subprocess.DEVNULL, + ) + prs = json.loads(out) + return {"count": len(prs), "repo": _current_repo() or "?"} + except Exception: + pass + + return {} + + +def poll_board() -> dict: + try: + out = subprocess.check_output( + ["gsutil", "cat", GCS_MANIFEST], + timeout=10, text=True, stderr=subprocess.DEVNULL, + ) + manifest = json.loads(out) + # Look for board results in manifest + board = manifest.get("board", {}) + if board: + return { + "score": board.get("prod", board.get("baseline", 0)), + "subjects": board.get("subjects", []), + "arm": "prod", + } + except Exception: + pass + + # Try local board result cache + board_cache = Path.home() / ".local/state/sourceos/board-last.json" + if board_cache.exists(): + try: + return json.loads(board_cache.read_text()) + except Exception: + pass + + return {} + + +def _current_repo() -> str: + try: + remote = subprocess.check_output( + ["git", "remote", "get-url", "origin"], + timeout=2, text=True, stderr=subprocess.DEVNULL, + ).strip() + # Extract owner/repo from URL + import re + m = re.search(r'[:/]([^/]+/[^/]+?)(?:\.git)?$', remote) + if m: + return m.group(1) + except Exception: + pass + return "" + + +def poll_all() -> None: + write_state("noetica", poll_noetica()) + write_state("ci", poll_ci()) + write_state("pr", poll_prs()) + write_state("board", poll_board()) + + +def print_status() -> None: + for name in ("noetica", "ci", "pr", "board"): + d = read_state(name) + if d: + age = "" + ts = d.get("ts", "") + if ts: + try: + t = datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")) + s = int((datetime.datetime.now(datetime.timezone.utc) - t).total_seconds()) + age = f" ({s}s ago)" + except Exception: + pass + print(f" {name:<10} {json.dumps({k: v for k, v in d.items() if k != 'ts'})}{age}") + else: + print(f" {name:<10} (no data)") + + +def main() -> None: + args = sys.argv[1:] + + if "--status" in args: + print_status() + return + + if "--once" in args: + poll_all() + print("polled once") + return + + # Daemon loop + while True: + try: + poll_all() + except Exception as e: + pass # never crash the daemon + time.sleep(POLL_INTERVAL) + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-voice-capture b/assets/sourceos/bin/turtle-voice-capture new file mode 100755 index 00000000000..99faf2e1d40 --- /dev/null +++ b/assets/sourceos/bin/turtle-voice-capture @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""turtle-voice-capture — record voice note → transcribe → memory mesh + Goose Notes. + +Records microphone audio, transcribes via Whisper (API or local), then pushes +the transcript to turtle-capture (mesh + ~/notes/). + +Requirements (any one path): + • brew install sox — recording + • brew install ffmpeg — recording fallback + • OPENAI_API_KEY set — cloud Whisper transcription (fastest) + • brew install whisper-cpp — local transcription fallback + +Usage: + turtle-voice-capture # record until Ctrl+C, then transcribe + turtle-voice-capture --max 30 # max 30s + turtle-voice-capture --title "Meeting notes" + turtle-voice-capture --once # non-interactive (used from keybinding) + +Output: + Prints transcript, then appends to memory mesh via turtle-capture. +""" +from __future__ import annotations + +import argparse +import datetime +import json +import os +import shutil +import signal +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from urllib import request as urlreq + +MESH_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "memory-mesh" +NOTES_DIR = Path(os.getenv("GOOSE_NOTES_DIR", str(Path.home() / "notes"))) +GOOSE_API = os.getenv("GOOSE_NOTES_API", "http://localhost:8765") +NOETICA_URL= os.getenv("NOETICA_URL", "http://localhost:7700") +OPENAI_KEY = os.getenv("OPENAI_API_KEY", "") + +C_RESET = "\033[0m" +C_BOLD = "\033[1m" +C_TEAL = "\033[38;2;63;185;80m" +C_BLUE = "\033[38;2;88;166;255m" +C_ORANGE = "\033[38;2;255;123;114m" +C_GREY = "\033[38;2;139;148;158m" +C_WHITE = "\033[38;2;230;237;243m" + + +def now_iso() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + + +def slugify(s: str) -> str: + import re + s = re.sub(r"[^\w\s-]", "", s.lower().strip()) + return re.sub(r"[\s_-]+", "-", s)[:60] + + +# ── recording ───────────────────────────────────────────────────────────────── + +def record_audio(out_path: Path, max_secs: int = 120) -> bool: + """Record audio to out_path (wav). Returns True on success.""" + # macOS built-in: afrecord + if shutil.which("sox"): + cmd = ["sox", "-d", str(out_path), "trim", "0", str(max_secs), + "silence", "1", "0.1", "3%", "1", "3.0", "3%"] + try: + print(f" {C_TEAL}●{C_RESET} Recording… {C_GREY}(speak now, stops on 3s silence or Ctrl+C){C_RESET}", flush=True) + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + proc.wait() + return out_path.exists() and out_path.stat().st_size > 1000 + except KeyboardInterrupt: + proc.terminate() + return out_path.exists() and out_path.stat().st_size > 1000 + except Exception as e: + print(f"sox error: {e}", file=sys.stderr) + + # ffmpeg fallback + if shutil.which("ffmpeg"): + cmd = ["ffmpeg", "-y", "-f", "avfoundation", "-i", ":0", + "-t", str(max_secs), str(out_path)] + try: + print(f" {C_TEAL}●{C_RESET} Recording (ffmpeg)… {C_GREY}Ctrl+C to stop{C_RESET}", flush=True) + proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + proc.wait(timeout=max_secs) + except (KeyboardInterrupt, subprocess.TimeoutExpired): + proc.terminate() + return out_path.exists() and out_path.stat().st_size > 1000 + + print(f"{C_ORANGE}No audio recorder found.{C_RESET} Install sox: brew install sox", file=sys.stderr) + return False + + +# ── transcription ───────────────────────────────────────────────────────────── + +def transcribe_openai(audio_path: Path) -> str: + """POST to OpenAI Whisper API. Requires OPENAI_API_KEY.""" + if not OPENAI_KEY: + return "" + try: + boundary = "----TurtleVoice" + audio_data = audio_path.read_bytes() + body = ( + f"--{boundary}\r\n" + f'Content-Disposition: form-data; name="file"; filename="{audio_path.name}"\r\n' + f"Content-Type: audio/wav\r\n\r\n" + ).encode() + audio_data + ( + f"\r\n--{boundary}\r\n" + f'Content-Disposition: form-data; name="model"\r\n\r\n' + f"whisper-1\r\n--{boundary}--\r\n" + ).encode() + + req = urlreq.Request( + "https://api.openai.com/v1/audio/transcriptions", + data=body, + headers={ + "Authorization": f"Bearer {OPENAI_KEY}", + "Content-Type": f"multipart/form-data; boundary={boundary}", + }, + ) + with urlreq.urlopen(req, timeout=60) as r: + result = json.load(r) + return result.get("text", "").strip() + except Exception as e: + print(f"OpenAI Whisper error: {e}", file=sys.stderr) + return "" + + +def transcribe_local(audio_path: Path) -> str: + """Use local whisper-cpp or whisper CLI.""" + for cmd_name in ["whisper-cpp", "whisper", "whisper.cpp"]: + if shutil.which(cmd_name): + try: + out = subprocess.check_output( + [cmd_name, str(audio_path), "--output-txt", "--model", "base"], + timeout=120, text=True, stderr=subprocess.DEVNULL, + ) + # whisper outputs .txt file alongside audio + txt_path = audio_path.with_suffix(".txt") + if txt_path.exists(): + return txt_path.read_text().strip() + return out.strip() + except Exception as e: + print(f"{cmd_name} error: {e}", file=sys.stderr) + + # Try faster-whisper / whisperx Python packages + try: + import faster_whisper # type: ignore + model = faster_whisper.WhisperModel("base", device="cpu", compute_type="int8") + segments, _ = model.transcribe(str(audio_path)) + return " ".join(s.text for s in segments).strip() + except Exception: + pass + + return "" + + +def transcribe(audio_path: Path) -> str: + print(f" {C_BLUE}◆{C_RESET} Transcribing…", flush=True) + # Cloud first (fastest), local fallback + result = transcribe_openai(audio_path) if OPENAI_KEY else "" + if not result: + result = transcribe_local(audio_path) + return result + + +# ── push to mesh + Goose Notes ───────────────────────────────────────────────── + +def push_to_mesh(title: str, transcript: str) -> None: + ts = now_iso() + MESH_DIR.mkdir(parents=True, exist_ok=True) + ctx = MESH_DIR / "context.jsonl" + ev = { + "ts": ts, + "kind": "voice-note", + "source": "turtle-voice", + "title": title, + "content": transcript, + } + with ctx.open("a") as fh: + fh.write(json.dumps(ev) + "\n") + + # Update active context + try: + import socket + (MESH_DIR / "active.json").write_text(json.dumps({ + "cwd": os.getcwd(), + "branch": "", + "title": f"Voice note: {title}", + "hostname": socket.gethostname(), + "updated": ts, + }, indent=2)) + except Exception: + pass + + +def push_to_notes(title: str, transcript: str) -> None: + NOTES_DIR.mkdir(parents=True, exist_ok=True) + date = datetime.date.today().isoformat() + slug = slugify(title) + path = NOTES_DIR / f"{date}-{slug}.md" + front = f"""--- +title: "{title}" +date: {now_iso()} +source: voice +tags: [voice, turtle-term] +--- + +""" + path.write_text(front + transcript + "\n") + + try: + payload = json.dumps({ + "title": title, + "body": transcript, + "tags": ["voice", "turtle-term"], + "source_type": "voice", + }).encode() + req = urlreq.Request(f"{GOOSE_API}/api/notes", data=payload, + headers={"Content-Type": "application/json"}) + urlreq.urlopen(req, timeout=2) + except Exception: + pass # Goose Notes may not be running + + +def auto_title(transcript: str) -> str: + """Ask Noetica for a concise title. Fallback = first 60 chars.""" + try: + payload = json.dumps({ + "messages": [{"role": "user", + "content": f"Give a concise 5-word title (no quotes) for this note: {transcript[:300]}"}], + "max_tokens": 20, + }).encode() + req = urlreq.Request(f"{NOETICA_URL}/api/chat", data=payload, + headers={"Content-Type": "application/json"}) + with urlreq.urlopen(req, timeout=3) as r: + resp = json.load(r) + content = (resp.get("choices", [{}])[0].get("message", {}).get("content", "") + or resp.get("content", "")) + if content: + return content.strip()[:60] + except Exception: + pass + return transcript[:60].replace("\n", " ").rstrip() + + +# ── main ────────────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser(description="TurtleTerm voice note capture") + parser.add_argument("--max", type=int, default=120, help="max recording seconds") + parser.add_argument("--title", default="", help="override auto-title") + parser.add_argument("--once", action="store_true", help="non-interactive mode") + args = parser.parse_args() + + print(f"\n {C_BLUE}{C_BOLD}◆ Voice Capture{C_RESET} {C_GREY}Press Ctrl+C when done speaking{C_RESET}\n") + + with tempfile.TemporaryDirectory() as tmp: + audio_path = Path(tmp) / "voice.wav" + + if not record_audio(audio_path, args.max): + print(f"\n{C_ORANGE}Recording failed or no audio captured.{C_RESET}", file=sys.stderr) + sys.exit(1) + + transcript = transcribe(audio_path) + + if not transcript: + print(f"\n{C_ORANGE}No transcript produced.{C_RESET} Is a Whisper backend available?", file=sys.stderr) + print(f" Set OPENAI_API_KEY or: brew install sox && pip install faster-whisper", file=sys.stderr) + sys.exit(1) + + title = args.title or auto_title(transcript) + + print(f"\n {C_TEAL}✓{C_RESET} {C_WHITE}{C_BOLD}{title}{C_RESET}") + print(f"\n{C_WHITE}{transcript}{C_RESET}\n") + + push_to_mesh(title, transcript) + push_to_notes(title, transcript) + + print(f" {C_GREY}→ saved to memory mesh + ~/notes/{datetime.date.today()}-{slugify(title)}.md{C_RESET}\n") + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/launchd/com.sourceos.turtle-mesh-push.plist b/assets/sourceos/launchd/com.sourceos.turtle-mesh-push.plist new file mode 100644 index 00000000000..aff5f166a6b --- /dev/null +++ b/assets/sourceos/launchd/com.sourceos.turtle-mesh-push.plist @@ -0,0 +1,36 @@ + + + + + Label + com.sourceos.turtle-mesh-push + + ProgramArguments + + /usr/bin/python3 + TURTLE_BIN_DIR/turtle-mesh-push + + + + StartInterval + 300 + + RunAtLoad + + + StandardOutPath + /tmp/turtle-mesh-push.log + + StandardErrorPath + /tmp/turtle-mesh-push.err + + EnvironmentVariables + + HOME + HOME_DIR + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + + diff --git a/assets/sourceos/launchd/com.sourceos.turtle-mesh-serve.plist b/assets/sourceos/launchd/com.sourceos.turtle-mesh-serve.plist new file mode 100644 index 00000000000..b160201b197 --- /dev/null +++ b/assets/sourceos/launchd/com.sourceos.turtle-mesh-serve.plist @@ -0,0 +1,37 @@ + + + + + Label + com.sourceos.turtle-mesh-serve + + ProgramArguments + + /usr/bin/python3 + TURTLE_BIN_DIR/turtle-mesh-serve + --port + 7788 + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /tmp/turtle-mesh-serve.log + + StandardErrorPath + /tmp/turtle-mesh-serve.err + + EnvironmentVariables + + HOME + HOME_DIR + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + + diff --git a/assets/sourceos/shell/turtle-shell-init.zsh b/assets/sourceos/shell/turtle-shell-init.zsh index f66a59577d4..a5b415c42b3 100644 --- a/assets/sourceos/shell/turtle-shell-init.zsh +++ b/assets/sourceos/shell/turtle-shell-init.zsh @@ -10,6 +10,42 @@ if [[ -z "${SOURCEOS_TERMINAL_SESSION_ID:-}" ]]; then export SOURCEOS_TERMINAL_SESSION_ID="term-$(python3 -c 'import uuid; print(uuid.uuid4().hex)' 2>/dev/null || date +%s)" fi +# Auto-start turtle-status-daemon if not already running (writes CI/PR/Noetica cache) +if [[ -z "${_TURTLE_STATUS_DAEMON_STARTED:-}" ]]; then + _TURTLE_STATUS_DAEMON_STARTED=1 + _turtle_status_daemon_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-status-daemon" + if [[ -x "$_turtle_status_daemon_bin" ]]; then + if ! pgrep -f "turtle-status-daemon" >/dev/null 2>&1; then + python3 "$_turtle_status_daemon_bin" &! 2>/dev/null + fi + fi + unset _turtle_status_daemon_bin +fi + +# Auto-start BearBrowser ↔ mesh bridge (syncs browse events into memory mesh) +if [[ -z "${_TURTLE_BB_BRIDGE_STARTED:-}" ]]; then + _TURTLE_BB_BRIDGE_STARTED=1 + _bb_bridge="$HOME/dev/BearBrowser/scripts/bearbrowser-mesh-bridge.py" + if [[ -f "$_bb_bridge" ]]; then + if ! pgrep -f "bearbrowser-mesh-bridge" >/dev/null 2>&1; then + python3 "$_bb_bridge" --watch &! 2>/dev/null + fi + fi + unset _bb_bridge +fi + +# Auto-start Goose Notes ↔ mesh bridge (syncs notes bidirectionally) +if [[ -z "${_TURTLE_GOOSE_BRIDGE_STARTED:-}" ]]; then + _TURTLE_GOOSE_BRIDGE_STARTED=1 + _goose_bridge="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-goose-bridge" + if [[ -x "$_goose_bridge" ]]; then + if ! pgrep -f "turtle-goose-bridge" >/dev/null 2>&1; then + python3 "$_goose_bridge" --watch &! 2>/dev/null + fi + fi + unset _goose_bridge +fi + export SOURCEOS_TERMINAL_FRONTEND="${SOURCEOS_TERMINAL_FRONTEND:-turtle-term}" export SOURCEOS_WORKSPACE="${SOURCEOS_WORKSPACE:-default}" @@ -55,6 +91,249 @@ EOF fi } +# ============================================================ +# |? semantic pipe operator — pipe any output through Noetica +# with active mesh context automatically injected. +# Usage: git log | ? "which commits touched auth" +# docker ps | ? "which containers are unhealthy" +# kubectl get pods | ? "which are not ready and why" +# ============================================================ + +_turtle_noetica_query() { + # Low-level: send a prompt to Noetica, print the response. + # Args: $1=noetica URL $2=prompt string + local _noetica="$1" _prompt="$2" + python3 -c " +import json, urllib.request, sys +noetica, prompt = sys.argv[1], sys.argv[2] +payload = json.dumps({'messages':[{'role':'user','content':prompt}],'stream':False}).encode() +try: + req = urllib.request.Request(noetica+'/api/chat', data=payload, + headers={'Content-Type':'application/json'}) + with urllib.request.urlopen(req, timeout=10) as r: + d = json.load(r) + msg = (d.get('choices',[{}])[0].get('message',{}).get('content','') + or d.get('message',{}).get('content','') + or d.get('content','')) + print(msg.strip()) +except Exception as e: + print(f'(Noetica unreachable: {e})', file=sys.stderr) + sys.exit(1) +" "$_noetica" "$_prompt" 2>&1 +} + +_turtle_active_context_snippet() { + # Returns a 1-3 line context string from active.json (cwd/branch/title). + local _mesh_dir="${XDG_STATE_HOME:-$HOME/.local/state}/sourceos/memory-mesh" + python3 -c " +import json, pathlib +active = pathlib.Path('$_mesh_dir/active.json') +if active.exists(): + try: + d = json.loads(active.read_text()) + parts = [] + if d.get('cwd'): parts.append('cwd: ' + d['cwd']) + if d.get('branch'): parts.append('branch: ' + d['branch']) + if d.get('title'): parts.append('context: ' + d['title']) + print('\n'.join(parts)) + except: pass +" 2>/dev/null +} + +\?() { + local _query="$*" + local _noetica="${NOETICA_URL:-http://localhost:7700}" + local _stdin_data _ctx + _stdin_data="$(cat)" + + if [[ -z "$_query" ]]; then + printf '%s\n' "$_stdin_data" + return + fi + + _ctx="$(_turtle_active_context_snippet)" + local _ctx_block="" + [[ -n "$_ctx" ]] && _ctx_block=$'\n\nShell context:\n'"$_ctx" + + local _prompt="Given this command output: + +${_stdin_data:0:3000}${_ctx_block} + +Answer: ${_query} + +Be concise (1-4 lines)." + + printf '\e[38;2;57;197;207m▍\e[0m ' + local _result + _result="$(_turtle_noetica_query "$_noetica" "$_prompt" 2>/dev/null)" + + if [[ -n "$_result" ]]; then + printf '\e[38;2;230;237;243m%s\e[0m\n' "$_result" + else + printf '\e[2m(no response — is Noetica running on %s?)\e[0m\n' "$_noetica" >&2 + fi +} + +# ============================================================ +# noe — direct Noetica query (no pipe needed) +# Injects active mesh context automatically. +# Usage: noe "explain this error: ECONNREFUSED" +# noe cap "what was that kubectl command I ran?" +# ============================================================ +noe() { + local _query="$*" + local _noetica="${NOETICA_URL:-http://localhost:7700}" + + if [[ -z "$_query" ]]; then + echo "Usage: noe " >&2 + echo " noe cap — ask about the last capture" >&2 + return 1 + fi + + # 'noe cap' — include last capture/note from mesh in context + if [[ "${1:-}" == "cap" ]]; then + shift + _query="$*" + local _mesh_dir="${XDG_STATE_HOME:-$HOME/.local/state}/sourceos/memory-mesh" + local _last_cap + _last_cap="$(python3 -c " +import json, pathlib +ctx = pathlib.Path('$_mesh_dir/context.jsonl') +if ctx.exists(): + for line in reversed(ctx.read_text(errors='replace').splitlines()): + try: + ev = json.loads(line) + if ev.get('kind') in ('capture','note','shell-cmd'): + print('# ' + ev.get('title','') + '\n' + ev.get('content','')[:800]) + break + except: pass +" 2>/dev/null)" + [[ -n "$_last_cap" ]] && _query="Context:\n${_last_cap}\n\nQuestion: ${_query}" + fi + + local _ctx; _ctx="$(_turtle_active_context_snippet)" + [[ -n "$_ctx" ]] && _query="${_query} + +Shell context: +${_ctx}" + + printf '\e[38;2;57;197;207m▍ Noetica\e[0m\n' + local _result + _result="$(_turtle_noetica_query "$_noetica" "$_query")" + if [[ -n "$_result" ]]; then + printf '\e[38;2;230;237;243m%s\e[0m\n\n' "$_result" + else + printf '\e[2m(no response from Noetica at %s)\e[0m\n' "$_noetica" >&2 + fi +} + +# ============================================================ +# Destructive command preview gate +# Extends the existing pattern check with actionable preview +# before the command runs. Requires explicit 'y' to continue. +# Disable with TURTLE_PREVIEW_GATE=0. +# ============================================================ +_turtle_destructive_patterns=( + '^rm[[:space:]].*-[a-zA-Z]*r[a-zA-Z]*[[:space:]]' + '^git[[:space:]]+push[[:space:]].*--force' + '^git[[:space:]]+push[[:space:]].*-f[[:space:]]' + '^git[[:space:]]+reset[[:space:]]--hard' + '^kubectl[[:space:]]+delete[[:space:]]' + '^terraform[[:space:]]+destroy' + '^helm[[:space:]]+uninstall' + '^docker[[:space:]]+rm[[:space:]]' + '^gcloud.*instances[[:space:]]+delete' + '^gsutil[[:space:]]+rm[[:space:]]' +) + +_turtle_preview_gate() { + [[ "${TURTLE_PREVIEW_GATE:-1}" == "0" ]] && return 0 + local cmd="$1" + local matched=0 + for pat in "${_turtle_destructive_patterns[@]}"; do + if [[ "$cmd" =~ $pat ]]; then + matched=1; break + fi + done + (( matched )) || return 0 + + printf '\e[38;2;255;123;114m⚠ Destructive command detected\e[0m\n' >&2 + printf '\e[2m %s\e[0m\n' "$cmd" >&2 + + # Show contextual preview + if [[ "$cmd" =~ '^rm ' ]]; then + local target + target="$(echo "$cmd" | grep -oE '[^[:space:]]+$')" + if [[ -e "$target" ]]; then + printf '\e[2m will delete: %s (%s items)\e[0m\n' "$target" \ + "$(find "$target" 2>/dev/null | wc -l | tr -d ' ')" >&2 + fi + elif [[ "$cmd" =~ '^git push.*--force' || "$cmd" =~ '^git push.*-f ' ]]; then + local ahead + ahead="$(git log --oneline @{u}..HEAD 2>/dev/null | wc -l | tr -d ' ')" + printf '\e[2m will force-push %s commits\e[0m\n' "$ahead" >&2 + fi + + printf '\e[38;2;255;123;114mProceed? [y/N] \e[0m' >&2 + local yn + read -r yn &2 + # Return non-zero to signal preexec should reject (zsh doesn't support + # blocking from preexec cleanly, so we clear the buffer via a trap) + return 1 +} + +# ============================================================ +# Auto-generated zsh completions for unknown turtle-*/prophet-* CLIs +# On first invocation: runs `cmd --help`, asks Noetica to generate +# a _cmd completion function, caches to ~/.turtle/completions/. +# ============================================================ +_TURTLE_COMP_DIR="${HOME}/.turtle/completions" + +_turtle_maybe_generate_completion() { + local cmd="$1" + # Only for our own toolchain + [[ "$cmd" =~ ^(turtle|prophet|goose|bearbrowser|sourceos) ]] || return 0 + local comp_file="${_TURTLE_COMP_DIR}/_${cmd}" + [[ -f "$comp_file" ]] && return 0 # already generated + # Run async so it never blocks the prompt + ( + local help_text + help_text="$("$cmd" --help 2>&1 | head -60)" + [[ -z "$help_text" ]] && exit 0 + local _noetica="${NOETICA_URL:-http://localhost:7700}" + local generated + generated="$(python3 -c " +import json, urllib.request, sys +cmd, help_text = sys.argv[1], sys.argv[2] +prompt = ( + f'Generate a minimal zsh _arguments completion function for the CLI tool \`{cmd}\`.\n' + f'Help output:\n{help_text}\n\n' + f'Output ONLY valid zsh code starting with \"#compdef {cmd}\" and ending with the function body. ' + f'No explanation, no markdown fences.' +) +payload = json.dumps({'messages':[{'role':'user','content':prompt}],'stream':False}).encode() +try: + req = urllib.request.Request('${_noetica}/api/chat', data=payload, + headers={'Content-Type':'application/json'}) + with urllib.request.urlopen(req, timeout=10) as r: + d = json.load(r) + print((d.get('message',{}).get('content','') or d.get('content','')).strip()) +except Exception: + pass +" "$cmd" "$help_text" 2>/dev/null)" + if [[ "$generated" == "#compdef"* ]]; then + mkdir -p "${_TURTLE_COMP_DIR}" + printf '%s\n' "$generated" > "$comp_file" + # Add to fpath if not already there + [[ " ${fpath[*]} " == *" ${_TURTLE_COMP_DIR} "* ]] || \ + fpath=("${_TURTLE_COMP_DIR}" "${fpath[@]}") + fi + ) &! +} + _TURTLE_ZSH_CMD="" _TURTLE_ZSH_STARTED_AT="" _TURTLE_ZSH_CMD_EPOCH=0 @@ -83,9 +362,16 @@ preexec() { # OSC 133 C — command output start (marks command start for prompt jumping) printf '\e]133;C\a' - # Dangerous pattern check + # Dangerous pattern check (warning only) _turtle_check_dangerous "$cmd" + # Destructive preview gate (requires explicit y for rm/force-push/destroy/etc.) + _turtle_preview_gate "$cmd" + + # Auto-generate completions for unknown turtle-*/prophet-* tools (async, non-blocking) + local _first_word="${cmd%% *}" + _turtle_maybe_generate_completion "$_first_word" + local writer writer="$(_turtle_writer)" local event_id="evt_$(python3 -c 'import uuid; print(uuid.uuid4().hex)' 2>/dev/null || echo "0")" @@ -257,9 +543,19 @@ bindkey '^R' _turtle_history_widget bindkey '\er' history-incremental-search-backward # ============================================================ -# Memory mesh: ?? recall, tc capture, mission control +# Memory mesh: ?? recall, tc capture, ctx, mission control # ============================================================ +# ctx — pretty snapshot of current SourceOS context (mesh+CI+BB) +# Example: ctx # full panel +# ctx --short # single-line summary for copy-paste into AI prompts +ctx() { + local _ctx_bin + _ctx_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-context" + [[ -x "$_ctx_bin" ]] || _ctx_bin="turtle-context" + "$_ctx_bin" "$@" +} + # ?? — quick memory recall from any prompt # Example: ?? postgres replica setup ??() { @@ -278,6 +574,16 @@ tc() { "$_turtle_capture_bin" "$@" } +# tcv — voice note capture: record mic → Whisper → mesh + Goose Notes +# Usage: tcv # record until silence/Ctrl+C +# tcv --title "standup" # override auto-title +tcv() { + local _voice_bin + _voice_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-voice-capture" + [[ -x "$_voice_bin" ]] || _voice_bin="turtle-voice-capture" + "$_voice_bin" "$@" +} + # mc — toggle mission control panel (TurtleTerm only; graceful no-op elsewhere) _turtle_mc() { local _mc_bin @@ -307,6 +613,297 @@ if (( ${chpwd_functions[(I)_turtle_mesh_cd]} == 0 )); then chpwd_functions+=(_turtle_mesh_cd) fi +# ============================================================ +# Inline file rendering +# ============================================================ + +_turtle_render_bin() { + local _bin + _bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-render" + [[ -x "$_bin" ]] && echo "$_bin" && return + echo "turtle-render" +} + +# smart cat: auto-renders images/PDF/CSV/JSON inline; falls through to system cat otherwise. +# Disable with TURTLE_SMART_CAT=0. +_TURTLE_IMAGE_EXTS=(png jpg jpeg gif webp bmp ico tiff tif avif svg) +_TURTLE_RENDER_EXTS=(pdf csv tsv json md markdown) + +cat() { + if [[ "${TURTLE_SMART_CAT:-1}" == "0" ]]; then + command cat "$@" + return + fi + local visual=0 + for arg in "$@"; do + [[ "$arg" == -* ]] && continue + local ext="${arg:l:e}" # lowercase extension + if (( ${_TURTLE_IMAGE_EXTS[(I)$ext]} )) || (( ${_TURTLE_RENDER_EXTS[(I)$ext]} )); then + visual=1 + break + fi + done + if (( visual )); then + local _rbin; _rbin="$(_turtle_render_bin)" + local render_args=() + for arg in "$@"; do + [[ "$arg" == -* ]] && { command cat "$@"; return; } + render_args+=("$arg") + done + "$_rbin" "${render_args[@]}" + else + command cat "$@" + fi +} + +# lsi — ls with inline image thumbnails (width=18 cols each) +lsi() { + local dir="${1:-.}" + local _rbin; _rbin="$(_turtle_render_bin)" + local found=0 + for f in "$dir"/*.{png,jpg,jpeg,gif,webp,bmp,tiff,svg}(N); do + [[ -f "$f" ]] || continue + found=1 + printf "\033[38;2;88;166;255m%s\033[0m\n" "$(basename "$f")" + "$_rbin" --width 18 "$f" 2>/dev/null + done + (( found )) || ls "$dir" +} + +# vv — visual view: always renders regardless of file type (explicit alias) +vv() { + local _rbin; _rbin="$(_turtle_render_bin)" + "$_rbin" "$@" +} + +# ============================================================ +# mesh — open local mesh dashboard in BearBrowser (or default browser) +# starts turtle-mesh-serve if not running +# Usage: mesh +# ============================================================ +mesh() { + local _port="${TURTLE_MESH_PORT:-7788}" + local _bin + _bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-mesh-serve" + [[ -x "$_bin" ]] || _bin="turtle-mesh-serve" + + if ! curl -s --max-time 1 "http://localhost:${_port}/api/state" >/dev/null 2>&1; then + echo " ◆ Starting mesh dashboard on :${_port}…" + python3 "$_bin" --port "$_port" &! 2>/dev/null + sleep 1 + fi + bb "http://localhost:${_port}" 2>/dev/null || open "http://localhost:${_port}" +} + +# ============================================================ +# glog — semantic git log with Noetica-generated one-liners +# Falls back to a pretty git log if Noetica unreachable. +# Usage: glog # last 20 commits +# glog -n 5 # last 5 +# ============================================================ +glog() { + local _n=20 + [[ "${1:-}" == "-n" ]] && { _n="${2:-20}"; shift 2 2>/dev/null ||: ; } + + local _noetica="${NOETICA_URL:-http://localhost:7700}" + local _log + _log="$(git log --oneline --no-decorate -"$_n" 2>/dev/null)" || { + echo "Not a git repo." >&2; return 1 + } + + if [[ -z "$_log" ]]; then + echo "(no commits)" + return + fi + + # Try Noetica summary + local _summary + _summary="$(TURTLE_LOG="$_log" TURTLE_NOETICA="$_noetica" python3 -c " +import json, os +from urllib import request as urlreq +log = os.environ['TURTLE_LOG'] +noetica = os.environ['TURTLE_NOETICA'] +try: + payload = json.dumps({'messages': [{'role': 'user', + 'content': 'For each git commit below, add a 4-6 word plain-English annotation on the same line after \" — \". Output ONLY the annotated lines, one per input line, no extra text.\n\n' + log}], + 'max_tokens': 800}).encode() + req = urlreq.Request(noetica + '/api/chat', data=payload, + headers={'Content-Type': 'application/json'}) + with urlreq.urlopen(req, timeout=8) as r: + resp = json.load(r) + msg = (resp.get('choices',[{}])[0].get('message',{}).get('content','') + or resp.get('content','')) + print(msg.strip()) +except Exception: + print('') +" 2>/dev/null)" + + if [[ -n "$_summary" ]]; then + # Color: hash teal, rest white, annotation dim + printf '%s\n' "$_summary" | while IFS= read -r line; do + local hash="${line:0:7}" + local rest="${line:8}" + local msg="${rest%% — *}" + local ann="" + [[ "$rest" == *" — "* ]] && ann="${rest#* — }" + printf '\e[38;2;63;185;80m%s\e[0m \e[38;2;230;237;243m%-50s\e[0m \e[2m%s\e[0m\n' \ + "$hash" "${msg:0:50}" "$ann" + done + else + # Plain pretty fallback + git log --oneline --color -"$_n" + fi +} + +# ============================================================ +# td / turtle-diff — render git diff with syntax highlighting +# In WezTerm: renders in a right split panel. +# Falls back to delta/diff-so-fancy/bat if available. +# Usage: td # diff HEAD +# td HEAD~3 # diff from 3 commits ago +# td --cached # diff staged changes +# td file.py # diff a single file +# ============================================================ +td() { + local _args=("$@") + local _diff + + # Check for delta (best) / diff-so-fancy / bat + if command -v delta >/dev/null 2>&1; then + GIT_PAGER="delta" git diff "${_args[@]}" + return + fi + + if command -v diff-so-fancy >/dev/null 2>&1; then + git diff --color "${_args[@]}" | diff-so-fancy | less -RFX + return + fi + + # bat with diff syntax + if command -v bat >/dev/null 2>&1; then + git diff --color=always "${_args[@]}" | bat --language=diff --style=plain --color=always --paging=never + return + fi + + # Plain fallback with color + git diff --color=always "${_args[@]}" +} + +# ============================================================ +# bb — open BearBrowser (optionally with a URL or file path) +# emits a mesh event so TurtleTerm knows what you're browsing +# bbs — BearBrowser search: summarize query via Noetica, then open BB +# Usage: bb +# bb https://example.com +# bb path/to/file.pdf +# bbs "how does turbulent flow work in microchannels" +# ============================================================ + +bb() { + local _url="${1:-}" + local _mesh_dir="${XDG_STATE_HOME:-$HOME/.local/state}/sourceos/memory-mesh" + local _bb_scripts="$HOME/dev/BearBrowser/scripts" + local _branch; _branch="$(git branch --show-current 2>/dev/null || echo '')" + + # Emit mesh event — pass values via env to avoid shell→Python injection + TURTLE_URL="$_url" TURTLE_MESH_DIR="$_mesh_dir" \ + python3 -c " +import json, datetime, os, pathlib +url = os.environ.get('TURTLE_URL', '') +mesh = pathlib.Path(os.environ['TURTLE_MESH_DIR']) +mesh.mkdir(parents=True, exist_ok=True) +ctx = mesh / 'context.jsonl' +ev = { + 'ts': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00','Z'), + 'kind': 'browse', + 'source': 'shell-bb', + 'title': url or 'BearBrowser opened', + 'content': ('User opened BearBrowser at ' + url) if url else 'User opened BearBrowser', + 'url': url, + 'cwd': os.getcwd(), +} +with ctx.open('a') as f: + f.write(json.dumps(ev) + '\n') +" 2>/dev/null &! + + # Update active context + TURTLE_URL="$_url" TURTLE_BRANCH="$_branch" TURTLE_MESH_DIR="$_mesh_dir" \ + python3 -c " +import json, datetime, os, pathlib, socket +url = os.environ.get('TURTLE_URL', '') +branch = os.environ.get('TURTLE_BRANCH', '') +mesh = pathlib.Path(os.environ['TURTLE_MESH_DIR']) +mesh.mkdir(parents=True, exist_ok=True) +(mesh / 'active.json').write_text(json.dumps({ + 'cwd': os.getcwd(), + 'branch': branch, + 'title': ('BearBrowser: ' + url) if url else 'BearBrowser', + 'hostname': socket.gethostname(), + 'updated': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00','Z'), +}, indent=2)) +" 2>/dev/null &! + + if [[ -n "$_url" ]] && [[ -f "$_url" ]]; then + # Local file — convert to file:// URL + _url="file://$(realpath "$_url")" + fi + + if [[ -x "$_bb_scripts/bearbrowser-open.sh" ]]; then + if [[ -n "$_url" ]]; then + bash "$_bb_scripts/bearbrowser-open.sh" && open -a BearBrowser "$_url" 2>/dev/null ||: + else + bash "$_bb_scripts/bearbrowser-open.sh" + fi + else + if [[ -n "$_url" ]]; then + open -a BearBrowser "$_url" 2>/dev/null || open "$_url" + else + open -a BearBrowser 2>/dev/null || echo "BearBrowser not found — run bearbrowser-open.sh" >&2 + fi + fi +} + +bbs() { + local _query="$*" + if [[ -z "$_query" ]]; then + echo "Usage: bbs " >&2 + return 1 + fi + + local _noetica="${NOETICA_URL:-http://localhost:7700}" + local _url + + # Ask Noetica for the best URL — pass query via env to avoid injection + _url="$(TURTLE_QUERY="$_query" TURTLE_NOETICA="$_noetica" python3 -c " +import json, os +from urllib import request as urlreq +query = os.environ['TURTLE_QUERY'] +noetica = os.environ['TURTLE_NOETICA'] +try: + payload = json.dumps({'messages': [{'role': 'user', + 'content': 'Reply with ONLY a single search URL (no explanation) for: ' + query}], + 'max_tokens': 80}).encode() + req = urlreq.Request(noetica + '/api/chat', data=payload, + headers={'Content-Type': 'application/json'}) + with urlreq.urlopen(req, timeout=4) as r: + resp = json.load(r) + print(resp.get('choices',[{}])[0].get('message',{}).get('content','').strip()) +except Exception: + print('') +" 2>/dev/null)" + + if [[ -z "$_url" ]] || [[ "$_url" != http* ]]; then + # Fallback: DuckDuckGo — pass query via env + local _encoded; _encoded="$(TURTLE_QUERY="$_query" python3 -c " +import urllib.parse, os; print(urllib.parse.quote_plus(os.environ['TURTLE_QUERY'])) +" 2>/dev/null || printf '%s' "$_query")" + _url="https://duckduckgo.com/?q=${_encoded}&ia=web" + fi + + echo " \033[38;2;0;200;200mBearBrowser → ${_url}\033[0m" + bb "$_url" +} + # ============================================================ # AI ghost-text — two modes: # 1. Explicit: ALT+/ or CTRL+SPACE fires immediately (synchronous) @@ -726,37 +1323,362 @@ if (( ${precmd_functions[(I)_turtle_precmd_timing]} == 0 )); then fi # ============================================================ -# Right-side prompt (RPROMPT) showing plan step + perf +# Rich RPROMPT — duration · git diff · peak RSS · exit code # ============================================================ -# Right-side prompt: active plan step + last command time +_TURTLE_RUSAGE_BEFORE=0 +_TURTLE_LAST_RSS=0 + +# Read resource.RUSAGE_CHILDREN (peak RSS of last waited child) from Python. +# Called in precmd — reads accumulated RSS since last call, so we delta it. +_turtle_read_rss() { + python3 -c " +import resource +r = resource.getrusage(resource.RUSAGE_CHILDREN) +# On macOS ru_maxrss is bytes; on Linux it's KB +import sys, platform +rss = r.ru_maxrss +if platform.system() == 'Darwin': + rss = rss // 1024 # → KB +print(rss) +" 2>/dev/null || echo 0 +} + +# Compact git diff stat: "+12 -3" or "" if clean +_turtle_git_diffstat() { + local stat + stat="$(git diff --shortstat 2>/dev/null)" + [[ -z "$stat" ]] && return + local added removed + added="$(echo "$stat" | grep -oE '[0-9]+ insertion' | grep -oE '[0-9]+')" + removed="$(echo "$stat" | grep -oE '[0-9]+ deletion' | grep -oE '[0-9]+')" + local out="" + [[ -n "$added" ]] && out="+${added}" + [[ -n "$removed" ]] && out="${out:+$out }−${removed}" + [[ -n "$out" ]] && printf '%s' "$out" +} + _turtle_rprompt() { local rp="" - # Show active plan step if any (cached, don't call agentd every prompt) + setopt localoptions nopromptsubst + + # Plan step if [[ -n "$_TURTLE_PLAN_STEP" ]]; then rp="%F{yellow}⟳ step ${_TURTLE_PLAN_STEP}%f " fi - # Show last command time if > 1s + + # Duration (> 1s) if [[ -n "$_TURTLE_LAST_ELAPSED" ]] && (( _TURTLE_LAST_ELAPSED > 1000 )); then local secs=$(( _TURTLE_LAST_ELAPSED / 1000 )) - rp="${rp}%F{240}${secs}s%f" + if (( secs >= 3600 )); then + rp="${rp}%F{240}$(( secs/3600 ))h$(( (secs%3600)/60 ))m%f " + elif (( secs >= 60 )); then + rp="${rp}%F{240}$(( secs/60 ))m$(( secs%60 ))s%f " + else + rp="${rp}%F{240}${secs}s%f " + fi + fi + + # Git diff stats (green/red) — only shown when there are uncommitted changes + local _diffstat + _diffstat="$(_turtle_git_diffstat 2>/dev/null)" + if [[ -n "$_diffstat" ]]; then + # colour each part + local _added _removed + _added="$(echo "$_diffstat" | grep -oE '\+[0-9]+')" + _removed="$(echo "$_diffstat" | grep -oE '−[0-9]+')" + [[ -n "$_added" ]] && rp="${rp}%F{2}${_added}%f " + [[ -n "$_removed" ]] && rp="${rp}%F{1}${_removed}%f " fi - echo -n "$rp" + + # Peak RSS of last command (shown when >50MB) + if (( _TURTLE_LAST_RSS > 51200 )); then + local mb=$(( _TURTLE_LAST_RSS / 1024 )) + rp="${rp}%F{240}${mb}MB%f " + fi + + # Trailing space trim + echo -n "${rp% }" } -# Track elapsed for RPROMPT _turtle_precmd_rprompt() { + # Capture elapsed if [[ -n "$_TURTLE_PERF_START" ]]; then _TURTLE_LAST_ELAPSED=$(( int(($EPOCHREALTIME - $_TURTLE_PERF_START) * 1000) )) fi + # Capture peak RSS delta (async — don't block the prompt) + { + local _rss_now; _rss_now="$(_turtle_read_rss)" + local _delta=$(( _rss_now - _TURTLE_RUSAGE_BEFORE )) + (( _delta > 0 )) && _TURTLE_LAST_RSS=$_delta || _TURTLE_LAST_RSS=0 + _TURTLE_RUSAGE_BEFORE=$_rss_now + } &! } if (( ${precmd_functions[(I)_turtle_precmd_rprompt]} == 0 )); then precmd_functions+=(_turtle_precmd_rprompt) fi -# Set RPROMPT if user hasn't set it if [[ -z "$RPROMPT" ]]; then RPROMPT='$(_turtle_rprompt)' setopt PROMPT_SUBST 2>/dev/null fi + +# ============================================================ +# wasi — cross-machine "where was I" +# Reads memory mesh events from other hostnames (via local mesh +# or GCS if available). Shows last cwd, branch, commands, agents. +# ============================================================ +wasi() { + local _mesh_dir="${XDG_STATE_HOME:-$HOME/.local/state}/sourceos/memory-mesh" + local _ctx_file="$_mesh_dir/context.jsonl" + local _this_host; _this_host="$(hostname -s 2>/dev/null || hostname)" + local _noetica="${NOETICA_URL:-http://localhost:7700}" + local _gcs="${SOURCEOS_MESH_BUCKET:-gs://sourceos-artifacts-socioprophet/memory-mesh}" + + printf '\e[38;2;88;166;255m◆ wasi — where was I\e[0m\n' + printf '\e[2m this machine: %s\e[0m\n\n' "$_this_host" + + # Try to pull from GCS first (gets other machines' context) + if command -v gsutil >/dev/null 2>&1; then + local _remote_ctx + _remote_ctx="$(gsutil cat "$_gcs/context.jsonl" 2>/dev/null | tail -200)" + if [[ -n "$_remote_ctx" ]]; then + echo "$_remote_ctx" | python3 -c " +import json, sys, os +this_host = os.uname().nodename.split('.')[0] +events = [] +for line in sys.stdin: + try: + e = json.loads(line) + h = e.get('hostname','') + if h and h != this_host: + events.append(e) + except: pass +if not events: + print(' \033[2mno events from other machines\033[0m') + sys.exit(0) +# Group by hostname +from collections import defaultdict +by_host = defaultdict(list) +for e in events: + by_host[e['hostname']].append(e) +for host, evs in sorted(by_host.items()): + print(f'\n \033[38;2;188;140;255m{host}\033[0m') + for ev in evs[-5:]: + ts = ev.get('ts','')[:16] + kind = ev.get('kind','?') + title = ev.get('title','')[:60] + cwd = ev.get('cwd','') + branch = ev.get('branch','') + loc = f'{cwd}' + (f' ({branch})' if branch else '') + print(f' \033[2m{ts}\033[0m \033[38;2;63;185;80m{kind:<10}\033[0m {title}') + if loc: + print(f' \033[2m{loc}\033[0m') +" + return + fi + fi + + # Fallback: local mesh (same machine, recent context for continuity) + if [[ -f "$_ctx_file" ]]; then + tail -30 "$_ctx_file" | python3 -c " +import json, sys +events = [] +for line in sys.stdin: + try: events.append(json.loads(line)) + except: pass +if not events: + print(' \033[2mno mesh events yet — run: some-command | tc\033[0m') + sys.exit(0) +print(' \033[2m(local mesh — no GCS credentials for cross-machine)\033[0m') +for ev in reversed(events[-8:]): + ts = ev.get('ts','')[:16] + kind = ev.get('kind','?') + title = ev.get('title','')[:60] + print(f' \033[2m{ts}\033[0m \033[38;2;63;185;80m{kind:<10}\033[0m {title}') +" + else + printf ' \e[2mno memory mesh yet — source turtle-shell-init.zsh and run: tc "first capture"\e[0m\n' + fi +} + +# ============================================================ +# Auto project env injection on cd +# Infers venv/nvm/kube-context/poetry from project files. +# Each activation is silent unless it changes something. +# Disable entirely: TURTLE_ENV_INJECT=0 +# ============================================================ +_TURTLE_LAST_VENV="" +_TURTLE_LAST_NODE="" +_TURTLE_LAST_KUBE="" + +_turtle_env_inject() { + [[ "${TURTLE_ENV_INJECT:-1}" == "0" ]] && return + + # ── Python venv ──────────────────────────────────────────── + local _venv="" + if [[ -d ".venv" ]]; then + _venv=".venv" + elif [[ -d "venv" ]]; then + _venv="venv" + elif [[ -f "pyproject.toml" ]] || [[ -f "setup.py" ]]; then + # poetry-managed — try `poetry env info -p` + if command -v poetry >/dev/null 2>&1; then + local _pe; _pe="$(poetry env info -p 2>/dev/null)" + [[ -n "$_pe" && -d "$_pe" ]] && _venv="$_pe" + fi + fi + if [[ -n "$_venv" && "$_venv" != "$_TURTLE_LAST_VENV" ]]; then + source "$_venv/bin/activate" 2>/dev/null && \ + printf '\e[2m ⚡ venv: %s\e[0m\n' "$_venv" + _TURTLE_LAST_VENV="$_venv" + elif [[ -z "$_venv" && -n "$_TURTLE_LAST_VENV" && -n "$VIRTUAL_ENV" ]]; then + deactivate 2>/dev/null || true + _TURTLE_LAST_VENV="" + fi + + # ── Node version (.nvmrc / .node-version) ───────────────── + if command -v nvm >/dev/null 2>&1; then + local _nvmrc="" + [[ -f ".nvmrc" ]] && _nvmrc="$(cat .nvmrc | tr -d '[:space:]')" + [[ -f ".node-version" ]] && _nvmrc="$(cat .node-version | tr -d '[:space:]')" + if [[ -n "$_nvmrc" && "$_nvmrc" != "$_TURTLE_LAST_NODE" ]]; then + nvm use "$_nvmrc" --silent 2>/dev/null && \ + printf '\e[2m ⚡ node: %s\e[0m\n' "$_nvmrc" + _TURTLE_LAST_NODE="$_nvmrc" + fi + fi + + # ── Kubernetes context (from .kube-context file) ─────────── + if command -v kubectl >/dev/null 2>&1 && [[ -f ".kube-context" ]]; then + local _kctx; _kctx="$(cat .kube-context | tr -d '[:space:]')" + if [[ -n "$_kctx" && "$_kctx" != "$_TURTLE_LAST_KUBE" ]]; then + kubectl config use-context "$_kctx" >/dev/null 2>&1 && \ + printf '\e[2m ⚡ kube: %s\e[0m\n' "$_kctx" + _TURTLE_LAST_KUBE="$_kctx" + fi + fi + + # ── direnv fallback ──────────────────────────────────────── + if command -v direnv >/dev/null 2>&1 && [[ -f ".envrc" ]]; then + direnv export zsh 2>/dev/null | source /dev/stdin 2>/dev/null || true + fi +} + +# Hook env injection after _turtle_mesh_cd +if (( ${chpwd_functions[(I)_turtle_env_inject]} == 0 )); then + chpwd_functions+=(_turtle_env_inject) +fi + +# ============================================================ +# session-save / session-restore — named workspace snapshots +# Persists: cwd, last 20 commands, env vars, git branch, agents +# Stored in memory mesh so cross-machine restore works via wasi. +# ============================================================ +session-save() { + local name="${1:-$(basename "$PWD")}" + local _mesh_dir="${XDG_STATE_HOME:-$HOME/.local/state}/sourceos/memory-mesh" + local _sessions_dir="$_mesh_dir/sessions" + mkdir -p "$_sessions_dir" + + local _branch; _branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')" + local _cmds=() + local _state_dir; _state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/sourceos/terminal" + local _hist_file="$_state_dir/history.jsonl" + + # Collect last 20 commands from JSONL history + if [[ -f "$_hist_file" ]]; then + maparray -t _cmds < <(tail -20 "$_hist_file" | python3 -c " +import json,sys +for line in sys.stdin: + try: print(json.loads(line).get('cmd','')) + except: pass +" 2>/dev/null) + fi + + # Env snapshot (filter out secrets) + local _env_json + _env_json="$(python3 -c " +import os, json +skip = {'HISTFILE','HISTSIZE','LS_COLORS','TERM_SESSION_ID','TMPDIR','LOGNAME'} +env = {k:v for k,v in os.environ.items() + if not any(s in k for s in ('SECRET','TOKEN','KEY','PASS','CRED')) + and k not in skip and len(v) < 200} +print(json.dumps(env)) +" 2>/dev/null || echo '{}')" + + local _session + _session="$(python3 -c " +import json, datetime, os, socket +data = { + 'name': '$name', + 'ts': datetime.datetime.utcnow().isoformat()+'Z', + 'hostname': socket.gethostname(), + 'cwd': os.getcwd(), + 'branch': '$_branch', + 'commands': $(python3 -c "import json,sys; print(json.dumps(sys.argv[1:]))" "${_cmds[@]}" 2>/dev/null || echo '[]'), + 'env': $(_env_json), +} +print(json.dumps(data, indent=2)) +" 2>/dev/null)" + + printf '%s\n' "$_session" > "$_sessions_dir/${name}.json" + + # Also write to mesh context + local _ts; _ts="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf '{"ts":"%s","kind":"session-save","source":"terminal","title":"%s","cwd":"%s","branch":"%s"}\n' \ + "$_ts" "$name" "$PWD" "$_branch" >> "$_mesh_dir/context.jsonl" + + printf '\e[38;2;63;185;80m✓\e[0m session saved: \e[1m%s\e[0m\n' "$name" + printf ' \e[2m%s\e[0m\n' "$_sessions_dir/${name}.json" +} + +session-restore() { + local name="${1}" + local _mesh_dir="${XDG_STATE_HOME:-$HOME/.local/state}/sourceos/memory-mesh" + local _sessions_dir="$_mesh_dir/sessions" + + if [[ -z "$name" ]]; then + # List available sessions + printf '\e[38;2;88;166;255m◆ Saved sessions:\e[0m\n' + for f in "$_sessions_dir"/*.json(N); do + local _n; _n="$(basename "$f" .json)" + local _ts; _ts="$(python3 -c "import json; d=json.load(open('$f')); print(d.get('ts','?')[:16])" 2>/dev/null)" + local _cwd; _cwd="$(python3 -c "import json; d=json.load(open('$f')); print(d.get('cwd','?'))" 2>/dev/null)" + printf ' \e[1m%-20s\e[0m \e[2m%s %s\e[0m\n' "$_n" "$_ts" "$_cwd" + done + return + fi + + local _file="$_sessions_dir/${name}.json" + if [[ ! -f "$_file" ]]; then + printf '\e[38;2;255;123;114m✗\e[0m no session: %s\n' "$name" >&2 + return 1 + fi + + python3 - "$_file" <<'PYEOF' +import json, sys +d = json.load(open(sys.argv[1])) +print(f"\033[38;2;88;166;255m◆ Restoring: {d.get('name','?')}\033[0m") +print(f" cwd: {d.get('cwd','?')}") +print(f" branch: {d.get('branch','?')}") +print(f" saved: {d.get('ts','?')[:16]} on {d.get('hostname','?')}") +cwd = d.get('cwd','') +branch = d.get('branch','') +print(f"\n \033[2mcd {cwd}\033[0m") +if branch: + print(f" \033[2mgit checkout {branch} (if available)\033[0m") +PYEOF + + local _cwd; _cwd="$(python3 -c "import json; print(json.load(open('$_file')).get('cwd',''))" 2>/dev/null)" + local _branch; _branch="$(python3 -c "import json; print(json.load(open('$_file')).get('branch',''))" 2>/dev/null)" + + [[ -n "$_cwd" && -d "$_cwd" ]] && cd "$_cwd" || printf ' \e[2m(cwd no longer exists)\e[0m\n' + if [[ -n "$_branch" ]]; then + git checkout "$_branch" 2>/dev/null && \ + printf ' \e[38;2;63;185;80m✓\e[0m branch: %s\n' "$_branch" || true + fi + + printf '\n\e[38;2;63;185;80m✓\e[0m session restored: \e[1m%s\e[0m\n' "$name" +} diff --git a/assets/sourceos/turtleterm.lua b/assets/sourceos/turtleterm.lua index 220d6b8d9be..a93820556ed 100644 --- a/assets/sourceos/turtleterm.lua +++ b/assets/sourceos/turtleterm.lua @@ -928,63 +928,77 @@ function turtle_ssh_picker(window, pane) ) end --- Feature: CTRL+SHIFT+P — preview file (image via imgcat, code via bat/cat) +-- Feature: CTRL+SHIFT+P — smart inline file render (images/PDF/CSV/JSON/code) +-- Uses turtle-render: images via iTerm2 protocol, PDFs, CSV tables, syntax highlight. local function turtle_preview_file() return wezterm.action_callback(function(window, pane) + local render_bin = turtle_bin('turtle-render') + + local function spawn_render(w, p, path) + w:perform_action( + act.SpawnCommandInNewPane { + direction = 'Right', size = { Percent = 50 }, + command = { args = { + 'sh', '-c', + 'python3 ' .. wezterm.shell_quote_arg(render_bin) .. ' ' .. wezterm.shell_quote_arg(path) + .. '; printf "\\n\\033[2m press Enter to close \\033[0m"; read -r _', + }}, + }, p + ) + w:toast_notification('TurtleTerm Render', path:match('[^/]+$') or path, nil, 2000) + end + -- 1. Try selection local path = '' local sel = window:get_selection_text_for_pane(pane) - if sel and sel:match('^[%w_/%.%-~]+$') and #sel < 300 then + if sel and sel:match('^[%w_/%.%-~%.]+$') and #sel < 400 then path = sel:gsub('^%s+', ''):gsub('%s+$', '') end - -- 2. Try input zone + + -- 2. Try last output zone — scan for file path patterns if path == '' then pcall(function() local zones = pane:get_semantic_zones() for _, z in ipairs(zones) do - if z.semantic_type == 'Input' then - local t = pane:get_text_from_semantic_zone(z) - if t then path = t:gsub('^%s+', ''):gsub('%s+$', '') end + if z.semantic_type == 'Output' then + local text = pane:get_text_from_semantic_zone(z) or '' + -- Match absolute or relative paths with known visual extensions + local m = text:match('([%w_/%.%-~]+%.%a+)') + if m and m:match('%.(png|jpg|jpeg|gif|webp|bmp|svg|pdf|csv|json|md)$') then + path = m + end end end end) end - local image_exts = { png=1, jpg=1, jpeg=1, gif=1, bmp=1, webp=1, svg=1, tiff=1, tif=1, ico=1 } - local ext = path:lower():match('%.([a-z]+)$') or '' - + -- 3. Try input zone if path == '' then - -- 3. Prompt - window:perform_action(act.PromptInputLine { - description = '🐢 Preview file: enter path', - action = wezterm.action_callback(function(w2, p2, line) - if line and line ~= '' then - local ext2 = line:lower():match('%.([a-z]+)$') or '' - local is_img = image_exts[ext2] ~= nil - w2:perform_action(act.SpawnCommandInNewPane { - direction = 'Right', size = { Percent = 50 }, - command = { args = is_img - and {'sh', '-c', 'imgcat ' .. line .. '; printf "\\npress Enter..."; read -r _'} - or {'sh', '-c', 'bat --paging=never ' .. line .. ' 2>/dev/null || cat ' .. line .. '; printf "\\npress Enter..."; read -r _'} - }, - }, p2) + pcall(function() + local zones = pane:get_semantic_zones() + for _, z in ipairs(zones) do + if z.semantic_type == 'Input' then + local t = pane:get_text_from_semantic_zone(z) + if t then path = t:gsub('^%s+', ''):gsub('%s+$', ''):match('[^%s]+$') or '' end end - end), - }, pane) + end + end) + end + + if path ~= '' then + spawn_render(window, pane, path) return end - local is_img = image_exts[ext] ~= nil - window:perform_action( - act.SpawnCommandInNewPane { - direction = 'Right', size = { Percent = 50 }, - command = { args = is_img - and {'sh', '-c', 'imgcat ' .. path .. ' 2>/dev/null; printf "\\npress Enter..."; read -r _'} - or {'sh', '-c', 'bat --paging=never ' .. path .. ' 2>/dev/null || cat ' .. path .. '; printf "\\npress Enter..."; read -r _'} - }, - }, pane - ) - window:toast_notification('TurtleTerm Preview', path, nil, 2000) + -- 4. Prompt + window:perform_action(act.PromptInputLine { + description = '◆ Render file: enter path (image/PDF/CSV/JSON/code)', + action = wezterm.action_callback(function(w2, p2, line) + if line and line ~= '' then + spawn_render(w2, p2, line) + end + end), + }, pane) end) end @@ -1398,7 +1412,7 @@ local PALETTE_COMMANDS = { -- Navigation { label = '🔍 History fuzzy search CTRL+R', id = 'history_search' }, { label = '🔍 Search output CTRL+SHIFT+F', id = 'search_output' }, - { label = '👁 Preview file (bat/imgcat) CTRL+SHIFT+P', id = 'preview' }, + { label = '👁 Render file (img/PDF/CSV/JSON) CTRL+SHIFT+P', id = 'preview' }, { label = '🗺 Atlas context CTRL+SHIFT+A', id = 'atlas_context' }, -- Workflows { label = '⚙ Browse workflows CTRL+SHIFT+W', id = 'workflows' }, @@ -1419,9 +1433,18 @@ local PALETTE_COMMANDS = { { label = '📂 Restore workspace CMD+SHIFT+O', id = 'workspace_restore' }, -- Memory mesh / cross-app integration { label = '◆ Mission Control (agents) CMD+SHIFT+M', id = 'mission_control' }, + { label = '◆ Context snapshot (ctx) CMD+SHIFT+X', id = 'context_snapshot' }, + { label = '◆ Voice note (tcv) CMD+SHIFT+V', id = 'voice_capture' }, { label = '◆ Capture to Goose Notes CMD+SHIFT+C', id = 'capture' }, { label = '◆ Memory Mesh Recall CMD+SHIFT+L', id = 'recall' }, { label = '◆ Sync mesh to GCS CMD+SHIFT+U', id = 'mesh_push' }, + { label = '🌐 Open in BearBrowser CMD+SHIFT+B', id = 'bb_open' }, + { label = '◆ Mesh dashboard (browser) —', id = 'mesh_dashboard' }, + -- Shell utilities + { label = '🖼 Image gallery (current dir) CMD+SHIFT+G', id = 'gallery' }, + { label = '⎇ Semantic git log (glog) —', id = 'glog' }, + { label = '⎇ Git diff highlight (td) —', id = 'tdiff' }, + { label = '⏱ Resource usage (last cmd) —', id = 'rss_info' }, } local function turtle_command_palette() @@ -1509,9 +1532,15 @@ local function turtle_command_palette() workspace_save = turtle_workspace_save(), workspace_restore = turtle_workspace_restore(), mission_control = turtle_mission_control(), + context_snapshot = act.SendString('ctx\n'), + voice_capture = act.SendString('tcv\n'), capture = turtle_capture_selection(), recall = turtle_recall(), mesh_push = turtle_mesh_push(), + bb_open = act.SendString('bb \n'), + mesh_dashboard = act.SendString('mesh\n'), + glog = act.SendString('glog\n'), + tdiff = act.SendString('td\n'), } local a = dispatch[id] if a then w:perform_action(a, p) end @@ -2109,11 +2138,29 @@ config.keys = { { key = 'h', mods = 'CTRL|SHIFT', action = wezterm.action_callback(function(w, p) turtle_ssh_picker(w, p) end) }, { key = 's', mods = 'CMD|SHIFT', action = turtle_workspace_save() }, -- Save workspace { key = 'o', mods = 'CMD|SHIFT', action = turtle_workspace_restore() }, -- Restore workspace - -- Memory mesh integration + -- Memory mesh + cross-product integration { key = 'm', mods = 'CMD|SHIFT', action = turtle_mission_control() }, -- Mission Control panel + { key = 'x', mods = 'CMD|SHIFT', action = act.SendString('ctx\n') }, -- Context snapshot + { key = 'v', mods = 'CMD|SHIFT', action = act.SendString('tcv\n') }, -- Voice note capture { key = 'c', mods = 'CMD|SHIFT', action = turtle_capture_selection() }, -- Capture to Goose Notes { key = 'l', mods = 'CMD|SHIFT', action = turtle_recall() }, -- Memory mesh recall { key = 'u', mods = 'CMD|SHIFT', action = turtle_mesh_push() }, -- Sync mesh to GCS + { key = 'b', mods = 'CMD|SHIFT', action = act.SendString('bb ') }, -- Open in BearBrowser + -- Image gallery (lsi equivalent from WezTerm) + { key = 'g', mods = 'CMD|SHIFT', action = wezterm.action_callback(function(w, p) + local cwd = '' + pcall(function() cwd = tostring(p.current_working_dir):gsub('file://[^/]*','') end) + if cwd == '' then cwd = os.getenv('HOME') or '.' end + w:perform_action(act.SpawnCommandInNewPane { + direction = 'Right', size = { Percent = 55 }, + command = { args = { 'sh', '-c', + 'python3 ' .. wezterm.shell_quote_arg(turtle_bin('turtle-render')) + .. ' --width 22 ' .. wezterm.shell_quote_arg(cwd) .. '/*.{png,jpg,jpeg,gif,webp,bmp,svg} 2>/dev/null' + .. ' || echo "no images in this directory"; echo; printf "press Enter..."; read -r _' + }}, + }, p) + w:toast_notification('TurtleTerm Gallery', cwd, nil, 2000) + end) }, { key = 's', mods = 'CTRL|SHIFT', @@ -2583,6 +2630,39 @@ local function count_agents() return 0 end +-- ── status bar state reader ─────────────────────────────────────────────────── +-- Reads cached JSON files written by turtle-status-daemon (polls every 30s). +-- All reads are synchronous file I/O — no network calls in the event loop. +local _status_cache = {} +local _status_cache_at = 0 + +local function read_status_cache() + local now = os.time() + if now - _status_cache_at < 5 then return _status_cache end -- reuse for 5s + local home = os.getenv('HOME') or '' + local base = home .. '/.local/state/sourceos/status/' + local cache = {} + for _, name in ipairs({'noetica', 'ci', 'pr', 'board'}) do + local f = io.open(base .. name .. '.json', 'r') + if f then + local raw = f:read('*a'); f:close() + local ok2, data = pcall(wezterm.json_parse, raw) + if ok2 and data then cache[name] = data end + end + end + _status_cache = cache + _status_cache_at = now + return cache +end + +local function ci_icon(status, conclusion) + if conclusion == 'success' then return '\xe2\x9c\x93 ' end -- ✓ + if conclusion == 'failure' or conclusion == 'error' then return '\xe2\x9c\x97 ' end -- ✗ + if status == 'in_progress' or status == 'running' then return '\xe2\x9f\xb3 ' end -- ⟳ + if status == 'queued' then return '\xc2\xb7 ' end -- · + return '' +end + wezterm.on('update-right-status', function(window, pane) local domain = turtle_domain() local proc = '' @@ -2591,6 +2671,8 @@ wezterm.on('update-right-status', function(window, pane) pcall(function() cwd_uri = tostring(pane.current_working_dir) or '' end) local is_ssh = proc:find('ssh') ~= nil or cwd_uri:find('^ssh://') ~= nil + local sc = read_status_cache() + -- Plan step badge local plan_badge = '' do @@ -2603,7 +2685,7 @@ wezterm.on('update-right-status', function(window, pane) if ok2 and plan and plan.steps then local step = (plan.current_step or 0) + 1 local total = #plan.steps - local goal = (plan.goal or ''):sub(1, 24) + local goal = (plan.goal or ''):sub(1, 20) if step <= total then plan_badge = string.format('\xe2\x9a\xa1 %s [%d/%d] ', goal, step, total) end @@ -2611,11 +2693,38 @@ wezterm.on('update-right-status', function(window, pane) end end + -- CI badge (from daemon cache) + local ci_part = '' + if sc.ci and (sc.ci.status or '') ~= '' then + local icon = ci_icon(sc.ci.status or '', sc.ci.conclusion or '') + if icon ~= '' then + ci_part = 'CI ' .. icon .. ' ' + end + end + + -- PR count badge + local pr_part = '' + if sc.pr and (sc.pr.count or 0) > 0 then + pr_part = string.format('\xe2\x9c\xa7 %d PR ', sc.pr.count) -- ✧ N PR + end + + -- Noetica health dot + local noe_part = '' + if sc.noetica then + noe_part = sc.noetica.ok and '\xe2\x97\x8f ' or '\xe2\x97\x8b ' -- ● or ○ + end + + -- Board score (if recent — within 6h) + local board_part = '' + if sc.board and (sc.board.score or 0) > 0 then + board_part = string.format('%.1f%% ', sc.board.score) + end + -- Agent count local agent_part = '' local n_agents = count_agents() if n_agents > 0 then - agent_part = string.format('\xf0\x9f\xa4\x96 %d ', n_agents) -- 🤖 N + agent_part = string.format('\xf0\x9f\xa4\x96 %d ', n_agents) end -- Clock @@ -2624,6 +2733,10 @@ wezterm.on('update-right-status', function(window, pane) -- Build right status local parts = {} if plan_badge ~= '' then table.insert(parts, plan_badge) end + if noe_part ~= '' then table.insert(parts, noe_part) end + if ci_part ~= '' then table.insert(parts, ci_part) end + if pr_part ~= '' then table.insert(parts, pr_part) end + if board_part ~= '' then table.insert(parts, board_part) end if agent_part ~= '' then table.insert(parts, agent_part) end if is_ssh then @@ -2665,21 +2778,45 @@ wezterm.on('update-right-status', function(window, pane) window:set_right_status(table.concat(parts, '')) end) +-- left-status git cache: re-read every 4s max (avoid hammering git on every tick) +local _git_status_cache = { branch='', stat='', at=0, cwd='' } + +local function refresh_git_status(cwd) + local now = os.time() + if cwd == _git_status_cache.cwd and now - _git_status_cache.at < 4 then + return _git_status_cache + end + local branch, stat = '', '' + local ok1, out1, _ = wezterm.run_child_process({ 'git', '-C', cwd, 'branch', '--show-current' }) + if ok1 and out1 then branch = out1:match('([^\n]+)') or '' end + -- shortstat: " 3 files changed, 12 insertions(+), 4 deletions(-)" + local ok2, out2, _ = wezterm.run_child_process({ 'git', '-C', cwd, 'diff', '--shortstat' }) + if ok2 and out2 and out2:match('%S') then + local added = out2:match('(%d+) insertion') or '' + local removed = out2:match('(%d+) deletion') or '' + if added ~= '' then stat = stat .. '+' .. added end + if removed ~= '' then + stat = stat .. (stat ~= '' and ' ' or '') .. '\xe2\x88\x92' .. removed -- − + end + end + _git_status_cache = { branch=branch, stat=stat, at=now, cwd=cwd } + return _git_status_cache +end + wezterm.on('update-left-status', function(window, pane) local parts = {} - -- Git branch from pane cwd + -- Git branch + diff stats from pane cwd if pane and pane.current_working_dir then local cwd = tostring(pane.current_working_dir):gsub('file://[^/]*', '') if cwd ~= '' then - local ok, stdout, _ = wezterm.run_child_process({ - 'git', '-C', cwd, 'branch', '--show-current', - }) - if ok and stdout and stdout:match('%S') then - local branch = stdout:match('([^\n]+)') - if branch then - table.insert(parts, ' \xe2\x8e\x87 ' .. branch) -- ⎇ UTF-8 + local gs = refresh_git_status(cwd) + if gs.branch ~= '' then + local branch_part = ' \xe2\x8e\x87 ' .. gs.branch -- ⎇ + if gs.stat ~= '' then + branch_part = branch_part .. ' \xe2\x80\x8b' .. gs.stat -- zero-width + stat end + table.insert(parts, branch_part) end end end diff --git a/packaging/PREFLIGHT.md b/packaging/PREFLIGHT.md new file mode 100644 index 00000000000..a03b0e56c37 --- /dev/null +++ b/packaging/PREFLIGHT.md @@ -0,0 +1,42 @@ +# Preflight — make a local "green" mean what CI's green means + +```sh +make preflight # or: bash packaging/scripts/preflight.sh +``` + +## Why this exists + +TurtleTerm's packaging is verified in CI by building the **real** `.deb`, +`.rpm`, and `.pkg.tar.zst` and asserting against the actual artifacts. It is +easy, on a laptop, to run a quick static check — grep a file, run a Python test +that inspects source — see it pass, and believe the packages are good. They are +not the same signal. A static grep can pass while the real `rpmbuild` fails on +unpackaged files, or a verifier crashes on an undefined variable, or a package +name has drifted. (This bit us: the fix that greened packaging was preceded by a +local suite reporting `12/12 pass` while the real build jobs were red.) + +`preflight` closes that gap. It runs the **same gates CI runs**, in the same +way, and tells you honestly which ones actually executed. + +## What you get + +- **Every CI packaging verifier**, run locally where the host can: the layout + and Arch-metadata checks always run; the deb/rpm/arch **real-build** verifiers + run when `dpkg-deb` / `rpmbuild` / `zstd` (+ an x86_64/aarch64 host) are + present, and are otherwise **skipped with the reason stated**. +- A **verdict you can trust**: + - `GREEN — full CI parity` only when nothing was skipped. + - `PARTIAL` when a real-build verifier was skipped — so a laptop pass is never + mistaken for the full CI signal. Get full parity on a Linux host with the + packaging tools installed (`apt-get install dpkg-dev rpm zstd`) or rely on + the CI packaging workflows. + - `FAILED` (non-zero exit) if any gate that ran failed. +- A **gate-drift self-check**: preflight parses the packaging workflows and + fails if CI runs a `verify-*.sh` that preflight doesn't — so the local gate + set cannot silently fall behind what CI enforces. + +## The rule + +If you touched anything under `packaging/`, `assets/sourceos/`, or the packaging +workflows, run `make preflight` before you push. If it says `PARTIAL`, the +real-build verifiers for the skipped formats only run in CI — watch them there. diff --git a/packaging/scripts/preflight.sh b/packaging/scripts/preflight.sh new file mode 100755 index 00000000000..fce3800c39c --- /dev/null +++ b/packaging/scripts/preflight.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# preflight.sh — run the SAME real gates CI runs, locally, and say honestly +# which ran and which were skipped. The point is that a local "green" means the +# same thing as a CI "green": it builds and verifies the actual packages, not a +# static grep of files that can lie (see the packaging retrospective, RC-2). +# +# Exit non-zero if any gate that RAN failed, OR if the local gate set has +# drifted from what CI enforces (a CI verifier this script doesn't know about). +# Skipped gates (missing dpkg-deb/rpmbuild/zstd, or a non-Linux-package host) +# do NOT fail the run, but they DO downgrade the verdict to PARTIAL so nobody +# mistakes a laptop pass for full CI parity. +set -uo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$repo_root" +log_dir="$(mktemp -d)" +trap 'rm -rf "$log_dir"' EXIT + +bold=$'\033[1m'; red=$'\033[31m'; grn=$'\033[32m'; ylw=$'\033[33m'; dim=$'\033[2m'; rst=$'\033[0m' +[ -t 1 ] || { bold=; red=; grn=; ylw=; dim=; rst=; } + +pass=0; fail=0; skip=0 +declare -a failed_gates=() +declare -a skipped_gates=() + +run_gate() { # name, command... + local name="$1"; shift + local log="$log_dir/$(echo "$name" | tr -c 'A-Za-z0-9' '_').log" + if "$@" >"$log" 2>&1; then + printf ' %s✓%s %s\n' "$grn" "$rst" "$name" + pass=$((pass+1)) + else + printf ' %s✗ %s%s\n' "$red" "$name" "$rst" + sed 's/^/ /' "$log" | tail -12 + fail=$((fail+1)); failed_gates+=("$name") + fi +} + +skip_gate() { # name, reason + printf ' %s∅ %s%s %s(skipped: %s)%s\n' "$ylw" "$1" "$rst" "$dim" "$2" "$rst" + skip=$((skip+1)); skipped_gates+=("$1 — $2") +} + +have() { command -v "$1" >/dev/null 2>&1; } +host_arch="$(uname -m)" +arch_buildable=false +{ have zstd && { [ "$host_arch" = x86_64 ] || [ "$host_arch" = aarch64 ]; }; } && arch_buildable=true + +echo "${bold}TurtleTerm preflight — the real CI gates, locally${rst}" + +# ── Gate set drift check: CI is the source of truth ───────────────────────── +# Every verify-*.sh a packaging workflow runs must be known here; if CI grows a +# gate this script hasn't adopted, fail loudly instead of giving false parity. +echo "${bold}Gate parity (local set vs CI)${rst}" +known_verifiers="verify-arch-package-metadata.sh verify-arch-package.sh verify-deb-package.sh verify-rpm-package.sh verify-linux-package-layout.sh" +ci_verifiers="$(grep -rhoE 'packaging/scripts/verify-[a-z0-9-]+\.sh' .github/workflows/*.yml 2>/dev/null | xargs -n1 basename | sort -u)" +drift=0 +for v in $ci_verifiers; do + case " $known_verifiers " in + *" $v "*) ;; + *) printf ' %s✗ CI runs %s but preflight does not — gate drift%s\n' "$red" "$v" "$rst"; drift=1 ;; + esac +done +[ "$drift" -eq 0 ] && printf ' %s✓%s preflight covers every CI packaging verifier\n' "$grn" "$rst" + +# ── Python tests: EXACTLY the set CI runs (derived from the workflows) ─────── +# Deriving from the workflows keeps parity honest — preflight can't run more or +# fewer tests than CI, so a pytest-only helper that isn't a CI gate never trips +# a false failure, and a test CI adds is picked up automatically. +echo "${bold}Python suite (exactly the tests CI runs)${rst}" +ci_tests="$(grep -rhoE 'assets/sourceos/tests/test_[a-z0-9_]+\.py' .github/workflows/*.yml 2>/dev/null | sort -u)" +py_fail=0; py_ran=0 +for t in $ci_tests; do + [ -f "$t" ] || continue + py_ran=$((py_ran+1)) + if ! python3 "$t" >"$log_dir/py.log" 2>&1; then + printf ' %s✗ %s%s\n' "$red" "$(basename "$t")" "$rst"; sed 's/^/ /' "$log_dir/py.log" | tail -8 + py_fail=$((py_fail+1)) + fi +done +if [ "$py_fail" -eq 0 ]; then printf ' %s✓%s %s CI-gated python tests pass\n' "$grn" "$rst" "$py_ran"; pass=$((pass+1)) +else fail=$((fail+1)); failed_gates+=("python-suite ($py_fail failing)"); fi + +# ── Shell verifiers (real artifact builds) ────────────────────────────────── +echo "${bold}Package verifiers${rst}" +run_gate "verify-linux-package-layout.sh" bash packaging/scripts/verify-linux-package-layout.sh +run_gate "verify-arch-package-metadata.sh" bash packaging/scripts/verify-arch-package-metadata.sh + +if have dpkg-deb; then run_gate "verify-deb-package.sh (builds real .deb)" bash packaging/scripts/verify-deb-package.sh +else skip_gate "verify-deb-package.sh" "dpkg-deb not installed"; fi + +if have rpmbuild; then run_gate "verify-rpm-package.sh (builds real .rpm)" bash packaging/scripts/verify-rpm-package.sh +else skip_gate "verify-rpm-package.sh" "rpmbuild not installed"; fi + +if $arch_buildable; then run_gate "verify-arch-package.sh (builds real .pkg.tar.zst)" bash packaging/scripts/verify-arch-package.sh +else skip_gate "verify-arch-package.sh" "needs zstd + x86_64/aarch64 host (have: $host_arch)"; fi + +# ── Verdict ───────────────────────────────────────────────────────────────── +echo +if [ "$fail" -ne 0 ] || [ "$drift" -ne 0 ]; then + echo "${bold}${red}PREFLIGHT FAILED${rst} — ${fail} gate(s) failed$( [ "$drift" -ne 0 ] && echo ", gate drift detected")" + for g in "${failed_gates[@]:-}"; do [ -n "$g" ] && echo " ${red}·${rst} $g"; done + exit 1 +fi +if [ "$skip" -ne 0 ]; then + echo "${bold}${ylw}PREFLIGHT PARTIAL${rst} — ${pass} ran, ${skip} skipped. ${dim}Not full CI parity; the real deb/rpm/arch builds run in CI (or a Linux host with dpkg-deb/rpmbuild/zstd).${rst}" + for g in "${skipped_gates[@]:-}"; do [ -n "$g" ] && echo " ${ylw}·${rst} $g"; done + exit 0 +fi +echo "${bold}${grn}PREFLIGHT GREEN — full CI parity${rst} (${pass} gates, nothing skipped)" +exit 0