loki-mode 7.66.1 → 7.68.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.66.1'
60
+ __version__ = '7.68.0'
@@ -223,7 +223,17 @@ class ConsolidationPipeline:
223
223
  merged = False
224
224
  for idx, existing in enumerate(existing_patterns):
225
225
  if self._patterns_similar(new_pattern, existing):
226
- merged_pattern = self.merge_with_existing(new_pattern, [existing])
226
+ # Re-read the target pattern fresh immediately before
227
+ # merging (BUG-MEM C1, lost-update). The whole-run
228
+ # snapshot at step 4 can be stale by now: a concurrent
229
+ # engine.increment_pattern_usage() (load_pattern then
230
+ # save_pattern) may have bumped usage_count/last_used
231
+ # AFTER the snapshot. merge_with_existing() builds the
232
+ # merged record from best_match.usage_count/last_used,
233
+ # so merging from the stale snapshot clobbers that bump.
234
+ # Re-reading narrows the window to this single write.
235
+ merge_base = self._reload_pattern(existing)
236
+ merged_pattern = self.merge_with_existing(new_pattern, [merge_base])
227
237
  self.storage.update_pattern(merged_pattern)
228
238
  # Refresh the in-memory copy so a later new pattern in
229
239
  # this same run that also merges into this existing
@@ -262,7 +272,11 @@ class ConsolidationPipeline:
262
272
  for idx, existing in enumerate(existing_patterns):
263
273
  if (existing.incorrect_approach and
264
274
  self._patterns_similar(anti_pattern, existing, threshold=0.6)):
265
- merged_pattern = self.merge_with_existing(anti_pattern, [existing])
275
+ # Re-read fresh before merge (same C1 lost-update guard as the
276
+ # cluster merge loop above): merge from current on-disk state,
277
+ # not the potentially-stale step-4 snapshot.
278
+ merge_base = self._reload_pattern(existing)
279
+ merged_pattern = self.merge_with_existing(anti_pattern, [merge_base])
266
280
  self.storage.update_pattern(merged_pattern)
267
281
  # Refresh in-memory copy (same data-loss guard as the cluster
268
282
  # merge loop above): a later anti-pattern merging into this same
@@ -305,6 +319,35 @@ class ConsolidationPipeline:
305
319
  result.duration_seconds = time.time() - start_time
306
320
  return result
307
321
 
322
+ def _reload_pattern(self, fallback: SemanticPattern) -> SemanticPattern:
323
+ """Re-read a pattern fresh from storage immediately before merging.
324
+
325
+ Used by the merge branches to avoid the C1 lost-update: the step-4
326
+ snapshot may be stale (a concurrent usage bump can land after it), and
327
+ merge_with_existing() copies usage_count/last_used from the base. Reading
328
+ the current on-disk record makes the merge build on live state.
329
+
330
+ Mirrors the snapshot's dict/object handling. If load_pattern returns
331
+ nothing (e.g. the record vanished), fall back to the in-memory copy so the
332
+ merge still proceeds rather than crashing.
333
+
334
+ Residual limitation (honest): load_pattern and update_pattern are SEPARATE
335
+ lock acquisitions, so a bump landing between this re-read and the write is
336
+ still lost. This narrows the race window from the whole run to a single
337
+ write; it is a mitigation, not cross-process atomicity. A full fix needs a
338
+ compare-and-set or merge-callback update in storage, which is out of scope
339
+ for this file (storage.py is frozen this batch).
340
+ """
341
+ try:
342
+ fresh = self.storage.load_pattern(fallback.id)
343
+ except Exception:
344
+ return fallback
345
+ if not fresh:
346
+ return fallback
347
+ if isinstance(fresh, dict):
348
+ return SemanticPattern.from_dict(fresh)
349
+ return fresh
350
+
308
351
  # -------------------------------------------------------------------------
309
352
  # Clustering Methods
310
353
  # -------------------------------------------------------------------------
@@ -475,7 +518,10 @@ class ConsolidationPipeline:
475
518
 
