loki-mode 7.68.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.68.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:
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
@@ -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.68.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.68.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",