Replace OpenCode harness with Hermes, remove workspace dependency, and move vocabulary/corrections to app folder
This commit is contained in:
@@ -12,17 +12,26 @@ import asyncio
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeAgentOptions, SandboxSettings
|
||||
from loguru import logger
|
||||
|
||||
import env_setup
|
||||
import web_server
|
||||
from brain import Brain
|
||||
from voice_manager import VoiceManager
|
||||
from model_manager import ModelManager
|
||||
from claude_llm import ClaudeCodeLLM
|
||||
from echo_guard import EchoGuardUserMuteStrategy
|
||||
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
||||
from pipecat.frames.frames import TTSSpeakFrame
|
||||
from pipecat.frames.frames import (
|
||||
TTSSpeakFrame,
|
||||
TranscriptionFrame,
|
||||
UserStartedSpeakingFrame,
|
||||
UserStoppedSpeakingFrame,
|
||||
)
|
||||
from pipecat.pipeline.pipeline import Pipeline
|
||||
from pipecat.pipeline.worker import PipelineParams, PipelineWorker
|
||||
from pipecat.processors.aggregators.llm_context import LLMContext
|
||||
@@ -47,7 +56,7 @@ from pipecat.turns.user_turn_strategies import UserTurnStrategies
|
||||
from pipecat.workers.runner import WorkerRunner
|
||||
from global_hotkey import HOLD_KEYS
|
||||
from push_to_talk import PushToTalk
|
||||
from journal import Journal
|
||||
from journal import Journal, recent_prompt
|
||||
from memory_tools import build_server
|
||||
from transcript_repair import TranscriptRepair
|
||||
from vocabulary import Vocabulary
|
||||
@@ -70,9 +79,13 @@ say them:
|
||||
conversation, not a document.
|
||||
- Spell out things that only make sense visually. Say "line forty-two of
|
||||
bot dot py" rather than pasting a path.
|
||||
- Use your available tools (listing directories, searching, reading files) whenever the user asks about files or workspace tasks.
|
||||
- Use your available tools (listing directories, searching, reading files, shell execution) whenever the user asks about files, commands, CLI tools (such as Paseo), or workspace tasks.
|
||||
- You can change your own voice! If the user asks to list available voices or switch voice, run `python bin/voice_tool.py list` or `python bin/voice_tool.py set <voice_name>` (voices: af_heart, af_bella, am_michael, am_fenrir, am_puck, bf_emma, bm_george, Moira, Daniel).
|
||||
- Complete multi-step tool calls fully before speaking your response. Do not stop halfway to ask if you should continue.
|
||||
- You can change your AI model on the fly! If the user asks to list available models or change model, run `python bin/model_tool.py list` or `python bin/model_tool.py set <model_name>` (models: luna, gemma, deepseek, gpt-oss, sonnet, etc.).
|
||||
- You can open files visually for the user in the Companion Web UI drawer! Run `python bin/web_tool.py show <filepath>`.
|
||||
- You can open links or the Companion Web UI in the default browser! Run `python bin/web_tool.py open <url>`.
|
||||
- Speak your intent out loud BEFORE calling tools! Give brief guidance on what you are attempting (e.g. "Switching your voice to af_heart now...", "Checking Paseo CLI agents...", "Inspecting bot dot py..."). Speak a short guiding sentence first, then run your tools.
|
||||
- Complete multi-step tool calls fully before speaking your final response summary.
|
||||
- Be strictly truthful about your findings and never invent fake file contents.
|
||||
- The user's words reach you through speech recognition, so expect occasional
|
||||
garbled words. Ask rather than guess when it matters.
|
||||
@@ -99,12 +112,10 @@ SHELL_TOOLS = ["Bash", "BashOutput", "KillShell"]
|
||||
# conversation time-to-first-token matters more than the extra capability.
|
||||
DEFAULT_CLAUDE_MODEL = "claude-sonnet-4-6"
|
||||
|
||||
# Where Claude works, and where the personality file lives. Deliberately not
|
||||
# this repo: the assistant is for everyday use, not for editing itself.
|
||||
DEFAULT_WORKSPACE = Path.home() / "Workspace"
|
||||
# Default directory is now the app folder itself
|
||||
DEFAULT_APP_DIR = Path(__file__).parent.resolve()
|
||||
|
||||
# The CLI reads CLAUDE.md from the working directory on its own but ignores
|
||||
# AGENTS.md, so that one is loaded here and appended to the system prompt.
|
||||
# Personality file path
|
||||
PERSONALITY_FILE = "AGENTS.md"
|
||||
|
||||
|
||||
@@ -156,14 +167,14 @@ def parse_args() -> argparse.Namespace:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--llm-engine",
|
||||
choices=["opencode", "ollama", "apple", "macos", "claude"],
|
||||
default="opencode",
|
||||
help="LLM engine to use: opencode/ollama for OpenCode Cloud models (gemma4:31b), apple/macos for local MLX, claude for Claude Code.",
|
||||
choices=["hermes", "ollama", "apple", "macos", "claude"],
|
||||
default="hermes",
|
||||
help="LLM engine to use: hermes for Hermes models/CLI/Gateway, apple/macos for local MLX, claude for Claude Code.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--opencode-model",
|
||||
default="ollama-cloud/gemma4:31b",
|
||||
help="OpenCode Cloud model (default ollama-cloud/gemma4:31b).",
|
||||
"--hermes-model",
|
||||
default="hermes-3",
|
||||
help="Hermes model (default hermes-3).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ollama-host",
|
||||
@@ -203,10 +214,9 @@ def parse_args() -> argparse.Namespace:
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cwd",
|
||||
default=str(DEFAULT_WORKSPACE),
|
||||
default=str(DEFAULT_APP_DIR),
|
||||
help=(
|
||||
"Where Claude's tools point, and where AGENTS.md is read from. "
|
||||
"Created if it doesn't exist."
|
||||
"Working directory for file tools and AGENTS.md."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
@@ -263,6 +273,17 @@ def parse_args() -> argparse.Namespace:
|
||||
action="store_true",
|
||||
help="Turn off vocabulary biasing and transcript repair.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--web-port",
|
||||
type=int,
|
||||
default=8888,
|
||||
help="Port for the Companion Web Chat UI server (default 8888).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-web",
|
||||
action="store_true",
|
||||
help="Disable the Companion Web Chat UI server.",
|
||||
)
|
||||
parser.add_argument("--log-level", default="INFO")
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -390,49 +411,27 @@ def build_vocabulary(args: argparse.Namespace, brain=None) -> Vocabulary | None:
|
||||
logger.info("Vocabulary biasing disabled.")
|
||||
return None
|
||||
|
||||
# These live in the workspace, not here: they are learned state that grows
|
||||
# with use, and keeping them beside AGENTS.md means git tracks how they
|
||||
# change. This repo only carries the starting templates.
|
||||
here = Path(__file__).parent
|
||||
workspace = Path(args.cwd)
|
||||
# Vocabulary and correction files live in the app folder
|
||||
app_dir = Path(__file__).parent
|
||||
vocabulary_file = (
|
||||
Path(args.vocabulary_file) if args.vocabulary_file else workspace / "vocabulary.txt"
|
||||
Path(args.vocabulary_file) if args.vocabulary_file else app_dir / "vocabulary.txt"
|
||||
)
|
||||
corrections_file = workspace / "corrections.txt"
|
||||
corrections_file = app_dir / "corrections.txt"
|
||||
|
||||
for target, template in (
|
||||
(vocabulary_file, here / "vocabulary.example.txt"),
|
||||
(corrections_file, here / "corrections.example.txt"),
|
||||
(workspace / PERSONALITY_FILE, here / "AGENTS.md.example"),
|
||||
(vocabulary_file, app_dir / "vocabulary.example.txt"),
|
||||
(corrections_file, app_dir / "corrections.example.txt"),
|
||||
):
|
||||
if not target.exists() and template.exists():
|
||||
target.write_text(template.read_text())
|
||||
logger.info(f"Created {target} from the template")
|
||||
|
||||
# Ensure bin/voice_tool.py is available in the workspace for OpenCode and Claude
|
||||
ws_bin = workspace / "bin"
|
||||
ws_bin.mkdir(exist_ok=True)
|
||||
voice_script = ws_bin / "voice_tool.py"
|
||||
source_script = here / "bin" / "voice_tool.py"
|
||||
if source_script.exists() and not voice_script.exists():
|
||||
try:
|
||||
voice_script.symlink_to(source_script.resolve())
|
||||
logger.info(f"Created symlink {voice_script} -> {source_script}")
|
||||
except Exception:
|
||||
try:
|
||||
voice_script.write_text(source_script.read_text())
|
||||
logger.info(f"Copied {source_script} -> {voice_script}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not install voice_tool.py into {ws_bin}: {e}")
|
||||
logger.info(f"Created {target} from template")
|
||||
|
||||
vocabulary = Vocabulary(
|
||||
project_dir=workspace,
|
||||
project_dir=Path(args.cwd),
|
||||
vocabulary_file=vocabulary_file,
|
||||
corrections_file=corrections_file,
|
||||
)
|
||||
if brain and brain.projects:
|
||||
# "CIP-Unified-Cooldown", "pSMSL-RT-Cache" — the words most likely to be
|
||||
# spoken and least likely to be recognised.
|
||||
vocabulary.add_terms(brain.projects)
|
||||
terms = vocabulary.terms()
|
||||
logger.info(f"Vocabulary: biasing towards {len(terms)} terms, e.g. {', '.join(terms[:6])}")
|
||||
@@ -464,30 +463,23 @@ def read_personality(cwd: str | None) -> str:
|
||||
return text
|
||||
|
||||
|
||||
def build_claude_options(args: argparse.Namespace, vocabulary=None, brain=None) -> ClaudeAgentOptions:
|
||||
# The SDK bundles its own stock Claude Code binary and prefers it over the
|
||||
# one on PATH. That build knows nothing about this org's gateway or its
|
||||
# managed apiKeyHelper, so every turn fails with "Invalid API key" unless we
|
||||
# point it back at the installed CLI.
|
||||
def build_claude_options(
|
||||
args: argparse.Namespace, vocabulary=None, brain=None, journal_context: str = ""
|
||||
) -> ClaudeAgentOptions:
|
||||
cli_path = shutil.which("claude")
|
||||
if cli_path:
|
||||
logger.info(f"Claude CLI: {cli_path}")
|
||||
else:
|
||||
logger.warning("No `claude` on PATH; falling back to the SDK's bundled CLI.")
|
||||
|
||||
# Naming the domain vocabulary lets Claude resolve a garbled transcript
|
||||
# while answering it. That recovers most of what a separate correction pass
|
||||
# would, at no latency cost, because he already reads every transcript.
|
||||
# Personality first, then the voice rules, so the constraints of speaking
|
||||
# aloud get the last word over anything the personality file asks for.
|
||||
style = ""
|
||||
if personality := read_personality(args.cwd):
|
||||
style += personality + "\n\n"
|
||||
style += VOICE_STYLE
|
||||
if brain and (memory := brain.prompt_block()):
|
||||
# Only the context goes in the prompt. When to write back is described
|
||||
# by the memory skill in the workspace, which Claude picks up on its own.
|
||||
style += "\n\n" + memory
|
||||
if journal_context:
|
||||
style += "\n\n" + journal_context
|
||||
if vocabulary and (terms := vocabulary.prompt_block()):
|
||||
style += (
|
||||
"\n\nSpeech recognition mangles unusual words. When a transcript is "
|
||||
@@ -505,33 +497,34 @@ def build_claude_options(args: argparse.Namespace, vocabulary=None, brain=None)
|
||||
permission_mode="bypassPermissions",
|
||||
cwd=args.cwd,
|
||||
model=args.claude_model,
|
||||
# "project" makes the workspace's own .claude/skills discoverable;
|
||||
# without it a skills folder there is silently ignored.
|
||||
setting_sources=(
|
||||
["user", "project", "local"] if args.load_settings else ["project"]
|
||||
),
|
||||
skills="all",
|
||||
include_partial_messages=True, # Speak as tokens arrive instead of per message.
|
||||
# The CLI's own sandbox uses sandbox-exec, which fails when this process
|
||||
# is already running inside one.
|
||||
include_partial_messages=True,
|
||||
sandbox=SandboxSettings(enabled=False),
|
||||
)
|
||||
|
||||
|
||||
def build_llm(args: argparse.Namespace, vocabulary=None, brain=None, observer=None):
|
||||
if args.llm_engine in ("opencode", "ollama"):
|
||||
from opencode_llm import OpenCodeLLM, probe_opencode
|
||||
def build_llm(
|
||||
args: argparse.Namespace,
|
||||
vocabulary=None,
|
||||
brain=None,
|
||||
observer=None,
|
||||
model_manager=None,
|
||||
journal_context: str = "",
|
||||
):
|
||||
model = (model_manager.load_saved_model() if model_manager else None) or getattr(args, "hermes_model", "hermes-3")
|
||||
if args.llm_engine in ("hermes", "ollama"):
|
||||
from hermes_llm import HermesLLM, probe_hermes
|
||||
|
||||
available, reason = probe_opencode(args.opencode_model)
|
||||
logger.info(f"LLM: OpenCode Cloud ({reason})")
|
||||
personality = read_personality(args.cwd) or "You are a helpful voice assistant."
|
||||
system_prompt = personality + "\n\n" + VOICE_STYLE
|
||||
if brain and (memory := brain.prompt_block()):
|
||||
system_prompt += "\n\n" + memory
|
||||
return OpenCodeLLM(
|
||||
model=args.opencode_model,
|
||||
available, reason = probe_hermes(model)
|
||||
logger.info(f"LLM: Hermes ({reason})")
|
||||
# Hermes handles persona, personality, and memory natively.
|
||||
return HermesLLM(
|
||||
model=model,
|
||||
cwd=args.cwd,
|
||||
system_prompt=system_prompt,
|
||||
session_name="Voice Agent",
|
||||
observer=observer,
|
||||
)
|
||||
|
||||
@@ -544,11 +537,13 @@ def build_llm(args: argparse.Namespace, vocabulary=None, brain=None, observer=No
|
||||
system_prompt = personality + "\n\n" + VOICE_STYLE
|
||||
if brain and (memory := brain.prompt_block()):
|
||||
system_prompt += "\n\n" + memory
|
||||
if journal_context:
|
||||
system_prompt += "\n\n" + journal_context
|
||||
return MacOSLLM(model=args.mlx_model, system_prompt=system_prompt, observer=observer)
|
||||
|
||||
logger.info("LLM: Claude Code")
|
||||
return ClaudeCodeLLM(
|
||||
options=build_claude_options(args, vocabulary, brain),
|
||||
options=build_claude_options(args, vocabulary, brain, journal_context),
|
||||
observer=observer,
|
||||
)
|
||||
|
||||
@@ -595,6 +590,9 @@ async def main() -> int:
|
||||
vocabulary = build_vocabulary(args, brain)
|
||||
stt = build_stt(args, vocabulary)
|
||||
journal = Journal(workspace / "journal.jsonl")
|
||||
journal_context = recent_prompt(workspace / "journal.jsonl")
|
||||
if journal_context:
|
||||
logger.info("Journal: loaded the 10 most recent entries")
|
||||
|
||||
def on_reply(text: str):
|
||||
journal.record_reply(text)
|
||||
@@ -602,7 +600,20 @@ async def main() -> int:
|
||||
vocabulary.observe(text)
|
||||
|
||||
voice_manager = VoiceManager(workspace)
|
||||
llm = build_llm(args, vocabulary, brain, observer=on_reply)
|
||||
model_manager = ModelManager(workspace)
|
||||
if not getattr(args, "no_web", False):
|
||||
await web_server.start_server(workspace, port=getattr(args, "web_port", 8888))
|
||||
web_server.set_managers(workspace, model_manager, voice_manager)
|
||||
|
||||
llm = build_llm(
|
||||
args,
|
||||
vocabulary,
|
||||
brain,
|
||||
observer=on_reply,
|
||||
model_manager=model_manager,
|
||||
journal_context=journal_context,
|
||||
)
|
||||
model_manager.set_llm_processor(llm)
|
||||
tts = build_tts(args, voice_manager=voice_manager)
|
||||
|
||||
# Claude keeps its own history; this context exists so Pipecat can decide
|
||||
@@ -645,6 +656,26 @@ async def main() -> int:
|
||||
idle_timeout_secs=None,
|
||||
)
|
||||
|
||||
async def on_web_input(text: str):
|
||||
logger.info(f"Web typed input: {text}")
|
||||
web_server.broadcast_event("heard", {"text": text})
|
||||
if hasattr(llm, "start_turn_direct"):
|
||||
llm.start_turn_direct(text)
|
||||
else:
|
||||
frames = [
|
||||
UserStartedSpeakingFrame(),
|
||||
TranscriptionFrame(
|
||||
text=text,
|
||||
user_id="user",
|
||||
timestamp=datetime.now().isoformat(timespec="seconds")
|
||||
),
|
||||
UserStoppedSpeakingFrame(),
|
||||
]
|
||||
await worker.queue_frames(frames)
|
||||
|
||||
if not getattr(args, "no_web", False):
|
||||
web_server.set_managers(workspace, model_manager, voice_manager, input_callback=on_web_input)
|
||||
|
||||
if args.greeting:
|
||||
await worker.queue_frames([TTSSpeakFrame(args.greeting)])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user