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,305 @@
1
+ """
2
+ Loki Mode Memory System - LLM-Reasoning Tree Search
3
+
4
+ Navigates the structure-aware TOC tree (memory/tree_index.py) to locate the
5
+ nodes most relevant to a query. This is the PageIndex IDEA: reason DOWN the
6
+ tree (pick relevant children at each level) instead of comparing embedding
7
+ vectors. The reasoning step is delegated to an injected LLM callable so this
8
+ module imports no provider SDK and stays dependency-free.
9
+
10
+ Graceful degradation is a first-class requirement:
11
+ - If an LLM callable is provided, it is asked at each level which children
12
+ to descend into.
13
+ - If NO LLM callable is available (the common local case), the search falls
14
+ back to a deterministic keyword scorer over node titles/summaries/paths.
15
+ The result is a best-effort structure-aware ranking with zero LLM cost.
16
+
17
+ Either way the function returns a ranked list of leaf-ish nodes (as plain
18
+ dicts), so callers can map them back to files/symbols/spec ranges.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import logging
25
+ import re
26
+ from typing import Any, Callable, Dict, List, Optional
27
+
28
+ from .tree_index import TreeNode
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # Type of the optional LLM callable. Given a prompt string it returns the raw
33
+ # model response string. The callable owns provider selection, auth, timeouts.
34
+ LLMCallable = Callable[[str], str]
35
+
36
+ _WORD_RE = re.compile(r"[a-z0-9]+")
37
+
38
+
39
+ def _tokenize(text: str) -> List[str]:
40
+ """Tokenize into lowercase word tokens.
41
+
42
+ Splits on every non-alphanumeric boundary INCLUDING underscores, so a
43
+ symbol like "retrieve_task_aware" yields ["retrieve", "task", "aware"]
44
+ and a natural-language query ("retrieve task aware") matches it. This
45
+ sub-word matching is what lets structure-aware search work without
46
+ embeddings.
47
+ """
48
+ return _WORD_RE.findall((text or "").lower())
49
+
50
+
51
+ def _node_text(node: TreeNode) -> str:
52
+ """Concatenate the searchable text of a node (title, summary, path)."""
53
+ return " ".join([node.title or "", node.summary or "", node.path or ""])
54
+
55
+
56
+ # -----------------------------------------------------------------------------
57
+ # Keyword (no-LLM) fallback scorer
58
+ # -----------------------------------------------------------------------------
59
+
60
+
61
+ def _keyword_score(node: TreeNode, query_tokens: List[str]) -> float:
62
+ """Deterministic relevance score for a node against query tokens.
63
+
64
+ Title matches weigh most, then path, then summary. Used both as the
65
+ no-LLM fallback and as a tie-breaker when ranking the descended nodes.
66
+ """
67
+ if not query_tokens:
68
+ return 0.0
69
+ title_tokens = set(_tokenize(node.title))
70
+ path_tokens = set(_tokenize(node.path))
71
+ summary_tokens = set(_tokenize(node.summary))
72
+ score = 0.0
73
+ for tok in query_tokens:
74
+ if tok in title_tokens:
75
+ score += 2.0
76
+ if tok in path_tokens:
77
+ score += 1.0
78
+ if tok in summary_tokens:
79
+ score += 0.5
80
+ return score
81
+
82
+
83
+ def _keyword_search(
84
+ root: TreeNode, query: str, top_k: int
85
+ ) -> List[Dict[str, Any]]:
86
+ """Rank every leaf-ish node by keyword score (no LLM)."""
87
+ query_tokens = _tokenize(query)
88
+ scored: List[tuple] = []
89
+ for node in root.walk():
90
+ # Leaf-ish: a node with no children (symbol/section/file leaf) is a
91
+ # retrieval target. Files with children are skipped in favor of their
92
+ # symbols; files without indexed symbols remain targets themselves.
93
+ if node.children:
94
+ continue
95
+ if node.kind == "root":
96
+ continue
97
+ score = _keyword_score(node, query_tokens)
98
+ if score > 0:
99
+ scored.append((score, node))
100
+ scored.sort(key=lambda x: x[0], reverse=True)
101
+ return [
102
+ _node_result(node, score, via="keyword")
103
+ for score, node in scored[:top_k]
104
+ ]
105
+
106
+
107
+ def _node_result(node: TreeNode, score: float, via: str) -> Dict[str, Any]:
108
+ """Map a TreeNode to a retrieval result dict.
109
+
110
+ The shape intentionally mirrors keyword retrieval results in retrieval.py
111
+ (_score / _source fields) so downstream merge/budget code can consume it.
112
+ """
113
+ result: Dict[str, Any] = {
114
+ "title": node.title,
115
+ "summary": node.summary,
116
+ "path": node.path,
117
+ "kind": node.kind,
118
+ "_score": float(score),
119
+ "_source": "tree",
120
+ "_retrieval": via,
121
+ }
122
+ if node.range is not None:
123
+ result["range"] = list(node.range)
124
+ return result
125
+
126
+
127
+ # -----------------------------------------------------------------------------
128
+ # LLM-reasoning descent
129
+ # -----------------------------------------------------------------------------
130
+
131
+
132
+ def _build_descent_prompt(
133
+ query: str, node: TreeNode, children: List[TreeNode]
134
+ ) -> str:
135
+ """Build the prompt asking the model which children to descend into."""
136
+ lines = [
137
+ "You are navigating a table-of-contents tree to find the nodes most",
138
+ "relevant to a query. Choose which child nodes are worth exploring.",
139
+ "",
140
+ f"QUERY: {query}",
141
+ "",
142
+ f"CURRENT NODE: {node.title} ({node.kind})",
143
+ "CHILDREN (index: title -- summary):",
144
+ ]
145
+ for i, child in enumerate(children):
146
+ summary = (child.summary or "").strip()
147
+ if len(summary) > 160:
148
+ summary = summary[:157] + "..."
149
+ lines.append(f" {i}: {child.title} -- {summary}")
150
+ lines += [
151
+ "",
152
+ "Respond with ONLY a JSON array of the indexes worth exploring,",
153
+ 'most relevant first, e.g. [2, 0]. Return [] if none are relevant.',
154
+ ]
155
+ return "\n".join(lines)
156
+
157
+
158
+ def _parse_indexes(response: str, n_children: int) -> List[int]:
159
+ """Parse a JSON array of child indexes from an LLM response.
160
+
161
+ Tolerant: extracts the first [...] block, ignores out-of-range / non-int
162
+ entries, dedupes while preserving order. Returns [] when nothing parses
163
+ (the caller then treats this branch as not-descended).
164
+ """
165
+ if not response:
166
+ return []
167
+ match = re.search(r"\[.*?\]", response, re.DOTALL)
168
+ if not match:
169
+ return []
170
+ try:
171
+ parsed = json.loads(match.group(0))
172
+ except (ValueError, TypeError):
173
+ return []
174
+ if not isinstance(parsed, list):
175
+ return []
176
+ seen: set = set()
177
+ out: List[int] = []
178
+ for item in parsed:
179
+ if isinstance(item, bool):
180
+ continue
181
+ if isinstance(item, int) and 0 <= item < n_children and item not in seen:
182
+ seen.add(item)
183
+ out.append(item)
184
+ return out
185
+
186
+
187
+ def _llm_search(
188
+ root: TreeNode,
189
+ query: str,
190
+ top_k: int,
191
+ llm: LLMCallable,
192
+ max_nodes: int,
193
+ beam: int,
194
+ ) -> List[Dict[str, Any]]:
195
+ """Descend the tree using the LLM to pick relevant children per level.
196
+
197
+ A bounded breadth-limited descent: at each internal node the LLM ranks its
198
+ children; the top `beam` are queued. Leaf nodes reached this way are the
199
+ results, ordered by descent depth-rank. Bounded by max_nodes LLM calls so
200
+ a pathological tree cannot run away. Any LLM error at a node degrades that
201
+ branch to the keyword scorer rather than aborting the whole search.
202
+ """
203
+ query_tokens = _tokenize(query)
204
+ results: List[Dict[str, Any]] = []
205
+ # Queue of (node, depth_rank) to expand. depth_rank seeds result ordering.
206
+ queue: List[tuple] = [(root, 0.0)]
207
+ llm_calls = 0
208
+ visited = 0
209
+
210
+ while queue and len(results) < top_k * 3:
211
+ node, rank = queue.pop(0)
212
+ visited += 1
213
+ if not node.children:
214
+ if node.kind != "root":
215
+ # Score the leaf with keyword relevance as a stable secondary
216
+ # signal; descent rank is the primary order.
217
+ kw = _keyword_score(node, query_tokens)
218
+ results.append(
219
+ _node_result(node, score=rank + kw, via="llm")
220
+ )
221
+ continue
222
+
223
+ children = node.children
224
+ chosen: List[int]
225
+ if llm_calls < max_nodes:
226
+ llm_calls += 1
227
+ try:
228
+ response = llm(_build_descent_prompt(query, node, children))
229
+ chosen = _parse_indexes(response, len(children))
230
+ except Exception as exc: # noqa: BLE001 - degrade, never abort
231
+ logger.warning(
232
+ "tree-search LLM call failed at node %s: %s; "
233
+ "falling back to keyword scoring for this branch",
234
+ node.title,
235
+ exc,
236
+ )
237
+ chosen = []
238
+ else:
239
+ chosen = []
240
+
241
+ if not chosen:
242
+ # No LLM guidance (budget exhausted or empty/failed response):
243
+ # rank children by keyword score so the branch still progresses.
244
+ ranked = sorted(
245
+ range(len(children)),
246
+ key=lambda i: _keyword_score(children[i], query_tokens),
247
+ reverse=True,
248
+ )
249
+ positive = [
250
+ i for i in ranked if _keyword_score(children[i], query_tokens) > 0
251
+ ]
252
+ if positive:
253
+ chosen = positive[:beam]
254
+ else:
255
+ # No child matches by keyword. Intermediate structural nodes
256
+ # (directories) rarely share tokens with a query, so dead-ending
257
+ # here would lose every descendant. Descend the top-`beam`
258
+ # children anyway so leaf symbols below still get scored. Leaf
259
+ # nodes (no grandchildren to redeem a 0 score) are NOT forced.
260
+ has_grandchildren = any(c.children for c in children)
261
+ chosen = ranked[:beam] if has_grandchildren else []
262
+
263
+ # Enqueue chosen children, highest-priority first. The descent rank
264
+ # decays with position so earlier picks rank higher in the output.
265
+ for position, idx in enumerate(chosen[:beam]):
266
+ child_rank = rank + (beam - position)
267
+ queue.append((children[idx], child_rank))
268
+
269
+ results.sort(key=lambda r: r.get("_score", 0.0), reverse=True)
270
+ return results[:top_k]
271
+
272
+
273
+ # -----------------------------------------------------------------------------
274
+ # Public entry point
275
+ # -----------------------------------------------------------------------------
276
+
277
+
278
+ def tree_search(
279
+ root: TreeNode,
280
+ query: str,
281
+ top_k: int = 5,
282
+ llm: Optional[LLMCallable] = None,
283
+ max_llm_nodes: int = 32,
284
+ beam: int = 3,
285
+ ) -> List[Dict[str, Any]]:
286
+ """Locate the tree nodes most relevant to a query.
287
+
288
+ Args:
289
+ root: the TOC tree root (from memory/tree_index.py).
290
+ query: the natural-language query.
291
+ top_k: maximum number of result nodes to return.
292
+ llm: optional LLM callable (prompt -> response). When None, a
293
+ deterministic keyword scorer is used (graceful degradation).
294
+ max_llm_nodes: cap on LLM calls during descent (cost guard).
295
+ beam: how many children to descend into per level.
296
+
297
+ Returns:
298
+ A ranked list of result dicts (see _node_result). Each carries a
299
+ "_source": "tree" field and "_retrieval" of "llm" or "keyword".
300
+ """
301
+ if root is None:
302
+ return []
303
+ if llm is None:
304
+ return _keyword_search(root, query, top_k)
305
+ return _llm_search(root, query, top_k, llm, max_llm_nodes, beam)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "7.78.0",
4
+ "version": "7.80.0",
5
5
  "description": "Loki Mode by Autonomi. Autonomous spec-to-product system: takes a PRD, GitHub issue, OpenAPI/JSON/YAML, or one-line brief to a deployed app via the RARV-C closure loop with 8 quality gates. Provider-agnostic (Claude Code, OpenAI Codex, Cline, Aider).",
6
6
  "keywords": [
7
7
  "agent",
@@ -83,6 +83,7 @@
83
83
  "api/",
84
84
  "events/",
85
85
  "memory/",
86
+ "lokistore/",
86
87
  "learning/",
87
88
  "magic/",
88
89
  "templates/",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "loki-mode",
4
4
  "displayName": "Loki Mode",
5
- "version": "7.78.0",
5
+ "version": "7.80.0",
6
6
  "description": "Autonomous spec-to-product build system with a built-in trust layer (RARV-C closure loop, 8 quality gates, completion council). Ships Loki's spec-hardening, drift-detection, and deterministic PR verification commands plus the Loki MCP server.",
7
7
  "author": {
8
8
  "name": "Autonomi",