algo-cli-runtime 0.14.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 (237) hide show
  1. algo_cli/__init__.py +3 -0
  2. algo_cli/__main__.py +7 -0
  3. algo_cli/_internal/__init__.py +12 -0
  4. algo_cli/_internal/policy_chain.py +259 -0
  5. algo_cli/action_registry.py +1047 -0
  6. algo_cli/agent_blocks.py +550 -0
  7. algo_cli/agent_pipeline.py +1457 -0
  8. algo_cli/agent_threads.py +308 -0
  9. algo_cli/animations.py +316 -0
  10. algo_cli/cache_admission.py +209 -0
  11. algo_cli/capability_mask.py +66 -0
  12. algo_cli/chat_protocol.py +116 -0
  13. algo_cli/chatgpt_auth.py +510 -0
  14. algo_cli/chatgpt_client.py +657 -0
  15. algo_cli/code_rag.py +479 -0
  16. algo_cli/config.py +651 -0
  17. algo_cli/context_budget.py +679 -0
  18. algo_cli/credential_helpers.py +315 -0
  19. algo_cli/deliberation.py +29 -0
  20. algo_cli/display.py +1470 -0
  21. algo_cli/evals/__init__.py +21 -0
  22. algo_cli/evals/algorithm_effectiveness.py +560 -0
  23. algo_cli/evals/competitive_harness_rating.py +702 -0
  24. algo_cli/evals/cot_quality.py +220 -0
  25. algo_cli/evals/harness_retrieval_benchmark.py +401 -0
  26. algo_cli/evals/performance_regression.py +136 -0
  27. algo_cli/evals/scorecard_grading.py +308 -0
  28. algo_cli/evals/session_distribution.py +84 -0
  29. algo_cli/execution_guardrails.py +806 -0
  30. algo_cli/extensions_manifest.py +84 -0
  31. algo_cli/git_evidence.py +227 -0
  32. algo_cli/google_workspace.py +407 -0
  33. algo_cli/google_workspace_auth.py +523 -0
  34. algo_cli/harness.py +2587 -0
  35. algo_cli/identity.py +557 -0
  36. algo_cli/index_compute_lab.py +228 -0
  37. algo_cli/inference_harness.py +70 -0
  38. algo_cli/intelligence/__init__.py +1103 -0
  39. algo_cli/intelligence/acrobat_config.py +307 -0
  40. algo_cli/intelligence/acrobat_manifests.py +338 -0
  41. algo_cli/intelligence/acrobat_models.py +195 -0
  42. algo_cli/intelligence/acrobat_pipeline.py +295 -0
  43. algo_cli/intelligence/acrobat_runtime.py +302 -0
  44. algo_cli/intelligence/acrobat_security.py +261 -0
  45. algo_cli/intelligence/acrobat_workflows.py +226 -0
  46. algo_cli/intelligence/actionability.py +165 -0
  47. algo_cli/intelligence/adversarial_audit.py +136 -0
  48. algo_cli/intelligence/agent_arena.py +92 -0
  49. algo_cli/intelligence/agent_benchmark.py +236 -0
  50. algo_cli/intelligence/agent_runtime.py +171 -0
  51. algo_cli/intelligence/agents_as_tools.py +70 -0
  52. algo_cli/intelligence/artifact_binding.py +80 -0
  53. algo_cli/intelligence/autonomous_engineer.py +1976 -0
  54. algo_cli/intelligence/backpressure.py +99 -0
  55. algo_cli/intelligence/bloom_filter.py +186 -0
  56. algo_cli/intelligence/bonferroni.py +66 -0
  57. algo_cli/intelligence/boundary_compaction.py +98 -0
  58. algo_cli/intelligence/catalog_verifier.py +172 -0
  59. algo_cli/intelligence/cavecrew.py +118 -0
  60. algo_cli/intelligence/changelog.py +176 -0
  61. algo_cli/intelligence/checkpoint_resume.py +92 -0
  62. algo_cli/intelligence/circuit_breaker.py +88 -0
  63. algo_cli/intelligence/clarification_gate.py +101 -0
  64. algo_cli/intelligence/code_graph.py +180 -0
  65. algo_cli/intelligence/coderank.py +97 -0
  66. algo_cli/intelligence/consistent_hash.py +150 -0
  67. algo_cli/intelligence/consortium_synthesis.py +139 -0
  68. algo_cli/intelligence/construction/__init__.py +241 -0
  69. algo_cli/intelligence/construction/common.py +273 -0
  70. algo_cli/intelligence/construction/documents.py +496 -0
  71. algo_cli/intelligence/construction/labor_units.py +1395 -0
  72. algo_cli/intelligence/construction/payments.py +470 -0
  73. algo_cli/intelligence/construction/risk.py +784 -0
  74. algo_cli/intelligence/content_extractor.py +132 -0
  75. algo_cli/intelligence/context_adaptive.py +102 -0
  76. algo_cli/intelligence/context_ops.py +95 -0
  77. algo_cli/intelligence/count_min.py +145 -0
  78. algo_cli/intelligence/cow_state.py +103 -0
  79. algo_cli/intelligence/critic_loop.py +119 -0
  80. algo_cli/intelligence/cross_source.py +113 -0
  81. algo_cli/intelligence/daemon_mode.py +99 -0
  82. algo_cli/intelligence/dag_orchestration.py +151 -0
  83. algo_cli/intelligence/deep_research.py +155 -0
  84. algo_cli/intelligence/degenerate_detector.py +78 -0
  85. algo_cli/intelligence/delta_report.py +92 -0
  86. algo_cli/intelligence/discovery_event_log.py +92 -0
  87. algo_cli/intelligence/document_ingest.py +298 -0
  88. algo_cli/intelligence/dual_layer_validate.py +151 -0
  89. algo_cli/intelligence/echo_fidelity.py +73 -0
  90. algo_cli/intelligence/ema_tuning.py +104 -0
  91. algo_cli/intelligence/event_log.py +92 -0
  92. algo_cli/intelligence/evidence_graph.py +114 -0
  93. algo_cli/intelligence/extension_host.py +162 -0
  94. algo_cli/intelligence/extension_manifest.py +115 -0
  95. algo_cli/intelligence/falsification_suite.py +178 -0
  96. algo_cli/intelligence/finance/__init__.py +169 -0
  97. algo_cli/intelligence/finance/anomalies.py +135 -0
  98. algo_cli/intelligence/finance/ap_ar.py +351 -0
  99. algo_cli/intelligence/finance/cash.py +162 -0
  100. algo_cli/intelligence/finance/close.py +332 -0
  101. algo_cli/intelligence/finance/common.py +244 -0
  102. algo_cli/intelligence/finance/construction.py +135 -0
  103. algo_cli/intelligence/finance/controls.py +172 -0
  104. algo_cli/intelligence/finance/evidence.py +119 -0
  105. algo_cli/intelligence/finance/exceptions.py +157 -0
  106. algo_cli/intelligence/finance/reconciliations.py +254 -0
  107. algo_cli/intelligence/finance/revenue.py +109 -0
  108. algo_cli/intelligence/finance/tax.py +74 -0
  109. algo_cli/intelligence/finance/workpapers.py +111 -0
  110. algo_cli/intelligence/finding_record.py +120 -0
  111. algo_cli/intelligence/flow_dag.py +267 -0
  112. algo_cli/intelligence/gatherer.py +223 -0
  113. algo_cli/intelligence/golden_master.py +98 -0
  114. algo_cli/intelligence/graph_rag.py +195 -0
  115. algo_cli/intelligence/group_chat.py +143 -0
  116. algo_cli/intelligence/hash_dedup.py +145 -0
  117. algo_cli/intelligence/hyperloglog.py +128 -0
  118. algo_cli/intelligence/incremental_index.py +316 -0
  119. algo_cli/intelligence/index_store.py +16 -0
  120. algo_cli/intelligence/iteration_plan.py +133 -0
  121. algo_cli/intelligence/kernel_plugins.py +167 -0
  122. algo_cli/intelligence/lesson_catalog.py +135 -0
  123. algo_cli/intelligence/llm_fallback.py +169 -0
  124. algo_cli/intelligence/log2_histogram.py +267 -0
  125. algo_cli/intelligence/lsp_integration.py +147 -0
  126. algo_cli/intelligence/memory_evolution.py +117 -0
  127. algo_cli/intelligence/minhash_lsh.py +182 -0
  128. algo_cli/intelligence/multi_model_score.py +174 -0
  129. algo_cli/intelligence/multi_tier_grade.py +211 -0
  130. algo_cli/intelligence/negative_controls.py +113 -0
  131. algo_cli/intelligence/numeric_clamp.py +63 -0
  132. algo_cli/intelligence/occ_editor.py +66 -0
  133. algo_cli/intelligence/output_normalize.py +112 -0
  134. algo_cli/intelligence/parallel_delegation.py +98 -0
  135. algo_cli/intelligence/parallel_fanout.py +104 -0
  136. algo_cli/intelligence/permission_modes.py +105 -0
  137. algo_cli/intelligence/pre_push_gate.py +68 -0
  138. algo_cli/intelligence/prefetch.py +171 -0
  139. algo_cli/intelligence/process_framework.py +217 -0
  140. algo_cli/intelligence/project_graph.py +387 -0
  141. algo_cli/intelligence/query_expansion.py +146 -0
  142. algo_cli/intelligence/ralph_loop.py +117 -0
  143. algo_cli/intelligence/rate_limiter.py +153 -0
  144. algo_cli/intelligence/refactor_transaction.py +94 -0
  145. algo_cli/intelligence/research_workspace.py +108 -0
  146. algo_cli/intelligence/retraction_ledger.py +72 -0
  147. algo_cli/intelligence/saga_pattern.py +88 -0
  148. algo_cli/intelligence/session_fork.py +100 -0
  149. algo_cli/intelligence/shadow_editor.py +67 -0
  150. algo_cli/intelligence/shell_session.py +213 -0
  151. algo_cli/intelligence/source_registry.py +143 -0
  152. algo_cli/intelligence/spawn_scales.py +99 -0
  153. algo_cli/intelligence/stat_stability.py +104 -0
  154. algo_cli/intelligence/structural_validator.py +148 -0
  155. algo_cli/intelligence/subagent_spawner.py +111 -0
  156. algo_cli/intelligence/symmetric_verify.py +70 -0
  157. algo_cli/intelligence/task_classifier.py +129 -0
  158. algo_cli/intelligence/team_execution.py +122 -0
  159. algo_cli/intelligence/tiered_access.py +121 -0
  160. algo_cli/intelligence/utility_registry.py +159 -0
  161. algo_cli/intuition_engine.py +560 -0
  162. algo_cli/intuition_injector.py +82 -0
  163. algo_cli/kernels/__init__.py +5 -0
  164. algo_cli/kernels/manifest.py +763 -0
  165. algo_cli/main.py +3903 -0
  166. algo_cli/memory_candidates.py +541 -0
  167. algo_cli/memory_echo_veil.py +394 -0
  168. algo_cli/memory_runtime.py +112 -0
  169. algo_cli/model_info.py +548 -0
  170. algo_cli/model_profile.py +160 -0
  171. algo_cli/model_routing.py +74 -0
  172. algo_cli/oneshot.py +331 -0
  173. algo_cli/perf_telemetry.py +389 -0
  174. algo_cli/plugins.py +245 -0
  175. algo_cli/private_event_store.py +654 -0
  176. algo_cli/quantization/__init__.py +24 -0
  177. algo_cli/quantization/lloyd_max.py +98 -0
  178. algo_cli/quantization/turbo_quant.py +308 -0
  179. algo_cli/reasoning/__init__.py +46 -0
  180. algo_cli/reasoning/combinatorial.py +356 -0
  181. algo_cli/reasoning/graph_of_thought.py +297 -0
  182. algo_cli/reasoning/mcts.py +220 -0
  183. algo_cli/reasoning/neuro_symbolic.py +250 -0
  184. algo_cli/reasoning/react.py +246 -0
  185. algo_cli/reasoning/reflexion.py +225 -0
  186. algo_cli/reasoning/tree_of_thought.py +241 -0
  187. algo_cli/reasoning_bridge.py +150 -0
  188. algo_cli/reconciliation.py +284 -0
  189. algo_cli/reflex.py +385 -0
  190. algo_cli/resources/docs/ALGO.md +13958 -0
  191. algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
  192. algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
  193. algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
  194. algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
  195. algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
  196. algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
  197. algo_cli/resources/docs/main-split-map.md +35 -0
  198. algo_cli/resources/docs/privacy-and-context.md +48 -0
  199. algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
  200. algo_cli/resources/skills/README.md +26 -0
  201. algo_cli/resources/skills/algo-cli.md +59 -0
  202. algo_cli/resources/skills/edit-file-precision.md +49 -0
  203. algo_cli/resources/skills/harness-search-first.md +47 -0
  204. algo_cli/resources/skills/memory-recall-ritual.md +51 -0
  205. algo_cli/resources/skills/qol-algorithms.md +224 -0
  206. algo_cli/resources/skills/smart-error-recovery.md +56 -0
  207. algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
  208. algo_cli/retrieval_algorithms.py +127 -0
  209. algo_cli/runtime_qos.py +236 -0
  210. algo_cli/runtime_services.py +320 -0
  211. algo_cli/session_commands.py +95 -0
  212. algo_cli/session_mode.py +113 -0
  213. algo_cli/skills.py +430 -0
  214. algo_cli/slash_dispatch.py +1265 -0
  215. algo_cli/small_context.py +206 -0
  216. algo_cli/spawn_budget.py +89 -0
  217. algo_cli/task_ledger.py +84 -0
  218. algo_cli/task_router.py +197 -0
  219. algo_cli/tool_context.py +94 -0
  220. algo_cli/tool_contract.py +99 -0
  221. algo_cli/tool_policy.py +357 -0
  222. algo_cli/tool_runtime.py +647 -0
  223. algo_cli/tools.py +3056 -0
  224. algo_cli/url_scheme.py +174 -0
  225. algo_cli/verify.py +154 -0
  226. algo_cli/version_manifest.py +178 -0
  227. algo_cli/vision_screenshot_verify.py +76 -0
  228. algo_cli/workspace_resolver.py +68 -0
  229. algo_cli/x_account.py +209 -0
  230. algo_cli/xai_auth.py +374 -0
  231. algo_cli/xai_client.py +600 -0
  232. algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
  233. algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
  234. algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
  235. algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
  236. algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
  237. ollama_cli/__init__.py +67 -0
