contextual-engine 0.1.0__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.
Files changed (60) hide show
  1. contextual/__init__.py +18 -0
  2. contextual/__main__.py +11 -0
  3. contextual/cli.py +339 -0
  4. contextual/cli_docs.py +685 -0
  5. contextual/config.py +7 -0
  6. contextual/core/__init__.py +11 -0
  7. contextual/core/errors.py +470 -0
  8. contextual/core/models.py +590 -0
  9. contextual/docs/__init__.py +66 -0
  10. contextual/docs/chunker.py +550 -0
  11. contextual/docs/pipeline.py +513 -0
  12. contextual/docs/retrieval.py +654 -0
  13. contextual/docs/watcher.py +265 -0
  14. contextual/embedding/__init__.py +87 -0
  15. contextual/embedding/cache.py +455 -0
  16. contextual/embedding/embedder.py +414 -0
  17. contextual/embedding/helpers.py +252 -0
  18. contextual/git/__init__.py +22 -0
  19. contextual/git/blame.py +334 -0
  20. contextual/indexing/__init__.py +20 -0
  21. contextual/indexing/bug_sweep.py +119 -0
  22. contextual/indexing/chunker.py +691 -0
  23. contextual/indexing/embedder.py +271 -0
  24. contextual/indexing/file_watcher.py +154 -0
  25. contextual/indexing/incremental.py +260 -0
  26. contextual/indexing/index_writer.py +442 -0
  27. contextual/indexing/pipeline.py +438 -0
  28. contextual/indexing/processor.py +436 -0
  29. contextual/indexing/queries/readme.md +22 -0
  30. contextual/indexing/symbol_extractor.py +426 -0
  31. contextual/indexing/tokenizer.py +203 -0
  32. contextual/integrations/__init__.py +10 -0
  33. contextual/mcp/__init__.py +15 -0
  34. contextual/mcp/__main__.py +24 -0
  35. contextual/mcp/docs_tools.py +286 -0
  36. contextual/mcp/server.py +118 -0
  37. contextual/mcp/tools.py +443 -0
  38. contextual/observability/__init__.py +21 -0
  39. contextual/observability/logging.py +115 -0
  40. contextual/py.typed +0 -0
  41. contextual/retrieval/__init__.py +24 -0
  42. contextual/retrieval/context_assembler.py +372 -0
  43. contextual/retrieval/ranker.py +193 -0
  44. contextual/retrieval/search.py +548 -0
  45. contextual/security/__init__.py +52 -0
  46. contextual/security/paths.py +347 -0
  47. contextual/security/sanitize.py +349 -0
  48. contextual/security/workspace.py +348 -0
  49. contextual/storage/__init__.py +36 -0
  50. contextual/storage/fts_manager.py +273 -0
  51. contextual/storage/migration_v2.py +289 -0
  52. contextual/storage/migrations.py +316 -0
  53. contextual/storage/schema.py +210 -0
  54. contextual/storage/sqlite_pool.py +468 -0
  55. contextual/storage/vec0_manager.py +421 -0
  56. contextual_engine-0.1.0.dist-info/METADATA +297 -0
  57. contextual_engine-0.1.0.dist-info/RECORD +60 -0
  58. contextual_engine-0.1.0.dist-info/WHEEL +4 -0
  59. contextual_engine-0.1.0.dist-info/entry_points.txt +2 -0
  60. contextual_engine-0.1.0.dist-info/licenses/LICENSE +111 -0
