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
@@ -0,0 +1,308 @@
1
+ """Persistent thread records for Agent Block and multi-agent runs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any, Callable
10
+
11
+ from . import config
12
+
13
+
14
+ THREADS_FILE_NAME = "agent_threads.json"
15
+ THREADS_SCHEMA_VERSION = 1
16
+ MAX_THREAD_RECORDS = 100
17
+ MAX_THREAD_TURNS = 16
18
+ MAX_THREAD_OUTPUT_CHARS = 12_000
19
+ _VALID_STATUSES = frozenset({"queued", "running", "complete", "partial", "failed", "cancelled"})
20
+
21
+
22
+ def threads_path() -> Path:
23
+ """Resolve lazily so test/runtime config-directory changes are honored."""
24
+
25
+ return config.CONFIG_DIR / THREADS_FILE_NAME
26
+
27
+
28
+ def _now() -> str:
29
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
30
+
31
+
32
+ def _clean_text(value: Any, limit: int) -> str:
33
+ text = str(value or "").strip()
34
+ return text[:limit]
35
+
36
+
37
+ def _empty_store() -> dict[str, Any]:
38
+ return {"version": THREADS_SCHEMA_VERSION, "threads": []}
39
+
40
+
41
+ def _normalize_record(raw: Any) -> dict[str, Any] | None:
42
+ if not isinstance(raw, dict):
43
+ return None
44
+ thread_id = _clean_text(raw.get("id"), 64)
45
+ if not thread_id:
46
+ return None
47
+ status = _clean_text(raw.get("status"), 24).lower()
48
+ if status not in _VALID_STATUSES:
49
+ status = "failed"
50
+ turns = raw.get("turns", [])
51
+ blocks = raw.get("blocks", [])
52
+ children = raw.get("children", [])
53
+ return {
54
+ "id": thread_id,
55
+ "parent_id": _clean_text(raw.get("parent_id"), 64),
56
+ "title": _clean_text(raw.get("title"), 120),
57
+ "task": _clean_text(raw.get("task"), 8_000),
58
+ "role": _clean_text(raw.get("role"), 80) or "orchestrator",
59
+ "pipeline": _clean_text(raw.get("pipeline"), 80) or "default",
60
+ "model": _clean_text(raw.get("model"), 120),
61
+ "status": status,
62
+ "created_at": _clean_text(raw.get("created_at"), 64) or _now(),
63
+ "updated_at": _clean_text(raw.get("updated_at"), 64) or _now(),
64
+ "output": _clean_text(raw.get("output"), MAX_THREAD_OUTPUT_CHARS),
65
+ "error": _clean_text(raw.get("error"), 2_000),
66
+ "children": [str(item)[:64] for item in children if str(item).strip()]
67
+ if isinstance(children, list)
68
+ else [],
69
+ "turns": [item for item in turns[-MAX_THREAD_TURNS:] if isinstance(item, dict)]
70
+ if isinstance(turns, list)
71
+ else [],
72
+ "blocks": [item for item in blocks if isinstance(item, dict)]
73
+ if isinstance(blocks, list)
74
+ else [],
75
+ }
76
+
77
+
78
+ def load_threads(path: Path | None = None) -> list[dict[str, Any]]:
79
+ target = path or threads_path()
80
+ loaded = config._load_json_file(target, _empty_store())
81
+ if not isinstance(loaded, dict) or loaded.get("version") != THREADS_SCHEMA_VERSION:
82
+ return []
83
+ records = loaded.get("threads", [])
84
+ if not isinstance(records, list):
85
+ return []
86
+ normalized = [_normalize_record(record) for record in records]
87
+ return [record for record in normalized if record is not None]
88
+
89
+
90
+ def _mutate(
91
+ callback: Callable[[list[dict[str, Any]]], Any],
92
+ *,
93
+ path: Path | None = None,
94
+ ) -> Any:
95
+ target = path or threads_path()
96
+ with config._exclusive_state_lock(target):
97
+ records = load_threads(target)
98
+ result = callback(records)
99
+ records.sort(key=lambda item: item.get("updated_at", ""), reverse=True)
100
+ del records[MAX_THREAD_RECORDS:]
101
+ payload = {"version": THREADS_SCHEMA_VERSION, "threads": records}
102
+ config._atomic_write_text(target, json.dumps(payload, indent=2))
103
+ return result
104
+
105
+
106
+ def _new_id(existing: set[str]) -> str:
107
+ while True:
108
+ candidate = uuid.uuid4().hex[:8]
109
+ if candidate not in existing:
110
+ return candidate
111
+
112
+
113
+ def create_thread(
114
+ task: str,
115
+ *,
116
+ role: str = "orchestrator",
117
+ pipeline: str = "default",
118
+ model: str = "",
119
+ parent_id: str = "",
120
+ title: str = "",
121
+ status: str = "queued",
122
+ start_turn: bool = False,
123
+ path: Path | None = None,
124
+ ) -> dict[str, Any]:
125
+ if status not in _VALID_STATUSES:
126
+ raise ValueError(f"Invalid agent thread status: {status}")
127
+ clean_task = _clean_text(task, 8_000)
128
+ clean_title = _clean_text(title, 120) or " ".join(clean_task.split())[:80] or "Agent task"
129
+
130
+ def add(records: list[dict[str, Any]]) -> dict[str, Any]:
131
+ thread_id = _new_id({record["id"] for record in records})
132
+ now = _now()
133
+ record = {
134
+ "id": thread_id,
135
+ "parent_id": _clean_text(parent_id, 64),
136
+ "title": clean_title,
137
+ "task": clean_task,
138
+ "role": _clean_text(role, 80) or "orchestrator",
139
+ "pipeline": _clean_text(pipeline, 80) or "default",
140
+ "model": _clean_text(model, 120),
141
+ "status": "running" if start_turn else status,
142
+ "created_at": now,
143
+ "updated_at": now,
144
+ "output": "",
145
+ "error": "",
146
+ "children": [],
147
+ "turns": (
148
+ [{"task": clean_task, "status": "running", "started_at": now, "output": ""}]
149
+ if start_turn
150
+ else []
151
+ ),
152
+ "blocks": [],
153
+ }
154
+ records.append(record)
155
+ if parent_id:
156
+ for parent in records:
157
+ if parent["id"] == parent_id and thread_id not in parent["children"]:
158
+ parent["children"].append(thread_id)
159
+ parent["updated_at"] = now
160
+ break
161
+ return dict(record)
162
+
163
+ return _mutate(add, path=path)
164
+
165
+
166
+ def update_thread(thread_id: str, *, path: Path | None = None, **changes: Any) -> dict[str, Any]:
167
+ allowed = {"title", "task", "role", "pipeline", "model", "status", "output", "error", "blocks"}
168
+ unknown = set(changes) - allowed
169
+ if unknown:
170
+ raise ValueError(f"Unsupported agent thread fields: {', '.join(sorted(unknown))}")
171
+ if "status" in changes and changes["status"] not in _VALID_STATUSES:
172
+ raise ValueError(f"Invalid agent thread status: {changes['status']}")
173
+
174
+ def update(records: list[dict[str, Any]]) -> dict[str, Any]:
175
+ for record in records:
176
+ if record["id"] != thread_id:
177
+ continue
178
+ for key, value in changes.items():
179
+ if key == "output":
180
+ record[key] = _clean_text(value, MAX_THREAD_OUTPUT_CHARS)
181
+ elif key == "error":
182
+ record[key] = _clean_text(value, 2_000)
183
+ elif key == "blocks":
184
+ record[key] = [item for item in value if isinstance(item, dict)] if isinstance(value, list) else []
185
+ else:
186
+ record[key] = _clean_text(value, 8_000 if key == "task" else 120)
187
+ record["updated_at"] = _now()
188
+ return dict(record)
189
+ raise KeyError(f"Unknown agent thread '{thread_id}'.")
190
+
191
+ return _mutate(update, path=path)
192
+
193
+
194
+ def begin_turn(
195
+ thread_id: str,
196
+ task: str,
197
+ *,
198
+ pipeline: str | None = None,
199
+ model: str | None = None,
200
+ path: Path | None = None,
201
+ ) -> dict[str, Any]:
202
+ def begin(records: list[dict[str, Any]]) -> dict[str, Any]:
203
+ for record in records:
204
+ if record["id"] != thread_id:
205
+ continue
206
+ now = _now()
207
+ record["status"] = "running"
208
+ record["updated_at"] = now
209
+ if not record["task"]:
210
+ record["task"] = _clean_text(task, 8_000)
211
+ if pipeline:
212
+ record["pipeline"] = _clean_text(pipeline, 80)
213
+ if model:
214
+ record["model"] = _clean_text(model, 120)
215
+ record["turns"].append(
216
+ {"task": _clean_text(task, 8_000), "status": "running", "started_at": now, "output": ""}
217
+ )
218
+ record["turns"] = record["turns"][-MAX_THREAD_TURNS:]
219
+ return dict(record)
220
+ raise KeyError(f"Unknown agent thread '{thread_id}'.")
221
+
222
+ return _mutate(begin, path=path)
223
+
224
+
225
+ def finish_turn(
226
+ thread_id: str,
227
+ *,
228
+ status: str,
229
+ output: str = "",
230
+ error: str = "",
231
+ blocks: list[dict[str, Any]] | None = None,
232
+ pipeline: str | None = None,
233
+ path: Path | None = None,
234
+ ) -> dict[str, Any]:
235
+ if status not in _VALID_STATUSES - {"queued", "running"}:
236
+ raise ValueError(f"Invalid terminal agent thread status: {status}")
237
+
238
+ def finish(records: list[dict[str, Any]]) -> dict[str, Any]:
239
+ for record in records:
240
+ if record["id"] != thread_id:
241
+ continue
242
+ now = _now()
243
+ record["status"] = status
244
+ record["updated_at"] = now
245
+ record["output"] = _clean_text(output, MAX_THREAD_OUTPUT_CHARS)
246
+ record["error"] = _clean_text(error, 2_000)
247
+ if blocks is not None:
248
+ record["blocks"] = [item for item in blocks if isinstance(item, dict)]
249
+ if pipeline:
250
+ record["pipeline"] = _clean_text(pipeline, 80)
251
+ if record["turns"]:
252
+ turn = record["turns"][-1]
253
+ if turn.get("status") == "running":
254
+ turn.update(
255
+ {
256
+ "status": status,
257
+ "finished_at": now,
258
+ "output": _clean_text(output, MAX_THREAD_OUTPUT_CHARS),
259
+ "error": _clean_text(error, 2_000),
260
+ }
261
+ )
262
+ return dict(record)
263
+ raise KeyError(f"Unknown agent thread '{thread_id}'.")
264
+
265
+ return _mutate(finish, path=path)
266
+
267
+
268
+ def resolve_thread(thread_ref: str, *, path: Path | None = None) -> dict[str, Any]:
269
+ ref = _clean_text(thread_ref, 64).lower()
270
+ if not ref:
271
+ raise KeyError("Agent thread ID is required.")
272
+ records = load_threads(path)
273
+ exact = [record for record in records if record["id"].lower() == ref]
274
+ if exact:
275
+ return exact[0]
276
+ matches = [record for record in records if record["id"].lower().startswith(ref)]
277
+ if len(matches) == 1:
278
+ return matches[0]
279
+ if len(matches) > 1:
280
+ raise KeyError(f"Agent thread prefix '{thread_ref}' is ambiguous.")
281
+ raise KeyError(f"Unknown agent thread '{thread_ref}'. Use /agent threads to list runs.")
282
+
283
+
284
+ def list_threads(*, limit: int = 20, path: Path | None = None) -> list[dict[str, Any]]:
285
+ return load_threads(path)[: max(1, min(int(limit), 100))]
286
+
287
+
288
+ def context_handoff(record: dict[str, Any], *, limit: int = 8_000) -> str:
289
+ """Produce bounded, explicit evidence context for resume/fork operations."""
290
+
291
+ blocks = record.get("blocks", [])
292
+ block_lines = []
293
+ if isinstance(blocks, list):
294
+ for block in blocks[-8:]:
295
+ if not isinstance(block, dict):
296
+ continue
297
+ code = f" ({block.get('status_code')})" if block.get("status_code") else ""
298
+ block_lines.append(
299
+ f"- {block.get('role', '?')}: {block.get('status', '?')}{code}"
300
+ )
301
+ text = (
302
+ f"Thread: {record.get('id', '?')}\n"
303
+ f"Original task: {record.get('task', '')}\n"
304
+ f"Last status: {record.get('status', '?')}\n"
305
+ f"Block evidence:\n{chr(10).join(block_lines) or '- none'}\n\n"
306
+ f"Last output:\n{record.get('output', '') or '(no output)'}"
307
+ )
308
+ return text[:limit]
algo_cli/animations.py ADDED
@@ -0,0 +1,316 @@
1
+ """Redeye companion — Unicode state animations for the Algo CLI display.
2
+
3
+ The CLI has a *buddy*: a red scanning eye (think KITT / Cylon) that lives in
4
+ the status line and reacts to what the agent is doing. Every state has its
5
+ own motion signature so you can read the agent's mode from across the room:
6
+
7
+ THINKING the eye sweeps left-right across a track ▰▰●▰▰ (scanning)
8
+ DOING a bolt strikes across a progress rail ⚡▰▰▱▱ (striking)
9
+ ANSWERING the eye emits chevrons toward you ◉ ❯❯❯ (speaking)
10
+ IDLE the eye breathes, with a slow blink ◉ … ─ (watching)
11
+ ERROR the eye locks wide open, no motion ⊘ (alarmed)
12
+
13
+ Architecture
14
+ ------------
15
+ - Pure rich + stdlib; zero threads of our own. Two animation channels:
16
+ 1. ``animate()`` / ``spinner_name()`` — registered rich spinners, animated
17
+ by rich's status refresh thread.
18
+ 2. ``current_frame()`` / ``buddy_frame()`` — wall-clock frame pickers for
19
+ callers that already re-render (Live panels, prompt redraws). Each call
20
+ returns the frame for *now*, so motion is free wherever updates happen.
21
+ - Windows-safe: ``force_utf8()`` switches the console to UTF-8 (code page
22
+ 65001 + stream reconfigure); if that fails, every frame set has an ASCII
23
+ twin chosen at import so nothing ever renders as mojibake.
24
+ - Styles are theme style *names* (primary/accent/error/...), so the buddy
25
+ automatically wears whatever palette is active — it bleeds red under the
26
+ ``redeye`` theme and adapts elsewhere.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import sys
32
+ import time
33
+ from contextlib import contextmanager
34
+ from dataclasses import dataclass
35
+ from enum import Enum
36
+ from typing import Any, Iterator
37
+
38
+ from rich.spinner import SPINNERS
39
+ from rich.text import Text
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Console capability
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def force_utf8() -> bool:
47
+ """Switch the Windows console + Python streams to UTF-8.
48
+
49
+ prompt_toolkit and rich both emit UTF-8; on a cp1252 console that turns
50
+ into mojibake (``â<9d>¯`` instead of ``❯``). Returns True when the
51
+ console can take full Unicode afterwards.
52
+ """
53
+ if sys.platform == "win32":
54
+ try:
55
+ import ctypes
56
+
57
+ ctypes.windll.kernel32.SetConsoleOutputCP(65001)
58
+ ctypes.windll.kernel32.SetConsoleCP(65001)
59
+ except Exception:
60
+ pass
61
+ for stream in (sys.stdout, sys.stderr):
62
+ try:
63
+ stream.reconfigure(encoding="utf-8") # type: ignore[union-attr]
64
+ except Exception:
65
+ pass
66
+ return supports_unicode()
67
+
68
+
69
+ def supports_unicode() -> bool:
70
+ """True when stdout's encoding can represent every buddy glyph."""
71
+ sample = "⠋▰▱❯◉◎⚡⊘●─"
72
+ encoding = getattr(sys.stdout, "encoding", None) or "ascii"
73
+ try:
74
+ sample.encode(encoding)
75
+ return True
76
+ except (UnicodeEncodeError, LookupError):
77
+ return False
78
+
79
+
80
+ _UNICODE_OK = force_utf8()
81
+
82
+
83
+ # ---------------------------------------------------------------------------
84
+ # State definitions
85
+ # ---------------------------------------------------------------------------
86
+
87
+ class AIState(str, Enum):
88
+ THINKING = "thinking"
89
+ DOING = "doing"
90
+ ANSWERING = "answering"
91
+ IDLE = "idle"
92
+ ERROR = "error"
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class StateAnimation:
97
+ """One state's complete visual definition."""
98
+
99
+ frames: tuple[str, ...] # compact frames (spinner / inline titles)
100
+ buddy: tuple[str, ...] # wide "companion" frames (status lines)
101
+ ascii_frames: tuple[str, ...]
102
+ ascii_buddy: tuple[str, ...]
103
+ interval_ms: int
104
+ style: str # rich theme style name
105
+ glyph: str # static symbol for one-off lines
106
+ ascii_glyph: str
107
+ label: str # default status label
108
+ tagline: str # buddy's voice for this state
109
+
110
+
111
+ def _scan(track: str, pupil: str, width: int) -> tuple[str, ...]:
112
+ """Build a KITT-style scanner: a pupil sweeping across a track and back."""
113
+ positions = list(range(width)) + list(range(width - 2, 0, -1))
114
+ return tuple(track * i + pupil + track * (width - 1 - i) for i in positions)
115
+
116
+
117
+ _ANIMATIONS: dict[AIState, StateAnimation] = {
118
+ AIState.THINKING: StateAnimation(
119
+ frames=_scan("▰", "●", 5),
120
+ buddy=tuple(f"⟦{frame}⟧" for frame in _scan("▰", "●", 7)),
121
+ ascii_frames=_scan("=", "0", 5),
122
+ ascii_buddy=tuple(f"[{frame}]" for frame in _scan("=", "0", 7)),
123
+ interval_ms=70,
124
+ style="secondary",
125
+ glyph="◉",
126
+ ascii_glyph="0",
127
+ label="thinking",
128
+ tagline="scanning the problem",
129
+ ),
130
+ AIState.DOING: StateAnimation(
131
+ frames=("⚡▱▱▱▱", "▰⚡▱▱▱", "▰▰⚡▱▱", "▰▰▰⚡▱", "▰▰▰▰⚡", "▰▰▰▰▰", "▱▰▰▰▰", "▱▱▰▰▰", "▱▱▱▰▰", "▱▱▱▱▰"),
132
+ buddy=("⟦⚡▱▱▱▱▱⟧", "⟦▰⚡▱▱▱▱⟧", "⟦▰▰⚡▱▱▱⟧", "⟦▰▰▰⚡▱▱⟧", "⟦▰▰▰▰⚡▱⟧", "⟦▰▰▰▰▰⚡⟧", "⟦▰▰▰▰▰▰⟧", "⟦▱▱▱▱▱▱⟧"),
133
+ ascii_frames=("*----", "#*---", "##*--", "###*-", "####*", "#####", "-####", "--###"),
134
+ ascii_buddy=("[*-----]", "[#*----]", "[##*---]", "[###*--]", "[####*-]", "[#####*]", "[######]", "[------]"),
135
+ interval_ms=75,
136
+ style="accent",
137
+ glyph="⚡",
138
+ ascii_glyph="*",
139
+ label="working",
140
+ tagline="executing",
141
+ ),
142
+ AIState.ANSWERING: StateAnimation(
143
+ frames=("◉ ", "◉❯ ", "◉❯❯ ", "◉❯❯❯", "◉ ❯❯", "◉ ❯"),
144
+ buddy=("⟦ ◉ ⟧ ", "⟦ ◉ ⟧❯ ", "⟦ ◉ ⟧❯❯ ", "⟦ ◉ ⟧❯❯❯", "⟦ ◉ ⟧ ❯❯", "⟦ ◉ ⟧ ❯"),
145
+ ascii_frames=("0 ", "0> ", "0>> ", "0>>>", "0 >>", "0 >"),
146
+ ascii_buddy=("[ 0 ] ", "[ 0 ]> ", "[ 0 ]>> ", "[ 0 ]>>>", "[ 0 ] >>", "[ 0 ] >"),
147
+ interval_ms=100,
148
+ style="primary",
149
+ glyph="❯",
150
+ ascii_glyph=">",
151
+ label="answering",
152
+ tagline="streaming the answer",
153
+ ),
154
+ AIState.IDLE: StateAnimation(
155
+ # Slow breath with an occasional blink (the ─ frame).
156
+ frames=("◉", "◉", "◉", "◎", "○", "◎", "◉", "◉", "─", "◉"),
157
+ buddy=("⟦ ◉ ⟧", "⟦ ◉ ⟧", "⟦ ◉ ⟧", "⟦ ◎ ⟧", "⟦ ○ ⟧", "⟦ ◎ ⟧", "⟦ ◉ ⟧", "⟦ ◉ ⟧", "⟦ ─ ⟧", "⟦ ◉ ⟧"),
158
+ ascii_frames=("0", "0", "0", "o", ".", "o", "0", "0", "-", "0"),
159
+ ascii_buddy=("[ 0 ]", "[ 0 ]", "[ 0 ]", "[ o ]", "[ . ]", "[ o ]", "[ 0 ]", "[ 0 ]", "[ - ]", "[ 0 ]"),
160
+ interval_ms=350,
161
+ style="muted",
162
+ glyph="◉",
163
+ ascii_glyph="0",
164
+ label="ready",
165
+ tagline="ready for work",
166
+ ),
167
+ AIState.ERROR: StateAnimation(
168
+ # Deliberately static: errors should arrest, not flicker.
169
+ frames=("⊘", "⊘"),
170
+ buddy=("⟦ ⊘ ⟧", "⟦ ⊘ ⟧"),
171
+ ascii_frames=("X", "X"),
172
+ ascii_buddy=("[ X ]", "[ X ]"),
173
+ interval_ms=500,
174
+ style="error",
175
+ glyph="⊘",
176
+ ascii_glyph="X",
177
+ label="error",
178
+ tagline="something broke",
179
+ ),
180
+ }
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Frame access
185
+ # ---------------------------------------------------------------------------
186
+
187
+ def animation_for(state: AIState | str) -> StateAnimation:
188
+ return _ANIMATIONS[AIState(state)]
189
+
190
+
191
+ def frames_for(state: AIState | str, *, buddy: bool = False) -> tuple[str, ...]:
192
+ anim = animation_for(state)
193
+ if buddy:
194
+ return anim.buddy if _UNICODE_OK else anim.ascii_buddy
195
+ return anim.frames if _UNICODE_OK else anim.ascii_frames
196
+
197
+
198
+ def glyph(state: AIState | str) -> Text:
199
+ """Static styled symbol for prefixing one-off lines (no animation)."""
200
+ anim = animation_for(state)
201
+ return Text(anim.glyph if _UNICODE_OK else anim.ascii_glyph, style=anim.style)
202
+
203
+
204
+ def current_frame(state: AIState | str) -> str:
205
+ """Wall-clock frame for callers that re-render on their own cadence.
206
+
207
+ Any display that updates regularly (a Live panel refreshed per token
208
+ batch, a prompt redraw) shows smooth motion just by calling this on
209
+ every render — no timer ownership needed.
210
+ """
211
+ return _pick_frame(frames_for(state), animation_for(state).interval_ms)
212
+
213
+
214
+ def buddy_frame(state: AIState | str) -> Text:
215
+ """The companion eye, styled, at this instant — for status/footer lines."""
216
+ anim = animation_for(state)
217
+ frame = _pick_frame(frames_for(state, buddy=True), anim.interval_ms)
218
+ return Text(frame, style=anim.style)
219
+
220
+
221
+ def _pick_frame(frames: tuple[str, ...], interval_ms: int) -> str:
222
+ # perf_counter, not monotonic: display tests stub time.monotonic with a
223
+ # finite tick iterator, and frame picking must never consume those ticks.
224
+ return frames[int(time.perf_counter() * 1000 / interval_ms) % len(frames)]
225
+
226
+
227
+ def state_line(state: AIState | str, message: str = "") -> Text:
228
+ """Single styled line: buddy + message — e.g. ``⟦ ◉ ⟧ ready for work``."""
229
+ anim = animation_for(state)
230
+ line = buddy_frame(state)
231
+ line.append(f" {message or anim.tagline}", style=anim.style)
232
+ return line
233
+
234
+
235
+ # ---------------------------------------------------------------------------
236
+ # Rich spinner channel
237
+ # ---------------------------------------------------------------------------
238
+
239
+ _SPINNERS_REGISTERED = False
240
+
241
+
242
+ def register_spinners() -> None:
243
+ """Register buddy spinners (``algo-<state>``) with rich. Idempotent."""
244
+ global _SPINNERS_REGISTERED
245
+ if _SPINNERS_REGISTERED:
246
+ return
247
+ for state, anim in _ANIMATIONS.items():
248
+ SPINNERS[f"algo-{state.value}"] = {
249
+ "interval": anim.interval_ms,
250
+ "frames": list(anim.buddy if _UNICODE_OK else anim.ascii_buddy),
251
+ }
252
+ _SPINNERS_REGISTERED = True
253
+
254
+
255
+ def spinner_name(state: AIState | str) -> str:
256
+ register_spinners()
257
+ return f"algo-{AIState(state).value}"
258
+
259
+
260
+ @contextmanager
261
+ def animate(console: Any, state: AIState | str, message: str = "") -> Iterator[None]:
262
+ """Animated buddy status for a state::
263
+
264
+ with animate(console, AIState.DOING, "search_files"):
265
+ run_tool()
266
+ """
267
+ anim = animation_for(state)
268
+ label = f"[{anim.style}]{message or anim.tagline}[/]"
269
+ with console.status(label, spinner=spinner_name(state), spinner_style=anim.style):
270
+ yield
271
+
272
+
273
+ # ---------------------------------------------------------------------------
274
+ # Set pieces
275
+ # ---------------------------------------------------------------------------
276
+
277
+ def intro_sweep(console: Any, *, duration: float = 0.9) -> None:
278
+ """Boot animation: one scanner sweep under the logo, then settle to idle.
279
+
280
+ Skipped automatically when stdout is not a live terminal (pipes, CI,
281
+ JSON bridges) so it never pollutes captured output.
282
+ """
283
+ if not getattr(console, "is_terminal", False):
284
+ return
285
+ anim = animation_for(AIState.THINKING)
286
+ deadline = time.perf_counter() + duration
287
+ try:
288
+ with console.status("", spinner=spinner_name(AIState.THINKING), spinner_style=anim.style):
289
+ while time.perf_counter() < deadline:
290
+ time.sleep(min(0.05, anim.interval_ms / 1000))
291
+ except Exception:
292
+ return
293
+
294
+
295
+ def demo() -> None: # pragma: no cover - manual visual check
296
+ """Walk the buddy through every state. Run: ``python -m algo_cli.animations``"""
297
+ from algo_cli.display import console, show_error
298
+
299
+ console.print()
300
+ console.print(state_line(AIState.IDLE))
301
+ intro_sweep(console, duration=1.2)
302
+ with animate(console, AIState.THINKING, "scanning the problem"):
303
+ time.sleep(2.4)
304
+ with animate(console, AIState.DOING, "executing search_files"):
305
+ time.sleep(2.4)
306
+ with animate(console, AIState.ANSWERING, "streaming the answer"):
307
+ time.sleep(1.8)
308
+ console.print(state_line(AIState.ANSWERING, "answer delivered"))
309
+ console.print()
310
+ show_error("simulated failure — eye locks wide open, no flicker")
311
+ console.print(state_line(AIState.IDLE, "recovered — ready for work"))
312
+ console.print()
313
+
314
+
315
+ if __name__ == "__main__": # pragma: no cover
316
+ demo()