Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ config.json.bak.*
cron_jobs.json
gateway.pid
daemon.pid
pet.pid
# The Merkle root over THIS install's adapters, and the per-adapter seals
# under it. Every install has its own adapters, so shipping one person's root
# would only ever tell someone else their own weights are wrong.
Expand Down
61 changes: 57 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,15 @@ default.
### The pet

```bash
symbio-pet # a cat on your desktop, watching this install
symbio-pet --demo # the same cat playing a scripted run; nothing is trained
symb pet # put a cat on your desktop, watching this install
symb pet --demo # the same cat playing a scripted run; nothing is trained
symb pet status # is it out
symb pet stop # call it in
```

`symb pet` starts it in the background, like `symb daemon start`; `symb pet run`
keeps it in the terminal instead, and `symbio-pet` does the same on its own.

A tilcayo — *Leopardus tilcayo*, the newest cat species, described on
17 September 2026: light-brown coat, big irregular rosettes, short round ears.
It is the fine-tune, drawn:
Expand All @@ -202,15 +207,63 @@ It is the fine-tune, drawn:
is kept, and the cat swats it off its collar when it is rolled back.

Between runs it sleeps while no model is loaded, thinks while the model works,
and strolls along the top of the Dock now and then. Drag it anywhere,
double-click it for the chat window, right-click to stop it wandering.
and strolls along the top of the Dock now and then. Drag it anywhere, and
right-click to stop it wandering. Double-click it and the chat opens as its
own app — a native Symbio window with the cat in the Dock and the usual
menus, not a browser tab.

It never imports `symbio`. Training writes `logs/training_live.json` from the
trainer's own lines and the gate's verdict; the pet reads that, the daemon's
pid and socket, and `ps`. Measured: about 80 MB resident, 4.5% of one core
sitting and 3% asleep, more while it walks or eats. It needs PyObjC
(`pip install "symbio-cli[pet]"`) and runs on macOS only.

### Symbio in other apps

```bash
symb acp # an ACP agent on stdio, for Zed, the VS Code and
# JetBrains ACP plugins, Toad, and other ACP hosts
symb connect claude-desktop # Claude Desktop gets Symbio as tools (MCP)
symb connect hermes # so does Hermes Agent (CLI and desktop app)
```

