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,187 @@
1
+ """
2
+ LokiStore factory: select and construct a backend from config or env.
3
+
4
+ Defaults to local. With nothing configured, get_store() returns a LocalStore
5
+ rooted at the project `.loki/` directory, honoring LOKI_DIR / TARGET_DIR
6
+ exactly like the rest of the codebase. Cloud backends are constructed only when
7
+ explicitly selected, and only then is their SDK imported.
8
+
9
+ Config precedence: an explicit config dict overrides env, env overrides the
10
+ local default. Recognized config keys mirror the env vars (without the
11
+ LOKI_STORAGE_ prefix): backend, bucket, prefix, region, base_dir.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ from typing import Any, Dict, Optional
18
+
19
+ from .base import LokiStore, StoreError
20
+ from .local import LocalStore
21
+
22
+
23
+ def resolve_local_base(explicit_base: Optional[str] = None) -> str:
24
+ """
25
+ Resolve the local `.loki/` base directory exactly like run.sh and the
26
+ dashboard do.
27
+
28
+ Resolution order, matching autonomy/run.sh
29
+ (`${LOKI_DIR:-${TARGET_DIR:-.}/.loki}`):
30
+ 1. explicit_base argument, if given
31
+ 2. $LOKI_DIR, if set (used verbatim, like the shell)
32
+ 3. $TARGET_DIR/.loki, if TARGET_DIR is set
33
+ 4. ./.loki (current working directory)
34
+
35
+ Returns:
36
+ The resolved base directory as a string path. It is NOT created here;
37
+ LocalStore creates parents lazily on first write.
38
+ """
39
+ if explicit_base:
40
+ return explicit_base
41
+ loki_dir = os.environ.get("LOKI_DIR")
42
+ if loki_dir:
43
+ return loki_dir
44
+ target_dir = os.environ.get("TARGET_DIR", ".")
45
+ return os.path.join(target_dir, ".loki")
46
+
47
+
48
+ def _config_from_env() -> Dict[str, Any]:
49
+ """Read the LOKI_STORAGE_* env vars into a config dict (omitting unset)."""
50
+ cfg: Dict[str, Any] = {}
51
+ backend = os.environ.get("LOKI_STORAGE_BACKEND")
52
+ if backend:
53
+ cfg["backend"] = backend
54
+ bucket = os.environ.get("LOKI_STORAGE_BUCKET")
55
+ if bucket:
56
+ cfg["bucket"] = bucket
57
+ prefix = os.environ.get("LOKI_STORAGE_PREFIX")
58
+ if prefix:
59
+ cfg["prefix"] = prefix
60
+ region = os.environ.get("LOKI_STORAGE_REGION")
61
+ if region:
62
+ cfg["region"] = region
63
+ return cfg
64
+
65
+
66
+ def build_store(config: Optional[Dict[str, Any]] = None) -> LokiStore:
67
+ """
68
+ Construct a LokiStore from an explicit config dict (no env fallback).
69
+
70
+ Use this when a caller has already assembled config from its own source.
71
+ Prefer get_store() for the standard env-aware path.
72
+
73
+ Recognized keys:
74
+ backend : "local" (default) | "s3" | "gcs" | "azure-blob"
75
+ bucket : bucket/container name (cloud backends)
76
+ prefix : key prefix within the bucket (optional)
77
+ region : region (s3, optional)
78
+ base_dir: local base directory (local backend only; overrides resolution)
79
+ """
80
+ config = dict(config or {})
81
+ backend = (config.get("backend") or "local").strip().lower()
82
+
83
+ if backend in ("local", "", "file", "filesystem"):
84
+ return LocalStore(resolve_local_base(config.get("base_dir")))
85
+
86
+ bucket = config.get("bucket")
87
+ prefix = config.get("prefix")
88
+ region = config.get("region")
89
+
90
+ if backend in ("s3", "aws", "aws-s3"):
91
+ from .cloud import S3Store
92
+
93
+ return S3Store(bucket=bucket, prefix=prefix, region=region)
94
+
95
+ if backend in ("gcs", "gcp", "google", "google-cloud-storage"):
96
+ from .cloud import GCSStore
97
+
98
+ return GCSStore(bucket=bucket, prefix=prefix, region=region)
99
+
100
+ if backend in ("azure-blob", "azure", "azureblob", "azure_blob"):
101
+ from .cloud import AzureBlobStore
102
+
103
+ return AzureBlobStore(bucket=bucket, prefix=prefix, region=region)
104
+
105
+ raise StoreError(
106
+ f"unknown storage backend: {backend!r} "
107
+ "(expected one of: local, s3, gcs, azure-blob)"
108
+ )
109
+
110
+
111
+ def get_store(config: Optional[Dict[str, Any]] = None) -> LokiStore:
112
+ """
113
+ Return a LokiStore, defaulting to local.
114
+
115
+ Resolution:
116
+ 1. Start from the LOKI_STORAGE_* env vars.
117
+ 2. Overlay any explicit config dict (config keys win over env).
118
+ 3. If no backend is selected, default to local.
119
+
120
+ With a clean environment and no config, this returns a LocalStore rooted at
121
+ the project `.loki/` -- zero new deps, zero behavior change.
122
+ """
123
+ merged = _config_from_env()
124
+ if config:
125
+ merged.update({k: v for k, v in config.items() if v is not None})
126
+ return build_store(merged)
127
+
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # Metadata backend selector (stub for this release).
131
+ #
132
+ # Blobs (state, checkpoints, artifacts) go through LokiStore above. Structured
133
+ # metadata (the dashboard's task/run index) lives in a relational store. Today
134
+ # that is the existing async-SQLite database in dashboard/database.py at
135
+ # $LOKI_DATA_DIR/dashboard.db. A postgres backend can be added later for shared
136
+ # multi-instance fleets; the selector below is the seam for that, defaulting to
137
+ # sqlite so there is no behavior change now.
138
+ # ---------------------------------------------------------------------------
139
+
140
+ def get_metadata_backend(config: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
141
+ """
142
+ Resolve the metadata (relational) backend selection.
143
+
144
+ This is intentionally a thin, documented descriptor rather than a live
145
+ connection: the existing dashboard/database.py owns the sqlite engine, and
146
+ a postgres implementation is deferred. Returning a descriptor lets future
147
+ callers branch on the choice without this module importing SQLAlchemy or
148
+ any database driver.
149
+
150
+ Selection (config["metadata_backend"] overrides
151
+ $LOKI_METADATA_BACKEND; default "sqlite"):
152
+ sqlite -> reuse $LOKI_DATA_DIR/dashboard.db (default, implemented today
153
+ in dashboard/database.py)
154
+ postgres -> read $LOKI_METADATA_URL (NOT YET IMPLEMENTED; selecting it
155
+ returns the descriptor with implemented=False so callers can
156
+ fail with a clear message until the impl lands)
157
+
158
+ Returns:
159
+ A descriptor dict: {backend, implemented, dsn, note}.
160
+ """
161
+ config = dict(config or {})
162
+ backend = (
163
+ config.get("metadata_backend")
164
+ or os.environ.get("LOKI_METADATA_BACKEND")
165
+ or "sqlite"
166
+ ).strip().lower()
167
+
168
+ if backend in ("sqlite", "", "default"):
169
+ data_dir = os.environ.get("LOKI_DATA_DIR", os.path.expanduser("~/.loki"))
170
+ return {
171
+ "backend": "sqlite",
172
+ "implemented": True,
173
+ "dsn": os.path.join(data_dir, "dashboard.db"),
174
+ "note": "reuses dashboard/database.py async-sqlite engine",
175
+ }
176
+
177
+ if backend in ("postgres", "postgresql", "pg"):
178
+ return {
179
+ "backend": "postgres",
180
+ "implemented": False,
181
+ "dsn": config.get("metadata_url") or os.environ.get("LOKI_METADATA_URL"),
182
+ "note": "postgres metadata backend is planned, not yet implemented",
183
+ }
184
+
185
+ raise StoreError(
186
+ f"unknown metadata backend: {backend!r} (expected: sqlite, postgres)"
187
+ )
@@ -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
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.78.0'
60
+ __version__ = '7.80.0'
@@ -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 = []