loki-mode 7.81.1 → 7.83.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/completion-council.sh +88 -27
- package/autonomy/lib/config-map.sh +61 -15
- package/autonomy/loki +641 -45
- package/autonomy/run.sh +202 -31
- package/completions/_loki +84 -6
- package/completions/loki.bash +25 -7
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +36 -10
- package/dashboard/registry.py +67 -24
- package/dashboard/server.py +131 -74
- package/docs/INSTALLATION.md +2 -2
- package/events/bus.py +17 -1
- package/events/emit.sh +15 -1
- package/loki-ts/dist/loki.js +7 -4
- package/lokistore/cloud.py +17 -1
- package/lokistore/factory.py +6 -1
- package/lokistore/local.py +11 -3
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +16 -4
- package/memory/cross_project.py +74 -5
- package/memory/embeddings.py +19 -1
- package/memory/retrieval.py +81 -153
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/codex.sh +9 -4
- package/memory/tree_index.py +0 -499
- package/memory/tree_search.py +0 -305
package/memory/retrieval.py
CHANGED
|
@@ -829,6 +829,32 @@ class MemoryRetrieval:
|
|
|
829
829
|
|
|
830
830
|
return items
|
|
831
831
|
|
|
832
|
+
@staticmethod
|
|
833
|
+
def _parse_episode_timestamp(value: Any) -> Optional[datetime]:
|
|
834
|
+
"""Parse an episode timestamp to a tz-aware datetime, or None.
|
|
835
|
+
|
|
836
|
+
Accepts ISO-8601 strings (with or without a trailing Z) and existing
|
|
837
|
+
datetime objects. Returns None when the value is missing or cannot be
|
|
838
|
+
parsed, so callers can fall back to coarser filtering instead of
|
|
839
|
+
crashing on a corrupt record. Naive results are assumed UTC so they
|
|
840
|
+
compare correctly against tz-aware bounds.
|
|
841
|
+
"""
|
|
842
|
+
if not value:
|
|
843
|
+
return None
|
|
844
|
+
try:
|
|
845
|
+
if isinstance(value, datetime):
|
|
846
|
+
dt = value
|
|
847
|
+
elif isinstance(value, str):
|
|
848
|
+
s = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
849
|
+
dt = datetime.fromisoformat(s)
|
|
850
|
+
else:
|
|
851
|
+
return None
|
|
852
|
+
except (ValueError, TypeError):
|
|
853
|
+
return None
|
|
854
|
+
if dt.tzinfo is None:
|
|
855
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
856
|
+
return dt
|
|
857
|
+
|
|
832
858
|
def retrieve_by_temporal(
|
|
833
859
|
self,
|
|
834
860
|
since: datetime,
|
|
@@ -848,6 +874,12 @@ class MemoryRetrieval:
|
|
|
848
874
|
List of memories within the time range
|
|
849
875
|
"""
|
|
850
876
|
until = until or datetime.now(timezone.utc)
|
|
877
|
+
# Normalize bounds to tz-aware UTC so comparisons against tz-aware
|
|
878
|
+
# episode/pattern timestamps below never raise on a naive bound.
|
|
879
|
+
if since.tzinfo is None:
|
|
880
|
+
since = since.replace(tzinfo=timezone.utc)
|
|
881
|
+
if until.tzinfo is None:
|
|
882
|
+
until = until.replace(tzinfo=timezone.utc)
|
|
851
883
|
results: List[Dict[str, Any]] = []
|
|
852
884
|
|
|
853
885
|
# Search episodic memories by date directory (via storage layer)
|
|
@@ -873,6 +905,17 @@ class MemoryRetrieval:
|
|
|
873
905
|
f"episodic/{date_dir.name}/{episode_file.name}"
|
|
874
906
|
)
|
|
875
907
|
if data:
|
|
908
|
+
# The date-dir match above is a coarse, day-granularity
|
|
909
|
+
# prefilter. Without this per-episode timestamp check, an
|
|
910
|
+
# episode at 08:00 on the `since` day was returned even
|
|
911
|
+
# when `since` was 14:00 that same day (and likewise at
|
|
912
|
+
# the `until` boundary). Filter each episode by its own
|
|
913
|
+
# timestamp when one is present and parseable; episodes
|
|
914
|
+
# with a missing/unparseable timestamp keep the previous
|
|
915
|
+
# day-level behavior rather than being silently dropped.
|
|
916
|
+
ep_ts = self._parse_episode_timestamp(data.get("timestamp"))
|
|
917
|
+
if ep_ts is not None and not (since <= ep_ts <= until):
|
|
918
|
+
continue
|
|
876
919
|
data["_source"] = "episodic"
|
|
877
920
|
if self._belongs_to_namespace(data):
|
|
878
921
|
results.append(data)
|
|
@@ -1038,7 +1081,23 @@ class MemoryRetrieval:
|
|
|
1038
1081
|
# Sort by weighted score
|
|
1039
1082
|
all_results.sort(key=lambda x: x.get("_weighted_score", 0), reverse=True)
|
|
1040
1083
|
|
|
1041
|
-
|
|
1084
|
+
# Defense-in-depth dedup by id. The same record can legitimately reach
|
|
1085
|
+
# more than one collection bucket (e.g. an anti-pattern bridged into the
|
|
1086
|
+
# anti_patterns source while a category filter is also expected upstream).
|
|
1087
|
+
# Keep the highest-scoring copy: results are already sorted descending, so
|
|
1088
|
+
# the first occurrence of an id is the best one. Records without an id are
|
|
1089
|
+
# never collapsed together (each keeps its own slot).
|
|
1090
|
+
deduped: List[Dict[str, Any]] = []
|
|
1091
|
+
seen_ids: set = set()
|
|
1092
|
+
for item in all_results:
|
|
1093
|
+
item_id = item.get("id")
|
|
1094
|
+
if item_id is not None:
|
|
1095
|
+
if item_id in seen_ids:
|
|
1096
|
+
continue
|
|
1097
|
+
seen_ids.add(item_id)
|
|
1098
|
+
deduped.append(item)
|
|
1099
|
+
|
|
1100
|
+
return deduped[:top_k]
|
|
1042
1101
|
|
|
1043
1102
|
def _apply_recency_boost(
|
|
1044
1103
|
self,
|
|
@@ -1076,11 +1135,19 @@ class MemoryRetrieval:
|
|
|
1076
1135
|
if item_time.tzinfo is None:
|
|
1077
1136
|
item_time = item_time.replace(tzinfo=timezone.utc)
|
|
1078
1137
|
|
|
1079
|
-
# Calculate age in days
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
#
|
|
1083
|
-
|
|
1138
|
+
# Calculate age in days. Use total_seconds()/86400 for a
|
|
1139
|
+
# continuous value (the .days attribute truncates to whole days,
|
|
1140
|
+
# losing sub-day resolution, e.g. an 18-hour-old record reads as
|
|
1141
|
+
# age 0 instead of 0.75).
|
|
1142
|
+
age_days = (now - item_time).total_seconds() / 86400.0
|
|
1143
|
+
|
|
1144
|
+
# Boost decays linearly over 30 days. Gate on [0, 30): a
|
|
1145
|
+
# future-dated record (clock skew or a forward-stamped entry)
|
|
1146
|
+
# has a negative age and must NOT be treated as the freshest
|
|
1147
|
+
# record. The old code (age_days < 30) let a negative age through
|
|
1148
|
+
# and produced boost = boost_factor * (1 - negative/30) > the
|
|
1149
|
+
# intended cap, inflating future records above all real ones.
|
|
1150
|
+
if 0 <= age_days < 30:
|
|
1084
1151
|
boost = boost_factor * (1 - age_days / 30)
|
|
1085
1152
|
current_score = result.get("_weighted_score", result.get("_score", 0.5))
|
|
1086
1153
|
result["_weighted_score"] = current_score * (1 + boost)
|
|
@@ -1483,153 +1550,6 @@ class MemoryRetrieval:
|
|
|
1483
1550
|
# Private Helper Methods
|
|
1484
1551
|
# -------------------------------------------------------------------------
|
|
1485
1552
|
|
|
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
|
-
|
|
1633
1553
|
def _build_query_from_context(self, context: Dict[str, Any]) -> str:
|
|
1634
1554
|
"""Build a query string from context dictionary."""
|
|
1635
1555
|
parts = []
|
|
@@ -1723,6 +1643,14 @@ class MemoryRetrieval:
|
|
|
1723
1643
|
for pattern in patterns_data.get("patterns", []):
|
|
1724
1644
|
if not isinstance(pattern, dict):
|
|
1725
1645
|
continue
|
|
1646
|
+
# Anti-patterns live in this same patterns.json (consolidation
|
|
1647
|
+
# writes them as SemanticPattern records with category="anti-pattern").
|
|
1648
|
+
# They are surfaced separately by _keyword_search_anti_patterns, which
|
|
1649
|
+
# bridges those records into the anti_patterns source. Including them
|
|
1650
|
+
# here too returns the same record twice (once as semantic, once as
|
|
1651
|
+
# anti_patterns), double-counting and wasting token budget. Skip them.
|
|
1652
|
+
if (pattern.get("category") or "").lower() == "anti-pattern":
|
|
1653
|
+
continue
|
|
1726
1654
|
# Defensive: corrupt or hand-edited records may carry null
|
|
1727
1655
|
# string fields; (x or "") avoids AttributeError on None.
|
|
1728
1656
|
pattern_text = (pattern.get("pattern") or "").lower()
|
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.
|
|
4
|
+
"version": "7.83.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",
|
|
@@ -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.
|
|
5
|
+
"version": "7.83.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",
|
package/providers/codex.sh
CHANGED
|
@@ -73,10 +73,15 @@ _codex_validate_model() {
|
|
|
73
73
|
echo "$CODEX_DEFAULT_MODEL"
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
# Provider-specific env (LOKI_CODEX_MODEL) is trusted
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
# Provider-specific env (LOKI_CODEX_MODEL) is trusted and used verbatim -- the
|
|
77
|
+
# operator set a Codex-specific model on purpose (e.g. a fine-tune or org-scoped
|
|
78
|
+
# name that need not match CODEX_KNOWN_MODELS). Only the GENERIC LOKI_MODEL_*
|
|
79
|
+
# fallback is validated, since it may carry Claude aliases (opus/sonnet/haiku)
|
|
80
|
+
# that are invalid for Codex. Validating the whole chain silently downgraded a
|
|
81
|
+
# trusted LOKI_CODEX_MODEL to the default (BUG-PROV-003 fix).
|
|
82
|
+
PROVIDER_MODEL_PLANNING="${LOKI_CODEX_MODEL:-$(_codex_validate_model "${LOKI_MODEL_PLANNING:-$CODEX_DEFAULT_MODEL}")}"
|
|
83
|
+
PROVIDER_MODEL_DEVELOPMENT="${LOKI_CODEX_MODEL:-$(_codex_validate_model "${LOKI_MODEL_DEVELOPMENT:-$CODEX_DEFAULT_MODEL}")}"
|
|
84
|
+
PROVIDER_MODEL_FAST="${LOKI_CODEX_MODEL:-$(_codex_validate_model "${LOKI_MODEL_FAST:-$CODEX_DEFAULT_MODEL}")}"
|
|
80
85
|
|
|
81
86
|
# Effort levels (Codex-specific: maps to reasoning time, not model capability)
|
|
82
87
|
PROVIDER_EFFORT_PLANNING="xhigh"
|