loki-mode 7.78.0 → 7.80.0

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,499 @@
1
+ """
2
+ Loki Mode Memory System - Structure-Aware TOC Tree Builder
3
+
4
+ Builds a hierarchical table-of-contents (TOC) tree over the existing
5
+ code-index manifest (.loki/state/code-index-manifest.json) and over large
6
+ spec/PRD documents. This adopts the PageIndex IDEA (a structure tree the
7
+ caller can reason down, instead of an embedding vector space), NOT the
8
+ PageIndex library. No embeddings, no external service, no new deps.
9
+
10
+ The tree is a third, parallel, OPTIONAL retrieval substrate alongside the
11
+ keyword path and the vector path. It is never built or consulted unless the
12
+ caller opts into tree retrieval (see memory/retrieval.py and
13
+ memory/tree_search.py). Local devs who do nothing are unaffected.
14
+
15
+ Node shape (see TreeNode):
16
+ title : short human label (directory / file / symbol / heading)
17
+ summary : short description used by tree search to reason about the node
18
+ path : the source path or identifier this node represents
19
+ range : optional [start_line, end_line] when known (specs/headings)
20
+ kind : "root" | "dir" | "file" | "symbol" | "section"
21
+ children : list of child nodes
22
+
23
+ Caching: the built tree is cached through the LokiStore-local backend at the
24
+ key "state/retrieval-tree.json" so it is not rebuilt on every retrieval. The
25
+ cache records the manifest fingerprint (version + per-file sha1/mtime) so a
26
+ stale tree is rebuilt when the manifest changes. Reuses the LokiStore atomic
27
+ write + path-traversal guarantees rather than reimplementing them.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import logging
34
+ from dataclasses import dataclass, field
35
+ from pathlib import PurePosixPath
36
+ from typing import Any, Dict, List, Optional
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ # The LokiStore key under which the built tree is cached. Resolved relative to
41
+ # the store base (the project .loki/), mirroring the manifest location which is
42
+ # .loki/state/code-index-manifest.json.
43
+ TREE_CACHE_KEY = "state/retrieval-tree.json"
44
+
45
+ # Schema version for the cached tree payload. Bump when the node shape or the
46
+ # fingerprint scheme changes so an old cache is treated as a miss.
47
+ TREE_SCHEMA_VERSION = 1
48
+
49
+ # Hard cap on tree nesting depth. The builders clamp to this depth so a
50
+ # pathological input (a spec with thousands of strictly-increasing heading
51
+ # levels, or a manifest path with thousands of segments) cannot build an
52
+ # arbitrarily deep node chain that would later RecursionError in the recursive
53
+ # walk()/count()/to_dict()/from_dict() traversals. Beyond the cap, deeper
54
+ # content is attached under the deepest allowed node rather than recursing
55
+ # further. 64 is far deeper than any real directory tree or document outline
56
+ # yet keeps recursion comfortably under the interpreter limit.
57
+ MAX_TREE_DEPTH = 64
58
+
59
+ # Hard cap on nesting accepted by TreeNode.from_dict when deserializing a
60
+ # (possibly forged) cached tree. It is deliberately larger than MAX_TREE_DEPTH
61
+ # so a legitimately-built tree -- whose total depth is the clamped directory
62
+ # depth plus the file and symbol leaf levels -- always round-trips, while a
63
+ # forged cache that nests unboundedly is still rejected well under the
64
+ # interpreter recursion limit.
65
+ MAX_DESERIALIZE_DEPTH = MAX_TREE_DEPTH * 4
66
+
67
+
68
+ @dataclass
69
+ class TreeNode:
70
+ """A single node in the structure-aware TOC tree.
71
+
72
+ The shape is deliberately small and JSON-serializable so the whole tree
73
+ round-trips through LokiStore as plain JSON.
74
+ """
75
+
76
+ title: str
77
+ summary: str = ""
78
+ path: str = ""
79
+ kind: str = "section"
80
+ range: Optional[List[int]] = None
81
+ children: List["TreeNode"] = field(default_factory=list)
82
+
83
+ def to_dict(self) -> Dict[str, Any]:
84
+ """Serialize to a plain dict (recursively)."""
85
+ node: Dict[str, Any] = {
86
+ "title": self.title,
87
+ "summary": self.summary,
88
+ "path": self.path,
89
+ "kind": self.kind,
90
+ }
91
+ if self.range is not None:
92
+ node["range"] = list(self.range)
93
+ if self.children:
94
+ node["children"] = [c.to_dict() for c in self.children]
95
+ return node
96
+
97
+ @classmethod
98
+ def from_dict(cls, data: Dict[str, Any], _depth: int = 0) -> "TreeNode":
99
+ """Reconstruct a node (recursively) from a plain dict.
100
+
101
+ Guards against a forged / corrupt cache: a payload whose children nest
102
+ deeper than MAX_DESERIALIZE_DEPTH would recurse unbounded and
103
+ RecursionError. Past the cap a clear ValueError is raised so callers
104
+ (load_tree_from_store) can treat the cache as a miss and rebuild rather
105
+ than crash.
106
+ """
107
+ if _depth > MAX_DESERIALIZE_DEPTH:
108
+ raise ValueError(
109
+ f"tree nesting exceeds MAX_DESERIALIZE_DEPTH "
110
+ f"({MAX_DESERIALIZE_DEPTH}); refusing to deserialize a "
111
+ "pathologically deep tree"
112
+ )
113
+ children_data = data.get("children") or []
114
+ children = [
115
+ cls.from_dict(c, _depth + 1)
116
+ for c in children_data
117
+ if isinstance(c, dict)
118
+ ]
119
+ rng = data.get("range")
120
+ if rng is not None and not (
121
+ isinstance(rng, list) and len(rng) == 2
122
+ ):
123
+ rng = None
124
+ return cls(
125
+ title=str(data.get("title") or ""),
126
+ summary=str(data.get("summary") or ""),
127
+ path=str(data.get("path") or ""),
128
+ kind=str(data.get("kind") or "section"),
129
+ range=[int(rng[0]), int(rng[1])] if rng else None,
130
+ children=children,
131
+ )
132
+
133
+ def walk(self):
134
+ """Yield this node and every descendant (pre-order)."""
135
+ yield self
136
+ for child in self.children:
137
+ yield from child.walk()
138
+
139
+ def count(self) -> int:
140
+ """Total node count including this node."""
141
+ return sum(1 for _ in self.walk())
142
+
143
+
144
+ # -----------------------------------------------------------------------------
145
+ # Manifest fingerprinting (cache invalidation)
146
+ # -----------------------------------------------------------------------------
147
+
148
+
149
+ def manifest_fingerprint(manifest: Dict[str, Any]) -> str:
150
+ """Compute a stable fingerprint of a manifest for cache invalidation.
151
+
152
+ Uses the manifest version plus each file's sha1 and mtime. Sorted so the
153
+ fingerprint is order-independent. A change to any indexed file (sha1 or
154
+ mtime) or a file added/removed changes the fingerprint.
155
+
156
+ The per-file fields are encoded as a JSON array of [path, sha1, mtime]
157
+ tuples rather than ":"-joined strings: a delimiter join is ambiguous (a
158
+ path "a:b" with sha1 "" collides with a path "a" carrying sha1 "b:"), so
159
+ two distinct manifests could share a fingerprint and serve a stale cached
160
+ tree. JSON quoting/escaping makes the field boundaries unambiguous.
161
+ """
162
+ version = manifest.get("version", 0)
163
+ files = manifest.get("files") or {}
164
+ entries: List[List[str]] = []
165
+ for rel_path in sorted(files.keys()):
166
+ entry = files.get(rel_path) or {}
167
+ sha1 = entry.get("sha1", "")
168
+ mtime = entry.get("mtime", "")
169
+ entries.append([str(rel_path), str(sha1), str(mtime)])
170
+ return json.dumps(
171
+ {"v": version, "files": entries}, sort_keys=True, separators=(",", ":")
172
+ )
173
+
174
+
175
+ # -----------------------------------------------------------------------------
176
+ # TOC tree builder over the code-index manifest
177
+ # -----------------------------------------------------------------------------
178
+
179
+
180
+ def _parse_chunk_id(chunk_id: str) -> str:
181
+ """Extract the symbol portion of a "<rel_path>::<symbol>" chunk id.
182
+
183
+ Returns the symbol label (e.g. "alpha", "build_prompt_L8987"). When the
184
+ chunk id has no "::" separator the whole id is returned as the label.
185
+ """
186
+ if "::" in chunk_id:
187
+ return chunk_id.split("::", 1)[1]
188
+ return chunk_id
189
+
190
+
191
+ def build_tree_from_manifest(
192
+ manifest: Dict[str, Any],
193
+ root_title: str = "codebase",
194
+ ) -> TreeNode:
195
+ """Build a hierarchical TOC tree from a code-index manifest.
196
+
197
+ The manifest maps relative file paths to {chunk_ids, mtime, sha1}. The
198
+ tree mirrors the directory structure:
199
+
200
+ root (dir)
201
+ dir (dir) ...
202
+ file (file)
203
+ symbol (symbol) <- one per chunk id
204
+
205
+ Structure-aware only: no embeddings, no LLM call. The summary fields are
206
+ cheap, deterministic descriptions derived from the structure so tree
207
+ search has something to reason over even before any LLM is consulted.
208
+
209
+ Args:
210
+ manifest: the parsed code-index manifest dict.
211
+ root_title: label for the synthetic root node.
212
+
213
+ Returns:
214
+ The root TreeNode.
215
+ """
216
+ files = manifest.get("files") or {}
217
+ root = TreeNode(
218
+ title=root_title,
219
+ summary=f"Codebase index over {len(files)} file(s).",
220
+ path="",
221
+ kind="root",
222
+ )
223
+
224
+ # Intermediate directory nodes are interned by their posix path so multiple
225
+ # files under the same directory share one node.
226
+ dir_nodes: Dict[str, TreeNode] = {"": root}
227
+
228
+ def _ensure_dir(dir_path: str) -> TreeNode:
229
+ """Return (creating as needed) the dir node for a posix dir path.
230
+
231
+ Iterative (not recursive) so a pathological path with thousands of
232
+ segments cannot blow the stack. The directory chain is also clamped to
233
+ MAX_TREE_DEPTH segments: a forged manifest path with thousands of "/"
234
+ segments would otherwise build a node chain deep enough to later
235
+ RecursionError in walk()/count()/to_dict(). Beyond the cap, the
236
+ remaining segments collapse onto the deepest allowed directory node.
237
+ """
238
+ if dir_path in dir_nodes:
239
+ return dir_nodes[dir_path]
240
+ # Split into segments from the root down, dropping empty segments.
241
+ segments = [s for s in PurePosixPath(dir_path).parts if s]
242
+ # Clamp the directory depth (root is depth 0; reserve room for the file
243
+ # and symbol levels that hang below a directory node).
244
+ if len(segments) > MAX_TREE_DEPTH:
245
+ segments = segments[:MAX_TREE_DEPTH]
246
+ node = root
247
+ accumulated = ""
248
+ for seg in segments:
249
+ accumulated = f"{accumulated}/{seg}" if accumulated else seg
250
+ existing = dir_nodes.get(accumulated)
251
+ if existing is not None:
252
+ node = existing
253
+ continue
254
+ child = TreeNode(
255
+ title=seg,
256
+ summary=f"Directory {accumulated}",
257
+ path=accumulated,
258
+ kind="dir",
259
+ )
260
+ node.children.append(child)
261
+ dir_nodes[accumulated] = child
262
+ node = child
263
+ # Intern the original (possibly over-deep) path to the clamped node so
264
+ # later files under the same long path reuse it without recursing.
265
+ dir_nodes[dir_path] = node
266
+ return node
267
+
268
+ for rel_path in sorted(files.keys()):
269
+ entry = files.get(rel_path) or {}
270
+ chunk_ids = entry.get("chunk_ids") or []
271
+
272
+ posix = PurePosixPath(rel_path)
273
+ parent_dir = str(posix.parent)
274
+ if parent_dir == ".":
275
+ parent_dir = ""
276
+ parent_node = _ensure_dir(parent_dir)
277
+
278
+ file_node = TreeNode(
279
+ title=posix.name,
280
+ summary=f"File {rel_path} with {len(chunk_ids)} indexed symbol(s).",
281
+ path=rel_path,
282
+ kind="file",
283
+ )
284
+ parent_node.children.append(file_node)
285
+
286
+ for chunk_id in chunk_ids:
287
+ if not isinstance(chunk_id, str):
288
+ continue
289
+ symbol = _parse_chunk_id(chunk_id)
290
+ file_node.children.append(
291
+ TreeNode(
292
+ title=symbol,
293
+ summary=f"Symbol {symbol} in {rel_path}",
294
+ path=chunk_id,
295
+ kind="symbol",
296
+ )
297
+ )
298
+
299
+ return root
300
+
301
+
302
+ # -----------------------------------------------------------------------------
303
+ # TOC tree builder over a large spec / PRD document
304
+ # -----------------------------------------------------------------------------
305
+
306
+
307
+ def build_tree_from_markdown(
308
+ text: str,
309
+ root_title: str = "spec",
310
+ path: str = "",
311
+ ) -> TreeNode:
312
+ """Build a TOC tree from a markdown document using its heading structure.
313
+
314
+ Heading levels (#, ##, ###, ...) define the nesting. Each section node
315
+ records the line range it spans so tree search can locate the relevant
316
+ region of a large spec without embeddings. The body text under a heading
317
+ becomes the node summary (truncated). Structure-aware only; no LLM call.
318
+
319
+ Args:
320
+ text: the full markdown document.
321
+ root_title: label for the synthetic root node.
322
+ path: optional source path recorded on every node.
323
+
324
+ Returns:
325
+ The root TreeNode.
326
+ """
327
+ root = TreeNode(title=root_title, summary="", path=path, kind="root")
328
+ # Stack of (level, node). Level 0 is the root.
329
+ stack: List[tuple] = [(0, root)]
330
+ lines = text.splitlines()
331
+
332
+ # Track the current section so trailing body text can seed its summary.
333
+ current_node = root
334
+ body_acc: List[str] = []
335
+
336
+ def _flush_body(node: TreeNode, acc: List[str]) -> None:
337
+ if node.kind == "root" and not node.path:
338
+ # Keep root summary empty unless there is preamble text.
339
+ pass
340
+ snippet = " ".join(s.strip() for s in acc if s.strip())
341
+ if snippet and not node.summary:
342
+ node.summary = snippet[:240]
343
+
344
+ for idx, raw in enumerate(lines):
345
+ stripped = raw.lstrip()
346
+ if stripped.startswith("#"):
347
+ # Count leading hashes for the heading level.
348
+ level = len(stripped) - len(stripped.lstrip("#"))
349
+ title = stripped[level:].strip() or "(untitled)"
350
+ if level < 1:
351
+ continue
352
+ # Clamp the heading level so the section stack (and therefore the
353
+ # built tree depth) never exceeds MAX_TREE_DEPTH. A pathological
354
+ # spec with thousands of strictly-increasing heading levels would
355
+ # otherwise build a node chain deep enough to RecursionError in the
356
+ # recursive walk()/count()/to_dict() traversals. Beyond the cap,
357
+ # deeper headings attach under the deepest allowed section.
358
+ if level > MAX_TREE_DEPTH:
359
+ level = MAX_TREE_DEPTH
360
+
361
+ _flush_body(current_node, body_acc)
362
+ body_acc = []
363
+
364
+ # Pop to the correct parent level.
365
+ while stack and stack[-1][0] >= level:
366
+ stack.pop()
367
+ if not stack:
368
+ stack = [(0, root)]
369
+ parent = stack[-1][1]
370
+
371
+ node = TreeNode(
372
+ title=title,
373
+ summary="",
374
+ path=path,
375
+ kind="section",
376
+ range=[idx + 1, idx + 1],
377
+ )
378
+ parent.children.append(node)
379
+ stack.append((level, node))
380
+ current_node = node
381
+ else:
382
+ body_acc.append(raw)
383
+ # Extend the current section's end line as body accumulates.
384
+ if current_node.range is not None:
385
+ current_node.range[1] = idx + 1
386
+
387
+ _flush_body(current_node, body_acc)
388
+ return root
389
+
390
+
391
+ # -----------------------------------------------------------------------------
392
+ # Cache round-trip through LokiStore
393
+ # -----------------------------------------------------------------------------
394
+
395
+
396
+ def _cache_payload(tree: TreeNode, fingerprint: str) -> bytes:
397
+ payload = {
398
+ "schema_version": TREE_SCHEMA_VERSION,
399
+ "fingerprint": fingerprint,
400
+ "tree": tree.to_dict(),
401
+ }
402
+ return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
403
+
404
+
405
+ def save_tree_to_store(store: Any, tree: TreeNode, fingerprint: str) -> None:
406
+ """Persist a built tree to the LokiStore at TREE_CACHE_KEY.
407
+
408
+ Best-effort: a cache write failure must never break retrieval, so callers
409
+ may wrap this; here we let the store's atomic write do its job and only
410
+ swallow nothing (callers decide). Reuses LokiStore.put atomicity.
411
+ """
412
+ store.put(TREE_CACHE_KEY, _cache_payload(tree, fingerprint))
413
+
414
+
415
+ def load_tree_from_store(
416
+ store: Any, expected_fingerprint: Optional[str] = None
417
+ ) -> Optional[TreeNode]:
418
+ """Load a cached tree from the LokiStore, validating the fingerprint.
419
+
420
+ Returns None (a cache miss) when:
421
+ - the key does not exist,
422
+ - the payload is unreadable / wrong schema version,
423
+ - expected_fingerprint is given and does not match the cached one.
424
+
425
+ A miss is never an error; callers rebuild on a miss.
426
+ """
427
+ try:
428
+ if not store.exists(TREE_CACHE_KEY):
429
+ return None
430
+ raw = store.get(TREE_CACHE_KEY)
431
+ except (FileNotFoundError, OSError, ValueError):
432
+ return None
433
+
434
+ try:
435
+ payload = json.loads(raw.decode("utf-8"))
436
+ except (ValueError, UnicodeDecodeError):
437
+ logger.warning("retrieval-tree cache is corrupt; treating as a miss")
438
+ return None
439
+ except RecursionError:
440
+ # A forged cache whose JSON nests deeper than the json module's own
441
+ # recursion limit raises here before from_dict is ever reached. Treat
442
+ # it as a miss (rebuild) rather than letting it crash retrieval.
443
+ logger.warning(
444
+ "retrieval-tree cache is too deeply nested to parse; "
445
+ "treating as a miss"
446
+ )
447
+ return None
448
+
449
+ if not isinstance(payload, dict):
450
+ return None
451
+ if payload.get("schema_version") != TREE_SCHEMA_VERSION:
452
+ return None
453
+ if (
454
+ expected_fingerprint is not None
455
+ and payload.get("fingerprint") != expected_fingerprint
456
+ ):
457
+ return None
458
+
459
+ tree_data = payload.get("tree")
460
+ if not isinstance(tree_data, dict):
461
+ return None
462
+ try:
463
+ return TreeNode.from_dict(tree_data)
464
+ except (ValueError, RecursionError):
465
+ # A forged / pathologically deep cached tree: from_dict raises
466
+ # ValueError past MAX_TREE_DEPTH, and RecursionError is caught as a
467
+ # belt-and-suspenders guard. Either way the cache is treated as a miss
468
+ # so the caller rebuilds rather than crashing.
469
+ logger.warning(
470
+ "retrieval-tree cache is too deeply nested; treating as a miss"
471
+ )
472
+ return None
473
+
474
+
475
+ def build_or_load_manifest_tree(
476
+ manifest: Dict[str, Any],
477
+ store: Any,
478
+ force_rebuild: bool = False,
479
+ ) -> TreeNode:
480
+ """Return a TOC tree for the manifest, using the LokiStore cache.
481
+
482
+ Builds the tree only on a cache miss or fingerprint mismatch (or when
483
+ force_rebuild is set), then writes it back to the cache. A cache write
484
+ failure is swallowed (best-effort) so retrieval still proceeds with the
485
+ freshly built tree.
486
+ """
487
+ fingerprint = manifest_fingerprint(manifest)
488
+
489
+ if not force_rebuild:
490
+ cached = load_tree_from_store(store, expected_fingerprint=fingerprint)
491
+ if cached is not None:
492
+ return cached
493
+
494
+ tree = build_tree_from_manifest(manifest)
495
+ try:
496
+ save_tree_to_store(store, tree, fingerprint)
497
+ except (OSError, ValueError) as exc:
498
+ logger.warning("could not cache retrieval tree: %s", exc)
499
+ return tree