loki-mode 6.3.1 → 6.5.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/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '6.3.1'
60
+ __version__ = '6.5.0'
package/mcp/server.py CHANGED
@@ -20,6 +20,7 @@ import os
20
20
  import json
21
21
  import logging
22
22
  import threading
23
+ import uuid
23
24
  from datetime import datetime, timezone
24
25
  from typing import Optional, List, Dict, Any
25
26
 
@@ -563,11 +564,11 @@ async def loki_memory_store_pattern(
563
564
  from memory.schemas import SemanticPattern
564
565
 
565
566
  base_path = safe_path_join('.loki', 'memory')
566
- engine = MemoryEngine(base_path)
567
+ engine = MemoryEngine(base_path=base_path)
567
568
  engine.initialize()
568
569
 
569
570
  pattern_obj = SemanticPattern(
570
- id=f"pattern-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}",
571
+ id=f"pattern-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8]}",
571
572
  pattern=pattern,
572
573
  category=category,
573
574
  conditions=[],
@@ -821,7 +822,7 @@ async def loki_state_get() -> str:
821
822
  try:
822
823
  from memory.engine import MemoryEngine
823
824
  memory_path = safe_path_join('.loki', 'memory')
824
- engine = MemoryEngine(memory_path)
825
+ engine = MemoryEngine(base_path=memory_path)
825
826
  state["memory_stats"] = engine.get_stats()
826
827
  except Exception:
827
828
  state["memory_stats"] = None
@@ -1004,9 +1005,13 @@ async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
1004
1005
  try:
1005
1006
  content = prd_content
1006
1007
  if not content and prd_path:
1007
- resolved = safe_path_join('.', prd_path)
1008
- if os.path.exists(resolved):
1009
- with safe_open(resolved, 'r') as f:
1008
+ # Resolve relative paths against project root, absolute paths used as-is
1009
+ if os.path.isabs(prd_path):
1010
+ resolved = os.path.realpath(prd_path)
1011
+ else:
1012
+ resolved = os.path.realpath(os.path.join(get_project_root(), prd_path))
1013
+ if os.path.exists(resolved) and os.path.isfile(resolved):
1014
+ with open(resolved, 'r', encoding='utf-8') as f:
1010
1015
  content = f.read()
1011
1016
  else:
1012
1017
  return json.dumps({"error": f"PRD file not found: {prd_path}"})
@@ -307,6 +307,10 @@ class TextChunker:
307
307
  if len(text) <= max_size:
308
308
  return [text]
309
309
 
310
+ # Guard against infinite loop when overlap >= max_size
311
+ if overlap >= max_size:
312
+ overlap = 0
313
+
310
314
  chunks = []
311
315
  start = 0
312
316
  while start < len(text):
@@ -1062,12 +1066,17 @@ class EmbeddingEngine:
1062
1066
  # Find texts that need computing
1063
1067
  texts_to_compute = []
1064
1068
  indices_to_compute = []
1065
- for i, (text, key) in enumerate(zip(texts, cache_keys)):
1066
- if not self.config.cache_enabled or cached_results.get(key) is None:
1067
- texts_to_compute.append(text)
1068
- indices_to_compute.append(i)
1069
- else:
1070
- self._metrics["cache_hits"] += 1
1069
+ if not self.config.cache_enabled:
1070
+ # No cache - all texts need computing
1071
+ texts_to_compute = list(texts)
1072
+ indices_to_compute = list(range(len(texts)))
1073
+ else:
1074
+ for i, (text, key) in enumerate(zip(texts, cache_keys)):
1075
+ if cached_results.get(key) is None:
1076
+ texts_to_compute.append(text)
1077
+ indices_to_compute.append(i)
1078
+ else:
1079
+ self._metrics["cache_hits"] += 1
1071
1080
 
1072
1081
  # Compute missing embeddings
1073
1082
  new_embeddings = None
package/memory/engine.py CHANGED
@@ -881,6 +881,21 @@ class MemoryEngine:
881
881
  for e in errors_raw
882
882
  ]
883
883
 
884
+ # Parse last_accessed datetime
885
+ last_accessed = None
886
+ last_accessed_raw = data.get("last_accessed")
887
+ if last_accessed_raw:
888
+ if isinstance(last_accessed_raw, str):
889
+ if last_accessed_raw.endswith("Z"):
890
+ last_accessed_raw = last_accessed_raw[:-1]
891
+ last_accessed = datetime.fromisoformat(last_accessed_raw)
892
+ if last_accessed.tzinfo is None:
893
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
894
+ elif isinstance(last_accessed_raw, datetime):
895
+ last_accessed = last_accessed_raw
896
+ if last_accessed.tzinfo is None:
897
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
898
+
884
899
  return EpisodeTrace(
885
900
  id=data.get("id", ""),
886
901
  task_id=data.get("task_id", ""),
@@ -897,6 +912,9 @@ class MemoryEngine:
897
912
  tokens_used=data.get("tokens_used", 0),
898
913
  files_read=data.get("files_read", context.get("files_involved", [])),
899
914
  files_modified=data.get("files_modified", []),
915
+ importance=data.get("importance", 0.5),
916
+ last_accessed=last_accessed,
917
+ access_count=data.get("access_count", 0),
900
918
  )
901
919
 
902
920
  def _dict_to_pattern(self, data: Dict[str, Any]) -> SemanticPattern:
@@ -924,6 +942,21 @@ class MemoryEngine:
924
942
  for link in links_raw
925
943
  ]
