loki-mode 7.57.0 → 7.58.1

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 CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Autonomous spec-driven build system with a built-in trust layer. It does not call work done until it is verified (RARV-C closure loop, 8 quality gates, completion council, verified-completion evidence gate). Triggers on "Loki Mode". Takes a spec (PRD, GitHub issue, OpenAPI doc, etc.) to deployed product with minimal human intervention. Provider-agnostic. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v7.57.0
6
+ # Loki Mode v7.58.1
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -406,4 +406,4 @@ See `CHANGELOG.md` entries [7.5.7], [7.5.8], [7.5.13] for the per-fix list and r
406
406
 
407
407
  ---
408
408
 
409
- **v7.57.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
409
+ **v7.58.1 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 7.57.0
1
+ 7.58.1
package/autonomy/loki CHANGED
@@ -2044,7 +2044,7 @@ cmd_start() {
2044
2044
  case "$effective_provider" in
2045
2045
  claude) echo " npm install -g @anthropic-ai/claude-code" ;;
2046
2046
  codex) echo " npm install -g @openai/codex" ;;
2047
- cline) echo " npm install -g @anthropic-ai/cline" ;;
2047
+ cline) echo " npm install -g cline" ;;
2048
2048
  aider) echo " pip install aider-chat" ;;
2049
2049
  *) echo " Check the provider documentation for installation." ;;
2050
2050
  esac
@@ -8049,10 +8049,37 @@ cmd_doctor() {
8049
8049
  echo ""
8050
8050
 
8051
8051
  echo -e "${CYAN}AI Providers:${NC}"
8052
- doctor_check "Claude CLI" claude optional || true
8053
- doctor_check "Codex CLI" codex optional || true
8054
- doctor_check "Cline CLI" cline optional || true
8055
- doctor_check "Aider CLI" aider optional || true
8052
+ # C1 (doctor UX): when an individual provider CLI is missing, print the
8053
+ # EXACT install command right under the WARN line so the user never has to
8054
+ # look it up. doctor_check returns 1 when the CLI is absent; we capture that
8055
+ # and emit the canonical install command for that provider. Honest: this
8056
+ # only PRINTS the command -- the offer-to-run path lives below, gated on a
8057
+ # TTY, and only when NO provider at all is installed.
8058
+ doctor_provider_install_cmd() {
8059
+ case "$1" in
8060
+ claude) echo "npm install -g @anthropic-ai/claude-code" ;;
8061
+ codex) echo "npm install -g @openai/codex" ;;
8062
+ cline) echo "npm install -g cline" ;;
8063
+ aider) echo "pip install aider-chat" ;;
8064
+ *) echo "" ;;
8065
+ esac
8066
+ }
8067
+ doctor_check_provider() {
8068
+ local _label="$1" _cmd="$2"
8069
+ if ! doctor_check "$_label" "$_cmd" optional; then
8070
+ local _install
8071
+ _install=$(doctor_provider_install_cmd "$_cmd")
8072
+ # Route the per-provider install hint to STDERR (fd 2), mirroring the
8073
+ # doctor_probe_note stderr-only pattern above. This keeps the
8074
+ # parity-captured STDOUT byte-identical to the Bun route (which emits
8075
+ # no per-provider Install line) while the user still sees the hint.
8076
+ [ -n "$_install" ] && echo -e " ${YELLOW}Install: ${_install}${NC}" >&2
8077
+ fi
8078
+ }
8079
+ doctor_check_provider "Claude CLI" claude || true
8080
+ doctor_check_provider "Codex CLI" codex || true
8081
+ doctor_check_provider "Cline CLI" cline || true
8082
+ doctor_check_provider "Aider CLI" aider || true
8056
8083
 
8057
8084
  # Check if at least one provider is installed (detect_any_provider is the
8058
8085
  # shared helper from provider-offer.sh, extracted from this exact loop).
@@ -8161,11 +8188,36 @@ cmd_doctor() {
8161
8188
  echo -e " ${YELLOW}WARN${NC} sentence-transformers - not installed (loki memory vectors setup)"
8162
8189
  warn_count=$((warn_count + 1))
8163
8190
  fi
8164
- # ChromaDB check
8165
- if curl -sf http://localhost:8100/api/v2/heartbeat >/dev/null 2>&1; then
8191
+ # C3 (doctor UX): the next checks are bounded network probes, not instant
8192
+ # local lookups. They are the slowest part of doctor (a stale/unreachable
8193
+ # host could otherwise hang on curl's default ~2min connect timeout). We
8194
+ # (a) bound every probe with --connect-timeout/--max-time so a dead host
8195
+ # fails fast, and (b) print a transient progress note to stderr on a TTY so
8196
+ # the user knows doctor is doing network work, not stuck. The note goes to
8197
+ # stderr only -- the parity-captured stdout stays byte-identical across the
8198
+ # bash and Bun routes.
8199
+ doctor_probe_note() {
8200
+ # return 0 is mandatory: [ -t 2 ] returns 1 off a TTY, and under
8201
+ # set -euo pipefail a bare call here aborts the whole doctor run on
8202
+ # CI/headless (the v7.58.1 Bun-Parity crash).
8203
+ [ -t 2 ] && printf ' %s...\r' "$1" >&2
8204
+ return 0
8205
+ }
8206
+ doctor_probe_clear() {
8207
+ # Erase the transient progress line so it does not linger above the
8208
+ # PASS/WARN result. Only on a TTY (where the note was printed).
8209
+ # return 0: same set -e safety as doctor_probe_note.
8210
+ [ -t 2 ] && printf '\r\033[K' >&2
8211
+ return 0
8212
+ }
8213
+ # ChromaDB check (bounded network probe)
8214
+ doctor_probe_note "Probing ChromaDB (port 8100)"
8215
+ if curl -sf --connect-timeout 2 --max-time 5 http://localhost:8100/api/v2/heartbeat >/dev/null 2>&1; then
8216
+ doctor_probe_clear
8166
8217
  echo -e " ${GREEN}PASS${NC} ChromaDB server (port 8100)"
8167
8218
  pass_count=$((pass_count + 1))
8168
8219
  else
8220
+ doctor_probe_clear
8169
8221
  echo -e " ${YELLOW}WARN${NC} ChromaDB - not running (docker start loki-chroma)"
8170
8222
  warn_count=$((warn_count + 1))
8171
8223
  fi
@@ -8189,10 +8241,13 @@ cmd_doctor() {
8189
8241
  # MiroFish check (optional service)
8190
8242
  local _mf_url="${LOKI_MIROFISH_URL:-http://localhost:5001}"
8191
8243
  if docker inspect loki-mirofish &>/dev/null 2>&1 || [[ -n "${LOKI_MIROFISH_URL:-}" ]]; then
8192
- if curl -sf "$_mf_url/health" >/dev/null 2>&1; then
8244
+ doctor_probe_note "Probing MiroFish ($_mf_url)"
8245
+ if curl -sf --connect-timeout 2 --max-time 5 "$_mf_url/health" >/dev/null 2>&1; then
8246
+ doctor_probe_clear
8193
8247
  echo -e " ${GREEN}PASS${NC} MiroFish server ($_mf_url)"
8194
8248
  pass_count=$((pass_count + 1))
8195
8249
  else
8250
+ doctor_probe_clear
8196
8251
  echo -e " ${YELLOW}WARN${NC} MiroFish - not running (loki start --mirofish-docker <image>)"
8197
8252
  warn_count=$((warn_count + 1))
8198
8253
  fi
@@ -9939,7 +9994,7 @@ QPRDEOF
9939
9994
  case "$_quick_provider" in
9940
9995
  claude) echo " npm install -g @anthropic-ai/claude-code" ;;
9941
9996
  codex) echo " npm install -g @openai/codex" ;;
