delimit-cli 4.5.13 → 4.6.0

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +9 -8
  3. package/bin/delimit-cli.js +162 -1
  4. package/bin/delimit-setup.js +46 -6
  5. package/gateway/ai/_compile_status.py +154 -0
  6. package/gateway/ai/agent_dispatch.py +36 -0
  7. package/gateway/ai/backends/tools_infra.py +150 -10
  8. package/gateway/ai/daemon.py +10 -0
  9. package/gateway/ai/daily_digest.py +1 -2
  10. package/gateway/ai/delimit_daemon.py +67 -0
  11. package/gateway/ai/dispatch_gate.py +399 -0
  12. package/gateway/ai/hot_reload.py +1 -2
  13. package/gateway/ai/led193_daemon/executor.py +9 -0
  14. package/gateway/ai/ledger_manager.py +9 -0
  15. package/gateway/ai/license_core.cpython-310-x86_64-linux-gnu.so +0 -0
  16. package/gateway/ai/notify.py +39 -0
  17. package/gateway/ai/outreach_substantive.py +676 -0
  18. package/gateway/ai/reaper.py +70 -0
  19. package/gateway/ai/reddit_scanner.py +10 -5
  20. package/gateway/ai/sensing/schema.py +1 -1
  21. package/gateway/ai/sensing/signal_store.py +0 -1
  22. package/gateway/ai/server.py +5171 -1462
  23. package/gateway/ai/social_capability/fit_floor.py +114 -12
  24. package/gateway/ai/tdqs_lint.py +611 -0
  25. package/gateway/ai/usage_allowlist.py +198 -0
  26. package/gateway/ai/workers/base.py +2 -2
  27. package/gateway/ai/workers/executor.py +32 -3
  28. package/gateway/ai/workers/outreach_drafter.py +0 -1
  29. package/gateway/ai/workers/pr_drafter.py +0 -1
  30. package/gateway/ai/x_ranker.py +12 -2
  31. package/gateway/core/json_schema_diff.py +25 -1
  32. package/lib/auth-signin.js +136 -0
  33. package/lib/auth-signout.js +169 -0
  34. package/lib/delimit-template.js +11 -0
  35. package/lib/migration-2092-banner.js +213 -0
  36. package/package.json +2 -2
  37. package/server.json +4 -4
@@ -45,10 +45,19 @@ from typing import Any, Dict, Iterable, Optional, Set, Tuple
45
45
 
46
46
  logger = logging.getLogger(__name__)
47
47
 
48
- # Default cooldown window for topic coverage. Founder directive (2026-05-05):
49
- # "if we've already drafted on a similar topic in the last 7 days, abstain on
50
- # the next one to avoid spam-pattern detection."
51
- DEFAULT_COOLDOWN_DAYS = 7
48
+ # Default cooldown window for topic coverage.
49
+ # Original founder directive (2026-05-05): 7 days to avoid spam-pattern detection.
50
+ # Revised 2026-05-12 (LED-1356, panel-unanimous): 7 days × cross-platform global
51
+ # scope was producing 57% abstention rate (115/200 recent abstentions =
52
+ # topic_cooldown) and effectively blocked every Delimit-relevant topic for a
53
+ # full week after one draft. Reduced to 48h AND scoped per-platform/per-subreddit
54
+ # so a Reddit/r/mcp draft no longer cools an HN draft on MCP, and a Reddit/r/mcp
55
+ # draft no longer cools a Reddit/r/programming draft on MCP. Different audiences,
56
+ # different scans.
57
+ DEFAULT_COOLDOWN_HOURS = 48
58
+ # Backwards-compat alias for any callers that still pass `cooldown_days=` —
59
+ # converted to hours internally. Don't add new callers using this name.
60
+ DEFAULT_COOLDOWN_DAYS = DEFAULT_COOLDOWN_HOURS / 24
52
61
 
53
62
  # Default high-engagement opportunity-cost threshold. Threads above this score
54
63
  # pass the fit floor even without keyword match, but with ``human_only=True``
@@ -179,18 +188,63 @@ def _parse_iso(value: Optional[str]) -> Optional[datetime]:
179
188
  return dt
180
189
 
181
190
 
