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
-
Noetica …
CI —
@@ -291,6 +341,267 @@ fetch('/api/state').then(r => r.json()).then(render).catch(() => {})
"""
+GRAPH_HTML = r"""
+
+
+
+
+
Notes Graph · SourceOS
+
+
+
+
+
+
+
+"""
+
+
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