926
944
 
945
+ # Parse last_accessed datetime
946
+ last_accessed = None
947
+ last_accessed_raw = data.get("last_accessed")
948
+ if last_accessed_raw:
949
+ if isinstance(last_accessed_raw, str):
950
+ if last_accessed_raw.endswith("Z"):
951
+ last_accessed_raw = last_accessed_raw[:-1]
952
+ last_accessed = datetime.fromisoformat(last_accessed_raw)
953
+ if last_accessed.tzinfo is None:
954
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
955
+ elif isinstance(last_accessed_raw, datetime):
956
+ last_accessed = last_accessed_raw
957
+ if last_accessed.tzinfo is None:
958
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
959
+
927
960
  return SemanticPattern(
928
961
  id=data.get("id", ""),
929
962
  pattern=data.get("pattern", ""),
@@ -936,6 +969,9 @@ class MemoryEngine:
936
969
  usage_count=data.get("usage_count", 0),
937
970
  last_used=last_used,
938
971
  links=links,
972
+ importance=data.get("importance", 0.5),
973
+ last_accessed=last_accessed,
974
+ access_count=data.get("access_count", 0),
939
975
  )
940
976
 
941
977
  def _dict_to_skill(self, data: Dict[str, Any]) -> ProceduralSkill:
@@ -945,6 +981,22 @@ class MemoryEngine:
945
981
  ErrorFix.from_dict(e) if isinstance(e, dict) else e
946
982
  for e in raw_errors
947
983
  ]
984
+
985
+ # Parse last_accessed datetime
986
+ last_accessed = None
987
+ last_accessed_raw = data.get("last_accessed")
988
+ if last_accessed_raw:
989
+ if isinstance(last_accessed_raw, str):
990
+ if last_accessed_raw.endswith("Z"):
991
+ last_accessed_raw = last_accessed_raw[:-1]
992
+ last_accessed = datetime.fromisoformat(last_accessed_raw)
993
+ if last_accessed.tzinfo is None:
994
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
995
+ elif isinstance(last_accessed_raw, datetime):
996
+ last_accessed = last_accessed_raw
997
+ if last_accessed.tzinfo is None:
998
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
999
+
948
1000
  return ProceduralSkill(
949
1001
  id=data.get("id", ""),
950
1002
  name=data.get("name", ""),
@@ -953,6 +1005,10 @@ class MemoryEngine:
953
1005
  steps=data.get("steps", []),
954
1006
  common_errors=common_errors,
955
1007
  exit_criteria=data.get("exit_criteria", []),
1008
+ example_usage=data.get("example_usage"),
1009
+ importance=data.get("importance", 0.5),
1010
+ last_accessed=last_accessed,
1011
+ access_count=data.get("access_count", 0),
956
1012
  )
957
1013
 
958
1014
  def _skill_to_markdown(self, skill: Dict[str, Any]) -> str:
package/memory/schemas.py CHANGED
@@ -20,9 +20,13 @@ from typing import Optional, List, Dict, Any
20
20
  def _to_utc_isoformat(dt: datetime) -> str:
21
21
  """Convert datetime to UTC ISO 8601 string with Z suffix.
22
22
 
23
- Handles both timezone-aware and timezone-naive datetimes,
24
- and avoids double-suffixing if already has timezone info.
23
+ Handles both timezone-aware and timezone-naive datetimes.
24
+ If dt has a non-UTC timezone, converts to UTC first.
25
25
  """
26
+ # If timezone-aware and not UTC, convert to UTC
27
+ if dt.tzinfo is not None and dt.utcoffset() != timezone.utc.utcoffset(None):
28
+ dt = dt.astimezone(timezone.utc)
29
+
26
30
  iso = dt.isoformat()
27
31
  # If already has timezone offset like +00:00, replace with Z
28
32
  if iso.endswith("+00:00"):
package/memory/storage.py CHANGED
@@ -233,7 +233,7 @@ class MemoryStorage:
233
233
  path: Path to JSON file
234
234
 
235
235
  Returns:
236
- Parsed JSON as dictionary, or None if file doesn't exist
236
+ Parsed JSON as dictionary, or None if file doesn't exist or is corrupted
237
237
  """
238
238
  path = Path(path)
239
239
  if not path.exists():
@@ -241,7 +241,10 @@ class MemoryStorage:
241
241
 
242
242
  with self._file_lock(path, exclusive=False):
243
243
  with open(path, "r") as f:
244
- return json.load(f)
244
+ try:
245
+ return json.load(f)
246
+ except json.JSONDecodeError:
247
+ return None
245
248
 
246
249
  def _generate_id(self, prefix: str) -> str:
247
250
  """
@@ -505,9 +505,12 @@ class TokenEconomics:
505
505
  Ratio of discovery_tokens / read_tokens (0.0 if no reads)
506
506
  """
507
507
  read_tokens = self.metrics["read_tokens"]
508
+ discovery_tokens = self.metrics["discovery_tokens"]
508
509
  if read_tokens == 0:
510
+ if discovery_tokens > 0:
511
+ return 999.99 # Sentinel: all discovery, no productive reads
509
512
  return 0.0
510
- return self.metrics["discovery_tokens"] / read_tokens
513
+ return discovery_tokens / read_tokens
511
514
 
512
515
  def get_savings_percent(self) -> float:
513
516
  """
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.3.1",
3
+ "version": "6.5.0",
4
4
  "description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "agent",