loki-mode 7.67.0 → 7.69.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.67.0'
60
+ __version__ = '7.69.0'
package/mcp/server.py CHANGED
@@ -1358,14 +1358,17 @@ async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
1358
1358
  try:
1359
1359
  resolved = validate_path(prd_path, allowed_dirs=['.'])
1360
1360
  except PathTraversalError as e:
1361
+ _emit_tool_event_async('loki_start_project', 'complete', result_status='error', error=str(e))
1361
1362
  return json.dumps({"error": str(e)})
1362
1363
  if os.path.exists(resolved) and os.path.isfile(resolved):
1363
1364
  with open(resolved, 'r', encoding='utf-8') as f:
1364
1365
  content = f.read()
1365
1366
  else:
1367
+ _emit_tool_event_async('loki_start_project', 'complete', result_status='error', error='PRD file not found')
1366
1368
  return json.dumps({"error": f"PRD file not found: {prd_path}"})
1367
1369
 
1368
1370
  if not content:
1371
+ _emit_tool_event_async('loki_start_project', 'complete', result_status='error', error='No PRD content or path provided')
1369
1372
  return json.dumps({"error": "No PRD content or path provided"})
1370
1373
 
1371
1374
  # Initialize project state using safe path operations
@@ -1390,6 +1393,7 @@ async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
1390
1393
  _emit_tool_event_async('loki_start_project', 'complete', result_status='success')
1391
1394
  return json.dumps({"success": True, **project})
1392
1395
  except PathTraversalError as e:
1396
+ _emit_tool_event_async('loki_start_project', 'complete', result_status='error', error='Access denied')
1393
1397
  return json.dumps({"error": f"Access denied: {e}"})
1394
1398
  except Exception as e:
1395
1399
  logger.error(f"Start project failed: {e}")
@@ -1440,6 +1444,7 @@ async def loki_project_status() -> str:
1440
1444
  _emit_tool_event_async('loki_project_status', 'complete', result_status='success')
1441
1445
  return json.dumps(status, default=str)
1442
1446
  except PathTraversalError:
1447
+ _emit_tool_event_async('loki_project_status', 'complete', result_status='error', error='Access denied')
1443
1448
  return json.dumps({"error": "Access denied"})
1444
1449
  except Exception as e:
1445
1450
  logger.error(f"Project status failed: {e}")
@@ -1478,6 +1483,7 @@ async def loki_agent_metrics() -> str:
1478
1483
  _emit_tool_event_async('loki_agent_metrics', 'complete', result_status='success')
1479
1484
  return json.dumps(metrics, default=str)
1480
1485
  except PathTraversalError:
1486
+ _emit_tool_event_async('loki_agent_metrics', 'complete', result_status='error', error='Access denied')
1481
1487
  return json.dumps({"error": "Access denied"})
1482
1488
  except Exception as e:
1483
1489
  logger.error(f"Agent metrics failed: {e}")
@@ -1500,6 +1506,7 @@ async def loki_checkpoint_restore(checkpoint_id: str = "") -> str:
1500
1506
  try:
1501
1507
  cp_dir = safe_path_join('.loki', 'state', 'checkpoints')
1502
1508
  if not os.path.isdir(cp_dir):
1509
+ _emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='success')
1503
1510
  return json.dumps({"checkpoints": [], "message": "No checkpoints directory"})
1504
1511
 
1505
1512
  checkpoints = []
@@ -1518,6 +1525,7 @@ async def loki_checkpoint_restore(checkpoint_id: str = "") -> str:
1518
1525
  # Find and restore specific checkpoint
1519
1526
  target = next((c for c in checkpoints if c["id"] == checkpoint_id), None)
1520
1527
  if not target:
1528
+ _emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='error', error='Checkpoint not found')
1521
1529
  return json.dumps({"error": f"Checkpoint not found: {checkpoint_id}"})
1522
1530
 
1523
1531
  # Write checkpoint state as current state, stripping the injected "id" field
@@ -1529,6 +1537,7 @@ async def loki_checkpoint_restore(checkpoint_id: str = "") -> str:
1529
1537
  _emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='success')
1530
1538
  return json.dumps({"restored": True, "checkpoint_id": checkpoint_id})
1531
1539
  except PathTraversalError:
1540
+ _emit_tool_event_async('loki_checkpoint_restore', 'complete', result_status='error', error='Access denied')
1532
1541
  return json.dumps({"error": "Access denied"})
