dirsql-plugin-embeddings 0.1.0__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.
@@ -0,0 +1,58 @@
1
+ /target
2
+ packages/*/target
3
+ .beads
4
+ .claude/settings.local.json
5
+ .claude/scheduled_tasks.lock
6
+ .worktrees/
7
+ .claude/worktrees/
8
+
9
+ # Generated by .claude/hooks/select-environment.sh on session start.
10
+ # The environment source files live in agents/environments/ and are checked in.
11
+ agents/build/
12
+
13
+ # Node
14
+ node_modules/
15
+ *.node
16
+
17
+ # TypeScript build output
18
+ packages/ts/dist/
19
+ # TypeScript coverage output
20
+ packages/ts/coverage/
21
+ # napi-rs generated type definitions (authored types live in packages/ts/src/)
22
+ packages/ts/index.d.ts
23
+
24
+ # Python
25
+ __pycache__/
26
+ *.pyc
27
+ *.pyo
28
+ *.so
29
+ .venv/
30
+ .coverage
31
+ .pytest_cache/
32
+ # Rust CLI binary bundled into the wheel by CI (not in source control).
33
+ packages/python/dirsql/_binary/
34
+
35
+ # putitoutthere per-(mode, triple) staging outputs
36
+ packages/ts/build/
37
+
38
+ # wireit cache (per-package)
39
+ packages/*/.wireit/
40
+
41
+ # uv
42
+ uv.lock
43
+
44
+ # Dolt database files (added by bd init)
45
+ .dolt/
46
+ *.db
47
+
48
+ # VitePress / docs
49
+ docs/node_modules/
50
+ docs/.vitepress/cache/
51
+ docs/.vitepress/dist/
52
+
53
+ # Playwright
54
+ docs/test-results/
55
+ docs/playwright-report/
56
+ docs/blob-report/
57
+ docs/.playwright/
58
+ actionlint
@@ -0,0 +1,5 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirsql-plugin-embeddings
3
+ Version: 0.1.0
4
+ Summary: First-party dirsql plugin: semantic search via an OpenAI-compatible embeddings endpoint.
5
+ Requires-Python: >=3.11
@@ -0,0 +1,58 @@
1
+ # dirsql-plugin-embeddings
2
+
3
+ A first-party [`dirsql`](https://github.com/thekevinscott/dirsql) plugin that
4
+ adds **semantic search** over a directory of Markdown files. It is the worked
5
+ implementation behind the [Search documents by
6
+ meaning](../../docs/howto/search-by-meaning.md) how-to, swapping that guide's
7
+ local `model2vec` model for any OpenAI-compatible `/v1/embeddings` endpoint.
8
+
9
+ This is an **in-repo, repo-only** package (not published to an index). It exists
10
+ to prove the dirsql plugin conventions (#531, part of #363) and power the
11
+ semantic-search demo. Deliberately minimal (v0.1): one embedding provider shape,
12
+ one table, no chunking, no config surface beyond three environment variables.
13
+
14
+ ## How it works
15
+
16
+ The plugin ships a `dirsql.toml` fragment that dirsql discovers when the package
17
+ is installed alongside it. The fragment declares:
18
+
19
+ - the [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension, for
20
+ `vec_distance_cosine()`;
21
+ - a `documents` table whose `on-file` hook embeds each `**/*.md` file into a
22
+ TEXT `embedding` column;
23
+ - a `pre-query` hook that embeds the incoming question and emits the
24
+ nearest-neighbor SQL.
25
+
26
+ Both hooks are console scripts that call the same embedder.
27
+
28
+ ## Configuration
29
+
30
+ The embedder reads three environment variables (point them at any hosted or
31
+ self-managed OpenAI-compatible inference server):
32
+
33
+ | Variable | Meaning |
34
+ |---|---|
35
+ | `DIRSQL_EMBEDDINGS_BASE_URL` | Base URL; `/v1/embeddings` is appended. |
36
+ | `DIRSQL_EMBEDDINGS_MODEL` | Model name sent in the request. |
37
+ | `DIRSQL_EMBEDDINGS_API_KEY` | Bearer token for `Authorization`. |
38
+
39
+ ## Console scripts
40
+
41
+ | Script | Hook | Input | Output |
42
+ |---|---|---|---|
43
+ | `dirsql-embeddings-on-file` | `on-file` | a file's absolute path (`argv[1]`) | one-line JSON row array with `path`, `text`, `embedding` |
44
+ | `dirsql-embeddings-pre-query` | `pre-query` | a raw request body (`argv[1]`) | nearest-neighbor SQL over `documents` |
45
+
46
+ `pre-query` accepts both a verbatim server body (`{"q": ...}`) and the CLI
47
+ `query` subcommand's `{"sql": <arg>}` wrapper, so `dirsql query '{"q": ...}'` and
48
+ a real `POST /query` both work.
49
+
50
+ ## Tests
51
+
52
+ Three tiers, per the dirsql testing conventions:
53
+
54
+ - **unit** (colocated, mocked seams) — `src/dirsql_plugin_embeddings/*_test.py`
55
+ - **integration** (`tests/integration/`) — each console script as a real
56
+ subprocess against a local stub `/v1/embeddings` server
57
+ - **e2e** (`tests/e2e/`) — the full loop through the real launcher + `dirsql`
58
+ binary + `sqlite-vec`, nothing mocked but the embedding endpoint
@@ -0,0 +1,7 @@
1
+ {
2
+ "command": "uv run --with sqlite-vec --with-editable . --with-editable ../../packages/python python -m pytest tests/e2e -x -q",
3
+ "ran_at": 1784022840,
4
+ "exit_code": 0,
5
+ "commit": "5f6caffd4996dfdf09cf798c1deb55f6e216fde5",
6
+ "branch": "claude/open-issues-review-le8hm5-531"
7
+ }
@@ -0,0 +1,66 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ # First-party dirsql plugin, published independently to PyPI: the worked
7
+ # implementation behind docs/howto/search-by-meaning.md, backed by an
8
+ # OpenAI-compatible /v1/embeddings endpoint. Exists to prove the plugin
9
+ # rails (#531, part of #363) and power the semantic-search demo.
10
+ name = "dirsql-plugin-embeddings"
11
+ dynamic = ["version"]
12
+ description = "First-party dirsql plugin: semantic search via an OpenAI-compatible embeddings endpoint."
13
+ requires-python = ">=3.11"
14
+
15
+ [tool.hatch.version]
16
+ source = "vcs"
17
+ # putitoutthere tags this package independently as
18
+ # `dirsql-plugin-embeddings-v<version>`, distinct from the workspace
19
+ # `dirsql-{rust,py,npm}` tags sharing the same git history.
20
+ tag-pattern = "^dirsql-plugin-embeddings-v(?P<version>[0-9]+\\.[0-9]+\\.[0-9]+)$"
21
+
22
+ [tool.hatch.version.raw-options]
23
+ version_scheme = "no-guess-dev"
24
+ # This pyproject.toml lives two directories below the repo root (the
25
+ # actual .git location); without an explicit root, setuptools-scm looks
26
+ # for .git right next to pyproject.toml and fails outright rather than
27
+ # walking up.
28
+ root = "../.."
29
+ # tag-pattern above only *extracts* a version from whatever tag `git
30
+ # describe` returns -- it does not restrict which tags are candidates.
31
+ # In this monorepo `git describe` finds the globally-nearest tag (e.g.
32
+ # `dirsql-py-v0.3.110`), the regex correctly rejects it, and
33
+ # setuptools-scm treats that as a hard error rather than falling back.
34
+ # `--match` constrains `git describe` itself to this package's own
35
+ # tag prefix.
36
+ git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "dirsql-plugin-embeddings-v*"]
37
+
38
+ [project.scripts]
39
+ # The two command hooks the fragment invokes; installing the package puts them
40
+ # on PATH so `dirsql` resolves them when it spawns the hooks.
41
+ dirsql-embeddings-on-file = "dirsql_plugin_embeddings.on_file:main"
42
+ dirsql-embeddings-pre-query = "dirsql_plugin_embeddings.pre_query:main"
43
+
44
+ [project.entry-points.dirsql]
45
+ # How the #529 launcher discovers the plugin: group `dirsql`, value = the
46
+ # top-level module whose root ships `dirsql.toml`.
47
+ embeddings = "dirsql_plugin_embeddings"
48
+
49
+ [dependency-groups]
50
+ dev = ["pytest>=8", "pytest-cov>=5", "pytest-describe>=2"]
51
+
52
+ [tool.hatch.build]
53
+ exclude = ["**/*_test.py"]
54
+
55
+ [tool.hatch.build.targets.wheel]
56
+ # The whole package ships, including the non-.py `dirsql.toml` fragment
57
+ # (importlib.resources loads it, and it must be present in the built artifact).
58
+ packages = ["src/dirsql_plugin_embeddings"]
59
+
60
+ [tool.hatch.build.targets.wheel.force-include]
61
+ "src/dirsql_plugin_embeddings/dirsql.toml" = "dirsql_plugin_embeddings/dirsql.toml"
62
+
63
+ [tool.pytest.ini_options]
64
+ # `src` layout: put it on the path so modules and their colocated tests import
65
+ # the package normally (`from dirsql_plugin_embeddings import ...`).
66
+ pythonpath = ["src"]
@@ -0,0 +1,18 @@
1
+ # Fragment shipped by the dirsql-plugin-embeddings plugin.
2
+ # Discovered via the package's [project.entry-points.dirsql] declaration and
3
+ # injected by the launcher as an ordinary -c flag ("installed = active", #529).
4
+ #
5
+ # The table name here MUST match pre_query.TABLE_NAME (the SQL the pre-query
6
+ # hook prints queries this table).
7
+ [dirsql]
8
+ pre-query = "dirsql-embeddings-pre-query {args}"
9
+ hook-timeout = 300
10
+
11
+ [[dirsql.extension]]
12
+ path = "sqlite_vec"
13
+ entrypoint = "sqlite3_vec_init"
14
+
15
+ [[table]]
16
+ ddl = "CREATE TABLE documents (path TEXT, text TEXT, embedding TEXT)"
17
+ glob = "**/*.md"
18
+ on-file = "dirsql-embeddings-on-file {path}"
@@ -0,0 +1,71 @@
1
+ """Embed text via an OpenAI-compatible ``/v1/embeddings`` endpoint.
2
+
3
+ Configuration comes from three environment variables (base URL, model, API
4
+ key). The HTTP call is behind an injected ``post`` seam so the unit tests drive
5
+ it without a real network; ``_urllib_post`` is the production seam.
6
+
7
+ Annotations are evaluated at runtime (no ``from __future__ import annotations``)
8
+ so a mutated ``X | None`` union in a signature fails at import instead of being
9
+ an inert string -- otherwise every annotation-union mutant would survive.
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import urllib.error
15
+ import urllib.request
16
+ from collections.abc import Mapping
17
+
18
+ ENV_BASE_URL = "DIRSQL_EMBEDDINGS_BASE_URL"
19
+ ENV_MODEL = "DIRSQL_EMBEDDINGS_MODEL"
20
+ ENV_API_KEY = "DIRSQL_EMBEDDINGS_API_KEY"
21
+
22
+
23
+ class EmbeddingError(RuntimeError):
24
+ """A configuration, transport, or response error while embedding."""
25
+
26
+
27
+ def _urllib_post(url: str, data: bytes, headers: Mapping[str, str]):
28
+ request = urllib.request.Request(
29
+ url, data=data, headers=dict(headers), method="POST"
30
+ )
31
+ try:
32
+ with urllib.request.urlopen(request) as response:
33
+ return response.status, response.read()
34
+ except urllib.error.HTTPError as error:
35
+ return error.code, error.read()
36
+
37
+
38
+ def _require(env: Mapping[str, str], name: str) -> str:
39
+ value = env.get(name, "")
40
+ if not value:
41
+ raise EmbeddingError(f"missing required environment variable {name}")
42
+ return value
43
+
44
+
45
+ def embed(
46
+ text: str, env: Mapping[str, str] | None = None, post=_urllib_post
47
+ ) -> list[float]:
48
+ if env is None:
49
+ env = os.environ
50
+ base_url = _require(env, ENV_BASE_URL).rstrip("/")
51
+ model = _require(env, ENV_MODEL)
52
+ api_key = _require(env, ENV_API_KEY)
53
+
54
+ data = json.dumps({"model": model, "input": [text]}).encode("utf-8")
55
+ headers = {
56
+ "Content-Type": "application/json",
57
+ "Authorization": f"Bearer {api_key}",
58
+ }
59
+ status, body = post(f"{base_url}/v1/embeddings", data=data, headers=headers)
60
+ if status != 200:
61
+ raise EmbeddingError(f"embeddings endpoint returned status {status}: {body!r}")
62
+
63
+ try:
64
+ entry = json.loads(body)["data"][0]
65
+ except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:
66
+ raise EmbeddingError(f"malformed embeddings response: {body!r}") from exc
67
+
68
+ vector = entry.get("embedding")
69
+ if not isinstance(vector, list) or not vector:
70
+ raise EmbeddingError(f"embeddings response carried no vector: {body!r}")
71
+ return [float(component) for component in vector]
@@ -0,0 +1,33 @@
1
+ """``on-file`` console script: embed one matched file into a dirsql row.
2
+
3
+ Reads the file at ``argv[1]``, embeds its text, and prints a one-line JSON row
4
+ array (``path``, ``text``, ``embedding``) -- the embedding stored as JSON text,
5
+ which ``sqlite-vec`` accepts directly.
6
+
7
+ Annotations are evaluated at runtime (no ``from __future__ import annotations``)
8
+ so a mutated ``X | None`` union in a signature fails at import rather than
9
+ surviving as an inert string.
10
+ """
11
+
12
+ import json
13
+ import sys
14
+
15
+ from .embedder import embed
16
+
17
+
18
+ def _read_text(path: str) -> str:
19
+ with open(path, encoding="utf-8") as handle:
20
+ return handle.read()
21
+
22
+
23
+ def build_rows(path: str, text: str, vector: list[float]) -> list[dict]:
24
+ return [{"path": path, "text": text, "embedding": json.dumps(vector)}]
25
+
26
+
27
+ def main(argv: list[str] | None = None) -> int:
28
+ if argv is None:
29
+ argv = sys.argv
30
+ path = argv[1]
31
+ text = _read_text(path)
32
+ print(json.dumps(build_rows(path, text, embed(text))))
33
+ return 0
@@ -0,0 +1,42 @@
1
+ """``pre-query`` console script: turn a ``{"q": ...}`` body into search SQL.
2
+
3
+ Accepts both a verbatim server body (``{"q": ...}``) and the CLI ``query``
4
+ subcommand's ``{"sql": <arg>}`` wrapper, embeds the question, and prints the
5
+ nearest-neighbor SQL over the ``documents`` table (ordered by
6
+ ``vec_distance_cosine``). The hook owns SQL safety: the only interpolated value
7
+ is a numeric vector this script produced.
8
+
9
+ Annotations are evaluated at runtime (no ``from __future__ import annotations``)
10
+ so a mutated ``X | None`` union in a signature fails at import rather than
11
+ surviving as an inert string.
12
+ """
13
+
14
+ import json
15
+ import sys
16
+
17
+ from .embedder import embed
18
+
19
+ TABLE_NAME = "documents"
20
+ RESULT_LIMIT = 3
21
+
22
+
23
+ def question(raw_body: str) -> str:
24
+ body = json.loads(raw_body)
25
+ if "q" in body:
26
+ return body["q"]
27
+ return json.loads(body["sql"])["q"]
28
+
29
+
30
+ def build_sql(vector: list[float]) -> str:
31
+ needle = json.dumps(vector)
32
+ return (
33
+ f"SELECT path, ROUND(vec_distance_cosine(embedding, '{needle}'), 3) "
34
+ f"AS distance FROM {TABLE_NAME} ORDER BY distance LIMIT {RESULT_LIMIT}"
35
+ )
36
+
37
+
38
+ def main(argv: list[str] | None = None) -> int:
39
+ if argv is None:
40
+ argv = sys.argv
41
+ print(build_sql(embed(question(argv[1]))))
42
+ return 0
@@ -0,0 +1,7 @@
1
+ # Dedicated testing-conventions config for the in-repo dirsql-plugin-embeddings
2
+ # package. Mirrors internals/checks: python coverage floor 100 + branch, zero
3
+ # exemptions, and deliberately no `[e2e] extra_scope` -- this package compiles
4
+ # none of packages/rust/src, so a core change never stales its attestation.
5
+ [python.coverage]
6
+ fail_under = 100
7
+ branch = true
@@ -0,0 +1,62 @@
1
+ """Shared fixtures for the integration and e2e tiers.
2
+
3
+ `stub_server` brings up a real, local, threaded OpenAI-compatible
4
+ `/v1/embeddings` endpoint. Its embedding is a deterministic keyword-count
5
+ vector, so nearest-neighbor search over the fixtures is reproducible without a
6
+ real model or network.
7
+ """
8
+
9
+ import json
10
+ import threading
11
+ from http.server import BaseHTTPRequestHandler, HTTPServer
12
+
13
+ import pytest
14
+
15
+ # One dimension per keyword; the embedding of a text is the per-keyword count.
16
+ KEYWORDS = [
17
+ "pasta",
18
+ "cook",
19
+ "garlic",
20
+ "git",
21
+ "code",
22
+ "review",
23
+ "tomato",
24
+ "plant",
25
+ "seed",
26
+ ]
27
+
28
+
29
+ def keyword_vector(text):
30
+ lowered = text.lower()
31
+ return [float(lowered.count(keyword)) for keyword in KEYWORDS]
32
+
33
+
34
+ class _Handler(BaseHTTPRequestHandler):
35
+ def do_POST(self):
36
+ length = int(self.headers["Content-Length"])
37
+ payload = json.loads(self.rfile.read(length))
38
+ text = payload["input"][0]
39
+ body = json.dumps(
40
+ {"data": [{"index": 0, "embedding": keyword_vector(text)}]}
41
+ ).encode("utf-8")
42
+ self.send_response(200)
43
+ self.send_header("Content-Type", "application/json")
44
+ self.send_header("Content-Length", str(len(body)))
45
+ self.end_headers()
46
+ self.wfile.write(body)
47
+
48
+ def log_message(self, *args): # silence the default stderr access log
49
+ pass
50
+
51
+
52
+ @pytest.fixture
53
+ def stub_server():
54
+ server = HTTPServer(("127.0.0.1", 0), _Handler)
55
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
56
+ thread.start()
57
+ host, port = server.server_address
58
+ try:
59
+ yield f"http://{host}:{port}"
60
+ finally:
61
+ server.shutdown()
62
+ thread.join()
File without changes