soothe-cli 0.7.12__tar.gz → 0.7.14__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.12 → soothe_cli-0.7.14}/PKG-INFO +1 -1
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/run_cmd.py +2 -2
- soothe_cli-0.7.14/src/soothe_cli/runtime/token_events_debug.py +180 -0
- soothe_cli-0.7.14/src/soothe_cli/runtime/token_usage.py +119 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/transport/session.py +16 -1
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/wire/chunk_filter.py +3 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_app.py +10 -5
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_execution.py +8 -83
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_history.py +12 -2
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_messages_mixin.py +5 -2
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_model.py +25 -6
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_module_init.py +1 -1
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_startup.py +7 -2
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/_ui.py +148 -31
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/binding.py +5 -4
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/command_registry.py +26 -8
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/sessions.py +1 -1
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/textual_adapter.py +187 -95
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/theme.py +1 -1
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/tips.py +1 -1
- soothe_cli-0.7.14/src/soothe_cli/tui/widgets/context_data.py +157 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/context_viewer.py +117 -86
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/loading.py +30 -2
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/loop_selector.py +35 -13
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/assistant.py +2 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/cognition_step.py +17 -38
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/cognition_step_activity.py +17 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/status.py +3 -2
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/.gitignore +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/README.md +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/pyproject.toml +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/autopilot_cmd.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/config_cmd.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/cron_cmd.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/loop_cmd.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/commands/status_cmd.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/daemon.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/daemon_errors.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/headless.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/headless_renderer.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/execution/launcher.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/cli/main.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/config/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/config/cli_config.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/config/loader.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/config/logging_setup.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/headless/processor.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/headless/processor_state.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/parse/message_processing.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/parse/tool_call_resolution.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/parse/tool_result.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/policy/display_policy.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/policy/essential_events.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/policy/tui_trace_log.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/presentation/duration_format.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/presentation/engine.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/presentation/renderer_base.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/presentation/renderer_protocol.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/presentation/subagent_task_display.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/state/file_tracker.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/state/session_stats.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/state/step_router.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/state/stream_accumulator.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/state/transcript.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/turn/pipeline.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/turn/prepare.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/wire/message_text.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/runtime/wire/messages.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/_cli_context.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/_env_vars.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/_version.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/app/app.tcss +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/commands/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/commands/command_router.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/commands/slash_commands.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/commands/subagent_routing.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/config.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/file_change_notify.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/file_change_renderers.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/hooks.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/input.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/markdown_theme.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/media_utils.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/model_config.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/path_utils.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/preview_limits.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/project_utils.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/shell_color.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/skills/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/skills/invocation.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/skills/load.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/spinner_labels.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/tool_display.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/unicode_security.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/update_check.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/_links.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/autocomplete.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/chat_input.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/clipboard.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/diff.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/editor.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/file_change_preview.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/help_screen.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/history.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/mcp_viewer.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/message_store.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/__init__.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/_helpers.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/app.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/clarification.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/cognition_goal_tree.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/cognition_reason.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/cognition_subagent.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/diff_message.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/error.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/skill.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/messages/user.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/model_selector.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/notification_settings.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/plan_quick_view_overlay.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/theme_selector.py +0 -0
- {soothe_cli-0.7.12 → soothe_cli-0.7.14}/src/soothe_cli/tui/widgets/welcome.py +0 -0
|
@@ -100,7 +100,7 @@ def run_impl(
|
|
|
100
100
|
result = get_last_app_result()
|
|
101
101
|
if result is not None and result.loop_id:
|
|
102
102
|
typer.echo(
|
|
103
|
-
f"💡 To continue
|
|
103
|
+
f"💡 To continue: soothe loop resume {result.loop_id}",
|
|
104
104
|
err=True,
|
|
105
105
|
)
|
|
106
106
|
|
|
@@ -113,7 +113,7 @@ def run_impl(
|
|
|
113
113
|
result = get_last_app_result()
|
|
114
114
|
if result is not None and result.loop_id:
|
|
115
115
|
typer.echo(
|
|
116
|
-
f"💡 To continue
|
|
116
|
+
f"💡 To continue: soothe loop resume {result.loop_id}",
|
|
117
117
|
err=True,
|
|
118
118
|
)
|
|
119
119
|
sys.exit(130)
|
|
@@ -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,119 @@
|
|
|
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
|
+
"coerce_total_tokens_used",
|
|
12
|
+
"extract_stream_message_token_usage",
|
|
13
|
+
"fetch_conversation_token_count",
|
|
14
|
+
"message_has_token_usage_metadata",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def coerce_total_tokens_used(value: Any) -> int:
|
|
19
|
+
"""Parse a non-negative ``total_tokens_used`` field from event payloads."""
|
|
20
|
+
try:
|
|
21
|
+
return max(0, int(value or 0))
|
|
22
|
+
except (TypeError, ValueError):
|
|
23
|
+
return 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _coerce_int(value: Any) -> int:
|
|
27
|
+
return coerce_total_tokens_used(value)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _usage_from_mapping(usage: dict[str, Any]) -> tuple[int, int, int]:
|
|
31
|
+
"""Return ``(input_tokens, output_tokens, total_tokens)`` from a usage dict."""
|
|
32
|
+
input_toks = _coerce_int(usage.get("input_tokens") or usage.get("prompt_tokens"))
|
|
33
|
+
output_toks = _coerce_int(usage.get("output_tokens") or usage.get("completion_tokens"))
|
|
34
|
+
total_toks = _coerce_int(usage.get("total_tokens"))
|
|
35
|
+
if not total_toks and (input_toks or output_toks):
|
|
36
|
+
total_toks = input_toks + output_toks
|
|
37
|
+
return input_toks, output_toks, total_toks
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def extract_stream_message_token_usage(message: Any) -> tuple[int, int, int]:
|
|
41
|
+
"""Extract token usage from a streamed LangChain or wire-format message.
|
|
42
|
+
|
|
43
|
+
Providers vary:
|
|
44
|
+
- LangChain ``usage_metadata`` (``input_tokens`` / ``output_tokens``)
|
|
45
|
+
- OpenAI-style ``response_metadata.token_usage`` (``prompt_tokens`` / ``completion_tokens``)
|
|
46
|
+
- Flat wire dicts with either shape
|
|
47
|
+
"""
|
|
48
|
+
if message is None:
|
|
49
|
+
return 0, 0, 0
|
|
50
|
+
|
|
51
|
+
usage = getattr(message, "usage_metadata", None)
|
|
52
|
+
if isinstance(usage, dict) and usage:
|
|
53
|
+
return _usage_from_mapping(usage)
|
|
54
|
+
|
|
55
|
+
response_metadata = getattr(message, "response_metadata", None)
|
|
56
|
+
if isinstance(response_metadata, dict):
|
|
57
|
+
for key in ("token_usage", "usage"):
|
|
58
|
+
nested = response_metadata.get(key)
|
|
59
|
+
if isinstance(nested, dict) and nested:
|
|
60
|
+
return _usage_from_mapping(nested)
|
|
61
|
+
|
|
62
|
+
if isinstance(message, dict):
|
|
63
|
+
from soothe_cli.runtime.wire.message_text import wire_message_body
|
|
64
|
+
|
|
65
|
+
bodies = [message]
|
|
66
|
+
nested = wire_message_body(message)
|
|
67
|
+
if nested is not message:
|
|
68
|
+
bodies.append(nested)
|
|
69
|
+
for body in bodies:
|
|
70
|
+
if not isinstance(body, dict):
|
|
71
|
+
continue
|
|
72
|
+
direct_usage = body.get("usage_metadata")
|
|
73
|
+
if isinstance(direct_usage, dict) and direct_usage:
|
|
74
|
+
return _usage_from_mapping(direct_usage)
|
|
75
|
+
response = body.get("response_metadata")
|
|
76
|
+
if isinstance(response, dict):
|
|
77
|
+
for key in ("token_usage", "usage"):
|
|
78
|
+
nested_usage = response.get(key)
|
|
79
|
+
if isinstance(nested_usage, dict) and nested_usage:
|
|
80
|
+
return _usage_from_mapping(nested_usage)
|
|
81
|
+
|
|
82
|
+
return 0, 0, 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def message_has_token_usage_metadata(message: Any) -> bool:
|
|
86
|
+
"""Return True when a stream message carries provider token usage."""
|
|
87
|
+
input_toks, output_toks, total_toks = extract_stream_message_token_usage(message)
|
|
88
|
+
return bool(input_toks or output_toks or total_toks)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def fetch_conversation_token_count(daemon_session: Any, loop_id: str | None) -> int | None:
|
|
92
|
+
"""Return approximate conversation-only token count from loop checkpoint messages."""
|
|
93
|
+
raw_loop_id = str(loop_id or "").strip()
|
|
94
|
+
if not raw_loop_id or daemon_session is None:
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
from langchain_core.messages import messages_from_dict
|
|
98
|
+
from langchain_core.messages.utils import count_tokens_approximately
|
|
99
|
+
|
|
100
|
+
snap = await daemon_session.aget_loop_state(raw_loop_id)
|
|
101
|
+
vals = getattr(snap, "values", None)
|
|
102
|
+
if not isinstance(vals, dict):
|
|
103
|
+
return None
|
|
104
|
+
raw = vals.get("messages")
|
|
105
|
+
if not isinstance(raw, list) or not raw:
|
|
106
|
+
return None
|
|
107
|
+
if isinstance(raw[0], dict):
|
|
108
|
+
messages = messages_from_dict(raw)
|
|
109
|
+
else:
|
|
110
|
+
messages = raw
|
|
111
|
+
count = count_tokens_approximately(messages)
|
|
112
|
+
return count if count > 0 else None
|
|
113
|
+
except Exception:
|
|
114
|
+
logger.debug(
|
|
115
|
+
"Failed to retrieve conversation token count for loop %s",
|
|
116
|
+
raw_loop_id,
|
|
117
|
+
exc_info=True,
|
|
118
|
+
)
|
|
119
|
+
return None
|
|
@@ -321,6 +321,7 @@ class TuiDaemonSession:
|
|
|
321
321
|
inbound_dropped_baseline = getattr(self._client, "inbound_dropped", 0)
|
|
322
322
|
query_started = False
|
|
323
323
|
expected_loop_id = self._loop_id
|
|
324
|
+
stream_payload_seen = False
|
|
324
325
|
self._streaming = True
|
|
325
326
|
turn_read_started = time.monotonic()
|
|
326
327
|
first_event_logged = False
|
|
@@ -405,7 +406,20 @@ class TuiDaemonSession:
|
|
|
405
406
|
if state == "running":
|
|
406
407
|
query_started = True
|
|
407
408
|
progress_seen = True
|
|
408
|
-
elif query_started and state
|
|
409
|
+
elif query_started and state == "stopped":
|
|
410
|
+
self.last_turn_end_state = state
|
|
411
|
+
async for chunk in self._drain_stream_events_after_idle(
|
|
412
|
+
expected_loop_id=expected_loop_id,
|
|
413
|
+
):
|
|
414
|
+
yield chunk
|
|
415
|
+
break
|
|
416
|
+
elif query_started and state == "idle":
|
|
417
|
+
if not stream_payload_seen and not self.last_turn_cancellation_seen:
|
|
418
|
+
logger.debug(
|
|
419
|
+
"Ignoring stale idle before stream payload (loop=%s)",
|
|
420
|
+
(expected_loop_id or "?")[:16],
|
|
421
|
+
)
|
|
422
|
+
continue
|
|
409
423
|
self.last_turn_end_state = state
|
|
410
424
|
async for chunk in self._drain_stream_events_after_idle(
|
|
411
425
|
expected_loop_id=expected_loop_id,
|
|
@@ -430,6 +444,7 @@ class TuiDaemonSession:
|
|
|
430
444
|
self.turn_event_stats.filtered_early += 1
|
|
431
445
|
continue
|
|
432
446
|
progress_seen = True
|
|
447
|
+
stream_payload_seen = True
|
|
433
448
|
yield (namespace, mode, data)
|
|
434
449
|
# Graph auto-resumes LangGraph interrupts server-side; keep consuming events.
|
|
435
450
|
if mode == "updates" and isinstance(data, dict) and "__interrupt__" in data:
|
|
@@ -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 "")
|
|
@@ -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)."""
|
|
@@ -25,7 +25,7 @@ from soothe_cli.cli.execution.daemon_errors import (
|
|
|
25
25
|
from soothe_cli.cli.execution.daemon_errors import (
|
|
26
26
|
friendly_daemon_execution_error as _friendly_agent_execution_error,
|
|
27
27
|
)
|
|
28
|
-
from soothe_cli.runtime.state.session_stats import SessionStats
|
|
28
|
+
from soothe_cli.runtime.state.session_stats import SessionStats
|
|
29
29
|
from soothe_cli.tui import theme
|
|
30
30
|
from soothe_cli.tui._cli_context import CLIContext
|
|
31
31
|
from soothe_cli.tui.app._module_init import (
|
|
@@ -450,37 +450,6 @@ class _ExecutionMixin:
|
|
|
450
450
|
"""
|
|
451
451
|
return f"{prefix}: {loop_id}"
|
|
452
452
|
|
|
453
|
-
async def _get_conversation_token_count(self) -> int | None:
|
|
454
|
-
"""Return the approximate conversation-only token count.
|
|
455
|
-
|
|
456
|
-
Returns:
|
|
457
|
-
Token count as an integer, or `None` if state is unavailable.
|
|
458
|
-
"""
|
|
459
|
-
if not self._lc_loop_id:
|
|
460
|
-
return None
|
|
461
|
-
try:
|
|
462
|
-
from langchain_core.messages import messages_from_dict
|
|
463
|
-
from langchain_core.messages.utils import count_tokens_approximately
|
|
464
|
-
|
|
465
|
-
if self._daemon_session is None:
|
|
466
|
-
return None
|
|
467
|
-
snap = await self._daemon_session.aget_loop_state(self._lc_loop_id)
|
|
468
|
-
vals = getattr(snap, "values", None)
|
|
469
|
-
if not isinstance(vals, dict):
|
|
470
|
-
return None
|
|
471
|
-
raw = vals.get("messages")
|
|
472
|
-
if not isinstance(raw, list) or not raw:
|
|
473
|
-
return None
|
|
474
|
-
if isinstance(raw[0], dict):
|
|
475
|
-
messages = messages_from_dict(raw)
|
|
476
|
-
else:
|
|
477
|
-
messages = raw
|
|
478
|
-
|
|
479
|
-
return count_tokens_approximately(messages)
|
|
480
|
-
except Exception: # best-effort for /tokens display
|
|
481
|
-
logger.debug("Failed to retrieve conversation token count", exc_info=True)
|
|
482
|
-
return None
|
|
483
|
-
|
|
484
453
|
async def _handle_command(self, command: str) -> None:
|
|
485
454
|
"""Handle a slash command.
|
|
486
455
|
|
|
@@ -516,9 +485,11 @@ class _ExecutionMixin:
|
|
|
516
485
|
self.query_one("#chat", VerticalScroll).anchor()
|
|
517
486
|
return
|
|
518
487
|
|
|
488
|
+
from soothe_cli.tui.command_registry import resolve_command_head
|
|
519
489
|
from soothe_cli.tui.config import settings
|
|
520
490
|
|
|
521
491
|
cmd = command.lower().strip()
|
|
492
|
+
cmd_head = resolve_command_head(command)
|
|
522
493
|
|
|
523
494
|
if cmd in {"/quit", "/q"}:
|
|
524
495
|
self._detach_or_exit()
|
|
@@ -540,9 +511,7 @@ class _ExecutionMixin:
|
|
|
540
511
|
self._pending_messages.clear()
|
|
541
512
|
self._queued_widgets.clear()
|
|
542
513
|
await self._clear_messages()
|
|
543
|
-
self.
|
|
544
|
-
self._tokens_approximate = False
|
|
545
|
-
self._update_tokens(0)
|
|
514
|
+
self._reset_loop_token_usage(None)
|
|
546
515
|
# Clear status message (e.g., "Interrupted" from previous session)
|
|
547
516
|
self._update_status("")
|
|
548
517
|
if self._session_state:
|
|
@@ -564,6 +533,7 @@ class _ExecutionMixin:
|
|
|
564
533
|
)
|
|
565
534
|
self._session_state.loop_id = new_loop_id
|
|
566
535
|
self._lc_loop_id = new_loop_id
|
|
536
|
+
self._reset_loop_token_usage(new_loop_id)
|
|
567
537
|
try:
|
|
568
538
|
banner = self.query_one("#welcome-banner", WelcomeBanner)
|
|
569
539
|
banner.update_loop_id(new_loop_id)
|
|
@@ -579,51 +549,8 @@ class _ExecutionMixin:
|
|
|
579
549
|
await self._handle_update_command()
|
|
580
550
|
elif cmd == "/auto-update":
|
|
581
551
|
await self._handle_auto_update_toggle()
|
|
582
|
-
elif
|
|
583
|
-
await self.
|
|
584
|
-
if self._context_tokens > 0:
|
|
585
|
-
count = self._context_tokens
|
|
586
|
-
formatted = format_token_count(count)
|
|
587
|
-
|
|
588
|
-
model_name = settings.model_name
|
|
589
|
-
context_limit = settings.model_context_limit
|
|
590
|
-
|
|
591
|
-
if context_limit is not None:
|
|
592
|
-
limit_str = format_token_count(context_limit)
|
|
593
|
-
pct = count / context_limit * 100
|
|
594
|
-
usage = f"{formatted} / {limit_str} tokens ({pct:.0f}%)"
|
|
595
|
-
else:
|
|
596
|
-
usage = f"{formatted} tokens used"
|
|
597
|
-
|
|
598
|
-
msg = f"{usage} \u00b7 {model_name}" if model_name else usage
|
|
599
|
-
|
|
600
|
-
conv_tokens = await self._get_conversation_token_count()
|
|
601
|
-
if conv_tokens is not None:
|
|
602
|
-
overhead = max(0, count - conv_tokens)
|
|
603
|
-
overhead_str = format_token_count(overhead)
|
|
604
|
-
conv_str = format_token_count(conv_tokens)
|
|
605
|
-
|
|
606
|
-
overhead_unit = " tokens" if overhead < 1000 else "" # noqa: PLR2004 # not bothersome, cosmetic
|
|
607
|
-
conv_unit = " tokens" if conv_tokens < 1000 else "" # noqa: PLR2004 # not bothersome, cosmetic
|
|
608
|
-
|
|
609
|
-
msg += (
|
|
610
|
-
f"\n\u251c System prompt + tools: ~{overhead_str}{overhead_unit} (fixed)" # noqa: E501
|
|
611
|
-
f"\n\u2514 Conversation: ~{conv_str}{conv_unit}"
|
|
612
|
-
)
|
|
613
|
-
|
|
614
|
-
await self._mount_message(AppMessage(msg))
|
|
615
|
-
else:
|
|
616
|
-
model_name = settings.model_name
|
|
617
|
-
context_limit = settings.model_context_limit
|
|
618
|
-
|
|
619
|
-
parts: list[str] = ["No token usage yet"]
|
|
620
|
-
if context_limit is not None:
|
|
621
|
-
limit_str = format_token_count(context_limit)
|
|
622
|
-
parts.append(f"{limit_str} token context window")
|
|
623
|
-
if model_name:
|
|
624
|
-
parts.append(model_name)
|
|
625
|
-
|
|
626
|
-
await self._mount_message(AppMessage(" · ".join(parts)))
|
|
552
|
+
elif cmd_head == "/context":
|
|
553
|
+
await self._show_context_viewer()
|
|
627
554
|
elif cmd == "/skill-creator" or cmd.startswith("/skill-creator "):
|
|
628
555
|
# Convenience alias for /skill:skill-creator — shorter and
|
|
629
556
|
# discoverable before skill loading completes.
|
|
@@ -654,8 +581,6 @@ class _ExecutionMixin:
|
|
|
654
581
|
)
|
|
655
582
|
return
|
|
656
583
|
await self._submit_cron_job(args, slash_input=command)
|
|
657
|
-
elif cmd == "/context":
|
|
658
|
-
await self._show_context_viewer()
|
|
659
584
|
elif cmd == "/mcp":
|
|
660
585
|
await self._show_mcp_viewer()
|
|
661
586
|
elif cmd == "/theme":
|
|
@@ -1046,7 +971,7 @@ class _ExecutionMixin:
|
|
|
1046
971
|
|
|
1047
972
|
# Ensure token display is restored (in case of early cancellation).
|
|
1048
973
|
# Pass the cached approximate flag so an interrupted "+" isn't clobbered.
|
|
1049
|
-
self.
|
|
974
|
+
self._refresh_token_displays(approximate=self._tokens_approximate)
|
|
1050
975
|
|
|
1051
976
|
try:
|
|
1052
977
|
await self._maybe_drain_deferred()
|
|
@@ -185,9 +185,12 @@ class _HistoryMixin:
|
|
|
185
185
|
)
|
|
186
186
|
return _LoopHistoryPayload([], context_tokens, goal_dicts)
|
|
187
187
|
|
|
188
|
-
from soothe_sdk.display.card_binder import
|
|
188
|
+
from soothe_sdk.display.card_binder import (
|
|
189
|
+
merge_consecutive_assistant_cards,
|
|
190
|
+
sanitize_resume_display_cards,
|
|
191
|
+
)
|
|
189
192
|
|
|
190
|
-
data = merge_consecutive_assistant_cards(data)
|
|
193
|
+
data = sanitize_resume_display_cards(merge_consecutive_assistant_cards(data))
|
|
191
194
|
|
|
192
195
|
return _LoopHistoryPayload(data, context_tokens, goal_dicts)
|
|
193
196
|
|
|
@@ -294,6 +297,7 @@ class _HistoryMixin:
|
|
|
294
297
|
|
|
295
298
|
logger.info("Starting background event consumer for subscribed loop")
|
|
296
299
|
from langchain_core.messages import AIMessage, AIMessageChunk, ToolMessage
|
|
300
|
+
from soothe_sdk.ux.loop_stream import LOOP_ASSISTANT_OUTPUT_PHASES, assistant_output_phase
|
|
297
301
|
|
|
298
302
|
assistant_cards_by_ns: dict[tuple[Any, ...], AssistantMessage] = {}
|
|
299
303
|
last_user_text_by_ns: dict[tuple[Any, ...], str] = {}
|
|
@@ -324,6 +328,9 @@ class _HistoryMixin:
|
|
|
324
328
|
message, _metadata = data
|
|
325
329
|
message = normalize_stream_message(message)
|
|
326
330
|
|
|
331
|
+
if self._is_loop_internal_checkpoint_message(message):
|
|
332
|
+
continue
|
|
333
|
+
|
|
327
334
|
user_text = extract_user_text_for_display(message)
|
|
328
335
|
if user_text is not None:
|
|
329
336
|
# Deduplicate immediate replayed user rows after reconnect/resubscribe.
|
|
@@ -338,6 +345,9 @@ class _HistoryMixin:
|
|
|
338
345
|
continue
|
|
339
346
|
|
|
340
347
|
if isinstance(message, (AIMessage, AIMessageChunk)):
|
|
348
|
+
phase = assistant_output_phase(message)
|
|
349
|
+
if phase is not None and phase not in LOOP_ASSISTANT_OUTPUT_PHASES:
|
|
350
|
+
continue
|
|
341
351
|
extracted = extract_ai_text_for_display(message)
|
|
342
352
|
is_terminal = is_stream_terminal(message)
|
|
343
353
|
if extracted:
|
|
@@ -96,9 +96,9 @@ class _MessagesMixin:
|
|
|
96
96
|
if not payload.messages:
|
|
97
97
|
return
|
|
98
98
|
|
|
99
|
-
# Seed token
|
|
99
|
+
# Seed loop token total from persisted checkpoint
|
|
100
100
|
if payload.context_tokens > 0:
|
|
101
|
-
self.
|
|
101
|
+
self._seed_loop_token_from_checkpoint(payload.context_tokens)
|
|
102
102
|
|
|
103
103
|
# Bulk load into store (sets visible window; replaces prior data).
|
|
104
104
|
_archived, visible = self._message_store.bulk_load(
|
|
@@ -113,9 +113,12 @@ class _MessagesMixin:
|
|
|
113
113
|
return
|
|
114
114
|
|
|
115
115
|
# Create and mount only visible widgets (max WINDOW_SIZE).
|
|
116
|
+
from soothe_sdk.display.card_binder import sanitize_resume_display_cards
|
|
117
|
+
|
|
116
118
|
from soothe_cli.tui.binding import message_to_widget
|
|
117
119
|
|
|
118
120
|
visible = self._dedupe_message_data_by_id(visible)
|
|
121
|
+
visible = sanitize_resume_display_cards(visible)
|
|
119
122
|
widgets = [message_to_widget(msg_data) for msg_data in visible]
|
|
120
123
|
if widgets:
|
|
121
124
|
await messages_container.mount(*widgets)
|