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.
- algo_cli/__init__.py +3 -0
- algo_cli/__main__.py +7 -0
- algo_cli/_internal/__init__.py +12 -0
- algo_cli/_internal/policy_chain.py +259 -0
- algo_cli/action_registry.py +1047 -0
- algo_cli/agent_blocks.py +550 -0
- algo_cli/agent_pipeline.py +1457 -0
- algo_cli/agent_threads.py +308 -0
- algo_cli/animations.py +316 -0
- algo_cli/cache_admission.py +209 -0
- algo_cli/capability_mask.py +66 -0
- algo_cli/chat_protocol.py +116 -0
- algo_cli/chatgpt_auth.py +510 -0
- algo_cli/chatgpt_client.py +657 -0
- algo_cli/code_rag.py +479 -0
- algo_cli/config.py +651 -0
- algo_cli/context_budget.py +679 -0
- algo_cli/credential_helpers.py +315 -0
- algo_cli/deliberation.py +29 -0
- algo_cli/display.py +1470 -0
- algo_cli/evals/__init__.py +21 -0
- algo_cli/evals/algorithm_effectiveness.py +560 -0
- algo_cli/evals/competitive_harness_rating.py +702 -0
- algo_cli/evals/cot_quality.py +220 -0
- algo_cli/evals/harness_retrieval_benchmark.py +401 -0
- algo_cli/evals/performance_regression.py +136 -0
- algo_cli/evals/scorecard_grading.py +308 -0
- algo_cli/evals/session_distribution.py +84 -0
- algo_cli/execution_guardrails.py +806 -0
- algo_cli/extensions_manifest.py +84 -0
- algo_cli/git_evidence.py +227 -0
- algo_cli/google_workspace.py +407 -0
- algo_cli/google_workspace_auth.py +523 -0
- algo_cli/harness.py +2587 -0
- algo_cli/identity.py +557 -0
- algo_cli/index_compute_lab.py +228 -0
- algo_cli/inference_harness.py +70 -0
- algo_cli/intelligence/__init__.py +1103 -0
- algo_cli/intelligence/acrobat_config.py +307 -0
- algo_cli/intelligence/acrobat_manifests.py +338 -0
- algo_cli/intelligence/acrobat_models.py +195 -0
- algo_cli/intelligence/acrobat_pipeline.py +295 -0
- algo_cli/intelligence/acrobat_runtime.py +302 -0
- algo_cli/intelligence/acrobat_security.py +261 -0
- algo_cli/intelligence/acrobat_workflows.py +226 -0
- algo_cli/intelligence/actionability.py +165 -0
- algo_cli/intelligence/adversarial_audit.py +136 -0
- algo_cli/intelligence/agent_arena.py +92 -0
- algo_cli/intelligence/agent_benchmark.py +236 -0
- algo_cli/intelligence/agent_runtime.py +171 -0
- algo_cli/intelligence/agents_as_tools.py +70 -0
- algo_cli/intelligence/artifact_binding.py +80 -0
- algo_cli/intelligence/autonomous_engineer.py +1976 -0
- algo_cli/intelligence/backpressure.py +99 -0
- algo_cli/intelligence/bloom_filter.py +186 -0
- algo_cli/intelligence/bonferroni.py +66 -0
- algo_cli/intelligence/boundary_compaction.py +98 -0
- algo_cli/intelligence/catalog_verifier.py +172 -0
- algo_cli/intelligence/cavecrew.py +118 -0
- algo_cli/intelligence/changelog.py +176 -0
- algo_cli/intelligence/checkpoint_resume.py +92 -0
- algo_cli/intelligence/circuit_breaker.py +88 -0
- algo_cli/intelligence/clarification_gate.py +101 -0
- algo_cli/intelligence/code_graph.py +180 -0
- algo_cli/intelligence/coderank.py +97 -0
- algo_cli/intelligence/consistent_hash.py +150 -0
- algo_cli/intelligence/consortium_synthesis.py +139 -0
- algo_cli/intelligence/construction/__init__.py +241 -0
- algo_cli/intelligence/construction/common.py +273 -0
- algo_cli/intelligence/construction/documents.py +496 -0
- algo_cli/intelligence/construction/labor_units.py +1395 -0
- algo_cli/intelligence/construction/payments.py +470 -0
- algo_cli/intelligence/construction/risk.py +784 -0
- algo_cli/intelligence/content_extractor.py +132 -0
- algo_cli/intelligence/context_adaptive.py +102 -0
- algo_cli/intelligence/context_ops.py +95 -0
- algo_cli/intelligence/count_min.py +145 -0
- algo_cli/intelligence/cow_state.py +103 -0
- algo_cli/intelligence/critic_loop.py +119 -0
- algo_cli/intelligence/cross_source.py +113 -0
- algo_cli/intelligence/daemon_mode.py +99 -0
- algo_cli/intelligence/dag_orchestration.py +151 -0
- algo_cli/intelligence/deep_research.py +155 -0
- algo_cli/intelligence/degenerate_detector.py +78 -0
- algo_cli/intelligence/delta_report.py +92 -0
- algo_cli/intelligence/discovery_event_log.py +92 -0
- algo_cli/intelligence/document_ingest.py +298 -0
- algo_cli/intelligence/dual_layer_validate.py +151 -0
- algo_cli/intelligence/echo_fidelity.py +73 -0
- algo_cli/intelligence/ema_tuning.py +104 -0
- algo_cli/intelligence/event_log.py +92 -0
- algo_cli/intelligence/evidence_graph.py +114 -0
- algo_cli/intelligence/extension_host.py +162 -0
- algo_cli/intelligence/extension_manifest.py +115 -0
- algo_cli/intelligence/falsification_suite.py +178 -0
- algo_cli/intelligence/finance/__init__.py +169 -0
- algo_cli/intelligence/finance/anomalies.py +135 -0
- algo_cli/intelligence/finance/ap_ar.py +351 -0
- algo_cli/intelligence/finance/cash.py +162 -0
- algo_cli/intelligence/finance/close.py +332 -0
- algo_cli/intelligence/finance/common.py +244 -0
- algo_cli/intelligence/finance/construction.py +135 -0
- algo_cli/intelligence/finance/controls.py +172 -0
- algo_cli/intelligence/finance/evidence.py +119 -0
- algo_cli/intelligence/finance/exceptions.py +157 -0
- algo_cli/intelligence/finance/reconciliations.py +254 -0
- algo_cli/intelligence/finance/revenue.py +109 -0
- algo_cli/intelligence/finance/tax.py +74 -0
- algo_cli/intelligence/finance/workpapers.py +111 -0
- algo_cli/intelligence/finding_record.py +120 -0
- algo_cli/intelligence/flow_dag.py +267 -0
- algo_cli/intelligence/gatherer.py +223 -0
- algo_cli/intelligence/golden_master.py +98 -0
- algo_cli/intelligence/graph_rag.py +195 -0
- algo_cli/intelligence/group_chat.py +143 -0
- algo_cli/intelligence/hash_dedup.py +145 -0
- algo_cli/intelligence/hyperloglog.py +128 -0
- algo_cli/intelligence/incremental_index.py +316 -0
- algo_cli/intelligence/index_store.py +16 -0
- algo_cli/intelligence/iteration_plan.py +133 -0
- algo_cli/intelligence/kernel_plugins.py +167 -0
- algo_cli/intelligence/lesson_catalog.py +135 -0
- algo_cli/intelligence/llm_fallback.py +169 -0
- algo_cli/intelligence/log2_histogram.py +267 -0
- algo_cli/intelligence/lsp_integration.py +147 -0
- algo_cli/intelligence/memory_evolution.py +117 -0
- algo_cli/intelligence/minhash_lsh.py +182 -0
- algo_cli/intelligence/multi_model_score.py +174 -0
- algo_cli/intelligence/multi_tier_grade.py +211 -0
- algo_cli/intelligence/negative_controls.py +113 -0
- algo_cli/intelligence/numeric_clamp.py +63 -0
- algo_cli/intelligence/occ_editor.py +66 -0
- algo_cli/intelligence/output_normalize.py +112 -0
- algo_cli/intelligence/parallel_delegation.py +98 -0
- algo_cli/intelligence/parallel_fanout.py +104 -0
- algo_cli/intelligence/permission_modes.py +105 -0
- algo_cli/intelligence/pre_push_gate.py +68 -0
- algo_cli/intelligence/prefetch.py +171 -0
- algo_cli/intelligence/process_framework.py +217 -0
- algo_cli/intelligence/project_graph.py +387 -0
- algo_cli/intelligence/query_expansion.py +146 -0
- algo_cli/intelligence/ralph_loop.py +117 -0
- algo_cli/intelligence/rate_limiter.py +153 -0
- algo_cli/intelligence/refactor_transaction.py +94 -0
- algo_cli/intelligence/research_workspace.py +108 -0
- algo_cli/intelligence/retraction_ledger.py +72 -0
- algo_cli/intelligence/saga_pattern.py +88 -0
- algo_cli/intelligence/session_fork.py +100 -0
- algo_cli/intelligence/shadow_editor.py +67 -0
- algo_cli/intelligence/shell_session.py +213 -0
- algo_cli/intelligence/source_registry.py +143 -0
- algo_cli/intelligence/spawn_scales.py +99 -0
- algo_cli/intelligence/stat_stability.py +104 -0
- algo_cli/intelligence/structural_validator.py +148 -0
- algo_cli/intelligence/subagent_spawner.py +111 -0
- algo_cli/intelligence/symmetric_verify.py +70 -0
- algo_cli/intelligence/task_classifier.py +129 -0
- algo_cli/intelligence/team_execution.py +122 -0
- algo_cli/intelligence/tiered_access.py +121 -0
- algo_cli/intelligence/utility_registry.py +159 -0
- algo_cli/intuition_engine.py +560 -0
- algo_cli/intuition_injector.py +82 -0
- algo_cli/kernels/__init__.py +5 -0
- algo_cli/kernels/manifest.py +763 -0
- algo_cli/main.py +3903 -0
- algo_cli/memory_candidates.py +541 -0
- algo_cli/memory_echo_veil.py +394 -0
- algo_cli/memory_runtime.py +112 -0
- algo_cli/model_info.py +548 -0
- algo_cli/model_profile.py +160 -0
- algo_cli/model_routing.py +74 -0
- algo_cli/oneshot.py +331 -0
- algo_cli/perf_telemetry.py +389 -0
- algo_cli/plugins.py +245 -0
- algo_cli/private_event_store.py +654 -0
- algo_cli/quantization/__init__.py +24 -0
- algo_cli/quantization/lloyd_max.py +98 -0
- algo_cli/quantization/turbo_quant.py +308 -0
- algo_cli/reasoning/__init__.py +46 -0
- algo_cli/reasoning/combinatorial.py +356 -0
- algo_cli/reasoning/graph_of_thought.py +297 -0
- algo_cli/reasoning/mcts.py +220 -0
- algo_cli/reasoning/neuro_symbolic.py +250 -0
- algo_cli/reasoning/react.py +246 -0
- algo_cli/reasoning/reflexion.py +225 -0
- algo_cli/reasoning/tree_of_thought.py +241 -0
- algo_cli/reasoning_bridge.py +150 -0
- algo_cli/reconciliation.py +284 -0
- algo_cli/reflex.py +385 -0
- algo_cli/resources/docs/ALGO.md +13958 -0
- algo_cli/resources/docs/algo-cli-algorithm-evidence-contract.md +60 -0
- algo_cli/resources/docs/algo-cli-execution-verification-contract.md +59 -0
- algo_cli/resources/docs/algo-cli-memory-lifecycle-contract.md +72 -0
- algo_cli/resources/docs/harness-extension-cleanup-recommendation.md +41 -0
- algo_cli/resources/docs/index-compute-lab-integration.md +32 -0
- algo_cli/resources/docs/inference-harness-loop-blueprint-2026-06.md +55 -0
- algo_cli/resources/docs/main-split-map.md +35 -0
- algo_cli/resources/docs/privacy-and-context.md +48 -0
- algo_cli/resources/docs/reflex-loop-v0.2.md +354 -0
- algo_cli/resources/skills/README.md +26 -0
- algo_cli/resources/skills/algo-cli.md +59 -0
- algo_cli/resources/skills/edit-file-precision.md +49 -0
- algo_cli/resources/skills/harness-search-first.md +47 -0
- algo_cli/resources/skills/memory-recall-ritual.md +51 -0
- algo_cli/resources/skills/qol-algorithms.md +224 -0
- algo_cli/resources/skills/smart-error-recovery.md +56 -0
- algo_cli/resources/skills/tool-selection-cheatsheet.md +65 -0
- algo_cli/retrieval_algorithms.py +127 -0
- algo_cli/runtime_qos.py +236 -0
- algo_cli/runtime_services.py +320 -0
- algo_cli/session_commands.py +95 -0
- algo_cli/session_mode.py +113 -0
- algo_cli/skills.py +430 -0
- algo_cli/slash_dispatch.py +1265 -0
- algo_cli/small_context.py +206 -0
- algo_cli/spawn_budget.py +89 -0
- algo_cli/task_ledger.py +84 -0
- algo_cli/task_router.py +197 -0
- algo_cli/tool_context.py +94 -0
- algo_cli/tool_contract.py +99 -0
- algo_cli/tool_policy.py +357 -0
- algo_cli/tool_runtime.py +647 -0
- algo_cli/tools.py +3056 -0
- algo_cli/url_scheme.py +174 -0
- algo_cli/verify.py +154 -0
- algo_cli/version_manifest.py +178 -0
- algo_cli/vision_screenshot_verify.py +76 -0
- algo_cli/workspace_resolver.py +68 -0
- algo_cli/x_account.py +209 -0
- algo_cli/xai_auth.py +374 -0
- algo_cli/xai_client.py +600 -0
- algo_cli_runtime-0.14.0.dist-info/METADATA +369 -0
- algo_cli_runtime-0.14.0.dist-info/RECORD +237 -0
- algo_cli_runtime-0.14.0.dist-info/WHEEL +4 -0
- algo_cli_runtime-0.14.0.dist-info/entry_points.txt +3 -0
- algo_cli_runtime-0.14.0.dist-info/licenses/LICENSE +21 -0
- 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()
|