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
@@ -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()