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,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
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 = []
|