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 +17 -0
- coffloader/backends/__init__.py +8 -0
- coffloader/backends/base.py +23 -0
- coffloader/backends/composite.py +41 -0
- coffloader/backends/local.py +37 -0
- coffloader/backends/memory.py +25 -0
- coffloader/index/__init__.py +15 -0
- coffloader/index/embeddings.py +181 -0
- coffloader/index/fts.py +218 -0
- coffloader/index/hybrid.py +80 -0
- coffloader/py.typed +0 -0
- coffloader/store.py +279 -0
- coffloader/toc.py +67 -0
- coffloader-0.1.0.dist-info/METADATA +201 -0
- coffloader-0.1.0.dist-info/RECORD +17 -0
- coffloader-0.1.0.dist-info/WHEEL +4 -0
- coffloader-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|
coffloader/index/fts.py
ADDED
|
@@ -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
|
+
[](https://www.python.org/downloads/)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
[](#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,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.
|