1533
1542
  except Exception as e:
1534
1543
  logger.error(f"Checkpoint restore failed: {e}")
@@ -1569,6 +1578,7 @@ async def loki_quality_report() -> str:
1569
1578
  _emit_tool_event_async('loki_quality_report', 'complete', result_status='success')
1570
1579
  return json.dumps(report, default=str)
1571
1580
  except PathTraversalError:
1581
+ _emit_tool_event_async('loki_quality_report', 'complete', result_status='error', error='Access denied')
1572
1582
  return json.dumps({"error": "Access denied"})
1573
1583
  except Exception as e:
1574
1584
  logger.error(f"Quality report failed: {e}")
@@ -2057,6 +2067,7 @@ async def mem_get(
2057
2067
 
2058
2068
  id_list = [i.strip() for i in ids.split(",") if i.strip()]
2059
2069
  if not id_list:
2070
+ _emit_tool_event_async('mem_get', 'complete', result_status='error', error='No IDs provided')
2060
2071
  return json.dumps({"entries": {}, "error": "No IDs provided"})
2061
2072
 
2062
2073
  # Cap at 20 to prevent abuse
@@ -2401,6 +2412,7 @@ async def loki_learnings(limit: int = 50) -> str:
2401
2412
  try:
2402
2413
  path = safe_path_join('.loki', 'state', 'relevant-learnings.json')
2403
2414
  if not os.path.exists(path):
2415
+ _emit_tool_event_async('loki_learnings', 'complete', result_status='success')
2404
2416
  return json.dumps({"version": 1, "learnings": [], "total": 0})
2405
2417
  try:
2406
2418
  with safe_open(path, 'r') as f:
@@ -839,6 +839,20 @@ class ConsolidationPipeline:
839
839
  if best_match is None or best_similarity < 0.5:
840
840
  return new_pattern
841
841
 
842
+ # Idempotency guard (consolidation-C4): only boost confidence when the
843
+ # merge actually introduces NEW evidence. consolidate() reloads every
844
+ # episode in the since-window on each run (storage.list_episodes has no
845
+ # consolidated-state filter), so re-running over an unchanged episode set
846
+ # re-extracts identical patterns that re-match this existing pattern. A
847
+ # flat +0.05 every time would ratchet confidence up artificially with no
848
+ # new data. Comparing source_episodes (which round-trips through storage)
849
+ # makes the merge a no-op for confidence when no new source episode is
850
+ # present, while still rewarding a genuinely new similar episode.
851
+ new_source_episodes = (
852
+ set(new_pattern.source_episodes) - set(best_match.source_episodes)
853
+ )
854
+ confidence_boost = 0.05 if new_source_episodes else 0.0
855
+
842
856
  # Merge patterns
843
857
  merged = SemanticPattern(
844
858
  id=best_match.id,
@@ -847,7 +861,7 @@ class ConsolidationPipeline:
847
861
  conditions=list(set(best_match.conditions + new_pattern.conditions)),
848
862
  correct_approach=best_match.correct_approach or new_pattern.correct_approach,
849
863
  incorrect_approach=best_match.incorrect_approach or new_pattern.incorrect_approach,
850
- confidence=min(best_match.confidence + 0.05, 0.99),
864
+ confidence=min(best_match.confidence + confidence_boost, 0.99),
851
865
  source_episodes=list(set(best_match.source_episodes + new_pattern.source_episodes)),
852
866
  usage_count=best_match.usage_count,
853
867
  last_used=best_match.last_used,
package/memory/engine.py CHANGED
@@ -244,7 +244,11 @@ class MemoryEngine:
244
244
  for pattern in patterns_data.get("patterns", []):
245
245
  if not isinstance(pattern, dict):
246
246
  continue
247
- referenced_ids.update(pattern.get("source_episodes", []))
247
+ # `or []` guards an explicit null source_episodes (corrupt or
248
+ # hand-edited record): .get(..., []) returns None on a stored
249
+ # null, and set.update(None) raises TypeError, crashing the whole
250
+ # cleanup pass. A null and an empty list are equivalent here.
251
+ referenced_ids.update(pattern.get("source_episodes") or [])
248
252
 
249
253
  # Scan episodic directories
250
254
  episodic_path = Path(self.base_path) / "episodic"
@@ -376,17 +380,28 @@ class MemoryEngine:
376
380
  "last_accessed": now,
377
381
  "relevance_score": 0.5,
378
382
  })
