delimit-cli 4.1.16 → 4.1.18

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 CHANGED
@@ -30,9 +30,9 @@ Works across any configuration — from a single model on a budget to an enterpr
30
30
  ## Try it in 2 minutes
31
31
 
32
32
  ```bash
33
- npx delimit-cli demo # See governance in action no setup needed
34
- npx delimit-cli init # Set up governance for your project
35
- npx delimit-cli setup # Configure your AI assistants
33
+ npx delimit-cli scan # Instant health grade for your API spec
34
+ npx delimit-cli demo # See governance in action — no setup needed
35
+ npx delimit-cli setup && source ~/.bashrc # Configure AI assistants + activate
36
36
  ```
37
37
 
38
38
  No API keys. No account. No config files.
@@ -142,6 +142,7 @@ That's it. Delimit auto-fetches the base branch spec, diffs it, and posts a PR c
142
142
  ## CLI commands
143
143
 
144
144
  ```bash
145
+ npx delimit-cli scan # Instant spec health grade + recommendations
145
146
  npx delimit-cli quickstart # Clone demo project + guided walkthrough
146
147
  npx delimit-cli try # Zero-risk demo — saves governance report
147
148
  npx delimit-cli demo # Self-contained governance demo
@@ -0,0 +1,105 @@
1
+ #!/bin/bash
2
+ # Delimit OS — the AI developer operating system
3
+ # Type 'delimit' to launch the TUI, or 'delimit <command>' for CLI tools
4
+ #
5
+ # Usage:
6
+ # delimit → Launch TUI (interactive terminal dashboard)
7
+ # delimit --quick → Quick status (non-interactive)
8
+ # delimit think → Trigger deliberation
9
+ # delimit build → Start autonomous build loop
10
+ # delimit ask <query> → Ask the swarm
11
+ # delimit lint <spec> → Lint an API spec
12
+ # delimit init → Initialize governance in current repo
13
+ # delimit setup → Configure AI assistants
14
+
15
+ set -e
16
+
17
+ DELIMIT_HOME="${DELIMIT_HOME:-$HOME/.delimit}"
18
+ GATEWAY="$DELIMIT_HOME/server/ai"
19
+
20
+ # If no args, launch TUI (interactive if terminal, quick if piped)
21
+ if [ $# -eq 0 ]; then
22
+ if [ -f "$GATEWAY/tui.py" ]; then
23
+ if [ -t 1 ] && [ -t 0 ]; then
24
+ cd "$DELIMIT_HOME/server" && exec python3 -m ai.tui
25
+ else
26
+ cd "$DELIMIT_HOME/server" && exec python3 -m ai.tui --quick
27
+ fi
28
+ else
29
+ # Fallback to npm CLI
30
+ exec delimit-cli "$@"
31
+ fi
32
+ fi
33
+
34
+ # Route commands
35
+ case "$1" in
36
+ --quick|-q)
37
+ if [ -f "$GATEWAY/tui.py" ]; then
38
+ cd "$DELIMIT_HOME/server" && exec python3 -m ai.tui --quick
39
+ else
40
+ exec delimit-cli status
41
+ fi
42
+ ;;
43
+ think|deliberate)
44
+ shift
45
+ QUESTION="${*:-What should we build next based on the current ledger and signals?}"
46
+ echo "[Delimit OS] Triggering deliberation..."
47
+ cd "$DELIMIT_HOME/server" && python3 -c "
48
+ from ai.deliberation import deliberate
49
+ import json
50
+ result = deliberate('''$QUESTION''', mode='dialogue', max_rounds=3)
51
+ if 'error' in result:
52
+ print(f'Error: {result[\"error\"]}')
53
+ elif result.get('mode') == 'single_model_reflection':
54
+ print(f'Model: {result.get(\"model\", \"?\")}')
55
+ print(f'\\nAdvocate:\\n{result.get(\"advocate\", \"\")[:500]}')
56
+ print(f'\\nCritic:\\n{result.get(\"critic\", \"\")[:500]}')
57
+ print(f'\\nSynthesis:\\n{result.get(\"synthesis\", \"\")}')
58
+ else:
59
+ print(f'Verdict: {result.get(\"final_verdict\", \"no consensus\")[:500]}')
60
+ print(f'Rounds: {result.get(\"rounds\", 0)}')
61
+ " 2>&1
62
+ ;;
63
+ build|loop)
64
+ shift
65
+ echo "[Delimit OS] Starting autonomous build loop..."
66
+ echo "Checking ledger for next task..."
67
+ cd "$DELIMIT_HOME/server" && python3 -c "
68
+ from ai.ledger_manager import get_context
69
+ import json
70
+ result = get_context()
71
+ items = result.get('next_up', [])
72
+ if items:
73
+ print(f'Next up: {items[0].get(\"id\", \"?\")} [{items[0].get(\"priority\", \"?\")}] {items[0].get(\"title\", \"?\")[:60]}')
74
+ print(f'Total open: {result.get(\"open_items\", 0)}')
75
+ else:
76
+ print('Ledger is clear — nothing to build.')
77
+ " 2>&1
78
+ ;;
79
+ ask)
80
+ shift
81
+ QUERY="$*"
82
+ if [ -z "$QUERY" ]; then
83
+ echo "Usage: delimit ask <question>"
84
+ exit 1
85
+ fi
86
+ echo "[Delimit OS] Checking context..."
87
+ cd "$DELIMIT_HOME/server" && python3 -c "
88
+ from ai.ledger_manager import get_context
89
+ import json
90
+ result = get_context()
91
+ print(json.dumps(result, indent=2)[:2000])
92
+ " 2>&1
93
+ ;;
94
+ status)
95
+ if [ -f "$GATEWAY/tui.py" ]; then
96
+ cd "$GATEWAY/.." && exec python3 -m ai.tui --quick
97
+ else
98
+ exec delimit-cli status
99
+ fi
100
+ ;;
101
+ *)
102
+ # Pass through to delimit-cli for all other commands
103
+ exec delimit-cli "$@"
104
+ ;;
105
+ esac
@@ -800,11 +800,19 @@ exit 127
800
800
  await logp(` ${green('✓')} Governance shims updated`);
801
801
  } else {
802
802
  log(` ${green('✓')} Governance wrapping enabled`);
803
+ }
804
+
805
+ // Check if shims are already in current PATH
806
+ const currentPath = process.env.PATH || '';
807
+ if (!currentPath.includes('.delimit/shims')) {
808
+ // Can't modify parent shell, but try exec $SHELL -l to reload
803
809
  log('');
804
- log(` ${bold('To activate now, run:')}`);
805
- log(` ${green('source ~/.bashrc')}`);
810
+ log(` ${yellow('!')} Run this to activate the banner now:`);
811
+ log(` ${green(`export PATH="${shimsDir}:$PATH"`)}`);
806
812
  log('');
807
- log(` ${dim('Or restart your terminal. The banner appears before each AI session.')}`);
813
+ log(` ${dim('Or just open a new terminal. Future sessions load it automatically.')}`);
814
+ } else {
815
+ log(` ${green('✓')} Shim active in current shell`);
808
816
  }
809
817
  } else {
810
818
  log(` ${dim(' Skipped. Enable later: delimit shims enable')}`);
@@ -1170,6 +1178,44 @@ exit 127
1170
1178
  log('');
1171
1179
  log(` ${bold('Keep Building.')}`);
1172
1180
  log('');
1181
+
1182
+ // Show governance banner preview
1183
+ const shimFile = path.join(DELIMIT_HOME, 'shims', 'claude');
1184
+ if (fs.existsSync(shimFile)) {
1185
+ log(dim(' --- Governance Banner Preview ---'));
1186
+ try {
1187
+ // Run the shim with DELIMIT_WRAPPED=true so it exits after banner
1188
+ // We simulate the banner directly instead
1189
+ const toolCount = (() => {
1190
+ try {
1191
+ const srv = fs.readFileSync(path.join(DELIMIT_HOME, 'server', 'ai', 'server.py'), 'utf-8');
1192
+ return (srv.match(/@mcp\.tool/g) || []).length + (srv.match(/mcp\.tool\(\)\(/g) || []).length;
1193
+ } catch { return 0; }
1194
+ })();
1195
+ const version = require('../package.json').version;
1196
+ const purple = '\x1b[35m', magenta = '\x1b[91m', orange = '\x1b[33m';
1197
+ const bold2 = '\x1b[1m', dim2 = '\x1b[2m', reset = '\x1b[0m', green2 = '\x1b[32m';
1198
+ console.log('');
1199
+ console.log(` ${purple}${bold2} ____ ________ ______ _____________${reset}`);
1200
+ console.log(` ${purple}${bold2} / __ \\/ ____/ / / _/ |/ / _/_ __/${reset}`);
1201
+ console.log(` ${magenta}${bold2} / / / / __/ / / / // /|_/ // / / / ${reset}`);
1202
+ console.log(` ${magenta}${bold2} / /_/ / /___/ /____/ // / / // / / / ${reset}`);
1203
+ console.log(` ${orange}${bold2}/_____/_____/_____/___/_/ /_/___/ /_/ ${reset}`);
1204
+ console.log(` ${dim2}v${version}${reset}`);
1205
+ console.log('');
1206
+ console.log(` ${purple}${bold2}[Delimit]${reset} ${dim2}Executing governance check...${reset}`);
1207
+ console.log(` ${purple}${bold2}[Delimit]${reset} ${orange}Mode: advisory${reset}`);
1208
+ console.log(` ${purple}${bold2}[Delimit]${reset} ${dim2}MCP server: ${toolCount} tools${reset}`);
1209
+ console.log(` ${magenta}${bold2}[Delimit]${reset} ${magenta}═══════════════════════════════════════════${reset}`);
1210
+ console.log(` ${magenta}${bold2}[Delimit]${reset} ${purple}<${magenta}/${orange}>${reset} ${bold2}GOVERNANCE ACTIVE: CLAUDE${reset}`);
1211
+ console.log(` ${magenta}${bold2}[Delimit]${reset} ${magenta}═══════════════════════════════════════════${reset}`);
1212
+ console.log(` ${green2}${bold2}[Delimit]${reset} ${green2}✓ Allowed${reset}`);
1213
+ console.log('');
1214
+ log(dim(' This banner appears before each AI session.'));
1215
+ log(dim(' If you don\'t see it, run: source ~/.bashrc'));
1216
+ } catch {}
1217
+ log('');
1218
+ }
1173
1219
  }
