boost-skill-cli 1.0.18__tar.gz → 1.0.20__tar.gz
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.
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/PKG-INFO +16 -1
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/README.md +13 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/_version.py +2 -2
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/cli.py +9 -3
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/discovery.py +20 -2
- boost_skill_cli-1.0.20/boost_cli/core/dense.py +267 -0
- boost_skill_cli-1.0.20/boost_cli/core/embed.py +122 -0
- boost_skill_cli-1.0.20/boost_cli/core/output.py +180 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/rag.py +22 -4
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_skill_cli.egg-info/PKG-INFO +16 -1
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_skill_cli.egg-info/SOURCES.txt +5 -0
- boost_skill_cli-1.0.20/boost_skill_cli.egg-info/requires.txt +3 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/design-roadmap.html +4 -4
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/rag-architecture.md +6 -1
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/pyproject.toml +8 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/conftest.py +3 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_discovery.py +42 -0
- boost_skill_cli-1.0.20/tests/unit/test_dense.py +260 -0
- boost_skill_cli-1.0.20/tests/unit/test_embed.py +159 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_output.py +129 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_rag.py +39 -0
- boost_skill_cli-1.0.18/boost_cli/core/output.py +0 -99
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/.gitignore +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/CLAUDE.md +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/CONTRIBUTING.md +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/LICENSE +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/MANIFEST.in +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/Makefile +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/SECURITY.md +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/__init__.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/__main__.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/__init__.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/bmad.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/configuration.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/hooks.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/info.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/intelligence.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/pkg.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/quality.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/taps.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/team.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/__init__.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/agents.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/ai.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/catalog.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/claude_settings.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/config.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/frontmatter.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/gitutil.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/journal.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/lockfile.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/logs.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/paths.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/policy.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/registry.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/store.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/util.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/data/registries.json +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/errors.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_skill_cli.egg-info/dependency_links.txt +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_skill_cli.egg-info/entry_points.txt +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_skill_cli.egg-info/top_level.txt +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/.claude/settings.local.json +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/DEBUGGING.md +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/demo.gif +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/demo.tape +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/index.html +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/roadmap.html +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/scripts/build_registries.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/scripts/mutation_gate.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/setup.cfg +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/style/README.md +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/style/boost.css +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/style/boost.js +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/style/demo.html +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_bmad.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_configuration.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_hooks.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_info.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_intelligence.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_pkg.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_quality.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_taps.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_team.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_everyday_loop.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/make_fixture.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/smoke.sh +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_agents.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_ai.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_catalog.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_claude_settings.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_config.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_errors_and_cli_table.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_frontmatter.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_gitutil.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_journal.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_lockfile.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_logs.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_mutation_hardening.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_paths.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_policy.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_registry.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_store.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_util.py +0 -0
- {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_version.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: boost-skill-cli
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.20
|
|
4
4
|
Summary: boost — Homebrew for AI coding skills
|
|
5
5
|
Author: Jonathan Reyes
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -700,6 +700,8 @@ Classifier: Topic :: Software Development :: Build Tools
|
|
|
700
700
|
Requires-Python: >=3.9
|
|
701
701
|
Description-Content-Type: text/markdown
|
|
702
702
|
License-File: LICENSE
|
|
703
|
+
Provides-Extra: rag
|
|
704
|
+
Requires-Dist: sqlite-vec>=0.1.6; extra == "rag"
|
|
703
705
|
Dynamic: license-file
|
|
704
706
|
|
|
705
707
|
# boost — a package manager for AI coding skills
|
|
@@ -747,6 +749,19 @@ boost --version
|
|
|
747
749
|
boost tap --defaults # pull in the 5 starter registries
|
|
748
750
|
```
|
|
749
751
|
|
|
752
|
+
The default install is **zero-dependency** (pure stdlib). `boost search` and the
|
|
753
|
+
`boost_search` MCP tool rank results with a built-in full-content BM25 engine
|
|
754
|
+
(run `boost reindex` once to build the index). For optional **dense semantic
|
|
755
|
+
search**, install the `[rag]` extra and set an embeddings key — retrieval then
|
|
756
|
+
embeds every skill and ranks by vector similarity, falling back to BM25 whenever
|
|
757
|
+
the extra or key is absent:
|
|
758
|
+
|
|
759
|
+
```bash
|
|
760
|
+
pip install "boost-skill-cli[rag]" # adds the sqlite-vec vector store
|
|
761
|
+
export VOYAGE_API_KEY=... # or OPENAI_API_KEY
|
|
762
|
+
boost reindex --dense # embed chunks into the vector store
|
|
763
|
+
```
|
|
764
|
+
|
|
750
765
|
Want the whole ecosystem instead of the starter set? boost ships a **curated
|
|
751
766
|
registry catalog** — 100+ classified GitHub registries of skills, Cursor/Windsurf
|
|
752
767
|
**rules**, and Claude Code **workflows** (slash commands & subagents),
|
|
@@ -43,6 +43,19 @@ boost --version
|
|
|
43
43
|
boost tap --defaults # pull in the 5 starter registries
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
+
The default install is **zero-dependency** (pure stdlib). `boost search` and the
|
|
47
|
+
`boost_search` MCP tool rank results with a built-in full-content BM25 engine
|
|
48
|
+
(run `boost reindex` once to build the index). For optional **dense semantic
|
|
49
|
+
search**, install the `[rag]` extra and set an embeddings key — retrieval then
|
|
50
|
+
embeds every skill and ranks by vector similarity, falling back to BM25 whenever
|
|
51
|
+
the extra or key is absent:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install "boost-skill-cli[rag]" # adds the sqlite-vec vector store
|
|
55
|
+
export VOYAGE_API_KEY=... # or OPENAI_API_KEY
|
|
56
|
+
boost reindex --dense # embed chunks into the vector store
|
|
57
|
+
```
|
|
58
|
+
|
|
46
59
|
Want the whole ecosystem instead of the starter set? boost ships a **curated
|
|
47
60
|
registry catalog** — 100+ classified GitHub registries of skills, Cursor/Windsurf
|
|
48
61
|
**rules**, and Claude Code **workflows** (slash commands & subagents),
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '1.0.
|
|
22
|
-
__version_tuple__ = version_tuple = (1, 0,
|
|
21
|
+
__version__ = version = '1.0.20'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 20)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -144,17 +144,23 @@ def print_version() -> None:
|
|
|
144
144
|
print("%s %s" % (PRODUCT, __version__))
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
# Per-group accent hue — mirrors the roadmap's colored track-icons, cycling the
|
|
148
|
+
# Aurora palette so each command group carries its own brand color.
|
|
149
|
+
_GROUP_HUES = ("cyan", "violet", "pink", "green", "yellow")
|
|
150
|
+
|
|
151
|
+
|
|
147
152
|
def print_help() -> None:
|
|
148
|
-
print(out.
|
|
153
|
+
print(out.gradient(PRODUCT) + " — Homebrew for AI coding skills"
|
|
149
154
|
+ out.c(" (%s · v%s)" % (TAGLINE, __version__), out.DIM))
|
|
150
155
|
print()
|
|
151
156
|
print(out.c("Usage:", out.BOLD) + " boost <command> [args] "
|
|
152
157
|
+ out.c("boost help <command> for details", out.DIM))
|
|
153
158
|
width = max(len(n) for n, _, _, _ in COMMANDS)
|
|
154
|
-
for gkey, (_icon, title, desc) in GROUPS.items():
|
|
159
|
+
for idx, (gkey, (_icon, title, desc)) in enumerate(GROUPS.items()):
|
|
155
160
|
cmds = [(n, s) for n, g, _m, s in COMMANDS if g == gkey]
|
|
161
|
+
bullet = out.aurora("●", _GROUP_HUES[idx % len(_GROUP_HUES)])
|
|
156
162
|
print()
|
|
157
|
-
print(out.c(
|
|
163
|
+
print(bullet + " " + out.c(title, out.BOLD)
|
|
158
164
|
+ out.c(" — %s" % desc, out.DIM))
|
|
159
165
|
for n, s in cmds:
|
|
160
166
|
print(" " + out.c(n.ljust(width + 2), out.CYAN) + s)
|
|
@@ -15,7 +15,7 @@ import sys
|
|
|
15
15
|
import time
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
|
|
18
|
-
from ..core import agents, ai, catalog, gitutil, journal, lockfile, paths, rag, registry, store, util
|
|
18
|
+
from ..core import agents, ai, catalog, dense, embed, gitutil, journal, lockfile, paths, rag, registry, store, util
|
|
19
19
|
from ..core import output as out
|
|
20
20
|
from ..errors import BoostError
|
|
21
21
|
|
|
@@ -228,6 +228,9 @@ def cmd_reindex(argv):
|
|
|
228
228
|
description="Build or refresh the full-content search index")
|
|
229
229
|
p.add_argument("--force", action="store_true",
|
|
230
230
|
help="reindex every tap, ignoring cached commits")
|
|
231
|
+
p.add_argument("--dense", action="store_true",
|
|
232
|
+
help="also embed chunks into the opt-in dense vector store "
|
|
233
|
+
"(needs the `rag` extra and an embeddings API key)")
|
|
231
234
|
p.add_argument("--json", action="store_true", dest="as_json",
|
|
232
235
|
help="machine-readable output")
|
|
233
236
|
args = p.parse_args(argv)
|
|
@@ -235,8 +238,10 @@ def cmd_reindex(argv):
|
|
|
235
238
|
raise BoostError("no taps configured — nothing to index",
|
|
236
239
|
hint="add the recommended registries with `boost tap --defaults`")
|
|
237
240
|
stats = rag.build(force=args.force)
|
|
241
|
+
dense_stats = _reindex_dense(args.force) if args.dense else None
|
|
238
242
|
if args.as_json:
|
|
239
|
-
print(json.dumps(stats
|
|
243
|
+
print(json.dumps({"bm25": stats, "dense": dense_stats}
|
|
244
|
+
if args.dense else stats))
|
|
240
245
|
return 0
|
|
241
246
|
out.ok("indexed %d passages across %d items from %d tap%s"
|
|
242
247
|
% (stats["docs"], stats["entries"], stats["taps"],
|
|
@@ -247,11 +252,24 @@ def cmd_reindex(argv):
|
|
|
247
252
|
"" if len(stats["reused"]) == 1 else "s",
|
|
248
253
|
", ".join(stats["reindexed"]) or "none"),
|
|
249
254
|
out.DIM))
|
|
255
|
+
if args.dense:
|
|
256
|
+
if dense_stats is None:
|
|
257
|
+
out.warn("dense index skipped — %s" % embed.fallback_note())
|
|
258
|
+
else:
|
|
259
|
+
out.ok("embedded %d passages (%s) into the dense vector store"
|
|
260
|
+
% (dense_stats["chunks"], dense_stats["provider"]))
|
|
250
261
|
journal.log("reindex", "%d passages" % stats["docs"],
|
|
251
262
|
entries=stats["entries"])
|
|
252
263
|
return 0
|
|
253
264
|
|
|
254
265
|
|
|
266
|
+
def _reindex_dense(force):
|
|
267
|
+
"""Build the dense vector store; returns stats or None when unavailable."""
|
|
268
|
+
if not dense.have_backend() or not embed.available():
|
|
269
|
+
return None
|
|
270
|
+
return dense.build(force=force)
|
|
271
|
+
|
|
272
|
+
|
|
255
273
|
def cmd_index(argv):
|
|
256
274
|
"""Build ~/.boost/cache/discovery.json via `gh api` code search."""
|
|
257
275
|
p = argparse.ArgumentParser(
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Optional dense vector backend for RAG Phase 2 (the ``[rag]`` extra).
|
|
2
|
+
|
|
3
|
+
Embeds every chunk (via :mod:`embed`) into a ``sqlite-vec`` virtual table and
|
|
4
|
+
answers queries by cosine KNN. It needs *both* the ``[rag]`` extra (``sqlite-vec``)
|
|
5
|
+
*and* an embeddings provider; when either is missing, or the persisted vectors
|
|
6
|
+
were built with a different provider/model, :func:`ready` is ``False`` and
|
|
7
|
+
callers fall back to the always-on BM25 engine in :mod:`rag`.
|
|
8
|
+
|
|
9
|
+
The vector store lives beside the BM25 index at ``~/.boost/cache/rag_vectors.sqlite``
|
|
10
|
+
and is refreshed per-tap on the git commit each tap was built from — embeddings
|
|
11
|
+
are the expensive step, so an unchanged tap is never re-embedded.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Dict, List, Optional, Tuple
|
|
19
|
+
|
|
20
|
+
from . import catalog, embed, paths
|
|
21
|
+
from .rag import Hit, chunk, read_body
|
|
22
|
+
|
|
23
|
+
INDEX_VERSION = 1
|
|
24
|
+
_BATCH = 128 # texts per embedding request
|
|
25
|
+
_POOL = 8 # chunk over-fetch factor for KNN before per-entry reduce
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _load():
|
|
29
|
+
"""The sqlite_vec module, or None when the [rag] extra isn't installed."""
|
|
30
|
+
try:
|
|
31
|
+
import sqlite_vec # type: ignore
|
|
32
|
+
except ImportError:
|
|
33
|
+
return None
|
|
34
|
+
return sqlite_vec
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def have_backend() -> bool:
|
|
38
|
+
return _load() is not None
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def db_path() -> Path:
|
|
42
|
+
return paths.cache_dir() / "rag_vectors.sqlite"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _connect() -> Optional[sqlite3.Connection]:
|
|
46
|
+
"""Open the vector DB with the sqlite-vec extension loaded, or None."""
|
|
47
|
+
mod = _load()
|
|
48
|
+
if mod is None:
|
|
49
|
+
return None
|
|
50
|
+
paths.ensure_dirs()
|
|
51
|
+
con = sqlite3.connect(str(db_path()))
|
|
52
|
+
try:
|
|
53
|
+
con.enable_load_extension(True)
|
|
54
|
+
mod.load(con)
|
|
55
|
+
con.enable_load_extension(False)
|
|
56
|
+
except (AttributeError, sqlite3.OperationalError):
|
|
57
|
+
con.close()
|
|
58
|
+
return None
|
|
59
|
+
return con
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _ensure_schema(con: sqlite3.Connection, dim: int) -> None:
|
|
63
|
+
con.execute(
|
|
64
|
+
"CREATE TABLE IF NOT EXISTS chunks (id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
65
|
+
" name TEXT, tap TEXT, kind TEXT, cix INTEGER, snip TEXT)")
|
|
66
|
+
con.execute("CREATE INDEX IF NOT EXISTS chunks_tap ON chunks(tap)")
|
|
67
|
+
con.execute("CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT)")
|
|
68
|
+
con.execute(
|
|
69
|
+
"CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING "
|
|
70
|
+
"vec0(embedding float[%d] distance_metric=cosine)" % dim)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_meta(con: sqlite3.Connection) -> Dict[str, object]:
|
|
74
|
+
try:
|
|
75
|
+
rows = con.execute("SELECT k, v FROM meta").fetchall()
|
|
76
|
+
except sqlite3.OperationalError:
|
|
77
|
+
return {}
|
|
78
|
+
out: Dict[str, object] = {}
|
|
79
|
+
for k, v in rows:
|
|
80
|
+
try:
|
|
81
|
+
out[k] = json.loads(v)
|
|
82
|
+
except (json.JSONDecodeError, TypeError):
|
|
83
|
+
out[k] = v
|
|
84
|
+
return out
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _write_meta(con: sqlite3.Connection, meta: Dict[str, object]) -> None:
|
|
88
|
+
con.executemany("INSERT OR REPLACE INTO meta (k, v) VALUES (?, ?)",
|
|
89
|
+
[(k, json.dumps(v)) for k, v in meta.items()])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _wipe(con: sqlite3.Connection) -> None:
|
|
93
|
+
con.execute("DROP TABLE IF EXISTS chunks")
|
|
94
|
+
con.execute("DROP TABLE IF EXISTS vec_chunks")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _chunk_texts(entry: dict, tap_paths: Optional[Dict[str, Path]]
|
|
98
|
+
) -> List[str]:
|
|
99
|
+
body = read_body(entry, tap_paths)
|
|
100
|
+
return chunk(body) or [body]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def ready() -> bool:
|
|
104
|
+
"""True when a usable, provider-matched vector index exists on disk."""
|
|
105
|
+
if not have_backend() or not embed.available() or not db_path().exists():
|
|
106
|
+
return False
|
|
107
|
+
con = _connect()
|
|
108
|
+
if con is None:
|
|
109
|
+
return False
|
|
110
|
+
try:
|
|
111
|
+
meta = _read_meta(con)
|
|
112
|
+
if meta.get("version") != INDEX_VERSION:
|
|
113
|
+
return False
|
|
114
|
+
if meta.get("provider") != embed.provider():
|
|
115
|
+
return False
|
|
116
|
+
if meta.get("dim") != embed.dimension():
|
|
117
|
+
return False
|
|
118
|
+
try:
|
|
119
|
+
rows = con.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
|
120
|
+
except sqlite3.OperationalError:
|
|
121
|
+
return False
|
|
122
|
+
return rows > 0
|
|
123
|
+
finally:
|
|
124
|
+
con.close()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build(entries: Optional[List[dict]] = None,
|
|
128
|
+
force: bool = False) -> Optional[dict]:
|
|
129
|
+
"""(Re)embed and store chunk vectors, reusing unchanged taps.
|
|
130
|
+
|
|
131
|
+
Returns stats, or ``None`` if the backend or an embeddings provider is
|
|
132
|
+
unavailable (nothing is written in that case).
|
|
133
|
+
"""
|
|
134
|
+
from .rag import _tap_commits, _tap_paths
|
|
135
|
+
if not have_backend() or not embed.available():
|
|
136
|
+
return None
|
|
137
|
+
entries = catalog.all_entries() if entries is None else entries
|
|
138
|
+
tap_paths = _tap_paths()
|
|
139
|
+
commits = _tap_commits()
|
|
140
|
+
dim = embed.dimension()
|
|
141
|
+
prov = embed.provider()
|
|
142
|
+
mdl = embed.model()
|
|
143
|
+
con = _connect()
|
|
144
|
+
if con is None or dim is None:
|
|
145
|
+
return None
|
|
146
|
+
try:
|
|
147
|
+
meta = _read_meta(con)
|
|
148
|
+
same_backend = (meta.get("provider") == prov and meta.get("dim") == dim)
|
|
149
|
+
if force or not same_backend:
|
|
150
|
+
_wipe(con)
|
|
151
|
+
_ensure_schema(con, dim)
|
|
152
|
+
|
|
153
|
+
reused: set = set()
|
|
154
|
+
if same_backend and not force:
|
|
155
|
+
old_commits = meta.get("commits", {})
|
|
156
|
+
if isinstance(old_commits, dict):
|
|
157
|
+
for safe, commit in commits.items():
|
|
158
|
+
if commit and old_commits.get(safe) == commit:
|
|
159
|
+
reused.add(safe)
|
|
160
|
+
|
|
161
|
+
fresh = [e for e in entries
|
|
162
|
+
if e["tap"].replace("/", "__") not in reused]
|
|
163
|
+
changed_taps = sorted({e["tap"] for e in fresh})
|
|
164
|
+
_delete_taps(con, changed_taps)
|
|
165
|
+
|
|
166
|
+
added = _embed_and_store(con, fresh, tap_paths)
|
|
167
|
+
_write_meta(con, {"version": INDEX_VERSION, "provider": prov,
|
|
168
|
+
"model": mdl, "dim": dim, "commits": commits})
|
|
169
|
+
con.commit()
|
|
170
|
+
total = con.execute("SELECT COUNT(*) FROM chunks").fetchone()[0]
|
|
171
|
+
return {
|
|
172
|
+
"entries": len({(e["name"], e["tap"]) for e in entries}),
|
|
173
|
+
"chunks": total,
|
|
174
|
+
"added": added,
|
|
175
|
+
"taps": len(commits),
|
|
176
|
+
"reindexed": changed_taps,
|
|
177
|
+
"reused": sorted(reused),
|
|
178
|
+
"provider": prov,
|
|
179
|
+
"model": mdl,
|
|
180
|
+
}
|
|
181
|
+
finally:
|
|
182
|
+
con.close()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _delete_taps(con: sqlite3.Connection, taps: List[str]) -> None:
|
|
186
|
+
for tap in taps:
|
|
187
|
+
ids = [r[0] for r in
|
|
188
|
+
con.execute("SELECT id FROM chunks WHERE tap = ?", (tap,))]
|
|
189
|
+
for i in ids:
|
|
190
|
+
con.execute("DELETE FROM vec_chunks WHERE rowid = ?", (i,))
|
|
191
|
+
con.execute("DELETE FROM chunks WHERE tap = ?", (tap,))
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _embed_and_store(con: sqlite3.Connection, entries: List[dict],
|
|
195
|
+
tap_paths: Optional[Dict[str, Path]]) -> int:
|
|
196
|
+
mod = _load()
|
|
197
|
+
rows: List[Tuple[dict, int, str]] = [] # (entry, chunk_ix, text)
|
|
198
|
+
for e in entries:
|
|
199
|
+
for ci, text in enumerate(_chunk_texts(e, tap_paths)):
|
|
200
|
+
rows.append((e, ci, text))
|
|
201
|
+
added = 0
|
|
202
|
+
for start in range(0, len(rows), _BATCH):
|
|
203
|
+
batch = rows[start:start + _BATCH]
|
|
204
|
+
vecs = embed.embed([t for _e, _c, t in batch], input_type="document")
|
|
205
|
+
if not vecs or len(vecs) != len(batch):
|
|
206
|
+
continue
|
|
207
|
+
for (e, ci, text), vec in zip(batch, vecs):
|
|
208
|
+
cur = con.execute(
|
|
209
|
+
"INSERT INTO chunks (name, tap, kind, cix, snip) "
|
|
210
|
+
"VALUES (?, ?, ?, ?, ?)",
|
|
211
|
+
(e["name"], e["tap"], e.get("kind", "skill"), ci,
|
|
212
|
+
text[:200].strip()))
|
|
213
|
+
con.execute("INSERT INTO vec_chunks (rowid, embedding) VALUES (?, ?)",
|
|
214
|
+
(cur.lastrowid, mod.serialize_float32(vec)))
|
|
215
|
+
added += 1
|
|
216
|
+
return added
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def retrieve(query: str, k: int = 60, kind: Optional[str] = None,
|
|
220
|
+
entries: Optional[List[dict]] = None) -> Optional[List[Hit]]:
|
|
221
|
+
"""Top-k dense hits for ``query``, or None to signal 'fall back to BM25'."""
|
|
222
|
+
if not ready():
|
|
223
|
+
return None
|
|
224
|
+
qv = embed.embed([query], input_type="query")
|
|
225
|
+
if not qv:
|
|
226
|
+
return None
|
|
227
|
+
mod = _load()
|
|
228
|
+
con = _connect()
|
|
229
|
+
if con is None:
|
|
230
|
+
return None
|
|
231
|
+
try:
|
|
232
|
+
pool = max(k * _POOL, 200)
|
|
233
|
+
knn = con.execute(
|
|
234
|
+
"SELECT rowid, distance FROM vec_chunks "
|
|
235
|
+
"WHERE embedding MATCH ? ORDER BY distance LIMIT ?",
|
|
236
|
+
(mod.serialize_float32(qv[0]), pool)).fetchall()
|
|
237
|
+
if not knn:
|
|
238
|
+
return []
|
|
239
|
+
by_id = {r[0]: (r[1], r[2], r[3], r[4]) for r in con.execute(
|
|
240
|
+
"SELECT id, name, tap, kind, snip FROM chunks WHERE id IN (%s)"
|
|
241
|
+
% ",".join("?" * len(knn)), [rid for rid, _d in knn])}
|
|
242
|
+
finally:
|
|
243
|
+
con.close()
|
|
244
|
+
|
|
245
|
+
entries = catalog.all_entries() if entries is None else entries
|
|
246
|
+
live = {(e["name"], e["tap"]): e for e in entries}
|
|
247
|
+
best: Dict[Tuple[str, str], Tuple[float, str]] = {}
|
|
248
|
+
for rid, dist in knn:
|
|
249
|
+
meta = by_id.get(rid)
|
|
250
|
+
if meta is None:
|
|
251
|
+
continue
|
|
252
|
+
name, tap, ckind, snip = meta
|
|
253
|
+
if kind is not None and ckind != kind:
|
|
254
|
+
continue
|
|
255
|
+
key = (name, tap)
|
|
256
|
+
if key not in live:
|
|
257
|
+
continue
|
|
258
|
+
score = 1.0 - dist # cosine distance -> similarity
|
|
259
|
+
prev = best.get(key)
|
|
260
|
+
if prev is None or score > prev[0]:
|
|
261
|
+
best[key] = (score, snip)
|
|
262
|
+
ranked = sorted(best.items(), key=lambda kv: (-kv[1][0], kv[0][0]))
|
|
263
|
+
hits: List[Hit] = []
|
|
264
|
+
for (name, tap), (score, snip) in ranked[:k]:
|
|
265
|
+
hits.append({"entry": live[(name, tap)], "score": score,
|
|
266
|
+
"snippet": snip}) # type: ignore[typeddict-item]
|
|
267
|
+
return hits
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Optional dense-embedding bridge for RAG Phase 2.
|
|
2
|
+
|
|
3
|
+
Strategy mirrors ``core.ai``: prefer Voyage AI when ``VOYAGE_API_KEY`` is set,
|
|
4
|
+
else OpenAI when ``OPENAI_API_KEY`` is set, else return ``None`` so every caller
|
|
5
|
+
degrades to the always-on BM25 engine. The embeddings themselves come from an
|
|
6
|
+
HTTP API over ``urllib`` — pure stdlib, no client library. Anthropic exposes no
|
|
7
|
+
embeddings endpoint, hence Voyage (its recommended partner) and OpenAI.
|
|
8
|
+
|
|
9
|
+
``BOOST_NO_EMBED=1`` is a hard kill-switch, matching ``BOOST_NO_AI``.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from typing import List, Optional
|
|
18
|
+
|
|
19
|
+
VOYAGE_URL = "https://api.voyageai.com/v1/embeddings"
|
|
20
|
+
OPENAI_URL = "https://api.openai.com/v1/embeddings"
|
|
21
|
+
|
|
22
|
+
# model -> output dimension. Stored in the vector index so a provider/model
|
|
23
|
+
# switch is detected and forces a clean rebuild instead of mixing spaces.
|
|
24
|
+
VOYAGE_MODEL = "voyage-3"
|
|
25
|
+
OPENAI_MODEL = "text-embedding-3-small"
|
|
26
|
+
_DIMS = {VOYAGE_MODEL: 1024, OPENAI_MODEL: 1536}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def enabled() -> bool:
|
|
30
|
+
return not os.environ.get("BOOST_NO_EMBED")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def provider() -> Optional[str]:
|
|
34
|
+
"""The active provider name, preferring Voyage, or None when unconfigured."""
|
|
35
|
+
if not enabled():
|
|
36
|
+
return None
|
|
37
|
+
if os.environ.get("VOYAGE_API_KEY"):
|
|
38
|
+
return "voyage"
|
|
39
|
+
if os.environ.get("OPENAI_API_KEY"):
|
|
40
|
+
return "openai"
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def available() -> bool:
|
|
45
|
+
return provider() is not None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def model() -> Optional[str]:
|
|
49
|
+
p = provider()
|
|
50
|
+
if p == "voyage":
|
|
51
|
+
return VOYAGE_MODEL
|
|
52
|
+
if p == "openai":
|
|
53
|
+
return OPENAI_MODEL
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def dimension() -> Optional[int]:
|
|
58
|
+
m = model()
|
|
59
|
+
return _DIMS.get(m) if m is not None else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def fallback_note() -> str:
|
|
63
|
+
return ("dense search needs the `rag` extra and VOYAGE_API_KEY or "
|
|
64
|
+
"OPENAI_API_KEY — using the BM25 full-content engine")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def embed(texts: List[str], input_type: Optional[str] = None,
|
|
68
|
+
timeout: int = 60) -> Optional[List[List[float]]]:
|
|
69
|
+
"""Embed a batch of texts.
|
|
70
|
+
|
|
71
|
+
Returns one vector per input, ``[]`` for an empty batch, or ``None`` when no
|
|
72
|
+
provider is configured or the call fails (so callers degrade cleanly).
|
|
73
|
+
``input_type`` ("query"/"document") is honored by Voyage and ignored by
|
|
74
|
+
OpenAI.
|
|
75
|
+
"""
|
|
76
|
+
p = provider()
|
|
77
|
+
if p is None:
|
|
78
|
+
return None
|
|
79
|
+
if not texts:
|
|
80
|
+
return []
|
|
81
|
+
if p == "voyage":
|
|
82
|
+
body = {"input": list(texts), "model": VOYAGE_MODEL}
|
|
83
|
+
if input_type:
|
|
84
|
+
body["input_type"] = input_type
|
|
85
|
+
return _vectors(_post(VOYAGE_URL, os.environ["VOYAGE_API_KEY"],
|
|
86
|
+
body, timeout), len(texts))
|
|
87
|
+
body = {"input": list(texts), "model": OPENAI_MODEL}
|
|
88
|
+
return _vectors(_post(OPENAI_URL, os.environ["OPENAI_API_KEY"],
|
|
89
|
+
body, timeout), len(texts))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _post(url: str, key: str, payload: dict, timeout: int) -> Optional[dict]:
|
|
93
|
+
data = json.dumps(payload).encode("utf-8")
|
|
94
|
+
req = urllib.request.Request(url, data=data, headers={
|
|
95
|
+
"Authorization": "Bearer %s" % key,
|
|
96
|
+
"Content-Type": "application/json",
|
|
97
|
+
})
|
|
98
|
+
try:
|
|
99
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
100
|
+
return json.loads(resp.read())
|
|
101
|
+
except (urllib.error.URLError, OSError, ValueError):
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _vectors(obj: Optional[dict], n: int) -> Optional[List[List[float]]]:
|
|
106
|
+
"""Pull ``data[].embedding`` out of an OpenAI-shaped response.
|
|
107
|
+
|
|
108
|
+
Both Voyage and OpenAI return ``{"data": [{"embedding": [...]}, ...]}``.
|
|
109
|
+
Returns None unless exactly ``n`` numeric vectors come back.
|
|
110
|
+
"""
|
|
111
|
+
if not isinstance(obj, dict):
|
|
112
|
+
return None
|
|
113
|
+
data = obj.get("data")
|
|
114
|
+
if not isinstance(data, list) or len(data) != n:
|
|
115
|
+
return None
|
|
116
|
+
out: List[List[float]] = []
|
|
117
|
+
for item in data:
|
|
118
|
+
vec = item.get("embedding") if isinstance(item, dict) else None
|
|
119
|
+
if not isinstance(vec, list) or not vec:
|
|
120
|
+
return None
|
|
121
|
+
out.append([float(x) for x in vec])
|
|
122
|
+
return out
|