boring-agent-memory 0.2.2__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.
- boring_agent_memory/__init__.py +12 -0
- boring_agent_memory/api.py +23 -0
- boring_agent_memory/benchmark.py +524 -0
- boring_agent_memory/canonical.py +73 -0
- boring_agent_memory/chunking.py +425 -0
- boring_agent_memory/cli.py +298 -0
- boring_agent_memory/config.py +162 -0
- boring_agent_memory/embeddings.py +253 -0
- boring_agent_memory/eval.py +271 -0
- boring_agent_memory/index.py +449 -0
- boring_agent_memory/ingest.py +175 -0
- boring_agent_memory/privacy.py +84 -0
- boring_agent_memory/query.py +233 -0
- boring_agent_memory/schema.py +212 -0
- boring_agent_memory/server.py +41 -0
- boring_agent_memory/snippets.py +26 -0
- boring_agent_memory-0.2.2.dist-info/METADATA +162 -0
- boring_agent_memory-0.2.2.dist-info/RECORD +22 -0
- boring_agent_memory-0.2.2.dist-info/WHEEL +5 -0
- boring_agent_memory-0.2.2.dist-info/entry_points.txt +2 -0
- boring_agent_memory-0.2.2.dist-info/licenses/LICENSE +22 -0
- boring_agent_memory-0.2.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import sqlite3
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import UTC, datetime
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .chunking import CHUNKER_VERSION, DEFAULT_CHUNK_SIZE
|
|
13
|
+
from .ingest import IngestedRecord, ingest_file, iter_candidate_files
|
|
14
|
+
from .privacy import DEFAULT_EXCLUDE_GLOBS, REDACTION_VERSION, SECRET_PATTERNS
|
|
15
|
+
from .schema import (
|
|
16
|
+
SCHEMA_VERSION,
|
|
17
|
+
clear_index,
|
|
18
|
+
connect,
|
|
19
|
+
init_db,
|
|
20
|
+
require_index,
|
|
21
|
+
schema_version,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class _UpdateOperations:
|
|
27
|
+
delete_ids: tuple[str, ...]
|
|
28
|
+
insert_records: tuple[IngestedRecord, ...]
|
|
29
|
+
removal_paths: tuple[str, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def build_index(
|
|
33
|
+
db_path: Path | str,
|
|
34
|
+
includes: list[str],
|
|
35
|
+
excludes: list[str] | None = None,
|
|
36
|
+
workspace: Path | str | None = None,
|
|
37
|
+
source_type: str = "file",
|
|
38
|
+
max_bytes: int = 512 * 1024,
|
|
39
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
40
|
+
identity_namespace: str | None = None,
|
|
41
|
+
) -> dict[str, int]:
|
|
42
|
+
excludes = excludes or []
|
|
43
|
+
workspace_path = _workspace_path(workspace)
|
|
44
|
+
records, skipped = _ingest_candidates(
|
|
45
|
+
includes,
|
|
46
|
+
excludes,
|
|
47
|
+
workspace_path,
|
|
48
|
+
source_type,
|
|
49
|
+
max_bytes,
|
|
50
|
+
chunk_size,
|
|
51
|
+
identity_namespace,
|
|
52
|
+
)
|
|
53
|
+
config_json, fingerprint = index_configuration(
|
|
54
|
+
includes,
|
|
55
|
+
excludes,
|
|
56
|
+
workspace_path,
|
|
57
|
+
source_type,
|
|
58
|
+
max_bytes,
|
|
59
|
+
chunk_size,
|
|
60
|
+
identity_namespace,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
conn = connect(db_path)
|
|
64
|
+
try:
|
|
65
|
+
init_db(conn)
|
|
66
|
+
# Hold the write lock first, so another build cannot fill the index between this check and the clear.
|
|
67
|
+
# init_db commits on a new index but leaves a transaction open on an existing one.
|
|
68
|
+
if not conn.in_transaction:
|
|
69
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
70
|
+
with conn:
|
|
71
|
+
if not records and conn.execute("SELECT 1 FROM documents LIMIT 1").fetchone():
|
|
72
|
+
raise ValueError("no indexable files matched the include paths; refusing to replace a populated index")
|
|
73
|
+
clear_index(conn)
|
|
74
|
+
for record in records:
|
|
75
|
+
insert_record(conn, record)
|
|
76
|
+
_write_index_metadata(conn, config_json, fingerprint, chunk_size)
|
|
77
|
+
_validate_index(conn)
|
|
78
|
+
finally:
|
|
79
|
+
conn.close()
|
|
80
|
+
return {
|
|
81
|
+
"indexed": len(records),
|
|
82
|
+
"chunks": sum(len(record.chunks) for record in records),
|
|
83
|
+
"skipped": skipped,
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def update_index(
|
|
88
|
+
db_path: Path | str,
|
|
89
|
+
includes: list[str],
|
|
90
|
+
excludes: list[str] | None = None,
|
|
91
|
+
workspace: Path | str | None = None,
|
|
92
|
+
source_type: str = "file",
|
|
93
|
+
max_bytes: int = 512 * 1024,
|
|
94
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
95
|
+
dry_run: bool = False,
|
|
96
|
+
before_commit: Callable[[sqlite3.Connection], None] | None = None,
|
|
97
|
+
identity_namespace: str | None = None,
|
|
98
|
+
) -> dict[str, object]:
|
|
99
|
+
"""Apply a hash-based index update in one transaction."""
|
|
100
|
+
path = require_index(db_path)
|
|
101
|
+
|
|
102
|
+
excludes = excludes or []
|
|
103
|
+
workspace_path = _workspace_path(workspace)
|
|
104
|
+
records, skipped = _ingest_candidates(
|
|
105
|
+
includes,
|
|
106
|
+
excludes,
|
|
107
|
+
workspace_path,
|
|
108
|
+
source_type,
|
|
109
|
+
max_bytes,
|
|
110
|
+
chunk_size,
|
|
111
|
+
identity_namespace,
|
|
112
|
+
)
|
|
113
|
+
config_json, fingerprint = index_configuration(
|
|
114
|
+
includes,
|
|
115
|
+
excludes,
|
|
116
|
+
workspace_path,
|
|
117
|
+
source_type,
|
|
118
|
+
max_bytes,
|
|
119
|
+
chunk_size,
|
|
120
|
+
identity_namespace,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
if dry_run:
|
|
124
|
+
wal_path = Path(f"{path}-wal")
|
|
125
|
+
if wal_path.exists() and wal_path.stat().st_size:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"dry-run requires a checkpointed index with no pending WAL content"
|
|
128
|
+
)
|
|
129
|
+
conn = sqlite3.connect(
|
|
130
|
+
path.resolve().as_uri() + "?mode=ro&immutable=1",
|
|
131
|
+
uri=True,
|
|
132
|
+
)
|
|
133
|
+
conn.row_factory = sqlite3.Row
|
|
134
|
+
if schema_version(conn) != SCHEMA_VERSION:
|
|
135
|
+
conn.close()
|
|
136
|
+
raise ValueError("dry-run requires a current index schema; run bam build first")
|
|
137
|
+
else:
|
|
138
|
+
conn = connect(path)
|
|
139
|
+
try:
|
|
140
|
+
if not dry_run:
|
|
141
|
+
init_db(conn)
|
|
142
|
+
metadata = conn.execute("SELECT * FROM index_metadata WHERE singleton = 1").fetchone()
|
|
143
|
+
if metadata is None:
|
|
144
|
+
raise ValueError("index has no configuration fingerprint; run bam build once")
|
|
145
|
+
if metadata["config_fingerprint"] != fingerprint:
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"index configuration changed; run bam build before using incremental update"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
existing_rows = conn.execute(
|
|
151
|
+
"""
|
|
152
|
+
SELECT
|
|
153
|
+
d.id,
|
|
154
|
+
d.source_path,
|
|
155
|
+
d.source_hash,
|
|
156
|
+
(SELECT count(*) FROM chunks c WHERE c.document_id = d.id) AS chunk_count
|
|
157
|
+
FROM documents d
|
|
158
|
+
WHERE d.workspace = ? AND d.source_type = ?
|
|
159
|
+
ORDER BY source_path
|
|
160
|
+
""",
|
|
161
|
+
(workspace_path.as_posix(), source_type),
|
|
162
|
+
).fetchall()
|
|
163
|
+
report, operations = _plan_update(existing_rows, records, skipped)
|
|
164
|
+
_validate_removal_access(operations.removal_paths)
|
|
165
|
+
report["applied"] = not dry_run
|
|
166
|
+
report["dry_run"] = dry_run
|
|
167
|
+
if dry_run:
|
|
168
|
+
return report
|
|
169
|
+
if not operations.delete_ids and not operations.insert_records:
|
|
170
|
+
return report
|
|
171
|
+
|
|
172
|
+
with conn:
|
|
173
|
+
for document_id in operations.delete_ids:
|
|
174
|
+
delete_document(conn, document_id)
|
|
175
|
+
for record in operations.insert_records:
|
|
176
|
+
insert_record(conn, record)
|
|
177
|
+
_write_index_metadata(conn, config_json, fingerprint, chunk_size)
|
|
178
|
+
_validate_index(conn)
|
|
179
|
+
if before_commit:
|
|
180
|
+
before_commit(conn)
|
|
181
|
+
return report
|
|
182
|
+
finally:
|
|
183
|
+
conn.close()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def insert_record(conn: sqlite3.Connection, record: IngestedRecord) -> None:
|
|
187
|
+
conn.execute(
|
|
188
|
+
"""
|
|
189
|
+
INSERT INTO documents (
|
|
190
|
+
id, source_type, source_path, workspace, title, content,
|
|
191
|
+
source_hash, content_hash, metadata_json, updated_at
|
|
192
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
193
|
+
""",
|
|
194
|
+
(
|
|
195
|
+
record.id,
|
|
196
|
+
record.source_type,
|
|
197
|
+
record.source_path,
|
|
198
|
+
record.workspace,
|
|
199
|
+
record.title,
|
|
200
|
+
record.content,
|
|
201
|
+
record.source_hash,
|
|
202
|
+
record.content_hash,
|
|
203
|
+
record.metadata_json,
|
|
204
|
+
record.updated_at,
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
for chunk in record.chunks:
|
|
208
|
+
conn.execute(
|
|
209
|
+
"""
|
|
210
|
+
INSERT INTO chunks (
|
|
211
|
+
id, document_id, heading, heading_key, ordinal,
|
|
212
|
+
start_line, end_line, content, content_hash
|
|
213
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
214
|
+
""",
|
|
215
|
+
(
|
|
216
|
+
chunk.id,
|
|
217
|
+
record.id,
|
|
218
|
+
chunk.heading,
|
|
219
|
+
chunk.heading_key,
|
|
220
|
+
chunk.ordinal,
|
|
221
|
+
chunk.start_line,
|
|
222
|
+
chunk.end_line,
|
|
223
|
+
chunk.content,
|
|
224
|
+
chunk.content_hash,
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
conn.execute(
|
|
228
|
+
"""
|
|
229
|
+
INSERT INTO chunks_fts (chunk_id, title, heading, content, source_path)
|
|
230
|
+
VALUES (?, ?, ?, ?, ?)
|
|
231
|
+
""",
|
|
232
|
+
(chunk.id, record.title, chunk.heading, chunk.content, record.source_path),
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def delete_document(conn: sqlite3.Connection, document_id: str) -> None:
|
|
237
|
+
conn.execute(
|
|
238
|
+
"DELETE FROM chunks_fts WHERE chunk_id IN "
|
|
239
|
+
"(SELECT id FROM chunks WHERE document_id = ?)",
|
|
240
|
+
(document_id,),
|
|
241
|
+
)
|
|
242
|
+
conn.execute("DELETE FROM documents WHERE id = ?", (document_id,))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def index_configuration(
|
|
246
|
+
includes: list[str],
|
|
247
|
+
excludes: list[str],
|
|
248
|
+
workspace: Path,
|
|
249
|
+
source_type: str,
|
|
250
|
+
max_bytes: int,
|
|
251
|
+
chunk_size: int,
|
|
252
|
+
identity_namespace: str | None = None,
|
|
253
|
+
) -> tuple[str, str]:
|
|
254
|
+
payload = {
|
|
255
|
+
"schema_version": SCHEMA_VERSION,
|
|
256
|
+
"chunker_version": CHUNKER_VERSION,
|
|
257
|
+
"chunk_size": chunk_size,
|
|
258
|
+
"includes": sorted(_normalize_include(value, workspace) for value in includes),
|
|
259
|
+
"excludes": sorted(excludes),
|
|
260
|
+
"workspace": workspace.as_posix(),
|
|
261
|
+
"source_type": source_type,
|
|
262
|
+
"max_bytes": max_bytes,
|
|
263
|
+
"document_id_version": 1,
|
|
264
|
+
"identity_namespace": identity_namespace,
|
|
265
|
+
"redaction_version": REDACTION_VERSION,
|
|
266
|
+
"tokenizer": "unicode61",
|
|
267
|
+
"privacy_policy": hashlib.sha256(
|
|
268
|
+
json.dumps(
|
|
269
|
+
{
|
|
270
|
+
"default_excludes": DEFAULT_EXCLUDE_GLOBS,
|
|
271
|
+
"secret_patterns": [
|
|
272
|
+
{"pattern": pattern.pattern, "flags": pattern.flags}
|
|
273
|
+
for pattern in SECRET_PATTERNS
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
sort_keys=True,
|
|
277
|
+
).encode("utf-8")
|
|
278
|
+
).hexdigest(),
|
|
279
|
+
}
|
|
280
|
+
rendered = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
|
281
|
+
return rendered, hashlib.sha256(rendered.encode("utf-8")).hexdigest()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _ingest_candidates(
|
|
285
|
+
includes: list[str],
|
|
286
|
+
excludes: list[str],
|
|
287
|
+
workspace: Path,
|
|
288
|
+
source_type: str,
|
|
289
|
+
max_bytes: int,
|
|
290
|
+
chunk_size: int,
|
|
291
|
+
identity_namespace: str | None,
|
|
292
|
+
) -> tuple[list[IngestedRecord], int]:
|
|
293
|
+
records: list[IngestedRecord] = []
|
|
294
|
+
skipped = 0
|
|
295
|
+
try:
|
|
296
|
+
candidates = iter_candidate_files(includes, excludes, workspace)
|
|
297
|
+
except OSError as exc:
|
|
298
|
+
raise OSError(f"failed to scan candidate sources in {workspace}: {exc}") from exc
|
|
299
|
+
for file_path in candidates:
|
|
300
|
+
try:
|
|
301
|
+
record = ingest_file(
|
|
302
|
+
file_path,
|
|
303
|
+
workspace=workspace,
|
|
304
|
+
source_type=source_type,
|
|
305
|
+
max_bytes=max_bytes,
|
|
306
|
+
chunk_size=chunk_size,
|
|
307
|
+
identity_namespace=identity_namespace,
|
|
308
|
+
)
|
|
309
|
+
except OSError as exc:
|
|
310
|
+
raise OSError(f"failed to read candidate source {file_path}: {exc}") from exc
|
|
311
|
+
if record is None:
|
|
312
|
+
skipped += 1
|
|
313
|
+
continue
|
|
314
|
+
records.append(record)
|
|
315
|
+
return records, skipped
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _plan_update(
|
|
319
|
+
existing_rows: list[sqlite3.Row],
|
|
320
|
+
records: list[IngestedRecord],
|
|
321
|
+
skipped: int,
|
|
322
|
+
) -> tuple[dict[str, object], _UpdateOperations]:
|
|
323
|
+
existing_by_path = {row["source_path"]: row for row in existing_rows}
|
|
324
|
+
current_by_path = {record.source_path: record for record in records}
|
|
325
|
+
|
|
326
|
+
unchanged = sorted(
|
|
327
|
+
path
|
|
328
|
+
for path in existing_by_path.keys() & current_by_path.keys()
|
|
329
|
+
if existing_by_path[path]["source_hash"] == current_by_path[path].source_hash
|
|
330
|
+
)
|
|
331
|
+
modified = sorted(
|
|
332
|
+
path
|
|
333
|
+
for path in existing_by_path.keys() & current_by_path.keys()
|
|
334
|
+
if existing_by_path[path]["source_hash"] != current_by_path[path].source_hash
|
|
335
|
+
)
|
|
336
|
+
added = set(current_by_path.keys() - existing_by_path.keys())
|
|
337
|
+
removed = set(existing_by_path.keys() - current_by_path.keys())
|
|
338
|
+
|
|
339
|
+
removed_by_hash: dict[str, list[str]] = defaultdict(list)
|
|
340
|
+
added_by_hash: dict[str, list[str]] = defaultdict(list)
|
|
341
|
+
for path in removed:
|
|
342
|
+
removed_by_hash[existing_by_path[path]["source_hash"]].append(path)
|
|
343
|
+
for path in added:
|
|
344
|
+
added_by_hash[current_by_path[path].source_hash].append(path)
|
|
345
|
+
|
|
346
|
+
moves: list[dict[str, str]] = []
|
|
347
|
+
for content_hash in sorted(removed_by_hash.keys() & added_by_hash.keys()):
|
|
348
|
+
old_paths = sorted(removed_by_hash[content_hash])
|
|
349
|
+
new_paths = sorted(added_by_hash[content_hash])
|
|
350
|
+
if len(old_paths) == 1 and len(new_paths) == 1:
|
|
351
|
+
old_path = old_paths[0]
|
|
352
|
+
new_path = new_paths[0]
|
|
353
|
+
moves.append({"from": old_path, "to": new_path})
|
|
354
|
+
removed.remove(old_path)
|
|
355
|
+
added.remove(new_path)
|
|
356
|
+
|
|
357
|
+
delete_paths = sorted([*removed, *modified, *(move["from"] for move in moves)])
|
|
358
|
+
insert_paths = sorted([*added, *modified, *(move["to"] for move in moves)])
|
|
359
|
+
report: dict[str, object] = {
|
|
360
|
+
"added": len(added),
|
|
361
|
+
"modified": len(modified),
|
|
362
|
+
"moved": len(moves),
|
|
363
|
+
"removed": len(removed),
|
|
364
|
+
"unchanged": len(unchanged),
|
|
365
|
+
"skipped": skipped,
|
|
366
|
+
"chunks_added": sum(len(current_by_path[path].chunks) for path in insert_paths),
|
|
367
|
+
"chunks_removed": sum(
|
|
368
|
+
int(existing_by_path[path]["chunk_count"]) for path in delete_paths
|
|
369
|
+
),
|
|
370
|
+
"changes": {
|
|
371
|
+
"added": sorted(added),
|
|
372
|
+
"modified": modified,
|
|
373
|
+
"moved": moves,
|
|
374
|
+
"removed": sorted(removed),
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
operations = _UpdateOperations(
|
|
378
|
+
delete_ids=tuple(existing_by_path[path]["id"] for path in delete_paths),
|
|
379
|
+
insert_records=tuple(current_by_path[path] for path in insert_paths),
|
|
380
|
+
removal_paths=tuple(sorted(removed)),
|
|
381
|
+
)
|
|
382
|
+
return report, operations
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _write_index_metadata(
|
|
386
|
+
conn: sqlite3.Connection,
|
|
387
|
+
config_json: str,
|
|
388
|
+
fingerprint: str,
|
|
389
|
+
chunk_size: int,
|
|
390
|
+
) -> None:
|
|
391
|
+
conn.execute(
|
|
392
|
+
"""
|
|
393
|
+
INSERT INTO index_metadata (
|
|
394
|
+
singleton, config_fingerprint, config_json, built_at,
|
|
395
|
+
chunker_version, chunk_size
|
|
396
|
+
) VALUES (1, ?, ?, ?, ?, ?)
|
|
397
|
+
ON CONFLICT(singleton) DO UPDATE SET
|
|
398
|
+
config_fingerprint = excluded.config_fingerprint,
|
|
399
|
+
config_json = excluded.config_json,
|
|
400
|
+
built_at = excluded.built_at,
|
|
401
|
+
chunker_version = excluded.chunker_version,
|
|
402
|
+
chunk_size = excluded.chunk_size
|
|
403
|
+
""",
|
|
404
|
+
(
|
|
405
|
+
fingerprint,
|
|
406
|
+
config_json,
|
|
407
|
+
datetime.now(UTC).isoformat(),
|
|
408
|
+
CHUNKER_VERSION,
|
|
409
|
+
chunk_size,
|
|
410
|
+
),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _workspace_path(workspace: Path | str | None) -> Path:
|
|
415
|
+
return Path(workspace).expanduser().resolve() if workspace else Path.cwd().resolve()
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _normalize_include(value: str, workspace: Path) -> str:
|
|
419
|
+
path = Path(value).expanduser()
|
|
420
|
+
if not path.is_absolute():
|
|
421
|
+
path = workspace / path
|
|
422
|
+
return path.resolve().as_posix()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _validate_index(conn: sqlite3.Connection) -> None:
|
|
426
|
+
foreign_key_error = conn.execute("PRAGMA foreign_key_check").fetchone()
|
|
427
|
+
if foreign_key_error is not None:
|
|
428
|
+
raise sqlite3.IntegrityError(f"index foreign-key violation: {tuple(foreign_key_error)}")
|
|
429
|
+
chunk_count = conn.execute("SELECT count(*) FROM chunks").fetchone()[0]
|
|
430
|
+
fts_count = conn.execute("SELECT count(*) FROM chunks_fts").fetchone()[0]
|
|
431
|
+
if chunk_count != fts_count:
|
|
432
|
+
raise sqlite3.IntegrityError(
|
|
433
|
+
f"chunk/FTS row mismatch: chunks={chunk_count} fts={fts_count}"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _validate_removal_access(paths: tuple[str, ...]) -> None:
|
|
438
|
+
"""Distinguish real deletion from a source hidden by an access failure."""
|
|
439
|
+
for value in paths:
|
|
440
|
+
path = Path(value)
|
|
441
|
+
try:
|
|
442
|
+
with path.open("rb") as source:
|
|
443
|
+
source.read(1)
|
|
444
|
+
except FileNotFoundError:
|
|
445
|
+
continue
|
|
446
|
+
except OSError as exc:
|
|
447
|
+
raise OSError(
|
|
448
|
+
f"cannot confirm removal of indexed source {path}: {exc}"
|
|
449
|
+
) from exc
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from glob import glob
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .chunking import DEFAULT_CHUNK_SIZE, TextChunk, chunk_text
|
|
12
|
+
from .privacy import redact_secrets, should_exclude_path
|
|
13
|
+
|
|
14
|
+
TEXT_EXTENSIONS = {
|
|
15
|
+
".adoc",
|
|
16
|
+
".cfg",
|
|
17
|
+
".conf",
|
|
18
|
+
".csv",
|
|
19
|
+
".ini",
|
|
20
|
+
".json",
|
|
21
|
+
".jsonl",
|
|
22
|
+
".log",
|
|
23
|
+
".md",
|
|
24
|
+
".mdx",
|
|
25
|
+
".py",
|
|
26
|
+
".rst",
|
|
27
|
+
".sql",
|
|
28
|
+
".toml",
|
|
29
|
+
".txt",
|
|
30
|
+
".yaml",
|
|
31
|
+
".yml",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class IngestedRecord:
|
|
37
|
+
id: str
|
|
38
|
+
source_type: str
|
|
39
|
+
source_path: str
|
|
40
|
+
workspace: str
|
|
41
|
+
title: str
|
|
42
|
+
content: str
|
|
43
|
+
source_hash: str
|
|
44
|
+
content_hash: str
|
|
45
|
+
metadata_json: str
|
|
46
|
+
updated_at: str
|
|
47
|
+
chunks: tuple[TextChunk, ...]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def iter_candidate_files(
|
|
51
|
+
includes: Iterable[str],
|
|
52
|
+
excludes: Iterable[str] = (),
|
|
53
|
+
workspace: Path | None = None,
|
|
54
|
+
) -> list[Path]:
|
|
55
|
+
root = (workspace or Path.cwd()).expanduser().resolve()
|
|
56
|
+
files: list[Path] = []
|
|
57
|
+
seen: set[Path] = set()
|
|
58
|
+
|
|
59
|
+
for include in includes:
|
|
60
|
+
candidates = _expand_include(include, root)
|
|
61
|
+
for candidate in candidates:
|
|
62
|
+
resolved = candidate.resolve()
|
|
63
|
+
if resolved in seen:
|
|
64
|
+
continue
|
|
65
|
+
if should_exclude_path(resolved, root=root, exclude_globs=tuple(excludes)):
|
|
66
|
+
continue
|
|
67
|
+
if not looks_like_text_file(resolved):
|
|
68
|
+
continue
|
|
69
|
+
seen.add(resolved)
|
|
70
|
+
files.append(resolved)
|
|
71
|
+
|
|
72
|
+
return sorted(files, key=lambda p: p.as_posix())
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _expand_include(include: str, root: Path) -> list[Path]:
|
|
76
|
+
path = Path(include).expanduser()
|
|
77
|
+
if not path.is_absolute():
|
|
78
|
+
path = root / path
|
|
79
|
+
|
|
80
|
+
pattern = path.as_posix()
|
|
81
|
+
if any(char in pattern for char in "*?[]"):
|
|
82
|
+
matches = [Path(match) for match in glob(pattern, recursive=True)]
|
|
83
|
+
elif path.exists():
|
|
84
|
+
matches = [path]
|
|
85
|
+
else:
|
|
86
|
+
raise FileNotFoundError(f"include path does not exist: {include}")
|
|
87
|
+
|
|
88
|
+
files: list[Path] = []
|
|
89
|
+
for match in matches:
|
|
90
|
+
if match.is_file():
|
|
91
|
+
files.append(match)
|
|
92
|
+
elif match.is_dir():
|
|
93
|
+
files.extend(p for p in match.rglob("*") if p.is_file())
|
|
94
|
+
return files
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ingest_file(
|
|
98
|
+
path: Path,
|
|
99
|
+
workspace: Path | None = None,
|
|
100
|
+
source_type: str = "file",
|
|
101
|
+
max_bytes: int = 512 * 1024,
|
|
102
|
+
chunk_size: int = DEFAULT_CHUNK_SIZE,
|
|
103
|
+
identity_namespace: str | None = None,
|
|
104
|
+
) -> IngestedRecord | None:
|
|
105
|
+
stat = path.stat()
|
|
106
|
+
if stat.st_size > max_bytes:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
raw = path.read_bytes()
|
|
110
|
+
if b"\x00" in raw:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
content = raw.decode("utf-8")
|
|
115
|
+
except UnicodeDecodeError:
|
|
116
|
+
content = raw.decode("utf-8", errors="replace")
|
|
117
|
+
|
|
118
|
+
source_hash = hashlib.sha256(raw).hexdigest()
|
|
119
|
+
content, redactions = redact_secrets(content)
|
|
120
|
+
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
121
|
+
resolved = path.expanduser().resolve()
|
|
122
|
+
root = (workspace or Path.cwd()).expanduser().resolve()
|
|
123
|
+
try:
|
|
124
|
+
relative_path = resolved.relative_to(root).as_posix()
|
|
125
|
+
except ValueError:
|
|
126
|
+
relative_path = resolved.as_posix()
|
|
127
|
+
identity_scope = (
|
|
128
|
+
f"namespace:{identity_namespace}"
|
|
129
|
+
if identity_namespace is not None
|
|
130
|
+
else root.as_posix()
|
|
131
|
+
)
|
|
132
|
+
identity = f"{source_type}\0{identity_scope}\0{relative_path}"
|
|
133
|
+
record_id = hashlib.sha256(identity.encode("utf-8")).hexdigest()
|
|
134
|
+
title = title_for_path(resolved)
|
|
135
|
+
metadata = {
|
|
136
|
+
"bytes": stat.st_size,
|
|
137
|
+
"mtime": datetime.fromtimestamp(stat.st_mtime, tz=UTC).isoformat(),
|
|
138
|
+
"extension": resolved.suffix.lower(),
|
|
139
|
+
"redactions": redactions,
|
|
140
|
+
"relative_path": relative_path,
|
|
141
|
+
}
|
|
142
|
+
chunks = chunk_text(record_id, content, resolved.suffix.lower(), max_chars=chunk_size)
|
|
143
|
+
|
|
144
|
+
return IngestedRecord(
|
|
145
|
+
id=record_id,
|
|
146
|
+
source_type=source_type,
|
|
147
|
+
source_path=resolved.as_posix(),
|
|
148
|
+
workspace=root.as_posix(),
|
|
149
|
+
title=title,
|
|
150
|
+
content=content,
|
|
151
|
+
source_hash=source_hash,
|
|
152
|
+
content_hash=content_hash,
|
|
153
|
+
metadata_json=json.dumps(metadata, sort_keys=True),
|
|
154
|
+
updated_at=datetime.now(UTC).isoformat(),
|
|
155
|
+
chunks=chunks,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def looks_like_text_file(path: Path) -> bool:
|
|
160
|
+
if path.suffix.lower() in TEXT_EXTENSIONS:
|
|
161
|
+
return True
|
|
162
|
+
with path.open("rb") as source:
|
|
163
|
+
chunk = source.read(2048)
|
|
164
|
+
if b"\x00" in chunk:
|
|
165
|
+
return False
|
|
166
|
+
if not chunk:
|
|
167
|
+
return True
|
|
168
|
+
printable = sum(1 for byte in chunk if byte in b"\n\r\t" or 32 <= byte <= 126)
|
|
169
|
+
return printable / len(chunk) > 0.85
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def title_for_path(path: Path) -> str:
|
|
173
|
+
if path.name.lower() in {"readme.md", "index.md"} and path.parent.name:
|
|
174
|
+
return path.parent.name
|
|
175
|
+
return path.name
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
REDACTION_VERSION = 2
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_EXCLUDE_GLOBS = (
|
|
11
|
+
"**/.git/**",
|
|
12
|
+
"**/__pycache__/**",
|
|
13
|
+
"**/.pytest_cache/**",
|
|
14
|
+
"**/.mypy_cache/**",
|
|
15
|
+
"**/.ruff_cache/**",
|
|
16
|
+
"**/node_modules/**",
|
|
17
|
+
"**/.venv/**",
|
|
18
|
+
"**/.env",
|
|
19
|
+
"**/.env.*",
|
|
20
|
+
"**/secrets/**",
|
|
21
|
+
"**/*secret*/**",
|
|
22
|
+
"**/*.pem",
|
|
23
|
+
"**/*.key",
|
|
24
|
+
"**/id_rsa",
|
|
25
|
+
"**/id_ed25519",
|
|
26
|
+
"**/.DS_Store",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
SECRET_PATTERNS = (
|
|
31
|
+
re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
|
|
32
|
+
re.compile(r"github_pat_[A-Za-z0-9_]{20,}"),
|
|
33
|
+
re.compile(r"ghp_[A-Za-z0-9_]{20,}"),
|
|
34
|
+
re.compile(r"AKIA[0-9A-Z]{16}"),
|
|
35
|
+
re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/=-]{20,}"),
|
|
36
|
+
re.compile(
|
|
37
|
+
r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----",
|
|
38
|
+
re.DOTALL,
|
|
39
|
+
),
|
|
40
|
+
re.compile(
|
|
41
|
+
r"(?im)^([A-Z0-9_]*(?:SECRET|TOKEN|PASSWORD|API_KEY|PRIVATE_KEY)[A-Z0-9_]*\s*=\s*).+$"
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalized_path(path: Path) -> str:
|
|
47
|
+
return path.expanduser().resolve().as_posix()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def should_exclude_path(
|
|
51
|
+
path: Path,
|
|
52
|
+
root: Path | None = None,
|
|
53
|
+
exclude_globs: list[str] | tuple[str, ...] = (),
|
|
54
|
+
) -> bool:
|
|
55
|
+
absolute = normalized_path(path)
|
|
56
|
+
candidates = [absolute, path.as_posix()]
|
|
57
|
+
if root is not None:
|
|
58
|
+
try:
|
|
59
|
+
candidates.append(path.resolve().relative_to(root.resolve()).as_posix())
|
|
60
|
+
except ValueError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
for pattern in (*DEFAULT_EXCLUDE_GLOBS, *exclude_globs):
|
|
64
|
+
expanded = str(Path(pattern).expanduser()) if pattern.startswith("~") else pattern
|
|
65
|
+
if any(fnmatch.fnmatch(candidate, expanded) for candidate in candidates):
|
|
66
|
+
return True
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def redact_secrets(content: str) -> tuple[str, int]:
|
|
71
|
+
redactions = 0
|
|
72
|
+
redacted = content
|
|
73
|
+
for pattern in SECRET_PATTERNS:
|
|
74
|
+
redacted, count = pattern.subn(_replacement, redacted)
|
|
75
|
+
redactions += count
|
|
76
|
+
return redacted, redactions
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _replacement(match: re.Match[str]) -> str:
|
|
80
|
+
if match.lastindex:
|
|
81
|
+
replacement = f"{match.group(1)}[REDACTED]"
|
|
82
|
+
else:
|
|
83
|
+
replacement = "[REDACTED]"
|
|
84
|
+
return replacement + ("\n" * match.group(0).count("\n"))
|