delos-cli 1.0.2__tar.gz → 1.0.3__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.
- delos_cli-1.0.3/.claude/settings.local.json +8 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/PKG-INFO +1 -1
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/chat/app.py +9 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/chat/commands.py +34 -1
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/chat/render.py +14 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/replay.py +3 -1
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/commands/prompts.py +6 -0
- delos_cli-1.0.3/delos_cli/context_report.py +176 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/server.py +48 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/__init__.py +10 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/edit_content.py +18 -4
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/explore.py +7 -2
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/run_shell.py +33 -5
- delos_cli-1.0.3/delos_cli/tools/verify.py +347 -0
- delos_cli-1.0.3/delos_cli/ui/banner.py +102 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/chat_picker.py +50 -22
- delos_cli-1.0.3/delos_cli/ui/completer.py +135 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/lexer.py +14 -1
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/output.py +4 -1
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/repl.py +65 -6
- delos_cli-1.0.3/delos_cli/ui/style.py +51 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/pyproject.toml +1 -1
- delos_cli-1.0.2/delos_cli/apps/chat/commands.py.bak +0 -17
- delos_cli-1.0.2/delos_cli/ui/banner.py +0 -51
- delos_cli-1.0.2/delos_cli/ui/completer.py +0 -68
- delos_cli-1.0.2/delos_cli/ui/style.py +0 -24
- {delos_cli-1.0.2 → delos_cli-1.0.3}/.gitignore +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/DELOS.md +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/README.md +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/agent/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/agent/session.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/agent/tools.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/agent/transport.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/agent/turn.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/base.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/chat/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/scribe/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/scribe/app.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/scribe/commands.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/apps/scribe/tools.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/auth/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/auth/config.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/auth/mfa.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/auth/oauth.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/auth/token_manager.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/commands/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/commands/base.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/commands/builtin.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ctx.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/git_context.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/loop.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/main.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/project_context.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/app.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/confirm.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/protocol.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/serve/rpc.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/state.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/glob_tool.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/grep.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/read.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/task.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/todo_write.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/tools/write_content.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/chats.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/client.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/documents.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/integrations.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/transport/models.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/__init__.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/document_picker.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/integrations_picker.py +0 -0
- {delos_cli-1.0.2 → delos_cli-1.0.3}/delos_cli/ui/model_picker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: delos-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.3
|
|
4
4
|
Summary: Terminal REPL for the Delos agent — the Claude-Code-style CLI for Delos.
|
|
5
5
|
Project-URL: Homepage, https://delos.so
|
|
6
6
|
Project-URL: Repository, https://github.com/Delos-Intelligence/cosmos-saas
|
|
@@ -29,16 +29,20 @@ from delos_cli.tools import (
|
|
|
29
29
|
handle_explore,
|
|
30
30
|
handle_glob,
|
|
31
31
|
handle_grep,
|
|
32
|
+
handle_lint_target,
|
|
32
33
|
handle_read,
|
|
33
34
|
handle_run_shell,
|
|
35
|
+
handle_run_tests,
|
|
34
36
|
handle_todo_write,
|
|
35
37
|
handle_write_content,
|
|
36
38
|
render_edit_content,
|
|
37
39
|
render_explore,
|
|
38
40
|
render_glob,
|
|
39
41
|
render_grep,
|
|
42
|
+
render_lint_target,
|
|
40
43
|
render_read,
|
|
41
44
|
render_run_shell,
|
|
45
|
+
render_run_tests,
|
|
42
46
|
render_task,
|
|
43
47
|
render_todo_write,
|
|
44
48
|
render_write_content,
|
|
@@ -82,6 +86,11 @@ def default_tool_registry() -> ToolRegistry:
|
|
|
82
86
|
# only its report — see delos_cli.tools.explore.
|
|
83
87
|
reg.handlers["explore"] = handle_explore
|
|
84
88
|
reg.renderers["explore"] = render_explore
|
|
89
|
+
# Verification tools — runner/linter auto-detection, see delos_cli.tools.verify.
|
|
90
|
+
reg.handlers["run_tests"] = handle_run_tests
|
|
91
|
+
reg.renderers["run_tests"] = render_run_tests
|
|
92
|
+
reg.handlers["lint_target"] = handle_lint_target
|
|
93
|
+
reg.renderers["lint_target"] = render_lint_target
|
|
85
94
|
return reg
|
|
86
95
|
|
|
87
96
|
|
|
@@ -18,6 +18,7 @@ Two modes:
|
|
|
18
18
|
from __future__ import annotations
|
|
19
19
|
|
|
20
20
|
import contextlib
|
|
21
|
+
from pathlib import Path
|
|
21
22
|
from typing import TYPE_CHECKING, Any
|
|
22
23
|
|
|
23
24
|
from prompt_toolkit.application import get_app
|
|
@@ -25,6 +26,8 @@ from rich.text import Text
|
|
|
25
26
|
|
|
26
27
|
from delos_cli.commands.base import CommandSpec, registry_from
|
|
27
28
|
from delos_cli.commands.prompts import EXECUTE_PROMPT, INIT_PROMPT_NEW, INIT_PROMPT_UPDATE
|
|
29
|
+
from delos_cli.context_report import fetch_context_report, render_report
|
|
30
|
+
from delos_cli.git_context import collect_git_context
|
|
28
31
|
from delos_cli.transport.chats import patch_chat_model
|
|
29
32
|
from delos_cli.transport.client import TransportError
|
|
30
33
|
|
|
@@ -128,4 +131,34 @@ EXECUTE = CommandSpec(
|
|
|
128
131
|
)
|
|
129
132
|
|
|
130
133
|
|
|
131
|
-
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# /context — token breakdown of the next turn's LLM payload
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def _handle_context(ctx: Ctx, args: str) -> None:
|
|
140
|
+
"""Fetch the backend context-report for this chat and render it."""
|
|
141
|
+
_ = args
|
|
142
|
+
sink = _sink(ctx)
|
|
143
|
+
git_ctx = await collect_git_context(Path.cwd())
|
|
144
|
+
try:
|
|
145
|
+
report = await fetch_context_report(
|
|
146
|
+
ctx.http,
|
|
147
|
+
ctx.state.conv_id,
|
|
148
|
+
custom_instructions=ctx.custom_instructions or None,
|
|
149
|
+
git_context=git_ctx,
|
|
150
|
+
)
|
|
151
|
+
except TransportError as e:
|
|
152
|
+
sink.print(Text(f"context report failed: {e}", style="red"))
|
|
153
|
+
return
|
|
154
|
+
sink.print(render_report(report))
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
CONTEXT = CommandSpec(
|
|
158
|
+
name="/context",
|
|
159
|
+
summary="show what fills the agent's context window (token breakdown)",
|
|
160
|
+
handler=_handle_context,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
CHAT_COMMANDS: dict[str, CommandSpec] = registry_from([MODEL, INIT, EXECUTE, CONTEXT])
|
|
@@ -55,6 +55,10 @@ class V6Renderer:
|
|
|
55
55
|
self._spinner = Spinner("dots", text=" thinking", style="dim")
|
|
56
56
|
self._thinking = False
|
|
57
57
|
self._anim_task: asyncio.Task[None] | None = None
|
|
58
|
+
# Inside a <think>…</think> fence — AgentV2 streams model reasoning
|
|
59
|
+
# inline between literal tag deltas. Reasoning is swallowed (the
|
|
60
|
+
# spinner keeps running) so it never lands in scrollback.
|
|
61
|
+
self._in_think = False
|
|
58
62
|
|
|
59
63
|
def apply(self, event: dict[str, Any]) -> None:
|
|
60
64
|
"""Apply a single v6 event to the output buffer."""
|
|
@@ -76,6 +80,16 @@ class V6Renderer:
|
|
|
76
80
|
delta = event.get("delta", "")
|
|
77
81
|
if not delta:
|
|
78
82
|
return
|
|
83
|
+
# Each <think> / </think> tag arrives as its own delta
|
|
84
|
+
# (agent_v2 injects them around reasoning), so plain equality
|
|
85
|
+
# checks are enough — no tag can be split across deltas.
|
|
86
|
+
if delta == "<think>":
|
|
87
|
+
self._in_think = True
|
|
88
|
+
return
|
|
89
|
+
if self._in_think:
|
|
90
|
+
if delta.startswith("</think>"):
|
|
91
|
+
self._in_think = False
|
|
92
|
+
return
|
|
79
93
|
self._stop_thinking()
|
|
80
94
|
self._assistant_text += delta
|
|
81
95
|
self._output.update_live(Markdown(self._assistant_text))
|
|
@@ -20,6 +20,7 @@ from rich.markdown import Markdown
|
|
|
20
20
|
from rich.text import Text
|
|
21
21
|
|
|
22
22
|
from delos_cli.agent import default_renderer
|
|
23
|
+
from delos_cli.ui.style import user_echo
|
|
23
24
|
|
|
24
25
|
if TYPE_CHECKING:
|
|
25
26
|
from delos_cli.apps.base import App
|
|
@@ -49,7 +50,8 @@ def replay_messages(
|
|
|
49
50
|
if role == "user":
|
|
50
51
|
text = _extract_text(msg.get("content"))
|
|
51
52
|
if text:
|
|
52
|
-
output.print(
|
|
53
|
+
output.print(user_echo(text))
|
|
54
|
+
output.print(Text(""))
|
|
53
55
|
elif role == "assistant":
|
|
54
56
|
text = _extract_text(msg.get("content"))
|
|
55
57
|
if text:
|
|
@@ -40,6 +40,8 @@ as you go. If no plan exists in this conversation, say so instead of \
|
|
|
40
40
|
improvising one."""
|
|
41
41
|
|
|
42
42
|
#: Catalogue served to frontends (``list_commands`` RPC) for autocomplete.
|
|
43
|
+
#: ``/context`` is not a prompt expansion — serve intercepts it and answers
|
|
44
|
+
#: locally — but it belongs in the same autocomplete list.
|
|
43
45
|
PROMPT_COMMANDS: tuple[dict[str, str], ...] = (
|
|
44
46
|
{
|
|
45
47
|
"name": "/init",
|
|
@@ -49,6 +51,10 @@ PROMPT_COMMANDS: tuple[dict[str, str], ...] = (
|
|
|
49
51
|
"name": "/execute",
|
|
50
52
|
"summary": "execute the plan from the previous plan-mode turn (loads it into todos)",
|
|
51
53
|
},
|
|
54
|
+
{
|
|
55
|
+
"name": "/context",
|
|
56
|
+
"summary": "show what fills the agent's context window (token breakdown)",
|
|
57
|
+
},
|
|
52
58
|
)
|
|
53
59
|
|
|
54
60
|
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""``/context`` — fetch and render the agent's context-window breakdown.
|
|
2
|
+
|
|
3
|
+
The backend endpoint (``POST /cli/chats/{id}/context-report``) rebuilds
|
|
4
|
+
exactly what the next turn would send to the LLM and returns a token
|
|
5
|
+
breakdown. This module owns the client side: the transport call and two
|
|
6
|
+
renderings of the same report — Rich for the REPL, markdown for serve
|
|
7
|
+
mode (displayed as a synthetic assistant message in VSCode).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from delos_cli.transport.client import AuthedClient
|
|
18
|
+
|
|
19
|
+
_BAR_WIDTH = 30
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def fetch_context_report(
|
|
23
|
+
http: AuthedClient,
|
|
24
|
+
chat_id: str,
|
|
25
|
+
*,
|
|
26
|
+
custom_instructions: str | None,
|
|
27
|
+
git_context: str | None,
|
|
28
|
+
) -> dict[str, Any]:
|
|
29
|
+
"""POST ``/cli/chats/{chat_id}/context-report`` and return the breakdown."""
|
|
30
|
+
return await http.json_post(
|
|
31
|
+
f"/cli/chats/{chat_id}/context-report",
|
|
32
|
+
{
|
|
33
|
+
"org_uuid": http.cfg.org_uuid,
|
|
34
|
+
"custom_instructions": custom_instructions,
|
|
35
|
+
"git_context": git_context,
|
|
36
|
+
},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Shared shaping
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _fmt(n: int) -> str:
|
|
46
|
+
"""Compact token count: 1234 → ``1.2k``."""
|
|
47
|
+
if n >= 1000: # noqa: PLR2004 — display threshold, not business logic
|
|
48
|
+
return f"{n / 1000:.1f}k"
|
|
49
|
+
return str(n)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _lines(report: dict[str, Any]) -> list[tuple[str, str]]:
|
|
53
|
+
"""(label, value) pairs shared by both renderings."""
|
|
54
|
+
msgs = report.get("messages") or {}
|
|
55
|
+
by_role = msgs.get("by_role") or {}
|
|
56
|
+
out = [
|
|
57
|
+
("Model", str(report.get("model", "?"))),
|
|
58
|
+
(
|
|
59
|
+
"System prompt",
|
|
60
|
+
f"{_fmt(report.get('system_prompt_tokens', 0))} tokens",
|
|
61
|
+
),
|
|
62
|
+
(
|
|
63
|
+
"Tools",
|
|
64
|
+
(
|
|
65
|
+
f"{_fmt((report.get('tools') or {}).get('tokens', 0))} tokens "
|
|
66
|
+
f"({(report.get('tools') or {}).get('count', 0)} tools)"
|
|
67
|
+
),
|
|
68
|
+
),
|
|
69
|
+
(
|
|
70
|
+
"Messages",
|
|
71
|
+
f"{_fmt(msgs.get('tokens', 0))} tokens ({msgs.get('count', 0)} messages)",
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
for role in ("user", "assistant", "tool"):
|
|
75
|
+
stats = by_role.get(role)
|
|
76
|
+
if stats:
|
|
77
|
+
out.append(
|
|
78
|
+
(
|
|
79
|
+
f" {role}",
|
|
80
|
+
f"{_fmt(stats.get('tokens', 0))} tokens ({stats.get('count', 0)})",
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
compacted = report.get("compacted_messages", 0)
|
|
84
|
+
if compacted:
|
|
85
|
+
out.append(("Compacted away", f"{compacted} messages (summarised)"))
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _usage(report: dict[str, Any]) -> tuple[int, int, int, float]:
|
|
90
|
+
"""(total, window, threshold, ratio) with safe fallbacks."""
|
|
91
|
+
total = int(report.get("total_tokens", 0))
|
|
92
|
+
window = int(report.get("context_window", 0)) or 1
|
|
93
|
+
threshold = int(report.get("compact_threshold", 0))
|
|
94
|
+
return total, window, threshold, min(1.0, total / window)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# REPL rendering (Rich)
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def render_report(report: dict[str, Any]) -> Text:
|
|
103
|
+
"""Rich rendering for the REPL output buffer."""
|
|
104
|
+
total, window, threshold, ratio = _usage(report)
|
|
105
|
+
filled = round(ratio * _BAR_WIDTH)
|
|
106
|
+
bar_style = "red" if total >= threshold else "yellow" if ratio > 0.5 else "green" # noqa: PLR2004
|
|
107
|
+
|
|
108
|
+
text = Text()
|
|
109
|
+
text.append("Context usage\n", style="bold")
|
|
110
|
+
text.append("█" * filled, style=bar_style)
|
|
111
|
+
text.append("░" * (_BAR_WIDTH - filled), style="dim")
|
|
112
|
+
text.append(f" {_fmt(total)} / {_fmt(window)} tokens ({ratio:.0%})\n", style="bold")
|
|
113
|
+
text.append(f"auto-compaction at {_fmt(threshold)}\n\n", style="dim")
|
|
114
|
+
|
|
115
|
+
for label, value in _lines(report):
|
|
116
|
+
text.append(f"{label:<16}", style="cyan" if not label.startswith(" ") else "dim")
|
|
117
|
+
text.append(f"{value}\n")
|
|
118
|
+
|
|
119
|
+
by_tool = (report.get("messages") or {}).get("by_tool") or []
|
|
120
|
+
if by_tool:
|
|
121
|
+
text.append("\nHeaviest tools\n", style="bold")
|
|
122
|
+
for t in by_tool:
|
|
123
|
+
text.append(f" {t.get('name', '?'):<24}", style="magenta")
|
|
124
|
+
text.append(f"{_fmt(t.get('tokens', 0)):>8} ({t.get('calls', 0)} calls)\n")
|
|
125
|
+
|
|
126
|
+
top = (report.get("messages") or {}).get("top") or []
|
|
127
|
+
if top:
|
|
128
|
+
text.append("\nLargest messages\n", style="bold")
|
|
129
|
+
for m in top:
|
|
130
|
+
who = m.get("tool") or m.get("role", "?")
|
|
131
|
+
text.append(f" #{m.get('index', 0):<4} {who:<20}", style="dim")
|
|
132
|
+
text.append(f"{_fmt(m.get('tokens', 0)):>8} ")
|
|
133
|
+
text.append(f"{m.get('preview', '')[:60]}\n", style="dim")
|
|
134
|
+
return text
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# ---------------------------------------------------------------------------
|
|
138
|
+
# Serve rendering (markdown — shown as an assistant message in VSCode)
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def format_report_markdown(report: dict[str, Any]) -> str:
|
|
143
|
+
"""Markdown rendering for serve mode."""
|
|
144
|
+
total, window, threshold, ratio = _usage(report)
|
|
145
|
+
filled = round(ratio * _BAR_WIDTH)
|
|
146
|
+
lines = [
|
|
147
|
+
"**Context usage**",
|
|
148
|
+
"",
|
|
149
|
+
(
|
|
150
|
+
f"`{'█' * filled}{'░' * (_BAR_WIDTH - filled)}` "
|
|
151
|
+
f"**{_fmt(total)} / {_fmt(window)} tokens ({ratio:.0%})** "
|
|
152
|
+
f"— auto-compaction at {_fmt(threshold)}"
|
|
153
|
+
),
|
|
154
|
+
"",
|
|
155
|
+
"| | |",
|
|
156
|
+
"|---|---|",
|
|
157
|
+
]
|
|
158
|
+
lines.extend(f"| {label.strip()} | {value} |" for label, value in _lines(report))
|
|
159
|
+
|
|
160
|
+
by_tool = (report.get("messages") or {}).get("by_tool") or []
|
|
161
|
+
if by_tool:
|
|
162
|
+
lines += ["", "**Heaviest tools**", "", "| tool | tokens | calls |", "|---|---|---|"]
|
|
163
|
+
lines.extend(
|
|
164
|
+
f"| {t.get('name', '?')} | {_fmt(t.get('tokens', 0))} | {t.get('calls', 0)} |"
|
|
165
|
+
for t in by_tool
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
top = (report.get("messages") or {}).get("top") or []
|
|
169
|
+
if top:
|
|
170
|
+
lines += ["", "**Largest messages**", ""]
|
|
171
|
+
lines.extend(
|
|
172
|
+
f"- `#{m.get('index', 0)}` {m.get('tool') or m.get('role', '?')} — "
|
|
173
|
+
f"{_fmt(m.get('tokens', 0))} tokens — {m.get('preview', '')[:60]}"
|
|
174
|
+
for m in top
|
|
175
|
+
)
|
|
176
|
+
return "\n".join(lines)
|
|
@@ -20,6 +20,7 @@ from delos_cli.agent import AgentSession, AgentTransport
|
|
|
20
20
|
from delos_cli.auth import config as cfg_mod
|
|
21
21
|
from delos_cli.auth.oauth import OAuthError, run_login_flow
|
|
22
22
|
from delos_cli.commands.prompts import PROMPT_COMMANDS, expand_slash_command
|
|
23
|
+
from delos_cli.context_report import fetch_context_report, format_report_markdown
|
|
23
24
|
from delos_cli.ctx import ConfirmOutcome, Ctx
|
|
24
25
|
from delos_cli.git_context import collect_git_context
|
|
25
26
|
from delos_cli.project_context import load_project_context
|
|
@@ -174,6 +175,14 @@ class StdioServer:
|
|
|
174
175
|
self.send({"type": "error", "message": "user_message needs chatId and content"})
|
|
175
176
|
return
|
|
176
177
|
|
|
178
|
+
# `/context` never reaches the agent: it's a local diagnostic that
|
|
179
|
+
# renders the backend's context-report as a synthetic assistant
|
|
180
|
+
# message (start / text-delta / finish), so it works from the
|
|
181
|
+
# VSCode input without extension changes.
|
|
182
|
+
if content.strip() == "/context":
|
|
183
|
+
await self._run_context_report(chat_id, turn_id)
|
|
184
|
+
return
|
|
185
|
+
|
|
177
186
|
# One active turn per chat — a new message supersedes the old one
|
|
178
187
|
# (same semantics as the extension's previous abort-then-send).
|
|
179
188
|
if (existing := self.turns.get(chat_id)) and not existing.done():
|
|
@@ -278,6 +287,45 @@ class StdioServer:
|
|
|
278
287
|
self._log(f"turn failed for {chat_id}: {error_text}")
|
|
279
288
|
self.send(done)
|
|
280
289
|
|
|
290
|
+
async def _run_context_report(self, chat_id: str, turn_id: str) -> None:
|
|
291
|
+
"""Handle ``/context``: fetch the token breakdown, emit it as a turn."""
|
|
292
|
+
self.send({"type": "turn_started", "chatId": chat_id, "turnId": turn_id})
|
|
293
|
+
error_text: str | None = None
|
|
294
|
+
text: str | None = None
|
|
295
|
+
try:
|
|
296
|
+
http = self.require_http()
|
|
297
|
+
git_ctx = await collect_git_context(self.workspace)
|
|
298
|
+
report = await fetch_context_report(
|
|
299
|
+
http,
|
|
300
|
+
chat_id,
|
|
301
|
+
custom_instructions=self.custom_instructions or None,
|
|
302
|
+
git_context=git_ctx,
|
|
303
|
+
)
|
|
304
|
+
text = format_report_markdown(report)
|
|
305
|
+
except Exception as e:
|
|
306
|
+
error_text = f"context report failed: {type(e).__name__}: {e}"
|
|
307
|
+
if text is not None:
|
|
308
|
+
message_id = str(uuid.uuid4())
|
|
309
|
+
for event in (
|
|
310
|
+
{"type": "start", "messageId": message_id},
|
|
311
|
+
{"type": "text-delta", "id": message_id, "delta": text},
|
|
312
|
+
{"type": "finish"},
|
|
313
|
+
):
|
|
314
|
+
self.send({
|
|
315
|
+
"type": "event",
|
|
316
|
+
"chatId": chat_id,
|
|
317
|
+
"turnId": turn_id,
|
|
318
|
+
"event": event,
|
|
319
|
+
})
|
|
320
|
+
done: dict[str, Any] = {
|
|
321
|
+
"type": "turn_finished",
|
|
322
|
+
"chatId": chat_id,
|
|
323
|
+
"turnId": turn_id,
|
|
324
|
+
}
|
|
325
|
+
if error_text:
|
|
326
|
+
done["error"] = error_text
|
|
327
|
+
self.send(done)
|
|
328
|
+
|
|
281
329
|
async def _abort_turn(self, chat_id: str) -> None:
|
|
282
330
|
if not chat_id:
|
|
283
331
|
return
|
|
@@ -14,6 +14,12 @@ from .read import handle_read, render_read
|
|
|
14
14
|
from .run_shell import handle_run_shell, render_run_shell
|
|
15
15
|
from .task import render_task
|
|
16
16
|
from .todo_write import handle_todo_write, render_todo_write
|
|
17
|
+
from .verify import (
|
|
18
|
+
handle_lint_target,
|
|
19
|
+
handle_run_tests,
|
|
20
|
+
render_lint_target,
|
|
21
|
+
render_run_tests,
|
|
22
|
+
)
|
|
17
23
|
from .write_content import handle_write_content, render_write_content
|
|
18
24
|
|
|
19
25
|
__all__ = [
|
|
@@ -21,16 +27,20 @@ __all__ = [
|
|
|
21
27
|
"handle_explore",
|
|
22
28
|
"handle_glob",
|
|
23
29
|
"handle_grep",
|
|
30
|
+
"handle_lint_target",
|
|
24
31
|
"handle_read",
|
|
25
32
|
"handle_run_shell",
|
|
33
|
+
"handle_run_tests",
|
|
26
34
|
"handle_todo_write",
|
|
27
35
|
"handle_write_content",
|
|
28
36
|
"render_edit_content",
|
|
29
37
|
"render_explore",
|
|
30
38
|
"render_glob",
|
|
31
39
|
"render_grep",
|
|
40
|
+
"render_lint_target",
|
|
32
41
|
"render_read",
|
|
33
42
|
"render_run_shell",
|
|
43
|
+
"render_run_tests",
|
|
34
44
|
"render_task",
|
|
35
45
|
"render_todo_write",
|
|
36
46
|
"render_write_content",
|
|
@@ -36,6 +36,12 @@ if TYPE_CHECKING:
|
|
|
36
36
|
|
|
37
37
|
_DIFF_CONTEXT_LINES = 8
|
|
38
38
|
|
|
39
|
+
#: Full-line diff highlights (git/Claude-Code style): dark red / green
|
|
40
|
+
#: backgrounds with the terminal's default text color on top. 256-palette
|
|
41
|
+
#: colors render identically in truecolor and 256-color terminals.
|
|
42
|
+
_DEL_BG = "on color(52)"
|
|
43
|
+
_ADD_BG = "on color(22)"
|
|
44
|
+
|
|
39
45
|
#: Sentinels for boundary inserts. When ``old_content`` matches one of
|
|
40
46
|
#: these, the handler skips the literal-match step and prepends /
|
|
41
47
|
#: appends ``new_content`` instead — typical use cases are adding
|
|
@@ -207,17 +213,25 @@ def _diff_panel(path: Path, file_text: str, new_text: str) -> Padding:
|
|
|
207
213
|
)
|
|
208
214
|
continue
|
|
209
215
|
# Change block: pair the k-th deleted line with the k-th added one.
|
|
216
|
+
# ``Padding`` paints the background across the full cell width on
|
|
217
|
+
# every wrapped row — a styled ``Text`` would only tint the glyphs.
|
|
210
218
|
dels = list(range(i1, i2))
|
|
211
219
|
adds = list(range(j1, j2))
|
|
212
220
|
for k in range(max(len(dels), len(adds))):
|
|
213
|
-
old_no
|
|
214
|
-
new_no
|
|
221
|
+
old_no: str = ""
|
|
222
|
+
new_no: str = ""
|
|
223
|
+
old_text: RenderableType = Text("")
|
|
224
|
+
new_text_cell: RenderableType = Text("")
|
|
215
225
|
if k < len(dels):
|
|
216
226
|
old_no = str(dels[k] + 1)
|
|
217
|
-
old_text =
|
|
227
|
+
old_text = Padding(
|
|
228
|
+
Text(old_lines[dels[k]]), 0, style=_DEL_BG, expand=True,
|
|
229
|
+
)
|
|
218
230
|
if k < len(adds):
|
|
219
231
|
new_no = str(adds[k] + 1)
|
|
220
|
-
new_text_cell =
|
|
232
|
+
new_text_cell = Padding(
|
|
233
|
+
Text(new_lines[adds[k]]), 0, style=_ADD_BG, expand=True,
|
|
234
|
+
)
|
|
221
235
|
table.add_row(old_no, old_text, new_no, new_text_cell)
|
|
222
236
|
|
|
223
237
|
title = Text(f"✎ proposed edit → {path}", style="bold")
|
|
@@ -38,7 +38,10 @@ if TYPE_CHECKING:
|
|
|
38
38
|
|
|
39
39
|
from delos_cli.ctx import Ctx
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
# Hard wall-clock cap: the parent turn (and the user) blocks on this. On
|
|
42
|
+
# timeout the handler still returns the last finished message as a partial
|
|
43
|
+
# report, so a lost explorer costs at most 2.5 minutes, not an open-ended wait.
|
|
44
|
+
_WALL_CLOCK_BUDGET_S = 150.0
|
|
42
45
|
_MAX_REPORT_CHARS = 10_000
|
|
43
46
|
|
|
44
47
|
#: Reasoning blocks some models emit inline in their text — never part of
|
|
@@ -147,11 +150,13 @@ async def handle_explore(tool_input: dict[str, Any], ctx: Ctx) -> str:
|
|
|
147
150
|
return "Error: explore requires a non-empty 'prompt'."
|
|
148
151
|
|
|
149
152
|
try:
|
|
153
|
+
# No ``model=``: the backend forces a fast model for explore rows
|
|
154
|
+
# (see CliContextBuilder) — passing the parent's model here would
|
|
155
|
+
# only make the row lie about what actually ran.
|
|
150
156
|
sub_chat_id = await create_chat(
|
|
151
157
|
ctx.http,
|
|
152
158
|
folder=str(Path.cwd()),
|
|
153
159
|
name=f"explore: {description}",
|
|
154
|
-
model=ctx.state.model,
|
|
155
160
|
surface="explore",
|
|
156
161
|
)
|
|
157
162
|
except TransportError as e:
|
|
@@ -40,8 +40,27 @@ if TYPE_CHECKING:
|
|
|
40
40
|
#: starts with one and has no shell metacharacters. Conservative on
|
|
41
41
|
#: purpose: extra confirmations are mild friction; an unintended write
|
|
42
42
|
#: is a real outage. ``find`` is intentionally absent — it has
|
|
43
|
-
#: ``-delete`` and ``-exec``.
|
|
44
|
-
|
|
43
|
+
#: ``-delete`` and ``-exec``. ``grep``/``rg`` are absent too — their
|
|
44
|
+
#: matched-text output can smuggle metacharacters and they are better
|
|
45
|
+
#: served by the dedicated ``grep`` tool.
|
|
46
|
+
_SAFE_BINARIES: frozenset[str] = frozenset(
|
|
47
|
+
{"ls", "cat", "head", "tail", "wc", "file", "du", "tree", "stat", "pwd"},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
#: Read-only ``git`` subcommands. ``branch`` is absent (``git branch -D``
|
|
51
|
+
#: deletes); history / inspection is what read-only usage actually needs.
|
|
52
|
+
_SAFE_GIT_SUBCOMMANDS: frozenset[str] = frozenset({
|
|
53
|
+
"log",
|
|
54
|
+
"show",
|
|
55
|
+
"diff",
|
|
56
|
+
"blame",
|
|
57
|
+
"status",
|
|
58
|
+
"shortlog",
|
|
59
|
+
"ls-files",
|
|
60
|
+
"rev-parse",
|
|
61
|
+
"describe",
|
|
62
|
+
"grep",
|
|
63
|
+
})
|
|
45
64
|
|
|
46
65
|
#: Tokens that can turn a read into a write (redirection / chaining /
|
|
47
66
|
#: substitution). Their presence anywhere in the command forces a
|
|
@@ -55,14 +74,23 @@ _OUTPUT_LIMIT = 8_000
|
|
|
55
74
|
def is_safe_command(command: str) -> bool:
|
|
56
75
|
"""True iff ``command`` may run without prompting the user.
|
|
57
76
|
|
|
58
|
-
Allowlist-based: any token that could redirect /
|
|
59
|
-
|
|
77
|
+
Allowlist-based and fails closed: any token that could redirect /
|
|
78
|
+
chain / substitute is rejected, ``--output`` (which makes ``git
|
|
79
|
+
log``/``diff`` write a file) is rejected, git global flags (``git -c
|
|
80
|
+
core.pager=… log`` would execute the pager) are rejected, and only
|
|
81
|
+
bare read-only binaries / git subcommands pass.
|
|
60
82
|
"""
|
|
61
83
|
if any(tok in command for tok in _UNSAFE_TOKENS):
|
|
62
84
|
return False
|
|
63
|
-
parts = command.
|
|
85
|
+
parts = command.split()
|
|
64
86
|
if not parts:
|
|
65
87
|
return False
|
|
88
|
+
if any(p.startswith("--output") for p in parts):
|
|
89
|
+
return False
|
|
90
|
+
if parts[0] == "git":
|
|
91
|
+
# The subcommand must come immediately after ``git`` — global
|
|
92
|
+
# flags (-c, -C, --exec-path…) are rejected wholesale.
|
|
93
|
+
return len(parts) > 1 and parts[1] in _SAFE_GIT_SUBCOMMANDS
|
|
66
94
|
return parts[0] in _SAFE_BINARIES
|
|
67
95
|
|
|
68
96
|
|