tomefile 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.
Files changed (36) hide show
  1. tomefile-0.1.0/LICENSE +21 -0
  2. tomefile-0.1.0/MANIFEST.in +3 -0
  3. tomefile-0.1.0/PKG-INFO +44 -0
  4. tomefile-0.1.0/README.md +15 -0
  5. tomefile-0.1.0/pyproject.toml +53 -0
  6. tomefile-0.1.0/setup.cfg +4 -0
  7. tomefile-0.1.0/src/tomefile/__init__.py +3 -0
  8. tomefile-0.1.0/src/tomefile/archive.py +93 -0
  9. tomefile-0.1.0/src/tomefile/build.py +143 -0
  10. tomefile-0.1.0/src/tomefile/chunking.py +66 -0
  11. tomefile-0.1.0/src/tomefile/cli.py +261 -0
  12. tomefile-0.1.0/src/tomefile/database.py +347 -0
  13. tomefile-0.1.0/src/tomefile/errors.py +10 -0
  14. tomefile-0.1.0/src/tomefile/gateway.py +192 -0
  15. tomefile-0.1.0/src/tomefile/graph.py +66 -0
  16. tomefile-0.1.0/src/tomefile/hashing.py +20 -0
  17. tomefile-0.1.0/src/tomefile/inspecting.py +167 -0
  18. tomefile-0.1.0/src/tomefile/manifest.py +298 -0
  19. tomefile-0.1.0/src/tomefile/models.py +66 -0
  20. tomefile-0.1.0/src/tomefile/opening.py +154 -0
  21. tomefile-0.1.0/src/tomefile/pack.py +100 -0
  22. tomefile-0.1.0/src/tomefile/runtime.py +234 -0
  23. tomefile-0.1.0/src/tomefile/signing.py +96 -0
  24. tomefile-0.1.0/src/tomefile.egg-info/PKG-INFO +44 -0
  25. tomefile-0.1.0/src/tomefile.egg-info/SOURCES.txt +34 -0
  26. tomefile-0.1.0/src/tomefile.egg-info/dependency_links.txt +1 -0
  27. tomefile-0.1.0/src/tomefile.egg-info/entry_points.txt +2 -0
  28. tomefile-0.1.0/src/tomefile.egg-info/requires.txt +9 -0
  29. tomefile-0.1.0/src/tomefile.egg-info/top_level.txt +1 -0
  30. tomefile-0.1.0/tests/__init__.py +1 -0
  31. tomefile-0.1.0/tests/support.py +62 -0
  32. tomefile-0.1.0/tests/test_chunking.py +35 -0
  33. tomefile-0.1.0/tests/test_gateway.py +83 -0
  34. tomefile-0.1.0/tests/test_graph.py +169 -0
  35. tomefile-0.1.0/tests/test_inspect.py +89 -0
  36. tomefile-0.1.0/tests/test_package.py +219 -0
