loki-mode 7.81.0 → 7.82.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/completion-council.sh +88 -27
- package/autonomy/lib/config-map.sh +61 -15
- package/autonomy/loki +172 -27
- package/autonomy/run.sh +34 -13
- package/completions/_loki +63 -3
- package/completions/loki.bash +6 -3
- package/dashboard/__init__.py +1 -1
- package/dashboard/audit.py +36 -10
- package/dashboard/registry.py +67 -24
- package/dashboard/server.py +131 -74
- package/docs/INSTALLATION.md +2 -2
- package/events/bus.py +17 -1
- package/events/emit.sh +15 -1
- package/loki-ts/dist/loki.js +2 -2
- package/lokistore/cloud.py +17 -1
- package/lokistore/factory.py +6 -1
- package/lokistore/local.py +11 -3
- package/mcp/__init__.py +1 -1
- package/mcp/server.py +16 -4
- package/memory/cross_project.py +74 -5
- package/memory/embeddings.py +19 -1
- package/memory/retrieval.py +81 -153
- package/package.json +1 -1
- package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
- package/providers/codex.sh +9 -4
- package/memory/tree_index.py +0 -499
- package/memory/tree_search.py +0 -305
package/autonomy/run.sh
CHANGED
|
@@ -3979,9 +3979,18 @@ EOF
|
|
|
3979
3979
|
if [ -n "$BUDGET_LIMIT" ]; then
|
|
3980
3980
|
# Validate budget limit is numeric before writing JSON
|
|
3981
3981
|
if ! echo "$BUDGET_LIMIT" | grep -qE '^[0-9]+(\.[0-9]+)?$'; then
|
|
3982
|
-
log_warn "Invalid BUDGET_LIMIT '$BUDGET_LIMIT',
|
|
3983
|
-
BUDGET_LIMIT=
|
|
3982
|
+
log_warn "Invalid BUDGET_LIMIT '$BUDGET_LIMIT', ignoring (no cap set)"
|
|
3983
|
+
BUDGET_LIMIT=""
|
|
3984
3984
|
fi
|
|
3985
|
+
# Mirror the CLI guard: a non-positive cap (0/0.00) would make
|
|
3986
|
+
# check_budget_limit pause before any work runs. Treat it as "no cap"
|
|
3987
|
+
# rather than a silent pre-work pause.
|
|
3988
|
+
if [ -n "$BUDGET_LIMIT" ] && ! awk -v b="$BUDGET_LIMIT" 'BEGIN{exit !(b+0 > 0)}'; then
|
|
3989
|
+
log_warn "BUDGET_LIMIT '$BUDGET_LIMIT' is not greater than 0, ignoring (no cap set)"
|
|
3990
|
+
BUDGET_LIMIT=""
|
|
3991
|
+
fi
|
|
3992
|
+
fi
|
|
3993
|
+
if [ -n "$BUDGET_LIMIT" ]; then
|
|
3985
3994
|
cat > ".loki/metrics/budget.json" << BUDGET_EOF
|
|
3986
3995
|
{
|
|
3987
3996
|
"limit": $BUDGET_LIMIT,
|
|
@@ -10386,20 +10395,32 @@ check_primary_recovery() {
|
|
|
10386
10395
|
# Returns: 0 if rate limit detected, 1 otherwise
|
|
10387
10396
|
is_rate_limited() {
|
|
10388
10397
|
local log_file="$1"
|
|
10389
|
-
|
|
10390
|
-
|
|
10391
|
-
# -
|
|
10392
|
-
#
|
|
10393
|
-
# -
|
|
10394
|
-
# - "
|
|
10395
|
-
# -
|
|
10396
|
-
|
|
10397
|
-
|
|
10398
|
+
[ -f "$log_file" ] || return 1
|
|
10399
|
+
|
|
10400
|
+
# Only consider the TAIL of the log: a real provider rate-limit appears at the
|
|
10401
|
+
# END of the iteration (the call that failed), not buried in mid-run prose.
|
|
10402
|
+
# Scanning the whole file false-positived on the agent's OWN output (a build
|
|
10403
|
+
# that printed or generated rate-limiting code -- "rate limit", "429",
|
|
10404
|
+
# "retry-after" as source/text -- wrongly triggered a multi-minute wait).
|
|
10405
|
+
local tail_txt
|
|
10406
|
+
tail_txt=$(tail -n 40 "$log_file" 2>/dev/null) || return 1
|
|
10407
|
+
|
|
10408
|
+
# Require an ERROR CONTEXT, not a bare keyword: a rate-limit token must
|
|
10409
|
+
# co-occur (same line) with a genuine provider-error frame -- an explicit
|
|
10410
|
+
# error word ("Error"/"failed"/"exceeded"), an HTTP/status frame, or the
|
|
10411
|
+
# canonical "429 Too Many Requests" phrasing. This distinguishes a real
|
|
10412
|
+
# failing API line from the words "rate limit"/"retry-after"/"429" appearing
|
|
10413
|
+
# in the model's own generated source or prose. Note: a bare "429" or a bare
|
|
10414
|
+
# "retry-after" is NOT sufficient on its own (both occur in generated code).
|
|
10415
|
+
local _err='(error|errored|failed|exceeded|http[ /]?[0-9]|status[: ]+[0-9]|too many requests)'
|
|
10416
|
+
local _rl='(rate.?limit|too many requests|quota exceeded|request limit|429[ )"]*too many|retry.?after)'
|
|
10417
|
+
if printf '%s\n' "$tail_txt" | grep -qiE "(${_rl}).*(${_err})|(${_err}).*(${_rl})" 2>/dev/null; then
|
|
10398
10418
|
return 0
|
|
10399
10419
|
fi
|
|
10400
10420
|
|
|
10401
|
-
# Claude-specific: "resets Xam/pm"
|
|
10402
|
-
|
|
10421
|
+
# Claude-specific: the explicit "resets Xam/pm" reset-time line is itself an
|
|
10422
|
+
# unambiguous provider rate-limit signal (the CLI only prints it on a limit).
|
|
10423
|
+
if printf '%s\n' "$tail_txt" | grep -qE 'resets [0-9]+[ap]m' 2>/dev/null; then
|
|
10403
10424
|
return 0
|
|
10404
10425
|
fi
|
|
10405
10426
|
|
package/completions/_loki
CHANGED
|
@@ -67,8 +67,12 @@ function _loki {
|
|
|
67
67
|
_arguments \
|
|
68
68
|
'--follow[Follow logs in real-time]' \
|
|
69
69
|
'-f[Follow logs in real-time]' \
|
|
70
|
-
'--
|
|
71
|
-
'-n[Number of lines]:number:'
|
|
70
|
+
'--tail[Number of lines to show]:number:' \
|
|
71
|
+
'-n[Number of lines to show]:number:' \
|
|
72
|
+
'--all[Show all lines]' \
|
|
73
|
+
'-a[Show all lines]' \
|
|
74
|
+
'--help[Show help]' \
|
|
75
|
+
'-h[Show help]'
|
|
72
76
|
;;
|
|
73
77
|
issue)
|
|
74
78
|
_loki_issue
|
|
@@ -150,7 +154,63 @@ function _loki_commands {
|
|
|
150
154
|
'share:Share session report as GitHub Gist'
|
|
151
155
|
'proof:Inspect/share proof-of-run artifacts'
|
|
152
156
|
'context:Context window management'
|
|
157
|
+
'ctx:Context window management (alias)'
|
|
153
158
|
'code:Codebase intelligence'
|
|
159
|
+
'web:Start the web dashboard'
|
|
160
|
+
'plan:Preview the build plan for a spec'
|
|
161
|
+
'report:Reporting commands (cost, kpis, share)'
|
|
162
|
+
'cost:Show cost of recent runs'
|
|
163
|
+
'kpis:Key performance indicators'
|
|
164
|
+
'stats:Session statistics'
|
|
165
|
+
'preview:Open the running app preview'
|
|
166
|
+
'deploy:Advisory deploy command (print-only)'
|
|
167
|
+
'docker:Run Loki in a Docker container'
|
|
168
|
+
'mcp:MCP server commands'
|
|
169
|
+
'magic:Spec-driven component generation'
|
|
170
|
+
'assets:Export/import shareable team assets'
|
|
171
|
+
'spec:Living spec drift detection'
|
|
172
|
+
'verify:Deterministic PR verification'
|
|
173
|
+
'grill:Interrogate a spec before building'
|
|
174
|
+
'trust:Visible trust trajectory'
|
|
175
|
+
'trust-metrics:Trust metrics report'
|
|
176
|
+
'why:Explain the last build outcome'
|
|
177
|
+
'heal:Legacy system healing'
|
|
178
|
+
'modernize:Modernize a legacy system'
|
|
179
|
+
'migrate:Migration commands'
|
|
180
|
+
'analyze:Analyze a codebase'
|
|
181
|
+
'compliance:Compliance reporting'
|
|
182
|
+
'crash:Crash report management'
|
|
183
|
+
'otel:OpenTelemetry management'
|
|
184
|
+
'wiki:Wiki commands'
|
|
185
|
+
'explain:Explain Loki concepts'
|
|
186
|
+
'optimize:Optimization commands'
|
|
187
|
+
'review:Code review commands'
|
|
188
|
+
'export:Export session data'
|
|
189
|
+
'test:Run tests'
|
|
190
|
+
'ci:CI helpers'
|
|
191
|
+
'watch:Watch mode'
|
|
192
|
+
'audit:Audit commands'
|
|
193
|
+
'syslog:System log commands'
|
|
194
|
+
'cluster:Cluster commands'
|
|
195
|
+
'failover:Failover commands'
|
|
196
|
+
'remote:Remote execution commands'
|
|
197
|
+
'worktree:Git worktree commands'
|
|
198
|
+
'wt:Git worktree commands (alias)'
|
|
199
|
+
'trigger:Trigger commands'
|
|
200
|
+
'cleanup:Clean up Loki state'
|
|
201
|
+
'rollback:Roll back a change'
|
|
202
|
+
'update:Update Loki Mode'
|
|
203
|
+
'self-update:Update Loki Mode'
|
|
204
|
+
'setup-skill:Set up the Claude Code skill'
|
|
205
|
+
'quickstart:Quickstart guide'
|
|
206
|
+
'sentrux:Sentrux integration'
|
|
207
|
+
'state:Inspect run state'
|
|
208
|
+
'template:PRD template commands'
|
|
209
|
+
'ultracode:Ultra code review'
|
|
210
|
+
'voice:Voice commands'
|
|
211
|
+
'bench:Run benchmarks'
|
|
212
|
+
'open:Open a project resource'
|
|
213
|
+
'rc:Release candidate commands'
|
|
154
214
|
'version:Show version'
|
|
155
215
|
'completions:Output shell completions'
|
|
156
216
|
'help:Show help'
|
|
@@ -160,7 +220,7 @@ function _loki_commands {
|
|
|
160
220
|
|
|
161
221
|
function _loki_start {
|
|
162
222
|
_arguments \
|
|
163
|
-
'--provider[AI provider]:provider name:(claude codex
|
|
223
|
+
'--provider[AI provider]:provider name:(claude codex cline aider)' \
|
|
164
224
|
'--parallel[Parallel mode]' \
|
|
165
225
|
'--background[Background mode]' \
|
|
166
226
|
'--bg[Background mode]' \
|
package/completions/loki.bash
CHANGED
|
@@ -5,7 +5,7 @@ _loki_completion() {
|
|
|
5
5
|
_init_completion || return
|
|
6
6
|
|
|
7
7
|
# Main subcommands (must match autonomy/loki main case statement)
|
|
8
|
-
local main_commands="start quick monitor demo init stop pause resume status dashboard
|
|
8
|
+
local main_commands="start quick monitor demo init stop pause resume status dashboard web serve api sandbox notify import github issue config provider reset memory compound checkpoint council dogfood projects enterprise secrets doctor watchdog audit metrics syslog onboard share proof explain plan report cost kpis stats test ci watch telemetry agent context ctx code run export review optimize heal modernize migrate cluster worktree wt trigger failover remote deploy docker mcp magic assets analyze compliance crash open otel preview quickstart rc rollback self-update sentrux setup-skill spec state template trust trust-metrics ultracode update verify voice why wiki bench cleanup logs grill docs cp version completions help"
|
|
9
9
|
|
|
10
10
|
# 1. If we are on the first argument (subcommand)
|
|
11
11
|
if [[ $cword -eq 1 ]]; then
|
|
@@ -18,7 +18,10 @@ _loki_completion() {
|
|
|
18
18
|
start)
|
|
19
19
|
# If the previous word was --provider, show provider names
|
|
20
20
|
if [[ "$prev" == "--provider" ]]; then
|
|
21
|
-
|
|
21
|
+
# Active providers (loki rejects gemini since v7.5.18; cline/aider
|
|
22
|
+
# are supported). Keep in sync with providers/loader.sh
|
|
23
|
+
# SUPPORTED_PROVIDERS.
|
|
24
|
+
COMPREPLY=( $(compgen -W "claude codex cline aider" -- "$cur") )
|
|
22
25
|
return 0
|
|
23
26
|
fi
|
|
24
27
|
|
|
@@ -119,7 +122,7 @@ _loki_completion() {
|
|
|
119
122
|
|
|
120
123
|
logs)
|
|
121
124
|
if [[ "$cur" == -* ]]; then
|
|
122
|
-
COMPREPLY=( $(compgen -W "--
|
|
125
|
+
COMPREPLY=( $(compgen -W "--tail -n --all -a --follow -f --help -h" -- "$cur") )
|
|
123
126
|
return 0
|
|
124
127
|
fi
|
|
125
128
|
;;
|
package/dashboard/__init__.py
CHANGED
package/dashboard/audit.py
CHANGED
|
@@ -21,6 +21,7 @@ import logging.handlers
|
|
|
21
21
|
import os
|
|
22
22
|
import socket
|
|
23
23
|
import sys
|
|
24
|
+
import threading
|
|
24
25
|
from datetime import datetime, timezone
|
|
25
26
|
from pathlib import Path
|
|
26
27
|
from typing import Any, Optional
|
|
@@ -47,6 +48,16 @@ _SYSLOG_PROTO = os.environ.get("LOKI_AUDIT_SYSLOG_PROTO", "udp").lower().strip()
|
|
|
47
48
|
INTEGRITY_ENABLED = os.environ.get("LOKI_AUDIT_NO_INTEGRITY", "").lower() not in ("true", "1", "yes")
|
|
48
49
|
_last_hash: str = "0" * 64 # Genesis hash
|
|
49
50
|
|
|
51
|
+
# Serializes the chain read-modify-write + file append in log_event(). Without
|
|
52
|
+
# it, concurrent callers (the dashboard fans audit writes out across async
|
|
53
|
+
# handlers / asyncio.to_thread workers) interleave the unsynchronized
|
|
54
|
+
# _last_hash RMW with the file append: lines get written in a different order
|
|
55
|
+
# than the hashes were chained, which breaks the tamper-evident chain so
|
|
56
|
+
# verify_all_logs() reports valid:False even though no entry was tampered with.
|
|
57
|
+
# Holding this lock makes "compute hash, update _last_hash, append the line" a
|
|
58
|
+
# single atomic step so on-disk line order always matches chain order.
|
|
59
|
+
_hash_lock = threading.Lock()
|
|
60
|
+
|
|
50
61
|
|
|
51
62
|
def _recover_last_hash() -> str:
|
|
52
63
|
"""Recover the last integrity hash from the most recent audit log file.
|
|
@@ -254,20 +265,35 @@ def log_event(
|
|
|
254
265
|
"details": details or {},
|
|
255
266
|
}
|
|
256
267
|
|
|
257
|
-
# Tamper-evident chain hash
|
|
268
|
+
# Tamper-evident chain hash + file append, serialized as one atomic step.
|
|
269
|
+
#
|
|
270
|
+
# The chain hash is a read-modify-write of the module-global _last_hash, and
|
|
271
|
+
# the line must land in the file in the same order the hashes were chained.
|
|
272
|
+
# _hash_lock guards the whole "compute hash -> update _last_hash -> append"
|
|
273
|
+
# critical section so concurrent callers cannot interleave and break the
|
|
274
|
+
# chain (see _hash_lock definition above).
|
|
275
|
+
#
|
|
276
|
+
# NOTE for async callers: log_event() does blocking file I/O while holding
|
|
277
|
+
# this lock. When called from an asyncio handler it SHOULD be offloaded with
|
|
278
|
+
# `await asyncio.to_thread(audit.log_event, ...)` so a slow disk does not
|
|
279
|
+
# stall the dashboard event loop. The thread-safety here is what makes that
|
|
280
|
+
# offload safe; rewriting every call site to async is a separate, larger
|
|
281
|
+
# change and is intentionally not done here.
|
|
258
282
|
global _last_hash
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
283
|
+
with _hash_lock:
|
|
284
|
+
if INTEGRITY_ENABLED:
|
|
285
|
+
entry_json = json.dumps(entry, sort_keys=True, default=str)
|
|
286
|
+
entry["_integrity_hash"] = _compute_chain_hash(entry_json, _last_hash)
|
|
287
|
+
_last_hash = entry["_integrity_hash"]
|
|
263
288
|
|
|
264
|
-
|
|
265
|
-
|
|
289
|
+
log_file = _get_current_log_file()
|
|
290
|
+
_rotate_logs_if_needed(log_file)
|
|
266
291
|
|
|
267
|
-
|
|
268
|
-
|
|
292
|
+
with open(log_file, "a") as f:
|
|
293
|
+
f.write(json.dumps(entry) + "\n")
|
|
269
294
|
|
|
270
|
-
# Forward to syslog if configured
|
|
295
|
+
# Forward to syslog if configured (outside the lock: fire-and-forget and
|
|
296
|
+
# must never extend the critical section / block other writers).
|
|
271
297
|
_forward_to_syslog(entry)
|
|
272
298
|
|
|
273
299
|
return entry
|
package/dashboard/registry.py
CHANGED
|
@@ -140,6 +140,9 @@ def register_project(
|
|
|
140
140
|
path: str,
|
|
141
141
|
name: Optional[str] = None,
|
|
142
142
|
alias: Optional[str] = None,
|
|
143
|
+
pid: Optional[int] = None,
|
|
144
|
+
port: Optional[int] = None,
|
|
145
|
+
status: Optional[str] = None,
|
|
143
146
|
) -> dict:
|
|
144
147
|
"""
|
|
145
148
|
Register a project in the registry.
|
|
@@ -148,6 +151,18 @@ def register_project(
|
|
|
148
151
|
path: Absolute path to the project directory
|
|
149
152
|
name: Display name (defaults to directory name)
|
|
150
153
|
alias: Short alias for CLI usage
|
|
154
|
+
pid: Live runtime pid for the project's current build (optional)
|
|
155
|
+
port: Live runtime port for the project's current build (optional)
|
|
156
|
+
status: Runtime status override, e.g. "active" / "running" (optional)
|
|
157
|
+
|
|
158
|
+
The pid / port / status kwargs let a caller register a project AND set its
|
|
159
|
+
runtime fields in one atomic, locked load->mutate->save. Before this, the
|
|
160
|
+
runner had to call register_project() and then do a SECOND, unlocked
|
|
161
|
+
load-mutate-save to stamp pid/port/status, which lost-updates a concurrent
|
|
162
|
+
writer (two writers each load the registry, set their own runtime fields,
|
|
163
|
+
and save -- the last save wins and silently drops the other's entry). Set
|
|
164
|
+
these here so the whole change happens inside _registry_lock(). When all
|
|
165
|
+
three are omitted the behavior is unchanged (backward compatible).
|
|
151
166
|
|
|
152
167
|
Returns:
|
|
153
168
|
The registered project entry
|
|
@@ -188,6 +203,16 @@ def register_project(
|
|
|
188
203
|
}
|
|
189
204
|
registry["projects"][project_id] = project
|
|
190
205
|
|
|
206
|
+
# Atomically stamp runtime fields inside the same lock (both the create
|
|
207
|
+
# and update branches). None means "leave as-is" so omitting these
|
|
208
|
+
# keeps the legacy behavior.
|
|
209
|
+
if pid is not None:
|
|
210
|
+
project["pid"] = pid
|
|
211
|
+
if port is not None:
|
|
212
|
+
project["port"] = port
|
|
213
|
+
if status is not None:
|
|
214
|
+
project["status"] = status
|
|
215
|
+
|
|
191
216
|
_save_registry(registry)
|
|
192
217
|
return project
|
|
193
218
|
|
|
@@ -438,7 +463,6 @@ def sync_registry_with_discovery() -> dict:
|
|
|
438
463
|
Returns:
|
|
439
464
|
Summary of sync results
|
|
440
465
|
"""
|
|
441
|
-
registry = _load_registry()
|
|
442
466
|
discovered = discover_projects()
|
|
443
467
|
|
|
444
468
|
# Track results
|
|
@@ -446,31 +470,50 @@ def sync_registry_with_discovery() -> dict:
|
|
|
446
470
|
updated = []
|
|
447
471
|
missing = []
|
|
448
472
|
|
|
449
|
-
#
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
# New project
|
|
458
|
-
project = register_project(path)
|
|
459
|
-
added.append(project)
|
|
460
|
-
else:
|
|
461
|
-
# Update existing
|
|
462
|
-
project = registry["projects"][project_id]
|
|
463
|
-
project["has_loki_dir"] = True
|
|
464
|
-
project["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
465
|
-
updated.append(project)
|
|
473
|
+
# Mutate a single registry copy under the lock and save it ONCE at the end.
|
|
474
|
+
# Previously this loaded the registry, then called register_project() in the
|
|
475
|
+
# loop (which loads+mutates+saves its OWN copy), and finally saved the stale
|
|
476
|
+
# original over the top -- silently dropping every newly added project (the
|
|
477
|
+
# summary said "added: N" while the on-disk registry kept zero of them). All
|
|
478
|
+
# mutations now happen on the same in-memory dict that gets persisted.
|
|
479
|
+
with _registry_lock():
|
|
480
|
+
registry = _load_registry()
|
|
466
481
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
482
|
+
# Add/update discovered projects
|
|
483
|
+
for project_info in discovered:
|
|
484
|
+
path = project_info["path"]
|
|
485
|
+
project_id = _generate_project_id(path)
|
|
486
|
+
|
|
487
|
+
if project_id not in registry["projects"]:
|
|
488
|
+
# New project
|
|
489
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
490
|
+
project = {
|
|
491
|
+
"id": project_id,
|
|
492
|
+
"path": path,
|
|
493
|
+
"name": os.path.basename(path),
|
|
494
|
+
"alias": None,
|
|
495
|
+
"registered_at": now,
|
|
496
|
+
"updated_at": now,
|
|
497
|
+
"last_accessed": None,
|
|
498
|
+
"has_loki_dir": os.path.isdir(os.path.join(path, ".loki")),
|
|
499
|
+
"status": "active",
|
|
500
|
+
}
|
|
501
|
+
registry["projects"][project_id] = project
|
|
502
|
+
added.append(project)
|
|
503
|
+
else:
|
|
504
|
+
# Update existing
|
|
505
|
+
project = registry["projects"][project_id]
|
|
506
|
+
project["has_loki_dir"] = True
|
|
507
|
+
project["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
508
|
+
updated.append(project)
|
|
472
509
|
|
|
473
|
-
|
|
510
|
+
# Check for missing projects
|
|
511
|
+
for project_id, project in registry["projects"].items():
|
|
512
|
+
if not os.path.isdir(project["path"]):
|
|
513
|
+
project["status"] = "missing"
|
|
514
|
+
missing.append(project)
|
|
515
|
+
|
|
516
|
+
_save_registry(registry)
|
|
474
517
|
|
|
475
518
|
return {
|
|
476
519
|
"added": len(added),
|