dirsql-plugin-embeddings 0.1.17__tar.gz → 0.1.19__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 (52) hide show
  1. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/.gitignore +3 -1
  2. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/PKG-INFO +1 -1
  3. dirsql_plugin_embeddings-0.1.19/changelog.d/2026-09-04-report-cache-hits.md +3 -0
  4. dirsql_plugin_embeddings-0.1.19/changelog.d/2026-09-05-one-function-per-file.md +7 -0
  5. dirsql_plugin_embeddings-0.1.19/e2e-attestations/claude-1095-ofpf-embeddings.json +7 -0
  6. dirsql_plugin_embeddings-0.1.19/e2e-attestations/claude-tackle-1034-plugin-flag.json +7 -0
  7. dirsql_plugin_embeddings-0.1.19/migrations.d/2026-09-04-report-cache-hits.md +39 -0
  8. dirsql_plugin_embeddings-0.1.19/src/dirsql_plugin_embeddings/embedding/model.py +7 -0
  9. dirsql_plugin_embeddings-0.1.17/src/dirsql_plugin_embeddings/embedding/model.py → dirsql_plugin_embeddings-0.1.19/src/dirsql_plugin_embeddings/embedding/model_identifier.py +0 -9
  10. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/embedding/worker.py +19 -6
  11. dirsql_plugin_embeddings-0.1.19/src/dirsql_plugin_embeddings/search/build_search_sql.py +18 -0
  12. dirsql_plugin_embeddings-0.1.19/src/dirsql_plugin_embeddings/search/count_corpus_sql.py +6 -0
  13. dirsql_plugin_embeddings-0.1.19/src/dirsql_plugin_embeddings/search/embed_call.py +7 -0
  14. dirsql_plugin_embeddings-0.1.19/src/dirsql_plugin_embeddings/search/no_rows_message.py +7 -0
  15. dirsql_plugin_embeddings-0.1.19/src/dirsql_plugin_embeddings/search/normalize_glob.py +11 -0
  16. dirsql_plugin_embeddings-0.1.19/src/dirsql_plugin_embeddings/search/quote.py +3 -0
  17. dirsql_plugin_embeddings-0.1.19/src/dirsql_plugin_embeddings/search/run.py +17 -0
  18. dirsql_plugin_embeddings-0.1.19/src/dirsql_plugin_embeddings/search/search_rows.py +23 -0
  19. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/testing-conventions.toml +3 -0
  20. dirsql_plugin_embeddings-0.1.17/src/dirsql_plugin_embeddings/search/run.py +0 -45
  21. dirsql_plugin_embeddings-0.1.17/src/dirsql_plugin_embeddings/search/sql.py +0 -41
  22. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/README.md +0 -0
  23. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/changelog.d/2026-08-10-delete-old-surface.md +0 -0
  24. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/changelog.d/2026-08-10-embed-worker.md +0 -0
  25. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/changelog.d/2026-08-11-one-liner-cli.md +0 -0
  26. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/changelog.d/2026-08-12-empty-search-reports-why.md +0 -0
  27. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/changelog.d/2026-08-12-no-per-call-progress-bar.md +0 -0
  28. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/changelog.d/2026-08-12-null-embedding-guard.md +0 -0
  29. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/changelog.d/2026-08-12-worker-semaphore-leak.md +0 -0
  30. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/e2e-attestations/claude-804-embed-worker.json +0 -0
  31. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/e2e-attestations/claude-805-one-liner.json +0 -0
  32. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/e2e-attestations/claude-814-suppress-per-call-progress.json +0 -0
  33. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/e2e-attestations/claude-815-loky-semaphore.json +0 -0
  34. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/e2e-attestations/claude-816-empty-corpus-error.json +0 -0
  35. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/e2e-attestations/claude-817-null-embedding-guard.json +0 -0
  36. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/migrations.d/2026-08-10-delete-old-surface.md +0 -0
  37. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/pyproject.toml +0 -0
  38. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/__init__.py +0 -0
  39. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/cli/__init__.py +0 -0
  40. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/cli/main.py +0 -0
  41. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/cli/search.py +0 -0
  42. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/cli/worker.py +0 -0
  43. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/dirsql.toml +0 -0
  44. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/embedding/__init__.py +0 -0
  45. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/embedding/cache.py +0 -0
  46. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/embedding/progress.py +0 -0
  47. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/embedding/values.py +0 -0
  48. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/search/__init__.py +0 -0
  49. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/src/dirsql_plugin_embeddings/search/output.py +0 -0
  50. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/tests/conftest.py +0 -0
  51. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/tests/e2e/__init__.py +0 -0
  52. {dirsql_plugin_embeddings-0.1.17 → dirsql_plugin_embeddings-0.1.19}/tests/integration/__init__.py +0 -0
