polymath-agent 0.4.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.
- polymath/__init__.py +2 -0
- polymath/adapters/__init__.py +7 -0
- polymath/adapters/base.py +175 -0
- polymath/adapters/claude.py +280 -0
- polymath/adapters/gemini.py +186 -0
- polymath/adapters/ollama.py +117 -0
- polymath/adapters/openai_adapter.py +168 -0
- polymath/bootstrap.py +159 -0
- polymath/command_registry.py +41 -0
- polymath/command_service.py +572 -0
- polymath/compressor.py +90 -0
- polymath/config.py +293 -0
- polymath/context_manager.py +76 -0
- polymath/context_store.py +336 -0
- polymath/detector.py +442 -0
- polymath/domain.py +78 -0
- polymath/execution_service.py +325 -0
- polymath/main.py +1293 -0
- polymath/memory/__init__.py +15 -0
- polymath/memory/chunker.py +6 -0
- polymath/memory/embedder.py +179 -0
- polymath/memory/migrate.py +2 -0
- polymath/memory/retriever.py +2 -0
- polymath/memory/store.py +9 -0
- polymath/memory/sync.py +2 -0
- polymath/memory/writer.py +9 -0
- polymath/model_policy.py +172 -0
- polymath/orchestrator/__init__.py +68 -0
- polymath/orchestrator/attempt_ledger.py +34 -0
- polymath/orchestrator/ensemble.py +229 -0
- polymath/orchestrator/fanout.py +322 -0
- polymath/orchestrator/output_policy.py +61 -0
- polymath/orchestrator/race.py +311 -0
- polymath/orchestrator/run_controller.py +91 -0
- polymath/orchestrator/speculative_review.py +120 -0
- polymath/orchestrator/state_responder.py +184 -0
- polymath/orchestrator/worker_pool.py +37 -0
- polymath/permissions.py +82 -0
- polymath/pipeline.py +700 -0
- polymath/project_config.py +229 -0
- polymath/project_runtime.py +109 -0
- polymath/router.py +127 -0
- polymath/setup_wizard.py +106 -0
- polymath/slash_commands.py +566 -0
- polymath/subagents.py +486 -0
- polymath/tools.py +333 -0
- polymath/ui_state.py +84 -0
- polymath/workspace.py +66 -0
- polymath_agent-0.4.0.dist-info/METADATA +693 -0
- polymath_agent-0.4.0.dist-info/RECORD +54 -0
- polymath_agent-0.4.0.dist-info/WHEEL +5 -0
- polymath_agent-0.4.0.dist-info/entry_points.txt +2 -0
- polymath_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
- polymath_agent-0.4.0.dist-info/top_level.txt +1 -0
polymath/main.py
ADDED
|
@@ -0,0 +1,1293 @@
|
|
|
1
|
+
"""
|
|
2
|
+
polymath — multi-model AI orchestrator REPL.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
polymath start interactive session (auto-uses repo .polymath/ when present)
|
|
6
|
+
polymath --setup re-run setup wizard
|
|
7
|
+
polymath --models list available models
|
|
8
|
+
polymath --session <id> resume a session
|
|
9
|
+
polymath --sessions list all sessions
|
|
10
|
+
polymath --project <name> start with a detached local project
|
|
11
|
+
polymath ask "question" one-shot answer, no REPL (auto-uses repo .polymath/ when present)
|
|
12
|
+
polymath ask "question" --project <name> one-shot with detached local project context
|
|
13
|
+
|
|
14
|
+
In the REPL:
|
|
15
|
+
@claude <task> force Claude
|
|
16
|
+
@gemini <task> force Gemini
|
|
17
|
+
@openai <task> force OpenAI
|
|
18
|
+
@ollama <task> force Ollama
|
|
19
|
+
--cheap <task> cost-first routing
|
|
20
|
+
--fast <task> speed-first routing
|
|
21
|
+
--ask <question> answer directly without pipeline
|
|
22
|
+
--verify run verify subagent after pipeline
|
|
23
|
+
--simplify run code-simplifier subagent after pipeline
|
|
24
|
+
/models list models
|
|
25
|
+
/sessions list sessions
|
|
26
|
+
/session <id> switch session
|
|
27
|
+
/session delete <id> delete a session
|
|
28
|
+
/new new session
|
|
29
|
+
/project new <name> create a new project
|
|
30
|
+
/project use <name> switch to a project
|
|
31
|
+
/project list list all projects
|
|
32
|
+
/project init create .polymath/ in current directory
|
|
33
|
+
/context show <type> show a context file
|
|
34
|
+
/context add <type> <entry> append an entry to context
|
|
35
|
+
/context edit <type> edit a context file
|
|
36
|
+
/ship [msg] commit + push + PR (AI-generated message)
|
|
37
|
+
/simplify simplify last output
|
|
38
|
+
/explain explain last output
|
|
39
|
+
/<command> run any custom slash command
|
|
40
|
+
/commands list all available slash commands
|
|
41
|
+
/subagents list all available subagents
|
|
42
|
+
/accounts list and switch authenticated accounts
|
|
43
|
+
/state show current project/session/runtime state
|
|
44
|
+
/queue inspect queued commands
|
|
45
|
+
/queue drop <n> remove a queued command
|
|
46
|
+
/queue move <a> <b> reorder queued commands
|
|
47
|
+
/exit exit
|
|
48
|
+
"""
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
# Bootstrap runs before everything — auto-fixes deps silently
|
|
52
|
+
import polymath.bootstrap as _bootstrap
|
|
53
|
+
_bootstrap.run()
|
|
54
|
+
|
|
55
|
+
import asyncio
|
|
56
|
+
from collections import deque
|
|
57
|
+
import os
|
|
58
|
+
import re
|
|
59
|
+
import subprocess
|
|
60
|
+
import sys
|
|
61
|
+
import termios
|
|
62
|
+
from typing import Optional
|
|
63
|
+
|
|
64
|
+
from prompt_toolkit import PromptSession
|
|
65
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
66
|
+
from prompt_toolkit.history import FileHistory
|
|
67
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
68
|
+
from prompt_toolkit.patch_stdout import patch_stdout
|
|
69
|
+
from prompt_toolkit.styles import Style
|
|
70
|
+
from rich.console import Console
|
|
71
|
+
from rich.markdown import Markdown
|
|
72
|
+
from rich.panel import Panel
|
|
73
|
+
|
|
74
|
+
from polymath.config import (
|
|
75
|
+
CONFIG_DIR, TaskComplexity, TaskType, load_config
|
|
76
|
+
)
|
|
77
|
+
from polymath.context_store import (
|
|
78
|
+
init_db, list_sessions, new_session, get_session,
|
|
79
|
+
get_recent_sessions, get_last_session_for_project,
|
|
80
|
+
was_recent, time_ago, new_session_for_project, auto_name_session,
|
|
81
|
+
session_cost,
|
|
82
|
+
)
|
|
83
|
+
from polymath.detector import detect
|
|
84
|
+
from polymath.router import (
|
|
85
|
+
classify_task, classify_complexity,
|
|
86
|
+
)
|
|
87
|
+
from polymath.setup_wizard import run_wizard, show_models
|
|
88
|
+
from polymath.command_registry import build_repl_completer
|
|
89
|
+
from polymath.command_service import CommandServiceContext, handle_command
|
|
90
|
+
from polymath.context_manager import (
|
|
91
|
+
get_project_meta, list_projects, list_context_files, TASK_CONTEXT_MAP,
|
|
92
|
+
)
|
|
93
|
+
from polymath.domain import Project
|
|
94
|
+
from polymath.domain import PermissionRequest
|
|
95
|
+
from polymath.execution_service import ExecutionCallbacks, run_ask_flow, run_pipeline_flow
|
|
96
|
+
from polymath.model_policy import resolve_models
|
|
97
|
+
from polymath.orchestrator import (
|
|
98
|
+
ExecutionStrategy,
|
|
99
|
+
OutputPolicy,
|
|
100
|
+
RunController,
|
|
101
|
+
RunRequest,
|
|
102
|
+
run_ensemble,
|
|
103
|
+
run_fanout,
|
|
104
|
+
select_ensemble_workers,
|
|
105
|
+
select_splitter_and_joiner,
|
|
106
|
+
)
|
|
107
|
+
from polymath.permissions import is_preapproved
|
|
108
|
+
from polymath.project_runtime import project_provenance_lines, resolve_project
|
|
109
|
+
from polymath.slash_commands import get_all_commands
|
|
110
|
+
from polymath.subagents import get_subagents
|
|
111
|
+
from polymath.ui_state import CompletionSummary, RunStatus, prompt_toolbar, render_completion_summary, render_run_status
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
console = Console(force_terminal=True, highlight=False)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class _SuppressEcho:
|
|
118
|
+
"""
|
|
119
|
+
Context manager that disables terminal echo while the pipeline runs.
|
|
120
|
+
Keystrokes the user types are still buffered and appear cleanly in
|
|
121
|
+
the next prompt — they just don't echo mid-stream.
|
|
122
|
+
No-ops silently on non-TTY outputs (pipes, tests).
|
|
123
|
+
"""
|
|
124
|
+
def __enter__(self):
|
|
125
|
+
self._old = None
|
|
126
|
+
try:
|
|
127
|
+
self._fd = sys.stdin.fileno()
|
|
128
|
+
self._old = termios.tcgetattr(self._fd)
|
|
129
|
+
new = list(self._old)
|
|
130
|
+
new[3] = new[3] & ~termios.ECHO # clear ECHO bit in lflags
|
|
131
|
+
termios.tcsetattr(self._fd, termios.TCSANOW, new)
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
return self
|
|
135
|
+
|
|
136
|
+
def __exit__(self, *_):
|
|
137
|
+
if self._old is not None:
|
|
138
|
+
try:
|
|
139
|
+
termios.tcsetattr(self._fd, termios.TCSANOW, self._old)
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
PROMPT_STYLE = Style.from_dict({
|
|
144
|
+
"prompt": "bold cyan",
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
MODEL_COLORS = {
|
|
148
|
+
"claude": "cyan",
|
|
149
|
+
"gemini": "blue",
|
|
150
|
+
"openai": "green",
|
|
151
|
+
"ollama": "yellow",
|
|
152
|
+
"lmstudio": "magenta",
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def _parse_force(text: str) -> tuple[str, str]:
|
|
156
|
+
"""Returns (provider_override, cleaned_text)."""
|
|
157
|
+
m = re.match(r"^@(\w+)\s+(.*)", text, re.DOTALL)
|
|
158
|
+
if m:
|
|
159
|
+
return m.group(1).lower(), m.group(2).strip()
|
|
160
|
+
return "", text
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _parse_flags(text: str) -> tuple[bool, bool, bool, bool, bool, bool, str]:
|
|
164
|
+
"""Returns (cheap, fast, verify, simplify, ensemble, fanout, cleaned_text)."""
|
|
165
|
+
cheap = "--cheap" in text
|
|
166
|
+
fast = "--fast" in text
|
|
167
|
+
verify = "--verify" in text
|
|
168
|
+
simplify = "--simplify" in text
|
|
169
|
+
ensemble = "--ensemble" in text
|
|
170
|
+
fanout = "--fanout" in text
|
|
171
|
+
text = (text
|
|
172
|
+
.replace("--cheap", "")
|
|
173
|
+
.replace("--fast", "")
|
|
174
|
+
.replace("--verify", "")
|
|
175
|
+
.replace("--simplify", "")
|
|
176
|
+
.replace("--ensemble", "")
|
|
177
|
+
.replace("--fanout", "")
|
|
178
|
+
.strip())
|
|
179
|
+
return cheap, fast, verify, simplify, ensemble, fanout, text
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _notify(title: str, message: str) -> None:
|
|
183
|
+
"""Fire an OS notification. Silent no-op if notifications are unavailable."""
|
|
184
|
+
import shutil
|
|
185
|
+
try:
|
|
186
|
+
if sys.platform == "darwin":
|
|
187
|
+
# macOS
|
|
188
|
+
subprocess.run(
|
|
189
|
+
["osascript", "-e",
|
|
190
|
+
f'display notification "{message}" with title "{title}"'],
|
|
191
|
+
capture_output=True, timeout=3,
|
|
192
|
+
)
|
|
193
|
+
elif shutil.which("notify-send"):
|
|
194
|
+
# Linux (libnotify)
|
|
195
|
+
subprocess.run(
|
|
196
|
+
["notify-send", title, message],
|
|
197
|
+
capture_output=True, timeout=3,
|
|
198
|
+
)
|
|
199
|
+
except Exception:
|
|
200
|
+
pass
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _expand_context_mentions(text: str, project: str) -> tuple[str, list[str]]:
|
|
204
|
+
"""
|
|
205
|
+
Expand @contextname mentions in user message.
|
|
206
|
+
e.g. "@rules check this" → injects rules.md content inline.
|
|
207
|
+
Returns (expanded_text, list_of_injected_names).
|
|
208
|
+
"""
|
|
209
|
+
if not project or "@" not in text:
|
|
210
|
+
return text, []
|
|
211
|
+
from polymath.context_manager import read_context, CONTEXT_FILES
|
|
212
|
+
injected = []
|
|
213
|
+
for ctx_name in CONTEXT_FILES:
|
|
214
|
+
pattern = re.compile(rf"@{re.escape(ctx_name)}\b", re.IGNORECASE)
|
|
215
|
+
if pattern.search(text):
|
|
216
|
+
content = read_context(project, ctx_name)
|
|
217
|
+
if content:
|
|
218
|
+
text = pattern.sub(f"\n[{ctx_name.upper()} CONTEXT]\n{content}\n", text)
|
|
219
|
+
injected.append(ctx_name)
|
|
220
|
+
return text, injected
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _active_polymath_sessions() -> int:
|
|
224
|
+
"""Count other polymath processes currently running (excluding this one)."""
|
|
225
|
+
try:
|
|
226
|
+
result = subprocess.run(
|
|
227
|
+
["pgrep", "-f", "polymath"],
|
|
228
|
+
capture_output=True, text=True, timeout=3,
|
|
229
|
+
)
|
|
230
|
+
pids = [p for p in result.stdout.strip().splitlines() if p.strip()]
|
|
231
|
+
return max(0, len(pids) - 1) # subtract self
|
|
232
|
+
except Exception:
|
|
233
|
+
return 0
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
async def _run_ensemble_flow(
|
|
237
|
+
*,
|
|
238
|
+
console,
|
|
239
|
+
text: str,
|
|
240
|
+
session_id: str,
|
|
241
|
+
active_project: str,
|
|
242
|
+
registry: list,
|
|
243
|
+
cfg: dict,
|
|
244
|
+
ensemble_warned: bool,
|
|
245
|
+
) -> None:
|
|
246
|
+
"""Run the same prompt across N available models in parallel + merge."""
|
|
247
|
+
from rich.panel import Panel
|
|
248
|
+
|
|
249
|
+
from polymath.context_store import add_message
|
|
250
|
+
from polymath.router import classify_task
|
|
251
|
+
|
|
252
|
+
workers, merger = select_ensemble_workers(registry, cfg, max_workers=3)
|
|
253
|
+
if not workers:
|
|
254
|
+
console.print("[red]No models available for ensemble.[/red]")
|
|
255
|
+
return
|
|
256
|
+
if len(workers) < 2:
|
|
257
|
+
console.print(
|
|
258
|
+
f"[yellow]Only one model available ({workers[0].model.display_name}); "
|
|
259
|
+
f"running it normally — no merge.[/yellow]"
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if not ensemble_warned:
|
|
263
|
+
names = ", ".join(w.model.display_name for w in workers)
|
|
264
|
+
console.print(
|
|
265
|
+
f"[yellow]Ensemble mode: calling {len(workers)} models in parallel "
|
|
266
|
+
f"({names}) → ~{len(workers)}x token cost.[/yellow]"
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
add_message(session_id, "user", text, step="ensemble")
|
|
270
|
+
|
|
271
|
+
ticker = {"started": 0, "done": 0}
|
|
272
|
+
|
|
273
|
+
def started(name: str) -> None:
|
|
274
|
+
ticker["started"] += 1
|
|
275
|
+
console.print(f"[dim]⋯ worker started: {name} ({ticker['started']}/{len(workers)})[/dim]")
|
|
276
|
+
|
|
277
|
+
def done(result):
|
|
278
|
+
ticker["done"] += 1
|
|
279
|
+
if result.ok:
|
|
280
|
+
console.print(
|
|
281
|
+
f"[green]✓[/green] [dim]{result.display_name} "
|
|
282
|
+
f"({ticker['done']}/{len(workers)}) — {len(result.output)} chars[/dim]"
|
|
283
|
+
)
|
|
284
|
+
else:
|
|
285
|
+
console.print(
|
|
286
|
+
f"[red]✗[/red] [dim]{result.display_name}: {result.error.splitlines()[0][:80]}[/dim]"
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
result = await run_ensemble(
|
|
290
|
+
workers=workers,
|
|
291
|
+
user_input=text,
|
|
292
|
+
task_type=classify_task(text),
|
|
293
|
+
active_project=active_project,
|
|
294
|
+
cfg=cfg,
|
|
295
|
+
merger=merger if len(workers) >= 2 else None,
|
|
296
|
+
on_worker_started=started,
|
|
297
|
+
on_worker_done=done,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
successful = result.successful
|
|
301
|
+
if not successful:
|
|
302
|
+
console.print("[red]All ensemble workers failed.[/red]")
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
console.print()
|
|
306
|
+
if result.merged:
|
|
307
|
+
title = f"[bold]Merged ({result.merger_model})[/bold]" if result.merger_model else "[bold]Result[/bold]"
|
|
308
|
+
console.print(Panel(result.merged, title=title, border_style="cyan"))
|
|
309
|
+
if result.disagreement_notes:
|
|
310
|
+
console.print(Panel(
|
|
311
|
+
result.disagreement_notes,
|
|
312
|
+
title="[bold yellow]Disagreements[/bold yellow]",
|
|
313
|
+
border_style="yellow",
|
|
314
|
+
))
|
|
315
|
+
for w in successful:
|
|
316
|
+
console.print(Panel(
|
|
317
|
+
w.output,
|
|
318
|
+
title=f"[dim]{w.display_name}[/dim]",
|
|
319
|
+
border_style="dim",
|
|
320
|
+
))
|
|
321
|
+
|
|
322
|
+
add_message(
|
|
323
|
+
session_id, "assistant",
|
|
324
|
+
result.merged or successful[0].output,
|
|
325
|
+
model="ensemble",
|
|
326
|
+
step="ensemble",
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
async def _run_fanout_flow(
|
|
331
|
+
*,
|
|
332
|
+
console,
|
|
333
|
+
text: str,
|
|
334
|
+
session_id: str,
|
|
335
|
+
active_project: str,
|
|
336
|
+
registry: list,
|
|
337
|
+
cfg: dict,
|
|
338
|
+
profile: str,
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Split into independent subtasks → run in parallel on best-fit specialists → join."""
|
|
341
|
+
from rich.panel import Panel
|
|
342
|
+
|
|
343
|
+
from polymath.context_store import add_message
|
|
344
|
+
from polymath.model_policy import resolve_models
|
|
345
|
+
from polymath.router import classify_task
|
|
346
|
+
|
|
347
|
+
splitter_pair, joiner_pair = select_splitter_and_joiner(registry, cfg, profile)
|
|
348
|
+
if not splitter_pair or not joiner_pair:
|
|
349
|
+
console.print("[red]Fanout needs at least one available model.[/red]")
|
|
350
|
+
return
|
|
351
|
+
|
|
352
|
+
# Get a quick plan first — splitter uses it as context
|
|
353
|
+
resolution = await resolve_models(
|
|
354
|
+
registry=registry, cfg=cfg, session_id=session_id,
|
|
355
|
+
user_input=text, active_project=active_project,
|
|
356
|
+
task_type=classify_task(text), profile=profile, is_ask=True,
|
|
357
|
+
)
|
|
358
|
+
plan = ""
|
|
359
|
+
if resolution.primary_adapter and resolution.primary_info:
|
|
360
|
+
try:
|
|
361
|
+
from polymath.adapters.base import Message
|
|
362
|
+
plan_resp = await resolution.primary_adapter.complete(
|
|
363
|
+
messages=[Message(role="user", content=f"Outline a step-by-step plan for: {text}")],
|
|
364
|
+
model_id=resolution.primary_info.id,
|
|
365
|
+
system="Output a numbered plan only.",
|
|
366
|
+
temperature=0.3,
|
|
367
|
+
)
|
|
368
|
+
plan = plan_resp if isinstance(plan_resp, str) else getattr(plan_resp, "content", "")
|
|
369
|
+
except Exception:
|
|
370
|
+
pass
|
|
371
|
+
|
|
372
|
+
add_message(session_id, "user", text, step="fanout")
|
|
373
|
+
console.print(f"[dim]Splitting task across specialists…[/dim]")
|
|
374
|
+
|
|
375
|
+
splitter_info, splitter_adapter = splitter_pair
|
|
376
|
+
joiner_info, joiner_adapter = joiner_pair
|
|
377
|
+
|
|
378
|
+
def on_split(subtasks):
|
|
379
|
+
if len(subtasks) <= 1:
|
|
380
|
+
console.print(f"[yellow]Splitter returned {len(subtasks)} subtask — fanout skipped.[/yellow]")
|
|
381
|
+
return
|
|
382
|
+
console.print(f"[cyan]Split into {len(subtasks)} subtasks:[/cyan]")
|
|
383
|
+
for st in subtasks:
|
|
384
|
+
console.print(f" [bold]{st.id}[/bold] ({st.task_type.value}): {st.description[:80]}")
|
|
385
|
+
|
|
386
|
+
def on_started(subtask, model):
|
|
387
|
+
console.print(f"[dim]⋯ {subtask.id} → {model.display_name}[/dim]")
|
|
388
|
+
|
|
389
|
+
def on_done(result):
|
|
390
|
+
if result.ok:
|
|
391
|
+
console.print(f"[green]✓[/green] [dim]{result.subtask.id} ({result.model_display}, {len(result.output)} chars)[/dim]")
|
|
392
|
+
else:
|
|
393
|
+
console.print(f"[red]✗[/red] [dim]{result.subtask.id}: {result.error.splitlines()[0][:80]}[/dim]")
|
|
394
|
+
|
|
395
|
+
result = await run_fanout(
|
|
396
|
+
task=text,
|
|
397
|
+
plan=plan,
|
|
398
|
+
registry=registry,
|
|
399
|
+
cfg=cfg,
|
|
400
|
+
splitter_adapter=splitter_adapter,
|
|
401
|
+
splitter_model=splitter_info,
|
|
402
|
+
joiner_adapter=joiner_adapter,
|
|
403
|
+
joiner_model=joiner_info,
|
|
404
|
+
profile=profile,
|
|
405
|
+
on_split=on_split,
|
|
406
|
+
on_subtask_started=on_started,
|
|
407
|
+
on_subtask_done=on_done,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if result.skipped_reason:
|
|
411
|
+
console.print(f"[yellow]Fanout skipped: {result.skipped_reason}[/yellow]")
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
console.print()
|
|
415
|
+
console.print(Panel(
|
|
416
|
+
result.joined,
|
|
417
|
+
title=f"[bold]Joined ({result.join_model})[/bold]",
|
|
418
|
+
border_style="cyan",
|
|
419
|
+
))
|
|
420
|
+
for r in result.successful:
|
|
421
|
+
console.print(Panel(
|
|
422
|
+
r.output,
|
|
423
|
+
title=f"[dim]{r.subtask.id} · {r.model_display}[/dim]",
|
|
424
|
+
border_style="dim",
|
|
425
|
+
))
|
|
426
|
+
|
|
427
|
+
add_message(
|
|
428
|
+
session_id, "assistant", result.joined,
|
|
429
|
+
model=f"fanout/{result.join_model}", step="fanout",
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _print_usage_bar(usage: dict) -> None:
|
|
434
|
+
"""Print right-aligned per-model token usage after each output."""
|
|
435
|
+
def _fmt(n: int) -> str:
|
|
436
|
+
return f"{n/1000:.1f}k" if n >= 1000 else str(n)
|
|
437
|
+
parts = []
|
|
438
|
+
for name, values in usage.items():
|
|
439
|
+
i, o = values[:2]
|
|
440
|
+
if not i and not o:
|
|
441
|
+
continue
|
|
442
|
+
extra = ""
|
|
443
|
+
if len(values) >= 3 and values[2]:
|
|
444
|
+
remaining = max(values[2] - i, 0)
|
|
445
|
+
extra = f" | ctx {_fmt(remaining)} left"
|
|
446
|
+
parts.append(f"{name}: {_fmt(i)}↑ {_fmt(o)}↓{extra}")
|
|
447
|
+
if not parts:
|
|
448
|
+
return
|
|
449
|
+
text = " · ".join(parts)
|
|
450
|
+
width = console.width or 100
|
|
451
|
+
console.print(text.rjust(width), style="dim")
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _print_startup_header() -> None:
|
|
455
|
+
logo = "\n".join([
|
|
456
|
+
"[bold #7FF7E8]██████╗ ██████╗ ██╗ ██╗ ██╗███╗ ███╗ █████╗ ████████╗██╗ ██╗[/bold #7FF7E8]",
|
|
457
|
+
"[bold #67EBDD]██╔══██╗██╔═══██╗██║ ╚██╗ ██╔╝████╗ ████║██╔══██╗╚══██╔══╝██║ ██║[/bold #67EBDD]",
|
|
458
|
+
"[bold #52DCCD]██████╔╝██║ ██║██║ ╚████╔╝ ██╔████╔██║███████║ ██║ ███████║[/bold #52DCCD]",
|
|
459
|
+
"[bold #43CFC1]██╔═══╝ ██║ ██║██║ ╚██╔╝ ██║╚██╔╝██║██╔══██║ ██║ ██╔══██║[/bold #43CFC1]",
|
|
460
|
+
"[bold #35C1B4]██║ ╚██████╔╝███████╗██║ ██║ ╚═╝ ██║██║ ██║ ██║ ██║ ██║[/bold #35C1B4]",
|
|
461
|
+
"[bold #28B3A7]╚═╝ ╚═════╝ ╚══════╝╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝[/bold #28B3A7]",
|
|
462
|
+
])
|
|
463
|
+
console.print(logo)
|
|
464
|
+
console.print("[#8CF5EA]multi-model terminal orchestrator[/#8CF5EA]")
|
|
465
|
+
console.print("[dim]one conversation surface • many models behind it[/dim]")
|
|
466
|
+
console.print()
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
async def run_session(session_id: str, cfg: dict, active_project: str = "") -> None:
|
|
470
|
+
import asyncio as _asyncio
|
|
471
|
+
registry = detect()
|
|
472
|
+
profile = cfg.get("priority_profile", "quality-first")
|
|
473
|
+
output_policy = OutputPolicy()
|
|
474
|
+
run_controller = RunController(output_policy=output_policy)
|
|
475
|
+
history_file = CONFIG_DIR / "history.txt"
|
|
476
|
+
_queued_inputs: deque[str | None] = deque()
|
|
477
|
+
_queue_ready = _asyncio.Event()
|
|
478
|
+
_proc = {"active": False} # mutable dict for closure access in _input_reader
|
|
479
|
+
|
|
480
|
+
bindings = KeyBindings()
|
|
481
|
+
|
|
482
|
+
@bindings.add("up")
|
|
483
|
+
def _edit_queued_or_history(event) -> None:
|
|
484
|
+
buf = event.app.current_buffer
|
|
485
|
+
if _queued_inputs and not buf.text:
|
|
486
|
+
queued = _queued_inputs.pop()
|
|
487
|
+
if not _queued_inputs:
|
|
488
|
+
_queue_ready.clear()
|
|
489
|
+
if queued is not None:
|
|
490
|
+
buf.text = queued
|
|
491
|
+
buf.cursor_position = len(queued)
|
|
492
|
+
return
|
|
493
|
+
buf.auto_up(count=1)
|
|
494
|
+
|
|
495
|
+
@bindings.add("down")
|
|
496
|
+
def _next_history(event) -> None:
|
|
497
|
+
event.app.current_buffer.auto_down(count=1)
|
|
498
|
+
|
|
499
|
+
project_state: Project = resolve_project(active_project)
|
|
500
|
+
|
|
501
|
+
def _toolbar() -> ANSI:
|
|
502
|
+
return ANSI(prompt_toolbar(project_state, _queued_inputs, _proc["active"], session_id))
|
|
503
|
+
|
|
504
|
+
# Main prompt session — used ONLY by the background input reader
|
|
505
|
+
prompt_session = PromptSession(
|
|
506
|
+
history=FileHistory(str(history_file)),
|
|
507
|
+
style=PROMPT_STYLE,
|
|
508
|
+
key_bindings=bindings,
|
|
509
|
+
completer=build_repl_completer([]),
|
|
510
|
+
complete_while_typing=True,
|
|
511
|
+
bottom_toolbar=_toolbar,
|
|
512
|
+
)
|
|
513
|
+
# ── Load local .polymath/ project config ──────────────────────────────────
|
|
514
|
+
local_project = project_state.local_project
|
|
515
|
+
if local_project and not active_project:
|
|
516
|
+
active_project = project_state.name
|
|
517
|
+
console.print(f"[dim]Found .polymath/ — project: [bold]{active_project}[/bold][/dim]")
|
|
518
|
+
|
|
519
|
+
# ── Load slash commands and subagents ─────────────────────────────────────
|
|
520
|
+
_cmds = get_all_commands(local_project.commands_dir if local_project else None)
|
|
521
|
+
_subs = get_subagents(local_project.subagents_dir if local_project else None)
|
|
522
|
+
prompt_session.completer = build_repl_completer(list(_cmds.keys()))
|
|
523
|
+
|
|
524
|
+
_print_startup_header()
|
|
525
|
+
proj_label = f" • project: [bold]{active_project}[/bold]" if active_project else ""
|
|
526
|
+
available = [m for m in registry if m.available]
|
|
527
|
+
model_label = (
|
|
528
|
+
f" • [bold]{available[0].display_name}[/bold] (1 model — /setup to add more)"
|
|
529
|
+
if len(available) == 1
|
|
530
|
+
else f" • {len(available)} models"
|
|
531
|
+
)
|
|
532
|
+
console.print(f"[dim]profile: {profile}{proj_label}{model_label}[/dim]")
|
|
533
|
+
parallel = _active_polymath_sessions()
|
|
534
|
+
parallel_label = f" • [dim]{parallel} other session{'s' if parallel != 1 else ''} running[/dim]" if parallel > 0 else ""
|
|
535
|
+
console.print(f"[dim]Type /help for commands, /exit to quit{parallel_label}[/dim]\n")
|
|
536
|
+
for line in project_provenance_lines(project_state):
|
|
537
|
+
console.print(f"[dim]{line}[/dim]")
|
|
538
|
+
console.print()
|
|
539
|
+
|
|
540
|
+
# Track last pipeline output for slash commands
|
|
541
|
+
_last_result_output: str = ""
|
|
542
|
+
|
|
543
|
+
async def _input_reader() -> None:
|
|
544
|
+
"""Background task: always shows a prompt and queues submitted commands."""
|
|
545
|
+
while True:
|
|
546
|
+
q = len(_queued_inputs)
|
|
547
|
+
proc_str = " ⋯" if _proc["active"] else ""
|
|
548
|
+
q_str = f" [{q}↑]" if q > 0 else ""
|
|
549
|
+
try:
|
|
550
|
+
raw = await prompt_session.prompt_async(
|
|
551
|
+
f"polymath{proc_str}{q_str} > "
|
|
552
|
+
)
|
|
553
|
+
_queued_inputs.append(raw.strip() if raw else "")
|
|
554
|
+
_queue_ready.set()
|
|
555
|
+
except (EOFError, KeyboardInterrupt):
|
|
556
|
+
_queued_inputs.append(None) # sentinel → exit
|
|
557
|
+
_queue_ready.set()
|
|
558
|
+
return
|
|
559
|
+
|
|
560
|
+
# ── patch_stdout: all console output renders ABOVE the prompt line ────────
|
|
561
|
+
with patch_stdout():
|
|
562
|
+
|
|
563
|
+
# ── Smart resume ────────────────────────────────────────────────────
|
|
564
|
+
if active_project:
|
|
565
|
+
last = get_last_session_for_project(active_project)
|
|
566
|
+
if last and last["id"] != session_id:
|
|
567
|
+
if was_recent(last, hours=24):
|
|
568
|
+
ago = time_ago(last)
|
|
569
|
+
console.print(
|
|
570
|
+
f'[yellow]Resume "[bold]{last["title"]}[/bold]" from {ago}?[/yellow] '
|
|
571
|
+
f'[dim](y / n / list)[/dim]'
|
|
572
|
+
)
|
|
573
|
+
try:
|
|
574
|
+
ans = (await prompt_session.prompt_async(" > ")).strip().lower()
|
|
575
|
+
if ans == "y":
|
|
576
|
+
session_id = last["id"]
|
|
577
|
+
console.print(f"[dim]Resumed: {last['title']}[/dim]\n")
|
|
578
|
+
elif ans == "list":
|
|
579
|
+
sid = await _pick_session_interactive(active_project, prompt_session)
|
|
580
|
+
if sid:
|
|
581
|
+
session_id = sid
|
|
582
|
+
except (EOFError, KeyboardInterrupt):
|
|
583
|
+
pass
|
|
584
|
+
else:
|
|
585
|
+
recent = get_recent_sessions(project=active_project, limit=5)
|
|
586
|
+
if len(recent) > 1:
|
|
587
|
+
console.print(f"[dim]{len(recent)} previous sessions — /sessions to browse[/dim]\n")
|
|
588
|
+
|
|
589
|
+
# ── First-run memory migration prompt ──────────────────────────────────
|
|
590
|
+
if active_project:
|
|
591
|
+
try:
|
|
592
|
+
from polymath.memory.migrate import estimate, migrate_project
|
|
593
|
+
from polymath.memory.store import MemoryStore
|
|
594
|
+
_store = MemoryStore(active_project)
|
|
595
|
+
if _store.stats()["total"] == 0:
|
|
596
|
+
_est = estimate(active_project)
|
|
597
|
+
if _est["chunks"] > 0:
|
|
598
|
+
console.print(
|
|
599
|
+
f"[yellow]Found {_est['files']} context files (~{_est['chunks']} chunks) "
|
|
600
|
+
f"not yet indexed for memory.[/yellow]"
|
|
601
|
+
)
|
|
602
|
+
console.print(
|
|
603
|
+
"[dim]Migrate to chunked memory now? "
|
|
604
|
+
"Free Gemini embedding tier (or OpenAI fallback). "
|
|
605
|
+
"[bold](y / n)[/bold][/dim]"
|
|
606
|
+
)
|
|
607
|
+
try:
|
|
608
|
+
_ans = (await prompt_session.prompt_async(" > ")).strip().lower()
|
|
609
|
+
if _ans == "y":
|
|
610
|
+
console.print("[dim]Indexing…[/dim]")
|
|
611
|
+
_result = await migrate_project(active_project, cfg)
|
|
612
|
+
if _result.error:
|
|
613
|
+
console.print(f"[red]Migration failed: {_result.error}[/red]")
|
|
614
|
+
console.print("[dim]You can retry later with /memory rebuild.[/dim]\n")
|
|
615
|
+
else:
|
|
616
|
+
console.print(
|
|
617
|
+
f"[green]✓ Indexed {_result.chunks_indexed} chunks "
|
|
618
|
+
f"({_result.embed_provider}/{_result.embed_model}).[/green]\n"
|
|
619
|
+
)
|
|
620
|
+
else:
|
|
621
|
+
console.print("[dim]Skipped. Run /memory rebuild anytime.[/dim]\n")
|
|
622
|
+
except (EOFError, KeyboardInterrupt):
|
|
623
|
+
pass
|
|
624
|
+
except Exception:
|
|
625
|
+
pass
|
|
626
|
+
|
|
627
|
+
# ── Start background input reader ──────────────────────────────────────
|
|
628
|
+
_input_task = _asyncio.ensure_future(_input_reader())
|
|
629
|
+
|
|
630
|
+
async def _inline_prompt(prompt: str) -> str:
|
|
631
|
+
nonlocal _input_task
|
|
632
|
+
_input_task.cancel()
|
|
633
|
+
try:
|
|
634
|
+
await _input_task
|
|
635
|
+
except _asyncio.CancelledError:
|
|
636
|
+
pass
|
|
637
|
+
except Exception:
|
|
638
|
+
pass
|
|
639
|
+
try:
|
|
640
|
+
return (await prompt_session.prompt_async(prompt)).strip()
|
|
641
|
+
finally:
|
|
642
|
+
_input_task = _asyncio.ensure_future(_input_reader())
|
|
643
|
+
|
|
644
|
+
async def _command_permission(request: PermissionRequest) -> bool:
|
|
645
|
+
if is_preapproved(request, local_project):
|
|
646
|
+
return True
|
|
647
|
+
console.print(f"\n[bold yellow]Permission needed:[/bold yellow] {request.action}")
|
|
648
|
+
answer = await _inline_prompt("permission > ")
|
|
649
|
+
return answer.strip().lower() in ("y", "yes")
|
|
650
|
+
|
|
651
|
+
while True:
|
|
652
|
+
project_state = resolve_project(active_project)
|
|
653
|
+
local_project = project_state.local_project
|
|
654
|
+
# Get next command — typed now OR queued while we were processing
|
|
655
|
+
while not _queued_inputs:
|
|
656
|
+
await _queue_ready.wait()
|
|
657
|
+
raw = _queued_inputs.popleft()
|
|
658
|
+
if not _queued_inputs:
|
|
659
|
+
_queue_ready.clear()
|
|
660
|
+
if raw is None:
|
|
661
|
+
console.print("\n[dim]Goodbye.[/dim]")
|
|
662
|
+
break
|
|
663
|
+
|
|
664
|
+
text = raw
|
|
665
|
+
if not text:
|
|
666
|
+
continue
|
|
667
|
+
|
|
668
|
+
cmd_result = await handle_command(CommandServiceContext(
|
|
669
|
+
console=console,
|
|
670
|
+
text=text,
|
|
671
|
+
session_id=session_id,
|
|
672
|
+
active_project=active_project,
|
|
673
|
+
profile=profile,
|
|
674
|
+
cfg=cfg,
|
|
675
|
+
registry=registry,
|
|
676
|
+
local_project=local_project,
|
|
677
|
+
project_state=project_state,
|
|
678
|
+
cmds=_cmds,
|
|
679
|
+
subs=_subs,
|
|
680
|
+
queued_inputs=_queued_inputs,
|
|
681
|
+
last_output=_last_result_output,
|
|
682
|
+
prompt_session=prompt_session,
|
|
683
|
+
on_permission=_command_permission,
|
|
684
|
+
print_projects=_print_projects,
|
|
685
|
+
print_context_files=_print_context_files,
|
|
686
|
+
print_commands=_print_commands,
|
|
687
|
+
print_subagents=_print_subagents,
|
|
688
|
+
pick_session_interactive=_pick_session_interactive,
|
|
689
|
+
show_models=show_models,
|
|
690
|
+
run_wizard=run_wizard,
|
|
691
|
+
))
|
|
692
|
+
if cmd_result.handled:
|
|
693
|
+
session_id = cmd_result.session_id or session_id
|
|
694
|
+
active_project = cmd_result.active_project
|
|
695
|
+
registry = cmd_result.registry or registry
|
|
696
|
+
local_project = cmd_result.local_project if cmd_result.local_project is not None else local_project
|
|
697
|
+
project_state = cmd_result.project_state or project_state
|
|
698
|
+
_cmds = cmd_result.cmds or _cmds
|
|
699
|
+
_subs = cmd_result.subs or _subs
|
|
700
|
+
if cmd_result.last_output is not None:
|
|
701
|
+
_last_result_output = cmd_result.last_output
|
|
702
|
+
if cmd_result.should_exit:
|
|
703
|
+
break
|
|
704
|
+
continue
|
|
705
|
+
|
|
706
|
+
# ── Parse flags & force-provider ──────────────────────────────────
|
|
707
|
+
explicit_ask = text.startswith("--ask ")
|
|
708
|
+
if explicit_ask:
|
|
709
|
+
text = text[6:].strip()
|
|
710
|
+
|
|
711
|
+
force_provider, text = _parse_force(text)
|
|
712
|
+
cheap, fast, verify, simplify, ensemble_flag, fanout_flag, text = _parse_flags(text)
|
|
713
|
+
|
|
714
|
+
if not text:
|
|
715
|
+
continue
|
|
716
|
+
|
|
717
|
+
# ── @context mention expansion ────────────────────────────────────
|
|
718
|
+
text, injected_ctx = _expand_context_mentions(text, active_project)
|
|
719
|
+
if injected_ctx:
|
|
720
|
+
console.print(f"[dim]@injected: {', '.join(injected_ctx)}[/dim]")
|
|
721
|
+
|
|
722
|
+
# ── Auto-name session from first message ───────────────────────────
|
|
723
|
+
session_info = get_session(session_id)
|
|
724
|
+
if session_info and session_info["title"].startswith("Session "):
|
|
725
|
+
auto_name_session(session_id, text)
|
|
726
|
+
|
|
727
|
+
# ── Classify + show processing indicator ──────────────────────────
|
|
728
|
+
task_type = classify_task(text)
|
|
729
|
+
complexity = classify_complexity(text)
|
|
730
|
+
decision = run_controller.decide(RunRequest(
|
|
731
|
+
text=text,
|
|
732
|
+
explicit_ask=explicit_ask,
|
|
733
|
+
task_type=task_type,
|
|
734
|
+
complexity=complexity,
|
|
735
|
+
verify=verify,
|
|
736
|
+
simplify=simplify,
|
|
737
|
+
profile=profile,
|
|
738
|
+
registry=registry,
|
|
739
|
+
ensemble=ensemble_flag or force_provider == "all",
|
|
740
|
+
fanout=fanout_flag,
|
|
741
|
+
force_provider=force_provider,
|
|
742
|
+
))
|
|
743
|
+
|
|
744
|
+
if decision.strategy == ExecutionStrategy.STATE:
|
|
745
|
+
console.print()
|
|
746
|
+
console.print(decision.local_response)
|
|
747
|
+
_last_result_output = decision.local_response
|
|
748
|
+
continue
|
|
749
|
+
|
|
750
|
+
if decision.strategy == ExecutionStrategy.ENSEMBLE:
|
|
751
|
+
await _run_ensemble_flow(
|
|
752
|
+
console=console,
|
|
753
|
+
text=text,
|
|
754
|
+
session_id=session_id,
|
|
755
|
+
active_project=active_project,
|
|
756
|
+
registry=registry,
|
|
757
|
+
cfg=cfg,
|
|
758
|
+
ensemble_warned=cfg.setdefault("_ensemble_warned", False),
|
|
759
|
+
)
|
|
760
|
+
cfg["_ensemble_warned"] = True
|
|
761
|
+
continue
|
|
762
|
+
|
|
763
|
+
if decision.strategy == ExecutionStrategy.FANOUT:
|
|
764
|
+
await _run_fanout_flow(
|
|
765
|
+
console=console,
|
|
766
|
+
text=text,
|
|
767
|
+
session_id=session_id,
|
|
768
|
+
active_project=active_project,
|
|
769
|
+
registry=registry,
|
|
770
|
+
cfg=cfg,
|
|
771
|
+
profile=profile,
|
|
772
|
+
)
|
|
773
|
+
continue
|
|
774
|
+
|
|
775
|
+
is_ask = decision.strategy == ExecutionStrategy.CHAT
|
|
776
|
+
|
|
777
|
+
# ── Select models ─────────────────────────────────────────────────
|
|
778
|
+
model_resolution = await resolve_models(
|
|
779
|
+
registry=registry,
|
|
780
|
+
cfg=cfg,
|
|
781
|
+
session_id=session_id,
|
|
782
|
+
user_input=text,
|
|
783
|
+
active_project=active_project,
|
|
784
|
+
task_type=task_type,
|
|
785
|
+
profile=profile,
|
|
786
|
+
force_provider=force_provider if force_provider != "all" else "",
|
|
787
|
+
cheap=cheap,
|
|
788
|
+
fast=fast,
|
|
789
|
+
is_ask=is_ask,
|
|
790
|
+
)
|
|
791
|
+
primary_info = model_resolution.primary_info
|
|
792
|
+
primary_adapter = model_resolution.primary_adapter
|
|
793
|
+
reviewer_info = model_resolution.reviewer_info
|
|
794
|
+
reviewer_adapter = model_resolution.reviewer_adapter
|
|
795
|
+
summarizer_info = model_resolution.summarizer_info
|
|
796
|
+
summarizer_adapter = model_resolution.summarizer_adapter
|
|
797
|
+
estimated_prompt_tokens = model_resolution.estimated_prompt_tokens
|
|
798
|
+
if not primary_info or not primary_adapter:
|
|
799
|
+
console.print("[red]No models available. Run /setup to configure.[/red]")
|
|
800
|
+
continue
|
|
801
|
+
|
|
802
|
+
available_count = len([m for m in registry if m.available])
|
|
803
|
+
if not reviewer_info and not is_ask:
|
|
804
|
+
# Single model available — use it for review too, just warn the user
|
|
805
|
+
reviewer_info = primary_info
|
|
806
|
+
console.print(
|
|
807
|
+
f"[dim]only 1 model available — "
|
|
808
|
+
f"[bold]{primary_info.display_name}[/bold] will self-review. "
|
|
809
|
+
f"Add more models via /setup for cross-model review.[/dim]"
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
# Show which context files will be injected
|
|
813
|
+
if active_project:
|
|
814
|
+
ctx_files = TASK_CONTEXT_MAP.get(task_type, set())
|
|
815
|
+
if ctx_files:
|
|
816
|
+
console.print(f"[dim]context: {', '.join(sorted(ctx_files))}[/dim]")
|
|
817
|
+
|
|
818
|
+
current_primary = {"info": primary_info}
|
|
819
|
+
run_status = RunStatus(
|
|
820
|
+
phase="direct answer" if is_ask else f"{task_type.value} · {complexity.value}",
|
|
821
|
+
model=primary_info.display_name,
|
|
822
|
+
reviewer=reviewer_info.display_name if reviewer_info and reviewer_info.id != primary_info.id else "",
|
|
823
|
+
queue_count=len([q for q in _queued_inputs if q]),
|
|
824
|
+
context_line=(
|
|
825
|
+
f"primary context: ~{max(primary_info.context_window - estimated_prompt_tokens, 0)} "
|
|
826
|
+
f"tokens left / {primary_info.context_window}"
|
|
827
|
+
),
|
|
828
|
+
note="[dim]starting run[/dim]",
|
|
829
|
+
)
|
|
830
|
+
last_status_signature = {"value": ""}
|
|
831
|
+
|
|
832
|
+
def render_status(force: bool = False) -> None:
|
|
833
|
+
signature = "|".join((
|
|
834
|
+
run_status.phase,
|
|
835
|
+
run_status.model,
|
|
836
|
+
run_status.reviewer,
|
|
837
|
+
str(run_status.queue_count),
|
|
838
|
+
run_status.context_line,
|
|
839
|
+
run_status.note,
|
|
840
|
+
))
|
|
841
|
+
if not force and signature == last_status_signature["value"]:
|
|
842
|
+
return
|
|
843
|
+
last_status_signature["value"] = signature
|
|
844
|
+
console.print(render_run_status(run_status))
|
|
845
|
+
|
|
846
|
+
# ── UI callbacks ──────────────────────────────────────────────────
|
|
847
|
+
def on_step(step, content):
|
|
848
|
+
from polymath.pipeline import Step
|
|
849
|
+
info = current_primary["info"]
|
|
850
|
+
run_status.model = info.display_name
|
|
851
|
+
run_status.queue_count = len([q for q in _queued_inputs if q])
|
|
852
|
+
if step == Step.UNDERSTAND:
|
|
853
|
+
run_status.phase = "understand"
|
|
854
|
+
run_status.note = "[dim]building task understanding[/dim]"
|
|
855
|
+
elif step == Step.PLAN:
|
|
856
|
+
run_status.phase = "planning"
|
|
857
|
+
run_status.note = "[dim]drafting execution plan[/dim]"
|
|
858
|
+
elif step in (Step.CLARIFY_1, Step.CLARIFY_2):
|
|
859
|
+
run_status.phase = "clarifying"
|
|
860
|
+
run_status.note = "[dim]checking for missing information[/dim]"
|
|
861
|
+
elif step == Step.REASON:
|
|
862
|
+
run_status.phase = "reasoning"
|
|
863
|
+
run_status.note = "[dim]thinking through the next move[/dim]"
|
|
864
|
+
elif step == Step.ACT:
|
|
865
|
+
run_status.phase = "tool use"
|
|
866
|
+
run_status.note = f"[magenta]tool:[/magenta] {content}"
|
|
867
|
+
elif step == Step.OBSERVE:
|
|
868
|
+
run_status.phase = "tool observe"
|
|
869
|
+
run_status.note = "[dim]received tool output[/dim]"
|
|
870
|
+
elif step == Step.EXECUTE:
|
|
871
|
+
run_status.phase = "answering"
|
|
872
|
+
run_status.note = "[dim]streaming response[/dim]"
|
|
873
|
+
elif step == Step.REVIEW or step == Step.RE_REVIEW:
|
|
874
|
+
run_status.phase = "review"
|
|
875
|
+
if reviewer_info:
|
|
876
|
+
run_status.reviewer = reviewer_info.display_name
|
|
877
|
+
run_status.note = "[dim]cross-checking with reviewer model[/dim]"
|
|
878
|
+
elif step == Step.CORRECTIONS:
|
|
879
|
+
run_status.phase = "corrections"
|
|
880
|
+
run_status.note = "[dim]applying reviewer feedback[/dim]"
|
|
881
|
+
elif step == Step.OUTPUT:
|
|
882
|
+
run_status.phase = "complete"
|
|
883
|
+
run_status.note = "[dim]finalizing output[/dim]"
|
|
884
|
+
render_status()
|
|
885
|
+
|
|
886
|
+
def on_token(token):
|
|
887
|
+
console.print(token, end="", highlight=False)
|
|
888
|
+
|
|
889
|
+
async def on_ask(question):
|
|
890
|
+
console.print(f"\n[bold yellow]?[/bold yellow] {question}")
|
|
891
|
+
return await _inline_prompt("clarify > ")
|
|
892
|
+
|
|
893
|
+
async def on_permission(request: PermissionRequest) -> bool:
|
|
894
|
+
if is_preapproved(request, local_project):
|
|
895
|
+
return True
|
|
896
|
+
console.print(f"\n[bold yellow]Permission needed:[/bold yellow] {request.action}")
|
|
897
|
+
answer = await _inline_prompt("permission > ")
|
|
898
|
+
return answer.strip().lower() in ("y", "yes")
|
|
899
|
+
|
|
900
|
+
async def on_plan_approval(plan: str) -> str:
|
|
901
|
+
console.print()
|
|
902
|
+
console.print(Panel(
|
|
903
|
+
Markdown(plan),
|
|
904
|
+
title="[bold cyan]Plan — approve to proceed[/bold cyan]",
|
|
905
|
+
border_style="cyan",
|
|
906
|
+
))
|
|
907
|
+
console.print(
|
|
908
|
+
"[dim]y / proceed = execute | "
|
|
909
|
+
"refine: <feedback> = revise plan | "
|
|
910
|
+
"cancel = abort[/dim]"
|
|
911
|
+
)
|
|
912
|
+
answer = await _inline_prompt("plan > ")
|
|
913
|
+
if not answer or answer.lower() in ("y", "yes", "proceed"):
|
|
914
|
+
return "proceed"
|
|
915
|
+
if answer.lower() == "cancel":
|
|
916
|
+
return "cancel"
|
|
917
|
+
if answer.lower().startswith("refine:"):
|
|
918
|
+
return answer
|
|
919
|
+
return "proceed"
|
|
920
|
+
|
|
921
|
+
_proc["active"] = True
|
|
922
|
+
try:
|
|
923
|
+
render_status(force=True)
|
|
924
|
+
if is_ask:
|
|
925
|
+
exec_result = await run_ask_flow(
|
|
926
|
+
text=text,
|
|
927
|
+
session_id=session_id,
|
|
928
|
+
active_project=active_project,
|
|
929
|
+
task_type=task_type,
|
|
930
|
+
primary_info=primary_info,
|
|
931
|
+
primary_adapter=primary_adapter,
|
|
932
|
+
summarizer_info=summarizer_info,
|
|
933
|
+
summarizer_adapter=summarizer_adapter,
|
|
934
|
+
profile=profile,
|
|
935
|
+
cfg=cfg,
|
|
936
|
+
registry=registry,
|
|
937
|
+
print_line=lambda msg: (
|
|
938
|
+
setattr(run_status, "note", msg.strip()),
|
|
939
|
+
setattr(run_status, "queue_count", len([q for q in _queued_inputs if q])),
|
|
940
|
+
render_status(),
|
|
941
|
+
),
|
|
942
|
+
suppress_echo=_SuppressEcho,
|
|
943
|
+
token_printer=lambda t: console.print(t, end="", highlight=False),
|
|
944
|
+
on_model_switch=lambda info: (
|
|
945
|
+
current_primary.__setitem__("info", info),
|
|
946
|
+
setattr(run_status, "model", info.display_name),
|
|
947
|
+
setattr(run_status, "note", f"[dim]switched to {info.display_name}[/dim]"),
|
|
948
|
+
render_status(),
|
|
949
|
+
),
|
|
950
|
+
attempt_ledger=decision.attempt_ledger,
|
|
951
|
+
output_policy=output_policy,
|
|
952
|
+
)
|
|
953
|
+
console.print()
|
|
954
|
+
if exec_result.usage:
|
|
955
|
+
_print_usage_bar(exec_result.usage)
|
|
956
|
+
console.print(render_completion_summary(CompletionSummary(
|
|
957
|
+
session_id=session_id,
|
|
958
|
+
primary_model=current_primary["info"].display_name,
|
|
959
|
+
token_line="direct answer finished",
|
|
960
|
+
queue_count=len([q for q in _queued_inputs if q]),
|
|
961
|
+
)))
|
|
962
|
+
continue
|
|
963
|
+
|
|
964
|
+
exec_result = await run_pipeline_flow(
|
|
965
|
+
text=text,
|
|
966
|
+
complexity=complexity,
|
|
967
|
+
task_type=task_type,
|
|
968
|
+
verify=verify,
|
|
969
|
+
simplify=simplify,
|
|
970
|
+
session_id=session_id,
|
|
971
|
+
active_project=active_project,
|
|
972
|
+
primary_info=primary_info,
|
|
973
|
+
primary_adapter=primary_adapter,
|
|
974
|
+
reviewer_info=reviewer_info,
|
|
975
|
+
reviewer_adapter=reviewer_adapter,
|
|
976
|
+
summarizer_info=summarizer_info,
|
|
977
|
+
summarizer_adapter=summarizer_adapter,
|
|
978
|
+
profile=profile,
|
|
979
|
+
cfg=cfg,
|
|
980
|
+
registry=registry,
|
|
981
|
+
subs=_subs,
|
|
982
|
+
callbacks=ExecutionCallbacks(
|
|
983
|
+
on_step=on_step,
|
|
984
|
+
on_token=on_token,
|
|
985
|
+
on_ask=on_ask,
|
|
986
|
+
on_permission=on_permission,
|
|
987
|
+
on_plan_approval=on_plan_approval,
|
|
988
|
+
on_model_switch=lambda info: (
|
|
989
|
+
current_primary.__setitem__("info", info),
|
|
990
|
+
setattr(run_status, "model", info.display_name),
|
|
991
|
+
setattr(run_status, "note", f"[dim]switched to {info.display_name}[/dim]"),
|
|
992
|
+
render_status(),
|
|
993
|
+
),
|
|
994
|
+
),
|
|
995
|
+
print_line=lambda msg: (
|
|
996
|
+
setattr(run_status, "note", msg.strip()),
|
|
997
|
+
setattr(run_status, "queue_count", len([q for q in _queued_inputs if q])),
|
|
998
|
+
render_status(),
|
|
999
|
+
),
|
|
1000
|
+
render_panel=lambda body, title: console.print(Panel(Markdown(body), title=f"[bold]{title}[/bold]", border_style="dim")),
|
|
1001
|
+
suppress_echo=_SuppressEcho,
|
|
1002
|
+
session_cost=session_cost,
|
|
1003
|
+
attempt_ledger=decision.attempt_ledger,
|
|
1004
|
+
output_policy=output_policy,
|
|
1005
|
+
)
|
|
1006
|
+
_last_result_output = exec_result.last_output or _last_result_output
|
|
1007
|
+
if exec_result.usage:
|
|
1008
|
+
_print_usage_bar(exec_result.usage)
|
|
1009
|
+
if exec_result.summary_line:
|
|
1010
|
+
console.print(exec_result.summary_line)
|
|
1011
|
+
console.print(render_completion_summary(CompletionSummary(
|
|
1012
|
+
session_id=session_id,
|
|
1013
|
+
primary_model=current_primary["info"].display_name,
|
|
1014
|
+
reviewer_model=reviewer_info.display_name if reviewer_info and reviewer_info.id != current_primary["info"].id else "",
|
|
1015
|
+
token_line=exec_result.summary_line.replace("[dim]", "").replace("[/dim]", "") if exec_result.summary_line else "",
|
|
1016
|
+
queue_count=len([q for q in _queued_inputs if q]),
|
|
1017
|
+
)))
|
|
1018
|
+
if exec_result.notify_message:
|
|
1019
|
+
_notify("polymath", exec_result.notify_message)
|
|
1020
|
+
except Exception as e:
|
|
1021
|
+
console.print(f"\n[red]Error: {e}[/red]")
|
|
1022
|
+
if os.environ.get("POLYMATH_DEBUG"):
|
|
1023
|
+
import traceback
|
|
1024
|
+
traceback.print_exc()
|
|
1025
|
+
else:
|
|
1026
|
+
console.print("[dim]Set POLYMATH_DEBUG=1 for a traceback.[/dim]")
|
|
1027
|
+
finally:
|
|
1028
|
+
_proc["active"] = False
|
|
1029
|
+
continue
|
|
1030
|
+
|
|
1031
|
+
# ── Clean up input reader on exit ──────────────────────────────────────
|
|
1032
|
+
_input_task.cancel()
|
|
1033
|
+
try:
|
|
1034
|
+
await _input_task
|
|
1035
|
+
except (_asyncio.CancelledError, Exception):
|
|
1036
|
+
pass
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
async def _pick_session_interactive(project: str, prompt_session) -> str:
|
|
1040
|
+
"""Show numbered session list and return chosen session_id, or '' to skip."""
|
|
1041
|
+
sessions = get_recent_sessions(project=project, limit=10)
|
|
1042
|
+
if not sessions:
|
|
1043
|
+
console.print("[dim]No previous sessions.[/dim]")
|
|
1044
|
+
return ""
|
|
1045
|
+
|
|
1046
|
+
from rich.table import Table
|
|
1047
|
+
from rich import box as rbox
|
|
1048
|
+
table = Table(box=rbox.SIMPLE, show_header=True)
|
|
1049
|
+
table.add_column("#", style="dim", width=3)
|
|
1050
|
+
table.add_column("Session name", style="cyan")
|
|
1051
|
+
table.add_column("Last active")
|
|
1052
|
+
for i, s in enumerate(sessions, 1):
|
|
1053
|
+
table.add_row(str(i), s["title"], time_ago(s))
|
|
1054
|
+
console.print(table)
|
|
1055
|
+
|
|
1056
|
+
try:
|
|
1057
|
+
ans = (await prompt_session.prompt_async(" Resume # (or Enter to skip) > ")).strip()
|
|
1058
|
+
if ans.isdigit():
|
|
1059
|
+
idx = int(ans) - 1
|
|
1060
|
+
if 0 <= idx < len(sessions):
|
|
1061
|
+
chosen = sessions[idx]
|
|
1062
|
+
console.print(f"[dim]Resumed: {chosen['title']}[/dim]\n")
|
|
1063
|
+
return chosen["id"]
|
|
1064
|
+
except (EOFError, KeyboardInterrupt):
|
|
1065
|
+
pass
|
|
1066
|
+
return ""
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
async def run_ask(question: str, project: str, cfg: dict) -> None:
|
|
1070
|
+
"""One-shot ask from CLI — no REPL, no pipeline."""
|
|
1071
|
+
registry = detect()
|
|
1072
|
+
profile = cfg.get("priority_profile", "quality-first")
|
|
1073
|
+
output_policy = OutputPolicy()
|
|
1074
|
+
run_controller = RunController(output_policy=output_policy)
|
|
1075
|
+
|
|
1076
|
+
task_type = classify_task(question)
|
|
1077
|
+
complexity = classify_complexity(question)
|
|
1078
|
+
decision = run_controller.decide(RunRequest(
|
|
1079
|
+
text=question,
|
|
1080
|
+
explicit_ask=True,
|
|
1081
|
+
task_type=task_type,
|
|
1082
|
+
complexity=complexity,
|
|
1083
|
+
verify=False,
|
|
1084
|
+
simplify=False,
|
|
1085
|
+
profile=profile,
|
|
1086
|
+
registry=registry,
|
|
1087
|
+
))
|
|
1088
|
+
if decision.strategy == ExecutionStrategy.STATE:
|
|
1089
|
+
console.print(decision.local_response)
|
|
1090
|
+
return
|
|
1091
|
+
model_resolution = await resolve_models(
|
|
1092
|
+
registry=registry,
|
|
1093
|
+
cfg=cfg,
|
|
1094
|
+
session_id="",
|
|
1095
|
+
user_input=question,
|
|
1096
|
+
active_project=project,
|
|
1097
|
+
task_type=task_type,
|
|
1098
|
+
profile=profile,
|
|
1099
|
+
is_ask=True,
|
|
1100
|
+
)
|
|
1101
|
+
primary_info = model_resolution.primary_info
|
|
1102
|
+
primary_adapter = model_resolution.primary_adapter
|
|
1103
|
+
summarizer_info = model_resolution.summarizer_info
|
|
1104
|
+
summarizer_adapter = model_resolution.summarizer_adapter
|
|
1105
|
+
estimated_prompt_tokens = model_resolution.estimated_prompt_tokens
|
|
1106
|
+
if not primary_info or not primary_adapter:
|
|
1107
|
+
console.print("[red]No models available. Run polymath --setup[/red]")
|
|
1108
|
+
return
|
|
1109
|
+
available_count = len([m for m in registry if m.available])
|
|
1110
|
+
if available_count == 1:
|
|
1111
|
+
console.print(
|
|
1112
|
+
f"[dim]only 1 model available — {primary_info.display_name}[/dim]"
|
|
1113
|
+
)
|
|
1114
|
+
|
|
1115
|
+
# Use last session for project, or create a fresh one
|
|
1116
|
+
if project:
|
|
1117
|
+
last = get_last_session_for_project(project)
|
|
1118
|
+
session_id = last["id"] if last else new_session_for_project(project)
|
|
1119
|
+
else:
|
|
1120
|
+
session_id = new_session()
|
|
1121
|
+
|
|
1122
|
+
session_info = get_session(session_id)
|
|
1123
|
+
if session_info:
|
|
1124
|
+
console.print(f"[dim]session: {session_info['title']}[/dim]")
|
|
1125
|
+
|
|
1126
|
+
color = MODEL_COLORS.get(primary_info.provider, "white")
|
|
1127
|
+
console.print(f"[bold {color}]{primary_info.display_name}[/bold {color}]", end=" ")
|
|
1128
|
+
console.print(
|
|
1129
|
+
f"\n[dim]primary context: ~{max(primary_info.context_window - estimated_prompt_tokens, 0)} "
|
|
1130
|
+
f"tokens left / {primary_info.context_window}[/dim]"
|
|
1131
|
+
)
|
|
1132
|
+
|
|
1133
|
+
exec_result = await run_ask_flow(
|
|
1134
|
+
text=question,
|
|
1135
|
+
session_id=session_id,
|
|
1136
|
+
active_project=project,
|
|
1137
|
+
task_type=task_type,
|
|
1138
|
+
primary_info=primary_info,
|
|
1139
|
+
primary_adapter=primary_adapter,
|
|
1140
|
+
summarizer_info=summarizer_info,
|
|
1141
|
+
summarizer_adapter=summarizer_adapter,
|
|
1142
|
+
profile=profile,
|
|
1143
|
+
cfg=cfg,
|
|
1144
|
+
registry=registry,
|
|
1145
|
+
print_line=lambda msg: console.print(msg),
|
|
1146
|
+
suppress_echo=_SuppressEcho,
|
|
1147
|
+
token_printer=lambda t: console.print(t, end="", highlight=False),
|
|
1148
|
+
attempt_ledger=decision.attempt_ledger,
|
|
1149
|
+
output_policy=output_policy,
|
|
1150
|
+
)
|
|
1151
|
+
console.print()
|
|
1152
|
+
if exec_result.usage:
|
|
1153
|
+
_print_usage_bar(exec_result.usage)
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _print_projects() -> None:
|
|
1157
|
+
projects = list_projects()
|
|
1158
|
+
if not projects:
|
|
1159
|
+
console.print("[dim]No projects yet. Use /project new <name>[/dim]")
|
|
1160
|
+
return
|
|
1161
|
+
from rich.table import Table
|
|
1162
|
+
from rich import box as rbox
|
|
1163
|
+
table = Table(box=rbox.SIMPLE, show_header=True)
|
|
1164
|
+
table.add_column("Name", style="cyan")
|
|
1165
|
+
table.add_column("Description")
|
|
1166
|
+
table.add_column("Created")
|
|
1167
|
+
for p in projects:
|
|
1168
|
+
table.add_row(p["name"], p.get("description", ""), p.get("created_at", "")[:10])
|
|
1169
|
+
console.print(table)
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _print_context_files(project: str) -> None:
|
|
1173
|
+
files = list_context_files(project)
|
|
1174
|
+
from rich.table import Table
|
|
1175
|
+
from rich import box as rbox
|
|
1176
|
+
table = Table(title=f"Context — {project}", box=rbox.SIMPLE)
|
|
1177
|
+
table.add_column("File", style="cyan")
|
|
1178
|
+
table.add_column("Description", style="dim")
|
|
1179
|
+
table.add_column("Size", justify="right")
|
|
1180
|
+
table.add_column("Preview")
|
|
1181
|
+
for f in files:
|
|
1182
|
+
size = f"[green]{f['size']}b[/green]" if f["size"] else "[dim]empty[/dim]"
|
|
1183
|
+
table.add_row(f["name"], f["description"], size, f["preview"])
|
|
1184
|
+
console.print(table)
|
|
1185
|
+
console.print("[dim]/context show <name> • /context add <name> <entry>[/dim]")
|
|
1186
|
+
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _print_commands(cmds: dict) -> None:
|
|
1190
|
+
from rich.table import Table
|
|
1191
|
+
from rich import box as rbox
|
|
1192
|
+
table = Table(box=rbox.SIMPLE, show_header=True, title="Slash Commands")
|
|
1193
|
+
table.add_column("Command", style="cyan")
|
|
1194
|
+
table.add_column("Description")
|
|
1195
|
+
table.add_column("Source", style="dim")
|
|
1196
|
+
for name, cmd in sorted(cmds.items()):
|
|
1197
|
+
table.add_row(f"/{name}", cmd.description, cmd.source)
|
|
1198
|
+
console.print(table)
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def _print_subagents(subs: dict) -> None:
|
|
1202
|
+
from rich.table import Table
|
|
1203
|
+
from rich import box as rbox
|
|
1204
|
+
table = Table(box=rbox.SIMPLE, show_header=True, title="Subagents")
|
|
1205
|
+
table.add_column("Name", style="cyan")
|
|
1206
|
+
table.add_column("Description")
|
|
1207
|
+
table.add_column("Trigger", style="dim")
|
|
1208
|
+
table.add_column("Source", style="dim")
|
|
1209
|
+
for name, sa in sorted(subs.items()):
|
|
1210
|
+
triggers = ", ".join(t.value for t in sa.trigger_on) if sa.trigger_on else "explicit only"
|
|
1211
|
+
table.add_row(name, sa.description, triggers, sa.source)
|
|
1212
|
+
console.print(table)
|
|
1213
|
+
console.print("[dim]Use --verify, --simplify, etc. to trigger. Run /subagents for full list.[/dim]")
|
|
1214
|
+
|
|
1215
|
+
|
|
1216
|
+
def main() -> None:
|
|
1217
|
+
import argparse
|
|
1218
|
+
parser = argparse.ArgumentParser(prog="polymath", description="Multi-model AI orchestrator")
|
|
1219
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
1220
|
+
|
|
1221
|
+
# polymath ask "question"
|
|
1222
|
+
ask_parser = subparsers.add_parser("ask", help="One-shot question, no REPL")
|
|
1223
|
+
ask_parser.add_argument("question", nargs="+", help="The question to ask")
|
|
1224
|
+
ask_parser.add_argument("--project", metavar="NAME", help="Detached local project context to use")
|
|
1225
|
+
|
|
1226
|
+
parser.add_argument("--setup", action="store_true", help="Re-run setup wizard")
|
|
1227
|
+
parser.add_argument("--models", action="store_true", help="List available models")
|
|
1228
|
+
parser.add_argument("--sessions", action="store_true", help="List sessions")
|
|
1229
|
+
parser.add_argument("--session", metavar="ID", help="Resume session by ID")
|
|
1230
|
+
parser.add_argument("--project", metavar="NAME", help="Start with detached local project")
|
|
1231
|
+
args = parser.parse_args()
|
|
1232
|
+
|
|
1233
|
+
init_db()
|
|
1234
|
+
cfg = load_config()
|
|
1235
|
+
repo_project_state = resolve_project("")
|
|
1236
|
+
|
|
1237
|
+
# ── polymath ask "question" ────────────────────────────────────────────
|
|
1238
|
+
if args.command == "ask":
|
|
1239
|
+
question = " ".join(args.question)
|
|
1240
|
+
project = args.project or ""
|
|
1241
|
+
if project and not get_project_meta(project):
|
|
1242
|
+
console.print(f"[red]Detached local project not found: {project}[/red]")
|
|
1243
|
+
console.print("[dim]Use /project new <name> in the REPL, or omit --project to use repo-backed .polymath/ context.[/dim]")
|
|
1244
|
+
return
|
|
1245
|
+
if not project and repo_project_state.shared and repo_project_state.name:
|
|
1246
|
+
project = repo_project_state.name
|
|
1247
|
+
asyncio.run(run_ask(question, project, cfg))
|
|
1248
|
+
return
|
|
1249
|
+
|
|
1250
|
+
# ── Non-interactive flags ──────────────────────────────────────────────
|
|
1251
|
+
if args.models:
|
|
1252
|
+
show_models()
|
|
1253
|
+
return
|
|
1254
|
+
|
|
1255
|
+
if args.sessions:
|
|
1256
|
+
sessions = list_sessions()
|
|
1257
|
+
if not sessions:
|
|
1258
|
+
console.print("[dim]No sessions yet.[/dim]")
|
|
1259
|
+
else:
|
|
1260
|
+
from rich.table import Table
|
|
1261
|
+
from rich import box as rbox
|
|
1262
|
+
table = Table(box=rbox.SIMPLE, show_header=True)
|
|
1263
|
+
table.add_column("ID", style="cyan")
|
|
1264
|
+
table.add_column("Title")
|
|
1265
|
+
table.add_column("Last active")
|
|
1266
|
+
for s in sessions:
|
|
1267
|
+
table.add_row(s["id"], s["title"], time_ago(s))
|
|
1268
|
+
console.print(table)
|
|
1269
|
+
return
|
|
1270
|
+
|
|
1271
|
+
if args.setup or not cfg.get("_setup_done"):
|
|
1272
|
+
cfg = run_wizard(force=args.setup)
|
|
1273
|
+
|
|
1274
|
+
project = args.project or ""
|
|
1275
|
+
if project and not get_project_meta(project):
|
|
1276
|
+
console.print(f"[red]Detached local project not found: {project}[/red]")
|
|
1277
|
+
console.print("[dim]Use /project new <name> in the REPL, or run without --project to use repo-backed .polymath/ context.[/dim]")
|
|
1278
|
+
return
|
|
1279
|
+
if not project and args.session:
|
|
1280
|
+
session = get_session(args.session)
|
|
1281
|
+
if session and (session.get("project") or "").strip():
|
|
1282
|
+
project = (session.get("project") or "").strip()
|
|
1283
|
+
if not project and repo_project_state.shared and repo_project_state.name:
|
|
1284
|
+
project = repo_project_state.name
|
|
1285
|
+
if project:
|
|
1286
|
+
sid = args.session or new_session_for_project(project)
|
|
1287
|
+
else:
|
|
1288
|
+
sid = args.session or new_session()
|
|
1289
|
+
asyncio.run(run_session(sid, cfg, active_project=project))
|
|
1290
|
+
|
|
1291
|
+
|
|
1292
|
+
if __name__ == "__main__":
|
|
1293
|
+
main()
|