loki-mode 7.63.1 → 7.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/app-runner.sh +110 -1
- package/dashboard/__init__.py +1 -1
- package/dashboard/server.py +81 -18
- package/docs/INSTALLATION.md +2 -2
- package/loki-ts/dist/loki.js +2 -2
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +27 -3
- package/memory/consolidation.py +22 -3
- package/memory/engine.py +157 -107
- package/memory/retrieval.py +105 -41
- package/memory/storage.py +131 -40
- package/memory/token_economics.py +38 -9
- 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.65.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.65.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
7.
|
|
1
|
+
7.65.0
|
package/autonomy/app-runner.sh
CHANGED
|
@@ -144,6 +144,76 @@ HEALTH_EOF
|
|
|
144
144
|
mv "$tmp_file" "$_APP_RUNNER_DIR/health.json"
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
# L5 fix (PID-file reuse race): capture a disambiguating identity token for a
|
|
148
|
+
# live PID, so a later liveness check can tell "our app is still running" apart
|
|
149
|
+
# from "the OS reassigned the crashed app's PID to an unrelated process". A raw
|
|
150
|
+
# `kill -0 $pid` cannot make that distinction: a reused PID reports a foreign
|
|
151
|
+
# process as a healthy app.
|
|
152
|
+
#
|
|
153
|
+
# Token = the process start time (`lstart`) plus its command name (`comm`), one
|
|
154
|
+
# line, read from `ps`. `lstart` is the strong disambiguator: it is constant for
|
|
155
|
+
# a process lifetime and a reused PID is, by definition, a different (later)
|
|
156
|
+
# process with a different start time. `comm` is a weaker secondary signal kept
|
|
157
|
+
# for human readability. We compare the whole line by plain string equality.
|
|
158
|
+
#
|
|
159
|
+
# Portability: the `ps -o lstart=,comm=` field set works on both BSD (macOS) and
|
|
160
|
+
# GNU/Linux. We never parse the format -- we only ever compare two reads taken on
|
|
161
|
+
# the SAME host (capture at start vs read at check), so the BSD-vs-Linux format
|
|
162
|
+
# difference is irrelevant: the two strings either match or they do not. Returns
|
|
163
|
+
# empty on any `ps` miss (dead/foreign pid) so callers can fall back safely.
|
|
164
|
+
_app_runner_pid_token() {
|
|
165
|
+
local pid="$1"
|
|
166
|
+
case "$pid" in
|
|
167
|
+
''|0|1) return 0 ;;
|
|
168
|
+
esac
|
|
169
|
+
[[ "$pid" =~ ^[0-9]+$ ]] || return 0
|
|
170
|
+
# Collapse internal whitespace runs to single spaces so a benign formatting
|
|
171
|
+
# quirk (e.g. extra padding) never causes a spurious mismatch.
|
|
172
|
+
ps -o lstart=,comm= -p "$pid" 2>/dev/null | tr -s ' ' ' ' | sed 's/^ *//; s/ *$//'
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
# Write the captured identity token for the current app PID. Best-effort: if the
|
|
176
|
+
# token cannot be read (no ps output) we remove any stale token file rather than
|
|
177
|
+
# leave a wrong one, which makes the later check fall back to trusting kill -0
|
|
178
|
+
# (no false-DEAD regression).
|
|
179
|
+
_write_app_token() {
|
|
180
|
+
local pid="$1"
|
|
181
|
+
local tok
|
|
182
|
+
tok=$(_app_runner_pid_token "$pid")
|
|
183
|
+
if [ -n "$tok" ]; then
|
|
184
|
+
local tmp_file="$_APP_RUNNER_DIR/app.token.tmp.$$"
|
|
185
|
+
printf '%s\n' "$tok" > "$tmp_file" 2>/dev/null && \
|
|
186
|
+
mv "$tmp_file" "$_APP_RUNNER_DIR/app.token" 2>/dev/null || \
|
|
187
|
+
rm -f "$tmp_file" 2>/dev/null
|
|
188
|
+
else
|
|
189
|
+
rm -f "$_APP_RUNNER_DIR/app.token" 2>/dev/null
|
|
190
|
+
fi
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
# Decide whether the live PID is still OUR app, using the captured token.
|
|
194
|
+
# Returns 0 (yes, ours) when:
|
|
195
|
+
# - no token file exists (app launched by a pre-fix version, or ps was
|
|
196
|
+
# unavailable at start) -- fall back to trusting kill -0, OR
|
|
197
|
+
# - the live PID's token matches the stored token.
|
|
198
|
+
# Returns 1 (NOT ours) ONLY when a token file exists AND the live token differs
|
|
199
|
+
# (the strong "PID was reused by a foreign process" signal). This asymmetry is
|
|
200
|
+
# deliberate: we never declare DEAD on a missing/unreadable token, so a
|
|
201
|
+
# legitimate still-running app is never falsely killed (no false-DEAD
|
|
202
|
+
# regression). The residual: if `ps` is entirely unavailable on the host, the
|
|
203
|
+
# token file is never written and we always fall back to bare kill -0 -- i.e. no
|
|
204
|
+
# worse than the pre-fix behavior, never worse.
|
|
205
|
+
_app_runner_pid_is_ours() {
|
|
206
|
+
local pid="$1"
|
|
207
|
+
local token_file="$_APP_RUNNER_DIR/app.token"
|
|
208
|
+
[ -f "$token_file" ] || return 0
|
|
209
|
+
local stored live
|
|
210
|
+
stored=$(cat "$token_file" 2>/dev/null)
|
|
211
|
+
[ -n "$stored" ] || return 0
|
|
212
|
+
live=$(_app_runner_pid_token "$pid")
|
|
213
|
+
[ -n "$live" ] || return 0
|
|
214
|
+
[ "$live" = "$stored" ]
|
|
215
|
+
}
|
|
216
|
+
|
|
147
217
|
# Re-derive a detection.json field (type/command) so we can rewrite it after a
|
|
148
218
|
# port reconcile without threading those values through globals. Echoes the raw
|
|
149
219
|
# string value (empty on miss). Mirrors the grep-based read style used by
|
|
@@ -1148,6 +1218,10 @@ app_runner_start() {
|
|
|
1148
1218
|
# live port even when the app ignored PORT. Mutates globals before the
|
|
1149
1219
|
# state write below. Bounded; no-op when the app honored the chosen port.
|
|
1150
1220
|
_app_runner_reconcile_port
|
|
1221
|
+
# L5 fix: capture the identity token now that the child has exec'd into
|
|
1222
|
+
# the real app process, so a later health-check/watchdog can detect a
|
|
1223
|
+
# reused PID (foreign process) instead of trusting it blindly.
|
|
1224
|
+
_write_app_token "$_APP_RUNNER_PID"
|
|
1151
1225
|
_write_app_state "running"
|
|
1152
1226
|
log_info "App Runner: application started (PID: $_APP_RUNNER_PID) on port $_APP_RUNNER_PORT"
|
|
1153
1227
|
return 0
|
|
@@ -1271,6 +1345,7 @@ app_runner_stop() {
|
|
|
1271
1345
|
fi
|
|
1272
1346
|
|
|
1273
1347
|
rm -f "$_APP_RUNNER_DIR/app.pid"
|
|
1348
|
+
rm -f "$_APP_RUNNER_DIR/app.token"
|
|
1274
1349
|
_write_app_state "stopped"
|
|
1275
1350
|
log_info "App Runner: application stopped"
|
|
1276
1351
|
_APP_RUNNER_PID=""
|
|
@@ -1282,7 +1357,20 @@ app_runner_restart() {
|
|
|
1282
1357
|
log_step "App Runner: restarting (restart #$_APP_RUNNER_RESTART_COUNT)..."
|
|
1283
1358
|
app_runner_stop
|
|
1284
1359
|
sleep 1
|
|
1360
|
+
# L6 fix (non-atomic restart): the old code returned app_runner_start's status
|
|
1361
|
+
# unhandled, so a failed start (e.g. the old port still in TIME_WAIT after the
|
|
1362
|
+
# 1s wait, tripping the port-conflict guard) left the app stopped with only an
|
|
1363
|
+
# internal log_warn -- silent to the caller, which saw the restart "succeed".
|
|
1364
|
+
# Surface the failure loudly via log_error and return the failure status
|
|
1365
|
+
# explicitly so the restart's failure is visible and actionable. The
|
|
1366
|
+
# happy-path behavior (start succeeds -> return 0) is unchanged.
|
|
1285
1367
|
app_runner_start
|
|
1368
|
+
local _start_rc=$?
|
|
1369
|
+
if [ "$_start_rc" -ne 0 ]; then
|
|
1370
|
+
log_error "App Runner: restart failed -- app stopped but could not be started again (start exit $_start_rc). Check $_APP_RUNNER_DIR/app.log; the previous port may still be held (TIME_WAIT) or the start command may be failing."
|
|
1371
|
+
return "$_start_rc"
|
|
1372
|
+
fi
|
|
1373
|
+
return 0
|
|
1286
1374
|
}
|
|
1287
1375
|
|
|
1288
1376
|
#===============================================================================
|
|
@@ -1374,6 +1462,18 @@ app_runner_health_check() {
|
|
|
1374
1462
|
return 1
|
|
1375
1463
|
fi
|
|
1376
1464
|
|
|
1465
|
+
# L5 fix (PID-file reuse race): kill -0 only proves SOME process owns this
|
|
1466
|
+
# PID, not that it is OUR app. The OS may have reassigned the crashed app's
|
|
1467
|
+
# PID to an unrelated process. Verify the captured identity token; on a
|
|
1468
|
+
# mismatch the live PID is a foreign process, so the app is DEAD. (When no
|
|
1469
|
+
# token was captured we fall back to trusting kill -0 -- see
|
|
1470
|
+
# _app_runner_pid_is_ours -- so a still-running legitimate app is never
|
|
1471
|
+
# falsely marked dead.)
|
|
1472
|
+
if ! _app_runner_pid_is_ours "$_APP_RUNNER_PID"; then
|
|
1473
|
+
_write_health "false"
|
|
1474
|
+
return 1
|
|
1475
|
+
fi
|
|
1476
|
+
|
|
1377
1477
|
# For HTTP apps, try an HTTP health check.
|
|
1378
1478
|
if [ -n "$_APP_RUNNER_PORT" ] && [ "$_APP_RUNNER_PORT" -gt 0 ] 2>/dev/null; then
|
|
1379
1479
|
# The health signal is "is the server answering HTTP at all", NOT "does /
|
|
@@ -1524,7 +1624,15 @@ app_runner_watchdog() {
|
|
|
1524
1624
|
# app_runner_health_check (HTTP-aware for apps that declared a port), and
|
|
1525
1625
|
# treat an unhealthy-but-alive process as a crash so the same circuit
|
|
1526
1626
|
# breaker + backoff + restart path handles it.
|
|
1527
|
-
|
|
1627
|
+
# L5 fix: gate on BOTH liveness AND identity. If kill -0 succeeds but the
|
|
1628
|
+
# PID is a foreign process the OS reassigned our crashed app's PID to
|
|
1629
|
+
# (_app_runner_pid_is_ours false), we must NOT enter this branch: the
|
|
1630
|
+
# alive-but-unhealthy path below calls app_runner_stop, which would send
|
|
1631
|
+
# TERM/KILL to an innocent stranger. Instead let a reused PID fall through to
|
|
1632
|
+
# the dead path, which only increments the crash count and restarts -- it
|
|
1633
|
+
# never signals the PID. (No token captured -> is_ours returns true ->
|
|
1634
|
+
# behaves exactly as before, so no regression for legitimately-running apps.)
|
|
1635
|
+
if kill -0 "$_APP_RUNNER_PID" 2>/dev/null && _app_runner_pid_is_ours "$_APP_RUNNER_PID"; then
|
|
1528
1636
|
if app_runner_health_check; then
|
|
1529
1637
|
# BUG 3 fix: a confirmed-healthy observation clears the accumulated
|
|
1530
1638
|
# crash count so the breaker fires only on 5 CONSECUTIVE failures,
|
|
@@ -1557,6 +1665,7 @@ app_runner_watchdog() {
|
|
|
1557
1665
|
done
|
|
1558
1666
|
_write_app_state "crashed"
|
|
1559
1667
|
rm -f "$_APP_RUNNER_DIR/app.pid"
|
|
1668
|
+
rm -f "$_APP_RUNNER_DIR/app.token"
|
|
1560
1669
|
_APP_RUNNER_PID=""
|
|
1561
1670
|
return 1
|
|
1562
1671
|
fi
|
package/dashboard/__init__.py
CHANGED
package/dashboard/server.py
CHANGED
|
@@ -436,23 +436,51 @@ class ConnectionManager:
|
|
|
436
436
|
if websocket in self.active_connections:
|
|
437
437
|
self.active_connections.remove(websocket)
|
|
438
438
|
|
|
439
|
+
# Per-client send timeout (seconds). A client that does not stop reading
|
|
440
|
+
# fills its TCP send buffer; without a bound the await blocks indefinitely
|
|
441
|
+
# and one stalled client would freeze the whole fan-out (and the 2s
|
|
442
|
+
# _push_loki_state_loop) for every other client. Drop the slow client
|
|
443
|
+
# instead of blocking everyone.
|
|
444
|
+
SEND_TIMEOUT_SECONDS = 5.0
|
|
445
|
+
|
|
439
446
|
async def broadcast(self, message: dict[str, Any]) -> None:
|
|
440
|
-
"""Broadcast a message to all connected clients.
|
|
441
|
-
|
|
442
|
-
|
|
447
|
+
"""Broadcast a message to all connected clients.
|
|
448
|
+
|
|
449
|
+
Sends run concurrently with a per-client timeout so a single stalled
|
|
450
|
+
or dead client is dropped rather than blocking the fan-out.
|
|
451
|
+
"""
|
|
452
|
+
connections = list(self.active_connections)
|
|
453
|
+
if not connections:
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
async def _send(conn: WebSocket) -> bool:
|
|
443
457
|
try:
|
|
444
|
-
await
|
|
458
|
+
await asyncio.wait_for(
|
|
459
|
+
conn.send_json(message), timeout=self.SEND_TIMEOUT_SECONDS
|
|
460
|
+
)
|
|
461
|
+
return True
|
|
462
|
+
except asyncio.TimeoutError:
|
|
463
|
+
logger.debug("WebSocket send timed out, dropping slow client")
|
|
464
|
+
return False
|
|
445
465
|
except Exception as e:
|
|
446
466
|
logger.debug(f"WebSocket send failed, client disconnected: {e}")
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
for
|
|
450
|
-
|
|
467
|
+
return False
|
|
468
|
+
|
|
469
|
+
results = await asyncio.gather(*(_send(c) for c in connections))
|
|
470
|
+
# Clean up clients that timed out or errored.
|
|
471
|
+
for conn, ok in zip(connections, results):
|
|
472
|
+
if not ok:
|
|
473
|
+
self.disconnect(conn)
|
|
451
474
|
|
|
452
475
|
async def send_personal(self, websocket: WebSocket, message: dict[str, Any]) -> None:
|
|
453
476
|
"""Send a message to a specific client."""
|
|
454
477
|
try:
|
|
455
|
-
await
|
|
478
|
+
await asyncio.wait_for(
|
|
479
|
+
websocket.send_json(message), timeout=self.SEND_TIMEOUT_SECONDS
|
|
480
|
+
)
|
|
481
|
+
except asyncio.TimeoutError:
|
|
482
|
+
logger.debug("WebSocket personal send timed out, dropping slow client")
|
|
483
|
+
self.disconnect(websocket)
|
|
456
484
|
except Exception as e:
|
|
457
485
|
logger.debug(f"WebSocket personal send failed: {e}")
|
|
458
486
|
self.disconnect(websocket)
|
|
@@ -5257,6 +5285,11 @@ def _compute_cost_snapshot() -> dict:
|
|
|
5257
5285
|
for eff_file in sorted(efficiency_dir.glob("*.json")):
|
|
5258
5286
|
try:
|
|
5259
5287
|
data = json.loads(eff_file.read_text())
|
|
5288
|
+
# A corrupt/truncated efficiency file can parse to a non-object
|
|
5289
|
+
# (list / null / scalar); data.get(...) would then raise
|
|
5290
|
+
# AttributeError. Skip such files rather than 500 the endpoint.
|
|
5291
|
+
if not isinstance(data, dict):
|
|
5292
|
+
continue
|
|
5260
5293
|
|
|
5261
5294
|
inp = data.get("input_tokens", 0)
|
|
5262
5295
|
out = data.get("output_tokens", 0)
|
|
@@ -5284,7 +5317,7 @@ def _compute_cost_snapshot() -> dict:
|
|
|
5284
5317
|
by_model[model]["input_tokens"] += inp
|
|
5285
5318
|
by_model[model]["output_tokens"] += out
|
|
5286
5319
|
by_model[model]["cost_usd"] += cost
|
|
5287
|
-
except (json.JSONDecodeError, KeyError, TypeError):
|
|
5320
|
+
except (json.JSONDecodeError, KeyError, TypeError, AttributeError):
|
|
5288
5321
|
pass
|
|
5289
5322
|
|
|
5290
5323
|
# Fallback: read from context tracking if efficiency files have no token data
|
|
@@ -5293,7 +5326,13 @@ def _compute_cost_snapshot() -> dict:
|
|
|
5293
5326
|
if ctx_file.exists():
|
|
5294
5327
|
try:
|
|
5295
5328
|
ctx = json.loads(ctx_file.read_text())
|
|
5329
|
+
# A corrupt/truncated tracking.json can parse to a non-object;
|
|
5330
|
+
# ctx.get(...) would then raise AttributeError. Skip it.
|
|
5331
|
+
if not isinstance(ctx, dict):
|
|
5332
|
+
ctx = {}
|
|
5296
5333
|
totals = ctx.get("totals", {})
|
|
5334
|
+
if not isinstance(totals, dict):
|
|
5335
|
+
totals = {}
|
|
5297
5336
|
total_input = totals.get("total_input", 0)
|
|
5298
5337
|
total_output = totals.get("total_output", 0)
|
|
5299
5338
|
if total_input > 0 or total_output > 0:
|
|
@@ -5309,7 +5348,7 @@ def _compute_cost_snapshot() -> dict:
|
|
|
5309
5348
|
by_model[model]["input_tokens"] += inp
|
|
5310
5349
|
by_model[model]["output_tokens"] += out
|
|
5311
5350
|
by_model[model]["cost_usd"] += cost
|
|
5312
|
-
except (json.JSONDecodeError, KeyError):
|
|
5351
|
+
except (json.JSONDecodeError, KeyError, TypeError, AttributeError):
|
|
5313
5352
|
pass
|
|
5314
5353
|
|
|
5315
5354
|
# Read budget configuration
|
|
@@ -5386,13 +5425,26 @@ async def get_budget():
|
|
|
5386
5425
|
except (json.JSONDecodeError, KeyError):
|
|
5387
5426
|
pass
|
|
5388
5427
|
|
|
5428
|
+
# Coerce defensively: a budget.json with a non-numeric budget_used/limit
|
|
5429
|
+
# (e.g. "n/a", null, a list) parses as valid JSON but would crash float()
|
|
5430
|
+
# with ValueError/TypeError. Treat non-numeric values as 0.0 / None so the
|
|
5431
|
+
# endpoint returns a clean payload instead of a 500.
|
|
5432
|
+
def _to_float(value, default=0.0):
|
|
5433
|
+
try:
|
|
5434
|
+
return float(value)
|
|
5435
|
+
except (ValueError, TypeError):
|
|
5436
|
+
return default
|
|
5437
|
+
|
|
5438
|
+
budget_limit_f = _to_float(budget_limit, None) if budget_limit is not None else None
|
|
5439
|
+
budget_used_f = _to_float(budget_used, 0.0)
|
|
5440
|
+
|
|
5389
5441
|
remaining = None
|
|
5390
|
-
if
|
|
5391
|
-
remaining = max(0.0,
|
|
5442
|
+
if budget_limit_f is not None:
|
|
5443
|
+
remaining = max(0.0, budget_limit_f - budget_used_f)
|
|
5392
5444
|
|
|
5393
5445
|
return {
|
|
5394
|
-
"budget_limit":
|
|
5395
|
-
"current_cost": round(
|
|
5446
|
+
"budget_limit": budget_limit_f,
|
|
5447
|
+
"current_cost": round(budget_used_f, 4),
|
|
5396
5448
|
"exceeded": exceeded,
|
|
5397
5449
|
"exceeded_at": exceeded_at,
|
|
5398
5450
|
"remaining": round(remaining, 4) if remaining is not None else None,
|
|
@@ -6590,10 +6642,17 @@ async def resume_agent(agent_id: str):
|
|
|
6590
6642
|
|
|
6591
6643
|
@app.get("/api/logs")
|
|
6592
6644
|
async def get_logs(lines: int = Query(default=100, ge=1, le=10000), token: Optional[dict] = Depends(auth.get_current_token)):
|
|
6593
|
-
"""Get recent log entries from session log files."""
|
|
6645
|
+
"""Get recent log entries from session log files (redacted)."""
|
|
6594
6646
|
log_dir = _get_loki_dir() / "logs"
|
|
6595
6647
|
entries = []
|
|
6596
6648
|
|
|
6649
|
+
# Session logs (.loki/logs/*.log) are written raw by run.sh and can contain
|
|
6650
|
+
# secrets an agent/tool echoed to stdout (sk-ant-, ghp_, Bearer, AWS keys).
|
|
6651
|
+
# Redact every returned message exactly like the /api/app-runner/logs and
|
|
6652
|
+
# /errors endpoints do. The response shape is unchanged; only the message
|
|
6653
|
+
# content passes through the redactor.
|
|
6654
|
+
_redact = _get_log_redactor()
|
|
6655
|
+
|
|
6597
6656
|
# Regex for full timestamp: [2026-02-07T01:32:00] [INFO] msg or 2026-02-07 01:32:00 INFO msg
|
|
6598
6657
|
_LOG_TS_FULL = re.compile(
|
|
6599
6658
|
r'^\[?(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})\]?\s*\[?(\w+)\]?\s*(.*)'
|
|
@@ -6661,7 +6720,7 @@ async def get_logs(lines: int = Query(default=100, ge=1, le=10000), token: Optio
|
|
|
6661
6720
|
timestamp = file_mtime
|
|
6662
6721
|
|
|
6663
6722
|
entries.append({
|
|
6664
|
-
"message": message,
|
|
6723
|
+
"message": _redact(message),
|
|
6665
6724
|
"level": level,
|
|
6666
6725
|
"timestamp": timestamp,
|
|
6667
6726
|
})
|
|
@@ -7171,6 +7230,10 @@ def _build_metrics_text() -> str:
|
|
|
7171
7230
|
for eff_file in efficiency_dir.glob("*.json"):
|
|
7172
7231
|
try:
|
|
7173
7232
|
data = json.loads(eff_file.read_text())
|
|
7233
|
+
# Skip non-object (corrupt/truncated) efficiency files so a
|
|
7234
|
+
# bad file does not 500 the Prometheus scrape.
|
|
7235
|
+
if not isinstance(data, dict):
|
|
7236
|
+
continue
|
|
7174
7237
|
cost = data.get("cost_usd")
|
|
7175
7238
|
if cost is not None:
|
|
7176
7239
|
estimated_cost += float(cost)
|
|
@@ -7180,7 +7243,7 @@ def _build_metrics_text() -> str:
|
|
|
7180
7243
|
estimated_cost += _calculate_model_cost(
|
|
7181
7244
|
data.get("model", "sonnet").lower(), inp, out
|
|
7182
7245
|
)
|
|
7183
|
-
except (json.JSONDecodeError, KeyError, TypeError):
|
|
7246
|
+
except (json.JSONDecodeError, KeyError, TypeError, AttributeError):
|
|
7184
7247
|
pass
|
|
7185
7248
|
except OSError:
|
|
7186
7249
|
pass
|
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.65.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.65.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 b=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var P=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var D1={};b(D1,{lokiDir:()=>j,homeLokiDir:()=>a$,findRepoRootForVersion:()=>n$,REPO_ROOT:()=>g});import{resolve as a,dirname as o$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as j$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(j$(a($,"VERSION"))&&j$(a($,"autonomy/run.sh")))return $;let Z=o$($);if(Z===$)break;$=Z}return a(S1,"..","..","..")}function n$($){let Q=$;for(let Z=0;Z<6;Z++){if(j$(a(Q,"VERSION"))&&j$(a(Q,"autonomy/run.sh")))return Q;let z=o$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function j(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function a$(){return a($Q(),".loki")}var S1,g;var C=P(()=>{S1=o$(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 F$(){if(Q$!==null)return Q$;let $="7.
|
|
2
|
+
var r6=Object.defineProperty;var t6=($)=>$;function i6($,Q){this[$]=t6.bind(null,Q)}var b=($,Q)=>{for(var Z in Q)r6($,Z,{get:Q[Z],enumerable:!0,configurable:!0,set:i6.bind(Q,Z)})};var P=($,Q)=>()=>($&&(Q=$($=0)),Q);var q$=import.meta.require;var D1={};b(D1,{lokiDir:()=>j,homeLokiDir:()=>a$,findRepoRootForVersion:()=>n$,REPO_ROOT:()=>g});import{resolve as a,dirname as o$}from"path";import{fileURLToPath as e6}from"url";import{existsSync as j$}from"fs";import{homedir as $Q}from"os";function QQ(){let $=S1;for(let Q=0;Q<6;Q++){if(j$(a($,"VERSION"))&&j$(a($,"autonomy/run.sh")))return $;let Z=o$($);if(Z===$)break;$=Z}return a(S1,"..","..","..")}function n$($){let Q=$;for(let Z=0;Z<6;Z++){if(j$(a(Q,"VERSION"))&&j$(a(Q,"autonomy/run.sh")))return Q;let z=o$(Q);if(z===Q)break;Q=z}return a($,"..","..","..")}function j(){return process.env.LOKI_DIR??a(process.cwd(),".loki")}function a$(){return a($Q(),".loki")}var S1,g;var C=P(()=>{S1=o$(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 F$(){if(Q$!==null)return Q$;let $="7.65.0";if(typeof $==="string"&&$.length>0)return Q$=$,Q$;try{let Q=XQ(KQ(import.meta.url)),Z=n$(Q);Q$=ZQ(zQ(Z,"VERSION"),"utf-8").trim()}catch{Q$="unknown"}return Q$}var Q$=null;var s$=P(()=>{C()});var b1={};b(b1,{runOrThrow:()=>qQ,run:()=>k,commandVersion:()=>JQ,commandExists:()=>f,ShellError:()=>r$});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 r$(`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 JQ($,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 r$;var d=P(()=>{r$=class r$ 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 s($){return WQ?"":$}var WQ,T,S,_,wZ,I,R,h,V;var c=P(()=>{WQ=(process.env.NO_COLOR??"").length>0;T=s("\x1B[0;31m"),S=s("\x1B[0;32m"),_=s("\x1B[1;33m"),wZ=s("\x1B[0;34m"),I=s("\x1B[0;36m"),R=s("\x1B[1m"),h=s("\x1B[2m"),V=s("\x1B[0m")});import{existsSync as wQ}from"fs";async function Z$(){if(Y$!==void 0)return Y$;let $="/opt/homebrew/bin/python3.12";if(wQ($))return Y$=$,$;let Q=await f("python3.12");if(Q)return Y$=Q,Q;let Z=await f("python3");return Y$=Z,Z}async function z$($,Q={}){let Z=await Z$();if(!Z)return{stdout:"",stderr:"python3 not found",exitCode:127};return k([Z,"-c",$],Q)}var Y$;var V$=P(()=>{d()});var e1={};b(e1,{runStatus:()=>uQ});import{existsSync as y,readFileSync as W$,readdirSync as d1,statSync as o1}from"fs";import{resolve as D,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($*R$/Q);if(X>R$)X=R$;let q=R$-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)
|
|
@@ -791,4 +791,4 @@ Set LOKI_LEGACY_BASH=1 to force the bash CLI for every command.
|
|
|
791
791
|
`),2}default:return process.stderr.write(`Unknown command: ${Q}
|
|
792
792
|
`),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);
|
|
793
793
|
|
|
794
|
-
//# debugId=
|
|
794
|
+
//# debugId=1BFA28C250C77A2E64756E2164756E21
|
package/mcp/__init__.py
CHANGED
package/mcp/server.py
CHANGED
|
@@ -15,6 +15,7 @@ Usage:
|
|
|
15
15
|
python -m mcp.server --transport http # HTTP mode
|
|
16
16
|
"""
|
|
17
17
|
|
|
18
|
+
import asyncio
|
|
18
19
|
import sys
|
|
19
20
|
import os
|
|
20
21
|
import json
|
|
@@ -811,7 +812,10 @@ async def loki_task_queue_add(
|
|
|
811
812
|
"created_at": datetime.now(timezone.utc).isoformat()
|
|
812
813
|
}
|
|
813
814
|
|
|
814
|
-
queue
|
|
815
|
+
# Use setdefault so an existing-but-malformed queue dict that lacks a
|
|
816
|
+
# "tasks" key does not raise KeyError, consistent with the safe
|
|
817
|
+
# queue.get("tasks", ...) read above.
|
|
818
|
+
queue.setdefault("tasks", []).append(task)
|
|
815
819
|
|
|
816
820
|
# Save using StateManager if available
|
|
817
821
|
if manager and STATE_MANAGER_AVAILABLE:
|
|
@@ -1710,7 +1714,10 @@ async def loki_code_search(
|
|
|
1710
1714
|
'type_filter': type_filter})
|
|
1711
1715
|
|
|
1712
1716
|
# Warn-if-stale (default) or opt-in auto-reindex before querying.
|
|
1713
|
-
_maybe_autoreindex_code(
|
|
1717
|
+
# _maybe_autoreindex_code runs a synchronous subprocess (timeout up to
|
|
1718
|
+
# 300s) when LOKI_CODE_INDEX_AUTOREINDEX=1; offload it so it cannot
|
|
1719
|
+
# freeze the MCP event loop while the indexer runs.
|
|
1720
|
+
await asyncio.to_thread(_maybe_autoreindex_code)
|
|
1714
1721
|
_staleness = _code_index_staleness()
|
|
1715
1722
|
|
|
1716
1723
|
collection = _get_chroma_collection()
|
|
@@ -1743,7 +1750,10 @@ async def loki_code_search(
|
|
|
1743
1750
|
where = {"$and": where_clauses}
|
|
1744
1751
|
|
|
1745
1752
|
try:
|
|
1746
|
-
|
|
1753
|
+
# collection.query is a blocking HTTP call to ChromaDB; offload it so
|
|
1754
|
+
# the MCP event loop is not frozen for the duration of the request.
|
|
1755
|
+
results = await asyncio.to_thread(
|
|
1756
|
+
collection.query,
|
|
1747
1757
|
query_texts=[query],
|
|
1748
1758
|
n_results=n_results,
|
|
1749
1759
|
where=where,
|
|
@@ -2209,6 +2219,13 @@ async def loki_get_co_changes(
|
|
|
2209
2219
|
continue
|
|
2210
2220
|
if not isinstance(pair_files, (list, tuple)) or len(pair_files) != 2:
|
|
2211
2221
|
continue
|
|
2222
|
+
# Coerce the count to int so a mixed-type producer (one row's
|
|
2223
|
+
# count a string, another's an int) does not raise TypeError when
|
|
2224
|
+
# results are sorted below. Skip rows whose count is not numeric.
|
|
2225
|
+
try:
|
|
2226
|
+
count = int(count)
|
|
2227
|
+
except (ValueError, TypeError):
|
|
2228
|
+
continue
|
|
2212
2229
|
a, b = pair_files[0], pair_files[1]
|
|
2213
2230
|
if a == b:
|
|
2214
2231
|
continue
|
|
@@ -2323,6 +2340,7 @@ async def loki_findings(iteration: int = -1) -> str:
|
|
|
2323
2340
|
return json.dumps(data)
|
|
2324
2341
|
reviews_dir = safe_path_join('.loki', 'quality', 'reviews')
|
|
2325
2342
|
if not os.path.exists(reviews_dir):
|
|
2343
|
+
_emit_tool_event_async('loki_findings', 'complete', result_status='success')
|
|
2326
2344
|
return json.dumps({"iteration": iteration, "review_id": None, "findings": []})
|
|
2327
2345
|
candidates = sorted([d for d in os.listdir(reviews_dir)
|
|
2328
2346
|
if d.startswith('review-')
|
|
@@ -2330,6 +2348,7 @@ async def loki_findings(iteration: int = -1) -> str:
|
|
|
2330
2348
|
if iteration >= 0:
|
|
2331
2349
|
candidates = [c for c in candidates if c.endswith(f'-{iteration}')]
|
|
2332
2350
|
if not candidates:
|
|
2351
|
+
_emit_tool_event_async('loki_findings', 'complete', result_status='success')
|
|
2333
2352
|
return json.dumps({"iteration": iteration, "review_id": None, "findings": []})
|
|
2334
2353
|
latest = candidates[-1]
|
|
2335
2354
|
review_path = safe_path_join('.loki', 'quality', 'reviews', latest)
|
|
@@ -2425,9 +2444,14 @@ async def loki_counter_evidence_template(iteration: int) -> str:
|
|
|
2425
2444
|
try:
|
|
2426
2445
|
findings_data = json.loads(await loki_findings(iteration=iteration))
|
|
2427
2446
|
if 'error' in findings_data:
|
|
2447
|
+
_emit_tool_event_async('loki_counter_evidence_template', 'complete',
|
|
2448
|
+
result_status='error',
|
|
2449
|
+
error=findings_data.get('error'))
|
|
2428
2450
|
return json.dumps(findings_data)
|
|
2429
2451
|
findings = findings_data.get('findings', [])
|
|
2430
2452
|
if not findings:
|
|
2453
|
+
_emit_tool_event_async('loki_counter_evidence_template', 'complete',
|
|
2454
|
+
result_status='success')
|
|
2431
2455
|
return json.dumps({"iteration": iteration, "template": None,
|
|
2432
2456
|
"message": f"No findings for iteration {iteration}."})
|
|
2433
2457
|
evidence = []
|
package/memory/consolidation.py
CHANGED
|
@@ -240,10 +240,22 @@ class ConsolidationPipeline:
|
|
|
240
240
|
self.storage.save_pattern(new_pattern)
|
|
241
241
|
new_patterns.append(new_pattern)
|
|
242
242
|
all_patterns.append(new_pattern)
|
|
243
|
+
# Add to existing_patterns so a later cluster pattern in
|
|
244
|
+
# this same run is deduped against it (mirrors the
|
|
245
|
+
# anti-pattern step below). Without this, two clusters
|
|
246
|
+
# producing >=0.8-similar patterns would both take the
|
|
247
|
+
# create branch, yielding near-duplicate patterns.
|
|
248
|
+
existing_patterns.append(new_pattern)
|
|
243
249
|
result.patterns_created += 1
|
|
244
250
|
|
|
245
251
|
# 6. Extract anti-patterns from failures
|
|
246
252
|
anti_patterns = self.extract_anti_patterns(failed_episodes)
|
|
253
|
+
# Track only anti-patterns that were persisted under their OWN id (the
|
|
254
|
+
# save_pattern branch). Merged anti-patterns are persisted under the
|
|
255
|
+
# existing pattern's id via update_pattern(merged_pattern); their own
|
|
256
|
+
# fresh uuid was never saved, so linking against it later would update
|
|
257
|
+
# a non-existent record (update_pattern -> False) and drop the links.
|
|
258
|
+
saved_anti_patterns = []
|
|
247
259
|
for anti_pattern in anti_patterns:
|
|
248
260
|
# Check if similar anti-pattern already exists
|
|
249
261
|
merged = False
|
|
@@ -264,18 +276,25 @@ class ConsolidationPipeline:
|
|
|
264
276
|
if not merged:
|
|
265
277
|
self.storage.save_pattern(anti_pattern)
|
|
266
278
|
all_patterns.append(anti_pattern)
|
|
279
|
+
saved_anti_patterns.append(anti_pattern)
|
|
267
280
|
# Add to existing_patterns so subsequent anti-patterns in this
|
|
268
281
|
# run are checked against it, preventing current-run duplicates.
|
|
269
282
|
existing_patterns.append(anti_pattern)
|
|
270
283
|
result.anti_patterns_created += 1
|
|
271
284
|
|
|
272
285
|
# 7. Create Zettelkasten links
|
|
273
|
-
|
|
286
|
+
# Only link patterns that were persisted under their own id this run
|
|
287
|
+
# (new_patterns from step 5 + saved_anti_patterns from step 6). Merged
|
|
288
|
+
# patterns already live under an existing id and were updated in place.
|
|
289
|
+
for pattern in new_patterns + saved_anti_patterns:
|
|
274
290
|
links = self.create_zettelkasten_links(pattern, all_patterns)
|
|
275
291
|
if links:
|
|
276
292
|
pattern.links.extend(links)
|
|
277
|
-
|
|
278
|
-
|
|
293
|
+
# Only count links that actually persisted. update_pattern()
|
|
294
|
+
# returns False when the target id is not on disk; counting
|
|
295
|
+
# unconditionally would inflate links_created.
|
|
296
|
+
if self.storage.update_pattern(pattern):
|
|
297
|
+
result.links_created += len(links)
|
|
279
298
|
|
|
280
299
|
# Flag vector indices as stale when patterns changed (BUG-MEM-007).
|
|
281
300
|
# Callers should rebuild vector indices when this flag is True to
|