dr-claw 1.1.2

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 (287) hide show
  1. package/LICENSE +1380 -0
  2. package/NOTICE +17 -0
  3. package/README.md +875 -0
  4. package/README.zh-CN.md +857 -0
  5. package/dist/api-docs.html +878 -0
  6. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  7. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  8. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  13. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  14. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  19. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  20. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  22. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  23. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  25. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  26. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  28. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  29. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  31. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  32. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  34. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  35. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  37. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  38. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  46. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  47. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  49. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  50. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  52. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  53. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  55. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  56. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  57. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  58. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  60. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  61. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  63. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  64. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  65. package/dist/assets/_basePickBy-C0n1uo4u.js +1 -0
  66. package/dist/assets/_baseUniq-BRNmhFkc.js +1 -0
  67. package/dist/assets/arc-DOMBmC_A.js +1 -0
  68. package/dist/assets/architectureDiagram-VXUJARFQ-CtMWglbD.js +36 -0
  69. package/dist/assets/blockDiagram-VD42YOAC-DK_gTkJy.js +122 -0
  70. package/dist/assets/c4Diagram-YG6GDRKO-DrDAvsn7.js +10 -0
  71. package/dist/assets/channel-BFOE7OyC.js +1 -0
  72. package/dist/assets/chunk-4BX2VUAB-CGbhdZcN.js +1 -0
  73. package/dist/assets/chunk-55IACEB6-sGeFSxen.js +1 -0
  74. package/dist/assets/chunk-B4BG7PRW-RxgHAU_P.js +165 -0
  75. package/dist/assets/chunk-DI55MBZ5-CvHUXZrx.js +220 -0
  76. package/dist/assets/chunk-FMBD7UC4-Sn7cXVNU.js +15 -0
  77. package/dist/assets/chunk-QN33PNHL-bDKOHppN.js +1 -0
  78. package/dist/assets/chunk-QZHKN3VN-B9kN5LGm.js +1 -0
  79. package/dist/assets/chunk-TZMSLE5B-CCBe6len.js +1 -0
  80. package/dist/assets/classDiagram-2ON5EDUG-DQoMNYlY.js +1 -0
  81. package/dist/assets/classDiagram-v2-WZHVMYZB-DQoMNYlY.js +1 -0
  82. package/dist/assets/clone-BulQSblH.js +1 -0
  83. package/dist/assets/cose-bilkent-S5V4N54A-r6sLiHBW.js +1 -0
  84. package/dist/assets/cytoscape.esm-5J0xJHOV.js +321 -0
  85. package/dist/assets/dagre-6UL2VRFP-AbsaR1Oa.js +4 -0
  86. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  87. package/dist/assets/diagram-PSM6KHXK-DjCvdUyy.js +24 -0
  88. package/dist/assets/diagram-QEK2KX5R-CqiocXkH.js +43 -0
  89. package/dist/assets/diagram-S2PKOQOG-D5ALeoII.js +24 -0
  90. package/dist/assets/erDiagram-Q2GNP2WA-C1AeOsiz.js +60 -0
  91. package/dist/assets/flowDiagram-NV44I4VS-C98ve_DO.js +162 -0
  92. package/dist/assets/ganttDiagram-JELNMOA3-3zdLvcxD.js +267 -0
  93. package/dist/assets/gitGraphDiagram-V2S2FVAM-nDuDMsHh.js +65 -0
  94. package/dist/assets/graph-Blm-emkR.js +1 -0
  95. package/dist/assets/index-B8Yutsnm.css +1 -0
  96. package/dist/assets/index-Cf6gyhc3.js +654 -0
  97. package/dist/assets/infoDiagram-HS3SLOUP-CmzE3q0R.js +2 -0
  98. package/dist/assets/init-Gi6I4Gst.js +1 -0
  99. package/dist/assets/journeyDiagram-XKPGCS4Q-CYaW7LrP.js +139 -0
  100. package/dist/assets/kanban-definition-3W4ZIXB7-mkmW7p0j.js +89 -0
  101. package/dist/assets/layout-Ct2kFIId.js +1 -0
  102. package/dist/assets/linear-CKG9EwIJ.js +1 -0
  103. package/dist/assets/mermaid.core-CaKcUnnd.js +250 -0
  104. package/dist/assets/mindmap-definition-VGOIOE7T-Bct-pYin.js +68 -0
  105. package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
  106. package/dist/assets/pieDiagram-ADFJNKIX-C8wFD4Jr.js +30 -0
  107. package/dist/assets/quadrantDiagram-AYHSOK5B-CuiE_f46.js +7 -0
  108. package/dist/assets/requirementDiagram-UZGBJVZJ-C4v1CqU8.js +64 -0
  109. package/dist/assets/sankeyDiagram-TZEHDZUN-keAqqs-Y.js +10 -0
  110. package/dist/assets/sequenceDiagram-WL72ISMW-ClNbvKTB.js +145 -0
  111. package/dist/assets/stateDiagram-FKZM4ZOC-D8We-2vq.js +1 -0
  112. package/dist/assets/stateDiagram-v2-4FDKWEC3-BUURfjrE.js +1 -0
  113. package/dist/assets/timeline-definition-IT6M3QCI-DsvEMT5T.js +61 -0
  114. package/dist/assets/treemap-GDKQZRPO-TJrzN7Bj.js +162 -0
  115. package/dist/assets/vendor-codemirror-ansM-eig.js +33 -0
  116. package/dist/assets/vendor-react-BWF6YQQ3.js +8 -0
  117. package/dist/assets/vendor-xterm-DfcmCpbH.js +66 -0
  118. package/dist/assets/xychartDiagram-PRI3JC2R-BdATmY5F.js +7 -0
  119. package/dist/clear-cache.html +85 -0
  120. package/dist/convert-icons.md +53 -0
  121. package/dist/dr-claw.png +0 -0
  122. package/dist/favicon.png +0 -0
  123. package/dist/generate-icons.js +49 -0
  124. package/dist/icons/claude-ai-icon.svg +1 -0
  125. package/dist/icons/codex-white.svg +3 -0
  126. package/dist/icons/codex.svg +3 -0
  127. package/dist/icons/cursor-white.svg +12 -0
  128. package/dist/icons/cursor.svg +1 -0
  129. package/dist/icons/file.svg +16997 -0
  130. package/dist/icons/generate-icons.md +19 -0
  131. package/dist/icons/icon-128x128.png +0 -0
  132. package/dist/icons/icon-144x144.png +0 -0
  133. package/dist/icons/icon-152x152.png +0 -0
  134. package/dist/icons/icon-192x192.png +0 -0
  135. package/dist/icons/icon-384x384.png +0 -0
  136. package/dist/icons/icon-512x512.png +0 -0
  137. package/dist/icons/icon-72x72.png +0 -0
  138. package/dist/icons/icon-96x96.png +0 -0
  139. package/dist/icons/news/arxiv.svg +1 -0
  140. package/dist/icons/news/huggingface.svg +8 -0
  141. package/dist/icons/news/x-black.png +0 -0
  142. package/dist/icons/news/x-white.png +0 -0
  143. package/dist/icons/news/xiaohongshu.png +0 -0
  144. package/dist/index.html +51 -0
  145. package/dist/leveraged-cognition.png +0 -0
  146. package/dist/manifest.json +61 -0
  147. package/dist/screenshots/chat.png +0 -0
  148. package/dist/screenshots/new_claw.png +0 -0
  149. package/dist/screenshots/news_dashboard.png +0 -0
  150. package/dist/screenshots/project_dashboard.png +0 -0
  151. package/dist/screenshots/research_lab.png +0 -0
  152. package/dist/screenshots/skill_library.png +0 -0
  153. package/dist/screenshots/task_list.png +0 -0
  154. package/dist/screenshots/terminal_example.png +0 -0
  155. package/dist/screenshots/terminal_example1.png +0 -0
  156. package/dist/sw.js +58 -0
  157. package/dist/wechat-group-qr.jpg +0 -0
  158. package/package.json +133 -0
  159. package/public/api-docs.html +878 -0
  160. package/public/clear-cache.html +85 -0
  161. package/public/convert-icons.md +53 -0
  162. package/public/dr-claw.png +0 -0
  163. package/public/favicon.png +0 -0
  164. package/public/generate-icons.js +49 -0
  165. package/public/icons/claude-ai-icon.svg +1 -0
  166. package/public/icons/codex-white.svg +3 -0
  167. package/public/icons/codex.svg +3 -0
  168. package/public/icons/cursor-white.svg +12 -0
  169. package/public/icons/cursor.svg +1 -0
  170. package/public/icons/file.svg +16997 -0
  171. package/public/icons/generate-icons.md +19 -0
  172. package/public/icons/icon-128x128.png +0 -0
  173. package/public/icons/icon-144x144.png +0 -0
  174. package/public/icons/icon-152x152.png +0 -0
  175. package/public/icons/icon-192x192.png +0 -0
  176. package/public/icons/icon-384x384.png +0 -0
  177. package/public/icons/icon-512x512.png +0 -0
  178. package/public/icons/icon-72x72.png +0 -0
  179. package/public/icons/icon-96x96.png +0 -0
  180. package/public/icons/news/arxiv.svg +1 -0
  181. package/public/icons/news/huggingface.svg +8 -0
  182. package/public/icons/news/x-black.png +0 -0
  183. package/public/icons/news/x-white.png +0 -0
  184. package/public/icons/news/xiaohongshu.png +0 -0
  185. package/public/leveraged-cognition.png +0 -0
  186. package/public/manifest.json +61 -0
  187. package/public/screenshots/chat.png +0 -0
  188. package/public/screenshots/new_claw.png +0 -0
  189. package/public/screenshots/news_dashboard.png +0 -0
  190. package/public/screenshots/project_dashboard.png +0 -0
  191. package/public/screenshots/research_lab.png +0 -0
  192. package/public/screenshots/skill_library.png +0 -0
  193. package/public/screenshots/task_list.png +0 -0
  194. package/public/screenshots/terminal_example.png +0 -0
  195. package/public/screenshots/terminal_example1.png +0 -0
  196. package/public/sw.js +58 -0
  197. package/public/wechat-group-qr.jpg +0 -0
  198. package/scripts/export-skills-catalog-v2.mjs +746 -0
  199. package/scripts/fix-node-pty.js +45 -0
  200. package/scripts/gemini_image_core.py +470 -0
  201. package/scripts/install-python-tools.js +64 -0
  202. package/server/AGENTS.md +73 -0
  203. package/server/__tests__/gemini-legacy-thought.test.mjs +88 -0
  204. package/server/__tests__/runtimePorts.test.mjs +115 -0
  205. package/server/__tests__/session-delete.test.mjs +166 -0
  206. package/server/bin/compute-node +70 -0
  207. package/server/claude-sdk.js +799 -0
  208. package/server/cli-chat.js +488 -0
  209. package/server/cli.js +354 -0
  210. package/server/compute-node.js +439 -0
  211. package/server/constants/config.js +5 -0
  212. package/server/cursor-cli.js +340 -0
  213. package/server/database/auth.db +0 -0
  214. package/server/database/db.js +1852 -0
  215. package/server/database/init.sql +194 -0
  216. package/server/gemini-cli.js +1352 -0
  217. package/server/index.js +3154 -0
  218. package/server/load-env.js +71 -0
  219. package/server/local-gpu.js +860 -0
  220. package/server/middleware/auth.js +100 -0
  221. package/server/openai-codex.js +724 -0
  222. package/server/openrouter.js +890 -0
  223. package/server/project-token-usage.js +407 -0
  224. package/server/projects.js +3973 -0
  225. package/server/routes/agent.js +1272 -0
  226. package/server/routes/auth.js +128 -0
  227. package/server/routes/auto-research.js +558 -0
  228. package/server/routes/cli-auth.js +958 -0
  229. package/server/routes/codex.js +344 -0
  230. package/server/routes/commands.js +601 -0
  231. package/server/routes/compute.js +358 -0
  232. package/server/routes/cursor.js +795 -0
  233. package/server/routes/git.js +1348 -0
  234. package/server/routes/mcp-utils.js +48 -0
  235. package/server/routes/mcp.js +618 -0
  236. package/server/routes/news.js +711 -0
  237. package/server/routes/projects.js +633 -0
  238. package/server/routes/references.js +338 -0
  239. package/server/routes/settings.js +285 -0
  240. package/server/routes/skills.js +914 -0
  241. package/server/routes/taskmaster.js +2566 -0
  242. package/server/routes/telemetry.js +89 -0
  243. package/server/routes/user.js +155 -0
  244. package/server/scripts/fix_session_names.js +57 -0
  245. package/server/scripts/research-news/common_words.py +19 -0
  246. package/server/scripts/research-news/link_keywords.py +298 -0
  247. package/server/scripts/research-news/scan_existing_notes.py +266 -0
  248. package/server/scripts/research-news/scoring_utils.py +227 -0
  249. package/server/scripts/research-news/search_arxiv.py +842 -0
  250. package/server/scripts/research-news/search_huggingface.py +438 -0
  251. package/server/scripts/research-news/search_x.py +490 -0
  252. package/server/scripts/research-news/search_xiaohongshu.py +610 -0
  253. package/server/taskmaster-templates/README.md +150 -0
  254. package/server/taskmaster-templates/README.zh-CN.md +150 -0
  255. package/server/taskmaster-templates/ai-research-dataset.json +230 -0
  256. package/server/taskmaster-templates/ai-research-method-model.json +230 -0
  257. package/server/taskmaster-templates/ai-research-position-paper.json +230 -0
  258. package/server/telemetry.js +121 -0
  259. package/server/templates/AGENTS.md +103 -0
  260. package/server/templates/CLAUDE.md +104 -0
  261. package/server/templates/GEMINI.md +103 -0
  262. package/server/templates/cursor-project.md +108 -0
  263. package/server/templates/index.js +37 -0
  264. package/server/utils/__tests__/sessionTokenUsage.test.js +82 -0
  265. package/server/utils/auto-research-mailer.js +57 -0
  266. package/server/utils/cliResolution.js +170 -0
  267. package/server/utils/commandParser.js +303 -0
  268. package/server/utils/cursorCommand.js +61 -0
  269. package/server/utils/geminiApiKey.js +28 -0
  270. package/server/utils/gitConfig.js +24 -0
  271. package/server/utils/imageAttachmentFiles.js +35 -0
  272. package/server/utils/mcp-detector.js +198 -0
  273. package/server/utils/parsers/bibtex-parser.js +207 -0
  274. package/server/utils/permissions.js +100 -0
  275. package/server/utils/runtimePorts.js +212 -0
  276. package/server/utils/sessionFormatting.js +68 -0
  277. package/server/utils/sessionIndex.js +100 -0
  278. package/server/utils/sessionMode.js +95 -0
  279. package/server/utils/sessionTokenUsage.js +105 -0
  280. package/server/utils/taskmaster-websocket.js +129 -0
  281. package/server/utils/zotero-client.js +136 -0
  282. package/shared/errorClassifier.js +102 -0
  283. package/shared/geminiThinkingSupport.d.ts +22 -0
  284. package/shared/geminiThinkingSupport.js +96 -0
  285. package/shared/geminiThoughtParser.d.ts +8 -0
  286. package/shared/geminiThoughtParser.js +30 -0
  287. package/shared/modelConstants.js +139 -0
