kolega-code 0.1.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.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Parsing and expansion of @path file mentions in CLI prompts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Tuple
|
|
9
|
+
|
|
10
|
+
MENTION_RE = re.compile(r'(?:(?<=\s)|^)@(?:"([^"]+)"|(\S+))')
|
|
11
|
+
|
|
12
|
+
TRAILING_PUNCTUATION = ",.;:!?)]}'\""
|
|
13
|
+
|
|
14
|
+
MAX_ATTACHMENT_LINES = 2000
|
|
15
|
+
MAX_ATTACHMENT_BYTES = 48 * 1024
|
|
16
|
+
MAX_DIR_ENTRIES = 200
|
|
17
|
+
MAX_MENTIONS_PER_MESSAGE = 20
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Mention:
|
|
22
|
+
raw: str # token text as typed, without the leading @
|
|
23
|
+
path: str # candidate path with trailing punctuation stripped
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def parse_mentions(text: str) -> List[Mention]:
|
|
27
|
+
"""Extract candidate @path tokens; purely syntactic, no filesystem checks."""
|
|
28
|
+
mentions: List[Mention] = []
|
|
29
|
+
for match in MENTION_RE.finditer(text):
|
|
30
|
+
quoted, bare = match.groups()
|
|
31
|
+
if quoted is not None:
|
|
32
|
+
mentions.append(Mention(raw=quoted, path=quoted))
|
|
33
|
+
continue
|
|
34
|
+
path = bare.rstrip(TRAILING_PUNCTUATION)
|
|
35
|
+
if path:
|
|
36
|
+
mentions.append(Mention(raw=bare, path=path))
|
|
37
|
+
return mentions
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def build_file_attachments(text: str, project_path: Path) -> Tuple[List[Dict[str, Any]], List[str]]:
|
|
41
|
+
"""Resolve @ mentions in ``text`` against ``project_path``.
|
|
42
|
+
|
|
43
|
+
Returns (attachments, unresolved) where attachments are
|
|
44
|
+
``{"type": "file", "path": rel, "content": str, "truncated": bool, "is_dir": bool}``
|
|
45
|
+
payloads and unresolved lists mention tokens that did not match an existing
|
|
46
|
+
path (left in the message as literal text).
|
|
47
|
+
"""
|
|
48
|
+
project_path = Path(project_path).resolve()
|
|
49
|
+
attachments: List[Dict[str, Any]] = []
|
|
50
|
+
unresolved: List[str] = []
|
|
51
|
+
seen: set[str] = set()
|
|
52
|
+
|
|
53
|
+
for mention in parse_mentions(text)[:MAX_MENTIONS_PER_MESSAGE]:
|
|
54
|
+
rel = _resolve_relative(mention.path, project_path)
|
|
55
|
+
if rel is None:
|
|
56
|
+
unresolved.append(mention.path)
|
|
57
|
+
continue
|
|
58
|
+
if rel in seen:
|
|
59
|
+
continue
|
|
60
|
+
seen.add(rel)
|
|
61
|
+
|
|
62
|
+
target = project_path / rel
|
|
63
|
+
if target.is_dir():
|
|
64
|
+
attachments.append(
|
|
65
|
+
{
|
|
66
|
+
"type": "file",
|
|
67
|
+
"path": rel,
|
|
68
|
+
"content": _directory_listing(target),
|
|
69
|
+
"truncated": False,
|
|
70
|
+
"is_dir": True,
|
|
71
|
+
}
|
|
72
|
+
)
|
|
73
|
+
else:
|
|
74
|
+
content, truncated = _read_file_content(target)
|
|
75
|
+
attachments.append(
|
|
76
|
+
{
|
|
77
|
+
"type": "file",
|
|
78
|
+
"path": rel,
|
|
79
|
+
"content": content,
|
|
80
|
+
"truncated": truncated,
|
|
81
|
+
"is_dir": False,
|
|
82
|
+
}
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
return attachments, unresolved
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _resolve_relative(candidate: str, project_path: Path) -> str | None:
|
|
89
|
+
"""Map a mention to a project-relative posix path, or None if invalid."""
|
|
90
|
+
raw = Path(candidate.rstrip("/")) if candidate not in ("", "/") else Path(".")
|
|
91
|
+
try:
|
|
92
|
+
if raw.is_absolute():
|
|
93
|
+
rel = raw.resolve().relative_to(project_path)
|
|
94
|
+
else:
|
|
95
|
+
rel = (project_path / raw).resolve().relative_to(project_path)
|
|
96
|
+
except (ValueError, OSError):
|
|
97
|
+
return None
|
|
98
|
+
target = project_path / rel
|
|
99
|
+
if not target.exists():
|
|
100
|
+
return None
|
|
101
|
+
return rel.as_posix()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _read_file_content(target: Path) -> Tuple[str, bool]:
|
|
105
|
+
if _looks_binary(target):
|
|
106
|
+
return "[binary file - content not attached]", False
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
raw = target.read_text(encoding="utf-8", errors="replace")
|
|
110
|
+
except OSError as exc:
|
|
111
|
+
return f"[could not read file: {exc}]", False
|
|
112
|
+
|
|
113
|
+
lines = raw.splitlines(keepends=True)
|
|
114
|
+
total_lines = len(lines)
|
|
115
|
+
truncated = False
|
|
116
|
+
|
|
117
|
+
if total_lines > MAX_ATTACHMENT_LINES:
|
|
118
|
+
lines = lines[:MAX_ATTACHMENT_LINES]
|
|
119
|
+
truncated = True
|
|
120
|
+
content = "".join(lines)
|
|
121
|
+
if len(content.encode("utf-8", errors="replace")) > MAX_ATTACHMENT_BYTES:
|
|
122
|
+
content = content.encode("utf-8", errors="replace")[:MAX_ATTACHMENT_BYTES].decode("utf-8", errors="replace")
|
|
123
|
+
truncated = True
|
|
124
|
+
|
|
125
|
+
if truncated:
|
|
126
|
+
shown_lines = content.count("\n") + (0 if content.endswith("\n") or not content else 1)
|
|
127
|
+
content += (
|
|
128
|
+
f"\n[truncated: showing first {shown_lines} of {total_lines} lines"
|
|
129
|
+
" - ask the agent to read specific sections for more]"
|
|
130
|
+
)
|
|
131
|
+
return content, truncated
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _looks_binary(target: Path) -> bool:
|
|
135
|
+
try:
|
|
136
|
+
with target.open("rb") as handle:
|
|
137
|
+
chunk = handle.read(8192)
|
|
138
|
+
except OSError:
|
|
139
|
+
return False
|
|
140
|
+
return b"\x00" in chunk
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _directory_listing(target: Path) -> str:
|
|
144
|
+
try:
|
|
145
|
+
children = sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))
|
|
146
|
+
except OSError as exc:
|
|
147
|
+
return f"[could not list directory: {exc}]"
|
|
148
|
+
|
|
149
|
+
names = []
|
|
150
|
+
for child in children[:MAX_DIR_ENTRIES]:
|
|
151
|
+
names.append(child.name + "/" if child.is_dir() else child.name)
|
|
152
|
+
listing = "\n".join(names) if names else "[empty directory]"
|
|
153
|
+
if len(children) > MAX_DIR_ENTRIES:
|
|
154
|
+
listing += f"\n[truncated: showing first {MAX_DIR_ENTRIES} of {len(children)} entries]"
|
|
155
|
+
return listing
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""User-facing microcopy for the Kolega Code CLI.
|
|
2
|
+
|
|
3
|
+
Single voice for every string the user reads: sentence case, full sentences
|
|
4
|
+
end with a period, in-progress states end with a single ellipsis character,
|
|
5
|
+
no exclamation marks.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
# Composer placeholders and modal prompts
|
|
11
|
+
COMPOSER_PLACEHOLDER = "Ask Kolega Code..."
|
|
12
|
+
PLAN_READY_PLACEHOLDER = "Plan ready. Choose Implement plan or Discuss further."
|
|
13
|
+
QUESTION_PLACEHOLDER = "Choose an option below or type a custom answer..."
|
|
14
|
+
|
|
15
|
+
# Durable transcript messages
|
|
16
|
+
THREAD_RESET_MESSAGE = "Thread reset. Previous messages were cleared."
|
|
17
|
+
TASK_LIST_EMPTY_MESSAGE = "No task list has been set."
|
|
18
|
+
PLAN_EMPTY_MESSAGE = "No plan captured yet."
|
|
19
|
+
|
|
20
|
+
# Turn progress
|
|
21
|
+
WORKING = "Working…"
|
|
22
|
+
GENERATING = "Generating…"
|
|
23
|
+
THINKING = "Thinking…"
|
|
24
|
+
READING_RESPONSE = "Reading model response…"
|
|
25
|
+
STOP_REQUESTED = "Stopping…"
|
|
26
|
+
FINISHED = "Finished."
|
|
27
|
+
STOPPED_BY_USER = "Stopped by user."
|
|
28
|
+
STOPPED_WITH_ERROR = "Stopped due to an error: {error}"
|
|
29
|
+
CANCEL_REQUESTED = "Cancellation requested."
|
|
30
|
+
WAITING_FOR_ANSWER = "Waiting for your answer…"
|
|
31
|
+
|
|
32
|
+
# Turn status strip finals
|
|
33
|
+
DONE_IN = "Done in {duration}"
|
|
34
|
+
STOPPED_AFTER = "Stopped after {duration}"
|
|
35
|
+
ERRORED_AFTER = "Errored after {duration}"
|
|
36
|
+
|
|
37
|
+
# Tool and sub-agent activity
|
|
38
|
+
RUNNING_TOOL = "Running {tool}…"
|
|
39
|
+
TOOL_DONE = "{tool} finished."
|
|
40
|
+
TOOL_FAILED = "{tool} failed."
|
|
41
|
+
RUNNING_TERMINAL_COMMAND = "Running terminal command…"
|
|
42
|
+
RUNNING_SUB_AGENT = "Running sub-agent {name} #{index}…"
|
|
43
|
+
RUNNING_SUB_AGENTS = "Running {count} sub-agents…"
|
|
44
|
+
|
|
45
|
+
# Confirmations
|
|
46
|
+
SWITCHED_MODE = "Switched to {mode} mode."
|
|
47
|
+
PLAN_CAPTURED = "Plan captured. Choose Implement plan or Discuss further."
|
|
48
|
+
PLAN_DISCUSSION_RESUMED = "Planning discussion resumed."
|
|
49
|
+
SKILL_ACTIVATED = "Activated skill {name}."
|
|
50
|
+
SKILLS_LISTED = "Listed agent skills."
|
|
51
|
+
|
|
52
|
+
# Mentions
|
|
53
|
+
MENTIONS_NOT_FOUND = "Not found, sent as plain text: {mentions}"
|
|
54
|
+
|
|
55
|
+
# Slash commands
|
|
56
|
+
MODEL_SWITCHED = "Switched model to {provider}/{model}."
|
|
57
|
+
MODEL_UNKNOWN = "Unknown model {model} for provider {provider}."
|
|
58
|
+
MODEL_SWITCH_HINT = "Switch with /model <name>."
|
|
59
|
+
COPY_LAST_RESPONSE = "Copied the last response to the clipboard."
|
|
60
|
+
COPY_NOTHING = "No response to copy yet."
|
|
61
|
+
VERSION_INFO = "Kolega Code version {version}."
|
|
62
|
+
|
|
63
|
+
# Blockers
|
|
64
|
+
BLOCK_STOP_BEFORE_RESET = "Stop the current turn before resetting the thread."
|
|
65
|
+
BLOCK_STOP_BEFORE_MODE_SWITCH = "Stop the current turn before switching modes."
|
|
66
|
+
BLOCK_STOP_BEFORE_SKILL = "Stop the current turn before activating a skill."
|
|
67
|
+
BLOCK_STOP_BEFORE_MODEL_SWITCH = "Stop the current turn before switching models."
|
|
68
|
+
BLOCK_PLAN_DECISION = "Choose Implement plan or Discuss further before sending another message."
|
|
69
|
+
BLOCK_PLAN_DECISION_MODE_SWITCH = "Choose Implement plan or Discuss further before switching modes."
|
|
70
|
+
BLOCK_PLAN_DECISION_SKILL = "Choose Implement plan or Discuss further before activating a skill."
|
|
71
|
+
BLOCK_PENDING_QUESTION_SKILL = "Answer the pending planning question before activating a skill."
|
|
72
|
+
SETTINGS_REQUIRED = "Save a provider, model, and API key before chatting."
|
|
73
|
+
SETTINGS_REQUIRED_SKILL = "Save a provider, model, and API key before activating a skill."
|
|
74
|
+
|
|
75
|
+
# Settings tab
|
|
76
|
+
SETTINGS_SAVED = "Settings saved."
|
|
77
|
+
SETTINGS_INCOMPLETE = "Configuration incomplete: {error}"
|
|
78
|
+
SETTINGS_ACTIVE_MODEL = "Active model: {provider}/{model}"
|
|
79
|
+
SETTINGS_API_KEY_LINE = "API key: {status}"
|
|
80
|
+
|
|
81
|
+
# Status dashboard
|
|
82
|
+
STATUS_TOKENS_UNKNOWN = "Token counts unavailable."
|
|
83
|
+
|
|
84
|
+
# Logs
|
|
85
|
+
LOG_IGNORED_EVENT = "Ignored non-display event: {event_type}"
|
|
86
|
+
|
|
87
|
+
# Misc
|
|
88
|
+
COPY_MACOS_FAILED = "Copied for supported terminals, but the macOS clipboard failed."
|
|
89
|
+
STREAM_TRUNCATED = "[stream truncated to the last {chars} characters]"
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Provider and model registry for the CLI settings UI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from kolega_code.config import ModelProvider
|
|
8
|
+
from kolega_code.llm.specs import get_model_specs
|
|
9
|
+
|
|
10
|
+
UI_DEFAULT_PROVIDER = ModelProvider.MOONSHOT.value
|
|
11
|
+
UI_DEFAULT_MODEL = "kimi-k2.6"
|
|
12
|
+
DEEPSEEK_DEFAULT_MODEL = "deepseek-v4-pro"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ModelOption:
|
|
17
|
+
provider: str
|
|
18
|
+
provider_label: str
|
|
19
|
+
model: str
|
|
20
|
+
model_label: str
|
|
21
|
+
api_key_env: str
|
|
22
|
+
context_length: int
|
|
23
|
+
max_completion_tokens: int
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _model_option(
|
|
27
|
+
provider: ModelProvider,
|
|
28
|
+
provider_label: str,
|
|
29
|
+
model: str,
|
|
30
|
+
model_label: str,
|
|
31
|
+
api_key_env: str,
|
|
32
|
+
) -> ModelOption:
|
|
33
|
+
specs = get_model_specs(provider, model)
|
|
34
|
+
return ModelOption(
|
|
35
|
+
provider=provider.value,
|
|
36
|
+
provider_label=provider_label,
|
|
37
|
+
model=model,
|
|
38
|
+
model_label=model_label,
|
|
39
|
+
api_key_env=api_key_env,
|
|
40
|
+
context_length=int(specs["context_length"]),
|
|
41
|
+
max_completion_tokens=int(specs["max_completion_tokens"]),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
UI_MODEL_OPTIONS = [
|
|
46
|
+
_model_option(
|
|
47
|
+
ModelProvider.MOONSHOT,
|
|
48
|
+
"Moonshot AI",
|
|
49
|
+
UI_DEFAULT_MODEL,
|
|
50
|
+
"Kimi K2.6",
|
|
51
|
+
"MOONSHOT_API_KEY",
|
|
52
|
+
),
|
|
53
|
+
_model_option(
|
|
54
|
+
ModelProvider.DEEPSEEK,
|
|
55
|
+
"DeepSeek AI",
|
|
56
|
+
DEEPSEEK_DEFAULT_MODEL,
|
|
57
|
+
"DeepSeek V4 Pro",
|
|
58
|
+
"DEEPSEEK_API_KEY",
|
|
59
|
+
),
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def ui_provider_options() -> list[tuple[str, str]]:
|
|
64
|
+
"""Return Textual Select options for supported UI providers."""
|
|
65
|
+
seen: set[str] = set()
|
|
66
|
+
options: list[tuple[str, str]] = []
|
|
67
|
+
for option in UI_MODEL_OPTIONS:
|
|
68
|
+
if option.provider in seen:
|
|
69
|
+
continue
|
|
70
|
+
seen.add(option.provider)
|
|
71
|
+
options.append((option.provider_label, option.provider))
|
|
72
|
+
return options
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def ui_model_options(provider: str) -> list[tuple[str, str]]:
|
|
76
|
+
"""Return Textual Select options for supported UI models."""
|
|
77
|
+
return [(option.model_label, option.model) for option in UI_MODEL_OPTIONS if option.provider == provider]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def get_ui_model(provider: str, model: str) -> ModelOption | None:
|
|
81
|
+
"""Return a supported UI model option."""
|
|
82
|
+
for option in UI_MODEL_OPTIONS:
|
|
83
|
+
if option.provider == provider and option.model == model:
|
|
84
|
+
return option
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def default_model_for_provider(provider: ModelProvider) -> str:
|
|
89
|
+
"""Return a usable default model for a provider when only the provider is selected."""
|
|
90
|
+
if provider == ModelProvider.MOONSHOT:
|
|
91
|
+
return UI_DEFAULT_MODEL
|
|
92
|
+
if provider == ModelProvider.DEEPSEEK:
|
|
93
|
+
return DEEPSEEK_DEFAULT_MODEL
|
|
94
|
+
if provider == ModelProvider.ANTHROPIC:
|
|
95
|
+
return "claude-opus-4-7"
|
|
96
|
+
raise ValueError(f"No default CLI model is registered for provider '{provider.value}'.")
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Local resumable session storage for the Kolega Code CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import uuid
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Iterable, Optional
|
|
13
|
+
|
|
14
|
+
SCHEMA_VERSION = 1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SessionStoreError(RuntimeError):
|
|
18
|
+
"""Raised when a CLI session cannot be loaded or saved."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SessionRecord:
|
|
23
|
+
session_id: str
|
|
24
|
+
project_path: str
|
|
25
|
+
workspace_id: str
|
|
26
|
+
thread_id: str
|
|
27
|
+
mode: str
|
|
28
|
+
title: str
|
|
29
|
+
created_at: str
|
|
30
|
+
updated_at: str
|
|
31
|
+
config: dict[str, Any] = field(default_factory=dict)
|
|
32
|
+
history: list[dict[str, Any]] = field(default_factory=list)
|
|
33
|
+
task_list_markdown: str = ""
|
|
34
|
+
latest_plan_markdown: str = ""
|
|
35
|
+
interaction_mode: str = "build"
|
|
36
|
+
schema_version: int = SCHEMA_VERSION
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def create(
|
|
40
|
+
cls,
|
|
41
|
+
project_path: Path,
|
|
42
|
+
mode: str,
|
|
43
|
+
config: dict[str, Any],
|
|
44
|
+
session_id: Optional[str] = None,
|
|
45
|
+
title: Optional[str] = None,
|
|
46
|
+
) -> "SessionRecord":
|
|
47
|
+
now = _now()
|
|
48
|
+
resolved_project = str(project_path.resolve())
|
|
49
|
+
session_id = session_id or uuid.uuid4().hex
|
|
50
|
+
return cls(
|
|
51
|
+
schema_version=SCHEMA_VERSION,
|
|
52
|
+
session_id=session_id,
|
|
53
|
+
project_path=resolved_project,
|
|
54
|
+
workspace_id=f"cli-{uuid.uuid4().hex}",
|
|
55
|
+
thread_id=uuid.uuid4().hex,
|
|
56
|
+
mode=mode,
|
|
57
|
+
title=title or Path(resolved_project).name or "Kolega Code",
|
|
58
|
+
created_at=now,
|
|
59
|
+
updated_at=now,
|
|
60
|
+
config=config,
|
|
61
|
+
history=[],
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
@classmethod
|
|
65
|
+
def from_dict(cls, data: dict[str, Any]) -> "SessionRecord":
|
|
66
|
+
if data.get("schema_version") != SCHEMA_VERSION:
|
|
67
|
+
raise SessionStoreError(f"Unsupported session schema version: {data.get('schema_version')}")
|
|
68
|
+
return cls(
|
|
69
|
+
schema_version=data["schema_version"],
|
|
70
|
+
session_id=data["session_id"],
|
|
71
|
+
project_path=data["project_path"],
|
|
72
|
+
workspace_id=data["workspace_id"],
|
|
73
|
+
thread_id=data["thread_id"],
|
|
74
|
+
mode=data["mode"],
|
|
75
|
+
title=data.get("title") or "Kolega Code",
|
|
76
|
+
created_at=data["created_at"],
|
|
77
|
+
updated_at=data["updated_at"],
|
|
78
|
+
config=data.get("config") or {},
|
|
79
|
+
history=data.get("history") or [],
|
|
80
|
+
task_list_markdown=data.get("task_list_markdown") or "",
|
|
81
|
+
latest_plan_markdown=data.get("latest_plan_markdown") or "",
|
|
82
|
+
interaction_mode=data.get("interaction_mode") or "build",
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
def to_dict(self) -> dict[str, Any]:
|
|
86
|
+
return {
|
|
87
|
+
"schema_version": self.schema_version,
|
|
88
|
+
"session_id": self.session_id,
|
|
89
|
+
"project_path": self.project_path,
|
|
90
|
+
"workspace_id": self.workspace_id,
|
|
91
|
+
"thread_id": self.thread_id,
|
|
92
|
+
"mode": self.mode,
|
|
93
|
+
"title": self.title,
|
|
94
|
+
"created_at": self.created_at,
|
|
95
|
+
"updated_at": self.updated_at,
|
|
96
|
+
"config": self.config,
|
|
97
|
+
"history": self.history,
|
|
98
|
+
"task_list_markdown": self.task_list_markdown,
|
|
99
|
+
"latest_plan_markdown": self.latest_plan_markdown,
|
|
100
|
+
"interaction_mode": self.interaction_mode,
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _now() -> str:
|
|
105
|
+
return datetime.now(timezone.utc).isoformat()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def default_state_dir(env: Optional[dict[str, str]] = None) -> Path:
|
|
109
|
+
env = env or os.environ
|
|
110
|
+
if env.get("KOLEGA_CODE_STATE_DIR"):
|
|
111
|
+
return Path(env["KOLEGA_CODE_STATE_DIR"]).expanduser()
|
|
112
|
+
|
|
113
|
+
if sys.platform == "darwin":
|
|
114
|
+
return Path.home() / "Library" / "Application Support" / "kolega-code"
|
|
115
|
+
if sys.platform.startswith("win"):
|
|
116
|
+
base = Path(env.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
117
|
+
return base / "kolega-code"
|
|
118
|
+
return Path(env.get("XDG_STATE_HOME", Path.home() / ".local" / "state")) / "kolega-code"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SessionStore:
|
|
122
|
+
"""Filesystem-backed session store."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, root: Optional[Path] = None) -> None:
|
|
125
|
+
self.root = (root or default_state_dir()).expanduser()
|
|
126
|
+
self.sessions_dir = self.root / "sessions"
|
|
127
|
+
|
|
128
|
+
def ensure_dirs(self) -> None:
|
|
129
|
+
self.sessions_dir.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
|
|
131
|
+
def path_for(self, session_id: str) -> Path:
|
|
132
|
+
return self.sessions_dir / f"{session_id}.json"
|
|
133
|
+
|
|
134
|
+
def create(
|
|
135
|
+
self,
|
|
136
|
+
project_path: Path,
|
|
137
|
+
mode: str,
|
|
138
|
+
config: dict[str, Any],
|
|
139
|
+
session_id: Optional[str] = None,
|
|
140
|
+
title: Optional[str] = None,
|
|
141
|
+
) -> SessionRecord:
|
|
142
|
+
record = SessionRecord.create(project_path, mode, config, session_id=session_id, title=title)
|
|
143
|
+
self.save(record)
|
|
144
|
+
return record
|
|
145
|
+
|
|
146
|
+
def save(self, record: SessionRecord) -> None:
|
|
147
|
+
self.ensure_dirs()
|
|
148
|
+
record.updated_at = _now()
|
|
149
|
+
payload = json.dumps(record.to_dict(), indent=2, sort_keys=True)
|
|
150
|
+
target = self.path_for(record.session_id)
|
|
151
|
+
temp = target.with_suffix(".json.tmp")
|
|
152
|
+
temp.write_text(payload + "\n", encoding="utf-8")
|
|
153
|
+
temp.replace(target)
|
|
154
|
+
|
|
155
|
+
def load(self, session_id: str) -> SessionRecord:
|
|
156
|
+
path = self.path_for(session_id)
|
|
157
|
+
if not path.exists():
|
|
158
|
+
raise SessionStoreError(f"Session not found: {session_id}")
|
|
159
|
+
try:
|
|
160
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
161
|
+
except json.JSONDecodeError as exc:
|
|
162
|
+
raise SessionStoreError(f"Session file is not valid JSON: {path}") from exc
|
|
163
|
+
return SessionRecord.from_dict(data)
|
|
164
|
+
|
|
165
|
+
def load_by_thread_id(self, thread_id: str) -> SessionRecord:
|
|
166
|
+
for record in self._iter_records():
|
|
167
|
+
if record.thread_id == thread_id:
|
|
168
|
+
return record
|
|
169
|
+
raise SessionStoreError(f"Thread not found: {thread_id}")
|
|
170
|
+
|
|
171
|
+
def load_session_or_thread(self, identifier: str) -> SessionRecord:
|
|
172
|
+
path = self.path_for(identifier)
|
|
173
|
+
if path.exists():
|
|
174
|
+
return self.load(identifier)
|
|
175
|
+
return self.load_by_thread_id(identifier)
|
|
176
|
+
|
|
177
|
+
def delete(self, session_id: str) -> None:
|
|
178
|
+
path = self.path_for(session_id)
|
|
179
|
+
if not path.exists():
|
|
180
|
+
raise SessionStoreError(f"Session not found: {session_id}")
|
|
181
|
+
path.unlink()
|
|
182
|
+
|
|
183
|
+
def list(self, project_path: Optional[Path] = None) -> list[SessionRecord]:
|
|
184
|
+
records = list(self._iter_records())
|
|
185
|
+
if project_path is not None:
|
|
186
|
+
resolved = str(project_path.resolve())
|
|
187
|
+
records = [record for record in records if record.project_path == resolved]
|
|
188
|
+
return sorted(records, key=lambda record: record.updated_at, reverse=True)
|
|
189
|
+
|
|
190
|
+
def latest_for_project(self, project_path: Path) -> Optional[SessionRecord]:
|
|
191
|
+
records = self.list(project_path=project_path)
|
|
192
|
+
return records[0] if records else None
|
|
193
|
+
|
|
194
|
+
def export(self, session_id: str) -> str:
|
|
195
|
+
return json.dumps(self.load(session_id).to_dict(), indent=2, sort_keys=True) + "\n"
|
|
196
|
+
|
|
197
|
+
def _iter_records(self) -> Iterable[SessionRecord]:
|
|
198
|
+
if not self.sessions_dir.exists():
|
|
199
|
+
return []
|
|
200
|
+
|
|
201
|
+
records = []
|
|
202
|
+
for path in self.sessions_dir.glob("*.json"):
|
|
203
|
+
try:
|
|
204
|
+
records.append(SessionRecord.from_dict(json.loads(path.read_text(encoding="utf-8"))))
|
|
205
|
+
except Exception:
|
|
206
|
+
continue
|
|
207
|
+
return records
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Persistent CLI settings for provider/model selection and API keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from .session_store import default_state_dir
|
|
12
|
+
|
|
13
|
+
SETTINGS_SCHEMA_VERSION = 1
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SettingsStoreError(RuntimeError):
|
|
17
|
+
"""Raised when CLI settings cannot be loaded or saved."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class CliSettings:
|
|
22
|
+
active_provider: Optional[str] = None
|
|
23
|
+
active_model: Optional[str] = None
|
|
24
|
+
api_keys: dict[str, str] = field(default_factory=dict)
|
|
25
|
+
schema_version: int = SETTINGS_SCHEMA_VERSION
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_dict(cls, data: dict) -> "CliSettings":
|
|
29
|
+
if data.get("schema_version") != SETTINGS_SCHEMA_VERSION:
|
|
30
|
+
raise SettingsStoreError(f"Unsupported settings schema version: {data.get('schema_version')}")
|
|
31
|
+
api_keys = data.get("api_keys") or {}
|
|
32
|
+
return cls(
|
|
33
|
+
schema_version=data["schema_version"],
|
|
34
|
+
active_provider=data.get("active_provider"),
|
|
35
|
+
active_model=data.get("active_model"),
|
|
36
|
+
api_keys={str(provider): str(key) for provider, key in api_keys.items() if key},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def to_dict(self) -> dict:
|
|
40
|
+
return {
|
|
41
|
+
"schema_version": self.schema_version,
|
|
42
|
+
"active_provider": self.active_provider,
|
|
43
|
+
"active_model": self.active_model,
|
|
44
|
+
"api_keys": self.api_keys,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
def get_api_key(self, provider: str) -> Optional[str]:
|
|
48
|
+
return self.api_keys.get(provider)
|
|
49
|
+
|
|
50
|
+
def set_api_key(self, provider: str, api_key: str) -> None:
|
|
51
|
+
if api_key:
|
|
52
|
+
self.api_keys[provider] = api_key
|
|
53
|
+
|
|
54
|
+
def has_api_key(self, provider: str) -> bool:
|
|
55
|
+
return bool(self.get_api_key(provider))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SettingsStore:
|
|
59
|
+
"""Filesystem-backed CLI settings store."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, root: Optional[Path] = None) -> None:
|
|
62
|
+
self.root = (root or default_state_dir()).expanduser()
|
|
63
|
+
self.path = self.root / "settings.json"
|
|
64
|
+
|
|
65
|
+
def load(self) -> CliSettings:
|
|
66
|
+
if not self.path.exists():
|
|
67
|
+
return CliSettings()
|
|
68
|
+
try:
|
|
69
|
+
return CliSettings.from_dict(json.loads(self.path.read_text(encoding="utf-8")))
|
|
70
|
+
except json.JSONDecodeError as exc:
|
|
71
|
+
raise SettingsStoreError(f"Settings file is not valid JSON: {self.path}") from exc
|
|
72
|
+
|
|
73
|
+
def save(self, settings: CliSettings) -> None:
|
|
74
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
payload = json.dumps(settings.to_dict(), indent=2, sort_keys=True)
|
|
76
|
+
temp = self.path.with_suffix(".json.tmp")
|
|
77
|
+
temp.write_text(payload + "\n", encoding="utf-8")
|
|
78
|
+
_chmod_private(temp)
|
|
79
|
+
temp.replace(self.path)
|
|
80
|
+
_chmod_private(self.path)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _chmod_private(path: Path) -> None:
|
|
84
|
+
try:
|
|
85
|
+
os.chmod(path, 0o600)
|
|
86
|
+
except OSError:
|
|
87
|
+
pass
|