coffloader 0.1.0__py3-none-any.whl

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.
coffloader/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """coffloader — External memory for AI agents."""
2
+
3
+ from .backends import CompositeBackend, LocalBackend, MemoryBackend
4
+ from .store import Coffloader
5
+ from .toc import InspectResult, TocEntry, WriteResult
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = [
10
+ "Coffloader",
11
+ "TocEntry",
12
+ "WriteResult",
13
+ "InspectResult",
14
+ "CompositeBackend",
15
+ "LocalBackend",
16
+ "MemoryBackend",
17
+ ]
@@ -0,0 +1,8 @@
1
+ """Storage backends for coffloader."""
2
+
3
+ from .base import BackendProtocol
4
+ from .composite import CompositeBackend
5
+ from .local import LocalBackend
6
+ from .memory import MemoryBackend
7
+
8
+ __all__ = ["BackendProtocol", "CompositeBackend", "LocalBackend", "MemoryBackend"]
@@ -0,0 +1,23 @@
1
+ """Backend protocol for VFS storage."""
2
+
3
+ from typing import Protocol
4
+
5
+
6
+ class BackendProtocol(Protocol):
7
+ """Interface for storage backends."""
8
+
9
+ def write(self, path: str, data: bytes) -> None:
10
+ """Store data at the given path."""
11
+ ...
12
+
13
+ def read(self, path: str) -> bytes:
14
+ """Read data from the given path. Raises KeyError if not found."""
15
+ ...
16
+
17
+ def delete(self, path: str) -> bool:
18
+ """Delete data at the given path. Returns True if deleted, False if not found."""
19
+ ...
20
+
21
+ def exists(self, path: str) -> bool:
22
+ """Check if path exists."""
23
+ ...
@@ -0,0 +1,41 @@
1
+ """Composite backend that routes by path prefix."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .base import BackendProtocol
6
+ from .memory import MemoryBackend
7
+
8
+
9
+ class CompositeBackend:
10
+ """Route paths to different backends based on prefix.
11
+
12
+ Longest matching prefix wins. Unmatched paths go to the default backend.
13
+ """
14
+
15
+ def __init__(
16
+ self,
17
+ default: BackendProtocol | None = None,
18
+ routes: dict[str, BackendProtocol] | None = None,
19
+ ) -> None:
20
+ self._default: BackendProtocol = default or MemoryBackend()
21
+ self._routes = routes or {}
22
+ # Sort routes by length descending for longest-prefix matching
23
+ self._sorted_prefixes = sorted(self._routes.keys(), key=len, reverse=True)
24
+
25
+ def _get_backend(self, path: str) -> BackendProtocol:
26
+ for prefix in self._sorted_prefixes:
27
+ if path.startswith(prefix):
28
+ return self._routes[prefix]
29
+ return self._default
30
+
31
+ def write(self, path: str, data: bytes) -> None:
32
+ self._get_backend(path).write(path, data)
33
+
34
+ def read(self, path: str) -> bytes:
35
+ return self._get_backend(path).read(path)
36
+
37
+ def delete(self, path: str) -> bool:
38
+ return self._get_backend(path).delete(path)
39
+
40
+ def exists(self, path: str) -> bool:
41
+ return self._get_backend(path).exists(path)
@@ -0,0 +1,37 @@
1
+ """Local filesystem storage backend."""
2
+
3
+ from pathlib import Path
4
+
5
+
6
+ class LocalBackend:
7
+ """Store blobs on local disk under a root directory."""
8
+
9
+ def __init__(self, root: str | Path) -> None:
10
+ self._root = Path(root).resolve()
11
+ self._root.mkdir(parents=True, exist_ok=True)
12
+
13
+ def _resolve(self, path: str) -> Path:
14
+ # Strip leading slash for joining
15
+ relative = path.lstrip("/")
16
+ return self._root / relative
17
+
18
+ def write(self, path: str, data: bytes) -> None:
19
+ file_path = self._resolve(path)
20
+ file_path.parent.mkdir(parents=True, exist_ok=True)
21
+ file_path.write_bytes(data)
22
+
23
+ def read(self, path: str) -> bytes:
24
+ file_path = self._resolve(path)
25
+ if not file_path.exists():
26
+ raise KeyError(f"Path not found: {path}")
27
+ return file_path.read_bytes()
28
+
29
+ def delete(self, path: str) -> bool:
30
+ file_path = self._resolve(path)
31
+ if file_path.exists():
32
+ file_path.unlink()
33
+ return True
34
+ return False
35
+
36
+ def exists(self, path: str) -> bool:
37
+ return self._resolve(path).exists()
@@ -0,0 +1,25 @@
1
+ """In-memory storage backend."""
2
+
3
+
4
+ class MemoryBackend:
5
+ """Store blobs in a Python dict. Data lost on process exit."""
6
+
7
+ def __init__(self) -> None:
8
+ self._store: dict[str, bytes] = {}
9
+
10
+ def write(self, path: str, data: bytes) -> None:
11
+ self._store[path] = data
12
+
13
+ def read(self, path: str) -> bytes:
14
+ if path not in self._store:
15
+ raise KeyError(f"Path not found: {path}")
16
+ return self._store[path]
17
+
18
+ def delete(self, path: str) -> bool:
19
+ if path in self._store:
20
+ del self._store[path]
21
+ return True
22
+ return False
23
+
24
+ def exists(self, path: str) -> bool:
25
+ return path in self._store
@@ -0,0 +1,15 @@
1
+ """Index implementations for TOC search."""
2
+
3
+ from .fts import FTSIndex
4
+
5
+ # Optional imports for embedding-based search
6
+ try:
7
+ from .embeddings import EmbeddingIndex
8
+ from .hybrid import HybridIndex
9
+ EMBEDDINGS_AVAILABLE = True
10
+ except ImportError:
11
+ EMBEDDINGS_AVAILABLE = False
12
+ EmbeddingIndex = None # type: ignore
13
+ HybridIndex = None # type: ignore
14
+
15
+ __all__ = ["FTSIndex", "EmbeddingIndex", "HybridIndex", "EMBEDDINGS_AVAILABLE"]
@@ -0,0 +1,181 @@
1
+ """Embedding-based semantic search using sentence-transformers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from ..toc import TocEntry
12
+
13
+ # Lazy load to avoid import cost when not using embeddings
14
+ _model = None
15
+
16
+
17
+ def _get_model():
18
+ """Lazy load the embedding model."""
19
+ global _model
20
+ if _model is None:
21
+ from sentence_transformers import SentenceTransformer
22
+ _model = SentenceTransformer("all-MiniLM-L6-v2")
23
+ return _model
24
+
25
+
26
+ def embed_text(text: str) -> np.ndarray:
27
+ """Embed text into a vector (float32)."""
28
+ model = _get_model()
29
+ embedding = model.encode(text, convert_to_numpy=True)
30
+ return embedding.astype(np.float32)
31
+
32
+
33
+ def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
34
+ """Compute cosine similarity between two vectors."""
35
+ return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))
36
+
37
+
38
+ class EmbeddingIndex:
39
+ """Semantic search index using embeddings + cosine similarity."""
40
+
41
+ def __init__(self, db_path: str = ":memory:", min_similarity: float = 0.3) -> None:
42
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
43
+ self._conn.row_factory = sqlite3.Row
44
+ self._min_similarity = min_similarity
45
+ self._init_schema()
46
+
47
+ def _init_schema(self) -> None:
48
+ cur = self._conn.cursor()
49
+ cur.execute("""
50
+ CREATE TABLE IF NOT EXISTS toc_embeddings (
51
+ id TEXT PRIMARY KEY,
52
+ address TEXT NOT NULL,
53
+ summary TEXT NOT NULL,
54
+ metadata TEXT NOT NULL,
55
+ content_hash TEXT,
56
+ byte_count INTEGER,
57
+ created_at TEXT,
58
+ excluded INTEGER DEFAULT 0,
59
+ embedding BLOB
60
+ )
61
+ """)
62
+ self._conn.commit()
63
+
64
+ def add(self, entry: TocEntry) -> None:
65
+ """Add or update a TOC entry with its embedding."""
66
+ cur = self._conn.cursor()
67
+ metadata_json = json.dumps(entry.metadata)
68
+
69
+ # Embed the summary
70
+ embedding = embed_text(entry.summary)
71
+ embedding_blob = embedding.tobytes()
72
+
73
+ cur.execute(
74
+ """
75
+ INSERT OR REPLACE INTO toc_embeddings
76
+ (id, address, summary, metadata, content_hash, byte_count, created_at, excluded, embedding)
77
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
78
+ """,
79
+ (
80
+ entry.id,
81
+ entry.address,
82
+ entry.summary,
83
+ metadata_json,
84
+ entry.content_hash,
85
+ entry.byte_count,
86
+ entry.created_at,
87
+ 1 if entry.excluded else 0,
88
+ embedding_blob,
89
+ ),
90
+ )
91
+ self._conn.commit()
92
+
93
+ def remove(self, entry_id: str) -> bool:
94
+ """Remove a TOC entry by ID."""
95
+ cur = self._conn.cursor()
96
+ cur.execute("DELETE FROM toc_embeddings WHERE id = ?", (entry_id,))
97
+ self._conn.commit()
98
+ return cur.rowcount > 0
99
+
100
+ def get(self, entry_id: str) -> TocEntry | None:
101
+ """Get a TOC entry by ID."""
102
+ cur = self._conn.cursor()
103
+ cur.execute("SELECT * FROM toc_embeddings WHERE id = ?", (entry_id,))
104
+ row = cur.fetchone()
105
+ if row is None:
106
+ return None
107
+ return self._row_to_entry(row)
108
+
109
+ def get_by_address(self, address: str) -> TocEntry | None:
110
+ """Get a TOC entry by address."""
111
+ cur = self._conn.cursor()
112
+ cur.execute("SELECT * FROM toc_embeddings WHERE address = ?", (address,))
113
+ row = cur.fetchone()
114
+ if row is None:
115
+ return None
116
+ return self._row_to_entry(row)
117
+
118
+ def search(
119
+ self,
120
+ query: str,
121
+ k: int = 5,
122
+ filters: dict[str, Any] | None = None,
123
+ namespace: str | None = None,
124
+ ) -> list[TocEntry]:
125
+ """Search using cosine similarity on embeddings."""
126
+ cur = self._conn.cursor()
127
+
128
+ # Build filter SQL
129
+ where_clauses = []
130
+ params: list[Any] = []
131
+
132
+ if namespace:
133
+ where_clauses.append("address LIKE ?")
134
+ params.append(f"{namespace}%")
135
+
136
+ if filters:
137
+ for key, value in filters.items():
138
+ where_clauses.append(f"json_extract(metadata, '$.{key}') = ?")
139
+ params.append(value)
140
+
141
+ where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
142
+
143
+ # Get all matching entries
144
+ sql = f"SELECT * FROM toc_embeddings WHERE {where_sql}"
145
+ cur.execute(sql, params)
146
+ rows = cur.fetchall()
147
+
148
+ if not rows:
149
+ return []
150
+
151
+ # Embed query and compute similarities
152
+ query_embedding = embed_text(query)
153
+
154
+ scored = []
155
+ for row in rows:
156
+ embedding = np.frombuffer(row["embedding"], dtype=np.float32)
157
+ score = cosine_similarity(query_embedding, embedding)
158
+ # Filter out low-similarity results
159
+ if score >= self._min_similarity:
160
+ scored.append((score, row))
161
+
162
+ # Sort by score descending, take top k
163
+ scored.sort(key=lambda x: x[0], reverse=True)
164
+
165
+ return [self._row_to_entry(row) for _, row in scored[:k]]
166
+
167
+ def _row_to_entry(self, row: sqlite3.Row) -> TocEntry:
168
+ return TocEntry(
169
+ id=row["id"],
170
+ address=row["address"],
171
+ summary=row["summary"],
172
+ metadata=json.loads(row["metadata"]),
173
+ content_hash=row["content_hash"] or "",
174
+ byte_count=row["byte_count"] or 0,
175
+ created_at=row["created_at"] or "",
176
+ excluded=bool(row["excluded"]),
177
+ )
178
+
179
+ def close(self) -> None:
180
+ """Close the database connection."""
181
+ self._conn.close()
@@ -0,0 +1,218 @@
1
+ """SQLite FTS5 index for TOC search."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ from typing import Any
8
+
9
+ from ..toc import TocEntry
10
+
11
+ # FTS5 special characters that need escaping or removal
12
+ FTS5_SPECIAL = set('"*?:^(){}[]\'')
13
+
14
+
15
+ def _sanitize_fts_query(query: str) -> str:
16
+ """Remove FTS5 special characters from query."""
17
+ return "".join(c if c not in FTS5_SPECIAL else " " for c in query)
18
+
19
+
20
+ class FTSIndex:
21
+ """Full-text search index using SQLite FTS5.
22
+
23
+ Indexes summary and serialized metadata for BM25 ranking.
24
+ """
25
+
26
+ def __init__(self, db_path: str = ":memory:") -> None:
27
+ self._conn = sqlite3.connect(db_path, check_same_thread=False)
28
+ self._conn.row_factory = sqlite3.Row
29
+ self._init_schema()
30
+
31
+ def _init_schema(self) -> None:
32
+ cur = self._conn.cursor()
33
+ # Main table for TOC entries
34
+ cur.execute("""
35
+ CREATE TABLE IF NOT EXISTS toc (
36
+ id TEXT PRIMARY KEY,
37
+ address TEXT NOT NULL,
38
+ summary TEXT NOT NULL,
39
+ metadata TEXT NOT NULL,
40
+ content_hash TEXT,
41
+ byte_count INTEGER,
42
+ created_at TEXT,
43
+ excluded INTEGER DEFAULT 0
44
+ )
45
+ """)
46
+ # Standalone FTS5 table (not external content)
47
+ cur.execute("""
48
+ CREATE VIRTUAL TABLE IF NOT EXISTS toc_fts USING fts5(
49
+ id,
50
+ summary,
51
+ metadata_text
52
+ )
53
+ """)
54
+ self._conn.commit()
55
+
56
+ def add(self, entry: TocEntry) -> None:
57
+ """Add or update a TOC entry in the index."""
58
+ cur = self._conn.cursor()
59
+ metadata_json = json.dumps(entry.metadata)
60
+
61
+ # Check if exists for update vs insert
62
+ cur.execute("SELECT id FROM toc WHERE id = ?", (entry.id,))
63
+ exists = cur.fetchone() is not None
64
+
65
+ if exists:
66
+ # Update toc
67
+ cur.execute(
68
+ """
69
+ UPDATE toc SET address=?, summary=?, metadata=?, content_hash=?,
70
+ byte_count=?, created_at=?, excluded=?
71
+ WHERE id=?
72
+ """,
73
+ (
74
+ entry.address,
75
+ entry.summary,
76
+ metadata_json,
77
+ entry.content_hash,
78
+ entry.byte_count,
79
+ entry.created_at,
80
+ 1 if entry.excluded else 0,
81
+ entry.id,
82
+ ),
83
+ )
84
+ # Update FTS
85
+ cur.execute("DELETE FROM toc_fts WHERE id = ?", (entry.id,))
86
+ cur.execute(
87
+ "INSERT INTO toc_fts (id, summary, metadata_text) VALUES (?, ?, ?)",
88
+ (entry.id, entry.summary, metadata_json),
89
+ )
90
+ else:
91
+ # Insert into toc
92
+ cur.execute(
93
+ """
94
+ INSERT INTO toc
95
+ (id, address, summary, metadata, content_hash, byte_count, created_at, excluded)
96
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
97
+ """,
98
+ (
99
+ entry.id,
100
+ entry.address,
101
+ entry.summary,
102
+ metadata_json,
103
+ entry.content_hash,
104
+ entry.byte_count,
105
+ entry.created_at,
106
+ 1 if entry.excluded else 0,
107
+ ),
108
+ )
109
+ # Insert into FTS
110
+ cur.execute(
111
+ "INSERT INTO toc_fts (id, summary, metadata_text) VALUES (?, ?, ?)",
112
+ (entry.id, entry.summary, metadata_json),
113
+ )
114
+
115
+ self._conn.commit()
116
+
117
+ def remove(self, entry_id: str) -> bool:
118
+ """Remove a TOC entry by ID. Returns True if deleted."""
119
+ cur = self._conn.cursor()
120
+ cur.execute("DELETE FROM toc WHERE id = ?", (entry_id,))
121
+ cur.execute("DELETE FROM toc_fts WHERE id = ?", (entry_id,))
122
+ self._conn.commit()
123
+ return cur.rowcount > 0
124
+
125
+ def get(self, entry_id: str) -> TocEntry | None:
126
+ """Get a TOC entry by ID."""
127
+ cur = self._conn.cursor()
128
+ cur.execute("SELECT * FROM toc WHERE id = ?", (entry_id,))
129
+ row = cur.fetchone()
130
+ if row is None:
131
+ return None
132
+ return self._row_to_entry(row)
133
+
134
+ def get_by_address(self, address: str) -> TocEntry | None:
135
+ """Get a TOC entry by address."""
136
+ cur = self._conn.cursor()
137
+ cur.execute("SELECT * FROM toc WHERE address = ?", (address,))
138
+ row = cur.fetchone()
139
+ if row is None:
140
+ return None
141
+ return self._row_to_entry(row)
142
+
143
+ def search(
144
+ self,
145
+ query: str,
146
+ k: int = 5,
147
+ filters: dict[str, Any] | None = None,
148
+ namespace: str | None = None,
149
+ ) -> list[TocEntry]:
150
+ """Search TOC entries using FTS5 BM25 ranking.
151
+
152
+ Args:
153
+ query: Search query string
154
+ k: Max results to return
155
+ filters: Metadata field filters (exact match)
156
+ namespace: Filter by address prefix
157
+ """
158
+ cur = self._conn.cursor()
159
+
160
+ # Sanitize query for FTS5
161
+ safe_query = _sanitize_fts_query(query.strip())
162
+
163
+ if safe_query:
164
+ # FTS search with BM25 ranking
165
+ base_sql = """
166
+ SELECT toc.* FROM toc
167
+ INNER JOIN toc_fts ON toc.id = toc_fts.id
168
+ WHERE toc_fts MATCH ?
169
+ """
170
+ params: list[Any] = [safe_query]
171
+
172
+ if namespace:
173
+ base_sql += " AND toc.address LIKE ?"
174
+ params.append(f"{namespace}%")
175
+
176
+ if filters:
177
+ for key, value in filters.items():
178
+ base_sql += f" AND json_extract(toc.metadata, '$.{key}') = ?"
179
+ params.append(value)
180
+
181
+ base_sql += " ORDER BY bm25(toc_fts) LIMIT ?"
182
+ params.append(k)
183
+
184
+ else:
185
+ # No query, just filter and sort by recency
186
+ base_sql = "SELECT * FROM toc WHERE 1=1"
187
+ params = []
188
+
189
+ if namespace:
190
+ base_sql += " AND address LIKE ?"
191
+ params.append(f"{namespace}%")
192
+
193
+ if filters:
194
+ for key, value in filters.items():
195
+ base_sql += f" AND json_extract(metadata, '$.{key}') = ?"
196
+ params.append(value)
197
+
198
+ base_sql += " ORDER BY created_at DESC LIMIT ?"
199
+ params.append(k)
200
+
201
+ cur.execute(base_sql, params)
202
+ return [self._row_to_entry(row) for row in cur.fetchall()]
203
+
204
+ def _row_to_entry(self, row: sqlite3.Row) -> TocEntry:
205
+ return TocEntry(
206
+ id=row["id"],
207
+ address=row["address"],
208
+ summary=row["summary"],
209
+ metadata=json.loads(row["metadata"]),
210
+ content_hash=row["content_hash"] or "",
211
+ byte_count=row["byte_count"] or 0,
212
+ created_at=row["created_at"] or "",
213
+ excluded=bool(row["excluded"]),
214
+ )
215
+
216
+ def close(self) -> None:
217
+ """Close the database connection."""
218
+ self._conn.close()
@@ -0,0 +1,80 @@
1
+ """Hybrid search combining BM25 and embeddings via Reciprocal Rank Fusion."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from ..toc import TocEntry
8
+ from .fts import FTSIndex
9
+ from .embeddings import EmbeddingIndex
10
+
11
+
12
+ class HybridIndex:
13
+ """Combines FTS5/BM25 and embedding search using RRF.
14
+
15
+ Reciprocal Rank Fusion merges ranked lists without needing
16
+ normalized scores. Formula: RRF(d) = Σ 1/(k + rank(d))
17
+ """
18
+
19
+ def __init__(self, db_path: str = ":memory:", rrf_k: int = 60, min_similarity: float = 0.3) -> None:
20
+ self._fts = FTSIndex(db_path=db_path)
21
+ self._embed = EmbeddingIndex(db_path=db_path, min_similarity=min_similarity)
22
+ self._rrf_k = rrf_k # RRF constant (typically 60)
23
+
24
+ def add(self, entry: TocEntry) -> None:
25
+ """Add entry to both indexes."""
26
+ self._fts.add(entry)
27
+ self._embed.add(entry)
28
+
29
+ def remove(self, entry_id: str) -> bool:
30
+ """Remove entry from both indexes."""
31
+ fts_removed = self._fts.remove(entry_id)
32
+ embed_removed = self._embed.remove(entry_id)
33
+ return fts_removed or embed_removed
34
+
35
+ def get(self, entry_id: str) -> TocEntry | None:
36
+ """Get entry by ID."""
37
+ return self._fts.get(entry_id)
38
+
39
+ def get_by_address(self, address: str) -> TocEntry | None:
40
+ """Get entry by address."""
41
+ return self._fts.get_by_address(address)
42
+
43
+ def search(
44
+ self,
45
+ query: str,
46
+ k: int = 5,
47
+ filters: dict[str, Any] | None = None,
48
+ namespace: str | None = None,
49
+ ) -> list[TocEntry]:
50
+ """Search using RRF fusion of BM25 and embedding results."""
51
+ # Get more candidates from each to allow good fusion
52
+ n_candidates = k * 3
53
+
54
+ # BM25 results
55
+ fts_results = self._fts.search(query, k=n_candidates, filters=filters, namespace=namespace)
56
+
57
+ # Embedding results
58
+ embed_results = self._embed.search(query, k=n_candidates, filters=filters, namespace=namespace)
59
+
60
+ # Compute RRF scores
61
+ rrf_scores: dict[str, float] = {}
62
+ entry_map: dict[str, TocEntry] = {}
63
+
64
+ for rank, entry in enumerate(fts_results):
65
+ rrf_scores[entry.id] = rrf_scores.get(entry.id, 0) + 1 / (self._rrf_k + rank + 1)
66
+ entry_map[entry.id] = entry
67
+
68
+ for rank, entry in enumerate(embed_results):
69
+ rrf_scores[entry.id] = rrf_scores.get(entry.id, 0) + 1 / (self._rrf_k + rank + 1)
70
+ entry_map[entry.id] = entry
71
+
72
+ # Sort by RRF score descending
73
+ sorted_ids = sorted(rrf_scores.keys(), key=lambda x: rrf_scores[x], reverse=True)
74
+
75
+ return [entry_map[id] for id in sorted_ids[:k]]
76
+
77
+ def close(self) -> None:
78
+ """Close both indexes."""
79
+ self._fts.close()
80
+ self._embed.close()
coffloader/py.typed ADDED
File without changes
coffloader/store.py ADDED
@@ -0,0 +1,279 @@
1
+ """Main Coffloader store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import uuid
7
+ from typing import Any
8
+
9
+ from .backends.base import BackendProtocol
10
+ from .backends.memory import MemoryBackend
11
+ from .index import EMBEDDINGS_AVAILABLE, FTSIndex
12
+ from .toc import InspectResult, TocEntry, WriteResult
13
+
14
+
15
+ class Coffloader:
16
+ """External memory for AI agents.
17
+
18
+ Offloads content to a VFS backend, indexes caller-provided summaries,
19
+ and retrieves via search.
20
+ """
21
+
22
+ DEFAULT_MAX_BYTES = 512_000
23
+ DEFAULT_MIN_SIMILARITY = 0.3
24
+
25
+ def __init__(
26
+ self,
27
+ backend: BackendProtocol | None = None,
28
+ index_path: str | None = None,
29
+ max_bytes: int = DEFAULT_MAX_BYTES,
30
+ max_estimated_tokens: int = 128_000,
31
+ on_oversize: str = "reject",
32
+ summary_max_chars: int = 1000,
33
+ hybrid: bool = True,
34
+ min_similarity: float = DEFAULT_MIN_SIMILARITY,
35
+ ) -> None:
36
+ """Initialize coffloader.
37
+
38
+ Args:
39
+ backend: Storage backend (default: in-memory)
40
+ index_path: SQLite index path (default: in-memory)
41
+ max_bytes: Max content size in bytes (default: 512_000)
42
+ max_estimated_tokens: Max estimated tokens (bytes / 4)
43
+ on_oversize: "reject" or "metadata_only"
44
+ summary_max_chars: Max summary length to index
45
+ hybrid: Use hybrid BM25+embeddings search if available (default: True)
46
+ min_similarity: Minimum cosine similarity for embedding results (default: 0.3).
47
+ Lower = more results, possibly less relevant.
48
+ Higher = fewer results, more relevant.
49
+ Set to 0.0 to disable filtering.
50
+ """
51
+ self._backend = backend or MemoryBackend()
52
+
53
+ # Use hybrid index if requested and embeddings are available
54
+ if hybrid and EMBEDDINGS_AVAILABLE:
55
+ from .index import HybridIndex
56
+ self._index = HybridIndex(db_path=index_path or ":memory:", min_similarity=min_similarity)
57
+ self._hybrid = True
58
+ else:
59
+ self._index = FTSIndex(db_path=index_path or ":memory:")
60
+ self._hybrid = False
61
+
62
+ self._max_bytes = max_bytes
63
+ self._max_estimated_tokens = max_estimated_tokens
64
+ self._on_oversize = on_oversize
65
+ self._summary_max_chars = summary_max_chars
66
+ self._min_similarity = min_similarity
67
+
68
+ @property
69
+ def is_hybrid(self) -> bool:
70
+ """Return True if using hybrid BM25+embeddings search."""
71
+ return self._hybrid
72
+
73
+ def inspect(self, content: str | bytes) -> InspectResult:
74
+ """Check if content is within size limits without storing.
75
+
76
+ Args:
77
+ content: Content to check
78
+
79
+ Returns:
80
+ InspectResult with acceptability and size stats
81
+ """
82
+ data = content.encode("utf-8") if isinstance(content, str) else content
83
+ byte_count = len(data)
84
+ estimated_tokens = byte_count // 4
85
+
86
+ if byte_count > self._max_bytes:
87
+ return InspectResult(
88
+ acceptable=False,
89
+ byte_count=byte_count,
90
+ estimated_tokens=estimated_tokens,
91
+ reason=f"content_exceeds_limit: {byte_count} bytes > {self._max_bytes}",
92
+ )
93
+
94
+ if estimated_tokens > self._max_estimated_tokens:
95
+ return InspectResult(
96
+ acceptable=False,
97
+ byte_count=byte_count,
98
+ estimated_tokens=estimated_tokens,
99
+ reason=f"content_exceeds_limit: ~{estimated_tokens} tokens > {self._max_estimated_tokens}",
100
+ )
101
+
102
+ return InspectResult(
103
+ acceptable=True,
104
+ byte_count=byte_count,
105
+ estimated_tokens=estimated_tokens,
106
+ )
107
+
108
+ def write(
109
+ self,
110
+ content: str | bytes,
111
+ summary: str,
112
+ *,
113
+ metadata: dict[str, Any] | None = None,
114
+ path: str | None = None,
115
+ ) -> WriteResult:
116
+ """Store content and create a TOC entry.
117
+
118
+ Args:
119
+ content: Full content to store
120
+ summary: Required summary for indexing (caller-provided)
121
+ metadata: Optional metadata fields for filtering
122
+ path: Optional VFS path (auto-generated if omitted)
123
+
124
+ Returns:
125
+ WriteResult with ok status, id, address, or rejection reason
126
+ """
127
+ if not summary or not summary.strip():
128
+ return WriteResult(
129
+ ok=False,
130
+ reason="summary_required",
131
+ stats={"message": "summary field is required and cannot be empty"},
132
+ )
133
+
134
+ data = content.encode("utf-8") if isinstance(content, str) else content
135
+ byte_count = len(data)
136
+ estimated_tokens = byte_count // 4
137
+
138
+ # Check size limits
139
+ check = self.inspect(content)
140
+ if not check.acceptable:
141
+ if self._on_oversize == "reject":
142
+ return WriteResult(
143
+ ok=False,
144
+ reason="content_exceeds_limit",
145
+ stats={
146
+ "byte_count": byte_count,
147
+ "estimated_tokens": estimated_tokens,
148
+ "max_bytes": self._max_bytes,
149
+ "max_estimated_tokens": self._max_estimated_tokens,
150
+ },
151
+ )
152
+ # metadata_only mode: create TOC entry but don't store blob
153
+ entry_id = str(uuid.uuid4())
154
+ address = path or f"/excluded/{entry_id}"
155
+ content_hash = f"sha256:{hashlib.sha256(data).hexdigest()}"
156
+
157
+ entry = TocEntry(
158
+ id=entry_id,
159
+ address=address,
160
+ summary=summary[: self._summary_max_chars],
161
+ metadata=metadata or {},
162
+ content_hash=content_hash,
163
+ byte_count=byte_count,
164
+ excluded=True,
165
+ )
166
+ self._index.add(entry)
167
+
168
+ return WriteResult(
169
+ ok=True,
170
+ id=entry_id,
171
+ address=address,
172
+ stats={
173
+ "byte_count": byte_count,
174
+ "estimated_tokens": estimated_tokens,
175
+ "excluded": True,
176
+ },
177
+ )
178
+
179
+ # Normal write
180
+ entry_id = str(uuid.uuid4())
181
+ address = path or f"/content/{entry_id}"
182
+ content_hash = f"sha256:{hashlib.sha256(data).hexdigest()}"
183
+
184
+ # Store blob
185
+ self._backend.write(address, data)
186
+
187
+ # Index TOC entry
188
+ entry = TocEntry(
189
+ id=entry_id,
190
+ address=address,
191
+ summary=summary[: self._summary_max_chars],
192
+ metadata=metadata or {},
193
+ content_hash=content_hash,
194
+ byte_count=byte_count,
195
+ excluded=False,
196
+ )
197
+ self._index.add(entry)
198
+
199
+ return WriteResult(
200
+ ok=True,
201
+ id=entry_id,
202
+ address=address,
203
+ stats={
204
+ "byte_count": byte_count,
205
+ "estimated_tokens": estimated_tokens,
206
+ },
207
+ )
208
+
209
+ def search(
210
+ self,
211
+ query: str,
212
+ k: int = 5,
213
+ *,
214
+ filters: dict[str, Any] | None = None,
215
+ namespace: str | None = None,
216
+ ) -> list[TocEntry]:
217
+ """Search the TOC index.
218
+
219
+ Args:
220
+ query: Search query
221
+ k: Max results to return
222
+ filters: Metadata field filters (exact match)
223
+ namespace: Filter by address prefix
224
+
225
+ Returns:
226
+ List of matching TocEntry objects (summary + address, not full content)
227
+ """
228
+ return self._index.search(query, k=k, filters=filters, namespace=namespace)
229
+
230
+ def read(self, address: str) -> bytes:
231
+ """Read full content from a VFS address.
232
+
233
+ Args:
234
+ address: VFS path returned by write() or search()
235
+
236
+ Returns:
237
+ Stored content as bytes
238
+
239
+ Raises:
240
+ KeyError: If address not found
241
+ """
242
+ return self._backend.read(address)
243
+
244
+ def read_text(self, address: str, encoding: str = "utf-8") -> str:
245
+ """Read full content as text.
246
+
247
+ Args:
248
+ address: VFS path
249
+ encoding: Text encoding (default: utf-8)
250
+
251
+ Returns:
252
+ Stored content as string
253
+ """
254
+ return self._backend.read(address).decode(encoding)
255
+
256
+ def delete(self, address: str) -> bool:
257
+ """Delete content and its TOC entry.
258
+
259
+ Args:
260
+ address: VFS path to delete
261
+
262
+ Returns:
263
+ True if deleted, False if not found
264
+ """
265
+ entry = self._index.get_by_address(address)
266
+ if entry:
267
+ self._index.remove(entry.id)
268
+ return self._backend.delete(address)
269
+
270
+ def get_entry(self, address: str) -> TocEntry | None:
271
+ """Get TOC entry by address without loading content.
272
+
273
+ Args:
274
+ address: VFS path
275
+
276
+ Returns:
277
+ TocEntry or None if not found
278
+ """
279
+ return self._index.get_by_address(address)
coffloader/toc.py ADDED
@@ -0,0 +1,67 @@
1
+ """Table of Context (TOC) entry and result models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timezone
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class TocEntry:
12
+ """A single entry in the Table of Context."""
13
+
14
+ id: str
15
+ address: str
16
+ summary: str
17
+ metadata: dict[str, Any] = field(default_factory=dict)
18
+ content_hash: str = ""
19
+ byte_count: int = 0
20
+ created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
21
+ excluded: bool = False
22
+
23
+ def to_dict(self) -> dict[str, Any]:
24
+ return {
25
+ "id": self.id,
26
+ "address": self.address,
27
+ "summary": self.summary,
28
+ "metadata": self.metadata,
29
+ "content_hash": self.content_hash,
30
+ "byte_count": self.byte_count,
31
+ "created_at": self.created_at,
32
+ "excluded": self.excluded,
33
+ }
34
+
35
+ @classmethod
36
+ def from_dict(cls, data: dict[str, Any]) -> "TocEntry":
37
+ return cls(
38
+ id=data["id"],
39
+ address=data["address"],
40
+ summary=data["summary"],
41
+ metadata=data.get("metadata", {}),
42
+ content_hash=data.get("content_hash", ""),
43
+ byte_count=data.get("byte_count", 0),
44
+ created_at=data.get("created_at", ""),
45
+ excluded=data.get("excluded", False),
46
+ )
47
+
48
+
49
+ @dataclass
50
+ class WriteResult:
51
+ """Result of a write operation."""
52
+
53
+ ok: bool
54
+ id: str = ""
55
+ address: str = ""
56
+ reason: str = ""
57
+ stats: dict[str, Any] = field(default_factory=dict)
58
+
59
+
60
+ @dataclass
61
+ class InspectResult:
62
+ """Result of an inspect (pre-check) operation."""
63
+
64
+ acceptable: bool
65
+ byte_count: int
66
+ estimated_tokens: int
67
+ reason: str = ""
@@ -0,0 +1,201 @@
1
+ Metadata-Version: 2.4
2
+ Name: coffloader
3
+ Version: 0.1.0
4
+ Summary: External memory for AI agents — offload context to a VFS, index summaries, retrieve on demand.
5
+ Project-URL: Homepage, https://github.com/mingyk/coffloader
6
+ Project-URL: Repository, https://github.com/mingyk/coffloader
7
+ Author: coffloader contributors
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agent,context,llm,memory,rag,vfs
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Requires-Python: >=3.9
21
+ Provides-Extra: dev
22
+ Requires-Dist: mypy>=1.10; extra == 'dev'
23
+ Requires-Dist: pytest>=8.0; extra == 'dev'
24
+ Requires-Dist: ruff>=0.4; extra == 'dev'
25
+ Provides-Extra: embed
26
+ Requires-Dist: numpy>=1.21; extra == 'embed'
27
+ Requires-Dist: sentence-transformers>=2.2; extra == 'embed'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # coffloader
31
+
32
+ **External memory for AI agents** — offload context to a VFS, index caller-provided summaries, retrieve on demand.
33
+
34
+ [![Python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://www.python.org/downloads/)
35
+ [![License](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
36
+ [![Status](https://img.shields.io/badge/status-pre--alpha-orange.svg)](#status)
37
+
38
+ ```bash
39
+ pip install coffloader # core (BM25 search)
40
+ pip install coffloader[embed] # + semantic search (sentence-transformers)
41
+ ```
42
+
43
+ ---
44
+
45
+ ## What it does
46
+
47
+ Agents accumulate context faster than any window allows. coffloader offloads content to storage, keeps a searchable index of summaries, and retrieves full content on demand.
48
+
49
+ ```
50
+ write(content, summary) → store blob + index summary
51
+ search(query) → top-k summaries + addresses
52
+ read(address) → full content
53
+ ```
54
+
55
+ **Key constraints:**
56
+ - `summary` is **required** on write — your agent/LLM provides it, not coffloader
57
+ - No LLM calls inside the library — pure storage and retrieval
58
+ - Caller handles contradiction detection, dedup, and reasoning
59
+
60
+ ---
61
+
62
+ ## Quick start
63
+
64
+ ```python
65
+ from coffloader import Coffloader
66
+
67
+ store = Coffloader()
68
+
69
+ # 1. Offload a conversation segment (summary comes from your agent)
70
+ store.write(
71
+ content="[Turn 1] User: I was charged twice for order #9910...",
72
+ summary="Customer reports duplicate charge on order #9910",
73
+ metadata={"session_id": "ticket_8842", "segment": 1},
74
+ path="/sessions/ticket_8842/seg_001.txt",
75
+ )
76
+
77
+ # 2. Later: search when user asks about earlier context
78
+ hits = store.search("order number", namespace="/sessions/ticket_8842/")
79
+
80
+ # 3. Load full content and inject into your LLM
81
+ text = store.read_text(hits[0].address)
82
+ ```
83
+
84
+ **The loop:** offload cold context → search when needed → read and inject.
85
+
86
+ ---
87
+
88
+ ## API
89
+
90
+ ```python
91
+ store = Coffloader(
92
+ backend=None, # default: in-memory VFS
93
+ max_bytes=512_000, # default: 512 KB — reject oversized payloads
94
+ on_oversize="reject", # "reject" or "metadata_only"
95
+ hybrid=True, # default: True — use BM25 + embeddings if available
96
+ min_similarity=0.3, # default: 0.3 — filter out weak embedding matches
97
+ # lower = more results, less relevant
98
+ # higher = fewer results, more relevant
99
+ # set to 0.0 to disable filtering
100
+ )
101
+
102
+ # Store content with a caller-provided summary
103
+ result = store.write(content, summary, metadata={}, path=None)
104
+
105
+ # Search indexed summaries (returns TocEntry list, not full content)
106
+ hits = store.search(query, k=5, filters={}, namespace=None)
107
+ # ^^^ number of results to return
108
+
109
+ # Load full content
110
+ data = store.read(address) # bytes
111
+ text = store.read_text(address) # str
112
+
113
+ # Check size before writing
114
+ check = store.inspect(content) # .acceptable, .byte_count
115
+
116
+ # Delete
117
+ store.delete(address)
118
+ ```
119
+
120
+ **Defaults are exposed as class attributes:**
121
+ ```python
122
+ Coffloader.DEFAULT_MAX_BYTES # 512_000
123
+ Coffloader.DEFAULT_MIN_SIMILARITY # 0.3
124
+ ```
125
+
126
+ ---
127
+
128
+ ## Composite backends
129
+
130
+ Route paths to different storage:
131
+
132
+ ```python
133
+ from coffloader import Coffloader, CompositeBackend, LocalBackend, MemoryBackend
134
+
135
+ store = Coffloader(
136
+ backend=CompositeBackend(
137
+ default=MemoryBackend(),
138
+ routes={"/archive/": LocalBackend(root="./data")},
139
+ )
140
+ )
141
+ ```
142
+
143
+ ---
144
+
145
+ ## Patterns
146
+
147
+ **Long session (segmented):** Offload every ~15 turns. Search returns precise segments, not the whole transcript.
148
+
149
+ ```python
150
+ store.write(content=turns_1_15, summary="...", path="/sessions/abc/seg_001.txt")
151
+ store.write(content=turns_16_30, summary="...", path="/sessions/abc/seg_002.txt")
152
+ ```
153
+
154
+ **Tool output:** Offload large grep/API results with a structural summary (no LLM needed).
155
+
156
+ ```python
157
+ store.write(
158
+ content=grep_output,
159
+ summary=f"grep error src/ → {n} matches",
160
+ path=f"/active/{session}/tool_001.txt",
161
+ )
162
+ ```
163
+
164
+ **Multi-agent:** Use namespaces for isolation (`/agent/{id}/`) or sharing (`/shared/`).
165
+
166
+ ---
167
+
168
+ ## Limits
169
+
170
+ - Max payload: 512 KB by default (configurable)
171
+ - Oversized content is rejected or recorded as metadata-only
172
+ - No silent truncation
173
+
174
+ ---
175
+
176
+ ## Status
177
+
178
+ Pre-alpha. Core API is stable: `write`, `search`, `read`, `inspect`, `delete`.
179
+
180
+ **Working:**
181
+ - BM25 (keyword) search via SQLite FTS5
182
+ - Semantic search via `[embed]` optional extra
183
+ - Hybrid search (BM25 + embeddings) with Reciprocal Rank Fusion
184
+
185
+ **Not yet implemented:**
186
+ - Persistent index to disk
187
+ - Sharded TOC for large corpora
188
+
189
+ ---
190
+
191
+ ## Non-goals
192
+
193
+ - LLM calls from the library
194
+ - Automatic dedup, contradiction detection, or memory merge
195
+ - Knowledge graphs or hierarchical rollups
196
+
197
+ ---
198
+
199
+ ## License
200
+
201
+ MIT
@@ -0,0 +1,17 @@
1
+ coffloader/__init__.py,sha256=hud-ptVjgFDi7kx5cOq_R1JlBwfNKXW897a2tvuPMQs,382
2
+ coffloader/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ coffloader/store.py,sha256=bn9CcSCN5Xx7Z--Gjz2I0LUQwgoTiqBXELQOpwWYLnI,9082
4
+ coffloader/toc.py,sha256=ucEOIElFI1uvBkr6mxKBrWuqBKfc8U7VDrnxuBjX79U,1771
5
+ coffloader/backends/__init__.py,sha256=6VV_H42MNyUXIA8LJktE7HpfE59IhWyBhc81Tm9nnxg,264
6
+ coffloader/backends/base.py,sha256=Tr_qtQbd4VCDOjmB7Hasvj-n5TAx-VH1j8nTNpJVJi0,618
7
+ coffloader/backends/composite.py,sha256=slKjVwSTfapaetkdarlgfpwLqCNL6dQ5T0lOkbYv3bE,1339
8
+ coffloader/backends/local.py,sha256=rC-CGLXK86waQtQshddn6O72mcXvQLw7FFF2lS9W5sc,1127
9
+ coffloader/backends/memory.py,sha256=mfpswR9QslgDzl0tpBPr8KV4OXtBWExCYw7CZCo9_Y8,680
10
+ coffloader/index/__init__.py,sha256=4INnoyRhX2cnGoRAzV2tihUoJ-0vk8_rBQmrtkAY93o,449
11
+ coffloader/index/embeddings.py,sha256=9Rc-fYPFkYy_S4axOCm89LJ5XotIUfQxuL2Q3A84kZo,5771
12
+ coffloader/index/fts.py,sha256=GItaVHJ4HhzEsbV-7lr7mpkVqXh0CgbEB4gFNjXpC1k,7140
13
+ coffloader/index/hybrid.py,sha256=JjXkd0Mxi-F53hDVVsg8Uz6fQVfvqdcEuD2Ax7sXihc,2799
14
+ coffloader-0.1.0.dist-info/METADATA,sha256=7eAtz4iQIxlI0HuXI5jHBkX_jSIPTCdMRO6MY6FOdpk,5934
15
+ coffloader-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
16
+ coffloader-0.1.0.dist-info/licenses/LICENSE,sha256=y5U7o9B9iqrJ8ZNu6APNngmk0-SB5602vo4jQGETgyo,1080
17
+ coffloader-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 coffloader contributors
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.