loki-mode 5.58.0 → 5.58.2

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v5.58.0
5
+ **Version:** v5.58.2
6
6
 
7
7
  ---
8
8
 
package/events/bus.ts CHANGED
@@ -69,6 +69,49 @@ function getTimestamp(): string {
69
69
  return new Date().toISOString();
70
70
  }
71
71
 
72
+ /**
73
+ * Write a file with an exclusive lockfile to prevent concurrent corruption.
74
+ * Creates a .lock file, writes data, then removes the lock.
75
+ */
76
+ function writeFileWithLock(filepath: string, data: string): void {
77
+ const lockfile = filepath + ".lock";
78
+ let fd: number | null = null;
79
+ try {
80
+ // Acquire lock by creating lockfile exclusively (fails if already exists)
81
+ fd = fs.openSync(lockfile, "wx");
82
+ fs.closeSync(fd);
83
+ fd = null;
84
+ // Write the actual data
85
+ fs.writeFileSync(filepath, data);
86
+ } catch (e: unknown) {
87
+ // If lock acquisition failed (file exists), retry once after brief delay
88
+ if (e instanceof Error && "code" in e && (e as NodeJS.ErrnoException).code === "EEXIST") {
89
+ // Another process holds the lock; wait briefly and retry
90
+ const start = Date.now();
91
+ while (fs.existsSync(lockfile) && Date.now() - start < 1000) {
92
+ // Busy-wait up to 1 second
93
+ }
94
+ try {
95
+ fd = fs.openSync(lockfile, "wx");
96
+ fs.closeSync(fd);
97
+ fd = null;
98
+ fs.writeFileSync(filepath, data);
99
+ } catch {
100
+ // Fall back to unlocked write
101
+ fs.writeFileSync(filepath, data);
102
+ }
103
+ } else {
104
+ throw e;
105
+ }
106
+ } finally {
107
+ try {
108
+ fs.unlinkSync(lockfile);
109
+ } catch {
110
+ // Ignore cleanup errors
111
+ }
112
+ }
113
+ }
114
+
72
115
  /**
73
116
  * File-based Event Bus
74
117
  */
@@ -132,7 +175,7 @@ export class EventBus {
132
175
  }
133
176
 
