kiwi-code 0.0.449__tar.gz → 0.0.502__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.
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/PKG-INFO +2 -2
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/pyproject.toml +2 -2
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/cli.py +11 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/client.py +67 -2
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/commands.py +367 -1
- kiwi_code-0.0.502/src/kiwi_cli/human_questions.py +200 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/sessions.py +13 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard.py +950 -85
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard_css.py +132 -1
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard_helpers.py +119 -7
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/term_dashboard_widgets.py +5 -1
- kiwi_code-0.0.502/src/kiwi_tui/screens/term_question_widgets.py +567 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/slash_commands.py +5 -0
- kiwi_code-0.0.502/tests/test_human_questions.py +636 -0
- kiwi_code-0.0.502/tests/test_run_steering.py +535 -0
- kiwi_code-0.0.502/tests/test_runtime_imports_on_windows.py +39 -0
- kiwi_code-0.0.502/tests/test_stats_command.py +210 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/uv.lock +5 -5
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/.github/workflows/publish.yml +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/.github/workflows/test.yml +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/.gitignore +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/.python-version +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/Makefile +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/README.md +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/docs/runtime_audit.html +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/docs/social_login_e2e.md +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/scripts/set_edition.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/scripts/ws_auth_check.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/common/__init__.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/common/banner.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/__init__.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/_edition.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/auth.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/autocode.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/checkpoints.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/edition.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/logger.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/models.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/oauth.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/runtime_manager.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/server.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_cli/terminal_mode.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/__init__.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/__main__.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/auth.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/checkpoints.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/cli.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/commands.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/constants.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/__init__.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/batch.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/codelens.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/context.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/handler.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/paths.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/read.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/search.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/text.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/upload.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/fs/write.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/handlers.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/link.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/main.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/restricted.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/state.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/tui_bridge.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/ui.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_runtime/uploads.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/__init__.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/inline_file_picker.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/main.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/random_words.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/role_mode.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/runtime_agent.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/__init__.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/attach_content.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/command_result.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/detach_files.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/file_browser.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/help.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/id_picker.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/login.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/runtime_cleanup.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/runtime_logs.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/screens/slash_picker.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/status_words.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/term_app.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/ui_settings.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/widgets.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/src/kiwi_tui/worktrees.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/test_hello.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/__init__.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/conftest.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_account_server.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_autocode_actions.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_batch_fs_tool.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_checkpoints.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_cli_help.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_copy_path_fs_tool.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_fs_edit_fidelity.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_imports.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_login_social.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_oauth.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_read_file_streaming.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_reexec_kiwi.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_role_mode_injection.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_runtime_cleanup_per_account.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_runtime_connection.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_runtime_fixes.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_runtime_log_trimming.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_runtime_social_login.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_search_in_files_fs_tool.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_slash_commands.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_term_dashboard_ui.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_terminal_mode.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_tokens.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_tui_interactive_runtime.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_tui_palette.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_unified_diff.py +0 -0
- {kiwi_code-0.0.449 → kiwi_code-0.0.502}/tests/test_worktrees.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: kiwi-code
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.502
|
|
4
4
|
Summary: A textual-based terminal user interface application
|
|
5
5
|
Project-URL: Homepage, https://kiwicode.ai
|
|
6
6
|
Author-email: Anurag <anurag@meetkiwi.co>
|
|
@@ -19,7 +19,7 @@ Requires-Dist: httpx>=0.25.0
|
|
|
19
19
|
Requires-Dist: loguru>=0.7.3
|
|
20
20
|
Requires-Dist: psutil>=5.9.0
|
|
21
21
|
Requires-Dist: pydantic>=2.12.5
|
|
22
|
-
Requires-Dist: pykiwiai==0.0.
|
|
22
|
+
Requires-Dist: pykiwiai==0.0.14
|
|
23
23
|
Requires-Dist: setproctitle>=1.3.0
|
|
24
24
|
Requires-Dist: textual-dev>=1.8.0
|
|
25
25
|
Requires-Dist: textual>=8.1.1
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "kiwi-code"
|
|
3
|
-
version = "0.0.
|
|
3
|
+
version = "0.0.502"
|
|
4
4
|
description = "A textual-based terminal user interface application"
|
|
5
5
|
readme = {file = "README.md", content-type = "text/markdown"}
|
|
6
6
|
requires-python = ">=3.11,<4.0"
|
|
7
7
|
dependencies = [
|
|
8
|
-
"pykiwiai==0.0.
|
|
8
|
+
"pykiwiai==0.0.14",
|
|
9
9
|
"loguru>=0.7.3",
|
|
10
10
|
"pydantic>=2.12.5",
|
|
11
11
|
"textual>=8.1.1",
|
|
@@ -206,6 +206,17 @@ def runs_get_cmd(id: str = typer.Argument(help="Action run ID")):
|
|
|
206
206
|
|
|
207
207
|
# -- top-level --------------------------------------------------------------
|
|
208
208
|
|
|
209
|
+
@app.command("stats")
|
|
210
|
+
def stats_cmd(
|
|
211
|
+
range: str = typer.Option(commands.DEFAULT_STATS_RANGE, "--range", help="7d, 30d, 90d or 1y"),
|
|
212
|
+
):
|
|
213
|
+
"""Show run statistics: runs, tokens, spend."""
|
|
214
|
+
if range not in commands.STATS_RANGES:
|
|
215
|
+
_print([f"Invalid range: {range}. Valid: {', '.join(commands.STATS_RANGES)}"])
|
|
216
|
+
raise typer.Exit(2)
|
|
217
|
+
_print(_run_with_refresh_retry(lambda c: commands.stats(c, range_key=range)))
|
|
218
|
+
|
|
219
|
+
|
|
209
220
|
@app.command()
|
|
210
221
|
def login(
|
|
211
222
|
username: str = typer.Option(..., prompt=True, help="Email"),
|
|
@@ -11,8 +11,12 @@ from pykiwiai.api.hello import hello_v1_hello_get
|
|
|
11
11
|
from pykiwiai.api.auth import (
|
|
12
12
|
return_user_and_session_v1_auth_post,
|
|
13
13
|
)
|
|
14
|
-
from pykiwiai.api.actions import
|
|
14
|
+
from pykiwiai.api.actions import (
|
|
15
|
+
answer_human_questions_v1_actions_action_result_id_answer_post,
|
|
16
|
+
async_run_action_v1_actions_id_async_run_post,
|
|
17
|
+
)
|
|
15
18
|
from pykiwiai.api.action_results import get_action_result_v1_action_results_id_get
|
|
19
|
+
from pykiwiai.models.human_answers_request import HumanAnswersRequest
|
|
16
20
|
from pykiwiai.models.body_return_user_and_session_v1_auth_post import (
|
|
17
21
|
BodyReturnUserAndSessionV1AuthPost,
|
|
18
22
|
)
|
|
@@ -21,6 +25,7 @@ from pykiwiai.models.body_async_run_action_v1_actions_id_async_run_post import (
|
|
|
21
25
|
)
|
|
22
26
|
from pykiwiai.models import DataBlock, Metadata
|
|
23
27
|
from .models import AuthTokens, LoginCredentials
|
|
28
|
+
from .human_questions import WAITING_STATUS, human_event_type, parse_questions
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
class AutobotsClientWrapper:
|
|
@@ -545,8 +550,18 @@ class AutobotsClientWrapper:
|
|
|
545
550
|
except Exception:
|
|
546
551
|
pass
|
|
547
552
|
|
|
553
|
+
questions = getattr(result, "human_questions", None)
|
|
554
|
+
result_dict["human_questions"] = parse_questions(
|
|
555
|
+
[q.to_dict() for q in questions] if isinstance(questions, list) else None
|
|
556
|
+
)
|
|
557
|
+
|
|
548
558
|
result_dict["id"] = run_id
|
|
549
559
|
|
|
560
|
+
# Prompts waiting behind the current turn: with any left, a finished turn
|
|
561
|
+
# is not the end of the run.
|
|
562
|
+
queued_inputs = getattr(getattr(result, "queued_inputs", None), "inputs", None)
|
|
563
|
+
result_dict["queued"] = len(queued_inputs) if isinstance(queued_inputs, list) else 0
|
|
564
|
+
|
|
550
565
|
logger.info(f"Parsed result: status={result_dict.get('status')}, has_result={('result' in result_dict)}")
|
|
551
566
|
logger.info(f"Result structure keys: {result_dict.keys()}")
|
|
552
567
|
if "result" in result_dict and isinstance(result_dict["result"], dict):
|
|
@@ -599,6 +614,10 @@ class AutobotsClientWrapper:
|
|
|
599
614
|
elif status == "cancelled":
|
|
600
615
|
return False, result, "Action cancelled"
|
|
601
616
|
|
|
617
|
+
# The turn ended asking the user something: done until they answer.
|
|
618
|
+
elif status == WAITING_STATUS:
|
|
619
|
+
return True, result, "Action waiting for an answer"
|
|
620
|
+
|
|
602
621
|
# Still processing (a run being cancelled is not over until it says so)
|
|
603
622
|
elif status in ["processing", "running", "pending", "request_cancel"]:
|
|
604
623
|
logger.debug(f"Action still {status}, attempt {attempt + 1}/{max_attempts}")
|
|
@@ -679,7 +698,12 @@ class AutobotsClientWrapper:
|
|
|
679
698
|
try:
|
|
680
699
|
data = json.loads(data_str)
|
|
681
700
|
on_message(data)
|
|
682
|
-
|
|
701
|
+
# A question event's `status` is the questions', not the run's.
|
|
702
|
+
if (
|
|
703
|
+
stop_on_terminal_status
|
|
704
|
+
and isinstance(data, dict)
|
|
705
|
+
and human_event_type(data) is None
|
|
706
|
+
):
|
|
683
707
|
status = str(data.get("status", "") or "").lower()
|
|
684
708
|
if status in ["completed", "success", "finished", "failed", "error", "cancelled"]:
|
|
685
709
|
return True
|
|
@@ -746,6 +770,47 @@ class AutobotsClientWrapper:
|
|
|
746
770
|
logger.error(f"SSE error: {e}")
|
|
747
771
|
raise
|
|
748
772
|
|
|
773
|
+
def answer_human_questions(
|
|
774
|
+
self, run_id: str, answers: list[dict]
|
|
775
|
+
) -> tuple[int, Optional[dict], str]:
|
|
776
|
+
"""Answer questions the run's model asked with `ask_human`.
|
|
777
|
+
|
|
778
|
+
`POST /v1/actions/{run_id}/answer`. Every open question must be in `answers`, each
|
|
779
|
+
answered (`selected` and/or `text`) or skipped (`skip: true`); recorded all or nothing.
|
|
780
|
+
|
|
781
|
+
Returns:
|
|
782
|
+
Tuple of (http_status, body, message). On 200 the body has `question_ids` and
|
|
783
|
+
`delivered_as`: "turn" when the run had finished and reopened as processing,
|
|
784
|
+
"instruct" when it was still running. 0 means the request did not complete.
|
|
785
|
+
"""
|
|
786
|
+
try:
|
|
787
|
+
if not isinstance(self.client, AuthenticatedClient):
|
|
788
|
+
return 401, None, "Authentication required"
|
|
789
|
+
response = answer_human_questions_v1_actions_action_result_id_answer_post.sync_detailed(
|
|
790
|
+
action_result_id=run_id,
|
|
791
|
+
client=self.client,
|
|
792
|
+
body=HumanAnswersRequest.from_dict({"answers": answers}),
|
|
793
|
+
)
|
|
794
|
+
code = int(response.status_code)
|
|
795
|
+
if code == 200 and response.parsed is not None and hasattr(response.parsed, "delivered_as"):
|
|
796
|
+
body = response.parsed.to_dict()
|
|
797
|
+
logger.info(f"Answered {len(answers)} question(s) on run {run_id}: {body}")
|
|
798
|
+
return 200, body, "Answer sent"
|
|
799
|
+
# Only 200 and 422 are typed; the 400/403/404/409 `detail` is read from the body.
|
|
800
|
+
try:
|
|
801
|
+
body = json.loads(response.content or b"null")
|
|
802
|
+
except ValueError:
|
|
803
|
+
body = None
|
|
804
|
+
detail = body.get("detail") if isinstance(body, dict) else None
|
|
805
|
+
if not isinstance(detail, str):
|
|
806
|
+
detail = json.dumps(detail) if detail else (response.content or b"").decode("utf-8", "replace")
|
|
807
|
+
message = f"HTTP {code}" + (f": {detail}" if detail else "")
|
|
808
|
+
logger.warning(f"Answering questions on run {run_id} failed: {message}")
|
|
809
|
+
return code, body if isinstance(body, dict) else None, message
|
|
810
|
+
except Exception as e:
|
|
811
|
+
logger.error(f"Error answering questions on run {run_id}: {e}")
|
|
812
|
+
return 0, None, f"Error sending answer: {e}"
|
|
813
|
+
|
|
749
814
|
def close(self) -> None:
|
|
750
815
|
"""Close the client connection."""
|
|
751
816
|
try:
|
|
@@ -4,19 +4,26 @@ Each command function takes an AuthenticatedClient and keyword args,
|
|
|
4
4
|
and returns a list of strings (output lines).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import re
|
|
8
|
+
import os
|
|
7
9
|
from concurrent.futures import ThreadPoolExecutor
|
|
8
10
|
from dataclasses import dataclass, field
|
|
9
|
-
from
|
|
11
|
+
from datetime import datetime, timedelta
|
|
12
|
+
from typing import Callable, Iterator, Optional
|
|
10
13
|
from pykiwiai import AuthenticatedClient
|
|
11
14
|
from pykiwiai.api.actions import (
|
|
12
15
|
list_actions_v1_actions_get,
|
|
13
16
|
get_action_v1_actions_id_get,
|
|
14
17
|
cancel_action_run_v1_actions_action_result_id_cancel_post,
|
|
18
|
+
instruct_action_run_v1_actions_action_result_id_instruct_post,
|
|
15
19
|
)
|
|
16
20
|
from pykiwiai.api.action_results import (
|
|
21
|
+
get_action_queue_v1_action_results_action_result_id_queue_get,
|
|
22
|
+
update_action_queue_v1_action_results_action_result_id_queue_put,
|
|
17
23
|
list_action_result_v1_action_results_get,
|
|
18
24
|
get_action_result_v1_action_results_id_get,
|
|
19
25
|
)
|
|
26
|
+
from pykiwiai.api.run_metrics import get_run_metrics_v1_run_metrics_get
|
|
20
27
|
from pykiwiai.api.subagents import list_subagents_of_run_v1_subagents_runs_parent_action_run_id_get
|
|
21
28
|
from pykiwiai.api.token_usage import get_token_usage_for_run_v1_token_usage_run_id_get
|
|
22
29
|
|
|
@@ -208,6 +215,121 @@ def runs_cancel(client: AuthenticatedClient, *, id: str) -> list[str]:
|
|
|
208
215
|
return cancel_lines(id, *cancel_run(client, id=id))
|
|
209
216
|
|
|
210
217
|
|
|
218
|
+
# steering a run that is in progress
|
|
219
|
+
|
|
220
|
+
def instruct_run(client: AuthenticatedClient, *, id: str, text: str, urls: Optional[list[str]] = None) -> int:
|
|
221
|
+
"""Hand a run that is processing an instruction to read at its next step. Returns the
|
|
222
|
+
HTTP status: 202 when taken, 409 when the run is not processing."""
|
|
223
|
+
from pykiwiai.models.run_instruction import RunInstruction
|
|
224
|
+
|
|
225
|
+
resp = instruct_action_run_v1_actions_action_result_id_instruct_post.sync_detailed(
|
|
226
|
+
action_result_id=id, client=client, body=RunInstruction(text=text, urls=list(urls or [])),
|
|
227
|
+
)
|
|
228
|
+
return resp.status_code
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# the queue: prompts waiting behind the turn in progress
|
|
232
|
+
|
|
233
|
+
def get_queue(client: AuthenticatedClient, *, id: str) -> tuple[int, list[str], int]:
|
|
234
|
+
"""The prompts queued on a run, oldest first. Returns (HTTP status, prompts, the
|
|
235
|
+
queue's version, which a write has to quote)."""
|
|
236
|
+
status, blocks, version = _read_queue(client, id)
|
|
237
|
+
return status, [_block_text(block) for block in blocks], version
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _block_text(block: object) -> str:
|
|
241
|
+
return str(getattr(block, "text", None) or "")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _read_queue(client: AuthenticatedClient, id: str) -> tuple[int, list, int]:
|
|
245
|
+
"""The queue as the server stores it: (HTTP status, input blocks, version)."""
|
|
246
|
+
resp = get_action_queue_v1_action_results_action_result_id_queue_get.sync_detailed(
|
|
247
|
+
action_result_id=id, client=client,
|
|
248
|
+
)
|
|
249
|
+
queue = resp.parsed
|
|
250
|
+
inputs = getattr(queue, "inputs", None)
|
|
251
|
+
if resp.status_code != 200 or not isinstance(inputs, list):
|
|
252
|
+
return resp.status_code, [], 0
|
|
253
|
+
version = getattr(queue, "version_number", 0)
|
|
254
|
+
return 200, list(inputs), version if isinstance(version, int) else 0
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@dataclass
|
|
258
|
+
class QueueRemoval:
|
|
259
|
+
status: int # HTTP status of the read or the write that decided it
|
|
260
|
+
removed: Optional[str] = None # the prompt taken out, when one was
|
|
261
|
+
waiting: int = 0 # how many are queued now
|
|
262
|
+
moved: bool = False # the queue is not what the caller was looking at
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def remove_from_queue(
|
|
266
|
+
client: AuthenticatedClient, *, id: str, number: int, is_expected: Optional[Callable[[str], bool]] = None,
|
|
267
|
+
) -> QueueRemoval:
|
|
268
|
+
"""Take the prompt at `number` (1 is the next to run) out of a run's queue.
|
|
269
|
+
|
|
270
|
+
Positions shift whenever the server starts a queued prompt, so the caller can pass
|
|
271
|
+
`is_expected` to say what it believes is at that position; if something else is there
|
|
272
|
+
nothing is removed. The write quotes the version it read, so a change in between is
|
|
273
|
+
refused by the server rather than removing the wrong prompt."""
|
|
274
|
+
from pykiwiai.models.queued_inputs import QueuedInputs
|
|
275
|
+
|
|
276
|
+
status, blocks, version = _read_queue(client, id)
|
|
277
|
+
if status != 200:
|
|
278
|
+
return QueueRemoval(status)
|
|
279
|
+
if not 1 <= number <= len(blocks):
|
|
280
|
+
return QueueRemoval(200, waiting=len(blocks))
|
|
281
|
+
target = _block_text(blocks[number - 1])
|
|
282
|
+
if is_expected is not None and not is_expected(target):
|
|
283
|
+
return QueueRemoval(200, waiting=len(blocks), moved=True)
|
|
284
|
+
# The blocks go back as they came, so each keeps its author and attachments.
|
|
285
|
+
kept = blocks[: number - 1] + blocks[number:]
|
|
286
|
+
resp = update_action_queue_v1_action_results_action_result_id_queue_put.sync_detailed(
|
|
287
|
+
action_result_id=id, client=client, body=QueuedInputs(inputs=kept, version_number=version),
|
|
288
|
+
)
|
|
289
|
+
if resp.status_code == 200:
|
|
290
|
+
return QueueRemoval(200, removed=target, waiting=len(kept))
|
|
291
|
+
return QueueRemoval(resp.status_code, waiting=len(blocks), moved=resp.status_code == 409)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def clear_queue(client: AuthenticatedClient, *, id: str) -> tuple[int, int]:
|
|
295
|
+
"""Drop every prompt queued on a run. Returns (HTTP status, how many were dropped).
|
|
296
|
+
The turn in progress is not touched. A prompt the server started, or one queued from
|
|
297
|
+
elsewhere, between the read and the write makes the write a 409; it is read again
|
|
298
|
+
and retried once."""
|
|
299
|
+
from pykiwiai.models.queued_inputs import QueuedInputs
|
|
300
|
+
|
|
301
|
+
status = 409
|
|
302
|
+
for _ in range(2):
|
|
303
|
+
status, prompts, version = get_queue(client, id=id)
|
|
304
|
+
if status != 200 or not prompts:
|
|
305
|
+
return status, 0
|
|
306
|
+
resp = update_action_queue_v1_action_results_action_result_id_queue_put.sync_detailed(
|
|
307
|
+
action_result_id=id, client=client,
|
|
308
|
+
body=QueuedInputs(inputs=[], version_number=version),
|
|
309
|
+
)
|
|
310
|
+
status = resp.status_code
|
|
311
|
+
if status == 200:
|
|
312
|
+
return 200, len(prompts)
|
|
313
|
+
if status == 404:
|
|
314
|
+
# The server keeps no queue for a run nothing was ever queued on.
|
|
315
|
+
return 200, 0
|
|
316
|
+
if status != 409:
|
|
317
|
+
break
|
|
318
|
+
return status, 0
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def queue_lines(prompts: list[str], *, width: int = 100) -> list[str]:
|
|
322
|
+
"""The queue as numbered lines, one prompt each, cut to fit."""
|
|
323
|
+
if not prompts:
|
|
324
|
+
return ["Nothing is queued."]
|
|
325
|
+
lines = [f"{len(prompts)} queued, in the order they will run:"]
|
|
326
|
+
for number, prompt in enumerate(prompts, 1):
|
|
327
|
+
text = " ".join(prompt.split())
|
|
328
|
+
room = max(10, width - 6)
|
|
329
|
+
lines.append(f" {number}. {text if len(text) <= room else text[: room - 1] + '…'}")
|
|
330
|
+
return lines
|
|
331
|
+
|
|
332
|
+
|
|
211
333
|
# token usage
|
|
212
334
|
|
|
213
335
|
@dataclass
|
|
@@ -395,6 +517,245 @@ def token_usage(client: AuthenticatedClient, *, run_id: str) -> list[str]:
|
|
|
395
517
|
return token_usage_lines(get_token_usage(client, run_id=run_id))
|
|
396
518
|
|
|
397
519
|
|
|
520
|
+
# stats (the run metrics dashboard)
|
|
521
|
+
|
|
522
|
+
#: What `/stats` accepts -> the server's range, how it is named, and the bucket size.
|
|
523
|
+
STATS_RANGES: dict[str, tuple[str, str, str]] = {
|
|
524
|
+
"7d": ("last_7_days", "Last 7 days", "daily"),
|
|
525
|
+
"30d": ("last_30_days", "Last 30 days", "daily"),
|
|
526
|
+
"90d": ("last_90_days", "Last 90 days", "weekly"),
|
|
527
|
+
"1y": ("last_year", "Last year", "weekly"),
|
|
528
|
+
}
|
|
529
|
+
DEFAULT_STATS_RANGE = "7d"
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
@dataclass
|
|
533
|
+
class RunStats:
|
|
534
|
+
"""The server's run metrics for one time range; `data` is its response as a dict."""
|
|
535
|
+
status_code: int
|
|
536
|
+
range_key: str
|
|
537
|
+
data: dict = field(default_factory=dict)
|
|
538
|
+
|
|
539
|
+
@property
|
|
540
|
+
def label(self) -> str:
|
|
541
|
+
return STATS_RANGES[self.range_key][1]
|
|
542
|
+
|
|
543
|
+
@property
|
|
544
|
+
def bucket_unit(self) -> str:
|
|
545
|
+
return "day" if STATS_RANGES[self.range_key][2] == "daily" else "week"
|
|
546
|
+
|
|
547
|
+
def runs_per_bucket(self) -> list[int]:
|
|
548
|
+
"""Runs in each day or week of the range, oldest first. The server leaves out a
|
|
549
|
+
bucket with no runs, so the gaps are put back as zeros."""
|
|
550
|
+
step = timedelta(days=1 if self.bucket_unit == "day" else 7)
|
|
551
|
+
try:
|
|
552
|
+
buckets = sorted(
|
|
553
|
+
(datetime.fromisoformat(str(b["bucket"])).replace(tzinfo=None), int(b.get("runs") or 0))
|
|
554
|
+
for b in self.data.get("buckets") or []
|
|
555
|
+
)
|
|
556
|
+
start = datetime.fromisoformat(str(self.data["start"])).replace(tzinfo=None)
|
|
557
|
+
end = datetime.fromisoformat(str(self.data["end"])).replace(tzinfo=None)
|
|
558
|
+
except (KeyError, TypeError, ValueError):
|
|
559
|
+
return []
|
|
560
|
+
if not buckets:
|
|
561
|
+
return []
|
|
562
|
+
first = buckets[0][0]
|
|
563
|
+
before = max(0, int((first - start) / step))
|
|
564
|
+
series = [0] * (before + max(0, int((end - first) / step)) + 1)
|
|
565
|
+
for at, runs in buckets:
|
|
566
|
+
slot = before + round((at - first) / step)
|
|
567
|
+
if 0 <= slot < len(series):
|
|
568
|
+
series[slot] += runs
|
|
569
|
+
return series
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def local_timezone_name() -> str:
|
|
573
|
+
"""This machine's IANA timezone, so a day in the stats is a day here. UTC when it
|
|
574
|
+
cannot be told, which is also what the server falls back to for a name it rejects."""
|
|
575
|
+
name = os.environ.get("TZ", "").lstrip(":")
|
|
576
|
+
if not name:
|
|
577
|
+
try:
|
|
578
|
+
target = os.path.realpath("/etc/localtime")
|
|
579
|
+
if "zoneinfo/" in target:
|
|
580
|
+
name = target.split("zoneinfo/", 1)[1]
|
|
581
|
+
except OSError:
|
|
582
|
+
name = ""
|
|
583
|
+
try:
|
|
584
|
+
from zoneinfo import ZoneInfo
|
|
585
|
+
|
|
586
|
+
ZoneInfo(name)
|
|
587
|
+
except Exception:
|
|
588
|
+
return "UTC"
|
|
589
|
+
return name
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def get_stats(client: AuthenticatedClient, *, range_key: str = DEFAULT_STATS_RANGE) -> RunStats:
|
|
593
|
+
"""Fetch the run metrics dashboard for a time range."""
|
|
594
|
+
time_range, _, granularity = STATS_RANGES[range_key]
|
|
595
|
+
resp = get_run_metrics_v1_run_metrics_get.sync_detailed(
|
|
596
|
+
client=client, time_range=time_range, granularity=granularity, timezone=local_timezone_name(),
|
|
597
|
+
)
|
|
598
|
+
if resp.status_code != 200 or resp.parsed is None:
|
|
599
|
+
return RunStats(status_code=resp.status_code, range_key=range_key)
|
|
600
|
+
return RunStats(status_code=200, range_key=range_key, data=resp.parsed.to_dict())
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def fmt_count(n) -> str:
|
|
604
|
+
"""A count short enough for a table: 99.8M, 52.6K, 842."""
|
|
605
|
+
n = n or 0
|
|
606
|
+
for limit, suffix in ((1e9, "B"), (1e6, "M"), (1e3, "K")):
|
|
607
|
+
if abs(n) >= limit:
|
|
608
|
+
return f"{n / limit:.1f}{suffix}"
|
|
609
|
+
return f"{n:,.0f}"
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def fmt_money(v) -> str:
|
|
613
|
+
if v is None:
|
|
614
|
+
return "\u2013"
|
|
615
|
+
if 0 < v < 0.01:
|
|
616
|
+
return "<$0.01"
|
|
617
|
+
return f"${v:,.2f}"
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def fmt_spend(totals: dict) -> str:
|
|
621
|
+
"""Everything a group spent. When some of its runs are unpriced the server gives no
|
|
622
|
+
total, so the known part is shown with a `+` rather than passed off as the whole."""
|
|
623
|
+
total = totals.get("total_cost_usd")
|
|
624
|
+
if total is not None:
|
|
625
|
+
return fmt_money(total)
|
|
626
|
+
known = (totals.get("llm_cost_usd") or 0) + (totals.get("media_cost_usd") or 0)
|
|
627
|
+
return f"{fmt_money(known)}+" if known else "\u2013"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def fmt_duration(seconds) -> str:
|
|
631
|
+
if seconds is None:
|
|
632
|
+
return "\u2013"
|
|
633
|
+
seconds = int(round(seconds))
|
|
634
|
+
if seconds < 60:
|
|
635
|
+
return f"{seconds}s"
|
|
636
|
+
minutes, seconds = divmod(seconds, 60)
|
|
637
|
+
if minutes < 60:
|
|
638
|
+
return f"{minutes}m {seconds:02d}s"
|
|
639
|
+
hours, minutes = divmod(minutes, 60)
|
|
640
|
+
return f"{hours}h {minutes:02d}m"
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def fmt_pct(v) -> str:
|
|
644
|
+
return "\u2013" if v is None else f"{v:.0%}"
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def stats_changes(stats: RunStats) -> list[tuple[str, float]]:
|
|
648
|
+
"""How runs, tokens and spend moved against the period before, as fractions. Empty
|
|
649
|
+
when that period had no runs, and a figure is left out when it had nothing to compare."""
|
|
650
|
+
now, before = stats.data.get("totals") or {}, stats.data.get("previous") or {}
|
|
651
|
+
if not before.get("runs"):
|
|
652
|
+
return []
|
|
653
|
+
changes = []
|
|
654
|
+
for label, key in (("runs", "runs"), ("tokens", "tokens_total"), ("spend", "total_cost_usd")):
|
|
655
|
+
old, new = before.get(key), now.get(key)
|
|
656
|
+
if old and new is not None:
|
|
657
|
+
changes.append((label, (new - old) / old))
|
|
658
|
+
return changes
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def top_subjects(stats: RunStats) -> list[dict]:
|
|
662
|
+
"""The busiest workflows first. The server ranks them by estimated hours saved, a
|
|
663
|
+
column this view does not show, so its order would look arbitrary here."""
|
|
664
|
+
return sorted(stats.data.get("top_subjects") or [], key=lambda s: -(s.get("runs") or 0))
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def subject_name(subject: dict) -> str:
|
|
668
|
+
"""A workflow's name; one the server could not name (deleted, say) keeps a short id."""
|
|
669
|
+
return subject.get("name") or f"(unnamed {str(subject.get('subject_id') or '')[:8]})"
|
|
670
|
+
|
|
671
|
+
|
|
672
|
+
TRIGGER_LABELS = {"api": "API", "batch": "Batch", "scheduled": "Scheduled", "webhook": "Webhook"}
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def trigger_label(trigger: Optional[str]) -> str:
|
|
676
|
+
"""A run's trigger as the web dashboard names it. Runs the server could not attribute
|
|
677
|
+
to a trigger come back as `unknown`."""
|
|
678
|
+
key = (trigger or "unknown").lower()
|
|
679
|
+
return TRIGGER_LABELS.get(key) or " ".join(w.capitalize() for w in re.split(r"[_\s-]+", key) if w)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def trigger_rows(stats: RunStats) -> list[tuple[str, str, str, str, str]]:
|
|
683
|
+
"""What started the runs, largest share first: label, runs, share, tokens, spend. A run
|
|
684
|
+
spawned by another run is its own row, so what subagents cost is read off directly."""
|
|
685
|
+
rows = sorted(stats.data.get("by_trigger") or [], key=lambda x: x.get("runs") or 0, reverse=True)
|
|
686
|
+
total = sum(x.get("runs") or 0 for x in rows)
|
|
687
|
+
return [
|
|
688
|
+
(
|
|
689
|
+
trigger_label(x.get("trigger")), f"{x.get('runs') or 0:,}", fmt_pct((x.get("runs") or 0) / total),
|
|
690
|
+
fmt_count(x.get("tokens_total")), fmt_spend(x),
|
|
691
|
+
)
|
|
692
|
+
for x in rows if x.get("runs")
|
|
693
|
+
]
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def stats_lines(stats: RunStats) -> list[str]:
|
|
697
|
+
"""The run metrics dashboard as plain output lines."""
|
|
698
|
+
if stats.status_code != 200:
|
|
699
|
+
return [f"Error: HTTP {stats.status_code}"]
|
|
700
|
+
data = stats.data
|
|
701
|
+
t = data.get("totals") or {}
|
|
702
|
+
if not t.get("runs"):
|
|
703
|
+
return [f"No runs in the {stats.label.lower()}."]
|
|
704
|
+
|
|
705
|
+
lines = [
|
|
706
|
+
f"{stats.label} ({data.get('scope', '')}, {data.get('timezone', '')})",
|
|
707
|
+
"",
|
|
708
|
+
f"Runs : {t['runs']:,} ({t.get('succeeded', 0):,} succeeded, {t.get('failed', 0):,} failed, "
|
|
709
|
+
f"{t.get('cancelled', 0):,} cancelled)",
|
|
710
|
+
f"Success rate : {fmt_pct(t.get('success_rate'))}",
|
|
711
|
+
f"Tokens : {fmt_count(t.get('tokens_total'))} (input {fmt_count(t.get('tokens_input'))}, "
|
|
712
|
+
f"cached {fmt_count(t.get('tokens_cached'))}, cache write {fmt_count(t.get('tokens_cache_write'))}, "
|
|
713
|
+
f"output {fmt_count(t.get('tokens_output'))})",
|
|
714
|
+
f"Cache hit : {fmt_pct(t.get('cache_hit_rate'))}",
|
|
715
|
+
f"Spend : {fmt_spend(t)} (LLM {fmt_money(t.get('llm_cost_usd'))}, media {fmt_money(t.get('media_cost_usd'))})",
|
|
716
|
+
f"Duration : {fmt_duration(t.get('duration_p50'))} median, {fmt_duration(t.get('duration_p95'))} slowest 5%",
|
|
717
|
+
]
|
|
718
|
+
if t.get("hours_saved"):
|
|
719
|
+
lines.append(f"Hours saved : {t['hours_saved']:,.1f} (estimated)")
|
|
720
|
+
changes = stats_changes(stats)
|
|
721
|
+
if changes:
|
|
722
|
+
lines.append("Vs previous : " + ", ".join(f"{label} {change:+.0%}" for label, change in changes))
|
|
723
|
+
|
|
724
|
+
subjects = top_subjects(stats)
|
|
725
|
+
if subjects:
|
|
726
|
+
header = f"{'Workflow':<28} {'Runs':>6} {'Success':>8} {'Tokens':>9} {'Spend':>10}"
|
|
727
|
+
lines += ["", header, "-" * len(header)]
|
|
728
|
+
for s in subjects:
|
|
729
|
+
name = subject_name(s)
|
|
730
|
+
lines.append(
|
|
731
|
+
f"{name[:28]:<28} {s.get('runs', 0):>6,} {fmt_pct(s.get('success_rate')):>8} "
|
|
732
|
+
f"{fmt_count(s.get('tokens_total')):>9} {fmt_spend(s):>10}"
|
|
733
|
+
)
|
|
734
|
+
models = data.get("by_model") or []
|
|
735
|
+
if models:
|
|
736
|
+
header = f"{'Model':<28} {'Runs':>6} {'Tokens':>9} {'Spend':>10}"
|
|
737
|
+
lines += ["", header, "-" * len(header)]
|
|
738
|
+
for m in models:
|
|
739
|
+
lines.append(
|
|
740
|
+
f"{(m.get('model') or '(unknown)')[:28]:<28} {m.get('runs', 0):>6,} "
|
|
741
|
+
f"{fmt_count(m.get('tokens_total')):>9} {fmt_spend(m):>10}"
|
|
742
|
+
)
|
|
743
|
+
triggers = trigger_rows(stats)
|
|
744
|
+
if triggers:
|
|
745
|
+
header = f"{'Started by':<28} {'Runs':>6} {'Share':>8} {'Tokens':>9} {'Spend':>10}"
|
|
746
|
+
lines += ["", header, "-" * len(header)]
|
|
747
|
+
lines += [f"{label[:28]:<28} {runs:>6} {share:>8} {tokens:>9} {spend:>10}" for label, runs, share, tokens, spend in triggers]
|
|
748
|
+
errors = data.get("top_errors") or []
|
|
749
|
+
if errors:
|
|
750
|
+
lines += ["", "Failures : " + ", ".join(f"{x.get('error_class') or 'Unclassified'} {x.get('runs', 0):,}" for x in errors)]
|
|
751
|
+
return lines
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def stats(client: AuthenticatedClient, *, range_key: str = DEFAULT_STATS_RANGE) -> list[str]:
|
|
755
|
+
"""Run metrics for a time range. Returns output lines."""
|
|
756
|
+
return stats_lines(get_stats(client, range_key=range_key))
|
|
757
|
+
|
|
758
|
+
|
|
398
759
|
# Command dispatcher — parses "/command args" from TUI input
|
|
399
760
|
|
|
400
761
|
HELP_TEXT = """\
|
|
@@ -404,6 +765,11 @@ Session:
|
|
|
404
765
|
/new Start new conversation
|
|
405
766
|
/status Show current action & run
|
|
406
767
|
/cancel [run_id] Cancel a running run (default: current run)
|
|
768
|
+
/queue <prompt> Run a prompt after the current turn finishes
|
|
769
|
+
/queue list Show the prompts waiting to run
|
|
770
|
+
/queue remove <n> Drop one waiting prompt, by its number in /queue list
|
|
771
|
+
/queue clear Drop every prompt waiting to run
|
|
772
|
+
/stats [7d|30d|90d|1y] Show run statistics: runs, tokens, spend (default: 7d)
|
|
407
773
|
/usage [run_id] Show token usage for a run and its subagents (default: current run)
|
|
408
774
|
|
|
409
775
|
Files:
|