@@ -0,0 +1,490 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ X (Twitter) research tweet search script.
4
+
5
+ Searches recent tweets matching research-related queries using the
6
+ twitter-cli tool (https://github.com/trevorhobenshield/twitter-cli),
7
+ which handles authentication via browser cookies automatically.
8
+
9
+ Scores results using the shared scoring_utils module, producing output
10
+ compatible with the other search scripts (search_arxiv.py, etc.).
11
+ """
12
+
13
+ import argparse
14
+ import json
15
+ import logging
16
+ import os
17
+ import re
18
+ import shutil
19
+ import subprocess
20
+ import sys
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+ from typing import Dict, List, Optional
24
+
25
+ # scoring_utils lives in the same directory
26
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
27
+
28
+ from scoring_utils import (
29
+ SCORE_MAX,
30
+ calculate_relevance_score,
31
+ calculate_recency_score,
32
+ calculate_quality_score,
33
+ calculate_recommendation_score,
34
+ )
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ # Popularity normalisation: 1000 total engagements == SCORE_MAX
39
+ POPULARITY_ENGAGEMENT_FULL_SCORE = 1000
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Config loading
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def load_research_config(config_path: str) -> Dict:
47
+ """Load research interest configuration from a YAML file."""
48
+ import json
49
+
50
+ try:
51
+ with open(config_path, "r", encoding="utf-8-sig") as f:
52
+ if config_path.endswith(".json"):
53
+ config = json.load(f)
54
+ else:
55
+ try:
56
+ import yaml
57
+ config = yaml.safe_load(f)
58
+ except ImportError:
59
+ config = json.load(f)
60
+ return config
61
+ except Exception as e:
62
+ logger.error("Error loading config: %s", e)
63
+ return {
64
+ "research_domains": {
65
+ "LLM": {
66
+ "keywords": ["large language model", "LLM"],
67
+ "priority": 5,
68
+ }
69
+ },
70
+ "excluded_keywords": [],
71
+ }
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # twitter-cli helpers
76
+ # ---------------------------------------------------------------------------
77
+
78
+ def _check_twitter_cli() -> str:
79
+ """Return the path to the twitter CLI, or raise if not found."""
80
+ path = shutil.which("twitter")
81
+ if not path:
82
+ raise RuntimeError(
83
+ "twitter-cli is not installed. "
84
+ "Run 'npm install' or install manually: uv tool install twitter-cli"
85
+ )
86
+ return path
87
+
88
+
89
+ def search_via_twitter_cli(query: str) -> List[Dict]:
90
+ """
91
+ Search tweets using twitter-cli.
92
+
93
+ Returns a list of raw tweet dicts from the CLI JSON output.
94
+ """
95
+ twitter_bin = shutil.which("twitter") or "twitter"
96
+ cmd = [twitter_bin, "search", query, "--json"]
97
+ logger.info("[X] Running: %s", " ".join(cmd))
98
+
99
+ # Strip __PYVENV_LAUNCHER__ so uv-installed CLIs find the right stdlib
100
+ env = os.environ.copy()
101
+ env.pop("__PYVENV_LAUNCHER__", None)
102
+
103
+ result = subprocess.run(cmd, capture_output=True, timeout=120, env=env)
104
+
105
+ if result.returncode != 0:
106
+ logger.error("[X] CLI exited %d: %s", result.returncode, result.stderr.decode(errors="replace"))
107
+ return []
108
+
109
+ raw = result.stdout
110
+ if not raw.strip():
111
+ logger.error("[X] CLI returned empty stdout")
112
+ return []
113
+
114
+ # Decode and strip ANSI escape sequences (the CLI colorizes JSON output)
115
+ stdout = raw.decode("utf-8", errors="replace")
116
+ stdout = re.sub(r"\x1b\[[0-9;]*m", "", stdout)
117
+
118
+ try:
119
+ envelope = json.loads(stdout)
120
+ except json.JSONDecodeError as e:
121
+ logger.error("[X] Failed to parse CLI output: %s", e)
122
+ logger.error("[X] cleaned stdout (first 200): %s", stdout[:200])
123
+ return []
124
+
125
+ if not envelope.get("ok"):
126
+ err = envelope.get("error", {})
127
+ msg = err.get("message", "") if isinstance(err, dict) else str(err)
128
+ logger.error("[X] CLI returned error: %s", msg)
129
+ return []
130
+
131
+ tweets = envelope.get("data", [])
132
+ logger.info("[X] Query '%s': got %d tweets", query[:40], len(tweets))
133
+ return tweets
134
+
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Scoring helpers
138
+ # ---------------------------------------------------------------------------
139
+
140
+ def _tweet_engagement(tweet: Dict) -> Dict[str, int]:
141
+ """Extract engagement metrics from a tweet.
142
+
143
+ Supports both twitter-cli format (metrics.*) and X API v2 format
144
+ (public_metrics.*_count) for backward compatibility.
145
+ """
146
+ metrics = tweet.get("metrics", {})
147
+ pm = tweet.get("public_metrics", {})
148
+ return {
149
+ "likes": metrics.get("likes", pm.get("like_count", 0)),
150
+ "retweets": metrics.get("retweets", pm.get("retweet_count", 0)),
151
+ "replies": metrics.get("replies", pm.get("reply_count", 0)),
152
+ "impressions": metrics.get("views", pm.get("impression_count", 0)),
153
+ }
154
+
155
+
156
+ def _calculate_popularity_score(engagement: Dict[str, int]) -> float:
157
+ """
158
+ Popularity score based on total engagement.
159
+
160
+ 1000 engagements = SCORE_MAX (3.0).
161
+ """
162
+ total = engagement["likes"] + engagement["retweets"] + engagement["replies"]
163
+ return min(total / (POPULARITY_ENGAGEMENT_FULL_SCORE / SCORE_MAX), SCORE_MAX)
164
+
165
+
166
+ def _calculate_tweet_quality_score(tweet: Dict) -> float:
167
+ """
168
+ Quality score for a tweet.
169
+
170
+ Bonus for:
171
+ - containing URLs (especially arxiv / paper links)
172
+ - thread indicators (e.g. "thread", "1/")
173
+ Also delegates to the generic calculate_quality_score for text analysis.
174
+ """
175
+ text = tweet.get("text", "")
176
+ text_lower = text.lower()
177
+
178
+ # Start with the generic text-quality score from scoring_utils
179
+ score = calculate_quality_score(text)
180
+
181
+ # Bonus for URLs — twitter-cli puts urls as a top-level list of strings,
182
+ # X API v2 nests them under entities.urls as objects with expanded_url.
183
+ urls = tweet.get("urls", [])
184
+ entities = tweet.get("entities", {})
185
+ entity_urls = entities.get("urls", [])
186
+ all_urls = urls + [u.get("expanded_url", "") for u in entity_urls]
187
+ if all_urls:
188
+ score += 0.3
189
+ # Extra bonus for academic / paper links
190
+ for url_str in all_urls:
191
+ expanded = (url_str if isinstance(url_str, str) else "").lower()
192
+ if any(domain in expanded for domain in [
193
+ "arxiv.org", "openreview.net", "semanticscholar.org",
194
+ "aclanthology.org", "papers.nips.cc", "proceedings.mlr.press",
195
+ "huggingface.co/papers",
196
+ ]):
197
+ score += 0.5
198
+ break
199
+
200
+ # Bonus for thread indicators
201
+ if any(ind in text_lower for ind in ["thread", "🧵", "1/"]):
202
+ score += 0.2
203
+
204
+ return min(score, SCORE_MAX)
205
+
206
+
207
+ def _parse_tweet_datetime(created_at: str) -> Optional[datetime]:
208
+ """Parse the ISO-8601 created_at string from X API."""
209
+ if not created_at:
210
+ return None
211
+ try:
212
+ # X returns e.g. "2024-01-15T12:34:56.000Z"
213
+ return datetime.fromisoformat(created_at.replace("Z", "+00:00"))
214
+ except (ValueError, TypeError):
215
+ return None
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Core pipeline
220
+ # ---------------------------------------------------------------------------
221
+
222
+ def build_queries(
223
+ config: Dict,
224
+ extra_queries: Optional[List[str]] = None,
225
+ accounts: Optional[List[str]] = None,
226
+ ) -> List[str]:
227
+ """
228
+ Build a list of X search queries from config domains, extra queries, and
229
+ tracked accounts.
230
+ """
231
+ queries: List[str] = []
232
+
233
+ # From research_domains config
234
+ domains = config.get("research_domains", {})
235
+ for domain_name, domain_config in domains.items():
236
+ keywords = domain_config.get("keywords", [])
237
+ for kw in keywords:
238
+ # Wrap multi-word keywords in quotes for exact matching
239
+ if " " in kw:
240
+ queries.append(f'"{kw}"')
241
+ else:
242
+ queries.append(kw)
243
+
244
+ # From --queries CLI arg
245
+ if extra_queries:
246
+ for q in extra_queries:
247
+ q = q.strip()
248
+ if q:
249
+ queries.append(q)
250
+
251
+ # From --accounts CLI arg: "from:handle" searches
252
+ if accounts:
253
+ for handle in accounts:
254
+ handle = handle.strip().lstrip("@")
255
+ if handle:
256
+ queries.append(f"from:{handle}")
257
+
258
+ # Deduplicate while preserving order
259
+ seen = set()
260
+ unique: List[str] = []
261
+ for q in queries:
262
+ q_lower = q.lower()
263
+ if q_lower not in seen:
264
+ seen.add(q_lower)
265
+ unique.append(q)
266
+ return unique
267
+
268
+
269
+ def score_tweets(
270
+ tweets: List[Dict],
271
+ config: Dict,
272
+ ) -> List[Dict]:
273
+ """
274
+ Score and normalise a list of raw tweets from twitter-cli.
275
+
276
+ Returns a list of scored item dicts ready for output.
277
+ """
278
+ domains = config.get("research_domains", {})
279
+ excluded_keywords = config.get("excluded_keywords", [])
280
+ scored: List[Dict] = []
281
+
282
+ for tweet in tweets:
283
+ tweet_id = tweet.get("id", "")
284
+ text = tweet.get("text", "")
285
+ created_at = tweet.get("createdAt", tweet.get("created_at", ""))
286
+
287
+ # twitter-cli nests author info under "author"
288
+ author = tweet.get("author", {})
289
+ username = author.get("screenName", author.get("username", "unknown"))
290
+ display_name = author.get("name", username)
291
+ avatar_url = author.get("profileImageUrl", author.get("profile_image_url", ""))
292
+
293
+ # Build a paper-like dict so calculate_relevance_score works
294
+ paper_proxy = {
295
+ "title": text[:100],
296
+ "summary": text,
297
+ "categories": [],
298
+ }
299
+
300
+ # -- Relevance --
301
+ relevance, matched_domain, matched_keywords = calculate_relevance_score(
302
+ paper_proxy, domains, excluded_keywords
303
+ )
304
+ # Even if relevance == 0 we still keep the tweet (it matched the
305
+ # search query itself), but give it a floor relevance of 0.5
306
+ if relevance == 0:
307
+ relevance = 0.5
308
+ matched_domain = matched_domain or "general"
309
+ matched_keywords = matched_keywords or []
310
+
311
+ # -- Recency --
312
+ published_dt = _parse_tweet_datetime(created_at)
313
+ recency = calculate_recency_score(published_dt)
314
+
315
+ # -- Popularity --
316
+ engagement = _tweet_engagement(tweet)
317
+ popularity = _calculate_popularity_score(engagement)
318
+
319
+ # -- Quality --
320
+ quality = _calculate_tweet_quality_score(tweet)
321
+
322
+ # -- Final score --
323
+ final_score = calculate_recommendation_score(
324
+ relevance, recency, popularity, quality, is_hot_paper=False
325
+ )
326
+
327
+ # Extract media URLs (photos only — video URLs return 403)
328
+ media_urls = [
329
+ m.get("url", "")
330
+ for m in tweet.get("media", [])
331
+ if m.get("url") and m.get("type") == "photo"
332
+ ]
333
+
334
+ item = {
335
+ "id": tweet_id,
336
+ "title": text[:100],
337
+ "authors": f"@{username} ({display_name})",
338
+ "abstract": text,
339
+ "published": created_at,
340
+ "categories": [],
341
+ "relevance_score": round(relevance, 2),
342
+ "recency_score": round(recency, 2),
343
+ "popularity_score": round(popularity, 2),
344
+ "quality_score": round(quality, 2),
345
+ "final_score": final_score,
346
+ "matched_domain": matched_domain,
347
+ "matched_keywords": matched_keywords,
348
+ "link": f"https://x.com/{username}/status/{tweet_id}",
349
+ "source": "x",
350
+ "engagement": engagement,
351
+ "avatar_url": avatar_url,
352
+ "media_urls": media_urls,
353
+ }
354
+ scored.append(item)
355
+
356
+ return scored
357
+
358
+
359
+ # ---------------------------------------------------------------------------
360
+ # Main
361
+ # ---------------------------------------------------------------------------
362
+
363
+ def main() -> int:
364
+ parser = argparse.ArgumentParser(
365
+ description="Fetch and score research-related tweets from X (Twitter) API v2"
366
+ )
367
+ parser.add_argument(
368
+ "--config", type=str, default=None,
369
+ help="Path to research interests YAML config file",
370
+ )
371
+ parser.add_argument(
372
+ "--output", type=str, default="x_filtered.json",
373
+ help="Output JSON file path",
374
+ )
375
+ parser.add_argument(
376
+ "--top-n", type=int, default=10,
377
+ help="Number of top tweets to return",
378
+ )
379
+ parser.add_argument(
380
+ "--queries", type=str, default=None,
381
+ help="Comma-separated search queries (e.g. 'LLM,GPT,AI agents')",
382
+ )
383
+ parser.add_argument(
384
+ "--accounts", type=str, default=None,
385
+ help="Comma-separated X handles to track (e.g. '_akhaliq,ylaboratory')",
386
+ )
387
+
388
+ args = parser.parse_args()
389
+
390
+ logging.basicConfig(
391
+ level=logging.INFO,
392
+ format="%(asctime)s [%(levelname)s] %(message)s",
393
+ datefmt="%H:%M:%S",
394
+ stream=sys.stderr,
395
+ )
396
+
397
+ # --- Check twitter-cli is installed ---
398
+ try:
399
+ _check_twitter_cli()
400
+ except RuntimeError as e:
401
+ print(f"ERROR: {e}", file=sys.stderr)
402
+ return 1
403
+
404
+ # --- Load config ---
405
+ if args.config:
406
+ config = load_research_config(args.config)
407
+ else:
408
+ config = {
409
+ "research_domains": {},
410
+ "excluded_keywords": [],
411
+ }
412
+
413
+ # --- Build queries ---
414
+ extra_queries = (
415
+ [q.strip() for q in args.queries.split(",") if q.strip()]
416
+ if args.queries
417
+ else None
418
+ )
419
+ accounts = (
420
+ [a.strip() for a in args.accounts.split(",") if a.strip()]
421
+ if args.accounts
422
+ else None
423
+ )
424
+ queries = build_queries(config, extra_queries, accounts)
425
+
426
+ if not queries:
427
+ logger.error(
428
+ "No search queries. Provide --config, --queries, or --accounts."
429
+ )
430
+ return 1
431
+
432
+ logger.info("Will search %d queries: %s", len(queries), queries)
433
+
434
+ # --- Fetch tweets ---
435
+ all_tweets: List[Dict] = []
436
+ seen_tweet_ids: set = set()
437
+
438
+ for query in queries:
439
+ tweets = search_via_twitter_cli(query)
440
+ for t in tweets:
441
+ tid = t.get("id")
442
+ if tid and tid not in seen_tweet_ids:
443
+ seen_tweet_ids.add(tid)
444
+ all_tweets.append(t)
445
+
446
+ total_found = len(all_tweets)
447
+ logger.info("Total unique tweets fetched: %d", total_found)
448
+
449
+ if total_found == 0:
450
+ logger.warning("No tweets found for any query.")
451
+
452
+ # --- Score ---
453
+ scored_items = score_tweets(all_tweets, config)
454
+
455
+ # Sort by final_score descending
456
+ scored_items.sort(key=lambda x: x["final_score"], reverse=True)
457
+
458
+ top_items = scored_items[: args.top_n]
459
+
460
+ # --- Output ---
461
+ output = {
462
+ "top_papers": top_items,
463
+ "total_found": total_found,
464
+ "total_filtered": len(scored_items),
465
+ "search_date": datetime.now().strftime("%Y-%m-%d"),
466
+ }
467
+
468
+ # Write to file
469
+ output_path = Path(args.output)
470
+ output_path.parent.mkdir(parents=True, exist_ok=True)
471
+ with open(output_path, "w", encoding="utf-8") as f:
472
+ json.dump(output, f, ensure_ascii=False, indent=2)
473
+
474
+ logger.info("Results saved to: %s", args.output)
475
+ for i, item in enumerate(top_items, 1):
476
+ logger.info(
477
+ " %d. @%s — %s... (score: %.2f)",
478
+ i,
479
+ item["authors"].split(" ")[0],
480
+ item["title"][:50],
481
+ item["final_score"],
482
+ )
483
+
484
+ # Also write to stdout for piping
485
+ print(json.dumps(output, ensure_ascii=True, indent=2))
486
+ return 0
487
+
488
+
489
+ if __name__ == "__main__":
490
+ sys.exit(main())