loki-mode 7.66.0 → 7.67.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/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +128 -7
- package/autonomy/loki +92 -50
- package/autonomy/run.sh +122 -33
- package/dashboard/__init__.py +1 -1
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +112 -110
- package/mcp/__init__.py +1 -1
- package/memory/consolidation.py +71 -11
- package/memory/storage.py +35 -5
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
package/mcp/__init__.py
CHANGED
package/memory/consolidation.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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."""
|
|
@@ -981,7 +1036,8 @@ def compress_episode_to_summary(episode: EpisodeTrace) -> str:
|
|
|
981
1036
|
action_count = len(episode.action_log)
|
|
982
1037
|
error_count = len(episode.errors_encountered)
|
|
983
1038
|
|
|
984
|
-
|
|
1039
|
+
# Guard explicit-null goal (C2): None is not subscriptable.
|
|
1040
|
+
summary = f"Task '{(episode.goal or '')[:50]}' {outcome}"
|
|
985
1041
|
|
|
986
1042
|
if action_count > 0:
|
|
987
1043
|
summary += f" after {action_count} actions"
|
|
@@ -1012,10 +1068,13 @@ def compress_episodes_to_pattern_desc(episodes: List[EpisodeTrace]) -> str:
|
|
|
1012
1068
|
return "Unknown pattern"
|
|
1013
1069
|
|
|
1014
1070
|
if len(episodes) == 1:
|
|
1015
|
-
|
|
1071
|
+
# No slice here, so a None goal would f-string as "None" without crashing;
|
|
1072
|
+
# normalize to "" for cleaner output (C2).
|
|
1073
|
+
return f"Pattern from: {episodes[0].goal or ''}"
|
|
1016
1074
|
|
|
1017
1075
|
# Find common goal elements
|
|
1018
|
-
|
|
1076
|
+
# Guard explicit-null goal (C2): None has no .lower().
|
|
1077
|
+
goals = [(ep.goal or "").lower() for ep in episodes]
|
|
1019
1078
|
|
|
1020
1079
|
# Find common words
|
|
1021
1080
|
word_counts: Dict[str, int] = defaultdict(int)
|
|
@@ -1035,4 +1094,5 @@ def compress_episodes_to_pattern_desc(episodes: List[EpisodeTrace]) -> str:
|
|
|
1035
1094
|
return f"Pattern for {theme} tasks ({len(episodes)} instances)"
|
|
1036
1095
|
|
|
1037
1096
|
# Fallback to first episode's goal
|
|
1038
|
-
|
|
1097
|
+
# Guard explicit-null goal (C2): None is not subscriptable.
|
|
1098
|
+
return f"Pattern: {(episodes[0].goal or '')[:100]} (and {len(episodes)-1} similar)"
|
package/memory/storage.py
CHANGED
|
@@ -317,10 +317,16 @@ class MemoryStorage:
|
|
|
317
317
|
if lock_file is not None:
|
|
318
318
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
319
319
|
lock_file.close()
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
320
|
+
# Do NOT os.remove(lock_path) here. Unlinking the lock file on
|
|
321
|
+
# release is a flock+unlink inode-replacement race: with 3+
|
|
322
|
+
# contenders, holder A unlinks inode-1 after B (blocked on it)
|
|
323
|
+
# acquires it, then C opens the path, finds it gone, creates
|
|
324
|
+
# inode-2, and flocks inode-2 -- entering the critical section
|
|
325
|
+
# while B is still inside. That dropped index.json topics under
|
|
326
|
+
# concurrent store_pattern/store_episode (reproduced on Linux
|
|
327
|
+
# py3.13, 16 threads). Persistent lock files are the standard
|
|
328
|
+
# flock pattern; stale ones are GC'd by _cleanup_stale_locks,
|
|
329
|
+
# which is itself flock-safe (probe-before-unlink, wave-6).
|
|
324
330
|
|
|
325
331
|
def _atomic_write(self, path: Path, data: dict) -> None:
|
|
326
332
|
"""
|
|
@@ -589,8 +595,32 @@ class MemoryStorage:
|
|
|
589
595
|
lock_path.unlink()
|
|
590
596
|
except OSError:
|
|
591
597
|
pass
|
|
592
|
-
# Clean up any remaining lock files before checking if dir
|
|
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).
|
|
593
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()
|
|
594
624
|
try:
|
|
595
625
|
stale_lock.unlink()
|
|
596
626
|
except OSError:
|
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.
|
|
4
|
+
"version": "7.67.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.
|
|
5
|
+
"version": "7.67.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",
|