Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Other install methods: [pip install](#alternative-install-with-pip) | [uv instal

## 🔥🔥🔥 News (Pacific Time)

- June 16, 2026 (latest): **Internal modules drop the `cc_` prefix`cc_config` / `cc_daemon` / `cc_kernel` / `cc_mcp` are now `config` / `daemon` / `kernel` / `mcp_client`.** A readability cleanup with no behavior change. The MCP client is named `mcp_client` (not bare `mcp`) to avoid shadowing Python's namespace and the `modelcontextprotocol` package. **Breaking only if you import these modules directly** — update your imports (`import cc_kernel` → `import kernel`, `from cc_mcp.client import …` → `from mcp_client.client import `). Whole-word rename so unrelated tokens are untouched; two name-collision regressions caught and fixed; full suite green (**2449 passed, 3 skipped**). Details: [docs/news.md](docs/news.md).
- June 16, 2026 (latest): **All internal modules now live under a single `cheetahclaws` packageimport as `cheetahclaws.config`, `cheetahclaws.kernel`, … instead of bare `config` / `kernel`.** The old flat top-level layout let generic names (`config`, `daemon`) get shadowed by other things on `sys.path` once the app was *installed* and launched from its entry point — another project's `config/` dir, the PyPI `python-daemon` package — which broke `cheetahclaws` at startup (`ImportError: … from 'config' (unknown location)`). Owning one `cheetahclaws.*` namespace removes that whole class of bug — a prerequisite for shipping as a broadly-installable app. **Breaking only if you import internals directly** (`import kernel` → `from cheetahclaws import kernel`). A built wheel ships everything under `cheetahclaws/` with no bare top-level modules; full suite green (**2449 passed, 3 skipped**). Details: [docs/news.md](docs/news.md).
- June 6, 2026 (**v3.5.82**): **macOS install reliably puts `cheetahclaws` on PATH, and local Ollama models that emit tool calls as text now actually execute them** (two fixes from issue #131). **(1) Install/PATH on macOS:** the installer `source`s the dedicated venv it creates, which made the post-install `command -v cheetahclaws` check succeed *inside the script's own shell* — so it reported "on PATH" and **skipped the entire rc-file step**, leaving `~/.zshrc` untouched and the binary unreachable in new terminals. It now symlinks only the `cheetahclaws` entry point into `~/.local/bin` (pipx-style, so the venv's `python`/`pip` don't shadow yours), creates `~/.zshrc` / `.bash_profile` if missing, and appends `~/.local/bin` to PATH there — without trusting the venv-polluted `command -v` (`scripts/install.sh`). **(2) Ollama tool calls:** `stream_ollama` only read Ollama's structured `message.tool_calls` field, while the cloud path already recovers calls a model emits as **text**, so Qwen-coder / Gemma / Mistral over Ollama produced "tool-calling-style chat" that streamed as plain text and never ran — the model seemed to "just keep talking." `stream_ollama` now mirrors the cloud path's interceptor: it buffers from the first `<tool_call>` / `<|tool_call|>` / `[TOOL_CALLS]` marker (so raw markup never reaches the user) and parses it into real tool calls at end-of-stream (`providers.py`). Details: [docs/guides/usage.md](docs/guides/usage.md#usage-open-source-models-local) · [docs/guides/faq.md](docs/guides/faq.md) · [docs/news.md](docs/news.md).
- June 5, 2026 (**v3.5.82**): **User-controllable token/cost budgets** — `/budget $5` / `/budget 200k` / `/budget daily $20` cap spend per session or per day, enforced before each model call; on hit the session auto-saves and you're shown how to `/resume` or raise the cap and continue (warns at ≥80%/95%; `--budget` sets it at startup). Details: [docs/guides/features.md](docs/guides/features.md) · [docs/news.md](docs/news.md).
- June 5, 2026: **Adaptive Markdown streaming — live output stays correct on every device** by auto-selecting a per-device tier (`live` in-place redraw on capable terminals incl. modern SSH emulators, append-only `commit` for SSH/Apple Terminal/pipes/CJK text so frames never duplicate, `plain` fallback); also ships a visual `/context` usage grid and a 1M context window for `deepseek-v4-flash`. Details: [docs/guides/features.md](docs/guides/features.md) · [docs/news.md](docs/news.md).
Expand Down
66 changes: 66 additions & 0 deletions cheetahclaws/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""CheetahClaws — package root.

Kept intentionally light. Importing ``cheetahclaws`` — or any submodule such
as ``cheetahclaws.config`` — must NOT trigger the CLI's heavy module-level
setup in :mod:`cheetahclaws.cli` (``.env`` loading, ``stdout``/``stderr``
wrapping, the command-table build). The CLI entry symbols (``cmd_*``,
``info``, ``err`` …) are exposed lazily through :func:`__getattr__`, so
``from cheetahclaws import cmd_init`` keeps working without paying that cost
on every submodule import.

Why a package at all: the modules used to live at the top level (``config``,
``daemon``, ``kernel`` …). Generic names like ``config`` / ``daemon`` collide
with other things on ``sys.path`` (another project's ``config/`` dir, the
``python-daemon`` package) once CheetahClaws is *installed* and launched from
its entry point rather than the repo dir. Owning a single ``cheetahclaws.*``
namespace removes that entire class of import-shadowing bug.
"""
from __future__ import annotations

from pathlib import Path


def _read_version() -> str:
"""Resolve the version: installed metadata first, pyproject.toml fallback."""
try:
from importlib.metadata import version as _v
return _v("cheetahclaws")
except Exception:
pass
try:
# pyproject.toml sits at the repo root, one level above this package.
_toml = Path(__file__).resolve().parent.parent / "pyproject.toml"
for _line in _toml.read_text(encoding="utf-8").splitlines():
if _line.startswith("version"):
return _line.split("=", 1)[1].strip().strip('"').strip("'")
except Exception:
pass
return "0.0.0"


VERSION = __version__ = _read_version()


def __getattr__(name: str):
"""Resolve ``cheetahclaws.<name>`` lazily (PEP 562).

Submodules (``config``, ``daemon``, ``kernel`` …) are imported directly —
crucially *without* touching :mod:`cheetahclaws.cli`, otherwise a
``from cheetahclaws import config`` would drag in the heavy CLI module and
recurse through its own ``from cheetahclaws import <submodule>`` lines.
Only names that are not submodules fall back to proxying a CLI entry symbol
(``cmd_init``, ``info``, ``err`` …).
"""
if name.startswith("__") and name.endswith("__"):
raise AttributeError(name)
import importlib
try:
return importlib.import_module(f"{__name__}.{name}")
except ImportError:
pass
from . import cli
try:
return getattr(cli, name)
except AttributeError:
raise AttributeError(
f"module 'cheetahclaws' has no attribute {name!r}") from None
5 changes: 5 additions & 0 deletions cheetahclaws/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""``python -m cheetahclaws`` entry point — delegates to the CLI."""
from cheetahclaws.cli import main

if __name__ == "__main__":
raise SystemExit(main())
38 changes: 19 additions & 19 deletions agent.py → cheetahclaws/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@
from dataclasses import dataclass, field
from typing import Generator

from tool_registry import get_tool_schemas
from tools import execute_tool
import tools as _tools_init # ensure built-in tools are registered on import
from providers import stream, AssistantTurn, TextChunk, ThinkingChunk, detect_provider, nim_next_model
from compaction import maybe_compact, estimate_tokens, get_context_limit, compact_messages, sanitize_history
import logging_utils as _log
import quota as _quota
from circuit_breaker import CircuitOpenError as _CircuitOpenError
import runtime
from cheetahclaws.tool_registry import get_tool_schemas
from cheetahclaws.tools import execute_tool
from cheetahclaws import tools as _tools_init # ensure built-in tools are registered on import
from cheetahclaws.providers import stream, AssistantTurn, TextChunk, ThinkingChunk, detect_provider, nim_next_model
from cheetahclaws.compaction import maybe_compact, estimate_tokens, get_context_limit, compact_messages, sanitize_history
from cheetahclaws import logging_utils as _log
from cheetahclaws import quota as _quota
from cheetahclaws.circuit_breaker import CircuitOpenError as _CircuitOpenError
from cheetahclaws import runtime

# ── Re-export event types (used by cheetahclaws.py) ────────────────────────
__all__ = [
Expand Down Expand Up @@ -170,8 +170,8 @@ def run(
if any(config.get(k) for k in ("session_token_budget", "session_cost_budget",
"daily_token_budget", "daily_cost_budget")):
try:
from compaction import estimate_tokens as _est_tok
from providers import calc_cost as _calc_cost
from cheetahclaws.compaction import estimate_tokens as _est_tok
from cheetahclaws.providers import calc_cost as _calc_cost
_proj_tokens = (_est_tok(state.messages)
+ _est_tok([{"role": "system", "content": system_prompt}]))
_proj_cost = _calc_cost(config["model"], _proj_tokens, 0)
Expand Down Expand Up @@ -232,7 +232,7 @@ def run(
return # circuit manages its own cooldown — don't retry

except Exception as e:
from error_classifier import classify as _classify_err, ErrorCategory as _ErrCat
from cheetahclaws.error_classifier import classify as _classify_err, ErrorCategory as _ErrCat
cerr = _classify_err(e)

# NIM 429 cascade: swap to the next free-tier model before
Expand Down Expand Up @@ -446,7 +446,7 @@ def run(
# Determine which tools can run in parallel — but treat redundant
# read-only calls as "sequential" (and short-circuit during exec)
# so the dedup path always lands on a single, predictable code path.
from tool_registry import get_tool as _get_tool
from cheetahclaws.tool_registry import get_tool as _get_tool
parallel_batch = []
sequential_batch = []
for tc in tool_calls:
Expand Down Expand Up @@ -539,11 +539,11 @@ def _exec_one(tc):
or _res_low.startswith("denied"))
if not _is_err:
try:
from multi_agent.fanout import (
from cheetahclaws.multi_agent.fanout import (
should_fanout, fanout_summarize, make_llm_caller,
fanout_notice,
)
from compaction import get_context_limit
from cheetahclaws.compaction import get_context_limit
_ctx = get_context_limit(config.get("model", ""), config)
if should_fanout(tc["name"], result, _ctx, config):
# Find last user message for query focus
Expand Down Expand Up @@ -650,15 +650,15 @@ def _check_permission(tc: dict, config: dict) -> bool:
if name == "NotebookEdit":
return False
if name == "Bash":
from tools import _is_safe_bash
from cheetahclaws.tools import _is_safe_bash
return _is_safe_bash(tc["input"].get("command", ""))
return True # reads are fine

# "auto" mode: only ask for writes and non-safe bash
if name in ("Read", "Glob", "Grep", "WebFetch", "WebSearch"):
return True
if name == "Bash":
from tools import _is_safe_bash
from cheetahclaws.tools import _is_safe_bash
return _is_safe_bash(tc["input"].get("command", ""))
return False # Write, Edit → ask

Expand All @@ -678,12 +678,12 @@ def _force_compact(state: AgentState, config: dict) -> bool:
before = estimate_tokens(state.messages)
if before <= 0:
return False
from compaction import snip_old_tool_results
from cheetahclaws.compaction import snip_old_tool_results
snip_old_tool_results(state.messages, max_chars=1000, preserve_last_n_turns=3)
if estimate_tokens(state.messages) < limit * 0.9:
return True
state.messages = compact_messages(state.messages, config)
from compaction import _restore_plan_context
from cheetahclaws.compaction import _restore_plan_context
state.messages.extend(_restore_plan_context(config))
after = estimate_tokens(state.messages)
return after < before
Expand Down
22 changes: 11 additions & 11 deletions agent_runner.py → cheetahclaws/agent_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from pathlib import Path
from typing import Callable, Optional

import logging_utils as _log
from cheetahclaws import logging_utils as _log

# ── Template resolution ────────────────────────────────────────────────────

Expand Down Expand Up @@ -122,7 +122,7 @@ def start_runner(
# send_fn is ignored in subprocess mode for the skeleton —
# ``notify`` IPC messages are dropped on the supervisor side
# until F-6/7/8 wires bridge delivery in.
from daemon import runner_supervisor
from cheetahclaws.daemon import runner_supervisor
return runner_supervisor.start(
name=name,
template_name=template_name,
Expand Down Expand Up @@ -161,7 +161,7 @@ def stop_runner(name: str) -> bool:
return True
# Subprocess mode (F-4): the handle lives in the daemon supervisor.
try:
from daemon import runner_supervisor
from cheetahclaws.daemon import runner_supervisor
except Exception:
return False
return runner_supervisor.stop(name)
Expand All @@ -175,7 +175,7 @@ def stop_all() -> int:
r.stop()
count = len(runners)
try:
from daemon import runner_supervisor
from cheetahclaws.daemon import runner_supervisor
count += runner_supervisor.stop_all()
except Exception:
pass
Expand Down Expand Up @@ -338,8 +338,8 @@ def _handle_permission_request(self, event) -> str:
return "permission"

def _run_loop(self) -> None:
from agent import AgentState, PermissionRequest, TurnDone
from agent import TextChunk, ToolStart, ToolEnd
from cheetahclaws.agent import AgentState, PermissionRequest, TurnDone
from cheetahclaws.agent import TextChunk, ToolStart, ToolEnd

state = AgentState()
config = self._config.copy()
Expand Down Expand Up @@ -417,7 +417,7 @@ def _run_loop(self) -> None:
# catches it internally and yields a quota text chunk —
# remains unchanged).
try:
import quota as _quota_mod
from cheetahclaws import quota as _quota_mod
_quota_mod.check_quota(
self._config.get("_session_id", "default"), self._config)
except _quota_mod.QuotaExceeded as _qe:
Expand All @@ -441,7 +441,7 @@ def _run_loop(self) -> None:
err_msg = ""

try:
for event in __import__("agent").run(
for event in __import__("cheetahclaws.agent", fromlist=["run"]).run(
prompt, state, config, system_prompt
):
if self._stop_event.is_set():
Expand Down Expand Up @@ -642,7 +642,7 @@ def _pipe_main(name_arg: Optional[str] = None) -> int:
"""Subprocess entry point. Returns the process exit code."""
import argparse
import sys as _sys
from daemon.runner_ipc import IpcReadTimeout, JsonLineChannel
from cheetahclaws.daemon.runner_ipc import IpcReadTimeout, JsonLineChannel

parser = argparse.ArgumentParser(prog="agent_runner")
parser.add_argument("--pipe", action="store_true",
Expand Down Expand Up @@ -700,8 +700,8 @@ def _pipe_main(name_arg: Optional[str] = None) -> int:
# paths can never reach it. The test caller drives termination via
# ``rs.stop()`` once it sees the iteration counter rise.
if os.environ.get("CHEETAHCLAWS_E2E_FAKE_AGENT") == "1":
import agent as _agent_mod
from agent import (
from cheetahclaws import agent as _agent_mod
from cheetahclaws.agent import (
TextChunk as _StubTextChunk,
TurnDone as _StubTurnDone,
PermissionRequest as _StubPermissionRequest,
Expand Down
File renamed without changes.
2 changes: 1 addition & 1 deletion auxiliary.py → cheetahclaws/auxiliary.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import os
from typing import Optional

import providers
from cheetahclaws import providers

# ── Fast model candidates (checked in order) ─────────────────────────────
# Each entry: (model_name, required_env_var_or_None)
Expand Down
6 changes: 3 additions & 3 deletions bootstrap.py → cheetahclaws/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"""
from __future__ import annotations

import logging_utils as _log
from cheetahclaws import logging_utils as _log

_bootstrapped: bool = False

Expand All @@ -36,14 +36,14 @@ def bootstrap(config: dict) -> None:
# ── 2. Tool registry ───────────────────────────────────────────────────
# tools.py self-registers built-ins + extension tools on first import.
# This import is the single explicit trigger; subsequent imports are no-ops.
import tools as _tools # noqa: F401
from cheetahclaws import tools as _tools # noqa: F401
_log.debug("bootstrap_tools_ready")

# ── 3. Health-check HTTP server ────────────────────────────────────────
port = config.get("health_check_port")
if port:
try:
from health import start_health_server
from cheetahclaws.health import start_health_server
start_health_server(int(port), config)
_log.info("health_server_started", port=int(port))
except Exception as exc:
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
import time
from typing import Callable

import logging_utils as _log
from cheetahclaws import logging_utils as _log

# ── pyte: proper vt100 terminal emulator ─────────────────────────────────
try:
Expand Down Expand Up @@ -115,7 +115,7 @@ def __init__(self, cmd: str, send_fn: Callable[[str], None],
if not isinstance(cmd, str) or "\x00" in cmd or len(cmd) > 4096:
raise RuntimeError("Refused: command empty, too long, or contains NUL.")
try:
from tools.shell import _bash_hard_denied
from cheetahclaws.tools.shell import _bash_hard_denied
denied = _bash_hard_denied(cmd)
except Exception:
denied = None
Expand Down
Loading
Loading