loki-mode 7.68.0 → 7.70.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.70.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
@@ -156,7 +156,6 @@ class ProgressiveLoader:
156
156
  remaining_tokens -= layer2_tokens
157
157
 
158
158
  # Collect timeline context for each relevant topic
159
- topic_ids = {t.id for t in relevant_topics}
160
159
  timeline_context: Dict[str, List[Dict[str, Any]]] = {}
161
160
 
162
161
  for topic in relevant_topics:
@@ -177,12 +176,17 @@ class ProgressiveLoader:
177
176
  self._metrics.calculate_savings(index.get("total_tokens_available", 0))
178
177
  return memories, self._metrics
179
178
 
180
- # Layer 3: Load full memories for high-relevance topics
179
+ # Layer 3: Load full memories for high-relevance topics.
180
+ # Gate on effective_score (boosted match score when set, else stored
181
+ # relevance), not the stored relevance_score. The Layer-1 keyword
182
+ # boost lives on the transient match_score precisely so a strongly
183
+ # matching topic can clear this Layer-3 gate; reading the un-boosted
184
+ # relevance_score here would silently drop exactly those topics the
185
+ # boost was meant to surface.
181
186
  if remaining_tokens > 0:
182
- # Sort topics by relevance
183
187
  high_relevance = [
184
188
  t for t in relevant_topics
185
- if t.relevance_score >= self.HIGH_RELEVANCE_THRESHOLD
189
+ if t.effective_score >= self.HIGH_RELEVANCE_THRESHOLD
186
190
  ]
187
191
 
188
192
  storage = self._get_storage()
@@ -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.70.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.70.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",
@@ -89,7 +89,7 @@ PROVIDER_COST_OUTPUT_FAST=0.015
89
89
  PROVIDER_DEGRADED=true
90
90
  PROVIDER_DEGRADED_REASONS=(
91
91
  "No subagent support"
92
- "Sequential execution only"
92
+ "Sequential execution only - no parallel agents (PROVIDER_MAX_PARALLEL=1)"
93
93
  "No Task tool or MCP"
94
94
  )
95
95
 
@@ -377,7 +377,15 @@ loki_apply_max_tier_clamp() {
377
377
 
378
378
  # Dynamic model resolution (v6.0.0)
379
379
  # Resolves a capability tier to a concrete model name at runtime.
380
- # Respects LOKI_MAX_TIER to cap cost (e.g., maxTier=sonnet prevents opus usage).
380
+ # Respects LOKI_MAX_TIER to cap cost via loki_apply_max_tier_clamp. NOTE the
381
+ # ceiling clamps DOWN to the provider's configured tier model, not to the alias
382
+ # named by the cap: with the stock config (CLAUDE_DEFAULT_DEVELOPMENT=opus, see
383
+ # line 56), LOKI_MAX_TIER=sonnet resolves planning/fable DOWN to
384
+ # PROVIDER_MODEL_DEVELOPMENT, which is still opus. To actually pin sonnet as the
385
+ # ceiling, also set LOKI_ALLOW_HAIKU=true (which makes PROVIDER_MODEL_DEVELOPMENT
386
+ # sonnet) or override LOKI_CLAUDE_MODEL_DEVELOPMENT=sonnet. This is intentional
387
+ # and is mirrored byte-for-byte by the dashboard/estimator ports (parity-locked
388
+ # in tests/test-model-override.sh).
381
389
  # Capability aliases: "best" -> planning tier, "fast" -> fast tier, "balanced" -> development tier
382
390
  resolve_model_for_tier() {
383
391
  local tier="$1"
@@ -161,6 +161,13 @@ resolve_model_for_tier() {
161
161
  esac
162
162
 
163
163
  local max_tier="${LOKI_MAX_TIER:-}"
164
+ # Normalize EXACTLY like claude.sh:356 (loki_apply_max_tier_clamp): trim +
165
+ # lowercase BEFORE the case match. Without this, a user-typed cap like "Haiku"
166
+ # or " haiku " (settings.json maxTier exports verbatim) fell through to the
167
+ # default arm and the cost ceiling was silently bypassed for codex while
168
+ # claude honored it. Both routes (this + applyCodexMaxTier in providers.ts)
169
+ # normalize identically. Parity fix.
170
+ max_tier="$(printf '%s' "$max_tier" | tr '[:upper:]' '[:lower:]' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
164
171
  local effort=""
165
172
 
166
173
  case "$tier" in