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/config.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""Configuration and persistent state."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from dataclasses import asdict, dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Callable, Iterable, Iterator
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Branding compatibility constants. New names take precedence; old names remain
|
|
16
|
+
# readable so existing installs continue to start cleanly.
|
|
17
|
+
NEW_CONFIG_DIR_NAME = ".algo_cli"
|
|
18
|
+
OLD_CONFIG_DIR_NAME = ".ollama_cli"
|
|
19
|
+
NEW_ENV_PREFIX = "ALGO_CLI_"
|
|
20
|
+
OLD_ENV_PREFIX = "OLLAMA_CLI_"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _resolve_config_dir() -> Path:
|
|
24
|
+
"""Resolve the active config directory with full dual-support for the rebrand.
|
|
25
|
+
|
|
26
|
+
Precedence (highest first):
|
|
27
|
+
1. Explicit ALGO_CLI_CONFIG_DIR
|
|
28
|
+
2. Explicit OLLAMA_CLI_CONFIG_DIR (legacy compat)
|
|
29
|
+
3. If ~/.algo_cli already exists on disk → use it
|
|
30
|
+
4. Default to ~/.algo_cli (new location)
|
|
31
|
+
"""
|
|
32
|
+
# 1. New explicit env wins
|
|
33
|
+
new_explicit = os.environ.get(f"{NEW_ENV_PREFIX}CONFIG_DIR")
|
|
34
|
+
if new_explicit:
|
|
35
|
+
return Path(new_explicit).expanduser()
|
|
36
|
+
|
|
37
|
+
# 2. Old explicit env (legacy)
|
|
38
|
+
old_explicit = os.environ.get(f"{OLD_ENV_PREFIX}CONFIG_DIR")
|
|
39
|
+
if old_explicit:
|
|
40
|
+
return Path(old_explicit).expanduser()
|
|
41
|
+
|
|
42
|
+
home = Path.home()
|
|
43
|
+
new_dir = home / NEW_CONFIG_DIR_NAME
|
|
44
|
+
|
|
45
|
+
# 3. If the new location already exists, prefer it
|
|
46
|
+
if new_dir.exists():
|
|
47
|
+
return new_dir
|
|
48
|
+
|
|
49
|
+
# 4. Default to the new location (migration logic will detect old data later)
|
|
50
|
+
return new_dir
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
CONFIG_DIR = _resolve_config_dir()
|
|
54
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
55
|
+
MEMORY_FILE = CONFIG_DIR / "memory.json"
|
|
56
|
+
MEMORY_CANDIDATE_STATE_FILE = CONFIG_DIR / "memory_candidate_state.json"
|
|
57
|
+
HISTORY_DIR = CONFIG_DIR / "saves"
|
|
58
|
+
CONTEXT_ARCHIVE_DIR = CONFIG_DIR / "context_archives"
|
|
59
|
+
PROMPT_HISTORY_FILE = CONFIG_DIR / "prompt_history.txt"
|
|
60
|
+
PERF_HISTORY_FILE = CONFIG_DIR / "perf_history.jsonl"
|
|
61
|
+
EMBED_PERF_FILE = CONFIG_DIR / "embed_perf.jsonl"
|
|
62
|
+
DEFAULT_RUNTIME_ENV_FILE = CONFIG_DIR / "env"
|
|
63
|
+
DOTENV_RUNTIME_ENV_FILE = CONFIG_DIR / ".env"
|
|
64
|
+
|
|
65
|
+
DEFAULT_MODEL = (
|
|
66
|
+
os.environ.get(f"{NEW_ENV_PREFIX}MODEL")
|
|
67
|
+
or os.environ.get(f"{OLD_ENV_PREFIX}MODEL")
|
|
68
|
+
or "qwen3"
|
|
69
|
+
)
|
|
70
|
+
DEFAULT_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
|
|
71
|
+
DEFAULT_THEME = (
|
|
72
|
+
os.environ.get(f"{NEW_ENV_PREFIX}THEME")
|
|
73
|
+
or os.environ.get(f"{OLD_ENV_PREFIX}THEME")
|
|
74
|
+
or "tokyo-night"
|
|
75
|
+
)
|
|
76
|
+
DEFAULT_CHAT_STREAM_TIMEOUT_SECONDS = 300.0
|
|
77
|
+
CODE_RAG_CONSENT_VERSION = 1
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def code_rag_consent_granted(cfg: Any) -> bool:
|
|
81
|
+
"""Return whether this config explicitly accepted the current code-RAG policy."""
|
|
82
|
+
|
|
83
|
+
return bool(getattr(cfg, "code_rag_enabled", False)) and (
|
|
84
|
+
getattr(cfg, "code_rag_consent_version", 0) == CODE_RAG_CONSENT_VERSION
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def safe_conversation_name(name: str) -> str:
|
|
89
|
+
safe_name = "".join(ch for ch in str(name) if ch.isalnum() or ch in ("-", "_")).strip()
|
|
90
|
+
if not safe_name:
|
|
91
|
+
raise ValueError("Save name must contain letters, numbers, hyphen, or underscore.")
|
|
92
|
+
return safe_name
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
LEGACY_DEFAULT_SYSTEM = """You are Algo CLI: a concise, terminal-native coding assistant (local Ollama, Ollama Cloud, or Grok).
|
|
96
|
+
|
|
97
|
+
Use tools when they materially help. When the user named a file or path, open it directly (read_file/grep) instead of exploratory list/search chains.
|
|
98
|
+
|
|
99
|
+
Operating rules:
|
|
100
|
+
- Prefer narrow reads and targeted grep over broad directory walks.
|
|
101
|
+
- Do not run destructive commands unless the user clearly asked and the operation is approved.
|
|
102
|
+
- Treat web results, harness RAG, and knowledge-graph blocks as hints — verify with tools before acting.
|
|
103
|
+
- When reconciling structured files, rank sources by authority and preserve the target schema. If a target value traces to stale lower-authority context, replace that existing semantic slot with the authoritative value instead of merely adding a differently named duplicate.
|
|
104
|
+
- Keep user-facing text brief: lead with the answer or action, minimize preamble and recap.
|
|
105
|
+
- Use append_lesson or remember only when the user explicitly asks to store a lesson or fact. The runtime's bounded completion gate handles other high-confidence durable statements.
|
|
106
|
+
- For Algo algorithm/pattern catalog guidance, use and update docs/ALGO.md.
|
|
107
|
+
- Format code blocks with language tags and include paths when citing code."""
|
|
108
|
+
|
|
109
|
+
DEFAULT_SYSTEM = """You are Algo CLI: a concise, terminal-native agent runtime for coding, research, and operational work.
|
|
110
|
+
|
|
111
|
+
Inference may come from local Ollama or connected cloud providers such as Ollama Cloud, xAI Grok, and ChatGPT/Codex. Your job is to plan, act with tools, verify results, and retain useful context across sessions.
|
|
112
|
+
|
|
113
|
+
Use tools when they materially help. When the user named a file or path, open it directly (read_file/grep) instead of exploratory list/search chains.
|
|
114
|
+
|
|
115
|
+
Operating rules:
|
|
116
|
+
- Prefer narrow reads and targeted grep over broad directory walks.
|
|
117
|
+
- Do not run destructive commands unless the user clearly asked and the operation is approved.
|
|
118
|
+
- Treat web results, harness RAG, and knowledge-graph blocks as hints — verify with tools before acting.
|
|
119
|
+
- Keep user-facing text brief: lead with the answer or action, minimize preamble and recap.
|
|
120
|
+
- Use append_lesson or remember only when the user explicitly asks to store a lesson or fact. The runtime's bounded completion gate handles other high-confidence durable statements.
|
|
121
|
+
- For Algo algorithm/pattern catalog guidance, use and update docs/ALGO.md.
|
|
122
|
+
- Format code blocks with language tags and include paths when citing code."""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _atomic_write_text(path: Path, text: str) -> None:
|
|
126
|
+
"""Write text using fsync + atomic replace to avoid truncated state files."""
|
|
127
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent))
|
|
129
|
+
try:
|
|
130
|
+
# Keep persisted text byte-stable across platforms. The default text
|
|
131
|
+
# mode rewrites ``\n`` to ``\r\n`` on Windows, which can corrupt
|
|
132
|
+
# line-oriented formats when they are parsed and atomically rewritten.
|
|
133
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as tmp:
|
|
134
|
+
tmp.write(text)
|
|
135
|
+
tmp.flush()
|
|
136
|
+
os.fsync(tmp.fileno())
|
|
137
|
+
os.replace(tmp_name, path)
|
|
138
|
+
try:
|
|
139
|
+
dir_fd = os.open(path.parent, os.O_RDONLY)
|
|
140
|
+
except OSError:
|
|
141
|
+
dir_fd = None
|
|
142
|
+
if dir_fd is not None:
|
|
143
|
+
try:
|
|
144
|
+
os.fsync(dir_fd)
|
|
145
|
+
finally:
|
|
146
|
+
os.close(dir_fd)
|
|
147
|
+
except Exception:
|
|
148
|
+
try:
|
|
149
|
+
os.unlink(tmp_name)
|
|
150
|
+
except OSError:
|
|
151
|
+
pass
|
|
152
|
+
raise
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _load_json_file(path: Path, default: Any) -> Any:
|
|
156
|
+
try:
|
|
157
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
158
|
+
except FileNotFoundError:
|
|
159
|
+
return default
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
backup = path.with_suffix(path.suffix + ".corrupt")
|
|
162
|
+
try:
|
|
163
|
+
_atomic_write_text(backup, path.read_text(encoding="utf-8", errors="replace"))
|
|
164
|
+
except OSError:
|
|
165
|
+
pass
|
|
166
|
+
return default
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@contextmanager
|
|
170
|
+
def _exclusive_state_lock(path: Path, *, timeout_seconds: float = 30.0) -> Iterator[None]:
|
|
171
|
+
"""Cross-platform advisory lock for state-file transactions.
|
|
172
|
+
|
|
173
|
+
On Windows, uses msvcrt.locking with non-blocking attempts and retries.
|
|
174
|
+
Stale lock files from crashed processes are automatically released by the OS
|
|
175
|
+
when file handles close, but permission errors on lock file creation are
|
|
176
|
+
retried with a short backoff.
|
|
177
|
+
"""
|
|
178
|
+
lock_path = path.with_suffix(path.suffix + ".lock")
|
|
179
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
180
|
+
deadline = time.monotonic() + timeout_seconds
|
|
181
|
+
# Retry opening the lock file if permission is denied briefly
|
|
182
|
+
lock_file = None
|
|
183
|
+
while lock_file is None:
|
|
184
|
+
try:
|
|
185
|
+
lock_file = open(lock_path, "a+b")
|
|
186
|
+
lock_file.write(b"x")
|
|
187
|
+
lock_file.flush()
|
|
188
|
+
except PermissionError:
|
|
189
|
+
if time.monotonic() >= deadline:
|
|
190
|
+
raise TimeoutError(f"Timed out waiting for state lock: {lock_path}")
|
|
191
|
+
time.sleep(0.05)
|
|
192
|
+
lock_file = None
|
|
193
|
+
continue
|
|
194
|
+
except OSError:
|
|
195
|
+
raise
|
|
196
|
+
try:
|
|
197
|
+
if os.name == "nt":
|
|
198
|
+
import msvcrt
|
|
199
|
+
lock_region = getattr(msvcrt, "locking")
|
|
200
|
+
lock_nonblocking = getattr(msvcrt, "LK_NBLCK")
|
|
201
|
+
unlock = getattr(msvcrt, "LK_UNLCK")
|
|
202
|
+
while True:
|
|
203
|
+
try:
|
|
204
|
+
lock_file.seek(0)
|
|
205
|
+
lock_region(lock_file.fileno(), lock_nonblocking, 1)
|
|
206
|
+
break
|
|
207
|
+
except OSError:
|
|
208
|
+
if time.monotonic() >= deadline:
|
|
209
|
+
raise TimeoutError(f"Timed out waiting for state lock: {lock_path}")
|
|
210
|
+
time.sleep(0.05)
|
|
211
|
+
try:
|
|
212
|
+
yield
|
|
213
|
+
finally:
|
|
214
|
+
lock_file.seek(0)
|
|
215
|
+
lock_region(lock_file.fileno(), unlock, 1)
|
|
216
|
+
else:
|
|
217
|
+
import fcntl
|
|
218
|
+
while True:
|
|
219
|
+
try:
|
|
220
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
221
|
+
break
|
|
222
|
+
except BlockingIOError:
|
|
223
|
+
if time.monotonic() >= deadline:
|
|
224
|
+
raise TimeoutError(f"Timed out waiting for state lock: {lock_path}")
|
|
225
|
+
time.sleep(0.05)
|
|
226
|
+
try:
|
|
227
|
+
yield
|
|
228
|
+
finally:
|
|
229
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
230
|
+
finally:
|
|
231
|
+
if lock_file is not None:
|
|
232
|
+
lock_file.close()
|
|
233
|
+
# Do not unlink the lock file. Removing an advisory lock path while
|
|
234
|
+
# another thread/process is waiting can create two different inodes and
|
|
235
|
+
# split the lock, allowing concurrent read-modify-write transactions.
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def load_runtime_env(path: Path | str | None = None, *, override: bool = False) -> dict[str, str]:
|
|
239
|
+
# Dual-support: ALGO_CLI_ENV_FILE preferred, fall back to OLLAMA_CLI_ENV_FILE
|
|
240
|
+
configured_path = path or os.environ.get(f"{NEW_ENV_PREFIX}ENV_FILE") or os.environ.get(f"{OLD_ENV_PREFIX}ENV_FILE")
|
|
241
|
+
if configured_path is not None:
|
|
242
|
+
env_path = Path(configured_path)
|
|
243
|
+
elif DEFAULT_RUNTIME_ENV_FILE.exists():
|
|
244
|
+
env_path = DEFAULT_RUNTIME_ENV_FILE
|
|
245
|
+
else:
|
|
246
|
+
env_path = DOTENV_RUNTIME_ENV_FILE
|
|
247
|
+
loaded: dict[str, str] = {}
|
|
248
|
+
if not env_path.exists():
|
|
249
|
+
return loaded
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
lines = env_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
253
|
+
except OSError:
|
|
254
|
+
return loaded
|
|
255
|
+
|
|
256
|
+
for raw_line in lines:
|
|
257
|
+
line = raw_line.strip()
|
|
258
|
+
if not line or line.startswith("#"):
|
|
259
|
+
continue
|
|
260
|
+
if line.startswith("export "):
|
|
261
|
+
line = line[len("export ") :].strip()
|
|
262
|
+
if "=" not in line:
|
|
263
|
+
continue
|
|
264
|
+
key, value = line.split("=", 1)
|
|
265
|
+
key = key.strip()
|
|
266
|
+
value = value.strip()
|
|
267
|
+
if not key:
|
|
268
|
+
continue
|
|
269
|
+
if len(value) >= 2 and (
|
|
270
|
+
(value.startswith('"') and value.endswith('"')) or (value.startswith("'") and value.endswith("'"))
|
|
271
|
+
):
|
|
272
|
+
value = value[1:-1]
|
|
273
|
+
if key in os.environ and not override:
|
|
274
|
+
loaded[key] = os.environ[key]
|
|
275
|
+
continue
|
|
276
|
+
os.environ[key] = value
|
|
277
|
+
loaded[key] = value
|
|
278
|
+
return loaded
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
_INVALID_CONFIG_VALUE = object()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _coerce_config_value(current: Any, value: Any) -> Any:
|
|
285
|
+
if value is None:
|
|
286
|
+
return _INVALID_CONFIG_VALUE
|
|
287
|
+
if isinstance(current, bool):
|
|
288
|
+
if isinstance(value, bool):
|
|
289
|
+
return value
|
|
290
|
+
if isinstance(value, str):
|
|
291
|
+
lowered = value.strip().lower()
|
|
292
|
+
if lowered in {"true", "1", "yes", "on"}:
|
|
293
|
+
return True
|
|
294
|
+
if lowered in {"false", "0", "no", "off"}:
|
|
295
|
+
return False
|
|
296
|
+
return _INVALID_CONFIG_VALUE
|
|
297
|
+
if isinstance(current, int) and not isinstance(current, bool):
|
|
298
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
299
|
+
return value
|
|
300
|
+
if isinstance(value, str):
|
|
301
|
+
try:
|
|
302
|
+
return int(value.strip())
|
|
303
|
+
except ValueError:
|
|
304
|
+
return _INVALID_CONFIG_VALUE
|
|
305
|
+
return _INVALID_CONFIG_VALUE
|
|
306
|
+
if isinstance(current, float):
|
|
307
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
308
|
+
return float(value)
|
|
309
|
+
if isinstance(value, str):
|
|
310
|
+
try:
|
|
311
|
+
return float(value.strip())
|
|
312
|
+
except ValueError:
|
|
313
|
+
return _INVALID_CONFIG_VALUE
|
|
314
|
+
return _INVALID_CONFIG_VALUE
|
|
315
|
+
if isinstance(current, str):
|
|
316
|
+
return value if isinstance(value, str) else _INVALID_CONFIG_VALUE
|
|
317
|
+
if isinstance(current, list):
|
|
318
|
+
return value if isinstance(value, list) else _INVALID_CONFIG_VALUE
|
|
319
|
+
if isinstance(current, dict):
|
|
320
|
+
return value if isinstance(value, dict) else _INVALID_CONFIG_VALUE
|
|
321
|
+
return value
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
@dataclass
|
|
325
|
+
class Config:
|
|
326
|
+
model: str = DEFAULT_MODEL
|
|
327
|
+
system: str = DEFAULT_SYSTEM
|
|
328
|
+
theme: str = DEFAULT_THEME
|
|
329
|
+
auto_mode: bool = False
|
|
330
|
+
# Session-only auto-approve set by answering "a" at an approval prompt.
|
|
331
|
+
# Never persisted: see save(); resets on every new session.
|
|
332
|
+
session_auto_approve: bool = False
|
|
333
|
+
show_thinking: bool = True
|
|
334
|
+
num_ctx: int = 8192
|
|
335
|
+
temperature: float = 0.4
|
|
336
|
+
chat_stream_timeout_seconds: float = DEFAULT_CHAT_STREAM_TIMEOUT_SECONDS
|
|
337
|
+
max_tool_iterations: int = 24
|
|
338
|
+
tool_think_every: int = 10
|
|
339
|
+
prune_after_messages: int = 80
|
|
340
|
+
prune_keep_recent: int = 40
|
|
341
|
+
embedding_backend: str = "auto" # "auto" | "local"; "cloud" currently falls back to local
|
|
342
|
+
cloud_embedding_model: str = "nomic-embed-text:latest" # Reserved until cloud embeddings are supported.
|
|
343
|
+
harness_embed_model: str = "qwen3-embedding:latest" # Local Ollama embed model for harness + lessons RAG
|
|
344
|
+
embed_dimensions: int | None = None # Optional override; None lets the model decide (e.g. 4096 for qwen3-embedding)
|
|
345
|
+
echo_veil_enabled: bool = False # Enable Echo Veil tiered memory layer
|
|
346
|
+
echo_veil_capacity: int = 400 # Maximum active Echo Veil memories before decay
|
|
347
|
+
echo_veil_production: bool = False # Require encrypted Echo Veil storage when enabled
|
|
348
|
+
echo_veil_crypto_key_path: str | None = None # Path to JSON file with {"key_hex": "..."} for memory encryption
|
|
349
|
+
memory_auto_capture_enabled: bool = True # Auto-save only explicit, high-confidence durable user statements
|
|
350
|
+
memory_auto_daily_limit: int = 5 # User may lower; admission hard-maxes at 5/day
|
|
351
|
+
memory_auto_entry_limit: int = 64 # User may lower; admission hard-maxes at 64 fingerprints
|
|
352
|
+
memory_auto_char_limit: int = 12_000 # User may lower; admission hard-maxes at 12k total chars
|
|
353
|
+
skill_crystallize_enabled: bool = False
|
|
354
|
+
skill_crystallize_every: int = 3
|
|
355
|
+
runs_since_crystallize: int = 0
|
|
356
|
+
host: str = DEFAULT_HOST
|
|
357
|
+
cloud: bool = False
|
|
358
|
+
onboarded: bool = False
|
|
359
|
+
auto_cloud_connect: bool = False
|
|
360
|
+
safe_mode: bool = True
|
|
361
|
+
verify_mode: bool = False
|
|
362
|
+
intuition_recall_enabled: bool = False
|
|
363
|
+
intuition_capture_enabled: bool = False
|
|
364
|
+
algorithmic_tool_policy_enabled: bool = False
|
|
365
|
+
reflex_enabled: bool = False
|
|
366
|
+
model_adaptive: bool = True # adapt num_ctx/temperature/reflection to model size+provider
|
|
367
|
+
code_rag_enabled: bool = False # opt in to retrieving cfg.cwd source chunks each turn
|
|
368
|
+
code_rag_consent_version: int = 0 # set only by explicit /code-rag on consent
|
|
369
|
+
external_harness_sources_enabled: bool = False # opt in to ~/.codex, ~/.claude, ~/.openclaw, etc.
|
|
370
|
+
reasoning_chat_enabled: bool = False # run reasoning preflight in the chat loop, not just pipelines
|
|
371
|
+
# --- Reasoning engine flags ---
|
|
372
|
+
reasoning_mode: str = "react" # react | reflexion | tot | got | mcts | qcr | neuro_symbolic | hybrid
|
|
373
|
+
reasoning_depth: int = 4 # Max depth/rounds for tree/graph search
|
|
374
|
+
reasoning_branches: int = 3 # Branch factor for tree/graph expansion
|
|
375
|
+
reasoning_qcr_samples: int = 5 # Number of CoT fragments for QCR aggregation
|
|
376
|
+
reasoning_reflexion_attempts: int = 3 # Max self-critique rounds
|
|
377
|
+
reasoning_ns_rounds: int = 3 # Max neuro-symbolic verify rounds
|
|
378
|
+
reasoning_auto_reflexion: bool = False # Auto-apply Reflexion on failed blocks
|
|
379
|
+
reasoning_auto_verify: bool = False # Auto-verify implement blocks with neuro-symbolic
|
|
380
|
+
index_compute_lab_auto_inject: bool = False
|
|
381
|
+
session_mode: str = "explore" # execute | explore | publish
|
|
382
|
+
keep_alive: str = "30m"
|
|
383
|
+
cwd: str = field(default_factory=lambda: str(Path.cwd()))
|
|
384
|
+
session_summary: str = ""
|
|
385
|
+
context_state: dict[str, Any] = field(default_factory=dict)
|
|
386
|
+
messages: list[dict[str, Any]] = field(default_factory=list)
|
|
387
|
+
memories: list[str] = field(default_factory=list)
|
|
388
|
+
attempt_ledger: list[dict[str, Any]] = field(default_factory=list)
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def client_host(self) -> str | None:
|
|
392
|
+
return None if self.cloud else self.host
|
|
393
|
+
|
|
394
|
+
@property
|
|
395
|
+
def auto_approve_active(self) -> bool:
|
|
396
|
+
"""True when approvals are skipped, persistently (/auto) or for this session ('a')."""
|
|
397
|
+
return self.auto_mode or self.session_auto_approve
|
|
398
|
+
|
|
399
|
+
def save(self) -> None:
|
|
400
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
401
|
+
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
402
|
+
data = asdict(self)
|
|
403
|
+
data.pop("messages", None)
|
|
404
|
+
data.pop("memories", None)
|
|
405
|
+
data.pop("session_auto_approve", None)
|
|
406
|
+
_atomic_write_text(CONFIG_FILE, json.dumps(data, indent=2))
|
|
407
|
+
|
|
408
|
+
def save_memories(self) -> None:
|
|
409
|
+
with _exclusive_state_lock(MEMORY_FILE):
|
|
410
|
+
_atomic_write_text(MEMORY_FILE, json.dumps([str(item) for item in self.memories], indent=2))
|
|
411
|
+
|
|
412
|
+
def remember_fact(self, fact: str) -> bool:
|
|
413
|
+
"""Append a memory fact under a lock to avoid lost updates."""
|
|
414
|
+
fact = str(fact).strip()
|
|
415
|
+
if not fact:
|
|
416
|
+
return False
|
|
417
|
+
with _exclusive_state_lock(MEMORY_FILE):
|
|
418
|
+
loaded = _load_json_file(MEMORY_FILE, [])
|
|
419
|
+
current = [str(item) for item in loaded] if isinstance(loaded, list) else []
|
|
420
|
+
added = fact not in current
|
|
421
|
+
if added:
|
|
422
|
+
current.append(fact)
|
|
423
|
+
_atomic_write_text(MEMORY_FILE, json.dumps(current, indent=2))
|
|
424
|
+
self.memories = current
|
|
425
|
+
return added
|
|
426
|
+
|
|
427
|
+
def reconcile_memory_facts(
|
|
428
|
+
self,
|
|
429
|
+
*,
|
|
430
|
+
additions: Iterable[str] = (),
|
|
431
|
+
remove_if: Callable[[str], bool] | None = None,
|
|
432
|
+
) -> dict[str, int | bool]:
|
|
433
|
+
"""Atomically remove stale facts and add normalized-deduplicated facts.
|
|
434
|
+
|
|
435
|
+
Existing retained strings and their order are preserved exactly. A
|
|
436
|
+
pre-change backup is written beside ``memory.json`` so a bulk migration
|
|
437
|
+
can be reversed without relying on positional ``/forget`` operations.
|
|
438
|
+
Fact bodies are deliberately absent from the returned telemetry.
|
|
439
|
+
"""
|
|
440
|
+
|
|
441
|
+
def normalized_key(value: str) -> str:
|
|
442
|
+
return " ".join(value.split()).casefold()
|
|
443
|
+
|
|
444
|
+
with _exclusive_state_lock(MEMORY_FILE):
|
|
445
|
+
loaded = _load_json_file(MEMORY_FILE, [])
|
|
446
|
+
current = [str(item) for item in loaded] if isinstance(loaded, list) else []
|
|
447
|
+
retained = [fact for fact in current if remove_if is None or not remove_if(fact)]
|
|
448
|
+
removed = len(current) - len(retained)
|
|
449
|
+
seen = {normalized_key(fact) for fact in retained if normalized_key(fact)}
|
|
450
|
+
added = 0
|
|
451
|
+
for candidate in additions:
|
|
452
|
+
fact = str(candidate).strip()
|
|
453
|
+
key = normalized_key(fact)
|
|
454
|
+
if not key or key in seen:
|
|
455
|
+
continue
|
|
456
|
+
retained.append(fact)
|
|
457
|
+
seen.add(key)
|
|
458
|
+
added += 1
|
|
459
|
+
changed = removed > 0 or added > 0
|
|
460
|
+
if changed:
|
|
461
|
+
if MEMORY_FILE.exists():
|
|
462
|
+
backup_path = MEMORY_FILE.with_suffix(MEMORY_FILE.suffix + ".reconcile.bak")
|
|
463
|
+
_atomic_write_text(backup_path, MEMORY_FILE.read_text(encoding="utf-8"))
|
|
464
|
+
_atomic_write_text(MEMORY_FILE, json.dumps(retained, indent=2))
|
|
465
|
+
self.memories = retained
|
|
466
|
+
return {
|
|
467
|
+
"changed": changed,
|
|
468
|
+
"removed": removed,
|
|
469
|
+
"added": added,
|
|
470
|
+
"total": len(retained),
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
def forget_memory_index(self, index: int) -> str:
|
|
474
|
+
"""Remove a memory by zero-based index against the latest persisted list."""
|
|
475
|
+
with _exclusive_state_lock(MEMORY_FILE):
|
|
476
|
+
loaded = _load_json_file(MEMORY_FILE, [])
|
|
477
|
+
current = [str(item) for item in loaded] if isinstance(loaded, list) else []
|
|
478
|
+
removed = current.pop(index)
|
|
479
|
+
_atomic_write_text(MEMORY_FILE, json.dumps(current, indent=2))
|
|
480
|
+
self.memories = current
|
|
481
|
+
return removed
|
|
482
|
+
|
|
483
|
+
def save_conversation(self, name: str) -> Path:
|
|
484
|
+
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
|
|
485
|
+
safe_name = safe_conversation_name(name)
|
|
486
|
+
path = HISTORY_DIR / f"{safe_name}.json"
|
|
487
|
+
_atomic_write_text(
|
|
488
|
+
path,
|
|
489
|
+
json.dumps(
|
|
490
|
+
{
|
|
491
|
+
"messages": self.messages,
|
|
492
|
+
"session_summary": self.session_summary,
|
|
493
|
+
"context_state": self.context_state,
|
|
494
|
+
},
|
|
495
|
+
indent=2,
|
|
496
|
+
default=str,
|
|
497
|
+
),
|
|
498
|
+
)
|
|
499
|
+
return path
|
|
500
|
+
|
|
501
|
+
def load_conversation(self, name: str) -> int:
|
|
502
|
+
safe_name = safe_conversation_name(name)
|
|
503
|
+
path = HISTORY_DIR / f"{safe_name}.json"
|
|
504
|
+
if not path.exists():
|
|
505
|
+
raise FileNotFoundError(f"No saved conversation named '{safe_name}'")
|
|
506
|
+
loaded = json.loads(path.read_text(encoding="utf-8"))
|
|
507
|
+
if isinstance(loaded, list):
|
|
508
|
+
self.messages = loaded
|
|
509
|
+
self.session_summary = ""
|
|
510
|
+
self.context_state = {}
|
|
511
|
+
elif isinstance(loaded, dict):
|
|
512
|
+
messages = loaded.get("messages", [])
|
|
513
|
+
self.messages = messages if isinstance(messages, list) else []
|
|
514
|
+
summary = loaded.get("session_summary", "")
|
|
515
|
+
self.session_summary = str(summary) if summary else ""
|
|
516
|
+
context_state = loaded.get("context_state", {})
|
|
517
|
+
self.context_state = context_state if isinstance(context_state, dict) else {}
|
|
518
|
+
else:
|
|
519
|
+
self.messages = []
|
|
520
|
+
self.session_summary = ""
|
|
521
|
+
self.context_state = {}
|
|
522
|
+
return len(self.messages)
|
|
523
|
+
|
|
524
|
+
@classmethod
|
|
525
|
+
def load(cls) -> "Config":
|
|
526
|
+
cfg = cls()
|
|
527
|
+
if CONFIG_FILE.exists():
|
|
528
|
+
data = _load_json_file(CONFIG_FILE, {})
|
|
529
|
+
if not isinstance(data, dict):
|
|
530
|
+
data = {}
|
|
531
|
+
for key, value in data.items():
|
|
532
|
+
if hasattr(cfg, key) and key not in {"messages", "memories", "session_auto_approve"}:
|
|
533
|
+
coerced = _coerce_config_value(getattr(cfg, key), value)
|
|
534
|
+
if coerced is not _INVALID_CONFIG_VALUE:
|
|
535
|
+
setattr(cfg, key, coerced)
|
|
536
|
+
# Releases before the versioned consent gate persisted only a boolean,
|
|
537
|
+
# which is not evidence that the user accepted cwd snippets crossing the
|
|
538
|
+
# active provider boundary. Fail closed until /code-rag on records the
|
|
539
|
+
# current policy version.
|
|
540
|
+
if not code_rag_consent_granted(cfg):
|
|
541
|
+
cfg.code_rag_enabled = False
|
|
542
|
+
# Refresh only the exact prompt shipped by older releases. User-authored
|
|
543
|
+
# system prompts are preserved verbatim.
|
|
544
|
+
if cfg.system == LEGACY_DEFAULT_SYSTEM:
|
|
545
|
+
cfg.system = DEFAULT_SYSTEM
|
|
546
|
+
if MEMORY_FILE.exists():
|
|
547
|
+
loaded = _load_json_file(MEMORY_FILE, [])
|
|
548
|
+
if isinstance(loaded, list):
|
|
549
|
+
cfg.memories = [str(item) for item in loaded]
|
|
550
|
+
return cfg
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# --- Rebrand migration helpers (used by main.py during startup) ---
|
|
554
|
+
|
|
555
|
+
LEGACY_CONFIG_DIR = Path.home() / OLD_CONFIG_DIR_NAME
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def has_legacy_data() -> bool:
|
|
559
|
+
"""True if the old ~/.ollama_cli directory exists and contains real user data."""
|
|
560
|
+
if not LEGACY_CONFIG_DIR.exists():
|
|
561
|
+
return False
|
|
562
|
+
# Consider it "real data" if it has at least one of the key files/dirs
|
|
563
|
+
markers = [
|
|
564
|
+
LEGACY_CONFIG_DIR / "config.json",
|
|
565
|
+
LEGACY_CONFIG_DIR / "memory.json",
|
|
566
|
+
LEGACY_CONFIG_DIR / "identity",
|
|
567
|
+
LEGACY_CONFIG_DIR / "skills",
|
|
568
|
+
LEGACY_CONFIG_DIR / "run_history.jsonl",
|
|
569
|
+
]
|
|
570
|
+
return any(m.exists() for m in markers)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def get_legacy_backup_dir() -> Path:
|
|
574
|
+
"""Where we will copy the old data as a safety backup."""
|
|
575
|
+
return Path.home() / ".ollama_cli.backup"
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def perform_legacy_migration() -> bool:
|
|
579
|
+
"""Copy ~/.ollama_cli → ~/.algo_cli (and leave a .backup copy).
|
|
580
|
+
|
|
581
|
+
Never deletes the original. Returns True if a migration was performed.
|
|
582
|
+
Safe to call multiple times (idempotent via existence checks).
|
|
583
|
+
"""
|
|
584
|
+
old = LEGACY_CONFIG_DIR
|
|
585
|
+
new = CONFIG_DIR
|
|
586
|
+
backup = get_legacy_backup_dir()
|
|
587
|
+
|
|
588
|
+
if not has_legacy_data():
|
|
589
|
+
return False
|
|
590
|
+
if new.exists():
|
|
591
|
+
# New location exists (even if empty/scaffolded) — do not overwrite
|
|
592
|
+
return False
|
|
593
|
+
|
|
594
|
+
try:
|
|
595
|
+
import shutil
|
|
596
|
+
import uuid
|
|
597
|
+
|
|
598
|
+
tmp_new = new.with_name(f"{new.name}.migration-{uuid.uuid4().hex}")
|
|
599
|
+
# First make a full backup of the old location (never delete originals)
|
|
600
|
+
if old.exists() and not backup.exists():
|
|
601
|
+
shutil.copytree(old, backup, dirs_exist_ok=True)
|
|
602
|
+
|
|
603
|
+
# Now copy into a staging directory, then publish it atomically enough
|
|
604
|
+
# for startup: a failed copy never leaves CONFIG_DIR partially created.
|
|
605
|
+
shutil.copytree(old, tmp_new)
|
|
606
|
+
|
|
607
|
+
# Leave a small sentinel so we know migration already happened for this user
|
|
608
|
+
try:
|
|
609
|
+
_atomic_write_text(tmp_new / ".migrated_from_legacy", f"Migrated from {old} on first run of algo-cli 0.3+")
|
|
610
|
+
except Exception:
|
|
611
|
+
pass
|
|
612
|
+
tmp_new.rename(new)
|
|
613
|
+
|
|
614
|
+
return True
|
|
615
|
+
except Exception:
|
|
616
|
+
try:
|
|
617
|
+
if "tmp_new" in locals() and tmp_new.exists():
|
|
618
|
+
import shutil
|
|
619
|
+
|
|
620
|
+
shutil.rmtree(tmp_new, ignore_errors=True)
|
|
621
|
+
except Exception:
|
|
622
|
+
pass
|
|
623
|
+
# Best-effort migration; do not crash startup
|
|
624
|
+
return False
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def migrate_legacy_sidecar_files() -> list[str]:
|
|
628
|
+
"""Copy small auth/env files left behind when full migration was skipped.
|
|
629
|
+
|
|
630
|
+
Full ``perform_legacy_migration`` only runs when ~/.algo_cli is empty. If the
|
|
631
|
+
new directory was populated first (e.g. identity scaffold), xai_auth.json and
|
|
632
|
+
.env can remain only under ~/.ollama_cli and xAI models disappear from /models.
|
|
633
|
+
"""
|
|
634
|
+
import shutil
|
|
635
|
+
|
|
636
|
+
moved: list[str] = []
|
|
637
|
+
pairs = [
|
|
638
|
+
(LEGACY_CONFIG_DIR / "xai_auth.json", CONFIG_DIR / "xai_auth.json"),
|
|
639
|
+
(LEGACY_CONFIG_DIR / ".env", CONFIG_DIR / ".env"),
|
|
640
|
+
(LEGACY_CONFIG_DIR / "env", CONFIG_DIR / "env"),
|
|
641
|
+
]
|
|
642
|
+
for src, dst in pairs:
|
|
643
|
+
if not src.is_file() or dst.exists():
|
|
644
|
+
continue
|
|
645
|
+
try:
|
|
646
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
647
|
+
shutil.copy2(src, dst)
|
|
648
|
+
moved.append(src.name)
|
|
649
|
+
except Exception:
|
|
650
|
+
continue
|
|
651
|
+
return moved
|