mycode-cli 0.14.2__py3-none-any.whl → 0.15.0__py3-none-any.whl
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.
- mycode_cli/config.py +20 -30
- mycode_cli/main.py +16 -3
- mycode_cli/permissions.py +4 -7
- mycode_cli/runtime.py +2 -13
- mycode_cli/server/app.py +18 -3
- mycode_cli/server/deps.py +8 -12
- mycode_cli/server/routers/chat.py +11 -10
- mycode_cli/server/routers/sessions.py +13 -15
- mycode_cli/server/routers/settings.py +10 -8
- mycode_cli/server/routers/workspaces.py +2 -2
- mycode_cli/server/run_manager.py +70 -33
- mycode_cli/sessions.py +32 -2
- mycode_cli/system_prompt.py +5 -4
- mycode_cli/tools.py +23 -21
- mycode_cli/tui/chat.py +21 -40
- mycode_cli/tui/render.py +3 -2
- {mycode_cli-0.14.2.dist-info → mycode_cli-0.15.0.dist-info}/METADATA +2 -2
- {mycode_cli-0.14.2.dist-info → mycode_cli-0.15.0.dist-info}/RECORD +21 -21
- {mycode_cli-0.14.2.dist-info → mycode_cli-0.15.0.dist-info}/WHEEL +0 -0
- {mycode_cli-0.14.2.dist-info → mycode_cli-0.15.0.dist-info}/entry_points.txt +0 -0
- {mycode_cli-0.14.2.dist-info → mycode_cli-0.15.0.dist-info}/licenses/LICENSE +0 -0
mycode_cli/config.py
CHANGED
|
@@ -187,7 +187,7 @@ def parse_compact_threshold(value: Any) -> float | None:
|
|
|
187
187
|
return None
|
|
188
188
|
threshold = float(value)
|
|
189
189
|
|
|
190
|
-
if
|
|
190
|
+
if not 0 <= threshold <= 1:
|
|
191
191
|
return None
|
|
192
192
|
return threshold
|
|
193
193
|
|
|
@@ -504,45 +504,37 @@ def get_settings(cwd: str | None = None) -> Settings:
|
|
|
504
504
|
if resolved_path not in config_paths:
|
|
505
505
|
config_paths.append(resolved_path)
|
|
506
506
|
|
|
507
|
-
for name, raw in
|
|
508
|
-
if not isinstance(name, str) or not isinstance(raw, dict):
|
|
509
|
-
continue
|
|
510
|
-
|
|
507
|
+
for name, raw in data.get("providers", {}).items():
|
|
511
508
|
merged = dict(raw_providers.get(name, {}))
|
|
512
509
|
|
|
513
510
|
if "type" in raw:
|
|
514
|
-
merged["type"] = raw
|
|
511
|
+
merged["type"] = raw["type"]
|
|
515
512
|
if "models" in raw:
|
|
516
|
-
merged["models"] = raw
|
|
513
|
+
merged["models"] = raw["models"]
|
|
517
514
|
if "api_key" in raw:
|
|
518
|
-
api_key, api_key_env_var = _parse_config_api_key(raw
|
|
515
|
+
api_key, api_key_env_var = _parse_config_api_key(raw["api_key"])
|
|
519
516
|
merged["api_key"] = api_key
|
|
520
517
|
merged["api_key_env_var"] = api_key_env_var
|
|
521
518
|
if "base_url" in raw:
|
|
522
|
-
merged["base_url"] = raw
|
|
519
|
+
merged["base_url"] = raw["base_url"]
|
|
523
520
|
if "supports_reasoning_effort" in raw:
|
|
524
|
-
merged["supports_reasoning_effort"] = raw
|
|
521
|
+
merged["supports_reasoning_effort"] = raw["supports_reasoning_effort"]
|
|
525
522
|
|
|
526
523
|
raw_providers[name] = merged
|
|
527
524
|
|
|
528
|
-
default = data.get("default")
|
|
529
|
-
if
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
default_model = v if isinstance(v, str) else None
|
|
536
|
-
if "compact_threshold" in default:
|
|
537
|
-
parsed_threshold = parse_compact_threshold(default.get("compact_threshold"))
|
|
538
|
-
if parsed_threshold is not None:
|
|
539
|
-
compact_threshold = parsed_threshold
|
|
525
|
+
default = data.get("default", {})
|
|
526
|
+
if "provider" in default:
|
|
527
|
+
default_provider = default["provider"]
|
|
528
|
+
if "model" in default:
|
|
529
|
+
default_model = default["model"]
|
|
530
|
+
if "compact_threshold" in default:
|
|
531
|
+
compact_threshold = float(default["compact_threshold"])
|
|
540
532
|
|
|
541
533
|
if "permission" in data:
|
|
542
534
|
permission = parse_permission(data.get("permission"), permission)
|
|
543
535
|
|
|
544
|
-
if
|
|
545
|
-
web =
|
|
536
|
+
if "web" in data:
|
|
537
|
+
web = data["web"]
|
|
546
538
|
for key in ("fetch", "search"):
|
|
547
539
|
if key in web:
|
|
548
540
|
raw_web[key] = web[key]
|
|
@@ -768,14 +760,12 @@ def _available_provider_references(settings: Settings) -> list[str]:
|
|
|
768
760
|
|
|
769
761
|
add(settings.default_provider)
|
|
770
762
|
|
|
771
|
-
for name
|
|
772
|
-
|
|
773
|
-
add(name)
|
|
763
|
+
for name in settings.providers:
|
|
764
|
+
add(name)
|
|
774
765
|
|
|
775
766
|
for provider_id in list_env_discoverable_providers():
|
|
776
|
-
if provider_id in configured_types_with_credentials
|
|
777
|
-
|
|
778
|
-
add(provider_id)
|
|
767
|
+
if provider_id not in configured_types_with_credentials:
|
|
768
|
+
add(provider_id)
|
|
779
769
|
|
|
780
770
|
return available
|
|
781
771
|
|
mycode_cli/main.py
CHANGED
|
@@ -7,7 +7,7 @@ import os
|
|
|
7
7
|
import sys
|
|
8
8
|
from contextlib import suppress
|
|
9
9
|
from dataclasses import dataclass, replace
|
|
10
|
-
from typing import Annotated, Any, Literal
|
|
10
|
+
from typing import TYPE_CHECKING, Annotated, Any, Literal
|
|
11
11
|
from uuid import uuid4
|
|
12
12
|
|
|
13
13
|
import typer
|
|
@@ -28,8 +28,9 @@ from mycode_cli.permissions import PERMISSION_DENIED_BY_USER_OUTPUT, PERMISSION_
|
|
|
28
28
|
from mycode_cli.sessions import SessionStore
|
|
29
29
|
|
|
30
30
|
from .runtime import build_agent
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from .tui.render import TerminalView
|
|
33
34
|
|
|
34
35
|
app = typer.Typer(add_completion=False, pretty_exceptions_enable=False)
|
|
35
36
|
session_app = typer.Typer(help="Session management")
|
|
@@ -157,6 +158,8 @@ def _bootstrap(
|
|
|
157
158
|
) -> _BootstrapContext:
|
|
158
159
|
"""Shared setup for the chat and run commands."""
|
|
159
160
|
|
|
161
|
+
from .tui.render import TerminalView
|
|
162
|
+
|
|
160
163
|
if session and continue_last:
|
|
161
164
|
raise typer.BadParameter("--session and --continue are mutually exclusive")
|
|
162
165
|
|
|
@@ -244,6 +247,8 @@ def chat(
|
|
|
244
247
|
if ctx.invoked_subcommand is not None:
|
|
245
248
|
return
|
|
246
249
|
|
|
250
|
+
from .tui.chat import TerminalChat
|
|
251
|
+
|
|
247
252
|
setup = _bootstrap(
|
|
248
253
|
provider=provider,
|
|
249
254
|
model=model,
|
|
@@ -323,6 +328,7 @@ def web(
|
|
|
323
328
|
resolved_port = port or settings.port
|
|
324
329
|
|
|
325
330
|
import uvicorn
|
|
331
|
+
from uvicorn.config import LOGGING_CONFIG
|
|
326
332
|
|
|
327
333
|
app_factory = "mycode_cli.server.app:create_web_app"
|
|
328
334
|
if dev:
|
|
@@ -334,6 +340,11 @@ def web(
|
|
|
334
340
|
port=resolved_port,
|
|
335
341
|
reload=dev,
|
|
336
342
|
factory=True,
|
|
343
|
+
timeout_graceful_shutdown=5,
|
|
344
|
+
log_config={
|
|
345
|
+
**LOGGING_CONFIG,
|
|
346
|
+
"root": {"handlers": ["default"], "level": "INFO"},
|
|
347
|
+
},
|
|
337
348
|
)
|
|
338
349
|
|
|
339
350
|
|
|
@@ -343,6 +354,8 @@ def session_list(
|
|
|
343
354
|
) -> None:
|
|
344
355
|
"""List saved sessions."""
|
|
345
356
|
|
|
357
|
+
from .tui.render import TerminalView
|
|
358
|
+
|
|
346
359
|
cwd = os.path.abspath(os.getcwd())
|
|
347
360
|
store = SessionStore(data_dir=resolve_sessions_dir())
|
|
348
361
|
view = TerminalView()
|
mycode_cli/permissions.py
CHANGED
|
@@ -13,7 +13,6 @@ from mycode_cli.config import PermissionConfig, PermissionLevel, Settings
|
|
|
13
13
|
from mycode_cli.system_prompt import discover_skills
|
|
14
14
|
from mycode_cli.workspace import CliDeps, resolve_path
|
|
15
15
|
|
|
16
|
-
PermissionTier = Literal["readonly", "safe", "standard", "yolo"]
|
|
17
16
|
PermissionDecision = Literal["allow", "ask", "deny"]
|
|
18
17
|
ToolReviewDecision = Literal["allow", "deny"]
|
|
19
18
|
|
|
@@ -54,7 +53,7 @@ _FIND_DANGEROUS_FLAGS = {
|
|
|
54
53
|
|
|
55
54
|
|
|
56
55
|
class PermissionCheck(NamedTuple):
|
|
57
|
-
tier:
|
|
56
|
+
tier: PermissionLevel
|
|
58
57
|
preview: str
|
|
59
58
|
|
|
60
59
|
|
|
@@ -102,10 +101,8 @@ def build_permission_hooks(
|
|
|
102
101
|
return hooks
|
|
103
102
|
|
|
104
103
|
|
|
105
|
-
def permission_decision(permission: PermissionConfig, tier:
|
|
106
|
-
if permission.level
|
|
107
|
-
return "allow"
|
|
108
|
-
if tier != "yolo" and _LEVEL_RANK[tier] <= _LEVEL_RANK[permission.level]:
|
|
104
|
+
def permission_decision(permission: PermissionConfig, tier: PermissionLevel) -> PermissionDecision:
|
|
105
|
+
if _LEVEL_RANK[tier] <= _LEVEL_RANK[permission.level]:
|
|
109
106
|
return "allow"
|
|
110
107
|
return permission.mode
|
|
111
108
|
|
|
@@ -147,7 +144,7 @@ def classify_tool(
|
|
|
147
144
|
return PermissionCheck("yolo", ctx.tool_name)
|
|
148
145
|
|
|
149
146
|
|
|
150
|
-
def _classify_bash(command: str) ->
|
|
147
|
+
def _classify_bash(command: str) -> PermissionLevel:
|
|
151
148
|
if not command:
|
|
152
149
|
return "yolo"
|
|
153
150
|
# Newlines and shell expansion bypass static analysis; treat as compound.
|
mycode_cli/runtime.py
CHANGED
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from mycode.agent import Agent
|
|
6
6
|
from mycode_cli.config import ResolvedProvider, Settings
|
|
7
7
|
from mycode_cli.permissions import ToolReviewCallback, build_permission_hooks
|
|
8
|
-
from mycode_cli.sessions import SessionStore
|
|
8
|
+
from mycode_cli.sessions import SessionStore, sum_session_cost
|
|
9
9
|
from mycode_cli.system_prompt import build_system_prompt
|
|
10
10
|
from mycode_cli.tools import DEFAULT_TOOLS
|
|
11
11
|
from mycode_cli.web_tools import build_web_tools
|
|
@@ -24,18 +24,7 @@ async def load_session_cost(store: SessionStore, session_id: str) -> float | Non
|
|
|
24
24
|
discarded by rewind. Records without a cost are skipped.
|
|
25
25
|
"""
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
for message in await store.load_raw_messages(session_id):
|
|
29
|
-
if message.get("role") not in {"assistant", "compact"}:
|
|
30
|
-
continue
|
|
31
|
-
meta = message.get("meta") or {}
|
|
32
|
-
cost = meta.get("cost")
|
|
33
|
-
if not isinstance(cost, dict):
|
|
34
|
-
continue
|
|
35
|
-
request_total = cost.get("total")
|
|
36
|
-
if isinstance(request_total, int | float):
|
|
37
|
-
total = sum_known_costs(total, float(request_total))
|
|
38
|
-
return total
|
|
27
|
+
return sum_session_cost(await store.load_raw_messages(session_id))
|
|
39
28
|
|
|
40
29
|
|
|
41
30
|
def build_agent(
|
mycode_cli/server/app.py
CHANGED
|
@@ -1,20 +1,24 @@
|
|
|
1
1
|
"""FastAPI application entry point."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
import logging
|
|
4
|
-
from collections.abc import Sequence
|
|
5
|
+
from collections.abc import AsyncGenerator, Sequence
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
5
7
|
from pathlib import Path
|
|
6
8
|
|
|
7
9
|
from fastapi import FastAPI
|
|
8
10
|
from fastapi.middleware.cors import CORSMiddleware
|
|
9
11
|
from fastapi.staticfiles import StaticFiles
|
|
10
12
|
|
|
13
|
+
from mycode_cli.config import resolve_sessions_dir
|
|
11
14
|
from mycode_cli.server.routers.chat import router as chat_router
|
|
12
15
|
from mycode_cli.server.routers.sessions import router as sessions_router
|
|
13
16
|
from mycode_cli.server.routers.settings import router as settings_router
|
|
14
17
|
from mycode_cli.server.routers.workspaces import router as workspaces_router
|
|
18
|
+
from mycode_cli.server.run_manager import RunManager
|
|
19
|
+
from mycode_cli.sessions import SessionStore
|
|
15
20
|
|
|
16
21
|
logger = logging.getLogger(__name__)
|
|
17
|
-
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
|
|
18
22
|
DEV_CORS_ORIGINS = ("http://localhost:5173", "http://127.0.0.1:5173")
|
|
19
23
|
|
|
20
24
|
|
|
@@ -24,9 +28,20 @@ def web_static_path() -> Path:
|
|
|
24
28
|
return Path(__file__).resolve().parent / "static"
|
|
25
29
|
|
|
26
30
|
|
|
31
|
+
@asynccontextmanager
|
|
32
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
|
|
33
|
+
app.state.store = await asyncio.to_thread(SessionStore, data_dir=resolve_sessions_dir())
|
|
34
|
+
runs = RunManager()
|
|
35
|
+
app.state.runs = runs
|
|
36
|
+
try:
|
|
37
|
+
yield
|
|
38
|
+
finally:
|
|
39
|
+
await runs.aclose()
|
|
40
|
+
|
|
41
|
+
|
|
27
42
|
def create_app(*, serve_web: bool = True, cors_origins: Sequence[str] = ()) -> FastAPI:
|
|
28
43
|
"""Create the FastAPI app."""
|
|
29
|
-
application = FastAPI(title="mycode")
|
|
44
|
+
application = FastAPI(title="mycode", lifespan=lifespan)
|
|
30
45
|
|
|
31
46
|
if cors_origins:
|
|
32
47
|
application.add_middleware(
|
mycode_cli/server/deps.py
CHANGED
|
@@ -3,12 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
-
from
|
|
7
|
-
from typing import Annotated
|
|
6
|
+
from typing import Annotated, cast
|
|
8
7
|
|
|
9
|
-
from fastapi import Depends, HTTPException
|
|
8
|
+
from fastapi import Depends, HTTPException, Request
|
|
10
9
|
|
|
11
|
-
from mycode_cli.config import resolve_sessions_dir
|
|
12
10
|
from mycode_cli.server.run_manager import RunManager
|
|
13
11
|
from mycode_cli.sessions import SessionStore
|
|
14
12
|
|
|
@@ -22,18 +20,16 @@ def resolve_workspace_cwd(raw: str | None) -> str:
|
|
|
22
20
|
return cwd
|
|
23
21
|
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"""Return the shared session store for server requests."""
|
|
23
|
+
async def get_store(request: Request) -> SessionStore:
|
|
24
|
+
"""Return this application's session store."""
|
|
28
25
|
|
|
29
|
-
return SessionStore
|
|
26
|
+
return cast(SessionStore, request.app.state.store)
|
|
30
27
|
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
"""Return the shared in-process run manager."""
|
|
29
|
+
async def get_run_manager(request: Request) -> RunManager:
|
|
30
|
+
"""Return this application's run manager."""
|
|
35
31
|
|
|
36
|
-
return RunManager
|
|
32
|
+
return cast(RunManager, request.app.state.runs)
|
|
37
33
|
|
|
38
34
|
|
|
39
35
|
StoreDep = Annotated[SessionStore, Depends(get_store)]
|
|
@@ -63,7 +63,7 @@ router = APIRouter()
|
|
|
63
63
|
def _resolve_workspace_attachment_path(rel_path: str, *, cwd: str) -> Path:
|
|
64
64
|
base = resolve_path(".", cwd=cwd)
|
|
65
65
|
path = resolve_path(rel_path, cwd=cwd)
|
|
66
|
-
if
|
|
66
|
+
if not path.is_relative_to(base):
|
|
67
67
|
raise HTTPException(status_code=400, detail=f"path outside workspace: {rel_path}")
|
|
68
68
|
return path
|
|
69
69
|
|
|
@@ -97,7 +97,9 @@ async def _build_user_message(chat: ChatRequest, cwd: str) -> ConversationMessag
|
|
|
97
97
|
for block in chat.input:
|
|
98
98
|
if block.type == "text":
|
|
99
99
|
if block.path:
|
|
100
|
-
blocks.extend(
|
|
100
|
+
blocks.extend(
|
|
101
|
+
await asyncio.to_thread(_read_workspace_text_attachment, block.path, name=block.name, cwd=cwd)
|
|
102
|
+
)
|
|
101
103
|
continue
|
|
102
104
|
text = block.text or ""
|
|
103
105
|
if block.is_attachment:
|
|
@@ -145,11 +147,8 @@ def _validate_rewind_request(
|
|
|
145
147
|
*,
|
|
146
148
|
session: dict[str, Any] | None,
|
|
147
149
|
messages: list[ConversationMessage],
|
|
148
|
-
rewind_to: int
|
|
150
|
+
rewind_to: int,
|
|
149
151
|
) -> None:
|
|
150
|
-
if rewind_to is None:
|
|
151
|
-
return
|
|
152
|
-
|
|
153
152
|
if session is None:
|
|
154
153
|
raise HTTPException(status_code=400, detail="rewind_to requires an existing session")
|
|
155
154
|
|
|
@@ -236,10 +235,11 @@ async def chat(chat: ChatRequest, store: StoreDep, runs: RunManagerDep) -> ChatR
|
|
|
236
235
|
detail={"message": "session already has a running task", "run": active},
|
|
237
236
|
)
|
|
238
237
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
238
|
+
if chat.rewind_to is not None:
|
|
239
|
+
data = await store.load_session(session_id)
|
|
240
|
+
session = cast(dict[str, Any] | None, data["session"] if data else None)
|
|
241
|
+
existing_messages = data["messages"] if data else []
|
|
242
|
+
_validate_rewind_request(session=session, messages=existing_messages, rewind_to=chat.rewind_to)
|
|
243
243
|
|
|
244
244
|
# All validation passed. Land the rewind marker (if any) and register
|
|
245
245
|
# the user turn in the catalog: first turn creates the entry and sets
|
|
@@ -374,6 +374,7 @@ async def compact_session(
|
|
|
374
374
|
session_id=session_id,
|
|
375
375
|
base_messages=agent.messages,
|
|
376
376
|
agent=agent,
|
|
377
|
+
session_cost_base=data["session_cost"],
|
|
377
378
|
on_complete=store.touch,
|
|
378
379
|
)
|
|
379
380
|
except ActiveRunError as exc:
|
|
@@ -9,7 +9,6 @@ from fastapi import APIRouter, HTTPException, Query
|
|
|
9
9
|
from fastapi import Path as PathParam
|
|
10
10
|
|
|
11
11
|
from mycode.messages import ConversationMessage
|
|
12
|
-
from mycode_cli.runtime import load_session_cost
|
|
13
12
|
from mycode_cli.server.deps import RunManagerDep, StoreDep, resolve_workspace_cwd
|
|
14
13
|
from mycode_cli.server.schemas import SessionCreateRequest, StatusResponse
|
|
15
14
|
|
|
@@ -57,19 +56,18 @@ async def load_session(
|
|
|
57
56
|
) -> dict[str, Any]:
|
|
58
57
|
"""Load a session, overlaying any active in-memory run state."""
|
|
59
58
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
59
|
+
async with runs.session_operation(session_id):
|
|
60
|
+
active = await runs.snapshot_session(session_id)
|
|
61
|
+
if active:
|
|
62
|
+
return {
|
|
63
|
+
"session": await store.load_metadata(session_id),
|
|
64
|
+
"messages": _redact_document_data(active["messages"]),
|
|
65
|
+
"session_cost": active["session_cost"],
|
|
66
|
+
"active_run": active["run"],
|
|
67
|
+
"pending_events": active["pending_events"],
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
data = await store.load_session(session_id)
|
|
73
71
|
|
|
74
72
|
if data is None:
|
|
75
73
|
return {"session": None, "messages": [], "session_cost": None, "active_run": None, "pending_events": []}
|
|
@@ -77,7 +75,7 @@ async def load_session(
|
|
|
77
75
|
return {
|
|
78
76
|
"session": data["session"],
|
|
79
77
|
"messages": _redact_document_data(data["messages"]),
|
|
80
|
-
"session_cost": session_cost,
|
|
78
|
+
"session_cost": data["session_cost"],
|
|
81
79
|
"active_run": None,
|
|
82
80
|
"pending_events": [],
|
|
83
81
|
}
|
|
@@ -168,14 +168,16 @@ def put_settings_endpoint(payload: SettingsRequest) -> dict[str, Any]:
|
|
|
168
168
|
# Three-state api_key merge: when the UI sends null / omits it, we copy the
|
|
169
169
|
# existing literal forward so secrets survive a save without round-tripping.
|
|
170
170
|
existing_providers = existing.get("providers") or {}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
171
|
+
incoming_providers = incoming.get("providers")
|
|
172
|
+
if isinstance(incoming_providers, dict):
|
|
173
|
+
for name, entry in incoming_providers.items():
|
|
174
|
+
if not isinstance(entry, dict) or entry.get("api_key") is not None:
|
|
175
|
+
continue
|
|
176
|
+
prior = existing_providers.get(name) if isinstance(existing_providers, dict) else None
|
|
177
|
+
if isinstance(prior, dict) and "api_key" in prior:
|
|
178
|
+
entry["api_key"] = prior["api_key"]
|
|
179
|
+
else:
|
|
180
|
+
entry.pop("api_key", None)
|
|
179
181
|
|
|
180
182
|
existing_web = existing.get("web") or {}
|
|
181
183
|
incoming_web = incoming.get("web") or {}
|
|
@@ -121,7 +121,7 @@ def list_workspace_files(
|
|
|
121
121
|
|
|
122
122
|
target = (base / dir).resolve(strict=False)
|
|
123
123
|
# Reject traversal outside cwd after resolving symlinks.
|
|
124
|
-
if
|
|
124
|
+
if not target.is_relative_to(base):
|
|
125
125
|
return {"entries": [], "truncated": False, "error": "path outside workspace"}
|
|
126
126
|
if not target.is_dir():
|
|
127
127
|
return {"entries": [], "truncated": False, "error": "not a directory"}
|
|
@@ -133,7 +133,7 @@ def list_workspace_files(
|
|
|
133
133
|
continue
|
|
134
134
|
try:
|
|
135
135
|
resolved = entry.resolve()
|
|
136
|
-
if
|
|
136
|
+
if not resolved.is_relative_to(base):
|
|
137
137
|
continue
|
|
138
138
|
is_dir = entry.is_dir()
|
|
139
139
|
except OSError:
|
mycode_cli/server/run_manager.py
CHANGED
|
@@ -6,15 +6,17 @@ import asyncio
|
|
|
6
6
|
import copy
|
|
7
7
|
import time
|
|
8
8
|
from collections.abc import AsyncGenerator, AsyncIterator, Awaitable, Callable
|
|
9
|
-
from contextlib import asynccontextmanager
|
|
9
|
+
from contextlib import asynccontextmanager
|
|
10
10
|
from dataclasses import dataclass, field
|
|
11
11
|
from typing import Any, Literal, Protocol
|
|
12
12
|
from uuid import uuid4
|
|
13
|
+
from weakref import WeakValueDictionary
|
|
13
14
|
|
|
14
15
|
from mycode.agent import Event
|
|
15
16
|
from mycode.messages import ConversationMessage
|
|
16
17
|
from mycode_cli.permissions import ToolReviewDecision
|
|
17
18
|
from mycode_cli.runtime import sum_known_costs
|
|
19
|
+
from mycode_cli.sessions import sum_session_cost
|
|
18
20
|
|
|
19
21
|
RunStatus = Literal["running", "completed", "failed", "cancelled"]
|
|
20
22
|
RunKind = Literal["chat", "compact"]
|
|
@@ -60,9 +62,9 @@ class RunState:
|
|
|
60
62
|
agent: RunAgent
|
|
61
63
|
kind: RunKind = "chat"
|
|
62
64
|
user_message: ConversationMessage | None = None
|
|
63
|
-
#
|
|
64
|
-
# router); None means unknown. Composed with each usage event's turn cost.
|
|
65
|
+
# Fixed history cost, combined with cumulative turn costs on each update.
|
|
65
66
|
session_cost_base: float | None = None
|
|
67
|
+
session_cost: float | None = None
|
|
66
68
|
on_complete: RunCompletionCallback | None = None
|
|
67
69
|
status: RunStatus = "running"
|
|
68
70
|
error: str | None = None
|
|
@@ -97,7 +99,7 @@ class RunManager:
|
|
|
97
99
|
self._lock = asyncio.Lock()
|
|
98
100
|
self._active_by_session: dict[str, RunState] = {}
|
|
99
101
|
self._runs_by_id: dict[str, RunState] = {}
|
|
100
|
-
self._session_locks:
|
|
102
|
+
self._session_locks: WeakValueDictionary[str, asyncio.Lock] = WeakValueDictionary()
|
|
101
103
|
|
|
102
104
|
async def start_run(
|
|
103
105
|
self,
|
|
@@ -123,6 +125,7 @@ class RunManager:
|
|
|
123
125
|
session_id: str,
|
|
124
126
|
base_messages: list[ConversationMessage],
|
|
125
127
|
agent: RunAgent,
|
|
128
|
+
session_cost_base: float | None = None,
|
|
126
129
|
on_complete: RunCompletionCallback | None = None,
|
|
127
130
|
) -> dict[str, Any]:
|
|
128
131
|
return await self._start(
|
|
@@ -131,6 +134,7 @@ class RunManager:
|
|
|
131
134
|
user_message=None,
|
|
132
135
|
base_messages=base_messages,
|
|
133
136
|
agent=agent,
|
|
137
|
+
session_cost_base=session_cost_base,
|
|
134
138
|
on_complete=on_complete,
|
|
135
139
|
)
|
|
136
140
|
|
|
@@ -158,6 +162,7 @@ class RunManager:
|
|
|
158
162
|
kind=kind,
|
|
159
163
|
user_message=copy.deepcopy(user_message),
|
|
160
164
|
session_cost_base=session_cost_base,
|
|
165
|
+
session_cost=session_cost_base,
|
|
161
166
|
base_messages=copy.deepcopy(base_messages),
|
|
162
167
|
agent=agent,
|
|
163
168
|
on_complete=on_complete,
|
|
@@ -182,6 +187,8 @@ class RunManager:
|
|
|
182
187
|
return None
|
|
183
188
|
|
|
184
189
|
async with state.condition:
|
|
190
|
+
if state.status != "running":
|
|
191
|
+
return None
|
|
185
192
|
messages = copy.deepcopy(state.base_messages)
|
|
186
193
|
if state.user_message is not None:
|
|
187
194
|
messages.append(copy.deepcopy(state.user_message))
|
|
@@ -189,10 +196,12 @@ class RunManager:
|
|
|
189
196
|
"run": state.info(),
|
|
190
197
|
"messages": messages,
|
|
191
198
|
"pending_events": list(state.events),
|
|
199
|
+
"session_cost": state.session_cost,
|
|
192
200
|
}
|
|
193
201
|
|
|
194
202
|
@asynccontextmanager
|
|
195
203
|
async def session_operation(self, session_id: str) -> AsyncGenerator[None]:
|
|
204
|
+
# Keep a strong reference while waiting and holding the session lock.
|
|
196
205
|
async with self._lock:
|
|
197
206
|
lock = self._session_locks.get(session_id)
|
|
198
207
|
if lock is None:
|
|
@@ -217,18 +226,21 @@ class RunManager:
|
|
|
217
226
|
last_seq = max(0, after)
|
|
218
227
|
while True:
|
|
219
228
|
async with state.condition:
|
|
220
|
-
pending = [
|
|
229
|
+
pending = []
|
|
230
|
+
for event in reversed(state.events):
|
|
231
|
+
if event["seq"] <= last_seq:
|
|
232
|
+
break
|
|
233
|
+
pending.append(event)
|
|
234
|
+
pending.reverse()
|
|
221
235
|
finished = state.status != "running"
|
|
222
236
|
|
|
223
237
|
if not pending and not finished:
|
|
224
|
-
|
|
225
|
-
with suppress(TimeoutError):
|
|
226
|
-
await asyncio.wait_for(state.condition.wait(), timeout=0.5)
|
|
238
|
+
await state.condition.wait()
|
|
227
239
|
continue
|
|
228
240
|
|
|
229
241
|
for payload in pending:
|
|
230
242
|
yield payload
|
|
231
|
-
last_seq =
|
|
243
|
+
last_seq = payload["seq"]
|
|
232
244
|
|
|
233
245
|
if finished:
|
|
234
246
|
break
|
|
@@ -237,14 +249,35 @@ class RunManager:
|
|
|
237
249
|
state = await self.get_run(run_id)
|
|
238
250
|
if not state:
|
|
239
251
|
return None
|
|
252
|
+
self._request_cancel(state)
|
|
253
|
+
if state.task is not None:
|
|
254
|
+
# Request cancellation must not interrupt the run's own cleanup.
|
|
255
|
+
await asyncio.shield(state.task)
|
|
256
|
+
return state.info()
|
|
257
|
+
|
|
258
|
+
def _request_cancel(self, state: RunState) -> None:
|
|
259
|
+
# Repeated task.cancel() can interrupt a tool's cancellation cleanup.
|
|
260
|
+
if state.cancel_requested or state.status != "running":
|
|
261
|
+
return
|
|
240
262
|
state.cancel_requested = True
|
|
241
263
|
state.agent.cancel()
|
|
242
264
|
for fut in state.pending_decisions.values():
|
|
243
265
|
if not fut.done():
|
|
244
266
|
fut.cancel()
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
267
|
+
|
|
268
|
+
async def aclose(self) -> None:
|
|
269
|
+
"""Cancel and await owned runs after the application stops handling requests."""
|
|
270
|
+
|
|
271
|
+
async with self._lock:
|
|
272
|
+
tasks = []
|
|
273
|
+
for state in self._runs_by_id.values():
|
|
274
|
+
if state.task is not None and not state.task.done():
|
|
275
|
+
self._request_cancel(state)
|
|
276
|
+
tasks.append(state.task)
|
|
277
|
+
await asyncio.gather(*tasks)
|
|
278
|
+
self._runs_by_id.clear()
|
|
279
|
+
self._active_by_session.clear()
|
|
280
|
+
self._session_locks.clear()
|
|
248
281
|
|
|
249
282
|
async def has_active_run(self, session_id: str) -> bool:
|
|
250
283
|
async with self._lock:
|
|
@@ -287,8 +320,7 @@ class RunManager:
|
|
|
287
320
|
try:
|
|
288
321
|
decision = await future
|
|
289
322
|
if decision == "deny":
|
|
290
|
-
state
|
|
291
|
-
state.agent.cancel()
|
|
323
|
+
self._request_cancel(state)
|
|
292
324
|
return decision
|
|
293
325
|
except asyncio.CancelledError:
|
|
294
326
|
# Treat cancellation as deny so the agent loop unwinds via its own cancel check.
|
|
@@ -323,15 +355,20 @@ class RunManager:
|
|
|
323
355
|
async def _run(self, state: RunState) -> None:
|
|
324
356
|
"""Run the agent and store streamed events."""
|
|
325
357
|
|
|
358
|
+
# Agent entrypoints reset their cancellation flags for a new turn.
|
|
359
|
+
if state.cancel_requested:
|
|
360
|
+
await self._finish_run(state, status="cancelled")
|
|
361
|
+
return
|
|
362
|
+
|
|
326
363
|
last_error: str | None = None
|
|
327
364
|
|
|
328
365
|
try:
|
|
329
366
|
if state.kind == "compact":
|
|
330
367
|
# The agent persists the compact marker before this returns.
|
|
331
|
-
await state.agent.acompact()
|
|
368
|
+
marker = await state.agent.acompact()
|
|
332
369
|
if state.on_complete is not None:
|
|
333
370
|
await state.on_complete(state.session_id)
|
|
334
|
-
await self._append_event(state, Event("compact", {}))
|
|
371
|
+
await self._append_event(state, Event("compact", {}), compact_cost=sum_session_cost([marker]))
|
|
335
372
|
else:
|
|
336
373
|
assert state.user_message is not None
|
|
337
374
|
async for event in state.agent.achat(state.user_message):
|
|
@@ -339,22 +376,6 @@ class RunManager:
|
|
|
339
376
|
continue
|
|
340
377
|
if event.type == "error":
|
|
341
378
|
last_error = str(event.data.get("message") or "unknown error")
|
|
342
|
-
elif event.type == "usage":
|
|
343
|
-
turn_cost = event.data.get("turn_cost")
|
|
344
|
-
turn_total = turn_cost.get("total") if isinstance(turn_cost, dict) else None
|
|
345
|
-
session_cost = sum_known_costs(
|
|
346
|
-
state.session_cost_base,
|
|
347
|
-
turn_total,
|
|
348
|
-
)
|
|
349
|
-
event = Event(
|
|
350
|
-
"usage",
|
|
351
|
-
{
|
|
352
|
-
**event.data,
|
|
353
|
-
"context_window": state.agent.context_window,
|
|
354
|
-
"model": state.agent.model,
|
|
355
|
-
"session_cost": session_cost,
|
|
356
|
-
},
|
|
357
|
-
)
|
|
358
379
|
await self._append_event(state, event)
|
|
359
380
|
except asyncio.CancelledError:
|
|
360
381
|
# BaseException, not caught by ``except Exception`` — without this
|
|
@@ -377,8 +398,24 @@ class RunManager:
|
|
|
377
398
|
|
|
378
399
|
await self._finish_run(state, status="completed")
|
|
379
400
|
|
|
380
|
-
async def _append_event(self, state: RunState, event: Event) -> None:
|
|
401
|
+
async def _append_event(self, state: RunState, event: Event, *, compact_cost: float | None = None) -> None:
|
|
381
402
|
async with state.condition:
|
|
403
|
+
if event.type == "usage":
|
|
404
|
+
turn_cost = event.data.get("turn_cost")
|
|
405
|
+
turn_total = turn_cost.get("total") if isinstance(turn_cost, dict) else None
|
|
406
|
+
state.session_cost = sum_known_costs(state.session_cost_base, turn_total)
|
|
407
|
+
event = Event(
|
|
408
|
+
"usage",
|
|
409
|
+
{
|
|
410
|
+
**event.data,
|
|
411
|
+
"context_window": state.agent.context_window,
|
|
412
|
+
"model": state.agent.model,
|
|
413
|
+
"session_cost": state.session_cost,
|
|
414
|
+
},
|
|
415
|
+
)
|
|
416
|
+
elif event.type == "compact" and state.kind == "compact":
|
|
417
|
+
state.session_cost = sum_known_costs(state.session_cost_base, compact_cost)
|
|
418
|
+
|
|
382
419
|
if event.type == "tool_done":
|
|
383
420
|
tool_use_id = event.data.get("tool_use_id")
|
|
384
421
|
retained = [
|
mycode_cli/sessions.py
CHANGED
|
@@ -23,12 +23,14 @@ import asyncio
|
|
|
23
23
|
import json
|
|
24
24
|
import os
|
|
25
25
|
import shutil
|
|
26
|
+
from collections.abc import Iterable
|
|
26
27
|
from datetime import UTC, datetime
|
|
27
28
|
from pathlib import Path
|
|
28
29
|
from typing import TypedDict
|
|
29
30
|
|
|
30
31
|
from mycode.messages import ConversationMessage
|
|
31
32
|
from mycode.session import SessionStore as TimelineStore
|
|
33
|
+
from mycode.session import apply_rewind
|
|
32
34
|
|
|
33
35
|
DEFAULT_SESSION_TITLE = "New chat"
|
|
34
36
|
_META_KEYS = ("cwd", "title", "created_at", "updated_at")
|
|
@@ -39,6 +41,23 @@ SessionMetaDict = dict[str, object]
|
|
|
39
41
|
class SessionData(TypedDict):
|
|
40
42
|
session: SessionMetaDict
|
|
41
43
|
messages: list[ConversationMessage]
|
|
44
|
+
session_cost: float | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def sum_session_cost(messages: Iterable[ConversationMessage]) -> float | None:
|
|
48
|
+
"""Sum known request costs, including compact markers and rewound turns."""
|
|
49
|
+
|
|
50
|
+
total: float | None = None
|
|
51
|
+
for message in messages:
|
|
52
|
+
if message.get("role") not in {"assistant", "compact"}:
|
|
53
|
+
continue
|
|
54
|
+
cost = (message.get("meta") or {}).get("cost")
|
|
55
|
+
if not isinstance(cost, dict):
|
|
56
|
+
continue
|
|
57
|
+
request_total = cost.get("total")
|
|
58
|
+
if isinstance(request_total, int | float):
|
|
59
|
+
total = (total if total is not None else 0.0) + float(request_total)
|
|
60
|
+
return total
|
|
42
61
|
|
|
43
62
|
|
|
44
63
|
def _now() -> str:
|
|
@@ -185,16 +204,27 @@ class SessionStore(TimelineStore):
|
|
|
185
204
|
sessions = await self.list_sessions(cwd=cwd)
|
|
186
205
|
return sessions[0] if sessions else None
|
|
187
206
|
|
|
207
|
+
async def load_metadata(self, session_id: str) -> SessionMetaDict | None:
|
|
208
|
+
"""Load a catalog entry without reading its message timeline."""
|
|
209
|
+
|
|
210
|
+
def load() -> SessionMetaDict | None:
|
|
211
|
+
meta = self._read_meta(session_id)
|
|
212
|
+
return self._summary(session_id, meta) if meta is not None else None
|
|
213
|
+
|
|
214
|
+
return await asyncio.to_thread(load)
|
|
215
|
+
|
|
188
216
|
async def load_session(self, session_id: str) -> SessionData | None:
|
|
189
|
-
"""Load
|
|
217
|
+
"""Load metadata, visible messages, and cost from one raw timeline read."""
|
|
190
218
|
|
|
191
219
|
def load() -> SessionData | None:
|
|
192
220
|
meta = self._read_meta(session_id)
|
|
193
221
|
if meta is None:
|
|
194
222
|
return None
|
|
223
|
+
raw = self.load_raw_messages_sync(session_id)
|
|
195
224
|
return {
|
|
196
225
|
"session": self._summary(session_id, meta),
|
|
197
|
-
"messages":
|
|
226
|
+
"messages": apply_rewind(raw),
|
|
227
|
+
"session_cost": sum_session_cost(raw),
|
|
198
228
|
}
|
|
199
229
|
|
|
200
230
|
return await asyncio.to_thread(load)
|
mycode_cli/system_prompt.py
CHANGED
|
@@ -323,13 +323,14 @@ def discover_slash_skills(cwd: str) -> list[Skill]:
|
|
|
323
323
|
def build_skill_snapshot_blocks(text: str, cwd: str) -> list[ContentBlock]:
|
|
324
324
|
"""Expand standalone ``/<skill-name>`` references in first-use order."""
|
|
325
325
|
|
|
326
|
+
names = [token[1:] for token in text.split() if token.startswith("/")]
|
|
327
|
+
if not names:
|
|
328
|
+
return []
|
|
329
|
+
|
|
326
330
|
skills = {skill.name: skill for skill in discover_slash_skills(cwd)}
|
|
327
331
|
blocks: list[ContentBlock] = []
|
|
328
332
|
seen: set[str] = set()
|
|
329
|
-
for
|
|
330
|
-
if not token.startswith("/"):
|
|
331
|
-
continue
|
|
332
|
-
name = token[1:]
|
|
333
|
+
for name in names:
|
|
333
334
|
skill = skills.get(name)
|
|
334
335
|
if not skill or name in seen:
|
|
335
336
|
continue
|
mycode_cli/tools.py
CHANGED
|
@@ -20,6 +20,7 @@ from dataclasses import dataclass
|
|
|
20
20
|
from difflib import SequenceMatcher, unified_diff
|
|
21
21
|
from pathlib import Path
|
|
22
22
|
from typing import BinaryIO
|
|
23
|
+
from uuid import uuid4
|
|
23
24
|
|
|
24
25
|
from pydantic import BaseModel, ConfigDict, Field
|
|
25
26
|
|
|
@@ -162,16 +163,18 @@ def read_tool(
|
|
|
162
163
|
|
|
163
164
|
|
|
164
165
|
def _atomic_write_text(path: Path, content: str, *, newline: str | None = None) -> None:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
tmp.write_text(content, encoding="utf-8")
|
|
168
|
-
else:
|
|
169
|
-
normalized = content.replace("\r\n", "\n")
|
|
166
|
+
if newline is not None:
|
|
167
|
+
content = content.replace("\r\n", "\n")
|
|
170
168
|
if newline == "\r\n":
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
169
|
+
content = content.replace("\n", "\r\n")
|
|
170
|
+
tmp = path.with_name(f".{uuid4().hex}.tmp")
|
|
171
|
+
file = tmp.open("x", encoding="utf-8", newline=None if newline is None else "")
|
|
172
|
+
try:
|
|
173
|
+
with file:
|
|
174
|
+
file.write(content)
|
|
175
|
+
tmp.replace(path)
|
|
176
|
+
finally:
|
|
177
|
+
tmp.unlink(missing_ok=True)
|
|
175
178
|
|
|
176
179
|
|
|
177
180
|
@tool(
|
|
@@ -238,21 +241,21 @@ def _closest_line_hint(text: str, needle: str) -> str | None:
|
|
|
238
241
|
def _normalize_text(text: str) -> tuple[str, list[int]]:
|
|
239
242
|
"""Normalize text while preserving a map back to original offsets."""
|
|
240
243
|
|
|
241
|
-
|
|
244
|
+
parts: list[str] = []
|
|
242
245
|
imap: list[int] = []
|
|
243
246
|
pos = 0
|
|
244
247
|
for line in text.splitlines(keepends=True):
|
|
245
248
|
content = line.rstrip("\r\n")
|
|
246
249
|
trimmed = content.rstrip(" \t")
|
|
247
|
-
|
|
250
|
+
parts.append(trimmed)
|
|
248
251
|
imap.extend(range(pos, pos + len(trimmed)))
|
|
249
252
|
eol = line[len(content) :]
|
|
250
253
|
if eol:
|
|
251
|
-
|
|
254
|
+
parts.append("\n")
|
|
252
255
|
imap.append(pos + len(content))
|
|
253
256
|
pos += len(line)
|
|
254
257
|
|
|
255
|
-
return "".join(
|
|
258
|
+
return "".join(parts), imap
|
|
256
259
|
|
|
257
260
|
|
|
258
261
|
@tool(
|
|
@@ -362,10 +365,13 @@ def edit_tool(ctx: ToolContext[CliDeps], path: str, edits: list[EditEntry]) -> T
|
|
|
362
365
|
is_error=True,
|
|
363
366
|
)
|
|
364
367
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
for start, end, new_text, _ in
|
|
368
|
-
|
|
368
|
+
parts: list[str] = []
|
|
369
|
+
cursor = 0
|
|
370
|
+
for start, end, new_text, _ in matches:
|
|
371
|
+
parts.extend((text[cursor:start], new_text))
|
|
372
|
+
cursor = end
|
|
373
|
+
parts.append(text[cursor:])
|
|
374
|
+
updated = "".join(parts)
|
|
369
375
|
|
|
370
376
|
if updated == text:
|
|
371
377
|
return ToolExecutionResult(output="error: edits produced no changes", is_error=True)
|
|
@@ -570,10 +576,6 @@ class _BashOutputAccumulator:
|
|
|
570
576
|
|
|
571
577
|
def _trim_tail(self) -> None:
|
|
572
578
|
encoded = self.tail_text.encode("utf-8")
|
|
573
|
-
if len(encoded) <= DEFAULT_MAX_BYTES * 2:
|
|
574
|
-
self.tail_bytes = len(encoded)
|
|
575
|
-
return
|
|
576
|
-
|
|
577
579
|
start = len(encoded) - DEFAULT_MAX_BYTES * 2
|
|
578
580
|
while start < len(encoded) and encoded[start] & 0xC0 == 0x80:
|
|
579
581
|
start += 1
|
mycode_cli/tui/chat.py
CHANGED
|
@@ -6,7 +6,6 @@ import asyncio
|
|
|
6
6
|
import re
|
|
7
7
|
import shlex
|
|
8
8
|
from collections.abc import Iterable
|
|
9
|
-
from contextlib import suppress
|
|
10
9
|
from dataclasses import dataclass
|
|
11
10
|
from pathlib import Path
|
|
12
11
|
from typing import Any, override
|
|
@@ -62,19 +61,18 @@ from .theme import ERROR, ERROR_MARKER, MUTED, PROMPT_CHAR, TERMINAL_THEME, TOOL
|
|
|
62
61
|
|
|
63
62
|
_PROMPT = ANSI(f"\033[1m\033[34m{PROMPT_CHAR}\033[0m ")
|
|
64
63
|
|
|
65
|
-
# (command, help usage, description) — the completer offers `command`, help prints `usage`.
|
|
66
64
|
_COMMANDS = (
|
|
67
|
-
("/clear", "
|
|
68
|
-
("/compact", "
|
|
69
|
-
("/new", "
|
|
70
|
-
("/resume", "
|
|
71
|
-
("/rewind", "
|
|
72
|
-
("/provider", "
|
|
73
|
-
("/model", "
|
|
74
|
-
("/effort", "
|
|
75
|
-
("/q", "
|
|
65
|
+
("/clear", "Clear conversation"),
|
|
66
|
+
("/compact", "Compact conversation context"),
|
|
67
|
+
("/new", "New session"),
|
|
68
|
+
("/resume", "Switch session"),
|
|
69
|
+
("/rewind", "Rewind to a previous message"),
|
|
70
|
+
("/provider", "Switch provider"),
|
|
71
|
+
("/model", "Switch model"),
|
|
72
|
+
("/effort", "Set reasoning effort"),
|
|
73
|
+
("/q", "Quit"),
|
|
76
74
|
)
|
|
77
|
-
_SLASH_COMMANDS = tuple(command for command, _
|
|
75
|
+
_SLASH_COMMANDS = tuple(command for command, _ in _COMMANDS)
|
|
78
76
|
# Only treat `@path` as a reference when it starts a standalone token.
|
|
79
77
|
_AT_PATH_RE = re.compile(r"""(?<!\S)@(?:'(?P<single>[^']*)'?$|"(?P<double>[^"]*)"?$|(?P<plain>[^\s'"]*))$""")
|
|
80
78
|
_SKILL_TOKEN_RE = re.compile(r"(?<!\S)/(?P<name>[a-zA-Z0-9_-]*)$")
|
|
@@ -151,7 +149,7 @@ class _PromptCompleter(Completer):
|
|
|
151
149
|
|
|
152
150
|
text = text_before_cursor.lstrip()
|
|
153
151
|
if re.fullmatch(r"/\S*", text):
|
|
154
|
-
for cmd,
|
|
152
|
+
for cmd, desc in _COMMANDS:
|
|
155
153
|
if cmd.startswith(text):
|
|
156
154
|
yield Completion(cmd, start_position=-len(text), display_meta=desc)
|
|
157
155
|
|
|
@@ -195,10 +193,9 @@ class _PromptCompleter(Completer):
|
|
|
195
193
|
|
|
196
194
|
if not base_dir.is_dir():
|
|
197
195
|
return
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
candidate = f"{base_prefix}{entry.name}{'/' if entry.is_dir() else ''}"
|
|
196
|
+
entries = [(entry, entry.is_dir()) for entry in base_dir.iterdir() if entry.name.startswith(partial)]
|
|
197
|
+
for entry, is_dir in sorted(entries, key=lambda item: (not item[1], item[0].name.lower())):
|
|
198
|
+
candidate = f"{base_prefix}{entry.name}{'/' if is_dir else ''}"
|
|
202
199
|
if quote:
|
|
203
200
|
replacement = f"@{quote}{candidate}{quote}"
|
|
204
201
|
elif any(ch.isspace() for ch in candidate):
|
|
@@ -209,7 +206,7 @@ class _PromptCompleter(Completer):
|
|
|
209
206
|
replacement,
|
|
210
207
|
start_position=-len(match.group(0)),
|
|
211
208
|
display="@" + candidate,
|
|
212
|
-
display_meta="dir" if
|
|
209
|
+
display_meta="dir" if is_dir else "file",
|
|
213
210
|
)
|
|
214
211
|
|
|
215
212
|
|
|
@@ -332,7 +329,6 @@ class ProviderOption:
|
|
|
332
329
|
name: str
|
|
333
330
|
provider: str
|
|
334
331
|
models: tuple[str, ...]
|
|
335
|
-
api_base: str | None
|
|
336
332
|
|
|
337
333
|
|
|
338
334
|
def clone_agent(agent: Agent, *, store: SessionStore, session_id: str, cwd: str) -> Agent:
|
|
@@ -376,7 +372,6 @@ def list_provider_options(settings: Settings) -> list[ProviderOption]:
|
|
|
376
372
|
name=name,
|
|
377
373
|
provider=config.type,
|
|
378
374
|
models=tuple(config.models),
|
|
379
|
-
api_base=config.base_url,
|
|
380
375
|
)
|
|
381
376
|
)
|
|
382
377
|
if provider_is_available(config):
|
|
@@ -390,7 +385,6 @@ def list_provider_options(settings: Settings) -> list[ProviderOption]:
|
|
|
390
385
|
name=provider_name,
|
|
391
386
|
provider=provider_name,
|
|
392
387
|
models=provider_default_models(provider_name),
|
|
393
|
-
api_base=None,
|
|
394
388
|
)
|
|
395
389
|
)
|
|
396
390
|
|
|
@@ -548,11 +542,10 @@ class TerminalChat:
|
|
|
548
542
|
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
549
543
|
self.agent.cancel()
|
|
550
544
|
renderer.cancel()
|
|
551
|
-
#
|
|
545
|
+
# Uncancel the task so the loop can continue after Ctrl+C.
|
|
552
546
|
task = asyncio.current_task()
|
|
553
547
|
if task is not None:
|
|
554
|
-
|
|
555
|
-
task.uncancel()
|
|
548
|
+
task.uncancel()
|
|
556
549
|
finally:
|
|
557
550
|
self._current_renderer = None
|
|
558
551
|
|
|
@@ -655,14 +648,6 @@ class TerminalChat:
|
|
|
655
648
|
|
|
656
649
|
return True
|
|
657
650
|
|
|
658
|
-
def _print_help(self) -> None:
|
|
659
|
-
self.view.console.print()
|
|
660
|
-
for _command, usage, desc in _COMMANDS:
|
|
661
|
-
line = Text()
|
|
662
|
-
line.append(f" {usage:<20}", style="bold")
|
|
663
|
-
line.append(desc, style=MUTED)
|
|
664
|
-
self.view.console.print(line)
|
|
665
|
-
|
|
666
651
|
def _print_runtime_status(self, action: str, value: str, *, changed: bool) -> None:
|
|
667
652
|
"""Print the result of a runtime-only change."""
|
|
668
653
|
|
|
@@ -690,11 +675,10 @@ class TerminalChat:
|
|
|
690
675
|
return
|
|
691
676
|
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
692
677
|
self.agent.cancel()
|
|
693
|
-
#
|
|
678
|
+
# Uncancel the task so the loop can continue after Ctrl+C.
|
|
694
679
|
task = asyncio.current_task()
|
|
695
680
|
if task is not None:
|
|
696
|
-
|
|
697
|
-
task.uncancel()
|
|
681
|
+
task.uncancel()
|
|
698
682
|
self.view.console.print(Text("cancelled", style=MUTED))
|
|
699
683
|
return
|
|
700
684
|
except Exception as exc:
|
|
@@ -815,7 +799,7 @@ class TerminalChat:
|
|
|
815
799
|
"""Prompt for a configured provider and apply it to the active agent."""
|
|
816
800
|
|
|
817
801
|
options = list_provider_options(self.settings)
|
|
818
|
-
current =
|
|
802
|
+
current = next((option for option in options if option.name == self.provider_name), None)
|
|
819
803
|
|
|
820
804
|
choices: list[tuple[str, str]] = []
|
|
821
805
|
for option in options:
|
|
@@ -919,10 +903,7 @@ class TerminalChat:
|
|
|
919
903
|
|
|
920
904
|
def _restore_effort(self) -> None:
|
|
921
905
|
saved = self.effort_preferences.get(self._effort_key())
|
|
922
|
-
if saved is None:
|
|
923
|
-
self.agent.reasoning_effort = None
|
|
924
|
-
return
|
|
925
|
-
if saved == "auto":
|
|
906
|
+
if saved is None or saved == "auto":
|
|
926
907
|
self.agent.reasoning_effort = None
|
|
927
908
|
return
|
|
928
909
|
if self.agent.supports_reasoning_effort and saved in self.reasoning_efforts:
|
mycode_cli/tui/render.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import time
|
|
6
|
+
from collections import deque
|
|
6
7
|
from datetime import datetime
|
|
7
8
|
from typing import Any, ClassVar, override
|
|
8
9
|
|
|
@@ -357,7 +358,7 @@ class ReplyRenderer:
|
|
|
357
358
|
self._context_window = context_window
|
|
358
359
|
self._session_cost_base = session_cost_base
|
|
359
360
|
self._live: Live | None = None
|
|
360
|
-
self._reasoning:
|
|
361
|
+
self._reasoning: deque[str] = deque(maxlen=31)
|
|
361
362
|
self._text: list[str] = []
|
|
362
363
|
self._text_started = False
|
|
363
364
|
# Reasoning & stats
|
|
@@ -719,7 +720,7 @@ class ReplyRenderer:
|
|
|
719
720
|
# Thinking in progress: show rolling preview of reasoning content.
|
|
720
721
|
# Join only the tail to avoid O(full_length) work on every frame.
|
|
721
722
|
if self._reasoning and not self._text:
|
|
722
|
-
tail = "".join(self._reasoning[-30:])
|
|
723
|
+
tail = "".join(list(self._reasoning)[-30:])
|
|
723
724
|
content = " ".join(tail.split())
|
|
724
725
|
if content:
|
|
725
726
|
preview = content[-80:].strip()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: mycode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.0
|
|
4
4
|
Summary: Minimal coding agent CLI and web UI built on mycode-sdk.
|
|
5
5
|
Project-URL: Homepage, https://github.com/legibet/mycode
|
|
6
6
|
Project-URL: Repository, https://github.com/legibet/mycode
|
|
@@ -25,7 +25,7 @@ Requires-Dist: beautifulsoup4>=4.15.0
|
|
|
25
25
|
Requires-Dist: fastapi>=0.139.0
|
|
26
26
|
Requires-Dist: httpx2>=2.10.0
|
|
27
27
|
Requires-Dist: markdownify>=1.2.3
|
|
28
|
-
Requires-Dist: mycode-sdk==0.
|
|
28
|
+
Requires-Dist: mycode-sdk==0.15.0
|
|
29
29
|
Requires-Dist: prompt-toolkit>=3.0.52
|
|
30
30
|
Requires-Dist: pydantic>=2.13.4
|
|
31
31
|
Requires-Dist: pyyaml>=6.0.3
|
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
mycode_cli/__init__.py,sha256=K9OoBd9T-1JdpfLnjTgRoVyv8gSnA4telBRzYSl23OE,231
|
|
2
|
-
mycode_cli/config.py,sha256=
|
|
3
|
-
mycode_cli/main.py,sha256=
|
|
4
|
-
mycode_cli/permissions.py,sha256=
|
|
5
|
-
mycode_cli/runtime.py,sha256=
|
|
6
|
-
mycode_cli/sessions.py,sha256=
|
|
7
|
-
mycode_cli/system_prompt.py,sha256=
|
|
8
|
-
mycode_cli/tools.py,sha256=
|
|
2
|
+
mycode_cli/config.py,sha256=B_kL9vijHM20JA0DrFdEN_SuBzgjozPeqtJE-PZjHJw,30274
|
|
3
|
+
mycode_cli/main.py,sha256=TMuD5HAH53F2XFtifd4lQeDx3RnLtmE_GsOz76fbgOI,11972
|
|
4
|
+
mycode_cli/permissions.py,sha256=GESSo6YxMLudE_WI_XtAHHih4sgREsIlNnXirkoR1G4,7765
|
|
5
|
+
mycode_cli/runtime.py,sha256=UG-ZQ3aGLC0j9LbcxK6bxG9DTA8wRo7xdjB3GkfcbMc,2702
|
|
6
|
+
mycode_cli/sessions.py,sha256=g97vpaTIZw5xhh1hKV0pz91496H5hBS3rMKhZ0_v5gw,8434
|
|
7
|
+
mycode_cli/system_prompt.py,sha256=S9ehciURi4kZgeKBOfXpUeuI1NRMKomekHdWMQDhIL8,12793
|
|
8
|
+
mycode_cli/tools.py,sha256=jwvQiu8U95qMj6KPI5qMJyBr73M6lX7qMOJhZWuL_hc,26476
|
|
9
9
|
mycode_cli/web_tools.py,sha256=vIcrPEeUs8VmdNec9ikpqh6NxudX_TXBla4RqRqSmQQ,17936
|
|
10
10
|
mycode_cli/workspace.py,sha256=n139AXmrJyfuErpfDViSzGeZE_VQz2Q3ixibmqO2NqI,961
|
|
11
11
|
mycode_cli/server/__init__.py,sha256=Bt8J0HJdkPdE2VQl7zjnEjofriUHksen5tHQ8agw7RM,32
|
|
12
|
-
mycode_cli/server/app.py,sha256=
|
|
13
|
-
mycode_cli/server/deps.py,sha256=
|
|
14
|
-
mycode_cli/server/run_manager.py,sha256=
|
|
12
|
+
mycode_cli/server/app.py,sha256=v1jdOv8qJ8FyQwd8dd8UMSvbUOIJ5i4ejjgT9M0ZC_Q,2687
|
|
13
|
+
mycode_cli/server/deps.py,sha256=MKwd89W0Vv954xThrbcOPyqEySCO5YJtP_WS91nUqpk,1050
|
|
14
|
+
mycode_cli/server/run_manager.py,sha256=qB7cxGnMtnR7O3qZh3-UAuAo9--FcpFosImcCe_unf8,16896
|
|
15
15
|
mycode_cli/server/schemas.py,sha256=ZXN2L1pBqDiGDDZmHgsLoPNUFWS_SNEOz_imkX-eUFU,4999
|
|
16
16
|
mycode_cli/server/routers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
17
|
-
mycode_cli/server/routers/chat.py,sha256=
|
|
18
|
-
mycode_cli/server/routers/sessions.py,sha256=
|
|
19
|
-
mycode_cli/server/routers/settings.py,sha256=
|
|
20
|
-
mycode_cli/server/routers/workspaces.py,sha256=
|
|
17
|
+
mycode_cli/server/routers/chat.py,sha256=6qj96sWjAgcNHqFa90JYsDy_Qrcw_xPCW6wiAhS0oaU,18406
|
|
18
|
+
mycode_cli/server/routers/sessions.py,sha256=hod6havxtTE81kv88cOi7PKlPHIkG1GnApQ49aoQMBo,3692
|
|
19
|
+
mycode_cli/server/routers/settings.py,sha256=9fqltbqpezH5LgZKw_7tjU2nRnwfHuU3gLFi4Poe2fU,7017
|
|
20
|
+
mycode_cli/server/routers/workspaces.py,sha256=lPm48yLWszazi0sOODj5alqcwrollKxOFf3NCA89hwc,5468
|
|
21
21
|
mycode_cli/server/static/favicon_slashes.svg,sha256=bxMiM7G7NR1zLWyUWnFBCa1FSu0AzFiDW2YY6DtcOWQ,504
|
|
22
22
|
mycode_cli/server/static/index.html,sha256=rxhif41gyfCseX02a8F085uwJrDF9tNxo9G4vqDhDVI,1265
|
|
23
23
|
mycode_cli/server/static/assets/EditDiff-DOjxGJl-.js,sha256=6y0JCwtOGrYKdmM0j0OT2VaE57J38IbLSiUIjc01XLU,251313
|
|
@@ -398,12 +398,12 @@ mycode_cli/server/static/fonts/OFL.txt,sha256=5ntCh7US9uFmOUyAp0oMbVNwk26XBuNd-d
|
|
|
398
398
|
mycode_cli/server/static/fonts/SourceCodePro-Variable.woff2,sha256=i3dKqlE3o470D0rJ0225pe7hUrL2ZYnf3IL_AH_IcTU,22044
|
|
399
399
|
mycode_cli/server/static/fonts/SourceSans3-Variable.woff2,sha256=ehmnAn4SUlfTEMbb14rjowteoeN5TWCxK7KCJ6ADv9o,28740
|
|
400
400
|
mycode_cli/tui/__init__.py,sha256=5sPlJS3c8HdNEBwcF0bUuj8f8brAD-_6MNmF4Q_1Edg,69
|
|
401
|
-
mycode_cli/tui/chat.py,sha256=
|
|
402
|
-
mycode_cli/tui/render.py,sha256=
|
|
401
|
+
mycode_cli/tui/chat.py,sha256=RwiWM0k53xC8T93yvIWuv5DImYUmvaQBIty4fBSpAlQ,35282
|
|
402
|
+
mycode_cli/tui/render.py,sha256=kdmYWts__qYllA_mfLkXYLDtgTIEcyo8ZRXWGJdINhQ,26226
|
|
403
403
|
mycode_cli/tui/state.py,sha256=vtG1NJQl6e7hI6ovlbyIsgckQiKbApnYPbRT0N0ybS0,1175
|
|
404
404
|
mycode_cli/tui/theme.py,sha256=HS3C1kSoIONrSaiSx22AWRLk1n6-T-Tn5iohCMewAg4,3455
|
|
405
|
-
mycode_cli-0.
|
|
406
|
-
mycode_cli-0.
|
|
407
|
-
mycode_cli-0.
|
|
408
|
-
mycode_cli-0.
|
|
409
|
-
mycode_cli-0.
|
|
405
|
+
mycode_cli-0.15.0.dist-info/METADATA,sha256=yn4sJVBEDJe2piB2t-XuIy7jMfgWp_cmusjpsn0GUAg,4907
|
|
406
|
+
mycode_cli-0.15.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
407
|
+
mycode_cli-0.15.0.dist-info/entry_points.txt,sha256=1R7YN8dxJUy5AMC4q2J5Ly9xuEom3ZbTaa8xXwxuZyk,48
|
|
408
|
+
mycode_cli-0.15.0.dist-info/licenses/LICENSE,sha256=DSE6T-B9WhrWypr6H7y2kTZzoF1tef5xf7veUyImQsM,1064
|
|
409
|
+
mycode_cli-0.15.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|