loki-mode 7.79.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/loki +130 -0
- package/autonomy/run.sh +3 -0
- package/dashboard/__init__.py +1 -1
- package/dashboard/registry.py +212 -0
- package/dashboard/server.py +236 -0
- package/dashboard/static/index.html +383 -150
- 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,219 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LocalStore: the default, always-available, zero-dependency LokiStore backend.
|
|
3
|
+
|
|
4
|
+
Backed by a base directory (the project `.loki/` by default). Behavior is
|
|
5
|
+
byte-identical to today's direct `.loki/` file writes:
|
|
6
|
+
|
|
7
|
+
- Atomic writes via a temp file in the same directory + os.replace (an atomic
|
|
8
|
+
rename on the same filesystem), so a reader never sees a torn file.
|
|
9
|
+
- Best-effort fcntl advisory locking for concurrent writers of the same key,
|
|
10
|
+
reentrant per-thread to avoid self-deadlock, using PERSISTENT lock files
|
|
11
|
+
(never unlinked on release) to avoid the flock+unlink inode-replacement race
|
|
12
|
+
documented in memory/storage.py.
|
|
13
|
+
- Path-traversal guard: a key can never escape the base directory.
|
|
14
|
+
|
|
15
|
+
This mirrors the house idiom in memory/storage.py and dashboard/registry.py so
|
|
16
|
+
local users get exactly the durability they have today, with no new deps.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import contextlib
|
|
22
|
+
import os
|
|
23
|
+
import tempfile
|
|
24
|
+
import threading
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import List, Union
|
|
27
|
+
|
|
28
|
+
from .base import LokiStore, normalize_key, read_source_bytes
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
import fcntl # POSIX only; absent on Windows
|
|
32
|
+
except ImportError: # pragma: no cover - exercised only on Windows
|
|
33
|
+
fcntl = None # type: ignore[assignment]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class LocalStore(LokiStore):
|
|
37
|
+
"""Filesystem-backed LokiStore rooted at a base directory."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, base_dir: Union[str, os.PathLike]):
|
|
40
|
+
"""
|
|
41
|
+
Args:
|
|
42
|
+
base_dir: Root directory for all keys. Created on first write.
|
|
43
|
+
Typically the project `.loki/` directory; the factory
|
|
44
|
+
resolves this honoring LOKI_DIR / TARGET_DIR.
|
|
45
|
+
"""
|
|
46
|
+
self._base = Path(base_dir).expanduser()
|
|
47
|
+
# Reentrant lock tracking, mirroring memory/storage.py: a thread that
|
|
48
|
+
# already holds the lock for a path skips re-acquiring it, so nested
|
|
49
|
+
# operations on the same key do not deadlock.
|
|
50
|
+
self._held_locks: threading.local = threading.local()
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def base_dir(self) -> Path:
|
|
54
|
+
"""The resolved root directory for this store."""
|
|
55
|
+
return self._base
|
|
56
|
+
|
|
57
|
+
# -- internal helpers ---------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def _resolve(self, key: str) -> Path:
|
|
60
|
+
"""
|
|
61
|
+
Map a normalized key to an absolute path inside the base dir, with a
|
|
62
|
+
realpath-based defense in depth so even a symlink inside the base
|
|
63
|
+
cannot redirect a read or write outside it.
|
|
64
|
+
"""
|
|
65
|
+
clean = normalize_key(key)
|
|
66
|
+
full = self._base / clean
|
|
67
|
+
|
|
68
|
+
real_base = os.path.realpath(self._base)
|
|
69
|
+
|
|
70
|
+
def _under_base(real_path: str) -> bool:
|
|
71
|
+
return real_path == real_base or real_path.startswith(
|
|
72
|
+
real_base + os.sep
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Guard the parent so a key can never be created outside the base, even
|
|
76
|
+
# when the target itself does not exist yet (the write path).
|
|
77
|
+
real_parent = os.path.realpath(full.parent)
|
|
78
|
+
if not _under_base(real_parent):
|
|
79
|
+
raise ValueError(f"key escapes store base directory: {key!r}")
|
|
80
|
+
|
|
81
|
+
# Guard the full target too: a leaf symlink placed AT the key path and
|
|
82
|
+
# pointing outside the base would otherwise be followed on read
|
|
83
|
+
# (get/get_to/exists), leaking an arbitrary file. realpath resolves the
|
|
84
|
+
# leaf symlink, so a target whose real location is outside the base is
|
|
85
|
+
# rejected. This is a no-op for ordinary files and for not-yet-existing
|
|
86
|
+
# keys (whose realpath stays inside the already-checked parent).
|
|
87
|
+
real_full = os.path.realpath(full)
|
|
88
|
+
if not _under_base(real_full):
|
|
89
|
+
raise ValueError(f"key escapes store base directory: {key!r}")
|
|
90
|
+
|
|
91
|
+
return full
|
|
92
|
+
|
|
93
|
+
@contextlib.contextmanager
|
|
94
|
+
def _file_lock(self, path: Path):
|
|
95
|
+
"""
|
|
96
|
+
Reentrant, best-effort exclusive advisory lock around a single key's
|
|
97
|
+
write. Uses a persistent sibling ".lock" file (never unlinked on
|
|
98
|
+
release) to avoid the flock+unlink inode race. Degrades to a no-op
|
|
99
|
+
where fcntl is unavailable (Windows); the atomic rename still
|
|
100
|
+
guarantees no torn reads, only lost-update protection is best-effort.
|
|
101
|
+
"""
|
|
102
|
+
lock_path = path.with_suffix(path.suffix + ".lock")
|
|
103
|
+
lock_key = str(lock_path)
|
|
104
|
+
|
|
105
|
+
if not hasattr(self._held_locks, "paths"):
|
|
106
|
+
self._held_locks.paths = set()
|
|
107
|
+
|
|
108
|
+
# Reentrant: this thread already holds it -> no-op.
|
|
109
|
+
if lock_key in self._held_locks.paths:
|
|
110
|
+
yield
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if fcntl is None:
|
|
114
|
+
# No advisory locking available; proceed (atomic rename still safe).
|
|
115
|
+
yield
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
lock_file = None
|
|
120
|
+
try:
|
|
121
|
+
lock_file = open(lock_path, "w")
|
|
122
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
123
|
+
self._held_locks.paths.add(lock_key)
|
|
124
|
+
yield
|
|
125
|
+
finally:
|
|
126
|
+
self._held_locks.paths.discard(lock_key)
|
|
127
|
+
if lock_file is not None:
|
|
128
|
+
try:
|
|
129
|
+
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
130
|
+
finally:
|
|
131
|
+
lock_file.close()
|
|
132
|
+
# Deliberately do NOT unlink the lock file (see module
|
|
133
|
+
# docstring and memory/storage.py for the inode-race rationale).
|
|
134
|
+
|
|
135
|
+
def _atomic_write_bytes(self, path: Path, payload: bytes) -> None:
|
|
136
|
+
"""Write payload to path atomically (temp in same dir + os.replace)."""
|
|
137
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
with self._file_lock(path):
|
|
139
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
140
|
+
dir=str(path.parent), prefix=".tmp_", suffix=".part"
|
|
141
|
+
)
|
|
142
|
+
try:
|
|
143
|
+
with os.fdopen(fd, "wb") as f:
|
|
144
|
+
f.write(payload)
|
|
145
|
+
f.flush()
|
|
146
|
+
os.fsync(f.fileno())
|
|
147
|
+
os.replace(tmp_path, str(path))
|
|
148
|
+
except BaseException:
|
|
149
|
+
with contextlib.suppress(OSError):
|
|
150
|
+
os.unlink(tmp_path)
|
|
151
|
+
raise
|
|
152
|
+
|
|
153
|
+
# -- LokiStore interface ------------------------------------------------
|
|
154
|
+
|
|
155
|
+
def put(self, key: str, data: Union[bytes, bytearray, str, os.PathLike]) -> None:
|
|
156
|
+
payload = read_source_bytes(data)
|
|
157
|
+
path = self._resolve(key)
|
|
158
|
+
self._atomic_write_bytes(path, payload)
|
|
159
|
+
|
|
160
|
+
def get(self, key: str) -> bytes:
|
|
161
|
+
path = self._resolve(key)
|
|
162
|
+
if not path.is_file():
|
|
163
|
+
raise FileNotFoundError(f"no such key: {key!r}")
|
|
164
|
+
with self._file_lock(path):
|
|
165
|
+
with open(path, "rb") as f:
|
|
166
|
+
return f.read()
|
|
167
|
+
|
|
168
|
+
def get_to(self, key: str, dest_path: Union[str, os.PathLike]) -> None:
|
|
169
|
+
payload = self.get(key)
|
|
170
|
+
dest = Path(dest_path).expanduser()
|
|
171
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
# Reuse the atomic-write helper so the destination is never torn.
|
|
173
|
+
self._atomic_write_bytes(dest, payload)
|
|
174
|
+
|
|
175
|
+
def exists(self, key: str) -> bool:
|
|
176
|
+
return self._resolve(key).is_file()
|
|
177
|
+
|
|
178
|
+
def list(self, prefix: str = "") -> List[str]:
|
|
179
|
+
# Normalize the prefix to a path under the base. An empty prefix lists
|
|
180
|
+
# everything under the base.
|
|
181
|
+
if prefix:
|
|
182
|
+
clean_prefix = normalize_key(prefix)
|
|
183
|
+
search_root = self._base / clean_prefix
|
|
184
|
+
else:
|
|
185
|
+
search_root = self._base
|
|
186
|
+
|
|
187
|
+
if not search_root.exists():
|
|
188
|
+
return []
|
|
189
|
+
|
|
190
|
+
base_str = os.path.realpath(self._base)
|
|
191
|
+
results: List[str] = []
|
|
192
|
+
|
|
193
|
+
if search_root.is_file():
|
|
194
|
+
# Prefix pointed directly at a key.
|
|
195
|
+
rel = os.path.relpath(str(search_root), base_str).replace(os.sep, "/")
|
|
196
|
+
return [rel]
|
|
197
|
+
|
|
198
|
+
for root, _dirs, files in os.walk(search_root):
|
|
199
|
+
for name in files:
|
|
200
|
+
full = os.path.join(root, name)
|
|
201
|
+
# Skip internal lock and temp files so they never surface as keys.
|
|
202
|
+
if name.endswith(".lock") or name.startswith(".tmp_"):
|
|
203
|
+
continue
|
|
204
|
+
rel = os.path.relpath(full, base_str).replace(os.sep, "/")
|
|
205
|
+
results.append(rel)
|
|
206
|
+
|
|
207
|
+
results.sort()
|
|
208
|
+
return results
|
|
209
|
+
|
|
210
|
+
def delete(self, key: str) -> bool:
|
|
211
|
+
path = self._resolve(key)
|
|
212
|
+
if not path.is_file():
|
|
213
|
+
return False
|
|
214
|
+
with self._file_lock(path):
|
|
215
|
+
try:
|
|
216
|
+
os.unlink(path)
|
|
217
|
+
return True
|
|
218
|
+
except FileNotFoundError:
|
|
219
|
+
return False
|
package/mcp/__init__.py
CHANGED
package/memory/retrieval.py
CHANGED
|
@@ -1483,6 +1483,153 @@ class MemoryRetrieval:
|
|
|
1483
1483
|
# Private Helper Methods
|
|
1484
1484
|
# -------------------------------------------------------------------------
|
|
1485
1485
|
|
|
1486
|
+
# -------------------------------------------------------------------------
|
|
1487
|
+
# Optional structure-aware tree retrieval (PageIndex pattern, OFF by default)
|
|
1488
|
+
# -------------------------------------------------------------------------
|
|
1489
|
+
|
|
1490
|
+
def retrieve_tree(
|
|
1491
|
+
self,
|
|
1492
|
+
context: Dict[str, Any],
|
|
1493
|
+
top_k: int = 5,
|
|
1494
|
+
manifest: Optional[Dict[str, Any]] = None,
|
|
1495
|
+
store: Optional[Any] = None,
|
|
1496
|
+
llm: Optional[Callable[[str], str]] = None,
|
|
1497
|
+
) -> List[Dict[str, Any]]:
|
|
1498
|
+
"""Structure-aware tree retrieval over the code-index manifest.
|
|
1499
|
+
|
|
1500
|
+
Third, parallel, OPTIONAL retrieval path alongside keyword and vector.
|
|
1501
|
+
It is NEVER reached unless a caller invokes it (directly or through the
|
|
1502
|
+
LOKI_RETRIEVAL_MODE=tree dispatcher in retrieve_dispatch). The default
|
|
1503
|
+
retrieve_task_aware path is byte-unchanged.
|
|
1504
|
+
|
|
1505
|
+
Builds (or loads from the LokiStore cache) a TOC tree from the code
|
|
1506
|
+
index manifest, then reasons down it for the query. Degrades to a
|
|
1507
|
+
deterministic keyword scorer when no LLM callable is available, and
|
|
1508
|
+
further degrades to the existing keyword retrieval path when the
|
|
1509
|
+
manifest itself is absent.
|
|
1510
|
+
|
|
1511
|
+
Args:
|
|
1512
|
+
context: query context (goal, phase, action_type, files).
|
|
1513
|
+
top_k: maximum number of results.
|
|
1514
|
+
manifest: parsed code-index manifest. When None, it is loaded from
|
|
1515
|
+
.loki/state/code-index-manifest.json (relative to the store).
|
|
1516
|
+
store: a LokiStore for caching the built tree. When None, one is
|
|
1517
|
+
built via lokistore.get_store() (local default, no new deps).
|
|
1518
|
+
llm: optional LLM callable (prompt -> response) for reasoning
|
|
1519
|
+
descent. When None, the keyword scorer is used.
|
|
1520
|
+
|
|
1521
|
+
Returns:
|
|
1522
|
+
A ranked list of result dicts. Each carries "_source": "tree".
|
|
1523
|
+
On any failure or a missing manifest, falls back to the existing
|
|
1524
|
+
keyword retrieval so the caller always gets results.
|
|
1525
|
+
"""
|
|
1526
|
+
# Local imports keep these optional modules off the default import path.
|
|
1527
|
+
try:
|
|
1528
|
+
from .tree_index import build_or_load_manifest_tree
|
|
1529
|
+
from .tree_search import tree_search
|
|
1530
|
+
except ImportError as exc: # pragma: no cover - defensive
|
|
1531
|
+
logger.warning("tree retrieval modules unavailable: %s", exc)
|
|
1532
|
+
return self._tree_keyword_fallback(context, top_k)
|
|
1533
|
+
|
|
1534
|
+
if store is None:
|
|
1535
|
+
try:
|
|
1536
|
+
from lokistore import get_store
|
|
1537
|
+
|
|
1538
|
+
store = get_store()
|
|
1539
|
+
except Exception as exc: # noqa: BLE001 - degrade, never abort
|
|
1540
|
+
logger.warning("could not obtain LokiStore for tree cache: %s", exc)
|
|
1541
|
+
store = None
|
|
1542
|
+
|
|
1543
|
+
if manifest is None:
|
|
1544
|
+
manifest = self._load_code_index_manifest(store)
|
|
1545
|
+
|
|
1546
|
+
if not manifest or not (manifest.get("files") or {}):
|
|
1547
|
+
# No structure to reason over: fall back to keyword retrieval so
|
|
1548
|
+
# the caller still gets results.
|
|
1549
|
+
return self._tree_keyword_fallback(context, top_k)
|
|
1550
|
+
|
|
1551
|
+
query = self._build_query_from_context(context)
|
|
1552
|
+
|
|
1553
|
+
try:
|
|
1554
|
+
if store is not None:
|
|
1555
|
+
tree = build_or_load_manifest_tree(manifest, store)
|
|
1556
|
+
else:
|
|
1557
|
+
from .tree_index import build_tree_from_manifest
|
|
1558
|
+
|
|
1559
|
+
tree = build_tree_from_manifest(manifest)
|
|
1560
|
+
return tree_search(tree, query, top_k=top_k, llm=llm)
|
|
1561
|
+
except Exception as exc: # noqa: BLE001 - degrade, never abort
|
|
1562
|
+
logger.warning("tree retrieval failed (%s); using keyword fallback", exc)
|
|
1563
|
+
return self._tree_keyword_fallback(context, top_k)
|
|
1564
|
+
|
|
1565
|
+
def retrieve_dispatch(
|
|
1566
|
+
self,
|
|
1567
|
+
context: Dict[str, Any],
|
|
1568
|
+
top_k: int = 5,
|
|
1569
|
+
token_budget: Optional[int] = None,
|
|
1570
|
+
mode: Optional[str] = None,
|
|
1571
|
+
**tree_kwargs: Any,
|
|
1572
|
+
) -> List[Dict[str, Any]]:
|
|
1573
|
+
"""Dispatch to a retrieval mode, defaulting to the existing path.
|
|
1574
|
+
|
|
1575
|
+
Mode resolution (first non-empty wins):
|
|
1576
|
+
1. explicit `mode` argument
|
|
1577
|
+
2. LOKI_RETRIEVAL_MODE env var
|
|
1578
|
+
3. "task_aware" (the existing default path)
|
|
1579
|
+
|
|
1580
|
+
Only mode == "tree" diverges; every other value (including the default)
|
|
1581
|
+
calls retrieve_task_aware UNCHANGED, so local devs who set nothing get
|
|
1582
|
+
byte-identical behavior. Unknown modes also fall through to the default.
|
|
1583
|
+
"""
|
|
1584
|
+
import os as _os
|
|
1585
|
+
|
|
1586
|
+
resolved = (mode or _os.environ.get("LOKI_RETRIEVAL_MODE") or "task_aware")
|
|
1587
|
+
resolved = resolved.strip().lower()
|
|
1588
|
+
|
|
1589
|
+
if resolved == "tree":
|
|
1590
|
+
return self.retrieve_tree(context, top_k=top_k, **tree_kwargs)
|
|
1591
|
+
|
|
1592
|
+
# Default and any unknown mode: existing behavior, untouched.
|
|
1593
|
+
return self.retrieve_task_aware(
|
|
1594
|
+
context, top_k=top_k, token_budget=token_budget
|
|
1595
|
+
)
|
|
1596
|
+
|
|
1597
|
+
def _load_code_index_manifest(
|
|
1598
|
+
self, store: Optional[Any]
|
|
1599
|
+
) -> Optional[Dict[str, Any]]:
|
|
1600
|
+
"""Load the code-index manifest, preferring the LokiStore.
|
|
1601
|
+
|
|
1602
|
+
Tries the store key "state/code-index-manifest.json" first (so it
|
|
1603
|
+
honors LOKI_DIR / TARGET_DIR resolution), then a direct filesystem
|
|
1604
|
+
read as a fallback. Returns None when no manifest is found.
|
|
1605
|
+
"""
|
|
1606
|
+
manifest_key = "state/code-index-manifest.json"
|
|
1607
|
+
if store is not None:
|
|
1608
|
+
try:
|
|
1609
|
+
if store.exists(manifest_key):
|
|
1610
|
+
raw = store.get(manifest_key)
|
|
1611
|
+
return json.loads(raw.decode("utf-8"))
|
|
1612
|
+
except (FileNotFoundError, OSError, ValueError, UnicodeDecodeError):
|
|
1613
|
+
pass
|
|
1614
|
+
# Filesystem fallback relative to the configured base path.
|
|
1615
|
+
candidate = Path(".loki/state/code-index-manifest.json")
|
|
1616
|
+
try:
|
|
1617
|
+
if candidate.is_file():
|
|
1618
|
+
return json.loads(candidate.read_text(encoding="utf-8"))
|
|
1619
|
+
except (OSError, ValueError):
|
|
1620
|
+
pass
|
|
1621
|
+
return None
|
|
1622
|
+
|
|
1623
|
+
def _tree_keyword_fallback(
|
|
1624
|
+
self, context: Dict[str, Any], top_k: int
|
|
1625
|
+
) -> List[Dict[str, Any]]:
|
|
1626
|
+
"""Fallback used by tree retrieval: the existing keyword path.
|
|
1627
|
+
|
|
1628
|
+
Reuses retrieve_task_aware so a tree-mode caller is never worse off
|
|
1629
|
+
than the default mode when the manifest or an LLM is unavailable.
|
|
1630
|
+
"""
|
|
1631
|
+
return self.retrieve_task_aware(context, top_k=top_k)
|
|
1632
|
+
|
|
1486
1633
|
def _build_query_from_context(self, context: Dict[str, Any]) -> str:
|
|
1487
1634
|
"""Build a query string from context dictionary."""
|
|
1488
1635
|
parts = []
|