191
+ def _log_entry_scope_key(entry: dict) -> str:
192
+ """LED-1356 platform-scoped cooldown key. Reddit cooldowns are per-subreddit
193
+ (different subreddits = different audiences); HN/devto/X are global within
194
+ the platform. Cross-platform doesn't cool.
195
+
196
+ Returns:
197
+ - "reddit:r/<name>" for Reddit entries with a subreddit field
198
+ - "reddit" for Reddit entries without (legacy fallback)
199
+ - The platform name itself for hn / devto / x / twitter
200
+ - "unknown" when the entry doesn't carry a platform
201
+ """
202
+ platform = (entry.get("platform") or "").lower()
203
+ if not platform:
204
+ return "unknown"
205
+ if platform == "reddit":
206
+ sub = (entry.get("subreddit") or "").lower()
207
+ if sub:
208
+ sub = sub.lstrip("r/")
209
+ return f"reddit:r/{sub}"
210
+ return "reddit"
211
+ # twitter and x are aliases for the same audience
212
+ if platform == "twitter":
213
+ return "x"
214
+ return platform
215
+
216
+
182
217
  def _recent_topic_fingerprints(
183
- cooldown_days: int = DEFAULT_COOLDOWN_DAYS,
218
+ cooldown_hours: float = DEFAULT_COOLDOWN_HOURS,
184
219
  log_path: Optional[Path] = None,
185
- ) -> Set[str]:
186
- """Return the union of topic fingerprints found in ``social_log.jsonl``
187
- within the cooldown window. Tolerant of malformed lines.
220
+ cooldown_days: Optional[float] = None,
221
+ ) -> Dict[str, Set[str]]:
222
+ """Return platform-scoped topic fingerprints from ``social_log.jsonl``
223
+ within the cooldown window.
224
+
225
+ LED-1356 changes (2026-05-12, panel-unanimous):
226
+ - Window default 7d → 48h.
227
+ - Return shape Set[str] → Dict[scope_key, Set[str]] so callers can ask
228
+ "what topics has THIS audience seen recently" instead of the prior
229
+ "global union of every topic across every platform."
230
+ - `cooldown_days` is preserved as backwards-compat kwarg; converted to
231
+ hours internally.
232
+
233
+ Callers should look up the relevant set via the dict and pass it to
234
+ ``evaluate_fit(..., recent_topics=...)``. A target's scope is computed
235
+ via :func:`_log_entry_scope_key` on the target dict.
236
+
237
+ Returns dict mapping scope_key → set of canonical signal fingerprints
238
+ drafted on for that scope within the window.
188
239
  """
189
240
  p = log_path or _social_log_path()
190
241
  if not p.exists():
191
- return set()
192
- cutoff = datetime.now(timezone.utc) - timedelta(days=cooldown_days)
193
- seen: Set[str] = set()
242
+ return {}
243
+ # Backwards compatibility: if cooldown_days passed, convert to hours.
244
+ if cooldown_days is not None:
245
+ cooldown_hours = float(cooldown_days) * 24.0
246
+ cutoff = datetime.now(timezone.utc) - timedelta(hours=cooldown_hours)
247
+ seen: Dict[str, Set[str]] = {}
194
248
  try:
195
249
  with open(p, "r", encoding="utf-8") as fh:
196
250
  for line in fh:
@@ -204,6 +258,7 @@ def _recent_topic_fingerprints(
204
258
  ts = _parse_iso(entry.get("ts"))
205
259
  if ts is None or ts < cutoff:
206
260
  continue
261
+ scope_key = _log_entry_scope_key(entry)
207
262
  # Use the post body + thread title (when present) as the
208
263
  # topic surface. Reddit entries log the thread title
209
264
  # separately; X entries don't have one but the post body
@@ -214,12 +269,55 @@ def _recent_topic_fingerprints(
214
269
  entry.get("thread_title") or "",
215
270
  ]
216
271
  )
217
- seen.update(_extract_topic_fingerprint(blob))
272
+ fps = _extract_topic_fingerprint(blob)
273
+ if not fps:
274
+ continue
275
+ seen.setdefault(scope_key, set()).update(fps)
218
276
  except OSError as exc:
219
277
  logger.warning("fit_floor: failed to read %s: %s", p, exc)
220
278
  return seen
221
279
 
222
280
 
281
+ def topics_for_scope(
282
+ recent_topics_dict: Dict[str, Set[str]],
283
+ scope_key: str,
284
+ ) -> Set[str]:
285
+ """Convenience helper: look up the topic set for a given scope.
286
+
287
+ Returns empty set if the scope has no recent activity in the window.
288
+ Callers should pass the result as ``evaluate_fit(..., recent_topics=...)``.
289
+ """
290
+ return recent_topics_dict.get(scope_key, set())
291
+
292
+
293
+ def target_scope_key(target: dict) -> str:
294
+ """Compute the scope key for a target dict (for cooldown lookup).
295
+
296
+ Mirror of :func:`_log_entry_scope_key` for the inbound target side.
297
+ Targets may carry `platform` plus (for Reddit) `subreddit` extracted
298
+ from the thread URL.
299
+ """
300
+ platform = (target.get("platform") or "").lower()
301
+ if not platform:
302
+ return "unknown"
303
+ if platform == "reddit":
304
+ sub = (target.get("subreddit") or "").lower()
305
+ if not sub:
306
+ # Try to extract from canonical_url / thread_url
307
+ url = target.get("canonical_url") or target.get("thread_url") or ""
308
+ import re as _re
309
+ m = _re.search(r"/r/([A-Za-z0-9_]+)", url)
310
+ if m:
311
+ sub = m.group(1).lower()
312
+ if sub:
313
+ sub = sub.lstrip("r/")
314
+ return f"reddit:r/{sub}"
315
+ return "reddit"
316
+ if platform == "twitter":
317
+ return "x"
318
+ return platform
319
+
320
+
223
321
  # ── Fit-floor decision ──────────────────────────────────────────────
224
322
 
225
323
 
@@ -348,6 +446,7 @@ def append_jsonl(path: Path, payload: Dict[str, Any]) -> None:
348
446
 
349
447
  __all__ = [
350
448
  "DEFAULT_COOLDOWN_DAYS",
449
+ "DEFAULT_COOLDOWN_HOURS",
351
450
  "DEFAULT_HIGH_ENGAGEMENT_FLOOR",
352
451
  "SOCIAL_LOG",
353
452
  "DELIMIT_DOMAIN_SIGNALS",
@@ -357,4 +456,7 @@ __all__ = [
357
456
  "append_jsonl",
358
457
  "_extract_topic_fingerprint",
359
458
  "_recent_topic_fingerprints",
459
+ "_log_entry_scope_key",
460
+ "target_scope_key",
461
+ "topics_for_scope",
360
462
  ]