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.
@@ -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
- return {
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
- def scan_once() -> Dict[str, Any]:
75
- """Execute a single social scan cycle and process results (LED-238)."""
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
- # 1. DISCOVER: Scan all platforms
80
- targets = scan_targets(platforms=["x", "reddit", "github", "hn", "devto"])
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
- _daemon_state.record_success(found)
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
- "processed": processed
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
- logger.info("Scan complete: %d new targets", result.get("targets_found", 0))
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
- return _daemon_state.to_dict()
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
- # supabase_sync Pro module (stubbed in npm package)
2
- # Full implementation available on delimit.ai server
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}")