379
- index["total_memories"] = index.get("total_memories", 0) + 1
383
+ # total_memories counts memories (episodes), not topics, to
384
+ # match the canonical rebuild_index semantics
385
+ # (total_memories += 1 per episode at line ~860). Previously
386
+ # this only incremented when a NEW topic was created, so N
387
+ # episodes sharing one phase reported total_memories=1 until
388
+ # the user ran rebuild_index, which then jumped it to N
389
+ # (two functions in this file disagreeing on the same field,
390
+ # surfaced by get_stats). Increment per distinct episode.
391
+ if episode_id:
392
+ index["total_memories"] = index.get("total_memories", 0) + 1
380
393
  else:
381
394
  # Only count a given episode once. On resume/checkpoint the same
382
395
  # trace id can be re-saved; without this guard episode_count,
383
- # total_cost_usd, and total_tokens would inflate on every re-save
384
- # even though episode_ids is already de-duplicated.
396
+ # total_cost_usd, total_tokens, and total_memories would inflate
397
+ # on every re-save even though episode_ids is already
398
+ # de-duplicated.
385
399
  if episode_id and episode_id not in found.get("episode_ids", []):
386
400
  found.setdefault("episode_ids", []).append(episode_id)
387
401
  found["episode_count"] = found.get("episode_count", 0) + 1
388
402
  found["total_cost_usd"] = float(found.get("total_cost_usd", 0) or 0) + cost
389
403
  found["total_tokens"] = int(found.get("total_tokens", 0) or 0) + tokens
404
+ index["total_memories"] = index.get("total_memories", 0) + 1
390
405
  merged = set(found.get("files_touched", []) or []) | set(files[:20])
391
406
  found["files_touched"] = sorted(merged)[:50]
392
407
  found["last_accessed"] = now
@@ -878,7 +893,12 @@ class MemoryEngine:
878
893
  }
879
894
 
880
895
  topics[phase]["token_count"] += tokens
881
- if data.get("timestamp", "") > topics[phase].get("last_accessed", ""):
896
+ # `or ""` on BOTH sides: an episode with a missing or
897
+ # explicit-null timestamp leaves last_accessed=None (set
898
+ # from data.get("timestamp") above), and `str > None`
899
+ # raises TypeError, crashing rebuild_index. A single
900
+ # such episode is enough to break the whole rebuild.
901
+ if (data.get("timestamp") or "") > (topics[phase].get("last_accessed") or ""):
882
902
  topics[phase]["last_accessed"] = data.get("timestamp")
883
903
 
884
904
  # Index semantic patterns
@@ -414,6 +414,7 @@ class MemoryRetrieval:
414
414
  context: Dict[str, Any],
415
415
  top_k: int = 5,
416
416
  token_budget: Optional[int] = None,
417
+ persist_boost: bool = False,
417
418
  ) -> List[Dict[str, Any]]:
418
419
  """
419
420
  Retrieve memories with task-type-aware weighting.
@@ -427,6 +428,12 @@ class MemoryRetrieval:
427
428
  token_budget: Optional maximum token budget for returned memories.
428
429
  If specified, results will be optimized to fit within
429
430
  this budget using importance/recency/relevance scoring.
431
+ persist_boost: When True, persist the retrieval-time importance boost
432
+ to disk ("use it or lose it" reinforcement). Default
433
+ False so manual/on-demand retrievals (dashboard, MCP)
434
+ do NOT silently reinforce importance; only the autonomous
435
+ RARV loop opts in. The in-memory boost that shapes the
436
+ returned ranking is applied either way.
430
437
 
431
438
  Returns:
432
439
  List of memory items with source field indicating origin
@@ -476,10 +483,20 @@ class MemoryRetrieval:
476
483
  # Apply recency boost
477
484
  merged = self._apply_recency_boost(merged, boost_factor=0.1)
478
485
 
479
- # Boost importance for retrieved memories (use it or lose it)
486
+ # Boost importance for retrieved memories (use it or lose it). The
487
+ # in-memory boost shapes the returned ranking; persist_boost writes the
488
+ # reinforcement to disk (retrieval-F1: boost_on_retrieval alone never
489
+ # persisted). Persistence is best-effort: a locked/missing record must
490
+ # never break retrieval, so failures are swallowed (mirrors other
491
+ # best-effort writes).
480
492
  if hasattr(self.storage, 'boost_on_retrieval'):
481
493
  for memory in merged[:top_k]:
482
494
  self.storage.boost_on_retrieval(memory, boost=0.05)
495
+ if persist_boost and hasattr(self.storage, 'persist_boost'):
496
+ try:
497
+ self.storage.persist_boost(memory, boost=0.05)
498
+ except Exception:
499
+ pass
483
500
 
484
501
  # Apply token budget optimization if specified
485
502
  if token_budget is not None and token_budget > 0:
package/memory/storage.py CHANGED
@@ -1383,6 +1383,142 @@ class MemoryStorage:
1383
1383
 
1384
1384
  return memory
1385
1385
 
1386
+ def persist_boost(
1387
+ self,
1388
+ memory: Dict[str, Any],
1389
+ boost: float = 0.1,
1390
+ ) -> bool:
1391
+ """
1392
+ Persist a retrieval-time boost to disk ("use it or lose it").
1393
+
1394
+ boost_on_retrieval mutates an in-memory dict only; without this the
1395
+ stored importance/access_count never rises, so repeated retrieval can
1396
+ never reinforce a memory against decay (retrieval-F1). This method
1397
+ applies the SAME boost math to the record as it currently exists on
1398
+ disk, under one exclusive _file_lock spanning a FRESH read -> mutate
1399
+ -> _atomic_write (mirrors _decay_episodic / _decay_semantic).
1400
+
1401
+ Race-safety: the boost is applied to the freshly-read record, NOT to
1402
+ the passed-in `memory` dict. So a concurrent content edit landed by
1403
+ another writer is preserved (we only overwrite importance,
1404
+ access_count, last_accessed), and no retrieval-only transient fields
1405
+ (_score, _source, _collection) leak into the stored record. This is
1406
+ the lost-update-safe pattern WAVE6 established for decay.
1407
+
1408
+ Keyed by memory["id"] and the collection marker retrieval attaches
1409
+ (_source, falling back to _collection). Covers episodic (per-file) and
1410
+ semantic patterns.json. Collections without an updater degrade
1411
+ gracefully (return False, no crash):
1412
+ - skills are keyed on disk by name, not id, so an id-keyed boost
1413
+ cannot reliably target the file; skipped honestly.
1414
+ - the legacy semantic/anti-patterns.json store has NO updater
1415
+ anywhere in this module, so there is nothing to write back to;
1416
+ skipped honestly rather than fabricating a writer.
1417
+
1418
+ Args:
1419
+ memory: A retrieved memory dict (must carry "id" and a source
1420
+ marker). The dict itself is not written to disk.
1421
+ boost: Amount to boost importance (default 0.1).
1422
+
1423
+ Returns:
1424
+ True if a record was found and persisted, False otherwise.
1425
+ """
1426
+ memory_id = memory.get("id")
1427
+ if not memory_id:
1428
+ return False
1429
+
1430
+ source = memory.get("_source") or memory.get("_collection") or ""
1431
+
1432
+ if source == "episodic":
1433
+ return self._persist_boost_episodic(str(memory_id), boost)
1434
+ if source == "semantic":
1435
+ return self._persist_boost_semantic(str(memory_id), boost)
1436
+
1437
+ # skills (keyed by name on disk) and the legacy anti-patterns.json
1438
+ # store (no updater exists in this module) cannot be safely targeted
1439
+ # by an id-keyed boost; skip rather than fabricate a writer.
1440
+ return False
1441
+
1442
+ def _persist_boost_episodic(self, memory_id: str, boost: float) -> bool:
1443
+ """Apply and persist a boost to one episodic record, keyed by id.
1444
+
1445
+ Locates the per-file record (task-<id>.json across date dirs) then does
1446
+ a lock-spanning fresh-read -> boost -> atomic-write, mirroring
1447
+ _decay_episodic. The id is sanitized exactly as save_episode does so a
1448
+ sanitized-on-write filename is still found.
1449
+ """
1450
+ episodic_dir = self.base_path / "episodic"
1451
+ if not episodic_dir.exists():
1452
+ return False
1453
+
1454
+ safe_id = "".join(
1455
+ c if c.isalnum() or c in "-_" else "_"
1456
+ for c in memory_id
1457
+ )
1458
+
1459
+ for date_dir in episodic_dir.iterdir():
1460
+ if not date_dir.is_dir():
1461
+ continue
1462
+ file_path = date_dir / f"task-{safe_id}.json"
1463
+ if not file_path.exists():
1464
+ continue
1465
+
1466
+ # One exclusive lock spanning read-mutate-write. boost_on_retrieval
1467
+ # mutates the freshly-read record in place (importance/access_count/
1468
+ # last_accessed only), so a concurrent content edit on disk is
1469
+ # preserved. _atomic_write re-enters the same reentrant lock.
1470
+ with self._file_lock(file_path, exclusive=True):
1471
+ if not file_path.exists():
1472
+ return False
1473
+ try:
1474
+ with open(file_path, "r", encoding="utf-8") as f:
1475
+ data = json.load(f)
1476
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
1477
+ return False
1478
+ if not data:
1479
+ return False
1480
+ self.boost_on_retrieval(data, boost=boost)
1481
+ self._atomic_write(file_path, data)
1482
+ return True
1483
+
1484
+ return False
1485
+
1486
+ def _persist_boost_semantic(self, memory_id: str, boost: float) -> bool:
1487
+ """Apply and persist a boost to one semantic pattern, keyed by id.
1488
+
1489
+ Patterns live in a single semantic/patterns.json list. Lock-spanning
1490
+ fresh read -> boost the matching entry -> atomic write, mirroring
1491
+ _decay_semantic / save_pattern.
1492
+ """
1493
+ patterns_path = self.base_path / "semantic" / "patterns.json"
1494
+ if not patterns_path.exists():
1495
+ return False
1496
+
1497
+ with self._file_lock(patterns_path, exclusive=True):
1498
+ if not patterns_path.exists():
1499
+ return False
1500
+ try:
1501
+ with open(patterns_path, "r", encoding="utf-8") as f:
1502
+ patterns_file = json.load(f)
1503
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError):
1504
+ return False
1505
+ if not patterns_file:
1506
+ return False
1507
+
1508
+ patterns = patterns_file.get("patterns", [])
1509
+ for pattern in patterns:
1510
+ if not isinstance(pattern, dict):
1511
+ continue
1512
+ if pattern.get("id") == memory_id:
1513
+ self.boost_on_retrieval(pattern, boost=boost)
1514
+ patterns_file["last_updated"] = datetime.now(
1515
+ timezone.utc
1516
+ ).isoformat()
1517
+ self._atomic_write(patterns_path, patterns_file)
1518
+ return True
1519
+
1520
+ return False
1521
+
1386
1522
  def batch_apply_decay(
1387
1523
  self,
1388
1524
  collection: str = "all",
@@ -256,6 +256,15 @@ def optimize_context(
256
256
  relevance = memory.get("_weighted_score", 0.5)
257
257
  else:
258
258
  relevance = memory.get("_score", 0.5)
259
+ # Guard against an explicit null score in a corrupt/hand-edited
260
+ # record: .get(key, default) returns None when the key is present
261
+ # but null, and the `relevance > 1.0` comparison below would then
262
+ # raise "'>' not supported between instances of 'NoneType' and
263
+ # 'float'". Use an is-None check (not `or`) so a legitimate stored
264
+ # 0.0 relevance is preserved, mirroring the confidence guard above
265
+ # and retrieval.py's own base_score guard.
266
+ if relevance is None:
267
+ relevance = 0.5
259
268
  if relevance > 1.0:
260
269
  # Normalize high scores
261
270
  relevance = min(1.0, relevance / 10.0)
@@ -42,7 +42,20 @@ class VectorIndex:
42
42
  Args:
43
43
  dimension: The dimensionality of vectors. Default is 384
44
44
  which matches MiniLM embedding size.
45
+
46
+ Raises:
47
+ ImportError: If numpy is not installed. Every operation in this
48
+ index (add, search, save, load) relies on numpy, so fail
49
+ fast with a clear message here rather than letting a later
50
+ call crash with an opaque ``AttributeError: 'NoneType'
51
+ object has no attribute ...`` once ``np`` is None. The
52
+ module-level NUMPY_AVAILABLE flag is what gates this check.
45
53
  """
54
+ if not NUMPY_AVAILABLE:
55
+ raise ImportError(
56
+ "numpy is required for the vector index. "
57
+ "Install it with: pip install numpy"
58
+ )
46
59
  self.dimension = dimension
47
60
  self.embeddings: List[np.ndarray] = []
48
61
  self.ids: List[str] = []
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.67.0",
4
+ "version": "7.69.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.67.0",
5
+ "version": "7.69.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",