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.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/lib/checkpoint_sync.py +189 -0
- package/autonomy/lib/config-map.sh +955 -0
- package/autonomy/loki +273 -8
- package/autonomy/run.sh +230 -195
- package/autonomy/sandbox.sh +98 -0
- package/autonomy/trigger-server.py +419 -43
- package/dashboard/__init__.py +1 -1
- package/dashboard/registry.py +212 -0
- package/dashboard/server.py +299 -7
- package/dashboard/static/index.html +383 -150
- package/docs/CONFIG-FILE-PLAN.md +459 -0
- package/docs/ENTERPRISE-IDENTITY-ROADMAP.md +206 -0
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/lokistore/__init__.py +65 -0
- package/lokistore/base.py +172 -0
- package/lokistore/cloud.py +305 -0
- package/lokistore/factory.py +187 -0
- package/lokistore/local.py +219 -0
- package/mcp/__init__.py +1 -1
- package/memory/retrieval.py +147 -0
- package/memory/tree_index.py +499 -0
- package/memory/tree_search.py +305 -0
- package/package.json +2 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
|
@@ -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
|