tomefile-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Simeon Emanuilov
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,3 @@
1
+ include LICENSE README.md pyproject.toml
2
+ graft tests
3
+ prune tests/__pycache__
@@ -0,0 +1,44 @@
1
+ Metadata-Version: 2.4
2
+ Name: tomefile
3
+ Version: 0.1.0
4
+ Summary: Local RAG as a single file
5
+ Author-email: Simeon Emanuilov <simeon.emanuilov@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://tomefile.com
8
+ Project-URL: Repository, https://github.com/s-emanuilov/tomefile
9
+ Keywords: rag,embeddings,sqlite,local
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Environment :: Console
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: typer>=0.12
21
+ Requires-Dist: fastapi>=0.115
22
+ Requires-Dist: uvicorn>=0.32
23
+ Requires-Dist: httpx>=0.27
24
+ Requires-Dist: sqlite-vec>=0.1.6
25
+ Requires-Dist: cryptography>=43
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=8; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # Tomefile
31
+
32
+ A local RAG index you pack **into one file**.
33
+
34
+ Fine-tuning a small model on your knowledgebase will not make it remember them. Retrieval will. Most RAG still calls a remote store, an embeddings API, and a set of tools. A `.tome` is the other shape: chunks, vectors, and the embedding model in one archive you open on `127.0.0.1`.
35
+
36
+ Build the index once. Hand someone the file.
37
+
38
+ - **`.tome`** — documents already chunked and embedded, plus the embedder (GGUF inside, or a SHA-256 reference). Checksums on every member. Optional Ed25519 signature.
39
+ - **`.tbox`** — the same file, with a chat model packed in or pinned by hash.
40
+ - **`tomefile serve`** — OpenAI-compatible `/v1/chat/completions` on the loopback interface. Answers cite source files and character offsets.
41
+
42
+ Chunk text is plaintext SQLite; this is not a way to hide a corpus. Vector search uses the embedder named in the file. A different model with the same vector size will retrieve the wrong neighbours.
43
+
44
+ Site: [tomefile.com](https://tomefile.com).
@@ -0,0 +1,15 @@
1
+ # Tomefile
2
+
3
+ A local RAG index you pack **into one file**.
4
+
5
+ Fine-tuning a small model on your knowledgebase will not make it remember them. Retrieval will. Most RAG still calls a remote store, an embeddings API, and a set of tools. A `.tome` is the other shape: chunks, vectors, and the embedding model in one archive you open on `127.0.0.1`.
6
+
7
+ Build the index once. Hand someone the file.
8
+
9
+ - **`.tome`** — documents already chunked and embedded, plus the embedder (GGUF inside, or a SHA-256 reference). Checksums on every member. Optional Ed25519 signature.
10
+ - **`.tbox`** — the same file, with a chat model packed in or pinned by hash.
11
+ - **`tomefile serve`** — OpenAI-compatible `/v1/chat/completions` on the loopback interface. Answers cite source files and character offsets.
12
+
13
+ Chunk text is plaintext SQLite; this is not a way to hide a corpus. Vector search uses the embedder named in the file. A different model with the same vector size will retrieve the wrong neighbours.
14
+
15
+ Site: [tomefile.com](https://tomefile.com).
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "tomefile"
3
+ dynamic = ["version"]
4
+ description = "Local RAG as a single file"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ authors = [
9
+ { name = "Simeon Emanuilov", email = "simeon.emanuilov@gmail.com" },
10
+ ]
11
+ requires-python = ">=3.10"
12
+ keywords = ["rag", "embeddings", "sqlite", "local"]
13
+ classifiers = [
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.10",
16
+ "Programming Language :: Python :: 3.11",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Operating System :: OS Independent",
19
+ "Environment :: Console",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ ]
22
+ dependencies = [
23
+ "typer>=0.12",
24
+ "fastapi>=0.115",
25
+ "uvicorn>=0.32",
26
+ "httpx>=0.27",
27
+ "sqlite-vec>=0.1.6",
28
+ "cryptography>=43",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = ["pytest>=8"]
33
+
34
+ [project.urls]
35
+ Homepage = "https://tomefile.com"
36
+ Repository = "https://github.com/s-emanuilov/tomefile"
37
+
38
+ [project.scripts]
39
+ tomefile = "tomefile.cli:app"
40
+
41
+ [build-system]
42
+ requires = ["setuptools>=77"]
43
+ build-backend = "setuptools.build_meta"
44
+
45
+ [tool.setuptools.dynamic]
46
+ version = { attr = "tomefile.__version__" }
47
+
48
+ [tool.setuptools.packages.find]
49
+ where = ["src"]
50
+
51
+ [tool.pytest.ini_options]
52
+ testpaths = ["tests"]
53
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Local RAG as a single file."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ import zipfile
4
+ from pathlib import Path
5
+
6
+ from tomefile.errors import TomeError
7
+ from tomefile.hashing import sha256_bytes, sha256_file
8
+ from tomefile.manifest import SHA_RE
9
+
10
+ CHECKSUMS = "checksums.sha256"
11
+ SIGNATURE = "signature.json"
12
+ EXCLUDED = {CHECKSUMS, SIGNATURE}
13
+
14
+
15
+ def checksum_document(members: dict[str, bytes]) -> bytes:
16
+ lines: list[str] = []
17
+ names = sorted((name for name in members if name not in EXCLUDED), key=lambda name: name.encode("utf-8"))
18
+ for name in names:
19
+ lines.append(f"{sha256_bytes(members[name])} {name}")
20
+ return ("\n".join(lines) + "\n").encode("utf-8")
21
+
22
+
23
+ def parse_checksums(raw: bytes) -> dict[str, str]:
24
+ try:
25
+ text = raw.decode("utf-8")
26
+ except UnicodeDecodeError as exc:
27
+ raise TomeError("checksums.sha256 is not UTF-8") from exc
28
+ listed: dict[str, str] = {}
29
+ for number, line in enumerate(text.splitlines(), start=1):
30
+ if line == "":
31
+ continue
32
+ digest, separator, name = line.partition(" ") # sha256sum: digest, two spaces, name
33
+ if separator != " " or SHA_RE.fullmatch(digest) is None or not _safe_name(name):
34
+ raise TomeError(f"checksums.sha256 line {number} is malformed")
35
+ if name in listed:
36
+ raise TomeError(f"checksums.sha256 lists {name} twice")
37
+ listed[name] = digest
38
+ return listed
39
+
40
+
41
+ def verify_checksums(members: dict[str, bytes]) -> bytes:
42
+ raw = members.get(CHECKSUMS)
43
+ if raw is None:
44
+ raise TomeError("checksums.sha256 is missing")
45
+ listed = parse_checksums(raw)
46
+ payload = {name: data for name, data in members.items() if name not in EXCLUDED}
47
+ if set(listed) != set(payload):
48
+ raise TomeError("checksums.sha256 does not match the archive members")
49
+ for name, digest in listed.items():
50
+ if sha256_bytes(payload[name]) != digest:
51
+ raise TomeError(f"checksum mismatch for {name}")
52
+ return raw
53
+
54
+
55
+ def write_archive(path: Path, members: dict[str, bytes]) -> None:
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ with zipfile.ZipFile(path, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
58
+ for name in sorted(members, key=lambda item: item.encode("utf-8")):
59
+ info = zipfile.ZipInfo(filename=name, date_time=(1980, 1, 1, 0, 0, 0)) # stable hashes
60
+ info.compress_type = zipfile.ZIP_STORED
61
+ info.flag_bits |= 0x800 # UTF-8 names
62
+ info.external_attr = 0o644 << 16
63
+ archive.writestr(info, members[name])
64
+
65
+
66
+ def read_archive(path: Path) -> tuple[str, dict[str, bytes]]:
67
+ digest = sha256_file(path)
68
+ try:
69
+ archive = zipfile.ZipFile(path)
70
+ except zipfile.BadZipFile as exc:
71
+ raise TomeError(f"{path} is not a ZIP archive") from exc
72
+ members: dict[str, bytes] = {}
73
+ with archive:
74
+ for info in archive.infolist():
75
+ if info.is_dir():
76
+ continue
77
+ if info.compress_type != zipfile.ZIP_STORED:
78
+ raise TomeError(f"{info.filename} is compressed; members must use store method")
79
+ if not _safe_name(info.filename):
80
+ raise TomeError(f"unsafe member {info.filename}")
81
+ if info.filename in members:
82
+ raise TomeError(f"duplicate member {info.filename}")
83
+ members[info.filename] = archive.read(info)
84
+ if not members:
85
+ raise TomeError(f"{path} is empty")
86
+ return digest, members
87
+
88
+
89
+ def _safe_name(name: str) -> bool:
90
+ if name == "" or name.startswith("/") or name.endswith("/"):
91
+ return False
92
+ parts = name.split("/")
93
+ return ".." not in parts and "." not in parts and "" not in parts
@@ -0,0 +1,143 @@
1
+ from __future__ import annotations
2
+
3
+ import tempfile
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+
7
+ from tomefile import __version__
8
+ from tomefile.archive import CHECKSUMS, SIGNATURE, checksum_document, write_archive
9
+ from tomefile.chunking import Chunk, chunk_documents, discover_documents
10
+ from tomefile.database import create_database
11
+ from tomefile.errors import TomeError
12
+ from tomefile.hashing import sha256_file
13
+ from tomefile.manifest import (
14
+ CHUNK_SIZE,
15
+ TOP_K,
16
+ LicenseGrant,
17
+ Manifest,
18
+ ModelRef,
19
+ default_name,
20
+ default_signer,
21
+ require_id,
22
+ )
23
+ from tomefile.models import cache_model_file, cached_model
24
+ from tomefile.signing import sign_checksums
25
+
26
+
27
+ def build_tome(
28
+ input_dir: Path,
29
+ output: Path,
30
+ *,
31
+ package_id: str,
32
+ revision: int,
33
+ model: Path,
34
+ embed_id: str,
35
+ embed_license_name: str,
36
+ embed: Callable[[str], list[float]],
37
+ name: str | None = None,
38
+ knowledge_license: str = "internal",
39
+ knowledge_commercial: bool = False,
40
+ knowledge_redistribute: bool = False,
41
+ attribution: bool = True,
42
+ redistribute_embed: bool = True,
43
+ embed_model: bool = False,
44
+ recommended_llm: str = "",
45
+ sign_key: Path | None = None,
46
+ signer: str | None = None,
47
+ cache_root: Path,
48
+ ) -> None:
49
+ package_id = require_id(package_id, "id")
50
+ if revision < 1:
51
+ raise TomeError("revision must be an integer >= 1")
52
+ if output.suffix.lower() != ".tome":
53
+ raise TomeError("output must end in .tome")
54
+ if not embed_id:
55
+ raise TomeError("--embed-id must not be empty")
56
+ if not embed_license_name:
57
+ raise TomeError("--embed-license must not be empty")
58
+ if embed_model and not redistribute_embed:
59
+ raise TomeError("--embed-model requires a redistributable embedding license")
60
+ documents = discover_documents(input_dir)
61
+ chunks = chunk_documents(documents, CHUNK_SIZE)
62
+ if not chunks:
63
+ raise TomeError("no text to index")
64
+ vectors = [_embed_chunk(embed, chunk) for chunk in chunks]
65
+ dimension = len(vectors[0])
66
+ digest = sha256_file(model)
67
+ already_cached = cached_model(cache_root, digest)
68
+ if not redistribute_embed and already_cached is None:
69
+ raise TomeError(
70
+ f"embedding model is not redistributable and {digest} is not in the model cache"
71
+ )
72
+ include_model = redistribute_embed and (embed_model or already_cached is None)
73
+ if already_cached is None and redistribute_embed:
74
+ cache_model_file(model, cache_root)
75
+ manifest = Manifest(
76
+ type="data_tome",
77
+ id=package_id,
78
+ revision=revision,
79
+ name=name or default_name(package_id),
80
+ embed="models/embed.gguf" if include_model else ModelRef(digest, model.name),
81
+ chat=None,
82
+ embedding_id=embed_id,
83
+ embedding_sha256=digest,
84
+ dimensions=dimension,
85
+ recommended_llm=recommended_llm,
86
+ builder="tomefile",
87
+ builder_version=__version__,
88
+ chunker="fixed-size",
89
+ knowledge=LicenseGrant(
90
+ knowledge_license or "internal",
91
+ knowledge_commercial,
92
+ knowledge_redistribute,
93
+ attribution,
94
+ ),
95
+ embed_license=LicenseGrant(embed_license_name, True, redistribute_embed, attribution),
96
+ chat_license=None,
97
+ chunk_size=CHUNK_SIZE,
98
+ retrieve_top_k=TOP_K,
99
+ )
100
+ with tempfile.TemporaryDirectory() as temporary:
101
+ database_path = Path(temporary) / "vectors.db"
102
+ create_database(
103
+ database_path,
104
+ chunks,
105
+ vectors,
106
+ {
107
+ "package_id": package_id,
108
+ "revision": str(revision),
109
+ "metric": "cosine",
110
+ "embedding_id": embed_id,
111
+ "embedding_sha256": digest,
112
+ },
113
+ )
114
+ members: dict[str, bytes] = {
115
+ "manifest.json": manifest.dumps(),
116
+ "knowledge/vectors.db": database_path.read_bytes(),
117
+ }
118
+ if include_model:
119
+ members["models/embed.gguf"] = model.read_bytes()
120
+ _finish_archive(output, members, sign_key, signer or default_signer(package_id))
121
+
122
+
123
+ def _embed_chunk(embed: Callable[[str], list[float]], chunk: Chunk) -> list[float]:
124
+ try:
125
+ vector = embed(chunk.text)
126
+ except Exception as exc:
127
+ raise TomeError(f"embedder rejected {chunk.source}: {exc}") from exc
128
+ if not vector:
129
+ raise TomeError(f"embedder rejected {chunk.source}: empty vector")
130
+ return [float(value) for value in vector]
131
+
132
+
133
+ def _finish_archive(
134
+ output: Path,
135
+ members: dict[str, bytes],
136
+ sign_key: Path | None,
137
+ signer: str,
138
+ ) -> None:
139
+ checksums = checksum_document(members)
140
+ members[CHECKSUMS] = checksums
141
+ if sign_key is not None:
142
+ members[SIGNATURE] = sign_checksums(checksums, sign_key, signer)
143
+ write_archive(output, members)
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from tomefile.errors import TomeError
7
+
8
+ TEXT_SUFFIXES = {".txt", ".md"}
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Chunk:
13
+ id: int
14
+ text: str
15
+ source: str
16
+ start: int
17
+ end: int
18
+
19
+
20
+ def discover_documents(input_dir: Path) -> list[tuple[str, str]]:
21
+ found: list[tuple[str, str]] = []
22
+ for path in input_dir.rglob("*"):
23
+ if not path.is_file() or path.suffix.lower() not in TEXT_SUFFIXES:
24
+ continue
25
+ relative = path.relative_to(input_dir).as_posix()
26
+ try:
27
+ text = path.read_text(encoding="utf-8")
28
+ except UnicodeDecodeError as exc:
29
+ raise TomeError(f"{relative} is not valid UTF-8") from exc
30
+ found.append((relative, text))
31
+ found.sort(key=lambda item: item[0].encode("utf-8"))
32
+ return found
33
+
34
+
35
+ def chunk_text(text: str, size: int) -> list[tuple[int, int, str]]:
36
+ if size < 1:
37
+ raise TomeError("chunk size must be positive")
38
+ pieces: list[tuple[int, int, str]] = []
39
+ index = 0
40
+ length = len(text)
41
+ while index < length:
42
+ end = min(index + size, length)
43
+ if end < length:
44
+ # last whitespace in the window; keep it in this chunk
45
+ window = text[index:end]
46
+ cut = -1
47
+ for offset, char in enumerate(window):
48
+ if char.isspace():
49
+ cut = offset
50
+ if cut >= 0:
51
+ end = index + cut + 1
52
+ if end <= index:
53
+ end = index + 1
54
+ pieces.append((index, end, text[index:end]))
55
+ index = end
56
+ return pieces
57
+
58
+
59
+ def chunk_documents(documents: list[tuple[str, str]], size: int) -> list[Chunk]:
60
+ chunks: list[Chunk] = []
61
+ next_id = 1
62
+ for source, text in documents:
63
+ for start, end, piece in chunk_text(text, size):
64
+ chunks.append(Chunk(next_id, piece, source, start, end))
65
+ next_id += 1
66
+ return chunks