kiwimatecoder 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.
- kiwimatecoder/__init__.py +8 -0
- kiwimatecoder/__main__.py +12 -0
- kiwimatecoder/acp/__init__.py +13 -0
- kiwimatecoder/acp/protocol.py +115 -0
- kiwimatecoder/acp/server.py +823 -0
- kiwimatecoder/acp/stdio.py +32 -0
- kiwimatecoder/agent.py +1162 -0
- kiwimatecoder/ai.py +68 -0
- kiwimatecoder/audit.py +65 -0
- kiwimatecoder/browser.py +261 -0
- kiwimatecoder/catalog.py +445 -0
- kiwimatecoder/checkpoints.py +103 -0
- kiwimatecoder/client.py +820 -0
- kiwimatecoder/clipboard.py +99 -0
- kiwimatecoder/commands.py +6757 -0
- kiwimatecoder/config.py +5335 -0
- kiwimatecoder/diagnostics.py +295 -0
- kiwimatecoder/documents.py +299 -0
- kiwimatecoder/evals/__init__.py +39 -0
- kiwimatecoder/evals/cases.py +230 -0
- kiwimatecoder/evals/render.py +46 -0
- kiwimatecoder/evals/runner.py +319 -0
- kiwimatecoder/events.py +95 -0
- kiwimatecoder/forge.py +172 -0
- kiwimatecoder/git.py +164 -0
- kiwimatecoder/headless.py +255 -0
- kiwimatecoder/hooks.py +170 -0
- kiwimatecoder/hunks.py +260 -0
- kiwimatecoder/i18n.py +368 -0
- kiwimatecoder/images.py +195 -0
- kiwimatecoder/index/__init__.py +14 -0
- kiwimatecoder/index/builder.py +376 -0
- kiwimatecoder/index/embeddings.py +151 -0
- kiwimatecoder/index/search.py +247 -0
- kiwimatecoder/index/store.py +251 -0
- kiwimatecoder/instructions.py +83 -0
- kiwimatecoder/jobs.py +594 -0
- kiwimatecoder/lsp/__init__.py +56 -0
- kiwimatecoder/lsp/client.py +548 -0
- kiwimatecoder/lsp/manager.py +353 -0
- kiwimatecoder/lsp/protocol.py +79 -0
- kiwimatecoder/main.py +4258 -0
- kiwimatecoder/mcp/__init__.py +45 -0
- kiwimatecoder/mcp/client.py +385 -0
- kiwimatecoder/mcp/errors.py +7 -0
- kiwimatecoder/mcp/manager.py +253 -0
- kiwimatecoder/mcp/protocol.py +132 -0
- kiwimatecoder/media.py +904 -0
- kiwimatecoder/memory.py +141 -0
- kiwimatecoder/network.py +112 -0
- kiwimatecoder/notify.py +139 -0
- kiwimatecoder/permissions.py +171 -0
- kiwimatecoder/pickers.py +417 -0
- kiwimatecoder/plugins.py +245 -0
- kiwimatecoder/pricing.py +167 -0
- kiwimatecoder/prompts.py +213 -0
- kiwimatecoder/providers.py +342 -0
- kiwimatecoder/redaction.py +66 -0
- kiwimatecoder/remote.py +294 -0
- kiwimatecoder/repl.py +1388 -0
- kiwimatecoder/routing.py +65 -0
- kiwimatecoder/sandbox.py +169 -0
- kiwimatecoder/sdk.py +146 -0
- kiwimatecoder/session.py +574 -0
- kiwimatecoder/share.py +386 -0
- kiwimatecoder/shell.py +660 -0
- kiwimatecoder/skills.py +138 -0
- kiwimatecoder/sync.py +513 -0
- kiwimatecoder/team.py +249 -0
- kiwimatecoder/telemetry.py +392 -0
- kiwimatecoder/templates.py +108 -0
- kiwimatecoder/tools/__init__.py +190 -0
- kiwimatecoder/tools/ask.py +62 -0
- kiwimatecoder/tools/base.py +69 -0
- kiwimatecoder/tools/browser.py +261 -0
- kiwimatecoder/tools/edit_file.py +143 -0
- kiwimatecoder/tools/forge.py +207 -0
- kiwimatecoder/tools/git.py +158 -0
- kiwimatecoder/tools/image.py +300 -0
- kiwimatecoder/tools/io.py +346 -0
- kiwimatecoder/tools/list_dir.py +67 -0
- kiwimatecoder/tools/lsp.py +254 -0
- kiwimatecoder/tools/memory.py +115 -0
- kiwimatecoder/tools/paths.py +234 -0
- kiwimatecoder/tools/read_file.py +113 -0
- kiwimatecoder/tools/registry.py +91 -0
- kiwimatecoder/tools/run_bash.py +189 -0
- kiwimatecoder/tools/search.py +224 -0
- kiwimatecoder/tools/selection.py +108 -0
- kiwimatecoder/tools/shell.py +158 -0
- kiwimatecoder/tools/skill.py +46 -0
- kiwimatecoder/tools/task.py +61 -0
- kiwimatecoder/tools/todo.py +77 -0
- kiwimatecoder/tools/web.py +146 -0
- kiwimatecoder/tools/write_file.py +83 -0
- kiwimatecoder/ui.py +189 -0
- kiwimatecoder/updater.py +502 -0
- kiwimatecoder/web.py +499 -0
- kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
- kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
- kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
- kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
- kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
- kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
kiwimatecoder/session.py
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
"""Mutable runtime state for an interactive session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from collections import deque
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from kiwimatecoder.checkpoints import Checkpoint, CheckpointStore
|
|
15
|
+
from kiwimatecoder.config import (
|
|
16
|
+
ensure_config_dir,
|
|
17
|
+
get_provider_config,
|
|
18
|
+
resolve_model,
|
|
19
|
+
)
|
|
20
|
+
from kiwimatecoder.permissions import PermissionMode
|
|
21
|
+
from kiwimatecoder.pricing import estimate_messages_tokens
|
|
22
|
+
from kiwimatecoder.providers import ProviderConfig
|
|
23
|
+
from kiwimatecoder.shell import ShellManager
|
|
24
|
+
|
|
25
|
+
SESSION_FORMAT_VERSION = 2
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Session:
|
|
30
|
+
"""All mutable state for one REPL session."""
|
|
31
|
+
|
|
32
|
+
provider_id: str
|
|
33
|
+
model: str
|
|
34
|
+
mode: PermissionMode = PermissionMode.ASK
|
|
35
|
+
workspace_root: Path = field(default_factory=Path.cwd)
|
|
36
|
+
messages: list[dict[str, Any]] = field(default_factory=list)
|
|
37
|
+
prompt_tokens: int = 0
|
|
38
|
+
completion_tokens: int = 0
|
|
39
|
+
touched_files: list[str] = field(default_factory=list)
|
|
40
|
+
context_files: list[str] = field(default_factory=list)
|
|
41
|
+
always_allowed: set[str] = field(default_factory=set)
|
|
42
|
+
active_provider_ids: list[str] = field(default_factory=list)
|
|
43
|
+
# Per-provider model overrides for fallbacks; without one, a fallback uses
|
|
44
|
+
# the model the user chose for it in config (see ``model_for``).
|
|
45
|
+
models: dict[str, str] = field(default_factory=dict)
|
|
46
|
+
# Output style name and a user-supplied system-prompt addition.
|
|
47
|
+
output_style: str = "default"
|
|
48
|
+
custom_system_prompt: str | None = None
|
|
49
|
+
# Command allow/deny regexes (see permissions.gate).
|
|
50
|
+
command_rules: dict[str, list[str]] = field(default_factory=dict)
|
|
51
|
+
# Show actions without running them when True.
|
|
52
|
+
dry_run: bool = False
|
|
53
|
+
# Whether reads outside the workspace are permitted.
|
|
54
|
+
trusted_workspace: bool = False
|
|
55
|
+
# Command run automatically after successful file edits (empty disables).
|
|
56
|
+
verify_command: str = ""
|
|
57
|
+
# History is trimmed before a request once it exceeds this estimate.
|
|
58
|
+
compact_at_tokens: int = 64000
|
|
59
|
+
# Approximate model context window for gauges.
|
|
60
|
+
context_window: int = 128000
|
|
61
|
+
# Agent-maintained task list.
|
|
62
|
+
todos: list[dict[str, Any]] = field(default_factory=list)
|
|
63
|
+
# File snapshots for /undo (in-memory; cleared when the process exits).
|
|
64
|
+
checkpoints: list[Checkpoint] = field(default_factory=list)
|
|
65
|
+
checkpoint_store: CheckpointStore | None = field(default=None, repr=False)
|
|
66
|
+
# Injected by the REPL so the ask_user tool can prompt interactively.
|
|
67
|
+
ask_user: Callable[[str, list[str]], str] | None = field(default=None, repr=False)
|
|
68
|
+
# True for sessions created by the task tool (runtime-only).
|
|
69
|
+
subagent: bool = field(default=False, repr=False)
|
|
70
|
+
# Runtime-only queues: lines typed while a turn is streaming (never saved).
|
|
71
|
+
steering: deque[str] = field(default_factory=deque, repr=False)
|
|
72
|
+
deferred_commands: deque[str] = field(default_factory=deque, repr=False)
|
|
73
|
+
# Images attached via @path or the view_image tool, awaiting the next
|
|
74
|
+
# request. Ephemeral: cleared after being attached to the conversation.
|
|
75
|
+
pending_images: list[dict[str, Any]] = field(default_factory=list, repr=False)
|
|
76
|
+
# Persistent shell + background processes (runtime-only, never saved).
|
|
77
|
+
shell: ShellManager | None = field(default=None, repr=False)
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def format_version(self) -> int:
|
|
81
|
+
return SESSION_FORMAT_VERSION
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def provider(self) -> ProviderConfig:
|
|
85
|
+
return get_provider_config(self.provider_id)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def active_providers(self) -> list[ProviderConfig]:
|
|
89
|
+
"""The active provider roster, primary first.
|
|
90
|
+
|
|
91
|
+
Falls back to the session's own provider when the roster is empty so a
|
|
92
|
+
resumed pre-feature session cannot pick up a different vendor from
|
|
93
|
+
today's config. Always resolves to at least one provider.
|
|
94
|
+
"""
|
|
95
|
+
ids = self.active_provider_ids or [self.provider_id]
|
|
96
|
+
providers: list[ProviderConfig] = []
|
|
97
|
+
seen: set[str] = set()
|
|
98
|
+
for pid in ids:
|
|
99
|
+
if pid in seen:
|
|
100
|
+
continue
|
|
101
|
+
seen.add(pid)
|
|
102
|
+
try:
|
|
103
|
+
providers.append(get_provider_config(pid))
|
|
104
|
+
except KeyError:
|
|
105
|
+
continue
|
|
106
|
+
return providers or [self.provider]
|
|
107
|
+
|
|
108
|
+
def model_for(self, provider_id: str) -> str:
|
|
109
|
+
"""Return the model to use for ``provider_id``, or ``""`` if none was chosen.
|
|
110
|
+
|
|
111
|
+
The primary provider uses ``session.model``; fallback providers use any
|
|
112
|
+
per-provider override, otherwise the model the user chose for them
|
|
113
|
+
(resolved live for local servers). There are no default models, so an
|
|
114
|
+
unconfigured fallback yields ``""`` and the agent skips it.
|
|
115
|
+
"""
|
|
116
|
+
if provider_id == self.provider_id:
|
|
117
|
+
return self.model
|
|
118
|
+
override = self.models.get(provider_id)
|
|
119
|
+
if override:
|
|
120
|
+
return override
|
|
121
|
+
provider = get_provider_config(provider_id)
|
|
122
|
+
return resolve_model(provider)
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def total_tokens(self) -> int:
|
|
126
|
+
return self.prompt_tokens + self.completion_tokens
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def estimated_history_tokens(self) -> int:
|
|
130
|
+
return estimate_messages_tokens(self.messages)
|
|
131
|
+
|
|
132
|
+
def set_provider(self, provider_id: str, model: str | None = None) -> None:
|
|
133
|
+
"""Switch provider, using ``model`` or the model chosen for it.
|
|
134
|
+
|
|
135
|
+
The model is ``""`` when none was ever chosen for the provider (local
|
|
136
|
+
servers resolve theirs live, see ``config.resolve_model``); interactive
|
|
137
|
+
callers then offer the model picker.
|
|
138
|
+
"""
|
|
139
|
+
provider = get_provider_config(provider_id)
|
|
140
|
+
self.provider_id = provider_id
|
|
141
|
+
self.model = model or resolve_model(provider)
|
|
142
|
+
# Tool approvals are persisted user preferences, so they survive both
|
|
143
|
+
# provider switches and restarts.
|
|
144
|
+
|
|
145
|
+
def set_active_providers(self, provider_ids: list[str]) -> None:
|
|
146
|
+
"""Set the active-provider roster; the first id becomes the primary.
|
|
147
|
+
|
|
148
|
+
``provider_ids`` must be a non-empty list of known providers. The
|
|
149
|
+
primary's model is resolved (or kept when unchanged), and fallback
|
|
150
|
+
providers use the models chosen for them (see ``model_for``).
|
|
151
|
+
"""
|
|
152
|
+
if not provider_ids:
|
|
153
|
+
raise ValueError("At least one active provider is required.")
|
|
154
|
+
cleaned: list[str] = []
|
|
155
|
+
seen: set[str] = set()
|
|
156
|
+
for pid in provider_ids:
|
|
157
|
+
if pid in seen:
|
|
158
|
+
continue
|
|
159
|
+
get_provider_config(pid) # raises UnknownProviderError (a KeyError)
|
|
160
|
+
seen.add(pid)
|
|
161
|
+
cleaned.append(pid)
|
|
162
|
+
if not cleaned:
|
|
163
|
+
raise ValueError("At least one active provider is required.")
|
|
164
|
+
previous_primary = self.provider_id
|
|
165
|
+
self.active_provider_ids = cleaned
|
|
166
|
+
if cleaned[0] != previous_primary:
|
|
167
|
+
self.set_provider(cleaned[0])
|
|
168
|
+
|
|
169
|
+
def record_touched(self, path: str) -> None:
|
|
170
|
+
if path not in self.touched_files:
|
|
171
|
+
self.touched_files.append(path)
|
|
172
|
+
|
|
173
|
+
def checkpoint(self, paths: list[str], label: str) -> Checkpoint | None:
|
|
174
|
+
"""Snapshot ``paths`` before a mutation so it can be undone."""
|
|
175
|
+
if self.checkpoint_store is None:
|
|
176
|
+
self.checkpoint_store = CheckpointStore()
|
|
177
|
+
try:
|
|
178
|
+
captured = self.checkpoint_store.capture(self.workspace_root, paths, label)
|
|
179
|
+
except OSError:
|
|
180
|
+
return None
|
|
181
|
+
self.checkpoints.append(captured)
|
|
182
|
+
return captured
|
|
183
|
+
|
|
184
|
+
def undo_checkpoints(self, count: int = 1) -> list[Checkpoint]:
|
|
185
|
+
"""Restore the most recent ``count`` checkpoints and drop them.
|
|
186
|
+
|
|
187
|
+
Snapshots are restored newest-first so the oldest selected checkpoint
|
|
188
|
+
wins, which rewinds the workspace to just before that action.
|
|
189
|
+
"""
|
|
190
|
+
if not self.checkpoints:
|
|
191
|
+
return []
|
|
192
|
+
count = max(1, min(count, len(self.checkpoints)))
|
|
193
|
+
selected = self.checkpoints[-count:]
|
|
194
|
+
if self.checkpoint_store is not None:
|
|
195
|
+
for item in reversed(selected):
|
|
196
|
+
self.checkpoint_store.restore(self.workspace_root, item)
|
|
197
|
+
self.checkpoints = self.checkpoints[: len(self.checkpoints) - count]
|
|
198
|
+
return selected
|
|
199
|
+
|
|
200
|
+
def add_context_file(self, path: str) -> bool:
|
|
201
|
+
"""Track a workspace-relative file as pinned context.
|
|
202
|
+
|
|
203
|
+
Returns True when the file was newly added and False when it was already
|
|
204
|
+
present. The caller is responsible for resolving and validating paths.
|
|
205
|
+
"""
|
|
206
|
+
if path in self.context_files:
|
|
207
|
+
return False
|
|
208
|
+
self.context_files.append(path)
|
|
209
|
+
return True
|
|
210
|
+
|
|
211
|
+
def remove_context_file(self, path: str) -> bool:
|
|
212
|
+
"""Remove a pinned context file, returning whether anything changed."""
|
|
213
|
+
try:
|
|
214
|
+
self.context_files.remove(path)
|
|
215
|
+
except ValueError:
|
|
216
|
+
return False
|
|
217
|
+
return True
|
|
218
|
+
|
|
219
|
+
def clear_context_files(self) -> int:
|
|
220
|
+
"""Remove all pinned context files and return the number removed."""
|
|
221
|
+
count = len(self.context_files)
|
|
222
|
+
self.context_files = []
|
|
223
|
+
return count
|
|
224
|
+
|
|
225
|
+
def is_always_allowed(self, tool_name: str) -> bool:
|
|
226
|
+
return tool_name in self.always_allowed
|
|
227
|
+
|
|
228
|
+
def allow_always(self, tool_name: str) -> None:
|
|
229
|
+
self.always_allowed.add(tool_name)
|
|
230
|
+
|
|
231
|
+
def add_usage(self, prompt_tokens: int, completion_tokens: int) -> None:
|
|
232
|
+
self.prompt_tokens += prompt_tokens
|
|
233
|
+
self.completion_tokens += completion_tokens
|
|
234
|
+
|
|
235
|
+
def reset_history(self) -> None:
|
|
236
|
+
"""Clear conversation history (the system prompt is rebuilt per turn)."""
|
|
237
|
+
self.messages = []
|
|
238
|
+
|
|
239
|
+
def trim_history(self, max_tokens: int = 64_000) -> int:
|
|
240
|
+
"""Prune older conversation turns if token budget is exceeded.
|
|
241
|
+
|
|
242
|
+
Preserves the initial user message and full turn boundaries so tool calls
|
|
243
|
+
and tool results are never decoupled.
|
|
244
|
+
"""
|
|
245
|
+
if len(self.messages) <= 4:
|
|
246
|
+
return 0
|
|
247
|
+
|
|
248
|
+
current_tokens = self.estimated_history_tokens
|
|
249
|
+
if current_tokens <= max_tokens:
|
|
250
|
+
return 0
|
|
251
|
+
|
|
252
|
+
turns: list[list[dict[str, Any]]] = []
|
|
253
|
+
current_turn: list[dict[str, Any]] = []
|
|
254
|
+
for msg in self.messages:
|
|
255
|
+
if msg.get("role") == "user" and not (
|
|
256
|
+
isinstance(msg.get("content"), list)
|
|
257
|
+
and any(
|
|
258
|
+
isinstance(b, dict) and b.get("type") == "tool_result"
|
|
259
|
+
for b in msg["content"]
|
|
260
|
+
)
|
|
261
|
+
):
|
|
262
|
+
if current_turn:
|
|
263
|
+
turns.append(current_turn)
|
|
264
|
+
current_turn = [msg]
|
|
265
|
+
else:
|
|
266
|
+
current_turn.append(msg)
|
|
267
|
+
if current_turn:
|
|
268
|
+
turns.append(current_turn)
|
|
269
|
+
|
|
270
|
+
if len(turns) <= 2:
|
|
271
|
+
return 0
|
|
272
|
+
|
|
273
|
+
original_count = len(self.messages)
|
|
274
|
+
# Drop oldest intermediate turns starting from index 1
|
|
275
|
+
while len(turns) > 2 and current_tokens > max_tokens:
|
|
276
|
+
dropped_turn = turns.pop(1)
|
|
277
|
+
dropped_tokens = estimate_messages_tokens(dropped_turn)
|
|
278
|
+
current_tokens -= dropped_tokens
|
|
279
|
+
|
|
280
|
+
new_messages: list[dict[str, Any]] = []
|
|
281
|
+
for turn in turns:
|
|
282
|
+
new_messages.extend(turn)
|
|
283
|
+
self.messages = new_messages
|
|
284
|
+
return original_count - len(new_messages)
|
|
285
|
+
|
|
286
|
+
def compact(self, target_tokens: int | None = None) -> dict[str, int]:
|
|
287
|
+
"""Trim history to a token budget; returns before/after estimates."""
|
|
288
|
+
before = self.estimated_history_tokens
|
|
289
|
+
budget = max(1, target_tokens or self.compact_at_tokens)
|
|
290
|
+
self.trim_history(max_tokens=budget)
|
|
291
|
+
return {
|
|
292
|
+
"before": before,
|
|
293
|
+
"after": self.estimated_history_tokens,
|
|
294
|
+
"budget": budget,
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
def to_dict(self) -> dict[str, Any]:
|
|
298
|
+
"""Serialize session state for persistence."""
|
|
299
|
+
return {
|
|
300
|
+
"format_version": SESSION_FORMAT_VERSION,
|
|
301
|
+
"provider_id": self.provider_id,
|
|
302
|
+
"model": self.model,
|
|
303
|
+
"mode": self.mode.value,
|
|
304
|
+
"workspace_root": str(self.workspace_root),
|
|
305
|
+
"messages": self.messages,
|
|
306
|
+
"prompt_tokens": self.prompt_tokens,
|
|
307
|
+
"completion_tokens": self.completion_tokens,
|
|
308
|
+
"touched_files": self.touched_files,
|
|
309
|
+
"context_files": self.context_files,
|
|
310
|
+
"always_allowed": sorted(self.always_allowed),
|
|
311
|
+
"active_provider_ids": self.active_provider_ids,
|
|
312
|
+
"models": self.models,
|
|
313
|
+
"output_style": self.output_style,
|
|
314
|
+
"custom_system_prompt": self.custom_system_prompt,
|
|
315
|
+
"command_rules": self.command_rules,
|
|
316
|
+
"dry_run": self.dry_run,
|
|
317
|
+
"trusted_workspace": self.trusted_workspace,
|
|
318
|
+
"verify_command": self.verify_command,
|
|
319
|
+
"compact_at_tokens": self.compact_at_tokens,
|
|
320
|
+
"context_window": self.context_window,
|
|
321
|
+
"todos": self.todos,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
def from_dict(cls, data: dict[str, Any]) -> Session:
|
|
326
|
+
"""Deserialize session state."""
|
|
327
|
+
provider_id = str(data.get("provider_id", "openrouter"))
|
|
328
|
+
raw_active = data.get("active_provider_ids") or []
|
|
329
|
+
active_provider_ids = [
|
|
330
|
+
str(pid) for pid in raw_active if str(pid).strip()
|
|
331
|
+
]
|
|
332
|
+
return cls(
|
|
333
|
+
provider_id=provider_id,
|
|
334
|
+
model=str(data.get("model", "")),
|
|
335
|
+
mode=PermissionMode.from_str(str(data.get("mode", "ask"))),
|
|
336
|
+
workspace_root=Path(str(data.get("workspace_root", "."))),
|
|
337
|
+
messages=list(data.get("messages", [])),
|
|
338
|
+
prompt_tokens=int(data.get("prompt_tokens", 0)),
|
|
339
|
+
completion_tokens=int(data.get("completion_tokens", 0)),
|
|
340
|
+
touched_files=list(data.get("touched_files", [])),
|
|
341
|
+
context_files=list(data.get("context_files", [])),
|
|
342
|
+
always_allowed={
|
|
343
|
+
str(name) for name in (data.get("always_allowed") or []) if str(name)
|
|
344
|
+
},
|
|
345
|
+
active_provider_ids=active_provider_ids or [provider_id],
|
|
346
|
+
models={
|
|
347
|
+
str(k): str(v) for k, v in (data.get("models") or {}).items()
|
|
348
|
+
},
|
|
349
|
+
output_style=str(data.get("output_style") or "default"),
|
|
350
|
+
custom_system_prompt=(
|
|
351
|
+
str(data["custom_system_prompt"])
|
|
352
|
+
if data.get("custom_system_prompt")
|
|
353
|
+
else None
|
|
354
|
+
),
|
|
355
|
+
command_rules={
|
|
356
|
+
str(kind): [str(pattern) for pattern in (patterns or []) if str(pattern)]
|
|
357
|
+
for kind, patterns in (data.get("command_rules") or {}).items()
|
|
358
|
+
},
|
|
359
|
+
dry_run=bool(data.get("dry_run", False)),
|
|
360
|
+
trusted_workspace=bool(data.get("trusted_workspace", False)),
|
|
361
|
+
verify_command=str(data.get("verify_command") or ""),
|
|
362
|
+
compact_at_tokens=int(data.get("compact_at_tokens") or 64000),
|
|
363
|
+
context_window=int(data.get("context_window") or 128000),
|
|
364
|
+
todos=[
|
|
365
|
+
dict(todo)
|
|
366
|
+
for todo in (data.get("todos") or [])
|
|
367
|
+
if isinstance(todo, dict)
|
|
368
|
+
],
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def apply_session_profile(session: Session, profile: dict[str, Any]) -> None:
|
|
373
|
+
"""Overlay a config profile onto a live session without persisting it.
|
|
374
|
+
|
|
375
|
+
Only keys present in ``profile`` are touched. Sampling and budget are
|
|
376
|
+
config-level settings (persist a profile with ``config profile use`` for
|
|
377
|
+
those); every other profile key maps to a session field.
|
|
378
|
+
"""
|
|
379
|
+
if "provider" in profile:
|
|
380
|
+
provider_id = str(profile["provider"])
|
|
381
|
+
if provider_id != session.provider_id:
|
|
382
|
+
session.set_active_providers([provider_id])
|
|
383
|
+
if profile.get("model"):
|
|
384
|
+
session.model = str(profile["model"])
|
|
385
|
+
if "mode" in profile:
|
|
386
|
+
try:
|
|
387
|
+
session.mode = PermissionMode.from_str(str(profile["mode"]))
|
|
388
|
+
except ValueError:
|
|
389
|
+
pass
|
|
390
|
+
if "output_style" in profile:
|
|
391
|
+
session.output_style = str(profile["output_style"])
|
|
392
|
+
if "system_prompt" in profile:
|
|
393
|
+
session.custom_system_prompt = profile["system_prompt"]
|
|
394
|
+
if "verify_command" in profile:
|
|
395
|
+
session.verify_command = str(profile["verify_command"])
|
|
396
|
+
if "trusted_workspace" in profile:
|
|
397
|
+
session.trusted_workspace = bool(profile["trusted_workspace"])
|
|
398
|
+
if "command_rules" in profile:
|
|
399
|
+
rules = profile["command_rules"]
|
|
400
|
+
session.command_rules = {
|
|
401
|
+
"allow": [str(pattern) for pattern in rules.get("allow") or []],
|
|
402
|
+
"deny": [str(pattern) for pattern in rules.get("deny") or []],
|
|
403
|
+
}
|
|
404
|
+
if "always_allowed" in profile:
|
|
405
|
+
session.always_allowed.update(
|
|
406
|
+
str(name) for name in profile["always_allowed"] if str(name).strip()
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _sessions_dir() -> Path:
|
|
411
|
+
s_dir = ensure_config_dir() / "sessions"
|
|
412
|
+
s_dir.mkdir(mode=0o700, exist_ok=True)
|
|
413
|
+
return s_dir
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# Name used by the implicit end-of-session autosave. ``--continue`` and
|
|
417
|
+
# ``/load last`` resolve to it (or to the newest saved session when it is gone).
|
|
418
|
+
AUTOSAVE_NAME = "last"
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def save_session(session: Session, name: str | None = None) -> Path:
|
|
422
|
+
"""Save session state to ~/.kiwimatecoder/sessions/<name>.json."""
|
|
423
|
+
if not name:
|
|
424
|
+
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
425
|
+
name = f"session_{ts}"
|
|
426
|
+
cleaned = re.sub(r"[^\w\-.]", "_", name.strip())
|
|
427
|
+
if not cleaned.endswith(".json"):
|
|
428
|
+
cleaned += ".json"
|
|
429
|
+
dest = _sessions_dir() / cleaned
|
|
430
|
+
data = session.to_dict()
|
|
431
|
+
data["saved_at"] = datetime.datetime.now().isoformat()
|
|
432
|
+
dest.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
|
433
|
+
return dest
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def load_session(
|
|
437
|
+
name_or_path: str,
|
|
438
|
+
workspace_root: Path | None = None,
|
|
439
|
+
*,
|
|
440
|
+
require_provider: bool = False,
|
|
441
|
+
) -> Session:
|
|
442
|
+
"""Load session state from name or path.
|
|
443
|
+
|
|
444
|
+
``"last"``/``"latest"`` resolve to the end-of-session autosave, falling
|
|
445
|
+
back to the newest saved session when that file is missing.
|
|
446
|
+
|
|
447
|
+
A session saved with a custom provider that has since been removed cannot
|
|
448
|
+
be continued: every request, ``/cost`` and ``/provider`` need that
|
|
449
|
+
provider's config. With ``require_provider`` such a session raises
|
|
450
|
+
``ValueError`` here, before the caller has applied any of it. Callers that
|
|
451
|
+
only read the conversation (``share create``) leave it off. Fallback
|
|
452
|
+
providers that are gone are not a problem: the roster skips them.
|
|
453
|
+
"""
|
|
454
|
+
target = Path(name_or_path)
|
|
455
|
+
if not target.is_file():
|
|
456
|
+
cleaned = name_or_path if name_or_path.endswith(".json") else f"{name_or_path}.json"
|
|
457
|
+
target = _sessions_dir() / cleaned
|
|
458
|
+
if (
|
|
459
|
+
not target.is_file()
|
|
460
|
+
and name_or_path.lower() in {AUTOSAVE_NAME, "latest"}
|
|
461
|
+
and (latest := latest_saved_session())
|
|
462
|
+
):
|
|
463
|
+
target = _sessions_dir() / latest
|
|
464
|
+
if not target.is_file():
|
|
465
|
+
raise FileNotFoundError(f"Session '{name_or_path}' not found.")
|
|
466
|
+
|
|
467
|
+
data: dict[str, Any] = json.loads(target.read_text(encoding="utf-8"))
|
|
468
|
+
sess = Session.from_dict(data)
|
|
469
|
+
if require_provider:
|
|
470
|
+
try:
|
|
471
|
+
get_provider_config(sess.provider_id)
|
|
472
|
+
except KeyError:
|
|
473
|
+
raise ValueError(
|
|
474
|
+
f"Session '{name_or_path}' uses provider '{sess.provider_id}', "
|
|
475
|
+
"which is not configured. Add it again with /config provider add "
|
|
476
|
+
"(same id), then load the session."
|
|
477
|
+
) from None
|
|
478
|
+
if workspace_root is not None:
|
|
479
|
+
sess.workspace_root = workspace_root
|
|
480
|
+
return sess
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def save_autosave(session: Session) -> Path | None:
|
|
484
|
+
"""Save the session as the implicit ``last`` autosave.
|
|
485
|
+
|
|
486
|
+
Empty sessions are not written so ``--continue`` keeps pointing at the most
|
|
487
|
+
recent session that actually had a conversation. Returns the written path,
|
|
488
|
+
or None when nothing was saved.
|
|
489
|
+
"""
|
|
490
|
+
if not session.messages:
|
|
491
|
+
return None
|
|
492
|
+
return save_session(session, AUTOSAVE_NAME)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def latest_saved_session() -> str | None:
|
|
496
|
+
"""Return the file name of the most recently saved session, if any."""
|
|
497
|
+
saved = list_saved_sessions()
|
|
498
|
+
if not saved:
|
|
499
|
+
return None
|
|
500
|
+
return str(saved[0]["file"])
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def fork_session(session: Session, name: str | None = None) -> Path:
|
|
504
|
+
"""Persist an independent copy of ``session`` under a new name."""
|
|
505
|
+
if not name:
|
|
506
|
+
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
507
|
+
name = f"fork_{stamp}"
|
|
508
|
+
return save_session(session, name)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def export_session_markdown(session: Session, max_tool_chars: int = 2000) -> str:
|
|
512
|
+
"""Render the conversation as a Markdown transcript."""
|
|
513
|
+
lines = [
|
|
514
|
+
"# KiwiMateCoder session",
|
|
515
|
+
"",
|
|
516
|
+
f"- Provider: `{session.provider_id}`",
|
|
517
|
+
f"- Model: `{session.model}`",
|
|
518
|
+
f"- Mode: `{session.mode.value}`",
|
|
519
|
+
f"- Messages: {len(session.messages)}",
|
|
520
|
+
f"- Tokens: {session.total_tokens:,}",
|
|
521
|
+
"",
|
|
522
|
+
]
|
|
523
|
+
for message in session.messages:
|
|
524
|
+
role = str(message.get("role") or "?")
|
|
525
|
+
content = message.get("content")
|
|
526
|
+
if isinstance(content, list):
|
|
527
|
+
text = "\n".join(str(block) for block in content)
|
|
528
|
+
else:
|
|
529
|
+
text = str(content or "")
|
|
530
|
+
if role == "tool":
|
|
531
|
+
clipped = text[:max_tool_chars]
|
|
532
|
+
if len(text) > max_tool_chars:
|
|
533
|
+
clipped += f"\n\n[truncated {len(text) - max_tool_chars} chars]"
|
|
534
|
+
lines.extend(["## Tool result", "", "```text", clipped, "```", ""])
|
|
535
|
+
continue
|
|
536
|
+
lines.extend([f"## {role.capitalize()}", "", text, ""])
|
|
537
|
+
for call in message.get("tool_calls") or []:
|
|
538
|
+
function = call.get("function") or {}
|
|
539
|
+
lines.extend(
|
|
540
|
+
[
|
|
541
|
+
f"- Tool call `{function.get('name', '?')}`:",
|
|
542
|
+
"",
|
|
543
|
+
"```json",
|
|
544
|
+
str(function.get("arguments") or "{}"),
|
|
545
|
+
"```",
|
|
546
|
+
"",
|
|
547
|
+
]
|
|
548
|
+
)
|
|
549
|
+
return "\n".join(lines).rstrip() + "\n"
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def list_saved_sessions() -> list[dict[str, Any]]:
|
|
553
|
+
"""List all saved sessions sorted newest first."""
|
|
554
|
+
s_dir = _sessions_dir()
|
|
555
|
+
results: list[dict[str, Any]] = []
|
|
556
|
+
for p in s_dir.glob("*.json"):
|
|
557
|
+
try:
|
|
558
|
+
data: dict[str, Any] = json.loads(p.read_text(encoding="utf-8"))
|
|
559
|
+
results.append(
|
|
560
|
+
{
|
|
561
|
+
"name": p.stem,
|
|
562
|
+
"file": p.name,
|
|
563
|
+
"provider": data.get("provider_id", ""),
|
|
564
|
+
"model": data.get("model", ""),
|
|
565
|
+
"saved_at": data.get("saved_at", ""),
|
|
566
|
+
"messages": len(data.get("messages", [])),
|
|
567
|
+
"tokens": int(data.get("prompt_tokens", 0))
|
|
568
|
+
+ int(data.get("completion_tokens", 0)),
|
|
569
|
+
}
|
|
570
|
+
)
|
|
571
|
+
except Exception:
|
|
572
|
+
continue
|
|
573
|
+
results.sort(key=lambda x: str(x.get("saved_at", "")), reverse=True)
|
|
574
|
+
return results
|