limbo-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.
- limbo/__init__.py +3 -0
- limbo/__main__.py +6 -0
- limbo/agent.py +456 -0
- limbo/app.py +107 -0
- limbo/config.py +99 -0
- limbo/history.py +90 -0
- limbo/llm/__init__.py +1 -0
- limbo/llm/anthropic_client.py +320 -0
- limbo/llm/catalog.py +234 -0
- limbo/llm/client.py +21 -0
- limbo/llm/factory.py +46 -0
- limbo/llm/openai_client.py +261 -0
- limbo/models.py +81 -0
- limbo/sessions.py +258 -0
- limbo/skills.py +89 -0
- limbo/tools/__init__.py +1 -0
- limbo/tools/base.py +102 -0
- limbo/tools/bash.py +172 -0
- limbo/tools/edit.py +70 -0
- limbo/tools/find.py +71 -0
- limbo/tools/grep.py +182 -0
- limbo/tools/ignore.py +111 -0
- limbo/tools/ls.py +41 -0
- limbo/tools/read.py +105 -0
- limbo/tools/registry.py +66 -0
- limbo/tools/write.py +34 -0
- limbo/trace.py +100 -0
- limbo/ui/__init__.py +1 -0
- limbo/ui/app.py +52 -0
- limbo/ui/app.tcss +174 -0
- limbo/ui/banner.py +83 -0
- limbo/ui/commands.py +61 -0
- limbo/ui/screens/__init__.py +1 -0
- limbo/ui/screens/game2048.py +213 -0
- limbo/ui/screens/main.py +369 -0
- limbo/ui/screens/session_picker.py +59 -0
- limbo/ui/widgets/__init__.py +1 -0
- limbo/ui/widgets/chat.py +149 -0
- limbo/ui/widgets/command_menu.py +45 -0
- limbo/ui/widgets/input.py +199 -0
- limbo/ui/widgets/status_bar.py +32 -0
- limbo/ui/widgets/tool_card.py +179 -0
- limbo_code-0.1.0.dist-info/METADATA +16 -0
- limbo_code-0.1.0.dist-info/RECORD +46 -0
- limbo_code-0.1.0.dist-info/WHEEL +4 -0
- limbo_code-0.1.0.dist-info/entry_points.txt +2 -0
limbo/llm/factory.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""LLM client factory.
|
|
2
|
+
|
|
3
|
+
Selects the client implementation for the model's provider API dialect
|
|
4
|
+
(``ProviderSpec.api``). New dialects (e.g. ``anthropic-messages`` for Kimi's
|
|
5
|
+
coding endpoint) plug in via ``register_client`` without touching call sites.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable
|
|
11
|
+
|
|
12
|
+
from limbo.config import Config
|
|
13
|
+
from limbo.llm.anthropic_client import AnthropicMessagesClient
|
|
14
|
+
from limbo.llm.catalog import (
|
|
15
|
+
API_ANTHROPIC_MESSAGES,
|
|
16
|
+
API_OPENAI_COMPLETIONS,
|
|
17
|
+
resolve_model,
|
|
18
|
+
)
|
|
19
|
+
from limbo.llm.client import LLMClient
|
|
20
|
+
from limbo.llm.openai_client import OpenAICompatibleClient
|
|
21
|
+
|
|
22
|
+
ClientFactory = Callable[[Config], LLMClient]
|
|
23
|
+
|
|
24
|
+
_FACTORIES: dict[str, ClientFactory] = {
|
|
25
|
+
API_OPENAI_COMPLETIONS: OpenAICompatibleClient,
|
|
26
|
+
API_ANTHROPIC_MESSAGES: AnthropicMessagesClient,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def register_client(api: str, factory: ClientFactory) -> None:
|
|
31
|
+
"""Register a client factory for a provider API dialect."""
|
|
32
|
+
_FACTORIES[api] = factory
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def create_llm_client(config: Config) -> LLMClient:
|
|
36
|
+
"""Create the LLM client for the configured model's provider."""
|
|
37
|
+
spec = resolve_model(config.llm.model)
|
|
38
|
+
factory = _FACTORIES.get(spec.provider.api)
|
|
39
|
+
if factory is None:
|
|
40
|
+
supported = ", ".join(sorted(_FACTORIES))
|
|
41
|
+
raise ValueError(
|
|
42
|
+
f"No LLM client implementation for API dialect "
|
|
43
|
+
f"{spec.provider.api!r} (model {spec.id!r}, provider "
|
|
44
|
+
f"{spec.provider.id!r}). Supported dialects: {supported}."
|
|
45
|
+
)
|
|
46
|
+
return factory(config)
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""OpenAI-compatible LLM client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
import warnings
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from openai import AsyncOpenAI, BadRequestError
|
|
12
|
+
|
|
13
|
+
from limbo.config import Config
|
|
14
|
+
from limbo.llm.catalog import (
|
|
15
|
+
ModelSpec,
|
|
16
|
+
resolve_api_key,
|
|
17
|
+
resolve_base_url,
|
|
18
|
+
resolve_model,
|
|
19
|
+
)
|
|
20
|
+
from limbo.llm.client import RequestHook
|
|
21
|
+
from limbo.models import (
|
|
22
|
+
CompletionMeta,
|
|
23
|
+
LLMEvent,
|
|
24
|
+
Message,
|
|
25
|
+
TextChunk,
|
|
26
|
+
ThinkingChunk,
|
|
27
|
+
ToolCallEvent,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class OpenAICompatibleClient:
|
|
32
|
+
"""Client for OpenAI-compatible chat completions with streaming.
|
|
33
|
+
|
|
34
|
+
Adapts request parameters and stream parsing to the model's catalog spec
|
|
35
|
+
(see ``limbo.llm.catalog``): per-model ``max_tokens``, thinking-parameter
|
|
36
|
+
format (``reasoning_effort`` vs ``thinking: {type: ...}``), and
|
|
37
|
+
``reasoning_content`` deltas/replay for reasoning models such as Kimi K3.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, config: Config):
|
|
41
|
+
self.config = config
|
|
42
|
+
self.spec: ModelSpec = resolve_model(config.llm.model)
|
|
43
|
+
self._client: AsyncOpenAI | None = None
|
|
44
|
+
# Whether to ask for streamed token usage. Disabled permanently after
|
|
45
|
+
# a provider rejects the stream_options parameter.
|
|
46
|
+
self._include_usage = True
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def client(self) -> AsyncOpenAI:
|
|
50
|
+
if self._client is None:
|
|
51
|
+
api_key = resolve_api_key(self.spec, self.config.llm.api_key)
|
|
52
|
+
if not api_key:
|
|
53
|
+
env = self.spec.provider.api_key_env
|
|
54
|
+
raise ValueError(
|
|
55
|
+
f"No API key for provider {self.spec.provider.id!r}. "
|
|
56
|
+
f"Set [llm] api_key in ~/.limbo/config.toml"
|
|
57
|
+
+ (f" or the ${env} environment variable." if env else ".")
|
|
58
|
+
)
|
|
59
|
+
self._client = AsyncOpenAI(
|
|
60
|
+
api_key=api_key,
|
|
61
|
+
base_url=resolve_base_url(self.spec, self.config.llm.base_url),
|
|
62
|
+
)
|
|
63
|
+
return self._client
|
|
64
|
+
|
|
65
|
+
async def close(self) -> None:
|
|
66
|
+
"""Close the underlying HTTP client if it has been created."""
|
|
67
|
+
if self._client is not None:
|
|
68
|
+
await self._client.close()
|
|
69
|
+
self._client = None
|
|
70
|
+
|
|
71
|
+
def _thinking_params(self) -> dict[str, Any]:
|
|
72
|
+
"""Build thinking-control parameters for the model's dialect."""
|
|
73
|
+
spec = self.spec
|
|
74
|
+
if not spec.reasoning or spec.thinking_format is None:
|
|
75
|
+
return {}
|
|
76
|
+
effort = self.config.llm.thinking_effort
|
|
77
|
+
if effort is None:
|
|
78
|
+
return {}
|
|
79
|
+
if spec.thinking_format == "openai":
|
|
80
|
+
value = spec.thinking_levels.get(effort)
|
|
81
|
+
if value is None:
|
|
82
|
+
warnings.warn(
|
|
83
|
+
f"thinking_effort={effort!r} is not supported by {spec.id} "
|
|
84
|
+
f"(supported: {sorted(spec.thinking_levels)}); ignoring.",
|
|
85
|
+
stacklevel=2,
|
|
86
|
+
)
|
|
87
|
+
return {}
|
|
88
|
+
return {"reasoning_effort": value}
|
|
89
|
+
if spec.thinking_format == "deepseek":
|
|
90
|
+
if effort == "off":
|
|
91
|
+
if not spec.thinking_can_disable:
|
|
92
|
+
warnings.warn(
|
|
93
|
+
f"{spec.id} does not support disabling thinking; ignoring.",
|
|
94
|
+
stacklevel=2,
|
|
95
|
+
)
|
|
96
|
+
return {}
|
|
97
|
+
return {"thinking": {"type": "disabled"}}
|
|
98
|
+
return {"thinking": {"type": "enabled"}}
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
async def chat(
|
|
102
|
+
self,
|
|
103
|
+
messages: list[Message],
|
|
104
|
+
tools: list[dict[str, Any]],
|
|
105
|
+
on_request: RequestHook | None = None,
|
|
106
|
+
) -> AsyncIterator[LLMEvent]:
|
|
107
|
+
include_reasoning = self.spec.requires_reasoning_content
|
|
108
|
+
request_messages = [
|
|
109
|
+
_message_to_openai(m, include_reasoning=include_reasoning)
|
|
110
|
+
for m in messages
|
|
111
|
+
]
|
|
112
|
+
kwargs: dict[str, Any] = {
|
|
113
|
+
"model": self.config.llm.model,
|
|
114
|
+
"messages": request_messages,
|
|
115
|
+
"temperature": self.config.llm.temperature,
|
|
116
|
+
"max_tokens": self.config.llm.max_tokens or self.spec.max_tokens,
|
|
117
|
+
"stream": True,
|
|
118
|
+
}
|
|
119
|
+
thinking = self._thinking_params()
|
|
120
|
+
if self.spec.thinking_format == "deepseek" and thinking:
|
|
121
|
+
# `thinking` is not an OpenAI SDK parameter; pass it through the
|
|
122
|
+
# request body untouched (Kimi K2 thinking dialect).
|
|
123
|
+
kwargs["extra_body"] = thinking
|
|
124
|
+
else:
|
|
125
|
+
kwargs.update(thinking)
|
|
126
|
+
if tools:
|
|
127
|
+
kwargs["tools"] = tools
|
|
128
|
+
if self._include_usage:
|
|
129
|
+
kwargs["stream_options"] = {"include_usage": True}
|
|
130
|
+
|
|
131
|
+
start = time.monotonic()
|
|
132
|
+
try:
|
|
133
|
+
stream = await self._create(kwargs, on_request)
|
|
134
|
+
except BadRequestError as e:
|
|
135
|
+
# Some providers reject stream_options; retry once without it and
|
|
136
|
+
# stop asking for usage on later requests.
|
|
137
|
+
if not self._include_usage or not _is_stream_options_error(e):
|
|
138
|
+
raise
|
|
139
|
+
self._include_usage = False
|
|
140
|
+
kwargs.pop("stream_options", None)
|
|
141
|
+
stream = await self._create(kwargs, on_request)
|
|
142
|
+
|
|
143
|
+
usage: dict[str, Any] | None = None
|
|
144
|
+
finish_reason: str | None = None
|
|
145
|
+
ttft: float | None = None
|
|
146
|
+
active_calls: dict[int, dict[str, Any]] = {}
|
|
147
|
+
async for chunk in stream:
|
|
148
|
+
if ttft is None:
|
|
149
|
+
ttft = time.monotonic() - start
|
|
150
|
+
if chunk.usage is not None:
|
|
151
|
+
# The final chunk carries usage when include_usage is on.
|
|
152
|
+
# model_dump keeps provider extras such as DeepSeek's
|
|
153
|
+
# prompt_cache_hit_tokens / prompt_cache_miss_tokens.
|
|
154
|
+
try:
|
|
155
|
+
usage = chunk.usage.model_dump()
|
|
156
|
+
except Exception: # noqa: BLE001
|
|
157
|
+
usage = {"raw": str(chunk.usage)}
|
|
158
|
+
if not chunk.choices:
|
|
159
|
+
continue
|
|
160
|
+
if chunk.choices[0].finish_reason:
|
|
161
|
+
finish_reason = chunk.choices[0].finish_reason
|
|
162
|
+
delta = chunk.choices[0].delta
|
|
163
|
+
# Reasoning models stream thinking in a separate field; the name
|
|
164
|
+
# varies by provider (Moonshot/DeepSeek: reasoning_content).
|
|
165
|
+
reasoning = None
|
|
166
|
+
for attr in ("reasoning_content", "reasoning"):
|
|
167
|
+
value = getattr(delta, attr, None)
|
|
168
|
+
if isinstance(value, str) and value:
|
|
169
|
+
reasoning = value
|
|
170
|
+
break
|
|
171
|
+
if reasoning:
|
|
172
|
+
yield ThinkingChunk(text=reasoning)
|
|
173
|
+
if delta.content:
|
|
174
|
+
yield TextChunk(text=delta.content)
|
|
175
|
+
|
|
176
|
+
if delta.tool_calls:
|
|
177
|
+
for tc in delta.tool_calls:
|
|
178
|
+
idx = tc.index
|
|
179
|
+
if idx not in active_calls:
|
|
180
|
+
active_calls[idx] = {
|
|
181
|
+
"id": tc.id or "",
|
|
182
|
+
"name": "",
|
|
183
|
+
"arguments": "",
|
|
184
|
+
}
|
|
185
|
+
current = active_calls[idx]
|
|
186
|
+
if tc.id:
|
|
187
|
+
current["id"] = tc.id
|
|
188
|
+
if tc.function:
|
|
189
|
+
if tc.function.name:
|
|
190
|
+
current["name"] += tc.function.name
|
|
191
|
+
if tc.function.arguments:
|
|
192
|
+
current["arguments"] += tc.function.arguments
|
|
193
|
+
|
|
194
|
+
for idx in sorted(active_calls):
|
|
195
|
+
call = active_calls[idx]
|
|
196
|
+
raw_arguments = call["arguments"]
|
|
197
|
+
try:
|
|
198
|
+
args = json.loads(raw_arguments) if raw_arguments else {}
|
|
199
|
+
except json.JSONDecodeError as e:
|
|
200
|
+
args = {
|
|
201
|
+
"raw_arguments": raw_arguments,
|
|
202
|
+
"parse_error": str(e),
|
|
203
|
+
}
|
|
204
|
+
yield ToolCallEvent(
|
|
205
|
+
id=call["id"] or f"call_{idx}",
|
|
206
|
+
name=call["name"],
|
|
207
|
+
arguments=args,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
yield CompletionMeta(
|
|
211
|
+
usage=usage,
|
|
212
|
+
finish_reason=finish_reason,
|
|
213
|
+
ttft=ttft,
|
|
214
|
+
duration=time.monotonic() - start,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
async def _create(
|
|
218
|
+
self, kwargs: dict[str, Any], on_request: RequestHook | None
|
|
219
|
+
):
|
|
220
|
+
"""Fire one HTTP attempt, reporting the exact body via the hook."""
|
|
221
|
+
if on_request is not None:
|
|
222
|
+
on_request(kwargs)
|
|
223
|
+
return await self.client.chat.completions.create(**kwargs)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _is_stream_options_error(error: BadRequestError) -> bool:
|
|
227
|
+
message = str(error).lower()
|
|
228
|
+
return "stream_options" in message or "include_usage" in message
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _message_to_openai(
|
|
232
|
+
message: Message, *, include_reasoning: bool = False
|
|
233
|
+
) -> dict[str, Any]:
|
|
234
|
+
m: dict[str, Any] = {"role": message.role}
|
|
235
|
+
# OpenAI requires `content` on assistant and tool messages, even when empty.
|
|
236
|
+
m["content"] = message.content or ""
|
|
237
|
+
if include_reasoning and message.role == "assistant":
|
|
238
|
+
# Kimi K3 requires reasoning_content on replayed assistant messages;
|
|
239
|
+
# send the stored thinking or an empty string.
|
|
240
|
+
m["reasoning_content"] = message.reasoning or ""
|
|
241
|
+
if message.tool_calls:
|
|
242
|
+
# The OpenAI SDK requires function.arguments to be a JSON string.
|
|
243
|
+
# Internal messages keep arguments as a dict for validation; serialize
|
|
244
|
+
# only when building the API request.
|
|
245
|
+
m["tool_calls"] = [
|
|
246
|
+
{
|
|
247
|
+
**tc,
|
|
248
|
+
"function": {
|
|
249
|
+
**tc["function"],
|
|
250
|
+
"arguments": (
|
|
251
|
+
json.dumps(tc["function"]["arguments"])
|
|
252
|
+
if isinstance(tc["function"]["arguments"], dict)
|
|
253
|
+
else tc["function"]["arguments"]
|
|
254
|
+
),
|
|
255
|
+
},
|
|
256
|
+
}
|
|
257
|
+
for tc in message.tool_calls
|
|
258
|
+
]
|
|
259
|
+
if message.tool_call_id:
|
|
260
|
+
m["tool_call_id"] = message.tool_call_id
|
|
261
|
+
return m
|
limbo/models.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Shared data models for Limbo."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Message(BaseModel):
|
|
12
|
+
"""A chat message in the conversation."""
|
|
13
|
+
|
|
14
|
+
role: str # system | user | assistant | tool
|
|
15
|
+
content: str | None = None
|
|
16
|
+
tool_calls: list[dict[str, Any]] | None = None
|
|
17
|
+
tool_call_id: str | None = None
|
|
18
|
+
# Reasoning/thinking text produced by reasoning models. Stored so it can
|
|
19
|
+
# be replayed to APIs that require it (e.g. Kimi K3) on later turns.
|
|
20
|
+
reasoning: str | None = None
|
|
21
|
+
# Signature authenticating a replayed thinking block (Anthropic dialect).
|
|
22
|
+
reasoning_signature: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ToolCall(BaseModel):
|
|
26
|
+
"""A tool call requested by the LLM."""
|
|
27
|
+
|
|
28
|
+
id: str
|
|
29
|
+
name: str
|
|
30
|
+
arguments: dict[str, Any]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ToolResult(BaseModel):
|
|
34
|
+
"""Result of executing a tool."""
|
|
35
|
+
|
|
36
|
+
success: bool
|
|
37
|
+
output: str | None = None
|
|
38
|
+
error: str | None = None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class TextChunk:
|
|
43
|
+
text: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class ThinkingChunk:
|
|
48
|
+
"""A streamed reasoning/thinking delta from a reasoning model."""
|
|
49
|
+
|
|
50
|
+
text: str
|
|
51
|
+
# Thinking-block signature (Anthropic dialect); carried on an empty-text
|
|
52
|
+
# chunk at the end of a thinking block.
|
|
53
|
+
signature: str | None = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ToolCallEvent:
|
|
58
|
+
id: str
|
|
59
|
+
name: str
|
|
60
|
+
arguments: dict[str, Any]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class CompletionMeta:
|
|
65
|
+
"""Response-level metadata emitted once per LLM call, after all chunks.
|
|
66
|
+
|
|
67
|
+
Carries whatever the provider returned: token usage (including cache-hit
|
|
68
|
+
counters where the provider exposes them), the finish/stop reason, and
|
|
69
|
+
client-measured timing. All fields may be None — providers differ widely
|
|
70
|
+
in what they report.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
usage: dict[str, Any] | None = None
|
|
74
|
+
finish_reason: str | None = None
|
|
75
|
+
# Seconds from request start to the first streamed chunk.
|
|
76
|
+
ttft: float | None = None
|
|
77
|
+
# Seconds from request start to the end of the stream.
|
|
78
|
+
duration: float | None = None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
LLMEvent = TextChunk | ThinkingChunk | ToolCallEvent | CompletionMeta
|
limbo/sessions.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Session storage: save, load, list, find, and export sessions.
|
|
2
|
+
|
|
3
|
+
Sessions are stored as JSONL files. The first line is a metadata record
|
|
4
|
+
(``{"type": "meta", ...}``) followed by one message per line. Files written
|
|
5
|
+
by older versions (no meta line) still load — the id falls back to the file
|
|
6
|
+
stem and the title is empty.
|
|
7
|
+
|
|
8
|
+
This module has no UI dependencies.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel
|
|
20
|
+
|
|
21
|
+
from limbo.models import Message
|
|
22
|
+
from limbo.trace import read_trace
|
|
23
|
+
|
|
24
|
+
META_TYPE = "meta"
|
|
25
|
+
SNAPSHOT_TYPE = "messages_snapshot"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SessionNotFoundError(Exception):
|
|
29
|
+
"""Raised when no session matches a lookup."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AmbiguousSessionError(Exception):
|
|
33
|
+
"""Raised when an id prefix matches multiple sessions."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, prefix: str, matches: list[str]):
|
|
36
|
+
self.prefix = prefix
|
|
37
|
+
self.matches = matches
|
|
38
|
+
super().__init__(
|
|
39
|
+
f"Session id prefix '{prefix}' is ambiguous, matches: "
|
|
40
|
+
+ ", ".join(matches)
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionMeta(BaseModel):
|
|
45
|
+
"""Metadata describing a session (stored as the first JSONL line)."""
|
|
46
|
+
|
|
47
|
+
id: str
|
|
48
|
+
workdir: str = ""
|
|
49
|
+
model: str = ""
|
|
50
|
+
title: str = ""
|
|
51
|
+
created_at: str = ""
|
|
52
|
+
updated_at: str = ""
|
|
53
|
+
# Populated by list/load; not written to disk.
|
|
54
|
+
path: Path | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _utc_now_iso() -> str:
|
|
58
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def derive_title(messages: list[Message], max_length: int = 50) -> str:
|
|
62
|
+
"""Derive a session title from the first user message."""
|
|
63
|
+
for msg in messages:
|
|
64
|
+
if msg.role == "user" and msg.content:
|
|
65
|
+
title = " ".join(msg.content.split())
|
|
66
|
+
if len(title) > max_length:
|
|
67
|
+
title = title[: max_length - 1].rstrip() + "…"
|
|
68
|
+
return title
|
|
69
|
+
return ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def save_session(path: Path, meta: SessionMeta, messages: list[Message]) -> None:
|
|
73
|
+
"""Atomically write the whole session (meta line + message lines)."""
|
|
74
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
75
|
+
meta.updated_at = _utc_now_iso()
|
|
76
|
+
meta_line = json.dumps(
|
|
77
|
+
{"type": META_TYPE, **meta.model_dump(exclude={"path"})},
|
|
78
|
+
ensure_ascii=False,
|
|
79
|
+
)
|
|
80
|
+
tmp_file = path.with_suffix(".tmp")
|
|
81
|
+
file_existed = path.exists()
|
|
82
|
+
with tmp_file.open("w", encoding="utf-8") as f:
|
|
83
|
+
f.write(meta_line + "\n")
|
|
84
|
+
for msg in messages:
|
|
85
|
+
f.write(msg.model_dump_json() + "\n")
|
|
86
|
+
os.replace(tmp_file, path)
|
|
87
|
+
if not file_existed:
|
|
88
|
+
path.chmod(0o600)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_meta(raw: dict[str, Any], fallback_id: str, path: Path) -> SessionMeta:
|
|
92
|
+
data = {k: v for k, v in raw.items() if k != "type"}
|
|
93
|
+
data.setdefault("id", fallback_id)
|
|
94
|
+
return SessionMeta(path=path, **data)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_session(path: Path) -> tuple[SessionMeta, list[Message]]:
|
|
98
|
+
"""Load a session file, tolerating legacy files and malformed lines."""
|
|
99
|
+
meta: SessionMeta | None = None
|
|
100
|
+
messages: list[Message] = []
|
|
101
|
+
with path.open("r", encoding="utf-8") as f:
|
|
102
|
+
for line in f:
|
|
103
|
+
line = line.strip()
|
|
104
|
+
if not line:
|
|
105
|
+
continue
|
|
106
|
+
try:
|
|
107
|
+
record = json.loads(line)
|
|
108
|
+
except json.JSONDecodeError:
|
|
109
|
+
continue
|
|
110
|
+
if record.get("type") == META_TYPE:
|
|
111
|
+
meta = _parse_meta(record, path.stem, path)
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
messages.append(Message(**record))
|
|
115
|
+
except ValueError:
|
|
116
|
+
continue
|
|
117
|
+
if meta is None:
|
|
118
|
+
# Legacy file without a meta line.
|
|
119
|
+
meta = SessionMeta(id=path.stem, path=path)
|
|
120
|
+
return meta, messages
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _read_meta_line(path: Path) -> SessionMeta:
|
|
124
|
+
"""Read only the first line of a session file (O(1) listing)."""
|
|
125
|
+
try:
|
|
126
|
+
with path.open("r", encoding="utf-8") as f:
|
|
127
|
+
first = f.readline().strip()
|
|
128
|
+
except OSError:
|
|
129
|
+
first = ""
|
|
130
|
+
if first:
|
|
131
|
+
try:
|
|
132
|
+
record = json.loads(first)
|
|
133
|
+
if record.get("type") == META_TYPE:
|
|
134
|
+
return _parse_meta(record, path.stem, path)
|
|
135
|
+
except (json.JSONDecodeError, ValueError):
|
|
136
|
+
pass
|
|
137
|
+
# Legacy file: fall back to stem + mtime for sorting.
|
|
138
|
+
mtime = path.stat().st_mtime
|
|
139
|
+
updated = datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat(
|
|
140
|
+
timespec="seconds"
|
|
141
|
+
)
|
|
142
|
+
return SessionMeta(id=path.stem, updated_at=updated, path=path)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def list_sessions(
|
|
146
|
+
session_dir: Path, workdir: Path | None = None
|
|
147
|
+
) -> list[SessionMeta]:
|
|
148
|
+
"""List sessions, most recently updated first.
|
|
149
|
+
|
|
150
|
+
When ``workdir`` is given, only sessions recorded for that directory are
|
|
151
|
+
returned.
|
|
152
|
+
"""
|
|
153
|
+
if not session_dir.is_dir():
|
|
154
|
+
return []
|
|
155
|
+
wanted = str(workdir.resolve()) if workdir is not None else None
|
|
156
|
+
sessions: list[SessionMeta] = []
|
|
157
|
+
for path in session_dir.glob("*.jsonl"):
|
|
158
|
+
try:
|
|
159
|
+
meta = _read_meta_line(path)
|
|
160
|
+
except OSError:
|
|
161
|
+
continue
|
|
162
|
+
if wanted is not None and meta.workdir != wanted:
|
|
163
|
+
continue
|
|
164
|
+
sessions.append(meta)
|
|
165
|
+
sessions.sort(key=lambda m: m.updated_at, reverse=True)
|
|
166
|
+
return sessions
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def latest_session(
|
|
170
|
+
session_dir: Path, workdir: Path | None = None
|
|
171
|
+
) -> Path | None:
|
|
172
|
+
"""Return the path of the most recently updated session, if any."""
|
|
173
|
+
sessions = list_sessions(session_dir, workdir=workdir)
|
|
174
|
+
if not sessions:
|
|
175
|
+
return None
|
|
176
|
+
return sessions[0].path
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def find_session(session_dir: Path, id_prefix: str) -> Path:
|
|
180
|
+
"""Find a session by exact id or unique id prefix."""
|
|
181
|
+
if not session_dir.is_dir():
|
|
182
|
+
raise SessionNotFoundError(f"No sessions in {session_dir}")
|
|
183
|
+
matches = sorted(p.stem for p in session_dir.glob(f"{id_prefix}*.jsonl"))
|
|
184
|
+
if not matches:
|
|
185
|
+
raise SessionNotFoundError(f"No session matching '{id_prefix}'")
|
|
186
|
+
if len(matches) > 1:
|
|
187
|
+
raise AmbiguousSessionError(id_prefix, matches)
|
|
188
|
+
return session_dir / f"{matches[0]}.jsonl"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def export_markdown(
|
|
192
|
+
meta: SessionMeta, messages: list[Message], path: Path
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Export the conversation (user/assistant text) as a Markdown file."""
|
|
195
|
+
lines = [
|
|
196
|
+
f"# {meta.title or meta.id}",
|
|
197
|
+
"",
|
|
198
|
+
f"- workdir: `{meta.workdir}`",
|
|
199
|
+
f"- model: `{meta.model}`",
|
|
200
|
+
f"- updated: {meta.updated_at}",
|
|
201
|
+
"",
|
|
202
|
+
]
|
|
203
|
+
for msg in messages:
|
|
204
|
+
if msg.role == "user" and msg.content:
|
|
205
|
+
lines += ["## User", "", msg.content, ""]
|
|
206
|
+
elif msg.role == "assistant" and msg.content:
|
|
207
|
+
lines += ["## Assistant", "", msg.content, ""]
|
|
208
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
path.write_text("\n".join(lines), encoding="utf-8")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def export_jsonl(
|
|
213
|
+
meta: SessionMeta,
|
|
214
|
+
messages: list[Message],
|
|
215
|
+
path: Path,
|
|
216
|
+
trace_path: Path | None = None,
|
|
217
|
+
) -> None:
|
|
218
|
+
"""Export the full-fidelity session log as a single JSONL file.
|
|
219
|
+
|
|
220
|
+
Layout:
|
|
221
|
+
|
|
222
|
+
1. a ``meta`` record (session metadata + export timestamp),
|
|
223
|
+
2. every trace record in chronological order (LLM request bodies,
|
|
224
|
+
usage, tool calls/results, errors) when the trace
|
|
225
|
+
file exists — otherwise the raw conversation messages,
|
|
226
|
+
3. a final ``messages_snapshot`` record with the exact message history
|
|
227
|
+
as persisted.
|
|
228
|
+
"""
|
|
229
|
+
meta_line = json.dumps(
|
|
230
|
+
{
|
|
231
|
+
"type": META_TYPE,
|
|
232
|
+
**meta.model_dump(exclude={"path"}),
|
|
233
|
+
"exported_at": _utc_now_iso(),
|
|
234
|
+
},
|
|
235
|
+
ensure_ascii=False,
|
|
236
|
+
)
|
|
237
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
with path.open("w", encoding="utf-8") as f:
|
|
239
|
+
f.write(meta_line + "\n")
|
|
240
|
+
trace_records = read_trace(trace_path) if trace_path is not None else []
|
|
241
|
+
if trace_records:
|
|
242
|
+
for record in trace_records:
|
|
243
|
+
f.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
244
|
+
else:
|
|
245
|
+
# Sessions from before tracing existed still export their messages.
|
|
246
|
+
for msg in messages:
|
|
247
|
+
f.write(msg.model_dump_json() + "\n")
|
|
248
|
+
snapshot = json.dumps(
|
|
249
|
+
{
|
|
250
|
+
"type": SNAPSHOT_TYPE,
|
|
251
|
+
"count": len(messages),
|
|
252
|
+
"messages": [
|
|
253
|
+
json.loads(msg.model_dump_json()) for msg in messages
|
|
254
|
+
],
|
|
255
|
+
},
|
|
256
|
+
ensure_ascii=False,
|
|
257
|
+
)
|
|
258
|
+
f.write(snapshot + "\n")
|