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/__init__.py +10 -0
- kittycode/__main__.py +3 -0
- kittycode/agent.py +125 -0
- kittycode/cli.py +367 -0
- kittycode/config.py +67 -0
- kittycode/context.py +170 -0
- kittycode/llm.py +325 -0
- kittycode/prompt.py +51 -0
- kittycode/session.py +66 -0
- kittycode/skills.py +125 -0
- kittycode/tools/__init__.py +27 -0
- kittycode/tools/agent.py +47 -0
- kittycode/tools/base.py +25 -0
- kittycode/tools/bash.py +100 -0
- kittycode/tools/edit.py +74 -0
- kittycode/tools/glob_tool.py +42 -0
- kittycode/tools/grep.py +70 -0
- kittycode/tools/read.py +49 -0
- kittycode/tools/write.py +37 -0
- kittycode-0.1.0.dist-info/METADATA +13 -0
- kittycode-0.1.0.dist-info/RECORD +24 -0
- kittycode-0.1.0.dist-info/WHEEL +4 -0
- kittycode-0.1.0.dist-info/entry_points.txt +2 -0
- kittycode-0.1.0.dist-info/licenses/LICENSE +21 -0
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]
|