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