delimit-cli 4.1.6 → 4.1.8
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/lib/cross-model-hooks.js +35 -11
- package/package.json +1 -1
- package/bin/delimit-os.sh +0 -105
- package/gateway/ai/ledger_propose.py +0 -240
- package/gateway/ai/reddit_proxy.py +0 -106
- package/gateway/ai/siem_streaming.py +0 -290
- package/gateway/ai/social_daemon.py +0 -189
- package/gateway/core/spec_health.py +0 -624
package/lib/cross-model-hooks.js
CHANGED
|
@@ -790,26 +790,52 @@ function getTopStrategyItem() {
|
|
|
790
790
|
*/
|
|
791
791
|
async function hookSessionStart() {
|
|
792
792
|
const config = loadHookConfig();
|
|
793
|
-
if
|
|
794
|
-
|
|
795
|
-
}
|
|
793
|
+
// Always show status — even if session_start is false in config
|
|
794
|
+
// This is the first thing a user sees. Make it count.
|
|
796
795
|
|
|
797
796
|
const lines = [];
|
|
798
|
-
lines.push('
|
|
799
|
-
|
|
797
|
+
lines.push('=== Delimit Status ===');
|
|
798
|
+
|
|
799
|
+
// Server status + tool count
|
|
800
|
+
const home = getHome();
|
|
801
|
+
const serverFile = path.join(home, '.delimit', 'server', 'ai', 'server.py');
|
|
802
|
+
if (fs.existsSync(serverFile)) {
|
|
803
|
+
try {
|
|
804
|
+
const content = fs.readFileSync(serverFile, 'utf-8');
|
|
805
|
+
const toolCount = (content.match(/@mcp\.tool\(\)/g) || []).length;
|
|
806
|
+
lines.push(`Server: ready (${toolCount} tools)`);
|
|
807
|
+
} catch {
|
|
808
|
+
lines.push('Server: ready');
|
|
809
|
+
}
|
|
810
|
+
} else {
|
|
811
|
+
lines.push('Server: not installed -- run npx delimit-cli setup');
|
|
812
|
+
}
|
|
800
813
|
|
|
801
|
-
//
|
|
814
|
+
// Governance status
|
|
802
815
|
const cwd = process.cwd();
|
|
803
816
|
const hasPolicy = fs.existsSync(path.join(cwd, 'delimit.yml'))
|
|
804
817
|
|| fs.existsSync(path.join(cwd, '.delimit.yml'))
|
|
805
818
|
|| fs.existsSync(path.join(cwd, '.delimit', 'policies.yml'));
|
|
806
819
|
|
|
807
820
|
if (hasPolicy) {
|
|
808
|
-
lines.push('
|
|
821
|
+
lines.push('Governance: active');
|
|
809
822
|
} else {
|
|
810
|
-
lines.push('
|
|
823
|
+
lines.push('Governance: not initialized -- run npx delimit-cli init');
|
|
811
824
|
}
|
|
812
825
|
|
|
826
|
+
// Last session context (prevents cross-session drift)
|
|
827
|
+
const sessionsDir = path.join(home, '.delimit', 'sessions');
|
|
828
|
+
try {
|
|
829
|
+
if (fs.existsSync(sessionsDir)) {
|
|
830
|
+
const sessions = fs.readdirSync(sessionsDir).filter(f => f.startsWith('session_')).sort().reverse();
|
|
831
|
+
if (sessions.length > 0) {
|
|
832
|
+
const latest = JSON.parse(fs.readFileSync(path.join(sessionsDir, sessions[0]), 'utf-8'));
|
|
833
|
+
const summary = (latest.summary || '').substring(0, 150);
|
|
834
|
+
if (summary) lines.push(`Last session: ${summary}`);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
} catch {}
|
|
838
|
+
|
|
813
839
|
// Auto-update check + install
|
|
814
840
|
try {
|
|
815
841
|
const pkgPath = path.join(__dirname, '..', 'package.json');
|
|
@@ -896,9 +922,7 @@ async function hookSessionStart() {
|
|
|
896
922
|
}
|
|
897
923
|
} catch { /* not in git repo */ }
|
|
898
924
|
|
|
899
|
-
|
|
900
|
-
lines.push('[Delimit] Action required: call delimit_inbox_daemon(action="start") to enable email-based approvals');
|
|
901
|
-
|
|
925
|
+
lines.push('=== Delimit Ready ===');
|
|
902
926
|
lines.push('');
|
|
903
927
|
process.stdout.write(lines.join('\n') + '\n');
|
|
904
928
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "delimit-cli",
|
|
3
3
|
"mcpName": "io.github.delimit-ai/delimit-mcp-server",
|
|
4
|
-
"version": "4.1.
|
|
4
|
+
"version": "4.1.8",
|
|
5
5
|
"description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"files": [
|
package/bin/delimit-os.sh
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
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
|
|
@@ -1,240 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,106 +0,0 @@
|
|
|
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
|