monkeyllm 0.49.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.
- monkeyllm/__init__.py +10 -0
- monkeyllm/canopy.py +246 -0
- monkeyllm/catalog.py +462 -0
- monkeyllm/cli.py +310 -0
- monkeyllm/curator.py +480 -0
- monkeyllm/dialect.py +100 -0
- monkeyllm/errors.py +33 -0
- monkeyllm/fetch.py +140 -0
- monkeyllm/forest.py +282 -0
- monkeyllm/gardener.py +1584 -0
- monkeyllm/gitops.py +65 -0
- monkeyllm/harvest.py +183 -0
- monkeyllm/indexer.py +78 -0
- monkeyllm/lint.py +88 -0
- monkeyllm/models.py +511 -0
- monkeyllm/parser.py +162 -0
- monkeyllm/ranger.py +221 -0
- monkeyllm/server.py +293 -0
- monkeyllm/snapshot.py +81 -0
- monkeyllm/telemetry.py +99 -0
- monkeyllm/tokens.py +44 -0
- monkeyllm/trails.py +154 -0
- monkeyllm/vine.py +1832 -0
- monkeyllm-0.49.0.dist-info/METADATA +304 -0
- monkeyllm-0.49.0.dist-info/RECORD +30 -0
- monkeyllm-0.49.0.dist-info/WHEEL +5 -0
- monkeyllm-0.49.0.dist-info/entry_points.txt +2 -0
- monkeyllm-0.49.0.dist-info/licenses/LICENSE +201 -0
- monkeyllm-0.49.0.dist-info/licenses/NOTICE +5 -0
- monkeyllm-0.49.0.dist-info/top_level.txt +1 -0
monkeyllm/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 Jimmy Wesley
|
|
3
|
+
|
|
4
|
+
"""MonkeyLLM — agent-navigable knowledge forest (Phase 0: Vine)."""
|
|
5
|
+
|
|
6
|
+
from monkeyllm.errors import VineError
|
|
7
|
+
from monkeyllm.vine import Vine
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = ["Vine", "VineError", "__version__"]
|
monkeyllm/canopy.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2026 Jimmy Wesley
|
|
3
|
+
|
|
4
|
+
"""Canopy — the optional vector layer for `locate` (Phase 1 kickoff).
|
|
5
|
+
|
|
6
|
+
Phase 0 `locate` is BM25-only (zero embeddings) by design — that is a spec
|
|
7
|
+
exit criterion. Canopy adds an *optional* dense-retrieval layer on top
|
|
8
|
+
WITHOUT changing the `locate` contract (architecture doc §3):
|
|
9
|
+
|
|
10
|
+
no index -> BM25-only (Phase 0 behaviour, unchanged)
|
|
11
|
+
index + an embedder -> hybrid: RRF(vector, BM25), pheromone on top
|
|
12
|
+
|
|
13
|
+
Summaries of branches AND bananas are embedded (bge-m3 by default, served
|
|
14
|
+
locally by llama.cpp), stored as a flat index in the derived layer, and
|
|
15
|
+
fused with BM25 at query time via Reciprocal Rank Fusion.
|
|
16
|
+
|
|
17
|
+
Pure-Python, stdlib only (no numpy): the forest is small and the SLM hop
|
|
18
|
+
dominates latency (architecture doc §11), so a flat scan over a few thousand
|
|
19
|
+
1024-d vectors is trivially fast. Everything lives in `_derived/canopy/`
|
|
20
|
+
and is fully rebuildable — never a source of truth.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import math
|
|
27
|
+
import os
|
|
28
|
+
import struct
|
|
29
|
+
import time
|
|
30
|
+
from array import array
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Protocol, Sequence
|
|
33
|
+
|
|
34
|
+
CANOPY_DIRNAME = "canopy"
|
|
35
|
+
DEFAULT_EMBED_MODEL = "bge-m3"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Embedder(Protocol):
|
|
39
|
+
"""Anything that turns text into unit vectors. The model id is recorded
|
|
40
|
+
in the index so a model swap forces a rebuild."""
|
|
41
|
+
|
|
42
|
+
model: str
|
|
43
|
+
|
|
44
|
+
def embed(self, texts: Sequence[str]) -> list[list[float]]: ...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def normalize(vec: Sequence[float]) -> list[float]:
|
|
48
|
+
norm = math.sqrt(sum(x * x for x in vec))
|
|
49
|
+
if norm == 0.0:
|
|
50
|
+
return list(vec)
|
|
51
|
+
return [x / norm for x in vec]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
|
55
|
+
"""Dot product. Vectors stored in the index are pre-normalized, so this
|
|
56
|
+
is cosine similarity for them."""
|
|
57
|
+
return sum(x * y for x, y in zip(a, b))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ----------------------------------------------------------------------------
|
|
61
|
+
# Embedder backend: llama.cpp / any OpenAI-compatible /v1/embeddings endpoint
|
|
62
|
+
# ----------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
class LlamaCppEmbedder:
|
|
65
|
+
"""Talks to an OpenAI-compatible `/embeddings` endpoint (llama.cpp's
|
|
66
|
+
`llama-server --embedding`, vLLM, LM Studio, ...). Returns unit vectors."""
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
endpoint: str = "http://localhost:8091/v1",
|
|
71
|
+
model: str = DEFAULT_EMBED_MODEL,
|
|
72
|
+
api_key: str = "no-key",
|
|
73
|
+
batch_size: int = 32,
|
|
74
|
+
timeout: float = 120.0,
|
|
75
|
+
):
|
|
76
|
+
self.endpoint = endpoint.rstrip("/")
|
|
77
|
+
self.model = model
|
|
78
|
+
self.api_key = api_key
|
|
79
|
+
self.batch_size = batch_size
|
|
80
|
+
self.timeout = timeout
|
|
81
|
+
self._client = None # persistent keep-alive connection (lazy)
|
|
82
|
+
|
|
83
|
+
def _http(self):
|
|
84
|
+
import httpx
|
|
85
|
+
|
|
86
|
+
if self._client is None:
|
|
87
|
+
self._client = httpx.Client(
|
|
88
|
+
timeout=self.timeout,
|
|
89
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
90
|
+
)
|
|
91
|
+
return self._client
|
|
92
|
+
|
|
93
|
+
def embed(self, texts: Sequence[str]) -> list[list[float]]:
|
|
94
|
+
out: list[list[float]] = []
|
|
95
|
+
client = self._http()
|
|
96
|
+
for i in range(0, len(texts), self.batch_size):
|
|
97
|
+
batch = list(texts[i : i + self.batch_size])
|
|
98
|
+
resp = client.post(
|
|
99
|
+
f"{self.endpoint}/embeddings",
|
|
100
|
+
json={"model": self.model, "input": batch},
|
|
101
|
+
)
|
|
102
|
+
resp.raise_for_status()
|
|
103
|
+
data = resp.json()["data"]
|
|
104
|
+
# the endpoint may reorder; OpenAI guarantees `index`
|
|
105
|
+
data.sort(key=lambda d: d.get("index", 0))
|
|
106
|
+
out.extend(normalize(d["embedding"]) for d in data)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def embedder_from_env() -> "LlamaCppEmbedder | None":
|
|
111
|
+
"""Build the dense embedder from the environment, or None to stay
|
|
112
|
+
BM25-only. Set MONKEYLLM_EMBED_ENDPOINT to activate the vector layer.
|
|
113
|
+
|
|
114
|
+
MONKEYLLM_EMBED_ENDPOINT OpenAI-compatible base_url (e.g. the
|
|
115
|
+
llama.cpp embedding server's /v1)
|
|
116
|
+
MONKEYLLM_EMBED_MODEL model id (default: bge-m3)
|
|
117
|
+
MONKEYLLM_EMBED_API_KEY key the endpoint expects (default: no-key)
|
|
118
|
+
"""
|
|
119
|
+
endpoint = os.environ.get("MONKEYLLM_EMBED_ENDPOINT")
|
|
120
|
+
if not endpoint:
|
|
121
|
+
return None
|
|
122
|
+
return LlamaCppEmbedder(
|
|
123
|
+
endpoint=endpoint,
|
|
124
|
+
model=os.environ.get("MONKEYLLM_EMBED_MODEL", DEFAULT_EMBED_MODEL),
|
|
125
|
+
api_key=os.environ.get("MONKEYLLM_EMBED_API_KEY", "no-key"),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ----------------------------------------------------------------------------
|
|
130
|
+
# The index
|
|
131
|
+
# ----------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
class CanopyIndex:
|
|
134
|
+
"""A flat, in-memory vector index persisted under `_derived/canopy/`.
|
|
135
|
+
|
|
136
|
+
Layout:
|
|
137
|
+
canopy/index.json -> {model, dim, built_at, ids: [...]}
|
|
138
|
+
canopy/vectors.f32 -> little-endian float32, dim per id, ids order
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
def __init__(self, model: str, dim: int):
|
|
142
|
+
self.model = model
|
|
143
|
+
self.dim = dim
|
|
144
|
+
self.ids: list[str] = []
|
|
145
|
+
self.vectors: list[list[float]] = [] # unit vectors
|
|
146
|
+
self.built_at: float = 0.0
|
|
147
|
+
|
|
148
|
+
# -- build / persist ----------------------------------------------------
|
|
149
|
+
|
|
150
|
+
@classmethod
|
|
151
|
+
def build(cls, rows: Sequence[tuple[str, str]], embedder: Embedder) -> "CanopyIndex":
|
|
152
|
+
"""`rows` is [(id, text_to_embed)]. text is normally the summary
|
|
153
|
+
(the smell of the node) — that is what `locate` ranks against."""
|
|
154
|
+
ids = [r[0] for r in rows]
|
|
155
|
+
texts = [r[1] for r in rows]
|
|
156
|
+
vecs = embedder.embed(texts) if texts else []
|
|
157
|
+
dim = len(vecs[0]) if vecs else 0
|
|
158
|
+
idx = cls(model=embedder.model, dim=dim)
|
|
159
|
+
idx.ids = ids
|
|
160
|
+
idx.vectors = [normalize(v) for v in vecs]
|
|
161
|
+
idx.built_at = time.time()
|
|
162
|
+
return idx
|
|
163
|
+
|
|
164
|
+
def save(self, derived_dir: Path) -> Path:
|
|
165
|
+
d = Path(derived_dir) / CANOPY_DIRNAME
|
|
166
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
flat = array("f")
|
|
168
|
+
for v in self.vectors:
|
|
169
|
+
flat.extend(v)
|
|
170
|
+
(d / "vectors.f32").write_bytes(flat.tobytes())
|
|
171
|
+
(d / "index.json").write_text(
|
|
172
|
+
json.dumps(
|
|
173
|
+
{"model": self.model, "dim": self.dim, "built_at": self.built_at, "ids": self.ids},
|
|
174
|
+
ensure_ascii=False,
|
|
175
|
+
),
|
|
176
|
+
encoding="utf-8",
|
|
177
|
+
)
|
|
178
|
+
return d
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def load(cls, derived_dir: Path) -> "CanopyIndex | None":
|
|
182
|
+
d = Path(derived_dir) / CANOPY_DIRNAME
|
|
183
|
+
meta_path = d / "index.json"
|
|
184
|
+
vec_path = d / "vectors.f32"
|
|
185
|
+
if not meta_path.exists() or not vec_path.exists():
|
|
186
|
+
return None
|
|
187
|
+
meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
188
|
+
idx = cls(model=meta["model"], dim=meta["dim"])
|
|
189
|
+
idx.ids = meta["ids"]
|
|
190
|
+
idx.built_at = meta.get("built_at", 0.0)
|
|
191
|
+
raw = vec_path.read_bytes()
|
|
192
|
+
dim = idx.dim
|
|
193
|
+
if dim:
|
|
194
|
+
count = len(raw) // (4 * dim)
|
|
195
|
+
flat = struct.unpack(f"<{count * dim}f", raw)
|
|
196
|
+
idx.vectors = [list(flat[i * dim : (i + 1) * dim]) for i in range(count)]
|
|
197
|
+
return idx
|
|
198
|
+
|
|
199
|
+
# -- incremental updates (lazy re-embedding, spec Phase 1) ---------------
|
|
200
|
+
|
|
201
|
+
def upsert(self, node_id: str, vector: Sequence[float]) -> None:
|
|
202
|
+
"""Replace (or append) one node's vector — the lazy re-embed path."""
|
|
203
|
+
vec = normalize(vector)
|
|
204
|
+
try:
|
|
205
|
+
i = self.ids.index(node_id)
|
|
206
|
+
self.vectors[i] = vec
|
|
207
|
+
except ValueError:
|
|
208
|
+
self.ids.append(node_id)
|
|
209
|
+
self.vectors.append(vec)
|
|
210
|
+
|
|
211
|
+
def remove(self, node_id: str) -> None:
|
|
212
|
+
try:
|
|
213
|
+
i = self.ids.index(node_id)
|
|
214
|
+
except ValueError:
|
|
215
|
+
return
|
|
216
|
+
del self.ids[i]
|
|
217
|
+
del self.vectors[i]
|
|
218
|
+
|
|
219
|
+
# -- query --------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
def search(self, query_vec: Sequence[float], k: int = 50) -> list[tuple[str, float]]:
|
|
222
|
+
q = normalize(query_vec)
|
|
223
|
+
scored = [(self.ids[i], cosine(q, self.vectors[i])) for i in range(len(self.ids))]
|
|
224
|
+
scored.sort(key=lambda x: x[1], reverse=True)
|
|
225
|
+
return scored[:k]
|
|
226
|
+
|
|
227
|
+
def __len__(self) -> int:
|
|
228
|
+
return len(self.ids)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# ----------------------------------------------------------------------------
|
|
232
|
+
# Reciprocal Rank Fusion (spec Phase 1: RRF fusing vector + BM25)
|
|
233
|
+
# ----------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
def rrf_fuse(
|
|
236
|
+
*ranked_lists: Sequence[str],
|
|
237
|
+
k: int = 60,
|
|
238
|
+
) -> dict[str, float]:
|
|
239
|
+
"""Reciprocal Rank Fusion. Each list is ids best-first. Returns
|
|
240
|
+
id -> fused score (higher is better). Rank-based, so it needs no score
|
|
241
|
+
calibration between the lexical and dense signals."""
|
|
242
|
+
fused: dict[str, float] = {}
|
|
243
|
+
for ranked in ranked_lists:
|
|
244
|
+
for rank, node_id in enumerate(ranked):
|
|
245
|
+
fused[node_id] = fused.get(node_id, 0.0) + 1.0 / (k + rank + 1)
|
|
246
|
+
return fused
|