9942
- cline) echo " npm install -g @anthropic-ai/cline" ;;
9997
+ cline) echo " npm install -g cline" ;;
9943
9998
  aider) echo " pip install aider-chat" ;;
9944
9999
  *) echo " Check the provider documentation for installation." ;;
9945
10000
  esac
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "7.57.0"
10
+ __version__ = "7.58.1"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -3865,34 +3865,40 @@ async def get_memory_summary():
3865
3865
  @app.get("/api/memory/episodes")
3866
3866
  async def list_episodes(limit: int = Query(default=50, ge=1, le=1000)):
3867
3867
  """List episodic memory entries."""
3868
- # Try SQLite backend first
3869
- storage = _get_memory_storage()
3870
- if storage is not None:
3871
- try:
3872
- ids = storage.list_episodes(limit=limit)
3873
- episodes = []
3874
- for eid in ids:
3875
- ep = storage.load_episode(eid)
3876
- if ep:
3877
- episodes.append(ep)
3878
- return episodes
3879
- except Exception:
3880
- pass
3881
-
3882
- # Fallback to JSON files -- use heapq to avoid sorting all files
3883
- import heapq
3884
- ep_dir = _get_loki_dir() / "memory" / "episodic"
3885
- episodes = []
3886
- if ep_dir.exists():
3887
- all_files = ep_dir.glob("*.json")
3888
- # nlargest by filename (timestamps sort lexicographically) avoids full sort
3889
- files = heapq.nlargest(limit, all_files, key=lambda f: f.name)
3890
- for f in files:
3868
+ # Both backends below are blocking (SQLite queries / a glob+read loop over
3869
+ # many JSON files) and only build a local list, so offload the whole read
3870
+ # off the event loop to keep status + WS heartbeat responsive.
3871
+ def _load_episodes() -> list:
3872
+ # Try SQLite backend first
3873
+ storage = _get_memory_storage()
3874
+ if storage is not None:
3891
3875
  try:
3892
- episodes.append(json.loads(f.read_text()))
3876
+ ids = storage.list_episodes(limit=limit)
3877
+ episodes = []
3878
+ for eid in ids:
3879
+ ep = storage.load_episode(eid)
3880
+ if ep:
3881
+ episodes.append(ep)
3882
+ return episodes
3893
3883
  except Exception:
3894
3884
  pass
3895
- return episodes
3885
+
3886
+ # Fallback to JSON files -- use heapq to avoid sorting all files
3887
+ import heapq
3888
+ ep_dir = _get_loki_dir() / "memory" / "episodic"
3889
+ episodes = []
3890
+ if ep_dir.exists():
3891
+ all_files = ep_dir.glob("*.json")
3892
+ # nlargest by filename (timestamps sort lexicographically) avoids full sort
3893
+ files = heapq.nlargest(limit, all_files, key=lambda f: f.name)
3894
+ for f in files:
3895
+ try:
3896
+ episodes.append(json.loads(f.read_text()))
3897
+ except Exception:
3898
+ pass
3899
+ return episodes
3900
+
3901
+ return await asyncio.to_thread(_load_episodes)
3896
3902
 
3897
3903
 
3898
3904
  @app.get("/api/memory/episodes/{episode_id}", dependencies=[Depends(auth.require_scope("read"))])
@@ -3969,30 +3975,35 @@ async def get_pattern(pattern_id: str):
3969
3975
  @app.get("/api/memory/skills")
3970
3976
  async def list_skills():
3971
3977
  """List procedural skills."""
3972
- # Try SQLite first
3973
- storage = _get_memory_storage()
3974
- if storage is not None:
3975
- try:
3976
- ids = storage.list_skills()
3977
- skills = []
3978
- for sid in ids:
3979
- s = storage.load_skill(sid)
3980
- if s:
3981
- skills.append(s)
3982
- return skills
3983
- except Exception:
3984
- pass
3985
-
3986
- # Fallback to JSON
3987
- skills_dir = _get_loki_dir() / "memory" / "skills"
3988
- skills = []
3989
- if skills_dir.exists():
3990
- for f in sorted(skills_dir.glob("*.json")):
3978
+ # Blocking SQLite query / glob+read loop; offload the whole read so the
3979
+ # event loop (status + WS heartbeat) stays responsive.
3980
+ def _load_skills() -> list:
3981
+ # Try SQLite first
3982
+ storage = _get_memory_storage()
3983
+ if storage is not None:
3991
3984
  try:
3992
- skills.append(json.loads(f.read_text()))
3985
+ ids = storage.list_skills()
3986
+ skills = []
3987
+ for sid in ids:
3988
+ s = storage.load_skill(sid)
3989
+ if s:
3990
+ skills.append(s)
3991
+ return skills
3993
3992
  except Exception:
3994
3993
  pass
3995
- return skills
3994
+
3995
+ # Fallback to JSON
3996
+ skills_dir = _get_loki_dir() / "memory" / "skills"
3997
+ skills = []
3998
+ if skills_dir.exists():
3999
+ for f in sorted(skills_dir.glob("*.json")):
4000
+ try:
4001
+ skills.append(json.loads(f.read_text()))
4002
+ except Exception:
4003
+ pass
4004
+ return skills
4005
+
4006
+ return await asyncio.to_thread(_load_skills)
3996
4007
 
3997
4008
 
3998
4009
  @app.get("/api/memory/skills/{skill_id}", dependencies=[Depends(auth.require_scope("read"))])