476
519
  def _episode_to_text(self, episode: EpisodeTrace) -> str:
477
520
  """Convert episode to text for embedding."""
478
- parts = [episode.goal]
521
+ # Guard explicit-null goal (C2): from_dict's .get(key, default) returns
522
+ # None on a JSON null, and the " ".join(parts) below crashes on a None
523
+ # member. Mirror the wave-6 (episode.goal or "") idiom.
524
+ parts = [episode.goal or ""]
479
525
 
480
526
  # Add action summaries (handle both ActionEntry objects and dicts)
481
527
  for action in episode.action_log[:5]: # Limit to first 5 actions
@@ -505,7 +551,8 @@ class ConsolidationPipeline:
505
551
  # Find common words in goals
506
552
  all_words: Dict[str, int] = defaultdict(int)
507
553
  for episode in episodes:
508
- for word in episode.goal.lower().split():
554
+ # Guard explicit-null goal (C2): None has no .lower().
555
+ for word in (episode.goal or "").lower().split():
509
556
  if len(word) > 3:
510
557
  all_words[word] += 1
511
558
 
@@ -562,7 +609,11 @@ class ConsolidationPipeline:
562
609
  tool_counts: Dict[str, int] = defaultdict(int)
563
610
  for episode in cluster:
564
611
  for action in episode.action_log:
565
- tool_counts[action.tool] += 1
612
+ # Skip explicit-null tools (C2): an explicit JSON null tool would
613
+ # become a None dict key here, harmless until ", ".join(common_tools)
614
+ # at the end of _extract_correct_approach crashes on it.
615
+ if action.tool:
616
+ tool_counts[action.tool] += 1
566
617
 
567
618
  # Filter to tools used in most episodes
