squads-cli 0.5.0 → 0.6.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 +161 -4
- package/dist/{chunk-HKWCBCEK.js → chunk-4CMAEQQY.js} +6 -2
- package/dist/chunk-4CMAEQQY.js.map +1 -0
- package/dist/{chunk-NA3IECJA.js → chunk-N7KDWU4W.js} +155 -58
- package/dist/chunk-N7KDWU4W.js.map +1 -0
- package/dist/{chunk-7PRYDHZW.js → chunk-NHGLXN2F.js} +8 -6
- package/dist/chunk-NHGLXN2F.js.map +1 -0
- package/dist/{chunk-QPH5OR7J.js → chunk-O7UV3FWI.js} +139 -21
- package/dist/chunk-O7UV3FWI.js.map +1 -0
- package/dist/{chunk-BV6S5AWZ.js → chunk-ZTQ7ISUR.js} +28 -109
- package/dist/chunk-ZTQ7ISUR.js.map +1 -0
- package/dist/cli.js +5493 -7665
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +110 -2
- package/dist/index.js +302 -26
- package/dist/index.js.map +1 -1
- package/dist/{memory-ZXDXF6KF.js → memory-VNF2VFRB.js} +2 -2
- package/dist/{sessions-F6LRY7EN.js → sessions-6PB7ALCE.js} +3 -3
- package/dist/{squad-parser-MSYE4PXL.js → squad-parser-4BI3G4RS.js} +4 -2
- package/dist/templates/seed/BUSINESS_BRIEF.md.template +27 -0
- package/dist/templates/seed/CLAUDE.md.template +69 -0
- package/dist/templates/seed/config/provider.yaml +4 -0
- package/dist/templates/seed/hooks/settings.json.template +31 -0
- package/dist/templates/seed/memory/company/manager/state.md +16 -0
- package/dist/templates/seed/memory/engineering/issue-solver/state.md +12 -0
- package/dist/templates/seed/memory/intelligence/intel-lead/state.md +9 -0
- package/dist/templates/seed/memory/marketing/content-drafter/state.md +12 -0
- package/dist/templates/seed/memory/operations/ops-lead/state.md +12 -0
- package/dist/templates/seed/memory/research/researcher/state.md +10 -0
- package/dist/templates/seed/skills/gh/SKILL.md +57 -0
- package/dist/templates/seed/skills/squads-cli/SKILL.md +88 -0
- package/dist/templates/seed/squads/company/SQUAD.md +49 -0
- package/dist/templates/seed/squads/company/company-critic.md +21 -0
- package/dist/templates/seed/squads/company/company-eval.md +21 -0
- package/dist/templates/seed/squads/company/event-dispatcher.md +21 -0
- package/dist/templates/seed/squads/company/goal-tracker.md +21 -0
- package/dist/templates/seed/squads/company/manager.md +66 -0
- package/dist/templates/seed/squads/engineering/SQUAD.md +48 -0
- package/dist/templates/seed/squads/engineering/code-reviewer.md +57 -0
- package/dist/templates/seed/squads/engineering/issue-solver.md +58 -0
- package/dist/templates/seed/squads/engineering/test-writer.md +50 -0
- package/dist/templates/seed/squads/intelligence/SQUAD.md +37 -0
- package/dist/templates/seed/squads/intelligence/intel-critic.md +36 -0
- package/dist/templates/seed/squads/intelligence/intel-eval.md +31 -0
- package/dist/templates/seed/squads/intelligence/intel-lead.md +71 -0
- package/dist/templates/seed/squads/marketing/SQUAD.md +47 -0
- package/dist/templates/seed/squads/marketing/content-drafter.md +71 -0
- package/dist/templates/seed/squads/marketing/growth-analyst.md +49 -0
- package/dist/templates/seed/squads/marketing/social-poster.md +44 -0
- package/dist/templates/seed/squads/operations/SQUAD.md +45 -0
- package/dist/templates/seed/squads/operations/finance-tracker.md +47 -0
- package/dist/templates/seed/squads/operations/goal-tracker.md +48 -0
- package/dist/templates/seed/squads/operations/ops-lead.md +58 -0
- package/dist/templates/seed/squads/research/SQUAD.md +38 -0
- package/dist/templates/seed/squads/research/analyst.md +27 -0
- package/dist/templates/seed/squads/research/research-critic.md +20 -0
- package/dist/templates/seed/squads/research/research-eval.md +20 -0
- package/dist/templates/seed/squads/research/researcher.md +28 -0
- package/dist/{terminal-JZSAQSN7.js → terminal-YKA4O5CX.js} +4 -2
- package/dist/{update-MAY6EXFQ.js → update-ALJKFFM7.js} +3 -2
- package/package.json +8 -21
- package/templates/seed/BUSINESS_BRIEF.md.template +27 -0
- package/templates/seed/CLAUDE.md.template +69 -0
- package/templates/seed/config/provider.yaml +4 -0
- package/templates/seed/hooks/settings.json.template +31 -0
- package/templates/seed/memory/company/manager/state.md +16 -0
- package/templates/seed/memory/engineering/issue-solver/state.md +12 -0
- package/templates/seed/memory/intelligence/intel-lead/state.md +9 -0
- package/templates/seed/memory/marketing/content-drafter/state.md +12 -0
- package/templates/seed/memory/operations/ops-lead/state.md +12 -0
- package/templates/seed/memory/research/researcher/state.md +10 -0
- package/templates/seed/skills/gh/SKILL.md +57 -0
- package/templates/seed/skills/squads-cli/SKILL.md +88 -0
- package/templates/seed/squads/company/SQUAD.md +49 -0
- package/templates/seed/squads/company/company-critic.md +21 -0
- package/templates/seed/squads/company/company-eval.md +21 -0
- package/templates/seed/squads/company/event-dispatcher.md +21 -0
- package/templates/seed/squads/company/goal-tracker.md +21 -0
- package/templates/seed/squads/company/manager.md +66 -0
- package/templates/seed/squads/engineering/SQUAD.md +48 -0
- package/templates/seed/squads/engineering/code-reviewer.md +57 -0
- package/templates/seed/squads/engineering/issue-solver.md +58 -0
- package/templates/seed/squads/engineering/test-writer.md +50 -0
- package/templates/seed/squads/intelligence/SQUAD.md +37 -0
- package/templates/seed/squads/intelligence/intel-critic.md +36 -0
- package/templates/seed/squads/intelligence/intel-eval.md +31 -0
- package/templates/seed/squads/intelligence/intel-lead.md +71 -0
- package/templates/seed/squads/marketing/SQUAD.md +47 -0
- package/templates/seed/squads/marketing/content-drafter.md +71 -0
- package/templates/seed/squads/marketing/growth-analyst.md +49 -0
- package/templates/seed/squads/marketing/social-poster.md +44 -0
- package/templates/seed/squads/operations/SQUAD.md +45 -0
- package/templates/seed/squads/operations/finance-tracker.md +47 -0
- package/templates/seed/squads/operations/goal-tracker.md +48 -0
- package/templates/seed/squads/operations/ops-lead.md +58 -0
- package/templates/seed/squads/research/SQUAD.md +38 -0
- package/templates/seed/squads/research/analyst.md +27 -0
- package/templates/seed/squads/research/research-critic.md +20 -0
- package/templates/seed/squads/research/research-eval.md +20 -0
- package/templates/seed/squads/research/researcher.md +28 -0
- package/dist/chunk-7PRYDHZW.js.map +0 -1
- package/dist/chunk-BV6S5AWZ.js.map +0 -1
- package/dist/chunk-HKWCBCEK.js.map +0 -1
- package/dist/chunk-NA3IECJA.js.map +0 -1
- package/dist/chunk-QPH5OR7J.js.map +0 -1
- package/docker/.env.example +0 -17
- package/docker/README.md +0 -92
- package/docker/docker-compose.engram.yml +0 -304
- package/docker/docker-compose.yml +0 -250
- package/docker/init-db.sql +0 -478
- package/docker/init-engram-db.sql +0 -148
- package/docker/init-langfuse-db.sh +0 -10
- package/docker/otel-collector.yaml +0 -34
- package/docker/squads-bridge/Dockerfile +0 -14
- package/docker/squads-bridge/Dockerfile.proxy +0 -14
- package/docker/squads-bridge/anthropic_proxy.py +0 -313
- package/docker/squads-bridge/requirements.txt +0 -7
- package/docker/squads-bridge/squads_bridge.py +0 -2299
- package/docker/telemetry-ping/Dockerfile +0 -10
- package/docker/telemetry-ping/deploy.sh +0 -69
- package/docker/telemetry-ping/main.py +0 -136
- package/docker/telemetry-ping/requirements.txt +0 -3
- /package/dist/{memory-ZXDXF6KF.js.map → memory-VNF2VFRB.js.map} +0 -0
- /package/dist/{sessions-F6LRY7EN.js.map → sessions-6PB7ALCE.js.map} +0 -0
- /package/dist/{squad-parser-MSYE4PXL.js.map → squad-parser-4BI3G4RS.js.map} +0 -0
- /package/dist/{terminal-JZSAQSN7.js.map → terminal-YKA4O5CX.js.map} +0 -0
- /package/dist/{update-MAY6EXFQ.js.map → update-ALJKFFM7.js.map} +0 -0
|
@@ -1,2299 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Squads Telemetry Bridge
|
|
3
|
-
Receives OpenTelemetry metrics/logs from Claude Code.
|
|
4
|
-
Saves to PostgreSQL (durable), Redis (real-time), Langfuse (optional).
|
|
5
|
-
Forwards conversations to engram/mem0 for embeddings and graph storage.
|
|
6
|
-
"""
|
|
7
|
-
import os
|
|
8
|
-
import json
|
|
9
|
-
import gzip
|
|
10
|
-
import hashlib
|
|
11
|
-
import threading
|
|
12
|
-
import time
|
|
13
|
-
import requests
|
|
14
|
-
from datetime import datetime, date, timedelta
|
|
15
|
-
from collections import deque
|
|
16
|
-
from flask import Flask, request, jsonify
|
|
17
|
-
import psycopg2
|
|
18
|
-
from psycopg2.extras import RealDictCursor
|
|
19
|
-
import redis
|
|
20
|
-
|
|
21
|
-
app = Flask(__name__)
|
|
22
|
-
|
|
23
|
-
# Configuration
|
|
24
|
-
DEBUG_MODE = os.environ.get("DEBUG", "1") == "1"
|
|
25
|
-
LANGFUSE_ENABLED = os.environ.get("LANGFUSE_ENABLED", "false").lower() == "true"
|
|
26
|
-
# Monthly quota based on Anthropic plan (Max5 = $200/month)
|
|
27
|
-
MONTHLY_QUOTA = float(os.environ.get("SQUADS_MONTHLY_QUOTA", "200.0"))
|
|
28
|
-
recent_logs = deque(maxlen=50)
|
|
29
|
-
|
|
30
|
-
# Engram/mem0 configuration for memory extraction
|
|
31
|
-
ENGRAM_URL = os.environ.get("ENGRAM_URL", "http://host.docker.internal:8000")
|
|
32
|
-
ENGRAM_ENABLED = os.environ.get("ENGRAM_ENABLED", "false").lower() == "true"
|
|
33
|
-
ENGRAM_USER_ID = os.environ.get("ENGRAM_USER_ID", "local")
|
|
34
|
-
|
|
35
|
-
# PostgreSQL connection (durable storage)
|
|
36
|
-
DATABASE_URL = os.environ.get(
|
|
37
|
-
"DATABASE_URL",
|
|
38
|
-
"postgresql://squads:squads_local_dev@postgres:5432/squads"
|
|
39
|
-
)
|
|
40
|
-
|
|
41
|
-
# Redis connection (real-time cache)
|
|
42
|
-
REDIS_URL = os.environ.get("REDIS_URL", "redis://redis:6379/0")
|
|
43
|
-
redis_client = None
|
|
44
|
-
try:
|
|
45
|
-
redis_client = redis.from_url(REDIS_URL, decode_responses=True)
|
|
46
|
-
redis_client.ping()
|
|
47
|
-
print(f"Redis connected: {REDIS_URL}")
|
|
48
|
-
except Exception as e:
|
|
49
|
-
print(f"Redis unavailable (degraded mode): {e}")
|
|
50
|
-
redis_client = None
|
|
51
|
-
|
|
52
|
-
def get_db():
|
|
53
|
-
"""Get database connection."""
|
|
54
|
-
return psycopg2.connect(DATABASE_URL)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
# =============================================================================
|
|
58
|
-
# Redis Keys & Helpers
|
|
59
|
-
# =============================================================================
|
|
60
|
-
def redis_key(prefix: str, *parts) -> str:
|
|
61
|
-
"""Build Redis key: prefix:part1:part2:..."""
|
|
62
|
-
return ":".join([prefix] + [str(p) for p in parts])
|
|
63
|
-
|
|
64
|
-
def today_str() -> str:
|
|
65
|
-
"""Get today's date as string for Redis keys."""
|
|
66
|
-
return date.today().isoformat()
|
|
67
|
-
|
|
68
|
-
def incr_cost(squad: str, cost_usd: float, input_tokens: int, output_tokens: int):
|
|
69
|
-
"""Increment real-time cost counters in Redis."""
|
|
70
|
-
if not redis_client:
|
|
71
|
-
return
|
|
72
|
-
|
|
73
|
-
today = today_str()
|
|
74
|
-
pipe = redis_client.pipeline()
|
|
75
|
-
|
|
76
|
-
# Global daily counters
|
|
77
|
-
pipe.incrbyfloat(redis_key("cost", "daily", today), cost_usd)
|
|
78
|
-
pipe.incrby(redis_key("tokens", "input", today), input_tokens)
|
|
79
|
-
pipe.incrby(redis_key("tokens", "output", today), output_tokens)
|
|
80
|
-
pipe.incr(redis_key("generations", today))
|
|
81
|
-
|
|
82
|
-
# Per-squad counters
|
|
83
|
-
pipe.incrbyfloat(redis_key("cost", "squad", squad, today), cost_usd)
|
|
84
|
-
pipe.incrby(redis_key("generations", "squad", squad, today), 1)
|
|
85
|
-
|
|
86
|
-
# Set expiry (48h) for all keys
|
|
87
|
-
for key in [
|
|
88
|
-
redis_key("cost", "daily", today),
|
|
89
|
-
redis_key("tokens", "input", today),
|
|
90
|
-
redis_key("tokens", "output", today),
|
|
91
|
-
redis_key("generations", today),
|
|
92
|
-
redis_key("cost", "squad", squad, today),
|
|
93
|
-
redis_key("generations", "squad", squad, today),
|
|
94
|
-
]:
|
|
95
|
-
pipe.expire(key, 172800) # 48 hours
|
|
96
|
-
|
|
97
|
-
pipe.execute()
|
|
98
|
-
|
|
99
|
-
def get_realtime_stats() -> dict:
|
|
100
|
-
"""Get real-time stats from Redis (fast path)."""
|
|
101
|
-
if not redis_client:
|
|
102
|
-
return None
|
|
103
|
-
|
|
104
|
-
today = today_str()
|
|
105
|
-
try:
|
|
106
|
-
cost = float(redis_client.get(redis_key("cost", "daily", today)) or 0)
|
|
107
|
-
input_tokens = int(redis_client.get(redis_key("tokens", "input", today)) or 0)
|
|
108
|
-
output_tokens = int(redis_client.get(redis_key("tokens", "output", today)) or 0)
|
|
109
|
-
generations = int(redis_client.get(redis_key("generations", today)) or 0)
|
|
110
|
-
|
|
111
|
-
# Get per-squad costs
|
|
112
|
-
squad_keys = redis_client.keys(redis_key("cost", "squad", "*", today))
|
|
113
|
-
by_squad = []
|
|
114
|
-
for key in squad_keys:
|
|
115
|
-
parts = key.split(":")
|
|
116
|
-
squad_name = parts[2] if len(parts) > 2 else "unknown"
|
|
117
|
-
squad_cost = float(redis_client.get(key) or 0)
|
|
118
|
-
squad_gens = int(redis_client.get(redis_key("generations", "squad", squad_name, today)) or 0)
|
|
119
|
-
by_squad.append({
|
|
120
|
-
"squad": squad_name,
|
|
121
|
-
"cost_usd": squad_cost,
|
|
122
|
-
"generations": squad_gens,
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
by_squad.sort(key=lambda x: x["cost_usd"], reverse=True)
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
"cost_usd": cost,
|
|
129
|
-
"input_tokens": input_tokens,
|
|
130
|
-
"output_tokens": output_tokens,
|
|
131
|
-
"generations": generations,
|
|
132
|
-
"by_squad": by_squad,
|
|
133
|
-
"budget_remaining": MONTHLY_QUOTA - cost,
|
|
134
|
-
"budget_pct": (cost / MONTHLY_QUOTA) * 100 if MONTHLY_QUOTA > 0 else 0,
|
|
135
|
-
}
|
|
136
|
-
except Exception as e:
|
|
137
|
-
print(f"Redis stats error: {e}")
|
|
138
|
-
return None
|
|
139
|
-
|
|
140
|
-
def cache_session(session_id: str, squad: str, agent: str):
|
|
141
|
-
"""Cache session info in Redis for fast lookups."""
|
|
142
|
-
if not redis_client:
|
|
143
|
-
return
|
|
144
|
-
|
|
145
|
-
key = redis_key("session", session_id)
|
|
146
|
-
redis_client.hset(key, mapping={"squad": squad, "agent": agent, "last_seen": datetime.now().isoformat()})
|
|
147
|
-
redis_client.expire(key, 86400) # 24h
|
|
148
|
-
|
|
149
|
-
def get_cached_session(session_id: str) -> dict | None:
|
|
150
|
-
"""Get cached session from Redis."""
|
|
151
|
-
if not redis_client:
|
|
152
|
-
return None
|
|
153
|
-
|
|
154
|
-
key = redis_key("session", session_id)
|
|
155
|
-
data = redis_client.hgetall(key)
|
|
156
|
-
return data if data else None
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
# =============================================================================
|
|
160
|
-
# Conversation Buffer (Redis -> Postgres + Engram)
|
|
161
|
-
# =============================================================================
|
|
162
|
-
CONV_BUFFER_KEY = "conversations:pending"
|
|
163
|
-
CONV_RECENT_KEY = "conversations:recent"
|
|
164
|
-
|
|
165
|
-
def buffer_conversation(conv_data: dict):
|
|
166
|
-
"""Push conversation to Redis buffer for async processing."""
|
|
167
|
-
if not redis_client:
|
|
168
|
-
return False
|
|
169
|
-
|
|
170
|
-
try:
|
|
171
|
-
# Add to pending queue
|
|
172
|
-
redis_client.lpush(CONV_BUFFER_KEY, json.dumps(conv_data))
|
|
173
|
-
|
|
174
|
-
# Also add to recent (circular buffer of last 100)
|
|
175
|
-
redis_client.lpush(CONV_RECENT_KEY, json.dumps(conv_data))
|
|
176
|
-
redis_client.ltrim(CONV_RECENT_KEY, 0, 99)
|
|
177
|
-
|
|
178
|
-
if DEBUG_MODE:
|
|
179
|
-
print(f"[BUFFER] Queued conversation: {conv_data.get('role')} - {len(conv_data.get('content', ''))} chars")
|
|
180
|
-
return True
|
|
181
|
-
except Exception as e:
|
|
182
|
-
print(f"[BUFFER] Error: {e}")
|
|
183
|
-
return False
|
|
184
|
-
|
|
185
|
-
def forward_to_engram(conv_data: dict) -> bool:
|
|
186
|
-
"""Forward conversation to engram/mem0 for extraction."""
|
|
187
|
-
if not ENGRAM_ENABLED:
|
|
188
|
-
return False
|
|
189
|
-
|
|
190
|
-
try:
|
|
191
|
-
# Format for mem0 API
|
|
192
|
-
payload = {
|
|
193
|
-
"messages": [{"role": conv_data.get("role", "user"), "content": conv_data.get("content", "")}],
|
|
194
|
-
"user_id": conv_data.get("user_id", ENGRAM_USER_ID),
|
|
195
|
-
"metadata": {
|
|
196
|
-
"session_id": conv_data.get("session_id", ""),
|
|
197
|
-
"type": conv_data.get("message_type", "message"),
|
|
198
|
-
"importance": conv_data.get("importance", "normal"),
|
|
199
|
-
"source": "squads-bridge",
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
response = requests.post(
|
|
204
|
-
f"{ENGRAM_URL}/memories",
|
|
205
|
-
json=payload,
|
|
206
|
-
timeout=30
|
|
207
|
-
)
|
|
208
|
-
|
|
209
|
-
if response.ok:
|
|
210
|
-
if DEBUG_MODE:
|
|
211
|
-
print(f"[ENGRAM] Forwarded: {conv_data.get('role')} -> mem0")
|
|
212
|
-
return True
|
|
213
|
-
else:
|
|
214
|
-
print(f"[ENGRAM] Error {response.status_code}: {response.text[:100]}")
|
|
215
|
-
return False
|
|
216
|
-
|
|
217
|
-
except requests.exceptions.ConnectionError:
|
|
218
|
-
if DEBUG_MODE:
|
|
219
|
-
print(f"[ENGRAM] Not available at {ENGRAM_URL}")
|
|
220
|
-
return False
|
|
221
|
-
except Exception as e:
|
|
222
|
-
print(f"[ENGRAM] Error: {e}")
|
|
223
|
-
return False
|
|
224
|
-
|
|
225
|
-
def process_conversation_queue():
|
|
226
|
-
"""Background worker to process conversation buffer."""
|
|
227
|
-
print("[WORKER] Conversation processor started")
|
|
228
|
-
|
|
229
|
-
while True:
|
|
230
|
-
try:
|
|
231
|
-
if not redis_client:
|
|
232
|
-
time.sleep(5)
|
|
233
|
-
continue
|
|
234
|
-
|
|
235
|
-
# Block-pop from queue (timeout 5s)
|
|
236
|
-
result = redis_client.brpop(CONV_BUFFER_KEY, timeout=5)
|
|
237
|
-
if not result:
|
|
238
|
-
continue
|
|
239
|
-
|
|
240
|
-
_, conv_json = result
|
|
241
|
-
conv_data = json.loads(conv_json)
|
|
242
|
-
|
|
243
|
-
# 1. Save to local postgres (for CLI search)
|
|
244
|
-
try:
|
|
245
|
-
conn = get_db()
|
|
246
|
-
save_conversation(
|
|
247
|
-
conn,
|
|
248
|
-
conv_data.get("session_id", ""),
|
|
249
|
-
conv_data.get("user_id", "local"),
|
|
250
|
-
conv_data.get("role", "user"),
|
|
251
|
-
conv_data.get("content", ""),
|
|
252
|
-
conv_data.get("message_type", "message"),
|
|
253
|
-
conv_data.get("importance", "normal"),
|
|
254
|
-
conv_data.get("metadata", {})
|
|
255
|
-
)
|
|
256
|
-
conn.commit()
|
|
257
|
-
conn.close()
|
|
258
|
-
if DEBUG_MODE:
|
|
259
|
-
print(f"[WORKER] Saved to postgres")
|
|
260
|
-
except Exception as e:
|
|
261
|
-
print(f"[WORKER] Postgres error: {e}")
|
|
262
|
-
|
|
263
|
-
# 2. Forward to engram/mem0 (for vectors + graph)
|
|
264
|
-
if ENGRAM_ENABLED:
|
|
265
|
-
forward_to_engram(conv_data)
|
|
266
|
-
|
|
267
|
-
except Exception as e:
|
|
268
|
-
print(f"[WORKER] Error: {e}")
|
|
269
|
-
time.sleep(1)
|
|
270
|
-
|
|
271
|
-
# Start background worker thread
|
|
272
|
-
conv_worker_thread = None
|
|
273
|
-
|
|
274
|
-
def start_conversation_worker():
|
|
275
|
-
"""Start the background conversation processor."""
|
|
276
|
-
global conv_worker_thread
|
|
277
|
-
if conv_worker_thread is None or not conv_worker_thread.is_alive():
|
|
278
|
-
conv_worker_thread = threading.Thread(target=process_conversation_queue, daemon=True)
|
|
279
|
-
conv_worker_thread.start()
|
|
280
|
-
print("[WORKER] Background thread started")
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
# Optional Langfuse client
|
|
284
|
-
langfuse = None
|
|
285
|
-
if LANGFUSE_ENABLED:
|
|
286
|
-
try:
|
|
287
|
-
from langfuse import Langfuse
|
|
288
|
-
langfuse = Langfuse(
|
|
289
|
-
public_key=os.environ.get("LANGFUSE_PUBLIC_KEY"),
|
|
290
|
-
secret_key=os.environ.get("LANGFUSE_SECRET_KEY"),
|
|
291
|
-
host=os.environ.get("LANGFUSE_HOST", "https://cloud.langfuse.com"),
|
|
292
|
-
)
|
|
293
|
-
print(f"Langfuse forwarding enabled: {os.environ.get('LANGFUSE_HOST')}")
|
|
294
|
-
except Exception as e:
|
|
295
|
-
print(f"Langfuse initialization failed: {e}")
|
|
296
|
-
langfuse = None
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
def get_json_data():
|
|
300
|
-
"""Get JSON data, handling gzip compression if present."""
|
|
301
|
-
raw_data = request.get_data()
|
|
302
|
-
|
|
303
|
-
if raw_data[:2] == b'\x1f\x8b':
|
|
304
|
-
try:
|
|
305
|
-
raw_data = gzip.decompress(raw_data)
|
|
306
|
-
except Exception as e:
|
|
307
|
-
print(f"Gzip decompress error: {e}")
|
|
308
|
-
return {}
|
|
309
|
-
|
|
310
|
-
try:
|
|
311
|
-
return json.loads(raw_data)
|
|
312
|
-
except json.JSONDecodeError as e:
|
|
313
|
-
print(f"JSON decode error: {e}")
|
|
314
|
-
return {}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def extract_attributes(attrs_list):
|
|
318
|
-
"""Extract attributes from OTel attribute list format."""
|
|
319
|
-
result = {}
|
|
320
|
-
for attr in attrs_list:
|
|
321
|
-
key = attr.get("key", "")
|
|
322
|
-
val = attr.get("value", {})
|
|
323
|
-
value = (
|
|
324
|
-
val.get("stringValue") or
|
|
325
|
-
val.get("intValue") or
|
|
326
|
-
val.get("doubleValue") or
|
|
327
|
-
val.get("boolValue") or
|
|
328
|
-
""
|
|
329
|
-
)
|
|
330
|
-
result[key] = value
|
|
331
|
-
return result
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
def safe_int(val, default=0):
|
|
335
|
-
"""Safely convert to int."""
|
|
336
|
-
try:
|
|
337
|
-
return int(val) if val else default
|
|
338
|
-
except (ValueError, TypeError):
|
|
339
|
-
return default
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
def safe_float(val, default=0.0):
|
|
343
|
-
"""Safely convert to float."""
|
|
344
|
-
try:
|
|
345
|
-
return float(val) if val else default
|
|
346
|
-
except (ValueError, TypeError):
|
|
347
|
-
return default
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
def extract_token_data(attrs):
|
|
351
|
-
"""Extract token counts from OTel attributes (handles multiple formats)."""
|
|
352
|
-
input_keys = [
|
|
353
|
-
"input_tokens", "usage.input_tokens", "prompt_tokens",
|
|
354
|
-
"usage.prompt_tokens", "inputTokens", "promptTokens",
|
|
355
|
-
"llm.usage.prompt_tokens", "gen_ai.usage.input_tokens"
|
|
356
|
-
]
|
|
357
|
-
output_keys = [
|
|
358
|
-
"output_tokens", "usage.output_tokens", "completion_tokens",
|
|
359
|
-
"usage.completion_tokens", "outputTokens", "completionTokens",
|
|
360
|
-
"llm.usage.completion_tokens", "gen_ai.usage.output_tokens"
|
|
361
|
-
]
|
|
362
|
-
cache_read_keys = [
|
|
363
|
-
"cache_read_tokens", "cache_read", "cacheReadTokens",
|
|
364
|
-
"usage.cache_read_tokens", "cache_read_input_tokens"
|
|
365
|
-
]
|
|
366
|
-
cache_creation_keys = [
|
|
367
|
-
"cache_creation_tokens", "cache_creation", "cacheCreationTokens",
|
|
368
|
-
"usage.cache_creation_tokens", "cache_creation_input_tokens"
|
|
369
|
-
]
|
|
370
|
-
cost_keys = [
|
|
371
|
-
"cost_usd", "cost", "total_cost", "usage.cost",
|
|
372
|
-
"llm.usage.cost", "gen_ai.usage.cost"
|
|
373
|
-
]
|
|
374
|
-
|
|
375
|
-
def find_value(keys, default=0):
|
|
376
|
-
for key in keys:
|
|
377
|
-
if key in attrs and attrs[key]:
|
|
378
|
-
return attrs[key]
|
|
379
|
-
return default
|
|
380
|
-
|
|
381
|
-
return {
|
|
382
|
-
"input_tokens": safe_int(find_value(input_keys)),
|
|
383
|
-
"output_tokens": safe_int(find_value(output_keys)),
|
|
384
|
-
"cache_read": safe_int(find_value(cache_read_keys)),
|
|
385
|
-
"cache_creation": safe_int(find_value(cache_creation_keys)),
|
|
386
|
-
"cost_usd": safe_float(find_value(cost_keys, 0.0)),
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
def ensure_session(conn, session_id, squad, agent, user_id):
|
|
391
|
-
"""Create or update session record."""
|
|
392
|
-
with conn.cursor() as cur:
|
|
393
|
-
cur.execute("""
|
|
394
|
-
INSERT INTO squads.sessions (id, squad, agent, user_id, last_activity_at)
|
|
395
|
-
VALUES (%s, %s, %s, %s, NOW())
|
|
396
|
-
ON CONFLICT (id) DO UPDATE SET
|
|
397
|
-
last_activity_at = NOW(),
|
|
398
|
-
squad = COALESCE(EXCLUDED.squad, squads.sessions.squad),
|
|
399
|
-
agent = COALESCE(EXCLUDED.agent, squads.sessions.agent)
|
|
400
|
-
""", (session_id, squad, agent, user_id or None))
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
def save_generation(conn, session_id, squad, agent, user_id, model, token_data,
|
|
404
|
-
task_type="execution", trigger_source="manual", execution_id=None):
|
|
405
|
-
"""Save LLM generation to postgres + Redis."""
|
|
406
|
-
with conn.cursor() as cur:
|
|
407
|
-
cur.execute("""
|
|
408
|
-
INSERT INTO squads.llm_generations
|
|
409
|
-
(session_id, squad, agent, user_id, model,
|
|
410
|
-
input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, cost_usd,
|
|
411
|
-
task_type, trigger_source, execution_id)
|
|
412
|
-
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
413
|
-
RETURNING id
|
|
414
|
-
""", (
|
|
415
|
-
session_id, squad, agent, user_id or None, model,
|
|
416
|
-
token_data["input_tokens"], token_data["output_tokens"],
|
|
417
|
-
token_data["cache_read"], token_data["cache_creation"],
|
|
418
|
-
token_data["cost_usd"],
|
|
419
|
-
task_type, trigger_source, execution_id
|
|
420
|
-
))
|
|
421
|
-
gen_id = cur.fetchone()[0]
|
|
422
|
-
|
|
423
|
-
# Update session aggregates
|
|
424
|
-
cur.execute("""
|
|
425
|
-
UPDATE squads.sessions SET
|
|
426
|
-
total_input_tokens = total_input_tokens + %s,
|
|
427
|
-
total_output_tokens = total_output_tokens + %s,
|
|
428
|
-
total_cost_usd = total_cost_usd + %s,
|
|
429
|
-
generation_count = generation_count + 1,
|
|
430
|
-
last_activity_at = NOW()
|
|
431
|
-
WHERE id = %s
|
|
432
|
-
""", (
|
|
433
|
-
token_data["input_tokens"], token_data["output_tokens"],
|
|
434
|
-
token_data["cost_usd"], session_id
|
|
435
|
-
))
|
|
436
|
-
|
|
437
|
-
# Update Redis real-time counters
|
|
438
|
-
incr_cost(squad, token_data["cost_usd"], token_data["input_tokens"], token_data["output_tokens"])
|
|
439
|
-
cache_session(session_id, squad, agent)
|
|
440
|
-
|
|
441
|
-
return gen_id
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
def save_tool_execution(conn, session_id, squad, agent, tool_name, success, duration_ms):
|
|
445
|
-
"""Save tool execution to postgres."""
|
|
446
|
-
with conn.cursor() as cur:
|
|
447
|
-
cur.execute("""
|
|
448
|
-
INSERT INTO squads.tool_executions
|
|
449
|
-
(session_id, squad, agent, tool_name, success, duration_ms)
|
|
450
|
-
VALUES (%s, %s, %s, %s, %s, %s)
|
|
451
|
-
RETURNING id
|
|
452
|
-
""", (session_id, squad, agent, tool_name, success, duration_ms))
|
|
453
|
-
tool_id = cur.fetchone()[0]
|
|
454
|
-
|
|
455
|
-
# Update session tool count
|
|
456
|
-
cur.execute("""
|
|
457
|
-
UPDATE squads.sessions SET
|
|
458
|
-
tool_count = tool_count + 1,
|
|
459
|
-
last_activity_at = NOW()
|
|
460
|
-
WHERE id = %s
|
|
461
|
-
""", (session_id,))
|
|
462
|
-
|
|
463
|
-
return tool_id
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
@app.route("/v1/metrics", methods=["POST"])
|
|
467
|
-
def receive_metrics():
|
|
468
|
-
"""Receive OTel metrics - acknowledge for now."""
|
|
469
|
-
try:
|
|
470
|
-
get_json_data()
|
|
471
|
-
return jsonify({"status": "ok"}), 200
|
|
472
|
-
except Exception as e:
|
|
473
|
-
print(f"Error processing metrics: {e}")
|
|
474
|
-
return jsonify({"error": str(e)}), 500
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
@app.route("/v1/logs", methods=["POST"])
|
|
478
|
-
def receive_logs():
|
|
479
|
-
"""Receive OTel logs - save to postgres, optionally forward to Langfuse."""
|
|
480
|
-
try:
|
|
481
|
-
data = get_json_data()
|
|
482
|
-
conn = get_db()
|
|
483
|
-
|
|
484
|
-
for resource_log in data.get("resourceLogs", []):
|
|
485
|
-
resource_attrs = extract_attributes(
|
|
486
|
-
resource_log.get("resource", {}).get("attributes", [])
|
|
487
|
-
)
|
|
488
|
-
|
|
489
|
-
service_name = resource_attrs.get("service.name", "claude-code")
|
|
490
|
-
|
|
491
|
-
# Detect squad/agent context - check OTel attrs first, then registered context
|
|
492
|
-
registered_ctx = get_context_for_session()
|
|
493
|
-
|
|
494
|
-
squad_name = (
|
|
495
|
-
resource_attrs.get("squad") or
|
|
496
|
-
resource_attrs.get("squads.squad") or
|
|
497
|
-
registered_ctx.get("squad") or
|
|
498
|
-
"hq"
|
|
499
|
-
)
|
|
500
|
-
agent_name = (
|
|
501
|
-
resource_attrs.get("agent") or
|
|
502
|
-
resource_attrs.get("squads.agent") or
|
|
503
|
-
registered_ctx.get("agent") or
|
|
504
|
-
"coo"
|
|
505
|
-
)
|
|
506
|
-
# Extract telemetry context (added for per-agent cost tracking)
|
|
507
|
-
task_type = (
|
|
508
|
-
resource_attrs.get("squads.task_type") or
|
|
509
|
-
registered_ctx.get("task_type") or
|
|
510
|
-
"execution"
|
|
511
|
-
)
|
|
512
|
-
trigger_source = (
|
|
513
|
-
resource_attrs.get("squads.trigger") or
|
|
514
|
-
registered_ctx.get("trigger") or
|
|
515
|
-
"manual"
|
|
516
|
-
)
|
|
517
|
-
execution_id = (
|
|
518
|
-
resource_attrs.get("squads.execution_id") or
|
|
519
|
-
None # Will be set by registered context if available
|
|
520
|
-
)
|
|
521
|
-
|
|
522
|
-
for scope_log in resource_log.get("scopeLogs", []):
|
|
523
|
-
for log_record in scope_log.get("logRecords", []):
|
|
524
|
-
log_attrs = extract_attributes(log_record.get("attributes", []))
|
|
525
|
-
|
|
526
|
-
event_name = log_attrs.get("event.name", "unknown")
|
|
527
|
-
session_id = log_attrs.get("session.id", "unknown")
|
|
528
|
-
user_id = log_attrs.get("user.id", "")
|
|
529
|
-
|
|
530
|
-
# Debug logging
|
|
531
|
-
if DEBUG_MODE:
|
|
532
|
-
recent_logs.append({
|
|
533
|
-
"timestamp": datetime.now().isoformat(),
|
|
534
|
-
"event_name": event_name,
|
|
535
|
-
"log_attrs": dict(log_attrs),
|
|
536
|
-
"resource_attrs": dict(resource_attrs),
|
|
537
|
-
})
|
|
538
|
-
if event_name == "api_request":
|
|
539
|
-
print(f"[DEBUG] api_request: session={session_id} squad={squad_name} agent={agent_name}")
|
|
540
|
-
|
|
541
|
-
# Ensure session exists
|
|
542
|
-
ensure_session(conn, session_id, squad_name, agent_name, user_id)
|
|
543
|
-
|
|
544
|
-
# Handle LLM API requests
|
|
545
|
-
if event_name == "api_request":
|
|
546
|
-
model = log_attrs.get("model", "claude")
|
|
547
|
-
token_data = extract_token_data(log_attrs)
|
|
548
|
-
|
|
549
|
-
# Save to postgres (primary)
|
|
550
|
-
gen_id = save_generation(
|
|
551
|
-
conn, session_id, squad_name, agent_name,
|
|
552
|
-
user_id, model, token_data,
|
|
553
|
-
task_type, trigger_source, execution_id
|
|
554
|
-
)
|
|
555
|
-
print(f"[PG] Generation #{gen_id}: {model} {token_data['input_tokens']}+{token_data['output_tokens']} tokens ${token_data['cost_usd']:.4f} [{task_type}]")
|
|
556
|
-
|
|
557
|
-
# Forward to Langfuse (optional)
|
|
558
|
-
if langfuse:
|
|
559
|
-
try:
|
|
560
|
-
# Name format: squad/agent for easy filtering
|
|
561
|
-
trace_name = f"{squad_name}/{agent_name}" if agent_name != "coo" else f"llm:{model}"
|
|
562
|
-
trace = langfuse.trace(
|
|
563
|
-
name=trace_name,
|
|
564
|
-
user_id=user_id or None,
|
|
565
|
-
session_id=session_id,
|
|
566
|
-
metadata={
|
|
567
|
-
"squad": squad_name,
|
|
568
|
-
"agent": agent_name,
|
|
569
|
-
"task_type": task_type,
|
|
570
|
-
"trigger": trigger_source,
|
|
571
|
-
"execution_id": execution_id,
|
|
572
|
-
"service": service_name,
|
|
573
|
-
},
|
|
574
|
-
tags=[squad_name, agent_name, task_type], # Enable filtering
|
|
575
|
-
)
|
|
576
|
-
trace.generation(
|
|
577
|
-
name=f"llm:{model}",
|
|
578
|
-
model=model,
|
|
579
|
-
usage={
|
|
580
|
-
"input": token_data["input_tokens"],
|
|
581
|
-
"output": token_data["output_tokens"],
|
|
582
|
-
"total": token_data["input_tokens"] + token_data["output_tokens"],
|
|
583
|
-
},
|
|
584
|
-
metadata={
|
|
585
|
-
"cache_read": token_data["cache_read"],
|
|
586
|
-
"cache_creation": token_data["cache_creation"],
|
|
587
|
-
"cost_usd": token_data["cost_usd"],
|
|
588
|
-
},
|
|
589
|
-
)
|
|
590
|
-
except Exception as e:
|
|
591
|
-
print(f"[Langfuse] Forward error: {e}")
|
|
592
|
-
|
|
593
|
-
# Handle tool results
|
|
594
|
-
elif event_name == "tool_result":
|
|
595
|
-
tool_name = log_attrs.get("tool_name", "unknown")
|
|
596
|
-
duration_ms = safe_int(log_attrs.get("duration_ms", 0))
|
|
597
|
-
success = log_attrs.get("success", "true") in ["true", True, "1"]
|
|
598
|
-
|
|
599
|
-
# Save to postgres (primary)
|
|
600
|
-
tool_id = save_tool_execution(
|
|
601
|
-
conn, session_id, squad_name, agent_name,
|
|
602
|
-
tool_name, success, duration_ms
|
|
603
|
-
)
|
|
604
|
-
|
|
605
|
-
# Only log non-trivial tools
|
|
606
|
-
if tool_name not in ["Read", "Glob", "Grep"]:
|
|
607
|
-
print(f"[PG] Tool #{tool_id}: {tool_name} success={success}")
|
|
608
|
-
|
|
609
|
-
# Forward to Langfuse (optional)
|
|
610
|
-
if langfuse:
|
|
611
|
-
try:
|
|
612
|
-
trace = langfuse.trace(
|
|
613
|
-
name=f"tool:{tool_name}",
|
|
614
|
-
session_id=session_id,
|
|
615
|
-
metadata={
|
|
616
|
-
"squad": squad_name,
|
|
617
|
-
"agent": agent_name,
|
|
618
|
-
},
|
|
619
|
-
)
|
|
620
|
-
trace.span(
|
|
621
|
-
name=f"tool:{tool_name}",
|
|
622
|
-
metadata={
|
|
623
|
-
"tool_name": tool_name,
|
|
624
|
-
"success": success,
|
|
625
|
-
"duration_ms": duration_ms,
|
|
626
|
-
},
|
|
627
|
-
)
|
|
628
|
-
except Exception as e:
|
|
629
|
-
print(f"[Langfuse] Forward error: {e}")
|
|
630
|
-
|
|
631
|
-
conn.commit()
|
|
632
|
-
conn.close()
|
|
633
|
-
|
|
634
|
-
if langfuse:
|
|
635
|
-
langfuse.flush()
|
|
636
|
-
|
|
637
|
-
return jsonify({"status": "ok"}), 200
|
|
638
|
-
|
|
639
|
-
except Exception as e:
|
|
640
|
-
import traceback
|
|
641
|
-
print(f"Error processing logs: {e}")
|
|
642
|
-
traceback.print_exc()
|
|
643
|
-
return jsonify({"error": str(e)}), 500
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
# Context registry for agent executions (maps session patterns to context)
|
|
647
|
-
# Used when OTel doesn't include resource attributes
|
|
648
|
-
execution_contexts = {} # execution_id -> {squad, agent, task_type, trigger, expires_at}
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
@app.route("/api/context/register", methods=["POST"])
|
|
652
|
-
def register_context():
|
|
653
|
-
"""Register execution context for upcoming Claude session.
|
|
654
|
-
|
|
655
|
-
CLI calls this before launching Claude, so the bridge knows
|
|
656
|
-
which squad/agent/task_type to associate with incoming telemetry.
|
|
657
|
-
"""
|
|
658
|
-
try:
|
|
659
|
-
data = request.get_json() or {}
|
|
660
|
-
execution_id = data.get("execution_id")
|
|
661
|
-
if not execution_id:
|
|
662
|
-
return jsonify({"error": "execution_id required"}), 400
|
|
663
|
-
|
|
664
|
-
# Store context with 2-hour expiry
|
|
665
|
-
expires_at = datetime.now() + timedelta(hours=2)
|
|
666
|
-
execution_contexts[execution_id] = {
|
|
667
|
-
"squad": data.get("squad", "hq"),
|
|
668
|
-
"agent": data.get("agent", "coo"),
|
|
669
|
-
"task_type": data.get("task_type", "execution"),
|
|
670
|
-
"trigger": data.get("trigger", "manual"),
|
|
671
|
-
"expires_at": expires_at,
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
# Also store in Redis for persistence across restarts
|
|
675
|
-
if redis_client:
|
|
676
|
-
redis_client.setex(
|
|
677
|
-
f"context:{execution_id}",
|
|
678
|
-
7200, # 2 hours TTL
|
|
679
|
-
json.dumps({
|
|
680
|
-
"squad": data.get("squad", "hq"),
|
|
681
|
-
"agent": data.get("agent", "coo"),
|
|
682
|
-
"task_type": data.get("task_type", "execution"),
|
|
683
|
-
"trigger": data.get("trigger", "manual"),
|
|
684
|
-
})
|
|
685
|
-
)
|
|
686
|
-
|
|
687
|
-
# Store as "latest" context for this squad/agent combination
|
|
688
|
-
key = f"{data.get('squad', 'hq')}:{data.get('agent', 'coo')}"
|
|
689
|
-
if redis_client:
|
|
690
|
-
redis_client.setex(f"context:latest:{key}", 3600, execution_id)
|
|
691
|
-
|
|
692
|
-
print(f"[CONTEXT] Registered {execution_id}: {data.get('squad')}/{data.get('agent')} [{data.get('task_type')}]")
|
|
693
|
-
return jsonify({"status": "ok", "execution_id": execution_id}), 200
|
|
694
|
-
|
|
695
|
-
except Exception as e:
|
|
696
|
-
return jsonify({"error": str(e)}), 500
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
def get_context_for_session() -> dict:
|
|
700
|
-
"""Get the most recent execution context.
|
|
701
|
-
|
|
702
|
-
Returns the latest registered context that hasn't expired.
|
|
703
|
-
This is used when OTel data doesn't include resource attributes.
|
|
704
|
-
"""
|
|
705
|
-
# Clean expired contexts
|
|
706
|
-
now = datetime.now()
|
|
707
|
-
expired = [k for k, v in execution_contexts.items() if v.get("expires_at", now) < now]
|
|
708
|
-
for k in expired:
|
|
709
|
-
del execution_contexts[k]
|
|
710
|
-
|
|
711
|
-
# Return most recent context
|
|
712
|
-
if execution_contexts:
|
|
713
|
-
# Get the most recently registered context
|
|
714
|
-
latest = max(execution_contexts.items(), key=lambda x: x[1].get("expires_at", now))
|
|
715
|
-
return latest[1]
|
|
716
|
-
|
|
717
|
-
return {}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
@app.route("/health", methods=["GET"])
|
|
721
|
-
def health():
|
|
722
|
-
"""Health check endpoint."""
|
|
723
|
-
status = {"status": "healthy"}
|
|
724
|
-
|
|
725
|
-
# Check Postgres
|
|
726
|
-
try:
|
|
727
|
-
conn = get_db()
|
|
728
|
-
with conn.cursor() as cur:
|
|
729
|
-
cur.execute("SELECT 1")
|
|
730
|
-
conn.close()
|
|
731
|
-
status["postgres"] = "connected"
|
|
732
|
-
except Exception as e:
|
|
733
|
-
status["postgres"] = f"error: {e}"
|
|
734
|
-
status["status"] = "degraded"
|
|
735
|
-
|
|
736
|
-
# Check Redis
|
|
737
|
-
if redis_client:
|
|
738
|
-
try:
|
|
739
|
-
redis_client.ping()
|
|
740
|
-
status["redis"] = "connected"
|
|
741
|
-
except Exception as e:
|
|
742
|
-
status["redis"] = f"error: {e}"
|
|
743
|
-
status["status"] = "degraded"
|
|
744
|
-
else:
|
|
745
|
-
status["redis"] = "disabled"
|
|
746
|
-
|
|
747
|
-
status["langfuse"] = "enabled" if langfuse else "disabled"
|
|
748
|
-
|
|
749
|
-
return jsonify(status), 200 if status["status"] == "healthy" else 503
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
@app.route("/stats", methods=["GET"])
|
|
753
|
-
def stats():
|
|
754
|
-
"""Get telemetry statistics - Redis (fast) or Postgres (fallback)."""
|
|
755
|
-
# Try Redis first (real-time, fast)
|
|
756
|
-
realtime = get_realtime_stats()
|
|
757
|
-
if realtime:
|
|
758
|
-
return jsonify({
|
|
759
|
-
"status": "running",
|
|
760
|
-
"source": "redis",
|
|
761
|
-
"today": {
|
|
762
|
-
"generations": realtime["generations"],
|
|
763
|
-
"input_tokens": realtime["input_tokens"],
|
|
764
|
-
"output_tokens": realtime["output_tokens"],
|
|
765
|
-
"cost_usd": realtime["cost_usd"],
|
|
766
|
-
},
|
|
767
|
-
"budget": {
|
|
768
|
-
"daily": MONTHLY_QUOTA,
|
|
769
|
-
"used": realtime["cost_usd"],
|
|
770
|
-
"remaining": realtime["budget_remaining"],
|
|
771
|
-
"used_pct": realtime["budget_pct"],
|
|
772
|
-
},
|
|
773
|
-
"by_squad": realtime["by_squad"],
|
|
774
|
-
"langfuse_enabled": langfuse is not None,
|
|
775
|
-
"redis_enabled": True,
|
|
776
|
-
}), 200
|
|
777
|
-
|
|
778
|
-
# Fallback to Postgres
|
|
779
|
-
try:
|
|
780
|
-
conn = get_db()
|
|
781
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
782
|
-
# Session count
|
|
783
|
-
cur.execute("SELECT COUNT(*) as count FROM squads.sessions")
|
|
784
|
-
sessions = cur.fetchone()["count"]
|
|
785
|
-
|
|
786
|
-
# Today's generations
|
|
787
|
-
cur.execute("""
|
|
788
|
-
SELECT
|
|
789
|
-
COUNT(*) as count,
|
|
790
|
-
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
|
791
|
-
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
|
792
|
-
COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
793
|
-
FROM squads.llm_generations
|
|
794
|
-
WHERE created_at >= CURRENT_DATE
|
|
795
|
-
""")
|
|
796
|
-
today = cur.fetchone()
|
|
797
|
-
|
|
798
|
-
# By squad (today)
|
|
799
|
-
cur.execute("""
|
|
800
|
-
SELECT
|
|
801
|
-
squad,
|
|
802
|
-
COUNT(*) as generations,
|
|
803
|
-
COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
804
|
-
FROM squads.llm_generations
|
|
805
|
-
WHERE created_at >= CURRENT_DATE
|
|
806
|
-
GROUP BY squad
|
|
807
|
-
ORDER BY cost_usd DESC
|
|
808
|
-
""")
|
|
809
|
-
by_squad = cur.fetchall()
|
|
810
|
-
|
|
811
|
-
conn.close()
|
|
812
|
-
cost_usd = float(today["cost_usd"])
|
|
813
|
-
|
|
814
|
-
return jsonify({
|
|
815
|
-
"status": "running",
|
|
816
|
-
"source": "postgres",
|
|
817
|
-
"sessions": sessions,
|
|
818
|
-
"today": {
|
|
819
|
-
"generations": today["count"],
|
|
820
|
-
"input_tokens": today["input_tokens"],
|
|
821
|
-
"output_tokens": today["output_tokens"],
|
|
822
|
-
"cost_usd": cost_usd,
|
|
823
|
-
},
|
|
824
|
-
"budget": {
|
|
825
|
-
"daily": MONTHLY_QUOTA,
|
|
826
|
-
"used": cost_usd,
|
|
827
|
-
"remaining": MONTHLY_QUOTA - cost_usd,
|
|
828
|
-
"used_pct": (cost_usd / MONTHLY_QUOTA) * 100 if MONTHLY_QUOTA > 0 else 0,
|
|
829
|
-
},
|
|
830
|
-
"by_squad": [dict(r) for r in by_squad],
|
|
831
|
-
"langfuse_enabled": langfuse is not None,
|
|
832
|
-
"redis_enabled": False,
|
|
833
|
-
}), 200
|
|
834
|
-
|
|
835
|
-
except Exception as e:
|
|
836
|
-
return jsonify({"error": str(e)}), 500
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
@app.route("/api/cost/summary", methods=["GET"])
|
|
840
|
-
def cost_summary():
|
|
841
|
-
"""Get cost summary for dashboard (replaces Langfuse MCP calls)."""
|
|
842
|
-
try:
|
|
843
|
-
period = request.args.get("period", "day")
|
|
844
|
-
squad = request.args.get("squad")
|
|
845
|
-
agent = request.args.get("agent")
|
|
846
|
-
task_type = request.args.get("task_type") # evaluation, execution, research, lead
|
|
847
|
-
|
|
848
|
-
conn = get_db()
|
|
849
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
850
|
-
# Determine time filter
|
|
851
|
-
if period == "day":
|
|
852
|
-
time_filter = "created_at >= CURRENT_DATE"
|
|
853
|
-
elif period == "week":
|
|
854
|
-
time_filter = "created_at >= CURRENT_DATE - INTERVAL '7 days'"
|
|
855
|
-
else: # month
|
|
856
|
-
time_filter = "created_at >= CURRENT_DATE - INTERVAL '30 days'"
|
|
857
|
-
|
|
858
|
-
# Build filters
|
|
859
|
-
filters = [time_filter]
|
|
860
|
-
if squad:
|
|
861
|
-
filters.append(f"squad = '{squad}'")
|
|
862
|
-
if agent:
|
|
863
|
-
filters.append(f"agent = '{agent}'")
|
|
864
|
-
if task_type:
|
|
865
|
-
filters.append(f"task_type = '{task_type}'")
|
|
866
|
-
where_clause = " AND ".join(filters)
|
|
867
|
-
|
|
868
|
-
# Aggregated stats
|
|
869
|
-
cur.execute(f"""
|
|
870
|
-
SELECT
|
|
871
|
-
COUNT(*) as generation_count,
|
|
872
|
-
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
|
873
|
-
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
|
874
|
-
COALESCE(SUM(cost_usd), 0) as total_cost_usd
|
|
875
|
-
FROM squads.llm_generations
|
|
876
|
-
WHERE {where_clause}
|
|
877
|
-
""")
|
|
878
|
-
totals = cur.fetchone()
|
|
879
|
-
|
|
880
|
-
# By squad
|
|
881
|
-
cur.execute(f"""
|
|
882
|
-
SELECT
|
|
883
|
-
squad,
|
|
884
|
-
COUNT(*) as generations,
|
|
885
|
-
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
|
886
|
-
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
|
887
|
-
COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
888
|
-
FROM squads.llm_generations
|
|
889
|
-
WHERE {time_filter}
|
|
890
|
-
GROUP BY squad
|
|
891
|
-
ORDER BY cost_usd DESC
|
|
892
|
-
""")
|
|
893
|
-
by_squad = cur.fetchall()
|
|
894
|
-
|
|
895
|
-
# By agent (NEW - for per-agent cost tracking)
|
|
896
|
-
cur.execute(f"""
|
|
897
|
-
SELECT
|
|
898
|
-
squad,
|
|
899
|
-
agent,
|
|
900
|
-
task_type,
|
|
901
|
-
COUNT(*) as generations,
|
|
902
|
-
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
|
903
|
-
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
|
904
|
-
COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
905
|
-
FROM squads.llm_generations
|
|
906
|
-
WHERE {time_filter}
|
|
907
|
-
GROUP BY squad, agent, task_type
|
|
908
|
-
ORDER BY cost_usd DESC
|
|
909
|
-
LIMIT 50
|
|
910
|
-
""")
|
|
911
|
-
by_agent = cur.fetchall()
|
|
912
|
-
|
|
913
|
-
# By task_type (NEW - evaluation vs execution breakdown)
|
|
914
|
-
cur.execute(f"""
|
|
915
|
-
SELECT
|
|
916
|
-
task_type,
|
|
917
|
-
COUNT(*) as generations,
|
|
918
|
-
COALESCE(SUM(input_tokens), 0) as input_tokens,
|
|
919
|
-
COALESCE(SUM(output_tokens), 0) as output_tokens,
|
|
920
|
-
COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
921
|
-
FROM squads.llm_generations
|
|
922
|
-
WHERE {time_filter}
|
|
923
|
-
GROUP BY task_type
|
|
924
|
-
ORDER BY cost_usd DESC
|
|
925
|
-
""")
|
|
926
|
-
by_task_type = cur.fetchall()
|
|
927
|
-
|
|
928
|
-
# By model
|
|
929
|
-
cur.execute(f"""
|
|
930
|
-
SELECT
|
|
931
|
-
model,
|
|
932
|
-
COUNT(*) as generations,
|
|
933
|
-
COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
934
|
-
FROM squads.llm_generations
|
|
935
|
-
WHERE {where_clause}
|
|
936
|
-
GROUP BY model
|
|
937
|
-
ORDER BY cost_usd DESC
|
|
938
|
-
""")
|
|
939
|
-
by_model = cur.fetchall()
|
|
940
|
-
|
|
941
|
-
conn.close()
|
|
942
|
-
|
|
943
|
-
return jsonify({
|
|
944
|
-
"period": period,
|
|
945
|
-
"filters": {
|
|
946
|
-
"squad": squad,
|
|
947
|
-
"agent": agent,
|
|
948
|
-
"task_type": task_type,
|
|
949
|
-
},
|
|
950
|
-
"totals": {
|
|
951
|
-
"generations": totals["generation_count"],
|
|
952
|
-
"input_tokens": totals["input_tokens"],
|
|
953
|
-
"output_tokens": totals["output_tokens"],
|
|
954
|
-
"cost_usd": float(totals["total_cost_usd"]),
|
|
955
|
-
},
|
|
956
|
-
"by_squad": [{
|
|
957
|
-
"squad": r["squad"],
|
|
958
|
-
"generations": r["generations"],
|
|
959
|
-
"input_tokens": r["input_tokens"],
|
|
960
|
-
"output_tokens": r["output_tokens"],
|
|
961
|
-
"cost_usd": float(r["cost_usd"]),
|
|
962
|
-
} for r in by_squad],
|
|
963
|
-
"by_agent": [{
|
|
964
|
-
"squad": r["squad"],
|
|
965
|
-
"agent": r["agent"],
|
|
966
|
-
"task_type": r["task_type"],
|
|
967
|
-
"generations": r["generations"],
|
|
968
|
-
"input_tokens": r["input_tokens"],
|
|
969
|
-
"output_tokens": r["output_tokens"],
|
|
970
|
-
"cost_usd": float(r["cost_usd"]),
|
|
971
|
-
} for r in by_agent],
|
|
972
|
-
"by_task_type": [{
|
|
973
|
-
"task_type": r["task_type"],
|
|
974
|
-
"generations": r["generations"],
|
|
975
|
-
"input_tokens": r["input_tokens"],
|
|
976
|
-
"output_tokens": r["output_tokens"],
|
|
977
|
-
"cost_usd": float(r["cost_usd"]),
|
|
978
|
-
} for r in by_task_type],
|
|
979
|
-
"by_model": [{
|
|
980
|
-
"model": r["model"],
|
|
981
|
-
"generations": r["generations"],
|
|
982
|
-
"cost_usd": float(r["cost_usd"]),
|
|
983
|
-
} for r in by_model],
|
|
984
|
-
}), 200
|
|
985
|
-
|
|
986
|
-
except Exception as e:
|
|
987
|
-
return jsonify({"error": str(e)}), 500
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
@app.route("/api/rate-limits", methods=["GET"])
|
|
991
|
-
def get_rate_limits():
|
|
992
|
-
"""Get current rate limits from Redis (captured by anthropic proxy)."""
|
|
993
|
-
if not redis_client:
|
|
994
|
-
return jsonify({"error": "Redis not available", "source": "none"}), 503
|
|
995
|
-
|
|
996
|
-
try:
|
|
997
|
-
# Get all rate limit keys
|
|
998
|
-
keys = redis_client.keys("ratelimit:latest:*")
|
|
999
|
-
limits = {}
|
|
1000
|
-
|
|
1001
|
-
for key in keys:
|
|
1002
|
-
family = key.split(":")[-1]
|
|
1003
|
-
data = redis_client.get(key)
|
|
1004
|
-
if data:
|
|
1005
|
-
limits[family] = json.loads(data)
|
|
1006
|
-
|
|
1007
|
-
return jsonify({
|
|
1008
|
-
"rate_limits": limits,
|
|
1009
|
-
"source": "redis",
|
|
1010
|
-
"fetched_at": datetime.now().isoformat(),
|
|
1011
|
-
}), 200
|
|
1012
|
-
|
|
1013
|
-
except Exception as e:
|
|
1014
|
-
return jsonify({"error": str(e)}), 500
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
@app.route("/api/telemetry", methods=["POST"])
|
|
1018
|
-
def receive_cli_telemetry():
|
|
1019
|
-
"""Receive anonymous CLI telemetry events."""
|
|
1020
|
-
try:
|
|
1021
|
-
data = request.get_json()
|
|
1022
|
-
if not data:
|
|
1023
|
-
return jsonify({"error": "No JSON data"}), 400
|
|
1024
|
-
|
|
1025
|
-
events = data.get("events", [data]) # Support single event or batch
|
|
1026
|
-
|
|
1027
|
-
conn = get_db()
|
|
1028
|
-
with conn.cursor() as cur:
|
|
1029
|
-
for event in events:
|
|
1030
|
-
cur.execute("""
|
|
1031
|
-
INSERT INTO squads.cli_events
|
|
1032
|
-
(anonymous_id, event_name, cli_version, properties)
|
|
1033
|
-
VALUES (%s, %s, %s, %s)
|
|
1034
|
-
""", (
|
|
1035
|
-
event.get("properties", {}).get("anonymousId", ""),
|
|
1036
|
-
event.get("event", "unknown"),
|
|
1037
|
-
event.get("properties", {}).get("cliVersion", "unknown"),
|
|
1038
|
-
json.dumps(event.get("properties", {})),
|
|
1039
|
-
))
|
|
1040
|
-
|
|
1041
|
-
conn.commit()
|
|
1042
|
-
conn.close()
|
|
1043
|
-
|
|
1044
|
-
return jsonify({
|
|
1045
|
-
"status": "ok",
|
|
1046
|
-
"received": len(events),
|
|
1047
|
-
}), 200
|
|
1048
|
-
|
|
1049
|
-
except Exception as e:
|
|
1050
|
-
if DEBUG_MODE:
|
|
1051
|
-
print(f"[TELEMETRY] Error: {e}")
|
|
1052
|
-
return jsonify({"error": str(e)}), 500
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
# =============================================================================
|
|
1056
|
-
# Conversations API - Captures from engram hook
|
|
1057
|
-
# =============================================================================
|
|
1058
|
-
|
|
1059
|
-
def save_conversation(conn, session_id, user_id, role, content, message_type, importance, metadata):
|
|
1060
|
-
"""Save conversation message to postgres."""
|
|
1061
|
-
with conn.cursor() as cur:
|
|
1062
|
-
cur.execute("""
|
|
1063
|
-
INSERT INTO squads.conversations
|
|
1064
|
-
(session_id, user_id, role, content, message_type, importance, metadata)
|
|
1065
|
-
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
1066
|
-
RETURNING id
|
|
1067
|
-
""", (
|
|
1068
|
-
session_id, user_id or 'local', role, content,
|
|
1069
|
-
message_type, importance, json.dumps(metadata or {})
|
|
1070
|
-
))
|
|
1071
|
-
return cur.fetchone()[0]
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
@app.route("/api/conversations", methods=["POST"])
|
|
1075
|
-
def receive_conversation():
|
|
1076
|
-
"""Receive conversation capture from engram hook.
|
|
1077
|
-
|
|
1078
|
-
Fast path: Buffer to Redis, return immediately.
|
|
1079
|
-
Background worker saves to Postgres and forwards to engram/mem0.
|
|
1080
|
-
|
|
1081
|
-
Supports two formats:
|
|
1082
|
-
1. Direct format (new): {session_id, user_id, role, content, message_type, importance, metadata}
|
|
1083
|
-
2. Legacy format (mem0): {messages: [{role, content}], user_id, metadata: {session_id, type, importance}}
|
|
1084
|
-
"""
|
|
1085
|
-
try:
|
|
1086
|
-
data = request.get_json()
|
|
1087
|
-
if not data:
|
|
1088
|
-
return jsonify({"error": "No JSON data"}), 400
|
|
1089
|
-
|
|
1090
|
-
queued = 0
|
|
1091
|
-
|
|
1092
|
-
# Check for direct format (has content field at top level)
|
|
1093
|
-
if "content" in data:
|
|
1094
|
-
# Direct format from updated engram hook
|
|
1095
|
-
content = data.get("content", "")
|
|
1096
|
-
if content and len(content) >= 5:
|
|
1097
|
-
conv_data = {
|
|
1098
|
-
"session_id": data.get("session_id", ""),
|
|
1099
|
-
"user_id": data.get("user_id", "local"),
|
|
1100
|
-
"role": data.get("role", "user"),
|
|
1101
|
-
"content": content,
|
|
1102
|
-
"message_type": data.get("message_type", "message"),
|
|
1103
|
-
"importance": data.get("importance", "normal"),
|
|
1104
|
-
"metadata": data.get("metadata", {}),
|
|
1105
|
-
}
|
|
1106
|
-
if data.get("working_dir"):
|
|
1107
|
-
conv_data["metadata"]["working_dir"] = data.get("working_dir")
|
|
1108
|
-
|
|
1109
|
-
if buffer_conversation(conv_data):
|
|
1110
|
-
queued += 1
|
|
1111
|
-
else:
|
|
1112
|
-
# Legacy format (mem0 compatible)
|
|
1113
|
-
messages = data.get("messages", [])
|
|
1114
|
-
user_id = data.get("user_id", "local")
|
|
1115
|
-
metadata = data.get("metadata", {})
|
|
1116
|
-
session_id = metadata.get("session_id", "")
|
|
1117
|
-
message_type = metadata.get("type", "message")
|
|
1118
|
-
importance = metadata.get("importance", "normal")
|
|
1119
|
-
|
|
1120
|
-
for msg in messages:
|
|
1121
|
-
role = msg.get("role", "user")
|
|
1122
|
-
content = msg.get("content", "")
|
|
1123
|
-
|
|
1124
|
-
if not content or len(content) < 5:
|
|
1125
|
-
continue
|
|
1126
|
-
|
|
1127
|
-
conv_data = {
|
|
1128
|
-
"session_id": session_id,
|
|
1129
|
-
"user_id": user_id,
|
|
1130
|
-
"role": role,
|
|
1131
|
-
"content": content,
|
|
1132
|
-
"message_type": message_type,
|
|
1133
|
-
"importance": importance,
|
|
1134
|
-
"metadata": metadata,
|
|
1135
|
-
}
|
|
1136
|
-
|
|
1137
|
-
if buffer_conversation(conv_data):
|
|
1138
|
-
queued += 1
|
|
1139
|
-
|
|
1140
|
-
return jsonify({
|
|
1141
|
-
"status": "ok",
|
|
1142
|
-
"queued": queued,
|
|
1143
|
-
}), 200
|
|
1144
|
-
|
|
1145
|
-
except Exception as e:
|
|
1146
|
-
import traceback
|
|
1147
|
-
print(f"Error saving conversation: {e}")
|
|
1148
|
-
traceback.print_exc()
|
|
1149
|
-
return jsonify({"error": str(e)}), 500
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
@app.route("/api/conversations/search", methods=["GET"])
|
|
1153
|
-
def search_conversations():
|
|
1154
|
-
"""Full-text search over conversations."""
|
|
1155
|
-
try:
|
|
1156
|
-
query = request.args.get("q", "")
|
|
1157
|
-
limit = min(int(request.args.get("limit", 20)), 100)
|
|
1158
|
-
user_id = request.args.get("user_id")
|
|
1159
|
-
message_type = request.args.get("type")
|
|
1160
|
-
|
|
1161
|
-
if not query:
|
|
1162
|
-
return jsonify({"error": "Query parameter 'q' required"}), 400
|
|
1163
|
-
|
|
1164
|
-
conn = get_db()
|
|
1165
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1166
|
-
# Build WHERE clause
|
|
1167
|
-
conditions = ["to_tsvector('english', content) @@ plainto_tsquery('english', %s)"]
|
|
1168
|
-
params = [query]
|
|
1169
|
-
|
|
1170
|
-
if user_id:
|
|
1171
|
-
conditions.append("user_id = %s")
|
|
1172
|
-
params.append(user_id)
|
|
1173
|
-
|
|
1174
|
-
if message_type:
|
|
1175
|
-
conditions.append("message_type = %s")
|
|
1176
|
-
params.append(message_type)
|
|
1177
|
-
|
|
1178
|
-
where_clause = " AND ".join(conditions)
|
|
1179
|
-
params.append(limit)
|
|
1180
|
-
|
|
1181
|
-
cur.execute(f"""
|
|
1182
|
-
SELECT
|
|
1183
|
-
id, session_id, user_id, role, content,
|
|
1184
|
-
message_type, importance, created_at,
|
|
1185
|
-
ts_rank(to_tsvector('english', content), plainto_tsquery('english', %s)) as rank
|
|
1186
|
-
FROM squads.conversations
|
|
1187
|
-
WHERE {where_clause}
|
|
1188
|
-
ORDER BY rank DESC, created_at DESC
|
|
1189
|
-
LIMIT %s
|
|
1190
|
-
""", [query] + params)
|
|
1191
|
-
|
|
1192
|
-
results = cur.fetchall()
|
|
1193
|
-
|
|
1194
|
-
conn.close()
|
|
1195
|
-
|
|
1196
|
-
return jsonify({
|
|
1197
|
-
"query": query,
|
|
1198
|
-
"count": len(results),
|
|
1199
|
-
"results": [{
|
|
1200
|
-
"id": r["id"],
|
|
1201
|
-
"session_id": r["session_id"],
|
|
1202
|
-
"role": r["role"],
|
|
1203
|
-
"content": r["content"][:500] + "..." if len(r["content"]) > 500 else r["content"],
|
|
1204
|
-
"type": r["message_type"],
|
|
1205
|
-
"importance": r["importance"],
|
|
1206
|
-
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
|
1207
|
-
"rank": float(r["rank"]),
|
|
1208
|
-
} for r in results],
|
|
1209
|
-
}), 200
|
|
1210
|
-
|
|
1211
|
-
except Exception as e:
|
|
1212
|
-
return jsonify({"error": str(e)}), 500
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
@app.route("/api/conversations/recent", methods=["GET"])
|
|
1216
|
-
def recent_conversations():
|
|
1217
|
-
"""Get recent conversations (for debugging/review)."""
|
|
1218
|
-
try:
|
|
1219
|
-
limit = min(int(request.args.get("limit", 20)), 100)
|
|
1220
|
-
user_id = request.args.get("user_id")
|
|
1221
|
-
|
|
1222
|
-
conn = get_db()
|
|
1223
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1224
|
-
if user_id:
|
|
1225
|
-
cur.execute("""
|
|
1226
|
-
SELECT id, session_id, user_id, role, content, message_type, importance, created_at
|
|
1227
|
-
FROM squads.conversations
|
|
1228
|
-
WHERE user_id = %s
|
|
1229
|
-
ORDER BY created_at DESC
|
|
1230
|
-
LIMIT %s
|
|
1231
|
-
""", (user_id, limit))
|
|
1232
|
-
else:
|
|
1233
|
-
cur.execute("""
|
|
1234
|
-
SELECT id, session_id, user_id, role, content, message_type, importance, created_at
|
|
1235
|
-
FROM squads.conversations
|
|
1236
|
-
ORDER BY created_at DESC
|
|
1237
|
-
LIMIT %s
|
|
1238
|
-
""", (limit,))
|
|
1239
|
-
|
|
1240
|
-
results = cur.fetchall()
|
|
1241
|
-
|
|
1242
|
-
conn.close()
|
|
1243
|
-
|
|
1244
|
-
return jsonify({
|
|
1245
|
-
"count": len(results),
|
|
1246
|
-
"conversations": [{
|
|
1247
|
-
"id": r["id"],
|
|
1248
|
-
"session_id": r["session_id"],
|
|
1249
|
-
"role": r["role"],
|
|
1250
|
-
"content": r["content"][:300] + "..." if len(r["content"]) > 300 else r["content"],
|
|
1251
|
-
"type": r["message_type"],
|
|
1252
|
-
"importance": r["importance"],
|
|
1253
|
-
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
|
|
1254
|
-
} for r in results],
|
|
1255
|
-
}), 200
|
|
1256
|
-
|
|
1257
|
-
except Exception as e:
|
|
1258
|
-
return jsonify({"error": str(e)}), 500
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
@app.route("/debug/logs", methods=["GET"])
|
|
1262
|
-
def debug_logs():
|
|
1263
|
-
"""Get recent log attributes for debugging."""
|
|
1264
|
-
if not DEBUG_MODE:
|
|
1265
|
-
return jsonify({"error": "Debug mode disabled"}), 403
|
|
1266
|
-
return jsonify({
|
|
1267
|
-
"debug_mode": True,
|
|
1268
|
-
"recent_logs": list(recent_logs),
|
|
1269
|
-
"count": len(recent_logs),
|
|
1270
|
-
}), 200
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
# =============================================================================
|
|
1274
|
-
# Task Tracking API - Track task completion, retries, quality
|
|
1275
|
-
# =============================================================================
|
|
1276
|
-
|
|
1277
|
-
@app.route("/api/tasks", methods=["POST"])
|
|
1278
|
-
def create_or_update_task():
|
|
1279
|
-
"""Create or update a task."""
|
|
1280
|
-
try:
|
|
1281
|
-
data = request.get_json()
|
|
1282
|
-
if not data:
|
|
1283
|
-
return jsonify({"error": "No JSON data"}), 400
|
|
1284
|
-
|
|
1285
|
-
task_id = data.get("task_id")
|
|
1286
|
-
if not task_id:
|
|
1287
|
-
return jsonify({"error": "task_id required"}), 400
|
|
1288
|
-
|
|
1289
|
-
conn = get_db()
|
|
1290
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1291
|
-
# Check if task exists
|
|
1292
|
-
cur.execute("SELECT id, retry_count FROM squads.tasks WHERE task_id = %s", (task_id,))
|
|
1293
|
-
existing = cur.fetchone()
|
|
1294
|
-
|
|
1295
|
-
if existing:
|
|
1296
|
-
# Update existing task
|
|
1297
|
-
retry_count = existing["retry_count"]
|
|
1298
|
-
if data.get("status") == "started" and data.get("is_retry"):
|
|
1299
|
-
retry_count += 1
|
|
1300
|
-
|
|
1301
|
-
cur.execute("""
|
|
1302
|
-
UPDATE squads.tasks SET
|
|
1303
|
-
status = COALESCE(%s, status),
|
|
1304
|
-
success = COALESCE(%s, success),
|
|
1305
|
-
retry_count = %s,
|
|
1306
|
-
output_type = COALESCE(%s, output_type),
|
|
1307
|
-
output_ref = COALESCE(%s, output_ref),
|
|
1308
|
-
total_tokens = COALESCE(%s, total_tokens),
|
|
1309
|
-
total_cost_usd = COALESCE(%s, total_cost_usd),
|
|
1310
|
-
peak_context_tokens = GREATEST(peak_context_tokens, COALESCE(%s, 0)),
|
|
1311
|
-
context_utilization_pct = GREATEST(context_utilization_pct, COALESCE(%s, 0)),
|
|
1312
|
-
completed_at = CASE WHEN %s IN ('completed', 'failed', 'cancelled') THEN NOW() ELSE completed_at END,
|
|
1313
|
-
duration_ms = CASE WHEN %s IN ('completed', 'failed', 'cancelled')
|
|
1314
|
-
THEN EXTRACT(EPOCH FROM (NOW() - started_at)) * 1000 ELSE duration_ms END,
|
|
1315
|
-
metadata = metadata || %s::jsonb
|
|
1316
|
-
WHERE task_id = %s
|
|
1317
|
-
RETURNING *
|
|
1318
|
-
""", (
|
|
1319
|
-
data.get("status"),
|
|
1320
|
-
data.get("success"),
|
|
1321
|
-
retry_count,
|
|
1322
|
-
data.get("output_type"),
|
|
1323
|
-
data.get("output_ref"),
|
|
1324
|
-
data.get("total_tokens"),
|
|
1325
|
-
data.get("total_cost_usd"),
|
|
1326
|
-
data.get("peak_context_tokens"),
|
|
1327
|
-
data.get("context_utilization_pct"),
|
|
1328
|
-
data.get("status"),
|
|
1329
|
-
data.get("status"),
|
|
1330
|
-
json.dumps(data.get("metadata", {})),
|
|
1331
|
-
task_id,
|
|
1332
|
-
))
|
|
1333
|
-
result = cur.fetchone()
|
|
1334
|
-
action = "updated"
|
|
1335
|
-
else:
|
|
1336
|
-
# Create new task
|
|
1337
|
-
cur.execute("""
|
|
1338
|
-
INSERT INTO squads.tasks
|
|
1339
|
-
(task_id, session_id, squad, agent, task_type, description,
|
|
1340
|
-
status, output_type, output_ref, total_tokens, total_cost_usd,
|
|
1341
|
-
peak_context_tokens, context_utilization_pct, metadata)
|
|
1342
|
-
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
1343
|
-
RETURNING *
|
|
1344
|
-
""", (
|
|
1345
|
-
task_id,
|
|
1346
|
-
data.get("session_id"),
|
|
1347
|
-
data.get("squad", "hq"),
|
|
1348
|
-
data.get("agent"),
|
|
1349
|
-
data.get("task_type", "goal"),
|
|
1350
|
-
data.get("description"),
|
|
1351
|
-
data.get("status", "started"),
|
|
1352
|
-
data.get("output_type"),
|
|
1353
|
-
data.get("output_ref"),
|
|
1354
|
-
data.get("total_tokens", 0),
|
|
1355
|
-
data.get("total_cost_usd", 0),
|
|
1356
|
-
data.get("peak_context_tokens", 0),
|
|
1357
|
-
data.get("context_utilization_pct"),
|
|
1358
|
-
json.dumps(data.get("metadata", {})),
|
|
1359
|
-
))
|
|
1360
|
-
result = cur.fetchone()
|
|
1361
|
-
action = "created"
|
|
1362
|
-
|
|
1363
|
-
conn.commit()
|
|
1364
|
-
conn.close()
|
|
1365
|
-
|
|
1366
|
-
return jsonify({
|
|
1367
|
-
"status": "ok",
|
|
1368
|
-
"action": action,
|
|
1369
|
-
"task": {
|
|
1370
|
-
"task_id": result["task_id"],
|
|
1371
|
-
"squad": result["squad"],
|
|
1372
|
-
"status": result["status"],
|
|
1373
|
-
"retry_count": result["retry_count"],
|
|
1374
|
-
}
|
|
1375
|
-
}), 200
|
|
1376
|
-
|
|
1377
|
-
except Exception as e:
|
|
1378
|
-
import traceback
|
|
1379
|
-
traceback.print_exc()
|
|
1380
|
-
return jsonify({"error": str(e)}), 500
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
@app.route("/api/tasks/<task_id>/feedback", methods=["POST"])
|
|
1384
|
-
def add_task_feedback(task_id):
|
|
1385
|
-
"""Add feedback for a task."""
|
|
1386
|
-
try:
|
|
1387
|
-
data = request.get_json()
|
|
1388
|
-
if not data:
|
|
1389
|
-
return jsonify({"error": "No JSON data"}), 400
|
|
1390
|
-
|
|
1391
|
-
conn = get_db()
|
|
1392
|
-
with conn.cursor() as cur:
|
|
1393
|
-
# Verify task exists
|
|
1394
|
-
cur.execute("SELECT id FROM squads.tasks WHERE task_id = %s", (task_id,))
|
|
1395
|
-
if not cur.fetchone():
|
|
1396
|
-
conn.close()
|
|
1397
|
-
return jsonify({"error": f"Task {task_id} not found"}), 404
|
|
1398
|
-
|
|
1399
|
-
# Insert feedback
|
|
1400
|
-
tags = data.get("tags", [])
|
|
1401
|
-
cur.execute("""
|
|
1402
|
-
INSERT INTO squads.task_feedback
|
|
1403
|
-
(task_id, quality_score, was_helpful, required_fixes, fix_description, tags, notes)
|
|
1404
|
-
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
1405
|
-
RETURNING id
|
|
1406
|
-
""", (
|
|
1407
|
-
task_id,
|
|
1408
|
-
data.get("quality_score"),
|
|
1409
|
-
data.get("was_helpful"),
|
|
1410
|
-
data.get("required_fixes", False),
|
|
1411
|
-
data.get("fix_description"),
|
|
1412
|
-
tags if tags else None,
|
|
1413
|
-
data.get("notes"),
|
|
1414
|
-
))
|
|
1415
|
-
feedback_id = cur.fetchone()[0]
|
|
1416
|
-
|
|
1417
|
-
conn.commit()
|
|
1418
|
-
conn.close()
|
|
1419
|
-
|
|
1420
|
-
return jsonify({
|
|
1421
|
-
"status": "ok",
|
|
1422
|
-
"feedback_id": feedback_id,
|
|
1423
|
-
}), 200
|
|
1424
|
-
|
|
1425
|
-
except Exception as e:
|
|
1426
|
-
return jsonify({"error": str(e)}), 500
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
@app.route("/api/insights", methods=["GET"])
|
|
1430
|
-
def get_insights():
|
|
1431
|
-
"""Get aggregated insights for dashboard."""
|
|
1432
|
-
try:
|
|
1433
|
-
squad = request.args.get("squad")
|
|
1434
|
-
period = request.args.get("period", "week") # day, week, month
|
|
1435
|
-
days = {"day": 1, "week": 7, "month": 30}.get(period, 7)
|
|
1436
|
-
|
|
1437
|
-
conn = get_db()
|
|
1438
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1439
|
-
# Squad filter
|
|
1440
|
-
squad_filter = "AND t.squad = %s" if squad else ""
|
|
1441
|
-
params = [days, squad] if squad else [days]
|
|
1442
|
-
|
|
1443
|
-
# Task completion metrics
|
|
1444
|
-
cur.execute(f"""
|
|
1445
|
-
SELECT
|
|
1446
|
-
COALESCE(t.squad, 'all') as squad,
|
|
1447
|
-
COUNT(*) as tasks_total,
|
|
1448
|
-
COUNT(*) FILTER (WHERE t.status = 'completed') as tasks_completed,
|
|
1449
|
-
COUNT(*) FILTER (WHERE t.status = 'failed') as tasks_failed,
|
|
1450
|
-
COUNT(*) FILTER (WHERE t.success = true) as tasks_successful,
|
|
1451
|
-
ROUND(100.0 * COUNT(*) FILTER (WHERE t.success = true) / NULLIF(COUNT(*), 0), 1) as success_rate,
|
|
1452
|
-
SUM(t.retry_count) as total_retries,
|
|
1453
|
-
COUNT(*) FILTER (WHERE t.retry_count > 0) as tasks_with_retries,
|
|
1454
|
-
ROUND(AVG(t.retry_count)::numeric, 2) as avg_retries,
|
|
1455
|
-
ROUND(AVG(t.duration_ms)::numeric, 0) as avg_duration_ms,
|
|
1456
|
-
ROUND(AVG(t.total_tokens)::numeric, 0) as avg_tokens,
|
|
1457
|
-
ROUND(AVG(t.total_cost_usd)::numeric, 4) as avg_cost,
|
|
1458
|
-
ROUND(AVG(t.context_utilization_pct)::numeric, 1) as avg_context_pct,
|
|
1459
|
-
MAX(t.peak_context_tokens) as max_context_tokens
|
|
1460
|
-
FROM squads.tasks t
|
|
1461
|
-
WHERE t.started_at >= NOW() - INTERVAL '%s days' {squad_filter}
|
|
1462
|
-
GROUP BY t.squad
|
|
1463
|
-
ORDER BY tasks_total DESC
|
|
1464
|
-
""", params)
|
|
1465
|
-
task_metrics = cur.fetchall()
|
|
1466
|
-
|
|
1467
|
-
# Quality metrics from feedback
|
|
1468
|
-
cur.execute(f"""
|
|
1469
|
-
SELECT
|
|
1470
|
-
COALESCE(t.squad, 'all') as squad,
|
|
1471
|
-
COUNT(f.id) as feedback_count,
|
|
1472
|
-
ROUND(AVG(f.quality_score)::numeric, 2) as avg_quality,
|
|
1473
|
-
ROUND(100.0 * COUNT(*) FILTER (WHERE f.was_helpful = true) / NULLIF(COUNT(*), 0), 1) as helpful_pct,
|
|
1474
|
-
ROUND(100.0 * COUNT(*) FILTER (WHERE f.required_fixes = true) / NULLIF(COUNT(*), 0), 1) as fix_required_pct
|
|
1475
|
-
FROM squads.tasks t
|
|
1476
|
-
LEFT JOIN squads.task_feedback f ON t.task_id = f.task_id
|
|
1477
|
-
WHERE t.started_at >= NOW() - INTERVAL '%s days' {squad_filter}
|
|
1478
|
-
AND f.id IS NOT NULL
|
|
1479
|
-
GROUP BY t.squad
|
|
1480
|
-
""", params)
|
|
1481
|
-
quality_metrics = cur.fetchall()
|
|
1482
|
-
|
|
1483
|
-
# Tool usage metrics
|
|
1484
|
-
cur.execute(f"""
|
|
1485
|
-
SELECT
|
|
1486
|
-
tool_name,
|
|
1487
|
-
COUNT(*) as usage_count,
|
|
1488
|
-
ROUND(100.0 * COUNT(*) FILTER (WHERE success = true) / NULLIF(COUNT(*), 0), 1) as success_rate,
|
|
1489
|
-
ROUND(AVG(duration_ms)::numeric, 0) as avg_duration_ms
|
|
1490
|
-
FROM squads.tool_executions
|
|
1491
|
-
WHERE created_at >= NOW() - INTERVAL '%s days'
|
|
1492
|
-
GROUP BY tool_name
|
|
1493
|
-
ORDER BY usage_count DESC
|
|
1494
|
-
LIMIT 15
|
|
1495
|
-
""", [days])
|
|
1496
|
-
top_tools = cur.fetchall()
|
|
1497
|
-
|
|
1498
|
-
# Overall tool failure rate
|
|
1499
|
-
cur.execute("""
|
|
1500
|
-
SELECT
|
|
1501
|
-
ROUND(100.0 * COUNT(*) FILTER (WHERE success = false) / NULLIF(COUNT(*), 0), 1) as failure_rate
|
|
1502
|
-
FROM squads.tool_executions
|
|
1503
|
-
WHERE created_at >= NOW() - INTERVAL '%s days'
|
|
1504
|
-
""", [days])
|
|
1505
|
-
tool_failure = cur.fetchone()
|
|
1506
|
-
|
|
1507
|
-
# Session efficiency
|
|
1508
|
-
cur.execute(f"""
|
|
1509
|
-
SELECT
|
|
1510
|
-
COALESCE(squad, 'all') as squad,
|
|
1511
|
-
COUNT(*) as sessions,
|
|
1512
|
-
ROUND(AVG(total_cost_usd)::numeric, 4) as avg_session_cost,
|
|
1513
|
-
ROUND(AVG(generation_count)::numeric, 1) as avg_generations,
|
|
1514
|
-
ROUND(AVG(tool_count)::numeric, 1) as avg_tools
|
|
1515
|
-
FROM squads.sessions
|
|
1516
|
-
WHERE started_at >= NOW() - INTERVAL '%s days' {squad_filter.replace('t.', '')}
|
|
1517
|
-
GROUP BY squad
|
|
1518
|
-
""", params)
|
|
1519
|
-
session_metrics = cur.fetchall()
|
|
1520
|
-
|
|
1521
|
-
conn.close()
|
|
1522
|
-
|
|
1523
|
-
return jsonify({
|
|
1524
|
-
"period": period,
|
|
1525
|
-
"days": days,
|
|
1526
|
-
"squad_filter": squad,
|
|
1527
|
-
"task_metrics": [dict(r) for r in task_metrics],
|
|
1528
|
-
"quality_metrics": [dict(r) for r in quality_metrics],
|
|
1529
|
-
"top_tools": [dict(r) for r in top_tools],
|
|
1530
|
-
"tool_failure_rate": float(tool_failure["failure_rate"] or 0) if tool_failure else 0,
|
|
1531
|
-
"session_metrics": [dict(r) for r in session_metrics],
|
|
1532
|
-
}), 200
|
|
1533
|
-
|
|
1534
|
-
except Exception as e:
|
|
1535
|
-
import traceback
|
|
1536
|
-
traceback.print_exc()
|
|
1537
|
-
return jsonify({"error": str(e)}), 500
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
@app.route("/api/insights/compute", methods=["POST"])
|
|
1541
|
-
def compute_insights():
|
|
1542
|
-
"""Compute and cache insights into agent_insights table."""
|
|
1543
|
-
try:
|
|
1544
|
-
period = request.args.get("period", "day") # day, week, month
|
|
1545
|
-
|
|
1546
|
-
conn = get_db()
|
|
1547
|
-
with conn.cursor() as cur:
|
|
1548
|
-
# Compute for each squad
|
|
1549
|
-
cur.execute("""
|
|
1550
|
-
INSERT INTO squads.agent_insights
|
|
1551
|
-
(period, period_start, squad, agent,
|
|
1552
|
-
tasks_started, tasks_completed, tasks_failed, success_rate,
|
|
1553
|
-
total_retries, avg_retries_per_task, tasks_with_retries,
|
|
1554
|
-
avg_quality_score, feedback_count, helpful_pct, fix_required_pct,
|
|
1555
|
-
avg_duration_ms, avg_tokens_per_task, avg_cost_per_task, avg_context_utilization,
|
|
1556
|
-
top_tools, tool_failure_rate)
|
|
1557
|
-
SELECT
|
|
1558
|
-
%s as period,
|
|
1559
|
-
CURRENT_DATE as period_start,
|
|
1560
|
-
t.squad,
|
|
1561
|
-
t.agent,
|
|
1562
|
-
COUNT(*) as tasks_started,
|
|
1563
|
-
COUNT(*) FILTER (WHERE t.status = 'completed') as tasks_completed,
|
|
1564
|
-
COUNT(*) FILTER (WHERE t.status = 'failed') as tasks_failed,
|
|
1565
|
-
ROUND(100.0 * COUNT(*) FILTER (WHERE t.success = true) / NULLIF(COUNT(*), 0), 2),
|
|
1566
|
-
SUM(t.retry_count),
|
|
1567
|
-
ROUND(AVG(t.retry_count)::numeric, 2),
|
|
1568
|
-
COUNT(*) FILTER (WHERE t.retry_count > 0),
|
|
1569
|
-
(SELECT ROUND(AVG(f.quality_score)::numeric, 2) FROM squads.task_feedback f WHERE f.task_id = ANY(ARRAY_AGG(t.task_id))),
|
|
1570
|
-
(SELECT COUNT(*) FROM squads.task_feedback f WHERE f.task_id = ANY(ARRAY_AGG(t.task_id))),
|
|
1571
|
-
NULL, NULL,
|
|
1572
|
-
ROUND(AVG(t.duration_ms)::numeric, 0),
|
|
1573
|
-
ROUND(AVG(t.total_tokens)::numeric, 0),
|
|
1574
|
-
ROUND(AVG(t.total_cost_usd)::numeric, 6),
|
|
1575
|
-
ROUND(AVG(t.context_utilization_pct)::numeric, 2),
|
|
1576
|
-
'[]'::jsonb,
|
|
1577
|
-
NULL
|
|
1578
|
-
FROM squads.tasks t
|
|
1579
|
-
WHERE t.started_at >= CURRENT_DATE - INTERVAL '1 day' * %s
|
|
1580
|
-
GROUP BY t.squad, t.agent
|
|
1581
|
-
ON CONFLICT (period, period_start, squad, agent) DO UPDATE SET
|
|
1582
|
-
tasks_started = EXCLUDED.tasks_started,
|
|
1583
|
-
tasks_completed = EXCLUDED.tasks_completed,
|
|
1584
|
-
tasks_failed = EXCLUDED.tasks_failed,
|
|
1585
|
-
success_rate = EXCLUDED.success_rate,
|
|
1586
|
-
total_retries = EXCLUDED.total_retries,
|
|
1587
|
-
avg_retries_per_task = EXCLUDED.avg_retries_per_task,
|
|
1588
|
-
tasks_with_retries = EXCLUDED.tasks_with_retries,
|
|
1589
|
-
avg_quality_score = EXCLUDED.avg_quality_score,
|
|
1590
|
-
feedback_count = EXCLUDED.feedback_count,
|
|
1591
|
-
avg_duration_ms = EXCLUDED.avg_duration_ms,
|
|
1592
|
-
avg_tokens_per_task = EXCLUDED.avg_tokens_per_task,
|
|
1593
|
-
avg_cost_per_task = EXCLUDED.avg_cost_per_task,
|
|
1594
|
-
avg_context_utilization = EXCLUDED.avg_context_utilization,
|
|
1595
|
-
captured_at = NOW()
|
|
1596
|
-
""", (period, {"day": 1, "week": 7, "month": 30}.get(period, 7)))
|
|
1597
|
-
|
|
1598
|
-
conn.commit()
|
|
1599
|
-
conn.close()
|
|
1600
|
-
|
|
1601
|
-
return jsonify({"status": "ok", "period": period}), 200
|
|
1602
|
-
|
|
1603
|
-
except Exception as e:
|
|
1604
|
-
import traceback
|
|
1605
|
-
traceback.print_exc()
|
|
1606
|
-
return jsonify({"error": str(e)}), 500
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
# =============================================================================
|
|
1610
|
-
# Business Brief API - Sync strategic context to Postgres
|
|
1611
|
-
# =============================================================================
|
|
1612
|
-
|
|
1613
|
-
@app.route("/api/brief", methods=["GET"])
|
|
1614
|
-
def get_brief():
|
|
1615
|
-
"""Get current business brief from Postgres."""
|
|
1616
|
-
try:
|
|
1617
|
-
conn = get_db()
|
|
1618
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1619
|
-
cur.execute("""
|
|
1620
|
-
SELECT id, priority, runway, focus, blockers, decision_framework,
|
|
1621
|
-
metrics, raw_content, source_hash, updated_at, synced_by
|
|
1622
|
-
FROM business_briefs
|
|
1623
|
-
WHERE is_active = true
|
|
1624
|
-
ORDER BY updated_at DESC
|
|
1625
|
-
LIMIT 1
|
|
1626
|
-
""")
|
|
1627
|
-
brief = cur.fetchone()
|
|
1628
|
-
conn.close()
|
|
1629
|
-
|
|
1630
|
-
if not brief:
|
|
1631
|
-
return jsonify({"error": "No brief found"}), 404
|
|
1632
|
-
|
|
1633
|
-
return jsonify({
|
|
1634
|
-
"status": "ok",
|
|
1635
|
-
"brief": {
|
|
1636
|
-
"id": str(brief["id"]),
|
|
1637
|
-
"priority": brief["priority"],
|
|
1638
|
-
"runway": brief["runway"],
|
|
1639
|
-
"focus": brief["focus"],
|
|
1640
|
-
"blockers": brief["blockers"],
|
|
1641
|
-
"decision_framework": brief["decision_framework"],
|
|
1642
|
-
"metrics": brief["metrics"],
|
|
1643
|
-
"raw_content": brief["raw_content"],
|
|
1644
|
-
"source_hash": brief["source_hash"],
|
|
1645
|
-
"updated_at": brief["updated_at"].isoformat() if brief["updated_at"] else None,
|
|
1646
|
-
"synced_by": brief["synced_by"],
|
|
1647
|
-
}
|
|
1648
|
-
}), 200
|
|
1649
|
-
|
|
1650
|
-
except Exception as e:
|
|
1651
|
-
return jsonify({"error": str(e)}), 500
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
@app.route("/api/brief", methods=["POST"])
|
|
1655
|
-
def sync_brief():
|
|
1656
|
-
"""Sync business brief from local to Postgres."""
|
|
1657
|
-
try:
|
|
1658
|
-
data = request.get_json()
|
|
1659
|
-
if not data:
|
|
1660
|
-
return jsonify({"error": "No JSON data"}), 400
|
|
1661
|
-
|
|
1662
|
-
import hashlib
|
|
1663
|
-
raw_content = data.get("raw_content", "")
|
|
1664
|
-
source_hash = hashlib.md5(raw_content.encode()).hexdigest()
|
|
1665
|
-
|
|
1666
|
-
conn = get_db()
|
|
1667
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1668
|
-
# Check if content changed
|
|
1669
|
-
cur.execute("""
|
|
1670
|
-
SELECT source_hash FROM business_briefs
|
|
1671
|
-
WHERE is_active = true
|
|
1672
|
-
ORDER BY updated_at DESC
|
|
1673
|
-
LIMIT 1
|
|
1674
|
-
""")
|
|
1675
|
-
existing = cur.fetchone()
|
|
1676
|
-
|
|
1677
|
-
if existing and existing["source_hash"] == source_hash:
|
|
1678
|
-
conn.close()
|
|
1679
|
-
return jsonify({
|
|
1680
|
-
"status": "unchanged",
|
|
1681
|
-
"message": "Brief content unchanged",
|
|
1682
|
-
"source_hash": source_hash,
|
|
1683
|
-
}), 200
|
|
1684
|
-
|
|
1685
|
-
# Deactivate old briefs
|
|
1686
|
-
cur.execute("UPDATE business_briefs SET is_active = false WHERE is_active = true")
|
|
1687
|
-
|
|
1688
|
-
# Insert new brief
|
|
1689
|
-
cur.execute("""
|
|
1690
|
-
INSERT INTO business_briefs
|
|
1691
|
-
(priority, runway, focus, blockers, decision_framework,
|
|
1692
|
-
metrics, raw_content, source_hash, source_path, synced_by)
|
|
1693
|
-
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
1694
|
-
RETURNING id
|
|
1695
|
-
""", (
|
|
1696
|
-
data.get("priority"),
|
|
1697
|
-
data.get("runway"),
|
|
1698
|
-
json.dumps(data.get("focus", [])),
|
|
1699
|
-
json.dumps(data.get("blockers", [])),
|
|
1700
|
-
json.dumps(data.get("decision_framework", [])),
|
|
1701
|
-
json.dumps(data.get("metrics", {})),
|
|
1702
|
-
raw_content,
|
|
1703
|
-
source_hash,
|
|
1704
|
-
data.get("source_path"),
|
|
1705
|
-
data.get("synced_by", "cli"),
|
|
1706
|
-
))
|
|
1707
|
-
brief_id = cur.fetchone()["id"]
|
|
1708
|
-
|
|
1709
|
-
conn.commit()
|
|
1710
|
-
conn.close()
|
|
1711
|
-
|
|
1712
|
-
if DEBUG_MODE:
|
|
1713
|
-
print(f"[BRIEF] Synced: {source_hash[:8]}... priority={data.get('priority', '')[:30]}")
|
|
1714
|
-
|
|
1715
|
-
return jsonify({
|
|
1716
|
-
"status": "synced",
|
|
1717
|
-
"brief_id": str(brief_id),
|
|
1718
|
-
"source_hash": source_hash,
|
|
1719
|
-
}), 200
|
|
1720
|
-
|
|
1721
|
-
except Exception as e:
|
|
1722
|
-
import traceback
|
|
1723
|
-
traceback.print_exc()
|
|
1724
|
-
return jsonify({"error": str(e)}), 500
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
@app.route("/api/brief/history", methods=["GET"])
|
|
1728
|
-
def brief_history():
|
|
1729
|
-
"""Get brief change history."""
|
|
1730
|
-
try:
|
|
1731
|
-
limit = min(int(request.args.get("limit", 10)), 50)
|
|
1732
|
-
|
|
1733
|
-
conn = get_db()
|
|
1734
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1735
|
-
cur.execute("""
|
|
1736
|
-
SELECT id, priority, runway, source_hash, updated_at, synced_by, is_active
|
|
1737
|
-
FROM business_briefs
|
|
1738
|
-
ORDER BY updated_at DESC
|
|
1739
|
-
LIMIT %s
|
|
1740
|
-
""", (limit,))
|
|
1741
|
-
briefs = cur.fetchall()
|
|
1742
|
-
conn.close()
|
|
1743
|
-
|
|
1744
|
-
return jsonify({
|
|
1745
|
-
"status": "ok",
|
|
1746
|
-
"count": len(briefs),
|
|
1747
|
-
"history": [{
|
|
1748
|
-
"id": str(b["id"]),
|
|
1749
|
-
"priority": b["priority"],
|
|
1750
|
-
"runway": b["runway"],
|
|
1751
|
-
"source_hash": b["source_hash"],
|
|
1752
|
-
"updated_at": b["updated_at"].isoformat() if b["updated_at"] else None,
|
|
1753
|
-
"synced_by": b["synced_by"],
|
|
1754
|
-
"is_active": b["is_active"],
|
|
1755
|
-
} for b in briefs],
|
|
1756
|
-
}), 200
|
|
1757
|
-
|
|
1758
|
-
except Exception as e:
|
|
1759
|
-
return jsonify({"error": str(e)}), 500
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
# =============================================================================
|
|
1763
|
-
# Execution Gates API - Pre-execution safety checks
|
|
1764
|
-
# =============================================================================
|
|
1765
|
-
|
|
1766
|
-
@app.route("/api/execution/preflight", methods=["POST"])
|
|
1767
|
-
def preflight_check():
|
|
1768
|
-
"""Check all execution gates before agent runs.
|
|
1769
|
-
|
|
1770
|
-
Returns:
|
|
1771
|
-
{
|
|
1772
|
-
"allowed": bool,
|
|
1773
|
-
"gates": {
|
|
1774
|
-
"budget": {"ok": bool, "used": float, "limit": float, "remaining": float},
|
|
1775
|
-
"cooldown": {"ok": bool, "elapsed_sec": int, "min_gap_sec": int}
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
"""
|
|
1779
|
-
try:
|
|
1780
|
-
data = request.get_json() or {}
|
|
1781
|
-
squad = data.get("squad", "hq")
|
|
1782
|
-
agent = data.get("agent")
|
|
1783
|
-
min_cooldown = int(data.get("min_cooldown_sec", 300)) # 5 min default
|
|
1784
|
-
|
|
1785
|
-
# Quota gate - check monthly spend against plan quota
|
|
1786
|
-
# Query monthly usage from Postgres (Redis only has daily stats)
|
|
1787
|
-
conn = get_db()
|
|
1788
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1789
|
-
cur.execute("""
|
|
1790
|
-
SELECT COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
1791
|
-
FROM squads.llm_generations
|
|
1792
|
-
WHERE created_at >= DATE_TRUNC('month', CURRENT_DATE)
|
|
1793
|
-
""")
|
|
1794
|
-
result = cur.fetchone()
|
|
1795
|
-
monthly_used = float(result["cost_usd"]) if result else 0
|
|
1796
|
-
conn.close()
|
|
1797
|
-
|
|
1798
|
-
quota_gate = {
|
|
1799
|
-
"ok": monthly_used < MONTHLY_QUOTA,
|
|
1800
|
-
"used": round(monthly_used, 2),
|
|
1801
|
-
"limit": MONTHLY_QUOTA,
|
|
1802
|
-
"remaining": round(MONTHLY_QUOTA - monthly_used, 2),
|
|
1803
|
-
"period": "month"
|
|
1804
|
-
}
|
|
1805
|
-
|
|
1806
|
-
# Cooldown gate (from existing tasks table)
|
|
1807
|
-
cooldown_gate = {"ok": True, "elapsed_sec": None, "min_gap_sec": min_cooldown}
|
|
1808
|
-
|
|
1809
|
-
if agent:
|
|
1810
|
-
conn = get_db()
|
|
1811
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1812
|
-
cur.execute("""
|
|
1813
|
-
SELECT MAX(started_at) as last_run
|
|
1814
|
-
FROM squads.tasks
|
|
1815
|
-
WHERE squad = %s AND agent = %s
|
|
1816
|
-
""", (squad, agent))
|
|
1817
|
-
result = cur.fetchone()
|
|
1818
|
-
conn.close()
|
|
1819
|
-
|
|
1820
|
-
if result and result["last_run"]:
|
|
1821
|
-
last_run = result["last_run"]
|
|
1822
|
-
# Handle timezone-aware datetime
|
|
1823
|
-
now = datetime.now(last_run.tzinfo) if last_run.tzinfo else datetime.now()
|
|
1824
|
-
elapsed = (now - last_run).total_seconds()
|
|
1825
|
-
cooldown_gate = {
|
|
1826
|
-
"ok": elapsed >= min_cooldown,
|
|
1827
|
-
"elapsed_sec": int(elapsed),
|
|
1828
|
-
"min_gap_sec": min_cooldown,
|
|
1829
|
-
"last_run": last_run.isoformat()
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
# Quota gate is informational only - never blocks execution
|
|
1833
|
-
# Track as KPI, not as enforcement (real limits come from Anthropic subscription)
|
|
1834
|
-
allowed = cooldown_gate["ok"]
|
|
1835
|
-
|
|
1836
|
-
if DEBUG_MODE:
|
|
1837
|
-
print(f"[PREFLIGHT] {squad}/{agent}: quota=${quota_gate['used']}/mo (KPI) cooldown={'OK' if cooldown_gate['ok'] else 'BLOCKED'}")
|
|
1838
|
-
|
|
1839
|
-
return jsonify({
|
|
1840
|
-
"allowed": allowed,
|
|
1841
|
-
"squad": squad,
|
|
1842
|
-
"agent": agent,
|
|
1843
|
-
"gates": {
|
|
1844
|
-
"quota": quota_gate,
|
|
1845
|
-
"cooldown": cooldown_gate
|
|
1846
|
-
}
|
|
1847
|
-
}), 200
|
|
1848
|
-
|
|
1849
|
-
except Exception as e:
|
|
1850
|
-
import traceback
|
|
1851
|
-
traceback.print_exc()
|
|
1852
|
-
# Fail open - if check fails, allow execution
|
|
1853
|
-
return jsonify({
|
|
1854
|
-
"allowed": True,
|
|
1855
|
-
"error": str(e),
|
|
1856
|
-
"gates": {}
|
|
1857
|
-
}), 200
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
@app.route("/api/learnings/relevant", methods=["GET"])
|
|
1861
|
-
def get_relevant_learnings():
|
|
1862
|
-
"""Get recent learnings for prompt injection.
|
|
1863
|
-
|
|
1864
|
-
Queries conversations marked as 'learning' type, ordered by importance.
|
|
1865
|
-
|
|
1866
|
-
Query params:
|
|
1867
|
-
squad: Filter by squad (optional)
|
|
1868
|
-
limit: Max results (default 5)
|
|
1869
|
-
|
|
1870
|
-
Returns:
|
|
1871
|
-
{
|
|
1872
|
-
"squad": str,
|
|
1873
|
-
"count": int,
|
|
1874
|
-
"learnings": [{"content": str, "importance": str, "created_at": str}]
|
|
1875
|
-
}
|
|
1876
|
-
"""
|
|
1877
|
-
try:
|
|
1878
|
-
squad = request.args.get("squad")
|
|
1879
|
-
limit = min(int(request.args.get("limit", 5)), 20)
|
|
1880
|
-
|
|
1881
|
-
conn = get_db()
|
|
1882
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
1883
|
-
# Query conversations marked as learnings
|
|
1884
|
-
# Order by importance (high > normal > low), then recency
|
|
1885
|
-
if squad:
|
|
1886
|
-
cur.execute("""
|
|
1887
|
-
SELECT content, importance, created_at, squad
|
|
1888
|
-
FROM squads.conversations
|
|
1889
|
-
WHERE message_type = 'learning'
|
|
1890
|
-
AND (squad = %s OR squad IS NULL)
|
|
1891
|
-
ORDER BY
|
|
1892
|
-
CASE importance WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,
|
|
1893
|
-
created_at DESC
|
|
1894
|
-
LIMIT %s
|
|
1895
|
-
""", (squad, limit))
|
|
1896
|
-
else:
|
|
1897
|
-
cur.execute("""
|
|
1898
|
-
SELECT content, importance, created_at, squad
|
|
1899
|
-
FROM squads.conversations
|
|
1900
|
-
WHERE message_type = 'learning'
|
|
1901
|
-
ORDER BY
|
|
1902
|
-
CASE importance WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,
|
|
1903
|
-
created_at DESC
|
|
1904
|
-
LIMIT %s
|
|
1905
|
-
""", (limit,))
|
|
1906
|
-
|
|
1907
|
-
learnings = cur.fetchall()
|
|
1908
|
-
conn.close()
|
|
1909
|
-
|
|
1910
|
-
if DEBUG_MODE:
|
|
1911
|
-
print(f"[LEARNINGS] Found {len(learnings)} learnings for squad={squad}")
|
|
1912
|
-
|
|
1913
|
-
return jsonify({
|
|
1914
|
-
"squad": squad,
|
|
1915
|
-
"count": len(learnings),
|
|
1916
|
-
"learnings": [{
|
|
1917
|
-
"content": l["content"][:500] if l["content"] else "",
|
|
1918
|
-
"importance": l["importance"] or "normal",
|
|
1919
|
-
"squad": l["squad"],
|
|
1920
|
-
"created_at": l["created_at"].isoformat() if l["created_at"] else None
|
|
1921
|
-
} for l in learnings]
|
|
1922
|
-
}), 200
|
|
1923
|
-
|
|
1924
|
-
except Exception as e:
|
|
1925
|
-
import traceback
|
|
1926
|
-
traceback.print_exc()
|
|
1927
|
-
return jsonify({
|
|
1928
|
-
"squad": request.args.get("squad"),
|
|
1929
|
-
"count": 0,
|
|
1930
|
-
"learnings": [],
|
|
1931
|
-
"error": str(e)
|
|
1932
|
-
}), 200
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
# =============================================================================
|
|
1936
|
-
# Dimension Sync API - Sync squad/agent definitions from CLI
|
|
1937
|
-
# =============================================================================
|
|
1938
|
-
|
|
1939
|
-
@app.route("/api/sync/dimensions", methods=["POST"])
|
|
1940
|
-
def sync_dimensions():
|
|
1941
|
-
"""Sync squad and agent definitions to dimension tables.
|
|
1942
|
-
|
|
1943
|
-
Expects:
|
|
1944
|
-
{
|
|
1945
|
-
"squads": [{"name": str, "mission": str, "domain": str, ...}],
|
|
1946
|
-
"agents": [{"name": str, "squad": str, "role": str, ...}]
|
|
1947
|
-
}
|
|
1948
|
-
|
|
1949
|
-
Returns:
|
|
1950
|
-
{"synced_squads": int, "synced_agents": int}
|
|
1951
|
-
"""
|
|
1952
|
-
try:
|
|
1953
|
-
data = request.get_json() or {}
|
|
1954
|
-
squads = data.get("squads", [])
|
|
1955
|
-
agents = data.get("agents", [])
|
|
1956
|
-
|
|
1957
|
-
conn = get_db()
|
|
1958
|
-
synced_squads = 0
|
|
1959
|
-
synced_agents = 0
|
|
1960
|
-
|
|
1961
|
-
with conn.cursor() as cur:
|
|
1962
|
-
# Upsert squads
|
|
1963
|
-
for squad in squads:
|
|
1964
|
-
cur.execute("""
|
|
1965
|
-
INSERT INTO squads.dim_squads (
|
|
1966
|
-
squad_name, mission, domain, default_provider,
|
|
1967
|
-
daily_budget, cooldown_seconds, metadata, updated_at
|
|
1968
|
-
) VALUES (%s, %s, %s, %s, %s, %s, %s, NOW())
|
|
1969
|
-
ON CONFLICT (squad_name) DO UPDATE SET
|
|
1970
|
-
mission = EXCLUDED.mission,
|
|
1971
|
-
domain = EXCLUDED.domain,
|
|
1972
|
-
default_provider = EXCLUDED.default_provider,
|
|
1973
|
-
daily_budget = EXCLUDED.daily_budget,
|
|
1974
|
-
cooldown_seconds = EXCLUDED.cooldown_seconds,
|
|
1975
|
-
metadata = EXCLUDED.metadata,
|
|
1976
|
-
updated_at = NOW()
|
|
1977
|
-
""", (
|
|
1978
|
-
squad["name"],
|
|
1979
|
-
squad.get("mission"),
|
|
1980
|
-
squad.get("domain"),
|
|
1981
|
-
squad.get("default_provider", "anthropic"),
|
|
1982
|
-
squad.get("daily_budget", 50),
|
|
1983
|
-
squad.get("cooldown_seconds", 300),
|
|
1984
|
-
json.dumps(squad.get("metadata", {})),
|
|
1985
|
-
))
|
|
1986
|
-
synced_squads += 1
|
|
1987
|
-
|
|
1988
|
-
# Upsert agents (need squad_id)
|
|
1989
|
-
for agent in agents:
|
|
1990
|
-
# Get squad_id
|
|
1991
|
-
cur.execute(
|
|
1992
|
-
"SELECT id FROM squads.dim_squads WHERE squad_name = %s",
|
|
1993
|
-
(agent["squad"],)
|
|
1994
|
-
)
|
|
1995
|
-
row = cur.fetchone()
|
|
1996
|
-
squad_id = row[0] if row else None
|
|
1997
|
-
|
|
1998
|
-
if squad_id:
|
|
1999
|
-
cur.execute("""
|
|
2000
|
-
INSERT INTO squads.dim_agents (
|
|
2001
|
-
agent_name, squad_id, role, purpose, provider,
|
|
2002
|
-
trigger_type, mcp_servers, skills, metadata, updated_at
|
|
2003
|
-
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
|
2004
|
-
ON CONFLICT (agent_name, squad_id) DO UPDATE SET
|
|
2005
|
-
role = EXCLUDED.role,
|
|
2006
|
-
purpose = EXCLUDED.purpose,
|
|
2007
|
-
provider = EXCLUDED.provider,
|
|
2008
|
-
trigger_type = EXCLUDED.trigger_type,
|
|
2009
|
-
mcp_servers = EXCLUDED.mcp_servers,
|
|
2010
|
-
skills = EXCLUDED.skills,
|
|
2011
|
-
metadata = EXCLUDED.metadata,
|
|
2012
|
-
updated_at = NOW()
|
|
2013
|
-
""", (
|
|
2014
|
-
agent["name"],
|
|
2015
|
-
squad_id,
|
|
2016
|
-
agent.get("role"),
|
|
2017
|
-
agent.get("purpose"),
|
|
2018
|
-
agent.get("provider"),
|
|
2019
|
-
agent.get("trigger_type", "manual"),
|
|
2020
|
-
agent.get("mcp_servers", []),
|
|
2021
|
-
agent.get("skills", []),
|
|
2022
|
-
json.dumps(agent.get("metadata", {})),
|
|
2023
|
-
))
|
|
2024
|
-
synced_agents += 1
|
|
2025
|
-
|
|
2026
|
-
conn.commit()
|
|
2027
|
-
|
|
2028
|
-
conn.close()
|
|
2029
|
-
|
|
2030
|
-
if DEBUG_MODE:
|
|
2031
|
-
print(f"[SYNC] Synced {synced_squads} squads, {synced_agents} agents")
|
|
2032
|
-
|
|
2033
|
-
return jsonify({
|
|
2034
|
-
"synced_squads": synced_squads,
|
|
2035
|
-
"synced_agents": synced_agents
|
|
2036
|
-
}), 200
|
|
2037
|
-
|
|
2038
|
-
except Exception as e:
|
|
2039
|
-
import traceback
|
|
2040
|
-
traceback.print_exc()
|
|
2041
|
-
return jsonify({"error": str(e)}), 500
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
@app.route("/api/sync/learnings", methods=["POST"])
|
|
2045
|
-
def sync_learnings():
|
|
2046
|
-
"""Sync learnings from CLI to Postgres conversations table.
|
|
2047
|
-
|
|
2048
|
-
Body:
|
|
2049
|
-
learnings: list of {squad, agent, content, category, importance, source_file}
|
|
2050
|
-
|
|
2051
|
-
Returns:
|
|
2052
|
-
{imported: int, skipped: int}
|
|
2053
|
-
"""
|
|
2054
|
-
try:
|
|
2055
|
-
data = request.get_json() or {}
|
|
2056
|
-
learnings = data.get("learnings", [])
|
|
2057
|
-
|
|
2058
|
-
if not learnings:
|
|
2059
|
-
return jsonify({"imported": 0, "skipped": 0}), 200
|
|
2060
|
-
|
|
2061
|
-
conn = get_db()
|
|
2062
|
-
imported = 0
|
|
2063
|
-
skipped = 0
|
|
2064
|
-
|
|
2065
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
2066
|
-
for learning in learnings:
|
|
2067
|
-
# Check for duplicate (same content hash)
|
|
2068
|
-
content = learning.get("content", "")
|
|
2069
|
-
content_hash = hashlib.md5(content.encode()).hexdigest()
|
|
2070
|
-
|
|
2071
|
-
cur.execute("""
|
|
2072
|
-
SELECT id FROM squads.conversations
|
|
2073
|
-
WHERE message_type = 'learning'
|
|
2074
|
-
AND metadata->>'content_hash' = %s
|
|
2075
|
-
""", (content_hash,))
|
|
2076
|
-
|
|
2077
|
-
if cur.fetchone():
|
|
2078
|
-
skipped += 1
|
|
2079
|
-
continue
|
|
2080
|
-
|
|
2081
|
-
# Insert into conversations table as learning
|
|
2082
|
-
cur.execute("""
|
|
2083
|
-
INSERT INTO squads.conversations (
|
|
2084
|
-
role, content, message_type, importance, squad, agent, metadata
|
|
2085
|
-
) VALUES (
|
|
2086
|
-
'system', %s, 'learning', %s, %s, %s, %s
|
|
2087
|
-
)
|
|
2088
|
-
""", (
|
|
2089
|
-
content,
|
|
2090
|
-
learning.get("importance", "normal"),
|
|
2091
|
-
learning.get("squad"),
|
|
2092
|
-
learning.get("agent"),
|
|
2093
|
-
json.dumps({
|
|
2094
|
-
"category": learning.get("category", "insight"),
|
|
2095
|
-
"source_file": learning.get("source_file"),
|
|
2096
|
-
"content_hash": content_hash,
|
|
2097
|
-
"imported_at": datetime.now().isoformat(),
|
|
2098
|
-
}),
|
|
2099
|
-
))
|
|
2100
|
-
imported += 1
|
|
2101
|
-
|
|
2102
|
-
conn.commit()
|
|
2103
|
-
|
|
2104
|
-
conn.close()
|
|
2105
|
-
|
|
2106
|
-
if DEBUG_MODE:
|
|
2107
|
-
print(f"[SYNC] Imported {imported} learnings, skipped {skipped} duplicates")
|
|
2108
|
-
|
|
2109
|
-
return jsonify({"imported": imported, "skipped": skipped}), 200
|
|
2110
|
-
|
|
2111
|
-
except Exception as e:
|
|
2112
|
-
import traceback
|
|
2113
|
-
traceback.print_exc()
|
|
2114
|
-
return jsonify({"error": str(e)}), 500
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
# =============================================================================
|
|
2118
|
-
# Autonomy Score API - Calculate and return autonomy metrics
|
|
2119
|
-
# =============================================================================
|
|
2120
|
-
|
|
2121
|
-
@app.route("/api/autonomy/score", methods=["GET"])
|
|
2122
|
-
def get_autonomy_score():
|
|
2123
|
-
"""Calculate current autonomy score.
|
|
2124
|
-
|
|
2125
|
-
Query params:
|
|
2126
|
-
squad: Filter by squad (optional)
|
|
2127
|
-
period: Time period - today, week, month (default: today)
|
|
2128
|
-
|
|
2129
|
-
Returns:
|
|
2130
|
-
{
|
|
2131
|
-
"overall_score": int (0-100),
|
|
2132
|
-
"confidence_level": str (low/medium/high),
|
|
2133
|
-
"components": {
|
|
2134
|
-
"budget_compliance": int,
|
|
2135
|
-
"cooldown_compliance": int,
|
|
2136
|
-
"quality_score": int,
|
|
2137
|
-
"success_rate": int,
|
|
2138
|
-
"learning_utilization": int
|
|
2139
|
-
},
|
|
2140
|
-
"execution_stats": {...}
|
|
2141
|
-
}
|
|
2142
|
-
"""
|
|
2143
|
-
try:
|
|
2144
|
-
squad = request.args.get("squad")
|
|
2145
|
-
period = request.args.get("period", "today")
|
|
2146
|
-
|
|
2147
|
-
# Determine date range (tasks uses started_at, others use created_at)
|
|
2148
|
-
if period == "today":
|
|
2149
|
-
tasks_date_filter = "started_at >= CURRENT_DATE"
|
|
2150
|
-
date_filter = "created_at >= CURRENT_DATE"
|
|
2151
|
-
elif period == "week":
|
|
2152
|
-
tasks_date_filter = "started_at >= CURRENT_DATE - INTERVAL '7 days'"
|
|
2153
|
-
date_filter = "created_at >= CURRENT_DATE - INTERVAL '7 days'"
|
|
2154
|
-
elif period == "month":
|
|
2155
|
-
tasks_date_filter = "started_at >= CURRENT_DATE - INTERVAL '30 days'"
|
|
2156
|
-
date_filter = "created_at >= CURRENT_DATE - INTERVAL '30 days'"
|
|
2157
|
-
else:
|
|
2158
|
-
tasks_date_filter = "started_at >= CURRENT_DATE"
|
|
2159
|
-
date_filter = "created_at >= CURRENT_DATE"
|
|
2160
|
-
|
|
2161
|
-
conn = get_db()
|
|
2162
|
-
components = {}
|
|
2163
|
-
|
|
2164
|
-
with conn.cursor(cursor_factory=RealDictCursor) as cur:
|
|
2165
|
-
# Squad filter
|
|
2166
|
-
squad_filter = f"AND squad = '{squad}'" if squad else ""
|
|
2167
|
-
|
|
2168
|
-
# 1. Usage KPI (monthly spend tracking - informational, not a gate)
|
|
2169
|
-
cur.execute("""
|
|
2170
|
-
SELECT COALESCE(SUM(cost_usd), 0) as cost_usd
|
|
2171
|
-
FROM squads.llm_generations
|
|
2172
|
-
WHERE created_at >= DATE_TRUNC('month', CURRENT_DATE)
|
|
2173
|
-
""")
|
|
2174
|
-
monthly_result = cur.fetchone()
|
|
2175
|
-
monthly_used = float(monthly_result["cost_usd"]) if monthly_result else 0
|
|
2176
|
-
# KPI: Track usage but don't penalize - real limits come from Anthropic subscription
|
|
2177
|
-
# Score represents "how actively we're using our capacity" (higher = more active)
|
|
2178
|
-
components["usage_kpi"] = min(100, int(monthly_used / 10)) # $1000/mo = 100% utilization
|
|
2179
|
-
|
|
2180
|
-
# 2. Success rate from tasks
|
|
2181
|
-
cur.execute(f"""
|
|
2182
|
-
SELECT
|
|
2183
|
-
COUNT(*) as total,
|
|
2184
|
-
COUNT(*) FILTER (WHERE status = 'completed' AND success = true) as successful
|
|
2185
|
-
FROM squads.tasks
|
|
2186
|
-
WHERE {tasks_date_filter} {squad_filter}
|
|
2187
|
-
""")
|
|
2188
|
-
task_stats = cur.fetchone()
|
|
2189
|
-
total_tasks = task_stats["total"] or 0
|
|
2190
|
-
successful_tasks = task_stats["successful"] or 0
|
|
2191
|
-
components["success_rate"] = int(100 * successful_tasks / total_tasks) if total_tasks > 0 else 100
|
|
2192
|
-
|
|
2193
|
-
# 3. Quality score from feedback
|
|
2194
|
-
cur.execute(f"""
|
|
2195
|
-
SELECT AVG(quality_score) as avg_score, COUNT(*) as count
|
|
2196
|
-
FROM squads.task_feedback
|
|
2197
|
-
WHERE {date_filter}
|
|
2198
|
-
""")
|
|
2199
|
-
feedback_stats = cur.fetchone()
|
|
2200
|
-
avg_quality = feedback_stats["avg_score"] or 3.5 # Default to neutral
|
|
2201
|
-
components["quality_score"] = int(avg_quality * 20) # Scale 1-5 to 0-100
|
|
2202
|
-
|
|
2203
|
-
# 4. Cooldown compliance (check for rapid re-executions)
|
|
2204
|
-
cur.execute(f"""
|
|
2205
|
-
WITH exec_gaps AS (
|
|
2206
|
-
SELECT
|
|
2207
|
-
squad, agent,
|
|
2208
|
-
started_at,
|
|
2209
|
-
LAG(started_at) OVER (PARTITION BY squad, agent ORDER BY started_at) as prev_start
|
|
2210
|
-
FROM squads.tasks
|
|
2211
|
-
WHERE {tasks_date_filter} {squad_filter}
|
|
2212
|
-
)
|
|
2213
|
-
SELECT
|
|
2214
|
-
COUNT(*) as total,
|
|
2215
|
-
COUNT(*) FILTER (WHERE EXTRACT(EPOCH FROM (started_at - prev_start)) >= 300 OR prev_start IS NULL) as compliant
|
|
2216
|
-
FROM exec_gaps
|
|
2217
|
-
""")
|
|
2218
|
-
cooldown_stats = cur.fetchone()
|
|
2219
|
-
total_execs = cooldown_stats["total"] or 0
|
|
2220
|
-
compliant_execs = cooldown_stats["compliant"] or 0
|
|
2221
|
-
components["cooldown_compliance"] = int(100 * compliant_execs / total_execs) if total_execs > 0 else 100
|
|
2222
|
-
|
|
2223
|
-
# 5. Learning utilization (check if learnings exist in conversations)
|
|
2224
|
-
cur.execute("""
|
|
2225
|
-
SELECT COUNT(*) as learning_count
|
|
2226
|
-
FROM squads.conversations
|
|
2227
|
-
WHERE message_type = 'learning'
|
|
2228
|
-
AND created_at >= CURRENT_DATE - INTERVAL '30 days'
|
|
2229
|
-
""")
|
|
2230
|
-
learning_stats = cur.fetchone()
|
|
2231
|
-
learning_count = learning_stats["learning_count"] or 0
|
|
2232
|
-
# Score based on having learnings available (simple heuristic)
|
|
2233
|
-
components["learning_utilization"] = min(100, learning_count * 10) if learning_count > 0 else 0
|
|
2234
|
-
|
|
2235
|
-
conn.close()
|
|
2236
|
-
|
|
2237
|
-
# Calculate weighted overall score
|
|
2238
|
-
# Note: usage_kpi excluded from score calculation (it's informational only)
|
|
2239
|
-
weights = {
|
|
2240
|
-
"success_rate": 0.35,
|
|
2241
|
-
"quality_score": 0.30,
|
|
2242
|
-
"cooldown_compliance": 0.20,
|
|
2243
|
-
"learning_utilization": 0.15
|
|
2244
|
-
}
|
|
2245
|
-
|
|
2246
|
-
overall_score = int(sum(
|
|
2247
|
-
components.get(k, 0) * v for k, v in weights.items()
|
|
2248
|
-
))
|
|
2249
|
-
|
|
2250
|
-
# Determine confidence level
|
|
2251
|
-
if overall_score >= 75:
|
|
2252
|
-
confidence_level = "high"
|
|
2253
|
-
elif overall_score >= 50:
|
|
2254
|
-
confidence_level = "medium"
|
|
2255
|
-
else:
|
|
2256
|
-
confidence_level = "low"
|
|
2257
|
-
|
|
2258
|
-
return jsonify({
|
|
2259
|
-
"overall_score": overall_score,
|
|
2260
|
-
"confidence_level": confidence_level,
|
|
2261
|
-
"period": period,
|
|
2262
|
-
"squad": squad,
|
|
2263
|
-
"components": components,
|
|
2264
|
-
"execution_stats": {
|
|
2265
|
-
"total_tasks": total_tasks,
|
|
2266
|
-
"successful_tasks": successful_tasks,
|
|
2267
|
-
"monthly_spend_usd": round(monthly_used, 2),
|
|
2268
|
-
"learning_count": learning_count
|
|
2269
|
-
},
|
|
2270
|
-
"usage_kpi": {
|
|
2271
|
-
"monthly_spend": round(monthly_used, 2),
|
|
2272
|
-
"note": "Track via /usage for real Anthropic limits"
|
|
2273
|
-
}
|
|
2274
|
-
}), 200
|
|
2275
|
-
|
|
2276
|
-
except Exception as e:
|
|
2277
|
-
import traceback
|
|
2278
|
-
traceback.print_exc()
|
|
2279
|
-
return jsonify({
|
|
2280
|
-
"overall_score": 50,
|
|
2281
|
-
"confidence_level": "unknown",
|
|
2282
|
-
"error": str(e)
|
|
2283
|
-
}), 200
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
if __name__ == "__main__":
|
|
2287
|
-
port = int(os.environ.get("PORT", 8080))
|
|
2288
|
-
print(f"Starting Squads Bridge on port {port}")
|
|
2289
|
-
print(f" PostgreSQL: {DATABASE_URL.split('@')[1] if '@' in DATABASE_URL else DATABASE_URL}")
|
|
2290
|
-
print(f" Redis: {'connected' if redis_client else 'disabled'}")
|
|
2291
|
-
print(f" Langfuse: {'enabled' if LANGFUSE_ENABLED else 'disabled'}")
|
|
2292
|
-
print(f" Engram: {'enabled -> ' + ENGRAM_URL if ENGRAM_ENABLED else 'disabled'}")
|
|
2293
|
-
print(f" Usage KPI: tracking (real limits via /usage)")
|
|
2294
|
-
|
|
2295
|
-
# Start background conversation processor
|
|
2296
|
-
if redis_client:
|
|
2297
|
-
start_conversation_worker()
|
|
2298
|
-
|
|
2299
|
-
app.run(host="0.0.0.0", port=port)
|