otto-cli-agent 0.1.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 (129) hide show
  1. agent/README.md +22 -0
  2. agent/__init__.py +0 -0
  3. agent/cli/README.md +77 -0
  4. agent/cli/__init__.py +0 -0
  5. agent/cli/art.py +371 -0
  6. agent/cli/chat.py +309 -0
  7. agent/cli/clipboard.py +106 -0
  8. agent/cli/context.py +56 -0
  9. agent/cli/doctor.py +60 -0
  10. agent/cli/errors.py +58 -0
  11. agent/cli/eval.py +130 -0
  12. agent/cli/eval_claw.py +414 -0
  13. agent/cli/eval_compaction.py +106 -0
  14. agent/cli/eval_hle.py +92 -0
  15. agent/cli/eval_memory.py +253 -0
  16. agent/cli/eval_swe.py +172 -0
  17. agent/cli/lessons.py +97 -0
  18. agent/cli/main.py +67 -0
  19. agent/cli/modals.py +570 -0
  20. agent/cli/models.py +64 -0
  21. agent/cli/output.py +54 -0
  22. agent/cli/route.py +78 -0
  23. agent/cli/sessions.py +125 -0
  24. agent/cli/setup_screen.py +562 -0
  25. agent/cli/shell.py +548 -0
  26. agent/cli/tui.py +1807 -0
  27. agent/cli/ui.py +14 -0
  28. agent/cli/usage_panel.py +159 -0
  29. agent/config/README.md +7 -0
  30. agent/config/__init__.py +0 -0
  31. agent/config/envfile.py +76 -0
  32. agent/eval/README.md +76 -0
  33. agent/eval/__init__.py +0 -0
  34. agent/eval/claw_bench.py +1031 -0
  35. agent/eval/compaction_bench.py +229 -0
  36. agent/eval/data/README.md +10 -0
  37. agent/eval/data/claw/README.md +108 -0
  38. agent/eval/data/claw/llm_judge-gemini.patch +57 -0
  39. agent/eval/data/claw/otto.yaml +35 -0
  40. agent/eval/failures.py +276 -0
  41. agent/eval/golden/README.md +33 -0
  42. agent/eval/golden/code_01.json +6 -0
  43. agent/eval/golden/code_02.json +6 -0
  44. agent/eval/golden/code_03.json +6 -0
  45. agent/eval/golden/code_04.json +6 -0
  46. agent/eval/golden/code_05.json +6 -0
  47. agent/eval/golden/code_06.json +6 -0
  48. agent/eval/golden/math_01.json +6 -0
  49. agent/eval/golden/math_02.json +6 -0
  50. agent/eval/golden/math_03.json +6 -0
  51. agent/eval/golden/math_04.json +6 -0
  52. agent/eval/golden/math_05.json +6 -0
  53. agent/eval/golden/math_06.json +6 -0
  54. agent/eval/golden/nphard_gcp_01.json +6 -0
  55. agent/eval/golden/nphard_ksp_01.json +6 -0
  56. agent/eval/golden/nphard_math_binpacking_01.json +6 -0
  57. agent/eval/golden/nphard_math_clique_01.json +6 -0
  58. agent/eval/golden/nphard_math_setcover_01.json +6 -0
  59. agent/eval/golden/nphard_math_subsetsum_01.json +6 -0
  60. agent/eval/golden/nphard_tsp_01.json +6 -0
  61. agent/eval/golden/nphard_tsp_02.json +6 -0
  62. agent/eval/hle_bench.py +273 -0
  63. agent/eval/langfuse_sync.py +172 -0
  64. agent/eval/memory_bench.py +538 -0
  65. agent/eval/runner.py +174 -0
  66. agent/eval/single_agent.py +120 -0
  67. agent/eval/swe_bench.py +604 -0
  68. agent/eval/terminal_bench.py +345 -0
  69. agent/memory/README.md +102 -0
  70. agent/memory/__init__.py +42 -0
  71. agent/memory/embeddings.py +302 -0
  72. agent/memory/hashing.py +15 -0
  73. agent/memory/lessons.py +483 -0
  74. agent/memory/queue.py +531 -0
  75. agent/memory/retrieval.py +493 -0
  76. agent/memory/session.py +60 -0
  77. agent/memory/sessions.py +436 -0
  78. agent/memory/store.py +429 -0
  79. agent/memory/tokens.py +60 -0
  80. agent/memory/wiring.py +146 -0
  81. agent/pipeline/README.md +135 -0
  82. agent/pipeline/__init__.py +0 -0
  83. agent/pipeline/browsing.py +609 -0
  84. agent/pipeline/budget.py +403 -0
  85. agent/pipeline/codemap.py +254 -0
  86. agent/pipeline/evidence.py +325 -0
  87. agent/pipeline/execution.py +67 -0
  88. agent/pipeline/modes.py +137 -0
  89. agent/pipeline/native.py +1137 -0
  90. agent/pipeline/nodes.py +3644 -0
  91. agent/pipeline/pricing.py +209 -0
  92. agent/pipeline/progress.py +139 -0
  93. agent/pipeline/rag.py +139 -0
  94. agent/pipeline/research.py +1325 -0
  95. agent/pipeline/run.py +528 -0
  96. agent/pipeline/screen.py +77 -0
  97. agent/pipeline/state.py +220 -0
  98. agent/pipeline/toolkit.py +328 -0
  99. agent/pipeline/tools.py +1990 -0
  100. agent/pipeline/tracing.py +147 -0
  101. agent/pipeline/usage.py +251 -0
  102. agent/pipeline/vision.py +84 -0
  103. agent/pipeline/walkthrough.py +735 -0
  104. agent/pipeline/workspace.py +229 -0
  105. agent/router/README.md +60 -0
  106. agent/router/__init__.py +0 -0
  107. agent/router/automap.py +114 -0
  108. agent/router/health.py +229 -0
  109. agent/router/llm_provider/README.md +38 -0
  110. agent/router/llm_provider/__init__.py +202 -0
  111. agent/router/llm_provider/anthropic_provider.py +128 -0
  112. agent/router/llm_provider/base.py +507 -0
  113. agent/router/llm_provider/custom.py +152 -0
  114. agent/router/llm_provider/gemini_provider.py +122 -0
  115. agent/router/llm_provider/inception_provider.py +687 -0
  116. agent/router/llm_provider/openai_provider.py +151 -0
  117. agent/router/llm_provider/retired.py +145 -0
  118. agent/router/llm_provider/temperature.py +371 -0
  119. agent/router/mapping.py +579 -0
  120. agent/router/outcomes.py +363 -0
  121. agent/router/overrides.py +389 -0
  122. agent/router/reload.py +28 -0
  123. agent/router/router.py +413 -0
  124. agent/router/setup.py +123 -0
  125. otto_cli_agent-0.1.0.dist-info/METADATA +115 -0
  126. otto_cli_agent-0.1.0.dist-info/RECORD +129 -0
  127. otto_cli_agent-0.1.0.dist-info/WHEEL +4 -0
  128. otto_cli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  129. otto_cli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