@@ -0,0 +1,550 @@
1
+ """Heading-aware markdown/MDX/RST document chunker for Contextual Phase 2.
2
+
3
+ Splits documents into semantically coherent chunks by heading hierarchy.
4
+ Strategy:
5
+ - Each H2 section is the primary chunk unit.
6
+ - H3+ sub-sections are nested under their H2 parent.
7
+ - Every chunk's ``embedding_text`` carries a breadcrumb prefix:
8
+ ``[File: path | Section: H1 > H2 > H3]\\nchunk content``
9
+ - Code blocks <80 tokens stay inline; 80-600 become a parallel ``docs_code``
10
+ chunk; >600 become standalone with a placeholder in the parent chunk.
11
+
12
+ Supported formats:
13
+ .md — CommonMark + GFM, YAML/TOML frontmatter stripped
14
+ .mdx — MDX: JSX/import/export lines stripped before markdown parse
15
+ .rst — reStructuredText: docutils → pseudo-markdown heading tree
16
+ .txt — Plain text: split by double-newline paragraphs
17
+
18
+ Token accounting uses a fast 4-chars-per-token approximation consistent with
19
+ jina-embeddings-v2-base-code (768-dim) which the embedder uses.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ import unicodedata
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+ from typing import Generator
28
+
29
+ import structlog
30
+
31
+ logger = structlog.get_logger(__name__)
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Token budget constants (tune here only)
35
+ # ---------------------------------------------------------------------------
36
+ CHUNK_TARGET_TOKENS: int = 450
37
+ CHUNK_MAX_TOKENS: int = 800
38
+ CHUNK_MIN_TOKENS: int = 120
39
+ OVERLAP_TOKENS: int = 60 # trailing overlap appended to next chunk
40
+ CODE_BLOCK_INLINE_MAX: int = 80 # tokens — keep inline in parent chunk
41
+ CODE_BLOCK_SPLIT_MAX: int = 600 # tokens — emit parallel docs_code chunk; >600 → standalone
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # Public data model
46
+ # ---------------------------------------------------------------------------
47
+
48
+ @dataclass(slots=True)
49
+ class DocChunk:
50
+ """A single semantically coherent chunk from a document.
51
+
52
+ Fields that map directly to the ``chunks`` table columns:
53
+ - ``content`` raw text sent to BM25/FTS5 index
54
+ - ``embedding_text`` breadcrumb-prefixed text sent to the embedder
55
+ - ``heading_path`` e.g. "Installation > macOS > Homebrew"
56
+ - ``heading_level`` depth of the deepest heading in this chunk (1-6)
57
+ - ``chunk_type`` "docs" or "docs_code"
58
+ - ``start_line`` 1-based line in source file
59
+ - ``end_line`` 1-based line in source file (inclusive)
60
+ - ``chunk_index`` sequential position within the file (0-based)
61
+ - ``token_estimate`` fast approximation; not exact token count
62
+ """
63
+
64
+ file_path: str # repo-relative or absolute
65
+ content: str
66
+ embedding_text: str
67
+ heading_path: str # ">"-joined breadcrumb
68
+ heading_level: int # 1-6
69
+ chunk_type: str # "docs" | "docs_code"
70
+ start_line: int
71
+ end_line: int
72
+ chunk_index: int
73
+ token_estimate: int
74
+ frontmatter: dict = field(default_factory=dict) # parsed YAML metadata
75
+
76
+
77
+ # ---------------------------------------------------------------------------
78
+ # Token estimation (fast, no tokeniser dependency)
79
+ # ---------------------------------------------------------------------------
80
+
81
+ def _est_tokens(text: str) -> int:
82
+ """Estimate token count at ~4 chars/token (standard LLM approximation)."""
83
+ return max(1, len(text) // 4)
84
+
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # MDX pre-processor
88
+ # ---------------------------------------------------------------------------
89
+ # Patterns that are valid in MDX but crash markdown-it-py
90
+ _MDX_IMPORT_EXPORT_RE = re.compile(
91
+ r"^(import|export)\s.*$",
92
+ re.MULTILINE,
93
+ )
94
+ _JSX_COMPONENT_RE = re.compile(
95
+ r"<([A-Z][A-Za-z0-9]*)(?:\s[^>]*)?\s*/?>.*?(?:</\1>)?",
96
+ re.DOTALL,
97
+ )
98
+ _MDX_EXPRESSION_RE = re.compile(r"\{[^}]*\}", re.DOTALL)
99
+
100
+
101
+ def _strip_mdx(source: str) -> str:
102
+ """Strip MDX-specific syntax, leaving valid CommonMark behind."""
103
+ source = _MDX_IMPORT_EXPORT_RE.sub("", source)
104
+ source = _JSX_COMPONENT_RE.sub("", source)
105
+ source = _MDX_EXPRESSION_RE.sub("", source)
106
+ # Collapse multiple blank lines left by stripping
107
+ source = re.sub(r"\n{3,}", "\n\n", source)
108
+ return source
109
+
110
+
111
+ # ---------------------------------------------------------------------------
112
+ # RST → pseudo-markdown converter
113
+ # ---------------------------------------------------------------------------
114
+ # RST heading adornments in typical decoration order
115
+ _RST_ADORNMENTS = r"[=\-`:'\"~^_*+#<>]"
116
+ _RST_HEADING_RE = re.compile(
117
+ rf"^({_RST_ADORNMENTS}){{3,}}\n(.+)\n({_RST_ADORNMENTS}){{3,}}$|"
118
+ rf"^(.+)\n({_RST_ADORNMENTS}){{3,}}$",
119
+ re.MULTILINE,
120
+ )
121
+
122
+ # Map adornment char to heading level using order of first encounter
123
+ def _rst_to_markdown(source: str) -> str:
124
+ """Convert RST heading syntax to markdown ATX headings (#, ##, ###, ...).
125
+
126
+ We do a two-pass: first collect all adornment characters in order of
127
+ appearance (determines hierarchy), then substitute. Non-heading RST
128
+ directives are left as-is — they become fenced code blocks or plain text
129
+ when parsed by markdown-it-py.
130
+ """
131
+ adornment_order: list[str] = []
132
+ lines = source.splitlines(keepends=True)
133
+ out: list[str] = []
134
+ i = 0
135
+ while i < len(lines):
136
+ line = lines[i].rstrip("\n")
137
+ # Detect underline-only (section title above)
138
+ if i > 0 and re.match(rf"^{_RST_ADORNMENTS}{{3,}}$", line):
139
+ prev = lines[i - 1].rstrip("\n")
140
+ char = line[0]
141
+ if char not in adornment_order:
142
+ adornment_order.append(char)
143
+ level = min(adornment_order.index(char) + 1, 6)
144
+ # Replace the already-pushed previous line with an ATX heading
145
+ if out:
146
+ out[-1] = "#" * level + " " + prev + "\n"
147
+ i += 1
148
+ continue
149
+ out.append(lines[i])
150
+ i += 1
151
+ return "".join(out)
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Frontmatter stripper
156
+ # ---------------------------------------------------------------------------
157
+ _YAML_FM_RE = re.compile(r"^---\s*\n(.*?\n?)---\s*\n", re.DOTALL)
158
+ _TOML_FM_RE = re.compile(r"^\+\+\+\s*\n(.*?\n?)\+\+\+\s*\n", re.DOTALL)
159
+
160
+
161
+ def _strip_frontmatter(source: str) -> tuple[str, dict]:
162
+ """Strip YAML or TOML frontmatter and return (body, metadata_dict)."""
163
+ meta: dict = {}
164
+ for pattern in (_YAML_FM_RE, _TOML_FM_RE):
165
+ m = pattern.match(source)
166
+ if m:
167
+ raw = m.group(1)
168
+ try:
169
+ import yaml # pyyaml ships with python-frontmatter
170
+ meta = yaml.safe_load(raw) or {}
171
+ except Exception: # noqa: BLE001
172
+ pass
173
+ source = source[m.end():]
174
+ break
175
+ return source, meta
176
+
177
+
178
+ # ---------------------------------------------------------------------------
179
+ # Heading tree builder
180
+ # ---------------------------------------------------------------------------
181
+
182
+ @dataclass
183
+ class _HeadingNode:
184
+ level: int # 1–6
185
+ title: str
186
+ start_line: int
187
+ end_line: int # set after next heading found
188
+ body_lines: list[str] = field(default_factory=list)
189
+ children: list["_HeadingNode"] = field(default_factory=list)
190
+
191
+
192
+ _ATX_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)(?:\s+#+)?$")
193
+
194
+
195
+ def _build_heading_tree(lines: list[str]) -> _HeadingNode:
196
+ """Parse ATX headings and build a heading tree from markdown lines.
197
+
198
+ Returns a virtual root node whose children are the top-level headings.
199
+ Lines before the first heading are collected under root.body_lines.
200
+ """
201
+ root = _HeadingNode(level=0, title="__root__", start_line=1, end_line=len(lines))
202
+ stack: list[_HeadingNode] = [root]
203
+
204
+ for lineno, raw in enumerate(lines, start=1):
205
+ m = _ATX_HEADING_RE.match(raw.rstrip("\n"))
206
+ if m:
207
+ level = len(m.group(1))
208
+ title = m.group(2).strip()
209
+ node = _HeadingNode(level=level, title=title, start_line=lineno, end_line=len(lines))
210
+
211
+ # Close nodes deeper than current level
212
+ while len(stack) > 1 and stack[-1].level >= level:
213
+ closed = stack.pop()
214
+ closed.end_line = lineno - 1
215
+
216
+ # Attach to parent
217
+ stack[-1].children.append(node)
218
+ stack.append(node)
219
+ else:
220
+ stack[-1].body_lines.append(raw)
221
+
222
+ return root
223
+
224
+
225
+ def _node_breadcrumb(ancestors: list[_HeadingNode], node: _HeadingNode) -> str:
226
+ """Build a ' > '-joined breadcrumb from ancestor titles + this node."""
227
+ parts = [n.title for n in ancestors if n.level > 0] + [node.title]
228
+ return " > ".join(parts)
229
+
230
+
231
+ def _collect_body(node: _HeadingNode, lines: list[str]) -> str:
232
+ """Collect the raw text body of a node (excluding child headings)."""
233
+ # Body is lines from node start to (first child start - 1) or node end
234
+ if node.children:
235
+ body_end = node.children[0].start_line - 1
236
+ else:
237
+ body_end = node.end_line
238
+ start = node.start_line # start_line is the heading line itself
239
+ body_lines = lines[start:body_end] # skip heading line (index start-1)
240
+ return "".join(body_lines).strip()
241
+
242
+
243
+ # ---------------------------------------------------------------------------
244
+ # Code block extractor
245
+ # ---------------------------------------------------------------------------
246
+ _FENCED_CODE_RE = re.compile(
247
+ r"(```(?:[^\n]*)?\n)(.*?)(```\s*$)",
248
+ re.DOTALL | re.MULTILINE,
249
+ )
250
+
251
+
252
+ def _split_code_blocks(
253
+ text: str,
254
+ ) -> tuple[str, list[tuple[str, str]]]:
255
+ """Extract large code blocks from text.
256
+
257
+ Returns:
258
+ (text_with_placeholders, [(placeholder, code_block_text), ...])
259
+ Large = > CODE_BLOCK_INLINE_MAX estimated tokens.
260
+ """
261
+ extracted: list[tuple[str, str]] = []
262
+ counter = [0]
263
+
264
+ def replacer(m: re.Match) -> str:
265
+ full_block = m.group(0)
266
+ tok = _est_tokens(full_block)
267
+ if tok <= CODE_BLOCK_INLINE_MAX:
268
+ return full_block # keep inline
269
+ placeholder = f"[[CODE_BLOCK_{counter[0]}]]"
270
+ extracted.append((placeholder, full_block))
271
+ counter[0] += 1
272
+ return placeholder
273
+
274
+ result = _FENCED_CODE_RE.sub(replacer, text)
275
+ return result, extracted
276
+
277
+
278
+ # ---------------------------------------------------------------------------
279
+ # Paragraph splitter (for overlong sections)
280
+ # ---------------------------------------------------------------------------
281
+
282
+ def _split_by_paragraphs(
283
+ text: str,
284
+ max_tokens: int = CHUNK_MAX_TOKENS,
285
+ overlap_tokens: int = OVERLAP_TOKENS,
286
+ ) -> list[str]:
287
+ """Split overlong text into paragraph-aligned sub-chunks with overlap."""
288
+ paragraphs = [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
289
+ if not paragraphs:
290
+ return [text] if text.strip() else []
291
+
292
+ chunks: list[str] = []
293
+ current_parts: list[str] = []
294
+ current_tokens = 0
295
+ overlap_tail = ""
296
+
297
+ for para in paragraphs:
298
+ ptok = _est_tokens(para)
299
+ if current_tokens + ptok > max_tokens and current_parts:
300
+ chunk_text = overlap_tail + "\n\n".join(current_parts)
301
+ chunks.append(chunk_text)
302
+ # Carry last paragraph as overlap seed
303
+ overlap_tail = current_parts[-1][:overlap_tokens * 4] + "\n\n"
304
+ current_parts = []
305
+ current_tokens = _est_tokens(overlap_tail)
306
+ current_parts.append(para)
307
+ current_tokens += ptok
308
+
309
+ if current_parts:
310
+ chunks.append(overlap_tail + "\n\n".join(current_parts))
311
+
312
+ return chunks if chunks else [text]
313
+
314
+
315
+ # ---------------------------------------------------------------------------
316
+ # Main chunker
317
+ # ---------------------------------------------------------------------------
318
+
319
+ class DocsChunker:
320
+ """Chunk a document file into ``DocChunk`` instances.
321
+
322
+ Usage::
323
+
324
+ chunker = DocsChunker()
325
+ chunks = chunker.chunk_file(Path("docs/README.md"))
326
+ """
327
+
328
+ SUPPORTED_EXTENSIONS = frozenset({".md", ".mdx", ".rst", ".txt"})
329
+
330
+ def __init__(
331
+ self,
332
+ target_tokens: int = CHUNK_TARGET_TOKENS,
333
+ max_tokens: int = CHUNK_MAX_TOKENS,
334
+ min_tokens: int = CHUNK_MIN_TOKENS,
335
+ ) -> None:
336
+ self._target = target_tokens
337
+ self._max = max_tokens
338
+ self._min = min_tokens
339
+
340
+ # ------------------------------------------------------------------
341
+ # Public API
342
+ # ------------------------------------------------------------------
343
+
344
+ def chunk_file(self, file_path: Path, repo_root: Path | None = None) -> list[DocChunk]:
345
+ """Parse and chunk a single document file.
346
+
347
+ Args:
348
+ file_path: Absolute path to the document.
349
+ repo_root: If provided, ``DocChunk.file_path`` is repo-relative.
350
+
351
+ Returns:
352
+ List of ``DocChunk`` objects (may be empty for blank files).
353
+ """
354
+ suffix = file_path.suffix.lower()
355
+ if suffix not in self.SUPPORTED_EXTENSIONS:
356
+ logger.debug("Skipping unsupported extension", path=str(file_path), suffix=suffix)
357
+ return []
358
+
359
+ try:
360
+ source = file_path.read_text(encoding="utf-8", errors="replace")
361
+ except OSError as exc:
362
+ logger.warning("Cannot read file", path=str(file_path), error=str(exc))
363
+ return []
364
+
365
+ if not source.strip():
366
+ return []
367
+
368
+ rel_path = str(file_path.relative_to(repo_root)) if repo_root else str(file_path)
369
+ return list(self._chunk_source(source, suffix, rel_path))
370
+
371
+ # ------------------------------------------------------------------
372
+ # Internal dispatch
373
+ # ------------------------------------------------------------------
374
+
375
+ def _chunk_source(
376
+ self, source: str, suffix: str, rel_path: str
377
+ ) -> Generator[DocChunk, None, None]:
378
+ """Dispatch to format-specific handler and yield DocChunk objects."""
379
+ if suffix == ".rst":
380
+ source = _rst_to_markdown(source)
381
+
382
+ if suffix == ".txt":
383
+ yield from self._chunk_plain_text(source, rel_path)
384
+ return
385
+
386
+ # Strip frontmatter for .md / .mdx / (converted .rst)
387
+ source, frontmatter = _strip_frontmatter(source)
388
+
389
+ if suffix == ".mdx":
390
+ source = _strip_mdx(source)
391
+
392
+ yield from self._chunk_markdown(source, rel_path, frontmatter)
393
+
394
+ # ------------------------------------------------------------------
395
+ # Markdown chunker
396
+ # ------------------------------------------------------------------
397
+
398
+ def _chunk_markdown(
399
+ self,
400
+ source: str,
401
+ rel_path: str,
402
+ frontmatter: dict,
403
+ ) -> Generator[DocChunk, None, None]:
404
+ """Main heading-aware chunker for CommonMark/MDX/RST (post-convert)."""
405
+ lines = source.splitlines(keepends=True)
406
+ root = _build_heading_tree(lines)
407
+
408
+ chunk_index = 0
409
+ file_label = Path(rel_path).name
410
+
411
+ # Pre-heading body (before any heading in the file)
412
+ if root.body_lines:
413
+ preamble = "".join(root.body_lines).strip()
414
+ if _est_tokens(preamble) >= self._min:
415
+ emb = f"[File: {file_label}]\n{preamble}"
416
+ yield DocChunk(
417
+ file_path=rel_path,
418
+ content=preamble,
419
+ embedding_text=emb,
420
+ heading_path="",
421
+ heading_level=0,
422
+ chunk_type="docs",
423
+ start_line=1,
424
+ end_line=root.children[0].start_line - 1 if root.children else len(lines),
425
+ chunk_index=chunk_index,
426
+ token_estimate=_est_tokens(preamble),
427
+ frontmatter=frontmatter,
428
+ )
429
+ chunk_index += 1
430
+
431
+ # Walk heading tree depth-first
432
+ def _walk(
433
+ node: _HeadingNode, ancestors: list[_HeadingNode]
434
+ ) -> Generator[DocChunk, None, None]:
435
+ nonlocal chunk_index
436
+
437
+ breadcrumb = _node_breadcrumb(ancestors, node)
438
+ body = _collect_body(node, lines)
439
+
440
+ # --- Extract oversize code blocks from body ---
441
+ body_no_code, code_blocks = _split_code_blocks(body)
442
+
443
+ # --- Emit code chunks for large blocks ---
444
+ for placeholder, code_text in code_blocks:
445
+ tok = _est_tokens(code_text)
446
+ if tok <= CODE_BLOCK_SPLIT_MAX:
447
+ # Parallel docs_code chunk
448
+ emb = f"[File: {file_label} | Section: {breadcrumb}]\n{code_text}"
449
+ yield DocChunk(
450
+ file_path=rel_path,
451
+ content=code_text,
452
+ embedding_text=emb,
453
+ heading_path=breadcrumb,
454
+ heading_level=node.level,
455
+ chunk_type="docs_code",
456
+ start_line=node.start_line,
457
+ end_line=node.end_line,
458
+ chunk_index=chunk_index,
459
+ token_estimate=tok,
460
+ frontmatter=frontmatter,
461
+ )
462
+ chunk_index += 1
463
+ # else: standalone — placeholder stays in body_no_code; body
464
+ # will reference it but we don't emit the raw code separately
465
+ # (too large; user sees [CODE_BLOCK_N] marker in content)
466
+
467
+ # --- Split body_no_code into sub-chunks if overlong ---
468
+ if body_no_code.strip():
469
+ sub_texts = (
470
+ _split_by_paragraphs(body_no_code, self._max)
471
+ if _est_tokens(body_no_code) > self._max
472
+ else [body_no_code]
473
+ )
474
+ for idx, sub in enumerate(sub_texts):
475
+ sub = sub.strip()
476
+ if not sub:
477
+ continue
478
+ tok = _est_tokens(sub)
479
+ if tok < self._min and len(sub_texts) > 1:
480
+ # Too small sub-chunk from a split — merge into prev
481
+ # by not yielding; caller will see gap in chunk_index
482
+ continue
483
+
484
+ section_label = f"{breadcrumb} (part {idx+1})" if len(sub_texts) > 1 else breadcrumb
485
+ emb = f"[File: {file_label} | Section: {section_label}]\n{sub}"
486
+ yield DocChunk(
487
+ file_path=rel_path,
488
+ content=sub,
489
+ embedding_text=emb,
490
+ heading_path=breadcrumb,
491
+ heading_level=node.level,
492
+ chunk_type="docs",
493
+ start_line=node.start_line,
494
+ end_line=node.end_line,
495
+ chunk_index=chunk_index,
496
+ token_estimate=tok,
497
+ frontmatter=frontmatter,
498
+ )
499
+ chunk_index += 1
500
+
501
+ # Recurse into children
502
+ for child in node.children:
503
+ yield from _walk(child, ancestors + [node])
504
+
505
+ for top_node in root.children:
506
+ yield from _walk(top_node, [])
507
+
508
+ # ------------------------------------------------------------------
509
+ # Plain text chunker
510
+ # ------------------------------------------------------------------
511
+
512
+ def _chunk_plain_text(
513
+ self, source: str, rel_path: str
514
+ ) -> Generator[DocChunk, None, None]:
515
+ """Paragraph-based chunker for .txt files (no heading structure)."""
516
+ file_label = Path(rel_path).name
517
+ sub_texts = _split_by_paragraphs(source, self._max)
518
+ for idx, sub in enumerate(sub_texts):
519
+ sub = sub.strip()
520
+ if not sub:
521
+ continue
522
+ tok = _est_tokens(sub)
523
+ if tok < self._min and len(sub_texts) > 1:
524
+ continue
525
+ emb = f"[File: {file_label}]\n{sub}"
526
+ yield DocChunk(
527
+ file_path=rel_path,
528
+ content=sub,
529
+ embedding_text=emb,
530
+ heading_path="",
531
+ heading_level=0,
532
+ chunk_type="docs",
533
+ start_line=1,
534
+ end_line=source.count("\n") + 1,
535
+ chunk_index=idx,
536
+ token_estimate=tok,
537
+ frontmatter={},
538
+ )
539
+
540
+
541
+ # ---------------------------------------------------------------------------
542
+ # Module-level convenience
543
+ # ---------------------------------------------------------------------------
544
+
545
+ def chunk_doc_file(
546
+ file_path: Path,
547
+ repo_root: Path | None = None,
548
+ ) -> list[DocChunk]:
549
+ """Convenience wrapper — chunk a single doc file with default settings."""
550
+ return DocsChunker().chunk_file(file_path, repo_root=repo_root)