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,445 @@
1
+ """Live model catalogs fetched from provider APIs.
2
+
3
+ Model ids drift fast: providers ship new ones every few weeks and retire old
4
+ ones without warning. The curated tuples in :mod:`kiwimatecoder.providers` are
5
+ only a shipped-at-build-time starting point, so this module asks each provider
6
+ what it actually serves today via ``GET {base_url}/models`` and turns the answer
7
+ into the list ``/model`` offers.
8
+
9
+ Two rules shape the result:
10
+
11
+ * **New models are added.** Anything the provider lists is offered, ordered
12
+ newest-first so freshly released ids surface at the top.
13
+ * **Deprecated models are removed.** Only ids present in the live listing
14
+ survive, so a retired model disappears from ``/model`` on the next refresh
15
+ even if it is still hard-coded in the curated catalog.
16
+
17
+ Listings are filtered to models this CLI can actually drive: text chat models
18
+ that support tool calling. Embedding, audio, image, and moderation endpoints are
19
+ dropped by name, and provider metadata (OpenRouter's ``supported_parameters`` /
20
+ ``architecture``, Mistral's ``capabilities``) is honored when present.
21
+
22
+ Nothing here touches disk; :mod:`kiwimatecoder.config` owns the cache that keeps
23
+ ``/model`` from hitting the network on every invocation.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ from collections.abc import Iterable, Sequence
30
+ from dataclasses import dataclass, field
31
+ from datetime import datetime
32
+ from typing import Any
33
+
34
+ import httpx
35
+
36
+ from kiwimatecoder import network
37
+ from kiwimatecoder.providers import ProviderConfig
38
+
39
+ # A catalog older than this is refetched the next time the models are shown.
40
+ CATALOG_TTL_SECONDS = 24 * 60 * 60
41
+ # After a failed fetch, don't retry automatically for this long (an explicit
42
+ # `/model refresh` always retries). Keeps `/model` snappy when offline.
43
+ FAILURE_BACKOFF_SECONDS = 30 * 60
44
+ # Model listings are small; a short timeout keeps a hung provider from blocking
45
+ # the selector.
46
+ FETCH_TIMEOUT = 8.0
47
+ # Server-detection probes (setup wizard) should feel instant.
48
+ PROBE_TIMEOUT = 0.75
49
+ # Gateways such as OpenRouter serve hundreds of models. Offer the newest slice
50
+ # in the selector — any id can still be set by name with `/model <id>`.
51
+ MAX_CATALOG_MODELS = 60
52
+ ANTHROPIC_VERSION = "2023-06-01"
53
+
54
+ # Substrings that mark an id as something other than a text chat model. Matched
55
+ # case-insensitively against the model id.
56
+ NON_CHAT_MARKERS = (
57
+ "embed",
58
+ "rerank",
59
+ "moderation",
60
+ "guard",
61
+ "whisper",
62
+ "audio",
63
+ "speech",
64
+ "transcribe",
65
+ "voice",
66
+ "tts",
67
+ "dall-e",
68
+ "image",
69
+ "imagen",
70
+ "video",
71
+ "veo",
72
+ "sora",
73
+ "diffusion",
74
+ "flux",
75
+ "clip",
76
+ "ocr",
77
+ )
78
+
79
+
80
+ class CatalogFetchError(RuntimeError):
81
+ """Raised when a provider's model listing cannot be retrieved or parsed."""
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class RemoteModel:
86
+ """One model as reported by a provider's listing endpoint."""
87
+
88
+ id: str
89
+ created: float = 0.0 # epoch seconds; 0 when the provider reports no date
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class ModelCatalog:
94
+ """The model list offered for a provider, plus how it was obtained.
95
+
96
+ ``source`` is ``"live"`` (just fetched), ``"cache"`` (a previous fetch), or
97
+ ``"curated"`` (the tuple shipped in the registry). ``added`` and ``removed``
98
+ are only populated for a live fetch and describe the change against the
99
+ previously known catalog — ``removed`` is the list of deprecated ids that
100
+ just dropped out of ``/model``.
101
+ """
102
+
103
+ provider_id: str
104
+ models: list[str]
105
+ source: str
106
+ fetched_at: float | None = None
107
+ added: list[str] = field(default_factory=list)
108
+ removed: list[str] = field(default_factory=list)
109
+ error: str | None = None
110
+
111
+
112
+ def models_url(provider: ProviderConfig) -> str:
113
+ """Return the listing endpoint for ``provider`` (with any api-version)."""
114
+ return provider.versioned_url(f"{provider.base_url.rstrip('/')}/models")
115
+
116
+
117
+ def request_headers(provider: ProviderConfig, api_key: str | None) -> dict[str, str]:
118
+ """Build auth headers for a listing request.
119
+
120
+ Anthropic's native API wants ``x-api-key`` plus a version header; every
121
+ OpenAI-compatible provider uses its configured ``key_header``/``key_prefix``
122
+ (``Authorization: Bearer`` by default, ``api-key`` for Azure). Provider
123
+ ``extra_headers`` are applied last so a custom provider can override
124
+ anything.
125
+ """
126
+ headers = {"Accept": "application/json"}
127
+ if provider.compat == "anthropic":
128
+ headers["anthropic-version"] = ANTHROPIC_VERSION
129
+ if api_key:
130
+ headers["x-api-key"] = api_key
131
+ elif api_key:
132
+ headers[provider.key_header] = f"{provider.key_prefix}{api_key}"
133
+ headers.update(provider.extra_headers)
134
+ return headers
135
+
136
+
137
+ def request_params(provider: ProviderConfig) -> dict[str, str | int]:
138
+ """Return query params for the listing request."""
139
+ if provider.compat == "anthropic":
140
+ # Anthropic paginates at 20 by default; 1000 is the documented maximum
141
+ # and comfortably covers the whole catalog in one request.
142
+ return {"limit": 1000}
143
+ return {}
144
+
145
+
146
+ def _parse_timestamp(value: object) -> float:
147
+ """Coerce a provider timestamp (epoch seconds or ISO 8601) to a float."""
148
+ if isinstance(value, bool):
149
+ return 0.0
150
+ if isinstance(value, (int, float)):
151
+ return float(value)
152
+ if isinstance(value, str) and value.strip():
153
+ try:
154
+ return datetime.fromisoformat(value.strip().replace("Z", "+00:00")).timestamp()
155
+ except ValueError:
156
+ return 0.0
157
+ return 0.0
158
+
159
+
160
+ def normalize_model_id(raw: object) -> str:
161
+ """Normalize a listed id to the form the chat endpoint expects.
162
+
163
+ Google's OpenAI-compatible listing returns ``models/gemini-...`` while its
164
+ chat endpoint expects the bare id, so the prefix is stripped.
165
+ """
166
+ model_id = str(raw or "").strip()
167
+ if model_id.startswith("models/"):
168
+ model_id = model_id[len("models/") :]
169
+ return model_id
170
+
171
+
172
+ # Name fragments that mark an id as a model of the given media kind; used only
173
+ # when the provider's listing has no modality metadata.
174
+ MEDIA_NAME_MARKERS: dict[str, tuple[str, ...]] = {
175
+ "image": ("image", "imagen", "dall-e", "flux", "diffusion"),
176
+ "video": ("video", "veo", "sora"),
177
+ }
178
+
179
+
180
+ def is_chat_model(
181
+ entry: dict[str, Any], model_id: str, *, require_tools: bool = True
182
+ ) -> bool:
183
+ """Return whether a listed model is a text chat model this CLI can drive.
184
+
185
+ Provider metadata wins when present; otherwise the id is matched against
186
+ :data:`NON_CHAT_MARKERS`. Tool calling is required — the agent loop is built
187
+ on it — so a provider that reports ``supported_parameters`` without
188
+ ``tools`` (OpenRouter does this for every entry) is filtered out, unless
189
+ ``require_tools`` is off for a chat-only provider.
190
+ """
191
+ lowered = model_id.lower()
192
+ if not lowered or any(marker in lowered for marker in NON_CHAT_MARKERS):
193
+ return False
194
+
195
+ capabilities = entry.get("capabilities")
196
+ if isinstance(capabilities, dict):
197
+ chat = capabilities.get("completion_chat")
198
+ if chat is False:
199
+ return False
200
+ if require_tools and capabilities.get("function_calling") is False:
201
+ return False
202
+
203
+ architecture = entry.get("architecture")
204
+ if isinstance(architecture, dict):
205
+ outputs = architecture.get("output_modalities")
206
+ if isinstance(outputs, list) and outputs and "text" not in outputs:
207
+ return False
208
+ inputs = architecture.get("input_modalities")
209
+ if isinstance(inputs, list) and inputs and "text" not in inputs:
210
+ return False
211
+
212
+ supported = entry.get("supported_parameters")
213
+ if (
214
+ require_tools
215
+ and isinstance(supported, list)
216
+ and supported
217
+ and "tools" not in supported
218
+ ):
219
+ return False
220
+
221
+ return True
222
+
223
+
224
+ def is_media_model(entry: dict[str, Any], model_id: str, kind: str) -> bool:
225
+ """Return whether a listed model produces ``kind`` ("image" or "video").
226
+
227
+ ``architecture.output_modalities`` wins when the provider reports it
228
+ (OpenRouter does); otherwise the id is matched against
229
+ :data:`MEDIA_NAME_MARKERS`.
230
+ """
231
+ lowered = model_id.lower()
232
+ if not lowered:
233
+ return False
234
+ architecture = entry.get("architecture")
235
+ if isinstance(architecture, dict):
236
+ outputs = architecture.get("output_modalities")
237
+ if isinstance(outputs, list) and outputs:
238
+ return kind in outputs
239
+ return any(marker in lowered for marker in MEDIA_NAME_MARKERS[kind])
240
+
241
+
242
+ def parse_models_response(
243
+ payload: object, *, require_tools: bool = True, kind: str = "chat"
244
+ ) -> list[RemoteModel]:
245
+ """Extract usable models of ``kind`` (chat by default) from a provider's listing payload.
246
+
247
+ Accepts the OpenAI shape (``{"data": [...]}``), a bare list, or a
248
+ ``{"models": [...]}`` variant, and tolerates entries that are plain strings.
249
+ Unusable entries are skipped rather than raising, so one odd record cannot
250
+ break the whole catalog. ``require_tools`` is passed to
251
+ :func:`is_chat_model`.
252
+ """
253
+ if isinstance(payload, dict):
254
+ entries = payload.get("data")
255
+ if not isinstance(entries, list):
256
+ entries = payload.get("models")
257
+ else:
258
+ entries = payload
259
+ if not isinstance(entries, list):
260
+ return []
261
+
262
+ models: dict[str, RemoteModel] = {}
263
+ for raw_entry in entries:
264
+ if isinstance(raw_entry, str):
265
+ entry: dict[str, Any] = {"id": raw_entry}
266
+ elif isinstance(raw_entry, dict):
267
+ entry = raw_entry
268
+ else:
269
+ continue
270
+
271
+ model_id = normalize_model_id(entry.get("id") or entry.get("name"))
272
+ if not model_id or model_id in models:
273
+ continue
274
+ if kind == "chat":
275
+ usable = is_chat_model(entry, model_id, require_tools=require_tools)
276
+ else:
277
+ usable = is_media_model(entry, model_id, kind)
278
+ if not usable:
279
+ continue
280
+
281
+ created = _parse_timestamp(
282
+ entry.get("created")
283
+ if entry.get("created") is not None
284
+ else entry.get("created_at")
285
+ )
286
+ models[model_id] = RemoteModel(id=model_id, created=created)
287
+
288
+ return list(models.values())
289
+
290
+
291
+ def fetch_models(
292
+ provider: ProviderConfig,
293
+ api_key: str | None = None,
294
+ *,
295
+ timeout: float = FETCH_TIMEOUT,
296
+ transport: httpx.BaseTransport | None = None,
297
+ kind: str = "chat",
298
+ ) -> list[RemoteModel]:
299
+ """Fetch and parse ``provider``'s live model listing.
300
+
301
+ Raises :class:`CatalogFetchError` for any transport, status, payload, or
302
+ "nothing usable came back" failure so callers have a single thing to catch.
303
+ ``transport`` is an injection point for tests.
304
+ """
305
+ if not provider.is_local and network.offline_enabled():
306
+ raise CatalogFetchError(
307
+ f"{provider.name}: {network.offline_message('model catalog fetch')}"
308
+ )
309
+ url = models_url(provider)
310
+ try:
311
+ with httpx.Client(
312
+ timeout=timeout,
313
+ transport=transport,
314
+ follow_redirects=True,
315
+ **network.current_options(),
316
+ ) as client:
317
+ response = client.get(
318
+ url,
319
+ headers=request_headers(provider, api_key),
320
+ # ``params={}`` would drop an api-version already in the URL.
321
+ params=request_params(provider) or None,
322
+ )
323
+ except httpx.HTTPError as exc:
324
+ raise CatalogFetchError(f"{provider.name}: {exc}") from exc
325
+
326
+ if response.status_code != 200:
327
+ body = response.text[:200].replace("\n", " ").strip()
328
+ raise CatalogFetchError(
329
+ f"{provider.name} returned HTTP {response.status_code} for {url}"
330
+ + (f": {body}" if body else "")
331
+ )
332
+
333
+ try:
334
+ payload = response.json()
335
+ except (json.JSONDecodeError, ValueError) as exc:
336
+ raise CatalogFetchError(
337
+ f"{provider.name} returned a non-JSON model list from {url}"
338
+ ) from exc
339
+
340
+ models = parse_models_response(
341
+ payload, require_tools=provider.supports_tools, kind=kind
342
+ )
343
+ if not models:
344
+ raise CatalogFetchError(f"{provider.name} listed no usable {kind} models")
345
+ return models
346
+
347
+
348
+ def probe(
349
+ provider: ProviderConfig,
350
+ *,
351
+ api_key: str | None = None,
352
+ timeout: float = PROBE_TIMEOUT,
353
+ transport: httpx.BaseTransport | None = None,
354
+ ) -> bool:
355
+ """Return whether ``provider``'s server answers a listing request right now.
356
+
357
+ Used to annotate local providers in the setup wizard; never raises. Any
358
+ 200 counts as "running", even when no models are loaded yet — that is a
359
+ server the user can still pull models into. A 401 also counts as running
360
+ for providers that enforce auth (Unsloth): the server is up, only the key
361
+ is missing — and it may not exist yet when the wizard first probes.
362
+
363
+ With offline mode on, cloud providers are reported as not running without a
364
+ request; local servers are still probed so the setup wizard keeps working.
365
+ """
366
+ if not provider.is_local and network.offline_enabled():
367
+ return False
368
+ try:
369
+ with httpx.Client(
370
+ timeout=timeout,
371
+ transport=transport,
372
+ follow_redirects=True,
373
+ **network.current_options(),
374
+ ) as client:
375
+ response = client.get(
376
+ models_url(provider),
377
+ headers=request_headers(provider, api_key),
378
+ params=request_params(provider) or None,
379
+ )
380
+ except httpx.HTTPError:
381
+ return False
382
+ if response.status_code == 200:
383
+ return True
384
+ return response.status_code == 401 and provider.requires_key
385
+
386
+
387
+ def search_models(models: Sequence[str], query: str) -> list[str]:
388
+ """Filter ``models`` to ids whose name matches every token of ``query``.
389
+
390
+ Matching is a case-insensitive substring test per whitespace-separated
391
+ token, combined with AND — ``search claude sonnet`` matches
392
+ ``anthropic/claude-sonnet-5``. An empty or blank query returns everything,
393
+ so callers can treat it as "no filter".
394
+ """
395
+ tokens = [token for token in query.lower().split() if token]
396
+ if not tokens:
397
+ return list(models)
398
+ return [
399
+ model_id
400
+ for model_id in models
401
+ if all(token in model_id.lower() for token in tokens)
402
+ ]
403
+
404
+
405
+ def summarize_ids(model_ids: Sequence[str], limit: int = 8) -> str:
406
+ """Join model ids for display, trimming long lists to ``limit`` entries.
407
+
408
+ A first refresh can add dozens of models at once; printing all of them
409
+ buries the part the user cares about.
410
+ """
411
+ shown = ", ".join(model_ids[:limit])
412
+ extra = len(model_ids) - limit
413
+ return f"{shown}, +{extra} more" if extra > 0 else shown
414
+
415
+
416
+ def merge_catalog(
417
+ provider: ProviderConfig,
418
+ remote: Iterable[RemoteModel],
419
+ *,
420
+ keep: Sequence[str] = (),
421
+ limit: int | None = MAX_CATALOG_MODELS,
422
+ ) -> list[str]:
423
+ """Turn a live listing into the catalog ``/model`` should offer.
424
+
425
+ Caller-supplied ``keep`` ids such as the session's current model come
426
+ first, then the rest newest-first. There is no default model to pin: the
427
+ user chooses. Only ids the provider still lists survive — that is how
428
+ deprecated models leave the catalog — so ``keep`` can pin a model but never
429
+ resurrect a retired one.
430
+ """
431
+ ordered = [model.id for model in sorted(remote, key=lambda m: -m.created)]
432
+ live = set(ordered)
433
+
434
+ pinned = list(
435
+ dict.fromkeys(
436
+ model
437
+ for model in keep
438
+ if model and model in live
439
+ )
440
+ )
441
+ rest = [model for model in ordered if model not in set(pinned)]
442
+ catalog = pinned + rest
443
+ if limit is not None and limit > 0:
444
+ catalog = catalog[:limit]
445
+ return catalog
@@ -0,0 +1,103 @@
1
+ """Per-tool file snapshots backing ``/undo`` and ``/rewind``.
2
+
3
+ Before a mutating tool runs, the target files are copied into a per-session
4
+ temporary directory. A checkpoint records where each pre-change snapshot lives
5
+ (``None`` means the file did not exist yet, so undo removes it). Restoring is
6
+ best-effort and never raises on a single unreadable file.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import datetime
12
+ import shutil
13
+ import tempfile
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+
17
+
18
+ def _resolve(relative: str, workspace_root: Path) -> Path | None:
19
+ """Resolve inside the workspace without a circular import at module load."""
20
+ from kiwimatecoder.tools.paths import PathError, resolve_in_workspace
21
+
22
+ try:
23
+ return resolve_in_workspace(relative, workspace_root)
24
+ except PathError:
25
+ return None
26
+
27
+
28
+ @dataclass
29
+ class Checkpoint:
30
+ """A snapshot of files captured before one mutating tool call."""
31
+
32
+ id: int
33
+ label: str
34
+ created_at: str
35
+ # Workspace-relative path -> snapshot file path, or None when absent.
36
+ files: dict[str, str | None] = field(default_factory=dict)
37
+
38
+ @property
39
+ def paths(self) -> list[str]:
40
+ return list(self.files)
41
+
42
+
43
+ class CheckpointStore:
44
+ """Captures and restores file snapshots for one session."""
45
+
46
+ def __init__(self, snapshot_root: Path | None = None) -> None:
47
+ self._root = snapshot_root or Path(
48
+ tempfile.mkdtemp(prefix="kiwimatecoder-checkpoints-")
49
+ )
50
+ self._counter = 0
51
+
52
+ def capture(
53
+ self, workspace_root: Path, paths: list[str], label: str
54
+ ) -> Checkpoint:
55
+ """Snapshot ``paths`` as they are right now and return the checkpoint."""
56
+ self._counter += 1
57
+ files: dict[str, str | None] = {}
58
+ for raw in paths:
59
+ relative = str(raw).strip()
60
+ if not relative or relative in files:
61
+ continue
62
+ target = _resolve(relative, workspace_root)
63
+ if target is None:
64
+ continue
65
+ if target.is_file():
66
+ dest = self._root / str(self._counter) / f"{len(files)}.snap"
67
+ dest.parent.mkdir(parents=True, exist_ok=True)
68
+ try:
69
+ shutil.copyfile(target, dest)
70
+ except OSError:
71
+ continue
72
+ files[relative] = str(dest)
73
+ else:
74
+ files[relative] = None
75
+ return Checkpoint(
76
+ id=self._counter,
77
+ label=label,
78
+ created_at=datetime.datetime.now().isoformat(),
79
+ files=files,
80
+ )
81
+
82
+ def restore(self, workspace_root: Path, checkpoint: Checkpoint) -> list[str]:
83
+ """Restore the checkpoint's files; returns the paths actually changed."""
84
+ restored: list[str] = []
85
+ for relative, snapshot in checkpoint.files.items():
86
+ target = _resolve(relative, workspace_root)
87
+ if target is None:
88
+ continue
89
+ if snapshot is None:
90
+ try:
91
+ if target.exists():
92
+ target.unlink()
93
+ restored.append(relative)
94
+ except OSError:
95
+ continue
96
+ else:
97
+ try:
98
+ target.parent.mkdir(parents=True, exist_ok=True)
99
+ shutil.copyfile(snapshot, target)
100
+ restored.append(relative)
101
+ except OSError:
102
+ continue
103
+ return restored