@@ -57,5 +57,7 @@ docs/blob-report/
57
57
  docs/.playwright/
58
58
  actionlint
59
59
 
60
- # cargo-mutants run output (never commit)
60
+ # cargo-mutants run output (never commit). The tool rotates the previous
61
+ # run's directory to `.old` on every re-run, so both forms need ignoring.
61
62
  mutants.out/
63
+ mutants.out.old/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: dirsql-plugin-embeddings
3
- Version: 0.1.17
3
+ Version: 0.1.19
4
4
  Summary: First-party dirsql plugin: semantic search over files.
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: cachetta>=0.7
@@ -0,0 +1,3 @@
1
+ **Added**
2
+
3
+ - **The worker now tells dirsql which embeddings cost nothing.** Every `ok` response carries `"meta": {"cached": <bool>}`, so the progress line can split its worker-call count — `dirsql: ran 41231 worker calls in 2m41s (38104 cached)` — instead of reporting one undifferentiated total for a run that was mostly disk reads. `cachetta`'s wrapper returns a hit and a miss identically, so the flag is recovered from whether the wrapped compute actually ran. (#1034)
@@ -0,0 +1,7 @@
1
+ **Changed**
2
+
3
+ Split `search/sql.py`, `search/run.py`, and `embedding/model.py` into one
4
+ module per non-trivial function, so the tree is clean at
5
+ `one-function-per-file`'s strictest threshold. Every symbol keeps its behavior;
6
+ `search.run._search` is now `search.search_rows.search_rows`, and the SQL
7
+ builders moved to modules named for them.
@@ -0,0 +1,7 @@
1
+ {
2
+ "command": "uv run --with-editable ../../packages/python python -m pytest tests/e2e/ -x -q",
3
+ "ran_at": 1788575701,
4
+ "exit_code": 0,
5
+ "commit": "8f07e97547e3c145e6e6635e89839b6318d25b7c",
6
+ "branch": "claude/1095-ofpf-embeddings"
7
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "command": "uv run --with-editable ../../packages/python python -m pytest tests/e2e/ -x -q",
3
+ "ran_at": 1788558384,
4
+ "exit_code": 0,
5
+ "commit": "fc3ee82adb022a9b6da85687695ca752dd178f03",
6
+ "branch": "claude/tackle-1034-plugin-flag"
7
+ }
@@ -0,0 +1,39 @@
1
+ ### dirsql-plugin-embeddings: `ok` responses now carry cache metadata
2
+
3
+ #### Summary
4
+
5
+ The worker's success responses gain an optional `"meta"` object:
6
+ `{"ok": [floats...], "meta": {"cached": true|false}}`. Nothing is removed and
7
+ no signature a user calls changes — the plugin exports no Python API, and its
8
+ CLI arguments, exit codes and vectors are all untouched. The one observable
9
+ difference is the extra key on the wire, and the progress line dirsql draws
10
+ from it.
11
+
12
+ #### Required changes
13
+
14
+ _None._
15
+
16
+ #### Deprecations removed
17
+
18
+ _None._
19
+
20
+ #### Behavior changes without code changes
21
+
22
+ - A worker `ok` line previously read `{"ok": [1.0, 0.0]}` and now reads
23
+ `{"ok": [1.0, 0.0], "meta": {"cached": false}}`. dirsql ignores unknown keys,
24
+ so this is additive for it; a third party parsing worker output with an
25
+ exact-equality check on the whole object will see a mismatch and should
26
+ compare `response["ok"]` instead.
27
+ - `{"ok": null}` for a NULL value is unchanged — no compute is attempted, so
28
+ there is no cache state to report.
29
+ - With the plugin installed, `dirsql` runs that hit the on-disk cache now show
30
+ the split in the progress line (`ran 41231 worker calls in 2m41s
31
+ (38104 cached)`). A run with no hits looks exactly as it did before.
32
+
33
+ #### Verification
34
+
35
+ ```bash
36
+ printf '{"call": ["hello"]}\n{"call": ["hello"]}\n' \
37
+ | uvx --from dirsql-plugin-embeddings dirsql-plugin-embeddings worker
38
+ # expected: first line "cached":false, second line "cached":true
39
+ ```
@@ -0,0 +1,7 @@
1
+ DEFAULT_MODEL_ID = "minishlab/potion-retrieval-32M"
2
+
3
+
4
+ def load_model(model_id):
5
+ from model2vec import StaticModel
6
+
7
+ return StaticModel.from_pretrained(model_id)
@@ -1,12 +1,3 @@
1
- DEFAULT_MODEL_ID = "minishlab/potion-retrieval-32M"
2
-
3
-
4
- def load_model(model_id):
5
- from model2vec import StaticModel
6
-
7
- return StaticModel.from_pretrained(model_id)
8
-
9
-
10
1
  def model_identifier(model_id, model):
