citrus-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 (70) hide show
  1. citrus_code/__init__.py +0 -0
  2. citrus_code/__main__.py +5 -0
  3. citrus_code/agent/__init__.py +0 -0
  4. citrus_code/agent/agent.py +255 -0
  5. citrus_code/agent/cancellation.py +66 -0
  6. citrus_code/agent/context.py +20 -0
  7. citrus_code/agent/events.py +97 -0
  8. citrus_code/agent/hooks.py +37 -0
  9. citrus_code/agent/loop.py +429 -0
  10. citrus_code/agent/message_queue.py +44 -0
  11. citrus_code/agent/state.py +13 -0
  12. citrus_code/agent/tool_runner.py +161 -0
  13. citrus_code/agent/types.py +34 -0
  14. citrus_code/ai/__init__.py +0 -0
  15. citrus_code/ai/completion.py +30 -0
  16. citrus_code/ai/events.py +78 -0
  17. citrus_code/ai/provider.py +20 -0
  18. citrus_code/ai/providers/__init__.py +0 -0
  19. citrus_code/ai/providers/chat_completions.py +503 -0
  20. citrus_code/ai/providers/chat_stream_types.py +47 -0
  21. citrus_code/ai/providers/deepseek.py +104 -0
  22. citrus_code/ai/providers/fake.py +101 -0
  23. citrus_code/ai/providers/tool_call_buffer.py +73 -0
  24. citrus_code/ai/registry.py +40 -0
  25. citrus_code/ai/types.py +142 -0
  26. citrus_code/cli/__init__.py +5 -0
  27. citrus_code/cli/main.py +258 -0
  28. citrus_code/cli/preview.py +55 -0
  29. citrus_code/cli/prompts.py +52 -0
  30. citrus_code/cli/render.py +306 -0
  31. citrus_code/cli/session_files.py +75 -0
  32. citrus_code/cli/terminal.py +280 -0
  33. citrus_code/cli/theme.py +51 -0
  34. citrus_code/coding/__init__.py +0 -0
  35. citrus_code/coding/coding_session.py +287 -0
  36. citrus_code/coding/compaction/__init__.py +5 -0
  37. citrus_code/coding/compaction/controller.py +168 -0
  38. citrus_code/coding/compaction/summarize.py +125 -0
  39. citrus_code/coding/compaction/tokens.py +48 -0
  40. citrus_code/coding/context_hooks.py +41 -0
  41. citrus_code/coding/history.py +47 -0
  42. citrus_code/coding/project_context.py +44 -0
  43. citrus_code/coding/session/__init__.py +30 -0
  44. citrus_code/coding/session/manager.py +200 -0
  45. citrus_code/coding/session/projection.py +50 -0
  46. citrus_code/coding/session/recovery.py +33 -0
  47. citrus_code/coding/session/storage.py +138 -0
  48. citrus_code/coding/session/types.py +108 -0
  49. citrus_code/coding/tools/__init__.py +35 -0
  50. citrus_code/coding/tools/bash.py +5 -0
  51. citrus_code/coding/tools/edit.py +101 -0
  52. citrus_code/coding/tools/edit_text.py +172 -0
  53. citrus_code/coding/tools/factory.py +57 -0
  54. citrus_code/coding/tools/find.py +86 -0
  55. citrus_code/coding/tools/grep.py +151 -0
  56. citrus_code/coding/tools/ls.py +61 -0
  57. citrus_code/coding/tools/mutation.py +39 -0
  58. citrus_code/coding/tools/paths.py +11 -0
  59. citrus_code/coding/tools/powershell.py +5 -0
  60. citrus_code/coding/tools/processes.py +103 -0
  61. citrus_code/coding/tools/read.py +133 -0
  62. citrus_code/coding/tools/shell.py +218 -0
  63. citrus_code/coding/tools/truncate.py +87 -0
  64. citrus_code/coding/tools/write.py +94 -0
  65. citrus_code/config.py +34 -0
  66. citrus_code-0.1.0.dist-info/METADATA +412 -0
  67. citrus_code-0.1.0.dist-info/RECORD +70 -0
  68. citrus_code-0.1.0.dist-info/WHEEL +4 -0
  69. citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
  70. citrus_code-0.1.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,104 @@
