loki-mode 5.52.4 → 5.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/SKILL.md +2 -2
- package/VERSION +1 -1
- package/autonomy/loki +250 -4
- package/dashboard/__init__.py +1 -1
- package/dashboard/activity_logger.py +231 -0
- package/dashboard/failure_extractor.py +228 -0
- package/dashboard/prompt_optimizer.py +281 -0
- package/dashboard/rigour_integration.py +331 -0
- package/dashboard/server.py +209 -0
- package/dashboard/static/index.html +1518 -617
- package/docs/INSTALLATION.md +1 -1
- package/mcp/__init__.py +1 -1
- package/package.json +38 -10
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 v5.
|
|
6
|
+
# Loki Mode v5.54.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
|
-
**v5.
|
|
266
|
+
**v5.54.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.
|
|
1
|
+
5.54.0
|
package/autonomy/loki
CHANGED
|
@@ -409,7 +409,8 @@ show_help() {
|
|
|
409
409
|
echo " council [cmd] Completion council (status|verdicts|convergence|force-review|report)"
|
|
410
410
|
echo " checkpoint|cp Save/restore session checkpoints"
|
|
411
411
|
echo " projects Multi-project registry management"
|
|
412
|
-
echo " audit
|
|
412
|
+
echo " audit [cmd] Agent audit log and quality scanning (log|scan)"
|
|
413
|
+
echo " optimize Optimize prompts based on session history"
|
|
413
414
|
echo " enterprise Enterprise feature management (tokens, OIDC)"
|
|
414
415
|
echo " metrics Prometheus/OpenMetrics metrics from dashboard"
|
|
415
416
|
echo " dogfood Show self-development statistics"
|
|
@@ -431,6 +432,7 @@ show_help() {
|
|
|
431
432
|
echo " --no-dashboard Disable web dashboard"
|
|
432
433
|
echo " --sandbox Run in Docker sandbox for isolation"
|
|
433
434
|
echo " --skip-memory Skip loading memory context at startup"
|
|
435
|
+
echo " --compliance PRESET Enable compliance mode (default|healthcare|fintech|government)"
|
|
434
436
|
echo " --budget USD Set cost budget limit (display in dashboard/status)"
|
|
435
437
|
echo ""
|
|
436
438
|
echo "Options for 'issue':"
|
|
@@ -484,6 +486,7 @@ cmd_start() {
|
|
|
484
486
|
echo " --no-dashboard Disable web dashboard"
|
|
485
487
|
echo " --sandbox Run in Docker sandbox"
|
|
486
488
|
echo " --skip-memory Skip loading memory context at startup"
|
|
489
|
+
echo " --compliance PRESET Enable compliance mode (default|healthcare|fintech|government)"
|
|
487
490
|
echo " --budget USD Cost budget limit (auto-pause when exceeded)"
|
|
488
491
|
echo " --yes, -y Skip confirmation prompts (auto-confirm)"
|
|
489
492
|
echo ""
|
|
@@ -550,6 +553,22 @@ cmd_start() {
|
|
|
550
553
|
export LOKI_SKIP_MEMORY=true
|
|
551
554
|
shift
|
|
552
555
|
;;
|
|
556
|
+
--compliance)
|
|
557
|
+
if [[ -n "${2:-}" ]]; then
|
|
558
|
+
export LOKI_COMPLIANCE_PRESET="$2"
|
|
559
|
+
echo -e "${CYAN}Compliance mode: $2${NC}"
|
|
560
|
+
shift 2
|
|
561
|
+
else
|
|
562
|
+
echo -e "${RED}--compliance requires a preset name (default, healthcare, fintech, government)${NC}"
|
|
563
|
+
exit 1
|
|
564
|
+
fi
|
|
565
|
+
;;
|
|
566
|
+
--compliance=*)
|
|
567
|
+
local compliance_val="${1#*=}"
|
|
568
|
+
export LOKI_COMPLIANCE_PRESET="$compliance_val"
|
|
569
|
+
echo -e "${CYAN}Compliance mode: $compliance_val${NC}"
|
|
570
|
+
shift
|
|
571
|
+
;;
|
|
553
572
|
--yes|-y)
|
|
554
573
|
export LOKI_AUTO_CONFIRM=true
|
|
555
574
|
shift
|
|
@@ -4688,6 +4707,108 @@ except Exception:
|
|
|
4688
4707
|
esac
|
|
4689
4708
|
}
|
|
4690
4709
|
|
|
4710
|
+
# Prompt optimization via dashboard API
|
|
4711
|
+
cmd_optimize() {
|
|
4712
|
+
local sessions=10
|
|
4713
|
+
local dry_run=""
|
|
4714
|
+
|
|
4715
|
+
while [[ $# -gt 0 ]]; do
|
|
4716
|
+
case "$1" in
|
|
4717
|
+
--sessions) if [[ -z "${2:-}" ]]; then echo -e "${RED}Error: --sessions requires a value${NC}"; exit 1; fi; sessions="$2"; shift 2 ;;
|
|
4718
|
+
--sessions=*) sessions="${1#*=}"; shift ;;
|
|
4719
|
+
--dry-run) dry_run="true"; shift ;;
|
|
4720
|
+
--help|-h)
|
|
4721
|
+
echo -e "${BOLD}loki optimize${NC} - Optimize prompts based on session history"
|
|
4722
|
+
echo ""
|
|
4723
|
+
echo "Usage: loki optimize [options]"
|
|
4724
|
+
echo ""
|
|
4725
|
+
echo "Analyzes recent session failures and proposes prompt improvements."
|
|
4726
|
+
echo "Requires the dashboard API to be running (loki serve)."
|
|
4727
|
+
echo ""
|
|
4728
|
+
echo "Options:"
|
|
4729
|
+
echo " --sessions N Number of recent sessions to analyze (default: 10)"
|
|
4730
|
+
echo " --dry-run Show proposed changes without applying"
|
|
4731
|
+
echo ""
|
|
4732
|
+
echo "Examples:"
|
|
4733
|
+
echo " loki optimize # Optimize using last 10 sessions"
|
|
4734
|
+
echo " loki optimize --sessions 20 # Analyze last 20 sessions"
|
|
4735
|
+
echo " loki optimize --dry-run # Preview changes only"
|
|
4736
|
+
exit 0
|
|
4737
|
+
;;
|
|
4738
|
+
*)
|
|
4739
|
+
echo -e "${RED}Unknown option: $1${NC}"
|
|
4740
|
+
echo "Run 'loki optimize --help' for usage."
|
|
4741
|
+
exit 1
|
|
4742
|
+
;;
|
|
4743
|
+
esac
|
|
4744
|
+
done
|
|
4745
|
+
|
|
4746
|
+
local port="${LOKI_DASHBOARD_PORT:-57374}"
|
|
4747
|
+
local host="127.0.0.1"
|
|
4748
|
+
local dry_run_param="false"
|
|
4749
|
+
if [ -n "$dry_run" ]; then
|
|
4750
|
+
dry_run_param="true"
|
|
4751
|
+
fi
|
|
4752
|
+
local url="http://${host}:${port}/api/prompt-optimize?sessions=${sessions}&dry_run=${dry_run_param}"
|
|
4753
|
+
|
|
4754
|
+
echo -e "${BOLD}Analyzing sessions for prompt optimization...${NC}"
|
|
4755
|
+
echo -e "${DIM}Sessions: $sessions | Dry run: ${dry_run_param}${NC}"
|
|
4756
|
+
echo ""
|
|
4757
|
+
|
|
4758
|
+
local response http_code
|
|
4759
|
+
response=$(curl -s -w "\n%{http_code}" -X POST "$url" 2>/dev/null) || true
|
|
4760
|
+
http_code=$(echo "$response" | tail -1)
|
|
4761
|
+
response=$(echo "$response" | sed '$d')
|
|
4762
|
+
if [ -z "$http_code" ] || [ "$http_code" = "000" ]; then
|
|
4763
|
+
echo -e "${RED}Error: Could not connect to dashboard API at http://${host}:${port}${NC}"
|
|
4764
|
+
echo "Make sure the dashboard is running: loki serve"
|
|
4765
|
+
exit 1
|
|
4766
|
+
fi
|
|
4767
|
+
if [ "$http_code" -ge 400 ] 2>/dev/null; then
|
|
4768
|
+
echo -e "${RED}Error: Dashboard API returned HTTP $http_code${NC}"
|
|
4769
|
+
[ -n "$response" ] && echo "$response"
|
|
4770
|
+
exit 1
|
|
4771
|
+
fi
|
|
4772
|
+
|
|
4773
|
+
if ! command -v python3 &>/dev/null; then
|
|
4774
|
+
echo "$response" | jq . 2>/dev/null || echo "$response"
|
|
4775
|
+
else
|
|
4776
|
+
echo "$response" | python3 -c "
|
|
4777
|
+
import json, sys
|
|
4778
|
+
data = json.loads(sys.stdin.read())
|
|
4779
|
+
|
|
4780
|
+
failures = data.get('failures_analyzed', 0)
|
|
4781
|
+
changes = data.get('changes', [])
|
|
4782
|
+
version = data.get('version', None)
|
|
4783
|
+
|
|
4784
|
+
print(f'Failures analyzed: {failures}')
|
|
4785
|
+
print(f'Changes proposed: {len(changes)}')
|
|
4786
|
+
print('---')
|
|
4787
|
+
|
|
4788
|
+
for i, change in enumerate(changes, 1):
|
|
4789
|
+
prompt = change.get('prompt', '?')
|
|
4790
|
+
rationale = change.get('rationale', 'No rationale provided')
|
|
4791
|
+
old = change.get('old_value', '')
|
|
4792
|
+
new = change.get('new_value', '')
|
|
4793
|
+
print(f'')
|
|
4794
|
+
print(f' {i}. {prompt}')
|
|
4795
|
+
print(f' Rationale: {rationale}')
|
|
4796
|
+
if old:
|
|
4797
|
+
print(f' Old: {old[:80]}...' if len(old) > 80 else f' Old: {old}')
|
|
4798
|
+
if new:
|
|
4799
|
+
print(f' New: {new[:80]}...' if len(new) > 80 else f' New: {new}')
|
|
4800
|
+
|
|
4801
|
+
if version is not None:
|
|
4802
|
+
print(f'')
|
|
4803
|
+
print(f'Prompts optimized to version {version}')
|
|
4804
|
+
elif not changes:
|
|
4805
|
+
print(f'')
|
|
4806
|
+
print('No optimization changes proposed.')
|
|
4807
|
+
print()
|
|
4808
|
+
" 2>/dev/null || echo "$response"
|
|
4809
|
+
fi
|
|
4810
|
+
}
|
|
4811
|
+
|
|
4691
4812
|
# Main command dispatcher
|
|
4692
4813
|
main() {
|
|
4693
4814
|
if [ $# -eq 0 ]; then
|
|
@@ -4803,6 +4924,9 @@ main() {
|
|
|
4803
4924
|
audit)
|
|
4804
4925
|
cmd_audit "$@"
|
|
4805
4926
|
;;
|
|
4927
|
+
optimize)
|
|
4928
|
+
cmd_optimize "$@"
|
|
4929
|
+
;;
|
|
4806
4930
|
metrics)
|
|
4807
4931
|
cmd_metrics "$@"
|
|
4808
4932
|
;;
|
|
@@ -4826,7 +4950,7 @@ main() {
|
|
|
4826
4950
|
esac
|
|
4827
4951
|
}
|
|
4828
4952
|
|
|
4829
|
-
# Agent action audit log
|
|
4953
|
+
# Agent action audit log and quality scanning
|
|
4830
4954
|
cmd_audit() {
|
|
4831
4955
|
local subcommand="${1:-help}"
|
|
4832
4956
|
local audit_file="$LOKI_DIR/logs/agent-audit.jsonl"
|
|
@@ -4885,16 +5009,138 @@ print(f' {\"TOTAL\":25s} {sum(counts.values())}')
|
|
|
4885
5009
|
echo " python3 required for count summary"
|
|
4886
5010
|
fi
|
|
4887
5011
|
;;
|
|
5012
|
+
scan)
|
|
5013
|
+
shift
|
|
5014
|
+
local preset="default"
|
|
5015
|
+
local do_export=""
|
|
5016
|
+
|
|
5017
|
+
while [[ $# -gt 0 ]]; do
|
|
5018
|
+
case "$1" in
|
|
5019
|
+
--preset) if [[ -z "${2:-}" ]]; then echo -e "${RED}Error: --preset requires a value${NC}"; exit 1; fi; preset="$2"; shift 2 ;;
|
|
5020
|
+
--preset=*) preset="${1#*=}"; shift ;;
|
|
5021
|
+
--export) do_export="true"; shift ;;
|
|
5022
|
+
--help|-h)
|
|
5023
|
+
echo -e "${BOLD}loki audit scan${NC} - Run quality scan"
|
|
5024
|
+
echo ""
|
|
5025
|
+
echo "Usage: loki audit scan [options]"
|
|
5026
|
+
echo ""
|
|
5027
|
+
echo "Options:"
|
|
5028
|
+
echo " --preset NAME Compliance preset (default|healthcare|fintech|government)"
|
|
5029
|
+
echo " --export Save report to .loki/quality/report-{date}.json"
|
|
5030
|
+
echo ""
|
|
5031
|
+
exit 0
|
|
5032
|
+
;;
|
|
5033
|
+
*) echo -e "${RED}Unknown option: $1${NC}"; exit 1 ;;
|
|
5034
|
+
esac
|
|
5035
|
+
done
|
|
5036
|
+
|
|
5037
|
+
case "$preset" in
|
|
5038
|
+
default|healthcare|fintech|government) ;;
|
|
5039
|
+
*) echo -e "${RED}Error: Invalid preset '$preset'. Must be one of: default, healthcare, fintech, government${NC}"; exit 1 ;;
|
|
5040
|
+
esac
|
|
5041
|
+
|
|
5042
|
+
local port="${LOKI_DASHBOARD_PORT:-57374}"
|
|
5043
|
+
local host="127.0.0.1"
|
|
5044
|
+
local url="http://${host}:${port}/api/quality-scan?preset=${preset}"
|
|
5045
|
+
|
|
5046
|
+
echo -e "${BOLD}Running quality scan...${NC} (preset: $preset)"
|
|
5047
|
+
echo ""
|
|
5048
|
+
|
|
5049
|
+
local response http_code
|
|
5050
|
+
response=$(curl -s -w "\n%{http_code}" -X POST "$url" 2>/dev/null) || true
|
|
5051
|
+
http_code=$(echo "$response" | tail -1)
|
|
5052
|
+
response=$(echo "$response" | sed '$d')
|
|
5053
|
+
if [ -z "$http_code" ] || [ "$http_code" = "000" ]; then
|
|
5054
|
+
echo -e "${RED}Error: Could not connect to dashboard API at http://${host}:${port}${NC}"
|
|
5055
|
+
echo "Make sure the dashboard is running: loki serve"
|
|
5056
|
+
exit 1
|
|
5057
|
+
fi
|
|
5058
|
+
if [ "$http_code" -ge 400 ] 2>/dev/null; then
|
|
5059
|
+
echo -e "${RED}Error: Dashboard API returned HTTP $http_code${NC}"
|
|
5060
|
+
[ -n "$response" ] && echo "$response"
|
|
5061
|
+
exit 1
|
|
5062
|
+
fi
|
|
5063
|
+
|
|
5064
|
+
if ! command -v python3 &>/dev/null; then
|
|
5065
|
+
echo "$response" | jq . 2>/dev/null || echo "$response"
|
|
5066
|
+
else
|
|
5067
|
+
echo "$response" | python3 -c "
|
|
5068
|
+
import json, sys
|
|
5069
|
+
data = json.loads(sys.stdin.read())
|
|
5070
|
+
|
|
5071
|
+
score = data.get('score', 0)
|
|
5072
|
+
grade = data.get('grade', '?')
|
|
5073
|
+
print(f'Quality Score: {score}/100 Grade: {grade}')
|
|
5074
|
+
print('---')
|
|
5075
|
+
|
|
5076
|
+
# Category scores
|
|
5077
|
+
categories = data.get('categories', {})
|
|
5078
|
+
if categories:
|
|
5079
|
+
print()
|
|
5080
|
+
print('Category Scores:')
|
|
5081
|
+
for cat, cat_score in sorted(categories.items()):
|
|
5082
|
+
print(f' {cat:20s} {cat_score}/100')
|
|
5083
|
+
|
|
5084
|
+
# Findings by severity
|
|
5085
|
+
findings = data.get('findings', [])
|
|
5086
|
+
if findings:
|
|
5087
|
+
print()
|
|
5088
|
+
print('Findings:')
|
|
5089
|
+
for f in findings:
|
|
5090
|
+
sev = f.get('severity', 'info')
|
|
5091
|
+
msg = f.get('message', '')
|
|
5092
|
+
cat = f.get('category', '')
|
|
5093
|
+
if sev == 'critical':
|
|
5094
|
+
print(f' \033[0;31m[CRITICAL]\033[0m {cat}: {msg}')
|
|
5095
|
+
elif sev == 'major':
|
|
5096
|
+
print(f' \033[1;33m[MAJOR]\033[0m {cat}: {msg}')
|
|
5097
|
+
else:
|
|
5098
|
+
print(f' [MINOR] {cat}: {msg}')
|
|
5099
|
+
print()
|
|
5100
|
+
" 2>/dev/null || echo "$response"
|
|
5101
|
+
fi
|
|
5102
|
+
|
|
5103
|
+
# Export report if requested
|
|
5104
|
+
if [ -n "$do_export" ]; then
|
|
5105
|
+
local report_dir="$LOKI_DIR/quality"
|
|
5106
|
+
mkdir -p "$report_dir"
|
|
5107
|
+
local date_str
|
|
5108
|
+
date_str=$(date +%Y-%m-%d)
|
|
5109
|
+
local report_file="$report_dir/report-${date_str}.json"
|
|
5110
|
+
|
|
5111
|
+
local report_url="http://${host}:${port}/api/quality-report?format=json"
|
|
5112
|
+
local report_data
|
|
5113
|
+
report_data=$(curl -sf "$report_url" 2>/dev/null) || true
|
|
5114
|
+
if [ -n "$report_data" ]; then
|
|
5115
|
+
echo "$report_data" > "$report_file"
|
|
5116
|
+
echo -e "${GREEN}Report exported to $report_file${NC}"
|
|
5117
|
+
else
|
|
5118
|
+
# Fall back to scan response
|
|
5119
|
+
echo "$response" > "$report_file"
|
|
5120
|
+
echo -e "${GREEN}Report exported to $report_file${NC}"
|
|
5121
|
+
fi
|
|
5122
|
+
fi
|
|
5123
|
+
;;
|
|
5124
|
+
--preset|--export)
|
|
5125
|
+
# Handle flags passed directly to 'loki audit' (shortcut for 'loki audit scan')
|
|
5126
|
+
cmd_audit scan "$@"
|
|
5127
|
+
return
|
|
5128
|
+
;;
|
|
4888
5129
|
--help|-h|help)
|
|
4889
|
-
echo -e "${BOLD}loki audit${NC} - Agent
|
|
5130
|
+
echo -e "${BOLD}loki audit${NC} - Agent audit log and quality scanning"
|
|
4890
5131
|
echo ""
|
|
4891
|
-
echo "Usage: loki audit <subcommand>"
|
|
5132
|
+
echo "Usage: loki audit <subcommand> [options]"
|
|
4892
5133
|
echo ""
|
|
4893
5134
|
echo "Subcommands:"
|
|
4894
5135
|
echo " log [N] Show last N audit log entries (default: 50)"
|
|
4895
5136
|
echo " count Count actions by type"
|
|
5137
|
+
echo " scan Run quality scan against dashboard API"
|
|
4896
5138
|
echo " help Show this help"
|
|
4897
5139
|
echo ""
|
|
5140
|
+
echo "Quality Scan Options (loki audit scan):"
|
|
5141
|
+
echo " --preset NAME Compliance preset (default|healthcare|fintech|government)"
|
|
5142
|
+
echo " --export Save report to .loki/quality/report-{date}.json"
|
|
5143
|
+
echo ""
|
|
4898
5144
|
echo "The agent audit log records actions taken during Loki sessions,"
|
|
4899
5145
|
echo "including CLI invocations, git commits, and session lifecycle events."
|
|
4900
5146
|
echo "Log file: $audit_file"
|
package/dashboard/__init__.py
CHANGED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Activity Logger for Loki Mode Dashboard.
|
|
3
|
+
|
|
4
|
+
Appends structured JSONL entries to ~/.loki/activity.jsonl with automatic
|
|
5
|
+
rotation at 10MB. Provides query and session-diff capabilities.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import threading
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Optional
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger("loki-activity")
|
|
17
|
+
|
|
18
|
+
LOKI_DATA_DIR = os.environ.get("LOKI_DATA_DIR", os.path.expanduser("~/.loki"))
|
|
19
|
+
|
|
20
|
+
# Valid entity types and actions for validation
|
|
21
|
+
VALID_ENTITY_TYPES = {"task", "agent", "phase", "checkpoint"}
|
|
22
|
+
VALID_ACTIONS = {"created", "status_changed", "completed", "failed", "blocked"}
|
|
23
|
+
|
|
24
|
+
# Rotation threshold in bytes (10MB)
|
|
25
|
+
MAX_FILE_SIZE = 10 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ActivityLogger:
|
|
29
|
+
"""Thread-safe activity logger that writes JSONL to ~/.loki/activity.jsonl."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, data_dir: Optional[str] = None) -> None:
|
|
32
|
+
self._data_dir = Path(data_dir or LOKI_DATA_DIR)
|
|
33
|
+
self._log_file = self._data_dir / "activity.jsonl"
|
|
34
|
+
self._lock = threading.Lock()
|
|
35
|
+
self._data_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def log_file(self) -> Path:
|
|
39
|
+
"""Return the path to the current activity log file."""
|
|
40
|
+
return self._log_file
|
|
41
|
+
|
|
42
|
+
def _rotate_if_needed(self) -> None:
|
|
43
|
+
"""Rotate the log file if it exceeds MAX_FILE_SIZE."""
|
|
44
|
+
try:
|
|
45
|
+
if self._log_file.exists() and self._log_file.stat().st_size >= MAX_FILE_SIZE:
|
|
46
|
+
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
47
|
+
rotated = self._data_dir / f"activity-{timestamp}.jsonl"
|
|
48
|
+
self._log_file.rename(rotated)
|
|
49
|
+
logger.info("Rotated activity log to %s", rotated)
|
|
50
|
+
except OSError as e:
|
|
51
|
+
logger.warning("Failed to rotate activity log: %s", e)
|
|
52
|
+
|
|
53
|
+
def log(
|
|
54
|
+
self,
|
|
55
|
+
entity_type: str,
|
|
56
|
+
entity_id: str,
|
|
57
|
+
action: str,
|
|
58
|
+
old_value: Optional[str] = None,
|
|
59
|
+
new_value: Optional[str] = None,
|
|
60
|
+
session_id: Optional[str] = None,
|
|
61
|
+
) -> dict[str, Any]:
|
|
62
|
+
"""Log an activity entry. Returns the entry dict."""
|
|
63
|
+
if entity_type not in VALID_ENTITY_TYPES:
|
|
64
|
+
logger.warning("Invalid entity_type %r (valid: %s)", entity_type, VALID_ENTITY_TYPES)
|
|
65
|
+
if action not in VALID_ACTIONS:
|
|
66
|
+
logger.warning("Invalid action %r (valid: %s)", action, VALID_ACTIONS)
|
|
67
|
+
|
|
68
|
+
entry: dict[str, Any] = {
|
|
69
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
70
|
+
"entity_type": entity_type,
|
|
71
|
+
"entity_id": entity_id,
|
|
72
|
+
"action": action,
|
|
73
|
+
"old_value": old_value,
|
|
74
|
+
"new_value": new_value,
|
|
75
|
+
"session_id": session_id,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
with self._lock:
|
|
79
|
+
self._rotate_if_needed()
|
|
80
|
+
try:
|
|
81
|
+
with open(self._log_file, "a", encoding="utf-8") as f:
|
|
82
|
+
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
|
83
|
+
except OSError as e:
|
|
84
|
+
logger.error("Failed to write activity entry: %s", e)
|
|
85
|
+
|
|
86
|
+
return entry
|
|
87
|
+
|
|
88
|
+
def query_since(self, timestamp: str) -> list[dict[str, Any]]:
|
|
89
|
+
"""Return activity entries after the given ISO timestamp."""
|
|
90
|
+
# Normalize Z-suffix so comparisons work consistently
|
|
91
|
+
timestamp = timestamp.replace("Z", "+00:00")
|
|
92
|
+
results: list[dict[str, Any]] = []
|
|
93
|
+
|
|
94
|
+
if not self._log_file.exists():
|
|
95
|
+
return results
|
|
96
|
+
|
|
97
|
+
with self._lock:
|
|
98
|
+
try:
|
|
99
|
+
with open(self._log_file, "r", encoding="utf-8") as f:
|
|
100
|
+
for line in f:
|
|
101
|
+
line = line.strip()
|
|
102
|
+
if not line:
|
|
103
|
+
continue
|
|
104
|
+
try:
|
|
105
|
+
entry = json.loads(line)
|
|
106
|
+
entry_ts = entry.get("timestamp", "").replace("Z", "+00:00")
|
|
107
|
+
if entry_ts > timestamp:
|
|
108
|
+
results.append(entry)
|
|
109
|
+
except json.JSONDecodeError:
|
|
110
|
+
continue
|
|
111
|
+
except OSError as e:
|
|
112
|
+
logger.error("Failed to read activity log: %s", e)
|
|
113
|
+
|
|
114
|
+
return results
|
|
115
|
+
|
|
116
|
+
def get_session_diff(self, since_timestamp: Optional[str] = None) -> dict[str, Any]:
|
|
117
|
+
"""Return a structured summary of activity since the given timestamp.
|
|
118
|
+
|
|
119
|
+
If no timestamp is provided, defaults to the last 24 hours.
|
|
120
|
+
"""
|
|
121
|
+
if since_timestamp is None:
|
|
122
|
+
since_dt = datetime.now(timezone.utc) - timedelta(hours=24)
|
|
123
|
+
since_timestamp = since_dt.isoformat()
|
|
124
|
+
|
|
125
|
+
# Normalize Z-suffix before passing to query_since
|
|
126
|
+
since_timestamp = since_timestamp.replace("Z", "+00:00")
|
|
127
|
+
|
|
128
|
+
entries = self.query_since(since_timestamp)
|
|
129
|
+
|
|
130
|
+
now = datetime.now(timezone.utc)
|
|
131
|
+
try:
|
|
132
|
+
since_dt = datetime.fromisoformat(since_timestamp.replace("Z", "+00:00"))
|
|
133
|
+
except (ValueError, AttributeError):
|
|
134
|
+
since_dt = now
|
|
135
|
+
|
|
136
|
+
period_hours = max(0.0, (now - since_dt).total_seconds() / 3600)
|
|
137
|
+
|
|
138
|
+
# Build summary counts
|
|
139
|
+
summary = {
|
|
140
|
+
"total_changes": len(entries),
|
|
141
|
+
"tasks_created": 0,
|
|
142
|
+
"tasks_completed": 0,
|
|
143
|
+
"tasks_blocked": 0,
|
|
144
|
+
"phases_transitioned": 0,
|
|
145
|
+
"checkpoints_created": 0,
|
|
146
|
+
"errors": 0,
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
highlights: list[str] = []
|
|
150
|
+
decisions: list[dict[str, str]] = []
|
|
151
|
+
|
|
152
|
+
for entry in entries:
|
|
153
|
+
entity_type = entry.get("entity_type", "")
|
|
154
|
+
action = entry.get("action", "")
|
|
155
|
+
entity_id = entry.get("entity_id", "")
|
|
156
|
+
|
|
157
|
+
if entity_type == "task":
|
|
158
|
+
if action == "created":
|
|
159
|
+
summary["tasks_created"] += 1
|
|
160
|
+
highlights.append(f"Task {entity_id} created")
|
|
161
|
+
elif action == "completed":
|
|
162
|
+
summary["tasks_completed"] += 1
|
|
163
|
+
highlights.append(f"Task {entity_id} completed")
|
|
164
|
+
elif action == "blocked":
|
|
165
|
+
summary["tasks_blocked"] += 1
|
|
166
|
+
highlights.append(f"Task {entity_id} blocked")
|
|
167
|
+
elif action == "failed":
|
|
168
|
+
summary["errors"] += 1
|
|
169
|
+
highlights.append(f"Task {entity_id} failed")
|
|
170
|
+
elif action == "status_changed":
|
|
171
|
+
old_val = entry.get("old_value", "")
|
|
172
|
+
new_val = entry.get("new_value", "")
|
|
173
|
+
highlights.append(f"Task {entity_id}: {old_val} -> {new_val}")
|
|
174
|
+
|
|
175
|
+
elif entity_type == "agent":
|
|
176
|
+
if action == "failed":
|
|
177
|
+
summary["errors"] += 1
|
|
178
|
+
highlights.append(f"Agent {entity_id} failed")
|
|
179
|
+
elif action == "created":
|
|
180
|
+
highlights.append(f"Agent {entity_id} created")
|
|
181
|
+
elif action == "status_changed":
|
|
182
|
+
old_val = entry.get("old_value", "")
|
|
183
|
+
new_val = entry.get("new_value", "")
|
|
184
|
+
highlights.append(f"Agent {entity_id}: {old_val} -> {new_val}")
|
|
185
|
+
# Agent status changes may represent decisions
|
|
186
|
+
if new_val:
|
|
187
|
+
decisions.append({
|
|
188
|
+
"timestamp": entry.get("timestamp", ""),
|
|
189
|
+
"decision": f"Agent {entity_id} transitioned to {new_val}",
|
|
190
|
+
"reasoning": f"Status changed from {old_val} to {new_val}",
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
elif entity_type == "phase":
|
|
194
|
+
if action == "status_changed":
|
|
195
|
+
summary["phases_transitioned"] += 1
|
|
196
|
+
old_val = entry.get("old_value", "")
|
|
197
|
+
new_val = entry.get("new_value", "")
|
|
198
|
+
highlights.append(f"Phase transition: {old_val} -> {new_val}")
|
|
199
|
+
decisions.append({
|
|
200
|
+
"timestamp": entry.get("timestamp", ""),
|
|
201
|
+
"decision": f"Phase transitioned to {new_val}",
|
|
202
|
+
"reasoning": f"Moved from {old_val} to {new_val}",
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
elif entity_type == "checkpoint":
|
|
206
|
+
if action == "created":
|
|
207
|
+
summary["checkpoints_created"] += 1
|
|
208
|
+
highlights.append(f"Checkpoint {entity_id} created")
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
"since": since_timestamp,
|
|
212
|
+
"period_hours": round(period_hours, 2),
|
|
213
|
+
"summary": summary,
|
|
214
|
+
"highlights": highlights,
|
|
215
|
+
"decisions": decisions,
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# Singleton instance
|
|
220
|
+
_instance: Optional[ActivityLogger] = None
|
|
221
|
+
_instance_lock = threading.Lock()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def get_activity_logger(data_dir: Optional[str] = None) -> ActivityLogger:
|
|
225
|
+
"""Get or create the singleton ActivityLogger instance."""
|
|
226
|
+
global _instance
|
|
227
|
+
if _instance is None:
|
|
228
|
+
with _instance_lock:
|
|
229
|
+
if _instance is None:
|
|
230
|
+
_instance = ActivityLogger(data_dir=data_dir)
|
|
231
|
+
return _instance
|