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/skills.py
ADDED
|
@@ -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
|