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.
Files changed (106) hide show
  1. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/PKG-INFO +16 -1
  2. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/README.md +13 -0
  3. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/_version.py +2 -2
  4. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/cli.py +9 -3
  5. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/discovery.py +20 -2
  6. boost_skill_cli-1.0.20/boost_cli/core/dense.py +267 -0
  7. boost_skill_cli-1.0.20/boost_cli/core/embed.py +122 -0
  8. boost_skill_cli-1.0.20/boost_cli/core/output.py +180 -0
  9. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/rag.py +22 -4
  10. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_skill_cli.egg-info/PKG-INFO +16 -1
  11. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_skill_cli.egg-info/SOURCES.txt +5 -0
  12. boost_skill_cli-1.0.20/boost_skill_cli.egg-info/requires.txt +3 -0
  13. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/design-roadmap.html +4 -4
  14. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/rag-architecture.md +6 -1
  15. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/pyproject.toml +8 -0
  16. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/conftest.py +3 -0
  17. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_discovery.py +42 -0
  18. boost_skill_cli-1.0.20/tests/unit/test_dense.py +260 -0
  19. boost_skill_cli-1.0.20/tests/unit/test_embed.py +159 -0
  20. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_output.py +129 -0
  21. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_rag.py +39 -0
  22. boost_skill_cli-1.0.18/boost_cli/core/output.py +0 -99
  23. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/.gitignore +0 -0
  24. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/CLAUDE.md +0 -0
  25. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/CONTRIBUTING.md +0 -0
  26. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/LICENSE +0 -0
  27. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/MANIFEST.in +0 -0
  28. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/Makefile +0 -0
  29. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/SECURITY.md +0 -0
  30. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost +0 -0
  31. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/__init__.py +0 -0
  32. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/__main__.py +0 -0
  33. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/__init__.py +0 -0
  34. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/bmad.py +0 -0
  35. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/configuration.py +0 -0
  36. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/hooks.py +0 -0
  37. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/info.py +0 -0
  38. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/intelligence.py +0 -0
  39. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/pkg.py +0 -0
  40. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/quality.py +0 -0
  41. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/taps.py +0 -0
  42. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/commands/team.py +0 -0
  43. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/__init__.py +0 -0
  44. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/agents.py +0 -0
  45. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/ai.py +0 -0
  46. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/catalog.py +0 -0
  47. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/claude_settings.py +0 -0
  48. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/config.py +0 -0
  49. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/frontmatter.py +0 -0
  50. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/gitutil.py +0 -0
  51. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/journal.py +0 -0
  52. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/lockfile.py +0 -0
  53. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/logs.py +0 -0
  54. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/paths.py +0 -0
  55. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/policy.py +0 -0
  56. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/registry.py +0 -0
  57. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/store.py +0 -0
  58. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/core/util.py +0 -0
  59. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/data/registries.json +0 -0
  60. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_cli/errors.py +0 -0
  61. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_skill_cli.egg-info/dependency_links.txt +0 -0
  62. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_skill_cli.egg-info/entry_points.txt +0 -0
  63. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/boost_skill_cli.egg-info/top_level.txt +0 -0
  64. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/.claude/settings.local.json +0 -0
  65. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/DEBUGGING.md +0 -0
  66. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/demo.gif +0 -0
  67. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/demo.tape +0 -0
  68. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/index.html +0 -0
  69. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/docs/roadmap.html +0 -0
  70. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/scripts/build_registries.py +0 -0
  71. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/scripts/mutation_gate.py +0 -0
  72. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/setup.cfg +0 -0
  73. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/style/README.md +0 -0
  74. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/style/boost.css +0 -0
  75. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/style/boost.js +0 -0
  76. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/style/demo.html +0 -0
  77. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_bmad.py +0 -0
  78. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_configuration.py +0 -0
  79. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_hooks.py +0 -0
  80. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_info.py +0 -0
  81. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_intelligence.py +0 -0
  82. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_pkg.py +0 -0
  83. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_quality.py +0 -0
  84. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_taps.py +0 -0
  85. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_cli_team.py +0 -0
  86. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/functional/test_everyday_loop.py +0 -0
  87. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/make_fixture.py +0 -0
  88. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/smoke.sh +0 -0
  89. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_agents.py +0 -0
  90. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_ai.py +0 -0
  91. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_catalog.py +0 -0
  92. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_claude_settings.py +0 -0
  93. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_config.py +0 -0
  94. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_errors_and_cli_table.py +0 -0
  95. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_frontmatter.py +0 -0
  96. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_gitutil.py +0 -0
  97. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_journal.py +0 -0
  98. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_lockfile.py +0 -0
  99. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_logs.py +0 -0
  100. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_mutation_hardening.py +0 -0
  101. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_paths.py +0 -0
  102. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_policy.py +0 -0
  103. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_registry.py +0 -0
  104. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_store.py +0 -0
  105. {boost_skill_cli-1.0.18 → boost_skill_cli-1.0.20}/tests/unit/test_util.py +0 -0
  106. {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.18
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.18'
22
- __version_tuple__ = version_tuple = (1, 0, 18)
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.c(PRODUCT, out.BOLD) + " — Homebrew for AI coding skills"
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("%s" % title, out.BOLD, out.YELLOW)
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