loki-mode 7.57.0 → 7.58.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/loki +58 -9
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +270 -192
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/memory/consolidation.py +14 -2
- package/memory/retrieval.py +10 -0
- package/memory/storage.py +10 -0
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
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.
|
|
6
|
+
# Loki Mode v7.58.0
|
|
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.
|
|
409
|
+
**v7.58.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.58.0
|
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
|
|
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
|
-
|
|
8053
|
-
|
|
8054
|
-
doctor_check
|
|
8055
|
-
|
|
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,30 @@ 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
|
-
#
|
|
8165
|
-
|
|
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
|
+
[ -t 2 ] && printf ' %s...\r' "$1" >&2
|
|
8201
|
+
}
|
|
8202
|
+
doctor_probe_clear() {
|
|
8203
|
+
# Erase the transient progress line so it does not linger above the
|
|
8204
|
+
# PASS/WARN result. Only on a TTY (where the note was printed).
|
|
8205
|
+
[ -t 2 ] && printf '\r\033[K' >&2
|
|
8206
|
+
}
|
|
8207
|
+
# ChromaDB check (bounded network probe)
|
|
8208
|
+
doctor_probe_note "Probing ChromaDB (port 8100)"
|
|
8209
|
+
if curl -sf --connect-timeout 2 --max-time 5 http://localhost:8100/api/v2/heartbeat >/dev/null 2>&1; then
|
|
8210
|
+
doctor_probe_clear
|
|
8166
8211
|
echo -e " ${GREEN}PASS${NC} ChromaDB server (port 8100)"
|
|
8167
8212
|
pass_count=$((pass_count + 1))
|
|
8168
8213
|
else
|
|
8214
|
+
doctor_probe_clear
|
|
8169
8215
|
echo -e " ${YELLOW}WARN${NC} ChromaDB - not running (docker start loki-chroma)"
|
|
8170
8216
|
warn_count=$((warn_count + 1))
|
|
8171
8217
|
fi
|
|
@@ -8189,10 +8235,13 @@ cmd_doctor() {
|
|
|
8189
8235
|
# MiroFish check (optional service)
|
|
8190
8236
|
local _mf_url="${LOKI_MIROFISH_URL:-http://localhost:5001}"
|
|
8191
8237
|
if docker inspect loki-mirofish &>/dev/null 2>&1 || [[ -n "${LOKI_MIROFISH_URL:-}" ]]; then
|
|
8192
|
-
|
|
8238
|
+
doctor_probe_note "Probing MiroFish ($_mf_url)"
|
|
8239
|
+
if curl -sf --connect-timeout 2 --max-time 5 "$_mf_url/health" >/dev/null 2>&1; then
|
|
8240
|
+
doctor_probe_clear
|
|
8193
8241
|
echo -e " ${GREEN}PASS${NC} MiroFish server ($_mf_url)"
|
|
8194
8242
|
pass_count=$((pass_count + 1))
|
|
8195
8243
|
else
|
|
8244
|
+
doctor_probe_clear
|
|
8196
8245
|
echo -e " ${YELLOW}WARN${NC} MiroFish - not running (loki start --mirofish-docker <image>)"
|
|
8197
8246
|
warn_count=$((warn_count + 1))
|
|
8198
8247
|
fi
|
|
@@ -9939,7 +9988,7 @@ QPRDEOF
|
|
|
9939
9988
|
case "$_quick_provider" in
|
|
9940
9989
|
claude) echo " npm install -g @anthropic-ai/claude-code" ;;
|
|
9941
9990
|
codex) echo " npm install -g @openai/codex" ;;
|
|
9942
|
-
cline) echo " npm install -g
|
|
9991
|
+
cline) echo " npm install -g cline" ;;
|
|
9943
9992
|
aider) echo " pip install aider-chat" ;;
|
|
9944
9993
|
*) echo " Check the provider documentation for installation." ;;
|
|
9945
9994
|
esac
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -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
|
-
#
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
4437
|
-
|
|
4438
|
-
|
|
4439
|
-
|
|
4440
|
-
|
|
4441
|
-
|
|
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
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
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
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
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
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4653
|
-
tool_signals = _read_learning_signals
|
|
4654
|
-
error_signals = _read_learning_signals
|
|
4655
|
-
pref_signals = _read_learning_signals
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
5744
|
-
|
|
5745
|
-
|
|
5746
|
-
|
|
5747
|
-
|
|
5748
|
-
|
|
5749
|
-
|
|
5750
|
-
|
|
5751
|
-
|
|
5752
|
-
|
|
5753
|
-
|
|
5754
|
-
|
|
5755
|
-
|
|
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
|
-
|
|
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
|
|
5902
|
+
response["hook_events"] = await asyncio.to_thread(_read_events, type_prefix=type_prefix)
|
|
5853
5903
|
return response
|
|
5854
5904
|
|
|
5855
|
-
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
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
|
-
|
|
5872
|
-
except
|
|
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
|
|
5915
|
+
if not isinstance(rec, dict):
|
|
5916
|
+
logger.warning("Skipping non-object council transcript file: %s", f.name)
|
|
5875
5917
|
continue
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
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
|
|
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
|
-
|
|
6563
|
-
|
|
6564
|
-
|
|
6565
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8039
|
-
|
|
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
|
-
|
|
8075
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8819
|
-
|
|
8820
|
-
|
|
8896
|
+
500,
|
|
8897
|
+
None,
|
|
8898
|
+
"managed_agents_fallback",
|
|
8821
8899
|
)
|
|
8822
8900
|
snapshot["last_fallback_ts"] = _last_fallback_ts(events)
|
|
8823
8901
|
except Exception:
|
package/docs/INSTALLATION.md
CHANGED
|
@@ -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.
|
|
5
|
+
**Version:** v7.58.0
|
|
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.
|
|
398
|
+
asklokesh/loki-mode:7.58.0 start ./my-spec.md
|
|
399
399
|
```
|
|
400
400
|
|
|
401
401
|
##### docker compose + .env (no host install)
|
package/loki-ts/dist/loki.js
CHANGED
|
@@ -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.
|
|
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.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}
|
|
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=
|
|
793
|
+
//# debugId=9F9D3B1A3FBD072264756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/memory/consolidation.py
CHANGED
|
@@ -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
|
package/memory/retrieval.py
CHANGED
|
@@ -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.
|
|
4
|
+
"version": "7.58.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.58.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",
|