loki-mode 7.76.0 → 7.78.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__ = '7.76.0'
60
+ __version__ = '7.78.0'
@@ -157,10 +157,15 @@ class ConsolidationPipeline:
157
157
  if lock_file is not None:
158
158
  fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
159
159
  lock_file.close()
160
- try:
161
- lock_path.unlink()
162
- except OSError:
163
- pass
160
+ # Do NOT unlink the lock inode here. Unlinking on release is a
161
+ # flock+unlink inode-replacement race: waiter B blocked on
162
+ # inode-1 acquires it after holder A unlinks inode-1, then a
163
+ # third consolidation C opens the path, finds it gone, creates
164
+ # inode-2 and flocks inode-2 -- entering _consolidate_locked
165
+ # while B is still inside, breaking the BUG-MEM-003
166
+ # single-consolidation guarantee. Same class fixed in
167
+ # storage._file_lock. Persistent lock files are the standard
168
+ # flock pattern; the file is reused across runs.
164
169
 
165
170
  def _consolidate_locked(self, since_hours: int) -> ConsolidationResult:
166
171
  """Run the consolidation pipeline under an exclusive lock."""
package/memory/engine.py CHANGED
@@ -7,6 +7,7 @@ from __future__ import annotations
7
7
  import json
8
8
  import logging
9
9
  import os
10
+ import re
10
11
  from datetime import datetime, timedelta, timezone
11
12
  from pathlib import Path
12
13
  from typing import Any, Callable, Dict, List, Optional, Union
@@ -304,6 +305,13 @@ class MemoryEngine:
304
305
  else:
305
306
  date_str = timestamp.strftime("%Y-%m-%d")
306
307
 
308
+ # Reject a junk date directory derived from a poisoned/round-tripped
309
+ # timestamp (mirrors storage.save_episode). Traversal is already
310
+ # contained by _resolve_path; this just stops non-date dirs being
311
+ # created. Only an exact YYYY-MM-DD string is allowed.
312
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date_str):
313
+ date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
314
+
307
315
  episode_id = trace_dict.get("id", f"ep-{date_str}-{self._generate_id()}")
308
316
  trace_dict["id"] = episode_id
309
317
 
@@ -297,6 +297,14 @@ class MemoryRetrieval:
297
297
 
298
298
  # Track which legacy entries we've already warned about to avoid log spam.
299
299
  _LEGACY_WARN_LIMIT = 5
300
+
301
+ # Cap on episode files read per keyword scan. The episodic store grows
302
+ # unbounded over a long-lived project, so an uncapped scan reads+parses
303
+ # every episode on every keyword query. Date dirs are walked newest-first,
304
+ # so the cap retains the most recent episodes (the natural relevance order)
305
+ # and only stops unbounded IO; it does not change scoring of retained
306
+ # candidates.
307
+ _KEYWORD_SCAN_MAX_EPISODES = 2000
300
308
  _legacy_warned_count: int = 0
301
309
 
302
310
  def _belongs_to_namespace(self, result: Dict[str, Any]) -> bool:
@@ -1509,7 +1517,14 @@ class MemoryRetrieval:
1509
1517
  if not date_dirs:
1510
1518
  return results
1511
1519
 
1520
+ # Bound the scan so an unbounded episodic store does not force a
1521
+ # read+parse of every episode on each query (see
1522
+ # _KEYWORD_SCAN_MAX_EPISODES). Newest date dirs first keeps the most
1523
+ # recent episodes.
1524
+ scanned = 0
1512
1525
  for date_dir in sorted(date_dirs, reverse=True):
1526
+ if scanned >= self._KEYWORD_SCAN_MAX_EPISODES:
1527
+ break
1513
1528
  if not date_dir.is_dir():
1514
1529
  continue
1515
1530
 
@@ -1519,7 +1534,10 @@ class MemoryRetrieval:
1519
1534
  for episode_file in episode_files:
1520
1535
  if episode_file.name == "index.json":
1521
1536
  continue
1537
+ if scanned >= self._KEYWORD_SCAN_MAX_EPISODES:
1538
+ break
1522
1539
 
1540
+ scanned += 1
1523
1541
  data = self.storage.read_json(
1524
1542
  f"episodic/{date_dir.name}/{episode_file.name}"
1525
1543
  )
package/memory/storage.py CHANGED
@@ -429,6 +429,21 @@ class MemoryStorage:
429
429
  # Episode Storage
430
430
  # -------------------------------------------------------------------------
431
431
 
432
+ @staticmethod
433
+ def _sanitize_episode_id(episode_id) -> str:
434
+ """
435
+ Sanitize an episode id for use in a filename.
436
+
437
+ Separators and "." segments cannot leak into the path (mirrors
438
+ save_skill). save_episode and load_episode must use the SAME transform
439
+ or a round-tripped id with ":", "/", or "." chars would write to one
440
+ file and read from another.
441
+ """
442
+ return "".join(
443
+ c if c.isalnum() or c in "-_" else "_"
444
+ for c in str(episode_id)
445
+ )
446
+
432
447
  def save_episode(self, episode: EpisodeTrace) -> str:
433
448
  """
434
449
  Save an episode trace to storage.
@@ -475,10 +490,7 @@ class MemoryStorage:
475
490
  if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date_str):
476
491
  date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
477
492
 
478
- safe_episode_id = "".join(
479
- c if c.isalnum() or c in "-_" else "_"
480
- for c in str(episode_id)
481
- )
493
+ safe_episode_id = self._sanitize_episode_id(episode_id)
482
494
 
483
495
  date_dir = self.base_path / "episodic" / date_str
484
496
  date_dir.mkdir(parents=True, exist_ok=True)
@@ -507,10 +519,14 @@ class MemoryStorage:
507
519
  if not episodic_dir.exists():
508
520
  return None
509
521
 
522
+ # Sanitize the same way save_episode does so an id carrying ":", "/",
523
+ # or "." chars resolves to the file it was actually written to.
524
+ safe_episode_id = self._sanitize_episode_id(episode_id)
525
+
510
526
  # Search all date directories
511
527
  for date_dir in episodic_dir.iterdir():
512
528
  if date_dir.is_dir():
513
- file_path = date_dir / f"task-{episode_id}.json"
529
+ file_path = date_dir / f"task-{safe_episode_id}.json"
514
530
  if file_path.exists():
515
531
  data = self._load_json(file_path)
516
532
  if data:
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.76.0",
4
+ "version": "7.78.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.76.0",
5
+ "version": "7.78.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",