agent/cli/shell.py ADDED
@@ -0,0 +1,548 @@
1
+ """The interactive shell: session state, slash commands, completion, and the
2
+ per-turn renderer.
3
+
4
+ Reconciled against the router/planner/solver/summarizer/finder/evaluator
5
+ graph (agent/pipeline/, replaced the orchestrator/worker/evaluate/
6
+ subtask_consensus/synthesize swarm pipeline on 2026-09-10, which had itself
7
+ replaced the Phase 11B code hive on 2026-09-09): the interactive default is
8
+ no longer a single resolved (RoutingDecision, chat model) pair, nor a
9
+ swarm of N workers -- it is run_pipeline_stream(), a sequence of
10
+ AgentState graph updates from a single router-dispatched specialist per
11
+ round. Session carries no `d`/`llm`/`config`/`agents` for that reason:
12
+ there is nothing left to size or fix, and `/task` from the original 9.5
13
+ spec is gone with it -- there is no longer a single task route for a turn
14
+ to switch, since each node already routes its own calls (router on
15
+ Task.CHAT_FAST, planner on Task.PLAN, solver on Task.REASON, ...)
16
+ independently of anything a REPL command could select.
17
+
18
+ Conversation memory (2026-09-10, same day, Phase 2 of docs/design/tiered-
19
+ memory-design.md): `Session` used to carry conversation history as a plain
20
+ `list[BaseMessage]`, growing every turn with nothing capping it -- that gap
21
+ was flagged, not fixed, when agent/pipeline/run.py's `history` parameter
22
+ was first added. It now keeps a `history_queue` (an `agent.memory.queue.
23
+ TieredQueue`, "history" kind) instead: `history_for_graph()` is what a
24
+ caller hands to `run_pipeline_stream()`/`run_pipeline()`, `record_turn()`
25
+ is what a caller writes a finished turn back with -- see agent/memory/
26
+ wiring.py for the actual reconstruction/formatting logic, kept out of this
27
+ file on purpose (this file's own job stays "session state, slash commands,
28
+ completion, the per-turn renderer", not memory-engine internals).
29
+
30
+ The workspace is session state too (2026-09-12, design call: "we need
31
+ filesystem management so we can use it to write code and work on already
32
+ implemented codebases"). It is one directory for the whole session rather than
33
+ a per-turn argument, because that is what it means to a person: you open otto
34
+ on a repository and every turn after that is about that repository. `/workspace`
35
+ below is how to look at it or point it somewhere else mid-session, and
36
+ `Session.reset()` deliberately KEEPS it -- a new session is a new conversation,
37
+ not a different project. See agent/pipeline/workspace.py for what binding one
38
+ actually grants and what it does not.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import uuid
44
+ from collections import Counter
45
+ from dataclasses import dataclass, field
46
+ from pathlib import Path
47
+ from typing import Callable
48
+
49
+ from langchain_core.messages import BaseMessage, HumanMessage
50
+ from prompt_toolkit import PromptSession
51
+ from prompt_toolkit.completion import Completer, Completion
52
+ from prompt_toolkit.history import FileHistory
53
+ from rich.markdown import Markdown
54
+ from rich.panel import Panel
55
+ from rich.table import Table
56
+
57
+ from agent.cli.art import decorate_board_line
58
+ from agent.cli.context import AppContext
59
+ from agent.cli.doctor import health_table, router_view
60
+ from agent.cli.models import models_table
61
+ from agent.cli.route import _chain, _summary
62
+ from agent.cli.sessions import sessions_table
63
+ from agent.cli.ui import err, out
64
+ from agent.memory import sessions as session_index
65
+ from agent.memory import wiring as memory_wiring
66
+ from agent.memory.queue import TieredQueue
67
+ from agent.router.llm_provider import all_models
68
+ from agent.router.mapping import Task
69
+ from agent.router.router import NoViableRoute
70
+
71
+
72
+ @dataclass
73
+ class Session:
74
+ ctx: AppContext
75
+ session_id: str = field(default_factory=lambda: uuid.uuid4().hex)
76
+ trace_id: str | None = None
77
+ #: The directory every file tool in this session may touch, or None for
78
+ #: no file access at all (module docstring; agent/pipeline/workspace.py
79
+ #: for the boundary). Handed to run_pipeline_stream() on every turn --
80
+ #: NOT bound here, because the contextvar has to be set on whichever
81
+ #: thread actually consumes the stream, which for the TUI is not this one.
82
+ workspace: Path | None = None
83
+ #: Counts turns that actually produced output, for output.py's filenames
84
+ #: -- not every dispatched line (slash commands don't count). Advanced by
85
+ #: `record_turn` (2026-09-13), which is also what writes it to the
86
+ #: session index, so the two cannot disagree about how far a session got.
87
+ turn: int = 0
88
+ #: What the session index calls this session (agent/memory/sessions.py):
89
+ #: the first message, cut short, until `/rename`. Empty until the first
90
+ #: turn is recorded -- an unnamed session is one nothing has happened in.
91
+ title: str = ""
92
+ #: This session's own bounded conversation memory (module docstring) --
93
+ #: `init=False`/set in __post_init__ rather than a `field(default_factory=...)`
94
+ #: because building one needs `session_id`, which isn't available yet
95
+ #: when dataclass field defaults are evaluated (session_id is itself
96
+ #: just becoming set at that point, by ITS OWN default_factory, with no
97
+ #: guaranteed ordering against a sibling field's factory).
98
+ history_queue: TieredQueue = field(init=False, repr=False)
99
+
100
+ def __post_init__(self) -> None:
101
+ self.history_queue = memory_wiring.new_history_queue(self.session_id)
102
+
103
+ def history_for_graph(self) -> tuple[list[BaseMessage], str]:
104
+ """(bounded prior messages, compacted-memory context) for this
105
+ session's NEXT turn -- agent/pipeline/run.py's `history`/
106
+ `memory_context` parameters. See agent/memory/wiring.py's
107
+ history_for_graph() for what each half actually contains."""
108
+ return memory_wiring.history_for_graph(self.history_queue)
109
+
110
+ def workspace_arg(self) -> str | None:
111
+ """This session's workspace as run.py wants it: a plain string, or
112
+ None. One accessor rather than `str(s.workspace) if s.workspace else
113
+ None` repeated at four call sites, each of which could get the
114
+ empty-vs-None distinction wrong on its own."""
115
+ return str(self.workspace) if self.workspace is not None else None
116
+
117
+ def record_turn(self, human: BaseMessage, ai: BaseMessage | None) -> None:
118
+ """Write one finished turn into this session's memory -- `ai` is
119
+ None for a turn that produced no final output (nothing worth
120
+ remembering as "otto said"), same as chat.py/tui.py already treat
121
+ an empty `raw_output` as nothing to save to disk.
122
+
123
+ Also counts the turn (when it produced output) and registers the
124
+ session in the index (agent/memory/sessions.py) -- creating its row
125
+ on the first turn, titled from what the person said, and bumping
126
+ its activity after. Here rather than in the two front ends because
127
+ both must do exactly this and neither may forget: a session the
128
+ index does not know is one `/resume` cannot find.
129
+ """
130
+ memory_wiring.record_turn(self.history_queue, human, ai)
131
+ if ai is not None:
132
+ self.turn += 1
133
+ info = session_index.touch(
134
+ self.session_id,
135
+ title=self.title or session_index.title_from(str(human.content)),
136
+ workspace=self.workspace, turns=self.turn,
137
+ )
138
+ self.title = info.title
139
+
140
+ def load(self, ref: str) -> session_index.SessionInfo:
141
+ """Adopt a saved session in place -- the counterpart of `reset()`,
142
+ which mints a new one. `ref` is what agent/memory/sessions.py's
143
+ `resolve` accepts: "last", an id, or a unique prefix; LookupError
144
+ for anything else, with the reason in the message.
145
+
146
+ The saved workspace comes back with it when the directory still
147
+ exists: a session about a repository is still about that repository.
148
+ A caller with an explicit `--workspace` sets it after this returns.
149
+ Memory is the session's own file, read back rather than started
150
+ over (agent/memory/wiring.py's `restore=True`).
151
+ """
152
+ info = session_index.resolve(ref)
153
+ self.close()
154
+ self.session_id = info.id
155
+ self.trace_id = None
156
+ self.turn = info.turns
157
+ self.title = info.title
158
+ self.history_queue = memory_wiring.new_history_queue(info.id, restore=True)
159
+ if info.workspace and Path(info.workspace).is_dir():
160
+ self.workspace = Path(info.workspace)
161
+ return info
162
+
163
+ def rename(self, title: str) -> None:
164
+ self.title = session_index.rename(self.session_id, title, workspace=self.workspace).title
165
+
166
+ def transcript(self) -> tuple[str, list[BaseMessage]]:
167
+ """What a resumed session has to show for itself: everything older
168
+ than the recent turns as the compacted text the model will see, and
169
+ the recent turns as typed messages -- exactly what the next turn's
170
+ prompt is built from, so what is on screen is what otto knows."""
171
+ return self.history_queue.earlier_view(), memory_wiring.recent_messages(self.history_queue)
172
+
173
+ def close(self) -> None:
174
+ """Release this session's memory file. `reset()`/`load()` call it on
175
+ the store they replace, and anything about to delete the session
176
+ must call it first: Windows will not delete a file a connection
177
+ still holds open (CI's windows-latest job found this), and POSIX
178
+ would -- silently, leaving the connection writing into an unlinked
179
+ inode. Safe to call twice."""
180
+ self.history_queue.store.close()
181
+
182
+ def reset(self) -> None:
183
+ """New session, new memory -- `/new` (chat.py)/"new session"
184
+ (tui.py)'s existing "clear history, start fresh" contract, now
185
+ covering `history_queue` too (a fresh TieredQueue backed by a
186
+ fresh SQLite file under this new session_id, not the old queue
187
+ cleared in place -- the old session's compacted history stays on
188
+ disk, keyed by its own now-abandoned session_id, exactly as
189
+ harmless and exactly as inaccessible as it always was once a
190
+ session ended)."""
191
+ self.close()
192
+ self.session_id = uuid.uuid4().hex
193
+ self.trace_id = None
194
+ self.turn = 0
195
+ self.title = ""
196
+ self.history_queue = memory_wiring.new_history_queue(self.session_id)
197
+ # `workspace` is deliberately NOT reset: "clear history, start fresh"
198
+ # is about the conversation, and a person who opened otto on a repo and
199
+ # then cleared the chat is still working on that repo. Changing it is
200
+ # `/workspace <path>`, which is explicit.
201
+
202
+
203
+ # --------------------------------------------------------------------------
204
+ # Slash commands
205
+ # --------------------------------------------------------------------------
206
+
207
+ @dataclass(frozen=True)
208
+ class Slash:
209
+ name: str
210
+ help: str
211
+ run: Callable[[Session, str], None]
212
+ options: Callable[[], list[str]] = lambda: []
213
+
214
+
215
+ def _route_cmd(session: Session, arg: str) -> None:
216
+ if not arg:
217
+ err.print("[warn]usage: /route <task>[/]")
218
+ return
219
+ try:
220
+ task = Task(arg)
221
+ except ValueError:
222
+ err.print(f"[warn]unknown task {arg!r}; one of {[t.value for t in Task]}[/]")
223
+ return
224
+ try:
225
+ d = session.ctx.router.resolve(task)
226
+ except NoViableRoute as exc:
227
+ out.print(_chain(task, exc.skipped, None))
228
+ err.print("[bad]no viable model[/]")
229
+ return
230
+ out.print(_chain(task, d.skipped, d))
231
+ out.print(_summary(d))
232
+
233
+
234
+ def _models_cmd(session: Session, arg: str) -> None:
235
+ with err.status("fetching catalogues…"):
236
+ found = all_models(None)
237
+ found.sort(key=lambda m: (m.provider, m.id))
238
+ out.print(models_table(found) if found else "[muted]no models matched[/]")
239
+
240
+
241
+ def _doctor_cmd(session: Session, arg: str) -> None:
242
+ from agent.router.llm_provider import health_report
243
+
244
+ with err.status("contacting providers"):
245
+ reports = health_report()
246
+ out.print(health_table(reports))
247
+ out.print(router_view(session.ctx.router))
248
+
249
+
250
+ def _score_common(session: Session, value: float, comment: str | None) -> None:
251
+ if session.trace_id is None:
252
+ err.print("[warn]nothing to rate yet[/]")
253
+ return
254
+ session.ctx.client.create_score(
255
+ name="user_feedback",
256
+ value=value,
257
+ data_type="NUMERIC",
258
+ trace_id=session.trace_id,
259
+ comment=comment,
260
+ )
261
+ session.ctx.client.flush()
262
+ err.print(f"[muted]scored {value:g}[/]")
263
+
264
+
265
+ def _good_cmd(session: Session, arg: str) -> None:
266
+ _score_common(session, 1.0, arg.strip() or None)
267
+
268
+
269
+ def _bad_cmd(session: Session, arg: str) -> None:
270
+ _score_common(session, 0.0, arg.strip() or None)
271
+
272
+
273
+ def _score_cmd(session: Session, arg: str) -> None:
274
+ value_text, _, comment_text = arg.partition(" ")
275
+ try:
276
+ value = float(value_text)
277
+ except ValueError:
278
+ err.print(r"[warn]usage: /score <0-1> \[comment][/]")
279
+ return
280
+ _score_common(session, value, comment_text.strip() or None)
281
+
282
+
283
+ def _new_cmd(session: Session, arg: str) -> None:
284
+ session.reset()
285
+ err.print("[muted]new session[/]")
286
+
287
+
288
+ def render_transcript(session: Session, sink: Callable[[object], None] | None = None) -> None:
289
+ """Print a resumed session's history the way it was first shown: the
290
+ compacted part as one dim panel, then each recent turn as the same
291
+ "you" line and final panel chat.py posts live. Shared shape with the
292
+ TUI's own replay, which posts the same pieces into its transcript.
293
+ `sink` defaults to the REPL console at call time, not definition time,
294
+ so a test that swaps the console sees what this prints."""
295
+ sink = sink or out.print
296
+ earlier, messages = session.transcript()
297
+ if earlier:
298
+ sink(Panel(earlier, title="[muted]earlier, compacted[/]", border_style="muted"))
299
+ for message in messages:
300
+ text = str(message.content)
301
+ if isinstance(message, HumanMessage):
302
+ sink(f"[bold]you[/] {text}")
303
+ else:
304
+ sink(Panel(Markdown(text), title="[spec]final[/]", border_style="ok"))
305
+
306
+
307
+ def _sessions_cmd(session: Session, arg: str) -> None:
308
+ rows = session_index.list_sessions(limit=20)
309
+ if not rows:
310
+ out.print("[muted]no saved sessions yet -- a session is saved once a turn finishes[/]")
311
+ return
312
+ out.print(sessions_table(rows, current=session.session_id))
313
+ out.print("[muted]/resume <id> picks one up; /resume last is the newest[/]")
314
+
315
+
316
+ def _resume_cmd(session: Session, arg: str) -> None:
317
+ if not arg:
318
+ err.print("[warn]usage: /resume <id or prefix | last>[/]")
319
+ return
320
+ try:
321
+ info = session.load(arg)
322
+ except LookupError as exc:
323
+ err.print(f"[warn]{exc}[/]")
324
+ return
325
+ err.print(f"[muted]resumed {info.short_id} · {info.label} · {info.turns} turn(s)[/]")
326
+ err.print(f"[muted]{describe_workspace(session.workspace)}[/]")
327
+ render_transcript(session)
328
+
329
+
330
+ def _rename_cmd(session: Session, arg: str) -> None:
331
+ if not arg:
332
+ err.print("[warn]usage: /rename <title>[/]")
333
+ return
334
+ session.rename(arg)
335
+ err.print(f"[muted]session is now called {session.title!r}[/]")
336
+
337
+
338
+ def resolve_workspace(chosen: Path | None, disabled: bool) -> Path | None:
339
+ """What `--workspace PATH` / `--no-workspace` / neither actually means.
340
+
341
+ One resolver shared by `otto chat` and `otto tui` so the two cannot drift,
342
+ and the single place the "current directory by default" policy is written
343
+ down (2026-09-12 design call, asked explicitly: a session should work on
344
+ the repo you launched it in, the way every other developer tool does).
345
+
346
+ `--no-workspace` wins over `--workspace`, deliberately: the two together
347
+ are a contradiction, and resolving it towards LESS access is the only
348
+ direction that cannot surprise someone.
349
+ """
350
+ if disabled:
351
+ return None
352
+ if chosen is not None:
353
+ return chosen.expanduser().resolve()
354
+ return Path.cwd()
355
+
356
+
357
+ def describe_workspace(workspace: Path | None) -> str:
358
+ """One line saying what file access this session has. Shared with the TUI
359
+ (agent/cli/tui.py) so both front ends say the same thing about the same
360
+ state -- the whole point of Session living in this module."""
361
+ if workspace is None:
362
+ return "no workspace: file tools are off (open one with --workspace, or /workspace <path>)"
363
+ return f"workspace: {workspace}"
364
+
365
+
366
+ def set_workspace(session: Session, path: str) -> str | None:
367
+ """Point `session` at `path`, or return why it cannot be. Returns None on
368
+ success so a caller can treat a string as "show this and stop".
369
+
370
+ Rejects a path that is not an existing directory rather than creating it,
371
+ unlike `bind_workspace`, which creates because a harness genuinely wants a
372
+ fresh scratch dir. A person typing a path at a prompt has almost certainly
373
+ typo'd it, and silently creating `~/projcts` is worse than saying so.
374
+ """
375
+ candidate = Path(path).expanduser()
376
+ try:
377
+ resolved = candidate.resolve()
378
+ except OSError as exc:
379
+ return f"{path!r} is not a usable path: {exc}"
380
+ if not resolved.exists():
381
+ return f"{resolved} does not exist"
382
+ if not resolved.is_dir():
383
+ return f"{resolved} is not a directory"
384
+ session.workspace = resolved
385
+ return None
386
+
387
+
388
+ def _workspace_cmd(session: Session, arg: str) -> None:
389
+ target = arg.strip()
390
+ if not target:
391
+ out.print(f"[muted]{describe_workspace(session.workspace)}[/]")
392
+ return
393
+ if target in {"off", "none"}:
394
+ session.workspace = None
395
+ err.print("[muted]workspace closed: file tools are off[/]")
396
+ return
397
+ problem = set_workspace(session, target)
398
+ if problem:
399
+ err.print(f"[warn]{problem}[/]")
400
+ return
401
+ err.print(f"[muted]workspace: {session.workspace}[/]")
402
+
403
+
404
+ def _help_cmd(session: Session, arg: str) -> None:
405
+ t = Table(box=None, show_header=False, pad_edge=False)
406
+ t.add_column(style="chosen")
407
+ t.add_column(style="muted")
408
+ for name, slash in sorted(COMMANDS.items()):
409
+ t.add_row(name, slash.help)
410
+ out.print(t)
411
+
412
+
413
+ COMMANDS: dict[str, Slash] = {
414
+ "/route": Slash("/route", "show how a task resolves", _route_cmd, lambda: [t.value for t in Task]),
415
+ "/models": Slash("/models", "list every configured model", _models_cmd),
416
+ "/doctor": Slash("/doctor", "check every provider", _doctor_cmd),
417
+ "/good": Slash("/good", "score the last answer 1.0", _good_cmd),
418
+ "/bad": Slash("/bad", "score the last answer 0.0", _bad_cmd),
419
+ "/score": Slash("/score", r"score the last answer <0-1> \[comment]", _score_cmd),
420
+ "/new": Slash("/new", "clear history, start a fresh session", _new_cmd),
421
+ "/sessions": Slash("/sessions", "list saved sessions, newest first", _sessions_cmd),
422
+ "/resume": Slash(
423
+ "/resume", "pick a saved session back up: <id or prefix>, or last", _resume_cmd,
424
+ lambda: ["last", *(r.short_id for r in session_index.list_sessions(limit=10))],
425
+ ),
426
+ "/rename": Slash("/rename", "give this session a title", _rename_cmd),
427
+ "/workspace": Slash(
428
+ "/workspace", "show the working directory, or <path> to change it (off to close)",
429
+ _workspace_cmd, lambda: ["off"],
430
+ ),
431
+ "/help": Slash("/help", "list these commands", _help_cmd),
432
+ }
433
+
434
+
435
+ def dispatch(session: Session, text: str) -> bool:
436
+ """Handle a slash command. Returns True if `text` was one (handled or
437
+ not -- an unknown slash still returns True so it never reaches the
438
+ model)."""
439
+ if not text.startswith("/"):
440
+ return False
441
+ head, _, arg = text.partition(" ")
442
+ slash = COMMANDS.get(head)
443
+ if slash is None:
444
+ err.print(f"[warn]unknown command {head!r}[/]")
445
+ _help_cmd(session, "")
446
+ return True
447
+ slash.run(session, arg.strip())
448
+ return True
449
+
450
+
451
+ # --------------------------------------------------------------------------
452
+ # Completion
453
+ # --------------------------------------------------------------------------
454
+
455
+ class SlashCompleter(Completer):
456
+ def __init__(self, commands: dict[str, Slash]) -> None:
457
+ self._commands = commands
458
+
459
+ def get_completions(self, document, complete_event):
460
+ text = document.text_before_cursor
461
+ if not text.startswith("/"):
462
+ return
463
+ head, sep, arg = text.partition(" ")
464
+ if not sep:
465
+ for name in self._commands:
466
+ if name.startswith(head):
467
+ yield Completion(name, start_position=-len(head))
468
+ return
469
+ slash = self._commands.get(head)
470
+ if slash is None:
471
+ return
472
+ for option in slash.options():
473
+ if option.startswith(arg):
474
+ yield Completion(option, start_position=-len(arg))
475
+
476
+
477
+ def build_prompt_session() -> PromptSession:
478
+ return PromptSession(
479
+ completer=SlashCompleter(COMMANDS),
480
+ history=FileHistory(str(Path.home() / ".otto_history")),
481
+ )
482
+
483
+
484
+ # --------------------------------------------------------------------------
485
+ # Rendering a turn -- plain lines/panels, no animation (there is no fan-out
486
+ # left to animate: exactly one specialist runs per round).
487
+ # --------------------------------------------------------------------------
488
+
489
+ #: The one working node. It was four -- planner, solver, summarizer, finder --
490
+ #: until they collapsed into a single agent loop that switches mode instead of
491
+ #: switching node (agent/pipeline/nodes.py). Still a literal rather than an
492
+ #: import, so this display module does not pull in the whole pipeline and its
493
+ #: module-level Router() just to know one string.
494
+ #:
495
+ #: Where its lines come from changed too, and that matters more than the name.
496
+ #: LangGraph's "updates" stream emits once per node RETURN, so with one
497
+ #: long-running loop this branch used to fire once, at the very end, after
498
+ #: minutes of silence. The loop now writes each tool call and mode swap to the
499
+ #: "custom" stream as it happens (nodes.py's `_emit`), in this same node-shaped
500
+ #: form -- so these lines arrive live and the branch below needs no changes.
501
+ _ROLE_NODES = ("agent", "research")
502
+
503
+
504
+ def render_update(node: str, delta: dict, tally: Counter, sink: Callable[[object], None] = out.print) -> None:
505
+ """Render one graph update by calling `sink(renderable_or_string)` once
506
+ (zero times for a node with nothing to show). Handles both shapes that
507
+ can appear on a stream: Phase 8's Hive (spawn/clone/consensus, dead code
508
+ today -- nothing calls agent/graph/run.py's run() from either front end
509
+ -- kept here only because a future caller wiring it back up would want
510
+ the same renderer) and the pipeline's AgentState (router/planner/
511
+ solver/summarizer/finder/evaluator) -- the REPL only ever drives the
512
+ latter.
513
+
514
+ `sink` defaults to the REPL's `out.print`; the TUI (tui.py) passes its
515
+ transcript widget's `.write` instead, so the same branch logic drives two
516
+ different front ends over one Rich renderable per call."""
517
+
518
+ if node == "spawn":
519
+ for line in delta.get("board", []):
520
+ sink(f"[muted]{line}[/]")
521
+ return
522
+ if node == "clone":
523
+ vote = delta["votes"][0]
524
+ tally[vote["answer"]] += 1
525
+ sink(Panel(
526
+ Markdown(f"**{vote['answer']}**\n\n{vote['rationale']}"),
527
+ title=f"[spec]clone · seed {vote['seed']}[/]", border_style="spec",
528
+ ))
529
+ return
530
+ if node == "consensus":
531
+ for line in delta.get("board", []):
532
+ sink(f"[muted]{line}[/]")
533
+ tally.clear()
534
+ return
535
+
536
+ if node in _ROLE_NODES:
537
+ for line in delta.get("board", []):
538
+ sink(f"[muted]{decorate_board_line(line)}[/]")
539
+ text = (delta.get("output") or "").strip()
540
+ if text:
541
+ if len(text) > 400:
542
+ text = text[:400] + "\n…"
543
+ sink(Panel(Markdown(text), title=f"[spec]{node}[/]", border_style="spec"))
544
+ return
545
+ if node == "evaluator":
546
+ for line in delta.get("board", []):
547
+ sink(f"[muted]{decorate_board_line(line)}[/]")
548
+ return