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.
@@ -0,0 +1,12 @@
1
+ """Boring Agent Memory."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ from .api import memory_query
6
+
7
+ try:
8
+ __version__ = version("boring-agent-memory")
9
+ except PackageNotFoundError:
10
+ __version__ = "0+unknown"
11
+
12
+ __all__ = ["__version__", "memory_query"]
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .query import QueryResult, query_memory
6
+ from .schema import DEFAULT_DB_PATH
7
+
8
+
9
+ def memory_query(
10
+ query: str,
11
+ limit: int = 5,
12
+ source_type: str | None = None,
13
+ db_path: Path | str = DEFAULT_DB_PATH,
14
+ workspace: Path | str | None = None,
15
+ ) -> list[QueryResult]:
16
+ """Small agent-facing query API."""
17
+ return query_memory(
18
+ db_path=db_path,
19
+ query=query,
20
+ limit=limit,
21
+ source_type=source_type,
22
+ workspace=workspace,
23
+ )
@@ -0,0 +1,524 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import shutil
6
+ import statistics
7
+ import tempfile
8
+ import time
9
+ from collections import Counter, defaultdict
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from .embeddings import (
16
+ DenseIndex,
17
+ EmbeddingAdapter,
18
+ load_embedding_documents,
19
+ reciprocal_rank_fusion,
20
+ )
21
+ from .index import build_index
22
+ from .ingest import IngestedRecord, ingest_file, iter_candidate_files
23
+ from .privacy import SECRET_PATTERNS
24
+ from .query import QueryResult, query_memory
25
+ from .schema import connect, init_db
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class BenchmarkCase:
30
+ id: str
31
+ category: str
32
+ query: str
33
+ expected_source: str | None
34
+ expected_heading: str | None = None
35
+ expected_start_line: int | None = None
36
+ expected_end_line: int | None = None
37
+ forbidden_sources: tuple[str, ...] = ()
38
+ forbidden_terms: tuple[str, ...] = ()
39
+
40
+
41
+ def run_benchmark(
42
+ corpus_dir: Path | str,
43
+ cases_path: Path | str,
44
+ limit: int = 3,
45
+ benchmark_name: str = "benchmark-v2",
46
+ chunk_size: int = 1600,
47
+ embedding_adapter: EmbeddingAdapter | None = None,
48
+ ) -> dict[str, Any]:
49
+ corpus_dir = Path(corpus_dir).expanduser().resolve()
50
+ cases_path = Path(cases_path).expanduser().resolve()
51
+ cases = load_benchmark_cases(cases_path)
52
+ identity_namespace = f"benchmark:{benchmark_name}"
53
+
54
+ with tempfile.TemporaryDirectory() as tmp:
55
+ workspace = (Path(tmp) / "corpus").resolve()
56
+ shutil.copytree(corpus_dir, workspace)
57
+ whole_db = Path(tmp) / "whole.db"
58
+ chunked_db = Path(tmp) / "chunked.db"
59
+
60
+ whole_index = build_benchmark_index(
61
+ whole_db,
62
+ workspace,
63
+ chunk_size=0,
64
+ identity_namespace=identity_namespace,
65
+ )
66
+ chunked_index = build_benchmark_index(
67
+ chunked_db,
68
+ workspace,
69
+ chunk_size=chunk_size,
70
+ identity_namespace=identity_namespace,
71
+ )
72
+ records = load_redacted_records(workspace, identity_namespace)
73
+
74
+ whole_bm25 = evaluate_strategy(
75
+ cases,
76
+ workspace,
77
+ lambda case: query_memory(
78
+ whole_db,
79
+ case.query,
80
+ limit=limit,
81
+ source_type="benchmark_fixture",
82
+ workspace=workspace,
83
+ ),
84
+ )
85
+ whole_bm25["index"] = whole_index
86
+ def chunked_retrieve(case: BenchmarkCase) -> list[QueryResult]:
87
+ return query_memory(
88
+ chunked_db,
89
+ case.query,
90
+ limit=limit,
91
+ source_type="benchmark_fixture",
92
+ workspace=workspace,
93
+ )
94
+
95
+ chunked_bm25 = evaluate_strategy(cases, workspace, chunked_retrieve)
96
+ chunked_bm25["index"] = chunked_index
97
+ exact_grep = evaluate_strategy(
98
+ cases,
99
+ workspace,
100
+ lambda case: exact_phrase_grep(records, case.query, limit),
101
+ )
102
+
103
+ if embedding_adapter is None:
104
+ dense: dict[str, Any] = {
105
+ "status": "not_run",
106
+ "reason": "no explicit local embedding adapter was provided",
107
+ }
108
+ hybrid: dict[str, Any] = {
109
+ "status": "not_run",
110
+ "reason": "hybrid retrieval requires an explicit local embedding adapter",
111
+ }
112
+ else:
113
+ candidate_depth = max(50, limit * 10)
114
+ dense_started = time.perf_counter()
115
+ dense_index = DenseIndex.build(
116
+ embedding_adapter,
117
+ load_embedding_documents(
118
+ chunked_db,
119
+ source_type="benchmark_fixture",
120
+ workspace=workspace,
121
+ ),
122
+ )
123
+ dense_build_ms = elapsed_ms(dense_started)
124
+ dense = evaluate_strategy(
125
+ cases,
126
+ workspace,
127
+ lambda case: dense_index.query(case.query, limit=limit),
128
+ )
129
+ dense["index"] = {
130
+ "build_ms": dense_build_ms,
131
+ "vectors": len(dense_index.vectors),
132
+ "dimensions": len(dense_index.vectors[0]) if dense_index.vectors else 0,
133
+ "size_bytes_estimate": sum(
134
+ len(vector) * 8 for vector in dense_index.vectors
135
+ ),
136
+ "model_id": embedding_adapter.model_id,
137
+ "model_revision": getattr(embedding_adapter, "model_revision", None),
138
+ "model_license": getattr(embedding_adapter, "model_license", None),
139
+ "model_source": (
140
+ "explicit_download"
141
+ if bool(getattr(embedding_adapter, "allow_download", False))
142
+ else "local_path"
143
+ ),
144
+ "text_leaves_machine": False,
145
+ "download_allowed": bool(
146
+ getattr(embedding_adapter, "allow_download", False)
147
+ ),
148
+ }
149
+ hybrid = evaluate_strategy(
150
+ cases,
151
+ workspace,
152
+ lambda case: reciprocal_rank_fusion(
153
+ query_memory(
154
+ chunked_db,
155
+ case.query,
156
+ limit=candidate_depth,
157
+ source_type="benchmark_fixture",
158
+ workspace=workspace,
159
+ ),
160
+ dense_index.query(case.query, limit=candidate_depth),
161
+ limit=limit,
162
+ ),
163
+ )
164
+ hybrid["parameters"] = {
165
+ "fusion": "weighted_rrf",
166
+ "lexical_weight": 0.6,
167
+ "dense_weight": 0.4,
168
+ "rrf_k": 60,
169
+ "lexical_candidate_depth": candidate_depth,
170
+ "dense_candidate_depth": candidate_depth,
171
+ }
172
+
173
+ return {
174
+ "schema_version": 2,
175
+ "benchmark": benchmark_name,
176
+ "corpus": {
177
+ "documents": whole_index["documents"],
178
+ "cases": len(cases),
179
+ "category_counts": dict(
180
+ sorted(Counter(case.category for case in cases).items())
181
+ ),
182
+ "corpus_sha256": directory_hash(corpus_dir),
183
+ "cases_sha256": file_hash(cases_path),
184
+ },
185
+ "strategies": {
186
+ "whole_document_bm25": whole_bm25,
187
+ "chunked_bm25": chunked_bm25,
188
+ "exact_phrase_grep": exact_grep,
189
+ "dense": dense,
190
+ "hybrid_rrf": hybrid,
191
+ },
192
+ }
193
+
194
+
195
+ def build_benchmark_index(
196
+ db_path: Path,
197
+ workspace: Path,
198
+ chunk_size: int,
199
+ identity_namespace: str,
200
+ ) -> dict[str, Any]:
201
+ started = time.perf_counter()
202
+ build = build_index(
203
+ db_path=db_path,
204
+ includes=[workspace.as_posix()],
205
+ workspace=workspace,
206
+ source_type="benchmark_fixture",
207
+ chunk_size=chunk_size,
208
+ identity_namespace=identity_namespace,
209
+ )
210
+ build_ms = elapsed_ms(started)
211
+ conn = connect(db_path)
212
+ init_db(conn)
213
+ metadata = conn.execute(
214
+ "SELECT config_json FROM index_metadata WHERE singleton = 1"
215
+ ).fetchone()
216
+ conn.close()
217
+ portable_config = json.loads(metadata["config_json"])
218
+ portable_config["workspace"] = "<BENCHMARK_CORPUS>"
219
+ portable_config["includes"] = ["<BENCHMARK_CORPUS>"]
220
+ portable_rendered = json.dumps(
221
+ portable_config,
222
+ sort_keys=True,
223
+ separators=(",", ":"),
224
+ )
225
+ return {
226
+ "build_ms": build_ms,
227
+ "size_bytes": database_size(db_path),
228
+ "documents": build["indexed"],
229
+ "chunks": build["chunks"],
230
+ "config_fingerprint": hashlib.sha256(portable_rendered.encode("utf-8")).hexdigest(),
231
+ "config": portable_config,
232
+ "chunk_size": chunk_size,
233
+ }
234
+
235
+
236
+ def load_benchmark_cases(path: Path) -> list[BenchmarkCase]:
237
+ cases: list[BenchmarkCase] = []
238
+ ids: set[str] = set()
239
+ for line_number, line in enumerate(
240
+ path.read_text(encoding="utf-8").splitlines(), start=1
241
+ ):
242
+ if not line.strip():
243
+ continue
244
+ payload = json.loads(line)
245
+ case = BenchmarkCase(
246
+ id=payload["id"],
247
+ category=payload["category"],
248
+ query=payload["query"],
249
+ expected_source=payload.get("expected_source"),
250
+ expected_heading=payload.get("expected_heading"),
251
+ expected_start_line=payload.get("expected_start_line"),
252
+ expected_end_line=payload.get("expected_end_line"),
253
+ forbidden_sources=tuple(payload.get("forbidden_sources", ())),
254
+ forbidden_terms=tuple(payload.get("forbidden_terms", ())),
255
+ )
256
+ if case.id in ids:
257
+ raise ValueError(f"{path}:{line_number} duplicate case id {case.id}")
258
+ ids.add(case.id)
259
+ cases.append(case)
260
+ if not cases:
261
+ raise ValueError(f"{path} does not contain any cases")
262
+ return cases
263
+
264
+
265
+ def load_redacted_records(
266
+ workspace: Path,
267
+ identity_namespace: str,
268
+ ) -> list[IngestedRecord]:
269
+ records: list[IngestedRecord] = []
270
+ for path in iter_candidate_files([workspace.as_posix()], workspace=workspace):
271
+ record = ingest_file(
272
+ path,
273
+ workspace=workspace,
274
+ source_type="benchmark_fixture",
275
+ identity_namespace=identity_namespace,
276
+ )
277
+ if record:
278
+ records.append(record)
279
+ return records
280
+
281
+
282
+ def exact_phrase_grep(
283
+ records: list[IngestedRecord], query: str, limit: int
284
+ ) -> list[QueryResult]:
285
+ needle = query.casefold().strip()
286
+ if not needle:
287
+ return []
288
+ matches: list[QueryResult] = []
289
+ for record in records:
290
+ text = f"{record.title}\n{record.content}"
291
+ if needle not in text.casefold() and needle not in record.source_path.casefold():
292
+ continue
293
+ result_id = hashlib.sha256(
294
+ f"{record.id}\0exact_phrase_grep\0{record.content_hash}".encode()
295
+ ).hexdigest()
296
+ matches.append(
297
+ QueryResult(
298
+ id=record.id,
299
+ chunk_id=result_id,
300
+ source_type="benchmark_fixture",
301
+ source_path=record.source_path,
302
+ workspace=record.workspace,
303
+ title=record.title,
304
+ heading="",
305
+ start_line=1,
306
+ end_line=max(1, len(text.splitlines())),
307
+ citation=(
308
+ f"{record.source_path}:L1-L{max(1, len(text.splitlines()))}"
309
+ ),
310
+ score=0.0,
311
+ snippet="[exact phrase match]",
312
+ strategy="exact_phrase_grep",
313
+ )
314
+ )
315
+ return matches[:limit]
316
+
317
+
318
+ def evaluate_strategy(
319
+ cases: list[BenchmarkCase],
320
+ workspace: Path,
321
+ retrieve: Callable[[BenchmarkCase], list[QueryResult]],
322
+ ) -> dict[str, Any]:
323
+ rows: list[dict[str, Any]] = []
324
+ latencies: list[float] = []
325
+ for case in cases:
326
+ started = time.perf_counter()
327
+ results = retrieve(case)
328
+ latencies.append(elapsed_ms(started))
329
+ rows.append(evaluate_case(case, results, workspace))
330
+
331
+ positive = [row for row in rows if row["expected_source"] is not None]
332
+ negative = [row for row in rows if row["expected_source"] is None]
333
+ categories: dict[str, list[dict[str, Any]]] = defaultdict(list)
334
+ for row in rows:
335
+ categories[row["category"]].append(row)
336
+
337
+ return {
338
+ "status": "run",
339
+ "metrics": summarize(positive, negative, rows),
340
+ "latency_ms": {
341
+ "median": round(statistics.median(latencies), 3),
342
+ "p95": round(percentile(latencies, 0.95), 3),
343
+ },
344
+ "categories": {
345
+ category: summarize(
346
+ [row for row in category_rows if row["expected_source"] is not None],
347
+ [row for row in category_rows if row["expected_source"] is None],
348
+ category_rows,
349
+ )
350
+ for category, category_rows in sorted(categories.items())
351
+ },
352
+ "cases": rows,
353
+ "failures": [row for row in rows if not row["correct"]],
354
+ }
355
+
356
+
357
+ def evaluate_case(
358
+ case: BenchmarkCase, results: list[QueryResult], workspace: Path
359
+ ) -> dict[str, Any]:
360
+ relative_results = [relative_source(result.source_path, workspace) for result in results]
361
+ expected = case.expected_source
362
+ rank = relative_results.index(expected) + 1 if expected in relative_results else None
363
+ evidence_rank = None
364
+ if expected is not None:
365
+ for index, result in enumerate(results, start=1):
366
+ if relative_source(result.source_path, workspace) != expected:
367
+ continue
368
+ heading_matches = (
369
+ case.expected_heading is None or result.heading == case.expected_heading
370
+ )
371
+ start_matches = (
372
+ case.expected_start_line is None
373
+ or result.start_line == case.expected_start_line
374
+ )
375
+ end_matches = (
376
+ case.expected_end_line is None or result.end_line == case.expected_end_line
377
+ )
378
+ if heading_matches and start_matches and end_matches:
379
+ evidence_rank = index
380
+ break
381
+ no_answer_correct = expected is None and not results
382
+ forbidden_ranks = [
383
+ relative_results.index(source) + 1
384
+ for source in case.forbidden_sources
385
+ if source in relative_results
386
+ ]
387
+ forbidden_source_hit = any(
388
+ rank is None or forbidden_rank <= rank for forbidden_rank in forbidden_ranks
389
+ )
390
+ privacy_leaks = count_privacy_leaks(results, case.forbidden_terms)
391
+ expected_found = no_answer_correct if expected is None else evidence_rank is not None
392
+ correct = expected_found and not forbidden_source_hit and privacy_leaks == 0
393
+ return {
394
+ "id": case.id,
395
+ "category": case.category,
396
+ "query": case.query,
397
+ "expected_source": expected,
398
+ "expected_heading": case.expected_heading,
399
+ "expected_start_line": case.expected_start_line,
400
+ "expected_end_line": case.expected_end_line,
401
+ "top_source": relative_results[0] if relative_results else None,
402
+ "rank": rank,
403
+ "evidence_rank": evidence_rank,
404
+ "no_answer_correct": no_answer_correct,
405
+ "forbidden_source_hit": forbidden_source_hit,
406
+ "privacy_leak_count": privacy_leaks,
407
+ "correct": correct,
408
+ "results": [
409
+ {
410
+ "source": relative_source(result.source_path, workspace),
411
+ "chunk_id": result.chunk_id,
412
+ "heading": result.heading,
413
+ "start_line": result.start_line,
414
+ "end_line": result.end_line,
415
+ "citation": relative_citation(result, workspace),
416
+ }
417
+ for result in results
418
+ ],
419
+ }
420
+
421
+
422
+ def summarize(
423
+ positive: list[dict[str, Any]],
424
+ negative: list[dict[str, Any]],
425
+ rows: list[dict[str, Any]],
426
+ ) -> dict[str, Any]:
427
+ return {
428
+ "cases": len(rows),
429
+ "recall_at_1": ratio(sum(row["rank"] == 1 for row in positive), len(positive)),
430
+ "recall_at_3": ratio(
431
+ sum(row["rank"] is not None and row["rank"] <= 3 for row in positive),
432
+ len(positive),
433
+ ),
434
+ "evidence_recall_at_1": ratio(
435
+ sum(row["evidence_rank"] == 1 for row in positive), len(positive)
436
+ ),
437
+ "evidence_recall_at_3": ratio(
438
+ sum(
439
+ row["evidence_rank"] is not None and row["evidence_rank"] <= 3
440
+ for row in positive
441
+ ),
442
+ len(positive),
443
+ ),
444
+ "mrr": ratio(
445
+ sum(1 / row["rank"] for row in positive if row["rank"]), len(positive)
446
+ ),
447
+ "source_accuracy": ratio(
448
+ sum(row["rank"] == 1 for row in positive), len(positive)
449
+ ),
450
+ "no_answer_precision": ratio(
451
+ sum(row["no_answer_correct"] for row in negative), len(negative)
452
+ ),
453
+ "stale_conflict_accuracy": ratio(
454
+ sum(
455
+ row["rank"] is not None
456
+ and not row["forbidden_source_hit"]
457
+ and row["privacy_leak_count"] == 0
458
+ for row in rows
459
+ if row["category"] == "stale_conflict"
460
+ ),
461
+ sum(row["category"] == "stale_conflict" for row in rows),
462
+ ),
463
+ "privacy_leak_count": sum(row["privacy_leak_count"] for row in rows),
464
+ }
465
+
466
+
467
+ def count_privacy_leaks(results: list[QueryResult], forbidden_terms: tuple[str, ...]) -> int:
468
+ leaks = 0
469
+ for result in results:
470
+ for pattern in SECRET_PATTERNS:
471
+ leaks += sum(
472
+ 1
473
+ for match in pattern.finditer(result.snippet)
474
+ if "[REDACTED]" not in match.group(0)
475
+ )
476
+ leaks += sum(
477
+ term.casefold() in result.snippet.casefold() for term in forbidden_terms
478
+ )
479
+ return leaks
480
+
481
+
482
+ def relative_source(source_path: str, workspace: Path) -> str:
483
+ try:
484
+ return Path(source_path).resolve().relative_to(workspace).as_posix()
485
+ except ValueError:
486
+ return source_path
487
+
488
+
489
+ def relative_citation(result: QueryResult, workspace: Path) -> str:
490
+ source = relative_source(result.source_path, workspace)
491
+ heading = f"#{result.heading}" if result.heading else ""
492
+ return f"{source}{heading}:L{result.start_line}-L{result.end_line}"
493
+
494
+
495
+ def database_size(path: Path) -> int:
496
+ return sum(candidate.stat().st_size for candidate in path.parent.glob(f"{path.name}*"))
497
+
498
+
499
+ def directory_hash(path: Path) -> str:
500
+ digest = hashlib.sha256()
501
+ for candidate in sorted(item for item in path.rglob("*") if item.is_file()):
502
+ digest.update(candidate.relative_to(path).as_posix().encode("utf-8"))
503
+ digest.update(b"\0")
504
+ digest.update(candidate.read_bytes())
505
+ digest.update(b"\0")
506
+ return digest.hexdigest()
507
+
508
+
509
+ def file_hash(path: Path) -> str:
510
+ return hashlib.sha256(path.read_bytes()).hexdigest()
511
+
512
+
513
+ def elapsed_ms(started: float) -> float:
514
+ return round((time.perf_counter() - started) * 1000, 3)
515
+
516
+
517
+ def percentile(values: list[float], percentile_value: float) -> float:
518
+ ordered = sorted(values)
519
+ index = min(len(ordered) - 1, max(0, int(len(ordered) * percentile_value) - 1))
520
+ return ordered[index]
521
+
522
+
523
+ def ratio(numerator: float, denominator: int) -> float:
524
+ return round(numerator / denominator, 4) if denominator else 0.0
@@ -0,0 +1,73 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import sqlite3
5
+ from pathlib import Path
6
+
7
+ from .schema import connect, init_db, require_index
8
+
9
+
10
+ def verify_canonical_source(db_path: Path | str, source_path: Path | str) -> dict[str, object]:
11
+ """Compare an indexed record with the current canonical file on disk."""
12
+ resolved = Path(source_path).expanduser().resolve()
13
+ conn = connect(require_index(db_path))
14
+ init_db(conn)
15
+ try:
16
+ row = conn.execute(
17
+ "SELECT * FROM documents WHERE source_path = ?",
18
+ (resolved.as_posix(),),
19
+ ).fetchone()
20
+ finally:
21
+ conn.close()
22
+
23
+ if row is None:
24
+ return {
25
+ "source_path": resolved.as_posix(),
26
+ "indexed": False,
27
+ "exists": resolved.exists(),
28
+ "content_hash_match": False,
29
+ }
30
+
31
+ current_hash = None
32
+ read_error = None
33
+ if resolved.exists():
34
+ try:
35
+ current_hash = hashlib.sha256(resolved.read_bytes()).hexdigest()
36
+ except OSError as exc:
37
+ read_error = str(exc)
38
+ raw_hash_available = bool(row["source_hash"])
39
+ result: dict[str, object] = {
40
+ "source_path": resolved.as_posix(),
41
+ "indexed": True,
42
+ "exists": resolved.exists(),
43
+ "content_hash_match": current_hash == row["source_hash"] if raw_hash_available else None,
44
+ "verification_available": raw_hash_available,
45
+ "indexed_hash": row["source_hash"] or None,
46
+ "current_hash": current_hash,
47
+ "indexed_at": row["updated_at"],
48
+ }
49
+ if not raw_hash_available:
50
+ result["reason"] = "legacy_raw_hash_unavailable"
51
+ if read_error:
52
+ result["read_error"] = read_error
53
+ return result
54
+
55
+
56
+ def list_canonical_sources(db_path: Path | str, limit: int = 50) -> list[dict[str, object]]:
57
+ conn = connect(require_index(db_path))
58
+ init_db(conn)
59
+ try:
60
+ rows = conn.execute(
61
+ """
62
+ SELECT source_type, source_path, title, workspace, updated_at
63
+ FROM documents
64
+ ORDER BY source_path ASC
65
+ LIMIT ?
66
+ """,
67
+ (limit,),
68
+ ).fetchall()
69
+ except sqlite3.DatabaseError:
70
+ return []
71
+ finally:
72
+ conn.close()
73
+ return [dict(row) for row in rows]