delimit-cli 4.1.53 → 4.3.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.
- package/CHANGELOG.md +26 -0
- package/README.md +34 -3
- package/bin/delimit-cli.js +150 -2
- package/bin/delimit-setup.js +22 -7
- package/gateway/ai/agent_dispatch.py +79 -0
- package/gateway/ai/daily_digest.py +386 -0
- package/gateway/ai/ledger_manager.py +32 -0
- package/gateway/ai/license_core.py +2 -0
- package/gateway/ai/notify.py +17 -11
- package/gateway/ai/reddit_proxy.py +28 -9
- package/gateway/ai/sensing/__init__.py +35 -0
- package/gateway/ai/sensing/schema.py +107 -0
- package/gateway/ai/sensing/signal_store.py +348 -0
- package/gateway/ai/server.py +419 -6
- package/gateway/ai/supabase_sync.py +308 -0
- package/gateway/ai/work_order.py +216 -0
- package/gateway/ai/workers/__init__.py +32 -0
- package/gateway/ai/workers/base.py +154 -0
- package/gateway/ai/workers/executor.py +861 -0
- package/gateway/ai/workers/outreach_drafter.py +161 -0
- package/gateway/ai/workers/pr_drafter.py +148 -0
- package/lib/ai-sbom-engine.js +154 -0
- package/lib/trust-page-engine.js +179 -0
- package/lib/wrap-engine.js +431 -0
- package/package.json +14 -1
- package/adapters/codex-security.js +0 -64
- package/adapters/codex-skill.js +0 -78
- package/adapters/cursor-rules.js +0 -73
- package/gateway/ai/continuity.py +0 -462
- package/gateway/ai/inbox_daemon_runner.py +0 -217
- package/gateway/ai/loop_engine.py +0 -1303
- package/gateway/ai/social_cache.py +0 -341
- package/gateway/ai/social_daemon.py +0 -483
- package/gateway/ai/tweet_corpus_schema.sql +0 -76
- package/scripts/crosspost_devto.py +0 -304
- package/scripts/demo-v420-clean.sh +0 -267
- package/scripts/demo-v420-deliberation.sh +0 -217
- package/scripts/demo-v420.sh +0 -55
- package/scripts/sync-gateway.sh +0 -112
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Signal store (LED-877).
|
|
2
|
+
|
|
3
|
+
Physically separated from the ledger. Daily shards, append-only. Reuses
|
|
4
|
+
~/.delimit/intel/ as the parent directory so intel_* tooling can already
|
|
5
|
+
query it via intel_dataset_list.
|
|
6
|
+
|
|
7
|
+
Consumers: delimit sense CLI, delimit_signals_query MCP tool (future).
|
|
8
|
+
NOT a consumer: build_loop, agent_dispatch, ledger_manager.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import time
|
|
16
|
+
import uuid
|
|
17
|
+
from collections import Counter, defaultdict
|
|
18
|
+
from datetime import datetime, timedelta, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
21
|
+
|
|
22
|
+
from ai.sensing.schema import (
|
|
23
|
+
Signal,
|
|
24
|
+
ValidationError,
|
|
25
|
+
fingerprint_of,
|
|
26
|
+
normalize_url,
|
|
27
|
+
validate_and_normalize,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class StorageError(RuntimeError):
|
|
32
|
+
"""Raised when signal persistence fails."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
INTEL_DIR = Path.home() / ".delimit" / "intel"
|
|
36
|
+
SIGNALS_DIR = INTEL_DIR / "signals"
|
|
37
|
+
ARCHIVE_DIR = SIGNALS_DIR / "archive"
|
|
38
|
+
DEDUP_INDEX_PATH = SIGNALS_DIR / "_dedup_index.json"
|
|
39
|
+
|
|
40
|
+
HOT_WINDOW_DAYS = 7
|
|
41
|
+
WARM_WINDOW_DAYS = 30
|
|
42
|
+
MAX_SIGNALS_PER_AUTHOR_PER_DAY = 3
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _now() -> datetime:
|
|
46
|
+
return datetime.now(timezone.utc)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _today_shard_path(when: Optional[datetime] = None) -> Path:
|
|
50
|
+
when = when or _now()
|
|
51
|
+
return SIGNALS_DIR / f"{when.date().isoformat()}.jsonl"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ensure_dirs() -> None:
|
|
55
|
+
SIGNALS_DIR.mkdir(parents=True, exist_ok=True)
|
|
56
|
+
ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_dedup_index() -> Dict[str, Dict[str, Any]]:
|
|
60
|
+
if not DEDUP_INDEX_PATH.exists():
|
|
61
|
+
return {}
|
|
62
|
+
try:
|
|
63
|
+
return json.loads(DEDUP_INDEX_PATH.read_text())
|
|
64
|
+
except (json.JSONDecodeError, OSError):
|
|
65
|
+
return {}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _save_dedup_index(index: Dict[str, Dict[str, Any]]) -> None:
|
|
69
|
+
_ensure_dirs()
|
|
70
|
+
tmp = DEDUP_INDEX_PATH.with_suffix(".tmp")
|
|
71
|
+
tmp.write_text(json.dumps(index))
|
|
72
|
+
tmp.replace(DEDUP_INDEX_PATH)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def dedup_check(fingerprint: str, window_days: int = WARM_WINDOW_DAYS) -> bool:
|
|
76
|
+
"""Return True if a signal with this fingerprint was ingested within window_days."""
|
|
77
|
+
if not fingerprint:
|
|
78
|
+
return False
|
|
79
|
+
index = _load_dedup_index()
|
|
80
|
+
entry = index.get(fingerprint)
|
|
81
|
+
if not entry:
|
|
82
|
+
return False
|
|
83
|
+
try:
|
|
84
|
+
ingested = datetime.fromisoformat(entry.get("ingested_at", ""))
|
|
85
|
+
except ValueError:
|
|
86
|
+
return False
|
|
87
|
+
cutoff = _now() - timedelta(days=window_days)
|
|
88
|
+
return ingested >= cutoff
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _author_count_today(author: str) -> int:
|
|
92
|
+
if not author:
|
|
93
|
+
return 0
|
|
94
|
+
path = _today_shard_path()
|
|
95
|
+
if not path.exists():
|
|
96
|
+
return 0
|
|
97
|
+
count = 0
|
|
98
|
+
needle = author.lower()
|
|
99
|
+
try:
|
|
100
|
+
for line in path.read_text().splitlines():
|
|
101
|
+
if not line.strip():
|
|
102
|
+
continue
|
|
103
|
+
try:
|
|
104
|
+
row = json.loads(line)
|
|
105
|
+
except json.JSONDecodeError:
|
|
106
|
+
continue
|
|
107
|
+
if (row.get("author") or "").lower() == needle:
|
|
108
|
+
count += 1
|
|
109
|
+
except OSError:
|
|
110
|
+
return 0
|
|
111
|
+
return count
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def ingest(raw: Dict[str, Any]) -> Dict[str, Any]:
|
|
115
|
+
"""Ingest a raw target dict as a validated Signal.
|
|
116
|
+
|
|
117
|
+
Raises ValidationError on schema violation (caller decides whether to
|
|
118
|
+
log-and-skip or propagate). Returns the stored signal with assigned id
|
|
119
|
+
and ingested_at, or a dedup/rate-limit notice.
|
|
120
|
+
"""
|
|
121
|
+
signal = validate_and_normalize(raw)
|
|
122
|
+
|
|
123
|
+
if dedup_check(signal.fingerprint):
|
|
124
|
+
index = _load_dedup_index()
|
|
125
|
+
entry = index.get(signal.fingerprint, {})
|
|
126
|
+
entry["hit_count"] = int(entry.get("hit_count", 1)) + 1
|
|
127
|
+
entry["last_seen_at"] = _now().isoformat()
|
|
128
|
+
index[signal.fingerprint] = entry
|
|
129
|
+
_save_dedup_index(index)
|
|
130
|
+
return {
|
|
131
|
+
"status": "deduped",
|
|
132
|
+
"fingerprint": signal.fingerprint,
|
|
133
|
+
"hit_count": entry["hit_count"],
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if _author_count_today(signal.author) >= MAX_SIGNALS_PER_AUTHOR_PER_DAY:
|
|
137
|
+
return {
|
|
138
|
+
"status": "rate_limited",
|
|
139
|
+
"author": signal.author,
|
|
140
|
+
"limit": MAX_SIGNALS_PER_AUTHOR_PER_DAY,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_ensure_dirs()
|
|
144
|
+
now = _now()
|
|
145
|
+
signal.ingested_at = now.isoformat()
|
|
146
|
+
signal.id = f"SIG-{uuid.uuid4().hex[:10].upper()}"
|
|
147
|
+
|
|
148
|
+
shard = _today_shard_path(now)
|
|
149
|
+
try:
|
|
150
|
+
with shard.open("a") as f:
|
|
151
|
+
f.write(json.dumps(signal.to_dict()) + "\n")
|
|
152
|
+
except OSError as exc:
|
|
153
|
+
raise StorageError(f"failed to write signal shard {shard}: {exc}") from exc
|
|
154
|
+
|
|
155
|
+
index = _load_dedup_index()
|
|
156
|
+
index[signal.fingerprint] = {
|
|
157
|
+
"id": signal.id,
|
|
158
|
+
"ingested_at": signal.ingested_at,
|
|
159
|
+
"hit_count": 1,
|
|
160
|
+
"shard": shard.name,
|
|
161
|
+
}
|
|
162
|
+
_save_dedup_index(index)
|
|
163
|
+
|
|
164
|
+
return {"status": "ingested", "signal": signal.to_dict(), "shard": shard.name}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _iter_shards(since_days: int = HOT_WINDOW_DAYS) -> Iterable[Path]:
|
|
168
|
+
if not SIGNALS_DIR.exists():
|
|
169
|
+
return []
|
|
170
|
+
cutoff = (_now() - timedelta(days=since_days)).date()
|
|
171
|
+
paths = []
|
|
172
|
+
for path in SIGNALS_DIR.glob("*.jsonl"):
|
|
173
|
+
if path.name.startswith("_"):
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
shard_date = datetime.fromisoformat(path.stem).date()
|
|
177
|
+
except ValueError:
|
|
178
|
+
continue
|
|
179
|
+
if shard_date >= cutoff:
|
|
180
|
+
paths.append(path)
|
|
181
|
+
return sorted(paths, reverse=True)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def query(
|
|
185
|
+
since_days: int = 1,
|
|
186
|
+
platform: str = "",
|
|
187
|
+
limit: int = 50,
|
|
188
|
+
) -> List[Dict[str, Any]]:
|
|
189
|
+
"""Return signals from the hot window, newest first.
|
|
190
|
+
|
|
191
|
+
since_days=1 returns the last 24h of signals (the default `delimit sense`
|
|
192
|
+
view). platform filters to a specific source; empty = all.
|
|
193
|
+
"""
|
|
194
|
+
rows: List[Dict[str, Any]] = []
|
|
195
|
+
want_platform = (platform or "").strip().lower()
|
|
196
|
+
for shard in _iter_shards(since_days):
|
|
197
|
+
try:
|
|
198
|
+
for line in shard.read_text().splitlines():
|
|
199
|
+
if not line.strip():
|
|
200
|
+
continue
|
|
201
|
+
try:
|
|
202
|
+
row = json.loads(line)
|
|
203
|
+
except json.JSONDecodeError:
|
|
204
|
+
continue
|
|
205
|
+
if want_platform and (row.get("platform") or "").lower() != want_platform:
|
|
206
|
+
continue
|
|
207
|
+
rows.append(row)
|
|
208
|
+
if len(rows) >= limit:
|
|
209
|
+
return rows
|
|
210
|
+
except OSError:
|
|
211
|
+
continue
|
|
212
|
+
return rows
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def age_out_to_warm(days: int = HOT_WINDOW_DAYS) -> int:
|
|
216
|
+
"""No-op placeholder: hot/warm separation is a query boundary, not a move.
|
|
217
|
+
|
|
218
|
+
We keep all shards in SIGNALS_DIR and rely on query()'s since_days filter
|
|
219
|
+
to enforce the hot window. Returns the count of shards older than `days`
|
|
220
|
+
for reporting.
|
|
221
|
+
"""
|
|
222
|
+
if not SIGNALS_DIR.exists():
|
|
223
|
+
return 0
|
|
224
|
+
cutoff = (_now() - timedelta(days=days)).date()
|
|
225
|
+
old = 0
|
|
226
|
+
for path in SIGNALS_DIR.glob("*.jsonl"):
|
|
227
|
+
if path.name.startswith("_"):
|
|
228
|
+
continue
|
|
229
|
+
try:
|
|
230
|
+
shard_date = datetime.fromisoformat(path.stem).date()
|
|
231
|
+
except ValueError:
|
|
232
|
+
continue
|
|
233
|
+
if shard_date < cutoff:
|
|
234
|
+
old += 1
|
|
235
|
+
return old
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def freeze_cold(month: str) -> str:
|
|
239
|
+
"""Move all shards whose date starts with `month` (YYYY-MM) into ARCHIVE_DIR/{month}.jsonl.
|
|
240
|
+
|
|
241
|
+
Returns the archive path. Safe to run repeatedly; reruns append.
|
|
242
|
+
"""
|
|
243
|
+
if not month or len(month) != 7 or month[4] != "-":
|
|
244
|
+
raise ValidationError(f"month must be YYYY-MM, got {month!r}")
|
|
245
|
+
_ensure_dirs()
|
|
246
|
+
archive_path = ARCHIVE_DIR / f"{month}.jsonl"
|
|
247
|
+
moved = 0
|
|
248
|
+
with archive_path.open("a") as out:
|
|
249
|
+
for path in sorted(SIGNALS_DIR.glob(f"{month}-*.jsonl")):
|
|
250
|
+
try:
|
|
251
|
+
out.write(path.read_text())
|
|
252
|
+
except OSError:
|
|
253
|
+
continue
|
|
254
|
+
try:
|
|
255
|
+
path.unlink()
|
|
256
|
+
moved += 1
|
|
257
|
+
except OSError:
|
|
258
|
+
pass
|
|
259
|
+
return str(archive_path)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def promote_to_ledger(
|
|
263
|
+
signal_id: str,
|
|
264
|
+
ledger: str = "ops",
|
|
265
|
+
priority: str = "P2",
|
|
266
|
+
extra_tags: Optional[List[str]] = None,
|
|
267
|
+
) -> Dict[str, Any]:
|
|
268
|
+
"""Explicit founder-initiated promotion of a signal to a ledger item.
|
|
269
|
+
|
|
270
|
+
This is the ONLY path from intel → ledger. Writes via ledger_manager.add_item
|
|
271
|
+
with promoted_by set so the guard accepts it.
|
|
272
|
+
"""
|
|
273
|
+
signal = _find_signal(signal_id)
|
|
274
|
+
if not signal:
|
|
275
|
+
raise ValidationError(f"signal {signal_id} not found in hot shards")
|
|
276
|
+
|
|
277
|
+
from ai.ledger_manager import add_item
|
|
278
|
+
|
|
279
|
+
title = f"[{(signal.get('platform') or 'signal').upper()}] Promoted: {signal.get('author') or signal.get('canonical_url')}"
|
|
280
|
+
description = (
|
|
281
|
+
f"Promoted from intel store (signal {signal_id}).\n"
|
|
282
|
+
f"URL: {signal.get('canonical_url', '')}\n"
|
|
283
|
+
f"Author: {signal.get('author', '')}\n"
|
|
284
|
+
f"Snippet: {(signal.get('content_snippet') or '')[:400]}\n"
|
|
285
|
+
f"Posted: {signal.get('posted_at', '')}\n"
|
|
286
|
+
f"Fingerprint: {signal.get('fingerprint', '')}"
|
|
287
|
+
)
|
|
288
|
+
tags = ["promoted-signal", signal.get("platform", "")]
|
|
289
|
+
if extra_tags:
|
|
290
|
+
tags.extend(extra_tags)
|
|
291
|
+
|
|
292
|
+
# Guard checks source=='promoted_signal' + promoted_by set, so bypass the
|
|
293
|
+
# social_scan rejection.
|
|
294
|
+
os.environ.setdefault("_DELIMIT_SIGNAL_PROMOTED_BY", "founder")
|
|
295
|
+
try:
|
|
296
|
+
result = add_item(
|
|
297
|
+
title=title,
|
|
298
|
+
ledger=ledger,
|
|
299
|
+
type="task",
|
|
300
|
+
priority=priority,
|
|
301
|
+
description=description,
|
|
302
|
+
source=f"promoted_signal:{signal_id}",
|
|
303
|
+
tags=tags,
|
|
304
|
+
context=f"Promoted from signal {signal_id} for strategic action.",
|
|
305
|
+
)
|
|
306
|
+
finally:
|
|
307
|
+
os.environ.pop("_DELIMIT_SIGNAL_PROMOTED_BY", None)
|
|
308
|
+
return result
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _find_signal(signal_id: str) -> Optional[Dict[str, Any]]:
|
|
312
|
+
if not signal_id:
|
|
313
|
+
return None
|
|
314
|
+
for shard in _iter_shards(since_days=WARM_WINDOW_DAYS):
|
|
315
|
+
try:
|
|
316
|
+
for line in shard.read_text().splitlines():
|
|
317
|
+
if not line.strip():
|
|
318
|
+
continue
|
|
319
|
+
try:
|
|
320
|
+
row = json.loads(line)
|
|
321
|
+
except json.JSONDecodeError:
|
|
322
|
+
continue
|
|
323
|
+
if row.get("id") == signal_id:
|
|
324
|
+
return row
|
|
325
|
+
except OSError:
|
|
326
|
+
continue
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def digest(since_days: int = HOT_WINDOW_DAYS, top_n: int = 20) -> Dict[str, Any]:
|
|
331
|
+
"""Cluster recent signals by platform + top authors + theme counters."""
|
|
332
|
+
rows = query(since_days=since_days, limit=1000)
|
|
333
|
+
by_platform: Counter[str] = Counter()
|
|
334
|
+
by_author: Counter[str] = Counter()
|
|
335
|
+
by_theme: Counter[str] = Counter()
|
|
336
|
+
for row in rows:
|
|
337
|
+
by_platform[row.get("platform", "?")] += 1
|
|
338
|
+
by_author[row.get("author", "?")] += 1
|
|
339
|
+
for theme in row.get("themes") or []:
|
|
340
|
+
by_theme[theme] += 1
|
|
341
|
+
return {
|
|
342
|
+
"window_days": since_days,
|
|
343
|
+
"total_signals": len(rows),
|
|
344
|
+
"top_platforms": by_platform.most_common(10),
|
|
345
|
+
"top_authors": by_author.most_common(top_n),
|
|
346
|
+
"top_themes": by_theme.most_common(top_n),
|
|
347
|
+
"sample": rows[:5],
|
|
348
|
+
}
|