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.
Files changed (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. 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