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,425 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import unicodedata
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import dataclass, replace
|
|
9
|
+
|
|
10
|
+
CHUNKER_VERSION = 2
|
|
11
|
+
DEFAULT_CHUNK_SIZE = 1600
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class TextChunk:
|
|
16
|
+
id: str
|
|
17
|
+
heading: str
|
|
18
|
+
heading_key: str
|
|
19
|
+
ordinal: int
|
|
20
|
+
start_line: int
|
|
21
|
+
end_line: int
|
|
22
|
+
content: str
|
|
23
|
+
content_hash: str
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class _Block:
|
|
28
|
+
heading: str
|
|
29
|
+
heading_key: str
|
|
30
|
+
start_line: int
|
|
31
|
+
end_line: int
|
|
32
|
+
content: str
|
|
33
|
+
protected: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*#*\s*$")
|
|
37
|
+
FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
|
|
38
|
+
LIST_RE = re.compile(r"^\s*(?:[-+*]|\d+[.)])\s+")
|
|
39
|
+
TABLE_SEPARATOR_RE = re.compile(
|
|
40
|
+
r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$"
|
|
41
|
+
)
|
|
42
|
+
BLOCKQUOTE_RE = re.compile(r"^\s*>\s?")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def chunk_text(
|
|
46
|
+
document_id: str,
|
|
47
|
+
content: str,
|
|
48
|
+
extension: str,
|
|
49
|
+
max_chars: int = DEFAULT_CHUNK_SIZE,
|
|
50
|
+
) -> tuple[TextChunk, ...]:
|
|
51
|
+
"""Split source text into deterministic, citation-ready chunks."""
|
|
52
|
+
if max_chars <= 0:
|
|
53
|
+
return (_make_chunk(document_id, "", "document", 0, 1, _line_count(content), content),)
|
|
54
|
+
|
|
55
|
+
if extension.lower() in {".md", ".mdx"}:
|
|
56
|
+
blocks = _markdown_blocks(content)
|
|
57
|
+
else:
|
|
58
|
+
blocks = _plain_blocks(content)
|
|
59
|
+
if not blocks:
|
|
60
|
+
blocks = [_Block("", "document", 1, 1, content)]
|
|
61
|
+
|
|
62
|
+
chunks: list[TextChunk] = []
|
|
63
|
+
section_ordinals: dict[str, int] = {}
|
|
64
|
+
identity_keys: list[tuple[str, str]] = []
|
|
65
|
+
pending: list[_Block] = []
|
|
66
|
+
pending_size = 0
|
|
67
|
+
pending_key: str | None = None
|
|
68
|
+
|
|
69
|
+
def flush() -> None:
|
|
70
|
+
nonlocal pending, pending_size, pending_key
|
|
71
|
+
if not pending:
|
|
72
|
+
return
|
|
73
|
+
key = pending[0].heading_key
|
|
74
|
+
ordinal = section_ordinals.get(key, 0)
|
|
75
|
+
section_ordinals[key] = ordinal + 1
|
|
76
|
+
chunk_content = "\n\n".join(block.content for block in pending).strip()
|
|
77
|
+
identity_key = (_semantic_heading_key(key), chunk_content)
|
|
78
|
+
identity_keys.append(identity_key)
|
|
79
|
+
chunks.append(
|
|
80
|
+
_make_chunk(
|
|
81
|
+
document_id=document_id,
|
|
82
|
+
heading=pending[0].heading,
|
|
83
|
+
heading_key=key,
|
|
84
|
+
ordinal=ordinal,
|
|
85
|
+
start_line=pending[0].start_line,
|
|
86
|
+
end_line=pending[-1].end_line,
|
|
87
|
+
content=chunk_content,
|
|
88
|
+
)
|
|
89
|
+
)
|
|
90
|
+
pending = []
|
|
91
|
+
pending_size = 0
|
|
92
|
+
pending_key = None
|
|
93
|
+
|
|
94
|
+
for original in blocks:
|
|
95
|
+
for block in _split_oversized_plain_block(original, max_chars):
|
|
96
|
+
addition = len(block.content) + (2 if pending else 0)
|
|
97
|
+
if pending and (block.heading_key != pending_key or pending_size + addition > max_chars):
|
|
98
|
+
flush()
|
|
99
|
+
pending.append(block)
|
|
100
|
+
pending_key = block.heading_key
|
|
101
|
+
pending_size += len(block.content) + (2 if len(pending) > 1 else 0)
|
|
102
|
+
if block.protected and len(block.content) > max_chars:
|
|
103
|
+
flush()
|
|
104
|
+
flush()
|
|
105
|
+
identity_totals = Counter(identity_keys)
|
|
106
|
+
duplicate_structure = hashlib.sha256(
|
|
107
|
+
json.dumps(
|
|
108
|
+
identity_keys,
|
|
109
|
+
ensure_ascii=False,
|
|
110
|
+
separators=(",", ":"),
|
|
111
|
+
).encode("utf-8")
|
|
112
|
+
).hexdigest()
|
|
113
|
+
return tuple(
|
|
114
|
+
replace(
|
|
115
|
+
chunk,
|
|
116
|
+
id=_chunk_id(
|
|
117
|
+
document_id,
|
|
118
|
+
chunk.heading_key,
|
|
119
|
+
chunk.content,
|
|
120
|
+
identity_occurrence,
|
|
121
|
+
identity_totals[identity_key],
|
|
122
|
+
(
|
|
123
|
+
duplicate_structure
|
|
124
|
+
if identity_totals[identity_key] > 1
|
|
125
|
+
else None
|
|
126
|
+
),
|
|
127
|
+
),
|
|
128
|
+
)
|
|
129
|
+
for chunk, identity_key, identity_occurrence in _identity_occurrence_rows(
|
|
130
|
+
chunks,
|
|
131
|
+
identity_keys,
|
|
132
|
+
)
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _markdown_blocks(content: str) -> list[_Block]:
|
|
137
|
+
lines = content.splitlines()
|
|
138
|
+
blocks: list[_Block] = []
|
|
139
|
+
heading_stack: list[tuple[int, str, str]] = []
|
|
140
|
+
heading_occurrences: dict[str, int] = {}
|
|
141
|
+
index = 0
|
|
142
|
+
|
|
143
|
+
while index < len(lines):
|
|
144
|
+
line = lines[index]
|
|
145
|
+
if not line.strip():
|
|
146
|
+
index += 1
|
|
147
|
+
continue
|
|
148
|
+
|
|
149
|
+
heading_match = HEADING_RE.match(line)
|
|
150
|
+
if heading_match:
|
|
151
|
+
level = len(heading_match.group(1))
|
|
152
|
+
text = heading_match.group(2).strip()
|
|
153
|
+
heading_stack = [item for item in heading_stack if item[0] < level]
|
|
154
|
+
parent_key = "/".join(item[2] for item in heading_stack)
|
|
155
|
+
base_key = f"{parent_key}/{_slug(text)}".strip("/")
|
|
156
|
+
occurrence = heading_occurrences.get(base_key, 0) + 1
|
|
157
|
+
heading_occurrences[base_key] = occurrence
|
|
158
|
+
local_key = f"{_slug(text)}@{occurrence}"
|
|
159
|
+
heading_stack.append((level, text, local_key))
|
|
160
|
+
heading, heading_key = _heading_context(heading_stack)
|
|
161
|
+
blocks.append(_Block(heading, heading_key, index + 1, index + 1, line, True))
|
|
162
|
+
index += 1
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
heading, heading_key = _heading_context(heading_stack)
|
|
166
|
+
fence_match = FENCE_RE.match(line)
|
|
167
|
+
if fence_match:
|
|
168
|
+
marker = fence_match.group(1)
|
|
169
|
+
fence_char = marker[0]
|
|
170
|
+
start = index
|
|
171
|
+
index += 1
|
|
172
|
+
while index < len(lines):
|
|
173
|
+
if re.match(rf"^\s{{0,3}}{re.escape(fence_char)}{{{len(marker)},}}\s*$", lines[index]):
|
|
174
|
+
index += 1
|
|
175
|
+
break
|
|
176
|
+
index += 1
|
|
177
|
+
blocks.append(
|
|
178
|
+
_Block(
|
|
179
|
+
heading,
|
|
180
|
+
heading_key,
|
|
181
|
+
start + 1,
|
|
182
|
+
index,
|
|
183
|
+
"\n".join(lines[start:index]),
|
|
184
|
+
True,
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
if _starts_table(lines, index):
|
|
190
|
+
start = index
|
|
191
|
+
index += 2
|
|
192
|
+
while index < len(lines) and lines[index].strip() and "|" in lines[index]:
|
|
193
|
+
index += 1
|
|
194
|
+
blocks.append(
|
|
195
|
+
_Block(heading, heading_key, start + 1, index, "\n".join(lines[start:index]), True)
|
|
196
|
+
)
|
|
197
|
+
continue
|
|
198
|
+
|
|
199
|
+
if LIST_RE.match(line) or BLOCKQUOTE_RE.match(line):
|
|
200
|
+
matcher = LIST_RE if LIST_RE.match(line) else BLOCKQUOTE_RE
|
|
201
|
+
start = index
|
|
202
|
+
index += 1
|
|
203
|
+
while index < len(lines) and lines[index].strip():
|
|
204
|
+
if HEADING_RE.match(lines[index]) or FENCE_RE.match(lines[index]):
|
|
205
|
+
break
|
|
206
|
+
if matcher is BLOCKQUOTE_RE and not BLOCKQUOTE_RE.match(lines[index]):
|
|
207
|
+
break
|
|
208
|
+
index += 1
|
|
209
|
+
blocks.append(
|
|
210
|
+
_Block(heading, heading_key, start + 1, index, "\n".join(lines[start:index]), True)
|
|
211
|
+
)
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
start = index
|
|
215
|
+
index += 1
|
|
216
|
+
while index < len(lines) and lines[index].strip():
|
|
217
|
+
if (
|
|
218
|
+
HEADING_RE.match(lines[index])
|
|
219
|
+
or FENCE_RE.match(lines[index])
|
|
220
|
+
or LIST_RE.match(lines[index])
|
|
221
|
+
or BLOCKQUOTE_RE.match(lines[index])
|
|
222
|
+
or _starts_table(lines, index)
|
|
223
|
+
):
|
|
224
|
+
break
|
|
225
|
+
index += 1
|
|
226
|
+
blocks.append(_Block(heading, heading_key, start + 1, index, "\n".join(lines[start:index])))
|
|
227
|
+
return blocks
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _plain_blocks(content: str) -> list[_Block]:
|
|
231
|
+
lines = content.splitlines()
|
|
232
|
+
if not lines:
|
|
233
|
+
return [_Block("", "document", 1, 1, content)]
|
|
234
|
+
blocks: list[_Block] = []
|
|
235
|
+
start = 0
|
|
236
|
+
for index, line in enumerate(lines + [""]):
|
|
237
|
+
if line.strip():
|
|
238
|
+
continue
|
|
239
|
+
if index > start:
|
|
240
|
+
blocks.append(
|
|
241
|
+
_Block("", "document", start + 1, index, "\n".join(lines[start:index]))
|
|
242
|
+
)
|
|
243
|
+
start = index + 1
|
|
244
|
+
return blocks
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _split_oversized_plain_block(block: _Block, max_chars: int) -> list[_Block]:
|
|
248
|
+
if block.protected or len(block.content) <= max_chars:
|
|
249
|
+
return [block]
|
|
250
|
+
lines = block.content.splitlines() or [block.content]
|
|
251
|
+
output: list[_Block] = []
|
|
252
|
+
current: list[str] = []
|
|
253
|
+
current_start = block.start_line
|
|
254
|
+
|
|
255
|
+
def append_current(end_line: int) -> None:
|
|
256
|
+
nonlocal current, current_start
|
|
257
|
+
if current:
|
|
258
|
+
output.append(
|
|
259
|
+
_Block(
|
|
260
|
+
block.heading,
|
|
261
|
+
block.heading_key,
|
|
262
|
+
current_start,
|
|
263
|
+
end_line,
|
|
264
|
+
"\n".join(current),
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
current = []
|
|
268
|
+
|
|
269
|
+
for offset, line in enumerate(lines):
|
|
270
|
+
line_number = block.start_line + offset
|
|
271
|
+
if len(line) > max_chars:
|
|
272
|
+
append_current(line_number - 1)
|
|
273
|
+
words = line.split()
|
|
274
|
+
piece: list[str] = []
|
|
275
|
+
for word in words:
|
|
276
|
+
if piece and len(" ".join([*piece, word])) > max_chars:
|
|
277
|
+
output.append(
|
|
278
|
+
_Block(
|
|
279
|
+
block.heading,
|
|
280
|
+
block.heading_key,
|
|
281
|
+
line_number,
|
|
282
|
+
line_number,
|
|
283
|
+
" ".join(piece),
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
piece = []
|
|
287
|
+
if len(word) > max_chars:
|
|
288
|
+
if piece:
|
|
289
|
+
output.append(
|
|
290
|
+
_Block(
|
|
291
|
+
block.heading,
|
|
292
|
+
block.heading_key,
|
|
293
|
+
line_number,
|
|
294
|
+
line_number,
|
|
295
|
+
" ".join(piece),
|
|
296
|
+
)
|
|
297
|
+
)
|
|
298
|
+
piece = []
|
|
299
|
+
output.extend(
|
|
300
|
+
_Block(
|
|
301
|
+
block.heading,
|
|
302
|
+
block.heading_key,
|
|
303
|
+
line_number,
|
|
304
|
+
line_number,
|
|
305
|
+
word[start : start + max_chars],
|
|
306
|
+
)
|
|
307
|
+
for start in range(0, len(word), max_chars)
|
|
308
|
+
)
|
|
309
|
+
else:
|
|
310
|
+
piece.append(word)
|
|
311
|
+
if piece:
|
|
312
|
+
output.append(
|
|
313
|
+
_Block(
|
|
314
|
+
block.heading,
|
|
315
|
+
block.heading_key,
|
|
316
|
+
line_number,
|
|
317
|
+
line_number,
|
|
318
|
+
" ".join(piece),
|
|
319
|
+
)
|
|
320
|
+
)
|
|
321
|
+
current_start = line_number + 1
|
|
322
|
+
continue
|
|
323
|
+
candidate = "\n".join([*current, line])
|
|
324
|
+
if current and len(candidate) > max_chars:
|
|
325
|
+
append_current(line_number - 1)
|
|
326
|
+
current_start = line_number
|
|
327
|
+
current.append(line)
|
|
328
|
+
append_current(block.end_line)
|
|
329
|
+
return output
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _make_chunk(
|
|
333
|
+
document_id: str,
|
|
334
|
+
heading: str,
|
|
335
|
+
heading_key: str,
|
|
336
|
+
ordinal: int,
|
|
337
|
+
start_line: int,
|
|
338
|
+
end_line: int,
|
|
339
|
+
content: str,
|
|
340
|
+
identity_ordinal: int = 0,
|
|
341
|
+
identity_total: int = 1,
|
|
342
|
+
duplicate_structure: str | None = None,
|
|
343
|
+
) -> TextChunk:
|
|
344
|
+
chunk_id = _chunk_id(
|
|
345
|
+
document_id,
|
|
346
|
+
heading_key,
|
|
347
|
+
content,
|
|
348
|
+
identity_ordinal,
|
|
349
|
+
identity_total,
|
|
350
|
+
duplicate_structure,
|
|
351
|
+
)
|
|
352
|
+
return TextChunk(
|
|
353
|
+
id=chunk_id,
|
|
354
|
+
heading=heading,
|
|
355
|
+
heading_key=heading_key,
|
|
356
|
+
ordinal=ordinal,
|
|
357
|
+
start_line=start_line,
|
|
358
|
+
end_line=end_line,
|
|
359
|
+
content=content,
|
|
360
|
+
content_hash=hashlib.sha256(content.encode("utf-8")).hexdigest(),
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _chunk_id(
|
|
365
|
+
document_id: str,
|
|
366
|
+
heading_key: str,
|
|
367
|
+
content: str,
|
|
368
|
+
identity_ordinal: int,
|
|
369
|
+
identity_total: int,
|
|
370
|
+
duplicate_structure: str | None,
|
|
371
|
+
) -> str:
|
|
372
|
+
stable_key = json.dumps(
|
|
373
|
+
[
|
|
374
|
+
document_id,
|
|
375
|
+
CHUNKER_VERSION,
|
|
376
|
+
_semantic_heading_key(heading_key),
|
|
377
|
+
content,
|
|
378
|
+
identity_ordinal,
|
|
379
|
+
identity_total,
|
|
380
|
+
duplicate_structure,
|
|
381
|
+
],
|
|
382
|
+
ensure_ascii=False,
|
|
383
|
+
separators=(",", ":"),
|
|
384
|
+
)
|
|
385
|
+
return hashlib.sha256(stable_key.encode("utf-8")).hexdigest()
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _identity_occurrence_rows(
|
|
389
|
+
chunks: list[TextChunk],
|
|
390
|
+
identity_keys: list[tuple[str, str]],
|
|
391
|
+
) -> list[tuple[TextChunk, tuple[str, str], int]]:
|
|
392
|
+
occurrences: dict[tuple[str, str], int] = {}
|
|
393
|
+
rows: list[tuple[TextChunk, tuple[str, str], int]] = []
|
|
394
|
+
for chunk, identity_key in zip(chunks, identity_keys):
|
|
395
|
+
occurrence = occurrences.get(identity_key, 0)
|
|
396
|
+
occurrences[identity_key] = occurrence + 1
|
|
397
|
+
rows.append((chunk, identity_key, occurrence))
|
|
398
|
+
return rows
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _heading_context(stack: list[tuple[int, str, str]]) -> tuple[str, str]:
|
|
402
|
+
if not stack:
|
|
403
|
+
return "", "document"
|
|
404
|
+
return " > ".join(item[1] for item in stack), "/".join(item[2] for item in stack)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _starts_table(lines: list[str], index: int) -> bool:
|
|
408
|
+
return (
|
|
409
|
+
index + 1 < len(lines)
|
|
410
|
+
and "|" in lines[index]
|
|
411
|
+
and bool(TABLE_SEPARATOR_RE.match(lines[index + 1]))
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _slug(value: str) -> str:
|
|
416
|
+
normalized = unicodedata.normalize("NFC", value) or "section"
|
|
417
|
+
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _semantic_heading_key(heading_key: str) -> str:
|
|
421
|
+
return "/".join(part.rsplit("@", 1)[0] for part in heading_key.split("/"))
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _line_count(content: str) -> int:
|
|
425
|
+
return max(1, len(content.splitlines()))
|