cli-tools-kit 0.6.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.
@@ -0,0 +1,40 @@
1
+ """Group a tool tree into named bands by what the tools are for.
2
+
3
+ The installer bands its rows by *something*. ``group_by="capability"`` uses the
4
+ one word each tool advertises, which needs no computation. This package is the
5
+ other option: it reads what a tree already documents about itself and produces
6
+ a small set of named categories, which is what you want once a tree has more
7
+ tools than a person can scan.
8
+
9
+ Three tiers, tried in order, so the feature degrades instead of failing:
10
+
11
+ 1. **LLM** — name six categories, file every tool into them, then review the
12
+ result and let the model move misfits and rename a category to match what it
13
+ actually holds. Uses Gemini when ``GEMINI_API_KEY`` is reachable, otherwise a
14
+ local LM Studio / Ollama server. This is the tier that produces good names.
15
+ 2. **capability** — band the advertised capability words into a fixed set of
16
+ six. No network, no model, instant, and the names are stable.
17
+ 3. **embed + k-means** — embed the documents and cluster them, naming each
18
+ cluster after its most distinctive token. Names are poor ("git", "clipboard")
19
+ but the tree still opens.
20
+
21
+ :func:`~cli_tools_kit.taxonomy.groups.ensure_groups` is the entry point a
22
+ wrapper's discoverer calls. It fingerprints the tree's documents and rebuilds
23
+ only when they changed, so the common case costs a couple of milliseconds.
24
+
25
+ from cli_tools_kit.taxonomy import ensure_groups
26
+ groups = ensure_groups(root) # {tool_name: band label}
27
+ """
28
+
29
+ from .embedder import EmbeddingUnavailable, embed_texts
30
+ from .groups import ensure_groups, load_groups
31
+ from .build import build_groups, DEFAULT_K
32
+
33
+ __all__ = [
34
+ "EmbeddingUnavailable",
35
+ "embed_texts",
36
+ "ensure_groups",
37
+ "load_groups",
38
+ "build_groups",
39
+ "DEFAULT_K",
40
+ ]
@@ -0,0 +1,171 @@
1
+ """Compute the tool grouping and write data/tool_groups.json.
2
+
3
+ The one place that decides the bands and writes the file. regroup.py calls it
4
+ to rebuild on demand; groups.ensure_groups calls it when the installer starts
5
+ and the stored grouping no longer matches the corpus.
6
+
7
+ Three ways to get there, tried in order:
8
+
9
+ 1. **gemini** — reads a condensed blurb per tool and returns named categories
10
+ with their members. The normal path: naming a category is a language task.
11
+ 2. **capability**, when a grouping is already stored — keep those bands and
12
+ their names, and file only the tools missing from them by the capability
13
+ word they advertise. No network. Preferred over re-embedding because it
14
+ preserves names a model chose over names tf-idf would invent.
15
+ 3. **embed** — no stored grouping to extend, so cluster the full documents and
16
+ name each cluster after its most distinctive token. Needs an embedding
17
+ backend; if that is missing too, fall back to capability words alone.
18
+
19
+ Only the first needs an API key, and only steps 1 and 3 need a network. A
20
+ distributed checkout ships with data/tool_groups.json already written, so it
21
+ never reaches any of them until its owner adds a tool of their own.
22
+ """
23
+
24
+ import datetime
25
+ import json
26
+ import os
27
+ import tempfile
28
+ from typing import Dict, List, Optional, Tuple
29
+
30
+ from .capability import capability_groups
31
+ from .cluster import cluster_documents
32
+ from .corpus import (
33
+ corpus_fingerprint,
34
+ tool_blurbs,
35
+ tool_capabilities,
36
+ tool_documents,
37
+ )
38
+ from .embedder import EmbeddingUnavailable, backend_name, embed_texts
39
+ from .groups import groups_path, read_groups_file
40
+ from .llm_groups import (
41
+ LLMGroupingUnavailable,
42
+ active_label,
43
+ llm_groups,
44
+ )
45
+
46
+ DEFAULT_K = 6
47
+ DEFAULT_SEED = 0
48
+
49
+
50
+ def build_groups(
51
+ root: str,
52
+ k: int = DEFAULT_K,
53
+ seed: int = DEFAULT_SEED,
54
+ write: bool = True,
55
+ method: str = "auto",
56
+ budget: Optional[float] = None,
57
+ rename: bool = False,
58
+ ) -> dict:
59
+ """Decide the grouping and (unless write=False) store it.
60
+
61
+ ``budget`` caps the LLM tier in seconds — the installer passes one, a
62
+ deliberate ``regroup.py`` run does not. ``rename`` throws the stored
63
+ category names away and asks for six fresh ones.
64
+
65
+ ``method`` is "auto" (the chain described above), or one tier by name:
66
+ "llm", "embed" or "capability". Raises ValueError when the tree holds no
67
+ tools, and EmbeddingUnavailable / LLMGroupingUnavailable when a single tier
68
+ was asked for and could not run. Existing overrides are carried over
69
+ untouched.
70
+ """
71
+ from .corpus import tool_dirs
72
+
73
+ names = tool_dirs(root)
74
+ if not names:
75
+ raise ValueError("no tools found")
76
+ previous = read_groups_file(root)
77
+ stored = previous.get("labels") or {}
78
+ categories = None if rename else previous.get("categories")
79
+ if not isinstance(categories, list) or len(categories) != k:
80
+ categories = None
81
+
82
+ labels: Dict[str, List[str]] = {}
83
+ used = ""
84
+ if method in ("auto", "llm"):
85
+ try:
86
+ labels = llm_groups(
87
+ tool_blurbs(root), k, budget=budget, categories=categories
88
+ )
89
+ used = active_label() or "llm"
90
+ except LLMGroupingUnavailable:
91
+ if method == "llm":
92
+ raise
93
+
94
+ if not labels and method in ("auto", "capability"):
95
+ if stored or method == "capability":
96
+ labels = capability_groups(tool_capabilities(root), stored or None)
97
+ used = "capability" + (":extended" if stored else "")
98
+
99
+ if not labels and method in ("auto", "embed"):
100
+ try:
101
+ labels = _embed_groups(root, names, k, seed)
102
+ used = f"embed-kmeans:{backend_name()}"
103
+ except EmbeddingUnavailable:
104
+ if method == "embed":
105
+ raise
106
+
107
+ if not labels:
108
+ labels = capability_groups(tool_capabilities(root), stored or None)
109
+ used = "capability"
110
+
111
+ payload = {
112
+ "generated": datetime.date.today().isoformat(),
113
+ "k": k,
114
+ "seed": seed,
115
+ "method": used,
116
+ "categories": _carry_scopes(labels, categories),
117
+ "fingerprint": corpus_fingerprint(root),
118
+ "labels": labels,
119
+ "overrides": read_groups_file(root).get("overrides") or {},
120
+ }
121
+ if write:
122
+ write_groups_file(root, payload)
123
+ return payload
124
+
125
+
126
+ def _carry_scopes(
127
+ labels: Dict[str, List[str]], categories: Optional[List[dict]]
128
+ ) -> List[dict]:
129
+ """The band names to reuse next time, keeping the scope of each survivor.
130
+
131
+ A band the review step renamed keeps its members but loses its scope; that
132
+ is fine, the scope only steers the next assignment.
133
+ """
134
+ scopes = {c.get("name"): c.get("scope", "") for c in (categories or [])}
135
+ return [{"name": name, "scope": scopes.get(name, "")} for name in labels]
136
+
137
+
138
+ def _embed_groups(
139
+ root: str, names: List[str], k: int, seed: int
140
+ ) -> Dict[str, List[str]]:
141
+ """Offline fallback: embed the full documents and k-means them."""
142
+ docs = tool_documents(root)
143
+ ordered = sorted(docs)
144
+ vectors = embed_texts([docs[n] for n in ordered])
145
+ groups: List[Tuple[str, List[str]]] = cluster_documents(
146
+ ordered, vectors, docs, k, seed=seed
147
+ )
148
+ return {label: members for label, members in groups}
149
+
150
+
151
+ def write_groups_file(root: str, payload: dict) -> str:
152
+ """Write the payload atomically so a concurrent reader sees whole JSON."""
153
+ path = groups_path(root)
154
+ os.makedirs(os.path.dirname(path), exist_ok=True)
155
+ fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
156
+ try:
157
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
158
+ json.dump(payload, fh, indent=2, ensure_ascii=False)
159
+ fh.write("\n")
160
+ os.replace(tmp, path)
161
+ except BaseException:
162
+ try:
163
+ os.unlink(tmp)
164
+ except OSError:
165
+ pass
166
+ raise
167
+ return path
168
+
169
+
170
+ __all__ = ["DEFAULT_K", "DEFAULT_SEED", "build_groups", "write_groups_file",
171
+ "EmbeddingUnavailable", "LLMGroupingUnavailable"]
@@ -0,0 +1,116 @@
1
+ """Group tools by the capability word they advertise. No network, no model.
2
+
3
+ The last tier of the grouping chain, and the only one that works on a host with
4
+ no API key and no local embedding server — which is every host but this fleet's,
5
+ since the key is read from the repo's own .env. A distributed checkout gets its
6
+ bands from the committed data/tool_groups.json; this is what happens when
7
+ someone then adds a tool of their own.
8
+
9
+ Two modes. With an existing grouping it is *incremental*: the stored bands and
10
+ their names are kept exactly as they are, and only tools missing from them are
11
+ filed, next to whichever band already holds their capability. Without one it
12
+ builds the bands from DEFAULT_BANDS below.
13
+ """
14
+
15
+ from typing import Dict, List, Optional, Sequence, Tuple
16
+
17
+ # Every word in validate_structure.CAPABILITY_VOCAB has a home here. A tool
18
+ # whose capability is missing or outside the vocabulary lands in the smallest
19
+ # band, which is the honest answer: nothing about it says where it belongs.
20
+ DEFAULT_BANDS: Dict[str, Tuple[str, ...]] = {
21
+ "AI & Chat": ("agent", "llm-chat", "chat"),
22
+ "Audio & Speech": ("tts", "stt", "audio-visualizer"),
23
+ "Vision & Creation": ("image-gen", "image-edit", "ocr", "vision-search"),
24
+ "Media & Publishing": (
25
+ "scrape", "summarize", "download", "media-launcher", "publish",
26
+ "pdf-extract",
27
+ ),
28
+ "Developer Tools": (
29
+ "commit-gen", "dep-check", "forge", "repo-hygiene", "finetune",
30
+ ),
31
+ "System & Context": ("smarthome", "vnc-display", "context-picker"),
32
+ }
33
+
34
+ _BAND_OF_CAPABILITY = {
35
+ capability: band
36
+ for band, capabilities in DEFAULT_BANDS.items()
37
+ for capability in capabilities
38
+ }
39
+
40
+
41
+ def _smallest(labels: Dict[str, List[str]]) -> str:
42
+ """Name of the least populated band, ties broken alphabetically."""
43
+ return min(labels, key=lambda band: (len(labels[band]), band))
44
+
45
+
46
+ def capability_groups(
47
+ capabilities: Dict[str, str],
48
+ existing: Optional[Dict[str, Sequence[str]]] = None,
49
+ ) -> Dict[str, List[str]]:
50
+ """Band name -> members, decided by each tool's capability word.
51
+
52
+ ``existing`` is a stored grouping to extend rather than replace: its band
53
+ names survive untouched, tools that no longer exist are dropped, and only
54
+ the newcomers are placed.
55
+ """
56
+ if not capabilities:
57
+ return {}
58
+
59
+ if existing:
60
+ labels = {
61
+ band: [tool for tool in members if tool in capabilities]
62
+ for band, members in existing.items()
63
+ }
64
+ labels = {band: members for band, members in labels.items() if members}
65
+ else:
66
+ labels = {}
67
+
68
+ if not labels:
69
+ for tool, capability in capabilities.items():
70
+ band = _BAND_OF_CAPABILITY.get(capability)
71
+ if band:
72
+ labels.setdefault(band, []).append(tool)
73
+ if not labels:
74
+ labels = {"AI & Chat": []}
75
+
76
+ # Which band already holds each capability, and each family of related
77
+ # capabilities, by weight of numbers.
78
+ home: Dict[str, Dict[str, int]] = {}
79
+ family: Dict[str, Dict[str, int]] = {}
80
+
81
+ def _record(capability: str, band: str) -> None:
82
+ if not capability:
83
+ return
84
+ home.setdefault(capability, {})
85
+ home[capability][band] = home[capability].get(band, 0) + 1
86
+ default = _BAND_OF_CAPABILITY.get(capability)
87
+ if default:
88
+ family.setdefault(default, {})
89
+ family[default][band] = family[default].get(band, 0) + 1
90
+
91
+ for band, members in labels.items():
92
+ for tool in members:
93
+ _record(capabilities.get(tool, ""), band)
94
+
95
+ def _pick(counts: Dict[str, int]) -> str:
96
+ return max(counts, key=lambda band: (counts[band], band))
97
+
98
+ for tool in sorted(capabilities):
99
+ if any(tool in members for members in labels.values()):
100
+ continue
101
+ capability = capabilities[tool]
102
+ default = _BAND_OF_CAPABILITY.get(capability)
103
+ if home.get(capability):
104
+ # A band already holds this exact capability.
105
+ band = _pick(home[capability])
106
+ elif default and family.get(default):
107
+ # No exact match, but a band holds its siblings: stt joins tts.
108
+ band = _pick(family[default])
109
+ elif default in labels:
110
+ band = default
111
+ else:
112
+ band = _smallest(labels)
113
+ labels[band].append(tool)
114
+ _record(capability, band)
115
+
116
+ return {band: sorted(members) for band, members in labels.items() if members}
@@ -0,0 +1,261 @@
1
+ """Deterministic k-means over normalised vectors, plus group labelling.
2
+
3
+ Pure python, no numpy, so the result is identical on every host. Vectors are
4
+ L2-normalised, which makes squared euclidean distance a stand-in for cosine
5
+ distance.
6
+ """
7
+
8
+ import math
9
+ import random
10
+ import re
11
+ from typing import Dict, List, Sequence, Tuple
12
+
13
+ STOPWORDS = {
14
+ "a", "about", "after", "all", "also", "an", "and", "any", "are", "as", "at",
15
+ "back", "be", "because", "been", "before", "being", "below", "between",
16
+ "both", "but", "by", "can", "claude", "code", "com", "d", "de", "did", "do",
17
+ "does", "doing", "done", "each", "etc", "even", "every", "few", "file",
18
+ "files", "for", "from", "get", "gets", "github", "had", "has", "have", "he",
19
+ "her", "here", "how", "http", "https", "i", "if", "in", "install", "into",
20
+ "is", "it", "its", "just", "like", "made", "main", "make", "makes", "many",
21
+ "md", "more", "most", "must", "my", "need", "needs", "new", "no", "not",
22
+ "now", "of", "off", "on", "once", "one", "only", "or", "other", "our",
23
+ "out", "over", "own", "path", "py", "python", "readme", "repo", "run",
24
+ "runs", "same", "script", "see", "set", "she", "should", "since", "so",
25
+ "some", "still", "such", "than", "that", "the", "their", "them", "then",
26
+ "there", "these", "they", "this", "those", "through", "to", "too", "tool",
27
+ "tools", "two", "under", "until", "up", "us", "use", "used", "uses",
28
+ "using", "very", "was", "way", "we", "were", "what", "when", "where",
29
+ "python3", "json", "name", "names", "default", "defaults", "example",
30
+ "examples", "args", "arg", "note", "notes", "directory", "dir", "output",
31
+ "which", "while", "who", "why", "will", "with", "would", "you", "your",
32
+ }
33
+
34
+ _TOKEN_RE = re.compile(r"[a-z0-9]+")
35
+
36
+
37
+ def normalize(vectors: Sequence[Sequence[float]]) -> List[List[float]]:
38
+ """Scale every vector to unit length."""
39
+ out = []
40
+ for vec in vectors:
41
+ norm = math.sqrt(sum(x * x for x in vec))
42
+ if norm == 0.0:
43
+ out.append([0.0] * len(vec))
44
+ else:
45
+ out.append([x / norm for x in vec])
46
+ return out
47
+
48
+
49
+ def _sqdist(a: Sequence[float], b: Sequence[float]) -> float:
50
+ return sum((x - y) * (x - y) for x, y in zip(a, b))
51
+
52
+
53
+ def _kmeans_plusplus(vectors: List[List[float]], k: int, rng: random.Random) -> List[List[float]]:
54
+ """Pick k starting centroids, spread out, using the given rng."""
55
+ first = rng.randrange(len(vectors))
56
+ centroids = [list(vectors[first])]
57
+ while len(centroids) < k:
58
+ dists = [min(_sqdist(v, c) for c in centroids) for v in vectors]
59
+ total = sum(dists)
60
+ if total <= 0.0:
61
+ centroids.append(list(vectors[rng.randrange(len(vectors))]))
62
+ continue
63
+ target = rng.random() * total
64
+ running = 0.0
65
+ chosen = len(vectors) - 1
66
+ for i, d in enumerate(dists):
67
+ running += d
68
+ if running >= target:
69
+ chosen = i
70
+ break
71
+ centroids.append(list(vectors[chosen]))
72
+ return centroids
73
+
74
+
75
+ def _assign(vectors: List[List[float]], centroids: List[List[float]]) -> List[int]:
76
+ """Assign each vector to its nearest centroid."""
77
+ labels = []
78
+ for vec in vectors:
79
+ best, best_d = 0, None
80
+ for ci, cen in enumerate(centroids):
81
+ d = _sqdist(vec, cen)
82
+ if best_d is None or d < best_d:
83
+ best, best_d = ci, d
84
+ labels.append(best)
85
+ return labels
86
+
87
+
88
+ def _assign_balanced(
89
+ vectors: List[List[float]], centroids: List[List[float]], cap: int
90
+ ) -> List[int]:
91
+ """Assign vectors nearest-first, never letting a cluster exceed cap."""
92
+ pairs = []
93
+ for vi, vec in enumerate(vectors):
94
+ for ci, cen in enumerate(centroids):
95
+ pairs.append((_sqdist(vec, cen), vi, ci))
96
+ pairs.sort()
97
+
98
+ labels = [-1] * len(vectors)
99
+ sizes = [0] * len(centroids)
100
+ placed = 0
101
+ for _, vi, ci in pairs:
102
+ if labels[vi] != -1 or sizes[ci] >= cap:
103
+ continue
104
+ labels[vi] = ci
105
+ sizes[ci] += 1
106
+ placed += 1
107
+ if placed == len(vectors):
108
+ break
109
+ # Anything left over (all its clusters full) goes to the emptiest cluster.
110
+ for vi, lab in enumerate(labels):
111
+ if lab == -1:
112
+ ci = min(range(len(centroids)), key=lambda c: (sizes[c], c))
113
+ labels[vi] = ci
114
+ sizes[ci] += 1
115
+ return labels
116
+
117
+
118
+ def _recentre(
119
+ vectors: List[List[float]], labels: List[int], centroids: List[List[float]]
120
+ ) -> List[List[float]]:
121
+ """Move each centroid to the mean of its members, normalised."""
122
+ dim = len(vectors[0])
123
+ sums = [[0.0] * dim for _ in centroids]
124
+ counts = [0] * len(centroids)
125
+ for vec, lab in zip(vectors, labels):
126
+ counts[lab] += 1
127
+ row = sums[lab]
128
+ for i, x in enumerate(vec):
129
+ row[i] += x
130
+ new = []
131
+ for ci, count in enumerate(counts):
132
+ if count == 0:
133
+ new.append(list(centroids[ci]))
134
+ else:
135
+ new.append([x / count for x in sums[ci]])
136
+ return normalize(new)
137
+
138
+
139
+ def kmeans(
140
+ vectors: Sequence[Sequence[float]],
141
+ k: int,
142
+ seed: int = 0,
143
+ iters: int = 50,
144
+ balanced: bool = True,
145
+ ) -> List[int]:
146
+ """Cluster vectors into k groups and return one cluster index per vector.
147
+
148
+ With balanced=True no cluster grows past ceil(N/k)+1 members.
149
+ """
150
+ points = normalize(vectors)
151
+ n = len(points)
152
+ if n == 0:
153
+ return []
154
+ k = max(1, min(k, n))
155
+ cap = math.ceil(n / k) + 1
156
+
157
+ rng = random.Random(seed)
158
+ centroids = _kmeans_plusplus(points, k, rng)
159
+
160
+ labels = []
161
+ for _ in range(iters):
162
+ if balanced:
163
+ new_labels = _assign_balanced(points, centroids, cap)
164
+ else:
165
+ new_labels = _assign(points, centroids)
166
+ if new_labels == labels:
167
+ break
168
+ labels = new_labels
169
+ centroids = _recentre(points, labels, centroids)
170
+ return labels
171
+
172
+
173
+ def tokenize(text: str) -> List[str]:
174
+ """Lowercase alphanumeric tokens, stopwords and 1-2 char words dropped."""
175
+ return [
176
+ t
177
+ for t in _TOKEN_RE.findall(text.lower())
178
+ if len(t) > 2 and t not in STOPWORDS and not t.isdigit()
179
+ ]
180
+
181
+
182
+ def label_group(
183
+ group_docs: Sequence[str], other_docs: Sequence[str], top: int = 1
184
+ ) -> str:
185
+ """Name a group by its most distinctive tokens, joined with ' · '.
186
+
187
+ ``top`` is how many tokens the name may carry; one by default.
188
+ """
189
+ if not group_docs:
190
+ return "Ungrouped"
191
+
192
+ def doc_freq(docs):
193
+ counts = {}
194
+ for doc in docs:
195
+ for token in set(tokenize(doc)):
196
+ counts[token] = counts.get(token, 0) + 1
197
+ return counts
198
+
199
+ def term_freq(docs):
200
+ counts = {}
201
+ for doc in docs:
202
+ for token in tokenize(doc):
203
+ counts[token] = counts.get(token, 0) + 1
204
+ return counts
205
+
206
+ inside_df = doc_freq(group_docs)
207
+ inside_tf = term_freq(group_docs)
208
+ outside_df = doc_freq(other_docs)
209
+ n_in = len(group_docs)
210
+ n_out = max(1, len(other_docs))
211
+
212
+ # A token found in only one document of a multi-document group names that
213
+ # document, not the group, so require two documents whenever we can.
214
+ min_docs = 2 if n_in > 2 else 1
215
+
216
+ scored = []
217
+ for token, docs_with in inside_df.items():
218
+ if docs_with < min_docs:
219
+ continue
220
+ tf = inside_tf[token] / n_in
221
+ spread = docs_with / n_in
222
+ idf = math.log((n_out + 1) / (outside_df.get(token, 0) + 1)) + 1.0
223
+ scored.append((-(tf * spread * idf), token))
224
+ scored.sort()
225
+ picked = [token for _, token in scored[:top]]
226
+ return " · ".join(picked) if picked else "Ungrouped"
227
+
228
+
229
+ def cluster_documents(
230
+ names: Sequence[str],
231
+ vectors: Sequence[Sequence[float]],
232
+ docs: Dict[str, str],
233
+ k: int,
234
+ seed: int = 0,
235
+ balanced: bool = True,
236
+ top: int = 1,
237
+ ) -> List[Tuple[str, List[str]]]:
238
+ """Cluster the tools and return (label, members) per group."""
239
+ labels = kmeans(vectors, k, seed=seed, balanced=balanced)
240
+ buckets: Dict[int, List[str]] = {}
241
+ for name, lab in zip(names, labels):
242
+ buckets.setdefault(lab, []).append(name)
243
+
244
+ groups = []
245
+ for lab in sorted(buckets):
246
+ members = sorted(buckets[lab])
247
+ inside = [docs.get(m, "") for m in members]
248
+ outside = [docs.get(n, "") for n in names if n not in set(members)]
249
+ groups.append((label_group(inside, outside, top=top), members))
250
+
251
+ # Two clusters can land on the same tokens; keep the labels unique.
252
+ seen = {}
253
+ unique = []
254
+ for label, members in groups:
255
+ if label in seen:
256
+ seen[label] += 1
257
+ label = f"{label} ({seen[label]})"
258
+ else:
259
+ seen[label] = 1
260
+ unique.append((label, members))
261
+ return unique