1
+ from collections.abc import AsyncIterator
2
+
3
+ import httpx
4
+ from pydantic import SecretStr
5
+
6
+ from ..events import AssistantMessageEvent
7
+ from ..types import (
8
+ ImageContent, Message, ModelContext, ModelOptions, ModelSpec,
9
+ TextContent, ToolResultMessage, UserMessage,
10
+ )
11
+ from .chat_completions import (
12
+ build_chat_request,
13
+ stream_chat_data,
14
+ stream_assistant_events,
15
+ )
16
+
17
+
18
+ DEEPSEEK_VISION_MODEL_IDS = frozenset({
19
+ "deepseek-flash",
20
+ "deepseek-v4-flash",
21
+ "deepseek-v4-flash-vision-exp",
22
+ })
23
+ DEEPSEEK_IMAGE_MIME_TYPES = frozenset({
24
+ "image/png", "image/jpeg", "image/gif", "image/webp",
25
+ })
26
+
27
+
28
+ class DeepSeekProvider:
29
+ id: str = "deepseek"
30
+ name: str = "DeepSeek"
31
+
32
+ def __init__(
33
+ self,
34
+ client: httpx.AsyncClient,
35
+ model: ModelSpec,
36
+ api_key: SecretStr,
37
+ ) -> None:
38
+ if model.provider != self.id:
39
+ raise ValueError(
40
+ "The model does not belong to this provider"
41
+ )
42
+
43
+ if model.api != "chat-completions":
44
+ raise ValueError(
45
+ "This provider requires the chat-completions API"
46
+ )
47
+
48
+ self._client = client
49
+ self._model = model
50
+ self._api_key = api_key
51
+
52
+ def get_models(self) -> list[ModelSpec]:
53
+ return [self._model]
54
+
55
+ def stream(
56
+ self,
57
+ model: ModelSpec,
58
+ context: ModelContext,
59
+ options: ModelOptions | None = None,
60
+ ) -> AsyncIterator[AssistantMessageEvent]:
61
+ if model != self._model:
62
+ raise ValueError(
63
+ "The requested model is not registered "
64
+ "with this provider"
65
+ )
66
+
67
+ if model.supports_images:
68
+ messages: list[Message] = []
69
+ for message in context.messages:
70
+ if isinstance(message, (UserMessage, ToolResultMessage)) and not isinstance(message.content, str):
71
+ content = [
72
+ TextContent(text=(
73
+ f"[图片未发送:DeepSeek 不支持 {block.mime_type};"
74
+ "请先转换为 PNG、JPEG、GIF 或 WebP。]"
75
+ ))
76
+ if isinstance(block, ImageContent) and block.mime_type not in DEEPSEEK_IMAGE_MIME_TYPES
77
+ else block
78
+ for block in message.content
79
+ ]
80
+ message = message.model_copy(update={"content": content})
81
+ messages.append(message)
82
+ # Transform only the request. An unsupported old attachment must
83
+ # not make every later request in the session fail with HTTP 400.
84
+ context = ModelContext(messages=messages, tools=context.tools)
85
+
86
+ body = build_chat_request(
87
+ model=model,
88
+ context=context,
89
+ options=options,
90
+ )
91
+
92
+ body["thinking"] = {"type": "disabled"}
93
+
94
+ data_stream = stream_chat_data(
95
+ client=self._client,
96
+ model=model,
97
+ api_key=self._api_key,
98
+ body=body,
99
+ )
100
+
101
+ return stream_assistant_events(
102
+ model=model,
103
+ data_stream=data_stream,
104
+ )
@@ -0,0 +1,101 @@
1
+ import asyncio
2
+ import time
3
+ from collections.abc import AsyncIterator
4
+
5
+ from ..events import (
6
+ AssistantMessageEvent,
7
+ DoneEvent,
8
+ StartEvent,
9
+ TextDeltaEvent,
10
+ TextEndEvent,
11
+ TextStartEvent,
12
+ )
13
+ from ..types import (
14
+ AssistantMessage,
15
+ ModelContext,
16
+ ModelOptions,
17
+ ModelSpec,
18
+ TextContent,
19
+ )
20
+ class FakeProvider:
21
+ id: str = "fake"
22
+ name: str = "Fake Provider"
23
+
24
+ def __init__(
25
+ self,
26
+ chunks: list[str],
27
+ delay_seconds: float=0.2
28
+ ) -> None:
29
+ if delay_seconds < 0:
30
+ raise ValueError("delay_seconds must not be negative")
31
+
32
+ self._chunks = chunks.copy()
33
+ self._delay_seconds = delay_seconds
34
+
35
+ self._model = ModelSpec(
36
+ id="fake-text",
37
+ name="Fake Text Model",
38
+ provider=self.id,
39
+ api="fake",
40
+ base_url="fake://local",
41
+ )
42
+
43
+ def get_models(self) -> list[ModelSpec]:
44
+ return [self._model]
45
+
46
+ async def stream(
47
+ self,
48
+ model: ModelSpec,
49
+ context: ModelContext,
50
+ options: ModelOptions | None = None,
51
+ ) -> AsyncIterator[AssistantMessageEvent]:
52
+ if(
53
+ model.provider != self.id
54
+ or model.id !=self._model.id
55
+ or model.api !=self._model.api
56
+ ):
57
+ raise ValueError("The request model is not supported")
58
+
59
+ message = AssistantMessage(
60
+ content=[],
61
+ api=model.api,
62
+ provider=model.provider,
63
+ model=model.id,
64
+ usage=None,
65
+ stop_reason="pending",
66
+ timestamp=int(time.time()*1000),
67
+ )
68
+ yield StartEvent(partial=message)
69
+
70
+ text_block = TextContent(text="")
71
+ message.content.append(text_block)
72
+
73
+ yield TextStartEvent(
74
+ content_index=0,
75
+ partial=message,
76
+ )
77
+
78
+ for chunk in self._chunks:
79
+ await asyncio.sleep(self._delay_seconds)
80
+
81
+ text_block.text += chunk
82
+
83
+ yield TextDeltaEvent(
84
+ content_index=0,
85
+ delta=chunk,
86
+ partial=message,
87
+ )
88
+
89
+ yield TextEndEvent(
90
+ content_index=0,
91
+ content=text_block.text,
92
+ partial=message,
93
+ )
94
+
95
+ message.stop_reason = "stop"
96
+
97
+ yield DoneEvent(
98
+ reason="stop",
99
+ message=message,
100
+ )
101
+
@@ -0,0 +1,73 @@
1
+ from pydantic import JsonValue, TypeAdapter
2
+
3
+ from ..types import ToolCall
4
+ from .chat_stream_types import ChatToolCallDelta
5
+
6
+
7
+ ARGUMENTS_ADAPTER = TypeAdapter(dict[str, JsonValue])
8
+
9
+
10
+ class ToolCallBuffer:
11
+ def __init__(
12
+ self,
13
+ tool_index: int,
14
+ content_index: int
15
+ ) -> None:
16
+ self.tool_index = tool_index
17
+ self.content_index = content_index
18
+ self.arguments_text = ""
19
+
20
+ self.tool_call = ToolCall(
21
+ id="",
22
+ name="",
23
+ arguments={},
24
+ )
25
+
26
+
27
+ def append(self, delta: ChatToolCallDelta) -> str:
28
+ if delta.index != self.tool_index:
29
+ raise ValueError(
30
+ "The delta belongs to a different tool call"
31
+ )
32
+
33
+ if delta.id is not None:
34
+ if self.tool_call.id and self.tool_call.id !=delta.id:
35
+ raise ValueError("The tool call ID changed")
36
+
37
+ self.tool_call.id = delta.id
38
+
39
+ if delta.function is None:
40
+ return ""
41
+
42
+ if delta.function.name is not None:
43
+ name = delta.function.name
44
+
45
+ if self.tool_call.name and self.tool_call.name != name:
46
+ raise ValueError("The tool name changed")
47
+
48
+ self.tool_call.name = name
49
+
50
+ fragment = delta.function.arguments
51
+
52
+ if fragment is None:
53
+ return ""
54
+
55
+ self.arguments_text += fragment
56
+
57
+ return fragment
58
+
59
+
60
+ def finish(self) -> ToolCall:
61
+ if not self.tool_call.id:
62
+ raise ValueError("The tool call ID is missing")
63
+
64
+ if not self.tool_call.name:
65
+ raise ValueError("The tool name is missing")
66
+
67
+ arguments = ARGUMENTS_ADAPTER.validate_json(
68
+ self.arguments_text
69
+ )
70
+
71
+ self.tool_call.arguments = arguments
72
+
73
+ return self.tool_call
@@ -0,0 +1,40 @@
1
+ from .provider import Provider
2
+ from .types import ModelSpec
3
+
4
+
5
+ class ProviderRegistry:
6
+ def __init__(self) -> None:
7
+ self._providers: dict[str,Provider] = {}
8
+
9
+ def register(self,provider: Provider) -> None:
10
+ if provider.id in self._providers:
11
+ raise ValueError(
12
+ f"Provider already registered: {provider.id}"
13
+ )
14
+ self._providers[provider.id]=provider
15
+
16
+ def get_provider(self,provider_id: str) -> Provider:
17
+ provider = self._providers.get(provider_id)
18
+
19
+ if provider is None:
20
+ raise KeyError(
21
+ f"Provider not found: {provider_id}"
22
+ )
23
+
24
+ return provider
25
+
26
+ def get_model(
27
+ self,
28
+ provider_id: str,
29
+ model_id: str,
30
+ ) -> ModelSpec:
31
+ provider = self.get_provider(provider_id)
32
+
33
+ for model in provider.get_models():
34
+ if model.id == model_id:
35
+ return model
36
+
37
+ raise KeyError(
38
+ f"Model not found: {provider_id}/{model_id}"
39
+ )
40
+
@@ -0,0 +1,142 @@
1
+ from typing import Annotated,Literal
2
+
3
+ from pydantic import BaseModel,ConfigDict,Field,JsonValue
4
+
5
+
6
+ class TextContent(BaseModel):
7
+ model_config = ConfigDict(extra="forbid")
8
+
9
+ type: Literal["text"] = "text"
10
+ text: str
11
+
12
+
13
+ class ImageContent(BaseModel):
14
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
15
+
16
+ type: Literal["image"] = "image"
17
+ data: str
18
+ mime_type: str = Field(alias="mimeType")
19
+
20
+
21
+ ToolContent = Annotated[
22
+ TextContent | ImageContent,
23
+ Field(discriminator="type"),
24
+ ]
25
+
26
+
27
+ class ToolCall(BaseModel):
28
+ model_config = ConfigDict(extra="forbid")
29
+
30
+ type: Literal["toolCall"]="toolCall"
31
+ id: str
32
+ name: str
33
+ arguments: dict[str,JsonValue]
34
+
35
+ class SystemMessage(BaseModel):
36
+ model_config = ConfigDict(extra="forbid")
37
+ role: Literal["system"] = "system"
38
+ content: str | list[TextContent]
39
+ timestamp: int
40
+
41
+
42
+ class UserMessage(BaseModel):
43
+ model_config = ConfigDict(extra="forbid")
44
+
45
+ role: Literal["user"] = "user"
46
+ content: str | list[ToolContent]
47
+ timestamp: int
48
+
49
+
50
+ class ToolResultMessage(BaseModel):
51
+ model_config = ConfigDict(extra="forbid")
52
+
53
+ role: Literal["toolResult"] = "toolResult"
54
+ tool_call_id: str
55
+ tool_name: str
56
+ content: list[ToolContent]
57
+ is_error: bool
58
+ timestamp: int
59
+
60
+ StopReason = Literal[
61
+ "pending",
62
+ "stop",
63
+ "length",
64
+ "toolUse",
65
+ "error",
66
+ "aborted",
67
+ ]
68
+
69
+
70
+ class Usage(BaseModel):
71
+ model_config = ConfigDict(extra="forbid")
72
+
73
+ input_tokens: int = Field(ge=0)
74
+ output_tokens: int = Field(ge=0)
75
+ total_tokens: int = Field(ge=0)
76
+
77
+ cache_read_tokens: int | None = Field(default=None,ge=0)
78
+ cache_write_tokens: int | None = Field(default=None,ge=0)
79
+
80
+
81
+ AssistantContent = Annotated[
82
+ TextContent | ToolCall,
83
+ Field(discriminator="type"),
84
+ ]
85
+
86
+
87
+ class AssistantMessage(BaseModel):
88
+ model_config = ConfigDict(extra="forbid")
89
+
90
+ role: Literal["assistant"] = "assistant"
91
+ content: list[AssistantContent]
92
+
93
+ api: str
94
+ provider: str
95
+ model: str
96
+
97
+ usage: Usage | None
98
+ stop_reason: StopReason
99
+ error_message: str | None = None
100
+ timestamp: int
101
+
102
+
103
+ Message = Annotated[
104
+ SystemMessage | UserMessage | AssistantMessage | ToolResultMessage,
105
+ Field(discriminator="role"),
106
+ ]
107
+
108
+
109
+ class ModelSpec(BaseModel):
110
+ model_config = ConfigDict(extra="forbid")
111
+
112
+ id: str
113
+ name: str
114
+ provider: str
115
+ api: str
116
+ base_url: str
117
+
118
+ context_window: int | None = Field(default=None,gt=0)
119
+ max_output_tokens: int | None = Field(default=None,gt=0)
120
+ supports_images: bool = False
121
+
122
+
123
+ class ToolDefinition(BaseModel):
124
+ model_config = ConfigDict(extra="forbid")
125
+
126
+ name: str
127
+ description: str
128
+ parameters: dict[str,JsonValue]
129
+
130
+
131
+ class ModelContext(BaseModel):
132
+ model_config = ConfigDict(extra="forbid")
133
+
134
+ messages: list[Message]
135
+ tools: list[ToolDefinition] = Field(default_factory=list)
136
+
137
+
138
+ class ModelOptions(BaseModel):
139
+ model_config = ConfigDict(extra="forbid")
140
+
141
+ max_output_tokens: int | None = Field(default=None,gt=0)
142
+ temperature: float | None = None
@@ -0,0 +1,5 @@
1
+ """Command-line application for Citrus Code."""
2
+
3
+ from .main import main
4
+
5
+ __all__ = ["main"]