134
177
  try {
135
- fs.writeFileSync(
178
+ writeFileWithLock(
136
179
  this.processedFile,
137
180
  JSON.stringify({ ids: Array.from(this.processedIds) })
138
181
  );
@@ -158,7 +201,7 @@ export class EventBus {
158
201
  const filepath = path.join(this.pendingDir, filename);
159
202
 
160
203
  try {
161
- fs.writeFileSync(filepath, JSON.stringify(fullEvent, null, 2));
204
+ writeFileWithLock(filepath, JSON.stringify(fullEvent, null, 2));
162
205
  } catch (e) {
163
206
  throw new Error(`Failed to emit event: ${e}`);
164
207
  }
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '5.58.0'
60
+ __version__ = '5.58.2'
package/mcp/server.py CHANGED
@@ -248,6 +248,7 @@ logger = logging.getLogger('loki-mcp')
248
248
 
249
249
  # Track tool call start times for duration calculation (per-tool stack)
250
250
  _tool_call_start_times: Dict[str, List[float]] = {}
251
+ _tool_call_times_lock = threading.Lock()
251
252
 
252
253
 
253
254
  def _emit_tool_event_async(tool_name: str, action: str, **kwargs) -> None:
@@ -261,15 +262,17 @@ def _emit_tool_event_async(tool_name: str, action: str, **kwargs) -> None:
261
262
  """
262
263
  import time
263
264
 
264
- # Track timing for learning signals using a per-tool-name stack
265
+ # Track timing for learning signals using a per-tool-name stack (thread-safe)
265
266
  if action == 'start':
266
- _tool_call_start_times.setdefault(tool_name, []).append(time.time())
267
+ with _tool_call_times_lock:
268
+ _tool_call_start_times.setdefault(tool_name, []).append(time.time())
267
269
  elif action == 'complete':
268
270
  # Pop the most recent start time for this tool
269
271
  start_time = None
270
- times = _tool_call_start_times.get(tool_name)
271
- if times:
272
- start_time = times.pop()
272
+ with _tool_call_times_lock:
273
+ times = _tool_call_start_times.get(tool_name)
274
+ if times:
275
+ start_time = times.pop()
273
276
  if start_time:
274
277
  execution_time_ms = int((time.time() - start_time) * 1000)
275
278
  _emit_learning_signal_async(
@@ -674,8 +677,10 @@ async def loki_task_queue_add(
674
677
  else:
675
678
  queue = {"tasks": [], "version": "1.0"}
676
679
 
677
- # Create new task
678
- task_id = f"task-{len(queue['tasks']) + 1:04d}"
680
+ # Create new task with monotonic counter to avoid ID collisions after deletions
681
+ next_id = queue.get("_next_id", len(queue['tasks']) + 1)
682
+ task_id = f"task-{next_id:04d}"
683
+ queue["_next_id"] = next_id + 1
679
684
  task = {
680
685
  "id": task_id,
681
686
  "title": title,
@@ -1064,7 +1069,7 @@ async def loki_project_status() -> str:
1064
1069
  status["tasks"] = {
1065
1070
  "total": len(tasks),
1066
1071
  "pending": sum(1 for t in tasks if t.get("status") == "pending"),
1067
- "in_progress": sum(1 for t in tasks if t.get("status") == "in-progress"),
1072
+ "in_progress": sum(1 for t in tasks if t.get("status") == "in_progress"),
1068
1073
  "completed": sum(1 for t in tasks if t.get("status") == "completed"),
1069
1074
  }
1070
1075
 
@@ -1098,8 +1103,8 @@ async def loki_agent_metrics() -> str:
1098
1103
  if os.path.isdir(metrics_dir):
1099
1104
  for fname in os.listdir(metrics_dir):
1100
1105
  if fname.endswith('.json'):
1101
- fpath = os.path.join(metrics_dir, fname)
1102
- with open(fpath, 'r') as f:
1106
+ fpath = safe_path_join('.loki', 'metrics', 'efficiency', fname)
1107
+ with safe_open(fpath, 'r') as f:
1103
1108
  metrics["agents"].append(json.load(f))
1104
1109
 
1105
1110
  # Read token economics
@@ -1139,8 +1144,8 @@ async def loki_checkpoint_restore(checkpoint_id: str = "") -> str:
1139
1144
  checkpoints = []
1140
1145
  for fname in sorted(os.listdir(cp_dir)):
1141
1146
  if fname.endswith('.json'):
1142
- fpath = os.path.join(cp_dir, fname)
1143
- with open(fpath, 'r') as f:
1147
+ fpath = safe_path_join('.loki', 'state', 'checkpoints', fname)
1148
+ with safe_open(fpath, 'r') as f:
1144
1149
  cp = json.load(f)
1145
1150
  cp["id"] = fname.replace('.json', '')
1146
1151
  checkpoints.append(cp)
package/mcp/tools.py CHANGED
@@ -100,9 +100,11 @@ def create_task(
100
100
  ) -> Dict[str, Any]:
101
101
  """Create a new task dictionary."""
102
102
  queue = load_task_queue()
103
- task_id = f"task-{len(queue['tasks']) + 1:04d}"
103
+ next_id = queue.get("_next_id", len(queue['tasks']) + 1)
104
+ task_id = f"task-{next_id:04d}"
105
+ queue["_next_id"] = next_id + 1
104
106
 
105
- return {
107
+ task = {
106
108
  "id": task_id,
107
109
  "title": title,
108
110
  "description": description,
@@ -112,6 +114,11 @@ def create_task(
112
114
  "created_at": datetime.now(timezone.utc).isoformat()
113
115
  }
114
116
 
117
+ # Persist _next_id so it survives across calls
118
+ save_task_queue(queue)
119
+
120
+ return task
121
+
115
122
 
116
123
  def filter_tasks_by_status(tasks: List[Dict], status: str) -> List[Dict]:
117
124
  """Filter tasks by their status."""
@@ -540,11 +540,14 @@ class ConsolidationPipeline:
540
540
  if not failed_episodes:
541
541
  return []
542
542
 
543
- # Group failures by error type
543
+ # Group failures by error type (use set to avoid duplicate episodes)
544
544
  error_groups: Dict[str, List[EpisodeTrace]] = defaultdict(list)
545
+ seen_episodes: Dict[str, set] = defaultdict(set)
545
546
  for episode in failed_episodes:
546
- for error in episode.errors_encountered:
547
- error_groups[error.error_type].append(episode)
547
+ for err_entry in episode.errors_encountered:
548
+ if episode.id not in seen_episodes[err_entry.error_type]:
549
+ error_groups[err_entry.error_type].append(episode)
550
+ seen_episodes[err_entry.error_type].add(episode.id)
548
551
 
549
552
  anti_patterns = []
550
553
 
@@ -564,9 +567,9 @@ class ConsolidationPipeline:
564
567
  )
565
568
 
566
569
  # Collect resolutions
567
- for error in episode.errors_encountered:
568
- if error.error_type == error_type and error.resolution:
569
- resolutions.append(error.resolution)
570
+ for err_entry in episode.errors_encountered:
571
+ if err_entry.error_type == error_type and err_entry.resolution:
572
+ resolutions.append(err_entry.resolution)
570
573
 
571
574
  # Create anti-pattern
572
575
  incorrect_approach = self._summarize_actions(pre_error_actions)
package/memory/engine.py CHANGED
@@ -14,6 +14,7 @@ from typing import Any, Callable, Dict, List, Optional, Union
14
14
  from .schemas import (
15
15
  ActionEntry,
16
16
  ErrorEntry,
17
+ ErrorFix,
17
18
  EpisodeTrace,
18
19
  Link,
19
20
  ProceduralSkill,
@@ -939,13 +940,18 @@ class MemoryEngine:
939
940
 
940
941
  def _dict_to_skill(self, data: Dict[str, Any]) -> ProceduralSkill:
941
942
  """Convert dictionary to ProceduralSkill."""
943
+ raw_errors = data.get("common_errors", [])
944
+ common_errors = [
945
+ ErrorFix.from_dict(e) if isinstance(e, dict) else e
946
+ for e in raw_errors
947
+ ]
942
948
  return ProceduralSkill(
943
949
  id=data.get("id", ""),
944
950
  name=data.get("name", ""),
945
951
  description=data.get("description", ""),
946
952
  prerequisites=data.get("prerequisites", []),
947
953
  steps=data.get("steps", []),
948
- common_errors=data.get("common_errors", []),
954
+ common_errors=common_errors,
949
955
  exit_criteria=data.get("exit_criteria", []),
950
956
  )
951
957
 
@@ -15,14 +15,16 @@ Based on competitor analysis (claude-mem project isolation patterns).
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
+ import fcntl
18
19
  import json
19
20
  import os
20
21
  import re
21
22
  import subprocess
23
+ from contextlib import contextmanager
22
24
  from dataclasses import dataclass, field
23
25
  from datetime import datetime, timezone
24
26
  from pathlib import Path
25
- from typing import Any, Dict, List, Optional, Set
27
+ from typing import Any, Dict, Generator, List, Optional, Set
26
28
 
27
29
 
28
30
  # Default namespace when none can be detected
@@ -65,7 +67,12 @@ class NamespaceInfo:
65
67
  "metadata": self.metadata,
66
68
  }
67
69
  if self.created_at:
68
- result["created_at"] = self.created_at.isoformat() + "Z"
70
+ iso = self.created_at.isoformat()
71
+ if iso.endswith("+00:00"):
72
+ iso = iso[:-6] + "Z"
73
+ elif not iso.endswith("Z"):
74
+ iso = iso + "Z"
75
+ result["created_at"] = iso
69
76
  return result
70
77
 
71
78
  @classmethod
@@ -127,21 +134,44 @@ class NamespaceManager:
127
134
  with open(registry_path, "w") as f:
128
135
  json.dump(initial_registry, f, indent=2)
129
136
 
137
+ @contextmanager
138
+ def _file_lock(self, path: Path, exclusive: bool = True) -> Generator[None, None, None]:
139
+ """Acquire a file lock for safe concurrent access."""
140
+ lock_path = Path(str(path) + ".lock")
141
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
142
+
143
+ lock_file = None
144
+ try:
145
+ lock_file = open(lock_path, "w")
146
+ lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
147
+ fcntl.flock(lock_file.fileno(), lock_type)
148
+ yield
149
+ finally:
150
+ if lock_file is not None:
151
+ fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
152
+ lock_file.close()
153
+ try:
154
+ os.remove(lock_path)
155
+ except OSError:
156
+ pass
157
+
130
158
  def _load_registry(self) -> Dict[str, Any]:
131
- """Load the namespace registry."""
159
+ """Load the namespace registry with shared file lock."""
132
160
  registry_path = self.base_path / self.REGISTRY_FILE
133
161
  if not registry_path.exists():
134
162
  return {"version": "1.0.0", "namespaces": {}}
135
163
 
136
- with open(registry_path, "r") as f:
137
- return json.load(f)
164
+ with self._file_lock(registry_path, exclusive=False):
165
+ with open(registry_path, "r") as f:
166
+ return json.load(f)
138
167
 
139
168
  def _save_registry(self, registry: Dict[str, Any]) -> None:
140
- """Save the namespace registry."""
169
+ """Save the namespace registry with exclusive file lock."""
141
170
  registry_path = self.base_path / self.REGISTRY_FILE
142
171
  self.base_path.mkdir(parents=True, exist_ok=True)
143
- with open(registry_path, "w") as f:
144
- json.dump(registry, f, indent=2)
172
+ with self._file_lock(registry_path, exclusive=True):
173
+ with open(registry_path, "w") as f:
174
+ json.dump(registry, f, indent=2)
145
175
 
146
176
  # -------------------------------------------------------------------------
147
177
  # Auto-Detection
@@ -1379,9 +1379,10 @@ class MemoryRetrieval:
1379
1379
  score += sum(0.5 for kw in keywords if kw in phase)
1380
1380
 
1381
1381
  if score > 0:
1382
- data["_score"] = score
1383
- data["_source"] = "episodic"
1384
- results.append(data)
1382
+ data_copy = dict(data)
1383
+ data_copy["_score"] = score
1384
+ data_copy["_source"] = "episodic"
1385
+ results.append(data_copy)
1385
1386
 
1386
1387
  return results
1387
1388
 
@@ -1407,9 +1408,10 @@ class MemoryRetrieval:
1407
1408
  score *= confidence
1408
1409
 
1409
1410
  if score > 0:
1410
- pattern["_score"] = score
1411
- pattern["_source"] = "semantic"
1412
- results.append(pattern)
1411
+ pattern_copy = dict(pattern)
1412
+ pattern_copy["_score"] = score
1413
+ pattern_copy["_source"] = "semantic"
1414
+ results.append(pattern_copy)
1413
1415
 
1414
1416
  return results
1415
1417
 
@@ -1435,9 +1437,10 @@ class MemoryRetrieval:
1435
1437
  score += sum(0.5 for kw in keywords if kw in steps_text)
1436
1438
 
1437
1439
  if score > 0:
1438
- data["_score"] = score
1439
- data["_source"] = "skills"
1440
- results.append(data)
1440
+ data_copy = dict(data)
1441
+ data_copy["_score"] = score
1442
+ data_copy["_source"] = "skills"
1443
+ results.append(data_copy)
1441
1444
 
1442
1445
  return results
1443
1446
 
@@ -1459,9 +1462,10 @@ class MemoryRetrieval:
1459
1462
  score += sum(1 for kw in keywords if kw in prevention)
1460
1463
 
1461
1464
  if score > 0:
1462
- anti["_score"] = score
1463
- anti["_source"] = "anti_patterns"
1464
- results.append(anti)
1465
+ anti_copy = dict(anti)
1466
+ anti_copy["_score"] = score
1467
+ anti_copy["_source"] = "anti_patterns"
1468
+ results.append(anti_copy)
1465
1469
 
1466
1470
  return results
1467
1471
 
package/memory/schemas.py CHANGED
@@ -17,6 +17,35 @@ from datetime import datetime, timezone
17
17
  from typing import Optional, List, Dict, Any
18
18
 
19
19
 
20
+ def _to_utc_isoformat(dt: datetime) -> str:
21
+ """Convert datetime to UTC ISO 8601 string with Z suffix.
22
+
23
+ Handles both timezone-aware and timezone-naive datetimes,
24
+ and avoids double-suffixing if already has timezone info.
25
+ """
26
+ iso = dt.isoformat()
27
+ # If already has timezone offset like +00:00, replace with Z
28
+ if iso.endswith("+00:00"):
29
+ return iso[:-6] + "Z"
30
+ # If no timezone info, append Z (assumed UTC)
31
+ if not iso.endswith("Z") and "+" not in iso and iso.count("-") <= 2:
32
+ return iso + "Z"
33
+ return iso
34
+
35
+
36
+ def _parse_utc_datetime(s: str) -> datetime:
37
+ """Parse an ISO 8601 string into a timezone-aware UTC datetime.
38
+
39
+ Handles trailing 'Z', '+00:00', and naive strings (assumed UTC).
40
+ """
41
+ if s.endswith("Z"):
42
+ s = s[:-1]
43
+ dt = datetime.fromisoformat(s)
44
+ if dt.tzinfo is None:
45
+ dt = dt.replace(tzinfo=timezone.utc)
46
+ return dt
47
+
48
+
20
49
  # -----------------------------------------------------------------------------
21
50
  # Supporting Types
22
51
  # -----------------------------------------------------------------------------
@@ -311,7 +340,7 @@ class EpisodeTrace:
311
340
  result = {
312
341
  "id": self.id,
313
342
  "task_id": self.task_id,
314
- "timestamp": self.timestamp.isoformat() + "Z",
343
+ "timestamp": _to_utc_isoformat(self.timestamp),
315
344
  "duration_seconds": self.duration_seconds,
316
345
  "agent": self.agent,
317
346
  "context": {
@@ -331,7 +360,7 @@ class EpisodeTrace:
331
360
  "access_count": self.access_count,
332
361
  }
333
362
  if self.last_accessed:
334
- result["last_accessed"] = self.last_accessed.isoformat() + "Z"
363
+ result["last_accessed"] = _to_utc_isoformat(self.last_accessed)
335
364
  return result
336
365
 
337
366
  @classmethod
@@ -339,24 +368,25 @@ class EpisodeTrace:
339
368
  """Create from dictionary."""
340
369
  context = data.get("context", {})
341
370
  timestamp_str = data.get("timestamp", "")
342
- if isinstance(timestamp_str, str):
343
- # Handle ISO format with Z suffix
344
- if timestamp_str.endswith("Z"):
345
- timestamp_str = timestamp_str[:-1]
346
- timestamp = datetime.fromisoformat(timestamp_str)
347
- else:
371
+ if isinstance(timestamp_str, str) and timestamp_str:
372
+ timestamp = _parse_utc_datetime(timestamp_str)
373
+ elif isinstance(timestamp_str, datetime):
348
374
  timestamp = timestamp_str
375
+ if timestamp.tzinfo is None:
376
+ timestamp = timestamp.replace(tzinfo=timezone.utc)
377
+ else:
378
+ timestamp = datetime.now(timezone.utc)
349
379
 
350
380
  # Parse last_accessed datetime
351
381
  last_accessed = None
352
382
  last_accessed_str = data.get("last_accessed")
353
383
  if last_accessed_str:
354
384
  if isinstance(last_accessed_str, str):
355
- if last_accessed_str.endswith("Z"):
356
- last_accessed_str = last_accessed_str[:-1]
357
- last_accessed = datetime.fromisoformat(last_accessed_str)
385
+ last_accessed = _parse_utc_datetime(last_accessed_str)
358
386
  else:
359
387
  last_accessed = last_accessed_str
388
+ if hasattr(last_accessed, 'tzinfo') and last_accessed.tzinfo is None:
389
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
360
390
 
361
391
  return cls(
362
392
  id=data.get("id", ""),
@@ -506,9 +536,9 @@ class SemanticPattern:
506
536
  "access_count": self.access_count,
507
537
  }
508
538
  if self.last_used:
509
- result["last_used"] = self.last_used.isoformat() + "Z"
539
+ result["last_used"] = _to_utc_isoformat(self.last_used)
510
540
  if self.last_accessed:
511
- result["last_accessed"] = self.last_accessed.isoformat() + "Z"
541
+ result["last_accessed"] = _to_utc_isoformat(self.last_accessed)
512
542
  return result
513
543
 
514
544
  @classmethod
@@ -518,17 +548,21 @@ class SemanticPattern:
518
548
  if data.get("last_used"):
519
549
  last_used_str = data["last_used"]
520
550
  if isinstance(last_used_str, str):
521
- if last_used_str.endswith("Z"):
522
- last_used_str = last_used_str[:-1]
523
- last_used = datetime.fromisoformat(last_used_str)
551
+ last_used = _parse_utc_datetime(last_used_str)
552
+ elif isinstance(last_used_str, datetime):
553
+ last_used = last_used_str
554
+ if last_used.tzinfo is None:
555
+ last_used = last_used.replace(tzinfo=timezone.utc)
524
556
 
525
557
  last_accessed = None
526
558
  if data.get("last_accessed"):
527
559
  last_accessed_str = data["last_accessed"]
528
560
  if isinstance(last_accessed_str, str):
529
- if last_accessed_str.endswith("Z"):
530
- last_accessed_str = last_accessed_str[:-1]
531
- last_accessed = datetime.fromisoformat(last_accessed_str)
561
+ last_accessed = _parse_utc_datetime(last_accessed_str)
562
+ elif isinstance(last_accessed_str, datetime):
563
+ last_accessed = last_accessed_str
564
+ if last_accessed.tzinfo is None:
565
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
532
566
 
533
567
  return cls(
534
568
  id=data.get("id", ""),
@@ -661,7 +695,7 @@ class ProceduralSkill:
661
695
  if self.example_usage:
662
696
  result["example_usage"] = self.example_usage
663
697
  if self.last_accessed:
664
- result["last_accessed"] = self.last_accessed.isoformat() + "Z"
698
+ result["last_accessed"] = _to_utc_isoformat(self.last_accessed)
665
699
  return result
666
700
 
667
701
  @classmethod
@@ -671,9 +705,11 @@ class ProceduralSkill:
671
705
  if data.get("last_accessed"):
672
706
  last_accessed_str = data["last_accessed"]
673
707
  if isinstance(last_accessed_str, str):
674
- if last_accessed_str.endswith("Z"):
675
- last_accessed_str = last_accessed_str[:-1]
676
- last_accessed = datetime.fromisoformat(last_accessed_str)
708
+ last_accessed = _parse_utc_datetime(last_accessed_str)
709
+ elif isinstance(last_accessed_str, datetime):
710
+ last_accessed = last_accessed_str
711
+ if last_accessed.tzinfo is None:
712
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
677
713
 
678
714
  return cls(
679
715
  id=data.get("id", ""),
package/memory/storage.py CHANGED
@@ -303,17 +303,20 @@ class MemoryStorage:
303
303
 
304
304
  return episode_id
305
305
 
306
- def load_episode(self, episode_id: str) -> Optional[EpisodeTrace]:
306
+ def load_episode(self, episode_id: str) -> Optional[dict]:
307
307
  """
308
308
  Load an episode trace by ID.
309
309
 
310
310
  Searches across all date directories.
311
311
 
312
+ Note: Returns a raw dict, not an EpisodeTrace object.
313
+ Callers should convert via EpisodeTrace.from_dict() if needed.
314
+
312
315
  Args:
313
316
  episode_id: The episode ID to load
314
317
 
315
318
  Returns:
316
- EpisodeTrace object or None if not found
319
+ dict or None if not found
317
320
  """
318
321
  episodic_dir = self.base_path / "episodic"
319
322
  if not episodic_dir.exists():
@@ -494,15 +497,18 @@ class MemoryStorage:
494
497
 
495
498
  return pattern_id
496
499
 
497
- def load_pattern(self, pattern_id: str) -> Optional[SemanticPattern]:
500
+ def load_pattern(self, pattern_id: str) -> Optional[dict]:
498
501
  """
499
502
  Load a semantic pattern by ID.
500
503
 
504
+ Note: Returns a raw dict, not a SemanticPattern object.
505
+ Callers should convert via SemanticPattern.from_dict() if needed.
506
+
501
507
  Args:
502
508
  pattern_id: The pattern ID to load
503
509
 
504
510
  Returns:
505
- SemanticPattern object or None if not found
511
+ dict or None if not found
506
512
  """
507
513
  patterns_path = self.base_path / "semantic" / "patterns.json"
508
514
  patterns_file = self._load_json(patterns_path)
@@ -646,15 +652,18 @@ class MemoryStorage:
646
652
 
647
653
  return skill_id
648
654
 
649
- def load_skill(self, skill_id: str) -> Optional[ProceduralSkill]:
655
+ def load_skill(self, skill_id: str) -> Optional[dict]:
650
656
  """
651
657
  Load a procedural skill by ID.
652
658
 
659
+ Note: Returns a raw dict, not a ProceduralSkill object.
660
+ Callers should convert via ProceduralSkill.from_dict() if needed.
661
+
653
662
  Args:
654
663
  skill_id: The skill ID to load
655
664
 
656
665
  Returns:
657
- ProceduralSkill object or None if not found
666
+ dict or None if not found
658
667
  """
659
668
  skills_dir = self.base_path / "skills"
660
669
  if not skills_dir.exists():
@@ -7,12 +7,18 @@ No FAISS dependency required - uses pure numpy for cosine similarity.
7
7
  This module provides efficient vector storage and retrieval for the
8
8
  memory system's embedding-based search capabilities.
9
9
  """
10
+ from __future__ import annotations
10
11
 
11
12
  import json
12
13
  import os
13
14
  from typing import Callable, Dict, List, Optional, Tuple
14
15
 
15
- import numpy as np
16
+ try:
17
+ import numpy as np
18
+ NUMPY_AVAILABLE = True
19
+ except ImportError:
20
+ np = None # type: ignore
21
+ NUMPY_AVAILABLE = False
16
22
 
17
23
 
18
24
  class VectorIndex:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "5.58.0",
3
+ "version": "5.58.2",
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",