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
|
@@ -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)
|