crabcode 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.
- crabcode-0.1.0.dist-info/METADATA +23 -0
- crabcode-0.1.0.dist-info/RECORD +64 -0
- crabcode-0.1.0.dist-info/WHEEL +4 -0
- crabcode-0.1.0.dist-info/entry_points.txt +2 -0
- crabcode_cli/__init__.py +3 -0
- crabcode_cli/app.py +142 -0
- crabcode_cli/banner.py +42 -0
- crabcode_cli/pipe.py +58 -0
- crabcode_cli/render/__init__.py +0 -0
- crabcode_cli/render/messages.py +19 -0
- crabcode_cli/repl.py +1263 -0
- crabcode_core/__init__.py +3 -0
- crabcode_core/api/__init__.py +6 -0
- crabcode_core/api/anthropic_adapter.py +250 -0
- crabcode_core/api/base.py +66 -0
- crabcode_core/api/codex_adapter.py +390 -0
- crabcode_core/api/openai_adapter.py +221 -0
- crabcode_core/api/registry.py +40 -0
- crabcode_core/compact/__init__.py +7 -0
- crabcode_core/compact/compact.py +150 -0
- crabcode_core/config/__init__.py +3 -0
- crabcode_core/config/claudemd.py +53 -0
- crabcode_core/config/manager.py +152 -0
- crabcode_core/events.py +392 -0
- crabcode_core/mcp/__init__.py +4 -0
- crabcode_core/mcp/client.py +163 -0
- crabcode_core/mcp/config.py +52 -0
- crabcode_core/permissions/__init__.py +3 -0
- crabcode_core/permissions/manager.py +106 -0
- crabcode_core/prompts/__init__.py +0 -0
- crabcode_core/prompts/context.py +209 -0
- crabcode_core/prompts/profile.py +66 -0
- crabcode_core/prompts/system.py +405 -0
- crabcode_core/prompts/templates.py +60 -0
- crabcode_core/query/__init__.py +0 -0
- crabcode_core/query/loop.py +442 -0
- crabcode_core/session/__init__.py +3 -0
- crabcode_core/session/storage.py +153 -0
- crabcode_core/skills/__init__.py +5 -0
- crabcode_core/skills/loader.py +147 -0
- crabcode_core/tools/__init__.py +29 -0
- crabcode_core/tools/agent.py +160 -0
- crabcode_core/tools/bash.py +127 -0
- crabcode_core/tools/diff_utils.py +120 -0
- crabcode_core/tools/file_edit.py +145 -0
- crabcode_core/tools/file_read.py +123 -0
- crabcode_core/tools/file_write.py +102 -0
- crabcode_core/tools/glob.py +109 -0
- crabcode_core/tools/grep.py +130 -0
- crabcode_core/tools/lint.py +368 -0
- crabcode_core/tools/memory.py +236 -0
- crabcode_core/tools/skill.py +112 -0
- crabcode_core/types/__init__.py +73 -0
- crabcode_core/types/config.py +103 -0
- crabcode_core/types/event.py +105 -0
- crabcode_core/types/message.py +177 -0
- crabcode_core/types/tool.py +138 -0
- crabcode_search/__init__.py +11 -0
- crabcode_search/background.py +371 -0
- crabcode_search/chunker.py +517 -0
- crabcode_search/embedder.py +447 -0
- crabcode_search/indexer.py +212 -0
- crabcode_search/store.py +222 -0
- crabcode_search/tool.py +232 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
"""Query loop — the agentic turn loop, heart of the system.
|
|
2
|
+
|
|
3
|
+
Ported from src/query.ts. Sends messages to the API, processes tool calls,
|
|
4
|
+
and loops until the model stops using tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, AsyncGenerator
|
|
13
|
+
|
|
14
|
+
from crabcode_core.api.base import APIAdapter, ModelConfig, StreamChunk
|
|
15
|
+
from crabcode_core.types.event import (
|
|
16
|
+
CompactEvent,
|
|
17
|
+
CoreEvent,
|
|
18
|
+
ErrorEvent,
|
|
19
|
+
PermissionRequestEvent,
|
|
20
|
+
StreamModeEvent,
|
|
21
|
+
StreamTextEvent,
|
|
22
|
+
ThinkingEvent,
|
|
23
|
+
ToolResultEvent,
|
|
24
|
+
ToolUseEvent,
|
|
25
|
+
TurnCompleteEvent,
|
|
26
|
+
)
|
|
27
|
+
from crabcode_core.types.message import (
|
|
28
|
+
AssistantMessage,
|
|
29
|
+
ContentBlock,
|
|
30
|
+
Message,
|
|
31
|
+
TextBlock,
|
|
32
|
+
ThinkingBlock,
|
|
33
|
+
ToolUseBlock,
|
|
34
|
+
create_assistant_message,
|
|
35
|
+
create_tool_result_message,
|
|
36
|
+
create_user_message,
|
|
37
|
+
)
|
|
38
|
+
from crabcode_core.types.tool import PermissionBehavior, Tool, ToolContext, ToolResult
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class QueryParams:
|
|
43
|
+
messages: list[Message]
|
|
44
|
+
system_prompt: list[str]
|
|
45
|
+
user_context: dict[str, str]
|
|
46
|
+
system_context: dict[str, str]
|
|
47
|
+
tools: list[Tool]
|
|
48
|
+
tool_context: ToolContext
|
|
49
|
+
api_adapter: APIAdapter
|
|
50
|
+
max_turns: int = 0
|
|
51
|
+
permission_manager: Any = None
|
|
52
|
+
permission_queue: asyncio.Queue | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _append_system_context(
|
|
56
|
+
system_prompt: list[str],
|
|
57
|
+
context: dict[str, str],
|
|
58
|
+
) -> list[str]:
|
|
59
|
+
"""Append system context entries to the end of system prompt."""
|
|
60
|
+
if not context:
|
|
61
|
+
return system_prompt
|
|
62
|
+
extra = "\n".join(f"{k}: {v}" for k, v in context.items() if v)
|
|
63
|
+
if extra:
|
|
64
|
+
return [*system_prompt, extra]
|
|
65
|
+
return system_prompt
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _prepend_user_context(
|
|
69
|
+
messages: list[Message],
|
|
70
|
+
user_context: dict[str, str],
|
|
71
|
+
) -> list[Message]:
|
|
72
|
+
"""Prepend user context as a meta user message at the start."""
|
|
73
|
+
if not user_context:
|
|
74
|
+
return messages
|
|
75
|
+
|
|
76
|
+
ctx_parts = []
|
|
77
|
+
for key, value in user_context.items():
|
|
78
|
+
ctx_parts.append(f"# {key}\n{value}")
|
|
79
|
+
ctx_text = "\n".join(ctx_parts)
|
|
80
|
+
|
|
81
|
+
meta_msg = create_user_message(
|
|
82
|
+
content=(
|
|
83
|
+
f"<system-reminder>\n"
|
|
84
|
+
f"As you answer the user's questions, you can use the following context:\n"
|
|
85
|
+
f"{ctx_text}\n\n"
|
|
86
|
+
f"IMPORTANT: this context may or may not be relevant to your tasks. "
|
|
87
|
+
f"You should not respond to this context unless it is highly relevant "
|
|
88
|
+
f"to your task.\n</system-reminder>\n"
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return [meta_msg, *messages]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _find_tool(tools: list[Tool], name: str) -> Tool | None:
|
|
96
|
+
for tool in tools:
|
|
97
|
+
if tool.name == name:
|
|
98
|
+
return tool
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _run_tools(
|
|
103
|
+
tool_use_blocks: list[ToolUseBlock],
|
|
104
|
+
assistant_msg: AssistantMessage,
|
|
105
|
+
tools: list[Tool],
|
|
106
|
+
context: ToolContext,
|
|
107
|
+
) -> list[tuple[Message, CoreEvent]]:
|
|
108
|
+
"""Execute tool calls and return (result_message, event) pairs.
|
|
109
|
+
|
|
110
|
+
Runs concurrency-safe tools in parallel, others sequentially.
|
|
111
|
+
"""
|
|
112
|
+
import asyncio
|
|
113
|
+
|
|
114
|
+
results: list[tuple[Message, CoreEvent]] = []
|
|
115
|
+
|
|
116
|
+
safe: list[ToolUseBlock] = []
|
|
117
|
+
unsafe: list[ToolUseBlock] = []
|
|
118
|
+
for block in tool_use_blocks:
|
|
119
|
+
tool = _find_tool(tools, block.name)
|
|
120
|
+
if tool and tool.is_concurrency_safe:
|
|
121
|
+
safe.append(block)
|
|
122
|
+
else:
|
|
123
|
+
unsafe.append(block)
|
|
124
|
+
|
|
125
|
+
async def execute_one(block: ToolUseBlock) -> tuple[Message, CoreEvent]:
|
|
126
|
+
tool = _find_tool(tools, block.name)
|
|
127
|
+
if not tool:
|
|
128
|
+
msg = create_tool_result_message(
|
|
129
|
+
tool_use_id=block.id,
|
|
130
|
+
result=f"Error: unknown tool '{block.name}'",
|
|
131
|
+
is_error=True,
|
|
132
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
133
|
+
)
|
|
134
|
+
event = ToolResultEvent(
|
|
135
|
+
tool_use_id=block.id,
|
|
136
|
+
tool_name=block.name,
|
|
137
|
+
result=f"Error: unknown tool '{block.name}'",
|
|
138
|
+
is_error=True,
|
|
139
|
+
)
|
|
140
|
+
return msg, event
|
|
141
|
+
|
|
142
|
+
validation_error = await tool.validate_input(block.input)
|
|
143
|
+
if validation_error:
|
|
144
|
+
msg = create_tool_result_message(
|
|
145
|
+
tool_use_id=block.id,
|
|
146
|
+
result=f"Validation error: {validation_error}",
|
|
147
|
+
is_error=True,
|
|
148
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
149
|
+
)
|
|
150
|
+
event = ToolResultEvent(
|
|
151
|
+
tool_use_id=block.id,
|
|
152
|
+
tool_name=block.name,
|
|
153
|
+
result=f"Validation error: {validation_error}",
|
|
154
|
+
is_error=True,
|
|
155
|
+
)
|
|
156
|
+
return msg, event
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
result = await tool.call(block.input, context)
|
|
160
|
+
except Exception as e:
|
|
161
|
+
result = ToolResult(
|
|
162
|
+
result_for_model=f"Error executing tool: {e}",
|
|
163
|
+
is_error=True,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
msg = create_tool_result_message(
|
|
167
|
+
tool_use_id=block.id,
|
|
168
|
+
result=result.result_for_model,
|
|
169
|
+
is_error=result.is_error,
|
|
170
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
171
|
+
)
|
|
172
|
+
event = ToolResultEvent(
|
|
173
|
+
tool_use_id=block.id,
|
|
174
|
+
tool_name=block.name,
|
|
175
|
+
result=result.result_for_model,
|
|
176
|
+
is_error=result.is_error,
|
|
177
|
+
result_for_display=result.result_for_display,
|
|
178
|
+
)
|
|
179
|
+
return msg, event
|
|
180
|
+
|
|
181
|
+
if safe:
|
|
182
|
+
safe_results = await asyncio.gather(*(execute_one(b) for b in safe))
|
|
183
|
+
results.extend(safe_results)
|
|
184
|
+
|
|
185
|
+
for block in unsafe:
|
|
186
|
+
result = await execute_one(block)
|
|
187
|
+
results.append(result)
|
|
188
|
+
|
|
189
|
+
return results
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def query_loop(
|
|
193
|
+
params: QueryParams,
|
|
194
|
+
) -> AsyncGenerator[CoreEvent, None]:
|
|
195
|
+
"""The main agentic turn loop.
|
|
196
|
+
|
|
197
|
+
Sends messages to the API, processes streaming response, executes
|
|
198
|
+
tool calls, and loops until no more tool calls are made.
|
|
199
|
+
"""
|
|
200
|
+
messages = list(params.messages)
|
|
201
|
+
turn_count = 0
|
|
202
|
+
total_usage: dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
|
|
203
|
+
|
|
204
|
+
while True:
|
|
205
|
+
turn_count += 1
|
|
206
|
+
|
|
207
|
+
full_system = _append_system_context(
|
|
208
|
+
params.system_prompt, params.system_context
|
|
209
|
+
)
|
|
210
|
+
messages_for_api = _prepend_user_context(messages, params.user_context)
|
|
211
|
+
|
|
212
|
+
tool_schemas = [t.to_api_schema() for t in params.tools if t.is_enabled]
|
|
213
|
+
|
|
214
|
+
model_config = ModelConfig(
|
|
215
|
+
model=params.api_adapter.config.model if hasattr(params.api_adapter, 'config') else "claude-sonnet-4-20250514",
|
|
216
|
+
max_tokens=16384,
|
|
217
|
+
timeout=getattr(params.api_adapter.config, 'timeout', 300) if hasattr(params.api_adapter, 'config') else 300,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
assistant_content: list[ContentBlock] = []
|
|
221
|
+
tool_use_blocks: list[ToolUseBlock] = []
|
|
222
|
+
current_text = ""
|
|
223
|
+
current_thinking = ""
|
|
224
|
+
current_tool: dict[str, str] = {}
|
|
225
|
+
emitted_mode: str = ""
|
|
226
|
+
|
|
227
|
+
def _flush_thinking_block() -> None:
|
|
228
|
+
nonlocal current_thinking
|
|
229
|
+
if not current_thinking:
|
|
230
|
+
return
|
|
231
|
+
assistant_content.append(ThinkingBlock(thinking=current_thinking))
|
|
232
|
+
current_thinking = ""
|
|
233
|
+
|
|
234
|
+
yield StreamModeEvent(mode="requesting")
|
|
235
|
+
|
|
236
|
+
try:
|
|
237
|
+
# Wrap stream with timeout to avoid hanging indefinitely
|
|
238
|
+
async def _stream_with_timeout():
|
|
239
|
+
async for chunk in params.api_adapter.stream_message(
|
|
240
|
+
messages=messages_for_api,
|
|
241
|
+
system=full_system,
|
|
242
|
+
tools=tool_schemas,
|
|
243
|
+
config=model_config,
|
|
244
|
+
):
|
|
245
|
+
yield chunk
|
|
246
|
+
|
|
247
|
+
stream = _stream_with_timeout()
|
|
248
|
+
chunk = None
|
|
249
|
+
while True:
|
|
250
|
+
try:
|
|
251
|
+
chunk = await asyncio.wait_for(
|
|
252
|
+
stream.__anext__(),
|
|
253
|
+
timeout=model_config.timeout,
|
|
254
|
+
)
|
|
255
|
+
except StopAsyncIteration:
|
|
256
|
+
break
|
|
257
|
+
except asyncio.TimeoutError:
|
|
258
|
+
yield ErrorEvent(
|
|
259
|
+
message=f"API request timed out after {model_config.timeout}s",
|
|
260
|
+
recoverable=True,
|
|
261
|
+
)
|
|
262
|
+
return
|
|
263
|
+
if chunk.type == "text":
|
|
264
|
+
_flush_thinking_block()
|
|
265
|
+
if emitted_mode != "responding":
|
|
266
|
+
yield StreamModeEvent(mode="responding")
|
|
267
|
+
emitted_mode = "responding"
|
|
268
|
+
yield StreamTextEvent(text=chunk.text)
|
|
269
|
+
current_text += chunk.text
|
|
270
|
+
|
|
271
|
+
elif chunk.type == "thinking":
|
|
272
|
+
if emitted_mode != "thinking":
|
|
273
|
+
yield StreamModeEvent(mode="thinking")
|
|
274
|
+
emitted_mode = "thinking"
|
|
275
|
+
yield ThinkingEvent(text=chunk.text)
|
|
276
|
+
current_thinking += chunk.text
|
|
277
|
+
|
|
278
|
+
elif chunk.type == "tool_use_start":
|
|
279
|
+
_flush_thinking_block()
|
|
280
|
+
if current_text:
|
|
281
|
+
assistant_content.append(TextBlock(text=current_text))
|
|
282
|
+
current_text = ""
|
|
283
|
+
if emitted_mode != "tool-input":
|
|
284
|
+
yield StreamModeEvent(mode="tool-input")
|
|
285
|
+
emitted_mode = "tool-input"
|
|
286
|
+
current_tool = {
|
|
287
|
+
"id": chunk.tool_use_id,
|
|
288
|
+
"name": chunk.tool_name,
|
|
289
|
+
"input_json": "",
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
elif chunk.type == "tool_use_delta":
|
|
293
|
+
current_tool["input_json"] = current_tool.get("input_json", "") + chunk.tool_input_json
|
|
294
|
+
|
|
295
|
+
elif chunk.type == "tool_use_end":
|
|
296
|
+
input_json = chunk.tool_input_json or current_tool.get("input_json", "{}")
|
|
297
|
+
try:
|
|
298
|
+
tool_input = json.loads(input_json)
|
|
299
|
+
except json.JSONDecodeError:
|
|
300
|
+
tool_input = {}
|
|
301
|
+
|
|
302
|
+
block = ToolUseBlock(
|
|
303
|
+
id=chunk.tool_use_id or current_tool.get("id", ""),
|
|
304
|
+
name=chunk.tool_name or current_tool.get("name", ""),
|
|
305
|
+
input=tool_input,
|
|
306
|
+
)
|
|
307
|
+
assistant_content.append(block)
|
|
308
|
+
tool_use_blocks.append(block)
|
|
309
|
+
|
|
310
|
+
yield ToolUseEvent(
|
|
311
|
+
tool_name=block.name,
|
|
312
|
+
tool_input=block.input,
|
|
313
|
+
tool_use_id=block.id,
|
|
314
|
+
)
|
|
315
|
+
current_tool = {}
|
|
316
|
+
|
|
317
|
+
elif chunk.type == "message_start":
|
|
318
|
+
if chunk.usage:
|
|
319
|
+
total_usage["input_tokens"] += chunk.usage.get("input_tokens", 0)
|
|
320
|
+
|
|
321
|
+
elif chunk.type == "message_delta":
|
|
322
|
+
if chunk.usage:
|
|
323
|
+
total_usage["output_tokens"] += chunk.usage.get("output_tokens", 0)
|
|
324
|
+
|
|
325
|
+
elif chunk.type == "message_stop":
|
|
326
|
+
pass
|
|
327
|
+
|
|
328
|
+
elif chunk.type == "error":
|
|
329
|
+
yield ErrorEvent(
|
|
330
|
+
message=chunk.error,
|
|
331
|
+
recoverable=False,
|
|
332
|
+
)
|
|
333
|
+
return
|
|
334
|
+
|
|
335
|
+
except Exception as e:
|
|
336
|
+
yield ErrorEvent(message=str(e), recoverable=False)
|
|
337
|
+
return
|
|
338
|
+
|
|
339
|
+
_flush_thinking_block()
|
|
340
|
+
if current_text:
|
|
341
|
+
assistant_content.append(TextBlock(text=current_text))
|
|
342
|
+
|
|
343
|
+
if assistant_content:
|
|
344
|
+
assistant_msg = create_assistant_message(content=assistant_content)
|
|
345
|
+
messages.append(assistant_msg)
|
|
346
|
+
else:
|
|
347
|
+
yield TurnCompleteEvent(
|
|
348
|
+
reason="empty_response",
|
|
349
|
+
turn_count=turn_count,
|
|
350
|
+
usage=total_usage,
|
|
351
|
+
)
|
|
352
|
+
params.messages[:] = messages
|
|
353
|
+
return
|
|
354
|
+
|
|
355
|
+
if not tool_use_blocks:
|
|
356
|
+
yield TurnCompleteEvent(
|
|
357
|
+
reason="end_turn",
|
|
358
|
+
turn_count=turn_count,
|
|
359
|
+
usage=total_usage,
|
|
360
|
+
)
|
|
361
|
+
params.messages[:] = messages
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
approved_blocks: list[ToolUseBlock] = []
|
|
365
|
+
|
|
366
|
+
for block in tool_use_blocks:
|
|
367
|
+
tool = _find_tool(params.tools, block.name)
|
|
368
|
+
|
|
369
|
+
if not params.permission_manager or not params.permission_queue:
|
|
370
|
+
approved_blocks.append(block)
|
|
371
|
+
continue
|
|
372
|
+
|
|
373
|
+
if not tool:
|
|
374
|
+
approved_blocks.append(block)
|
|
375
|
+
continue
|
|
376
|
+
|
|
377
|
+
perm = params.permission_manager.check(tool, block.input)
|
|
378
|
+
|
|
379
|
+
if perm.behavior == PermissionBehavior.ALLOW:
|
|
380
|
+
approved_blocks.append(block)
|
|
381
|
+
elif perm.behavior == PermissionBehavior.DENY:
|
|
382
|
+
reason = perm.reason or "denied by rules"
|
|
383
|
+
msg = create_tool_result_message(
|
|
384
|
+
tool_use_id=block.id,
|
|
385
|
+
result=f"Permission denied: {reason}",
|
|
386
|
+
is_error=True,
|
|
387
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
388
|
+
)
|
|
389
|
+
messages.append(msg)
|
|
390
|
+
yield ToolResultEvent(
|
|
391
|
+
tool_use_id=block.id,
|
|
392
|
+
tool_name=block.name,
|
|
393
|
+
result=f"Permission denied: {reason}",
|
|
394
|
+
is_error=True,
|
|
395
|
+
)
|
|
396
|
+
elif perm.behavior == PermissionBehavior.ASK:
|
|
397
|
+
yield PermissionRequestEvent(
|
|
398
|
+
tool_name=block.name,
|
|
399
|
+
tool_input=block.input,
|
|
400
|
+
tool_use_id=block.id,
|
|
401
|
+
)
|
|
402
|
+
response = await params.permission_queue.get()
|
|
403
|
+
if response.allowed:
|
|
404
|
+
if response.always_allow:
|
|
405
|
+
params.permission_manager.add_allow_rule(tool.name)
|
|
406
|
+
approved_blocks.append(block)
|
|
407
|
+
else:
|
|
408
|
+
msg = create_tool_result_message(
|
|
409
|
+
tool_use_id=block.id,
|
|
410
|
+
result=(
|
|
411
|
+
"Permission denied by user. Do not retry the same "
|
|
412
|
+
"tool call. Explain what you wanted to do and ask "
|
|
413
|
+
"for guidance."
|
|
414
|
+
),
|
|
415
|
+
is_error=True,
|
|
416
|
+
source_tool_assistant_uuid=assistant_msg.uuid,
|
|
417
|
+
)
|
|
418
|
+
messages.append(msg)
|
|
419
|
+
yield ToolResultEvent(
|
|
420
|
+
tool_use_id=block.id,
|
|
421
|
+
tool_name=block.name,
|
|
422
|
+
result="Permission denied by user.",
|
|
423
|
+
is_error=True,
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if approved_blocks:
|
|
427
|
+
yield StreamModeEvent(mode="tool-running")
|
|
428
|
+
tool_results = await _run_tools(
|
|
429
|
+
approved_blocks, assistant_msg, params.tools, params.tool_context
|
|
430
|
+
)
|
|
431
|
+
for msg, event in tool_results:
|
|
432
|
+
messages.append(msg)
|
|
433
|
+
yield event
|
|
434
|
+
|
|
435
|
+
if params.max_turns and turn_count >= params.max_turns:
|
|
436
|
+
yield TurnCompleteEvent(
|
|
437
|
+
reason="max_turns_reached",
|
|
438
|
+
turn_count=turn_count,
|
|
439
|
+
usage=total_usage,
|
|
440
|
+
)
|
|
441
|
+
params.messages[:] = messages
|
|
442
|
+
return
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Session storage — JSONL-based conversation persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import uuid
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from crabcode_core.types.message import Message
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_config_home() -> Path:
|
|
17
|
+
"""Get the CrabCode config directory (~/.crabcode/)."""
|
|
18
|
+
return Path.home() / ".crabcode"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_projects_dir() -> Path:
|
|
22
|
+
"""Get the projects directory for session storage."""
|
|
23
|
+
return get_config_home() / "projects"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _sanitize_path(path: str) -> str:
|
|
27
|
+
"""Sanitize a filesystem path for use as a directory name."""
|
|
28
|
+
sanitized = re.sub(r'[^\w\-.]', '_', path)
|
|
29
|
+
if len(sanitized) > 200:
|
|
30
|
+
sanitized = sanitized[:200]
|
|
31
|
+
return sanitized
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def get_project_dir(cwd: str) -> Path:
|
|
35
|
+
"""Get the project-specific session directory."""
|
|
36
|
+
return get_projects_dir() / _sanitize_path(os.path.abspath(cwd))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_transcript_path(cwd: str, session_id: str) -> Path:
|
|
40
|
+
"""Get the path for a session transcript file."""
|
|
41
|
+
return get_project_dir(cwd) / f"{session_id}.jsonl"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def generate_session_id() -> str:
|
|
45
|
+
return str(uuid.uuid4())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SessionStorage:
|
|
49
|
+
"""Manages session persistence using JSONL files."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, cwd: str, session_id: str | None = None):
|
|
52
|
+
self.cwd = cwd
|
|
53
|
+
self.session_id = session_id or generate_session_id()
|
|
54
|
+
self._transcript_path = get_transcript_path(cwd, self.session_id)
|
|
55
|
+
self._initialized = False
|
|
56
|
+
self._written_uuids: set[str] = set()
|
|
57
|
+
|
|
58
|
+
def _ensure_dir(self) -> None:
|
|
59
|
+
if not self._initialized:
|
|
60
|
+
self._transcript_path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
self._initialized = True
|
|
62
|
+
|
|
63
|
+
def append_message(self, message: Message) -> None:
|
|
64
|
+
"""Append a message to the session transcript (skips duplicates by uuid)."""
|
|
65
|
+
if message.uuid in self._written_uuids:
|
|
66
|
+
return
|
|
67
|
+
self._written_uuids.add(message.uuid)
|
|
68
|
+
|
|
69
|
+
self._ensure_dir()
|
|
70
|
+
entry = {
|
|
71
|
+
"type": message.role.value,
|
|
72
|
+
"uuid": message.uuid,
|
|
73
|
+
"parent_uuid": message.parent_uuid,
|
|
74
|
+
"timestamp": message.timestamp,
|
|
75
|
+
"content": message.content if isinstance(message.content, str) else [
|
|
76
|
+
block.model_dump() for block in message.content
|
|
77
|
+
],
|
|
78
|
+
}
|
|
79
|
+
with open(self._transcript_path, "a") as f:
|
|
80
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
81
|
+
|
|
82
|
+
def load_messages(self) -> list[dict[str, Any]]:
|
|
83
|
+
"""Load all messages from the session transcript (deduped by uuid)."""
|
|
84
|
+
if not self._transcript_path.exists():
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
messages: list[dict[str, Any]] = []
|
|
88
|
+
seen: set[str] = set()
|
|
89
|
+
try:
|
|
90
|
+
with open(self._transcript_path) as f:
|
|
91
|
+
for line in f:
|
|
92
|
+
line = line.strip()
|
|
93
|
+
if not line:
|
|
94
|
+
continue
|
|
95
|
+
try:
|
|
96
|
+
entry = json.loads(line)
|
|
97
|
+
except json.JSONDecodeError:
|
|
98
|
+
continue
|
|
99
|
+
msg_uuid = entry.get("uuid", "")
|
|
100
|
+
if msg_uuid and msg_uuid in seen:
|
|
101
|
+
continue
|
|
102
|
+
if msg_uuid:
|
|
103
|
+
seen.add(msg_uuid)
|
|
104
|
+
messages.append(entry)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass
|
|
107
|
+
|
|
108
|
+
self._written_uuids = seen
|
|
109
|
+
return messages
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def list_sessions(cwd: str) -> list[dict[str, Any]]:
|
|
113
|
+
"""List all sessions for a given working directory."""
|
|
114
|
+
project_dir = get_project_dir(cwd)
|
|
115
|
+
if not project_dir.exists():
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
sessions: list[dict[str, Any]] = []
|
|
119
|
+
for path in sorted(project_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True):
|
|
120
|
+
session_id = path.stem
|
|
121
|
+
try:
|
|
122
|
+
stat = path.stat()
|
|
123
|
+
with open(path) as f:
|
|
124
|
+
first_line = f.readline().strip()
|
|
125
|
+
first_msg = json.loads(first_line) if first_line else {}
|
|
126
|
+
|
|
127
|
+
sessions.append({
|
|
128
|
+
"session_id": session_id,
|
|
129
|
+
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
|
|
130
|
+
"size": stat.st_size,
|
|
131
|
+
"preview": _extract_preview(first_msg),
|
|
132
|
+
})
|
|
133
|
+
except Exception:
|
|
134
|
+
sessions.append({
|
|
135
|
+
"session_id": session_id,
|
|
136
|
+
"modified": "",
|
|
137
|
+
"size": 0,
|
|
138
|
+
"preview": "",
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
return sessions
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _extract_preview(msg: dict[str, Any]) -> str:
|
|
145
|
+
"""Extract a short preview from a message entry."""
|
|
146
|
+
content = msg.get("content", "")
|
|
147
|
+
if isinstance(content, str):
|
|
148
|
+
return content[:100]
|
|
149
|
+
if isinstance(content, list) and content:
|
|
150
|
+
first = content[0]
|
|
151
|
+
if isinstance(first, dict) and "text" in first:
|
|
152
|
+
return first["text"][:100]
|
|
153
|
+
return ""
|