delimit-cli 4.1.42 → 4.1.43

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.
@@ -24,6 +24,10 @@ logger = logging.getLogger("delimit.ai.social_daemon")
24
24
  SCAN_INTERVAL = int(os.environ.get("DELIMIT_SOCIAL_SCAN_INTERVAL", "900"))
25
25
  MAX_CONSECUTIVE_FAILURES = 3
26
26
 
27
+ # Retry config: exponential backoff (5s, 15s, 45s)
28
+ RETRY_DELAYS = [5, 15, 45]
29
+ MAX_RETRIES = len(RETRY_DELAYS)
30
+
27
31
  ALERTS_DIR = Path.home() / ".delimit" / "alerts"
28
32
  ALERT_FILE = ALERTS_DIR / "social_daemon.json"
29
33
  DAEMON_STATE = Path.home() / ".delimit" / "social_daemon_state.json"
@@ -42,10 +46,12 @@ class SocialDaemonState:
42
46
  self._lock = threading.Lock()
43
47
  self._thread: Optional[threading.Thread] = None
44
48
  self._stop_event = threading.Event()
49
+ # Scan stats for compact output
50
+ self.last_scan_stats: Optional[Dict[str, Any]] = None
45
51
 
46
52
  def to_dict(self) -> Dict[str, Any]:
47
53
  with self._lock:
48
- return {
54
+ result = {
49
55
  "running": self.running,
50
56
  "last_scan": self.last_scan,
51
57
  "targets_found": self.targets_found,
@@ -54,13 +60,18 @@ class SocialDaemonState:
54
60
  "stopped_reason": self.stopped_reason,
55
61
  "scan_interval_seconds": SCAN_INTERVAL,
56
62
  }
63
+ if self.last_scan_stats:
64
+ result["last_scan_stats"] = self.last_scan_stats
65
+ return result
57
66
 
58
- def record_success(self, found: int):
67
+ def record_success(self, found: int, stats: Optional[Dict[str, Any]] = None):
59
68
  with self._lock:
60
69
  self.consecutive_failures = 0
61
70
  self.targets_found += found
62
71
  self.total_scans += 1
63
72
  self.last_scan = datetime.now(timezone.utc).isoformat()
73
+ if stats:
74
+ self.last_scan_stats = stats
64
75
 
65
76
  def record_failure(self) -> int:
66
77
  with self._lock:
@@ -71,43 +82,283 @@ class SocialDaemonState:
71
82
 
72
83
  _daemon_state = SocialDaemonState()
73
84
 
74
- def scan_once() -> Dict[str, Any]:
75
- """Execute a single social scan cycle and process results (LED-238)."""
85
+
86
+ def _scan_with_retry() -> Dict[str, Any]:
87
+ """Execute scan_targets with exponential backoff retry on failure.
88
+
89
+ Retries up to MAX_RETRIES times with delays of 5s, 15s, 45s.
90
+ """
76
91
  from ai.social_target import scan_targets, process_targets
77
-
92
+
93
+ last_error = None
94
+ for attempt in range(MAX_RETRIES + 1):
95
+ try:
96
+ targets = []
97
+ # Use broad reddit_scanner (scans 25+ subreddits with relevance scoring)
98
+ try:
99
+ from ai.reddit_scanner import scan_all
100
+ reddit_result = scan_all(sort="hot", limit_per_sub=10)
101
+ reddit_targets = reddit_result.get("targets", [])
102
+ # Sort by engagement (score + comments), take top 5 for drafts
103
+ MAX_REDDIT_DRAFTS = 5
104
+ eligible = [rt for rt in reddit_targets if rt.get("priority") in ("high", "medium")]
105
+ eligible.sort(key=lambda t: (t.get("score", 0) or 0) + (t.get("num_comments", 0) or 0) * 2, reverse=True)
106
+ top_ids = set(id(rt) for rt in eligible[:MAX_REDDIT_DRAFTS])
107
+ for rt in reddit_targets:
108
+ if id(rt) in top_ids:
109
+ rt.setdefault("classification", "reply")
110
+ else:
111
+ rt.setdefault("classification", "skip")
112
+ rt.setdefault("platform", "reddit")
113
+ rt.setdefault("venture", "delimit")
114
+ rt.setdefault("fingerprint", f"reddit:{rt.get('id', '')}")
115
+ targets.extend(reddit_targets)
116
+ logger.info("Reddit broad scan: %d targets", len(reddit_targets))
117
+ except Exception as reddit_err:
118
+ logger.warning("Reddit broad scan failed: %s", reddit_err)
119
+
120
+ # Also run venture-based scan for non-Reddit platforms
121
+ try:
122
+ other_targets = scan_targets(platforms=["x", "github", "hn", "devto"])
123
+ targets.extend(other_targets)
124
+ except Exception as other_err:
125
+ logger.warning("Venture scan failed: %s", other_err)
126
+
127
+ return {"targets": targets, "attempt": attempt + 1}
128
+ except Exception as e:
129
+ last_error = e
130
+ if attempt < MAX_RETRIES:
131
+ delay = RETRY_DELAYS[attempt]
132
+ logger.warning(
133
+ "Scan attempt %d/%d failed: %s. Retrying in %ds...",
134
+ attempt + 1, MAX_RETRIES + 1, e, delay,
135
+ )
136
+ # Use stop_event.wait so we can be interrupted during retry sleep
137
+ if _daemon_state._stop_event.wait(timeout=delay):
138
+ # Daemon was stopped during retry
139
+ raise
140
+ else:
141
+ logger.error(
142
+ "All %d scan attempts failed. Last error: %s",
143
+ MAX_RETRIES + 1, e,
144
+ )
145
+ raise
146
+
147
+
148
+ def _build_compact_summary(targets: List[Dict], processed: Dict) -> Dict[str, Any]:
149
+ """Build a compact scan summary instead of returning all 322 posts.
150
+
151
+ Returns summary counts + only new high-priority posts.
152
+ """
153
+ # Separate high-priority from regular targets
154
+ high_priority = [
155
+ t for t in targets
156
+ if not t.get("error")
157
+ and t.get("relevance_score", 0) > 0.8
158
+ ]
159
+ medium_priority = [
160
+ t for t in targets
161
+ if not t.get("error")
162
+ and 0.3 < t.get("relevance_score", 0) <= 0.8
163
+ ]
164
+ auto_ledger = [t for t in targets if t.get("auto_ledger")]
165
+
166
+ # Platform breakdown
167
+ platform_counts: Dict[str, int] = {}
168
+ for t in targets:
169
+ if not t.get("error"):
170
+ p = t.get("platform", "unknown")
171
+ platform_counts[p] = platform_counts.get(p, 0) + 1
172
+
173
+ # Get cache stats if available
174
+ cache_stats = {}
175
+ try:
176
+ from ai.social_cache import get_scan_stats
177
+ cache_stats = get_scan_stats()
178
+ except Exception:
179
+ pass
180
+
181
+ return {
182
+ "summary": {
183
+ "total_new_targets": len([t for t in targets if not t.get("error")]),
184
+ "high_priority": len(high_priority),
185
+ "medium_priority": len(medium_priority),
186
+ "auto_ledger_flagged": len(auto_ledger),
187
+ "platform_breakdown": platform_counts,
188
+ "drafted": len(processed.get("drafted", [])),
189
+ "ledger_items": len(processed.get("ledger_items", [])),
190
+ "owner_actions": len(processed.get("owner_actions", [])),
191
+ },
192
+ "high_priority_targets": [
193
+ {
194
+ "fingerprint": t.get("fingerprint"),
195
+ "subreddit": t.get("subreddit"),
196
+ "post_title": t.get("post_title"),
197
+ "relevance_score": t.get("relevance_score"),
198
+ "canonical_url": t.get("canonical_url"),
199
+ "venture": t.get("venture"),
200
+ "auto_ledger": t.get("auto_ledger", False),
201
+ }
202
+ for t in high_priority[:10] # Cap at 10 for compact output
203
+ ],
204
+ "cache_stats": cache_stats,
205
+ }
206
+
207
+
208
+ def _send_scan_digest(compact: Dict, processed: Dict) -> None:
209
+ """Send a digest email summarizing the scan results.
210
+
211
+ Only sends if there are new high-priority targets, new drafts, or new ledger items.
212
+ Suppresses digest if nothing actionable to avoid email fatigue.
213
+ """
78
214
  try:
79
- # 1. DISCOVER: Scan all platforms
80
- targets = scan_targets(platforms=["x", "reddit", "github", "hn", "devto"])
215
+ from ai.notify import send_email
216
+
217
+ s = compact.get("summary", {})
218
+ high = s.get("high_priority", 0)
219
+ drafted = s.get("drafted", 0)
220
+ ledger_items = s.get("ledger_items", 0)
221
+ total = s.get("total_new_targets", 0)
222
+ platforms = s.get("platform_breakdown", {})
223
+ owner_actions = len(processed.get("owner_actions", []))
224
+
225
+ # Only send if there's something actionable
226
+ if high == 0 and drafted == 0 and ledger_items == 0 and owner_actions == 0:
227
+ return
228
+
229
+ lines = []
230
+ lines.append(f"Social scan found {total} new targets across {platforms}.")
231
+ lines.append("")
232
+
233
+ if high > 0:
234
+ lines.append(f"HIGH PRIORITY: {high} targets need attention")
235
+ for t in compact.get("high_priority_targets", [])[:5]:
236
+ sub = t.get("subreddit", t.get("platform", ""))
237
+ title = t.get("title", t.get("text", ""))[:80]
238
+ url = t.get("url", t.get("canonical_url", ""))
239
+ lines.append(f" [{sub}] {title}")
240
+ if url:
241
+ lines.append(f" {url}")
242
+ lines.append("")
243
+
244
+ if drafted > 0:
245
+ lines.append(f"DRAFTS: {drafted} reply drafts created")
246
+ lines.append("")
247
+ # Include actual draft text for ready drafts
248
+ for action in processed.get("owner_actions", []):
249
+ draft_id = action.get("draft_id", "")
250
+ if not draft_id:
251
+ continue
252
+ try:
253
+ from ai.social import list_drafts
254
+ all_drafts = list_drafts(status="pending")
255
+ for d in all_drafts:
256
+ if d.get("draft_id") == draft_id and d.get("quality") == "ready":
257
+ link = action.get("link", "")
258
+ platform = action.get("platform", "X")
259
+ lines.append(f"--- {platform} DRAFT ---")
260
+ if link:
261
+ lines.append(f"REPLY TO: {link}")
262
+ lines.append(f"WHY: {action.get('summary', '')[:100]}")
263
+ lines.append("")
264
+ lines.append("COPY THIS:")
265
+ lines.append(d.get("text", ""))
266
+ lines.append("--- END ---")
267
+ lines.append("")
268
+ break
269
+ except Exception:
270
+ pass
271
+
272
+ if ledger_items > 0:
273
+ lines.append(f"LEDGER: {ledger_items} items added to project ledger")
274
+ lines.append("")
275
+
276
+ if owner_actions > 0:
277
+ lines.append(f"ACTIONS: {owner_actions} items need your review")
278
+ lines.append("")
279
+
280
+ cache = compact.get("cache_stats", {})
281
+ lines.append(f"Cache: {cache.get('total_cached', 0)} posts tracked, "
282
+ f"{cache.get('high_relevance', 0)} high relevance")
283
+
284
+ send_email(
285
+ message="\n".join(lines),
286
+ subject=f"[SOCIAL] {high} high-pri, {drafted} drafts, {total} targets",
287
+ event_type="social_digest",
288
+ )
289
+ except Exception as e:
290
+ logger.warning("Failed to send scan digest email: %s", e)
291
+
292
+
293
+ _scan_lock = threading.Lock()
294
+
295
+ def scan_once() -> Dict[str, Any]:
296
+ """Execute a single social scan cycle and process results (LED-238).
297
+
298
+ Uses retry with exponential backoff and returns compact summary.
299
+ Thread-safe: only one scan runs at a time.
300
+ """
301
+ if not _scan_lock.acquire(blocking=False):
302
+ return {"error": "Scan already in progress", "skipped": True}
303
+ try:
304
+ from ai.social_target import process_targets
305
+
306
+ # 1. DISCOVER: Scan all platforms (with retry)
307
+ scan_result = _scan_with_retry()
308
+ targets = scan_result["targets"]
309
+ attempt = scan_result["attempt"]
81
310
  found = len(targets)
82
-
311
+
83
312
  # 2. ORCHESTRATE: Process discovered targets (LED-238)
84
- # draft_replies=True -> emits social_draft emails
85
- # create_ledger=True -> creates strategic ledger items
86
313
  processed = process_targets(targets, draft_replies=True, create_ledger=True)
314
+
315
+ # 3. Build compact summary
316
+ compact = _build_compact_summary(targets, processed)
317
+
318
+ # 4. Write owner action summary
87
319
  OWNER_ACTION_SUMMARY.parent.mkdir(parents=True, exist_ok=True)
88
320
  OWNER_ACTION_SUMMARY.write_text(json.dumps({
89
321
  "timestamp": datetime.now(timezone.utc).isoformat(),
90
322
  "targets_found": found,
323
+ "scan_attempt": attempt,
91
324
  "owner_actions": len(processed.get("owner_actions", [])),
92
325
  "drafted": len(processed.get("drafted", [])),
93
326
  "ledger_items": len(processed.get("ledger_items", [])),
94
327
  "strategy_items": len(processed.get("strategy_items", [])),
328
+ "compact_summary": compact["summary"],
95
329
  }, indent=2) + "\n")
96
-
97
- _daemon_state.record_success(found)
330
+
331
+ # 5. Log scan stats
332
+ s = compact["summary"]
333
+ logger.info(
334
+ "Scan complete: %d new targets (%d high-pri, %d med-pri, %d auto-ledger) "
335
+ "in %d attempt(s). Platforms: %s",
336
+ s["total_new_targets"], s["high_priority"], s["medium_priority"],
337
+ s["auto_ledger_flagged"], attempt, s["platform_breakdown"],
338
+ )
339
+
340
+ _daemon_state.record_success(found, stats=compact["summary"])
341
+
342
+ # 6. Send digest email if there are actionable items
343
+ _send_scan_digest(compact, processed)
344
+
345
+ # Return compact output (not all 322 targets)
98
346
  return {
99
347
  "targets_found": found,
100
- "processed": processed
348
+ "scan_attempt": attempt,
349
+ "compact_summary": compact,
101
350
  }
102
351
  except Exception as e:
103
352
  failures = _daemon_state.record_failure()
104
- logger.error("Social scan failed: %s", e)
353
+ logger.error("Social scan failed after retries: %s", e)
105
354
  if failures >= MAX_CONSECUTIVE_FAILURES:
106
355
  reason = f"3 consecutive social scan failures. Last: {e}"
107
356
  _daemon_state.stopped_reason = reason
108
357
  _daemon_state.running = False
109
358
  _daemon_state._stop_event.set()
110
359
  return {"error": str(e), "consecutive_failures": failures}
360
+ finally:
361
+ _scan_lock.release()
111
362
 
112
363
  def _daemon_loop() -> None:
113
364
  """Main scanning loop."""
@@ -119,7 +370,13 @@ def _daemon_loop() -> None:
119
370
  if "error" in result:
120
371
  logger.warning("Scan cycle error: %s", result["error"])
121
372
  else:
122
- logger.info("Scan complete: %d new targets", result.get("targets_found", 0))
373
+ summary = result.get("compact_summary", {}).get("summary", {})
374
+ logger.info(
375
+ "Scan cycle done: %d targets, %d high-pri, cache=%s",
376
+ result.get("targets_found", 0),
377
+ summary.get("high_priority", 0),
378
+ result.get("compact_summary", {}).get("cache_stats", {}).get("total_cached", "?"),
379
+ )
123
380
  except Exception as e:
124
381
  logger.error("Unexpected error in social daemon loop: %s", e)
125
382
  failures = _daemon_state.record_failure()
@@ -161,8 +418,14 @@ def stop_daemon() -> Dict[str, Any]:
161
418
  return {"status": "stopped", **_daemon_state.to_dict()}
162
419
 
163
420
  def get_daemon_status() -> Dict[str, Any]:
164
- """Get current daemon status."""
165
- return _daemon_state.to_dict()
421
+ """Get current daemon status including cache stats."""
422
+ status = _daemon_state.to_dict()
423
+ try:
424
+ from ai.social_cache import get_scan_stats
425
+ status["cache_stats"] = get_scan_stats()
426
+ except Exception:
427
+ pass
428
+ return status
166
429
 
167
430
  def main():
168
431
  """Run as standalone process."""
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "delimit-cli",
3
3
  "mcpName": "io.github.delimit-ai/delimit-mcp-server",
4
- "version": "4.1.42",
4
+ "version": "4.1.43",
5
5
  "description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
6
6
  "main": "index.js",
7
7
  "files": [