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,138 @@
1
+ """On-demand Agent Skills: markdown bundles the model loads when relevant.
2
+
3
+ A skill is a directory containing ``SKILL.md`` under
4
+ ``~/.kiwimatecoder/skills/`` or ``<workspace>/.kiwimatecoder/skills/`` (the
5
+ workspace wins name collisions). The system prompt advertises only each
6
+ skill's name and one-line description; the full body is fetched through the
7
+ ``load_skill`` tool when a task matches, so unused skills cost no tokens.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ from kiwimatecoder import config
16
+
17
+ WORKSPACE_SKILLS_DIR = Path(".kiwimatecoder") / "skills"
18
+ USER_SKILLS_DIR_NAME = "skills"
19
+ SKILL_FILE_NAME = "SKILL.md"
20
+ MAX_SKILL_BYTES = 32 * 1024
21
+ MAX_LISTING_BYTES = 2048
22
+ MAX_DESCRIPTION_CHARS = 200
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Skill:
27
+ """One discoverable skill on disk."""
28
+
29
+ name: str
30
+ path: Path
31
+ description: str
32
+
33
+
34
+ def _skill_dirs(workspace_root: Path) -> list[Path]:
35
+ """Workspace first so it wins name collisions with user skills."""
36
+ dirs = [workspace_root / WORKSPACE_SKILLS_DIR]
37
+ try:
38
+ dirs.append(config.ensure_config_dir() / USER_SKILLS_DIR_NAME)
39
+ except OSError:
40
+ # An unwritable home directory just means no user-level skills.
41
+ pass
42
+ return dirs
43
+
44
+
45
+ def _read_raw(path: Path) -> bytes | None:
46
+ try:
47
+ raw = path.read_bytes()
48
+ except OSError:
49
+ return None
50
+ if b"\x00" in raw[:1024]:
51
+ return None
52
+ return raw
53
+
54
+
55
+ def _description(text: str, fallback: str) -> str:
56
+ for raw_line in text.splitlines():
57
+ line = raw_line.strip().lstrip("#").strip()
58
+ if line:
59
+ return line[:MAX_DESCRIPTION_CHARS]
60
+ return fallback
61
+
62
+
63
+ def discover_skills(workspace_root: Path) -> list[Skill]:
64
+ """Return every valid skill sorted by name; workspace shadows user."""
65
+ found: dict[str, Skill] = {}
66
+ for directory in _skill_dirs(workspace_root):
67
+ if not directory.is_dir():
68
+ continue
69
+ for entry in sorted(directory.iterdir()):
70
+ # A bare SKILL.md directly in the skills directory is not a skill;
71
+ # skills must live inside their own named directory.
72
+ if not entry.is_dir():
73
+ continue
74
+ path = entry / SKILL_FILE_NAME
75
+ if not path.is_file():
76
+ continue
77
+ name = entry.name.strip()
78
+ if not name or name in found:
79
+ continue
80
+ raw = _read_raw(path)
81
+ if raw is None:
82
+ continue
83
+ text = raw.decode("utf-8", "replace")
84
+ found[name] = Skill(
85
+ name=name, path=path, description=_description(text, name)
86
+ )
87
+ return [found[name] for name in sorted(found)]
88
+
89
+
90
+ def load_skill(name: str, workspace_root: Path) -> str | None:
91
+ """Return a skill's markdown body, capped and truncation-noted, or None."""
92
+ key = str(name).strip()
93
+ if not key:
94
+ return None
95
+ skill = next(
96
+ (item for item in discover_skills(workspace_root) if item.name == key), None
97
+ )
98
+ if skill is None:
99
+ return None
100
+ raw = _read_raw(skill.path)
101
+ if raw is None:
102
+ return None
103
+ clipped = raw[:MAX_SKILL_BYTES]
104
+ text = clipped.decode("utf-8", "replace")
105
+ if len(raw) > len(clipped):
106
+ text += f"\n\n[truncated: showing {len(clipped)} of {len(raw)} bytes]"
107
+ return text
108
+
109
+
110
+ def skills_section(workspace_root: Path) -> str:
111
+ """Render the compact "available skills" block for the system prompt.
112
+
113
+ Only names and descriptions are listed; the full body is intentionally
114
+ left out so unused skills do not consume context. The listing is capped
115
+ at :data:`MAX_LISTING_BYTES` with a note when skills are omitted.
116
+ """
117
+ skills = discover_skills(workspace_root)
118
+ if not skills:
119
+ return ""
120
+ lines = [
121
+ "Available skills — call load_skill(name) when a task matches one; "
122
+ "their full instructions are not loaded until you do:"
123
+ ]
124
+ budget = MAX_LISTING_BYTES
125
+ appended = 0
126
+ omitted = 0
127
+ for skill in skills:
128
+ line = f"- {skill.name}: {skill.description}"
129
+ cost = len(line.encode("utf-8")) + 1
130
+ if cost > budget:
131
+ omitted = len(skills) - appended
132
+ break
133
+ lines.append(line)
134
+ budget -= cost
135
+ appended += 1
136
+ if omitted:
137
+ lines.append(f"[{omitted} more skill(s) omitted]")
138
+ return "\n\n" + "\n".join(lines)
kiwimatecoder/sync.py ADDED
@@ -0,0 +1,513 @@
1
+ """Opt-in cross-machine session sync through a user-provided folder.
2
+
3
+ There is no server component: a machine copies saved sessions into a shared
4
+ folder (Dropbox, iCloud Drive, a network mount, a git checkout) and other
5
+ machines copy them back. The feature is disabled by default and never touches
6
+ anything until it is explicitly enabled with a path.
7
+
8
+ The shared folder contains a ``kiwimatecoder-sessions/`` directory with the
9
+ session JSON files plus a ``manifest.json`` index. Each machine also keeps a
10
+ small local record of what it last pushed or pulled so a file that changed on
11
+ two machines at once can be detected instead of silently overwritten. Every
12
+ write is best-effort: unreadable or corrupt session files are skipped and
13
+ reported, never fatal.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import datetime
19
+ import hashlib
20
+ import json
21
+ import os
22
+ import re
23
+ from collections.abc import Iterable
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+ from typing import Any
27
+
28
+ from kiwimatecoder import session as session_store
29
+ from kiwimatecoder.config import ensure_config_dir, get_sync
30
+
31
+ MANIFEST_VERSION = 1
32
+ SYNC_DIR_NAME = "kiwimatecoder-sessions"
33
+ MANIFEST_NAME = "manifest.json"
34
+ STATE_NAME = "sync_state.json"
35
+
36
+
37
+ class SyncError(Exception):
38
+ """Raised when sync is unavailable or misconfigured."""
39
+
40
+
41
+ @dataclass
42
+ class SyncReport:
43
+ """Outcome of one :func:`push` or :func:`pull`."""
44
+
45
+ lines: list[str] = field(default_factory=list)
46
+ pushed: int = 0
47
+ pulled: int = 0
48
+ conflicts: int = 0
49
+ errors: list[str] = field(default_factory=list)
50
+
51
+ def summary(self) -> str:
52
+ parts = [
53
+ f"pushed {self.pushed}",
54
+ f"pulled {self.pulled}",
55
+ f"conflicts {self.conflicts}",
56
+ ]
57
+ if self.errors:
58
+ parts.append(f"errors {len(self.errors)}")
59
+ return "Sync complete: " + ", ".join(parts) + "."
60
+
61
+
62
+ @dataclass
63
+ class SyncStatus:
64
+ """A read-only view of the local and shared session stores."""
65
+
66
+ enabled: bool = False
67
+ machine: str = ""
68
+ root: Path | None = None
69
+ local: int = 0
70
+ remote: int = 0
71
+ last_sync: str | None = None
72
+ pending_pushes: list[str] = field(default_factory=list)
73
+ pending_pulls: list[str] = field(default_factory=list)
74
+ conflicts: list[str] = field(default_factory=list)
75
+ errors: list[str] = field(default_factory=list)
76
+
77
+ def summary(self) -> str:
78
+ if not self.enabled:
79
+ return (
80
+ "Session sync is disabled (opt-in).\n"
81
+ "Enable it with: kiwimatecoder sync enable <folder>"
82
+ )
83
+ lines = [
84
+ f"Sync folder: {self.root}",
85
+ f"Machine: {self.machine}",
86
+ f"Local sessions: {self.local}",
87
+ f"Remote sessions: {self.remote}",
88
+ f"Last sync: {self.last_sync or 'never'}",
89
+ f"Pending pushes: {len(self.pending_pushes)}",
90
+ f"Pending pulls: {len(self.pending_pulls)}",
91
+ f"Conflicts: {len(self.conflicts)}",
92
+ ]
93
+ if self.pending_pushes:
94
+ lines.append(" push: " + ", ".join(self.pending_pushes))
95
+ if self.pending_pulls:
96
+ lines.append(" pull: " + ", ".join(self.pending_pulls))
97
+ if self.conflicts:
98
+ lines.append(" both changed: " + ", ".join(self.conflicts))
99
+ if self.errors:
100
+ lines.append(f"Errors: {len(self.errors)}")
101
+ lines.extend(f" {error}" for error in self.errors)
102
+ return "\n".join(lines)
103
+
104
+
105
+ def _now() -> str:
106
+ return datetime.datetime.now().isoformat()
107
+
108
+
109
+ def _write_json_atomic(path: Path, payload: dict[str, Any]) -> None:
110
+ """Write ``payload`` as JSON next to ``path`` and atomically replace it."""
111
+ temp = path.with_name(f".{path.name}.tmp")
112
+ temp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
113
+ os.replace(temp, path)
114
+
115
+
116
+ def _copy_file(source: Path, destination: Path) -> None:
117
+ """Copy the raw bytes of one session file, replacing the destination."""
118
+ data = source.read_bytes()
119
+ destination.parent.mkdir(parents=True, exist_ok=True)
120
+ temp = destination.with_name(f".{destination.name}.tmp")
121
+ temp.write_bytes(data)
122
+ os.replace(temp, destination)
123
+
124
+
125
+ def _read_meta(path: Path) -> dict[str, str] | None:
126
+ """Return ``{"sha1", "saved_at"}`` for a session file, or None if corrupt.
127
+
128
+ Raises ``OSError`` when the file itself cannot be read; callers turn that
129
+ into a report entry rather than a failure.
130
+ """
131
+ raw = path.read_bytes()
132
+ try:
133
+ data = json.loads(raw.decode("utf-8"))
134
+ except (UnicodeDecodeError, json.JSONDecodeError):
135
+ return None
136
+ if not isinstance(data, dict):
137
+ return None
138
+ return {
139
+ "sha1": hashlib.sha1(raw).hexdigest(),
140
+ "saved_at": str(data.get("saved_at") or ""),
141
+ }
142
+
143
+
144
+ def _safe_meta(path: Path) -> dict[str, str] | None:
145
+ try:
146
+ return _read_meta(path)
147
+ except OSError:
148
+ return None
149
+
150
+
151
+ def _record(store: dict[str, Any], name: str, meta: dict[str, str]) -> None:
152
+ store[name] = {"saved_at": meta.get("saved_at", ""), "sha1": meta["sha1"]}
153
+
154
+
155
+ def _require_enabled(settings: dict[str, Any]) -> None:
156
+ if not settings["enabled"]:
157
+ raise SyncError(
158
+ "Session sync is disabled. Enable it with: "
159
+ "kiwimatecoder sync enable <folder>"
160
+ )
161
+ if not settings["path"]:
162
+ raise SyncError(
163
+ "Session sync has no folder configured. Run: "
164
+ "kiwimatecoder sync enable <folder>"
165
+ )
166
+
167
+
168
+ def sync_root(cfg: dict[str, Any] | None = None) -> Path:
169
+ """Return (creating on demand) the shared ``kiwimatecoder-sessions/`` dir.
170
+
171
+ Raises :class:`SyncError` when sync is disabled or has no usable folder.
172
+ """
173
+ settings = get_sync(cfg)
174
+ _require_enabled(settings)
175
+ root = Path(settings["path"]).expanduser() / SYNC_DIR_NAME
176
+ try:
177
+ root.mkdir(parents=True, exist_ok=True)
178
+ except OSError as exc:
179
+ raise SyncError(f"Could not create the sync folder {root}: {exc}") from exc
180
+ return root
181
+
182
+
183
+ def _filtered(paths: Iterable[Path], include_autosave: bool) -> list[Path]:
184
+ """Sort session paths and drop the autosave unless it is opted in."""
185
+ result: list[Path] = []
186
+ for path in sorted(paths, key=lambda item: item.name):
187
+ if not path.is_file():
188
+ continue
189
+ if not include_autosave and path.stem == session_store.AUTOSAVE_NAME:
190
+ continue
191
+ result.append(path)
192
+ return result
193
+
194
+
195
+ def _local_session_files(include_autosave: bool) -> list[Path]:
196
+ sessions_dir = session_store._sessions_dir()
197
+ return [
198
+ path
199
+ for path in _filtered(sessions_dir.glob("*.json"), include_autosave)
200
+ if path.name != MANIFEST_NAME
201
+ ]
202
+
203
+
204
+ def _remote_session_files(root: Path, include_autosave: bool) -> list[Path]:
205
+ return [
206
+ path
207
+ for path in _filtered(root.glob("*.json"), include_autosave)
208
+ if path.name != MANIFEST_NAME
209
+ ]
210
+
211
+
212
+ def _load_manifest(root: Path) -> dict[str, Any]:
213
+ """Load the shared manifest, tolerating absence and corruption."""
214
+ try:
215
+ data = json.loads((root / MANIFEST_NAME).read_text(encoding="utf-8"))
216
+ except (OSError, json.JSONDecodeError):
217
+ data = None
218
+ machine = ""
219
+ last_sync: str | None = None
220
+ files: dict[str, dict[str, str]] = {}
221
+ if isinstance(data, dict) and data.get("version") == MANIFEST_VERSION:
222
+ if isinstance(data.get("machine"), str):
223
+ machine = data["machine"]
224
+ if isinstance(data.get("last_sync"), str):
225
+ last_sync = data["last_sync"]
226
+ raw_files = data.get("files")
227
+ if isinstance(raw_files, dict):
228
+ for name, entry in raw_files.items():
229
+ if not isinstance(entry, dict):
230
+ continue
231
+ sha1 = entry.get("sha1")
232
+ if not isinstance(sha1, str) or not sha1:
233
+ continue
234
+ files[str(name)] = {
235
+ "saved_at": str(entry.get("saved_at") or ""),
236
+ "sha1": sha1,
237
+ }
238
+ return {
239
+ "version": MANIFEST_VERSION,
240
+ "machine": machine,
241
+ "last_sync": last_sync,
242
+ "files": files,
243
+ }
244
+
245
+
246
+ def _state_file() -> Path:
247
+ return ensure_config_dir() / STATE_NAME
248
+
249
+
250
+ def _load_state(machine: str) -> dict[str, Any]:
251
+ """Load this machine's record of what it last pushed/pulled.
252
+
253
+ A record written by a different machine (the config was copied, or the
254
+ ``machine`` name changed) is discarded so it can never mislabel baselines.
255
+ """
256
+ try:
257
+ data = json.loads(_state_file().read_text(encoding="utf-8"))
258
+ except (OSError, json.JSONDecodeError):
259
+ data = None
260
+ files: dict[str, dict[str, str]] = {}
261
+ if isinstance(data, dict) and data.get("machine") == machine:
262
+ raw_files = data.get("files")
263
+ if isinstance(raw_files, dict):
264
+ for name, entry in raw_files.items():
265
+ if not isinstance(entry, dict):
266
+ continue
267
+ sha1 = entry.get("sha1")
268
+ if not isinstance(sha1, str) or not sha1:
269
+ continue
270
+ files[str(name)] = {
271
+ "saved_at": str(entry.get("saved_at") or ""),
272
+ "sha1": sha1,
273
+ }
274
+ return {"version": 1, "machine": machine, "files": files}
275
+
276
+
277
+ def _conflict_name(name: str, machine: str, directory: Path) -> str:
278
+ """Pick a free ``<name>__<machine>.json`` conflict filename."""
279
+ stem = Path(name).stem
280
+ slug = re.sub(r"[^\w\-]", "_", str(machine).strip()) or "machine"
281
+ candidate = f"{stem}__{slug}.json"
282
+ counter = 2
283
+ while (directory / candidate).exists():
284
+ candidate = f"{stem}__{slug}-{counter}.json"
285
+ counter += 1
286
+ return candidate
287
+
288
+
289
+ def status(cfg: dict[str, Any] | None = None) -> SyncStatus:
290
+ """Compare the local and shared session stores without copying anything."""
291
+ settings = get_sync(cfg)
292
+ result = SyncStatus(enabled=settings["enabled"], machine=settings["machine"])
293
+ if not settings["enabled"]:
294
+ return result
295
+ try:
296
+ root = sync_root(cfg)
297
+ except SyncError as exc:
298
+ result.errors.append(str(exc))
299
+ return result
300
+ manifest = _load_manifest(root)
301
+ state = _load_state(settings["machine"])
302
+ result.root = root
303
+ result.last_sync = manifest["last_sync"]
304
+
305
+ local: dict[str, dict[str, str]] = {}
306
+ for path in _local_session_files(settings["include_autosave"]):
307
+ meta = _safe_meta(path)
308
+ if meta is None:
309
+ result.errors.append(f"Skipped unreadable session: {path.name}")
310
+ else:
311
+ local[path.name] = meta
312
+ remote: dict[str, dict[str, str]] = {}
313
+ for path in _remote_session_files(root, settings["include_autosave"]):
314
+ meta = _safe_meta(path)
315
+ if meta is None:
316
+ result.errors.append(f"Skipped corrupt session: {path.name}")
317
+ else:
318
+ remote[path.name] = meta
319
+ result.local = len(local)
320
+ result.remote = len(remote)
321
+
322
+ for name in sorted(local):
323
+ local_meta = local[name]
324
+ remote_meta = remote.get(name)
325
+ if remote_meta is None:
326
+ result.pending_pushes.append(name)
327
+ continue
328
+ if remote_meta["sha1"] == local_meta["sha1"]:
329
+ continue
330
+ base = state["files"].get(name)
331
+ local_changed = base is None or base["sha1"] != local_meta["sha1"]
332
+ remote_changed = base is None or base["sha1"] != remote_meta["sha1"]
333
+ if base is not None and local_changed and remote_changed:
334
+ result.conflicts.append(name)
335
+ elif local_changed or (
336
+ base is None
337
+ and local_meta["saved_at"] >= remote_meta["saved_at"]
338
+ ):
339
+ result.pending_pushes.append(name)
340
+ else:
341
+ result.pending_pulls.append(name)
342
+ for name in sorted(remote):
343
+ if name not in local:
344
+ result.pending_pulls.append(name)
345
+ return result
346
+
347
+
348
+ def _finish(
349
+ root: Path,
350
+ manifest: dict[str, Any],
351
+ state: dict[str, Any],
352
+ settings: dict[str, Any],
353
+ report: SyncReport,
354
+ *,
355
+ claim_machine: bool,
356
+ ) -> None:
357
+ """Persist the manifest and local baseline, reporting any write failure."""
358
+ manifest["version"] = MANIFEST_VERSION
359
+ if claim_machine or not manifest["machine"]:
360
+ manifest["machine"] = settings["machine"]
361
+ manifest["last_sync"] = _now()
362
+ try:
363
+ _write_json_atomic(root / MANIFEST_NAME, manifest)
364
+ except OSError as exc:
365
+ report.errors.append(f"Could not update the sync manifest: {exc}")
366
+ try:
367
+ _write_json_atomic(_state_file(), state)
368
+ except OSError as exc:
369
+ report.errors.append(f"Could not update the local sync state: {exc}")
370
+
371
+
372
+ def push(cfg: dict[str, Any] | None = None, force: bool = False) -> SyncReport:
373
+ """Copy local sessions into the shared folder.
374
+
375
+ A shared copy that changed elsewhere while the local copy also changed
376
+ since this machine last synced is kept as both: the shared copy stays and
377
+ the local one is uploaded as ``<name>__<machine>.json``. Otherwise the
378
+ newer ``saved_at`` wins; ``force=True`` makes the local copy win
379
+ unconditionally.
380
+ """
381
+ settings = get_sync(cfg)
382
+ _require_enabled(settings)
383
+ root = sync_root(cfg)
384
+ manifest = _load_manifest(root)
385
+ state = _load_state(settings["machine"])
386
+ machine = settings["machine"]
387
+ report = SyncReport()
388
+ files = _local_session_files(settings["include_autosave"])
389
+ if not files:
390
+ report.lines.append("No local sessions to push.")
391
+ for path in files:
392
+ local_meta = _safe_meta(path)
393
+ if local_meta is None:
394
+ report.errors.append(f"Skipped unreadable session: {path.name}")
395
+ continue
396
+ remote = root / path.name
397
+ remote_meta = _safe_meta(remote) if remote.is_file() else None
398
+ if remote.is_file() and remote_meta is None:
399
+ report.errors.append(f"Skipped corrupt remote session: {path.name}")
400
+ continue
401
+ try:
402
+ if remote_meta is None:
403
+ _copy_file(path, remote)
404
+ _record(manifest["files"], path.name, local_meta)
405
+ _record(state["files"], path.name, local_meta)
406
+ report.pushed += 1
407
+ report.lines.append(f"Pushed {path.name}")
408
+ continue
409
+ if remote_meta["sha1"] == local_meta["sha1"]:
410
+ _record(manifest["files"], path.name, local_meta)
411
+ _record(state["files"], path.name, local_meta)
412
+ continue
413
+ base = state["files"].get(path.name)
414
+ local_changed = base is None or base["sha1"] != local_meta["sha1"]
415
+ remote_changed = base is None or base["sha1"] != remote_meta["sha1"]
416
+ if base is not None and local_changed and remote_changed and not force:
417
+ conflict = _conflict_name(path.name, machine, root)
418
+ _copy_file(path, root / conflict)
419
+ _record(manifest["files"], conflict, local_meta)
420
+ _record(state["files"], conflict, local_meta)
421
+ _record(manifest["files"], path.name, remote_meta)
422
+ _record(state["files"], path.name, remote_meta)
423
+ report.conflicts += 1
424
+ report.lines.append(
425
+ f"Conflict on {path.name}: kept the shared copy and "
426
+ f"pushed the local one as {conflict}"
427
+ )
428
+ continue
429
+ if force or local_meta["saved_at"] >= remote_meta["saved_at"]:
430
+ _copy_file(path, remote)
431
+ _record(manifest["files"], path.name, local_meta)
432
+ _record(state["files"], path.name, local_meta)
433
+ report.pushed += 1
434
+ report.lines.append(f"Pushed {path.name}")
435
+ else:
436
+ _record(manifest["files"], path.name, remote_meta)
437
+ _record(state["files"], path.name, remote_meta)
438
+ report.lines.append(f"Kept the shared {path.name} (newer)")
439
+ except OSError as exc:
440
+ report.errors.append(f"Could not sync {path.name}: {exc}")
441
+ _finish(root, manifest, state, settings, report, claim_machine=True)
442
+ return report
443
+
444
+
445
+ def pull(cfg: dict[str, Any] | None = None, force: bool = False) -> SyncReport:
446
+ """Copy newer shared sessions into the local sessions directory.
447
+
448
+ The both-changed rule matches :func:`push`, except the incoming file is
449
+ renamed with the remote machine's suffix instead of overwriting the local
450
+ one. ``force=True`` makes the shared copy win unconditionally.
451
+ """
452
+ settings = get_sync(cfg)
453
+ _require_enabled(settings)
454
+ root = sync_root(cfg)
455
+ manifest = _load_manifest(root)
456
+ state = _load_state(settings["machine"])
457
+ machine = settings["machine"]
458
+ origin = manifest["machine"] if manifest["machine"] not in {"", machine} else "remote"
459
+ report = SyncReport()
460
+ files = _remote_session_files(root, settings["include_autosave"])
461
+ if not files:
462
+ report.lines.append("No remote sessions to pull.")
463
+ local_dir = session_store._sessions_dir()
464
+ for remote in files:
465
+ remote_meta = _safe_meta(remote)
466
+ if remote_meta is None:
467
+ report.errors.append(f"Skipped corrupt remote session: {remote.name}")
468
+ continue
469
+ local = local_dir / remote.name
470
+ local_meta = _safe_meta(local) if local.is_file() else None
471
+ if local.is_file() and local_meta is None:
472
+ report.errors.append(f"Skipped unreadable session: {remote.name}")
473
+ continue
474
+ try:
475
+ if local_meta is None:
476
+ _copy_file(remote, local)
477
+ _record(manifest["files"], remote.name, remote_meta)
478
+ _record(state["files"], remote.name, remote_meta)
479
+ report.pulled += 1
480
+ report.lines.append(f"Pulled {remote.name}")
481
+ continue
482
+ if local_meta["sha1"] == remote_meta["sha1"]:
483
+ _record(manifest["files"], remote.name, remote_meta)
484
+ _record(state["files"], remote.name, remote_meta)
485
+ continue
486
+ base = state["files"].get(remote.name)
487
+ local_changed = base is None or base["sha1"] != local_meta["sha1"]
488
+ remote_changed = base is None or base["sha1"] != remote_meta["sha1"]
489
+ if base is not None and local_changed and remote_changed and not force:
490
+ conflict = _conflict_name(remote.name, origin, local_dir)
491
+ _copy_file(remote, local_dir / conflict)
492
+ _record(state["files"], conflict, remote_meta)
493
+ _record(state["files"], remote.name, local_meta)
494
+ _record(manifest["files"], remote.name, remote_meta)
495
+ report.conflicts += 1
496
+ report.lines.append(
497
+ f"Conflict on {remote.name}: kept the local copy and "
498
+ f"pulled the shared one as {conflict}"
499
+ )
500
+ continue
501
+ if force or remote_meta["saved_at"] > local_meta["saved_at"]:
502
+ _copy_file(remote, local)
503
+ _record(state["files"], remote.name, remote_meta)
504
+ _record(manifest["files"], remote.name, remote_meta)
505
+ report.pulled += 1
506
+ report.lines.append(f"Pulled {remote.name}")
507
+ else:
508
+ _record(state["files"], remote.name, local_meta)
509
+ report.lines.append(f"Kept the local {remote.name} (newer)")
510
+ except OSError as exc:
511
+ report.errors.append(f"Could not sync {remote.name}: {exc}")
512
+ _finish(root, manifest, state, settings, report, claim_machine=False)
513
+ return report