@@ -4347,15 +4358,16 @@ async def get_memory_file(
4347
4358
  st = target.stat()
4348
4359
  except Exception:
4349
4360
  raise HTTPException(status_code=500, detail="stat failed")
4350
- truncated = False
4361
+ truncated = st.st_size > _MEMORY_FILE_MAX_BYTES
4362
+
4363
+ def _read_memory_blob() -> bytes:
4364
+ # Up to a 2 MiB blocking read; offloaded so the single-worker event
4365
+ # loop (and /api/status + WS heartbeat) stays responsive.
4366
+ with open(target, "rb") as fh:
4367
+ return fh.read(_MEMORY_FILE_MAX_BYTES) if truncated else fh.read()
4368
+
4351
4369
  try:
4352
- if st.st_size > _MEMORY_FILE_MAX_BYTES:
4353
- with open(target, "rb") as fh:
4354
- raw = fh.read(_MEMORY_FILE_MAX_BYTES)
4355
- truncated = True
4356
- else:
4357
- with open(target, "rb") as fh:
4358
- raw = fh.read()
4370
+ raw = await asyncio.to_thread(_read_memory_blob)
4359
4371
  # Decode as UTF-8 with replacement so we never 500 on a stray byte.
4360
4372
  content = raw.decode("utf-8", errors="replace")
4361
4373
  except HTTPException:
@@ -4433,44 +4445,49 @@ async def search_memory(
4433
4445
  @app.get("/api/memory/stats")
4434
4446
  async def get_memory_stats():
4435
4447
  """Get memory system statistics (counts, size, backend info)."""
4436
- storage = _get_memory_storage()
4437
- if storage is not None:
4438
- try:
4439
- return storage.get_stats()
4440
- except Exception:
4441
- pass
4448
+ # SQLite stats query or a directory-walk over many JSON files; both block,
4449
+ # so offload off the event loop.
4450
+ def _compute_stats() -> dict:
4451
+ storage = _get_memory_storage()
4452
+ if storage is not None:
4453
+ try:
4454
+ return storage.get_stats()
4455
+ except Exception:
4456
+ pass
4442
4457
 
4443
- # Fallback: compute stats from JSON files
4444
- memory_dir = _get_loki_dir() / "memory"
4445
- ep_count = 0
4446
- ep_dir = memory_dir / "episodic"
4447
- if ep_dir.exists():
4448
- for d in ep_dir.iterdir():
4449
- if d.is_dir():
4450
- ep_count += len(list(d.glob("*.json")))
4451
- elif d.suffix == ".json":
4452
- ep_count += 1
4453
-
4454
- pat_count = 0
4455
- patterns_file = memory_dir / "semantic" / "patterns.json"
4456
- if patterns_file.exists():
4457
- try:
4458
- data = json.loads(patterns_file.read_text())
4459
- pat_count = len(data) if isinstance(data, list) else len(data.get("patterns", []))
4460
- except Exception:
4461
- pass
4458
+ # Fallback: compute stats from JSON files
4459
+ memory_dir = _get_loki_dir() / "memory"
4460
+ ep_count = 0
4461
+ ep_dir = memory_dir / "episodic"
4462
+ if ep_dir.exists():
4463
+ for d in ep_dir.iterdir():
4464
+ if d.is_dir():
4465
+ ep_count += len(list(d.glob("*.json")))
4466
+ elif d.suffix == ".json":
4467
+ ep_count += 1
4468
+
4469
+ pat_count = 0
4470
+ patterns_file = memory_dir / "semantic" / "patterns.json"
4471
+ if patterns_file.exists():
4472
+ try:
4473
+ data = json.loads(patterns_file.read_text())
4474
+ pat_count = len(data) if isinstance(data, list) else len(data.get("patterns", []))
4475
+ except Exception:
4476
+ pass
4462
4477
 
4463
- skill_count = 0
4464
- skills_dir = memory_dir / "skills"
4465
- if skills_dir.exists():
4466
- skill_count = len(list(skills_dir.glob("*.json")))
4478
+ skill_count = 0
4479
+ skills_dir = memory_dir / "skills"
4480
+ if skills_dir.exists():
4481
+ skill_count = len(list(skills_dir.glob("*.json")))
4467
4482
 
4468
- return {
4469
- "backend": "json",
4470
- "episode_count": ep_count,
4471
- "pattern_count": pat_count,
4472
- "skill_count": skill_count,
4473
- }
4483
+ return {
4484
+ "backend": "json",
4485
+ "episode_count": ep_count,
4486
+ "pattern_count": pat_count,
4487
+ "skill_count": skill_count,
4488
+ }
4489
+
4490
+ return await asyncio.to_thread(_compute_stats)
4474
4491
 
4475
4492
 
4476
4493
  # Learning/metrics endpoints
@@ -4516,10 +4533,10 @@ async def get_learning_metrics(
4516
4533
  source: Optional[str] = None,
4517
4534
  ):
4518
4535
  """Get learning metrics from events, metrics files, and learning signals."""
4519
- events = _read_events(timeRange)
4536
+ events = await asyncio.to_thread(_read_events, timeRange)
4520
4537
 
4521
4538
  # Also read from learning signals directory
4522
- all_signals = _read_learning_signals(limit=10000)
4539
+ all_signals = await asyncio.to_thread(_read_learning_signals, limit=10000)
4523
4540
 
4524
4541
  # Filter by type and source
4525
4542
  if signalType:
@@ -4596,7 +4613,7 @@ async def get_learning_trends(
4596
4613
  source: Optional[str] = None,
4597
4614
  ):
4598
4615
  """Get learning trend data."""
4599
- events = _read_events(timeRange)
4616
+ events = await asyncio.to_thread(_read_events, timeRange)
4600
4617
  # Group by hour for trend data
4601
4618
  by_hour: dict = {}
4602
4619
  for e in events:
@@ -4618,14 +4635,14 @@ async def get_learning_signals(
4618
4635
  offset: int = Query(default=0, ge=0),
4619
4636
  ):
4620
4637
  """Get raw learning signals from both events.jsonl and learning signals directory."""
4621
- events = _read_events(timeRange)
4638
+ events = await asyncio.to_thread(_read_events, timeRange)
4622
4639
  if signalType:
4623
4640
  events = [e for e in events if e.get("type") == signalType]
4624
4641
  if source:
4625
4642
  events = [e for e in events if e.get("data", {}).get("source") == source]
4626
4643
 
4627
4644
  # Also read from learning signals directory
4628
- file_signals = _read_learning_signals(signal_type=signalType, limit=10000)
4645
+ file_signals = await asyncio.to_thread(_read_learning_signals, signal_type=signalType, limit=10000)
4629
4646
  if source:
4630
4647
  file_signals = [s for s in file_signals if s.get("source") == source]
4631
4648
 
@@ -4649,10 +4666,10 @@ async def get_learning_aggregation():
4649
4666
  pass
4650
4667
 
4651
4668
  # Supplement with live data from learning signals directory
4652
- success_signals = _read_learning_signals(signal_type="success_pattern", limit=500)
4653
- tool_signals = _read_learning_signals(signal_type="tool_efficiency", limit=500)
4654
- error_signals = _read_learning_signals(signal_type="error_pattern", limit=500)
4655
- pref_signals = _read_learning_signals(signal_type="user_preference", limit=500)
4669
+ success_signals = await asyncio.to_thread(_read_learning_signals, signal_type="success_pattern", limit=500)
4670
+ tool_signals = await asyncio.to_thread(_read_learning_signals, signal_type="tool_efficiency", limit=500)
4671
+ error_signals = await asyncio.to_thread(_read_learning_signals, signal_type="error_pattern", limit=500)
4672
+ pref_signals = await asyncio.to_thread(_read_learning_signals, signal_type="user_preference", limit=500)
4656
4673
 
4657
4674
  # Merge success patterns from signals if aggregation file had none
4658
4675
  if not result.get("success_patterns") and success_signals:
@@ -4726,6 +4743,14 @@ async def trigger_aggregation():
4726
4743
  if not _read_limiter.check("learning_aggregate"):
4727
4744
  raise HTTPException(status_code=429, detail="Rate limit exceeded")
4728
4745
 
4746
+ # Reads up to 10 MB of events.jsonl, parses every line, then writes the
4747
+ # aggregation.json metrics file. All blocking, all on local state +
4748
+ # filesystem (no shared in-memory state), so offload the whole computation
4749
+ # to a thread to keep the event loop (status + WS heartbeat) responsive.
4750
+ return await asyncio.to_thread(_compute_learning_aggregation)
4751
+
4752
+
4753
+ def _compute_learning_aggregation() -> dict:
4729
4754
  events_file = _get_loki_dir() / "events.jsonl"
4730
4755
  preferences: dict = {}
4731
4756
  error_patterns: dict = {}
@@ -4821,10 +4846,10 @@ async def trigger_aggregation():
4821
4846
  @app.get("/api/learning/preferences", dependencies=[Depends(auth.require_scope("read"))])
4822
4847
  async def get_learning_preferences(limit: int = Query(default=50, ge=1, le=1000)):
4823
4848
  """Get aggregated user preferences from events and learning signals directory."""
4824
- events = _read_events("30d")
4849
+ events = await asyncio.to_thread(_read_events, "30d")
4825
4850
  prefs = [e for e in events if e.get("type") == "user_preference"]
4826
4851
  # Also read from learning signals directory
4827
- file_prefs = _read_learning_signals(signal_type="user_preference", limit=limit)
4852
+ file_prefs = await asyncio.to_thread(_read_learning_signals, signal_type="user_preference", limit=limit)
4828
4853
  combined = prefs + file_prefs
4829
4854
  combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
4830
4855
  return combined[:limit]
@@ -4833,10 +4858,10 @@ async def get_learning_preferences(limit: int = Query(default=50, ge=1, le=1000)
4833
4858
  @app.get("/api/learning/errors", dependencies=[Depends(auth.require_scope("read"))])
4834
4859
  async def get_learning_errors(limit: int = Query(default=50, ge=1, le=1000)):
4835
4860
  """Get aggregated error patterns from events and learning signals directory."""
4836
- events = _read_events("30d")
4861
+ events = await asyncio.to_thread(_read_events, "30d")
4837
4862
  errors = [e for e in events if e.get("type") == "error_pattern"]
4838
4863
  # Also read from learning signals directory
4839
- file_errors = _read_learning_signals(signal_type="error_pattern", limit=limit)
4864
+ file_errors = await asyncio.to_thread(_read_learning_signals, signal_type="error_pattern", limit=limit)
4840
4865
  combined = errors + file_errors
4841
4866
  combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
4842
4867
  return combined[:limit]
@@ -4845,10 +4870,10 @@ async def get_learning_errors(limit: int = Query(default=50, ge=1, le=1000)):
4845
4870
  @app.get("/api/learning/success", dependencies=[Depends(auth.require_scope("read"))])
4846
4871
  async def get_learning_success(limit: int = Query(default=50, ge=1, le=1000)):
4847
4872
  """Get aggregated success patterns from events and learning signals directory."""
4848
- events = _read_events("30d")
4873
+ events = await asyncio.to_thread(_read_events, "30d")
4849
4874
  successes = [e for e in events if e.get("type") == "success_pattern"]
4850
4875
  # Also read from learning signals directory
4851
- file_successes = _read_learning_signals(signal_type="success_pattern", limit=limit)
4876
+ file_successes = await asyncio.to_thread(_read_learning_signals, signal_type="success_pattern", limit=limit)
4852
4877
  combined = successes + file_successes
4853
4878
  combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
4854
4879
  return combined[:limit]
@@ -4857,10 +4882,10 @@ async def get_learning_success(limit: int = Query(default=50, ge=1, le=1000)):
4857
4882
  @app.get("/api/learning/tools", dependencies=[Depends(auth.require_scope("read"))])
4858
4883
  async def get_tool_efficiency(limit: int = Query(default=50, ge=1, le=1000)):
4859
4884
  """Get tool efficiency rankings from events and learning signals directory."""
4860
- events = _read_events("30d")
4885
+ events = await asyncio.to_thread(_read_events, "30d")
4861
4886
  tools = [e for e in events if e.get("type") == "tool_efficiency"]
4862
4887
  # Also read from learning signals directory
4863
- file_tools = _read_learning_signals(signal_type="tool_efficiency", limit=limit)
4888
+ file_tools = await asyncio.to_thread(_read_learning_signals, signal_type="tool_efficiency", limit=limit)
4864
4889
  combined = tools + file_tools
4865
4890
  combined.sort(key=lambda s: s.get("timestamp", ""), reverse=True)
4866
4891
  return combined[:limit]
@@ -5204,7 +5229,16 @@ def _calculate_model_cost(model: str, input_tokens: int, output_tokens: int) ->
5204
5229
 
5205
5230
  @app.get("/api/cost")
5206
5231
  async def get_cost():
5207
- """Get cost visibility data from .loki/metrics/efficiency/ and budget.json."""
5232
+ """Get cost visibility data from .loki/metrics/efficiency/ and budget.json.
5233
+
5234
+ The computation globs + reads every per-iteration efficiency JSON file
5235
+ (a blocking multi-file read loop building only local aggregates), so it is
5236
+ offloaded to a thread to keep the event loop responsive.
5237
+ """
5238
+ return await asyncio.to_thread(_compute_cost_snapshot)
5239
+
5240
+
5241
+ def _compute_cost_snapshot() -> dict:
5208
5242
  loki_dir = _get_loki_dir()
5209
5243
  efficiency_dir = loki_dir / "metrics" / "efficiency"
5210
5244
  budget_file = loki_dir / "metrics" / "budget.json"
@@ -5471,7 +5505,15 @@ async def get_cost_timeline():
5471
5505
  classifies into ok/warn/exceeded so the UI can warn at 80% before the cap.
5472
5506
  Cost is never fabricated: when nothing was recorded, cost_recorded is False
5473
5507
  and totals are honestly null rather than a misleading $0.00.
5508
+
5509
+ Globs + reads every efficiency iteration file and every proof.json (a
5510
+ blocking multi-file read loop building only local state), so it is offloaded
5511
+ to a thread to keep the event loop responsive.
5474
5512
  """
5513
+ return await asyncio.to_thread(_compute_cost_timeline)
5514
+
5515
+
5516
+ def _compute_cost_timeline() -> dict:
5475
5517
  loki_dir = _get_loki_dir()
5476
5518
  efficiency_dir = loki_dir / "metrics" / "efficiency"
5477
5519
 
@@ -5730,51 +5772,59 @@ async def get_council_state():
5730
5772
 
5731
5773
  @app.get("/api/council/verdicts")
5732
5774
  async def get_council_verdicts(limit: int = Query(default=20, ge=1, le=1000)):
5733
- """Get council vote history (decision log)."""
5734
- state_file = _get_loki_dir() / "council" / "state.json"
5735
- verdicts = []
5736
- if state_file.exists():
5737
- try:
5738
- state = json.loads(state_file.read_text())
5739
- verdicts = state.get("verdicts", [])
5740
- except Exception:
5741
- pass
5775
+ """Get council vote history (decision log).
5742
5776
 
5743
- # Also read individual vote files for detail
5744
- votes_dir = _get_loki_dir() / "council" / "votes"
5745
- detailed_verdicts = []
5746
- if votes_dir.exists():
5747
- for vote_dir in sorted(votes_dir.iterdir(), reverse=True):
5748
- if vote_dir.is_dir():
5749
- verdict_detail = {"iteration": vote_dir.name}
5750
- # Read evidence
5751
- evidence_file = vote_dir / "evidence.md"
5752
- if evidence_file.exists():
5753
- try:
5754
- verdict_detail["evidence_preview"] = evidence_file.read_text()[:500]
5755
- except Exception:
5756
- verdict_detail["evidence_preview"] = ""
5757
- # Read member votes
5758
- members = []
5759
- for member_file in sorted(vote_dir.glob("member-*.txt")):
5760
- try:
5761
- content = member_file.read_text().strip()
5762
- members.append({
5763
- "member": member_file.stem,
5764
- "content": content
5765
- })
5766
- except Exception:
5767
- pass
5768
- verdict_detail["members"] = members
5769
- # Read contrarian
5770
- contrarian_file = vote_dir / "contrarian.txt"
5771
- if contrarian_file.exists():
5772
- verdict_detail["contrarian"] = contrarian_file.read_text().strip()
5773
- detailed_verdicts.append(verdict_detail)
5774
- if len(detailed_verdicts) >= limit:
5775
- break
5777
+ Walks every vote directory and reads its evidence/member/contrarian files
5778
+ (a blocking multi-file read loop building only local state), so it is
5779
+ offloaded to a thread to keep the event loop responsive.
5780
+ """
5781
+ def _collect_verdicts() -> dict:
5782
+ state_file = _get_loki_dir() / "council" / "state.json"
5783
+ verdicts = []
5784
+ if state_file.exists():
5785
+ try:
5786
+ state = json.loads(state_file.read_text())
5787
+ verdicts = state.get("verdicts", [])
5788
+ except Exception:
5789
+ pass
5776
5790
 
5777
- return {"verdicts": verdicts, "details": detailed_verdicts}
5791
+ # Also read individual vote files for detail
5792
+ votes_dir = _get_loki_dir() / "council" / "votes"
5793
+ detailed_verdicts = []
5794
+ if votes_dir.exists():
5795
+ for vote_dir in sorted(votes_dir.iterdir(), reverse=True):
5796
+ if vote_dir.is_dir():
5797
+ verdict_detail = {"iteration": vote_dir.name}
5798
+ # Read evidence
5799
+ evidence_file = vote_dir / "evidence.md"
5800
+ if evidence_file.exists():
5801
+ try:
5802
+ verdict_detail["evidence_preview"] = evidence_file.read_text()[:500]
5803
+ except Exception:
5804
+ verdict_detail["evidence_preview"] = ""
5805
+ # Read member votes
5806
+ members = []
5807
+ for member_file in sorted(vote_dir.glob("member-*.txt")):
5808
+ try:
5809
+ content = member_file.read_text().strip()
5810
+ members.append({
5811
+ "member": member_file.stem,
5812
+ "content": content
5813
+ })
5814
+ except Exception:
5815
+ pass
5816
+ verdict_detail["members"] = members
5817
+ # Read contrarian
5818
+ contrarian_file = vote_dir / "contrarian.txt"
5819
+ if contrarian_file.exists():
5820
+ verdict_detail["contrarian"] = contrarian_file.read_text().strip()
5821
+ detailed_verdicts.append(verdict_detail)
5822
+ if len(detailed_verdicts) >= limit:
5823
+ break
5824
+
5825
+ return {"verdicts": verdicts, "details": detailed_verdicts}
5826
+
5827
+ return await asyncio.to_thread(_collect_verdicts)
5778
5828
 
5779
5829
 
5780
5830
  @app.get("/api/council/convergence")
@@ -5849,35 +5899,41 @@ async def get_council_transcripts(
5849
5899
  if not transcripts_dir.exists():
5850
5900
  response: dict = {"transcripts": [], "total": 0, "latest_id": None}
5851
5901
  if type_prefix:
5852
- response["hook_events"] = _read_events(type_prefix=type_prefix)
5902
+ response["hook_events"] = await asyncio.to_thread(_read_events, type_prefix=type_prefix)
5853
5903
  return response
5854
5904
 
5855
- records = []
5856
- for f in sorted(transcripts_dir.glob("iter-*.json"), reverse=True):
5857
- try:
5858
- rec = json.loads(f.read_text())
5859
- except Exception:
5860
- logger.warning("Skipping corrupt council transcript file: %s", f.name)
5861
- continue
5862
- if not isinstance(rec, dict):
5863
- logger.warning("Skipping non-object council transcript file: %s", f.name)
5864
- continue
5865
- if not isinstance(rec.get("iteration_id"), str):
5866
- logger.warning("Skipping transcript missing iteration_id field: %s", f.name)
5867
- continue
5868
- if since_dt is not None:
5869
- ts_str = rec.get("timestamp", "")
5905
+ def _collect_transcript_records() -> list:
5906
+ # Globs + reads up to `limit` (<=200) JSON transcript files; a blocking
5907
+ # multi-file read loop offloaded so the event loop stays responsive.
5908
+ out: list = []
5909
+ for f in sorted(transcripts_dir.glob("iter-*.json"), reverse=True):
5870
5910
  try:
5871
- ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
5872
- except (ValueError, AttributeError):
5911
+ rec = json.loads(f.read_text())
5912
+ except Exception:
5913
+ logger.warning("Skipping corrupt council transcript file: %s", f.name)
5873
5914
  continue
5874
- if ts <= since_dt:
5915
+ if not isinstance(rec, dict):
5916
+ logger.warning("Skipping non-object council transcript file: %s", f.name)
5875
5917
  continue
5876
- if iter_min is not None and rec.get("iteration", 0) < iter_min:
5877
- continue
5878
- records.append(rec)
5879
- if len(records) >= limit:
5880
- break
5918
+ if not isinstance(rec.get("iteration_id"), str):
5919
+ logger.warning("Skipping transcript missing iteration_id field: %s", f.name)
5920
+ continue
5921
+ if since_dt is not None:
5922
+ ts_str = rec.get("timestamp", "")
5923
+ try:
5924
+ ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
5925
+ except (ValueError, AttributeError):
5926
+ continue
5927
+ if ts <= since_dt:
5928
+ continue
5929
+ if iter_min is not None and rec.get("iteration", 0) < iter_min:
5930
+ continue
5931
+ out.append(rec)
5932
+ if len(out) >= limit:
5933
+ break
5934
+ return out
5935
+
5936
+ records = await asyncio.to_thread(_collect_transcript_records)
5881
5937
 
5882
5938
  response = {
5883
5939
  "transcripts": records,
@@ -5886,7 +5942,7 @@ async def get_council_transcripts(
5886
5942
  }
5887
5943
  # v7.5.22 Phase D: opt-in hook-event passthrough via _read_events filter.
5888
5944
  if type_prefix:
5889
- response["hook_events"] = _read_events(type_prefix=type_prefix)
5945
+ response["hook_events"] = await asyncio.to_thread(_read_events, type_prefix=type_prefix)
5890
5946
  return response
5891
5947
 
5892
5948
 
@@ -6107,7 +6163,16 @@ def _sanitize_checkpoint_id(checkpoint_id: str) -> str:
6107
6163
 
6108
6164
  @app.get("/api/checkpoints")
6109
6165
  async def list_checkpoints(limit: int = Query(default=20, ge=1, le=200)):
6110
- """List recent checkpoints from index.jsonl, enriched with metadata when available."""
6166
+ """List recent checkpoints from index.jsonl, enriched with metadata when available.
6167
+
6168
+ Reads index.jsonl plus a metadata.json and a recursive rglob() file count
6169
+ per checkpoint (a blocking multi-file walk building only local state), so
6170
+ it is offloaded to a thread to keep the event loop responsive.
6171
+ """
6172
+ return await asyncio.to_thread(_collect_checkpoints, limit)
6173
+
6174
+
6175
+ def _collect_checkpoints(limit: int) -> list:
6111
6176
  loki_dir = _get_loki_dir()
6112
6177
  index_file = loki_dir / "state" / "checkpoints" / "index.jsonl"
6113
6178
  checkpoints_dir = loki_dir / "state" / "checkpoints"
@@ -6558,17 +6623,18 @@ async def get_logs(lines: int = 100, token: Optional[dict] = Depends(auth.get_cu
6558
6623
  file_mtime = datetime.fromtimestamp(log_file.stat().st_mtime, tz=timezone.utc).strftime(
6559
6624
  "%Y-%m-%dT%H:%M:%S"
6560
6625
  )
6561
- # Read only the tail to avoid loading huge files into memory
6562
- tail_lines = []
6563
- try:
6564
- with open(log_file, "rb") as lf:
6565
- # Seek from end to find enough lines
6626
+ # Read only the tail to avoid loading huge files into memory.
6627
+ # The up-to-1MB blocking read is offloaded to a thread so the
6628
+ # single-worker event loop (status + WS heartbeat) stays free.
6629
+ def _read_log_tail(lf_path=log_file, n=lines) -> list[str]:
6630
+ with open(lf_path, "rb") as lf:
6566
6631
  lf.seek(0, 2)
6567
6632
  file_size = lf.tell()
6568
- # Read at most 1MB from the end (plenty for any reasonable lines count)
6569
6633
  read_size = min(file_size, 1024 * 1024)
6570
6634
  lf.seek(max(0, file_size - read_size))
6571
- tail_lines = lf.read().decode("utf-8", errors="replace").strip().split("\n")[-lines:]
6635
+ return lf.read().decode("utf-8", errors="replace").strip().split("\n")[-n:]
6636
+ try:
6637
+ tail_lines = await asyncio.to_thread(_read_log_tail)
6572
6638
  except (OSError, UnicodeDecodeError):
6573
6639
  tail_lines = []
6574
6640
  for raw_line in tail_lines:
@@ -8035,8 +8101,12 @@ async def get_app_runner_logs(lines: int = Query(default=100, ge=1, le=1000)):
8035
8101
  return {"lines": []}
8036
8102
  try:
8037
8103
  redact = _get_log_redactor()
8038
- all_lines = _safe_read_text(log_file).splitlines()
8039
- return {"lines": [redact(ln) for ln in all_lines[-lines:]], "redacted": True}
8104
+ # Reading + redacting the app log is blocking (the log can be large);
8105
+ # offload so the event loop (status + WS heartbeat) is not stalled.
8106
+ def _read_redacted(p=log_file, n=lines):
8107
+ return [redact(ln) for ln in _safe_read_text(p).splitlines()[-n:]]
8108
+ out_lines = await asyncio.to_thread(_read_redacted)
8109
+ return {"lines": out_lines, "redacted": True}
8040
8110
  except OSError:
8041
8111
  return {"lines": []}
8042
8112
 
@@ -8071,8 +8141,10 @@ async def get_app_runner_errors(lines: int = Query(default=50, ge=1, le=500)):
8071
8141
  if log_file.exists():
8072
8142
  try:
8073
8143
  redact = _get_log_redactor()
8074
- all_lines = _safe_read_text(log_file).splitlines()
8075
- out_lines = [redact(ln) for ln in all_lines[-lines:]]
8144
+ # Offload the blocking log read + redaction off the event loop.
8145
+ def _read_redacted(p=log_file, n=lines):
8146
+ return [redact(ln) for ln in _safe_read_text(p).splitlines()[-n:]]
8147
+ out_lines = await asyncio.to_thread(_read_redacted)
8076
8148
  except OSError:
8077
8149
  out_lines = []
8078
8150
 
@@ -8790,7 +8862,11 @@ async def get_managed_events(
8790
8862
  """
8791
8863
  try:
8792
8864
  path = _managed_events_path()
8793
- records = _tail_ndjson(path, limit=limit, since_iso=since, event_type=type)
8865
+ # Tails an ndjson file (rotated at 10MB) via a blocking readlines();
8866
+ # offload so the event loop stays responsive.
8867
+ records = await asyncio.to_thread(
8868
+ _tail_ndjson, path, limit, since, type
8869
+ )
8794
8870
  return {
8795
8871
  "events": records,
8796
8872
  "count": len(records),
@@ -8813,11 +8889,13 @@ async def get_managed_status():
8813
8889
  snapshot = _managed_flags_snapshot()
8814
8890
  # last_fallback_ts is best-effort from the local events file.
8815
8891
  try:
8816
- events = _tail_ndjson(
8892
+ # Blocking ndjson tail read; offload off the event loop.
8893
+ events = await asyncio.to_thread(
8894
+ _tail_ndjson,
8817
8895
  _managed_events_path(),
8818
- limit=500,
8819
- since_iso=None,
8820
- event_type="managed_agents_fallback",
8896
+ 500,
8897
+ None,
8898
+ "managed_agents_fallback",
8821
8899
  )
8822
8900
  snapshot["last_fallback_ts"] = _last_fallback_ts(events)
8823
8901
  except Exception:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Loki Mode is a spec-driven autonomous builder with a built-in trust layer that takes any spec to a deployed product and verifies completion with evidence (quality gates plus a completion council), not just a "done" claim. Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v7.57.0
5
+ **Version:** v7.58.1
6
6
 
7
7
  ---
8
8
 
@@ -395,7 +395,7 @@ provider works inside the container. Provide auth with your Anthropic API key:
395
395
  # Run Loki Mode in Docker (Claude provider, API-key auth)
396
396
  docker run --rm -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
397
397
  -v $(pwd):/workspace -w /workspace \
398
- asklokesh/loki-mode:7.57.0 start ./my-spec.md
398
+ asklokesh/loki-mode:7.58.1 start ./my-spec.md
399
399
  ```
400
400
 
401
401
  ##### docker compose + .env (no host install)
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var D1={};h(D1,{lokiDir:()=>P,homeLokiDir:()=>n$,findRepoRootForVersion:()=>o$,REPO_ROOT:()=>g});import{resolve as n,dirname as d$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as P$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=d$($);if(Z===$)break;$=Z}return n(S1,"..","..","..")}function o$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=d$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function n$(){return n($Q(),".loki")}var S1,g;var b=L(()=>{S1=d$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function j$(){if($$!==null)return $$;let $="7.57.0";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=XQ(KQ(import.meta.url)),Z=o$(Q);$$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var a$=L(()=>{b()});var b1={};h(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>WQ,commandExists:()=>f,ShellError:()=>s$});async function k($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new s$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function WQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var s$;var d=L(()=>{s$=class s$ extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function a($){return JQ?"":$}var JQ,T,S,_,wZ,I,R,y,V;var c=L(()=>{JQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),_=a("\x1B[1;33m"),wZ=a("\x1B[0;34m"),I=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),V=a("\x1B[0m")});import{existsSync as wQ}from"fs";async function Q$(){if(G$!==void 0)return G$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return G$=$,$;let Q=await f("python3.12");if(Q)return G$=Q,Q;let Z=await f("python3");return G$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var G$;var q$=L(()=>{d()});var e1={};h(e1,{runStatus:()=>uQ});import{existsSync as v,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as C,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*k$/Q);if(X>k$)X=k$;let q=k$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
2
+ var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var h=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var L=($,Q)=>()=>($&&(Q=$($=0)),Q);var K$=import.meta.require;var D1={};h(D1,{lokiDir:()=>P,homeLokiDir:()=>n$,findRepoRootForVersion:()=>o$,REPO_ROOT:()=>g});import{resolve as n,dirname as d$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as P$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(P$(n($,"VERSION"))&&P$(n($,"autonomy/run.sh")))return $;let Z=d$($);if(Z===$)break;$=Z}return n(S1,"..","..","..")}function o$($){let Q=$;for(let Z=0;Z<6;Z++){if(P$(n(Q,"VERSION"))&&P$(n(Q,"autonomy/run.sh")))return Q;let z=d$(Q);if(z===Q)break;Q=z}return n($,"..","..","..")}function P(){return process.env.LOKI_DIR??n(process.cwd(),".loki")}function n$(){return n($Q(),".loki")}var S1,g;var b=L(()=>{S1=d$(e6(import.meta.url));g=QQ()});import{readFileSync as ZQ}from"fs";import{resolve as zQ,dirname as XQ}from"path";import{fileURLToPath as KQ}from"url";function j$(){if($$!==null)return $$;let $="7.58.1";if(typeof $==="string"&&$.length>0)return $$=$,$$;try{let Q=XQ(KQ(import.meta.url)),Z=o$(Q);$$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{$$="unknown"}return $$}var $$=null;var a$=L(()=>{b()});var b1={};h(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>WQ,commandExists:()=>f,ShellError:()=>s$});async function k($,Q={}){let Z=Bun.spawn({cmd:[...$],stdout:"pipe",stderr:"pipe",env:Q.env?{...process.env,...Q.env}:process.env,cwd:Q.cwd}),z,X;if(Q.timeoutMs&&Q.timeoutMs>0)z=setTimeout(()=>{try{Z.kill("SIGTERM")}catch{}X=setTimeout(()=>{try{Z.kill("SIGKILL")}catch{}},2000)},Q.timeoutMs);try{let[q,K,W]=await Promise.all([new Response(Z.stdout).text(),new Response(Z.stderr).text(),Z.exited]);return{stdout:q,stderr:K,exitCode:W}}finally{if(z)clearTimeout(z);if(X)clearTimeout(X)}}async function qQ($,Q={}){let Z=await k($,Q);if(Z.exitCode!==0)throw new s$(`command failed (${Z.exitCode}): ${$.join(" ")}`,Z.exitCode,Z.stdout,Z.stderr);return Z}async function f($){let Q=VQ($),Z=await k(["sh","-c",`command -v ${Q}`],{timeoutMs:5000});if(Z.exitCode===0)return Z.stdout.trim()||null;return null}function VQ($){if(!/^[A-Za-z0-9._/-]+$/.test($))throw Error(`refused to shell-escape suspect token: ${$}`);return $}async function WQ($,Q="--version"){if(!await f($))return null;let z=await k([$,Q],{timeoutMs:5000});if(z.exitCode!==0)return null;return((z.stdout||z.stderr).split(/\r?\n/)[0]?.trim()??"")||null}var s$;var d=L(()=>{s$=class s$ extends Error{message;exitCode;stdout;stderr;constructor($,Q,Z,z){super($);this.message=$;this.exitCode=Q;this.stdout=Z;this.stderr=z;this.name="ShellError"}}});function a($){return JQ?"":$}var JQ,T,S,_,wZ,I,R,y,V;var c=L(()=>{JQ=(process.env.NO_COLOR??"").length>0;T=a("\x1B[0;31m"),S=a("\x1B[0;32m"),_=a("\x1B[1;33m"),wZ=a("\x1B[0;34m"),I=a("\x1B[0;36m"),R=a("\x1B[1m"),y=a("\x1B[2m"),V=a("\x1B[0m")});import{existsSync as wQ}from"fs";async function Q$(){if(G$!==void 0)return G$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return G$=$,$;let Q=await f("python3.12");if(Q)return G$=Q,Q;let Z=await f("python3");return G$=Z,Z}async function Z$($,Q={}){let Z=await Q$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var G$;var q$=L(()=>{d()});var e1={};h(e1,{runStatus:()=>uQ});import{existsSync as v,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as C,basename as DQ}from"path";import{homedir as CQ}from"os";function n1($){let Q=Math.trunc($);if(Q>=1e6)return`${(Math.trunc(Q/1e6*10)/10).toFixed(1)}M`;if(Q>=1000)return`${(Math.trunc(Q/1000*10)/10).toFixed(1)}K`;return String(Q)}function a1($,Q,Z){if(Q===0)return null;let z=Math.trunc($*100/Q),X=Math.trunc($*k$/Q);if(X>k$)X=k$;let q=k$-X,K=S;if(z>=80)K=T;else if(z>=50)K=_;let W="=".repeat(Math.max(0,X))+" ".repeat(Math.max(0,q)),J=n1($),U=n1(Q);return` ${R}${Z}${V} ${K}[${W}]${V} ${z}% (${J} / ${U})`}async function hQ(){if(await f("jq"))return!0;return process.stdout.write(`${T}Error: jq is required but not installed.${V}
3
3
  `),process.stdout.write(`Install with:
4
4
  `),process.stdout.write(` brew install jq (macOS)
5
5
  `),process.stdout.write(` apt install jq (Debian/Ubuntu)
@@ -790,4 +790,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
790
790
  `),2}default:return process.stderr.write(`Unknown command: ${Q}
791
791
  `),process.stderr.write(s6),2}}l1();process.on("SIGINT",()=>process.exit(130));process.on("SIGTERM",()=>process.exit(143));var KZ=await XZ(Bun.argv.slice(2));process.exit(KZ);
792
792
 
793
- //# debugId=6D8496B2540606D064756E2164756E21
793
+ //# debugId=D9E2CF477FD688DE64756E2164756E21
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '7.57.0'
60
+ __version__ = '7.58.1'
@@ -221,10 +221,17 @@ class ConsolidationPipeline:
221
221
  if new_pattern:
222
222
  # Try to merge with existing
223
223
  merged = False
224
- for existing in existing_patterns:
224
+ for idx, existing in enumerate(existing_patterns):
225
225
  if self._patterns_similar(new_pattern, existing):
226
226
  merged_pattern = self.merge_with_existing(new_pattern, [existing])
227
227
  self.storage.update_pattern(merged_pattern)
228
+ # Refresh the in-memory copy so a later new pattern in
229
+ # this same run that also merges into this existing
230
+ # pattern builds on the just-merged state. Without this,
231
+ # the second merge reads the stale pre-merge base and its
232
+ # update_pattern() overwrites storage, silently dropping
233
+ # the first merge's conditions/source_episodes/confidence.
234
+ existing_patterns[idx] = merged_pattern
228
235
  result.patterns_merged += 1
229
236
  merged = True
230
237
  break
@@ -240,11 +247,16 @@ class ConsolidationPipeline:
240
247
  for anti_pattern in anti_patterns:
241
248
  # Check if similar anti-pattern already exists
242
249
  merged = False
243
- for existing in existing_patterns:
250
+ for idx, existing in enumerate(existing_patterns):
244
251
  if (existing.incorrect_approach and
245
252
  self._patterns_similar(anti_pattern, existing, threshold=0.6)):
246
253
  merged_pattern = self.merge_with_existing(anti_pattern, [existing])
247
254
  self.storage.update_pattern(merged_pattern)
255
+ # Refresh in-memory copy (same data-loss guard as the cluster
256
+ # merge loop above): a later anti-pattern merging into this same
257
+ # existing pattern must build on the just-merged state, not the
258
+ # stale pre-merge base.
259
+ existing_patterns[idx] = merged_pattern
248
260
  result.patterns_merged += 1
249
261
  merged = True
250
262
  break
@@ -855,6 +855,8 @@ class MemoryRetrieval:
855
855
  # Filter semantic patterns by last_used
856
856
  patterns_data = self.storage.read_json("semantic/patterns.json") or {}
857
857
  for pattern in patterns_data.get("patterns", []):
858
+ if not isinstance(pattern, dict):
859
+ continue
858
860
  last_used = pattern.get("last_used")
859
861
  if last_used:
860
862
  try:
@@ -1483,6 +1485,8 @@ class MemoryRetrieval:
1483
1485
  patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1484
1486
 
1485
1487
  for pattern in patterns_data.get("patterns", []):
1488
+ if not isinstance(pattern, dict):
1489
+ continue
1486
1490
  pattern_text = pattern.get("pattern", "").lower()
1487
1491
  category = pattern.get("category", "").lower()
1488
1492
  correct = pattern.get("correct_approach", "").lower()
@@ -1566,6 +1570,8 @@ class MemoryRetrieval:
1566
1570
  # what_fails / why / prevention scoring shape.
1567
1571
  patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1568
1572
  for pat in patterns_data.get("patterns", []):
1573
+ if not isinstance(pat, dict):
1574
+ continue
1569
1575
  if pat.get("category") != "anti-pattern":
1570
1576
  continue
1571
1577
  what_fails = (pat.get("incorrect_approach", "")
@@ -1633,6 +1639,8 @@ class MemoryRetrieval:
1633
1639
  patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1634
1640
 
1635
1641
  for pattern in patterns_data.get("patterns", []):
1642
+ if not isinstance(pattern, dict):
1643
+ continue
1636
1644
  # Create text for embedding
1637
1645
  text = f"{pattern.get('pattern', '')} {pattern.get('category', '')} {pattern.get('correct_approach', '')}"
1638
1646
 
@@ -1690,6 +1698,8 @@ class MemoryRetrieval:
1690
1698
  # too so embedding-based retrieval sees consolidated anti-patterns.
1691
1699
  patterns_data = self.storage.read_json("semantic/patterns.json") or {}
1692
1700
  for pat in patterns_data.get("patterns", []):
1701
+ if not isinstance(pat, dict):
1702
+ continue
1693
1703
  if pat.get("category") != "anti-pattern":
1694
1704
  continue
1695
1705
  what_fails = pat.get("incorrect_approach", "") or pat.get("pattern", "")
package/memory/storage.py CHANGED
@@ -626,6 +626,8 @@ class MemoryStorage:
626
626
  # Upsert: update existing pattern or append new
627
627
  existing_idx = None
628
628
  for i, p in enumerate(patterns_file["patterns"]):
629
+ if not isinstance(p, dict):
630
+ continue
629
631
  if p.get("id") == pattern_id:
630
632
  existing_idx = i
631
633
  break
@@ -672,6 +674,8 @@ class MemoryStorage:
672
674
  return None
673
675
 
674
676
  for pattern in patterns_file.get("patterns", []):
677
+ if not isinstance(pattern, dict):
678
+ continue
675
679
  if pattern.get("id") == pattern_id:
676
680
  return pattern
677
681
 
@@ -695,6 +699,8 @@ class MemoryStorage:
695
699
 
696
700
  pattern_ids = []
697
701
  for pattern in patterns_file.get("patterns", []):
702
+ if not isinstance(pattern, dict):
703
+ continue
698
704
  if category is None or pattern.get("category") == category:
699
705
  pattern_ids.append(pattern.get("id"))
700
706
 
@@ -737,6 +743,8 @@ class MemoryStorage:
737
743
  # Find and update pattern
738
744
  found = False
739
745
  for i, p in enumerate(patterns_file.get("patterns", [])):
746
+ if not isinstance(p, dict):
747
+ continue
740
748
  if p.get("id") == pattern_id:
741
749
  pattern_data["updated_at"] = datetime.now(timezone.utc).isoformat()
742
750
  patterns_file["patterns"][i] = pattern_data
@@ -1364,6 +1372,8 @@ class MemoryStorage:
1364
1372
 
1365
1373
  updated = 0
1366
1374
  for pattern in patterns:
1375
+ if not isinstance(pattern, dict):
1376
+ continue
1367
1377
  original = pattern.get("importance", 0.5)
1368
1378
  self.apply_decay([pattern], decay_rate, half_life_days)
1369
1379
  if abs(pattern.get("importance", 0.5) - original) > 0.001:
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.57.0",
4
+ "version": "7.58.1",
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.57.0",
5
+ "version": "7.58.1",
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",