delimit-cli 4.1.7 → 4.1.9

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.
@@ -1,189 +0,0 @@
1
- """
2
- Social sensing daemon for Delimit.
3
-
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.
6
- Also monitors for direct replies to owned posts (LED-300).
7
-
8
- Consensus 123: Part of the continuous sensing loop.
9
- """
10
-
11
- import json
12
- import logging
13
- import os
14
- import threading
15
- import time
16
- from datetime import datetime, timezone
17
- from pathlib import Path
18
- from typing import Any, Dict, List, Optional
19
-
20
- logger = logging.getLogger("delimit.ai.social_daemon")
21
-
22
- # ── Configuration ────────────────────────────────────────────────────
23
- # Default to 15 minutes (900 seconds)
24
- SCAN_INTERVAL = int(os.environ.get("DELIMIT_SOCIAL_SCAN_INTERVAL", "900"))
25
- MAX_CONSECUTIVE_FAILURES = 3
26
-
27
- ALERTS_DIR = Path.home() / ".delimit" / "alerts"
28
- ALERT_FILE = ALERTS_DIR / "social_daemon.json"
29
- DAEMON_STATE = Path.home() / ".delimit" / "social_daemon_state.json"
30
- OWNER_ACTION_SUMMARY = Path.home() / ".delimit" / "owner_action_summary.json"
31
-
32
- class SocialDaemonState:
33
- """Thread-safe state for the social sensing daemon."""
34
-
35
- def __init__(self):
36
- self.running = False
37
- self.last_scan: Optional[str] = None
38
- self.targets_found: int = 0
39
- self.consecutive_failures: int = 0
40
- self.total_scans: int = 0
41
- self.stopped_reason: Optional[str] = None
42
- self._lock = threading.Lock()
43
- self._thread: Optional[threading.Thread] = None
44
- self._stop_event = threading.Event()
45
-
46
- def to_dict(self) -> Dict[str, Any]:
47
- with self._lock:
48
- return {
49
- "running": self.running,
50
- "last_scan": self.last_scan,
51
- "targets_found": self.targets_found,
52
- "consecutive_failures": self.consecutive_failures,
53
- "total_scans": self.total_scans,
54
- "stopped_reason": self.stopped_reason,
55
- "scan_interval_seconds": SCAN_INTERVAL,
56
- }
57
-
58
- def record_success(self, found: int):
59
- with self._lock:
60
- self.consecutive_failures = 0
61
- self.targets_found += found
62
- self.total_scans += 1
63
- self.last_scan = datetime.now(timezone.utc).isoformat()
64
-
65
- def record_failure(self) -> int:
66
- with self._lock:
67
- self.consecutive_failures += 1
68
- self.total_scans += 1
69
- self.last_scan = datetime.now(timezone.utc).isoformat()
70
- return self.consecutive_failures
71
-
72
- _daemon_state = SocialDaemonState()
73
-
74
- def scan_once() -> Dict[str, Any]:
75
- """Execute a single social scan cycle and process results (LED-238)."""
76
- from ai.social_target import scan_targets, process_targets
77
-
78
- try:
79
- # 1. DISCOVER: Scan all platforms
80
- targets = scan_targets(platforms=["x", "reddit", "github", "hn", "devto"])
81
- found = len(targets)
82
-
83
- # 2. ORCHESTRATE: Process discovered targets (LED-238)
84
- # draft_replies=True -> emits social_draft emails
85
- # create_ledger=True -> creates strategic ledger items
86
- processed = process_targets(targets, draft_replies=True, create_ledger=True)
87
- OWNER_ACTION_SUMMARY.parent.mkdir(parents=True, exist_ok=True)
88
- OWNER_ACTION_SUMMARY.write_text(json.dumps({
89
- "timestamp": datetime.now(timezone.utc).isoformat(),
90
- "targets_found": found,
91
- "owner_actions": len(processed.get("owner_actions", [])),
92
- "drafted": len(processed.get("drafted", [])),
93
- "ledger_items": len(processed.get("ledger_items", [])),
94
- "strategy_items": len(processed.get("strategy_items", [])),
95
- }, indent=2) + "\n")
96
-
97
- _daemon_state.record_success(found)
98
- return {
99
- "targets_found": found,
100
- "processed": processed
101
- }
102
- except Exception as e:
103
- failures = _daemon_state.record_failure()
104
- logger.error("Social scan failed: %s", e)
105
- if failures >= MAX_CONSECUTIVE_FAILURES:
106
- reason = f"3 consecutive social scan failures. Last: {e}"
107
- _daemon_state.stopped_reason = reason
108
- _daemon_state.running = False
109
- _daemon_state._stop_event.set()
110
- return {"error": str(e), "consecutive_failures": failures}
111
-
112
- def _daemon_loop() -> None:
113
- """Main scanning loop."""
114
- logger.info("Social daemon started. Scanning every %d seconds.", SCAN_INTERVAL)
115
-
116
- while not _daemon_state._stop_event.is_set():
117
- try:
118
- result = scan_once()
119
- if "error" in result:
120
- logger.warning("Scan cycle error: %s", result["error"])
121
- else:
122
- logger.info("Scan complete: %d new targets", result.get("targets_found", 0))
123
- except Exception as e:
124
- logger.error("Unexpected error in social daemon loop: %s", e)
125
- failures = _daemon_state.record_failure()
126
- if failures >= MAX_CONSECUTIVE_FAILURES:
127
- break
128
-
129
- _daemon_state._stop_event.wait(timeout=SCAN_INTERVAL)
130
-
131
- _daemon_state.running = False
132
- logger.info("Social daemon stopped.")
133
-
134
- def start_daemon() -> Dict[str, Any]:
135
- """Start the social daemon in a background thread."""
136
- if _daemon_state.running:
137
- return {"status": "already_running", **_daemon_state.to_dict()}
138
-
139
- _daemon_state.running = True
140
- _daemon_state.stopped_reason = None
141
- _daemon_state.consecutive_failures = 0
142
- _daemon_state._stop_event.clear()
143
-
144
- thread = threading.Thread(target=_daemon_loop, name="social-daemon", daemon=True)
145
- _daemon_state._thread = thread
146
- thread.start()
147
-
148
- return {"status": "started", **_daemon_state.to_dict()}
149
-
150
- def stop_daemon() -> Dict[str, Any]:
151
- """Stop the social daemon."""
152
- if not _daemon_state.running:
153
- return {"status": "not_running", **_daemon_state.to_dict()}
154
-
155
- _daemon_state._stop_event.set()
156
- _daemon_state.stopped_reason = "manual_stop"
157
- if _daemon_state._thread:
158
- _daemon_state._thread.join(timeout=5)
159
- _daemon_state.running = False
160
-
161
- return {"status": "stopped", **_daemon_state.to_dict()}
162
-
163
- def get_daemon_status() -> Dict[str, Any]:
164
- """Get current daemon status."""
165
- return _daemon_state.to_dict()
166
-
167
- def main():
168
- """Run as standalone process."""
169
- import argparse
170
- parser = argparse.ArgumentParser(description="Delimit social sensing daemon")
171
- parser.add_argument("--interval", type=int, help="Scan interval in seconds")
172
- parser.add_argument("--once", action="store_true", help="Run once and exit")
173
- args = parser.parse_args()
174
-
175
- logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s")
176
-
177
- if args.interval:
178
- global SCAN_INTERVAL
179
- SCAN_INTERVAL = args.interval
180
-
181
- if args.once:
182
- scan_once()
183
- return
184
-
185
- _daemon_state.running = True
186
- _daemon_loop()
187
-
188
- if __name__ == "__main__":
189
- main()