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.
@@ -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
- return all_results[:top_k]
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
- age_days = (now - item_time).days
1081
-
1082
- # Boost decays linearly over 30 days
1083
- if age_days < 30:
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.81.1",
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.81.1",
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",
@@ -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; generic LOKI_MODEL_* is validated
77
- PROVIDER_MODEL_PLANNING="$(_codex_validate_model "${LOKI_CODEX_MODEL:-${LOKI_MODEL_PLANNING:-$CODEX_DEFAULT_MODEL}}")"
78
- PROVIDER_MODEL_DEVELOPMENT="$(_codex_validate_model "${LOKI_CODEX_MODEL:-${LOKI_MODEL_DEVELOPMENT:-$CODEX_DEFAULT_MODEL}}")"
79
- PROVIDER_MODEL_FAST="$(_codex_validate_model "${LOKI_CODEX_MODEL:-${LOKI_MODEL_FAST:-$CODEX_DEFAULT_MODEL}}")"
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"