loki-mode 5.32.2 → 5.34.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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v5.32.2
5
+ **Version:** v5.34.0
6
6
 
7
7
  ---
8
8
 
@@ -90,7 +90,7 @@ Open VS Code Settings and search for "loki":
90
90
  | Setting | Default | Description |
91
91
  |---------|---------|-------------|
92
92
  | `loki.provider` | `claude` | Default AI provider |
93
- | `loki.apiPort` | `9898` | API server port |
93
+ | `loki.apiPort` | `57374` | API server port |
94
94
  | `loki.apiHost` | `localhost` | API server host |
95
95
  | `loki.autoConnect` | `true` | Auto-connect on activation |
96
96
  | `loki.showStatusBar` | `true` | Show status bar item |
@@ -110,10 +110,10 @@ loki start
110
110
  ./autonomy/run.sh
111
111
 
112
112
  # Option C: Direct API server start
113
- node autonomy/api-server.js
113
+ loki serve
114
114
  ```
115
115
 
116
- The extension will automatically connect when it detects the server is running at `localhost:9898`.
116
+ The extension will automatically connect when it detects the server is running at `localhost:57374`.
117
117
 
118
118
  **Troubleshooting:** If you see "API server is not running" errors, make sure you started the server first using one of the commands above.
119
119
 
@@ -579,15 +579,14 @@ Loki Mode uses two network ports for different services:
579
579
 
580
580
  | Port | Service | Description |
581
581
  |------|---------|-------------|
582
- | **57374** | Dashboard (FastAPI) | Web dashboard UI with real-time monitoring, task board, Completion Council views, memory browser, and log streaming. Served by `dashboard/server.py`. |
583
- | **9898** | REST API Server | JSON API used by the VS Code extension, CLI tools, and programmatic access. Serves endpoints like `/api/status`, `/api/tasks`, `/api/memory`, etc. |
582
+ | **57374** | Dashboard + API (FastAPI) | Unified server serving both the web dashboard UI (real-time monitoring, task board, Completion Council, memory browser, log streaming) and the REST API (used by VS Code extension, CLI tools, programmatic access). Served by `dashboard/server.py`. |
584
583
 
585
584
  ### When to Use Which Port
586
585
 
587
586
  - **Browser access** (dashboard, monitoring): Use port **57374** -- `http://localhost:57374`
588
- - **API calls** (REST, programmatic): Use port **9898** -- `http://localhost:9898`
589
- - **VS Code extension**: Connects to API on port **9898** automatically (configurable via `loki.apiPort` setting)
590
- - **Both ports** are started automatically when you run `loki start` or `./autonomy/run.sh`. No manual configuration is needed.
587
+ - **API calls** (REST, programmatic): Use port **57374** -- `http://localhost:57374`
588
+ - **VS Code extension**: Connects to API on port **57374** automatically (configurable via `loki.apiPort` setting)
589
+ - The server is started automatically when you run `loki start` or `./autonomy/run.sh`. No manual configuration is needed.
591
590
 
592
591
  ### Port Configuration
593
592
 
@@ -595,8 +594,8 @@ Loki Mode uses two network ports for different services:
595
594
  # Dashboard port (default: 57374)
596
595
  LOKI_DASHBOARD_PORT=57374 loki dashboard start
597
596
 
598
- # API port (default: 9898)
599
- loki serve --port 9898
597
+ # API port (default: 57374)
598
+ loki serve --port 57374
600
599
  ```
601
600
 
602
601
  ### CORS Configuration
@@ -72,7 +72,7 @@ loki memory index
72
72
  loki memory retrieve "authentication"
73
73
 
74
74
  # Manage API server (enables VS Code, dashboard)
75
- loki serve --port 9898
75
+ loki serve --port 57374
76
76
  loki api start
77
77
 
78
78
  # View dashboard (web UI)
@@ -24,7 +24,7 @@ open http://localhost:57374
24
24
 
25
25
  The dashboard automatically syncs with Loki Mode when it's running, polling `dashboard-state.json` every 2 seconds.
26
26
 
27
- **Ports:** The dashboard runs on port **57374**. The REST API server runs separately on port **9898**. See [INSTALLATION.md](INSTALLATION.md#ports) for details.
27
+ **Ports:** The dashboard and API run on unified port **57374** (FastAPI serves both). See [INSTALLATION.md](INSTALLATION.md#ports) for details.
28
28
 
29
29
  ---
30
30
 
package/events/emit.sh CHANGED
@@ -25,30 +25,37 @@ mkdir -p "$EVENTS_DIR"
25
25
  TYPE="${1:-state}"
26
26
  SOURCE="${2:-cli}"
27
27
  ACTION="${3:-unknown}"
28
- if [ $# -ge 3 ]; then shift 3; else shift $#; fi
28
+ if [ "$#" -ge 3 ]; then shift 3; else shift "$#"; fi
29
29
 
30
30
  # Generate event ID and timestamp
31
31
  EVENT_ID=$(head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n')
32
32
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
33
33
 
34
+ # JSON escape helper: handles \, ", and control characters
35
+ json_escape() {
36
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' | tr -d '\n'
37
+ }
38
+
34
39
  # Build payload JSON
35
- PAYLOAD="{\"action\":\"$ACTION\""
40
+ ACTION_ESC=$(json_escape "$ACTION")
41
+ PAYLOAD="{\"action\":\"$ACTION_ESC\""
36
42
  for arg in "$@"; do
37
43
  key="${arg%%=*}"
38
44
  value="${arg#*=}"
39
- # Escape special characters for JSON
40
- key_escaped=$(printf '%s' "$key" | sed 's/\\/\\\\/g; s/"/\\"/g')
41
- value=$(printf '%s' "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
42
- PAYLOAD="$PAYLOAD,\"$key_escaped\":\"$value\""
45
+ key_escaped=$(json_escape "$key")
46
+ value_escaped=$(json_escape "$value")
47
+ PAYLOAD="$PAYLOAD,\"$key_escaped\":\"$value_escaped\""
43
48
  done
44
49
  PAYLOAD="$PAYLOAD}"
45
50
 
46
- # Build full event JSON
51
+ # Build full event JSON (escape type/source for safe embedding)
52
+ TYPE_ESC=$(json_escape "$TYPE")
53
+ SOURCE_ESC=$(json_escape "$SOURCE")
47
54
  EVENT=$(cat <<EOF
48
55
  {
49
56
  "id": "$EVENT_ID",
50
- "type": "$TYPE",
51
- "source": "$SOURCE",
57
+ "type": "$TYPE_ESC",
58
+ "source": "$SOURCE_ESC",
52
59
  "timestamp": "$TIMESTAMP",
53
60
  "payload": $PAYLOAD,
54
61
  "version": "1.0"
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.34.0",
4
4
  "description": "Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "claude",