-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
205 lines (180 loc) · 8.74 KB
/
Copy pathmain.py
File metadata and controls
205 lines (180 loc) · 8.74 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
"""Tuffy's entry point: wires up skills/MCP discovery, then hands off to the
CLI's input loop. See src/cli/ for the banner, commands, and turn loop —
this file only owns process startup/shutdown."""
import os
import sys
import traceback
# ELASTIMEM_TIER must land in os.environ before `import src.memory` below —
# elastimem.open() reads Tier.from_env() once, at construction time, so
# setting this any later (e.g. inside src/memory.py itself) would be too
# late. Read straight out of .env here rather than requiring the user to
# export it in their shell every session; a real exported var still wins,
# matching src/llm/openai_compatible_provider.py's same dotenv-fallback
# convention for API keys.
if "ELASTIMEM_TIER" not in os.environ:
_dotenv_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env")
if os.path.isfile(_dotenv_path):
with open(_dotenv_path, encoding="utf-8") as _f:
for _line in _f:
_line = _line.strip()
if not _line or _line.startswith("#") or "=" not in _line:
continue
_key, _, _value = _line.partition("=")
if _key.strip() == "ELASTIMEM_TIER":
_value = _value.strip().strip("'\"")
if _value:
os.environ["ELASTIMEM_TIER"] = _value
break
from src.models import DEFAULT_MODEL, FALLBACK_MODEL
from src.models.registry import registry as model_registry
from src.settings import get_default_model, get_network_mode
from src import network_mode as netmode
from src.skills.loader import discover_skills, mcp_configs_from_skills
from src.tools.mcp_client import connect_mcp_servers
import src.tools # noqa: F401 - registers tools (editing/coding/research/system) as a side effect of import
import src.memory # noqa: F401 - registers the 'remember' tool
import src.skills # noqa: F401 - registers the read_skill tool
from src.cli.session import Session
from src.cli.commands import apply_network_mode, handle_command
from src.cli.turn import run_turn
from src.cli.display import print_logo, print_session_info, select_one, C_DIM, C_USER, C_RESET
def _prompt_network_mode() -> str:
"""Asks Online or Offline every startup via an arrow-key selector,
defaulting to (pre-selected on) the last persisted choice - or 'online'
on first run, matching today's actual default model. Returns 'online'
or 'offline'."""
last_choice = get_network_mode() or "online"
try:
mode = select_one("Online or offline mode?", ["online", "offline"], last_choice)
except (KeyboardInterrupt, EOFError):
print()
mode = last_choice
# The chosen mode is not announced here - it is the first row of the
# session block printed once the model is up (print_session_info), so the
# startup output is one block instead of a sentence, a gap, and a block.
# The same helper '/network <mode>' and an API-model switch both go
# through (src/cli/commands.apply_network_mode), so startup can't drift
# from mid-session switching: it sets the live mode, persists it, and
# applies the mode's rules to memory's embedder - which means loading it
# (downloading once, if online) HERE rather than lazily inside the first
# turn, where the download used to land mid-answer with its progress bars
# fighting the spinner for the same terminal rows. See src/embedder.py.
apply_network_mode(mode)
return mode
def main():
print_logo()
network_choice = _prompt_network_mode()
# Scans ./.tuffy/skills/*/ and auto-imports each skill's tools.py before
# the first system prompt is built, so skill descriptions and
# skill-provided tools are both present from the very first turn.
# Placed here (after the banner, before model loading) rather than at
# module import time so its own printed output ("[mcp] Connected to
# ...") reads as part of the same startup summary block as the model
# load/ready lines just below it, instead of appearing above the banner
# before Tuffy has even announced itself.
discover_skills()
# Connects to any MCP servers configured in ./.tuffy/mcp.json (gitignored
# — see docs/configure-mcp.md) plus each loaded skill's own mcp.json, if
# any. A no-op when no servers are configured. Must run after
# discover_skills() (so skills' mcp.json files are known) and before the
# first system prompt is built, so MCP tools appear in TOOLS YOU CAN
# CALL from turn one.
connect_mcp_servers(extra_configs=mcp_configs_from_skills())
import sys
voice_mode = False
if "--voice" in sys.argv:
voice_mode = True
# User's persisted choice (set via '/models default <id>') wins over the
# hardcoded DEFAULT_MODEL; falls back to DEFAULT_MODEL on first run.
# Works uniformly whether the chosen model is local or API - model cards
# carry their own 'provider' field, so nothing here needs to special-case
# model type.
persisted_default = get_default_model() or DEFAULT_MODEL
if network_choice == "offline":
# Hard restriction: offline means no API model may be active, no
# matter what's persisted as the general default. Prefer the user's
# own default if it's already local; otherwise use FALLBACK_MODEL.
card = model_registry.get(persisted_default)
startup_model = persisted_default if card["provider"] == "llama_cpp" else FALLBACK_MODEL
else:
startup_model = persisted_default
try:
session = Session(startup_model)
except Exception as e:
# Most commonly a missing GROQ_API_KEY (ValueError from the
# OpenAI-compatible provider's load()) or a network/API failure.
# Tuffy must still work fully offline with zero configuration, so
# fall back to the local gguf model instead of refusing to start.
print(
f"{C_DIM}Couldn't load default model '{startup_model}' ({e}). "
f"Falling back to local model '{FALLBACK_MODEL}'.{C_RESET}"
)
session = Session(FALLBACK_MODEL)
import src.memory as memory
from src.prompts import build_system_prompt
memory.attach_llm(session.agent.complete)
model_card = model_registry.get(session.current_model_id)
static_tokens = len(build_system_prompt(model_card=model_card)) // 4
memory.reconfigure_for_model(model_card, static_prompt_tokens=static_tokens)
if voice_mode:
from src.voice import start_voice_session
start_voice_session(session)
return
print_session_info(model_card, session.agent.supports_vision, netmode.get_mode())
while True:
try:
user_input = input(f"{C_USER}You ❯{C_RESET} ")
except (KeyboardInterrupt, EOFError):
print()
session.end()
print(f"{C_DIM}Goodbye!{C_RESET}")
break
stripped = user_input.strip()
if not stripped:
continue
if stripped.startswith("/"):
cmd_lower = stripped.lower()
if cmd_lower == "/mode" or cmd_lower.startswith("/mode "):
mode = cmd_lower[len("/mode"):].strip()
if not mode:
print(f"{C_DIM}Current mode: text. Use '/mode voice' to switch.{C_RESET}\n")
continue
if mode == "voice":
from src.voice import start_voice_session
res = start_voice_session(session)
if res == "exit":
break
continue
elif mode == "text":
print(f"{C_DIM}Already in text mode.{C_RESET}\n")
continue
else:
print(f"{C_DIM}Unknown mode: {mode}. Use '/mode voice' or '/mode text'.{C_RESET}\n")
continue
result = handle_command(session, stripped)
if result == "exit":
session.end()
print(f"{C_DIM}Goodbye!{C_RESET}")
break
if result == "handled":
continue
print(f"{C_DIM}Unknown command: {stripped}. Type /help for a list of commands.{C_RESET}\n")
continue
run_turn(session, user_input)
if __name__ == "__main__":
# Exit via os._exit on every path — clean or crashed — so llama.cpp's
# Metal backend never runs its C++ static destructors, which trip a
# harmless-but-scary GGML_ASSERT backtrace during atexit teardown.
# Everything real (model, mtmd context, KV cache) is freed by unload().
exit_code = 0
try:
main()
except Exception:
traceback.print_exc()
exit_code = 1
finally:
sys.stdout.flush()
sys.stderr.flush()
import src.memory as memory
memory.mem.close()
os._exit(exit_code)