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.
Files changed (104) hide show
  1. kiwimatecoder/__init__.py +8 -0
  2. kiwimatecoder/__main__.py +12 -0
  3. kiwimatecoder/acp/__init__.py +13 -0
  4. kiwimatecoder/acp/protocol.py +115 -0
  5. kiwimatecoder/acp/server.py +823 -0
  6. kiwimatecoder/acp/stdio.py +32 -0
  7. kiwimatecoder/agent.py +1162 -0
  8. kiwimatecoder/ai.py +68 -0
  9. kiwimatecoder/audit.py +65 -0
  10. kiwimatecoder/browser.py +261 -0
  11. kiwimatecoder/catalog.py +445 -0
  12. kiwimatecoder/checkpoints.py +103 -0
  13. kiwimatecoder/client.py +820 -0
  14. kiwimatecoder/clipboard.py +99 -0
  15. kiwimatecoder/commands.py +6757 -0
  16. kiwimatecoder/config.py +5335 -0
  17. kiwimatecoder/diagnostics.py +295 -0
  18. kiwimatecoder/documents.py +299 -0
  19. kiwimatecoder/evals/__init__.py +39 -0
  20. kiwimatecoder/evals/cases.py +230 -0
  21. kiwimatecoder/evals/render.py +46 -0
  22. kiwimatecoder/evals/runner.py +319 -0
  23. kiwimatecoder/events.py +95 -0
  24. kiwimatecoder/forge.py +172 -0
  25. kiwimatecoder/git.py +164 -0
  26. kiwimatecoder/headless.py +255 -0
  27. kiwimatecoder/hooks.py +170 -0
  28. kiwimatecoder/hunks.py +260 -0
  29. kiwimatecoder/i18n.py +368 -0
  30. kiwimatecoder/images.py +195 -0
  31. kiwimatecoder/index/__init__.py +14 -0
  32. kiwimatecoder/index/builder.py +376 -0
  33. kiwimatecoder/index/embeddings.py +151 -0
  34. kiwimatecoder/index/search.py +247 -0
  35. kiwimatecoder/index/store.py +251 -0
  36. kiwimatecoder/instructions.py +83 -0
  37. kiwimatecoder/jobs.py +594 -0
  38. kiwimatecoder/lsp/__init__.py +56 -0
  39. kiwimatecoder/lsp/client.py +548 -0
  40. kiwimatecoder/lsp/manager.py +353 -0
  41. kiwimatecoder/lsp/protocol.py +79 -0
  42. kiwimatecoder/main.py +4258 -0
  43. kiwimatecoder/mcp/__init__.py +45 -0
  44. kiwimatecoder/mcp/client.py +385 -0
  45. kiwimatecoder/mcp/errors.py +7 -0
  46. kiwimatecoder/mcp/manager.py +253 -0
  47. kiwimatecoder/mcp/protocol.py +132 -0
  48. kiwimatecoder/media.py +904 -0
  49. kiwimatecoder/memory.py +141 -0
  50. kiwimatecoder/network.py +112 -0
  51. kiwimatecoder/notify.py +139 -0
  52. kiwimatecoder/permissions.py +171 -0
  53. kiwimatecoder/pickers.py +417 -0
  54. kiwimatecoder/plugins.py +245 -0
  55. kiwimatecoder/pricing.py +167 -0
  56. kiwimatecoder/prompts.py +213 -0
  57. kiwimatecoder/providers.py +342 -0
  58. kiwimatecoder/redaction.py +66 -0
  59. kiwimatecoder/remote.py +294 -0
  60. kiwimatecoder/repl.py +1388 -0
  61. kiwimatecoder/routing.py +65 -0
  62. kiwimatecoder/sandbox.py +169 -0
  63. kiwimatecoder/sdk.py +146 -0
  64. kiwimatecoder/session.py +574 -0
  65. kiwimatecoder/share.py +386 -0
  66. kiwimatecoder/shell.py +660 -0
  67. kiwimatecoder/skills.py +138 -0
  68. kiwimatecoder/sync.py +513 -0
  69. kiwimatecoder/team.py +249 -0
  70. kiwimatecoder/telemetry.py +392 -0
  71. kiwimatecoder/templates.py +108 -0
  72. kiwimatecoder/tools/__init__.py +190 -0
  73. kiwimatecoder/tools/ask.py +62 -0
  74. kiwimatecoder/tools/base.py +69 -0
  75. kiwimatecoder/tools/browser.py +261 -0
  76. kiwimatecoder/tools/edit_file.py +143 -0
  77. kiwimatecoder/tools/forge.py +207 -0
  78. kiwimatecoder/tools/git.py +158 -0
  79. kiwimatecoder/tools/image.py +300 -0
  80. kiwimatecoder/tools/io.py +346 -0
  81. kiwimatecoder/tools/list_dir.py +67 -0
  82. kiwimatecoder/tools/lsp.py +254 -0
  83. kiwimatecoder/tools/memory.py +115 -0
  84. kiwimatecoder/tools/paths.py +234 -0
  85. kiwimatecoder/tools/read_file.py +113 -0
  86. kiwimatecoder/tools/registry.py +91 -0
  87. kiwimatecoder/tools/run_bash.py +189 -0
  88. kiwimatecoder/tools/search.py +224 -0
  89. kiwimatecoder/tools/selection.py +108 -0
  90. kiwimatecoder/tools/shell.py +158 -0
  91. kiwimatecoder/tools/skill.py +46 -0
  92. kiwimatecoder/tools/task.py +61 -0
  93. kiwimatecoder/tools/todo.py +77 -0
  94. kiwimatecoder/tools/web.py +146 -0
  95. kiwimatecoder/tools/write_file.py +83 -0
  96. kiwimatecoder/ui.py +189 -0
  97. kiwimatecoder/updater.py +502 -0
  98. kiwimatecoder/web.py +499 -0
  99. kiwimatecoder-0.1.0.dist-info/METADATA +2016 -0
  100. kiwimatecoder-0.1.0.dist-info/RECORD +104 -0
  101. kiwimatecoder-0.1.0.dist-info/WHEEL +5 -0
  102. kiwimatecoder-0.1.0.dist-info/entry_points.txt +2 -0
  103. kiwimatecoder-0.1.0.dist-info/licenses/LICENSE +21 -0
  104. kiwimatecoder-0.1.0.dist-info/top_level.txt +1 -0
@@ -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