kittycode 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.
kittycode/context.py ADDED
@@ -0,0 +1,170 @@
1
+ """Multi-layer context compression."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from .llm import LLM
9
+
10
+
11
+ def _approx_tokens(text: str) -> int:
12
+ """Rough token count for mixed English and Chinese content."""
13
+ return len(text) // 3
14
+
15
+
16
+ def estimate_tokens(messages: list[dict]) -> int:
17
+ total = 0
18
+ for message in messages:
19
+ if message.get("content"):
20
+ total += _approx_tokens(message["content"])
21
+ if message.get("tool_calls"):
22
+ total += _approx_tokens(str(message["tool_calls"]))
23
+ return total
24
+
25
+
26
+ class ContextManager:
27
+ def __init__(self, max_tokens: int = 128_000):
28
+ self.max_tokens = max_tokens
29
+ self._snip_at = int(max_tokens * 0.50)
30
+ self._summarize_at = int(max_tokens * 0.70)
31
+ self._collapse_at = int(max_tokens * 0.90)
32
+
33
+ def maybe_compress(self, messages: list[dict], llm: LLM | None = None) -> bool:
34
+ """Apply compression layers as needed."""
35
+ current = estimate_tokens(messages)
36
+ compressed = False
37
+
38
+ if current > self._snip_at:
39
+ if self._snip_tool_outputs(messages):
40
+ compressed = True
41
+ current = estimate_tokens(messages)
42
+
43
+ if current > self._summarize_at and len(messages) > 10:
44
+ if self._summarize_old(messages, llm, keep_recent=8):
45
+ compressed = True
46
+ current = estimate_tokens(messages)
47
+
48
+ if current > self._collapse_at and len(messages) > 4:
49
+ self._hard_collapse(messages, llm)
50
+ compressed = True
51
+
52
+ return compressed
53
+
54
+ @staticmethod
55
+ def _snip_tool_outputs(messages: list[dict]) -> bool:
56
+ changed = False
57
+ for message in messages:
58
+ if message.get("role") != "tool":
59
+ continue
60
+ content = message.get("content", "")
61
+ if len(content) <= 1500:
62
+ continue
63
+ lines = content.splitlines()
64
+ if len(lines) <= 6:
65
+ continue
66
+ message["content"] = (
67
+ "\n".join(lines[:3])
68
+ + f"\n... ({len(lines)} lines, snipped to save context) ...\n"
69
+ + "\n".join(lines[-3:])
70
+ )
71
+ changed = True
72
+ return changed
73
+
74
+ def _summarize_old(
75
+ self,
76
+ messages: list[dict],
77
+ llm: LLM | None,
78
+ keep_recent: int = 8,
79
+ ) -> bool:
80
+ if len(messages) <= keep_recent:
81
+ return False
82
+
83
+ old_messages = messages[:-keep_recent]
84
+ recent_messages = messages[-keep_recent:]
85
+ summary = self._get_summary(old_messages, llm)
86
+
87
+ messages.clear()
88
+ messages.append(
89
+ {
90
+ "role": "user",
91
+ "content": f"[Context compressed - conversation summary]\n{summary}",
92
+ }
93
+ )
94
+ messages.append(
95
+ {
96
+ "role": "assistant",
97
+ "content": "Got it, I have the context from our earlier conversation.",
98
+ }
99
+ )
100
+ messages.extend(recent_messages)
101
+ return True
102
+
103
+ def _hard_collapse(self, messages: list[dict], llm: LLM | None):
104
+ tail = messages[-4:] if len(messages) > 4 else messages[-2:]
105
+ summary = self._get_summary(messages[:-len(tail)], llm)
106
+
107
+ messages.clear()
108
+ messages.append({"role": "user", "content": f"[Hard context reset]\n{summary}"})
109
+ messages.append(
110
+ {"role": "assistant", "content": "Context restored. Continuing from where we left off."}
111
+ )
112
+ messages.extend(tail)
113
+
114
+ def _get_summary(self, messages: list[dict], llm: LLM | None) -> str:
115
+ flat = self._flatten(messages)
116
+
117
+ if llm:
118
+ try:
119
+ response = llm.chat(
120
+ messages=[
121
+ {
122
+ "role": "system",
123
+ "content": (
124
+ "Compress this conversation into a brief summary. "
125
+ "Preserve: file paths edited, key decisions made, "
126
+ "errors encountered, current task state. "
127
+ "Drop: verbose command output, code listings, "
128
+ "redundant back-and-forth."
129
+ ),
130
+ },
131
+ {"role": "user", "content": flat[:15000]},
132
+ ],
133
+ )
134
+ return response.content
135
+ except Exception:
136
+ pass
137
+
138
+ return self._extract_key_info(messages)
139
+
140
+ @staticmethod
141
+ def _flatten(messages: list[dict]) -> str:
142
+ parts = []
143
+ for message in messages:
144
+ role = message.get("role", "?")
145
+ text = message.get("content", "") or ""
146
+ if text:
147
+ parts.append(f"[{role}] {text[:400]}")
148
+ return "\n".join(parts)
149
+
150
+ @staticmethod
151
+ def _extract_key_info(messages: list[dict]) -> str:
152
+ import re
153
+
154
+ files_seen = set()
155
+ errors = []
156
+
157
+ for message in messages:
158
+ text = message.get("content", "") or ""
159
+ for match in re.finditer(r"[\w./\-]+\.\w{1,5}", text):
160
+ files_seen.add(match.group())
161
+ for line in text.splitlines():
162
+ if "error" in line.lower():
163
+ errors.append(line.strip()[:150])
164
+
165
+ parts = []
166
+ if files_seen:
167
+ parts.append(f"Files touched: {', '.join(sorted(files_seen)[:20])}")
168
+ if errors:
169
+ parts.append(f"Errors seen: {'; '.join(errors[:5])}")
170
+ return "\n".join(parts) or "(no extractable context)"
kittycode/llm.py ADDED
@@ -0,0 +1,325 @@
1
+ """LLM provider layer for OpenAI-compatible and Anthropic APIs."""
2
+
3
+ import json
4
+ import time
5
+ from dataclasses import dataclass, field
6
+
7
+ import anthropic
8
+ from anthropic import Anthropic
9
+ from openai import APIConnectionError, APIError, APITimeoutError, OpenAI, RateLimitError
10
+
11
+
12
+ @dataclass
13
+ class ToolCall:
14
+ id: str
15
+ name: str
16
+ arguments: dict
17
+
18
+
19
+ @dataclass
20
+ class LLMResponse:
21
+ content: str = ""
22
+ tool_calls: list[ToolCall] = field(default_factory=list)
23
+ prompt_tokens: int = 0
24
+ completion_tokens: int = 0
25
+
26
+ @property
27
+ def message(self) -> dict:
28
+ message: dict = {"role": "assistant", "content": self.content or None}
29
+ if self.tool_calls:
30
+ message["tool_calls"] = [
31
+ {
32
+ "id": tool_call.id,
33
+ "type": "function",
34
+ "function": {
35
+ "name": tool_call.name,
36
+ "arguments": json.dumps(tool_call.arguments),
37
+ },
38
+ }
39
+ for tool_call in self.tool_calls
40
+ ]
41
+ return message
42
+
43
+
44
+ class LLM:
45
+ def __init__(
46
+ self,
47
+ model: str,
48
+ api_key: str,
49
+ interface: str = "openai",
50
+ base_url: str | None = None,
51
+ **kwargs,
52
+ ):
53
+ self.model = model
54
+ self.interface = interface
55
+ if interface == "anthropic":
56
+ self.client = Anthropic(api_key=api_key, base_url=base_url)
57
+ else:
58
+ self.client = OpenAI(api_key=api_key, base_url=base_url)
59
+ self.extra = kwargs
60
+ self.total_prompt_tokens = 0
61
+ self.total_completion_tokens = 0
62
+
63
+ def chat(
64
+ self,
65
+ messages: list[dict],
66
+ tools: list[dict] | None = None,
67
+ on_token=None,
68
+ ) -> LLMResponse:
69
+ if self.interface == "anthropic":
70
+ return self._chat_anthropic(messages, tools=tools, on_token=on_token)
71
+
72
+ return self._chat_openai(messages, tools=tools, on_token=on_token)
73
+
74
+ def _chat_openai(
75
+ self,
76
+ messages: list[dict],
77
+ tools: list[dict] | None = None,
78
+ on_token=None,
79
+ ) -> LLMResponse:
80
+ params: dict = {
81
+ "model": self.model,
82
+ "messages": messages,
83
+ "stream": True,
84
+ **self.extra,
85
+ }
86
+ if tools:
87
+ params["tools"] = tools
88
+
89
+ try:
90
+ params["stream_options"] = {"include_usage": True}
91
+ stream = self._call_with_retry(params)
92
+ except Exception:
93
+ params.pop("stream_options", None)
94
+ stream = self._call_with_retry(params)
95
+
96
+ content_parts: list[str] = []
97
+ tool_call_map: dict[int, dict] = {}
98
+ prompt_tokens = 0
99
+ completion_tokens = 0
100
+
101
+ for chunk in stream:
102
+ if chunk.usage:
103
+ prompt_tokens = chunk.usage.prompt_tokens
104
+ completion_tokens = chunk.usage.completion_tokens
105
+
106
+ if not chunk.choices:
107
+ continue
108
+ delta = chunk.choices[0].delta
109
+
110
+ if delta.content:
111
+ content_parts.append(delta.content)
112
+ if on_token:
113
+ on_token(delta.content)
114
+
115
+ if delta.tool_calls:
116
+ for tool_call_delta in delta.tool_calls:
117
+ index = tool_call_delta.index
118
+ if index not in tool_call_map:
119
+ tool_call_map[index] = {"id": "", "name": "", "args": ""}
120
+ if tool_call_delta.id:
121
+ tool_call_map[index]["id"] = tool_call_delta.id
122
+ if tool_call_delta.function:
123
+ if tool_call_delta.function.name:
124
+ tool_call_map[index]["name"] = tool_call_delta.function.name
125
+ if tool_call_delta.function.arguments:
126
+ tool_call_map[index]["args"] += tool_call_delta.function.arguments
127
+
128
+ parsed_tool_calls: list[ToolCall] = []
129
+ for index in sorted(tool_call_map):
130
+ raw = tool_call_map[index]
131
+ try:
132
+ args = json.loads(raw["args"])
133
+ except (json.JSONDecodeError, KeyError):
134
+ args = {}
135
+ parsed_tool_calls.append(
136
+ ToolCall(id=raw["id"], name=raw["name"], arguments=args)
137
+ )
138
+
139
+ self.total_prompt_tokens += prompt_tokens
140
+ self.total_completion_tokens += completion_tokens
141
+
142
+ return LLMResponse(
143
+ content="".join(content_parts),
144
+ tool_calls=parsed_tool_calls,
145
+ prompt_tokens=prompt_tokens,
146
+ completion_tokens=completion_tokens,
147
+ )
148
+
149
+ def _chat_anthropic(
150
+ self,
151
+ messages: list[dict],
152
+ tools: list[dict] | None = None,
153
+ on_token=None,
154
+ ) -> LLMResponse:
155
+ system_message, conversation = _extract_system_message(messages)
156
+ params: dict = {
157
+ "model": self.model,
158
+ "messages": _to_anthropic_messages(conversation),
159
+ "max_tokens": int(self.extra.get("max_tokens", 4096)),
160
+ }
161
+
162
+ temperature = self.extra.get("temperature")
163
+ if temperature is not None:
164
+ params["temperature"] = temperature
165
+ if system_message:
166
+ params["system"] = system_message
167
+ if tools:
168
+ params["tools"] = _to_anthropic_tools(tools)
169
+
170
+ message = self._call_anthropic_with_retry(params)
171
+ response = _anthropic_message_to_response(message, on_token=on_token)
172
+ self.total_prompt_tokens += response.prompt_tokens
173
+ self.total_completion_tokens += response.completion_tokens
174
+ return response
175
+
176
+ def _call_with_retry(self, params: dict, max_retries: int = 3):
177
+ for attempt in range(max_retries):
178
+ try:
179
+ return self.client.chat.completions.create(**params)
180
+ except (RateLimitError, APITimeoutError, APIConnectionError):
181
+ if attempt == max_retries - 1:
182
+ raise
183
+ time.sleep(2 ** attempt)
184
+ except APIError as exc:
185
+ if exc.status_code and exc.status_code >= 500 and attempt < max_retries - 1:
186
+ time.sleep(2 ** attempt)
187
+ else:
188
+ raise
189
+
190
+ def _call_anthropic_with_retry(self, params: dict, max_retries: int = 3):
191
+ for attempt in range(max_retries):
192
+ try:
193
+ return self.client.messages.create(**params)
194
+ except (anthropic.RateLimitError, anthropic.APITimeoutError, anthropic.APIConnectionError):
195
+ if attempt == max_retries - 1:
196
+ raise
197
+ time.sleep(2 ** attempt)
198
+ except anthropic.APIError as exc:
199
+ status_code = getattr(exc, "status_code", None)
200
+ if status_code and status_code >= 500 and attempt < max_retries - 1:
201
+ time.sleep(2 ** attempt)
202
+ else:
203
+ raise
204
+
205
+
206
+ def _extract_system_message(messages: list[dict]) -> tuple[str, list[dict]]:
207
+ system_parts: list[str] = []
208
+ conversation: list[dict] = []
209
+
210
+ for message in messages:
211
+ if message.get("role") == "system":
212
+ content = message.get("content")
213
+ if content:
214
+ system_parts.append(content)
215
+ continue
216
+ conversation.append(message)
217
+
218
+ return "\n\n".join(system_parts), conversation
219
+
220
+
221
+ def _to_anthropic_tools(tools: list[dict]) -> list[dict]:
222
+ converted = []
223
+ for tool in tools:
224
+ function = tool.get("function", {})
225
+ converted.append(
226
+ {
227
+ "name": function.get("name", ""),
228
+ "description": function.get("description", ""),
229
+ "input_schema": function.get("parameters", {"type": "object", "properties": {}}),
230
+ }
231
+ )
232
+ return converted
233
+
234
+
235
+ def _to_anthropic_messages(messages: list[dict]) -> list[dict]:
236
+ converted: list[dict] = []
237
+ index = 0
238
+
239
+ while index < len(messages):
240
+ message = messages[index]
241
+ role = message.get("role")
242
+
243
+ if role == "tool":
244
+ tool_results = []
245
+ while index < len(messages) and messages[index].get("role") == "tool":
246
+ current = messages[index]
247
+ tool_results.append(
248
+ {
249
+ "type": "tool_result",
250
+ "tool_use_id": current.get("tool_call_id", ""),
251
+ "content": current.get("content", "") or "",
252
+ }
253
+ )
254
+ index += 1
255
+ converted.append({"role": "user", "content": tool_results})
256
+ continue
257
+
258
+ if role in {"user", "assistant"}:
259
+ converted.append({"role": role, "content": _to_anthropic_content(message)})
260
+
261
+ index += 1
262
+
263
+ return converted
264
+
265
+
266
+ def _to_anthropic_content(message: dict) -> list[dict]:
267
+ blocks: list[dict] = []
268
+
269
+ content = message.get("content")
270
+ if content:
271
+ blocks.append({"type": "text", "text": str(content)})
272
+
273
+ for tool_call in message.get("tool_calls", []) or []:
274
+ function = tool_call.get("function", {})
275
+ raw_args = function.get("arguments", {})
276
+ if isinstance(raw_args, str):
277
+ try:
278
+ parsed_args = json.loads(raw_args)
279
+ except json.JSONDecodeError:
280
+ parsed_args = {}
281
+ else:
282
+ parsed_args = raw_args or {}
283
+
284
+ blocks.append(
285
+ {
286
+ "type": "tool_use",
287
+ "id": tool_call.get("id", ""),
288
+ "name": function.get("name", ""),
289
+ "input": parsed_args,
290
+ }
291
+ )
292
+
293
+ return blocks or [{"type": "text", "text": ""}]
294
+
295
+
296
+ def _anthropic_message_to_response(message, on_token=None) -> LLMResponse:
297
+ content_parts: list[str] = []
298
+ tool_calls: list[ToolCall] = []
299
+
300
+ for block in getattr(message, "content", []) or []:
301
+ block_type = getattr(block, "type", None)
302
+ if block_type == "text":
303
+ text = getattr(block, "text", "") or ""
304
+ content_parts.append(text)
305
+ if text and on_token:
306
+ on_token(text)
307
+ elif block_type == "tool_use":
308
+ tool_calls.append(
309
+ ToolCall(
310
+ id=getattr(block, "id", ""),
311
+ name=getattr(block, "name", ""),
312
+ arguments=getattr(block, "input", {}) or {},
313
+ )
314
+ )
315
+
316
+ usage = getattr(message, "usage", None)
317
+ prompt_tokens = getattr(usage, "input_tokens", 0) if usage else 0
318
+ completion_tokens = getattr(usage, "output_tokens", 0) if usage else 0
319
+
320
+ return LLMResponse(
321
+ content="".join(content_parts),
322
+ tool_calls=tool_calls,
323
+ prompt_tokens=prompt_tokens,
324
+ completion_tokens=completion_tokens,
325
+ )
kittycode/prompt.py ADDED
@@ -0,0 +1,51 @@
1
+ """System prompt builder."""
2
+
3
+ import os
4
+ import platform
5
+
6
+
7
+ def system_prompt(tools, skills=None) -> str:
8
+ cwd = os.getcwd()
9
+ tool_list = "\n".join(f"- **{tool.name}**: {tool.description}" for tool in tools)
10
+ uname = platform.uname()
11
+ skill_block = _format_skill_block(skills or [])
12
+
13
+ return f"""\
14
+ {skill_block}
15
+
16
+ You are KittyCode, an AI coding assistant running in the user's terminal.
17
+ You help with software engineering: writing code, fixing bugs, refactoring, explaining code, running commands, and more.
18
+
19
+ # Environment
20
+ - Working directory: {cwd}
21
+ - OS: {uname.system} {uname.release} ({uname.machine})
22
+ - Python: {platform.python_version()}
23
+
24
+ # Tools
25
+ {tool_list}
26
+
27
+ # Rules
28
+ 1. Read before edit. Always read a file before modifying it.
29
+ 2. edit_file for small changes. Use edit_file for targeted edits; write_file only for new files or complete rewrites.
30
+ 3. Verify your work. After making changes, run relevant tests or commands to confirm correctness.
31
+ 4. Be concise. Show code over prose. Explain only what is necessary.
32
+ 5. One step at a time. For multi-step tasks, execute them sequentially.
33
+ 6. edit_file uniqueness. When using edit_file, include enough surrounding context in old_string to guarantee a unique match.
34
+ 7. Respect existing style. Match the project's coding conventions.
35
+ 8. Ask when unsure. If the request is ambiguous, ask for clarification rather than guessing.
36
+ """
37
+
38
+
39
+ def _format_skill_block(skills) -> str:
40
+ if not skills:
41
+ return "# Available Skills\n- None loaded from ~/.kittycode/skills"
42
+
43
+ lines = [
44
+ "# Available Skills",
45
+ "Use these local skills when relevant. If one looks useful, read its SKILL.md and any related files under the listed path before using it.",
46
+ ]
47
+ for skill in skills:
48
+ lines.append(f"- name: {skill.name}")
49
+ lines.append(f" description: {skill.description}")
50
+ lines.append(f" path: {skill.path}")
51
+ return "\n".join(lines)
kittycode/session.py ADDED
@@ -0,0 +1,66 @@
1
+ """Session persistence helpers."""
2
+
3
+ import json
4
+ import time
5
+ from pathlib import Path
6
+
7
+ SESSIONS_DIR = Path.home() / ".kittycode" / "sessions"
8
+
9
+
10
+ def save_session(messages: list[dict], model: str, session_id: str | None = None) -> str:
11
+ """Save conversation to disk and return the session ID."""
12
+ SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
13
+
14
+ if not session_id:
15
+ session_id = f"session_{int(time.time())}"
16
+
17
+ data = {
18
+ "id": session_id,
19
+ "model": model,
20
+ "saved_at": time.strftime("%Y-%m-%d %H:%M:%S"),
21
+ "messages": messages,
22
+ }
23
+
24
+ path = SESSIONS_DIR / f"{session_id}.json"
25
+ path.write_text(json.dumps(data, ensure_ascii=False, indent=2))
26
+ return session_id
27
+
28
+
29
+ def load_session(session_id: str) -> tuple[list[dict], str] | None:
30
+ """Load a saved session and return (messages, model)."""
31
+ path = SESSIONS_DIR / f"{session_id}.json"
32
+ if not path.exists():
33
+ return None
34
+
35
+ data = json.loads(path.read_text())
36
+ return data["messages"], data["model"]
37
+
38
+
39
+ def list_sessions() -> list[dict]:
40
+ """List available sessions, newest first."""
41
+ if not SESSIONS_DIR.exists():
42
+ return []
43
+
44
+ sessions = []
45
+ for path in sorted(SESSIONS_DIR.glob("*.json"), reverse=True):
46
+ try:
47
+ data = json.loads(path.read_text())
48
+ except (json.JSONDecodeError, KeyError):
49
+ continue
50
+
51
+ preview = ""
52
+ for message in data.get("messages", []):
53
+ if message.get("role") == "user" and message.get("content"):
54
+ preview = message["content"][:80]
55
+ break
56
+
57
+ sessions.append(
58
+ {
59
+ "id": data.get("id", path.stem),
60
+ "model": data.get("model", "?"),
61
+ "saved_at": data.get("saved_at", "?"),
62
+ "preview": preview,
63
+ }
64
+ )
65
+
66
+ return sessions[:20]