11
2
  config = getattr(model, "config", None) or {}
12
3
  version = config.get("model2vec_version")
@@ -3,6 +3,7 @@ from hashlib import sha256
3
3
 
4
4
  from . import model
5
5
  from .cache import make_cache
6
+ from .model_identifier import model_identifier
6
7
  from .values import ProtocolError, decode_value
7
8
 
8
9
  MALFORMED_SHAPE = (
@@ -18,6 +19,7 @@ class Worker:
18
19
  self._cache = make_cache()
19
20
  self._compute_cached = self._cache.wrap(self._compute)
20
21
  self._pending = None
22
+ self._computed = False
21
23
 
22
24
  def _model(self, model_id):
23
25
  if model_id not in self._models:
@@ -25,6 +27,10 @@ class Worker:
25
27
  return self._models[model_id]
26
28
 
27
29
  def _compute(self, digest, identifier):
30
+ # cachetta's wrapper returns a hit and a miss identically, so the only
31
+ # place a miss is observable is here: _compute runs on a miss and never
32
+ # on a hit. embed() clears the flag and reads it back afterwards.
33
+ self._computed = True
28
34
  text, loaded = self._pending
29
35
  # No progress bar: the protocol embeds one value per round trip, so a
30
36
  # per-call bar can only ever say 1/1. The model *download* bar, which
@@ -33,11 +39,21 @@ class Worker:
33
39
  return [float(component) for component in vector]
34
40
 
35
41
  def embed(self, text, model_id):
42
+ """Return the vector for ``text``, and whether the cache served it."""
36
43
  loaded = self._model(model_id)
37
- identifier = model.model_identifier(model_id, loaded)
44
+ identifier = model_identifier(model_id, loaded)
38
45
  digest = sha256(text.encode("utf-8")).hexdigest()
39
46
  self._pending = (text, loaded)
40
- return self._compute_cached(digest, identifier)
47
+ self._computed = False
48
+ vector = self._compute_cached(digest, identifier)
49
+ return vector, not self._computed
50
+
51
+ def _embed_response(self, text, model_id):
52
+ try:
53
+ vector, cached = self.embed(text, model_id)
54
+ except Exception as error:
55
+ return {"err": f"embed({model_id!r}) failed: {error}"}
56
+ return {"ok": vector, "meta": {"cached": cached}}
41
57
 
42
58
  def handle(self, line):
43
59
  try:
@@ -59,10 +75,7 @@ class Worker:
59
75
  (model_id,) = rest or [model.DEFAULT_MODEL_ID]
60
76
  if not isinstance(model_id, str):
61
77
  return {"err": MALFORMED_MODEL_ID}
62
- try:
63
- return {"ok": self.embed(text, model_id)}
64
- except Exception as error:
65
- return {"err": f"embed({model_id!r}) failed: {error}"}
78
+ return self._embed_response(text, model_id)
66
79
 
67
80
  def serve(self, stdin, stdout):
68
81
  for line in stdin:
@@ -0,0 +1,18 @@
1
+ from .embed_call import embed_call
2
+ from .normalize_glob import normalize_glob
3
+ from .quote import quote
4
+
5
+
6
+ def build_search_sql(glob, query, limit, model=None):
7
+ outer = embed_call(quote(query), model)
8
+ inner = embed_call("content", model)
9
+ # A matched file that is unreadable, not valid UTF-8, or deleted mid-scan
10
+ # has NULL content, so its embedding is NULL too. Dropping those rows is
11
+ # what keeps the ranking honest: a NULL distance sorts FIRST ascending in
12
+ # SQLite, so unrankable files would otherwise take the top-k slots.
13
+ return (
14
+ f"SELECT path, vec_distance_cosine(emb, {outer}) AS distance"
15
+ f" FROM (SELECT path, {inner} AS emb FROM {quote(normalize_glob(glob))})"
16
+ f" WHERE emb IS NOT NULL"
17
+ f" ORDER BY distance LIMIT {int(limit):d}"
18
+ )
@@ -0,0 +1,6 @@
1
+ from .normalize_glob import normalize_glob
2
+ from .quote import quote
3
+
4
+
5
+ def count_corpus_sql(glob):
6
+ return f"SELECT COUNT(*) AS n FROM {quote(normalize_glob(glob))}"
@@ -0,0 +1,7 @@
1
+ from .quote import quote
2
+
3
+
4
+ def embed_call(argument, model):
5
+ if model is None:
6
+ return f"embed({argument})"
7
+ return f"embed({argument}, {quote(model)})"
@@ -0,0 +1,7 @@
1
+ def no_rows_message(glob, matched, root):
2
+ if not matched:
3
+ return f"no files matched {glob!r} (searched from {root})"
4
+ return (
5
+ f"{glob!r} matched {matched} file(s), but none had text content to"
6
+ f" embed -- unreadable or not valid UTF-8 (searched from {root})"
7
+ )
@@ -0,0 +1,11 @@
1
+ PATH_PREFIXES = ("./", "../", "/", "~/")
2
+
3
+
4
+ def normalize_glob(glob):
5
+ # The core only rescues path-shaped missing tables (./, ../, /, ~/); a
6
+ # bare relative glob like '**/*.md' would error with a "did you mean
7
+ # './...'" hint. Here GLOB is unambiguously a corpus glob, so spare the
8
+ # user the round trip.
9
+ if glob.startswith(PATH_PREFIXES):
10
+ return glob
11
+ return f"./{glob}"
@@ -0,0 +1,3 @@
1
+ def quote(text):
2
+ escaped = text.replace("'", "''")
3
+ return f"'{escaped}'"
@@ -0,0 +1,17 @@
1
+ import asyncio
2
+ import os
3
+
4
+ from .no_rows_message import no_rows_message
5
+ from .output import format_rows
6
+ from .search_rows import search_rows
7
+
8
+
9
+ class NothingToRank(Exception):
10
+ """The search produced no rows, and why -- so the CLI can say which."""
11
+
12
+
13
+ def run_search(glob, query, limit, model=None):
14
+ rows, matched = asyncio.run(search_rows(glob, query, limit, model))
15
+ if not rows:
16
+ raise NothingToRank(no_rows_message(glob, matched, os.getcwd()))
17
+ return format_rows(rows)
@@ -0,0 +1,23 @@
1
+ from importlib import resources
2
+
3
+ import dirsql
4
+
5
+ from .build_search_sql import build_search_sql
6
+ from .count_corpus_sql import count_corpus_sql
7
+
8
+
9
+ def config_fragment():
10
+ return str(resources.files("dirsql_plugin_embeddings").joinpath("dirsql.toml"))
11
+
12
+
13
+ async def search_rows(glob, query, limit, model):
14
+ db = dirsql.DirSQL(config=config_fragment())
15
+ rows = await db.query(build_search_sql(glob, query, limit, model))
16
+ if rows:
17
+ return rows, None
18
+ # Only now, on the error path, is the second scan worth its cost: it is
19
+ # what tells "no files matched" apart from "matched, none embeddable".
20
+ # One row, always: `SELECT COUNT(*)` cannot return anything else, and
21
+ # unpacking says so rather than trusting an index.
22
+ (counted,) = await db.query(count_corpus_sql(glob))
23
+ return rows, counted["n"]
@@ -2,6 +2,9 @@
2
2
  # package. Mirrors internals/checks: python coverage floor 100 + branch, zero
3
3
  # exemptions, and deliberately no `[e2e] extra_scope` -- this package compiles
4
4
  # none of packages/rust/src, so a core change never stales its attestation.
5
+ [python]
6
+ one_function_per_file = { max_lines = 10 }
7
+
5
8
  [python.coverage]
6
9
  fail_under = 100
7
10
  branch = true
@@ -1,45 +0,0 @@
1
- import asyncio
2
- import os
3
- from importlib import resources
4
-
5
- import dirsql
6
-
7
- from .output import format_rows
8
- from .sql import build_search_sql, count_corpus_sql
9
-
10
-
11
- class NothingToRank(Exception):
12
- """The search produced no rows, and why -- so the CLI can say which."""
13
-
14
-
15
- def config_fragment():
16
- return str(resources.files("dirsql_plugin_embeddings").joinpath("dirsql.toml"))
17
-
18
-
19
- def no_rows_message(glob, matched, root):
20
- if not matched:
21
- return f"no files matched {glob!r} (searched from {root})"
22
- return (
23
- f"{glob!r} matched {matched} file(s), but none had text content to"
24
- f" embed -- unreadable or not valid UTF-8 (searched from {root})"
25
- )
26
-
27
-
28
- async def _search(glob, query, limit, model):
29
- db = dirsql.DirSQL(config=config_fragment())
30
- rows = await db.query(build_search_sql(glob, query, limit, model))
31
- if rows:
32
- return rows, None
33
- # Only now, on the error path, is the second scan worth its cost: it is
34
- # what tells "no files matched" apart from "matched, none embeddable".
35
- # One row, always: `SELECT COUNT(*)` cannot return anything else, and
36
- # unpacking says so rather than trusting an index.
37
- (counted,) = await db.query(count_corpus_sql(glob))
38
- return rows, counted["n"]
39
-
40
-
41
- def run_search(glob, query, limit, model=None):
42
- rows, matched = asyncio.run(_search(glob, query, limit, model))
43
- if not rows:
44
- raise NothingToRank(no_rows_message(glob, matched, os.getcwd()))
45
- return format_rows(rows)
@@ -1,41 +0,0 @@
1
- PATH_PREFIXES = ("./", "../", "/", "~/")
2
-
3
-
4
- def quote(text):
5
- escaped = text.replace("'", "''")
6
- return f"'{escaped}'"
7
-
8
-
9
- def normalize_glob(glob):
10
- # The core only rescues path-shaped missing tables (./, ../, /, ~/); a
11
- # bare relative glob like '**/*.md' would error with a "did you mean
12
- # './...'" hint. Here GLOB is unambiguously a corpus glob, so spare the
13
- # user the round trip.
14
- if glob.startswith(PATH_PREFIXES):
15
- return glob
16
- return f"./{glob}"
17
-
18
-
19
- def embed_call(argument, model):
20
- if model is None:
21
- return f"embed({argument})"
22
- return f"embed({argument}, {quote(model)})"
23
-
24
-
25
- def build_search_sql(glob, query, limit, model=None):
26
- outer = embed_call(quote(query), model)
27
- inner = embed_call("content", model)
28
- # A matched file that is unreadable, not valid UTF-8, or deleted mid-scan
29
- # has NULL content, so its embedding is NULL too. Dropping those rows is
30
- # what keeps the ranking honest: a NULL distance sorts FIRST ascending in
31
- # SQLite, so unrankable files would otherwise take the top-k slots.
32
- return (
33
- f"SELECT path, vec_distance_cosine(emb, {outer}) AS distance"
34
- f" FROM (SELECT path, {inner} AS emb FROM {quote(normalize_glob(glob))})"
35
- f" WHERE emb IS NOT NULL"
36
- f" ORDER BY distance LIMIT {int(limit):d}"
37
- )
38
-
39
-
40
- def count_corpus_sql(glob):
41
- return f"SELECT COUNT(*) AS n FROM {quote(normalize_glob(glob))}"