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.
Files changed (54) hide show
  1. polymath/__init__.py +2 -0
  2. polymath/adapters/__init__.py +7 -0
  3. polymath/adapters/base.py +175 -0
  4. polymath/adapters/claude.py +280 -0
  5. polymath/adapters/gemini.py +186 -0
  6. polymath/adapters/ollama.py +117 -0
  7. polymath/adapters/openai_adapter.py +168 -0
  8. polymath/bootstrap.py +159 -0
  9. polymath/command_registry.py +41 -0
  10. polymath/command_service.py +572 -0
  11. polymath/compressor.py +90 -0
  12. polymath/config.py +293 -0
  13. polymath/context_manager.py +76 -0
  14. polymath/context_store.py +336 -0
  15. polymath/detector.py +442 -0
  16. polymath/domain.py +78 -0
  17. polymath/execution_service.py +325 -0
  18. polymath/main.py +1293 -0
  19. polymath/memory/__init__.py +15 -0
  20. polymath/memory/chunker.py +6 -0
  21. polymath/memory/embedder.py +179 -0
  22. polymath/memory/migrate.py +2 -0
  23. polymath/memory/retriever.py +2 -0
  24. polymath/memory/store.py +9 -0
  25. polymath/memory/sync.py +2 -0
  26. polymath/memory/writer.py +9 -0
  27. polymath/model_policy.py +172 -0
  28. polymath/orchestrator/__init__.py +68 -0
  29. polymath/orchestrator/attempt_ledger.py +34 -0
  30. polymath/orchestrator/ensemble.py +229 -0
  31. polymath/orchestrator/fanout.py +322 -0
  32. polymath/orchestrator/output_policy.py +61 -0
  33. polymath/orchestrator/race.py +311 -0
  34. polymath/orchestrator/run_controller.py +91 -0
  35. polymath/orchestrator/speculative_review.py +120 -0
  36. polymath/orchestrator/state_responder.py +184 -0
  37. polymath/orchestrator/worker_pool.py +37 -0
  38. polymath/permissions.py +82 -0
  39. polymath/pipeline.py +700 -0
  40. polymath/project_config.py +229 -0
  41. polymath/project_runtime.py +109 -0
  42. polymath/router.py +127 -0
  43. polymath/setup_wizard.py +106 -0
  44. polymath/slash_commands.py +566 -0
  45. polymath/subagents.py +486 -0
  46. polymath/tools.py +333 -0
  47. polymath/ui_state.py +84 -0
  48. polymath/workspace.py +66 -0
  49. polymath_agent-0.4.0.dist-info/METADATA +693 -0
  50. polymath_agent-0.4.0.dist-info/RECORD +54 -0
  51. polymath_agent-0.4.0.dist-info/WHEEL +5 -0
  52. polymath_agent-0.4.0.dist-info/entry_points.txt +2 -0
  53. polymath_agent-0.4.0.dist-info/licenses/LICENSE +21 -0
  54. 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()