diff --git a/assets/sourceos/bin/turtle-mesh-serve b/assets/sourceos/bin/turtle-mesh-serve index b18afecb8af..d2c29d4b01b 100755 --- a/assets/sourceos/bin/turtle-mesh-serve +++ b/assets/sourceos/bin/turtle-mesh-serve @@ -32,7 +32,8 @@ 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" +BB_SUPPORT = Path.home() / "Library" / "Application Support" / "BearBrowser" +BACKLINKS_PATH = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "notes-backlinks.json" DEFAULT_PORT = int(os.getenv("TURTLE_MESH_PORT", "7788")) @@ -95,6 +96,52 @@ def gather_state() -> dict: } +def build_graph() -> dict: + """Read backlinks JSON and return nodes+edges for the graph view.""" + data = load_json(BACKLINKS_PATH) + forward: dict = data.get("forward", {}) + reverse: dict = data.get("reverse", {}) + + # Collect all slug names from both indexes + slugs: set[str] = set() + for slug, targets in forward.items(): + slugs.add(slug) + for t in targets: + slugs.add(t) + for slug, linkers in reverse.items(): + slugs.add(slug) + for lnk in linkers: + slugs.add(lnk) + + # Also pick up any *.md filenames from NOTES_DIR even if not yet indexed + if NOTES_DIR.exists(): + for p in NOTES_DIR.glob("*.md"): + slugs.add(p.stem) + + def to_label(slug: str) -> str: + return slug.replace("-", " ").title() + + nodes = [ + { + "id": slug, + "label": to_label(slug), + "size": len(reverse.get(slug, [])) + 1, + } + for slug in sorted(slugs) + ] + + edges: list[dict] = [] + seen: set[tuple] = set() + for source, targets in forward.items(): + for target in targets: + key = (source, target) + if key not in seen: + seen.add(key) + edges.append({"source": source, "target": target}) + + return {"nodes": nodes, "edges": edges} + + # ── SSE broadcast ───────────────────────────────────────────────────────────── _sse_clients: list[queue.Queue] = [] @@ -174,7 +221,10 @@ h2{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em -

◆ SourceOS Memory Mesh

+
+

◆ SourceOS Memory Mesh

+ ◆ Graph view +
Noetica … CI — @@ -291,6 +341,267 @@ fetch('/api/state').then(r => r.json()).then(render).catch(() => {}) """ +GRAPH_HTML = r""" + + + + +Notes Graph · SourceOS + + + +
+ ← Dashboard +

◆ Notes Graph

+ +
+
+
+ +
loading…
+
+
+

Node

+
Click a node to inspect
+
+
+ + +""" + + class DashHandler(BaseHTTPRequestHandler): def log_message(self, *args): pass # quiet @@ -304,6 +615,24 @@ class DashHandler(BaseHTTPRequestHandler): self.end_headers() self.wfile.write(body) + elif self.path == "/graph": + body = GRAPH_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/graph": + graph = build_graph() + body = json.dumps(graph, 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 == "/api/state": state = gather_state() body = json.dumps(state, default=str).encode() diff --git a/assets/sourceos/bin/turtle-noetica-memory b/assets/sourceos/bin/turtle-noetica-memory new file mode 100755 index 00000000000..b1d6fa43bcb --- /dev/null +++ b/assets/sourceos/bin/turtle-noetica-memory @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Persistent Noetica memory — user facts survive across sessions. + +Stores facts in SQLite at ~/.local/state/sourceos/noetica-memory.db. +Injected as system context into every Noetica query. + +Usage: + turtle-noetica-memory add "I prefer Go over Python for CLI tools" + turtle-noetica-memory list + turtle-noetica-memory remove 3 # by ID + turtle-noetica-memory context # print top facts as system prompt block + turtle-noetica-memory extract "..." # parse a user utterance for saveable facts +""" +from __future__ import annotations + +import datetime +import json +import os +import re +import sqlite3 +import sys +from pathlib import Path +from urllib import request as urlreq + +STATE_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" +DB_PATH = STATE_DIR / "noetica-memory.db" +NOETICA_URL = os.getenv("NOETICA_URL", "http://localhost:7700") +MAX_CONTEXT = int(os.getenv("TURTLE_MEMORY_MAX", "8")) + +# Patterns that signal a "remember this" intent +REMEMBER_RE = re.compile( + r"(?:remember (?:that )?|note (?:that )?|always |i (?:prefer|use|like|want|need)|my |don't |never )", + re.I, +) + + +def _db() -> sqlite3.Connection: + STATE_DIR.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH) + conn.execute("""CREATE TABLE IF NOT EXISTS facts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT NOT NULL, + source TEXT DEFAULT 'manual', + ts TEXT DEFAULT (datetime('now')) + )""") + conn.commit() + return conn + + +def add_fact(content: str, source: str = "manual") -> int: + content = content.strip() + if not content: + return -1 + with _db() as conn: + cur = conn.execute("INSERT INTO facts(content,source) VALUES(?,?)", (content, source)) + conn.commit() + return cur.lastrowid or -1 + + +def list_facts() -> list[dict]: + with _db() as conn: + rows = conn.execute("SELECT id,content,source,ts FROM facts ORDER BY id DESC").fetchall() + return [{"id": r[0], "content": r[1], "source": r[2], "ts": r[3]} for r in rows] + + +def remove_fact(fact_id: int) -> bool: + with _db() as conn: + n = conn.execute("DELETE FROM facts WHERE id=?", (fact_id,)).rowcount + conn.commit() + return n > 0 + + +def context_block(limit: int = MAX_CONTEXT) -> str: + """Return a formatted block of top facts for injection into prompts.""" + with _db() as conn: + rows = conn.execute( + "SELECT content FROM facts ORDER BY id DESC LIMIT ?", (limit,) + ).fetchall() + if not rows: + return "" + facts = "\n".join(f"- {r[0]}" for r in reversed(rows)) + return f"User context (persistent memory):\n{facts}" + + +def extract_from_utterance(text: str) -> list[str]: + """Ask Noetica to extract saveable facts from a user utterance.""" + try: + payload = json.dumps({ + "messages": [{"role": "user", + "content": ( + "Extract any personal preferences, constraints, or facts " + "worth remembering from this text. Reply with a JSON array of " + "strings (each a concise fact), or [] if nothing is worth saving.\n\n" + f"Text: {text[:600]}" + )}], + "max_tokens": 120, + }).encode() + req = urlreq.Request(f"{NOETICA_URL}/api/chat", data=payload, + headers={"Content-Type": "application/json"}) + with urlreq.urlopen(req, timeout=5) as r: + d = json.load(r) + msg = (d.get("choices", [{}])[0].get("message", {}).get("content", "") + or d.get("content", "")) + # Parse JSON array from response + m = re.search(r"\[.*\]", msg, re.DOTALL) + if m: + return json.loads(m.group()) + except Exception: + pass + return [] + + +def should_remember(text: str) -> bool: + return bool(REMEMBER_RE.search(text)) + + +def main() -> None: + args = sys.argv[1:] + if not args: + print(__doc__) + return + + cmd = args[0] + + if cmd == "add": + content = " ".join(args[1:]) + fid = add_fact(content) + print(f" saved fact #{fid}: {content}") + + elif cmd == "list": + facts = list_facts() + if not facts: + print(" (no facts saved)") + for f in facts: + print(f" [{f['id']}] {f['content']} \033[2m({f['source']} · {f['ts'][:10]})\033[0m") + + elif cmd == "remove": + if len(args) < 2: + print("Usage: turtle-noetica-memory remove ", file=sys.stderr) + sys.exit(1) + ok = remove_fact(int(args[1])) + print(f" {'removed' if ok else 'not found'} fact #{args[1]}") + + elif cmd == "context": + block = context_block() + print(block if block else "(no memory)") + + elif cmd == "extract": + text = " ".join(args[1:]) + facts = extract_from_utterance(text) + if not facts: + print(" (nothing extractable)") + for f in facts: + fid = add_fact(f, source="auto-extract") + print(f" saved fact #{fid}: {f}") + + else: + print(f"Unknown command: {cmd}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-noetica-search b/assets/sourceos/bin/turtle-noetica-search new file mode 100755 index 00000000000..c4bb00c4d2d --- /dev/null +++ b/assets/sourceos/bin/turtle-noetica-search @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# MIT License — https://sourceos.io +# turtle-noetica-search — Perplexity-gap closure: web search + Noetica synthesis + inline citations +# Usage: +# turtle-noetica-search "latest AI research on retrieval augmented generation" +# TURTLE_NOETICA=http://localhost:7700 TURTLE_PROMPT="query" python3 turtle-noetica-search +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +from pathlib import Path + + +def _web_search_bin() -> str: + """Resolve the turtle-web-search binary path.""" + sibling = Path(__file__).parent / "turtle-web-search" + if sibling.is_file() and os.access(sibling, os.X_OK): + return str(sibling) + found = shutil.which("turtle-web-search") + if found: + return found + return str(sibling) # will fail with a clear message if missing + + +def _stream_bin() -> str: + """Resolve the turtle-noetica-stream binary path.""" + sibling = Path(__file__).parent / "turtle-noetica-stream" + if sibling.is_file() and os.access(sibling, os.X_OK): + return str(sibling) + found = shutil.which("turtle-noetica-stream") + if found: + return found + return str(sibling) + + +def _fetch_results(query: str, count: int = 5) -> list[dict] | None: + """Run turtle-web-search and return parsed results, or None on failure.""" + ws_bin = _web_search_bin() + try: + import json as _json + proc = subprocess.run( + [sys.executable, ws_bin, "--json", "--count", str(count), query], + capture_output=True, + text=True, + timeout=15, + ) + if proc.returncode != 0: + return None + return _json.loads(proc.stdout) + except Exception: # noqa: BLE001 + return None + + +def _build_grounded_prompt(query: str, results: list[dict]) -> str: + lines = [ + "Answer this question using the web search results below.", + "Include inline citations like [1] [2] referring to the sources.", + "At the end, print a 'Sources:' section listing each cited source with its URL.", + "", + f"Question: {query}", + "", + "Search results:", + ] + for r in results: + lines.append(f"[{r['rank']}] {r['title']} — {r['snippet']}") + lines.append(f" URL: {r['url']}") + lines.append("") + lines.append("Answer (with inline citations):") + return "\n".join(lines) + + +def main() -> None: + # Accept query as CLI arg or via TURTLE_PROMPT env var + if len(sys.argv) > 1: + query = " ".join(sys.argv[1:]) + else: + query = os.environ.get("TURTLE_PROMPT", "").strip() + + if not query: + print("Usage: turtle-noetica-search ", file=sys.stderr) + sys.exit(1) + + noetica = os.environ.get("TURTLE_NOETICA", "http://localhost:7700") + stream_bin = _stream_bin() + + # Step 1: web search + results = _fetch_results(query) + if not results: + print("(no web context — SearXNG unreachable)", file=sys.stderr) + # Fall back: send query directly to Noetica without search context + env = {**os.environ, "TURTLE_NOETICA": noetica, "TURTLE_PROMPT": query, + "TURTLE_MAX_TOKENS": "800"} + subprocess.run([sys.executable, stream_bin], env=env) + return + + # Step 2: build grounded prompt + prompt = _build_grounded_prompt(query, results) + + # Step 3: stream through Noetica with inherited stdout (tokens appear live) + env = { + **os.environ, + "TURTLE_NOETICA": noetica, + "TURTLE_PROMPT": prompt, + "TURTLE_MAX_TOKENS": "800", + } + subprocess.run([sys.executable, stream_bin], env=env) + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-noetica-stream b/assets/sourceos/bin/turtle-noetica-stream new file mode 100755 index 00000000000..37fe812ae97 --- /dev/null +++ b/assets/sourceos/bin/turtle-noetica-stream @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Stream Noetica API response to stdout with real SSE or word-by-word fallback. + +Usage (called by shell functions, not directly): + TURTLE_NOETICA=http://localhost:7700 TURTLE_PROMPT="..." turtle-noetica-stream + TURTLE_NOETICA=... TURTLE_PROMPT="..." turtle-noetica-stream --max-tokens 200 +""" +from __future__ import annotations + +import http.client +import json +import os +import sys +import time +import urllib.parse + +NOETICA_URL = os.environ.get("TURTLE_NOETICA", os.environ.get("NOETICA_URL", "http://localhost:7700")) +PROMPT = os.environ.get("TURTLE_PROMPT", "") +MAX_TOKENS = int(os.environ.get("TURTLE_MAX_TOKENS", "400")) + + +def _stream_sse(resp: http.client.HTTPResponse) -> int: + buf = b"" + printed = False + while True: + try: + chunk = resp.read(256) + except Exception: + break + if not chunk: + break + buf += chunk + while b"\n" in buf: + line, buf = buf.split(b"\n", 1) + line = line.strip() + if not line or line == b":" or line.startswith(b": "): + continue + if line.startswith(b"data: "): + data = line[6:] + if data in (b"[DONE]", b""): + if printed: + sys.stdout.write("\n") + sys.stdout.flush() + return 0 + try: + d = json.loads(data) + delta = (d.get("choices", [{}])[0] + .get("delta", {}).get("content", "")) + if delta: + sys.stdout.write(delta) + sys.stdout.flush() + printed = True + except Exception: + pass + if printed: + sys.stdout.write("\n") + sys.stdout.flush() + return 0 if printed else 1 + + +def _word_stream(text: str) -> int: + """Print word-by-word at ~120 wpm to simulate streaming.""" + if not text: + return 1 + words = text.split() + for i, word in enumerate(words): + sys.stdout.write(word) + if i < len(words) - 1: + sys.stdout.write(" ") + sys.stdout.flush() + time.sleep(0.007) + sys.stdout.write("\n") + sys.stdout.flush() + return 0 + + +def run(prompt: str, max_tokens: int = 400) -> int: + if not prompt: + return 1 + + parsed = urllib.parse.urlparse(NOETICA_URL) + host = parsed.hostname or "localhost" + port = parsed.port or 7700 + api_path = (parsed.path or "").rstrip("/") + "/api/chat" + + payload = json.dumps({ + "messages": [{"role": "user", "content": prompt}], + "stream": True, + "max_tokens": max_tokens, + }).encode() + + try: + conn = http.client.HTTPConnection(host, port, timeout=15) + conn.request("POST", api_path, payload, {"Content-Type": "application/json"}) + resp = conn.getresponse() + + if resp.status != 200: + raise ValueError(f"HTTP {resp.status}") + + ctype = resp.getheader("Content-Type", "") + if "text/event-stream" in ctype: + return _stream_sse(resp) + + # Non-streaming response — read all, then word-stream + raw = resp.read() + try: + d = json.loads(raw) + except json.JSONDecodeError: + sys.stderr.write("(Noetica: bad JSON response)\n") + return 1 + + msg = (d.get("choices", [{}])[0].get("message", {}).get("content", "") + or d.get("message", {}).get("content", "") + or d.get("content", "") + or d.get("text", "")) + return _word_stream(msg.strip()) + + except OSError: + sys.stderr.write(f"(Noetica unreachable at {NOETICA_URL})\n") + return 1 + except Exception as exc: + sys.stderr.write(f"(Noetica error: {exc})\n") + return 1 + + +if __name__ == "__main__": + sys.exit(run(PROMPT, MAX_TOKENS)) diff --git a/assets/sourceos/bin/turtle-notes-backlinks b/assets/sourceos/bin/turtle-notes-backlinks new file mode 100755 index 00000000000..5bdc1c8ed33 --- /dev/null +++ b/assets/sourceos/bin/turtle-notes-backlinks @@ -0,0 +1,249 @@ +#!/usr/bin/env python3 +# turtle-notes-backlinks — Obsidian-style backlinks indexer for ~/notes/*.md +# MIT License +# Usage: +# turtle-notes-backlinks rebuild index +# turtle-notes-backlinks --watch rebuild on change (poll every 2s) +# turtle-notes-backlinks --backlinks print backlinks for a note +# turtle-notes-backlinks --json dump full index as JSON to stdout + +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import re +import sys +import time +from datetime import datetime, timezone +from typing import Dict, List, Optional + +# ── constants ──────────────────────────────────────────────────────────────── + +NOTES_DIR = pathlib.Path.home() / "notes" +STATE_DIR = pathlib.Path( + os.environ.get("XDG_STATE_HOME", str(pathlib.Path.home() / ".local" / "state")) +) / "sourceos" +INDEX_FILE = STATE_DIR / "notes-backlinks.json" + +_DATE_PREFIX_RE = re.compile(r"^\d{4}-\d{2}-\d{2}-") +_WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:\|[^\]]*)?\]\]") + + +# ── slug helpers ───────────────────────────────────────────────────────────── + +def file_to_slug(path: pathlib.Path) -> str: + """Convert a note file path to a slug. + + ~/notes/2026-08-03-my-idea.md → my-idea + ~/notes/hello-world.md → hello-world + """ + stem = path.stem # filename without extension + stem = _DATE_PREFIX_RE.sub("", stem) + return stem + + +def wikilink_to_slug(link_text: str) -> str: + """Normalise a wikilink target to a slug. + + '[[My Idea]]' → 'my-idea' + '[[My Idea|alias]]' → 'my-idea' (caller strips the alias before calling) + """ + return re.sub(r"[^a-z0-9-]", "-", + link_text.strip().lower().replace(" ", "-")).strip("-") + + +# ── index building ──────────────────────────────────────────────────────────── + +def build_index() -> Dict: + """Scan ~/notes/*.md and return forward + reverse link index.""" + if not NOTES_DIR.exists(): + return {"forward": {}, "reverse": {}} + + files = sorted(NOTES_DIR.glob("*.md")) + + # forward[slug] = [slug, ...] — links this note contains + forward: Dict[str, List[str]] = {} + # reverse[slug] = [slug, ...] — notes that link to this slug + reverse: Dict[str, List[str]] = {} + + for f in files: + slug = file_to_slug(f) + try: + text = f.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + + targets: List[str] = [] + for m in _WIKILINK_RE.finditer(text): + raw = m.group(1) + target_slug = wikilink_to_slug(raw) + if target_slug and target_slug != slug: + targets.append(target_slug) + + # deduplicate while preserving order + seen = set() + deduped: List[str] = [] + for t in targets: + if t not in seen: + seen.add(t) + deduped.append(t) + + forward[slug] = deduped + for t in deduped: + reverse.setdefault(t, []) + if slug not in reverse[t]: + reverse[t].append(slug) + + # ensure every forward key has a reverse entry (even if empty) + for slug in forward: + reverse.setdefault(slug, []) + + return {"forward": forward, "reverse": reverse} + + +def write_index(index: Dict) -> None: + """Persist the index to STATE_DIR/notes-backlinks.json.""" + STATE_DIR.mkdir(parents=True, exist_ok=True) + payload = { + "generated": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), + "forward": index["forward"], + "reverse": index["reverse"], + } + tmp = INDEX_FILE.with_suffix(".json.tmp") + tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8") + tmp.replace(INDEX_FILE) + + +def load_index() -> Optional[Dict]: + if not INDEX_FILE.exists(): + return None + try: + return json.loads(INDEX_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +# ── file-change detection (mtime-based) ────────────────────────────────────── + +def notes_snapshot() -> Dict[str, float]: + """Return {path_str: mtime} for all *.md in NOTES_DIR.""" + if not NOTES_DIR.exists(): + return {} + return { + str(f): f.stat().st_mtime + for f in NOTES_DIR.glob("*.md") + } + + +# ── CLI commands ────────────────────────────────────────────────────────────── + +def cmd_rebuild(verbose: bool = True) -> None: + index = build_index() + write_index(index) + if verbose: + n_notes = len(index["forward"]) + n_links = sum(len(v) for v in index["forward"].values()) + print( + f"◆ Backlinks rebuilt: {n_notes} notes, {n_links} forward links" + f" → {INDEX_FILE}" + ) + + +def cmd_watch() -> None: + print( + f"◆ turtle-notes-backlinks --watch (polling {NOTES_DIR} every 2s)", + flush=True, + ) + last_snap = notes_snapshot() + cmd_rebuild(verbose=True) + + while True: + time.sleep(2) + try: + snap = notes_snapshot() + except Exception: + continue + + if snap != last_snap: + last_snap = snap + try: + index = build_index() + write_index(index) + ts = datetime.now().strftime("%H:%M:%S") + n = len(index["forward"]) + print(f"[{ts}] rebuilt — {n} notes", flush=True) + except Exception as exc: + print(f"[error] rebuild failed: {exc}", file=sys.stderr, flush=True) + + +def cmd_backlinks(slug: str) -> None: + idx = load_index() + if idx is None: + # Build on the fly if no cache yet + idx = build_index() + write_index(idx) + + reverse = idx.get("reverse", {}) + linkers = reverse.get(slug, []) + + if not linkers: + print(f"(no backlinks found for ‘{slug}’)") + return + + print(f"◆ Backlinks for ‘{slug}’ ({len(linkers)}):") + for lnk in linkers: + # Try to find the actual file for display + matches = sorted(NOTES_DIR.glob(f"*-{lnk}.md")) + sorted( + NOTES_DIR.glob(f"{lnk}.md") + ) + fpath = str(matches[0]) if matches else f"~/notes/.../{lnk}.md" + print(f" {lnk} → {fpath}") + + +def cmd_json() -> None: + idx = load_index() + if idx is None: + idx_data = build_index() + write_index(idx_data) + idx = load_index() + print(json.dumps(idx, indent=2, ensure_ascii=False)) + + +# ── entry point ─────────────────────────────────────────────────────────────── + +def main() -> None: + parser = argparse.ArgumentParser( + prog="turtle-notes-backlinks", + description="Obsidian-style backlinks indexer for ~/notes/*.md", + ) + parser.add_argument( + "--watch", + action="store_true", + help="Poll ~/notes every 2s and rebuild on change", + ) + parser.add_argument( + "--backlinks", + metavar="SLUG", + help="Print notes that link to SLUG", + ) + parser.add_argument( + "--json", + action="store_true", + help="Dump full index as JSON to stdout", + ) + args = parser.parse_args() + + if args.watch: + cmd_watch() + elif args.backlinks: + cmd_backlinks(args.backlinks) + elif args.json: + cmd_json() + else: + cmd_rebuild(verbose=True) + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-runbook b/assets/sourceos/bin/turtle-runbook index f7fa72f565d..24a92b1047a 100755 --- a/assets/sourceos/bin/turtle-runbook +++ b/assets/sourceos/bin/turtle-runbook @@ -1,155 +1,583 @@ #!/usr/bin/env python3 -"""turtle-runbook — Git-native runbook runner for TurtleTerm. - -Runbooks are YAML files stored in .turtle/runbooks/ (project) or -~/.config/turtleterm/runbooks/ (user-global). - -Format: - name: deploy-staging - description: Deploy app to staging - steps: - - cmd: npm run build - desc: Build production bundle - - cmd: docker build -t app:staging . - desc: Build Docker image - -Usage: - turtle-runbook list List available runbooks - turtle-runbook show NAME Show runbook steps - turtle-runbook run NAME Run as a plan (injects steps to terminal) - turtle-runbook create NAME Interactive runbook creation wizard -""" +# turtle-runbook — cloud-shared runbook CLI for TurtleTerm. +# Sovereign equivalent of Warp Drive's team runbook library. +# +# Runbooks are YAML files stored locally at: +# ~/.local/state/sourceos/runbooks/ +# and synced to GCS: +# gs:///runbooks/ +# +# Usage: +# turtle-runbook list list all local runbooks +# turtle-runbook show print steps +# turtle-runbook run [KEY=VAL…] execute steps interactively +# turtle-runbook new create a runbook in $EDITOR +# turtle-runbook push sync local → GCS +# turtle-runbook pull sync GCS → local (merge, skip newer) +# turtle-runbook search fuzzy search names/descriptions/tags +# turtle-runbook share print GCS URI for sharing +# +# SPDX-License-Identifier: MIT +# Author: @mdheller + from __future__ import annotations -import json + +import argparse import os +import pathlib +import re +import shutil import subprocess import sys -from pathlib import Path +import tempfile +from typing import Any -AGENTCTL = Path(__file__).parent / "turtle-agentctl" +try: + import yaml as _yaml_mod + _HAS_YAML = True +except ImportError: + _HAS_YAML = False +# ── colour helpers (ANSI only, no curses) ──────────────────────────────────── -def agentd(payload: dict) -> dict | None: - try: - r = subprocess.run( - [sys.executable, str(AGENTCTL), "--stdio"], - input=json.dumps(payload), text=True, capture_output=True, timeout=15, - ) - return json.loads(r.stdout) - except Exception: - return None - - -def cmd_list(args: list[str]) -> int: - d = args[0] if args else "." - r = agentd({"action": "runbook_list", "dir": d}) - if not r: - print("Error: agentd not responding", file=sys.stderr) - return 1 - runbooks = r.get("data", {}).get("runbooks", []) - if not runbooks: - print("No runbooks found.") - print(f"Create one in .turtle/runbooks/ or ~/.config/turtleterm/runbooks/") - return 0 - print(f"{'NAME':<30} {'STEPS':>5} DESCRIPTION") - print("-" * 70) - for rb in runbooks: - print(f"{rb['name']:<30} {rb.get('steps',0):>5} {rb.get('description','')[:35]}") - return 0 - - -def cmd_show(args: list[str]) -> int: - if not args: - print("Usage: turtle-runbook show NAME", file=sys.stderr) - return 1 - r = agentd({"action": "runbook_show", "name": args[0]}) - if not r or r.get("status") != "ok": - print(f"Error: {(r or {}).get('data', {}).get('message', 'not found')}", file=sys.stderr) - return 1 - rb = r.get("data", {}).get("runbook", {}) - print(f"\n {rb.get('name', args[0])}") - print(f" {rb.get('description', '')}\n") - for i, step in enumerate(rb.get("steps", []), 1): - cmd = step.get("cmd", step) if isinstance(step, dict) else str(step) - desc = step.get("desc", "") if isinstance(step, dict) else "" - print(f" {i:2}. {desc or cmd}") - if desc: - print(f" $ {cmd}") +_TEAL = '\033[38;2;57;197;207m' +_GREEN = '\033[38;2;63;185;80m' +_RED = '\033[38;2;248;81;73m' +_YELLOW = '\033[38;2;210;153;34m' +_DIM = '\033[2m' +_BOLD = '\033[1m' +_RESET = '\033[0m' + + +def _c(s: str, col: str) -> str: + return f'{col}{s}{_RESET}' + + +# ── stdlib YAML parser for runbook files ───────────────────────────────────── + +def _parse_yaml_basic(text: str) -> dict[str, Any]: + """ + Minimal YAML parser — covers the runbook schema only. + Handles scalars, bracket tag lists, and sequences of mappings. + """ + lines = text.splitlines() + root: dict[str, Any] = {} + i = 0 + n = len(lines) + + def indent_of(line: str) -> int: + return len(line) - len(line.lstrip()) + + def parse_scalar(raw: str) -> Any: + raw = raw.strip() + if raw.startswith('[') and raw.endswith(']'): + inner = raw[1:-1] + return [v.strip().strip('"').strip("'") for v in inner.split(',') if v.strip()] + if (raw.startswith('"') and raw.endswith('"')) or \ + (raw.startswith("'") and raw.endswith("'")): + return raw[1:-1] + if raw in ('true', 'True'): + return True + if raw in ('false', 'False'): + return False + if raw in ('null', '~', ''): + return None + try: + return int(raw) + except ValueError: + pass + return raw + + while i < n: + line = lines[i] + stripped = line.strip() + if not stripped or stripped.startswith('#'): + i += 1 + continue + + m = re.match(r'^([\w][\w\s-]*):\s*(.*)', line) + if not m: + i += 1 + continue + + key = m.group(1).strip() + val_raw = m.group(2).strip() + i += 1 + + if val_raw: + root[key] = parse_scalar(val_raw) + continue + + # look-ahead for block sequence or block mapping + items: list[Any] = [] + mapping: dict[str, Any] = {} + + while i < n: + nline = lines[i] + nstripped = nline.strip() + if not nstripped or nstripped.startswith('#'): + i += 1 + continue + if indent_of(nline) == 0: + break + if nstripped.startswith('- '): + entry: dict[str, Any] = {} + first_field = nstripped[2:].strip() + fm = re.match(r'^([\w][\w\s-]*):\s*(.*)', first_field) + if fm: + entry[fm.group(1).strip()] = parse_scalar(fm.group(2).strip()) + i += 1 + # sub-keys of this sequence item (indent >= 4) + while i < n and indent_of(lines[i]) >= 4: + sl = lines[i].strip() + sm = re.match(r'^([\w][\w\s-]*):\s*(.*)', sl) + if sm: + entry[sm.group(1).strip()] = parse_scalar(sm.group(2).strip()) + i += 1 + items.append(entry) + else: + km = re.match(r'^([\w][\w\s-]*):\s*(.*)', nstripped) + if km: + mapping[km.group(1).strip()] = parse_scalar(km.group(2).strip()) + i += 1 + + if items: + root[key] = items + elif mapping: + root[key] = mapping + else: + root[key] = None + + return root + + +def _load_yaml(path: pathlib.Path) -> dict[str, Any]: + text = path.read_text(errors='replace') + if _HAS_YAML: + return _yaml_mod.safe_load(text) or {} + return _parse_yaml_basic(text) + + +def _dump_yaml_basic(data: dict[str, Any]) -> str: + lines: list[str] = [] + + def scalar(v: Any) -> str: + if v is None: + return '' + if isinstance(v, bool): + return 'true' if v else 'false' + if isinstance(v, (int, float)): + return str(v) + s = str(v) + if any(c in s for c in ':#{}[]|>&*!,') or s in ('true', 'false', 'null'): + return f'"{s}"' + return s + + for k, v in data.items(): + if isinstance(v, list) and v and isinstance(v[0], dict): + lines.append(f'{k}:') + for item in v: + first = True + for ik, iv in item.items(): + prefix = ' - ' if first else ' ' + first = False + lines.append(f'{prefix}{ik}: {scalar(iv)}') + elif isinstance(v, list): + lines.append(f'{k}: [' + ', '.join(scalar(t) for t in v) + ']') + elif isinstance(v, dict): + lines.append(f'{k}:') + for dk, dv in v.items(): + lines.append(f' {dk}: {scalar(dv)}') + else: + lines.append(f'{k}: {scalar(v)}') + return '\n'.join(lines) + '\n' + + +# ── paths ───────────────────────────────────────────────────────────────────── + +def _runbook_dir() -> pathlib.Path: + state = pathlib.Path( + os.environ.get('XDG_STATE_HOME', os.path.expanduser('~/.local/state')) + ) + d = state / 'sourceos' / 'runbooks' + d.mkdir(parents=True, exist_ok=True) + return d + + +def _bucket() -> str: + bucket_file = pathlib.Path( + os.environ.get('XDG_STATE_HOME', os.path.expanduser('~/.local/state')) + ) / 'sourceos' / 'mesh-bucket' + if bucket_file.exists(): + return bucket_file.read_text().strip() or 'sourceos-mesh' + return 'sourceos-mesh' + + +def _gcs_prefix() -> str: + return f'gs://{_bucket()}/runbooks' + + +def _list_local() -> list[pathlib.Path]: + return sorted(_runbook_dir().glob('*.yaml')) + + +def _load_runbook(name: str) -> dict[str, Any]: + path = _runbook_dir() / f'{name}.yaml' + if not path.exists(): + print(f'{_RED}runbook not found:{_RESET} {name}', file=sys.stderr) + sys.exit(1) + return _load_yaml(path) + + +def _substitute_vars(cmd: str, env: dict[str, str]) -> str: + """Replace ${VAR:-default} / ${VAR} / $VAR from env then os.environ.""" + def replace(m: re.Match) -> str: + varname = m.group(1) or m.group(2) or m.group(3) + default = m.group(4) # only present for ${VAR:-default} form + val = env.get(varname, os.environ.get(varname)) + if val is not None: + return val + if default is not None: + return default + return m.group(0) # leave unexpanded + # ${VAR:-default} + cmd = re.sub( + r'\$\{([A-Za-z_][A-Za-z0-9_]*):-([^}]*)\}', + replace, cmd + ) + # ${VAR} + cmd = re.sub( + r'\$\{([A-Za-z_][A-Za-z0-9_]*)\}', + lambda m: env.get(m.group(1), os.environ.get(m.group(1), m.group(0))), + cmd + ) + # $VAR (word boundary) + cmd = re.sub( + r'\$([A-Za-z_][A-Za-z0-9_]*)', + lambda m: env.get(m.group(1), os.environ.get(m.group(1), m.group(0))), + cmd + ) + return cmd + + +def _gsutil_base() -> list[str]: + if shutil.which('gsutil'): + return ['gsutil'] + if shutil.which('gcloud'): + return ['gcloud', 'storage'] + return ['gsutil'] # will fail with a useful error if missing + + +# ── subcommands ─────────────────────────────────────────────────────────────── + +def cmd_list(_args: argparse.Namespace) -> None: + paths = _list_local() + if not paths: + print(f'{_DIM}No runbooks in {_runbook_dir()}{_RESET}') + print(f'{_DIM}Create one: rb new {_RESET}') + return + max_name = max(len(p.stem) for p in paths) + for p in paths: + rb = _load_yaml(p) + desc = rb.get('description', '') + tags = rb.get('tags', []) + tag_str = _c(' [' + ', '.join(str(t) for t in tags) + ']', _DIM) if tags else '' + print(f' {_c(p.stem.ljust(max_name), _TEAL)} {desc}{tag_str}') + + +def cmd_show(args: argparse.Namespace) -> None: + rb = _load_runbook(args.name) + print(f'\n{_BOLD}{_c(rb.get("name", args.name), _TEAL)}{_RESET}') + if rb.get('description'): + print(f' {rb["description"]}') + if rb.get('author'): + print(f' {_c("author: " + rb["author"], _DIM)}') + if rb.get('tags'): + print(f' {_c("tags: " + ", ".join(str(t) for t in rb["tags"]), _DIM)}') + steps = rb.get('steps', []) + if not steps: + print(f'\n {_c("(no steps)", _DIM)}') + return + print(f'\n {_c("Steps:", _DIM)}') + for i, step in enumerate(steps, 1): + step_name = step.get('name', f'step {i}') + step_cmd = step.get('cmd', '') + print(f'\n {_c(str(i), _YELLOW)}. {_BOLD}{step_name}{_RESET}') + print(f' {_c(step_cmd, _DIM)}') + vars_block = rb.get('vars') or {} + if vars_block: + print(f'\n {_c("Variables:", _DIM)}') + for k, v in vars_block.items(): + print(f' {_c(k, _TEAL)} = {v!r}') print() - return 0 - - -def cmd_run(args: list[str]) -> int: - if not args: - print("Usage: turtle-runbook run NAME", file=sys.stderr) - return 1 - name = args[0] - print(f"Submitting runbook '{name}' as plan...") - r = agentd({"action": "runbook_run", "name": name}) - if not r: - print("Error: agentd not responding", file=sys.stderr) - return 1 - if r.get("status") != "ok": - msg = (r.get("data") or {}).get("message", "unknown error") - print(f"Error: {msg}", file=sys.stderr) - return 1 - plan = r.get("data", {}) - print(f"Plan created: {plan.get('goal', name)}") - print(f" Steps: {len(plan.get('steps', []))}") - print(f" First command will appear in terminal — press Enter to execute.") - return 0 - - -def cmd_create(args: list[str]) -> int: - if not args: - print("Usage: turtle-runbook create NAME", file=sys.stderr) - return 1 - name = args[0] - print(f"Creating runbook: {name}") - description = input("Description: ").strip() - steps = [] - print("Enter steps (empty cmd to finish):") - while True: - cmd = input(f" Step {len(steps)+1} cmd: ").strip() - if not cmd: - break - desc = input(f" Step {len(steps)+1} desc: ").strip() - steps.append({"cmd": cmd, "desc": desc}) + + +def cmd_run(args: argparse.Namespace) -> None: + rb = _load_runbook(args.name) + steps = rb.get('steps', []) if not steps: - print("No steps entered, runbook not saved.") - return 1 - rb_dir = Path(".turtle") / "runbooks" - rb_dir.mkdir(parents=True, exist_ok=True) - rb_file = rb_dir / f"{name}.yaml" - lines = [f"name: {name}", f"description: {description}", "steps:"] - for s in steps: - lines.append(f" - cmd: {s['cmd']}") - if s["desc"]: - lines.append(f" desc: {s['desc']}") - rb_file.write_text("\n".join(lines) + "\n") - print(f"Saved: {rb_file}") - print(f"Run with: turtle-runbook run {name}") - return 0 - - -def main() -> int: - args = sys.argv[1:] - if not args or args[0] in ("-h", "--help"): - print(__doc__) - return 0 - subcmd = args[0] - rest = args[1:] - if subcmd == "list": - return cmd_list(rest) - if subcmd == "show": - return cmd_show(rest) - if subcmd == "run": - return cmd_run(rest) - if subcmd == "create": - return cmd_create(rest) - print(f"Unknown command: {subcmd}. Use: list, show, run, create", file=sys.stderr) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) + print(f'{_YELLOW}No steps in runbook {args.name}.{_RESET}') + return + + # Merge vars: runbook defaults ← CLI KEY=VAL overrides + env: dict[str, str] = {} + for k, v in (rb.get('vars') or {}).items(): + env[k] = str(v) if v is not None else '' + for kv in (args.vars or []): + if '=' in kv: + k, v = kv.split('=', 1) + env[k] = v + + total = len(steps) + print(f'\n{_BOLD}{_c(rb.get("name", args.name), _TEAL)}{_RESET} ' + f'{_c(f"({total} step{"s" if total != 1 else ""})", _DIM)}\n') + + for i, step in enumerate(steps, 1): + step_name = step.get('name', f'Step {i}') + raw_cmd = step.get('cmd', '') + cmd = _substitute_vars(raw_cmd, env) + + print(f'{_BOLD}Step {i}/{total}:{_RESET} {step_name}') + print(f' {_c("$ " + cmd, _DIM)}') + + try: + choice = input( + f' {_c("[Enter to run / s to skip / q to quit]", _YELLOW)} ' + ).strip().lower() + except (EOFError, KeyboardInterrupt): + print(f'\n{_c("aborted", _DIM)}') + sys.exit(130) + + if choice == 'q': + print(_c('quit', _DIM)) + sys.exit(0) + if choice == 's': + print(f' {_c("skipped", _DIM)}\n') + continue + + result = subprocess.run(cmd, shell=True) + rc = result.returncode + if rc == 0: + print(f' {_c(f"exit {rc}", _TEAL)}\n') + else: + print(f' {_c(f"exit {rc}", _RED)}\n') + + print(f'{_c("Done.", _GREEN)}') + + +def cmd_new(args: argparse.Namespace) -> None: + path = _runbook_dir() / f'{args.name}.yaml' + if path.exists(): + print(f'{_YELLOW}Runbook already exists:{_RESET} {path}') + try: + choice = input('Overwrite? [y/N] ').strip().lower() + except (EOFError, KeyboardInterrupt): + print('\nAborted.') + return + if choice != 'y': + print('Aborted.') + return + + template = ( + f'name: {args.name}\n' + f'description: ""\n' + f'tags: []\n' + f'author: {os.environ.get("USER", "")}\n' + f'steps:\n' + f' - name: First step\n' + f' cmd: echo "hello"\n' + f'vars: {{}}\n' + ) + editor = os.environ.get('EDITOR', 'nano') + with tempfile.NamedTemporaryFile(suffix='.yaml', mode='w', delete=False) as tmp: + tmp.write(template) + tmp_path = pathlib.Path(tmp.name) + try: + subprocess.run([editor, str(tmp_path)]) + path.write_text(tmp_path.read_text()) + print(f'{_c("Saved:", _TEAL)} {path}') + finally: + tmp_path.unlink(missing_ok=True) + + +def cmd_push(_args: argparse.Namespace) -> None: + paths = _list_local() + if not paths: + print(f'{_YELLOW}No local runbooks to push.{_RESET}') + return + prefix = _gcs_prefix() + base = _gsutil_base() + errors = 0 + for p in paths: + dest = f'{prefix}/{p.name}' + print(f' {_c("pushing " + p.name + " → " + dest, _DIM)}') + result = subprocess.run(base + ['cp', str(p), dest], capture_output=True, text=True) + if result.returncode != 0: + print(f' {_c("failed:", _RED)} {result.stderr.strip()}') + errors += 1 + else: + print(f' {_c("ok", _TEAL)}') + if errors == 0: + print(f'\n{_c("Push complete.", _GREEN)} {_c(f"({len(paths)} runbooks)", _DIM)}') + else: + print(f'\n{_c(f"{errors} error(s) during push.", _YELLOW)}') + sys.exit(1) + + +def cmd_pull(_args: argparse.Namespace) -> None: + prefix = _gcs_prefix() + base = _gsutil_base() + local_dir = _runbook_dir() + + if base[0] == 'gsutil': + list_args = base + ['ls', f'{prefix}/'] + else: + list_args = base + ['ls', '--recursive', f'{prefix}/'] + + list_result = subprocess.run(list_args, capture_output=True, text=True) + if list_result.returncode != 0: + print(f'{_YELLOW}Could not list GCS runbooks — is gsutil/gcloud configured?{_RESET}') + print(f' {_c(list_result.stderr.strip(), _DIM)}') + sys.exit(1) + + remote_files = [ + line.strip() for line in list_result.stdout.splitlines() + if line.strip().endswith('.yaml') + ] + if not remote_files: + print(f'{_c(f"No remote runbooks found at {prefix}/", _DIM)}') + return + + errors = updated = skipped = 0 + for remote in remote_files: + name = pathlib.Path(remote).name + local = local_dir / name + with tempfile.NamedTemporaryFile(suffix='.yaml', delete=False) as tmp: + tmp_path = pathlib.Path(tmp.name) + try: + dl = subprocess.run(base + ['cp', remote, str(tmp_path)], capture_output=True, text=True) + if dl.returncode != 0: + print(f' {_c("failed to fetch " + name + ":", _RED)} {dl.stderr.strip()}') + errors += 1 + continue + if local.exists() and local.stat().st_mtime >= tmp_path.stat().st_mtime: + print(f' {_c(name + ": local is newer — skipping", _DIM)}') + skipped += 1 + else: + tmp_path.replace(local) + print(f' {_c("updated", _TEAL)} {name}') + updated += 1 + finally: + tmp_path.unlink(missing_ok=True) + + print( + f'\n{_c("Pull complete.", _GREEN)} ' + f'{_c(f"updated={updated} skipped={skipped} errors={errors}", _DIM)}' + ) + if errors: + sys.exit(1) + + +def cmd_search(args: argparse.Namespace) -> None: + query = args.query.lower() + paths = _list_local() + scored: list[tuple[int, str, str, list]] = [] + + for p in paths: + rb = _load_yaml(p) + name = p.stem.lower() + desc = (rb.get('description') or '').lower() + tags = [str(t).lower() for t in (rb.get('tags') or [])] + score = 0 + if query in name: + score += 3 + if any(query in t for t in tags): + score += 2 + if query in desc: + score += 1 + if score > 0: + scored.append((score, p.stem, rb.get('description', ''), rb.get('tags', []))) + + if not scored: + print(f'{_c(f"No runbooks matching {query!r}", _DIM)}') + return + + scored.sort(key=lambda x: -x[0]) + print() + max_name = max(len(r[1]) for r in scored) + for score, name, desc, tags in scored: + tag_str = _c(' [' + ', '.join(str(t) for t in tags) + ']', _DIM) if tags else '' + sc_str = _c(f'(score {score})', _DIM) + print(f' {_c(name.ljust(max_name), _TEAL)} {desc}{tag_str} {sc_str}') + print() + + +def cmd_share(args: argparse.Namespace) -> None: + path = _runbook_dir() / f'{args.name}.yaml' + if not path.exists(): + print(f'{_c("Runbook not found:", _RED)} {args.name}', file=sys.stderr) + sys.exit(1) + uri = f'{_gcs_prefix()}/{args.name}.yaml' + print(f'\n {_c("GCS URI:", _DIM)} {_c(uri, _TEAL)}\n') + print(f' {_c("Share this URI — recipients run:", _DIM)}') + print(f' {_c(" rb pull", _DIM)}') + print(f' {_c(f" rb run {args.name}", _DIM)}\n') + + +# ── CLI ─────────────────────────────────────────────────────────────────────── + +_DISPATCH = { + 'list': cmd_list, + 'show': cmd_show, + 'run': cmd_run, + 'new': cmd_new, + 'push': cmd_push, + 'pull': cmd_pull, + 'search': cmd_search, + 'share': cmd_share, +} + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog='turtle-runbook', + description='Cloud-shared runbook CLI — sovereign Warp Drive equivalent', + ) + sub = p.add_subparsers(dest='command', required=True) + + sub.add_parser('list', help='List local runbooks') + + ps = sub.add_parser('show', help='Print runbook steps') + ps.add_argument('name') + + pr = sub.add_parser('run', help='Execute steps interactively') + pr.add_argument('name') + pr.add_argument('vars', nargs='*', metavar='KEY=VAL') + + pn = sub.add_parser('new', help='Create a runbook in $EDITOR') + pn.add_argument('name') + + sub.add_parser('push', help='Sync local runbooks → GCS') + sub.add_parser('pull', help='Sync GCS → local (skip newer)') + + pse = sub.add_parser('search', help='Search by name/description/tags') + pse.add_argument('query') + + psh = sub.add_parser('share', help='Print GCS URI for sharing') + psh.add_argument('name') + + return p + + +def main() -> None: + parser = _build_parser() + args = parser.parse_args() + fn = _DISPATCH.get(args.command) + if fn is None: + parser.print_help() + sys.exit(1) + fn(args) + + +if __name__ == '__main__': + main() diff --git a/assets/sourceos/bin/turtle-screen-capture b/assets/sourceos/bin/turtle-screen-capture new file mode 100755 index 00000000000..69f66244e48 --- /dev/null +++ b/assets/sourceos/bin/turtle-screen-capture @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""Capture a screen region and inject it as context into a Noetica query. + +Closes the ChatGPT Desktop gap: "capture screen → ask AI about it." + +Usage: + turtle-screen-capture # interactive region select, then prompts for question + turtle-screen-capture "what does this say?" # ask specific question about selected region + turtle-screen-capture --ocr # OCR only, print text (no Noetica query) + turtle-screen-capture --full # full screen instead of interactive region + +Env vars: + NOETICA_URL default: http://localhost:7700 + TURTLE_MAX_TOKENS default: 600 +""" +from __future__ import annotations + +import base64 +import json +import os +import subprocess +import sys +import tempfile +import time +import urllib.parse +import urllib.request +from pathlib import Path + +NOETICA_URL = os.environ.get("NOETICA_URL", "http://localhost:7700") +MAX_TOKENS = int(os.environ.get("TURTLE_MAX_TOKENS", "600")) +MESH_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "memory-mesh" + + +def _screencapture(out_path: str, interactive: bool = True, full: bool = False) -> bool: + """Run macOS screencapture. Returns True on success.""" + cmd = ["screencapture", "-f", out_path] + if interactive and not full: + cmd.append("-i") # interactive region select + elif full: + pass # capture entire screen + # -x: no sound; -t png + cmd += ["-x", "-t", "png"] + try: + r = subprocess.run(cmd, capture_output=True, timeout=60) + return r.returncode == 0 and Path(out_path).stat().st_size > 0 + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return False + + +def _ocr(image_path: str) -> str: + """Try to extract text from image. Returns empty string on failure.""" + # Try tesseract first + try: + r = subprocess.run( + ["tesseract", image_path, "stdout", "-l", "eng"], + capture_output=True, text=True, timeout=15 + ) + if r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + # Try mlx_lm / ocrmac on macOS + try: + r = subprocess.run( + ["ocrmac", image_path], + capture_output=True, text=True, timeout=15 + ) + if r.returncode == 0 and r.stdout.strip(): + return r.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + return "" + + +def _b64_image(image_path: str) -> str: + with open(image_path, "rb") as f: + return base64.b64encode(f.read()).decode() + + +def _query_noetica_vision(image_b64: str, question: str) -> str | None: + """Try OpenAI-compatible vision endpoint. Returns None if not supported.""" + payload = json.dumps({ + "messages": [{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_b64}"}}, + {"type": "text", "text": question}, + ], + }], + "max_tokens": MAX_TOKENS, + "stream": False, + }).encode() + + parsed = urllib.parse.urlparse(NOETICA_URL) + api_path = (parsed.path or "").rstrip("/") + "/api/chat" + + try: + req = urllib.request.Request( + f"{NOETICA_URL}{api_path.replace('/api/chat', '')}/api/chat" + if api_path.count("/api/chat") > 1 else f"{NOETICA_URL}/api/chat", + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=30) as r: + d = json.load(r) + return (d.get("choices", [{}])[0].get("message", {}).get("content", "") + or d.get("content", "") or "").strip() + except Exception: + return None + + +def _query_noetica_text(ocr_text: str, question: str) -> str | None: + """Fall back to text-only query with OCR content.""" + prompt = f"""Screen content (OCR extracted): +--- +{ocr_text[:2000]} +--- + +Question: {question} + +Answer concisely.""" + + payload = json.dumps({ + "messages": [{"role": "user", "content": prompt}], + "max_tokens": MAX_TOKENS, + "stream": False, + }).encode() + + try: + req = urllib.request.Request( + f"{NOETICA_URL}/api/chat", + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urllib.request.urlopen(req, timeout=20) as r: + d = json.load(r) + return (d.get("choices", [{}])[0].get("message", {}).get("content", "") + or d.get("content", "") or "").strip() + except Exception: + return None + + +def _push_to_mesh(question: str, answer: str, ocr_text: str) -> None: + if not MESH_DIR.is_dir(): + return + entry = json.dumps({ + "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "type": "screen-capture", + "title": question[:80], + "data": {"question": question, "answer": answer[:400], "ocr_preview": ocr_text[:200]}, + }) + try: + (MESH_DIR / "context.jsonl").open("a").write(entry + "\n") + except OSError: + pass + + +def _word_stream(text: str) -> None: + words = text.split() + for i, w in enumerate(words): + sys.stdout.write(w) + if i < len(words) - 1: + sys.stdout.write(" ") + sys.stdout.flush() + time.sleep(0.007) + sys.stdout.write("\n") + sys.stdout.flush() + + +def main() -> int: + args = sys.argv[1:] + + ocr_only = "--ocr" in args + full_screen = "--full" in args + question_parts = [a for a in args if not a.startswith("--")] + question = " ".join(question_parts).strip() or "What does this screen show? Describe concisely." + + print("\033[38;2;57;197;207m◆\033[0m \033[2mSelect a screen region…\033[0m", file=sys.stderr) + + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tf: + tmp_path = tf.name + + try: + ok = _screencapture(tmp_path, interactive=not full_screen, full=full_screen) + if not ok: + print("\033[2m(no region selected or capture failed)\033[0m", file=sys.stderr) + return 1 + + ocr_text = _ocr(tmp_path) + + if ocr_only: + print(ocr_text if ocr_text else "(no text detected)") + return 0 + + print(f"\033[38;2;57;197;207m▍\033[0m \033[38;2;230;237;243m", end="", flush=True) + + # Try vision endpoint first + image_b64 = _b64_image(tmp_path) + answer = _query_noetica_vision(image_b64, question) + + # Fall back to OCR + text query + if not answer: + if ocr_text: + answer = _query_noetica_text(ocr_text, question) + else: + answer = None + + if not answer: + print("\033[0m\033[2m(Noetica unreachable or vision not supported)\033[0m", file=sys.stderr) + if ocr_text: + print(f"\033[2mOCR text:\033[0m {ocr_text[:500]}") + return 1 + + _word_stream(answer) + print("\033[0m", end="", flush=True) + + _push_to_mesh(question, answer, ocr_text) + + finally: + try: + os.unlink(tmp_path) + except OSError: + pass + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/assets/sourceos/bin/turtle-web-search b/assets/sourceos/bin/turtle-web-search new file mode 100755 index 00000000000..2175def5edd --- /dev/null +++ b/assets/sourceos/bin/turtle-web-search @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +# MIT License — https://sourceos.io +# turtle-web-search — sovereign web search via SearXNG +# Usage: +# turtle-web-search "query" +# turtle-web-search --json "query" +# turtle-web-search --urls "query" +# turtle-web-search --count 5 "query" +from __future__ import annotations + +import argparse +import json +import os +import sys +import urllib.error +import urllib.parse +import urllib.request + +_DEFAULT_SEARXNG = "https://searx.be" +_USER_AGENT = "TurtleTerm/1.0 (sovereign; +https://sourceos.io)" + + +def search(query: str, count: int = 5) -> list[dict]: + """Query SearXNG and return a ranked list of results.""" + base = os.environ.get("SEARXNG_URL", _DEFAULT_SEARXNG).rstrip("/") + params = urllib.parse.urlencode({ + "q": query, + "format": "json", + "engines": "google,bing,duckduckgo", + "language": "en", + "time_range": "", + "safesearch": "0", + }) + url = f"{base}/search?{params}" + req = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT}) + try: + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.load(resp) + except urllib.error.HTTPError as exc: + print(f"searxng HTTP error: {exc.code} {exc.reason}", file=sys.stderr) + sys.exit(1) + except Exception as exc: # noqa: BLE001 + print(f"searxng unreachable: {exc}", file=sys.stderr) + sys.exit(1) + + results = [] + for rank, item in enumerate(data.get("results", [])[:count], start=1): + results.append({ + "rank": rank, + "title": item.get("title", ""), + "url": item.get("url", ""), + "snippet": item.get("content", ""), + }) + return results + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Sovereign web search via SearXNG", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("query", help="Search query") + parser.add_argument("--json", dest="fmt_json", action="store_true", + help="Output raw JSON array") + parser.add_argument("--urls", dest="fmt_urls", action="store_true", + help="Output one URL per line") + parser.add_argument("--count", type=int, default=5, metavar="N", + help="Number of results (default 5)") + args = parser.parse_args() + + results = search(args.query, count=args.count) + + if args.fmt_json: + print(json.dumps(results, indent=2, ensure_ascii=False)) + elif args.fmt_urls: + for r in results: + print(r["url"]) + else: + for r in results: + snippet = r["snippet"] + if len(snippet) > 120: + snippet = snippet[:117] + "..." + print(f"[{r['rank']}] {r['title']}") + print(f" {snippet}") + print(f" {r['url']}") + print() + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/shell/turtle-shell-init.zsh b/assets/sourceos/shell/turtle-shell-init.zsh index a5b415c42b3..71bbcbf0e12 100644 --- a/assets/sourceos/shell/turtle-shell-init.zsh +++ b/assets/sourceos/shell/turtle-shell-init.zsh @@ -46,6 +46,26 @@ if [[ -z "${_TURTLE_GOOSE_BRIDGE_STARTED:-}" ]]; then unset _goose_bridge fi +# Auto-start backlinks indexer daemon (watches ~/notes for wikilink changes) +if [[ -z "${_TURTLE_BACKLINKS_STARTED:-}" ]]; then + _TURTLE_BACKLINKS_STARTED=1 + _blinks_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-notes-backlinks" + if [[ -x "$_blinks_bin" ]]; then + _blinks_pid_file="/tmp/turtle-notes-backlinks.pid" + _blinks_running=0 + if [[ -f "$_blinks_pid_file" ]]; then + _blinks_pid="$(cat "$_blinks_pid_file" 2>/dev/null)" + kill -0 "$_blinks_pid" 2>/dev/null && _blinks_running=1 + fi + if (( ! _blinks_running )); then + python3 "$_blinks_bin" --watch &! 2>/dev/null + echo $! > "$_blinks_pid_file" 2>/dev/null + fi + unset _blinks_pid_file _blinks_running _blinks_pid + fi + unset _blinks_bin +fi + export SOURCEOS_TERMINAL_FRONTEND="${SOURCEOS_TERMINAL_FRONTEND:-turtle-term}" export SOURCEOS_WORKSPACE="${SOURCEOS_WORKSPACE:-default}" @@ -99,27 +119,32 @@ EOF # kubectl get pods | ? "which are not ready and why" # ============================================================ +_turtle_stream_bin() { + local _bin + _bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-noetica-stream" + [[ -x "$_bin" ]] && echo "$_bin" && return + echo "turtle-noetica-stream" +} + +_turtle_memory_bin() { + local _bin + _bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-noetica-memory" + [[ -x "$_bin" ]] && echo "$_bin" && return + echo "turtle-noetica-memory" +} + +_turtle_memory_context() { + local _mbin; _mbin="$(_turtle_memory_bin)" + python3 "$_mbin" context 2>/dev/null +} + _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 + # Streaming Noetica query — real SSE if supported, word-by-word fallback. + # Args: $1=noetica URL $2=prompt string $3=max_tokens (optional) + local _noetica="$1" _prompt="$2" _max="${3:-400}" + local _sbin; _sbin="$(_turtle_stream_bin)" + TURTLE_NOETICA="$_noetica" TURTLE_PROMPT="$_prompt" TURTLE_MAX_TOKENS="$_max" \ + python3 "$_sbin" } _turtle_active_context_snippet() { @@ -163,15 +188,13 @@ 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)" + # Inject persistent memory into pipe queries too + local _mem_ctx; _mem_ctx="$(_turtle_memory_context 2>/dev/null)" + [[ -n "$_mem_ctx" ]] && _prompt="${_mem_ctx}\n\n${_prompt}" - 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 + printf '\e[38;2;57;197;207m▍\e[0m \e[38;2;230;237;243m' + _turtle_noetica_query "$_noetica" "$_prompt" 150 + printf '\e[0m' } # ============================================================ @@ -181,24 +204,41 @@ Be concise (1-4 lines)." # noe cap "what was that kubectl command I ran?" # ============================================================ noe() { - local _query="$*" local _noetica="${NOETICA_URL:-http://localhost:7700}" + local _mbin; _mbin="$(_turtle_memory_bin)" - if [[ -z "$_query" ]]; then + if [[ -z "${1:-}" ]]; then echo "Usage: noe " >&2 - echo " noe cap — ask about the last capture" >&2 + echo " noe cap — ask about the last capture" >&2 + echo " noe mem — save a fact to persistent memory" >&2 + echo " noe mem list — show saved memory" >&2 return 1 fi - # 'noe cap' — include last capture/note from mesh in context + # noe mem — persistent memory management + if [[ "${1:-}" == "mem" ]]; then + shift + if [[ "${1:-}" == "list" ]]; then + python3 "$_mbin" list + elif [[ -n "$*" ]]; then + python3 "$_mbin" add "$*" + else + python3 "$_mbin" list + fi + return + fi + + local _query="$*" + + # noe cap — include last mesh capture 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') +import json, os, pathlib +ctx = pathlib.Path(os.environ.get('XDG_STATE_HOME', os.path.expanduser('~/.local/state'))) / 'sourceos' / 'memory-mesh' / 'context.jsonl' if ctx.exists(): for line in reversed(ctx.read_text(errors='replace').splitlines()): try: @@ -211,19 +251,25 @@ if ctx.exists(): [[ -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 + # Build full prompt: persistent memory + active context + query + local _mem_ctx; _mem_ctx="$(python3 "$_mbin" context 2>/dev/null)" + local _shell_ctx; _shell_ctx="$(_turtle_active_context_snippet)" + local _full_prompt="" + [[ -n "$_mem_ctx" ]] && _full_prompt="${_mem_ctx}\n\n" + [[ -n "$_shell_ctx" ]] && _full_prompt="${_full_prompt}Shell context:\n${_shell_ctx}\n\n" + _full_prompt="${_full_prompt}${_query}" + + printf '\e[38;2;57;197;207m▍ Noetica\e[0m ' + _turtle_noetica_query "$_noetica" "$_full_prompt" + printf '\n' + + # Auto-extract saveable facts from the query (background, non-blocking) + if python3 -c " +import re, sys +text = sys.argv[1] +print('1' if re.search(r'(?:remember|note that|always|prefer|i use|i like|my |never )', text, re.I) else '0') +" "$_query" 2>/dev/null | grep -q 1; then + TURTLE_NOETICA="$_noetica" python3 "$_mbin" extract "$_query" &! 2>/dev/null fi } @@ -584,6 +630,143 @@ tcv() { "$_voice_bin" "$@" } +# ============================================================ +# note — quick-capture to ~/notes (Obsidian-competitive) +# Usage: note "my idea about X" capture inline +# note open $EDITOR for a new note +# note bl show backlinks for that note +# ============================================================ +note() { + local _title="${1:-}" + local _notes_dir="${HOME}/notes" + local _ts; _ts="$(date +%Y-%m-%d)" + + if [[ "${_title:-}" == "bl" ]]; then + local _slug="${2:-}" + local _blinks_bin + _blinks_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-notes-backlinks" + [[ -x "$_blinks_bin" ]] && python3 "$_blinks_bin" --backlinks "$_slug" || echo "(backlinks unavailable)" >&2 + return + fi + + if [[ -z "$_title" ]]; then + local _fname="${_notes_dir}/${_ts}-untitled.md" + mkdir -p "$_notes_dir" + ${EDITOR:-nano} "$_fname" + return + fi + + local _slug; _slug="$(echo "${_title:0:60}" | tr '[:upper:]' '[:lower:]' | tr ' ' '-' | tr -cd 'a-z0-9-')" + local _fname="${_notes_dir}/${_ts}-${_slug}.md" + mkdir -p "$_notes_dir" + { + printf '# %s\n\n' "$_title" + printf '_Captured: %s_\n\n' "$(date '+%Y-%m-%d %H:%M')" + } >> "$_fname" + + # Append to memory mesh + local _mesh_dir="${HOME}/.local/state/sourceos/memory-mesh" + if [[ -d "$_mesh_dir" ]]; then + local _entry; _entry="$(printf '{"ts":"%s","type":"note","title":"%s","data":{"file":"%s"}}' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + "$(echo "$_title" | tr '"' "'")" \ + "$_fname")" + echo "$_entry" >> "${_mesh_dir}/context.jsonl" 2>/dev/null + fi + + printf '\e[38;2;57;197;207m◆\e[0m Captured: \e[38;2;230;237;243m%s\e[0m\n' "$_fname" + printf ' Open with: \e[2m$EDITOR %s\e[0m\n' "$_fname" +} + +# note_watch — manually start/restart the backlinks watch daemon +# The daemon is auto-started at shell init; use this to restart it if stopped. +note_watch() { + local _blinks_bin + _blinks_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-notes-backlinks" + [[ -x "$_blinks_bin" ]] || { echo "(turtle-notes-backlinks not found)" >&2; return 1; } + local _pid_file="/tmp/turtle-notes-backlinks.pid" + # Kill existing daemon if running + if [[ -f "$_pid_file" ]]; then + local _old_pid; _old_pid="$(cat "$_pid_file" 2>/dev/null)" + kill "$_old_pid" 2>/dev/null || true + rm -f "$_pid_file" + fi + python3 "$_blinks_bin" --watch &! 2>/dev/null + echo $! > "$_pid_file" 2>/dev/null + printf '\e[38;2;57;197;207m◆\e[0m backlinks daemon started (pid %s)\n' "$(cat "$_pid_file" 2>/dev/null)" +} + +ngraph() { + # Open the notes graph view in BearBrowser + local _url="http://localhost:7788/graph" + if command -v bb >/dev/null 2>&1; then + bb "$_url" + else + open "$_url" + fi +} + +# ============================================================ +# rb — runbook CLI shortcut (sovereign Warp Drive equivalent) +# Usage: rb list +# rb show +# rb run [KEY=VAL…] +# rb new +# rb push / rb pull +# rb search +# rb share +# ============================================================ +rb() { + local _rb_bin + _rb_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-runbook" + [[ -x "$_rb_bin" ]] || _rb_bin="turtle-runbook" + python3 "$_rb_bin" "$@" +} + +# rbr — quick run a runbook by name +# Usage: rbr deploy-k8s SERVICE=myapp +rbr() { + rb run "$@" +} + +# ============================================================ +# ws — sovereign web search via SearXNG +# Usage: ws "query" +# ws --json "query" +# ws --count 3 "query" +# ============================================================ +ws() { + local _ws_bin + _ws_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-web-search" + [[ -x "$_ws_bin" ]] || _ws_bin="turtle-web-search" + python3 "$_ws_bin" "$@" +} + +# ============================================================ +# noes — Noetica + web search with inline citations (Perplexity gap closure) +# Usage: noes "what is the latest k8s release?" +# ============================================================ +noes() { + local _ns_bin + _ns_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-noetica-search" + [[ -x "$_ns_bin" ]] || _ns_bin="turtle-noetica-search" + local _noetica="${NOETICA_URL:-http://localhost:7700}" + printf '\e[38;2;57;197;207m◆\e[0m \e[2msearching…\e[0m\n' + TURTLE_NOETICA="$_noetica" python3 "$_ns_bin" "$@" +} + +# tsc — screen-region capture → Noetica query (ChatGPT Desktop gap closure) +# Usage: tsc (interactive region select, ask what it shows) +# tsc "what is the error?" (ask specific question about captured region) +# tsc --full "summarise" (full screen) +# tsc --ocr (OCR only, no Noetica) +tsc() { + local _sc_bin + _sc_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-screen-capture" + [[ -x "$_sc_bin" ]] || _sc_bin="turtle-screen-capture" + "$_sc_bin" "$@" +} + # mc — toggle mission control panel (TurtleTerm only; graceful no-op elsewhere) _turtle_mc() { local _mc_bin @@ -1295,6 +1478,8 @@ _turtle_preexec_timing() { _TURTLE_PERF_CMD="$1" } +_TURTLE_TRIAGE_FILE="${XDG_STATE_HOME:-$HOME/.local/state}/sourceos/last-error-triage.txt" + _turtle_precmd_timing() { local rc=$? if [[ -n "$_TURTLE_PERF_START" && -n "$_TURTLE_PERF_CMD" ]]; then @@ -1309,9 +1494,39 @@ _turtle_precmd_timing() { (osascript -e "display notification \"${cmd_short} (${elapsed_ms}ms)\" with title \"TurtleTerm: Command done\"" 2>/dev/null &) fi _TURTLE_LAST_ELAPSED=$elapsed_ms + _TURTLE_LAST_RC=$rc + + # ── Auto error triage (Warp-equivalent) ─────────────────────────── + # Non-zero exit on a real command (not Ctrl+C, not empty buffer) → + # async Noetica query, result printed at next prompt. + if [[ $rc -ne 0 && $rc -ne 130 && $rc -ne 146 && -n "$_TURTLE_PERF_CMD" ]] \ + && [[ "${TURTLE_ERROR_TRIAGE:-1}" != "0" ]]; then + local _cmd="$_TURTLE_PERF_CMD" + local _sbin; _sbin="$(_turtle_stream_bin)" + local _noetica="${NOETICA_URL:-http://localhost:7700}" + local _tfile="$_TURTLE_TRIAGE_FILE" + TURTLE_NOETICA="$_noetica" \ + TURTLE_PROMPT="Command failed (exit $rc): ${_cmd} +In 1-2 lines: what likely went wrong and how to fix it. Be direct, no preamble." \ + TURTLE_MAX_TOKENS=100 \ + python3 "$_sbin" > "$_tfile" 2>/dev/null &! + fi + _TURTLE_PERF_START="" _TURTLE_PERF_CMD="" fi + + # ── Display pending error triage from previous command ───────────────── + if [[ -f "$_TURTLE_TRIAGE_FILE" ]]; then + local _triage_age=$(( EPOCHSECONDS - $(stat -f %m "$_TURTLE_TRIAGE_FILE" 2>/dev/null || echo 0) )) + if (( _triage_age < 90 )); then + local _triage; _triage="$(< "$_TURTLE_TRIAGE_FILE")" + if [[ -n "$_triage" ]]; then + printf '\n\e[38;2;57;197;207m◆ Noetica:\e[0m \e[38;2;139;148;158m%s\e[0m\n' "$_triage" + fi + fi + rm -f "$_TURTLE_TRIAGE_FILE" + fi } # Install hooks (avoid duplicates) diff --git a/assets/sourceos/turtleterm.lua b/assets/sourceos/turtleterm.lua index a93820556ed..24e1f4f8991 100644 --- a/assets/sourceos/turtleterm.lua +++ b/assets/sourceos/turtleterm.lua @@ -1410,6 +1410,8 @@ local PALETTE_COMMANDS = { -- SynapseIQ { label = '⬡ Diagnose file (SynapseIQ) CTRL+SHIFT+D', id = 'diagnose' }, -- Navigation + { label = '🔍 Web search (ws) —', id = 'web_search' }, + { label = '🌐 Noetica + web citations (noes) —', id = 'noetica_search' }, { label = '🔍 History fuzzy search CTRL+R', id = 'history_search' }, { label = '🔍 Search output CTRL+SHIFT+F', id = 'search_output' }, { label = '👁 Render file (img/PDF/CSV/JSON) CTRL+SHIFT+P', id = 'preview' }, @@ -1435,6 +1437,7 @@ local PALETTE_COMMANDS = { { 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 = '📸 Screen capture → Noetica CMD+SHIFT+S', id = 'screen_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' }, @@ -1445,6 +1448,8 @@ local PALETTE_COMMANDS = { { label = '⎇ Semantic git log (glog) —', id = 'glog' }, { label = '⎇ Git diff highlight (td) —', id = 'tdiff' }, { label = '⏱ Resource usage (last cmd) —', id = 'rss_info' }, + { label = '📋 Runbook list (rb list) —', id = 'runbook_list' }, + { label = '🔍 Runbook search (rb search) —', id = 'runbook_search' }, } local function turtle_command_palette() @@ -1534,6 +1539,7 @@ local function turtle_command_palette() mission_control = turtle_mission_control(), context_snapshot = act.SendString('ctx\n'), voice_capture = act.SendString('tcv\n'), + screen_capture = act.SendString('tsc\n'), capture = turtle_capture_selection(), recall = turtle_recall(), mesh_push = turtle_mesh_push(), @@ -1541,6 +1547,10 @@ local function turtle_command_palette() mesh_dashboard = act.SendString('mesh\n'), glog = act.SendString('glog\n'), tdiff = act.SendString('td\n'), + web_search = act.SendString('ws '), + noetica_search = act.SendString('noes '), + runbook_list = act.SendString('rb list\n'), + runbook_search = act.SendString('rb search '), } local a = dispatch[id] if a then w:perform_action(a, p) end @@ -2142,6 +2152,7 @@ config.keys = { { 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 = 's', mods = 'CMD|SHIFT', action = act.SendString('tsc\n') }, -- Screen capture → Noetica { 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