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.
Files changed (127) hide show
  1. package/README.md +161 -4
  2. package/dist/{chunk-HKWCBCEK.js → chunk-4CMAEQQY.js} +6 -2
  3. package/dist/chunk-4CMAEQQY.js.map +1 -0
  4. package/dist/{chunk-NA3IECJA.js → chunk-N7KDWU4W.js} +155 -58
  5. package/dist/chunk-N7KDWU4W.js.map +1 -0
  6. package/dist/{chunk-7PRYDHZW.js → chunk-NHGLXN2F.js} +8 -6
  7. package/dist/chunk-NHGLXN2F.js.map +1 -0
  8. package/dist/{chunk-QPH5OR7J.js → chunk-O7UV3FWI.js} +139 -21
  9. package/dist/chunk-O7UV3FWI.js.map +1 -0
  10. package/dist/{chunk-BV6S5AWZ.js → chunk-ZTQ7ISUR.js} +28 -109
  11. package/dist/chunk-ZTQ7ISUR.js.map +1 -0
  12. package/dist/cli.js +5493 -7665
  13. package/dist/cli.js.map +1 -1
  14. package/dist/index.d.ts +110 -2
  15. package/dist/index.js +302 -26
  16. package/dist/index.js.map +1 -1
  17. package/dist/{memory-ZXDXF6KF.js → memory-VNF2VFRB.js} +2 -2
  18. package/dist/{sessions-F6LRY7EN.js → sessions-6PB7ALCE.js} +3 -3
  19. package/dist/{squad-parser-MSYE4PXL.js → squad-parser-4BI3G4RS.js} +4 -2
  20. package/dist/templates/seed/BUSINESS_BRIEF.md.template +27 -0
  21. package/dist/templates/seed/CLAUDE.md.template +69 -0
  22. package/dist/templates/seed/config/provider.yaml +4 -0
  23. package/dist/templates/seed/hooks/settings.json.template +31 -0
  24. package/dist/templates/seed/memory/company/manager/state.md +16 -0
  25. package/dist/templates/seed/memory/engineering/issue-solver/state.md +12 -0
  26. package/dist/templates/seed/memory/intelligence/intel-lead/state.md +9 -0
  27. package/dist/templates/seed/memory/marketing/content-drafter/state.md +12 -0
  28. package/dist/templates/seed/memory/operations/ops-lead/state.md +12 -0
  29. package/dist/templates/seed/memory/research/researcher/state.md +10 -0
  30. package/dist/templates/seed/skills/gh/SKILL.md +57 -0
  31. package/dist/templates/seed/skills/squads-cli/SKILL.md +88 -0
  32. package/dist/templates/seed/squads/company/SQUAD.md +49 -0
  33. package/dist/templates/seed/squads/company/company-critic.md +21 -0
  34. package/dist/templates/seed/squads/company/company-eval.md +21 -0
  35. package/dist/templates/seed/squads/company/event-dispatcher.md +21 -0
  36. package/dist/templates/seed/squads/company/goal-tracker.md +21 -0
  37. package/dist/templates/seed/squads/company/manager.md +66 -0
  38. package/dist/templates/seed/squads/engineering/SQUAD.md +48 -0
  39. package/dist/templates/seed/squads/engineering/code-reviewer.md +57 -0
  40. package/dist/templates/seed/squads/engineering/issue-solver.md +58 -0
  41. package/dist/templates/seed/squads/engineering/test-writer.md +50 -0
  42. package/dist/templates/seed/squads/intelligence/SQUAD.md +37 -0
  43. package/dist/templates/seed/squads/intelligence/intel-critic.md +36 -0
  44. package/dist/templates/seed/squads/intelligence/intel-eval.md +31 -0
  45. package/dist/templates/seed/squads/intelligence/intel-lead.md +71 -0
  46. package/dist/templates/seed/squads/marketing/SQUAD.md +47 -0
  47. package/dist/templates/seed/squads/marketing/content-drafter.md +71 -0
  48. package/dist/templates/seed/squads/marketing/growth-analyst.md +49 -0
  49. package/dist/templates/seed/squads/marketing/social-poster.md +44 -0
  50. package/dist/templates/seed/squads/operations/SQUAD.md +45 -0
  51. package/dist/templates/seed/squads/operations/finance-tracker.md +47 -0
  52. package/dist/templates/seed/squads/operations/goal-tracker.md +48 -0
  53. package/dist/templates/seed/squads/operations/ops-lead.md +58 -0
  54. package/dist/templates/seed/squads/research/SQUAD.md +38 -0
  55. package/dist/templates/seed/squads/research/analyst.md +27 -0
  56. package/dist/templates/seed/squads/research/research-critic.md +20 -0
  57. package/dist/templates/seed/squads/research/research-eval.md +20 -0
  58. package/dist/templates/seed/squads/research/researcher.md +28 -0
  59. package/dist/{terminal-JZSAQSN7.js → terminal-YKA4O5CX.js} +4 -2
  60. package/dist/{update-MAY6EXFQ.js → update-ALJKFFM7.js} +3 -2
  61. package/package.json +8 -21
  62. package/templates/seed/BUSINESS_BRIEF.md.template +27 -0
  63. package/templates/seed/CLAUDE.md.template +69 -0
  64. package/templates/seed/config/provider.yaml +4 -0
  65. package/templates/seed/hooks/settings.json.template +31 -0
  66. package/templates/seed/memory/company/manager/state.md +16 -0
  67. package/templates/seed/memory/engineering/issue-solver/state.md +12 -0
  68. package/templates/seed/memory/intelligence/intel-lead/state.md +9 -0
  69. package/templates/seed/memory/marketing/content-drafter/state.md +12 -0
  70. package/templates/seed/memory/operations/ops-lead/state.md +12 -0
  71. package/templates/seed/memory/research/researcher/state.md +10 -0
  72. package/templates/seed/skills/gh/SKILL.md +57 -0
  73. package/templates/seed/skills/squads-cli/SKILL.md +88 -0
  74. package/templates/seed/squads/company/SQUAD.md +49 -0
  75. package/templates/seed/squads/company/company-critic.md +21 -0
  76. package/templates/seed/squads/company/company-eval.md +21 -0
  77. package/templates/seed/squads/company/event-dispatcher.md +21 -0
  78. package/templates/seed/squads/company/goal-tracker.md +21 -0
  79. package/templates/seed/squads/company/manager.md +66 -0
  80. package/templates/seed/squads/engineering/SQUAD.md +48 -0
  81. package/templates/seed/squads/engineering/code-reviewer.md +57 -0
  82. package/templates/seed/squads/engineering/issue-solver.md +58 -0
  83. package/templates/seed/squads/engineering/test-writer.md +50 -0
  84. package/templates/seed/squads/intelligence/SQUAD.md +37 -0
  85. package/templates/seed/squads/intelligence/intel-critic.md +36 -0
  86. package/templates/seed/squads/intelligence/intel-eval.md +31 -0
  87. package/templates/seed/squads/intelligence/intel-lead.md +71 -0
  88. package/templates/seed/squads/marketing/SQUAD.md +47 -0
  89. package/templates/seed/squads/marketing/content-drafter.md +71 -0
  90. package/templates/seed/squads/marketing/growth-analyst.md +49 -0
  91. package/templates/seed/squads/marketing/social-poster.md +44 -0
  92. package/templates/seed/squads/operations/SQUAD.md +45 -0
  93. package/templates/seed/squads/operations/finance-tracker.md +47 -0
  94. package/templates/seed/squads/operations/goal-tracker.md +48 -0
  95. package/templates/seed/squads/operations/ops-lead.md +58 -0
  96. package/templates/seed/squads/research/SQUAD.md +38 -0
  97. package/templates/seed/squads/research/analyst.md +27 -0
  98. package/templates/seed/squads/research/research-critic.md +20 -0
  99. package/templates/seed/squads/research/research-eval.md +20 -0
  100. package/templates/seed/squads/research/researcher.md +28 -0
  101. package/dist/chunk-7PRYDHZW.js.map +0 -1
  102. package/dist/chunk-BV6S5AWZ.js.map +0 -1
  103. package/dist/chunk-HKWCBCEK.js.map +0 -1
  104. package/dist/chunk-NA3IECJA.js.map +0 -1
  105. package/dist/chunk-QPH5OR7J.js.map +0 -1
  106. package/docker/.env.example +0 -17
  107. package/docker/README.md +0 -92
  108. package/docker/docker-compose.engram.yml +0 -304
  109. package/docker/docker-compose.yml +0 -250
  110. package/docker/init-db.sql +0 -478
  111. package/docker/init-engram-db.sql +0 -148
  112. package/docker/init-langfuse-db.sh +0 -10
  113. package/docker/otel-collector.yaml +0 -34
  114. package/docker/squads-bridge/Dockerfile +0 -14
  115. package/docker/squads-bridge/Dockerfile.proxy +0 -14
  116. package/docker/squads-bridge/anthropic_proxy.py +0 -313
  117. package/docker/squads-bridge/requirements.txt +0 -7
  118. package/docker/squads-bridge/squads_bridge.py +0 -2299
  119. package/docker/telemetry-ping/Dockerfile +0 -10
  120. package/docker/telemetry-ping/deploy.sh +0 -69
  121. package/docker/telemetry-ping/main.py +0 -136
  122. package/docker/telemetry-ping/requirements.txt +0 -3
  123. /package/dist/{memory-ZXDXF6KF.js.map → memory-VNF2VFRB.js.map} +0 -0
  124. /package/dist/{sessions-F6LRY7EN.js.map → sessions-6PB7ALCE.js.map} +0 -0
  125. /package/dist/{squad-parser-MSYE4PXL.js.map → squad-parser-4BI3G4RS.js.map} +0 -0
  126. /package/dist/{terminal-JZSAQSN7.js.map → terminal-YKA4O5CX.js.map} +0 -0
  127. /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)