dirsql-plugin-embeddings 0.1.9__tar.gz → 0.1.11__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 (27) hide show
  1. dirsql_plugin_embeddings-0.1.11/PKG-INFO +28 -0
  2. dirsql_plugin_embeddings-0.1.11/README.md +20 -0
  3. dirsql_plugin_embeddings-0.1.11/changelog.d/2026-08-10-delete-old-surface.md +10 -0
  4. dirsql_plugin_embeddings-0.1.11/migrations.d/2026-08-10-delete-old-surface.md +41 -0
  5. {dirsql_plugin_embeddings-0.1.9 → dirsql_plugin_embeddings-0.1.11}/pyproject.toml +10 -18
  6. dirsql_plugin_embeddings-0.1.11/src/dirsql_plugin_embeddings/dirsql.toml +6 -0
  7. dirsql_plugin_embeddings-0.1.9/PKG-INFO +0 -90
  8. dirsql_plugin_embeddings-0.1.9/README.md +0 -81
  9. dirsql_plugin_embeddings-0.1.9/e2e-attestations/claude-706-widen-glob.json +0 -7
  10. dirsql_plugin_embeddings-0.1.9/e2e-attestations/claude-725-plugin-glob-comment.json +0 -7
  11. dirsql_plugin_embeddings-0.1.9/src/dirsql_plugin_embeddings/cache.py +0 -20
  12. dirsql_plugin_embeddings-0.1.9/src/dirsql_plugin_embeddings/config.py +0 -31
  13. dirsql_plugin_embeddings-0.1.9/src/dirsql_plugin_embeddings/dirsql.toml +0 -27
  14. dirsql_plugin_embeddings-0.1.9/src/dirsql_plugin_embeddings/embedder.py +0 -71
  15. dirsql_plugin_embeddings-0.1.9/src/dirsql_plugin_embeddings/on_file/__init__.py +0 -11
  16. dirsql_plugin_embeddings-0.1.9/src/dirsql_plugin_embeddings/on_file/build_rows.py +0 -13
  17. dirsql_plugin_embeddings-0.1.9/src/dirsql_plugin_embeddings/on_file/on_file.py +0 -25
  18. dirsql_plugin_embeddings-0.1.9/src/dirsql_plugin_embeddings/on_file/read_content.py +0 -22
  19. dirsql_plugin_embeddings-0.1.9/src/dirsql_plugin_embeddings/on_file/read_pdf.py +0 -31
  20. dirsql_plugin_embeddings-0.1.9/src/dirsql_plugin_embeddings/on_file/read_text.py +0 -11
  21. dirsql_plugin_embeddings-0.1.9/src/dirsql_plugin_embeddings/pre_query.py +0 -42
  22. dirsql_plugin_embeddings-0.1.9/tests/conftest.py +0 -110
  23. dirsql_plugin_embeddings-0.1.9/tests/e2e/__init__.py +0 -0
  24. dirsql_plugin_embeddings-0.1.9/tests/integration/__init__.py +0 -0
  25. {dirsql_plugin_embeddings-0.1.9 → dirsql_plugin_embeddings-0.1.11}/.gitignore +0 -0
  26. {dirsql_plugin_embeddings-0.1.9 → dirsql_plugin_embeddings-0.1.11}/src/dirsql_plugin_embeddings/__init__.py +0 -0
  27. {dirsql_plugin_embeddings-0.1.9 → dirsql_plugin_embeddings-0.1.11}/testing-conventions.toml +0 -0
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirsql-plugin-embeddings
3
+ Version: 0.1.11
4
+ Summary: First-party dirsql plugin: semantic search over files (rebuild in progress).
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: sqlite-vec>=0.1
7
+ Description-Content-Type: text/markdown
8
+
9
+ # dirsql-plugin-embeddings
10
+
11
+ A first-party [`dirsql`](https://github.com/thekevinscott/dirsql) plugin for
12
+ semantic search over files.
13
+
14
+ **Rebuild in progress**
15
+ ([#800](https://github.com/thekevinscott/dirsql/issues/800)): the previous
16
+ surface — a declared `documents` table built eagerly over the working
17
+ directory, per-file `on-file` embedding hooks, a `pre-query` hook, and
18
+ `DIRSQL_EMBEDDINGS_*` endpoint configuration — has been removed. The plugin is
19
+ being rebuilt around a plugin-provided `embed()` SQL function that is inert
20
+ until a query calls it.
21
+
22
+ Today, installing the plugin loads the
23
+ [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension (for
24
+ `vec_distance_cosine()` and friends) and nothing else.
25
+
26
+ ```sh
27
+ uvx --with dirsql-plugin-embeddings dirsql "SELECT vec_distance_cosine('[1, 0]', '[0, 1]') AS d"
28
+ ```
@@ -0,0 +1,20 @@
1
+ # dirsql-plugin-embeddings
2
+
3
+ A first-party [`dirsql`](https://github.com/thekevinscott/dirsql) plugin for
4
+ semantic search over files.
5
+
6
+ **Rebuild in progress**
7
+ ([#800](https://github.com/thekevinscott/dirsql/issues/800)): the previous
8
+ surface — a declared `documents` table built eagerly over the working
9
+ directory, per-file `on-file` embedding hooks, a `pre-query` hook, and
10
+ `DIRSQL_EMBEDDINGS_*` endpoint configuration — has been removed. The plugin is
11
+ being rebuilt around a plugin-provided `embed()` SQL function that is inert
12
+ until a query calls it.
13
+
14
+ Today, installing the plugin loads the
15
+ [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension (for
16
+ `vec_distance_cosine()` and friends) and nothing else.
17
+
18
+ ```sh
19
+ uvx --with dirsql-plugin-embeddings dirsql "SELECT vec_distance_cosine('[1, 0]', '[0, 1]') AS d"
20
+ ```
@@ -0,0 +1,10 @@
1
+ **Removed**
2
+
3
+ The plugin's entire v0.1 surface: the declared `documents` table (built
4
+ eagerly over the working directory on every `dirsql` invocation), the
5
+ `on-file` readers (text and PDF), the `pre-query` hook, both console scripts
6
+ (`dirsql-embeddings-on-file`, `dirsql-embeddings-pre-query`), and the
7
+ `DIRSQL_EMBEDDINGS_*` environment variables. The shipped `dirsql.toml`
8
+ fragment now declares only sqlite-vec extension loading, which carries
9
+ forward. The plugin is being rebuilt around a plugin-provided `embed()` SQL
10
+ function (#800).
@@ -0,0 +1,41 @@
1
+ ### dirsql-plugin-embeddings: old surface removed (declared table, hooks, env vars)
2
+
3
+ #### Summary
4
+
5
+ The plugin's v0.1 surface is deleted with no deprecation period ahead of the
6
+ `embed()` SQL-function rebuild (#800): installing the plugin no longer
7
+ declares a `documents` table, no longer spawns per-file `on-file` embedding
8
+ subprocesses, no longer rewrites queries through a `pre-query` hook, and no
9
+ longer reads `DIRSQL_EMBEDDINGS_*` environment variables. Only sqlite-vec
10
+ extension loading carries forward. Any workflow that queried the `documents`
11
+ table with the plugin installed breaks.
12
+
13
+ #### Required changes
14
+
15
+ | Surface | Before | After |
16
+ | ------- | ------ | ----- |
17
+ | `documents` table | `dirsql query '{"q": "..."}'` returned nearest-neighbor rows from an auto-built `documents` table | Removed; no replacement until the #800 rebuild lands (`SELECT ... FROM documents` now fails with `no such table: documents`) |
18
+ | Console scripts | `dirsql-embeddings-on-file <path>`, `dirsql-embeddings-pre-query <body>` on PATH | Removed; uninstall any external callers |
19
+ | Env vars | `DIRSQL_EMBEDDINGS_BASE_URL` / `DIRSQL_EMBEDDINGS_MODEL` / `DIRSQL_EMBEDDINGS_API_KEY` (and `DIRSQL_EMBEDDINGS_CACHE_READ`) configured the embedding endpoint | Removed; unset them — nothing reads them |
20
+ | sqlite-vec loading | `[[dirsql.extension]]` in the shipped fragment | Unchanged — still loaded when the plugin is installed |
21
+
22
+ #### Deprecations removed
23
+
24
+ _None._
25
+
26
+ #### Behavior changes without code changes
27
+
28
+ - Any `dirsql` invocation with the plugin installed: previously built the
29
+ `documents` table eagerly — one Python subprocess (and, when configured, one
30
+ billed embedding call) per matched file under the working directory, before
31
+ the query ran; now no subprocess is spawned and no table is created — the
32
+ plugin only loads sqlite-vec.
33
+
34
+ #### Verification
35
+
36
+ ```bash
37
+ uvx --with dirsql-plugin-embeddings dirsql query "SELECT vec_distance_cosine('[1, 0]', '[0, 1]') AS d"
38
+ # expected: [{"d":1.0}]
39
+ uvx --with dirsql-plugin-embeddings dirsql query "SELECT * FROM documents"
40
+ # expected: error containing `no such table: documents`
41
+ ```
@@ -3,22 +3,20 @@ requires = ["hatchling", "hatch-vcs"]
3
3
  build-backend = "hatchling.build"
4
4
 
5
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.
6
+ # First-party dirsql plugin, published independently to PyPI. Mid-rebuild:
7
+ # today it only loads sqlite-vec; the embed() SQL function surface is landing
8
+ # in follow-up slices.
10
9
  name = "dirsql-plugin-embeddings"
11
10
  dynamic = ["version"]
12
- description = "First-party dirsql plugin: semantic search via an OpenAI-compatible embeddings endpoint."
11
+ description = "First-party dirsql plugin: semantic search over files (rebuild in progress)."
13
12
  readme = "README.md"
14
13
  requires-python = ">=3.10"
15
- # pypdf, not pymupdf: pymupdf is AGPL-3.0, and a runtime dependency of this
16
- # MIT-licensed package propagates to everyone who installs it.
17
- #
18
- # cachetta >=0.6.15 is load-bearing, not a nicety: every earlier release
19
- # declares `requires-python >=3.12`, which would drag this package's floor up
20
- # with it. 0.6.15 lowered cachetta's own floor to `>=3.10`, matching ours.
21
- dependencies = ["pypdf>=6", "cachetta>=0.6.15"]
14
+ # sqlite-vec is a runtime dependency, not a docs suggestion: the shipped
15
+ # dirsql.toml fragment declares `path = "sqlite_vec"`, which the launcher can
16
+ # only resolve when the package is installed. Declaring it here makes the
17
+ # quickstart (`uvx --with dirsql-plugin-embeddings dirsql`) work without a
18
+ # separate `--with sqlite-vec`.
19
+ dependencies = ["sqlite-vec>=0.1"]
22
20
 
23
21
  [tool.hatch.version]
24
22
  source = "vcs"
@@ -43,12 +41,6 @@ root = "../.."
43
41
  # tag prefix.
44
42
  git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "dirsql-plugin-embeddings-v*"]
45
43
 
46
- [project.scripts]
47
- # The two command hooks the fragment invokes; installing the package puts them
48
- # on PATH so `dirsql` resolves them when it spawns the hooks.
49
- dirsql-embeddings-on-file = "dirsql_plugin_embeddings.on_file:on_file"
50
- dirsql-embeddings-pre-query = "dirsql_plugin_embeddings.pre_query:main"
51
-
52
44
  [project.entry-points.dirsql]
53
45
  # How the #529 launcher discovers the plugin: group `dirsql`, value = the
54
46
  # top-level module whose root ships `dirsql.toml`.
@@ -0,0 +1,6 @@
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").
4
+ [[dirsql.extension]]
5
+ path = "sqlite_vec"
6
+ entrypoint = "sqlite3_vec_init"
@@ -1,90 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: dirsql-plugin-embeddings
3
- Version: 0.1.9
4
- Summary: First-party dirsql plugin: semantic search via an OpenAI-compatible embeddings endpoint.
5
- Requires-Python: >=3.10
6
- Requires-Dist: cachetta>=0.6.15
7
- Requires-Dist: pypdf>=6
8
- Description-Content-Type: text/markdown
9
-
10
- # dirsql-plugin-embeddings
11
-
12
- A first-party [`dirsql`](https://github.com/thekevinscott/dirsql) plugin that
13
- adds **semantic search** over a directory of documents -- Markdown, plain
14
- text, reStructuredText and PDFs. It is the worked
15
- implementation behind the [Search documents by
16
- meaning](https://thekevinscott.github.io/dirsql/howto/search-by-meaning) how-to,
17
- swapping that guide's local `model2vec` model for any OpenAI-compatible
18
- `/v1/embeddings` endpoint.
19
-
20
- ```sh
21
- uvx --with dirsql-plugin-embeddings dirsql
22
- ```
23
-
24
- Deliberately minimal (v0.1): one embedding provider shape, one table, no
25
- chunking, no config surface beyond three environment variables.
26
-
27
- ## How it works
28
-
29
- The plugin ships a `dirsql.toml` fragment that dirsql discovers when the package
30
- is installed alongside it. The fragment declares:
31
-
32
- - the [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension, for
33
- `vec_distance_cosine()`;
34
- - a `documents` table whose `on-file` hook embeds each
35
- `**/*.{md,markdown,mdx,rst,txt,pdf}` file into a TEXT `embedding` column;
36
- - a `pre-query` hook that embeds the incoming question and emits the
37
- nearest-neighbor SQL.
38
-
39
- Both hooks are console scripts that call the same embedder.
40
-
41
- Every matched extension except `.pdf` is read as UTF-8 text; a `.pdf` is read with
42
- [pypdf](https://pypdf.readthedocs.io), whose per-page extracted text is joined
43
- and embedded like any other document. The extension check is case-insensitive
44
- (`.PDF` is a PDF), though the glob above is not — globset matches case-sensitively,
45
- so an uppercase-suffixed file needs its own `glob` entry to be picked up at all.
46
-
47
- The glob is an allowlist rather than `**/*` on cost, not correctness. Every
48
- matched file costs a hook subprocess, and every file the plugin can decode costs
49
- a billed embedding call — so pointing `**/*` at a tree containing `node_modules`
50
- or `.git` makes for a slow and expensive scan. The list is what is worth
51
- embedding; widening it trades money for recall.
52
-
53
- A file the plugin cannot read is skipped, not fatal. The hook exits non-zero,
54
- dirsql names the file on stderr and carries on indexing the rest, and the run
55
- exits `23` — "completed, some files skipped". From the SDK the same information
56
- is on `scan_failures()` / `scanFailures()`. A *scanned*, image-only PDF is not a
57
- failure at all: pypdf yields no text, and the file is indexed with an empty
58
- `text`, exactly like an empty `.md`.
59
-
60
- ## Configuration
61
-
62
- The embedder reads three environment variables (point them at any hosted or
63
- self-managed OpenAI-compatible inference server):
64
-
65
- | Variable | Meaning |
66
- |---|---|
67
- | `DIRSQL_EMBEDDINGS_BASE_URL` | Base URL; `/v1/embeddings` is appended. |
68
- | `DIRSQL_EMBEDDINGS_MODEL` | Model name sent in the request. |
69
- | `DIRSQL_EMBEDDINGS_API_KEY` | Bearer token for `Authorization`. |
70
-
71
- ## Console scripts
72
-
73
- | Script | Hook | Input | Output |
74
- |---|---|---|---|
75
- | `dirsql-embeddings-on-file` | `on-file` | a file's absolute path (`argv[1]`), text or PDF | one-line JSON row array with `path`, `text`, `embedding` |
76
- | `dirsql-embeddings-pre-query` | `pre-query` | a raw request body (`argv[1]`) | nearest-neighbor SQL over `documents` |
77
-
78
- `pre-query` accepts both a verbatim server body (`{"q": ...}`) and the CLI
79
- `query` subcommand's `{"sql": <arg>}` wrapper, so `dirsql query '{"q": ...}'` and
80
- a real `POST /query` both work.
81
-
82
- ## Tests
83
-
84
- Three tiers, per the dirsql testing conventions:
85
-
86
- - **unit** (colocated, mocked seams) — `src/dirsql_plugin_embeddings/*_test.py`
87
- - **integration** (`tests/integration/`) — each console script as a real
88
- subprocess against a local stub `/v1/embeddings` server
89
- - **e2e** (`tests/e2e/`) — the full loop through the real launcher + `dirsql`
90
- binary + `sqlite-vec`, nothing mocked but the embedding endpoint
@@ -1,81 +0,0 @@
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 documents -- Markdown, plain
5
- text, reStructuredText and PDFs. It is the worked
6
- implementation behind the [Search documents by
7
- meaning](https://thekevinscott.github.io/dirsql/howto/search-by-meaning) how-to,
8
- swapping that guide's local `model2vec` model for any OpenAI-compatible
9
- `/v1/embeddings` endpoint.
10
-
11
- ```sh
12
- uvx --with dirsql-plugin-embeddings dirsql
13
- ```
14
-
15
- Deliberately minimal (v0.1): one embedding provider shape, one table, no
16
- chunking, no config surface beyond three environment variables.
17
-
18
- ## How it works
19
-
20
- The plugin ships a `dirsql.toml` fragment that dirsql discovers when the package
21
- is installed alongside it. The fragment declares:
22
-
23
- - the [`sqlite-vec`](https://github.com/asg017/sqlite-vec) extension, for
24
- `vec_distance_cosine()`;
25
- - a `documents` table whose `on-file` hook embeds each
26
- `**/*.{md,markdown,mdx,rst,txt,pdf}` file into a TEXT `embedding` column;
27
- - a `pre-query` hook that embeds the incoming question and emits the
28
- nearest-neighbor SQL.
29
-
30
- Both hooks are console scripts that call the same embedder.
31
-
32
- Every matched extension except `.pdf` is read as UTF-8 text; a `.pdf` is read with
33
- [pypdf](https://pypdf.readthedocs.io), whose per-page extracted text is joined
34
- and embedded like any other document. The extension check is case-insensitive
35
- (`.PDF` is a PDF), though the glob above is not — globset matches case-sensitively,
36
- so an uppercase-suffixed file needs its own `glob` entry to be picked up at all.
37
-
38
- The glob is an allowlist rather than `**/*` on cost, not correctness. Every
39
- matched file costs a hook subprocess, and every file the plugin can decode costs
40
- a billed embedding call — so pointing `**/*` at a tree containing `node_modules`
41
- or `.git` makes for a slow and expensive scan. The list is what is worth
42
- embedding; widening it trades money for recall.
43
-
44
- A file the plugin cannot read is skipped, not fatal. The hook exits non-zero,
45
- dirsql names the file on stderr and carries on indexing the rest, and the run
46
- exits `23` — "completed, some files skipped". From the SDK the same information
47
- is on `scan_failures()` / `scanFailures()`. A *scanned*, image-only PDF is not a
48
- failure at all: pypdf yields no text, and the file is indexed with an empty
49
- `text`, exactly like an empty `.md`.
50
-
51
- ## Configuration
52
-
53
- The embedder reads three environment variables (point them at any hosted or
54
- self-managed OpenAI-compatible inference server):
55
-
56
- | Variable | Meaning |
57
- |---|---|
58
- | `DIRSQL_EMBEDDINGS_BASE_URL` | Base URL; `/v1/embeddings` is appended. |
59
- | `DIRSQL_EMBEDDINGS_MODEL` | Model name sent in the request. |
60
- | `DIRSQL_EMBEDDINGS_API_KEY` | Bearer token for `Authorization`. |
61
-
62
- ## Console scripts
63
-
64
- | Script | Hook | Input | Output |
65
- |---|---|---|---|
66
- | `dirsql-embeddings-on-file` | `on-file` | a file's absolute path (`argv[1]`), text or PDF | one-line JSON row array with `path`, `text`, `embedding` |
67
- | `dirsql-embeddings-pre-query` | `pre-query` | a raw request body (`argv[1]`) | nearest-neighbor SQL over `documents` |
68
-
69
- `pre-query` accepts both a verbatim server body (`{"q": ...}`) and the CLI
70
- `query` subcommand's `{"sql": <arg>}` wrapper, so `dirsql query '{"q": ...}'` and
71
- a real `POST /query` both work.
72
-
73
- ## Tests
74
-
75
- Three tiers, per the dirsql testing conventions:
76
-
77
- - **unit** (colocated, mocked seams) — `src/dirsql_plugin_embeddings/*_test.py`
78
- - **integration** (`tests/integration/`) — each console script as a real
79
- subprocess against a local stub `/v1/embeddings` server
80
- - **e2e** (`tests/e2e/`) — the full loop through the real launcher + `dirsql`
81
- binary + `sqlite-vec`, nothing mocked but the embedding endpoint
@@ -1,7 +0,0 @@
1
- {
2
- "command": "uv run --with sqlite-vec --with-editable . --with-editable ../../packages/python python -m pytest tests/e2e -x -q",
3
- "ran_at": 1785339217,
4
- "exit_code": 0,
5
- "commit": "f96d82085395d4e38bd74992a6ae3c7b029d5f60",
6
- "branch": "claude/706-widen-glob"
7
- }
@@ -1,7 +0,0 @@
1
- {
2
- "command": "uv run --with sqlite-vec --with-editable . --with-editable ../../packages/python python -m pytest tests/e2e -q",
3
- "ran_at": 1785680099,
4
- "exit_code": 0,
5
- "commit": "0030917b47975bfa9b4d7412e7f00ca2b760f292",
6
- "branch": "claude/725-plugin-glob-comment"
7
- }
@@ -1,20 +0,0 @@
1
- """The plugin's one configured cache.
2
-
3
- Every cached function derives its own sub-cache from this singleton with
4
- cachetta's ``cache / 'name'``, which keeps the defaults and appends a
5
- subdirectory. Naming that subdirectory after the function is what keeps caches
6
- apart: cachetta hashes the *arguments* of a call, not the identity of the
7
- function, so two cached functions sharing a directory would collide as soon as
8
- their argument shapes matched.
9
- """
10
-
11
- from cachetta import Cachetta
12
-
13
- from .config import CACHE_DIR, CACHE_DURATION, CACHE_READ
14
-
15
- cache = Cachetta(
16
- path=CACHE_DIR,
17
- hashed=True,
18
- duration=CACHE_DURATION,
19
- read=CACHE_READ,
20
- )
@@ -1,31 +0,0 @@
1
- """Cache configuration: what the plugin caches, where, and for how long.
2
-
3
- Annotations are evaluated at runtime (no ``from __future__ import
4
- annotations``) so a mutated ``X | None`` union in a signature fails at import
5
- rather than surviving as an inert string.
6
- """
7
-
8
- import os
9
- from datetime import timedelta
10
- from pathlib import Path
11
-
12
- PLUGIN_NAME = "dirsql-plugin-embeddings"
13
-
14
- CACHE_DIR = Path.home() / ".cache" / PLUGIN_NAME
15
-
16
- # Every cache here keys on the source file's mtime, so a hit can never be
17
- # stale and expiry would only re-do work for a file nothing has touched.
18
- # cachetta's own default is 7 days.
19
- CACHE_DURATION = timedelta(days=365)
20
-
21
- # Set to "0" to make every call recompute. Writes still happen -- it is reads
22
- # that would otherwise let one test run's leftovers decide the next one's
23
- # result, and the hooks are subprocesses, so an inherited environment variable
24
- # is what reaches them.
25
- ENV_CACHE_READ = "DIRSQL_EMBEDDINGS_CACHE_READ"
26
- CACHE_READ_OFF = frozenset({"0"})
27
-
28
- # Membership rather than `!= "0"`: CPython interns single-character strings, so
29
- # for any value the environment can hold, `x == "0"` and `x is "0"` agree --
30
- # which makes the equality form's identity variant untestable in one direction.
31
- CACHE_READ = os.environ.get(ENV_CACHE_READ, "1") not in CACHE_READ_OFF
@@ -1,27 +0,0 @@
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
- # The brace is globset alternation, not a dirsql `{name}` placeholder: the core
16
- # rewrites those to `*`, but only when the braces hold a bare identifier, so a
17
- # comma-separated list reaches globset intact.
18
- #
19
- # An allowlist rather than `**/*` because every matched file costs a hook
20
- # subprocess and, for anything `read_content` can decode, a billed embedding
21
- # call. Pointed at a tree holding `node_modules` or `.git`, `**/*` is a slow
22
- # and expensive scan, not a broken one -- unreadable files are skipped. So the
23
- # list is what is worth embedding, and widening it trades money for recall.
24
- [[table]]
25
- ddl = "CREATE TABLE documents (path TEXT, text TEXT, embedding TEXT)"
26
- glob = "**/*.{md,markdown,mdx,rst,txt,pdf}"
27
- on-file = "dirsql-embeddings-on-file {path}"
@@ -1,71 +0,0 @@
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]
@@ -1,11 +0,0 @@
1
- """``on-file`` console script: embed one matched file into a dirsql row.
2
-
3
- The package barrel: ``pyproject.toml`` points the ``dirsql-embeddings-on-file``
4
- script at ``dirsql_plugin_embeddings.on_file:on_file``, which resolves here, so
5
- this re-export is the shipped command's public surface -- moving the callable
6
- between modules is free, dropping it from ``__all__`` breaks the install.
7
- """
8
-
9
- from .on_file import on_file
10
-
11
- __all__ = ["on_file"]
@@ -1,13 +0,0 @@
1
- """Shape one embedded file into the command hook's row array.
2
-
3
- Annotations are evaluated at runtime (no ``from __future__ import
4
- annotations``) so a mutated ``X | None`` union in a signature fails at import
5
- rather than surviving as an inert string.
6
- """
7
-
8
- import json
9
-
10
-
11
- def build_rows(path: str, text: str, vector: list[float]) -> list[dict]:
12
- # The embedding is stored as JSON text, which `sqlite-vec` accepts directly.
13
- return [{"path": path, "text": text, "embedding": json.dumps(vector)}]
@@ -1,25 +0,0 @@
1
- """The ``on-file`` hook entry point.
2
-
3
- Reads the file at ``argv[1]``, embeds its text, and prints a one-line JSON row
4
- array (``path``, ``text``, ``embedding``).
5
-
6
- Annotations are evaluated at runtime (no ``from __future__ import
7
- annotations``) so a mutated ``X | None`` union in a signature fails at import
8
- rather than surviving as an inert string.
9
- """
10
-
11
- import json
12
- import sys
13
-
14
- from ..embedder import embed
15
- from .build_rows import build_rows
16
- from .read_content import read_content
17
-
18
-
19
- def on_file(argv: list[str] | None = None) -> int:
20
- if argv is None:
21
- argv = sys.argv
22
- path = argv[1]
23
- text = read_content(path)
24
- print(json.dumps(build_rows(path, text, embed(text))))
25
- return 0
@@ -1,22 +0,0 @@
1
- """The entry point's single read seam: everything the hook reads comes through
2
- here, so per-format handling has one place to land without touching `on_file`.
3
-
4
- The extension is matched case-insensitively, and more permissively than the
5
- fragment's glob (which globset matches case-sensitively): the hook is a console
6
- script runnable against any path, not only the ones a glob selected.
7
-
8
- Annotations are evaluated at runtime (no ``from __future__ import
9
- annotations``) so a mutated ``X | None`` union in a signature fails at import
10
- rather than surviving as an inert string.
11
- """
12
-
13
- import os
14
-
15
- from .read_pdf import read_pdf
16
- from .read_text import read_text
17
-
18
-
19
- def read_content(path: str) -> str:
20
- if os.path.splitext(path)[1].lower() == ".pdf":
21
- return read_pdf(path)
22
- return read_text(path)
@@ -1,31 +0,0 @@
1
- """Extract a PDF's text, once per version of the file.
2
-
3
- pypdf rather than pymupdf: pymupdf is AGPL-3.0, and a runtime dependency of
4
- this MIT-licensed package propagates to everyone who installs it.
5
-
6
- Extraction is the expensive step of a scan and a scan re-reads every matched
7
- file, so the result is persisted to disk. The cache key is the pair
8
- ``(path, mtime)``: an edited PDF gets a different key rather than a stale hit.
9
-
10
- Annotations are evaluated at runtime (no ``from __future__ import
11
- annotations``) so a mutated ``X | None`` union in a signature fails at import
12
- rather than surviving as an inert string.
13
- """
14
-
15
- import os
16
-
17
- from pypdf import PdfReader
18
-
19
- from ..cache import cache
20
-
21
-
22
- @cache / "extract"
23
- def extract(path: str, mtime: float) -> str:
24
- # `mtime` is unread on purpose: it is what makes an edited PDF a cache
25
- # miss, since cachetta derives the key from the arguments it is called with.
26
- reader = PdfReader(path)
27
- return "\n".join(page.extract_text() for page in reader.pages)
28
-
29
-
30
- def read_pdf(path: str) -> str:
31
- return extract(path, os.path.getmtime(path))
@@ -1,11 +0,0 @@
1
- """Read the matched file's text off disk.
2
-
3
- Annotations are evaluated at runtime (no ``from __future__ import
4
- annotations``) so a mutated ``X | None`` union in a signature fails at import
5
- rather than surviving as an inert string.
6
- """
7
-
8
-
9
- def read_text(path: str) -> str:
10
- with open(path, encoding="utf-8") as handle:
11
- return handle.read()
@@ -1,42 +0,0 @@
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
@@ -1,110 +0,0 @@
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
- `make_pdf` builds a real PDF by hand rather than pulling in a writer library:
9
- these tiers mock nothing, and a one-page catalog with a `BT ... Tj ET` content
10
- stream is enough for pypdf to extract the text back out.
11
-
12
- Cache reads are switched off for both tiers. These tiers run the hooks as
13
- subprocesses, which no in-process mock can reach, so a cached extraction from an
14
- earlier run would otherwise decide this one's result. Writes are left alone --
15
- they are harmless, and it is only the read that leaks state between runs.
16
- """
17
-
18
- import json
19
- import os
20
- import threading
21
- from http.server import BaseHTTPRequestHandler, HTTPServer
22
-
23
- os.environ["DIRSQL_EMBEDDINGS_CACHE_READ"] = "0"
24
-
25
- import pytest
26
-
27
- # One dimension per keyword; the embedding of a text is the per-keyword count.
28
- KEYWORDS = [
29
- "pasta",
30
- "cook",
31
- "garlic",
32
- "git",
33
- "code",
34
- "review",
35
- "tomato",
36
- "plant",
37
- "seed",
38
- ]
39
-
40
-
41
- def keyword_vector(text):
42
- lowered = text.lower()
43
- return [float(lowered.count(keyword)) for keyword in KEYWORDS]
44
-
45
-
46
- class _Handler(BaseHTTPRequestHandler):
47
- def do_POST(self):
48
- length = int(self.headers["Content-Length"])
49
- payload = json.loads(self.rfile.read(length))
50
- text = payload["input"][0]
51
- body = json.dumps(
52
- {"data": [{"index": 0, "embedding": keyword_vector(text)}]}
53
- ).encode("utf-8")
54
- self.send_response(200)
55
- self.send_header("Content-Type", "application/json")
56
- self.send_header("Content-Length", str(len(body)))
57
- self.end_headers()
58
- self.wfile.write(body)
59
-
60
- def log_message(self, *args): # silence the default stderr access log
61
- pass
62
-
63
-
64
- def _make_pdf(text):
65
- stream = f"BT /F1 24 Tf 72 700 Td ({text}) Tj ET".encode()
66
- objs = [
67
- b"<< /Type /Catalog /Pages 2 0 R >>",
68
- b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
69
- b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
70
- b"/Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
71
- b"<< /Length "
72
- + str(len(stream)).encode()
73
- + b" >>\nstream\n"
74
- + stream
75
- + b"\nendstream",
76
- b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
77
- ]
78
- out = bytearray(b"%PDF-1.4\n")
79
- offsets = []
80
- for index, body in enumerate(objs, start=1):
81
- offsets.append(len(out))
82
- out += f"{index} 0 obj\n".encode() + body + b"\nendobj\n"
83
- xref = len(out)
84
- out += f"xref\n0 {len(objs) + 1}\n".encode()
85
- out += b"0000000000 65535 f \n"
86
- for offset in offsets:
87
- out += f"{offset:010d} 00000 n \n".encode()
88
- out += (
89
- f"trailer\n<< /Size {len(objs) + 1} /Root 1 0 R >>\n"
90
- f"startxref\n{xref}\n%%EOF\n"
91
- ).encode()
92
- return bytes(out)
93
-
94
-
95
- @pytest.fixture
96
- def make_pdf():
97
- return _make_pdf
98
-
99
-
100
- @pytest.fixture
101
- def stub_server():
102
- server = HTTPServer(("127.0.0.1", 0), _Handler)
103
- thread = threading.Thread(target=server.serve_forever, daemon=True)
104
- thread.start()
105
- host, port = server.server_address
106
- try:
107
- yield f"http://{host}:{port}"
108
- finally:
109
- server.shutdown()
110
- thread.join()
File without changes