algo_cli/main.py ADDED
@@ -0,0 +1,3903 @@
1
+
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ from concurrent.futures import ThreadPoolExecutor, as_completed
6
+ from html import escape
7
+ import importlib
8
+ import json
9
+ import logging
10
+ import os
11
+ import shutil
12
+ import shlex
13
+ import subprocess
14
+ import sys
15
+ import time
16
+ from typing import Any
17
+ from pathlib import Path
18
+ from urllib.parse import parse_qs, urlparse
19
+
20
+ from ollama import Client
21
+ from prompt_toolkit.formatted_text import HTML
22
+ from prompt_toolkit.history import FileHistory
23
+ from prompt_toolkit.shortcuts import CompleteStyle
24
+ from prompt_toolkit.styles import Style
25
+ from rich import box
26
+ from rich.table import Table
27
+
28
+ from .config import (
29
+ CODE_RAG_CONSENT_VERSION,
30
+ CONFIG_DIR,
31
+ Config,
32
+ PROMPT_HISTORY_FILE,
33
+ load_runtime_env,
34
+ has_legacy_data,
35
+ perform_legacy_migration,
36
+ get_legacy_backup_dir,
37
+ migrate_legacy_sidecar_files,
38
+ NEW_ENV_PREFIX,
39
+ OLD_ENV_PREFIX,
40
+ LEGACY_CONFIG_DIR,
41
+ _atomic_write_text,
42
+ code_rag_consent_granted,
43
+ )
44
+ from . import agent_blocks # noqa: F401 — tests patch main.agent_blocks
45
+ from . import git_evidence # noqa: F401 — tests patch main.git_evidence
46
+ from . import harness
47
+ from . import identity
48
+ from . import code_rag
49
+ from . import execution_guardrails
50
+ from . import model_info as _model_info_module
51
+ from . import model_profile
52
+ from . import memory_runtime
53
+ from . import reasoning_bridge
54
+ from . import reconciliation
55
+ from . import task_ledger
56
+ from . import skills
57
+ from . import task_router # noqa: F401 — tests use main.task_router
58
+ from . import verify as _verify_module
59
+ from . import xai_auth
60
+ from . import chatgpt_auth
61
+ from . import google_workspace_auth
62
+ from . import google_workspace
63
+ from . import x_account
64
+ from .display import (
65
+ compact_path,
66
+ _format_bytes,
67
+ console,
68
+ current_theme_name,
69
+ show_error,
70
+ show_banner,
71
+ show_status_footer,
72
+ show_info,
73
+ start_streaming_response,
74
+ show_stream_text,
75
+ show_thinking_text,
76
+ finish_thinking_block,
77
+ show_tool_call,
78
+ show_tool_result,
79
+ show_recalled_context,
80
+ show_session_overview, # noqa: F401 — re-exported for slash_dispatch (m.show_session_overview)
81
+ finish_streaming_response,
82
+ theme_colors,
83
+ set_theme,
84
+ json_sink,
85
+ tool_execution_status,
86
+ )
87
+ from . import tools as tools_module
88
+ from .chat_protocol import (
89
+ collapse_tool_history_for_gemini,
90
+ get_attr,
91
+ normalize_tool_call,
92
+ serialize_tool_call,
93
+ )
94
+ from .model_routing import (
95
+ effective_runtime_host, # noqa: F401 — re-exported for tests and oneshot callers
96
+ is_cloud_model_name,
97
+ is_embedding_model_name,
98
+ is_vision_model_name,
99
+ require_cloud_api_key, # noqa: F401
100
+ uses_ollama_cloud, # noqa: F401
101
+ )
102
+ from .runtime_services import (
103
+ SERVER_READY_CACHE,
104
+ client_for_model, # noqa: F401 — re-exported for tests
105
+ create_client,
106
+ host_is_local,
107
+ ollama_server_ready,
108
+ scoped_tool_runtime_env,
109
+ start_local_ollama_host,
110
+ start_ollama_server,
111
+ start_supplemental_gateway,
112
+ )
113
+ from .runtime_qos import order_tool_batch_by_qos
114
+ from .perf_telemetry import (
115
+ flush_perf_records,
116
+ log_embed_perf,
117
+ record_chat_metrics,
118
+ record_perf_event,
119
+ )
120
+ from .tool_runtime import (
121
+ RuntimeToolPreflight,
122
+ ask_approval,
123
+ augment_tool_result_with_reflex,
124
+ classify_tool_status,
125
+ find_failed_attempt,
126
+ preflight_runtime_tool,
127
+ record_tool_attempt,
128
+ reflection_checkpoint,
129
+ run_tool,
130
+ run_args_preview as _run_args_preview,
131
+ tool_attempt_signature,
132
+ tool_result_message,
133
+ tool_runtime_args,
134
+ )
135
+ from .tool_context import select_tools_for_prompt
136
+ from .slash_dispatch import SLASH_COMMANDS, SlashCommandCompleter, handle_command, unknown_command_message
137
+ from .agent_pipeline import ( # noqa: F401
138
+ _session_pipeline_blocks,
139
+ AgentRunResult,
140
+ MAX_RECOVERY_IMPLEMENT_ITERATIONS,
141
+ RECOVERABLE_IMPLEMENT_CODES,
142
+ agent_execution_active,
143
+ agent_usage_text,
144
+ capture_optional_mutation_audit,
145
+ clear_session_pipeline_blocks,
146
+ enforce_required_change_contract,
147
+ execute_agent_command,
148
+ maybe_show_route_suggestion,
149
+ parse_agent_invocation_checked,
150
+ parse_agent_invocation,
151
+ parse_agent_team_invocation,
152
+ recovery_plan_block,
153
+ resolve_agent_workspace,
154
+ resolve_pipeline_for_cli,
155
+ retry_implementation_block,
156
+ run_agent_block,
157
+ run_agent_pipeline,
158
+ run_agent_team,
159
+ session_pipeline_blocks,
160
+ should_recover_implementation,
161
+ show_agent_thread,
162
+ show_agent_threads,
163
+ show_task_route,
164
+ )
165
+ from .context_budget import (
166
+ CONTEXT_COMPACT_THRESHOLD,
167
+ CONTEXT_KEEP_MESSAGES,
168
+ FOOTER_METRICS_FRESHNESS_SECONDS,
169
+ OptionalContextBlock,
170
+ build_system_prompt,
171
+ context_status,
172
+ estimate_context_usage, # noqa: F401
173
+ estimate_message_tokens, # noqa: F401
174
+ estimate_text_tokens, # noqa: F401
175
+ estimate_usage_with_system_prompt,
176
+ fit_optional_context_blocks,
177
+ invalidate_context_usage_cache,
178
+ maybe_compact_context,
179
+ prune_stale_tool_messages,
180
+ rebuild_context_summary,
181
+ summarize_message_batch, # noqa: F401
182
+ )
183
+ from .context_budget import _last_chat_token_usage as _last_chat_token_usage_for
184
+ from .context_budget import _tool_call_id # noqa: F401
185
+ from . import small_context
186
+
187
+
188
+ def _last_chat_token_usage() -> int | None:
189
+ return _last_chat_token_usage_for(RUNTIME_STATUS)
190
+
191
+ ALL_TOOLS = tools_module.ALL_TOOLS
192
+ TOOL_MAP = tools_module.TOOL_MAP
193
+ logger = logging.getLogger(__name__)
194
+
195
+ # ---------- Cognitive Stack ----------
196
+ # Keep runtime engines package-local. The OpenClaw workspace is an R&D sandbox,
197
+ # not a stable import surface for this CLI.
198
+ try:
199
+ from .intuition_engine import IntuitionEngine as _IntuitionEngineCls
200
+ except ImportError:
201
+ _IntuitionEngineCls = None # type: ignore[assignment,misc]
202
+
203
+
204
+ def _make_engine(cls: type | None) -> Any:
205
+ if cls is None:
206
+ logger.debug("Cognitive engine class is unavailable.")
207
+ return None
208
+ try:
209
+ instance = cls()
210
+ logger.debug("Cognitive engine %s initialized.", cls.__name__)
211
+ return instance
212
+ except Exception as exc:
213
+ logger.debug("Cognitive engine %s unavailable: %s", cls.__name__, exc)
214
+ return None
215
+
216
+
217
+ _intuition_engine = _make_engine(_IntuitionEngineCls)
218
+
219
+ CLOUD_MODEL_CHOICES = [
220
+ "glm-4.6:cloud",
221
+ "gpt-oss:20b-cloud",
222
+ "gpt-oss:120b-cloud",
223
+ "qwen3-coder:480b-cloud",
224
+ "qwen3:235b-cloud",
225
+ "qwen3-vl:235b-cloud",
226
+ "deepseek-v3.1:671b-cloud",
227
+ ]
228
+ # xAI Grok models routed via subscription OAuth only. See xai_client.py.
229
+ # These are fallback names after OAuth is present but /v1/models is unavailable.
230
+ # Never add API-key fallback behavior here.
231
+ XAI_MODEL_CHOICES = [
232
+ "grok-4.3",
233
+ "grok-4.20-0309-reasoning",
234
+ "grok-4.20-0309-non-reasoning",
235
+ "grok-4.20-multi-agent-0309",
236
+ ]
237
+ # ChatGPT/Codex models routed via subscription OAuth. These are fallback names
238
+ # for the picker because ChatGPT OAuth tokens can be valid for Codex while
239
+ # api.openai.com model listing is unavailable or missing model.request scope.
240
+ CHATGPT_MODEL_CHOICES = [
241
+ "gpt-5.5",
242
+ "gpt-5.4",
243
+ "gpt-5.4-mini",
244
+ "gpt-5.3-codex-spark",
245
+ "gpt-5.1-codex",
246
+ ]
247
+ MAINTENANCE_CLOUD_MODEL = "glm-5.1:cloud"
248
+
249
+ ATTEMPT_PROMPT_LIMIT = 24
250
+ LOCAL_MODEL_LIST_TTL_SECONDS = 60.0 # increased: avoid re-querying Ollama after every generation
251
+
252
+ RUNTIME_STATUS: dict[str, Any] = {}
253
+ LOCAL_MODEL_CACHE: dict[str, tuple[float, list[str]]] = {}
254
+
255
+
256
+ def sanitize_prompt_text(text: str) -> str:
257
+ """Remove lone surrogate code points before history or tool writes."""
258
+ if not text:
259
+ return text
260
+ return text.encode("utf-8", "surrogatepass").decode("utf-8", "replace")
261
+
262
+
263
+ PROMPT_HISTORY_MAX_ENTRIES = 500
264
+ PROMPT_HISTORY_MAX_BYTES = 2 * 1024 * 1024
265
+ PROMPT_HISTORY_MAX_ENTRY_CHARS = 100_000
266
+ PROMPT_HISTORY_COMPACT_EVERY = 32
267
+
268
+
269
+ class SafeFileHistory(FileHistory):
270
+ """Wrap prompt_toolkit FileHistory so invalid surrogate text never gets persisted."""
271
+
272
+ def __init__(self, path: str) -> None:
273
+ history_path = Path(path)
274
+ history_path.parent.mkdir(parents=True, exist_ok=True)
275
+ if history_path.is_symlink():
276
+ raise OSError("prompt history path must not be a symlink")
277
+ if os.name == "posix":
278
+ os.chmod(history_path.parent, 0o700)
279
+ if history_path.exists():
280
+ os.chmod(history_path, 0o600)
281
+ self._stores_since_compaction = 0
282
+ super().__init__(path)
283
+
284
+ def store_string(self, string: str) -> None:
285
+ safe = sanitize_prompt_text(string)[:PROMPT_HISTORY_MAX_ENTRY_CHARS]
286
+ super().store_string(safe)
287
+ path = Path(str(self.filename))
288
+ if os.name == "posix":
289
+ os.chmod(path, 0o600)
290
+ self._stores_since_compaction += 1
291
+ try:
292
+ oversized = path.stat().st_size > PROMPT_HISTORY_MAX_BYTES
293
+ except OSError:
294
+ oversized = False
295
+ if oversized or self._stores_since_compaction >= PROMPT_HISTORY_COMPACT_EVERY:
296
+ self._compact_private_history()
297
+
298
+ def append_string(self, string: str) -> None:
299
+ super().append_string(sanitize_prompt_text(string))
300
+
301
+ @staticmethod
302
+ def _history_block(value: str) -> str:
303
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
304
+ lines = "".join(f"+{line}\n" for line in value.split("\n"))
305
+ return f"\n# {timestamp}\n{lines}"
306
+
307
+ def _compact_private_history(self) -> None:
308
+ newest = list(self.load_history_strings())
309
+ kept_newest: list[str] = []
310
+ retained_bytes = 0
311
+ for loaded_item in newest[:PROMPT_HISTORY_MAX_ENTRIES]:
312
+ # prompt_toolkit's history reader can retain carriage returns from
313
+ # files written in Windows text mode. Do not compound them on each
314
+ # bounded-history rewrite.
315
+ item = loaded_item.replace("\r", "")
316
+ block = self._history_block(item)
317
+ block_bytes = len(block.encode("utf-8"))
318
+ if retained_bytes + block_bytes > PROMPT_HISTORY_MAX_BYTES:
319
+ break
320
+ kept_newest.append(item)
321
+ retained_bytes += block_bytes
322
+ payload = "".join(self._history_block(item) for item in reversed(kept_newest))
323
+ _atomic_write_text(Path(str(self.filename)), payload)
324
+ if os.name == "posix":
325
+ os.chmod(self.filename, 0o600)
326
+ self._loaded_strings = list(kept_newest)
327
+ self._stores_since_compaction = 0
328
+
329
+
330
+ def _chip(label: str, value: str, *, fg: str, bg: str, value_fg: str) -> str:
331
+ del bg
332
+ return (
333
+ f'<style fg="{fg}"><b>{escape(label)}</b></style>'
334
+ f'<style fg="{value_fg}"> {escape(value)}</style>'
335
+ )
336
+
337
+
338
+ _LAST_REFRESH_TIME: float = 0.0
339
+ _REFRESH_MIN_INTERVAL_S: float = 2.0 # Skip redundant refresh within this window
340
+
341
+
342
+ def refresh_runtime_status(cfg: Config, client: Any | None = None, *, force: bool = False) -> None:
343
+ """Update the runtime status dict used by the toolbar and status footer.
344
+
345
+ Skips redundant refreshes within _REFRESH_MIN_INTERVAL_S unless force=True.
346
+ """
347
+ global _LAST_REFRESH_TIME
348
+ now = time.monotonic()
349
+ if not force and (now - _LAST_REFRESH_TIME) < _REFRESH_MIN_INTERVAL_S:
350
+ return
351
+ _LAST_REFRESH_TIME = now
352
+ model_info = _model_info_module.resolve_model_info(cfg, client)
353
+ used, total, remaining, runtime_cap, native_ctx = context_status(
354
+ cfg, client=client, model_info=model_info
355
+ )
356
+ if total > 0:
357
+ pct_left = int((remaining / total) * 100)
358
+ context = f"{used}/{total} ({pct_left}% left)"
359
+ else:
360
+ pct_left = None
361
+ context = "unknown"
362
+ last_metrics = RUNTIME_STATUS.get("last_metrics")
363
+ RUNTIME_STATUS.clear()
364
+ local_models: list[str] = []
365
+ is_xai = _model_info_module.is_xai_model(cfg.model)
366
+ is_chatgpt = _model_info_module.is_chatgpt_model(cfg.model)
367
+ if not cfg.cloud and not is_xai and not is_chatgpt:
368
+ local_models = local_model_names(cfg)
369
+ if is_xai:
370
+ mode = "xai"
371
+ elif is_chatgpt:
372
+ mode = "chatgpt"
373
+ elif cfg.cloud:
374
+ mode = "cloud"
375
+ else:
376
+ mode = "local"
377
+ RUNTIME_STATUS.update(
378
+ {
379
+ "context": context,
380
+ "context_used": used,
381
+ "context_total": total,
382
+ "context_native": native_ctx,
383
+ "context_runtime_cap": runtime_cap,
384
+ "context_pct_left": pct_left,
385
+ "model_info": model_info,
386
+ "local_models": local_models,
387
+ "cwd": compact_path(cfg.cwd, 32),
388
+ "theme": cfg.theme,
389
+ "model": cfg.model,
390
+ "mode": mode,
391
+ "auto_mode": cfg.auto_approve_active,
392
+ "safe_mode": cfg.safe_mode,
393
+ "tool_think_every": max(1, int(cfg.tool_think_every)),
394
+ "max_tool_iterations": max(1, int(cfg.max_tool_iterations)),
395
+ "memory_count": len(cfg.memories),
396
+ }
397
+ )
398
+ if last_metrics is not None:
399
+ RUNTIME_STATUS["last_metrics"] = last_metrics
400
+
401
+
402
+ def _ftr_chip(text: str, fg: str, *, bold: bool = False) -> str:
403
+ inner = escape(text)
404
+ if bold:
405
+ inner = f"<b>{inner}</b>"
406
+ return f'<style fg="{fg}">{inner}</style>'
407
+
408
+
409
+ def _ftr_sep(palette: dict[str, str]) -> str:
410
+ return f'<style fg="{palette["muted"]}"> · </style>'
411
+
412
+
413
+ def _format_short_count(value: Any) -> str:
414
+ try:
415
+ n = int(value)
416
+ except (TypeError, ValueError):
417
+ return "?"
418
+ if n >= 1_000_000:
419
+ formatted = f"{n / 1_000_000:.1f}M"
420
+ return formatted.replace(".0M", "M")
421
+ if n >= 1000:
422
+ formatted = f"{n / 1000:.1f}k"
423
+ return formatted.replace(".0k", "k")
424
+ return str(n)
425
+
426
+
427
+ def _connectivity_dot(cfg: Config, palette: dict[str, str]) -> str:
428
+ if (
429
+ _model_info_module.is_xai_model(cfg.model)
430
+ or _model_info_module.is_chatgpt_model(cfg.model)
431
+ or cfg.cloud
432
+ ):
433
+ color = palette["info"]
434
+ else:
435
+ cached = SERVER_READY_CACHE.get(cfg.host)
436
+ if cached and cached[1]:
437
+ color = palette["success"]
438
+ elif cached:
439
+ color = palette["error"]
440
+ else:
441
+ color = palette["muted"]
442
+ return f'<style fg="{color}">●</style>'
443
+
444
+
445
+ def _context_chip(palette: dict[str, str]) -> str:
446
+ used = RUNTIME_STATUS.get("context_used")
447
+ total = RUNTIME_STATUS.get("context_total")
448
+ native = RUNTIME_STATUS.get("context_native")
449
+ runtime_cap = RUNTIME_STATUS.get("context_runtime_cap")
450
+ pct_left = RUNTIME_STATUS.get("context_pct_left")
451
+ if not total or pct_left is None:
452
+ return _ftr_chip("▣ ctx ?", palette["muted"])
453
+ if pct_left >= 50:
454
+ color = palette["muted"]
455
+ warn = ""
456
+ elif pct_left >= 20:
457
+ color = palette["warning"]
458
+ warn = ""
459
+ else:
460
+ color = palette["error"]
461
+ warn = " ⚠"
462
+ body = f"▣ {_format_short_count(used)}/{_format_short_count(total)} {pct_left}%{warn}"
463
+ if (
464
+ isinstance(native, int)
465
+ and native > 0
466
+ and isinstance(runtime_cap, int)
467
+ and runtime_cap > 0
468
+ and native > runtime_cap
469
+ ):
470
+ body += f" · cap {_format_short_count(runtime_cap)}"
471
+ return _ftr_chip(body, color)
472
+
473
+
474
+ def _token_rate_chip(palette: dict[str, str]) -> str | None:
475
+ metrics = RUNTIME_STATUS.get("last_metrics") or {}
476
+ if not isinstance(metrics, dict):
477
+ return None
478
+ timestamp = metrics.get("timestamp")
479
+ if not timestamp or (time.time() - float(timestamp)) > FOOTER_METRICS_FRESHNESS_SECONDS:
480
+ return None
481
+ eval_count = metrics.get("eval_count")
482
+ eval_duration = metrics.get("eval_duration")
483
+ try:
484
+ count = float(eval_count or 0)
485
+ duration_s = float(eval_duration or 0) / 1_000_000_000.0
486
+ except (TypeError, ValueError):
487
+ return None
488
+ if count <= 0 or duration_s <= 0:
489
+ return None
490
+ rate = count / duration_s
491
+ return _ftr_chip(f"{rate:.0f} tok/s", palette["info"])
492
+
493
+
494
+ def build_status_toolbar(cfg: Config):
495
+ palette = theme_colors(cfg.theme)
496
+ sep = _ftr_sep(palette)
497
+ parts: list[str] = []
498
+
499
+ parts.append(" ")
500
+ parts.append(_connectivity_dot(cfg, palette))
501
+ parts.append(" ")
502
+ parts.append(_ftr_chip(RUNTIME_STATUS.get("model", cfg.model), palette["text"], bold=True))
503
+ parts.append(sep)
504
+ mode = RUNTIME_STATUS.get("mode", "local")
505
+ parts.append(_ftr_chip(mode, palette["info"] if mode in {"cloud", "xai", "chatgpt"} else palette["muted"]))
506
+ parts.append(sep)
507
+ parts.append(_context_chip(palette))
508
+
509
+ tool_max = RUNTIME_STATUS.get("max_tool_iterations", max(1, int(cfg.max_tool_iterations)))
510
+ reflect = RUNTIME_STATUS.get("tool_think_every", max(1, int(cfg.tool_think_every)))
511
+ parts.append(sep)
512
+ parts.append(_ftr_chip(f"tools {tool_max}", palette["muted"]))
513
+ parts.append(" ")
514
+ parts.append(_ftr_chip(f"reflect {reflect}", palette["muted"]))
515
+
516
+ rate_chip = _token_rate_chip(palette)
517
+ if rate_chip:
518
+ parts.append(sep)
519
+ parts.append(rate_chip)
520
+
521
+ if not RUNTIME_STATUS.get("safe_mode", cfg.safe_mode):
522
+ parts.append(sep)
523
+ parts.append(_ftr_chip("safe off", palette["error"], bold=True))
524
+
525
+ if RUNTIME_STATUS.get("auto_mode", cfg.auto_approve_active):
526
+ parts.append(sep)
527
+ parts.append(_ftr_chip("auto on", palette["warning"], bold=True))
528
+
529
+ parts.append(" ")
530
+ return HTML("".join(parts))
531
+
532
+
533
+ def build_prompt_style(palette: dict[str, str]) -> Style:
534
+ return Style.from_dict(
535
+ {
536
+ # noreverse: prompt_toolkit defaults reverse video on toolbars (white bar bug).
537
+ "bottom-toolbar": f"noreverse bg:{palette['surface_alt']} {palette['text']}",
538
+ "bottom-toolbar.off": f"noreverse bg:{palette['surface_alt']} {palette['text']}",
539
+ "bottom-toolbar.on": f"noreverse bg:{palette['surface_alt']} {palette['text']}",
540
+ "rprompt": f"noreverse bg:{palette['surface']} {palette['muted']}",
541
+ "bottom-toolbar.text": f"noreverse {palette['text']}",
542
+ "rprompt.text": f"noreverse {palette['muted']}",
543
+ }
544
+ )
545
+
546
+
547
+ def invalidate_prompt_toolbar(session: Any | None) -> None:
548
+ """Repaint the persistent footer after context/metrics change."""
549
+ if session is None:
550
+ return
551
+ try:
552
+ app = session.app
553
+ if app is not None:
554
+ app.invalidate()
555
+ except Exception:
556
+ pass
557
+
558
+
559
+ def build_status_rprompt(cfg: Config):
560
+ palette = theme_colors(cfg.theme)
561
+ sep = _ftr_sep(palette)
562
+ cwd = RUNTIME_STATUS.get("cwd", compact_path(cfg.cwd, 32))
563
+ theme_name = RUNTIME_STATUS.get("theme", cfg.theme)
564
+ memory_count = RUNTIME_STATUS.get("memory_count", len(cfg.memories))
565
+ from . import session_mode
566
+
567
+ mode_label = session_mode.normalize_mode(cfg.session_mode)
568
+ parts = [
569
+ _ftr_chip(cwd, palette["muted"]),
570
+ sep,
571
+ _ftr_chip(mode_label, palette["info"] if mode_label == "publish" else palette["muted"]),
572
+ sep,
573
+ _ftr_chip(f"mem {memory_count}", palette["text"]),
574
+ sep,
575
+ _ftr_chip(theme_name, palette["primary"]),
576
+ ]
577
+ return HTML("".join(parts))
578
+
579
+
580
+ def default_embedding_model(cfg: Config, local_names: list[str] | None = None) -> str:
581
+ if cfg.model and is_embedding_model_name(cfg.model) and cfg.model.lower() not in harness.DEPRECATED_EMBED_MODELS:
582
+ return cfg.model
583
+ preferred = harness.resolve_embed_model(cfg)
584
+ base = preferred.split(":", 1)[0]
585
+ local = local_names if local_names is not None else local_model_names(cfg)
586
+ if any(name.startswith(base) for name in local):
587
+ return preferred
588
+ for candidate in (
589
+ preferred,
590
+ "qwen3-embedding",
591
+ "embeddinggemma",
592
+ "paraphrase-multilingual:latest",
593
+ "nomic-embed-text",
594
+ ):
595
+ cand_base = candidate.split(":", 1)[0]
596
+ if any(name.startswith(cand_base) for name in local):
597
+ return candidate
598
+ return preferred
599
+
600
+
601
+ def default_vision_model(cfg: Config) -> str:
602
+ return cfg.model if is_vision_model_name(cfg.model) else "gemma3"
603
+
604
+
605
+ def resolve_multimodal_model(
606
+ cfg: Config,
607
+ *,
608
+ explicit_model: str | None,
609
+ available: list[str],
610
+ predicate,
611
+ fallback: str,
612
+ install_hint: str,
613
+ missing_hint: str,
614
+ ) -> str | None:
615
+ if explicit_model:
616
+ if explicit_model in available:
617
+ return explicit_model
618
+ show_error(install_hint)
619
+ return None
620
+ match = next((name for name in available if predicate(name)), "")
621
+ if match:
622
+ return match
623
+ if fallback and fallback in available:
624
+ return fallback
625
+ if available:
626
+ show_error(missing_hint)
627
+ else:
628
+ show_error("No local models are available yet. Use /models or pull a compatible model first.")
629
+ return None
630
+
631
+
632
+ def handle_embed_command(arg: str, cfg: Config, client: Client) -> None:
633
+ parser = argparse.ArgumentParser(prog="/embed", add_help=False, exit_on_error=False)
634
+ parser.add_argument("--model", default=None)
635
+ parser.add_argument("--file", default=None)
636
+ parser.add_argument("--truncate", action=argparse.BooleanOptionalAction, default=True)
637
+ parser.add_argument("--dimensions", type=int, default=None)
638
+ parser.add_argument("text", nargs="*")
639
+ try:
640
+ ns = parser.parse_args(shlex.split(arg))
641
+ except Exception:
642
+ show_error("Usage: /embed [--model MODEL] [--file PATH] [--no-truncate] [--dimensions N] TEXT")
643
+ return
644
+
645
+ text = " ".join(ns.text).strip()
646
+ if ns.file:
647
+ path = Path(ns.file).expanduser()
648
+ if not path.is_absolute():
649
+ path = Path(cfg.cwd) / path
650
+ if not path.exists():
651
+ show_error(f"File not found: {path}")
652
+ return
653
+ text = path.read_text(encoding="utf-8", errors="replace")
654
+ if not text.strip():
655
+ show_error("Usage: /embed [--model MODEL] [--file PATH] [--no-truncate] [--dimensions N] TEXT")
656
+ return
657
+
658
+ if cfg.cloud:
659
+ start_supplemental_gateway(cfg)
660
+ available = [
661
+ name for name in local_model_names(cfg)
662
+ if name.lower() not in harness.DEPRECATED_EMBED_MODELS
663
+ ]
664
+ model = resolve_multimodal_model(
665
+ cfg,
666
+ explicit_model=ns.model,
667
+ available=available,
668
+ predicate=is_embedding_model_name,
669
+ fallback=default_embedding_model(cfg),
670
+ install_hint="That embedding model is not installed locally. Use /models and pick one like qwen3-embedding, embeddinggemma, or nomic-embed-text.",
671
+ missing_hint="No supported embedding model is installed. Use /models and pick one like qwen3-embedding, embeddinggemma, or nomic-embed-text.",
672
+ )
673
+ if not model:
674
+ return
675
+ try:
676
+ response: Any = tools_module.gateway_embed(text, model, ns.truncate, ns.dimensions)
677
+ if response is None:
678
+ response = Client(host=cfg.host).embed(model=model, input=text, truncate=ns.truncate, dimensions=ns.dimensions)
679
+ except Exception as exc:
680
+ show_error(f"Error generating embeddings: {exc}")
681
+ return
682
+ payload = tools_module.unpack_embed_response(
683
+ response, model, text, truncate=ns.truncate, dimensions=ns.dimensions
684
+ )
685
+ console.print(json.dumps(payload, indent=2))
686
+
687
+
688
+ def handle_vision_command(arg: str, cfg: Config, client: Client) -> None:
689
+ parser = argparse.ArgumentParser(prog="/vision", add_help=False, exit_on_error=False)
690
+ parser.add_argument("--model", default=None)
691
+ parser.add_argument("--prompt", default=None)
692
+ parser.add_argument("image", nargs="?")
693
+ parser.add_argument("question", nargs="*")
694
+ try:
695
+ ns = parser.parse_args(shlex.split(arg))
696
+ except Exception:
697
+ show_error("Usage: /vision [--model MODEL] [--prompt TEXT] IMAGE [QUESTION]")
698
+ return
699
+
700
+ image_path = ns.image
701
+ if not image_path:
702
+ show_error("Usage: /vision [--model MODEL] [--prompt TEXT] IMAGE [QUESTION]")
703
+ return
704
+ prompt = ns.prompt or " ".join(ns.question).strip() or "What is in this image? Be concise."
705
+ if cfg.cloud:
706
+ start_supplemental_gateway(cfg)
707
+ available = local_model_names(cfg)
708
+ model = resolve_multimodal_model(
709
+ cfg,
710
+ explicit_model=ns.model,
711
+ available=available,
712
+ predicate=is_vision_model_name,
713
+ fallback=default_vision_model(cfg),
714
+ install_hint="That vision model is not installed locally. Use /models and pick one like gemma3, qwen3-vl, or llava.",
715
+ missing_hint="No vision model is installed. Use /models and pick one like gemma3, qwen3-vl, or llava.",
716
+ )
717
+ if not model:
718
+ return
719
+ resolved = Path(image_path).expanduser()
720
+ if not resolved.is_absolute():
721
+ resolved = Path(cfg.cwd) / resolved
722
+ if not resolved.exists():
723
+ show_error(f"Image not found: {resolved}")
724
+ return
725
+ try:
726
+ response = Client(host=cfg.host).chat(
727
+ model=model,
728
+ messages=[
729
+ {
730
+ "role": "user",
731
+ "content": prompt,
732
+ "images": [str(resolved)],
733
+ }
734
+ ],
735
+ stream=False,
736
+ keep_alive=cfg.keep_alive,
737
+ )
738
+ except Exception as exc:
739
+ show_error(f"Error running vision request: {exc}")
740
+ return
741
+ message = get_attr(response, "message", {}) or {}
742
+ content = get_attr(message, "content", "")
743
+ console.print(content or "(empty response)")
744
+
745
+
746
+ def handle_pdf_command(arg: str, cfg: Config) -> None:
747
+ parser = argparse.ArgumentParser(prog="/pdf", add_help=False, exit_on_error=False)
748
+ parser.add_argument("--pages", type=int, default=24)
749
+ parser.add_argument("--chars", type=int, default=50_000)
750
+ parser.add_argument("path", nargs="?")
751
+ try:
752
+ ns = parser.parse_args(shlex.split(arg))
753
+ except Exception:
754
+ show_error("Usage: /pdf [--pages N] [--chars N] PATH")
755
+ return
756
+ if not ns.path:
757
+ show_error("Usage: /pdf [--pages N] [--chars N] PATH")
758
+ return
759
+ from .tools import read_pdf
760
+
761
+ console.print(
762
+ read_pdf(
763
+ ns.path,
764
+ cwd=cfg.cwd,
765
+ max_pages=max(1, int(ns.pages)),
766
+ max_chars=max(1000, int(ns.chars)),
767
+ )
768
+ )
769
+
770
+
771
+ def run_ollama_login() -> None:
772
+ show_info("Starting `ollama signin`. Follow the browser/terminal prompts if they appear.")
773
+ try:
774
+ result = subprocess.run(["ollama", "signin"], check=False)
775
+ except FileNotFoundError:
776
+ show_error("Could not find `ollama` on PATH. Install Ollama before signing in.")
777
+ return
778
+ except KeyboardInterrupt:
779
+ show_info("Ollama sign-in interrupted.")
780
+ return
781
+ if result.returncode == 0:
782
+ show_info("Ollama sign-in finished.")
783
+ else:
784
+ show_error(f"`ollama signin` exited with code {result.returncode}.")
785
+
786
+
787
+ def run_xai_login(arg: str = "") -> None:
788
+ load_runtime_env(override=True)
789
+ if not xai_auth.client_id_configured():
790
+ show_error(
791
+ "xAI subscription OAuth is optional and not configured. "
792
+ "Set XAI_CLIENT_ID in ~/.algo_cli/env (or ALGO_CLI_ENV_FILE) to a client id "
793
+ "you are authorized to use, then retry /xai-login. Algo CLI does not bundle one."
794
+ )
795
+ return
796
+ tokens_split = (arg or "").split()
797
+ no_browser = "--no-browser" in tokens_split
798
+ manual_only = "--manual" in tokens_split
799
+ redirect_port = xai_auth.XAI_REDIRECT_PORT if manual_only else xai_auth.select_redirect_port()
800
+ if redirect_port is None:
801
+ show_error(
802
+ "No xAI loopback redirect ports are available on 127.0.0.1. "
803
+ "Close another login listener or retry with /xai-login --manual."
804
+ )
805
+ return
806
+ try:
807
+ prep = xai_auth.begin_login(no_browser=no_browser or manual_only, redirect_port=redirect_port)
808
+ except Exception as exc:
809
+ show_error(f"Could not start xAI login: {xai_auth.safe_error_message(exc)}")
810
+ return
811
+ if no_browser or manual_only:
812
+ show_info("Open this URL on any browser you're signed into xAI with:")
813
+ console.print(prep["auth_url"])
814
+ if no_browser and not manual_only:
815
+ show_info("If you're SSHed in, forward the callback port first:")
816
+ console.print(f" {prep['ssh_tunnel_cmd']}")
817
+ elif prep.get("browser_opened"):
818
+ # The full authorization URL contains the configured client id. Avoid
819
+ # echoing it into routine terminal transcripts when the browser opened.
820
+ show_info("Opened xAI authorization in your browser.")
821
+ else:
822
+ show_info("The browser did not open. Open this authorization URL manually:")
823
+ console.print(prep["auth_url"])
824
+
825
+ callback: dict[str, str] = {}
826
+ if not manual_only:
827
+ show_info(f"Listening on {prep['redirect_uri']} (waiting up to 5 minutes)…")
828
+ show_info("If the browser shows 'Could not establish connection' with a code, paste it here when prompted.")
829
+ try:
830
+ callback = xai_auth.run_loopback_capture(redirect_port=redirect_port)
831
+ except KeyboardInterrupt:
832
+ show_info("Loopback listener cancelled — falling back to manual paste.")
833
+ except Exception as exc:
834
+ show_error(f"Loopback listener failed: {exc} — falling back to manual paste.")
835
+
836
+ if not callback:
837
+ if not manual_only:
838
+ show_info("Loopback redirect did not arrive. If xAI showed you a code, paste it now.")
839
+ try:
840
+ pasted = input("xAI callback URL (or blank to cancel): ").strip()
841
+ except (EOFError, KeyboardInterrupt):
842
+ show_info("xAI login cancelled.")
843
+ return
844
+ if not pasted:
845
+ show_info("xAI login cancelled.")
846
+ return
847
+ parsed = urlparse(pasted)
848
+ if parsed.query:
849
+ qs = parse_qs(parsed.query)
850
+ callback = {key: values[0] for key, values in qs.items() if values}
851
+ else:
852
+ show_error("Manual xAI login requires the full callback URL so the OAuth state can be verified.")
853
+ return
854
+ callback["redirect_uri"] = prep["redirect_uri"]
855
+
856
+ try:
857
+ if callback:
858
+ callback.setdefault("redirect_uri", prep["redirect_uri"])
859
+ tokens = xai_auth.complete_login(prep["code_verifier"], prep["state"], callback)
860
+ except Exception as exc:
861
+ show_error(xai_auth.safe_error_message(exc))
862
+ return
863
+ expires_in = max(0, int(tokens.get("expires_at", 0)) - int(time.time()))
864
+ show_info(f"xAI authentication successful (token valid for {expires_in}s).")
865
+
866
+
867
+ def run_xai_logout() -> None:
868
+ if xai_auth.clear_tokens():
869
+ show_info("xAI tokens cleared.")
870
+ else:
871
+ show_info("No stored xAI tokens to clear.")
872
+
873
+
874
+ def run_xai_status() -> None:
875
+ load_runtime_env(override=True)
876
+ status = xai_auth.auth_status()
877
+ if not status.get("client_configured"):
878
+ if status.get("token_present"):
879
+ show_info(
880
+ "xAI subscription OAuth: a local token exists, but XAI_CLIENT_ID is not configured; "
881
+ "refresh and new login are unavailable. Set your authorized client id in ~/.algo_cli/env."
882
+ )
883
+ else:
884
+ show_info(
885
+ "xAI subscription OAuth: optional, not configured. Algo CLI bundles no client id. "
886
+ "Set XAI_CLIENT_ID in ~/.algo_cli/env only if you want to enable this provider."
887
+ )
888
+ return
889
+ if not status.get("authenticated"):
890
+ show_info("xAI subscription OAuth: client configured, not authenticated. Run /xai-login to continue.")
891
+ return
892
+ show_info(
893
+ f"xAI: authenticated. Token expires in {status['expires_in']}s "
894
+ f"(refresh token: {'yes' if status['has_refresh_token'] else 'no'}, "
895
+ f"scope: {status.get('scope') or '?'})."
896
+ )
897
+
898
+
899
+ def run_google_login(arg: str = "") -> None:
900
+ tokens_split = (arg or "").split()
901
+ no_browser = "--no-browser" in tokens_split
902
+ manual_only = "--manual" in tokens_split
903
+ redirect_port = google_workspace_auth.GOOGLE_REDIRECT_PORT if manual_only else google_workspace_auth.select_redirect_port()
904
+ if redirect_port is None:
905
+ show_error("No Google Workspace loopback port is free. Close anything bound to 56251-56270 or use --manual.")
906
+ return
907
+ try:
908
+ prep = google_workspace_auth.begin_login(no_browser=no_browser or manual_only, redirect_port=redirect_port)
909
+ except Exception as exc:
910
+ show_error(f"Could not start Google login: {exc}")
911
+ return
912
+ if no_browser or manual_only:
913
+ show_info("Open this URL in a browser where you are signed into Google with the target Workspace account:")
914
+ console.print(prep["auth_url"])
915
+ if no_browser and not manual_only:
916
+ show_info("If you're SSHed in, forward the callback port first:")
917
+ console.print(f" {prep['ssh_tunnel_cmd']}")
918
+ else:
919
+ show_info("Opening Google auth in your browser…")
920
+ show_info("If the browser does not open, copy this URL manually:")
921
+ console.print(prep["auth_url"])
922
+
923
+ callback: dict[str, str] = {}
924
+ if not manual_only:
925
+ try:
926
+ callback = google_workspace_auth.wait_for_callback(
927
+ redirect_port=int(prep["redirect_port"]),
928
+ timeout=float(prep.get("timeout", 300.0)),
929
+ )
930
+ except Exception as exc:
931
+ show_info(f"Google loopback did not arrive automatically: {exc}")
932
+ if not callback:
933
+ show_info(
934
+ "Loopback redirect did not arrive. Copy the callback URL and run "
935
+ "/google-callback --clipboard, or paste the full callback URL now."
936
+ )
937
+ try:
938
+ pasted = input("Google callback URL (or blank to cancel): ").strip()
939
+ except (EOFError, KeyboardInterrupt):
940
+ show_info("Google login cancelled.")
941
+ return
942
+ if not pasted:
943
+ show_info("Google login cancelled.")
944
+ return
945
+ callback = google_workspace_auth.parse_callback_value(pasted)
946
+ if not callback:
947
+ show_error("Manual Google login requires the full callback URL so the OAuth state can be verified.")
948
+ return
949
+ else:
950
+ try:
951
+ pasted = input("Google callback URL (or blank to cancel): ").strip()
952
+ except (EOFError, KeyboardInterrupt):
953
+ show_info("Google login cancelled.")
954
+ return
955
+ if not pasted:
956
+ show_info("Google login cancelled.")
957
+ return
958
+ callback = google_workspace_auth.parse_callback_value(pasted)
959
+ if not callback:
960
+ show_error("Manual Google login requires the full callback URL so the OAuth state can be verified.")
961
+ return
962
+
963
+ try:
964
+ callback.setdefault("redirect_uri", prep["redirect_uri"])
965
+ tokens = google_workspace_auth.complete_login(prep["code_verifier"], prep["state"], callback)
966
+ except Exception as exc:
967
+ show_error(str(exc))
968
+ return
969
+ expires_in = max(0, int(tokens.get("expires_at", 0)) - int(time.time()))
970
+ show_info(f"Google Workspace authentication successful (token valid for {expires_in}s).")
971
+
972
+
973
+ def read_clipboard_text() -> str:
974
+ try:
975
+ result = subprocess.run(
976
+ ["pbpaste"],
977
+ capture_output=True,
978
+ text=True,
979
+ check=False,
980
+ timeout=5,
981
+ )
982
+ except (OSError, subprocess.SubprocessError) as exc:
983
+ raise RuntimeError(f"Could not read clipboard: {exc}") from exc
984
+ if result.returncode != 0:
985
+ raise RuntimeError("Could not read clipboard with pbpaste.")
986
+ return result.stdout.strip()
987
+
988
+
989
+ def run_google_callback(arg: str = "") -> None:
990
+ pending = google_workspace_auth.load_pending_login()
991
+ if not pending:
992
+ show_error("No pending Google login. Run /google-login first, then approve access.")
993
+ return
994
+
995
+ tokens_split = shlex.split(arg or "")
996
+ callback_text = ""
997
+ if "--clipboard" in tokens_split:
998
+ try:
999
+ callback_text = read_clipboard_text()
1000
+ except Exception as exc:
1001
+ show_error(str(exc))
1002
+ return
1003
+ elif "--file" in tokens_split:
1004
+ idx = tokens_split.index("--file")
1005
+ if idx + 1 >= len(tokens_split):
1006
+ show_error("Usage: /google-callback --file PATH")
1007
+ return
1008
+ try:
1009
+ callback_text = Path(tokens_split[idx + 1]).expanduser().read_text(encoding="utf-8", errors="replace").strip()
1010
+ except OSError as exc:
1011
+ show_error(f"Could not read callback file: {exc}")
1012
+ return
1013
+ else:
1014
+ callback_text = (arg or "").strip()
1015
+ if not callback_text:
1016
+ show_info("Paste the Google callback URL, or use /google-callback --clipboard.")
1017
+ try:
1018
+ callback_text = input("Google callback URL (or blank to cancel): ").strip()
1019
+ except (EOFError, KeyboardInterrupt):
1020
+ show_info("Google login cancelled.")
1021
+ return
1022
+ if not callback_text:
1023
+ show_info("Google login cancelled.")
1024
+ return
1025
+
1026
+ callback = google_workspace_auth.parse_callback_value(callback_text)
1027
+ if not callback:
1028
+ show_error("Could not parse Google callback URL. Copy the full callback URL and retry /google-callback --clipboard.")
1029
+ return
1030
+ callback.setdefault("redirect_uri", pending["redirect_uri"])
1031
+ try:
1032
+ tokens = google_workspace_auth.complete_login(pending["code_verifier"], pending["state"], callback)
1033
+ except Exception as exc:
1034
+ show_error(str(exc))
1035
+ return
1036
+ expires_in = max(0, int(tokens.get("expires_at", 0)) - int(time.time()))
1037
+ show_info(f"Google Workspace authentication successful (token valid for {expires_in}s).")
1038
+
1039
+
1040
+ def run_google_logout() -> None:
1041
+ if google_workspace_auth.clear_tokens():
1042
+ show_info("Google Workspace tokens cleared.")
1043
+ else:
1044
+ show_info("No stored Google Workspace tokens to clear.")
1045
+
1046
+
1047
+ def run_google_status() -> None:
1048
+ status = google_workspace_auth.auth_status()
1049
+ if not status.get("client_configured"):
1050
+ show_error("GOOGLE_OAUTH_CLIENT_ID is not set. Export it (and GOOGLE_OAUTH_CLIENT_SECRET) before /google-login.")
1051
+ return
1052
+ if not status.get("authenticated"):
1053
+ show_info("Google Workspace: not authenticated. Run /google-login to start the OAuth flow.")
1054
+ return
1055
+ show_info(
1056
+ f"Google Workspace: authenticated. Token expires in {status['expires_in']}s "
1057
+ f"(refresh token: {'yes' if status['has_refresh_token'] else 'no'}, "
1058
+ f"scope: {status.get('scope') or '?'})."
1059
+ )
1060
+
1061
+
1062
+ _GOOGLE_TEXT_LIMIT = 6000
1063
+
1064
+
1065
+ def _google_usage_text() -> str:
1066
+ return (
1067
+ "Google Workspace subcommands:\n"
1068
+ " /google drive-list [query] [--max N]\n"
1069
+ " /google drive-search NAME [--max N] [--mime MIME]\n"
1070
+ " /google drive-get FILE_ID [--download | --export MIME]\n"
1071
+ " /google docs-get DOCUMENT_ID\n"
1072
+ " /google sheets-values SPREADSHEET_ID RANGE\n"
1073
+ " /google calendar-list [--max N] [--time-min RFC3339] [--time-max RFC3339]\n"
1074
+ " /google gmail-list [query] [--max N] [--label LABEL]\n"
1075
+ " /google gmail-get MESSAGE_ID\n"
1076
+ " /google gmail-draft --to EMAIL --subject SUBJECT [--html-file PATH | --text-file PATH | BODY...] [--cc EMAIL] [--bcc EMAIL]\n"
1077
+ " /google help"
1078
+ )
1079
+
1080
+
1081
+ def _google_pop_option(tokens: list[str], option: str) -> str | None:
1082
+ value: str | None = None
1083
+ kept: list[str] = []
1084
+ i = 0
1085
+ while i < len(tokens):
1086
+ if tokens[i] == option:
1087
+ if i + 1 >= len(tokens):
1088
+ raise ValueError(f"{option} requires a value")
1089
+ value = tokens[i + 1]
1090
+ i += 2
1091
+ else:
1092
+ kept.append(tokens[i])
1093
+ i += 1
1094
+ tokens[:] = kept
1095
+ return value
1096
+
1097
+
1098
+ def _google_pop_int_option(tokens: list[str], option: str, default: int) -> int:
1099
+ raw = _google_pop_option(tokens, option)
1100
+ if raw is None:
1101
+ return default
1102
+ try:
1103
+ value = int(raw)
1104
+ except ValueError as exc:
1105
+ raise ValueError(f"{option} must be an integer") from exc
1106
+ if value < 1:
1107
+ raise ValueError(f"{option} must be at least 1")
1108
+ return value
1109
+
1110
+
1111
+ def _google_pop_flag(tokens: list[str], flag: str) -> bool:
1112
+ found = False
1113
+ kept: list[str] = []
1114
+ for token in tokens:
1115
+ if token == flag:
1116
+ found = True
1117
+ else:
1118
+ kept.append(token)
1119
+ tokens[:] = kept
1120
+ return found
1121
+
1122
+
1123
+ def _google_print_lines(lines: list[str]) -> None:
1124
+ for line in lines:
1125
+ console.print(line)
1126
+
1127
+
1128
+ def _google_print_text(text: str, *, limit: int = _GOOGLE_TEXT_LIMIT) -> None:
1129
+ console.print(text[:limit] + ("\n...[truncated]" if len(text) > limit else ""))
1130
+
1131
+
1132
+ def run_google(arg: str = "") -> None:
1133
+ """Dispatch Google Workspace subcommands (reads plus Gmail draft creation)."""
1134
+ try:
1135
+ tokens_split = shlex.split(arg or "")
1136
+ except ValueError as exc:
1137
+ show_error(f"Invalid /google arguments: {exc}")
1138
+ return
1139
+ if not tokens_split or tokens_split[0] in {"help", "--help", "-h"}:
1140
+ show_info(_google_usage_text())
1141
+ return
1142
+ if not google_workspace_auth.get_valid_token():
1143
+ show_error("Not authenticated with Google Workspace. Run /google-login first.")
1144
+ return
1145
+ sub = tokens_split[0]
1146
+ rest = tokens_split[1:]
1147
+ client = google_workspace.GoogleWorkspaceClient()
1148
+ try:
1149
+ if sub == "drive-list":
1150
+ args = list(rest)
1151
+ max_n = _google_pop_int_option(args, "--max", 20)
1152
+ query = " ".join(args).strip() or None
1153
+ payload = client.drive_list(query=query, page_size=max_n)
1154
+ _google_print_lines(google_workspace.format_drive_files(payload))
1155
+ elif sub == "drive-search":
1156
+ args = list(rest)
1157
+ max_n = _google_pop_int_option(args, "--max", 20)
1158
+ mime_type = _google_pop_option(args, "--mime")
1159
+ name = " ".join(args).strip()
1160
+ if not name:
1161
+ show_error("Usage: /google drive-search NAME [--max N] [--mime MIME]")
1162
+ return
1163
+ payload = client.drive_search(name, mime_type=mime_type, page_size=max_n)
1164
+ _google_print_lines(google_workspace.format_drive_files(payload))
1165
+ elif sub == "drive-get":
1166
+ if not rest:
1167
+ show_error("Usage: /google drive-get FILE_ID [--download | --export MIME]")
1168
+ return
1169
+ args = list(rest[1:])
1170
+ file_id = rest[0]
1171
+ export_mime = _google_pop_option(args, "--export")
1172
+ download = _google_pop_flag(args, "--download")
1173
+ if args:
1174
+ show_error("Usage: /google drive-get FILE_ID [--download | --export MIME]")
1175
+ return
1176
+ if export_mime:
1177
+ data, _headers = client.drive_export(file_id, mime_type=export_mime)
1178
+ _google_print_text(data.decode("utf-8", errors="replace"))
1179
+ elif download:
1180
+ data, _headers = client.drive_download(file_id)
1181
+ _google_print_text(data.decode("utf-8", errors="replace"))
1182
+ else:
1183
+ console.print(json.dumps(client.drive_get(file_id), indent=2, sort_keys=True))
1184
+ elif sub == "docs-get":
1185
+ if len(rest) != 1:
1186
+ show_error("Usage: /google docs-get DOCUMENT_ID")
1187
+ return
1188
+ document = client.docs_get(rest[0])
1189
+ title = document.get("title")
1190
+ if title:
1191
+ console.print(f"# {title}")
1192
+ _google_print_text(google_workspace.format_docs_plain_text(document, client))
1193
+ elif sub == "sheets-values":
1194
+ if len(rest) != 2:
1195
+ show_error("Usage: /google sheets-values SPREADSHEET_ID RANGE")
1196
+ return
1197
+ payload = client.sheets_values_get(rest[0], rest[1])
1198
+ _google_print_text(google_workspace.format_sheet_values(payload))
1199
+ elif sub == "calendar-list":
1200
+ args = list(rest)
1201
+ max_n = _google_pop_int_option(args, "--max", 20)
1202
+ time_min = _google_pop_option(args, "--time-min")
1203
+ time_max = _google_pop_option(args, "--time-max")
1204
+ if args:
1205
+ show_error("Usage: /google calendar-list [--max N] [--time-min RFC3339] [--time-max RFC3339]")
1206
+ return
1207
+ payload = client.calendar_events_list(time_min=time_min, time_max=time_max, max_results=max_n)
1208
+ _google_print_lines(google_workspace.format_calendar_events(payload))
1209
+ elif sub == "gmail-list":
1210
+ args = list(rest)
1211
+ max_n = _google_pop_int_option(args, "--max", 20)
1212
+ label = _google_pop_option(args, "--label")
1213
+ query_parts = list(args)
1214
+ if label:
1215
+ query_parts.insert(0, f"label:{label}")
1216
+ payload = client.gmail_list(query=" ".join(query_parts).strip() or None, max_results=max_n)
1217
+ messages = payload.get("messages", []) or []
1218
+ if not messages:
1219
+ console.print(" (no messages)")
1220
+ for msg in messages:
1221
+ console.print(f" - id={msg.get('id', '?')} thread={msg.get('threadId', '?')}")
1222
+ elif sub == "gmail-get":
1223
+ if not rest:
1224
+ show_error("Usage: /google gmail-get MESSAGE_ID")
1225
+ return
1226
+ message = client.gmail_get(rest[0], fmt="metadata")
1227
+ _google_print_text(google_workspace.format_gmail_message(message))
1228
+ elif sub == "gmail-draft":
1229
+ args = list(rest)
1230
+ to = _google_pop_option(args, "--to")
1231
+ subject = _google_pop_option(args, "--subject")
1232
+ cc = _google_pop_option(args, "--cc")
1233
+ bcc = _google_pop_option(args, "--bcc")
1234
+ html_file = _google_pop_option(args, "--html-file")
1235
+ text_file = _google_pop_option(args, "--text-file")
1236
+ if not to or not subject:
1237
+ show_error("Usage: /google gmail-draft --to EMAIL --subject SUBJECT [--html-file PATH | --text-file PATH | BODY...] [--cc EMAIL] [--bcc EMAIL]")
1238
+ return
1239
+ if html_file and text_file:
1240
+ show_error("Use either --html-file or --text-file, not both.")
1241
+ return
1242
+ html_body = None
1243
+ text_body = None
1244
+ if html_file:
1245
+ html_body = Path(html_file).expanduser().read_text(encoding="utf-8", errors="replace")
1246
+ elif text_file:
1247
+ text_body = Path(text_file).expanduser().read_text(encoding="utf-8", errors="replace")
1248
+ else:
1249
+ text_body = " ".join(args).strip()
1250
+ draft = client.gmail_create_draft(to=to, subject=subject, html_body=html_body, text_body=text_body, cc=cc, bcc=bcc)
1251
+ message = draft.get("message") or {}
1252
+ show_info(f"Gmail draft created: draft_id={draft.get('id', '?')} message_id={message.get('id', '?')}")
1253
+ else:
1254
+ show_error(f"Unknown /google subcommand: {sub}. Try /google help.")
1255
+ except ValueError as exc:
1256
+ show_error(str(exc))
1257
+ except Exception as exc:
1258
+ show_error(f"Google Workspace call failed: {exc}")
1259
+
1260
+
1261
+ def run_chatgpt_login(arg: str = "") -> None:
1262
+ tokens_split = (arg or "").split()
1263
+ no_browser = "--no-browser" in tokens_split
1264
+ manual_only = "--manual" in tokens_split
1265
+ device_code = "--device-code" in tokens_split or "--codex-device" in tokens_split
1266
+ if device_code:
1267
+ show_info("Starting ChatGPT Plus/Pro - Codex device-code login.")
1268
+ show_info(f"When Codex prints a one-time code, open {chatgpt_auth.CODEX_DEVICE_VERIFY_URL} and approve it.")
1269
+ try:
1270
+ tokens = chatgpt_auth.run_codex_device_login()
1271
+ except Exception as exc:
1272
+ show_error(str(exc))
1273
+ return
1274
+ expires_in = max(0, int(tokens.get("expires_at", 0)) - int(time.time()))
1275
+ show_info(f"ChatGPT authentication successful (token valid for {expires_in}s).")
1276
+ return
1277
+ redirect_port = chatgpt_auth.CHATGPT_REDIRECT_PORT if manual_only else chatgpt_auth.select_redirect_port()
1278
+ if redirect_port is None:
1279
+ show_error("ChatGPT loopback redirect port 1455 is not available. Retry with /chatgpt-login --manual.")
1280
+ return
1281
+ try:
1282
+ prep = chatgpt_auth.begin_login(no_browser=no_browser or manual_only, redirect_port=redirect_port)
1283
+ except Exception as exc:
1284
+ show_error(f"Could not start ChatGPT login: {exc}")
1285
+ return
1286
+ if no_browser or manual_only:
1287
+ show_info("Open this URL on any browser you're signed into ChatGPT/OpenAI with:")
1288
+ console.print(prep["auth_url"])
1289
+ if no_browser and not manual_only:
1290
+ show_info("If you're SSHed in, forward the callback port first:")
1291
+ console.print(f" {prep['ssh_tunnel_cmd']}")
1292
+ else:
1293
+ show_info("Opening ChatGPT/OpenAI auth in your browser…")
1294
+ show_info("If the browser does not open, copy this URL manually:")
1295
+ console.print(prep["auth_url"])
1296
+
1297
+ callback: dict[str, str] = {}
1298
+ if not manual_only:
1299
+ show_info(f"Listening on {prep['redirect_uri']} (waiting up to 5 minutes)…")
1300
+ try:
1301
+ callback = chatgpt_auth.run_loopback_capture(redirect_port=redirect_port)
1302
+ except KeyboardInterrupt:
1303
+ show_info("Loopback listener cancelled — falling back to manual paste.")
1304
+ except Exception as exc:
1305
+ show_error(f"Loopback listener failed: {exc} — falling back to manual paste.")
1306
+
1307
+ if not callback:
1308
+ if not manual_only:
1309
+ show_info("Loopback redirect did not arrive. If ChatGPT showed you a code, paste it now.")
1310
+ try:
1311
+ pasted = input("ChatGPT callback URL (or blank to cancel): ").strip()
1312
+ except (EOFError, KeyboardInterrupt):
1313
+ show_info("ChatGPT login cancelled.")
1314
+ return
1315
+ if not pasted:
1316
+ show_info("ChatGPT login cancelled.")
1317
+ return
1318
+ parsed = urlparse(pasted)
1319
+ if parsed.query:
1320
+ qs = parse_qs(parsed.query)
1321
+ callback = {key: values[0] for key, values in qs.items() if values}
1322
+ else:
1323
+ show_error("Manual ChatGPT login requires the full callback URL so the OAuth state can be verified.")
1324
+ return
1325
+ callback["redirect_uri"] = prep["redirect_uri"]
1326
+
1327
+ try:
1328
+ callback.setdefault("redirect_uri", prep["redirect_uri"])
1329
+ tokens = chatgpt_auth.complete_login(prep["code_verifier"], prep["state"], callback)
1330
+ except Exception as exc:
1331
+ show_error(str(exc))
1332
+ return
1333
+ expires_in = max(0, int(tokens.get("expires_at", 0)) - int(time.time()))
1334
+ show_info(f"ChatGPT authentication successful (token valid for {expires_in}s).")
1335
+
1336
+
1337
+ def run_chatgpt_logout() -> None:
1338
+ if chatgpt_auth.clear_tokens():
1339
+ show_info("ChatGPT tokens cleared.")
1340
+ else:
1341
+ show_info("No stored ChatGPT tokens to clear.")
1342
+
1343
+
1344
+ def run_chatgpt_status() -> None:
1345
+ status = chatgpt_auth.auth_status()
1346
+ if not status.get("authenticated"):
1347
+ show_info("ChatGPT: not authenticated. Run /chatgpt-login to start Codex browser OAuth.")
1348
+ show_info(f"Device-code fallback: /chatgpt-login --device-code ({chatgpt_auth.CODEX_DEVICE_VERIFY_URL})")
1349
+ return
1350
+ show_info(
1351
+ f"ChatGPT: authenticated. Token expires in {status['expires_in']}s "
1352
+ f"(refresh token: {'yes' if status['has_refresh_token'] else 'no'}, "
1353
+ f"scope: {status.get('scope') or '?'})."
1354
+ )
1355
+
1356
+
1357
+ def run_model_check(arg: str = "", *, active_model: str = "") -> None:
1358
+ """Static compatibility report for Grok/xAI models (no chat API call)."""
1359
+ from . import model_info as _mi
1360
+ from .xai_client import is_multi_agent_model
1361
+
1362
+ name = (arg or active_model or "").strip()
1363
+ if not name:
1364
+ show_error("Usage: /model-check MODEL_NAME (e.g. grok-4.20-multi-agent-0309)")
1365
+ return
1366
+ bare = name.split(":", 1)[0].strip()
1367
+ lines: list[str] = [f"Model: {name}"]
1368
+ if not _mi.is_xai_model(name):
1369
+ lines.append("Family: not Grok/xAI — routed via Ollama host/cloud per /model and cfg.host.")
1370
+ for line in lines:
1371
+ console.print(line)
1372
+ return
1373
+ lines.append("Family: Grok/xAI (optional subscription OAuth)")
1374
+ auth = xai_auth.auth_status()
1375
+ lines.append(f"OAuth client configured: {'yes' if auth.get('client_configured') else 'no — set XAI_CLIENT_ID'}")
1376
+ if auth.get("client_configured"):
1377
+ auth_label = "yes" if auth.get("authenticated") else "no — run /xai-login"
1378
+ else:
1379
+ auth_label = "no — optional provider is not configured"
1380
+ lines.append(f"OAuth authenticated: {auth_label}")
1381
+ in_fallback = bare in XAI_MODEL_CHOICES
1382
+ lines.append(f"In XAI_MODEL_CHOICES fallback list: {'yes' if in_fallback else 'no (may still work if /v1/models lists it)'}")
1383
+ if is_multi_agent_model(name):
1384
+ lines.append("API route: POST https://api.x.ai/v1/responses (multi-agent)")
1385
+ lines.append("Harness: prior tool calls/results folded into text; no client-side tools on this path.")
1386
+ lines.append("Timeout: up to 3600s per request in xai_client.chat().")
1387
+ else:
1388
+ lines.append("API route: POST https://api.x.ai/v1/chat/completions (OpenAI-compatible)")
1389
+ lines.append("Billing: optional subscription OAuth only — XAI_API_KEY fallback is disabled.")
1390
+ lines.append("Sources: algo_cli/main.py (XAI_MODEL_CHOICES), xai_client.py, tests/test_xai_client.py")
1391
+ for line in lines:
1392
+ console.print(line)
1393
+
1394
+
1395
+ def run_xai_test() -> None:
1396
+ from . import xai_client
1397
+
1398
+ status = xai_auth.auth_status()
1399
+ if not status.get("client_configured"):
1400
+ show_error("Optional xAI OAuth is not configured. Set your authorized XAI_CLIENT_ID before /xai-login.")
1401
+ return
1402
+ if not xai_auth.get_valid_token():
1403
+ show_error("Not authenticated with xAI. Run /xai-login first.")
1404
+ return
1405
+ try:
1406
+ result = xai_client.get_models()
1407
+ except Exception as exc:
1408
+ show_error(f"xAI /v1/models failed: {xai_auth.safe_error_message(exc)}")
1409
+ show_info(
1410
+ "If you see 403/insufficient_scope, the OAuth scope likely does not grant API access "
1411
+ "on this account. Some xAI account tiers do not include /v1 access via OAuth."
1412
+ )
1413
+ return
1414
+ items = result.get("data") or result.get("models") or []
1415
+ if not items:
1416
+ show_info(f"xAI returned no models. Raw payload: {result}")
1417
+ return
1418
+ show_info(f"xAI returned {len(items)} accessible models:")
1419
+ for item in items:
1420
+ if isinstance(item, dict):
1421
+ name = item.get("id") or item.get("name") or "(unnamed)"
1422
+ owned = item.get("owned_by", "")
1423
+ console.print(f" - {name}" + (f" [muted]({owned})[/]" if owned else ""))
1424
+ else:
1425
+ console.print(f" - {item}")
1426
+
1427
+
1428
+ def run_x_account(arg: str = "") -> None:
1429
+ try:
1430
+ parts = shlex.split(arg or "")
1431
+ except ValueError as exc:
1432
+ show_error(f"Could not parse /x-account args: {exc}")
1433
+ return
1434
+ if not parts or parts[0] in {"help", "-h", "--help"}:
1435
+ show_info("X account commands use xurl and separate X API OAuth, not xAI Grok OAuth.")
1436
+ console.print(" /x-account status")
1437
+ console.print(' /x-account draft-post "text"')
1438
+ console.print(' /x-account draft-reply POST_ID_OR_URL "text"')
1439
+ console.print(' /x-account post --confirm "text"')
1440
+ console.print(' /x-account reply --confirm POST_ID_OR_URL "text"')
1441
+ console.print(" /x-account like|unlike|repost|unrepost|bookmark|unbookmark|delete --confirm POST_ID_OR_URL")
1442
+ return
1443
+
1444
+ sub = parts[0].lower()
1445
+ if sub == "status":
1446
+ result = x_account.status()
1447
+ elif sub == "draft-post":
1448
+ result = x_account.draft_post(" ".join(parts[1:]))
1449
+ elif sub == "draft-reply":
1450
+ if len(parts) < 3:
1451
+ show_error('Usage: /x-account draft-reply POST_ID_OR_URL "text"')
1452
+ return
1453
+ result = x_account.draft_reply(parts[1], " ".join(parts[2:]))
1454
+ elif sub == "post":
1455
+ confirm = "--confirm" in parts[1:]
1456
+ text_parts = [item for item in parts[1:] if item != "--confirm"]
1457
+ result = x_account.post(" ".join(text_parts), confirm=confirm)
1458
+ elif sub == "reply":
1459
+ confirm = "--confirm" in parts[1:]
1460
+ text_parts = [item for item in parts[1:] if item != "--confirm"]
1461
+ if len(text_parts) < 2:
1462
+ show_error('Usage: /x-account reply --confirm POST_ID_OR_URL "text"')
1463
+ return
1464
+ result = x_account.reply(text_parts[0], " ".join(text_parts[1:]), confirm=confirm)
1465
+ elif sub in x_account.CONFIRMED_POST_ACTIONS:
1466
+ confirm = "--confirm" in parts[1:]
1467
+ text_parts = [item for item in parts[1:] if item != "--confirm"]
1468
+ if len(text_parts) != 1:
1469
+ show_error(f"Usage: /x-account {sub} --confirm POST_ID_OR_URL")
1470
+ return
1471
+ result = x_account.post_action(sub, text_parts[0], confirm=confirm)
1472
+ else:
1473
+ show_error(f"Unknown /x-account subcommand: {sub}")
1474
+ return
1475
+
1476
+ if result.ok:
1477
+ show_info(result.message)
1478
+ else:
1479
+ show_error(result.message)
1480
+ if result.data:
1481
+ console.print(json.dumps(result.data, indent=2))
1482
+
1483
+
1484
+ def auth_hint_for_cloud() -> None:
1485
+ load_runtime_env(override=True)
1486
+ if os.environ.get("OLLAMA_API_KEY"):
1487
+ show_info("OLLAMA_API_KEY detected for Ollama Cloud direct API/web tools.")
1488
+ else:
1489
+ show_info(
1490
+ "For cloud models through local Ollama, run /login (`ollama signin`) and select a :cloud model. "
1491
+ "Set OLLAMA_API_KEY only for direct Cloud API/web tools."
1492
+ )
1493
+
1494
+
1495
+ def maybe_prompt_cloud_login() -> None:
1496
+ load_runtime_env(override=True)
1497
+ if os.environ.get("OLLAMA_API_KEY"):
1498
+ auth_hint_for_cloud()
1499
+ return
1500
+ auth_hint_for_cloud()
1501
+ answer = input("Run `ollama signin` now? [Y/n] ").strip().lower()
1502
+ if answer in {"", "y", "yes"}:
1503
+ run_ollama_login()
1504
+
1505
+
1506
+ def local_model_names(cfg: Config) -> list[str]:
1507
+ if not start_local_ollama_host(cfg.host):
1508
+ return []
1509
+ cached = LOCAL_MODEL_CACHE.get(cfg.host)
1510
+ now = time.time()
1511
+ if cached and now - cached[0] <= LOCAL_MODEL_LIST_TTL_SECONDS:
1512
+ return cached[1]
1513
+ try:
1514
+ models = Client(host=cfg.host).list()
1515
+ except Exception as exc:
1516
+ show_error(f"Could not list local models: {exc}")
1517
+ return []
1518
+ items = get_attr(models, "models", []) or []
1519
+ names: list[str] = []
1520
+ for model in items:
1521
+ name = get_attr(model, "name", None) or get_attr(model, "model", None)
1522
+ if name:
1523
+ names.append(str(name))
1524
+ result = sorted(set(names))
1525
+ LOCAL_MODEL_CACHE[cfg.host] = (now, result)
1526
+ return result
1527
+
1528
+
1529
+ def cloud_model_names() -> list[str]:
1530
+ load_runtime_env(override=True)
1531
+ api_key = os.environ.get("OLLAMA_API_KEY", "")
1532
+ if not api_key:
1533
+ return []
1534
+ try:
1535
+ models = Client(host="https://ollama.com", headers={"Authorization": f"Bearer {api_key}"}).list()
1536
+ except Exception:
1537
+ return CLOUD_MODEL_CHOICES
1538
+ items = get_attr(models, "models", []) or []
1539
+ names: list[str] = []
1540
+ for model in items:
1541
+ name = get_attr(model, "name", None) or get_attr(model, "model", None)
1542
+ if name:
1543
+ names.append(str(name))
1544
+ return sorted(set(names)) or CLOUD_MODEL_CHOICES
1545
+
1546
+
1547
+ def chatgpt_model_names() -> tuple[list[str], bool]:
1548
+ """Return (model_names, authenticated) for ChatGPT/Codex subscription OAuth."""
1549
+ if not chatgpt_auth.get_valid_token():
1550
+ return [], False
1551
+ return list(CHATGPT_MODEL_CHOICES), True
1552
+
1553
+
1554
+ def xai_model_names() -> tuple[list[str], bool]:
1555
+ """Return (model_names, authenticated) for subscription OAuth only."""
1556
+ try:
1557
+ from . import xai_auth
1558
+ except Exception:
1559
+ return [], False
1560
+ if not xai_auth.get_valid_token():
1561
+ return [], False
1562
+ try:
1563
+ from . import xai_client
1564
+ response = xai_client.get_models()
1565
+ except Exception:
1566
+ return list(XAI_MODEL_CHOICES), True
1567
+ items = response.get("data") if isinstance(response, dict) else None
1568
+ names: list[str] = []
1569
+ for item in items or []:
1570
+ if not isinstance(item, dict):
1571
+ continue
1572
+ name = item.get("id") or item.get("name")
1573
+ if not name:
1574
+ continue
1575
+ # Filter to chat/text models; skip embedding / image / video / audio entries.
1576
+ bare = str(name).lower()
1577
+ if any(skip in bare for skip in ("embed", "image", "video", "tts", "asr", "imagine")):
1578
+ continue
1579
+ names.append(str(name))
1580
+ return (sorted(set(names)) or list(XAI_MODEL_CHOICES)), True
1581
+
1582
+
1583
+ def collect_dashboard_state(client: Client, cfg: Config) -> tuple[list[dict[str, str]], list[dict[str, str]], list[str]]:
1584
+ installed_models: list[dict[str, str]] = []
1585
+ running_models: list[dict[str, str]] = []
1586
+ event_lines: list[str] = []
1587
+
1588
+ try:
1589
+ list_response = client.list()
1590
+ items = get_attr(list_response, "models", []) or []
1591
+ for item in items:
1592
+ if len(installed_models) >= 4:
1593
+ break
1594
+ details = get_attr(item, "details", {}) or {}
1595
+ installed_models.append(
1596
+ {
1597
+ "name": str(get_attr(item, "model", None) or get_attr(item, "name", "?")),
1598
+ "size": _format_bytes(get_attr(item, "size", None)),
1599
+ "quant": str(get_attr(details, "quantization_level", None) or "?"),
1600
+ }
1601
+ )
1602
+ except Exception as exc:
1603
+ event_lines.append(f"installed models unavailable: {exc}")
1604
+
1605
+ try:
1606
+ process_response = client.ps()
1607
+ items = get_attr(process_response, "models", []) or []
1608
+ for item in items:
1609
+ if len(running_models) >= 3:
1610
+ break
1611
+ details = get_attr(item, "details", {}) or {}
1612
+ running_models.append(
1613
+ {
1614
+ "name": str(get_attr(item, "name", None) or get_attr(item, "model", None) or "?"),
1615
+ "size_vram": _format_bytes(get_attr(item, "size_vram", None) or get_attr(item, "size", None)),
1616
+ "context": str(get_attr(item, "context_length", None) or get_attr(details, "parameter_size", None) or "?"),
1617
+ }
1618
+ )
1619
+ except Exception as exc:
1620
+ event_lines.append(f"running models unavailable: {exc}")
1621
+
1622
+ event_lines.extend(
1623
+ [
1624
+ f"connected {cfg.host}",
1625
+ f"mode {'cloud' if cfg.cloud else 'local'}",
1626
+ f"context {cfg.num_ctx}",
1627
+ f"theme {cfg.theme}",
1628
+ f"memories {len(cfg.memories)}",
1629
+ ]
1630
+ )
1631
+ return installed_models, running_models, event_lines
1632
+
1633
+
1634
+ def choose_from_menu(title: str, choices: list[tuple[str, str]], default: int = 1) -> int | None:
1635
+ console.print(f"\n[bold]{title}[/]")
1636
+ for index, (label, detail) in enumerate(choices, 1):
1637
+ suffix = f" [dim]{detail}[/]" if detail else ""
1638
+ console.print(f" [cyan]{index}[/]. {label}{suffix}")
1639
+ while True:
1640
+ raw = input(f"Select [{default}]: ").strip()
1641
+ if not raw:
1642
+ return default
1643
+ if raw.lower() in {"q", "quit", "exit"}:
1644
+ return None
1645
+ try:
1646
+ choice = int(raw)
1647
+ except ValueError:
1648
+ console.print("[red]Enter a number, or q to cancel.[/]")
1649
+ continue
1650
+ if 1 <= choice <= len(choices):
1651
+ return choice
1652
+ console.print("[red]Choice out of range.[/]")
1653
+
1654
+
1655
+ def model_picker(cfg: Config, *, first_run: bool = False) -> bool:
1656
+ local_names = local_model_names(cfg)
1657
+ choices: list[tuple[str, str]] = []
1658
+ for name in local_names:
1659
+ detail = "cloud via local Ollama" if is_cloud_model_name(name) else "local"
1660
+ choices.append((name, detail))
1661
+ for name in cloud_model_names():
1662
+ if name not in local_names:
1663
+ choices.append((name, "direct cloud API"))
1664
+ chatgpt_names, chatgpt_authed = chatgpt_model_names()
1665
+ chatgpt_suffix = "OpenAI Codex CLI (subscription quota)"
1666
+ for name in chatgpt_names:
1667
+ choices.append((name, chatgpt_suffix))
1668
+ xai_names, xai_authed = xai_model_names()
1669
+ xai_suffix = "xAI Grok OAuth (subscription quota)"
1670
+ for name in xai_names:
1671
+ choices.append((name, xai_suffix))
1672
+
1673
+ if not choices:
1674
+ show_error(
1675
+ "No models are selectable yet. Pull a local model with `ollama pull qwen3`, "
1676
+ "or run /login and pull/select a :cloud model through local Ollama."
1677
+ )
1678
+ return False
1679
+
1680
+ prompt = "First-run model picker" if first_run else "Model picker"
1681
+ selected = choose_from_menu(prompt, choices)
1682
+ if selected is None:
1683
+ return False
1684
+
1685
+ model, mode = choices[selected - 1]
1686
+ cfg.model = model
1687
+ # Cloud flag is only meaningful for Ollama Cloud; xAI uses its own client regardless.
1688
+ cfg.cloud = mode == "direct cloud API"
1689
+ cfg.save()
1690
+ show_info(f"Model set to {cfg.model} ({mode}).")
1691
+ if mode.startswith("OpenAI") and not chatgpt_authed:
1692
+ show_info("ChatGPT/Codex OAuth is not authenticated. Run /chatgpt-login.")
1693
+ elif mode.startswith("xAI") and not xai_authed:
1694
+ if xai_auth.client_id_configured():
1695
+ show_info("xAI OAuth is not authenticated. Run /xai-login; API-key fallback is disabled.")
1696
+ else:
1697
+ show_info(
1698
+ "Optional xAI OAuth is not configured. Set your authorized XAI_CLIENT_ID before /xai-login; "
1699
+ "API-key fallback is disabled."
1700
+ )
1701
+ elif cfg.cloud and cfg.auto_cloud_connect:
1702
+ maybe_prompt_cloud_login()
1703
+ elif cfg.cloud:
1704
+ show_info("Direct Cloud API model selected. OLLAMA_API_KEY is used for this route.")
1705
+ return True
1706
+
1707
+
1708
+ def reload_runtime() -> Config:
1709
+ global tools_module, ALL_TOOLS, TOOL_MAP
1710
+
1711
+ cfg = Config.load()
1712
+ for module_name in (
1713
+ "algo_cli.tools",
1714
+ "algo_cli.harness",
1715
+ "algo_cli.session_mode",
1716
+ "algo_cli.session_commands",
1717
+ "algo_cli.workspace_resolver",
1718
+ "algo_cli.task_router",
1719
+ "algo_cli.reflex",
1720
+ "algo_cli.tool_policy",
1721
+ ):
1722
+ loaded = sys.modules.get(module_name)
1723
+ if loaded is not None:
1724
+ importlib.reload(loaded)
1725
+ tools_module = sys.modules["algo_cli.tools"]
1726
+ ALL_TOOLS = tools_module.ALL_TOOLS
1727
+ TOOL_MAP = tools_module.TOOL_MAP
1728
+ harness.configure_context_sources(
1729
+ external=cfg.external_harness_sources_enabled,
1730
+ index_compute_lab=cfg.index_compute_lab_auto_inject,
1731
+ )
1732
+ try:
1733
+ set_theme(cfg.theme)
1734
+ except ValueError:
1735
+ cfg.theme = current_theme_name()
1736
+ return cfg
1737
+
1738
+
1739
+ def handle_status_command(cfg: Config, client: Any | None = None) -> None:
1740
+ used, total, remaining, runtime_cap, native_ctx = context_status(cfg, client=client)
1741
+ features: list[str] = []
1742
+ for enabled, label in (
1743
+ (cfg.cloud, "cloud"),
1744
+ (cfg.auto_approve_active, "auto-approve"),
1745
+ (cfg.safe_mode, "safe-mode"),
1746
+ (cfg.show_thinking, "thinking"),
1747
+ (cfg.verify_mode, "verify"),
1748
+ (cfg.algorithmic_tool_policy_enabled, "policy"),
1749
+ (cfg.reflex_enabled, "reflex"),
1750
+ (cfg.intuition_recall_enabled, "intuition"),
1751
+ (code_rag_consent_granted(cfg), "code-rag"),
1752
+ (cfg.skill_crystallize_enabled, "skills"),
1753
+ (bool(cfg.session_summary.strip()), "summary"),
1754
+ ):
1755
+ if enabled:
1756
+ features.append(label)
1757
+ console.print(f"[bold primary]Model:[/] {cfg.model}")
1758
+ ctx_line = f"{used}/{total} tokens ({remaining} remaining)"
1759
+ if native_ctx and runtime_cap and native_ctx > runtime_cap:
1760
+ ctx_line += f" · runtime cap {runtime_cap:,}"
1761
+ console.print(f"[bold primary]Context:[/] {ctx_line}")
1762
+ console.print(f"[bold primary]Features:[/] {', '.join(features) if features else 'none'}")
1763
+
1764
+
1765
+ def small_maintenance_client(cfg: Config, fallback_client: Client | None = None) -> tuple[Client, str]:
1766
+ local_names = [name for name in local_model_names(cfg) if not is_embedding_model_name(name)]
1767
+ preferred = ("qwen3:4b", "qwen3", "gemma3:4b", "gemma3")
1768
+ timeout = max(1.0, float(cfg.chat_stream_timeout_seconds))
1769
+ for model in preferred:
1770
+ if model in local_names:
1771
+ return Client(host=cfg.host, timeout=timeout), model
1772
+ if local_names:
1773
+ return Client(host=cfg.host, timeout=timeout), local_names[0]
1774
+ if cfg.cloud:
1775
+ return create_client(Config(model=MAINTENANCE_CLOUD_MODEL, cloud=True, host=cfg.host)), MAINTENANCE_CLOUD_MODEL
1776
+ return fallback_client or create_client(cfg), cfg.model
1777
+
1778
+
1779
+ def local_maintenance_client(cfg: Config) -> tuple[Client, str] | None:
1780
+ """Return a genuinely local, non-embedding maintenance model or None."""
1781
+
1782
+ if not host_is_local(cfg.host) or not ollama_server_ready(cfg.host):
1783
+ return None
1784
+ local_names = [name for name in local_model_names(cfg) if not is_embedding_model_name(name)]
1785
+ if not local_names:
1786
+ return None
1787
+ preferred = ("qwen3:4b", "qwen3", "gemma3:4b", "gemma3")
1788
+ model = next((name for name in preferred if name in local_names), local_names[0])
1789
+ timeout = max(1.0, float(cfg.chat_stream_timeout_seconds))
1790
+ return Client(host=cfg.host, timeout=timeout), model
1791
+
1792
+
1793
+ def handle_diff_command() -> None:
1794
+ """Show the most recent verified Git diff captured by a requires_change block."""
1795
+ blocks = session_pipeline_blocks()
1796
+ if not blocks:
1797
+ show_info("No pipeline activity in this session. Run /agent first.")
1798
+ return
1799
+ for block in reversed(blocks):
1800
+ if block.requires_change and (block.git_evidence or "").strip():
1801
+ console.print(
1802
+ f"[bold]Diff captured by [{block.role}] block[/] — status: [text]{block.status}[/]"
1803
+ )
1804
+ if block.status_reason:
1805
+ console.print(f"[muted]reason:[/] {block.status_reason}")
1806
+ if block.verification_warning:
1807
+ console.print(f"[warning]verification:[/] {block.verification_warning}")
1808
+ if block.successful_writes:
1809
+ console.print(
1810
+ f"[muted]successful_writes:[/] {', '.join(block.successful_writes)}"
1811
+ )
1812
+ console.print()
1813
+ console.print(block.git_evidence.strip())
1814
+ return
1815
+ show_info(
1816
+ "No verified diff captured in this session. requires_change blocks have run "
1817
+ "but none recorded Git evidence (e.g., repository unavailable or no changes detected)."
1818
+ )
1819
+
1820
+
1821
+ def handle_changes_command() -> None:
1822
+ """Summarize per-block activity from the most recent pipeline run."""
1823
+ blocks = session_pipeline_blocks()
1824
+ if not blocks:
1825
+ show_info("No pipeline activity in this session. Run /agent first.")
1826
+ return
1827
+ console.print(
1828
+ f"[bold]Pipeline activity[/] — {len(blocks)} block{'s' if len(blocks) != 1 else ''}"
1829
+ )
1830
+ for block in blocks:
1831
+ duration_s = (block.duration_ms or 0) / 1000
1832
+ status_style = "success" if block.status == "complete" else (
1833
+ "warning" if block.status == "partial" else "error"
1834
+ )
1835
+ console.print(
1836
+ f" [bold][{block.role}][/] [{status_style}]{block.status}[/]"
1837
+ f" {duration_s:.1f}s {block.tool_calls} tool call"
1838
+ f"{'' if block.tool_calls == 1 else 's'}"
1839
+ )
1840
+ if block.status_reason:
1841
+ console.print(f" [muted]reason:[/] {block.status_reason}")
1842
+ if block.verification_warning:
1843
+ console.print(f" [warning]verification:[/] {block.verification_warning}")
1844
+ if block.successful_writes:
1845
+ console.print(
1846
+ f" [muted]writes:[/] {', '.join(block.successful_writes)}"
1847
+ )
1848
+ if block.mutation_actions:
1849
+ console.print(
1850
+ f" [muted]mutation_actions:[/] {', '.join(block.mutation_actions)}"
1851
+ )
1852
+
1853
+
1854
+ def handle_context_command(arg: str, cfg: Config, client: Client) -> None:
1855
+ subcommand = (arg or "status").strip().lower()
1856
+ if subcommand in {"", "status"}:
1857
+ used, total, remaining, runtime_cap, native_ctx = context_status(cfg, client=client)
1858
+ pct_left = int((remaining / total) * 100) if total > 0 else 0
1859
+ ctx_line = f"{used}/{total} tokens ({pct_left}% left)"
1860
+ if native_ctx and runtime_cap and native_ctx > runtime_cap:
1861
+ ctx_line += f" · runtime cap {runtime_cap:,}"
1862
+ console.print(f"[muted]Context window:[/] {ctx_line}")
1863
+ console.print(f" messages : [text]{len(cfg.messages)}[/]")
1864
+ console.print(f" summary active : [text]{bool(cfg.session_summary.strip())}[/]")
1865
+ console.print(f" summary chars : [text]{len(cfg.session_summary)}[/]")
1866
+ console.print(f" keep recent : [text]{CONTEXT_KEEP_MESSAGES} messages[/]")
1867
+ console.print(f" compact at : [text]{int(CONTEXT_COMPACT_THRESHOLD * 100)}% used[/]")
1868
+ elif subcommand == "clear":
1869
+ if not cfg.session_summary:
1870
+ show_info("No context summary to clear.")
1871
+ return
1872
+ cfg.session_summary = ""
1873
+ cfg.save()
1874
+ show_info("Context summary cleared.")
1875
+ elif subcommand == "rebuild":
1876
+ ok, message = rebuild_context_summary(client, cfg)
1877
+ if ok:
1878
+ show_info(message)
1879
+ else:
1880
+ show_error(message)
1881
+ else:
1882
+ show_error("Usage: /context [status|rebuild|clear]")
1883
+
1884
+
1885
+ def onboard_if_needed(cfg: Config) -> None:
1886
+ load_runtime_env(override=True)
1887
+ if cfg.onboarded:
1888
+ if cfg.cloud and not os.environ.get("OLLAMA_API_KEY"):
1889
+ auth_hint_for_cloud()
1890
+ return
1891
+
1892
+ console.print("\n[bold cyan]First run setup[/]")
1893
+ console.print("Pick a default model once. The CLI will keep using it until you change it with /model or /models.")
1894
+ if model_picker(cfg, first_run=True):
1895
+ cfg.onboarded = True
1896
+ cfg.save()
1897
+ else:
1898
+ show_info("Setup is still pending. Install or authenticate a model, then restart Algo CLI to try again.")
1899
+
1900
+
1901
+ LESSONS_TOP_K = 5
1902
+ HARNESS_TOP_K = 6
1903
+
1904
+ # Tracks Gemini models we've already shown the workaround notice for this session.
1905
+ _GEMINI_WORKAROUND_NOTICE_SHOWN: set[str] = set()
1906
+
1907
+ READ_ONLY_TOOLS = frozenset({
1908
+ "read_file", "read_pdf", "render_pdf_pages", "list_directory",
1909
+ "search_files", "git_status", "git_diff", "harness_search", "harness_read", "harness_stats",
1910
+ "available_actions",
1911
+ "model_show",
1912
+ })
1913
+
1914
+
1915
+ def _terminal_answer_from_tool_calls(tool_calls: list[Any]) -> str | None:
1916
+ """Normalize a declared final-answer control call into assistant content."""
1917
+
1918
+ if not tool_calls:
1919
+ return None
1920
+ normalized = [normalize_tool_call(call) for call in tool_calls]
1921
+ if any(name != "final_answer" for name, _args in normalized):
1922
+ return None
1923
+ answers = [str(args.get("answer") or "").strip() for _name, args in normalized]
1924
+ return "\n\n".join(answer for answer in answers if answer)
1925
+
1926
+
1927
+ def configured_embed_dimensions(cfg: Config) -> int | None:
1928
+ """Return a valid configured vector width, or None for model default."""
1929
+ value = getattr(cfg, "embed_dimensions", None)
1930
+ if value is None:
1931
+ return None
1932
+ try:
1933
+ dimensions = int(value)
1934
+ except (TypeError, ValueError):
1935
+ return None
1936
+ return dimensions if dimensions > 0 else None
1937
+
1938
+
1939
+ def make_local_embed_fn(cfg: Config, model: str) -> identity.EmbedFn:
1940
+ """Closure that batches Ollama embed calls.
1941
+
1942
+ Prefers the supplemental gateway when it is reachable so batch embedding
1943
+ for the harness index piggybacks on the Go proxy and the in-process RAG
1944
+ score is faster. Falls back to the direct Ollama client when the gateway
1945
+ is not ready or returns an error.
1946
+ """
1947
+ host = cfg.host
1948
+ dimensions = configured_embed_dimensions(cfg)
1949
+
1950
+ def _embed(texts: list[str]) -> list[list[float]]:
1951
+ if not texts:
1952
+ return []
1953
+ # Prefer the supplemental gateway for batch embedding.
1954
+ if tools_module.gateway_ready():
1955
+ response = tools_module.gateway_embed_batch(
1956
+ texts, model, True, dimensions
1957
+ )
1958
+ if response is not None:
1959
+ embeddings = get_attr(response, "embeddings", []) or []
1960
+ if embeddings:
1961
+ return [list(vec) for vec in embeddings]
1962
+ # Fallback: direct Ollama client.
1963
+ client = Client(host=host)
1964
+ if dimensions is None:
1965
+ client_response = client.embed(model=model, input=texts)
1966
+ else:
1967
+ client_response = client.embed(model=model, input=texts, dimensions=dimensions)
1968
+ embeddings = get_attr(client_response, "embeddings", []) or []
1969
+ return [list(vec) for vec in embeddings]
1970
+
1971
+ return _embed
1972
+ return _embed
1973
+
1974
+
1975
+ # Per-session backend resolution cache. Cloud embeddings are not currently
1976
+ # served by Ollama Cloud, so all supported embedding work remains local.
1977
+ _EMBED_BACKEND_CACHE: dict[str, tuple[str, str]] = {}
1978
+ _EMBED_BACKEND_ANNOUNCED: set[str] = set()
1979
+
1980
+
1981
+ def resolve_embed_backend(cfg: Config) -> tuple[str, str]:
1982
+ """Decide which embedding backend to use this session.
1983
+
1984
+ Returns (backend, reason). Ollama Cloud currently authenticates chat and
1985
+ web-search API requests but does not serve embedding models, so 'auto'
1986
+ remains local and an explicit 'cloud' setting falls back visibly to local.
1987
+ """
1988
+ setting = (cfg.embedding_backend or "auto").strip().lower()
1989
+ if setting in _EMBED_BACKEND_CACHE:
1990
+ return _EMBED_BACKEND_CACHE[setting]
1991
+
1992
+ if setting == "local":
1993
+ result = ("local", "embedding_backend=local")
1994
+ elif setting == "cloud":
1995
+ result = ("local", "cloud embeddings unavailable; using local")
1996
+ else:
1997
+ result = ("local", "auto: local embeddings only")
1998
+
1999
+ _EMBED_BACKEND_CACHE[setting] = result
2000
+ if setting not in _EMBED_BACKEND_ANNOUNCED:
2001
+ _EMBED_BACKEND_ANNOUNCED.add(setting)
2002
+ show_info(f"Embedding backend: {result[0]} ({result[1]})")
2003
+ return result
2004
+
2005
+
2006
+ def reset_embed_backend_cache() -> None:
2007
+ """Clear the resolver cache. For tests and config-change handlers."""
2008
+ _EMBED_BACKEND_CACHE.clear()
2009
+ _EMBED_BACKEND_ANNOUNCED.clear()
2010
+
2011
+
2012
+ def make_embed_fn(cfg: Config, local_model: str) -> tuple[identity.EmbedFn, str, str]:
2013
+ """Backend-aware embed factory. Returns (embed_fn, backend, active_model).
2014
+
2015
+ This preserves one routing boundary for future embedding backends while
2016
+ selecting only the currently supported local backend.
2017
+ """
2018
+ backend, _reason = resolve_embed_backend(cfg)
2019
+ return make_local_embed_fn(cfg, local_model), backend, local_model
2020
+
2021
+
2022
+ def make_maintenance_llm_fn(cfg: Config) -> skills.LLMFn:
2023
+ """Closure wrapping the small local maintenance model as a (system, user) -> text fn."""
2024
+
2025
+ def _llm(system: str, user: str) -> str:
2026
+ client, model = small_maintenance_client(cfg)
2027
+ response = client.chat(
2028
+ model=model,
2029
+ messages=[
2030
+ {"role": "system", "content": system},
2031
+ {"role": "user", "content": user},
2032
+ ],
2033
+ stream=False,
2034
+ think=False,
2035
+ keep_alive=cfg.keep_alive,
2036
+ options={"temperature": 0.2, "num_ctx": min(cfg.num_ctx, 8192)},
2037
+ )
2038
+ return get_attr(get_attr(response, "message", {}), "content", "") or ""
2039
+
2040
+ return _llm
2041
+
2042
+
2043
+ def make_local_maintenance_llm_fn(cfg: Config) -> skills.LLMFn | None:
2044
+ """Build a maintenance closure that cannot fall back to a cloud provider."""
2045
+
2046
+ resolved = local_maintenance_client(cfg)
2047
+ if resolved is None:
2048
+ return None
2049
+ client, model = resolved
2050
+
2051
+ def _llm(system: str, user: str) -> str:
2052
+ response = client.chat(
2053
+ model=model,
2054
+ messages=[
2055
+ {"role": "system", "content": system},
2056
+ {"role": "user", "content": user},
2057
+ ],
2058
+ stream=False,
2059
+ think=False,
2060
+ keep_alive=cfg.keep_alive,
2061
+ options={"temperature": 0.2, "num_ctx": min(cfg.num_ctx, 8192)},
2062
+ )
2063
+ return get_attr(get_attr(response, "message", {}), "content", "") or ""
2064
+
2065
+ return _llm
2066
+
2067
+
2068
+ def intuition_embed_fn(cfg: Config) -> identity.EmbedFn | None:
2069
+ backend, _reason = resolve_embed_backend(cfg)
2070
+ if not host_is_local(cfg.host) or not ollama_server_ready(cfg.host):
2071
+ return None
2072
+ embed_fn, _backend, _model = make_embed_fn(cfg, harness.resolve_embed_model(cfg))
2073
+ return embed_fn
2074
+
2075
+
2076
+ def capture_intuition_block(
2077
+ cfg: Config,
2078
+ block_type: str,
2079
+ content: str,
2080
+ *,
2081
+ source: str,
2082
+ force: bool = False,
2083
+ ) -> str | None:
2084
+ if _intuition_engine is None:
2085
+ return None
2086
+ if not force and not cfg.intuition_capture_enabled:
2087
+ return None
2088
+ try:
2089
+ return _intuition_engine.capture_block(
2090
+ block_type,
2091
+ content,
2092
+ source=source,
2093
+ embed_fn=intuition_embed_fn(cfg),
2094
+ embedding_model=harness.resolve_embed_model(cfg),
2095
+ )
2096
+ except Exception as exc:
2097
+ logger.debug("Intuition capture failed: %s", exc)
2098
+ return None
2099
+
2100
+
2101
+ def handle_icl_command(arg: str, cfg: Config) -> None:
2102
+ """index-compute-lab auto-inject and status (/icl)."""
2103
+ from . import index_compute_lab
2104
+
2105
+ parts = (arg or "status").strip().split(maxsplit=1)
2106
+ sub = (parts[0].lower() if parts else "status") or "status"
2107
+ if sub in {"on", "off"}:
2108
+ cfg.index_compute_lab_auto_inject = sub == "on"
2109
+ cfg.save()
2110
+ harness.configure_context_sources(
2111
+ external=cfg.external_harness_sources_enabled,
2112
+ index_compute_lab=cfg.index_compute_lab_auto_inject,
2113
+ )
2114
+ harness.load_index(refresh=True)
2115
+ show_info(f"index-compute-lab auto-inject: {'ON' if cfg.index_compute_lab_auto_inject else 'OFF'}")
2116
+ return
2117
+ if sub == "path":
2118
+ show_info(f"Lab root: {index_compute_lab.resolve_lab_root()}")
2119
+ show_info(f"Available: {index_compute_lab.lab_available()}")
2120
+ return
2121
+ if sub == "ask":
2122
+ if len(parts) < 2 or not parts[1].strip():
2123
+ show_error("Usage: /icl ask <question>")
2124
+ return
2125
+ console.print(index_compute_lab.run_ask(parts[1].strip(), limit=10))
2126
+ return
2127
+ console.print(f"[muted]index-compute-lab root:[/] {index_compute_lab.resolve_lab_root()}")
2128
+ console.print(f" assets ready : [text]{index_compute_lab.lab_available()}[/]")
2129
+ console.print(f" auto-inject : [text]{'on' if cfg.index_compute_lab_auto_inject else 'off'}[/]")
2130
+ console.print("[muted]Use /icl on|off, /icl ask <question>, /icl path.[/]")
2131
+
2132
+
2133
+ def handle_intuition_command(arg: str, cfg: Config) -> None:
2134
+ sub, _, rest = (arg or "status").strip().partition(" ")
2135
+ sub = sub.lower() or "status"
2136
+ if _intuition_engine is None:
2137
+ show_error("Intuition engine is unavailable.")
2138
+ return
2139
+
2140
+ if sub in {"on", "off"}:
2141
+ enabled = sub == "on"
2142
+ cfg.intuition_recall_enabled = enabled
2143
+ cfg.intuition_capture_enabled = enabled
2144
+ cfg.save()
2145
+ _intuition_engine.config["recall_enabled"] = enabled
2146
+ show_info(f"Intuition recall/capture: {'ON' if enabled else 'OFF'}")
2147
+ return
2148
+
2149
+ if sub == "status":
2150
+ status = _intuition_engine.status()
2151
+ console.print(f"[muted]Intuition index:[/] {status['index_path']}")
2152
+ console.print(f" recall enabled : [text]{cfg.intuition_recall_enabled}[/]")
2153
+ console.print(f" capture enabled: [text]{cfg.intuition_capture_enabled}[/]")
2154
+ console.print(f" blocks : [text]{status['block_count']}[/]")
2155
+ console.print(f" embedded : [text]{status['embedded']}[/]")
2156
+ console.print(f" pending : [text]{status['pending']}[/]")
2157
+ console.print(f" max blocks : [text]{status['max_blocks']}[/]")
2158
+ if status["by_type"]:
2159
+ console.print(f" by type : [text]{json.dumps(status['by_type'], sort_keys=True)}[/]")
2160
+ console.print("[muted]Use /intuition on|off|list|reindex|forget <id>|add <type> <text>.[/]")
2161
+ return
2162
+
2163
+ if sub == "list":
2164
+ blocks = _intuition_engine.list_blocks()
2165
+ if not blocks:
2166
+ show_info("No intuition blocks saved.")
2167
+ return
2168
+ table = Table(title=f"Intuition Blocks ({len(blocks)})", box=box.ROUNDED)
2169
+ table.add_column("ID", style="primary", overflow="fold")
2170
+ table.add_column("Type", style="secondary")
2171
+ table.add_column("Embed", style="text")
2172
+ table.add_column("Timestamp", style="muted")
2173
+ table.add_column("Snippet", style="text", overflow="fold")
2174
+ for block in blocks:
2175
+ metadata = block.get("metadata") if isinstance(block.get("metadata"), dict) else {}
2176
+ embed_state = "ready" if block.get("embedding") else str(metadata.get("embedding_status") or "pending")
2177
+ snippet = str(block.get("content", "")).replace("\n", " ").strip()
2178
+ if len(snippet) > 90:
2179
+ snippet = snippet[:89] + "..."
2180
+ table.add_row(
2181
+ str(block.get("id", "?")),
2182
+ str(block.get("type", "general")),
2183
+ embed_state,
2184
+ str(block.get("timestamp", ""))[:19],
2185
+ snippet,
2186
+ )
2187
+ console.print(table)
2188
+ return
2189
+
2190
+ if sub == "forget":
2191
+ block_id = rest.strip()
2192
+ if not block_id:
2193
+ show_error("Usage: /intuition forget <id>")
2194
+ return
2195
+ removed = _intuition_engine.forget_block(block_id)
2196
+ if removed is None:
2197
+ show_error(f"No intuition block found: {block_id}")
2198
+ else:
2199
+ show_info(f"Forgot intuition block: {block_id}")
2200
+ return
2201
+
2202
+ if sub == "reindex":
2203
+ embed_fn = intuition_embed_fn(cfg)
2204
+ if embed_fn is None:
2205
+ show_error("Local Ollama is not reachable; cannot reindex intuition blocks.")
2206
+ return
2207
+ result = _intuition_engine.reindex(embed_fn, embedding_model=harness.resolve_embed_model(cfg))
2208
+ if result.get("ok"):
2209
+ show_info(
2210
+ f"Reindexed {result.get('updated', 0)}/{result.get('total', 0)} intuition blocks "
2211
+ f"with {harness.resolve_embed_model(cfg)}."
2212
+ )
2213
+ else:
2214
+ show_error(
2215
+ f"Reindex incomplete: {result.get('updated', 0)} updated, "
2216
+ f"{result.get('failed', 0)} failed."
2217
+ )
2218
+ return
2219
+
2220
+ if sub == "add":
2221
+ block_type, _, text = rest.strip().partition(" ")
2222
+ if not block_type or not text.strip():
2223
+ show_error("Usage: /intuition add <type> <text>")
2224
+ return
2225
+ captured_id = capture_intuition_block(cfg, block_type, text, source="/intuition add", force=True)
2226
+ if captured_id:
2227
+ show_info(f"Intuition block saved: {captured_id}")
2228
+ else:
2229
+ show_error("Could not save intuition block.")
2230
+ return
2231
+
2232
+ show_error("Usage: /intuition [on|off|status|list|reindex|forget <id>|add <type> <text>]")
2233
+
2234
+
2235
+ def handle_intelligence_command(arg: str = "", cfg: Config | None = None) -> None:
2236
+ raw = (arg or "").strip()
2237
+ sub, _, rest = raw.partition(" ")
2238
+ sub = (sub or "status").lower()
2239
+ if sub in {"help", "?"}:
2240
+ show_info("Usage: /intelligence [status|query <term>|reindex] (alias: /intel)")
2241
+ return
2242
+ try:
2243
+ from . import intelligence
2244
+ except Exception as exc:
2245
+ show_error(f"Intelligence layer unavailable: {exc}")
2246
+ return
2247
+
2248
+ root = Path(getattr(cfg, "cwd", "") or os.getcwd()).expanduser().resolve()
2249
+ if sub == "query":
2250
+ term = rest.strip()
2251
+ if not term:
2252
+ show_error("Usage: /intelligence query <term> (alias: /intel query <term>)")
2253
+ return
2254
+ graph = intelligence.build_project_graph(root, persist=False)
2255
+ rows = intelligence.query_project_graph(graph, term, limit=10)
2256
+ console.print(f"[muted]Intelligence query:[/] {term}")
2257
+ if not rows:
2258
+ console.print(" [text]no matches[/]")
2259
+ return
2260
+ for row in rows:
2261
+ kind = str(row.get("kind", "?"))
2262
+ path = str(row.get("path", row.get("id", "?")))
2263
+ line = row.get("line")
2264
+ suffix = f":{line}" if line else ""
2265
+ label = str(row.get("qualname") or row.get("module") or row.get("id") or "")
2266
+ console.print(f" [primary]{kind}[/] [text]{path}{suffix}[/] {label}")
2267
+ return
2268
+
2269
+ if sub == "reindex":
2270
+ graph = intelligence.build_project_graph(root, persist=True)
2271
+ console.print("[muted]Intelligence graph indexed:[/]")
2272
+ console.print(f" root : [text]{graph.root}[/]")
2273
+ console.print(f" files : [text]{len(graph.files)}[/]")
2274
+ console.print(f" symbols: [text]{len(graph.symbols)}[/]")
2275
+ console.print(f" imports: [text]{len(graph.imports)}[/]")
2276
+ return
2277
+
2278
+ if sub != "status":
2279
+ show_error("Usage: /intelligence [status|query <term>|reindex] (alias: /intel)")
2280
+ return
2281
+
2282
+ exports = set(getattr(intelligence, "__all__", ()))
2283
+ capability_names = [
2284
+ "build_project_graph",
2285
+ "query_project_graph",
2286
+ "GraphRAGIndex",
2287
+ "DeepResearchEngine",
2288
+ "LSPManager",
2289
+ "TaskClassifier",
2290
+ "MemoryEngine",
2291
+ ]
2292
+ available = [name for name in capability_names if name in exports or hasattr(intelligence, name)]
2293
+ console.print("[muted]Intelligence layer:[/] wired")
2294
+ console.print(" commands : [text]status, query <term>, reindex[/]")
2295
+ console.print(f" root : [text]{root}[/]")
2296
+ console.print(f" module : [text]{intelligence.__name__}[/]")
2297
+ console.print(f" exports : [text]{len(exports)}[/]")
2298
+ console.print(f" capabilities: [text]{', '.join(available) if available else 'none'}[/]")
2299
+
2300
+
2301
+ def handle_kernel_command(arg: str = "") -> None:
2302
+ from .kernels.manifest import audit_kernels, get_kernel, list_kernels, render_kernel_audit
2303
+
2304
+ raw = (arg or "").strip()
2305
+ sub, _, rest = raw.partition(" ")
2306
+ sub = (sub or "list").lower()
2307
+
2308
+ if sub in {"help", "?"}:
2309
+ show_info("Usage: /kernel list | /kernel show NAME | /kernel check [NAME]")
2310
+ return
2311
+
2312
+ if sub == "list":
2313
+ console.print("Kernels:")
2314
+ for spec in list_kernels():
2315
+ console.print(f" {spec.name} ({spec.status}/{spec.safety_level}) - {spec.description}")
2316
+ return
2317
+
2318
+ if sub == "show":
2319
+ name = rest.strip().lower()
2320
+ if not name:
2321
+ show_error("Usage: /kernel show NAME")
2322
+ return
2323
+ selected_spec = get_kernel(name)
2324
+ if selected_spec is None:
2325
+ show_error(f"Unknown kernel: {name}")
2326
+ return
2327
+ console.print(f"Kernel: {selected_spec.name}")
2328
+ console.print(f"Description: {selected_spec.description}")
2329
+ console.print(f"Status: {selected_spec.status}")
2330
+ console.print(f"Safety: {selected_spec.safety_level}")
2331
+ console.print("Modules:")
2332
+ for module in selected_spec.modules:
2333
+ console.print(f" - {module}")
2334
+ console.print("Actions:")
2335
+ for action in selected_spec.actions:
2336
+ console.print(f" - {action}")
2337
+ console.print("Slash commands:")
2338
+ for command in selected_spec.slash_commands:
2339
+ console.print(f" - {command}")
2340
+ console.print(f"Readiness: /kernel check {selected_spec.name}")
2341
+ return
2342
+
2343
+ if sub == "check":
2344
+ check_name = rest.strip().lower() or None
2345
+ try:
2346
+ audits = audit_kernels(check_name)
2347
+ except KeyError as exc:
2348
+ show_error(str(exc).strip("'"))
2349
+ return
2350
+ console.print(render_kernel_audit(audits))
2351
+ return
2352
+
2353
+ show_error("Usage: /kernel list | /kernel show NAME | /kernel check [NAME]")
2354
+
2355
+
2356
+ def maybe_crystallize_skills(cfg: Config) -> None:
2357
+ """Every N substantive runs, review recent run history and crystallize new skills."""
2358
+ if not cfg.skill_crystallize_enabled:
2359
+ return
2360
+ if cfg.runs_since_crystallize < max(1, int(cfg.skill_crystallize_every)):
2361
+ return
2362
+ llm_fn = make_local_maintenance_llm_fn(cfg)
2363
+ if llm_fn is None:
2364
+ return
2365
+ cfg.runs_since_crystallize = 0
2366
+ cfg.save()
2367
+ show_info("Crystallizing skills from recent runs…")
2368
+ result = skills.crystallize(llm_fn)
2369
+ quarantined = result.get("quarantined", [])
2370
+ if quarantined:
2371
+ show_info(
2372
+ f"Quarantined {len(quarantined)} skill candidate(s): {', '.join(quarantined)}. "
2373
+ "Review with /skills status, then use /skills approve NAME to promote one."
2374
+ )
2375
+ else:
2376
+ show_info(f"No skill candidates quarantined ({result.get('reason', 'nothing qualified')}).")
2377
+
2378
+
2379
+ def ensure_harness_index(cfg: Config, local_names: list[str] | None = None, *, max_records: int = 0) -> bool:
2380
+ """Embed any harness records missing the embedding for the active model.
2381
+
2382
+ Returns True if at least some records have embeddings for DEFAULT_EMBED_MODEL.
2383
+ Idempotent: cheap when everything is already embedded.
2384
+ """
2385
+ backend, _reason = resolve_embed_backend(cfg)
2386
+ # Resolve the active model up-front so embedded_count reflects the right backend.
2387
+ embed_fn, _backend2, active_model = make_embed_fn(cfg, harness.resolve_embed_model(cfg))
2388
+ matching, total = harness.embedded_count(active_model)
2389
+ if total == 0:
2390
+ return False
2391
+ if matching == total:
2392
+ return True
2393
+ if not host_is_local(cfg.host) or not ollama_server_ready(cfg.host):
2394
+ return matching > 0
2395
+ # Auto-pull the embed model if it isn't present locally.
2396
+ if local_names is None:
2397
+ local_names = local_model_names(cfg)
2398
+ base_name = active_model.split(":")[0]
2399
+ if local_names and not any(n.startswith(base_name) for n in local_names):
2400
+ show_info(f"Pulling embed model {active_model} (first-time setup)…")
2401
+ try:
2402
+ Client(host=cfg.host).pull(active_model)
2403
+ except Exception as exc:
2404
+ show_info(f"Could not auto-pull {active_model}: {exc}. RAG disabled until model is available.")
2405
+ return matching > 0
2406
+ pending = total - matching
2407
+ # If switching backends/models leaves the index stale, surface that before
2408
+ # we kick off what may be a long re-embed pass.
2409
+ if matching == 0 and any(r.get("embedding") for r in (harness.load_index().get("records") or [])):
2410
+ show_info(
2411
+ f"Embedding backend produced a model change to {active_model}; "
2412
+ f"all {total} records will be re-embedded under the new model."
2413
+ )
2414
+ queue_note = ""
2415
+ queue = harness.embedding_progress(active_model)
2416
+ if int(queue.get("total", 0)) == total and int(queue.get("high_value_total", 0)) > 0:
2417
+ next_priority = str(queue.get("next_priority") or "complete").replace("_", " ")
2418
+ queue_note = (
2419
+ f" High-value coverage: {queue.get('high_value_embedded', 0)}/"
2420
+ f"{queue.get('high_value_total', 0)}; next tier: {next_priority}."
2421
+ )
2422
+ pass_limit = max_records or harness.EMBED_PER_TURN_CAP
2423
+ selected = min(pending, pass_limit) if pass_limit > 0 else pending
2424
+ show_info(
2425
+ f"Embedding the next {selected} of {pending} pending harness records with "
2426
+ f"{active_model} ({backend}) using {harness.EMBED_PRIORITY_POLICY}.{queue_note}"
2427
+ )
2428
+ last_pct = -10
2429
+ def _progress(done: int, target: int) -> None:
2430
+ nonlocal last_pct
2431
+ pct = int(done * 100 / max(1, target))
2432
+ if pct - last_pct >= 10:
2433
+ show_info(f" harness embeddings: {done}/{target} ({pct}%)")
2434
+ last_pct = pct
2435
+ result = harness.embed_index_records(
2436
+ embed_fn,
2437
+ active_model,
2438
+ max_records=max_records or harness.EMBED_PER_TURN_CAP,
2439
+ on_progress=_progress,
2440
+ on_perf=lambda rec: log_embed_perf(rec, source="ensure_harness_index", backend=backend),
2441
+ )
2442
+ if result.get("ready"):
2443
+ show_info(f"Harness embeddings ready: {result.get('embedded', 0)} embedded, {result.get('total', 0)} total.")
2444
+ return True
2445
+ if result.get("reason") == "max_records_reached" and int(result.get("embedded", 0)) > 0:
2446
+ next_priority = str(result.get("next_priority") or "complete").replace("_", " ")
2447
+ show_info(
2448
+ f"Harness embeddings partially ready: {result.get('embedded', 0)} embedded this turn, "
2449
+ f"{result.get('pending', 0)} pending. Next tier: {next_priority}; continuing incrementally."
2450
+ )
2451
+ return True
2452
+ show_info(f"Harness embedding unavailable: {result.get('reason', 'unknown')}. RAG disabled this session.")
2453
+ return matching > 0
2454
+
2455
+
2456
+ def _parse_benchmark_embed_args(arg: str) -> tuple[int, str | None]:
2457
+ count = 20
2458
+ model: str | None = None
2459
+ tokens = arg.split()[1:] if arg else []
2460
+ i = 0
2461
+ while i < len(tokens):
2462
+ token = tokens[i]
2463
+ if token == "--count" and i + 1 < len(tokens):
2464
+ try:
2465
+ count = max(1, int(tokens[i + 1]))
2466
+ except ValueError:
2467
+ pass
2468
+ i += 2
2469
+ elif token == "--model" and i + 1 < len(tokens):
2470
+ model = tokens[i + 1]
2471
+ i += 2
2472
+ else:
2473
+ i += 1
2474
+ return count, model
2475
+
2476
+
2477
+ def _synthetic_embed_text(index: int) -> str:
2478
+ base = (
2479
+ "Title: Synthetic harness record\n"
2480
+ "Kind: skill\n"
2481
+ "Path: synthetic/record_{i}.md\n"
2482
+ "Summary: This is a synthetic benchmark record approximating the length of a "
2483
+ "typical harness preamble. It exercises tokenization, batching, and Ollama "
2484
+ "embed round-trip overhead under a controlled workload."
2485
+ ).format(i=index)
2486
+ return base + " " + ("filler " * 30)
2487
+
2488
+
2489
+ def run_harness_benchmark_embed(cfg: Config, arg: str) -> None:
2490
+ if not host_is_local(cfg.host) or not ollama_server_ready(cfg.host):
2491
+ show_error("Local Ollama is not reachable; cannot run embedding benchmark.")
2492
+ return
2493
+ count, override_model = _parse_benchmark_embed_args(arg)
2494
+ model = override_model or harness.resolve_embed_model(cfg)
2495
+ embed_fn, backend, model = make_embed_fn(cfg, model)
2496
+ show_info(f"Benchmark: embedding {count} synthetic records with {model} ({backend})…")
2497
+ texts = [_synthetic_embed_text(i) for i in range(count)]
2498
+
2499
+ single_start = time.perf_counter()
2500
+ try:
2501
+ embed_fn([texts[0]])
2502
+ except Exception as exc:
2503
+ show_error(f"Embedding failed: {exc}")
2504
+ return
2505
+ single_ms = round((time.perf_counter() - single_start) * 1000, 2)
2506
+
2507
+ batch_size = harness.EMBED_BATCH_SIZE
2508
+ per_batch_ms: list[float] = []
2509
+ overall_start = time.perf_counter()
2510
+ try:
2511
+ for start in range(0, count, batch_size):
2512
+ chunk = texts[start:start + batch_size]
2513
+ chunk_start = time.perf_counter()
2514
+ embed_fn(chunk)
2515
+ per_batch_ms.append(round((time.perf_counter() - chunk_start) * 1000, 2))
2516
+ except Exception as exc:
2517
+ show_error(f"Embedding failed mid-run: {exc}")
2518
+ return
2519
+ total_ms = round((time.perf_counter() - overall_start) * 1000, 2)
2520
+
2521
+ sorted_batches = sorted(per_batch_ms)
2522
+ p50 = sorted_batches[len(sorted_batches) // 2]
2523
+ p95_index = max(0, int(round(len(sorted_batches) * 0.95)) - 1)
2524
+ p95 = sorted_batches[p95_index] if sorted_batches else 0.0
2525
+ per_record_mean_ms = round(total_ms / max(1, count), 2)
2526
+
2527
+ show_info(
2528
+ f"Benchmark complete: count={count} model={model} batch_size={batch_size}"
2529
+ )
2530
+ show_info(
2531
+ f" total={total_ms}ms per-record-mean={per_record_mean_ms}ms "
2532
+ f"single-record-baseline={single_ms}ms"
2533
+ )
2534
+ show_info(
2535
+ f" batch latency p50={p50}ms p95={p95}ms batches={len(per_batch_ms)}"
2536
+ )
2537
+
2538
+ log_embed_perf(
2539
+ {
2540
+ "event": "benchmark",
2541
+ "count": count,
2542
+ "batch_size": batch_size,
2543
+ "model": model,
2544
+ "total_ms": total_ms,
2545
+ "per_record_mean_ms": per_record_mean_ms,
2546
+ "single_record_ms": single_ms,
2547
+ "batch_p50_ms": p50,
2548
+ "batch_p95_ms": p95,
2549
+ "batch_count": len(per_batch_ms),
2550
+ },
2551
+ source="benchmark_embed",
2552
+ backend=backend,
2553
+ )
2554
+
2555
+
2556
+ def ensure_lessons_index(cfg: Config) -> bool:
2557
+ """Rebuild the lessons embedding index if stale. Returns True if index is ready."""
2558
+ if not identity.LESSONS_PATH.exists():
2559
+ return False
2560
+ requested_model = harness.resolve_embed_model(cfg)
2561
+ requested_dimensions = configured_embed_dimensions(cfg)
2562
+ if not identity.lessons_index_stale(requested_model, requested_dimensions):
2563
+ status = identity.lessons_index_status()
2564
+ return bool(status.get("index"))
2565
+ backend, _reason = resolve_embed_backend(cfg)
2566
+ if not host_is_local(cfg.host) or not ollama_server_ready(cfg.host):
2567
+ return False
2568
+ embed_fn, _backend, active_model = make_embed_fn(cfg, requested_model)
2569
+ show_info(f"Embedding lessons with {active_model} ({backend})…")
2570
+ result = identity.rebuild_lessons_index(
2571
+ embed_fn,
2572
+ active_model,
2573
+ expected_dimensions=requested_dimensions,
2574
+ )
2575
+ if result.get("ready"):
2576
+ show_info(f"Lessons indexed: {result.get('chunk_count', 0)} chunks.")
2577
+ return True
2578
+ show_info(f"Lesson embedding unavailable: {result.get('reason', 'unknown')}. Falling back to inline lessons.")
2579
+ return False
2580
+
2581
+
2582
+
2583
+
2584
+ def agent_loop(client: Client, cfg: Config, user_message: str) -> None:
2585
+ if json_sink() is None:
2586
+ console.rule(style="border")
2587
+ persisted_user_message = user_message
2588
+ context_query_message = user_message
2589
+ optional_context_blocks: list[OptionalContextBlock] = []
2590
+ reconciliation_guidance = reconciliation.guidance_for_prompt(user_message)
2591
+ if reconciliation_guidance:
2592
+ optional_context_blocks.append(
2593
+ OptionalContextBlock("reconciliation", "Structured Reconciliation", reconciliation_guidance)
2594
+ )
2595
+ active_tools = select_tools_for_prompt(user_message, ALL_TOOLS)
2596
+ for changed_path in identity.detect_changes():
2597
+ show_info(f"↻ identity updated · {changed_path.name}")
2598
+ # Single memoized embed function shared by both retrieval calls (same model).
2599
+ # Saves one Ollama round-trip when both modules embed the same user message.
2600
+ _embed_memo: dict[tuple[str, ...], list[list[float]]] = {}
2601
+ _embed_base, _embed_backend, _embed_model = make_embed_fn(cfg, harness.resolve_embed_model(cfg))
2602
+
2603
+ def _shared_embed(texts: list[str]) -> list[list[float]]:
2604
+ key = tuple(texts)
2605
+ hit = _embed_memo.get(key)
2606
+ if hit is not None:
2607
+ return hit
2608
+ result = _embed_base(texts)
2609
+ _embed_memo[key] = result
2610
+ return result
2611
+
2612
+ # Fetch model metadata once per turn; used to set context window and gate think mode.
2613
+ try:
2614
+ _active_model_info = _model_info_module.resolve_model_info(cfg, client)
2615
+ except Exception:
2616
+ _active_model_info = _model_info_module.resolve_model_info(cfg, None)
2617
+
2618
+ _turn_local_models: list[str] = []
2619
+ if not cfg.cloud and not _model_info_module.is_xai_model(cfg.model):
2620
+ _turn_local_models = local_model_names(cfg)
2621
+
2622
+ retrieved_lessons: list[str] | None = None
2623
+ if ensure_lessons_index(cfg):
2624
+ retrieved_lessons = identity.retrieve_lessons(
2625
+ context_query_message, _shared_embed, _embed_model, k=LESSONS_TOP_K
2626
+ )
2627
+ retrieved_context: list[dict[str, Any]] | None = None
2628
+ from .session_mode import normalize_mode
2629
+
2630
+ _session_mode = normalize_mode(cfg.session_mode)
2631
+ harness_tools_available = any(
2632
+ getattr(tool, "__name__", "").startswith("harness_") for tool in active_tools
2633
+ )
2634
+ if (
2635
+ _session_mode != "execute"
2636
+ and (json_sink() is None or harness_tools_available)
2637
+ and ensure_harness_index(cfg, _turn_local_models)
2638
+ ):
2639
+ retrieved_context = harness.hybrid_search(
2640
+ context_query_message, _shared_embed, _embed_model, k=HARNESS_TOP_K
2641
+ )
2642
+ context_block = harness.format_retrieved_context(retrieved_context or [])
2643
+ if context_block:
2644
+ optional_context_blocks.append(
2645
+ OptionalContextBlock("harness", "Relevant Context", context_block)
2646
+ )
2647
+ if _intuition_engine is not None:
2648
+ try:
2649
+ recalled_blocks = _intuition_engine.recall(
2650
+ context_query_message,
2651
+ enabled=cfg.intuition_recall_enabled,
2652
+ embed_fn=_shared_embed if cfg.intuition_recall_enabled else None,
2653
+ )
2654
+ if recalled_blocks:
2655
+ show_recalled_context(recalled_blocks)
2656
+ injection = _intuition_engine.format_for_injection(recalled_blocks)
2657
+ if injection:
2658
+ optional_context_blocks.append(OptionalContextBlock("intuition", "", injection))
2659
+ context_query_message = f"{context_query_message}\n\n{injection}"
2660
+ except Exception as exc:
2661
+ logger.debug("Intuition run failed: %s", exc)
2662
+ if cfg.index_compute_lab_auto_inject:
2663
+ from . import index_compute_lab
2664
+
2665
+ lab_block = index_compute_lab.context_for_query(context_query_message)
2666
+ if lab_block:
2667
+ optional_context_blocks.append(
2668
+ OptionalContextBlock("index-compute-lab", "Knowledge Graph (index-compute-lab)", lab_block)
2669
+ )
2670
+ if (
2671
+ getattr(cfg, "code_rag_enabled", False)
2672
+ and getattr(cfg, "code_rag_consent_version", 0) == CODE_RAG_CONSENT_VERSION
2673
+ and code_rag_consent_granted(cfg)
2674
+ and host_is_local(cfg.host)
2675
+ and ollama_server_ready(cfg.host)
2676
+ and code_rag.looks_like_code_project(cfg.cwd)
2677
+ ):
2678
+ try:
2679
+ code_hits = code_rag.retrieve(cfg.cwd, persisted_user_message, _shared_embed, _embed_model, k=4)
2680
+ code_block = code_rag.format_code_context(code_hits)
2681
+ if code_block:
2682
+ optional_context_blocks.append(
2683
+ OptionalContextBlock("code", "Working-Directory Code", code_block)
2684
+ )
2685
+ except Exception as exc:
2686
+ logger.debug("Code RAG failed: %s", exc)
2687
+ if getattr(cfg, "reasoning_chat_enabled", False):
2688
+ if json_sink() is None:
2689
+ show_info(f"↳ reasoning preflight ({cfg.reasoning_mode})…")
2690
+ plan_block = reasoning_bridge.maybe_reasoning_plan(cfg, client, persisted_user_message)
2691
+ if plan_block:
2692
+ optional_context_blocks.append(OptionalContextBlock("reasoning", "", plan_block))
2693
+ cfg.messages.append({"role": "user", "content": persisted_user_message})
2694
+ max_iterations = max(8, int(cfg.max_tool_iterations))
2695
+ # Model-aware params: adapt num_ctx/temperature/reflection cadence to the
2696
+ # active model's size + provider, honoring any explicit user overrides.
2697
+ if getattr(cfg, "model_adaptive", True):
2698
+ _profile_params = model_profile.effective_params(cfg, _active_model_info)
2699
+ if _profile_params.adapted_fields and json_sink() is None:
2700
+ show_info(
2701
+ f"↳ model-adaptive ({', '.join(_profile_params.adapted_fields)}): "
2702
+ f"ctx={_profile_params.num_ctx} temp={_profile_params.temperature} "
2703
+ f"reflect={_profile_params.tool_think_every}"
2704
+ )
2705
+ else:
2706
+ _profile_params = model_profile.EffectiveParams(
2707
+ num_ctx=int(cfg.num_ctx),
2708
+ temperature=float(cfg.temperature),
2709
+ tool_think_every=max(1, int(cfg.tool_think_every)),
2710
+ adapted_fields=(),
2711
+ )
2712
+ reflection_interval = max(1, _profile_params.tool_think_every)
2713
+ tool_calls_since_reflection = 0
2714
+ run_started = time.perf_counter()
2715
+ run_tool_calls: list[dict[str, Any]] = []
2716
+ final_content = ""
2717
+ turn_completed_normally = False
2718
+ iterations_used = 0
2719
+ context_selection_notified = False
2720
+ small_context_ledger: small_context.SmallContextLedger | None = None
2721
+ small_context_notified = False
2722
+ completion_nudged = False
2723
+
2724
+ def _fit_request_user_message(system_prompt: str) -> tuple[str, int, list[str], list[str]]:
2725
+ nonlocal small_context_ledger, small_context_notified
2726
+ base_used = estimate_usage_with_system_prompt(system_prompt, cfg)
2727
+ _used, _total, _remaining, runtime_cap, _native = context_status(
2728
+ cfg,
2729
+ client=client,
2730
+ model_info=_active_model_info,
2731
+ usage_override=base_used,
2732
+ )
2733
+ base_message = persisted_user_message
2734
+ adjusted_base_used = base_used
2735
+ live_optional_blocks = optional_context_blocks
2736
+ if optional_context_blocks and small_context.is_small_context(runtime_cap):
2737
+ if small_context_ledger is None:
2738
+ try:
2739
+ small_context_ledger = small_context.write_ledger(
2740
+ model=cfg.model,
2741
+ runtime_cap=runtime_cap,
2742
+ cwd=str(cfg.cwd),
2743
+ base_message=persisted_user_message,
2744
+ optional_blocks=optional_context_blocks,
2745
+ session_summary=cfg.session_summary,
2746
+ messages=cfg.messages,
2747
+ )
2748
+ except OSError as exc:
2749
+ logger.debug("Small-context ledger write failed: %s", exc)
2750
+ if small_context_ledger is not None:
2751
+ trigger = small_context.refresh_trigger(small_context_ledger)
2752
+ base_message = f"{persisted_user_message}\n\n{trigger}"
2753
+ adjusted_base_used += estimate_text_tokens("\n\n" + trigger)
2754
+ if not small_context_notified and json_sink() is None:
2755
+ show_info(f"↳ small-context ledger: {small_context_ledger.path}")
2756
+ small_context_notified = True
2757
+ fitted, included, omitted, optional_used = fit_optional_context_blocks(
2758
+ base_message,
2759
+ live_optional_blocks,
2760
+ base_used_tokens=adjusted_base_used,
2761
+ runtime_cap=runtime_cap,
2762
+ model_info=_active_model_info,
2763
+ )
2764
+ return fitted, adjusted_base_used + optional_used, included, omitted
2765
+
2766
+ try:
2767
+ execution_scope = execution_guardrails.begin_execution_scope(cfg.cwd)
2768
+ except execution_guardrails.ExecutionGuardrailError as exc:
2769
+ show_error(f"Cannot start a safe execution scope: {exc}")
2770
+ return
2771
+
2772
+ try:
2773
+ # Keep the configured tool/model iteration ceiling strict, but reserve
2774
+ # one tool-free response turn when the final capped iteration leaves a
2775
+ # verified state. This prevents a correct run from being reported as
2776
+ # partial solely because its verifier consumed the last work turn.
2777
+ for _ in range(max_iterations + 1):
2778
+ finalization_turn = _ == max_iterations
2779
+ if finalization_turn:
2780
+ completion = execution_guardrails.completion_decision()
2781
+ if not completion.allowed:
2782
+ show_error(
2783
+ f"Max tool iterations reached ({max_iterations}) before successful "
2784
+ "post-mutation verification. Use /toolmax to raise or lower the limit."
2785
+ )
2786
+ break
2787
+ optional_context_blocks.clear()
2788
+ cfg.messages.append(
2789
+ {
2790
+ "role": "user",
2791
+ "content": (
2792
+ "[Internal finalization turn] The configured work-iteration budget is "
2793
+ "exhausted and the current result is verified. Do not call more tools. "
2794
+ "Give a concise final answer describing the verified result or any "
2795
+ "remaining blocker."
2796
+ ),
2797
+ }
2798
+ )
2799
+ iterations_used += 1
2800
+ prune_stale_tool_messages(cfg)
2801
+ last_user = persisted_user_message
2802
+ for msg in reversed(cfg.messages):
2803
+ if msg.get("role") == "user":
2804
+ last_user = str(msg.get("content") or persisted_user_message)
2805
+ break
2806
+ system_prompt = build_system_prompt(
2807
+ cfg,
2808
+ retrieved_lessons=retrieved_lessons,
2809
+ active_model_info=_active_model_info,
2810
+ user_message=last_user,
2811
+ )
2812
+ request_user_message, precomputed_used, included_contexts, omitted_contexts = _fit_request_user_message(system_prompt)
2813
+ if maybe_compact_context(
2814
+ client,
2815
+ cfg,
2816
+ precomputed_used=precomputed_used,
2817
+ model_info=_active_model_info,
2818
+ ):
2819
+ invalidate_context_usage_cache()
2820
+ system_prompt = build_system_prompt(
2821
+ cfg,
2822
+ retrieved_lessons=retrieved_lessons,
2823
+ active_model_info=_active_model_info,
2824
+ user_message=last_user,
2825
+ )
2826
+ request_user_message, precomputed_used, included_contexts, omitted_contexts = _fit_request_user_message(system_prompt)
2827
+ request_messages = [{"role": "system", "content": system_prompt}] + cfg.messages
2828
+ if request_user_message != persisted_user_message:
2829
+ for i in range(len(request_messages) - 1, -1, -1):
2830
+ if request_messages[i].get("role") == "user":
2831
+ msg = dict(request_messages[i])
2832
+ msg["content"] = request_user_message
2833
+ request_messages[i] = msg
2834
+ break
2835
+ if optional_context_blocks and not context_selection_notified and json_sink() is None:
2836
+ if included_contexts:
2837
+ show_info(f"↳ auto context attached: {', '.join(included_contexts)}")
2838
+ if omitted_contexts:
2839
+ show_info(f"↳ context budget omitted: {', '.join(omitted_contexts)}")
2840
+ context_selection_notified = True
2841
+ thinking_text = ""
2842
+ content_text = ""
2843
+ tool_calls: list[Any] = []
2844
+ message_signature: str | None = None
2845
+ completion_pending_before_response = not execution_guardrails.completion_decision().allowed
2846
+
2847
+ # Use the model's real context window when known; cap at the
2848
+ # model-adaptive window (which already honors user overrides and the
2849
+ # native ceiling).
2850
+ _model_ctx = _model_info_module.get_context_length(_active_model_info)
2851
+ _effective_ctx = min(_profile_params.num_ctx, _model_ctx) if _model_ctx else _profile_params.num_ctx
2852
+ # Gemini-3 ignores think=False at the SDK layer (model thinks by default
2853
+ # at MINIMAL levels and still requires thought_signature on every
2854
+ # functionCall). Disable thinking on our side AND collapse prior tool_call
2855
+ # history into content turns. Pending Ollama PR #14676 / issue #14567.
2856
+ if _model_info_module.is_gemini_model(cfg.model):
2857
+ _think = False
2858
+ request_messages = collapse_tool_history_for_gemini(request_messages)
2859
+ if cfg.model not in _GEMINI_WORKAROUND_NOTICE_SHOWN:
2860
+ _GEMINI_WORKAROUND_NOTICE_SHOWN.add(cfg.model)
2861
+ show_info(
2862
+ f"Note: {cfg.model} is using a tool-call workaround pending "
2863
+ "Ollama issue #14567. Tool history is collapsed into content "
2864
+ "turns instead of native functionCall parts — slight reasoning "
2865
+ "tradeoff but tool use works end-to-end."
2866
+ )
2867
+ else:
2868
+ _think = cfg.show_thinking and _model_info_module.supports_thinking(_active_model_info)
2869
+ chat_options: dict[str, Any] = {
2870
+ "num_ctx": _effective_ctx,
2871
+ "temperature": _profile_params.temperature,
2872
+ # num_keep pins the system prompt in the KV cache slot so it is
2873
+ # never evicted by sliding-window truncation during long sessions.
2874
+ "num_keep": estimate_text_tokens(system_prompt),
2875
+ }
2876
+ status = None
2877
+ if json_sink() is None:
2878
+ status = console.status("[muted]waiting for model...[/]", spinner="dots")
2879
+ status.start()
2880
+ stream_started = False
2881
+ stream_error: Exception | None = None
2882
+ try:
2883
+ stream = client.chat(
2884
+ model=cfg.model,
2885
+ messages=request_messages,
2886
+ tools=[] if finalization_turn else active_tools,
2887
+ stream=True,
2888
+ think=_think,
2889
+ keep_alive=cfg.keep_alive,
2890
+ options=chat_options,
2891
+ )
2892
+
2893
+ for chunk in stream:
2894
+ if status is not None:
2895
+ status.stop()
2896
+ status = None
2897
+ record_chat_metrics(cfg, chunk)
2898
+ event_sink = json_sink()
2899
+ record_usage = getattr(event_sink, "chat_usage", None)
2900
+ if callable(record_usage):
2901
+ record_usage(
2902
+ prompt_tokens=get_attr(chunk, "prompt_eval_count", None),
2903
+ completion_tokens=get_attr(chunk, "eval_count", None),
2904
+ )
2905
+ message = get_attr(chunk, "message", {})
2906
+ thinking = get_attr(message, "thinking", "")
2907
+ content = get_attr(message, "content", "")
2908
+ calls = get_attr(message, "tool_calls", None)
2909
+ if thinking and cfg.show_thinking:
2910
+ show_thinking_text(thinking)
2911
+ thinking_text += thinking
2912
+ if content:
2913
+ finish_thinking_block()
2914
+ if not completion_pending_before_response:
2915
+ if not stream_started:
2916
+ start_streaming_response()
2917
+ stream_started = True
2918
+ show_stream_text(content)
2919
+ content_text += content
2920
+ if calls:
2921
+ tool_calls.extend(calls)
2922
+ # Forward-compat: capture any message-level thought_signature
2923
+ # for round-tripping when the SDK starts exposing it.
2924
+ sig = (
2925
+ get_attr(message, "thought_signature", None)
2926
+ or get_attr(message, "thoughtSignature", None)
2927
+ )
2928
+ if sig:
2929
+ message_signature = sig
2930
+ except Exception as exc:
2931
+ stream_error = exc
2932
+ finally:
2933
+ if status is not None:
2934
+ status.stop()
2935
+ finish_thinking_block()
2936
+ finish_streaming_response()
2937
+
2938
+ if json_sink() is None:
2939
+ console.print()
2940
+ if stream_error is not None and not (content_text or thinking_text):
2941
+ raise stream_error
2942
+ terminal_answer = _terminal_answer_from_tool_calls(tool_calls)
2943
+ if terminal_answer is not None:
2944
+ tool_calls = []
2945
+ if terminal_answer:
2946
+ content_text = terminal_answer
2947
+ if not completion_pending_before_response:
2948
+ start_streaming_response()
2949
+ show_stream_text(terminal_answer)
2950
+ finish_streaming_response()
2951
+ assistant: dict[str, Any] = {"role": "assistant"}
2952
+ if content_text:
2953
+ assistant["content"] = content_text
2954
+ final_content = content_text
2955
+ if thinking_text:
2956
+ assistant["thinking"] = thinking_text
2957
+ serialized_calls = [serialize_tool_call(call) for call in tool_calls]
2958
+ if tool_calls and stream_error is None:
2959
+ assistant["tool_calls"] = serialized_calls
2960
+ if message_signature:
2961
+ assistant["thought_signature"] = message_signature
2962
+ cfg.messages.append(assistant)
2963
+
2964
+ if stream_error is not None:
2965
+ show_error(
2966
+ "Response stream interrupted after partial output: "
2967
+ f"{stream_error}. Partial response retained; retry the request to continue."
2968
+ )
2969
+ break
2970
+
2971
+ if finalization_turn and tool_calls:
2972
+ final_content = ""
2973
+ show_error("Finalization turn attempted an additional tool call; completion withheld.")
2974
+ break
2975
+
2976
+ if not tool_calls:
2977
+ completion = execution_guardrails.completion_decision()
2978
+ if completion.allowed:
2979
+ turn_completed_normally = True
2980
+ break
2981
+ if not completion_nudged:
2982
+ if _ + 1 >= max_iterations:
2983
+ final_content = ""
2984
+ show_error(
2985
+ "Completion blocked: the tool-iteration budget ended before successful "
2986
+ "post-mutation verification."
2987
+ )
2988
+ break
2989
+ completion_nudged = True
2990
+ final_content = ""
2991
+ optional_context_blocks.clear()
2992
+ show_info(
2993
+ "Unverified final text was withheld. Completion is deferred until the last "
2994
+ "workspace mutation has a passing "
2995
+ "test, lint/type check, or git diff verification."
2996
+ )
2997
+ cfg.messages.append(
2998
+ {
2999
+ "role": "user",
3000
+ "content": (
3001
+ "[Internal completion gate] Do not claim completion yet. The last "
3002
+ "workspace mutation has no successful post-mutation verifier. Run one "
3003
+ "appropriate non-mutating test, lint/type check, or git_diff tool now. "
3004
+ "Custom verification must fail on mismatch: run a healthcheck/check/verify "
3005
+ "script, or Python -c with one or more assertions; then give a concise "
3006
+ "final answer grounded in that result."
3007
+ ),
3008
+ }
3009
+ )
3010
+ continue
3011
+ final_content = ""
3012
+ show_error(
3013
+ "Completion blocked: the model stopped twice without successful verification "
3014
+ "after its last workspace mutation."
3015
+ )
3016
+ break
3017
+
3018
+ # Normalize all calls once; reused by both the parallel check and _batch build.
3019
+ _nc = [normalize_tool_call(c) for c in tool_calls]
3020
+
3021
+ def _exec_one(item: tuple[tuple[str, dict[str, Any]], str | None]) -> tuple[str, dict[str, Any], str | None, str, float]:
3022
+ (n, a), tid = item
3023
+ t0 = time.perf_counter()
3024
+ res = run_tool(n, a, cfg)
3025
+ return n, a, tid, res, round((time.perf_counter() - t0) * 1000, 2)
3026
+
3027
+ # Parallel dispatch when all tool calls in this batch are read-only.
3028
+ _parallel = (
3029
+ len(tool_calls) > 1
3030
+ and all(n[0] in READ_ONLY_TOOLS for n in _nc)
3031
+ and all(not find_failed_attempt(cfg, tool_attempt_signature(n[0], tool_runtime_args(n[0], n[1], cfg))) for n in _nc)
3032
+ )
3033
+ if _parallel:
3034
+ # _batch items must match _exec_one signature: tuple[tuple[str, dict], str | None]
3035
+ _batch = [(n, (str(sc.get("id") or "") or None)) for n, sc in zip(_nc, serialized_calls)]
3036
+ for item in _batch:
3037
+ (name, args), tid = item
3038
+ show_tool_call(name, args, call_id=tid)
3039
+
3040
+ ordered_results: list[
3041
+ tuple[str, dict[str, Any], str | None, str, float] | None
3042
+ ] = [None] * len(_batch)
3043
+ dispatch_order = order_tool_batch_by_qos([item[0] for item in _batch])
3044
+ preflight_by_index: dict[int, RuntimeToolPreflight] = {}
3045
+ allowed_dispatch_order: list[int] = []
3046
+ for queue_position, batch_index in enumerate(dispatch_order):
3047
+ (queued_name, queued_args), _queued_id = _batch[batch_index]
3048
+ preflight = preflight_runtime_tool(
3049
+ queued_name,
3050
+ queued_args,
3051
+ cfg,
3052
+ queue_position=queue_position,
3053
+ )
3054
+ preflight_by_index[batch_index] = preflight
3055
+ if preflight.allowed:
3056
+ allowed_dispatch_order.append(batch_index)
3057
+ if allowed_dispatch_order:
3058
+ with scoped_tool_runtime_env(cfg):
3059
+ with ThreadPoolExecutor(max_workers=min(4, len(allowed_dispatch_order))) as _pool:
3060
+ future_to_index = {
3061
+ _pool.submit(_exec_one, _batch[idx]): idx for idx in allowed_dispatch_order
3062
+ }
3063
+ for future in as_completed(future_to_index):
3064
+ ordered_results[future_to_index[future]] = future.result()
3065
+
3066
+ for idx, ((name, args), _tid) in enumerate(_batch):
3067
+ preflight = preflight_by_index[idx]
3068
+ if not preflight.allowed:
3069
+ result = preflight.blocked_result
3070
+ show_tool_result(name, result, approved=False, call_id=_tid)
3071
+ cfg.messages.append(tool_result_message(name, result, _tid))
3072
+ record_tool_attempt(
3073
+ cfg,
3074
+ name=name,
3075
+ args=preflight.signature_args,
3076
+ result=result,
3077
+ status="denied",
3078
+ )
3079
+ record_perf_event(
3080
+ "tool",
3081
+ tool=name,
3082
+ status="denied",
3083
+ duration_ms=0.0,
3084
+ **preflight.qos_fields,
3085
+ )
3086
+ run_tool_calls.append(
3087
+ {"name": name, "status": "denied", "args": _run_args_preview(args, name=name)}
3088
+ )
3089
+ tool_calls_since_reflection += 1
3090
+ continue
3091
+ row = ordered_results[idx]
3092
+ if row is None:
3093
+ continue
3094
+ name, args, tool_call_id, result, duration_ms = row
3095
+ tool_status = classify_tool_status(result)
3096
+ result = augment_tool_result_with_reflex(
3097
+ cfg, name, preflight.signature_args, result, tool_status
3098
+ )
3099
+ show_tool_result(name, result, duration_ms=duration_ms, call_id=tool_call_id)
3100
+ cfg.messages.append(tool_result_message(name, result, tool_call_id))
3101
+ record_tool_attempt(
3102
+ cfg,
3103
+ name=name,
3104
+ args=preflight.signature_args,
3105
+ result=result,
3106
+ status=tool_status,
3107
+ )
3108
+ record_perf_event(
3109
+ "tool",
3110
+ tool=name,
3111
+ status=tool_status,
3112
+ duration_ms=duration_ms,
3113
+ **preflight.qos_fields,
3114
+ )
3115
+ run_tool_calls.append(
3116
+ {"name": name, "status": tool_status, "args": _run_args_preview(args, name=name)}
3117
+ )
3118
+ tool_calls_since_reflection += 1
3119
+ else:
3120
+ for call, serialized_call in zip(tool_calls, serialized_calls):
3121
+ tool_call_id = str(serialized_call.get("id") or "") or None
3122
+ name, args = normalize_tool_call(call)
3123
+ show_tool_call(name, args, call_id=tool_call_id)
3124
+ preflight = preflight_runtime_tool(name, args, cfg)
3125
+ signature_args = preflight.signature_args
3126
+ if not preflight.allowed:
3127
+ result = preflight.blocked_result
3128
+ show_tool_result(name, result, approved=False, call_id=tool_call_id)
3129
+ cfg.messages.append(tool_result_message(name, result, tool_call_id))
3130
+ record_tool_attempt(cfg, name=name, args=signature_args, result=result, status="denied")
3131
+ record_perf_event(
3132
+ "tool",
3133
+ tool=name,
3134
+ status="denied",
3135
+ duration_ms=0.0,
3136
+ **preflight.qos_fields,
3137
+ )
3138
+ run_tool_calls.append(
3139
+ {"name": name, "status": "denied", "args": _run_args_preview(args, name=name)}
3140
+ )
3141
+ tool_calls_since_reflection += 1
3142
+ continue
3143
+ signature = tool_attempt_signature(name, signature_args)
3144
+ previous_failure = find_failed_attempt(cfg, signature)
3145
+ if previous_failure:
3146
+ result = (
3147
+ "Skipped repeated failed attempt. "
3148
+ f"Prior outcome: {previous_failure.get('summary', 'same tool path already failed or was denied')}."
3149
+ )
3150
+ show_tool_result(name, result, approved=False, call_id=tool_call_id)
3151
+ cfg.messages.append(tool_result_message(name, result, tool_call_id))
3152
+ record_tool_attempt(cfg, name=name, args=signature_args, result=result, status="skipped")
3153
+ record_perf_event(
3154
+ "tool",
3155
+ tool=name,
3156
+ status="skipped",
3157
+ duration_ms=0.0,
3158
+ **preflight.qos_fields,
3159
+ )
3160
+ run_tool_calls.append({"name": name, "status": "skipped", "args": _run_args_preview(args, name=name)})
3161
+ tool_calls_since_reflection += 1
3162
+ continue
3163
+ if not ask_approval(name, args, cfg):
3164
+ result = "User denied this operation."
3165
+ show_tool_result(name, result, approved=False, call_id=tool_call_id)
3166
+ cfg.messages.append(tool_result_message(name, result, tool_call_id))
3167
+ record_tool_attempt(cfg, name=name, args=signature_args, result=result, status="denied")
3168
+ record_perf_event(
3169
+ "tool",
3170
+ tool=name,
3171
+ status="denied",
3172
+ duration_ms=0.0,
3173
+ **preflight.qos_fields,
3174
+ )
3175
+ run_tool_calls.append({"name": name, "status": "denied", "args": _run_args_preview(args, name=name)})
3176
+ continue
3177
+
3178
+ started = time.perf_counter()
3179
+ with scoped_tool_runtime_env(cfg):
3180
+ with tool_execution_status(
3181
+ f"[muted]executing {name} · {preflight.runtime_hint.spawn_class.value}...[/]"
3182
+ ):
3183
+ result = run_tool(name, args, cfg)
3184
+ duration_ms = round((time.perf_counter() - started) * 1000, 2)
3185
+ tool_status = classify_tool_status(result)
3186
+ result = augment_tool_result_with_reflex(
3187
+ cfg, name, signature_args, result, tool_status
3188
+ )
3189
+ show_tool_result(name, result, duration_ms=duration_ms, call_id=tool_call_id)
3190
+ cfg.messages.append(tool_result_message(name, result, tool_call_id))
3191
+ record_tool_attempt(
3192
+ cfg, name=name, args=signature_args, result=result, status=tool_status
3193
+ )
3194
+ record_perf_event(
3195
+ "tool",
3196
+ tool=name,
3197
+ status=tool_status,
3198
+ duration_ms=duration_ms,
3199
+ **preflight.qos_fields,
3200
+ )
3201
+ run_tool_calls.append(
3202
+ {"name": name, "status": tool_status, "args": _run_args_preview(args, name=name)}
3203
+ )
3204
+ tool_calls_since_reflection += 1
3205
+
3206
+ if tool_calls_since_reflection >= reflection_interval:
3207
+ if json_sink() is None:
3208
+ reflection_checkpoint(client, cfg, persisted_user_message, reflection_interval)
3209
+ tool_calls_since_reflection -= reflection_interval
3210
+ finally:
3211
+ try:
3212
+ execution_guardrails.end_execution_scope(execution_scope)
3213
+ except execution_guardrails.ExecutionGuardrailError as exc:
3214
+ logger.error("Could not close execution guardrail scope: %s", exc)
3215
+ turn_completed_normally = False
3216
+ cfg.save()
3217
+ flush_perf_records()
3218
+
3219
+ # Tier-3: claim-grounding verify pass when verify_mode is active.
3220
+ if (
3221
+ turn_completed_normally
3222
+ and cfg.verify_mode
3223
+ and final_content
3224
+ and host_is_local(cfg.host)
3225
+ and ollama_server_ready(cfg.host)
3226
+ ):
3227
+ try:
3228
+ llm_fn = make_maintenance_llm_fn(cfg)
3229
+ report = _verify_module.verify_response(final_content, llm_fn)
3230
+ summary = _verify_module.format_verification_report(report)
3231
+ if summary:
3232
+ show_info(summary)
3233
+ if report["confidence"] < 0.5:
3234
+ show_info(
3235
+ "⚠ Less than half the model's claims are grounded in the harness. "
3236
+ "Treat specific facts with caution and verify with tool calls."
3237
+ )
3238
+ except Exception:
3239
+ pass
3240
+
3241
+ memory_result = memory_runtime.capture_completed_user_turn(
3242
+ cfg,
3243
+ persisted_user_message,
3244
+ completed=turn_completed_normally,
3245
+ tool_calls=run_tool_calls,
3246
+ source="chat",
3247
+ )
3248
+ flush_perf_records()
3249
+ if memory_result.get("status") == "stored":
3250
+ show_info("Saved 1 durable memory automatically; review it with /memories.")
3251
+
3252
+ # Post-run: record the completed run, then periodically crystallize skills.
3253
+ if cfg.skill_crystallize_enabled and run_tool_calls and turn_completed_normally:
3254
+ run_duration_ms = round((time.perf_counter() - run_started) * 1000, 2)
3255
+ skills.record_run(
3256
+ goal=user_message,
3257
+ tool_calls=run_tool_calls,
3258
+ outcome=final_content,
3259
+ iterations=iterations_used,
3260
+ duration_ms=run_duration_ms,
3261
+ )
3262
+ cfg.runs_since_crystallize += 1
3263
+ cfg.save()
3264
+ maybe_crystallize_skills(cfg)
3265
+
3266
+
3267
+ GOAL_COMPLETE_MARKER = "GOAL COMPLETE"
3268
+ GOAL_BLOCKED_MARKER = "GOAL BLOCKED:"
3269
+ GOAL_DEFAULT_MAX_ROUNDS = 10
3270
+
3271
+ _GOAL_INSTRUCTIONS = (
3272
+ "\n\n[Goal mode] Work toward the goal above until it is 100% complete.\n"
3273
+ "- Verify progress with tools (run tests, read files) before claiming completion.\n"
3274
+ f"- When and ONLY when the goal is fully complete and verified, end your reply with a line containing exactly: {GOAL_COMPLETE_MARKER}\n"
3275
+ f"- If you cannot proceed without input only the user can give, end with: {GOAL_BLOCKED_MARKER} <one-line reason>\n"
3276
+ "- Otherwise just keep working; you will be asked to continue."
3277
+ )
3278
+
3279
+ _GOAL_CONTINUE_PROMPT = (
3280
+ "[Goal mode] The goal is not yet marked complete. Re-check what remains, "
3281
+ "continue working, and verify with tools. End with the completion or "
3282
+ "blocked marker per the goal-mode rules."
3283
+ )
3284
+
3285
+
3286
+ def _last_assistant_content(cfg: Config) -> str:
3287
+ for message in reversed(cfg.messages):
3288
+ if message.get("role") == "assistant" and message.get("content"):
3289
+ return str(message["content"])
3290
+ return ""
3291
+
3292
+
3293
+ def parse_goal_args(arg: str) -> tuple[str, int]:
3294
+ """Parse '/goal [--rounds N] TASK' into (task, max_rounds)."""
3295
+ tokens = (arg or "").split()
3296
+ max_rounds = GOAL_DEFAULT_MAX_ROUNDS
3297
+ rest: list[str] = []
3298
+ i = 0
3299
+ while i < len(tokens):
3300
+ if tokens[i] == "--rounds" and i + 1 < len(tokens):
3301
+ try:
3302
+ max_rounds = max(1, int(tokens[i + 1]))
3303
+ except ValueError:
3304
+ pass
3305
+ i += 2
3306
+ continue
3307
+ rest.append(tokens[i])
3308
+ i += 1
3309
+ return " ".join(rest).strip(), max_rounds
3310
+
3311
+
3312
+ def _drive_goal(client: Client, cfg: Config, record: task_ledger.GoalRecord, *, first_prompt: str) -> None:
3313
+ """Run rounds for an (already-persisted) goal record until done/blocked/cap."""
3314
+ prompt = first_prompt
3315
+ remaining = record.max_rounds - record.rounds_done
3316
+ if remaining <= 0:
3317
+ show_error(
3318
+ f"Goal already used its {record.max_rounds}-round budget. "
3319
+ "Raise it with /goal resume --rounds N, or /goal clear to drop it."
3320
+ )
3321
+ return
3322
+ show_info(f"Goal mode: {remaining} round(s) remaining of {record.max_rounds}. Ctrl+C to stop.")
3323
+ for _ in range(remaining):
3324
+ round_number = record.rounds_done + 1
3325
+ show_info(f"Goal round {round_number}/{record.max_rounds}")
3326
+ try:
3327
+ agent_loop(client, cfg, prompt)
3328
+ except KeyboardInterrupt:
3329
+ record.status = task_ledger.STATUS_STOPPED
3330
+ record.reason = "interrupted by user"
3331
+ record.add_round("(interrupted)")
3332
+ task_ledger.save_goal(record)
3333
+ show_info(f"Goal stopped after round {round_number}. Resume with /goal resume.")
3334
+ return
3335
+ final = _last_assistant_content(cfg)
3336
+ record.add_round(final[:500])
3337
+ if GOAL_COMPLETE_MARKER in final:
3338
+ record.status = task_ledger.STATUS_COMPLETE
3339
+ task_ledger.save_goal(record)
3340
+ show_info(f"Goal marked complete after {round_number} round(s).")
3341
+ return
3342
+ blocked_at = final.find(GOAL_BLOCKED_MARKER)
3343
+ if blocked_at != -1:
3344
+ reason_lines = final[blocked_at + len(GOAL_BLOCKED_MARKER):].strip().splitlines()
3345
+ reason = reason_lines[0] if reason_lines else "(no reason given)"
3346
+ record.status = task_ledger.STATUS_BLOCKED
3347
+ record.reason = reason
3348
+ task_ledger.save_goal(record)
3349
+ show_error(f"Goal blocked: {reason}")
3350
+ return
3351
+ record.status = task_ledger.STATUS_RUNNING
3352
+ task_ledger.save_goal(record)
3353
+ prompt = _GOAL_CONTINUE_PROMPT
3354
+ record.status = task_ledger.STATUS_STOPPED
3355
+ record.reason = "round cap reached"
3356
+ task_ledger.save_goal(record)
3357
+ show_error(
3358
+ f"Goal not marked complete after {record.max_rounds} rounds. "
3359
+ "Continue with /goal resume [--rounds N], or /goal clear to drop it."
3360
+ )
3361
+
3362
+
3363
+ def show_goal_status() -> None:
3364
+ record = task_ledger.load_goal()
3365
+ if record is None:
3366
+ show_info("No active goal. Start one with /goal <task>.")
3367
+ return
3368
+ console.print(f"[bold primary]Goal:[/] {record.goal}")
3369
+ console.print(f" status : [text]{record.status}[/]")
3370
+ console.print(f" rounds : [text]{record.rounds_done}/{record.max_rounds}[/]")
3371
+ if record.cwd:
3372
+ console.print(f" cwd : [text]{record.cwd}[/]")
3373
+ if record.reason:
3374
+ console.print(f" reason : [text]{record.reason}[/]")
3375
+ if record.history:
3376
+ last = record.history[-1]
3377
+ console.print(f" last : [muted]{str(last.get('summary', ''))[:160]}[/]")
3378
+ if record.is_open:
3379
+ console.print("[muted]Resume with /goal resume; drop with /goal clear.[/]")
3380
+
3381
+
3382
+ def run_goal_loop(client: Client, cfg: Config, arg: str) -> None:
3383
+ """Drive agent_loop rounds until the model marks the goal complete/blocked.
3384
+
3385
+ Subcommands: /goal status, /goal resume [--rounds N], /goal clear.
3386
+ """
3387
+ stripped = (arg or "").strip()
3388
+ sub = stripped.split(maxsplit=1)[0].lower() if stripped else ""
3389
+
3390
+ if sub == "status":
3391
+ show_goal_status()
3392
+ return
3393
+ if sub == "clear":
3394
+ show_info("Cleared active goal." if task_ledger.clear_goal() else "No active goal to clear.")
3395
+ return
3396
+ if sub == "resume":
3397
+ record = task_ledger.load_goal()
3398
+ if record is None:
3399
+ show_error("No saved goal to resume. Start one with /goal <task>.")
3400
+ return
3401
+ if not record.is_open:
3402
+ show_error(f"Saved goal is '{record.status}', not resumable. Use /goal <task> to start fresh.")
3403
+ return
3404
+ _, extra_rounds = parse_goal_args(stripped[len(sub):])
3405
+ if "--rounds" in stripped:
3406
+ record.max_rounds = record.rounds_done + extra_rounds
3407
+ if record.cwd:
3408
+ cfg.cwd = record.cwd
3409
+ show_info(f"Resuming goal: {record.goal}")
3410
+ _drive_goal(client, cfg, record, first_prompt=_GOAL_CONTINUE_PROMPT)
3411
+ return
3412
+
3413
+ goal, max_rounds = parse_goal_args(stripped)
3414
+ if not goal:
3415
+ show_error("Usage: /goal [--rounds N] <task> | /goal resume|status|clear "
3416
+ f"(default rounds: {GOAL_DEFAULT_MAX_ROUNDS}; Ctrl+C stops)")
3417
+ return
3418
+ record = task_ledger.GoalRecord(goal=goal, max_rounds=max_rounds, cwd=cfg.cwd)
3419
+ task_ledger.save_goal(record)
3420
+ _drive_goal(client, cfg, record, first_prompt=f"GOAL: {goal}{_GOAL_INSTRUCTIONS}")
3421
+
3422
+
3423
+ def print_models(client: Client) -> None:
3424
+ models = client.list()
3425
+ items = get_attr(models, "models", []) or []
3426
+ if not items:
3427
+ show_info("No local models returned.")
3428
+ return
3429
+ for model in items:
3430
+ name = get_attr(model, "name", None) or get_attr(model, "model", "?")
3431
+ size = get_attr(model, "size", 0) or 0
3432
+ size_text = f"{size / 1e9:.1f} GB" if size else "?"
3433
+ console.print(f" {name} ({size_text})")
3434
+
3435
+
3436
+ def print_harness_results(
3437
+ query: str,
3438
+ cfg: Config | None = None,
3439
+ harness_name: str | None = None,
3440
+ kind: str | None = None,
3441
+ ) -> None:
3442
+ if cfg is not None:
3443
+ ensure_harness_index(cfg) # embed any pending records before searching
3444
+ if cfg is not None:
3445
+ embed_fn, _backend, active_model = make_embed_fn(cfg, harness.resolve_embed_model(cfg))
3446
+ matching, _total = harness.embedded_count(active_model)
3447
+ else:
3448
+ embed_fn = None
3449
+ active_model = harness.DEFAULT_EMBED_MODEL
3450
+ matching, _total = harness.embedded_count(active_model)
3451
+ if cfg is not None and embed_fn is not None and matching > 0:
3452
+ results = harness.hybrid_search(query, embed_fn, active_model, k=12, harness=harness_name, kind=kind)
3453
+ if results:
3454
+ lines = [f"[dim]hybrid (RRF) results for:[/] {query}", ""]
3455
+ for rec in results:
3456
+ harness_kind = " · ".join(filter(None, [rec.get("harness"), rec.get("kind")]))
3457
+ score = rec.get("score", 0.0)
3458
+ lines.append(f" [primary]{rec.get('id', '')}[/] {rec.get('title', '')} [muted]{harness_kind} rrf={score:.4f}[/]")
3459
+ if rec.get("snippet"):
3460
+ lines.append(f" [muted]{rec['snippet'][:160]}[/]")
3461
+ console.print("\n".join(lines))
3462
+ return
3463
+ from .tools import harness_search
3464
+ console.print(harness_search(query=query, harness_name=harness_name, kind=kind, limit=12))
3465
+
3466
+
3467
+ def build_rust_indexer() -> None:
3468
+ source_dir = Path(__file__).resolve().parents[1] / "harness-indexer"
3469
+ cargo = shutil.which("cargo") or (
3470
+ str(Path.home() / ".cargo" / "bin" / "cargo.exe")
3471
+ if (Path.home() / ".cargo" / "bin" / "cargo.exe").exists()
3472
+ else ""
3473
+ )
3474
+ if not source_dir.exists():
3475
+ show_error(f"Rust indexer source not found: {source_dir}")
3476
+ return
3477
+ if not cargo:
3478
+ show_error("Cargo was not found. Install Rust or add cargo to PATH.")
3479
+ return
3480
+ show_info("Building optional Rust harness indexer with `cargo build --release`.")
3481
+ try:
3482
+ proc = subprocess.run(
3483
+ [cargo, "build", "--release"],
3484
+ cwd=source_dir,
3485
+ text=True,
3486
+ encoding="utf-8",
3487
+ errors="replace",
3488
+ capture_output=True,
3489
+ timeout=180,
3490
+ )
3491
+ except Exception as exc:
3492
+ show_error(f"Could not build Rust indexer: {exc}")
3493
+ return
3494
+ if proc.returncode != 0:
3495
+ show_error((proc.stderr or proc.stdout or "Rust indexer build failed.").strip())
3496
+ return
3497
+ binary = harness.find_rust_indexer()
3498
+ if binary:
3499
+ show_info(f"Rust harness indexer built: {binary}")
3500
+ try:
3501
+ harness.INDEX_PATH.unlink(missing_ok=True)
3502
+ harness._INDEX_CACHE = None
3503
+ index = harness.load_index(refresh=True)
3504
+ except Exception as exc:
3505
+ show_error(f"Rust indexer built, but immediate refresh failed: {exc}")
3506
+ return
3507
+ show_info(f"Rust harness indexer exercised. Records: {index.get('record_count', 0)}.")
3508
+ else:
3509
+ show_error("Cargo build finished, but the Rust indexer binary was not found.")
3510
+
3511
+
3512
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
3513
+ parser = argparse.ArgumentParser(
3514
+ description="Agent runtime for tools, durable context, and verified work."
3515
+ )
3516
+ parser.add_argument("--model", help="Model to use for this session.")
3517
+ parser.add_argument("--host", help="Local Ollama host.")
3518
+ parser.add_argument("--cloud", action="store_true", help="Use Ollama Cloud client defaults.")
3519
+ parser.add_argument("--cwd", help="Working directory for tools.")
3520
+ parser.add_argument(
3521
+ "--version",
3522
+ action="store_true",
3523
+ help="Show full version manifest (CLI, Python, platform, harness, plugins) and exit.",
3524
+ )
3525
+ parser.add_argument(
3526
+ "--oneshot",
3527
+ action="store_true",
3528
+ help="Run a single non-interactive turn and exit. Requires --json. Prompt comes from positional arg or stdin.",
3529
+ )
3530
+ parser.add_argument(
3531
+ "--json",
3532
+ dest="json_events",
3533
+ action="store_true",
3534
+ help="Emit one JSON event per line to stdout (NDJSON). Implies non-Rich output; only valid with --oneshot.",
3535
+ )
3536
+ parser.add_argument(
3537
+ "--approval-mode",
3538
+ choices=["never", "auto"],
3539
+ default="never",
3540
+ help="In --oneshot, control how approval-required tools are handled: never (default, deny + tool_denied event) or auto (auto-approve, equivalent to /auto).",
3541
+ )
3542
+ parser.add_argument(
3543
+ "--thinking",
3544
+ choices=["auto", "on", "off"],
3545
+ default="auto",
3546
+ help="In --oneshot, use adaptive deliberation (default), force model thinking on, or force it off.",
3547
+ )
3548
+ parser.add_argument("prompt", nargs="*", help="Prompt for --oneshot mode. If omitted, read from stdin. Use `doctor` for readiness diagnostics, `plugin list` for plugins, `credential list` for credential helpers, `url-scheme <url>` for URL scheme parsing.")
3549
+ ns = parser.parse_args(argv)
3550
+ # Normalize nargs="*" list into a single string for downstream code
3551
+ if ns.prompt:
3552
+ ns.prompt = " ".join(ns.prompt)
3553
+ else:
3554
+ ns.prompt = None
3555
+ return ns
3556
+
3557
+
3558
+ def _run_oneshot_entry(args: argparse.Namespace) -> int:
3559
+ if not args.json_events:
3560
+ sys.stderr.write("--oneshot requires --json (NDJSON output). Exiting.\n")
3561
+ return 64
3562
+ # args.prompt is normalized to a string by parse_args()/main()
3563
+ prompt = args.prompt or sys.stdin.read()
3564
+ prompt = (prompt or "").strip()
3565
+ if not prompt:
3566
+ sys.stderr.write("No prompt provided (positional arg empty and stdin empty). Exiting.\n")
3567
+ return 64
3568
+ overrides: dict[str, Any] = {}
3569
+ if args.model:
3570
+ overrides["model"] = args.model
3571
+ if args.host:
3572
+ overrides["host"] = args.host
3573
+ overrides["cloud"] = False
3574
+ if args.cloud:
3575
+ overrides["cloud"] = True
3576
+ if args.cwd:
3577
+ overrides["cwd"] = str(Path(args.cwd).expanduser().resolve())
3578
+ if args.thinking != "auto":
3579
+ overrides["show_thinking"] = args.thinking == "on"
3580
+ from . import oneshot as _oneshot_module
3581
+ return _oneshot_module.run_oneshot(
3582
+ prompt=prompt,
3583
+ approval_mode=args.approval_mode,
3584
+ cfg_overrides=overrides or None,
3585
+ )
3586
+
3587
+
3588
+ def _force_utf8_console() -> None:
3589
+ """Reconfigure Windows console + Python streams to use UTF-8.
3590
+
3591
+ Source files are now clean UTF-8, but Windows consoles default to cp1252
3592
+ and downgrade glyphs (●, ·, →, ⚡, ✓, ⏵, etc.) at render time. Without
3593
+ this helper the status bar would re-mojibake even after the source-level
3594
+ repair. On non-Windows or when stdout/stderr are not real TTYs this is a
3595
+ no-op.
3596
+ """
3597
+ try:
3598
+ if sys.platform == "win32":
3599
+ import ctypes
3600
+ kernel32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
3601
+ # CP_UTF8 = 65001
3602
+ kernel32.SetConsoleOutputCP(65001)
3603
+ kernel32.SetConsoleCP(65001)
3604
+ # Best-effort: enable VT processing so Rich can paint colors/unicode
3605
+ mode = ctypes.c_uint32()
3606
+ if kernel32.GetConsoleMode(kernel32.GetStdHandle(-11), ctypes.byref(mode)):
3607
+ ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
3608
+ if not (mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING):
3609
+ kernel32.SetConsoleMode(
3610
+ kernel32.GetStdHandle(-11),
3611
+ mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING,
3612
+ )
3613
+ except Exception:
3614
+ # Never let codec setup crash startup; log and fall through.
3615
+ try:
3616
+ logging.getLogger(__name__).debug("utf8 console setup failed", exc_info=True)
3617
+ except Exception:
3618
+ pass
3619
+ # Python stream encoding (works on every platform).
3620
+ for stream_name in ("stdout", "stderr"):
3621
+ stream = getattr(sys, stream_name, None)
3622
+ if stream is None:
3623
+ continue
3624
+ try:
3625
+ stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
3626
+ except (AttributeError, OSError):
3627
+ pass
3628
+
3629
+
3630
+ def main() -> None:
3631
+ _force_utf8_console()
3632
+ if Path(sys.argv[0]).name.lower().startswith("ollama-cli"):
3633
+ console.print("[warning]`ollama-cli` is deprecated; use `algo-cli` instead.[/]")
3634
+ load_runtime_env(override=True)
3635
+ args = parse_args()
3636
+ if args.version:
3637
+ from .version_manifest import build_manifest, format_version_string
3638
+ console.print(format_version_string(build_manifest()))
3639
+ return
3640
+ if args.oneshot:
3641
+ _exit = _run_oneshot_entry(args)
3642
+ sys.exit(_exit)
3643
+ # Migration to new default location (~/.algo_cli) must happen before any
3644
+ # first-run scaffolding writes into CONFIG_DIR; otherwise the migration
3645
+ # helper will correctly refuse to overwrite the newly-created directory and
3646
+ # legacy memories/config are stranded in ~/.ollama_cli.
3647
+ already_migrated = (CONFIG_DIR / ".migrated_from_legacy").exists()
3648
+ migrated = False
3649
+ if has_legacy_data() and not already_migrated:
3650
+ migrated = perform_legacy_migration()
3651
+
3652
+ sidecar = migrate_legacy_sidecar_files()
3653
+ if sidecar:
3654
+ show_info(
3655
+ f"Imported legacy config file(s) into {CONFIG_DIR}: {', '.join(sidecar)}"
3656
+ )
3657
+
3658
+ cfg = Config.load()
3659
+ harness.configure_context_sources(
3660
+ external=cfg.external_harness_sources_enabled,
3661
+ index_compute_lab=cfg.index_compute_lab_auto_inject,
3662
+ )
3663
+ if args.model:
3664
+ cfg.model = args.model
3665
+ if args.host:
3666
+ cfg.host = args.host
3667
+ cfg.cloud = False
3668
+ if args.cloud:
3669
+ cfg.cloud = True
3670
+ if args.cwd:
3671
+ cfg.cwd = str(Path(args.cwd).expanduser().resolve())
3672
+ if (args.prompt or "").strip().lower() == "doctor" and not args.oneshot:
3673
+ from .action_registry import build_doctor_report, render_doctor
3674
+
3675
+ report = build_doctor_report(cfg)
3676
+ console.print(render_doctor(report))
3677
+ if report.overall_status == "blocked":
3678
+ raise SystemExit(1)
3679
+ return
3680
+
3681
+ created = identity.scaffold_if_needed()
3682
+ if created:
3683
+ show_info(f"Scaffolded identity in {identity.IDENTITY_DIR} ({len(created)} files). Edit USER.md to teach the CLI about yourself.")
3684
+ skills.ensure_dirs()
3685
+ from . import index_compute_lab
3686
+
3687
+ if index_compute_lab.ensure_harness_roots_file():
3688
+ show_info(
3689
+ "Removed legacy index-compute-lab entry from harness_roots.json "
3690
+ f"(lab is indexed dynamically from {index_compute_lab.resolve_lab_root()}). "
3691
+ "Run /harness refresh to drop duplicate atom records."
3692
+ )
3693
+
3694
+ # --- Subcommand: plugin list ---
3695
+ _prompt_lower = (args.prompt or "").strip().lower()
3696
+ if _prompt_lower.startswith("plugin ") and not args.oneshot:
3697
+ from .plugins import discover_plugins, plugin_status
3698
+ sub = _prompt_lower.split(maxsplit=1)[1].strip() if " " in _prompt_lower else ""
3699
+ if sub in ("", "list"):
3700
+ discovered = discover_plugins()
3701
+ if not discovered:
3702
+ console.print("[dim]No plugins discovered in ~/.algo_cli/plugins/[/dim]")
3703
+ else:
3704
+ table = Table(title="Discovered Plugins", box=box.ROUNDED)
3705
+ table.add_column("Name", style="cyan")
3706
+ table.add_column("Version", style="green")
3707
+ table.add_column("Description")
3708
+ table.add_column("Enabled", style="yellow")
3709
+ for manifest in sorted(discovered, key=lambda item: item.name.lower()):
3710
+ table.add_row(
3711
+ manifest.name,
3712
+ manifest.version,
3713
+ manifest.description,
3714
+ "yes" if manifest.enabled else "no",
3715
+ )
3716
+ console.print(table)
3717
+ return
3718
+ elif sub == "status":
3719
+ statuses = plugin_status()
3720
+ if not statuses:
3721
+ console.print("[dim]No plugins loaded.[/dim]")
3722
+ else:
3723
+ table = Table(title="Plugin Status", box=box.ROUNDED)
3724
+ table.add_column("Name", style="cyan")
3725
+ table.add_column("Loaded", style="green")
3726
+ table.add_column("Error", style="red")
3727
+ for s in statuses:
3728
+ table.add_row(
3729
+ s.get("name", "?"),
3730
+ "yes" if s.get("loaded") else "no",
3731
+ s.get("error", "") or "",
3732
+ )
3733
+ console.print(table)
3734
+ return
3735
+ else:
3736
+ console.print("[yellow]Usage: algo-cli plugin [list|status][/yellow]")
3737
+ return
3738
+
3739
+ # --- Subcommand: credential list ---
3740
+ if _prompt_lower.startswith("credential ") and not args.oneshot:
3741
+ from .credential_helpers import list_helpers, get_helper
3742
+ sub = _prompt_lower.split(maxsplit=1)[1].strip() if " " in _prompt_lower else ""
3743
+ if sub in ("", "list"):
3744
+ helpers = sorted(list_helpers())
3745
+ if not helpers:
3746
+ console.print("[dim]No credential helpers registered.[/dim]")
3747
+ else:
3748
+ table = Table(title="Credential Helpers", box=box.ROUNDED)
3749
+ table.add_column("Name", style="cyan")
3750
+ table.add_column("Description")
3751
+ for name in helpers:
3752
+ h = get_helper(name)
3753
+ table.add_row(name, h.description if h else "?")
3754
+ console.print(table)
3755
+ return
3756
+ elif sub.startswith("get "):
3757
+ raw_prompt = args.prompt.strip()
3758
+ parts = raw_prompt.split(maxsplit=3)
3759
+ if len(parts) != 4:
3760
+ console.print("[yellow]Usage: algo-cli credential get <helper> <key>[/yellow]")
3761
+ return
3762
+ _command, _verb, helper, key = parts
3763
+ from .credential_helpers import get_credential
3764
+ val = get_credential(helper, key)
3765
+ if val is None:
3766
+ console.print(f"[dim]No credential found for '{key}' in helper '{helper}'[/dim]")
3767
+ else:
3768
+ console.print(f"[green]{helper}/{key}[/green]: configured (value redacted)")
3769
+ return
3770
+ else:
3771
+ console.print("[yellow]Usage: algo-cli credential [list|get <helper> <key>][/yellow]")
3772
+ return
3773
+
3774
+ # --- Subcommand: url-scheme <url> ---
3775
+ if _prompt_lower.startswith("url-scheme ") and not args.oneshot:
3776
+ from .url_scheme import handle_deep_link, format_help
3777
+ url = args.prompt.strip().split(maxsplit=1)[1] if " " in args.prompt.strip() else ""
3778
+ if not url or url == "help":
3779
+ console.print(format_help())
3780
+ return
3781
+ result = handle_deep_link(url)
3782
+ if not result.get("valid"):
3783
+ console.print(f"[red]Invalid URL: {result.get('error', 'unknown error')}[/red]")
3784
+ return
3785
+ console.print(f"[green]Action:[/green] {result.get('action', '?')}")
3786
+ if result.get('target'):
3787
+ console.print(f"[green]Target:[/green] {result['target']}")
3788
+ if result.get('query'):
3789
+ console.print(f"[green]Query:[/green] {result['query']}")
3790
+ return
3791
+ try:
3792
+ cfg.theme = set_theme(cfg.theme)
3793
+ except ValueError:
3794
+ cfg.theme = current_theme_name()
3795
+ show_banner()
3796
+ show_info("Ask naturally. Type / for commands or /status for runtime details.")
3797
+
3798
+ # Report migration after banner/theme initialization so the message is visible.
3799
+ if migrated:
3800
+ show_info(
3801
+ f"Data migrated from legacy location {LEGACY_CONFIG_DIR} → new default {CONFIG_DIR}."
3802
+ )
3803
+ show_info(
3804
+ f"Full backup preserved at {get_legacy_backup_dir()} (originals untouched)."
3805
+ )
3806
+ show_info(
3807
+ "You are now using the new default config directory. "
3808
+ "Legacy OLLAMA_CLI_* environment variables and the `ollama-cli` command "
3809
+ "remain available as compatibility aliases."
3810
+ )
3811
+
3812
+ # Deprecation notice when old OLLAMA_CLI_* vars are still in use
3813
+ used_old = [k for k in os.environ if k.startswith(OLD_ENV_PREFIX) and not k.startswith(NEW_ENV_PREFIX)]
3814
+ if used_old:
3815
+ show_info(
3816
+ "Compatibility notice: legacy OLLAMA_CLI_* environment variables are active. "
3817
+ "Use ALGO_CLI_* for new configuration."
3818
+ )
3819
+
3820
+ onboard_if_needed(cfg)
3821
+ cfg.save()
3822
+
3823
+ if cfg.cloud:
3824
+ start_supplemental_gateway(cfg)
3825
+ else:
3826
+ start_ollama_server(cfg)
3827
+ client = create_client(cfg)
3828
+
3829
+ try:
3830
+ from prompt_toolkit import PromptSession
3831
+ slash_completer = SlashCommandCompleter(SLASH_COMMANDS)
3832
+ palette = theme_colors(cfg.theme)
3833
+ session: PromptSession[str] | None = PromptSession(
3834
+ history=SafeFileHistory(str(PROMPT_HISTORY_FILE)),
3835
+ completer=slash_completer,
3836
+ complete_while_typing=True,
3837
+ complete_style=CompleteStyle.MULTI_COLUMN,
3838
+ style=build_prompt_style(palette),
3839
+ bottom_toolbar=lambda: build_status_toolbar(cfg),
3840
+ rprompt=lambda: build_status_rprompt(cfg),
3841
+ reserve_space_for_menu=8,
3842
+ )
3843
+ except Exception:
3844
+ session = None
3845
+
3846
+ while True:
3847
+ try:
3848
+ refresh_runtime_status(cfg, client)
3849
+ user_input = (
3850
+ session.prompt(" ❯ ", complete_style=CompleteStyle.MULTI_COLUMN)
3851
+ if session
3852
+ else input(" ❯ ")
3853
+ ).strip()
3854
+ except (EOFError, KeyboardInterrupt):
3855
+ console.print("\n[dim]Bye.[/]")
3856
+ break
3857
+ if not user_input:
3858
+ continue
3859
+ user_input = sanitize_prompt_text(user_input)
3860
+ if user_input.startswith("/"):
3861
+ try:
3862
+ handled, client = handle_command(user_input, cfg, client, session)
3863
+ except EOFError:
3864
+ console.print("\n[dim]Bye.[/]")
3865
+ break
3866
+ except Exception as exc:
3867
+ show_error(str(exc))
3868
+ refresh_runtime_status(cfg, client)
3869
+ invalidate_prompt_toolbar(session)
3870
+ continue
3871
+ if handled:
3872
+ continue
3873
+ show_error(unknown_command_message(user_input))
3874
+ continue
3875
+ try:
3876
+ if cfg.cloud:
3877
+ start_supplemental_gateway(cfg)
3878
+ elif not start_ollama_server(cfg):
3879
+ continue
3880
+ maybe_show_route_suggestion(user_input)
3881
+ agent_loop(client, cfg, user_input)
3882
+ refresh_runtime_status(cfg, client)
3883
+ invalidate_prompt_toolbar(session)
3884
+ if session is None:
3885
+ used, total, _remaining, _runtime_cap, _native = context_status(cfg, client=client)
3886
+ show_status_footer(
3887
+ cfg.model,
3888
+ used,
3889
+ total,
3890
+ summary_active=bool(cfg.session_summary.strip()),
3891
+ )
3892
+ except KeyboardInterrupt:
3893
+ console.print("\n[yellow]Generation interrupted.[/]")
3894
+ refresh_runtime_status(cfg, client)
3895
+ invalidate_prompt_toolbar(session)
3896
+ except Exception as exc:
3897
+ show_error(str(exc))
3898
+ refresh_runtime_status(cfg, client)
3899
+ invalidate_prompt_toolbar(session)
3900
+
3901
+
3902
+ if __name__ == "__main__":
3903
+ main()