`symb acp` speaks the [Agent Client Protocol](https://agentclientprotocol.com):
the host starts it, and Symbio's resident model becomes the host's agent. The
reply streams in as the agent's message, Symbio's activity lines arrive as its
thinking, and its approval prompts are asked through the host's own permission
dialog. It starts `symb daemon` itself if no model is loaded. In Zed:

```json
"agent_servers": { "Symbio": { "command": "symb", "args": ["acp"] } }
```

The fine-tune loop comes along. A session has two modes: **Private** (the
default; nothing is added to the corpus) and **Learn** (the conversation is
saved for training when the session closes). The loop's commands — `/save`,
`/learn`, `/train`, `/golden`, `/forget_last`, `/status` — show up in the
host's command menu. A retrain started from the session appears as a tool call
that counts the steps and the loss, then reports the golden gate's verdict:
kept, or rolled back to the previous adapter. The four stages (collect, train,
golden gate, keep or roll back) are shown as the session's plan.

Claude Desktop hosts MCP servers rather than ACP agents, so `symb connect
claude-desktop` registers `symb mcp bridge` in its config instead: Claude gets
`ask_symbio` (a turn with Symbio, which keeps the thread between calls) and
`symbio_status` (model, adapter, and any fine-tune in progress). Approval
prompts cannot be answered from inside a tool call there, so they are declined
and the reply says what was asked. `--remove` takes it out again.

Hermes Agent can run as an ACP agent but cannot host one, so it gets the same
MCP bridge. `symb connect hermes` registers it through Hermes's own `hermes mcp
add`, which keeps the comments in its config.yaml, and raises the tool timeout
to 900 seconds so a `/train` sent through `ask_symbio` is not cut off at
Hermes's default of 300. The loop's commands work through `ask_symbio` too:
send it `/status`, `/save`, `/golden` or `/train` and their output comes back
as the reply, without the trainer's per-step lines.

Both bridges were checked with the official ACP and MCP SDK clients, and the
MCP bridge by typing into Hermes Agent's own terminal UI.

### Staying online

```bash
Expand Down
55 changes: 55 additions & 0 deletions symbio/app/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ def _build_parser() -> argparse.ArgumentParser:
daemon_sub.add_parser("run", help=argparse.SUPPRESS) # internal: the daemon process
daemon_parser.set_defaults(daemon_command="status")

# One optional action rather than subparsers, so `symb pet --demo` and
# `symb pet start --demo` both parse: flags declared on both a parser and
# its subparser are overwritten by the subparser's defaults.
pet_parser = sub.add_parser(
"pet", help="The desktop cat that shows training as it happens (macOS)")
pet_parser.add_argument(
"pet_command", nargs="?", default="start",
choices=["start", "stop", "status", "run"],
help="start (default) puts it on the desktop; run keeps it in this terminal")
pet_parser.add_argument("--demo", action="store_true",
help="Play a scripted run instead of watching this install")

train_parser = sub.add_parser("train", help="Run LoRA training")
train_parser.add_argument(
"skill",
Expand Down Expand Up @@ -245,8 +257,19 @@ def _build_parser() -> argparse.ArgumentParser:
mcp_build.add_argument("name", help="Tool name")
mcp_build.add_argument("--desc", default="", help="Short description of what the tool does")
mcp_sub.add_parser("list", help="List generated MCP tools")
mcp_sub.add_parser(
"bridge", help="Serve Symbio's resident model to MCP hosts such as Claude Desktop")
mcp_parser.set_defaults(mcp_command="run")

sub.add_parser(
"acp", help="Speak the Agent Client Protocol on stdio, for Zed, VS Code, "
"JetBrains, Toad and other ACP hosts")
connect_parser = sub.add_parser("connect", help="Hook Symbio into another app")
connect_parser.add_argument("target", choices=["claude-desktop", "hermes"],
help="The app to connect Symbio to")
connect_parser.add_argument("--remove", action="store_true",
help="Disconnect instead")

benchmark_parser = sub.add_parser("benchmark", help="Benchmark Ollama models as local brains")
benchmark_parser.add_argument(
"--models",
Expand Down Expand Up @@ -1279,6 +1302,26 @@ def main(argv: list[str] | None = None) -> int:

ok = retrain_model(config, digest=not args.no_digest, seed=not args.no_seed)
return 0 if ok else 1
if command in ("acp", "mcp") and (command == "acp" or args.mcp_command == "bridge"):
# The host talks to the bridge on stdio. It becomes this process
# rather than a child of it, so none of the agent stack this CLI has
# loaded stays resident behind a bridge that needs none of it.
from symbio.app.connect import package_root

module = "symbio_desktop.acp" if command == "acp" else "symbio_desktop.mcp_bridge"
env = dict(os.environ, SYMBIO_HOME=str(constants.PROJECT_DIR))
env["PYTHONPATH"] = os.pathsep.join(
p for p in (package_root(), env.get("PYTHONPATH", "")) if p)
sys.stdout.flush()
os.execve(sys.executable, [sys.executable, "-m", module], env)

if command == "connect":
from symbio.app import connect

if args.target == "hermes":
return connect.hermes(remove=args.remove)
return connect.claude_desktop(remove=args.remove)

if command == "mcp":
sub = getattr(args, "mcp_command", None) or "run"
if sub == "run":
Expand Down Expand Up @@ -1389,6 +1432,18 @@ def main(argv: list[str] | None = None) -> int:
print("Usage: symb daemon [start | stop | status]")
return 1

if command == "pet":
from symbio.app import pet

action = getattr(args, "pet_command", None) or "start"
if action == "stop":
return pet.stop_pet()
if action == "status":
return pet.pet_status()
if action == "run":
return pet.run_pet(demo=args.demo)
return pet.start_pet(demo=args.demo)

parser.print_help()
return 1

Expand Down
153 changes: 153 additions & 0 deletions symbio/app/connect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""`symb connect`: hook Symbio into another app.

Claude Desktop hosts MCP servers, not ACP agents, so it gets Symbio through
symbio_desktop.mcp_bridge, registered under `mcpServers` in its config file.
Everything else already in that file is kept exactly as it was, the file is
backed up before it is changed, and it is written through a rename so Claude
Desktop never reads half of one.

Hermes Agent is an ACP server but not an ACP client, so it takes the same MCP
bridge. Its config.yaml is registered through Hermes's own `hermes mcp add`
rather than written from here: Hermes keeps that file's comments through a
round-trip writer, and a plain YAML dump would strip them.
"""

from __future__ import annotations

import json
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path

from symbio import constants

CLAUDE_DESKTOP_CONFIG = (Path.home() / "Library" / "Application Support" / "Claude"
/ "claude_desktop_config.json")
SERVER_NAME = "symbio"
# Hermes gives an MCP tool call 300s by default. ask_symbio can hold a /train
# for up to the bridge's own turn limit (symbio_desktop.mcp_bridge.TURN_S).
HERMES_TOOL_TIMEOUT_S = 900
_ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")


def package_root() -> str:
"""This code's own root, so the host runs this version of the bridge and
not whatever an editable install elsewhere happens to point at."""
return str(Path(__file__).resolve().parent.parent.parent)


def mcp_entry() -> dict:
return {
"command": sys.executable,
"args": ["-m", "symbio_desktop.mcp_bridge"],
"env": {"SYMBIO_HOME": str(constants.PROJECT_DIR), "PYTHONPATH": package_root()},
}


def claude_desktop(remove: bool = False, config_path: Path = CLAUDE_DESKTOP_CONFIG) -> int:
try:
data = (json.loads(config_path.read_text(encoding="utf-8"))
if config_path.exists() else {})
except (OSError, ValueError) as e:
print(f"Could not read {config_path} ({e}); leaving it alone.")
return 1
if not isinstance(data, dict):
print(f"{config_path} is not a JSON object; leaving it alone.")
return 1
servers = data.get("mcpServers")
if not isinstance(servers, dict):
servers = {}
if remove:
if SERVER_NAME not in servers:
print("Symbio is not connected to Claude Desktop.")
return 0
servers.pop(SERVER_NAME)
else:
servers[SERVER_NAME] = mcp_entry()
data["mcpServers"] = servers

config_path.parent.mkdir(parents=True, exist_ok=True)
if config_path.exists():
shutil.copy2(config_path, config_path.with_name(config_path.name + ".bak-symbio"))
temporary = config_path.with_name(config_path.name + ".tmp-symbio")
temporary.write_text(json.dumps(data, indent=2), encoding="utf-8")
os.replace(temporary, config_path)

if remove:
print("Removed Symbio from Claude Desktop. Restart Claude Desktop to drop it.")
else:
print(f"Connected: Claude Desktop will start Symbio's MCP bridge "
f"({SERVER_NAME}: ask_symbio, symbio_status), watching {constants.PROJECT_DIR}.")
print("Quit and reopen Claude Desktop, then ask Claude to use Symbio.")
print(f"(Backup of the previous config: {config_path.name}.bak-symbio)")
return 0


def hermes_home() -> Path:
home = os.environ.get("HERMES_HOME", "").strip()
return Path(home).expanduser() if home else Path.home() / ".hermes"


def hermes_binary() -> str | None:
"""`hermes` on PATH, or where its two installers put it: the desktop
installer under installs/, install.sh under hermes-agent/venv."""
found = shutil.which("hermes")
if found:
return found
candidates: list[Path] = []
for home in dict.fromkeys((hermes_home(), Path.home() / ".hermes")):
candidates += sorted(home.glob("installs/*/environments/*/venv/bin/hermes"),
key=lambda p: p.stat().st_mtime, reverse=True)
candidates.append(home / "hermes-agent" / "venv" / "bin" / "hermes")
for candidate in candidates:
if os.access(candidate, os.X_OK):
return str(candidate)
return None


def hermes(remove: bool = False, binary: str | None = None) -> int:
binary = binary or hermes_binary()
if not binary:
print("Hermes Agent was not found: no `hermes` on PATH or under "
f"{hermes_home()}. Install it, then run this again.")
return 1

def run(*args: str, answers: str = "") -> tuple[int, str]:
# Hermes asks before it overwrites or removes, and which tools to
# enable; every question here gets a yes.
try:
done = subprocess.run([binary, *args], input=answers, capture_output=True,
text=True, timeout=180, check=False)
except (OSError, subprocess.TimeoutExpired) as e:
return 1, str(e)
return done.returncode, _ANSI.sub("", done.stdout + done.stderr)

if remove:
code, said = run("mcp", "remove", SERVER_NAME, answers="y\n")
if code != 0:
print(said.strip())
return 1
print("Symbio is not connected to Hermes." if "not found" in said
else "Removed Symbio from Hermes. New Hermes sessions will not have it.")
return 0

entry = mcp_entry()
code, said = run("mcp", "add", SERVER_NAME, "--command", entry["command"],
"--env", *(f"{k}={v}" for k, v in entry["env"].items()),
"--args", *entry["args"], answers="y\ny\n")
if code != 0 or f"Saved '{SERVER_NAME}'" not in said:
print("Hermes did not register Symbio:")
print(said.strip())
return 1
code, said = run("config", "set", f"mcp_servers.{SERVER_NAME}.timeout",
str(HERMES_TOOL_TIMEOUT_S))
if code != 0:
print(f"Registered, but the tool timeout stayed at Hermes's default "
f"(a /train through ask_symbio may be cut off): {said.strip()}")
print(f"Connected: Hermes will start Symbio's MCP bridge ({SERVER_NAME}: "
f"ask_symbio, symbio_status), watching {constants.PROJECT_DIR}.")
print("Start a new Hermes session (or /reload-mcp) and ask Hermes to use Symbio.")
return 0
Loading
Loading