delos-cli 1.0.1__tar.gz → 1.0.2__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.1 → delos_cli-1.0.2}/.gitignore +1 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/PKG-INFO +3 -1
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/__init__.py +1 -1
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/agent/session.py +25 -4
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/chat/app.py +12 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/chat/commands.py +43 -1
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/auth/oauth.py +44 -8
- delos_cli-1.0.2/delos_cli/commands/prompts.py +80 -0
- delos_cli-1.0.2/delos_cli/git_context.py +74 -0
- delos_cli-1.0.2/delos_cli/serve/app.py +125 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/serve/rpc.py +64 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/serve/server.py +93 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/state.py +4 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/__init__.py +3 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/edit_content.py +60 -29
- delos_cli-1.0.2/delos_cli/tools/explore.py +293 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/glob_tool.py +25 -2
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/grep.py +50 -2
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/transport/chats.py +116 -1
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/transport/client.py +51 -6
- delos_cli-1.0.2/delos_cli/transport/documents.py +169 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/transport/integrations.py +83 -32
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/repl.py +29 -1
- {delos_cli-1.0.1 → delos_cli-1.0.2}/pyproject.toml +6 -1
- delos_cli-1.0.1/delos_cli/serve/app.py +0 -57
- delos_cli-1.0.1/delos_cli/transport/documents.py +0 -76
- {delos_cli-1.0.1 → delos_cli-1.0.2}/DELOS.md +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/README.md +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/agent/__init__.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/agent/tools.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/agent/transport.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/agent/turn.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/__init__.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/base.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/chat/__init__.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/chat/commands.py.bak +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/chat/render.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/replay.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/scribe/__init__.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/scribe/app.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/scribe/commands.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/apps/scribe/tools.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/auth/__init__.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/auth/config.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/auth/mfa.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/auth/token_manager.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/commands/__init__.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/commands/base.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/commands/builtin.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ctx.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/loop.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/main.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/project_context.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/serve/__init__.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/serve/confirm.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/serve/protocol.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/read.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/run_shell.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/task.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/todo_write.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/tools/write_content.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/transport/__init__.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/transport/models.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/__init__.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/banner.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/chat_picker.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/completer.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/document_picker.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/integrations_picker.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/lexer.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/model_picker.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/output.py +0 -0
- {delos_cli-1.0.1 → delos_cli-1.0.2}/delos_cli/ui/style.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.2
|
|
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
|
|
@@ -24,6 +24,8 @@ Requires-Dist: httpx>=0.27.0
|
|
|
24
24
|
Requires-Dist: prompt-toolkit>=3.0.47
|
|
25
25
|
Requires-Dist: questionary>=2.0.1
|
|
26
26
|
Requires-Dist: rich>=13.8.0
|
|
27
|
+
Requires-Dist: ripgrep>=15; sys_platform == 'darwin' and platform_machine == 'arm64'
|
|
28
|
+
Requires-Dist: ripgrep>=15; sys_platform == 'linux' and platform_machine == 'x86_64'
|
|
27
29
|
Requires-Dist: typer>=0.12.5
|
|
28
30
|
Description-Content-Type: text/markdown
|
|
29
31
|
|
|
@@ -11,6 +11,8 @@ how client-tool calls collect input from the user.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import asyncio
|
|
15
|
+
from itertools import starmap
|
|
14
16
|
from typing import TYPE_CHECKING, Any
|
|
15
17
|
|
|
16
18
|
if TYPE_CHECKING:
|
|
@@ -90,18 +92,37 @@ class AgentSession:
|
|
|
90
92
|
if stopped or not pending:
|
|
91
93
|
return
|
|
92
94
|
|
|
93
|
-
|
|
95
|
+
# The backend only batches non-interactive client tools
|
|
96
|
+
# (read/grep/glob…) in the same turn, so the pending calls are
|
|
97
|
+
# safe to EXECUTE concurrently. Results are then POSTed
|
|
98
|
+
# sequentially: the ``app_cli_messages`` seq trigger computes
|
|
99
|
+
# ``MAX(seq)+1``, so concurrent inserts on the same chat race
|
|
100
|
+
# into a unique-constraint 500. Execution is the slow part;
|
|
101
|
+
# the posts are cheap.
|
|
102
|
+
async def _execute(
|
|
103
|
+
tcid: str, meta: dict[str, Any],
|
|
104
|
+
) -> tuple[str, dict[str, Any], str]:
|
|
94
105
|
handler = self._registry.handler_for(meta["name"])
|
|
95
106
|
result = await handler(meta["input"], ctx)
|
|
107
|
+
return tcid, meta, result
|
|
108
|
+
|
|
109
|
+
outcomes = await asyncio.gather(
|
|
110
|
+
*starmap(_execute, pending.items()),
|
|
111
|
+
return_exceptions=True,
|
|
112
|
+
)
|
|
113
|
+
for outcome in outcomes:
|
|
114
|
+
if isinstance(outcome, BaseException):
|
|
115
|
+
raise outcome
|
|
116
|
+
tcid, meta, result = outcome
|
|
117
|
+
await self._transport.submit_tool_result(
|
|
118
|
+
self._chat_id, tcid, meta["name"], result,
|
|
119
|
+
)
|
|
96
120
|
if emit_tool_output:
|
|
97
121
|
yield {
|
|
98
122
|
"type": "tool-output-available",
|
|
99
123
|
"toolCallId": tcid,
|
|
100
124
|
"output": result,
|
|
101
125
|
}
|
|
102
|
-
await self._transport.submit_tool_result(
|
|
103
|
-
self._chat_id, tcid, meta["name"], result,
|
|
104
|
-
)
|
|
105
126
|
|
|
106
127
|
current_body = {**body, "messages": [], "tool_continuation": True}
|
|
107
128
|
|
|
@@ -14,6 +14,7 @@ from __future__ import annotations
|
|
|
14
14
|
|
|
15
15
|
import contextlib
|
|
16
16
|
from dataclasses import dataclass, field
|
|
17
|
+
from pathlib import Path
|
|
17
18
|
from typing import TYPE_CHECKING, Any
|
|
18
19
|
|
|
19
20
|
from rich.text import Text
|
|
@@ -21,9 +22,11 @@ from rich.text import Text
|
|
|
21
22
|
from delos_cli.agent import AgentSession, AgentTransport, ToolRegistry
|
|
22
23
|
from delos_cli.apps.base import App
|
|
23
24
|
from delos_cli.apps.replay import replay_messages
|
|
25
|
+
from delos_cli.git_context import collect_git_context
|
|
24
26
|
from delos_cli.state import TodoStore
|
|
25
27
|
from delos_cli.tools import (
|
|
26
28
|
handle_edit_content,
|
|
29
|
+
handle_explore,
|
|
27
30
|
handle_glob,
|
|
28
31
|
handle_grep,
|
|
29
32
|
handle_read,
|
|
@@ -31,6 +34,7 @@ from delos_cli.tools import (
|
|
|
31
34
|
handle_todo_write,
|
|
32
35
|
handle_write_content,
|
|
33
36
|
render_edit_content,
|
|
37
|
+
render_explore,
|
|
34
38
|
render_glob,
|
|
35
39
|
render_grep,
|
|
36
40
|
render_read,
|
|
@@ -74,6 +78,10 @@ def default_tool_registry() -> ToolRegistry:
|
|
|
74
78
|
# `task` is server-side (no handler needed); the renderer shows the
|
|
75
79
|
# sub-agent's input/output lifecycle in the terminal.
|
|
76
80
|
reg.renderers["task"] = render_task
|
|
81
|
+
# `explore` spawns a local read-only sub-agent conversation and returns
|
|
82
|
+
# only its report — see delos_cli.tools.explore.
|
|
83
|
+
reg.handlers["explore"] = handle_explore
|
|
84
|
+
reg.renderers["explore"] = render_explore
|
|
77
85
|
return reg
|
|
78
86
|
|
|
79
87
|
|
|
@@ -136,6 +144,10 @@ class ChatApp(App):
|
|
|
136
144
|
}
|
|
137
145
|
if ctx.custom_instructions:
|
|
138
146
|
body["custom_instructions"] = ctx.custom_instructions
|
|
147
|
+
# Fresh git snapshot per turn — rendered into the system prompt so
|
|
148
|
+
# the agent doesn't burn its first tool call on `git status`.
|
|
149
|
+
if (git_ctx := await collect_git_context(Path.cwd())) is not None:
|
|
150
|
+
body["git_context"] = git_ctx
|
|
139
151
|
|
|
140
152
|
self._current_session = session
|
|
141
153
|
try:
|
|
@@ -24,6 +24,7 @@ from prompt_toolkit.application import get_app
|
|
|
24
24
|
from rich.text import Text
|
|
25
25
|
|
|
26
26
|
from delos_cli.commands.base import CommandSpec, registry_from
|
|
27
|
+
from delos_cli.commands.prompts import EXECUTE_PROMPT, INIT_PROMPT_NEW, INIT_PROMPT_UPDATE
|
|
27
28
|
from delos_cli.transport.chats import patch_chat_model
|
|
28
29
|
from delos_cli.transport.client import TransportError
|
|
29
30
|
|
|
@@ -86,4 +87,45 @@ MODEL = CommandSpec(
|
|
|
86
87
|
)
|
|
87
88
|
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
# /init — generate (or refresh) the project's DELOS.md
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def _handle_init(ctx: Ctx, args: str) -> None: # noqa: RUF029 — CommandSpec handlers are async
|
|
96
|
+
"""Stage the DELOS.md generation prompt as the next agent turn."""
|
|
97
|
+
_ = args
|
|
98
|
+
ctx.state.staged_message = (
|
|
99
|
+
INIT_PROMPT_UPDATE if ctx.custom_instructions else INIT_PROMPT_NEW
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
INIT = CommandSpec(
|
|
104
|
+
name="/init",
|
|
105
|
+
summary="generate or refresh the project's DELOS.md by exploring the codebase",
|
|
106
|
+
handler=_handle_init,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
# /execute — hand a plan-mode plan over to execution
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
async def _handle_execute(ctx: Ctx, args: str) -> None: # noqa: RUF029 — CommandSpec handlers are async
|
|
116
|
+
"""Leave plan permission-mode and stage the execute prompt."""
|
|
117
|
+
_ = args
|
|
118
|
+
if ctx.state.permission_mode == "plan":
|
|
119
|
+
ctx.state.permission_mode = "default"
|
|
120
|
+
_sink(ctx).print(Text("permission mode → default", style="dim"))
|
|
121
|
+
ctx.state.staged_message = EXECUTE_PROMPT
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
EXECUTE = CommandSpec(
|
|
125
|
+
name="/execute",
|
|
126
|
+
summary="execute the plan from the previous plan-mode turn (loads it into todos)",
|
|
127
|
+
handler=_handle_execute,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
CHAT_COMMANDS: dict[str, CommandSpec] = registry_from([MODEL, INIT, EXECUTE])
|
|
@@ -54,16 +54,52 @@ HTTP_ERROR_THRESHOLD = 400
|
|
|
54
54
|
CLIENT_ID = "delos-code"
|
|
55
55
|
LOOPBACK_PORT = 52700
|
|
56
56
|
|
|
57
|
+
# Self-contained sign-in result pages. No external assets (the loopback server
|
|
58
|
+
# only serves these two responses), brand teal matching the apps, dark-mode
|
|
59
|
+
# aware via prefers-color-scheme.
|
|
60
|
+
_PAGE_STYLE = """
|
|
61
|
+
:root { color-scheme: light dark; --brand: #3dd6b9; --bg: #f7f8f8; --card: #ffffff;
|
|
62
|
+
--fg: #1a1a1a; --muted: #6b7280; --border: rgba(0,0,0,.08); --shadow: rgba(0,0,0,.08); }
|
|
63
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #0e1110; --card: #171a19;
|
|
64
|
+
--fg: #e8eae9; --muted: #9aa3a0; --border: rgba(255,255,255,.08); --shadow: rgba(0,0,0,.4); } }
|
|
65
|
+
* { box-sizing: border-box; }
|
|
66
|
+
body { margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
|
67
|
+
background: var(--bg); color: var(--fg); padding: 1.5rem;
|
|
68
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; }
|
|
69
|
+
.card { background: var(--card); border: 1px solid var(--border); border-radius: 16px;
|
|
70
|
+
padding: 2.5rem 2.75rem; max-width: 380px; width: 100%; text-align: center;
|
|
71
|
+
box-shadow: 0 8px 30px var(--shadow); animation: rise .35s ease both; }
|
|
72
|
+
@keyframes rise { from { opacity: 0; transform: translateY(8px); } }
|
|
73
|
+
.badge { width: 56px; height: 56px; border-radius: 50%; display: flex; align-items: center;
|
|
74
|
+
justify-content: center; margin: 0 auto 1.25rem; }
|
|
75
|
+
.badge svg { width: 28px; height: 28px; }
|
|
76
|
+
.ok { background: rgba(61,214,185,.14); color: var(--brand); }
|
|
77
|
+
.err { background: rgba(239,68,68,.14); color: #ef4444; }
|
|
78
|
+
h1 { margin: 0 0 .4rem; font-size: 1.3rem; font-weight: 650; letter-spacing: -.01em; }
|
|
79
|
+
p { margin: 0; color: var(--muted); font-size: .92rem; line-height: 1.5; }
|
|
80
|
+
""".strip()
|
|
81
|
+
|
|
57
82
|
_SUCCESS_HTML = (
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
83
|
+
"<!doctype html><html lang='en'><head><meta charset='utf-8'>"
|
|
84
|
+
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
|
85
|
+
"<title>Signed in · Delos</title><style>" + _PAGE_STYLE + "</style></head>"
|
|
86
|
+
"<body><main class='card'><div class='badge ok'>"
|
|
87
|
+
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' "
|
|
88
|
+
"stroke-linecap='round' stroke-linejoin='round'><path d='M20 6 9 17l-5-5'/></svg>"
|
|
89
|
+
"</div><h1>You're signed in</h1>"
|
|
90
|
+
"<p>You can close this tab and return to your editor.</p></main></body></html>"
|
|
91
|
+
).encode("utf-8")
|
|
92
|
+
|
|
62
93
|
_ERROR_HTML = (
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
94
|
+
"<!doctype html><html lang='en'><head><meta charset='utf-8'>"
|
|
95
|
+
"<meta name='viewport' content='width=device-width, initial-scale=1'>"
|
|
96
|
+
"<title>Sign-in failed · Delos</title><style>" + _PAGE_STYLE + "</style></head>"
|
|
97
|
+
"<body><main class='card'><div class='badge err'>"
|
|
98
|
+
"<svg viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' "
|
|
99
|
+
"stroke-linecap='round' stroke-linejoin='round'><path d='M18 6 6 18M6 6l12 12'/></svg>"
|
|
100
|
+
"</div><h1>Sign-in failed</h1>"
|
|
101
|
+
"<p>Something went wrong. Check the terminal for details and try again.</p></main></body></html>"
|
|
102
|
+
).encode("utf-8")
|
|
67
103
|
|
|
68
104
|
|
|
69
105
|
class OAuthError(RuntimeError):
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Slash commands that expand into agent prompts — shared REPL/serve.
|
|
2
|
+
|
|
3
|
+
The REPL surfaces these as ``CommandSpec`` handlers (``/init``, ``/execute``
|
|
4
|
+
in ``apps/chat/commands.py``); serve mode expands them inline when a
|
|
5
|
+
``user_message`` starts with the command, so typing ``/init`` in the VSCode
|
|
6
|
+
input works without any extension change. ``list_commands`` (RPC) exposes
|
|
7
|
+
the catalogue so the webview can offer autocomplete.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
INIT_PROMPT_NEW = """\
|
|
13
|
+
Generate this project's DELOS.md — the instructions file that will be loaded \
|
|
14
|
+
into your system prompt for every future conversation in this repository.
|
|
15
|
+
|
|
16
|
+
Method: use `explore` (several calls in the same turn, they run in parallel) \
|
|
17
|
+
to map the project — build/run/test/lint commands, architecture and directory \
|
|
18
|
+
layout, tech stack, code conventions, anything a coding agent must know to \
|
|
19
|
+
work here safely. Read the key config files (package manifests, CI, existing \
|
|
20
|
+
README) yourself where precision matters.
|
|
21
|
+
|
|
22
|
+
Then write a DELOS.md at the repository root with `write_content` (create it \
|
|
23
|
+
with `run_shell touch DELOS.md` first if needed). Keep it under ~150 lines: \
|
|
24
|
+
dense, factual, imperative. Sections: project overview (2-3 lines), commands, \
|
|
25
|
+
architecture, conventions, warnings/pitfalls. No filler prose."""
|
|
26
|
+
|
|
27
|
+
INIT_PROMPT_UPDATE = """\
|
|
28
|
+
This repository already has DELOS.md content (it is loaded in your system \
|
|
29
|
+
prompt under "Project instructions"). Review it against the actual codebase \
|
|
30
|
+
and update it: use `explore` (several calls in the same turn, they run in \
|
|
31
|
+
parallel) to verify the documented commands, architecture and conventions \
|
|
32
|
+
still match reality, then edit DELOS.md at the repository root to fix what \
|
|
33
|
+
drifted, remove what's dead, and add what's missing. Keep it under ~150 \
|
|
34
|
+
lines: dense, factual, imperative."""
|
|
35
|
+
|
|
36
|
+
EXECUTE_PROMPT = """\
|
|
37
|
+
Execute the plan you presented above. First mirror its steps into \
|
|
38
|
+
`todo_write`, then work through them in order, marking each step completed \
|
|
39
|
+
as you go. If no plan exists in this conversation, say so instead of \
|
|
40
|
+
improvising one."""
|
|
41
|
+
|
|
42
|
+
#: Catalogue served to frontends (``list_commands`` RPC) for autocomplete.
|
|
43
|
+
PROMPT_COMMANDS: tuple[dict[str, str], ...] = (
|
|
44
|
+
{
|
|
45
|
+
"name": "/init",
|
|
46
|
+
"summary": "generate or refresh the project's DELOS.md by exploring the codebase",
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "/execute",
|
|
50
|
+
"summary": "execute the plan from the previous plan-mode turn (loads it into todos)",
|
|
51
|
+
},
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def expand_slash_command(
|
|
56
|
+
content: str,
|
|
57
|
+
mode: str,
|
|
58
|
+
*,
|
|
59
|
+
has_project_instructions: bool,
|
|
60
|
+
) -> tuple[str, str]:
|
|
61
|
+
"""Expand a leading slash command into its agent prompt.
|
|
62
|
+
|
|
63
|
+
Returns ``(content, mode)`` — unchanged when the message isn't a known
|
|
64
|
+
command (unknown ``/xyz`` passes through as plain text; the agent can
|
|
65
|
+
answer helpfully). ``/execute`` forces build mode: executing a plan is
|
|
66
|
+
pointless with read-only tools.
|
|
67
|
+
"""
|
|
68
|
+
head, _, rest = content.strip().partition(" ")
|
|
69
|
+
rest = rest.strip()
|
|
70
|
+
if head == "/init":
|
|
71
|
+
prompt = INIT_PROMPT_UPDATE if has_project_instructions else INIT_PROMPT_NEW
|
|
72
|
+
if rest:
|
|
73
|
+
prompt += f"\n\nAdditional user instructions: {rest}"
|
|
74
|
+
return prompt, mode
|
|
75
|
+
if head == "/execute":
|
|
76
|
+
prompt = EXECUTE_PROMPT
|
|
77
|
+
if rest:
|
|
78
|
+
prompt += f"\n\nAdditional user instructions: {rest}"
|
|
79
|
+
return prompt, "build"
|
|
80
|
+
return content, mode
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Collect a compact git snapshot at the start of each agent turn.
|
|
2
|
+
|
|
3
|
+
Sent to the backend as ``git_context`` on every ``/completions`` request and
|
|
4
|
+
rendered into the system prompt, so the agent knows the branch, dirty state,
|
|
5
|
+
and recent history without burning its first tool call on ``git status``.
|
|
6
|
+
|
|
7
|
+
Collection is best-effort: any failure (not a repo, git missing, timeout)
|
|
8
|
+
returns ``None`` and the turn proceeds without the section.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_CMD_TIMEOUT_S = 2.0
|
|
20
|
+
_MAX_STATUS_LINES = 20
|
|
21
|
+
_LOG_COUNT = 5
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def _git(workspace: Path, *args: str) -> str | None:
|
|
25
|
+
"""Run one git command in ``workspace``; None on any failure."""
|
|
26
|
+
try:
|
|
27
|
+
proc = await asyncio.create_subprocess_exec(
|
|
28
|
+
"git",
|
|
29
|
+
"-C",
|
|
30
|
+
str(workspace),
|
|
31
|
+
*args,
|
|
32
|
+
stdout=asyncio.subprocess.PIPE,
|
|
33
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
34
|
+
)
|
|
35
|
+
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=_CMD_TIMEOUT_S)
|
|
36
|
+
except (OSError, TimeoutError):
|
|
37
|
+
return None
|
|
38
|
+
if proc.returncode != 0:
|
|
39
|
+
return None
|
|
40
|
+
return stdout.decode("utf-8", errors="replace").rstrip()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def collect_git_context(workspace: Path) -> str | None:
|
|
44
|
+
"""Return a compact ``branch / status / recent commits`` block, or None.
|
|
45
|
+
|
|
46
|
+
``None`` when the workspace isn't a git repo or git isn't usable —
|
|
47
|
+
callers just omit the field.
|
|
48
|
+
"""
|
|
49
|
+
inside = await _git(workspace, "rev-parse", "--is-inside-work-tree")
|
|
50
|
+
if inside != "true":
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
branch, status, log = await asyncio.gather(
|
|
54
|
+
_git(workspace, "branch", "--show-current"),
|
|
55
|
+
_git(workspace, "status", "--porcelain"),
|
|
56
|
+
_git(workspace, "log", "--oneline", f"-{_LOG_COUNT}"),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
lines: list[str] = [f"Branch: {branch or '(detached HEAD)'}"]
|
|
60
|
+
|
|
61
|
+
if status:
|
|
62
|
+
status_lines = status.splitlines()
|
|
63
|
+
shown = status_lines[:_MAX_STATUS_LINES]
|
|
64
|
+
lines.append(f"Status ({len(status_lines)} changed file(s)):")
|
|
65
|
+
lines.extend(shown)
|
|
66
|
+
if len(status_lines) > len(shown):
|
|
67
|
+
lines.append(f"... ({len(status_lines) - len(shown)} more)")
|
|
68
|
+
else:
|
|
69
|
+
lines.append("Status: clean")
|
|
70
|
+
|
|
71
|
+
if log:
|
|
72
|
+
lines.extend(("Recent commits:", log))
|
|
73
|
+
|
|
74
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Minimal :class:`App` for headless serve mode.
|
|
2
|
+
|
|
3
|
+
The stdio server drives :class:`~delos_cli.agent.AgentSession` directly and
|
|
4
|
+
forwards raw v6 events to the frontend — there is no renderer and no REPL
|
|
5
|
+
command set. This class only exists so ``Ctx.app`` is populated with the
|
|
6
|
+
bits tool handlers actually reach for: the :class:`ToolRegistry` and the
|
|
7
|
+
``todo_store`` the ``todo_write`` handler looks up via ``getattr``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from delos_cli.agent import ToolRegistry
|
|
16
|
+
from delos_cli.apps.base import App
|
|
17
|
+
from delos_cli.apps.chat.app import default_tool_registry
|
|
18
|
+
from delos_cli.state import TodoStore
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
22
|
+
|
|
23
|
+
from delos_cli.agent.tools import ToolHandler
|
|
24
|
+
from delos_cli.apps.base import Renderer
|
|
25
|
+
from delos_cli.commands.base import CommandSpec
|
|
26
|
+
from delos_cli.ctx import Ctx
|
|
27
|
+
from delos_cli.ui.output import OutputBuffer
|
|
28
|
+
|
|
29
|
+
#: Tools resolved by the VSCode extension itself (they need the editor API);
|
|
30
|
+
#: the serve loop relays them over the stdio protocol. Single source of
|
|
31
|
+
#: truth for the forwarding wiring — must match the backend's
|
|
32
|
+
#: ``cosmos.agents.tools.client.vscode`` package.
|
|
33
|
+
VSCODE_FORWARDED_TOOLS: tuple[str, ...] = (
|
|
34
|
+
"get_diagnostics",
|
|
35
|
+
"get_active_editor_context",
|
|
36
|
+
"get_document_symbols",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class HeadlessToolRegistry(ToolRegistry):
|
|
41
|
+
"""Registry whose fallback fails fast instead of prompting.
|
|
42
|
+
|
|
43
|
+
The base registry's default handler opens an interactive
|
|
44
|
+
prompt_toolkit session. In headless serve mode there is no TTY —
|
|
45
|
+
stdin is the extension's NDJSON pipe — so an unknown client tool
|
|
46
|
+
(typically a backend newer than this CLI build) would block the turn
|
|
47
|
+
forever waiting for keyboard input. Return a self-describing error
|
|
48
|
+
so the model can route around the missing tool and the user sees a
|
|
49
|
+
reply instead of an infinite spinner.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def handler_for(self, name: str) -> ToolHandler:
|
|
53
|
+
"""Return the registered handler, or an error-returning stub."""
|
|
54
|
+
handler = self.handlers.get(name)
|
|
55
|
+
if handler is not None:
|
|
56
|
+
return handler
|
|
57
|
+
|
|
58
|
+
async def _unknown_tool(tool_input: dict[str, Any], ctx: Ctx) -> str: # noqa: RUF029 — ToolHandler protocol is async
|
|
59
|
+
_ = tool_input, ctx
|
|
60
|
+
return (
|
|
61
|
+
f"Error: client tool '{name}' is not supported by this "
|
|
62
|
+
"delos-cli build (backend/CLI version skew). Do not retry it. "
|
|
63
|
+
"Continue with the tools that work, and suggest the user "
|
|
64
|
+
"restart or update the Delos CLI."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
return _unknown_tool
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _make_forwarding_handler(name: str) -> ToolHandler:
|
|
71
|
+
"""Relay one editor tool through ``ctx.app.forward_tool`` (set by the server)."""
|
|
72
|
+
|
|
73
|
+
async def _handler(tool_input: dict[str, Any], ctx: Ctx) -> str:
|
|
74
|
+
forward = getattr(ctx.app, "forward_tool", None)
|
|
75
|
+
if forward is None:
|
|
76
|
+
return (
|
|
77
|
+
f"Error: '{name}' needs the VSCode editor bridge, which is not "
|
|
78
|
+
"connected. Continue without it."
|
|
79
|
+
)
|
|
80
|
+
return await forward(name, tool_input)
|
|
81
|
+
|
|
82
|
+
return _handler
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _headless_registry() -> HeadlessToolRegistry:
|
|
86
|
+
"""The default tool set + editor forwarding, wrapped with the fail-fast fallback."""
|
|
87
|
+
reg = default_tool_registry()
|
|
88
|
+
for name in VSCODE_FORWARDED_TOOLS:
|
|
89
|
+
reg.handlers[name] = _make_forwarding_handler(name)
|
|
90
|
+
return HeadlessToolRegistry(renderers=reg.renderers, handlers=reg.handlers)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@dataclass
|
|
94
|
+
class VsCodeServeApp(App):
|
|
95
|
+
"""Ctx.app placeholder for ``delos serve --app vscode``."""
|
|
96
|
+
|
|
97
|
+
name: str = "vscode"
|
|
98
|
+
commands: dict[str, CommandSpec] = field(default_factory=dict)
|
|
99
|
+
tools: ToolRegistry = field(default_factory=_headless_registry)
|
|
100
|
+
#: Mutated by the ``todo_write`` handler; no ``attach`` hook in
|
|
101
|
+
#: headless mode (the frontend renders its own plan UI, if any).
|
|
102
|
+
todo_store: TodoStore = field(default_factory=TodoStore)
|
|
103
|
+
#: Set by :class:`~delos_cli.serve.server.StdioServer` — async
|
|
104
|
+
#: ``(tool_name, input) -> result`` relay used by the editor-tool
|
|
105
|
+
#: forwarding handlers. ``None`` outside serve mode.
|
|
106
|
+
forward_tool: Callable[[str, dict[str, Any]], Awaitable[str]] | None = None
|
|
107
|
+
|
|
108
|
+
async def on_enter(self, ctx: Ctx) -> None:
|
|
109
|
+
"""Nothing to prefetch in headless mode."""
|
|
110
|
+
|
|
111
|
+
def send(
|
|
112
|
+
self,
|
|
113
|
+
ctx: Ctx,
|
|
114
|
+
messages: list[dict[str, str]],
|
|
115
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
116
|
+
"""Unused — the serve loop drives :class:`AgentSession` itself."""
|
|
117
|
+
_ = self, ctx, messages
|
|
118
|
+
msg = "VsCodeServeApp.send is not used; the stdio server owns the turn loop"
|
|
119
|
+
raise NotImplementedError(msg)
|
|
120
|
+
|
|
121
|
+
def make_renderer(self, output: OutputBuffer) -> Renderer:
|
|
122
|
+
"""Unused — headless mode forwards raw events, nothing renders."""
|
|
123
|
+
_ = self, output
|
|
124
|
+
msg = "VsCodeServeApp has no renderer; events are forwarded raw"
|
|
125
|
+
raise NotImplementedError(msg)
|
|
@@ -8,10 +8,14 @@ the dispatcher in ``server.py`` turns exceptions into error responses.
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
10
|
import asyncio
|
|
11
|
+
import base64
|
|
12
|
+
import binascii
|
|
11
13
|
import os
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import TYPE_CHECKING, Any
|
|
14
16
|
|
|
17
|
+
from delos_cli.transport.chats import fork_chat as _fork_chat
|
|
18
|
+
from delos_cli.transport.documents import list_drive_documents as _list_drive_documents
|
|
15
19
|
from delos_cli.transport.integrations import (
|
|
16
20
|
fetch_integrations,
|
|
17
21
|
fetch_manage_url,
|
|
@@ -63,6 +67,23 @@ async def delete_chat(server: StdioServer, params: dict[str, Any]) -> dict[str,
|
|
|
63
67
|
)
|
|
64
68
|
|
|
65
69
|
|
|
70
|
+
async def fork_chat(server: StdioServer, params: dict[str, Any]) -> dict[str, Any]:
|
|
71
|
+
"""Duplicate a chat's context into a new conversation.
|
|
72
|
+
|
|
73
|
+
``userMessageCount`` bounds the copied prefix (see
|
|
74
|
+
:func:`delos_cli.transport.chats.fork_chat`); ``0`` copies everything.
|
|
75
|
+
"""
|
|
76
|
+
http = server.require_http()
|
|
77
|
+
chat_id = _required_str(params, "chatId")
|
|
78
|
+
user_message_count = int(params.get("userMessageCount") or 0)
|
|
79
|
+
new_id, title = await _fork_chat(
|
|
80
|
+
http,
|
|
81
|
+
source_chat_id=chat_id,
|
|
82
|
+
user_message_count=user_message_count,
|
|
83
|
+
)
|
|
84
|
+
return {"id": new_id, "title": title}
|
|
85
|
+
|
|
86
|
+
|
|
66
87
|
async def find_files(server: StdioServer, params: dict[str, Any]) -> list[str]:
|
|
67
88
|
"""Fuzzy-ish file lookup: ``rg --files`` + case-insensitive substring filter.
|
|
68
89
|
|
|
@@ -152,6 +173,49 @@ async def toggle_integration(
|
|
|
152
173
|
return await set_integration_active(server.require_http(), service, active)
|
|
153
174
|
|
|
154
175
|
|
|
176
|
+
async def upload_file(server: StdioServer, params: dict[str, Any]) -> dict[str, Any]:
|
|
177
|
+
"""Upload one attachment to ``/files/upload`` (origin=chat); return its id.
|
|
178
|
+
|
|
179
|
+
The frontend sends ``filename``, ``mime`` and base64 ``content``. Returns
|
|
180
|
+
``{"fileId": str, "name": str, "fileType": str | None, "kind": "doc"}`` —
|
|
181
|
+
the shape the frontend turns into a ``message_files`` ref for the next turn.
|
|
182
|
+
"""
|
|
183
|
+
http = server.require_http()
|
|
184
|
+
filename = _required_str(params, "filename")
|
|
185
|
+
mime = str(params.get("mime") or "application/octet-stream")
|
|
186
|
+
b64 = str(params.get("content") or "")
|
|
187
|
+
try:
|
|
188
|
+
content = base64.b64decode(b64.rsplit(",", maxsplit=1)[-1], validate=False)
|
|
189
|
+
except (binascii.Error, ValueError) as exc:
|
|
190
|
+
msg = "invalid base64 file content"
|
|
191
|
+
raise ValueError(msg) from exc
|
|
192
|
+
|
|
193
|
+
result = await http.upload_file(
|
|
194
|
+
"/files/upload",
|
|
195
|
+
filename=filename,
|
|
196
|
+
content=content,
|
|
197
|
+
mime=mime,
|
|
198
|
+
data={"org_uuid": str(server.cfg.org_uuid), "origin": "chat", "language": "en"},
|
|
199
|
+
)
|
|
200
|
+
file_id = str(result.get("file_id") or "")
|
|
201
|
+
if not file_id:
|
|
202
|
+
msg = "upload did not return a file_id"
|
|
203
|
+
raise ValueError(msg)
|
|
204
|
+
ext = filename.rsplit(".", 1)[-1].lower() if "." in filename else None
|
|
205
|
+
return {"fileId": file_id, "name": filename, "fileType": ext, "kind": "doc"}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def list_drive_documents(server: StdioServer, params: dict[str, Any]) -> list[dict[str, Any]]:
|
|
209
|
+
"""List Delos documents the user can attach (Import from Drive)."""
|
|
210
|
+
_ = params
|
|
211
|
+
http = server.require_http()
|
|
212
|
+
items = await _list_drive_documents(http, str(server.cfg.org_uuid))
|
|
213
|
+
return [
|
|
214
|
+
{"id": it.id, "name": it.name, "kind": it.kind, "fileType": it.file_type}
|
|
215
|
+
for it in items
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
|
|
155
219
|
def _required_str(params: dict[str, Any], key: str) -> str:
|
|
156
220
|
value = str(params.get(key) or "").strip()
|
|
157
221
|
if not value:
|