soothe-cli 0.7.11__tar.gz → 0.7.13__tar.gz
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.
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/PKG-INFO +1 -1
- soothe_cli-0.7.13/src/soothe_cli/__init__.py +5 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/main.py +2 -2
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/presentation/duration_format.py +9 -2
- soothe_cli-0.7.13/src/soothe_cli/runtime/token_events_debug.py +180 -0
- soothe_cli-0.7.13/src/soothe_cli/runtime/token_usage.py +113 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/wire/chunk_filter.py +3 -0
- soothe_cli-0.7.13/src/soothe_cli/tui/_version.py +128 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_app.py +10 -5
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_execution.py +10 -112
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_history.py +12 -2
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_messages_mixin.py +5 -2
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_model.py +25 -6
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_module_init.py +1 -1
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_startup.py +10 -2
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/_ui.py +148 -31
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/binding.py +5 -4
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/command_registry.py +26 -8
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/config.py +13 -86
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/sessions.py +1 -1
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/textual_adapter.py +187 -95
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/tips.py +1 -1
- soothe_cli-0.7.13/src/soothe_cli/tui/widgets/context_data.py +157 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/context_viewer.py +117 -86
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/loading.py +33 -5
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/loop_selector.py +35 -13
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/assistant.py +2 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +2 -3
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +2 -2
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +1 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/status.py +3 -2
- soothe_cli-0.7.11/src/soothe_cli/__init__.py +0 -10
- soothe_cli-0.7.11/src/soothe_cli/tui/_version.py +0 -21
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/.gitignore +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/README.md +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/pyproject.toml +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/run_cmd.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/daemon.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/headless.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/config/cli_config.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/config/loader.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/config/logging_setup.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/__init__.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/headless/processor.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/presentation/engine.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/state/session_stats.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/state/step_router.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/state/transcript.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/transport/session.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/turn/prepare.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/wire/message_text.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/wire/messages.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/_cli_context.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/_env_vars.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/__init__.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/app/app.tcss +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/commands/__init__.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/commands/command_router.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/file_change_notify.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/file_change_renderers.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/hooks.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/input.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/markdown_theme.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/media_utils.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/model_config.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/path_utils.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/preview_limits.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/project_utils.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/shell_color.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/spinner_labels.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/theme.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/tool_display.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/unicode_security.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/update_check.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/cognition_step.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
- {soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/tui/widgets/welcome.py +0 -0
|
@@ -6,7 +6,6 @@ from dotenv import load_dotenv
|
|
|
6
6
|
|
|
7
7
|
load_dotenv()
|
|
8
8
|
|
|
9
|
-
from importlib.metadata import version # noqa: E402
|
|
10
9
|
from pathlib import Path # noqa: E402
|
|
11
10
|
from typing import Annotated # noqa: E402
|
|
12
11
|
|
|
@@ -15,6 +14,7 @@ from soothe_sdk.client.config import SOOTHE_HOME # noqa: E402
|
|
|
15
14
|
|
|
16
15
|
from soothe_cli.config.cli_config import CLIConfig # noqa: E402
|
|
17
16
|
from soothe_cli.config.loader import set_runtime_config # noqa: E402
|
|
17
|
+
from soothe_cli.tui._version import __version__ # noqa: E402
|
|
18
18
|
from soothe_cli.tui.markdown_theme import ( # noqa: E402
|
|
19
19
|
DEFAULT_MARKDOWN_THEME,
|
|
20
20
|
REGISTRY,
|
|
@@ -177,7 +177,7 @@ def main(
|
|
|
177
177
|
|
|
178
178
|
# Handle --version flag
|
|
179
179
|
if show_version:
|
|
180
|
-
typer.echo(f"soothe {
|
|
180
|
+
typer.echo(f"soothe {__version__}")
|
|
181
181
|
raise typer.Exit
|
|
182
182
|
|
|
183
183
|
home_path = Path(soothe_home).expanduser() if soothe_home else Path(SOOTHE_HOME)
|
{soothe_cli-0.7.11 → soothe_cli-0.7.13}/src/soothe_cli/runtime/presentation/duration_format.py
RENAMED
|
@@ -4,13 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
def format_duration(seconds: float) -> str:
|
|
7
|
-
"""Format a duration in seconds into a human-readable string.
|
|
7
|
+
"""Format a completed duration in seconds into a human-readable string.
|
|
8
8
|
|
|
9
9
|
Args:
|
|
10
10
|
seconds: Duration in seconds.
|
|
11
11
|
|
|
12
12
|
Returns:
|
|
13
|
-
Formatted string like
|
|
13
|
+
Formatted string like ``5s``, ``2.3s``, ``5m 12s``, or ``1h 23m 4s``.
|
|
14
|
+
For live running timers that tick in whole seconds, use
|
|
15
|
+
:func:`format_running_elapsed` instead.
|
|
14
16
|
"""
|
|
15
17
|
rounded = round(seconds, 1)
|
|
16
18
|
if rounded < 60: # noqa: PLR2004
|
|
@@ -24,6 +26,11 @@ def format_duration(seconds: float) -> str:
|
|
|
24
26
|
return f"{hours}h {minutes}m {secs}s"
|
|
25
27
|
|
|
26
28
|
|
|
29
|
+
def format_running_elapsed(seconds: float) -> str:
|
|
30
|
+
"""Format a live running timer using whole-second ticks (e.g. ``20s``, ``1m 5s``)."""
|
|
31
|
+
return format_duration(float(max(0, int(seconds))))
|
|
32
|
+
|
|
33
|
+
|
|
27
34
|
def format_duration_ms(milliseconds: int) -> str:
|
|
28
35
|
"""Format a wall-clock duration in milliseconds for status lines and cards.
|
|
29
36
|
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Debug tracing for loop token events in the CLI TUI.
|
|
2
|
+
|
|
3
|
+
Enable with ``logging_level: DEBUG`` in CLI config (``~/.soothe/config.yml``)
|
|
4
|
+
or ``SOOTHE_LOG_LEVEL=DEBUG``. Grep ``cli.log`` for ``[token-events]``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger("soothe_cli.token_events")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class TokenEventTrace:
|
|
17
|
+
"""Per-turn counters for token-related daemon/TUI events."""
|
|
18
|
+
|
|
19
|
+
stream_chunks: int = 0
|
|
20
|
+
stream_input_tokens: int = 0
|
|
21
|
+
stream_output_tokens: int = 0
|
|
22
|
+
plan_phase_events: int = 0
|
|
23
|
+
plan_phase_with_total_field: int = 0
|
|
24
|
+
plan_phase_missing_total_field: int = 0
|
|
25
|
+
step_completed_events: int = 0
|
|
26
|
+
step_completed_with_total_field: int = 0
|
|
27
|
+
authoritative_applied: int = 0
|
|
28
|
+
authoritative_unchanged: int = 0
|
|
29
|
+
display_refreshes: int = 0
|
|
30
|
+
anomalies: list[str] = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
def reset(self) -> None:
|
|
33
|
+
"""Clear counters for a new turn."""
|
|
34
|
+
self.stream_chunks = 0
|
|
35
|
+
self.stream_input_tokens = 0
|
|
36
|
+
self.stream_output_tokens = 0
|
|
37
|
+
self.plan_phase_events = 0
|
|
38
|
+
self.plan_phase_with_total_field = 0
|
|
39
|
+
self.plan_phase_missing_total_field = 0
|
|
40
|
+
self.step_completed_events = 0
|
|
41
|
+
self.step_completed_with_total_field = 0
|
|
42
|
+
self.authoritative_applied = 0
|
|
43
|
+
self.authoritative_unchanged = 0
|
|
44
|
+
self.display_refreshes = 0
|
|
45
|
+
self.anomalies.clear()
|
|
46
|
+
|
|
47
|
+
def note_stream_usage(
|
|
48
|
+
self, *, input_tokens: int, output_tokens: int, total_tokens: int
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Record a messages-stream chunk carrying provider usage."""
|
|
51
|
+
self.stream_chunks += 1
|
|
52
|
+
self.stream_input_tokens += max(0, input_tokens)
|
|
53
|
+
self.stream_output_tokens += max(0, output_tokens)
|
|
54
|
+
logger.debug(
|
|
55
|
+
"[token-events] stream usage chunk #%d in=%d out=%d total=%d",
|
|
56
|
+
self.stream_chunks,
|
|
57
|
+
input_tokens,
|
|
58
|
+
output_tokens,
|
|
59
|
+
total_tokens,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
def note_plan_phase(
|
|
63
|
+
self, *, label: str, total_tokens_used: int | None, has_total_field: bool
|
|
64
|
+
) -> None:
|
|
65
|
+
"""Record a StrangeLoop plan-phase lifecycle event."""
|
|
66
|
+
self.plan_phase_events += 1
|
|
67
|
+
if has_total_field:
|
|
68
|
+
self.plan_phase_with_total_field += 1
|
|
69
|
+
else:
|
|
70
|
+
self.plan_phase_missing_total_field += 1
|
|
71
|
+
self.anomalies.append(f"plan_phase missing total_tokens_used ({label!r})")
|
|
72
|
+
logger.debug(
|
|
73
|
+
"[token-events] plan_phase label=%r total=%s has_total_field=%s",
|
|
74
|
+
label,
|
|
75
|
+
total_tokens_used if has_total_field else "—",
|
|
76
|
+
has_total_field,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def note_step_completed(
|
|
80
|
+
self, *, step_id: str, total_tokens_used: int | None, has_total_field: bool
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Record step.completed with optional backend loop total."""
|
|
83
|
+
self.step_completed_events += 1
|
|
84
|
+
if has_total_field:
|
|
85
|
+
self.step_completed_with_total_field += 1
|
|
86
|
+
logger.debug(
|
|
87
|
+
"[token-events] step_completed step_id=%s total=%s has_total_field=%s",
|
|
88
|
+
step_id or "—",
|
|
89
|
+
total_tokens_used if has_total_field else "—",
|
|
90
|
+
has_total_field,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def note_authoritative_merge(
|
|
94
|
+
self,
|
|
95
|
+
*,
|
|
96
|
+
source: str,
|
|
97
|
+
goal_run_total: int,
|
|
98
|
+
previous_goal_run: int,
|
|
99
|
+
applied: bool,
|
|
100
|
+
display_total: int,
|
|
101
|
+
) -> None:
|
|
102
|
+
"""Record backend total merged into the TUI loop counter."""
|
|
103
|
+
if applied:
|
|
104
|
+
self.authoritative_applied += 1
|
|
105
|
+
else:
|
|
106
|
+
self.authoritative_unchanged += 1
|
|
107
|
+
logger.debug(
|
|
108
|
+
"[token-events] authoritative source=%s goal_run=%d prev_goal_run=%d applied=%s display=%d",
|
|
109
|
+
source,
|
|
110
|
+
goal_run_total,
|
|
111
|
+
previous_goal_run,
|
|
112
|
+
applied,
|
|
113
|
+
display_total,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def note_display_refresh(self, *, display_total: int, target: str) -> None:
|
|
117
|
+
"""Record a push to the thinking row or status bar."""
|
|
118
|
+
self.display_refreshes += 1
|
|
119
|
+
logger.debug(
|
|
120
|
+
"[token-events] display refresh target=%s total=%d refresh#%d",
|
|
121
|
+
target,
|
|
122
|
+
display_total,
|
|
123
|
+
self.display_refreshes,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def finish_turn(
|
|
127
|
+
self,
|
|
128
|
+
*,
|
|
129
|
+
loop_id: str | None,
|
|
130
|
+
baseline: int,
|
|
131
|
+
goal_run: int,
|
|
132
|
+
display_total: int,
|
|
133
|
+
turn_input: int,
|
|
134
|
+
turn_output: int,
|
|
135
|
+
approximate: bool,
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Emit one summary line per turn; flags missing events."""
|
|
138
|
+
has_stream = self.stream_chunks > 0
|
|
139
|
+
has_backend_total = (
|
|
140
|
+
self.plan_phase_with_total_field > 0 or self.step_completed_with_total_field > 0
|
|
141
|
+
)
|
|
142
|
+
has_any_usage = display_total > 0 or turn_input > 0 or turn_output > 0
|
|
143
|
+
|
|
144
|
+
if has_any_usage and not has_stream and not has_backend_total:
|
|
145
|
+
self.anomalies.append("usage visible but no stream or backend total events")
|
|
146
|
+
if self.plan_phase_events > 0 and self.plan_phase_missing_total_field > 0:
|
|
147
|
+
self.anomalies.append(
|
|
148
|
+
f"{self.plan_phase_missing_total_field}/{self.plan_phase_events} plan_phase "
|
|
149
|
+
"events lacked total_tokens_used"
|
|
150
|
+
)
|
|
151
|
+
if not has_any_usage and (self.plan_phase_events or self.step_completed_events):
|
|
152
|
+
self.anomalies.append("lifecycle events seen but display total still zero")
|
|
153
|
+
|
|
154
|
+
status = "ok"
|
|
155
|
+
if self.anomalies:
|
|
156
|
+
status = "anomaly"
|
|
157
|
+
elif not has_stream and not has_backend_total and not has_any_usage:
|
|
158
|
+
status = "no_events"
|
|
159
|
+
|
|
160
|
+
logger.debug(
|
|
161
|
+
"[token-events] turn summary loop=%s status=%s baseline=%d goal_run=%d "
|
|
162
|
+
"display=%d turn_in=%d turn_out=%d stream_chunks=%d plan_phase=%d "
|
|
163
|
+
"step_completed=%d authoritative_applied=%d approximate=%s anomalies=%s",
|
|
164
|
+
loop_id or "—",
|
|
165
|
+
status,
|
|
166
|
+
baseline,
|
|
167
|
+
goal_run,
|
|
168
|
+
display_total,
|
|
169
|
+
turn_input,
|
|
170
|
+
turn_output,
|
|
171
|
+
self.stream_chunks,
|
|
172
|
+
self.plan_phase_events,
|
|
173
|
+
self.step_completed_events,
|
|
174
|
+
self.authoritative_applied,
|
|
175
|
+
approximate,
|
|
176
|
+
"; ".join(self.anomalies) if self.anomalies else "none",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
__all__ = ["TokenEventTrace", "logger"]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Token usage extraction and estimation helpers for the TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
logger = logging.getLogger(__name__)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"extract_stream_message_token_usage",
|
|
12
|
+
"fetch_conversation_token_count",
|
|
13
|
+
"message_has_token_usage_metadata",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _coerce_int(value: Any) -> int:
|
|
18
|
+
try:
|
|
19
|
+
return max(0, int(value or 0))
|
|
20
|
+
except (TypeError, ValueError):
|
|
21
|
+
return 0
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _usage_from_mapping(usage: dict[str, Any]) -> tuple[int, int, int]:
|
|
25
|
+
"""Return ``(input_tokens, output_tokens, total_tokens)`` from a usage dict."""
|
|
26
|
+
input_toks = _coerce_int(usage.get("input_tokens") or usage.get("prompt_tokens"))
|
|
27
|
+
output_toks = _coerce_int(usage.get("output_tokens") or usage.get("completion_tokens"))
|
|
28
|
+
total_toks = _coerce_int(usage.get("total_tokens"))
|
|
29
|
+
if not total_toks and (input_toks or output_toks):
|
|
30
|
+
total_toks = input_toks + output_toks
|
|
31
|
+
return input_toks, output_toks, total_toks
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def extract_stream_message_token_usage(message: Any) -> tuple[int, int, int]:
|
|
35
|
+
"""Extract token usage from a streamed LangChain or wire-format message.
|
|
36
|
+
|
|
37
|
+
Providers vary:
|
|
38
|
+
- LangChain ``usage_metadata`` (``input_tokens`` / ``output_tokens``)
|
|
39
|
+
- OpenAI-style ``response_metadata.token_usage`` (``prompt_tokens`` / ``completion_tokens``)
|
|
40
|
+
- Flat wire dicts with either shape
|
|
41
|
+
"""
|
|
42
|
+
if message is None:
|
|
43
|
+
return 0, 0, 0
|
|
44
|
+
|
|
45
|
+
usage = getattr(message, "usage_metadata", None)
|
|
46
|
+
if isinstance(usage, dict) and usage:
|
|
47
|
+
return _usage_from_mapping(usage)
|
|
48
|
+
|
|
49
|
+
response_metadata = getattr(message, "response_metadata", None)
|
|
50
|
+
if isinstance(response_metadata, dict):
|
|
51
|
+
for key in ("token_usage", "usage"):
|
|
52
|
+
nested = response_metadata.get(key)
|
|
53
|
+
if isinstance(nested, dict) and nested:
|
|
54
|
+
return _usage_from_mapping(nested)
|
|
55
|
+
|
|
56
|
+
if isinstance(message, dict):
|
|
57
|
+
from soothe_cli.runtime.wire.message_text import wire_message_body
|
|
58
|
+
|
|
59
|
+
bodies = [message]
|
|
60
|
+
nested = wire_message_body(message)
|
|
61
|
+
if nested is not message:
|
|
62
|
+
bodies.append(nested)
|
|
63
|
+
for body in bodies:
|
|
64
|
+
if not isinstance(body, dict):
|
|
65
|
+
continue
|
|
66
|
+
direct_usage = body.get("usage_metadata")
|
|
67
|
+
if isinstance(direct_usage, dict) and direct_usage:
|
|
68
|
+
return _usage_from_mapping(direct_usage)
|
|
69
|
+
response = body.get("response_metadata")
|
|
70
|
+
if isinstance(response, dict):
|
|
71
|
+
for key in ("token_usage", "usage"):
|
|
72
|
+
nested_usage = response.get(key)
|
|
73
|
+
if isinstance(nested_usage, dict) and nested_usage:
|
|
74
|
+
return _usage_from_mapping(nested_usage)
|
|
75
|
+
|
|
76
|
+
return 0, 0, 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def message_has_token_usage_metadata(message: Any) -> bool:
|
|
80
|
+
"""Return True when a stream message carries provider token usage."""
|
|
81
|
+
input_toks, output_toks, total_toks = extract_stream_message_token_usage(message)
|
|
82
|
+
return bool(input_toks or output_toks or total_toks)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def fetch_conversation_token_count(daemon_session: Any, loop_id: str | None) -> int | None:
|
|
86
|
+
"""Return approximate conversation-only token count from loop checkpoint messages."""
|
|
87
|
+
raw_loop_id = str(loop_id or "").strip()
|
|
88
|
+
if not raw_loop_id or daemon_session is None:
|
|
89
|
+
return None
|
|
90
|
+
try:
|
|
91
|
+
from langchain_core.messages import messages_from_dict
|
|
92
|
+
from langchain_core.messages.utils import count_tokens_approximately
|
|
93
|
+
|
|
94
|
+
snap = await daemon_session.aget_loop_state(raw_loop_id)
|
|
95
|
+
vals = getattr(snap, "values", None)
|
|
96
|
+
if not isinstance(vals, dict):
|
|
97
|
+
return None
|
|
98
|
+
raw = vals.get("messages")
|
|
99
|
+
if not isinstance(raw, list) or not raw:
|
|
100
|
+
return None
|
|
101
|
+
if isinstance(raw[0], dict):
|
|
102
|
+
messages = messages_from_dict(raw)
|
|
103
|
+
else:
|
|
104
|
+
messages = raw
|
|
105
|
+
count = count_tokens_approximately(messages)
|
|
106
|
+
return count if count > 0 else None
|
|
107
|
+
except Exception:
|
|
108
|
+
logger.debug(
|
|
109
|
+
"Failed to retrieve conversation token count for loop %s",
|
|
110
|
+
raw_loop_id,
|
|
111
|
+
exc_info=True,
|
|
112
|
+
)
|
|
113
|
+
return None
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import Any
|
|
6
6
|
|
|
7
|
+
from soothe_cli.runtime.token_usage import message_has_token_usage_metadata
|
|
7
8
|
from soothe_cli.runtime.wire.message_text import (
|
|
8
9
|
extract_plain_text_from_stream_message,
|
|
9
10
|
wire_message_body,
|
|
@@ -73,6 +74,8 @@ def message_chunk_is_non_actionable(data: Any) -> bool:
|
|
|
73
74
|
|
|
74
75
|
if isinstance(msg, ToolMessage):
|
|
75
76
|
return False
|
|
77
|
+
if message_has_token_usage_metadata(msg):
|
|
78
|
+
return False
|
|
76
79
|
if isinstance(msg, dict):
|
|
77
80
|
body = wire_message_body(msg)
|
|
78
81
|
raw = str(body.get("type") or msg.get("type") or "")
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Version information and lightweight constants for `soothe`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import re
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, distribution, version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from urllib.parse import unquote, urlparse
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
_VERSION_PATTERN = re.compile(r"^(\d+\.\d+\.\d+)")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _parse_version_file_text(text: str) -> str | None:
|
|
18
|
+
"""Extract the semver from a repo ``VERSION`` file."""
|
|
19
|
+
for line in text.splitlines():
|
|
20
|
+
stripped = line.strip()
|
|
21
|
+
if not stripped or stripped.startswith("#"):
|
|
22
|
+
continue
|
|
23
|
+
match = _VERSION_PATTERN.match(stripped)
|
|
24
|
+
if match:
|
|
25
|
+
return match.group(1)
|
|
26
|
+
return None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _editable_source_roots() -> list[Path]:
|
|
30
|
+
"""Return resolved source roots for editable Soothe packages."""
|
|
31
|
+
roots: list[Path] = []
|
|
32
|
+
seen: set[Path] = set()
|
|
33
|
+
for dist_name in ("soothe-cli", "soothe", "Soothe"):
|
|
34
|
+
try:
|
|
35
|
+
dist = distribution(dist_name)
|
|
36
|
+
except PackageNotFoundError:
|
|
37
|
+
continue
|
|
38
|
+
try:
|
|
39
|
+
raw = dist.read_text("direct_url.json")
|
|
40
|
+
except (FileNotFoundError, OSError):
|
|
41
|
+
continue
|
|
42
|
+
if not raw:
|
|
43
|
+
continue
|
|
44
|
+
try:
|
|
45
|
+
data = json.loads(raw)
|
|
46
|
+
except json.JSONDecodeError:
|
|
47
|
+
logger.debug("Invalid direct_url.json for %s", dist_name, exc_info=True)
|
|
48
|
+
continue
|
|
49
|
+
if not data.get("dir_info", {}).get("editable"):
|
|
50
|
+
continue
|
|
51
|
+
url = data.get("url", "")
|
|
52
|
+
if not url.startswith("file://"):
|
|
53
|
+
continue
|
|
54
|
+
root = Path(unquote(urlparse(url).path)).resolve()
|
|
55
|
+
if root not in seen:
|
|
56
|
+
seen.add(root)
|
|
57
|
+
roots.append(root)
|
|
58
|
+
return roots
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_repo_version_from_source(root: Path) -> str | None:
|
|
62
|
+
"""Read the monorepo ``VERSION`` file for an editable install root."""
|
|
63
|
+
candidates = [root.parent.parent / "VERSION", *(parent / "VERSION" for parent in root.parents)]
|
|
64
|
+
for path in candidates:
|
|
65
|
+
if not path.is_file():
|
|
66
|
+
continue
|
|
67
|
+
try:
|
|
68
|
+
parsed = _parse_version_file_text(path.read_text(encoding="utf-8"))
|
|
69
|
+
except OSError:
|
|
70
|
+
logger.debug("Failed to read VERSION file at %s", path, exc_info=True)
|
|
71
|
+
continue
|
|
72
|
+
if parsed:
|
|
73
|
+
return parsed
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _resolve_version() -> str:
|
|
78
|
+
"""Resolve the CLI version, preferring repo ``VERSION`` for editable installs."""
|
|
79
|
+
for root in _editable_source_roots():
|
|
80
|
+
repo_version = _read_repo_version_from_source(root)
|
|
81
|
+
if repo_version:
|
|
82
|
+
return repo_version
|
|
83
|
+
|
|
84
|
+
for pkg in ("soothe-cli", "soothe"):
|
|
85
|
+
try:
|
|
86
|
+
return version(pkg)
|
|
87
|
+
except PackageNotFoundError:
|
|
88
|
+
continue
|
|
89
|
+
return "0.0.0"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def is_editable_install() -> bool:
|
|
93
|
+
"""Return whether Soothe is installed in editable mode."""
|
|
94
|
+
return bool(_editable_source_roots())
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def editable_install_display_path() -> str | None:
|
|
98
|
+
"""Return a ``~``-contracted source path for editable installs."""
|
|
99
|
+
roots = _editable_source_roots()
|
|
100
|
+
if not roots:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
install_root = roots[0]
|
|
104
|
+
for root in roots:
|
|
105
|
+
if root.name == "soothe-cli":
|
|
106
|
+
install_root = root
|
|
107
|
+
break
|
|
108
|
+
|
|
109
|
+
path = str(install_root)
|
|
110
|
+
home = str(Path.home())
|
|
111
|
+
if path.startswith(home):
|
|
112
|
+
return "~" + path[len(home) :]
|
|
113
|
+
return path
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
__version__ = _resolve_version()
|
|
117
|
+
|
|
118
|
+
DOCS_URL = "https://github.com/mirasoth/soothe/docs"
|
|
119
|
+
"""URL for Soothe documentation."""
|
|
120
|
+
|
|
121
|
+
PYPI_URL = "https://pypi.org/pypi/soothe/json"
|
|
122
|
+
"""PyPI JSON API endpoint for version checks."""
|
|
123
|
+
|
|
124
|
+
CHANGELOG_URL = "https://github.com/mirasoth/soothe/blob/main/CHANGELOG.md"
|
|
125
|
+
"""URL for the full changelog."""
|
|
126
|
+
|
|
127
|
+
USER_AGENT = f"soothe/{__version__} update-check"
|
|
128
|
+
"""User-Agent header sent with PyPI requests."""
|
|
@@ -253,12 +253,17 @@ class SootheApp(
|
|
|
253
253
|
self._connect_spinner_start_mono: float | None = None
|
|
254
254
|
"""Monotonic anchor for startup daemon-connect elapsed time in the thinking row."""
|
|
255
255
|
|
|
256
|
-
self.
|
|
257
|
-
"""
|
|
256
|
+
self._loop_token_scope_id: str | None = None
|
|
257
|
+
"""Loop id that ``_loop_*_tokens`` counters belong to."""
|
|
258
258
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
259
|
+
self._loop_baseline_tokens: int = 0
|
|
260
|
+
"""Persisted loop usage total loaded from checkpoint (resume baseline)."""
|
|
261
|
+
|
|
262
|
+
self._loop_input_tokens: int = 0
|
|
263
|
+
"""Input tokens for the in-flight goal/turn (stream or backend)."""
|
|
264
|
+
|
|
265
|
+
self._loop_output_tokens: int = 0
|
|
266
|
+
"""Output tokens for the in-flight goal/turn (stream or backend)."""
|
|
262
267
|
|
|
263
268
|
self._tokens_approximate: bool = False
|
|
264
269
|
"""Whether the cached token count is stale (interrupted generation)."""
|