@matt82198/aesop 0.1.0 → 0.3.1
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/CHANGELOG.md +117 -2
- package/README.md +59 -218
- package/bin/cli.js +168 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +74 -4
- package/docs/PORTING.md +166 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +372 -0
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +150 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +673 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +179 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +254 -0
- package/driver/wave_loop.py +1408 -0
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +131 -33
- package/mcp/server.mjs +320 -4
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +22 -14
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +4 -2
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/store.py +185 -73
- package/state_store/write_api.py +462 -0
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +166 -43
- package/tools/cost_econ.py +480 -0
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +188 -26
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +523 -0
- package/tools/portability_check.py +206 -0
- package/tools/proposals.mjs +47 -2
- package/tools/reconcile.py +7 -4
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +207 -65
- package/tools/self_stats.py +20 -0
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +779 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +340 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +81 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +383 -55
- package/ui/quality_scorecard.py +232 -0
- package/ui/sse.py +3 -3
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +383 -0
- package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""test_battery.py -- run the local union test battery, parallel by default.
|
|
3
|
+
|
|
4
|
+
Runs the four harnesses (python unittest discover, node --test, shell suites,
|
|
5
|
+
ui vitest+tsc) as concurrent subprocesses with per-harness rc capture, stdin
|
|
6
|
+
closed (the hook suite hangs on never-EOF stdin), and an explicit summary
|
|
7
|
+
table. Exit 0 only when every harness exits 0.
|
|
8
|
+
|
|
9
|
+
Per-harness timeout (AESOP_BATTERY_HARNESS_TIMEOUT_S, default 1800s = 30min):
|
|
10
|
+
on expiry, the process tree is killed and rc=124 is recorded with a TIMEOUT
|
|
11
|
+
note. Applies in both serial and parallel modes.
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
python tools/test_battery.py [--serial] [--skip ui|sh|node|py ...] [--json]
|
|
15
|
+
|
|
16
|
+
--serial runs harnesses one at a time (the pre-wave-29 behavior; fallback if
|
|
17
|
+
parallel runs prove load-fragile on a box). Logs land in the state scratch dir
|
|
18
|
+
(AESOP_BATTERY_LOGDIR or the system temp dir) as battery-<harness>.log.
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import platform
|
|
24
|
+
import signal
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
REPO = Path(__file__).resolve().parent.parent
|
|
32
|
+
|
|
33
|
+
HARNESSES = {
|
|
34
|
+
"py": [sys.executable, "-m", "unittest", "discover", "-s", "tests"],
|
|
35
|
+
"node": ["npm", "run", "test:node"],
|
|
36
|
+
"sh": ["npm", "run", "test:sh"],
|
|
37
|
+
"ui": None, # composite: tsc + vitest, run via _ui_command
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _get_harness_timeout():
|
|
42
|
+
"""Get per-harness timeout in seconds (env AESOP_BATTERY_HARNESS_TIMEOUT_S, default 1800)."""
|
|
43
|
+
timeout_s = os.environ.get("AESOP_BATTERY_HARNESS_TIMEOUT_S", "1800")
|
|
44
|
+
try:
|
|
45
|
+
return int(timeout_s)
|
|
46
|
+
except ValueError:
|
|
47
|
+
return 1800
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _kill_process_tree(proc):
|
|
51
|
+
"""Kill process and all children (Windows: taskkill /T /F, others: SIGKILL)."""
|
|
52
|
+
try:
|
|
53
|
+
if platform.system() == "Windows":
|
|
54
|
+
subprocess.run(
|
|
55
|
+
["taskkill", "/PID", str(proc.pid), "/T", "/F"],
|
|
56
|
+
capture_output=True,
|
|
57
|
+
timeout=5,
|
|
58
|
+
)
|
|
59
|
+
else:
|
|
60
|
+
# Unix: SIGKILL via os.killpg (process group)
|
|
61
|
+
try:
|
|
62
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
63
|
+
except (ProcessLookupError, OSError):
|
|
64
|
+
# Process already gone or not in a group; try direct kill
|
|
65
|
+
try:
|
|
66
|
+
os.kill(proc.pid, signal.SIGKILL)
|
|
67
|
+
except (ProcessLookupError, OSError):
|
|
68
|
+
pass
|
|
69
|
+
except Exception:
|
|
70
|
+
# Ignore kill errors; process may already be dead
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _wait_with_timeout(proc, timeout_s):
|
|
75
|
+
"""Wait for process with timeout; return (rc, timed_out).
|
|
76
|
+
|
|
77
|
+
On timeout, kills the process tree and returns (124, True).
|
|
78
|
+
Otherwise returns (proc.returncode, False).
|
|
79
|
+
"""
|
|
80
|
+
try:
|
|
81
|
+
proc.wait(timeout=timeout_s)
|
|
82
|
+
return proc.returncode, False
|
|
83
|
+
except subprocess.TimeoutExpired:
|
|
84
|
+
_kill_process_tree(proc)
|
|
85
|
+
# Wait a moment for kill to take effect, then force reap
|
|
86
|
+
try:
|
|
87
|
+
proc.wait(timeout=1)
|
|
88
|
+
except subprocess.TimeoutExpired:
|
|
89
|
+
pass
|
|
90
|
+
return 124, True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _ui_command():
|
|
94
|
+
# ui/web needs node_modules; npx resolves local binaries.
|
|
95
|
+
return "npx tsc --noEmit && npx vitest run --silent"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_harness(name, logdir, parallel=True):
|
|
99
|
+
"""Spawn one harness with stdin closed; return (name, Popen, logfile)."""
|
|
100
|
+
log = Path(logdir) / f"battery-{name}.log"
|
|
101
|
+
f = open(log, "w", encoding="utf-8", errors="replace")
|
|
102
|
+
env = os.environ.copy()
|
|
103
|
+
if parallel and name == "node" and "AESOP_TEST_CHILD_TIMEOUT_MS" not in env:
|
|
104
|
+
# Under 4-harness parallel load, scaffold child processes legitimately
|
|
105
|
+
# exceed the 30s solo ceiling; the tests honor this knob (wave-29).
|
|
106
|
+
env["AESOP_TEST_CHILD_TIMEOUT_MS"] = "90000"
|
|
107
|
+
if name == "ui":
|
|
108
|
+
proc = subprocess.Popen(
|
|
109
|
+
_ui_command(), shell=True, cwd=str(REPO / "ui" / "web"),
|
|
110
|
+
stdin=subprocess.DEVNULL, stdout=f, stderr=subprocess.STDOUT, env=env,
|
|
111
|
+
)
|
|
112
|
+
else:
|
|
113
|
+
# npm needs shell resolution on Windows.
|
|
114
|
+
use_shell = os.name == "nt" and HARNESSES[name][0] == "npm"
|
|
115
|
+
cmd = " ".join(HARNESSES[name]) if use_shell else HARNESSES[name]
|
|
116
|
+
proc = subprocess.Popen(
|
|
117
|
+
cmd, shell=use_shell, cwd=str(REPO),
|
|
118
|
+
stdin=subprocess.DEVNULL, stdout=f, stderr=subprocess.STDOUT, env=env,
|
|
119
|
+
)
|
|
120
|
+
return name, proc, f, log
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def main():
|
|
124
|
+
ap = argparse.ArgumentParser(description="Run the local union test battery")
|
|
125
|
+
ap.add_argument("--serial", action="store_true", help="run harnesses sequentially")
|
|
126
|
+
ap.add_argument("--skip", action="append", default=[], choices=list(HARNESSES),
|
|
127
|
+
help="skip a harness (repeatable)")
|
|
128
|
+
ap.add_argument("--json", action="store_true", help="machine-readable summary")
|
|
129
|
+
args = ap.parse_args()
|
|
130
|
+
|
|
131
|
+
logdir = os.environ.get("AESOP_BATTERY_LOGDIR") or tempfile.mkdtemp(prefix="aesop-battery-")
|
|
132
|
+
os.makedirs(logdir, exist_ok=True)
|
|
133
|
+
names = [n for n in HARNESSES if n not in args.skip]
|
|
134
|
+
started = time.time()
|
|
135
|
+
results = {}
|
|
136
|
+
timeout_s = _get_harness_timeout()
|
|
137
|
+
|
|
138
|
+
if args.serial:
|
|
139
|
+
for n in names:
|
|
140
|
+
name, proc, f, log = run_harness(n, logdir, parallel=False)
|
|
141
|
+
rc, timed_out = _wait_with_timeout(proc, timeout_s)
|
|
142
|
+
f.close()
|
|
143
|
+
result = {"rc": rc, "log": str(log)}
|
|
144
|
+
if timed_out:
|
|
145
|
+
result["note"] = "TIMEOUT"
|
|
146
|
+
results[name] = result
|
|
147
|
+
else:
|
|
148
|
+
procs = [run_harness(n, logdir) for n in names]
|
|
149
|
+
for name, proc, f, log in procs:
|
|
150
|
+
rc, timed_out = _wait_with_timeout(proc, timeout_s)
|
|
151
|
+
f.close()
|
|
152
|
+
result = {"rc": rc, "log": str(log)}
|
|
153
|
+
if timed_out:
|
|
154
|
+
result["note"] = "TIMEOUT"
|
|
155
|
+
results[name] = result
|
|
156
|
+
|
|
157
|
+
wall = round(time.time() - started, 1)
|
|
158
|
+
ok = all(r["rc"] == 0 for r in results.values())
|
|
159
|
+
if args.json:
|
|
160
|
+
print(json.dumps({"ok": ok, "wall_s": wall, "mode": "serial" if args.serial else "parallel",
|
|
161
|
+
"results": results}))
|
|
162
|
+
else:
|
|
163
|
+
print(f"battery mode={'serial' if args.serial else 'parallel'} wall={wall}s")
|
|
164
|
+
for n, r in results.items():
|
|
165
|
+
verdict = "PASS" if r["rc"] == 0 else "FAIL"
|
|
166
|
+
note_str = f" {r['note']}" if "note" in r else ""
|
|
167
|
+
print(f" {n:5s} rc={r['rc']} {verdict}{note_str} log={r['log']}")
|
|
168
|
+
print("BATTERY:", "GREEN" if ok else "RED")
|
|
169
|
+
sys.exit(0 if ok else 1)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
if __name__ == "__main__":
|
|
173
|
+
main()
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
transcript_digest.py — Digest agent transcripts into compact briefs.
|
|
4
|
+
|
|
5
|
+
Reads agent-*.jsonl transcripts from a session subagents directory and appends
|
|
6
|
+
compact ~200-byte per-agent briefs to state/ledger/transcripts-brief.jsonl.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python -m tools.transcript_digest --transcripts-dir /path/to/session/subagents \
|
|
10
|
+
--wave rc.6 [--state-root /path/to/state]
|
|
11
|
+
|
|
12
|
+
Briefs include: agent label, files touched, tool-call count, pass/fail outcome,
|
|
13
|
+
token count, and a 1-2 sentence summary. Aggressive redaction removes absolute
|
|
14
|
+
paths, usernames, emails, tokens, and repo names.
|
|
15
|
+
|
|
16
|
+
Deterministic + idempotent (skips agents already in the ledger).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Redaction patterns (derived from secret_scan.py)
|
|
30
|
+
REDACTION_PATTERNS = { # secretscan: allow-pattern-docs
|
|
31
|
+
"pem_private_key": (r"-----BEGIN .* PRIVATE " r"KEY-----.*?-----END .* PRIVATE " r"KEY-----", re.DOTALL | re.IGNORECASE),
|
|
32
|
+
"aws_access_key": (r"AKIA[0-9A-Z]{16}", 0),
|
|
33
|
+
"aws_secret_pattern": (r"aws[_-]?secret[_-]?access[_-]?key\s*[:=]\s*[^\s\$\<\{]", re.IGNORECASE),
|
|
34
|
+
"github_token": (r"(ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{20,}", 0),
|
|
35
|
+
"slack_token": (r"xox[baprs]-[A-Za-z0-9-]{10,}", 0),
|
|
36
|
+
"openai_anthropic_key": (r"sk-[A-Za-z0-9_\-]{20,}", 0),
|
|
37
|
+
"connection_string": (r"://[^:]+:[^@/\s]+@(?!localhost|127\.|example|test)[^\s]+", 0),
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
# Patterns to redact by category
|
|
41
|
+
EMAIL_PATTERN = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
|
|
42
|
+
# Windows path: C:\ or POSIX path: /
|
|
43
|
+
PATH_PATTERN = r"[A-Za-z]:\\[^\\/:*<>|]*|/[^/:*<>|]*"
|
|
44
|
+
REPO_NAME_PATTERN = r"\b(?:aesop|conductor3|tr-sample-tracker|ecm-ai|TR-Automation-Scripts)\b"
|
|
45
|
+
USERNAME_PATTERN = r"\b(?:matt8|matt82198|John|Jack|Doe)\b"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def redact_text(text: str) -> str:
|
|
49
|
+
"""Aggressively redact secrets, paths, emails, usernames, and repo names."""
|
|
50
|
+
if not text:
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
# Redact keys and credentials
|
|
54
|
+
for pattern, flags in REDACTION_PATTERNS.values():
|
|
55
|
+
text = re.sub(pattern, "[REDACTED]", text, flags=flags)
|
|
56
|
+
|
|
57
|
+
# Redact emails
|
|
58
|
+
text = re.sub(EMAIL_PATTERN, "[EMAIL]", text, flags=re.IGNORECASE)
|
|
59
|
+
|
|
60
|
+
# Redact absolute paths (Windows and POSIX)
|
|
61
|
+
text = re.sub(PATH_PATTERN, "[PATH]", text)
|
|
62
|
+
|
|
63
|
+
# Redact repo names
|
|
64
|
+
text = re.sub(REPO_NAME_PATTERN, "[REPO]", text, flags=re.IGNORECASE)
|
|
65
|
+
|
|
66
|
+
# Redact usernames
|
|
67
|
+
text = re.sub(USERNAME_PATTERN, "[USER]", text, flags=re.IGNORECASE)
|
|
68
|
+
|
|
69
|
+
return text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def infer_outcome(messages: List[Dict], errors: List[Dict]) -> str:
|
|
73
|
+
"""Infer outcome from transcript structure: completed|stalled|failed|timeout."""
|
|
74
|
+
# Check for explicit timeout markers first
|
|
75
|
+
if errors:
|
|
76
|
+
if any("timeout" in e.get("message", "").lower() for e in errors):
|
|
77
|
+
return "timeout"
|
|
78
|
+
return "stalled"
|
|
79
|
+
|
|
80
|
+
if not messages:
|
|
81
|
+
return "failed"
|
|
82
|
+
|
|
83
|
+
last_msg = messages[-1] if isinstance(messages, list) else {}
|
|
84
|
+
msg_type = last_msg.get("type", "")
|
|
85
|
+
|
|
86
|
+
# If last message is a tool result with error, mark as stalled
|
|
87
|
+
if msg_type == "tool_result" and "error" in last_msg:
|
|
88
|
+
return "stalled"
|
|
89
|
+
|
|
90
|
+
# Default to completed
|
|
91
|
+
return "completed"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def extract_files_from_calls(messages: List[Dict]) -> Tuple[Set[str], Set[str]]:
|
|
95
|
+
"""Extract created and modified file paths from tool calls."""
|
|
96
|
+
created = set()
|
|
97
|
+
modified = set()
|
|
98
|
+
|
|
99
|
+
if not isinstance(messages, list):
|
|
100
|
+
return created, modified
|
|
101
|
+
|
|
102
|
+
for msg in messages:
|
|
103
|
+
if msg.get("type") == "tool_result" or "content" in msg:
|
|
104
|
+
content = msg.get("content", "")
|
|
105
|
+
if isinstance(content, str):
|
|
106
|
+
# Look for Write, Edit, Read operations in logs
|
|
107
|
+
if "Write" in content or "write" in content:
|
|
108
|
+
# Heuristic: files mentioned after write ops are likely created
|
|
109
|
+
matches = re.findall(r"(?:Write|write|created?|added?)\s+(?:to\s+)?['\"]?([^\s'\"]+\.(?:py|js|md|json|sh))", content)
|
|
110
|
+
created.update(matches)
|
|
111
|
+
if "Edit" in content or "edit" in content or "modified?" in content:
|
|
112
|
+
matches = re.findall(r"(?:Edit|edit|modified?|updated?)\s+(?:file\s+)?['\"]?([^\s'\"]+\.(?:py|js|md|json|sh))", content)
|
|
113
|
+
modified.update(matches)
|
|
114
|
+
|
|
115
|
+
# Redact file paths
|
|
116
|
+
created = {redact_text(f) for f in created}
|
|
117
|
+
modified = {redact_text(f) for f in modified}
|
|
118
|
+
|
|
119
|
+
return created, modified
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def extract_tool_calls(messages: List[Dict]) -> Tuple[List[str], int]:
|
|
123
|
+
"""Extract tool call types and count."""
|
|
124
|
+
tools = {}
|
|
125
|
+
|
|
126
|
+
if not isinstance(messages, list):
|
|
127
|
+
return [], 0
|
|
128
|
+
|
|
129
|
+
for msg in messages:
|
|
130
|
+
if msg.get("type") == "tool_use":
|
|
131
|
+
tool_name = msg.get("name", "Unknown")
|
|
132
|
+
tools[tool_name] = tools.get(tool_name, 0) + 1
|
|
133
|
+
|
|
134
|
+
# Sort by frequency, take top 3
|
|
135
|
+
top_tools = sorted(tools.keys(), key=lambda t: tools[t], reverse=True)[:3]
|
|
136
|
+
total_calls = sum(tools.values())
|
|
137
|
+
|
|
138
|
+
return top_tools, total_calls
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def extract_errors(messages: List[Dict]) -> List[Dict]:
|
|
142
|
+
"""Extract error messages with timestamps."""
|
|
143
|
+
errors = []
|
|
144
|
+
|
|
145
|
+
if not isinstance(messages, list):
|
|
146
|
+
return errors
|
|
147
|
+
|
|
148
|
+
for i, msg in enumerate(messages):
|
|
149
|
+
if msg.get("type") == "tool_result" and msg.get("is_error"):
|
|
150
|
+
error_msg = msg.get("content", "Unknown error")
|
|
151
|
+
# Redact sensitive data from error messages
|
|
152
|
+
error_msg = redact_text(error_msg)
|
|
153
|
+
# Truncate to ~100 chars
|
|
154
|
+
error_msg = error_msg[:100]
|
|
155
|
+
errors.append({
|
|
156
|
+
"tool": msg.get("name", "Unknown"),
|
|
157
|
+
"at_sec": i * 5, # Rough estimate: ~5 sec per message
|
|
158
|
+
"message": error_msg
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
return errors[:3] # Keep top 3 errors
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def extract_token_usage(metadata: Dict) -> Dict:
|
|
165
|
+
"""Extract token usage from metadata."""
|
|
166
|
+
usage = metadata.get("usage", {})
|
|
167
|
+
return {
|
|
168
|
+
"input": usage.get("input_tokens", 0),
|
|
169
|
+
"output": usage.get("output_tokens", 0),
|
|
170
|
+
"model": metadata.get("model", "haiku")
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def generate_brief(
|
|
175
|
+
messages: List[Dict],
|
|
176
|
+
tool_calls: List[str],
|
|
177
|
+
files_created: Set[str],
|
|
178
|
+
files_modified: Set[str],
|
|
179
|
+
errors: List[Dict]
|
|
180
|
+
) -> str:
|
|
181
|
+
"""Generate a 1-2 sentence summary of the transcript."""
|
|
182
|
+
parts = []
|
|
183
|
+
|
|
184
|
+
if tool_calls:
|
|
185
|
+
parts.append(f"Used {len(tool_calls)} tool types ({', '.join(tool_calls[:2])})")
|
|
186
|
+
|
|
187
|
+
total_files = len(files_created) + len(files_modified)
|
|
188
|
+
if total_files > 0:
|
|
189
|
+
parts.append(f"modified {total_files} file(s)")
|
|
190
|
+
|
|
191
|
+
if errors:
|
|
192
|
+
parts.append(f"encountered {len(errors)} error(s)")
|
|
193
|
+
else:
|
|
194
|
+
parts.append("completed without errors")
|
|
195
|
+
|
|
196
|
+
brief = "; ".join(parts) + "."
|
|
197
|
+
|
|
198
|
+
# Truncate to ~150 chars max
|
|
199
|
+
if len(brief) > 150:
|
|
200
|
+
brief = brief[:147] + "..."
|
|
201
|
+
|
|
202
|
+
return brief
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def stream_jsonl_transcripts(transcripts_dir: Path) -> Dict[str, Tuple[Dict, List]]:
|
|
206
|
+
"""Stream parse all agent-*.jsonl files in directory."""
|
|
207
|
+
agents = {}
|
|
208
|
+
|
|
209
|
+
if not transcripts_dir.exists():
|
|
210
|
+
return agents
|
|
211
|
+
|
|
212
|
+
for jsonl_file in sorted(transcripts_dir.glob("agent-*.jsonl")):
|
|
213
|
+
agent_id = jsonl_file.stem.replace("agent-", "")
|
|
214
|
+
messages = []
|
|
215
|
+
metadata = {}
|
|
216
|
+
|
|
217
|
+
try:
|
|
218
|
+
with open(jsonl_file, "r", encoding="utf-8") as f:
|
|
219
|
+
for line in f:
|
|
220
|
+
if not line.strip():
|
|
221
|
+
continue
|
|
222
|
+
try:
|
|
223
|
+
obj = json.loads(line)
|
|
224
|
+
if obj.get("type") == "metadata":
|
|
225
|
+
metadata = obj
|
|
226
|
+
else:
|
|
227
|
+
messages.append(obj)
|
|
228
|
+
except json.JSONDecodeError:
|
|
229
|
+
continue
|
|
230
|
+
except Exception:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
if messages or metadata:
|
|
234
|
+
agents[agent_id] = (metadata, messages)
|
|
235
|
+
|
|
236
|
+
return agents
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def create_brief(
|
|
240
|
+
wave: str,
|
|
241
|
+
agent_id: str,
|
|
242
|
+
metadata: Dict,
|
|
243
|
+
messages: List[Dict]
|
|
244
|
+
) -> Dict:
|
|
245
|
+
"""Create a brief dict from transcript data."""
|
|
246
|
+
start_time = metadata.get("start_time", datetime.now(timezone.utc).isoformat())
|
|
247
|
+
end_time = metadata.get("end_time", datetime.now(timezone.utc).isoformat())
|
|
248
|
+
|
|
249
|
+
# Parse timestamps to compute duration
|
|
250
|
+
try:
|
|
251
|
+
start = datetime.fromisoformat(start_time.replace("Z", "+00:00"))
|
|
252
|
+
end = datetime.fromisoformat(end_time.replace("Z", "+00:00"))
|
|
253
|
+
duration_sec = int((end - start).total_seconds())
|
|
254
|
+
except Exception:
|
|
255
|
+
duration_sec = 0
|
|
256
|
+
|
|
257
|
+
tool_calls, tool_count = extract_tool_calls(messages)
|
|
258
|
+
files_created, files_modified = extract_files_from_calls(messages)
|
|
259
|
+
errors = extract_errors(messages)
|
|
260
|
+
outcome = infer_outcome(messages, errors)
|
|
261
|
+
token_usage = extract_token_usage(metadata)
|
|
262
|
+
brief_text = generate_brief(messages, tool_calls, files_created, files_modified, errors)
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
"wave": wave,
|
|
266
|
+
"agent_id": agent_id,
|
|
267
|
+
"start_time": start_time,
|
|
268
|
+
"end_time": end_time,
|
|
269
|
+
"duration_sec": duration_sec,
|
|
270
|
+
"outcome": outcome,
|
|
271
|
+
"top_tool_calls": tool_calls,
|
|
272
|
+
"files_created": sorted(list(files_created)),
|
|
273
|
+
"files_modified": sorted(list(files_modified)),
|
|
274
|
+
"errors": errors,
|
|
275
|
+
"token_usage": token_usage,
|
|
276
|
+
"brief": brief_text,
|
|
277
|
+
"brief_schema_version": 1
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def get_existing_agent_ids(ledger_path: Path) -> Set[str]:
|
|
282
|
+
"""Read already-digested agent IDs from ledger."""
|
|
283
|
+
agent_ids = set()
|
|
284
|
+
|
|
285
|
+
if not ledger_path.exists():
|
|
286
|
+
return agent_ids
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
with open(ledger_path, "r", encoding="utf-8") as f:
|
|
290
|
+
for line in f:
|
|
291
|
+
if not line.strip():
|
|
292
|
+
continue
|
|
293
|
+
try:
|
|
294
|
+
obj = json.loads(line)
|
|
295
|
+
agent_ids.add(obj.get("agent_id", ""))
|
|
296
|
+
except json.JSONDecodeError:
|
|
297
|
+
continue
|
|
298
|
+
except Exception:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
return agent_ids
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def append_briefs(ledger_path: Path, briefs: List[Dict]) -> int:
|
|
305
|
+
"""Append briefs to ledger file (create parent dir if needed)."""
|
|
306
|
+
if not briefs:
|
|
307
|
+
return 0
|
|
308
|
+
|
|
309
|
+
# Ensure parent directory exists
|
|
310
|
+
ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
|
|
312
|
+
# Append briefs
|
|
313
|
+
try:
|
|
314
|
+
with open(ledger_path, "a", encoding="utf-8") as f:
|
|
315
|
+
for brief in briefs:
|
|
316
|
+
f.write(json.dumps(brief) + "\n")
|
|
317
|
+
return len(briefs)
|
|
318
|
+
except Exception:
|
|
319
|
+
return 0
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def main():
|
|
323
|
+
parser = argparse.ArgumentParser(
|
|
324
|
+
description=__doc__,
|
|
325
|
+
formatter_class=argparse.RawDescriptionHelpFormatter
|
|
326
|
+
)
|
|
327
|
+
parser.add_argument(
|
|
328
|
+
"--transcripts-dir",
|
|
329
|
+
type=Path,
|
|
330
|
+
required=True,
|
|
331
|
+
help="Directory containing agent-*.jsonl files"
|
|
332
|
+
)
|
|
333
|
+
parser.add_argument(
|
|
334
|
+
"--wave",
|
|
335
|
+
default="unknown",
|
|
336
|
+
help="Wave ID for the briefs (default: unknown)"
|
|
337
|
+
)
|
|
338
|
+
parser.add_argument(
|
|
339
|
+
"--state-root",
|
|
340
|
+
type=Path,
|
|
341
|
+
default=Path(os.environ.get("AESOP_STATE_ROOT", "./state")),
|
|
342
|
+
help="State directory root (default: $AESOP_STATE_ROOT or ./state)"
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
args = parser.parse_args()
|
|
346
|
+
|
|
347
|
+
# Resolve paths
|
|
348
|
+
transcripts_dir = args.transcripts_dir.resolve()
|
|
349
|
+
ledger_path = args.state_root / "ledger" / "transcripts-brief.jsonl"
|
|
350
|
+
|
|
351
|
+
if not transcripts_dir.exists():
|
|
352
|
+
print(f"ERROR: transcripts directory not found: {transcripts_dir}", file=sys.stderr)
|
|
353
|
+
sys.exit(1)
|
|
354
|
+
|
|
355
|
+
# Read existing agent IDs (for idempotency)
|
|
356
|
+
existing_ids = get_existing_agent_ids(ledger_path)
|
|
357
|
+
|
|
358
|
+
# Stream and digest transcripts
|
|
359
|
+
agents = stream_jsonl_transcripts(transcripts_dir)
|
|
360
|
+
briefs_to_append = []
|
|
361
|
+
|
|
362
|
+
for agent_id, (metadata, messages) in sorted(agents.items()):
|
|
363
|
+
# Skip if already digested
|
|
364
|
+
if agent_id in existing_ids:
|
|
365
|
+
continue
|
|
366
|
+
|
|
367
|
+
brief = create_brief(args.wave, agent_id, metadata, messages)
|
|
368
|
+
briefs_to_append.append(brief)
|
|
369
|
+
|
|
370
|
+
# Append to ledger
|
|
371
|
+
count = append_briefs(ledger_path, briefs_to_append)
|
|
372
|
+
|
|
373
|
+
if count > 0:
|
|
374
|
+
print(f"Appended {count} brief(s) to {ledger_path}")
|
|
375
|
+
|
|
376
|
+
sys.exit(0)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
if __name__ == "__main__":
|
|
380
|
+
main()
|