hubble-cli 4.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hubble/__init__.py +3 -0
- hubble/__main__.py +4 -0
- hubble/agent.py +546 -0
- hubble/banner.py +484 -0
- hubble/board.py +118 -0
- hubble/main.py +299 -0
- hubble/models.py +92 -0
- hubble/onboarding.py +94 -0
- hubble/permissions.py +139 -0
- hubble/prompts.py +157 -0
- hubble/provider.py +283 -0
- hubble/providers.py +186 -0
- hubble/repl.py +976 -0
- hubble/scanner.py +134 -0
- hubble/session.py +128 -0
- hubble/settings.py +184 -0
- hubble/skills.py +173 -0
- hubble/spinner.py +77 -0
- hubble/tools.py +763 -0
- hubble/ui.py +469 -0
- hubble/web.py +279 -0
- hubble_cli-4.0.0.dist-info/METADATA +275 -0
- hubble_cli-4.0.0.dist-info/RECORD +27 -0
- hubble_cli-4.0.0.dist-info/WHEEL +5 -0
- hubble_cli-4.0.0.dist-info/entry_points.txt +3 -0
- hubble_cli-4.0.0.dist-info/licenses/LICENSE +21 -0
- hubble_cli-4.0.0.dist-info/top_level.txt +1 -0
hubble/repl.py
ADDED
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
"""Interactive REPL: prompt_toolkit input, slash commands, @file mentions, # memory notes."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Callable, Dict, List, Optional, Tuple
|
|
11
|
+
|
|
12
|
+
from prompt_toolkit import PromptSession
|
|
13
|
+
from prompt_toolkit.completion import Completer, Completion
|
|
14
|
+
from prompt_toolkit.filters import completion_is_selected
|
|
15
|
+
from prompt_toolkit.formatted_text import HTML
|
|
16
|
+
from prompt_toolkit.history import FileHistory
|
|
17
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
18
|
+
from rich.markup import escape
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from hubble import __version__
|
|
22
|
+
from hubble.agent import Agent
|
|
23
|
+
from hubble.models import resolve_model
|
|
24
|
+
from hubble.provider import OpenAICompatProvider
|
|
25
|
+
from hubble.providers import (DEFAULT_PROVIDER, ProviderConfig, normalize_provider_name, provider_models,
|
|
26
|
+
remove_provider, resolve_fallback, save_listing, save_provider, scan_file)
|
|
27
|
+
from hubble.permissions import MODE_HELP, MODES
|
|
28
|
+
from hubble.prompts import INIT_PROMPT, PERSONAS
|
|
29
|
+
from hubble.scanner import ModelScanner, scan_age_hours
|
|
30
|
+
from hubble.session import SessionStore
|
|
31
|
+
from hubble.settings import HOME_DIR, save_user_setting
|
|
32
|
+
from hubble.skills import Skill, TEMPLATE, discover_skills
|
|
33
|
+
from hubble.tools import is_secret_path, truncate
|
|
34
|
+
from hubble.ui import console, pick, render_todos
|
|
35
|
+
|
|
36
|
+
MENTION_RX = re.compile(r"(?<![\w@])@([\w./\\:-]+)")
|
|
37
|
+
MAX_MENTION_CHARS = 60000
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Command:
|
|
41
|
+
def __init__(self, name: str, fn: Callable, help: str, aliases: Tuple[str, ...] = (), args: str = ""):
|
|
42
|
+
self.name, self.fn, self.help, self.aliases, self.args = name, fn, help, aliases, args
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReplCompleter(Completer):
|
|
46
|
+
def __init__(self, repl: "Repl"):
|
|
47
|
+
self.repl = repl
|
|
48
|
+
|
|
49
|
+
def get_completions(self, document, complete_event):
|
|
50
|
+
text = document.text_before_cursor
|
|
51
|
+
m = re.match(r"^/model\s+(\S*)$", text)
|
|
52
|
+
if m:
|
|
53
|
+
typed = m.group(1).lower()
|
|
54
|
+
current = (self.repl.agent.provider_name, self.repl.agent.model)
|
|
55
|
+
for value, label, meta in self.repl.model_items():
|
|
56
|
+
if typed in label.lower():
|
|
57
|
+
mark = " (current)" if value == current else ""
|
|
58
|
+
yield Completion(label, start_position=-len(m.group(1)), display_meta=meta.strip() + mark)
|
|
59
|
+
return
|
|
60
|
+
if text.startswith("/") and " " not in text:
|
|
61
|
+
typed = text[1:].lower()
|
|
62
|
+
for cmd in self.repl.commands.values():
|
|
63
|
+
names = [cmd.name, *cmd.aliases]
|
|
64
|
+
hit = next((n for n in names if n.startswith(typed)), None)
|
|
65
|
+
if hit is None:
|
|
66
|
+
continue
|
|
67
|
+
shown = f"/{cmd.name} {cmd.args}".rstrip()
|
|
68
|
+
yield Completion("/" + (cmd.name if hit == cmd.name or not typed else hit),
|
|
69
|
+
start_position=-len(text), display=shown, display_meta=cmd.help)
|
|
70
|
+
return
|
|
71
|
+
m = re.search(r"@([\w./\\-]*)$", text)
|
|
72
|
+
if m:
|
|
73
|
+
partial = m.group(1).replace("\\", "/")
|
|
74
|
+
base_dir = self.repl.agent.ctx.root / os.path.dirname(partial)
|
|
75
|
+
prefix = os.path.basename(partial)
|
|
76
|
+
try:
|
|
77
|
+
entries = sorted(base_dir.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
|
|
78
|
+
except OSError:
|
|
79
|
+
return
|
|
80
|
+
for e in entries[:200]:
|
|
81
|
+
if e.name.startswith(prefix) and e.name not in (".git", "__pycache__", "node_modules"):
|
|
82
|
+
rel = (Path(os.path.dirname(partial)) / e.name).as_posix().removeprefix("./")
|
|
83
|
+
yield Completion(rel + ("/" if e.is_dir() else ""), start_position=-len(partial))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Repl:
|
|
87
|
+
def __init__(self, agent: Agent, store: SessionStore, providers: Optional[Dict[str, ProviderConfig]] = None):
|
|
88
|
+
self.providers: Dict[str, ProviderConfig] = dict(providers or {})
|
|
89
|
+
self.clients: Dict[str, OpenAICompatProvider] = {agent.provider_name: agent.provider}
|
|
90
|
+
self.scanners: Dict[str, ModelScanner] = {}
|
|
91
|
+
self._unannounced: set = set()
|
|
92
|
+
self.turn_stats = None
|
|
93
|
+
self.agent = agent
|
|
94
|
+
agent.fallback_resolver = self.fallback_for
|
|
95
|
+
self.store = store
|
|
96
|
+
self.last_model_list: List[str] = []
|
|
97
|
+
self.commands: Dict[str, Command] = {}
|
|
98
|
+
self._register_builtin()
|
|
99
|
+
self._load_custom_commands()
|
|
100
|
+
|
|
101
|
+
# ----- setup ---------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
def _register(self, name, fn, help, aliases=(), args=""):
|
|
104
|
+
self.commands[name] = Command(name, fn, help, aliases, args)
|
|
105
|
+
|
|
106
|
+
def command_names(self) -> Dict[str, Command]:
|
|
107
|
+
names = {}
|
|
108
|
+
for cmd in self.commands.values():
|
|
109
|
+
names[cmd.name] = cmd
|
|
110
|
+
for a in cmd.aliases:
|
|
111
|
+
names[a] = cmd
|
|
112
|
+
return names
|
|
113
|
+
|
|
114
|
+
def _register_builtin(self):
|
|
115
|
+
r = self._register
|
|
116
|
+
r("help", self.cmd_help, "Show commands and shortcuts", ("h", "?"))
|
|
117
|
+
r("home", lambda arg: self.banner(), "Show the animated home screen again", ("intro", "banner"))
|
|
118
|
+
r("exit", self.cmd_exit, "Quit", ("quit", "q"))
|
|
119
|
+
r("clear", self.cmd_clear, "Start a new conversation (new session)", ("new", "reset"))
|
|
120
|
+
r("compact", self.cmd_compact, "Summarize history to free context", args="[focus]")
|
|
121
|
+
r("model", self.cmd_model, "Show or switch model (name, fragment or # from /models)", args="[name|#]")
|
|
122
|
+
r("models", self.cmd_models, "List models; '/models refresh' re-checks which are available",
|
|
123
|
+
args="[filter|refresh]")
|
|
124
|
+
r("provider", self.cmd_provider, "Switch provider, or add/remove one (base URL + API key)",
|
|
125
|
+
("providers",), "[add|remove <name>|list]")
|
|
126
|
+
r("mode", self.cmd_mode, "Permission mode: default, accept-edits, plan, yolo (Shift+Tab cycles)",
|
|
127
|
+
("permissions",), "[mode]")
|
|
128
|
+
r("persona", self.cmd_persona, f"System persona: {', '.join(PERSONAS)}", args="[name]")
|
|
129
|
+
r("resume", self.cmd_resume, "Resume a previous session of this project", ("sessions",), "[id|#]")
|
|
130
|
+
r("cost", self.cmd_cost, "Token usage and context size", ("stats", "tokens", "status"))
|
|
131
|
+
r("context", self.cmd_context, "Show what is in the context window")
|
|
132
|
+
r("undo", self.cmd_undo, "Revert file changes from the last turn that edited files", ("rewind",))
|
|
133
|
+
r("diff", self.cmd_diff, "Show git diff of the workspace")
|
|
134
|
+
r("init", self.cmd_init, "Generate HUBBLE.md project instructions")
|
|
135
|
+
r("memory", self.cmd_memory, "Show loaded memory files (HUBBLE.md / AGENTS.md)")
|
|
136
|
+
r("add", self.cmd_add, "Pin a file into the system prompt", ("pin",), "<file>")
|
|
137
|
+
r("drop", self.cmd_drop, "Unpin a file", ("unpin",), "<file>")
|
|
138
|
+
r("files", self.cmd_files, "List pinned files")
|
|
139
|
+
r("todos", self.cmd_todos, "Show the current task list")
|
|
140
|
+
r("test", self.cmd_test, "Check a model responds and measure latency", args="[model]")
|
|
141
|
+
r("temp", self.cmd_temp, "Show or set temperature", args="[0-2]")
|
|
142
|
+
r("fallback", self.cmd_fallback, "Choose the model used when the current one is rate limited",
|
|
143
|
+
args="[model|auto|off]")
|
|
144
|
+
r("thinking", self.cmd_thinking, "Show or hide the model's reasoning text", args="[on|off]")
|
|
145
|
+
r("sandbox", self.cmd_sandbox, "Run shell commands in an isolated Docker container instead of "
|
|
146
|
+
"directly on this machine", args="[on|off]")
|
|
147
|
+
r("config", self.cmd_config, "Show effective settings and allow/deny rules")
|
|
148
|
+
r("export", self.cmd_export, "Export conversation to markdown", ("copy",), "[file]")
|
|
149
|
+
r("skills", self.cmd_skills, "List skills (the model can also call these on its own)")
|
|
150
|
+
r("skill", self.cmd_skill_new, "Create a new skill template to fill in", args="new <name> [user]")
|
|
151
|
+
|
|
152
|
+
def _load_custom_commands(self):
|
|
153
|
+
"""Skills become /<name>; a skill's own description is shown in /help and /<tab>."""
|
|
154
|
+
for skill in self.agent.skills:
|
|
155
|
+
self._register(skill.name, self._make_skill_runner(skill),
|
|
156
|
+
f"(skill, {skill.scope}) {skill.description}", args=skill.args_hint or "[args]")
|
|
157
|
+
# Back-compat: a plain command with no frontmatter, e.g. from an older .hubble/commands/ setup.
|
|
158
|
+
for folder in (HOME_DIR / "commands", self.agent.ctx.root / ".hubble" / "commands"):
|
|
159
|
+
if not folder.is_dir():
|
|
160
|
+
continue
|
|
161
|
+
for f in sorted(folder.glob("*.md")):
|
|
162
|
+
name = f.stem.lower()
|
|
163
|
+
if name in self.commands:
|
|
164
|
+
continue
|
|
165
|
+
try:
|
|
166
|
+
body = f.read_text(encoding="utf-8")
|
|
167
|
+
except OSError:
|
|
168
|
+
continue
|
|
169
|
+
first = next((l.strip() for l in body.splitlines() if l.strip()), "")[:60]
|
|
170
|
+
|
|
171
|
+
def run(arg, body=body):
|
|
172
|
+
prompt = body.replace("$ARGUMENTS", arg) if "$ARGUMENTS" in body else \
|
|
173
|
+
(body + (f"\n\n{arg}" if arg else ""))
|
|
174
|
+
self.send(prompt)
|
|
175
|
+
self._register(name, run, f"(custom) {first}", args="[args]")
|
|
176
|
+
|
|
177
|
+
def _make_skill_runner(self, skill: Skill):
|
|
178
|
+
def run(arg):
|
|
179
|
+
body = skill.body()
|
|
180
|
+
prompt = body.replace("$ARGUMENTS", arg) if "$ARGUMENTS" in body else \
|
|
181
|
+
(body + (f"\n\n{arg}" if arg else ""))
|
|
182
|
+
self.send(prompt)
|
|
183
|
+
return run
|
|
184
|
+
|
|
185
|
+
# ----- main loop -----------------------------------------------------
|
|
186
|
+
|
|
187
|
+
def _prompt_session(self) -> PromptSession:
|
|
188
|
+
kb = KeyBindings()
|
|
189
|
+
|
|
190
|
+
@kb.add("escape", "enter")
|
|
191
|
+
def _(event):
|
|
192
|
+
event.current_buffer.insert_text("\n")
|
|
193
|
+
|
|
194
|
+
@kb.add("c-j")
|
|
195
|
+
def _(event):
|
|
196
|
+
event.current_buffer.insert_text("\n")
|
|
197
|
+
|
|
198
|
+
@kb.add("s-tab")
|
|
199
|
+
def _(event):
|
|
200
|
+
self.agent.permissions.cycle_mode()
|
|
201
|
+
event.app.invalidate()
|
|
202
|
+
|
|
203
|
+
@kb.add("enter", filter=completion_is_selected)
|
|
204
|
+
def _(event):
|
|
205
|
+
# A highlighted item in the dropdown (e.g. /model <space>, @file): one Enter both
|
|
206
|
+
# picks it and submits, instead of submitting the still-unfinished typed text.
|
|
207
|
+
buf = event.current_buffer
|
|
208
|
+
buf.apply_completion(buf.complete_state.current_completion)
|
|
209
|
+
buf.validate_and_handle()
|
|
210
|
+
|
|
211
|
+
HOME_DIR.mkdir(parents=True, exist_ok=True)
|
|
212
|
+
return PromptSession(history=FileHistory(str(HOME_DIR / "history")), completer=ReplCompleter(self),
|
|
213
|
+
complete_while_typing=True, key_bindings=kb, bottom_toolbar=self._toolbar,
|
|
214
|
+
multiline=False, enable_history_search=False, refresh_interval=1.0,
|
|
215
|
+
reserve_space_for_menu=14)
|
|
216
|
+
|
|
217
|
+
def _toolbar(self):
|
|
218
|
+
a = self.agent
|
|
219
|
+
mode = a.permissions.mode
|
|
220
|
+
color = {"default": "ansigray", "accept-edits": "ansigreen", "plan": "ansiblue", "yolo": "ansired"}[mode]
|
|
221
|
+
ctx_pct = f"{a.context_ratio():.0%}"
|
|
222
|
+
pinned = f" | {len(a.pinned)} pinned" if a.pinned else ""
|
|
223
|
+
prov = f"{escape_html(a.provider_name)}: " if len(self.providers) > 1 else ""
|
|
224
|
+
line1 = (f" {prov}<b>{escape_html(a.model)}</b> | mode: <style fg='{color}'><b>{mode}</b></style>"
|
|
225
|
+
f" (shift+tab) | persona: {a.persona} | context {ctx_pct}{pinned}"
|
|
226
|
+
+ "".join(f" | {escape_html(n)}: {s.summary()}" for n, s in self.scanners.items() if s.running)
|
|
227
|
+
+ " ")
|
|
228
|
+
return HTML(line1 + "\n" + self._stats_line())
|
|
229
|
+
|
|
230
|
+
def _stats_line(self) -> str:
|
|
231
|
+
a = self.agent
|
|
232
|
+
s = self.turn_stats
|
|
233
|
+
session_total = a.total_prompt_tokens + a.total_completion_tokens
|
|
234
|
+
if s is None or not s.model_calls:
|
|
235
|
+
return f" Tokens: no turns yet • session total {session_total:,} "
|
|
236
|
+
total = s.prompt_tokens + s.completion_tokens
|
|
237
|
+
speed = s.completion_tokens / s.duration if s.duration else 0.0
|
|
238
|
+
tools = f" | 🔧 {s.tool_calls} tool call{'s' if s.tool_calls != 1 else ''}" if s.tool_calls else ""
|
|
239
|
+
return (f" Tokens: <b>📥 In: {s.prompt_tokens:,}</b> | <b>📤 Out: {s.completion_tokens:,}</b>"
|
|
240
|
+
f" | <b>📊 Total: {total:,}</b> • Time: {s.duration:.2f}s | 🚀 {speed:.1f} tok/s{tools}"
|
|
241
|
+
f" • session {session_total:,} ")
|
|
242
|
+
|
|
243
|
+
def _home_info(self):
|
|
244
|
+
a = self.agent
|
|
245
|
+
known = [m for m in provider_models(a.provider_name) if m.get("available") is not False]
|
|
246
|
+
return dict(version=__version__, provider=a.provider_name, model=a.model, mode=a.permissions.mode,
|
|
247
|
+
root=str(a.ctx.root), session_id=a.session.id if a.session else None,
|
|
248
|
+
memory_files=[p.name for p, _ in a.memory], model_count=len(known) or None,
|
|
249
|
+
provider_count=len(self.providers), resumable=max(0, len(self.store.list()) - 1),
|
|
250
|
+
show_provider=len(self.providers) > 1)
|
|
251
|
+
|
|
252
|
+
def _live_home_ok(self) -> bool:
|
|
253
|
+
return (bool(self.agent.settings.get("home_animation", True)) and sys.stdin.isatty()
|
|
254
|
+
and sys.stdout.isatty() and console.is_terminal and not console.legacy_windows)
|
|
255
|
+
|
|
256
|
+
def banner(self):
|
|
257
|
+
"""Show the home screen: animated inside the next prompt when possible, else printed once."""
|
|
258
|
+
if self._live_home_ok():
|
|
259
|
+
from hubble.banner import home_info_lines
|
|
260
|
+
self._home = home_info_lines(**self._home_info())
|
|
261
|
+
self._home_t0 = time.time()
|
|
262
|
+
else:
|
|
263
|
+
from hubble.banner import render_home
|
|
264
|
+
render_home(console, **self._home_info())
|
|
265
|
+
|
|
266
|
+
def _home_message(self):
|
|
267
|
+
"""Prompt message for the first prompt: the whole home screen, re-rendered every frame."""
|
|
268
|
+
from prompt_toolkit.application import get_app
|
|
269
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
270
|
+
from hubble.banner import compose_home_fragments
|
|
271
|
+
try:
|
|
272
|
+
app = get_app()
|
|
273
|
+
size = app.output.get_size()
|
|
274
|
+
typed = app.current_buffer.text
|
|
275
|
+
except Exception:
|
|
276
|
+
return HTML("<ansicyan><b>❯</b></ansicyan> ")
|
|
277
|
+
# Leave room for the prompt line and the 2-line toolbar, plus the completion menu while typing / or @.
|
|
278
|
+
budget = size.rows - 4 - (10 if typed[:1] in ("/", "@") or " @" in typed else 0)
|
|
279
|
+
# prompt_toolkit asks for the message several times per redraw (measure, then draw);
|
|
280
|
+
# build each frame once per 1/30 s tick and reuse it.
|
|
281
|
+
elapsed = time.time() - self._home_t0
|
|
282
|
+
key = (int(elapsed * 30), size.columns, budget)
|
|
283
|
+
cached = getattr(self, "_home_frame", None)
|
|
284
|
+
if cached and cached[0] == key:
|
|
285
|
+
return cached[1]
|
|
286
|
+
status, tips = self._home
|
|
287
|
+
frags = compose_home_fragments(size.columns, max(budget, 3), elapsed, status, tips, __version__)
|
|
288
|
+
frame = FormattedText(frags + [("bold fg:ansicyan", "❯"), ("", " ")])
|
|
289
|
+
self._home_frame = (key, frame)
|
|
290
|
+
return frame
|
|
291
|
+
|
|
292
|
+
def fallback_for(self, provider: str, model: str):
|
|
293
|
+
choice = resolve_fallback(self.agent.settings, self.providers, provider, model)
|
|
294
|
+
if not choice or choice[0] not in self.providers and choice[0] != self.agent.provider_name:
|
|
295
|
+
return None
|
|
296
|
+
return self.client(choice[0]), choice[1]
|
|
297
|
+
|
|
298
|
+
def cmd_fallback(self, arg):
|
|
299
|
+
prov = self.agent.provider_name
|
|
300
|
+
per = dict(self.agent.settings.get("fallback_models") or {})
|
|
301
|
+
current = per.get(prov) or "auto"
|
|
302
|
+
if arg:
|
|
303
|
+
choice = arg.strip()
|
|
304
|
+
elif sys.stdin.isatty():
|
|
305
|
+
auto = resolve_fallback({**self.agent.settings, "fallback_models": {}}, self.providers, prov, self.agent.model)
|
|
306
|
+
auto_meta = f"now: {auto[0]}: {auto[1]}" if auto else "nothing suitable found"
|
|
307
|
+
items = [("auto", "Automatic", auto_meta), ("off", "Off", "never switch models on errors")]
|
|
308
|
+
items += [(v[1], label, meta) for v, label, meta in self.model_items(only=prov) if v[1] != self.agent.model]
|
|
309
|
+
choice = pick(f"Fallback model for {prov}", items, current=current)
|
|
310
|
+
if not choice:
|
|
311
|
+
return
|
|
312
|
+
else:
|
|
313
|
+
console.print(f"Fallback for {escape(prov)}: {escape(current)}")
|
|
314
|
+
return
|
|
315
|
+
if choice == "auto":
|
|
316
|
+
per.pop(prov, None)
|
|
317
|
+
else:
|
|
318
|
+
per[prov] = choice
|
|
319
|
+
self.agent.settings["fallback_models"] = per
|
|
320
|
+
save_user_setting("fallback_models", per)
|
|
321
|
+
console.print(f"[green]Fallback for {escape(prov)}: {escape(choice)}[/green]")
|
|
322
|
+
|
|
323
|
+
def client(self, name: str) -> OpenAICompatProvider:
|
|
324
|
+
if name not in self.clients:
|
|
325
|
+
cfg = self.providers[name]
|
|
326
|
+
self.clients[name] = OpenAICompatProvider(cfg.base_url, cfg.api_key)
|
|
327
|
+
return self.clients[name]
|
|
328
|
+
|
|
329
|
+
def start_scan(self, name: str, reason: str) -> bool:
|
|
330
|
+
cfg = self.providers.get(name)
|
|
331
|
+
if not cfg:
|
|
332
|
+
return False
|
|
333
|
+
scanner = self.scanners.get(name)
|
|
334
|
+
if scanner is None:
|
|
335
|
+
scanner = self.scanners[name] = ModelScanner(cfg.base_url, cfg.api_key, output=scan_file(name))
|
|
336
|
+
if not scanner.start():
|
|
337
|
+
return False
|
|
338
|
+
self._unannounced.add(name)
|
|
339
|
+
console.print(f"[dim] {reason} Checking which {escape(name)} models are available in the background "
|
|
340
|
+
"(progress in the bottom bar)...[/dim]")
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
def _announce_scans(self):
|
|
344
|
+
for name in list(self._unannounced):
|
|
345
|
+
scanner = self.scanners.get(name)
|
|
346
|
+
if not scanner or scanner.running:
|
|
347
|
+
continue
|
|
348
|
+
self._unannounced.discard(name)
|
|
349
|
+
if scanner.status == "done":
|
|
350
|
+
console.print(f"[green]{escape(name)}: {scanner.working} of {scanner.total} models are "
|
|
351
|
+
"available.[/green] [dim]/model to see them[/dim]")
|
|
352
|
+
elif scanner.status == "failed":
|
|
353
|
+
console.print(f"[yellow]{escape(name)}: model check failed ({escape(scanner.error)}); "
|
|
354
|
+
"keeping the previous list.[/yellow]")
|
|
355
|
+
|
|
356
|
+
def _refresh_stale_scans(self):
|
|
357
|
+
raw = self.agent.settings.get("model_refresh_hours", 0)
|
|
358
|
+
if raw is None: # explicitly disabled ("model_refresh_hours": null)
|
|
359
|
+
return
|
|
360
|
+
hours = float(raw) # 0 (the default): check every startup; >0: only once that stale
|
|
361
|
+
for name, cfg in self.providers.items():
|
|
362
|
+
if not cfg.check_models:
|
|
363
|
+
continue
|
|
364
|
+
age = scan_age_hours(scan_file(name))
|
|
365
|
+
if age is None or hours <= 0 or age >= hours:
|
|
366
|
+
reason = "Model list is missing." if age is None else (
|
|
367
|
+
"Checking on startup." if hours <= 0 else f"Model list is {age:.0f}h old.")
|
|
368
|
+
self.start_scan(name, reason)
|
|
369
|
+
|
|
370
|
+
def run(self, initial_prompt: Optional[str] = None):
|
|
371
|
+
self._home = None
|
|
372
|
+
self.banner()
|
|
373
|
+
self._refresh_stale_scans()
|
|
374
|
+
session = self._prompt_session()
|
|
375
|
+
if initial_prompt:
|
|
376
|
+
self._home = None
|
|
377
|
+
self.handle(initial_prompt)
|
|
378
|
+
while True:
|
|
379
|
+
try:
|
|
380
|
+
self._announce_scans()
|
|
381
|
+
if self._home is not None:
|
|
382
|
+
# Animated home screen lives in the prompt until the first message is sent;
|
|
383
|
+
# the last frame stays in the scrollback.
|
|
384
|
+
text = session.prompt(self._home_message, refresh_interval=1 / 30, reserve_space_for_menu=0)
|
|
385
|
+
self._home = None
|
|
386
|
+
else:
|
|
387
|
+
text = session.prompt(HTML("<ansicyan><b>❯</b></ansicyan> "))
|
|
388
|
+
except KeyboardInterrupt:
|
|
389
|
+
continue
|
|
390
|
+
except EOFError:
|
|
391
|
+
break
|
|
392
|
+
try:
|
|
393
|
+
if self.handle(text) is False:
|
|
394
|
+
break
|
|
395
|
+
except KeyboardInterrupt:
|
|
396
|
+
console.print("[yellow]Interrupted.[/yellow]")
|
|
397
|
+
console.print("[dim]Bye.[/dim]")
|
|
398
|
+
|
|
399
|
+
def handle(self, text: str):
|
|
400
|
+
text = text.strip()
|
|
401
|
+
if not text:
|
|
402
|
+
return None
|
|
403
|
+
if text.startswith("!"):
|
|
404
|
+
self.run_shell(text[1:].strip())
|
|
405
|
+
return None
|
|
406
|
+
if text.startswith("#") and not text.startswith("##"):
|
|
407
|
+
self.remember(text[1:].strip())
|
|
408
|
+
return None
|
|
409
|
+
if text.startswith("/") and not text.startswith("//"):
|
|
410
|
+
name, _, arg = text[1:].partition(" ")
|
|
411
|
+
cmd = self.command_names().get(name.lower())
|
|
412
|
+
if not cmd:
|
|
413
|
+
console.print(f"[yellow]Unknown command /{escape(name)}. Try /help.[/yellow]")
|
|
414
|
+
return None
|
|
415
|
+
return cmd.fn(arg.strip())
|
|
416
|
+
self.send(text)
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
def send(self, text: str):
|
|
420
|
+
prompt = self.expand_mentions(text)
|
|
421
|
+
self.agent.run(prompt)
|
|
422
|
+
if self.agent.last_stats.model_calls:
|
|
423
|
+
self.turn_stats = self.agent.last_stats # shown in the bar under the input box
|
|
424
|
+
|
|
425
|
+
def expand_mentions(self, text: str) -> str:
|
|
426
|
+
attachments = []
|
|
427
|
+
for m in MENTION_RX.finditer(text):
|
|
428
|
+
raw = m.group(1).rstrip(".,:;")
|
|
429
|
+
try:
|
|
430
|
+
p = self.agent.ctx.resolve(raw)
|
|
431
|
+
except Exception:
|
|
432
|
+
continue
|
|
433
|
+
if is_secret_path(p) and not self.agent.ctx.allow_secrets:
|
|
434
|
+
console.print(f"[yellow]Skipped @{escape(raw)}: looks like a secrets file.[/yellow]")
|
|
435
|
+
continue
|
|
436
|
+
if p.is_file():
|
|
437
|
+
try:
|
|
438
|
+
content = p.read_text(encoding="utf-8", errors="replace")
|
|
439
|
+
except OSError:
|
|
440
|
+
continue
|
|
441
|
+
self.agent.ctx.read_mtimes[str(p)] = p.stat().st_mtime
|
|
442
|
+
attachments.append(f'<file path="{self.agent.ctx.rel(p)}">\n{truncate(content, MAX_MENTION_CHARS)}\n</file>')
|
|
443
|
+
console.print(f"[dim] attached {escape(self.agent.ctx.rel(p))} ({len(content.splitlines())} lines)[/dim]")
|
|
444
|
+
elif p.is_dir():
|
|
445
|
+
listing = "\n".join(sorted(e.name + ("/" if e.is_dir() else "") for e in p.iterdir())[:200])
|
|
446
|
+
attachments.append(f'<directory path="{self.agent.ctx.rel(p)}">\n{listing}\n</directory>')
|
|
447
|
+
if not attachments:
|
|
448
|
+
return text
|
|
449
|
+
return text + "\n\n" + "\n\n".join(attachments)
|
|
450
|
+
|
|
451
|
+
def run_shell(self, cmd: str):
|
|
452
|
+
if not cmd:
|
|
453
|
+
return
|
|
454
|
+
argv = list(self.agent.ctx.shell_argv) + [cmd]
|
|
455
|
+
try:
|
|
456
|
+
subprocess.run(argv, cwd=self.agent.ctx.root)
|
|
457
|
+
except (OSError, KeyboardInterrupt) as e:
|
|
458
|
+
console.print(f"[red]{escape(str(e))}[/red]")
|
|
459
|
+
|
|
460
|
+
def remember(self, note: str):
|
|
461
|
+
if not note:
|
|
462
|
+
return
|
|
463
|
+
path = self.agent.ctx.root / "HUBBLE.md"
|
|
464
|
+
existing = path.read_text(encoding="utf-8") if path.exists() else "# Project notes\n"
|
|
465
|
+
with open(path, "w", encoding="utf-8", newline="") as f:
|
|
466
|
+
f.write(existing.rstrip("\n") + f"\n- {note}\n")
|
|
467
|
+
self.agent.reload_memory()
|
|
468
|
+
console.print(f"[green]Saved to {escape(str(path))}[/green]")
|
|
469
|
+
|
|
470
|
+
# ----- commands ------------------------------------------------------
|
|
471
|
+
|
|
472
|
+
def cmd_help(self, arg):
|
|
473
|
+
table = Table(show_header=False, box=None, padding=(0, 2))
|
|
474
|
+
for cmd in self.commands.values():
|
|
475
|
+
alias = f" [dim](/{', /'.join(cmd.aliases)})[/dim]" if cmd.aliases else ""
|
|
476
|
+
table.add_row(f"[bold]/{cmd.name}[/bold] [dim]{escape(cmd.args)}[/dim]", escape(cmd.help) + alias)
|
|
477
|
+
console.print(table)
|
|
478
|
+
console.print("\n[bold]Input[/bold]\n"
|
|
479
|
+
" @path attach a file or directory listing to your message\n"
|
|
480
|
+
" !command run a shell command yourself (not sent to the model)\n"
|
|
481
|
+
" #note append a note to ./HUBBLE.md (project memory)\n"
|
|
482
|
+
" Esc+Enter new line (also Ctrl+J)\n"
|
|
483
|
+
" Shift+Tab cycle permission mode\n"
|
|
484
|
+
" Ctrl+C cancel the running turn · Ctrl+D quit\n"
|
|
485
|
+
"\n[bold]Skills[/bold]: .hubble/skills/<name>/SKILL.md (or ~/.hubble/skills for every "
|
|
486
|
+
"project) becomes /<name>, and the model can call it itself when its description "
|
|
487
|
+
"matches the task. /skill new <name> creates one; /skills lists them. "
|
|
488
|
+
"$ARGUMENTS is replaced with what you type after the command.")
|
|
489
|
+
|
|
490
|
+
def cmd_exit(self, arg):
|
|
491
|
+
return False
|
|
492
|
+
|
|
493
|
+
def cmd_clear(self, arg):
|
|
494
|
+
self.agent.clear()
|
|
495
|
+
if self.agent.session:
|
|
496
|
+
self.agent.session = self.store.new(self.agent.model)
|
|
497
|
+
console.print(f"[green]New conversation.[/green] "
|
|
498
|
+
f"[dim]session {self.agent.session.id if self.agent.session else '(not saved)'}[/dim]")
|
|
499
|
+
|
|
500
|
+
def cmd_compact(self, arg):
|
|
501
|
+
with console.status("[dim]Compacting...[/dim]"):
|
|
502
|
+
try:
|
|
503
|
+
ok = self.agent.compact(focus=arg)
|
|
504
|
+
except Exception as e:
|
|
505
|
+
console.print(f"[red]Compaction failed: {escape(str(e))}[/red]")
|
|
506
|
+
return
|
|
507
|
+
if not ok:
|
|
508
|
+
console.print("[dim]Nothing to compact.[/dim]")
|
|
509
|
+
|
|
510
|
+
def cmd_model(self, arg):
|
|
511
|
+
if not arg:
|
|
512
|
+
if not sys.stdin.isatty():
|
|
513
|
+
self.cmd_models("")
|
|
514
|
+
return
|
|
515
|
+
self.choose_model()
|
|
516
|
+
return
|
|
517
|
+
if arg.isdigit() and self.last_model_list:
|
|
518
|
+
idx = int(arg) - 1
|
|
519
|
+
if 0 <= idx < len(self.last_model_list):
|
|
520
|
+
return self._set_model(self.last_model_list[idx])
|
|
521
|
+
console.print(f"[red]Pick 1-{len(self.last_model_list)}[/red]")
|
|
522
|
+
return
|
|
523
|
+
pairs = [v for v, _, _ in self.model_items()]
|
|
524
|
+
names = list(dict.fromkeys(m for _, m in pairs))
|
|
525
|
+
matches = resolve_model(arg, names)
|
|
526
|
+
if len(matches) == 1:
|
|
527
|
+
owners = [p for p, m in pairs if m == matches[0]]
|
|
528
|
+
prov = self.agent.provider_name if self.agent.provider_name in owners else owners[0]
|
|
529
|
+
return self._set_model(matches[0], prov)
|
|
530
|
+
if len(matches) > 1:
|
|
531
|
+
self.last_model_list = matches
|
|
532
|
+
for i, m in enumerate(matches, 1):
|
|
533
|
+
console.print(f" {i:>2}. {escape(m)}")
|
|
534
|
+
console.print("[dim]Several match; use /model <#>.[/dim]")
|
|
535
|
+
return
|
|
536
|
+
self._set_model(arg)
|
|
537
|
+
console.print("[dim](not in the registry; using it as given)[/dim]")
|
|
538
|
+
|
|
539
|
+
def model_items(self, only: Optional[str] = None):
|
|
540
|
+
"""Picker rows across providers: value (provider, model), label, meta."""
|
|
541
|
+
names = [only] if only else [self.agent.provider_name] + [n for n in self.providers
|
|
542
|
+
if n != self.agent.provider_name]
|
|
543
|
+
multi = len(self.providers) > 1
|
|
544
|
+
items = []
|
|
545
|
+
for pname in names:
|
|
546
|
+
for m in provider_models(pname):
|
|
547
|
+
if m.get("available") is False:
|
|
548
|
+
meta = f"unavailable · {m['category']}"
|
|
549
|
+
elif m.get("available") is None and pname != DEFAULT_PROVIDER:
|
|
550
|
+
meta = "not checked"
|
|
551
|
+
else:
|
|
552
|
+
lat = f"{m['latency_ms']} ms" if m.get("latency_ms") else "-"
|
|
553
|
+
meta = f"{lat:>8} · {m['category']}"
|
|
554
|
+
items.append(((pname, m["model"]), m["model"], (f"{pname} · " if multi else "") + meta))
|
|
555
|
+
return items
|
|
556
|
+
|
|
557
|
+
def choose_model(self, only: Optional[str] = None) -> bool:
|
|
558
|
+
running = [f"{n}: {s.summary()}" for n, s in self.scanners.items() if s.running and (not only or n == only)]
|
|
559
|
+
if running:
|
|
560
|
+
console.print(f"[dim]{'; '.join(running)}. Showing what is known so far.[/dim]")
|
|
561
|
+
items = self.model_items(only)
|
|
562
|
+
if not items:
|
|
563
|
+
console.print("[yellow]No models known for this provider yet.[/yellow]")
|
|
564
|
+
return False
|
|
565
|
+
title = f"Select a model ({only})" if only else "Select a model"
|
|
566
|
+
chosen = pick(title, items, current=(self.agent.provider_name, self.agent.model))
|
|
567
|
+
if not chosen or chosen == (self.agent.provider_name, self.agent.model):
|
|
568
|
+
console.print(f"[dim]Model unchanged: {escape(self.agent.model)}[/dim]")
|
|
569
|
+
return False
|
|
570
|
+
self._set_model(chosen[1], chosen[0])
|
|
571
|
+
return True
|
|
572
|
+
|
|
573
|
+
def _set_model(self, name: str, provider: Optional[str] = None):
|
|
574
|
+
provider = provider or self.agent.provider_name
|
|
575
|
+
if provider != self.agent.provider_name:
|
|
576
|
+
self.agent.provider = self.client(provider)
|
|
577
|
+
self.agent.provider_name = provider
|
|
578
|
+
self.agent.model = name
|
|
579
|
+
self.agent.settings["model"] = name
|
|
580
|
+
if self.agent.session:
|
|
581
|
+
self.agent.session.meta(model=name, provider=provider)
|
|
582
|
+
try:
|
|
583
|
+
save_user_setting("model", name)
|
|
584
|
+
save_user_setting("provider", provider)
|
|
585
|
+
saved = " (saved as default)"
|
|
586
|
+
except OSError:
|
|
587
|
+
saved = ""
|
|
588
|
+
prov = f"{escape(provider)}: " if len(self.providers) > 1 else ""
|
|
589
|
+
console.print(f"[green]Model: {prov}{escape(name)}{saved}[/green]")
|
|
590
|
+
|
|
591
|
+
def cmd_models(self, arg):
|
|
592
|
+
name = self.agent.provider_name
|
|
593
|
+
scanner = self.scanners.get(name)
|
|
594
|
+
if arg.lower() in ("refresh", "scan", "update"):
|
|
595
|
+
if scanner and scanner.running:
|
|
596
|
+
console.print(f"[dim]Already running: {scanner.summary()}[/dim]")
|
|
597
|
+
elif not self.start_scan(name, "Refreshing."):
|
|
598
|
+
console.print("[dim]Cannot check this provider.[/dim]")
|
|
599
|
+
return
|
|
600
|
+
age = scan_age_hours(scan_file(name))
|
|
601
|
+
if scanner and scanner.running:
|
|
602
|
+
console.print(f"[dim]{scanner.summary()}; the list below is from the previous check.[/dim]")
|
|
603
|
+
elif age is not None:
|
|
604
|
+
console.print(f"[dim]{escape(name)}: availability checked {age:.1f}h ago. "
|
|
605
|
+
"'/models refresh' re-checks now.[/dim]")
|
|
606
|
+
models = [m for m in provider_models(name) if not arg or arg.lower() in m["model"].lower()]
|
|
607
|
+
self.last_model_list = [m["model"] for m in models]
|
|
608
|
+
table = Table(box=None, padding=(0, 2))
|
|
609
|
+
table.add_column("#", justify="right", style="dim")
|
|
610
|
+
table.add_column("model", overflow="fold")
|
|
611
|
+
table.add_column("category", style="dim")
|
|
612
|
+
table.add_column("latency", justify="right", style="dim")
|
|
613
|
+
for i, m in enumerate(models, 1):
|
|
614
|
+
label = f"[bold green]{escape(m['model'])}[/bold green]" if m["model"] == self.agent.model else escape(m["model"])
|
|
615
|
+
if m.get("available") is False:
|
|
616
|
+
lat = "[red]unavailable[/red]"
|
|
617
|
+
label = f"[dim]{label}[/dim]"
|
|
618
|
+
else:
|
|
619
|
+
lat = f"{m['latency_ms']} ms" if m.get("latency_ms") else "-"
|
|
620
|
+
table.add_row(str(i), label, m["category"], lat)
|
|
621
|
+
console.print(table)
|
|
622
|
+
console.print("[dim]Switch with /model <#>. Re-check availability: /models refresh[/dim]")
|
|
623
|
+
|
|
624
|
+
def cmd_provider(self, arg):
|
|
625
|
+
sub, _, rest = arg.partition(" ")
|
|
626
|
+
sub = sub.lower()
|
|
627
|
+
if sub == "add":
|
|
628
|
+
return self.add_provider()
|
|
629
|
+
if sub in ("remove", "rm", "delete"):
|
|
630
|
+
return self.remove_provider(rest.strip())
|
|
631
|
+
if sub == "list" or not sys.stdin.isatty():
|
|
632
|
+
for n, cfg in self.providers.items():
|
|
633
|
+
mark = "[bold green]●[/bold green]" if n == self.agent.provider_name else " "
|
|
634
|
+
console.print(f" {mark} [bold]{escape(n):<14}[/bold] [dim]{escape(cfg.base_url)}[/dim]")
|
|
635
|
+
console.print("[dim]/provider add · /provider remove <name> · /provider to switch[/dim]")
|
|
636
|
+
return
|
|
637
|
+
if sub and sub in self.providers:
|
|
638
|
+
return self.choose_model(only=sub)
|
|
639
|
+
items = [(n, n, cfg.base_url + (" (active)" if n == self.agent.provider_name else ""))
|
|
640
|
+
for n, cfg in self.providers.items()]
|
|
641
|
+
items.append(("__add__", "+ Add a provider", "base URL + API key"))
|
|
642
|
+
chosen = pick("Provider", items, current=self.agent.provider_name)
|
|
643
|
+
if chosen == "__add__":
|
|
644
|
+
self.add_provider()
|
|
645
|
+
elif chosen:
|
|
646
|
+
self.choose_model(only=chosen)
|
|
647
|
+
|
|
648
|
+
def add_provider(self):
|
|
649
|
+
from hubble.onboarding import add_provider_wizard, _confirm
|
|
650
|
+
result = add_provider_wizard(self.providers.keys())
|
|
651
|
+
if not result:
|
|
652
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
653
|
+
return
|
|
654
|
+
cfg, ids = result
|
|
655
|
+
register_provider(cfg, ids)
|
|
656
|
+
self.providers[cfg.name] = cfg
|
|
657
|
+
console.print(f"[green]Saved provider {escape(cfg.name)}[/green] [dim](~/.hubble/providers.json)[/dim]")
|
|
658
|
+
if cfg.check_models:
|
|
659
|
+
self.start_scan(cfg.name, "New provider.")
|
|
660
|
+
if _confirm(f"Switch to {cfg.name} now?"):
|
|
661
|
+
self.choose_model(only=cfg.name)
|
|
662
|
+
|
|
663
|
+
def remove_provider(self, name: str):
|
|
664
|
+
if not name:
|
|
665
|
+
console.print("Usage: /provider remove <name>")
|
|
666
|
+
elif name == DEFAULT_PROVIDER:
|
|
667
|
+
console.print("[yellow]The built-in hubble provider comes from HUBBLE_API_KEY / .env; remove it there.[/yellow]")
|
|
668
|
+
elif name == self.agent.provider_name:
|
|
669
|
+
console.print("[yellow]Switch to another provider before removing the active one.[/yellow]")
|
|
670
|
+
elif remove_provider(name):
|
|
671
|
+
self.providers.pop(name, None)
|
|
672
|
+
self.clients.pop(name, None)
|
|
673
|
+
self.scanners.pop(name, None)
|
|
674
|
+
console.print(f"[green]Removed provider {escape(name)}[/green]")
|
|
675
|
+
else:
|
|
676
|
+
console.print(f"[red]No provider named {escape(name)}[/red]")
|
|
677
|
+
|
|
678
|
+
def cmd_mode(self, arg):
|
|
679
|
+
perms = self.agent.permissions
|
|
680
|
+
if not arg:
|
|
681
|
+
if not sys.stdin.isatty():
|
|
682
|
+
for m in MODES:
|
|
683
|
+
mark = "[bold green]●[/bold green]" if m == perms.mode else " "
|
|
684
|
+
console.print(f" {mark} [bold]{m:<13}[/bold] [dim]{MODE_HELP[m]}[/dim]")
|
|
685
|
+
return
|
|
686
|
+
arg = pick("Permission mode", [(m, m, MODE_HELP[m]) for m in MODES], current=perms.mode)
|
|
687
|
+
if not arg:
|
|
688
|
+
return
|
|
689
|
+
if arg not in MODES:
|
|
690
|
+
console.print(f"[red]Unknown mode. Choose: {', '.join(MODES)}[/red]")
|
|
691
|
+
return
|
|
692
|
+
perms.mode = arg
|
|
693
|
+
console.print(f"[green]Permission mode: {arg}[/green] [dim]({MODE_HELP[arg]})[/dim]")
|
|
694
|
+
|
|
695
|
+
def cmd_persona(self, arg):
|
|
696
|
+
if not arg and sys.stdin.isatty():
|
|
697
|
+
arg = pick("Persona", [(p, p, PERSONAS[p][:70]) for p in PERSONAS], current=self.agent.persona) or ""
|
|
698
|
+
if not arg:
|
|
699
|
+
return
|
|
700
|
+
if not arg:
|
|
701
|
+
console.print(f"Persona: {self.agent.persona}. Available: {', '.join(PERSONAS)}")
|
|
702
|
+
elif arg in PERSONAS:
|
|
703
|
+
self.agent.persona = arg
|
|
704
|
+
console.print(f"[green]Persona: {arg}[/green]")
|
|
705
|
+
else:
|
|
706
|
+
console.print(f"[red]Unknown persona. Available: {', '.join(PERSONAS)}[/red]")
|
|
707
|
+
|
|
708
|
+
def cmd_resume(self, arg):
|
|
709
|
+
items = self.store.list()
|
|
710
|
+
if not items:
|
|
711
|
+
console.print("[dim]No saved sessions for this project.[/dim]")
|
|
712
|
+
return
|
|
713
|
+
if not arg and sys.stdin.isatty():
|
|
714
|
+
arg = pick("Resume a session", session_items(items)) or ""
|
|
715
|
+
if not arg:
|
|
716
|
+
return
|
|
717
|
+
if not arg:
|
|
718
|
+
for i, it in enumerate(items, 1):
|
|
719
|
+
when = time.strftime("%m-%d %H:%M", time.localtime(it["updated"]))
|
|
720
|
+
console.print(f" {i:>2}. [dim]{when}[/dim] {escape(it['title'] or '(no prompt)')} "
|
|
721
|
+
f"[dim]{it['messages']} msgs · {escape(str(it['model']))} · {it['id']}[/dim]")
|
|
722
|
+
console.print("[dim]Resume with /resume <#|id>.[/dim]")
|
|
723
|
+
return
|
|
724
|
+
sid = items[int(arg) - 1]["id"] if arg.isdigit() and 0 < int(arg) <= len(items) else arg
|
|
725
|
+
try:
|
|
726
|
+
session, messages, meta = self.store.load(sid)
|
|
727
|
+
except FileNotFoundError as e:
|
|
728
|
+
console.print(f"[red]{escape(str(e))}[/red]")
|
|
729
|
+
return
|
|
730
|
+
self.agent.session = session
|
|
731
|
+
self.agent.load_history(messages)
|
|
732
|
+
prov = normalize_provider_name(meta.get("provider"))
|
|
733
|
+
if meta.get("model") and prov in self.providers:
|
|
734
|
+
if prov != self.agent.provider_name:
|
|
735
|
+
self.agent.provider = self.client(prov)
|
|
736
|
+
self.agent.provider_name = prov
|
|
737
|
+
self.agent.model = meta["model"]
|
|
738
|
+
console.print(f"[green]Resumed {session.id}[/green] [dim]({len(messages)} messages, model {escape(self.agent.model)})[/dim]")
|
|
739
|
+
print_history_tail(messages)
|
|
740
|
+
|
|
741
|
+
def cmd_cost(self, arg):
|
|
742
|
+
a = self.agent
|
|
743
|
+
window = int(a.settings.get("context_window", 128000))
|
|
744
|
+
console.print(f" model {escape(a.model)}")
|
|
745
|
+
console.print(f" input tokens {a.total_prompt_tokens:,}")
|
|
746
|
+
console.print(f" output tokens {a.total_completion_tokens:,}")
|
|
747
|
+
console.print(f" context ~{a.context_tokens:,} / {window:,} ({a.context_ratio():.0%})")
|
|
748
|
+
console.print(f" messages {len(a.messages)}")
|
|
749
|
+
|
|
750
|
+
def cmd_context(self, arg):
|
|
751
|
+
a = self.agent
|
|
752
|
+
from hubble.agent import estimate_messages, estimate_tokens
|
|
753
|
+
sys_tokens = estimate_tokens(a.system_prompt())
|
|
754
|
+
tool_tokens = estimate_tokens(str([t.schema() for t in a.tools]))
|
|
755
|
+
console.print(f" system prompt ~{sys_tokens:,} tokens "
|
|
756
|
+
f"(memory: {len(a.memory)} file(s), pinned: {len(a.pinned)})")
|
|
757
|
+
console.print(f" tool schemas ~{tool_tokens:,} tokens ({len(a.tools)} tools)")
|
|
758
|
+
console.print(f" messages ~{estimate_messages(a.messages):,} tokens ({len(a.messages)} messages)")
|
|
759
|
+
|
|
760
|
+
def cmd_undo(self, arg):
|
|
761
|
+
restored = self.agent.ctx.undo()
|
|
762
|
+
if not restored:
|
|
763
|
+
console.print("[dim]No file changes to undo.[/dim]")
|
|
764
|
+
return
|
|
765
|
+
for r in restored:
|
|
766
|
+
console.print(f" [yellow]reverted[/yellow] {escape(r)}")
|
|
767
|
+
note = "The user reverted your last file changes (/undo): " + ", ".join(restored)
|
|
768
|
+
self.agent.add_user_message(f"[{note}]")
|
|
769
|
+
self.agent._append({"role": "assistant", "content": "Noted; those changes were reverted."})
|
|
770
|
+
|
|
771
|
+
def cmd_diff(self, arg):
|
|
772
|
+
try:
|
|
773
|
+
res = subprocess.run(["git", "diff", "--stat"] + ([arg] if arg else []), cwd=self.agent.ctx.root,
|
|
774
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
775
|
+
full = subprocess.run(["git", "diff"] + ([arg] if arg else []), cwd=self.agent.ctx.root,
|
|
776
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
777
|
+
except OSError as e:
|
|
778
|
+
console.print(f"[red]{escape(str(e))}[/red]")
|
|
779
|
+
return
|
|
780
|
+
if res.returncode != 0:
|
|
781
|
+
console.print(f"[red]{escape(res.stderr.strip() or 'git diff failed')}[/red]")
|
|
782
|
+
return
|
|
783
|
+
if not full.stdout.strip():
|
|
784
|
+
console.print("[dim]No unstaged changes.[/dim]")
|
|
785
|
+
return
|
|
786
|
+
from hubble.ui import render_diff
|
|
787
|
+
console.print(render_diff(full.stdout, max_lines=400))
|
|
788
|
+
console.print(f"[dim]{escape(res.stdout.strip())}[/dim]")
|
|
789
|
+
|
|
790
|
+
def cmd_init(self, arg):
|
|
791
|
+
self.send(INIT_PROMPT + (f"\n\nExtra instructions: {arg}" if arg else ""))
|
|
792
|
+
self.agent.reload_memory()
|
|
793
|
+
|
|
794
|
+
def cmd_memory(self, arg):
|
|
795
|
+
if not self.agent.memory:
|
|
796
|
+
console.print("[dim]No memory files. Create one with /init or add notes with #note.[/dim]")
|
|
797
|
+
for p, text in self.agent.memory:
|
|
798
|
+
console.print(f" [bold]{escape(str(p))}[/bold] [dim]({len(text.splitlines())} lines)[/dim]")
|
|
799
|
+
|
|
800
|
+
def cmd_skills(self, arg):
|
|
801
|
+
if not self.agent.skills:
|
|
802
|
+
console.print("[dim]No skills yet. Ask the model to save one, or /skill new <name>.[/dim]")
|
|
803
|
+
return
|
|
804
|
+
for s in self.agent.skills:
|
|
805
|
+
console.print(f" [bold]/{s.name}[/bold] [dim]({s.scope})[/dim] {escape(s.description)}")
|
|
806
|
+
console.print("[dim]Run one with /<name>, or the model calls them on its own when relevant.[/dim]")
|
|
807
|
+
|
|
808
|
+
def cmd_skill_new(self, arg):
|
|
809
|
+
parts = arg.split()
|
|
810
|
+
if not parts or parts[0] != "new" or len(parts) < 2:
|
|
811
|
+
console.print("Usage: /skill new <name> [user] (default scope: this project)")
|
|
812
|
+
return
|
|
813
|
+
name, scope = parts[1].lower(), ("user" if len(parts) > 2 and parts[2] == "user" else "project")
|
|
814
|
+
if not re.match(r"^[a-z0-9][a-z0-9_-]{0,40}$", name):
|
|
815
|
+
console.print("[red]Name must be lowercase letters, digits, - or _ (max 41 chars).[/red]")
|
|
816
|
+
return
|
|
817
|
+
base = HOME_DIR / "skills" if scope == "user" else self.agent.ctx.root / ".hubble" / "skills"
|
|
818
|
+
path = base / name / "SKILL.md"
|
|
819
|
+
if path.exists():
|
|
820
|
+
console.print(f"[yellow]Already exists: {escape(str(path))}[/yellow]")
|
|
821
|
+
return
|
|
822
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
823
|
+
path.write_text(TEMPLATE.format(name=name, title=name.replace("-", " ").title()), encoding="utf-8")
|
|
824
|
+
self.agent.reload_skills()
|
|
825
|
+
self._load_custom_commands()
|
|
826
|
+
console.print(f"[green]Created {escape(str(path))}[/green] [dim]— fill in its description and "
|
|
827
|
+
f"instructions, then use it as /{name}.[/dim]")
|
|
828
|
+
|
|
829
|
+
def cmd_add(self, arg):
|
|
830
|
+
if not arg:
|
|
831
|
+
console.print("Usage: /add <file>")
|
|
832
|
+
return
|
|
833
|
+
try:
|
|
834
|
+
p = self.agent.ctx.resolve(arg)
|
|
835
|
+
except Exception as e:
|
|
836
|
+
console.print(f"[red]{escape(str(e))}[/red]")
|
|
837
|
+
return
|
|
838
|
+
if not p.is_file():
|
|
839
|
+
console.print(f"[red]Not a file: {escape(arg)}[/red]")
|
|
840
|
+
return
|
|
841
|
+
if is_secret_path(p) and not self.agent.ctx.allow_secrets:
|
|
842
|
+
console.print("[red]Refusing to pin a secrets file.[/red]")
|
|
843
|
+
return
|
|
844
|
+
rel = self.agent.ctx.rel(p)
|
|
845
|
+
self.agent.pinned[rel] = p.read_text(encoding="utf-8", errors="replace")
|
|
846
|
+
console.print(f"[green]Pinned {escape(rel)}[/green] [dim]({len(self.agent.pinned[rel]):,} chars)[/dim]")
|
|
847
|
+
|
|
848
|
+
def cmd_drop(self, arg):
|
|
849
|
+
keys = [k for k in self.agent.pinned if arg and arg.replace("\\", "/") in k]
|
|
850
|
+
if not keys:
|
|
851
|
+
console.print("[red]Not pinned.[/red]")
|
|
852
|
+
return
|
|
853
|
+
for k in keys:
|
|
854
|
+
del self.agent.pinned[k]
|
|
855
|
+
console.print(f"[green]Unpinned {escape(k)}[/green]")
|
|
856
|
+
|
|
857
|
+
def cmd_files(self, arg):
|
|
858
|
+
if not self.agent.pinned:
|
|
859
|
+
console.print("[dim]No pinned files. Use /add <file> or mention @file in a message.[/dim]")
|
|
860
|
+
for k, v in self.agent.pinned.items():
|
|
861
|
+
console.print(f" {escape(k)} [dim]({len(v.splitlines())} lines)[/dim]")
|
|
862
|
+
|
|
863
|
+
def cmd_todos(self, arg):
|
|
864
|
+
if not self.agent.ctx.todos:
|
|
865
|
+
console.print("[dim]No todos.[/dim]")
|
|
866
|
+
render_todos(self.agent.ctx.todos)
|
|
867
|
+
|
|
868
|
+
def cmd_test(self, arg):
|
|
869
|
+
model = arg or self.agent.model
|
|
870
|
+
with console.status(f"[dim]Testing {escape(model)}...[/dim]"):
|
|
871
|
+
res = self.agent.provider.ping(model)
|
|
872
|
+
if res["ok"]:
|
|
873
|
+
console.print(f"[green]✔ {escape(model)} OK[/green] [dim]{res['latency_ms']} ms · {escape(res['msg'])}[/dim]")
|
|
874
|
+
else:
|
|
875
|
+
console.print(f"[red]✘ {escape(model)} failed[/red] [dim]{res['latency_ms']} ms · {escape(res['msg'])}[/dim]")
|
|
876
|
+
|
|
877
|
+
def cmd_thinking(self, arg):
|
|
878
|
+
ev = self.agent.events
|
|
879
|
+
if arg.lower() in ("on", "off"):
|
|
880
|
+
ev.show_reasoning = arg.lower() == "on"
|
|
881
|
+
else:
|
|
882
|
+
ev.show_reasoning = not getattr(ev, "show_reasoning", False)
|
|
883
|
+
state = "shown" if ev.show_reasoning else "hidden (the spinner counts thinking tokens)"
|
|
884
|
+
save_user_setting("show_reasoning", ev.show_reasoning)
|
|
885
|
+
console.print(f"[green]Reasoning text {state}.[/green]")
|
|
886
|
+
|
|
887
|
+
def cmd_sandbox(self, arg):
|
|
888
|
+
ctx = self.agent.ctx
|
|
889
|
+
arg = arg.lower().strip()
|
|
890
|
+
if arg in ("on", "docker"):
|
|
891
|
+
if not shutil.which("docker"):
|
|
892
|
+
console.print("[red]docker was not found on PATH. Install Docker Desktop first.[/red]")
|
|
893
|
+
return
|
|
894
|
+
ctx.sandbox = "docker"
|
|
895
|
+
elif arg == "off":
|
|
896
|
+
ctx.sandbox = "off"
|
|
897
|
+
elif arg:
|
|
898
|
+
console.print("Usage: /sandbox [on|off]")
|
|
899
|
+
return
|
|
900
|
+
if arg:
|
|
901
|
+
self.agent.settings["shell_sandbox"] = ctx.sandbox
|
|
902
|
+
save_user_setting("shell_sandbox", ctx.sandbox)
|
|
903
|
+
if ctx.sandbox == "docker":
|
|
904
|
+
net = "with network access" if ctx.sandbox_network else "network disabled"
|
|
905
|
+
console.print(f"[green]Sandbox: docker[/green] [dim](image {ctx.sandbox_image}, "
|
|
906
|
+
f"{ctx.sandbox_memory} mem, {ctx.sandbox_cpus} cpu, {net})[/dim]")
|
|
907
|
+
else:
|
|
908
|
+
console.print("[yellow]Sandbox: off[/yellow] [dim](shell commands run directly on this machine)[/dim]")
|
|
909
|
+
|
|
910
|
+
def cmd_temp(self, arg):
|
|
911
|
+
if not arg:
|
|
912
|
+
console.print(f"Temperature: {self.agent.temperature}")
|
|
913
|
+
return
|
|
914
|
+
try:
|
|
915
|
+
val = float(arg)
|
|
916
|
+
if not 0 <= val <= 2:
|
|
917
|
+
raise ValueError
|
|
918
|
+
except ValueError:
|
|
919
|
+
console.print("[red]Temperature must be between 0 and 2[/red]")
|
|
920
|
+
return
|
|
921
|
+
self.agent.temperature = val
|
|
922
|
+
console.print(f"[green]Temperature: {val}[/green]")
|
|
923
|
+
|
|
924
|
+
def cmd_config(self, arg):
|
|
925
|
+
s = self.agent.settings
|
|
926
|
+
for key in ("model", "base_url", "max_tokens", "context_window", "auto_compact_ratio", "max_turns",
|
|
927
|
+
"shell", "shell_timeout", "additional_dirs", "allow_secret_files"):
|
|
928
|
+
console.print(f" {key:<20} {escape(str(s.get(key)))}")
|
|
929
|
+
perms = self.agent.permissions
|
|
930
|
+
console.print(f" {'allow rules':<20} {escape(', '.join(perms.allow + perms.session_allow) or '-')}")
|
|
931
|
+
console.print(f" {'deny rules':<20} {escape(', '.join(perms.deny) or '-')}")
|
|
932
|
+
console.print(f"[dim] files: {HOME_DIR / 'settings.json'}, .hubble/settings.json, .hubble/settings.local.json[/dim]")
|
|
933
|
+
|
|
934
|
+
def cmd_export(self, arg):
|
|
935
|
+
path = Path(arg or f"hubble_chat_{time.strftime('%Y%m%d_%H%M%S')}.md")
|
|
936
|
+
if not path.is_absolute():
|
|
937
|
+
path = self.agent.ctx.root / path
|
|
938
|
+
lines = [f"# Hubble session {self.agent.session.id if self.agent.session else ''}", "",
|
|
939
|
+
f"Model: {self.agent.model}", ""]
|
|
940
|
+
for m in self.agent.messages:
|
|
941
|
+
role = m.get("role")
|
|
942
|
+
if role == "tool":
|
|
943
|
+
lines += [f"### Tool result ({m.get('name', '')})", "```", truncate(m.get("content") or "", 3000), "```", ""]
|
|
944
|
+
continue
|
|
945
|
+
lines += [f"### {role.capitalize()}", m.get("content") or "", ""]
|
|
946
|
+
for tc in m.get("tool_calls") or []:
|
|
947
|
+
lines += [f"- tool call `{tc['function']['name']}` `{truncate(tc['function']['arguments'], 500)}`"]
|
|
948
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
949
|
+
console.print(f"[green]Exported to {escape(str(path))}[/green]")
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def register_provider(cfg: ProviderConfig, ids):
|
|
953
|
+
save_provider(cfg)
|
|
954
|
+
save_listing(cfg.name, cfg.base_url, ids)
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def session_items(items):
|
|
958
|
+
out = []
|
|
959
|
+
for it in items:
|
|
960
|
+
when = time.strftime("%m-%d %H:%M", time.localtime(it["updated"]))
|
|
961
|
+
title = (it["title"] or "(no prompt)")[:55]
|
|
962
|
+
out.append((it["id"], f"{when} {title}", f"{it['messages']} msgs · {it['model']}"))
|
|
963
|
+
return out
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def escape_html(text: str) -> str:
|
|
967
|
+
return text.replace("&", "&").replace("<", "<").replace(">", ">")
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def print_history_tail(messages, n: int = 4):
|
|
971
|
+
shown = [m for m in messages if m.get("role") in ("user", "assistant") and m.get("content")][-n:]
|
|
972
|
+
for m in shown:
|
|
973
|
+
who = "[bold cyan]you[/bold cyan]" if m["role"] == "user" else "[bold magenta]hubble[/bold magenta]"
|
|
974
|
+
text = m["content"].strip().splitlines()
|
|
975
|
+
preview = " ".join(text)[:200] if text else ""
|
|
976
|
+
console.print(f" {who} [dim]{escape(preview)}[/dim]")
|