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/share.py ADDED
@@ -0,0 +1,386 @@
1
+ """Redacted, shareable session bundles (local files, or a secret GitHub gist).
2
+
3
+ Sharing is deliberately local-first: a bundle is a redacted JSON snapshot of a
4
+ conversation that can be committed, pasted into a chat, or uploaded to a GitHub
5
+ gist through the user's own ``gh`` login. There is no hosted service. Tool
6
+ output is excluded by default and every string passes through
7
+ :func:`kiwimatecoder.redaction.redact`, so API keys never leave the machine.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import datetime
13
+ import json
14
+ import os
15
+ import re
16
+ import subprocess
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from kiwimatecoder import forge
21
+ from kiwimatecoder.redaction import redact
22
+ from kiwimatecoder.session import Session, save_session
23
+
24
+ SHARE_VERSION = 1
25
+ MAX_SHARE_BYTES = 256 * 1024
26
+ MAX_MESSAGE_CHARS = 16_000
27
+ MAX_TOOL_CHARS = 2048
28
+ OMITTED_TOOL_OUTPUT = "[tool output omitted]"
29
+ OMITTED_IMAGE = "[image omitted]"
30
+ SHARES_DIR_NAME = "shares"
31
+
32
+
33
+ class ShareError(Exception):
34
+ """Raised when a share bundle cannot be read, validated, or imported."""
35
+
36
+
37
+ def _truncate(text: str, limit: int) -> str:
38
+ """Clip ``text`` to ``limit`` chars, noting how much was dropped."""
39
+ if len(text) <= limit:
40
+ return text
41
+ return text[:limit] + f"\n[truncated {len(text) - limit} chars]"
42
+
43
+
44
+ def _share_text(value: Any, limit: int = MAX_MESSAGE_CHARS) -> str:
45
+ """Redact and clip one text value for a share bundle."""
46
+ return _truncate(redact(str(value)), limit)
47
+
48
+
49
+ def _share_arguments(arguments: Any) -> str:
50
+ """Keep a tool call's arguments, redacted and clipped (names stay visible)."""
51
+ if arguments is None:
52
+ return "{}"
53
+ if isinstance(arguments, str):
54
+ raw = arguments
55
+ else:
56
+ raw = json.dumps(arguments, default=str, sort_keys=True)
57
+ return _truncate(redact(raw), MAX_TOOL_CHARS)
58
+
59
+
60
+ def _share_block(block: Any, include_tool_output: bool) -> dict[str, Any]:
61
+ """Convert one content block, dropping images and (by default) tool output."""
62
+ if isinstance(block, str):
63
+ return {"type": "text", "text": _share_text(block)}
64
+ if not isinstance(block, dict):
65
+ return {"type": "text", "text": _share_text(block)}
66
+ block_type = str(block.get("type") or "text")
67
+ if block_type == "tool_result":
68
+ content = block.get("content")
69
+ if isinstance(content, str):
70
+ text = content
71
+ else:
72
+ text = "" if content is None else json.dumps(content, default=str)
73
+ return {
74
+ "type": "tool_result",
75
+ "tool_use_id": str(block.get("tool_use_id") or ""),
76
+ "content": (
77
+ _truncate(redact(text), MAX_TOOL_CHARS)
78
+ if include_tool_output
79
+ else OMITTED_TOOL_OUTPUT
80
+ ),
81
+ }
82
+ if block_type in {"image", "image_url", "input_image"}:
83
+ return {"type": "text", "text": OMITTED_IMAGE}
84
+ if block_type == "text":
85
+ return {"type": "text", "text": _share_text(block.get("text"))}
86
+ return {"type": "text", "text": _share_text(json.dumps(block, default=str))}
87
+
88
+
89
+ def _share_message(
90
+ message: dict[str, Any], include_tool_output: bool
91
+ ) -> dict[str, Any]:
92
+ """Build one redacted message, preserving roles and tool call names."""
93
+ role = str(message.get("role") or "unknown")
94
+ shared: dict[str, Any] = {"role": role}
95
+ content = message.get("content")
96
+ if isinstance(content, list):
97
+ shared["content"] = [
98
+ _share_block(block, include_tool_output) for block in content
99
+ ]
100
+ elif role == "tool":
101
+ shared["content"] = (
102
+ _share_text("" if content is None else content, MAX_TOOL_CHARS)
103
+ if include_tool_output
104
+ else OMITTED_TOOL_OUTPUT
105
+ )
106
+ else:
107
+ shared["content"] = _share_text("" if content is None else content)
108
+ if message.get("tool_call_id"):
109
+ shared["tool_call_id"] = str(message["tool_call_id"])
110
+ tool_calls = message.get("tool_calls")
111
+ if isinstance(tool_calls, list):
112
+ kept: list[dict[str, Any]] = []
113
+ for call in tool_calls:
114
+ if not isinstance(call, dict):
115
+ continue
116
+ function = call.get("function")
117
+ if not isinstance(function, dict):
118
+ function = {}
119
+ kept.append(
120
+ {
121
+ "id": str(call.get("id") or ""),
122
+ "type": str(call.get("type") or "function"),
123
+ "function": {
124
+ "name": str(function.get("name") or ""),
125
+ "arguments": _share_arguments(function.get("arguments")),
126
+ },
127
+ }
128
+ )
129
+ if kept:
130
+ shared["tool_calls"] = kept
131
+ return shared
132
+
133
+
134
+ def _content_text(message: dict[str, Any]) -> str:
135
+ """Extract plain text from one message's content (for summary/slug)."""
136
+ content = message.get("content")
137
+ if isinstance(content, list):
138
+ return "\n".join(
139
+ str(block.get("text") or "")
140
+ for block in content
141
+ if isinstance(block, dict) and block.get("type") == "text"
142
+ )
143
+ return "" if content is None else str(content)
144
+
145
+
146
+ def _summary(messages: list[dict[str, Any]]) -> str:
147
+ """The first non-empty user message, redacted and clipped for display."""
148
+ for message in messages:
149
+ if str(message.get("role") or "") != "user":
150
+ continue
151
+ cleaned = redact(" ".join(_content_text(message).split()))
152
+ if cleaned:
153
+ return _truncate(cleaned, 200)
154
+ return ""
155
+
156
+
157
+ def _slug(session: Session) -> str:
158
+ """A filename-safe slug derived from the first user message."""
159
+ for message in session.messages:
160
+ if not isinstance(message, dict) or message.get("role") != "user":
161
+ continue
162
+ words = re.findall(r"[a-z0-9]+", redact(_content_text(message)).lower())
163
+ if words:
164
+ return "-".join(words[:6])[:48].strip("-") or "session"
165
+ return "session"
166
+
167
+
168
+ def _encoded_size(bundle: dict[str, Any]) -> int:
169
+ return len(json.dumps(bundle, ensure_ascii=False, default=str).encode("utf-8"))
170
+
171
+
172
+ def _fit_bundle(bundle: dict[str, Any]) -> None:
173
+ """Drop oldest messages until the serialized bundle fits the size cap."""
174
+ messages: list[dict[str, Any]] = bundle["messages"]
175
+ if _encoded_size(bundle) <= MAX_SHARE_BYTES:
176
+ return
177
+ dropped = 0
178
+ # Set the real markers before measuring so the final size is within cap.
179
+ bundle["truncated"] = True
180
+ bundle["truncated_messages"] = 0
181
+ while messages and _encoded_size(bundle) > MAX_SHARE_BYTES:
182
+ messages.pop(0)
183
+ dropped += 1
184
+ bundle["truncated_messages"] = dropped
185
+ # Never start with an orphaned tool result (some providers reject it).
186
+ while messages and messages[0].get("role") == "tool":
187
+ messages.pop(0)
188
+ dropped += 1
189
+ bundle["truncated_messages"] = dropped
190
+
191
+
192
+ def build_share(session: Session, *, include_tool_output: bool = False) -> dict[str, Any]:
193
+ """Build a redacted share bundle for ``session``.
194
+
195
+ Tool results are replaced with a placeholder unless ``include_tool_output``
196
+ is set, in which case each result is redacted and clipped to 2 KB; tool
197
+ call arguments are always redacted and clipped. The whole bundle is capped
198
+ at ~256 KB: when the conversation is larger the oldest messages are dropped
199
+ and ``truncated``/``truncated_messages`` record it.
200
+ """
201
+ messages = [
202
+ _share_message(message, include_tool_output)
203
+ for message in session.messages
204
+ if isinstance(message, dict)
205
+ ]
206
+ bundle: dict[str, Any] = {
207
+ "version": SHARE_VERSION,
208
+ "created_at": datetime.datetime.now().isoformat(),
209
+ "provider": session.provider_id,
210
+ "model": session.model,
211
+ "mode": session.mode.value,
212
+ "summary": _summary(messages),
213
+ "messages": messages,
214
+ "todos": [dict(todo) for todo in session.todos if isinstance(todo, dict)],
215
+ "stats": {
216
+ "messages": len(messages),
217
+ "prompt_tokens": session.prompt_tokens,
218
+ "completion_tokens": session.completion_tokens,
219
+ "total_tokens": session.total_tokens,
220
+ },
221
+ "truncated": False,
222
+ "truncated_messages": 0,
223
+ }
224
+ _fit_bundle(bundle)
225
+ return bundle
226
+
227
+
228
+ def default_share_path(session: Session) -> Path:
229
+ """Default bundle path: ``<workspace>/.kiwimatecoder/shares/<ts>-<slug>.json``."""
230
+ stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
231
+ return (
232
+ session.workspace_root
233
+ / ".kiwimatecoder"
234
+ / SHARES_DIR_NAME
235
+ / f"{stamp}-{_slug(session)}.share.json"
236
+ )
237
+
238
+
239
+ def write_share(
240
+ session: Session,
241
+ dest: str | Path | None = None,
242
+ include_tool_output: bool = False,
243
+ ) -> Path:
244
+ """Write a redacted bundle atomically and return the file path.
245
+
246
+ ``dest`` may be a directory (the default share name is used inside it) or a
247
+ file path; when omitted the bundle lands in the session workspace under
248
+ ``.kiwimatecoder/shares/``.
249
+ """
250
+ bundle = build_share(session, include_tool_output=include_tool_output)
251
+ if dest is None:
252
+ target = default_share_path(session)
253
+ else:
254
+ target = Path(dest).expanduser()
255
+ if target.is_dir():
256
+ target = target / default_share_path(session).name
257
+ target.parent.mkdir(parents=True, exist_ok=True)
258
+ payload = json.dumps(bundle, indent=2, ensure_ascii=False) + "\n"
259
+ temp = target.with_name(f".{target.name}.tmp")
260
+ temp.write_text(payload, encoding="utf-8")
261
+ os.replace(temp, target)
262
+ return target
263
+
264
+
265
+ def load_share(path: str | Path) -> dict[str, Any]:
266
+ """Load and validate a share bundle, tolerating missing optional fields.
267
+
268
+ Raises :class:`ShareError` for a missing file, unreadable data, a document
269
+ that is not an object, an unsupported version, or a missing message list.
270
+ """
271
+ target = Path(path).expanduser()
272
+ if not target.is_file():
273
+ raise ShareError(f"Share file not found: {path}")
274
+ try:
275
+ data = json.loads(target.read_text(encoding="utf-8"))
276
+ except (json.JSONDecodeError, OSError) as exc:
277
+ raise ShareError(f"Cannot read share file {path}: {exc}") from exc
278
+ if not isinstance(data, dict):
279
+ raise ShareError(f"Share file {path} must be a JSON object.")
280
+ version = data.get("version")
281
+ if version != SHARE_VERSION:
282
+ raise ShareError(
283
+ f"Unsupported share version {version!r} (expected {SHARE_VERSION})."
284
+ )
285
+ messages = data.get("messages")
286
+ if not isinstance(messages, list):
287
+ raise ShareError(f"Share file {path} has no message list.")
288
+ stats = data.get("stats")
289
+ try:
290
+ truncated_messages = int(data.get("truncated_messages") or 0)
291
+ except (TypeError, ValueError):
292
+ truncated_messages = 0
293
+ return {
294
+ "version": SHARE_VERSION,
295
+ "created_at": str(data.get("created_at") or ""),
296
+ "provider": str(data.get("provider") or ""),
297
+ "model": str(data.get("model") or ""),
298
+ "mode": str(data.get("mode") or "ask"),
299
+ "summary": str(data.get("summary") or ""),
300
+ "messages": [item for item in messages if isinstance(item, dict)],
301
+ "todos": [item for item in (data.get("todos") or []) if isinstance(item, dict)],
302
+ "stats": stats if isinstance(stats, dict) else {},
303
+ "truncated": bool(data.get("truncated", False)),
304
+ "truncated_messages": truncated_messages,
305
+ }
306
+
307
+
308
+ def import_share(path: str | Path, name: str | None = None) -> Path:
309
+ """Create a normal saved session from a share bundle and return its path.
310
+
311
+ The reconstructed session can be resumed with ``/load`` or
312
+ ``--resume``; a bundle without a provider falls back to the configured
313
+ primary provider so it stays loadable.
314
+ """
315
+ bundle = load_share(path)
316
+ provider_id = bundle["provider"]
317
+ if not provider_id:
318
+ from kiwimatecoder.config import get_selected_provider_id
319
+
320
+ provider_id = get_selected_provider_id()
321
+ session = Session.from_dict(
322
+ {
323
+ "provider_id": provider_id,
324
+ "model": bundle["model"],
325
+ "mode": bundle["mode"],
326
+ "messages": bundle["messages"],
327
+ "todos": bundle["todos"],
328
+ "active_provider_ids": [provider_id],
329
+ }
330
+ )
331
+ if not name:
332
+ stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
333
+ name = f"shared_{stamp}"
334
+ return save_session(session, name)
335
+
336
+
337
+ def gist_args(path: str | Path) -> list[str]:
338
+ """Return the exact ``gh gist create`` argv for a share file (secret gist)."""
339
+ return [
340
+ "gh",
341
+ "gist",
342
+ "create",
343
+ "--public=false",
344
+ "--desc",
345
+ "kiwimatecoder session share",
346
+ str(path),
347
+ ]
348
+
349
+
350
+ def share_to_gist(
351
+ session: Session,
352
+ *,
353
+ include_tool_output: bool = False,
354
+ dest: str | Path | None = None,
355
+ ) -> tuple[bool, str]:
356
+ """Write a bundle and upload it as a secret gist via the user's ``gh`` CLI.
357
+
358
+ Returns ``(ok, url-or-error)``; failures are messages, never exceptions, so
359
+ callers can print them. ``gh`` must be installed and authenticated; no
360
+ token is read or stored by KiwiMateCoder.
361
+ """
362
+ if not forge.cli_available("gh"):
363
+ return (
364
+ False,
365
+ "gh is not installed or not on PATH. Install the GitHub CLI and "
366
+ "run `gh auth login` first.",
367
+ )
368
+ path = write_share(session, dest=dest, include_tool_output=include_tool_output)
369
+ try:
370
+ proc = subprocess.run(
371
+ gist_args(path),
372
+ capture_output=True,
373
+ text=True,
374
+ check=False,
375
+ timeout=60,
376
+ )
377
+ except FileNotFoundError:
378
+ return False, "gh is not installed or not on PATH."
379
+ except subprocess.TimeoutExpired:
380
+ return False, "gh gist create timed out after 60s."
381
+ output = (proc.stdout or "").strip()
382
+ if proc.returncode != 0:
383
+ detail = (proc.stderr or output or "gh gist create failed").strip()
384
+ return False, detail
385
+ url = output.splitlines()[0].strip() if output else ""
386
+ return True, url or "Gist created."