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 —
+ —
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+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