delimit-cli 4.1.15 → 4.1.17
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/README.md +258 -3
- package/bin/delimit-os.sh +105 -0
- package/bin/delimit-setup.js +53 -5
- package/gateway/ai/ledger_propose.py +240 -0
- package/gateway/ai/reddit_proxy.py +106 -0
- package/gateway/ai/siem_streaming.py +290 -0
- package/gateway/ai/social_daemon.py +189 -0
- package/gateway/core/spec_health.py +624 -0
- package/lib/cross-model-hooks.js +22 -14
- package/package.json +1 -1
|
@@ -0,0 +1,189 @@
|
|
|
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()
|