diff --git a/.github/workflows/turtle-term-linux-packaging.yml b/.github/workflows/turtle-term-linux-packaging.yml index 246ef048b15..88af7d11353 100644 --- a/.github/workflows/turtle-term-linux-packaging.yml +++ b/.github/workflows/turtle-term-linux-packaging.yml @@ -53,7 +53,7 @@ jobs: - name: Validate Debian control shape run: | test -f packaging/linux/deb/control - grep -q '^Package: turtle-term$' packaging/linux/deb/control + grep -q '^Package: turtleterm$' packaging/linux/deb/control grep -q '^Architecture: amd64 arm64$' packaging/linux/deb/control grep -q '^Homepage: https://github.com/SourceOS-Linux/TurtleTerm$' packaging/linux/deb/control @@ -76,7 +76,7 @@ jobs: - name: Validate RPM spec shape run: | test -f packaging/linux/rpm/turtle-term.spec - grep -q '^Name: turtle-term$' packaging/linux/rpm/turtle-term.spec + grep -q '^Name: turtleterm$' packaging/linux/rpm/turtle-term.spec grep -q '^ExclusiveArch: x86_64 aarch64$' packaging/linux/rpm/turtle-term.spec grep -q 'TURTLE_TERM_STAGE_PREFIX=%{buildroot}%{_prefix}' packaging/linux/rpm/turtle-term.spec grep -q 'ai.sourceos.TurtleTerm.desktop' packaging/linux/rpm/turtle-term.spec diff --git a/.github/workflows/turtle-term-native-linux-packages.yml b/.github/workflows/turtle-term-native-linux-packages.yml index 2e7310e1707..d11f7e567ac 100644 --- a/.github/workflows/turtle-term-native-linux-packages.yml +++ b/.github/workflows/turtle-term-native-linux-packages.yml @@ -97,15 +97,15 @@ jobs: - name: Verify package artifacts exist run: | ls -lah dist/native - test -n "$(find dist/native -name 'turtle-term_*.deb' -print -quit)" - test -n "$(find dist/native -name 'turtle-term_*.deb.sha256' -print -quit)" - test -n "$(find dist/native -name 'turtle-term_*.deb.manifest.json' -print -quit)" - test -n "$(find dist/native -name 'turtle-term-*.rpm' -print -quit)" - test -n "$(find dist/native -name 'turtle-term-*.rpm.sha256' -print -quit)" - test -n "$(find dist/native -name 'turtle-term-*.rpm.manifest.json' -print -quit)" - test -n "$(find dist/native -name 'turtle-term-*.pkg.tar.zst' -print -quit)" - test -n "$(find dist/native -name 'turtle-term-*.pkg.tar.zst.sha256' -print -quit)" - test -n "$(find dist/native -name 'turtle-term-*.pkg.tar.zst.manifest.json' -print -quit)" + test -n "$(find dist/native -name 'turtleterm_*.deb' -print -quit)" + test -n "$(find dist/native -name 'turtleterm_*.deb.sha256' -print -quit)" + test -n "$(find dist/native -name 'turtleterm_*.deb.manifest.json' -print -quit)" + test -n "$(find dist/native -name 'turtleterm-*.rpm' -print -quit)" + test -n "$(find dist/native -name 'turtleterm-*.rpm.sha256' -print -quit)" + test -n "$(find dist/native -name 'turtleterm-*.rpm.manifest.json' -print -quit)" + test -n "$(find dist/native -name 'turtleterm-*.pkg.tar.zst' -print -quit)" + test -n "$(find dist/native -name 'turtleterm-*.pkg.tar.zst.sha256' -print -quit)" + test -n "$(find dist/native -name 'turtleterm-*.pkg.tar.zst.manifest.json' -print -quit)" - name: Write native package index env: @@ -137,15 +137,15 @@ jobs: uses: actions/attest@v4 with: subject-path: | - dist/native/turtle-term_*.deb - dist/native/turtle-term_*.deb.sha256 - dist/native/turtle-term_*.deb.manifest.json + dist/native/turtleterm_*.deb + dist/native/turtleterm_*.deb.sha256 + dist/native/turtleterm_*.deb.manifest.json dist/native/rpmbuild/RPMS/**/*.rpm dist/native/rpmbuild/RPMS/**/*.rpm.sha256 dist/native/rpmbuild/RPMS/**/*.rpm.manifest.json - dist/native/turtle-term-*.pkg.tar.zst - dist/native/turtle-term-*.pkg.tar.zst.sha256 - dist/native/turtle-term-*.pkg.tar.zst.manifest.json + dist/native/turtleterm-*.pkg.tar.zst + dist/native/turtleterm-*.pkg.tar.zst.sha256 + dist/native/turtleterm-*.pkg.tar.zst.manifest.json dist/native/turtle-term-native-packages.index.json dist/native/turtle-term-native-packages.index.json.sha256 @@ -154,15 +154,15 @@ jobs: with: name: turtle-term-native-linux-${{ matrix.deb_arch }} path: | - dist/native/turtle-term_*.deb - dist/native/turtle-term_*.deb.sha256 - dist/native/turtle-term_*.deb.manifest.json + dist/native/turtleterm_*.deb + dist/native/turtleterm_*.deb.sha256 + dist/native/turtleterm_*.deb.manifest.json dist/native/rpmbuild/RPMS/**/*.rpm dist/native/rpmbuild/RPMS/**/*.rpm.sha256 dist/native/rpmbuild/RPMS/**/*.rpm.manifest.json - dist/native/turtle-term-*.pkg.tar.zst - dist/native/turtle-term-*.pkg.tar.zst.sha256 - dist/native/turtle-term-*.pkg.tar.zst.manifest.json + dist/native/turtleterm-*.pkg.tar.zst + dist/native/turtleterm-*.pkg.tar.zst.sha256 + dist/native/turtleterm-*.pkg.tar.zst.manifest.json dist/native/turtle-term-native-packages.index.json dist/native/turtle-term-native-packages.index.json.sha256 diff --git a/.github/workflows/turtle-term-release.yml b/.github/workflows/turtle-term-release.yml index 249405a1a1f..dba158a9a34 100644 --- a/.github/workflows/turtle-term-release.yml +++ b/.github/workflows/turtle-term-release.yml @@ -28,8 +28,12 @@ jobs: include: - os: macos-14 target: macos-arm64 + - os: macos-13 + target: macos-x86_64 - os: ubuntu-24.04 target: linux-x86_64 + - os: ubuntu-24.04-arm + target: linux-arm64 steps: - name: Check out repository @@ -95,6 +99,14 @@ jobs: dist/turtle-term-*.tar.gz.sha256 dist/turtle-term-*.tar.gz.manifest.json + - name: Generate SBOM + uses: anchore/sbom-action@v0 + with: + path: dist/ + format: spdx-json + artifact-name: turtle-term-${{ matrix.target }}.sbom.spdx.json + output-file: dist/turtle-term-${{ matrix.target }}.sbom.spdx.json + - name: Upload artifact uses: actions/upload-artifact@v4 with: @@ -106,16 +118,14 @@ jobs: - name: Publish GitHub release assets if: startsWith(github.ref, 'refs/tags/turtle-term-v') - env: - GH_TOKEN: ${{ github.token }} - run: | - tag="${GITHUB_REF_NAME}" - gh release create "$tag" \ - --title "TurtleTerm ${tag}" \ - --notes "TurtleTerm ${tag} — see CHANGELOG for details." \ - 2>/dev/null || true - gh release upload "$tag" \ - dist/turtle-term-*.tar.gz \ - dist/turtle-term-*.tar.gz.sha256 \ - dist/turtle-term-*.tar.gz.manifest.json \ - --clobber + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: TurtleTerm ${{ github.ref_name }} + body: "TurtleTerm ${{ github.ref_name }} — see CHANGELOG for details." + fail_on_unmatched_files: false + files: | + dist/turtle-term-*.tar.gz + dist/turtle-term-*.tar.gz.sha256 + dist/turtle-term-*.tar.gz.manifest.json + dist/turtle-term-*.sbom.spdx.json diff --git a/assets/sourceos/bin/turtle-capture b/assets/sourceos/bin/turtle-capture new file mode 100755 index 00000000000..23bf874bf79 --- /dev/null +++ b/assets/sourceos/bin/turtle-capture @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""turtle-capture — capture terminal output to Goose Notes + memory mesh. + +Usage: + some-command | turtle-capture "note title" # pipe output + turtle-capture # capture last history entry + turtle-capture -f output.txt # read from file + +Output goes to: + 1. ~/notes/{YYYY-MM-DD}-{slug}.md (Goose Notes pickup dir) + 2. ~/.local/state/sourceos/memory-mesh/context.jsonl (mesh event) + 3. Goose Notes HTTP API on :8765 (if running) +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from urllib import error as urlerr +from urllib import request as urlreq + +MESH_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "memory-mesh" +HISTORY_FILE = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "terminal" / "history.jsonl" +NOTES_DIR = Path(os.getenv("GOOSE_NOTES_DIR", str(Path.home() / "notes"))) +GOOSE_API = os.getenv("GOOSE_NOTES_API", "http://localhost:8765") +NOETICA_URL = os.getenv("NOETICA_URL", "http://localhost:7700") + + +def now_iso() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + + +def slugify(s: str) -> str: + s = s.lower().strip() + s = re.sub(r"[^\w\s-]", "", s) + s = re.sub(r"[\s_-]+", "-", s) + return s[:60] + + +def git_branch() -> str: + try: + return subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + stderr=subprocess.DEVNULL, timeout=2, text=True + ).strip() + except Exception: + return "" + + +def last_history_entry() -> dict | None: + if not HISTORY_FILE.exists(): + return None + try: + lines = HISTORY_FILE.read_text(errors="replace").splitlines() + for line in reversed(lines): + try: + return json.loads(line) + except Exception: + pass + except Exception: + pass + return None + + +def write_mesh_event(kind: str, title: str, content: str, source: str = "terminal") -> None: + MESH_DIR.mkdir(parents=True, exist_ok=True) + ctx_file = MESH_DIR / "context.jsonl" + event = { + "ts": now_iso(), + "kind": kind, + "source": source, + "title": title, + "content": content[:500], + } + with ctx_file.open("a") as f: + f.write(json.dumps(event) + "\n") + # Cap at 5000 lines + try: + lines = ctx_file.read_text(errors="replace").splitlines() + if len(lines) > 5000: + ctx_file.write_text("\n".join(lines[-5000:]) + "\n") + except Exception: + pass + + +def write_active_context(cwd: str, branch: str, title: str) -> None: + MESH_DIR.mkdir(parents=True, exist_ok=True) + active = { + "updated": now_iso(), + "cwd": cwd, + "branch": branch, + "focus": title, + } + (MESH_DIR / "active.json").write_text(json.dumps(active, indent=2)) + + +def write_note_file(title: str, body: str, cwd: str, branch: str, tags: list[str]) -> Path: + NOTES_DIR.mkdir(parents=True, exist_ok=True) + date = datetime.date.today().isoformat() + slug = slugify(title) + fname = NOTES_DIR / f"{date}-{slug}.md" + + branch_line = f"\nbranch: {branch}" if branch else "" + frontmatter = f"""--- +title: "{title}" +date: {now_iso()} +source: terminal +cwd: {cwd}{branch_line} +tags: [{", ".join(tags)}] +--- + +""" + fname.write_text(frontmatter + body) + return fname + + +def post_to_goose_notes(title: str, body: str, tags: list[str]) -> bool: + payload = json.dumps({ + "title": title, + "body": body, + "tags": tags, + "source_type": "terminal", + }).encode() + try: + req = urlreq.Request( + f"{GOOSE_API}/api/notes", + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urlreq.urlopen(req, timeout=2) as r: + return r.status < 300 + except Exception: + return False + + +def generate_title_via_noetica(content: str) -> str: + """Ask Noetica to suggest a note title from content.""" + try: + payload = json.dumps({ + "messages": [{ + "role": "user", + "content": ( + f"Generate a concise, descriptive title (max 8 words) for this terminal capture:\n\n" + f"{content[:400]}\n\nReply with ONLY the title, nothing else." + ), + }], + "stream": False, + }).encode() + req = urlreq.Request( + f"{NOETICA_URL}/api/chat", + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urlreq.urlopen(req, timeout=3) as r: + data = json.load(r) + content_out = data.get("message", {}).get("content", "") or data.get("content", "") + title = content_out.strip().strip('"').strip("'") + if title and len(title) < 100: + return title + except Exception: + pass + return "" + + +def main(): + parser = argparse.ArgumentParser(description="Capture terminal output to Goose Notes + memory mesh") + parser.add_argument("title", nargs="?", default="", help="Note title (auto-generated if omitted)") + parser.add_argument("-f", "--file", help="Read content from file instead of stdin") + parser.add_argument("-t", "--tag", action="append", default=[], help="Add tag") + parser.add_argument("--no-notes", action="store_true", help="Skip writing to ~/notes/") + parser.add_argument("--no-mesh", action="store_true", help="Skip writing to memory mesh") + parser.add_argument("--context", action="store_true", help="Include last shell history as context") + args = parser.parse_args() + + cwd = os.getcwd() + branch = git_branch() + + # Read content + if args.file: + try: + content = Path(args.file).read_text(errors="replace") + except Exception as e: + print(f"turtle-capture: cannot read {args.file}: {e}", file=sys.stderr) + sys.exit(1) + elif not sys.stdin.isatty(): + content = sys.stdin.read() + else: + # No pipe: capture last history entry + entry = last_history_entry() + if not entry: + print("turtle-capture: no stdin and no history — pipe output or use -f", file=sys.stderr) + sys.exit(1) + cmd = entry.get("cmd", "") + content = f"$ {cmd}\n\n(exit {entry.get('exit', 0)}, {entry.get('duration', 0):.1f}s)" + if not args.title: + args.title = cmd[:60] + + if not content.strip(): + print("turtle-capture: empty content, nothing to capture", file=sys.stderr) + sys.exit(1) + + # Context block + if args.context: + entry = last_history_entry() + if entry: + content = f"$ {entry.get('cmd', '')}\n\n{content}" + + # Generate title + title = args.title.strip() + if not title: + title = generate_title_via_noetica(content) + if not title: + # Fallback: first non-empty line + for line in content.splitlines(): + line = line.strip().lstrip("$").strip() + if line: + title = line[:60] + break + if not title: + title = f"Capture {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}" + + tags = args.tag or ["terminal"] + if branch: + tags.append(branch) + + # Format body with context header + branch_info = f" · {branch}" if branch else "" + body = f"**Source:** `{cwd}`{branch_info}\n\n```\n{content.rstrip()}\n```\n" + + results = [] + + # Write to ~/notes/ + if not args.no_notes: + note_path = write_note_file(title, body, cwd, branch, tags) + results.append(f"note: {note_path}") + + # Write to memory mesh + if not args.no_mesh: + write_mesh_event("capture", title, content[:500], source="terminal") + write_active_context(cwd, branch, title) + results.append("mesh: updated") + + # Try Goose Notes API + if post_to_goose_notes(title, body, tags): + results.append("goose-notes-api: synced") + + print(f"captured: {title!r}") + for r in results: + print(f" → {r}") + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-history b/assets/sourceos/bin/turtle-history new file mode 100755 index 00000000000..f29eae7b27c --- /dev/null +++ b/assets/sourceos/bin/turtle-history @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +"""turtle-history — semantic NL shell history search. + +The thing nobody else has: 'what was the command I used to set up the Postgres replica?' +- ^R → fzf-backed history with exit codes, duration, cwd context +- ^R then '?' prefix → Noetica NL semantic search over history +- turtle-history "query" → pipe-friendly NL search +- turtle-history --widget → raw fzf mode for zle binding +""" + +from __future__ import annotations + +import argparse +import difflib +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from urllib import error as urlerr +from urllib import request as urlreq + +HISTORY_FILE = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "terminal" / "history.jsonl" +AGENTD_SOCKET = Path(os.getenv("XDG_RUNTIME_DIR", "/tmp")) / f"turtle-agentd-{os.getuid()}.sock" +NOETICA_URL = os.getenv("NOETICA_URL", "http://localhost:7700") + + +# ── history loading ──────────────────────────────────────────────────────────── + +def load_history(limit: int = 2000) -> list[dict]: + entries = [] + if not HISTORY_FILE.exists(): + # Fall back to plain zsh/bash history if our rich index doesn't exist yet + for hf in [Path.home() / ".zsh_history", Path.home() / ".bash_history"]: + if hf.exists(): + try: + for line in hf.read_text(errors="replace").splitlines()[-limit:]: + line = re.sub(r"^: \d+:\d+;", "", line).strip() + if line: + entries.append({"cmd": line, "exit": 0, "cwd": "", "ts": "", "duration": 0}) + except Exception: + pass + return entries[-limit:] + + try: + lines = HISTORY_FILE.read_text(errors="replace").splitlines() + for line in lines[-limit:]: + try: + entries.append(json.loads(line)) + except Exception: + pass + except Exception: + pass + + # Deduplicate: keep last occurrence of each unique command + seen: dict[str, dict] = {} + for e in entries: + seen[e.get("cmd", "")] = e + return list(seen.values()) + + +# ── search backends ──────────────────────────────────────────────────────────── + +def search_noetica(query: str, entries: list[dict]) -> list[dict]: + """Ask Noetica to rank history entries by semantic relevance to the query.""" + try: + history_text = "\n".join( + f"{i}: {e.get('cmd', '')} [{e.get('cwd', '')}]" + for i, e in enumerate(entries[-200:]) + ) + payload = json.dumps({ + "messages": [{ + "role": "user", + "content": ( + f"Shell history (index: command [cwd]):\n{history_text}\n\n" + f"Return the 10 most relevant command indices for this query: {query!r}\n" + f"Reply ONLY with a JSON array of integers, e.g. [42, 7, 13, ...]" + ), + }], + "stream": False, + }).encode() + req = urlreq.Request( + f"{NOETICA_URL}/api/chat", + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urlreq.urlopen(req, timeout=3) as resp: + data = json.load(resp) + content = data.get("message", {}).get("content", "") or data.get("content", "") + # extract JSON array from response + m = re.search(r"\[[\d,\s]+\]", content) + if m: + indices = json.loads(m.group()) + base = entries[-200:] + return [base[i] for i in indices if 0 <= i < len(base)] + except Exception: + pass + return [] + + +def search_fuzzy(query: str, entries: list[dict]) -> list[dict]: + """Fast fuzzy match using difflib sequence matching + keyword scoring.""" + q = query.lower() + keywords = q.split() + + def score(e: dict) -> float: + cmd = e.get("cmd", "").lower() + cwd = e.get("cwd", "").lower() + # Keyword hit score + kw_score = sum(1.0 for kw in keywords if kw in cmd or kw in cwd) + # Sequence match + seq = difflib.SequenceMatcher(None, q, cmd[:80]).ratio() + # Recency bonus (index-based, higher = more recent) + return kw_score * 2 + seq + + scored = sorted(entries, key=score, reverse=True) + return [e for e in scored[:20] if score(e) > 0.05] + + +def search(query: str, entries: list[dict]) -> list[dict]: + if not query: + return list(reversed(entries[-50:])) + + # NL mode: try Noetica first, fall back to fuzzy + results = search_noetica(query, entries) + if not results: + results = search_fuzzy(query, entries) + return results[:20] + + +# ── fzf presentation ────────────────────────────────────────────────────────── + +def format_entry(e: dict) -> str: + cmd = e.get("cmd", "") + exit_code = e.get("exit", 0) + cwd = e.get("cwd", "") + duration = e.get("duration", 0) + ts = e.get("ts", "")[:16] # YYYY-MM-DDTHH:MM + + status = "✓" if exit_code == 0 else f"✗{exit_code}" + dur_str = f"{duration:.0f}s" if duration and duration > 1 else "" + meta = " ".join(filter(None, [ts, cwd.split("/")[-1] if cwd else "", dur_str])) + + return f"{status} {cmd:<80} {meta}" + + +def run_fzf(entries: list[dict], query: str = "") -> str | None: + lines = [format_entry(e) for e in entries] + fzf_input = "\n".join(lines) + + fzf_args = [ + "fzf", + "--ansi", + "--no-sort", + "--height=40%", + "--min-height=10", + "--border=rounded", + "--prompt=history> ", + "--header=^R:reload ?query:semantic Enter:select", + "--color=bg:#0d1117,bg+:#161b22,fg:#e6edf3,fg+:#58a6ff,border:#30363d,prompt:#58a6ff,header:#8b949e,hl:#3fb950,hl+:#56d364", + "--bind=ctrl-r:reload(echo '{q}' | turtle-history --fzf-reload)", + ] + if query: + fzf_args += ["--query", query] + + try: + result = subprocess.run( + fzf_args, + input=fzf_input, + capture_output=True, + text=True, + ) + if result.returncode == 0 and result.stdout.strip(): + # Extract just the command part (after the status indicator) + line = result.stdout.strip() + m = re.match(r"[✓✗\d]+\s+(.+?)\s{2,}", line) + if m: + return m.group(1).strip() + return line + except FileNotFoundError: + pass # fzf not installed + return None + + +def run_plain(entries: list[dict]) -> str | None: + for i, e in enumerate(entries[:20]): + print(f" {i+1:>2} {format_entry(e)}", file=sys.stderr) + try: + sys.stderr.write("Select [1-20]: ") + sys.stderr.flush() + choice = input().strip() + idx = int(choice) - 1 + if 0 <= idx < len(entries): + return entries[idx].get("cmd", "") + except (ValueError, EOFError, KeyboardInterrupt): + pass + return None + + +# ── main ─────────────────────────────────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser(description="TurtleTerm semantic history search") + parser.add_argument("query", nargs="?", default="", help="NL query (prefix with ? for semantic mode)") + parser.add_argument("--widget", action="store_true", help="zle widget mode: output selected command to stdout") + parser.add_argument("--fzf-reload", action="store_true", help="internal: reload fzf results for query on stdin") + parser.add_argument("--index", action="store_true", help="print history index and exit") + parser.add_argument("--limit", type=int, default=2000) + args = parser.parse_args() + + entries = load_history(limit=args.limit) + + if args.index: + for e in reversed(entries[-100:]): + print(format_entry(e)) + return + + if args.fzf_reload: + # Read query from stdin, print formatted results + q = sys.stdin.read().strip().lstrip("?") + results = search(q, entries) + for e in results: + print(format_entry(e)) + return + + query = args.query.lstrip("?").strip() + + if args.widget: + # Widget mode: show fzf (or plain) and print selected command + results = search(query, entries) + selected = run_fzf(results, query=query) + if selected is None: + selected = run_plain(results) + if selected: + print(selected) + return + + # Pipe / CLI mode + results = search(query, entries) + if not results: + print("No matching history entries.", file=sys.stderr) + sys.exit(1) + + if sys.stdout.isatty(): + selected = run_fzf(results, query=query) + if selected is None: + selected = run_plain(results) + if selected: + print(selected) + else: + # Non-interactive: print all results + for e in results: + print(e.get("cmd", "")) + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-language b/assets/sourceos/bin/turtle-language index 69a0794e217..65b6fb66256 100755 --- a/assets/sourceos/bin/turtle-language +++ b/assets/sourceos/bin/turtle-language @@ -191,17 +191,34 @@ def symbols(path: Path) -> dict[str, Any]: sha = sha256_text(text) base = {"file": str(path), "language": lang, "content_sha256": sha, "mutationAllowed": False} + # The local AST walk is complete (classes AND their methods); the SynapseIQ + # LSP may only surface top-level symbols. Compute local first, then let the + # LSP enrich it — never drop a symbol the local pass found. + local = python_symbols(path, text) if lang == "python" else generic_symbols(path, text) + ensure_synapseiq() synapse = synapseiq_symbols(str(path), text, lang) - if synapse is not None: + if isinstance(synapse, dict): base["source"] = "synapseiq-lsp" base["synapseiq_url"] = synapseiq_url() - base["symbols"] = synapse.get("symbols", []) + # A flaky LSP may return a null "symbols", scalars, or non-dict entries — + # keep only well-formed symbol dicts so enrichment can never crash the + # command (this path has no CI coverage when the LSP is offline). + lsp_symbols = [s for s in (synapse.get("symbols") or []) if isinstance(s, dict)] + + # Dedup on (name, line): a class method must NOT be dropped just because a + # top-level symbol shares its name — only a genuine duplicate is skipped. + def _key(sym: dict[str, Any]) -> tuple[Any, Any]: + return (sym.get("name"), sym.get("line")) + + seen = {_key(s) for s in lsp_symbols} + merged = lsp_symbols + [s for s in local if _key(s) not in seen] + # A present-but-null "line" must not blow up the sort. + base["symbols"] = sorted(merged, key=lambda item: (item.get("line") or 0)) return base - items = python_symbols(path, text) if lang == "python" else generic_symbols(path, text) base["source"] = "builtin-heuristic" - base["symbols"] = items + base["symbols"] = local return base diff --git a/assets/sourceos/bin/turtle-mesh-push b/assets/sourceos/bin/turtle-mesh-push new file mode 100755 index 00000000000..7b868ec9b02 --- /dev/null +++ b/assets/sourceos/bin/turtle-mesh-push @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""turtle-mesh-push — sync local memory mesh to GCS + pull cloud events. + +Bidirectional sync: + push: ~/.local/state/sourceos/memory-mesh/* → GCS bucket + pull: GCS bucket → local merge (dedup by ts) + +Usage: + turtle-mesh-push # push + pull + turtle-mesh-push --push-only # push only + turtle-mesh-push --pull-only # pull only + turtle-mesh-push --status # show last sync timestamp +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import os +import subprocess +import sys +from pathlib import Path + +MESH_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "memory-mesh" +GCS_BUCKET = os.getenv("SOURCEOS_MESH_BUCKET", "gs://sourceos-artifacts-socioprophet/memory-mesh") +SYNC_STAMP = MESH_DIR / ".last-sync" + + +def now_iso() -> str: + return datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") + + +def gsutil(*args: str, capture: bool = False) -> tuple[bool, str]: + cmd = ["gsutil"] + list(args) + try: + if capture: + out = subprocess.check_output(cmd, stderr=subprocess.DEVNULL, timeout=30, text=True) + return True, out + else: + r = subprocess.run(cmd, timeout=30, capture_output=True, text=True) + return r.returncode == 0, r.stdout + r.stderr + except Exception as e: + return False, str(e) + + +def merge_jsonl(local: Path, remote_lines: list[str]) -> int: + existing = set() + if local.exists(): + for line in local.read_text(errors="replace").splitlines(): + try: + ev = json.loads(line) + existing.add(ev.get("ts", "") + ev.get("title", "")) + except Exception: + pass + + new_lines = [] + for line in remote_lines: + try: + ev = json.loads(line) + key = ev.get("ts", "") + ev.get("title", "") + if key not in existing: + new_lines.append(line) + existing.add(key) + except Exception: + pass + + if new_lines: + with local.open("a") as f: + for line in new_lines: + f.write(line + "\n") + + return len(new_lines) + + +def push_to_gcs() -> tuple[bool, str]: + MESH_DIR.mkdir(parents=True, exist_ok=True) + ok, msg = gsutil("-m", "rsync", "-r", str(MESH_DIR), f"{GCS_BUCKET}/local") + return ok, msg + + +def pull_from_gcs() -> int: + ok, raw = gsutil("cat", f"{GCS_BUCKET}/context.jsonl", capture=True) + if not ok or not raw.strip(): + return 0 + remote_lines = raw.strip().splitlines() + local_ctx = MESH_DIR / "context.jsonl" + MESH_DIR.mkdir(parents=True, exist_ok=True) + merged = merge_jsonl(local_ctx, remote_lines) + return merged + + +def get_status() -> dict: + if not SYNC_STAMP.exists(): + return {"last_sync": None, "events": 0} + try: + d = json.loads(SYNC_STAMP.read_text()) + return d + except Exception: + return {"last_sync": SYNC_STAMP.read_text().strip(), "events": 0} + + +def save_status(pushed: bool, pulled: int) -> None: + ctx = MESH_DIR / "context.jsonl" + ev_count = 0 + if ctx.exists(): + try: + ev_count = len(ctx.read_text().splitlines()) + except Exception: + pass + SYNC_STAMP.write_text(json.dumps({ + "last_sync": now_iso(), + "pushed": pushed, + "pulled": pulled, + "local_events": ev_count, + })) + + +def main(): + parser = argparse.ArgumentParser(description="Sync memory mesh to/from GCS") + parser.add_argument("--push-only", action="store_true") + parser.add_argument("--pull-only", action="store_true") + parser.add_argument("--status", action="store_true", help="Print last sync info and exit") + args = parser.parse_args() + + if args.status: + s = get_status() + last = s.get("last_sync") or "never" + ev = s.get("local_events", "?") + print(f"last sync: {last}") + print(f"local events: {ev}") + ctx = MESH_DIR / "context.jsonl" + if ctx.exists(): + n = len(ctx.read_text(errors="replace").splitlines()) + print(f"current local events: {n}") + return + + pushed = False + pulled_count = 0 + + if not args.pull_only: + ok, msg = push_to_gcs() + pushed = ok + if ok: + print(f"pushed: {GCS_BUCKET}/local") + else: + print(f"push failed: {msg[:200]}", file=sys.stderr) + + if not args.push_only: + pulled_count = pull_from_gcs() + if pulled_count > 0: + print(f"pulled: {pulled_count} new events from cloud") + else: + print("pulled: no new cloud events") + + save_status(pushed, pulled_count) + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-mission-control b/assets/sourceos/bin/turtle-mission-control new file mode 100755 index 00000000000..947e5d50dfe --- /dev/null +++ b/assets/sourceos/bin/turtle-mission-control @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +"""turtle-mission-control — live multi-agent status panel. + +CMD+SHIFT+M in TurtleTerm opens this in a bottom split. +Shows: all running AI agents, memory mesh recent events, Noetica health. +Auto-refreshes every 2s using ANSI in-place rewrite. + +Agents tracked: + - Claude Code (claude, claude-code, claude_code) + - Goose (goose, goose-notes) + - Noetica (noetica, prophetd, noetica-agent) + - Autonomous (turtle-autonomous, turtle-agent) +""" + +from __future__ import annotations + +import argparse +import datetime +import json +import os +import re +import subprocess +import sys +import time +from pathlib import Path +from urllib import request as urlreq + +NOETICA_URL = os.getenv("NOETICA_URL", "http://localhost:7700") +BEARBROWSER_PORT = int(os.getenv("BEARBROWSER_PORT", "9339")) +MESH_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "memory-mesh" +HISTORY_FILE = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "terminal" / "history.jsonl" + +# TurtleTerm Dark palette (ANSI 24-bit) +C_BG = "\033[48;2;13;17;23m" +C_RESET = "\033[0m" +C_DIM = "\033[2m" +C_BOLD = "\033[1m" +C_TEAL = "\033[38;2;63;185;80m" # green-teal — running +C_BLUE = "\033[38;2;88;166;255m" # blue — headings +C_ORANGE = "\033[38;2;255;123;114m" # orange/red — errors +C_YELLOW = "\033[38;2;210;153;34m" # yellow — warnings +C_WHITE = "\033[38;2;230;237;243m" # fg +C_GREY = "\033[38;2;139;148;158m" # dim fg +C_PURPLE = "\033[38;2;188;140;255m" # purple — memory mesh +C_CYAN = "\033[38;2;0;200;200m" # cyan — BearBrowser + +AGENT_PATTERNS = [ + ("Claude Code", re.compile(r'claude[\-_]?code|claude --', re.I)), + ("Goose", re.compile(r'\bgoose\b', re.I)), + ("Noetica", re.compile(r'noetica|prophetd|noetica.agent', re.I)), + ("Autonomous", re.compile(r'turtle.auto|turtle.agent|autonomous', re.I)), +] + + +def elapsed(start_ts: str) -> str: + try: + t = datetime.datetime.fromisoformat(start_ts.replace("Z", "+00:00")) + s = int((datetime.datetime.now(datetime.timezone.utc) - t).total_seconds()) + if s < 60: + return f"{s}s" + elif s < 3600: + return f"{s//60}m{s%60:02d}s" + else: + return f"{s//3600}h{(s%3600)//60:02d}m" + except Exception: + return "?" + + +def get_running_agents() -> list[dict]: + agents = [] + try: + out = subprocess.check_output(["ps", "aux"], text=True, timeout=3) + for line in out.splitlines()[1:]: + parts = line.split(None, 10) + if len(parts) < 11: + continue + pid, cpu, mem, cmd = parts[1], parts[2], parts[3], parts[10] + if "/bin/ps" in cmd or "turtle-mission" in cmd: + continue + for name, pat in AGENT_PATTERNS: + if pat.search(cmd): + agents.append({ + "name": name, + "pid": pid, + "cpu": cpu, + "mem": mem, + "cmd": cmd[:100], + }) + break + except Exception: + pass + return agents + + +def noetica_status() -> dict: + try: + with urlreq.urlopen(f"{NOETICA_URL}/health", timeout=1) as r: + data = json.load(r) + return {"ok": True, "data": data} + except Exception: + pass + return {"ok": False} + + +def bearbrowser_status() -> dict: + try: + with urlreq.urlopen(f"http://127.0.0.1:{BEARBROWSER_PORT}/health", timeout=1) as r: + data = json.load(r) + tabs = data.get("tabs", 0) + return {"ok": True, "tabs": tabs} + except Exception: + pass + return {"ok": False} + + +def recent_mesh_events(limit: int = 8) -> list[dict]: + ctx = MESH_DIR / "context.jsonl" + if not ctx.exists(): + return [] + events = [] + try: + lines = ctx.read_text(errors="replace").splitlines() + for line in lines[-100:]: + try: + events.append(json.loads(line)) + except Exception: + pass + except Exception: + pass + return events[-limit:] + + +def recent_commands(limit: int = 5) -> list[dict]: + if not HISTORY_FILE.exists(): + return [] + try: + lines = HISTORY_FILE.read_text(errors="replace").splitlines() + cmds = [] + for line in lines[-50:]: + try: + cmds.append(json.loads(line)) + except Exception: + pass + return cmds[-limit:] + except Exception: + return [] + + +def hr(width: int = 70) -> str: + return C_GREY + "─" * width + C_RESET + + +def format_panel(agents: list[dict], noetica: dict, bb: dict, mesh: list[dict], cmds: list[dict], now: str) -> str: + lines = [] + W = 72 + + # Header + lines.append("") + lines.append(f" {C_BLUE}{C_BOLD}◆ Mission Control{C_RESET} {C_GREY}{now} q=quit r=refresh{C_RESET}") + lines.append(f" {hr(W)}") + + # Agents section + lines.append(f" {C_BLUE}AGENTS{C_RESET} {C_GREY}({len(agents)} running){C_RESET}") + if not agents: + lines.append(f" {C_DIM} — no agent processes detected{C_RESET}") + for ag in agents: + status_c = C_TEAL + lines.append( + f" {status_c}●{C_RESET} {C_WHITE}{ag['name']:<14}{C_RESET}" + f" {C_GREY}pid={ag['pid']} cpu={ag['cpu']}% mem={ag['mem']}%{C_RESET}" + ) + cmd_preview = ag["cmd"][:55].strip() + lines.append(f" {C_DIM}{cmd_preview}{C_RESET}") + + lines.append(f" {hr(W)}") + + # Services section + lines.append(f" {C_BLUE}SERVICES{C_RESET}") + + noe_dot = (C_TEAL + "●") if noetica["ok"] else (C_ORANGE + "○") + noe_detail = "" + if noetica["ok"]: + d = noetica.get("data", {}) + brain = d.get("brain", "?") + queries = d.get("queries", "?") + noe_detail = f" brain={brain} queries={queries}" + else: + noe_detail = " unreachable — start noetica or set NOETICA_URL" + lines.append(f" {noe_dot}{C_RESET} {C_WHITE}Noetica{C_RESET} {C_GREY}{noe_detail}{C_RESET}") + + bb_dot = (C_CYAN + "●") if bb["ok"] else (C_ORANGE + "○") + bb_detail = f" {bb.get('tabs', 0)} tab(s)" if bb["ok"] else " unreachable — open BearBrowser" + lines.append(f" {bb_dot}{C_RESET} {C_WHITE}BearBrowser{C_RESET} {C_GREY}{bb_detail}{C_RESET}") + + lines.append(f" {hr(W)}") + + # Memory mesh section + lines.append(f" {C_PURPLE}MEMORY MESH{C_RESET} {C_GREY}(recent events){C_RESET}") + if not mesh: + lines.append(f" {C_DIM} — no events yet (turtle-capture to add){C_RESET}") + for ev in reversed(mesh): + ts = ev.get("ts", "")[:16] + kind = ev.get("kind", "?")[:12] + src = ev.get("source", "?")[:10] + title = ev.get("title", ev.get("content", ""))[:50] + lines.append( + f" {C_PURPLE}·{C_RESET} {C_GREY}{ts}{C_RESET}" + f" {C_YELLOW}{kind:<12}{C_RESET}" + f" {C_GREY}[{src}]{C_RESET}" + f" {C_WHITE}{title}{C_RESET}" + ) + + lines.append(f" {hr(W)}") + + # Recent shell history + lines.append(f" {C_BLUE}RECENT SHELL{C_RESET} {C_GREY}(last 5 commands){C_RESET}") + if not cmds: + lines.append(f" {C_DIM} — no history yet{C_RESET}") + for c in reversed(cmds): + cmd = c.get("cmd", "")[:60] + ex = c.get("exit", 0) + dur = c.get("duration", 0) + ex_c = C_TEAL if ex == 0 else C_ORANGE + dur_s = f" {dur:.0f}s" if dur and dur > 0.5 else "" + lines.append(f" {ex_c}{'✓' if ex==0 else f'✗{ex}'}{C_RESET} {C_WHITE}{cmd}{C_RESET}{C_GREY}{dur_s}{C_RESET}") + + lines.append("") + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--once", action="store_true", help="print once and exit (no live refresh)") + args = parser.parse_args() + + if args.once: + agents = get_running_agents() + noetica = noetica_status() + bb = bearbrowser_status() + mesh = recent_mesh_events() + cmds = recent_commands() + now = datetime.datetime.now().strftime("%H:%M:%S") + print(format_panel(agents, noetica, bb, mesh, cmds, now)) + return + + # Live refresh loop + try: + while True: + agents = get_running_agents() + noetica = noetica_status() + bb = bearbrowser_status() + mesh = recent_mesh_events() + cmds = recent_commands() + now = datetime.datetime.now().strftime("%H:%M:%S") + panel = format_panel(agents, noetica, bb, mesh, cmds, now) + + # Count lines for in-place rewrite + panel_lines = panel.splitlines() + n = len(panel_lines) + + # Move cursor up by n lines if not first paint + if hasattr(main, "_painted"): + sys.stdout.write(f"\033[{main._painted}A\033[J") + main._painted = n + + sys.stdout.write(panel + "\n") + sys.stdout.flush() + + # Wait for 2s but break on 'q' + import select + rlist, _, _ = select.select([sys.stdin], [], [], 2.0) + if rlist: + ch = sys.stdin.read(1) + if ch in ("q", "Q", "\x03"): + break + except KeyboardInterrupt: + pass + finally: + sys.stdout.write(C_RESET + "\n") + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/bin/turtle-recall b/assets/sourceos/bin/turtle-recall new file mode 100755 index 00000000000..91535c7fc9f --- /dev/null +++ b/assets/sourceos/bin/turtle-recall @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +"""turtle-recall — query the memory mesh for past context. + +Shell alias: ?? + +Sources: + 1. Local memory mesh JSONL (~/.local/state/sourceos/memory-mesh/context.jsonl) + 2. ~/notes/ markdown files (Goose Notes pickup dir) + 3. Noetica semantic search (if running) + +Output: formatted matches printed to stdout (pipeline-friendly). +""" + +from __future__ import annotations + +import argparse +import difflib +import glob +import json +import os +import re +import sys +from pathlib import Path +from urllib import request as urlreq + +MESH_DIR = Path(os.getenv("XDG_STATE_HOME", str(Path.home() / ".local/state"))) / "sourceos" / "memory-mesh" +NOTES_DIR = Path(os.getenv("GOOSE_NOTES_DIR", str(Path.home() / "notes"))) +NOETICA_URL = os.getenv("NOETICA_URL", "http://localhost:7700") + +# TurtleTerm Dark colors +C_RESET = "\033[0m" +C_BLUE = "\033[38;2;88;166;255m" +C_TEAL = "\033[38;2;63;185;80m" +C_PURPLE = "\033[38;2;188;140;255m" +C_WHITE = "\033[38;2;230;237;243m" +C_GREY = "\033[38;2;139;148;158m" +C_DIM = "\033[2m" +C_BOLD = "\033[1m" + + +def load_mesh_events(limit: int = 500) -> list[dict]: + ctx = MESH_DIR / "context.jsonl" + if not ctx.exists(): + return [] + events = [] + try: + for line in ctx.read_text(errors="replace").splitlines()[-limit:]: + try: + events.append(json.loads(line)) + except Exception: + pass + except Exception: + pass + return events + + +def load_notes(limit: int = 100) -> list[dict]: + notes = [] + if not NOTES_DIR.exists(): + return notes + try: + paths = sorted(NOTES_DIR.glob("*.md"), key=lambda p: p.stat().st_mtime, reverse=True) + for p in paths[:limit]: + try: + text = p.read_text(errors="replace") + # Parse frontmatter title + m = re.search(r'^title:\s*"?([^"\n]+)"?', text, re.M) + title = m.group(1) if m else p.stem + notes.append({ + "kind": "note", + "source": "goose-notes", + "title": title, + "content": text[:800], + "path": str(p), + "ts": "", + }) + except Exception: + pass + except Exception: + pass + return notes + + +def score_entry(query: str, entry: dict) -> float: + q = query.lower() + keywords = q.split() + text = " ".join([ + entry.get("title", ""), + entry.get("content", ""), + entry.get("kind", ""), + entry.get("source", ""), + ]).lower() + + kw = sum(2.0 for kw in keywords if kw in text) + seq = difflib.SequenceMatcher(None, q, text[:200]).ratio() + return kw + seq + + +def search_local(query: str, entries: list[dict]) -> list[dict]: + if not query: + return entries[-20:] + scored = [(score_entry(query, e), e) for e in entries] + scored.sort(key=lambda x: x[0], reverse=True) + return [e for s, e in scored if s > 0.1][:15] + + +def search_noetica(query: str, entries: list[dict]) -> list[dict]: + try: + texts = "\n".join( + f"{i}: [{e.get('kind','?')}] {e.get('title','')} — {e.get('content','')[:100]}" + for i, e in enumerate(entries[-100:]) + ) + payload = json.dumps({ + "messages": [{ + "role": "user", + "content": ( + f"Memory mesh entries (index: [kind] title — content):\n{texts}\n\n" + f"Return the 8 most relevant entry indices for this query: {query!r}\n" + f"Reply ONLY with a JSON array of integers." + ), + }], + "stream": False, + }).encode() + req = urlreq.Request( + f"{NOETICA_URL}/api/chat", + data=payload, + headers={"Content-Type": "application/json"}, + ) + with urlreq.urlopen(req, timeout=3) as resp: + data = json.load(resp) + content = data.get("message", {}).get("content", "") or data.get("content", "") + m = re.search(r"\[[\d,\s]+\]", content) + if m: + indices = json.loads(m.group()) + base = entries[-100:] + return [base[i] for i in indices if 0 <= i < len(base)] + except Exception: + pass + return [] + + +def format_entry(e: dict, i: int) -> str: + ts = e.get("ts", "")[:16] + kind = e.get("kind", "?") + source = e.get("source", "?") + title = e.get("title", "") + content = e.get("content", "").strip()[:200] + path = e.get("path", "") + + kind_c = { + "capture": C_TEAL, + "note": C_PURPLE, + "recall": C_BLUE, + }.get(kind, C_GREY) + + lines = [ + f" {C_GREY}{i+1:>2}.{C_RESET} {kind_c}{kind:<10}{C_RESET} " + f"{C_GREY}{ts} [{source}]{C_RESET}", + f" {C_WHITE}{C_BOLD}{title}{C_RESET}", + ] + if content and content != title: + # Show first 2 lines of content + for cl in content.splitlines()[:2]: + cl = cl.strip() + if cl: + lines.append(f" {C_DIM}{cl}{C_RESET}") + if path: + lines.append(f" {C_GREY}{path}{C_RESET}") + return "\n".join(lines) + + +def main(): + parser = argparse.ArgumentParser(description="Query the SourceOS memory mesh") + parser.add_argument("query", nargs="*", help="Search query") + parser.add_argument("--json", action="store_true", help="Output as JSON array") + parser.add_argument("--limit", type=int, default=10) + parser.add_argument("--no-notes", action="store_true", help="Skip ~/notes/") + parser.add_argument("--no-noetica", action="store_true", help="Skip Noetica semantic search") + args = parser.parse_args() + + query = " ".join(args.query).strip() + + # Load all sources + mesh = load_mesh_events() + notes = load_notes() if not args.no_notes else [] + all_entries = mesh + notes + + if not all_entries: + print(f"{C_GREY}No memory mesh entries yet. Use: some-command | turtle-capture{C_RESET}", file=sys.stderr) + sys.exit(1) + + # Search + if query and not args.no_noetica: + results = search_noetica(query, all_entries) + if not results: + results = search_local(query, all_entries) + else: + results = search_local(query, all_entries) + + results = results[:args.limit] + + if not results: + print(f"{C_GREY}No matches for {query!r}{C_RESET}", file=sys.stderr) + sys.exit(1) + + if args.json: + print(json.dumps(results, indent=2)) + return + + if sys.stdout.isatty(): + print() + hdr = f"query: {query!r}" if query else "recent memory (no query)" + print(f" {C_BLUE}{C_BOLD}◆ Memory Mesh Recall{C_RESET} {C_GREY}{hdr} ({len(results)} results){C_RESET}") + print(f" {C_GREY}{'─' * 66}{C_RESET}") + for i, e in enumerate(results): + print(format_entry(e, i)) + print() + else: + # Non-interactive: print titles and content for piping + for e in results: + print(f"# {e.get('title', '')}") + print(e.get("content", "").strip()[:400]) + print() + + +if __name__ == "__main__": + main() diff --git a/assets/sourceos/shell/turtle-shell-init.zsh b/assets/sourceos/shell/turtle-shell-init.zsh index 61599e907c4..f66a59577d4 100644 --- a/assets/sourceos/shell/turtle-shell-init.zsh +++ b/assets/sourceos/shell/turtle-shell-init.zsh @@ -60,6 +60,19 @@ _TURTLE_ZSH_STARTED_AT="" _TURTLE_ZSH_CMD_EPOCH=0 _TURTLE_ZSH_EVT_ID="" +# ── AI/shell boundary: print a teal left-gutter bar before AI-sourced output ─ +# Called by pending_command injection path (see turtleterm.lua) via a state flag. +# The bar makes it immediately obvious: "this output came from an AI action, not +# your shell." Warp's most-upvoted UX complaint is this ambiguity. We solve it. +_turtle_ai_boundary_start() { + # OSC 8 hyperlink trick: a teal ▍ gutter with OSC 133 B for semantic zone + printf '\e]133;B\a' + printf '\e[38;2;57;197;207m▍\e[0m ' # teal ▍ = AI-sourced output +} +_turtle_ai_boundary_end() { + printf '\e]133;C\a' +} + preexec() { local cmd="$1" [[ -z "$cmd" ]] && return @@ -114,6 +127,42 @@ precmd() { printf '%d' "$_elapsed" > "$state_dir/last_duration" 2>/dev/null || true fi + # ── inline failure explainer ─────────────────────────────────────────────── + # On non-zero exit: ask Noetica reason-lane for a structured diagnosis and + # print it inline BELOW the error output — first-class UX, not a toast. + # Opt out: TURTLE_EXPLAIN_ERRORS=0 + if [[ $exit_status -ne 0 && "${TURTLE_EXPLAIN_ERRORS:-1}" != "0" && -n "$_TURTLE_ZSH_CMD" ]]; then + local _last_cmd_for_explain="$_TURTLE_ZSH_CMD" + { + local _explanation + _explanation=$(python3 -c " +import urllib.request, urllib.error, json, sys, os +try: + payload = json.dumps({ + 'messages': [{'role':'user','content': + f'Shell command failed (exit {sys.argv[1]}): \`{sys.argv[2]}\`\n' + f'Give a 1-sentence cause and 2-3 concrete fix suggestions. No preamble. Be terse.' + }], + 'stream': False + }).encode() + req = urllib.request.Request( + os.getenv('NOETICA_URL','http://localhost:7700') + '/api/chat', + data=payload, headers={'Content-Type':'application/json'}) + with urllib.request.urlopen(req, timeout=4) as r: + d = json.load(r) + content = d.get('message',{}).get('content','') or d.get('content','') + if content: + print(content.strip()) +except Exception: + pass +" "$exit_status" "$_last_cmd_for_explain" 2>/dev/null) + if [[ -n "$_explanation" ]]; then + printf '\n\e[38;2;88;166;255m◆\e[0m \e[2mexit %d\e[0m %s\n\n' \ + "$exit_status" "$_explanation" >&2 + fi + } &! + fi + # Long command notification (>10s, skipped if TURTLE_NOTIFY_THRESHOLD=0) if [[ -n "$_TURTLE_ZSH_CMD" && ${_TURTLE_ZSH_CMD_EPOCH:-0} -gt 0 ]]; then local elapsed=$(( ${EPOCHSECONDS:-0} - _TURTLE_ZSH_CMD_EPOCH )) @@ -158,8 +207,106 @@ precmd() { } } EOF + + # ── rich history index for turtle-history semantic search ───────────────── + # Append one JSONL line per completed command. Debounced to skip rapid-fire + # completions (like make steps) — only write if cmd is non-trivial. + if [[ ${#cmd} -gt 3 && "$cmd" != "ls" && "$cmd" != "cd"* ]]; then + local _hist_dir; _hist_dir="$(_turtle_state_dir)" + local _hist_file="$_hist_dir/history.jsonl" + local _dur=$(( ${EPOCHSECONDS:-0} - ${_TURTLE_ZSH_CMD_EPOCH:-0} )) + local _cwd; _cwd="$(pwd)" + local _ts; _ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo '')" + local _branch="" + _branch="$(git -C "$_cwd" branch --show-current 2>/dev/null || echo '')" + # fire-and-forget, never blocks the prompt + { + printf '{"cmd":%s,"exit":%d,"cwd":%s,"branch":%s,"ts":%s,"duration":%d}\n' \ + "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$cmd" 2>/dev/null || echo '""')" \ + "$exit_status" \ + "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$_cwd" 2>/dev/null || echo '""')" \ + "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$_branch" 2>/dev/null || echo '""')" \ + "$(python3 -c 'import json,sys; print(json.dumps(sys.argv[1]))' "$_ts" 2>/dev/null || echo '""')" \ + "$_dur" >> "$_hist_file" 2>/dev/null + # cap at 5000 lines (trim oldest) + if [[ $(wc -l < "$_hist_file" 2>/dev/null || echo 0) -gt 5000 ]]; then + tail -4000 "$_hist_file" > "$_hist_file.tmp" && mv "$_hist_file.tmp" "$_hist_file" + fi + } &! + fi +} + +# ── turtle-history: semantic ^R replacement ──────────────────────────────────── +# ^R → fzf history with exit codes, cwd context, duration +# ^R ? → type a NL query, backed by Noetica when available +_turtle_history_widget() { + local query="${BUFFER}" + local selected + # Run turtle-history in widget mode; it handles fzf/plain fallback internally + selected="$(turtle-history --widget ${query:+"$query"} 2>/dev/tty — quick memory recall from any prompt +# Example: ?? postgres replica setup +??() { + local _turtle_recall_bin + _turtle_recall_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-recall" + [[ -x "$_turtle_recall_bin" ]] || _turtle_recall_bin="turtle-recall" + "$_turtle_recall_bin" "$@" +} + +# tc — pipe alias to capture terminal output to Goose Notes + mesh +# Example: docker logs myapp | tc "app crash log" +tc() { + local _turtle_capture_bin + _turtle_capture_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-capture" + [[ -x "$_turtle_capture_bin" ]] || _turtle_capture_bin="turtle-capture" + "$_turtle_capture_bin" "$@" } +# mc — toggle mission control panel (TurtleTerm only; graceful no-op elsewhere) +_turtle_mc() { + local _mc_bin + _mc_bin="$(dirname "$(readlink -f "${(%):-%x}" 2>/dev/null || echo "${0:A}")")/../bin/turtle-mission-control" + [[ -x "$_mc_bin" ]] || _mc_bin="turtle-mission-control" + if [[ -n "${WEZTERM_PANE:-}" ]]; then + # In WezTerm: prefer the CMD+SHIFT+M binding; fallback to running in pane + "$_mc_bin" --once + else + "$_mc_bin" --once + fi +} + +# Auto-write memory mesh active context on each directory change +_turtle_mesh_cd() { + local _state="${XDG_STATE_HOME:-$HOME/.local/state}/sourceos/memory-mesh" + local _branch="" + _branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + mkdir -p "$_state" + printf '{"updated":"%s","cwd":"%s","branch":"%s","focus":"cd %s"}\n' \ + "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$PWD" "$_branch" "$PWD" \ + > "$_state/active.json" +} + +# Hook into chpwd for passive context tracking +if (( ${chpwd_functions[(I)_turtle_mesh_cd]} == 0 )); then + chpwd_functions+=(_turtle_mesh_cd) +fi + # ============================================================ # AI ghost-text — two modes: # 1. Explicit: ALT+/ or CTRL+SPACE fires immediately (synchronous) @@ -202,7 +349,8 @@ _turtle_ai_complete() { if [[ -n "$result" && "$result" != "$current_buffer" ]]; then _TURTLE_GHOST_SUGGESTION="$result" _TURTLE_GHOST_BUF="$current_buffer" - zle -M " AI▸ $result [ALT+/ to accept]" + # AI/shell boundary: blue ◆ prefix makes AI suggestions visually distinct from shell history + zle -M $'\e[38;2;88;166;255m◆ AI\e[0m '"$result"$' \e[2m[ALT+/ to accept]\e[0m' fi } zle -N _turtle_ai_complete diff --git a/assets/sourceos/tests/test_turtle_language_symbol_merge.py b/assets/sourceos/tests/test_turtle_language_symbol_merge.py new file mode 100644 index 00000000000..0a895b6a0c2 --- /dev/null +++ b/assets/sourceos/tests/test_turtle_language_symbol_merge.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Regression coverage for turtle-language symbol merge under a flaky LSP. + +When the SynapseIQ LSP is reachable, `turtle-language symbols` merges the LSP +result with the local AST walk. CI has no LSP, so that merge path is otherwise +never exercised — this test drives it directly with malformed LSP payloads that +a real server can return (null symbols, scalar entries, null line numbers) and +asserts the command neither crashes nor drops a local symbol. +""" +import importlib.machinery +import importlib.util +import sys +import tempfile +from pathlib import Path + +BIN = Path(__file__).resolve().parents[1] / "bin" / "turtle-language" + + +def _load(): + loader = importlib.machinery.SourceFileLoader("turtle_language_mod", str(BIN)) + spec = importlib.util.spec_from_loader(loader.name, loader) + mod = importlib.util.module_from_spec(spec) + loader.exec_module(mod) + return mod + + +def _symbols_with_lsp(mod, payload): + mod.ensure_synapseiq = lambda: None + mod.synapseiq_symbols = lambda *a, **k: payload + with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as fh: + fh.write("class Greeter:\n def hello(self):\n return 1\n") + p = Path(fh.name) + try: + return mod.symbols(p) + finally: + p.unlink(missing_ok=True) + + +def main() -> int: + mod = _load() + names = lambda res: {s.get("name") for s in res["symbols"]} + + # 1. null "symbols" must not crash; local symbols survive. + r = _symbols_with_lsp(mod, {"symbols": None}) + assert "Greeter" in names(r) and "hello" in names(r), r + + # 2. non-dict entries are filtered, not fatal. + r = _symbols_with_lsp(mod, {"symbols": ["junk", 42, {"name": "Greeter", "line": 1}]}) + assert "hello" in names(r), r + + # 3. a present-but-null line must not blow up the sort. + r = _symbols_with_lsp(mod, {"symbols": [{"name": "Greeter", "line": None}]}) + assert "hello" in names(r), r + + # 4. a top-level LSP symbol sharing a method's name must NOT drop the method. + r = _symbols_with_lsp(mod, {"symbols": [{"name": "hello", "line": 99}]}) + hello_lines = sorted(s.get("line") for s in r["symbols"] if s.get("name") == "hello") + assert len(hello_lines) == 2, f"method dropped by name-only dedup: {r['symbols']}" + + # 5. non-dict top-level payload falls back to the builtin heuristic cleanly. + r = _symbols_with_lsp(mod, ["not", "a", "dict"]) + assert r["source"] == "builtin-heuristic" and "hello" in names(r), r + + print("turtle-language symbol merge: 5/5 robustness checks pass") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/assets/sourceos/tests/test_turtle_linux_native_packaging.py b/assets/sourceos/tests/test_turtle_linux_native_packaging.py index 9e101bcc10b..befb8d9e6dc 100644 --- a/assets/sourceos/tests/test_turtle_linux_native_packaging.py +++ b/assets/sourceos/tests/test_turtle_linux_native_packaging.py @@ -20,12 +20,12 @@ def main() -> int: arch_builder = read("packaging/scripts/build-arch-package.sh") native_manifest = read("packaging/scripts/write-native-package-manifest.py") - assert "Package: turtle-term" in deb + assert "Package: turtleterm" in deb assert "Architecture: amd64 arm64" in deb assert "Homepage: https://github.com/SourceOS-Linux/TurtleTerm" in deb assert "libexec/turtle-term" in deb - assert "Name: turtle-term" in rpm + assert "Name: turtleterm" in rpm assert "ExclusiveArch: x86_64 aarch64" in rpm assert "fontconfig-devel" in rpm assert "ai.sourceos.TurtleTerm.desktop" in rpm @@ -34,7 +34,7 @@ def main() -> int: assert "%{_bindir}/turtle-agent-machine" in rpm assert "%{_bindir}/turtle-language" in rpm - assert "pkgname=turtle-term" in arch + assert "pkgname=turtleterm" in arch assert "arch=('x86_64' 'aarch64')" in arch assert "TURTLE_TERM_STAGE_PREFIX" in arch diff --git a/assets/sourceos/tests/test_turtle_term_release_readiness.py b/assets/sourceos/tests/test_turtle_term_release_readiness.py index 96f3f569d7f..eca79812b7e 100644 --- a/assets/sourceos/tests/test_turtle_term_release_readiness.py +++ b/assets/sourceos/tests/test_turtle_term_release_readiness.py @@ -117,9 +117,9 @@ ] REQUIRED_NATIVE_WORKFLOW_SNIPPETS = [ - "turtle-term_*.deb", - "turtle-term-*.rpm", - "turtle-term-*.pkg.tar.zst", + "turtleterm_*.deb", + "turtleterm-*.rpm", + "turtleterm-*.pkg.tar.zst", "turtle-term-native-packages.index.json", "actions/attest@v4", ] diff --git a/assets/sourceos/turtleterm.lua b/assets/sourceos/turtleterm.lua index 78a97ca1883..220d6b8d9be 100644 --- a/assets/sourceos/turtleterm.lua +++ b/assets/sourceos/turtleterm.lua @@ -40,22 +40,42 @@ config.default_prog = nil config.automatically_reload_config = true config.check_for_updates = false --- iTerm2 default color palette -local iterm2_colors = { - foreground = '#c7c7c7', - background = '#000000', - cursor_bg = '#ffffff', - cursor_fg = '#000000', - cursor_border = '#ffffff', - selection_bg = '#4d4d4d', - selection_fg = '#ffffff', +-- TurtleTerm Dark — designed color palette (GitHub Dark-spectrum, terminal-tuned) +local turtleterm_dark = { + foreground = '#e6edf3', + background = '#0d1117', + cursor_bg = '#58a6ff', + cursor_fg = '#0d1117', + cursor_border = '#58a6ff', + selection_bg = '#1f3044', + selection_fg = '#e6edf3', ansi = { - '#000000', '#c91b00', '#00c200', '#c7c400', - '#0225c7', '#ca30c7', '#00c5c7', '#c7c7c7', + '#21262d', -- black + '#f85149', -- red + '#3fb950', -- green + '#d29922', -- yellow + '#388bfd', -- blue + '#bc8cff', -- magenta + '#39c5cf', -- cyan + '#b1bac4', -- white }, brights = { - '#686868', '#ff6e67', '#5ffa68', '#fffc67', - '#6871ff', '#ff77ff', '#60fdff', '#ffffff', + '#30363d', -- bright black + '#ff7b72', -- bright red + '#56d364', -- bright green + '#e3b341', -- bright yellow + '#79c0ff', -- bright blue + '#d2a8ff', -- bright magenta + '#56d4dd', -- bright cyan + '#cdd9e5', -- bright white + }, + tab_bar = { + background = '#010409', + active_tab = { bg_color = '#0d1117', fg_color = '#58a6ff', intensity = 'Bold' }, + inactive_tab = { bg_color = '#010409', fg_color = '#8b949e' }, + inactive_tab_hover = { bg_color = '#161b22', fg_color = '#e6edf3' }, + new_tab = { bg_color = '#010409', fg_color = '#8b949e' }, + new_tab_hover = { bg_color = '#161b22', fg_color = '#58a6ff' }, }, } @@ -64,7 +84,7 @@ if env('TURTLETERM_COLOR_SCHEME', '') ~= '' then elseif env('SOURCEOS_TERMINAL_COLOR_SCHEME', '') ~= '' then config.color_scheme = env('SOURCEOS_TERMINAL_COLOR_SCHEME', '') else - config.colors = iterm2_colors + config.colors = turtleterm_dark end -- Load persisted theme choice (written by theme picker on confirm) do @@ -76,20 +96,22 @@ do end end +-- JetBrains Mono is vendored in assets/fonts/ — use it as the default. +-- Ligatures disabled by default (some users hate them; enable via TURTLETERM_LIGATURES=1). +local _ligatures = env('TURTLETERM_LIGATURES', '0') == '1' config.font = wezterm.font_with_fallback({ - 'Menlo', - 'Monaco', - 'Courier New', - 'Symbols Nerd Font Mono', + { + family = 'JetBrains Mono', + weight = 'Regular', + harfbuzz_features = _ligatures and { 'calt=1', 'liga=1' } or { 'calt=0', 'liga=0' }, + }, + { family = 'Menlo' }, + { family = 'Symbols Nerd Font Mono', scale = 0.85 }, }) -config.font_size = tonumber(env('TURTLETERM_FONT_SIZE', env('SOURCEOS_TERMINAL_FONT_SIZE', '13.0'))) +config.font_size = tonumber(env('TURTLETERM_FONT_SIZE', env('SOURCEOS_TERMINAL_FONT_SIZE', '13.5'))) +config.line_height = 1.15 -config.window_padding = { - left = 5, - right = 5, - top = 5, - bottom = 5, -} +config.window_padding = { left = 16, right = 16, top = 12, bottom = 10 } config.use_fancy_tab_bar = true config.hide_tab_bar_if_only_one_tab = true @@ -97,15 +119,35 @@ config.tab_bar_at_bottom = false config.window_decorations = 'TITLE | RESIZE' config.audible_bell = 'Disabled' config.enable_scroll_bar = false -config.scrollback_lines = 20000 - -config.default_cursor_style = 'BlinkingBlock' -config.cursor_blink_rate = 600 -config.cursor_blink_ease_in = 'Constant' -config.cursor_blink_ease_out = 'Constant' - -config.window_background_opacity = 1.0 -config.macos_window_background_blur = 0 +config.scrollback_lines = 50000 + +config.default_cursor_style = 'BlinkingBar' +config.cursor_blink_rate = 500 +config.cursor_blink_ease_in = 'EaseIn' +config.cursor_blink_ease_out = 'EaseOut' + +-- macOS: subtle transparency + blur — feels native without being distracting +config.window_background_opacity = tonumber(env('TURTLETERM_OPACITY', '0.97')) +config.macos_window_background_blur = 24 +config.text_background_opacity = 1.0 + +-- Titlebar chrome that matches the dark theme +config.window_frame = { + font = wezterm.font('JetBrains Mono', { weight = 'Medium' }), + font_size = 11.5, + active_titlebar_bg = '#161b22', + inactive_titlebar_bg = '#0d1117', + active_titlebar_fg = '#e6edf3', + inactive_titlebar_fg = '#8b949e', + border_left_color = '#21262d', + border_right_color = '#21262d', + border_top_color = '#21262d', + border_bottom_color = '#21262d', + border_left_width = '0.5cell', + border_right_width = '0.5cell', + border_bottom_height = '0cell', + border_top_height = '0cell', +} config.unix_domains = { { @@ -997,6 +1039,133 @@ local function turtle_atlas_context() end) end +-- ============================================================ +-- Mission Control (CMD+SHIFT+M) — multi-agent status panel +-- Opens turtle-mission-control in a bottom split (25% height). +-- ============================================================ + +local function turtle_mission_control() + return wezterm.action_callback(function(window, pane) + -- Toggle: close if already open + local mc_id = wezterm.GLOBAL.mission_control_pane_id + if mc_id then + local found = false + for _, p in ipairs(window:mux_window():panes()) do + if p:pane_id() == mc_id then found = true; break end + end + if found then + wezterm.GLOBAL.mission_control_pane_id = nil + window:toast_notification('TurtleTerm', 'Mission Control closed', nil, 1500) + return + end + end + local new_pane = pane:split { + direction = 'Bottom', + size = 0.28, + command = { + args = { + 'python3', turtle_bin('turtle-mission-control'), + }, + }, + } + if new_pane then + wezterm.GLOBAL.mission_control_pane_id = new_pane:pane_id() + end + end) +end + +-- ============================================================ +-- Capture (CMD+SHIFT+C) — save selection/last-output to Goose Notes + mesh +-- ============================================================ + +local function turtle_capture_selection() + return wezterm.action_callback(function(window, pane) + local sel = window:get_selection_text_for_pane(pane) + -- Fall back to last output zone + if not sel or sel == '' then + pcall(function() + local zones = pane:get_semantic_zones() + for _, z in ipairs(zones) do + if z.semantic_type == 'Output' then + sel = pane:get_text_from_semantic_zone(z) or '' + end + end + end) + end + if not sel or sel == '' then + window:toast_notification('TurtleTerm', 'Select output first, then CMD+SHIFT+C to capture', nil, 3000) + return + end + local tmp = '/tmp/turtle-capture-sel.txt' + local f = io.open(tmp, 'w') + if f then f:write(sel); f:close() end + window:perform_action( + act.SpawnCommandInNewPane { + direction = 'Bottom', + size = { Percent = 20 }, + command = { args = { + 'sh', '-c', + 'python3 "' .. turtle_bin('turtle-capture') .. '" < /tmp/turtle-capture-sel.txt' + .. ' && printf "\\n captured ✓ — press Enter to close\\n" && read -r _' + .. ' || (printf "\\ncapture failed\\n"; read -r _)', + }}, + }, pane + ) + window:toast_notification('TurtleTerm', 'Capturing to Goose Notes…', nil, 2000) + end) +end + +-- ============================================================ +-- Recall (CMD+SHIFT+L) — query memory mesh in a split pane +-- ============================================================ + +local function turtle_recall() + return wezterm.action_callback(function(window, pane) + window:perform_action( + act.PromptInputLine { + description = '◆ Memory Recall — describe what you\'re looking for', + action = wezterm.action_callback(function(w, p, query) + if not query or query == '' then return end + w:perform_action( + act.SpawnCommandInNewPane { + direction = 'Right', + size = { Percent = 42 }, + command = { args = { + 'sh', '-c', + 'python3 "' .. turtle_bin('turtle-recall') .. '" ' .. wezterm.shell_quote_arg(query) + .. '; echo; printf " press Enter to close... "; read -r _', + }}, + }, p + ) + w:toast_notification('TurtleTerm Recall', 'Searching: ' .. query, nil, 2000) + end), + }, pane + ) + end) +end + +-- ============================================================ +-- Mesh push (CMD+SHIFT+U) — sync memory mesh to/from GCS +-- ============================================================ + +local function turtle_mesh_push() + return wezterm.action_callback(function(window, pane) + window:perform_action( + act.SpawnCommandInNewPane { + direction = 'Bottom', + size = { Percent = 15 }, + command = { args = { + 'sh', '-c', + 'python3 "' .. turtle_bin('turtle-mesh-push') .. '"' + .. ' && printf "\\n sync complete ✓ — press Enter\\n" && read -r _' + .. ' || (printf "\\nsync failed (gsutil required)\\n"; read -r _)', + }}, + }, pane + ) + window:toast_notification('TurtleTerm', 'Syncing memory mesh…', nil, 2000) + end) +end + -- ============================================================ -- AI Sidebar (CTRL+SHIFT+X) -- ============================================================ @@ -1248,6 +1417,11 @@ local PALETTE_COMMANDS = { -- Workspace { label = '💾 Save workspace CMD+SHIFT+S', id = 'workspace_save' }, { label = '📂 Restore workspace CMD+SHIFT+O', id = 'workspace_restore' }, + -- Memory mesh / cross-app integration + { label = '◆ Mission Control (agents) CMD+SHIFT+M', id = 'mission_control' }, + { label = '◆ 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' }, } local function turtle_command_palette() @@ -1334,6 +1508,10 @@ local function turtle_command_palette() theme_picker = turtle_theme_picker(), workspace_save = turtle_workspace_save(), workspace_restore = turtle_workspace_restore(), + mission_control = turtle_mission_control(), + capture = turtle_capture_selection(), + recall = turtle_recall(), + mesh_push = turtle_mesh_push(), } local a = dispatch[id] if a then w:perform_action(a, p) end @@ -1931,6 +2109,11 @@ config.keys = { { key = 'h', mods = 'CTRL|SHIFT', action = wezterm.action_callback(function(w, p) turtle_ssh_picker(w, p) end) }, { key = 's', mods = 'CMD|SHIFT', action = turtle_workspace_save() }, -- Save workspace { key = 'o', mods = 'CMD|SHIFT', action = turtle_workspace_restore() }, -- Restore workspace + -- Memory mesh integration + { key = 'm', mods = 'CMD|SHIFT', action = turtle_mission_control() }, -- Mission Control panel + { key = 'c', mods = 'CMD|SHIFT', action = turtle_capture_selection() }, -- Capture to Goose Notes + { key = 'l', mods = 'CMD|SHIFT', action = turtle_recall() }, -- Memory mesh recall + { key = 'u', mods = 'CMD|SHIFT', action = turtle_mesh_push() }, -- Sync mesh to GCS { key = 's', mods = 'CTRL|SHIFT', @@ -2386,6 +2569,20 @@ wezterm.on('format-tab-title', function(tab, tabs, panes, config_, hover, max_wi return string.format(' %d %s ', idx, label) end) +-- Count running AI agents (Claude Code, Goose, Noetica agentd, turtle-autonomous) +local function count_agents() + local ok, out, _ = wezterm.run_child_process({ + 'sh', '-c', + "ps aux 2>/dev/null | grep -cE '(claude|goose|autonomous|noetica.*agent)' | tr -d ' \n' || echo 0" + }) + if ok and out then + local n = tonumber(out:match('%d+')) or 0 + -- subtract the grep itself + return math.max(0, n - 1) + end + return 0 +end + wezterm.on('update-right-status', function(window, pane) local domain = turtle_domain() local proc = '' @@ -2394,7 +2591,7 @@ wezterm.on('update-right-status', function(window, pane) pcall(function() cwd_uri = tostring(pane.current_working_dir) or '' end) local is_ssh = proc:find('ssh') ~= nil or cwd_uri:find('^ssh://') ~= nil - -- Plan step indicator: show ⚡ goal [step/total] when a plan is active + -- Plan step badge local plan_badge = '' do local home = os.getenv('HOME') or '' @@ -2406,38 +2603,53 @@ wezterm.on('update-right-status', function(window, pane) if ok2 and plan and plan.steps then local step = (plan.current_step or 0) + 1 local total = #plan.steps - local goal = (plan.goal or ''):sub(1, 30) + local goal = (plan.goal or ''):sub(1, 24) if step <= total then - plan_badge = string.format(' \xe2\x9a\xa1 %s [%d/%d] ', goal, step, total) + plan_badge = string.format('\xe2\x9a\xa1 %s [%d/%d] ', goal, step, total) end end end end + -- Agent count + local agent_part = '' + local n_agents = count_agents() + if n_agents > 0 then + agent_part = string.format('\xf0\x9f\xa4\x96 %d ', n_agents) -- 🤖 N + end + + -- Clock + local time_part = wezterm.time.now():format('%H:%M') + + -- Build right status + local parts = {} + if plan_badge ~= '' then table.insert(parts, plan_badge) end + if agent_part ~= '' then table.insert(parts, agent_part) end + if is_ssh then - window:set_right_status(plan_badge .. ' \xe2\x87\x84 ssh ') -- ⇄ ssh - -- Red title bar tint while inside SSH session + table.insert(parts, '\xe2\x87\x84 ssh ') local overrides = window:get_config_overrides() or {} if not overrides._ssh_frame then overrides._ssh_frame = true overrides.window_frame = { - active_titlebar_bg = '#3a0000', - inactive_titlebar_bg = '#290000', - active_titlebar_fg = '#ffaaaa', - inactive_titlebar_fg = '#cc7777', + font = wezterm.font('JetBrains Mono', { weight = 'Medium' }), + font_size = 11.5, + active_titlebar_bg = '#2d1215', + inactive_titlebar_bg = '#1a0a0b', + active_titlebar_fg = '#ff7b72', + inactive_titlebar_fg = '#cc5555', + border_left_color = '#4a1a1a', + border_right_color = '#4a1a1a', + border_top_color = '#4a1a1a', + border_bottom_color = '#4a1a1a', border_left_width = '0.5cell', border_right_width = '0.5cell', - border_bottom_height = '0.25cell', - border_top_height = '0.25cell', - border_left_color = '#5a0000', - border_right_color = '#5a0000', - border_bottom_color = '#5a0000', - border_top_color = '#5a0000', + border_bottom_height = '0cell', + border_top_height = '0cell', } window:set_config_overrides(overrides) end else - -- Clear SSH frame override when we leave SSH local overrides = window:get_config_overrides() or {} if overrides._ssh_frame then overrides._ssh_frame = nil @@ -2445,11 +2657,12 @@ wezterm.on('update-right-status', function(window, pane) window:set_config_overrides(overrides) end if domain ~= 'host' then - window:set_right_status(plan_badge .. string.format(' %s ', domain)) - else - window:set_right_status(plan_badge) + table.insert(parts, string.format('%s ', domain)) end end + + table.insert(parts, time_part .. ' ') + window:set_right_status(table.concat(parts, '')) end) wezterm.on('update-left-status', function(window, pane) diff --git a/packaging/homebrew/Formula/turtle-term.rb b/packaging/homebrew/Formula/turtle-term.rb index 7b6912cc5dc..8dd0d10a18d 100644 --- a/packaging/homebrew/Formula/turtle-term.rb +++ b/packaging/homebrew/Formula/turtle-term.rb @@ -123,7 +123,10 @@ def install def caveats <<~EOS - TurtleTerm v0.2 installed. + TurtleTerm v0.2 installed — the shell rides on the turtle's back. + + To launch TurtleTerm: + turtleterm Profile: #{etc}/turtle-term/turtleterm.lua Shell inits: #{pkgshare}/shell/ diff --git a/packaging/linux/arch/PKGBUILD b/packaging/linux/arch/PKGBUILD index a1f9adf237f..3cb8d653f34 100644 --- a/packaging/linux/arch/PKGBUILD +++ b/packaging/linux/arch/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: SourceOS Linux pkgname=turtleterm -pkgver=1.4.0 +pkgver=0.1.0 pkgrel=1 pkgdesc="AI agent terminal fabric for SourceOS — WezTerm + 38 agent commands" arch=('x86_64' 'aarch64') @@ -16,123 +16,34 @@ optdepends=( ) source=("$pkgname-$pkgver.tar.gz::https://github.com/SourceOS-Linux/TurtleTerm/archive/refs/tags/v$pkgver.tar.gz") sha256sums=('SKIP') +# GitHub's tag tarball extracts to -, not -. +_srcdir="TurtleTerm-$pkgver" prepare() { - cd "$pkgname-$pkgver" + cd "$_srcdir" export RUSTUP_TOOLCHAIN=stable cargo fetch --locked --target "$(rustc -vV | sed -n 's/host: //p')" } build() { - cd "$pkgname-$pkgver" + cd "$_srcdir" export RUSTUP_TOOLCHAIN=stable export CARGO_TARGET_DIR=target cargo build --release --locked -p wezterm -p wezterm-gui -p wezterm-mux-server } package() { - cd "$pkgname-$pkgver" - - # WezTerm engine binaries → libexec - install -d "$pkgdir/usr/lib/turtleterm" - install -Dm755 target/release/wezterm "$pkgdir/usr/lib/turtleterm/" - install -Dm755 target/release/wezterm-gui "$pkgdir/usr/lib/turtleterm/" - install -Dm755 target/release/wezterm-mux-server "$pkgdir/usr/lib/turtleterm/" - - # turtleterm wrapper Lua launcher → libexec - install -d "$pkgdir/usr/lib/turtleterm-bin" - if [ -f "assets/sourceos/bin/turtleterm" ]; then - install -Dm755 assets/sourceos/bin/turtleterm \ - "$pkgdir/usr/lib/turtleterm-bin/turtleterm" - fi - if [ -f "assets/sourceos/bin/turtleterm-mux-server" ]; then - install -Dm755 assets/sourceos/bin/turtleterm-mux-server \ - "$pkgdir/usr/lib/turtleterm-bin/turtleterm-mux-server" - fi - - # Shell wrapper shims - install -d "$pkgdir/usr/bin" - cat > "$pkgdir/usr/bin/turtleterm" << 'EOF' -#!/bin/sh -export TURTLE_TERM_RUNTIME_DIR="/usr/lib/turtleterm" -export TURTLETERM_CONFIG="/etc/turtleterm/turtleterm.lua" -exec "/usr/lib/turtleterm-bin/turtleterm" "$@" -EOF - chmod 0755 "$pkgdir/usr/bin/turtleterm" - - cat > "$pkgdir/usr/bin/turtleterm-mux-server" << 'EOF' -#!/bin/sh -export TURTLE_TERM_RUNTIME_DIR="/usr/lib/turtleterm" -exec "/usr/lib/turtleterm-bin/turtleterm-mux-server" "$@" -EOF - chmod 0755 "$pkgdir/usr/bin/turtleterm-mux-server" - - # Agent scripts - for script in \ - sourceos-term turtle-term turtle-agentd turtle-agentctl turtle-agent-status \ - turtle-tmux turtle-cloudfog turtle-superconscious turtle-agent-machine \ - turtle-language turtle-session turtle-synapseiq synapseiq-lsp \ - turtle-plan-view turtle-selftest turtle-runbook turtle-voice turtle-sync \ - turtle-perf turtle-persona turtle-files turtle-bg turtle-dash turtle-pr \ - turtle-issue turtle-hooks turtle-gitea turtle-ci turtle-review turtle-watch turtle-netwatch \ - turtle-cost turtle-copilot turtle-gh turtle-env turtle-diagnose \ - turtle-apply turtle-chain turtle-ai-chat; do - src="assets/sourceos/bin/${script}" - if [ -f "${src}" ]; then - install -Dm755 "${src}" "$pkgdir/usr/bin/${script}" - fi - done - - # MCP server - if [ -f "assets/sourceos/mcp/turtle-mcp-server" ]; then - install -d "$pkgdir/usr/share/turtleterm/mcp" - install -Dm755 assets/sourceos/mcp/turtle-mcp-server \ - "$pkgdir/usr/share/turtleterm/mcp/" - ln -s /usr/share/turtleterm/mcp/turtle-mcp-server \ - "$pkgdir/usr/bin/turtle-mcp-server" - fi - - # Shell integration files - install -d "$pkgdir/usr/share/turtleterm/shell" - for f in \ - turtle-shell-init.zsh turtle-shell-init.bash turtle-shell-init.fish \ - turtle-shell-init.ps1 \ - turtle-agentctl-completions.zsh turtle-agentctl-completions.bash \ - turtle-agentctl-completions.fish turtle-agentctl-completions.ps1 \ - turtle-gh-completions.zsh turtle-gh-completions.bash \ - turtle-copilot-completions.zsh turtle-copilot-completions.bash; do - if [ -f "assets/sourceos/shell/${f}" ]; then - install -Dm644 "assets/sourceos/shell/${f}" \ - "$pkgdir/usr/share/turtleterm/shell/${f}" - fi - done - - # Config - install -d "$pkgdir/etc/turtleterm" - if [ -f "assets/sourceos/turtleterm.lua" ]; then - install -Dm644 assets/sourceos/turtleterm.lua \ - "$pkgdir/etc/turtleterm/turtleterm.lua" - fi - - # Desktop integration - if [ -f "assets/sourceos/desktop/ai.sourceos.TurtleTerm.desktop" ]; then - install -Dm644 assets/sourceos/desktop/ai.sourceos.TurtleTerm.desktop \ - "$pkgdir/usr/share/applications/ai.sourceos.TurtleTerm.desktop" - fi - if [ -f "assets/sourceos/desktop/ai.sourceos.TurtleTerm.metainfo.xml" ]; then - install -Dm644 assets/sourceos/desktop/ai.sourceos.TurtleTerm.metainfo.xml \ - "$pkgdir/usr/share/metainfo/ai.sourceos.TurtleTerm.metainfo.xml" - fi - if [ -f "assets/sourceos/brand/ai.sourceos.TurtleTerm.svg" ]; then - install -Dm644 assets/sourceos/brand/ai.sourceos.TurtleTerm.svg \ - "$pkgdir/usr/share/icons/hicolor/scalable/apps/ai.sourceos.TurtleTerm.svg" - fi - - # Neovim plugin, skills, docs - for dir in neovim skills brand desktop mcp; do - [ -d "assets/sourceos/$dir" ] && \ - cp -r "assets/sourceos/$dir" "$pkgdir/usr/share/turtleterm/" - done - - install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" + cd "$_srcdir" + + # Single canonical staging (libexec/turtle-term layout) via the shared stage + # script — deb/rpm/arch all install the identical agent-command surface with + # the private WezTerm runtime under /usr/libexec/turtle-term (never on PATH) + # and launch wrappers that resolve the runtime at /usr. + TURTLE_TERM_STAGE_PREFIX="$pkgdir/usr" \ + TURTLE_TERM_ETC_DIR="$pkgdir/etc" \ + TURTLE_TERM_RUNTIME_PREFIX="/usr" \ + TURTLE_TERM_RUNTIME_ETC_DIR="/etc" \ + bash packaging/scripts/stage-linux-package.sh + + install -Dm644 LICENSE.md "$pkgdir/usr/share/licenses/$pkgname/LICENSE.md" } diff --git a/packaging/linux/deb/control b/packaging/linux/deb/control index c06793a5592..2983f41569a 100644 --- a/packaging/linux/deb/control +++ b/packaging/linux/deb/control @@ -1,8 +1,8 @@ Package: turtleterm -Version: 1.4.0 +Version: 0.1.0 Section: devel Priority: optional -Architecture: amd64 +Architecture: amd64 arm64 Maintainer: SourceOS Linux Depends: libc6, libfontconfig1, libfreetype6, libssl3, libx11-6, libxcb1, libxkbcommon0, zlib1g, python3 (>= 3.10) Recommends: git @@ -17,3 +17,5 @@ Description: TurtleTerm — AI agent terminal fabric for SourceOS . Shell integration for zsh, bash, fish, and PowerShell. MCP server for Claude Code integration. SynapseIQ LSP support. + . + Runtime commands and helpers install under /usr/libexec/turtle-term. diff --git a/packaging/linux/rpm/turtle-term.spec b/packaging/linux/rpm/turtle-term.spec index fb36e9c4c0f..834e8817c07 100644 --- a/packaging/linux/rpm/turtle-term.spec +++ b/packaging/linux/rpm/turtle-term.spec @@ -1,11 +1,12 @@ Name: turtleterm -Version: 1.4.0 +Version: 0.1.0 Release: 1%{?dist} Summary: AI agent terminal fabric for SourceOS License: MIT URL: https://github.com/SourceOS-Linux/TurtleTerm Source0: %{name}-%{version}.tar.gz +ExclusiveArch: x86_64 aarch64 BuildRequires: rust cargo cmake pkg-config BuildRequires: fontconfig-devel freetype-devel openssl-devel @@ -172,6 +173,10 @@ fi %ghost %{_bindir}/turtle-chain %ghost %{_bindir}/turtle-selftest %ghost %{_bindir}/turtle-mcp-server +%ghost %{_bindir}/turtle-cloudfog +%ghost %{_bindir}/turtle-superconscious +%ghost %{_bindir}/turtle-agent-machine +%ghost %{_bindir}/turtle-language %changelog * Sat Jun 21 2026 SourceOS Linux - 1.4.0-1 diff --git a/packaging/scripts/build-arch-package.sh b/packaging/scripts/build-arch-package.sh index 8db69a13b28..3aac6ee97ff 100755 --- a/packaging/scripts/build-arch-package.sh +++ b/packaging/scripts/build-arch-package.sh @@ -6,7 +6,7 @@ arch="${TURTLE_TERM_ARCH_ARCH:-$(uname -m)}" repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" out_dir="${TURTLE_TERM_OUT_DIR:-$repo_root/dist}" pkgroot="$out_dir/arch-root" -pkg="$out_dir/turtle-term-${version}-1-${arch}.pkg.tar.zst" +pkg="$out_dir/turtleterm-${version}-1-${arch}.pkg.tar.zst" case "$arch" in x86_64|aarch64) ;; @@ -26,8 +26,8 @@ TURTLE_TERM_RUNTIME_ETC_DIR="/etc" \ bash "$repo_root/packaging/scripts/stage-linux-package.sh" >/dev/null cat > "$pkgroot/.PKGINFO" < size = 0 arch = $arch license = MIT +depend = python depend = fontconfig depend = freetype2 depend = libx11 @@ -75,6 +76,8 @@ python3 "$repo_root/packaging/scripts/write-native-package-manifest.py" \ --kind arch \ --version "$version" \ --arch "$arch" \ - --out "$pkg.manifest.json" + --out "$pkg.manifest.json" >/dev/null +# stdout must carry ONLY the package path — verify-arch-package.sh captures it +# via command substitution, so any earlier chatter corrupts "$pkg". echo "$pkg" diff --git a/packaging/scripts/build-deb-package.sh b/packaging/scripts/build-deb-package.sh index 4ef1491edd6..fc202b44932 100755 --- a/packaging/scripts/build-deb-package.sh +++ b/packaging/scripts/build-deb-package.sh @@ -10,7 +10,7 @@ prefix="$package_root/usr" etc_dir="$package_root/etc" debian_dir="$package_root/DEBIAN" deb_build="$out_dir/deb-build" -deb="$out_dir/turtle-term_${version}_${arch}.deb" +deb="$out_dir/turtleterm_${version}_${arch}.deb" case "$arch" in amd64|arm64) ;; @@ -31,13 +31,13 @@ TURTLE_TERM_RUNTIME_ETC_DIR="/etc" \ bash "$repo_root/packaging/scripts/stage-linux-package.sh" >/dev/null cat > "$debian_dir/control" < -Depends: libc6, libfontconfig1, libfreetype6, libssl3, libx11-6, libxcb1, libxkbcommon0, zlib1g +Depends: libc6, libfontconfig1, libfreetype6, libssl3, libx11-6, libxcb1, libxkbcommon0, zlib1g, python3 (>= 3.10) Homepage: https://github.com/SourceOS-Linux/TurtleTerm Description: TurtleTerm trusted terminal and agent workbench TurtleTerm is the SourceOS policy-aware, agent-addressable terminal workbench diff --git a/packaging/scripts/build-rpm-package.sh b/packaging/scripts/build-rpm-package.sh index b2ddd995c59..87e3ac6e22f 100755 --- a/packaging/scripts/build-rpm-package.sh +++ b/packaging/scripts/build-rpm-package.sh @@ -14,13 +14,14 @@ rm -rf "$rpmbuild_root" mkdir -p "$rpmbuild_root/BUILD" "$rpmbuild_root/BUILDROOT" "$rpmbuild_root/RPMS" "$rpmbuild_root/SOURCES" "$rpmbuild_root/SPECS" "$rpmbuild_root/SRPMS" cat > "$spec" <= 3.10 Requires: fontconfig Requires: freetype Requires: libX11 @@ -47,19 +48,10 @@ if [ -f $repo_root/THIRD_PARTY_NOTICES.md ]; then cp $repo_root/THIRD_PARTY_NOTI %files %license /LICENSE.md %doc /THIRD_PARTY_NOTICES.md -/usr/bin/turtleterm -/usr/bin/turtleterm-mux-server -/usr/bin/turtle-term -/usr/bin/turtle-agentd -/usr/bin/turtle-agentctl -/usr/bin/turtle-agent-status -/usr/bin/turtle-tmux -/usr/bin/turtle-cloudfog -/usr/bin/turtle-superconscious -/usr/bin/turtle-agent-machine -/usr/bin/turtle-language -/usr/bin/turtle-session -/usr/bin/sourceos-term +# Glob the whole staged tree so %files always matches what +# stage-linux-package.sh installs (all agent commands, runtime, data) — +# an explicit list silently desyncs and trips "unpackaged file(s) found". +/usr/bin/* /etc/turtle-term/turtleterm.lua /usr/libexec/turtle-term/ /usr/share/applications/ai.sourceos.TurtleTerm.desktop @@ -69,7 +61,7 @@ if [ -f $repo_root/THIRD_PARTY_NOTICES.md ]; then cp $repo_root/THIRD_PARTY_NOTI EOF rpmbuild --define "_topdir $rpmbuild_root" -bb "$spec" >&2 -rpm="$(find "$rpmbuild_root/RPMS" -name 'turtle-term-*.rpm' -print -quit)" +rpm="$(find "$rpmbuild_root/RPMS" -name 'turtleterm-*.rpm' -print -quit)" if [ -z "$rpm" ]; then echo "no turtle-term RPM built under $rpmbuild_root/RPMS" >&2 find "$rpmbuild_root" -maxdepth 4 -type f -print >&2 @@ -81,6 +73,8 @@ python3 "$repo_root/packaging/scripts/write-native-package-manifest.py" \ --kind rpm \ --version "$version" \ --arch "$arch" \ - --out "$rpm.manifest.json" + --out "$rpm.manifest.json" >/dev/null +# stdout must carry ONLY the package path — verify-rpm-package.sh captures it +# via command substitution, so any earlier chatter corrupts "$rpm". echo "$rpm" \ No newline at end of file diff --git a/packaging/scripts/verify-arch-package-metadata.sh b/packaging/scripts/verify-arch-package-metadata.sh index 39e5ad7bc0c..7ecd95b4156 100644 --- a/packaging/scripts/verify-arch-package-metadata.sh +++ b/packaging/scripts/verify-arch-package-metadata.sh @@ -7,7 +7,7 @@ pkgbuild="$repo_root/packaging/linux/arch/PKGBUILD" test -f "$pkgbuild" bash -n "$pkgbuild" -grep -q '^pkgname=turtle-term$' "$pkgbuild" +grep -q '^pkgname=turtleterm$' "$pkgbuild" grep -q '^pkgver=0.1.0$' "$pkgbuild" grep -q "arch=('x86_64' 'aarch64')" "$pkgbuild" grep -q "license=('MIT')" "$pkgbuild" diff --git a/packaging/scripts/verify-arch-package.sh b/packaging/scripts/verify-arch-package.sh index 6d24bae3293..bfdc955e966 100755 --- a/packaging/scripts/verify-arch-package.sh +++ b/packaging/scripts/verify-arch-package.sh @@ -17,11 +17,13 @@ done pkg="$(TURTLE_TERM_OUT_DIR="$tmp" TURTLE_TERM_VERSION="0.1.0" TURTLE_TERM_ARCH_ARCH="$(uname -m)" \ bash "$repo_root/packaging/scripts/build-arch-package.sh")" extract="$tmp/extract" +contents="$tmp/arch-contents.txt" test -f "$pkg" test -f "$pkg.sha256" test -f "$pkg.manifest.json" sha256sum -c "$pkg.sha256" >/dev/null +tar --zstd -tf "$pkg" > "$contents" python3 - </dev/null -deb="$tmp/turtle-term_0.1.0_amd64.deb" +deb="$tmp/turtleterm_0.1.0_amd64.deb" contents="$tmp/deb-contents.txt" extract="$tmp/extract" test -f "$deb" @@ -33,13 +33,13 @@ assert manifest['product'] == 'TurtleTerm' assert manifest['kind'] == 'deb' assert manifest['version'] == '0.1.0' assert manifest['arch'] == 'amd64' -assert manifest['package'] == 'turtle-term_0.1.0_amd64.deb' +assert manifest['package'] == 'turtleterm_0.1.0_amd64.deb' assert manifest['profile'] == '/etc/turtle-term/turtleterm.lua' for command in ['turtle-agent-status', 'turtle-cloudfog', 'turtle-superconscious', 'turtle-agent-machine', 'turtle-language', 'turtle-session']: assert command in manifest['public_commands'], command PY -dpkg-deb --field "$deb" Package | grep -qx 'turtle-term' +dpkg-deb --field "$deb" Package | grep -qx 'turtleterm' dpkg-deb --field "$deb" Version | grep -qx '0.1.0' dpkg-deb --field "$deb" Architecture | grep -qx 'amd64' dpkg-deb --contents "$deb" > "$contents" diff --git a/packaging/scripts/verify-rpm-package.sh b/packaging/scripts/verify-rpm-package.sh index 004fd745a31..de58150efb1 100755 --- a/packaging/scripts/verify-rpm-package.sh +++ b/packaging/scripts/verify-rpm-package.sh @@ -49,7 +49,7 @@ for command in ['turtle-agent-status', 'turtle-cloudfog', 'turtle-superconscious assert command in manifest['public_commands'], command PY -rpm -qp --queryformat '%{NAME}\n' "$rpm" | grep -qx 'turtle-term' +rpm -qp --queryformat '%{NAME}\n' "$rpm" | grep -qx 'turtleterm' rpm -qp --queryformat '%{VERSION}\n' "$rpm" | grep -qx '0.1.0' contents="$tmp/contents.txt" rpm -qpl "$rpm" > "$contents"