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,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()))