ihmt-memory 1.2.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.
ihmt/__init__.py ADDED
@@ -0,0 +1,134 @@
1
+ """IHMT — Infinite Hierarchical Memory Tree.
2
+
3
+ A domain-agnostic, dependency-free long-term memory for LLMs. Knowledge is
4
+ stored as a recursive tree on the local filesystem: raw ``.txt`` leaves at
5
+ layer 0, JSON "summaries of summaries" in layers 1..N, and a single
6
+ ``root.json`` trunk. Retrieval walks that tree instead of scanning it, so the
7
+ cost of a query grows with the *depth* of the tree, not with its size.
8
+
9
+ Typical use::
10
+
11
+ from ihmt import IHMT
12
+
13
+ memory = IHMT.initialize("./workspace")
14
+ memory.ingest_file("notes/journal.txt")
15
+ memory.flush()
16
+ print(memory.search("vacaciones en Benidorm").best.excerpt)
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .api import IHMT
22
+ from .project_index import CodeHit, FindResult, ProjectIndex, SyncReport
23
+ from .chunkers import (
24
+ Block,
25
+ Chunk,
26
+ Chunker,
27
+ CodeAwareChunker,
28
+ GenericChunker,
29
+ NarrativeChunker,
30
+ ProcessChunker,
31
+ TemporalChunker,
32
+ get_chunker,
33
+ )
34
+ from .config import IHMTConfig
35
+ from .conflict_resolver import ConflictResolver, FactPattern
36
+ from .detectors import Detection, DomainDetector
37
+ from .models import (
38
+ BranchNode,
39
+ ChildRef,
40
+ Contradiction,
41
+ DataType,
42
+ DomainEntry,
43
+ Fact,
44
+ IHMTError,
45
+ LeafStatus,
46
+ MalformedLeafError,
47
+ MemoryLeaf,
48
+ NodeKind,
49
+ NodeNotFoundError,
50
+ RootIndex,
51
+ StoreNotInitializedError,
52
+ )
53
+ from .recursive_summarizer import ConsolidationReport, RecursiveSummarizer, SummarizationEvent
54
+ from .semantic_navigator import (
55
+ ClueOption,
56
+ ClueRequest,
57
+ SearchResponse,
58
+ SearchResult,
59
+ SemanticNavigator,
60
+ )
61
+ from .storage import Catalog, CatalogEntry, MemoryStore
62
+ from .summarizers import (
63
+ AnthropicSummarizer,
64
+ HeuristicSummarizer,
65
+ NodeSummary,
66
+ SummarizerBackend,
67
+ SummaryInput,
68
+ get_summarizer,
69
+ )
70
+ from .universal_ingestor import IngestReport, UniversalIngestor
71
+
72
+ __version__ = "1.1.0"
73
+
74
+ __all__ = [
75
+ "ProjectIndex",
76
+ "SyncReport",
77
+ "CodeHit",
78
+ "FindResult",
79
+ "__version__",
80
+ # facade
81
+ "IHMT",
82
+ "IHMTConfig",
83
+ # storage
84
+ "MemoryStore",
85
+ "Catalog",
86
+ "CatalogEntry",
87
+ # model
88
+ "MemoryLeaf",
89
+ "BranchNode",
90
+ "ChildRef",
91
+ "RootIndex",
92
+ "DomainEntry",
93
+ "Fact",
94
+ "Contradiction",
95
+ "DataType",
96
+ "NodeKind",
97
+ "LeafStatus",
98
+ "IHMTError",
99
+ "NodeNotFoundError",
100
+ "MalformedLeafError",
101
+ "StoreNotInitializedError",
102
+ # pipeline
103
+ "DomainDetector",
104
+ "Detection",
105
+ "UniversalIngestor",
106
+ "IngestReport",
107
+ "RecursiveSummarizer",
108
+ "ConsolidationReport",
109
+ "SummarizationEvent",
110
+ "SemanticNavigator",
111
+ "SearchResponse",
112
+ "SearchResult",
113
+ "ClueRequest",
114
+ "ClueOption",
115
+ "ConflictResolver",
116
+ "FactPattern",
117
+ # chunkers
118
+ "Chunker",
119
+ "Chunk",
120
+ "Block",
121
+ "CodeAwareChunker",
122
+ "NarrativeChunker",
123
+ "TemporalChunker",
124
+ "ProcessChunker",
125
+ "GenericChunker",
126
+ "get_chunker",
127
+ # summarizers
128
+ "SummarizerBackend",
129
+ "SummaryInput",
130
+ "NodeSummary",
131
+ "HeuristicSummarizer",
132
+ "AnthropicSummarizer",
133
+ "get_summarizer",
134
+ ]
ihmt/api.py ADDED
@@ -0,0 +1,166 @@
1
+ """High-level facade wiring the five components together.
2
+
3
+ Most callers — the CLI, a notebook, or an LLM tool layer — only need this::
4
+
5
+ from ihmt import IHMT
6
+
7
+ memory = IHMT.initialize("./workspace") # or IHMT("./workspace")
8
+ memory.ingest_file("InventoryService.java")
9
+ memory.flush() # close the tree
10
+ answer = memory.search("reserveStock concurrency")
11
+ answer = memory.ask("Luis", clue_provider=input_clue)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+ from typing import Any, Callable, Dict, List, Optional
18
+
19
+ from .config import IHMTConfig
20
+ from .conflict_resolver import ConflictResolver
21
+ from .detectors import DomainDetector
22
+ from .models import Contradiction, DataType, MemoryLeaf
23
+ from .recursive_summarizer import ConsolidationReport, RecursiveSummarizer
24
+ from .semantic_navigator import ClueRequest, SearchResponse, SemanticNavigator
25
+ from .storage import MemoryStore
26
+ from .summarizers import SummarizerBackend
27
+ from .universal_ingestor import IngestReport, UniversalIngestor
28
+
29
+
30
+ class IHMT:
31
+ """One initialized memory tree, with every component pre-wired."""
32
+
33
+ def __init__(
34
+ self,
35
+ base_dir: Path | str = ".",
36
+ *,
37
+ backend: Optional[SummarizerBackend] = None,
38
+ auto_consolidate: bool = True,
39
+ require_init: bool = True,
40
+ ) -> None:
41
+ """
42
+ Args:
43
+ base_dir: Directory containing (or to contain) ``ihmt_memory``.
44
+ backend: Summarization backend override.
45
+ auto_consolidate: Fire Summarization Events during ingestion.
46
+ require_init: Raise if the store does not exist yet.
47
+ """
48
+ self.store = MemoryStore.open(base_dir, require_init=require_init)
49
+ self.config: IHMTConfig = self.store.config
50
+ self.detector = DomainDetector()
51
+ self.resolver = ConflictResolver(self.store)
52
+ self.summarizer = RecursiveSummarizer(self.store, backend=backend)
53
+ self.ingestor = UniversalIngestor(
54
+ self.store,
55
+ detector=self.detector,
56
+ summarizer=self.summarizer,
57
+ conflict_resolver=self.resolver,
58
+ auto_consolidate=auto_consolidate,
59
+ )
60
+ self.navigator = SemanticNavigator(self.store, conflict_resolver=self.resolver)
61
+
62
+ # ------------------------------------------------------------- lifecycle
63
+ @classmethod
64
+ def initialize(
65
+ cls,
66
+ base_dir: Path | str = ".",
67
+ *,
68
+ force: bool = False,
69
+ **overrides: Any,
70
+ ) -> "IHMT":
71
+ """Create the store on disk (idempotent) and return a ready facade.
72
+
73
+ Args:
74
+ base_dir: Where to create ``ihmt_memory``.
75
+ force: Discard all *derived* state — trunk, branch layers, catalog
76
+ and fact timeline — and detach the surviving leaves so the next
77
+ consolidation rebuilds the hierarchy. Leaf content is kept.
78
+ **overrides: Configuration fields, e.g. ``branch_factor=4``.
79
+ """
80
+ config = IHMTConfig.load(base_dir)
81
+ for key, value in overrides.items():
82
+ if not hasattr(config, key):
83
+ raise ValueError(f"unknown configuration field: {key}")
84
+ setattr(config, key, value)
85
+ config.validate()
86
+ MemoryStore(config).initialize(force=force)
87
+ return cls(base_dir)
88
+
89
+ # -------------------------------------------------------------- ingestion
90
+ def ingest_text(self, text: str, **kwargs: Any) -> IngestReport:
91
+ """Ingest an in-memory document. See :meth:`UniversalIngestor.ingest_text`."""
92
+ return self.ingestor.ingest_text(text, **kwargs)
93
+
94
+ def ingest_file(self, path: Path | str, **kwargs: Any) -> IngestReport:
95
+ """Ingest a single file."""
96
+ return self.ingestor.ingest_file(path, **kwargs)
97
+
98
+ def ingest_directory(self, path: Path | str, **kwargs: Any) -> List[IngestReport]:
99
+ """Ingest every text file under a directory."""
100
+ return self.ingestor.ingest_directory(path, **kwargs)
101
+
102
+ # ---------------------------------------------------------- consolidation
103
+ def consolidate(self, *, force: bool = False) -> ConsolidationReport:
104
+ """Run pending Summarization Events and rewrite the trunk."""
105
+ return self.summarizer.consolidate(force=force)
106
+
107
+ def flush(self) -> ConsolidationReport:
108
+ """Close the tree completely, promoting partial groups as well."""
109
+ return self.summarizer.flush()
110
+
111
+ # ------------------------------------------------------------- retrieval
112
+ def search(self, query: str, **kwargs: Any) -> SearchResponse:
113
+ """Walk the tree for ``query``."""
114
+ return self.navigator.search(query, **kwargs)
115
+
116
+ def ask(
117
+ self,
118
+ query: str,
119
+ clue_provider: Callable[[ClueRequest], Optional[str]],
120
+ **kwargs: Any,
121
+ ) -> SearchResponse:
122
+ """Search with the interactive clue loop enabled."""
123
+ return self.navigator.search_interactive(query, clue_provider, **kwargs)
124
+
125
+ def get_leaf(self, leaf_id: str) -> MemoryLeaf:
126
+ """Fetch one leaf by identifier."""
127
+ return self.store.load_leaf(leaf_id)
128
+
129
+ def outline(self, **kwargs: Any) -> List[str]:
130
+ """Readable outline of the tree."""
131
+ return self.navigator.outline(**kwargs)
132
+
133
+ # -------------------------------------------------------------- timeline
134
+ def record_fact(self, subject: str, attribute: str, value: str, **kwargs: Any):
135
+ """Add an assertion to the timeline."""
136
+ return self.resolver.record_fact(subject, attribute, value, **kwargs)
137
+
138
+ def conflicts(self, **kwargs: Any) -> List[Contradiction]:
139
+ """Every attribute whose value changed over time."""
140
+ return self.resolver.detect_conflicts(**kwargs)
141
+
142
+ def supersede(self, old_leaf_id: str, new_leaf_id: str) -> str:
143
+ """Record that one memory corrects another; return the user-facing notice."""
144
+ return self.resolver.supersede_leaf(old_leaf_id, new_leaf_id)
145
+
146
+ def notices(self, **kwargs: Any) -> List[str]:
147
+ """User-facing contradiction notices."""
148
+ return [c.render_notice() for c in self.resolver.detect_conflicts(**kwargs)]
149
+
150
+ # ------------------------------------------------------------ inspection
151
+ def stats(self) -> Dict[str, Any]:
152
+ """Counters describing the store."""
153
+ stats = self.store.stats()
154
+ stats["facts"] = len(self.resolver.facts)
155
+ stats["conflicts"] = len(self.resolver.detect_conflicts())
156
+ return stats
157
+
158
+ def rebuild(self) -> Dict[str, Any]:
159
+ """Rebuild the catalog, the timeline and the trunk from the files."""
160
+ self.store.rebuild_catalog()
161
+ facts = self.resolver.rebuild()
162
+ self.summarizer.rebuild_root()
163
+ return {"catalog_entries": len(self.store.catalog.entries), "facts": facts}
164
+
165
+
166
+ __all__ = ["IHMT"]
@@ -0,0 +1,55 @@
1
+ """Type-aware splitters and the factory that selects one.
2
+
3
+ Adding support for a new material type means adding a :class:`~ihmt.chunkers.base.Chunker`
4
+ subclass and one entry in :func:`get_chunker` — nothing else in the framework
5
+ changes.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Optional
11
+
12
+ from ..config import IHMTConfig
13
+ from ..models import DataType
14
+ from .base import Block, Chunk, Chunker
15
+ from .code import BraceScanner, CodeAwareChunker
16
+ from .generic import GenericChunker
17
+ from .narrative import NarrativeChunker
18
+ from .process import ProcessChunker
19
+ from .temporal import TemporalChunker, extract_date, leading_date
20
+
21
+ __all__ = [
22
+ "Block",
23
+ "Chunk",
24
+ "Chunker",
25
+ "BraceScanner",
26
+ "CodeAwareChunker",
27
+ "GenericChunker",
28
+ "NarrativeChunker",
29
+ "ProcessChunker",
30
+ "TemporalChunker",
31
+ "extract_date",
32
+ "get_chunker",
33
+ "leading_date",
34
+ ]
35
+
36
+
37
+ def get_chunker(data_type: DataType, config: IHMTConfig, *, source: str = "") -> Chunker:
38
+ """Return the splitter responsible for ``data_type``.
39
+
40
+ Args:
41
+ data_type: Type decided by :class:`~ihmt.detectors.DomainDetector`.
42
+ config: Store configuration (token budgets).
43
+ source: Optional filename, used to pin the programming language.
44
+ """
45
+ if data_type is DataType.CODE:
46
+ return CodeAwareChunker(config, language=CodeAwareChunker.language_for(source))
47
+ if data_type is DataType.NARRATIVE:
48
+ return NarrativeChunker(config)
49
+ if data_type is DataType.CLINICAL:
50
+ return TemporalChunker(config, clinical=True)
51
+ if data_type is DataType.PERSONAL:
52
+ return TemporalChunker(config, clinical=False)
53
+ if data_type is DataType.PROCESS:
54
+ return ProcessChunker(config)
55
+ return GenericChunker(config)
ihmt/chunkers/base.py ADDED
@@ -0,0 +1,297 @@
1
+ """Chunker interface and the block-packing algorithm shared by all splitters.
2
+
3
+ Every splitter works in two phases:
4
+
5
+ 1. **Block detection** — cut the document only at boundaries that are
6
+ *meaningful for its type*: a syntactic block for code, a paragraph or scene
7
+ for narrative, a dated entry for a journal or clinical note. A block is the
8
+ atomic unit: IHMT never cuts inside one.
9
+ 2. **Packing** (:meth:`Chunker.pack`) — greedily merge consecutive blocks up to
10
+ ``target_tokens``. If a single block exceeds ``max_tokens`` it is emitted
11
+ whole and flagged ``oversized``: block integrity outranks the token budget.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from abc import ABC, abstractmethod
17
+ from dataclasses import dataclass, field
18
+ from typing import Any, Dict, List, Optional, Sequence
19
+
20
+ from ..config import IHMTConfig
21
+ from ..textutils import estimate_tokens, truncate
22
+
23
+
24
+ @dataclass
25
+ class Block:
26
+ """An indivisible span of a source document.
27
+
28
+ Attributes:
29
+ text: Exact source text, byte-for-byte, including trailing newlines.
30
+ title: Human-readable label (``"class InventoryService"``, a scene
31
+ heading, a journal date...).
32
+ start_line: 1-based inclusive first line in the source.
33
+ end_line: 1-based inclusive last line in the source.
34
+ kind: Block flavour, e.g. ``"class"``, ``"method"``, ``"paragraph"``.
35
+ tags: Structural tags propagated to the resulting leaf.
36
+ timestamp: Date extracted from the content, when the type carries one.
37
+ hard_boundary: If true, this block never merges with the previous one
38
+ (a new scene, a new clinical encounter, a new journal date).
39
+ context: Enclosing context that is *not* part of the text, such as the
40
+ class header of an extracted method. Stored in leaf metadata so the
41
+ LLM sees it without corrupting the byte-exact content.
42
+ """
43
+
44
+ text: str
45
+ title: str = ""
46
+ start_line: int = 1
47
+ end_line: int = 1
48
+ kind: str = "block"
49
+ tags: List[str] = field(default_factory=list)
50
+ timestamp: Optional[str] = None
51
+ hard_boundary: bool = False
52
+ context: str = ""
53
+
54
+ @property
55
+ def tokens(self) -> int:
56
+ return estimate_tokens(self.text)
57
+
58
+ @property
59
+ def is_blank(self) -> bool:
60
+ return not self.text.strip()
61
+
62
+
63
+ @dataclass
64
+ class Chunk:
65
+ """A packed group of blocks, ready to become a :class:`~ihmt.models.MemoryLeaf`."""
66
+
67
+ text: str
68
+ title: str = ""
69
+ start_line: int = 1
70
+ end_line: int = 1
71
+ kind: str = "chunk"
72
+ tags: List[str] = field(default_factory=list)
73
+ timestamp: Optional[str] = None
74
+ oversized: bool = False
75
+ context: str = ""
76
+ meta: Dict[str, Any] = field(default_factory=dict)
77
+
78
+ @property
79
+ def tokens(self) -> int:
80
+ return estimate_tokens(self.text)
81
+
82
+
83
+ def split_lines(text: str) -> List[str]:
84
+ """Split into lines while keeping the line terminators (exact round-trip)."""
85
+ return text.splitlines(keepends=True)
86
+
87
+
88
+ def join_lines(lines: Sequence[str], start_line: int, end_line: int) -> str:
89
+ """Join 1-based inclusive line range ``[start_line, end_line]``."""
90
+ return "".join(lines[start_line - 1 : end_line])
91
+
92
+
93
+ class Chunker(ABC):
94
+ """Base class for every splitter.
95
+
96
+ Subclasses implement :meth:`blocks`; :meth:`chunk` packs those blocks into
97
+ leaf-sized units. Overriding :meth:`chunk` directly is allowed when a type
98
+ needs custom grouping (see the temporal splitter).
99
+ """
100
+
101
+ #: Short identifier recorded in leaf metadata.
102
+ name: str = "base"
103
+
104
+ def __init__(self, config: IHMTConfig) -> None:
105
+ self.config = config
106
+
107
+ @abstractmethod
108
+ def blocks(self, text: str, *, source: str = "") -> List[Block]:
109
+ """Detect the indivisible blocks of ``text``."""
110
+
111
+ def chunk(self, text: str, *, source: str = "") -> List[Chunk]:
112
+ """Split ``text`` into leaf-sized chunks. The public entry point."""
113
+ if not text.strip():
114
+ return []
115
+ return self.pack(self.blocks(text, source=source))
116
+
117
+ # ------------------------------------------------------------------ packing
118
+ def pack(self, blocks: Sequence[Block]) -> List[Chunk]:
119
+ """Merge consecutive blocks into chunks of roughly ``target_tokens``.
120
+
121
+ The packing rules, in priority order:
122
+
123
+ 1. A block larger than ``max_tokens`` is emitted alone and flagged
124
+ ``oversized`` — it is never cut.
125
+ 2. A block marked ``hard_boundary`` always starts a new chunk.
126
+ 3. Otherwise blocks accumulate until adding one would exceed
127
+ ``target_tokens``, provided the current chunk already carries at
128
+ least ``min_tokens`` (this avoids emitting slivers).
129
+ """
130
+ target = self.config.target_tokens
131
+ maximum = self.config.max_tokens
132
+ minimum = self.config.min_tokens
133
+
134
+ chunks: List[Chunk] = []
135
+ current: List[Block] = []
136
+ current_tokens = 0
137
+
138
+ def flush() -> None:
139
+ nonlocal current, current_tokens
140
+ if current:
141
+ chunks.append(self._merge(current))
142
+ current = []
143
+ current_tokens = 0
144
+
145
+ for block in blocks:
146
+ if block.is_blank:
147
+ # Whitespace-only runs ride along with the preceding block so
148
+ # that concatenating chunks reproduces the source exactly.
149
+ if current:
150
+ current.append(block)
151
+ else:
152
+ current = [block]
153
+ current_tokens = 0
154
+ continue
155
+
156
+ tokens = block.tokens
157
+ if tokens > maximum:
158
+ flush()
159
+ chunk = self._merge([block])
160
+ chunk.oversized = True
161
+ chunk.meta["oversized_reason"] = (
162
+ f"indivisible {block.kind} of ~{tokens} tokens exceeds max_tokens={maximum}"
163
+ )
164
+ chunks.append(chunk)
165
+ continue
166
+
167
+ if current and (block.hard_boundary or (current_tokens + tokens > target and current_tokens >= minimum)):
168
+ flush()
169
+
170
+ current.append(block)
171
+ current_tokens += tokens
172
+
173
+ flush()
174
+ return chunks
175
+
176
+ def pack_each(self, blocks: Sequence[Block]) -> List[Chunk]:
177
+ """One chunk per block (symbol granularity).
178
+
179
+ Whitespace-only blocks ride along with the preceding block, exactly as
180
+ in :meth:`pack`, so concatenating the chunks reproduces the source.
181
+ Runs of *small* blocks of the same kind — import lines, fields, trivial
182
+ accessors — are grouped up to ``target_tokens``, so a lookup never has
183
+ to wade through a dozen one-line leaves.
184
+ """
185
+ small = max(24, self.config.target_tokens // 4)
186
+
187
+ def solid(group: List[Block]) -> List[Block]:
188
+ return [b for b in group if not b.is_blank]
189
+
190
+ groups: List[List[Block]] = []
191
+ leading: List[Block] = []
192
+ for block in blocks:
193
+ if block.is_blank:
194
+ if groups:
195
+ groups[-1].append(block)
196
+ else:
197
+ leading.append(block)
198
+ continue
199
+ if groups and not leading and block.tokens < small:
200
+ previous = solid(groups[-1])
201
+ if (
202
+ previous
203
+ and previous[-1].tokens < small
204
+ and previous[-1].kind == block.kind
205
+ and sum(b.tokens for b in previous) + block.tokens <= self.config.target_tokens
206
+ ):
207
+ groups[-1].append(block)
208
+ continue
209
+ groups.append(leading + [block])
210
+ leading = []
211
+ if leading:
212
+ groups.append(leading)
213
+
214
+ chunks: List[Chunk] = []
215
+ for group in groups:
216
+ chunk = self._merge(group)
217
+ big = [b for b in group if not b.is_blank and b.tokens > self.config.max_tokens]
218
+ if big:
219
+ chunk.oversized = True
220
+ chunk.meta["oversized_reason"] = (
221
+ f"indivisible {big[0].kind} of ~{big[0].tokens} tokens exceeds max_tokens={self.config.max_tokens}"
222
+ )
223
+ chunks.append(chunk)
224
+ return chunks
225
+
226
+ def _merge(self, blocks: Sequence[Block]) -> Chunk:
227
+ """Fuse a run of blocks into a single chunk, preserving exact text."""
228
+ named = [b for b in blocks if b.title and not b.is_blank] or list(blocks)
229
+ if len(named) == 1:
230
+ title = named[0].title
231
+ else:
232
+ title = truncate(f"{named[0].title} → {named[-1].title}", 90)
233
+
234
+ tags: List[str] = []
235
+ for block in blocks:
236
+ for tag in block.tags:
237
+ if tag not in tags:
238
+ tags.append(tag)
239
+
240
+ timestamps = [b.timestamp for b in blocks if b.timestamp]
241
+ contexts = [b.context for b in blocks if b.context]
242
+ kinds = {b.kind for b in blocks if not b.is_blank}
243
+
244
+ return Chunk(
245
+ text="".join(b.text for b in blocks),
246
+ title=title or "untitled",
247
+ start_line=blocks[0].start_line,
248
+ end_line=blocks[-1].end_line,
249
+ kind=kinds.pop() if len(kinds) == 1 else "mixed",
250
+ tags=tags,
251
+ timestamp=timestamps[0] if timestamps else None,
252
+ context=contexts[0] if contexts else "",
253
+ meta={"blocks": len(blocks), "chunker": self.name},
254
+ )
255
+
256
+ # ------------------------------------------------------------------ helpers
257
+ def _fallback_blocks(self, text: str) -> List[Block]:
258
+ """Line-based blocks used when no structure can be detected.
259
+
260
+ Cuts only at blank lines, so even the degenerate path never splits a
261
+ line in half.
262
+ """
263
+ lines = split_lines(text)
264
+ blocks: List[Block] = []
265
+ start = 1
266
+ for index, line in enumerate(lines, start=1):
267
+ if not line.strip() and index > start:
268
+ blocks.append(
269
+ Block(
270
+ text=join_lines(lines, start, index),
271
+ title=self._first_meaningful_line(lines[start - 1 : index]),
272
+ start_line=start,
273
+ end_line=index,
274
+ kind="segment",
275
+ )
276
+ )
277
+ start = index + 1
278
+ if start <= len(lines):
279
+ blocks.append(
280
+ Block(
281
+ text=join_lines(lines, start, len(lines)),
282
+ title=self._first_meaningful_line(lines[start - 1 :]),
283
+ start_line=start,
284
+ end_line=len(lines),
285
+ kind="segment",
286
+ )
287
+ )
288
+ return blocks
289
+
290
+ @staticmethod
291
+ def _first_meaningful_line(lines: Sequence[str], limit: int = 70) -> str:
292
+ """Title heuristic: the first non-empty line, trimmed."""
293
+ for line in lines:
294
+ stripped = line.strip()
295
+ if stripped:
296
+ return truncate(stripped, limit)
297
+ return ""