delimit-cli 4.1.42 → 4.1.44
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -0
- package/README.md +46 -5
- package/bin/delimit-cli.js +1523 -208
- package/bin/delimit-setup.js +8 -2
- package/gateway/ai/agent_dispatch.py +34 -2
- package/gateway/ai/backends/deploy_bridge.py +167 -12
- package/gateway/ai/content_engine.py +1276 -2
- package/gateway/ai/github_scanner.py +1 -1
- package/gateway/ai/governance.py +58 -0
- package/gateway/ai/key_resolver.py +95 -2
- package/gateway/ai/ledger_manager.py +13 -3
- package/gateway/ai/loop_engine.py +220 -349
- package/gateway/ai/notify.py +1786 -2
- package/gateway/ai/reddit_scanner.py +45 -1
- package/gateway/ai/screen_record.py +1 -1
- package/gateway/ai/secrets_broker.py +5 -1
- package/gateway/ai/social_cache.py +341 -0
- package/gateway/ai/social_daemon.py +312 -18
- package/gateway/ai/supabase_sync.py +190 -2
- package/gateway/ai/tui.py +594 -36
- package/gateway/core/zero_spec/express_extractor.py +2 -2
- package/gateway/core/zero_spec/nestjs_extractor.py +40 -9
- package/gateway/requirements.txt +3 -6
- package/package.json +4 -3
- package/scripts/demo-v420-clean.sh +267 -0
- package/scripts/demo-v420-deliberation.sh +217 -0
- package/scripts/demo-v420.sh +55 -0
- package/scripts/postinstall.js +4 -3
- package/scripts/publish-ci-guard.sh +30 -0
- package/scripts/record-and-upload.sh +132 -0
- package/scripts/release.sh +126 -0
- package/scripts/sync-gateway.sh +100 -0
- package/scripts/youtube-upload.py +141 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
Social sensing daemon for Delimit.
|
|
3
3
|
|
|
4
4
|
Runs social discovery scans (X, Reddit, GitHub, Dev.to) on a regular interval.
|
|
5
|
-
Deduplicates findings and emits HTML draft emails for human approval.
|
|
5
|
+
Deduplicates findings via SQLite cache and emits HTML draft emails for human approval.
|
|
6
6
|
Also monitors for direct replies to owned posts (LED-300).
|
|
7
7
|
|
|
8
8
|
Consensus 123: Part of the continuous sensing loop.
|
|
@@ -19,11 +19,22 @@ from typing import Any, Dict, List, Optional
|
|
|
19
19
|
|
|
20
20
|
logger = logging.getLogger("delimit.ai.social_daemon")
|
|
21
21
|
|
|
22
|
+
# ── Vertex AI credentials (prefer ADC from gcloud auth) ─────────────
|
|
23
|
+
_adc_path = str(Path.home() / ".config" / "gcloud" / "application_default_credentials.json")
|
|
24
|
+
if not os.environ.get("GOOGLE_APPLICATION_CREDENTIALS") and os.path.exists(_adc_path):
|
|
25
|
+
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = _adc_path
|
|
26
|
+
if not os.environ.get("GOOGLE_CLOUD_PROJECT"):
|
|
27
|
+
os.environ["GOOGLE_CLOUD_PROJECT"] = "jamsons"
|
|
28
|
+
|
|
22
29
|
# ── Configuration ────────────────────────────────────────────────────
|
|
23
30
|
# Default to 15 minutes (900 seconds)
|
|
24
31
|
SCAN_INTERVAL = int(os.environ.get("DELIMIT_SOCIAL_SCAN_INTERVAL", "900"))
|
|
25
32
|
MAX_CONSECUTIVE_FAILURES = 3
|
|
26
33
|
|
|
34
|
+
# Retry config: exponential backoff (5s, 15s, 45s)
|
|
35
|
+
RETRY_DELAYS = [5, 15, 45]
|
|
36
|
+
MAX_RETRIES = len(RETRY_DELAYS)
|
|
37
|
+
|
|
27
38
|
ALERTS_DIR = Path.home() / ".delimit" / "alerts"
|
|
28
39
|
ALERT_FILE = ALERTS_DIR / "social_daemon.json"
|
|
29
40
|
DAEMON_STATE = Path.home() / ".delimit" / "social_daemon_state.json"
|
|
@@ -42,10 +53,12 @@ class SocialDaemonState:
|
|
|
42
53
|
self._lock = threading.Lock()
|
|
43
54
|
self._thread: Optional[threading.Thread] = None
|
|
44
55
|
self._stop_event = threading.Event()
|
|
56
|
+
# Scan stats for compact output
|
|
57
|
+
self.last_scan_stats: Optional[Dict[str, Any]] = None
|
|
45
58
|
|
|
46
59
|
def to_dict(self) -> Dict[str, Any]:
|
|
47
60
|
with self._lock:
|
|
48
|
-
|
|
61
|
+
result = {
|
|
49
62
|
"running": self.running,
|
|
50
63
|
"last_scan": self.last_scan,
|
|
51
64
|
"targets_found": self.targets_found,
|
|
@@ -54,13 +67,18 @@ class SocialDaemonState:
|
|
|
54
67
|
"stopped_reason": self.stopped_reason,
|
|
55
68
|
"scan_interval_seconds": SCAN_INTERVAL,
|
|
56
69
|
}
|
|
70
|
+
if self.last_scan_stats:
|
|
71
|
+
result["last_scan_stats"] = self.last_scan_stats
|
|
72
|
+
return result
|
|
57
73
|
|
|
58
|
-
def record_success(self, found: int):
|
|
74
|
+
def record_success(self, found: int, stats: Optional[Dict[str, Any]] = None):
|
|
59
75
|
with self._lock:
|
|
60
76
|
self.consecutive_failures = 0
|
|
61
77
|
self.targets_found += found
|
|
62
78
|
self.total_scans += 1
|
|
63
79
|
self.last_scan = datetime.now(timezone.utc).isoformat()
|
|
80
|
+
if stats:
|
|
81
|
+
self.last_scan_stats = stats
|
|
64
82
|
|
|
65
83
|
def record_failure(self) -> int:
|
|
66
84
|
with self._lock:
|
|
@@ -71,43 +89,307 @@ class SocialDaemonState:
|
|
|
71
89
|
|
|
72
90
|
_daemon_state = SocialDaemonState()
|
|
73
91
|
|
|
74
|
-
|
|
75
|
-
|
|
92
|
+
|
|
93
|
+
def _scan_with_retry() -> Dict[str, Any]:
|
|
94
|
+
"""Execute scan_targets with exponential backoff retry on failure.
|
|
95
|
+
|
|
96
|
+
Retries up to MAX_RETRIES times with delays of 5s, 15s, 45s.
|
|
97
|
+
"""
|
|
76
98
|
from ai.social_target import scan_targets, process_targets
|
|
77
|
-
|
|
99
|
+
|
|
100
|
+
last_error = None
|
|
101
|
+
for attempt in range(MAX_RETRIES + 1):
|
|
102
|
+
try:
|
|
103
|
+
targets = []
|
|
104
|
+
# Use broad reddit_scanner (scans 25+ subreddits with relevance scoring)
|
|
105
|
+
try:
|
|
106
|
+
from ai.reddit_scanner import scan_all
|
|
107
|
+
reddit_result = scan_all(sort="hot", limit_per_sub=10)
|
|
108
|
+
reddit_targets = reddit_result.get("targets", [])
|
|
109
|
+
# Sort by engagement (score + comments), take top 5 for drafts
|
|
110
|
+
MAX_REDDIT_DRAFTS = 5
|
|
111
|
+
eligible = [rt for rt in reddit_targets if rt.get("priority") in ("high", "medium")]
|
|
112
|
+
eligible.sort(key=lambda t: (t.get("score", 0) or 0) + (t.get("num_comments", 0) or 0) * 2, reverse=True)
|
|
113
|
+
top_ids = set(id(rt) for rt in eligible[:MAX_REDDIT_DRAFTS])
|
|
114
|
+
for rt in reddit_targets:
|
|
115
|
+
if id(rt) in top_ids:
|
|
116
|
+
rt.setdefault("classification", "reply")
|
|
117
|
+
else:
|
|
118
|
+
rt.setdefault("classification", "skip")
|
|
119
|
+
rt.setdefault("platform", "reddit")
|
|
120
|
+
rt.setdefault("venture", "delimit")
|
|
121
|
+
rt.setdefault("fingerprint", f"reddit:{rt.get('id', '')}")
|
|
122
|
+
targets.extend(reddit_targets)
|
|
123
|
+
logger.info("Reddit broad scan: %d targets", len(reddit_targets))
|
|
124
|
+
except Exception as reddit_err:
|
|
125
|
+
logger.warning("Reddit broad scan failed: %s", reddit_err)
|
|
126
|
+
|
|
127
|
+
# Also run venture-based scan for non-Reddit platforms
|
|
128
|
+
try:
|
|
129
|
+
other_targets = scan_targets(platforms=["x", "github", "hn", "devto"])
|
|
130
|
+
targets.extend(other_targets)
|
|
131
|
+
except Exception as other_err:
|
|
132
|
+
logger.warning("Venture scan failed: %s", other_err)
|
|
133
|
+
|
|
134
|
+
return {"targets": targets, "attempt": attempt + 1}
|
|
135
|
+
except Exception as e:
|
|
136
|
+
last_error = e
|
|
137
|
+
if attempt < MAX_RETRIES:
|
|
138
|
+
delay = RETRY_DELAYS[attempt]
|
|
139
|
+
logger.warning(
|
|
140
|
+
"Scan attempt %d/%d failed: %s. Retrying in %ds...",
|
|
141
|
+
attempt + 1, MAX_RETRIES + 1, e, delay,
|
|
142
|
+
)
|
|
143
|
+
# Use stop_event.wait so we can be interrupted during retry sleep
|
|
144
|
+
if _daemon_state._stop_event.wait(timeout=delay):
|
|
145
|
+
# Daemon was stopped during retry
|
|
146
|
+
raise
|
|
147
|
+
else:
|
|
148
|
+
logger.error(
|
|
149
|
+
"All %d scan attempts failed. Last error: %s",
|
|
150
|
+
MAX_RETRIES + 1, e,
|
|
151
|
+
)
|
|
152
|
+
raise
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _build_compact_summary(targets: List[Dict], processed: Dict) -> Dict[str, Any]:
|
|
156
|
+
"""Build a compact scan summary instead of returning all 322 posts.
|
|
157
|
+
|
|
158
|
+
Returns summary counts + only new high-priority posts.
|
|
159
|
+
"""
|
|
160
|
+
# Separate high-priority from regular targets
|
|
161
|
+
high_priority = [
|
|
162
|
+
t for t in targets
|
|
163
|
+
if not t.get("error")
|
|
164
|
+
and t.get("relevance_score", 0) > 0.8
|
|
165
|
+
]
|
|
166
|
+
medium_priority = [
|
|
167
|
+
t for t in targets
|
|
168
|
+
if not t.get("error")
|
|
169
|
+
and 0.3 < t.get("relevance_score", 0) <= 0.8
|
|
170
|
+
]
|
|
171
|
+
auto_ledger = [t for t in targets if t.get("auto_ledger")]
|
|
172
|
+
|
|
173
|
+
# Platform breakdown
|
|
174
|
+
platform_counts: Dict[str, int] = {}
|
|
175
|
+
for t in targets:
|
|
176
|
+
if not t.get("error"):
|
|
177
|
+
p = t.get("platform", "unknown")
|
|
178
|
+
platform_counts[p] = platform_counts.get(p, 0) + 1
|
|
179
|
+
|
|
180
|
+
# Get cache stats if available
|
|
181
|
+
cache_stats = {}
|
|
182
|
+
try:
|
|
183
|
+
from ai.social_cache import get_scan_stats
|
|
184
|
+
cache_stats = get_scan_stats()
|
|
185
|
+
except Exception:
|
|
186
|
+
pass
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
"summary": {
|
|
190
|
+
"total_new_targets": len([t for t in targets if not t.get("error")]),
|
|
191
|
+
"high_priority": len(high_priority),
|
|
192
|
+
"medium_priority": len(medium_priority),
|
|
193
|
+
"auto_ledger_flagged": len(auto_ledger),
|
|
194
|
+
"platform_breakdown": platform_counts,
|
|
195
|
+
"drafted": len(processed.get("drafted", [])),
|
|
196
|
+
"ledger_items": len(processed.get("ledger_items", [])),
|
|
197
|
+
"owner_actions": len(processed.get("owner_actions", [])),
|
|
198
|
+
},
|
|
199
|
+
"high_priority_targets": [
|
|
200
|
+
{
|
|
201
|
+
"fingerprint": t.get("fingerprint"),
|
|
202
|
+
"subreddit": t.get("subreddit"),
|
|
203
|
+
"post_title": t.get("post_title"),
|
|
204
|
+
"relevance_score": t.get("relevance_score"),
|
|
205
|
+
"canonical_url": t.get("canonical_url"),
|
|
206
|
+
"venture": t.get("venture"),
|
|
207
|
+
"auto_ledger": t.get("auto_ledger", False),
|
|
208
|
+
}
|
|
209
|
+
for t in high_priority[:10] # Cap at 10 for compact output
|
|
210
|
+
],
|
|
211
|
+
"cache_stats": cache_stats,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
_scan_digest_count_today: int = 0
|
|
216
|
+
_scan_digest_last_date: str = ""
|
|
217
|
+
_SCAN_DIGEST_MAX_PER_DAY = 4 # Max scan digest emails per day
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _send_scan_digest(compact: Dict, processed: Dict) -> None:
|
|
221
|
+
"""Send a digest email summarizing the scan results.
|
|
222
|
+
|
|
223
|
+
Only sends if there are REAL actionable items (ready drafts, not placeholders).
|
|
224
|
+
Suppresses digest if nothing actionable to avoid email fatigue.
|
|
225
|
+
Capped at 4 per day to prevent inbox flooding.
|
|
226
|
+
"""
|
|
227
|
+
global _scan_digest_count_today, _scan_digest_last_date
|
|
78
228
|
try:
|
|
79
|
-
|
|
80
|
-
|
|
229
|
+
from ai.notify import send_email
|
|
230
|
+
|
|
231
|
+
s = compact.get("summary", {})
|
|
232
|
+
high = s.get("high_priority", 0)
|
|
233
|
+
ledger_items = s.get("ledger_items", 0)
|
|
234
|
+
total = s.get("total_new_targets", 0)
|
|
235
|
+
platforms = s.get("platform_breakdown", {})
|
|
236
|
+
|
|
237
|
+
# Count only REAL owner actions (not placeholder drafts)
|
|
238
|
+
owner_actions = [a for a in processed.get("owner_actions", []) if a.get("draft_id")]
|
|
239
|
+
real_owner_actions = len(owner_actions)
|
|
240
|
+
|
|
241
|
+
# Count ready drafts only (not placeholders that failed quality check)
|
|
242
|
+
real_drafted = len([d for d in processed.get("drafted", [])
|
|
243
|
+
if not d.get("suppressed_reason") and not d.get("deduped")])
|
|
244
|
+
|
|
245
|
+
# Only send if there's something genuinely actionable
|
|
246
|
+
if high == 0 and real_drafted == 0 and ledger_items == 0 and real_owner_actions == 0:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
# Daily cap — reset counter at midnight
|
|
250
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
251
|
+
if today != _scan_digest_last_date:
|
|
252
|
+
_scan_digest_count_today = 0
|
|
253
|
+
_scan_digest_last_date = today
|
|
254
|
+
if _scan_digest_count_today >= _SCAN_DIGEST_MAX_PER_DAY:
|
|
255
|
+
logger.info("Scan digest daily cap reached (%d/%d). Suppressing.",
|
|
256
|
+
_scan_digest_count_today, _SCAN_DIGEST_MAX_PER_DAY)
|
|
257
|
+
return
|
|
258
|
+
_scan_digest_count_today += 1
|
|
259
|
+
|
|
260
|
+
lines = []
|
|
261
|
+
lines.append(f"Social scan found {total} new targets across {platforms}.")
|
|
262
|
+
lines.append("")
|
|
263
|
+
|
|
264
|
+
if high > 0:
|
|
265
|
+
lines.append(f"HIGH PRIORITY: {high} targets need attention")
|
|
266
|
+
for t in compact.get("high_priority_targets", [])[:5]:
|
|
267
|
+
sub = t.get("subreddit", t.get("platform", ""))
|
|
268
|
+
title = t.get("title", t.get("text", ""))[:80]
|
|
269
|
+
url = t.get("url", t.get("canonical_url", ""))
|
|
270
|
+
lines.append(f" [{sub}] {title}")
|
|
271
|
+
if url:
|
|
272
|
+
lines.append(f" {url}")
|
|
273
|
+
lines.append("")
|
|
274
|
+
|
|
275
|
+
if real_drafted > 0:
|
|
276
|
+
lines.append(f"DRAFTS: {real_drafted} ready drafts (quality-checked)")
|
|
277
|
+
lines.append("")
|
|
278
|
+
# Include actual draft text for ready drafts
|
|
279
|
+
for action in processed.get("owner_actions", []):
|
|
280
|
+
draft_id = action.get("draft_id", "")
|
|
281
|
+
if not draft_id:
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
from ai.social import list_drafts
|
|
285
|
+
all_drafts = list_drafts(status="pending")
|
|
286
|
+
for d in all_drafts:
|
|
287
|
+
if d.get("draft_id") == draft_id and d.get("quality") == "ready":
|
|
288
|
+
link = action.get("link", "")
|
|
289
|
+
platform = action.get("platform", "X")
|
|
290
|
+
lines.append(f"--- {platform} DRAFT ---")
|
|
291
|
+
if link:
|
|
292
|
+
lines.append(f"REPLY TO: {link}")
|
|
293
|
+
lines.append(f"WHY: {action.get('summary', '')[:100]}")
|
|
294
|
+
lines.append("")
|
|
295
|
+
lines.append("COPY THIS:")
|
|
296
|
+
lines.append(d.get("text", ""))
|
|
297
|
+
lines.append("--- END ---")
|
|
298
|
+
lines.append("")
|
|
299
|
+
break
|
|
300
|
+
except Exception:
|
|
301
|
+
pass
|
|
302
|
+
|
|
303
|
+
if ledger_items > 0:
|
|
304
|
+
lines.append(f"LEDGER: {ledger_items} items added to project ledger")
|
|
305
|
+
lines.append("")
|
|
306
|
+
|
|
307
|
+
if real_owner_actions > 0:
|
|
308
|
+
lines.append(f"ACTIONS: {real_owner_actions} items need your review")
|
|
309
|
+
lines.append("")
|
|
310
|
+
|
|
311
|
+
cache = compact.get("cache_stats", {})
|
|
312
|
+
lines.append(f"Cache: {cache.get('total_cached', 0)} posts tracked, "
|
|
313
|
+
f"{cache.get('high_relevance', 0)} high relevance")
|
|
314
|
+
|
|
315
|
+
send_email(
|
|
316
|
+
message="\n".join(lines),
|
|
317
|
+
subject=f"[SOCIAL] {high} high-pri, {real_drafted} ready drafts, {real_owner_actions} actions",
|
|
318
|
+
event_type="social_digest",
|
|
319
|
+
)
|
|
320
|
+
except Exception as e:
|
|
321
|
+
logger.warning("Failed to send scan digest email: %s", e)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
_scan_lock = threading.Lock()
|
|
325
|
+
|
|
326
|
+
def scan_once() -> Dict[str, Any]:
|
|
327
|
+
"""Execute a single social scan cycle and process results (LED-238).
|
|
328
|
+
|
|
329
|
+
Uses retry with exponential backoff and returns compact summary.
|
|
330
|
+
Thread-safe: only one scan runs at a time.
|
|
331
|
+
"""
|
|
332
|
+
if not _scan_lock.acquire(blocking=False):
|
|
333
|
+
return {"error": "Scan already in progress", "skipped": True}
|
|
334
|
+
try:
|
|
335
|
+
from ai.social_target import process_targets
|
|
336
|
+
|
|
337
|
+
# 1. DISCOVER: Scan all platforms (with retry)
|
|
338
|
+
scan_result = _scan_with_retry()
|
|
339
|
+
targets = scan_result["targets"]
|
|
340
|
+
attempt = scan_result["attempt"]
|
|
81
341
|
found = len(targets)
|
|
82
|
-
|
|
342
|
+
|
|
83
343
|
# 2. ORCHESTRATE: Process discovered targets (LED-238)
|
|
84
|
-
# draft_replies=True -> emits social_draft emails
|
|
85
|
-
# create_ledger=True -> creates strategic ledger items
|
|
86
344
|
processed = process_targets(targets, draft_replies=True, create_ledger=True)
|
|
345
|
+
|
|
346
|
+
# 3. Build compact summary
|
|
347
|
+
compact = _build_compact_summary(targets, processed)
|
|
348
|
+
|
|
349
|
+
# 4. Write owner action summary
|
|
87
350
|
OWNER_ACTION_SUMMARY.parent.mkdir(parents=True, exist_ok=True)
|
|
88
351
|
OWNER_ACTION_SUMMARY.write_text(json.dumps({
|
|
89
352
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
90
353
|
"targets_found": found,
|
|
354
|
+
"scan_attempt": attempt,
|
|
91
355
|
"owner_actions": len(processed.get("owner_actions", [])),
|
|
92
356
|
"drafted": len(processed.get("drafted", [])),
|
|
93
357
|
"ledger_items": len(processed.get("ledger_items", [])),
|
|
94
358
|
"strategy_items": len(processed.get("strategy_items", [])),
|
|
359
|
+
"compact_summary": compact["summary"],
|
|
95
360
|
}, indent=2) + "\n")
|
|
96
|
-
|
|
97
|
-
|
|
361
|
+
|
|
362
|
+
# 5. Log scan stats
|
|
363
|
+
s = compact["summary"]
|
|
364
|
+
logger.info(
|
|
365
|
+
"Scan complete: %d new targets (%d high-pri, %d med-pri, %d auto-ledger) "
|
|
366
|
+
"in %d attempt(s). Platforms: %s",
|
|
367
|
+
s["total_new_targets"], s["high_priority"], s["medium_priority"],
|
|
368
|
+
s["auto_ledger_flagged"], attempt, s["platform_breakdown"],
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
_daemon_state.record_success(found, stats=compact["summary"])
|
|
372
|
+
|
|
373
|
+
# 6. Send digest email if there are actionable items
|
|
374
|
+
_send_scan_digest(compact, processed)
|
|
375
|
+
|
|
376
|
+
# Return compact output (not all 322 targets)
|
|
98
377
|
return {
|
|
99
378
|
"targets_found": found,
|
|
100
|
-
"
|
|
379
|
+
"scan_attempt": attempt,
|
|
380
|
+
"compact_summary": compact,
|
|
101
381
|
}
|
|
102
382
|
except Exception as e:
|
|
103
383
|
failures = _daemon_state.record_failure()
|
|
104
|
-
logger.error("Social scan failed: %s", e)
|
|
384
|
+
logger.error("Social scan failed after retries: %s", e)
|
|
105
385
|
if failures >= MAX_CONSECUTIVE_FAILURES:
|
|
106
386
|
reason = f"3 consecutive social scan failures. Last: {e}"
|
|
107
387
|
_daemon_state.stopped_reason = reason
|
|
108
388
|
_daemon_state.running = False
|
|
109
389
|
_daemon_state._stop_event.set()
|
|
110
390
|
return {"error": str(e), "consecutive_failures": failures}
|
|
391
|
+
finally:
|
|
392
|
+
_scan_lock.release()
|
|
111
393
|
|
|
112
394
|
def _daemon_loop() -> None:
|
|
113
395
|
"""Main scanning loop."""
|
|
@@ -119,7 +401,13 @@ def _daemon_loop() -> None:
|
|
|
119
401
|
if "error" in result:
|
|
120
402
|
logger.warning("Scan cycle error: %s", result["error"])
|
|
121
403
|
else:
|
|
122
|
-
|
|
404
|
+
summary = result.get("compact_summary", {}).get("summary", {})
|
|
405
|
+
logger.info(
|
|
406
|
+
"Scan cycle done: %d targets, %d high-pri, cache=%s",
|
|
407
|
+
result.get("targets_found", 0),
|
|
408
|
+
summary.get("high_priority", 0),
|
|
409
|
+
result.get("compact_summary", {}).get("cache_stats", {}).get("total_cached", "?"),
|
|
410
|
+
)
|
|
123
411
|
except Exception as e:
|
|
124
412
|
logger.error("Unexpected error in social daemon loop: %s", e)
|
|
125
413
|
failures = _daemon_state.record_failure()
|
|
@@ -161,8 +449,14 @@ def stop_daemon() -> Dict[str, Any]:
|
|
|
161
449
|
return {"status": "stopped", **_daemon_state.to_dict()}
|
|
162
450
|
|
|
163
451
|
def get_daemon_status() -> Dict[str, Any]:
|
|
164
|
-
"""Get current daemon status."""
|
|
165
|
-
|
|
452
|
+
"""Get current daemon status including cache stats."""
|
|
453
|
+
status = _daemon_state.to_dict()
|
|
454
|
+
try:
|
|
455
|
+
from ai.social_cache import get_scan_stats
|
|
456
|
+
status["cache_stats"] = get_scan_stats()
|
|
457
|
+
except Exception:
|
|
458
|
+
pass
|
|
459
|
+
return status
|
|
166
460
|
|
|
167
461
|
def main():
|
|
168
462
|
"""Run as standalone process."""
|
|
@@ -1,2 +1,190 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
"""Supabase sync -- writes gateway data to cloud for dashboard access.
|
|
2
|
+
|
|
3
|
+
Writes are fire-and-forget (never blocks tool execution).
|
|
4
|
+
If Supabase is unreachable, data stays in local files (always the source of truth).
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import logging
|
|
9
|
+
import uuid
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Dict, Optional
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("delimit.supabase_sync")
|
|
14
|
+
|
|
15
|
+
_client = None
|
|
16
|
+
_init_attempted = False
|
|
17
|
+
SUPABASE_URL = os.environ.get("SUPABASE_URL", "")
|
|
18
|
+
SUPABASE_KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY", "")
|
|
19
|
+
|
|
20
|
+
# Also check local secrets file
|
|
21
|
+
if not SUPABASE_URL:
|
|
22
|
+
secrets_file = Path.home() / ".delimit" / "secrets" / "supabase.json"
|
|
23
|
+
if secrets_file.exists():
|
|
24
|
+
try:
|
|
25
|
+
creds = json.loads(secrets_file.read_text())
|
|
26
|
+
SUPABASE_URL = creds.get("url", "")
|
|
27
|
+
SUPABASE_KEY = creds.get("service_role_key", "")
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _get_client():
|
|
33
|
+
"""Lazy-init Supabase client. Returns the SDK client, 'http' for fallback, or None."""
|
|
34
|
+
global _client, _init_attempted
|
|
35
|
+
if _client is not None:
|
|
36
|
+
return _client
|
|
37
|
+
if _init_attempted:
|
|
38
|
+
return _client # Already tried and failed, return cached result (may be None or "http")
|
|
39
|
+
_init_attempted = True
|
|
40
|
+
if not SUPABASE_URL or not SUPABASE_KEY:
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
from supabase import create_client
|
|
44
|
+
_client = create_client(SUPABASE_URL, SUPABASE_KEY)
|
|
45
|
+
return _client
|
|
46
|
+
except ImportError:
|
|
47
|
+
logger.debug("supabase-py not installed, using HTTP fallback")
|
|
48
|
+
_client = "http"
|
|
49
|
+
return _client
|
|
50
|
+
except Exception as e:
|
|
51
|
+
logger.warning(f"Supabase init failed: {e}")
|
|
52
|
+
_client = "http" # Fall back to HTTP rather than giving up entirely
|
|
53
|
+
return _client
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _http_post(table: str, data: dict, headers_extra: Optional[Dict] = None) -> bool:
|
|
57
|
+
"""POST to Supabase REST API without the SDK."""
|
|
58
|
+
import urllib.request
|
|
59
|
+
try:
|
|
60
|
+
url = f"{SUPABASE_URL}/rest/v1/{table}"
|
|
61
|
+
body = json.dumps(data).encode()
|
|
62
|
+
req = urllib.request.Request(url, data=body, method="POST")
|
|
63
|
+
req.add_header("Content-Type", "application/json")
|
|
64
|
+
req.add_header("apikey", SUPABASE_KEY)
|
|
65
|
+
req.add_header("Authorization", f"Bearer {SUPABASE_KEY}")
|
|
66
|
+
req.add_header("Prefer", "return=minimal")
|
|
67
|
+
if headers_extra:
|
|
68
|
+
for k, v in headers_extra.items():
|
|
69
|
+
req.add_header(k, v)
|
|
70
|
+
urllib.request.urlopen(req, timeout=5)
|
|
71
|
+
return True
|
|
72
|
+
except Exception as e:
|
|
73
|
+
logger.debug(f"Supabase HTTP POST to {table} failed: {e}")
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _http_patch(table: str, query: str, data: dict) -> bool:
|
|
78
|
+
"""PATCH to Supabase REST API without the SDK."""
|
|
79
|
+
import urllib.request
|
|
80
|
+
try:
|
|
81
|
+
url = f"{SUPABASE_URL}/rest/v1/{table}?{query}"
|
|
82
|
+
body = json.dumps(data).encode()
|
|
83
|
+
req = urllib.request.Request(url, data=body, method="PATCH")
|
|
84
|
+
req.add_header("Content-Type", "application/json")
|
|
85
|
+
req.add_header("apikey", SUPABASE_KEY)
|
|
86
|
+
req.add_header("Authorization", f"Bearer {SUPABASE_KEY}")
|
|
87
|
+
req.add_header("Prefer", "return=minimal")
|
|
88
|
+
urllib.request.urlopen(req, timeout=5)
|
|
89
|
+
return True
|
|
90
|
+
except Exception as e:
|
|
91
|
+
logger.debug(f"Supabase HTTP PATCH to {table} failed: {e}")
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def sync_event(event: dict):
|
|
96
|
+
"""Sync an event to Supabase (fire-and-forget).
|
|
97
|
+
|
|
98
|
+
Maps the gateway event dict to the Supabase events table schema:
|
|
99
|
+
id (uuid, required), type (text, required), tool (text, required),
|
|
100
|
+
ts, model, status, venture, detail, user_id, session_id
|
|
101
|
+
"""
|
|
102
|
+
try:
|
|
103
|
+
client = _get_client()
|
|
104
|
+
if client is None:
|
|
105
|
+
return
|
|
106
|
+
row = {
|
|
107
|
+
"id": str(uuid.uuid4()),
|
|
108
|
+
"type": event.get("type", "tool_call"),
|
|
109
|
+
"tool": event.get("tool", "unknown"),
|
|
110
|
+
"ts": event.get("ts", ""),
|
|
111
|
+
"model": event.get("model", ""),
|
|
112
|
+
"status": event.get("status", "ok"),
|
|
113
|
+
"venture": event.get("venture", ""),
|
|
114
|
+
"session_id": event.get("session_id", ""),
|
|
115
|
+
"user_id": event.get("user_id", ""),
|
|
116
|
+
}
|
|
117
|
+
# Include risk_level and trace info in detail field
|
|
118
|
+
detail_parts = []
|
|
119
|
+
if event.get("risk_level"):
|
|
120
|
+
detail_parts.append(f"risk={event['risk_level']}")
|
|
121
|
+
if event.get("trace_id"):
|
|
122
|
+
detail_parts.append(f"trace={event['trace_id']}")
|
|
123
|
+
if event.get("span_id"):
|
|
124
|
+
detail_parts.append(f"span={event['span_id']}")
|
|
125
|
+
if detail_parts:
|
|
126
|
+
row["detail"] = " ".join(detail_parts)
|
|
127
|
+
|
|
128
|
+
if client == "http":
|
|
129
|
+
_http_post("events", row)
|
|
130
|
+
else:
|
|
131
|
+
client.table("events").insert(row).execute()
|
|
132
|
+
except Exception as e:
|
|
133
|
+
logger.debug(f"Event sync failed: {e}")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def sync_ledger_item(item: dict):
|
|
137
|
+
"""Sync a ledger item to Supabase (upsert).
|
|
138
|
+
|
|
139
|
+
Maps the gateway ledger item to the Supabase ledger_items table schema:
|
|
140
|
+
id (text, required), title (text, required), priority, venture,
|
|
141
|
+
status, description, source, note, assignee
|
|
142
|
+
"""
|
|
143
|
+
try:
|
|
144
|
+
client = _get_client()
|
|
145
|
+
if client is None:
|
|
146
|
+
return
|
|
147
|
+
row = {
|
|
148
|
+
"id": item.get("id", ""),
|
|
149
|
+
"title": item.get("title", ""),
|
|
150
|
+
"priority": item.get("priority", "P1"),
|
|
151
|
+
"venture": item.get("venture", ""),
|
|
152
|
+
"status": item.get("status", "open"),
|
|
153
|
+
"description": item.get("description", ""),
|
|
154
|
+
"source": item.get("source", "mcp"),
|
|
155
|
+
}
|
|
156
|
+
if not row["id"] or not row["title"]:
|
|
157
|
+
return # Required fields missing
|
|
158
|
+
if client == "http":
|
|
159
|
+
_http_post(
|
|
160
|
+
"ledger_items",
|
|
161
|
+
row,
|
|
162
|
+
headers_extra={
|
|
163
|
+
"Prefer": "resolution=merge-duplicates,return=minimal",
|
|
164
|
+
},
|
|
165
|
+
)
|
|
166
|
+
else:
|
|
167
|
+
client.table("ledger_items").upsert(row).execute()
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.debug(f"Ledger item sync failed: {e}")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def sync_ledger_update(item_id: str, status: str, note: str = ""):
|
|
173
|
+
"""Sync a ledger status update to Supabase."""
|
|
174
|
+
try:
|
|
175
|
+
client = _get_client()
|
|
176
|
+
if client is None:
|
|
177
|
+
return
|
|
178
|
+
update = {"status": status}
|
|
179
|
+
if note:
|
|
180
|
+
update["note"] = note
|
|
181
|
+
if status == "done":
|
|
182
|
+
from datetime import datetime, timezone
|
|
183
|
+
update["completed_at"] = datetime.now(timezone.utc).isoformat()
|
|
184
|
+
|
|
185
|
+
if client == "http":
|
|
186
|
+
_http_patch("ledger_items", f"id=eq.{item_id}", update)
|
|
187
|
+
else:
|
|
188
|
+
client.table("ledger_items").update(update).eq("id", item_id).execute()
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.debug(f"Ledger update sync failed: {e}")
|