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/tools.py ADDED
@@ -0,0 +1,3056 @@
1
+ """Tools exposed to Ollama tool calling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import logging
7
+ import math
8
+ import os
9
+ import json
10
+ import fnmatch
11
+ import re
12
+ import shutil
13
+ import subprocess
14
+ import tempfile
15
+ import time
16
+ from pathlib import Path
17
+ import inspect
18
+ from typing import Any
19
+ from urllib.error import URLError
20
+ from urllib.request import Request, urlopen
21
+
22
+ from . import harness
23
+ from . import identity
24
+ from . import index_compute_lab as _index_compute_lab
25
+ from .config import CONFIG_DIR, Config, load_runtime_env, _atomic_write_text
26
+ from ollama import Client
27
+
28
+ from .chat_protocol import get_attr
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ MAX_READ_CHARS = 50_000
34
+ MAX_PDF_PAGES = 24
35
+ MAX_RENDER_PDF_PAGES = 6
36
+ MAX_TOOL_RESULT = 20_000
37
+ SESSION_COMMAND_OUTPUT_LIMIT = MAX_TOOL_RESULT
38
+ SEARCH_FALLBACK_SKIP_DIRS = {
39
+ ".git", "node_modules", ".venv", "venv", "dist", "build",
40
+ "__pycache__", ".next", "target", ".mypy_cache", ".pytest_cache",
41
+ }
42
+ SEARCH_FALLBACK_MAX_FILE_BYTES = 2_000_000
43
+ SEARCH_FALLBACK_MAX_FILES = 5_000
44
+ DEFAULT_GATEWAY_URL = (
45
+ os.environ.get("ALGO_CLI_GATEWAY_URL")
46
+ or os.environ.get("OLLAMA_CLI_GATEWAY_URL")
47
+ or "http://127.0.0.1:8765"
48
+ )
49
+ DEFAULT_OLLAMA_HOST = "http://localhost:11434"
50
+ DENY_COMMAND_RE = re.compile(
51
+ r"\b(rm|del|erase|rd|rmdir|format|diskpart|shutdown|restart-computer|stop-computer|"
52
+ r"git\s+reset|git\s+checkout|Remove-Item)\b",
53
+ re.IGNORECASE,
54
+ )
55
+ REQUIRED_CHANGE_SHELL_MUTATION_RE = re.compile(
56
+ r"\bgit\s+(?:add|apply|commit|checkout|restore|reset|clean|mv|rm|switch|merge|rebase|cherry-pick)\b|"
57
+ r"\b(?:remove-item|move-item|copy-item|rename-item|new-item|set-content|add-content|clear-content|"
58
+ r"out-file|export-csv|export-clixml|start-transcript)\b|"
59
+ r"(?:^|[\s;&|\"']+)(?:rm|mv|cp|del|erase|rd|rmdir|touch|mkdir|md|ni|ri|mi|cpi)\b|"
60
+ r"\b(?:sed\s+-i|perl\s+-pi|truncate\s+-s)\b|"
61
+ r"\brobocopy\b[^\n]*\s/(?:mir|purge)\b|"
62
+ r"(?:>{1,2}(?!&)|(?:\|\s*)tee\b)|"
63
+ r"\bpython(?:3)?\b[^\n]*\s-c\s+[^\n]*(?:write_text\s*\(|write_bytes\s*\(|\.write\s*\(|"
64
+ r"\.unlink\s*\(|\.rename\s*\(|\.replace\s*\(|\.mkdir\s*\(|os\.(?:remove|unlink|rename|replace|mkdir|makedirs)\s*\(|"
65
+ r"shutil\.(?:copy|copy2|copyfile|move|rmtree)\s*\()",
66
+ re.IGNORECASE,
67
+ )
68
+ PYTHON_OPEN_WRITE_RE = re.compile(
69
+ r"\bpython(?:3)?\b[^\n]*\s-c\s+[^\n]*\bopen\s*\([^)]*(?:,\s*['\"]"
70
+ r"(?:[wax][^'\"]*|[^'\"]*\+[^'\"]*)['\"]|\bmode\s*=\s*['\"]"
71
+ r"(?:[wax][^'\"]*|[^'\"]*\+[^'\"]*)['\"])",
72
+ re.IGNORECASE,
73
+ )
74
+ NULL_REDIRECTION_RE = re.compile(r"\b\d?\s*>{1,2}\s*(?:\$null\b|nul\b|/dev/null\b)", re.IGNORECASE)
75
+
76
+
77
+ def shell_mutates_workspace(command: str) -> bool:
78
+ """Return whether a shell command appears to alter files or Git state."""
79
+
80
+ without_null_redirection = NULL_REDIRECTION_RE.sub("", command or "")
81
+ return bool(
82
+ REQUIRED_CHANGE_SHELL_MUTATION_RE.search(without_null_redirection)
83
+ or PYTHON_OPEN_WRITE_RE.search(without_null_redirection)
84
+ )
85
+
86
+
87
+ def shell_is_dangerous(command: str) -> bool:
88
+ """Return whether safe mode must block the command.
89
+
90
+ The destructive deny list covers host-level actions such as shutdown and
91
+ disk formatting that are not necessarily workspace mutations.
92
+ """
93
+
94
+ return bool(DENY_COMMAND_RE.search(command or "")) or shell_mutates_workspace(command)
95
+
96
+
97
+ def _resolve(path: str, cwd: str | None = None) -> Path:
98
+ base = Path(cwd or os.getcwd()).expanduser()
99
+ p = Path(path).expanduser()
100
+ if not p.is_absolute():
101
+ p = base / p
102
+ return p.resolve()
103
+
104
+
105
+ def _cap(text: str, limit: int = MAX_TOOL_RESULT) -> str:
106
+ return text[:limit] + ("\n...[truncated]" if len(text) > limit else "")
107
+
108
+
109
+ def _bounded_int(value: Any, default: int, minimum: int, maximum: int) -> int:
110
+ try:
111
+ parsed = int(value)
112
+ except (TypeError, ValueError):
113
+ parsed = default
114
+ return max(minimum, min(parsed, maximum))
115
+
116
+
117
+ def _missing_file_matches(path: Path, cwd: str | None, *, limit: int = 3) -> list[Path]:
118
+ """Find bounded same-basename recovery candidates inside the active cwd."""
119
+
120
+ if not path.name:
121
+ return []
122
+ root = Path(cwd or os.getcwd()).expanduser().resolve()
123
+ matches: list[Path] = []
124
+ scanned = 0
125
+ skipped_dirs = {".git", ".venv", "node_modules", "__pycache__"}
126
+ try:
127
+ for dirpath, dirnames, filenames in os.walk(root):
128
+ scanned += 1
129
+ dirnames[:] = [name for name in dirnames if name not in skipped_dirs]
130
+ if path.name in filenames:
131
+ candidate = (Path(dirpath) / path.name).resolve()
132
+ if candidate != path:
133
+ matches.append(candidate)
134
+ if len(matches) >= limit:
135
+ break
136
+ if scanned >= 2_000:
137
+ break
138
+ except OSError:
139
+ return []
140
+ return matches
141
+
142
+
143
+ def unpack_embed_response(
144
+ response: Any,
145
+ model: str,
146
+ input_text: str,
147
+ *,
148
+ truncate: bool | None = None,
149
+ dimensions: int | None = None,
150
+ ) -> dict[str, Any]:
151
+ """Normalize Ollama embed API responses (dict or object) into a JSON-serializable payload."""
152
+ embeddings = get_attr(response, "embeddings", []) or []
153
+ first = embeddings[0] if embeddings else []
154
+ payload: dict[str, Any] = {
155
+ "model": get_attr(response, "model", model),
156
+ "input_chars": len(input_text),
157
+ "vector_count": len(embeddings),
158
+ "vector_length": len(first),
159
+ "preview": [round(float(value), 6) for value in first[:8]],
160
+ "total_duration": get_attr(response, "total_duration", None),
161
+ "load_duration": get_attr(response, "load_duration", None),
162
+ "prompt_eval_count": get_attr(response, "prompt_eval_count", None),
163
+ }
164
+ if truncate is not None:
165
+ payload["truncate"] = truncate
166
+ if dimensions is not None:
167
+ payload["dimensions"] = dimensions
168
+ return payload
169
+
170
+
171
+ def active_ollama_client(*, cloud: bool = False) -> Client:
172
+ load_runtime_env(override=True)
173
+ if cloud:
174
+ api_key = os.environ.get("OLLAMA_API_KEY", "")
175
+ headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
176
+ return Client(host="https://ollama.com", headers=headers)
177
+ return Client(host=os.environ.get("OLLAMA_HOST", DEFAULT_OLLAMA_HOST))
178
+
179
+
180
+ def _ollama_cloud_web_preflight(action: str) -> str | None:
181
+ load_runtime_env(override=True)
182
+ if os.environ.get("OLLAMA_API_KEY", "").strip():
183
+ return None
184
+ return (
185
+ f"Error {action}: OLLAMA_API_KEY is not set. "
186
+ "Set ALGO_CLI_ENV_FILE or ~/.algo_cli/env with OLLAMA_API_KEY, then run /doctor to verify "
187
+ "Ollama Cloud web access."
188
+ )
189
+
190
+
191
+ def read_file(
192
+ path: str,
193
+ cwd: str | None = None,
194
+ max_chars: int = MAX_READ_CHARS,
195
+ start_line: int = 1,
196
+ offset: int | None = None,
197
+ ) -> str:
198
+ """Read a text file.
199
+
200
+ Args:
201
+ path: File path to read.
202
+ cwd: Optional working directory for relative paths.
203
+ max_chars: Maximum characters to return.
204
+ start_line: One-based line number to begin reading from.
205
+ offset: Compatibility alias for start_line.
206
+ """
207
+ p = _resolve(path, cwd)
208
+ if not p.exists():
209
+ matches = _missing_file_matches(p, cwd)
210
+ if not matches:
211
+ return f"Error: file not found: {p}"
212
+ suggestions = "\n".join(f"- {candidate}" for candidate in matches)
213
+ return (
214
+ f"Error: file not found: {p}\n"
215
+ f"Same-name file(s) found inside the working directory:\n{suggestions}\n"
216
+ "Retry read_file with the intended exact path."
217
+ )
218
+ if p.is_dir():
219
+ return f"Error: {p} is a directory. Use list_directory."
220
+ try:
221
+ max_chars = _bounded_int(max_chars, MAX_READ_CHARS, 1, MAX_READ_CHARS)
222
+ text = p.read_text(encoding="utf-8", errors="replace")
223
+ requested_line = offset if offset is not None else start_line
224
+ line_number = max(1, int(requested_line))
225
+ if line_number > 1:
226
+ text = "".join(text.splitlines(keepends=True)[line_number - 1:])
227
+ return text[:max_chars]
228
+ except Exception as exc:
229
+ return f"Error reading {p}: {exc}"
230
+
231
+
232
+ def read_pdf(
233
+ path: str,
234
+ cwd: str | None = None,
235
+ max_chars: int = MAX_READ_CHARS,
236
+ max_pages: int = MAX_PDF_PAGES,
237
+ ) -> str:
238
+ """Extract text from a PDF using local Python PDF libraries.
239
+
240
+ Args:
241
+ path: PDF file path to read.
242
+ cwd: Optional working directory for relative paths.
243
+ max_chars: Maximum characters to return.
244
+ max_pages: Maximum pages to inspect.
245
+ """
246
+ p = _resolve(path, cwd)
247
+ if not p.exists():
248
+ return f"Error: PDF not found: {p}"
249
+ if p.is_dir():
250
+ return f"Error: {p} is a directory, not a PDF."
251
+ if p.suffix.lower() != ".pdf":
252
+ return f"Error: {p} does not look like a PDF."
253
+ max_chars = _bounded_int(max_chars, MAX_READ_CHARS, 1, MAX_READ_CHARS)
254
+ max_pages = _bounded_int(max_pages, MAX_PDF_PAGES, 1, MAX_PDF_PAGES)
255
+
256
+ pages: list[str] = []
257
+ engine = ""
258
+ page_count = 0
259
+ try:
260
+ import fitz # type: ignore[import-not-found]
261
+
262
+ engine = "PyMuPDF"
263
+ with fitz.open(p) as doc:
264
+ page_count = len(doc)
265
+ for index, page in enumerate(doc):
266
+ if index >= max_pages:
267
+ break
268
+ text = page.get_text("text").strip()
269
+ pages.append(f"[page {index + 1}]\n{text}" if text else f"[page {index + 1}]\n")
270
+ except Exception:
271
+ try:
272
+ from PyPDF2 import PdfReader # type: ignore[import-not-found]
273
+
274
+ engine = "PyPDF2"
275
+ reader = PdfReader(str(p))
276
+ page_count = len(reader.pages)
277
+ for index, page in enumerate(reader.pages[:max_pages]):
278
+ text = (page.extract_text() or "").strip()
279
+ pages.append(f"[page {index + 1}]\n{text}" if text else f"[page {index + 1}]\n")
280
+ except Exception as exc:
281
+ return f"Error extracting PDF text from {p}: {exc}"
282
+
283
+ combined = "\n\n".join(pages).strip()
284
+ if not combined or all(not chunk.split("\n", 1)[-1].strip() for chunk in pages):
285
+ return (
286
+ f"PDF extraction completed with {engine}, but no text layer was found in {p}. "
287
+ "This PDF may be scanned or image-only. Use render_pdf_pages next, then pass the returned PNG path(s) to vision_describe or another OCR-capable workflow."
288
+ )
289
+ suffix = ""
290
+ if page_count > max_pages:
291
+ suffix = f"\n\n...[limited to first {max_pages} of {page_count} pages]"
292
+ header = f"PDF: {p}\nEngine: {engine}\nPages read: {min(page_count, max_pages)} of {page_count}\n\n"
293
+ return _cap((header + combined + suffix)[:max_chars])
294
+
295
+
296
+ def render_pdf_pages(
297
+ path: str,
298
+ cwd: str | None = None,
299
+ start_page: int = 1,
300
+ max_pages: int = MAX_RENDER_PDF_PAGES,
301
+ scale: float = 1.75,
302
+ ) -> str:
303
+ """Render PDF pages to PNG images for downstream OCR or visual inspection.
304
+
305
+ Args:
306
+ path: PDF file path to render.
307
+ cwd: Optional working directory for relative paths.
308
+ start_page: 1-based page number to start from.
309
+ max_pages: Maximum number of pages to render.
310
+ scale: Render scale multiplier; higher values improve OCR at larger image sizes.
311
+ """
312
+ p = _resolve(path, cwd)
313
+ if not p.exists():
314
+ return f"Error: PDF not found: {p}"
315
+ if p.is_dir():
316
+ return f"Error: {p} is a directory, not a PDF."
317
+ if p.suffix.lower() != ".pdf":
318
+ return f"Error: {p} does not look like a PDF."
319
+ if start_page < 1:
320
+ return "Error: start_page must be 1 or greater."
321
+ if max_pages < 1:
322
+ return "Error: max_pages must be 1 or greater."
323
+ try:
324
+ import fitz # type: ignore[import-not-found]
325
+ except Exception as exc:
326
+ return f"Error: PDF rendering requires PyMuPDF/fitz, but it could not be imported: {exc}"
327
+
328
+ output_dir = Path(tempfile.gettempdir()) / "ollama_cli_pdf_pages"
329
+ output_dir.mkdir(parents=True, exist_ok=True)
330
+ rendered: list[str] = []
331
+ try:
332
+ with fitz.open(p) as doc:
333
+ first_index = start_page - 1
334
+ if first_index >= len(doc):
335
+ return f"Error: start_page {start_page} exceeds PDF page count {len(doc)}."
336
+ last_index = min(len(doc), first_index + max_pages)
337
+ safe_stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", p.stem).strip("_") or "pdf"
338
+ matrix = fitz.Matrix(max(0.5, float(scale)), max(0.5, float(scale)))
339
+ for index in range(first_index, last_index):
340
+ page = doc.load_page(index)
341
+ pix = page.get_pixmap(matrix=matrix, alpha=False)
342
+ out = output_dir / f"{safe_stem}_page_{index + 1}.png"
343
+ pix.save(out)
344
+ rendered.append(str(out))
345
+ return json.dumps(
346
+ {
347
+ "pdf": str(p),
348
+ "page_count": len(doc),
349
+ "rendered_pages": len(rendered),
350
+ "paths": rendered,
351
+ "next_step": "Pass one returned PNG path to vision_describe or an OCR workflow.",
352
+ },
353
+ indent=2,
354
+ )
355
+ except Exception as exc:
356
+ return f"Error rendering PDF pages from {p}: {exc}"
357
+
358
+
359
+ def write_file(path: str, content: str, cwd: str | None = None, overwrite: bool = False) -> str:
360
+ """Write text to a file. Existing files require overwrite=true.
361
+
362
+ Args:
363
+ path: File path to write.
364
+ content: Content to write.
365
+ cwd: Optional working directory for relative paths.
366
+ overwrite: Whether to overwrite an existing file.
367
+ """
368
+ p = _resolve(path, cwd)
369
+ if p.exists() and not overwrite:
370
+ return f"Error: {p} already exists. Re-run with overwrite=true if intended."
371
+ try:
372
+ p.parent.mkdir(parents=True, exist_ok=True)
373
+ _atomic_write_text(p, content)
374
+ return f"Wrote {len(content)} characters to {p}"
375
+ except Exception as exc:
376
+ return f"Error writing {p}: {exc}"
377
+
378
+
379
+ def edit_file(
380
+ path: str,
381
+ old_string: str,
382
+ new_string: str,
383
+ cwd: str | None = None,
384
+ replace_all: bool = False,
385
+ ) -> str:
386
+ """Make a precise, surgical edit to a text file using a find/replace match.
387
+
388
+ This is the preferred tool for modifying existing files. It is faster, safer,
389
+ and uses fewer tokens than reading the whole file and rewriting it with
390
+ write_file. The edit is applied atomically (write-to-tmp + os.replace) and
391
+ the tool reports the affected line numbers so callers can verify the change.
392
+
393
+ Args:
394
+ path: File path to edit.
395
+ old_string: Exact text to find. Must match the file contents byte-for-byte
396
+ (after decoding as UTF-8). Include enough surrounding context (3-5
397
+ lines) to make the match unique. Whitespace, indentation, and line
398
+ endings matter.
399
+ new_string: The replacement text. Use an empty string to delete the
400
+ matched region. The new_string is inserted verbatim; preserve
401
+ trailing newlines and indentation.
402
+ cwd: Optional working directory for relative paths.
403
+ replace_all: If True, replace every non-overlapping occurrence. If False
404
+ (default), the call FAILS when more than one match is found so you
405
+ do not accidentally rewrite repeated patterns. Set replace_all=True
406
+ only when the match is genuinely meant to apply everywhere.
407
+
408
+ Returns a human-readable summary with the affected line range, or an error
409
+ explaining why the edit could not be applied (file missing, no match, or
410
+ ambiguous match). The file is NOT modified when the call returns an error.
411
+
412
+ Smart-edit guidelines:
413
+ - First call read_file to confirm the exact text you are about to change.
414
+ - Prefer the smallest, most unique snippet that still locates the right
415
+ place. A full function definition is usually too much; 2-5 lines with
416
+ a distinctive local anchor is ideal.
417
+ - If the match is ambiguous, tighten old_string (add more context) or set
418
+ replace_all=True only when the rewrite is intentionally global.
419
+ - If the file is large or has many similar blocks, call search_files with
420
+ a unique anchor regex to find line numbers first, then construct a
421
+ narrow old_string that includes just enough context to be unique.
422
+ """
423
+ if not old_string:
424
+ return "Error: edit_file requires a non-empty old_string. Use write_file to create a new file."
425
+
426
+ p = _resolve(path, cwd)
427
+ if not p.exists():
428
+ return f"Error: file not found: {p}"
429
+ if p.is_dir():
430
+ return f"Error: {p} is a directory. edit_file only works on text files."
431
+
432
+ try:
433
+ original = p.read_text(encoding="utf-8", errors="replace")
434
+ except Exception as exc:
435
+ return f"Error reading {p}: {exc}"
436
+
437
+ occurrences = original.count(old_string)
438
+ if occurrences == 0:
439
+ # Give the model a helpful pointer: show the closest matching line if any
440
+ first_line = old_string.splitlines()[0] if old_string.splitlines() else old_string
441
+ snippet = first_line[:80] + ("..." if len(first_line) > 80 else "")
442
+ return (
443
+ f"Error: old_string not found in {p}. "
444
+ f"No match for the first line: {snippet!r}. "
445
+ "Re-read the file to confirm exact whitespace and indentation, then retry."
446
+ )
447
+ if occurrences > 1 and not replace_all:
448
+ line_numbers: list[int] = []
449
+ start = 0
450
+ while True:
451
+ idx = original.find(old_string, start)
452
+ if idx < 0:
453
+ break
454
+ line_numbers.append(original.count("\n", 0, idx) + 1)
455
+ start = idx + max(1, len(old_string))
456
+ return (
457
+ f"Error: old_string matched {occurrences} locations in {p} "
458
+ f"(first occurrences near lines {line_numbers[:8]}). "
459
+ "Tighten old_string with more surrounding context, or pass replace_all=True "
460
+ "if the rewrite is intentionally global."
461
+ )
462
+
463
+ if replace_all and occurrences > 1:
464
+ new_content = original.replace(old_string, new_string)
465
+ replaced = occurrences
466
+ else:
467
+ new_content = original.replace(old_string, new_string, 1)
468
+ replaced = 1
469
+
470
+ # Compute line numbers for the edit anchor (start line, end line)
471
+ try:
472
+ start_index = original.index(old_string)
473
+ except ValueError:
474
+ start_index = 0
475
+ prefix = original[:start_index]
476
+ start_line = prefix.count("\n") + 1
477
+ end_line = start_line + old_string.count("\n")
478
+ span = f"lines {start_line}-{end_line}"
479
+
480
+ if new_content == original:
481
+ return (
482
+ f"Error: old_string and new_string are identical at {span} in {p}. "
483
+ "No change would be made. Adjust new_string to actually differ."
484
+ )
485
+
486
+ try:
487
+ _atomic_write_text(p, new_content)
488
+ except Exception as exc:
489
+ return f"Error writing {p}: {exc}"
490
+
491
+ delta = len(new_string) - len(old_string)
492
+ return (
493
+ f"Edited {p}: replaced {replaced} occurrence(s) at {span} "
494
+ f"({len(old_string)} -> {len(new_string)} chars, delta {delta:+d})."
495
+ )
496
+
497
+
498
+ def find_unique_anchor(
499
+ path: str,
500
+ needle: str,
501
+ cwd: str | None = None,
502
+ *,
503
+ context_before: int = 2,
504
+ context_after: int = 2,
505
+ max_results: int = 5,
506
+ ) -> str:
507
+ """Locate occurrences of ``needle`` in a file and return enough surrounding
508
+ context that an ``edit_file`` call with that context as ``old_string`` would
509
+ match a unique location.
510
+
511
+ Use this when ``edit_file`` reports an ambiguous match (multiple
512
+ locations) and you need to disambiguate by including more context, or
513
+ when you are about to call ``edit_file`` and want a guaranteed-unique
514
+ snippet on the first try.
515
+
516
+ The function reports every match with its line number and a few
517
+ surrounding lines. Pass those context lines back as ``old_string`` to
518
+ ``edit_file`` and the match will (with high probability) be unique.
519
+
520
+ Args:
521
+ path: File path to search.
522
+ needle: The text to find. Can be multi-line. Whitespace matters.
523
+ cwd: Optional working directory for relative paths.
524
+ context_before: Lines of context to include BEFORE each match.
525
+ context_after: Lines of context to include AFTER each match.
526
+ max_results: Stop after this many matches. Default 5 is enough to
527
+ decide whether a stricter old_string is needed.
528
+ """
529
+ if not needle:
530
+ return "Error: needle is empty."
531
+
532
+ p = _resolve(path, cwd)
533
+ if not p.exists():
534
+ return f"Error: file not found: {p}"
535
+ if p.is_dir():
536
+ return f"Error: {p} is a directory."
537
+ try:
538
+ text = p.read_text(encoding="utf-8", errors="replace")
539
+ except Exception as exc:
540
+ return f"Error reading {p}: {exc}"
541
+
542
+ lines = text.splitlines(keepends=True)
543
+ if not needle.splitlines():
544
+ return "Error: needle is empty."
545
+
546
+ matches: list[str] = []
547
+ for i in range(len(lines) - max(1, len(needle.splitlines())) + 1):
548
+ n = len(needle.splitlines())
549
+ block = "".join(lines[i : i + n])
550
+ block_for_compare = block
551
+ if not needle.endswith("\n") and block_for_compare.endswith("\n"):
552
+ block_for_compare = block_for_compare[:-1]
553
+ if block_for_compare == needle:
554
+ start_line = i + 1
555
+ ctx_start = max(0, i - context_before)
556
+ ctx_end = min(len(lines), i + n + context_after)
557
+ ctx_block = "".join(lines[ctx_start:ctx_end])
558
+ matches.append(
559
+ f"--- match at line {start_line} (with {context_before}/{context_after} lines context) ---\n"
560
+ f"{ctx_block.rstrip()}\n"
561
+ f"--- end match ---"
562
+ )
563
+ if len(matches) >= max_results:
564
+ break
565
+
566
+ if not matches:
567
+ # Suggest: the first line of needle as a separate search hint
568
+ first = needle.splitlines()[0].rstrip("\n")
569
+ return (
570
+ f"No match for needle in {p}.\n"
571
+ f"First line of needle was: {first!r}\n"
572
+ "Re-read the file and check exact whitespace / line endings, then retry."
573
+ )
574
+ if len(matches) == 1:
575
+ return (
576
+ f"Found 1 unique match for needle in {p}.\n"
577
+ f"{matches[0]}\n"
578
+ "This block should match a unique location when passed to edit_file."
579
+ )
580
+ return (
581
+ f"Found {len(matches)} matches for needle in {p}. "
582
+ f"Include more context (one of the surrounding blocks below) in old_string to disambiguate:\n\n"
583
+ + "\n\n".join(matches)
584
+ )
585
+
586
+
587
+ def batch_edit(
588
+ path: str,
589
+ edits: list[dict[str, str]],
590
+ cwd: str | None = None,
591
+ *,
592
+ replace_all: bool = False,
593
+ ) -> str:
594
+ """Apply a sequence of find/replace edits to one file in a single tool call.
595
+
596
+ Faster and cheaper than calling ``edit_file`` once per edit when the same
597
+ file needs multiple independent changes. The edits are applied in the
598
+ order given; each edit operates on the file as modified by the previous
599
+ edit, so line numbers from the original file are not preserved.
600
+
601
+ All edits run in a single atomic write at the end. If any edit fails to
602
+ find its match (or finds ambiguous matches), the entire batch is
603
+ rejected and the file is NOT modified.
604
+
605
+ Args:
606
+ path: File path to edit.
607
+ edits: List of edit objects, each ``{"old_string": "...", "new_string": "..."}``.
608
+ Edit order matters: later edits operate on the post-edit content
609
+ of earlier ones.
610
+ cwd: Optional working directory for relative paths.
611
+ replace_all: Apply replace_all=True to every edit. Default False
612
+ requires each old_string to match exactly once.
613
+ """
614
+ if not edits:
615
+ return "Error: edits list is empty."
616
+
617
+ p = _resolve(path, cwd)
618
+ if not p.exists():
619
+ return f"Error: file not found: {p}"
620
+ if p.is_dir():
621
+ return f"Error: {p} is a directory."
622
+
623
+ try:
624
+ original = p.read_text(encoding="utf-8", errors="replace")
625
+ except Exception as exc:
626
+ return f"Error reading {p}: {exc}"
627
+
628
+ working = original
629
+ applied: list[str] = []
630
+ for index, edit in enumerate(edits, 1):
631
+ old = str(edit.get("old_string", ""))
632
+ new = str(edit.get("new_string", ""))
633
+ if not old:
634
+ return f"Error: edit #{index} has empty old_string. Refusing to apply batch."
635
+ count = working.count(old)
636
+ if count == 0:
637
+ return (
638
+ f"Error: edit #{index} old_string not found in working content. "
639
+ f"Refused to apply batch. Earlier edits were: {'; '.join(applied) or '(none)'}."
640
+ )
641
+ if count > 1 and not replace_all:
642
+ return (
643
+ f"Error: edit #{index} old_string matched {count} locations. "
644
+ "Refused to apply batch. Pass replace_all=True or tighten the snippet."
645
+ )
646
+ if replace_all and count > 1:
647
+ working = working.replace(old, new)
648
+ applied.append(f"#{index}: {count} occurrences")
649
+ else:
650
+ working = working.replace(old, new, 1)
651
+ applied.append(f"#{index}: 1 occurrence")
652
+
653
+ if working == original:
654
+ return "Error: batch contained only no-op edits. Refused to write the same file."
655
+
656
+ try:
657
+ _atomic_write_text(p, working)
658
+ except Exception as exc:
659
+ return f"Error writing {p}: {exc}"
660
+
661
+ delta = len(working) - len(original)
662
+ return (
663
+ f"Batch-edited {p}: applied {len(edits)} edits ({'; '.join(applied)}). "
664
+ f"File grew by {delta:+d} chars."
665
+ )
666
+
667
+
668
+ def list_directory(path: str = ".", cwd: str | None = None, limit: int = 200) -> str:
669
+ """List files and directories.
670
+
671
+ Args:
672
+ path: Directory path to list.
673
+ cwd: Optional working directory for relative paths.
674
+ limit: Maximum entries to return.
675
+ """
676
+ p = _resolve(path, cwd)
677
+ if not p.exists():
678
+ return f"Error: directory not found: {p}"
679
+ if not p.is_dir():
680
+ return f"Error: {p} is not a directory."
681
+ entries = []
682
+ try:
683
+ for entry in sorted(p.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower()))[:limit]:
684
+ suffix = "/" if entry.is_dir() else ""
685
+ size = ""
686
+ if entry.is_file():
687
+ try:
688
+ size = f" ({entry.stat().st_size:,} bytes)"
689
+ except OSError:
690
+ size = ""
691
+ entries.append(f"{entry.name}{suffix}{size}")
692
+ except Exception as exc:
693
+ return f"Error listing {p}: {exc}"
694
+ more = "" if len(entries) < limit else f"\n...[limited to {limit} entries]"
695
+ return "\n".join(entries) + more if entries else "(empty directory)"
696
+
697
+
698
+ def search_files(pattern: str, path: str = ".", cwd: str | None = None, glob: str | None = None, limit: int = 100) -> str:
699
+ """Search files with ripgrep when available.
700
+
701
+ Args:
702
+ pattern: Text or regex pattern to search.
703
+ path: Root path to search.
704
+ cwd: Optional working directory for relative paths.
705
+ glob: Optional rg glob, such as *.py.
706
+ limit: Maximum matching lines.
707
+ """
708
+ root = _resolve(path, cwd)
709
+ if not root.exists():
710
+ return f"Error: path not found: {root}"
711
+ if root.is_file():
712
+ try:
713
+ if glob and not fnmatch.fnmatch(root.name, glob):
714
+ return "No matches."
715
+ if root.stat().st_size > SEARCH_FALLBACK_MAX_FILE_BYTES:
716
+ return "No matches."
717
+ text = root.read_text(encoding="utf-8", errors="ignore")
718
+ file_matches = [
719
+ f"{root}:{lineno}:{line}"
720
+ for lineno, line in enumerate(text.splitlines(), 1)
721
+ if re.search(pattern, line)
722
+ ]
723
+ return "\n".join(file_matches[:limit]) if file_matches else "No matches."
724
+ except Exception as exc:
725
+ return f"Error searching: {exc}"
726
+ rg = shutil.which("rg")
727
+ if rg:
728
+ cmd = [rg, "--line-number", "--hidden", "--glob", "!{.git,node_modules,.venv,venv,dist,build,__pycache__}"]
729
+ if glob:
730
+ cmd.extend(["--glob", glob])
731
+ cmd.extend([pattern, str(root)])
732
+ try:
733
+ proc = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=20)
734
+ if proc.returncode not in {0, 1}:
735
+ return f"Error searching: {(proc.stderr or proc.stdout or '').strip() or f'rg exited with {proc.returncode}'}"
736
+ lines = (proc.stdout or "").splitlines()
737
+ return "\n".join(lines[:limit]) or "No matches."
738
+ except subprocess.TimeoutExpired:
739
+ return "Error: search timed out after 20 seconds."
740
+ matches: list[str] = []
741
+ scanned = 0
742
+ truncated = False
743
+ try:
744
+ for current, dirs, files in os.walk(root):
745
+ dirs[:] = [d for d in dirs if d not in SEARCH_FALLBACK_SKIP_DIRS]
746
+ for filename in files:
747
+ if len(matches) >= limit:
748
+ break
749
+ if scanned >= SEARCH_FALLBACK_MAX_FILES:
750
+ truncated = True
751
+ break
752
+ if glob and not fnmatch.fnmatch(filename, glob):
753
+ continue
754
+ fpath = Path(current) / filename
755
+ try:
756
+ if fpath.stat().st_size > SEARCH_FALLBACK_MAX_FILE_BYTES:
757
+ continue
758
+ except OSError:
759
+ continue
760
+ scanned += 1
761
+ try:
762
+ text = fpath.read_text(encoding="utf-8", errors="ignore")
763
+ except OSError:
764
+ continue
765
+ for lineno, line in enumerate(text.splitlines(), 1):
766
+ if re.search(pattern, line):
767
+ matches.append(f"{fpath}:{lineno}:{line}")
768
+ if len(matches) >= limit:
769
+ break
770
+ if len(matches) >= limit or truncated:
771
+ break
772
+ except Exception as exc:
773
+ return f"Error searching: {exc}"
774
+ if not matches:
775
+ return "No matches."
776
+ suffix = f"\n...[stopped after scanning {SEARCH_FALLBACK_MAX_FILES} files]" if truncated else ""
777
+ return "\n".join(matches) + suffix
778
+
779
+
780
+ def _isolated_process_group_kwargs(platform_name: str | None = None) -> dict[str, Any]:
781
+ """Return portable subprocess flags that isolate a child process group."""
782
+ if (platform_name or os.name) == "nt":
783
+ # ``subprocess.CREATE_NEW_PROCESS_GROUP`` is only defined on Windows.
784
+ # The documented Win32 value keeps this module importable/type-checkable
785
+ # on POSIX while still preferring the platform constant when available.
786
+ return {
787
+ "creationflags": getattr(
788
+ subprocess,
789
+ "CREATE_NEW_PROCESS_GROUP",
790
+ 0x00000200,
791
+ )
792
+ }
793
+ return {"start_new_session": True}
794
+
795
+
796
+ def run_shell(command: str, cwd: str | None = None, timeout: int = 30, safe_mode: bool = False) -> str:
797
+ """Run a shell command and return output.
798
+
799
+ On Windows this executes under cmd.exe: Unix tools like head, tail, grep,
800
+ sed, and awk are NOT available. Use native equivalents (findstr, more),
801
+ flags on the command itself (e.g. pytest -q, --maxfail=1), or read_file /
802
+ search_files instead of piping.
803
+
804
+ Args:
805
+ command: Shell command to execute.
806
+ cwd: Optional working directory.
807
+ timeout: Timeout in seconds (capped at 120). Do not pass milliseconds.
808
+ safe_mode: When True, block shell commands that appear to mutate files or Git state. Default False for LLM autonomy — the approval gate in tool_runtime handles safety instead.
809
+ """
810
+ if safe_mode and shell_is_dangerous(command):
811
+ return (
812
+ "Blocked by safe mode: command appears destructive or may mutate files/Git state. "
813
+ "Toggle /safe only for an explicitly approved, narrower operation."
814
+ )
815
+ workdir = _resolve(cwd or ".", None)
816
+ actual_timeout = min(timeout, 120)
817
+ # Isolate the child in its own process group. Without this, every child
818
+ # shares the CLI's console group, and any Ctrl+C/Ctrl+Break console event
819
+ # raised inside the child tree (test runners, scripts, taskkill) is also
820
+ # delivered to the CLI as a phantom KeyboardInterrupt mid-generation.
821
+ popen_kwargs = _isolated_process_group_kwargs()
822
+ try:
823
+ proc = subprocess.run(
824
+ command,
825
+ shell=True,
826
+ cwd=workdir,
827
+ capture_output=True,
828
+ text=True,
829
+ encoding="utf-8",
830
+ errors="replace",
831
+ timeout=actual_timeout,
832
+ **popen_kwargs,
833
+ )
834
+ except subprocess.TimeoutExpired:
835
+ return f"Error: command timed out after {actual_timeout} seconds."
836
+ except Exception as exc:
837
+ return f"Error running command: {exc}"
838
+ output = ""
839
+ if proc.stdout:
840
+ output += proc.stdout.strip()
841
+ if proc.stderr:
842
+ output += ("\nSTDERR: " if output else "STDERR: ") + proc.stderr.strip()
843
+ if not output:
844
+ output = "(command produced no output)"
845
+ suffix = f"[exit code: {proc.returncode}]"
846
+ body_limit = max(1, MAX_TOOL_RESULT - len(suffix) - 1)
847
+ return f"{_cap(output, body_limit)}\n{suffix}"
848
+
849
+
850
+ def git_status(cwd: str | None = None) -> str:
851
+ """Show concise Git working-tree status for the active project.
852
+
853
+ Args:
854
+ cwd: Optional project directory. Relative paths are not accepted by Git itself.
855
+ """
856
+ workdir = _resolve(cwd or ".", None)
857
+ try:
858
+ proc = subprocess.run(
859
+ ["git", "status", "--short", "--branch"],
860
+ cwd=workdir,
861
+ capture_output=True,
862
+ text=True,
863
+ encoding="utf-8",
864
+ errors="replace",
865
+ timeout=20,
866
+ )
867
+ except subprocess.TimeoutExpired:
868
+ return "Error: git status timed out after 20 seconds."
869
+ except Exception as exc:
870
+ return f"Error running git status: {exc}"
871
+ output = (proc.stdout or proc.stderr or "").strip()
872
+ if proc.returncode != 0:
873
+ return f"Error: git status failed ({proc.returncode}): {output or 'no output'}"
874
+ return _cap(output or "(clean working tree)")
875
+
876
+
877
+ def git_diff(path: str | None = None, cwd: str | None = None, names_only: bool = False) -> str:
878
+ """Show the current tracked Git diff against HEAD.
879
+
880
+ Args:
881
+ path: Optional file or directory path filter within the project.
882
+ cwd: Optional project directory.
883
+ names_only: Return only changed tracked file names when true.
884
+ """
885
+ workdir = _resolve(cwd or ".", None)
886
+ command = ["git", "diff", "--no-ext-diff"]
887
+ if names_only:
888
+ command.append("--name-only")
889
+ command.append("HEAD")
890
+ if path:
891
+ command.extend(["--", path])
892
+ try:
893
+ proc = subprocess.run(command, cwd=workdir, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=20)
894
+ except subprocess.TimeoutExpired:
895
+ return "Error: git diff timed out after 20 seconds."
896
+ except Exception as exc:
897
+ return f"Error running git diff: {exc}"
898
+ output = (proc.stdout or proc.stderr or "").strip()
899
+ if proc.returncode != 0:
900
+ return f"Error: git diff failed ({proc.returncode}): {output or 'no output'}"
901
+ return _cap(output or "(no tracked diff)")
902
+
903
+
904
+ def web_search(query: str, max_results: int = 5) -> str:
905
+ """Search the web through Ollama Cloud, when configured.
906
+
907
+ Args:
908
+ query: Search query.
909
+ max_results: Maximum results.
910
+ """
911
+ preflight = _ollama_cloud_web_preflight("searching web")
912
+ if preflight:
913
+ return preflight
914
+ try:
915
+ response = active_ollama_client(cloud=True).web_search(query, max_results=max_results)
916
+ except Exception as exc:
917
+ return f"Error searching web: {exc}. This usually requires ollama>=0.5 and OLLAMA_API_KEY."
918
+ results = response.get("results", response) if isinstance(response, dict) else getattr(response, "results", response)
919
+ if not results:
920
+ return "No results found."
921
+ rendered = []
922
+ for result in results:
923
+ title = result.get("title", "(untitled)") if isinstance(result, dict) else getattr(result, "title", "(untitled)")
924
+ url = result.get("url", "") if isinstance(result, dict) else getattr(result, "url", "")
925
+ content = result.get("content", "") if isinstance(result, dict) else getattr(result, "content", "")
926
+ rendered.append(f"### {title}\n{url}\n{content}")
927
+ return _cap("\n\n---\n\n".join(rendered))
928
+
929
+
930
+ def web_fetch(url: str, timeout: int = 30) -> str:
931
+ """Fetch web page text through Ollama Cloud, when configured.
932
+
933
+ Args:
934
+ url: URL to fetch.
935
+ timeout: Timeout seconds for the fetch operation.
936
+ """
937
+ preflight = _ollama_cloud_web_preflight("fetching URL")
938
+ if preflight:
939
+ return preflight
940
+ import queue
941
+ import threading
942
+
943
+ def _fetch():
944
+ try:
945
+ result = active_ollama_client(cloud=True).web_fetch(url)
946
+ content = result.get("content", "") if isinstance(result, dict) else getattr(result, "content", str(result))
947
+ results.put(("ok", _cap(content)))
948
+ except Exception as exc:
949
+ results.put(("error", exc))
950
+
951
+ actual_timeout = max(1, min(int(timeout or 30), 120))
952
+ results: queue.Queue[tuple[str, Any]] = queue.Queue(maxsize=1)
953
+ thread = threading.Thread(target=_fetch, name="algo-cli-web-fetch", daemon=True)
954
+ thread.start()
955
+ try:
956
+ status, payload = results.get(timeout=actual_timeout)
957
+ except queue.Empty:
958
+ return f"Error fetching URL: timed out after {actual_timeout} seconds."
959
+ if status == "ok":
960
+ return str(payload)
961
+ exc = payload
962
+ if isinstance(exc, Exception):
963
+ return f"Error fetching URL: {exc}. This usually requires ollama>=0.5 and OLLAMA_API_KEY."
964
+ return f"Error fetching URL: {exc}. This usually requires ollama>=0.5 and OLLAMA_API_KEY."
965
+
966
+
967
+ def x_search(query: str, max_results: int = 10) -> str:
968
+ """Search X.com (Twitter) in real time via Grok's native Live Search.
969
+
970
+ Requires xAI OAuth authentication (run /xai-login first). This CLI uses
971
+ the subscription OAuth path only and refuses API-key fallback. Results are
972
+ summarized by Grok and include citation URLs, then cached as a harness record so
973
+ future turns can retrieve them via RAG.
974
+
975
+ Args:
976
+ query: What to search X.com for.
977
+ max_results: Maximum number of source posts Grok considers (1-30).
978
+ """
979
+ from . import xai_auth, xai_client
980
+ from .config import _resolve_config_dir
981
+
982
+ if not xai_auth.get_valid_token():
983
+ return "Error: not authenticated with xAI. Run /xai-login first."
984
+ if not query or not query.strip():
985
+ return "Error: query is empty."
986
+
987
+ try:
988
+ result = xai_client.active_xai_client().search(
989
+ query=query.strip(),
990
+ sources=[{"type": "x"}],
991
+ max_results=max(1, min(int(max_results), 30)),
992
+ )
993
+ except Exception as exc:
994
+ return f"Error running x_search: {exc}"
995
+
996
+ content = result.get("content", "") or "(Grok returned no summary.)"
997
+ citations = result.get("citations") or []
998
+
999
+ cache_dir = _resolve_config_dir() / "x_search_cache"
1000
+ cache_dir.mkdir(parents=True, exist_ok=True)
1001
+ # Use hash of full query to avoid collisions from truncation
1002
+ query_hash = hashlib.sha256(query.strip().encode("utf-8")).hexdigest()[:16]
1003
+ ts = time.strftime("%Y%m%dT%H%M%S") + f".{int(time.time() * 1000) % 1000:03d}"
1004
+ path = cache_dir / f"x_search_{query_hash}_{ts}.md"
1005
+
1006
+ body_lines = [
1007
+ "---",
1008
+ f"id: algo-cli:x_search:{query_hash}_{ts}",
1009
+ "harness: algo-cli",
1010
+ "kind: x_search",
1011
+ f"title: \"X.com search: {query.strip()}\"",
1012
+ "tags: [x_search, xai, realtime]",
1013
+ f"fetched_at: {ts}",
1014
+ "---",
1015
+ f"# X.com search: {query.strip()}",
1016
+ "",
1017
+ content,
1018
+ ]
1019
+ if citations:
1020
+ body_lines.append("")
1021
+ body_lines.append("## Citations")
1022
+ for url in citations:
1023
+ body_lines.append(f"- {url}")
1024
+ try:
1025
+ _atomic_write_text(path, "\n".join(body_lines))
1026
+ except OSError as exc:
1027
+ logger.debug("x_search cache write failed for %s: %s", path, exc)
1028
+
1029
+ out_parts = [content]
1030
+ if citations:
1031
+ out_parts.append("")
1032
+ out_parts.append("Sources:")
1033
+ out_parts.extend(f"- {url}" for url in citations[:max_results])
1034
+ out_parts.append("")
1035
+ out_parts.append(f"(cached to {path.name})")
1036
+ return _cap("\n".join(out_parts))
1037
+
1038
+
1039
+ def x_account_status() -> str:
1040
+ """Check X account CLI auth status through xurl without reading token files.
1041
+
1042
+ This uses the separate X account OAuth lane (api.x.com), not xAI Grok OAuth.
1043
+ It never reads or prints ~/.xurl directly.
1044
+ """
1045
+ from . import x_account
1046
+
1047
+ return x_account.status().to_json()
1048
+
1049
+
1050
+ def x_account_draft_post(text: str) -> str:
1051
+ """Create a browser draft URL for an X post without publishing it.
1052
+
1053
+ Args:
1054
+ text: Exact post text to draft.
1055
+ """
1056
+ from . import x_account
1057
+
1058
+ return x_account.draft_post(text).to_json()
1059
+
1060
+
1061
+ def x_account_draft_reply(post: str, text: str) -> str:
1062
+ """Create a browser draft URL for an X reply without publishing it.
1063
+
1064
+ Args:
1065
+ post: X post id or x.com status URL to reply to.
1066
+ text: Exact reply text to draft.
1067
+ """
1068
+ from . import x_account
1069
+
1070
+ return x_account.draft_reply(post, text).to_json()
1071
+
1072
+
1073
+ def x_account_post(text: str, confirm: bool = False) -> str:
1074
+ """Publish an X post through xurl only after explicit user confirmation.
1075
+
1076
+ Args:
1077
+ text: Exact post text to publish.
1078
+ confirm: Must be true only when the user explicitly approved this exact text.
1079
+ """
1080
+ from . import x_account
1081
+
1082
+ return x_account.post(text, confirm=confirm).to_json()
1083
+
1084
+
1085
+ def x_account_reply(post: str, text: str, confirm: bool = False) -> str:
1086
+ """Publish an X reply through xurl only after explicit user confirmation.
1087
+
1088
+ Args:
1089
+ post: X post id or x.com status URL to reply to.
1090
+ text: Exact reply text to publish.
1091
+ confirm: Must be true only when the user explicitly approved this exact reply.
1092
+ """
1093
+ from . import x_account
1094
+
1095
+ return x_account.reply(post, text, confirm=confirm).to_json()
1096
+
1097
+
1098
+ def x_account_post_action(action: str, post: str, confirm: bool = False) -> str:
1099
+ """Run a confirmed X post action through xurl.
1100
+
1101
+ Supported actions: delete, like, unlike, repost, unrepost, bookmark, unbookmark.
1102
+
1103
+ Args:
1104
+ action: The action to run.
1105
+ post: X post id or x.com status URL.
1106
+ confirm: Must be true only when the user explicitly approved this exact action.
1107
+ """
1108
+ from . import x_account
1109
+
1110
+ return x_account.post_action(action, post, confirm=confirm).to_json()
1111
+
1112
+
1113
+ def remember(fact: str, cfg: Config | None = None) -> str:
1114
+ """Store a fact in long-term memory.
1115
+
1116
+ Call only when the user explicitly asks to remember something. The runtime's
1117
+ bounded completion gate handles other high-confidence durable markers, so do
1118
+ not duplicate them with a speculative tool call. One concise sentence per
1119
+ explicit request.
1120
+
1121
+ Args:
1122
+ fact: Fact to remember.
1123
+ cfg: Optional Config instance for persistence (required for actual storage).
1124
+ """
1125
+ if cfg is not None:
1126
+ added = cfg.remember_fact(fact)
1127
+ if added:
1128
+ from .main import capture_intuition_block
1129
+ capture_intuition_block(cfg, "memory", fact, source="tool:remember")
1130
+ return f"Remembered: {fact}"
1131
+ return f"Fact already in memory: {fact}"
1132
+ return f"Remembered: {fact} (no config provided - not persisted)"
1133
+
1134
+
1135
+ def append_lesson(text: str, cfg: Config | None = None) -> str:
1136
+ """Append a lesson to lessons-learned.md so it is available in future turns.
1137
+
1138
+ Call only when the user explicitly asks to retain a preference, correction,
1139
+ or pattern as a lesson. The lesson is timestamped and embedded for retrieval
1140
+ on the next turn. Do NOT use this for session notes, speculative capture, or
1141
+ to paraphrase the last message.
1142
+
1143
+ Args:
1144
+ text: The lesson, written as a short paragraph. Be specific about the
1145
+ preference and, when useful, the reason.
1146
+ cfg: Optional Config instance for intuition capture (required for embedding).
1147
+ """
1148
+ if not text or not text.strip():
1149
+ return "Error: lesson text was empty."
1150
+ path = identity.append_lesson(text)
1151
+ if cfg is not None:
1152
+ from .main import capture_intuition_block
1153
+ capture_intuition_block(cfg, "lesson", text.strip(), source="tool:append_lesson")
1154
+ return f"Appended lesson to {path}"
1155
+
1156
+
1157
+ def update_user_profile(content: str) -> str:
1158
+ """Overwrite USER.md (the 'About the User' identity file).
1159
+
1160
+ Use this ONLY when the user explicitly asks you to update or rewrite their
1161
+ profile. Preserve existing structure unless they ask for a rewrite. Do NOT
1162
+ update USER.md to record session-specific facts; use append_lesson for those.
1163
+
1164
+ Never modify SOUL.md or IDENTITY.md programmatically; only the user edits
1165
+ those by hand.
1166
+
1167
+ Args:
1168
+ content: The full new contents of USER.md as Markdown. Include the
1169
+ existing sections (Who I am, How I work, etc.) unless the user
1170
+ asked for a different structure.
1171
+ """
1172
+ if not content or not content.strip():
1173
+ return "Error: refusing to overwrite USER.md with empty content."
1174
+ path = identity.write_user_profile(content)
1175
+ return f"Wrote {len(content)} chars to {path}"
1176
+
1177
+
1178
+ def current_gateway_url(url: str | None = None) -> str:
1179
+ return (
1180
+ url
1181
+ or os.environ.get("ALGO_CLI_GATEWAY_URL")
1182
+ or os.environ.get("OLLAMA_CLI_GATEWAY_URL")
1183
+ or DEFAULT_GATEWAY_URL
1184
+ ).rstrip("/")
1185
+
1186
+
1187
+ def gateway_ready(url: str | None = None) -> bool:
1188
+ url = current_gateway_url(url)
1189
+ try:
1190
+ request = Request(url + "/healthz", method="GET")
1191
+ with urlopen(request, timeout=1.0) as response:
1192
+ return 200 <= response.status < 500
1193
+ except (OSError, URLError, ValueError):
1194
+ return False
1195
+
1196
+
1197
+ def gateway_embed(
1198
+ text: str,
1199
+ model: str,
1200
+ truncate: bool,
1201
+ dimensions: int | None,
1202
+ url: str | None = None,
1203
+ ) -> dict[str, Any] | None:
1204
+ payload: dict[str, Any] = {
1205
+ "model": model,
1206
+ "input": text,
1207
+ "truncate": truncate,
1208
+ }
1209
+ if dimensions is not None:
1210
+ payload["dimensions"] = dimensions
1211
+ data = json.dumps(payload).encode("utf-8")
1212
+ request = Request(
1213
+ current_gateway_url(url) + "/supplemental/embed",
1214
+ data=data,
1215
+ method="POST",
1216
+ headers={"Content-Type": "application/json"},
1217
+ )
1218
+ try:
1219
+ with urlopen(request, timeout=60) as response:
1220
+ raw = response.read().decode("utf-8", errors="replace")
1221
+ except (OSError, URLError, ValueError):
1222
+ return None
1223
+ try:
1224
+ loaded = json.loads(raw)
1225
+ except json.JSONDecodeError:
1226
+ return None
1227
+ return loaded if isinstance(loaded, dict) else None
1228
+
1229
+
1230
+ def gateway_embed_batch(
1231
+ texts: list[str],
1232
+ model: str,
1233
+ truncate: bool,
1234
+ dimensions: int | None,
1235
+ url: str | None = None,
1236
+ ) -> dict[str, Any] | None:
1237
+ payload: dict[str, Any] = {
1238
+ "model": model,
1239
+ "input": list(texts),
1240
+ "truncate": truncate,
1241
+ }
1242
+ if dimensions is not None:
1243
+ payload["dimensions"] = dimensions
1244
+ data = json.dumps(payload).encode("utf-8")
1245
+ request = Request(
1246
+ current_gateway_url(url) + "/supplemental/embed",
1247
+ data=data,
1248
+ method="POST",
1249
+ headers={"Content-Type": "application/json"},
1250
+ )
1251
+ try:
1252
+ with urlopen(request, timeout=60) as response:
1253
+ raw = response.read().decode("utf-8", errors="replace")
1254
+ except (OSError, URLError, ValueError):
1255
+ return None
1256
+ try:
1257
+ loaded = json.loads(raw)
1258
+ except json.JSONDecodeError:
1259
+ return None
1260
+ return loaded if isinstance(loaded, dict) else None
1261
+
1262
+ def embed_text(
1263
+ text: str,
1264
+ model: str = "embeddinggemma",
1265
+ truncate: bool = True,
1266
+ dimensions: int | None = None,
1267
+ ) -> str:
1268
+ """Generate embeddings for text through Ollama."""
1269
+ try:
1270
+ response: Any = gateway_embed(text, model, truncate, dimensions)
1271
+ if response is None:
1272
+ response = active_ollama_client().embed(model=model, input=text, truncate=truncate, dimensions=dimensions)
1273
+ except Exception as exc:
1274
+ return f"Error generating embeddings: {exc}"
1275
+ payload = unpack_embed_response(
1276
+ response, model, text, truncate=truncate, dimensions=dimensions
1277
+ )
1278
+ return json.dumps(payload, indent=2)
1279
+
1280
+
1281
+ def vision_describe(
1282
+ image_path: str,
1283
+ prompt: str = "What is in this image? Be concise.",
1284
+ model: str = "gemma3",
1285
+ ) -> str:
1286
+ """Describe an image through Ollama vision."""
1287
+ load_runtime_env(override=True)
1288
+ resolved = Path(image_path).expanduser()
1289
+ if not resolved.exists():
1290
+ return f"Error: image not found: {resolved}"
1291
+ if resolved.suffix.lower() == ".pdf":
1292
+ return "Error: vision_describe expects an image file, not a PDF. Use render_pdf_pages first, then pass a returned PNG path."
1293
+ try:
1294
+ response = active_ollama_client().chat(
1295
+ model=model,
1296
+ messages=[
1297
+ {
1298
+ "role": "user",
1299
+ "content": prompt,
1300
+ "images": [str(resolved)],
1301
+ }
1302
+ ],
1303
+ stream=False,
1304
+ )
1305
+ except Exception as exc:
1306
+ return f"Error running vision request: {exc}"
1307
+ message = get_attr(response, "message", {}) or {}
1308
+ content = get_attr(message, "content", "")
1309
+ return content or "(empty response)"
1310
+
1311
+
1312
+ def available_actions(topic: str | None = None) -> str:
1313
+ """Show the CLI's available commands, model-callable tools, and internal harness stats.
1314
+
1315
+ Use this before answering questions like "what can you do?", "what actions are available?",
1316
+ "what tools do you have?", or "what internal knowledge can you search?"
1317
+
1318
+ Args:
1319
+ topic: Optional focus area such as files, shell, web, memory, harness, verification, or models.
1320
+ """
1321
+ focus = (topic or "").strip().lower()
1322
+ commands = {
1323
+ "model": ["/model [NAME]", "/models", "/cloud [on|off|status]", "/cloudauto [on|off|status]", "/login", "/host URL"],
1324
+ "session": [
1325
+ "/help",
1326
+ "/status",
1327
+ "/info",
1328
+ "/clear",
1329
+ "/context [status|rebuild|clear]",
1330
+ "/save NAME",
1331
+ "/load NAME",
1332
+ "/theme NAME",
1333
+ "/mode [execute|explore|publish|status]",
1334
+ "/exit",
1335
+ ],
1336
+ "tools": ["/auto [on|off|status]", "/safe [on|off|status]", "/policy [on|off|status]", "/thinking [on|off|status]", "/verify [on|off|status]", "/ctx NUM", "/temp NUM", "/toolmax NUM", "/thinkevery NUM", "/cd PATH", "/route TASK"],
1337
+ "agent": [
1338
+ "/agent help",
1339
+ "/agent init",
1340
+ "/agent [--pipeline NAME] TASK",
1341
+ "/agent team [--roles ROLE,ROLE[,ROLE,ROLE]] TASK",
1342
+ "/agent threads",
1343
+ "/agent show THREAD",
1344
+ "/agent resume THREAD [TASK]",
1345
+ "/agent fork THREAD TASK",
1346
+ "/route TASK",
1347
+ "/goal [--rounds N] TASK",
1348
+ ],
1349
+ "reasoning": ["/reason status", "/reason guide", "/reason react", "/reason reflexion", "/reason tot", "/reason got", "/reason mcts", "/reason qcr", "/reason neuro_symbolic", "/reason depth N", "/reason branches N", "/reason auto-reflexion on|off", "/reason auto-verify on|off"],
1350
+ "xai": ["/xai-login", "/xai-status", "/xai-test", "/x-account status"],
1351
+ "google": [
1352
+ "/google-login",
1353
+ "/google-callback --clipboard",
1354
+ "/google-status",
1355
+ "/google drive-list [query] [--max N]",
1356
+ "/google drive-search NAME [--max N] [--mime MIME]",
1357
+ "/google drive-get FILE_ID [--download | --export MIME]",
1358
+ "/google docs-get DOCUMENT_ID",
1359
+ "/google sheets-values SPREADSHEET_ID RANGE",
1360
+ "/google calendar-list [--max N] [--time-min RFC3339] [--time-max RFC3339]",
1361
+ "/google gmail-list [query] [--max N] [--label LABEL]",
1362
+ "/google gmail-get MESSAGE_ID",
1363
+ ],
1364
+ "chatgpt": ["/chatgpt-login", "/chatgpt-status", "/chatgpt-logout"],
1365
+ "multimodal": [
1366
+ "/embed [--model MODEL] [--file PATH] TEXT",
1367
+ "/vision [--model MODEL] [--prompt TEXT] IMAGE [QUESTION]",
1368
+ ],
1369
+ "documents": ["/pdf [--pages N] [--chars N] PATH"],
1370
+ "memory": [
1371
+ "/memory-auto [on|off|status]",
1372
+ "/remember FACT",
1373
+ "/memories",
1374
+ "/forget ID",
1375
+ "/intuition [on|off|status|add]",
1376
+ ],
1377
+ "intelligence": [
1378
+ "/intel status",
1379
+ "/intel query TERM",
1380
+ "/intel reindex",
1381
+ "/intel status|query TERM|reindex",
1382
+ "/intelligence status|query TERM|reindex",
1383
+ "/intelagence status|query TERM|reindex",
1384
+ ],
1385
+ "kernel": [
1386
+ "/kernel list",
1387
+ "/kernel show NAME",
1388
+ "/kernel check [NAME]",
1389
+ ],
1390
+ "harness": [
1391
+ "/code-rag [on|off|status]",
1392
+ "/harness status",
1393
+ "/harness refresh",
1394
+ "/harness embed",
1395
+ "/harness score",
1396
+ "/harness compare",
1397
+ "/hsearch QUERY",
1398
+ "/hread RECORD_ID",
1399
+ "/actions",
1400
+ "/selfcheck",
1401
+ "/reload",
1402
+ ],
1403
+ }
1404
+ tool_groups = {
1405
+ "files": [
1406
+ "read_file",
1407
+ "edit_file",
1408
+ "read_pdf",
1409
+ "render_pdf_pages",
1410
+ "write_file",
1411
+ "list_directory",
1412
+ "search_files",
1413
+ "find_unique_anchor",
1414
+ "batch_edit",
1415
+ "git_status",
1416
+ "git_diff",
1417
+ "session_slash",
1418
+ ],
1419
+ "session": [
1420
+ "session_slash: /read PATH, /ls [PATH], /cd PATH, /cwd (safe cwd file navigation)",
1421
+ "session_command: any registered slash command such as /status, /mode execute, /context status, /code-rag status, /harness status, /harness refresh, /harness embed, /harness score, /harness compare, /route TASK (session control; may require approval)",
1422
+ ],
1423
+ "shell": ["run_shell"],
1424
+ "web": ["web_search", "web_fetch"],
1425
+ "xai": ["x_search"],
1426
+ "x_account": [
1427
+ "x_account_status",
1428
+ "x_account_draft_post",
1429
+ "x_account_draft_reply",
1430
+ "x_account_post",
1431
+ "x_account_reply",
1432
+ "x_account_post_action",
1433
+ ],
1434
+ "memory": ["remember"],
1435
+ "multimodal": ["embed_text", "vision_describe"],
1436
+ "harness": [
1437
+ "available_actions",
1438
+ "harness_stats",
1439
+ "harness_scorecard",
1440
+ "harness_competitive_rating",
1441
+ "harness_search",
1442
+ "harness_read",
1443
+ "harness_refresh",
1444
+ "query_knowledge_graph",
1445
+ "reindex_knowledge_graph",
1446
+ "write_knowledge_graph_note",
1447
+ ],
1448
+ "models": ["model_show", "model_pull", "model_copy", "model_create", "model_delete"],
1449
+ }
1450
+ slash_guidance = [
1451
+ "Slash commands are session controls. Writing '/command' in a final answer does not execute it.",
1452
+ "Use session_slash for /read, /ls, /cd, and /cwd when you need deterministic cwd-relative file navigation.",
1453
+ "Use session_command for non-file slash commands only when the user asks for that action or session state must change/check before continuing; read-only/status commands such as /kernel list, /code-rag status, /harness status, /agent threads, /harness score, or /harness compare run without approval; state-changing commands such as /code-rag on, /code-rag off, /harness refresh, or /harness embed and agent execution require approval.",
1454
+ "Prefer direct tools for actual work: write_file for edits, run_shell for tests/builds, read_pdf/render_pdf_pages for PDFs, web_search/web_fetch for web.",
1455
+ "Prefer explicit on/off/status forms for toggles (/auto on, /safe off, /memory-auto status, /code-rag status, /thinking status, /verify on, /cloud off) so you do not accidentally flip state.",
1456
+ "For /reason, check /reason status or /reason guide first; only change reasoning mode for genuinely complex, failed, ambiguous, or verification-heavy work.",
1457
+ "For independent complex work, the parent runtime may invoke /agent team through session_command. Use 2-4 clear roles; specialists are read-only and one integration pipeline owns mutations and verification.",
1458
+ "Use /agent resume THREAD to continue the same context or /agent fork THREAD TASK to explore a distinct follow-up. Do not delegate routine one-step work.",
1459
+ "Call available_actions('agent'), available_actions('kernel'), available_actions('intel'), available_actions('google'), available_actions('chatgpt'), available_actions('slash'), or available_actions('reason') when unsure which command/tool should be used.",
1460
+ ]
1461
+ reasoning_guidance = [
1462
+ "/reason status: inspect current reasoning mode, depth, branches, and auto flags; safe/read-only.",
1463
+ "/reason guide: show this mode-selection guide; safe/read-only.",
1464
+ "/reason react: default for normal ReAct tool-use loops, file inspection, small coding edits, and straightforward tasks.",
1465
+ "/reason reflexion: use after a failed, partial, or contradicted attempt when self-critique/retry discipline is needed.",
1466
+ "/reason tot: Tree-of-Thought; use for ambiguous planning/architecture decisions where several independent paths should be explored.",
1467
+ "/reason got: Graph-of-Thought; use when ideas/evidence interconnect, merge, or revise each other across a research/design problem.",
1468
+ "/reason mcts: Monte Carlo tree search; use for deeper exploration/exploitation when there are many possible action sequences.",
1469
+ "/reason qcr: quantum-inspired candidate aggregation; use to compare/rank multiple proposed solutions or reasoning fragments.",
1470
+ "/reason neuro_symbolic (or neuro-symbolic): use for verification-heavy logic, math, code invariants, contracts, or claim checking.",
1471
+ "/reason depth N and /reason branches N: adjust search cost; keep small unless the user asks for deeper exploration.",
1472
+ "/reason auto-reflexion on|off and /reason auto-verify on|off: enable automation for failed blocks or implementation verification only when the user wants that behavior.",
1473
+ "Do not switch /reason for routine reads, simple edits, or before every answer; mode changes are session state changes and may require approval.",
1474
+ "Changing /reason does not replace evidence gathering: still use read_file, search_files, run_shell, git_diff, or web/harness tools to verify facts.",
1475
+ ]
1476
+ verification_layer = [
1477
+ "Use harness_search before broad filesystem scans for skills, prompts, memory, wiki, and workflows.",
1478
+ "Use read_pdf for PDF text extraction. If it reports a scanned/image-only PDF, use render_pdf_pages next and then vision_describe on the returned PNG paths.",
1479
+ "Prefer edit_file (find/replace) over write_file for modifying existing files — it is faster, uses fewer tokens, and is less error-prone. Use write_file only for new files or full rewrites.",
1480
+ "When using edit_file, first call read_file to confirm the exact text, then include 2-5 lines of surrounding context so the match is unique. If the tool reports an ambiguous match, tighten old_string or pass replace_all=True when the rewrite is genuinely global.",
1481
+ "If edit_file fails with 'old_string not found' or 'matched N locations', call find_unique_anchor with the same needle to get a unique snippet with line numbers and context — then retry edit_file with that context.",
1482
+ "When the same file needs 2+ independent edits, call batch_edit with all of them at once instead of looping edit_file. One atomic write, faster, and you can see the whole change set in the result.",
1483
+ "Use run_shell for build/test/typecheck/lint/git verification after code changes.",
1484
+ "In requires_change Agent Blocks, use write_file or edit_file for file edits; mutating shell or Git commands require explicit approval.",
1485
+ "For Algo algorithm/pattern catalog guidance, read and update docs/ALGO.md.",
1486
+ "For harness maintenance, use harness_stats or /harness status to inspect quality, harness_scorecard or /harness score to grade readiness, harness_refresh or /harness refresh after source edits, and /harness embed to fill pending embeddings.",
1487
+ "Use web_search/web_fetch only when OLLAMA_API_KEY enables Ollama Cloud web access.",
1488
+ "Use x_search for real-time X.com content; requires /xai-login (SuperGrok OAuth).",
1489
+ "Use x_account_* for X account actions through xurl; writes require explicit confirmation and separate X API OAuth.",
1490
+ "Treat memory/wiki as navigation; verify consequential facts against live files or endpoints.",
1491
+ ]
1492
+ stats = harness.stats()
1493
+ payload: dict[str, Any] = {
1494
+ "topic": focus or "all",
1495
+ "commands": commands,
1496
+ "model_callable_tools": tool_groups,
1497
+ "slash_command_guidance": slash_guidance,
1498
+ "reasoning_mode_guidance": reasoning_guidance,
1499
+ "verification_layer": verification_layer,
1500
+ "harness_index": {
1501
+ "record_count": stats.get("record_count"),
1502
+ "generated": stats.get("generated"),
1503
+ "counts": stats.get("counts", {}),
1504
+ },
1505
+ }
1506
+ if focus:
1507
+ slash_focus = focus in {"slash", "slashes", "command", "commands", "session-command", "session_command"}
1508
+ reason_focus = focus in {"reason", "reasoning", "reason-engine", "reasoning-engine"}
1509
+ matching: dict[str, Any] = {
1510
+ "commands": commands if slash_focus else {key: value for key, value in commands.items() if reason_focus and key == "reasoning" or focus in key or any(focus in item.lower() for item in value)},
1511
+ "model_callable_tools": {key: value for key, value in tool_groups.items() if slash_focus and key == "session" or focus in key or any(focus in item.lower() for item in value)},
1512
+ }
1513
+ if slash_focus:
1514
+ matching["when_to_use"] = slash_guidance
1515
+ if reason_focus:
1516
+ matching["reasoning_mode_guidance"] = reasoning_guidance
1517
+ payload["focused"] = matching
1518
+ return json.dumps(payload, indent=2)
1519
+
1520
+
1521
+ def session_slash(command: str) -> str:
1522
+ """Execute a session slash command (same as TUI /read, /ls, /cd).
1523
+
1524
+ Prefer this for deterministic reads when the user names files under session cwd.
1525
+ Allowed commands: /read PATH, /ls [PATH], /cd PATH, /cwd.
1526
+
1527
+ Args:
1528
+ command: Full slash line, e.g. "/read PERMIT_ROLLOUT_PLAN.md" or "/ls".
1529
+ """
1530
+ return "Error: session_slash must be invoked by the algo CLI runtime (not called directly)."
1531
+
1532
+
1533
+ _SESSION_OUTPUT_COMMANDS = frozenset({
1534
+ "/actions",
1535
+ "/changes",
1536
+ "/chatgpt-status",
1537
+ "/credentials",
1538
+ "/dashboard",
1539
+ "/diff",
1540
+ "/doctor",
1541
+ "/google-status",
1542
+ "/help",
1543
+ "/hread",
1544
+ "/hsearch",
1545
+ "/identity",
1546
+ "/info",
1547
+ "/memories",
1548
+ "/model-check",
1549
+ "/perf",
1550
+ "/route",
1551
+ "/selfcheck",
1552
+ "/status",
1553
+ "/url-scheme",
1554
+ "/xai-status",
1555
+ })
1556
+ _SESSION_STATUS_COMMANDS = frozenset({
1557
+ "/auto",
1558
+ "/cloud",
1559
+ "/cloudauto",
1560
+ "/code-rag",
1561
+ "/context",
1562
+ "/icl",
1563
+ "/intel",
1564
+ "/intelagence",
1565
+ "/intelligence",
1566
+ "/intuition",
1567
+ "/lessons",
1568
+ "/memory-auto",
1569
+ "/mode",
1570
+ "/policy",
1571
+ "/reason",
1572
+ "/reflex",
1573
+ "/safe",
1574
+ "/skills",
1575
+ "/thinking",
1576
+ "/verify",
1577
+ })
1578
+ _SESSION_STATUS_ARGS = frozenset({"", "?", "guide", "help", "show", "status"})
1579
+ _SESSION_EMPTY_ARG_TOGGLES = frozenset({
1580
+ "/auto",
1581
+ "/cloud",
1582
+ "/cloudauto",
1583
+ "/safe",
1584
+ "/thinking",
1585
+ "/verify",
1586
+ })
1587
+ _READ_ONLY_GOOGLE_SUBCOMMANDS = frozenset({
1588
+ "calendar-list",
1589
+ "docs-get",
1590
+ "drive-get",
1591
+ "drive-list",
1592
+ "drive-search",
1593
+ "gmail-get",
1594
+ "gmail-list",
1595
+ "help",
1596
+ "sheets-values",
1597
+ })
1598
+ _READ_ONLY_HARNESS_SUBCOMMANDS = frozenset({
1599
+ "",
1600
+ "?",
1601
+ "grade",
1602
+ "help",
1603
+ "quality",
1604
+ "rating",
1605
+ "score",
1606
+ "scorecard",
1607
+ "compare",
1608
+ "competitive",
1609
+ "stats",
1610
+ "status",
1611
+ })
1612
+ _SESSION_SECRET_ASSIGNMENT_RE = re.compile(
1613
+ r"(?i)(\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|"
1614
+ r"client[_-]?secret|password)\b[\"']?\s*[:=]\s*)"
1615
+ r"(?:\"[^\"]*\"|'[^']*'|[^\s,;&}\]]+)"
1616
+ )
1617
+ _SESSION_BEARER_RE = re.compile(r"(?i)\bBearer\s+[^\s,;&}\]]+")
1618
+
1619
+
1620
+ def _session_command_captures_output(command_line: str) -> bool:
1621
+ """Return whether a slash result is safe and useful to return to the model.
1622
+
1623
+ Capture is intentionally narrower than the set of commands that the runtime
1624
+ can execute without approval. In particular, authentication and mutation
1625
+ routes must keep rendering only to the interactive console so OAuth URLs,
1626
+ callback values, and message bodies do not become model-visible tool output.
1627
+ """
1628
+
1629
+ stripped = (command_line or "").strip()
1630
+ if not stripped:
1631
+ return False
1632
+ parts = stripped.split(maxsplit=1)
1633
+ root = parts[0].lower()
1634
+ arg = parts[1].strip() if len(parts) > 1 else ""
1635
+ normalized_arg = arg.lower()
1636
+
1637
+ if root in _SESSION_OUTPUT_COMMANDS:
1638
+ return True
1639
+ if root in _SESSION_STATUS_COMMANDS:
1640
+ if root in _SESSION_EMPTY_ARG_TOGGLES and not normalized_arg:
1641
+ return False
1642
+ if normalized_arg in _SESSION_STATUS_ARGS:
1643
+ return True
1644
+ if root == "/harness":
1645
+ subcommand = normalized_arg.split(maxsplit=1)[0] if normalized_arg else ""
1646
+ return subcommand in _READ_ONLY_HARNESS_SUBCOMMANDS
1647
+ if root == "/google":
1648
+ subcommand = normalized_arg.split(maxsplit=1)[0] if normalized_arg else "help"
1649
+ return subcommand in _READ_ONLY_GOOGLE_SUBCOMMANDS
1650
+ if root == "/kernel":
1651
+ subcommand = normalized_arg.split(maxsplit=1)[0] if normalized_arg else "list"
1652
+ return subcommand in {"?", "check", "help", "list", "show"}
1653
+ if root in {"/intel", "/intelagence", "/intelligence"}:
1654
+ return normalized_arg in {"", "?", "guide", "help", "show", "status"} or normalized_arg.startswith("query ")
1655
+ if root == "/icl":
1656
+ return normalized_arg.startswith("ask ")
1657
+ if root == "/x-account":
1658
+ return normalized_arg == "status"
1659
+ if root == "/plugins":
1660
+ subcommand = normalized_arg.split(maxsplit=1)[0] if normalized_arg else "list"
1661
+ return subcommand in {"?", "help", "list", "status"}
1662
+ return False
1663
+
1664
+
1665
+ def _redact_session_command_output(output: str) -> str:
1666
+ """Remove common credential forms from captured slash-command output."""
1667
+
1668
+ redacted = _SESSION_BEARER_RE.sub("Bearer <redacted>", str(output))
1669
+ redacted = _SESSION_SECRET_ASSIGNMENT_RE.sub(r"\1<redacted>", redacted)
1670
+ # Read-only slash output can be returned to a cloud model. Preserve useful
1671
+ # logical locations without disclosing the user's home or an overridden
1672
+ # config directory. Interactive console output remains unchanged.
1673
+ for prefix, replacement in sorted(
1674
+ ((str(CONFIG_DIR), "$ALGO_CLI_CONFIG_DIR"), (str(Path.home()), "~")),
1675
+ key=lambda item: len(item[0]),
1676
+ reverse=True,
1677
+ ):
1678
+ if prefix:
1679
+ redacted = re.sub(re.escape(prefix), replacement, redacted, flags=re.IGNORECASE)
1680
+ return redacted
1681
+
1682
+
1683
+ def _captured_session_result(output: str, normalized: str) -> str:
1684
+ rendered = _redact_session_command_output(output).strip()
1685
+ # Rich error output begins with the product glyph. Normalize it so the
1686
+ # runtime's existing error classifier still recognizes failed tool calls.
1687
+ first_line, separator, remainder = rendered.partition("\n")
1688
+ marker = " Error: "
1689
+ if marker in first_line and first_line.index(marker) <= 3:
1690
+ first_line = f"Error: {first_line.split(marker, 1)[1]}"
1691
+ rendered = first_line + (separator + remainder if separator else "")
1692
+ if not rendered:
1693
+ return f"Executed: {normalized} (command produced no output)."
1694
+ truncation_marker = "\n...[truncated]"
1695
+ if len(rendered) > SESSION_COMMAND_OUTPUT_LIMIT:
1696
+ return rendered[: SESSION_COMMAND_OUTPUT_LIMIT - len(truncation_marker)] + truncation_marker
1697
+ return rendered
1698
+
1699
+
1700
+ def _direct_read_only_session_result(command_line: str) -> str | None:
1701
+ """Return source payloads for read-only routes that already expose strings.
1702
+
1703
+ Sending JSON through ``Rich Console.print`` can insert display-width line
1704
+ breaks inside quoted values. Returning these tool payloads directly keeps
1705
+ `/harness status`, `/harness score`, and `/actions` machine-parseable while
1706
+ the capture path remains available for slash handlers with no return value.
1707
+ """
1708
+ parts = (command_line or "").strip().split(maxsplit=1)
1709
+ if not parts:
1710
+ return None
1711
+ root = parts[0].lower()
1712
+ arg = parts[1].strip() if len(parts) > 1 else ""
1713
+ if root == "/actions":
1714
+ return available_actions(arg or None)
1715
+ if root == "/hread":
1716
+ return harness_read(arg) if arg else "Error: Usage: /hread <record-id>"
1717
+ if root != "/harness":
1718
+ return None
1719
+ harness_parts = arg.split(maxsplit=1)
1720
+ subcommand = harness_parts[0].lower() if harness_parts else ""
1721
+ if len(harness_parts) > 1:
1722
+ return None
1723
+ if subcommand in {"", "status", "stats", "quality"}:
1724
+ return harness_stats()
1725
+ if subcommand in {"score", "scorecard", "grade", "rating"}:
1726
+ return harness_scorecard()
1727
+ if subcommand in {"compare", "competitive"}:
1728
+ return harness_competitive_rating()
1729
+ return None
1730
+
1731
+
1732
+ def session_command(command: str, cfg: Any = None) -> str:
1733
+ """Execute an Algo CLI slash command as a tool call.
1734
+
1735
+ Use this for session control when the user asks for it or the next step depends
1736
+ on CLI state. Do not call it just to perform normal work: use write_file for
1737
+ edits, run_shell for tests/builds, and session_slash for /read, /ls, /cd, /cwd.
1738
+ If you merely type a slash command in a final answer, it will not execute.
1739
+
1740
+ Prefer idempotent commands with explicit state when available:
1741
+ - /status, /info — inspect model, cwd, context, and active toggles
1742
+ - /cloud on|off|status, /auto on|off|status, /safe on|off|status
1743
+ - /thinking on|off|status, /verify on|off|status, /policy on|off|status
1744
+ - /mode execute|explore|publish|status — switch/check session mode
1745
+ - /reason status|guide — inspect reasoning posture and mode-selection guidance
1746
+ - /reason react|reflexion|tot|got|mcts|qcr|neuro_symbolic|hybrid — set reasoning posture only for complex/failed/ambiguous/verification-heavy work
1747
+ - /reason depth N, /reason branches N — reasoning search-cost parameters
1748
+ - /agent [--pipeline NAME] TASK — run a traceable agent pipeline for larger tasks
1749
+ - /agent team [--roles ROLE,ROLE[,ROLE,ROLE]] TASK — run 2-4 independent read-only specialists, then one verified integration pipeline
1750
+ - /agent threads, /agent show THREAD — inspect persistent parent/child run records
1751
+ - /agent resume THREAD [TASK], /agent fork THREAD TASK — continue or branch prior verified context
1752
+ - /route TASK — preview route/budget/tool policy without running a pipeline
1753
+ - /kernel list, /kernel show NAME — inspect promoted kernel specs without executing workloads
1754
+ - /kernel check [NAME] — validate imports, slash routes, metadata, and active ActionSpecs
1755
+ - /code-rag on|off|status — opt in/out of cwd source indexing and prompt retrieval
1756
+ - /harness status, /harness refresh, /harness embed, /harness score, /harness compare, /hsearch QUERY, /hread ID — harness index
1757
+ - /intelligence status|query TERM|reindex or /intel ... — repository intelligence project graph
1758
+ - /remember FACT, /memories, /forget ID, /lesson TEXT, /lessons reindex
1759
+ - /context status|rebuild|clear — context management
1760
+ - /save NAME, /load NAME — conversation persistence
1761
+ - /temp N, /ctx N, /toolmax N, /thinkevery N — model parameters
1762
+ - /diff, /changes — git/agent activity
1763
+ - /skills, /intuition, /icl, /reflex on|off|status — knowledge/loop controls
1764
+ - /theme NAME, /embed, /vision, /pdf, /reload
1765
+
1766
+ Args:
1767
+ command: Full slash command line, e.g. "/status" or "/mode execute".
1768
+ cfg: Runtime Config (injected automatically, do not set).
1769
+ """
1770
+ if cfg is None:
1771
+ return "Error: session_command must be invoked by the algo CLI runtime (not called directly)."
1772
+ normalized = command.strip()
1773
+ from .slash_dispatch import handle_command, unknown_command_message
1774
+ from .runtime_services import create_client
1775
+ if normalized.lower() == "/agent" or normalized.lower().startswith("/agent "):
1776
+ from .agent_pipeline import agent_execution_active, execute_agent_command
1777
+
1778
+ if agent_execution_active():
1779
+ return "Error: recursive /agent delegation is blocked while an Agent Blocks run is active."
1780
+ client = create_client(cfg)
1781
+ arg = normalized[len("/agent"):].strip()
1782
+ return execute_agent_command(arg, cfg, client)
1783
+ direct_result = _direct_read_only_session_result(normalized)
1784
+ if direct_result is not None:
1785
+ return _captured_session_result(direct_result, normalized)
1786
+ client = create_client(cfg)
1787
+ try:
1788
+ if _session_command_captures_output(normalized):
1789
+ # Capture is context-local rather than a process stdout redirect, so
1790
+ # direct interactive slash commands and unrelated threads keep their
1791
+ # existing console behavior.
1792
+ from . import display as display_module
1793
+
1794
+ with display_module.capture_console_output() as capture:
1795
+ handled, _client = handle_command(normalized, cfg, client)
1796
+ if not handled:
1797
+ return unknown_command_message(normalized)
1798
+ return _captured_session_result(capture.get(), normalized)
1799
+ handled, _client = handle_command(normalized, cfg, client)
1800
+ if not handled:
1801
+ return unknown_command_message(normalized)
1802
+ return f"Executed: {normalized}"
1803
+ except EOFError:
1804
+ return "Executed: exit command (session ended)."
1805
+ except Exception as exc:
1806
+ return f"Error executing {normalized}: {exc}"
1807
+
1808
+
1809
+ def harness_refresh() -> str:
1810
+ """Refresh the local harness index for skills, tools, prompts, memories, and wiki pages."""
1811
+ index = harness.load_index(refresh=True)
1812
+ indexer = str(index.get("indexer") or "python")
1813
+ refresh_stats = index.get("refresh_stats", {})
1814
+ if indexer == "rust":
1815
+ refresh_detail = "Rust full rebuild."
1816
+ else:
1817
+ refresh_detail = (
1818
+ f"Reused: {refresh_stats.get('reused_records', 0)}, "
1819
+ f"rebuilt: {refresh_stats.get('rebuilt_records', 0)}, "
1820
+ f"removed: {refresh_stats.get('removed_records', 0)}."
1821
+ )
1822
+ embeddings = index.get("embeddings") or harness._embeddings_summary(index.get("records", []) or [])
1823
+ embedded_count = int(embeddings.get("embedded_count", 0))
1824
+ pending_count = int(embeddings.get("pending_count", 0))
1825
+ total = embedded_count + pending_count
1826
+ active_model = embeddings.get("active_model") or harness.DEFAULT_EMBED_MODEL
1827
+ if total == 0:
1828
+ embed_detail = "Embeddings: no records to embed."
1829
+ elif embeddings.get("complete"):
1830
+ embed_detail = f"Embeddings: {active_model} ({embedded_count}/{total} ready, embedded by {embeddings.get('embedded_by', 'python')})."
1831
+ else:
1832
+ embed_detail = (
1833
+ f"Embeddings: {active_model} ({embedded_count}/{total} ready, {pending_count} pending). "
1834
+ f"Run /harness embed or wait for the next chat turn to fill them in."
1835
+ )
1836
+ return (
1837
+ f"Refreshed harness index at {harness.INDEX_PATH}. "
1838
+ f"Indexer: {indexer}. Records: {index.get('record_count', 0)}. {refresh_detail} "
1839
+ f"{embed_detail}"
1840
+ )
1841
+
1842
+
1843
+ def harness_stats() -> str:
1844
+ """Show counts for indexed Codex, Claude, OpenClaw, Mercury, Pi, and shared harness assets."""
1845
+ return json.dumps(harness.stats(), indent=2)
1846
+
1847
+
1848
+ def _scorecard_check(
1849
+ name: str,
1850
+ status: str,
1851
+ evidence: str,
1852
+ recommendation: str = "",
1853
+ *,
1854
+ critical: bool = False,
1855
+ metrics: dict[str, Any] | None = None,
1856
+ ) -> dict[str, Any]:
1857
+ check: dict[str, Any] = {
1858
+ "name": name,
1859
+ "status": status,
1860
+ "evidence": evidence,
1861
+ "recommendation": recommendation,
1862
+ "critical": critical,
1863
+ }
1864
+ if metrics is not None:
1865
+ check["metrics"] = metrics
1866
+ return check
1867
+
1868
+
1869
+ def _object_status(value: Any) -> str:
1870
+ if isinstance(value, dict):
1871
+ return str(value.get("status") or value.get("overall_status") or "")
1872
+ return str(getattr(value, "status", getattr(value, "overall_status", "")) or "")
1873
+
1874
+
1875
+ def _collect_harness_index_integrity() -> dict[str, Any]:
1876
+ """Validate the persisted index contract from raw records, not status labels."""
1877
+ try:
1878
+ index = harness.load_index()
1879
+ raw_records = index.get("records", [])
1880
+ records = [record for record in raw_records if isinstance(record, dict)]
1881
+ ids = [str(record.get("id") or "") for record in records]
1882
+ declared_count = int(index.get("record_count", -1))
1883
+ embedding_dimensions: dict[str, set[int]] = {}
1884
+ malformed_embeddings = 0
1885
+ for record in records:
1886
+ raw_embedding = record.get("embedding")
1887
+ if raw_embedding is None:
1888
+ continue
1889
+ embedding_model = str(record.get("embedding_model") or "").strip()
1890
+ if (
1891
+ not isinstance(raw_embedding, list)
1892
+ or not raw_embedding
1893
+ or not embedding_model
1894
+ ):
1895
+ malformed_embeddings += 1
1896
+ continue
1897
+ try:
1898
+ finite = all(math.isfinite(float(value)) for value in raw_embedding)
1899
+ except (TypeError, ValueError):
1900
+ finite = False
1901
+ if not finite:
1902
+ malformed_embeddings += 1
1903
+ continue
1904
+ embedding_dimensions.setdefault(embedding_model, set()).add(len(raw_embedding))
1905
+ required_fields_present = all(
1906
+ record.get("id") and record.get("harness") and record.get("kind") and record.get("path")
1907
+ for record in records
1908
+ )
1909
+ checks = {
1910
+ "nonempty": bool(records),
1911
+ "record_count_matches": declared_count == len(records) == len(raw_records),
1912
+ "unique_ids": bool(ids) and len(ids) == len(set(ids)) and all(ids),
1913
+ "required_fields_present": required_fields_present,
1914
+ "embedding_vectors_well_formed": malformed_embeddings == 0,
1915
+ "embedding_dimensions_consistent": all(
1916
+ len(dimensions) == 1 for dimensions in embedding_dimensions.values()
1917
+ ),
1918
+ "generated_present": bool(str(index.get("generated") or "").strip()),
1919
+ "roots_present": bool(index.get("roots")),
1920
+ "source_current": not harness.index_is_stale(allow_cached=True),
1921
+ }
1922
+ fingerprint_rows = [
1923
+ (
1924
+ record.get("id"),
1925
+ record.get("file_size"),
1926
+ record.get("file_mtime_ns"),
1927
+ record.get("embedding_model"),
1928
+ len(record.get("embedding") or []),
1929
+ )
1930
+ for record in records
1931
+ ]
1932
+ fingerprint = hashlib.sha256(
1933
+ json.dumps(fingerprint_rows, ensure_ascii=True, separators=(",", ":")).encode("utf-8")
1934
+ ).hexdigest()[:16]
1935
+ failed = sorted(name for name, passed in checks.items() if not passed)
1936
+ return {
1937
+ "status": "pass" if not failed else "fail",
1938
+ "record_count": len(records),
1939
+ "declared_count": declared_count,
1940
+ "fingerprint": fingerprint,
1941
+ "embedding_dimensions": {
1942
+ model: sorted(dimensions)
1943
+ for model, dimensions in sorted(embedding_dimensions.items())
1944
+ },
1945
+ "malformed_embeddings": malformed_embeddings,
1946
+ "checks": checks,
1947
+ "failed": failed,
1948
+ }
1949
+ except Exception as exc:
1950
+ return {
1951
+ "status": "error",
1952
+ "record_count": 0,
1953
+ "fingerprint": "",
1954
+ "checks": {},
1955
+ "failed": [type(exc).__name__],
1956
+ }
1957
+
1958
+
1959
+ def harness_scorecard() -> str:
1960
+ """Run the evidence-backed v2 harness scorecard and return structured JSON.
1961
+
1962
+ Ten scored gates are worth one point each. A 10/10 therefore requires
1963
+ current index/embedding evidence, retrieval correctness, a repeatable
1964
+ benchmark, and proof that every required production algorithm actually ran.
1965
+ Optional cloud/Web and Google availability remain visible but unscored so a
1966
+ healthy local-first runtime is not penalized for intentionally absent creds.
1967
+ """
1968
+ from . import action_registry
1969
+ from .evals.algorithm_effectiveness import (
1970
+ PROBE_NAME,
1971
+ PROBE_SCHEMA_VERSION,
1972
+ REQUIRED_CHECKS,
1973
+ run_algorithm_effectiveness_probe,
1974
+ )
1975
+ from .evals.harness_retrieval_benchmark import (
1976
+ BENCHMARK_VERSION,
1977
+ MAX_WARM_MAD_RATIO,
1978
+ MIN_REUSABLE_SPEEDUP,
1979
+ run_harness_retrieval_benchmark,
1980
+ )
1981
+ from .evals.scorecard_grading import finalize_scorecard
1982
+
1983
+ checks: list[dict[str, Any]] = []
1984
+ capabilities: list[dict[str, Any]] = []
1985
+
1986
+ integrity = _collect_harness_index_integrity()
1987
+ integrity_status = str(integrity.get("status") or "error")
1988
+ checks.append(
1989
+ _scorecard_check(
1990
+ "index integrity",
1991
+ integrity_status,
1992
+ (
1993
+ f"records={integrity.get('record_count', 0)} "
1994
+ f"fingerprint={integrity.get('fingerprint') or '-'} "
1995
+ f"failed={integrity.get('failed', [])}"
1996
+ ),
1997
+ "Refresh the index and repair duplicate/malformed/stale records." if integrity_status != "pass" else "",
1998
+ critical=True,
1999
+ metrics=integrity,
2000
+ )
2001
+ )
2002
+
2003
+ stats_error = ""
2004
+ try:
2005
+ stats = harness.stats()
2006
+ except Exception as exc:
2007
+ stats = {}
2008
+ stats_error = type(exc).__name__
2009
+ quality_value = stats.get("quality")
2010
+ embeddings_value = stats.get("embeddings")
2011
+ echo_value = stats.get("echo_veil")
2012
+ runtime_store_value = stats.get("runtime_event_store")
2013
+ quality = quality_value if isinstance(quality_value, dict) else {}
2014
+ embeddings = embeddings_value if isinstance(embeddings_value, dict) else {}
2015
+ echo_readiness = echo_value if isinstance(echo_value, dict) else {}
2016
+ runtime_store = runtime_store_value if isinstance(runtime_store_value, dict) else {}
2017
+
2018
+ embedding_fields = (
2019
+ stats.get("record_count"),
2020
+ embeddings.get("embedded_count"),
2021
+ embeddings.get("pending_count"),
2022
+ embeddings.get("high_value_pending"),
2023
+ )
2024
+ if stats_error or any(value is None for value in embedding_fields):
2025
+ status = "error" if stats_error else "unavailable"
2026
+ total = embedded_count = pending_count = high_value_pending = 0
2027
+ else:
2028
+ total = int(embedding_fields[0] or 0)
2029
+ embedded_count = int(embedding_fields[1] or 0)
2030
+ pending_count = int(embedding_fields[2] or 0)
2031
+ high_value_pending = int(embedding_fields[3] or 0)
2032
+ counts_consistent = embedded_count + pending_count == total
2033
+ if (
2034
+ total > 0
2035
+ and counts_consistent
2036
+ and embedded_count == total
2037
+ and pending_count == 0
2038
+ and high_value_pending == 0
2039
+ and embeddings.get("complete") is True
2040
+ ):
2041
+ status = "pass"
2042
+ elif embedded_count > 0 and counts_consistent and high_value_pending == 0:
2043
+ status = "warn"
2044
+ else:
2045
+ status = "fail"
2046
+ checks.append(
2047
+ _scorecard_check(
2048
+ "embedding readiness",
2049
+ status,
2050
+ (
2051
+ f"model={embeddings.get('active_model') or '-'} "
2052
+ f"embedded={embedded_count}/{total} pending={pending_count} "
2053
+ f"high_value_pending={high_value_pending}"
2054
+ ),
2055
+ "Run /harness embed until active-model and high-value coverage are complete." if status != "pass" else "",
2056
+ metrics={
2057
+ "total": total,
2058
+ "embedded": embedded_count,
2059
+ "pending": pending_count,
2060
+ "high_value_pending": high_value_pending,
2061
+ "complete": embeddings.get("complete"),
2062
+ },
2063
+ )
2064
+ )
2065
+
2066
+ memory_value = quality.get("memory_records")
2067
+ curated_memory_value = quality.get("curated_product_memory_records")
2068
+ wiki_value = quality.get("wiki_records")
2069
+ required_value = quality.get("required_product_memory_categories")
2070
+ covered_value = quality.get("covered_product_memory_categories")
2071
+ missing_value = quality.get("missing_product_memory_categories")
2072
+ echo_fields_ready = all(
2073
+ key in echo_readiness
2074
+ for key in (
2075
+ "installed",
2076
+ "enabled",
2077
+ "write_wired",
2078
+ "retrieval_wired",
2079
+ "persistence_wired",
2080
+ "readiness_source",
2081
+ "runtime",
2082
+ )
2083
+ )
2084
+ echo_enabled = bool(echo_readiness.get("enabled"))
2085
+ echo_stages = {
2086
+ "write": bool(echo_readiness.get("write_wired")),
2087
+ "retrieval": bool(echo_readiness.get("retrieval_wired")),
2088
+ "persistence": bool(echo_readiness.get("persistence_wired")),
2089
+ }
2090
+ echo_safe = (
2091
+ not echo_enabled
2092
+ or (
2093
+ bool(echo_readiness.get("installed"))
2094
+ and all(echo_stages.values())
2095
+ )
2096
+ )
2097
+ runtime_store_fields_ready = all(
2098
+ key in runtime_store
2099
+ for key in (
2100
+ "status",
2101
+ "initialized",
2102
+ "directory_private",
2103
+ "file_private",
2104
+ "lock_private",
2105
+ "compaction_needed",
2106
+ )
2107
+ )
2108
+ runtime_store_safe = bool(
2109
+ runtime_store_fields_ready
2110
+ and runtime_store.get("status") in {"ready", "empty"}
2111
+ and runtime_store.get("directory_private") is True
2112
+ and runtime_store.get("lock_private") is True
2113
+ and (
2114
+ runtime_store.get("initialized") is not True
2115
+ or runtime_store.get("file_private") is True
2116
+ )
2117
+ and runtime_store.get("compaction_needed") is False
2118
+ )
2119
+ if (
2120
+ memory_value is None
2121
+ or curated_memory_value is None
2122
+ or wiki_value is None
2123
+ or not isinstance(required_value, list)
2124
+ or not isinstance(covered_value, list)
2125
+ or not isinstance(missing_value, list)
2126
+ or not echo_fields_ready
2127
+ or not runtime_store_fields_ready
2128
+ ):
2129
+ status = "unavailable"
2130
+ memory_records = curated_memory_records = wiki_records = 0
2131
+ required_categories: list[str] = []
2132
+ covered_categories: list[str] = []
2133
+ missing_categories: list[str] = []
2134
+ else:
2135
+ memory_records, wiki_records = int(memory_value), int(wiki_value)
2136
+ curated_memory_records = int(curated_memory_value)
2137
+ required_categories = [str(item) for item in required_value]
2138
+ covered_categories = [str(item) for item in covered_value]
2139
+ missing_categories = [str(item) for item in missing_value]
2140
+ required_set = set(required_categories)
2141
+ covered_set = set(covered_categories)
2142
+ category_coverage = len(required_set & covered_set)
2143
+ if not echo_safe or not runtime_store_safe:
2144
+ status = "fail"
2145
+ elif (
2146
+ required_set
2147
+ and required_set <= covered_set
2148
+ and not missing_categories
2149
+ and curated_memory_records >= len(required_set)
2150
+ and wiki_records >= 5
2151
+ ):
2152
+ status = "pass"
2153
+ elif category_coverage >= max(1, len(required_set) - 1) and wiki_records >= 2:
2154
+ status = "warn"
2155
+ else:
2156
+ status = "fail"
2157
+ checks.append(
2158
+ _scorecard_check(
2159
+ "project memory/wiki coverage",
2160
+ status,
2161
+ (
2162
+ f"product_memory={memory_records} curated={curated_memory_records} "
2163
+ f"categories={len(covered_categories)}/{len(required_categories)} "
2164
+ f"missing={missing_categories} wiki={wiki_records} "
2165
+ f"echo_enabled={echo_enabled} echo_stages={echo_stages} "
2166
+ f"private_store={runtime_store.get('status') or 'unknown'}"
2167
+ ),
2168
+ (
2169
+ "Cover every required product-memory category with a curated contract, "
2170
+ "retain at least five curated wiki records, and disable Echo Veil until "
2171
+ "its write/retrieval/persistence stages are all operational. Keep the runtime "
2172
+ "event store private, bounded, and compact; then refresh."
2173
+ if status != "pass"
2174
+ else ""
2175
+ ),
2176
+ critical=True,
2177
+ metrics={
2178
+ "product_memory_records": memory_records,
2179
+ "curated_product_memory_records": curated_memory_records,
2180
+ "required_categories": required_categories,
2181
+ "covered_categories": covered_categories,
2182
+ "missing_categories": missing_categories,
2183
+ "wiki_records": wiki_records,
2184
+ "echo_installed": bool(echo_readiness.get("installed")),
2185
+ "echo_enabled": echo_enabled,
2186
+ "echo_stages": echo_stages,
2187
+ "echo_readiness_source": echo_readiness.get("readiness_source"),
2188
+ "echo_runtime": echo_readiness.get("runtime"),
2189
+ "runtime_event_store": runtime_store,
2190
+ },
2191
+ )
2192
+ )
2193
+
2194
+ extension_value = quality.get("extension_share")
2195
+ project_value = quality.get("project_specific_share")
2196
+ if extension_value is None or project_value is None:
2197
+ status = "unavailable"
2198
+ extension_share = project_share = 0.0
2199
+ else:
2200
+ extension_share, project_share = float(extension_value), float(project_value)
2201
+ if extension_share <= 0.5 and project_share >= 0.2:
2202
+ status = "pass"
2203
+ elif extension_share <= 0.7 and project_share >= 0.1:
2204
+ status = "warn"
2205
+ else:
2206
+ status = "fail"
2207
+ checks.append(
2208
+ _scorecard_check(
2209
+ "corpus signal balance",
2210
+ status,
2211
+ f"project_share={project_share:.3f} extension_share={extension_share:.3f}",
2212
+ "Increase curated project signal or reduce generic extension dominance." if status != "pass" else "",
2213
+ )
2214
+ )
2215
+
2216
+ try:
2217
+ meta_results = harness.search_index("rate your harness", limit=5)
2218
+ meta_ids = [str(record.get("id", "")) for record in meta_results if isinstance(record, dict)]
2219
+ if meta_ids and meta_ids[0] == "algo-cli:algorithm:ALGO.md":
2220
+ status = "pass"
2221
+ elif "algo-cli:algorithm:ALGO.md" in meta_ids:
2222
+ status = "warn"
2223
+ else:
2224
+ status = "fail"
2225
+ except Exception as exc:
2226
+ meta_ids = []
2227
+ status = "error"
2228
+ meta_ids.append(type(exc).__name__)
2229
+ checks.append(
2230
+ _scorecard_check(
2231
+ "meta-query retrieval",
2232
+ status,
2233
+ f"top_ids={meta_ids[:5]}",
2234
+ "Ensure the reviewed ALGO record is the stable top self-evaluation result." if status != "pass" else "",
2235
+ critical=True,
2236
+ )
2237
+ )
2238
+
2239
+ try:
2240
+ kg_text = str(query_knowledge_graph("rate your harness"))
2241
+ canonical_match = re.search(r"(?<![\w-])project:algo-cli(?![\w-])", kg_text) is not None
2242
+ if canonical_match:
2243
+ status = "pass"
2244
+ elif "No matching canonicals" in kg_text:
2245
+ status = "fail"
2246
+ else:
2247
+ status = "warn"
2248
+ except Exception as exc:
2249
+ kg_text = type(exc).__name__
2250
+ status = "error"
2251
+ checks.append(
2252
+ _scorecard_check(
2253
+ "knowledge graph",
2254
+ status,
2255
+ kg_text[:240],
2256
+ "Reindex the graph and verify the exact project:algo-cli canonical." if status != "pass" else "",
2257
+ )
2258
+ )
2259
+
2260
+ try:
2261
+ audit = action_registry.audit_action_registry_runtime()
2262
+ audit_status = str(getattr(audit, "overall_status", "") or "")
2263
+ status = "pass" if audit_status == "ready" else "fail"
2264
+ except Exception as exc:
2265
+ audit_status = type(exc).__name__
2266
+ status = "error"
2267
+ checks.append(
2268
+ _scorecard_check(
2269
+ "action registry runtime audit",
2270
+ status,
2271
+ f"overall_status={audit_status or 'unknown'}",
2272
+ "Run /selfcheck and repair ActionSpec/tool/slash coverage." if status != "pass" else "",
2273
+ critical=True,
2274
+ )
2275
+ )
2276
+
2277
+ required_harness_commands = {
2278
+ "/harness status",
2279
+ "/harness refresh",
2280
+ "/harness embed",
2281
+ "/harness score",
2282
+ "/harness compare",
2283
+ }
2284
+ try:
2285
+ harness_actions = json.loads(available_actions("harness"))
2286
+ harness_commands = set(harness_actions.get("commands", {}).get("harness", []))
2287
+ missing_harness_commands = sorted(required_harness_commands - harness_commands)
2288
+ direct_status = _direct_read_only_session_result("/harness status")
2289
+ status_payload = json.loads(direct_status or "")
2290
+ payload_contract = isinstance(status_payload, dict) and isinstance(status_payload.get("embeddings"), dict)
2291
+ capture_contract = all(
2292
+ _session_command_captures_output(command)
2293
+ for command in ("/harness score", "/harness compare")
2294
+ )
2295
+ status = "pass" if not missing_harness_commands and payload_contract and capture_contract else "fail"
2296
+ except Exception as exc:
2297
+ harness_commands = set()
2298
+ missing_harness_commands = sorted(required_harness_commands)
2299
+ payload_contract = capture_contract = False
2300
+ status = "error"
2301
+ direct_status = type(exc).__name__
2302
+ checks.append(
2303
+ _scorecard_check(
2304
+ "harness maintenance loop",
2305
+ status,
2306
+ (
2307
+ f"commands={sorted(required_harness_commands & harness_commands)} "
2308
+ f"payload_json={payload_contract} capture={capture_contract}"
2309
+ ),
2310
+ "Restore maintenance commands and machine-parseable read-only payloads." if status != "pass" else "",
2311
+ )
2312
+ )
2313
+
2314
+ try:
2315
+ benchmark = run_harness_retrieval_benchmark()
2316
+ status = str(benchmark.get("status") or "error")
2317
+ correctness_value = benchmark.get("correctness")
2318
+ performance_value = benchmark.get("performance")
2319
+ evidence_value = benchmark.get("evidence")
2320
+ correctness = correctness_value if isinstance(correctness_value, dict) else {}
2321
+ performance = performance_value if isinstance(performance_value, dict) else {}
2322
+ benchmark_evidence = evidence_value if isinstance(evidence_value, dict) else {}
2323
+ try:
2324
+ measured_speedup = float(str(performance.get("speedup")))
2325
+ measured_mad_ratio = float(str(performance.get("warm_mad_ratio")))
2326
+ except (TypeError, ValueError):
2327
+ measured_speedup = measured_mad_ratio = math.nan
2328
+ benchmark_contract = (
2329
+ benchmark.get("benchmark_version") == BENCHMARK_VERSION
2330
+ and correctness.get("passed") is True
2331
+ and correctness.get("stable_rankings") is True
2332
+ and math.isfinite(measured_speedup)
2333
+ and measured_speedup >= MIN_REUSABLE_SPEEDUP
2334
+ and math.isfinite(measured_mad_ratio)
2335
+ and measured_mad_ratio <= MAX_WARM_MAD_RATIO
2336
+ and bool(benchmark_evidence.get("index_digest"))
2337
+ )
2338
+ if status == "pass" and not benchmark_contract:
2339
+ status = "fail"
2340
+ benchmark["scorecard_contract_error"] = "pass payload lacked required correctness/performance evidence"
2341
+ except Exception as exc:
2342
+ benchmark = {"status": "error", "reason": type(exc).__name__}
2343
+ status = "error"
2344
+ checks.append(
2345
+ _scorecard_check(
2346
+ "retrieval benchmark",
2347
+ status,
2348
+ json.dumps(benchmark, sort_keys=True, default=str)[:1000],
2349
+ "Repair retrieval correctness or investigate the measured reusable-index regression." if status != "pass" else "",
2350
+ critical=True,
2351
+ metrics=benchmark,
2352
+ )
2353
+ )
2354
+
2355
+ try:
2356
+ algorithm_report = run_algorithm_effectiveness_probe()
2357
+ status = str(algorithm_report.get("status") or "error")
2358
+ probe_checks_value = algorithm_report.get("checks")
2359
+ probe_summary_value = algorithm_report.get("summary")
2360
+ probe_checks = probe_checks_value if isinstance(probe_checks_value, dict) else {}
2361
+ probe_summary = probe_summary_value if isinstance(probe_summary_value, dict) else {}
2362
+ required_checks = set(REQUIRED_CHECKS)
2363
+ algorithm_contract = (
2364
+ algorithm_report.get("schema_version") == PROBE_SCHEMA_VERSION
2365
+ and algorithm_report.get("probe") == PROBE_NAME
2366
+ and set(algorithm_report.get("required_checks") or ()) == required_checks
2367
+ and required_checks <= set(probe_checks)
2368
+ and all(
2369
+ isinstance(probe_checks.get(name), dict)
2370
+ and probe_checks[name].get("status") == "pass"
2371
+ and probe_checks[name].get("required") is True
2372
+ for name in required_checks
2373
+ )
2374
+ and int(probe_summary.get("required") or 0) == len(required_checks)
2375
+ and int(probe_summary.get("passed") or 0) == len(required_checks)
2376
+ and int(probe_summary.get("failed") or 0) == 0
2377
+ )
2378
+ if status == "pass" and not algorithm_contract:
2379
+ status = "fail"
2380
+ algorithm_report["scorecard_contract_error"] = "pass payload lacked every required production-path check"
2381
+ except Exception as exc:
2382
+ algorithm_report = {"status": "error", "reason": type(exc).__name__}
2383
+ status = "error"
2384
+ checks.append(
2385
+ _scorecard_check(
2386
+ "algorithm effectiveness",
2387
+ status,
2388
+ json.dumps(algorithm_report, sort_keys=True, default=str)[:1000],
2389
+ "Fix the failing production-path algorithm probe before claiming readiness." if status != "pass" else "",
2390
+ critical=True,
2391
+ metrics=algorithm_report,
2392
+ )
2393
+ )
2394
+
2395
+ try:
2396
+ doctor = action_registry.build_doctor_report(Config())
2397
+ doctor_findings = getattr(doctor, "findings", ()) or ()
2398
+ web_status = ""
2399
+ web_message = "web-tools finding missing"
2400
+ for finding in doctor_findings:
2401
+ area = finding.get("area") if isinstance(finding, dict) else getattr(finding, "area", "")
2402
+ if area == "web-tools":
2403
+ web_status = _object_status(finding)
2404
+ value = finding.get("message") if isinstance(finding, dict) else getattr(finding, "message", "")
2405
+ web_message = str(value or "")
2406
+ break
2407
+ capability_status = "pass" if web_status == "ready" else ("warn" if web_status else "unavailable")
2408
+ except Exception as exc:
2409
+ capability_status = "error"
2410
+ web_message = type(exc).__name__
2411
+ capabilities.append(_scorecard_check("web tools", capability_status, web_message))
2412
+
2413
+ try:
2414
+ google_actions = json.loads(available_actions("google"))
2415
+ google_commands = set(google_actions.get("commands", {}).get("google", []))
2416
+ required_google_fragments = (
2417
+ "/google-login", "/google-callback", "/google-status", "/google drive-list",
2418
+ "/google gmail-list", "/google docs-get", "/google sheets-values", "/google calendar-list",
2419
+ )
2420
+ missing_google = [
2421
+ fragment for fragment in required_google_fragments
2422
+ if not any(command.startswith(fragment) for command in google_commands)
2423
+ ]
2424
+ capability_status = "pass" if not missing_google else "warn"
2425
+ except Exception as exc:
2426
+ google_commands = set()
2427
+ missing_google = [type(exc).__name__]
2428
+ capability_status = "error"
2429
+ capabilities.append(
2430
+ _scorecard_check(
2431
+ "google workspace wiring",
2432
+ capability_status,
2433
+ f"commands={len(google_commands)} missing={missing_google}",
2434
+ )
2435
+ )
2436
+
2437
+ return json.dumps(finalize_scorecard(checks, capabilities=capabilities), indent=2)
2438
+
2439
+
2440
+ def harness_competitive_rating() -> str:
2441
+ """Run local evidence probes and grade the attached cross-harness comparison.
2442
+
2443
+ The report deliberately cannot declare Algo CLI the leader without
2444
+ revision-pinned competitor artifacts and a same-workload protocol. Local
2445
+ benchmark and algorithm receipts are still included so the missing proof is
2446
+ distinguishable from a local regression.
2447
+ """
2448
+ from . import git_evidence
2449
+ from .evals.algorithm_effectiveness import run_algorithm_effectiveness_probe
2450
+ from .evals.competitive_harness_rating import (
2451
+ SUBJECT_PROJECT,
2452
+ build_competitive_harness_report,
2453
+ recompute_comparative_rating,
2454
+ )
2455
+ from .evals.harness_retrieval_benchmark import run_harness_retrieval_benchmark
2456
+
2457
+ rating = recompute_comparative_rating()
2458
+ projects = [str(row["project"]) for row in rating.get("ranking", [])]
2459
+
2460
+ try:
2461
+ benchmark = run_harness_retrieval_benchmark()
2462
+ except Exception as exc:
2463
+ benchmark = {"status": "error", "reason": f"{type(exc).__name__}: {exc}"}
2464
+ benchmark_json = json.dumps(benchmark, sort_keys=True, default=str)
2465
+ performance = benchmark.get("performance") if isinstance(benchmark, dict) else {}
2466
+ if not isinstance(performance, dict):
2467
+ performance = {}
2468
+ local_run_count = min(
2469
+ int(performance.get("cold_sample_count") or 0),
2470
+ int(performance.get("warm_sample_count") or 0),
2471
+ )
2472
+
2473
+ try:
2474
+ algorithms = run_algorithm_effectiveness_probe()
2475
+ except Exception as exc:
2476
+ algorithms = {"status": "error", "reason": f"{type(exc).__name__}: {exc}"}
2477
+ algorithm_json = json.dumps(algorithms, sort_keys=True, default=str)
2478
+ required = [str(item) for item in algorithms.get("required_checks", [])]
2479
+ raw_checks = algorithms.get("checks")
2480
+ checks = raw_checks if isinstance(raw_checks, dict) else {}
2481
+ receipts = {
2482
+ name: "sha256:"
2483
+ + hashlib.sha256(
2484
+ json.dumps(checks.get(name, {}), sort_keys=True, default=str).encode("utf-8")
2485
+ ).hexdigest()
2486
+ for name in required
2487
+ }
2488
+ summary = algorithms.get("summary") if isinstance(algorithms, dict) else {}
2489
+ if not isinstance(summary, dict):
2490
+ summary = {}
2491
+
2492
+ snapshot = git_evidence.capture_git_snapshot()
2493
+ clean = git_evidence.snapshot_is_clean(snapshot) if snapshot.available else None
2494
+ local_verification_digest = "sha256:" + hashlib.sha256(
2495
+ f"{benchmark_json}\n{algorithm_json}".encode("utf-8")
2496
+ ).hexdigest()
2497
+ local_evidence: dict[str, Any] = {
2498
+ "benchmark": {
2499
+ "status": str(benchmark.get("status") or "error"),
2500
+ "protocol": str(benchmark.get("benchmark_version") or "local-retrieval-benchmark"),
2501
+ "artifact_digest": "sha256:" + hashlib.sha256(benchmark_json.encode("utf-8")).hexdigest(),
2502
+ "projects": [SUBJECT_PROJECT],
2503
+ "runs_per_project": local_run_count,
2504
+ },
2505
+ "algorithms": {
2506
+ "status": str(algorithms.get("status") or "error"),
2507
+ "probe": str(algorithms.get("probe") or "algorithm-effectiveness"),
2508
+ "artifact_digest": "sha256:" + hashlib.sha256(algorithm_json.encode("utf-8")).hexdigest(),
2509
+ "required_checks": len(required),
2510
+ "passed_checks": int(summary.get("passed") or 0),
2511
+ "algorithm_ids": required,
2512
+ "production_receipts": receipts,
2513
+ },
2514
+ "release": {
2515
+ "status": (
2516
+ "pass"
2517
+ if clean is True
2518
+ and benchmark.get("status") == "pass"
2519
+ and algorithms.get("status") == "pass"
2520
+ else "fail"
2521
+ ),
2522
+ "commit": snapshot.head or "",
2523
+ "verification_artifact": local_verification_digest,
2524
+ },
2525
+ }
2526
+ report = build_competitive_harness_report(
2527
+ evidence=local_evidence,
2528
+ worktree_clean=clean,
2529
+ competitor_evidence_complete=False,
2530
+ )
2531
+ report["local_probe_artifacts"] = {
2532
+ "retrieval_benchmark": benchmark,
2533
+ "algorithm_effectiveness": algorithms,
2534
+ "warning": (
2535
+ "Local evidence is not cross-harness evidence. Leadership remains blocked until every "
2536
+ f"project ({', '.join(projects)}) is revision-pinned and run under one protocol."
2537
+ ),
2538
+ }
2539
+ return json.dumps(report, indent=2, sort_keys=True)
2540
+
2541
+
2542
+ def harness_search(query: str, harness_name: str | None = None, kind: str | None = None, limit: int = 10) -> str:
2543
+ """Search local harness assets.
2544
+
2545
+ Args:
2546
+ query: Search terms.
2547
+ harness_name: Optional harness filter: codex, claude, openclaw, openclaude, mercury, pi, agents.
2548
+ kind: Optional kind filter: skill, tool, prompt, memory, wiki, workflow, extension.
2549
+ limit: Maximum records.
2550
+ """
2551
+ results = harness.search_index(query, harness_name, kind, limit)
2552
+ if not results:
2553
+ return "No harness matches."
2554
+ lines = []
2555
+ for record in results:
2556
+ lines.append(
2557
+ f"- {record['id']}\n"
2558
+ f" title: {record['title']}\n"
2559
+ f" path: {record['path']}\n"
2560
+ f" summary: {record.get('description') or record.get('summary', '')[:220]}"
2561
+ )
2562
+ return "\n".join(lines)
2563
+
2564
+
2565
+ def harness_read(record_id: str, max_chars: int = 20_000) -> str:
2566
+ """Read one indexed harness asset by id from harness_search.
2567
+
2568
+ Args:
2569
+ record_id: Exact record id returned by harness_search.
2570
+ max_chars: Maximum characters to return.
2571
+ """
2572
+ return harness.read_record(record_id, _bounded_int(max_chars, 20_000, 1, 50_000))
2573
+
2574
+
2575
+ _KG_HARNESS_META_TERMS = {
2576
+ "assess",
2577
+ "audit",
2578
+ "capabilities",
2579
+ "capability",
2580
+ "evaluate",
2581
+ "evaluation",
2582
+ "grade",
2583
+ "rate",
2584
+ "rating",
2585
+ "score",
2586
+ "selfcheck",
2587
+ }
2588
+
2589
+
2590
+ def _knowledge_graph_query_expansion(question: str) -> str | None:
2591
+ terms = {
2592
+ term.lower()
2593
+ for term in re.findall(r"[\w.-]+", question or "")
2594
+ if len(term) > 1
2595
+ }
2596
+ if "harness" in terms and terms & _KG_HARNESS_META_TERMS:
2597
+ return "Algo CLI harness self-evaluation capability audit"
2598
+ return None
2599
+
2600
+
2601
+ def query_knowledge_graph(question: str, limit: int = 10) -> str:
2602
+ """Query the local index-compute-lab ranked association graph.
2603
+
2604
+ Returns co-occurring entities and relationship counts (not prose bios).
2605
+ For biography-style questions, use harness_search on user-configured sources
2606
+ and verify important details against live files or authoritative sources.
2607
+
2608
+ Args:
2609
+ question: Natural-language question about entities, projects, or relationships.
2610
+ limit: Maximum ranked neighbors or results to return, between 1 and 20.
2611
+ """
2612
+ text = (question or "").strip()
2613
+ if not text:
2614
+ return "Error: knowledge graph question was empty."
2615
+ output = _index_compute_lab.run_ask(text, limit=limit, timeout=20.0)
2616
+ if output and "no matching canonicals" in output.lower():
2617
+ expanded = _knowledge_graph_query_expansion(text)
2618
+ if expanded and expanded != text:
2619
+ fallback = _index_compute_lab.run_ask(expanded, limit=limit, timeout=20.0)
2620
+ if fallback and "no matching canonicals" not in fallback.lower() and not fallback.startswith("Error:"):
2621
+ output = fallback
2622
+ return _cap(output or "No knowledge graph results.")
2623
+
2624
+
2625
+ def reindex_knowledge_graph(
2626
+ include_removable_seed: bool = False,
2627
+ removable_scope: str | None = None,
2628
+ ) -> str:
2629
+ """Rebuild index-compute-lab ranked graph (association → normalize → rank).
2630
+
2631
+ Use only when the user explicitly asks to rebuild configured graph sources.
2632
+ include_removable_seed runs removable_drive_atoms.py first; pass removable_scope
2633
+ to limit the scan to one user-provided path.
2634
+ After completion, run harness_refresh in the same session.
2635
+
2636
+ Args:
2637
+ include_removable_seed: When true, re-seed removable-drive atoms before the pipeline.
2638
+ removable_scope: Optional single --scope path for removable_drive_atoms.py.
2639
+ """
2640
+ scopes = [removable_scope.strip()] if removable_scope and removable_scope.strip() else None
2641
+ output = _index_compute_lab.run_pipeline(
2642
+ include_removable_seed=include_removable_seed,
2643
+ removable_scopes=scopes,
2644
+ )
2645
+ return _cap(output)
2646
+
2647
+
2648
+ def write_knowledge_graph_note(title: str, body: str) -> str:
2649
+ """Write a markdown note under index-compute-lab/atoms/agent-notes/ for harness RAG.
2650
+
2651
+ Use when the user states a fact that should be retrievable before the next full reindex
2652
+ (contacts, project aliases, corrections). Follow with harness_refresh.
2653
+
2654
+ Args:
2655
+ title: Short note title (used as filename slug).
2656
+ body: Markdown body (one or more paragraphs).
2657
+ """
2658
+ return _index_compute_lab.write_graph_note(title, body)
2659
+
2660
+
2661
+ def model_pull(name: str) -> str:
2662
+ """Pull a model from the Ollama registry.
2663
+
2664
+ Args:
2665
+ name: Model name to pull, e.g. 'llama3.2' or 'nomic-embed-text'.
2666
+ """
2667
+ client = active_ollama_client()
2668
+ try:
2669
+ last_status = ""
2670
+ for progress in client.pull(name, stream=True):
2671
+ status = getattr(progress, "status", None) or (progress.get("status") if isinstance(progress, dict) else None)
2672
+ if status:
2673
+ last_status = str(status)
2674
+ return f"Pulled {name}: {last_status or 'complete'}"
2675
+ except Exception as exc:
2676
+ return f"Error pulling {name}: {exc}"
2677
+
2678
+
2679
+ def model_delete(name: str) -> str:
2680
+ """Delete a local Ollama model. This is irreversible and requires approval.
2681
+
2682
+ Args:
2683
+ name: Exact model name to delete, e.g. 'llama3.2:latest'.
2684
+ """
2685
+ client = active_ollama_client()
2686
+ try:
2687
+ client.delete(name)
2688
+ return f"Deleted model: {name}"
2689
+ except Exception as exc:
2690
+ return f"Error deleting {name}: {exc}"
2691
+
2692
+
2693
+ def _parse_modelfile(modelfile: str) -> dict[str, Any]:
2694
+ """Translate common Modelfile directives to the current Ollama SDK API."""
2695
+ directives: list[tuple[str, str, int]] = []
2696
+ lines = modelfile.splitlines()
2697
+ index = 0
2698
+ while index < len(lines):
2699
+ line_number = index + 1
2700
+ stripped = lines[index].strip()
2701
+ index += 1
2702
+ if not stripped or stripped.startswith("#"):
2703
+ continue
2704
+ match = re.match(r"^([A-Za-z]+)(?:\s+(.*))?$", stripped)
2705
+ if match is None or not (match.group(2) or "").strip():
2706
+ raise ValueError(f"invalid Modelfile instruction on line {line_number}")
2707
+ instruction = match.group(1).upper()
2708
+ value = (match.group(2) or "").strip()
2709
+ if value.startswith('"""'):
2710
+ first_segment = value[3:]
2711
+ segments = [first_segment]
2712
+ while True:
2713
+ closing = segments[-1].find('"""')
2714
+ if closing >= 0:
2715
+ trailing = segments[-1][closing + 3 :].strip()
2716
+ segments[-1] = segments[-1][:closing]
2717
+ if trailing and not trailing.startswith("#"):
2718
+ raise ValueError(
2719
+ f"unexpected text after multiline value on line {index}"
2720
+ )
2721
+ break
2722
+ if index >= len(lines):
2723
+ raise ValueError(
2724
+ f"unterminated multiline {instruction} starting on line {line_number}"
2725
+ )
2726
+ segments.append(lines[index])
2727
+ index += 1
2728
+ value = "\n".join(segments)
2729
+ if not first_segment and value.startswith("\n"):
2730
+ value = value[1:]
2731
+ value = value.rstrip("\n")
2732
+ directives.append((instruction, value, line_number))
2733
+
2734
+ create_args: dict[str, Any] = {}
2735
+ parameters: dict[str, Any] = {}
2736
+ messages: list[dict[str, str]] = []
2737
+ licenses: list[str] = []
2738
+ for instruction, value, line_number in directives:
2739
+ if instruction == "FROM":
2740
+ if "from_" in create_args:
2741
+ raise ValueError(f"duplicate FROM instruction on line {line_number}")
2742
+ create_args["from_"] = value
2743
+ elif instruction in {"SYSTEM", "TEMPLATE"}:
2744
+ key = instruction.lower()
2745
+ if key in create_args:
2746
+ raise ValueError(
2747
+ f"duplicate {instruction} instruction on line {line_number}"
2748
+ )
2749
+ create_args[key] = value
2750
+ elif instruction == "PARAMETER":
2751
+ parts = value.split(maxsplit=1)
2752
+ if len(parts) != 2:
2753
+ raise ValueError(f"invalid PARAMETER instruction on line {line_number}")
2754
+ parameter_name, raw_value = parts
2755
+ try:
2756
+ parameter_value: Any = json.loads(raw_value)
2757
+ except json.JSONDecodeError:
2758
+ parameter_value = raw_value
2759
+ existing = parameters.get(parameter_name)
2760
+ if parameter_name not in parameters:
2761
+ parameters[parameter_name] = parameter_value
2762
+ elif isinstance(existing, list):
2763
+ existing.append(parameter_value)
2764
+ else:
2765
+ parameters[parameter_name] = [existing, parameter_value]
2766
+ elif instruction == "MESSAGE":
2767
+ parts = value.split(maxsplit=1)
2768
+ if len(parts) != 2:
2769
+ raise ValueError(f"invalid MESSAGE instruction on line {line_number}")
2770
+ role, content = parts
2771
+ messages.append({"role": role.lower(), "content": content})
2772
+ elif instruction == "LICENSE":
2773
+ licenses.append(value)
2774
+ else:
2775
+ raise ValueError(
2776
+ f"unsupported Modelfile instruction {instruction!r} on line {line_number}"
2777
+ )
2778
+
2779
+ if not create_args.get("from_"):
2780
+ raise ValueError("Modelfile requires a FROM instruction")
2781
+ if parameters:
2782
+ create_args["parameters"] = parameters
2783
+ if messages:
2784
+ create_args["messages"] = messages
2785
+ if licenses:
2786
+ create_args["license"] = licenses[0] if len(licenses) == 1 else licenses
2787
+ return create_args
2788
+
2789
+
2790
+ def model_create(name: str, modelfile: str) -> str:
2791
+ """Create a custom Ollama model from a Modelfile string.
2792
+
2793
+ Use this to bake a persona, system prompt, or parameter set into a reusable
2794
+ local model. Requires approval.
2795
+
2796
+ Args:
2797
+ name: Name for the new model, e.g. 'my-assistant:latest'.
2798
+ modelfile: Full Modelfile content (FROM, SYSTEM, PARAMETER lines).
2799
+ """
2800
+ try:
2801
+ create_args = _parse_modelfile(modelfile)
2802
+ client = active_ollama_client()
2803
+ last_status = ""
2804
+ for progress in client.create(
2805
+ model=name,
2806
+ from_=create_args.get("from_"),
2807
+ template=create_args.get("template"),
2808
+ license=create_args.get("license"),
2809
+ system=create_args.get("system"),
2810
+ parameters=create_args.get("parameters"),
2811
+ messages=create_args.get("messages"),
2812
+ stream=True,
2813
+ ):
2814
+ status = get_attr(progress, "status", "")
2815
+ if status:
2816
+ last_status = str(status)
2817
+ return f"Created model {name}: {last_status or 'complete'}"
2818
+ except Exception as exc:
2819
+ return f"Error creating {name}: {exc}"
2820
+
2821
+
2822
+ def model_copy(source: str, destination: str) -> str:
2823
+ """Copy a local Ollama model to a new name.
2824
+
2825
+ Args:
2826
+ source: Source model name.
2827
+ destination: Destination model name.
2828
+ """
2829
+ client = active_ollama_client()
2830
+ try:
2831
+ client.copy(source, destination)
2832
+ return f"Copied {source} → {destination}"
2833
+ except Exception as exc:
2834
+ return f"Error copying {source} to {destination}: {exc}"
2835
+
2836
+
2837
+ def model_show(name: str) -> str:
2838
+ """Show detailed metadata for a local Ollama model.
2839
+
2840
+ Returns context length, architecture, parameter count, quantization, and capability flags.
2841
+
2842
+ Args:
2843
+ name: Model name to inspect.
2844
+ """
2845
+ client = active_ollama_client()
2846
+ try:
2847
+ response = client.show(name)
2848
+ except Exception as exc:
2849
+ return f"Error showing {name}: {exc}"
2850
+ details = getattr(response, "details", None) or {}
2851
+ raw_info = getattr(response, "model_info", None) or {}
2852
+ if not isinstance(raw_info, dict):
2853
+ try:
2854
+ raw_info = dict(raw_info)
2855
+ except Exception:
2856
+ raw_info = {}
2857
+ ctx_length = None
2858
+ for key, val in raw_info.items():
2859
+ if key.endswith(".context_length") and isinstance(val, int):
2860
+ ctx_length = val
2861
+ break
2862
+ family = getattr(details, "family", None) or (details.get("family") if isinstance(details, dict) else None)
2863
+ param_size = getattr(details, "parameter_size", None) or (details.get("parameter_size") if isinstance(details, dict) else None)
2864
+ quant = getattr(details, "quantization_level", None) or (details.get("quantization_level") if isinstance(details, dict) else None)
2865
+ fmt = getattr(details, "format", None) or (details.get("format") if isinstance(details, dict) else None)
2866
+ payload = {
2867
+ "name": name,
2868
+ "family": str(family or ""),
2869
+ "parameter_size": str(param_size or ""),
2870
+ "quantization": str(quant or ""),
2871
+ "context_length": ctx_length,
2872
+ "format": str(fmt or ""),
2873
+ }
2874
+ return json.dumps(payload, indent=2)
2875
+
2876
+
2877
+
2878
+ def _hide_cfg_param(fn):
2879
+ """Remove the ``cfg`` parameter from the Ollama tool-call schema.
2880
+
2881
+ ``remember`` and ``append_lesson`` accept a runtime-injected ``cfg``
2882
+ (Config instance) that the model should never pass. Without this
2883
+ wrapper the Ollama SDK tries to build a Pydantic model from
2884
+ ``Config | None`` and crashes with "not fully defined".
2885
+ """
2886
+ original_sig = inspect.signature(fn)
2887
+ params = [p for name, p in original_sig.parameters.items() if name != "cfg"]
2888
+ fn.__signature__ = original_sig.replace(parameters=params)
2889
+ return fn
2890
+
2891
+
2892
+ # ---------------------------------------------------------------------------
2893
+ # Plugin / version / credential / URL-scheme tool wrappers
2894
+ # ---------------------------------------------------------------------------
2895
+ # Thin wrappers that expose the four new subsystems as model-callable tools.
2896
+ # The heavy logic lives in the dedicated modules; these just bridge the
2897
+ # tool-calling interface.
2898
+
2899
+
2900
+ def plugins_discover() -> str:
2901
+ """Discover plugins from ~/.algo_cli/plugins/ and return a JSON summary."""
2902
+ from .plugins import discover_plugins
2903
+ return json.dumps([manifest.as_dict() for manifest in discover_plugins()], indent=2, sort_keys=True)
2904
+
2905
+
2906
+ def plugins_load(plugin_name: str) -> str:
2907
+ """Explicitly import a discovered plugin and return its module load status.
2908
+
2909
+ Dynamic action/tool registration is not yet part of the stable runtime API.
2910
+ """
2911
+ from .plugins import discover_plugins, load_plugin
2912
+
2913
+ manifest = next(
2914
+ (item for item in discover_plugins() if item.name.lower() == plugin_name.strip().lower()),
2915
+ None,
2916
+ )
2917
+ if manifest is None:
2918
+ return json.dumps({"loaded": False, "error": f"Plugin not found: {plugin_name}"})
2919
+ result = load_plugin(manifest)
2920
+ return json.dumps(result.as_dict(), indent=2, sort_keys=True)
2921
+
2922
+
2923
+ def version_manifest_build() -> str:
2924
+ """Build a version manifest with CLI, Python, platform, harness, and plugin versions."""
2925
+ from .version_manifest import build_manifest
2926
+ m = build_manifest()
2927
+ return json.dumps(m.as_dict(), indent=2, sort_keys=True)
2928
+
2929
+
2930
+ def extensions_manifest_build() -> str:
2931
+ """Build an extension manifest with plugin/helper binary versions and status."""
2932
+ from .extensions_manifest import build_extensions_manifest
2933
+ m = build_extensions_manifest()
2934
+ return m.to_json()
2935
+
2936
+
2937
+ def runtime_qos_hint(tool_name: str, args_json: str = "{}") -> str:
2938
+ """Classify a tool call's runtime QoS and named log destination."""
2939
+ from .runtime_qos import classify_tool_runtime
2940
+ try:
2941
+ args = json.loads(args_json or "{}")
2942
+ if not isinstance(args, dict):
2943
+ args = {}
2944
+ except Exception:
2945
+ args = {}
2946
+ return json.dumps(classify_tool_runtime(tool_name, args).to_dict(), indent=2, sort_keys=True)
2947
+
2948
+
2949
+ def screenshot_description_verify(description: str, expected_terms: str = "", forbidden_terms: str = "") -> str:
2950
+ """Verify a screenshot description against expected and forbidden comma-separated terms."""
2951
+ from .vision_screenshot_verify import verify_screenshot_description
2952
+ expected = [term.strip() for term in (expected_terms or "").split(",") if term.strip()]
2953
+ forbidden = [term.strip() for term in (forbidden_terms or "").split(",") if term.strip()]
2954
+ return json.dumps(verify_screenshot_description(description, expected, forbidden).to_dict(), indent=2, sort_keys=True)
2955
+
2956
+
2957
+ def capability_mask_describe(tier: str = "", capabilities: str = "") -> str:
2958
+ """Describe a stable capability bit mask from a tier and/or comma-separated capability names."""
2959
+ from .capability_mask import CapabilityMask, mask_from_names, tier_mask
2960
+ names = [name.strip() for name in (capabilities or "").split(",") if name.strip()]
2961
+ mask = CapabilityMask(tier_mask(tier) | mask_from_names(names).value)
2962
+ return json.dumps(mask.to_dict(), indent=2, sort_keys=True)
2963
+
2964
+
2965
+ def small_context_ledger_preview(model: str, runtime_cap: int, blocks_json: str = "[]") -> str:
2966
+ """Preview whether the small-context ledger path would activate for a model/window."""
2967
+ from .small_context import preview_small_context_ledger
2968
+ return preview_small_context_ledger(model, runtime_cap, blocks_json)
2969
+
2970
+
2971
+ def credential_helpers_get(helper: str, key: str) -> str:
2972
+ """Check a named helper for a credential without returning its plaintext value."""
2973
+ from .credential_helpers import get_credential
2974
+ val = get_credential(helper, key)
2975
+ return json.dumps(
2976
+ {
2977
+ "helper": helper,
2978
+ "key": key,
2979
+ "found": val is not None,
2980
+ "value": "<redacted>" if val is not None else None,
2981
+ }
2982
+ )
2983
+
2984
+
2985
+ def credential_helpers_store(helper: str, key: str, value: str) -> str:
2986
+ """Store a credential value by key via a named credential helper."""
2987
+ from .credential_helpers import store_credential
2988
+ stored = store_credential(helper, key, value)
2989
+ return json.dumps({"helper": helper, "key": key, "stored": stored})
2990
+
2991
+
2992
+ def url_scheme_parse(url: str) -> str:
2993
+ """Parse an algo-cli:// deep link into an action descriptor."""
2994
+ from .url_scheme import handle_deep_link
2995
+
2996
+ result = handle_deep_link(url)
2997
+ return json.dumps(result, indent=2, sort_keys=True)
2998
+
2999
+
3000
+ ALL_TOOLS = [
3001
+ read_file,
3002
+ edit_file,
3003
+ read_pdf,
3004
+ render_pdf_pages,
3005
+ write_file,
3006
+ list_directory,
3007
+ search_files,
3008
+ find_unique_anchor,
3009
+ batch_edit,
3010
+ run_shell,
3011
+ git_status,
3012
+ git_diff,
3013
+ web_search,
3014
+ web_fetch,
3015
+ x_search,
3016
+ x_account_status,
3017
+ x_account_draft_post,
3018
+ x_account_draft_reply,
3019
+ x_account_post,
3020
+ x_account_reply,
3021
+ x_account_post_action,
3022
+ _hide_cfg_param(remember),
3023
+ _hide_cfg_param(append_lesson),
3024
+ update_user_profile,
3025
+ embed_text,
3026
+ vision_describe,
3027
+ available_actions,
3028
+ session_slash,
3029
+ _hide_cfg_param(session_command),
3030
+ harness_refresh,
3031
+ harness_stats,
3032
+ harness_scorecard,
3033
+ harness_competitive_rating,
3034
+ harness_search,
3035
+ harness_read,
3036
+ query_knowledge_graph,
3037
+ reindex_knowledge_graph,
3038
+ write_knowledge_graph_note,
3039
+ model_pull,
3040
+ model_delete,
3041
+ model_create,
3042
+ model_copy,
3043
+ model_show,
3044
+ plugins_discover,
3045
+ plugins_load,
3046
+ version_manifest_build,
3047
+ extensions_manifest_build,
3048
+ runtime_qos_hint,
3049
+ screenshot_description_verify,
3050
+ capability_mask_describe,
3051
+ small_context_ledger_preview,
3052
+ credential_helpers_get,
3053
+ credential_helpers_store,
3054
+ url_scheme_parse,
3055
+ ]
3056
+ TOOL_MAP = {fn.__name__: fn for fn in ALL_TOOLS}