loki-mode 6.4.0 → 6.5.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/README.md CHANGED
@@ -9,7 +9,7 @@
9
9
  [![Agent Types](https://img.shields.io/badge/Agent%20Types-41-blue)]()
10
10
  [![Autonomi](https://img.shields.io/badge/Autonomi-autonomi.dev-5B4EEA)](https://www.autonomi.dev/)
11
11
 
12
- **Current Version: v6.2.1**
12
+ **Current Version: v6.5.0**
13
13
 
14
14
  ---
15
15
 
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v6.4.0
6
+ # Loki Mode v6.5.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -263,4 +263,4 @@ The following features are documented in skill modules but not yet fully automat
263
263
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
264
264
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
265
265
 
266
- **v6.4.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
266
+ **v6.5.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.4.0
1
+ 6.5.0
@@ -52,7 +52,7 @@ load_migration_hook_config() {
52
52
  # Parse YAML config safely using read/declare instead of eval
53
53
  while IFS='=' read -r key val; do
54
54
  case "$key" in
55
- HOOK_*) declare -g "$key=$val" ;;
55
+ HOOK_*) printf -v "$key" '%s' "$val" ;;
56
56
  esac
57
57
  done < <(python3 -c "
58
58
  import sys
@@ -224,7 +224,7 @@ try:
224
224
  print(len([s for s in steps if s.get('status') != 'completed']))
225
225
  except: print(-1)
226
226
  " 2>/dev/null || echo -1)
227
- [[ "$pending" -gt 0 ]] && echo "GATE_BLOCKED: ${pending} steps still pending" && return 1
227
+ [[ "$pending" -ne 0 ]] && echo "GATE_BLOCKED: ${pending} steps still pending (or plan missing)" && return 1
228
228
  ;;
229
229
  esac
230
230
 
@@ -236,6 +236,10 @@ hook_on_agent_stop() {
236
236
  local features_path="${LOKI_FEATURES_PATH:-}"
237
237
 
238
238
  [[ "$HOOK_ON_AGENT_STOP_ENABLED" != "true" ]] && return 0
239
+ if [[ -z "$features_path" ]]; then
240
+ echo "HOOK_BLOCKED: LOKI_FEATURES_PATH not set. Cannot verify features."
241
+ return 1
242
+ fi
239
243
  [[ ! -f "$features_path" ]] && return 0
240
244
 
241
245
  local failing
package/autonomy/loki CHANGED
@@ -1356,7 +1356,7 @@ cmd_status() {
1356
1356
  [ -n "$line" ] && running_sessions+=("$line")
1357
1357
  done < <(list_running_sessions 2>/dev/null)
1358
1358
 
1359
- if [ ${#running_sessions[@]} -gt 1 ]; then
1359
+ if [ ${#running_sessions[@]} -gt 0 ]; then
1360
1360
  echo -e "${GREEN}Active Sessions: ${#running_sessions[@]}${NC}"
1361
1361
  for entry in "${running_sessions[@]}"; do
1362
1362
  local sid="${entry%%:*}"
@@ -2092,7 +2092,7 @@ cmd_dashboard_stop() {
2092
2092
  local wait_count=0
2093
2093
  while kill -0 "$pid" 2>/dev/null && [ $wait_count -lt 10 ]; do
2094
2094
  sleep 0.5
2095
- ((wait_count++))
2095
+ wait_count=$((wait_count + 1))
2096
2096
  done
2097
2097
 
2098
2098
  # Force kill if still running
@@ -2874,20 +2874,17 @@ cmd_run() {
2874
2874
  --pr)
2875
2875
  use_worktree=true
2876
2876
  create_pr=true
2877
- start_args+=("--parallel")
2878
2877
  shift
2879
2878
  ;;
2880
2879
  --ship)
2881
2880
  use_worktree=true
2882
2881
  create_pr=true
2883
2882
  auto_merge=true
2884
- start_args+=("--parallel")
2885
2883
  shift
2886
2884
  ;;
2887
2885
  --detach|-d)
2888
2886
  use_worktree=true
2889
2887
  run_detached=true
2890
- start_args+=("--parallel")
2891
2888
  shift
2892
2889
  ;;
2893
2890
  -*)
@@ -2904,6 +2901,11 @@ cmd_run() {
2904
2901
  esac
2905
2902
  done
2906
2903
 
2904
+ # Add --parallel once if worktree mode is enabled (not per-flag)
2905
+ if $use_worktree; then
2906
+ start_args+=("--parallel")
2907
+ fi
2908
+
2907
2909
  if [[ -z "$issue_ref" ]]; then
2908
2910
  echo -e "${RED}Error: Issue reference required${NC}"
2909
2911
  echo ""
@@ -2971,6 +2973,13 @@ cmd_run() {
2971
2973
 
2972
2974
  # Detached mode: fork to background
2973
2975
  if $run_detached; then
2976
+ # Guard: prevent launching duplicate session
2977
+ if is_session_running "$session_id"; then
2978
+ echo -e "${RED}Error: Session '$session_id' is already running.${NC}"
2979
+ echo -e "Stop it first with: ${CYAN}loki stop $session_id${NC}"
2980
+ exit 1
2981
+ fi
2982
+
2974
2983
  local log_file="$LOKI_DIR/logs/run-${number:-$(date +%s)}.log"
2975
2984
  mkdir -p "$(dirname "$log_file")"
2976
2985
  echo -e "${GREEN}Running detached. Logs: $log_file${NC}"
@@ -2985,32 +2994,51 @@ cmd_run() {
2985
2994
  branch_name="issue/detach-$(date +%s)"
2986
2995
  fi
2987
2996
 
2988
- nohup bash -c "
2989
- cd $(pwd)
2990
- export LOKI_DETACHED=true
2991
- export LOKI_PARALLEL_MODE=true
2992
- export LOKI_SESSION_ID=\"$session_id\"
2993
- export LOKI_WORKTREE_BRANCH=\"$branch_name\"
2994
- $(command -v loki || echo "$0") start \"$detach_prd\" --parallel ${start_args[*]+"${start_args[*]}"}
2995
-
2996
- # Post-completion: create PR if requested
2997
- if [[ \"$create_pr\" == \"true\" ]]; then
2998
- branch_current=\$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo \"\")
2999
- if [[ -n \"\$branch_current\" && \"\$branch_current\" != \"main\" && \"\$branch_current\" != \"master\" ]]; then
3000
- git push origin \"\$branch_current\" 2>/dev/null || true
3001
- gh pr create --title \"${title:-Implementation for issue ${issue_ref}}\" --body \"Implemented by Loki Mode\" --head \"\$branch_current\" 2>/dev/null || true
3002
- fi
3003
- fi
3004
- # Post-completion: auto-merge if requested
3005
- if [[ \"$auto_merge\" == \"true\" ]]; then
3006
- branch_current=\$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo \"\")
3007
- if gh pr merge \"\$branch_current\" --squash --delete-branch 2>/dev/null; then
3008
- if [[ -n \"${number:-}\" ]]; then
3009
- gh issue close \"$number\" --comment \"Resolved by Loki Mode\" 2>/dev/null || true
3010
- fi
3011
- fi
3012
- fi
3013
- " > "$log_file" 2>&1 &
2997
+ # Write a temp script to avoid shell injection via variable interpolation
2998
+ local run_script="$LOKI_DIR/scripts/run-${number:-detached}.sh"
2999
+ mkdir -p "$LOKI_DIR/scripts"
3000
+ local loki_cmd
3001
+ loki_cmd="$(command -v loki || echo "$0")"
3002
+ cat > "$run_script" << 'INNER_SCRIPT_EOF'
3003
+ #!/usr/bin/env bash
3004
+ set -euo pipefail
3005
+ cd "$LOKI_RUN_DIR"
3006
+ export LOKI_DETACHED=true
3007
+ export LOKI_PARALLEL_MODE=true
3008
+ "$LOKI_CMD" start "$LOKI_PRD_PATH" ${LOKI_START_ARGS:-}
3009
+
3010
+ # Post-completion: create PR if requested
3011
+ if [[ "$LOKI_CREATE_PR" == "true" ]]; then
3012
+ branch_current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
3013
+ if [[ -n "$branch_current" && "$branch_current" != "main" && "$branch_current" != "master" ]]; then
3014
+ git push origin "$branch_current" 2>/dev/null || true
3015
+ gh pr create --title "$LOKI_PR_TITLE" --body "Implemented by Loki Mode" --head "$branch_current" 2>/dev/null || true
3016
+ fi
3017
+ fi
3018
+ # Post-completion: auto-merge if requested
3019
+ if [[ "$LOKI_AUTO_MERGE" == "true" ]]; then
3020
+ branch_current=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
3021
+ if gh pr merge "$branch_current" --squash --delete-branch 2>/dev/null; then
3022
+ if [[ -n "${LOKI_ISSUE_NUMBER:-}" ]]; then
3023
+ gh issue close "$LOKI_ISSUE_NUMBER" --comment "Resolved by Loki Mode" 2>/dev/null || true
3024
+ fi
3025
+ fi
3026
+ fi
3027
+ INNER_SCRIPT_EOF
3028
+ chmod +x "$run_script"
3029
+
3030
+ # Pass all variables safely via environment
3031
+ LOKI_RUN_DIR="$(pwd)" \
3032
+ LOKI_CMD="$loki_cmd" \
3033
+ LOKI_SESSION_ID="$session_id" \
3034
+ LOKI_WORKTREE_BRANCH="$branch_name" \
3035
+ LOKI_PRD_PATH="$detach_prd" \
3036
+ LOKI_START_ARGS="${start_args[*]+"${start_args[*]}"}" \
3037
+ LOKI_CREATE_PR="$create_pr" \
3038
+ LOKI_AUTO_MERGE="$auto_merge" \
3039
+ LOKI_PR_TITLE="${title:-Implementation for issue ${issue_ref}}" \
3040
+ LOKI_ISSUE_NUMBER="${number:-}" \
3041
+ nohup bash "$run_script" > "$log_file" 2>&1 &
3014
3042
 
3015
3043
  local bg_pid=$!
3016
3044
  echo "$bg_pid" > "$LOKI_DIR/run-${number:-detached}.pid"
@@ -4946,7 +4974,7 @@ cmd_notify_test() {
4946
4974
  echo -n " Slack... "
4947
4975
  if send_slack_notification "$message" "Test"; then
4948
4976
  echo -e "${GREEN}OK${NC}"
4949
- ((channels_notified++))
4977
+ channels_notified=$((channels_notified + 1))
4950
4978
  else
4951
4979
  echo -e "${RED}FAILED${NC}"
4952
4980
  fi
@@ -4959,7 +4987,7 @@ cmd_notify_test() {
4959
4987
  echo -n " Discord... "
4960
4988
  if send_discord_notification "$message" "Test"; then
4961
4989
  echo -e "${GREEN}OK${NC}"
4962
- ((channels_notified++))
4990
+ channels_notified=$((channels_notified + 1))
4963
4991
  else
4964
4992
  echo -e "${RED}FAILED${NC}"
4965
4993
  fi
@@ -4972,7 +5000,7 @@ cmd_notify_test() {
4972
5000
  echo -n " Webhook... "
4973
5001
  if send_webhook_notification "$message" "Test"; then
4974
5002
  echo -e "${GREEN}OK${NC}"
4975
- ((channels_notified++))
5003
+ channels_notified=$((channels_notified + 1))
4976
5004
  else
4977
5005
  echo -e "${RED}FAILED${NC}"
4978
5006
  fi
@@ -6691,7 +6719,7 @@ Tasks:
6691
6719
  local provider_name="${LOKI_PROVIDER:-claude}"
6692
6720
  case "$provider_name" in
6693
6721
  claude)
6694
- (cd "$codebase_path" && claude --dangerously-skip-permissions -p "$phase_prompt" --output-format stream-json --verbose 2>&1) | \
6722
+ { (cd "$codebase_path" && claude --dangerously-skip-permissions -p "$phase_prompt" --output-format stream-json --verbose 2>&1) | \
6695
6723
  while IFS= read -r line; do
6696
6724
  # Extract text from stream-json
6697
6725
  if echo "$line" | python3 -c "
@@ -6706,8 +6734,7 @@ except Exception: pass
6706
6734
  " 2>/dev/null; then
6707
6735
  true
6708
6736
  fi
6709
- done
6710
- phase_exit=${PIPESTATUS[0]}
6737
+ done; } && phase_exit=0 || phase_exit=$?
6711
6738
  ;;
6712
6739
  codex)
6713
6740
  (cd "$codebase_path" && codex exec --full-auto "$phase_prompt" 2>&1) || phase_exit=$?
@@ -6854,7 +6881,7 @@ IMPORTANT RULES:
6854
6881
  local provider_name="${LOKI_PROVIDER:-claude}"
6855
6882
  case "$provider_name" in
6856
6883
  claude)
6857
- (cd "$codebase_path" && claude --dangerously-skip-permissions -p "$doc_prompt" --output-format stream-json --verbose 2>&1) | \
6884
+ { (cd "$codebase_path" && claude --dangerously-skip-permissions -p "$doc_prompt" --output-format stream-json --verbose 2>&1) | \
6858
6885
  while IFS= read -r line; do
6859
6886
  if echo "$line" | python3 -c "
6860
6887
  import sys, json
@@ -6868,8 +6895,7 @@ except Exception: pass
6868
6895
  " 2>/dev/null; then
6869
6896
  true
6870
6897
  fi
6871
- done
6872
- doc_exit=${PIPESTATUS[0]}
6898
+ done; } && doc_exit=0 || doc_exit=$?
6873
6899
  ;;
6874
6900
  codex)
6875
6901
  (cd "$codebase_path" && codex exec --full-auto "$doc_prompt" 2>&1) || doc_exit=$?
package/autonomy/run.sh CHANGED
@@ -7434,12 +7434,13 @@ run_autonomous() {
7434
7434
  audit_agent_action "cli_invoke" "Starting iteration $ITERATION_COUNT" "provider=${PROVIDER_NAME:-claude},tier=$CURRENT_TIER"
7435
7435
 
7436
7436
  # Provider-specific invocation with dynamic tier selection
7437
+ local exit_code=0
7437
7438
  case "${PROVIDER_NAME:-claude}" in
7438
7439
  claude)
7439
7440
  # Claude: Full features with stream-json output and agent tracking
7440
7441
  # Uses dynamic tier for model selection based on RARV phase
7441
7442
  # Pass tier to Python via environment for dashboard display
7442
- LOKI_CURRENT_MODEL="$tier_param" \
7443
+ { LOKI_CURRENT_MODEL="$tier_param" \
7443
7444
  claude --dangerously-skip-permissions --model "$tier_param" -p "$prompt" \
7444
7445
  --output-format stream-json --verbose 2>&1 | \
7445
7446
  tee -a "$log_file" "$agent_log" | \
@@ -7647,7 +7648,7 @@ if __name__ == "__main__":
7647
7648
  except BrokenPipeError:
7648
7649
  sys.exit(0)
7649
7650
  '
7650
- local exit_code=${PIPESTATUS[0]}
7651
+ } && exit_code=0 || exit_code=$?
7651
7652
  ;;
7652
7653
 
7653
7654
  codex)
@@ -7655,10 +7656,10 @@ if __name__ == "__main__":
7655
7656
  # Uses positional prompt after exec subcommand
7656
7657
  # Note: Effort is set via env var, not CLI flag
7657
7658
  # Uses dynamic tier from RARV phase (tier_param already set above)
7658
- CODEX_MODEL_REASONING_EFFORT="$tier_param" \
7659
+ { CODEX_MODEL_REASONING_EFFORT="$tier_param" \
7659
7660
  codex exec --full-auto \
7660
- "$prompt" 2>&1 | tee -a "$log_file" "$agent_log"
7661
- local exit_code=${PIPESTATUS[0]}
7661
+ "$prompt" 2>&1 | tee -a "$log_file" "$agent_log"; \
7662
+ } && exit_code=0 || exit_code=$?
7662
7663
  ;;
7663
7664
 
7664
7665
  gemini)
@@ -7672,8 +7673,8 @@ if __name__ == "__main__":
7672
7673
  # Try primary model, fallback on rate limit
7673
7674
  local tmp_output
7674
7675
  tmp_output=$(mktemp)
7675
- gemini --approval-mode=yolo --model "$model" "$prompt" < /dev/null 2>&1 | tee "$tmp_output" | tee -a "$log_file" "$agent_log"
7676
- local exit_code=${PIPESTATUS[0]}
7676
+ { gemini --approval-mode=yolo --model "$model" "$prompt" < /dev/null 2>&1 | tee "$tmp_output" | tee -a "$log_file" "$agent_log"; \
7677
+ } && exit_code=0 || exit_code=$?
7677
7678
 
7678
7679
  if [[ $exit_code -ne 0 ]] && grep -qiE "(rate.?limit|429|quota|resource.?exhausted)" "$tmp_output"; then
7679
7680
  log_warn "Rate limit hit on $model, falling back to $fallback"
@@ -8143,17 +8144,18 @@ except (json.JSONDecodeError, OSError): pass
8143
8144
  stop_dashboard
8144
8145
  stop_status_monitor
8145
8146
  kill_all_registered
8146
- rm -f .loki/loki.pid .loki/PAUSE 2>/dev/null
8147
+ rm -f "$loki_dir/loki.pid" "$loki_dir/PAUSE" 2>/dev/null
8147
8148
  # Clean up per-session PID file if running with session ID
8148
8149
  if [ -n "${LOKI_SESSION_ID:-}" ]; then
8149
- rm -f ".loki/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null
8150
+ rm -f "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null
8150
8151
  fi
8151
8152
  # Mark session.json as stopped
8152
- if [ -f ".loki/session.json" ]; then
8153
- python3 -c "
8154
- import json
8153
+ if [ -f "$loki_dir/session.json" ]; then
8154
+ _LOKI_SESSION_FILE="$loki_dir/session.json" python3 -c "
8155
+ import json, os
8156
+ sf = os.environ['_LOKI_SESSION_FILE']
8155
8157
  try:
8156
- with open('.loki/session.json', 'r+') as f:
8158
+ with open(sf, 'r+') as f:
8157
8159
  d = json.load(f); d['status'] = 'stopped'
8158
8160
  f.seek(0); f.truncate(); json.dump(d, f)
8159
8161
  except (json.JSONDecodeError, OSError): pass
@@ -8736,17 +8738,19 @@ main() {
8736
8738
  fi
8737
8739
  stop_dashboard
8738
8740
  stop_status_monitor
8739
- rm -f .loki/loki.pid 2>/dev/null
8741
+ local loki_dir="${TARGET_DIR:-.}/.loki"
8742
+ rm -f "$loki_dir/loki.pid" 2>/dev/null
8740
8743
  # Clean up per-session PID file if running with session ID
8741
8744
  if [ -n "${LOKI_SESSION_ID:-}" ]; then
8742
- rm -f ".loki/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null
8745
+ rm -f "$loki_dir/sessions/${LOKI_SESSION_ID}/loki.pid" 2>/dev/null
8743
8746
  fi
8744
8747
  # Mark session.json as stopped
8745
- if [ -f ".loki/session.json" ]; then
8746
- python3 -c "
8747
- import json
8748
+ if [ -f "$loki_dir/session.json" ]; then
8749
+ _LOKI_SESSION_FILE="$loki_dir/session.json" python3 -c "
8750
+ import json, os
8751
+ sf = os.environ['_LOKI_SESSION_FILE']
8748
8752
  try:
8749
- with open('.loki/session.json', 'r+') as f:
8753
+ with open(sf, 'r+') as f:
8750
8754
  d = json.load(f); d['status'] = 'stopped'
8751
8755
  f.seek(0); f.truncate(); json.dump(d, f)
8752
8756
  except (json.JSONDecodeError, OSError): pass
@@ -68,28 +68,32 @@ PROMPT_INJECTION_ENABLED="${LOKI_PROMPT_INJECTION:-false}"
68
68
 
69
69
  # Preset definitions: "host_path:container_path:mode"
70
70
  # Container runs as user 'loki' (UID 1000), so mount to /home/loki/
71
- declare -A DOCKER_MOUNT_PRESETS
72
- DOCKER_MOUNT_PRESETS=(
73
- # Note: gh and git are also hardcoded in start_sandbox() defaults
74
- [gh]="$HOME/.config/gh:/home/loki/.config/gh:ro"
75
- [git]="$HOME/.gitconfig:/home/loki/.gitconfig:ro"
76
- [ssh]="$HOME/.ssh/known_hosts:/home/loki/.ssh/known_hosts:ro"
77
- [aws]="$HOME/.aws:/home/loki/.aws:ro"
78
- [azure]="$HOME/.azure:/home/loki/.azure:ro"
79
- [kube]="$HOME/.kube:/home/loki/.kube:ro"
80
- [terraform]="$HOME/.terraform.d:/home/loki/.terraform.d:ro"
81
- [gcloud]="$HOME/.config/gcloud:/home/loki/.config/gcloud:ro"
82
- [npm]="$HOME/.npmrc:/home/loki/.npmrc:ro"
83
- )
71
+ # Uses functions instead of declare -A for bash 3.2 compatibility (macOS default)
72
+ _get_mount_preset() {
73
+ case "$1" in
74
+ gh) echo "$HOME/.config/gh:/home/loki/.config/gh:ro" ;;
75
+ git) echo "$HOME/.gitconfig:/home/loki/.gitconfig:ro" ;;
76
+ ssh) echo "$HOME/.ssh/known_hosts:/home/loki/.ssh/known_hosts:ro" ;;
77
+ aws) echo "$HOME/.aws:/home/loki/.aws:ro" ;;
78
+ azure) echo "$HOME/.azure:/home/loki/.azure:ro" ;;
79
+ kube) echo "$HOME/.kube:/home/loki/.kube:ro" ;;
80
+ terraform) echo "$HOME/.terraform.d:/home/loki/.terraform.d:ro" ;;
81
+ gcloud) echo "$HOME/.config/gcloud:/home/loki/.config/gcloud:ro" ;;
82
+ npm) echo "$HOME/.npmrc:/home/loki/.npmrc:ro" ;;
83
+ *) echo "" ;;
84
+ esac
85
+ }
84
86
 
85
87
  # Environment variables auto-passed per preset (comma-separated)
86
- declare -A DOCKER_ENV_PRESETS
87
- DOCKER_ENV_PRESETS=(
88
- [aws]="AWS_REGION,AWS_PROFILE,AWS_DEFAULT_REGION"
89
- [azure]="AZURE_SUBSCRIPTION_ID,AZURE_TENANT_ID"
90
- [gcloud]="GOOGLE_PROJECT,GOOGLE_REGION,GCLOUD_PROJECT"
91
- [terraform]="TF_VAR_*"
92
- )
88
+ _get_env_preset() {
89
+ case "$1" in
90
+ aws) echo "AWS_REGION,AWS_PROFILE,AWS_DEFAULT_REGION" ;;
91
+ azure) echo "AZURE_SUBSCRIPTION_ID,AZURE_TENANT_ID" ;;
92
+ gcloud) echo "GOOGLE_PROJECT,GOOGLE_REGION,GCLOUD_PROJECT" ;;
93
+ terraform) echo "TF_VAR_*" ;;
94
+ *) echo "" ;;
95
+ esac
96
+ }
93
97
 
94
98
  #===============================================================================
95
99
  # Utility Functions
@@ -836,7 +840,8 @@ except: pass
836
840
  while IFS= read -r name; do
837
841
  [[ -z "$name" ]] && continue
838
842
 
839
- local preset_value="${DOCKER_MOUNT_PRESETS[$name]:-}"
843
+ local preset_value
844
+ preset_value="$(_get_mount_preset "$name")"
840
845
  if [[ -z "$preset_value" ]]; then
841
846
  log_warn "Unknown Docker mount preset: $name"
842
847
  continue
@@ -861,7 +866,8 @@ except: pass
861
866
  fi
862
867
 
863
868
  # Add associated env vars
864
- local env_list="${DOCKER_ENV_PRESETS[$name]:-}"
869
+ local env_list
870
+ env_list="$(_get_env_preset "$name")"
865
871
  if [[ -n "$env_list" ]]; then
866
872
  IFS=',' read -ra env_names <<< "$env_list"
867
873
  local env_name
@@ -52,17 +52,30 @@ loki_telemetry() {
52
52
  os_name=$(uname -s 2>/dev/null || echo "unknown")
53
53
  arch=$(uname -m 2>/dev/null || echo "unknown")
54
54
 
55
- # Build properties from remaining args (key=value pairs)
56
- local extra_props=""
55
+ # Build JSON payload safely using Python to prevent injection
56
+ local extra_args=""
57
57
  for arg in "$@"; do
58
- local key="${arg%%=*}"
59
- local val="${arg#*=}"
60
- extra_props="${extra_props},\"${key}\":\"${val}\""
58
+ extra_args="${extra_args}${extra_args:+ }${arg}"
61
59
  done
62
60
 
63
61
  local payload
64
- payload=$(printf '{"api_key":"%s","event":"%s","distinct_id":"%s","properties":{"os":"%s","arch":"%s","version":"%s","channel":"%s"%s}}' \
65
- "$LOKI_POSTHOG_KEY" "$event" "$distinct_id" "$os_name" "$arch" "$version" "$channel" "$extra_props")
62
+ payload=$(python3 -c "
63
+ import json, sys
64
+ props = {'os': sys.argv[1], 'arch': sys.argv[2], 'version': sys.argv[3], 'channel': sys.argv[4]}
65
+ for arg in sys.argv[5:]:
66
+ if '=' in arg:
67
+ k, v = arg.split('=', 1)
68
+ props[k] = v
69
+ print(json.dumps({'api_key': '$LOKI_POSTHOG_KEY', 'event': sys.argv[5] if len(sys.argv) > 5 else '', 'distinct_id': '$distinct_id', 'properties': props}))
70
+ " "$os_name" "$arch" "$version" "$channel" $extra_args 2>/dev/null) || return 0
71
+ # Re-inject event and distinct_id properly
72
+ payload=$(python3 -c "
73
+ import json, sys
74
+ d = json.loads(sys.argv[1])
75
+ d['event'] = sys.argv[2]
76
+ d['distinct_id'] = sys.argv[3]
77
+ print(json.dumps(d))
78
+ " "$payload" "$event" "$distinct_id" 2>/dev/null) || return 0
66
79
 
67
80
  (curl -sS --max-time 3 -X POST "${LOKI_POSTHOG_HOST}/capture/" \
68
81
  -H "Content-Type: application/json" \
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.4.0"
10
+ __version__ = "6.5.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -886,7 +886,7 @@ Summary: {summary}
886
886
  if len(entries) > 50:
887
887
  header = entries[0]
888
888
  recent = entries[-50:]
889
- content = header + "\n## Session:".join(recent)
889
+ content = header + "\n## Session:" + "\n## Session:".join(recent)
890
890
  else:
891
891
  content = existing
892
892
  content += entry
@@ -536,9 +536,13 @@ async def get_status() -> StatusResponse:
536
536
 
537
537
  # Global session
538
538
  if running:
539
+ try:
540
+ _global_pid = int(pid_str) if pid_str else 0
541
+ except (ValueError, TypeError):
542
+ _global_pid = 0
539
543
  active_session_list.append(SessionInfo(
540
544
  session_id="global",
541
- pid=int(pid_str) if pid_str else 0,
545
+ pid=_global_pid,
542
546
  status=status,
543
547
  ))
544
548
 
@@ -1441,7 +1445,7 @@ async def query_audit_logs(
1441
1445
  action: Optional[str] = None,
1442
1446
  resource_type: Optional[str] = None,
1443
1447
  resource_id: Optional[str] = None,
1444
- limit: int = 100,
1448
+ limit: int = Query(default=100, ge=1, le=1000),
1445
1449
  offset: int = 0,
1446
1450
  ):
1447
1451
  """
@@ -1561,7 +1565,7 @@ async def get_memory_summary():
1561
1565
 
1562
1566
 
1563
1567
  @app.get("/api/memory/episodes")
1564
- async def list_episodes(limit: int = 50):
1568
+ async def list_episodes(limit: int = Query(default=50, ge=1, le=1000)):
1565
1569
  """List episodic memory entries."""
1566
1570
  ep_dir = _get_loki_dir() / "memory" / "episodic"
1567
1571
  episodes = []
@@ -1578,11 +1582,15 @@ async def list_episodes(limit: int = 50):
1578
1582
  @app.get("/api/memory/episodes/{episode_id}")
1579
1583
  async def get_episode(episode_id: str):
1580
1584
  """Get a specific episodic memory entry."""
1581
- ep_dir = _get_loki_dir() / "memory" / "episodic"
1585
+ loki_dir = _get_loki_dir()
1586
+ ep_dir = loki_dir / "memory" / "episodic"
1582
1587
  if not ep_dir.exists():
1583
1588
  raise HTTPException(status_code=404, detail="Episode not found")
1584
1589
  # Try direct filename match
1585
1590
  for f in ep_dir.glob("*.json"):
1591
+ resolved = os.path.realpath(f)
1592
+ if not resolved.startswith(os.path.realpath(str(loki_dir))):
1593
+ raise HTTPException(status_code=403, detail="Access denied")
1586
1594
  try:
1587
1595
  data = json.loads(f.read_text())
1588
1596
  if data.get("id") == episode_id or f.stem == episode_id:
@@ -1633,10 +1641,14 @@ async def list_skills():
1633
1641
  @app.get("/api/memory/skills/{skill_id}")
1634
1642
  async def get_skill(skill_id: str):
1635
1643
  """Get a specific procedural skill."""
1636
- skills_dir = _get_loki_dir() / "memory" / "skills"
1644
+ loki_dir = _get_loki_dir()
1645
+ skills_dir = loki_dir / "memory" / "skills"
1637
1646
  if not skills_dir.exists():
1638
1647
  raise HTTPException(status_code=404, detail="Skill not found")
1639
1648
  for f in skills_dir.glob("*.json"):
1649
+ resolved = os.path.realpath(f)
1650
+ if not resolved.startswith(os.path.realpath(str(loki_dir))):
1651
+ raise HTTPException(status_code=403, detail="Access denied")
1640
1652
  try:
1641
1653
  data = json.loads(f.read_text())
1642
1654
  if data.get("id") == skill_id or f.stem == skill_id:
@@ -1706,6 +1718,7 @@ def _read_learning_signals(signal_type: Optional[str] = None, limit: int = 50) -
1706
1718
  (learning/emitter.py). Each file contains a single signal object with fields:
1707
1719
  id, type, source, action, timestamp, confidence, outcome, data, context.
1708
1720
  """
1721
+ limit = min(limit, 1000)
1709
1722
  signals_dir = _get_loki_dir() / "learning" / "signals"
1710
1723
  if not signals_dir.exists() or not signals_dir.is_dir():
1711
1724
  return []
@@ -1825,7 +1838,7 @@ async def get_learning_signals(
1825
1838
  timeRange: str = "7d",
1826
1839
  signalType: Optional[str] = None,
1827
1840
  source: Optional[str] = None,
1828
- limit: int = 50,
1841
+ limit: int = Query(default=50, ge=1, le=1000),
1829
1842
  offset: int = 0,
1830
1843
  ):
1831
1844
  """Get raw learning signals from both events.jsonl and learning signals directory."""
@@ -2030,7 +2043,7 @@ async def trigger_aggregation():
2030
2043
 
2031
2044
 
2032
2045
  @app.get("/api/learning/preferences")
2033
- async def get_learning_preferences(limit: int = 50):
2046
+ async def get_learning_preferences(limit: int = Query(default=50, ge=1, le=1000)):
2034
2047
  """Get aggregated user preferences from events and learning signals directory."""
2035
2048
  events = _read_events("30d")
2036
2049
  prefs = [e for e in events if e.get("type") == "user_preference"]
@@ -2042,7 +2055,7 @@ async def get_learning_preferences(limit: int = 50):
2042
2055
 
2043
2056
 
2044
2057
  @app.get("/api/learning/errors")
2045
- async def get_learning_errors(limit: int = 50):
2058
+ async def get_learning_errors(limit: int = Query(default=50, ge=1, le=1000)):
2046
2059
  """Get aggregated error patterns from events and learning signals directory."""
2047
2060
  events = _read_events("30d")
2048
2061
  errors = [e for e in events if e.get("type") == "error_pattern"]
@@ -2054,7 +2067,7 @@ async def get_learning_errors(limit: int = 50):
2054
2067
 
2055
2068
 
2056
2069
  @app.get("/api/learning/success")
2057
- async def get_learning_success(limit: int = 50):
2070
+ async def get_learning_success(limit: int = Query(default=50, ge=1, le=1000)):
2058
2071
  """Get aggregated success patterns from events and learning signals directory."""
2059
2072
  events = _read_events("30d")
2060
2073
  successes = [e for e in events if e.get("type") == "success_pattern"]
@@ -2066,7 +2079,7 @@ async def get_learning_success(limit: int = 50):
2066
2079
 
2067
2080
 
2068
2081
  @app.get("/api/learning/tools")
2069
- async def get_tool_efficiency(limit: int = 50):
2082
+ async def get_tool_efficiency(limit: int = Query(default=50, ge=1, le=1000)):
2070
2083
  """Get tool efficiency rankings from events and learning signals directory."""
2071
2084
  events = _read_events("30d")
2072
2085
  tools = [e for e in events if e.get("type") == "tool_efficiency"]
@@ -2505,7 +2518,7 @@ async def get_council_state():
2505
2518
 
2506
2519
 
2507
2520
  @app.get("/api/council/verdicts")
2508
- async def get_council_verdicts(limit: int = 20):
2521
+ async def get_council_verdicts(limit: int = Query(default=20, ge=1, le=1000)):
2509
2522
  """Get council vote history (decision log)."""
2510
2523
  state_file = _get_loki_dir() / "council" / "state.json"
2511
2524
  verdicts = []
@@ -2,11 +2,11 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v6.4.0
5
+ **Version:** v6.5.0
6
6
 
7
7
  ---
8
8
 
9
- ## What's New in v6.4.0
9
+ ## What's New in v6.5.0
10
10
 
11
11
  ### Dual-Mode Architecture (v6.0.0)
12
12
  - `loki run` command for direct autonomous execution
package/events/emit.sh CHANGED
@@ -43,7 +43,7 @@ fi
43
43
 
44
44
  # JSON escape helper: handles \, ", and control characters
45
45
  json_escape() {
46
- printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' | tr -d '\n'
46
+ printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g; s/\t/\\t/g; s/\r/\\r/g' | awk '{if(NR>1) printf "\\n"; printf "%s", $0}'
47
47
  }
48
48
 
49
49
  # Build payload JSON
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '6.4.0'
60
+ __version__ = '6.5.0'
package/mcp/server.py CHANGED
@@ -20,6 +20,7 @@ import os
20
20
  import json
21
21
  import logging
22
22
  import threading
23
+ import uuid
23
24
  from datetime import datetime, timezone
24
25
  from typing import Optional, List, Dict, Any
25
26
 
@@ -563,11 +564,11 @@ async def loki_memory_store_pattern(
563
564
  from memory.schemas import SemanticPattern
564
565
 
565
566
  base_path = safe_path_join('.loki', 'memory')
566
- engine = MemoryEngine(base_path)
567
+ engine = MemoryEngine(base_path=base_path)
567
568
  engine.initialize()
568
569
 
569
570
  pattern_obj = SemanticPattern(
570
- id=f"pattern-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}",
571
+ id=f"pattern-{datetime.now(timezone.utc).strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8]}",
571
572
  pattern=pattern,
572
573
  category=category,
573
574
  conditions=[],
@@ -821,7 +822,7 @@ async def loki_state_get() -> str:
821
822
  try:
822
823
  from memory.engine import MemoryEngine
823
824
  memory_path = safe_path_join('.loki', 'memory')
824
- engine = MemoryEngine(memory_path)
825
+ engine = MemoryEngine(base_path=memory_path)
825
826
  state["memory_stats"] = engine.get_stats()
826
827
  except Exception:
827
828
  state["memory_stats"] = None
@@ -1004,9 +1005,13 @@ async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
1004
1005
  try:
1005
1006
  content = prd_content
1006
1007
  if not content and prd_path:
1007
- resolved = safe_path_join('.', prd_path)
1008
- if os.path.exists(resolved):
1009
- with safe_open(resolved, 'r') as f:
1008
+ # Resolve relative paths against project root, absolute paths used as-is
1009
+ if os.path.isabs(prd_path):
1010
+ resolved = os.path.realpath(prd_path)
1011
+ else:
1012
+ resolved = os.path.realpath(os.path.join(get_project_root(), prd_path))
1013
+ if os.path.exists(resolved) and os.path.isfile(resolved):
1014
+ with open(resolved, 'r', encoding='utf-8') as f:
1010
1015
  content = f.read()
1011
1016
  else:
1012
1017
  return json.dumps({"error": f"PRD file not found: {prd_path}"})
@@ -307,6 +307,10 @@ class TextChunker:
307
307
  if len(text) <= max_size:
308
308
  return [text]
309
309
 
310
+ # Guard against infinite loop when overlap >= max_size
311
+ if overlap >= max_size:
312
+ overlap = 0
313
+
310
314
  chunks = []
311
315
  start = 0
312
316
  while start < len(text):
@@ -1062,12 +1066,17 @@ class EmbeddingEngine:
1062
1066
  # Find texts that need computing
1063
1067
  texts_to_compute = []
1064
1068
  indices_to_compute = []
1065
- for i, (text, key) in enumerate(zip(texts, cache_keys)):
1066
- if not self.config.cache_enabled or cached_results.get(key) is None:
1067
- texts_to_compute.append(text)
1068
- indices_to_compute.append(i)
1069
- else:
1070
- self._metrics["cache_hits"] += 1
1069
+ if not self.config.cache_enabled:
1070
+ # No cache - all texts need computing
1071
+ texts_to_compute = list(texts)
1072
+ indices_to_compute = list(range(len(texts)))
1073
+ else:
1074
+ for i, (text, key) in enumerate(zip(texts, cache_keys)):
1075
+ if cached_results.get(key) is None:
1076
+ texts_to_compute.append(text)
1077
+ indices_to_compute.append(i)
1078
+ else:
1079
+ self._metrics["cache_hits"] += 1
1071
1080
 
1072
1081
  # Compute missing embeddings
1073
1082
  new_embeddings = None
package/memory/engine.py CHANGED
@@ -881,6 +881,21 @@ class MemoryEngine:
881
881
  for e in errors_raw
882
882
  ]
883
883
 
884
+ # Parse last_accessed datetime
885
+ last_accessed = None
886
+ last_accessed_raw = data.get("last_accessed")
887
+ if last_accessed_raw:
888
+ if isinstance(last_accessed_raw, str):
889
+ if last_accessed_raw.endswith("Z"):
890
+ last_accessed_raw = last_accessed_raw[:-1]
891
+ last_accessed = datetime.fromisoformat(last_accessed_raw)
892
+ if last_accessed.tzinfo is None:
893
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
894
+ elif isinstance(last_accessed_raw, datetime):
895
+ last_accessed = last_accessed_raw
896
+ if last_accessed.tzinfo is None:
897
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
898
+
884
899
  return EpisodeTrace(
885
900
  id=data.get("id", ""),
886
901
  task_id=data.get("task_id", ""),
@@ -897,6 +912,9 @@ class MemoryEngine:
897
912
  tokens_used=data.get("tokens_used", 0),
898
913
  files_read=data.get("files_read", context.get("files_involved", [])),
899
914
  files_modified=data.get("files_modified", []),
915
+ importance=data.get("importance", 0.5),
916
+ last_accessed=last_accessed,
917
+ access_count=data.get("access_count", 0),
900
918
  )
901
919
 
902
920
  def _dict_to_pattern(self, data: Dict[str, Any]) -> SemanticPattern:
@@ -924,6 +942,21 @@ class MemoryEngine:
924
942
  for link in links_raw
925
943
  ]
926
944
 
945
+ # Parse last_accessed datetime
946
+ last_accessed = None
947
+ last_accessed_raw = data.get("last_accessed")
948
+ if last_accessed_raw:
949
+ if isinstance(last_accessed_raw, str):
950
+ if last_accessed_raw.endswith("Z"):
951
+ last_accessed_raw = last_accessed_raw[:-1]
952
+ last_accessed = datetime.fromisoformat(last_accessed_raw)
953
+ if last_accessed.tzinfo is None:
954
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
955
+ elif isinstance(last_accessed_raw, datetime):
956
+ last_accessed = last_accessed_raw
957
+ if last_accessed.tzinfo is None:
958
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
959
+
927
960
  return SemanticPattern(
928
961
  id=data.get("id", ""),
929
962
  pattern=data.get("pattern", ""),
@@ -936,6 +969,9 @@ class MemoryEngine:
936
969
  usage_count=data.get("usage_count", 0),
937
970
  last_used=last_used,
938
971
  links=links,
972
+ importance=data.get("importance", 0.5),
973
+ last_accessed=last_accessed,
974
+ access_count=data.get("access_count", 0),
939
975
  )
940
976
 
941
977
  def _dict_to_skill(self, data: Dict[str, Any]) -> ProceduralSkill:
@@ -945,6 +981,22 @@ class MemoryEngine:
945
981
  ErrorFix.from_dict(e) if isinstance(e, dict) else e
946
982
  for e in raw_errors
947
983
  ]
984
+
985
+ # Parse last_accessed datetime
986
+ last_accessed = None
987
+ last_accessed_raw = data.get("last_accessed")
988
+ if last_accessed_raw:
989
+ if isinstance(last_accessed_raw, str):
990
+ if last_accessed_raw.endswith("Z"):
991
+ last_accessed_raw = last_accessed_raw[:-1]
992
+ last_accessed = datetime.fromisoformat(last_accessed_raw)
993
+ if last_accessed.tzinfo is None:
994
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
995
+ elif isinstance(last_accessed_raw, datetime):
996
+ last_accessed = last_accessed_raw
997
+ if last_accessed.tzinfo is None:
998
+ last_accessed = last_accessed.replace(tzinfo=timezone.utc)
999
+
948
1000
  return ProceduralSkill(
949
1001
  id=data.get("id", ""),
950
1002
  name=data.get("name", ""),
@@ -953,6 +1005,10 @@ class MemoryEngine:
953
1005
  steps=data.get("steps", []),
954
1006
  common_errors=common_errors,
955
1007
  exit_criteria=data.get("exit_criteria", []),
1008
+ example_usage=data.get("example_usage"),
1009
+ importance=data.get("importance", 0.5),
1010
+ last_accessed=last_accessed,
1011
+ access_count=data.get("access_count", 0),
956
1012
  )
957
1013
 
958
1014
  def _skill_to_markdown(self, skill: Dict[str, Any]) -> str:
package/memory/schemas.py CHANGED
@@ -20,9 +20,13 @@ from typing import Optional, List, Dict, Any
20
20
  def _to_utc_isoformat(dt: datetime) -> str:
21
21
  """Convert datetime to UTC ISO 8601 string with Z suffix.
22
22
 
23
- Handles both timezone-aware and timezone-naive datetimes,
24
- and avoids double-suffixing if already has timezone info.
23
+ Handles both timezone-aware and timezone-naive datetimes.
24
+ If dt has a non-UTC timezone, converts to UTC first.
25
25
  """
26
+ # If timezone-aware and not UTC, convert to UTC
27
+ if dt.tzinfo is not None and dt.utcoffset() != timezone.utc.utcoffset(None):
28
+ dt = dt.astimezone(timezone.utc)
29
+
26
30
  iso = dt.isoformat()
27
31
  # If already has timezone offset like +00:00, replace with Z
28
32
  if iso.endswith("+00:00"):
package/memory/storage.py CHANGED
@@ -233,7 +233,7 @@ class MemoryStorage:
233
233
  path: Path to JSON file
234
234
 
235
235
  Returns:
236
- Parsed JSON as dictionary, or None if file doesn't exist
236
+ Parsed JSON as dictionary, or None if file doesn't exist or is corrupted
237
237
  """
238
238
  path = Path(path)
239
239
  if not path.exists():
@@ -241,7 +241,10 @@ class MemoryStorage:
241
241
 
242
242
  with self._file_lock(path, exclusive=False):
243
243
  with open(path, "r") as f:
244
- return json.load(f)
244
+ try:
245
+ return json.load(f)
246
+ except json.JSONDecodeError:
247
+ return None
245
248
 
246
249
  def _generate_id(self, prefix: str) -> str:
247
250
  """
@@ -505,9 +505,12 @@ class TokenEconomics:
505
505
  Ratio of discovery_tokens / read_tokens (0.0 if no reads)
506
506
  """
507
507
  read_tokens = self.metrics["read_tokens"]
508
+ discovery_tokens = self.metrics["discovery_tokens"]
508
509
  if read_tokens == 0:
510
+ if discovery_tokens > 0:
511
+ return 999.99 # Sentinel: all discovery, no productive reads
509
512
  return 0.0
510
- return self.metrics["discovery_tokens"] / read_tokens
513
+ return discovery_tokens / read_tokens
511
514
 
512
515
  def get_savings_percent(self) -> float:
513
516
  """
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.4.0",
3
+ "version": "6.5.0",
4
4
  "description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "agent",