6Slash-command dispatch and handlers for the OC Chat REPL.
10Register handlers with the :func:`command` decorator; :func:`dispatch`
11routes raw user input to the appropriate handler. Includes commands
12for model switching, filesystem inspection, session management,
13agentic goals, and help.
17- Maintain the ``COMMANDS`` registry and the ``COMMAND_HELP`` table.
18- :func:`dispatch` — route ``/<verb> <args>`` to the right handler,
19 returning ``True`` when the input was a slash command (so the REPL
20 knows to skip the LLM round-trip).
21- :class:`Context` — the mutable per-REPL state passed to every handler
22 (session, presenter, model manager, tool registry, in-flight goal).
23- Handlers themselves: ``/help``, ``/models``, ``/model``, ``/effort``,
24 ``/system``, ``/ls``, ``/read``, ``/context``, ``/session``, ``/ui``,
25 ``/budget``, ``/react``, ``/goal``, ``/stop``, etc.
31 L2 — command-handler entry / exit
32 L3 — dispatch decisions and arg parsing
33 L4 — registry-table dumps
37- Handlers receive a :class:`Context` and the raw arg string; they
38 return ``None`` and mutate ``ctx`` in place.
39- :func:`dispatch` returns ``True`` for any input starting with ``/``
40 (even unknown verbs — handled with a friendly error), ``False``
42- Unknown verbs do not crash the REPL.
46- oc_diagnostics (structured debug logging via _dbg)
47- oc_chat.core.config (shared config constants and ANSI styles)
48- oc_chat.core.session (lazy; ChatSession, CANONICAL_POLICY, build_system_prompt, _auto_name - the fresh session for /session new and clear)
49- oc_chat.core.defaults (default - the ui_reasoning_tail default)
50- oc_chat.core.session_transition (replace_session, load_session_into, rename_session and the endpoint activation pair - session and endpoint changes through the transition seam)
51- oc_chat.core.ask_menu (lazy; present_post_answer_menu - the menu after an /ask answer)
52- oc_chat.core.cache_usage (lazy; cache_hit_ratio_pct - the /cache hit ratio)
53- oc_chat.core.deployment_shape (lazy; check_deployment_shape - refuses a /sandbox set root that overlaps an importable root)
54- oc_chat.core.event_bootstrap (lazy; active_event_dir, _slug - where /packet writes its draft)
55- oc_chat.core.external_content_gate (lazy; is_canonical_record - the canonical-record check before a session is activated)
56- oc_chat.core.generation_options (lazy; GenerationOptions - the per-session /options and their default)
57- oc_chat.core.lane_picker (lazy; run_lane_picker, lane_options_from_registry - the /lane drill-down and list)
58- oc_chat.core.lane_switch (lazy; switch_lane, LaneKeyMissingError - switching onto the chosen lane)
59- oc_chat.core.persistence (lazy; session save, load, export and listing for /session and the palette, and the latest tool artifact for /open)
60- oc_chat.core.plan_inventory (lazy; TERMINAL_STATUSES, _expected_filename - plan state and file name for /plan)
61- oc_chat.core.plans (lazy; create, find, pause, continue, complete, abort and list plans for /plan)
62- oc_chat.core.provider_oauth (lazy; write_credentials - stores or removes the /login and /logout credential)
63- oc_chat.core.provider_plugin (lazy; registry - the lane named by /login and /logout)
64- oc_chat.core.resource_caps (lazy; the session cost hard cap and thresholds for /budget cost)
65- oc_chat.core.risk_profile (lazy; resolve_risk_profile - the profile a /sandbox set root is checked under)
66- oc_chat.core.session_checkpoints (lazy; tool_calls_auto_continue, cost_prompts_suppressed - the checkpoint latches for the status bar and /budget cost)
67- oc_chat.core.session_mode (lazy; the /mode confirmation mode and remembered answers)
68- oc_chat.core.startup (lazy; refresh_variable_context - refreshes the variable context after /cwd)
69- oc_chat.core.step_routing (lazy; resolve_output_tokens - the completion caps /budget prints)
70- oc_chat.core.tier2_session_grant (lazy; active_session_grant, revoke_session_grants - the Tier-2 grant for the status bar and /grants)
71- oc_chat.core.trace_view (lazy; read_session_events, format_trace, build_packet_draft - /trace and /packet)
72- oc_chat.tools._sandbox (lazy; set_session_sandbox_root_override, install_resolved - applies the session sandbox root)
73- oc_chat.tools.filesystem (lazy; list_dir, read_file, _sandbox_root_info - /ls, /read, /context and the sandbox display)
74- oc_chat.tools.planner (lazy; get_status, stop_plan - /status and /stop)
75- prompt_toolkit (lazy; DummyHistory - keeps the pasted login code out of the prompt input history)
76- rich (lazy; Console, Markdown - the /last pager and its Markdown rendering)
80oc_chat/docs/conventions/CONTRACTS.md §1.5 Context — owner of the command-handler
83from __future__
import annotations
86from collections.abc
import Callable
87from dataclasses
import dataclass, field
88from typing
import Any, TypeVar
90from oc_diagnostics
import _dbg
92_F = TypeVar(
"_F", bound=Callable[..., Any])
95 DEFAULT_MAX_REACT_ITERATIONS,
96 REASONING_EFFORT_LEVELS,
102 TransitionRejectedError,
103 commit_endpoint_activation,
105 prepare_endpoint_activation,
115COMMAND_HELP: dict[str, str] = {
116 "/help":
"/help - show this help message",
117 "/models":
"/models - list available models and their capabilities",
118 "/model":
"/model <name> - switch to any model (keeps conversation)",
119 "/use":
"/use <name> - alias for /model",
120 "/login":
"/login <lane> - sign in using a pasted authorization code",
121 "/logout":
"/logout <lane> - remove the stored OAuth credential",
122 "/lane":
"/lane [list|<name>] - drill down to a lane and model, or switch to a named lane",
123 "/pro":
"/pro - switch to the active provider's reasoning-tier model",
124 "/flash":
"/flash - switch to the active provider's cheap-tier model",
125 "/effort":
"/effort [low|medium|high]- show or set reasoning effort level",
126 "/sandbox":
"/sandbox [set <path>|unset] - show or set the session sandbox root",
127 "/grants":
"/grants [list|revoke] - show or revoke the session Tier-2 python grant",
128 "/mode":
"/mode [ask|auto|forget] - confirmation mode for this session; 'forget' drops the remembered answers",
129 "/system":
"/system [prompt] - show or set the system prompt",
130 "/ls":
"/ls [-r] [path] - list files/dirs (add -r for recursive)",
131 "/read":
"/read <file> - print a file's contents",
132 "/context":
"/context <file> - inject file content into conversation (works with any model)",
133 "/cwd":
"/cwd [<path>] - re-anchor this session to a new working directory",
134 "/open":
"/open last - print the most recent tool artifact for this session",
135 "/last":
"/last [answer|raw|reasoning|tool] - page the latest answer or related output",
136 "/session":
"/session <subcommand> - manage sessions (new, list, current, save, load, export, switch, clear)",
137 "/ui":
"/ui <subcommand> - configure the terminal UI (reasoning visibility; theme/stats in later phases)",
138 "/palette":
"/palette [query] - fuzzy-search commands and recent sessions (Ctrl+P at the prompt)",
139 "/goal":
"/goal <task> - give the agent an autonomous goal to execute",
140 "/status":
"/status - show the current plan's step-by-step status",
141 "/stop":
"/stop - halt the active plan",
142 "/cache":
"/cache - prompt cache hit/miss (session + recent turns)",
143 "/trace":
"/trace [N|all] - show this session's recorded event stream (default last 200)",
144 "/packet":
"/packet - write a draft merge-readiness packet (packet-draft/v0) for this session",
145 "/budget":
"/budget [cheap|balanced|deep|cost] - token profile for completion caps; 'cost' shows session spend, checkpoints and the hard cap",
146 "/react":
"/react [N|reset] - show or set max ReAct iterations (1–99)",
147 "/ask":
"/ask <question> - one-shot question (no session log, no tool calls)",
148 "/exit":
"/exit (or /quit) - save conversation log and exit",
154 return GenerationOptions()
158 """Return the latest message with *role*, or ``None``."""
159 func =
"_latest_message_of_role"
160 for message
in reversed(messages):
161 if message.get(
"role") == role:
162 _dbg(
"OC_CHAT.MAIN", func, f
"found role={role}", 4)
164 _dbg(
"OC_CHAT.MAIN", func, f
"missing role={role}", 4)
169 messages: list[dict],
173 """Return the latest role-matched message with a non-empty *field_name*."""
174 func =
"_latest_message_with_field"
175 for message
in reversed(messages):
176 if message.get(
"role") == role
and message.get(field_name):
177 _dbg(
"OC_CHAT.MAIN", func, f
"found role={role} field={field_name}", 4)
179 _dbg(
"OC_CHAT.MAIN", func, f
"missing role={role} field={field_name}", 4)
184 """Convert stored message content into display text."""
185 func =
"_message_content_to_text"
188 if isinstance(content, str):
192 return json.dumps(content, ensure_ascii=
False, indent=2)
193 except TypeError
as exc:
194 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {type(exc).__name__}: {exc}", 3)
199 """Render *content* in a pager when interactive, else print inline."""
200 func =
"_page_last_content"
202 from rich.console
import Console
203 from rich.markdown
import Markdown
205 is_tty = bool(getattr(sys.stdout,
"isatty",
lambda:
False)())
207 force_terminal=is_tty,
208 color_system=
"truecolor" if is_tty
else None,
211 def _print_content() -> None:
213 console.print(Markdown(content))
215 console.print(content, markup=
False)
218 _dbg(
"OC_CHAT.MAIN", func,
"non-tty fallback", 3)
222 _dbg(
"OC_CHAT.MAIN", func, f
"pager markdown={markdown}", 3)
223 with console.pager(styles=
True):
227def command(*names: str) -> Callable[[_F], _F]:
228 """Decorator that registers a handler for one or more command verbs."""
229 def deco(fn: _F) -> _F:
238 """Aggregate object passed to every command handler.
240 Bundles the live session, model manager, tool registry, presenter,
241 and API client so commands can interact with the full system.
244 model_manager: object
248 tool_schemas: list = field(default_factory=list)
251 in_goal: bool =
False
252 """``True`` when the current chat turn was triggered by ``/goal``."""
253 goal_description: str =
""
254 """The goal text passed to ``/goal``, used as the changelog scope."""
255 goal_files_changed: list = field(default_factory=list)
256 """File paths (relative strings) written during the current goal turn."""
259 in_plan: bool =
False
260 """``True`` when the current chat turn was triggered by ``/plan``."""
261 plan_description: str =
""
262 """The description passed to ``/plan`` (or empty after a ``--continue``)."""
263 plan_files_changed: list = field(default_factory=list)
264 """File paths (relative strings) written during the current plan turn."""
268 """Per-session generation options (temperature, top_k, etc.)."""
271 endpoint_registry: object =
None
272 """Optional ``EndpointRegistry`` bound at startup in main.py. ``None``
273 when initialisation failed or no provider endpoints were registered;
274 ``/endpoint`` then surfaces a friendly hint."""
277 sandbox_root_override: str |
None =
None
278 """Optional per-session sandbox root set via ``/sandbox set``."""
281 iteration_stats_sink: object =
None
282 """Optional process-owned ``IterationStatsSink`` opened at startup in
283 main.py when ``--stats-file`` is given. Sessions BORROW its writer at every
284 transition commit, so the knobs survive session replacement and the
285 finaliser closes exactly one handle instead of leaking one per session."""
288 turn_stats: object =
None
289 """Optional ``TurnStats`` (``oc_ui.terminal.input``) bound at startup in
290 main.py. Read by :func:`refresh_presenter_bindings` to build the
291 ``bind_status`` provider closure; ``None`` when no turn/session timing
292 is available (e.g. a headless caller that never built one)."""
296 """Optional back-reference to the owning ``Agent``, bound in main.py.
298 ``Agent.client`` is the object ``run_turn`` hands the provider, so a
299 lane switch that updated only ``ctx.client`` would leave the NEXT TURN
300 on the old lane while every command reported the new one. ``None`` for
301 headless callers that never build an Agent."""
303 prompt_driver: object =
None
305 """Optional live ``PromptDriver``, bound in main.py once the real one
306 exists. ``/lane`` reads its picker answers through it so the drill-down
307 shares the REPL's input backend; ``None`` falls back to ``input()``."""
311 """Load *name*'s metadata at the LIVE load/switch boundary (plan 152 D9).
313 This is the ONLY boundary where in-process F67 trust inheritance is
314 possible — a fresh process starts with an empty trust set — so the
315 ``session_id -> canonical record`` rule is enforced here and startup, resume
316 and batch stay untouched.
318 A record is NON-canonical when any of these holds:
320 (a) its persisted name disagrees with the store location it was resolved
321 from — which is what a metadata file copied into another session's
322 directory looks like;
323 (b) it presents a ``session_id`` this process already binds to a DIFFERENT
325 (c) it carries the rename ``superseded_by`` mark, making it non-canonical
326 by construction rather than by load order.
328 For a non-canonical record we mint a FRESH canonical id before hydration, so
329 ``apply_session_metadata_fields`` keeps that id and resets trust before the
330 identity is exposed. Minting explicitly matters here: load/switch hydrate
331 IN PLACE, so simply dropping the persisted key would leave the session
332 carrying the PREVIOUS session's id — and its trust.
334 Residual, stated honestly: a cross-run whole-directory clone with an edited
335 name is indistinguishable without a global uniqueness index. That closes
336 when the sessions store makes ``session_id`` a primary key.
338 func =
"_metadata_for_activation"
342 loaded = load_session_metadata_located(name)
343 meta = dict(loaded.metadata)
344 locator = loaded.canonical_record_locator
345 sid = meta.get(
"session_id")
347 persisted_name = meta.get(
"session_name")
349 if isinstance(persisted_name, str)
and persisted_name
and persisted_name != name:
350 reason =
"record name disagrees with the store location it was resolved from"
351 elif meta.get(
"superseded_by"):
352 reason =
"record was superseded by a rename"
353 elif not is_canonical_record(sid, locator):
354 reason =
"session_id is already owned by a different record in this process"
357 meta.pop(
"session_id",
None)
358 ctx.session.session_id = str(uuid.uuid4())
359 _dbg(
"OC_CHAT.MAIN", func,
360 f
"SECURITY_ERROR: non-canonical record for {name!r} ({reason}); "
361 f
"minted a fresh identity and trust will reset before exposure", 2, override=
True)
366 """Re-bind the presenter to the current session + status snapshot (Plan 147 D1).
368 Calls ``presenter.bind_session(ctx.session)`` and
369 ``presenter.bind_status(provider)`` together. Route EVERY session-
370 mutation path through this single helper — startup, the pre-turn
371 rebind, and every ``/session`` subcommand that replaces or reloads
372 ``ctx.session`` — so presenter chrome (the enhanced header, the D3
373 status bar) never shows a stale session after ``/session new`` /
374 ``load`` / ``switch`` / ``clear``.
376 The ``bind_status`` provider is a fresh closure over *ctx* built on
377 every call — it reads ``ctx.session`` at RENDER time (late-bound),
378 never captures the ``ChatSession`` object itself, so a later
379 ``/session new`` (which reassigns ``ctx.session``) is picked up
380 without re-binding. ``TurnStats.rebind_session`` resets the session-
381 elapsed clock only when the session identity actually changed —
382 calling this helper on every turn is safe and idempotent.
384 presenter = ctx.presenter
385 if hasattr(presenter,
"bind_session"):
386 presenter.bind_session(ctx.session)
387 if ctx.turn_stats
is not None:
388 ctx.turn_stats.rebind_session(ctx.session.session_id)
390 def _status_provider():
391 session = ctx.session
392 turn_stats = ctx.turn_stats
393 if session
is None or turn_stats
is None:
395 session_tools = session.stats.tool_call_count
if hasattr(session,
"stats")
else 0
400 granted = active_session_grant()
406 _cost = getattr(session,
"stats",
None)
407 _cost = getattr(_cost,
"cost",
None)
408 _priced = getattr(_cost,
"priced_usd", 0.0)
if _cost
is not None else 0.0
409 _unpriced = getattr(_cost,
"unpriced_samples", 0)
if _cost
is not None else 0
410 return turn_stats.build_status(
412 session_name=session.name,
413 effort=getattr(session,
"reasoning_effort",
"high"),
414 session_tools=session_tools,
415 session_grant=granted.name
if granted
is not None else None,
416 session_cost_usd=(_priced
if (_priced
or _unpriced)
else None),
417 unpriced_samples=_unpriced,
418 checkpoint_auto=tool_calls_auto_continue(),
421 presenter.bind_status(_status_provider)
425NOT_HANDLED =
"NOT_HANDLED"
426HANDLED_NO_TURN =
"HANDLED_NO_TURN"
427HANDLED_TURN_QUEUED =
"HANDLED_TURN_QUEUED"
428INTERNAL_FAILURE =
"INTERNAL_FAILURE"
432EFFECT_COMMITTED =
"COMMITTED"
433EFFECT_UNKNOWN =
"UNKNOWN"
440INSTRUMENTED_VERBS = frozenset({
"/goal",
"/plan"})
443@dataclass(frozen=True)
445 """What ``dispatch`` observed, as a typed value rather than a bool.
447 ``kind`` is one of the four constants above. ``HANDLED_TURN_QUEUED`` is
448 produced ONLY by a handler explicitly returning a ``queued_turn_token`` — it
449 is NEVER derived from a message-count delta. That heuristic looked
450 equivalent but was not: an in-place ``/session load`` whose restored log ends
451 in a user message would have fired an unrequested LLM turn, and ``/context``
452 (a fourth appender) would have fired one on its own instead of letting its
453 injected content ride into the next user-initiated turn.
455 ``effect_disposition`` says what a failure left behind: ``NONE`` (the handler
456 guarantees no mutation ran), ``COMMITTED`` (honour it — never compensate it
457 away), or ``UNKNOWN`` (fail closed and report a reconciliation path).
459 Truthiness matches the old ``bool`` return, so existing ``if handled:``
460 call sites and tests keep working.
464 effect_disposition: str = EFFECT_NONE
465 queued_turn_token: object =
None
469 """``True`` when the input was consumed as a command."""
470 return self.kind != NOT_HANDLED
473def dispatch(ctx:
"Context", raw_input: str) -> DispatchResult:
474 """Dispatch a ``/command``. Returns a typed :class:`DispatchResult`.
476 The backstop below is scoped to :data:`INSTRUMENTED_VERBS` ONLY. It is a
477 LAST-RESORT net for a handler that raised past its own handler-local
478 containment: it classifies nothing and fails closed with
479 ``effect_disposition=UNKNOWN``. An exception escaping any OTHER handler
480 propagates exactly as it does today — no new containment, no behaviour
481 change. ``SystemExit`` is never caught.
484 parts = raw_input.split()
487 verb = parts[0].lower()
488 _dbg(
"OC_CHAT.MAIN", func, f
"verb={verb}", 2)
489 handler = COMMANDS.get(verb)
491 if verb
in INSTRUMENTED_VERBS:
493 outcome = handler(ctx, raw_input)
494 except Exception
as exc:
495 _dbg(
"OC_CHAT.MAIN", func,
496 f
"SYSTEM_ERROR: {verb} raised past its own containment: "
497 f
"{type(exc).__name__}: {exc}", 1, override=
True)
498 ctx.presenter.show_error(
499 f
"{verb} failed unexpectedly: {type(exc).__name__}: {exc}. "
500 f
"Its effects are INDETERMINATE — check /plan --list and the "
501 f
"session state before retrying."
504 kind=INTERNAL_FAILURE, effect_disposition=EFFECT_UNKNOWN,
506 if isinstance(outcome, DispatchResult):
509 handler(ctx, raw_input)
511 if verb.startswith(
"/"):
512 ctx.presenter.show_error(
513 f
"Unknown command: {verb}. Type /help for available commands."
523 """List all known models and their capabilities."""
524 added = ctx.model_manager.fetch_live_if_needed(ctx.client)
526 ctx.presenter.show_info(f
"Loaded {added} new model(s) from API.")
527 models = ctx.model_manager.known_models()
528 ctx.presenter.show_available_models(models)
532 """Print a dimmed warning if *model_name* does not support tool use."""
533 caps = ctx.model_manager.capabilities(model_name)
534 if not caps.get(
"supports_tools",
True):
536 print(f
"\n{STYLE_DIM}[Note: {model_name} does not support tool use — agent tools are disabled for this model]{STYLE_RESET}")
540 """Plan 134 N5: re-bind the session so a context-aware presenter (enhanced)
541 refreshes its ctx-gauge window for the NEW model. bind_session is idempotent
542 and safe; presenters without it (plain console) are skipped.
544 bind = getattr(ctx.presenter,
"bind_session",
None)
550 """Switch the session model to whatever the active provider maps the
551 given semantic tier to. If the provider has no model for that tier,
552 surface a friendly message and leave the session model unchanged.
554 target = ctx.model_manager.provider.tier(tier_name)
556 ctx.presenter.show_info(
557 f
"Active provider has no '{tier_name}' tier — command unavailable."
560 ctx.session.set_model(target)
561 ctx.model_manager.ensure(target)
563 ctx.presenter.show_info(f
"Switched to: {target} (conversation preserved)")
569 """Switch to the active provider's reasoning-tier model."""
575 """Switch to the active provider's cheap-tier model."""
579@command("/model", "/use")
581 """Switch to a model specified by name."""
582 parts = raw.split(maxsplit=1)
584 ctx.presenter.show_info(
"Usage: /model <model_name>")
586 name = parts[1].strip()
587 if name
not in ctx.model_manager.known_models():
589 ctx.model_manager.fetch_live_if_needed(ctx.client)
590 if name
not in ctx.model_manager.known_models():
591 ctx.presenter.show_info(f
"Unknown model: {name}. Use /models to list available.")
593 ctx.session.set_model(name)
595 ctx.presenter.show_info(f
"Switched to: {name} (conversation preserved)")
600 """Level-one rows for the live registry (plan 217 T4)."""
603 return lane_options_from_registry()
607 """Switch *ctx* onto *lane_name*; report and return ``False`` on refusal.
609 Every failure mode surfaces as a message rather than a traceback: an
610 unset key names the variable and points at the wizard (P3), and an
611 unknown lane lists what is available.
613 func =
"_apply_lane_selection"
617 prepared = switch_lane(ctx, lane_name, model_id)
618 except LaneKeyMissingError
as exc:
621 _dbg(
"OC_CHAT.MAIN", func, f
"refused lane {lane_name!r}: no key", 2)
622 ctx.presenter.show_error(f
" {exc}")
624 except KeyError
as exc:
626 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {exc}", 3)
627 ctx.presenter.show_error(f
" {exc}")
629 except (PermissionError, OSError, ValueError)
as exc:
631 _dbg(
"OC_CHAT.MAIN", func,
632 f
"EXTERNAL_ERROR: {type(exc).__name__}: {exc}", 2, override=
True)
633 ctx.presenter.show_error(f
" Could not switch to {lane_name!r}: {exc}")
637 ctx.presenter.show_info(
638 f
"Switched to lane {prepared.lane_name} "
639 f
"(model: {prepared.model_id}, conversation preserved)"
647 """Choose a provider lane and model, or switch to a named lane.
649 /lane — open the drill-down picker (lane, free/paid, family, model)
650 /lane list — list every registered lane with its key state
651 /lane <name> — switch straight to a lane, on its default model
653 The picker mutates nothing until its final confirmation, and stepping
654 back off the first level leaves the session exactly as it was
659 parts = raw.split(maxsplit=1)
660 argument = parts[1].strip()
if len(parts) > 1
else ""
662 if argument.lower()
in (
"list",
"ls"):
663 lines = [
"Provider lanes:"]
665 state =
"ready" if option.has_key
else "no key"
666 key_note = option.api_key_env_var
or "(no key needed)"
668 f
" {option.name:<18}{option.display_label:<30}"
669 f
"{option.badge:<5} {len(option.models):>3} curated "
670 f
"{state} [{key_note}]"
672 ctx.presenter.show_info(
"\n".join(lines))
679 selection = run_lane_picker(
680 ctx.presenter, prompt_driver=getattr(ctx,
"prompt_driver",
None)
682 if selection
is None:
683 ctx.presenter.show_info(
"Lane unchanged.")
689 """Render provider callbacks through the existing terminal seams."""
695 self.
ctx.presenter.show_info(url)
698 driver = getattr(self.
ctx,
'prompt_driver',
None)
699 if driver
is None or getattr(driver,
'using_prompt_toolkit',
None)
is False:
700 from getpass
import getpass
701 value = getpass(prompt)
702 elif getattr(driver,
'using_prompt_toolkit',
None)
is True:
703 from prompt_toolkit.history
import DummyHistory
704 terminal = driver._pt
705 previous = (terminal.history, terminal.default_buffer.history,
706 terminal.is_password, terminal.completer, terminal.auto_suggest)
707 terminal.history = terminal.default_buffer.history = DummyHistory()
708 terminal.is_password =
True
709 terminal.completer = terminal.auto_suggest =
None
711 value = driver.read_user_input(
None, prompt_text=prompt)
714 terminal.default_buffer.reset()
715 (terminal.history, terminal.default_buffer.history,
716 terminal.is_password, terminal.completer, terminal.auto_suggest) = previous
718 value = driver.read_user_input(
None, prompt_text=prompt)
719 if not value
or not value.strip():
725 """Select exactly one known lane without echoing untrusted arguments."""
729 ctx.presenter.show_error(
'Use /login <lane> or /logout <lane>.')
732 return registry.get(parts[1])
734 ctx.presenter.show_error(
'Unknown provider lane.')
740 """Run paste-only provider login and atomically store its credential."""
745 capability = getattr(provider,
'oauth',
None)
746 if capability
is None:
747 ctx.presenter.show_error(
'This lane does not support OAuth login.')
751 write_credentials(provider.name, credentials)
752 except (EOFError, KeyboardInterrupt):
753 ctx.presenter.show_info(
'Login cancelled.')
757 ctx.presenter.show_error(
'Login failed; no new credential was saved.')
759 ctx.presenter.show_info(f
'Signed in to {provider.name}. Use /lane {provider.name} to select it.')
764 """Remove only the selected lane from the OAuth sidecar."""
770 write_credentials(provider.name,
None)
772 ctx.presenter.show_error(
'Logout failed; check the OAuth sidecar.')
774 ctx.presenter.show_info(
'Stored OAuth credential removed. Existing clients keep their current key until rebuilt.')
777@command("/model-info")
779 """Show detailed metadata for a model from the provider.
781 /model-info — info for the current model
782 /model-info <name> — info for any named model
784 parts = raw.split(
None, 1)
785 model = parts[1].strip()
if len(parts) > 1
else ctx.session.model
786 info = ctx.client.provider.model_info(model, ctx.client._client)
788 ctx.presenter.show_info(
789 f
"Provider {ctx.client.provider.name!r} does not support model info."
793 f
"Name: {info.name}",
794 f
"Family: {info.family or '—'}",
795 f
"Parameters: {info.parameter_count or '—'}",
796 f
"Quantization: {info.quantization or '—'}",
797 f
"Context window: {info.context_window or '—'}",
799 ctx.presenter.show_info(
"\n".join(lines))
804 """Show or set the reasoning effort level."""
807 ctx.presenter.show_info(
808 f
"Reasoning effort: {ctx.session.reasoning_effort} "
809 f
"(options: {', '.join(REASONING_EFFORT_LEVELS)})"
812 level = parts[1].lower()
813 if level
in REASONING_EFFORT_LEVELS:
814 ctx.session.reasoning_effort = level
815 ctx.session.dirty =
True
816 ctx.presenter.show_info(f
"Reasoning effort set to: {level}")
818 ctx.presenter.show_info(f
"Invalid level. Choose from: {', '.join(REASONING_EFFORT_LEVELS)}")
827 """Show, list, or switch the active provider endpoint (Phase O2).
829 /endpoint — show the current endpoint
830 /endpoint list — list every known endpoint with LAN policy
831 /endpoint use <name> — switch the active endpoint and invalidate caches
833 registry = getattr(ctx,
"endpoint_registry",
None)
834 if registry
is None or not registry.entries:
835 ctx.presenter.show_info(
837 "No endpoint registry configured for the current provider. "
838 "Set OLLAMA_BASE_URLS or populate ~/.oc_chat/endpoints.toml."
842 parts = raw.split(maxsplit=2)
843 sub = parts[1].strip().lower()
if len(parts) > 1
else ""
845 if sub ==
"" or sub ==
"show":
846 active = registry.active()
847 ctx.presenter.show_info(
848 f
"Active endpoint: {active.name} -> {active.base_url} [{active.lan_policy_status}]"
853 lines = [
"Endpoints:"]
854 active_name = registry.active_name
855 for name
in sorted(registry.entries):
856 ep = registry.entries[name]
857 marker =
"*" if name == active_name
else " "
858 lines.append(f
" {marker} {name:<16} {ep.base_url} [{ep.lan_policy_status}]")
859 ctx.presenter.show_info(
"\n".join(lines))
863 if len(parts) < 3
or not parts[2].strip():
864 ctx.presenter.show_info(
"Usage: /endpoint use <name>")
866 target = parts[2].strip()
879 prepared = prepare_endpoint_activation(ctx, target)
880 except KeyError
as exc:
881 ctx.presenter.show_error(str(exc))
883 except PermissionError
as exc:
884 ctx.presenter.show_error(str(exc))
886 commit_endpoint_activation(ctx, prepared)
887 new_active = prepared.endpoint
888 ctx.presenter.show_info(
889 f
"Switched to endpoint '{new_active.name}' ({new_active.base_url})"
893 ctx.presenter.show_info(
894 "Usage: /endpoint | /endpoint list | /endpoint use <name>"
907 """Show or set the system prompt."""
908 parts = raw.split(maxsplit=1)
910 ctx.presenter.show_info(f
"System prompt: {ctx.session.system_prompt}")
912 ctx.session.set_system_prompt(parts[1].strip())
913 ctx.presenter.show_info(
"System prompt updated.")
919def cmd_ls(ctx:
"Context", raw: str) ->
None:
920 """List files and directories (supports -r for recursive)."""
922 tokens = raw.split()[1:]
923 recursive =
"--recursive" in tokens
or "-r" in tokens
924 path_parts = [t
for t
in tokens
if not t.startswith(
"-")]
925 path = path_parts[0]
if path_parts
else "."
926 max_depth = 5
if recursive
else 0
929 result = list_dir(path=path, max_depth=max_depth)
930 for dir_key, contents
in sorted(result.items()):
931 print(f
"\n{dir_key}/")
932 for d
in contents[
"dirs"]:
934 for f
in contents[
"files"]:
936 except Exception
as e:
937 ctx.presenter.show_error(str(e))
942 """Print a file's contents to the terminal."""
944 parts = raw.split(maxsplit=1)
946 ctx.presenter.show_info(
"Usage: /read <file_path>")
948 result = read_file(parts[1].strip())
949 if "error" in result:
950 ctx.presenter.show_error(result[
"error"])
952 print(result[
"content"])
953 if result.get(
"truncated"):
954 ctx.presenter.show_info(
"[File truncated at limit]")
959 """Print the most-recently-spilled tool artifact for the current session.
961 Phase 1.C ships only ``/open last`` — useful after a long
962 ``read_file`` / ``run_command`` was spilled to a file in
963 ``oc_chat/sessions/<name>/artifacts/``.
965 parts = raw.split(maxsplit=1)
966 target = parts[1].strip()
if len(parts) > 1
else "last"
968 if target.lower() !=
"last":
969 ctx.presenter.show_info(
970 f
"Only '/open last' is supported in this phase (got: {target!r})."
975 path = find_latest_artifact(ctx.session.name)
977 ctx.presenter.show_info(
978 f
"No tool artifacts found for session '{ctx.session.name}'."
983 text = path.read_text(encoding=
"utf-8")
984 except OSError
as exc:
985 ctx.presenter.show_error(f
"Could not read {path}: {exc}")
988 ctx.presenter.show_info(f
"Artifact: {path}")
994 """Page the latest answer, raw answer, reasoning text, or tool result."""
996 parts = raw.split(maxsplit=1)
997 variant = parts[1].strip().lower()
if len(parts) > 1
else "answer"
1001 messages = getattr(ctx.session,
"messages", [])
or []
1006 if variant ==
"answer":
1010 elif variant ==
"raw":
1013 elif variant ==
"reasoning":
1018 "reasoning_content",
1024 elif variant ==
"tool":
1025 kind =
"tool result"
1029 _dbg(
"OC_CHAT.MAIN", func, f
"unknown variant={variant}", 3)
1030 ctx.presenter.show_error(
"Usage: /last [answer|raw|reasoning|tool]")
1033 if not content.strip():
1034 ctx.presenter.show_info(f
"No {kind} available yet this session.")
1042 """Inject a file's content into the conversation as a user message."""
1044 parts = raw.split(maxsplit=1)
1046 ctx.presenter.show_info(
"Usage: /context <file_path>")
1048 path = parts[1].strip()
1049 result = read_file(path)
1050 if "error" in result:
1051 ctx.presenter.show_error(result[
"error"])
1053 content = result[
"content"]
1054 ctx.session.messages.append({
1056 "content": f
"File context — {path}:\n\n{content}",
1058 ctx.session.dirty =
True
1059 ctx.presenter.show_info(f
"Injected {path} into conversation ({len(content)} chars).")
1060 if result.get(
"truncated"):
1061 ctx.presenter.show_info(
"[File was truncated at read limit]")
1067 """Format a token count with commas, switching to ``M`` for millions."""
1069 return f
"{n/1_000_000:.1f}M"
1074 """Return token string, or ``'--'`` when tokens were never tracked for a session that has messages."""
1075 if stats.total_tokens == 0
and stats.message_count > 0:
1083 """Sync the active session sandbox override into the sandbox helper."""
1084 override = getattr(ctx,
"sandbox_root_override",
None)
1085 if override
is None:
1086 override = getattr(ctx.session,
"sandbox_root_override",
None)
1087 ctx.sandbox_root_override = override
1089 set_session_sandbox_root_override(override)
1093 """Return True when cwd resolves under *root*."""
1094 from pathlib
import Path
1095 cwd = Path.cwd().resolve(strict=
False)
1097 cwd.relative_to(Path(root).resolve(strict=
False))
1105 """Show or set the current session's filesystem sandbox root."""
1106 from pathlib
import Path
1110 parts = raw.split(maxsplit=2)
1111 sub = parts[1].lower()
if len(parts) > 1
else ""
1113 if sub
in {
"",
"show",
"current"}:
1115 root, source = filesystem._sandbox_root_info()
1117 ctx.presenter.show_info(
1118 f
"Sandbox root: {root}\nSource: {source}\nCWD inside sandbox: {inside}"
1123 if len(parts) < 3
or not parts[2].strip():
1124 ctx.presenter.show_info(
"Usage: /sandbox set <existing-directory>")
1126 candidate = Path(parts[2].strip().strip(
'"').strip(
"'")).expanduser()
1127 root = candidate.resolve(strict=
False)
1128 if not root.is_dir():
1129 ctx.presenter.show_error(f
"Sandbox root does not exist or is not a directory: {candidate}")
1136 check_deployment_shape(root, resolve_risk_profile())
1137 except DeploymentShapeError
as exc:
1138 ctx.presenter.show_error(f
"Refused: {exc}")
1140 override = str(root)
1141 ctx.sandbox_root_override = override
1142 ctx.session.sandbox_root_override = override
1143 ctx.session.dirty =
True
1144 set_session_sandbox_root_override(override)
1145 ctx.presenter.show_info(f
"Sandbox root set to {root}")
1148 if sub
in {
"unset",
"reset"}:
1149 ctx.sandbox_root_override =
None
1150 ctx.session.sandbox_root_override =
None
1151 ctx.session.dirty =
True
1152 set_session_sandbox_root_override(
None)
1153 root, source = filesystem._sandbox_root_info()
1154 ctx.presenter.show_info(f
"Sandbox override cleared. Effective root: {root} ({source})")
1157 ctx.presenter.show_info(
"Usage: /sandbox [set <existing-directory>|unset]")
1162 """Show or revoke the session-long Tier-2 python grant (plan 235 / SHELL-02).
1164 /grants — print the granted directory, or "none"
1165 /grants list — alias for the above
1166 /grants revoke — drop the grant; the next python run asks the human again
1168 The grant is what the third key at the Tier-2 confirm prompt records: for the
1169 rest of the session, python scripts resolving inside that one directory run
1170 without a keystroke. It is process-lifetime state — never persisted — and it
1171 also drops on ``/session clear`` and on a session load/switch.
1175 parts = raw.split(maxsplit=1)
1176 sub = parts[1].strip().lower()
if len(parts) > 1
else ""
1178 if sub
in {
"",
"list",
"show"}:
1179 active = active_session_grant()
1180 ctx.presenter.show_info(
1181 f
"Session Tier-2 python grant: {active}\n"
1182 " Any .py file the agent writes there runs without asking. "
1183 "Drop it with /grants revoke."
1184 if active
else "Session Tier-2 python grant: none"
1189 active = active_session_grant()
1190 revoke_session_grants()
1191 ctx.presenter.show_info(
1192 f
"Revoked the session Tier-2 python grant on {active}."
1193 if active
else "No session Tier-2 python grant to revoke."
1197 ctx.presenter.show_info(
"Usage: /grants [list|revoke]")
1202 """Show or set this session's confirmation mode (plan 283 / SHELL-04).
1204 /mode — print the mode and every remembered answer
1205 /mode ask — every confirmable call reaches the human (the default)
1206 /mode auto — the routine asks are answered without a keystroke
1207 /mode forget — drop the remembered answers, so the human is asked again
1209 This command, the ``--auto`` launch flag and the ``u`` key at the Tier-2 ask
1210 are the ONLY ways in. No tool, no model output, no settings file and no
1211 environment variable can set the mode — which is the point of it: it is the
1212 human's switch, not the agent's. Auto mode never answers a protected write,
1213 never turns an argv-policy denial into a run, and is inert outside the
1214 ``permissive`` risk profile.
1219 parts = raw.split(maxsplit=1)
1220 sub = parts[1].strip().lower()
if len(parts) > 1
else ""
1222 if sub
in {
"",
"list",
"show"}:
1223 remembered = session_mode.remembered_programs()
1224 lines = [f
"Confirmation mode: {session_mode.current_mode()}"]
1225 if session_mode.current_mode() == session_mode.AUTO
and not session_mode.auto_mode_available():
1226 lines.append(
" (inert here: Auto mode applies only under the "
1227 "permissive risk profile)")
1229 lines.append(f
" Remembered answers ({len(remembered)}), "
1230 f
"drop them with /mode forget:")
1231 for program, answer
in sorted(remembered.items()):
1232 verdict = (f
"always run (budget {answer.budget_s} s)"
1233 if answer.run
else "never run")
1234 lines.append(f
" {program}: {verdict}")
1236 lines.append(
" Remembered answers: none")
1237 ctx.presenter.show_info(
"\n".join(lines))
1241 dropped = session_mode.forget_remembered()
1242 ctx.presenter.show_info(
1243 f
"Dropped {dropped} remembered answer(s); the next Tier-2 command "
1244 f
"asks again." if dropped
else "No remembered answers to drop.")
1247 if sub
in session_mode.VALID_MODES:
1248 if sub == session_mode.AUTO
and not session_mode.auto_mode_available():
1252 ctx.presenter.show_error(
1254 "Auto mode applies only under the permissive risk profile. "
1255 "The active profile is set out of band by OC_CHAT_PROFILE and "
1256 "cannot be changed from here; the mode is unchanged.")
1257 _dbg(
"OC_CHAT.MAIN", func,
"refused /mode auto off permissive", 2)
1259 session_mode.set_mode(sub)
1260 ctx.presenter.show_info(
1261 f
"Confirmation mode: {sub}."
1262 + (
" The routine asks are answered without a keystroke; a "
1263 "protected write still always reaches you. /mode ask switches back."
1264 if sub == session_mode.AUTO
else
1265 " Every confirmable call reaches you again.")
1269 ctx.presenter.show_info(
"Usage: /mode [ask|auto|forget]")
1274 """Re-anchor this oc_chat to a new working directory.
1276 /cwd — print current anchors (cwd, sandbox root, source label)
1277 /cwd <path> — re-anchor to <path>, which must be an existing directory
1279 Uses the 152 entry lease (transition_entry_lease) so a mid-turn or
1280 reentrant call is rejected with zero effects. Commits chdir + sandbox
1281 override (both mirrors) + dirty flag, then refreshes variable context.
1284 from pathlib
import Path
1288 TransitionRejectedError, transition_entry_lease,
1292 parts = raw.split(maxsplit=1)
1294 if len(parts) == 1
or not parts[1].strip():
1297 root, source = filesystem._sandbox_root_info()
1298 ctx.presenter.show_info(
1299 f
"CWD: {Path.cwd()}\nSandbox root: {root}\nSource: {source}"
1304 candidate = Path(parts[1].strip().strip(
'"').strip(
"'")).expanduser()
1305 resolved = candidate.resolve(strict=
False)
1307 if not resolved.is_dir():
1308 ctx.presenter.show_error(
1309 f
"/cwd: {candidate} does not exist or is not a directory"
1314 original_cwd = _os.getcwd()
1316 with transition_entry_lease():
1318 override = str(resolved)
1319 ctx.sandbox_root_override = override
1320 ctx.session.sandbox_root_override = override
1321 ctx.session.dirty =
True
1322 _sandbox.install_resolved(resolved)
1330 _os.environ[
"OC_CHAT_SANDBOX_ROOT"] = override
1333 refresh_variable_context(ctx.session)
1335 root, source = filesystem._sandbox_root_info()
1336 ctx.presenter.show_info(
1337 f
"re-anchored \u2192 {resolved} (sandbox: {source})"
1339 except TransitionRejectedError
as exc:
1340 ctx.presenter.show_error(f
"/cwd rejected: {exc}")
1341 except OSError
as exc:
1344 _os.chdir(original_cwd)
1347 ctx.presenter.show_error(f
"/cwd: cannot change to {resolved}: {exc}")
1351 """Print help for ``/session`` subcommands."""
1352 print(
"\n/session subcommands:")
1353 print(
" /session new [name] - start a new named session")
1354 print(
" /session list - list all saved sessions")
1355 print(
" /session current - show current session info")
1356 print(
" /session save [name] - save current session")
1357 print(
" /session load <name> - load a saved session (auto-saves current)")
1358 print(
" /session export [--html] [file] - export current session as Markdown (default) or HTML")
1359 print(
" /session switch <name> - switch to another session (auto-saves current)")
1360 print(
" /session clear - clear conversation history with verification & backup options")
1366 """Dispatch ``/session <subcommand> [...]``."""
1368 save_chat_log, save_session_metadata, load_session_metadata,
1369 export_markdown, list_sessions, session_exists, DEFAULT_SESSIONS_DIR,
1375 build_system_prompt,
1378 parts = raw.split(maxsplit=2)
1379 sub = parts[1].lower()
if len(parts) > 1
else ""
1383 name = parts[2].strip()
if len(parts) > 2
else _auto_name()
1384 if session_exists(name):
1385 ctx.presenter.show_error(
1386 f
"Session '{name}' already exists. "
1387 f
"Use /session switch {name} to resume it, or pick a different name."
1391 if ctx.session.dirty:
1392 save_chat_log(ctx.session)
1393 save_session_metadata(ctx.session)
1394 ctx.presenter.show_info(f
"Auto-saved current session.")
1396 system_text = build_system_prompt(CANONICAL_POLICY, ctx.tool_schemas)
1397 new_session = ChatSession(
1399 messages=[{
"role":
"system",
"content": system_text}],
1401 new_session.created = name.split(
"_", 1)[1]
if "_" in name
else "unknown"
1409 replace_session(ctx, new_session)
1413 ctx.presenter.show_info(f
"New session: {name} (model: {new_session.model})")
1418 sessions = list_sessions()
1419 ctx.presenter.show_session_list(sessions, current_name=ctx.session.name)
1423 if sub ==
"current":
1425 status =
"(unsaved changes)" if s.dirty
else "(clean)"
1426 if s.filepath
is None and not s.dirty:
1427 status =
"(never saved)"
1428 print(f
"\nSession: {s.name}")
1429 print(f
"Model: {s.model}")
1430 print(f
"Reasoning effort: {s.reasoning_effort}")
1431 print(f
"Messages: {s.stats.message_count} {status}")
1432 print(f
"Tool calls: {s.stats.tool_call_count}")
1433 if s.stats.total_tokens == 0
and s.stats.message_count > 0:
1434 print(
"Token usage: -- (not tracked for this session)")
1436 print(f
"Token usage: prompt={_fmt_tokens(s.stats.total_prompt_tokens)}, "
1437 f
"completion={_fmt_tokens(s.stats.total_completion_tokens)}, "
1438 f
"total={_fmt_tokens(s.stats.total_tokens)}")
1440 print(f
"Chat logs ({len(s.chat_logs)}):")
1441 for cl
in s.chat_logs:
1444 print(f
"Metadata file: {s.filepath}")
1450 name = parts[2].strip()
if len(parts) > 2
else None
1451 if name
and name != ctx.session.name
and session_exists(name):
1452 ctx.presenter.show_error(
1453 f
"Session '{name}' already exists. Use a different name."
1462 log_path, meta_path = rename_session(ctx, name
or ctx.session.name)
1463 ctx.presenter.show_info(f
"Saved chat log to {log_path}")
1464 ctx.presenter.show_info(f
"Saved metadata to {meta_path}")
1470 ctx.presenter.show_info(
"Usage: /session load <name>")
1472 name = parts[2].strip()
1473 if not session_exists(name):
1474 ctx.presenter.show_error(f
"No session named '{name}' found in sessions directory.")
1485 outcome = load_session_into(ctx, name)
1486 except (ChatLogLoadError, TransitionRejectedError)
as e:
1487 ctx.presenter.show_error(str(e))
1489 except Exception
as e:
1490 ctx.presenter.show_error(str(e))
1492 if outcome.outgoing_saved:
1493 ctx.presenter.show_info(
"Auto-saved current session.")
1494 if outcome.migration_notice:
1495 ctx.presenter.show_info(outcome.migration_notice)
1496 ctx.presenter.show_info(
1497 f
"Loaded session '{ctx.session.name}' — "
1498 f
"{ctx.session.stats.message_count} messages, "
1499 f
"{ctx.session.stats.tool_call_count} tool calls, "
1500 f
"{_fmt_tokens(ctx.session.stats.total_tokens)} tokens "
1501 f
"(model: {ctx.session.model})"
1508 rest = parts[2].strip()
if len(parts) > 2
else ""
1509 tokens = rest.split()
1510 as_html =
"--html" in tokens
1511 filename_tokens = [t
for t
in tokens
if not t.startswith(
"--")]
1512 filename = filename_tokens[0]
if filename_tokens
else None
1516 path = export_html(ctx.session, filepath=filename)
1518 path = export_markdown(ctx.session, filepath=filename)
1519 ctx.presenter.show_info(f
"Exported to {path}")
1520 except ImportError
as e:
1521 ctx.presenter.show_error(
1522 f
"--html requires the optional `rich` dependency: {e}. "
1523 f
"Install with: pip install -e .[rich]"
1525 except Exception
as e:
1526 ctx.presenter.show_error(str(e))
1532 ctx.presenter.show_info(
"Usage: /session switch <name>")
1534 name = parts[2].strip()
1535 if name == ctx.session.name:
1536 ctx.presenter.show_info(f
"Already on session '{name}'.")
1538 if not session_exists(name):
1539 ctx.presenter.show_error(f
"No session named '{name}' found in sessions directory.")
1547 outcome = load_session_into(ctx, name)
1548 except (ChatLogLoadError, TransitionRejectedError)
as e:
1549 ctx.presenter.show_error(str(e))
1551 except Exception
as e:
1552 ctx.presenter.show_error(str(e))
1554 if outcome.outgoing_saved:
1555 ctx.presenter.show_info(
"Auto-saved current session.")
1556 if outcome.migration_notice:
1557 ctx.presenter.show_info(outcome.migration_notice)
1558 ctx.presenter.show_info(
1559 f
"Switched to session: {ctx.session.name} — "
1560 f
"{ctx.session.stats.message_count} messages, "
1561 f
"{ctx.session.stats.tool_call_count} tool calls, "
1562 f
"{_fmt_tokens(ctx.session.stats.total_tokens)} tokens "
1563 f
"(model: {ctx.session.model})"
1569 msg_count = ctx.session.stats.message_count
1570 print(f
"\nSession '{ctx.session.name}' has {msg_count} message(s).")
1571 print(
"What would you like to do before clearing?")
1573 print(
" 1) Save an archived backup, then clear")
1574 print(
" 2) Save as a new session & switch to a fresh one")
1575 print(
" 3) Clear without saving")
1578 choice = input(
"\nChoice (1-4): ").strip()
1585 save_chat_log(ctx.session)
1586 save_session_metadata(ctx.session)
1588 save_chat_log(ctx.session)
1589 save_session_metadata(ctx.session)
1590 ctx.presenter.show_info(
1591 "Conversation history cleared. Pre-clear snapshot is in "
1592 f
"oc_chat/sessions/archive/{ctx.session.name}/."
1597 save_chat_log(ctx.session)
1598 save_session_metadata(ctx.session)
1599 ctx.presenter.show_info(f
"Current session saved.")
1600 new_name = _auto_name()
1601 system_text = build_system_prompt(CANONICAL_POLICY, ctx.tool_schemas)
1602 new_session = ChatSession(
1604 messages=[{
"role":
"system",
"content": system_text}],
1606 new_session.created = new_name.split(
"_", 1)[1]
if "_" in new_name
else "unknown"
1608 replace_session(ctx, new_session)
1609 ctx.presenter.show_info(f
"Started new session: {new_name} (model: {new_session.model})")
1613 confirm = input(
"Clearing will permanently delete all messages. Are you sure? (y/N): ").strip().lower()
1614 if confirm
in (
"y",
"yes"):
1616 ctx.presenter.show_info(
"Conversation history cleared.")
1618 ctx.presenter.show_info(
"Clear cancelled.")
1621 ctx.presenter.show_info(
"Clear cancelled.")
1633UI_REASONING_VALUES = (
"show",
"dim",
"hide")
1634UI_REASONING_DEFAULT =
"dim"
1638UI_REASONING_TAIL_DEFAULT = default(
"ui_reasoning_tail")
1642 """Print help for /ui subcommands."""
1643 print(
"\n/ui subcommands:")
1644 print(
" /ui reasoning [show|dim [N]|hide] - control how reasoning / chain-of-thought is rendered")
1645 print(f
" ('dim' shows the last N lines, default {UI_REASONING_TAIL_DEFAULT};")
1646 print(
" takes effect with --ui rich; persisted regardless)")
1648 print(
" Future phases will add: /ui theme <name>, /ui stats <compact|verbose|off>,")
1649 print(
" /ui set <key> <value>")
1654 """Read ``meta.json[ui][<key>]`` from a live session (None-safe)."""
1655 ui = getattr(session,
"ui_prefs",
None)
1658 return ui.get(key, default)
1662 """Write ``meta.json[ui][<key>]`` and mark session dirty so it persists on save."""
1663 if not hasattr(session,
"ui_prefs")
or session.ui_prefs
is None:
1664 session.ui_prefs = {}
1665 session.ui_prefs[key] = value
1666 session.dirty =
True
1671 """Dispatch ``/ui <subcommand> [args...]``.
1673 Phase 0 implements only the ``reasoning`` subcommand. The dispatcher
1674 is structured to mirror :func:`cmd_session` so Phases 1+ can add
1675 ``theme``, ``stats``, ``set``, etc. as one-clause additions.
1677 parts = raw.split(maxsplit=2)
1678 sub = parts[1].lower()
if len(parts) > 1
else ""
1684 if sub ==
"reasoning":
1686 current =
_read_ui_pref(ctx.session,
"show_reasoning", UI_REASONING_DEFAULT)
1687 tail =
_read_ui_pref(ctx.session,
"reasoning_tail", UI_REASONING_TAIL_DEFAULT)
1688 ctx.presenter.show_info(
1689 f
"reasoning: {current} (dim tail: {tail} lines) "
1690 f
"(options: {', '.join(UI_REASONING_VALUES)})"
1695 arg_parts = parts[2].strip().split(maxsplit=1)
1696 value = arg_parts[0].lower()
1697 if value
not in UI_REASONING_VALUES:
1698 ctx.presenter.show_error(
1699 f
"Invalid value '{value}' for /ui reasoning. "
1700 f
"Choose one of: {', '.join(UI_REASONING_VALUES)}"
1704 tail_arg = arg_parts[1].strip()
if len(arg_parts) > 1
else ""
1708 ctx.presenter.show_error(
1709 f
"A tail line count is only valid with 'dim' (got '{value} {tail_arg}')."
1713 tail_n = int(tail_arg)
1715 ctx.presenter.show_error(
1716 f
"Invalid tail line count '{tail_arg}' — must be a positive integer."
1720 ctx.presenter.show_error(f
"Tail line count must be >= 1 (got {tail_n}).")
1723 tail_note = f
" (tail: {tail_n} lines)"
1726 ctx.presenter.show_info(
1727 f
"reasoning: {value}{tail_note} "
1728 f
"(persisted in session metadata; takes visible effect with --ui rich)"
1732 ctx.presenter.show_error(f
"Unknown /ui subcommand: '{sub}'")
1740 """Fuzzy-search commands and recent sessions.
1742 This is the slash-command equivalent of ``Ctrl+P`` at the prompt.
1743 If a query is provided, matching commands and session-load actions
1744 are printed. Without a query, the full list is shown.
1746 func =
"cmd_palette"
1747 parts = raw.split(maxsplit=1)
1748 query = parts[1].strip().lower()
if len(parts) > 1
else ""
1751 entries: list[tuple[str, str]] = []
1753 for verb, help_text
in COMMAND_HELP.items():
1754 display = help_text.split(
" - ", 1)[1]
if " - " in help_text
else verb
1755 entries.append((f
"{verb:<20} {display}", verb))
1760 for s
in list_sessions()[:10]:
1763 f
"/session load {name:<25} load saved session",
1764 f
"/session load {name}",
1766 except Exception
as e:
1768 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
1774 for display, action
in entries
1775 if query
in display.lower()
or query
in action.lower()
1779 ctx.presenter.show_info(
"No matching commands or sessions found.")
1782 lines = [f
"\n Command palette{' — ' + query if query else ''}:",
""]
1783 for display, _
in entries:
1784 lines.append(f
" {display}")
1787 ctx.presenter.show_info(line)
1788 ctx.presenter.show_info(
1789 "Tip: Press Ctrl+P at the prompt for interactive fuzzy search."
1796def cmd_goal(ctx:
"Context", raw: str) -> DispatchResult:
1797 """Inject an autonomous goal as a user message; main loop will run the turn.
1799 Plan 152 D7/R11-04 — containment is HANDLER-LOCAL. The mutation unit (the
1800 goal flags plus the appended message) runs CONTIGUOUSLY and BEFORE any
1801 raising call, and this handler catches its own failures while its
1802 compensation tokens are still in scope. ``dispatch`` cannot do that job: a
1803 raised handler returns nothing, so the tokens would never reach it.
1805 A post-commit REPORTING failure is NOT a command failure — the turn stays
1806 queued and runs exactly once, carrying a ``warning``. Mapping it to a
1807 no-turn failure would strand a committed queue.
1810 parts = raw.split(maxsplit=1)
1812 ctx.presenter.show_info(
"Usage: /goal <task description>")
1814 goal = parts[1].strip()
1817 prior_in_goal = ctx.in_goal
1818 prior_description = ctx.goal_description
1819 prior_files = list(ctx.goal_files_changed
or [])
1820 message_index = len(ctx.session.messages)
1825 ctx.goal_description = goal
1826 ctx.goal_files_changed = []
1827 ctx.session.add_user(
1828 f
"Please accomplish this goal using the available tools. "
1829 f
"Break it into steps, execute each step, and report results.\n\nGoal: {goal}"
1831 except Exception
as exc:
1834 _dbg(
"OC_CHAT.MAIN", func,
1835 f
"SYSTEM_ERROR: goal unit failed: {type(exc).__name__}: {exc}", 1, override=
True)
1836 ctx.in_goal = prior_in_goal
1837 ctx.goal_description = prior_description
1838 ctx.goal_files_changed = prior_files
1839 del ctx.session.messages[message_index:]
1840 ctx.presenter.show_error(f
"Could not queue the goal: {exc}")
1841 return DispatchResult(kind=INTERNAL_FAILURE, effect_disposition=EFFECT_NONE)
1846 ctx.presenter.show_info(f
"Goal queued: {goal}")
1847 except Exception
as exc:
1848 warning = f
"goal queued, but the confirmation could not be displayed: {exc}"
1849 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: {warning}", 2, override=
True)
1851 kind=HANDLED_TURN_QUEUED,
1852 effect_disposition=EFFECT_COMMITTED,
1853 queued_turn_token=message_index,
1859def cmd_plan(ctx:
"Context", raw: str) ->
"DispatchResult | None":
1860 """Plan-document management — create, pause, resume, list, etc.
1863 /plan <description> — create a new plan (alias: --new <desc>)
1864 /plan --status — show the active plan's state
1865 /plan --pause — pause the active plan
1866 /plan --continue — resume a paused plan
1867 /plan --complete — mark the active plan complete
1868 /plan --abort — abandon the active plan
1869 /plan --list — list all plans, grouped by status
1870 /plan --switch <id> — change the active plan
1872 parts = raw.split(maxsplit=1)
1873 rest = parts[1].strip()
if len(parts) > 1
else ""
1877 ctx.presenter.show_info(cmd_plan.__doc__
or "")
1882 if rest.startswith(
"--new"):
1884 desc = rest[len(
"--new"):].strip()
1886 if rest ==
"--status":
1888 if rest ==
"--pause":
1890 if rest ==
"--continue":
1892 if rest ==
"--complete":
1894 if rest ==
"--abort":
1896 if rest ==
"--list":
1898 if rest.startswith(
"--switch"):
1899 target = rest[len(
"--switch"):].strip()
1901 if rest.startswith(
"--"):
1902 ctx.presenter.show_info(
1903 "Unknown /plan flag. Run /plan without args for usage."
1911 """Create a new plan and enter plan mode."""
1912 func =
"cmd_plan_new"
1914 ctx.presenter.show_info(
"Usage: /plan <description>")
1921 if ctx.session.active_plan_id:
1922 existing = find_plan_by_id(ctx.session.active_plan_id)
1923 if existing
is not None and existing.status
not in TERMINAL_STATUSES:
1924 ctx.presenter.show_info(
1925 f
"Another plan is active in this session: "
1926 f
"{ctx.session.active_plan_id} (status: {existing.status}). "
1927 "Run /plan --complete or /plan --abort first, "
1928 "or /plan --switch to a different plan."
1933 prior_active_plan_id = ctx.session.active_plan_id
1934 prior_dirty = ctx.session.dirty
1935 prior_in_plan = ctx.in_plan
1936 prior_plan_description = ctx.plan_description
1937 prior_plan_files = list(ctx.plan_files_changed
or [])
1938 message_index = len(ctx.session.messages)
1943 plan_id, path = create_plan(desc, session_name=ctx.session.name)
1944 ctx.session.active_plan_id = plan_id
1945 ctx.session.dirty =
True
1947 ctx.plan_description = desc
1948 ctx.plan_files_changed = []
1949 ctx.session.add_user(
1951 f
"Design an implementation plan for: {desc}\n\n"
1952 f
"Follow the template at `oc_chat/plans/PLAN_TEMPLATE.md`. "
1953 f
"Read only files necessary to design the plan; "
1954 f
"**do NOT modify any source files**. "
1955 f
"Output the complete plan as a single Markdown file to "
1956 f
"`{path.as_posix()}` using `write_file`. "
1957 f
"When finished, reply with the filename only."
1959 except Exception
as exc:
1960 _dbg(
"OC_CHAT.MAIN", func,
1961 f
"SYSTEM_ERROR: plan-new unit failed: {type(exc).__name__}: {exc}",
1963 ctx.session.active_plan_id = prior_active_plan_id
1964 ctx.session.dirty = prior_dirty
1965 ctx.in_plan = prior_in_plan
1966 ctx.plan_description = prior_plan_description
1967 ctx.plan_files_changed = prior_plan_files
1968 del ctx.session.messages[message_index:]
1973 disposition = EFFECT_NONE
1978 except Exception
as abort_exc:
1979 disposition = EFFECT_UNKNOWN
1980 _dbg(
"OC_CHAT.MAIN", func,
1981 f
"SYSTEM_ERROR: could not retract orphan {plan_id}: {abort_exc}",
1983 ctx.presenter.show_error(
1984 f
"Could not create the plan: {exc}"
1985 + (
"" if disposition == EFFECT_NONE
else
1986 f
" The orphaned record {plan_id} could NOT be retracted — its state is "
1987 f
"indeterminate. Check /plan --list and clear it with /plan --abort "
1988 f
"before creating another plan.")
1990 return DispatchResult(kind=INTERNAL_FAILURE, effect_disposition=disposition)
1994 ctx.presenter.show_info(f
"Plan queued: {plan_id} ({path.as_posix()})")
1995 except Exception
as exc:
1996 warning = f
"plan {plan_id} created, but the confirmation could not be displayed: {exc}"
1997 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: {warning}", 2, override=
True)
1998 _dbg(
"OC_CHAT.MAIN", func, f
"created {plan_id!r}", 2)
2000 kind=HANDLED_TURN_QUEUED,
2001 effect_disposition=EFFECT_COMMITTED,
2002 queued_turn_token=message_index,
2008 """Count `- [ ]` (open) and `- [x]` (done) lines in a plan markdown file.
2010 Returns ``(done, open)``; returns ``(0, 0)`` if the file is missing
2014 content = path.read_text(encoding=
"utf-8")
2015 except (OSError, UnicodeDecodeError):
2019 for line
in content.splitlines():
2020 stripped = line.lstrip()
2021 if stripped.startswith(
"- [ ]"):
2023 elif stripped.startswith(
"- [x]")
or stripped.startswith(
"- [X]"):
2025 return (done, open_)
2029 """Resolve session.active_plan_id to a PlanRecord; warn if missing.
2031 Returns ``None`` and prints a presenter message in the no-active-plan
2032 case, so callers can `if rec is None: return` cleanly.
2036 pid = ctx.session.active_plan_id
2038 ctx.presenter.show_info(
"No active plan in this session.")
2040 rec = find_plan_by_id(pid)
2042 ctx.presenter.show_info(
2043 f
"Inventory has no record for active plan {pid!r}. "
2044 "Run /plan --list to see existing plans."
2051 """Show the active plan's filename, state, description, timestamps,
2052 and checkbox tally."""
2053 from pathlib
import Path
2061 expected = _expected_filename(rec.plan_id, rec.status)
2062 path = DEFAULT_PLANS_DIR / expected
2065 tally = f
"{done}/{done + open_} steps complete"
2067 tally =
"file missing"
2070 f
"Plan: {rec.plan_id}",
2071 f
"Status: {rec.status}",
2072 f
"Description: {rec.description}",
2073 f
"Filename: {expected}",
2074 f
"Created: {rec.created_at}",
2076 if rec.completed_at:
2077 lines.append(f
"Completed: {rec.completed_at}")
2079 lines.append(f
"Aborted: {rec.aborted_at}")
2080 lines.append(f
"Progress: {tally}")
2082 ctx.presenter.show_info(line)
2086 """Pause the active plan."""
2093 new_rec = pause_plan(rec.plan_id)
2094 except ValueError
as exc:
2095 ctx.presenter.show_info(f
"Cannot pause: {exc}")
2097 ctx.presenter.show_info(f
"Paused: {new_rec.plan_id}")
2101 """Resume a paused plan and inject a continuation message.
2103 Compensation here is NON-DESTRUCTIVE, and deliberately different from
2104 ``--new`` (plan 152 R11-06). A ``--new`` orphan may be abort-retracted
2105 because nothing valid existed before it. A ``--continue`` failure must NEVER
2106 abort the plan: the user has a real, valid, paused plan, and terminalising
2107 it because queue assembly or reporting failed would destroy work and make it
2108 unretryable. Either the plan stays paused and retryable, or — if the durable
2109 paused->active transition already committed and only later steps failed —
2110 the result reports COMMITTED and the queue reconciles. It never acquires
2111 ``aborted_at`` because a message could not be displayed.
2113 func =
"cmd_plan_continue"
2120 new_rec = continue_plan(rec.plan_id)
2121 except ValueError
as exc:
2123 ctx.presenter.show_info(f
"Cannot continue: {exc}")
2127 prior_in_plan = ctx.in_plan
2128 prior_plan_description = ctx.plan_description
2129 prior_plan_files = list(ctx.plan_files_changed
or [])
2130 message_index = len(ctx.session.messages)
2134 ctx.plan_description = new_rec.description
2135 ctx.plan_files_changed = []
2136 ctx.session.add_user(
2137 f
"Continue work on plan `{new_rec.plan_id}.md`. "
2138 "Re-read it and proceed from where you left off."
2140 except Exception
as exc:
2141 _dbg(
"OC_CHAT.MAIN", func,
2142 f
"SYSTEM_ERROR: continue unit failed after the durable transition: "
2143 f
"{type(exc).__name__}: {exc}", 1, override=
True)
2144 ctx.in_plan = prior_in_plan
2145 ctx.plan_description = prior_plan_description
2146 ctx.plan_files_changed = prior_plan_files
2147 del ctx.session.messages[message_index:]
2149 ctx.presenter.show_error(
2150 f
"Plan {new_rec.plan_id} was resumed on disk, but the continuation "
2151 f
"message could not be queued: {exc}. The plan is ACTIVE and "
2152 f
"retryable — run /plan --continue again."
2155 kind=INTERNAL_FAILURE, effect_disposition=EFFECT_COMMITTED,
2160 ctx.presenter.show_info(f
"Resumed: {new_rec.plan_id}")
2161 except Exception
as exc:
2162 warning = f
"plan {new_rec.plan_id} resumed, but the confirmation could not be displayed: {exc}"
2163 _dbg(
"OC_CHAT.MAIN", func, f
"EXTERNAL_ERROR: {warning}", 2, override=
True)
2165 kind=HANDLED_TURN_QUEUED,
2166 effect_disposition=EFFECT_COMMITTED,
2167 queued_turn_token=message_index,
2173 """Mark the active plan complete and clear the session pointer."""
2180 new_rec = complete_plan(rec.plan_id)
2181 except ValueError
as exc:
2182 ctx.presenter.show_info(f
"Cannot complete: {exc}")
2184 ctx.session.active_plan_id =
None
2185 ctx.session.dirty =
True
2186 ctx.presenter.show_info(f
"Completed: {new_rec.plan_id}")
2190 """Abort the active plan and clear the session pointer."""
2197 new_rec = abort_plan(rec.plan_id)
2198 except ValueError
as exc:
2199 ctx.presenter.show_info(f
"Cannot abort: {exc}")
2201 ctx.session.active_plan_id =
None
2202 ctx.session.dirty =
True
2203 ctx.presenter.show_info(f
"Aborted: {new_rec.plan_id}")
2207 """List all plans grouped by status, marking the active one."""
2210 records = list_plans()
2212 ctx.presenter.show_info(
"No plans found.")
2215 active_id = ctx.session.active_plan_id
2216 groups: dict[str, list] = {
"active": [],
"paused": [],
"complete": [],
"aborted": []}
2218 groups.setdefault(r.status, []).append(r)
2220 for status
in (
"active",
"paused",
"complete",
"aborted"):
2221 bucket = groups.get(status, [])
2224 ctx.presenter.show_info(f
"[{status}]")
2226 marker =
"*" if r.plan_id == active_id
else " "
2227 desc = r.description
if len(r.description) <= 60
else r.description[:57] +
"..."
2228 ctx.presenter.show_info(f
" {marker} {r.plan_id} {desc}")
2232 """Change session.active_plan_id to a different (non-terminal) plan."""
2237 ctx.presenter.show_info(
"Usage: /plan --switch <plan-id-or-prefix>")
2240 rec = find_plan_by_id(target)
2241 except ValueError
as exc:
2242 ctx.presenter.show_info(f
"Cannot switch: {exc}")
2245 ctx.presenter.show_info(f
"No plan found for {target!r}.")
2247 if rec.status
in TERMINAL_STATUSES:
2248 ctx.presenter.show_info(
2249 f
"Cannot switch to {rec.plan_id} — status is {rec.status!r} (terminal). "
2250 "Choose an active or paused plan instead."
2253 ctx.session.active_plan_id = rec.plan_id
2254 ctx.session.dirty =
True
2255 ctx.presenter.show_info(f
"Switched to: {rec.plan_id} (status: {rec.status})")
2260 """Manage per-session generation options (temperature, top_k, etc.).
2262 /options — show current options
2263 /options set <field> <value> — set a field
2264 /options reset — clear all options
2268 parts = raw.strip().split(
None, 3)
2269 sub = parts[1].lower()
if len(parts) > 1
else ""
2272 ctx.generation_options = GenerationOptions()
2273 ctx.session.generation_options = {}
2274 ctx.presenter.show_info(
"Generation options cleared (using provider defaults).")
2279 ctx.presenter.show_info(
"Usage: /options set <field> <value>")
2281 fname, raw_val = parts[2].lower(), parts[3]
2282 if fname
not in GenerationOptions.model_fields:
2283 valid =
", ".join(GenerationOptions.model_fields)
2284 ctx.presenter.show_info(f
"Unknown option {fname!r}. Valid: {valid}")
2287 ann = GenerationOptions.model_fields[fname].annotation
2289 base = next((t
for t
in getattr(ann,
"__args__", [ann])
if t
is not type(
None)), float)
2290 value = base(raw_val)
2291 except (ValueError, TypeError):
2292 ctx.presenter.show_info(f
"Cannot convert {raw_val!r} for {fname}")
2294 ctx.generation_options = ctx.generation_options.model_copy(update={fname: value})
2295 ctx.session.generation_options = ctx.generation_options.model_dump(exclude_none=
True)
2296 ctx.presenter.show_info(f
"Set {fname} = {value}")
2300 opts = ctx.generation_options
2301 if not opts.any_set():
2302 ctx.presenter.show_info(
"No generation options set (using provider defaults).")
2304 lines = [f
" {k} = {v}" for k, v
in opts.model_dump(exclude_none=
True).items()]
2305 ctx.presenter.show_info(
"Generation options:\n" +
"\n".join(lines))
2310 """Display the current plan's step-by-step status as JSON."""
2313 print(json.dumps(get_status(), indent=2, ensure_ascii=
False))
2318 """Halt the active plan."""
2321 ctx.presenter.show_info(
"Active plan stopped.")
2328 """Show prompt disk-cache stats for this session."""
2333 hit, miss = st.prompt_cache_hit_tokens, st.prompt_cache_miss_tokens
2334 pct = cache_hit_ratio_pct(hit, miss)
2335 print(
"\n── Prompt cache (session cumulative) ──")
2336 print(f
" cache_hit_tokens: {hit:,}")
2337 print(f
" cache_miss_tokens: {miss:,}")
2339 print(f
" hit ratio: {pct:.1f}%")
2341 print(
" hit ratio: (no cache data yet)")
2343 snaps = list(getattr(s,
"cache_turn_snapshots", [])
or [])
2345 print(
"\n── Recent turns (newest last) ──")
2347 for i, row
in enumerate(tail, start=len(snaps) - len(tail) + 1):
2348 h, m = int(row.get(
"hit", 0)), int(row.get(
"miss", 0))
2349 p = int(row.get(
"prompt", 0))
2350 r = cache_hit_ratio_pct(h, m)
2351 rs = f
"{r:.0f}%" if r
is not None else "—"
2352 print(f
" turn {i:>2}: hit={h:>6} miss={m:>6} ratio={rs:>5} prompt≈{p:,}")
2354 print(
"\n(no per-turn cache snapshots yet — complete a model turn first)")
2360 """Render this session's persisted M2 event stream (M2.6 /trace)."""
2363 parts = raw.split(maxsplit=1)
2366 arg = parts[1].strip().lower()
2367 if arg
in (
"all",
"full"):
2370 limit = max(1, int(arg))
2371 events = read_session_events(ctx.session)
2372 print(
"\n" + format_trace(events, limit=limit) +
"\n")
2377 """Write a draft merge-readiness packet (packet-draft/v0) for this session (M2.6 /packet)."""
2379 from datetime
import datetime
2385 events = read_session_events(s)
2386 packet = build_packet_draft(s, events)
2387 out_dir = active_event_dir(s)
2388 out_dir.mkdir(parents=
True, exist_ok=
True)
2389 stamp = datetime.now().strftime(
"%Y%m%d-%H%M%S")
2390 path = out_dir / f
"{_slug(s.name)}-{stamp}.packet.json"
2391 path.write_text(json.dumps(packet, ensure_ascii=
False, indent=2, sort_keys=
True), encoding=
"utf-8")
2392 print(f
"\n[packet-draft written to {path}]")
2393 print(f
" events: {sum(packet['event_counts'].values())} turns: {packet['turn_count']}\n")
2397 """Print the completion caps the profile actually resolves to.
2399 The caps derive from the model's declared ``max_output_tokens`` and the
2400 operator ceiling, so the profile name alone does not tell you what you
2401 get. Printing the numbers also explains why ``deep`` can equal
2402 ``balanced``: a bucket already at the ceiling has nowhere to scale.
2404 func =
"_show_resolved_output_caps"
2409 model = ctx.session.model
2410 profile = getattr(ctx.session,
"token_profile",
"balanced")
2412 (
"planning / final answer",
"planning"),
2413 (
"tool iterations",
"thinking"),
2414 (
"after a plain read",
"simple"),
2417 f
"{label} {resolve_output_tokens(ctx.model_manager, model, bucket, profile):,}"
2418 for label, bucket
in rows
2420 ctx.presenter.show_info(
2421 f
"Completion caps for {model} at '{profile}': {caps} "
2422 f
"(operator ceiling {get_output_tokens_ceiling():,})"
2424 except Exception
as e:
2425 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
2429 """Print the plan-237 money block: spend, checkpoints, cap, and the knob.
2431 Everything an operator needs to answer "how much has this cost, and what
2432 stops it?" without reading the source — including the residual, because a
2433 total that silently excludes unpriced samples is a number that lies by
2436 func =
"_show_session_cost"
2439 _COST_CEILING_ENV_VAR, resolve_cost_thresholds_usd,
2440 resolve_session_cost_hard_cap_usd,
2443 cost_prompts_suppressed, tool_calls_auto_continue,
2446 cost = ctx.session.stats.cost
2447 cap = resolve_session_cost_hard_cap_usd()
2448 thresholds = resolve_cost_thresholds_usd()
2449 crossed = [t
for t
in thresholds
if cost.has_fired(t)]
2450 pending = [t
for t
in thresholds
if not cost.has_fired(t)]
2451 _plural = cost.unpriced_samples != 1
2453 f
" ({cost.unpriced_samples} sample{'s' if _plural else ''} could not "
2454 f
"be priced and {'are' if _plural else 'is'} NOT included, so real "
2455 f
"spend may be higher)"
2456 if cost.unpriced_samples
else ""
2459 if tool_calls_auto_continue():
2460 latches.append(
"tool-call checkpoint: not asking again this session")
2461 if cost_prompts_suppressed():
2462 latches.append(
"cost prompts: suppressed this session (alerts and the "
2463 "hard cap still apply)")
2465 f
"Session spend: ${cost.priced_usd:.2f}{unpriced}",
2466 f
" checkpoints crossed : "
2467 + (
", ".join(f
"${t:.2f}" for t
in crossed)
if crossed
else "none"),
2468 f
" checkpoints ahead : "
2469 + (
", ".join(f
"${t:.2f}" for t
in pending)
if pending
else "none"),
2470 f
" hard cap : ${cap} - at or above it no further provider "
2471 f
"call is made this session",
2472 f
" raise the cap : "
2473 + (f
"{_COST_CEILING_ENV_VAR}=<whole dollars>, out of band only "
2474 f
"(a settings file can only LOWER it)"
2475 if _COST_CEILING_ENV_VAR
else "no out-of-band raise path"),
2477 lines.extend(f
" {note}" for note
in latches)
2478 ctx.presenter.show_info(
"\n".join(lines))
2479 except Exception
as e:
2480 _dbg(
"OC_CHAT.MAIN", func, f
"VALIDATION_ERROR: {type(e).__name__}: {e}", 3)
2485 """Show or set the session token profile, and show the session money bounds.
2487 Plan 237 (CAPS-01 P7) asked for a ``/budget`` that prints session spend, the
2488 checkpoints already crossed, the hard cap and the env variable that raises
2489 it. ``/budget`` was NOT a new command — it has selected the token profile
2490 since phase 4 — so rather than take the name and break that, the bare form
2491 now prints BOTH blocks (profile + money), ``/budget cost`` prints the money
2492 block alone, and ``/budget cheap|balanced|deep`` is unchanged.
2494 parts = raw.split(maxsplit=1)
2497 ctx.presenter.show_info(
2498 f
"Token profile: {getattr(s, 'token_profile', 'balanced')} "
2499 f
"(options: {', '.join(TOKEN_PROFILES)}). Example: /budget cheap"
2504 val = parts[1].strip().lower()
2508 if val
not in TOKEN_PROFILES:
2509 ctx.presenter.show_error(
2510 f
"Unknown profile. Use one of: {', '.join(TOKEN_PROFILES)} — "
2511 f
"or 'cost' for this session's spend against the hard cap."
2514 s.token_profile = val
2516 ctx.presenter.show_info(f
"Token profile set to: {val}")
2522 """Inspect or override the ReAct iteration cap for this session."""
2523 parts = raw.split(maxsplit=1)
2525 cap = getattr(s,
"max_react_iterations",
None)
2526 eff = cap
if isinstance(cap, int)
and cap >= 1
else DEFAULT_MAX_REACT_ITERATIONS
2528 ctx.presenter.show_info(
2529 f
"ReAct cap: {eff} (default {DEFAULT_MAX_REACT_ITERATIONS}). "
2530 f
"Set with /react 15 or /react reset"
2533 arg = parts[1].strip().lower()
2535 s.max_react_iterations =
None
2537 ctx.presenter.show_info(f
"ReAct cap reset to default ({DEFAULT_MAX_REACT_ITERATIONS}).")
2540 n = int(parts[1].strip())
2542 ctx.presenter.show_error(
"Usage: /react [1-99|reset]")
2544 if not 1 <= n <= 99:
2545 ctx.presenter.show_error(
"ReAct cap must be between 1 and 99.")
2547 s.max_react_iterations = n
2549 ctx.presenter.show_info(f
"ReAct cap set to {n} iterations for this session.")
2556 """Issue a single non-streaming question without touching the session log.
2558 No ReAct iteration, no tool schemas in the system prompt (the model
2559 cannot dispatch tool calls in one-shot mode), and ``session.messages``
2560 /``session.stats`` are left untouched. Phase K.5b wires the
2561 post-answer menu onto this handler.
2564 parts = raw.split(maxsplit=1)
2565 if len(parts) < 2
or not parts[1].strip():
2566 ctx.presenter.show_error(
"Usage: /ask <question>")
2568 question = parts[1].strip()
2569 _dbg(
"OC_CHAT.MAIN", func, f
"one-shot question_len={len(question)}", 2)
2572 getattr(ctx.session,
"system_prompt",
"")
2573 or "You are a helpful assistant."
2576 {
"role":
"system",
"content": system_prompt},
2577 {
"role":
"user",
"content": question},
2581 response = ctx.client.one_shot(
2587 except Exception
as e:
2588 _dbg(
"OC_CHAT.MAIN", func, f
"one_shot raised {type(e).__name__}: {e}", 1, override=
True)
2589 ctx.presenter.show_error(f
"/ask failed: {type(e).__name__}: {e}")
2592 ctx.presenter.show_content(response)
2593 ctx.presenter.show_info(
"(one-shot — no session log written)")
2594 _dbg(
"OC_CHAT.MAIN", func, f
"one-shot response_len={len(response)}", 2)
2598 chosen = present_post_answer_menu(ctx, question, response)
2599 _dbg(
"OC_CHAT.MAIN", func, f
"post-answer option chosen: {chosen}", 2)
2606 """Print the full command listing, session subcommands, and registered tools."""
2607 tools = sorted(ctx.tool_registry)
if ctx.tool_registry
else None
2608 ctx.presenter.show_help(COMMAND_HELP, tools=tools)
None __init__(self, "Context" ctx)
str on_prompt(self, str prompt)
None on_auth(self, str url)
_read_ui_pref(session, str key, default)
None cmd_last("Context" ctx, str raw)
None _show_session_cost("Context" ctx)
None cmd_grants("Context" ctx, str raw)
None cmd_effort("Context" ctx, str raw)
None cmd_context("Context" ctx, str raw)
None cmd_help("Context" ctx, str _)
tuple[int, int] _plan_checkbox_tally(path)
"DispatchResult | None" cmd_plan("Context" ctx, str raw)
None cmd_status("Context" ctx, str _)
None cmd_stop("Context" ctx, str _)
DispatchResult cmd_goal("Context" ctx, str raw)
None cmd_ls("Context" ctx, str raw)
None cmd_ui("Context" ctx, str raw)
None cmd_ask("Context" ctx, str raw)
Any _oauth_provider("Context" ctx, str raw)
None cmd_cache("Context" ctx, str _raw)
bool _apply_lane_selection("Context" ctx, str lane_name, str model_id="")
None cmd_session("Context" ctx, str raw)
None _refresh_presenter_for_model(ctx)
None _cmd_plan_status("Context" ctx)
None _cmd_plan_complete("Context" ctx)
None cmd_switch_model("Context" ctx, str raw)
None cmd_react("Context" ctx, str raw)
None cmd_login("Context" ctx, str raw)
dict|None _latest_message_with_field(list[dict] messages, str role, str field_name)
_active_record_or_warn("Context" ctx)
DispatchResult _cmd_plan_continue("Context" ctx)
None _cmd_plan_switch("Context" ctx, str target)
None _warn_if_no_tools(ctx, str model_name)
None _cmd_plan_pause("Context" ctx)
None cmd_logout("Context" ctx, str raw)
_lane_options_for("Context" ctx)
tuple[dict, str] _metadata_for_activation("Context" ctx, str name)
None cmd_options("Context" ctx, str raw)
None cmd_packet("Context" ctx, str _raw)
None cmd_open("Context" ctx, str raw)
None cmd_cwd("Context" ctx, str raw)
Callable[[_F], _F] command(*str names)
None refresh_presenter_bindings("Context" ctx)
None _switch_to_tier(ctx, str tier_name)
None _sync_sandbox_override("Context" ctx)
str _message_content_to_text(object content)
None cmd_mode("Context" ctx, str raw)
None cmd_budget("Context" ctx, str raw)
None _write_ui_pref(session, str key, value)
None _page_last_content(str content, *, bool markdown)
None cmd_read("Context" ctx, str raw)
dict|None _latest_message_of_role(list[dict] messages, str role)
None _show_resolved_output_caps("Context" ctx)
DispatchResult _cmd_plan_new("Context" ctx, str desc)
None cmd_model_info("Context" ctx, str raw)
None cmd_sandbox("Context" ctx, str raw)
None cmd_trace("Context" ctx, str raw)
None _cmd_plan_list("Context" ctx)
None cmd_pro("Context" ctx, str _)
None cmd_system("Context" ctx, str raw)
None cmd_palette("Context" ctx, str raw)
bool _cwd_inside_sandbox(object root)
None cmd_endpoint("Context" ctx, str raw)
None cmd_flash("Context" ctx, str _)
None _cmd_plan_abort("Context" ctx)
None cmd_lane("Context" ctx, str raw)
None cmd_models("Context" ctx, str _)
str _fmt_tokens_display(stats)