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
|
@@ -0,0 +1,572 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Awaitable, Callable
|
|
5
|
+
|
|
6
|
+
from rich.markdown import Markdown
|
|
7
|
+
from rich.panel import Panel
|
|
8
|
+
|
|
9
|
+
from polymath.command_registry import build_repl_completer
|
|
10
|
+
from polymath.domain import PermissionRequest
|
|
11
|
+
from polymath.context_manager import append_context, get_project_meta, list_context_files, read_context, write_context
|
|
12
|
+
from polymath.context_store import (
|
|
13
|
+
delete_session,
|
|
14
|
+
get_last_session_for_project,
|
|
15
|
+
get_session,
|
|
16
|
+
new_session,
|
|
17
|
+
new_session_for_project,
|
|
18
|
+
set_session_project,
|
|
19
|
+
)
|
|
20
|
+
from polymath.detector import detect, invalidate_cache, list_gemini_accounts, switch_gemini_account
|
|
21
|
+
from polymath.model_policy import resolve_models
|
|
22
|
+
from polymath.project_runtime import project_provenance_lines, resolve_project
|
|
23
|
+
from polymath.slash_commands import get_all_commands, run_command as run_slash_cmd
|
|
24
|
+
from polymath.subagents import get_subagents
|
|
25
|
+
from polymath.ui_state import render_queue_table
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class CommandServiceContext:
|
|
30
|
+
console: Any
|
|
31
|
+
text: str
|
|
32
|
+
session_id: str
|
|
33
|
+
active_project: str
|
|
34
|
+
profile: str
|
|
35
|
+
cfg: dict
|
|
36
|
+
registry: list[Any]
|
|
37
|
+
local_project: Any
|
|
38
|
+
project_state: Any
|
|
39
|
+
cmds: dict[str, Any]
|
|
40
|
+
subs: dict[str, Any]
|
|
41
|
+
queued_inputs: Any
|
|
42
|
+
last_output: str
|
|
43
|
+
prompt_session: Any
|
|
44
|
+
on_permission: Callable[[PermissionRequest], Awaitable[bool] | bool]
|
|
45
|
+
print_projects: Callable[[], None]
|
|
46
|
+
print_context_files: Callable[[str], None]
|
|
47
|
+
print_commands: Callable[[dict[str, Any]], None]
|
|
48
|
+
print_subagents: Callable[[dict[str, Any]], None]
|
|
49
|
+
pick_session_interactive: Callable[[str, Any], Awaitable[str]]
|
|
50
|
+
show_models: Callable[[], None]
|
|
51
|
+
run_wizard: Callable[..., dict]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class CommandServiceResult:
|
|
56
|
+
handled: bool = False
|
|
57
|
+
should_exit: bool = False
|
|
58
|
+
session_id: str = ""
|
|
59
|
+
active_project: str = ""
|
|
60
|
+
registry: list[Any] | None = None
|
|
61
|
+
local_project: Any = None
|
|
62
|
+
project_state: Any = None
|
|
63
|
+
cmds: dict[str, Any] | None = None
|
|
64
|
+
subs: dict[str, Any] | None = None
|
|
65
|
+
last_output: str | None = None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def handle_command(ctx: CommandServiceContext) -> CommandServiceResult:
|
|
69
|
+
text = ctx.text
|
|
70
|
+
result = CommandServiceResult(
|
|
71
|
+
handled=True,
|
|
72
|
+
session_id=ctx.session_id,
|
|
73
|
+
active_project=ctx.active_project,
|
|
74
|
+
registry=ctx.registry,
|
|
75
|
+
local_project=ctx.local_project,
|
|
76
|
+
project_state=ctx.project_state,
|
|
77
|
+
cmds=ctx.cmds,
|
|
78
|
+
subs=ctx.subs,
|
|
79
|
+
last_output=ctx.last_output,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if text == "/exit":
|
|
83
|
+
ctx.console.print("[dim]Goodbye.[/dim]")
|
|
84
|
+
result.should_exit = True
|
|
85
|
+
return result
|
|
86
|
+
if text == "/models":
|
|
87
|
+
ctx.show_models()
|
|
88
|
+
return result
|
|
89
|
+
if text == "/sessions":
|
|
90
|
+
sid = await ctx.pick_session_interactive(ctx.active_project, ctx.prompt_session)
|
|
91
|
+
if sid:
|
|
92
|
+
session = get_session(sid)
|
|
93
|
+
if session:
|
|
94
|
+
result.session_id = sid
|
|
95
|
+
session_project = (session.get("project") or "").strip()
|
|
96
|
+
if session_project:
|
|
97
|
+
result.active_project = session_project
|
|
98
|
+
result.project_state = resolve_project(result.active_project)
|
|
99
|
+
result.local_project = result.project_state.local_project
|
|
100
|
+
result.cmds = get_all_commands(result.local_project.commands_dir if result.local_project else None)
|
|
101
|
+
result.subs = get_subagents(result.local_project.subagents_dir if result.local_project else None)
|
|
102
|
+
ctx.prompt_session.completer = build_repl_completer(list(result.cmds.keys()))
|
|
103
|
+
return result
|
|
104
|
+
if text == "/new":
|
|
105
|
+
result.session_id = new_session_for_project(ctx.active_project) if ctx.active_project else new_session()
|
|
106
|
+
ctx.console.print(f"[dim]New session: {result.session_id}[/dim]")
|
|
107
|
+
return result
|
|
108
|
+
if text.startswith("/session delete "):
|
|
109
|
+
sid = text[16:].strip()
|
|
110
|
+
if delete_session(sid):
|
|
111
|
+
ctx.console.print(f"[dim]Deleted session: {sid}[/dim]")
|
|
112
|
+
if result.session_id == sid:
|
|
113
|
+
result.session_id = new_session_for_project(ctx.active_project) if ctx.active_project else new_session()
|
|
114
|
+
ctx.console.print(f"[dim]Started new session: {result.session_id}[/dim]")
|
|
115
|
+
else:
|
|
116
|
+
ctx.console.print(f"[red]Session not found: {sid}[/red]")
|
|
117
|
+
return result
|
|
118
|
+
if text.startswith("/session "):
|
|
119
|
+
sid = text[9:].strip()
|
|
120
|
+
session = get_session(sid)
|
|
121
|
+
if session:
|
|
122
|
+
result.session_id = sid
|
|
123
|
+
session_project = (session.get("project") or "").strip()
|
|
124
|
+
if session_project:
|
|
125
|
+
result.active_project = session_project
|
|
126
|
+
result.project_state = resolve_project(result.active_project)
|
|
127
|
+
result.local_project = result.project_state.local_project
|
|
128
|
+
result.cmds = get_all_commands(result.local_project.commands_dir if result.local_project else None)
|
|
129
|
+
result.subs = get_subagents(result.local_project.subagents_dir if result.local_project else None)
|
|
130
|
+
ctx.prompt_session.completer = build_repl_completer(list(result.cmds.keys()))
|
|
131
|
+
ctx.console.print(f"[dim]Resumed session: {sid} — {session['title']}[/dim]")
|
|
132
|
+
else:
|
|
133
|
+
ctx.console.print(f"[red]Session not found: {sid}[/red]")
|
|
134
|
+
return result
|
|
135
|
+
if text == "/help":
|
|
136
|
+
import polymath.main as main_mod
|
|
137
|
+
ctx.console.print(Markdown(main_mod.__doc__))
|
|
138
|
+
return result
|
|
139
|
+
if text == "/state":
|
|
140
|
+
ctx.console.print(f"[dim]session:[/dim] [bold]{ctx.session_id}[/bold]")
|
|
141
|
+
ctx.console.print(f"[dim]project:[/dim] [bold]{ctx.project_state.name or '-'}[/bold] ({ctx.project_state.source})")
|
|
142
|
+
for line in project_provenance_lines(ctx.project_state):
|
|
143
|
+
ctx.console.print(f"[dim]{line}[/dim]")
|
|
144
|
+
if ctx.active_project:
|
|
145
|
+
ctx_files = list_context_files(ctx.active_project)
|
|
146
|
+
loaded = [c["name"] for c in ctx_files if c["size"] > 0]
|
|
147
|
+
ctx.console.print(f"[dim]context files:[/dim] {', '.join(loaded) if loaded else '(empty)'}")
|
|
148
|
+
cmd_sources = ", ".join(f"/{name}:{cmd.source}" for name, cmd in sorted(ctx.cmds.items()))
|
|
149
|
+
sub_sources = ", ".join(f"{name}:{cfg.source}" for name, cfg in sorted(ctx.subs.items()))
|
|
150
|
+
ctx.console.print(f"[dim]commands:[/dim] {cmd_sources if cmd_sources else '(none)'}")
|
|
151
|
+
ctx.console.print(f"[dim]subagents:[/dim] {sub_sources if sub_sources else '(none)'}")
|
|
152
|
+
if ctx.local_project:
|
|
153
|
+
allowed = ctx.local_project.settings.allowed_tools or []
|
|
154
|
+
ctx.console.print(f"[dim]allowed tools:[/dim] {', '.join(allowed) if allowed else '(prompt for all)'}")
|
|
155
|
+
return result
|
|
156
|
+
if text == "/queue":
|
|
157
|
+
queued_only = [q for q in ctx.queued_inputs if q]
|
|
158
|
+
if not queued_only:
|
|
159
|
+
ctx.console.print("[dim]Queue is empty.[/dim]")
|
|
160
|
+
else:
|
|
161
|
+
ctx.console.print(render_queue_table(queued_only))
|
|
162
|
+
return result
|
|
163
|
+
if text.startswith("/queue drop "):
|
|
164
|
+
idx_str = text[12:].strip()
|
|
165
|
+
queued_positions = [i for i, item in enumerate(ctx.queued_inputs) if item]
|
|
166
|
+
if idx_str.isdigit():
|
|
167
|
+
idx = int(idx_str) - 1
|
|
168
|
+
if 0 <= idx < len(queued_positions):
|
|
169
|
+
del ctx.queued_inputs[queued_positions[idx]]
|
|
170
|
+
ctx.console.print(f"[dim]Dropped queued command #{idx + 1}[/dim]")
|
|
171
|
+
else:
|
|
172
|
+
ctx.console.print("[red]Queue index out of range.[/red]")
|
|
173
|
+
else:
|
|
174
|
+
ctx.console.print("[dim]Usage: /queue drop <n>[/dim]")
|
|
175
|
+
return result
|
|
176
|
+
if text.startswith("/queue move "):
|
|
177
|
+
parts = text.split()
|
|
178
|
+
queued_positions = [i for i, item in enumerate(ctx.queued_inputs) if item]
|
|
179
|
+
if len(parts) == 4 and parts[2].isdigit() and parts[3].isdigit():
|
|
180
|
+
src = int(parts[2]) - 1
|
|
181
|
+
dst = int(parts[3]) - 1
|
|
182
|
+
if 0 <= src < len(queued_positions) and 0 <= dst < len(queued_positions):
|
|
183
|
+
queue_list = list(ctx.queued_inputs)
|
|
184
|
+
item = queue_list.pop(queued_positions[src])
|
|
185
|
+
queued_without_item = [i for i, value in enumerate(queue_list) if value]
|
|
186
|
+
insert_at = len(queue_list) if dst >= len(queued_without_item) else queued_without_item[dst]
|
|
187
|
+
queue_list.insert(insert_at, item)
|
|
188
|
+
ctx.queued_inputs.clear()
|
|
189
|
+
ctx.queued_inputs.extend(queue_list)
|
|
190
|
+
ctx.console.print(f"[dim]Moved queued command {src + 1} -> {dst + 1}[/dim]")
|
|
191
|
+
else:
|
|
192
|
+
ctx.console.print("[red]Queue indices out of range.[/red]")
|
|
193
|
+
else:
|
|
194
|
+
ctx.console.print("[dim]Usage: /queue move <from> <to>[/dim]")
|
|
195
|
+
return result
|
|
196
|
+
if text.startswith("/setup"):
|
|
197
|
+
ctx.run_wizard(force=True)
|
|
198
|
+
result.registry = detect()
|
|
199
|
+
return result
|
|
200
|
+
if text == "/project list":
|
|
201
|
+
if ctx.project_state.shared:
|
|
202
|
+
ctx.console.print("[dim]Repo-first mode: current git repo is the primary project.[/dim]")
|
|
203
|
+
ctx.print_projects()
|
|
204
|
+
return result
|
|
205
|
+
if text.startswith("/project new "):
|
|
206
|
+
pname = text[13:].strip()
|
|
207
|
+
if pname:
|
|
208
|
+
from polymath.context_manager import create_project
|
|
209
|
+
create_project(pname)
|
|
210
|
+
result.active_project = pname
|
|
211
|
+
result.session_id = new_session_for_project(pname)
|
|
212
|
+
result.project_state = resolve_project(result.active_project)
|
|
213
|
+
result.local_project = result.project_state.local_project
|
|
214
|
+
result.cmds = get_all_commands(result.local_project.commands_dir if result.local_project else None)
|
|
215
|
+
result.subs = get_subagents(result.local_project.subagents_dir if result.local_project else None)
|
|
216
|
+
ctx.prompt_session.completer = build_repl_completer(list(result.cmds.keys()))
|
|
217
|
+
ctx.console.print(f"[green]Created detached local project:[/green] {pname}")
|
|
218
|
+
ctx.console.print("[dim]Repo-first mode still prefers .polymath/ in the current git repo when present.[/dim]")
|
|
219
|
+
return result
|
|
220
|
+
if text.startswith("/project use "):
|
|
221
|
+
pname = text[13:].strip()
|
|
222
|
+
meta = get_project_meta(pname)
|
|
223
|
+
if meta:
|
|
224
|
+
result.active_project = pname
|
|
225
|
+
last = get_last_session_for_project(pname)
|
|
226
|
+
if last:
|
|
227
|
+
result.session_id = last["id"]
|
|
228
|
+
ctx.console.print(f"[dim]Switched to detached local project: [bold]{pname}[/bold] • resumed {last['title']}[/dim]")
|
|
229
|
+
else:
|
|
230
|
+
result.session_id = new_session_for_project(pname)
|
|
231
|
+
ctx.console.print(f"[dim]Switched to detached local project: [bold]{pname}[/bold] • started new session[/dim]")
|
|
232
|
+
result.project_state = resolve_project(result.active_project)
|
|
233
|
+
result.local_project = result.project_state.local_project
|
|
234
|
+
result.cmds = get_all_commands(result.local_project.commands_dir if result.local_project else None)
|
|
235
|
+
result.subs = get_subagents(result.local_project.subagents_dir if result.local_project else None)
|
|
236
|
+
ctx.prompt_session.completer = build_repl_completer(list(result.cmds.keys()))
|
|
237
|
+
else:
|
|
238
|
+
ctx.console.print(f"[red]Project not found: {pname}[/red]")
|
|
239
|
+
return result
|
|
240
|
+
if text == "/project":
|
|
241
|
+
if ctx.project_state.shared:
|
|
242
|
+
ctx.console.print(f"[dim]Repo project: [bold]{ctx.project_state.name}[/bold][/dim]")
|
|
243
|
+
for line in project_provenance_lines(ctx.project_state):
|
|
244
|
+
ctx.console.print(f"[dim]{line}[/dim]")
|
|
245
|
+
elif ctx.active_project:
|
|
246
|
+
ctx.console.print(f"[dim]Detached local project: [bold]{ctx.active_project}[/bold][/dim]")
|
|
247
|
+
ctx.print_context_files(ctx.active_project)
|
|
248
|
+
else:
|
|
249
|
+
ctx.console.print("[dim]No active project. Preferred flow: run /project init inside a git repo.[/dim]")
|
|
250
|
+
return result
|
|
251
|
+
if text == "/context":
|
|
252
|
+
if not ctx.active_project:
|
|
253
|
+
ctx.console.print("[red]No active project. Preferred flow: /project init in a repo.[/red]")
|
|
254
|
+
else:
|
|
255
|
+
ctx.print_context_files(ctx.active_project)
|
|
256
|
+
return result
|
|
257
|
+
if text.startswith("/context show "):
|
|
258
|
+
ctx_name = text[14:].strip()
|
|
259
|
+
if not ctx.active_project:
|
|
260
|
+
ctx.console.print("[red]No active project.[/red]")
|
|
261
|
+
else:
|
|
262
|
+
content = read_context(ctx.active_project, ctx_name)
|
|
263
|
+
ctx.console.print(Panel(content or "(empty)", title=f"[bold]{ctx_name}[/bold]"))
|
|
264
|
+
return result
|
|
265
|
+
if text.startswith("/context add "):
|
|
266
|
+
parts = text[13:].strip().split(" ", 1)
|
|
267
|
+
if len(parts) == 2 and ctx.active_project:
|
|
268
|
+
ctx_name, entry = parts
|
|
269
|
+
append_context(ctx.active_project, ctx_name, entry)
|
|
270
|
+
ctx.console.print(f"[green]Added to {ctx_name}[/green]")
|
|
271
|
+
await _sync_memory_quiet(ctx, ctx_name)
|
|
272
|
+
else:
|
|
273
|
+
ctx.console.print("[dim]Usage: /context add <type> <entry>[/dim]")
|
|
274
|
+
return result
|
|
275
|
+
if text.startswith("/context edit "):
|
|
276
|
+
ctx_name = text[14:].strip()
|
|
277
|
+
if not ctx.active_project:
|
|
278
|
+
ctx.console.print("[red]No active project.[/red]")
|
|
279
|
+
return result
|
|
280
|
+
from polymath.context_manager import context_dir
|
|
281
|
+
import os
|
|
282
|
+
import subprocess as sp
|
|
283
|
+
editor = os.environ.get("EDITOR", "")
|
|
284
|
+
ctx_path = context_dir(ctx.active_project) / f"{ctx_name}.md"
|
|
285
|
+
if editor:
|
|
286
|
+
sp.run([editor, str(ctx_path)])
|
|
287
|
+
ctx.console.print("[green]Saved.[/green]")
|
|
288
|
+
await _sync_memory_quiet(ctx, ctx_name)
|
|
289
|
+
else:
|
|
290
|
+
current = read_context(ctx.active_project, ctx_name)
|
|
291
|
+
ctx.console.print(Panel(current or "(empty)", title=f"[bold]{ctx_name}[/bold] — current content"))
|
|
292
|
+
ctx.console.print("[dim]Paste new content, then type ### on its own line to save.[/dim]")
|
|
293
|
+
lines = []
|
|
294
|
+
while True:
|
|
295
|
+
line = await ctx.prompt_session.prompt_async("")
|
|
296
|
+
if line.strip() == "###":
|
|
297
|
+
break
|
|
298
|
+
lines.append(line)
|
|
299
|
+
if lines:
|
|
300
|
+
write_context(ctx.active_project, ctx_name, "\n".join(lines) + "\n")
|
|
301
|
+
ctx.console.print(f"[green]Saved {ctx_name}.[/green]")
|
|
302
|
+
await _sync_memory_quiet(ctx, ctx_name)
|
|
303
|
+
return result
|
|
304
|
+
if text == "/project init":
|
|
305
|
+
from polymath.project_config import init_local_project
|
|
306
|
+
from polymath.slash_commands import get_all_commands
|
|
307
|
+
from polymath.subagents import get_subagents
|
|
308
|
+
lp = init_local_project()
|
|
309
|
+
ctx.console.print(f"[green]Created .polymath/ in {lp.root.parent}[/green]")
|
|
310
|
+
result.local_project = lp
|
|
311
|
+
result.active_project = lp.root.parent.name
|
|
312
|
+
if get_session(result.session_id) and result.active_project:
|
|
313
|
+
set_session_project(result.session_id, result.active_project)
|
|
314
|
+
result.project_state = resolve_project(result.active_project)
|
|
315
|
+
result.cmds = get_all_commands(lp.commands_dir)
|
|
316
|
+
result.subs = get_subagents(lp.subagents_dir)
|
|
317
|
+
ctx.prompt_session.completer = build_repl_completer(list(result.cmds.keys()))
|
|
318
|
+
return result
|
|
319
|
+
if text == "/commands":
|
|
320
|
+
ctx.print_commands(ctx.cmds)
|
|
321
|
+
return result
|
|
322
|
+
if text == "/parallel":
|
|
323
|
+
import polymath.main as main_mod
|
|
324
|
+
n = main_mod._active_polymath_sessions()
|
|
325
|
+
if n == 0:
|
|
326
|
+
ctx.console.print("[dim]No other polymath sessions running.[/dim]")
|
|
327
|
+
else:
|
|
328
|
+
ctx.console.print(f"[cyan]{n} other polymath session{'s' if n != 1 else ''} running.[/cyan]")
|
|
329
|
+
return result
|
|
330
|
+
if text == "/subagents":
|
|
331
|
+
ctx.print_subagents(ctx.subs)
|
|
332
|
+
return result
|
|
333
|
+
if text.startswith("/accounts"):
|
|
334
|
+
acct_args = text[9:].strip()
|
|
335
|
+
gemini_accts = list_gemini_accounts()
|
|
336
|
+
if acct_args.startswith("switch "):
|
|
337
|
+
switch_target = acct_args[7:].strip()
|
|
338
|
+
if switch_target.lower() == "claude":
|
|
339
|
+
ctx.console.print("[yellow]Claude Code supports one account at a time.[/yellow]")
|
|
340
|
+
invalidate_cache()
|
|
341
|
+
result.registry = detect(force=True)
|
|
342
|
+
elif switch_target.lower() == "openai":
|
|
343
|
+
ctx.console.print("[yellow]Codex supports one account at a time.[/yellow]")
|
|
344
|
+
invalidate_cache()
|
|
345
|
+
result.registry = detect(force=True)
|
|
346
|
+
elif switch_target.lower().startswith("gemini"):
|
|
347
|
+
email = switch_target[6:].strip()
|
|
348
|
+
if email and switch_gemini_account(email):
|
|
349
|
+
ctx.console.print(f"[green]Switched Gemini account to {email}.[/green]")
|
|
350
|
+
result.registry = detect(force=True)
|
|
351
|
+
else:
|
|
352
|
+
ctx.console.print(f"[red]Unknown Gemini account: {email}[/red]")
|
|
353
|
+
else:
|
|
354
|
+
from rich.table import Table
|
|
355
|
+
tbl = Table(title="Authenticated Accounts", show_header=True, header_style="bold cyan")
|
|
356
|
+
tbl.add_column("Provider", style="cyan")
|
|
357
|
+
tbl.add_column("Account", style="white")
|
|
358
|
+
tbl.add_column("Status", style="dim")
|
|
359
|
+
tbl.add_column("Switch", style="dim")
|
|
360
|
+
claude_avail = any(m.provider == "claude" and m.available for m in ctx.registry)
|
|
361
|
+
tbl.add_row("claude", "keychain / env", "[green]active[/green]" if claude_avail else "[dim]not detected[/dim]", "/accounts switch claude")
|
|
362
|
+
if gemini_accts["active"]:
|
|
363
|
+
tbl.add_row("gemini", gemini_accts["active"], "[green]active[/green]", "—")
|
|
364
|
+
for old_acct in gemini_accts["old"]:
|
|
365
|
+
tbl.add_row("gemini", old_acct, "[dim]available[/dim]", f"/accounts switch gemini {old_acct}")
|
|
366
|
+
oai_avail = any(m.provider == "openai" and m.available for m in ctx.registry)
|
|
367
|
+
tbl.add_row("openai", "codex auth", "[green]active[/green]" if oai_avail else "[dim]not detected[/dim]", "/accounts switch openai")
|
|
368
|
+
ctx.console.print(tbl)
|
|
369
|
+
return result
|
|
370
|
+
|
|
371
|
+
if text.startswith("/memory"):
|
|
372
|
+
await _handle_memory(ctx, text)
|
|
373
|
+
return result
|
|
374
|
+
|
|
375
|
+
if text.startswith("/") and not any(text.startswith(p) for p in (
|
|
376
|
+
"/exit", "/models", "/sessions", "/session", "/new", "/project",
|
|
377
|
+
"/context", "/setup", "/help", "/commands", "/subagents", "/artifact",
|
|
378
|
+
"/parallel", "/accounts", "/state", "/queue", "/memory",
|
|
379
|
+
)):
|
|
380
|
+
parts = text[1:].split(None, 1)
|
|
381
|
+
cmd_name = parts[0].lower()
|
|
382
|
+
cmd_args = parts[1] if len(parts) > 1 else ""
|
|
383
|
+
if cmd_name not in ctx.cmds:
|
|
384
|
+
ctx.console.print(f"[red]Unknown command: /{cmd_name} (use /commands to list)[/red]")
|
|
385
|
+
return result
|
|
386
|
+
from polymath.router import classify_task
|
|
387
|
+
model_resolution = await resolve_models(
|
|
388
|
+
registry=ctx.registry,
|
|
389
|
+
cfg=ctx.cfg,
|
|
390
|
+
session_id=ctx.session_id,
|
|
391
|
+
user_input=cmd_args or ctx.last_output or cmd_name,
|
|
392
|
+
active_project=ctx.active_project,
|
|
393
|
+
task_type=classify_task(cmd_args or ctx.last_output),
|
|
394
|
+
profile=ctx.profile,
|
|
395
|
+
is_ask=True,
|
|
396
|
+
)
|
|
397
|
+
pi = model_resolution.primary_info
|
|
398
|
+
pa = model_resolution.primary_adapter
|
|
399
|
+
if not pi or not pa:
|
|
400
|
+
ctx.console.print("[red]No models available for slash command.[/red]")
|
|
401
|
+
return result
|
|
402
|
+
output = await run_slash_cmd(
|
|
403
|
+
cmd=ctx.cmds[cmd_name],
|
|
404
|
+
session_id=ctx.session_id,
|
|
405
|
+
primary_adapter=pa,
|
|
406
|
+
primary_model=pi,
|
|
407
|
+
last_output=ctx.last_output,
|
|
408
|
+
on_token=lambda t: ctx.console.print(t, end="", highlight=False),
|
|
409
|
+
on_permission=ctx.on_permission,
|
|
410
|
+
active_project=ctx.active_project,
|
|
411
|
+
cmd_args=cmd_args,
|
|
412
|
+
)
|
|
413
|
+
ctx.console.print()
|
|
414
|
+
if output:
|
|
415
|
+
ctx.console.print(Panel(output, title=f"[bold]/{cmd_name}[/bold]", border_style="dim"))
|
|
416
|
+
result.last_output = output
|
|
417
|
+
return result
|
|
418
|
+
|
|
419
|
+
result.handled = False
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def _sync_memory_quiet(ctx: CommandServiceContext, ctx_name: str) -> None:
|
|
424
|
+
"""Re-index a context file after edit. Quiet on missing embedder."""
|
|
425
|
+
if not ctx.active_project:
|
|
426
|
+
return
|
|
427
|
+
from polymath.memory.store import MemoryStore
|
|
428
|
+
if MemoryStore(ctx.active_project).stats()["total"] == 0:
|
|
429
|
+
return # memory not initialized yet; user will run /memory rebuild
|
|
430
|
+
from polymath.memory.sync import sync_source
|
|
431
|
+
res = await sync_source(ctx.active_project, ctx_name, ctx.cfg)
|
|
432
|
+
if res.error:
|
|
433
|
+
ctx.console.print(f"[dim](memory sync skipped: {res.error.splitlines()[0]})[/dim]")
|
|
434
|
+
elif res.indexed:
|
|
435
|
+
ctx.console.print(f"[dim](memory: re-indexed {res.indexed} chunks)[/dim]")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
async def _handle_memory(ctx: CommandServiceContext, text: str) -> None:
|
|
439
|
+
"""Handle /memory subcommands. Operates on ctx.active_project."""
|
|
440
|
+
from rich.table import Table
|
|
441
|
+
|
|
442
|
+
from polymath.memory.migrate import estimate, migrate_project
|
|
443
|
+
from polymath.memory.retriever import retrieve
|
|
444
|
+
from polymath.memory.store import MemoryStore
|
|
445
|
+
from polymath.memory.writer import preview as writeback_preview
|
|
446
|
+
|
|
447
|
+
if not ctx.active_project:
|
|
448
|
+
ctx.console.print("[yellow]No active project. Use /project use <name> first.[/yellow]")
|
|
449
|
+
return
|
|
450
|
+
|
|
451
|
+
args = text[len("/memory"):].strip()
|
|
452
|
+
|
|
453
|
+
if args in ("", "show"):
|
|
454
|
+
store = MemoryStore(ctx.active_project)
|
|
455
|
+
stats = store.stats()
|
|
456
|
+
manifest = stats["manifest"]
|
|
457
|
+
if stats["total"] == 0:
|
|
458
|
+
est = estimate(ctx.active_project)
|
|
459
|
+
ctx.console.print(
|
|
460
|
+
f"[dim]No memory yet for [bold]{ctx.active_project}[/bold]. "
|
|
461
|
+
f"{est['files']} context file(s), ~{est['chunks']} chunks ready to index.[/dim]"
|
|
462
|
+
)
|
|
463
|
+
ctx.console.print("[dim]Run [bold]/memory rebuild[/bold] to index.[/dim]")
|
|
464
|
+
return
|
|
465
|
+
tbl = Table(title=f"Memory — {ctx.active_project}", show_header=True, header_style="bold cyan")
|
|
466
|
+
tbl.add_column("Type", style="cyan")
|
|
467
|
+
tbl.add_column("Confirmed", justify="right")
|
|
468
|
+
tbl.add_column("Candidate", justify="right")
|
|
469
|
+
for type_name, counts in stats["by_type"].items():
|
|
470
|
+
tbl.add_row(type_name, str(counts.get("confirmed", 0)), str(counts.get("candidate", 0)))
|
|
471
|
+
ctx.console.print(tbl)
|
|
472
|
+
provider = manifest.get("embed_provider", "?")
|
|
473
|
+
model = manifest.get("embed_model", "?")
|
|
474
|
+
dim = manifest.get("embed_dim", "?")
|
|
475
|
+
last = manifest.get("last_writeback_at") or manifest.get("updated_at") or "—"
|
|
476
|
+
ctx.console.print(f"[dim]Embedder: {provider} · {model} ({dim}d) · last write: {last}[/dim]")
|
|
477
|
+
ctx.console.print(f"[dim]Location: {store.location.root}[/dim]")
|
|
478
|
+
return
|
|
479
|
+
|
|
480
|
+
if args == "rebuild":
|
|
481
|
+
ctx.console.print("[dim]Re-chunking and re-embedding all context files…[/dim]")
|
|
482
|
+
result = await migrate_project(ctx.active_project, ctx.cfg)
|
|
483
|
+
if result.error:
|
|
484
|
+
ctx.console.print(f"[red]Rebuild failed: {result.error}[/red]")
|
|
485
|
+
return
|
|
486
|
+
ctx.console.print(
|
|
487
|
+
f"[green]Indexed {result.chunks_indexed} chunks across {result.files_processed} files "
|
|
488
|
+
f"using {result.embed_provider} / {result.embed_model}.[/green]"
|
|
489
|
+
)
|
|
490
|
+
return
|
|
491
|
+
|
|
492
|
+
if args.startswith("search "):
|
|
493
|
+
query = args[len("search "):].strip()
|
|
494
|
+
if not query:
|
|
495
|
+
ctx.console.print("[yellow]Usage: /memory search <query>[/yellow]")
|
|
496
|
+
return
|
|
497
|
+
from polymath.config import TaskType
|
|
498
|
+
res = await retrieve(ctx.active_project, query, TaskType.GENERAL, ctx.cfg, k=8)
|
|
499
|
+
if not res.used_memory:
|
|
500
|
+
ctx.console.print(f"[yellow]Memory unavailable: {res.fallback_reason}[/yellow]")
|
|
501
|
+
return
|
|
502
|
+
if not res.chunks:
|
|
503
|
+
ctx.console.print("[dim]No matches.[/dim]")
|
|
504
|
+
return
|
|
505
|
+
for chunk in res.chunks:
|
|
506
|
+
heading = " > ".join(chunk.heading_path) if chunk.heading_path else "(top)"
|
|
507
|
+
tag = "[green]✓[/green]" if chunk.status == "confirmed" else "[yellow]?[/yellow]"
|
|
508
|
+
ctx.console.print(f"{tag} [bold]{chunk.type}[/bold] · {heading} · {chunk.id}")
|
|
509
|
+
preview_text = chunk.content.replace("\n", " ")[:160]
|
|
510
|
+
ctx.console.print(f" [dim]{preview_text}…[/dim]")
|
|
511
|
+
return
|
|
512
|
+
|
|
513
|
+
if args.startswith("confirm "):
|
|
514
|
+
chunk_id = args[len("confirm "):].strip()
|
|
515
|
+
store = MemoryStore(ctx.active_project)
|
|
516
|
+
if store.update_status(chunk_id, "confirmed"):
|
|
517
|
+
ctx.console.print(f"[green]Promoted {chunk_id} to confirmed.[/green]")
|
|
518
|
+
else:
|
|
519
|
+
ctx.console.print(f"[red]Chunk {chunk_id} not found.[/red]")
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
if args == "test":
|
|
523
|
+
from polymath.memory.embedder import Embedder, EmbedderUnavailable
|
|
524
|
+
ctx.console.print("[dim]Testing embedder…[/dim]")
|
|
525
|
+
embedder = Embedder(ctx.cfg)
|
|
526
|
+
try:
|
|
527
|
+
res = await embedder.embed(["polymath memory smoke test"])
|
|
528
|
+
except EmbedderUnavailable as e:
|
|
529
|
+
ctx.console.print(f"[red]Embedder unavailable.[/red]\n[dim]{e}[/dim]")
|
|
530
|
+
return
|
|
531
|
+
sample = ", ".join(f"{v:.4f}" for v in res.vectors[0][:4])
|
|
532
|
+
ctx.console.print(
|
|
533
|
+
f"[green]✓ {res.provider} / {res.model} ({res.dim}d)[/green]"
|
|
534
|
+
)
|
|
535
|
+
ctx.console.print(f"[dim]first 4 dims: [{sample}, …][/dim]")
|
|
536
|
+
return
|
|
537
|
+
|
|
538
|
+
if args == "diff":
|
|
539
|
+
from polymath.router import classify_task
|
|
540
|
+
model_resolution = await resolve_models(
|
|
541
|
+
registry=ctx.registry,
|
|
542
|
+
cfg=ctx.cfg,
|
|
543
|
+
session_id=ctx.session_id,
|
|
544
|
+
user_input="memory writeback preview",
|
|
545
|
+
active_project=ctx.active_project,
|
|
546
|
+
task_type=classify_task(""),
|
|
547
|
+
profile=ctx.profile,
|
|
548
|
+
is_ask=True,
|
|
549
|
+
)
|
|
550
|
+
pi = model_resolution.primary_info
|
|
551
|
+
pa = model_resolution.primary_adapter
|
|
552
|
+
if not pi or not pa:
|
|
553
|
+
ctx.console.print("[red]No models available.[/red]")
|
|
554
|
+
return
|
|
555
|
+
candidates = await writeback_preview(
|
|
556
|
+
session_id=ctx.session_id,
|
|
557
|
+
project=ctx.active_project,
|
|
558
|
+
primary_adapter=pa,
|
|
559
|
+
primary_model_id=pi.id,
|
|
560
|
+
cfg=ctx.cfg,
|
|
561
|
+
)
|
|
562
|
+
if not candidates:
|
|
563
|
+
ctx.console.print("[dim]Nothing extractable from this session yet.[/dim]")
|
|
564
|
+
return
|
|
565
|
+
ctx.console.print(f"[cyan]Would extract {len(candidates)} candidate chunks:[/cyan]")
|
|
566
|
+
for c in candidates:
|
|
567
|
+
ctx.console.print(f" [bold]{c['type']}[/bold]: {c['content']}")
|
|
568
|
+
return
|
|
569
|
+
|
|
570
|
+
ctx.console.print(
|
|
571
|
+
"[yellow]Usage: /memory [show|rebuild|search <q>|confirm <id>|diff|test][/yellow]"
|
|
572
|
+
)
|
polymath/compressor.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Smart context windowing.
|
|
3
|
+
- Always send the last N messages verbatim.
|
|
4
|
+
- Everything older is compressed into a cached summary.
|
|
5
|
+
- Summary is regenerated only when new messages push old ones out of the window.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from polymath.adapters.base import BaseAdapter, Message
|
|
10
|
+
from polymath.context_store import (
|
|
11
|
+
get_messages, get_summary, last_message_id, save_summary
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
TOKEN_BUDGET = 3000 # approximate tokens to include verbatim
|
|
16
|
+
SUMMARY_TOKENS = 300
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def build_context(
|
|
20
|
+
session_id: str,
|
|
21
|
+
window: int = 0, # kept for backward compatibility but ignored
|
|
22
|
+
summarizer: BaseAdapter | None = None,
|
|
23
|
+
summarizer_model: str = "",
|
|
24
|
+
) -> list[Message]:
|
|
25
|
+
"""
|
|
26
|
+
Returns a message list ready to send to any model:
|
|
27
|
+
[summary_as_system_msg?] + [recent messages within TOKEN_BUDGET]
|
|
28
|
+
"""
|
|
29
|
+
all_msgs = get_messages(session_id)
|
|
30
|
+
|
|
31
|
+
# Walk backwards accumulating tokens until budget is exceeded
|
|
32
|
+
budget = TOKEN_BUDGET
|
|
33
|
+
cutoff = len(all_msgs)
|
|
34
|
+
for i in range(len(all_msgs) - 1, -1, -1):
|
|
35
|
+
tokens = len(all_msgs[i].content) // 4
|
|
36
|
+
if budget - tokens < 0:
|
|
37
|
+
break
|
|
38
|
+
budget -= tokens
|
|
39
|
+
cutoff = i
|
|
40
|
+
|
|
41
|
+
if cutoff == 0:
|
|
42
|
+
return all_msgs
|
|
43
|
+
|
|
44
|
+
recent = all_msgs[cutoff:]
|
|
45
|
+
old = all_msgs[:cutoff]
|
|
46
|
+
|
|
47
|
+
summary_rec = get_summary(session_id)
|
|
48
|
+
last_id = last_message_id(session_id)
|
|
49
|
+
through_id = last_id - window # approximate
|
|
50
|
+
|
|
51
|
+
# Regenerate summary only if there are new old messages since last summary
|
|
52
|
+
if summary_rec and summary_rec.get("summarized_through", 0) >= through_id:
|
|
53
|
+
summary_text = summary_rec["summary"]
|
|
54
|
+
elif summarizer and summarizer_model:
|
|
55
|
+
summary_text = await _summarize(old, summarizer, summarizer_model)
|
|
56
|
+
save_summary(session_id, summary_text, through_id)
|
|
57
|
+
else:
|
|
58
|
+
# Fallback: simple concatenation truncated
|
|
59
|
+
combined = " | ".join(f"{m.role}: {m.content[:80]}" for m in old)
|
|
60
|
+
summary_text = f"[Earlier context] {combined}"
|
|
61
|
+
|
|
62
|
+
summary_msg = Message(
|
|
63
|
+
role="user",
|
|
64
|
+
content=f"[Context summary from earlier in this conversation]\n{summary_text}",
|
|
65
|
+
model="system",
|
|
66
|
+
step="summary",
|
|
67
|
+
)
|
|
68
|
+
# Insert summary as the first message so recent context follows naturally
|
|
69
|
+
return [summary_msg] + recent
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _summarize(
|
|
73
|
+
messages: list[Message],
|
|
74
|
+
adapter: BaseAdapter,
|
|
75
|
+
model_id: str,
|
|
76
|
+
) -> str:
|
|
77
|
+
prompt_parts = [f"{m.role.upper()}: {m.content}" for m in messages]
|
|
78
|
+
prompt = (
|
|
79
|
+
"Summarize the following conversation in 2-3 sentences, preserving key facts, "
|
|
80
|
+
"decisions, and any code/output produced. Be concise.\n\n"
|
|
81
|
+
+ "\n".join(prompt_parts)
|
|
82
|
+
)
|
|
83
|
+
summary = await adapter.complete(
|
|
84
|
+
messages=[Message(role="user", content=prompt)],
|
|
85
|
+
model_id=model_id,
|
|
86
|
+
system="You are a concise summarizer. Output only the summary, no preamble.",
|
|
87
|
+
temperature=0.3,
|
|
88
|
+
max_tokens=SUMMARY_TOKENS,
|
|
89
|
+
)
|
|
90
|
+
return summary.strip()
|