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
kiwimatecoder/team.py ADDED
@@ -0,0 +1,249 @@
1
+ """Shared team policy overlays for multi-user repositories.
2
+
3
+ A team policy is a JSON file with the same shape as a project
4
+ ``.kiwimatecoder.json`` that is layered on top of global and project config.
5
+ Policy values always win; when ``team.enforce`` is on, each policy key replaces
6
+ the merged value wholesale and a project overlay may not add or override any
7
+ key in the policy namespace (a cloned repository cannot widen or weaken the
8
+ team's policy).
9
+
10
+ There is **no server component**: the file is read from the local path the user
11
+ configured in ``team.policy_path`` and API keys are never read from it. A
12
+ missing or corrupt policy never breaks config loading; :func:`policy_issues`
13
+ reports it and ``config validate`` / ``doctor`` surface it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import copy
19
+ import json
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ # Config keys a shared policy is allowed to control. ``team`` itself is not a
24
+ # policy key: a policy cannot redirect or disable policy loading.
25
+ POLICY_KEYS: tuple[str, ...] = (
26
+ "default_mode",
27
+ "selected_provider",
28
+ "active_providers",
29
+ "selected_model",
30
+ "model_filters",
31
+ "command_rules",
32
+ "tool_permissions",
33
+ "sampling",
34
+ "trusted_workspace",
35
+ "output_style",
36
+ "system_prompt",
37
+ "verify_command",
38
+ "budget",
39
+ "mcp_servers",
40
+ "plugins",
41
+ "subagents",
42
+ "sandbox",
43
+ "remote",
44
+ "network",
45
+ )
46
+
47
+ # Keys that are never policy-controlled: ``keys`` would carry secrets and
48
+ # ``version``/``team`` are config bookkeeping. They are ignored silently.
49
+ _IGNORED_KEYS = frozenset({"keys", "version", "team"})
50
+
51
+ # path -> (mtime, size, parsed policy); re-read only when the file changes.
52
+ _CACHE: dict[str, tuple[float, int, dict[str, Any]]] = {}
53
+
54
+
55
+ class PolicyError(Exception):
56
+ """Raised when a configured policy file is missing or unreadable."""
57
+
58
+
59
+ def policy_keys() -> tuple[str, ...]:
60
+ """Return the config keys a shared policy may control."""
61
+ return POLICY_KEYS
62
+
63
+
64
+ def clear_policy_cache() -> None:
65
+ """Drop the mtime cache (tests and callers that rewrite a policy file)."""
66
+ _CACHE.clear()
67
+
68
+
69
+ def _team_section(cfg: dict[str, Any] | None) -> dict[str, Any]:
70
+ """Return the normalized ``team`` section from ``cfg`` (or active config)."""
71
+ if cfg is None:
72
+ from kiwimatecoder import config as config_module
73
+
74
+ cfg = config_module.load_config()
75
+ stored = cfg.get("team") or {}
76
+ if not isinstance(stored, dict):
77
+ return {"policy_path": "", "enforce": False}
78
+ path = stored.get("policy_path")
79
+ enforce = stored.get("enforce")
80
+ return {
81
+ "policy_path": path.strip() if isinstance(path, str) else "",
82
+ "enforce": enforce if isinstance(enforce, bool) else False,
83
+ }
84
+
85
+
86
+ def _read_policy_file(path: Path) -> dict[str, Any]:
87
+ """Read, validate, and filter one policy file (cached by mtime+size)."""
88
+ try:
89
+ stat = path.stat()
90
+ except OSError as exc:
91
+ raise PolicyError(f"Team policy file not found: {path}") from exc
92
+ cache_key = str(path)
93
+ cached = _CACHE.get(cache_key)
94
+ if cached and cached[0] == stat.st_mtime and cached[1] == stat.st_size:
95
+ return copy.deepcopy(cached[2])
96
+ try:
97
+ raw = path.read_text(encoding="utf-8")
98
+ except OSError as exc:
99
+ raise PolicyError(f"Cannot read team policy {path}: {exc}") from exc
100
+ try:
101
+ data = json.loads(raw)
102
+ except json.JSONDecodeError as exc:
103
+ raise PolicyError(f"Team policy {path} is not valid JSON: {exc}") from exc
104
+ if not isinstance(data, dict):
105
+ raise PolicyError(f"Team policy {path} must be a JSON object.")
106
+ policy = {key: value for key, value in data.items() if key in POLICY_KEYS}
107
+ _CACHE[cache_key] = (stat.st_mtime, stat.st_size, copy.deepcopy(policy))
108
+ return policy
109
+
110
+
111
+ def load_policy(
112
+ path: str | Path | None = None, cfg: dict[str, Any] | None = None
113
+ ) -> dict[str, Any]:
114
+ """Load the shared policy, returning only policy-controlled keys.
115
+
116
+ ``path`` defaults to ``team.policy_path`` in ``cfg`` (or the active config
117
+ when ``cfg`` is None); an unset path yields an empty policy. Raises
118
+ :class:`PolicyError` for a missing file, unreadable data, invalid JSON, or
119
+ a document that is not a JSON object. ``keys`` is never read from a policy.
120
+ """
121
+ if path is None:
122
+ resolved = _team_section(cfg)["policy_path"]
123
+ if not resolved:
124
+ return {}
125
+ target = Path(resolved).expanduser()
126
+ else:
127
+ target = Path(path).expanduser()
128
+ return _read_policy_file(target)
129
+
130
+
131
+ def policy_issues(cfg: dict[str, Any] | None = None) -> list[dict[str, str]]:
132
+ """Return issues with the configured policy file.
133
+
134
+ A missing or corrupt policy never breaks config loading, so this is how
135
+ ``config validate`` and ``doctor`` surface it. Unknown policy keys are
136
+ warnings because a newer release may add keys this one does not know.
137
+ """
138
+ path = _team_section(cfg)["policy_path"]
139
+ if not path:
140
+ return []
141
+ target = Path(path).expanduser()
142
+ if not target.is_file():
143
+ return [
144
+ {
145
+ "level": "error",
146
+ "key": "team.policy_path",
147
+ "message": f"Team policy file not found: {path}",
148
+ }
149
+ ]
150
+ try:
151
+ raw = json.loads(target.read_text(encoding="utf-8"))
152
+ except (json.JSONDecodeError, OSError) as exc:
153
+ return [
154
+ {
155
+ "level": "error",
156
+ "key": "team.policy_path",
157
+ "message": f"Cannot read team policy {path}: {exc}",
158
+ }
159
+ ]
160
+ if not isinstance(raw, dict):
161
+ return [
162
+ {
163
+ "level": "error",
164
+ "key": "team.policy_path",
165
+ "message": f"Team policy {path} must be a JSON object.",
166
+ }
167
+ ]
168
+ issues: list[dict[str, str]] = []
169
+ for key in sorted(set(raw) - set(POLICY_KEYS) - _IGNORED_KEYS):
170
+ issues.append(
171
+ {
172
+ "level": "warning",
173
+ "key": f"team.policy_path.{key}",
174
+ "message": f"Unknown policy key '{key}' is ignored.",
175
+ }
176
+ )
177
+ return issues
178
+
179
+
180
+ def _deep_overlay(cfg: dict[str, Any], overlay: dict[str, Any]) -> None:
181
+ """Deep-merge ``overlay`` onto ``cfg`` (overlay values win per sub-key)."""
182
+ for key, value in overlay.items():
183
+ if isinstance(value, dict) and isinstance(cfg.get(key), dict):
184
+ merged = dict(cfg[key])
185
+ for sub_key, sub_value in value.items():
186
+ if isinstance(sub_value, dict) and isinstance(merged.get(sub_key), dict):
187
+ merged[sub_key] = {**merged[sub_key], **sub_value}
188
+ else:
189
+ merged[sub_key] = sub_value
190
+ cfg[key] = merged
191
+ else:
192
+ cfg[key] = value
193
+
194
+
195
+ def apply_policy(
196
+ cfg: dict[str, Any],
197
+ *,
198
+ project: dict[str, Any] | None = None,
199
+ global_cfg: dict[str, Any] | None = None,
200
+ ) -> dict[str, Any]:
201
+ """Apply the configured policy to ``cfg`` in place and return it.
202
+
203
+ Called by :func:`kiwimatecoder.config.load_config` after the project
204
+ overlay so policy values win. With ``team.enforce`` on, every policy key
205
+ replaces the merged value wholesale and project values for policy keys the
206
+ policy does not define are rolled back to the global config, so a
207
+ repository cannot widen or weaken the policy namespace. Failures are
208
+ swallowed so a missing/corrupt file can never break config loading;
209
+ :func:`policy_issues` reports them.
210
+ """
211
+ section = _team_section(cfg)
212
+ path = section["policy_path"]
213
+ if not path:
214
+ return {}
215
+ try:
216
+ policy = load_policy(path, cfg)
217
+ except PolicyError:
218
+ return {}
219
+ if not policy:
220
+ return {}
221
+ if section["enforce"]:
222
+ if project:
223
+ for key in POLICY_KEYS:
224
+ if key not in project or key in policy:
225
+ continue
226
+ if global_cfg is not None and key in global_cfg:
227
+ cfg[key] = copy.deepcopy(global_cfg[key])
228
+ else:
229
+ cfg.pop(key, None)
230
+ # A project selected_provider seeds active_providers before the
231
+ # policy is applied; roll that seeding back too unless the policy
232
+ # pins the roster (or a provider) itself.
233
+ if (
234
+ "selected_provider" in project
235
+ and "active_providers" not in project
236
+ and "selected_provider" not in policy
237
+ and "active_providers" not in policy
238
+ ):
239
+ if global_cfg is not None and "active_providers" in global_cfg:
240
+ cfg["active_providers"] = copy.deepcopy(
241
+ global_cfg["active_providers"]
242
+ )
243
+ else:
244
+ cfg.pop("active_providers", None)
245
+ for key, value in policy.items():
246
+ cfg[key] = copy.deepcopy(value)
247
+ else:
248
+ _deep_overlay(cfg, policy)
249
+ return policy
@@ -0,0 +1,392 @@
1
+ """Opt-in local telemetry, debug logging, and crash reports.
2
+
3
+ Nothing leaves the machine: events are appended as one compact JSON object per
4
+ line to a user-owned log (``~/.kiwimatecoder/logs/kiwimatecoder.log`` by
5
+ default) with secret-looking strings redacted. Telemetry is **off by default**
6
+ and does nothing until ``telemetry.enabled`` is true and a level other than
7
+ ``off`` is configured. The log rotates to ``<log_file>.1`` once it would grow
8
+ past ``telemetry.max_log_bytes``.
9
+
10
+ ``KIWIMATECODER_DEBUG=1`` (or ``true``/``yes``/``on``) forces the ``debug``
11
+ level for one run without changing config, which is handy when diagnosing a
12
+ stubborn provider or network issue.
13
+
14
+ Crash reports are opt-in too: :func:`install_crash_handler` (called by
15
+ :func:`configure` while telemetry is enabled) wraps ``sys.excepthook`` and
16
+ ``threading.excepthook`` to write a redacted ``crash-<ts>.json`` bundle before
17
+ delegating to the previous hook.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import datetime
23
+ import json
24
+ import os
25
+ import platform
26
+ import sys
27
+ import threading
28
+ import traceback
29
+ from pathlib import Path
30
+ from typing import Any, Callable
31
+
32
+ from kiwimatecoder import __version__, config, events
33
+ from kiwimatecoder.redaction import redact
34
+
35
+ DEBUG_ENV = "KIWIMATECODER_DEBUG"
36
+ LOG_DIR_NAME = "logs"
37
+ LOG_FILE_NAME = "kiwimatecoder.log"
38
+ CRASH_PREFIX = "crash-"
39
+ MAX_RECENT_LINES = 50
40
+ _RECENT_MAX_BYTES = 64 * 1024
41
+ _LEVEL_RANK = {"off": 0, "error": 1, "info": 2, "debug": 3}
42
+ _DEBUG_TRUTHY = {"1", "true", "yes", "on"}
43
+
44
+
45
+ def log_dir() -> Path:
46
+ """Return the telemetry log directory (resolved lazily for tests)."""
47
+ return config.CONFIG_DIR / LOG_DIR_NAME
48
+
49
+
50
+ def default_log_file() -> Path:
51
+ """Return the default telemetry log path."""
52
+ return log_dir() / LOG_FILE_NAME
53
+
54
+
55
+ def _debug_env_enabled() -> bool:
56
+ return os.environ.get(DEBUG_ENV, "").strip().lower() in _DEBUG_TRUTHY
57
+
58
+
59
+ def _chmod(path: Path, mode: int) -> None:
60
+ try:
61
+ os.chmod(path, mode)
62
+ except OSError:
63
+ pass
64
+
65
+
66
+ def _ensure_private_dir(path: Path) -> None:
67
+ path.mkdir(parents=True, exist_ok=True)
68
+ _chmod(path, 0o700)
69
+
70
+
71
+ def _write_private_text(path: Path, text: str) -> None:
72
+ _ensure_private_dir(path.parent)
73
+ tmp = path.with_name(path.name + ".kiwi.tmp")
74
+ try:
75
+ tmp.write_text(text, encoding="utf-8")
76
+ os.replace(tmp, path)
77
+ except OSError:
78
+ try:
79
+ tmp.unlink()
80
+ except OSError:
81
+ pass
82
+ raise
83
+ _chmod(path, 0o600)
84
+
85
+
86
+ def _redact_value(value: Any) -> Any:
87
+ """Recursively redact secret-looking strings in a JSON-friendly value."""
88
+ if isinstance(value, str):
89
+ return redact(value)
90
+ if isinstance(value, dict):
91
+ return {str(key): _redact_value(item) for key, item in value.items()}
92
+ if isinstance(value, (list, tuple)):
93
+ return [_redact_value(item) for item in value]
94
+ return value
95
+
96
+
97
+ class Telemetry:
98
+ """Local JSON-line telemetry with rotation and lifecycle subscriptions."""
99
+
100
+ def __init__(self) -> None:
101
+ self._enabled = False
102
+ self._level = "off"
103
+ self._log_file: Path | None = None
104
+ self._max_bytes = int(config.TELEMETRY_DEFAULTS["max_log_bytes"])
105
+ self._unsubscribers: list[Callable[[], None]] = []
106
+
107
+ @property
108
+ def enabled(self) -> bool:
109
+ """Whether telemetry is currently recording."""
110
+ return self._enabled
111
+
112
+ @property
113
+ def level(self) -> str:
114
+ """The effective level: off, error, info, or debug."""
115
+ return self._level
116
+
117
+ @property
118
+ def log_file(self) -> Path:
119
+ """The active log path (the default when unset)."""
120
+ return self._log_file or default_log_file()
121
+
122
+ def configure(
123
+ self,
124
+ cfg: dict[str, Any] | None = None,
125
+ *,
126
+ bus: events.EventBus | None = None,
127
+ ) -> None:
128
+ """Apply telemetry config, (re)subscribe, and arm crash reports.
129
+
130
+ ``KIWIMATECODER_DEBUG`` forces ``enabled``/``debug`` for this run.
131
+ Never raises: a broken config leaves telemetry off.
132
+ """
133
+ try:
134
+ settings = config.get_telemetry(cfg)
135
+ except Exception:
136
+ settings = dict(config.TELEMETRY_DEFAULTS)
137
+ enabled = bool(settings["enabled"])
138
+ level = str(settings["level"])
139
+ if _debug_env_enabled():
140
+ enabled = True
141
+ level = "debug"
142
+ self._enabled = enabled
143
+ self._level = level if enabled else "off"
144
+ log_file = str(settings["log_file"] or "").strip()
145
+ self._log_file = Path(log_file).expanduser() if log_file else default_log_file()
146
+ self._max_bytes = int(settings["max_log_bytes"])
147
+ self._detach()
148
+ if self._enabled and self._level != "off":
149
+ target_bus = bus if bus is not None else events.BUS
150
+ for name in events.EVENT_NAMES:
151
+ self._unsubscribers.append(
152
+ target_bus.subscribe(name, self._on_event)
153
+ )
154
+ install_crash_handler()
155
+ else:
156
+ uninstall_crash_handler()
157
+
158
+ def _detach(self) -> None:
159
+ for unsubscribe in self._unsubscribers:
160
+ try:
161
+ unsubscribe()
162
+ except Exception:
163
+ pass
164
+ self._unsubscribers.clear()
165
+
166
+ def _on_event(self, event: events.Event) -> None:
167
+ try:
168
+ self.log_event(event.name, **dict(event.payload))
169
+ except Exception:
170
+ pass
171
+
172
+ def log_event(self, name: str, **fields: Any) -> None:
173
+ """Append one JSON line when the current level admits the event.
174
+
175
+ Pass ``level="error"``/``"debug"`` to filter by severity; the default
176
+ is ``info``. Every string field is redacted. Never raises.
177
+ """
178
+ level = str(fields.pop("level", "info")).strip().lower() or "info"
179
+ if not self._enabled or self._level == "off":
180
+ return
181
+ if _LEVEL_RANK.get(level, 2) > _LEVEL_RANK.get(self._level, 0):
182
+ return
183
+ try:
184
+ entry: dict[str, Any] = {
185
+ "timestamp": datetime.datetime.now().isoformat(),
186
+ "level": level,
187
+ "event": redact(str(name)),
188
+ }
189
+ for key, value in fields.items():
190
+ entry[str(key)] = _redact_value(value)
191
+ line = json.dumps(
192
+ entry, ensure_ascii=False, separators=(",", ":"), default=str
193
+ )
194
+ self._append_line(line)
195
+ except Exception:
196
+ pass
197
+
198
+ def log_exception(
199
+ self, exc: BaseException, context: str | None = None
200
+ ) -> None:
201
+ """Log one exception with a redacted traceback at the error level."""
202
+ try:
203
+ tb = "".join(
204
+ traceback.format_exception(type(exc), exc, exc.__traceback__)
205
+ )
206
+ self.log_event(
207
+ "exception",
208
+ level="error",
209
+ error=redact(f"{type(exc).__name__}: {exc}"),
210
+ context=context or "",
211
+ traceback=redact(tb),
212
+ )
213
+ except Exception:
214
+ pass
215
+
216
+ def _append_line(self, line: str) -> None:
217
+ try:
218
+ path = self.log_file
219
+ _ensure_private_dir(path.parent)
220
+ payload = (line + "\n").encode("utf-8", "replace")
221
+ if path.exists() and path.stat().st_size + len(payload) > self._max_bytes:
222
+ rotated = path.with_name(path.name + ".1")
223
+ try:
224
+ os.replace(path, rotated)
225
+ except OSError:
226
+ try:
227
+ path.unlink()
228
+ except OSError:
229
+ pass
230
+ _chmod(rotated, 0o600)
231
+ with path.open("ab") as handle:
232
+ handle.write(payload)
233
+ _chmod(path, 0o600)
234
+ except OSError:
235
+ pass
236
+
237
+
238
+ TELEMETRY = Telemetry()
239
+
240
+
241
+ def configure(
242
+ cfg: dict[str, Any] | None = None, *, bus: events.EventBus | None = None
243
+ ) -> None:
244
+ """Configure the default telemetry manager."""
245
+ TELEMETRY.configure(cfg, bus=bus)
246
+
247
+
248
+ def enabled() -> bool:
249
+ """Whether the default manager is recording."""
250
+ return TELEMETRY.enabled
251
+
252
+
253
+ def log_event(name: str, **fields: Any) -> None:
254
+ """Log one event on the default manager."""
255
+ TELEMETRY.log_event(name, **fields)
256
+
257
+
258
+ def log_exception(exc: BaseException, context: str | None = None) -> None:
259
+ """Log one exception on the default manager."""
260
+ TELEMETRY.log_exception(exc, context)
261
+
262
+
263
+ def current_log_path() -> Path:
264
+ """Return the default manager's active log path."""
265
+ return TELEMETRY.log_file
266
+
267
+
268
+ def _recent_lines(limit: int = MAX_RECENT_LINES) -> list[str]:
269
+ """Return the tail of the log, bounded by bytes and lines."""
270
+ try:
271
+ path = TELEMETRY.log_file
272
+ if not path.is_file():
273
+ return []
274
+ with path.open("rb") as handle:
275
+ handle.seek(0, os.SEEK_END)
276
+ size = handle.tell()
277
+ handle.seek(max(0, size - _RECENT_MAX_BYTES))
278
+ data = handle.read()
279
+ except OSError:
280
+ return []
281
+ return data.decode("utf-8", "replace").splitlines()[-limit:]
282
+
283
+
284
+ def _unique_crash_path(now: datetime.datetime) -> Path:
285
+ base = log_dir() / f"{CRASH_PREFIX}{now.strftime('%Y%m%d_%H%M%S')}.json"
286
+ if not base.exists():
287
+ return base
288
+ counter = 1
289
+ while True:
290
+ candidate = base.with_name(f"{base.stem}-{counter}.json")
291
+ if not candidate.exists():
292
+ return candidate
293
+ counter += 1
294
+
295
+
296
+ def _write_crash_report(exc: BaseException | None) -> Path | None:
297
+ """Write a redacted crash bundle; returns its path (None when skipped)."""
298
+ try:
299
+ if not TELEMETRY.enabled or TELEMETRY.level == "off":
300
+ return None
301
+ now = datetime.datetime.now()
302
+ if isinstance(exc, BaseException):
303
+ trace = "".join(
304
+ traceback.format_exception(type(exc), exc, exc.__traceback__)
305
+ )
306
+ error = f"{type(exc).__name__}: {exc}"
307
+ else:
308
+ trace = ""
309
+ error = type(exc).__name__ if exc is not None else "unknown"
310
+ payload: dict[str, Any] = {
311
+ "timestamp": now.isoformat(),
312
+ "version": __version__,
313
+ "platform": platform.platform(),
314
+ "python": platform.python_version(),
315
+ "exception": redact(error),
316
+ "traceback": redact(trace),
317
+ "recent": [redact(line) for line in _recent_lines()],
318
+ }
319
+ data = json.dumps(payload, ensure_ascii=False, indent=2, default=str)
320
+ path = _unique_crash_path(now)
321
+ _write_private_text(path, data + "\n")
322
+ return path
323
+ except Exception:
324
+ return None
325
+
326
+
327
+ def crash_report_paths() -> list[Path]:
328
+ """Return the recorded crash bundles, oldest first."""
329
+ try:
330
+ return sorted(log_dir().glob(f"{CRASH_PREFIX}*.json"))
331
+ except OSError:
332
+ return []
333
+
334
+
335
+ _lock = threading.Lock()
336
+ _installed_sys_hook: Any = None
337
+ _installed_thread_hook: Any = None
338
+ _previous_sys_hook: Any = None
339
+ _previous_thread_hook: Any = None
340
+
341
+
342
+ def install_crash_handler() -> None:
343
+ """Wrap the excepthooks to write a crash bundle, then call the originals.
344
+
345
+ Idempotent; while telemetry is disabled the hooks are not installed (and
346
+ :func:`uninstall_crash_handler` restores whatever was there before).
347
+ """
348
+ global _installed_sys_hook, _installed_thread_hook
349
+ global _previous_sys_hook, _previous_thread_hook
350
+ with _lock:
351
+ if _installed_sys_hook is not None:
352
+ return
353
+ _previous_sys_hook = sys.excepthook
354
+ _previous_thread_hook = threading.excepthook
355
+
356
+ def _sys_hook(
357
+ exc_type: type[BaseException],
358
+ exc_value: BaseException | None,
359
+ tb: Any,
360
+ ) -> None:
361
+ try:
362
+ _write_crash_report(exc_value)
363
+ except Exception:
364
+ pass
365
+ _previous_sys_hook(exc_type, exc_value, tb)
366
+
367
+ def _thread_hook(args: threading.ExceptHookArgs) -> None:
368
+ try:
369
+ _write_crash_report(args.exc_value)
370
+ except Exception:
371
+ pass
372
+ _previous_thread_hook(args)
373
+
374
+ sys.excepthook = _sys_hook
375
+ threading.excepthook = _thread_hook
376
+ _installed_sys_hook = _sys_hook
377
+ _installed_thread_hook = _thread_hook
378
+
379
+
380
+ def uninstall_crash_handler() -> None:
381
+ """Restore the excepthooks captured by :func:`install_crash_handler`."""
382
+ global _installed_sys_hook, _installed_thread_hook
383
+ with _lock:
384
+ if _installed_sys_hook is not None and sys.excepthook is _installed_sys_hook:
385
+ sys.excepthook = _previous_sys_hook
386
+ if (
387
+ _installed_thread_hook is not None
388
+ and threading.excepthook is _installed_thread_hook
389
+ ):
390
+ threading.excepthook = _previous_thread_hook
391
+ _installed_sys_hook = None
392
+ _installed_thread_hook = None