loki-mode 6.60.0 → 6.62.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 +34 -8
- package/autonomy/completion-council.sh +70 -32
- package/autonomy/issue-parser.sh +4 -7
- package/autonomy/loki +238 -119
- package/autonomy/notification-checker.py +49 -23
- package/autonomy/run.sh +162 -79
- package/autonomy/sandbox.sh +91 -24
- package/bin/loki-mode.js +1 -2
- package/bin/postinstall.js +10 -4
- package/dashboard/__init__.py +1 -1
- package/dashboard/control.py +46 -36
- package/dashboard/database.py +21 -4
- package/dashboard/server.py +107 -78
- package/docs/BUG-AUDIT-v6.61.0.md +957 -0
- package/docs/INSTALLATION.md +2 -2
- package/events/bus.py +129 -28
- package/events/bus.ts +41 -27
- package/events/emit.sh +1 -1
- package/integrations/openclaw/README.md +139 -0
- package/integrations/openclaw/SKILL.md +88 -0
- package/integrations/openclaw/bridge/__init__.py +1 -0
- package/integrations/openclaw/bridge/__main__.py +88 -0
- package/integrations/openclaw/bridge/schema_map.py +180 -0
- package/integrations/openclaw/bridge/watcher.py +100 -0
- package/integrations/openclaw/scripts/format-progress.sh +80 -0
- package/integrations/openclaw/scripts/poll-status.sh +74 -0
- package/integrations/vibe-kanban.md +289 -0
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +96 -73
- package/memory/consolidation.py +21 -6
- package/memory/engine.py +53 -26
- package/memory/layers/index_layer.py +16 -3
- package/memory/layers/timeline_layer.py +16 -3
- package/memory/retrieval.py +4 -1
- package/memory/schemas.py +4 -2
- package/memory/storage.py +25 -4
- package/memory/token_economics.py +9 -2
- package/memory/vector_index.py +2 -2
- package/package.json +3 -1
- package/providers/cline.sh +5 -4
- package/providers/codex.sh +27 -5
- package/providers/gemini.sh +59 -23
- package/providers/loader.sh +3 -2
- package/skills/parallel-workflows.md +9 -7
- package/state/__init__.py +10 -0
- package/state/index.ts +18 -0
- package/state/manager.py +1801 -0
- package/state/manager.ts +1774 -0
- package/state/sqlite_backend.py +188 -0
- package/state/test_manager.py +703 -0
- package/state/test_manager.ts +366 -0
- package/templates/README.md +19 -4
- package/templates/dashboard.md +45 -0
- package/templates/data-pipeline.md +45 -0
- package/templates/game.md +48 -0
- package/templates/microservice.md +49 -0
- package/templates/npm-library.md +42 -0
- package/templates/rest-api.md +170 -33
- package/templates/slack-bot.md +48 -0
- package/templates/web-scraper.md +45 -0
- package/web-app/server.py +360 -191
- package/templates/saas-app.md +0 -42
package/memory/engine.py
CHANGED
|
@@ -290,10 +290,10 @@ class MemoryEngine:
|
|
|
290
290
|
"""
|
|
291
291
|
# Parse date from episode ID (format: ep-YYYY-MM-DD-XXX)
|
|
292
292
|
parts = episode_id.split("-")
|
|
293
|
-
if len(parts) >= 4:
|
|
293
|
+
if len(parts) >= 5 and len(parts[1]) == 4 and len(parts[2]) == 2 and len(parts[3]) == 2:
|
|
294
294
|
date_str = f"{parts[1]}-{parts[2]}-{parts[3]}"
|
|
295
295
|
else:
|
|
296
|
-
#
|
|
296
|
+
# Non-standard ID format; search all directories
|
|
297
297
|
return self._search_episode(episode_id)
|
|
298
298
|
|
|
299
299
|
data = self.storage.read_json(f"episodic/{date_str}/task-{episode_id}.json")
|
|
@@ -355,26 +355,9 @@ class MemoryEngine:
|
|
|
355
355
|
Returns:
|
|
356
356
|
Pattern ID
|
|
357
357
|
"""
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
# Load existing patterns
|
|
363
|
-
patterns_data = self.storage.read_json("semantic/patterns.json") or {"patterns": []}
|
|
364
|
-
|
|
365
|
-
# Check if pattern exists and update, otherwise append
|
|
366
|
-
existing_idx = None
|
|
367
|
-
for idx, p in enumerate(patterns_data["patterns"]):
|
|
368
|
-
if p.get("id") == pattern_id:
|
|
369
|
-
existing_idx = idx
|
|
370
|
-
break
|
|
371
|
-
|
|
372
|
-
if existing_idx is not None:
|
|
373
|
-
patterns_data["patterns"][existing_idx] = pattern_dict
|
|
374
|
-
else:
|
|
375
|
-
patterns_data["patterns"].append(pattern_dict)
|
|
376
|
-
|
|
377
|
-
self.storage.write_json("semantic/patterns.json", patterns_data)
|
|
358
|
+
# Delegate to storage.save_pattern() which performs the
|
|
359
|
+
# read-modify-write under a single file lock, preventing races.
|
|
360
|
+
pattern_id = self.storage.save_pattern(pattern)
|
|
378
361
|
|
|
379
362
|
# Update index
|
|
380
363
|
self._update_index_with_pattern(pattern_dict)
|
|
@@ -1191,10 +1174,54 @@ class MemoryEngine:
|
|
|
1191
1174
|
collection: str,
|
|
1192
1175
|
top_k: int,
|
|
1193
1176
|
) -> List[Dict[str, Any]]:
|
|
1194
|
-
"""Vector similarity search
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1177
|
+
"""Vector similarity search using cosine similarity against stored memories."""
|
|
1178
|
+
try:
|
|
1179
|
+
import numpy as np
|
|
1180
|
+
except ImportError:
|
|
1181
|
+
# No numpy available, fall back to keyword search
|
|
1182
|
+
return self._keyword_search("", collection, top_k)
|
|
1183
|
+
|
|
1184
|
+
query_vec = np.asarray(embedding, dtype=np.float32)
|
|
1185
|
+
query_norm = np.linalg.norm(query_vec)
|
|
1186
|
+
if query_norm == 0:
|
|
1187
|
+
return self._keyword_search("", collection, top_k)
|
|
1188
|
+
query_vec = query_vec / query_norm
|
|
1189
|
+
|
|
1190
|
+
# Load memories from the collection using the same paths as _keyword_search
|
|
1191
|
+
items: List[Dict[str, Any]] = []
|
|
1192
|
+
if collection == "episodic":
|
|
1193
|
+
for ep in self.get_recent_episodes(limit=50):
|
|
1194
|
+
items.append(ep.to_dict() if hasattr(ep, "to_dict") else ep.__dict__.copy())
|
|
1195
|
+
elif collection == "semantic":
|
|
1196
|
+
for pattern in self.find_patterns(min_confidence=0.3):
|
|
1197
|
+
items.append(pattern.to_dict() if hasattr(pattern, "to_dict") else pattern.__dict__.copy())
|
|
1198
|
+
elif collection == "procedural":
|
|
1199
|
+
for skill in self.list_skills():
|
|
1200
|
+
items.append(skill.to_dict() if hasattr(skill, "to_dict") else skill.__dict__.copy())
|
|
1201
|
+
|
|
1202
|
+
# Score each item by cosine similarity against its stored embedding
|
|
1203
|
+
scored: List[tuple] = []
|
|
1204
|
+
for item in items:
|
|
1205
|
+
item_embedding = item.get("_embedding")
|
|
1206
|
+
if not item_embedding:
|
|
1207
|
+
continue
|
|
1208
|
+
item_vec = np.asarray(item_embedding, dtype=np.float32)
|
|
1209
|
+
item_norm = np.linalg.norm(item_vec)
|
|
1210
|
+
if item_norm == 0:
|
|
1211
|
+
continue
|
|
1212
|
+
similarity = float(np.dot(query_vec, item_vec / item_norm))
|
|
1213
|
+
scored.append((similarity, item))
|
|
1214
|
+
|
|
1215
|
+
if not scored:
|
|
1216
|
+
# No embeddings stored; fall back to keyword search
|
|
1217
|
+
return self._keyword_search("", collection, top_k)
|
|
1218
|
+
|
|
1219
|
+
scored.sort(key=lambda x: x[0], reverse=True)
|
|
1220
|
+
results = []
|
|
1221
|
+
for score, item in scored[:top_k]:
|
|
1222
|
+
item["_score"] = score
|
|
1223
|
+
results.append(item)
|
|
1224
|
+
return results
|
|
1198
1225
|
|
|
1199
1226
|
def _queue_for_embedding(
|
|
1200
1227
|
self,
|
|
@@ -10,6 +10,8 @@ loading full memory content.
|
|
|
10
10
|
"""
|
|
11
11
|
|
|
12
12
|
import json
|
|
13
|
+
import os
|
|
14
|
+
import tempfile
|
|
13
15
|
from dataclasses import dataclass, field, asdict
|
|
14
16
|
from datetime import datetime, timezone
|
|
15
17
|
from pathlib import Path
|
|
@@ -109,7 +111,7 @@ class IndexLayer:
|
|
|
109
111
|
|
|
110
112
|
def _save(self, index: Dict[str, Any]) -> None:
|
|
111
113
|
"""
|
|
112
|
-
Save index to disk.
|
|
114
|
+
Save index to disk atomically via temp file + os.replace().
|
|
113
115
|
|
|
114
116
|
Args:
|
|
115
117
|
index: Index dictionary to save
|
|
@@ -117,8 +119,19 @@ class IndexLayer:
|
|
|
117
119
|
self.base_path.mkdir(parents=True, exist_ok=True)
|
|
118
120
|
index["last_updated"] = datetime.now(timezone.utc).isoformat()
|
|
119
121
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
123
|
+
dir=str(self.base_path), prefix=".tmp_index_", suffix=".json"
|
|
124
|
+
)
|
|
125
|
+
try:
|
|
126
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
127
|
+
json.dump(index, f, indent=2)
|
|
128
|
+
os.replace(tmp_path, str(self.index_path))
|
|
129
|
+
except Exception:
|
|
130
|
+
try:
|
|
131
|
+
os.unlink(tmp_path)
|
|
132
|
+
except OSError:
|
|
133
|
+
pass
|
|
134
|
+
raise
|
|
122
135
|
|
|
123
136
|
self._cache = index
|
|
124
137
|
|
|
@@ -9,6 +9,8 @@ providing temporal context before loading full memories.
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
|
+
import os
|
|
13
|
+
import tempfile
|
|
12
14
|
from datetime import datetime, timezone
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
from typing import List, Dict, Any, Optional
|
|
@@ -70,7 +72,7 @@ class TimelineLayer:
|
|
|
70
72
|
|
|
71
73
|
def _save(self, timeline: Dict[str, Any]) -> None:
|
|
72
74
|
"""
|
|
73
|
-
Save timeline to disk.
|
|
75
|
+
Save timeline to disk atomically via temp file + os.replace().
|
|
74
76
|
|
|
75
77
|
Args:
|
|
76
78
|
timeline: Timeline dictionary to save
|
|
@@ -78,8 +80,19 @@ class TimelineLayer:
|
|
|
78
80
|
self.base_path.mkdir(parents=True, exist_ok=True)
|
|
79
81
|
timeline["last_updated"] = datetime.now(timezone.utc).isoformat()
|
|
80
82
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
fd, tmp_path = tempfile.mkstemp(
|
|
84
|
+
dir=str(self.base_path), prefix=".tmp_timeline_", suffix=".json"
|
|
85
|
+
)
|
|
86
|
+
try:
|
|
87
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
88
|
+
json.dump(timeline, f, indent=2)
|
|
89
|
+
os.replace(tmp_path, str(self.timeline_path))
|
|
90
|
+
except Exception:
|
|
91
|
+
try:
|
|
92
|
+
os.unlink(tmp_path)
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
raise
|
|
83
96
|
|
|
84
97
|
self._cache = timeline
|
|
85
98
|
|
package/memory/retrieval.py
CHANGED
|
@@ -1082,7 +1082,10 @@ class MemoryRetrieval:
|
|
|
1082
1082
|
|
|
1083
1083
|
# Layer 3: Full details for highest priority items
|
|
1084
1084
|
if budget_remaining > 100: # At least 100 tokens remaining
|
|
1085
|
-
|
|
1085
|
+
# Cap top_k based on remaining budget to avoid loading unbounded data.
|
|
1086
|
+
# Estimate ~50 tokens per result as a rough lower bound.
|
|
1087
|
+
max_results = max(1, min(10, budget_remaining // 50))
|
|
1088
|
+
full_details = self.retrieve_task_aware(context, top_k=max_results)
|
|
1086
1089
|
for detail in full_details:
|
|
1087
1090
|
detail["_layer"] = 3
|
|
1088
1091
|
|
package/memory/schemas.py
CHANGED
|
@@ -23,8 +23,10 @@ def _to_utc_isoformat(dt: datetime) -> str:
|
|
|
23
23
|
Handles both timezone-aware and timezone-naive datetimes.
|
|
24
24
|
If dt has a non-UTC timezone, converts to UTC first.
|
|
25
25
|
"""
|
|
26
|
-
# If timezone-aware and not UTC, convert to UTC
|
|
27
|
-
|
|
26
|
+
# If timezone-aware and not UTC, convert to UTC.
|
|
27
|
+
# Compare as timedelta values (not by identity) for reliable cross-tz checks.
|
|
28
|
+
from datetime import timedelta as _td
|
|
29
|
+
if dt.tzinfo is not None and (dt.utcoffset() or _td(0)) != _td(0):
|
|
28
30
|
dt = dt.astimezone(timezone.utc)
|
|
29
31
|
|
|
30
32
|
iso = dt.isoformat()
|
package/memory/storage.py
CHANGED
|
@@ -13,6 +13,7 @@ import os
|
|
|
13
13
|
import tempfile
|
|
14
14
|
import shutil
|
|
15
15
|
import fcntl
|
|
16
|
+
import threading
|
|
16
17
|
from datetime import datetime, timezone, timedelta
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import Optional, List, Dict, Any, Union
|
|
@@ -81,6 +82,10 @@ class MemoryStorage:
|
|
|
81
82
|
else:
|
|
82
83
|
self.base_path = self._root_path
|
|
83
84
|
|
|
85
|
+
# Reentrant lock tracking: prevents deadlock when _file_lock is
|
|
86
|
+
# called on the same path from nested operations in the same thread.
|
|
87
|
+
self._held_locks: threading.local = threading.local()
|
|
88
|
+
|
|
84
89
|
self._ensure_directories()
|
|
85
90
|
self._ensure_index()
|
|
86
91
|
self._ensure_timeline()
|
|
@@ -193,7 +198,10 @@ class MemoryStorage:
|
|
|
193
198
|
@contextmanager
|
|
194
199
|
def _file_lock(self, path: Path, exclusive: bool = True):
|
|
195
200
|
"""
|
|
196
|
-
Context manager for file locking.
|
|
201
|
+
Context manager for reentrant file locking.
|
|
202
|
+
|
|
203
|
+
If the current thread already holds the lock for this path,
|
|
204
|
+
the call is a no-op (avoids deadlock from nested lock acquisition).
|
|
197
205
|
|
|
198
206
|
Args:
|
|
199
207
|
path: Path to the file to lock
|
|
@@ -203,6 +211,17 @@ class MemoryStorage:
|
|
|
203
211
|
File handle with lock held
|
|
204
212
|
"""
|
|
205
213
|
lock_path = path.with_suffix(path.suffix + ".lock")
|
|
214
|
+
lock_key = str(lock_path)
|
|
215
|
+
|
|
216
|
+
# Check if this thread already holds the lock (reentrant case)
|
|
217
|
+
if not hasattr(self._held_locks, "paths"):
|
|
218
|
+
self._held_locks.paths = set()
|
|
219
|
+
|
|
220
|
+
if lock_key in self._held_locks.paths:
|
|
221
|
+
# Already held by this thread -- skip to avoid deadlock
|
|
222
|
+
yield
|
|
223
|
+
return
|
|
224
|
+
|
|
206
225
|
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
207
226
|
|
|
208
227
|
lock_file = None
|
|
@@ -211,8 +230,10 @@ class MemoryStorage:
|
|
|
211
230
|
lock_file = open(lock_path, "w")
|
|
212
231
|
lock_type = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
|
213
232
|
fcntl.flock(lock_file.fileno(), lock_type)
|
|
233
|
+
self._held_locks.paths.add(lock_key)
|
|
214
234
|
yield
|
|
215
235
|
finally:
|
|
236
|
+
self._held_locks.paths.discard(lock_key)
|
|
216
237
|
if lock_file is not None:
|
|
217
238
|
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
218
239
|
lock_file.close()
|
|
@@ -1196,7 +1217,7 @@ class MemoryStorage:
|
|
|
1196
1217
|
if data:
|
|
1197
1218
|
original_importance = data.get("importance", 0.5)
|
|
1198
1219
|
memories = self.apply_decay([data], decay_rate, half_life_days)
|
|
1199
|
-
if memories[0].get("importance")
|
|
1220
|
+
if abs(memories[0].get("importance", 0.5) - original_importance) > 0.001:
|
|
1200
1221
|
self._atomic_write(file_path, memories[0])
|
|
1201
1222
|
updated += 1
|
|
1202
1223
|
|
|
@@ -1220,7 +1241,7 @@ class MemoryStorage:
|
|
|
1220
1241
|
for pattern in patterns:
|
|
1221
1242
|
original = pattern.get("importance", 0.5)
|
|
1222
1243
|
self.apply_decay([pattern], decay_rate, half_life_days)
|
|
1223
|
-
if pattern.get("importance")
|
|
1244
|
+
if abs(pattern.get("importance", 0.5) - original) > 0.001:
|
|
1224
1245
|
updated += 1
|
|
1225
1246
|
|
|
1226
1247
|
if updated > 0:
|
|
@@ -1241,7 +1262,7 @@ class MemoryStorage:
|
|
|
1241
1262
|
if data:
|
|
1242
1263
|
original = data.get("importance", 0.5)
|
|
1243
1264
|
self.apply_decay([data], decay_rate, half_life_days)
|
|
1244
|
-
if data.get("importance")
|
|
1265
|
+
if abs(data.get("importance", 0.5) - original) > 0.001:
|
|
1245
1266
|
self._atomic_write(file_path, data)
|
|
1246
1267
|
updated += 1
|
|
1247
1268
|
|
|
@@ -557,15 +557,22 @@ class TokenEconomics:
|
|
|
557
557
|
"""
|
|
558
558
|
Get a summary of token economics for this session.
|
|
559
559
|
|
|
560
|
+
Caches computed values to avoid redundant filesystem scans
|
|
561
|
+
when check_thresholds() re-calls get_ratio()/get_savings_percent().
|
|
562
|
+
|
|
560
563
|
Returns:
|
|
561
564
|
Dictionary with session info, metrics, and computed values
|
|
562
565
|
"""
|
|
566
|
+
# Pre-compute once; check_thresholds reuses the cached baseline.
|
|
567
|
+
ratio = self.get_ratio()
|
|
568
|
+
savings = self.get_savings_percent()
|
|
569
|
+
|
|
563
570
|
return {
|
|
564
571
|
"session_id": self.session_id,
|
|
565
572
|
"started_at": self.started_at.isoformat(),
|
|
566
573
|
"metrics": dict(self.metrics),
|
|
567
|
-
"ratio":
|
|
568
|
-
"savings_percent":
|
|
574
|
+
"ratio": ratio,
|
|
575
|
+
"savings_percent": savings,
|
|
569
576
|
"thresholds_triggered": [a.to_dict() for a in self.check_thresholds()],
|
|
570
577
|
}
|
|
571
578
|
|
package/memory/vector_index.py
CHANGED
|
@@ -290,7 +290,7 @@ class VectorIndex:
|
|
|
290
290
|
"dimension": self.dimension
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
with open(f"{path}.json", "w") as f:
|
|
293
|
+
with open(f"{path}.json", "w", encoding="utf-8") as f:
|
|
294
294
|
json.dump(sidecar_data, f, indent=2)
|
|
295
295
|
|
|
296
296
|
def load(self, path: str) -> None:
|
|
@@ -318,7 +318,7 @@ class VectorIndex:
|
|
|
318
318
|
self.dimension = int(data["dimension"][0])
|
|
319
319
|
|
|
320
320
|
# Load metadata
|
|
321
|
-
with open(json_path, "r") as f:
|
|
321
|
+
with open(json_path, "r", encoding="utf-8") as f:
|
|
322
322
|
sidecar_data = json.load(f)
|
|
323
323
|
|
|
324
324
|
self.ids = sidecar_data["ids"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loki-mode",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.62.0",
|
|
4
4
|
"description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agent",
|
|
@@ -80,6 +80,8 @@
|
|
|
80
80
|
"dashboard/static/",
|
|
81
81
|
"dashboard/requirements.txt",
|
|
82
82
|
"mcp/",
|
|
83
|
+
"state/",
|
|
84
|
+
"integrations/",
|
|
83
85
|
"completions/",
|
|
84
86
|
"src/",
|
|
85
87
|
"web-app/dist/",
|
package/providers/cline.sh
CHANGED
|
@@ -90,14 +90,15 @@ provider_version() {
|
|
|
90
90
|
|
|
91
91
|
# Invocation function
|
|
92
92
|
# Uses -y (YOLO) for autonomous mode, positional prompt
|
|
93
|
+
# BUG-PROV-009 fix: build model flag as array to prevent word-splitting on model
|
|
94
|
+
# names that contain spaces or special characters
|
|
93
95
|
provider_invoke() {
|
|
94
96
|
local prompt="$1"
|
|
95
97
|
shift
|
|
96
98
|
local model="${LOKI_CLINE_MODEL:-}"
|
|
97
|
-
local
|
|
98
|
-
[[ -n "$model" ]] &&
|
|
99
|
-
|
|
100
|
-
cline -y $model_flag "$prompt" "$@" 2>&1
|
|
99
|
+
local model_args=()
|
|
100
|
+
[[ -n "$model" ]] && model_args=("-m" "$model")
|
|
101
|
+
cline -y "${model_args[@]}" "$prompt" "$@" 2>&1
|
|
101
102
|
}
|
|
102
103
|
|
|
103
104
|
# Model tier to parameter (Cline uses single model, returns model name)
|
package/providers/codex.sh
CHANGED
|
@@ -51,9 +51,28 @@ PROVIDER_MAX_PARALLEL=1
|
|
|
51
51
|
# Codex uses single model with effort parameter
|
|
52
52
|
# NOTE: gpt-5.3-codex is the official model name for Codex CLI v0.98+
|
|
53
53
|
CODEX_DEFAULT_MODEL="gpt-5.3-codex"
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
|
|
55
|
+
# Known valid Codex model prefixes for validation (BUG-PROV-002 fix)
|
|
56
|
+
# Generic LOKI_MODEL_* may contain Claude/Gemini model names (e.g. "opus", "sonnet",
|
|
57
|
+
# "gemini-3-pro-preview") which are invalid for Codex. Validate before accepting.
|
|
58
|
+
CODEX_KNOWN_MODELS=("gpt-" "o1-" "o3-" "o4-" "codex-" "ft:gpt-")
|
|
59
|
+
|
|
60
|
+
_codex_validate_model() {
|
|
61
|
+
local model="$1"
|
|
62
|
+
for prefix in "${CODEX_KNOWN_MODELS[@]}"; do
|
|
63
|
+
if [[ "$model" == ${prefix}* ]]; then
|
|
64
|
+
echo "$model"
|
|
65
|
+
return 0
|
|
66
|
+
fi
|
|
67
|
+
done
|
|
68
|
+
# Not a valid Codex model name -- fall back to default
|
|
69
|
+
echo "$CODEX_DEFAULT_MODEL"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Provider-specific env (LOKI_CODEX_MODEL) is trusted; generic LOKI_MODEL_* is validated
|
|
73
|
+
PROVIDER_MODEL_PLANNING="$(_codex_validate_model "${LOKI_CODEX_MODEL:-${LOKI_MODEL_PLANNING:-$CODEX_DEFAULT_MODEL}}")"
|
|
74
|
+
PROVIDER_MODEL_DEVELOPMENT="$(_codex_validate_model "${LOKI_CODEX_MODEL:-${LOKI_MODEL_DEVELOPMENT:-$CODEX_DEFAULT_MODEL}}")"
|
|
75
|
+
PROVIDER_MODEL_FAST="$(_codex_validate_model "${LOKI_CODEX_MODEL:-${LOKI_MODEL_FAST:-$CODEX_DEFAULT_MODEL}}")"
|
|
57
76
|
|
|
58
77
|
# Effort levels (Codex-specific: maps to reasoning time, not model capability)
|
|
59
78
|
PROVIDER_EFFORT_PLANNING="xhigh"
|
|
@@ -115,8 +134,11 @@ provider_get_tier_param() {
|
|
|
115
134
|
}
|
|
116
135
|
|
|
117
136
|
# Dynamic model resolution (v6.0.0)
|
|
118
|
-
#
|
|
119
|
-
#
|
|
137
|
+
# NOTE (BUG-PROV-012): Unlike other providers, Codex resolve_model_for_tier returns
|
|
138
|
+
# an EFFORT LEVEL (xhigh/high/low), not a model name. Codex uses a single model
|
|
139
|
+
# (gpt-5.3-codex) with varying effort. Callers that need the model name should use
|
|
140
|
+
# PROVIDER_MODEL_DEVELOPMENT (or CODEX_DEFAULT_MODEL) directly.
|
|
141
|
+
# The effort value is passed via CODEX_MODEL_REASONING_EFFORT env var at invocation.
|
|
120
142
|
resolve_model_for_tier() {
|
|
121
143
|
local tier="$1"
|
|
122
144
|
|
package/providers/gemini.sh
CHANGED
|
@@ -54,12 +54,39 @@ PROVIDER_MAX_PARALLEL=1
|
|
|
54
54
|
GEMINI_DEFAULT_PRO="gemini-3-pro-preview"
|
|
55
55
|
GEMINI_DEFAULT_FLASH="gemini-3-flash-preview"
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
57
|
+
# Known valid Gemini model prefixes for validation
|
|
58
|
+
GEMINI_KNOWN_MODELS=("gemini-" "models/gemini-")
|
|
59
|
+
|
|
60
|
+
# Validate that a model name looks like a Gemini model
|
|
61
|
+
_gemini_validate_model() {
|
|
62
|
+
local model="$1"
|
|
63
|
+
local fallback="$2"
|
|
64
|
+
for prefix in "${GEMINI_KNOWN_MODELS[@]}"; do
|
|
65
|
+
if [[ "$model" == ${prefix}* ]]; then
|
|
66
|
+
echo "$model"
|
|
67
|
+
return 0
|
|
68
|
+
fi
|
|
69
|
+
done
|
|
70
|
+
# Not a valid Gemini model name -- fall back
|
|
71
|
+
echo "$fallback"
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
PROVIDER_MODEL_PLANNING="$(_gemini_validate_model "${LOKI_GEMINI_MODEL_PLANNING:-${LOKI_MODEL_PLANNING:-$GEMINI_DEFAULT_PRO}}" "$GEMINI_DEFAULT_PRO")"
|
|
75
|
+
PROVIDER_MODEL_DEVELOPMENT="$(_gemini_validate_model "${LOKI_GEMINI_MODEL_DEVELOPMENT:-${LOKI_MODEL_DEVELOPMENT:-$GEMINI_DEFAULT_PRO}}" "$GEMINI_DEFAULT_PRO")"
|
|
76
|
+
PROVIDER_MODEL_FAST="$(_gemini_validate_model "${LOKI_GEMINI_MODEL_FAST:-${LOKI_MODEL_FAST:-$GEMINI_DEFAULT_FLASH}}" "$GEMINI_DEFAULT_FLASH")"
|
|
61
77
|
PROVIDER_MODEL_FALLBACK="${LOKI_GEMINI_MODEL_FALLBACK:-$GEMINI_DEFAULT_FLASH}"
|
|
62
78
|
|
|
79
|
+
# BUG-PROV-006 fix: PROVIDER_MODEL is now a function, not a frozen variable.
|
|
80
|
+
# For backward compatibility, set the variable to planning model at load time,
|
|
81
|
+
# but callers should use provider_get_current_model() for runtime resolution.
|
|
82
|
+
PROVIDER_MODEL="${PROVIDER_MODEL_PLANNING}"
|
|
83
|
+
|
|
84
|
+
# Return the model for the current tier at runtime (not frozen at load time)
|
|
85
|
+
provider_get_current_model() {
|
|
86
|
+
local tier="${LOKI_CURRENT_TIER:-planning}"
|
|
87
|
+
resolve_model_for_tier "$tier"
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
# Thinking levels (Gemini-specific: maps to reasoning depth)
|
|
64
91
|
PROVIDER_THINKING_PLANNING="high"
|
|
65
92
|
PROVIDER_THINKING_DEVELOPMENT="medium"
|
|
@@ -106,27 +133,37 @@ provider_version() {
|
|
|
106
133
|
# Invocation function with rate limit fallback
|
|
107
134
|
# Uses --model flag to specify model, --approval-mode=yolo for autonomous mode
|
|
108
135
|
# Falls back to flash model if pro hits rate limit
|
|
136
|
+
# Accepts optional --model <name> as first args to override default model
|
|
137
|
+
# BUG-PROV-010 fix: uses tee to stream output while still capturing for rate-limit check
|
|
109
138
|
# Note: < /dev/null prevents Gemini from pausing on stdin
|
|
110
139
|
provider_invoke() {
|
|
140
|
+
local model
|
|
141
|
+
model=$(provider_get_current_model)
|
|
142
|
+
|
|
143
|
+
# Allow callers to pass --model <name> to override
|
|
144
|
+
if [[ "${1:-}" == "--model" ]] && [[ -n "${2:-}" ]]; then
|
|
145
|
+
model="$2"
|
|
146
|
+
shift 2
|
|
147
|
+
fi
|
|
148
|
+
|
|
111
149
|
local prompt="$1"
|
|
112
150
|
shift
|
|
113
|
-
local output
|
|
114
151
|
local exit_code
|
|
115
152
|
|
|
116
|
-
#
|
|
117
|
-
local stderr_file
|
|
153
|
+
# Stream output via tee while capturing for rate-limit check
|
|
154
|
+
local output_file stderr_file
|
|
155
|
+
output_file=$(mktemp)
|
|
118
156
|
stderr_file=$(mktemp)
|
|
119
|
-
|
|
120
|
-
exit_code
|
|
157
|
+
gemini --approval-mode=yolo --model "$model" "$prompt" "$@" < /dev/null 2>"$stderr_file" | tee "$output_file"
|
|
158
|
+
exit_code=${PIPESTATUS[0]}
|
|
121
159
|
|
|
122
160
|
# Check for rate limit (429) or quota exceeded (check stderr for error indicators)
|
|
123
161
|
if [[ $exit_code -ne 0 ]] && grep -qiE "(rate.?limit|429|quota|resource.?exhausted)" "$stderr_file" 2>/dev/null; then
|
|
124
|
-
rm -f "$stderr_file"
|
|
125
|
-
echo "[loki] Rate limit hit on $
|
|
162
|
+
rm -f "$stderr_file" "$output_file"
|
|
163
|
+
echo "[loki] Rate limit hit on $model, falling back to $PROVIDER_MODEL_FALLBACK" >&2
|
|
126
164
|
gemini --approval-mode=yolo --model "$PROVIDER_MODEL_FALLBACK" "$prompt" "$@" < /dev/null
|
|
127
165
|
else
|
|
128
|
-
|
|
129
|
-
rm -f "$stderr_file"
|
|
166
|
+
rm -f "$stderr_file" "$output_file"
|
|
130
167
|
return $exit_code
|
|
131
168
|
fi
|
|
132
169
|
}
|
|
@@ -185,8 +222,8 @@ resolve_model_for_tier() {
|
|
|
185
222
|
}
|
|
186
223
|
|
|
187
224
|
# Tier-aware invocation with rate limit fallback
|
|
188
|
-
#
|
|
189
|
-
#
|
|
225
|
+
# BUG-PROV-001 fix: uses resolve_model_for_tier to select actual model for the tier
|
|
226
|
+
# BUG-PROV-010 fix: uses tee to stream output while capturing for rate-limit check
|
|
190
227
|
# Note: < /dev/null prevents Gemini from pausing on stdin
|
|
191
228
|
provider_invoke_with_tier() {
|
|
192
229
|
local tier="$1"
|
|
@@ -198,23 +235,22 @@ provider_invoke_with_tier() {
|
|
|
198
235
|
|
|
199
236
|
echo "[loki] Using tier: $tier, model: $model" >&2
|
|
200
237
|
|
|
201
|
-
local output
|
|
202
238
|
local exit_code
|
|
203
239
|
|
|
204
|
-
#
|
|
205
|
-
local stderr_file
|
|
240
|
+
# Stream output via tee while capturing for rate-limit check
|
|
241
|
+
local output_file stderr_file
|
|
242
|
+
output_file=$(mktemp)
|
|
206
243
|
stderr_file=$(mktemp)
|
|
207
|
-
|
|
208
|
-
exit_code
|
|
244
|
+
gemini --approval-mode=yolo --model "$model" "$prompt" "$@" < /dev/null 2>"$stderr_file" | tee "$output_file"
|
|
245
|
+
exit_code=${PIPESTATUS[0]}
|
|
209
246
|
|
|
210
247
|
# Check for rate limit (429) or quota exceeded - fallback to flash
|
|
211
248
|
if [[ $exit_code -ne 0 ]] && grep -qiE "(rate.?limit|429|quota|resource.?exhausted)" "$stderr_file" 2>/dev/null; then
|
|
212
|
-
rm -f "$stderr_file"
|
|
249
|
+
rm -f "$stderr_file" "$output_file"
|
|
213
250
|
echo "[loki] Rate limit hit on $model, falling back to $PROVIDER_MODEL_FALLBACK" >&2
|
|
214
251
|
gemini --approval-mode=yolo --model "$PROVIDER_MODEL_FALLBACK" "$prompt" "$@" < /dev/null
|
|
215
252
|
else
|
|
216
|
-
|
|
217
|
-
rm -f "$stderr_file"
|
|
253
|
+
rm -f "$stderr_file" "$output_file"
|
|
218
254
|
return $exit_code
|
|
219
255
|
fi
|
|
220
256
|
}
|
package/providers/loader.sh
CHANGED
|
@@ -171,9 +171,10 @@ print_capability_matrix() {
|
|
|
171
171
|
}
|
|
172
172
|
|
|
173
173
|
# Auto-detect best available provider
|
|
174
|
+
# BUG-PROV-007 fix: includes all 5 supported providers in priority order
|
|
175
|
+
# Priority: Claude (Tier 1, full) > Cline (Tier 2, near-full) > Codex/Gemini/Aider (Tier 3, degraded)
|
|
174
176
|
auto_detect_provider() {
|
|
175
|
-
|
|
176
|
-
for p in claude codex gemini; do
|
|
177
|
+
for p in claude cline codex gemini aider; do
|
|
177
178
|
if check_provider_installed "$p"; then
|
|
178
179
|
echo "$p"
|
|
179
180
|
return 0
|
|
@@ -55,11 +55,13 @@ cd ../project-feature-auth
|
|
|
55
55
|
npm install # or pip install, cargo build, etc.
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
### Create Testing Worktree (
|
|
58
|
+
### Create Testing Worktree (based on main)
|
|
59
59
|
|
|
60
60
|
```bash
|
|
61
|
-
# Testing always runs against latest main
|
|
62
|
-
git worktree
|
|
61
|
+
# Testing always runs against latest main via a dedicated branch
|
|
62
|
+
# NOTE: git worktree cannot checkout the same branch in two worktrees,
|
|
63
|
+
# so we create a parallel-testing branch based on main
|
|
64
|
+
git worktree add ../project-testing -b parallel-testing main
|
|
63
65
|
|
|
64
66
|
# Pull latest before each test run
|
|
65
67
|
cd ../project-testing
|
|
@@ -369,16 +371,16 @@ for feature in "${features[@]}"; do
|
|
|
369
371
|
echo "Spawned: ${feature} (PID: $!)"
|
|
370
372
|
done
|
|
371
373
|
|
|
372
|
-
# Create testing worktree
|
|
374
|
+
# Create testing worktree (dedicated branch based on main)
|
|
373
375
|
testing_path="../${PROJECT_NAME}-testing"
|
|
374
376
|
if [ ! -d "$testing_path" ]; then
|
|
375
|
-
git worktree add "$testing_path" main
|
|
377
|
+
git worktree add "$testing_path" -b parallel-testing main
|
|
376
378
|
fi
|
|
377
379
|
|
|
378
|
-
# Create docs worktree
|
|
380
|
+
# Create docs worktree (dedicated branch based on main)
|
|
379
381
|
docs_path="../${PROJECT_NAME}-docs"
|
|
380
382
|
if [ ! -d "$docs_path" ]; then
|
|
381
|
-
git worktree add "$docs_path" main
|
|
383
|
+
git worktree add "$docs_path" -b parallel-docs main
|
|
382
384
|
fi
|
|
383
385
|
|
|
384
386
|
echo "Parallel streams initialized"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loki Mode State Manager
|
|
3
|
+
|
|
4
|
+
Centralized state management with file-based caching,
|
|
5
|
+
file watching, and event bus integration.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .manager import StateManager, StateChange, ManagedFile
|
|
9
|
+
|
|
10
|
+
__all__ = ["StateManager", "StateChange", "ManagedFile"]
|
package/state/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loki Mode State Manager - TypeScript Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Centralized state management with file-based caching,
|
|
5
|
+
* file watching, and event bus integration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export {
|
|
9
|
+
StateManager,
|
|
10
|
+
StateChange,
|
|
11
|
+
ManagedFile,
|
|
12
|
+
ManagedFileType,
|
|
13
|
+
StateCallback,
|
|
14
|
+
Disposable,
|
|
15
|
+
getStateDiff,
|
|
16
|
+
getStateManager,
|
|
17
|
+
resetStateManager,
|
|
18
|
+
} from "./manager";
|