568
619
  common_tools = [
@@ -641,8 +692,10 @@ class ConsolidationPipeline:
641
692
  for episode in episodes:
642
693
  # Get actions before error
643
694
  if episode.action_log:
695
+ # Filter explicit-null tools (C2): pre_error_actions feeds
696
+ # _summarize_actions, whose ", ".join crashes on a None member.
644
697
  pre_error_actions.extend(
645
- [a.tool for a in episode.action_log[-3:]] # Last 3 actions
698
+ [a.tool for a in episode.action_log[-3:] if a.tool] # Last 3 actions
646
699
  )
647
700
 
648
701
  # Collect resolutions
@@ -718,7 +771,9 @@ class ConsolidationPipeline:
718
771
  for i, tool in enumerate(common_seq[:5], 1):
719
772
  steps.append(f"{i}. Use {tool}")
720
773
 
721
- return "; ".join(steps) if steps else f"Use: {', '.join(common_tools)}"
774
+ # Defensively drop any None tool before join (C2): callers filter Nones,
775
+ # but guard here too since this join crashes on a None member.
776
+ return "; ".join(steps) if steps else f"Use: {', '.join(t for t in common_tools if t)}"
722
777
 
723
778
  def _summarize_actions(self, actions: List[str]) -> str:
724
779
  """Summarize a list of actions into a description."""
@@ -784,6 +839,20 @@ class ConsolidationPipeline:
784
839
  if best_match is None or best_similarity < 0.5:
785
840
  return new_pattern
786
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
+
787
856
  # Merge patterns
788
857
  merged = SemanticPattern(
789
858
  id=best_match.id,
@@ -792,7 +861,7 @@ class ConsolidationPipeline:
792
861
  conditions=list(set(best_match.conditions + new_pattern.conditions)),
793
862
  correct_approach=best_match.correct_approach or new_pattern.correct_approach,
794
863
  incorrect_approach=best_match.incorrect_approach or new_pattern.incorrect_approach,
795
- confidence=min(best_match.confidence + 0.05, 0.99),
864
+ confidence=min(best_match.confidence + confidence_boost, 0.99),
796
865
  source_episodes=list(set(best_match.source_episodes + new_pattern.source_episodes)),
797
866
  usage_count=best_match.usage_count,
798
867
  last_used=best_match.last_used,
@@ -981,7 +1050,8 @@ def compress_episode_to_summary(episode: EpisodeTrace) -> str:
981
1050
  action_count = len(episode.action_log)
982
1051
  error_count = len(episode.errors_encountered)
983
1052
 
984
- summary = f"Task '{episode.goal[:50]}' {outcome}"
1053
+ # Guard explicit-null goal (C2): None is not subscriptable.
1054
+ summary = f"Task '{(episode.goal or '')[:50]}' {outcome}"
985
1055
 
986
1056
  if action_count > 0:
987
1057
  summary += f" after {action_count} actions"
@@ -1012,10 +1082,13 @@ def compress_episodes_to_pattern_desc(episodes: List[EpisodeTrace]) -> str:
1012
1082
  return "Unknown pattern"
1013
1083
 
1014
1084
  if len(episodes) == 1:
1015
- return f"Pattern from: {episodes[0].goal}"
1085
+ # No slice here, so a None goal would f-string as "None" without crashing;
1086
+ # normalize to "" for cleaner output (C2).
1087
+ return f"Pattern from: {episodes[0].goal or ''}"
1016
1088
 
1017
1089
  # Find common goal elements
1018
- goals = [ep.goal.lower() for ep in episodes]
1090
+ # Guard explicit-null goal (C2): None has no .lower().
1091
+ goals = [(ep.goal or "").lower() for ep in episodes]
1019
1092
 
1020
1093
  # Find common words
1021
1094
  word_counts: Dict[str, int] = defaultdict(int)
@@ -1035,4 +1108,5 @@ def compress_episodes_to_pattern_desc(episodes: List[EpisodeTrace]) -> str:
1035
1108
  return f"Pattern for {theme} tasks ({len(episodes)} instances)"
1036
1109
 
1037
1110
  # Fallback to first episode's goal
1038
- return f"Pattern: {episodes[0].goal[:100]} (and {len(episodes)-1} similar)"
1111
+ # Guard explicit-null goal (C2): None is not subscriptable.
1112
+ return f"Pattern: {(episodes[0].goal or '')[:100]} (and {len(episodes)-1} similar)"
@@ -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
@@ -595,8 +595,32 @@ class MemoryStorage:
595
595
  lock_path.unlink()
596
596
  except OSError:
597
597
  pass
598
- # Clean up any remaining lock files before checking if dir is empty
598
+ # Clean up any remaining lock files before checking if dir
599
+ # is empty. A blanket unlink of every *.lock here is the same
600
+ # flock+unlink inode-replacement race fixed in _file_lock and
601
+ # _cleanup_stale_locks: a lock held by a concurrent writer of
602
+ # a DIFFERENT episode in this same date dir would have its
603
+ # inode unlinked, letting a third writer create a new inode
604
+ # and enter the critical section concurrently (data loss).
605
+ # Only unlink a lock we can take ourselves (nobody holds it);
606
+ # held locks are left in place (their writer is still active).
599
607
  for stale_lock in date_dir.glob("*.lock"):
608
+ probe_fd = None
609
+ try:
610
+ probe_fd = open(stale_lock, "a")
611
+ fcntl.flock(probe_fd.fileno(),
612
+ fcntl.LOCK_EX | fcntl.LOCK_NB)
613
+ except (OSError, BlockingIOError):
614
+ # Held by a live writer -- leave it alone.
615
+ continue
616
+ finally:
617
+ if probe_fd is not None:
618
+ try:
619
+ fcntl.flock(probe_fd.fileno(),
620
+ fcntl.LOCK_UN)
621
+ except OSError:
622
+ pass
623
+ probe_fd.close()
600
624
  try:
601
625
  stale_lock.unlink()
602
626
  except OSError:
@@ -1359,6 +1383,142 @@ class MemoryStorage:
1359
1383
 
1360
1384
  return memory
1361
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
+
1362
1522
  def batch_apply_decay(
1363
1523
  self,
1364
1524
  collection: str = "all",
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.66.1",
4
+ "version": "7.68.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.66.1",
5
+ "version": "7.68.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",