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