1174
1220
 
1175
1221
  // LED-213: Import canonical template from shared module
@@ -0,0 +1,240 @@
1
+ """Ledger Propose — AI-driven item generation from signals.
2
+
3
+ Analyzes repo state, sensing signals, completed work, and venture priorities
4
+ to propose 3-5 new ledger items with rationale. Runs at end of build loops
5
+ when the queue is empty, or on-demand.
6
+
7
+ Works across all AI models via MCP — no model-specific code.
8
+ """
9
+
10
+ import json
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any, Dict, List, Optional
14
+
15
+
16
+ SIGNALS_DIR = Path.home() / ".delimit" / "signals"
17
+ PROPOSALS_DIR = Path.home() / ".delimit" / "proposals"
18
+
19
+
20
+ def _gather_context(venture: str = "") -> Dict[str, Any]:
21
+ """Collect context from multiple sources for proposal generation."""
22
+ context = {
23
+ "timestamp": time.time(),
24
+ "venture": venture or "all",
25
+ "sources": {},
26
+ }
27
+
28
+ # 1. Recent ledger completions (what just shipped)
29
+ ledger_path = Path.home() / ".delimit" / "ledger.jsonl"
30
+ if ledger_path.exists():
31
+ recent_done = []
32
+ for line in ledger_path.read_text().strip().split("\n")[-100:]:
33
+ try:
34
+ item = json.loads(line)
35
+ if item.get("status") == "done":
36
+ recent_done.append({
37
+ "id": item.get("id", ""),
38
+ "title": item.get("title", ""),
39
+ "venture": item.get("venture", ""),
40
+ })
41
+ except json.JSONDecodeError:
42
+ continue
43
+ context["sources"]["completed_work"] = recent_done[-10:]
44
+
45
+ # 2. Open items (what's already tracked)
46
+ open_items = []
47
+ if ledger_path.exists():
48
+ for line in ledger_path.read_text().strip().split("\n"):
49
+ try:
50
+ item = json.loads(line)
51
+ if item.get("status") == "open":
52
+ open_items.append(item.get("title", ""))
53
+ except json.JSONDecodeError:
54
+ continue
55
+ context["sources"]["open_items_count"] = len(open_items)
56
+ context["sources"]["open_item_titles"] = open_items[:20]
57
+
58
+ # 3. Sensing signals (GitHub issues, Reddit, migrations)
59
+ signals_file = Path.home() / ".delimit" / "signals" / "recent.jsonl"
60
+ if signals_file.exists():
61
+ signals = []
62
+ for line in signals_file.read_text().strip().split("\n")[-20:]:
63
+ try:
64
+ sig = json.loads(line)
65
+ signals.append({
66
+ "type": sig.get("type", ""),
67
+ "title": sig.get("title", ""),
68
+ "source": sig.get("source", ""),
69
+ "relevance": sig.get("relevance", 0),
70
+ })
71
+ except json.JSONDecodeError:
72
+ continue
73
+ context["sources"]["signals"] = signals
74
+
75
+ # 4. Git recent activity
76
+ try:
77
+ import subprocess
78
+ result = subprocess.run(
79
+ ["git", "log", "--oneline", "-10"],
80
+ capture_output=True, text=True, timeout=5,
81
+ )
82
+ if result.returncode == 0:
83
+ context["sources"]["recent_commits"] = result.stdout.strip().split("\n")
84
+ except Exception:
85
+ pass
86
+
87
+ # 5. Swarm state
88
+ swarm_registry = Path.home() / ".delimit" / "swarm" / "agent_registry.json"
89
+ if swarm_registry.exists():
90
+ try:
91
+ reg = json.loads(swarm_registry.read_text())
92
+ context["sources"]["swarm_agents"] = len(reg.get("agents", {}))
93
+ except json.JSONDecodeError:
94
+ pass
95
+
96
+ return context
97
+
98
+
99
+ def propose_items(
100
+ venture: str = "",
101
+ focus: str = "",
102
+ max_items: int = 5,
103
+ ) -> Dict[str, Any]:
104
+ """Generate proposed ledger items based on current context.
105
+
106
+ Analyzes completed work, open items, sensing signals, and repo state
107
+ to suggest what to work on next. Returns structured proposals that
108
+ can be added to the ledger with delimit_ledger_add.
109
+
110
+ This is the AI's "what should I do next?" engine. Works with any model.
111
+
112
+ Args:
113
+ venture: Focus proposals on a specific venture.
114
+ focus: Optional focus area (e.g., "outreach", "engineering", "security").
115
+ max_items: Maximum number of proposals to generate.
116
+ """
117
+ context = _gather_context(venture)
118
+
119
+ # Build proposal prompt from context
120
+ proposals = []
121
+ open_titles = set(context["sources"].get("open_item_titles", []))
122
+
123
+ # Strategy 1: Follow-through on completed work
124
+ for done in context["sources"].get("completed_work", []):
125
+ title = done.get("title", "")
126
+ if venture and done.get("venture", "") != venture:
127
+ continue
128
+ # Suggest follow-up actions
129
+ if "deploy" in title.lower() or "publish" in title.lower():
130
+ follow_up = f"Verify deployment: {title}"
131
+ if follow_up not in open_titles:
132
+ proposals.append({
133
+ "title": follow_up,
134
+ "rationale": f"Follow-through: '{title}' was shipped but may need verification",
135
+ "priority": "P1",
136
+ "type": "task",
137
+ "source": "ledger_propose:follow_through",
138
+ })
139
+ if "outreach" in title.lower() or "issue" in title.lower():
140
+ follow_up = f"Monitor engagement: {title}"
141
+ if follow_up not in open_titles:
142
+ proposals.append({
143
+ "title": follow_up,
144
+ "rationale": f"Outreach needs monitoring for responses and engagement",
145
+ "priority": "P1",
146
+ "type": "task",
147
+ "source": "ledger_propose:follow_through",
148
+ })
149
+
150
+ # Strategy 2: Act on unprocessed signals
151
+ for sig in context["sources"].get("signals", []):
152
+ sig_title = sig.get("title", "")
153
+ if sig_title and sig_title not in open_titles:
154
+ proposals.append({
155
+ "title": f"Evaluate signal: {sig_title[:80]}",
156
+ "rationale": f"Unprocessed {sig.get('type', 'signal')} from {sig.get('source', 'unknown')}",
157
+ "priority": "P1",
158
+ "type": "strategy",
159
+ "source": "ledger_propose:signal",
160
+ })
161
+
162
+ # Strategy 3: Detect gaps
163
+ has_tests = any("test" in t.lower() for t in open_titles)
164
+ has_docs = any("doc" in t.lower() or "readme" in t.lower() for t in open_titles)
165
+ has_security = any("security" in t.lower() or "audit" in t.lower() for t in open_titles)
166
+
167
+ if not has_tests and focus != "outreach":
168
+ proposals.append({
169
+ "title": f"Run test coverage analysis{f' for {venture}' if venture else ''}",
170
+ "rationale": "No test-related items in queue — ensure coverage hasn't regressed",
171
+ "priority": "P1",
172
+ "type": "task",
173
+ "source": "ledger_propose:gap_detection",
174
+ })
175
+
176
+ if not has_security and focus != "outreach":
177
+ proposals.append({
178
+ "title": f"Security audit{f' for {venture}' if venture else ''}",
179
+ "rationale": "No security items in queue — periodic audits prevent surprises",
180
+ "priority": "P2",
181
+ "type": "task",
182
+ "source": "ledger_propose:gap_detection",
183
+ })
184
+
185
+ if not has_docs:
186
+ proposals.append({
187
+ "title": f"Documentation freshness check{f' for {venture}' if venture else ''}",
188
+ "rationale": "No doc items in queue — ensure README/CHANGELOG reflect current state",
189
+ "priority": "P2",
190
+ "type": "task",
191
+ "source": "ledger_propose:gap_detection",
192
+ })
193
+
194
+ # Apply focus filter
195
+ if focus:
196
+ focus_lower = focus.lower()
197
+ proposals = [p for p in proposals if focus_lower in p.get("title", "").lower()
198
+ or focus_lower in p.get("rationale", "").lower()
199
+ or focus_lower in p.get("type", "").lower()]
200
+
201
+ # Deduplicate and limit
202
+ seen = set()
203
+ unique = []
204
+ for p in proposals:
205
+ key = p["title"][:50]
206
+ if key not in seen:
207
+ seen.add(key)
208
+ unique.append(p)
209
+ proposals = unique[:max_items]
210
+
211
+ # Save proposals for audit trail
212
+ PROPOSALS_DIR.mkdir(parents=True, exist_ok=True)
213
+ proposal_file = PROPOSALS_DIR / f"proposal_{int(time.time())}.json"
214
+ proposal_file.write_text(json.dumps({
215
+ "proposals": proposals,
216
+ "context_summary": {
217
+ "completed_work": len(context["sources"].get("completed_work", [])),
218
+ "open_items": context["sources"].get("open_items_count", 0),
219
+ "signals": len(context["sources"].get("signals", [])),
220
+ "swarm_agents": context["sources"].get("swarm_agents", 0),
221
+ },
222
+ "venture": venture,
223
+ "focus": focus,
224
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
225
+ }, indent=2))
226
+
227
+ return {
228
+ "status": "ok",
229
+ "proposals": proposals,
230
+ "total": len(proposals),
231
+ "venture": venture or "all",
232
+ "focus": focus or "none",
233
+ "context": {
234
+ "completed_work_analyzed": len(context["sources"].get("completed_work", [])),
235
+ "open_items": context["sources"].get("open_items_count", 0),
236
+ "signals_analyzed": len(context["sources"].get("signals", [])),
237
+ },
238
+ "message": f"Generated {len(proposals)} proposal(s). "
239
+ "Use delimit_ledger_add to add approved items.",
240
+ }
@@ -0,0 +1,106 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import urllib.parse
5
+ import urllib.request
6
+ from pathlib import Path
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ logger = logging.getLogger("delimit.ai.reddit_proxy")
10
+
11
+ def _get_proxy_config() -> Dict[str, str]:
12
+ """Load proxy config from private secrets or environment."""
13
+ config = {"proxy_url": ""}
14
+
15
+ # 1. Check environment variable
16
+ env_url = os.environ.get("DELIMIT_REDDIT_PROXY")
17
+ if env_url:
18
+ config["proxy_url"] = env_url
19
+ return config
20
+
21
+ # 2. Check private secrets file
22
+ secrets_path = Path.home() / ".delimit" / "secrets" / "reddit-proxy.json"
23
+ if secrets_path.exists():
24
+ try:
25
+ secrets = json.loads(secrets_path.read_text())
26
+ config["proxy_url"] = secrets.get("proxy_url", "")
27
+ except Exception as e:
28
+ logger.debug(f"Failed to load reddit-proxy secrets: {e}")
29
+
30
+ return config
31
+
32
+ def fetch_subreddit(subreddit: str, sort: str = "new", limit: int = 10) -> List[Dict[str, Any]]:
33
+ """
34
+ Fetch posts from a single subreddit with fallback chain.
35
+ Returns standardized post dicts.
36
+ """
37
+ reddit_url = f"https://www.reddit.com/r/{subreddit}/{sort}.json?limit={limit}&raw_json=1"
38
+
39
+ # 1. Try Local Proxy (Residential IP)
40
+ proxy_cfg = _get_proxy_config()
41
+ proxy_url = proxy_cfg.get("proxy_url")
42
+ if proxy_url:
43
+ try:
44
+ fetch_url = f"{proxy_url}?url={urllib.parse.quote(reddit_url, safe='')}"
45
+ req = urllib.request.Request(fetch_url, headers={"User-Agent": "Delimit/1.0"})
46
+ with urllib.request.urlopen(req, timeout=10) as resp:
47
+ body = json.loads(resp.read().decode())
48
+ children = body.get("data", {}).get("children", [])
49
+ return [c.get("data", {}) for c in children if c.get("data")]
50
+ except Exception as e:
51
+ logger.debug(f"Local proxy failed for r/{subreddit}: {e}")
52
+
53
+ # 2. Fallback: PullPush API (Public Archive)
54
+ try:
55
+ pp_url = f"https://api.pullpush.io/reddit/search/submission/?subreddit={subreddit}&size={limit}&sort=desc"
56
+ req = urllib.request.Request(pp_url, headers={"User-Agent": "Delimit/1.0"})
57
+ with urllib.request.urlopen(req, timeout=10) as resp:
58
+ body = json.loads(resp.read().decode())
59
+ return body.get("data", [])
60
+ except Exception as e:
61
+ logger.debug(f"PullPush fallback failed for r/{subreddit}: {e}")
62
+
63
+ # 3. Fallback: Direct (Often blocked on servers)
64
+ try:
65
+ req = urllib.request.Request(reddit_url, headers={"User-Agent": "Mozilla/5.0 (Delimit)"})
66
+ with urllib.request.urlopen(req, timeout=5) as resp:
67
+ body = json.loads(resp.read().decode())
68
+ children = body.get("data", {}).get("children", [])
69
+ return [c.get("data", {}) for c in children if c.get("data")]
70
+ except Exception as e:
71
+ logger.warning(f"Direct fetch failed for r/{subreddit}: {e}")
72
+
73
+ return []
74
+
75
+ def fetch_thread(thread_id: str) -> Optional[Dict[str, Any]]:
76
+ """
77
+ Fetch a single Reddit thread by ID with fallback chain.
78
+ """
79
+ reddit_url = f"https://www.reddit.com/comments/{thread_id}.json?raw_json=1"
80
+
81
+ # 1. Try Local Proxy
82
+ proxy_cfg = _get_proxy_config()
83
+ proxy_url = proxy_cfg.get("proxy_url")
84
+ if proxy_url:
85
+ try:
86
+ fetch_url = f"{proxy_url}?url={urllib.parse.quote(reddit_url, safe='')}"
87
+ req = urllib.request.Request(fetch_url, headers={"User-Agent": "Delimit/1.0"})
88
+ with urllib.request.urlopen(req, timeout=10) as resp:
89
+ data = json.loads(resp.read().decode())
90
+ if isinstance(data, list) and len(data) > 0:
91
+ return data[0].get("data", {}).get("children", [{}])[0].get("data", {})
92
+ except Exception as e:
93
+ logger.debug(f"Local proxy failed for thread {thread_id}: {e}")
94
+
95
+ # 2. Fallback: PullPush
96
+ try:
97
+ pp_url = f"https://api.pullpush.io/reddit/search/submission/?ids={thread_id}"
98
+ req = urllib.request.Request(pp_url, headers={"User-Agent": "Delimit/1.0"})
99
+ with urllib.request.urlopen(req, timeout=10) as resp:
100
+ body = json.loads(resp.read().decode())
101
+ data = body.get("data", [])
102
+ return data[0] if data else None
103
+ except Exception as e:
104
+ logger.debug(f"PullPush fallback failed for thread {thread_id}: {e}")
105
+
106
+ return None