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,298 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sqlite3
6
+ from collections.abc import Mapping
7
+ from pathlib import Path
8
+
9
+ from . import __version__
10
+ from .canonical import list_canonical_sources, verify_canonical_source
11
+ from .config import load_config
12
+ from .eval import evaluate_gates, run_eval
13
+ from .index import build_index, update_index
14
+ from .query import query_memory
15
+ from .schema import DEFAULT_DB_PATH, connect, fts5_available, init_db, schema_version
16
+ from .server import serve_stdio
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ parser = argparse.ArgumentParser(prog="bam", description="Boring Agent Memory")
21
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
22
+ parser.add_argument("--db", default=None, help="SQLite database path")
23
+ subparsers = parser.add_subparsers(dest="command", required=True)
24
+
25
+ init_parser = subparsers.add_parser("init", help="Initialize the local database")
26
+ init_parser.add_argument("--json", action="store_true")
27
+
28
+ build_parser = subparsers.add_parser("build", help="Build a local BM25 index")
29
+ build_parser.add_argument("--config", help="YAML, TOML, or JSON memory config")
30
+ build_parser.add_argument("--include", action="append", default=[], help="File or directory to index")
31
+ build_parser.add_argument("--exclude", action="append", default=[], help="Glob to exclude")
32
+ build_parser.add_argument("--workspace", default=None, help="Workspace root for relative includes")
33
+ build_parser.add_argument("--source-type", default=None)
34
+ build_parser.add_argument("--max-bytes", type=int, default=None)
35
+ build_parser.add_argument("--chunk-size", type=int, default=None)
36
+ build_parser.add_argument("--json", action="store_true")
37
+
38
+ update_parser = subparsers.add_parser("update", help="Incrementally update a local index")
39
+ update_parser.add_argument("--config", help="YAML, TOML, or JSON memory config")
40
+ update_parser.add_argument("--include", action="append", default=[], help="File or directory to index")
41
+ update_parser.add_argument("--exclude", action="append", default=[], help="Glob to exclude")
42
+ update_parser.add_argument("--workspace", default=None, help="Workspace root for relative includes")
43
+ update_parser.add_argument("--source-type", default=None)
44
+ update_parser.add_argument("--max-bytes", type=int, default=None)
45
+ update_parser.add_argument("--chunk-size", type=int, default=None)
46
+ update_parser.add_argument("--dry-run", action="store_true")
47
+ update_parser.add_argument("--json", action="store_true")
48
+
49
+ query_parser = subparsers.add_parser("query", help="Query the local memory index")
50
+ query_parser.add_argument("query")
51
+ query_parser.add_argument("--limit", type=int, default=5)
52
+ query_parser.add_argument("--source-type", default=None)
53
+ query_parser.add_argument("--workspace", default=None)
54
+ query_parser.add_argument("--json", action="store_true")
55
+
56
+ status_parser = subparsers.add_parser("status", help="Show index status")
57
+ status_parser.add_argument("--json", action="store_true")
58
+
59
+ inspect_parser = subparsers.add_parser("inspect", help="Inspect indexed canonical sources")
60
+ inspect_parser.add_argument("source_path", nargs="?", help="Canonical source path to verify")
61
+ inspect_parser.add_argument("--limit", type=int, default=50)
62
+ inspect_parser.add_argument("--json", action="store_true")
63
+
64
+ health_parser = subparsers.add_parser("health", help="Run local health checks")
65
+ health_parser.add_argument("--json", action="store_true")
66
+
67
+ eval_parser = subparsers.add_parser("eval", help="Run deterministic memory retrieval evals")
68
+ eval_parser.add_argument(
69
+ "--fixture",
70
+ "--fixtures",
71
+ dest="fixtures",
72
+ default="evals/fixtures",
73
+ help="Directory containing eval fixture files",
74
+ )
75
+ eval_parser.add_argument(
76
+ "--golden",
77
+ default="evals/golden.jsonl",
78
+ help="JSONL file with golden eval cases",
79
+ )
80
+ eval_parser.add_argument("--limit", type=int, default=3)
81
+ eval_parser.add_argument("--min-recall-at-1", type=float, default=None)
82
+ eval_parser.add_argument("--min-recall-at-3", type=float, default=None)
83
+ eval_parser.add_argument("--min-source-accuracy", type=float, default=None)
84
+ eval_parser.add_argument("--min-snippet-term-rate", type=float, default=None)
85
+ eval_parser.add_argument("--min-stale-detection-rate", type=float, default=None)
86
+ eval_parser.add_argument("--max-privacy-leaks", type=int, default=None)
87
+ eval_parser.add_argument("--json", action="store_true")
88
+
89
+ serve_parser = subparsers.add_parser("serve", help="Serve a tiny JSON-lines query interface")
90
+ serve_parser.add_argument("--stdio", action="store_true", help="Read JSON lines from stdin")
91
+
92
+ args = parser.parse_args(argv)
93
+ db_path = Path(args.db).expanduser() if args.db else DEFAULT_DB_PATH
94
+
95
+ if args.command == "init":
96
+ conn = connect(db_path)
97
+ init_db(conn)
98
+ ok = fts5_available(conn)
99
+ conn.close()
100
+ return emit({"database": db_path.as_posix(), "fts5": ok}, args.json)
101
+
102
+ if args.command in {"build", "update"}:
103
+ try:
104
+ config = load_config(args.config) if args.config else None
105
+ except (OSError, ValueError) as exc:
106
+ parser.error(str(exc))
107
+ db_path = Path(args.db).expanduser() if args.db else (config.index_path if config else DEFAULT_DB_PATH)
108
+ includes = [*(config.include if config else ()), *args.include]
109
+ excludes = [*(config.exclude if config else ()), *args.exclude]
110
+ workspace = args.workspace or (config.workspace if config else None)
111
+ source_type = args.source_type or (config.source_type if config else "file")
112
+ max_bytes = args.max_bytes if args.max_bytes is not None else (config.max_bytes if config else 512 * 1024)
113
+ chunk_size = args.chunk_size if args.chunk_size is not None else (config.chunk_size if config else 1600)
114
+ if not includes:
115
+ parser.error(f"{args.command} requires --include or --config with include paths")
116
+ stats: dict[str, object]
117
+ if args.command == "build":
118
+ try:
119
+ stats = dict(
120
+ build_index(
121
+ db_path=db_path,
122
+ includes=list(includes),
123
+ excludes=list(excludes),
124
+ workspace=workspace,
125
+ source_type=source_type,
126
+ max_bytes=max_bytes,
127
+ chunk_size=chunk_size,
128
+ )
129
+ )
130
+ except (OSError, ValueError, sqlite3.DatabaseError) as exc:
131
+ parser.error(str(exc))
132
+ else:
133
+ try:
134
+ stats = update_index(
135
+ db_path=db_path,
136
+ includes=list(includes),
137
+ excludes=list(excludes),
138
+ workspace=workspace,
139
+ source_type=source_type,
140
+ max_bytes=max_bytes,
141
+ chunk_size=chunk_size,
142
+ dry_run=args.dry_run,
143
+ )
144
+ except (OSError, ValueError, sqlite3.DatabaseError) as exc:
145
+ parser.error(str(exc))
146
+ return emit({"database": db_path.as_posix(), **stats}, args.json)
147
+
148
+ if args.command == "query":
149
+ try:
150
+ results = [
151
+ result.to_dict()
152
+ for result in query_memory(
153
+ db_path=db_path,
154
+ query=args.query,
155
+ limit=args.limit,
156
+ source_type=args.source_type,
157
+ workspace=args.workspace,
158
+ )
159
+ ]
160
+ except FileNotFoundError as exc:
161
+ parser.error(str(exc))
162
+ if args.json:
163
+ print(json.dumps({"query": args.query, "results": results}, indent=2, ensure_ascii=False))
164
+ else:
165
+ print_results(results)
166
+ return 0
167
+
168
+ if args.command == "status":
169
+ return emit(status(db_path), args.json)
170
+
171
+ if args.command == "inspect":
172
+ try:
173
+ if args.source_path:
174
+ return emit(verify_canonical_source(db_path, args.source_path), args.json)
175
+ payload: dict[str, object] = {
176
+ "database": db_path.as_posix(),
177
+ "sources": list_canonical_sources(db_path, limit=args.limit),
178
+ }
179
+ except FileNotFoundError as exc:
180
+ parser.error(str(exc))
181
+ return emit(payload, args.json)
182
+
183
+ if args.command == "health":
184
+ health = status(db_path)
185
+ health["sqlite_fts5"] = _sqlite_fts5_health()
186
+ health["ok"] = health["sqlite_fts5"] and health["database_exists"]
187
+ return emit(health, args.json)
188
+
189
+ if args.command == "eval":
190
+ try:
191
+ report = run_eval(fixture_dir=args.fixtures, golden_path=args.golden, limit=args.limit)
192
+ except FileNotFoundError as exc:
193
+ parser.error(f"eval fixtures not found: {exc}; run from a repository checkout or pass --fixtures and --golden")
194
+ failures = evaluate_gates(
195
+ report,
196
+ min_recall_at_1=args.min_recall_at_1,
197
+ min_recall_at_3=args.min_recall_at_3,
198
+ min_source_accuracy=args.min_source_accuracy,
199
+ min_snippet_term_rate=args.min_snippet_term_rate,
200
+ min_stale_detection_rate=args.min_stale_detection_rate,
201
+ max_privacy_leaks=args.max_privacy_leaks,
202
+ )
203
+ report["passed"] = not failures
204
+ report["failures"] = failures
205
+ if args.json:
206
+ print(json.dumps(report, indent=2, ensure_ascii=False))
207
+ else:
208
+ print_eval_report(report)
209
+ return 0 if not failures else 1
210
+
211
+ if args.command == "serve":
212
+ if not args.stdio:
213
+ parser.error("serve currently requires --stdio")
214
+ return serve_stdio(db_path)
215
+
216
+ parser.error("unknown command")
217
+ return 2
218
+
219
+
220
+ def status(db_path: Path) -> dict[str, object]:
221
+ if not db_path.exists():
222
+ return {
223
+ "database": db_path.as_posix(),
224
+ "database_exists": False,
225
+ "records": 0,
226
+ "documents": 0,
227
+ "chunks": 0,
228
+ "schema_version": None,
229
+ "config_fingerprint": None,
230
+ "source_types": {},
231
+ }
232
+ conn = connect(db_path)
233
+ init_db(conn)
234
+ documents = conn.execute("SELECT count(*) FROM documents").fetchone()[0]
235
+ chunks = conn.execute("SELECT count(*) FROM chunks").fetchone()[0]
236
+ rows = conn.execute(
237
+ "SELECT source_type, count(*) AS count FROM documents GROUP BY source_type ORDER BY source_type"
238
+ ).fetchall()
239
+ metadata = conn.execute("SELECT config_fingerprint FROM index_metadata WHERE singleton = 1").fetchone()
240
+ version = schema_version(conn)
241
+ conn.close()
242
+ return {
243
+ "database": db_path.as_posix(),
244
+ "database_exists": True,
245
+ "records": documents,
246
+ "documents": documents,
247
+ "chunks": chunks,
248
+ "schema_version": version,
249
+ "config_fingerprint": metadata["config_fingerprint"] if metadata else None,
250
+ "source_types": {row["source_type"]: row["count"] for row in rows},
251
+ }
252
+
253
+
254
+ def _sqlite_fts5_health() -> bool:
255
+ conn = sqlite3.connect(":memory:")
256
+ try:
257
+ return fts5_available(conn)
258
+ finally:
259
+ conn.close()
260
+
261
+
262
+ def emit(payload: Mapping[str, object], as_json: bool) -> int:
263
+ if as_json:
264
+ print(json.dumps(payload, indent=2, ensure_ascii=False))
265
+ else:
266
+ for key, value in payload.items():
267
+ print(f"{key}: {value}")
268
+ return 0
269
+
270
+
271
+ def print_results(results: list[dict[str, object]]) -> None:
272
+ for result in results:
273
+ print(f"{result['title']} score={result['score']}")
274
+ print(f" {result['source_path']}")
275
+ print(f" {result['snippet']}")
276
+
277
+
278
+ def print_eval_report(report: dict[str, object]) -> None:
279
+ metrics = report["metrics"]
280
+ assert isinstance(metrics, dict)
281
+ print(f"cases: {metrics['cases']}")
282
+ print(f"recall_at_1: {metrics['recall_at_1']:.3f}")
283
+ print(f"recall_at_3: {metrics['recall_at_3']:.3f}")
284
+ print(f"mrr: {metrics['mrr']:.3f}")
285
+ print(f"source_accuracy: {metrics['source_accuracy']:.3f}")
286
+ print(f"snippet_term_rate: {metrics['snippet_term_rate']:.3f}")
287
+ print(f"privacy_leak_count: {metrics['privacy_leak_count']}")
288
+ print(f"stale_detection_rate: {metrics['stale_detection_rate']:.3f}")
289
+ failures = report.get("failures", [])
290
+ if failures:
291
+ print("failures:")
292
+ assert isinstance(failures, list)
293
+ for failure in failures:
294
+ print(f"- {failure}")
295
+
296
+
297
+ if __name__ == "__main__":
298
+ raise SystemExit(main())
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import tomllib
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from .chunking import DEFAULT_CHUNK_SIZE
10
+ from .schema import DEFAULT_DB_PATH
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class MemoryConfig:
15
+ index_path: Path = DEFAULT_DB_PATH
16
+ include: tuple[str, ...] = ()
17
+ exclude: tuple[str, ...] = ()
18
+ workspace: Path | None = None
19
+ source_type: str = "file"
20
+ max_file_size_kb: int = 512
21
+ chunk_size: int = DEFAULT_CHUNK_SIZE
22
+
23
+ @property
24
+ def max_bytes(self) -> int:
25
+ return self.max_file_size_kb * 1024
26
+
27
+
28
+ def load_config(path: Path | str) -> MemoryConfig:
29
+ config_path = Path(path).expanduser()
30
+ raw = _load_mapping(config_path)
31
+ base_dir = config_path.resolve().parent
32
+ privacy_value = raw.get("privacy")
33
+ privacy: dict[str, Any] = privacy_value if isinstance(privacy_value, dict) else {}
34
+
35
+ index_path = _path_value(raw.get("index_path", raw.get("database")), DEFAULT_DB_PATH, base_dir)
36
+ workspace = _optional_path_value(raw.get("workspace"), base_dir)
37
+ if "max_bytes" in raw:
38
+ max_file_size_kb = max(1, int(raw["max_bytes"]) // 1024)
39
+ else:
40
+ max_file_size_kb = int(privacy.get("max_file_size_kb", raw.get("max_file_size_kb", 512)))
41
+
42
+ return MemoryConfig(
43
+ index_path=index_path,
44
+ include=tuple(_list_value(raw.get("include"))),
45
+ exclude=tuple(_list_value(raw.get("exclude"))),
46
+ workspace=workspace,
47
+ source_type=str(raw.get("source_type", "file")),
48
+ max_file_size_kb=max_file_size_kb,
49
+ chunk_size=int(raw.get("chunk_size", DEFAULT_CHUNK_SIZE)),
50
+ )
51
+
52
+
53
+ def _load_mapping(path: Path) -> dict[str, Any]:
54
+ text = path.read_text(encoding="utf-8")
55
+ suffix = path.suffix.lower()
56
+ if suffix == ".json":
57
+ data = json.loads(text)
58
+ elif suffix == ".toml":
59
+ data = tomllib.loads(text)
60
+ elif suffix in {".yaml", ".yml"}:
61
+ data = _parse_simple_yaml(text)
62
+ else:
63
+ raise ValueError(f"Unsupported config format: {path.suffix}")
64
+ if not isinstance(data, dict):
65
+ raise ValueError("Config root must be a mapping") # noqa: TRY004
66
+ return data
67
+
68
+
69
+ def _parse_simple_yaml(text: str) -> dict[str, Any]:
70
+ """Parse the small YAML subset used by BAM examples without a dependency."""
71
+ data: dict[str, Any] = {}
72
+ current_key: str | None = None
73
+
74
+ for line_number, raw_line in enumerate(text.splitlines(), start=1):
75
+ line = _strip_comment(raw_line).rstrip()
76
+ if not line.strip():
77
+ continue
78
+
79
+ stripped = line.strip()
80
+ if not line.startswith(" ") and ":" in stripped and not stripped.startswith("- "):
81
+ key, value = stripped.split(":", 1)
82
+ key = key.strip()
83
+ value = value.strip()
84
+ if value:
85
+ data[key] = _scalar(value)
86
+ current_key = None
87
+ else:
88
+ data[key] = {}
89
+ current_key = key
90
+ continue
91
+
92
+ if stripped.startswith("- ") and current_key:
93
+ if data[current_key] == {}:
94
+ data[current_key] = []
95
+ if not isinstance(data[current_key], list):
96
+ raise ValueError(f"unsupported YAML on line {line_number}: {current_key} mixes keys and list items")
97
+ data[current_key].append(_scalar(stripped[2:].strip()))
98
+ continue
99
+
100
+ if current_key and isinstance(data.get(current_key), dict) and ":" in stripped:
101
+ key, value = stripped.split(":", 1)
102
+ data[current_key][key.strip()] = _scalar(value.strip())
103
+ continue
104
+
105
+ raise ValueError(f"unsupported YAML on line {line_number}: {stripped}")
106
+
107
+ return data
108
+
109
+
110
+ def _strip_comment(line: str) -> str:
111
+ quote = ""
112
+ for index, char in enumerate(line):
113
+ starts_token = index == 0 or line[index - 1].isspace()
114
+ if quote:
115
+ if char == quote:
116
+ quote = ""
117
+ elif char in {"'", '"'} and starts_token:
118
+ quote = char
119
+ elif char == "#" and starts_token:
120
+ return line[:index]
121
+ return line
122
+
123
+
124
+ def _scalar(value: str) -> Any:
125
+ value = value.strip()
126
+ if value[:1] in {"[", "{"}:
127
+ raise ValueError(f"unsupported YAML flow syntax: {value}; use a block list")
128
+ if value[:1] in {"'", '"'}:
129
+ inner = value[1:-1]
130
+ if len(value) < 2 or value[-1] != value[0] or "''" in inner or (value[0] == '"' and "\\" in inner):
131
+ raise ValueError(f"unsupported YAML quoting: {value}")
132
+ return inner
133
+ if value.lower() in {"true", "false"}:
134
+ return value.lower() == "true"
135
+ try:
136
+ return int(value)
137
+ except ValueError:
138
+ return value
139
+
140
+
141
+ def _path_value(value: Any, default: Path, base_dir: Path) -> Path:
142
+ if value is None:
143
+ return default
144
+ path = Path(str(value)).expanduser()
145
+ return path if path.is_absolute() else base_dir / path
146
+
147
+
148
+ def _optional_path_value(value: Any, base_dir: Path) -> Path | None:
149
+ if value in {None, ""}:
150
+ return None
151
+ path = Path(str(value)).expanduser()
152
+ return path if path.is_absolute() else base_dir / path
153
+
154
+
155
+ def _list_value(value: Any) -> list[str]:
156
+ if value is None:
157
+ return []
158
+ if isinstance(value, str):
159
+ return [value]
160
+ if isinstance(value, list):
161
+ return [str(item) for item in value]
162
+ raise ValueError("Expected a string or list")