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,247 @@
1
+ """BM25 ranking over the local code index, with optional vector blending.
2
+
3
+ The lexical ranker is a pure function over ``{path: {term: count}}`` maps so it
4
+ can be unit-tested on a tiny corpus. Optional embeddings only reweight files
5
+ that already carry vectors; if the embedding call fails for any reason the
6
+ lexical scores are returned unchanged.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from collections.abc import Mapping, Sequence
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from kiwimatecoder import config
18
+ from kiwimatecoder.index import embeddings
19
+ from kiwimatecoder.index.builder import Embedder, embeddings_configured, tokenize
20
+ from kiwimatecoder.index.store import load_store
21
+
22
+ BM25_K1 = 1.5
23
+ BM25_B = 0.75
24
+ SNIPPET_LINES = 6
25
+ VECTOR_WEIGHT = 0.5
26
+ DEFAULT_LIMIT = 10
27
+ MAX_LIMIT = 50
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class IndexHit:
32
+ """One ranked file with the best matching snippet."""
33
+
34
+ path: str
35
+ score: float
36
+ snippet: str
37
+ start_line: int
38
+
39
+
40
+ def bm25_scores(
41
+ query_terms: Sequence[str],
42
+ docs: Mapping[str, Mapping[str, int]],
43
+ df: Mapping[str, int],
44
+ *,
45
+ k1: float = BM25_K1,
46
+ b: float = BM25_B,
47
+ ) -> dict[str, float]:
48
+ """Score ``docs`` against ``query_terms`` with BM25.
49
+
50
+ Only documents sharing at least one query term are returned. ``df`` is the
51
+ document frequency of each term across the whole index.
52
+ """
53
+ total = len(docs)
54
+ if total == 0 or not query_terms:
55
+ return {}
56
+ lengths = {path: sum(counts.values()) for path, counts in docs.items()}
57
+ avgdl = sum(lengths.values()) / total
58
+ if avgdl <= 0:
59
+ return {}
60
+ scores: dict[str, float] = {}
61
+ for path, counts in docs.items():
62
+ score = 0.0
63
+ length = lengths[path]
64
+ for term in query_terms:
65
+ frequency = counts.get(term, 0)
66
+ if frequency <= 0:
67
+ continue
68
+ doc_freq = max(0, min(int(df.get(term, 0)), total))
69
+ idf = math.log(1.0 + (total - doc_freq + 0.5) / (doc_freq + 0.5))
70
+ denominator = frequency + k1 * (1.0 - b + b * length / avgdl)
71
+ score += idf * (frequency * (k1 + 1.0)) / denominator
72
+ if score > 0.0:
73
+ scores[path] = score
74
+ return scores
75
+
76
+
77
+ def best_snippet(
78
+ lines: Sequence[str],
79
+ query_terms: Sequence[str],
80
+ *,
81
+ window: int = SNIPPET_LINES,
82
+ ) -> tuple[str, int]:
83
+ """Return the ``window``-line snippet with the most query terms.
84
+
85
+ The returned line number is 1-based. When nothing matches, the first
86
+ non-blank line is used.
87
+ """
88
+
89
+ def first_content_line() -> tuple[str, int]:
90
+ for index, line in enumerate(lines):
91
+ if line.strip():
92
+ return line.strip(), index + 1
93
+ return (lines[0].strip() if lines else "", 1)
94
+
95
+ if not lines:
96
+ return "", 1
97
+ terms = set(query_terms)
98
+ if not terms:
99
+ return first_content_line()
100
+ line_scores = [len(set(tokenize(line)) & terms) for line in lines]
101
+ best_start = 0
102
+ best_total = -1
103
+ for start in range(len(lines)):
104
+ total = sum(line_scores[start : start + max(1, window)])
105
+ if total > best_total:
106
+ best_total = total
107
+ best_start = start
108
+ if best_total <= 0:
109
+ return first_content_line()
110
+ snippet = "\n".join(lines[best_start : best_start + max(1, window)]).strip()
111
+ return snippet, best_start + 1
112
+
113
+
114
+ def _read_lines(path: Path) -> list[str]:
115
+ try:
116
+ return path.read_text(encoding="utf-8", errors="replace").splitlines()
117
+ except OSError:
118
+ return []
119
+
120
+
121
+ def _vector_similarity(entry: dict[str, Any], query_vector: list[float]) -> float | None:
122
+ vectors = entry.get("vectors")
123
+ chunks = entry.get("chunks")
124
+ if (
125
+ not isinstance(vectors, list)
126
+ or not isinstance(chunks, list)
127
+ or len(vectors) != len(chunks)
128
+ or not vectors
129
+ ):
130
+ return None
131
+ best = 0.0
132
+ for vector in vectors:
133
+ if isinstance(vector, list):
134
+ best = max(best, max(0.0, embeddings.cosine(query_vector, vector)))
135
+ return best
136
+
137
+
138
+ def entry_has_vectors(entry: dict[str, Any]) -> bool:
139
+ """Whether an entry carries one vector per stored chunk."""
140
+ vectors = entry.get("vectors")
141
+ chunks = entry.get("chunks")
142
+ return (
143
+ isinstance(vectors, list)
144
+ and isinstance(chunks, list)
145
+ and len(vectors) == len(chunks)
146
+ and len(vectors) > 0
147
+ )
148
+
149
+
150
+ def search_index(
151
+ query: str,
152
+ *,
153
+ limit: int = DEFAULT_LIMIT,
154
+ workspace_root: Path,
155
+ embedder: Embedder | None = None,
156
+ ) -> list[IndexHit]:
157
+ """Rank indexed files against ``query`` and return up to ``limit`` hits.
158
+
159
+ Lexical BM25 drives the ranking. When embeddings are configured (or an
160
+ ``embedder`` is supplied) and the query embeds successfully, each file's
161
+ normalized lexical score is blended 50/50 with its best chunk similarity;
162
+ files without vectors keep their lexical score. Any embedding failure is
163
+ ignored.
164
+ """
165
+ settings = config.get_index()
166
+ if not settings["enabled"]:
167
+ return []
168
+ try:
169
+ limit = max(1, min(int(limit), MAX_LIMIT))
170
+ except (TypeError, ValueError):
171
+ limit = DEFAULT_LIMIT
172
+ query_terms = tokenize(query)
173
+ if not query_terms:
174
+ return []
175
+
176
+ root = Path(workspace_root).resolve()
177
+ store = load_store(root)
178
+ files: dict[str, dict[str, Any]] = store["files"]
179
+ if not files:
180
+ return []
181
+ docs = {
182
+ rel: entry.get("tokens", {})
183
+ for rel, entry in files.items()
184
+ if isinstance(entry.get("tokens"), dict)
185
+ }
186
+ scores = bm25_scores(query_terms, docs, store["df"])
187
+ if not scores:
188
+ return []
189
+
190
+ embed = embedder
191
+ if embed is None and embeddings_configured(settings):
192
+ vector_targets = [rel for rel in scores if entry_has_vectors(files[rel])]
193
+ if vector_targets:
194
+ embed = _query_embedder(settings)
195
+ if embed is not None:
196
+ try:
197
+ vectors = embed([query])
198
+ except Exception:
199
+ vectors = []
200
+ if vectors:
201
+ scores = _blend(scores, files, vectors[0])
202
+
203
+ ranked = sorted(scores.items(), key=lambda item: (-item[1], item[0]))[:limit]
204
+ hits: list[IndexHit] = []
205
+ for rel, score in ranked:
206
+ lines = _read_lines(root / rel)
207
+ snippet, start = best_snippet(lines, query_terms)
208
+ hits.append(
209
+ IndexHit(
210
+ path=rel, score=round(score, 6), snippet=snippet, start_line=start
211
+ )
212
+ )
213
+ return hits
214
+
215
+
216
+ def _query_embedder(settings: dict[str, Any]) -> Embedder:
217
+ embedding_settings = settings["embeddings"]
218
+ provider = str(embedding_settings["provider"])
219
+ model = str(embedding_settings["model"])
220
+ batch_size = int(embedding_settings["batch_size"])
221
+
222
+ def embedder(texts: list[str]) -> list[list[float]]:
223
+ return embeddings.embed_texts(
224
+ texts, provider, model, batch_size=batch_size
225
+ )
226
+
227
+ return embedder
228
+
229
+
230
+ def _blend(
231
+ scores: dict[str, float],
232
+ files: dict[str, dict[str, Any]],
233
+ query_vector: list[float],
234
+ ) -> dict[str, float]:
235
+ """Blend lexical scores with vector similarity for vector-bearing files."""
236
+ largest = max(scores.values())
237
+ if largest <= 0:
238
+ return scores
239
+ blended: dict[str, float] = {}
240
+ for rel, score in scores.items():
241
+ lexical = score / largest
242
+ similarity = _vector_similarity(files[rel], query_vector)
243
+ if similarity is None:
244
+ blended[rel] = lexical
245
+ else:
246
+ blended[rel] = (1.0 - VECTOR_WEIGHT) * lexical + VECTOR_WEIGHT * similarity
247
+ return blended
@@ -0,0 +1,251 @@
1
+ """Versioned JSON persistence for the codebase index.
2
+
3
+ Each workspace gets its own store at
4
+ ``~/.kiwimatecoder/index/<sha1(root)[:16]>.json``. The store is written
5
+ atomically and every read tolerates a missing, truncated, or otherwise corrupt
6
+ file by returning an empty store, since the index is a cache that can always be
7
+ rebuilt.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import time
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from kiwimatecoder import config
20
+ from kiwimatecoder.tools.paths import atomic_write_text
21
+
22
+ INDEX_VERSION = 1
23
+ INDEX_DIR_NAME = "index"
24
+
25
+
26
+ def store_path(workspace_root: Path) -> Path:
27
+ """Return the store path for ``workspace_root`` (directory may not exist)."""
28
+ root = Path(workspace_root).resolve()
29
+ digest = hashlib.sha1(str(root).encode("utf-8")).hexdigest()[:16]
30
+ return config.ensure_config_dir() / INDEX_DIR_NAME / f"{digest}.json"
31
+
32
+
33
+ def empty_store(workspace_root: Path) -> dict[str, Any]:
34
+ """Return a fresh, empty store for ``workspace_root``."""
35
+ root = Path(workspace_root).resolve()
36
+ return {
37
+ "version": INDEX_VERSION,
38
+ "root": str(root),
39
+ "files": {},
40
+ "df": {},
41
+ "built_at": None,
42
+ }
43
+
44
+
45
+ def compute_df(files: dict[str, dict[str, Any]]) -> dict[str, int]:
46
+ """Compute the document-frequency map for a ``rel -> entry`` mapping."""
47
+ df: dict[str, int] = {}
48
+ for entry in files.values():
49
+ tokens = entry.get("tokens")
50
+ if not isinstance(tokens, dict):
51
+ continue
52
+ for term in tokens:
53
+ df[str(term)] = df.get(str(term), 0) + 1
54
+ return df
55
+
56
+
57
+ def _clean_tokens(raw: object) -> dict[str, int]:
58
+ if not isinstance(raw, dict):
59
+ return {}
60
+ tokens: dict[str, int] = {}
61
+ for term, count in raw.items():
62
+ try:
63
+ value = int(count)
64
+ except (TypeError, ValueError):
65
+ continue
66
+ if value > 0:
67
+ tokens[str(term)] = value
68
+ return tokens
69
+
70
+
71
+ def _clean_chunks(raw: object) -> list[dict[str, Any]] | None:
72
+ if raw is None:
73
+ return None
74
+ if not isinstance(raw, list):
75
+ return None
76
+ chunks: list[dict[str, Any]] = []
77
+ for item in raw:
78
+ if not isinstance(item, dict) or not isinstance(item.get("text"), str):
79
+ return None
80
+ try:
81
+ start = int(item.get("start") or 1)
82
+ except (TypeError, ValueError):
83
+ start = 1
84
+ chunks.append({"start": max(1, start), "text": item["text"]})
85
+ return chunks
86
+
87
+
88
+ def _clean_vectors(raw: object, expected: int) -> list[list[float]] | None:
89
+ if raw is None or expected <= 0:
90
+ return None
91
+ if not isinstance(raw, list) or len(raw) != expected:
92
+ return None
93
+ vectors: list[list[float]] = []
94
+ for vector in raw:
95
+ if not isinstance(vector, list) or not vector:
96
+ return None
97
+ try:
98
+ vectors.append([float(value) for value in vector])
99
+ except (TypeError, ValueError):
100
+ return None
101
+ return vectors
102
+
103
+
104
+ def _clean_entry(raw: object) -> dict[str, Any] | None:
105
+ if not isinstance(raw, dict):
106
+ return None
107
+ tokens = raw.get("tokens")
108
+ if not isinstance(tokens, dict):
109
+ return None
110
+ try:
111
+ mtime = float(raw.get("mtime") or 0.0)
112
+ size = int(raw.get("size") or 0)
113
+ except (TypeError, ValueError):
114
+ return None
115
+ entry: dict[str, Any] = {
116
+ "mtime": mtime,
117
+ "size": size,
118
+ "tokens": _clean_tokens(tokens),
119
+ }
120
+ chunks = _clean_chunks(raw.get("chunks"))
121
+ if chunks is not None:
122
+ entry["chunks"] = chunks
123
+ vectors = _clean_vectors(raw.get("vectors"), len(chunks))
124
+ if vectors is not None:
125
+ entry["vectors"] = vectors
126
+ return entry
127
+
128
+
129
+ def load_store(workspace_root: Path) -> dict[str, Any]:
130
+ """Load the store for ``workspace_root``, tolerating any corruption.
131
+
132
+ A missing file, invalid JSON, version mismatch, or wrong workspace root all
133
+ yield an :func:`empty_store` result so callers can simply rebuild.
134
+ """
135
+ root = Path(workspace_root).resolve()
136
+ path = store_path(root)
137
+ try:
138
+ stored = json.loads(path.read_text(encoding="utf-8"))
139
+ except (OSError, json.JSONDecodeError, ValueError):
140
+ return empty_store(root)
141
+ if not isinstance(stored, dict) or stored.get("version") != INDEX_VERSION:
142
+ return empty_store(root)
143
+ if str(stored.get("root") or "") != str(root):
144
+ return empty_store(root)
145
+ raw_files = stored.get("files")
146
+ if not isinstance(raw_files, dict):
147
+ return empty_store(root)
148
+
149
+ files: dict[str, dict[str, Any]] = {}
150
+ for rel, entry in raw_files.items():
151
+ if not isinstance(rel, str):
152
+ continue
153
+ cleaned = _clean_entry(entry)
154
+ if cleaned is not None:
155
+ files[rel] = cleaned
156
+
157
+ df: dict[str, int] = {}
158
+ raw_df = stored.get("df")
159
+ if isinstance(raw_df, dict):
160
+ for term, count in raw_df.items():
161
+ try:
162
+ value = int(count)
163
+ except (TypeError, ValueError):
164
+ continue
165
+ if value > 0:
166
+ df[str(term)] = value
167
+ if not df and files:
168
+ df = compute_df(files)
169
+
170
+ built_at = stored.get("built_at")
171
+ if not isinstance(built_at, (int, float)):
172
+ built_at = None
173
+ return {
174
+ "version": INDEX_VERSION,
175
+ "root": str(root),
176
+ "files": files,
177
+ "df": df,
178
+ "built_at": built_at,
179
+ }
180
+
181
+
182
+ def save_store(workspace_root: Path, store: dict[str, Any]) -> None:
183
+ """Write ``store`` atomically, stamping version, root, and build time."""
184
+ root = Path(workspace_root).resolve()
185
+ path = store_path(root)
186
+ path.parent.mkdir(parents=True, exist_ok=True)
187
+ store["version"] = INDEX_VERSION
188
+ store["root"] = str(root)
189
+ store["built_at"] = time.time()
190
+ atomic_write_text(path, json.dumps(store, separators=(",", ":")))
191
+
192
+
193
+ def clear_store(workspace_root: Path) -> bool:
194
+ """Delete the store for ``workspace_root``; returns whether it existed."""
195
+ path = store_path(workspace_root)
196
+ try:
197
+ path.unlink()
198
+ except FileNotFoundError:
199
+ return False
200
+ except OSError:
201
+ return False
202
+ return True
203
+
204
+
205
+ @dataclass(frozen=True)
206
+ class IndexStatus:
207
+ """Human-facing snapshot of the index for one workspace."""
208
+
209
+ enabled: bool
210
+ path: str
211
+ files: int
212
+ terms: int
213
+ size_bytes: int
214
+ stale: int
215
+ embeddings: bool
216
+ built_at: float | None
217
+
218
+
219
+ def index_status(
220
+ workspace_root: Path, *, cfg: dict[str, Any] | None = None
221
+ ) -> IndexStatus:
222
+ """Summarize the store, counting indexed files whose stat changed."""
223
+ settings = config.get_index(cfg)
224
+ root = Path(workspace_root).resolve()
225
+ store = load_store(root)
226
+ files = store["files"]
227
+ stale = 0
228
+ for rel, entry in files.items():
229
+ try:
230
+ stat = (root / rel).stat()
231
+ except OSError:
232
+ stale += 1
233
+ continue
234
+ if stat.st_mtime != entry.get("mtime") or stat.st_size != entry.get("size"):
235
+ stale += 1
236
+ path = store_path(root)
237
+ try:
238
+ size_bytes = path.stat().st_size
239
+ except OSError:
240
+ size_bytes = 0
241
+ embeddings = settings["embeddings"]
242
+ return IndexStatus(
243
+ enabled=bool(settings["enabled"]),
244
+ path=str(path),
245
+ files=len(files),
246
+ terms=len(store["df"]),
247
+ size_bytes=size_bytes,
248
+ stale=stale,
249
+ embeddings=bool(embeddings["provider"] and embeddings["model"]),
250
+ built_at=store.get("built_at"),
251
+ )
@@ -0,0 +1,83 @@
1
+ """Project instruction files (``AGENTS.md`` and friends).
2
+
3
+ Instruction files are project-owned guidance that the agent should follow:
4
+ build commands, style rules, testing conventions. They are loaded from the
5
+ workspace root and injected into the system prompt with a size budget so a
6
+ pathological file cannot crowd out the conversation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ # Checked in order; the first matching file for each name wins.
14
+ INSTRUCTION_FILES: tuple[str, ...] = (
15
+ "AGENTS.md",
16
+ "CLAUDE.md",
17
+ ".kiwimatecoder/AGENTS.md",
18
+ ".kiwimatecoder/instructions.md",
19
+ )
20
+
21
+ MAX_INSTRUCTION_FILE_BYTES = 32 * 1024
22
+ MAX_INSTRUCTION_TOTAL_BYTES = 48 * 1024
23
+
24
+
25
+ def discover_instruction_files(workspace_root: Path) -> list[Path]:
26
+ """Return existing instruction files under ``workspace_root``, in order."""
27
+ found: list[Path] = []
28
+ for relative in INSTRUCTION_FILES:
29
+ candidate = workspace_root / relative
30
+ if candidate.is_file():
31
+ found.append(candidate)
32
+ return found
33
+
34
+
35
+ def load_instructions(
36
+ workspace_root: Path,
37
+ *,
38
+ file_budget: int = MAX_INSTRUCTION_FILE_BYTES,
39
+ total_budget: int = MAX_INSTRUCTION_TOTAL_BYTES,
40
+ ) -> list[tuple[str, str]]:
41
+ """Load instruction files as ``(relative_path, text)`` pairs.
42
+
43
+ Returns an empty list when there are none. Missing/unreadable/binary files
44
+ are skipped. Total and per-file byte budgets are enforced.
45
+ """
46
+ loaded: list[tuple[str, str]] = []
47
+ remaining = max(0, total_budget)
48
+ for path in discover_instruction_files(workspace_root):
49
+ if remaining <= 0:
50
+ break
51
+ limit = min(file_budget, remaining)
52
+ try:
53
+ raw = path.read_bytes()
54
+ except OSError:
55
+ continue
56
+ if b"\x00" in raw[:1024]:
57
+ continue
58
+ clipped = raw[:limit]
59
+ text = clipped.decode("utf-8", "replace").strip()
60
+ if not text:
61
+ continue
62
+ truncated = len(raw) > len(clipped)
63
+ if truncated:
64
+ text += f"\n\n[truncated: showing {len(clipped)} of {len(raw)} bytes]"
65
+ loaded.append((path.relative_to(workspace_root).as_posix(), text))
66
+ remaining -= len(clipped)
67
+ return loaded
68
+
69
+
70
+ def instructions_section(workspace_root: Path) -> str:
71
+ """Render the project-instructions block for the system prompt."""
72
+ loaded = load_instructions(workspace_root)
73
+ if not loaded:
74
+ return ""
75
+ blocks = [
76
+ "Project instructions follow. These come from the repository and should "
77
+ "be followed for work in this workspace:"
78
+ ]
79
+ for relative, text in loaded:
80
+ blocks.append(
81
+ f'<project_instructions path="{relative}">\n{text}\n</project_instructions>'
82
+ )
83
+ return "\n\n" + "\n\n".join(blocks)