loki-mode 5.32.2 → 5.33.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/memory/engine.py CHANGED
@@ -6,7 +6,7 @@ from __future__ import annotations
6
6
 
7
7
  import json
8
8
  import os
9
- from datetime import datetime, timedelta
9
+ from datetime import datetime, timedelta, timezone
10
10
  from pathlib import Path
11
11
  from typing import Any, Callable, Dict, List, Optional, Union
12
12
 
@@ -118,7 +118,7 @@ class MemoryEngine:
118
118
  "index.json",
119
119
  {
120
120
  "version": "1.0",
121
- "last_updated": datetime.now().isoformat(),
121
+ "last_updated": datetime.now(timezone.utc).isoformat(),
122
122
  "topics": [],
123
123
  "total_memories": 0,
124
124
  "total_tokens_available": 0,
@@ -131,7 +131,7 @@ class MemoryEngine:
131
131
  "timeline.json",
132
132
  {
133
133
  "version": "1.0",
134
- "last_updated": datetime.now().isoformat(),
134
+ "last_updated": datetime.now(timezone.utc).isoformat(),
135
135
  "recent_actions": [],
136
136
  "key_decisions": [],
137
137
  "active_context": {
@@ -195,7 +195,7 @@ class MemoryEngine:
195
195
  Returns:
196
196
  Number of memories removed
197
197
  """
198
- cutoff = datetime.now() - timedelta(days=days)
198
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
199
199
  removed_count = 0
200
200
 
201
201
  # Get referenced episode IDs from semantic patterns
@@ -215,7 +215,7 @@ class MemoryEngine:
215
215
 
216
216
  # Parse date from directory name (e.g., 2026-01-06)
217
217
  try:
218
- dir_date = datetime.strptime(date_dir.name, "%Y-%m-%d")
218
+ dir_date = datetime.strptime(date_dir.name, "%Y-%m-%d").replace(tzinfo=timezone.utc)
219
219
  except ValueError:
220
220
  continue
221
221
 
@@ -251,7 +251,7 @@ class MemoryEngine:
251
251
  """
252
252
  # Determine storage path based on timestamp
253
253
  trace_dict = trace.to_dict() if hasattr(trace, "to_dict") else trace.__dict__.copy()
254
- timestamp = trace_dict.get("timestamp", datetime.now().isoformat())
254
+ timestamp = trace_dict.get("timestamp", datetime.now(timezone.utc).isoformat())
255
255
 
256
256
  if isinstance(timestamp, str):
257
257
  date_str = timestamp[:10] # Extract YYYY-MM-DD
@@ -436,7 +436,7 @@ class MemoryEngine:
436
436
  for pattern in patterns_data["patterns"]:
437
437
  if pattern.get("id") == pattern_id:
438
438
  pattern["usage_count"] = pattern.get("usage_count", 0) + 1
439
- pattern["last_used"] = datetime.now().isoformat()
439
+ pattern["last_used"] = datetime.now(timezone.utc).isoformat()
440
440
  break
441
441
 
442
442
  self.storage.write_json("semantic/patterns.json", patterns_data)
@@ -618,7 +618,7 @@ class MemoryEngine:
618
618
  Returns:
619
619
  List of memories within the time range
620
620
  """
621
- until = until or datetime.now()
621
+ until = until or datetime.now(timezone.utc)
622
622
  results: List[Dict[str, Any]] = []
623
623
 
624
624
  episodic_path = Path(self.base_path) / "episodic"
@@ -630,7 +630,7 @@ class MemoryEngine:
630
630
  continue
631
631
 
632
632
  try:
633
- dir_date = datetime.strptime(date_dir.name, "%Y-%m-%d")
633
+ dir_date = datetime.strptime(date_dir.name, "%Y-%m-%d").replace(tzinfo=timezone.utc)
634
634
  except ValueError:
635
635
  continue
636
636
 
@@ -745,7 +745,7 @@ class MemoryEngine:
745
745
  "index.json",
746
746
  {
747
747
  "version": "1.0",
748
- "last_updated": datetime.now().isoformat(),
748
+ "last_updated": datetime.now(timezone.utc).isoformat(),
749
749
  "topics": list(topics.values()),
750
750
  "total_memories": total_memories,
751
751
  "total_tokens_available": total_tokens,
@@ -774,7 +774,7 @@ class MemoryEngine:
774
774
  # Create action summary
775
775
  context = episode.get("context", {})
776
776
  action_entry = {
777
- "timestamp": episode.get("timestamp", datetime.now().isoformat()),
777
+ "timestamp": episode.get("timestamp", datetime.now(timezone.utc).isoformat()),
778
778
  "action": context.get("goal", "Task completed")[:100],
779
779
  "outcome": episode.get("outcome", "unknown"),
780
780
  "topic_id": context.get("phase", "general"),
@@ -783,7 +783,7 @@ class MemoryEngine:
783
783
  # Add to recent actions (keep last 50)
784
784
  timeline["recent_actions"].insert(0, action_entry)
785
785
  timeline["recent_actions"] = timeline["recent_actions"][:50]
786
- timeline["last_updated"] = datetime.now().isoformat()
786
+ timeline["last_updated"] = datetime.now(timezone.utc).isoformat()
787
787
 
788
788
  self.storage.write_json("timeline.json", timeline)
789
789
 
@@ -802,7 +802,7 @@ class MemoryEngine:
802
802
  topic_found = False
803
803
  for topic in index["topics"]:
804
804
  if topic.get("id") == category:
805
- topic["last_accessed"] = datetime.now().isoformat()
805
+ topic["last_accessed"] = datetime.now(timezone.utc).isoformat()
806
806
  topic["relevance_score"] = max(
807
807
  topic.get("relevance_score", 0.5),
808
808
  pattern.get("confidence", 0.5),
@@ -815,11 +815,11 @@ class MemoryEngine:
815
815
  "id": category,
816
816
  "summary": f"Patterns for {category}",
817
817
  "relevance_score": pattern.get("confidence", 0.5),
818
- "last_accessed": datetime.now().isoformat(),
818
+ "last_accessed": datetime.now(timezone.utc).isoformat(),
819
819
  "token_count": len(json.dumps(pattern)) // 4,
820
820
  })
821
821
 
822
- index["last_updated"] = datetime.now().isoformat()
822
+ index["last_updated"] = datetime.now(timezone.utc).isoformat()
823
823
  index["total_memories"] = index.get("total_memories", 0) + 1
824
824
 
825
825
  self.storage.write_json("index.json", index)
@@ -851,10 +851,14 @@ class MemoryEngine:
851
851
  if timestamp_str.endswith("Z"):
852
852
  timestamp_str = timestamp_str[:-1]
853
853
  timestamp = datetime.fromisoformat(timestamp_str)
854
+ if timestamp.tzinfo is None:
855
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
854
856
  elif isinstance(timestamp_str, datetime):
855
857
  timestamp = timestamp_str
858
+ if timestamp.tzinfo is None:
859
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
856
860
  else:
857
- timestamp = datetime.now()
861
+ timestamp = datetime.now(timezone.utc)
858
862
 
859
863
  # Extract phase and goal from context dict
860
864
  context = data.get("context", {})
@@ -904,8 +908,12 @@ class MemoryEngine:
904
908
  if last_used_raw.endswith("Z"):
905
909
  last_used_raw = last_used_raw[:-1]
906
910
  last_used = datetime.fromisoformat(last_used_raw)
911
+ if last_used.tzinfo is None:
912
+ last_used = last_used.replace(tzinfo=timezone.utc)
907
913
  elif isinstance(last_used_raw, datetime):
908
914
  last_used = last_used_raw
915
+ if last_used.tzinfo is None:
916
+ last_used = last_used.replace(tzinfo=timezone.utc)
909
917
 
910
918
  # Convert links dicts to Link objects
911
919
  links_raw = data.get("links", [])
@@ -19,7 +19,7 @@ from __future__ import annotations
19
19
 
20
20
  import json
21
21
  import re
22
- from datetime import datetime
22
+ from datetime import datetime, timezone
23
23
  from pathlib import Path
24
24
  from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, Union, TYPE_CHECKING
25
25
 
@@ -746,7 +746,7 @@ class MemoryRetrieval:
746
746
  Returns:
747
747
  List of memories within the time range
748
748
  """
749
- until = until or datetime.now()
749
+ until = until or datetime.now(timezone.utc)
750
750
  results: List[Dict[str, Any]] = []
751
751
 
752
752
  # Search episodic memories by date directory (via storage layer)
@@ -756,7 +756,7 @@ class MemoryRetrieval:
756
756
  continue
757
757
 
758
758
  try:
759
- dir_date = datetime.strptime(date_dir.name, "%Y-%m-%d")
759
+ dir_date = datetime.strptime(date_dir.name, "%Y-%m-%d").replace(tzinfo=timezone.utc)
760
760
  except ValueError:
761
761
  continue
762
762
 
@@ -938,7 +938,7 @@ class MemoryRetrieval:
938
938
  Returns:
939
939
  Results with recency boost applied
940
940
  """
941
- now = datetime.now()
941
+ now = datetime.now(timezone.utc)
942
942
 
943
943
  for result in results:
944
944
  timestamp = result.get("timestamp") or result.get("last_used")
@@ -1281,25 +1281,34 @@ class MemoryRetrieval:
1281
1281
  """
1282
1282
  Save all vector indices to disk.
1283
1283
  """
1284
- vectors_path = self.base_path / "vectors"
1285
- vectors_path.mkdir(parents=True, exist_ok=True)
1284
+ if hasattr(self.storage, 'ensure_directory'):
1285
+ self.storage.ensure_directory("vectors")
1286
1286
 
1287
1287
  for name, index in self.vector_indices.items():
1288
- index_path = vectors_path / f"{name}_index"
1289
- index.save(str(index_path))
1288
+ # Resolve path through storage to respect namespace isolation
1289
+ if hasattr(self.storage, '_resolve_path'):
1290
+ index_path = self.storage._resolve_path(f"vectors/{name}_index")
1291
+ else:
1292
+ index_path = str(self.base_path / "vectors" / f"{name}_index")
1293
+ index.save(index_path)
1290
1294
 
1291
1295
  def load_indices(self) -> None:
1292
1296
  """
1293
1297
  Load all vector indices from disk.
1294
1298
  """
1295
- vectors_path = self.base_path / "vectors"
1296
- if not vectors_path.exists():
1299
+ vectors_files = self.storage.list_files("vectors", "*")
1300
+ if not vectors_files:
1297
1301
  return
1298
1302
 
1299
1303
  for name, index in self.vector_indices.items():
1300
- index_path = vectors_path / f"{name}_index"
1301
- if index_path.exists():
1302
- index.load(str(index_path))
1304
+ if hasattr(self.storage, '_resolve_path'):
1305
+ index_path = self.storage._resolve_path(f"vectors/{name}_index")
1306
+ else:
1307
+ index_path = str(self.base_path / "vectors" / f"{name}_index")
1308
+ # Check if the npz file exists (VectorIndex.load expects base path without extension)
1309
+ import os
1310
+ if os.path.exists(f"{index_path}.npz"):
1311
+ index.load(index_path)
1303
1312
 
1304
1313
  # -------------------------------------------------------------------------
1305
1314
  # Private Helper Methods
@@ -1453,16 +1462,16 @@ class MemoryRetrieval:
1453
1462
  return
1454
1463
 
1455
1464
  index = self.vector_indices["episodic"]
1456
- episodic_path = self.base_path / "episodic"
1457
-
1458
- if not episodic_path.exists():
1459
- return
1465
+ date_dirs = self.storage.list_files("episodic", "*")
1460
1466
 
1461
- for date_dir in episodic_path.iterdir():
1467
+ for date_dir in date_dirs:
1462
1468
  if not date_dir.is_dir():
1463
1469
  continue
1464
1470
 
1465
- for episode_file in date_dir.glob("*.json"):
1471
+ episode_files = self.storage.list_files(
1472
+ f"episodic/{date_dir.name}", "*.json"
1473
+ )
1474
+ for episode_file in episode_files:
1466
1475
  if episode_file.name == "index.json":
1467
1476
  continue
1468
1477
 
package/memory/storage.py CHANGED
@@ -118,6 +118,23 @@ class MemoryStorage:
118
118
  for directory in directories:
119
119
  directory.mkdir(parents=True, exist_ok=True)
120
120
 
121
+ # Clean up stale lock files from previous crashed processes
122
+ self._cleanup_stale_locks()
123
+
124
+ def _cleanup_stale_locks(self) -> None:
125
+ """Remove stale .lock files older than 5 minutes (safe with concurrent processes)."""
126
+ try:
127
+ stale_cutoff = datetime.now(timezone.utc) - timedelta(minutes=5)
128
+ for lock_file in self.base_path.rglob("*.lock"):
129
+ try:
130
+ mtime = datetime.fromtimestamp(lock_file.stat().st_mtime, tz=timezone.utc)
131
+ if mtime < stale_cutoff:
132
+ lock_file.unlink()
133
+ except OSError:
134
+ pass
135
+ except OSError:
136
+ pass
137
+
121
138
  def _ensure_index(self) -> None:
122
139
  """Initialize index.json if it doesn't exist."""
123
140
  index_path = self.base_path / "index.json"
@@ -383,10 +400,19 @@ class MemoryStorage:
383
400
  if file_path.exists():
384
401
  with self._file_lock(file_path, exclusive=True):
385
402
  file_path.unlink()
386
- # Clean up lock file
403
+ # Clean up lock file (safety net in case _file_lock missed it)
387
404
  lock_path = file_path.with_suffix(".json.lock")
388
- if lock_path.exists():
389
- lock_path.unlink()
405
+ try:
406
+ if lock_path.exists():
407
+ lock_path.unlink()
408
+ except OSError:
409
+ pass
410
+ # Clean up any remaining lock files before checking if dir is empty
411
+ for stale_lock in date_dir.glob("*.lock"):
412
+ try:
413
+ stale_lock.unlink()
414
+ except OSError:
415
+ pass
390
416
  # Clean up empty date directory
391
417
  if not any(date_dir.iterdir()):
392
418
  date_dir.rmdir()
@@ -348,15 +348,15 @@ class VectorIndex:
348
348
  Normalize copies of all vectors for cosine similarity search.
349
349
 
350
350
  This is called automatically before search operations.
351
- Uses copies to avoid corrupting the original stored embeddings.
351
+ Uses explicit copies to avoid corrupting the original stored embeddings.
352
352
  """
353
353
  self._normalized_embeddings = []
354
354
  for embedding in self.embeddings:
355
- norm = np.linalg.norm(embedding)
355
+ vec_copy = embedding.copy()
356
+ norm = np.linalg.norm(vec_copy)
356
357
  if norm > 0:
357
- self._normalized_embeddings.append(embedding / norm)
358
- else:
359
- self._normalized_embeddings.append(embedding.copy())
358
+ vec_copy = vec_copy / norm
359
+ self._normalized_embeddings.append(vec_copy)
360
360
  self._normalized = True
361
361
 
362
362
  def _cosine_similarity(self, a: np.ndarray, b: np.ndarray) -> float:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "5.32.2",
3
+ "version": "5.33.0",
4
4
  "description": "Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "claude",